Compare commits

...

19 Commits

Author SHA1 Message Date
806f81c6a0 3.1.2 2025-03-28 22:30:01 +00:00
88e353eec6 fix(cli/ups-handler): Improve UPS device listing table formatting for better column alignment 2025-03-28 22:30:01 +00:00
80ff1b1230 3.1.1 2025-03-28 22:27:21 +00:00
1075335497 fix(cli): Improve table header formatting in group and UPS listings 2025-03-28 22:27:21 +00:00
eafb5207a4 3.1.0 2025-03-28 22:12:01 +00:00
9969e0f703 feat(cli): Refactor CLI commands to use dedicated handlers for UPS, group, and service management 2025-03-28 22:12:01 +00:00
ac4b2c95f3 3.0.1 2025-03-28 16:32:08 +00:00
c593d76ead fix(cli): Simplify UPS ID generation by removing the redundant promptForUniqueUpsId function in the CLI module and replacing it with the shortId helper. 2025-03-28 16:32:08 +00:00
01ccf2d080 3.0.0 2025-03-28 16:19:43 +00:00
0e55f22dad BREAKING CHANGE(core): Add multi-UPS support and group management; update CLI, configuration and documentation to support multiple UPS devices with group modes 2025-03-28 16:19:43 +00:00
bd3042de25 2.6.17 2025-03-26 22:43:19 +00:00
456351ca34 fix(logger): Preserve logbox width after logBoxEnd so that subsequent logBoxLine calls continue using the set width. 2025-03-26 22:43:18 +00:00
00afa317ef 2.6.16 2025-03-26 22:38:24 +00:00
45ee8208b5 fix(cli): Improve CLI logging consistency by replacing direct console output with unified logger calls. 2025-03-26 22:38:24 +00:00
39bf3e2239 2.6.15 2025-03-26 22:28:38 +00:00
f3de3f0618 fix(logger): Replace direct console logging with unified logger interface for consistent formatting 2025-03-26 22:28:38 +00:00
03056d279d update 2025-03-26 22:19:24 +00:00
f860f39e59 2.6.14 2025-03-26 18:15:17 +00:00
fa4516de3b fix(systemd): Shorten closing log divider in systemd service installation output for consistent formatting. 2025-03-26 18:15:17 +00:00
16 changed files with 3308 additions and 1335 deletions

View File

@ -1,5 +1,68 @@
# Changelog # Changelog
## 2025-03-28 - 3.1.2 - fix(cli/ups-handler)
Improve UPS device listing table formatting for better column alignment
- Adjusted header spacing for the Host column and overall table alignment in the UPS handler output.
## 2025-03-28 - 3.1.1 - fix(cli)
Improve table header formatting in group and UPS listings
- Adjusted column padding in group listing for proper alignment
- Fixed UPS table header spacing for consistent CLI output
## 2025-03-28 - 3.1.0 - feat(cli)
Refactor CLI commands to use dedicated handlers for UPS, group, and service management
- Extracted UPS-related CLI logic into a new UpsHandler
- Introduced GroupHandler to manage UPS groups commands
- Added ServiceHandler for systemd service operations
- Updated CLI routing in cli.ts to delegate commands to the new handlers
- Exposed getters for the new handlers in the Nupst class
## 2025-03-28 - 3.0.1 - fix(cli)
Simplify UPS ID generation by removing the redundant promptForUniqueUpsId function in the CLI module and replacing it with the shortId helper.
- Deleted the unused promptForUniqueUpsId method from ts/cli.ts.
- Updated UPS configuration to generate a unique ID directly using helpers.shortId().
- Improved code clarity by removing unnecessary interactive prompts for UPS IDs.
## 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.
- Removed the reset of currentBoxWidth in logBoxEnd to allow persistent width across logbox calls.
- Ensures that logBoxLine uses the previously set width when no new width is provided.
## 2025-03-26 - 2.6.16 - fix(cli)
Improve CLI logging consistency by replacing direct console output with unified logger calls.
- Replaced console.log and console.error with logger.log and logger.error in CLI commands
- Standardized debug, error, and status messages using logger's logbox utilities
- Enhanced consistency of log output throughout the ts/cli.ts file
## 2025-03-26 - 2.6.15 - fix(logger)
Replace direct console logging with unified logger interface for consistent formatting
- Substitute console.log, console.error, and related calls with logger methods in cli, daemon, systemd, nupst, and index modules
- Integrate logBox formatting for structured output and consistent log presentation
- Update test expectations in test.logger.ts to check for standardized error messages
- Refactor logging calls throughout the codebase for improved clarity and maintainability
## 2025-03-26 - 2.6.14 - fix(systemd)
Shorten closing log divider in systemd service installation output for consistent formatting.
- Replaced the overly long footer with a shorter one in ts/systemd.ts.
- This change improves log readability without affecting functionality.
## 2025-03-26 - 2.6.13 - fix(cli) ## 2025-03-26 - 2.6.13 - fix(cli)
Fix CLI update output box formatting Fix CLI update output box formatting

View File

@ -1,6 +1,6 @@
{ {
"name": "@serve.zone/nupst", "name": "@serve.zone/nupst",
"version": "2.6.13", "version": "3.1.2",
"description": "Node.js UPS Shutdown Tool for SNMP-enabled UPS devices", "description": "Node.js UPS Shutdown Tool for SNMP-enabled UPS devices",
"main": "dist/index.js", "main": "dist/index.js",
"bin": { "bin": {
@ -56,5 +56,6 @@
"mongodb-memory-server", "mongodb-memory-server",
"puppeteer" "puppeteer"
] ]
} },
"packageManager": "pnpm@10.7.0+sha512.6b865ad4b62a1d9842b61d674a393903b871d9244954f652b8842c2b553c72176b278f64c463e52d40fff8aba385c235c8c9ecf5cc7de4fd78b8bb6d49633ab6"
} }

View File

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

160
test/test.logger.ts Normal file
View File

@ -0,0 +1,160 @@
import { tap, expect } from '@push.rocks/tapbundle';
import { Logger } from '../ts/logger.js';
// Create a Logger instance for testing
const logger = new Logger();
tap.test('should create a logger instance', async () => {
expect(logger instanceof Logger).toBeTruthy();
});
tap.test('should log messages with different log levels', async () => {
// We're not testing console output directly, just ensuring no errors
logger.log('Regular log message');
logger.error('Error message');
logger.warn('Warning message');
logger.success('Success message');
// Just assert that the test runs without errors
expect(true).toBeTruthy();
});
tap.test('should create a logbox with title, content, and end', async () => {
// Just ensuring no errors occur
logger.logBoxTitle('Test Box', 40);
logger.logBoxLine('This is a test line');
logger.logBoxEnd();
// Just assert that the test runs without errors
expect(true).toBeTruthy();
});
tap.test('should handle width persistence between logbox calls', async () => {
logger.logBoxTitle('Width Test', 45);
// These should use the width from the title
logger.logBoxLine('Line 1');
logger.logBoxLine('Line 2');
logger.logBoxEnd();
let errorThrown = false;
try {
// This should work fine after the reset in logBoxEnd
logger.logBoxTitle('New Box', 30);
logger.logBoxLine('New line');
logger.logBoxEnd();
} catch (error) {
errorThrown = true;
}
expect(errorThrown).toBeFalsy();
});
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;
try {
logger.logBoxLine('This should use default width');
logger.logBoxEnd();
} catch (error) {
errorThrown = true;
}
// Verify no error was thrown
expect(errorThrown).toBeFalsy();
});
tap.test('should create a complete logbox in one call', async () => {
// Just ensuring no errors occur
logger.logBox('Complete Box', [
'Line 1',
'Line 2',
'Line 3'
], 40);
// Just assert that the test runs without errors
expect(true).toBeTruthy();
});
tap.test('should handle content that exceeds box width', async () => {
// Just ensuring no errors occur when content is too long
logger.logBox('Truncation Test', [
'This line is way too long and should be truncated because it exceeds the available space'
], 30);
// Just assert that the test runs without errors
expect(true).toBeTruthy();
});
tap.test('should create dividers with custom characters', async () => {
// Just ensuring no errors occur
logger.logDivider(30);
logger.logDivider(20, '*');
// Just assert that the test runs without errors
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');
// Basic logging
logger.log('Regular log message');
logger.error('Error message');
logger.warn('Warning message');
logger.success('Success message');
// Logbox with title, content lines, and end
logger.logBoxTitle('Configuration Loaded', 50);
logger.logBoxLine('SNMP Settings:');
logger.logBoxLine(' Host: 127.0.0.1');
logger.logBoxLine(' Port: 161');
logger.logBoxLine(' Version: 1');
logger.logBoxEnd();
// Complete logbox in one call
logger.logBox('UPS Status', [
'Power Status: onBattery',
'Battery Capacity: 75%',
'Runtime Remaining: 30 minutes'
], 45);
// Logbox with content that's too long for the width
logger.logBox('Truncation Example', [
'This line is short enough to fit within the box width',
'This line is way too long and will be truncated because it exceeds the available space for content within the logbox'
], 40);
// Demonstrating logbox width being remembered
logger.logBoxTitle('Width Persistence Example', 60);
logger.logBoxLine('These lines use the width from the title');
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();
});
// Export the default tap object
export default tap.start();

View File

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

1254
ts/cli.ts

File diff suppressed because it is too large Load Diff

565
ts/cli/group-handler.ts Normal file
View File

