Compare commits

..

27 Commits

Author SHA1 Message Date
d1a3576d31 chore(release): bump version to 4.3.1
Some checks failed
CI / Type Check & Lint (push) Failing after 4s
CI / Build Test (Current Platform) (push) Successful in 4s
Release / build-and-release (push) Successful in 42s
CI / Build All Platforms (push) Successful in 46s
2025-10-20 12:34:52 +00:00
1ca05e879b feat(action): add group action support
Extended action management to support groups in addition to UPS devices:

Changes:
- Auto-detects whether target ID is a UPS or group
- All action commands now work with both UPS and groups:
  * nupst action add <ups-id|group-id>
  * nupst action remove <ups-id|group-id> <index>
  * nupst action list [ups-id|group-id]

- Updated ActionHandler methods to handle both target types
- Updated help text and usage examples
- List command shows both UPS and group actions when no target specified
- Clear labeling in output distinguishes UPS actions from group actions

Example usage:
  nupst action list                 # Shows all UPS and group actions
  nupst action add dc-rack-1        # Adds action to group 'dc-rack-1'
  nupst action remove default 0     # Removes action from UPS 'default'

Groups can now have their own shutdown actions, allowing fine-grained
control over group behavior during power events.
2025-10-20 12:34:47 +00:00
9c6fa37eb8 chore(release): bump version to 4.3.0
Some checks failed
CI / Type Check & Lint (push) Failing after 5s
CI / Build Test (Current Platform) (push) Successful in 4s
Release / build-and-release (push) Successful in 43s
CI / Build All Platforms (push) Successful in 48s
2025-10-20 12:32:17 +00:00
ff433b2256 feat(cli): add action management commands
Added comprehensive action management:

Commands:
- nupst action add <ups-id> - Add a new action to a UPS interactively
- nupst action remove <ups-id> <index> - Remove an action by index
- nupst action list [ups-id] - List all actions (optionally for specific UPS)

Features:
- Interactive prompts for action configuration
- Battery and runtime threshold configuration
- Trigger mode selection (onlyPowerChanges, onlyThresholds, powerChangesAndThresholds, anyChange)
- Shutdown delay configuration
- Table-based display of actions with indices
- Support for managing actions across multiple UPS devices

Implementation:
- Created ActionHandler class in ts/cli/action-handler.ts
- Integrated with existing CLI infrastructure
- Added to nupst.ts, cli.ts, and help system
- Proper TypeScript typing throughout

Closes the gap where users had to manually edit config.json to manage actions.
2025-10-20 12:32:14 +00:00
263d69aef1 chore(release): bump version to 4.2.5
Some checks failed
CI / Type Check & Lint (push) Failing after 5s
CI / Build Test (Current Platform) (push) Successful in 5s
Release / build-and-release (push) Successful in 42s
CI / Build All Platforms (push) Successful in 47s
2025-10-20 12:27:06 +00:00
b6b7b43161 refactor: replace 'any' types with proper TypeScript interfaces
Major type safety improvements throughout the codebase:

- Updated DEFAULT_CONFIG version to 4.2
- Replaced 'any' with proper types in systemd.ts:
  * displaySingleUpsStatus now uses IUpsConfig and NupstSnmp types
  * Fixed legacy config handling to use proper IUpsConfig format
  * Removed inline 'any' type annotations

- Replaced 'any' with proper types in daemon.ts:
  * emergencyUps now properly typed as { ups: IUpsConfig, status: ISnmpUpsStatus }
  * Exported IUpsStatus interface for reuse
  * Added ISnmpUpsStatus import to disambiguate from daemon's IUpsStatus

- Replaced 'any' with Record<string, unknown> in migration system:
  * Updated BaseMigration abstract class signatures
  * Updated MigrationRunner.run() signature
  * Updated migration-v4.0-to-v4.1.ts to use proper types
  * Migrations use Record<string, unknown> because they deal with
    unknown config schemas that are being upgraded

Benefits:
- TypeScript now catches type errors at compile time
- Would have caught the ups.thresholds bug earlier
- Better IDE autocomplete and type checking
- More maintainable and self-documenting code
2025-10-20 12:27:02 +00:00
316c66c344 chore(release): bump version to 4.2.4
Some checks failed
CI / Type Check & Lint (push) Failing after 5s
CI / Build Test (Current Platform) (push) Successful in 5s
Release / build-and-release (push) Successful in 45s
CI / Build All Platforms (push) Successful in 50s
2025-10-20 12:20:42 +00:00
4debda856b fix(status): update status display to use action-based thresholds
The status command was still trying to access ups.thresholds.battery which
no longer exists in v4.1+ configs. Thresholds are now in the actions array.

Changes:
- Updated displaySingleUpsStatus() to get thresholds from actions
- Finds first action with thresholds defined for battery symbol display
- Shows success/warning symbol only if threshold is defined

This fixes 'Cannot read properties of undefined (reading battery)' error
when running nupst status on v4.1+ configs.
2025-10-20 12:20:40 +00:00
0e7bcab499 chore(release): bump version to 4.2.3
Some checks failed
CI / Type Check & Lint (push) Failing after 4s
CI / Build Test (Current Platform) (push) Successful in 6s
Release / build-and-release (push) Successful in 43s
CI / Build All Platforms (push) Successful in 48s
2025-10-20 12:17:06 +00:00
7bf65d8495 fix(migrations): revert to correct migration v4.0-to-v4.1
The migration was correct as v4.0→v4.1. Config version goes from 4.0 to 4.1
when thresholds are moved to actions. The original error was not the migration
but the ups-handler.ts bug (already fixed in v4.2.1).

User's config shows version "4.1" with actions already present, confirming
the migration ran successfully.
2025-10-20 12:17:03 +00:00
f2ce0180d3 chore(release): bump version to 4.2.2
Some checks failed
CI / Type Check & Lint (push) Failing after 5s
CI / Build Test (Current Platform) (push) Successful in 6s
Release / build-and-release (push) Successful in 45s
CI / Build All Platforms (push) Successful in 48s
2025-10-20 12:14:16 +00:00
8c1be6555f fix(migrations): correct migration version from v4.0-to-v4.1 to v4.1-to-v4.2
The migration was incorrectly named as v4.0→v4.1 but was actually performing
the v4.1→v4.2 migration (moving thresholds from UPS-level to action-level).
This meant users upgrading from v4.1 would not get their configs migrated.

Changes:
- Renamed migration file from migration-v4.0-to-v4.1.ts to migration-v4.1-to-v4.2.ts
- Updated class name from MigrationV4_0ToV4_1 to MigrationV4_1ToV4_2
- Updated fromVersion from '4.0' to '4.1'
- Updated toVersion from '4.1' to '4.2'
- Updated shouldRun() to check for config.version === '4.1'
- Updated all imports and exports to reference the new class name
- Updated comments and log messages to reflect v4.1→v4.2 migration
2025-10-20 12:14:02 +00:00
1a5558e91f chore(release): bump version to 4.2.1
Some checks failed
CI / Type Check & Lint (push) Failing after 5s
CI / Build Test (Current Platform) (push) Successful in 4s
Release / build-and-release (push) Successful in 43s
CI / Build All Platforms (push) Successful in 51s
2025-10-20 12:08:44 +00:00
611a9ddd19 fix(cli): remove obsolete gatherThresholdSettings method
- Remove call to gatherThresholdSettings in runAddProcess
- Delete entire gatherThresholdSettings method
- Thresholds are now configured per-action in gatherActionSettings

Fixes: Cannot read properties of undefined (reading 'battery')
2025-10-20 12:08:29 +00:00
afd026d08c refactor(cli, ups-handler, daemon, migrations): remove thresholds handling and update migration order logic
Some checks failed
CI / Type Check & Lint (push) Failing after 4s
CI / Build Test (Current Platform) (push) Successful in 5s
Release / build-and-release (push) Successful in 41s
CI / Build All Platforms (push) Successful in 46s
2025-10-20 12:03:14 +00:00
2c8ea44d40 chore(release): bump version to 4.2.0
Some checks failed
CI / Type Check & Lint (push) Failing after 4s
CI / Build Test (Current Platform) (push) Failing after 3s
CI / Build All Platforms (push) Failing after 3s
Release / build-and-release (push) Failing after 3s
2025-10-20 11:59:54 +00:00
32bd27b849 feat(actions): implement action system for UPS state management with shutdown, webhook, and script actions 2025-10-20 11:47:51 +00:00
a7113d0387 fix(daemon): replace require() with ES6 imports for Deno compatibility
All checks were successful
CI / Type Check & Lint (push) Successful in 4s
CI / Build Test (Current Platform) (push) Successful in 4s
Release / build-and-release (push) Successful in 47s
CI / Build All Platforms (push) Successful in 49s
- Add proper ES6 imports at top of file for theme, symbols, colors
- Remove all require() calls that were causing 'require is not defined' errors
- Daemon now starts properly with modernized logging intact
2025-10-20 01:43:09 +00:00
61d4e9037a chore(release): bump version to 4.1.6
Some checks failed
CI / Type Check & Lint (push) Failing after 5s
CI / Build Test (Current Platform) (push) Successful in 4s
Release / build-and-release (push) Successful in 44s
CI / Build All Platforms (push) Successful in 49s
2025-10-20 01:38:52 +00:00
caced2718f feat(daemon): modernize daemon logging with tables and color-coded output
- Modernize periodic status update with logger.logTable() and color-coded battery/runtime
- Modernize configuration loaded display with tables for UPS devices and groups
- Enhance power status change notifications with better colors and timestamps
- Modernize shutdown monitoring with real-time table display of UPS status
- Add color-coded CRITICAL indicators for emergency conditions
- Improve visual hierarchy with appropriate box styles (info, warning, error, success)
- Ensure consistent theming across all daemon log output
2025-10-20 01:38:44 +00:00
8516056f84 chore(release): bump version to 4.1.5
All checks were successful
CI / Type Check & Lint (push) Successful in 5s
CI / Build Test (Current Platform) (push) Successful in 4s
Release / build-and-release (push) Successful in 44s
CI / Build All Platforms (push) Successful in 48s
2025-10-20 01:31:43 +00:00
07ec9d7595 feat(cli): modernize all CLI output to use logger tables
- Modernize ups list command with logger.logTable()
- Modernize group list command with logger.logTable()
- Completely rewrite config show with tables and proper box styling
- Add professional column definitions with themed colors
- Replace all manual table formatting (padEnd, pipe separators)
- Improve visual hierarchy with appropriate box styles (info, warning, success)
- Ensure consistent theming across all CLI commands
2025-10-20 01:30:57 +00:00
d14ba1dd65 feat(status): display version and update status in nupst status command
All checks were successful
CI / Type Check & Lint (push) Successful in 5s
CI / Build Test (Current Platform) (push) Successful in 4s
Release / build-and-release (push) Successful in 46s
CI / Build All Platforms (push) Successful in 49s
- Add version display at the top of status output
- Check for available updates and notify user
- Show "Up to date" or "Update available" with version
- Display before service and UPS status information
- Improves user awareness of software version and updates

Bumps version to 4.1.4
2025-10-20 01:01:06 +00:00
7d595fa175 chore(release): bump version to 4.1.3
All checks were successful
CI / Type Check & Lint (push) Successful in 5s
CI / Build Test (Current Platform) (push) Successful in 4s
Release / build-and-release (push) Successful in 45s
CI / Build All Platforms (push) Successful in 51s
2025-10-20 00:40:56 +00:00
df417432b0 chore(branding): update description to 'Network UPS Shutdown Tool' 2025-10-20 00:40:52 +00:00
e5f1ebf343 chore(release): bump version to 4.1.2
All checks were successful
CI / Type Check & Lint (push) Successful in 6s
CI / Build Test (Current Platform) (push) Successful in 5s
Release / build-and-release (push) Successful in 46s
CI / Build All Platforms (push) Successful in 50s
2025-10-20 00:34:03 +00:00
3ff0dd7ac8 fix(cli): resolve process hang and improve output consistency
- Add process.stdin.destroy() after rl.close() in all interactive commands
  to properly release stdin and allow process to exit cleanly
- Replace raw console.log with logger methods throughout CLI handlers
- Convert manual box drawing to logger.logBox() in daemon.ts
- Standardize menu formatting with logger.info() and logger.dim()
- Improve migration output to only show when migrations actually run

