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:
Philipp Kunz 2025-03-28 16:19:43 +00:00
parent bd3042de25
commit 0e55f22dad
10 changed files with 2381 additions and 780 deletions

View File

@ -1,5 +1,14 @@
# Changelog # Changelog
## 2025-03-28 - 3.0.0 - BREAKING CHANGE(core)
Add multi-UPS support and group management; update CLI, configuration and documentation to support multiple UPS devices with group modes
- Implemented multi-UPS configuration with an array of UPS devices and groups in the configuration file
- Added group management commands (group add, edit, delete, list) with redundant and non-redundant modes
- Revamped CLI command parsing for UPS management (add, edit, delete, list, setup) and group subcommands
- Updated readme and documentation to reflect new configuration structure and features
- Enhanced logging and status display for multiple UPS devices
## 2025-03-26 - 2.6.17 - fix(logger) ## 2025-03-26 - 2.6.17 - fix(logger)
Preserve logbox width after logBoxEnd so that subsequent logBoxLine calls continue using the set width. Preserve logbox width after logBoxEnd so that subsequent logBoxLine calls continue using the set width.

162
readme.md
View File

@ -4,6 +4,10 @@ NUPST is a command-line tool that monitors SNMP-enabled UPS devices and initiate
## Features ## Features
- **Multi-UPS Support**: Monitor and manage multiple UPS devices from a single installation
- **Group Management**: Organize UPS devices into groups with different operating modes
- **Redundant Mode**: Only shutdown when ALL UPS devices in a group are in critical condition
- **Non-Redundant Mode**: Shutdown when ANY UPS device in a group is in critical condition
- Monitors UPS devices using SNMP (v1, v2c, and v3 supported) - Monitors UPS devices using SNMP (v1, v2c, and v3 supported)
- Automatic shutdown when battery level falls below threshold - Automatic shutdown when battery level falls below threshold
- Automatic shutdown when runtime remaining falls below threshold - Automatic shutdown when runtime remaining falls below threshold
@ -124,8 +128,22 @@ Usage:
nupst stop - Stop the systemd service nupst stop - Stop the systemd service
nupst start - Start the systemd service nupst start - Start the systemd service
nupst status - Show status of the systemd service and UPS status nupst status - Show status of the systemd service and UPS status
nupst setup - Run the interactive setup to configure SNMP settings
nupst test - Test the current configuration by connecting to the UPS UPS Management:
nupst add - Add a new UPS device
nupst edit [id] - Edit an existing UPS (default UPS if no ID provided)
nupst delete <id> - Delete a UPS by ID
nupst list - List all configured UPS devices
nupst setup - Alias for 'nupst edit' (backward compatibility)
Group Management:
nupst group list - List all UPS groups
nupst group add - Add a new UPS group
nupst group edit <id> - Edit an existing UPS group
nupst group delete <id> - Delete a UPS group
System Commands:
nupst test - Test the current configuration by connecting to all UPS devices
nupst config - Display the current configuration nupst config - Display the current configuration
nupst update - Update NUPST from repository and refresh systemd service (requires root) nupst update - Update NUPST from repository and refresh systemd service (requires root)
nupst uninstall - Completely uninstall NUPST from the system (requires root) nupst uninstall - Completely uninstall NUPST from the system (requires root)
@ -138,62 +156,114 @@ Options:
## Configuration ## Configuration
NUPST provides an interactive setup to configure your UPS: NUPST supports monitoring multiple UPS devices organized into groups. You can set up your UPS devices using the interactive commands:
```bash ```bash
nupst setup # Add a new UPS device
nupst add
# Create a new group
nupst group add
# Assign UPS devices to groups
nupst group edit <group-id>
``` ```
This will guide you through setting up: ### Configuration File Structure
- UPS IP address and SNMP settings
- Shutdown thresholds for battery percentage and runtime
- Monitoring interval
- Test the connection to your UPS
Alternatively, you can manually edit the configuration file at `/etc/nupst/config.json`. A default configuration will be created on first run: The configuration file is located at `/etc/nupst/config.json`. Here's an example of a multi-UPS configuration:
```json ```json
{ {
"snmp": { "checkInterval": 30000,
"host": "192.168.1.100", "upsDevices": [
"port": 161, {
"community": "public", "id": "ups-1",
"version": 1, "name": "Server Room UPS",
"timeout": 5000, "snmp": {
"upsModel": "cyberpower" "host": "192.168.1.100",
}, "port": 161,
"thresholds": { "community": "public",
"battery": 60, "version": 1,
"runtime": 20 "timeout": 5000,
}, "upsModel": "cyberpower"
"checkInterval": 30000 },
"thresholds": {
"battery": 60,
"runtime": 20
},
"groups": ["datacenter"]
},
{
"id": "ups-2",
"name": "Network Rack UPS",
"snmp": {
"host": "192.168.1.101",
"port": 161,
"community": "public",
"version": 1,
"timeout": 5000,
"upsModel": "apc"
},
"thresholds": {
"battery": 50,
"runtime": 15
},
"groups": ["datacenter"]
}
],
"groups": [
{
"id": "datacenter",
"name": "Data Center",
"mode": "redundant",
"description": "Main data center UPS group"
}
]
} }
``` ```
- `snmp`: SNMP connection settings ### Configuration Fields
- `host`: IP address of your UPS (default: 127.0.0.1)
- `port`: SNMP port (default: 161)
- `version`: SNMP version (1, 2, or 3)
- `timeout`: Timeout in milliseconds (default: 5000)
- `upsModel`: The UPS model ('cyberpower', 'apc', 'eaton', 'tripplite', 'liebert', or 'custom')
- For SNMPv1/v2c:
- `community`: SNMP community string (default: public)
- For SNMPv3:
- `securityLevel`: Security level ('noAuthNoPriv', 'authNoPriv', or 'authPriv')
- `username`: SNMPv3 username
- `authProtocol`: Authentication protocol ('MD5' or 'SHA')
- `authKey`: Authentication password/key
- `privProtocol`: Privacy/encryption protocol ('DES' or 'AES')
- `privKey`: Privacy password/key
- For custom UPS models:
- `customOIDs`: Object containing custom OIDs for your UPS:
- `POWER_STATUS`: OID for power status
- `BATTERY_CAPACITY`: OID for battery capacity percentage
- `BATTERY_RUNTIME`: OID for runtime remaining in minutes
- `thresholds`: When to trigger shutdown
- `battery`: Battery percentage threshold (default: 60%)
- `runtime`: Runtime minutes threshold (default: 20 minutes)
- `checkInterval`: How often to check UPS status in milliseconds (default: 30000) - `checkInterval`: How often to check UPS status in milliseconds (default: 30000)
- `upsDevices`: Array of UPS device configurations
- `id`: Unique identifier for the UPS
- `name`: Friendly name for the UPS
- `snmp`: SNMP connection settings
- `host`: IP address of your UPS (default: 127.0.0.1)
- `port`: SNMP port (default: 161)
- `version`: SNMP version (1, 2, or 3)
- `timeout`: Timeout in milliseconds (default: 5000)
- `upsModel`: The UPS model ('cyberpower', 'apc', 'eaton', 'tripplite', 'liebert', or 'custom')
- For SNMPv1/v2c:
- `community`: SNMP community string (default: public)
- For SNMPv3:
- `securityLevel`: Security level ('noAuthNoPriv', 'authNoPriv', or 'authPriv')
- `username`: SNMPv3 username
- `authProtocol`: Authentication protocol ('MD5' or 'SHA')
- `authKey`: Authentication password/key
- `privProtocol`: Privacy/encryption protocol ('DES' or 'AES')
- `privKey`: Privacy password/key
- For custom UPS models:
- `customOIDs`: Object containing custom OIDs for your UPS:
- `POWER_STATUS`: OID for power status
- `BATTERY_CAPACITY`: OID for battery capacity percentage
- `BATTERY_RUNTIME`: OID for runtime remaining in minutes
- `thresholds`: When to trigger shutdown
- `battery`: Battery percentage threshold (default: 60%)
- `runtime`: Runtime minutes threshold (default: 20 minutes)
- `groups`: Array of group IDs this UPS belongs to
- `groups`: Array of group configurations
- `id`: Unique identifier for the group
- `name`: Friendly name for the group
- `mode`: Group operating mode ('redundant' or 'nonRedundant')
- `description`: Optional description of the group
### Group Modes
- **Redundant Mode**: The system will only initiate shutdown if ALL UPS devices in the group are in critical condition (below threshold). This is ideal for redundant power setups where one UPS can keep systems running.
- **Non-Redundant Mode**: The system will initiate shutdown if ANY UPS device in the group is in critical condition. This is useful for scenarios where all UPS devices must be operational for the system to function properly.
## Setup as a Service ## Setup as a Service

