Compare commits

...

33 Commits

Author SHA1 Message Date
992a776fd2 chore(release): bump version to 4.3.2
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 42s
CI / Build All Platforms (push) Successful in 47s
2025-10-20 12:42:34 +00:00
3e15a2d52f fix(action): correct message about config reload
The daemon already has automatic config file watching and reloads changes
without requiring a restart. Updated action handler messages to correctly
reflect this behavior.

Changed:
- 'Restart service to apply changes: nupst service restart'
  → 'Changes saved and will be applied automatically'

The config file watcher (daemon.ts:986) uses Deno.watchFs() to monitor
/etc/nupst/config.json and automatically calls reloadConfig() when changes
are detected. No restart needed.
2025-10-20 12:42:31 +00:00
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
bb87316dd3 fix(snmp): correct power status interpretation using OID set mappings
All checks were successful
CI / Type Check & Lint (push) Successful in 7s
CI / Build Test (Current Platform) (push) Successful in 5s
Release / build-and-release (push) Successful in 44s
CI / Build All Platforms (push) Successful in 49s
Move power status value interpretation from hardcoded logic to OID set configuration.
Each UPS model now defines its own value mappings (e.g., CyberPower: 2=online, 3=onBattery).

Fixes incorrect status display where UPS showed "On Battery" when actually online.

Changes:
- Add POWER_STATUS_VALUES to IOidSet interface
- Define value mappings for all UPS models (cyberpower, apc, eaton, tripplite, liebert)
- Refactor determinePowerStatus() to use OID set mappings instead of hardcoded values
- CyberPower now correctly interprets value 2 as online (was incorrectly onBattery)
2025-10-19 23:48:13 +00:00
d6e0a1a274 feat(cli): remove ALL ugly boxes from status output - now fully beautiful
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 44s
CI / Build All Platforms (push) Successful in 49s
Removed the last remaining ugly ASCII boxes:
- Version info box (┌─┐│└┘) that appeared at top
- Async version check box that ended randomly in middle
- Configuration error box

Now status output is 100% clean and beautiful with just colored text:

● Service: active (running)
  PID: 9120  Memory: 45.7M  CPU: 190ms

UPS Devices (2):
  ⚠ Test UPS (SNMP v1) - On Battery
    Battery: 100% ✓  Runtime: 48 min
    Host: 192.168.187.140:161

  ◯ Test UPS (SNMP v3) - Unknown
    Battery: 0% ⚠  Runtime: 0 min
    Host: 192.168.187.140:161

No boxes, just beautiful colored output with symbols!
Bumped to v4.1.0 to mark completion of beautiful CLI feature.
2025-10-19 23:01:25 +00:00
95fa4f8b0b fix(update): normalize version strings for correct comparison
All checks were successful
CI / Type Check & Lint (push) Successful in 5s
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
The version check was comparing "4.0.8" (no prefix) with "v4.0.8"
(with prefix), causing it to always think an update was available.

Now both versions are normalized to have the "v" prefix before
comparison, so "Already up to date!" works correctly.
2025-10-19 22:56:35 +00:00
c2f2f1e2ee feat(update): add version check to skip update when already latest
All checks were successful
CI / Type Check & Lint (push) Successful in 6s
CI / Build Test (Current Platform) (push) Successful in 6s
Release / build-and-release (push) Successful in 48s
CI / Build All Platforms (push) Successful in 53s
Now `nupst update` checks current version against latest release before
downloading anything.

Behavior:
- Fetches latest version from Gitea API
- Compares with current version
- Shows "Already up to date!" if versions match
- Only downloads/installs if newer version available

Example output when up to date:
  Checking for updates...
  Current version: v4.0.8
  Latest version:  v4.0.8

  ✓ Already up to date!
2025-10-19 22:50:03 +00:00
26 changed files with 2324 additions and 638 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "@serve.zone/nupst",
"version": "4.0.7",
"version": "4.3.2",
"exports": "./mod.ts",
"tasks": {
"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 = {
name: denoConfig.name,
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();
});
}
}

309
ts/cli.ts
View File

