Compare commits

...

36 Commits

Author SHA1 Message Date
263d69aef1 chore(release): bump version to 4.2.5
Some checks failed
CI / Type Check & Lint (push) Failing after 5s
CI / Build Test (Current Platform) (push) Successful in 5s
Release / build-and-release (push) Successful in 42s
CI / Build All Platforms (push) Successful in 47s
2025-10-20 12:27:06 +00:00
b6b7b43161 refactor: replace 'any' types with proper TypeScript interfaces
Major type safety improvements throughout the codebase:

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

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

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

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

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

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

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

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

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

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

Fixes issue where process would not exit after "Setup complete!" message
due to stdin keeping the event loop alive.
2025-10-20 00:32:06 +00:00
bb87316dd3 fix(snmp): correct power status interpretation using OID set mappings
All checks were successful
CI / Type Check & Lint (push) Successful in 7s
CI / Build Test (Current Platform) (push) Successful in 5s
Release / build-and-release (push) Successful in 44s
CI / Build All Platforms (push) Successful in 49s
Move power status value interpretation from hardcoded logic to OID set configuration.
Each UPS model now defines its own value mappings (e.g., CyberPower: 2=online, 3=onBattery).

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

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

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

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

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

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

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

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

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

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

  ✓ Already up to date!
2025-10-19 22:50:03 +00:00
936f86c346 fix(update): rewrite nupst update for v4 (download install script instead of git pull)
All checks were successful
CI / Type Check & Lint (push) Successful in 6s
CI / Build Test (Current Platform) (push) Successful in 6s
Release / build-and-release (push) Successful in 45s
CI / Build All Platforms (push) Successful in 50s
The update command was still using v3 logic (git pull, setup.sh) which
doesn't work for v4 binary distribution.

Now it simply:
1. Downloads install.sh from main branch
2. Runs it (handles download, stop, replace, restart automatically)

This is much simpler and matches how v4 is distributed. No more git,
no more setup.sh, just download the latest binary.
2025-10-19 21:54:05 +00:00
7ff1a7da36 feat(cli): replace ugly ASCII boxes with beautiful colored status output
All checks were successful
CI / Type Check & Lint (push) Successful in 6s
CI / Build Test (Current Platform) (push) Successful in 5s
Release / build-and-release (push) Successful in 48s
CI / Build All Platforms (push) Successful in 52s
Replaced all ASCII box characters (┌─┐│└┘) with modern, clean colored
output using the existing color theme and symbols.

Changes in ts/systemd.ts:
- displayServiceStatus(): Parse systemctl output and show key info
  with colored symbols (● for running, ○ for stopped)
- displaySingleUpsStatus(): Clean output with battery/runtime colors
  - Green >60%, yellow 30-60%, red <30% for battery
  - Power status with colored symbols and text
  - Clean indented layout without boxes

Example new output:
  ● Service: active (running)
    PID: 7606  Memory: 41.5M  CPU: 279ms

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

Much cleaner and more readable than ASCII boxes!
2025-10-19 21:50:31 +00:00
a87710144c fix(migration): detect flat structure in upsDevices for proper v3→v4 migration
All checks were successful
CI / Type Check & Lint (push) Successful in 6s
CI / Build Test (Current Platform) (push) Successful in 6s
CI / Build All Platforms (push) Successful in 52s
Release / build-and-release (push) Successful in 50s
The previous migration only checked for upsList field, but saveConfig()
strips upsList when saving, creating a race condition. If the daemon
restarted with a partially-migrated config (upsDevices with flat structure),
the migration wouldn't run because it only looked for upsList.

Now shouldRun() also detects:
- upsDevices with flat structure (host at top level, no snmp object)

And migrate() handles both:
- config.upsList (pure v3)
- config.upsDevices with flat structure (partially migrated)

This fixes the "Cannot read properties of undefined (reading 'host')"
error that occurred when configs had upsDevices but flat structure.
2025-10-19 21:41:50 +00:00
23fd5cc5cd refactor(install): remove interactive mode, simplify installation
All checks were successful
CI / Type Check & Lint (push) Successful in 6s
CI / Build Test (Current Platform) (push) Successful in 5s
CI / Build All Platforms (push) Successful in 50s
Interactive mode was causing issues with automated testing and the
nupst update command (failed with /dev/tty errors). Since users
running curl|bash have already decided to install, prompts add no
value and only create friction.

Changes:
- Removed -y/--yes flag (no longer needed)
- Removed all interactive confirmation prompts
- Removed terminal detection logic (/dev/tty handling)
- Updated README to remove all -y flag references
- Simplified installation examples

Benefits:
- Works in all environments (piped, non-interactive, containers)
- Fixes nupst update command
- Cleaner user experience
- Matches standard install script patterns (homebrew, rustup, etc.)
2025-10-19 21:37:41 +00:00
fb4d776bdd fix(migration): properly transform v3 flat structure to v4 nested snmp config
All checks were successful
CI / Type Check & Lint (push) Successful in 6s
CI / Build Test (Current Platform) (push) Successful in 8s
Release / build-and-release (push) Successful in 48s
CI / Build All Platforms (push) Successful in 51s
The v3→v4 migration was only renaming upsList to upsDevices without
transforming the device structure. V3 had a flat structure with SNMP
fields directly on the device object, while v4 expects a nested 'snmp'
object.

This commit fixes the migration to:
- Move host, port, community, version, etc. into nested snmp object
- Convert version from string to number
- Add default timeout (5000ms)
- Create thresholds object with defaults
- Preserve all SNMPv1, v2c, and v3 authentication fields

Also includes install.sh fix for better non-interactive handling.
2025-10-19 21:32:55 +00:00
88ad16c638 chore: bump version to 4.0.3
All checks were successful
CI / Type Check & Lint (push) Successful in 6s
CI / Build Test (Current Platform) (push) Successful in 6s
Release / build-and-release (push) Successful in 46s
CI / Build All Platforms (push) Successful in 51s
2025-10-19 20:41:39 +00:00
016681b77b feat(migrations): add migration system for v3→v4 config format
- Create abstract BaseMigration class with order, shouldRun(), migrate()
- Add MigrationRunner to execute migrations in order
- Add Migration v1→v2 (snmp → upsDevices)
- Add Migration v3→v4 (upsList → upsDevices)
- Update INupstConfig with version field
- Update loadConfig() to run migrations automatically
- Update saveConfig() to ensure version field and remove legacy fields
- Update Docker test scripts to use real UPS data from .nogit/env.json
- Remove colors.bright (not available in @std/fmt/colors)