@ -0,0 +1,565 @@
import { Nupst } from '../nupst.js';
import { logger } from '../logger.js';
import * as helpers from '../helpers/index.js';
import { type IGroupConfig } from '../daemon.js';
/**
* Class for handling group-related CLI commands
* Provides interface for managing UPS groups
*/
export class GroupHandler {
private readonly nupst: Nupst;
/**
* Create a new Group handler
* @param nupst Reference to the main Nupst instance
*/
constructor(nupst: Nupst) {
this.nupst = nupst;
}
/**
* List all UPS groups
*/
public async list(): Promise<void> {
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
*/
public async add(): Promise<void> {
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<string> => {
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);
// Save again after assigning UPS devices
await this.nupst.getDaemon().saveConfig(config);
}
}
// Check if service is running and restart it if needed
this.nupst.getUpsHandler().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
*/
public async edit(groupId: string): Promise<void> {
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<string> => {
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);
// Save again after editing assignments
await this.nupst.getDaemon().saveConfig(config);
}
// Check if service is running and restart it if needed
this.nupst.getUpsHandler().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
*/
public async delete(groupId: string): Promise<void> {
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<string>(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
this.nupst.getUpsHandler().restartServiceIfRunning();
} catch (error) {
logger.error(`Failed to delete group: ${error.message}`);
}
}
/**
* Assign UPS devices to groups
* @param ups UPS configuration to update
* @param groups Available groups
* @param prompt Function to prompt for user input
*/
public async assignUpsToGroups(
ups: any,
groups: any[],
prompt: (question: string) => Promise<string>
): Promise<void> {
// Initialize groups array if it doesn't exist
if (!ups.groups) {
ups.groups = [];
}
// 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];
// Initialize groups array if needed (should already be done above)
if (!ups.groups) {
ups.groups = [];
}
// Toggle assignment
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
*/
public async assignUpsToGroup(
groupId: string,
config: any,
prompt: (question: string) => Promise<string>
): Promise<void> {
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}"`);
}
}
}
}

320
ts/cli/service-handler.ts Normal file
View File

@ -0,0 +1,320 @@
import { execSync } from 'child_process';
import { Nupst } from '../nupst.js';
import { logger } from '../logger.js';
/**
* Class for handling service-related CLI commands
* Provides interface for managing systemd service
*/
export class ServiceHandler {
private readonly nupst: Nupst;
/**
* Create a new Service handler
* @param nupst Reference to the main Nupst instance
*/
constructor(nupst: Nupst) {
this.nupst = nupst;
}
/**
* Enable the service (requires root)
*/
public async enable(): Promise<void> {
this.checkRootAccess('This command must be run as root.');
await this.nupst.getSystemd().install();
logger.log('NUPST service has been installed. Use "nupst start" to start the service.');
}
/**
* Start the daemon directly
* @param debugMode Whether to enable debug mode
*/
public async daemonStart(debugMode: boolean = false): Promise<void> {
logger.log('Starting NUPST daemon...');
try {
// Enable debug mode for SNMP if requested
if (debugMode) {
this.nupst.getSnmp().enableDebug();
logger.log('SNMP debug mode enabled');
}
await this.nupst.getDaemon().start();
} catch (error) {
// Error is already logged and process.exit is called in daemon.start()
// No need to handle it here
}
}
/**
* Show logs of the systemd service
*/
public async logs(): Promise<void> {
try {
// Use exec with spawn to properly follow logs in real-time
const { spawn } = await import('child_process');
logger.log('Tailing nupst service logs (Ctrl+C to exit)...\n');
const journalctl = spawn('journalctl', ['-u', 'nupst.service', '-n', '50', '-f'], {
stdio: ['ignore', 'inherit', 'inherit'],
});
// Forward signals to child process
process.on('SIGINT', () => {
journalctl.kill('SIGINT');
process.exit(0);
});
// Wait for process to exit
await new Promise<void>((resolve) => {
journalctl.on('exit', () => resolve());
});
} catch (error) {
logger.error(`Failed to retrieve logs: ${error}`);
process.exit(1);
}
}
/**
* Stop the systemd service
*/
public async stop(): Promise<void> {
await this.nupst.getSystemd().stop();
}
/**
* Start the systemd service
*/
public async start(): Promise<void> {
try {
await this.nupst.getSystemd().start();
} catch (error) {
// Error will be displayed by systemd.start()
process.exit(1);
}
}
/**
* Show status of the systemd service and UPS
*/
public async status(): Promise<void> {
// Extract debug options from args array
const debugOptions = this.extractDebugOptions(process.argv);
await this.nupst.getSystemd().getStatus(debugOptions.debugMode);
}
/**
* Disable the service (requires root)
*/
public async disable(): Promise<void> {
this.checkRootAccess('This command must be run as root.');
await this.nupst.getSystemd().disable();
}
/**
* Check if the user has root access
* @param errorMessage Error message to display if not root
*/
private checkRootAccess(errorMessage: string): void {
if (process.getuid && process.getuid() !== 0) {
logger.error(errorMessage);
process.exit(1);
}
}
/**
* Update NUPST from repository and refresh systemd service
*/
public async update(): Promise<void> {
try {
// Check if running as root
this.checkRootAccess(
'This command must be run as root to update NUPST and refresh the systemd service.'
);
const boxWidth = 45;
logger.logBoxTitle('NUPST Update Process', boxWidth);
logger.logBoxLine('Updating NUPST from repository...');
// Determine the installation directory (assuming it's either /opt/nupst or the current directory)
const { existsSync } = await import('fs');
let installDir = '/opt/nupst';
if (!existsSync(installDir)) {
// If not installed in /opt/nupst, use the current directory
const { dirname } = await import('path');
installDir = dirname(dirname(process.argv[1])); // Go up two levels from the executable
logger.logBoxLine(`Using local installation directory: ${installDir}`);
}
try {
// 1. Update the repository
logger.logBoxLine('Pulling latest changes from git repository...');
execSync(`cd ${installDir} && git fetch origin && git reset --hard origin/main`, {
stdio: 'pipe',
});
// 2. Run the install.sh script
logger.logBoxLine('Running install.sh to update NUPST...');
execSync(`cd ${installDir} && bash ./install.sh`, { stdio: 'pipe' });
// 3. Run the setup.sh script with force flag to update Node.js and dependencies
logger.logBoxLine('Running setup.sh to update Node.js and dependencies...');
execSync(`cd ${installDir} && bash ./setup.sh --force`, { stdio: 'pipe' });
// 4. Refresh the systemd service
logger.logBoxLine('Refreshing systemd service...');
// First check if service exists
let serviceExists = false;
try {
const output = execSync('systemctl list-unit-files | grep nupst.service').toString();
serviceExists = output.includes('nupst.service');
} catch (error) {
// If grep fails (service not found), serviceExists remains false
serviceExists = false;
}
if (serviceExists) {
// Stop the service if it's running
const isRunning =
execSync('systemctl is-active nupst.service || true').toString().trim() === 'active';
if (isRunning) {
logger.logBoxLine('Stopping nupst service...');
execSync('systemctl stop nupst.service');
}
// Reinstall the service
logger.logBoxLine('Reinstalling systemd service...');
await this.nupst.getSystemd().install();
// Restart the service if it was running
if (isRunning) {
logger.logBoxLine('Restarting nupst service...');
execSync('systemctl start nupst.service');
}
} else {
logger.logBoxLine('Systemd service not installed, skipping service refresh.');
logger.logBoxLine('Run "nupst enable" to install the service.');
}
logger.logBoxLine('Update completed successfully!');
logger.logBoxEnd();
} catch (error) {
logger.logBoxLine('Error during update process:');
logger.logBoxLine(`${error.message}`);
logger.logBoxEnd();
process.exit(1);
}
} catch (error) {
logger.error(`Update failed: ${error.message}`);
process.exit(1);
}
}
/**
* Completely uninstall NUPST from the system
*/
public async uninstall(): Promise<void> {
// 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<string> => {
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 { dirname, join } = await import('path');
const modulePath = dirname(dirname(binPath));
uninstallScriptPath = join(modulePath, 'uninstall.sh');
// Check if the script exists
const { access } = await import('fs/promises');
await access(uninstallScriptPath);
} catch (error) {
// If we can't find it in the expected location, try common installation paths
const commonPaths = ['/opt/nupst/uninstall.sh', `${process.cwd()}/uninstall.sh`];
const { existsSync } = await import('fs');
uninstallScriptPath = '';
for (const path of commonPaths) {
if (existsSync(path)) {
uninstallScriptPath = path;
break;
}
}
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);
}
}
/**
* Extract and remove debug options from args array
* @param args Command line arguments
* @returns Object with debug flags and cleaned args
*/
private extractDebugOptions(args: string[]): { debugMode: boolean; cleanedArgs: string[] } {
const debugMode = args.includes('--debug') || args.includes('-d');
// Remove debug flags from args
const cleanedArgs = args.filter((arg) => arg !== '--debug' && arg !== '-d');
return { debugMode, cleanedArgs };
}
}

986
ts/cli/ups-handler.ts Normal file
View File