Fixes issue where process would not exit after "Setup complete!" message
due to stdin keeping the event loop alive.
2025-10-20 00:32:06 +00:00
23 changed files with 2246 additions and 592 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "@serve.zone/nupst", "name": "@serve.zone/nupst",
"version": "4.1.1", "version": "4.3.1",
"exports": "./mod.ts", "exports": "./mod.ts",
"tasks": { "tasks": {
"dev": "deno run --allow-all mod.ts", "dev": "deno run --allow-all mod.ts",

122
example-action.sh Normal file
View File

@@ -0,0 +1,122 @@
#!/bin/bash
# NUPST Action Script Example
# Copy this to /etc/nupst/ and customize for your needs
#
# This script is called by NUPST when power events or threshold violations occur.
# It receives UPS state information via environment variables and command-line arguments.
# ==============================================================================
# ARGUMENTS (positional parameters)
# ==============================================================================
# $1 = Power Status (online|onBattery|unknown)
# $2 = Battery Capacity (percentage, 0-100)
# $3 = Battery Runtime (estimated minutes remaining)
POWER_STATUS=$1
BATTERY_CAPACITY=$2
BATTERY_RUNTIME=$3
# ==============================================================================
# ENVIRONMENT VARIABLES
# ==============================================================================
# NUPST_UPS_ID - Unique UPS identifier
# NUPST_UPS_NAME - Human-readable UPS name
# NUPST_POWER_STATUS - Current power status
# NUPST_BATTERY_CAPACITY - Battery percentage (0-100)
# NUPST_BATTERY_RUNTIME - Estimated runtime in minutes
# NUPST_THRESHOLDS_EXCEEDED - "true" if below configured thresholds
# NUPST_TRIGGER_REASON - "powerStatusChange" or "thresholdViolation"
# NUPST_BATTERY_THRESHOLD - Configured battery threshold percentage
# NUPST_RUNTIME_THRESHOLD - Configured runtime threshold in minutes
# NUPST_TIMESTAMP - Unix timestamp (milliseconds since epoch)
# ==============================================================================
# EXAMPLE: Log the event
# ==============================================================================
LOG_FILE="/var/log/nupst-actions.log"
echo "========================================" >> "$LOG_FILE"
echo "NUPST Action Triggered: $(date)" >> "$LOG_FILE"
echo "----------------------------------------" >> "$LOG_FILE"
echo "UPS: $NUPST_UPS_NAME ($NUPST_UPS_ID)" >> "$LOG_FILE"
echo "Power Status: $POWER_STATUS" >> "$LOG_FILE"
echo "Battery: $BATTERY_CAPACITY%" >> "$LOG_FILE"
echo "Runtime: $BATTERY_RUNTIME minutes" >> "$LOG_FILE"
echo "Trigger Reason: $NUPST_TRIGGER_REASON" >> "$LOG_FILE"
echo "Thresholds Exceeded: $NUPST_THRESHOLDS_EXCEEDED" >> "$LOG_FILE"
echo "========================================" >> "$LOG_FILE"
# ==============================================================================
# EXAMPLE: Send email notification
# ==============================================================================
# if [ "$NUPST_TRIGGER_REASON" = "thresholdViolation" ]; then
# echo "ALERT: UPS $NUPST_UPS_NAME battery critical!" | \
# mail -s "UPS Battery Critical" admin@example.com
# fi
# ==============================================================================
# EXAMPLE: Gracefully shutdown virtual machines
# ==============================================================================
# if [ "$NUPST_POWER_STATUS" = "onBattery" ] && [ "$NUPST_THRESHOLDS_EXCEEDED" = "true" ]; then
# echo "Shutting down VMs..." >> "$LOG_FILE"
# # virsh shutdown vm1
# # virsh shutdown vm2
# # Wait for VMs to shutdown
# # sleep 120
# fi
# ==============================================================================
# EXAMPLE: Call external API/service
# ==============================================================================
# curl -X POST https://monitoring.example.com/ups-alert \
# -H "Content-Type: application/json" \
# -d "{
# \"upsId\": \"$NUPST_UPS_ID\",
# \"upsName\": \"$NUPST_UPS_NAME\",
# \"powerStatus\": \"$POWER_STATUS\",
# \"batteryCapacity\": $BATTERY_CAPACITY,
# \"batteryRuntime\": $BATTERY_RUNTIME,
# \"triggerReason\": \"$NUPST_TRIGGER_REASON\"
# }"
# ==============================================================================
# EXAMPLE: Remote shutdown via SSH with password
# ==============================================================================
# You can implement custom shutdown logic for remote systems
# that require password authentication or webhooks
#
# if [ "$NUPST_THRESHOLDS_EXCEEDED" = "true" ]; then
# # Call a webhook with a secret password/token
# curl -X POST "https://remote-server.local/shutdown?token=YOUR_SECRET_TOKEN"
#
# # Or use SSH with password (requires sshpass)
# # sshpass -p 'your-password' ssh user@remote-server 'sudo shutdown -h +5'
# fi
# ==============================================================================
# EXAMPLE: Conditional logic based on battery level
# ==============================================================================
# if [ "$BATTERY_CAPACITY" -lt 20 ]; then
# echo "Battery critically low! Immediate action needed." >> "$LOG_FILE"
# elif [ "$BATTERY_CAPACITY" -lt 50 ]; then
# echo "Battery low. Preparing for shutdown." >> "$LOG_FILE"
# else
# echo "Battery acceptable. Monitoring." >> "$LOG_FILE"
# fi
# ==============================================================================
# EXAMPLE: Different actions for different trigger reasons
# ==============================================================================
# case "$NUPST_TRIGGER_REASON" in
# powerStatusChange)
# echo "Power status changed to: $POWER_STATUS" >> "$LOG_FILE"
# # Send notification but don't take drastic action yet
# ;;
# thresholdViolation)
# echo "Thresholds violated! Taking emergency action." >> "$LOG_FILE"
# # Initiate graceful shutdowns, save data, etc.
# ;;
# esac
# Exit with success
exit 0

View File

@@ -6,5 +6,5 @@ import denoConfig from '../deno.json' with { type: 'json' };
export const commitinfo = { export const commitinfo = {
name: denoConfig.name, name: denoConfig.name,
version: denoConfig.version, version: denoConfig.version,
description: 'Deno-powered UPS monitoring tool for SNMP-enabled UPS devices', description: 'Network UPS Shutdown Tool (https://nupst.serve.zone)',
}; };

170
ts/actions/base-action.ts Normal file
View File

@@ -0,0 +1,170 @@
/**
* Base classes and interfaces for the NUPST action system
*
* Actions are triggered on:
* 1. Power status changes (online ↔ onBattery)
* 2. Threshold violations (battery/runtime cross below configured thresholds)
*/
export type TPowerStatus = 'online' | 'onBattery' | 'unknown';
/**
* Context provided to actions when they execute
* Contains all relevant UPS state and trigger information
*/
export interface IActionContext {
// UPS identification
/** Unique ID of the UPS */
upsId: string;
/** Human-readable name of the UPS */
upsName: string;
// Current state
/** Current power status */
powerStatus: TPowerStatus;
/** Current battery capacity percentage (0-100) */
batteryCapacity: number;
/** Estimated battery runtime in minutes */
batteryRuntime: number;
// State tracking
/** Previous power status before this trigger */
previousPowerStatus: TPowerStatus;
// Metadata
/** Timestamp when this action was triggered (milliseconds since epoch) */
timestamp: number;
/** Reason this action was triggered */
triggerReason: 'powerStatusChange' | 'thresholdViolation';
}
/**
* Action trigger mode - determines when an action executes
*/
export type TActionTriggerMode =
| 'onlyPowerChanges' // Only on power status changes (online ↔ onBattery)
| 'onlyThresholds' // Only when action's thresholds are exceeded
| 'powerChangesAndThresholds' // On power changes OR threshold violations
| 'anyChange'; // On every UPS poll/check (every ~30s)
/**
* Configuration for an action
*/
export interface IActionConfig {
/** Type of action to execute */
type: 'shutdown' | 'webhook' | 'script';
// Trigger configuration
/**
* When should this action be triggered?
* - onlyPowerChanges: Only on power status changes
* - onlyThresholds: Only when thresholds exceeded
* - powerChangesAndThresholds: On both (default)
* - anyChange: On every check
*/
triggerMode?: TActionTriggerMode;
// Threshold configuration (applies to all action types)
/** Threshold settings for this action */
thresholds?: {
/** Battery percentage threshold (0-100) */
battery: number;
/** Runtime threshold in minutes */
runtime: number;
};
// Shutdown action configuration
/** Delay before shutdown in minutes (default: 5) */
shutdownDelay?: number;
/** Only execute shutdown on threshold violation, not power status changes */
onlyOnThresholdViolation?: boolean;
// Webhook action configuration
/** URL to call for webhook */
webhookUrl?: string;
/** HTTP method to use (default: POST) */
webhookMethod?: 'GET' | 'POST';
/** Timeout for webhook request in milliseconds (default: 10000) */
webhookTimeout?: number;
/** Only execute webhook on threshold violation */
webhookOnlyOnThresholdViolation?: boolean;
// Script action configuration
/** Path to script relative to /etc/nupst (e.g., "myaction.sh") */
scriptPath?: string;
/** Timeout for script execution in milliseconds (default: 60000) */
scriptTimeout?: number;
/** Only execute script on threshold violation */
scriptOnlyOnThresholdViolation?: boolean;
}
/**
* Abstract base class for all actions
* Each action type must extend this class and implement execute()
*/
export abstract class Action {
/** Type identifier for this action */
abstract readonly type: string;
/**
* Create a new action with the given configuration
* @param config Action configuration
*/
constructor(protected config: IActionConfig) {}
/**
* Execute this action with the given context
* @param context Current UPS state and trigger information
*/
abstract execute(context: IActionContext): Promise<void>;
/**
* Helper to check if this action should execute based on trigger mode
* @param context Action context with current UPS state
* @returns True if action should execute
*/
protected shouldExecute(context: IActionContext): boolean {
const mode = this.config.triggerMode || 'powerChangesAndThresholds'; // Default
switch (mode) {
case 'onlyPowerChanges':
// Only execute on power status changes
return context.triggerReason === 'powerStatusChange';
case 'onlyThresholds':
// Only execute when this action's thresholds are exceeded
if (!this.config.thresholds) return false; // No thresholds = never execute
return this.areThresholdsExceeded(context.batteryCapacity, context.batteryRuntime);
case 'powerChangesAndThresholds':
// Execute on power changes OR when thresholds exceeded
if (context.triggerReason === 'powerStatusChange') return true;
if (!this.config.thresholds) return false;
return this.areThresholdsExceeded(context.batteryCapacity, context.batteryRuntime);
case 'anyChange':
// Execute on every trigger (power change or threshold check)
return true;
default:
return true;
}
}
/**
* Check if current battery/runtime exceeds this action's thresholds
* @param batteryCapacity Current battery percentage
* @param batteryRuntime Current runtime in minutes
* @returns True if thresholds are exceeded
*/
protected areThresholdsExceeded(batteryCapacity: number, batteryRuntime: number): boolean {
if (!this.config.thresholds) {
return false; // No thresholds configured
}
return (
batteryCapacity < this.config.thresholds.battery ||
batteryRuntime < this.config.thresholds.runtime
);
}
}

91
ts/actions/index.ts Normal file
View File

@@ -0,0 +1,91 @@
/**
* Action system exports and ActionManager
*
* This module provides the central coordination for the action system.
* The ActionManager is responsible for creating and executing actions.
*/
import { logger } from '../logger.ts';
import type { Action, IActionConfig, IActionContext } from './base-action.ts';
import { ShutdownAction } from './shutdown-action.ts';
import { WebhookAction } from './webhook-action.ts';
import { ScriptAction } from './script-action.ts';
// Re-export types for convenience
export type { IActionConfig, IActionContext, TPowerStatus } from './base-action.ts';
export { Action } from './base-action.ts';
export { ShutdownAction } from './shutdown-action.ts';
export { WebhookAction } from './webhook-action.ts';
export { ScriptAction } from './script-action.ts';
/**
* ActionManager - Coordinates action creation and execution
*
* Provides factory methods for creating actions from configuration
* and orchestrates action execution with error handling.
*/
export class ActionManager {
/**
* Create an action instance from configuration
* @param config Action configuration
* @returns Instantiated action
* @throws Error if action type is unknown
*/
static createAction(config: IActionConfig): Action {
switch (config.type) {
case 'shutdown':
return new ShutdownAction(config);
case 'webhook':
return new WebhookAction(config);
case 'script':
return new ScriptAction(config);
default:
throw new Error(`Unknown action type: ${(config as IActionConfig).type}`);
}
}
/**
* Execute a sequence of actions with the given context
* Each action runs sequentially, and failures are logged but don't stop the chain
* @param actions Array of action configurations to execute
* @param context Action context with UPS state
*/
static async executeActions(
actions: IActionConfig[],
context: IActionContext,
): Promise<void> {
if (!actions || actions.length === 0) {
return;
}
logger.log('');
logger.logBoxTitle(`Executing ${actions.length} Action(s)`, 60, 'info');
logger.logBoxLine(`Trigger: ${context.triggerReason}`);
logger.logBoxLine(`UPS: ${context.upsName} (${context.upsId})`);
logger.logBoxLine(`Power: ${context.powerStatus}`);
logger.logBoxLine(`Battery: ${context.batteryCapacity}% / ${context.batteryRuntime} min`);
logger.logBoxEnd();
logger.log('');
for (let i = 0; i < actions.length; i++) {
const actionConfig = actions[i];
try {
logger.info(`[${i + 1}/${actions.length}] ${actionConfig.type} action...`);
const action = this.createAction(actionConfig);
await action.execute(context);
} catch (error) {
logger.error(
`Action ${actionConfig.type} failed: ${
error instanceof Error ? error.message : String(error)
}`,
);
// Continue with next action despite failure
}
}
logger.log('');
logger.success('Action execution completed');
logger.log('');
}
}

166
ts/actions/script-action.ts Normal file
View File

@@ -0,0 +1,166 @@
import * as path from 'node:path';
import * as fs from 'node:fs';
import { exec } from 'node:child_process';
import { promisify } from 'node:util';
import { Action, type IActionConfig, type IActionContext } from './base-action.ts';
import { logger } from '../logger.ts';
const execAsync = promisify(exec);
/**
* ScriptAction - Executes a custom shell script from /etc/nupst/
*
* Runs user-provided scripts with UPS state passed as environment variables and arguments.
* Scripts must be .sh files located in /etc/nupst/ for security.
*/
export class ScriptAction extends Action {
readonly type = 'script';
private static readonly SCRIPT_DIR = '/etc/nupst';
/**
* Execute the script action
* @param context Action context with UPS state
*/
async execute(context: IActionContext): Promise<void> {
// Check if we should execute based on trigger mode
if (!this.shouldExecute(context)) {
logger.info(`Script action skipped (trigger mode: ${this.config.triggerMode || 'powerChangesAndThresholds'})`);
return;
}
if (!this.config.scriptPath) {
logger.error('Script path not configured');
return;
}
// Validate and build script path
const scriptPath = this.validateAndBuildScriptPath(this.config.scriptPath);
if (!scriptPath) {
logger.error(`Invalid script path: ${this.config.scriptPath}`);
return;
}
// Check if script exists and is executable
if (!fs.existsSync(scriptPath)) {
logger.error(`Script not found: ${scriptPath}`);
return;
}
const timeout = this.config.scriptTimeout || 60000; // Default 60 seconds
logger.info(`Executing script: ${scriptPath}`);
try {
await this.executeScript(scriptPath, context, timeout);
logger.success('Script executed successfully');
} catch (error) {
logger.error(
`Script execution failed: ${error instanceof Error ? error.message : String(error)}`,
);
// Don't throw - script failures shouldn't stop other actions
}
}
/**
* Validate script path and build full path
* Ensures security by preventing path traversal and limiting to /etc/nupst
* @param scriptPath Relative script path from config
* @returns Full validated path or null if invalid
*/
private validateAndBuildScriptPath(scriptPath: string): string | null {
// Remove any leading/trailing whitespace
scriptPath = scriptPath.trim();
// Reject paths with path traversal attempts
if (scriptPath.includes('..') || scriptPath.includes('/') || scriptPath.includes('\\')) {
logger.error('Script path must not contain directory separators or parent references');
return null;
}
// Require .sh extension
if (!scriptPath.endsWith('.sh')) {
logger.error('Script must have .sh extension');
return null;
}
// Build full path
return path.join(ScriptAction.SCRIPT_DIR, scriptPath);
}
/**
* Execute the script with UPS state as environment variables and arguments
* @param scriptPath Full path to the script
* @param context Action context
* @param timeout Execution timeout in milliseconds
*/
private async executeScript(
scriptPath: string,
context: IActionContext,
timeout: number,
): Promise<void> {
// Prepare environment variables
const env = {
...process.env,
NUPST_UPS_ID: context.upsId,
NUPST_UPS_NAME: context.upsName,
NUPST_POWER_STATUS: context.powerStatus,
NUPST_BATTERY_CAPACITY: String(context.batteryCapacity),
NUPST_BATTERY_RUNTIME: String(context.batteryRuntime),
NUPST_TRIGGER_REASON: context.triggerReason,
NUPST_TIMESTAMP: String(context.timestamp),
// Include action's own thresholds if configured
NUPST_BATTERY_THRESHOLD: this.config.thresholds ? String(this.config.thresholds.battery) : '',
NUPST_RUNTIME_THRESHOLD: this.config.thresholds ? String(this.config.thresholds.runtime) : '',
};
// Build command with arguments
// Arguments: powerStatus batteryCapacity batteryRuntime
const args = [
context.powerStatus,
String(context.batteryCapacity),
String(context.batteryRuntime),
].join(' ');
const command = `bash "${scriptPath}" ${args}`;
try {
const { stdout, stderr } = await execAsync(command, {
env,
cwd: ScriptAction.SCRIPT_DIR,
timeout,
});
// Log output
if (stdout) {
logger.log('Script stdout:');
logger.dim(stdout.trim());
}
if (stderr) {
logger.warn('Script stderr:');
logger.dim(stderr.trim());
}
} catch (error) {
// Check if it was a timeout
if (error instanceof Error && 'killed' in error && error.killed) {
throw new Error(`Script timed out after ${timeout}ms`);
}
// Include stdout/stderr in error if available
if (error && typeof error === 'object' && 'stdout' in error && 'stderr' in error) {
const execError = error as { stdout: string; stderr: string };
if (execError.stdout) {
logger.log('Script stdout:');
logger.dim(execError.stdout.trim());
}
if (execError.stderr) {
logger.warn('Script stderr:');
logger.dim(execError.stderr.trim());
}
}
throw error;
}
}
}

View File

@@ -0,0 +1,142 @@
import * as fs from 'node:fs';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { Action, type IActionConfig, type IActionContext } from './base-action.ts';
import { logger } from '../logger.ts';
const execFileAsync = promisify(execFile);
/**
* ShutdownAction - Initiates system shutdown
*
* This action triggers a system shutdown using the standard shutdown command.
* It includes a configurable delay to allow VMs and services to gracefully terminate.
*/
export class ShutdownAction extends Action {
readonly type = 'shutdown';
/**
* Execute the shutdown action
* @param context Action context with UPS state
*/
async execute(context: IActionContext): Promise<void> {
// Check if we should execute based on trigger mode and thresholds
if (!this.shouldExecute(context)) {
logger.info(`Shutdown action skipped (trigger mode: ${this.config.triggerMode || 'powerChangesAndThresholds'})`);
return;
}
const shutdownDelay = this.config.shutdownDelay || 5; // Default 5 minutes
logger.log('');
logger.logBoxTitle('Initiating System Shutdown', 60, 'error');
logger.logBoxLine(`UPS: ${context.upsName} (${context.upsId})`);
logger.logBoxLine(`Power Status: ${context.powerStatus}`);
logger.logBoxLine(`Battery: ${context.batteryCapacity}%`);
logger.logBoxLine(`Runtime: ${context.batteryRuntime} minutes`);
logger.logBoxLine(`Trigger: ${context.triggerReason}`);
logger.logBoxLine(`Shutdown delay: ${shutdownDelay} minutes`);
logger.logBoxEnd();
logger.log('');
try {
await this.executeShutdownCommand(shutdownDelay);
} catch (error) {
logger.error(
`Shutdown command failed: ${error instanceof Error ? error.message : String(error)}`,
);
// Try alternative methods
await this.tryAlternativeShutdownMethods();
}
}
/**
* Execute the primary shutdown command
* @param delayMinutes Minutes to delay before shutdown
*/
private async executeShutdownCommand(delayMinutes: number): Promise<void> {
// Find shutdown command in common system paths
const shutdownPaths = [
'/sbin/shutdown',
'/usr/sbin/shutdown',
'/bin/shutdown',
'/usr/bin/shutdown',
];
let shutdownCmd = '';
for (const path of shutdownPaths) {
try {
if (fs.existsSync(path)) {
shutdownCmd = path;
logger.log(`Found shutdown command at: ${shutdownCmd}`);
break;
}
} catch (_e) {
// Continue checking other paths
}
}
if (shutdownCmd) {
// Execute shutdown command with delay to allow for VM graceful shutdown
const message = `UPS battery critical, shutting down in ${delayMinutes} minutes`;
logger.log(`Executing: ${shutdownCmd} -h +${delayMinutes} "${message}"`);
const { stdout } = await execFileAsync(shutdownCmd, [
'-h',
`+${delayMinutes}`,
message,
]);
logger.log(`Shutdown initiated: ${stdout}`);
logger.log(`Allowing ${delayMinutes} minutes for VMs to shut down safely`);
} else {
throw new Error('Shutdown command not found in common paths');
}
}
/**
* Try alternative shutdown methods if primary command fails
*/
private async tryAlternativeShutdownMethods(): Promise<void> {
logger.error('Trying alternative shutdown methods...');
const alternatives = [
{ cmd: 'poweroff', args: ['--force'] },
{ cmd: 'halt', args: ['-p'] },
{ cmd: 'systemctl', args: ['poweroff'] },
{ cmd: 'reboot', args: ['-p'] }, // Some systems allow reboot -p for power off
];
for (const alt of alternatives) {
try {
// First check if command exists in common system paths
const paths = [
`/sbin/${alt.cmd}`,
`/usr/sbin/${alt.cmd}`,
`/bin/${alt.cmd}`,
`/usr/bin/${alt.cmd}`,
];
let cmdPath = '';
for (const path of paths) {
if (fs.existsSync(path)) {
cmdPath = path;
break;
}
}
if (cmdPath) {
logger.log(`Trying alternative shutdown method: ${cmdPath} ${alt.args.join(' ')}`);
await execFileAsync(cmdPath, alt.args);
logger.log(`Alternative method ${alt.cmd} succeeded`);
return; // Exit if successful
}
} catch (_altError) {
logger.error(`Alternative method ${alt.cmd} failed`);
// Continue to next method
}
}
logger.error('All shutdown methods failed');
}
}

View File

@@ -0,0 +1,141 @@
import * as http from 'node:http';
import * as https from 'node:https';
import { URL } from 'node:url';
import { Action, type IActionConfig, type IActionContext } from './base-action.ts';
import { logger } from '../logger.ts';
/**
* WebhookAction - Calls an HTTP webhook with UPS state information
*
* Sends UPS status to a configured webhook URL via GET or POST.
* This is useful for remote notifications and integrations with external systems.
*/
export class WebhookAction extends Action {
readonly type = 'webhook';
/**
* Execute the webhook action
* @param context Action context with UPS state
*/
async execute(context: IActionContext): Promise<void> {
// Check if we should execute based on trigger mode
if (!this.shouldExecute(context)) {
logger.info(`Webhook action skipped (trigger mode: ${this.config.triggerMode || 'powerChangesAndThresholds'})`);
return;
}
if (!this.config.webhookUrl) {
logger.error('Webhook URL not configured');
return;
}
const method = this.config.webhookMethod || 'POST';
const timeout = this.config.webhookTimeout || 10000;
logger.info(`Calling webhook: ${method} ${this.config.webhookUrl}`);
try {
await this.callWebhook(context, method, timeout);
logger.success('Webhook call successful');
} catch (error) {
logger.error(
`Webhook call failed: ${error instanceof Error ? error.message : String(error)}`,
);
// Don't throw - webhook failures shouldn't stop other actions
}
}
/**
* Call the webhook with UPS state data
* @param context Action context
* @param method HTTP method (GET or POST)
* @param timeout Request timeout in milliseconds
*/
private async callWebhook(
context: IActionContext,
method: 'GET' | 'POST',
timeout: number,
): Promise<void> {
const payload: any = {
upsId: context.upsId,
upsName: context.upsName,
powerStatus: context.powerStatus,
batteryCapacity: context.batteryCapacity,
batteryRuntime: context.batteryRuntime,
triggerReason: context.triggerReason,
timestamp: context.timestamp,
};
// Include action's own thresholds if configured
if (this.config.thresholds) {
payload.thresholds = {
battery: this.config.thresholds.battery,
runtime: this.config.thresholds.runtime,
};
}
const url = new URL(this.config.webhookUrl!);
if (method === 'GET') {
// Append payload as query parameters for GET
url.searchParams.append('upsId', payload.upsId);
url.searchParams.append('upsName', payload.upsName);
url.searchParams.append('powerStatus', payload.powerStatus);
url.searchParams.append('batteryCapacity', String(payload.batteryCapacity));
url.searchParams.append('batteryRuntime', String(payload.batteryRuntime));
url.searchParams.append('triggerReason', payload.triggerReason);
url.searchParams.append('timestamp', String(payload.timestamp));
}
return new Promise((resolve, reject) => {
const protocol = url.protocol === 'https:' ? https : http;
const options: http.RequestOptions = {
method,
headers: method === 'POST'
? {
'Content-Type': 'application/json',
'User-Agent': 'nupst',
}
: {
'User-Agent': 'nupst',
},
timeout,
};
const req = protocol.request(url, options, (res) => {
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
logger.dim(`Webhook response (${res.statusCode}): ${data.substring(0, 100)}`);
resolve();
} else {
reject(new Error(`Webhook returned status ${res.statusCode}`));
}
});
});
req.on('error', (error) => {
reject(error);
});
req.on('timeout', () => {
req.destroy();
reject(new Error(`Webhook request timed out after ${timeout}ms`));
});
// Send POST data if applicable
if (method === 'POST') {
req.write(JSON.stringify(payload));
}
req.end();
});
}
}

