Compare commits

...

7 Commits

Author SHA1 Message Date
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
16 changed files with 1283 additions and 225 deletions

View File

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

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
echo "Create symlink in $BIN_DIR for global access? (Y/n): "
read -r symlink_confirm
if [[ ! "$symlink_confirm" =~ ^[Nn]$ ]]; then
ln -sf "$BINARY_PATH" "$BIN_DIR/nupst" ln -sf "$BINARY_PATH" "$BIN_DIR/nupst"
echo "Symlink created: $BIN_DIR/nupst -> $BINARY_PATH" 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/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('');

114
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 } from './logger.ts';
import { theme, symbols } from './colors.ts';
/** /**
* Class for handling CLI commands * Class for handling CLI commands
@@ -475,58 +476,83 @@ export class NupstCli {
* 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 : ''}`);
} }
/** /**

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

@@ -6,6 +6,7 @@ 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 } from './snmp/types.ts';
import { logger } from './logger.ts'; import { logger } from './logger.ts';
import { MigrationRunner } from './migrations/index.ts';
const execAsync = promisify(exec); const execAsync = promisify(exec);
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
@@ -49,6 +50,8 @@ export interface IGroupConfig {
* 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;
@@ -91,6 +96,7 @@ export class NupstDaemon {
/** Default configuration */ /** Default configuration */
private readonly DEFAULT_CONFIG: INupstConfig = { private readonly DEFAULT_CONFIG: INupstConfig = {
version: '4.0',
upsDevices: [ upsDevices: [
{ {
id: 'default', id: 'default',
@@ -153,29 +159,16 @@ 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
if (migrated) {
// Save the new format this.config = migratedConfig;
await this.saveConfig(this.config); await this.saveConfig(this.config);
} else { } else {
this.config = parsedConfig; this.config = migratedConfig;
} }
return this.config; return this.config;
@@ -202,8 +195,17 @@ 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; // Ensure version is always set and remove legacy fields before saving
const configToSave: INupstConfig = {
version: '4.0',
upsDevices: config.upsDevices,
groups: config.groups,
checkInterval: config.checkInterval,
};
fs.writeFileSync(this.CONFIG_PATH, JSON.stringify(configToSave, null, 2));
this.config = configToSave;
console.log('┌─ Configuration Saved ─────────────────────┐'); console.log('┌─ Configuration Saved ─────────────────────┐');
console.log(`│ Location: ${this.CONFIG_PATH}`); console.log(`│ Location: ${this.CONFIG_PATH}`);
@@ -353,8 +355,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;
} }
@@ -890,6 +893,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,54 @@
/**
* 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).
*/
export abstract class BaseMigration {
/**
* Migration order number
* - Order 2: v1 → v2
* - Order 3: v2 → v3
* - Order 4: v3 → v4
* etc.
*/
abstract readonly order: number;
/**
* Source version this migration upgrades from
* e.g., "1.x", "3.x"
*/
abstract readonly fromVersion: string;
/**
* Target version this migration upgrades to
* e.g., "2.0", "4.0"
*/
abstract readonly toVersion: string;
/**
* Check if this migration should run on the given config
*
* @param config - Raw configuration object to check
* @returns True if migration should run, false otherwise
*/
abstract shouldRun(config: any): Promise<boolean>;
/**
* Perform the migration on the given config
*
* @param config - Raw configuration object to migrate
* @returns Migrated configuration object
*/
abstract migrate(config: any): Promise<any>;
/**
* Get human-readable name for this migration
*
* @returns Migration name
*/
getName(): string {
return `Migration ${this.fromVersion}${this.toVersion}`;
}
}

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

@@ -0,0 +1,10 @@
/**
* 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';

View File

@@ -0,0 +1,69 @@
import { BaseMigration } from './base-migration.ts';
import { MigrationV1ToV2 } from './migration-v1-to-v2.ts';
import { MigrationV3ToV4 } from './migration-v3-to-v4.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(),
// Add future migrations here (v4→v5, v5→v6, etc.)
];
// Sort by order to ensure they run in sequence
this.migrations.sort((a, b) => a.order - b.order);
}
/**
* Run all applicable migrations on the config
*
* @param config - Raw configuration object to migrate
* @returns Migrated configuration and whether migrations ran
*/
async run(config: any): Promise<{ config: any; migrated: boolean }> {
let currentConfig = config;
let anyMigrationsRan = false;
logger.dim('Checking for required config migrations...');
for (const migration of this.migrations) {
const shouldRun = await migration.shouldRun(currentConfig);
if (shouldRun) {
logger.info(`Running ${migration.getName()}...`);
currentConfig = await migration.migrate(currentConfig);
anyMigrationsRan = true;
}
}
if (anyMigrationsRan) {
logger.success('Configuration migrations complete');
} else {
logger.dim('No migrations needed');
}
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,56 @@
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 order = 2;
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,119 @@
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 order = 4;
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;
}
}