Compare commits

...

31 Commits

Author SHA1 Message Date
f03c683d02 fix(install): correct installation order for updates
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 42s
CI / Build All Platforms (push) Successful in 47s
- Stop service first
- Remove /opt/nupst
- Create fresh directory
- Download binary
- Ensures clean installation without leaving empty directories
2025-10-20 13:28:56 +00:00
f750299780 fix(install): simplify installation to only binary in /opt/nupst
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 48s
- Remove all conditional migration logic
- Always completely clean /opt/nupst before installation
- Ensures only NUPST binary exists in installation directory
- Simplified service restart logic
2025-10-20 13:24:03 +00:00
ca1039408d chore(release): bump version to 5.0.2
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 42s
CI / Build All Platforms (push) Successful in 47s
2025-10-20 13:09:20 +00:00
df3e0b9424 fix: import process from node:process in script-action
Some checks failed
CI / Type Check & Lint (push) Successful in 5s
CI / Build Test (Current Platform) (push) Successful in 5s
CI / Build All Platforms (push) Has been cancelled
Fixes TS2580 error where process was undefined
2025-10-20 13:08:43 +00:00
c8e5960abd chore(release): bump version to 5.0.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 45s
CI / Build All Platforms (push) Successful in 48s
2025-10-20 13:07:07 +00:00
7304a62357 chore(release): bump version to 5.0.0
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 47s
BREAKING CHANGE: Deprecated CLI commands removed

This is a major version bump due to breaking changes:
- Removed all deprecated flat command structure
- Users must now use modern subcommand structure:
  - nupst service <subcommand>
  - nupst ups <subcommand>
  - nupst group <subcommand>
  - nupst action <subcommand>

New in v5.0:
- Enhanced status display showing actions and groups (v4.3.3)
- Action management system (v4.3.0)
- Improved type safety (v4.2.5)
- Config auto-reload (v4.3.2)

Updated readme version references to v5.0.0
2025-10-20 13:00:42 +00:00
a5a88e53ba docs: improve accuracy of dependency claims
Some checks failed
CI / Type Check & Lint (push) Failing after 5s
CI / Build Test (Current Platform) (push) Successful in 5s
CI / Build All Platforms (push) Successful in 45s
- Clarify 'zero runtime dependencies' means for compiled binary
- Change 'no npm' to 'no installation required'
- Update 'Zero Dependencies' to 'Self-Contained Binary'
- Improve migration table accuracy (Runtime Dependencies vs build deps)
- More honest about supply chain (reduced vs eliminated)
2025-10-20 12:59:14 +00:00
73bc271c59 docs: remove deprecated command migration documentation
Some checks failed
CI / Type Check & Lint (push) Failing after 5s
CI / Build Test (Current Platform) (push) Successful in 4s
CI / Build All Platforms (push) Successful in 45s
- Remove 'Command Migration' section showing old commands
- Users must now use modern subcommand structure
- Clean up migration guide to focus on actual changes
2025-10-20 12:57:48 +00:00
1e98181e71 feat: remove deprecated CLI commands
BREAKING CHANGE: Old flat command structure no longer supported

Removed deprecated commands:
- nupst add → use 'nupst ups add'
- nupst edit → use 'nupst ups edit'
- nupst delete → use 'nupst ups remove'
- nupst list → use 'nupst ups list'
- nupst test → use 'nupst ups test'
- nupst setup → use 'nupst ups edit'
- nupst enable → use 'nupst service enable'
- nupst disable → use 'nupst service disable'
- nupst start → use 'nupst service start'
- nupst stop → use 'nupst service stop'
- nupst status → use 'nupst service status'
- nupst logs → use 'nupst service logs'
- nupst daemon-start → use 'nupst service start-daemon'

Also removed 'delete' as alias for 'remove' (use 'rm' instead)

Modern command structure is now required:
- nupst service <subcommand>
- nupst ups <subcommand>
- nupst group <subcommand>
- nupst action <subcommand>

Kept modern aliases: rm, ls
2025-10-20 12:57:23 +00:00
eb5a8185ae docs: create comprehensive readme with v4.3 features
Some checks failed
CI / Type Check & Lint (push) Failing after 4s
CI / Build Test (Current Platform) (push) Successful in 6s
CI / Build All Platforms (push) Successful in 45s
- Add action management documentation (new feature)
- Update configuration examples to v4.1+ action-based format
- Document trigger modes and action system
- Update status command output examples with groups and actions
- Remove outdated contributing section
- Add modern emojis and engaging tone
- Update all version references to v4.3.3
- Maintain Task Venture Capital GmbH legal section
2025-10-20 12:52:26 +00:00
ef3d3f3fa3 chore(release): bump version to 4.3.3
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 43s
CI / Build All Platforms (push) Successful in 48s
2025-10-20 12:48:26 +00:00
34e6e850ad feat(status): display actions and groups in status command
- Add action display to UPS status showing trigger mode, thresholds, and delays
- Create displayGroupsStatus() method to show group information
- Display group mode, member UPS devices, and group actions
- Integrate groups section into status command output
2025-10-20 12:48:14 +00:00
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
21 changed files with 2421 additions and 847 deletions

View File

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

122
example-action.sh Normal file
View File

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

View File