313
ts/cli.ts
View File

@@ -1,6 +1,6 @@
import { execSync } from 'node:child_process'; import { execSync } from 'node:child_process';
import { Nupst } from './nupst.ts'; import { Nupst } from './nupst.ts';
import { logger } from './logger.ts'; import { logger, type ITableColumn } from './logger.ts';
import { theme, symbols } from './colors.ts'; import { theme, symbols } from './colors.ts';
/** /**
@@ -72,6 +72,7 @@ export class NupstCli {
const upsHandler = this.nupst.getUpsHandler(); const upsHandler = this.nupst.getUpsHandler();
const groupHandler = this.nupst.getGroupHandler(); const groupHandler = this.nupst.getGroupHandler();
const serviceHandler = this.nupst.getServiceHandler(); const serviceHandler = this.nupst.getServiceHandler();
const actionHandler = this.nupst.getActionHandler();
// Handle service subcommands // Handle service subcommands
if (command === 'service') { if (command === 'service') {
@@ -193,6 +194,38 @@ export class NupstCli {
return; return;
} }
// Handle action subcommands
if (command === 'action') {
const subcommand = commandArgs[0] || 'list';
const subcommandArgs = commandArgs.slice(1);
switch (subcommand) {
case 'add': {
const upsId = subcommandArgs[0];
await actionHandler.add(upsId);
break;
}
case 'remove':
case 'rm': // Alias
case 'delete': { // Backward compatibility
const upsId = subcommandArgs[0];
const actionIndex = subcommandArgs[1];
await actionHandler.remove(upsId, actionIndex);
break;
}
case 'list':
case 'ls': { // Alias
const upsId = subcommandArgs[0];
await actionHandler.list(upsId);
break;
}
default:
this.showActionHelp();
break;
}
return;
}
// Handle config subcommand // Handle config subcommand
if (command === 'config') { if (command === 'config') {
const subcommand = commandArgs[0] || 'show'; const subcommand = commandArgs[0] || 'show';
@@ -303,154 +336,162 @@ export class NupstCli {
try { try {
await this.nupst.getDaemon().loadConfig(); await this.nupst.getDaemon().loadConfig();
} catch (_error) { } catch (_error) {
const errorBoxWidth = 45; logger.logBox('Configuration Error', [
logger.logBoxTitle('Configuration Error', errorBoxWidth); 'No configuration found.',
logger.logBoxLine('No configuration found.'); "Please run 'nupst ups add' first to create a configuration.",
logger.logBoxLine("Please run 'nupst setup' first to create a configuration."); ], 50, 'error');
logger.logBoxEnd();
return; return;
} }
// Get current configuration // Get current configuration
const config = this.nupst.getDaemon().getConfig(); const config = this.nupst.getDaemon().getConfig();
const boxWidth = 50;
logger.logBoxTitle('NUPST Configuration', boxWidth);
// Check if multi-UPS config // Check if multi-UPS config
if (config.upsDevices && Array.isArray(config.upsDevices)) { if (config.upsDevices && Array.isArray(config.upsDevices)) {
// Multi-UPS configuration // === Multi-UPS Configuration ===
logger.logBoxLine(`UPS Devices: ${config.upsDevices.length}`);
logger.logBoxLine(`Groups: ${config.groups ? config.groups.length : 0}`);
logger.logBoxLine(`Check Interval: ${config.checkInterval / 1000} seconds`);
logger.logBoxLine('');
logger.logBoxLine('Configuration File Location:');
logger.logBoxLine(' /etc/nupst/config.json');
logger.logBoxEnd();
// Show UPS devices // Overview Box
logger.log('');
logger.logBox('NUPST Configuration', [
`UPS Devices: ${theme.highlight(String(config.upsDevices.length))}`,
`Groups: ${theme.highlight(String(config.groups ? config.groups.length : 0))}`,
`Check Interval: ${theme.info(String(config.checkInterval / 1000))} seconds`,
'',
theme.dim('Configuration File:'),
` ${theme.path('/etc/nupst/config.json')}`,
], 60, 'info');
// UPS Devices Table
if (config.upsDevices.length > 0) { if (config.upsDevices.length > 0) {
logger.logBoxTitle('UPS Devices', boxWidth); const upsRows = config.upsDevices.map((ups) => ({
for (const ups of config.upsDevices) { name: ups.name,
logger.logBoxLine(`${ups.name} (${ups.id}):`); id: theme.dim(ups.id),
logger.logBoxLine(` Host: ${ups.snmp.host}:${ups.snmp.port}`); host: `${ups.snmp.host}:${ups.snmp.port}`,
logger.logBoxLine(` Model: ${ups.snmp.upsModel}`); model: ups.snmp.upsModel || 'cyberpower',
logger.logBoxLine( actions: `${(ups.actions || []).length} configured`,
` Thresholds: ${ups.thresholds.battery}% battery, ${ups.thresholds.runtime} min runtime`, groups: ups.groups.length > 0 ? ups.groups.join(', ') : theme.dim('None'),
); }));
logger.logBoxLine(
` Groups: ${ups.groups.length > 0 ? ups.groups.join(', ') : 'None'}`, const upsColumns: ITableColumn[] = [
); { header: 'Name', key: 'name', align: 'left', color: theme.highlight },
logger.logBoxLine(''); { header: 'ID', key: 'id', align: 'left' },
} { header: 'Host:Port', key: 'host', align: 'left', color: theme.info },
logger.logBoxEnd(); { header: 'Model', key: 'model', align: 'left' },
{ header: 'Actions', key: 'actions', align: 'left' },
{ header: 'Groups', key: 'groups', align: 'left' },
];
logger.log('');
logger.info(`UPS Devices (${config.upsDevices.length}):`);
logger.log('');
logger.logTable(upsColumns, upsRows);
} }
// Show groups // Groups Table
if (config.groups && config.groups.length > 0) { if (config.groups && config.groups.length > 0) {
logger.logBoxTitle('UPS Groups', boxWidth); const groupRows = config.groups.map((group) => {
for (const group of config.groups) {
logger.logBoxLine(`${group.name} (${group.id}):`);
logger.logBoxLine(` Mode: ${group.mode}`);
if (group.description) {
logger.logBoxLine(` Description: ${group.description}`);
}
// List UPS devices in this group
const upsInGroup = config.upsDevices.filter((ups) => const upsInGroup = config.upsDevices.filter((ups) =>
ups.groups && ups.groups.includes(group.id) ups.groups && ups.groups.includes(group.id)
); );
logger.logBoxLine( return {
` UPS Devices: ${ name: group.name,
upsInGroup.length > 0 ? upsInGroup.map((ups) => ups.name).join(', ') : 'None' id: theme.dim(group.id),
}`, mode: group.mode,
); upsCount: String(upsInGroup.length),
logger.logBoxLine(''); ups: upsInGroup.length > 0
} ? upsInGroup.map((ups) => ups.name).join(', ')
logger.logBoxEnd(); : theme.dim('None'),
description: group.description || theme.dim('—'),
};
});
const groupColumns: ITableColumn[] = [
{ header: 'Name', key: 'name', align: 'left', color: theme.highlight },
{ header: 'ID', key: 'id', align: 'left' },
{ header: 'Mode', key: 'mode', align: 'left', color: theme.info },
{ header: 'UPS', key: 'upsCount', align: 'right' },
{ header: 'UPS Devices', key: 'ups', align: 'left' },
{ header: 'Description', key: 'description', align: 'left' },
];
logger.log('');
logger.info(`UPS Groups (${config.groups.length}):`);
logger.log('');
logger.logTable(groupColumns, groupRows);
} }
} else { } else {
// Legacy single UPS configuration // === Legacy Single UPS Configuration ===
if (!config.snmp) { if (!config.snmp) {
logger.logBoxLine('Error: Legacy configuration missing SNMP settings'); logger.logBox('Configuration Error', [
} else { 'Error: Legacy configuration missing SNMP settings',
// SNMP Settings ], 60, 'error');
logger.logBoxLine('SNMP Settings:'); return;
logger.logBoxLine(` Host: ${config.snmp.host}`);
logger.logBoxLine(` Port: ${config.snmp.port}`);
logger.logBoxLine(` Version: ${config.snmp.version}`);
logger.logBoxLine(` UPS Model: ${config.snmp.upsModel || 'cyberpower'}`);
if (config.snmp.version === 1 || config.snmp.version === 2) {
logger.logBoxLine(` Community: ${config.snmp.community}`);
} else if (config.snmp.version === 3) {
logger.logBoxLine(` Security Level: ${config.snmp.securityLevel}`);
logger.logBoxLine(` Username: ${config.snmp.username}`);
// Show auth and privacy details based on security level
if (
config.snmp.securityLevel === 'authNoPriv' ||
config.snmp.securityLevel === 'authPriv'
) {
logger.logBoxLine(` Auth Protocol: ${config.snmp.authProtocol || 'None'}`);
}
if (config.snmp.securityLevel === 'authPriv') {
logger.logBoxLine(` Privacy Protocol: ${config.snmp.privProtocol || 'None'}`);
}
// Show timeout value
logger.logBoxLine(` Timeout: ${config.snmp.timeout / 1000} seconds`);
}
// Show OIDs if custom model is selected
if (config.snmp.upsModel === 'custom' && config.snmp.customOIDs) {
logger.logBoxLine('Custom OIDs:');
logger.logBoxLine(
` Power Status: ${config.snmp.customOIDs.POWER_STATUS || 'Not set'}`,
);
logger.logBoxLine(
` Battery Capacity: ${config.snmp.customOIDs.BATTERY_CAPACITY || 'Not set'}`,
);
logger.logBoxLine(
` Battery Runtime: ${config.snmp.customOIDs.BATTERY_RUNTIME || 'Not set'}`,
);
}
} }
// Thresholds logger.log('');
if (!config.thresholds) { logger.logBox('NUPST Configuration (Legacy)', [
logger.logBoxLine('Error: Legacy configuration missing threshold settings'); theme.warning('Legacy single-UPS configuration format'),
} else { '',
logger.logBoxLine('Thresholds:'); theme.dim('SNMP Settings:'),
logger.logBoxLine(` Battery: ${config.thresholds.battery}%`); ` Host: ${theme.info(config.snmp.host)}`,
logger.logBoxLine(` Runtime: ${config.thresholds.runtime} minutes`); ` Port: ${theme.info(String(config.snmp.port))}`,
} ` Version: ${config.snmp.version}`,
logger.logBoxLine(`Check Interval: ${config.checkInterval / 1000} seconds`); ` UPS Model: ${config.snmp.upsModel || 'cyberpower'}`,
...(config.snmp.version === 1 || config.snmp.version === 2
? [` Community: ${config.snmp.community}`]
: []
),
...(config.snmp.version === 3
? [
` Security Level: ${config.snmp.securityLevel}`,
` Username: ${config.snmp.username}`,
...(config.snmp.securityLevel === 'authNoPriv' || config.snmp.securityLevel === 'authPriv'
? [` Auth Protocol: ${config.snmp.authProtocol || 'None'}`]
: []
),
...(config.snmp.securityLevel === 'authPriv'
? [` Privacy Protocol: ${config.snmp.privProtocol || 'None'}`]
: []
),
` Timeout: ${config.snmp.timeout / 1000} seconds`,
]
: []
),
...(config.snmp.upsModel === 'custom' && config.snmp.customOIDs
? [
theme.dim('Custom OIDs:'),
` Power Status: ${config.snmp.customOIDs.POWER_STATUS || 'Not set'}`,
` Battery Capacity: ${config.snmp.customOIDs.BATTERY_CAPACITY || 'Not set'}`,
` Battery Runtime: ${config.snmp.customOIDs.BATTERY_RUNTIME || 'Not set'}`,
]
: []
),
'',
// Configuration file location ` Check Interval: ${config.checkInterval / 1000} seconds`,
logger.logBoxLine(''); '',
logger.logBoxLine('Configuration File Location:'); theme.dim('Configuration File:'),
logger.logBoxLine(' /etc/nupst/config.json'); ` ${theme.path('/etc/nupst/config.json')}`,
logger.logBoxLine(''); '',
logger.logBoxLine('Note: Using legacy single-UPS configuration format.'); theme.warning('Note: Using legacy single-UPS configuration format.'),
logger.logBoxLine('Consider using "nupst add" to migrate to multi-UPS format.'); `Consider using ${theme.command('nupst ups add')} to migrate to multi-UPS format.`,
], 70, 'warning');
logger.logBoxEnd();
} }
// Show service status // Service Status
try { try {
const isActive = const isActive =
execSync('systemctl is-active nupst.service || true').toString().trim() === 'active'; execSync('systemctl is-active nupst.service || true').toString().trim() === 'active';
const isEnabled = const isEnabled =
execSync('systemctl is-enabled nupst.service || true').toString().trim() === 'enabled'; execSync('systemctl is-enabled nupst.service || true').toString().trim() === 'enabled';
const statusBoxWidth = 45; logger.log('');
logger.logBoxTitle('Service Status', statusBoxWidth); logger.logBox('Service Status', [
logger.logBoxLine(`Service Active: ${isActive ? 'Yes' : 'No'}`); `Active: ${isActive ? theme.success('Yes') : theme.dim('No')}`,
logger.logBoxLine(`Service Enabled: ${isEnabled ? 'Yes' : 'No'}`); `Enabled: ${isEnabled ? theme.success('Yes') : theme.dim('No')}`,
logger.logBoxEnd(); ], 50, isActive ? 'success' : 'default');
logger.log('');
} catch (_error) { } catch (_error) {
// Ignore errors checking service status // Ignore errors checking service status
} }
@@ -469,7 +510,7 @@ export class NupstCli {
private showVersion(): void { private showVersion(): void {
const version = this.nupst.getVersion(); const version = this.nupst.getVersion();
logger.log(`NUPST version ${version}`); logger.log(`NUPST version ${version}`);
logger.log('Deno-powered UPS monitoring tool'); logger.log('Network UPS Shutdown Tool (https://nupst.serve.zone)');
} }
/** /**
@@ -491,6 +532,7 @@ export class NupstCli {
this.printCommand('service <subcommand>', 'Manage systemd service'); this.printCommand('service <subcommand>', 'Manage systemd service');
this.printCommand('ups <subcommand>', 'Manage UPS devices'); this.printCommand('ups <subcommand>', 'Manage UPS devices');
this.printCommand('group <subcommand>', 'Manage UPS groups'); this.printCommand('group <subcommand>', 'Manage UPS groups');
this.printCommand('action <subcommand>', 'Manage UPS actions');
this.printCommand('config [show]', 'Display current configuration'); this.printCommand('config [show]', 'Display current configuration');
this.printCommand('update', 'Update NUPST from repository', theme.dim('(requires root)')); this.printCommand('update', 'Update NUPST from repository', theme.dim('(requires root)'));
this.printCommand('uninstall', 'Completely remove NUPST', theme.dim('(requires root)')); this.printCommand('uninstall', 'Completely remove NUPST', theme.dim('(requires root)'));
@@ -527,6 +569,13 @@ export class NupstCli {
this.printCommand('nupst group list (or ls)', 'List all UPS groups'); this.printCommand('nupst group list (or ls)', 'List all UPS groups');
console.log(''); console.log('');
// Action subcommands
logger.log(theme.info('Action Subcommands:'));
this.printCommand('nupst action add <target-id>', 'Add a new action to a UPS or group');
this.printCommand('nupst action remove <target-id> <index>', 'Remove an action by index');
this.printCommand('nupst action list [target-id]', 'List all actions (optionally for specific target)');
console.log('');
// Options // Options
logger.log(theme.info('Options:')); logger.log(theme.info('Options:'));
this.printCommand('--debug, -d', 'Enable debug mode for detailed SNMP logging'); this.printCommand('--debug, -d', 'Enable debug mode for detailed SNMP logging');
@@ -631,6 +680,30 @@ Examples:
nupst group add - Create a new group nupst group add - Create a new group
nupst group edit dc-1 - Edit group with ID 'dc-1' nupst group edit dc-1 - Edit group with ID 'dc-1'
nupst group remove dc-1 - Remove group with ID 'dc-1' nupst group remove dc-1 - Remove group with ID 'dc-1'
`);
}
private showActionHelp(): void {
logger.log(`
NUPST - Action Management Commands
Usage:
nupst action <subcommand> [arguments]
Subcommands:
add <ups-id|group-id> - Add a new action to a UPS or group interactively
remove <ups-id|group-id> <index> - Remove an action by index (alias: rm, delete)
list [ups-id|group-id] - List all actions (optionally for specific target) (alias: ls)
Options:
--debug, -d - Enable debug mode for detailed logging
Examples:
nupst action list - List actions for all UPS devices and groups
nupst action list default - List actions for UPS or group with ID 'default'
nupst action add default - Add a new action to UPS or group 'default'
nupst action remove default 0 - Remove action at index 0 from UPS or group 'default'
nupst action add dc-rack-1 - Add a new action to group 'dc-rack-1'
`); `);
} }
} }

363
ts/cli/action-handler.ts Normal file
View File

@@ -0,0 +1,363 @@
import process from 'node:process';
import { Nupst } from '../nupst.ts';
import { logger, type ITableColumn } from '../logger.ts';
import { theme, symbols } from '../colors.ts';
import type { IActionConfig } from '../actions/base-action.ts';
import type { IUpsConfig, IGroupConfig } from '../daemon.ts';
/**
* Class for handling action-related CLI commands
* Provides interface for managing UPS actions
*/
export class ActionHandler {
private readonly nupst: Nupst;
/**
* Create a new action handler
* @param nupst Reference to the main Nupst instance
*/
constructor(nupst: Nupst) {
this.nupst = nupst;
}
/**
* Add a new action to a UPS or group
*/
public async add(targetId?: string): Promise<void> {
try {
if (!targetId) {
logger.error('Target ID is required');
logger.log(
` ${theme.dim('Usage:')} ${theme.command('nupst action add <ups-id|group-id>')}`,
);
logger.log('');
logger.log(` ${theme.dim('List UPS devices:')} ${theme.command('nupst ups list')}`);
logger.log(` ${theme.dim('List groups:')} ${theme.command('nupst group list')}`);
logger.log('');
process.exit(1);
}
const config = await this.nupst.getDaemon().loadConfig();
// Check if it's a UPS
const ups = config.upsDevices.find((u) => u.id === targetId);
// Check if it's a group
const group = config.groups?.find((g) => g.id === targetId);
if (!ups && !group) {
logger.error(`UPS or Group with ID '${targetId}' not found`);
logger.log('');
logger.log(` ${theme.dim('List available UPS devices:')} ${theme.command('nupst ups list')}`);
logger.log(` ${theme.dim('List available groups:')} ${theme.command('nupst group list')}`);
logger.log('');
process.exit(1);
}
const target = ups || group;
const targetType = ups ? 'UPS' : 'Group';
const targetName = ups ? ups.name : group!.name;
const readline = await import('node:readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const prompt = (question: string): Promise<string> => {
return new Promise((resolve) => {
rl.question(question, (answer: string) => {
resolve(answer);
});
});
};
try {
logger.log('');
logger.info(`Add Action to ${targetType} ${theme.highlight(targetName)}`);
logger.log('');
// Action type (currently only shutdown is supported)
const type = 'shutdown';
logger.log(` ${theme.dim('Action type:')} ${theme.highlight('shutdown')}`);
// Battery threshold
const batteryStr = await prompt(
` ${theme.dim('Battery threshold')} ${theme.dim('(%):')} `,
);
const battery = parseInt(batteryStr, 10);
if (isNaN(battery) || battery < 0 || battery > 100) {
logger.error('Invalid battery threshold. Must be 0-100.');
process.exit(1);
}
// Runtime threshold
const runtimeStr = await prompt(
` ${theme.dim('Runtime threshold')} ${theme.dim('(minutes):')} `,
);
const runtime = parseInt(runtimeStr, 10);
if (isNaN(runtime) || runtime < 0) {
logger.error('Invalid runtime threshold. Must be >= 0.');
process.exit(1);
}
// Trigger mode
logger.log('');
logger.log(` ${theme.dim('Trigger mode:')}`);
logger.log(` ${theme.dim('1)')} onlyPowerChanges - Trigger only when power status changes`);
logger.log(
` ${theme.dim('2)')} onlyThresholds - Trigger only when thresholds are violated`,
);
logger.log(
` ${theme.dim('3)')} powerChangesAndThresholds - Trigger on power change AND thresholds`,
);
logger.log(` ${theme.dim('4)')} anyChange - Trigger on any status change`);
const triggerChoice = await prompt(` ${theme.dim('Choice')} ${theme.dim('[2]:')} `);
const triggerModeMap: Record<string, string> = {
'1': 'onlyPowerChanges',
'2': 'onlyThresholds',
'3': 'powerChangesAndThresholds',
'4': 'anyChange',
'': 'onlyThresholds', // Default
};
const triggerMode = triggerModeMap[triggerChoice] || 'onlyThresholds';
// Shutdown delay
const delayStr = await prompt(
` ${theme.dim('Shutdown delay')} ${theme.dim('(seconds) [5]:')} `,
);
const shutdownDelay = delayStr ? parseInt(delayStr, 10) : 5;
if (isNaN(shutdownDelay) || shutdownDelay < 0) {
logger.error('Invalid shutdown delay. Must be >= 0.');
process.exit(1);
}
// Create the action
const newAction: IActionConfig = {
type,
thresholds: {
battery,
runtime,
},
triggerMode: triggerMode as IActionConfig['triggerMode'],
shutdownDelay,
};
// Add to target (UPS or group)
if (!target!.actions) {
target!.actions = [];
}
target!.actions.push(newAction);
await this.nupst.getDaemon().saveConfig(config);
logger.log('');
logger.success(`Action added to ${targetType} ${targetName}`);
logger.log('');
logger.log(
` ${theme.dim('Restart service to apply changes:')} ${theme.command('nupst service restart')}`,
);
logger.log('');
} finally {
rl.close();
}
} catch (error) {
logger.error(
`Failed to add action: ${error instanceof Error ? error.message : String(error)}`,
);
process.exit(1);
}
}
/**
* Remove an action from a UPS or group
*/
public async remove(targetId?: string, actionIndexStr?: string): Promise<void> {
try {
if (!targetId || !actionIndexStr) {
logger.error('Target ID and action index are required');
logger.log(
` ${theme.dim('Usage:')} ${theme.command('nupst action remove <ups-id|group-id> <action-index>')}`,
);
logger.log('');
logger.log(` ${theme.dim('List actions:')} ${theme.command('nupst action list')}`);
logger.log('');
process.exit(1);
}
const actionIndex = parseInt(actionIndexStr, 10);
if (isNaN(actionIndex) || actionIndex < 0) {
logger.error('Invalid action index. Must be >= 0.');
process.exit(1);
}
const config = await this.nupst.getDaemon().loadConfig();
// Check if it's a UPS
const ups = config.upsDevices.find((u) => u.id === targetId);
// Check if it's a group
const group = config.groups?.find((g) => g.id === targetId);
if (!ups && !group) {
logger.error(`UPS or Group with ID '${targetId}' not found`);
logger.log('');
logger.log(` ${theme.dim('List available UPS devices:')} ${theme.command('nupst ups list')}`);
logger.log(` ${theme.dim('List available groups:')} ${theme.command('nupst group list')}`);
logger.log('');
process.exit(1);
}
const target = ups || group;
const targetType = ups ? 'UPS' : 'Group';
const targetName = ups ? ups.name : group!.name;
if (!target!.actions || target!.actions.length === 0) {
logger.error(`No actions configured for ${targetType} '${targetName}'`);
logger.log('');
process.exit(1);
}
if (actionIndex >= target!.actions.length) {
logger.error(
`Invalid action index. ${targetType} '${targetName}' has ${target!.actions.length} action(s) (index 0-${target!.actions.length - 1})`,
);
logger.log('');
logger.log(
` ${theme.dim('List actions:')} ${theme.command(`nupst action list ${targetId}`)}`,
);
logger.log('');
process.exit(1);
}
const removedAction = target!.actions[actionIndex];
target!.actions.splice(actionIndex, 1);
await this.nupst.getDaemon().saveConfig(config);
logger.log('');
logger.success(`Action removed from ${targetType} ${targetName}`);
logger.log(` ${theme.dim('Type:')} ${removedAction.type}`);
if (removedAction.thresholds) {
logger.log(
` ${theme.dim('Thresholds:')} Battery: ${removedAction.thresholds.battery}%, Runtime: ${removedAction.thresholds.runtime}min`,
);
}
logger.log('');
logger.log(
` ${theme.dim('Restart service to apply changes:')} ${theme.command('nupst service restart')}`,
);
logger.log('');
} catch (error) {
logger.error(
`Failed to remove action: ${error instanceof Error ? error.message : String(error)}`,
);
process.exit(1);
}
}
/**
* List all actions for a specific UPS/group or all devices
*/
public async list(targetId?: string): Promise<void> {
try {
const config = await this.nupst.getDaemon().loadConfig();
if (targetId) {
// List actions for specific UPS or group
const ups = config.upsDevices.find((u) => u.id === targetId);
const group = config.groups?.find((g) => g.id === targetId);
if (!ups && !group) {
logger.error(`UPS or Group with ID '${targetId}' not found`);
logger.log('');
logger.log(` ${theme.dim('List available UPS devices:')} ${theme.command('nupst ups list')}`);
logger.log(` ${theme.dim('List available groups:')} ${theme.command('nupst group list')}`);
logger.log('');
process.exit(1);
}
if (ups) {
this.displayTargetActions(ups, 'UPS');
} else {
this.displayTargetActions(group!, 'Group');
}
} else {
// List actions for all UPS devices and groups
logger.log('');
logger.info('Actions for All UPS Devices and Groups');
logger.log('');
let hasAnyActions = false;
// Display UPS actions
for (const ups of config.upsDevices) {
if (ups.actions && ups.actions.length > 0) {
hasAnyActions = true;
this.displayTargetActions(ups, 'UPS');
}
}
// Display Group actions
for (const group of config.groups || []) {
if (group.actions && group.actions.length > 0) {
hasAnyActions = true;
this.displayTargetActions(group, 'Group');
}
}
if (!hasAnyActions) {
logger.log(` ${theme.dim('No actions configured')}`);
logger.log('');
logger.log(
` ${theme.dim('Add an action:')} ${theme.command('nupst action add <ups-id|group-id>')}`,
);
logger.log('');
}
}
} catch (error) {
logger.error(
`Failed to list actions: ${error instanceof Error ? error.message : String(error)}`,
);
process.exit(1);
}
}
/**
* Display actions for a single UPS or Group
*/
private displayTargetActions(
target: IUpsConfig | IGroupConfig,
targetType: 'UPS' | 'Group',
): void {
logger.log(
`${symbols.info} ${targetType} ${theme.highlight(target.name)} ${theme.dim(`(${target.id})`)}`,
);
logger.log('');
if (!target.actions || target.actions.length === 0) {
logger.log(` ${theme.dim('No actions configured')}`);
logger.log('');
return;
}
const columns: ITableColumn[] = [
{ header: 'Index', key: 'index', align: 'right' },
{ header: 'Type', key: 'type', align: 'left' },
{ header: 'Battery', key: 'battery', align: 'right' },
{ header: 'Runtime', key: 'runtime', align: 'right' },
{ header: 'Trigger Mode', key: 'triggerMode', align: 'left' },
{ header: 'Delay', key: 'delay', align: 'right' },
];
const rows = target.actions.map((action, index) => ({
index: theme.dim(index.toString()),
type: theme.highlight(action.type),
battery: action.thresholds ? `${action.thresholds.battery}%` : theme.dim('N/A'),
runtime: action.thresholds ? `${action.thresholds.runtime}min` : theme.dim('N/A'),
triggerMode: theme.dim(action.triggerMode || 'onlyThresholds'),
delay: `${action.shutdownDelay || 5}s`,
}));
logger.logTable(columns, rows);
logger.log('');
}
}