Config migrations allow users to jump versions (e.g., v1→v4) with all
intermediate migrations running automatically. Version field tracks
config format for future migrations.
2025-10-19 20:41:09 +00:00
49f7a7da8b Merge pull request 'migration/deno-v4' (#1) from migration/deno-v4 into main
Some checks failed
CI / Type Check & Lint (push) Failing after 6s
CI / Build Test (Current Platform) (push) Successful in 6s
CI / Build All Platforms (push) Successful in 47s
Reviewed-on: #1
2025-10-19 15:14:03 +00:00
f8269a1cb7 feat(cli): add beautiful colored output and fix daemon exit bug
Some checks failed
CI / Type Check & Lint (push) Failing after 6s
CI / Build Test (Current Platform) (push) Successful in 6s
Release / build-and-release (push) Successful in 44s
CI / Build All Platforms (push) Successful in 50s
CI / Type Check & Lint (pull_request) Failing after 5s
CI / Build Test (Current Platform) (pull_request) Successful in 5s
CI / Build All Platforms (pull_request) Successful in 49s
Major improvements:
- Created color theme system (ts/colors.ts) with ANSI colors
- Enhanced logger with colors, table formatting, and styled boxes
- Fixed daemon exit bug - now stays running when no UPS configured
- Added config hot-reload with file watcher for live updates
- Beautified CLI help output with color-coded commands
- Added showcase test demonstrating all output features
- Fixed ANSI code handling for perfect table/box alignment

Features:
- Color-coded messages (success=green, error=red, warning=yellow, info=cyan)
- Status symbols (●○◐◯ for running/stopped/starting/unknown)
- Battery level colors (green>60%, yellow 30-60%, red<30%)
- Table formatting with auto-sizing and column alignment
- Styled boxes (success, error, warning, info styles)
- Hot-reload: daemon watches config file and reloads automatically
- Idle mode: daemon stays alive when no devices, checks periodically

Daemon improvements:
- No longer exits when no UPS devices configured
- Enters idle monitoring loop waiting for config
- File watcher detects config changes in real-time
- Auto-reloads and starts monitoring when devices added
- Logs warnings instead of errors for missing devices

Technical fixes:
- Strip ANSI codes when calculating text width for alignment
- Use visible length for padding calculations in tables and boxes
- Properly handle colored text in table cells and box lines

Breaking changes: None (backward compatible)
2025-10-19 15:08:30 +00:00
31 changed files with 3221 additions and 930 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "@serve.zone/nupst", "name": "@serve.zone/nupst",
"version": "4.0.1", "version": "4.2.5",
"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

@@ -10,15 +10,7 @@
# 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 v4.0.0
# #
# Non-interactive mode (auto-confirm):
# curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash -s -- -y
#
# Downloaded script:
# curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh -o nupst-install.sh
# sudo bash nupst-install.sh
#
# Options: # Options:
# -y, --yes Automatically answer yes to all prompts
# -h, --help Show this help message # -h, --help Show this help message
# --version VERSION Install specific version (e.g., v4.0.0) # --version VERSION Install specific version (e.g., v4.0.0)
# --install-dir DIR Installation directory (default: /opt/nupst) # --install-dir DIR Installation directory (default: /opt/nupst)
@@ -26,7 +18,6 @@
set -e set -e
# Default values # Default values
AUTO_YES=0
SHOW_HELP=0 SHOW_HELP=0
SPECIFIED_VERSION="" SPECIFIED_VERSION=""
INSTALL_DIR="/opt/nupst" INSTALL_DIR="/opt/nupst"
@@ -36,10 +27,6 @@ GITEA_REPO="serve.zone/nupst"
# Parse command line arguments # Parse command line arguments
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case $1 in case $1 in
-y|--yes)
AUTO_YES=1
shift
;;
-h|--help) -h|--help)
SHOW_HELP=1 SHOW_HELP=1
shift shift
@@ -67,7 +54,6 @@ if [ $SHOW_HELP -eq 1 ]; then
echo "Usage: $0 [options]" echo "Usage: $0 [options]"
echo "" echo ""
echo "Options:" echo "Options:"
echo " -y, --yes Automatically answer yes to all prompts"
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., v4.0.0)"
echo " --install-dir DIR Installation directory (default: /opt/nupst)" echo " --install-dir DIR Installation directory (default: /opt/nupst)"
@@ -78,9 +64,6 @@ if [ $SHOW_HELP -eq 1 ]; then
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 v4.0.0"
echo ""
echo " # Non-interactive installation"
echo " curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash -s -- -y"
exit 0 exit 0
fi fi
@@ -90,32 +73,6 @@ if [ "$EUID" -ne 0 ]; then
exit 1 exit 1
fi fi
# Detect if script is being piped or run directly
INTERACTIVE=1
if [ ! -t 0 ] || [ ! -t 1 ]; then
# Either stdin or stdout is not a terminal
if [ $AUTO_YES -ne 1 ]; then
echo "Script detected it's running in a non-interactive environment without -y flag."
echo "Attempting to find a controlling terminal for interactive prompts..."
# Try to use a controlling terminal for user input
exec < /dev/tty 2>/dev/null || INTERACTIVE=0
if [ $INTERACTIVE -eq 0 ]; then
echo "ERROR: No controlling terminal available for interactive prompts."
echo ""
echo "For interactive installation (RECOMMENDED):"
echo " curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh -o nupst-install.sh"
echo " sudo bash nupst-install.sh"
echo ""
echo "For non-interactive installation with auto-confirm:"
echo " curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash -s -- -y"
exit 1
else
echo "Interactive terminal found, continuing with prompts..."
fi
fi
fi
# Helper function to detect OS and architecture # Helper function to detect OS and architecture
detect_platform() { detect_platform() {
local os=$(uname -s) local os=$(uname -s)
@@ -225,22 +182,6 @@ if [ -d "$INSTALL_DIR" ]; then
echo "" echo ""
fi fi
if [ $AUTO_YES -eq 0 ] && [ $INTERACTIVE -eq 1 ]; then
if [ $OLD_NODE_INSTALL -eq 1 ]; then
echo "This will replace your Node.js installation with a pre-compiled binary."
echo "Your configuration in /etc/nupst/config.json will be preserved."
echo ""
fi
echo "Installation directory already exists: $INSTALL_DIR"
echo "Do you want to update/reinstall? (Y/n): "
read -r update_confirm
if [[ "$update_confirm" =~ ^[Nn]$ ]]; then
echo "Installation cancelled."
exit 0
fi
fi
echo "Updating existing installation at $INSTALL_DIR..." echo "Updating existing installation at $INSTALL_DIR..."
# Check if service exists (enabled or running) and stop it if active # Check if service exists (enabled or running) and stop it if active
@@ -269,17 +210,6 @@ if [ -d "$INSTALL_DIR" ]; then
echo "Old installation files removed." echo "Old installation files removed."
fi fi
else else
if [ $AUTO_YES -eq 0 ] && [ $INTERACTIVE -eq 1 ]; then
echo "NUPST will be installed to: $INSTALL_DIR"
echo "Continue? (Y/n): "
read -r install_confirm
if [[ "$install_confirm" =~ ^[Nn]$ ]]; then
echo "Installation cancelled."
exit 0
fi
fi
echo "Creating installation directory: $INSTALL_DIR" echo "Creating installation directory: $INSTALL_DIR"
mkdir -p "$INSTALL_DIR" mkdir -p "$INSTALL_DIR"
fi fi
@@ -325,22 +255,8 @@ else
fi fi
# Create symlink for global access # Create symlink for global access
if [ $AUTO_YES -eq 0 ] && [ $INTERACTIVE -eq 1 ]; then ln -sf "$BINARY_PATH" "$BIN_DIR/nupst"
echo "Create symlink in $BIN_DIR for global access? (Y/n): " echo "Symlink created: $BIN_DIR/nupst -> $BINARY_PATH"
read -r symlink_confirm
if [[ ! "$symlink_confirm" =~ ^[Nn]$ ]]; then
ln -sf "$BINARY_PATH" "$BIN_DIR/nupst"
echo "Symlink created: $BIN_DIR/nupst -> $BINARY_PATH"
else
echo "Symlink creation skipped."
echo "To use NUPST, run: $BINARY_PATH"
echo "Or manually create symlink: sudo ln -sf $BINARY_PATH $BIN_DIR/nupst"
fi
else
ln -sf "$BINARY_PATH" "$BIN_DIR/nupst"
echo "Symlink created: $BIN_DIR/nupst -> $BINARY_PATH"
fi
echo "" echo ""

View File

@@ -29,15 +29,8 @@ dependencies.
The easiest way to install NUPST is using the automated installer: The easiest way to install NUPST is using the automated installer:
```bash ```bash
# Download and run installer (most reliable) # One-line installation
curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh -o nupst-install.sh curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash
sudo bash nupst-install.sh
rm nupst-install.sh
```
```bash
# One-line installation (non-interactive with auto-confirm)
curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash -s -- -y
``` ```
The installer will: The installer will:
@@ -76,7 +69,6 @@ sudo mv nupst /usr/local/bin/nupst
The installer script (`install.sh`) supports the following options: The installer script (`install.sh`) supports the following options:
``` ```
-y, --yes Automatically answer yes to all prompts
-h, --help Show help message -h, --help Show help message
--version VERSION Install specific version (e.g., --version v4.0.0) --version VERSION Install specific version (e.g., --version v4.0.0)
--install-dir DIR Custom installation directory (default: /opt/nupst) --install-dir DIR Custom installation directory (default: /opt/nupst)
@@ -373,7 +365,7 @@ sudo nupst service disable
Re-run the installer to update to the latest version: Re-run the installer to update to the latest version:
```bash ```bash
curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash -s -- -y curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash
``` ```
The installer will: The installer will:
@@ -461,7 +453,7 @@ The installer script automatically handles the entire migration while preserving
```bash ```bash
# Run the installer (handles stop/update/restart automatically) # Run the installer (handles stop/update/restart automatically)
curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash -s -- -y curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash
# Verify # Verify
nupst service status nupst service status

View File

@@ -0,0 +1,168 @@
#!/bin/bash
#
# Test fresh v4 installation from scratch
# Tests the most common user scenario: clean install using curl | bash
#
set -e
CONTAINER_NAME="nupst-test-fresh-v4"
echo "================================================"
echo " NUPST Fresh v4 Installation Test"
echo "================================================"
echo ""
# Check if container already exists
if docker ps -a --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
echo "⚠️ Container ${CONTAINER_NAME} already exists"
read -p "Remove and recreate? (y/N): " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
echo "→ Stopping and removing existing container..."
docker stop ${CONTAINER_NAME} 2>/dev/null || true
docker rm ${CONTAINER_NAME} 2>/dev/null || true
else
echo "Exiting. Remove manually with: docker rm -f ${CONTAINER_NAME}"
exit 1
fi
fi
echo "→ Creating Docker container with systemd..."
docker run -d \
--name ${CONTAINER_NAME} \
--privileged \
--cgroupns=host \
-v /sys/fs/cgroup:/sys/fs/cgroup:rw \
ubuntu:22.04 \
/bin/bash -c "apt-get update && apt-get install -y systemd systemd-sysv && exec /sbin/init"
echo "→ Waiting for systemd to initialize..."
sleep 10
echo "→ Waiting for dpkg lock to be released..."
docker exec ${CONTAINER_NAME} bash -c "
while fuser /var/lib/dpkg/lock-frontend >/dev/null 2>&1; do
echo ' Waiting for dpkg lock...'
sleep 2
done
echo ' dpkg lock released'
"
echo "→ Installing prerequisites (curl)..."
docker exec ${CONTAINER_NAME} bash -c "
apt-get update -qq
apt-get install -y -qq curl
"
echo ""
echo "→ Installing NUPST v4 using curl | bash..."
echo " Command: curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | bash -s -- -y"
echo ""
docker exec ${CONTAINER_NAME} bash -c "
curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | bash -s -- -y
"
echo ""
echo "================================================"
echo " Verifying Installation"
echo "================================================"
echo ""
echo "→ Checking binary location..."
docker exec ${CONTAINER_NAME} bash -c "
if [ -f /opt/nupst/nupst ]; then
echo ' ✓ Binary exists at /opt/nupst/nupst'
ls -lh /opt/nupst/nupst
else
echo ' ✗ Binary not found at /opt/nupst/nupst'
exit 1
fi
"
echo ""
echo "→ Checking symlink..."
docker exec ${CONTAINER_NAME} bash -c "
if [ -L /usr/local/bin/nupst ]; then
echo ' ✓ Symlink exists at /usr/local/bin/nupst'
ls -lh /usr/local/bin/nupst
elif [ -L /usr/bin/nupst ]; then
echo ' ✓ Symlink exists at /usr/bin/nupst'
ls -lh /usr/bin/nupst
else
echo ' ✗ Symlink not found in /usr/local/bin or /usr/bin'
exit 1
fi
"
echo ""
echo "→ Checking PATH integration..."
docker exec ${CONTAINER_NAME} bash -c "
NUPST_PATH=\$(which nupst 2>/dev/null)
if [ -n \"\$NUPST_PATH\" ]; then
echo ' ✓ nupst found in PATH at: '\$NUPST_PATH
else
echo ' ✗ nupst not found in PATH'
echo ' PATH contents:'
echo \$PATH
exit 1
fi
"
echo ""
echo "→ Testing nupst command execution..."
docker exec ${CONTAINER_NAME} nupst --version
echo ""
echo "→ Creating minimal config for service test..."
docker exec ${CONTAINER_NAME} bash -c "
mkdir -p /etc/nupst
cat > /etc/nupst/config.json << 'EOF'
{
\"version\": \"4.0\",
\"upsDevices\": [],
\"groups\": [],
\"checkInterval\": 30000
}
EOF
echo ' ✓ Minimal config created'
"
echo ""
echo "→ Testing service creation..."
docker exec ${CONTAINER_NAME} bash -c "
echo ' Running: nupst service enable'
nupst service enable
if [ -f /etc/systemd/system/nupst.service ]; then
echo ' ✓ Service file created successfully'
else
echo ' ✗ Service file creation failed'
exit 1
fi
"
echo ""
echo "→ Checking if service is enabled..."
docker exec ${CONTAINER_NAME} systemctl is-enabled nupst
echo ""
echo "================================================"
echo " ✓ Fresh v4 Installation Test Complete"
echo "================================================"
echo ""
echo "Installation verified successfully:"
echo " • Binary installed to /opt/nupst/nupst"
echo " • Symlink created for global access"
echo " • nupst command available in PATH"
echo " • Command executes correctly"
echo " • Systemd service file created"
echo ""
echo "Useful commands:"
echo " docker exec -it ${CONTAINER_NAME} bash"
echo " docker exec ${CONTAINER_NAME} nupst --help"
echo " docker exec ${CONTAINER_NAME} nupst service status"
echo " docker stop ${CONTAINER_NAME}"
echo " docker rm -f ${CONTAINER_NAME}"
echo ""

View File

@@ -53,7 +53,7 @@ docker exec ${CONTAINER_NAME} bash -c "
echo "→ Installing prerequisites in container..." echo "→ Installing prerequisites in container..."
docker exec ${CONTAINER_NAME} bash -c " docker exec ${CONTAINER_NAME} bash -c "
apt-get update -qq apt-get update -qq
apt-get install -y -qq git curl sudo apt-get install -y -qq git curl sudo jq
" "
echo "→ Cloning NUPST v3 (commit ${V3_COMMIT})..." echo "→ Cloning NUPST v3 (commit ${V3_COMMIT})..."
@@ -66,35 +66,59 @@ docker exec ${CONTAINER_NAME} bash -c "
git log -1 --oneline git log -1 --oneline
" "
echo "→ Running NUPST v3 installation script..." echo "→ Running NUPST v3 installation directly (bypassing install.sh auto-update)..."
docker exec ${CONTAINER_NAME} bash -c " docker exec ${CONTAINER_NAME} bash -c "
cd /opt/nupst cd /opt/nupst
bash install.sh -y # Run setup.sh directly to avoid install.sh trying to update to v4
bash setup.sh -y
" "
echo "→ Creating dummy NUPST configuration for testing..." echo "→ Creating NUPST configuration using real UPS data from .nogit/env.json..."
docker exec ${CONTAINER_NAME} bash -c "
mkdir -p /etc/nupst # Check if .nogit/env.json exists
cat > /etc/nupst/config.json << 'EOF' if [ ! -f "../../.nogit/env.json" ]; then
echo "❌ Error: .nogit/env.json not found"
echo "This file contains test UPS credentials and is required for testing"
exit 1
fi
# Read UPS data from .nogit/env.json and create v3 config
docker exec ${CONTAINER_NAME} bash -c "mkdir -p /etc/nupst"
# Generate config from .nogit/env.json using jq
cat ../../.nogit/env.json | jq -r '
{ {
\"upsList\": [ "upsList": [
{ {
\"id\": \"test-ups\", "id": "test-ups-v1",
\"name\": \"Test UPS\", "name": "Test UPS (SNMP v1)",
\"host\": \"127.0.0.1\", "host": .testConfigV1.snmp.host,
\"port\": 161, "port": .testConfigV1.snmp.port,
\"community\": \"public\", "community": .testConfigV1.snmp.community,
\"version\": \"2c\", "version": (.testConfigV1.snmp.version | tostring),
\"batteryLowOID\": \"1.3.6.1.4.1.935.1.1.1.3.3.1.0\", "batteryLowOID": "1.3.6.1.4.1.935.1.1.1.3.3.1.0",
\"onBatteryOID\": \"1.3.6.1.4.1.935.1.1.1.3.3.2.0\", "onBatteryOID": "1.3.6.1.4.1.935.1.1.1.3.3.2.0",
\"shutdownCommand\": \"echo 'Shutdown triggered'\" "shutdownCommand": "echo \"Shutdown triggered for test-ups-v1\""
},
{
"id": "test-ups-v3",
"name": "Test UPS (SNMP v3)",
"host": .testConfigV3.snmp.host,
"port": .testConfigV3.snmp.port,
"version": (.testConfigV3.snmp.version | tostring),
"securityLevel": .testConfigV3.snmp.securityLevel,
"username": .testConfigV3.snmp.username,
"authProtocol": .testConfigV3.snmp.authProtocol,
"authKey": .testConfigV3.snmp.authKey,
"batteryLowOID": "1.3.6.1.4.1.935.1.1.1.3.3.1.0",
"onBatteryOID": "1.3.6.1.4.1.935.1.1.1.3.3.2.0",
"shutdownCommand": "echo \"Shutdown triggered for test-ups-v3\""
} }
], ],
\"groups\": [] "groups": []
} }' | docker exec -i ${CONTAINER_NAME} tee /etc/nupst/config.json > /dev/null
EOF
echo 'Dummy config created at /etc/nupst/config.json' echo " ✓ Real UPS config created at /etc/nupst/config.json (from .nogit/env.json)"
"
echo "→ Enabling NUPST systemd service..." echo "→ Enabling NUPST systemd service..."
docker exec ${CONTAINER_NAME} bash -c " docker exec ${CONTAINER_NAME} bash -c "

View File

@@ -32,23 +32,10 @@ echo "→ Stopping v3 service..."
docker exec ${CONTAINER_NAME} systemctl stop nupst docker exec ${CONTAINER_NAME} systemctl stop nupst
echo "" echo ""
echo "→ Pulling latest v4 code from migration/deno-v4 branch..." echo "→ Running v4 installation from main branch (should auto-detect v3 and migrate)..."
echo " Using: curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash"
docker exec ${CONTAINER_NAME} bash -c " docker exec ${CONTAINER_NAME} bash -c "
cd /opt/nupst curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | bash -s -- -y
git fetch origin
# Reset any local changes made by install.sh
git reset --hard HEAD
git clean -fd
git checkout migration/deno-v4
git pull origin migration/deno-v4
echo 'Now on:'
git log -1 --oneline
"
echo "→ Running install.sh (should auto-detect v3 and migrate)..."
docker exec ${CONTAINER_NAME} bash -c "
cd /opt/nupst
bash install.sh -y
" "
echo "→ Checking service status after migration..." echo "→ Checking service status after migration..."

233
test/test.showcase.ts Normal file
View File

@@ -0,0 +1,233 @@
/**
* Showcase test for NUPST CLI outputs
* Demonstrates all the beautiful colored output features
*
* Run with: deno run --allow-all test/showcase.ts
*/
import { logger, type ITableColumn } from '../ts/logger.ts';
import { theme, symbols, getBatteryColor, formatPowerStatus } from '../ts/colors.ts';
console.log('');
console.log('═'.repeat(80));
logger.highlight('NUPST CLI OUTPUT SHOWCASE');
logger.dim('Demonstrating beautiful, colored terminal output');
console.log('═'.repeat(80));
console.log('');
// === 1. Basic Logging Methods ===
logger.logBoxTitle('Basic Logging Methods', 60, 'info');
logger.logBoxLine('');
logger.log('Normal log message (default color)');
logger.success('Success message with ✓ symbol');
logger.error('Error message with ✗ symbol');
logger.warn('Warning message with ⚠ symbol');
logger.info('Info message with symbol');
logger.dim('Dim/secondary text for less important info');
logger.highlight('Highlighted/bold text for emphasis');
logger.logBoxLine('');
logger.logBoxEnd();
console.log('');
// === 2. Colored Boxes ===
logger.logBoxTitle('Colored Box Styles', 60);
logger.logBoxLine('');
logger.logBoxLine('Boxes can be styled with different colors:');
logger.logBoxEnd();
console.log('');
logger.logBox('Success Box (Green)', [
'Used for successful operations',
'Installation complete, service started, etc.',
], 60, 'success');
console.log('');
logger.logBox('Error Box (Red)', [
'Used for critical errors and failures',
'Configuration errors, connection failures, etc.',
], 60, 'error');
console.log('');
logger.logBox('Warning Box (Yellow)', [
'Used for warnings and deprecations',
'Old command format, missing config, etc.',
], 60, 'warning');
console.log('');
logger.logBox('Info Box (Cyan)', [
'Used for informational messages',
'Version info, update available, etc.',
], 60, 'info');
console.log('');
// === 3. Status Symbols ===
logger.logBoxTitle('Status Symbols', 60, 'info');
logger.logBoxLine('');
logger.logBoxLine(`${symbols.running} Service Running`);
logger.logBoxLine(`${symbols.stopped} Service Stopped`);
logger.logBoxLine(`${symbols.starting} Service Starting`);
logger.logBoxLine(`${symbols.unknown} Status Unknown`);
logger.logBoxLine('');
logger.logBoxLine(`${symbols.success} Operation Successful`);
logger.logBoxLine(`${symbols.error} Operation Failed`);
logger.logBoxLine(`${symbols.warning} Warning Condition`);
logger.logBoxLine(`${symbols.info} Information`);
logger.logBoxLine('');
logger.logBoxEnd();
console.log('');
// === 4. Battery Level Colors ===
logger.logBoxTitle('Battery Level Color Coding', 60, 'info');
logger.logBoxLine('');
logger.logBoxLine('Battery levels are color-coded:');
logger.logBoxLine('');
logger.logBoxLine(` ${getBatteryColor(85)('85%')} - Good (green, ≥60%)`);
logger.logBoxLine(` ${getBatteryColor(45)('45%')} - Medium (yellow, 30-60%)`);
logger.logBoxLine(` ${getBatteryColor(15)('15%')} - Critical (red, <30%)`);
logger.logBoxLine('');
logger.logBoxEnd();
console.log('');
// === 5. Power Status Formatting ===
logger.logBoxTitle('Power Status Formatting', 60, 'info');
logger.logBoxLine('');
logger.logBoxLine(`Status: ${formatPowerStatus('online')}`);
logger.logBoxLine(`Status: ${formatPowerStatus('onBattery')}`);
logger.logBoxLine(`Status: ${formatPowerStatus('unknown')}`);
logger.logBoxLine('');
logger.logBoxEnd();
console.log('');
// === 6. Table Formatting ===
const upsColumns: ITableColumn[] = [
{ header: 'ID', key: 'id' },
{ header: 'Name', key: 'name' },
{ header: 'Host', key: 'host' },
{ header: 'Status', key: 'status', color: (v) => {
if (v.includes('Online')) return theme.success(v);
if (v.includes('Battery')) return theme.warning(v);
return theme.dim(v);
}},
{ header: 'Battery', key: 'battery', align: 'right', color: (v) => {
const pct = parseInt(v);
return getBatteryColor(pct)(v);
}},
{ header: 'Runtime', key: 'runtime', align: 'right' },
];
const upsData = [
{
id: 'ups-1',
name: 'Main UPS',
host: '192.168.1.10',
status: 'Online',
battery: '95%',
runtime: '45 min',
},
{
id: 'ups-2',
name: 'Backup UPS',
host: '192.168.1.11',
status: 'On Battery',
battery: '42%',
runtime: '12 min',
},
{
id: 'ups-3',
name: 'Critical UPS',
host: '192.168.1.12',
status: 'On Battery',
battery: '18%',
runtime: '5 min',
},
];
logger.logTable(upsColumns, upsData, 'UPS Devices');
console.log('');
// === 7. Group Table ===
const groupColumns: ITableColumn[] = [
{ header: 'ID', key: 'id' },
{ header: 'Name', key: 'name' },
{ header: 'Mode', key: 'mode' },
{ header: 'UPS Count', key: 'count', align: 'right' },
];
const groupData = [
{ id: 'dc-1', name: 'Data Center 1', mode: 'redundant', count: '3' },
{ id: 'office', name: 'Office Servers', mode: 'nonRedundant', count: '2' },
];
logger.logTable(groupColumns, groupData, 'UPS Groups');
console.log('');
// === 8. Service Status Example ===
logger.logBoxTitle('Service Status', 70, 'success');
logger.logBoxLine('');
logger.logBoxLine(`Status: ${symbols.running} ${theme.statusActive('Active (Running)')}`);
logger.logBoxLine(`Enabled: ${symbols.success} ${theme.success('Yes')}`);
logger.logBoxLine(`Uptime: 2 days, 5 hours, 23 minutes`);
logger.logBoxLine(`PID: ${theme.dim('12345')}`);
logger.logBoxLine(`Memory: ${theme.dim('45.2 MB')}`);
logger.logBoxLine('');
logger.logBoxEnd();
console.log('');
// === 9. Configuration Example ===
logger.logBoxTitle('Configuration', 70);
logger.logBoxLine('');
logger.logBoxLine(`UPS Devices: ${theme.highlight('3')}`);
logger.logBoxLine(`Groups: ${theme.highlight('2')}`);
logger.logBoxLine(`Check Interval: ${theme.dim('30 seconds')}`);
logger.logBoxLine(`Config File: ${theme.path('/etc/nupst/config.json')}`);
logger.logBoxLine('');
logger.logBoxEnd();
console.log('');
// === 10. Update Available Example ===
logger.logBoxTitle('Update Available', 70, 'warning');
logger.logBoxLine('');
logger.logBoxLine(`Current Version: ${theme.dim('4.0.1')}`);
logger.logBoxLine(`Latest Version: ${theme.highlight('4.0.2')}`);
logger.logBoxLine('');
logger.logBoxLine(`Run ${theme.command('sudo nupst update')} to update`);
logger.logBoxLine('');
logger.logBoxEnd();
console.log('');
// === 11. Error Example ===
logger.logBoxTitle('Error Example', 70, 'error');
logger.logBoxLine('');
logger.logBoxLine(`${symbols.error} Failed to connect to UPS at 192.168.1.10`);
logger.logBoxLine('');
logger.logBoxLine('Possible causes:');
logger.logBoxLine(` ${theme.dim('• UPS is offline or unreachable')}`);
logger.logBoxLine(` ${theme.dim('• Incorrect SNMP community string')}`);
logger.logBoxLine(` ${theme.dim('• Firewall blocking port 161')}`);
logger.logBoxLine('');
logger.logBoxLine(`Try: ${theme.command('nupst ups test --debug')}`);
logger.logBoxLine('');
logger.logBoxEnd();
console.log('');
// === Final Summary ===
console.log('═'.repeat(80));
logger.success('CLI Output Showcase Complete!');
logger.dim('All color and formatting features demonstrated');
console.log('═'.repeat(80));
console.log('');

View File

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

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

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

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

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

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

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

View File

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

View File

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

362
ts/cli.ts
View File

@@ -1,6 +1,7 @@
import { execSync } from 'node:child_process'; import { execSync } from 'node:child_process';
import { Nupst } from './nupst.ts'; import { Nupst } from './nupst.ts';
import { logger } from './logger.ts'; import { logger, type ITableColumn } from './logger.ts';
import { theme, symbols } from './colors.ts';
/** /**
* Class for handling CLI commands * Class for handling CLI commands
@@ -302,154 +303,162 @@ export class NupstCli {
try { try {
await this.nupst.getDaemon().loadConfig(); await this.nupst.getDaemon().loadConfig();
} catch (_error) { } catch (_error) {
const errorBoxWidth = 45; logger.logBox('Configuration Error', [
logger.logBoxTitle('Configuration Error', errorBoxWidth); 'No configuration found.',
logger.logBoxLine('No configuration found.'); "Please run 'nupst ups add' first to create a configuration.",
logger.logBoxLine("Please run 'nupst setup' first to create a configuration."); ], 50, 'error');
logger.logBoxEnd();
return; return;
} }
// Get current configuration // Get current configuration
const config = this.nupst.getDaemon().getConfig(); const config = this.nupst.getDaemon().getConfig();
const boxWidth = 50;
logger.logBoxTitle('NUPST Configuration', boxWidth);
// Check if multi-UPS config // Check if multi-UPS config
if (config.upsDevices && Array.isArray(config.upsDevices)) { if (config.upsDevices && Array.isArray(config.upsDevices)) {
// Multi-UPS configuration // === Multi-UPS Configuration ===
logger.logBoxLine(`UPS Devices: ${config.upsDevices.length}`);
logger.logBoxLine(`Groups: ${config.groups ? config.groups.length : 0}`);
logger.logBoxLine(`Check Interval: ${config.checkInterval / 1000} seconds`);
logger.logBoxLine('');
logger.logBoxLine('Configuration File Location:');
logger.logBoxLine(' /etc/nupst/config.json');
logger.logBoxEnd();
// Show UPS devices // Overview Box
logger.log('');
logger.logBox('NUPST Configuration', [
`UPS Devices: ${theme.highlight(String(config.upsDevices.length))}`,
`Groups: ${theme.highlight(String(config.groups ? config.groups.length : 0))}`,
`Check Interval: ${theme.info(String(config.checkInterval / 1000))} seconds`,
'',
theme.dim('Configuration File:'),
` ${theme.path('/etc/nupst/config.json')}`,
], 60, 'info');
// UPS Devices Table
if (config.upsDevices.length > 0) { if (config.upsDevices.length > 0) {
logger.logBoxTitle('UPS Devices', boxWidth); const upsRows = config.upsDevices.map((ups) => ({
for (const ups of config.upsDevices) { name: ups.name,
logger.logBoxLine(`${ups.name} (${ups.id}):`); id: theme.dim(ups.id),
logger.logBoxLine(` Host: ${ups.snmp.host}:${ups.snmp.port}`); host: `${ups.snmp.host}:${ups.snmp.port}`,
logger.logBoxLine(` Model: ${ups.snmp.upsModel}`); model: ups.snmp.upsModel || 'cyberpower',
logger.logBoxLine( actions: `${(ups.actions || []).length} configured`,
` Thresholds: ${ups.thresholds.battery}% battery, ${ups.thresholds.runtime} min runtime`, groups: ups.groups.length > 0 ? ups.groups.join(', ') : theme.dim('None'),
); }));
logger.logBoxLine(
` Groups: ${ups.groups.length > 0 ? ups.groups.join(', ') : 'None'}`, const upsColumns: ITableColumn[] = [
); { header: 'Name', key: 'name', align: 'left', color: theme.highlight },
logger.logBoxLine(''); { header: 'ID', key: 'id', align: 'left' },
} { header: 'Host:Port', key: 'host', align: 'left', color: theme.info },
logger.logBoxEnd(); { header: 'Model', key: 'model', align: 'left' },
{ header: 'Actions', key: 'actions', align: 'left' },
{ header: 'Groups', key: 'groups', align: 'left' },
];
logger.log('');
logger.info(`UPS Devices (${config.upsDevices.length}):`);
logger.log('');
logger.logTable(upsColumns, upsRows);
} }
// Show groups // Groups Table
if (config.groups && config.groups.length > 0) { if (config.groups && config.groups.length > 0) {
logger.logBoxTitle('UPS Groups', boxWidth); const groupRows = config.groups.map((group) => {
for (const group of config.groups) {
logger.logBoxLine(`${group.name} (${group.id}):`);
logger.logBoxLine(` Mode: ${group.mode}`);
if (group.description) {
logger.logBoxLine(` Description: ${group.description}`);
}
// List UPS devices in this group
const upsInGroup = config.upsDevices.filter((ups) => const upsInGroup = config.upsDevices.filter((ups) =>
ups.groups && ups.groups.includes(group.id) ups.groups && ups.groups.includes(group.id)
); );
logger.logBoxLine( return {
` UPS Devices: ${ name: group.name,
upsInGroup.length > 0 ? upsInGroup.map((ups) => ups.name).join(', ') : 'None' id: theme.dim(group.id),
}`, mode: group.mode,
); upsCount: String(upsInGroup.length),
logger.logBoxLine(''); ups: upsInGroup.length > 0
} ? upsInGroup.map((ups) => ups.name).join(', ')
logger.logBoxEnd(); : theme.dim('None'),
description: group.description || theme.dim('—'),
};
});
const groupColumns: ITableColumn[] = [
{ header: 'Name', key: 'name', align: 'left', color: theme.highlight },
{ header: 'ID', key: 'id', align: 'left' },
{ header: 'Mode', key: 'mode', align: 'left', color: theme.info },
{ header: 'UPS', key: 'upsCount', align: 'right' },
{ header: 'UPS Devices', key: 'ups', align: 'left' },
{ header: 'Description', key: 'description', align: 'left' },
];
logger.log('');
logger.info(`UPS Groups (${config.groups.length}):`);
logger.log('');
logger.logTable(groupColumns, groupRows);
} }
} else { } else {
// Legacy single UPS configuration // === Legacy Single UPS Configuration ===
if (!config.snmp) { if (!config.snmp) {
logger.logBoxLine('Error: Legacy configuration missing SNMP settings'); logger.logBox('Configuration Error', [
} else { 'Error: Legacy configuration missing SNMP settings',
// SNMP Settings ], 60, 'error');
logger.logBoxLine('SNMP Settings:'); return;
logger.logBoxLine(` Host: ${config.snmp.host}`);
logger.logBoxLine(` Port: ${config.snmp.port}`);
logger.logBoxLine(` Version: ${config.snmp.version}`);
logger.logBoxLine(` UPS Model: ${config.snmp.upsModel || 'cyberpower'}`);
if (config.snmp.version === 1 || config.snmp.version === 2) {
logger.logBoxLine(` Community: ${config.snmp.community}`);
} else if (config.snmp.version === 3) {
logger.logBoxLine(` Security Level: ${config.snmp.securityLevel}`);
logger.logBoxLine(` Username: ${config.snmp.username}`);
// Show auth and privacy details based on security level
if (
config.snmp.securityLevel === 'authNoPriv' ||
config.snmp.securityLevel === 'authPriv'
) {
logger.logBoxLine(` Auth Protocol: ${config.snmp.authProtocol || 'None'}`);
}
if (config.snmp.securityLevel === 'authPriv') {
logger.logBoxLine(` Privacy Protocol: ${config.snmp.privProtocol || 'None'}`);
}
// Show timeout value
logger.logBoxLine(` Timeout: ${config.snmp.timeout / 1000} seconds`);
}
// Show OIDs if custom model is selected
if (config.snmp.upsModel === 'custom' && config.snmp.customOIDs) {
logger.logBoxLine('Custom OIDs:');
logger.logBoxLine(
` Power Status: ${config.snmp.customOIDs.POWER_STATUS || 'Not set'}`,
);
logger.logBoxLine(
` Battery Capacity: ${config.snmp.customOIDs.BATTERY_CAPACITY || 'Not set'}`,
);
logger.logBoxLine(
` Battery Runtime: ${config.snmp.customOIDs.BATTERY_RUNTIME || 'Not set'}`,
);
}
} }
// Thresholds logger.log('');
if (!config.thresholds) { logger.logBox('NUPST Configuration (Legacy)', [
logger.logBoxLine('Error: Legacy configuration missing threshold settings'); theme.warning('Legacy single-UPS configuration format'),
} else { '',
logger.logBoxLine('Thresholds:'); theme.dim('SNMP Settings:'),
logger.logBoxLine(` Battery: ${config.thresholds.battery}%`); ` Host: ${theme.info(config.snmp.host)}`,
logger.logBoxLine(` Runtime: ${config.thresholds.runtime} minutes`); ` Port: ${theme.info(String(config.snmp.port))}`,
} ` Version: ${config.snmp.version}`,
logger.logBoxLine(`Check Interval: ${config.checkInterval / 1000} seconds`); ` UPS Model: ${config.snmp.upsModel || 'cyberpower'}`,
...(config.snmp.version === 1 || config.snmp.version === 2
? [` Community: ${config.snmp.community}`]
: []
),
...(config.snmp.version === 3
? [
` Security Level: ${config.snmp.securityLevel}`,
` Username: ${config.snmp.username}`,
...(config.snmp.securityLevel === 'authNoPriv' || config.snmp.securityLevel === 'authPriv'
? [` Auth Protocol: ${config.snmp.authProtocol || 'None'}`]
: []
),
...(config.snmp.securityLevel === 'authPriv'
? [` Privacy Protocol: ${config.snmp.privProtocol || 'None'}`]
: []
),
` Timeout: ${config.snmp.timeout / 1000} seconds`,
]
: []
),
...(config.snmp.upsModel === 'custom' && config.snmp.customOIDs
? [
theme.dim('Custom OIDs:'),
` Power Status: ${config.snmp.customOIDs.POWER_STATUS || 'Not set'}`,
` Battery Capacity: ${config.snmp.customOIDs.BATTERY_CAPACITY || 'Not set'}`,
` Battery Runtime: ${config.snmp.customOIDs.BATTERY_RUNTIME || 'Not set'}`,
]
: []
),
'',
// Configuration file location ` Check Interval: ${config.checkInterval / 1000} seconds`,
logger.logBoxLine(''); '',
logger.logBoxLine('Configuration File Location:'); theme.dim('Configuration File:'),
logger.logBoxLine(' /etc/nupst/config.json'); ` ${theme.path('/etc/nupst/config.json')}`,
logger.logBoxLine(''); '',
logger.logBoxLine('Note: Using legacy single-UPS configuration format.'); theme.warning('Note: Using legacy single-UPS configuration format.'),
logger.logBoxLine('Consider using "nupst add" to migrate to multi-UPS format.'); `Consider using ${theme.command('nupst ups add')} to migrate to multi-UPS format.`,
], 70, 'warning');
logger.logBoxEnd();
} }
// Show service status // Service Status
try { try {
const isActive = const isActive =
execSync('systemctl is-active nupst.service || true').toString().trim() === 'active'; execSync('systemctl is-active nupst.service || true').toString().trim() === 'active';
const isEnabled = const isEnabled =
execSync('systemctl is-enabled nupst.service || true').toString().trim() === 'enabled'; execSync('systemctl is-enabled nupst.service || true').toString().trim() === 'enabled';
const statusBoxWidth = 45; logger.log('');
logger.logBoxTitle('Service Status', statusBoxWidth); logger.logBox('Service Status', [
logger.logBoxLine(`Service Active: ${isActive ? 'Yes' : 'No'}`); `Active: ${isActive ? theme.success('Yes') : theme.dim('No')}`,
logger.logBoxLine(`Service Enabled: ${isEnabled ? 'Yes' : 'No'}`); `Enabled: ${isEnabled ? theme.success('Yes') : theme.dim('No')}`,
logger.logBoxEnd(); ], 50, isActive ? 'success' : 'default');
logger.log('');
} catch (_error) { } catch (_error) {
// Ignore errors checking service status // Ignore errors checking service status
} }
@@ -468,65 +477,90 @@ export class NupstCli {
private showVersion(): void { private showVersion(): void {
const version = this.nupst.getVersion(); const version = this.nupst.getVersion();
logger.log(`NUPST version ${version}`); logger.log(`NUPST version ${version}`);
logger.log('Deno-powered UPS monitoring tool'); logger.log('Network UPS Shutdown Tool (https://nupst.serve.zone)');
} }
/** /**
* Display help message * Display help message
*/ */
private showHelp(): void { private showHelp(): void {
logger.log(` console.log('');
NUPST - UPS Shutdown Tool logger.highlight('NUPST - UPS Shutdown Tool');
logger.dim('Deno-powered UPS monitoring and shutdown automation');
console.log('');
Usage: // Usage section
nupst <command> [options] logger.log(theme.info('Usage:'));
logger.log(` ${theme.command('nupst')} ${theme.dim('<command> [options]')}`);
console.log('');
Commands: // Main commands section
service <subcommand> - Manage systemd service logger.log(theme.info('Commands:'));
ups <subcommand> - Manage UPS devices this.printCommand('service <subcommand>', 'Manage systemd service');
group <subcommand> - Manage UPS groups this.printCommand('ups <subcommand>', 'Manage UPS devices');
config [show] - Display current configuration this.printCommand('group <subcommand>', 'Manage UPS groups');
update - Update NUPST from repository (requires root) this.printCommand('config [show]', 'Display current configuration');
uninstall - Completely remove NUPST from system (requires root) this.printCommand('update', 'Update NUPST from repository', theme.dim('(requires root)'));
help, --help, -h - Show this help message this.printCommand('uninstall', 'Completely remove NUPST', theme.dim('(requires root)'));
--version, -v - Show version information this.printCommand('help, --help, -h', 'Show this help message');
this.printCommand('--version, -v', 'Show version information');
console.log('');
Service Subcommands: // Service subcommands
nupst service enable - Install and enable systemd service (requires root) logger.log(theme.info('Service Subcommands:'));
nupst service disable - Stop and disable systemd service (requires root) this.printCommand('nupst service enable', 'Install and enable systemd service', theme.dim('(requires root)'));
nupst service start - Start the systemd service this.printCommand('nupst service disable', 'Stop and disable systemd service', theme.dim('(requires root)'));
nupst service stop - Stop the systemd service this.printCommand('nupst service start', 'Start the systemd service');
nupst service restart - Restart the systemd service this.printCommand('nupst service stop', 'Stop the systemd service');
nupst service status - Show service and UPS status this.printCommand('nupst service restart', 'Restart the systemd service');
nupst service logs - Show service logs in real-time this.printCommand('nupst service status', 'Show service and UPS status');
nupst service start-daemon - Start daemon process directly this.printCommand('nupst service logs', 'Show service logs in real-time');
this.printCommand('nupst service start-daemon', 'Start daemon process directly');
console.log('');
UPS Subcommands: // UPS subcommands
nupst ups add - Add a new UPS device logger.log(theme.info('UPS Subcommands:'));
nupst ups edit [id] - Edit a UPS device (default if no ID) this.printCommand('nupst ups add', 'Add a new UPS device');
nupst ups remove <id> - Remove a UPS device by ID this.printCommand('nupst ups edit [id]', 'Edit a UPS device (default if no ID)');
nupst ups list (or ls) - List all configured UPS devices this.printCommand('nupst ups remove <id>', 'Remove a UPS device by ID');
nupst ups test - Test UPS connections this.printCommand('nupst ups list (or ls)', 'List all configured UPS devices');
this.printCommand('nupst ups test', 'Test UPS connections');
console.log('');
Group Subcommands: // Group subcommands
nupst group add - Add a new UPS group logger.log(theme.info('Group Subcommands:'));
nupst group edit <id> - Edit an existing UPS group this.printCommand('nupst group add', 'Add a new UPS group');
nupst group remove <id> - Remove a UPS group by ID this.printCommand('nupst group edit <id>', 'Edit an existing UPS group');
nupst group list (or ls) - List all UPS groups this.printCommand('nupst group remove <id>', 'Remove a UPS group by ID');
this.printCommand('nupst group list (or ls)', 'List all UPS groups');
console.log('');
Options: // Options
--debug, -d - Enable debug mode for detailed SNMP logging logger.log(theme.info('Options:'));
(Example: nupst ups test --debug) this.printCommand('--debug, -d', 'Enable debug mode for detailed SNMP logging');
logger.dim(' (Example: nupst ups test --debug)');
console.log('');
Examples: // Examples
nupst service enable - Install and start the service logger.log(theme.info('Examples:'));
nupst ups add - Add a new UPS interactively logger.dim(' nupst service enable # Install and start the service');
nupst group list - Show all configured groups logger.dim(' nupst ups add # Add a new UPS interactively');
nupst config - Display current configuration logger.dim(' nupst group list # Show all configured groups');
logger.dim(' nupst config # Display current configuration');
console.log('');
Note: Old command format (e.g., 'nupst add') still works but is deprecated. // Note about deprecated commands
Use the new format (e.g., 'nupst ups add') going forward. 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('');
}
/**
* Helper to print a command with description
*/
private printCommand(command: string, description: string, extra?: string): void {
const paddedCommand = command.padEnd(30);
logger.log(` ${theme.command(paddedCommand)} ${description}${extra ? ' ' + extra : ''}`);
} }
/** /**

View File

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

View File

@@ -129,81 +129,57 @@ export class ServiceHandler {
try { try {
// Check if running as root // Check if running as root
this.checkRootAccess( this.checkRootAccess(
'This command must be run as root to update NUPST and refresh the systemd service.', 'This command must be run as root to update NUPST.',
); );
const boxWidth = 45; console.log('');
logger.logBoxTitle('NUPST Update Process', boxWidth); logger.info('Checking for updates...');
logger.logBoxLine('Updating NUPST from repository...');
// Determine the installation directory (assuming it's either /opt/nupst or the current directory)
const { existsSync } = await import('fs');
let installDir = '/opt/nupst';
if (!existsSync(installDir)) {
// If not installed in /opt/nupst, use the current directory
const { dirname } = await import('path');
installDir = dirname(dirname(process.argv[1])); // Go up two levels from the executable
logger.logBoxLine(`Using local installation directory: ${installDir}`);
}
try { try {
// 1. Update the repository // Get current version
logger.logBoxLine('Pulling latest changes from git repository...'); const currentVersion = this.nupst.getVersion();
execSync(`cd ${installDir} && git fetch origin && git reset --hard origin/main`, {
stdio: 'pipe', // Fetch latest version from Gitea API
const apiUrl = 'https://code.foss.global/api/v1/repos/serve.zone/nupst/releases/latest';
const response = execSync(`curl -sSL ${apiUrl}`).toString();
const release = JSON.parse(response);
const latestVersion = release.tag_name; // e.g., "v4.0.7"
// Normalize versions for comparison (ensure both have "v" prefix)
const normalizedCurrent = currentVersion.startsWith('v') ? currentVersion : `v${currentVersion}`;
const normalizedLatest = latestVersion.startsWith('v') ? latestVersion : `v${latestVersion}`;
logger.dim(`Current version: ${normalizedCurrent}`);
logger.dim(`Latest version: ${normalizedLatest}`);
console.log('');
// Compare normalized versions
if (normalizedCurrent === normalizedLatest) {
logger.success('Already up to date!');
console.log('');
return;
}
logger.info(`New version available: ${latestVersion}`);
logger.dim('Downloading and installing...');
console.log('');
// Download and run the install script
// This handles everything: download binary, stop service, replace, restart
const installUrl = 'https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh';
execSync(`curl -sSL ${installUrl} | bash`, {
stdio: 'inherit', // Show install script output to user
}); });
// 2. Run the install.sh script console.log('');
logger.logBoxLine('Running install.sh to update NUPST...'); logger.success(`Updated to ${latestVersion}`);
execSync(`cd ${installDir} && bash ./install.sh`, { stdio: 'pipe' }); console.log('');
// 3. Run the setup.sh script with force flag to update Node.js and dependencies
logger.logBoxLine('Running setup.sh to update Node.js and dependencies...');
execSync(`cd ${installDir} && bash ./setup.sh --force`, { stdio: 'pipe' });
// 4. Refresh the systemd service
logger.logBoxLine('Refreshing systemd service...');
// First check if service exists
let serviceExists = false;
try {
const output = execSync('systemctl list-unit-files | grep nupst.service').toString();
serviceExists = output.includes('nupst.service');
} catch (error) {
// If grep fails (service not found), serviceExists remains false
serviceExists = false;
}
if (serviceExists) {
// Stop the service if it's running
const isRunning =
execSync('systemctl is-active nupst.service || true').toString().trim() === 'active';
if (isRunning) {
logger.logBoxLine('Stopping nupst service...');
execSync('systemctl stop nupst.service');
}
// Reinstall the service
logger.logBoxLine('Reinstalling systemd service...');
await this.nupst.getSystemd().install();
// Restart the service if it was running
if (isRunning) {
logger.logBoxLine('Restarting nupst service...');
execSync('systemctl start nupst.service');
}
} else {
logger.logBoxLine('Systemd service not installed, skipping service refresh.');
logger.logBoxLine('Run "nupst enable" to install the service.');
}
logger.logBoxLine('Update completed successfully!');
logger.logBoxEnd();
} catch (error) { } catch (error) {
logger.logBoxLine('Error during update process:'); console.log('');
logger.logBoxLine(`${error instanceof Error ? error.message : String(error)}`); logger.error('Update failed');
logger.logBoxEnd(); logger.dim(`${error instanceof Error ? error.message : String(error)}`);
console.log('');
process.exit(1); process.exit(1);
} }
} catch (error) { } catch (error) {
@@ -237,9 +213,11 @@ export class ServiceHandler {
}); });
}; };
console.log('\nNUPST Uninstaller'); logger.log('');
console.log('==============='); logger.highlight('NUPST Uninstaller');
console.log('This will completely remove NUPST from your system.\n'); logger.dim('===============');
logger.log('This will completely remove NUPST from your system.');
logger.log('');
// Ask about removing configuration // Ask about removing configuration
const removeConfig = await prompt( const removeConfig = await prompt(
@@ -275,17 +253,20 @@ export class ServiceHandler {
} }
if (!uninstallScriptPath) { if (!uninstallScriptPath) {
console.error('Could not locate uninstall.sh script. Aborting uninstall.'); logger.error('Could not locate uninstall.sh script. Aborting uninstall.');
rl.close(); rl.close();
process.stdin.destroy();
process.exit(1); process.exit(1);
} }
} }
// Close readline before executing script // Close readline before executing script
rl.close(); rl.close();
process.stdin.destroy();
// Execute uninstall.sh with the appropriate option // Execute uninstall.sh with the appropriate option
console.log(`\nRunning uninstaller from ${uninstallScriptPath}...`); logger.log('');
logger.log(`Running uninstaller from ${uninstallScriptPath}...`);
// Pass the configuration removal option as an environment variable // Pass the configuration removal option as an environment variable
const env = { const env = {
@@ -301,7 +282,7 @@ export class ServiceHandler {
stdio: 'inherit', // Show output in the terminal stdio: 'inherit', // Show output in the terminal
}); });
} catch (error) { } catch (error) {
console.error(`Uninstall failed: ${error instanceof Error ? error.message : String(error)}`); logger.error(`Uninstall failed: ${error instanceof Error ? error.message : String(error)}`);
process.exit(1); process.exit(1);
} }
} }

View File

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

88
ts/colors.ts Normal file
View File

@@ -0,0 +1,88 @@
/**
* Color theme and styling utilities for NUPST CLI
* Uses Deno standard library colors module
*/
import * as colors from '@std/fmt/colors';
/**
* Color theme for consistent CLI styling
*/
export const theme = {
// Message types
error: colors.red,
warning: colors.yellow,
success: colors.green,
info: colors.cyan,
dim: colors.dim,
highlight: colors.bold,
// Status indicators
statusActive: (text: string) => colors.green(colors.bold(text)),
statusInactive: (text: string) => colors.red(text),
statusWarning: (text: string) => colors.yellow(text),
statusUnknown: (text: string) => colors.dim(text),
// Battery level colors
batteryGood: colors.green, // > 60%
batteryMedium: colors.yellow, // 30-60%
batteryCritical: colors.red, // < 30%
// Box borders
borderSuccess: colors.green,
borderError: colors.red,
borderWarning: colors.yellow,
borderInfo: colors.cyan,
borderDefault: (text: string) => text, // No color
// Command/code highlighting
command: colors.cyan,
code: colors.dim,
path: colors.blue,
};
/**
* Status symbols with colors
*/
export const symbols = {
success: colors.green('✓'),
error: colors.red('✗'),
warning: colors.yellow('⚠'),
info: colors.cyan(''),
running: colors.green('●'),
stopped: colors.red('○'),
starting: colors.yellow('◐'),
unknown: colors.dim('◯'),
};
/**
* Get color for battery level
*/
export function getBatteryColor(percentage: number): (text: string) => string {
if (percentage >= 60) return theme.batteryGood;
if (percentage >= 30) return theme.batteryMedium;
return theme.batteryCritical;
}
/**
* Get color for runtime remaining
*/
export function getRuntimeColor(minutes: number): (text: string) => string {
if (minutes >= 20) return theme.batteryGood;
if (minutes >= 10) return theme.batteryMedium;
return theme.batteryCritical;
}
/**
* Format UPS power status with color
*/
export function formatPowerStatus(status: 'online' | 'onBattery' | 'unknown'): string {
switch (status) {
case 'online':
return theme.success('Online');
case 'onBattery':
return theme.warning('On Battery');
case 'unknown':
default:
return theme.dim('Unknown');
}
}

View File

@@ -4,8 +4,12 @@ import * as path from 'node:path';
import { exec, execFile } from 'node:child_process'; import { exec, execFile } from 'node:child_process';
import { promisify } from 'node:util'; import { promisify } from 'node:util';
import { NupstSnmp } from './snmp/manager.ts'; import { NupstSnmp } from './snmp/manager.ts';
import type { ISnmpConfig } from './snmp/types.ts'; import type { ISnmpConfig, IUpsStatus as ISnmpUpsStatus } from './snmp/types.ts';
import { logger } from './logger.ts'; import { logger, type ITableColumn } from './logger.ts';
import { MigrationRunner } from './migrations/index.ts';
import { 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);
@@ -20,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[];
} }
/** /**
@@ -43,12 +42,16 @@ 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[];
} }
/** /**
* Configuration interface for the daemon * Configuration interface for the daemon
*/ */
export interface INupstConfig { export interface INupstConfig {
/** Configuration format version */
version?: string;
/** UPS devices configuration */ /** UPS devices configuration */
upsDevices: IUpsConfig[]; upsDevices: IUpsConfig[];
/** Groups configuration */ /** Groups configuration */
@@ -56,10 +59,12 @@ export interface INupstConfig {
/** Check interval in milliseconds */ /** Check interval in milliseconds */
checkInterval: number; checkInterval: number;
// Legacy fields for backward compatibility // Legacy fields for backward compatibility (will be migrated away)
/** SNMP configuration settings (legacy) */ /** UPS list (v3 format - legacy) */
upsList?: IUpsConfig[];
/** SNMP configuration settings (v1 format - legacy) */
snmp?: ISnmpConfig; snmp?: ISnmpConfig;
/** Threshold settings (legacy) */ /** Threshold settings (v1 format - legacy) */
thresholds?: { thresholds?: {
/** Shutdown when battery below this percentage */ /** Shutdown when battery below this percentage */
battery: number; battery: number;
@@ -71,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';
@@ -91,6 +96,7 @@ export class NupstDaemon {
/** Default configuration */ /** Default configuration */
private readonly DEFAULT_CONFIG: INupstConfig = { private readonly DEFAULT_CONFIG: INupstConfig = {
version: '4.2',
upsDevices: [ upsDevices: [
{ {
id: 'default', id: 'default',
@@ -111,16 +117,23 @@ export class NupstDaemon {
// UPS model for OID selection // UPS model for OID selection
upsModel: 'cyberpower', upsModel: 'cyberpower',
}, },
thresholds: {
battery: 60, // Shutdown when battery below 60%
runtime: 20, // Shutdown when runtime below 20 minutes
},
groups: [], groups: [],
actions: [
{
type: 'shutdown',
triggerMode: 'onlyThresholds',
thresholds: {
battery: 60, // Shutdown when battery below 60%
runtime: 20, // Shutdown when runtime below 20 minutes
},
shutdownDelay: 5,
},
],
}, },
], ],
groups: [], groups: [],
checkInterval: 30000, // Check every 30 seconds checkInterval: 30000, // Check every 30 seconds
}; }
private config: INupstConfig; private config: INupstConfig;
private snmp: NupstSnmp; private snmp: NupstSnmp;
@@ -153,29 +166,18 @@ export class NupstDaemon {
const configData = fs.readFileSync(this.CONFIG_PATH, 'utf8'); const configData = fs.readFileSync(this.CONFIG_PATH, 'utf8');
const parsedConfig = JSON.parse(configData); const parsedConfig = JSON.parse(configData);
// Handle legacy configuration format // Run migrations to upgrade config format if needed
if (!parsedConfig.upsDevices && parsedConfig.snmp) { const migrationRunner = new MigrationRunner();
// Convert legacy format to new format const { config: migratedConfig, migrated } = await migrationRunner.run(parsedConfig);
this.config = {
upsDevices: [
{
id: 'default',
name: 'Default UPS',
snmp: parsedConfig.snmp,
thresholds: parsedConfig.thresholds,
groups: [],
},
],
groups: [],
checkInterval: parsedConfig.checkInterval,
};
logger.log('Legacy configuration format detected. Converting to multi-UPS format.'); // Save migrated config back to disk if any migrations ran
// Cast to INupstConfig since migrations ensure the output is valid
// Save the new format const validConfig = migratedConfig as unknown as INupstConfig;
if (migrated) {
this.config = validConfig;
await this.saveConfig(this.config); await this.saveConfig(this.config);
} else { } else {
this.config = parsedConfig; this.config = validConfig;
} }
return this.config; return this.config;
@@ -202,14 +204,21 @@ export class NupstDaemon {
if (!fs.existsSync(configDir)) { if (!fs.existsSync(configDir)) {
fs.mkdirSync(configDir, { recursive: true }); fs.mkdirSync(configDir, { recursive: true });
} }
fs.writeFileSync(this.CONFIG_PATH, JSON.stringify(config, null, 2));
this.config = config;
console.log('┌─ Configuration Saved ─────────────────────┐'); // Ensure version is always set and remove legacy fields before saving
console.log(`│ Location: ${this.CONFIG_PATH}`); const configToSave: INupstConfig = {
console.log('└──────────────────────────────────────────┘'); version: '4.1',
upsDevices: config.upsDevices,
groups: config.groups,
checkInterval: config.checkInterval,
};
fs.writeFileSync(this.CONFIG_PATH, JSON.stringify(configToSave, null, 2));
this.config = configToSave;
logger.logBox('Configuration Saved', [`Location: ${this.CONFIG_PATH}`], 45, 'success');
} catch (error) { } catch (error) {
console.error('Error saving configuration:', error); logger.error(`Error saving configuration: ${error}`);
} }
} }
@@ -217,10 +226,7 @@ export class NupstDaemon {
* Helper method to log configuration errors consistently * Helper method to log configuration errors consistently
*/ */
private logConfigError(message: string): void { private logConfigError(message: string): void {
console.error('┌─ Configuration Error ─────────────────────┐'); logger.logBox('Configuration Error', [message, "Please run 'nupst setup' first to create a configuration."], 45, 'error');
console.error(`${message}`);
console.error("│ Please run 'nupst setup' first to create a configuration.");
console.error('└───────────────────────────────────────────┘');
} }
/** /**
@@ -313,29 +319,57 @@ export class NupstDaemon {
* Log the loaded configuration settings * Log the loaded configuration settings
*/ */
private logConfigLoaded(): void { private logConfigLoaded(): void {
const boxWidth = 50;
logger.logBoxTitle('Configuration Loaded', boxWidth);
if (this.config.upsDevices && this.config.upsDevices.length > 0) {
logger.logBoxLine(`UPS Devices: ${this.config.upsDevices.length}`);
for (const ups of this.config.upsDevices) {
logger.logBoxLine(` - ${ups.name} (${ups.id}): ${ups.snmp.host}:${ups.snmp.port}`);
}
} else {
logger.logBoxLine('No UPS devices configured');
}
if (this.config.groups && this.config.groups.length > 0) {
logger.logBoxLine(`Groups: ${this.config.groups.length}`);
for (const group of this.config.groups) {
logger.logBoxLine(` - ${group.name} (${group.id}): ${group.mode} mode`);
}
} else {
logger.logBoxLine('No Groups configured');
}
logger.log('');
logger.logBoxTitle('Configuration Loaded', 70, 'success');
logger.logBoxLine(`Check Interval: ${this.config.checkInterval / 1000} seconds`); logger.logBoxLine(`Check Interval: ${this.config.checkInterval / 1000} seconds`);
logger.logBoxEnd(); logger.logBoxEnd();
logger.log('');
// Display UPS devices in a table
if (this.config.upsDevices && this.config.upsDevices.length > 0) {
logger.info(`UPS Devices (${this.config.upsDevices.length}):`);
const upsColumns: Array<{ header: string; key: string; align?: 'left' | 'right'; color?: (val: string) => string }> = [
{ header: 'Name', key: 'name', align: 'left', color: theme.highlight },
{ header: 'ID', key: 'id', align: 'left', color: theme.dim },
{ header: 'Host:Port', key: 'host', align: 'left', color: theme.info },
{ header: 'Actions', key: 'actions', align: 'left' },
];
const upsRows: Array<Record<string, string>> = this.config.upsDevices.map((ups) => ({
name: ups.name,
id: ups.id,
host: `${ups.snmp.host}:${ups.snmp.port}`,
actions: `${(ups.actions || []).length} configured`,
}));
logger.logTable(upsColumns, upsRows);
logger.log('');
} else {
logger.warn('No UPS devices configured');
logger.log('');
}
// Display groups in a table
if (this.config.groups && this.config.groups.length > 0) {
logger.info(`Groups (${this.config.groups.length}):`);
const groupColumns: Array<{ header: string; key: string; align?: 'left' | 'right'; color?: (val: string) => string }> = [
{ header: 'Name', key: 'name', align: 'left', color: theme.highlight },
{ header: 'ID', key: 'id', align: 'left', color: theme.dim },
{ header: 'Mode', key: 'mode', align: 'left', color: theme.info },
];
const groupRows: Array<Record<string, string>> = this.config.groups.map((group) => ({
name: group.name,
id: group.id,
mode: group.mode,
}));
logger.logTable(groupColumns, groupRows);
logger.log('');
}
} }
/** /**
@@ -353,8 +387,9 @@ export class NupstDaemon {
logger.log('Starting UPS monitoring...'); logger.log('Starting UPS monitoring...');
if (!this.config.upsDevices || this.config.upsDevices.length === 0) { if (!this.config.upsDevices || this.config.upsDevices.length === 0) {
logger.error('No UPS devices found in configuration. Monitoring stopped.'); logger.warn('No UPS devices found in configuration. Daemon will remain idle...');
this.isRunning = false; // Don't exit - enter idle monitoring mode instead
await this.idleMonitoring();
return; return;
} }
@@ -374,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) {
@@ -430,11 +462,42 @@ export class NupstDaemon {
// Check if power status changed // Check if power status changed
if (currentStatus && currentStatus.powerStatus !== status.powerStatus) { if (currentStatus && currentStatus.powerStatus !== status.powerStatus) {
logger.logBoxTitle(`Power Status Change: ${ups.name}`, 50); logger.log('');
logger.logBoxLine(`Status changed: ${currentStatus.powerStatus}${status.powerStatus}`); logger.logBoxTitle(`Power Status Change: ${ups.name}`, 60, 'warning');
logger.logBoxLine(`Previous: ${formatPowerStatus(currentStatus.powerStatus)}`);
logger.logBoxLine(`Current: ${formatPowerStatus(status.powerStatus)}`);
logger.logBoxLine(`Time: ${new Date().toISOString()}`);
logger.logBoxEnd(); logger.logBoxEnd();
logger.log('');
updatedStatus.lastStatusChange = currentTime; updatedStatus.lastStatusChange = currentTime;
// Trigger actions for power status change
await this.triggerUpsActions(ups, updatedStatus, currentStatus, 'powerStatusChange');
}
// Check if any action's thresholds are exceeded (for threshold violation triggers)
// Only check when on battery power
if (status.powerStatus === 'onBattery' && ups.actions && ups.actions.length > 0) {
let anyThresholdExceeded = false;
for (const actionConfig of ups.actions) {
if (actionConfig.thresholds) {
if (
status.batteryCapacity < actionConfig.thresholds.battery ||
status.batteryRuntime < actionConfig.thresholds.runtime
) {
anyThresholdExceeded = true;
break;
}
}
}
// Trigger actions with threshold violation reason if any threshold is exceeded
// Actions will individually check their own thresholds in shouldExecute()
if (anyThresholdExceeded) {
await this.triggerUpsActions(ups, updatedStatus, currentStatus, 'thresholdViolation');
}
} }
// Update the status in the map // Update the status in the map
@@ -454,171 +517,100 @@ export class NupstDaemon {
*/ */
private logAllUpsStatus(): void { private logAllUpsStatus(): void {
const timestamp = new Date().toISOString(); const timestamp = new Date().toISOString();
const boxWidth = 60;
logger.logBoxTitle('Periodic Status Update', boxWidth); logger.log('');
logger.logBoxTitle('Periodic Status Update', 70, 'info');
logger.logBoxLine(`Timestamp: ${timestamp}`); logger.logBoxLine(`Timestamp: ${timestamp}`);
logger.logBoxLine('');
for (const [id, status] of this.upsStatus.entries()) {
logger.logBoxLine(`UPS: ${status.name} (${id})`);
logger.logBoxLine(` Power Status: ${status.powerStatus}`);
logger.logBoxLine(
` Battery: ${status.batteryCapacity}% | Runtime: ${status.batteryRuntime} min`,
);
logger.logBoxLine('');
}
logger.logBoxEnd(); logger.logBoxEnd();
logger.log('');
// Build table data
const columns: Array<{ header: string; key: string; align?: 'left' | 'right'; color?: (val: string) => string }> = [
{ header: 'UPS Name', key: 'name', align: 'left', color: theme.highlight },
{ header: 'ID', key: 'id', align: 'left', color: theme.dim },
{ header: 'Power Status', key: 'powerStatus', align: 'left' },
{ header: 'Battery', key: 'battery', align: 'right' },
{ header: 'Runtime', key: 'runtime', align: 'right' },
];
const rows: Array<Record<string, string>> = [];
for (const [id, status] of this.upsStatus.entries()) {
const batteryColor = getBatteryColor(status.batteryCapacity);
const runtimeColor = getRuntimeColor(status.batteryRuntime);
rows.push({
name: status.name,
id: id,
powerStatus: formatPowerStatus(status.powerStatus),
battery: batteryColor(status.batteryCapacity + '%'),
runtime: runtimeColor(status.batteryRuntime + ' min'),
});
}
logger.logTable(columns, rows);
logger.log('');
}
/**
* Build action context from UPS state
* @param ups UPS configuration
* @param status Current UPS status
* @param triggerReason Why this action is being triggered
* @returns Action context
*/
private buildActionContext(
ups: IUpsConfig,
status: IUpsStatus,
triggerReason: 'powerStatusChange' | 'thresholdViolation',
): IActionContext {
return {
upsId: ups.id,
upsName: ups.name,
powerStatus: status.powerStatus as TPowerStatus,
batteryCapacity: status.batteryCapacity,
batteryRuntime: status.batteryRuntime,
previousPowerStatus: 'unknown' as TPowerStatus, // Will be set from map in calling code
timestamp: Date.now(),
triggerReason,
};
} }
/** /**
* Evaluate if shutdown is required based on group configurations * Trigger actions for a UPS device
* @param ups UPS configuration
* @param status Current UPS status
* @param previousStatus Previous UPS status (for determining previousPowerStatus)
* @param triggerReason Why actions are being triggered
*/ */
private async evaluateGroupShutdownConditions(): Promise<void> { private async triggerUpsActions(
if (!this.config.groups || this.config.groups.length === 0) { ups: IUpsConfig,
// No groups defined, check individual UPS conditions status: IUpsStatus,
for (const [id, status] of this.upsStatus.entries()) { previousStatus: IUpsStatus | undefined,
if (status.powerStatus === 'onBattery') { triggerReason: 'powerStatusChange' | 'thresholdViolation',
// Find the UPS config
const ups = this.config.upsDevices.find((u) => u.id === id);
if (ups) {
await this.evaluateUpsShutdownCondition(ups, status);
}
}
}
return;
}
// Evaluate each group
for (const group of this.config.groups) {
// Find all UPS devices in this group
const upsDevicesInGroup = this.config.upsDevices.filter((ups) =>
ups.groups && ups.groups.includes(group.id)
);
if (upsDevicesInGroup.length === 0) {
// No UPS devices in this group
continue;
}
if (group.mode === 'redundant') {
// Redundant mode: only shutdown if ALL UPS devices in the group are in critical condition
await this.evaluateRedundantGroup(group, upsDevicesInGroup);
} else {
// Non-redundant mode: shutdown if ANY UPS device in the group is in critical condition
await this.evaluateNonRedundantGroup(group, upsDevicesInGroup);
}
}
}
/**
* Evaluate a redundant group for shutdown conditions
* In redundant mode, we only shut down if ALL UPS devices are in critical condition
*/
private async evaluateRedundantGroup(
group: IGroupConfig,
upsDevices: IUpsConfig[],
): Promise<void> { ): Promise<void> {
// Count UPS devices on battery and in critical condition const actions = ups.actions || [];
let upsOnBattery = 0;
let upsInCriticalCondition = 0;
for (const ups of upsDevices) {
const status = this.upsStatus.get(ups.id);
if (!status) continue;
if (status.powerStatus === 'onBattery') {
upsOnBattery++;
// Check if this UPS is in critical condition
if (
status.batteryCapacity < ups.thresholds.battery ||
status.batteryRuntime < ups.thresholds.runtime
) {
upsInCriticalCondition++;
}
}
}
// All UPS devices must be online for a redundant group to be considered healthy
const allUpsCount = upsDevices.length;
// If all UPS are on battery and in critical condition, shutdown
if (upsOnBattery === allUpsCount && upsInCriticalCondition === allUpsCount) {
logger.logBoxTitle(`Group Shutdown Required: ${group.name}`, 50);
logger.logBoxLine(`Mode: Redundant`);
logger.logBoxLine(`All ${allUpsCount} UPS devices in critical condition`);
logger.logBoxEnd();
await this.initiateShutdown(
`All UPS devices in redundant group "${group.name}" in critical condition`,
);
}
}
/**
* Evaluate a non-redundant group for shutdown conditions
* In non-redundant mode, we shut down if ANY UPS device is in critical condition
*/
private async evaluateNonRedundantGroup(
group: IGroupConfig,
upsDevices: IUpsConfig[],
): Promise<void> {
for (const ups of upsDevices) {
const status = this.upsStatus.get(ups.id);
if (!status) continue;
if (status.powerStatus === 'onBattery') {
// Check if this UPS is in critical condition
if (
status.batteryCapacity < ups.thresholds.battery ||
status.batteryRuntime < ups.thresholds.runtime
) {
logger.logBoxTitle(`Group Shutdown Required: ${group.name}`, 50);
logger.logBoxLine(`Mode: Non-Redundant`);
logger.logBoxLine(`UPS ${ups.name} in critical condition`);
logger.logBoxLine(
`Battery: ${status.batteryCapacity}% (threshold: ${ups.thresholds.battery}%)`,
);
logger.logBoxLine(
`Runtime: ${status.batteryRuntime} min (threshold: ${ups.thresholds.runtime} min)`,
);
logger.logBoxEnd();
await this.initiateShutdown(
`UPS "${ups.name}" in non-redundant group "${group.name}" in critical condition`,
);
return; // Exit after initiating shutdown
}
}
}
}
/**
* Evaluate an individual UPS for shutdown conditions
*/
private async evaluateUpsShutdownCondition(ups: IUpsConfig, status: IUpsStatus): Promise<void> {
// Only evaluate UPS devices not in any group
if (ups.groups && ups.groups.length > 0) {
return;
}
// Check threshold conditions
if (
status.batteryCapacity < ups.thresholds.battery ||
status.batteryRuntime < ups.thresholds.runtime
) {
logger.logBoxTitle(`UPS Shutdown Required: ${ups.name}`, 50);
logger.logBoxLine(
`Battery: ${status.batteryCapacity}% (threshold: ${ups.thresholds.battery}%)`,
);
logger.logBoxLine(
`Runtime: ${status.batteryRuntime} min (threshold: ${ups.thresholds.runtime} min)`,
);
logger.logBoxEnd();
// Backward compatibility: if no actions configured, use default shutdown behavior
if (actions.length === 0 && triggerReason === 'thresholdViolation') {
// Fall back to old shutdown logic for backward compatibility
await this.initiateShutdown(`UPS "${ups.name}" battery or runtime below threshold`); await this.initiateShutdown(`UPS "${ups.name}" battery or runtime below threshold`);
return;
} }
if (actions.length === 0) {
return; // No actions to execute
}
// Build action context
const context = this.buildActionContext(ups, status, triggerReason);
context.previousPowerStatus = (previousStatus?.powerStatus || 'unknown') as TPowerStatus;
// Execute actions
await ActionManager.executeActions(actions, context);
} }
/** /**
@@ -747,38 +739,61 @@ export class NupstDaemon {
const MAX_MONITORING_TIME = 5 * 60 * 1000; // Max 5 minutes of monitoring const MAX_MONITORING_TIME = 5 * 60 * 1000; // Max 5 minutes of monitoring
const startTime = Date.now(); const startTime = Date.now();
logger.log( logger.log('');
`Emergency shutdown threshold: ${EMERGENCY_RUNTIME_THRESHOLD} minutes remaining battery runtime`, logger.logBoxTitle('Shutdown Monitoring Active', 60, 'warning');
); logger.logBoxLine(`Emergency threshold: ${EMERGENCY_RUNTIME_THRESHOLD} minutes runtime`);
logger.logBoxLine(`Check interval: ${CHECK_INTERVAL / 1000} seconds`);
logger.logBoxLine(`Max monitoring time: ${MAX_MONITORING_TIME / 1000} seconds`);
logger.logBoxEnd();
logger.log('');
// Continue monitoring until max monitoring time is reached // Continue monitoring until max monitoring time is reached
while (Date.now() - startTime < MAX_MONITORING_TIME) { while (Date.now() - startTime < MAX_MONITORING_TIME) {
try { try {
logger.log('Checking UPS status during shutdown...'); logger.info('Checking UPS status during shutdown...');
// Build table for UPS status during shutdown
const columns: Array<{ header: string; key: string; align?: 'left' | 'right'; color?: (val: string) => string }> = [
{ header: 'UPS Name', key: 'name', align: 'left', color: theme.highlight },
{ header: 'Battery', key: 'battery', align: 'right' },
{ header: 'Runtime', key: 'runtime', align: 'right' },
{ header: 'Status', key: 'status', align: 'left' },
];
const rows: Array<Record<string, string>> = [];
let emergencyDetected = false;
let emergencyUps: { ups: IUpsConfig; status: ISnmpUpsStatus } | null = null;
// Check all UPS devices // Check all UPS devices
for (const ups of this.config.upsDevices) { for (const ups of this.config.upsDevices) {
try { try {
const status = await this.snmp.getUpsStatus(ups.snmp); const status = await this.snmp.getUpsStatus(ups.snmp);
logger.log( const batteryColor = getBatteryColor(status.batteryCapacity);
`UPS ${ups.name}: Battery ${status.batteryCapacity}%, Runtime: ${status.batteryRuntime} minutes`, const runtimeColor = getRuntimeColor(status.batteryRuntime);
);
// If any UPS battery runtime gets critically low, force immediate shutdown const isCritical = status.batteryRuntime < EMERGENCY_RUNTIME_THRESHOLD;
if (status.batteryRuntime < EMERGENCY_RUNTIME_THRESHOLD) {
logger.logBoxTitle('EMERGENCY SHUTDOWN', 50);
logger.logBoxLine(
`UPS ${ups.name} runtime critically low: ${status.batteryRuntime} minutes`,
);
logger.logBoxLine('Forcing immediate shutdown!');
logger.logBoxEnd();
// Force immediate shutdown rows.push({
await this.forceImmediateShutdown(); name: ups.name,
return; battery: batteryColor(status.batteryCapacity + '%'),
runtime: runtimeColor(status.batteryRuntime + ' min'),
status: isCritical ? theme.error('CRITICAL!') : theme.success('OK'),
});
// If any UPS battery runtime gets critically low, flag for immediate shutdown
if (isCritical && !emergencyDetected) {
emergencyDetected = true;
emergencyUps = { ups, status };
} }
} catch (upsError) { } catch (upsError) {
rows.push({
name: ups.name,
battery: theme.error('N/A'),
runtime: theme.error('N/A'),
status: theme.error('ERROR'),
});
logger.error( logger.error(
`Error checking UPS ${ups.name} during shutdown: ${ `Error checking UPS ${ups.name} during shutdown: ${
upsError instanceof Error ? upsError.message : String(upsError) upsError instanceof Error ? upsError.message : String(upsError)
@@ -787,6 +802,27 @@ export class NupstDaemon {
} }
} }
// Display the table
logger.logTable(columns, rows);
logger.log('');
// If emergency detected, trigger immediate shutdown
if (emergencyDetected && emergencyUps) {
logger.log('');
logger.logBoxTitle('EMERGENCY SHUTDOWN', 60, 'error');
logger.logBoxLine(
`UPS ${emergencyUps.ups.name} runtime critically low: ${emergencyUps.status.batteryRuntime} minutes`,
);
logger.logBoxLine(`Emergency threshold: ${EMERGENCY_RUNTIME_THRESHOLD} minutes`);
logger.logBoxLine('Forcing immediate shutdown!');
logger.logBoxEnd();
logger.log('');
// Force immediate shutdown
await this.forceImmediateShutdown();
return;
}
// Wait before checking again // Wait before checking again
await this.sleep(CHECK_INTERVAL); await this.sleep(CHECK_INTERVAL);
} catch (error) { } catch (error) {
@@ -799,7 +835,9 @@ export class NupstDaemon {
} }
} }
logger.log('UPS monitoring during shutdown completed'); logger.log('');
logger.success('UPS monitoring during shutdown completed');
logger.log('');
} }
/** /**
@@ -890,6 +928,133 @@ export class NupstDaemon {
} }
} }
/**
* Idle monitoring loop when no UPS devices are configured
* Watches for config changes and reloads when detected
*/
private async idleMonitoring(): Promise<void> {
const IDLE_CHECK_INTERVAL = 60000; // Check every 60 seconds
let lastConfigCheck = Date.now();
const CONFIG_CHECK_INTERVAL = 60000; // Check config every minute
logger.log('Entering idle monitoring mode...');
logger.log('Daemon will check for config changes every 60 seconds');
// Start file watcher for hot-reload
this.watchConfigFile();
while (this.isRunning) {
try {
const currentTime = Date.now();
// Periodically check if config has been updated
if (currentTime - lastConfigCheck >= CONFIG_CHECK_INTERVAL) {
try {
// Try to load config
const newConfig = await this.loadConfig();
// Check if we now have UPS devices configured
if (newConfig.upsDevices && newConfig.upsDevices.length > 0) {
logger.success('Configuration updated! UPS devices found. Starting monitoring...');
this.initializeUpsStatus();
// Exit idle mode and start monitoring
await this.monitor();
return;
}
} catch (error) {
// Config still doesn't exist or invalid, continue waiting
}
lastConfigCheck = currentTime;
}
await this.sleep(IDLE_CHECK_INTERVAL);
} catch (error) {
logger.error(
`Error during idle monitoring: ${error instanceof Error ? error.message : String(error)}`,
);
await this.sleep(IDLE_CHECK_INTERVAL);
}
}
logger.log('Idle monitoring stopped');
}
/**
* Watch config file for changes and reload automatically
*/
private watchConfigFile(): void {
try {
// Use Deno's file watcher to monitor config file
const configDir = path.dirname(this.CONFIG_PATH);
// Spawn a background watcher (non-blocking)
(async () => {
try {
const watcher = Deno.watchFs(configDir);
logger.log('Config file watcher started');
for await (const event of watcher) {
// Only respond to modify events on the config file
if (
event.kind === 'modify' &&
event.paths.some((p) => p.includes('config.json'))
) {
logger.info('Config file changed, reloading...');
await this.reloadConfig();
}
// Stop watching if daemon stopped
if (!this.isRunning) {
break;
}
}
} catch (error) {
// Watcher error - not critical, just log it
logger.dim(
`Config watcher stopped: ${error instanceof Error ? error.message : String(error)}`,
);
}
})();
} catch (error) {
// If we can't start the watcher, just log and continue
// The periodic check will still work
logger.dim('Could not start config file watcher, using periodic checks only');
}
}
/**
* Reload configuration and restart monitoring if needed
*/
private async reloadConfig(): Promise<void> {
try {
const oldDeviceCount = this.config.upsDevices?.length || 0;
// Load the new configuration
await this.loadConfig();
const newDeviceCount = this.config.upsDevices?.length || 0;
if (newDeviceCount > 0 && oldDeviceCount === 0) {
logger.success(`Configuration reloaded! Found ${newDeviceCount} UPS device(s)`);
logger.info('Monitoring will start automatically...');
} else if (newDeviceCount !== oldDeviceCount) {
logger.success(
`Configuration reloaded! UPS devices: ${oldDeviceCount}${newDeviceCount}`,
);
// Reinitialize UPS status tracking
this.initializeUpsStatus();
} else {
logger.success('Configuration reloaded successfully');
}
} catch (error) {
logger.warn(
`Failed to reload config: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
/** /**
* Sleep for the specified milliseconds * Sleep for the specified milliseconds
*/ */

View File

@@ -1,9 +1,38 @@
import { theme, symbols } from './colors.ts';
/**
* Table column alignment options
*/
export type TColumnAlign = 'left' | 'right' | 'center';
/**
* Table column definition
*/
export interface ITableColumn {
/** Column header text */
header: string;
/** Column key in data object */
key: string;
/** Column alignment (default: left) */
align?: TColumnAlign;
/** Column width (auto-calculated if not specified) */
width?: number;
/** Color function to apply to cell values */
color?: (value: string) => string;
}
/**
* Box style types with colors
*/
export type TBoxStyle = 'default' | 'success' | 'error' | 'warning' | 'info';
/** /**
* A simple logger class that provides consistent formatting for log messages * A simple logger class that provides consistent formatting for log messages
* including support for logboxes with title, lines, and closing * including support for logboxes with title, lines, and closing
*/ */
export class Logger { export class Logger {
private currentBoxWidth: number | null = null; private currentBoxWidth: number | null = null;
private currentBoxStyle: TBoxStyle = 'default';
private static instance: Logger; private static instance: Logger;
/** Default width to use when no width is specified */ /** Default width to use when no width is specified */
@@ -36,36 +65,83 @@ export class Logger {
} }
/** /**
* Log an error message * Log an error message (red with ✗ symbol)
* @param message Error message to log * @param message Error message to log
*/ */
public error(message: string): void { public error(message: string): void {
console.error(message); console.error(`${symbols.error} ${theme.error(message)}`);
} }
/** /**
* Log a warning message with a warning emoji * Log a warning message (yellow with ⚠ symbol)
* @param message Warning message to log * @param message Warning message to log
*/ */
public warn(message: string): void { public warn(message: string): void {
console.warn(`⚠️ ${message}`); console.warn(`${symbols.warning} ${theme.warning(message)}`);
} }
/** /**
* Log a success message with a checkmark * Log a success message (green with ✓ symbol)
* @param message Success message to log * @param message Success message to log
*/ */
public success(message: string): void { public success(message: string): void {
console.log(`${message}`); console.log(`${symbols.success} ${theme.success(message)}`);
}
/**
* Log an info message (cyan with symbol)
* @param message Info message to log
*/
public info(message: string): void {
console.log(`${symbols.info} ${theme.info(message)}`);
}
/**
* Log a dim/secondary message
* @param message Message to log in dim style
*/
public dim(message: string): void {
console.log(theme.dim(message));
}
/**
* Log a highlighted/bold message
* @param message Message to highlight
*/
public highlight(message: string): void {
console.log(theme.highlight(message));
}
/**
* Get color function for box based on style
*/
private getBoxColor(style: TBoxStyle): (text: string) => string {
switch (style) {
case 'success':
return theme.borderSuccess;
case 'error':
return theme.borderError;
case 'warning':
return theme.borderWarning;
case 'info':
return theme.borderInfo;
case 'default':
default:
return theme.borderDefault;
}
} }
/** /**
* Log a logbox title and set the current box width * Log a logbox title and set the current box width
* @param title Title of the logbox * @param title Title of the logbox
* @param width Width of the logbox (including borders), defaults to DEFAULT_WIDTH * @param width Width of the logbox (including borders), defaults to DEFAULT_WIDTH
* @param style Box style for coloring (default, success, error, warning, info)
*/ */
public logBoxTitle(title: string, width?: number): void { public logBoxTitle(title: string, width?: number, style?: TBoxStyle): void {
this.currentBoxWidth = width || this.DEFAULT_WIDTH; this.currentBoxWidth = width || this.DEFAULT_WIDTH;
this.currentBoxStyle = style || 'default';
const colorFn = this.getBoxColor(this.currentBoxStyle);
// Create the title line with appropriate padding // Create the title line with appropriate padding
const paddedTitle = ` ${title} `; const paddedTitle = ` ${title} `;
@@ -74,7 +150,7 @@ export class Logger {
// Title line: ┌─ Title ───┐ // Title line: ┌─ Title ───┐
const titleLine = `┌─${paddedTitle}${'─'.repeat(Math.max(0, remainingSpace))}`; const titleLine = `┌─${paddedTitle}${'─'.repeat(Math.max(0, remainingSpace))}`;
console.log(titleLine); console.log(colorFn(titleLine));
} }
/** /**
@@ -89,17 +165,21 @@ export class Logger {
} }
const boxWidth = width || this.currentBoxWidth || this.DEFAULT_WIDTH; const boxWidth = width || this.currentBoxWidth || this.DEFAULT_WIDTH;
const colorFn = this.getBoxColor(this.currentBoxStyle);
// Calculate the available space for content // Calculate the available space for content (use visible length)
const availableSpace = boxWidth - 2; // Account for left and right borders const availableSpace = boxWidth - 2; // Account for left and right borders
const visibleLen = this.visibleLength(content);
if (content.length <= availableSpace - 1) { if (visibleLen <= availableSpace - 1) {
// If content fits with at least one space for the right border stripe // If content fits with at least one space for the right border stripe
const padding = availableSpace - content.length - 1; const padding = availableSpace - visibleLen - 1;
console.log(`${content}${' '.repeat(padding)}`); const line = `${content}${' '.repeat(padding)}`;
console.log(colorFn(line));
} else { } else {
// Content is too long, let it flow out of boundaries. // Content is too long, let it flow out of boundaries.
console.log(`${content}`); const line = `${content}`;
console.log(colorFn(line));
} }
} }
@@ -109,12 +189,15 @@ export class Logger {
*/ */
public logBoxEnd(width?: number): void { public logBoxEnd(width?: number): void {
const boxWidth = width || this.currentBoxWidth || this.DEFAULT_WIDTH; const boxWidth = width || this.currentBoxWidth || this.DEFAULT_WIDTH;
const colorFn = this.getBoxColor(this.currentBoxStyle);
// Create the bottom border: └────────┘ // Create the bottom border: └────────┘
console.log(`${'─'.repeat(boxWidth - 2)}`); const bottomLine = `${'─'.repeat(boxWidth - 2)}`;
console.log(colorFn(bottomLine));
// Reset the current box width // Reset the current box width and style
this.currentBoxWidth = null; this.currentBoxWidth = null;
this.currentBoxStyle = 'default';
} }
/** /**
@@ -122,9 +205,10 @@ export class Logger {
* @param title Title of the logbox * @param title Title of the logbox
* @param lines Array of content lines * @param lines Array of content lines
* @param width Width of the logbox, defaults to DEFAULT_WIDTH * @param width Width of the logbox, defaults to DEFAULT_WIDTH
* @param style Box style for coloring
*/ */
public logBox(title: string, lines: string[], width?: number): void { public logBox(title: string, lines: string[], width?: number, style?: TBoxStyle): void {
this.logBoxTitle(title, width || this.DEFAULT_WIDTH); this.logBoxTitle(title, width || this.DEFAULT_WIDTH, style);
for (const line of lines) { for (const line of lines) {
this.logBoxLine(line); this.logBoxLine(line);
@@ -141,6 +225,108 @@ export class Logger {
public logDivider(width?: number, character: string = '─'): void { public logDivider(width?: number, character: string = '─'): void {
console.log(character.repeat(width || this.DEFAULT_WIDTH)); console.log(character.repeat(width || this.DEFAULT_WIDTH));
} }
/**
* Strip ANSI color codes from string for accurate length calculation
*/
private stripAnsi(text: string): string {
// Remove ANSI escape codes
return text.replace(/\x1b\[[0-9;]*m/g, '');
}
/**
* Get visible length of string (excluding ANSI codes)
*/
private visibleLength(text: string): number {
return this.stripAnsi(text).length;
}
/**
* Align text within a column (handles ANSI color codes correctly)
*/
private alignText(text: string, width: number, align: TColumnAlign = 'left'): string {
const visibleLen = this.visibleLength(text);
if (visibleLen >= width) {
// Text is too long, truncate the visible part
const stripped = this.stripAnsi(text);
return stripped.substring(0, width);
}
const padding = width - visibleLen;
switch (align) {
case 'right':
return ' '.repeat(padding) + text;
case 'center': {
const leftPad = Math.floor(padding / 2);
const rightPad = padding - leftPad;
return ' '.repeat(leftPad) + text + ' '.repeat(rightPad);
}
case 'left':
default:
return text + ' '.repeat(padding);
}
}
/**
* Log a formatted table
* @param columns Column definitions
* @param rows Array of data objects
* @param title Optional table title
*/
public logTable(columns: ITableColumn[], rows: Record<string, string>[], title?: string): void {
if (rows.length === 0) {
this.dim('No data to display');
return;
}
// Calculate column widths
const columnWidths = columns.map((col) => {
if (col.width) return col.width;
// Auto-calculate width based on header and data (use visible length)
let maxWidth = this.visibleLength(col.header);
for (const row of rows) {
const value = String(row[col.key] || '');
maxWidth = Math.max(maxWidth, this.visibleLength(value));
}
return maxWidth;
});
// Calculate total table width
const totalWidth = columnWidths.reduce((sum, w) => sum + w, 0) + (columns.length * 3) + 1;
// Print title if provided
if (title) {
this.logBoxTitle(title, totalWidth);
} else {
// Print top border
console.log('┌' + columnWidths.map((w) => '─'.repeat(w + 2)).join('┬') + '┐');
}
// Print header row
const headerCells = columns.map((col, i) =>
theme.highlight(this.alignText(col.header, columnWidths[i], col.align))
);
console.log('│ ' + headerCells.join(' │ ') + ' │');
// Print separator
console.log('├' + columnWidths.map((w) => '─'.repeat(w + 2)).join('┼') + '┤');
// Print data rows
for (const row of rows) {
const cells = columns.map((col, i) => {
const value = String(row[col.key] || '');
const aligned = this.alignText(value, columnWidths[i], col.align);
return col.color ? col.color(aligned) : aligned;
});
console.log('│ ' + cells.join(' │ ') + ' │');
}
// Print bottom border
console.log('└' + columnWidths.map((w) => '─'.repeat(w + 2)).join('┴') + '┘');
}
} }
// Export a singleton instance for easy use // Export a singleton instance for easy use

View File

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

11
ts/migrations/index.ts Normal file
View File

@@ -0,0 +1,11 @@
/**
* Configuration migrations module
*
* Exports the migration system for upgrading configs between versions.
*/
export { BaseMigration } from './base-migration.ts';
export { MigrationRunner } from './migration-runner.ts';
export { MigrationV1ToV2 } from './migration-v1-to-v2.ts';
export { MigrationV3ToV4 } from './migration-v3-to-v4.ts';
export { MigrationV4_0ToV4_1 } from './migration-v4.0-to-v4.1.ts';

View File

@@ -0,0 +1,75 @@
import { BaseMigration } from './base-migration.ts';
import { MigrationV1ToV2 } from './migration-v1-to-v2.ts';
import { MigrationV3ToV4 } from './migration-v3-to-v4.ts';
import { MigrationV4_0ToV4_1 } from './migration-v4.0-to-v4.1.ts';
import { logger } from '../logger.ts';
/**
* Migration runner
*
* Discovers all available migrations, sorts them by order,
* and runs applicable migrations in sequence.
*/
export class MigrationRunner {
private migrations: BaseMigration[];
constructor() {
// Register all migrations here
this.migrations = [
new MigrationV1ToV2(),
new MigrationV3ToV4(),
new MigrationV4_0ToV4_1(),
// Add future migrations here (v4.3, v4.4, etc.)
];
// Sort by version order to ensure they run in sequence
this.migrations.sort((a, b) => a.getVersionOrder() - b.getVersionOrder());
}
/**
* Run all applicable migrations on the config
*
* @param config - Raw configuration object to migrate
* @returns Migrated configuration and whether migrations ran
*/
async run(
config: Record<string, unknown>,
): Promise<{ config: Record<string, unknown>; migrated: boolean }> {
let currentConfig = config;
let anyMigrationsRan = false;
for (const migration of this.migrations) {
const shouldRun = await migration.shouldRun(currentConfig);
if (shouldRun) {
// Only show "checking" message when we actually need to migrate
if (!anyMigrationsRan) {
logger.dim('Checking for required config migrations...');
}
logger.info(`Running ${migration.getName()}...`);
currentConfig = await migration.migrate(currentConfig);
anyMigrationsRan = true;
}
}
if (anyMigrationsRan) {
logger.success('Configuration migrations complete');
} else {
logger.success('config format ok');
}
return {
config: currentConfig,
migrated: anyMigrationsRan,
};
}
/**
* Get all registered migrations
*
* @returns Array of all migrations sorted by order
*/
getMigrations(): BaseMigration[] {
return [...this.migrations];
}
}

View File

@@ -0,0 +1,55 @@
import { BaseMigration } from './base-migration.ts';
import { logger } from '../logger.ts';
/**
* Migration from v1 (single SNMP config) to v2 (upsDevices array)
*
* Detects old format:
* {
* snmp: { ... },
* thresholds: { ... },
* checkInterval: 30000
* }
*
* Converts to:
* {
* version: "2.0",
* upsDevices: [{ id: "default", name: "Default UPS", snmp: ..., thresholds: ... }],
* groups: [],
* checkInterval: 30000
* }
*/
export class MigrationV1ToV2 extends BaseMigration {
readonly fromVersion = '1.x';
readonly toVersion = '2.0';
async shouldRun(config: any): Promise<boolean> {
// V1 format has snmp field directly at root, no upsDevices or upsList
return !!config.snmp && !config.upsDevices && !config.upsList;
}
async migrate(config: any): Promise<any> {
logger.info(`${this.getName()}: Converting single SNMP config to multi-UPS format...`);
const migrated = {
version: this.toVersion,
upsDevices: [
{
id: 'default',
name: 'Default UPS',
snmp: config.snmp,
thresholds: config.thresholds || {
battery: 60,
runtime: 20,
},
groups: [],
},
],
groups: [],
checkInterval: config.checkInterval || 30000,
};
logger.success(`${this.getName()}: Migration complete`);
return migrated;
}
}

View File

@@ -0,0 +1,118 @@
import { BaseMigration } from './base-migration.ts';
import { logger } from '../logger.ts';
/**
* Migration from v3 (upsList) to v4 (upsDevices)
*
* Transforms v3 format with flat SNMP config:
* {
* upsList: [
* {
* id: "ups-1",
* name: "UPS 1",
* host: "192.168.1.1",
* port: 161,
* community: "public",
* version: "1" // string
* }
* ]
* }
*
* To v4 format with nested SNMP config:
* {
* version: "4.0",
* upsDevices: [
* {
* id: "ups-1",
* name: "UPS 1",
* snmp: {
* host: "192.168.1.1",
* port: 161,
* community: "public",
* version: 1, // number
* timeout: 5000
* },
* thresholds: { battery: 60, runtime: 20 },
* groups: []
* }
* ]
* }
*/
export class MigrationV3ToV4 extends BaseMigration {
readonly fromVersion = '3.x';
readonly toVersion = '4.0';
async shouldRun(config: any): Promise<boolean> {
// V3 format has upsList OR has upsDevices with flat structure (host at top level)
if (config.upsList && !config.upsDevices) {
return true; // Classic v3 with upsList
}
// Check if upsDevices exists but has flat structure (v3 format)
if (config.upsDevices && config.upsDevices.length > 0) {
const firstDevice = config.upsDevices[0];
// V3 has host at top level, v4 has it nested in snmp object
return !!firstDevice.host && !firstDevice.snmp;
}
return false;
}
async migrate(config: any): Promise<any> {
logger.info(`${this.getName()}: Migrating v3 config to v4 format...`);
logger.dim(` - Restructuring UPS devices (flat → nested snmp config)`);
// Get devices from either upsList or upsDevices (for partially migrated configs)
const sourceDevices = config.upsList || config.upsDevices;
// Transform each UPS device from v3 flat structure to v4 nested structure
const transformedDevices = sourceDevices.map((device: any) => {
// Build SNMP config object
const snmpConfig: any = {
host: device.host,
port: device.port || 161,
version: typeof device.version === 'string' ? parseInt(device.version, 10) : device.version,
timeout: device.timeout || 5000,
};
// Add SNMPv1/v2c fields
if (device.community) {
snmpConfig.community = device.community;
}
// Add SNMPv3 fields
if (device.securityLevel) snmpConfig.securityLevel = device.securityLevel;
if (device.username) snmpConfig.username = device.username;
if (device.authProtocol) snmpConfig.authProtocol = device.authProtocol;
if (device.authKey) snmpConfig.authKey = device.authKey;
if (device.privProtocol) snmpConfig.privProtocol = device.privProtocol;
if (device.privKey) snmpConfig.privKey = device.privKey;
// Add UPS model if present
if (device.upsModel) snmpConfig.upsModel = device.upsModel;
if (device.customOIDs) snmpConfig.customOIDs = device.customOIDs;
// Return v4 format with nested structure
return {
id: device.id,
name: device.name,
snmp: snmpConfig,
thresholds: device.thresholds || {
battery: 60,
runtime: 20,
},
groups: device.groups || [],
};
});
const migrated = {
version: this.toVersion,
upsDevices: transformedDevices,
groups: config.groups || [],
checkInterval: config.checkInterval || 30000,
};
logger.success(`${this.getName()}: Migration complete (${transformedDevices.length} devices transformed)`);
return migrated;
}
}

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

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

View File

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

View File

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

View File

@@ -1,8 +1,10 @@
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';
/** /**
* Class for managing systemd service * Class for managing systemd service
@@ -49,11 +51,11 @@ WantedBy=multi-user.target
try { try {
await fs.access(configPath); await fs.access(configPath);
} catch (error) { } catch (error) {
const boxWidth = 50; logger.log('');
logger.logBoxTitle('Configuration Error', boxWidth); logger.error('No configuration found');
logger.logBoxLine(`No configuration file found at ${configPath}`); logger.log(` ${theme.dim('Config file:')} ${configPath}`);
logger.logBoxLine("Please run 'nupst add' first to create a UPS configuration."); logger.log(` ${theme.dim('Run')} ${theme.command('nupst ups add')} ${theme.dim('to create a configuration')}`);
logger.logBoxEnd(); logger.log('');
throw new Error('Configuration not found'); throw new Error('Configuration not found');
} }
} }
@@ -133,21 +135,59 @@ WantedBy=multi-user.target
* Get status of the systemd service and UPS * Get status of the systemd service and UPS
* @param debugMode Whether to enable debug mode for SNMP * @param debugMode Whether to enable debug mode for SNMP
*/ */
/**
* Display version information and update status
* @private
*/
private async displayVersionInfo(): Promise<void> {
try {
const nupst = this.daemon.getNupstSnmp().getNupst();
const version = nupst.getVersion();
// Check for updates
const updateAvailable = await nupst.checkForUpdates();
// Display version info
if (updateAvailable) {
const updateStatus = nupst.getUpdateStatus();
logger.log('');
logger.log(
`${theme.dim('NUPST')} ${theme.dim('v' + version)} ${symbols.warning} ${theme.statusWarning(`Update available: v${updateStatus.latestVersion}`)}`,
);
logger.log(` ${theme.dim('Run')} ${theme.command('sudo nupst update')} ${theme.dim('to upgrade')}`);
} else {
logger.log('');
logger.log(
`${theme.dim('NUPST')} ${theme.dim('v' + version)} ${symbols.success} ${theme.success('Up to date')}`,
);
}
} catch (error) {
// If version check fails, show at least the current version
try {
const nupst = this.daemon.getNupstSnmp().getNupst();
const version = nupst.getVersion();
logger.log('');
logger.log(`${theme.dim('NUPST')} ${theme.dim('v' + version)}`);
} catch (_innerError) {
// Silently fail if we can't even get the version
}
}
}
public async getStatus(debugMode: boolean = false): Promise<void> { public async getStatus(debugMode: boolean = false): Promise<void> {
try { try {
// Enable debug mode if requested // Enable debug mode if requested
if (debugMode) { if (debugMode) {
const boxWidth = 45; console.log('');
logger.logBoxTitle('Debug Mode', boxWidth); logger.info('Debug Mode: SNMP debugging enabled');
logger.logBoxLine('SNMP debugging enabled - detailed logs will be shown'); console.log('');
logger.logBoxEnd();
this.daemon.getNupstSnmp().enableDebug(); this.daemon.getNupstSnmp().enableDebug();
} }
// Display version information // Display version and update status first
this.daemon.getNupstSnmp().getNupst().logVersionInfo(); await this.displayVersionInfo();
// Check if config exists first // Check if config exists
try { try {
await this.checkConfigExists(); await this.checkConfigExists();
} catch (error) { } catch (error) {
@@ -171,18 +211,50 @@ WantedBy=multi-user.target
private displayServiceStatus(): void { private displayServiceStatus(): void {
try { try {
const serviceStatus = execSync('systemctl status nupst.service').toString(); const serviceStatus = execSync('systemctl status nupst.service').toString();
const boxWidth = 45; const lines = serviceStatus.split('\n');
logger.logBoxTitle('Service Status', boxWidth);
// Process each line of the status output // Parse key information from systemctl output
serviceStatus.split('\n').forEach((line) => { let isActive = false;
logger.logBoxLine(line); let pid = '';
}); let memory = '';
logger.logBoxEnd(); let cpu = '';
for (const line of lines) {
if (line.includes('Active:')) {
isActive = line.includes('active (running)');
} else if (line.includes('Main PID:')) {
const match = line.match(/Main PID:\s+(\d+)/);
if (match) pid = match[1];
} else if (line.includes('Memory:')) {
const match = line.match(/Memory:\s+([\d.]+[A-Z])/);
if (match) memory = match[1];
} else if (line.includes('CPU:')) {
const match = line.match(/CPU:\s+([\d.]+(?:ms|s))/);
if (match) cpu = match[1];
}
}
// Display beautiful status
logger.log('');
if (isActive) {
logger.log(`${symbols.running} ${theme.success('Service:')} ${theme.statusActive('active (running)')}`);
} else {
logger.log(`${symbols.stopped} ${theme.dim('Service:')} ${theme.statusInactive('inactive')}`);
}
if (pid || memory || cpu) {
const details = [];
if (pid) details.push(`PID: ${theme.dim(pid)}`);
if (memory) details.push(`Memory: ${theme.dim(memory)}`);
if (cpu) details.push(`CPU: ${theme.dim(cpu)}`);
logger.log(` ${details.join(' ')}`);
}
logger.log('');
} catch (error) { } catch (error) {
const boxWidth = 45; logger.log('');
logger.logBoxTitle('Service Status', boxWidth); logger.log(`${symbols.stopped} ${theme.dim('Service:')} ${theme.statusInactive('not installed')}`);
logger.logBoxLine('Service is not running'); logger.log('');
logger.logBoxEnd();
} }
} }
@@ -199,33 +271,44 @@ WantedBy=multi-user.target
// Check if we have the new multi-UPS config format // Check if we have the new multi-UPS config format
if (config.upsDevices && Array.isArray(config.upsDevices) && config.upsDevices.length > 0) { if (config.upsDevices && Array.isArray(config.upsDevices) && config.upsDevices.length > 0) {
logger.log(`Found ${config.upsDevices.length} UPS device(s) in configuration`); logger.info(`UPS Devices (${config.upsDevices.length}):`);
// Show status for each UPS // Show status for each UPS
for (const ups of config.upsDevices) { for (const ups of config.upsDevices) {
await this.displaySingleUpsStatus(ups, snmp); await this.displaySingleUpsStatus(ups, snmp);
} }
} else if (config.snmp) { } else if (config.snmp) {
// Legacy single UPS configuration // Legacy single UPS configuration (v1/v2 format)
const legacyUps = { logger.info('UPS Devices (1):');
const legacyUps: IUpsConfig = {
id: 'default', id: 'default',
name: 'Default UPS', name: 'Default UPS',
snmp: config.snmp, snmp: config.snmp,
thresholds: config.thresholds,
groups: [], groups: [],
actions: config.thresholds
? [
{
type: 'shutdown',
thresholds: config.thresholds,
triggerMode: 'onlyThresholds',
shutdownDelay: 5,
},
]
: [],
}; };
await this.displaySingleUpsStatus(legacyUps, snmp); await this.displaySingleUpsStatus(legacyUps, snmp);
} else { } else {
logger.error('No UPS devices found in configuration'); logger.log('');
logger.warn('No UPS devices configured');
logger.log(` ${theme.dim('Run')} ${theme.command('nupst ups add')} ${theme.dim('to add a device')}`);
logger.log('');
} }
} catch (error) { } catch (error) {
const boxWidth = 45; logger.log('');
logger.logBoxTitle('UPS Status', boxWidth); logger.error('Failed to retrieve UPS status');
logger.logBoxLine( logger.log(` ${theme.dim(error instanceof Error ? error.message : String(error))}`);
`Failed to retrieve UPS status: ${error instanceof Error ? error.message : String(error)}`, logger.log('');
);
logger.logBoxEnd();
} }
} }
@@ -234,25 +317,7 @@ WantedBy=multi-user.target
* @param ups UPS configuration * @param ups UPS configuration
* @param snmp SNMP manager * @param snmp SNMP manager
*/ */
private async displaySingleUpsStatus(ups: any, snmp: any): Promise<void> { private async displaySingleUpsStatus(ups: IUpsConfig, snmp: NupstSnmp): Promise<void> {
const boxWidth = 45;
logger.logBoxTitle(`Connecting to UPS: ${ups.name}`, boxWidth);
logger.logBoxLine(`ID: ${ups.id}`);
logger.logBoxLine(`Host: ${ups.snmp.host}:${ups.snmp.port}`);
logger.logBoxLine(`UPS Model: ${ups.snmp.upsModel || 'cyberpower'}`);
if (ups.groups && ups.groups.length > 0) {
// Get group names if available
const config = this.daemon.getConfig();
const groupNames = ups.groups.map((groupId: string) => {
const group = config.groups?.find((g: { id: string }) => g.id === groupId);
return group ? group.name : groupId;
});
logger.logBoxLine(`Groups: ${groupNames.join(', ')}`);
}
logger.logBoxEnd();
try { try {
// Create a test config with a short timeout // Create a test config with a short timeout
const testConfig = { const testConfig = {
@@ -262,32 +327,52 @@ WantedBy=multi-user.target
const status = await snmp.getUpsStatus(testConfig); const status = await snmp.getUpsStatus(testConfig);
logger.logBoxTitle(`UPS Status: ${ups.name}`, boxWidth); // Determine status symbol based on power status
logger.logBoxLine(`Power Status: ${status.powerStatus}`); let statusSymbol = symbols.unknown;
logger.logBoxLine(`Battery Capacity: ${status.batteryCapacity}%`); if (status.powerStatus === 'online') {
logger.logBoxLine(`Runtime Remaining: ${status.batteryRuntime} minutes`); statusSymbol = symbols.running;
} else if (status.powerStatus === 'onBattery') {
statusSymbol = symbols.warning;
}
// Show threshold status // Display UPS name and power status
logger.logBoxLine(''); logger.log(` ${statusSymbol} ${theme.highlight(ups.name)} - ${formatPowerStatus(status.powerStatus)}`);
logger.logBoxLine('Thresholds:');
logger.logBoxLine( // Display battery with color coding
` Battery: ${status.batteryCapacity}% / ${ups.thresholds.battery}% ${ const batteryColor = getBatteryColor(status.batteryCapacity);
status.batteryCapacity < ups.thresholds.battery ? '⚠️' : '✓'
}`, // Get threshold from actions (if any action has thresholds defined)
); const actionWithThresholds = ups.actions?.find((action) => action.thresholds);
logger.logBoxLine( const batteryThreshold = actionWithThresholds?.thresholds?.battery;
` Runtime: ${status.batteryRuntime} min / ${ups.thresholds.runtime} min ${ const batterySymbol = batteryThreshold !== undefined && status.batteryCapacity >= batteryThreshold
status.batteryRuntime < ups.thresholds.runtime ? '⚠️' : '✓' ? symbols.success
}`, : batteryThreshold !== undefined
); ? symbols.warning
: '';
logger.log(` Battery: ${batteryColor(status.batteryCapacity + '%')} ${batterySymbol} Runtime: ${getRuntimeColor(status.batteryRuntime)(status.batteryRuntime + ' min')}`);
// Display host info
logger.log(` ${theme.dim(`Host: ${ups.snmp.host}:${ups.snmp.port}`)}`);
// Display groups if any
if (ups.groups && ups.groups.length > 0) {
const config = this.daemon.getConfig();
const groupNames = ups.groups.map((groupId: string) => {
const group = config.groups?.find((g: { id: string }) => g.id === groupId);
return group ? group.name : groupId;
});
logger.log(` ${theme.dim(`Groups: ${groupNames.join(', ')}`)}`);
}
logger.log('');
logger.logBoxEnd();
} catch (error) { } catch (error) {
logger.logBoxTitle(`UPS Status: ${ups.name}`, boxWidth); // Display error for this UPS
logger.logBoxLine( logger.log(` ${symbols.error} ${theme.highlight(ups.name)} - ${theme.error('Connection failed')}`);
`Failed to retrieve UPS status: ${error instanceof Error ? error.message : String(error)}`, logger.log(` ${theme.dim(error instanceof Error ? error.message : String(error))}`);
); logger.log(` ${theme.dim(`Host: ${ups.snmp.host}:${ups.snmp.port}`)}`);
logger.logBoxEnd(); logger.log('');
} }
} }