@@ -1,6 +1,6 @@
import { execSync } from 'node:child_process';
import { Nupst } from './nupst.ts';
import { logger } from './logger.ts';
import { logger, type ITableColumn } from './logger.ts';
import { theme, symbols } from './colors.ts';
/**
@@ -72,6 +72,7 @@ export class NupstCli {
const upsHandler = this.nupst.getUpsHandler();
const groupHandler = this.nupst.getGroupHandler();
const serviceHandler = this.nupst.getServiceHandler();
const actionHandler = this.nupst.getActionHandler();
// Handle service subcommands
if (command === 'service') {
@@ -193,6 +194,38 @@ export class NupstCli {
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
if (command === 'config') {
const subcommand = commandArgs[0] || 'show';
@@ -303,154 +336,162 @@ export class NupstCli {
try {
await this.nupst.getDaemon().loadConfig();
} catch (_error) {
const errorBoxWidth = 45;
logger.logBoxTitle('Configuration Error', errorBoxWidth);
logger.logBoxLine('No configuration found.');
logger.logBoxLine("Please run 'nupst setup' first to create a configuration.");
logger.logBoxEnd();
logger.logBox('Configuration Error', [
'No configuration found.',
"Please run 'nupst ups add' first to create a configuration.",
], 50, 'error');
return;
}
// Get current configuration
const config = this.nupst.getDaemon().getConfig();
const boxWidth = 50;
logger.logBoxTitle('NUPST Configuration', boxWidth);
// Check if multi-UPS config
if (config.upsDevices && Array.isArray(config.upsDevices)) {
// Multi-UPS configuration
logger.logBoxLine(`UPS Devices: ${config.upsDevices.length}`);
logger.logBoxLine(`Groups: ${config.groups ? config.groups.length : 0}`);
logger.logBoxLine(`Check Interval: ${config.checkInterval / 1000} seconds`);
logger.logBoxLine('');
logger.logBoxLine('Configuration File Location:');
logger.logBoxLine(' /etc/nupst/config.json');
logger.logBoxEnd();
// === Multi-UPS Configuration ===
// 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) {
logger.logBoxTitle('UPS Devices', boxWidth);
for (const ups of config.upsDevices) {
logger.logBoxLine(`${ups.name} (${ups.id}):`);
logger.logBoxLine(` Host: ${ups.snmp.host}:${ups.snmp.port}`);
logger.logBoxLine(` Model: ${ups.snmp.upsModel}`);
logger.logBoxLine(
` Thresholds: ${ups.thresholds.battery}% battery, ${ups.thresholds.runtime} min runtime`,
);
logger.logBoxLine(
` Groups: ${ups.groups.length > 0 ? ups.groups.join(', ') : 'None'}`,
);
logger.logBoxLine('');
}
logger.logBoxEnd();
const upsRows = config.upsDevices.map((ups) => ({
name: ups.name,
id: theme.dim(ups.id),
host: `${ups.snmp.host}:${ups.snmp.port}`,
model: ups.snmp.upsModel || 'cyberpower',
actions: `${(ups.actions || []).length} configured`,
groups: ups.groups.length > 0 ? ups.groups.join(', ') : theme.dim('None'),
}));
const upsColumns: ITableColumn[] = [
{ header: 'Name', key: 'name', align: 'left', color: theme.highlight },
{ header: 'ID', key: 'id', align: 'left' },
{ header: 'Host:Port', key: 'host', align: 'left', color: theme.info },
{ 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) {
logger.logBoxTitle('UPS Groups', boxWidth);
for (const group of config.groups) {
logger.logBoxLine(`${group.name} (${group.id}):`);
logger.logBoxLine(` Mode: ${group.mode}`);
if (group.description) {
logger.logBoxLine(` Description: ${group.description}`);
}
// List UPS devices in this group
const groupRows = config.groups.map((group) => {
const upsInGroup = config.upsDevices.filter((ups) =>
ups.groups && ups.groups.includes(group.id)
);
logger.logBoxLine(
` UPS Devices: ${
upsInGroup.length > 0 ? upsInGroup.map((ups) => ups.name).join(', ') : 'None'
}`,
);
logger.logBoxLine('');
}
logger.logBoxEnd();
return {
name: group.name,
id: theme.dim(group.id),
mode: group.mode,
upsCount: String(upsInGroup.length),
ups: upsInGroup.length > 0
? upsInGroup.map((ups) => ups.name).join(', ')
: 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 {
// Legacy single UPS configuration
// === Legacy Single UPS Configuration ===
if (!config.snmp) {
logger.logBoxLine('Error: Legacy configuration missing SNMP settings');
} else {
// SNMP Settings
logger.logBoxLine('SNMP Settings:');
logger.logBoxLine(` Host: ${config.snmp.host}`);
logger.logBoxLine(` Port: ${config.snmp.port}`);
logger.logBoxLine(` Version: ${config.snmp.version}`);
logger.logBoxLine(` UPS Model: ${config.snmp.upsModel || 'cyberpower'}`);
if (config.snmp.version === 1 || config.snmp.version === 2) {
logger.logBoxLine(` Community: ${config.snmp.community}`);
} else if (config.snmp.version === 3) {
logger.logBoxLine(` Security Level: ${config.snmp.securityLevel}`);
logger.logBoxLine(` Username: ${config.snmp.username}`);
// Show auth and privacy details based on security level
if (
config.snmp.securityLevel === 'authNoPriv' ||
config.snmp.securityLevel === 'authPriv'
) {
logger.logBoxLine(` Auth Protocol: ${config.snmp.authProtocol || 'None'}`);
logger.logBox('Configuration Error', [
'Error: Legacy configuration missing SNMP settings',
], 60, 'error');
return;
}
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(
logger.log('');
logger.logBox('NUPST Configuration (Legacy)', [
theme.warning('Legacy single-UPS configuration format'),
'',
theme.dim('SNMP Settings:'),
` Host: ${theme.info(config.snmp.host)}`,
` Port: ${theme.info(String(config.snmp.port))}`,
` Version: ${config.snmp.version}`,
` 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'}`,
);
logger.logBoxLine(
` Battery Capacity: ${config.snmp.customOIDs.BATTERY_CAPACITY || 'Not set'}`,
);
logger.logBoxLine(
` Battery Runtime: ${config.snmp.customOIDs.BATTERY_RUNTIME || 'Not set'}`,
);
}
]
: []
),
'',
` Check Interval: ${config.checkInterval / 1000} seconds`,
'',
theme.dim('Configuration File:'),
` ${theme.path('/etc/nupst/config.json')}`,
'',
theme.warning('Note: Using legacy single-UPS configuration format.'),
`Consider using ${theme.command('nupst ups add')} to migrate to multi-UPS format.`,
], 70, 'warning');
}
// Thresholds
if (!config.thresholds) {
logger.logBoxLine('Error: Legacy configuration missing threshold settings');
} else {
logger.logBoxLine('Thresholds:');
logger.logBoxLine(` Battery: ${config.thresholds.battery}%`);
logger.logBoxLine(` Runtime: ${config.thresholds.runtime} minutes`);
}
logger.logBoxLine(`Check Interval: ${config.checkInterval / 1000} seconds`);
// Configuration file location
logger.logBoxLine('');
logger.logBoxLine('Configuration File Location:');
logger.logBoxLine(' /etc/nupst/config.json');
logger.logBoxLine('');
logger.logBoxLine('Note: Using legacy single-UPS configuration format.');
logger.logBoxLine('Consider using "nupst add" to migrate to multi-UPS format.');
logger.logBoxEnd();
}
// Show service status
// Service Status
try {
const isActive =
execSync('systemctl is-active nupst.service || true').toString().trim() === 'active';
const isEnabled =
execSync('systemctl is-enabled nupst.service || true').toString().trim() === 'enabled';
const statusBoxWidth = 45;
logger.logBoxTitle('Service Status', statusBoxWidth);
logger.logBoxLine(`Service Active: ${isActive ? 'Yes' : 'No'}`);
logger.logBoxLine(`Service Enabled: ${isEnabled ? 'Yes' : 'No'}`);
logger.logBoxEnd();
logger.log('');
logger.logBox('Service Status', [
`Active: ${isActive ? theme.success('Yes') : theme.dim('No')}`,
`Enabled: ${isEnabled ? theme.success('Yes') : theme.dim('No')}`,
], 50, isActive ? 'success' : 'default');
logger.log('');
} catch (_error) {
// Ignore errors checking service status
}
@@ -469,7 +510,7 @@ export class NupstCli {
private showVersion(): void {
const version = this.nupst.getVersion();
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('ups <subcommand>', 'Manage UPS devices');
this.printCommand('group <subcommand>', 'Manage UPS groups');
this.printCommand('action <subcommand>', 'Manage UPS actions');
this.printCommand('config [show]', 'Display current configuration');
this.printCommand('update', 'Update NUPST from repository', 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');
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
logger.log(theme.info('Options:'));
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 edit dc-1 - Edit 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'
`);
}
}

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

@@ -0,0 +1,357 @@
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(` ${theme.dim('Changes saved and will be applied automatically')}`);
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(` ${theme.dim('Changes saved and will be applied automatically')}`);
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 { 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 { type IGroupConfig } from '../daemon.ts';
@@ -28,11 +29,10 @@ export class GroupHandler {
try {
await this.nupst.getDaemon().loadConfig();
} catch (error) {
const errorBoxWidth = 45;
logger.logBoxTitle('Configuration Error', errorBoxWidth);
logger.logBoxLine('No configuration found.');
logger.logBoxLine("Please run 'nupst setup' first to create a configuration.");
logger.logBoxEnd();
logger.logBox('Configuration Error', [
'No configuration found.',
"Please run 'nupst ups add' first to create a configuration.",
], 50, 'error');
return;
}
@@ -41,43 +41,53 @@ export class GroupHandler {
// Check if multi-UPS config
if (!config.groups || !Array.isArray(config.groups)) {
// Legacy or missing groups configuration
const boxWidth = 45;
logger.logBoxTitle('UPS Groups', boxWidth);
logger.logBoxLine('No groups configured.');
logger.logBoxLine('Use "nupst group add" to add a UPS group.');
logger.logBoxEnd();
logger.logBox('UPS Groups', [
'No groups configured.',
'',
`${theme.dim('Run')} ${theme.command('nupst group add')} ${theme.dim('to add a group')}`,
], 50, 'info');
return;
}
// Display group list
const boxWidth = 60;
logger.logBoxTitle('UPS Groups', boxWidth);
// Display group list with modern table
if (config.groups.length === 0) {
logger.logBoxLine('No UPS groups configured.');
logger.logBoxLine('Use "nupst group add" to add a UPS group.');
} else {
logger.logBoxLine(`Found ${config.groups.length} group(s)`);
logger.logBoxLine('');
logger.logBoxLine('ID | Name | Mode | UPS Devices');
logger.logBoxLine('-----------+----------------------+--------------+----------------');
for (const group of config.groups) {
const id = group.id.padEnd(10, ' ').substring(0, 10);
const name = (group.name || '').padEnd(20, ' ').substring(0, 20);
const mode = (group.mode || 'unknown').padEnd(12, ' ').substring(0, 12);
logger.logBox('UPS Groups', [
'No UPS groups configured.',
'',
`${theme.dim('Run')} ${theme.command('nupst group add')} ${theme.dim('to add a group')}`,
], 60, 'info');
return;
}
// 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(', ');
logger.logBoxLine(`${id} | ${name} | ${mode} | ${upsCount > 0 ? upsNames : 'None'}`);
}
}
return {
id: group.id,
name: group.name || '',
mode: group.mode || 'unknown',
count: String(upsCount),
devices: upsCount > 0 ? upsNames : theme.dim('None'),
};
});
logger.logBoxEnd();
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) {
logger.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!');
} finally {
rl.close();
process.stdin.destroy();
}
} catch (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!');
} finally {
rl.close();
process.stdin.destroy();
}
} catch (error) {
logger.error(`Edit group error: ${error instanceof Error ? error.message : String(error)}`);
@@ -366,6 +378,7 @@ export class GroupHandler {
});
rl.close();
process.stdin.destroy();
if (confirm !== 'y' && confirm !== 'yes') {
logger.log('Deletion cancelled.');

View File

@@ -133,22 +133,47 @@ export class ServiceHandler {
);
console.log('');
logger.info('Updating NUPST to latest version...');
console.log('');
logger.info('Checking for updates...');
try {
// Get current version
const currentVersion = this.nupst.getVersion();
// Fetch latest version from Gitea API
const apiUrl = 'https://code.foss.global/api/v1/repos/serve.zone/nupst/releases/latest';
const response = execSync(`curl -sSL ${apiUrl}`).toString();
const release = JSON.parse(response);
const latestVersion = release.tag_name; // e.g., "v4.0.7"
// Normalize versions for comparison (ensure both have "v" prefix)
const normalizedCurrent = currentVersion.startsWith('v') ? currentVersion : `v${currentVersion}`;
const normalizedLatest = latestVersion.startsWith('v') ? latestVersion : `v${latestVersion}`;
logger.dim(`Current version: ${normalizedCurrent}`);
logger.dim(`Latest version: ${normalizedLatest}`);
console.log('');
// Compare normalized versions
if (normalizedCurrent === normalizedLatest) {
logger.success('Already up to date!');
console.log('');
return;
}
logger.info(`New version available: ${latestVersion}`);
logger.dim('Downloading and installing...');
console.log('');
// Download and run the install script
// This handles everything: download binary, stop service, replace, restart
const installUrl = 'https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh';
logger.dim('Downloading install script...');
execSync(`curl -sSL ${installUrl} | bash`, {
stdio: 'inherit', // Show install script output to user
});
console.log('');
logger.success('Update completed successfully!');
logger.dim('Run "nupst service status" to verify the update.');
logger.success(`Updated to ${latestVersion}`);
console.log('');
} catch (error) {
console.log('');
@@ -188,9 +213,11 @@ export class ServiceHandler {
});
};
console.log('\nNUPST Uninstaller');
console.log('===============');
console.log('This will completely remove NUPST from your system.\n');
logger.log('');
logger.highlight('NUPST Uninstaller');
logger.dim('===============');
logger.log('This will completely remove NUPST from your system.');
logger.log('');
// Ask about removing configuration
const removeConfig = await prompt(
@@ -226,17 +253,20 @@ export class ServiceHandler {
}
if (!uninstallScriptPath) {
console.error('Could not locate uninstall.sh script. Aborting uninstall.');
logger.error('Could not locate uninstall.sh script. Aborting uninstall.');
rl.close();
process.stdin.destroy();
process.exit(1);
}
}
// Close readline before executing script
rl.close();
process.stdin.destroy();
// 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
const env = {
@@ -252,7 +282,7 @@ export class ServiceHandler {
stdio: 'inherit', // Show output in the terminal
});
} 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);
}
}

View File

@@ -1,7 +1,8 @@
import process from 'node:process';
import { execSync } from 'node:child_process';
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 type { TUpsModel } from '../snmp/types.ts';
import type { INupstConfig } from '../daemon.ts';
@@ -47,6 +48,7 @@ export class UpsHandler {
await this.runAddProcess(prompt);
} finally {
rl.close();
process.stdin.destroy();
}
} catch (error) {
logger.error(`Add UPS error: ${error instanceof Error ? error.message : String(error)}`);
@@ -77,8 +79,8 @@ export class UpsHandler {
id: 'default',
name: 'Default UPS',
snmp: config.snmp,
thresholds: config.thresholds,
groups: [],
actions: [],
}],
groups: [],
};
@@ -115,14 +117,12 @@ export class UpsHandler {
runtime: 20,
},
groups: [],
actions: [],
};
// Gather SNMP settings
await this.gatherSnmpSettings(newUps.snmp, prompt);
// Gather threshold settings
await this.gatherThresholdSettings(newUps.thresholds, prompt);
// Gather UPS model settings
await this.gatherUpsModelSettings(newUps.snmp, prompt);
@@ -134,6 +134,9 @@ export class UpsHandler {
await groupHandler.assignUpsToGroups(newUps, config.groups, prompt);
}
// Gather action settings
await this.gatherActionSettings(newUps.actions, prompt);
// Add the new UPS to the config
config.upsDevices.push(newUps);
@@ -178,6 +181,7 @@ export class UpsHandler {
await this.runEditProcess(upsId, prompt);
} finally {
rl.close();
process.stdin.destroy();
}
} catch (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
if (!config.upsDevices) {
// Initialize with the current config as the first UPS
if (!config.snmp || !config.thresholds) {
logger.error('Legacy configuration is missing required SNMP or threshold settings');
if (!config.snmp) {
logger.error('Legacy configuration is missing required SNMP settings');
return;
}
config.upsDevices = [{
id: 'default',
name: 'Default UPS',
snmp: config.snmp,
thresholds: config.thresholds,
groups: [],
actions: [],
}];
config.groups = [];
logger.log('Converting existing configuration to multi-UPS format.');
@@ -262,9 +266,6 @@ export class UpsHandler {
// Edit SNMP settings
await this.gatherSnmpSettings(upsToEdit.snmp, prompt);
// Edit threshold settings
await this.gatherThresholdSettings(upsToEdit.thresholds, prompt);
// Edit UPS model settings
await this.gatherUpsModelSettings(upsToEdit.snmp, prompt);
@@ -276,6 +277,14 @@ export class UpsHandler {
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
await this.nupst.getDaemon().saveConfig(config);
@@ -344,6 +353,7 @@ export class UpsHandler {
});
rl.close();
process.stdin.destroy();
if (confirm !== 'y' && confirm !== 'yes') {
logger.log('Deletion cancelled.');
@@ -376,11 +386,10 @@ export class UpsHandler {
try {
await this.nupst.getDaemon().loadConfig();
} catch (error) {
const errorBoxWidth = 45;
logger.logBoxTitle('Configuration Error', errorBoxWidth);
logger.logBoxLine('No configuration found.');
logger.logBoxLine("Please run 'nupst setup' first to create a configuration.");
logger.logBoxEnd();
logger.logBox('Configuration Error', [
'No configuration found.',
"Please run 'nupst ups add' first to create a configuration.",
], 50, 'error');
return;
}
@@ -390,58 +399,56 @@ export class UpsHandler {
// Check if multi-UPS config
if (!config.upsDevices || !Array.isArray(config.upsDevices)) {
// Legacy single UPS configuration
const boxWidth = 45;
logger.logBoxTitle('UPS Devices', boxWidth);
logger.logBoxLine('Legacy single-UPS configuration detected.');
if (!config.snmp || !config.thresholds) {
logger.logBoxLine('');
logger.logBoxLine('Error: Configuration missing SNMP or threshold settings');
logger.logBoxEnd();
return;
}
logger.logBoxLine('');
logger.logBoxLine('Default UPS:');
logger.logBoxLine(` Host: ${config.snmp.host}:${config.snmp.port}`);
logger.logBoxLine(` Model: ${config.snmp.upsModel || 'cyberpower'}`);
logger.logBoxLine(
` Thresholds: ${config.thresholds.battery}% battery, ${config.thresholds.runtime} min runtime`,
);
logger.logBoxLine('');
logger.logBoxLine('Use "nupst add" to add more UPS devices and migrate');
logger.logBoxLine('to the multi-UPS configuration format.');
logger.logBoxEnd();
logger.logBox('UPS Devices', [
'Legacy single-UPS configuration detected.',
'',
...(!config.snmp
? ['Error: Configuration missing SNMP settings']
: [
'Default UPS:',
` Host: ${config.snmp.host}:${config.snmp.port}`,
` Model: ${config.snmp.upsModel || 'cyberpower'}`,
'',
'Use "nupst ups add" to add more UPS devices and migrate',
'to the multi-UPS configuration format.',
]
),
], 60, 'warning');
return;
}
// Display UPS list
const boxWidth = 60;
logger.logBoxTitle('UPS Devices', boxWidth);
// Display UPS list with modern table
if (config.upsDevices.length === 0) {
logger.logBoxLine('No UPS devices configured.');
logger.logBoxLine('Use "nupst add" to add a UPS device.');
} else {
logger.logBoxLine(`Found ${config.upsDevices.length} UPS device(s)`);
logger.logBoxLine('');
logger.logBoxLine(
'ID | Name | Host | Mode | Groups',
);
logger.logBoxLine(
'-----------+----------------------+-----------------+--------------+----------------',
);
for (const ups of config.upsDevices) {
const id = ups.id.padEnd(10, ' ').substring(0, 10);
const name = (ups.name || '').padEnd(20, ' ').substring(0, 20);
const host = `${ups.snmp.host}:${ups.snmp.port}`.padEnd(15, ' ').substring(0, 15);
const model = (ups.snmp.upsModel || 'cyberpower').padEnd(12, ' ').substring(0, 12);
const groups = ups.groups.length > 0 ? ups.groups.join(', ') : 'None';
logger.logBoxLine(`${id} | ${name} | ${host} | ${model} | ${groups}`);
}
logger.logBox('UPS Devices', [
'No UPS devices configured.',
'',
`${theme.dim('Run')} ${theme.command('nupst ups add')} ${theme.dim('to add a device')}`,
], 60, 'info');
return;
}
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) {
logger.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 {
// 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 thresholds = isUpsConfig ? config.thresholds : config.thresholds || {};
const checkInterval = config.checkInterval || 30000;
// 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('Thresholds:');
logger.logBoxLine(` Battery: ${thresholds.battery}%`);
logger.logBoxLine(` Runtime: ${thresholds.runtime} minutes`);
// Show group assignments if this is a UPS config
if (config.groups && Array.isArray(config.groups)) {
logger.logBoxLine(
@@ -577,7 +579,6 @@ export class UpsHandler {
try {
// Create a test config with a short timeout
const snmpConfig = config.snmp ? config.snmp : config.snmp;
const thresholds = config.thresholds ? config.thresholds : config.thresholds;
const testConfig = {
...snmpConfig,
@@ -594,10 +595,7 @@ export class UpsHandler {
logger.logBoxLine(` Runtime Remaining: ${status.batteryRuntime} minutes`);
logger.logBoxEnd();
// Check status against thresholds if on battery
if (status.powerStatus === 'onBattery') {
this.analyzeThresholds(status, thresholds);
}
} catch (error) {
const errorBoxWidth = 45;
logger.logBoxTitle(`Connection Failed: ${upsName}`, errorBoxWidth);
@@ -667,10 +665,11 @@ export class UpsHandler {
// SNMP Version
const defaultVersion = snmpConfig.version || 1;
console.log('\nSNMP Version:');
console.log(' 1) SNMPv1');
console.log(' 2) SNMPv2c');
console.log(' 3) SNMPv3 (with security features)');
logger.log('');
logger.info('SNMP Version:');
logger.dim(' 1) SNMPv1');
logger.dim(' 2) SNMPv2c');
logger.dim(' 3) SNMPv3 (with security features)');
const versionInput = await prompt(`Select SNMP version [${defaultVersion}]: `);
const version = parseInt(versionInput, 10);
snmpConfig.version = versionInput.trim() && (version === 1 || version === 2 || version === 3)
@@ -697,13 +696,15 @@ export class UpsHandler {
snmpConfig: any,
prompt: (question: string) => Promise<string>,
): Promise<void> {
console.log('\nSNMPv3 Security Settings:');
logger.log('');
logger.info('SNMPv3 Security Settings:');
// Security Level
console.log('\nSecurity Level:');
console.log(' 1) noAuthNoPriv (No Authentication, No Privacy)');
console.log(' 2) authNoPriv (Authentication, No Privacy)');
console.log(' 3) authPriv (Authentication and Privacy)');
logger.log('');
logger.info('Security Level:');
logger.dim(' 1) noAuthNoPriv (No Authentication, No Privacy)');
logger.dim(' 2) authNoPriv (Authentication, No Privacy)');
logger.dim(' 3) authPriv (Authentication and Privacy)');
const defaultSecLevel = snmpConfig.securityLevel
? snmpConfig.securityLevel === 'noAuthNoPriv'
? 1
@@ -752,8 +753,9 @@ export class UpsHandler {
// Allow customizing the timeout value
const defaultTimeout = snmpConfig.timeout / 1000; // Convert from ms to seconds for display
console.log(
'\nSNMPv3 operations with authentication and privacy may require longer timeouts.',
logger.log('');
logger.info(
'SNMPv3 operations with authentication and privacy may require longer timeouts.',
);
const timeoutInput = await prompt(`SNMP Timeout in seconds [${defaultTimeout}]: `);
const timeout = parseInt(timeoutInput, 10);
@@ -773,9 +775,10 @@ export class UpsHandler {
prompt: (question: string) => Promise<string>,
): Promise<void> {
// Authentication protocol
console.log('\nAuthentication Protocol:');
console.log(' 1) MD5');
console.log(' 2) SHA');
logger.log('');
logger.info('Authentication Protocol:');
logger.dim(' 1) MD5');
logger.dim(' 2) SHA');
const defaultAuthProtocol = snmpConfig.authProtocol === 'SHA' ? 2 : 1;
const authProtocolInput = await prompt(
`Select Authentication Protocol [${defaultAuthProtocol}]: `,
@@ -799,9 +802,10 @@ export class UpsHandler {
prompt: (question: string) => Promise<string>,
): Promise<void> {
// Privacy protocol
console.log('\nPrivacy Protocol:');
console.log(' 1) DES');
console.log(' 2) AES');
logger.log('');
logger.info('Privacy Protocol:');
logger.dim(' 1) DES');
logger.dim(' 2) AES');
const defaultPrivProtocol = snmpConfig.privProtocol === 'AES' ? 2 : 1;
const privProtocolInput = await prompt(`Select Privacy Protocol [${defaultPrivProtocol}]: `);
const privProtocol = parseInt(privProtocolInput, 10) || defaultPrivProtocol;
@@ -813,38 +817,6 @@ export class UpsHandler {
snmpConfig.privKey = privKey.trim() || defaultPrivKey;
}
/**
* Gather threshold settings
* @param thresholds Thresholds configuration object to update
* @param prompt Function to prompt for user input
*/
private async gatherThresholdSettings(
thresholds: any,
prompt: (question: string) => Promise<string>,
): Promise<void> {
console.log('\nShutdown Thresholds:');
// Battery threshold
const defaultBatteryThreshold = thresholds.battery || 60;
const batteryThresholdInput = await prompt(
`Battery percentage threshold [${defaultBatteryThreshold}%]: `,
);
const batteryThreshold = parseInt(batteryThresholdInput, 10);
thresholds.battery = batteryThresholdInput.trim() && !isNaN(batteryThreshold)
? batteryThreshold
: defaultBatteryThreshold;
// Runtime threshold
const defaultRuntimeThreshold = thresholds.runtime || 20;
const runtimeThresholdInput = await prompt(
`Runtime minutes threshold [${defaultRuntimeThreshold} minutes]: `,
);
const runtimeThreshold = parseInt(runtimeThresholdInput, 10);
thresholds.runtime = runtimeThresholdInput.trim() && !isNaN(runtimeThreshold)
? runtimeThreshold
: defaultRuntimeThreshold;
}
/**
* Gather UPS model settings
* @param snmpConfig SNMP configuration object to update
@@ -854,13 +826,14 @@ export class UpsHandler {
snmpConfig: any,
prompt: (question: string) => Promise<string>,
): Promise<void> {
console.log('\nUPS Model Selection:');
console.log(' 1) CyberPower');
console.log(' 2) APC');
console.log(' 3) Eaton');
console.log(' 4) TrippLite');
console.log(' 5) Liebert/Vertiv');
console.log(' 6) Custom (Advanced)');
logger.log('');
logger.info('UPS Model Selection:');
logger.dim(' 1) CyberPower');
logger.dim(' 2) APC');
logger.dim(' 3) Eaton');
logger.dim(' 4) TrippLite');
logger.dim(' 5) Liebert/Vertiv');
logger.dim(' 6) Custom (Advanced)');
const defaultModelValue = snmpConfig.upsModel === 'cyberpower'
? 1
@@ -891,8 +864,9 @@ export class UpsHandler {
snmpConfig.upsModel = 'liebert';
} else if (modelValue === 6) {
snmpConfig.upsModel = 'custom';
console.log('\nEnter custom OIDs for your UPS:');
console.log('(Leave blank to use standard RFC 1628 OIDs as fallback)');
logger.log('');
logger.info('Enter custom OIDs for your UPS:');
logger.dim('(Leave blank to use standard RFC 1628 OIDs as fallback)');
// Custom OIDs
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
* @param ups UPS configuration
@@ -920,9 +1039,7 @@ export class UpsHandler {
logger.logBoxLine(`SNMP Host: ${ups.snmp.host}:${ups.snmp.port}`);
logger.logBoxLine(`SNMP Version: ${ups.snmp.version}`);
logger.logBoxLine(`UPS Model: ${ups.snmp.upsModel}`);
logger.logBoxLine(
`Thresholds: ${ups.thresholds.battery}% battery, ${ups.thresholds.runtime} min runtime`,
);
if (ups.groups && ups.groups.length > 0) {
logger.logBoxLine(`Groups: ${ups.groups.join(', ')}`);
} else {

View File

@@ -4,9 +4,12 @@ import * as path from 'node:path';
import { exec, execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { NupstSnmp } from './snmp/manager.ts';
import type { ISnmpConfig } from './snmp/types.ts';
import { logger } from './logger.ts';
import type { ISnmpConfig, IUpsStatus as ISnmpUpsStatus } from './snmp/types.ts';
import { logger, type ITableColumn } from './logger.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 execFileAsync = promisify(execFile);
@@ -21,15 +24,10 @@ export interface IUpsConfig {
name: string;
/** SNMP configuration settings */
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 */
groups: string[];
/** Actions to trigger on power status changes and threshold violations */
actions?: IActionConfig[];
}
/**
@@ -44,6 +42,8 @@ export interface IGroupConfig {
mode: 'redundant' | 'nonRedundant';
/** Optional description */
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
*/
interface IUpsStatus {
export interface IUpsStatus {
id: string;
name: string;
powerStatus: 'online' | 'onBattery' | 'unknown';
@@ -96,7 +96,7 @@ export class NupstDaemon {
/** Default configuration */
private readonly DEFAULT_CONFIG: INupstConfig = {
version: '4.0',
version: '4.2',
upsDevices: [
{
id: 'default',
@@ -117,16 +117,23 @@ export class NupstDaemon {
// UPS model for OID selection
upsModel: 'cyberpower',
},
groups: [],
actions: [
{
type: 'shutdown',
triggerMode: 'onlyThresholds',
thresholds: {
battery: 60, // Shutdown when battery below 60%
runtime: 20, // Shutdown when runtime below 20 minutes
},
groups: [],
shutdownDelay: 5,
},
],
},
],
groups: [],
checkInterval: 30000, // Check every 30 seconds
};
}
private config: INupstConfig;
private snmp: NupstSnmp;
@@ -164,11 +171,13 @@ export class NupstDaemon {
const { config: migratedConfig, migrated } = await migrationRunner.run(parsedConfig);
// 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) {
this.config = migratedConfig;
this.config = validConfig;
await this.saveConfig(this.config);
} else {
this.config = migratedConfig;
this.config = validConfig;
}
return this.config;
@@ -198,7 +207,7 @@ export class NupstDaemon {
// Ensure version is always set and remove legacy fields before saving
const configToSave: INupstConfig = {
version: '4.0',
version: '4.1',
upsDevices: config.upsDevices,
groups: config.groups,
checkInterval: config.checkInterval,
@@ -207,11 +216,9 @@ export class NupstDaemon {
fs.writeFileSync(this.CONFIG_PATH, JSON.stringify(configToSave, null, 2));
this.config = configToSave;
console.log('┌─ Configuration Saved ─────────────────────┐');
console.log(`│ Location: ${this.CONFIG_PATH}`);
console.log('└──────────────────────────────────────────┘');
logger.logBox('Configuration Saved', [`Location: ${this.CONFIG_PATH}`], 45, 'success');
} 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
*/
private logConfigError(message: string): void {
console.error('┌─ Configuration Error ─────────────────────┐');
console.error(`${message}`);
console.error("│ Please run 'nupst setup' first to create a configuration.");
console.error('└───────────────────────────────────────────┘');
logger.logBox('Configuration Error', [message, "Please run 'nupst setup' first to create a configuration."], 45, 'error');
}
/**
@@ -315,29 +319,57 @@ export class NupstDaemon {
* Log the loaded configuration settings
*/
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.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;
}
// Check if shutdown is required based on group configurations
await this.evaluateGroupShutdownConditions();
// Wait before next check
await this.sleep(this.config.checkInterval);
} catch (error) {
@@ -433,11 +462,42 @@ export class NupstDaemon {
// Check if power status changed
if (currentStatus && currentStatus.powerStatus !== status.powerStatus) {
logger.logBoxTitle(`Power Status Change: ${ups.name}`, 50);
logger.logBoxLine(`Status changed: ${currentStatus.powerStatus}${status.powerStatus}`);
logger.log('');
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.log('');
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
@@ -457,171 +517,100 @@ export class NupstDaemon {
*/
private logAllUpsStatus(): void {
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('');
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()) {
logger.logBoxLine(`UPS: ${status.name} (${id})`);
logger.logBoxLine(` Power Status: ${status.powerStatus}`);
logger.logBoxLine(
` Battery: ${status.batteryCapacity}% | Runtime: ${status.batteryRuntime} min`,
);
logger.logBoxLine('');
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.logBoxEnd();
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> {
if (!this.config.groups || this.config.groups.length === 0) {
// No groups defined, check individual UPS conditions
for (const [id, status] of this.upsStatus.entries()) {
if (status.powerStatus === 'onBattery') {
// Find the UPS config
const ups = this.config.upsDevices.find((u) => u.id === id);
if (ups) {
await this.evaluateUpsShutdownCondition(ups, status);
}
}
}
return;
}
// 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[],
private async triggerUpsActions(
ups: IUpsConfig,
status: IUpsStatus,
previousStatus: IUpsStatus | undefined,
triggerReason: 'powerStatusChange' | 'thresholdViolation',
): Promise<void> {
// Count UPS devices on battery and in critical condition
let upsOnBattery = 0;
let upsInCriticalCondition = 0;
for (const ups of upsDevices) {
const status = this.upsStatus.get(ups.id);
if (!status) continue;
if (status.powerStatus === 'onBattery') {
upsOnBattery++;
// Check if this UPS is in critical condition
if (
status.batteryCapacity < ups.thresholds.battery ||
status.batteryRuntime < ups.thresholds.runtime
) {
upsInCriticalCondition++;
}
}
}
// All UPS devices must be online for a redundant group to be considered healthy
const allUpsCount = upsDevices.length;
// If all UPS are on battery and in critical condition, shutdown
if (upsOnBattery === allUpsCount && upsInCriticalCondition === allUpsCount) {
logger.logBoxTitle(`Group Shutdown Required: ${group.name}`, 50);
logger.logBoxLine(`Mode: Redundant`);
logger.logBoxLine(`All ${allUpsCount} UPS devices in critical condition`);
logger.logBoxEnd();
await this.initiateShutdown(
`All UPS devices in redundant group "${group.name}" in critical condition`,
);
}
}
/**
* Evaluate a non-redundant group for shutdown conditions
* In non-redundant mode, we shut down if ANY UPS device is in critical condition
*/
private async evaluateNonRedundantGroup(
group: IGroupConfig,
upsDevices: IUpsConfig[],
): Promise<void> {
for (const ups of upsDevices) {
const status = this.upsStatus.get(ups.id);
if (!status) continue;
if (status.powerStatus === 'onBattery') {
// Check if this UPS is in critical condition
if (
status.batteryCapacity < ups.thresholds.battery ||
status.batteryRuntime < ups.thresholds.runtime
) {
logger.logBoxTitle(`Group Shutdown Required: ${group.name}`, 50);
logger.logBoxLine(`Mode: Non-Redundant`);
logger.logBoxLine(`UPS ${ups.name} in critical condition`);
logger.logBoxLine(
`Battery: ${status.batteryCapacity}% (threshold: ${ups.thresholds.battery}%)`,
);
logger.logBoxLine(
`Runtime: ${status.batteryRuntime} min (threshold: ${ups.thresholds.runtime} min)`,
);
logger.logBoxEnd();
await this.initiateShutdown(
`UPS "${ups.name}" in non-redundant group "${group.name}" in critical condition`,
);
return; // Exit after initiating shutdown
}
}
}
}
/**
* Evaluate an individual UPS for shutdown conditions
*/
private async evaluateUpsShutdownCondition(ups: IUpsConfig, status: IUpsStatus): Promise<void> {
// Only evaluate UPS devices not in any group
if (ups.groups && ups.groups.length > 0) {
return;
}
// Check threshold conditions
if (
status.batteryCapacity < ups.thresholds.battery ||
status.batteryRuntime < ups.thresholds.runtime
) {
logger.logBoxTitle(`UPS Shutdown Required: ${ups.name}`, 50);
logger.logBoxLine(
`Battery: ${status.batteryCapacity}% (threshold: ${ups.thresholds.battery}%)`,
);
logger.logBoxLine(
`Runtime: ${status.batteryRuntime} min (threshold: ${ups.thresholds.runtime} min)`,
);
logger.logBoxEnd();
const actions = ups.actions || [];
// 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`);
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 startTime = Date.now();
logger.log(
`Emergency shutdown threshold: ${EMERGENCY_RUNTIME_THRESHOLD} minutes remaining battery runtime`,
);
logger.log('');
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
while (Date.now() - startTime < MAX_MONITORING_TIME) {
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
for (const ups of this.config.upsDevices) {
try {
const status = await this.snmp.getUpsStatus(ups.snmp);
logger.log(
`UPS ${ups.name}: Battery ${status.batteryCapacity}%, Runtime: ${status.batteryRuntime} minutes`,
);
const batteryColor = getBatteryColor(status.batteryCapacity);
const runtimeColor = getRuntimeColor(status.batteryRuntime);
// If any UPS battery runtime gets critically low, force immediate shutdown
if (status.batteryRuntime < EMERGENCY_RUNTIME_THRESHOLD) {
logger.logBoxTitle('EMERGENCY SHUTDOWN', 50);
logger.logBoxLine(
`UPS ${ups.name} runtime critically low: ${status.batteryRuntime} minutes`,
);
logger.logBoxLine('Forcing immediate shutdown!');
logger.logBoxEnd();
const isCritical = status.batteryRuntime < EMERGENCY_RUNTIME_THRESHOLD;
// Force immediate shutdown
await this.forceImmediateShutdown();
return;
rows.push({
name: ups.name,
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) {
rows.push({
name: ups.name,
battery: theme.error('N/A'),
runtime: theme.error('N/A'),
status: theme.error('ERROR'),
});
logger.error(
`Error checking UPS ${ups.name} during shutdown: ${
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
await this.sleep(CHECK_INTERVAL);
} 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
* multiple versions (e.g., v1 → v4 runs migrations 2, 3, and 4).
*/
export abstract class BaseMigration {
/**
* Migration order number
* - Order 2: v1 → v2
* - Order 3: v2 → v3
* - Order 4: v3 → v4
* etc.
/**
* 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).
*/
abstract readonly order: number;
export abstract class BaseMigration {
/**
* Source version this migration upgrades from
* e.g., "1.x", "3.x"
@@ -23,25 +21,25 @@ export abstract class BaseMigration {
/**
* Target version this migration upgrades to
* e.g., "2.0", "4.0"
* e.g., "2.0", "4.0", "4.1"
*/
abstract readonly toVersion: string;
/**
* 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
*/
abstract shouldRun(config: any): Promise<boolean>;
abstract shouldRun(config: Record<string, unknown>): Promise<boolean>;
/**
* 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
*/
abstract migrate(config: any): Promise<any>;
abstract migrate(config: Record<string, unknown>): Promise<Record<string, unknown>>;
/**
* Get human-readable name for this migration
@@ -51,4 +49,19 @@ export abstract class BaseMigration {
getName(): string {
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 { MigrationV1ToV2 } from './migration-v1-to-v2.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 { MigrationV1ToV2 } from './migration-v1-to-v2.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';
/**
@@ -17,11 +18,12 @@ export class MigrationRunner {
this.migrations = [
new MigrationV1ToV2(),
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
this.migrations.sort((a, b) => a.order - b.order);
// Sort by version order to ensure they run in sequence
this.migrations.sort((a, b) => a.getVersionOrder() - b.getVersionOrder());
}
/**
@@ -30,16 +32,20 @@ export class MigrationRunner {
* @param config - Raw configuration object to migrate
* @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 anyMigrationsRan = false;
logger.dim('Checking for required config migrations...');
for (const migration of this.migrations) {
const shouldRun = await migration.shouldRun(currentConfig);
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()}...`);
currentConfig = await migration.migrate(currentConfig);
anyMigrationsRan = true;
@@ -49,7 +55,7 @@ export class MigrationRunner {
if (anyMigrationsRan) {
logger.success('Configuration migrations complete');
} else {
logger.dim('No migrations needed');
logger.success('config format ok');
}
return {

View File

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

View File

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

View File

@@ -525,6 +525,7 @@ export class NupstSnmp {
/**
* Determine power status based on UPS model and raw value
* Uses the value mappings defined in the OID sets
* @param upsModel UPS model
* @param powerStatusValue Raw power status value
* @returns Standardized power status
@@ -533,38 +534,27 @@ export class NupstSnmp {
upsModel: TUpsModel | undefined,
powerStatusValue: number,
): 'online' | 'onBattery' | 'unknown' {
if (upsModel === 'cyberpower') {
// CyberPower RMCARD205: upsBaseOutputStatus values
// 2=onLine, 3=onBattery, 4=onBoost, 5=onSleep, 6=off, etc.
if (powerStatusValue === 2) {
// Get the OID set for this UPS model
if (upsModel && upsModel !== 'custom') {
const oidSet = UpsOidSets.getOidSet(upsModel);
// Use the value mappings if available
if (oidSet.POWER_STATUS_VALUES) {
if (powerStatusValue === oidSet.POWER_STATUS_VALUES.online) {
return 'online';
} else if (powerStatusValue === 3) {
} else if (powerStatusValue === oidSet.POWER_STATUS_VALUES.onBattery) {
return 'onBattery';
}
} else if (upsModel === 'eaton') {
// Eaton UPS: xupsOutputSource values
// 3=normal/mains, 5=battery, etc.
}
}
// Fallback for custom or undefined models (RFC 1628 standard)
// upsOutputSource: 3=normal (mains), 5=battery
if (powerStatusValue === 3) {
return 'online';
} else if (powerStatusValue === 5) {
return 'onBattery';
}
} else if (upsModel === 'apc') {
// APC UPS: upsBasicOutputStatus values
// 2=online, 3=onBattery, etc.
if (powerStatusValue === 2) {
return 'online';
} else if (powerStatusValue === 3) {
return 'onBattery';
}
} else {
// Default interpretation for other UPS models
if (powerStatusValue === 1) {
return 'online';
} else if (powerStatusValue === 2) {
return 'onBattery';
}
}
return 'unknown';
}

View File

@@ -11,37 +11,57 @@ export class UpsOidSets {
private static readonly UPS_OID_SETS: Record<TUpsModel, IOidSet> = {
// Cyberpower OIDs for RMCARD205 (based on CyberPower_MIB_v2.11)
cyberpower: {
POWER_STATUS: '1.3.6.1.4.1.3808.1.1.1.4.1.1.0', // upsBaseOutputStatus (2=online, 3=on battery)
POWER_STATUS: '1.3.6.1.4.1.3808.1.1.1.4.1.1.0', // upsBaseOutputStatus
BATTERY_CAPACITY: '1.3.6.1.4.1.3808.1.1.1.2.2.1.0', // upsAdvanceBatteryCapacity (percentage)
BATTERY_RUNTIME: '1.3.6.1.4.1.3808.1.1.1.2.2.4.0', // upsAdvanceBatteryRunTimeRemaining (TimeTicks)
POWER_STATUS_VALUES: {
online: 2, // upsBaseOutputStatus: 2=onLine
onBattery: 3, // upsBaseOutputStatus: 3=onBattery
},
},
// APC OIDs
apc: {
POWER_STATUS: '1.3.6.1.4.1.318.1.1.1.4.1.1.0', // Power status (1=online, 2=on battery)
POWER_STATUS: '1.3.6.1.4.1.318.1.1.1.4.1.1.0', // upsBasicOutputStatus
BATTERY_CAPACITY: '1.3.6.1.4.1.318.1.1.1.2.2.1.0', // Battery capacity in percentage
BATTERY_RUNTIME: '1.3.6.1.4.1.318.1.1.1.2.2.3.0', // Remaining runtime in minutes
POWER_STATUS_VALUES: {
online: 2, // upsBasicOutputStatus: 2=onLine
onBattery: 3, // upsBasicOutputStatus: 3=onBattery
},
},
// Eaton OIDs
eaton: {
POWER_STATUS: '1.3.6.1.4.1.534.1.4.4.0', // xupsOutputSource (3=normal/mains, 5=battery)
POWER_STATUS: '1.3.6.1.4.1.534.1.4.4.0', // xupsOutputSource
BATTERY_CAPACITY: '1.3.6.1.4.1.534.1.2.4.0', // xupsBatCapacity (percentage)
BATTERY_RUNTIME: '1.3.6.1.4.1.534.1.2.1.0', // xupsBatTimeRemaining (seconds)
POWER_STATUS_VALUES: {
online: 3, // xupsOutputSource: 3=normal (mains power)
onBattery: 5, // xupsOutputSource: 5=battery
},
},
// TrippLite OIDs
tripplite: {
POWER_STATUS: '1.3.6.1.4.1.850.1.1.3.1.1.1.0', // Power status
POWER_STATUS: '1.3.6.1.4.1.850.1.1.3.1.1.1.0', // tlUpsOutputSource
BATTERY_CAPACITY: '1.3.6.1.4.1.850.1.1.3.2.4.1.0', // Battery capacity in percentage
BATTERY_RUNTIME: '1.3.6.1.4.1.850.1.1.3.2.2.1.0', // Remaining runtime in minutes
POWER_STATUS_VALUES: {
online: 2, // tlUpsOutputSource: 2=normal (mains power)
onBattery: 3, // tlUpsOutputSource: 3=onBattery
},
},
// Liebert/Vertiv OIDs
liebert: {
POWER_STATUS: '1.3.6.1.4.1.476.1.42.3.9.20.1.20.1.2.1.2.1', // Power status
POWER_STATUS: '1.3.6.1.4.1.476.1.42.3.9.20.1.20.1.2.1.2.1', // lgpPwrOutputSource
BATTERY_CAPACITY: '1.3.6.1.4.1.476.1.42.3.9.20.1.20.1.2.1.4.1', // Battery capacity in percentage
BATTERY_RUNTIME: '1.3.6.1.4.1.476.1.42.3.9.20.1.20.1.2.1.5.1', // Remaining runtime in minutes
POWER_STATUS_VALUES: {
online: 2, // lgpPwrOutputSource: 2=normal (mains power)
onBattery: 3, // lgpPwrOutputSource: 3=onBattery
},
},
// Custom OIDs (to be provided by the user)

View File

@@ -28,6 +28,13 @@ export interface IOidSet {
BATTERY_CAPACITY: string;
/** OID for battery runtime */
BATTERY_RUNTIME: string;
/** Power status value mappings */
POWER_STATUS_VALUES?: {
/** SNMP value that indicates UPS is online (on AC power) */
online: number;
/** SNMP value that indicates UPS is on battery */
onBattery: number;
};
}
/**

View File

@@ -1,7 +1,8 @@
import process from 'node:process';
import { promises as fs } from 'node:fs';
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 { theme, symbols, getBatteryColor, getRuntimeColor, formatPowerStatus } from './colors.ts';
@@ -50,11 +51,11 @@ WantedBy=multi-user.target
try {
await fs.access(configPath);
} catch (error) {
const boxWidth = 50;
logger.logBoxTitle('Configuration Error', boxWidth);
logger.logBoxLine(`No configuration file found at ${configPath}`);
logger.logBoxLine("Please run 'nupst add' first to create a UPS configuration.");
logger.logBoxEnd();
logger.log('');
logger.error('No configuration found');
logger.log(` ${theme.dim('Config file:')} ${configPath}`);
logger.log(` ${theme.dim('Run')} ${theme.command('nupst ups add')} ${theme.dim('to create a configuration')}`);
logger.log('');
throw new Error('Configuration not found');
}
}
@@ -134,21 +135,59 @@ WantedBy=multi-user.target
* Get status of the systemd service and UPS
* @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> {
try {
// Enable debug mode if requested
if (debugMode) {
const boxWidth = 45;
logger.logBoxTitle('Debug Mode', boxWidth);
logger.logBoxLine('SNMP debugging enabled - detailed logs will be shown');
logger.logBoxEnd();
console.log('');
logger.info('Debug Mode: SNMP debugging enabled');
console.log('');
this.daemon.getNupstSnmp().enableDebug();
}
// Display version information
this.daemon.getNupstSnmp().getNupst().logVersionInfo();
// Display version and update status first
await this.displayVersionInfo();
// Check if config exists first
// Check if config exists
try {
await this.checkConfigExists();
} catch (error) {
@@ -196,11 +235,11 @@ WantedBy=multi-user.target
}
// Display beautiful status
console.log('');
logger.log('');
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 {
console.log(`${symbols.stopped} ${theme.dim('Service:')} ${theme.statusInactive('inactive')}`);
logger.log(`${symbols.stopped} ${theme.dim('Service:')} ${theme.statusInactive('inactive')}`);
}
if (pid || memory || cpu) {
@@ -208,14 +247,14 @@ WantedBy=multi-user.target
if (pid) details.push(`PID: ${theme.dim(pid)}`);
if (memory) details.push(`Memory: ${theme.dim(memory)}`);
if (cpu) details.push(`CPU: ${theme.dim(cpu)}`);
console.log(` ${details.join(' ')}`);
logger.log(` ${details.join(' ')}`);
}
console.log('');
logger.log('');
} catch (error) {
console.log('');
console.log(`${symbols.stopped} ${theme.dim('Service:')} ${theme.statusInactive('not installed')}`);
console.log('');
logger.log('');
logger.log(`${symbols.stopped} ${theme.dim('Service:')} ${theme.statusInactive('not installed')}`);
logger.log('');
}
}
@@ -232,35 +271,44 @@ WantedBy=multi-user.target
// Check if we have the new multi-UPS config format
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
for (const ups of config.upsDevices) {
await this.displaySingleUpsStatus(ups, snmp);
}
} else if (config.snmp) {
// Legacy single UPS configuration
console.log(theme.info('UPS Devices (1):'));
const legacyUps = {
// Legacy single UPS configuration (v1/v2 format)
logger.info('UPS Devices (1):');
const legacyUps: IUpsConfig = {
id: 'default',
name: 'Default UPS',
snmp: config.snmp,
thresholds: config.thresholds,
groups: [],
actions: config.thresholds
? [
{
type: 'shutdown',
thresholds: config.thresholds,
triggerMode: 'onlyThresholds',
shutdownDelay: 5,
},
]
: [],
};
await this.displaySingleUpsStatus(legacyUps, snmp);
} else {
console.log('');
console.log(`${symbols.warning} ${theme.warning('No UPS devices configured')}`);
console.log(` ${theme.dim('Run')} ${theme.command('nupst ups add')} ${theme.dim('to add a device')}`);
console.log('');
logger.log('');
logger.warn('No UPS devices configured');
logger.log(` ${theme.dim('Run')} ${theme.command('nupst ups add')} ${theme.dim('to add a device')}`);
logger.log('');
}
} catch (error) {
console.log('');
console.log(`${symbols.error} ${theme.error('Failed to retrieve UPS status')}`);
console.log(` ${theme.dim(error instanceof Error ? error.message : String(error))}`);
console.log('');
logger.log('');
logger.error('Failed to retrieve UPS status');
logger.log(` ${theme.dim(error instanceof Error ? error.message : String(error))}`);
logger.log('');
}
}
@@ -269,7 +317,7 @@ WantedBy=multi-user.target
* @param ups UPS configuration
* @param snmp SNMP manager
*/
private async displaySingleUpsStatus(ups: any, snmp: any): Promise<void> {
private async displaySingleUpsStatus(ups: IUpsConfig, snmp: NupstSnmp): Promise<void> {
try {
// Create a test config with a short timeout
const testConfig = {
@@ -288,15 +336,24 @@ WantedBy=multi-user.target
}
// 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
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
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
if (ups.groups && ups.groups.length > 0) {
@@ -305,17 +362,17 @@ WantedBy=multi-user.target
const group = config.groups?.find((g: { id: string }) => g.id === 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) {
// Display error for this UPS
console.log(` ${symbols.error} ${theme.highlight(ups.name)} - ${theme.error('Connection failed')}`);
console.log(` ${theme.dim(error instanceof Error ? error.message : String(error))}`);
console.log(` ${theme.dim(`Host: ${ups.snmp.host}:${ups.snmp.port}`)}`);
console.log('');
logger.log(` ${symbols.error} ${theme.highlight(ups.name)} - ${theme.error('Connection failed')}`);
logger.log(` ${theme.dim(error instanceof Error ? error.message : String(error))}`);
logger.log(` ${theme.dim(`Host: ${ups.snmp.host}:${ups.snmp.port}`)}`);
logger.log('');
}
}