BREAKING CHANGE(core): Add multi-UPS support and group management; update CLI, configuration and documentation to support multiple UPS devices with group modes

This commit is contained in:
2025-03-28 16:19:43 +00:00
parent bd3042de25
commit 0e55f22dad
10 changed files with 2381 additions and 780 deletions

View File

@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/nupst',
version: '2.6.17',
version: '3.0.0',
description: 'Node.js UPS Shutdown Tool for SNMP-enabled UPS devices'
}

2145
ts/cli.ts

File diff suppressed because it is too large Load Diff

View File

@ -10,9 +10,13 @@ const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);
/**
* Configuration interface for the daemon
* UPS configuration interface
*/
export interface INupstConfig {
export interface IUpsConfig {
/** Unique ID for the UPS */
id: string;
/** Friendly name for the UPS */
name: string;
/** SNMP configuration settings */
snmp: ISnmpConfig;
/** Threshold settings for initiating shutdown */
@ -22,8 +26,58 @@ export interface INupstConfig {
/** Shutdown when runtime below this minutes */
runtime: number;
};
/** Group IDs this UPS belongs to */
groups: string[];
}
/**
* Group configuration interface
*/
export interface IGroupConfig {
/** Unique ID for the group */
id: string;
/** Friendly name for the group */
name: string;
/** Group operation mode */
mode: 'redundant' | 'nonRedundant';
/** Optional description */
description?: string;
}
/**
* Configuration interface for the daemon
*/
export interface INupstConfig {
/** UPS devices configuration */
upsDevices: IUpsConfig[];
/** Groups configuration */
groups: IGroupConfig[];
/** Check interval in milliseconds */
checkInterval: number;
// Legacy fields for backward compatibility
/** SNMP configuration settings (legacy) */
snmp?: ISnmpConfig;
/** Threshold settings (legacy) */
thresholds?: {
/** Shutdown when battery below this percentage */
battery: number;
/** Shutdown when runtime below this minutes */
runtime: number;
};
}
/**
* UPS status tracking interface
*/
interface IUpsStatus {
id: string;
name: string;
powerStatus: 'online' | 'onBattery' | 'unknown';
batteryCapacity: number;
batteryRuntime: number;
lastStatusChange: number;
lastCheckTime: number;
}
/**
@ -36,32 +90,41 @@ export class NupstDaemon {
/** Default configuration */
private readonly DEFAULT_CONFIG: INupstConfig = {
snmp: {
host: '127.0.0.1',
port: 161,
community: 'public',
version: 1,
timeout: 5000,
// SNMPv3 defaults (used only if version === 3)
securityLevel: 'authPriv',
username: '',
authProtocol: 'SHA',
authKey: '',
privProtocol: 'AES',
privKey: '',
// UPS model for OID selection
upsModel: 'cyberpower'
},
thresholds: {
battery: 60, // Shutdown when battery below 60%
runtime: 20, // Shutdown when runtime below 20 minutes
},
upsDevices: [
{
id: 'default',
name: 'Default UPS',
snmp: {
host: '127.0.0.1',
port: 161,
community: 'public',
version: 1,
timeout: 5000,
// SNMPv3 defaults (used only if version === 3)
securityLevel: 'authPriv',
username: '',
authProtocol: 'SHA',
authKey: '',
privProtocol: 'AES',
privKey: '',
// UPS model for OID selection
upsModel: 'cyberpower'
},
thresholds: {
battery: 60, // Shutdown when battery below 60%
runtime: 20, // Shutdown when runtime below 20 minutes
},
groups: []
}
],
groups: [],
checkInterval: 30000, // Check every 30 seconds
};
private config: INupstConfig;
private snmp: NupstSnmp;
private isRunning: boolean = false;
private upsStatus: Map<string, IUpsStatus> = new Map();
/**
* Create a new daemon instance with the given SNMP manager
@ -87,10 +150,36 @@ export class NupstDaemon {
// Read and parse config
const configData = fs.readFileSync(this.CONFIG_PATH, 'utf8');
this.config = JSON.parse(configData);
const parsedConfig = JSON.parse(configData);
// Handle legacy configuration format
if (!parsedConfig.upsDevices && parsedConfig.snmp) {
// Convert legacy format to new format
this.config = {
upsDevices: [
{
id: 'default',
name: 'Default UPS',
snmp: parsedConfig.snmp,
thresholds: parsedConfig.thresholds,
groups: []
}
],
groups: [],
checkInterval: parsedConfig.checkInterval
};
logger.log('Legacy configuration format detected. Converting to multi-UPS format.');
// Save the new format
await this.saveConfig(this.config);
} else {
this.config = parsedConfig;
}
return this.config;
} catch (error) {
if (error.message.includes('No configuration found')) {
if (error.message && error.message.includes('No configuration found')) {
throw error; // Re-throw the no configuration error
}
@ -175,6 +264,9 @@ export class NupstDaemon {
}
}).catch(() => {}); // Ignore errors checking for updates
// Initialize UPS status tracking
this.initializeUpsStatus();
// Start UPS monitoring
this.isRunning = true;
await this.monitor();
@ -185,19 +277,56 @@ export class NupstDaemon {
}
}
/**
* Initialize UPS status tracking for all UPS devices
*/
private initializeUpsStatus(): void {
this.upsStatus.clear();
if (this.config.upsDevices && this.config.upsDevices.length > 0) {
for (const ups of this.config.upsDevices) {
this.upsStatus.set(ups.id, {
id: ups.id,
name: ups.name,
powerStatus: 'unknown',
batteryCapacity: 100,
batteryRuntime: 999, // High value as default
lastStatusChange: Date.now(),
lastCheckTime: 0
});
}
logger.log(`Initialized status tracking for ${this.config.upsDevices.length} UPS devices`);
} else {
logger.error('No UPS devices found in configuration');
}
}
/**
* Log the loaded configuration settings
*/
private logConfigLoaded(): void {
const boxWidth = 50;
logger.logBoxTitle('Configuration Loaded', boxWidth);
logger.logBoxLine('SNMP Settings:');
logger.logBoxLine(` Host: ${this.config.snmp.host}`);
logger.logBoxLine(` Port: ${this.config.snmp.port}`);
logger.logBoxLine(` Version: ${this.config.snmp.version}`);
logger.logBoxLine('Thresholds:');
logger.logBoxLine(` Battery: ${this.config.thresholds.battery}%`);
logger.logBoxLine(` Runtime: ${this.config.thresholds.runtime} minutes`);
if (this.config.upsDevices && this.config.upsDevices.length > 0) {
logger.logBoxLine(`UPS Devices: ${this.config.upsDevices.length}`);
for (const ups of this.config.upsDevices) {
logger.logBoxLine(` - ${ups.name} (${ups.id}): ${ups.snmp.host}:${ups.snmp.port}`);
}
} else {
logger.logBoxLine('No UPS devices configured');
}
if (this.config.groups && this.config.groups.length > 0) {
logger.logBoxLine(`Groups: ${this.config.groups.length}`);
for (const group of this.config.groups) {
logger.logBoxLine(` - ${group.name} (${group.id}): ${group.mode} mode`);
}
} else {
logger.logBoxLine('No Groups configured');
}
logger.logBoxLine(`Check Interval: ${this.config.checkInterval / 1000} seconds`);
logger.logBoxEnd();
}
@ -216,81 +345,239 @@ export class NupstDaemon {
private async monitor(): Promise<void> {
logger.log('Starting UPS monitoring...');
let lastStatus: 'online' | 'onBattery' | 'unknown' = 'unknown';
if (!this.config.upsDevices || this.config.upsDevices.length === 0) {
logger.error('No UPS devices found in configuration. Monitoring stopped.');
this.isRunning = false;
return;
}
let lastLogTime = 0; // Track when we last logged status
const LOG_INTERVAL = 5 * 60 * 1000; // Log at least every 5 minutes (300000ms)
// Monitor continuously
while (this.isRunning) {
try {
const status = await this.snmp.getUpsStatus(this.config.snmp);
const currentTime = Date.now();
const shouldLogStatus = (currentTime - lastLogTime) >= LOG_INTERVAL;
// Check all UPS devices
await this.checkAllUpsDevices();
// Log status changes
if (status.powerStatus !== lastStatus) {
const statusBoxWidth = 45;
logger.logBoxTitle('Power Status Change', statusBoxWidth);
logger.logBoxLine(`Status changed: ${lastStatus}${status.powerStatus}`);
logger.logBoxEnd();
lastStatus = status.powerStatus;
lastLogTime = currentTime; // Reset log timer when status changes
}
// Log status periodically (at least every 5 minutes)
else if (shouldLogStatus) {
const timestamp = new Date().toISOString();
const periodicBoxWidth = 45;
logger.logBoxTitle('Periodic Status Update', periodicBoxWidth);
logger.logBoxLine(`Timestamp: ${timestamp}`);
logger.logBoxLine(`Power Status: ${status.powerStatus}`);
logger.logBoxLine(`Battery: ${status.batteryCapacity}% | Runtime: ${status.batteryRuntime} min`);
logger.logBoxEnd();
// Log periodic status update
const currentTime = Date.now();
if (currentTime - lastLogTime >= LOG_INTERVAL) {
this.logAllUpsStatus();
lastLogTime = currentTime;
}
// Handle battery power status
if (status.powerStatus === 'onBattery') {
await this.handleOnBatteryStatus(status);
}
// Check if shutdown is required based on group configurations
await this.evaluateGroupShutdownConditions();
// Wait before next check
await this.sleep(this.config.checkInterval);
} catch (error) {
console.error('Error during UPS monitoring:', error);
logger.error(`Error during UPS monitoring: ${error.message}`);
await this.sleep(this.config.checkInterval);
}
}
console.log('UPS monitoring stopped');
logger.log('UPS monitoring stopped');
}
/**
* Handle UPS status when running on battery
* Check status of all UPS devices
*/
private async handleOnBatteryStatus(status: {
powerStatus: string,
batteryCapacity: number,
batteryRuntime: number
}): Promise<void> {
console.log('┌─ UPS Status ─────────────────────────────┐');
console.log(`│ Battery: ${status.batteryCapacity}% | Runtime: ${status.batteryRuntime} min`);
console.log('└──────────────────────────────────────────┘');
private async checkAllUpsDevices(): Promise<void> {
for (const ups of this.config.upsDevices) {
try {
const upsStatus = this.upsStatus.get(ups.id);
if (!upsStatus) {
// Initialize status for this UPS if not exists
this.upsStatus.set(ups.id, {
id: ups.id,
name: ups.name,
powerStatus: 'unknown',
batteryCapacity: 100,
batteryRuntime: 999,
lastStatusChange: Date.now(),
lastCheckTime: 0
});
}
// Check UPS status
const status = await this.snmp.getUpsStatus(ups.snmp);
const currentTime = Date.now();
// Get the current status from the map
const currentStatus = this.upsStatus.get(ups.id);
// Update status with new values
const updatedStatus = {
...currentStatus,
powerStatus: status.powerStatus,
batteryCapacity: status.batteryCapacity,
batteryRuntime: status.batteryRuntime,
lastCheckTime: currentTime
};
// Check if power status changed
if (currentStatus.powerStatus !== status.powerStatus) {
logger.logBoxTitle(`Power Status Change: ${ups.name}`, 50);
logger.logBoxLine(`Status changed: ${currentStatus.powerStatus}${status.powerStatus}`);
logger.logBoxEnd();
updatedStatus.lastStatusChange = currentTime;
}
// Update the status in the map
this.upsStatus.set(ups.id, updatedStatus);
} catch (error) {
logger.error(`Error checking UPS ${ups.name} (${ups.id}): ${error.message}`);
}
}
}
/**
* Log status of all UPS devices
*/
private logAllUpsStatus(): void {
const timestamp = new Date().toISOString();
const boxWidth = 60;
logger.logBoxTitle('Periodic Status Update', boxWidth);
logger.logBoxLine(`Timestamp: ${timestamp}`);
logger.logBoxLine('');
// Check battery threshold
if (status.batteryCapacity < this.config.thresholds.battery) {
console.log('⚠️ WARNING: Battery capacity below threshold');
console.log(`Current: ${status.batteryCapacity}% | Threshold: ${this.config.thresholds.battery}%`);
await this.initiateShutdown('Battery capacity below threshold');
for (const [id, status] of this.upsStatus.entries()) {
logger.logBoxLine(`UPS: ${status.name} (${id})`);
logger.logBoxLine(` Power Status: ${status.powerStatus}`);
logger.logBoxLine(` Battery: ${status.batteryCapacity}% | Runtime: ${status.batteryRuntime} min`);
logger.logBoxLine('');
}
logger.logBoxEnd();
}
/**
* Evaluate if shutdown is required based on group configurations
*/
private async evaluateGroupShutdownConditions(): Promise<void> {
if (!this.config.groups || this.config.groups.length === 0) {
// No groups defined, check individual UPS conditions
for (const [id, status] of this.upsStatus.entries()) {
if (status.powerStatus === 'onBattery') {
// Find the UPS config
const ups = this.config.upsDevices.find(u => u.id === id);
if (ups) {
await this.evaluateUpsShutdownCondition(ups, status);
}
}
}
return;
}
// Check runtime threshold
if (status.batteryRuntime < this.config.thresholds.runtime) {
console.log('⚠️ WARNING: Runtime below threshold');
console.log(`Current: ${status.batteryRuntime} min | Threshold: ${this.config.thresholds.runtime} min`);
await this.initiateShutdown('Runtime below threshold');
// Evaluate each group
for (const group of this.config.groups) {
// Find all UPS devices in this group
const upsDevicesInGroup = this.config.upsDevices.filter(ups =>
ups.groups && ups.groups.includes(group.id)
);
if (upsDevicesInGroup.length === 0) {
// No UPS devices in this group
continue;
}
if (group.mode === 'redundant') {
// Redundant mode: only shutdown if ALL UPS devices in the group are in critical condition
await this.evaluateRedundantGroup(group, upsDevicesInGroup);
} else {
// Non-redundant mode: shutdown if ANY UPS device in the group is in critical condition
await this.evaluateNonRedundantGroup(group, upsDevicesInGroup);
}
}
}
/**
* Evaluate a redundant group for shutdown conditions
* In redundant mode, we only shut down if ALL UPS devices are in critical condition
*/
private async evaluateRedundantGroup(group: IGroupConfig, upsDevices: IUpsConfig[]): Promise<void> {
// Count UPS devices on battery and in critical condition
let upsOnBattery = 0;
let upsInCriticalCondition = 0;
for (const ups of upsDevices) {
const status = this.upsStatus.get(ups.id);
if (!status) continue;
if (status.powerStatus === 'onBattery') {
upsOnBattery++;
// Check if this UPS is in critical condition
if (status.batteryCapacity < ups.thresholds.battery ||
status.batteryRuntime < ups.thresholds.runtime) {
upsInCriticalCondition++;
}
}
}
// All UPS devices must be online for a redundant group to be considered healthy
const allUpsCount = upsDevices.length;
// If all UPS are on battery and in critical condition, shutdown
if (upsOnBattery === allUpsCount && upsInCriticalCondition === allUpsCount) {
logger.logBoxTitle(`Group Shutdown Required: ${group.name}`, 50);
logger.logBoxLine(`Mode: Redundant`);
logger.logBoxLine(`All ${allUpsCount} UPS devices in critical condition`);
logger.logBoxEnd();
await this.initiateShutdown(`All UPS devices in redundant group "${group.name}" in critical condition`);
}
}
/**
* Evaluate a non-redundant group for shutdown conditions
* In non-redundant mode, we shut down if ANY UPS device is in critical condition
*/
private async evaluateNonRedundantGroup(group: IGroupConfig, upsDevices: IUpsConfig[]): Promise<void> {
for (const ups of upsDevices) {
const status = this.upsStatus.get(ups.id);
if (!status) continue;
if (status.powerStatus === 'onBattery') {
// Check if this UPS is in critical condition
if (status.batteryCapacity < ups.thresholds.battery ||
status.batteryRuntime < ups.thresholds.runtime) {
logger.logBoxTitle(`Group Shutdown Required: ${group.name}`, 50);
logger.logBoxLine(`Mode: Non-Redundant`);
logger.logBoxLine(`UPS ${ups.name} in critical condition`);
logger.logBoxLine(`Battery: ${status.batteryCapacity}% (threshold: ${ups.thresholds.battery}%)`);
logger.logBoxLine(`Runtime: ${status.batteryRuntime} min (threshold: ${ups.thresholds.runtime} min)`);
logger.logBoxEnd();
await this.initiateShutdown(`UPS "${ups.name}" in non-redundant group "${group.name}" in critical condition`);
return; // Exit after initiating shutdown
}
}
}
}
/**
* Evaluate an individual UPS for shutdown conditions
*/
private async evaluateUpsShutdownCondition(ups: IUpsConfig, status: IUpsStatus): Promise<void> {
// Only evaluate UPS devices not in any group
if (ups.groups && ups.groups.length > 0) {
return;
}
// Check threshold conditions
if (status.batteryCapacity < ups.thresholds.battery ||
status.batteryRuntime < ups.thresholds.runtime) {
logger.logBoxTitle(`UPS Shutdown Required: ${ups.name}`, 50);
logger.logBoxLine(`Battery: ${status.batteryCapacity}% (threshold: ${ups.thresholds.battery}%)`);
logger.logBoxLine(`Runtime: ${status.batteryRuntime} min (threshold: ${ups.thresholds.runtime} min)`);
logger.logBoxEnd();
await this.initiateShutdown(`UPS "${ups.name}" battery or runtime below threshold`);
}
}
/**
@ -404,7 +691,7 @@ export class NupstDaemon {
/**
* Monitor UPS during system shutdown
* Force immediate shutdown if battery gets critically low
* Force immediate shutdown if any UPS gets critically low
*/
private async monitorDuringShutdown(): Promise<void> {
const EMERGENCY_RUNTIME_THRESHOLD = 5; // 5 minutes remaining is critical
@ -412,112 +699,126 @@ export class NupstDaemon {
const MAX_MONITORING_TIME = 5 * 60 * 1000; // Max 5 minutes of monitoring
const startTime = Date.now();
console.log(`Emergency shutdown threshold: ${EMERGENCY_RUNTIME_THRESHOLD} minutes remaining battery runtime`);
logger.log(`Emergency shutdown threshold: ${EMERGENCY_RUNTIME_THRESHOLD} minutes remaining battery runtime`);
// Continue monitoring until max monitoring time is reached
while (Date.now() - startTime < MAX_MONITORING_TIME) {
try {
console.log('Checking UPS status during shutdown...');
const status = await this.snmp.getUpsStatus(this.config.snmp);
logger.log('Checking UPS status during shutdown...');
console.log(`Current battery: ${status.batteryCapacity}%, Runtime: ${status.batteryRuntime} minutes`);
// If battery runtime gets critically low, force immediate shutdown
if (status.batteryRuntime < EMERGENCY_RUNTIME_THRESHOLD) {
console.log('┌─ EMERGENCY SHUTDOWN ─────────────────────┐');
console.log(`│ Battery runtime critically low: ${status.batteryRuntime} minutes`);
console.log('│ Forcing immediate shutdown!');
console.log('└──────────────────────────────────────────┘');
// Check all UPS devices
for (const ups of this.config.upsDevices) {
try {
// Find shutdown command in common system paths
const shutdownPaths = [
'/sbin/shutdown',
'/usr/sbin/shutdown',
'/bin/shutdown',
'/usr/bin/shutdown'
];
const status = await this.snmp.getUpsStatus(ups.snmp);
let shutdownCmd = '';
for (const path of shutdownPaths) {
if (fs.existsSync(path)) {
shutdownCmd = path;
console.log(`Found shutdown command at: ${shutdownCmd}`);
break;
}
logger.log(`UPS ${ups.name}: Battery ${status.batteryCapacity}%, Runtime: ${status.batteryRuntime} minutes`);
// If any UPS battery runtime gets critically low, force immediate shutdown
if (status.batteryRuntime < EMERGENCY_RUNTIME_THRESHOLD) {
logger.logBoxTitle('EMERGENCY SHUTDOWN', 50);
logger.logBoxLine(`UPS ${ups.name} runtime critically low: ${status.batteryRuntime} minutes`);
logger.logBoxLine('Forcing immediate shutdown!');
logger.logBoxEnd();
// Force immediate shutdown
await this.forceImmediateShutdown();
return;
}
if (shutdownCmd) {
console.log(`Executing emergency shutdown: ${shutdownCmd} -h now`);
await execFileAsync(shutdownCmd, ['-h', 'now', 'EMERGENCY: UPS battery critically low, shutting down NOW']);
} else {
// Try using the PATH to find shutdown
console.log('Shutdown command not found in common paths, trying via PATH...');
await execAsync('shutdown -h now "EMERGENCY: UPS battery critically low, shutting down NOW"', {
env: process.env // Pass the current environment
});
}
} catch (error) {
console.error('Emergency shutdown failed, trying alternative methods...');
// Try alternative shutdown methods in sequence
const alternatives = [
{ cmd: 'poweroff', args: ['--force'] },
{ cmd: 'halt', args: ['-p'] },
{ cmd: 'systemctl', args: ['poweroff'] }
];
for (const alt of alternatives) {
try {
// Check common paths
const paths = [
`/sbin/${alt.cmd}`,
`/usr/sbin/${alt.cmd}`,
`/bin/${alt.cmd}`,
`/usr/bin/${alt.cmd}`
];
let cmdPath = '';
for (const path of paths) {
if (fs.existsSync(path)) {
cmdPath = path;
break;
}
}
if (cmdPath) {
console.log(`Emergency: using ${cmdPath} ${alt.args.join(' ')}`);
await execFileAsync(cmdPath, alt.args);
return; // Exit if successful
} else {
// Try using PATH
console.log(`Emergency: trying ${alt.cmd} via PATH`);
await execAsync(`${alt.cmd} ${alt.args.join(' ')}`, {
env: process.env
});
return; // Exit if successful
}
} catch (altError) {
// Continue to next method
}
}
console.error('All emergency shutdown methods failed');
} catch (upsError) {
logger.error(`Error checking UPS ${ups.name} during shutdown: ${upsError.message}`);
}
// Stop monitoring after initiating emergency shutdown
return;
}
// Wait before checking again
await this.sleep(CHECK_INTERVAL);
} catch (error) {
console.error('Error monitoring UPS during shutdown:', error);
logger.error(`Error monitoring UPS during shutdown: ${error.message}`);
await this.sleep(CHECK_INTERVAL);
}
}
console.log('UPS monitoring during shutdown completed');
logger.log('UPS monitoring during shutdown completed');
}
/**
* Force an immediate system shutdown
*/
private async forceImmediateShutdown(): Promise<void> {
try {
// Find shutdown command in common system paths
const shutdownPaths = [
'/sbin/shutdown',
'/usr/sbin/shutdown',
'/bin/shutdown',
'/usr/bin/shutdown'
];
let shutdownCmd = '';
for (const path of shutdownPaths) {
if (fs.existsSync(path)) {
shutdownCmd = path;
logger.log(`Found shutdown command at: ${shutdownCmd}`);
break;
}
}
if (shutdownCmd) {
logger.log(`Executing emergency shutdown: ${shutdownCmd} -h now`);
await execFileAsync(shutdownCmd, ['-h', 'now', 'EMERGENCY: UPS battery critically low, shutting down NOW']);
} else {
// Try using the PATH to find shutdown
logger.log('Shutdown command not found in common paths, trying via PATH...');
await execAsync('shutdown -h now "EMERGENCY: UPS battery critically low, shutting down NOW"', {
env: process.env // Pass the current environment
});
}
} catch (error) {
logger.error('Emergency shutdown failed, trying alternative methods...');
// Try alternative shutdown methods in sequence
const alternatives = [
{ cmd: 'poweroff', args: ['--force'] },
{ cmd: 'halt', args: ['-p'] },
{ cmd: 'systemctl', args: ['poweroff'] }
];
for (const alt of alternatives) {
try {
// Check common paths
const paths = [
`/sbin/${alt.cmd}`,
`/usr/sbin/${alt.cmd}`,
`/bin/${alt.cmd}`,
`/usr/bin/${alt.cmd}`
];
let cmdPath = '';
for (const path of paths) {
if (fs.existsSync(path)) {
cmdPath = path;
break;
}
}
if (cmdPath) {
logger.log(`Emergency: using ${cmdPath} ${alt.args.join(' ')}`);
await execFileAsync(cmdPath, alt.args);
return; // Exit if successful
} else {
// Try using PATH
logger.log(`Emergency: trying ${alt.cmd} via PATH`);
await execAsync(`${alt.cmd} ${alt.args.join(' ')}`, {
env: process.env
});
return; // Exit if successful
}
} catch (altError) {
// Continue to next method
}
}
logger.error('All emergency shutdown methods failed');
}
}
/**

1
ts/helpers/index.ts Normal file
View File

@ -0,0 +1 @@
export * from './shortid.js';

22
ts/helpers/shortid.ts Normal file
View File

@ -0,0 +1,22 @@
/**
* Generate a short unique ID of 6 alphanumeric characters
* @returns A 6-character alphanumeric string
*/
export function shortId(): string {
// Define the character set: a-z, A-Z, 0-9
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
// Generate cryptographically secure random values
const randomValues = new Uint8Array(6);
crypto.getRandomValues(randomValues);
// Map each random value to a character in our set
let result = '';
for (let i = 0; i < 6; i++) {
// Use modulo to map the random byte to a character index
const index = randomValues[i] % chars.length;
result += chars[index];
}
return result;
}

View File

@ -5,6 +5,9 @@
export class Logger {
private currentBoxWidth: number | null = null;
private static instance: Logger;
/** Default width to use when no width is specified */
private readonly DEFAULT_WIDTH = 60;
/**
* Creates a new Logger instance
@ -59,17 +62,17 @@ export class Logger {
/**
* Log a logbox title and set the current box width
* @param title Title of the logbox
* @param width Width of the logbox (including borders)
* @param width Width of the logbox (including borders), defaults to DEFAULT_WIDTH
*/
public logBoxTitle(title: string, width: number): void {
this.currentBoxWidth = width;
public logBoxTitle(title: string, width?: number): void {
this.currentBoxWidth = width || this.DEFAULT_WIDTH;
// Create the title line with appropriate padding
const paddedTitle = ` ${title} `;
const remainingSpace = width - 3 - paddedTitle.length;
const remainingSpace = this.currentBoxWidth - 3 - paddedTitle.length;
// Title line: ┌─ Title ───┐
const titleLine = `┌─${paddedTitle}${'─'.repeat(remainingSpace)}`;
const titleLine = `┌─${paddedTitle}${'─'.repeat(Math.max(0, remainingSpace))}`;
console.log(titleLine);
}
@ -77,15 +80,16 @@ export class Logger {
/**
* Log a logbox line
* @param content Content of the line
* @param width Optional width override. If not provided, uses the current box width.
* @param width Optional width override. If not provided, uses the current box width or DEFAULT_WIDTH.
*/
public logBoxLine(content: string, width?: number): void {
const boxWidth = width || this.currentBoxWidth;
if (!boxWidth) {
throw new Error('No box width specified and no previous box width to use');
if (!this.currentBoxWidth && !width) {
// No current width and no width provided, use default width
this.logBoxTitle('', this.DEFAULT_WIDTH);
}
const boxWidth = width || this.currentBoxWidth || this.DEFAULT_WIDTH;
// Calculate the available space for content
const availableSpace = boxWidth - 2; // Account for left and right borders
@ -101,27 +105,26 @@ export class Logger {
/**
* Log a logbox end
* @param width Optional width override. If not provided, uses the current box width.
* @param width Optional width override. If not provided, uses the current box width or DEFAULT_WIDTH.
*/
public logBoxEnd(width?: number): void {
const boxWidth = width || this.currentBoxWidth;
if (!boxWidth) {
throw new Error('No box width specified and no previous box width to use');
}
const boxWidth = width || this.currentBoxWidth || this.DEFAULT_WIDTH;
// Create the bottom border: └────────┘
console.log(`${'─'.repeat(boxWidth - 2)}`);
// Reset the current box width
this.currentBoxWidth = null;
}
/**
* Log a complete logbox with title, content lines, and ending
* @param title Title of the logbox
* @param lines Array of content lines
* @param width Width of the logbox
* @param width Width of the logbox, defaults to DEFAULT_WIDTH
*/
public logBox(title: string, lines: string[], width: number): void {
this.logBoxTitle(title, width);
public logBox(title: string, lines: string[], width?: number): void {
this.logBoxTitle(title, width || this.DEFAULT_WIDTH);
for (const line of lines) {
this.logBoxLine(line);
@ -132,11 +135,11 @@ export class Logger {
/**
* Log a divider line
* @param width Width of the divider
* @param width Width of the divider, defaults to DEFAULT_WIDTH
* @param character Character to use for the divider (default: ─)
*/
public logDivider(width: number, character: string = '─'): void {
console.log(character.repeat(width));
public logDivider(width?: number, character: string = '─'): void {
console.log(character.repeat(width || this.DEFAULT_WIDTH));
}
}

View File

@ -14,7 +14,7 @@ export class NupstSystemd {
/** Template for the systemd service file */
private readonly serviceTemplate = `[Unit]
Description=Node.js UPS Shutdown Tool
Description=Node.js UPS Shutdown Tool for Multiple UPS Devices
After=network.target
[Service]
@ -51,7 +51,7 @@ WantedBy=multi-user.target
const boxWidth = 50;
logger.logBoxTitle('Configuration Error', boxWidth);
logger.logBoxLine(`No configuration file found at ${configPath}`);
logger.logBoxLine("Please run 'nupst setup' first to create a configuration.");
logger.logBoxLine("Please run 'nupst add' first to create a UPS configuration.");
logger.logBoxEnd();
throw new Error('Configuration not found');
}
@ -155,7 +155,7 @@ WantedBy=multi-user.target
}
await this.displayServiceStatus();
await this.displayUpsStatus();
await this.displayAllUpsStatus();
} catch (error) {
logger.error(`Failed to get status: ${error.message}`);
}
@ -184,35 +184,38 @@ WantedBy=multi-user.target
}
/**
* Display the UPS status
* Display all UPS statuses
* @private
*/
private async displayUpsStatus(): Promise<void> {
private async displayAllUpsStatus(): Promise<void> {
try {
// Explicitly load the configuration first to ensure it's up-to-date
await this.daemon.loadConfig();
const config = this.daemon.getConfig();
const snmp = this.daemon.getNupstSnmp();
// Create a test config with appropriate timeout, similar to the test command
const snmpConfig = {
...config.snmp,
timeout: Math.min(config.snmp.timeout, 10000) // Use at most 10 seconds for status check
};
const boxWidth = 45;
logger.logBoxTitle('Connecting to UPS...', boxWidth);
logger.logBoxLine(`Host: ${config.snmp.host}:${config.snmp.port}`);
logger.logBoxLine(`UPS Model: ${config.snmp.upsModel || 'cyberpower'}`);
logger.logBoxEnd();
const status = await snmp.getUpsStatus(snmpConfig);
logger.logBoxTitle('UPS Status', boxWidth);
logger.logBoxLine(`Power Status: ${status.powerStatus}`);
logger.logBoxLine(`Battery Capacity: ${status.batteryCapacity}%`);
logger.logBoxLine(`Runtime Remaining: ${status.batteryRuntime} minutes`);
logger.logBoxEnd();
// Check if we have the new multi-UPS config format
if (config.upsDevices && Array.isArray(config.upsDevices) && config.upsDevices.length > 0) {
logger.log(`Found ${config.upsDevices.length} UPS device(s) in configuration`);
// Show status for each UPS
for (const ups of config.upsDevices) {
await this.displaySingleUpsStatus(ups, snmp);
}
} else if (config.snmp) {
// Legacy single UPS configuration
const legacyUps = {
id: 'default',
name: 'Default UPS',
snmp: config.snmp,
thresholds: config.thresholds,
groups: []
};
await this.displaySingleUpsStatus(legacyUps, snmp);
} else {
logger.error('No UPS devices found in configuration');
}
} catch (error) {
const boxWidth = 45;
logger.logBoxTitle('UPS Status', boxWidth);
@ -220,6 +223,62 @@ WantedBy=multi-user.target
logger.logBoxEnd();
}
}
/**
* Display status of a single UPS
* @param ups UPS configuration
* @param snmp SNMP manager
*/
private async displaySingleUpsStatus(ups: any, snmp: any): Promise<void> {
const boxWidth = 45;
logger.logBoxTitle(`Connecting to UPS: ${ups.name}`, boxWidth);
logger.logBoxLine(`ID: ${ups.id}`);
logger.logBoxLine(`Host: ${ups.snmp.host}:${ups.snmp.port}`);
logger.logBoxLine(`UPS Model: ${ups.snmp.upsModel || 'cyberpower'}`);
if (ups.groups && ups.groups.length > 0) {
// Get group names if available
const config = this.daemon.getConfig();
const groupNames = ups.groups.map(groupId => {
const group = config.groups?.find(g => g.id === groupId);
return group ? group.name : groupId;
});
logger.logBoxLine(`Groups: ${groupNames.join(', ')}`);
}
logger.logBoxEnd();
try {
// Create a test config with a short timeout
const testConfig = {
...ups.snmp,
timeout: Math.min(ups.snmp.timeout, 10000) // Use at most 10 seconds for status check
};
const status = await snmp.getUpsStatus(testConfig);
logger.logBoxTitle(`UPS Status: ${ups.name}`, boxWidth);
logger.logBoxLine(`Power Status: ${status.powerStatus}`);
logger.logBoxLine(`Battery Capacity: ${status.batteryCapacity}%`);
logger.logBoxLine(`Runtime Remaining: ${status.batteryRuntime} minutes`);
// Show threshold status
logger.logBoxLine('');
logger.logBoxLine('Thresholds:');
logger.logBoxLine(` Battery: ${status.batteryCapacity}% / ${ups.thresholds.battery}% ${
status.batteryCapacity < ups.thresholds.battery ? '⚠️' : '✓'
}`);
logger.logBoxLine(` Runtime: ${status.batteryRuntime} min / ${ups.thresholds.runtime} min ${
status.batteryRuntime < ups.thresholds.runtime ? '⚠️' : '✓'
}`);
logger.logBoxEnd();
} catch (error) {
logger.logBoxTitle(`UPS Status: ${ups.name}`, boxWidth);
logger.logBoxLine(`Failed to retrieve UPS status: ${error.message}`);
logger.logBoxEnd();
}
}
/**
* Disable and uninstall the systemd service