View File

@@ -1,6 +1,7 @@
import process from 'node:process'; import process from 'node:process';
import { Nupst } from '../nupst.ts'; import { Nupst } from '../nupst.ts';
import { logger } from '../logger.ts'; import { logger, type ITableColumn } from '../logger.ts';
import { theme } from '../colors.ts';
import * as helpers from '../helpers/index.ts'; import * as helpers from '../helpers/index.ts';
import { type IGroupConfig } from '../daemon.ts'; import { type IGroupConfig } from '../daemon.ts';
@@ -28,11 +29,10 @@ export class GroupHandler {
try { try {
await this.nupst.getDaemon().loadConfig(); await this.nupst.getDaemon().loadConfig();
} catch (error) { } catch (error) {
const errorBoxWidth = 45; logger.logBox('Configuration Error', [
logger.logBoxTitle('Configuration Error', errorBoxWidth); 'No configuration found.',
logger.logBoxLine('No configuration found.'); "Please run 'nupst ups add' first to create a configuration.",
logger.logBoxLine("Please run 'nupst setup' first to create a configuration."); ], 50, 'error');
logger.logBoxEnd();
return; return;
} }
@@ -41,43 +41,53 @@ export class GroupHandler {
// Check if multi-UPS config // Check if multi-UPS config
if (!config.groups || !Array.isArray(config.groups)) { if (!config.groups || !Array.isArray(config.groups)) {
// Legacy or missing groups configuration logger.logBox('UPS Groups', [
const boxWidth = 45; 'No groups configured.',
logger.logBoxTitle('UPS Groups', boxWidth); '',
logger.logBoxLine('No groups configured.'); `${theme.dim('Run')} ${theme.command('nupst group add')} ${theme.dim('to add a group')}`,
logger.logBoxLine('Use "nupst group add" to add a UPS group.'); ], 50, 'info');
logger.logBoxEnd();
return; return;
} }
// Display group list // Display group list with modern table
const boxWidth = 60;
logger.logBoxTitle('UPS Groups', boxWidth);
if (config.groups.length === 0) { if (config.groups.length === 0) {
logger.logBoxLine('No UPS groups configured.'); logger.logBox('UPS Groups', [
logger.logBoxLine('Use "nupst group add" to add a UPS group.'); 'No UPS groups configured.',
} else { '',
logger.logBoxLine(`Found ${config.groups.length} group(s)`); `${theme.dim('Run')} ${theme.command('nupst group add')} ${theme.dim('to add a group')}`,
logger.logBoxLine(''); ], 60, 'info');
logger.logBoxLine('ID | Name | Mode | UPS Devices'); return;
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(); // Prepare table data
const rows = config.groups.map((group) => {
// 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(', ');
return {
id: group.id,
name: group.name || '',
mode: group.mode || 'unknown',
count: String(upsCount),
devices: upsCount > 0 ? upsNames : theme.dim('None'),
};
});
const columns: ITableColumn[] = [
{ header: 'ID', key: 'id', align: 'left', color: theme.highlight },
{ header: 'Name', key: 'name', align: 'left' },
{ header: 'Mode', key: 'mode', align: 'left', color: theme.info },
{ header: 'UPS Count', key: 'count', align: 'right' },
{ header: 'UPS Devices', key: 'devices', align: 'left' },
];
logger.log('');
logger.info(`UPS Groups (${config.groups.length}):`);
logger.log('');
logger.logTable(columns, rows);
logger.log('');
} catch (error) { } catch (error) {
logger.error( logger.error(
`Failed to list UPS groups: ${error instanceof Error ? error.message : String(error)}`, `Failed to list UPS groups: ${error instanceof Error ? error.message : String(error)}`,
@@ -192,6 +202,7 @@ export class GroupHandler {
logger.log('\nGroup setup complete!'); logger.log('\nGroup setup complete!');
} finally { } finally {
rl.close(); rl.close();
process.stdin.destroy();
} }
} catch (error) { } catch (error) {
logger.error(`Add group error: ${error instanceof Error ? error.message : String(error)}`); logger.error(`Add group error: ${error instanceof Error ? error.message : String(error)}`);
@@ -309,6 +320,7 @@ export class GroupHandler {
logger.log('\nGroup edit complete!'); logger.log('\nGroup edit complete!');
} finally { } finally {
rl.close(); rl.close();
process.stdin.destroy();
} }
} catch (error) { } catch (error) {
logger.error(`Edit group error: ${error instanceof Error ? error.message : String(error)}`); logger.error(`Edit group error: ${error instanceof Error ? error.message : String(error)}`);
@@ -366,6 +378,7 @@ export class GroupHandler {
}); });
rl.close(); rl.close();
process.stdin.destroy();
if (confirm !== 'y' && confirm !== 'yes') { if (confirm !== 'y' && confirm !== 'yes') {
logger.log('Deletion cancelled.'); logger.log('Deletion cancelled.');

View File

@@ -213,9 +213,11 @@ export class ServiceHandler {
}); });
}; };
console.log('\nNUPST Uninstaller'); logger.log('');
console.log('==============='); logger.highlight('NUPST Uninstaller');
console.log('This will completely remove NUPST from your system.\n'); logger.dim('===============');
logger.log('This will completely remove NUPST from your system.');
logger.log('');
// Ask about removing configuration // Ask about removing configuration
const removeConfig = await prompt( const removeConfig = await prompt(
@@ -251,17 +253,20 @@ export class ServiceHandler {
} }
if (!uninstallScriptPath) { if (!uninstallScriptPath) {
console.error('Could not locate uninstall.sh script. Aborting uninstall.'); logger.error('Could not locate uninstall.sh script. Aborting uninstall.');
rl.close(); rl.close();
process.stdin.destroy();
process.exit(1); process.exit(1);
} }
} }
// Close readline before executing script // Close readline before executing script
rl.close(); rl.close();
process.stdin.destroy();
// Execute uninstall.sh with the appropriate option // Execute uninstall.sh with the appropriate option
console.log(`\nRunning uninstaller from ${uninstallScriptPath}...`); logger.log('');
logger.log(`Running uninstaller from ${uninstallScriptPath}...`);
// Pass the configuration removal option as an environment variable // Pass the configuration removal option as an environment variable
const env = { const env = {
@@ -277,7 +282,7 @@ export class ServiceHandler {
stdio: 'inherit', // Show output in the terminal stdio: 'inherit', // Show output in the terminal
}); });
} catch (error) { } catch (error) {
console.error(`Uninstall failed: ${error instanceof Error ? error.message : String(error)}`); logger.error(`Uninstall failed: ${error instanceof Error ? error.message : String(error)}`);
process.exit(1); process.exit(1);
} }
} }