@ -0,0 +1,986 @@
import { execSync } from 'child_process';
import { Nupst } from '../nupst.js';
import { logger } from '../logger.js';
import * as helpers from '../helpers/index.js';
/**
* Class for handling UPS-related CLI commands
* Provides interface for managing UPS devices
*/
export class UpsHandler {
private readonly nupst: Nupst;
/**
* Create a new UPS handler
* @param nupst Reference to the main Nupst instance
*/
constructor(nupst: Nupst) {
this.nupst = nupst;
}
/**
* Add a new UPS configuration
*/
public async add(): Promise<void> {
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<string> => {
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
*/
public async runAddProcess(prompt: (question: string) => Promise<string>): Promise<void> {
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 = helpers.shortId();
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);
// Get access to GroupHandler for group assignments
const groupHandler = this.nupst.getGroupHandler();
// Assign to groups if any exist
if (config.groups && config.groups.length > 0) {
await groupHandler.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)
*/
public async edit(upsId?: string): Promise<void> {
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<string> => {
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
*/
public async runEditProcess(upsId: string | undefined, prompt: (question: string) => Promise<string>): Promise<void> {
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);
// Get access to GroupHandler for group assignments
const groupHandler = this.nupst.getGroupHandler();
// Edit group assignments
if (config.groups && config.groups.length > 0) {
await groupHandler.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!');
}
/**
* Delete a UPS by ID
* @param upsId ID of the UPS to delete
*/
public async delete(upsId: string): Promise<void> {
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<string>(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}`);
}
}
/**
* List all configured UPS devices
*/
public async list(): Promise<void> {
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}`);
}
}
/**
* Test the current configuration by connecting to the UPS
* @param debugMode Whether to enable debug mode
*/
public async test(debugMode: boolean = false): Promise<void> {
try {
// Debug mode is now handled in parseAndExecute
if (debugMode) {
const boxWidth = 45;
logger.logBoxTitle('Debug Mode', boxWidth);
logger.logBoxLine('SNMP debugging enabled - detailed logs will be shown');
logger.logBoxEnd();
}
// Try to load the 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();
// 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 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<void> {
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();
}
/**
* Gather SNMP settings
* @param snmpConfig SNMP configuration object to update
* @param prompt Function to prompt for user input
*/
private async gatherSnmpSettings(
snmpConfig: any,
prompt: (question: string) => Promise<string>
): Promise<void> {
// SNMP IP Address
const defaultHost = snmpConfig.host || '127.0.0.1';
const host = await prompt(`UPS IP Address [${defaultHost}]: `);
snmpConfig.host = host.trim() || defaultHost;
// SNMP Port
const defaultPort = snmpConfig.port || 161;
const portInput = await prompt(`SNMP Port [${defaultPort}]: `);
const port = parseInt(portInput, 10);
snmpConfig.port = portInput.trim() && !isNaN(port) ? port : defaultPort;
// 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);
snmpConfig.version =
versionInput.trim() && (version === 1 || version === 2 || version === 3)
? version
: defaultVersion;
if (snmpConfig.version === 1 || snmpConfig.version === 2) {
// SNMP Community String (for v1/v2c)
const defaultCommunity = snmpConfig.community || 'public';
const community = await prompt(`SNMP Community String [${defaultCommunity}]: `);
snmpConfig.community = community.trim() || defaultCommunity;
} else if (snmpConfig.version === 3) {
// SNMP v3 settings
await this.gatherSnmpV3Settings(snmpConfig, prompt);
}
}
/**
* Gather SNMPv3 specific settings
* @param snmpConfig SNMP configuration object to update
* @param prompt Function to prompt for user input
*/
private async gatherSnmpV3Settings(
snmpConfig: any,
prompt: (question: string) => Promise<string>
): Promise<void> {
console.log('\nSNMPv3 Security Settings:');
// Security Level
console.log('\nSecurity Level:');
console.log(' 1) noAuthNoPriv (No Authentication, No Privacy)');
console.log(' 2) authNoPriv (Authentication, No Privacy)');
console.log(' 3) authPriv (Authentication and Privacy)');
const defaultSecLevel = snmpConfig.securityLevel
? snmpConfig.securityLevel === 'noAuthNoPriv'
? 1
: snmpConfig.securityLevel === 'authNoPriv'
? 2
: 3
: 3;
const secLevelInput = await prompt(`Select Security Level [${defaultSecLevel}]: `);
const secLevel = parseInt(secLevelInput, 10) || defaultSecLevel;
if (secLevel === 1) {
snmpConfig.securityLevel = 'noAuthNoPriv';
// No auth, no priv - clear out authentication and privacy settings
snmpConfig.authProtocol = '';
snmpConfig.authKey = '';
snmpConfig.privProtocol = '';
snmpConfig.privKey = '';
// Set appropriate timeout for security level
snmpConfig.timeout = 5000; // 5 seconds for basic security
} else if (secLevel === 2) {
snmpConfig.securityLevel = 'authNoPriv';
// Auth, no priv - clear out privacy settings
snmpConfig.privProtocol = '';
snmpConfig.privKey = '';
// Set appropriate timeout for security level
snmpConfig.timeout = 10000; // 10 seconds for authentication
} else {
snmpConfig.securityLevel = 'authPriv';
// Set appropriate timeout for security level
snmpConfig.timeout = 15000; // 15 seconds for full encryption
}
// Username
const defaultUsername = snmpConfig.username || '';
const username = await prompt(`SNMPv3 Username [${defaultUsername}]: `);
snmpConfig.username = username.trim() || defaultUsername;
if (secLevel >= 2) {
// Authentication settings
await this.gatherAuthenticationSettings(snmpConfig, prompt);
if (secLevel === 3) {
// Privacy settings
await this.gatherPrivacySettings(snmpConfig, prompt);
}
// Allow customizing the timeout value
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)) {
snmpConfig.timeout = timeout * 1000; // Convert to ms
}
}
}
/**
* Gather authentication settings for SNMPv3
* @param snmpConfig SNMP configuration object to update
* @param prompt Function to prompt for user input
*/
private async gatherAuthenticationSettings(
snmpConfig: any,
prompt: (question: string) => Promise<string>
): Promise<void> {
// Authentication protocol
console.log('\nAuthentication Protocol:');
console.log(' 1) MD5');
console.log(' 2) SHA');
const defaultAuthProtocol = snmpConfig.authProtocol === 'SHA' ? 2 : 1;
const authProtocolInput = await prompt(
`Select Authentication Protocol [${defaultAuthProtocol}]: `
);
const authProtocol = parseInt(authProtocolInput, 10) || defaultAuthProtocol;
snmpConfig.authProtocol = authProtocol === 2 ? 'SHA' : 'MD5';
// Authentication Key/Password
const defaultAuthKey = snmpConfig.authKey || '';
const authKey = await prompt(`Authentication Password ${defaultAuthKey ? '[*****]' : ''}: `);
snmpConfig.authKey = authKey.trim() || defaultAuthKey;
}
/**
* Gather privacy settings for SNMPv3
* @param snmpConfig SNMP configuration object to update
* @param prompt Function to prompt for user input
*/
private async gatherPrivacySettings(
snmpConfig: any,
prompt: (question: string) => Promise<string>
): Promise<void> {
// Privacy protocol
console.log('\nPrivacy Protocol:');
console.log(' 1) DES');
console.log(' 2) AES');
const defaultPrivProtocol = snmpConfig.privProtocol === 'AES' ? 2 : 1;
const privProtocolInput = await prompt(`Select Privacy Protocol [${defaultPrivProtocol}]: `);
const privProtocol = parseInt(privProtocolInput, 10) || defaultPrivProtocol;
snmpConfig.privProtocol = privProtocol === 2 ? 'AES' : 'DES';
// Privacy Key/Password
const defaultPrivKey = snmpConfig.privKey || '';
const privKey = await prompt(`Privacy Password ${defaultPrivKey ? '[*****]' : ''}: `);
snmpConfig.privKey = privKey.trim() || defaultPrivKey;
}
/**
* Gather threshold settings
* @param thresholds Thresholds configuration object to update
* @param prompt Function to prompt for user input
*/
private async gatherThresholdSettings(
thresholds: any,
prompt: (question: string) => Promise<string>
): Promise<void> {
console.log('\nShutdown Thresholds:');
// Battery threshold
const defaultBatteryThreshold = thresholds.battery || 60;
const batteryThresholdInput = await prompt(
`Battery percentage threshold [${defaultBatteryThreshold}%]: `
);
const batteryThreshold = parseInt(batteryThresholdInput, 10);
thresholds.battery =
batteryThresholdInput.trim() && !isNaN(batteryThreshold)
? batteryThreshold
: defaultBatteryThreshold;
// Runtime threshold
const defaultRuntimeThreshold = thresholds.runtime || 20;
const runtimeThresholdInput = await prompt(
`Runtime minutes threshold [${defaultRuntimeThreshold} minutes]: `
);
const runtimeThreshold = parseInt(runtimeThresholdInput, 10);
thresholds.runtime =
runtimeThresholdInput.trim() && !isNaN(runtimeThreshold)
? runtimeThreshold
: defaultRuntimeThreshold;
}
/**
* Gather UPS model settings
* @param snmpConfig SNMP configuration object to update
* @param prompt Function to prompt for user input
*/
private async gatherUpsModelSettings(
snmpConfig: any,
prompt: (question: string) => Promise<string>
): Promise<void> {
console.log('\nUPS Model Selection:');
console.log(' 1) CyberPower');
console.log(' 2) APC');
console.log(' 3) Eaton');
console.log(' 4) TrippLite');
console.log(' 5) Liebert/Vertiv');
console.log(' 6) Custom (Advanced)');
const defaultModelValue =
snmpConfig.upsModel === 'cyberpower'
? 1
: snmpConfig.upsModel === 'apc'
? 2
: snmpConfig.upsModel === 'eaton'
? 3
: snmpConfig.upsModel === 'tripplite'
? 4
: snmpConfig.upsModel === 'liebert'
? 5
: snmpConfig.upsModel === 'custom'
? 6
: 1;
const modelInput = await prompt(`Select UPS model [${defaultModelValue}]: `);
const modelValue = parseInt(modelInput, 10) || defaultModelValue;
if (modelValue === 1) {
snmpConfig.upsModel = 'cyberpower';
} else if (modelValue === 2) {
snmpConfig.upsModel = 'apc';
} else if (modelValue === 3) {
snmpConfig.upsModel = 'eaton';
} else if (modelValue === 4) {
snmpConfig.upsModel = 'tripplite';
} else if (modelValue === 5) {
snmpConfig.upsModel = 'liebert';
} else if (modelValue === 6) {
snmpConfig.upsModel = 'custom';
console.log('\nEnter custom OIDs for your UPS:');
console.log('(Leave blank to use standard RFC 1628 OIDs as fallback)');
// Custom OIDs
const powerStatusOID = await prompt('Power Status OID: ');
const batteryCapacityOID = await prompt('Battery Capacity OID: ');
const batteryRuntimeOID = await prompt('Battery Runtime OID: ');
// Create custom OIDs object
snmpConfig.customOIDs = {
POWER_STATUS: powerStatusOID.trim(),
BATTERY_CAPACITY: batteryCapacityOID.trim(),
BATTERY_RUNTIME: batteryRuntimeOID.trim(),
};
}
}
/**
* 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('');
}
/**
* Optionally test connection to UPS
* @param snmpConfig SNMP configuration to test
* @param prompt Function to prompt for user input
*/
private async optionallyTestConnection(
snmpConfig: any,
prompt: (question: string) => Promise<string>
): Promise<void> {
const testConnection = await prompt(
'Would you like to test the connection to your UPS? (y/N): '
);
if (testConnection.toLowerCase() === 'y') {
logger.log('\nTesting connection to UPS...');
try {
// Create a test config with a short timeout
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.log('');
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();
} catch (error) {
const errorBoxWidth = 45;
logger.log('');
logger.logBoxTitle('Connection Failed!', errorBoxWidth);
logger.logBoxLine(`Error: ${error.message}`);
logger.logBoxEnd();
logger.log('\nPlease check your settings and try again.');
}
}
}
/**
* Check if the systemd service is running and restart it if it is
* This is useful after configuration changes
*/
public async restartServiceIfRunning(): Promise<void> {
try {
// Check if the service is active
const isActive =
execSync('systemctl is-active nupst.service || true').toString().trim() === 'active';
if (isActive) {
// Service is running, restart it
const boxWidth = 45;
logger.logBoxTitle('Service Update', boxWidth);
logger.logBoxLine('Configuration has changed.');
logger.logBoxLine('Restarting NUPST service to apply changes...');
try {
if (process.getuid && process.getuid() === 0) {
// We have root access, restart directly
execSync('systemctl restart nupst.service');
logger.logBoxLine('Service restarted successfully.');
} else {
// No root access, show instructions
logger.logBoxLine('Please restart the service with:');
logger.logBoxLine(' sudo systemctl restart nupst.service');
}
} catch (error) {
logger.logBoxLine(`Error restarting service: ${error.message}`);
logger.logBoxLine('You may need to restart the service manually:');
logger.logBoxLine(' sudo systemctl restart nupst.service');
}
logger.logBoxEnd();
}
} catch (error) {
// Ignore errors checking service status
}
}
}

View File

@ -4,14 +4,19 @@ import { exec, execFile } from 'child_process';
import { promisify } from 'util'; import { promisify } from 'util';
import { NupstSnmp } from './snmp/manager.js'; import { NupstSnmp } from './snmp/manager.js';
import type { ISnmpConfig } from './snmp/types.js'; import type { ISnmpConfig } from './snmp/types.js';
import { logger } from './logger.js';
const execAsync = promisify(exec); const execAsync = promisify(exec);
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
/** /**
* Configuration interface for the daemon * UPS configuration interface
*/ */
export interface INupstConfig { export interface IUpsConfig {
/** Unique ID for the UPS */
id: string;
/** Friendly name for the UPS */
name: string;
/** SNMP configuration settings */ /** SNMP configuration settings */
snmp: ISnmpConfig; snmp: ISnmpConfig;
/** Threshold settings for initiating shutdown */ /** Threshold settings for initiating shutdown */
@ -21,8 +26,58 @@ export interface INupstConfig {
/** Shutdown when runtime below this minutes */ /** Shutdown when runtime below this minutes */
runtime: number; runtime: number;
}; };
/** Group IDs this UPS belongs to */
groups: string[];
}
/**
* Group configuration interface
*/
export interface IGroupConfig {
/** Unique ID for the group */
id: string;
/** Friendly name for the group */
name: string;
/** Group operation mode */
mode: 'redundant' | 'nonRedundant';
/** Optional description */
description?: string;
}
/**
* Configuration interface for the daemon
*/
export interface INupstConfig {
/** UPS devices configuration */
upsDevices: IUpsConfig[];
/** Groups configuration */
groups: IGroupConfig[];
/** Check interval in milliseconds */ /** Check interval in milliseconds */
checkInterval: number; checkInterval: number;
// Legacy fields for backward compatibility
/** SNMP configuration settings (legacy) */
snmp?: ISnmpConfig;
/** Threshold settings (legacy) */
thresholds?: {
/** Shutdown when battery below this percentage */
battery: number;
/** Shutdown when runtime below this minutes */
runtime: number;
};
}
/**
* UPS status tracking interface
*/
interface IUpsStatus {
id: string;
name: string;
powerStatus: 'online' | 'onBattery' | 'unknown';
batteryCapacity: number;
batteryRuntime: number;
lastStatusChange: number;
lastCheckTime: number;
} }
/** /**
@ -35,6 +90,10 @@ export class NupstDaemon {
/** Default configuration */ /** Default configuration */
private readonly DEFAULT_CONFIG: INupstConfig = { private readonly DEFAULT_CONFIG: INupstConfig = {
upsDevices: [
{
id: 'default',
name: 'Default UPS',
snmp: { snmp: {
host: '127.0.0.1', host: '127.0.0.1',
port: 161, port: 161,
@ -55,12 +114,17 @@ export class NupstDaemon {
battery: 60, // Shutdown when battery below 60% battery: 60, // Shutdown when battery below 60%
runtime: 20, // Shutdown when runtime below 20 minutes runtime: 20, // Shutdown when runtime below 20 minutes
}, },
groups: []
}
],
groups: [],
checkInterval: 30000, // Check every 30 seconds checkInterval: 30000, // Check every 30 seconds
}; };
private config: INupstConfig; private config: INupstConfig;
private snmp: NupstSnmp; private snmp: NupstSnmp;
private isRunning: boolean = false; private isRunning: boolean = false;
private upsStatus: Map<string, IUpsStatus> = new Map();
/** /**
* Create a new daemon instance with the given SNMP manager * Create a new daemon instance with the given SNMP manager
@ -86,10 +150,36 @@ export class NupstDaemon {
// Read and parse config // Read and parse config
const configData = fs.readFileSync(this.CONFIG_PATH, 'utf8'); const configData = fs.readFileSync(this.CONFIG_PATH, 'utf8');
this.config = JSON.parse(configData); const parsedConfig = JSON.parse(configData);
// Handle legacy configuration format
if (!parsedConfig.upsDevices && parsedConfig.snmp) {
// Convert legacy format to new format
this.config = {
upsDevices: [
{
id: 'default',
name: 'Default UPS',
snmp: parsedConfig.snmp,
thresholds: parsedConfig.thresholds,
groups: []
}
],
groups: [],
checkInterval: parsedConfig.checkInterval
};
logger.log('Legacy configuration format detected. Converting to multi-UPS format.');
// Save the new format
await this.saveConfig(this.config);
} else {
this.config = parsedConfig;
}
return this.config; return this.config;
} catch (error) { } catch (error) {
if (error.message.includes('No configuration found')) { if (error.message && error.message.includes('No configuration found')) {
throw error; // Re-throw the no configuration error throw error; // Re-throw the no configuration error
} }
@ -147,11 +237,11 @@ export class NupstDaemon {
*/ */
public async start(): Promise<void> { public async start(): Promise<void> {
if (this.isRunning) { if (this.isRunning) {
console.log('Daemon is already running'); logger.log('Daemon is already running');
return; return;
} }
console.log('Starting NUPST daemon...'); logger.log('Starting NUPST daemon...');
try { try {
// Load configuration - this will throw an error if config doesn't exist // Load configuration - this will throw an error if config doesn't exist
@ -165,45 +255,87 @@ export class NupstDaemon {
this.snmp.getNupst().checkForUpdates().then(updateAvailable => { this.snmp.getNupst().checkForUpdates().then(updateAvailable => {
if (updateAvailable) { if (updateAvailable) {
const updateStatus = this.snmp.getNupst().getUpdateStatus(); const updateStatus = this.snmp.getNupst().getUpdateStatus();
console.log('┌─ Update Available ───────────────────────┐'); const boxWidth = 45;
console.log(`│ Current Version: ${updateStatus.currentVersion}`); logger.logBoxTitle('Update Available', boxWidth);
console.log(`│ Latest Version: ${updateStatus.latestVersion}`); logger.logBoxLine(`Current Version: ${updateStatus.currentVersion}`);
console.log('│ Run "sudo nupst update" to update'); logger.logBoxLine(`Latest Version: ${updateStatus.latestVersion}`);
console.log('└──────────────────────────────────────────┘'); logger.logBoxLine('Run "sudo nupst update" to update');
logger.logBoxEnd();
} }
}).catch(() => {}); // Ignore errors checking for updates }).catch(() => {}); // Ignore errors checking for updates
// Initialize UPS status tracking
this.initializeUpsStatus();
// Start UPS monitoring // Start UPS monitoring
this.isRunning = true; this.isRunning = true;
await this.monitor(); await this.monitor();
} catch (error) { } catch (error) {
this.isRunning = false; this.isRunning = false;
console.error(`Daemon failed to start: ${error.message}`); logger.error(`Daemon failed to start: ${error.message}`);
process.exit(1); // Exit with error process.exit(1); // Exit with error
} }
} }
/**
* Initialize UPS status tracking for all UPS devices
*/
private initializeUpsStatus(): void {
this.upsStatus.clear();
if (this.config.upsDevices && this.config.upsDevices.length > 0) {
for (const ups of this.config.upsDevices) {
this.upsStatus.set(ups.id, {
id: ups.id,
name: ups.name,
powerStatus: 'unknown',
batteryCapacity: 100,
batteryRuntime: 999, // High value as default
lastStatusChange: Date.now(),
lastCheckTime: 0
});
}
logger.log(`Initialized status tracking for ${this.config.upsDevices.length} UPS devices`);
} else {
logger.error('No UPS devices found in configuration');
}
}
/** /**
* Log the loaded configuration settings * Log the loaded configuration settings
*/ */
private logConfigLoaded(): void { private logConfigLoaded(): void {
console.log('┌─ Configuration Loaded ─────────────────────┐'); const boxWidth = 50;
console.log('│ SNMP Settings:'); logger.logBoxTitle('Configuration Loaded', boxWidth);
console.log(`│ Host: ${this.config.snmp.host}`);
console.log(`│ Port: ${this.config.snmp.port}`); if (this.config.upsDevices && this.config.upsDevices.length > 0) {
console.log(`│ Version: ${this.config.snmp.version}`); logger.logBoxLine(`UPS Devices: ${this.config.upsDevices.length}`);
console.log('│ Thresholds:'); for (const ups of this.config.upsDevices) {
console.log(`│ Battery: ${this.config.thresholds.battery}%`); logger.logBoxLine(` - ${ups.name} (${ups.id}): ${ups.snmp.host}:${ups.snmp.port}`);
console.log(`│ Runtime: ${this.config.thresholds.runtime} minutes`); }
console.log(`│ Check Interval: ${this.config.checkInterval / 1000} seconds`); } else {
console.log('└────────────────────────────────────────────┘'); 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();
} }
/** /**
* Stop the monitoring daemon * Stop the monitoring daemon
*/ */
public stop(): void { public stop(): void {
console.log('Stopping NUPST daemon...'); logger.log('Stopping NUPST daemon...');
this.isRunning = false; this.isRunning = false;
} }
@ -211,81 +343,241 @@ export class NupstDaemon {
* Monitor the UPS status and trigger shutdown when necessary * Monitor the UPS status and trigger shutdown when necessary
*/ */
private async monitor(): Promise<void> { private async monitor(): Promise<void> {
console.log('Starting UPS monitoring...'); logger.log('Starting UPS monitoring...');
if (!this.config.upsDevices || this.config.upsDevices.length === 0) {
logger.error('No UPS devices found in configuration. Monitoring stopped.');
this.isRunning = false;
return;
}
let lastStatus: 'online' | 'onBattery' | 'unknown' = 'unknown';
let lastLogTime = 0; // Track when we last logged status let lastLogTime = 0; // Track when we last logged status
const LOG_INTERVAL = 5 * 60 * 1000; // Log at least every 5 minutes (300000ms) const LOG_INTERVAL = 5 * 60 * 1000; // Log at least every 5 minutes (300000ms)
// Monitor continuously // Monitor continuously
while (this.isRunning) { while (this.isRunning) {
try { try {
const status = await this.snmp.getUpsStatus(this.config.snmp); // Check all UPS devices
const currentTime = Date.now(); await this.checkAllUpsDevices();
const shouldLogStatus = (currentTime - lastLogTime) >= LOG_INTERVAL;
// Log status changes // Log periodic status update
if (status.powerStatus !== lastStatus) { const currentTime = Date.now();
console.log('┌─ Power Status Change ─────────────────────┐'); if (currentTime - lastLogTime >= LOG_INTERVAL) {
console.log(`│ Status changed: ${lastStatus}${status.powerStatus}`); this.logAllUpsStatus();
console.log('└───────────────────────────────────────────┘');
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();
console.log('┌─ Periodic Status Update ──────────────────┐');
console.log(`│ Timestamp: ${timestamp}`);
console.log(`│ Power Status: ${status.powerStatus}`);
console.log(`│ Battery: ${status.batteryCapacity}% | Runtime: ${status.batteryRuntime} min`);
console.log('└───────────────────────────────────────────┘');
lastLogTime = currentTime; lastLogTime = currentTime;
} }
// Handle battery power status // Check if shutdown is required based on group configurations
if (status.powerStatus === 'onBattery') { await this.evaluateGroupShutdownConditions();
await this.handleOnBatteryStatus(status);
}
// Wait before next check // Wait before next check
await this.sleep(this.config.checkInterval); await this.sleep(this.config.checkInterval);
} catch (error) { } catch (error) {
console.error('Error during UPS monitoring:', error); logger.error(`Error during UPS monitoring: ${error.message}`);
await this.sleep(this.config.checkInterval); await this.sleep(this.config.checkInterval);
} }
} }
console.log('UPS monitoring stopped'); logger.log('UPS monitoring stopped');
} }
/** /**
* Handle UPS status when running on battery * Check status of all UPS devices
*/ */
private async handleOnBatteryStatus(status: { private async checkAllUpsDevices(): Promise<void> {
powerStatus: string, for (const ups of this.config.upsDevices) {
batteryCapacity: number, try {
batteryRuntime: number const upsStatus = this.upsStatus.get(ups.id);
}): Promise<void> { if (!upsStatus) {
console.log('┌─ UPS Status ─────────────────────────────┐'); // Initialize status for this UPS if not exists
console.log(`│ Battery: ${status.batteryCapacity}% | Runtime: ${status.batteryRuntime} min`); this.upsStatus.set(ups.id, {
console.log('└──────────────────────────────────────────┘'); id: ups.id,
name: ups.name,
powerStatus: 'unknown',
batteryCapacity: 100,
batteryRuntime: 999,
lastStatusChange: Date.now(),
lastCheckTime: 0
});
}
// Check battery threshold // Check UPS status
if (status.batteryCapacity < this.config.thresholds.battery) { const status = await this.snmp.getUpsStatus(ups.snmp);
console.log('⚠️ WARNING: Battery capacity below threshold'); const currentTime = Date.now();
console.log(`Current: ${status.batteryCapacity}% | Threshold: ${this.config.thresholds.battery}%`);
await this.initiateShutdown('Battery capacity below threshold'); // 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('');
for (const [id, status] of this.upsStatus.entries()) {
logger.logBoxLine(`UPS: ${status.name} (${id})`);
logger.logBoxLine(` Power Status: ${status.powerStatus}`);
logger.logBoxLine(` Battery: ${status.batteryCapacity}% | Runtime: ${status.batteryRuntime} min`);
logger.logBoxLine('');
}
logger.logBoxEnd();
}
/**
* Evaluate if shutdown is required based on group configurations
*/
private async evaluateGroupShutdownConditions(): Promise<void> {
if (!this.config.groups || this.config.groups.length === 0) {
// No groups defined, check individual UPS conditions
for (const [id, status] of this.upsStatus.entries()) {
if (status.powerStatus === 'onBattery') {
// Find the UPS config
const ups = this.config.upsDevices.find(u => u.id === id);
if (ups) {
await this.evaluateUpsShutdownCondition(ups, status);
}
}
}
return; return;
} }
// Check runtime threshold // Evaluate each group
if (status.batteryRuntime < this.config.thresholds.runtime) { for (const group of this.config.groups) {
console.log('⚠️ WARNING: Runtime below threshold'); // Find all UPS devices in this group
console.log(`Current: ${status.batteryRuntime} min | Threshold: ${this.config.thresholds.runtime} min`); const upsDevicesInGroup = this.config.upsDevices.filter(ups =>
await this.initiateShutdown('Runtime below threshold'); ups.groups && ups.groups.includes(group.id)
);
if (upsDevicesInGroup.length === 0) {
// No UPS devices in this group
continue;
}
if (group.mode === 'redundant') {
// Redundant mode: only shutdown if ALL UPS devices in the group are in critical condition
await this.evaluateRedundantGroup(group, upsDevicesInGroup);
} else {
// Non-redundant mode: shutdown if ANY UPS device in the group is in critical condition
await this.evaluateNonRedundantGroup(group, upsDevicesInGroup);
}
}
}
/**
* Evaluate a redundant group for shutdown conditions
* In redundant mode, we only shut down if ALL UPS devices are in critical condition
*/
private async evaluateRedundantGroup(group: IGroupConfig, upsDevices: IUpsConfig[]): Promise<void> {
// Count UPS devices on battery and in critical condition
let upsOnBattery = 0;
let upsInCriticalCondition = 0;
for (const ups of upsDevices) {
const status = this.upsStatus.get(ups.id);
if (!status) continue;
if (status.powerStatus === 'onBattery') {
upsOnBattery++;
// Check if this UPS is in critical condition
if (status.batteryCapacity < ups.thresholds.battery ||
status.batteryRuntime < ups.thresholds.runtime) {
upsInCriticalCondition++;
}
}
}
// All UPS devices must be online for a redundant group to be considered healthy
const allUpsCount = upsDevices.length;
// If all UPS are on battery and in critical condition, shutdown
if (upsOnBattery === allUpsCount && upsInCriticalCondition === allUpsCount) {
logger.logBoxTitle(`Group Shutdown Required: ${group.name}`, 50);
logger.logBoxLine(`Mode: Redundant`);
logger.logBoxLine(`All ${allUpsCount} UPS devices in critical condition`);
logger.logBoxEnd();
await this.initiateShutdown(`All UPS devices in redundant group "${group.name}" in critical condition`);
}
}
/**
* Evaluate a non-redundant group for shutdown conditions
* In non-redundant mode, we shut down if ANY UPS device is in critical condition
*/
private async evaluateNonRedundantGroup(group: IGroupConfig, upsDevices: IUpsConfig[]): Promise<void> {
for (const ups of upsDevices) {
const status = this.upsStatus.get(ups.id);
if (!status) continue;
if (status.powerStatus === 'onBattery') {
// Check if this UPS is in critical condition
if (status.batteryCapacity < ups.thresholds.battery ||
status.batteryRuntime < ups.thresholds.runtime) {
logger.logBoxTitle(`Group Shutdown Required: ${group.name}`, 50);
logger.logBoxLine(`Mode: Non-Redundant`);
logger.logBoxLine(`UPS ${ups.name} in critical condition`);
logger.logBoxLine(`Battery: ${status.batteryCapacity}% (threshold: ${ups.thresholds.battery}%)`);
logger.logBoxLine(`Runtime: ${status.batteryRuntime} min (threshold: ${ups.thresholds.runtime} min)`);
logger.logBoxEnd();
await this.initiateShutdown(`UPS "${ups.name}" in non-redundant group "${group.name}" in critical condition`);
return; // Exit after initiating shutdown
}
}
}
}
/**
* Evaluate an individual UPS for shutdown conditions
*/
private async evaluateUpsShutdownCondition(ups: IUpsConfig, status: IUpsStatus): Promise<void> {
// Only evaluate UPS devices not in any group
if (ups.groups && ups.groups.length > 0) {
return; return;
} }
// Check threshold conditions
if (status.batteryCapacity < ups.thresholds.battery ||
status.batteryRuntime < ups.thresholds.runtime) {
logger.logBoxTitle(`UPS Shutdown Required: ${ups.name}`, 50);
logger.logBoxLine(`Battery: ${status.batteryCapacity}% (threshold: ${ups.thresholds.battery}%)`);
logger.logBoxLine(`Runtime: ${status.batteryRuntime} min (threshold: ${ups.thresholds.runtime} min)`);
logger.logBoxEnd();
await this.initiateShutdown(`UPS "${ups.name}" battery or runtime below threshold`);
}
} }
/** /**
@ -293,7 +585,7 @@ export class NupstDaemon {
* @param reason Reason for shutdown * @param reason Reason for shutdown
*/ */
public async initiateShutdown(reason: string): Promise<void> { public async initiateShutdown(reason: string): Promise<void> {
console.log(`Initiating system shutdown due to: ${reason}`); logger.log(`Initiating system shutdown due to: ${reason}`);
// Set a longer delay for shutdown to allow VMs and services to close // Set a longer delay for shutdown to allow VMs and services to close
const shutdownDelayMinutes = 5; const shutdownDelayMinutes = 5;
@ -312,7 +604,7 @@ export class NupstDaemon {
try { try {
if (fs.existsSync(path)) { if (fs.existsSync(path)) {
shutdownCmd = path; shutdownCmd = path;
console.log(`Found shutdown command at: ${shutdownCmd}`); logger.log(`Found shutdown command at: ${shutdownCmd}`);
break; break;
} }
} catch (e) { } catch (e) {
@ -322,32 +614,32 @@ export class NupstDaemon {
if (shutdownCmd) { if (shutdownCmd) {
// Execute shutdown command with delay to allow for VM graceful shutdown // Execute shutdown command with delay to allow for VM graceful shutdown
console.log(`Executing: ${shutdownCmd} -h +${shutdownDelayMinutes} "UPS battery critical..."`); logger.log(`Executing: ${shutdownCmd} -h +${shutdownDelayMinutes} "UPS battery critical..."`);
const { stdout } = await execFileAsync(shutdownCmd, [ const { stdout } = await execFileAsync(shutdownCmd, [
'-h', '-h',
`+${shutdownDelayMinutes}`, `+${shutdownDelayMinutes}`,
`UPS battery critical, shutting down in ${shutdownDelayMinutes} minutes` `UPS battery critical, shutting down in ${shutdownDelayMinutes} minutes`
]); ]);
console.log('Shutdown initiated:', stdout); logger.log(`Shutdown initiated: ${stdout}`);
console.log(`Allowing ${shutdownDelayMinutes} minutes for VMs to shut down safely`); logger.log(`Allowing ${shutdownDelayMinutes} minutes for VMs to shut down safely`);
} else { } else {
// Try using the PATH to find shutdown // Try using the PATH to find shutdown
try { try {
console.log('Shutdown command not found in common paths, trying via PATH...'); logger.log('Shutdown command not found in common paths, trying via PATH...');
const { stdout } = await execAsync(`shutdown -h +${shutdownDelayMinutes} "UPS battery critical, shutting down in ${shutdownDelayMinutes} minutes"`, { const { stdout } = await execAsync(`shutdown -h +${shutdownDelayMinutes} "UPS battery critical, shutting down in ${shutdownDelayMinutes} minutes"`, {
env: process.env // Pass the current environment env: process.env // Pass the current environment
}); });
console.log('Shutdown initiated:', stdout); logger.log(`Shutdown initiated: ${stdout}`);
} catch (e) { } catch (e) {
throw new Error(`Shutdown command not found: ${e.message}`); throw new Error(`Shutdown command not found: ${e.message}`);
} }
} }
// Monitor UPS during shutdown and force immediate shutdown if battery gets too low // Monitor UPS during shutdown and force immediate shutdown if battery gets too low
console.log('Monitoring UPS during shutdown process...'); logger.log('Monitoring UPS during shutdown process...');
await this.monitorDuringShutdown(); await this.monitorDuringShutdown();
} catch (error) { } catch (error) {
console.error('Failed to initiate shutdown:', error); logger.error(`Failed to initiate shutdown: ${error}`);
// Try alternative shutdown methods // Try alternative shutdown methods
const alternatives = [ const alternatives = [
@ -376,30 +668,30 @@ export class NupstDaemon {
} }
if (cmdPath) { if (cmdPath) {
console.log(`Trying alternative shutdown method: ${cmdPath} ${alt.args.join(' ')}`); logger.log(`Trying alternative shutdown method: ${cmdPath} ${alt.args.join(' ')}`);
await execFileAsync(cmdPath, alt.args); await execFileAsync(cmdPath, alt.args);
return; // Exit if successful return; // Exit if successful
} else { } else {
// Try using PATH environment // Try using PATH environment
console.log(`Trying alternative via PATH: ${alt.cmd} ${alt.args.join(' ')}`); logger.log(`Trying alternative via PATH: ${alt.cmd} ${alt.args.join(' ')}`);
await execAsync(`${alt.cmd} ${alt.args.join(' ')}`, { await execAsync(`${alt.cmd} ${alt.args.join(' ')}`, {
env: process.env // Pass the current environment env: process.env // Pass the current environment
}); });
return; // Exit if successful return; // Exit if successful
} }
} catch (altError) { } catch (altError) {
console.error(`Alternative method ${alt.cmd} failed:`, altError); logger.error(`Alternative method ${alt.cmd} failed: ${altError}`);
// Continue to next method // Continue to next method
} }
} }
console.error('All shutdown methods failed'); logger.error('All shutdown methods failed');
} }
} }
/** /**
* Monitor UPS during system shutdown * Monitor UPS during system shutdown
* Force immediate shutdown if battery gets critically low * Force immediate shutdown if any UPS gets critically low
*/ */
private async monitorDuringShutdown(): Promise<void> { private async monitorDuringShutdown(): Promise<void> {
const EMERGENCY_RUNTIME_THRESHOLD = 5; // 5 minutes remaining is critical const EMERGENCY_RUNTIME_THRESHOLD = 5; // 5 minutes remaining is critical
@ -407,23 +699,51 @@ export class NupstDaemon {
const MAX_MONITORING_TIME = 5 * 60 * 1000; // Max 5 minutes of monitoring const MAX_MONITORING_TIME = 5 * 60 * 1000; // Max 5 minutes of monitoring
const startTime = Date.now(); const startTime = Date.now();
console.log(`Emergency shutdown threshold: ${EMERGENCY_RUNTIME_THRESHOLD} minutes remaining battery runtime`); logger.log(`Emergency shutdown threshold: ${EMERGENCY_RUNTIME_THRESHOLD} minutes remaining battery runtime`);
// Continue monitoring until max monitoring time is reached // Continue monitoring until max monitoring time is reached
while (Date.now() - startTime < MAX_MONITORING_TIME) { while (Date.now() - startTime < MAX_MONITORING_TIME) {
try { try {
console.log('Checking UPS status during shutdown...'); logger.log('Checking UPS status during shutdown...');
const status = await this.snmp.getUpsStatus(this.config.snmp);
console.log(`Current battery: ${status.batteryCapacity}%, Runtime: ${status.batteryRuntime} minutes`); // Check all UPS devices
for (const ups of this.config.upsDevices) {
try {
const status = await this.snmp.getUpsStatus(ups.snmp);
// If battery runtime gets critically low, force immediate shutdown 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) { if (status.batteryRuntime < EMERGENCY_RUNTIME_THRESHOLD) {
console.log('┌─ EMERGENCY SHUTDOWN ─────────────────────┐'); logger.logBoxTitle('EMERGENCY SHUTDOWN', 50);
console.log(`│ Battery runtime critically low: ${status.batteryRuntime} minutes`); logger.logBoxLine(`UPS ${ups.name} runtime critically low: ${status.batteryRuntime} minutes`);
console.log('│ Forcing immediate shutdown!'); logger.logBoxLine('Forcing immediate shutdown!');
console.log('└──────────────────────────────────────────┘'); logger.logBoxEnd();
// Force immediate shutdown
await this.forceImmediateShutdown();
return;
}
} catch (upsError) {
logger.error(`Error checking UPS ${ups.name} during shutdown: ${upsError.message}`);
}
}
// Wait before checking again
await this.sleep(CHECK_INTERVAL);
} catch (error) {
logger.error(`Error monitoring UPS during shutdown: ${error.message}`);
await this.sleep(CHECK_INTERVAL);
}
}
logger.log('UPS monitoring during shutdown completed');
}
/**
* Force an immediate system shutdown
*/
private async forceImmediateShutdown(): Promise<void> {
try { try {
// Find shutdown command in common system paths // Find shutdown command in common system paths
const shutdownPaths = [ const shutdownPaths = [
@ -437,23 +757,23 @@ export class NupstDaemon {
for (const path of shutdownPaths) { for (const path of shutdownPaths) {
if (fs.existsSync(path)) { if (fs.existsSync(path)) {
shutdownCmd = path; shutdownCmd = path;
console.log(`Found shutdown command at: ${shutdownCmd}`); logger.log(`Found shutdown command at: ${shutdownCmd}`);
break; break;
} }
} }
if (shutdownCmd) { if (shutdownCmd) {
console.log(`Executing emergency shutdown: ${shutdownCmd} -h now`); logger.log(`Executing emergency shutdown: ${shutdownCmd} -h now`);
await execFileAsync(shutdownCmd, ['-h', 'now', 'EMERGENCY: UPS battery critically low, shutting down NOW']); await execFileAsync(shutdownCmd, ['-h', 'now', 'EMERGENCY: UPS battery critically low, shutting down NOW']);
} else { } else {
// Try using the PATH to find shutdown // Try using the PATH to find shutdown
console.log('Shutdown command not found in common paths, trying via PATH...'); 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"', { await execAsync('shutdown -h now "EMERGENCY: UPS battery critically low, shutting down NOW"', {
env: process.env // Pass the current environment env: process.env // Pass the current environment
}); });
} }
} catch (error) { } catch (error) {
console.error('Emergency shutdown failed, trying alternative methods...'); logger.error('Emergency shutdown failed, trying alternative methods...');
// Try alternative shutdown methods in sequence // Try alternative shutdown methods in sequence
const alternatives = [ const alternatives = [
@ -481,12 +801,12 @@ export class NupstDaemon {
} }
if (cmdPath) { if (cmdPath) {
console.log(`Emergency: using ${cmdPath} ${alt.args.join(' ')}`); logger.log(`Emergency: using ${cmdPath} ${alt.args.join(' ')}`);
await execFileAsync(cmdPath, alt.args); await execFileAsync(cmdPath, alt.args);
return; // Exit if successful return; // Exit if successful
} else { } else {
// Try using PATH // Try using PATH
console.log(`Emergency: trying ${alt.cmd} via PATH`); logger.log(`Emergency: trying ${alt.cmd} via PATH`);
await execAsync(`${alt.cmd} ${alt.args.join(' ')}`, { await execAsync(`${alt.cmd} ${alt.args.join(' ')}`, {
env: process.env env: process.env
}); });
@ -497,22 +817,8 @@ export class NupstDaemon {
} }
} }
console.error('All emergency shutdown methods failed'); logger.error('All emergency shutdown methods failed');
} }
// 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);
await this.sleep(CHECK_INTERVAL);
}
}
console.log('UPS monitoring during shutdown completed');
} }
/** /**

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

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

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

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

View File

@ -1,6 +1,7 @@
#!/usr/bin/env node #!/usr/bin/env node
import { NupstCli } from './cli.js'; import { NupstCli } from './cli.js';
import { logger } from './logger.js';
/** /**
* Main entry point for NUPST * Main entry point for NUPST
@ -13,6 +14,6 @@ async function main() {
// Run the main function and handle any errors // Run the main function and handle any errors
main().catch(error => { main().catch(error => {
console.error('Error:', error); logger.error(`Error: ${error}`);
process.exit(1); process.exit(1);
}); });

147
ts/logger.ts Normal file
View File

@ -0,0 +1,147 @@
/**
* A simple logger class that provides consistent formatting for log messages
* including support for logboxes with title, lines, and closing
*/
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
*/
constructor() {
this.currentBoxWidth = null;
}
/**
* Get the singleton logger instance
* @returns The singleton logger instance
*/
public static getInstance(): Logger {
if (!Logger.instance) {
Logger.instance = new Logger();
}
return Logger.instance;
}
/**
* Log a message
* @param message Message to log
*/
public log(message: string): void {
console.log(message);
}
/**
* Log an error message
* @param message Error message to log
*/
public error(message: string): void {
console.error(message);
}
/**
* Log a warning message with a warning emoji
* @param message Warning message to log
*/
public warn(message: string): void {
console.warn(`⚠️ ${message}`);
}
/**
* Log a success message with a checkmark
* @param message Success message to log
*/
public success(message: string): void {
console.log(`${message}`);
}
/**
* Log a logbox title and set the current box width
* @param title Title of the logbox
* @param width Width of the logbox (including borders), defaults to DEFAULT_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 = this.currentBoxWidth - 3 - paddedTitle.length;
// Title line: ┌─ Title ───┐
const titleLine = `┌─${paddedTitle}${'─'.repeat(Math.max(0, remainingSpace))}`;
console.log(titleLine);
}
/**
* Log a logbox line
* @param content Content of the line
* @param width Optional width override. If not provided, uses the current box width or DEFAULT_WIDTH.
*/
public logBoxLine(content: string, width?: number): void {
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
if (content.length <= availableSpace - 1) {
// If content fits with at least one space for the right border stripe
const padding = availableSpace - content.length - 1;
console.log(`${content}${' '.repeat(padding)}`);
} else {
// Content is too long, let it flow out of boundaries.
console.log(`${content}`);
}
}
/**
* Log a logbox end
* @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 || 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, defaults to DEFAULT_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);
}
this.logBoxEnd();
}
/**
* Log a divider line
* @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 || this.DEFAULT_WIDTH));
}
}
// Export a singleton instance for easy use
export const logger = Logger.getInstance();

View File

@ -2,7 +2,10 @@ import { NupstSnmp } from './snmp/manager.js';
import { NupstDaemon } from './daemon.js'; import { NupstDaemon } from './daemon.js';
import { NupstSystemd } from './systemd.js'; import { NupstSystemd } from './systemd.js';
import { commitinfo } from './00_commitinfo_data.js'; import { commitinfo } from './00_commitinfo_data.js';
import { spawn } from 'child_process'; import { logger } from './logger.js';
import { UpsHandler } from './cli/ups-handler.js';
import { GroupHandler } from './cli/group-handler.js';
import { ServiceHandler } from './cli/service-handler.js';
import * as https from 'https'; import * as https from 'https';
/** /**
@ -13,6 +16,9 @@ export class Nupst {
private readonly snmp: NupstSnmp; private readonly snmp: NupstSnmp;
private readonly daemon: NupstDaemon; private readonly daemon: NupstDaemon;
private readonly systemd: NupstSystemd; private readonly systemd: NupstSystemd;
private readonly upsHandler: UpsHandler;
private readonly groupHandler: GroupHandler;
private readonly serviceHandler: ServiceHandler;
private updateAvailable: boolean = false; private updateAvailable: boolean = false;
private latestVersion: string = ''; private latestVersion: string = '';
@ -20,10 +26,16 @@ export class Nupst {
* Create a new Nupst instance with all necessary components * Create a new Nupst instance with all necessary components
*/ */
constructor() { constructor() {
// Initialize core components
this.snmp = new NupstSnmp(); this.snmp = new NupstSnmp();
this.snmp.setNupst(this); // Set up bidirectional reference this.snmp.setNupst(this); // Set up bidirectional reference
this.daemon = new NupstDaemon(this.snmp); this.daemon = new NupstDaemon(this.snmp);
this.systemd = new NupstSystemd(this.daemon); this.systemd = new NupstSystemd(this.daemon);
// Initialize handlers
this.upsHandler = new UpsHandler(this);
this.groupHandler = new GroupHandler(this);
this.serviceHandler = new ServiceHandler(this);
} }
/** /**
@ -47,6 +59,27 @@ export class Nupst {
return this.systemd; return this.systemd;
} }
/**
* Get the UPS handler for UPS management
*/
public getUpsHandler(): UpsHandler {
return this.upsHandler;
}
/**
* Get the Group handler for group management
*/
public getGroupHandler(): GroupHandler {
return this.groupHandler;
}
/**
* Get the Service handler for service management
*/
public getServiceHandler(): ServiceHandler {
return this.serviceHandler;
}
/** /**
* Get the current version of NUPST * Get the current version of NUPST
* @returns The current version string * @returns The current version string
@ -70,7 +103,7 @@ export class Nupst {
return this.updateAvailable; return this.updateAvailable;
} catch (error) { } catch (error) {
console.error(`Error checking for updates: ${error.message}`); logger.error(`Error checking for updates: ${error.message}`);
return false; return false;
} }
} }
@ -162,28 +195,33 @@ export class Nupst {
*/ */
public logVersionInfo(checkForUpdates: boolean = true): void { public logVersionInfo(checkForUpdates: boolean = true): void {
const version = this.getVersion(); const version = this.getVersion();
console.log('┌─ NUPST Version ────────────────────────────┐'); const boxWidth = 45;
console.log(`│ Current Version: ${version}`);
logger.logBoxTitle('NUPST Version', boxWidth);
logger.logBoxLine(`Current Version: ${version}`);
if (this.updateAvailable && this.latestVersion) { if (this.updateAvailable && this.latestVersion) {
console.log(`│ Update Available: ${this.latestVersion}`); logger.logBoxLine(`Update Available: ${this.latestVersion}`);
console.log('│ Run "sudo nupst update" to update'); logger.logBoxLine('Run "sudo nupst update" to update');
logger.logBoxEnd();
} else if (checkForUpdates) { } else if (checkForUpdates) {
console.log('│ Checking for updates...'); logger.logBoxLine('Checking for updates...');
// We can't end the box yet since we're in an async operation
this.checkForUpdates().then(updateAvailable => { this.checkForUpdates().then(updateAvailable => {
if (updateAvailable) { if (updateAvailable) {
console.log(`Update Available: ${this.latestVersion}`); logger.logBoxLine(`Update Available: ${this.latestVersion}`);
console.log('│ Run "sudo nupst update" to update'); logger.logBoxLine('Run "sudo nupst update" to update');
} else { } else {
console.log('│ You are running the latest version'); logger.logBoxLine('You are running the latest version');
} }
console.log('└──────────────────────────────────────────┘'); logger.logBoxEnd();
}).catch(() => { }).catch(() => {
console.log('│ Could not check for updates'); logger.logBoxLine('Could not check for updates');
console.log('└──────────────────────────────────────────┘'); logger.logBoxEnd();
}); });
} else { } else {
console.log('└──────────────────────────────────────────┘'); logger.logBoxEnd();
} }
} }
} }

View File

@ -1,6 +1,7 @@
import { promises as fs } from 'fs'; import { promises as fs } from 'fs';
import { execSync } from 'child_process'; import { execSync } from 'child_process';
import { NupstDaemon } from './daemon.js'; import { NupstDaemon } from './daemon.js';
import { logger } from './logger.js';
/** /**
* Class for managing systemd service * Class for managing systemd service
@ -13,7 +14,7 @@ export class NupstSystemd {
/** Template for the systemd service file */ /** Template for the systemd service file */
private readonly serviceTemplate = `[Unit] private readonly serviceTemplate = `[Unit]
Description=Node.js UPS Shutdown Tool Description=Node.js UPS Shutdown Tool for Multiple UPS Devices
After=network.target After=network.target
[Service] [Service]
@ -47,10 +48,11 @@ WantedBy=multi-user.target
try { try {
await fs.access(configPath); await fs.access(configPath);
} catch (error) { } catch (error) {
console.error('┌─ Configuration Error ─────────────────────┐'); const boxWidth = 50;
console.error(`│ No configuration file found at ${configPath}`); logger.logBoxTitle('Configuration Error', boxWidth);
console.error('│ Please run \'nupst setup\' first to create a configuration.'); logger.logBoxLine(`No configuration file found at ${configPath}`);
console.error('└──────────────────────────────────────────┘'); logger.logBoxLine("Please run 'nupst add' first to create a UPS configuration.");
logger.logBoxEnd();
throw new Error('Configuration not found'); throw new Error('Configuration not found');
} }
} }
@ -66,23 +68,24 @@ WantedBy=multi-user.target
// Write the service file // Write the service file
await fs.writeFile(this.serviceFilePath, this.serviceTemplate); await fs.writeFile(this.serviceFilePath, this.serviceTemplate);
console.log('┌─ Service Installation ──────────────────────┐'); const boxWidth = 50;
console.log(`│ Service file created at ${this.serviceFilePath}`); logger.logBoxTitle('Service Installation', boxWidth);
logger.logBoxLine(`Service file created at ${this.serviceFilePath}`);
// Reload systemd daemon // Reload systemd daemon
execSync('systemctl daemon-reload'); execSync('systemctl daemon-reload');
console.log('│ Systemd daemon reloaded'); logger.logBoxLine('Systemd daemon reloaded');
// Enable the service // Enable the service
execSync('systemctl enable nupst.service'); execSync('systemctl enable nupst.service');
console.log('│ Service enabled to start on boot'); logger.logBoxLine('Service enabled to start on boot');
console.log('└────────────────────────────────────────────────┘'); logger.logBoxEnd();
} catch (error) { } catch (error) {
if (error.message === 'Configuration not found') { if (error.message === 'Configuration not found') {
// Just rethrow the error as the message has already been displayed // Just rethrow the error as the message has already been displayed
throw error; throw error;
} }
console.error('Failed to install systemd service:', error); logger.error(`Failed to install systemd service: ${error}`);
throw error; throw error;
} }
} }
@ -97,15 +100,16 @@ WantedBy=multi-user.target
await this.checkConfigExists(); await this.checkConfigExists();
execSync('systemctl start nupst.service'); execSync('systemctl start nupst.service');
console.log('┌─ Service Status ───────────────────────────┐'); const boxWidth = 45;
console.log('│ NUPST service started successfully'); logger.logBoxTitle('Service Status', boxWidth);
console.log('└────────────────────────────────────────────┘'); logger.logBoxLine('NUPST service started successfully');
logger.logBoxEnd();
} catch (error) { } catch (error) {
if (error.message === 'Configuration not found') { if (error.message === 'Configuration not found') {
// Exit with error code since configuration is required // Exit with error code since configuration is required
process.exit(1); process.exit(1);
} }
console.error('Failed to start service:', error); logger.error(`Failed to start service: ${error}`);
throw error; throw error;
} }
} }
@ -117,9 +121,9 @@ WantedBy=multi-user.target
public async stop(): Promise<void> { public async stop(): Promise<void> {
try { try {
execSync('systemctl stop nupst.service'); execSync('systemctl stop nupst.service');
console.log('NUPST service stopped'); logger.success('NUPST service stopped');
} catch (error) { } catch (error) {
console.error('Failed to stop service:', error); logger.error(`Failed to stop service: ${error}`);
throw error; throw error;
} }
} }
@ -132,9 +136,10 @@ WantedBy=multi-user.target
try { try {
// Enable debug mode if requested // Enable debug mode if requested
if (debugMode) { if (debugMode) {
console.log('┌─ Debug Mode ─────────────────────────────┐'); const boxWidth = 45;
console.log('│ SNMP debugging enabled - detailed logs will be shown'); logger.logBoxTitle('Debug Mode', boxWidth);
console.log('└──────────────────────────────────────────┘'); logger.logBoxLine('SNMP debugging enabled - detailed logs will be shown');
logger.logBoxEnd();
this.daemon.getNupstSnmp().enableDebug(); this.daemon.getNupstSnmp().enableDebug();
} }
@ -150,9 +155,9 @@ WantedBy=multi-user.target
} }
await this.displayServiceStatus(); await this.displayServiceStatus();
await this.displayUpsStatus(); await this.displayAllUpsStatus();
} catch (error) { } catch (error) {
console.error(`Failed to get status: ${error.message}`); logger.error(`Failed to get status: ${error.message}`);
} }
} }
@ -163,49 +168,115 @@ WantedBy=multi-user.target
private async displayServiceStatus(): Promise<void> { private async displayServiceStatus(): Promise<void> {
try { try {
const serviceStatus = execSync('systemctl status nupst.service').toString(); const serviceStatus = execSync('systemctl status nupst.service').toString();
console.log('┌─ Service Status ─────────────────────────┐'); const boxWidth = 45;
console.log(serviceStatus.split('\n').map(line => `${line}`).join('\n')); logger.logBoxTitle('Service Status', boxWidth);
console.log('└──────────────────────────────────────────┘'); // Process each line of the status output
serviceStatus.split('\n').forEach(line => {
logger.logBoxLine(line);
});
logger.logBoxEnd();
} catch (error) { } catch (error) {
console.error('┌─ Service Status ─────────────────────────┐'); const boxWidth = 45;
console.error('│ Service is not running'); logger.logBoxTitle('Service Status', boxWidth);
console.error('└──────────────────────────────────────────┘'); logger.logBoxLine('Service is not running');
logger.logBoxEnd();
} }
} }
/** /**
* Display the UPS status * Display all UPS statuses
* @private * @private
*/ */
private async displayUpsStatus(): Promise<void> { private async displayAllUpsStatus(): Promise<void> {
try { try {
// Explicitly load the configuration first to ensure it's up-to-date // Explicitly load the configuration first to ensure it's up-to-date
await this.daemon.loadConfig(); await this.daemon.loadConfig();
const config = this.daemon.getConfig(); const config = this.daemon.getConfig();
const snmp = this.daemon.getNupstSnmp(); const snmp = this.daemon.getNupstSnmp();
// Create a test config with appropriate timeout, similar to the test command // Check if we have the new multi-UPS config format
const snmpConfig = { if (config.upsDevices && Array.isArray(config.upsDevices) && config.upsDevices.length > 0) {
...config.snmp, logger.log(`Found ${config.upsDevices.length} UPS device(s) in configuration`);
timeout: Math.min(config.snmp.timeout, 10000) // Use at most 10 seconds for status check
// Show status for each UPS
for (const ups of config.upsDevices) {
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: []
}; };
console.log('┌─ Connecting to UPS... ─────────────────────┐'); await this.displaySingleUpsStatus(legacyUps, snmp);
console.log(`│ Host: ${config.snmp.host}:${config.snmp.port}`); } else {
console.log(`│ UPS Model: ${config.snmp.upsModel || 'cyberpower'}`); logger.error('No UPS devices found in configuration');
console.log('└────────────────────────────────────────────┘'); }
const status = await snmp.getUpsStatus(snmpConfig);
console.log('┌─ UPS Status ─────────────────────────────┐');
console.log(`│ Power Status: ${status.powerStatus}`);
console.log(`│ Battery Capacity: ${status.batteryCapacity}%`);
console.log(`│ Runtime Remaining: ${status.batteryRuntime} minutes`);
console.log('└──────────────────────────────────────────┘');
} catch (error) { } catch (error) {
console.error('┌─ UPS Status ─────────────────────────────┐'); const boxWidth = 45;
console.error(`│ Failed to retrieve UPS status: ${error.message}`); logger.logBoxTitle('UPS Status', boxWidth);
console.error('└──────────────────────────────────────────┘'); logger.logBoxLine(`Failed to retrieve UPS status: ${error.message}`);
logger.logBoxEnd();
}
}
/**
* Display status of a single UPS
* @param ups UPS configuration
* @param snmp SNMP manager
*/
private async displaySingleUpsStatus(ups: any, snmp: any): Promise<void> {
const boxWidth = 45;
logger.logBoxTitle(`Connecting to UPS: ${ups.name}`, boxWidth);
logger.logBoxLine(`ID: ${ups.id}`);
logger.logBoxLine(`Host: ${ups.snmp.host}:${ups.snmp.port}`);
logger.logBoxLine(`UPS Model: ${ups.snmp.upsModel || 'cyberpower'}`);
if (ups.groups && ups.groups.length > 0) {
// Get group names if available
const config = this.daemon.getConfig();
const groupNames = ups.groups.map(groupId => {
const group = config.groups?.find(g => g.id === groupId);
return group ? group.name : groupId;
});
logger.logBoxLine(`Groups: ${groupNames.join(', ')}`);
}
logger.logBoxEnd();
try {
// Create a test config with a short timeout
const testConfig = {
...ups.snmp,
timeout: Math.min(ups.snmp.timeout, 10000) // Use at most 10 seconds for status check
};
const status = await snmp.getUpsStatus(testConfig);
logger.logBoxTitle(`UPS Status: ${ups.name}`, boxWidth);
logger.logBoxLine(`Power Status: ${status.powerStatus}`);
logger.logBoxLine(`Battery Capacity: ${status.batteryCapacity}%`);
logger.logBoxLine(`Runtime Remaining: ${status.batteryRuntime} minutes`);
// Show threshold status
logger.logBoxLine('');
logger.logBoxLine('Thresholds:');
logger.logBoxLine(` Battery: ${status.batteryCapacity}% / ${ups.thresholds.battery}% ${
status.batteryCapacity < ups.thresholds.battery ? '⚠️' : '✓'
}`);
logger.logBoxLine(` Runtime: ${status.batteryRuntime} min / ${ups.thresholds.runtime} min ${
status.batteryRuntime < ups.thresholds.runtime ? '⚠️' : '✓'
}`);
logger.logBoxEnd();
} catch (error) {
logger.logBoxTitle(`UPS Status: ${ups.name}`, boxWidth);
logger.logBoxLine(`Failed to retrieve UPS status: ${error.message}`);
logger.logBoxEnd();
} }
} }
@ -221,10 +292,10 @@ WantedBy=multi-user.target
// Reload systemd daemon // Reload systemd daemon
execSync('systemctl daemon-reload'); execSync('systemctl daemon-reload');
console.log('Systemd daemon reloaded'); logger.log('Systemd daemon reloaded');
console.log('NUPST service has been successfully uninstalled'); logger.success('NUPST service has been successfully uninstalled');
} catch (error) { } catch (error) {
console.error('Failed to disable and uninstall service:', error); logger.error(`Failed to disable and uninstall service: ${error}`);
throw error; throw error;
} }
} }
@ -235,11 +306,11 @@ WantedBy=multi-user.target
*/ */
private async stopService(): Promise<void> { private async stopService(): Promise<void> {
try { try {
console.log('Stopping NUPST service...'); logger.log('Stopping NUPST service...');
execSync('systemctl stop nupst.service'); execSync('systemctl stop nupst.service');
} catch (error) { } catch (error) {
// Service might not be running, that's okay // Service might not be running, that's okay
console.log('Service was not running or could not be stopped'); logger.log('Service was not running or could not be stopped');
} }
} }
@ -249,10 +320,10 @@ WantedBy=multi-user.target
*/ */
private async disableService(): Promise<void> { private async disableService(): Promise<void> {
try { try {
console.log('Disabling NUPST service...'); logger.log('Disabling NUPST service...');
execSync('systemctl disable nupst.service'); execSync('systemctl disable nupst.service');
} catch (error) { } catch (error) {
console.log('Service was not enabled or could not be disabled'); logger.log('Service was not enabled or could not be disabled');
} }
} }
@ -262,11 +333,11 @@ WantedBy=multi-user.target
*/ */
private async removeServiceFile(): Promise<void> { private async removeServiceFile(): Promise<void> {
if (await fs.stat(this.serviceFilePath).catch(() => null)) { if (await fs.stat(this.serviceFilePath).catch(() => null)) {
console.log(`Removing service file ${this.serviceFilePath}...`); logger.log(`Removing service file ${this.serviceFilePath}...`);
await fs.unlink(this.serviceFilePath); await fs.unlink(this.serviceFilePath);
console.log('Service file removed'); logger.log('Service file removed');
} else { } else {
console.log('Service file did not exist'); logger.log('Service file did not exist');
} }
} }
} }