diff --git a/changelog.md b/changelog.md index 0698a3f..09a7910 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,14 @@ # 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) Preserve logbox width after logBoxEnd so that subsequent logBoxLine calls continue using the set width. diff --git a/readme.md b/readme.md index 3142569..4273419 100644 --- a/readme.md +++ b/readme.md @@ -4,6 +4,10 @@ NUPST is a command-line tool that monitors SNMP-enabled UPS devices and initiate ## 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) - Automatic shutdown when battery level falls below threshold - Automatic shutdown when runtime remaining falls below threshold @@ -124,8 +128,22 @@ Usage: nupst stop - Stop the systemd service nupst start - Start the systemd service 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 - 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 - Edit an existing UPS group + nupst group delete - 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 update - Update NUPST from repository and refresh systemd service (requires root) nupst uninstall - Completely uninstall NUPST from the system (requires root) @@ -138,62 +156,114 @@ Options: ## 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 -nupst setup +# Add a new UPS device +nupst add + +# Create a new group +nupst group add + +# Assign UPS devices to groups +nupst group edit ``` -This will guide you through setting up: -- UPS IP address and SNMP settings -- Shutdown thresholds for battery percentage and runtime -- Monitoring interval -- Test the connection to your UPS +### Configuration File Structure -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 { - "snmp": { - "host": "192.168.1.100", - "port": 161, - "community": "public", - "version": 1, - "timeout": 5000, - "upsModel": "cyberpower" - }, - "thresholds": { - "battery": 60, - "runtime": 20 - }, - "checkInterval": 30000 + "checkInterval": 30000, + "upsDevices": [ + { + "id": "ups-1", + "name": "Server Room UPS", + "snmp": { + "host": "192.168.1.100", + "port": 161, + "community": "public", + "version": 1, + "timeout": 5000, + "upsModel": "cyberpower" + }, + "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 - - `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) +### Configuration Fields + - `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 diff --git a/test/test.logger.ts b/test/test.logger.ts index 6f3ba77..07f3d23 100644 --- a/test/test.logger.ts +++ b/test/test.logger.ts @@ -51,21 +51,19 @@ tap.test('should handle width persistence between logbox calls', async () => { 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 errorMessage = ''; try { - // Should throw because no width is set - logger.logBoxLine('This should fail'); + logger.logBoxLine('This should use default width'); + logger.logBoxEnd(); } catch (error) { errorThrown = true; - errorMessage = (error as Error).message; } - expect(errorThrown).toBeTruthy(); - expect(errorMessage).toBeTruthy(); - expect(errorMessage.includes('No box width')).toBeTruthy(); + // Verify no error was thrown + expect(errorThrown).toBeFalsy(); }); 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(); }); +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 () => { 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.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 logger.log('\nDivider example:'); logger.logDivider(30); logger.logDivider(30, '*'); + logger.logDivider(undefined, '='); expect(true).toBeTruthy(); }); diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 297f5de..0bcd63b 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -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' } diff --git a/ts/cli.ts b/ts/cli.ts index d331ecd..b960b7a 100644 --- a/ts/cli.ts +++ b/ts/cli.ts @@ -4,6 +4,9 @@ import { dirname, join } from 'path'; import { fileURLToPath } from 'url'; import { Nupst } from './nupst.js'; import { logger } from './logger.js'; +import { type IGroupConfig } from './daemon.js'; + +import * as helpers from './helpers/index.js'; /** * Class for handling CLI commands @@ -33,10 +36,11 @@ export class NupstCli { } // Get the command (default to help if none provided) - const command = args[2] || 'help'; + const command = debugOptions.cleanedArgs[2] || 'help'; + const commandArgs = debugOptions.cleanedArgs.slice(3); // Route to the appropriate command handler - await this.executeCommand(command, debugOptions.debugMode); + await this.executeCommand(command, commandArgs, debugOptions.debugMode); } /** @@ -55,10 +59,81 @@ export class NupstCli { /** * Execute the command with the given arguments * @param command Command to execute + * @param commandArgs Additional command arguments * @param debugMode Whether debug mode is enabled */ - private async executeCommand(command: string, debugMode: boolean): Promise { + private async executeCommand(command: string, commandArgs: string[], debugMode: boolean): Promise { + // Handle group subcommands + if (command === 'group') { + const subcommand = commandArgs[0] || 'list'; + const subcommandArgs = commandArgs.slice(1); + + switch (subcommand) { + case 'add': + await this.groupAdd(); + break; + + case 'edit': + const groupId = subcommandArgs[0]; + if (!groupId) { + logger.error('Group ID is required for edit command'); + this.showGroupHelp(); + return; + } + await this.groupEdit(groupId); + break; + + case 'delete': + const groupIdToDelete = subcommandArgs[0]; + if (!groupIdToDelete) { + logger.error('Group ID is required for delete command'); + this.showGroupHelp(); + return; + } + await this.groupDelete(groupIdToDelete); + break; + + case 'list': + await this.groupList(); + break; + + default: + this.showGroupHelp(); + break; + } + return; + } + + // Handle main commands switch (command) { + case 'add': + await this.add(); + break; + + case 'edit': + const upsId = commandArgs[0]; + await this.edit(upsId); + break; + + case 'delete': + const upsIdToDelete = commandArgs[0]; + if (!upsIdToDelete) { + logger.error('UPS ID is required for delete command'); + this.showHelp(); + return; + } + await this.delete(upsIdToDelete); + break; + + case 'list': + await this.list(); + break; + + case 'setup': + // Backward compatibility: setup is now an alias for edit with no specific UPS ID + await this.edit(undefined); + break; + case 'enable': await this.enable(); break; @@ -87,10 +162,6 @@ export class NupstCli { await this.disable(); break; - case 'setup': - await this.setup(); - break; - case 'test': await this.test(debugMode); break; @@ -247,161 +318,26 @@ export class NupstCli { // Get current configuration const config = this.nupst.getDaemon().getConfig(); - this.displayTestConfig(config); - await this.testConnection(config); + // Handle new multi-UPS configuration format + if (config.upsDevices && config.upsDevices.length > 0) { + logger.log(`Found ${config.upsDevices.length} UPS devices in configuration.`); + + for (let i = 0; i < config.upsDevices.length; i++) { + const ups = config.upsDevices[i]; + logger.log(`\nTesting UPS: ${ups.name} (${ups.id})`); + this.displayTestConfig(ups); + await this.testConnection(ups); + } + } else { + // Legacy configuration format + this.displayTestConfig(config); + await this.testConnection(config); + } } catch (error) { logger.error(`Test failed: ${error.message}`); } } - /** - * Display the configuration for testing - * @param config Current configuration - */ - private displayTestConfig(config: any): void { - const boxWidth = 45; - logger.logBoxTitle('Testing Configuration', boxWidth); - logger.logBoxLine('SNMP Settings:'); - logger.logBoxLine(` Host: ${config.snmp.host}`); - logger.logBoxLine(` Port: ${config.snmp.port}`); - logger.logBoxLine(` Version: ${config.snmp.version}`); - logger.logBoxLine(` UPS Model: ${config.snmp.upsModel || 'cyberpower'}`); - - if (config.snmp.version === 1 || config.snmp.version === 2) { - logger.logBoxLine(` Community: ${config.snmp.community}`); - } else if (config.snmp.version === 3) { - logger.logBoxLine(` Security Level: ${config.snmp.securityLevel}`); - logger.logBoxLine(` Username: ${config.snmp.username}`); - - // Show auth and privacy details based on security level - if (config.snmp.securityLevel === 'authNoPriv' || config.snmp.securityLevel === 'authPriv') { - logger.logBoxLine(` Auth Protocol: ${config.snmp.authProtocol || 'None'}`); - } - - if (config.snmp.securityLevel === 'authPriv') { - logger.logBoxLine(` Privacy Protocol: ${config.snmp.privProtocol || 'None'}`); - } - - // Show timeout value - logger.logBoxLine(` Timeout: ${config.snmp.timeout / 1000} seconds`); - } - - // Show OIDs if custom model is selected - if (config.snmp.upsModel === 'custom' && config.snmp.customOIDs) { - logger.logBoxLine('Custom OIDs:'); - logger.logBoxLine(` Power Status: ${config.snmp.customOIDs.POWER_STATUS || 'Not set'}`); - logger.logBoxLine(` Battery Capacity: ${config.snmp.customOIDs.BATTERY_CAPACITY || 'Not set'}`); - logger.logBoxLine(` Battery Runtime: ${config.snmp.customOIDs.BATTERY_RUNTIME || 'Not set'}`); - } - logger.logBoxLine('Thresholds:'); - logger.logBoxLine(` Battery: ${config.thresholds.battery}%`); - logger.logBoxLine(` Runtime: ${config.thresholds.runtime} minutes`); - logger.logBoxLine(`Check Interval: ${config.checkInterval / 1000} seconds`); - logger.logBoxEnd(); - } - - /** - * Test connection to the UPS - * @param config Current configuration - */ - private async testConnection(config: any): Promise { - logger.log('\nTesting connection to UPS...'); - try { - // Create a test config with a short timeout - const testConfig = { - ...config.snmp, - timeout: Math.min(config.snmp.timeout, 10000), // Use at most 10 seconds for testing - }; - - const status = await this.nupst.getSnmp().getUpsStatus(testConfig); - - const boxWidth = 45; - logger.logBoxTitle('Connection Successful!', boxWidth); - logger.logBoxLine('UPS Status:'); - logger.logBoxLine(` Power Status: ${status.powerStatus}`); - logger.logBoxLine(` Battery Capacity: ${status.batteryCapacity}%`); - logger.logBoxLine(` Runtime Remaining: ${status.batteryRuntime} minutes`); - logger.logBoxEnd(); - - // Check status against thresholds if on battery - if (status.powerStatus === 'onBattery') { - this.analyzeThresholds(status, config); - } - } catch (error) { - const errorBoxWidth = 45; - logger.logBoxTitle('Connection Failed!', errorBoxWidth); - logger.logBoxLine(`Error: ${error.message}`); - logger.logBoxEnd(); - logger.log("\nPlease check your settings and run 'nupst setup' to reconfigure."); - } - } - - /** - * Analyze UPS status against thresholds - * @param status UPS status - * @param config Current configuration - */ - private analyzeThresholds(status: any, config: any): void { - const boxWidth = 45; - logger.logBoxTitle('Threshold Analysis', boxWidth); - - if (status.batteryCapacity < config.thresholds.battery) { - logger.logBoxLine('⚠️ WARNING: Battery capacity below threshold'); - logger.logBoxLine( - ` Current: ${status.batteryCapacity}% | Threshold: ${config.thresholds.battery}%` - ); - logger.logBoxLine(' System would initiate shutdown'); - } else { - logger.logBoxLine('✓ Battery capacity above threshold'); - logger.logBoxLine( - ` Current: ${status.batteryCapacity}% | Threshold: ${config.thresholds.battery}%` - ); - } - - if (status.batteryRuntime < config.thresholds.runtime) { - logger.logBoxLine('⚠️ WARNING: Runtime below threshold'); - logger.logBoxLine( - ` Current: ${status.batteryRuntime} min | Threshold: ${config.thresholds.runtime} min` - ); - logger.logBoxLine(' System would initiate shutdown'); - } else { - logger.logBoxLine('✓ Runtime above threshold'); - logger.logBoxLine( - ` Current: ${status.batteryRuntime} min | Threshold: ${config.thresholds.runtime} min` - ); - } - - logger.logBoxEnd(); - } - - /** - * Display help message - */ - private showHelp(): void { - logger.log(` -NUPST - Node.js UPS Shutdown Tool - -Usage: - nupst enable - Install and enable the systemd service (requires root) - nupst disable - Stop and uninstall the systemd service (requires root) - nupst daemon-start - Start the daemon process directly - nupst logs - Show logs of the systemd service - nupst stop - Stop the systemd service - nupst start - Start the systemd service - 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 - nupst config - Display the current configuration - nupst update - Update NUPST from repository and refresh systemd service (requires root) - nupst uninstall - Completely uninstall NUPST from the system (requires root) - nupst help - Show this help message - -Options: - --debug, -d - Enable debug mode for detailed SNMP logging - (Example: nupst test --debug) -`); - } - /** * Update NUPST from repository and refresh systemd service */ @@ -493,7 +429,639 @@ Options: } /** - * Interactive setup for configuring SNMP settings + * Completely uninstall NUPST from the system + */ + private async uninstall(): Promise { + // Check if running as root + this.checkRootAccess('This command must be run as root.'); + + try { + // Import readline module for user input + const readline = await import('readline'); + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + // Helper function to prompt for input + const prompt = (question: string): Promise => { + return new Promise((resolve) => { + rl.question(question, (answer: string) => { + resolve(answer); + }); + }); + }; + + console.log('\nNUPST Uninstaller'); + console.log('==============='); + console.log('This will completely remove NUPST from your system.\n'); + + // Ask about removing configuration + const removeConfig = await prompt( + 'Do you want to remove the NUPST configuration files? (y/N): ' + ); + + // Find the uninstall.sh script location + let uninstallScriptPath: string; + + // Try to determine script location based on executable path + try { + // For ESM, we can use import.meta.url, but since we might be in CJS + // we'll use a more reliable approach based on process.argv[1] + const binPath = process.argv[1]; + const modulePath = dirname(dirname(binPath)); + uninstallScriptPath = join(modulePath, 'uninstall.sh'); + + // Check if the script exists + await fs.access(uninstallScriptPath); + } catch (error) { + // If we can't find it in the expected location, try common installation paths + const commonPaths = ['/opt/nupst/uninstall.sh', join(process.cwd(), 'uninstall.sh')]; + + for (const path of commonPaths) { + try { + await fs.access(path); + uninstallScriptPath = path; + break; + } catch { + // Continue to next path + } + } + + if (!uninstallScriptPath) { + console.error('Could not locate uninstall.sh script. Aborting uninstall.'); + rl.close(); + process.exit(1); + } + } + + // Close readline before executing script + rl.close(); + + // Execute uninstall.sh with the appropriate option + console.log(`\nRunning uninstaller from ${uninstallScriptPath}...`); + + // Pass the configuration removal option as an environment variable + const env = { + ...process.env, + REMOVE_CONFIG: removeConfig.toLowerCase() === 'y' ? 'yes' : 'no', + REMOVE_REPO: 'yes', // Always remove repo as requested + NUPST_CLI_CALL: 'true', // Flag to indicate this is being called from CLI + }; + + // Run the uninstall script with sudo + execSync(`sudo bash ${uninstallScriptPath}`, { + env, + stdio: 'inherit', // Show output in the terminal + }); + } catch (error) { + console.error(`Uninstall failed: ${error.message}`); + process.exit(1); + } + } + + /** + * Add a new UPS configuration + */ + private async add(): Promise { + try { + // Import readline module for user input + const readline = await import('readline'); + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + // Helper function to prompt for input + const prompt = (question: string): Promise => { + return new Promise((resolve) => { + rl.question(question, (answer: string) => { + resolve(answer); + }); + }); + }; + + try { + await this.runAddProcess(prompt); + } finally { + rl.close(); + } + } catch (error) { + logger.error(`Add UPS error: ${error.message}`); + } + } + + /** + * Run the interactive process to add a new UPS + * @param prompt Function to prompt for user input + */ + private async runAddProcess(prompt: (question: string) => Promise): Promise { + logger.log('\nNUPST Add UPS'); + logger.log('=============\n'); + logger.log('This will guide you through configuring a new UPS.\n'); + + // Try to load existing config if available + let config; + try { + await this.nupst.getDaemon().loadConfig(); + config = this.nupst.getDaemon().getConfig(); + + // Convert old format to new format if needed + if (!config.upsDevices) { + // Initialize with the current config as the first UPS + config = { + checkInterval: config.checkInterval, + upsDevices: [{ + id: 'default', + name: 'Default UPS', + snmp: config.snmp, + thresholds: config.thresholds, + groups: [] + }], + groups: [] + }; + logger.log('Converting existing configuration to multi-UPS format.'); + } + } catch (error) { + // If config doesn't exist, initialize with empty config + config = { + checkInterval: 30000, // Default check interval + upsDevices: [], + groups: [] + }; + logger.log('No existing configuration found. Creating a new configuration.'); + } + + // Get UPS ID and name + const upsId = await this.promptForUniqueUpsId(prompt, config.upsDevices); + const name = await prompt('UPS Name: '); + + // Create a new UPS configuration object with defaults + const newUps = { + id: upsId, + name: name || `UPS-${upsId}`, + snmp: { + host: '127.0.0.1', + port: 161, + community: 'public', + version: 1, + timeout: 5000, + upsModel: 'cyberpower' + }, + thresholds: { + battery: 60, + runtime: 20 + }, + groups: [] + }; + + // Gather SNMP settings + await this.gatherSnmpSettings(newUps.snmp, prompt); + + // Gather threshold settings + await this.gatherThresholdSettings(newUps.thresholds, prompt); + + // Gather UPS model settings + await this.gatherUpsModelSettings(newUps.snmp, prompt); + + // Assign to groups if any exist + if (config.groups && config.groups.length > 0) { + await this.assignUpsToGroups(newUps, config.groups, prompt); + } + + // Add the new UPS to the config + config.upsDevices.push(newUps); + + // Save the configuration + await this.nupst.getDaemon().saveConfig(config); + + this.displayUpsConfigSummary(newUps); + + // Test the connection if requested + await this.optionallyTestConnection(newUps.snmp, prompt); + + // Check if service is running and restart it if needed + await this.restartServiceIfRunning(); + + logger.log('\nSetup complete!'); + } + + /** + * Edit an existing UPS configuration + * @param upsId ID of the UPS to edit (undefined for default UPS) + */ + private async edit(upsId?: string): Promise { + try { + // Import readline module for user input + const readline = await import('readline'); + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + // Helper function to prompt for input + const prompt = (question: string): Promise => { + return new Promise((resolve) => { + rl.question(question, (answer: string) => { + resolve(answer); + }); + }); + }; + + try { + await this.runEditProcess(upsId, prompt); + } finally { + rl.close(); + } + } catch (error) { + logger.error(`Edit UPS error: ${error.message}`); + } + } + + /** + * Run the interactive process to edit a UPS + * @param upsId ID of the UPS to edit (undefined for default UPS) + * @param prompt Function to prompt for user input + */ + private async runEditProcess(upsId: string | undefined, prompt: (question: string) => Promise): Promise { + logger.log('\nNUPST Edit UPS'); + logger.log('=============\n'); + + // Try to load existing config + try { + await this.nupst.getDaemon().loadConfig(); + } catch (error) { + if (!upsId) { + // For default UPS (no ID specified), run setup if no config exists + logger.log('No existing configuration found. Running setup for new UPS.'); + await this.runAddProcess(prompt); + return; + } else { + // For specific UPS ID, error if config doesn't exist + logger.error('No configuration found. Please run "nupst setup" first.'); + return; + } + } + + // Get the config + const config = this.nupst.getDaemon().getConfig(); + + // Convert old format to new format if needed + if (!config.upsDevices) { + // Initialize with the current config as the first UPS + config.upsDevices = [{ + id: 'default', + name: 'Default UPS', + snmp: config.snmp, + thresholds: config.thresholds, + groups: [] + }]; + config.groups = []; + logger.log('Converting existing configuration to multi-UPS format.'); + } + + // Find the UPS to edit + let upsToEdit; + if (upsId) { + // Find specific UPS by ID + upsToEdit = config.upsDevices.find(ups => ups.id === upsId); + if (!upsToEdit) { + logger.error(`UPS with ID "${upsId}" not found.`); + return; + } + logger.log(`Editing UPS: ${upsToEdit.name} (${upsToEdit.id})\n`); + } else { + // For backward compatibility, edit the first UPS if no ID specified + if (config.upsDevices.length === 0) { + logger.error('No UPS devices configured. Please run "nupst add" to add a UPS.'); + return; + } + upsToEdit = config.upsDevices[0]; + logger.log(`Editing default UPS: ${upsToEdit.name} (${upsToEdit.id})\n`); + } + + // Allow editing UPS name + const newName = await prompt(`UPS Name [${upsToEdit.name}]: `); + if (newName.trim()) { + upsToEdit.name = newName; + } + + // Edit SNMP settings + await this.gatherSnmpSettings(upsToEdit.snmp, prompt); + + // Edit threshold settings + await this.gatherThresholdSettings(upsToEdit.thresholds, prompt); + + // Edit UPS model settings + await this.gatherUpsModelSettings(upsToEdit.snmp, prompt); + + // Edit group assignments + if (config.groups && config.groups.length > 0) { + await this.assignUpsToGroups(upsToEdit, config.groups, prompt); + } + + // Save the configuration + await this.nupst.getDaemon().saveConfig(config); + + this.displayUpsConfigSummary(upsToEdit); + + // Test the connection if requested + await this.optionallyTestConnection(upsToEdit.snmp, prompt); + + // Check if service is running and restart it if needed + await this.restartServiceIfRunning(); + + logger.log('\nEdit complete!'); + } + + /** + * Display the configuration for testing + * @param config Current configuration or individual UPS configuration + */ + private displayTestConfig(config: any): void { + // Check if this is a UPS device or full configuration + const isUpsConfig = config.snmp && config.thresholds; + const snmpConfig = isUpsConfig ? config.snmp : config.snmp || {}; + const thresholds = isUpsConfig ? config.thresholds : config.thresholds || {}; + const checkInterval = config.checkInterval || 30000; + + // Get UPS name and ID if available + const upsName = config.name ? config.name : 'Default UPS'; + const upsId = config.id ? config.id : 'default'; + + const boxWidth = 45; + logger.logBoxTitle(`Testing Configuration: ${upsName}`, boxWidth); + logger.logBoxLine(`UPS ID: ${upsId}`); + logger.logBoxLine('SNMP Settings:'); + logger.logBoxLine(` Host: ${snmpConfig.host}`); + logger.logBoxLine(` Port: ${snmpConfig.port}`); + logger.logBoxLine(` Version: ${snmpConfig.version}`); + logger.logBoxLine(` UPS Model: ${snmpConfig.upsModel || 'cyberpower'}`); + + if (snmpConfig.version === 1 || snmpConfig.version === 2) { + logger.logBoxLine(` Community: ${snmpConfig.community}`); + } else if (snmpConfig.version === 3) { + logger.logBoxLine(` Security Level: ${snmpConfig.securityLevel}`); + logger.logBoxLine(` Username: ${snmpConfig.username}`); + + // Show auth and privacy details based on security level + if (snmpConfig.securityLevel === 'authNoPriv' || snmpConfig.securityLevel === 'authPriv') { + logger.logBoxLine(` Auth Protocol: ${snmpConfig.authProtocol || 'None'}`); + } + + if (snmpConfig.securityLevel === 'authPriv') { + logger.logBoxLine(` Privacy Protocol: ${snmpConfig.privProtocol || 'None'}`); + } + + // Show timeout value + logger.logBoxLine(` Timeout: ${snmpConfig.timeout / 1000} seconds`); + } + + // Show OIDs if custom model is selected + if (snmpConfig.upsModel === 'custom' && snmpConfig.customOIDs) { + logger.logBoxLine('Custom OIDs:'); + logger.logBoxLine(` Power Status: ${snmpConfig.customOIDs.POWER_STATUS || 'Not set'}`); + logger.logBoxLine(` Battery Capacity: ${snmpConfig.customOIDs.BATTERY_CAPACITY || 'Not set'}`); + logger.logBoxLine(` Battery Runtime: ${snmpConfig.customOIDs.BATTERY_RUNTIME || 'Not set'}`); + } + logger.logBoxLine('Thresholds:'); + logger.logBoxLine(` Battery: ${thresholds.battery}%`); + logger.logBoxLine(` Runtime: ${thresholds.runtime} minutes`); + + // Show group assignments if this is a UPS config + if (config.groups && Array.isArray(config.groups)) { + logger.logBoxLine(`Group Assignments: ${config.groups.length === 0 ? 'None' : config.groups.join(', ')}`); + } + + logger.logBoxLine(`Check Interval: ${checkInterval / 1000} seconds`); + logger.logBoxEnd(); + } + + /** + * Test connection to the UPS + * @param config Current UPS configuration or legacy config + */ + private async testConnection(config: any): Promise { + const upsId = config.id || 'default'; + const upsName = config.name || 'Default UPS'; + logger.log(`\nTesting connection to UPS: ${upsName} (${upsId})...`); + + try { + // Create a test config with a short timeout + const snmpConfig = config.snmp ? config.snmp : config.snmp; + const thresholds = config.thresholds ? config.thresholds : config.thresholds; + + const testConfig = { + ...snmpConfig, + timeout: Math.min(snmpConfig.timeout, 10000), // Use at most 10 seconds for testing + }; + + const status = await this.nupst.getSnmp().getUpsStatus(testConfig); + + const boxWidth = 45; + logger.logBoxTitle(`Connection Successful: ${upsName}`, boxWidth); + logger.logBoxLine('UPS Status:'); + logger.logBoxLine(` Power Status: ${status.powerStatus}`); + logger.logBoxLine(` Battery Capacity: ${status.batteryCapacity}%`); + logger.logBoxLine(` Runtime Remaining: ${status.batteryRuntime} minutes`); + logger.logBoxEnd(); + + // Check status against thresholds if on battery + if (status.powerStatus === 'onBattery') { + this.analyzeThresholds(status, thresholds); + } + } catch (error) { + const errorBoxWidth = 45; + logger.logBoxTitle(`Connection Failed: ${upsName}`, errorBoxWidth); + logger.logBoxLine(`Error: ${error.message}`); + logger.logBoxEnd(); + logger.log("\nPlease check your settings and run 'nupst edit' to reconfigure this UPS."); + } + } + + /** + * Analyze UPS status against thresholds + * @param status UPS status + * @param thresholds Threshold configuration + */ + private analyzeThresholds(status: any, thresholds: any): void { + const boxWidth = 45; + logger.logBoxTitle('Threshold Analysis', boxWidth); + + if (status.batteryCapacity < thresholds.battery) { + logger.logBoxLine('⚠️ WARNING: Battery capacity below threshold'); + logger.logBoxLine( + ` Current: ${status.batteryCapacity}% | Threshold: ${thresholds.battery}%` + ); + logger.logBoxLine(' System would initiate shutdown'); + } else { + logger.logBoxLine('✓ Battery capacity above threshold'); + logger.logBoxLine( + ` Current: ${status.batteryCapacity}% | Threshold: ${thresholds.battery}%` + ); + } + + if (status.batteryRuntime < thresholds.runtime) { + logger.logBoxLine('⚠️ WARNING: Runtime below threshold'); + logger.logBoxLine( + ` Current: ${status.batteryRuntime} min | Threshold: ${thresholds.runtime} min` + ); + logger.logBoxLine(' System would initiate shutdown'); + } else { + logger.logBoxLine('✓ Runtime above threshold'); + logger.logBoxLine( + ` Current: ${status.batteryRuntime} min | Threshold: ${thresholds.runtime} min` + ); + } + + logger.logBoxEnd(); + } + + /** + * List all configured UPS devices + */ + private async list(): Promise { + try { + // Try to load configuration + try { + await this.nupst.getDaemon().loadConfig(); + } catch (error) { + const errorBoxWidth = 45; + logger.logBoxTitle('Configuration Error', errorBoxWidth); + logger.logBoxLine('No configuration found.'); + logger.logBoxLine("Please run 'nupst setup' first to create a configuration."); + logger.logBoxEnd(); + return; + } + + // Get current configuration + const config = this.nupst.getDaemon().getConfig(); + + // Check if multi-UPS config + if (!config.upsDevices || !Array.isArray(config.upsDevices)) { + // Legacy single UPS configuration + const boxWidth = 45; + logger.logBoxTitle('UPS Devices', boxWidth); + logger.logBoxLine('Legacy single-UPS configuration detected.'); + logger.logBoxLine(''); + logger.logBoxLine('Default UPS:'); + logger.logBoxLine(` Host: ${config.snmp.host}:${config.snmp.port}`); + logger.logBoxLine(` Model: ${config.snmp.upsModel || 'cyberpower'}`); + logger.logBoxLine(` Thresholds: ${config.thresholds.battery}% battery, ${config.thresholds.runtime} min runtime`); + logger.logBoxLine(''); + logger.logBoxLine('Use "nupst add" to add more UPS devices and migrate'); + logger.logBoxLine('to the multi-UPS configuration format.'); + logger.logBoxEnd(); + return; + } + + // Display UPS list + const boxWidth = 60; + logger.logBoxTitle('UPS Devices', boxWidth); + + if (config.upsDevices.length === 0) { + logger.logBoxLine('No UPS devices configured.'); + logger.logBoxLine('Use "nupst add" to add a UPS device.'); + } else { + logger.logBoxLine(`Found ${config.upsDevices.length} UPS device(s)`); + logger.logBoxLine(''); + logger.logBoxLine('ID | Name | Host | Mode | Groups'); + logger.logBoxLine('-----------+----------------------+----------------+--------------+----------------'); + + for (const ups of config.upsDevices) { + const id = ups.id.padEnd(10, ' ').substring(0, 10); + const name = (ups.name || '').padEnd(20, ' ').substring(0, 20); + const host = `${ups.snmp.host}:${ups.snmp.port}`.padEnd(15, ' ').substring(0, 15); + const model = (ups.snmp.upsModel || 'cyberpower').padEnd(12, ' ').substring(0, 12); + const groups = ups.groups.length > 0 ? ups.groups.join(', ') : 'None'; + + logger.logBoxLine(`${id} | ${name} | ${host} | ${model} | ${groups}`); + } + } + + logger.logBoxEnd(); + } catch (error) { + logger.error(`Failed to list UPS devices: ${error.message}`); + } + } + + /** + * Delete a UPS by ID + * @param upsId ID of the UPS to delete + */ + private async delete(upsId: string): Promise { + try { + // Try to load configuration + try { + await this.nupst.getDaemon().loadConfig(); + } catch (error) { + const errorBoxWidth = 45; + logger.logBoxTitle('Configuration Error', errorBoxWidth); + logger.logBoxLine('No configuration found.'); + logger.logBoxLine("Please run 'nupst setup' first to create a configuration."); + logger.logBoxEnd(); + return; + } + + // Get current configuration + const config = this.nupst.getDaemon().getConfig(); + + // Check if multi-UPS config + if (!config.upsDevices || !Array.isArray(config.upsDevices)) { + logger.error('Legacy single-UPS configuration detected. Cannot delete UPS.'); + logger.log('Use "nupst add" to migrate to multi-UPS configuration format first.'); + return; + } + + // Find the UPS to delete + const upsIndex = config.upsDevices.findIndex(ups => ups.id === upsId); + if (upsIndex === -1) { + logger.error(`UPS with ID "${upsId}" not found.`); + return; + } + + const upsToDelete = config.upsDevices[upsIndex]; + + // Get confirmation before deleting + const readline = await import('readline'); + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + const confirm = await new Promise(resolve => { + rl.question(`Are you sure you want to delete UPS "${upsToDelete.name}" (${upsId})? [y/N]: `, answer => { + resolve(answer.toLowerCase()); + }); + }); + + rl.close(); + + if (confirm !== 'y' && confirm !== 'yes') { + logger.log('Deletion cancelled.'); + return; + } + + // Remove the UPS from the array + config.upsDevices.splice(upsIndex, 1); + + // Save the configuration + await this.nupst.getDaemon().saveConfig(config); + + logger.log(`UPS "${upsToDelete.name}" (${upsId}) has been deleted.`); + + // Check if service is running and restart it if needed + await this.restartServiceIfRunning(); + } catch (error) { + logger.error(`Failed to delete UPS: ${error.message}`); + } + } + + /** + * Interactive setup for configuring SNMP settings (alias for edit) */ private async setup(): Promise { try { @@ -515,7 +1083,8 @@ Options: }; try { - await this.runSetupProcess(prompt); + // Setup is now an alias for edit with no UPS ID (for backward compatibility) + await this.runEditProcess(undefined, prompt); } finally { rl.close(); } @@ -523,108 +1092,892 @@ Options: logger.error(`Setup error: ${error.message}`); } } + + /** + * List all UPS groups + */ + private async groupList(): Promise { + try { + // Try to load configuration + try { + await this.nupst.getDaemon().loadConfig(); + } catch (error) { + const errorBoxWidth = 45; + logger.logBoxTitle('Configuration Error', errorBoxWidth); + logger.logBoxLine('No configuration found.'); + logger.logBoxLine("Please run 'nupst setup' first to create a configuration."); + logger.logBoxEnd(); + return; + } + + // Get current configuration + const config = this.nupst.getDaemon().getConfig(); + + // Check if multi-UPS config + if (!config.groups || !Array.isArray(config.groups)) { + // Legacy or missing groups configuration + const boxWidth = 45; + logger.logBoxTitle('UPS Groups', boxWidth); + logger.logBoxLine('No groups configured.'); + logger.logBoxLine('Use "nupst group add" to add a UPS group.'); + logger.logBoxEnd(); + return; + } + + // Display group list + const boxWidth = 60; + logger.logBoxTitle('UPS Groups', boxWidth); + + if (config.groups.length === 0) { + logger.logBoxLine('No UPS groups configured.'); + logger.logBoxLine('Use "nupst group add" to add a UPS group.'); + } else { + logger.logBoxLine(`Found ${config.groups.length} group(s)`); + logger.logBoxLine(''); + logger.logBoxLine('ID | Name | Mode | UPS Devices'); + logger.logBoxLine('-----------+----------------------+--------------+----------------'); + + for (const group of config.groups) { + const id = group.id.padEnd(10, ' ').substring(0, 10); + const name = (group.name || '').padEnd(20, ' ').substring(0, 20); + const mode = (group.mode || 'unknown').padEnd(12, ' ').substring(0, 12); + + // Count UPS devices in this group + const upsInGroup = config.upsDevices.filter(ups => ups.groups.includes(group.id)); + const upsCount = upsInGroup.length; + const upsNames = upsInGroup.map(ups => ups.name).join(', '); + + logger.logBoxLine(`${id} | ${name} | ${mode} | ${upsCount > 0 ? upsNames : 'None'}`); + } + } + + logger.logBoxEnd(); + } catch (error) { + logger.error(`Failed to list UPS groups: ${error.message}`); + } + } + + /** + * Add a new UPS group + */ + private async groupAdd(): Promise { + try { + // Import readline module for user input + const readline = await import('readline'); + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + // Helper function to prompt for input + const prompt = (question: string): Promise => { + return new Promise((resolve) => { + rl.question(question, (answer: string) => { + resolve(answer); + }); + }); + }; + + try { + // Try to load configuration + try { + await this.nupst.getDaemon().loadConfig(); + } catch (error) { + logger.error('No configuration found. Please run "nupst setup" first to create a configuration.'); + return; + } + + // Get current configuration + const config = this.nupst.getDaemon().getConfig(); + + // Initialize groups array if not exists + if (!config.groups) { + config.groups = []; + } + + // Check if upsDevices is initialized + if (!config.upsDevices) { + config.upsDevices = []; + } + + logger.log('\nNUPST Add Group'); + logger.log('==============\n'); + logger.log('This will guide you through creating a new UPS group.\n'); + + // Generate a new unique group ID + const groupId = helpers.shortId(); + + // Get group name + const name = await prompt('Group Name: '); + + // Get group mode + const modeInput = await prompt('Group Mode (redundant/nonRedundant) [redundant]: '); + const mode = modeInput.toLowerCase() === 'nonredundant' ? 'nonRedundant' : 'redundant'; + + // Get optional description + const description = await prompt('Group Description (optional): '); + + // Create the new group + const newGroup: IGroupConfig = { + id: groupId, + name: name || `Group-${groupId}`, + mode, + description: description || undefined + }; + + // Add the group to the configuration + config.groups.push(newGroup); + + // Save the configuration + await this.nupst.getDaemon().saveConfig(config); + + // Display summary + const boxWidth = 45; + logger.logBoxTitle('Group Created', boxWidth); + logger.logBoxLine(`ID: ${newGroup.id}`); + logger.logBoxLine(`Name: ${newGroup.name}`); + logger.logBoxLine(`Mode: ${newGroup.mode}`); + if (newGroup.description) { + logger.logBoxLine(`Description: ${newGroup.description}`); + } + logger.logBoxEnd(); + + // Check if there are UPS devices to assign to this group + if (config.upsDevices.length > 0) { + const assignUps = await prompt('Would you like to assign UPS devices to this group now? (y/N): '); + if (assignUps.toLowerCase() === 'y') { + await this.assignUpsToGroup(newGroup.id, config, prompt); + } + } + + // Check if service is running and restart it if needed + await this.restartServiceIfRunning(); + + logger.log('\nGroup setup complete!'); + } finally { + rl.close(); + } + } catch (error) { + logger.error(`Add group error: ${error.message}`); + } + } + + /** + * Edit an existing UPS group + * @param groupId ID of the group to edit + */ + private async groupEdit(groupId: string): Promise { + try { + // Import readline module for user input + const readline = await import('readline'); + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + // Helper function to prompt for input + const prompt = (question: string): Promise => { + return new Promise((resolve) => { + rl.question(question, (answer: string) => { + resolve(answer); + }); + }); + }; + + try { + // Try to load configuration + try { + await this.nupst.getDaemon().loadConfig(); + } catch (error) { + logger.error('No configuration found. Please run "nupst setup" first to create a configuration.'); + return; + } + + // Get current configuration + const config = this.nupst.getDaemon().getConfig(); + + // Check if groups are initialized + if (!config.groups || !Array.isArray(config.groups)) { + logger.error('No groups configured. Please run "nupst group add" first to create a group.'); + return; + } + + // Find the group to edit + const groupIndex = config.groups.findIndex(group => group.id === groupId); + if (groupIndex === -1) { + logger.error(`Group with ID "${groupId}" not found.`); + return; + } + + const group = config.groups[groupIndex]; + + logger.log(`\nNUPST Edit Group: ${group.name} (${group.id})`); + logger.log('==============================================\n'); + + // Edit group name + const newName = await prompt(`Group Name [${group.name}]: `); + if (newName.trim()) { + group.name = newName; + } + + // Edit group mode + const currentMode = group.mode || 'redundant'; + const modeInput = await prompt(`Group Mode (redundant/nonRedundant) [${currentMode}]: `); + if (modeInput.trim()) { + group.mode = modeInput.toLowerCase() === 'nonredundant' ? 'nonRedundant' : 'redundant'; + } + + // Edit description + const currentDesc = group.description || ''; + const newDesc = await prompt(`Group Description [${currentDesc}]: `); + if (newDesc.trim() || newDesc === '') { + group.description = newDesc.trim() || undefined; + } + + // Update the group in the configuration + config.groups[groupIndex] = group; + + // Save the configuration + await this.nupst.getDaemon().saveConfig(config); + + // Display summary + const boxWidth = 45; + logger.logBoxTitle('Group Updated', boxWidth); + logger.logBoxLine(`ID: ${group.id}`); + logger.logBoxLine(`Name: ${group.name}`); + logger.logBoxLine(`Mode: ${group.mode}`); + if (group.description) { + logger.logBoxLine(`Description: ${group.description}`); + } + logger.logBoxEnd(); + + // Edit UPS assignments if requested + const editAssignments = await prompt('Would you like to edit UPS assignments for this group? (y/N): '); + if (editAssignments.toLowerCase() === 'y') { + await this.assignUpsToGroup(group.id, config, prompt); + } + + // Check if service is running and restart it if needed + await this.restartServiceIfRunning(); + + logger.log('\nGroup edit complete!'); + } finally { + rl.close(); + } + } catch (error) { + logger.error(`Edit group error: ${error.message}`); + } + } + + /** + * Delete an existing UPS group + * @param groupId ID of the group to delete + */ + private async groupDelete(groupId: string): Promise { + try { + // Try to load configuration + try { + await this.nupst.getDaemon().loadConfig(); + } catch (error) { + logger.error('No configuration found. Please run "nupst setup" first to create a configuration.'); + return; + } + + // Get current configuration + const config = this.nupst.getDaemon().getConfig(); + + // Check if groups are initialized + if (!config.groups || !Array.isArray(config.groups)) { + logger.error('No groups configured.'); + return; + } + + // Find the group to delete + const groupIndex = config.groups.findIndex(group => group.id === groupId); + if (groupIndex === -1) { + logger.error(`Group with ID "${groupId}" not found.`); + return; + } + + const groupToDelete = config.groups[groupIndex]; + + // Get confirmation before deleting + const readline = await import('readline'); + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + const confirm = await new Promise(resolve => { + rl.question(`Are you sure you want to delete group "${groupToDelete.name}" (${groupId})? [y/N]: `, answer => { + resolve(answer.toLowerCase()); + }); + }); + + rl.close(); + + if (confirm !== 'y' && confirm !== 'yes') { + logger.log('Deletion cancelled.'); + return; + } + + // Remove this group from all UPS device group assignments + if (config.upsDevices && Array.isArray(config.upsDevices)) { + for (const ups of config.upsDevices) { + const groupIndex = ups.groups.indexOf(groupId); + if (groupIndex !== -1) { + ups.groups.splice(groupIndex, 1); + } + } + } + + // Remove the group from the array + config.groups.splice(groupIndex, 1); + + // Save the configuration + await this.nupst.getDaemon().saveConfig(config); + + logger.log(`Group "${groupToDelete.name}" (${groupId}) has been deleted.`); + + // Check if service is running and restart it if needed + await this.restartServiceIfRunning(); + } catch (error) { + logger.error(`Failed to delete group: ${error.message}`); + } + } /** - * Run the interactive setup process + * Display help message + */ + private showHelp(): void { + logger.log(` +NUPST - Node.js UPS Shutdown Tool + +Usage: + nupst enable - Install and enable the systemd service (requires root) + nupst disable - Stop and uninstall the systemd service (requires root) + nupst daemon-start - Start the daemon process directly + nupst logs - Show logs of the systemd service + nupst stop - Stop the systemd service + nupst start - Start the systemd service + nupst status - Show status of the systemd service and UPS status + +UPS Management: + nupst add - Add a new UPS device + nupst edit [id] - Edit an existing UPS (default UPS if no ID provided) + nupst delete - 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 - Edit an existing UPS group + nupst group delete - 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 update - Update NUPST from repository and refresh systemd service (requires root) + nupst uninstall - Completely uninstall NUPST from the system (requires root) + nupst help - Show this help message + +Options: + --debug, -d - Enable debug mode for detailed SNMP logging + (Example: nupst test --debug) +`); + } + + /** + * Display help message for group commands + */ + private showGroupHelp(): void { + logger.log(` +NUPST - Group Management Commands + +Usage: + nupst group list - List all UPS groups + nupst group add - Add a new UPS group + nupst group edit - Edit an existing UPS group + nupst group delete - Delete a UPS group + +Options: + --debug, -d - Enable debug mode for detailed logging +`); + } + + /** + * Display the current configuration + */ + private async showConfig(): Promise { + try { + // Try to load configuration + try { + await this.nupst.getDaemon().loadConfig(); + } catch (error) { + const errorBoxWidth = 45; + logger.logBoxTitle('Configuration Error', errorBoxWidth); + logger.logBoxLine('No configuration found.'); + logger.logBoxLine("Please run 'nupst setup' first to create a configuration."); + logger.logBoxEnd(); + return; + } + + // Get current configuration + const config = this.nupst.getDaemon().getConfig(); + + const boxWidth = 50; + logger.logBoxTitle('NUPST Configuration', boxWidth); + + // Check if multi-UPS config + if (config.upsDevices && Array.isArray(config.upsDevices)) { + // Multi-UPS configuration + logger.logBoxLine(`UPS Devices: ${config.upsDevices.length}`); + logger.logBoxLine(`Groups: ${config.groups ? config.groups.length : 0}`); + logger.logBoxLine(`Check Interval: ${config.checkInterval / 1000} seconds`); + logger.logBoxLine(''); + logger.logBoxLine('Configuration File Location:'); + logger.logBoxLine(' /etc/nupst/config.json'); + logger.logBoxEnd(); + + // Show UPS devices + if (config.upsDevices.length > 0) { + logger.logBoxTitle('UPS Devices', boxWidth); + for (const ups of config.upsDevices) { + logger.logBoxLine(`${ups.name} (${ups.id}):`); + logger.logBoxLine(` Host: ${ups.snmp.host}:${ups.snmp.port}`); + logger.logBoxLine(` Model: ${ups.snmp.upsModel}`); + logger.logBoxLine(` Thresholds: ${ups.thresholds.battery}% battery, ${ups.thresholds.runtime} min runtime`); + logger.logBoxLine(` Groups: ${ups.groups.length > 0 ? ups.groups.join(', ') : 'None'}`); + logger.logBoxLine(''); + } + logger.logBoxEnd(); + } + + // Show groups + if (config.groups && config.groups.length > 0) { + logger.logBoxTitle('UPS Groups', boxWidth); + for (const group of config.groups) { + logger.logBoxLine(`${group.name} (${group.id}):`); + logger.logBoxLine(` Mode: ${group.mode}`); + if (group.description) { + logger.logBoxLine(` Description: ${group.description}`); + } + + // List UPS devices in this group + const upsInGroup = config.upsDevices.filter(ups => ups.groups.includes(group.id)); + logger.logBoxLine(` UPS Devices: ${upsInGroup.length > 0 ? + upsInGroup.map(ups => ups.name).join(', ') : 'None'}`); + logger.logBoxLine(''); + } + logger.logBoxEnd(); + } + } else { + // Legacy single UPS configuration + // SNMP Settings + logger.logBoxLine('SNMP Settings:'); + logger.logBoxLine(` Host: ${config.snmp.host}`); + logger.logBoxLine(` Port: ${config.snmp.port}`); + logger.logBoxLine(` Version: ${config.snmp.version}`); + logger.logBoxLine(` UPS Model: ${config.snmp.upsModel || 'cyberpower'}`); + + if (config.snmp.version === 1 || config.snmp.version === 2) { + logger.logBoxLine(` Community: ${config.snmp.community}`); + } else if (config.snmp.version === 3) { + logger.logBoxLine(` Security Level: ${config.snmp.securityLevel}`); + logger.logBoxLine(` Username: ${config.snmp.username}`); + + // Show auth and privacy details based on security level + if ( + config.snmp.securityLevel === 'authNoPriv' || + config.snmp.securityLevel === 'authPriv' + ) { + logger.logBoxLine(` Auth Protocol: ${config.snmp.authProtocol || 'None'}`); + } + + if (config.snmp.securityLevel === 'authPriv') { + logger.logBoxLine(` Privacy Protocol: ${config.snmp.privProtocol || 'None'}`); + } + + // Show timeout value + logger.logBoxLine(` Timeout: ${config.snmp.timeout / 1000} seconds`); + } + + // Show OIDs if custom model is selected + if (config.snmp.upsModel === 'custom' && config.snmp.customOIDs) { + logger.logBoxLine('Custom OIDs:'); + logger.logBoxLine(` Power Status: ${config.snmp.customOIDs.POWER_STATUS || 'Not set'}`); + logger.logBoxLine( + ` Battery Capacity: ${config.snmp.customOIDs.BATTERY_CAPACITY || 'Not set'}` + ); + logger.logBoxLine(` Battery Runtime: ${config.snmp.customOIDs.BATTERY_RUNTIME || 'Not set'}`); + } + + // Thresholds + logger.logBoxLine('Thresholds:'); + logger.logBoxLine(` Battery: ${config.thresholds.battery}%`); + logger.logBoxLine(` Runtime: ${config.thresholds.runtime} minutes`); + logger.logBoxLine(`Check Interval: ${config.checkInterval / 1000} seconds`); + + // Configuration file location + logger.logBoxLine(''); + logger.logBoxLine('Configuration File Location:'); + logger.logBoxLine(' /etc/nupst/config.json'); + logger.logBoxLine(''); + logger.logBoxLine('Note: Using legacy single-UPS configuration format.'); + logger.logBoxLine('Consider using "nupst add" to migrate to multi-UPS format.'); + + logger.logBoxEnd(); + } + + // Show service status + try { + const isActive = + execSync('systemctl is-active nupst.service || true').toString().trim() === 'active'; + const isEnabled = + execSync('systemctl is-enabled nupst.service || true').toString().trim() === 'enabled'; + + const statusBoxWidth = 45; + logger.logBoxTitle('Service Status', statusBoxWidth); + logger.logBoxLine(`Service Active: ${isActive ? 'Yes' : 'No'}`); + logger.logBoxLine(`Service Enabled: ${isEnabled ? 'Yes' : 'No'}`); + logger.logBoxEnd(); + } catch (error) { + // Ignore errors checking service status + } + } catch (error) { + logger.error(`Failed to display configuration: ${error.message}`); + } + } + + /** + * Generate a unique UPS ID + * @param prompt Function to prompt for user input + * @param existingUpsDevices Array of existing UPS devices + * @returns Unique UPS ID + */ + private async promptForUniqueUpsId( + prompt: (question: string) => Promise, + existingUpsDevices: any[] + ): Promise { + const existingIds = existingUpsDevices.map(ups => ups.id); + + // First ask for a custom ID + const customId = await prompt('UPS ID (leave empty for auto-generated): '); + + if (customId.trim()) { + // Check if ID is already in use + if (existingIds.includes(customId.trim())) { + logger.error(`UPS ID "${customId.trim()}" is already in use.`); + // Recursively call this function to try again + return this.promptForUniqueUpsId(prompt, existingUpsDevices); + } + return customId.trim(); + } + + // Generate a unique ID with timestamp + const timestamp = new Date().getTime().toString(36); + const randomPart = Math.floor(Math.random() * 1000).toString(36); + return `ups-${timestamp}-${randomPart}`; + } + + /** + * Generate a unique group ID + * @param prompt Function to prompt for user input + * @param existingGroups Array of existing groups + * @returns Unique group ID + */ + private async promptForUniqueGroupId( + prompt: (question: string) => Promise, + existingGroups: any[] + ): Promise { + const existingIds = existingGroups.map(group => group.id); + + // First ask for a custom ID + const customId = await prompt('Group ID (leave empty for auto-generated): '); + + if (customId.trim()) { + // Check if ID is already in use + if (existingIds.includes(customId.trim())) { + logger.error(`Group ID "${customId.trim()}" is already in use.`); + // Recursively call this function to try again + return this.promptForUniqueGroupId(prompt, existingGroups); + } + return customId.trim(); + } + + // Generate a unique ID with timestamp + const timestamp = new Date().getTime().toString(36); + const randomPart = Math.floor(Math.random() * 1000).toString(36); + return `group-${timestamp}-${randomPart}`; + } + + /** + * Display UPS configuration summary + * @param ups UPS configuration + */ + private displayUpsConfigSummary(ups: any): void { + const boxWidth = 45; + logger.log(''); + logger.logBoxTitle(`UPS Configuration: ${ups.name}`, boxWidth); + logger.logBoxLine(`UPS ID: ${ups.id}`); + logger.logBoxLine(`SNMP Host: ${ups.snmp.host}:${ups.snmp.port}`); + logger.logBoxLine(`SNMP Version: ${ups.snmp.version}`); + logger.logBoxLine(`UPS Model: ${ups.snmp.upsModel}`); + logger.logBoxLine( + `Thresholds: ${ups.thresholds.battery}% battery, ${ups.thresholds.runtime} min runtime` + ); + if (ups.groups && ups.groups.length > 0) { + logger.logBoxLine(`Groups: ${ups.groups.join(', ')}`); + } else { + logger.logBoxLine('Groups: None'); + } + logger.logBoxEnd(); + logger.log(''); + } + + /** + * Assign UPS devices to groups + * @param ups UPS configuration to update + * @param groups Available groups + * @param prompt Function to prompt for user input + */ + private async assignUpsToGroups( + ups: any, + groups: any[], + prompt: (question: string) => Promise + ): Promise { + // Show current group assignments + logger.log('\nCurrent Group Assignments:'); + if (ups.groups && ups.groups.length > 0) { + for (const groupId of ups.groups) { + const group = groups.find(g => g.id === groupId); + if (group) { + logger.log(`- ${group.name} (${group.id})`); + } else { + logger.log(`- Unknown group (${groupId})`); + } + } + } else { + logger.log('- None'); + } + + // Show available groups + logger.log('\nAvailable Groups:'); + if (groups.length === 0) { + logger.log('- No groups available. Use "nupst group add" to create groups.'); + return; + } + + for (let i = 0; i < groups.length; i++) { + const group = groups[i]; + const assigned = ups.groups && ups.groups.includes(group.id); + logger.log(`${i + 1}) ${group.name} (${group.id}) [${assigned ? 'Assigned' : 'Not Assigned'}]`); + } + + // Prompt for group selection + const selection = await prompt('\nSelect groups to assign/unassign (comma-separated numbers, or "clear" to remove all): '); + + if (selection.toLowerCase() === 'clear') { + // Clear all group assignments + ups.groups = []; + logger.log('All group assignments cleared.'); + return; + } + + if (!selection.trim()) { + // No change if empty input + return; + } + + // Process selections + const selections = selection.split(',').map(s => s.trim()); + + for (const sel of selections) { + const index = parseInt(sel, 10) - 1; + if (isNaN(index) || index < 0 || index >= groups.length) { + logger.error(`Invalid selection: ${sel}`); + continue; + } + + const group = groups[index]; + + // Toggle assignment + if (!ups.groups) { + ups.groups = []; + } + + const groupIndex = ups.groups.indexOf(group.id); + if (groupIndex === -1) { + // Add to group + ups.groups.push(group.id); + logger.log(`Added to group: ${group.name} (${group.id})`); + } else { + // Remove from group + ups.groups.splice(groupIndex, 1); + logger.log(`Removed from group: ${group.name} (${group.id})`); + } + } + } + + /** + * Assign UPS devices to a specific group + * @param groupId Group ID to assign UPS devices to + * @param config Full configuration + * @param prompt Function to prompt for user input + */ + private async assignUpsToGroup( + groupId: string, + config: any, + prompt: (question: string) => Promise + ): Promise { + if (!config.upsDevices || config.upsDevices.length === 0) { + logger.log('No UPS devices available. Use "nupst add" to add UPS devices.'); + return; + } + + const group = config.groups.find(g => g.id === groupId); + if (!group) { + logger.error(`Group with ID "${groupId}" not found.`); + return; + } + + // Show current assignments + logger.log(`\nUPS devices in group "${group.name}" (${group.id}):`); + const upsInGroup = config.upsDevices.filter(ups => ups.groups && ups.groups.includes(groupId)); + if (upsInGroup.length === 0) { + logger.log('- None'); + } else { + for (const ups of upsInGroup) { + logger.log(`- ${ups.name} (${ups.id})`); + } + } + + // Show all UPS devices + logger.log('\nAvailable UPS devices:'); + for (let i = 0; i < config.upsDevices.length; i++) { + const ups = config.upsDevices[i]; + const assigned = ups.groups && ups.groups.includes(groupId); + logger.log(`${i + 1}) ${ups.name} (${ups.id}) [${assigned ? 'Assigned' : 'Not Assigned'}]`); + } + + // Prompt for UPS selection + const selection = await prompt('\nSelect UPS devices to assign/unassign (comma-separated numbers, or "clear" to remove all): '); + + if (selection.toLowerCase() === 'clear') { + // Clear all UPS from this group + for (const ups of config.upsDevices) { + if (ups.groups) { + const groupIndex = ups.groups.indexOf(groupId); + if (groupIndex !== -1) { + ups.groups.splice(groupIndex, 1); + } + } + } + logger.log(`All UPS devices removed from group "${group.name}".`); + return; + } + + if (!selection.trim()) { + // No change if empty input + return; + } + + // Process selections + const selections = selection.split(',').map(s => s.trim()); + + for (const sel of selections) { + const index = parseInt(sel, 10) - 1; + if (isNaN(index) || index < 0 || index >= config.upsDevices.length) { + logger.error(`Invalid selection: ${sel}`); + continue; + } + + const ups = config.upsDevices[index]; + + // Initialize groups array if needed + if (!ups.groups) { + ups.groups = []; + } + + // Toggle assignment + const groupIndex = ups.groups.indexOf(groupId); + if (groupIndex === -1) { + // Add to group + ups.groups.push(groupId); + logger.log(`Added "${ups.name}" to group "${group.name}"`); + } else { + // Remove from group + ups.groups.splice(groupIndex, 1); + logger.log(`Removed "${ups.name}" from group "${group.name}"`); + } + } + } + + /** + * Run the interactive setup process (legacy version, now replaced by edit) * @param prompt Function to prompt for user input */ private async runSetupProcess(prompt: (question: string) => Promise): Promise { - logger.log('\nNUPST Interactive Setup'); - logger.log('======================\n'); - logger.log('This will guide you through configuring your UPS SNMP settings.\n'); - - // Try to load existing config if available - let config; - try { - await this.nupst.getDaemon().loadConfig(); - config = this.nupst.getDaemon().getConfig(); - } catch (error) { - // If config doesn't exist, use default config - config = this.nupst.getDaemon().getConfig(); - logger.log('No existing configuration found. Creating a new configuration.'); - } - - // Gather SNMP settings - config = await this.gatherSnmpSettings(config, prompt); - - // Gather threshold settings - config = await this.gatherThresholdSettings(config, prompt); - - // Gather UPS model settings - config = await this.gatherUpsModelSettings(config, prompt); - - // Save the configuration - await this.nupst.getDaemon().saveConfig(config); - - this.displayConfigSummary(config); - - // Test the connection if requested - await this.optionallyTestConnection(config, prompt); - - // Check if service is running and restart it if needed - await this.restartServiceIfRunning(); - - logger.log('\nSetup complete!'); - await this.optionallyEnableService(prompt); + // For backward compatibility, just call runEditProcess with no UPS ID + await this.runEditProcess(undefined, prompt); } /** * Gather SNMP settings - * @param config Current configuration + * @param snmpConfig SNMP configuration object to update * @param prompt Function to prompt for user input - * @returns Updated configuration */ private async gatherSnmpSettings( - config: any, + snmpConfig: any, prompt: (question: string) => Promise - ): Promise { + ): Promise { // SNMP IP Address - const defaultHost = config.snmp.host; + const defaultHost = snmpConfig.host || '127.0.0.1'; const host = await prompt(`UPS IP Address [${defaultHost}]: `); - config.snmp.host = host.trim() || defaultHost; + snmpConfig.host = host.trim() || defaultHost; // SNMP Port - const defaultPort = config.snmp.port; + const defaultPort = snmpConfig.port || 161; const portInput = await prompt(`SNMP Port [${defaultPort}]: `); const port = parseInt(portInput, 10); - config.snmp.port = portInput.trim() && !isNaN(port) ? port : defaultPort; + snmpConfig.port = portInput.trim() && !isNaN(port) ? port : defaultPort; // SNMP Version - const defaultVersion = config.snmp.version; + const defaultVersion = snmpConfig.version || 1; console.log('\nSNMP Version:'); console.log(' 1) SNMPv1'); console.log(' 2) SNMPv2c'); console.log(' 3) SNMPv3 (with security features)'); const versionInput = await prompt(`Select SNMP version [${defaultVersion}]: `); const version = parseInt(versionInput, 10); - config.snmp.version = + snmpConfig.version = versionInput.trim() && (version === 1 || version === 2 || version === 3) ? version : defaultVersion; - if (config.snmp.version === 1 || config.snmp.version === 2) { + if (snmpConfig.version === 1 || snmpConfig.version === 2) { // SNMP Community String (for v1/v2c) - const defaultCommunity = config.snmp.community || 'public'; + const defaultCommunity = snmpConfig.community || 'public'; const community = await prompt(`SNMP Community String [${defaultCommunity}]: `); - config.snmp.community = community.trim() || defaultCommunity; - } else if (config.snmp.version === 3) { + snmpConfig.community = community.trim() || defaultCommunity; + } else if (snmpConfig.version === 3) { // SNMP v3 settings - config = await this.gatherSnmpV3Settings(config, prompt); + await this.gatherSnmpV3Settings(snmpConfig, prompt); } - - return config; } /** * Gather SNMPv3 specific settings - * @param config Current configuration + * @param snmpConfig SNMP configuration object to update * @param prompt Function to prompt for user input - * @returns Updated configuration */ private async gatherSnmpV3Settings( - config: any, + snmpConfig: any, prompt: (question: string) => Promise - ): Promise { + ): Promise { console.log('\nSNMPv3 Security Settings:'); // Security Level @@ -632,10 +1985,10 @@ Options: console.log(' 1) noAuthNoPriv (No Authentication, No Privacy)'); console.log(' 2) authNoPriv (Authentication, No Privacy)'); console.log(' 3) authPriv (Authentication and Privacy)'); - const defaultSecLevel = config.snmp.securityLevel - ? config.snmp.securityLevel === 'noAuthNoPriv' + const defaultSecLevel = snmpConfig.securityLevel + ? snmpConfig.securityLevel === 'noAuthNoPriv' ? 1 - : config.snmp.securityLevel === 'authNoPriv' + : snmpConfig.securityLevel === 'authNoPriv' ? 2 : 3 : 3; @@ -643,168 +1996,147 @@ Options: const secLevel = parseInt(secLevelInput, 10) || defaultSecLevel; if (secLevel === 1) { - config.snmp.securityLevel = 'noAuthNoPriv'; + snmpConfig.securityLevel = 'noAuthNoPriv'; // No auth, no priv - clear out authentication and privacy settings - config.snmp.authProtocol = ''; - config.snmp.authKey = ''; - config.snmp.privProtocol = ''; - config.snmp.privKey = ''; + snmpConfig.authProtocol = ''; + snmpConfig.authKey = ''; + snmpConfig.privProtocol = ''; + snmpConfig.privKey = ''; // Set appropriate timeout for security level - config.snmp.timeout = 5000; // 5 seconds for basic security + snmpConfig.timeout = 5000; // 5 seconds for basic security } else if (secLevel === 2) { - config.snmp.securityLevel = 'authNoPriv'; + snmpConfig.securityLevel = 'authNoPriv'; // Auth, no priv - clear out privacy settings - config.snmp.privProtocol = ''; - config.snmp.privKey = ''; + snmpConfig.privProtocol = ''; + snmpConfig.privKey = ''; // Set appropriate timeout for security level - config.snmp.timeout = 10000; // 10 seconds for authentication + snmpConfig.timeout = 10000; // 10 seconds for authentication } else { - config.snmp.securityLevel = 'authPriv'; + snmpConfig.securityLevel = 'authPriv'; // Set appropriate timeout for security level - config.snmp.timeout = 15000; // 15 seconds for full encryption + snmpConfig.timeout = 15000; // 15 seconds for full encryption } // Username - const defaultUsername = config.snmp.username || ''; + const defaultUsername = snmpConfig.username || ''; const username = await prompt(`SNMPv3 Username [${defaultUsername}]: `); - config.snmp.username = username.trim() || defaultUsername; + snmpConfig.username = username.trim() || defaultUsername; if (secLevel >= 2) { // Authentication settings - config = await this.gatherAuthenticationSettings(config, prompt); + await this.gatherAuthenticationSettings(snmpConfig, prompt); if (secLevel === 3) { // Privacy settings - config = await this.gatherPrivacySettings(config, prompt); + await this.gatherPrivacySettings(snmpConfig, prompt); } // Allow customizing the timeout value - const defaultTimeout = config.snmp.timeout / 1000; // Convert from ms to seconds for display + const defaultTimeout = snmpConfig.timeout / 1000; // Convert from ms to seconds for display console.log( '\nSNMPv3 operations with authentication and privacy may require longer timeouts.' ); const timeoutInput = await prompt(`SNMP Timeout in seconds [${defaultTimeout}]: `); const timeout = parseInt(timeoutInput, 10); if (timeoutInput.trim() && !isNaN(timeout)) { - config.snmp.timeout = timeout * 1000; // Convert to ms + snmpConfig.timeout = timeout * 1000; // Convert to ms } } - - return config; } /** * Gather authentication settings for SNMPv3 - * @param config Current configuration + * @param snmpConfig SNMP configuration object to update * @param prompt Function to prompt for user input - * @returns Updated configuration */ private async gatherAuthenticationSettings( - config: any, + snmpConfig: any, prompt: (question: string) => Promise - ): Promise { + ): Promise { // Authentication protocol console.log('\nAuthentication Protocol:'); console.log(' 1) MD5'); console.log(' 2) SHA'); - const defaultAuthProtocol = config.snmp.authProtocol === 'SHA' ? 2 : 1; + const defaultAuthProtocol = snmpConfig.authProtocol === 'SHA' ? 2 : 1; const authProtocolInput = await prompt( `Select Authentication Protocol [${defaultAuthProtocol}]: ` ); const authProtocol = parseInt(authProtocolInput, 10) || defaultAuthProtocol; - config.snmp.authProtocol = authProtocol === 2 ? 'SHA' : 'MD5'; + snmpConfig.authProtocol = authProtocol === 2 ? 'SHA' : 'MD5'; // Authentication Key/Password - const defaultAuthKey = config.snmp.authKey || ''; + const defaultAuthKey = snmpConfig.authKey || ''; const authKey = await prompt(`Authentication Password ${defaultAuthKey ? '[*****]' : ''}: `); - config.snmp.authKey = authKey.trim() || defaultAuthKey; - - return config; + snmpConfig.authKey = authKey.trim() || defaultAuthKey; } /** * Gather privacy settings for SNMPv3 - * @param config Current configuration + * @param snmpConfig SNMP configuration object to update * @param prompt Function to prompt for user input - * @returns Updated configuration */ private async gatherPrivacySettings( - config: any, + snmpConfig: any, prompt: (question: string) => Promise - ): Promise { + ): Promise { // Privacy protocol console.log('\nPrivacy Protocol:'); console.log(' 1) DES'); console.log(' 2) AES'); - const defaultPrivProtocol = config.snmp.privProtocol === 'AES' ? 2 : 1; + const defaultPrivProtocol = snmpConfig.privProtocol === 'AES' ? 2 : 1; const privProtocolInput = await prompt(`Select Privacy Protocol [${defaultPrivProtocol}]: `); const privProtocol = parseInt(privProtocolInput, 10) || defaultPrivProtocol; - config.snmp.privProtocol = privProtocol === 2 ? 'AES' : 'DES'; + snmpConfig.privProtocol = privProtocol === 2 ? 'AES' : 'DES'; // Privacy Key/Password - const defaultPrivKey = config.snmp.privKey || ''; + const defaultPrivKey = snmpConfig.privKey || ''; const privKey = await prompt(`Privacy Password ${defaultPrivKey ? '[*****]' : ''}: `); - config.snmp.privKey = privKey.trim() || defaultPrivKey; - - return config; + snmpConfig.privKey = privKey.trim() || defaultPrivKey; } /** * Gather threshold settings - * @param config Current configuration + * @param thresholds Thresholds configuration object to update * @param prompt Function to prompt for user input - * @returns Updated configuration */ private async gatherThresholdSettings( - config: any, + thresholds: any, prompt: (question: string) => Promise - ): Promise { + ): Promise { console.log('\nShutdown Thresholds:'); // Battery threshold - const defaultBatteryThreshold = config.thresholds.battery; + const defaultBatteryThreshold = thresholds.battery || 60; const batteryThresholdInput = await prompt( `Battery percentage threshold [${defaultBatteryThreshold}%]: ` ); const batteryThreshold = parseInt(batteryThresholdInput, 10); - config.thresholds.battery = + thresholds.battery = batteryThresholdInput.trim() && !isNaN(batteryThreshold) ? batteryThreshold : defaultBatteryThreshold; // Runtime threshold - const defaultRuntimeThreshold = config.thresholds.runtime; + const defaultRuntimeThreshold = thresholds.runtime || 20; const runtimeThresholdInput = await prompt( `Runtime minutes threshold [${defaultRuntimeThreshold} minutes]: ` ); const runtimeThreshold = parseInt(runtimeThresholdInput, 10); - config.thresholds.runtime = + thresholds.runtime = runtimeThresholdInput.trim() && !isNaN(runtimeThreshold) ? runtimeThreshold : defaultRuntimeThreshold; - - // Check interval - const defaultInterval = config.checkInterval / 1000; // Convert from ms to seconds for display - const intervalInput = await prompt(`Check interval in seconds [${defaultInterval}]: `); - const interval = parseInt(intervalInput, 10); - config.checkInterval = - intervalInput.trim() && !isNaN(interval) - ? interval * 1000 // Convert to ms - : defaultInterval * 1000; - - return config; } /** * Gather UPS model settings - * @param config Current configuration + * @param snmpConfig SNMP configuration object to update * @param prompt Function to prompt for user input - * @returns Updated configuration */ private async gatherUpsModelSettings( - config: any, + snmpConfig: any, prompt: (question: string) => Promise - ): Promise { + ): Promise { console.log('\nUPS Model Selection:'); console.log(' 1) CyberPower'); console.log(' 2) APC'); @@ -814,17 +2146,17 @@ Options: console.log(' 6) Custom (Advanced)'); const defaultModelValue = - config.snmp.upsModel === 'cyberpower' + snmpConfig.upsModel === 'cyberpower' ? 1 - : config.snmp.upsModel === 'apc' + : snmpConfig.upsModel === 'apc' ? 2 - : config.snmp.upsModel === 'eaton' + : snmpConfig.upsModel === 'eaton' ? 3 - : config.snmp.upsModel === 'tripplite' + : snmpConfig.upsModel === 'tripplite' ? 4 - : config.snmp.upsModel === 'liebert' + : snmpConfig.upsModel === 'liebert' ? 5 - : config.snmp.upsModel === 'custom' + : snmpConfig.upsModel === 'custom' ? 6 : 1; @@ -832,17 +2164,17 @@ Options: const modelValue = parseInt(modelInput, 10) || defaultModelValue; if (modelValue === 1) { - config.snmp.upsModel = 'cyberpower'; + snmpConfig.upsModel = 'cyberpower'; } else if (modelValue === 2) { - config.snmp.upsModel = 'apc'; + snmpConfig.upsModel = 'apc'; } else if (modelValue === 3) { - config.snmp.upsModel = 'eaton'; + snmpConfig.upsModel = 'eaton'; } else if (modelValue === 4) { - config.snmp.upsModel = 'tripplite'; + snmpConfig.upsModel = 'tripplite'; } else if (modelValue === 5) { - config.snmp.upsModel = 'liebert'; + snmpConfig.upsModel = 'liebert'; } else if (modelValue === 6) { - config.snmp.upsModel = 'custom'; + snmpConfig.upsModel = 'custom'; console.log('\nEnter custom OIDs for your UPS:'); console.log('(Leave blank to use standard RFC 1628 OIDs as fallback)'); @@ -852,42 +2184,21 @@ Options: const batteryRuntimeOID = await prompt('Battery Runtime OID: '); // Create custom OIDs object - config.snmp.customOIDs = { + snmpConfig.customOIDs = { POWER_STATUS: powerStatusOID.trim(), BATTERY_CAPACITY: batteryCapacityOID.trim(), BATTERY_RUNTIME: batteryRuntimeOID.trim(), }; } - - return config; - } - - /** - * Display configuration summary - * @param config Current configuration - */ - private displayConfigSummary(config: any): void { - const boxWidth = 45; - logger.log(''); - logger.logBoxTitle('Configuration Summary', boxWidth); - logger.logBoxLine(`SNMP Host: ${config.snmp.host}:${config.snmp.port}`); - logger.logBoxLine(`SNMP Version: ${config.snmp.version}`); - logger.logBoxLine(`UPS Model: ${config.snmp.upsModel}`); - logger.logBoxLine( - `Thresholds: ${config.thresholds.battery}% battery, ${config.thresholds.runtime} min runtime` - ); - logger.logBoxLine(`Check Interval: ${config.checkInterval / 1000} seconds`); - logger.logBoxEnd(); - logger.log(''); } /** * Optionally test connection to UPS - * @param config Current configuration + * @param snmpConfig SNMP configuration to test * @param prompt Function to prompt for user input */ private async optionallyTestConnection( - config: any, + snmpConfig: any, prompt: (question: string) => Promise ): Promise { const testConnection = await prompt( @@ -898,8 +2209,8 @@ Options: try { // Create a test config with a short timeout const testConfig = { - ...config.snmp, - timeout: Math.min(config.snmp.timeout, 10000), // Use at most 10 seconds for testing + ...snmpConfig, + timeout: Math.min(snmpConfig.timeout, 10000), // Use at most 10 seconds for testing }; const status = await this.nupst.getSnmp().getUpsStatus(testConfig); @@ -996,192 +2307,4 @@ Options: } } } - - /** - * Display the current configuration - */ - private async showConfig(): Promise { - try { - // Try to load configuration - try { - await this.nupst.getDaemon().loadConfig(); - } catch (error) { - const errorBoxWidth = 45; - logger.logBoxTitle('Configuration Error', errorBoxWidth); - logger.logBoxLine('No configuration found.'); - logger.logBoxLine("Please run 'nupst setup' first to create a configuration."); - logger.logBoxEnd(); - return; - } - - // Get current configuration - const config = this.nupst.getDaemon().getConfig(); - - const boxWidth = 50; - logger.logBoxTitle('NUPST Configuration', boxWidth); - - // SNMP Settings - logger.logBoxLine('SNMP Settings:'); - logger.logBoxLine(` Host: ${config.snmp.host}`); - logger.logBoxLine(` Port: ${config.snmp.port}`); - logger.logBoxLine(` Version: ${config.snmp.version}`); - logger.logBoxLine(` UPS Model: ${config.snmp.upsModel || 'cyberpower'}`); - - if (config.snmp.version === 1 || config.snmp.version === 2) { - logger.logBoxLine(` Community: ${config.snmp.community}`); - } else if (config.snmp.version === 3) { - logger.logBoxLine(` Security Level: ${config.snmp.securityLevel}`); - logger.logBoxLine(` Username: ${config.snmp.username}`); - - // Show auth and privacy details based on security level - if ( - config.snmp.securityLevel === 'authNoPriv' || - config.snmp.securityLevel === 'authPriv' - ) { - logger.logBoxLine(` Auth Protocol: ${config.snmp.authProtocol || 'None'}`); - } - - if (config.snmp.securityLevel === 'authPriv') { - logger.logBoxLine(` Privacy Protocol: ${config.snmp.privProtocol || 'None'}`); - } - - // Show timeout value - logger.logBoxLine(` Timeout: ${config.snmp.timeout / 1000} seconds`); - } - - // Show OIDs if custom model is selected - if (config.snmp.upsModel === 'custom' && config.snmp.customOIDs) { - logger.logBoxLine('Custom OIDs:'); - logger.logBoxLine(` Power Status: ${config.snmp.customOIDs.POWER_STATUS || 'Not set'}`); - logger.logBoxLine( - ` Battery Capacity: ${config.snmp.customOIDs.BATTERY_CAPACITY || 'Not set'}` - ); - logger.logBoxLine(` Battery Runtime: ${config.snmp.customOIDs.BATTERY_RUNTIME || 'Not set'}`); - } - - // Thresholds - logger.logBoxLine('Thresholds:'); - logger.logBoxLine(` Battery: ${config.thresholds.battery}%`); - logger.logBoxLine(` Runtime: ${config.thresholds.runtime} minutes`); - logger.logBoxLine(`Check Interval: ${config.checkInterval / 1000} seconds`); - - // Configuration file location - logger.logBoxLine(''); - logger.logBoxLine('Configuration File Location:'); - logger.logBoxLine(' /etc/nupst/config.json'); - - logger.logBoxEnd(); - - // Show service status - try { - const isActive = - execSync('systemctl is-active nupst.service || true').toString().trim() === 'active'; - const isEnabled = - execSync('systemctl is-enabled nupst.service || true').toString().trim() === 'enabled'; - - const statusBoxWidth = 45; - logger.logBoxTitle('Service Status', statusBoxWidth); - logger.logBoxLine(`Service Active: ${isActive ? 'Yes' : 'No'}`); - logger.logBoxLine(`Service Enabled: ${isEnabled ? 'Yes' : 'No'}`); - logger.logBoxEnd(); - } catch (error) { - // Ignore errors checking service status - } - } catch (error) { - logger.error(`Failed to display configuration: ${error.message}`); - } - } - - /** - * Completely uninstall NUPST from the system - */ - private async uninstall(): Promise { - // Check if running as root - this.checkRootAccess('This command must be run as root.'); - - try { - // Import readline module for user input - const readline = await import('readline'); - - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - - // Helper function to prompt for input - const prompt = (question: string): Promise => { - return new Promise((resolve) => { - rl.question(question, (answer: string) => { - resolve(answer); - }); - }); - }; - - console.log('\nNUPST Uninstaller'); - console.log('==============='); - console.log('This will completely remove NUPST from your system.\n'); - - // Ask about removing configuration - const removeConfig = await prompt( - 'Do you want to remove the NUPST configuration files? (y/N): ' - ); - - // Find the uninstall.sh script location - let uninstallScriptPath: string; - - // Try to determine script location based on executable path - try { - // For ESM, we can use import.meta.url, but since we might be in CJS - // we'll use a more reliable approach based on process.argv[1] - const binPath = process.argv[1]; - const modulePath = dirname(dirname(binPath)); - uninstallScriptPath = join(modulePath, 'uninstall.sh'); - - // Check if the script exists - await fs.access(uninstallScriptPath); - } catch (error) { - // If we can't find it in the expected location, try common installation paths - const commonPaths = ['/opt/nupst/uninstall.sh', join(process.cwd(), 'uninstall.sh')]; - - for (const path of commonPaths) { - try { - await fs.access(path); - uninstallScriptPath = path; - break; - } catch { - // Continue to next path - } - } - - if (!uninstallScriptPath) { - console.error('Could not locate uninstall.sh script. Aborting uninstall.'); - rl.close(); - process.exit(1); - } - } - - // Close readline before executing script - rl.close(); - - // Execute uninstall.sh with the appropriate option - console.log(`\nRunning uninstaller from ${uninstallScriptPath}...`); - - // Pass the configuration removal option as an environment variable - const env = { - ...process.env, - REMOVE_CONFIG: removeConfig.toLowerCase() === 'y' ? 'yes' : 'no', - REMOVE_REPO: 'yes', // Always remove repo as requested - NUPST_CLI_CALL: 'true', // Flag to indicate this is being called from CLI - }; - - // Run the uninstall script with sudo - execSync(`sudo bash ${uninstallScriptPath}`, { - env, - stdio: 'inherit', // Show output in the terminal - }); - } catch (error) { - console.error(`Uninstall failed: ${error.message}`); - process.exit(1); - } - } -} +} \ No newline at end of file diff --git a/ts/daemon.ts b/ts/daemon.ts index 1c7344a..697ef04 100644 --- a/ts/daemon.ts +++ b/ts/daemon.ts @@ -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 = 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 { 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 { - console.log('┌─ UPS Status ─────────────────────────────┐'); - console.log(`│ Battery: ${status.batteryCapacity}% | Runtime: ${status.batteryRuntime} min`); - console.log('└──────────────────────────────────────────┘'); + private async checkAllUpsDevices(): Promise { + 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 { + 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 { + // 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 { + 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 { + // 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 { 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 { + 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'); + } } /** diff --git a/ts/helpers/index.ts b/ts/helpers/index.ts new file mode 100644 index 0000000..61ed984 --- /dev/null +++ b/ts/helpers/index.ts @@ -0,0 +1 @@ +export * from './shortid.js'; \ No newline at end of file diff --git a/ts/helpers/shortid.ts b/ts/helpers/shortid.ts new file mode 100644 index 0000000..5776bff --- /dev/null +++ b/ts/helpers/shortid.ts @@ -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; +} \ No newline at end of file diff --git a/ts/logger.ts b/ts/logger.ts index 75ab154..ec1059d 100644 --- a/ts/logger.ts +++ b/ts/logger.ts @@ -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)); } } diff --git a/ts/systemd.ts b/ts/systemd.ts index 7f77859..88c7813 100644 --- a/ts/systemd.ts +++ b/ts/systemd.ts @@ -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 { + private async displayAllUpsStatus(): Promise { 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 { + 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