Compare commits

...

11 Commits

Author SHA1 Message Date
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
15 changed files with 699 additions and 303 deletions

View File

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

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) {

View File

@@ -15,7 +15,6 @@ export const theme = {
info: colors.cyan, info: colors.cyan,
dim: colors.dim, dim: colors.dim,
highlight: colors.bold, highlight: colors.bold,
bright: colors.bright,
// Status indicators // Status indicators
statusActive: (text: string) => colors.green(colors.bold(text)), statusActive: (text: string) => colors.green(colors.bold(text)),

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}`);

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;
}
}

View File

@@ -3,6 +3,7 @@ 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 } from './daemon.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 +50,11 @@ WantedBy=multi-user.target
try { try {
await fs.access(configPath); await fs.access(configPath);
} catch (error) { } catch (error) {
const boxWidth = 50; console.log('');
logger.logBoxTitle('Configuration Error', boxWidth); console.log(`${symbols.error} ${theme.error('No configuration found')}`);
logger.logBoxLine(`No configuration file found at ${configPath}`); console.log(` ${theme.dim('Config file:')} ${configPath}`);
logger.logBoxLine("Please run 'nupst add' first to create a UPS configuration."); console.log(` ${theme.dim('Run')} ${theme.command('nupst ups add')} ${theme.dim('to create a configuration')}`);
logger.logBoxEnd(); console.log('');
throw new Error('Configuration not found'); throw new Error('Configuration not found');
} }
} }
@@ -137,16 +138,12 @@ WantedBy=multi-user.target
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
this.daemon.getNupstSnmp().getNupst().logVersionInfo();
// Check if config exists first // Check if config exists first
try { try {
await this.checkConfigExists(); await this.checkConfigExists();
@@ -171,18 +168,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
console.log('');
if (isActive) {
console.log(`${symbols.running} ${theme.success('Service:')} ${theme.statusActive('active (running)')}`);
} else {
console.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)}`);
console.log(` ${details.join(' ')}`);
}
console.log('');
} catch (error) { } catch (error) {
const boxWidth = 45; console.log('');
logger.logBoxTitle('Service Status', boxWidth); console.log(`${symbols.stopped} ${theme.dim('Service:')} ${theme.statusInactive('not installed')}`);
logger.logBoxLine('Service is not running'); console.log('');
logger.logBoxEnd();
} }
} }
@@ -199,7 +228,7 @@ 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`); console.log(theme.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) {
@@ -207,6 +236,7 @@ WantedBy=multi-user.target
} }
} else if (config.snmp) { } else if (config.snmp) {
// Legacy single UPS configuration // Legacy single UPS configuration
console.log(theme.info('UPS Devices (1):'));
const legacyUps = { const legacyUps = {
id: 'default', id: 'default',
name: 'Default UPS', name: 'Default UPS',
@@ -217,15 +247,16 @@ WantedBy=multi-user.target
await this.displaySingleUpsStatus(legacyUps, snmp); await this.displaySingleUpsStatus(legacyUps, snmp);
} else { } else {
logger.error('No UPS devices found in configuration'); console.log('');
console.log(`${symbols.warning} ${theme.warning('No UPS devices configured')}`);
console.log(` ${theme.dim('Run')} ${theme.command('nupst ups add')} ${theme.dim('to add a device')}`);
console.log('');
} }
} catch (error) { } catch (error) {
const boxWidth = 45; console.log('');
logger.logBoxTitle('UPS Status', boxWidth); console.log(`${symbols.error} ${theme.error('Failed to retrieve UPS status')}`);
logger.logBoxLine( console.log(` ${theme.dim(error instanceof Error ? error.message : String(error))}`);
`Failed to retrieve UPS status: ${error instanceof Error ? error.message : String(error)}`, console.log('');
);
logger.logBoxEnd();
} }
} }
@@ -235,24 +266,6 @@ WantedBy=multi-user.target
* @param snmp SNMP manager * @param snmp SNMP manager
*/ */
private async displaySingleUpsStatus(ups: any, snmp: any): Promise<void> { private async displaySingleUpsStatus(ups: any, snmp: any): 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 +275,43 @@ 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(''); console.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 ? '⚠️' : '✓' const batterySymbol = status.batteryCapacity >= ups.thresholds.battery ? symbols.success : symbols.warning;
}`, console.log(` Battery: ${batteryColor(status.batteryCapacity + '%')} ${batterySymbol} Runtime: ${getRuntimeColor(status.batteryRuntime)(status.batteryRuntime + ' min')}`);
);
logger.logBoxLine( // Display host info
` Runtime: ${status.batteryRuntime} min / ${ups.thresholds.runtime} min ${ console.log(` ${theme.dim(`Host: ${ups.snmp.host}:${ups.snmp.port}`)}`);
status.batteryRuntime < ups.thresholds.runtime ? '⚠️' : '✓'
}`, // 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;
});
console.log(` ${theme.dim(`Groups: ${groupNames.join(', ')}`)}`);
}
console.log('');
logger.logBoxEnd();
} catch (error) { } catch (error) {
logger.logBoxTitle(`UPS Status: ${ups.name}`, boxWidth); // Display error for this UPS
logger.logBoxLine( console.log(` ${symbols.error} ${theme.highlight(ups.name)} - ${theme.error('Connection failed')}`);
`Failed to retrieve UPS status: ${error instanceof Error ? error.message : String(error)}`, console.log(` ${theme.dim(error instanceof Error ? error.message : String(error))}`);
); console.log(` ${theme.dim(`Host: ${ups.snmp.host}:${ups.snmp.port}`)}`);
logger.logBoxEnd(); console.log('');
} }
} }