@@ -1,6 +1,6 @@
#!/bin/bash #!/bin/bash
# NUPST Installer Script (v4.0+) # NUPST Installer Script (v5.0+)
# Downloads and installs pre-compiled NUPST binary from Gitea releases # Downloads and installs pre-compiled NUPST binary from Gitea releases
# #
# Usage: # Usage:
@@ -8,7 +8,7 @@
# curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash # curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash
# #
# With version specification: # With version specification:
# curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash -s -- --version v4.0.0 # curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash -s -- --version v5.0.0
# #
# Options: # Options:
# -h, --help Show this help message # -h, --help Show this help message
@@ -48,14 +48,14 @@ while [[ $# -gt 0 ]]; do
done done
if [ $SHOW_HELP -eq 1 ]; then if [ $SHOW_HELP -eq 1 ]; then
echo "NUPST Installer Script (v4.0+)" echo "NUPST Installer Script (v5.0+)"
echo "Downloads and installs pre-compiled NUPST binary" echo "Downloads and installs pre-compiled NUPST binary"
echo "" echo ""
echo "Usage: $0 [options]" echo "Usage: $0 [options]"
echo "" echo ""
echo "Options:" echo "Options:"
echo " -h, --help Show this help message" echo " -h, --help Show this help message"
echo " --version VERSION Install specific version (e.g., v4.0.0)" echo " --version VERSION Install specific version (e.g., v5.0.0)"
echo " --install-dir DIR Installation directory (default: /opt/nupst)" echo " --install-dir DIR Installation directory (default: /opt/nupst)"
echo "" echo ""
echo "Examples:" echo "Examples:"
@@ -63,7 +63,7 @@ if [ $SHOW_HELP -eq 1 ]; then
echo " curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash" echo " curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash"
echo "" echo ""
echo " # Install specific version" echo " # Install specific version"
echo " curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash -s -- --version v4.0.0" echo " curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash -s -- --version v5.0.0"
exit 0 exit 0
fi fi
@@ -145,7 +145,7 @@ get_latest_version() {
# Main installation process # Main installation process
echo "================================================" echo "================================================"
echo " NUPST Installation Script (v4.0+)" echo " NUPST Installation Script (v5.0+)"
echo "================================================" echo "================================================"
echo "" echo ""
@@ -169,50 +169,25 @@ DOWNLOAD_URL="${GITEA_BASE_URL}/${GITEA_REPO}/releases/download/${VERSION}/${BIN
echo "Download URL: $DOWNLOAD_URL" echo "Download URL: $DOWNLOAD_URL"
echo "" echo ""
# Check if installation directory exists # Check if service is running and stop it
SERVICE_WAS_RUNNING=0 SERVICE_WAS_RUNNING=0
OLD_NODE_INSTALL=0
if [ -d "$INSTALL_DIR" ]; then
# Check if this is an old Node.js-based installation
if [ -f "$INSTALL_DIR/package.json" ] || [ -d "$INSTALL_DIR/node_modules" ]; then
OLD_NODE_INSTALL=1
echo "Detected old Node.js-based NUPST installation (v3.x or earlier)"
echo "This installer will migrate to the new Deno-based binary version (v4.0+)"
echo ""
fi
echo "Updating existing installation at $INSTALL_DIR..."
# Check if service exists (enabled or running) and stop it if active
if systemctl is-enabled --quiet nupst 2>/dev/null || systemctl is-active --quiet nupst 2>/dev/null; then if systemctl is-enabled --quiet nupst 2>/dev/null || systemctl is-active --quiet nupst 2>/dev/null; then
SERVICE_WAS_RUNNING=1 SERVICE_WAS_RUNNING=1
if systemctl is-active --quiet nupst 2>/dev/null; then if systemctl is-active --quiet nupst 2>/dev/null; then
echo "Stopping NUPST service..." echo "Stopping NUPST service..."
systemctl stop nupst systemctl stop nupst
else
echo "Service is installed but not currently running (will be updated)..."
fi fi
fi fi
# Clean up old Node.js installation files # Clean installation directory - ensure only binary exists
if [ $OLD_NODE_INSTALL -eq 1 ]; then if [ -d "$INSTALL_DIR" ]; then
echo "Cleaning up old Node.js installation files..." echo "Cleaning installation directory: $INSTALL_DIR"
rm -rf "$INSTALL_DIR/node_modules" 2>/dev/null || true rm -rf "$INSTALL_DIR"
rm -rf "$INSTALL_DIR/vendor" 2>/dev/null || true
rm -rf "$INSTALL_DIR/dist_ts" 2>/dev/null || true
rm -f "$INSTALL_DIR/package.json" 2>/dev/null || true
rm -f "$INSTALL_DIR/package-lock.json" 2>/dev/null || true
rm -f "$INSTALL_DIR/pnpm-lock.yaml" 2>/dev/null || true
rm -f "$INSTALL_DIR/tsconfig.json" 2>/dev/null || true
rm -f "$INSTALL_DIR/setup.sh" 2>/dev/null || true
rm -rf "$INSTALL_DIR/bin" 2>/dev/null || true
echo "Old installation files removed."
fi fi
else
# Create fresh installation directory
echo "Creating installation directory: $INSTALL_DIR" echo "Creating installation directory: $INSTALL_DIR"
mkdir -p "$INSTALL_DIR" mkdir -p "$INSTALL_DIR"
fi
# Download binary # Download binary
echo "Downloading NUPST binary..." echo "Downloading NUPST binary..."
@@ -260,14 +235,6 @@ echo "Symlink created: $BIN_DIR/nupst -> $BINARY_PATH"
echo "" echo ""
# Update systemd service file if migrating from v3
if [ $SERVICE_WAS_RUNNING -eq 1 ] && [ $OLD_NODE_INSTALL -eq 1 ]; then
echo "Updating systemd service file for v4..."
$BINARY_PATH service enable > /dev/null 2>&1
echo "Service file updated."
echo ""
fi
# Restart service if it was running before update # Restart service if it was running before update
if [ $SERVICE_WAS_RUNNING -eq 1 ]; then if [ $SERVICE_WAS_RUNNING -eq 1 ]; then
echo "Restarting NUPST service..." echo "Restarting NUPST service..."
@@ -280,20 +247,6 @@ echo "================================================"
echo " NUPST Installation Complete!" echo " NUPST Installation Complete!"
echo "================================================" echo "================================================"
echo "" echo ""
if [ $OLD_NODE_INSTALL -eq 1 ]; then
echo "Migration from v3.x to v4.0 successful!"
echo ""
echo "What changed:"
echo " • Node.js runtime removed (now a self-contained binary)"
echo " • Faster startup and lower memory usage"
echo " • CLI commands now use subcommand structure"
echo " (old commands still work with deprecation warnings)"
echo ""
echo "See readme for migration details: https://code.foss.global/serve.zone/nupst#migration-from-v3x"
echo ""
fi
echo "Installation details:" echo "Installation details:"
echo " Binary location: $BINARY_PATH" echo " Binary location: $BINARY_PATH"
echo " Symlink location: $BIN_DIR/nupst" echo " Symlink location: $BIN_DIR/nupst"

1005
readme.md

File diff suppressed because it is too large Load Diff

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('');
}
}

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

@@ -0,0 +1,167 @@
import * as path from 'node:path';
import * as fs from 'node:fs';
import process from 'node:process';
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();
});
}
}

153
ts/cli.ts
View File