View File

@ -51,21 +51,19 @@ tap.test('should handle width persistence between logbox calls', async () => {
expect(errorThrown).toBeFalsy(); expect(errorThrown).toBeFalsy();
}); });
tap.test('should throw error when using logBoxLine without width', async () => { tap.test('should use default width when no width is specified', async () => {
// This should automatically use the default width instead of throwing
let errorThrown = false; let errorThrown = false;
let errorMessage = '';
try { try {
// Should throw because no width is set logger.logBoxLine('This should use default width');
logger.logBoxLine('This should fail'); logger.logBoxEnd();
} catch (error) { } catch (error) {
errorThrown = true; errorThrown = true;
errorMessage = (error as Error).message;
} }
expect(errorThrown).toBeTruthy(); // Verify no error was thrown
expect(errorMessage).toBeTruthy(); expect(errorThrown).toBeFalsy();
expect(errorMessage.includes('No box width')).toBeTruthy();
}); });
tap.test('should create a complete logbox in one call', async () => { tap.test('should create a complete logbox in one call', async () => {
@ -99,6 +97,14 @@ tap.test('should create dividers with custom characters', async () => {
expect(true).toBeTruthy(); expect(true).toBeTruthy();
}); });
tap.test('should create divider with default width', async () => {
// This should use the default width
logger.logDivider(undefined, '-');
// Just assert that the test runs without errors
expect(true).toBeTruthy();
});
tap.test('Logger Demo', async () => { tap.test('Logger Demo', async () => {
console.log('\n=== LOGGER DEMO ===\n'); console.log('\n=== LOGGER DEMO ===\n');
@ -135,10 +141,17 @@ tap.test('Logger Demo', async () => {
logger.logBoxLine('No need to specify the width again'); logger.logBoxLine('No need to specify the width again');
logger.logBoxEnd(); logger.logBoxEnd();
// Demonstrating default width
console.log('\nDefault Width Example:');
logger.logBoxLine('This line uses the default width');
logger.logBoxLine('Still using default width');
logger.logBoxEnd();
// Divider example // Divider example
logger.log('\nDivider example:'); logger.log('\nDivider example:');
logger.logDivider(30); logger.logDivider(30);
logger.logDivider(30, '*'); logger.logDivider(30, '*');
logger.logDivider(undefined, '=');
expect(true).toBeTruthy(); expect(true).toBeTruthy();
}); });

View File

@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@serve.zone/nupst', name: '@serve.zone/nupst',
version: '2.6.17', version: '3.0.0',
description: 'Node.js UPS Shutdown Tool for SNMP-enabled UPS devices' 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); 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 configuration settings */
snmp: ISnmpConfig; snmp: ISnmpConfig;
/** Threshold settings for initiating shutdown */ /** Threshold settings for initiating shutdown */
@ -22,8 +26,58 @@ export interface INupstConfig {
/** Shutdown when runtime below this minutes */ /** Shutdown when runtime below this minutes */
runtime: number; 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 */ /** Check interval in milliseconds */
checkInterval: number; 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 */ /** Default configuration */
private readonly DEFAULT_CONFIG: INupstConfig = { private readonly DEFAULT_CONFIG: INupstConfig = {
snmp: { upsDevices: [
host: '127.0.0.1', {
port: 161, id: 'default',
community: 'public', name: 'Default UPS',
version: 1, snmp: {
timeout: 5000, host: '127.0.0.1',
// SNMPv3 defaults (used only if version === 3) port: 161,
securityLevel: 'authPriv', community: 'public',
username: '', version: 1,
authProtocol: 'SHA', timeout: 5000,
authKey: '', // SNMPv3 defaults (used only if version === 3)
privProtocol: 'AES', securityLevel: 'authPriv',
privKey: '', username: '',
// UPS model for OID selection authProtocol: 'SHA',
upsModel: 'cyberpower' authKey: '',
}, privProtocol: 'AES',
thresholds: { privKey: '',
battery: 60, // Shutdown when battery below 60% // UPS model for OID selection
runtime: 20, // Shutdown when runtime below 20 minutes 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 checkInterval: 30000, // Check every 30 seconds
}; };
private config: INupstConfig; private config: INupstConfig;
private snmp: NupstSnmp; private snmp: NupstSnmp;
private isRunning: boolean = false; private isRunning: boolean = false;
private upsStatus: Map<string, IUpsStatus> = new Map();
/** /**
* Create a new daemon instance with the given SNMP manager * Create a new daemon instance with the given SNMP manager
@ -87,10 +150,36 @@ export class NupstDaemon {
// Read and parse config // Read and parse config
const configData = fs.readFileSync(this.CONFIG_PATH, 'utf8'); 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; return this.config;
} catch (error) { } 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 throw error; // Re-throw the no configuration error
} }
@ -175,6 +264,9 @@ export class NupstDaemon {
} }
}).catch(() => {}); // Ignore errors checking for updates }).catch(() => {}); // Ignore errors checking for updates
// Initialize UPS status tracking
this.initializeUpsStatus();
// Start UPS monitoring // Start UPS monitoring
this.isRunning = true; this.isRunning = true;
await this.monitor(); 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 * Log the loaded configuration settings
*/ */
private logConfigLoaded(): void { private logConfigLoaded(): void {
const boxWidth = 50; const boxWidth = 50;
logger.logBoxTitle('Configuration Loaded', boxWidth); logger.logBoxTitle('Configuration Loaded', boxWidth);
logger.logBoxLine('SNMP Settings:');
logger.logBoxLine(` Host: ${this.config.snmp.host}`); if (this.config.upsDevices && this.config.upsDevices.length > 0) {
logger.logBoxLine(` Port: ${this.config.snmp.port}`); logger.logBoxLine(`UPS Devices: ${this.config.upsDevices.length}`);
logger.logBoxLine(` Version: ${this.config.snmp.version}`); for (const ups of this.config.upsDevices) {
logger.logBoxLine('Thresholds:'); logger.logBoxLine(` - ${ups.name} (${ups.id}): ${ups.snmp.host}:${ups.snmp.port}`);
logger.logBoxLine(` Battery: ${this.config.thresholds.battery}%`); }
logger.logBoxLine(` Runtime: ${this.config.thresholds.runtime} minutes`); } 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.logBoxLine(`Check Interval: ${this.config.checkInterval / 1000} seconds`);
logger.logBoxEnd(); logger.logBoxEnd();
} }
@ -216,81 +345,239 @@ export class NupstDaemon {
private async monitor(): Promise<void> { private async monitor(): Promise<void> {
logger.log('Starting UPS monitoring...'); 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 let lastLogTime = 0; // Track when we last logged status
const LOG_INTERVAL = 5 * 60 * 1000; // Log at least every 5 minutes (300000ms) const LOG_INTERVAL = 5 * 60 * 1000; // Log at least every 5 minutes (300000ms)
// Monitor continuously // Monitor continuously
while (this.isRunning) { while (this.isRunning) {
try { try {
const status = await this.snmp.getUpsStatus(this.config.snmp); // Check all UPS devices
const currentTime = Date.now(); await this.checkAllUpsDevices();
const shouldLogStatus = (currentTime - lastLogTime) >= LOG_INTERVAL;
// Log status changes // Log periodic status update
if (status.powerStatus !== lastStatus) { const currentTime = Date.now();
const statusBoxWidth = 45; if (currentTime - lastLogTime >= LOG_INTERVAL) {
logger.logBoxTitle('Power Status Change', statusBoxWidth); this.logAllUpsStatus();
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();
lastLogTime = currentTime; lastLogTime = currentTime;
} }
// Handle battery power status // Check if shutdown is required based on group configurations
if (status.powerStatus === 'onBattery') { await this.evaluateGroupShutdownConditions();
await this.handleOnBatteryStatus(status);
}
// Wait before next check // Wait before next check
await this.sleep(this.config.checkInterval); await this.sleep(this.config.checkInterval);
} catch (error) { } catch (error) {
console.error('Error during UPS monitoring:', error); logger.error(`Error during UPS monitoring: ${error.message}`);
await this.sleep(this.config.checkInterval); 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: { private async checkAllUpsDevices(): Promise<void> {
powerStatus: string, for (const ups of this.config.upsDevices) {
batteryCapacity: number, try {
batteryRuntime: number const upsStatus = this.upsStatus.get(ups.id);
}): Promise<void> { if (!upsStatus) {
console.log('┌─ UPS Status ─────────────────────────────┐'); // Initialize status for this UPS if not exists
console.log(`│ Battery: ${status.batteryCapacity}% | Runtime: ${status.batteryRuntime} min`); this.upsStatus.set(ups.id, {
console.log('└──────────────────────────────────────────┘'); 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 for (const [id, status] of this.upsStatus.entries()) {
if (status.batteryCapacity < this.config.thresholds.battery) { logger.logBoxLine(`UPS: ${status.name} (${id})`);
console.log('⚠️ WARNING: Battery capacity below threshold'); logger.logBoxLine(` Power Status: ${status.powerStatus}`);
console.log(`Current: ${status.batteryCapacity}% | Threshold: ${this.config.thresholds.battery}%`); logger.logBoxLine(` Battery: ${status.batteryCapacity}% | Runtime: ${status.batteryRuntime} min`);
await this.initiateShutdown('Battery capacity below threshold'); 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; return;
} }
// Check runtime threshold // Evaluate each group
if (status.batteryRuntime < this.config.thresholds.runtime) { for (const group of this.config.groups) {
console.log('⚠️ WARNING: Runtime below threshold'); // Find all UPS devices in this group
console.log(`Current: ${status.batteryRuntime} min | Threshold: ${this.config.thresholds.runtime} min`); const upsDevicesInGroup = this.config.upsDevices.filter(ups =>
await this.initiateShutdown('Runtime below threshold'); 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; 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 * 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> { private async monitorDuringShutdown(): Promise<void> {
const EMERGENCY_RUNTIME_THRESHOLD = 5; // 5 minutes remaining is critical 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 MAX_MONITORING_TIME = 5 * 60 * 1000; // Max 5 minutes of monitoring
const startTime = Date.now(); 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 // Continue monitoring until max monitoring time is reached
while (Date.now() - startTime < MAX_MONITORING_TIME) { while (Date.now() - startTime < MAX_MONITORING_TIME) {
try { try {
console.log('Checking UPS status during shutdown...'); logger.log('Checking UPS status during shutdown...');
const status = await this.snmp.getUpsStatus(this.config.snmp);
console.log(`Current battery: ${status.batteryCapacity}%, Runtime: ${status.batteryRuntime} minutes`); // Check all UPS devices
for (const ups of this.config.upsDevices) {
// 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('└──────────────────────────────────────────┘');
try { try {
// Find shutdown command in common system paths const status = await this.snmp.getUpsStatus(ups.snmp);
const shutdownPaths = [
'/sbin/shutdown',
'/usr/sbin/shutdown',
'/bin/shutdown',
'/usr/bin/shutdown'
];
let shutdownCmd = ''; logger.log(`UPS ${ups.name}: Battery ${status.batteryCapacity}%, Runtime: ${status.batteryRuntime} minutes`);
for (const path of shutdownPaths) {
if (fs.existsSync(path)) { // If any UPS battery runtime gets critically low, force immediate shutdown
shutdownCmd = path; if (status.batteryRuntime < EMERGENCY_RUNTIME_THRESHOLD) {
console.log(`Found shutdown command at: ${shutdownCmd}`); logger.logBoxTitle('EMERGENCY SHUTDOWN', 50);
break; 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;
} }
} catch (upsError) {
if (shutdownCmd) { logger.error(`Error checking UPS ${ups.name} during shutdown: ${upsError.message}`);
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');
} }
// Stop monitoring after initiating emergency shutdown
return;
} }
// Wait before checking again // Wait before checking again
await this.sleep(CHECK_INTERVAL); await this.sleep(CHECK_INTERVAL);
} catch (error) { } catch (error) {
console.error('Error monitoring UPS during shutdown:', error); logger.error(`Error monitoring UPS during shutdown: ${error.message}`);
await this.sleep(CHECK_INTERVAL); 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 { export class Logger {
private currentBoxWidth: number | null = null; private currentBoxWidth: number | null = null;
private static instance: Logger; private static instance: Logger;
/** Default width to use when no width is specified */
private readonly DEFAULT_WIDTH = 60;
/** /**
* Creates a new Logger instance * Creates a new Logger instance
@ -59,17 +62,17 @@ export class Logger {
/** /**
* Log a logbox title and set the current box width * Log a logbox title and set the current box width
* @param title Title of the logbox * @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 { public logBoxTitle(title: string, width?: number): void {
this.currentBoxWidth = width; this.currentBoxWidth = width || this.DEFAULT_WIDTH;
// Create the title line with appropriate padding // Create the title line with appropriate padding
const paddedTitle = ` ${title} `; const paddedTitle = ` ${title} `;
const remainingSpace = width - 3 - paddedTitle.length; const remainingSpace = this.currentBoxWidth - 3 - paddedTitle.length;
// Title line: ┌─ Title ───┐ // Title line: ┌─ Title ───┐
const titleLine = `┌─${paddedTitle}${'─'.repeat(remainingSpace)}`; const titleLine = `┌─${paddedTitle}${'─'.repeat(Math.max(0, remainingSpace))}`;
console.log(titleLine); console.log(titleLine);
} }
@ -77,15 +80,16 @@ export class Logger {
/** /**
* Log a logbox line * Log a logbox line
* @param content Content of the 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 { public logBoxLine(content: string, width?: number): void {
const boxWidth = width || this.currentBoxWidth; if (!this.currentBoxWidth && !width) {
// No current width and no width provided, use default width
if (!boxWidth) { this.logBoxTitle('', this.DEFAULT_WIDTH);
throw new Error('No box width specified and no previous box width to use');
} }
const boxWidth = width || this.currentBoxWidth || this.DEFAULT_WIDTH;
// Calculate the available space for content // Calculate the available space for content
const availableSpace = boxWidth - 2; // Account for left and right borders const availableSpace = boxWidth - 2; // Account for left and right borders
@ -101,27 +105,26 @@ export class Logger {
/** /**
* Log a logbox end * 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 { public logBoxEnd(width?: number): void {
const boxWidth = width || this.currentBoxWidth; const boxWidth = width || this.currentBoxWidth || this.DEFAULT_WIDTH;
if (!boxWidth) {
throw new Error('No box width specified and no previous box width to use');
}
// Create the bottom border: └────────┘ // Create the bottom border: └────────┘
console.log(`${'─'.repeat(boxWidth - 2)}`); console.log(`${'─'.repeat(boxWidth - 2)}`);
// Reset the current box width
this.currentBoxWidth = null;
} }
/** /**
* Log a complete logbox with title, content lines, and ending * Log a complete logbox with title, content lines, and ending
* @param title Title of the logbox * @param title Title of the logbox
* @param lines Array of content lines * @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 { public logBox(title: string, lines: string[], width?: number): void {
this.logBoxTitle(title, width); this.logBoxTitle(title, width || this.DEFAULT_WIDTH);
for (const line of lines) { for (const line of lines) {
this.logBoxLine(line); this.logBoxLine(line);
@ -132,11 +135,11 @@ export class Logger {
/** /**
* Log a divider line * 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: ) * @param character Character to use for the divider (default: )
*/ */
public logDivider(width: number, character: string = '─'): void { public logDivider(width?: number, character: string = '─'): void {
console.log(character.repeat(width)); console.log(character.repeat(width || this.DEFAULT_WIDTH));
} }
} }

View File

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