Compare commits

...

5 Commits

Author SHA1 Message Date
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
7 changed files with 369 additions and 240 deletions

View File

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

@@ -129,81 +129,32 @@ 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('Updating NUPST to latest version...');
logger.logBoxLine('Updating NUPST from repository...'); console.log('');
// 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 // Download and run the install script
logger.logBoxLine('Pulling latest changes from git repository...'); // This handles everything: download binary, stop service, replace, restart
execSync(`cd ${installDir} && git fetch origin && git reset --hard origin/main`, { const installUrl = 'https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh';
stdio: 'pipe',
logger.dim('Downloading install script...');
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('Update completed successfully!');
execSync(`cd ${installDir} && bash ./install.sh`, { stdio: 'pipe' }); logger.dim('Run "nupst service status" to verify the update.');
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

@@ -4,19 +4,38 @@ import { logger } from '../logger.ts';
/** /**
* Migration from v3 (upsList) to v4 (upsDevices) * Migration from v3 (upsList) to v4 (upsDevices)
* *
* Detects v3 format: * Transforms v3 format with flat SNMP config:
* { * {
* upsList: [ ... ], * upsList: [
* groups: [ ... ], * {
* checkInterval: 30000 * id: "ups-1",
* name: "UPS 1",
* host: "192.168.1.1",
* port: 161,
* community: "public",
* version: "1" // string
* }
* ]
* } * }
* *
* Converts to: * To v4 format with nested SNMP config:
* { * {
* version: "4.0", * version: "4.0",
* upsDevices: [ ... ], // renamed from upsList * upsDevices: [
* groups: [ ... ], * {
* checkInterval: 30000 * 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 { export class MigrationV3ToV4 extends BaseMigration {
@@ -25,21 +44,76 @@ export class MigrationV3ToV4 extends BaseMigration {
readonly toVersion = '4.0'; readonly toVersion = '4.0';
async shouldRun(config: any): Promise<boolean> { async shouldRun(config: any): Promise<boolean> {
// V3 format has upsList instead of upsDevices // V3 format has upsList OR has upsDevices with flat structure (host at top level)
return !!config.upsList && !config.upsDevices; 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> { async migrate(config: any): Promise<any> {
logger.info(`${this.getName()}: Renaming upsList to upsDevices...`); 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 = { const migrated = {
version: this.toVersion, version: this.toVersion,
upsDevices: config.upsList, // Rename upsList → upsDevices upsDevices: transformedDevices,
groups: config.groups || [], groups: config.groups || [],
checkInterval: config.checkInterval || 30000, checkInterval: config.checkInterval || 30000,
}; };
logger.success(`${this.getName()}: Migration complete`); logger.success(`${this.getName()}: Migration complete (${transformedDevices.length} devices transformed)`);
return migrated; 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
@@ -171,18 +172,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 +232,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 +240,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 +251,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 +270,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 +279,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('');
} }
} }