@@ -72,6 +72,7 @@ export class NupstCli {
const upsHandler = this.nupst.getUpsHandler(); const upsHandler = this.nupst.getUpsHandler();
const groupHandler = this.nupst.getGroupHandler(); const groupHandler = this.nupst.getGroupHandler();
const serviceHandler = this.nupst.getServiceHandler(); const serviceHandler = this.nupst.getServiceHandler();
const actionHandler = this.nupst.getActionHandler();
// Handle service subcommands // Handle service subcommands
if (command === 'service') { if (command === 'service') {
@@ -126,8 +127,7 @@ export class NupstCli {
break; break;
} }
case 'remove': case 'remove':
case 'rm': // Alias case 'rm': {
case 'delete': { // Backward compatibility
const upsIdToRemove = subcommandArgs[0]; const upsIdToRemove = subcommandArgs[0];
if (!upsIdToRemove) { if (!upsIdToRemove) {
logger.error('UPS ID is required for remove command'); logger.error('UPS ID is required for remove command');
@@ -171,8 +171,7 @@ export class NupstCli {
break; break;
} }
case 'remove': case 'remove':
case 'rm': // Alias case 'rm': {
case 'delete': { // Backward compatibility
const groupIdToRemove = subcommandArgs[0]; const groupIdToRemove = subcommandArgs[0];
if (!groupIdToRemove) { if (!groupIdToRemove) {
logger.error('Group ID is required for remove command'); logger.error('Group ID is required for remove command');
@@ -193,6 +192,37 @@ export class NupstCli {
return; return;
} }
// Handle action subcommands
if (command === 'action') {
const subcommand = commandArgs[0] || 'list';
const subcommandArgs = commandArgs.slice(1);
switch (subcommand) {
case 'add': {
const upsId = subcommandArgs[0];
await actionHandler.add(upsId);
break;
}
case 'remove':
case 'rm': {
const upsId = subcommandArgs[0];
const actionIndex = subcommandArgs[1];
await actionHandler.remove(upsId, actionIndex);
break;
}
case 'list':
case 'ls': { // Alias
const upsId = subcommandArgs[0];
await actionHandler.list(upsId);
break;
}
default:
this.showActionHelp();
break;
}
return;
}
// Handle config subcommand // Handle config subcommand
if (command === 'config') { if (command === 'config') {
const subcommand = commandArgs[0] || 'show'; const subcommand = commandArgs[0] || 'show';
@@ -209,72 +239,8 @@ export class NupstCli {
return; return;
} }
// Handle top-level commands and backward compatibility // Handle top-level commands
switch (command) { switch (command) {
// Backward compatibility - old UPS commands
case 'add':
logger.log("Note: 'nupst add' is deprecated. Use 'nupst ups add' instead.");
await upsHandler.add();
break;
case 'edit':
logger.log("Note: 'nupst edit' is deprecated. Use 'nupst ups edit' instead.");
await upsHandler.edit(commandArgs[0]);
break;
case 'delete':
logger.log("Note: 'nupst delete' is deprecated. Use 'nupst ups remove' instead.");
if (!commandArgs[0]) {
logger.error('UPS ID is required for delete command');
this.showHelp();
return;
}
await upsHandler.remove(commandArgs[0]);
break;
case 'list':
logger.log("Note: 'nupst list' is deprecated. Use 'nupst ups list' instead.");
await upsHandler.list();
break;
case 'test':
logger.log("Note: 'nupst test' is deprecated. Use 'nupst ups test' instead.");
await upsHandler.test(debugMode);
break;
case 'setup':
logger.log("Note: 'nupst setup' is deprecated. Use 'nupst ups edit' instead.");
await upsHandler.edit(undefined);
break;
// Backward compatibility - old service commands
case 'enable':
logger.log("Note: 'nupst enable' is deprecated. Use 'nupst service enable' instead.");
await serviceHandler.enable();
break;
case 'disable':
logger.log("Note: 'nupst disable' is deprecated. Use 'nupst service disable' instead.");
await serviceHandler.disable();
break;
case 'start':
logger.log("Note: 'nupst start' is deprecated. Use 'nupst service start' instead.");
await serviceHandler.start();
break;
case 'stop':
logger.log("Note: 'nupst stop' is deprecated. Use 'nupst service stop' instead.");
await serviceHandler.stop();
break;
case 'status':
logger.log("Note: 'nupst status' is deprecated. Use 'nupst service status' instead.");
await serviceHandler.status();
break;
case 'logs':
logger.log("Note: 'nupst logs' is deprecated. Use 'nupst service logs' instead.");
await serviceHandler.logs();
break;
case 'daemon-start':
logger.log(
"Note: 'nupst daemon-start' is deprecated. Use 'nupst service start-daemon' instead.",
);
await serviceHandler.daemonStart(debugMode);
break;
// Top-level commands (no changes)
case 'update': case 'update':
await serviceHandler.update(); await serviceHandler.update();
break; break;
@@ -335,7 +301,7 @@ export class NupstCli {
id: theme.dim(ups.id), id: theme.dim(ups.id),
host: `${ups.snmp.host}:${ups.snmp.port}`, host: `${ups.snmp.host}:${ups.snmp.port}`,
model: ups.snmp.upsModel || 'cyberpower', model: ups.snmp.upsModel || 'cyberpower',
thresholds: `${ups.thresholds.battery}% / ${ups.thresholds.runtime}min`, actions: `${(ups.actions || []).length} configured`,
groups: ups.groups.length > 0 ? ups.groups.join(', ') : theme.dim('None'), groups: ups.groups.length > 0 ? ups.groups.join(', ') : theme.dim('None'),
})); }));
@@ -344,7 +310,7 @@ export class NupstCli {
{ header: 'ID', key: 'id', align: 'left' }, { header: 'ID', key: 'id', align: 'left' },
{ header: 'Host:Port', key: 'host', align: 'left', color: theme.info }, { header: 'Host:Port', key: 'host', align: 'left', color: theme.info },
{ header: 'Model', key: 'model', align: 'left' }, { header: 'Model', key: 'model', align: 'left' },
{ header: 'Battery/Runtime', key: 'thresholds', align: 'left' }, { header: 'Actions', key: 'actions', align: 'left' },
{ header: 'Groups', key: 'groups', align: 'left' }, { header: 'Groups', key: 'groups', align: 'left' },
]; ];
@@ -389,9 +355,9 @@ export class NupstCli {
} else { } else {
// === Legacy Single UPS Configuration === // === Legacy Single UPS Configuration ===
if (!config.snmp || !config.thresholds) { if (!config.snmp) {
logger.logBox('Configuration Error', [ logger.logBox('Configuration Error', [
'Error: Legacy configuration missing SNMP or threshold settings', 'Error: Legacy configuration missing SNMP settings',
], 60, 'error'); ], 60, 'error');
return; return;
} }
@@ -435,9 +401,7 @@ export class NupstCli {
: [] : []
), ),
'', '',
theme.dim('Thresholds:'),
` Battery: ${theme.highlight(String(config.thresholds.battery))}%`,
` Runtime: ${theme.highlight(String(config.thresholds.runtime))} minutes`,
` Check Interval: ${config.checkInterval / 1000} seconds`, ` Check Interval: ${config.checkInterval / 1000} seconds`,
'', '',
theme.dim('Configuration File:'), theme.dim('Configuration File:'),
@@ -501,6 +465,7 @@ export class NupstCli {
this.printCommand('service <subcommand>', 'Manage systemd service'); this.printCommand('service <subcommand>', 'Manage systemd service');
this.printCommand('ups <subcommand>', 'Manage UPS devices'); this.printCommand('ups <subcommand>', 'Manage UPS devices');
this.printCommand('group <subcommand>', 'Manage UPS groups'); this.printCommand('group <subcommand>', 'Manage UPS groups');
this.printCommand('action <subcommand>', 'Manage UPS actions');
this.printCommand('config [show]', 'Display current configuration'); this.printCommand('config [show]', 'Display current configuration');
this.printCommand('update', 'Update NUPST from repository', theme.dim('(requires root)')); this.printCommand('update', 'Update NUPST from repository', theme.dim('(requires root)'));
this.printCommand('uninstall', 'Completely remove NUPST', theme.dim('(requires root)')); this.printCommand('uninstall', 'Completely remove NUPST', theme.dim('(requires root)'));
@@ -537,6 +502,13 @@ export class NupstCli {
this.printCommand('nupst group list (or ls)', 'List all UPS groups'); this.printCommand('nupst group list (or ls)', 'List all UPS groups');
console.log(''); console.log('');
// Action subcommands
logger.log(theme.info('Action Subcommands:'));
this.printCommand('nupst action add <target-id>', 'Add a new action to a UPS or group');
this.printCommand('nupst action remove <target-id> <index>', 'Remove an action by index');
this.printCommand('nupst action list [target-id]', 'List all actions (optionally for specific target)');
console.log('');
// Options // Options
logger.log(theme.info('Options:')); logger.log(theme.info('Options:'));
this.printCommand('--debug, -d', 'Enable debug mode for detailed SNMP logging'); this.printCommand('--debug, -d', 'Enable debug mode for detailed SNMP logging');
@@ -550,11 +522,6 @@ export class NupstCli {
logger.dim(' nupst group list # Show all configured groups'); logger.dim(' nupst group list # Show all configured groups');
logger.dim(' nupst config # Display current configuration'); logger.dim(' nupst config # Display current configuration');
console.log(''); console.log('');
// Note about deprecated commands
logger.warn('Note: Old command format (e.g., \'nupst add\') still works but is deprecated.');
logger.dim(' Use the new format (e.g., \'nupst ups add\') going forward.');
console.log('');
} }
/** /**
@@ -641,6 +608,30 @@ Examples:
nupst group add - Create a new group nupst group add - Create a new group
nupst group edit dc-1 - Edit group with ID 'dc-1' nupst group edit dc-1 - Edit group with ID 'dc-1'
nupst group remove dc-1 - Remove group with ID 'dc-1' nupst group remove dc-1 - Remove group with ID 'dc-1'
`);
}
private showActionHelp(): void {
logger.log(`
NUPST - Action Management Commands
Usage:
nupst action <subcommand> [arguments]
Subcommands:
add <ups-id|group-id> - Add a new action to a UPS or group interactively
remove <ups-id|group-id> <index> - Remove an action by index (alias: rm)
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

@@ -79,8 +79,8 @@ export class UpsHandler {
id: 'default', id: 'default',
name: 'Default UPS', name: 'Default UPS',
snmp: config.snmp, snmp: config.snmp,
thresholds: config.thresholds,
groups: [], groups: [],
actions: [],
}], }],
groups: [], groups: [],
}; };
@@ -117,14 +117,12 @@ export class UpsHandler {
runtime: 20, runtime: 20,
}, },
groups: [], groups: [],
actions: [],
}; };
// Gather SNMP settings // Gather SNMP settings
await this.gatherSnmpSettings(newUps.snmp, prompt); await this.gatherSnmpSettings(newUps.snmp, prompt);
// Gather threshold settings
await this.gatherThresholdSettings(newUps.thresholds, prompt);
// Gather UPS model settings // Gather UPS model settings
await this.gatherUpsModelSettings(newUps.snmp, prompt); await this.gatherUpsModelSettings(newUps.snmp, prompt);
@@ -136,6 +134,9 @@ export class UpsHandler {
await groupHandler.assignUpsToGroups(newUps, config.groups, prompt); await groupHandler.assignUpsToGroups(newUps, config.groups, prompt);
} }
// Gather action settings
await this.gatherActionSettings(newUps.actions, prompt);
// Add the new UPS to the config // Add the new UPS to the config
config.upsDevices.push(newUps); config.upsDevices.push(newUps);
@@ -221,16 +222,16 @@ export class UpsHandler {
// Convert old format to new format if needed // Convert old format to new format if needed
if (!config.upsDevices) { if (!config.upsDevices) {
// Initialize with the current config as the first UPS // Initialize with the current config as the first UPS
if (!config.snmp || !config.thresholds) { if (!config.snmp) {
logger.error('Legacy configuration is missing required SNMP or threshold settings'); logger.error('Legacy configuration is missing required SNMP settings');
return; return;
} }
config.upsDevices = [{ config.upsDevices = [{
id: 'default', id: 'default',
name: 'Default UPS', name: 'Default UPS',
snmp: config.snmp, snmp: config.snmp,
thresholds: config.thresholds,
groups: [], groups: [],
actions: [],
}]; }];
config.groups = []; config.groups = [];
logger.log('Converting existing configuration to multi-UPS format.'); logger.log('Converting existing configuration to multi-UPS format.');
@@ -265,9 +266,6 @@ export class UpsHandler {
// Edit SNMP settings // Edit SNMP settings
await this.gatherSnmpSettings(upsToEdit.snmp, prompt); await this.gatherSnmpSettings(upsToEdit.snmp, prompt);
// Edit threshold settings
await this.gatherThresholdSettings(upsToEdit.thresholds, prompt);
// Edit UPS model settings // Edit UPS model settings
await this.gatherUpsModelSettings(upsToEdit.snmp, prompt); await this.gatherUpsModelSettings(upsToEdit.snmp, prompt);
@@ -279,6 +277,14 @@ export class UpsHandler {
await groupHandler.assignUpsToGroups(upsToEdit, config.groups, prompt); await groupHandler.assignUpsToGroups(upsToEdit, config.groups, prompt);
} }
// Initialize actions array if not exists
if (!upsToEdit.actions) {
upsToEdit.actions = [];
}
// Edit action settings
await this.gatherActionSettings(upsToEdit.actions, prompt);
// Save the configuration // Save the configuration
await this.nupst.getDaemon().saveConfig(config); await this.nupst.getDaemon().saveConfig(config);
@@ -396,13 +402,12 @@ export class UpsHandler {
logger.logBox('UPS Devices', [ logger.logBox('UPS Devices', [
'Legacy single-UPS configuration detected.', 'Legacy single-UPS configuration detected.',
'', '',
...((!config.snmp || !config.thresholds) ...(!config.snmp
? ['Error: Configuration missing SNMP or threshold settings'] ? ['Error: Configuration missing SNMP settings']
: [ : [
'Default UPS:', 'Default UPS:',
` Host: ${config.snmp.host}:${config.snmp.port}`, ` Host: ${config.snmp.host}:${config.snmp.port}`,
` Model: ${config.snmp.upsModel || 'cyberpower'}`, ` Model: ${config.snmp.upsModel || 'cyberpower'}`,
` Thresholds: ${config.thresholds.battery}% battery, ${config.thresholds.runtime} min runtime`,
'', '',
'Use "nupst ups add" to add more UPS devices and migrate', 'Use "nupst ups add" to add more UPS devices and migrate',
'to the multi-UPS configuration format.', 'to the multi-UPS configuration format.',
@@ -506,9 +511,8 @@ export class UpsHandler {
*/ */
private displayTestConfig(config: any): void { private displayTestConfig(config: any): void {
// Check if this is a UPS device or full configuration // Check if this is a UPS device or full configuration
const isUpsConfig = config.snmp && config.thresholds; const isUpsConfig = config.snmp;
const snmpConfig = isUpsConfig ? config.snmp : config.snmp || {}; const snmpConfig = isUpsConfig ? config.snmp : config.snmp || {};
const thresholds = isUpsConfig ? config.thresholds : config.thresholds || {};
const checkInterval = config.checkInterval || 30000; const checkInterval = config.checkInterval || 30000;
// Get UPS name and ID if available // Get UPS name and ID if available
@@ -552,10 +556,6 @@ export class UpsHandler {
); );
logger.logBoxLine(` Battery Runtime: ${snmpConfig.customOIDs.BATTERY_RUNTIME || 'Not set'}`); logger.logBoxLine(` Battery Runtime: ${snmpConfig.customOIDs.BATTERY_RUNTIME || 'Not set'}`);
} }
logger.logBoxLine('Thresholds:');
logger.logBoxLine(` Battery: ${thresholds.battery}%`);
logger.logBoxLine(` Runtime: ${thresholds.runtime} minutes`);
// Show group assignments if this is a UPS config // Show group assignments if this is a UPS config
if (config.groups && Array.isArray(config.groups)) { if (config.groups && Array.isArray(config.groups)) {
logger.logBoxLine( logger.logBoxLine(
@@ -579,7 +579,6 @@ export class UpsHandler {
try { try {
// Create a test config with a short timeout // Create a test config with a short timeout
const snmpConfig = config.snmp ? config.snmp : config.snmp; const snmpConfig = config.snmp ? config.snmp : config.snmp;
const thresholds = config.thresholds ? config.thresholds : config.thresholds;
const testConfig = { const testConfig = {
...snmpConfig, ...snmpConfig,
@@ -596,10 +595,7 @@ export class UpsHandler {
logger.logBoxLine(` Runtime Remaining: ${status.batteryRuntime} minutes`); logger.logBoxLine(` Runtime Remaining: ${status.batteryRuntime} minutes`);
logger.logBoxEnd(); logger.logBoxEnd();
// Check status against thresholds if on battery
if (status.powerStatus === 'onBattery') {
this.analyzeThresholds(status, thresholds);
}
} catch (error) { } catch (error) {
const errorBoxWidth = 45; const errorBoxWidth = 45;
logger.logBoxTitle(`Connection Failed: ${upsName}`, errorBoxWidth); logger.logBoxTitle(`Connection Failed: ${upsName}`, errorBoxWidth);
@@ -821,39 +817,6 @@ export class UpsHandler {
snmpConfig.privKey = privKey.trim() || defaultPrivKey; snmpConfig.privKey = privKey.trim() || defaultPrivKey;
} }
/**
* Gather threshold settings
* @param thresholds Thresholds configuration object to update
* @param prompt Function to prompt for user input
*/
private async gatherThresholdSettings(
thresholds: any,
prompt: (question: string) => Promise<string>,
): Promise<void> {
logger.log('');
logger.info('Shutdown Thresholds:');
// Battery threshold
const defaultBatteryThreshold = thresholds.battery || 60;
const batteryThresholdInput = await prompt(
`Battery percentage threshold [${defaultBatteryThreshold}%]: `,
);
const batteryThreshold = parseInt(batteryThresholdInput, 10);
thresholds.battery = batteryThresholdInput.trim() && !isNaN(batteryThreshold)
? batteryThreshold
: defaultBatteryThreshold;
// Runtime threshold
const defaultRuntimeThreshold = thresholds.runtime || 20;
const runtimeThresholdInput = await prompt(
`Runtime minutes threshold [${defaultRuntimeThreshold} minutes]: `,
);
const runtimeThreshold = parseInt(runtimeThresholdInput, 10);
thresholds.runtime = runtimeThresholdInput.trim() && !isNaN(runtimeThreshold)
? runtimeThreshold
: defaultRuntimeThreshold;
}
/** /**
* Gather UPS model settings * Gather UPS model settings
* @param snmpConfig SNMP configuration object to update * @param snmpConfig SNMP configuration object to update
@@ -919,6 +882,151 @@ export class UpsHandler {
} }
} }
/**
* Gather action configuration settings
* @param actions Actions array to configure
* @param prompt Function to prompt for user input
*/
private async gatherActionSettings(
actions: any[],
prompt: (question: string) => Promise<string>,
): Promise<void> {
logger.log('');
logger.info('Action Configuration (Optional):');
logger.dim('Actions are triggered on power status changes and threshold violations.');
logger.dim('Leave empty to use default shutdown behavior on threshold violations.');
const configureActions = await prompt('Configure custom actions? (y/N): ');
if (configureActions.toLowerCase() !== 'y') {
return; // Keep existing actions or use default
}
// Clear existing actions
actions.length = 0;
let addMore = true;
while (addMore) {
logger.log('');
logger.info('Action Type:');
logger.dim(' 1) Shutdown (system shutdown)');
logger.dim(' 2) Webhook (HTTP notification)');
logger.dim(' 3) Custom Script (run .sh file from /etc/nupst)');
const typeInput = await prompt('Select action type [1]: ');
const typeValue = parseInt(typeInput, 10) || 1;
const action: any = {};
if (typeValue === 1) {
// Shutdown action
action.type = 'shutdown';
const delayInput = await prompt('Shutdown delay in minutes [5]: ');
const delay = parseInt(delayInput, 10);
if (delayInput.trim() && !isNaN(delay)) {
action.shutdownDelay = delay;
}
} else if (typeValue === 2) {
// Webhook action
action.type = 'webhook';
const url = await prompt('Webhook URL: ');
if (!url.trim()) {
logger.warn('Webhook URL required, skipping action');
continue;
}
action.webhookUrl = url.trim();
logger.log('');
logger.info('HTTP Method:');
logger.dim(' 1) POST (JSON body)');
logger.dim(' 2) GET (query parameters)');
const methodInput = await prompt('Select method [1]: ');
action.webhookMethod = methodInput === '2' ? 'GET' : 'POST';
const timeoutInput = await prompt('Timeout in seconds [10]: ');
const timeout = parseInt(timeoutInput, 10);
if (timeoutInput.trim() && !isNaN(timeout)) {
action.webhookTimeout = timeout * 1000; // Convert to ms
}
} else if (typeValue === 3) {
// Script action
action.type = 'script';
const scriptPath = await prompt('Script filename (in /etc/nupst/, must end with .sh): ');
if (!scriptPath.trim() || !scriptPath.trim().endsWith('.sh')) {
logger.warn('Script path must end with .sh, skipping action');
continue;
}
action.scriptPath = scriptPath.trim();
const timeoutInput = await prompt('Script timeout in seconds [60]: ');
const timeout = parseInt(timeoutInput, 10);
if (timeoutInput.trim() && !isNaN(timeout)) {
action.scriptTimeout = timeout * 1000; // Convert to ms
}
} else {
logger.warn('Invalid action type, skipping');
continue;
}
// Configure trigger mode (applies to all action types)
logger.log('');
logger.info('Trigger Mode:');
logger.dim(' 1) Power changes + thresholds (default)');
logger.dim(' 2) Only power status changes');
logger.dim(' 3) Only threshold violations');
logger.dim(' 4) Any change (every ~30s check)');
const triggerInput = await prompt('Select trigger mode [1]: ');
const triggerValue = parseInt(triggerInput, 10) || 1;
switch (triggerValue) {
case 2:
action.triggerMode = 'onlyPowerChanges';
break;
case 3:
action.triggerMode = 'onlyThresholds';
break;
case 4:
action.triggerMode = 'anyChange';
break;
default:
action.triggerMode = 'powerChangesAndThresholds';
}
// Configure thresholds if needed for onlyThresholds or powerChangesAndThresholds modes
if (action.triggerMode === 'onlyThresholds' || action.triggerMode === 'powerChangesAndThresholds') {
logger.log('');
logger.info('Action Thresholds:');
logger.dim('Action will trigger when battery or runtime falls below these values (while on battery)');
const batteryInput = await prompt('Battery threshold percentage [60]: ');
const battery = parseInt(batteryInput, 10);
const batteryThreshold = (batteryInput.trim() && !isNaN(battery)) ? battery : 60;
const runtimeInput = await prompt('Runtime threshold in minutes [20]: ');
const runtime = parseInt(runtimeInput, 10);
const runtimeThreshold = (runtimeInput.trim() && !isNaN(runtime)) ? runtime : 20;
action.thresholds = {
battery: batteryThreshold,
runtime: runtimeThreshold,
};
}
actions.push(action);
logger.success(`${action.type.charAt(0).toUpperCase() + action.type.slice(1)} action added (mode: ${action.triggerMode || 'powerChangesAndThresholds'})`);
const more = await prompt('Add another action? (y/N): ');
addMore = more.toLowerCase() === 'y';
}
if (actions.length > 0) {
logger.log('');
logger.success(`${actions.length} action(s) configured`);
}
}
/** /**
* Display UPS configuration summary * Display UPS configuration summary
* @param ups UPS configuration * @param ups UPS configuration
@@ -931,9 +1039,7 @@ export class UpsHandler {
logger.logBoxLine(`SNMP Host: ${ups.snmp.host}:${ups.snmp.port}`); logger.logBoxLine(`SNMP Host: ${ups.snmp.host}:${ups.snmp.port}`);
logger.logBoxLine(`SNMP Version: ${ups.snmp.version}`); logger.logBoxLine(`SNMP Version: ${ups.snmp.version}`);
logger.logBoxLine(`UPS Model: ${ups.snmp.upsModel}`); logger.logBoxLine(`UPS Model: ${ups.snmp.upsModel}`);
logger.logBoxLine(
`Thresholds: ${ups.thresholds.battery}% battery, ${ups.thresholds.runtime} min runtime`,
);
if (ups.groups && ups.groups.length > 0) { if (ups.groups && ups.groups.length > 0) {
logger.logBoxLine(`Groups: ${ups.groups.join(', ')}`); logger.logBoxLine(`Groups: ${ups.groups.join(', ')}`);
} else { } else {

View File

@@ -4,10 +4,12 @@ import * as path from 'node:path';
import { exec, execFile } from 'node:child_process'; import { exec, execFile } from 'node:child_process';
import { promisify } from 'node:util'; import { promisify } from 'node:util';
import { NupstSnmp } from './snmp/manager.ts'; import { NupstSnmp } from './snmp/manager.ts';
import type { ISnmpConfig } from './snmp/types.ts'; import type { ISnmpConfig, IUpsStatus as ISnmpUpsStatus } from './snmp/types.ts';
import { logger, type ITableColumn } from './logger.ts'; import { logger, type ITableColumn } from './logger.ts';
import { MigrationRunner } from './migrations/index.ts'; import { MigrationRunner } from './migrations/index.ts';
import { theme, symbols, getBatteryColor, getRuntimeColor, formatPowerStatus } from './colors.ts'; import { theme, symbols, getBatteryColor, getRuntimeColor, formatPowerStatus } from './colors.ts';
import type { IActionConfig } from './actions/base-action.ts';
import { ActionManager, type IActionContext, type TPowerStatus } from './actions/index.ts';
const execAsync = promisify(exec); const execAsync = promisify(exec);
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
@@ -22,15 +24,10 @@ export interface IUpsConfig {
name: string; name: string;
/** SNMP configuration settings */ /** SNMP configuration settings */
snmp: ISnmpConfig; snmp: ISnmpConfig;
/** Threshold settings for initiating shutdown */
thresholds: {
/** Shutdown when battery below this percentage */
battery: number;
/** Shutdown when runtime below this minutes */
runtime: number;
};
/** Group IDs this UPS belongs to */ /** Group IDs this UPS belongs to */
groups: string[]; groups: string[];
/** Actions to trigger on power status changes and threshold violations */
actions?: IActionConfig[];
} }
/** /**
@@ -45,6 +42,8 @@ export interface IGroupConfig {
mode: 'redundant' | 'nonRedundant'; mode: 'redundant' | 'nonRedundant';
/** Optional description */ /** Optional description */
description?: string; description?: string;
/** Actions to trigger on power status changes and threshold violations */
actions?: IActionConfig[];
} }
/** /**
@@ -77,7 +76,7 @@ export interface INupstConfig {
/** /**
* UPS status tracking interface * UPS status tracking interface
*/ */
interface IUpsStatus { export interface IUpsStatus {
id: string; id: string;
name: string; name: string;
powerStatus: 'online' | 'onBattery' | 'unknown'; powerStatus: 'online' | 'onBattery' | 'unknown';
@@ -97,7 +96,7 @@ export class NupstDaemon {
/** Default configuration */ /** Default configuration */
private readonly DEFAULT_CONFIG: INupstConfig = { private readonly DEFAULT_CONFIG: INupstConfig = {
version: '4.0', version: '4.2',
upsDevices: [ upsDevices: [
{ {
id: 'default', id: 'default',
@@ -118,16 +117,23 @@ export class NupstDaemon {
// UPS model for OID selection // UPS model for OID selection
upsModel: 'cyberpower', upsModel: 'cyberpower',
}, },
groups: [],
actions: [
{
type: 'shutdown',
triggerMode: 'onlyThresholds',
thresholds: { thresholds: {
battery: 60, // Shutdown when battery below 60% battery: 60, // Shutdown when battery below 60%
runtime: 20, // Shutdown when runtime below 20 minutes runtime: 20, // Shutdown when runtime below 20 minutes
}, },
groups: [], shutdownDelay: 5,
},
],
}, },
], ],
groups: [], groups: [],
checkInterval: 30000, // Check every 30 seconds checkInterval: 30000, // Check every 30 seconds
}; }
private config: INupstConfig; private config: INupstConfig;
private snmp: NupstSnmp; private snmp: NupstSnmp;
@@ -165,11 +171,13 @@ export class NupstDaemon {
const { config: migratedConfig, migrated } = await migrationRunner.run(parsedConfig); const { config: migratedConfig, migrated } = await migrationRunner.run(parsedConfig);
// Save migrated config back to disk if any migrations ran // Save migrated config back to disk if any migrations ran
// Cast to INupstConfig since migrations ensure the output is valid
const validConfig = migratedConfig as unknown as INupstConfig;
if (migrated) { if (migrated) {
this.config = migratedConfig; this.config = validConfig;
await this.saveConfig(this.config); await this.saveConfig(this.config);
} else { } else {
this.config = migratedConfig; this.config = validConfig;
} }
return this.config; return this.config;
@@ -199,7 +207,7 @@ export class NupstDaemon {
// Ensure version is always set and remove legacy fields before saving // Ensure version is always set and remove legacy fields before saving
const configToSave: INupstConfig = { const configToSave: INupstConfig = {
version: '4.0', version: '4.1',
upsDevices: config.upsDevices, upsDevices: config.upsDevices,
groups: config.groups, groups: config.groups,
checkInterval: config.checkInterval, checkInterval: config.checkInterval,
@@ -326,14 +334,14 @@ export class NupstDaemon {
{ header: 'Name', key: 'name', align: 'left', color: theme.highlight }, { header: 'Name', key: 'name', align: 'left', color: theme.highlight },
{ header: 'ID', key: 'id', align: 'left', color: theme.dim }, { header: 'ID', key: 'id', align: 'left', color: theme.dim },
{ header: 'Host:Port', key: 'host', align: 'left', color: theme.info }, { header: 'Host:Port', key: 'host', align: 'left', color: theme.info },
{ header: 'Battery/Runtime', key: 'thresholds', align: 'left' }, { header: 'Actions', key: 'actions', align: 'left' },
]; ];
const upsRows: Array<Record<string, string>> = this.config.upsDevices.map((ups) => ({ const upsRows: Array<Record<string, string>> = this.config.upsDevices.map((ups) => ({
name: ups.name, name: ups.name,
id: ups.id, id: ups.id,
host: `${ups.snmp.host}:${ups.snmp.port}`, host: `${ups.snmp.host}:${ups.snmp.port}`,
thresholds: `${ups.thresholds.battery}% / ${ups.thresholds.runtime} min`, actions: `${(ups.actions || []).length} configured`,
})); }));
logger.logTable(upsColumns, upsRows); logger.logTable(upsColumns, upsRows);
@@ -401,9 +409,6 @@ export class NupstDaemon {
lastLogTime = currentTime; lastLogTime = currentTime;
} }
// Check if shutdown is required based on group configurations
await this.evaluateGroupShutdownConditions();
// Wait before next check // Wait before next check
await this.sleep(this.config.checkInterval); await this.sleep(this.config.checkInterval);
} catch (error) { } catch (error) {
@@ -466,6 +471,33 @@ export class NupstDaemon {
logger.log(''); logger.log('');
updatedStatus.lastStatusChange = currentTime; updatedStatus.lastStatusChange = currentTime;
// Trigger actions for power status change
await this.triggerUpsActions(ups, updatedStatus, currentStatus, 'powerStatusChange');
}
// Check if any action's thresholds are exceeded (for threshold violation triggers)
// Only check when on battery power
if (status.powerStatus === 'onBattery' && ups.actions && ups.actions.length > 0) {
let anyThresholdExceeded = false;
for (const actionConfig of ups.actions) {
if (actionConfig.thresholds) {
if (
status.batteryCapacity < actionConfig.thresholds.battery ||
status.batteryRuntime < actionConfig.thresholds.runtime
) {
anyThresholdExceeded = true;
break;
}
}
}
// Trigger actions with threshold violation reason if any threshold is exceeded
// Actions will individually check their own thresholds in shouldExecute()
if (anyThresholdExceeded) {
await this.triggerUpsActions(ups, updatedStatus, currentStatus, 'thresholdViolation');
}
} }
// Update the status in the map // Update the status in the map
@@ -519,154 +551,66 @@ export class NupstDaemon {
logger.log(''); logger.log('');
} }
/** /**
* Evaluate if shutdown is required based on group configurations * 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 async evaluateGroupShutdownConditions(): Promise<void> { private buildActionContext(
if (!this.config.groups || this.config.groups.length === 0) { ups: IUpsConfig,
// No groups defined, check individual UPS conditions status: IUpsStatus,
for (const [id, status] of this.upsStatus.entries()) { triggerReason: 'powerStatusChange' | 'thresholdViolation',
if (status.powerStatus === 'onBattery') { ): IActionContext {
// Find the UPS config return {
const ups = this.config.upsDevices.find((u) => u.id === id); upsId: ups.id,
if (ups) { upsName: ups.name,
await this.evaluateUpsShutdownCondition(ups, status); powerStatus: status.powerStatus as TPowerStatus,
} batteryCapacity: status.batteryCapacity,
} batteryRuntime: status.batteryRuntime,
} previousPowerStatus: 'unknown' as TPowerStatus, // Will be set from map in calling code
return; timestamp: Date.now(),
} triggerReason,
};
// 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 * Trigger actions for a UPS device
* In redundant mode, we only shut down if ALL UPS devices are in critical condition * @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 evaluateRedundantGroup( private async triggerUpsActions(
group: IGroupConfig, ups: IUpsConfig,
upsDevices: IUpsConfig[], status: IUpsStatus,
previousStatus: IUpsStatus | undefined,
triggerReason: 'powerStatusChange' | 'thresholdViolation',
): Promise<void> { ): Promise<void> {
// Count UPS devices on battery and in critical condition const actions = ups.actions || [];
let upsOnBattery = 0;
let upsInCriticalCondition = 0;
for (const ups of upsDevices) {
const status = this.upsStatus.get(ups.id);
if (!status) continue;
if (status.powerStatus === 'onBattery') {
upsOnBattery++;
// Check if this UPS is in critical condition
if (
status.batteryCapacity < ups.thresholds.battery ||
status.batteryRuntime < ups.thresholds.runtime
) {
upsInCriticalCondition++;
}
}
}
// All UPS devices must be online for a redundant group to be considered healthy
const allUpsCount = upsDevices.length;
// If all UPS are on battery and in critical condition, shutdown
if (upsOnBattery === allUpsCount && upsInCriticalCondition === allUpsCount) {
logger.logBoxTitle(`Group Shutdown Required: ${group.name}`, 50);
logger.logBoxLine(`Mode: Redundant`);
logger.logBoxLine(`All ${allUpsCount} UPS devices in critical condition`);
logger.logBoxEnd();
await this.initiateShutdown(
`All UPS devices in redundant group "${group.name}" in critical condition`,
);
}
}
/**
* Evaluate a non-redundant group for shutdown conditions
* In non-redundant mode, we shut down if ANY UPS device is in critical condition
*/
private async evaluateNonRedundantGroup(
group: IGroupConfig,
upsDevices: IUpsConfig[],
): Promise<void> {
for (const ups of upsDevices) {
const status = this.upsStatus.get(ups.id);
if (!status) continue;
if (status.powerStatus === 'onBattery') {
// Check if this UPS is in critical condition
if (
status.batteryCapacity < ups.thresholds.battery ||
status.batteryRuntime < ups.thresholds.runtime
) {
logger.logBoxTitle(`Group Shutdown Required: ${group.name}`, 50);
logger.logBoxLine(`Mode: Non-Redundant`);
logger.logBoxLine(`UPS ${ups.name} in critical condition`);
logger.logBoxLine(
`Battery: ${status.batteryCapacity}% (threshold: ${ups.thresholds.battery}%)`,
);
logger.logBoxLine(
`Runtime: ${status.batteryRuntime} min (threshold: ${ups.thresholds.runtime} min)`,
);
logger.logBoxEnd();
await this.initiateShutdown(
`UPS "${ups.name}" in non-redundant group "${group.name}" in critical condition`,
);
return; // Exit after initiating shutdown
}
}
}
}
/**
* Evaluate an individual UPS for shutdown conditions
*/
private async evaluateUpsShutdownCondition(ups: IUpsConfig, status: IUpsStatus): Promise<void> {
// Only evaluate UPS devices not in any group
if (ups.groups && ups.groups.length > 0) {
return;
}
// Check threshold conditions
if (
status.batteryCapacity < ups.thresholds.battery ||
status.batteryRuntime < ups.thresholds.runtime
) {
logger.logBoxTitle(`UPS Shutdown Required: ${ups.name}`, 50);
logger.logBoxLine(
`Battery: ${status.batteryCapacity}% (threshold: ${ups.thresholds.battery}%)`,
);
logger.logBoxLine(
`Runtime: ${status.batteryRuntime} min (threshold: ${ups.thresholds.runtime} min)`,
);
logger.logBoxEnd();
// Backward compatibility: if no actions configured, use default shutdown behavior
if (actions.length === 0 && triggerReason === 'thresholdViolation') {
// Fall back to old shutdown logic for backward compatibility
await this.initiateShutdown(`UPS "${ups.name}" battery or runtime below threshold`); await this.initiateShutdown(`UPS "${ups.name}" battery or runtime below threshold`);
return;
} }
if (actions.length === 0) {
return; // No actions to execute
}
// Build action context
const context = this.buildActionContext(ups, status, triggerReason);
context.previousPowerStatus = (previousStatus?.powerStatus || 'unknown') as TPowerStatus;
// Execute actions
await ActionManager.executeActions(actions, context);
} }
/** /**
@@ -818,7 +762,7 @@ export class NupstDaemon {
const rows: Array<Record<string, string>> = []; const rows: Array<Record<string, string>> = [];
let emergencyDetected = false; let emergencyDetected = false;
let emergencyUps: any = null; let emergencyUps: { ups: IUpsConfig; status: ISnmpUpsStatus } | null = null;
// Check all UPS devices // Check all UPS devices
for (const ups of this.config.upsDevices) { for (const ups of this.config.upsDevices) {

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
import { BaseMigration } from './base-migration.ts'; import { BaseMigration } from './base-migration.ts';
import { MigrationV1ToV2 } from './migration-v1-to-v2.ts'; import { MigrationV1ToV2 } from './migration-v1-to-v2.ts';
import { MigrationV3ToV4 } from './migration-v3-to-v4.ts'; import { MigrationV3ToV4 } from './migration-v3-to-v4.ts';
import { MigrationV4_0ToV4_1 } from './migration-v4.0-to-v4.1.ts';
import { logger } from '../logger.ts'; import { logger } from '../logger.ts';
/** /**
@@ -17,11 +18,12 @@ export class MigrationRunner {
this.migrations = [ this.migrations = [
new MigrationV1ToV2(), new MigrationV1ToV2(),
new MigrationV3ToV4(), new MigrationV3ToV4(),
// Add future migrations here (v4→v5, v5→v6, etc.) new MigrationV4_0ToV4_1(),
// Add future migrations here (v4.3, v4.4, etc.)
]; ];
// Sort by order to ensure they run in sequence // Sort by version order to ensure they run in sequence
this.migrations.sort((a, b) => a.order - b.order); this.migrations.sort((a, b) => a.getVersionOrder() - b.getVersionOrder());
} }
/** /**
@@ -30,7 +32,9 @@ export class MigrationRunner {
* @param config - Raw configuration object to migrate * @param config - Raw configuration object to migrate
* @returns Migrated configuration and whether migrations ran * @returns Migrated configuration and whether migrations ran
*/ */
async run(config: any): Promise<{ config: any; migrated: boolean }> { async run(
config: Record<string, unknown>,
): Promise<{ config: Record<string, unknown>; migrated: boolean }> {
let currentConfig = config; let currentConfig = config;
let anyMigrationsRan = false; let anyMigrationsRan = false;

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,8 @@
import process from 'node:process'; import process from 'node:process';
import { promises as fs } from 'node:fs'; import { promises as fs } from 'node:fs';
import { execSync } from 'node:child_process'; import { execSync } from 'node:child_process';
import { NupstDaemon } from './daemon.ts'; import { NupstDaemon, type IUpsConfig } from './daemon.ts';
import { NupstSnmp } from './snmp/manager.ts';
import { logger } from './logger.ts'; import { logger } from './logger.ts';
import { theme, symbols, getBatteryColor, getRuntimeColor, formatPowerStatus } from './colors.ts'; import { theme, symbols, getBatteryColor, getRuntimeColor, formatPowerStatus } from './colors.ts';
@@ -276,15 +277,27 @@ WantedBy=multi-user.target
for (const ups of config.upsDevices) { for (const ups of config.upsDevices) {
await this.displaySingleUpsStatus(ups, snmp); await this.displaySingleUpsStatus(ups, snmp);
} }
// Display groups after UPS devices
this.displayGroupsStatus();
} else if (config.snmp) { } else if (config.snmp) {
// Legacy single UPS configuration // Legacy single UPS configuration (v1/v2 format)
logger.info('UPS Devices (1):'); logger.info('UPS Devices (1):');
const legacyUps = { const legacyUps: IUpsConfig = {
id: 'default', id: 'default',
name: 'Default UPS', name: 'Default UPS',
snmp: config.snmp, snmp: config.snmp,
thresholds: config.thresholds,
groups: [], groups: [],
actions: config.thresholds
? [
{
type: 'shutdown',
thresholds: config.thresholds,
triggerMode: 'onlyThresholds',
shutdownDelay: 5,
},
]
: [],
}; };
await this.displaySingleUpsStatus(legacyUps, snmp); await this.displaySingleUpsStatus(legacyUps, snmp);
@@ -307,7 +320,7 @@ WantedBy=multi-user.target
* @param ups UPS configuration * @param ups UPS configuration
* @param snmp SNMP manager * @param snmp SNMP manager
*/ */
private async displaySingleUpsStatus(ups: any, snmp: any): Promise<void> { private async displaySingleUpsStatus(ups: IUpsConfig, snmp: NupstSnmp): Promise<void> {
try { try {
// Create a test config with a short timeout // Create a test config with a short timeout
const testConfig = { const testConfig = {
@@ -330,7 +343,16 @@ WantedBy=multi-user.target
// Display battery with color coding // Display battery with color coding
const batteryColor = getBatteryColor(status.batteryCapacity); const batteryColor = getBatteryColor(status.batteryCapacity);
const batterySymbol = status.batteryCapacity >= ups.thresholds.battery ? symbols.success : symbols.warning;
// 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')}`); logger.log(` Battery: ${batteryColor(status.batteryCapacity + '%')} ${batterySymbol} Runtime: ${getRuntimeColor(status.batteryRuntime)(status.batteryRuntime + ' min')}`);
// Display host info // Display host info
@@ -346,6 +368,27 @@ WantedBy=multi-user.target
logger.log(` ${theme.dim(`Groups: ${groupNames.join(', ')}`)}`); logger.log(` ${theme.dim(`Groups: ${groupNames.join(', ')}`)}`);
} }
// Display actions if any
if (ups.actions && ups.actions.length > 0) {
for (const action of ups.actions) {
let actionDesc = `${action.type}`;
if (action.thresholds) {
actionDesc += ` (${action.triggerMode || 'onlyThresholds'}: battery<${action.thresholds.battery}%, runtime<${action.thresholds.runtime}min`;
if (action.shutdownDelay) {
actionDesc += `, delay=${action.shutdownDelay}s`;
}
actionDesc += ')';
} else {
actionDesc += ` (${action.triggerMode || 'onlyPowerChanges'}`;
if (action.shutdownDelay) {
actionDesc += `, delay=${action.shutdownDelay}s`;
}
actionDesc += ')';
}
logger.log(` ${theme.dim('Action:')} ${theme.info(actionDesc)}`);
}
}
logger.log(''); logger.log('');
} catch (error) { } catch (error) {
@@ -357,6 +400,69 @@ WantedBy=multi-user.target
} }
} }
/**
* Display status of all groups
* @private
*/
private displayGroupsStatus(): void {
const config = this.daemon.getConfig();
if (!config.groups || config.groups.length === 0) {
return; // No groups to display
}
logger.log('');
logger.info(`Groups (${config.groups.length}):`);
for (const group of config.groups) {
// Display group name and mode
const modeColor = group.mode === 'redundant' ? theme.success : theme.warning;
logger.log(
` ${symbols.info} ${theme.highlight(group.name)} ${theme.dim(`(${modeColor(group.mode)})`)}`,
);
// Display description if present
if (group.description) {
logger.log(` ${theme.dim(group.description)}`);
}
// Display UPS devices in this group
const upsInGroup = config.upsDevices.filter((ups) =>
ups.groups && ups.groups.includes(group.id)
);
if (upsInGroup.length > 0) {
const upsNames = upsInGroup.map((ups) => ups.name).join(', ');
logger.log(` ${theme.dim(`UPS Devices (${upsInGroup.length}):`)} ${upsNames}`);
} else {
logger.log(` ${theme.dim('UPS Devices: None')}`);
}
// Display actions if any
if (group.actions && group.actions.length > 0) {
for (const action of group.actions) {
let actionDesc = `${action.type}`;
if (action.thresholds) {
actionDesc += ` (${action.triggerMode || 'onlyThresholds'}: battery<${action.thresholds.battery}%, runtime<${action.thresholds.runtime}min`;
if (action.shutdownDelay) {
actionDesc += `, delay=${action.shutdownDelay}s`;
}
actionDesc += ')';
} else {
actionDesc += ` (${action.triggerMode || 'onlyPowerChanges'}`;
if (action.shutdownDelay) {
actionDesc += `, delay=${action.shutdownDelay}s`;
}
actionDesc += ')';
}
logger.log(` ${theme.dim('Action:')} ${theme.info(actionDesc)}`);
}
}
logger.log('');
}
}
/** /**
* Disable and uninstall the systemd service * Disable and uninstall the systemd service
* @throws Error if disabling fails * @throws Error if disabling fails