Compare commits
16 Commits
Author | SHA1 | Date | |
---|---|---|---|
d6e0a1a274 | |||
95fa4f8b0b | |||
c2f2f1e2ee | |||
936f86c346 | |||
7ff1a7da36 | |||
a87710144c | |||
23fd5cc5cd | |||
fb4d776bdd | |||
88ad16c638 | |||
016681b77b | |||
49f7a7da8b | |||
f8269a1cb7 | |||
b37e1aae6c | |||
7076829747 | |||
1387ca262b | |||
684f034aee |
@@ -152,6 +152,29 @@ jobs:
|
|||||||
echo "Release notes:"
|
echo "Release notes:"
|
||||||
cat /tmp/release_notes.md
|
cat /tmp/release_notes.md
|
||||||
|
|
||||||
|
- name: Delete existing release if it exists
|
||||||
|
run: |
|
||||||
|
VERSION="${{ steps.version.outputs.version }}"
|
||||||
|
|
||||||
|
echo "Checking for existing release $VERSION..."
|
||||||
|
|
||||||
|
# Try to get existing release by tag
|
||||||
|
EXISTING_RELEASE_ID=$(curl -s \
|
||||||
|
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||||
|
"https://code.foss.global/api/v1/repos/serve.zone/nupst/releases/tags/$VERSION" \
|
||||||
|
| jq -r '.id // empty')
|
||||||
|
|
||||||
|
if [ -n "$EXISTING_RELEASE_ID" ]; then
|
||||||
|
echo "Found existing release (ID: $EXISTING_RELEASE_ID), deleting..."
|
||||||
|
curl -X DELETE -s \
|
||||||
|
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||||
|
"https://code.foss.global/api/v1/repos/serve.zone/nupst/releases/$EXISTING_RELEASE_ID"
|
||||||
|
echo "Existing release deleted"
|
||||||
|
sleep 2
|
||||||
|
else
|
||||||
|
echo "No existing release found, proceeding with creation"
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Create Gitea Release
|
- name: Create Gitea Release
|
||||||
run: |
|
run: |
|
||||||
VERSION="${{ steps.version.outputs.version }}"
|
VERSION="${{ steps.version.outputs.version }}"
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@serve.zone/nupst",
|
"name": "@serve.zone/nupst",
|
||||||
"version": "4.0.0",
|
"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",
|
||||||
|
100
install.sh
100
install.sh
@@ -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,29 +182,17 @@ 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 is running and stop it
|
# Check if service exists (enabled or running) and stop it if active
|
||||||
|
if systemctl is-enabled --quiet nupst 2>/dev/null || systemctl is-active --quiet nupst 2>/dev/null; then
|
||||||
|
SERVICE_WAS_RUNNING=1
|
||||||
if systemctl is-active --quiet nupst 2>/dev/null; then
|
if systemctl is-active --quiet nupst 2>/dev/null; then
|
||||||
echo "Stopping NUPST service..."
|
echo "Stopping NUPST service..."
|
||||||
systemctl stop nupst
|
systemctl stop nupst
|
||||||
SERVICE_WAS_RUNNING=1
|
else
|
||||||
|
echo "Service is installed but not currently running (will be updated)..."
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Clean up old Node.js installation files
|
# Clean up old Node.js installation files
|
||||||
@@ -265,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
|
||||||
@@ -321,25 +255,19 @@ 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 ""
|
||||||
|
|
||||||
|
# Update systemd service file if migrating from v3
|
||||||
|
if [ $SERVICE_WAS_RUNNING -eq 1 ] && [ $OLD_NODE_INSTALL -eq 1 ]; then
|
||||||
|
echo "Updating systemd service file for v4..."
|
||||||
|
$BINARY_PATH service enable > /dev/null 2>&1
|
||||||
|
echo "Service file updated."
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
# Restart service if it was running before update
|
# Restart service if it was running before update
|
||||||
if [ $SERVICE_WAS_RUNNING -eq 1 ]; then
|
if [ $SERVICE_WAS_RUNNING -eq 1 ]; then
|
||||||
echo "Restarting NUPST service..."
|
echo "Restarting NUPST service..."
|
||||||
|
16
readme.md
16
readme.md
@@ -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
|
||||||
|
168
test/manualdocker/00-test-fresh-v4-install.sh
Executable file
168
test/manualdocker/00-test-fresh-v4-install.sh
Executable 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 ""
|
@@ -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 "
|
||||||
|
@@ -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
233
test/showcase.ts
Normal 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
114
ts/cli.ts
@@ -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 : ''}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@@ -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) {
|
} catch (error) {
|
||||||
// If grep fails (service not found), serviceExists remains false
|
console.log('');
|
||||||
serviceExists = false;
|
logger.error('Update failed');
|
||||||
}
|
logger.dim(`${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
console.log('');
|
||||||
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) {
|
|
||||||
logger.logBoxLine('Error during update process:');
|
|
||||||
logger.logBoxLine(`${error instanceof Error ? error.message : String(error)}`);
|
|
||||||
logger.logBoxEnd();
|
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
88
ts/colors.ts
Normal file
88
ts/colors.ts
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
184
ts/daemon.ts
184
ts/daemon.ts
@@ -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
|
||||||
*/
|
*/
|
||||||
|
220
ts/logger.ts
220
ts/logger.ts
@@ -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
|
||||||
|
54
ts/migrations/base-migration.ts
Normal file
54
ts/migrations/base-migration.ts
Normal 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
10
ts/migrations/index.ts
Normal 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';
|
69
ts/migrations/migration-runner.ts
Normal file
69
ts/migrations/migration-runner.ts
Normal 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];
|
||||||
|
}
|
||||||
|
}
|
56
ts/migrations/migration-v1-to-v2.ts
Normal file
56
ts/migrations/migration-v1-to-v2.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
119
ts/migrations/migration-v3-to-v4.ts
Normal file
119
ts/migrations/migration-v3-to-v4.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
176
ts/systemd.ts
176
ts/systemd.ts
@@ -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
|
||||||
@@ -15,17 +16,17 @@ export class NupstSystemd {
|
|||||||
|
|
||||||
/** Template for the systemd service file */
|
/** Template for the systemd service file */
|
||||||
private readonly serviceTemplate = `[Unit]
|
private readonly serviceTemplate = `[Unit]
|
||||||
Description=Node.js UPS Shutdown Tool for Multiple UPS Devices
|
Description=NUPST - Deno-powered UPS Monitoring Tool
|
||||||
After=network.target
|
After=network.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
ExecStart=/opt/nupst/bin/nupst daemon-start
|
ExecStart=/usr/local/bin/nupst service start-daemon
|
||||||
Restart=always
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
User=root
|
User=root
|
||||||
Group=root
|
Group=root
|
||||||
Environment=PATH=/usr/bin:/usr/local/bin
|
Environment=PATH=/usr/bin:/usr/local/bin
|
||||||
Environment=NODE_ENV=production
|
WorkingDirectory=/opt/nupst
|
||||||
WorkingDirectory=/tmp
|
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
@@ -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('');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user