View File

@@ -1,7 +1,8 @@
import process from 'node:process'; import process from 'node:process';
import { execSync } from 'node:child_process'; import { execSync } from 'node:child_process';
import { Nupst } from '../nupst.ts'; import { Nupst } from '../nupst.ts';
import { logger } from '../logger.ts'; import { logger, type ITableColumn } from '../logger.ts';
import { theme } from '../colors.ts';
import * as helpers from '../helpers/index.ts'; import * as helpers from '../helpers/index.ts';
import type { TUpsModel } from '../snmp/types.ts'; import type { TUpsModel } from '../snmp/types.ts';
import type { INupstConfig } from '../daemon.ts'; import type { INupstConfig } from '../daemon.ts';
@@ -47,6 +48,7 @@ export class UpsHandler {
await this.runAddProcess(prompt); await this.runAddProcess(prompt);
} finally { } finally {
rl.close(); rl.close();
process.stdin.destroy();
} }
} catch (error) { } catch (error) {
logger.error(`Add UPS error: ${error instanceof Error ? error.message : String(error)}`); logger.error(`Add UPS error: ${error instanceof Error ? error.message : String(error)}`);
@@ -75,10 +77,10 @@ export class UpsHandler {
checkInterval: config.checkInterval, checkInterval: config.checkInterval,
upsDevices: [{ upsDevices: [{
id: 'default', id: 'default',
name: 'Default UPS', name: 'Default UPS',
snmp: config.snmp, snmp: config.snmp,
thresholds: config.thresholds, groups: [],
groups: [], actions: [],
}], }],
groups: [], groups: [],
}; };
@@ -115,14 +117,12 @@ export class UpsHandler {
runtime: 20, runtime: 20,
}, },
groups: [], groups: [],
actions: [],
}; };
// Gather SNMP settings // Gather SNMP settings
await this.gatherSnmpSettings(newUps.snmp, prompt); await this.gatherSnmpSettings(newUps.snmp, prompt);
// Gather threshold settings
await this.gatherThresholdSettings(newUps.thresholds, prompt);
// Gather UPS model settings // Gather UPS model settings
await this.gatherUpsModelSettings(newUps.snmp, prompt); await this.gatherUpsModelSettings(newUps.snmp, prompt);
@@ -134,6 +134,9 @@ export class UpsHandler {
await groupHandler.assignUpsToGroups(newUps, config.groups, prompt); await groupHandler.assignUpsToGroups(newUps, config.groups, prompt);
} }
// Gather action settings
await this.gatherActionSettings(newUps.actions, prompt);
// Add the new UPS to the config // Add the new UPS to the config
config.upsDevices.push(newUps); config.upsDevices.push(newUps);
@@ -178,6 +181,7 @@ export class UpsHandler {
await this.runEditProcess(upsId, prompt); await this.runEditProcess(upsId, prompt);
} finally { } finally {
rl.close(); rl.close();
process.stdin.destroy();
} }
} catch (error) { } catch (error) {
logger.error(`Edit UPS error: ${error instanceof Error ? error.message : String(error)}`); logger.error(`Edit UPS error: ${error instanceof Error ? error.message : String(error)}`);
@@ -218,16 +222,16 @@ export class UpsHandler {
// Convert old format to new format if needed // Convert old format to new format if needed
if (!config.upsDevices) { if (!config.upsDevices) {
// Initialize with the current config as the first UPS // Initialize with the current config as the first UPS
if (!config.snmp || !config.thresholds) { if (!config.snmp) {
logger.error('Legacy configuration is missing required SNMP or threshold settings'); logger.error('Legacy configuration is missing required SNMP settings');
return; return;
} }
config.upsDevices = [{ config.upsDevices = [{
id: 'default', id: 'default',
name: 'Default UPS', name: 'Default UPS',
snmp: config.snmp, snmp: config.snmp,
thresholds: config.thresholds,
groups: [], groups: [],
actions: [],
}]; }];
config.groups = []; config.groups = [];
logger.log('Converting existing configuration to multi-UPS format.'); logger.log('Converting existing configuration to multi-UPS format.');
@@ -262,9 +266,6 @@ export class UpsHandler {
// Edit SNMP settings // Edit SNMP settings
await this.gatherSnmpSettings(upsToEdit.snmp, prompt); await this.gatherSnmpSettings(upsToEdit.snmp, prompt);
// Edit threshold settings
await this.gatherThresholdSettings(upsToEdit.thresholds, prompt);
// Edit UPS model settings // Edit UPS model settings
await this.gatherUpsModelSettings(upsToEdit.snmp, prompt); await this.gatherUpsModelSettings(upsToEdit.snmp, prompt);
@@ -276,6 +277,14 @@ export class UpsHandler {
await groupHandler.assignUpsToGroups(upsToEdit, config.groups, prompt); await groupHandler.assignUpsToGroups(upsToEdit, config.groups, prompt);
} }
// Initialize actions array if not exists
if (!upsToEdit.actions) {
upsToEdit.actions = [];
}
// Edit action settings
await this.gatherActionSettings(upsToEdit.actions, prompt);
// Save the configuration // Save the configuration
await this.nupst.getDaemon().saveConfig(config); await this.nupst.getDaemon().saveConfig(config);
@@ -344,6 +353,7 @@ export class UpsHandler {
}); });
rl.close(); rl.close();
process.stdin.destroy();
if (confirm !== 'y' && confirm !== 'yes') { if (confirm !== 'y' && confirm !== 'yes') {
logger.log('Deletion cancelled.'); logger.log('Deletion cancelled.');
@@ -376,11 +386,10 @@ export class UpsHandler {
try { try {
await this.nupst.getDaemon().loadConfig(); await this.nupst.getDaemon().loadConfig();
} catch (error) { } catch (error) {
const errorBoxWidth = 45; logger.logBox('Configuration Error', [
logger.logBoxTitle('Configuration Error', errorBoxWidth); 'No configuration found.',
logger.logBoxLine('No configuration found.'); "Please run 'nupst ups add' first to create a configuration.",
logger.logBoxLine("Please run 'nupst setup' first to create a configuration."); ], 50, 'error');
logger.logBoxEnd();
return; return;
} }
@@ -390,58 +399,56 @@ export class UpsHandler {
// Check if multi-UPS config // Check if multi-UPS config
if (!config.upsDevices || !Array.isArray(config.upsDevices)) { if (!config.upsDevices || !Array.isArray(config.upsDevices)) {
// Legacy single UPS configuration // Legacy single UPS configuration
const boxWidth = 45; logger.logBox('UPS Devices', [
logger.logBoxTitle('UPS Devices', boxWidth); 'Legacy single-UPS configuration detected.',
logger.logBoxLine('Legacy single-UPS configuration detected.'); '',
if (!config.snmp || !config.thresholds) { ...(!config.snmp
logger.logBoxLine(''); ? ['Error: Configuration missing SNMP settings']
logger.logBoxLine('Error: Configuration missing SNMP or threshold settings'); : [
logger.logBoxEnd(); 'Default UPS:',
return; ` Host: ${config.snmp.host}:${config.snmp.port}`,
} ` Model: ${config.snmp.upsModel || 'cyberpower'}`,
logger.logBoxLine(''); '',
logger.logBoxLine('Default UPS:'); 'Use "nupst ups add" to add more UPS devices and migrate',
logger.logBoxLine(` Host: ${config.snmp.host}:${config.snmp.port}`); 'to the multi-UPS configuration format.',
logger.logBoxLine(` Model: ${config.snmp.upsModel || 'cyberpower'}`); ]
logger.logBoxLine( ),
` Thresholds: ${config.thresholds.battery}% battery, ${config.thresholds.runtime} min runtime`, ], 60, 'warning');
);
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; return;
} }
// Display UPS list // Display UPS list with modern table
const boxWidth = 60;
logger.logBoxTitle('UPS Devices', boxWidth);
if (config.upsDevices.length === 0) { if (config.upsDevices.length === 0) {
logger.logBoxLine('No UPS devices configured.'); logger.logBox('UPS Devices', [
logger.logBoxLine('Use "nupst add" to add a UPS device.'); 'No UPS devices configured.',
} else { '',
logger.logBoxLine(`Found ${config.upsDevices.length} UPS device(s)`); `${theme.dim('Run')} ${theme.command('nupst ups add')} ${theme.dim('to add a device')}`,
logger.logBoxLine(''); ], 60, 'info');
logger.logBoxLine( return;
'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(); // Prepare table data
const rows = config.upsDevices.map((ups) => ({
id: ups.id,
name: ups.name || '',
host: `${ups.snmp.host}:${ups.snmp.port}`,
model: ups.snmp.upsModel || 'cyberpower',
groups: ups.groups.length > 0 ? ups.groups.join(', ') : theme.dim('None'),
}));
const columns: ITableColumn[] = [
{ header: 'ID', key: 'id', align: 'left', color: theme.highlight },
{ header: 'Name', key: 'name', align: 'left' },
{ header: 'Host:Port', key: 'host', align: 'left', color: theme.info },
{ header: 'Model', key: 'model', align: 'left' },
{ header: 'Groups', key: 'groups', align: 'left' },
];
logger.log('');
logger.info(`UPS Devices (${config.upsDevices.length}):`);
logger.log('');
logger.logTable(columns, rows);
logger.log('');
} catch (error) { } catch (error) {
logger.error( logger.error(
`Failed to list UPS devices: ${error instanceof Error ? error.message : String(error)}`, `Failed to list UPS devices: ${error instanceof Error ? error.message : String(error)}`,
@@ -504,9 +511,8 @@ export class UpsHandler {
*/ */
private displayTestConfig(config: any): void { private displayTestConfig(config: any): void {
// Check if this is a UPS device or full configuration // Check if this is a UPS device or full configuration
const isUpsConfig = config.snmp && config.thresholds; const isUpsConfig = config.snmp;
const snmpConfig = isUpsConfig ? config.snmp : config.snmp || {}; const snmpConfig = isUpsConfig ? config.snmp : config.snmp || {};
const thresholds = isUpsConfig ? config.thresholds : config.thresholds || {};
const checkInterval = config.checkInterval || 30000; const checkInterval = config.checkInterval || 30000;
// Get UPS name and ID if available // Get UPS name and ID if available
@@ -550,10 +556,6 @@ export class UpsHandler {
); );
logger.logBoxLine(` Battery Runtime: ${snmpConfig.customOIDs.BATTERY_RUNTIME || '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 // Show group assignments if this is a UPS config
if (config.groups && Array.isArray(config.groups)) { if (config.groups && Array.isArray(config.groups)) {
logger.logBoxLine( logger.logBoxLine(
@@ -577,7 +579,6 @@ export class UpsHandler {
try { try {
// Create a test config with a short timeout // Create a test config with a short timeout
const snmpConfig = config.snmp ? config.snmp : config.snmp; const snmpConfig = config.snmp ? config.snmp : config.snmp;
const thresholds = config.thresholds ? config.thresholds : config.thresholds;
const testConfig = { const testConfig = {
...snmpConfig, ...snmpConfig,
@@ -594,10 +595,7 @@ export class UpsHandler {
logger.logBoxLine(` Runtime Remaining: ${status.batteryRuntime} minutes`); logger.logBoxLine(` Runtime Remaining: ${status.batteryRuntime} minutes`);
logger.logBoxEnd(); logger.logBoxEnd();
// Check status against thresholds if on battery
if (status.powerStatus === 'onBattery') {
this.analyzeThresholds(status, thresholds);
}
} catch (error) { } catch (error) {
const errorBoxWidth = 45; const errorBoxWidth = 45;
logger.logBoxTitle(`Connection Failed: ${upsName}`, errorBoxWidth); logger.logBoxTitle(`Connection Failed: ${upsName}`, errorBoxWidth);
@@ -667,10 +665,11 @@ export class UpsHandler {
// SNMP Version // SNMP Version
const defaultVersion = snmpConfig.version || 1; const defaultVersion = snmpConfig.version || 1;
console.log('\nSNMP Version:'); logger.log('');
console.log(' 1) SNMPv1'); logger.info('SNMP Version:');
console.log(' 2) SNMPv2c'); logger.dim(' 1) SNMPv1');
console.log(' 3) SNMPv3 (with security features)'); logger.dim(' 2) SNMPv2c');
logger.dim(' 3) SNMPv3 (with security features)');
const versionInput = await prompt(`Select SNMP version [${defaultVersion}]: `); const versionInput = await prompt(`Select SNMP version [${defaultVersion}]: `);
const version = parseInt(versionInput, 10); const version = parseInt(versionInput, 10);
snmpConfig.version = versionInput.trim() && (version === 1 || version === 2 || version === 3) snmpConfig.version = versionInput.trim() && (version === 1 || version === 2 || version === 3)
@@ -697,13 +696,15 @@ export class UpsHandler {
snmpConfig: any, snmpConfig: any,
prompt: (question: string) => Promise<string>, prompt: (question: string) => Promise<string>,
): Promise<void> { ): Promise<void> {
console.log('\nSNMPv3 Security Settings:'); logger.log('');
logger.info('SNMPv3 Security Settings:');
// Security Level // Security Level
console.log('\nSecurity Level:'); logger.log('');
console.log(' 1) noAuthNoPriv (No Authentication, No Privacy)'); logger.info('Security Level:');
console.log(' 2) authNoPriv (Authentication, No Privacy)'); logger.dim(' 1) noAuthNoPriv (No Authentication, No Privacy)');
console.log(' 3) authPriv (Authentication and Privacy)'); logger.dim(' 2) authNoPriv (Authentication, No Privacy)');
logger.dim(' 3) authPriv (Authentication and Privacy)');
const defaultSecLevel = snmpConfig.securityLevel const defaultSecLevel = snmpConfig.securityLevel
? snmpConfig.securityLevel === 'noAuthNoPriv' ? snmpConfig.securityLevel === 'noAuthNoPriv'
? 1 ? 1
@@ -752,8 +753,9 @@ export class UpsHandler {
// Allow customizing the timeout value // Allow customizing the timeout value
const defaultTimeout = snmpConfig.timeout / 1000; // Convert from ms to seconds for display const defaultTimeout = snmpConfig.timeout / 1000; // Convert from ms to seconds for display
console.log( logger.log('');
'\nSNMPv3 operations with authentication and privacy may require longer timeouts.', logger.info(
'SNMPv3 operations with authentication and privacy may require longer timeouts.',
); );
const timeoutInput = await prompt(`SNMP Timeout in seconds [${defaultTimeout}]: `); const timeoutInput = await prompt(`SNMP Timeout in seconds [${defaultTimeout}]: `);
const timeout = parseInt(timeoutInput, 10); const timeout = parseInt(timeoutInput, 10);
@@ -773,9 +775,10 @@ export class UpsHandler {
prompt: (question: string) => Promise<string>, prompt: (question: string) => Promise<string>,
): Promise<void> { ): Promise<void> {
// Authentication protocol // Authentication protocol
console.log('\nAuthentication Protocol:'); logger.log('');
console.log(' 1) MD5'); logger.info('Authentication Protocol:');
console.log(' 2) SHA'); logger.dim(' 1) MD5');
logger.dim(' 2) SHA');
const defaultAuthProtocol = snmpConfig.authProtocol === 'SHA' ? 2 : 1; const defaultAuthProtocol = snmpConfig.authProtocol === 'SHA' ? 2 : 1;
const authProtocolInput = await prompt( const authProtocolInput = await prompt(
`Select Authentication Protocol [${defaultAuthProtocol}]: `, `Select Authentication Protocol [${defaultAuthProtocol}]: `,
@@ -799,9 +802,10 @@ export class UpsHandler {
prompt: (question: string) => Promise<string>, prompt: (question: string) => Promise<string>,
): Promise<void> { ): Promise<void> {
// Privacy protocol // Privacy protocol
console.log('\nPrivacy Protocol:'); logger.log('');
console.log(' 1) DES'); logger.info('Privacy Protocol:');
console.log(' 2) AES'); logger.dim(' 1) DES');
logger.dim(' 2) AES');
const defaultPrivProtocol = snmpConfig.privProtocol === 'AES' ? 2 : 1; const defaultPrivProtocol = snmpConfig.privProtocol === 'AES' ? 2 : 1;
const privProtocolInput = await prompt(`Select Privacy Protocol [${defaultPrivProtocol}]: `); const privProtocolInput = await prompt(`Select Privacy Protocol [${defaultPrivProtocol}]: `);
const privProtocol = parseInt(privProtocolInput, 10) || defaultPrivProtocol; const privProtocol = parseInt(privProtocolInput, 10) || defaultPrivProtocol;
@@ -813,38 +817,6 @@ export class UpsHandler {
snmpConfig.privKey = privKey.trim() || 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 * Gather UPS model settings
* @param snmpConfig SNMP configuration object to update * @param snmpConfig SNMP configuration object to update
@@ -854,13 +826,14 @@ export class UpsHandler {
snmpConfig: any, snmpConfig: any,
prompt: (question: string) => Promise<string>, prompt: (question: string) => Promise<string>,
): Promise<void> { ): Promise<void> {
console.log('\nUPS Model Selection:'); logger.log('');
console.log(' 1) CyberPower'); logger.info('UPS Model Selection:');
console.log(' 2) APC'); logger.dim(' 1) CyberPower');
console.log(' 3) Eaton'); logger.dim(' 2) APC');
console.log(' 4) TrippLite'); logger.dim(' 3) Eaton');
console.log(' 5) Liebert/Vertiv'); logger.dim(' 4) TrippLite');
console.log(' 6) Custom (Advanced)'); logger.dim(' 5) Liebert/Vertiv');
logger.dim(' 6) Custom (Advanced)');
const defaultModelValue = snmpConfig.upsModel === 'cyberpower' const defaultModelValue = snmpConfig.upsModel === 'cyberpower'
? 1 ? 1
@@ -891,8 +864,9 @@ export class UpsHandler {
snmpConfig.upsModel = 'liebert'; snmpConfig.upsModel = 'liebert';
} else if (modelValue === 6) { } else if (modelValue === 6) {
snmpConfig.upsModel = 'custom'; snmpConfig.upsModel = 'custom';
console.log('\nEnter custom OIDs for your UPS:'); logger.log('');
console.log('(Leave blank to use standard RFC 1628 OIDs as fallback)'); logger.info('Enter custom OIDs for your UPS:');
logger.dim('(Leave blank to use standard RFC 1628 OIDs as fallback)');
// Custom OIDs // Custom OIDs
const powerStatusOID = await prompt('Power Status OID: '); const powerStatusOID = await prompt('Power Status OID: ');
@@ -908,6 +882,151 @@ export class UpsHandler {
} }
} }
/**
* Gather action configuration settings
* @param actions Actions array to configure
* @param prompt Function to prompt for user input
*/
private async gatherActionSettings(
actions: any[],
prompt: (question: string) => Promise<string>,
): Promise<void> {
logger.log('');
logger.info('Action Configuration (Optional):');
logger.dim('Actions are triggered on power status changes and threshold violations.');
logger.dim('Leave empty to use default shutdown behavior on threshold violations.');
const configureActions = await prompt('Configure custom actions? (y/N): ');
if (configureActions.toLowerCase() !== 'y') {
return; // Keep existing actions or use default
}
// Clear existing actions
actions.length = 0;
let addMore = true;
while (addMore) {
logger.log('');
logger.info('Action Type:');
logger.dim(' 1) Shutdown (system shutdown)');
logger.dim(' 2) Webhook (HTTP notification)');
logger.dim(' 3) Custom Script (run .sh file from /etc/nupst)');
const typeInput = await prompt('Select action type [1]: ');
const typeValue = parseInt(typeInput, 10) || 1;
const action: any = {};
if (typeValue === 1) {
// Shutdown action
action.type = 'shutdown';
const delayInput = await prompt('Shutdown delay in minutes [5]: ');
const delay = parseInt(delayInput, 10);
if (delayInput.trim() && !isNaN(delay)) {
action.shutdownDelay = delay;
}
} else if (typeValue === 2) {
// Webhook action
action.type = 'webhook';
const url = await prompt('Webhook URL: ');
if (!url.trim()) {
logger.warn('Webhook URL required, skipping action');
continue;
}
action.webhookUrl = url.trim();
logger.log('');
logger.info('HTTP Method:');
logger.dim(' 1) POST (JSON body)');
logger.dim(' 2) GET (query parameters)');
const methodInput = await prompt('Select method [1]: ');
action.webhookMethod = methodInput === '2' ? 'GET' : 'POST';
const timeoutInput = await prompt('Timeout in seconds [10]: ');
const timeout = parseInt(timeoutInput, 10);
if (timeoutInput.trim() && !isNaN(timeout)) {
action.webhookTimeout = timeout * 1000; // Convert to ms
}
} else if (typeValue === 3) {
// Script action
action.type = 'script';
const scriptPath = await prompt('Script filename (in /etc/nupst/, must end with .sh): ');
if (!scriptPath.trim() || !scriptPath.trim().endsWith('.sh')) {
logger.warn('Script path must end with .sh, skipping action');
continue;
}
action.scriptPath = scriptPath.trim();
const timeoutInput = await prompt('Script timeout in seconds [60]: ');
const timeout = parseInt(timeoutInput, 10);
if (timeoutInput.trim() && !isNaN(timeout)) {
action.scriptTimeout = timeout * 1000; // Convert to ms
}
} else {
logger.warn('Invalid action type, skipping');
continue;
}
// Configure trigger mode (applies to all action types)
logger.log('');
logger.info('Trigger Mode:');
logger.dim(' 1) Power changes + thresholds (default)');
logger.dim(' 2) Only power status changes');
logger.dim(' 3) Only threshold violations');
logger.dim(' 4) Any change (every ~30s check)');
const triggerInput = await prompt('Select trigger mode [1]: ');
const triggerValue = parseInt(triggerInput, 10) || 1;
switch (triggerValue) {
case 2:
action.triggerMode = 'onlyPowerChanges';
break;
case 3:
action.triggerMode = 'onlyThresholds';
break;
case 4:
action.triggerMode = 'anyChange';
break;
default:
action.triggerMode = 'powerChangesAndThresholds';
}
// Configure thresholds if needed for onlyThresholds or powerChangesAndThresholds modes
if (action.triggerMode === 'onlyThresholds' || action.triggerMode === 'powerChangesAndThresholds') {
logger.log('');
logger.info('Action Thresholds:');
logger.dim('Action will trigger when battery or runtime falls below these values (while on battery)');
const batteryInput = await prompt('Battery threshold percentage [60]: ');
const battery = parseInt(batteryInput, 10);
const batteryThreshold = (batteryInput.trim() && !isNaN(battery)) ? battery : 60;
const runtimeInput = await prompt('Runtime threshold in minutes [20]: ');
const runtime = parseInt(runtimeInput, 10);
const runtimeThreshold = (runtimeInput.trim() && !isNaN(runtime)) ? runtime : 20;
action.thresholds = {
battery: batteryThreshold,
runtime: runtimeThreshold,
};
}
actions.push(action);
logger.success(`${action.type.charAt(0).toUpperCase() + action.type.slice(1)} action added (mode: ${action.triggerMode || 'powerChangesAndThresholds'})`);
const more = await prompt('Add another action? (y/N): ');
addMore = more.toLowerCase() === 'y';
}
if (actions.length > 0) {
logger.log('');
logger.success(`${actions.length} action(s) configured`);
}
}
/** /**
* Display UPS configuration summary * Display UPS configuration summary
* @param ups UPS configuration * @param ups UPS configuration
@@ -920,9 +1039,7 @@ export class UpsHandler {
logger.logBoxLine(`SNMP Host: ${ups.snmp.host}:${ups.snmp.port}`); logger.logBoxLine(`SNMP Host: ${ups.snmp.host}:${ups.snmp.port}`);
logger.logBoxLine(`SNMP Version: ${ups.snmp.version}`); logger.logBoxLine(`SNMP Version: ${ups.snmp.version}`);
logger.logBoxLine(`UPS Model: ${ups.snmp.upsModel}`); 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) { if (ups.groups && ups.groups.length > 0) {
logger.logBoxLine(`Groups: ${ups.groups.join(', ')}`); logger.logBoxLine(`Groups: ${ups.groups.join(', ')}`);
} else { } else {

View File

@@ -4,9 +4,12 @@ import * as path from 'node:path';
import { exec, execFile } from 'node:child_process'; import { exec, execFile } from 'node:child_process';
import { promisify } from 'node:util'; import { promisify } from 'node:util';
import { NupstSnmp } from './snmp/manager.ts'; import { NupstSnmp } from './snmp/manager.ts';
import type { ISnmpConfig } from './snmp/types.ts'; import type { ISnmpConfig, IUpsStatus as ISnmpUpsStatus } from './snmp/types.ts';
import { logger } from './logger.ts'; import { logger, type ITableColumn } from './logger.ts';
import { MigrationRunner } from './migrations/index.ts'; import { MigrationRunner } from './migrations/index.ts';
import { theme, symbols, getBatteryColor, getRuntimeColor, formatPowerStatus } from './colors.ts';
import type { IActionConfig } from './actions/base-action.ts';
import { ActionManager, type IActionContext, type TPowerStatus } from './actions/index.ts';
const execAsync = promisify(exec); const execAsync = promisify(exec);
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
@@ -21,15 +24,10 @@ export interface IUpsConfig {
name: string; name: string;
/** SNMP configuration settings */ /** SNMP configuration settings */
snmp: ISnmpConfig; snmp: ISnmpConfig;
/** Threshold settings for initiating shutdown */
thresholds: {
/** Shutdown when battery below this percentage */
battery: number;
/** Shutdown when runtime below this minutes */
runtime: number;
};
/** Group IDs this UPS belongs to */ /** Group IDs this UPS belongs to */
groups: string[]; groups: string[];
/** Actions to trigger on power status changes and threshold violations */
actions?: IActionConfig[];
} }
/** /**
@@ -44,6 +42,8 @@ export interface IGroupConfig {
mode: 'redundant' | 'nonRedundant'; mode: 'redundant' | 'nonRedundant';
/** Optional description */ /** Optional description */
description?: string; description?: string;
/** Actions to trigger on power status changes and threshold violations */
actions?: IActionConfig[];
} }
/** /**
@@ -76,7 +76,7 @@ export interface INupstConfig {
/** /**
* UPS status tracking interface * UPS status tracking interface
*/ */
interface IUpsStatus { export interface IUpsStatus {
id: string; id: string;
name: string; name: string;
powerStatus: 'online' | 'onBattery' | 'unknown'; powerStatus: 'online' | 'onBattery' | 'unknown';
@@ -96,7 +96,7 @@ export class NupstDaemon {
/** Default configuration */ /** Default configuration */
private readonly DEFAULT_CONFIG: INupstConfig = { private readonly DEFAULT_CONFIG: INupstConfig = {
version: '4.0', version: '4.2',
upsDevices: [ upsDevices: [
{ {
id: 'default', id: 'default',
@@ -117,16 +117,23 @@ export class NupstDaemon {
// UPS model for OID selection // UPS model for OID selection
upsModel: 'cyberpower', upsModel: 'cyberpower',
}, },
thresholds: {
battery: 60, // Shutdown when battery below 60%
runtime: 20, // Shutdown when runtime below 20 minutes
},
groups: [], groups: [],
actions: [
{
type: 'shutdown',
triggerMode: 'onlyThresholds',
thresholds: {
battery: 60, // Shutdown when battery below 60%
runtime: 20, // Shutdown when runtime below 20 minutes
},
shutdownDelay: 5,
},
],
}, },
], ],
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;
@@ -164,11 +171,13 @@ export class NupstDaemon {
const { config: migratedConfig, migrated } = await migrationRunner.run(parsedConfig); const { config: migratedConfig, migrated } = await migrationRunner.run(parsedConfig);
// Save migrated config back to disk if any migrations ran // Save migrated config back to disk if any migrations ran
// Cast to INupstConfig since migrations ensure the output is valid
const validConfig = migratedConfig as unknown as INupstConfig;
if (migrated) { if (migrated) {
this.config = migratedConfig; this.config = validConfig;
await this.saveConfig(this.config); await this.saveConfig(this.config);
} else { } else {
this.config = migratedConfig; this.config = validConfig;
} }
return this.config; return this.config;
@@ -198,7 +207,7 @@ export class NupstDaemon {
// Ensure version is always set and remove legacy fields before saving // Ensure version is always set and remove legacy fields before saving
const configToSave: INupstConfig = { const configToSave: INupstConfig = {
version: '4.0', version: '4.1',
upsDevices: config.upsDevices, upsDevices: config.upsDevices,
groups: config.groups, groups: config.groups,
checkInterval: config.checkInterval, checkInterval: config.checkInterval,
@@ -207,11 +216,9 @@ export class NupstDaemon {
fs.writeFileSync(this.CONFIG_PATH, JSON.stringify(configToSave, null, 2)); fs.writeFileSync(this.CONFIG_PATH, JSON.stringify(configToSave, null, 2));
this.config = configToSave; this.config = configToSave;
console.log('┌─ Configuration Saved ─────────────────────┐'); logger.logBox('Configuration Saved', [`Location: ${this.CONFIG_PATH}`], 45, 'success');
console.log(`│ Location: ${this.CONFIG_PATH}`);
console.log('└──────────────────────────────────────────┘');
} catch (error) { } catch (error) {
console.error('Error saving configuration:', error); logger.error(`Error saving configuration: ${error}`);
} }
} }
@@ -219,10 +226,7 @@ export class NupstDaemon {
* Helper method to log configuration errors consistently * Helper method to log configuration errors consistently
*/ */
private logConfigError(message: string): void { private logConfigError(message: string): void {
console.error('┌─ Configuration Error ─────────────────────┐'); logger.logBox('Configuration Error', [message, "Please run 'nupst setup' first to create a configuration."], 45, 'error');
console.error(`${message}`);
console.error("│ Please run 'nupst setup' first to create a configuration.");
console.error('└───────────────────────────────────────────┘');
} }
/** /**
@@ -315,29 +319,57 @@ export class NupstDaemon {
* Log the loaded configuration settings * Log the loaded configuration settings
*/ */
private logConfigLoaded(): void { private logConfigLoaded(): void {
const boxWidth = 50;
logger.logBoxTitle('Configuration Loaded', boxWidth);
if (this.config.upsDevices && this.config.upsDevices.length > 0) {
logger.logBoxLine(`UPS Devices: ${this.config.upsDevices.length}`);
for (const ups of this.config.upsDevices) {
logger.logBoxLine(` - ${ups.name} (${ups.id}): ${ups.snmp.host}:${ups.snmp.port}`);
}
} else {
logger.logBoxLine('No UPS devices configured');
}
if (this.config.groups && this.config.groups.length > 0) {
logger.logBoxLine(`Groups: ${this.config.groups.length}`);
for (const group of this.config.groups) {
logger.logBoxLine(` - ${group.name} (${group.id}): ${group.mode} mode`);
}
} else {
logger.logBoxLine('No Groups configured');
}
logger.log('');
logger.logBoxTitle('Configuration Loaded', 70, 'success');
logger.logBoxLine(`Check Interval: ${this.config.checkInterval / 1000} seconds`); logger.logBoxLine(`Check Interval: ${this.config.checkInterval / 1000} seconds`);
logger.logBoxEnd(); logger.logBoxEnd();
logger.log('');
// Display UPS devices in a table
if (this.config.upsDevices && this.config.upsDevices.length > 0) {
logger.info(`UPS Devices (${this.config.upsDevices.length}):`);
const upsColumns: Array<{ header: string; key: string; align?: 'left' | 'right'; color?: (val: string) => string }> = [
{ header: 'Name', key: 'name', align: 'left', color: theme.highlight },
{ header: 'ID', key: 'id', align: 'left', color: theme.dim },
{ header: 'Host:Port', key: 'host', align: 'left', color: theme.info },
{ header: 'Actions', key: 'actions', align: 'left' },
];
const upsRows: Array<Record<string, string>> = this.config.upsDevices.map((ups) => ({
name: ups.name,
id: ups.id,
host: `${ups.snmp.host}:${ups.snmp.port}`,
actions: `${(ups.actions || []).length} configured`,
}));
logger.logTable(upsColumns, upsRows);
logger.log('');
} else {
logger.warn('No UPS devices configured');
logger.log('');
}
// Display groups in a table
if (this.config.groups && this.config.groups.length > 0) {
logger.info(`Groups (${this.config.groups.length}):`);
const groupColumns: Array<{ header: string; key: string; align?: 'left' | 'right'; color?: (val: string) => string }> = [
{ header: 'Name', key: 'name', align: 'left', color: theme.highlight },
{ header: 'ID', key: 'id', align: 'left', color: theme.dim },
{ header: 'Mode', key: 'mode', align: 'left', color: theme.info },
];
const groupRows: Array<Record<string, string>> = this.config.groups.map((group) => ({
name: group.name,
id: group.id,
mode: group.mode,
}));
logger.logTable(groupColumns, groupRows);
logger.log('');
}
} }
/** /**
@@ -377,9 +409,6 @@ export class NupstDaemon {
lastLogTime = currentTime; lastLogTime = currentTime;
} }
// Check if shutdown is required based on group configurations
await this.evaluateGroupShutdownConditions();
// Wait before next check // Wait before next check
await this.sleep(this.config.checkInterval); await this.sleep(this.config.checkInterval);
} catch (error) { } catch (error) {
@@ -433,11 +462,42 @@ export class NupstDaemon {
// Check if power status changed // Check if power status changed
if (currentStatus && currentStatus.powerStatus !== status.powerStatus) { if (currentStatus && currentStatus.powerStatus !== status.powerStatus) {
logger.logBoxTitle(`Power Status Change: ${ups.name}`, 50); logger.log('');
logger.logBoxLine(`Status changed: ${currentStatus.powerStatus}${status.powerStatus}`); logger.logBoxTitle(`Power Status Change: ${ups.name}`, 60, 'warning');
logger.logBoxLine(`Previous: ${formatPowerStatus(currentStatus.powerStatus)}`);
logger.logBoxLine(`Current: ${formatPowerStatus(status.powerStatus)}`);
logger.logBoxLine(`Time: ${new Date().toISOString()}`);
logger.logBoxEnd(); logger.logBoxEnd();
logger.log('');
updatedStatus.lastStatusChange = currentTime; updatedStatus.lastStatusChange = currentTime;
// Trigger actions for power status change
await this.triggerUpsActions(ups, updatedStatus, currentStatus, 'powerStatusChange');
}
// Check if any action's thresholds are exceeded (for threshold violation triggers)
// Only check when on battery power
if (status.powerStatus === 'onBattery' && ups.actions && ups.actions.length > 0) {
let anyThresholdExceeded = false;
for (const actionConfig of ups.actions) {
if (actionConfig.thresholds) {
if (
status.batteryCapacity < actionConfig.thresholds.battery ||
status.batteryRuntime < actionConfig.thresholds.runtime
) {
anyThresholdExceeded = true;
break;
}
}
}
// Trigger actions with threshold violation reason if any threshold is exceeded
// Actions will individually check their own thresholds in shouldExecute()
if (anyThresholdExceeded) {
await this.triggerUpsActions(ups, updatedStatus, currentStatus, 'thresholdViolation');
}
} }
// Update the status in the map // Update the status in the map
@@ -457,171 +517,100 @@ export class NupstDaemon {
*/ */
private logAllUpsStatus(): void { private logAllUpsStatus(): void {
const timestamp = new Date().toISOString(); const timestamp = new Date().toISOString();
const boxWidth = 60;
logger.logBoxTitle('Periodic Status Update', boxWidth); logger.log('');
logger.logBoxTitle('Periodic Status Update', 70, 'info');
logger.logBoxLine(`Timestamp: ${timestamp}`); 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(); logger.logBoxEnd();
logger.log('');
// Build table data
const columns: Array<{ header: string; key: string; align?: 'left' | 'right'; color?: (val: string) => string }> = [
{ header: 'UPS Name', key: 'name', align: 'left', color: theme.highlight },
{ header: 'ID', key: 'id', align: 'left', color: theme.dim },
{ header: 'Power Status', key: 'powerStatus', align: 'left' },
{ header: 'Battery', key: 'battery', align: 'right' },
{ header: 'Runtime', key: 'runtime', align: 'right' },
];
const rows: Array<Record<string, string>> = [];
for (const [id, status] of this.upsStatus.entries()) {
const batteryColor = getBatteryColor(status.batteryCapacity);
const runtimeColor = getRuntimeColor(status.batteryRuntime);
rows.push({
name: status.name,
id: id,
powerStatus: formatPowerStatus(status.powerStatus),
battery: batteryColor(status.batteryCapacity + '%'),
runtime: runtimeColor(status.batteryRuntime + ' min'),
});
}
logger.logTable(columns, rows);
logger.log('');
}
/**
* Build action context from UPS state
* @param ups UPS configuration
* @param status Current UPS status
* @param triggerReason Why this action is being triggered
* @returns Action context
*/
private buildActionContext(
ups: IUpsConfig,
status: IUpsStatus,
triggerReason: 'powerStatusChange' | 'thresholdViolation',
): IActionContext {
return {
upsId: ups.id,
upsName: ups.name,
powerStatus: status.powerStatus as TPowerStatus,
batteryCapacity: status.batteryCapacity,
batteryRuntime: status.batteryRuntime,
previousPowerStatus: 'unknown' as TPowerStatus, // Will be set from map in calling code
timestamp: Date.now(),
triggerReason,
};
} }
/** /**
* Evaluate if shutdown is required based on group configurations * Trigger actions for a UPS device
* @param ups UPS configuration
* @param status Current UPS status
* @param previousStatus Previous UPS status (for determining previousPowerStatus)
* @param triggerReason Why actions are being triggered
*/ */
private async evaluateGroupShutdownConditions(): Promise<void> { private async triggerUpsActions(
if (!this.config.groups || this.config.groups.length === 0) { ups: IUpsConfig,
// No groups defined, check individual UPS conditions status: IUpsStatus,
for (const [id, status] of this.upsStatus.entries()) { previousStatus: IUpsStatus | undefined,
if (status.powerStatus === 'onBattery') { triggerReason: 'powerStatusChange' | 'thresholdViolation',
// Find the UPS config
const ups = this.config.upsDevices.find((u) => u.id === id);
if (ups) {
await this.evaluateUpsShutdownCondition(ups, status);
}
}
}
return;
}
// Evaluate each group
for (const group of this.config.groups) {
// Find all UPS devices in this group
const upsDevicesInGroup = this.config.upsDevices.filter((ups) =>
ups.groups && ups.groups.includes(group.id)
);
if (upsDevicesInGroup.length === 0) {
// No UPS devices in this group
continue;
}
if (group.mode === 'redundant') {
// Redundant mode: only shutdown if ALL UPS devices in the group are in critical condition
await this.evaluateRedundantGroup(group, upsDevicesInGroup);
} else {
// Non-redundant mode: shutdown if ANY UPS device in the group is in critical condition
await this.evaluateNonRedundantGroup(group, upsDevicesInGroup);
}
}
}
/**
* Evaluate a redundant group for shutdown conditions
* In redundant mode, we only shut down if ALL UPS devices are in critical condition
*/
private async evaluateRedundantGroup(
group: IGroupConfig,
upsDevices: IUpsConfig[],
): Promise<void> { ): Promise<void> {
// Count UPS devices on battery and in critical condition const actions = ups.actions || [];
let upsOnBattery = 0;
let upsInCriticalCondition = 0;
for (const ups of upsDevices) {
const status = this.upsStatus.get(ups.id);
if (!status) continue;
if (status.powerStatus === 'onBattery') {
upsOnBattery++;
// Check if this UPS is in critical condition
if (
status.batteryCapacity < ups.thresholds.battery ||
status.batteryRuntime < ups.thresholds.runtime
) {
upsInCriticalCondition++;
}
}
}
// All UPS devices must be online for a redundant group to be considered healthy
const allUpsCount = upsDevices.length;
// If all UPS are on battery and in critical condition, shutdown
if (upsOnBattery === allUpsCount && upsInCriticalCondition === allUpsCount) {
logger.logBoxTitle(`Group Shutdown Required: ${group.name}`, 50);
logger.logBoxLine(`Mode: Redundant`);
logger.logBoxLine(`All ${allUpsCount} UPS devices in critical condition`);
logger.logBoxEnd();
await this.initiateShutdown(
`All UPS devices in redundant group "${group.name}" in critical condition`,
);
}
}
/**
* Evaluate a non-redundant group for shutdown conditions
* In non-redundant mode, we shut down if ANY UPS device is in critical condition
*/
private async evaluateNonRedundantGroup(
group: IGroupConfig,
upsDevices: IUpsConfig[],
): Promise<void> {
for (const ups of upsDevices) {
const status = this.upsStatus.get(ups.id);
if (!status) continue;
if (status.powerStatus === 'onBattery') {
// Check if this UPS is in critical condition
if (
status.batteryCapacity < ups.thresholds.battery ||
status.batteryRuntime < ups.thresholds.runtime
) {
logger.logBoxTitle(`Group Shutdown Required: ${group.name}`, 50);
logger.logBoxLine(`Mode: Non-Redundant`);
logger.logBoxLine(`UPS ${ups.name} in critical condition`);
logger.logBoxLine(
`Battery: ${status.batteryCapacity}% (threshold: ${ups.thresholds.battery}%)`,
);
logger.logBoxLine(
`Runtime: ${status.batteryRuntime} min (threshold: ${ups.thresholds.runtime} min)`,
);
logger.logBoxEnd();
await this.initiateShutdown(
`UPS "${ups.name}" in non-redundant group "${group.name}" in critical condition`,
);
return; // Exit after initiating shutdown
}
}
}
}
/**
* Evaluate an individual UPS for shutdown conditions
*/
private async evaluateUpsShutdownCondition(ups: IUpsConfig, status: IUpsStatus): Promise<void> {
// Only evaluate UPS devices not in any group
if (ups.groups && ups.groups.length > 0) {
return;
}
// Check threshold conditions
if (
status.batteryCapacity < ups.thresholds.battery ||
status.batteryRuntime < ups.thresholds.runtime
) {
logger.logBoxTitle(`UPS Shutdown Required: ${ups.name}`, 50);
logger.logBoxLine(
`Battery: ${status.batteryCapacity}% (threshold: ${ups.thresholds.battery}%)`,
);
logger.logBoxLine(
`Runtime: ${status.batteryRuntime} min (threshold: ${ups.thresholds.runtime} min)`,
);
logger.logBoxEnd();
// Backward compatibility: if no actions configured, use default shutdown behavior
if (actions.length === 0 && triggerReason === 'thresholdViolation') {
// Fall back to old shutdown logic for backward compatibility
await this.initiateShutdown(`UPS "${ups.name}" battery or runtime below threshold`); await this.initiateShutdown(`UPS "${ups.name}" battery or runtime below threshold`);
return;
} }
if (actions.length === 0) {
return; // No actions to execute
}
// Build action context
const context = this.buildActionContext(ups, status, triggerReason);
context.previousPowerStatus = (previousStatus?.powerStatus || 'unknown') as TPowerStatus;
// Execute actions
await ActionManager.executeActions(actions, context);
} }
/** /**
@@ -750,38 +739,61 @@ 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();
logger.log( logger.log('');
`Emergency shutdown threshold: ${EMERGENCY_RUNTIME_THRESHOLD} minutes remaining battery runtime`, logger.logBoxTitle('Shutdown Monitoring Active', 60, 'warning');
); logger.logBoxLine(`Emergency threshold: ${EMERGENCY_RUNTIME_THRESHOLD} minutes runtime`);
logger.logBoxLine(`Check interval: ${CHECK_INTERVAL / 1000} seconds`);
logger.logBoxLine(`Max monitoring time: ${MAX_MONITORING_TIME / 1000} seconds`);
logger.logBoxEnd();
logger.log('');
// 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 {
logger.log('Checking UPS status during shutdown...'); logger.info('Checking UPS status during shutdown...');
// Build table for UPS status during shutdown
const columns: Array<{ header: string; key: string; align?: 'left' | 'right'; color?: (val: string) => string }> = [
{ header: 'UPS Name', key: 'name', align: 'left', color: theme.highlight },
{ header: 'Battery', key: 'battery', align: 'right' },
{ header: 'Runtime', key: 'runtime', align: 'right' },
{ header: 'Status', key: 'status', align: 'left' },
];
const rows: Array<Record<string, string>> = [];
let emergencyDetected = false;
let emergencyUps: { ups: IUpsConfig; status: ISnmpUpsStatus } | null = null;
// Check all UPS devices // Check all UPS devices
for (const ups of this.config.upsDevices) { for (const ups of this.config.upsDevices) {
try { try {
const status = await this.snmp.getUpsStatus(ups.snmp); const status = await this.snmp.getUpsStatus(ups.snmp);
logger.log( const batteryColor = getBatteryColor(status.batteryCapacity);
`UPS ${ups.name}: Battery ${status.batteryCapacity}%, Runtime: ${status.batteryRuntime} minutes`, const runtimeColor = getRuntimeColor(status.batteryRuntime);
);
// If any UPS battery runtime gets critically low, force immediate shutdown const isCritical = status.batteryRuntime < EMERGENCY_RUNTIME_THRESHOLD;
if (status.batteryRuntime < EMERGENCY_RUNTIME_THRESHOLD) {
logger.logBoxTitle('EMERGENCY SHUTDOWN', 50);
logger.logBoxLine(
`UPS ${ups.name} runtime critically low: ${status.batteryRuntime} minutes`,
);
logger.logBoxLine('Forcing immediate shutdown!');
logger.logBoxEnd();
// Force immediate shutdown rows.push({
await this.forceImmediateShutdown(); name: ups.name,
return; battery: batteryColor(status.batteryCapacity + '%'),
runtime: runtimeColor(status.batteryRuntime + ' min'),
status: isCritical ? theme.error('CRITICAL!') : theme.success('OK'),
});
// If any UPS battery runtime gets critically low, flag for immediate shutdown
if (isCritical && !emergencyDetected) {
emergencyDetected = true;
emergencyUps = { ups, status };
} }
} catch (upsError) { } catch (upsError) {
rows.push({
name: ups.name,
battery: theme.error('N/A'),
runtime: theme.error('N/A'),
status: theme.error('ERROR'),
});
logger.error( logger.error(
`Error checking UPS ${ups.name} during shutdown: ${ `Error checking UPS ${ups.name} during shutdown: ${
upsError instanceof Error ? upsError.message : String(upsError) upsError instanceof Error ? upsError.message : String(upsError)
@@ -790,6 +802,27 @@ export class NupstDaemon {
} }
} }
// Display the table
logger.logTable(columns, rows);
logger.log('');
// If emergency detected, trigger immediate shutdown
if (emergencyDetected && emergencyUps) {
logger.log('');
logger.logBoxTitle('EMERGENCY SHUTDOWN', 60, 'error');
logger.logBoxLine(
`UPS ${emergencyUps.ups.name} runtime critically low: ${emergencyUps.status.batteryRuntime} minutes`,
);
logger.logBoxLine(`Emergency threshold: ${EMERGENCY_RUNTIME_THRESHOLD} minutes`);
logger.logBoxLine('Forcing immediate shutdown!');
logger.logBoxEnd();
logger.log('');
// Force immediate shutdown
await this.forceImmediateShutdown();
return;
}
// Wait before checking again // Wait before checking again
await this.sleep(CHECK_INTERVAL); await this.sleep(CHECK_INTERVAL);
} catch (error) { } catch (error) {
@@ -802,7 +835,9 @@ export class NupstDaemon {
} }
} }
logger.log('UPS monitoring during shutdown completed'); logger.log('');
logger.success('UPS monitoring during shutdown completed');
logger.log('');
} }
/** /**

View File

@@ -5,16 +5,14 @@
* Migrations run in order based on the `order` field, allowing users to jump * Migrations run in order based on the `order` field, allowing users to jump
* multiple versions (e.g., v1 → v4 runs migrations 2, 3, and 4). * multiple versions (e.g., v1 → v4 runs migrations 2, 3, and 4).
*/ */
/**
* Abstract base class for configuration migrations
*
* Each migration represents an upgrade from one config version to another.
* Migrations run in order based on the `toVersion` field, allowing users to jump
* multiple versions (e.g., v1 → v4 runs migrations 2, 3, and 4).
*/
export abstract class BaseMigration { export abstract class BaseMigration {
/**
* Migration order number
* - Order 2: v1 → v2
* - Order 3: v2 → v3
* - Order 4: v3 → v4
* etc.
*/
abstract readonly order: number;
/** /**
* Source version this migration upgrades from * Source version this migration upgrades from
* e.g., "1.x", "3.x" * e.g., "1.x", "3.x"
@@ -23,25 +21,25 @@ export abstract class BaseMigration {
/** /**
* Target version this migration upgrades to * Target version this migration upgrades to
* e.g., "2.0", "4.0" * e.g., "2.0", "4.0", "4.1"
*/ */
abstract readonly toVersion: string; abstract readonly toVersion: string;
/** /**
* Check if this migration should run on the given config * Check if this migration should run on the given config
* *
* @param config - Raw configuration object to check * @param config - Raw configuration object to check (unknown schema for migrations)
* @returns True if migration should run, false otherwise * @returns True if migration should run, false otherwise
*/ */
abstract shouldRun(config: any): Promise<boolean>; abstract shouldRun(config: Record<string, unknown>): Promise<boolean>;
/** /**
* Perform the migration on the given config * Perform the migration on the given config
* *
* @param config - Raw configuration object to migrate * @param config - Raw configuration object to migrate (unknown schema for migrations)
* @returns Migrated configuration object * @returns Migrated configuration object
*/ */
abstract migrate(config: any): Promise<any>; abstract migrate(config: Record<string, unknown>): Promise<Record<string, unknown>>;
/** /**
* Get human-readable name for this migration * Get human-readable name for this migration
@@ -51,4 +49,19 @@ export abstract class BaseMigration {
getName(): string { getName(): string {
return `Migration ${this.fromVersion}${this.toVersion}`; return `Migration ${this.fromVersion}${this.toVersion}`;
} }
/**
* Parse version string into a comparable number
* Supports formats like "2.0", "4.1", etc.
* Returns a number like 2.0, 4.1 for sorting
*
* @returns Parsed version number for ordering
*/
getVersionOrder(): number {
const parsed = parseFloat(this.toVersion);
if (isNaN(parsed)) {
throw new Error(`Invalid version format: ${this.toVersion}`);
}
return parsed;
}
} }

View File

@@ -8,3 +8,4 @@ export { BaseMigration } from './base-migration.ts';
export { MigrationRunner } from './migration-runner.ts'; export { MigrationRunner } from './migration-runner.ts';
export { MigrationV1ToV2 } from './migration-v1-to-v2.ts'; export { MigrationV1ToV2 } from './migration-v1-to-v2.ts';
export { MigrationV3ToV4 } from './migration-v3-to-v4.ts'; export { MigrationV3ToV4 } from './migration-v3-to-v4.ts';
export { MigrationV4_0ToV4_1 } from './migration-v4.0-to-v4.1.ts';

View File

@@ -1,6 +1,7 @@
import { BaseMigration } from './base-migration.ts'; import { BaseMigration } from './base-migration.ts';
import { MigrationV1ToV2 } from './migration-v1-to-v2.ts'; import { MigrationV1ToV2 } from './migration-v1-to-v2.ts';
import { MigrationV3ToV4 } from './migration-v3-to-v4.ts'; import { MigrationV3ToV4 } from './migration-v3-to-v4.ts';
import { MigrationV4_0ToV4_1 } from './migration-v4.0-to-v4.1.ts';
import { logger } from '../logger.ts'; import { logger } from '../logger.ts';
/** /**
@@ -17,11 +18,12 @@ export class MigrationRunner {
this.migrations = [ this.migrations = [
new MigrationV1ToV2(), new MigrationV1ToV2(),
new MigrationV3ToV4(), new MigrationV3ToV4(),
// Add future migrations here (v4→v5, v5→v6, etc.) new MigrationV4_0ToV4_1(),
// Add future migrations here (v4.3, v4.4, etc.)
]; ];
// Sort by order to ensure they run in sequence // Sort by version order to ensure they run in sequence
this.migrations.sort((a, b) => a.order - b.order); this.migrations.sort((a, b) => a.getVersionOrder() - b.getVersionOrder());
} }
/** /**
@@ -30,16 +32,20 @@ export class MigrationRunner {
* @param config - Raw configuration object to migrate * @param config - Raw configuration object to migrate
* @returns Migrated configuration and whether migrations ran * @returns Migrated configuration and whether migrations ran
*/ */
async run(config: any): Promise<{ config: any; migrated: boolean }> { async run(
config: Record<string, unknown>,
): Promise<{ config: Record<string, unknown>; migrated: boolean }> {
let currentConfig = config; let currentConfig = config;
let anyMigrationsRan = false; let anyMigrationsRan = false;
logger.dim('Checking for required config migrations...');
for (const migration of this.migrations) { for (const migration of this.migrations) {
const shouldRun = await migration.shouldRun(currentConfig); const shouldRun = await migration.shouldRun(currentConfig);
if (shouldRun) { if (shouldRun) {
// Only show "checking" message when we actually need to migrate
if (!anyMigrationsRan) {
logger.dim('Checking for required config migrations...');
}
logger.info(`Running ${migration.getName()}...`); logger.info(`Running ${migration.getName()}...`);
currentConfig = await migration.migrate(currentConfig); currentConfig = await migration.migrate(currentConfig);
anyMigrationsRan = true; anyMigrationsRan = true;
@@ -49,7 +55,7 @@ export class MigrationRunner {
if (anyMigrationsRan) { if (anyMigrationsRan) {
logger.success('Configuration migrations complete'); logger.success('Configuration migrations complete');
} else { } else {
logger.dim('No migrations needed'); logger.success('config format ok');
} }
return { return {

View File

@@ -20,7 +20,6 @@ import { logger } from '../logger.ts';
* } * }
*/ */
export class MigrationV1ToV2 extends BaseMigration { export class MigrationV1ToV2 extends BaseMigration {
readonly order = 2;
readonly fromVersion = '1.x'; readonly fromVersion = '1.x';
readonly toVersion = '2.0'; readonly toVersion = '2.0';

View File

@@ -39,7 +39,6 @@ import { logger } from '../logger.ts';
* } * }
*/ */
export class MigrationV3ToV4 extends BaseMigration { export class MigrationV3ToV4 extends BaseMigration {
readonly order = 4;
readonly fromVersion = '3.x'; readonly fromVersion = '3.x';
readonly toVersion = '4.0'; readonly toVersion = '4.0';

View File

@@ -0,0 +1,127 @@
import { BaseMigration } from './base-migration.ts';
import { logger } from '../logger.ts';
/**
* Migration from v4.0 to v4.1
*
* Major changes:
* 1. Moves thresholds from UPS level to action level
* 2. Creates default shutdown action for UPS devices that had thresholds
* 3. Adds empty actions array to UPS devices without actions
* 4. Adds empty actions array to groups
*
* Transforms v4.0 format (with UPS-level thresholds):
* {
* version: "4.0",
* upsDevices: [
* {
* id: "ups-1",
* name: "UPS 1",
* snmp: {...},
* thresholds: { battery: 60, runtime: 20 }, // UPS-level
* groups: []
* }
* ]
* }
*
* To v4.1 format (with action-level thresholds):
* {
* version: "4.1",
* upsDevices: [
* {
* id: "ups-1",
* name: "UPS 1",
* snmp: {...},
* groups: [],
* actions: [ // Thresholds moved here
* {
* type: "shutdown",
* thresholds: { battery: 60, runtime: 20 },
* triggerMode: "onlyThresholds",
* shutdownDelay: 5
* }
* ]
* }
* ]
* }
*/
export class MigrationV4_0ToV4_1 extends BaseMigration {
readonly fromVersion = '4.0';
readonly toVersion = '4.1';
async shouldRun(config: Record<string, unknown>): Promise<boolean> {
// Run if config is version 4.0
if (config.version === '4.0') {
return true;
}
// Also run if config has upsDevices with thresholds at UPS level (v4.0 format)
if (Array.isArray(config.upsDevices) && config.upsDevices.length > 0) {
const firstDevice = config.upsDevices[0] as Record<string, unknown>;
// v4.0 has thresholds at UPS level, v4.1 has them in actions
return firstDevice.thresholds !== undefined;
}
return false;
}
async migrate(config: Record<string, unknown>): Promise<Record<string, unknown>> {
logger.info(`${this.getName()}: Migrating v4.0 config to v4.1 format...`);
logger.dim(` - Moving thresholds from UPS level to action level`);
logger.dim(` - Creating default shutdown actions from existing thresholds`);
// Migrate UPS devices
const devices = (config.upsDevices as Array<Record<string, unknown>>) || [];
const migratedDevices = devices.map((device) => {
const migrated: Record<string, unknown> = {
id: device.id,
name: device.name,
snmp: device.snmp,
groups: device.groups || [],
};
// If device has thresholds at UPS level, convert to shutdown action
const deviceThresholds = device.thresholds as { battery: number; runtime: number } | undefined;
if (deviceThresholds) {
migrated.actions = [
{
type: 'shutdown',
thresholds: {
battery: deviceThresholds.battery,
runtime: deviceThresholds.runtime,
},
triggerMode: 'onlyThresholds', // Preserve old behavior (only on threshold violation)
shutdownDelay: 5, // Default delay
},
];
logger.dim(
`${device.name}: Created shutdown action (battery: ${deviceThresholds.battery}%, runtime: ${deviceThresholds.runtime}min)`,
);
} else {
// No thresholds, just add empty actions array
migrated.actions = device.actions || [];
}
return migrated;
});
// Add actions to groups
const groups = (config.groups as Array<Record<string, unknown>>) || [];
const migratedGroups = groups.map((group) => ({
...group,
actions: group.actions || [],
}));
const result = {
version: this.toVersion,
upsDevices: migratedDevices,
groups: migratedGroups,
checkInterval: config.checkInterval || 30000,
};
logger.success(
`${this.getName()}: Migration complete (${migratedDevices.length} devices, ${migratedGroups.length} groups updated)`,
);
return result;
}
}

View File

@@ -6,6 +6,7 @@ import { logger } from './logger.ts';
import { UpsHandler } from './cli/ups-handler.ts'; import { UpsHandler } from './cli/ups-handler.ts';
import { GroupHandler } from './cli/group-handler.ts'; import { GroupHandler } from './cli/group-handler.ts';
import { ServiceHandler } from './cli/service-handler.ts'; import { ServiceHandler } from './cli/service-handler.ts';
import { ActionHandler } from './cli/action-handler.ts';
import * as https from 'node:https'; import * as https from 'node:https';
/** /**
@@ -19,6 +20,7 @@ export class Nupst {
private readonly upsHandler: UpsHandler; private readonly upsHandler: UpsHandler;
private readonly groupHandler: GroupHandler; private readonly groupHandler: GroupHandler;
private readonly serviceHandler: ServiceHandler; private readonly serviceHandler: ServiceHandler;
private readonly actionHandler: ActionHandler;
private updateAvailable: boolean = false; private updateAvailable: boolean = false;
private latestVersion: string = ''; private latestVersion: string = '';
@@ -36,6 +38,7 @@ export class Nupst {
this.upsHandler = new UpsHandler(this); this.upsHandler = new UpsHandler(this);
this.groupHandler = new GroupHandler(this); this.groupHandler = new GroupHandler(this);
this.serviceHandler = new ServiceHandler(this); this.serviceHandler = new ServiceHandler(this);
this.actionHandler = new ActionHandler(this);
} }
/** /**
@@ -80,6 +83,13 @@ export class Nupst {
return this.serviceHandler; return this.serviceHandler;
} }
/**
* Get the Action handler for action management
*/
public getActionHandler(): ActionHandler {
return this.actionHandler;
}
/** /**
* Get the current version of NUPST * Get the current version of NUPST
* @returns The current version string * @returns The current version string

View File

@@ -1,7 +1,8 @@
import process from 'node:process'; import process from 'node:process';
import { promises as fs } from 'node:fs'; import { promises as fs } from 'node:fs';
import { execSync } from 'node:child_process'; import { execSync } from 'node:child_process';
import { NupstDaemon } from './daemon.ts'; import { NupstDaemon, type IUpsConfig } from './daemon.ts';
import { NupstSnmp } from './snmp/manager.ts';
import { logger } from './logger.ts'; import { logger } from './logger.ts';
import { theme, symbols, getBatteryColor, getRuntimeColor, formatPowerStatus } from './colors.ts'; import { theme, symbols, getBatteryColor, getRuntimeColor, formatPowerStatus } from './colors.ts';
@@ -50,11 +51,11 @@ WantedBy=multi-user.target
try { try {
await fs.access(configPath); await fs.access(configPath);
} catch (error) { } catch (error) {
console.log(''); logger.log('');
console.log(`${symbols.error} ${theme.error('No configuration found')}`); logger.error('No configuration found');
console.log(` ${theme.dim('Config file:')} ${configPath}`); logger.log(` ${theme.dim('Config file:')} ${configPath}`);
console.log(` ${theme.dim('Run')} ${theme.command('nupst ups add')} ${theme.dim('to create a configuration')}`); logger.log(` ${theme.dim('Run')} ${theme.command('nupst ups add')} ${theme.dim('to create a configuration')}`);
console.log(''); logger.log('');
throw new Error('Configuration not found'); throw new Error('Configuration not found');
} }
} }
@@ -134,6 +135,45 @@ WantedBy=multi-user.target
* Get status of the systemd service and UPS * Get status of the systemd service and UPS
* @param debugMode Whether to enable debug mode for SNMP * @param debugMode Whether to enable debug mode for SNMP
*/ */
/**
* Display version information and update status
* @private
*/
private async displayVersionInfo(): Promise<void> {
try {
const nupst = this.daemon.getNupstSnmp().getNupst();
const version = nupst.getVersion();
// Check for updates
const updateAvailable = await nupst.checkForUpdates();
// Display version info
if (updateAvailable) {
const updateStatus = nupst.getUpdateStatus();
logger.log('');
logger.log(
`${theme.dim('NUPST')} ${theme.dim('v' + version)} ${symbols.warning} ${theme.statusWarning(`Update available: v${updateStatus.latestVersion}`)}`,
);
logger.log(` ${theme.dim('Run')} ${theme.command('sudo nupst update')} ${theme.dim('to upgrade')}`);
} else {
logger.log('');
logger.log(
`${theme.dim('NUPST')} ${theme.dim('v' + version)} ${symbols.success} ${theme.success('Up to date')}`,
);
}
} catch (error) {
// If version check fails, show at least the current version
try {
const nupst = this.daemon.getNupstSnmp().getNupst();
const version = nupst.getVersion();
logger.log('');
logger.log(`${theme.dim('NUPST')} ${theme.dim('v' + version)}`);
} catch (_innerError) {
// Silently fail if we can't even get the version
}
}
}
public async getStatus(debugMode: boolean = false): Promise<void> { public async getStatus(debugMode: boolean = false): Promise<void> {
try { try {
// Enable debug mode if requested // Enable debug mode if requested
@@ -144,7 +184,10 @@ WantedBy=multi-user.target
this.daemon.getNupstSnmp().enableDebug(); this.daemon.getNupstSnmp().enableDebug();
} }
// Check if config exists first // Display version and update status first
await this.displayVersionInfo();
// Check if config exists
try { try {
await this.checkConfigExists(); await this.checkConfigExists();
} catch (error) { } catch (error) {
@@ -192,11 +235,11 @@ WantedBy=multi-user.target
} }
// Display beautiful status // Display beautiful status
console.log(''); logger.log('');
if (isActive) { if (isActive) {
console.log(`${symbols.running} ${theme.success('Service:')} ${theme.statusActive('active (running)')}`); logger.log(`${symbols.running} ${theme.success('Service:')} ${theme.statusActive('active (running)')}`);
} else { } else {
console.log(`${symbols.stopped} ${theme.dim('Service:')} ${theme.statusInactive('inactive')}`); logger.log(`${symbols.stopped} ${theme.dim('Service:')} ${theme.statusInactive('inactive')}`);
} }
if (pid || memory || cpu) { if (pid || memory || cpu) {
@@ -204,14 +247,14 @@ WantedBy=multi-user.target
if (pid) details.push(`PID: ${theme.dim(pid)}`); if (pid) details.push(`PID: ${theme.dim(pid)}`);
if (memory) details.push(`Memory: ${theme.dim(memory)}`); if (memory) details.push(`Memory: ${theme.dim(memory)}`);
if (cpu) details.push(`CPU: ${theme.dim(cpu)}`); if (cpu) details.push(`CPU: ${theme.dim(cpu)}`);
console.log(` ${details.join(' ')}`); logger.log(` ${details.join(' ')}`);
} }
console.log(''); logger.log('');
} catch (error) { } catch (error) {
console.log(''); logger.log('');
console.log(`${symbols.stopped} ${theme.dim('Service:')} ${theme.statusInactive('not installed')}`); logger.log(`${symbols.stopped} ${theme.dim('Service:')} ${theme.statusInactive('not installed')}`);
console.log(''); logger.log('');
} }
} }
@@ -228,35 +271,44 @@ WantedBy=multi-user.target
// Check if we have the new multi-UPS config format // Check if we have the new multi-UPS config format
if (config.upsDevices && Array.isArray(config.upsDevices) && config.upsDevices.length > 0) { if (config.upsDevices && Array.isArray(config.upsDevices) && config.upsDevices.length > 0) {
console.log(theme.info(`UPS Devices (${config.upsDevices.length}):`)); logger.info(`UPS Devices (${config.upsDevices.length}):`);
// Show status for each UPS // Show status for each UPS
for (const ups of config.upsDevices) { for (const ups of config.upsDevices) {
await this.displaySingleUpsStatus(ups, snmp); await this.displaySingleUpsStatus(ups, snmp);
} }
} else if (config.snmp) { } else if (config.snmp) {
// Legacy single UPS configuration // Legacy single UPS configuration (v1/v2 format)
console.log(theme.info('UPS Devices (1):')); logger.info('UPS Devices (1):');
const legacyUps = { const legacyUps: IUpsConfig = {
id: 'default', id: 'default',
name: 'Default UPS', name: 'Default UPS',
snmp: config.snmp, snmp: config.snmp,
thresholds: config.thresholds,
groups: [], groups: [],
actions: config.thresholds
? [
{
type: 'shutdown',
thresholds: config.thresholds,
triggerMode: 'onlyThresholds',
shutdownDelay: 5,
},
]
: [],
}; };
await this.displaySingleUpsStatus(legacyUps, snmp); await this.displaySingleUpsStatus(legacyUps, snmp);
} else { } else {
console.log(''); logger.log('');
console.log(`${symbols.warning} ${theme.warning('No UPS devices configured')}`); logger.warn('No UPS devices configured');
console.log(` ${theme.dim('Run')} ${theme.command('nupst ups add')} ${theme.dim('to add a device')}`); logger.log(` ${theme.dim('Run')} ${theme.command('nupst ups add')} ${theme.dim('to add a device')}`);
console.log(''); logger.log('');
} }
} catch (error) { } catch (error) {
console.log(''); logger.log('');
console.log(`${symbols.error} ${theme.error('Failed to retrieve UPS status')}`); logger.error('Failed to retrieve UPS status');
console.log(` ${theme.dim(error instanceof Error ? error.message : String(error))}`); logger.log(` ${theme.dim(error instanceof Error ? error.message : String(error))}`);
console.log(''); logger.log('');
} }
} }
@@ -265,7 +317,7 @@ WantedBy=multi-user.target
* @param ups UPS configuration * @param ups UPS configuration
* @param snmp SNMP manager * @param snmp SNMP manager
*/ */
private async displaySingleUpsStatus(ups: any, snmp: any): Promise<void> { private async displaySingleUpsStatus(ups: IUpsConfig, snmp: NupstSnmp): Promise<void> {
try { try {
// Create a test config with a short timeout // Create a test config with a short timeout
const testConfig = { const testConfig = {
@@ -284,15 +336,24 @@ WantedBy=multi-user.target
} }
// Display UPS name and power status // Display UPS name and power status
console.log(` ${statusSymbol} ${theme.highlight(ups.name)} - ${formatPowerStatus(status.powerStatus)}`); logger.log(` ${statusSymbol} ${theme.highlight(ups.name)} - ${formatPowerStatus(status.powerStatus)}`);
// Display battery with color coding // Display battery with color coding
const batteryColor = getBatteryColor(status.batteryCapacity); const batteryColor = getBatteryColor(status.batteryCapacity);
const batterySymbol = status.batteryCapacity >= ups.thresholds.battery ? symbols.success : symbols.warning;
console.log(` Battery: ${batteryColor(status.batteryCapacity + '%')} ${batterySymbol} Runtime: ${getRuntimeColor(status.batteryRuntime)(status.batteryRuntime + ' min')}`); // Get threshold from actions (if any action has thresholds defined)
const actionWithThresholds = ups.actions?.find((action) => action.thresholds);
const batteryThreshold = actionWithThresholds?.thresholds?.battery;
const batterySymbol = batteryThreshold !== undefined && status.batteryCapacity >= batteryThreshold
? symbols.success
: batteryThreshold !== undefined
? symbols.warning
: '';
logger.log(` Battery: ${batteryColor(status.batteryCapacity + '%')} ${batterySymbol} Runtime: ${getRuntimeColor(status.batteryRuntime)(status.batteryRuntime + ' min')}`);
// Display host info // Display host info
console.log(` ${theme.dim(`Host: ${ups.snmp.host}:${ups.snmp.port}`)}`); logger.log(` ${theme.dim(`Host: ${ups.snmp.host}:${ups.snmp.port}`)}`);
// Display groups if any // Display groups if any
if (ups.groups && ups.groups.length > 0) { if (ups.groups && ups.groups.length > 0) {
@@ -301,17 +362,17 @@ WantedBy=multi-user.target
const group = config.groups?.find((g: { id: string }) => g.id === groupId); const group = config.groups?.find((g: { id: string }) => g.id === groupId);
return group ? group.name : groupId; return group ? group.name : groupId;
}); });
console.log(` ${theme.dim(`Groups: ${groupNames.join(', ')}`)}`); logger.log(` ${theme.dim(`Groups: ${groupNames.join(', ')}`)}`);
} }
console.log(''); logger.log('');
} catch (error) { } catch (error) {
// Display error for this UPS // Display error for this UPS
console.log(` ${symbols.error} ${theme.highlight(ups.name)} - ${theme.error('Connection failed')}`); logger.log(` ${symbols.error} ${theme.highlight(ups.name)} - ${theme.error('Connection failed')}`);
console.log(` ${theme.dim(error instanceof Error ? error.message : String(error))}`); logger.log(` ${theme.dim(error instanceof Error ? error.message : String(error))}`);
console.log(` ${theme.dim(`Host: ${ups.snmp.host}:${ups.snmp.port}`)}`); logger.log(` ${theme.dim(`Host: ${ups.snmp.host}:${ups.snmp.port}`)}`);
console.log(''); logger.log('');
} }
} }