Compare commits
	
		
			73 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| a3d6a8b75d | |||
| fbd71b1f3b | |||
| 6481572981 | |||
| 0dc14a6ea1 | |||
| dea344e6ba | |||
| f81f5957ab | |||
| 281d3fbbeb | |||
| c1cb136a7d | |||
| b80275a594 | |||
| b64a515c94 | |||
| 68c4eb6480 | |||
| 6c8f6ac33f | |||
| ffa491c7a1 | |||
| 777d48d82e | |||
| b7a0bbcf6d | |||
| fbe1cd64cb | |||
| 9ba50da73c | |||
| 684319983d | |||
| 18bd9f6cda | |||
| f03c683d02 | |||
| f750299780 | |||
| ca1039408d | |||
| df3e0b9424 | |||
| c8e5960abd | |||
| 7304a62357 | |||
| a5a88e53ba | |||
| 73bc271c59 | |||
| 1e98181e71 | |||
| eb5a8185ae | |||
| ef3d3f3fa3 | |||
| 34e6e850ad | |||
| 992a776fd2 | |||
| 3e15a2d52f | |||
| d1a3576d31 | |||
| 1ca05e879b | |||
| 9c6fa37eb8 | |||
| ff433b2256 | |||
| 263d69aef1 | |||
| b6b7b43161 | |||
| 316c66c344 | |||
| 4debda856b | |||
| 0e7bcab499 | |||
| 7bf65d8495 | |||
| f2ce0180d3 | |||
| 8c1be6555f | |||
| 1a5558e91f | |||
| 611a9ddd19 | |||
| afd026d08c | |||
| 2c8ea44d40 | |||
| 32bd27b849 | |||
| a7113d0387 | |||
| 61d4e9037a | |||
| caced2718f | |||
| 8516056f84 | |||
| 07ec9d7595 | |||
| d14ba1dd65 | |||
| 7d595fa175 | |||
| df417432b0 | |||
| e5f1ebf343 | |||
| 3ff0dd7ac8 | |||
| bb87316dd3 | |||
| d6e0a1a274 | |||
| 95fa4f8b0b | |||
| c2f2f1e2ee | |||
| 936f86c346 | |||
| 7ff1a7da36 | |||
| a87710144c | |||
| 23fd5cc5cd | |||
| fb4d776bdd | |||
| 88ad16c638 | |||
| 016681b77b | |||
| 49f7a7da8b | |||
| f8269a1cb7 | 
							
								
								
									
										31
									
								
								.gitea/release-template.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								.gitea/release-template.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,31 @@ | |||||||
|  | ## NUPST {{VERSION}} | ||||||
|  |  | ||||||
|  | Pre-compiled binaries for multiple platforms. | ||||||
|  |  | ||||||
|  | ### Installation | ||||||
|  |  | ||||||
|  | #### Option 1: Via npm (recommended) | ||||||
|  | ```bash | ||||||
|  | npm install -g @serve.zone/nupst | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | #### Option 2: Via installer script | ||||||
|  | ```bash | ||||||
|  | curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | #### Option 3: Direct binary download | ||||||
|  | Download the appropriate binary for your platform from the assets below and make it executable. | ||||||
|  |  | ||||||
|  | ### Supported Platforms | ||||||
|  | - Linux x86_64 (x64) | ||||||
|  | - Linux ARM64 (aarch64) | ||||||
|  | - macOS x86_64 (Intel) | ||||||
|  | - macOS ARM64 (Apple Silicon) | ||||||
|  | - Windows x86_64 | ||||||
|  |  | ||||||
|  | ### Checksums | ||||||
|  | SHA256 checksums are provided in `SHA256SUMS.txt` for binary verification. | ||||||
|  |  | ||||||
|  | ### npm Package | ||||||
|  | The npm package includes automatic binary detection and installation for your platform. | ||||||
| @@ -21,7 +21,7 @@ jobs: | |||||||
|       - name: Set up Deno |       - name: Set up Deno | ||||||
|         uses: denoland/setup-deno@v1 |         uses: denoland/setup-deno@v1 | ||||||
|         with: |         with: | ||||||
|           deno-version: v1.x |           deno-version: v2.x | ||||||
|  |  | ||||||
|       - name: Check TypeScript types |       - name: Check TypeScript types | ||||||
|         run: deno check mod.ts |         run: deno check mod.ts | ||||||
| @@ -45,7 +45,7 @@ jobs: | |||||||
|       - name: Set up Deno |       - name: Set up Deno | ||||||
|         uses: denoland/setup-deno@v1 |         uses: denoland/setup-deno@v1 | ||||||
|         with: |         with: | ||||||
|           deno-version: v1.x |           deno-version: v2.x | ||||||
|  |  | ||||||
|       - name: Compile for current platform |       - name: Compile for current platform | ||||||
|         run: | |         run: | | ||||||
| @@ -71,7 +71,7 @@ jobs: | |||||||
|       - name: Set up Deno |       - name: Set up Deno | ||||||
|         uses: denoland/setup-deno@v1 |         uses: denoland/setup-deno@v1 | ||||||
|         with: |         with: | ||||||
|           deno-version: v1.x |           deno-version: v2.x | ||||||
|  |  | ||||||
|       - name: Compile all platform binaries |       - name: Compile all platform binaries | ||||||
|         run: bash scripts/compile-all.sh |         run: bash scripts/compile-all.sh | ||||||
|   | |||||||
							
								
								
									
										129
									
								
								.gitea/workflows/npm-publish.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										129
									
								
								.gitea/workflows/npm-publish.yml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,129 @@ | |||||||
|  | name: Publish to npm | ||||||
|  |  | ||||||
|  | on: | ||||||
|  |   push: | ||||||
|  |     tags: | ||||||
|  |       - 'v*' | ||||||
|  |  | ||||||
|  | jobs: | ||||||
|  |   npm-publish: | ||||||
|  |     runs-on: ubuntu-latest | ||||||
|  |  | ||||||
|  |     steps: | ||||||
|  |       - name: Checkout code | ||||||
|  |         uses: actions/checkout@v4 | ||||||
|  |  | ||||||
|  |       - name: Set up Deno | ||||||
|  |         uses: denoland/setup-deno@v1 | ||||||
|  |         with: | ||||||
|  |           deno-version: v2.x | ||||||
|  |  | ||||||
|  |       - name: Setup Node.js for npm publishing | ||||||
|  |         uses: actions/setup-node@v4 | ||||||
|  |         with: | ||||||
|  |           node-version: '18.x' | ||||||
|  |           registry-url: 'https://registry.npmjs.org/' | ||||||
|  |  | ||||||
|  |       - name: Get version from tag | ||||||
|  |         id: version | ||||||
|  |         run: | | ||||||
|  |           VERSION=${GITHUB_REF#refs/tags/} | ||||||
|  |           echo "version=$VERSION" >> $GITHUB_OUTPUT | ||||||
|  |           echo "version_number=${VERSION#v}" >> $GITHUB_OUTPUT | ||||||
|  |           echo "Publishing version: $VERSION" | ||||||
|  |  | ||||||
|  |       - name: Verify deno.json version matches tag | ||||||
|  |         run: | | ||||||
|  |           DENO_VERSION=$(grep -o '"version": "[^"]*"' deno.json | cut -d'"' -f4) | ||||||
|  |           TAG_VERSION="${{ steps.version.outputs.version_number }}" | ||||||
|  |           echo "deno.json version: $DENO_VERSION" | ||||||
|  |           echo "Tag version: $TAG_VERSION" | ||||||
|  |           if [ "$DENO_VERSION" != "$TAG_VERSION" ]; then | ||||||
|  |             echo "ERROR: Version mismatch!" | ||||||
|  |             echo "deno.json has version $DENO_VERSION but tag is $TAG_VERSION" | ||||||
|  |             exit 1 | ||||||
|  |           fi | ||||||
|  |  | ||||||
|  |       - name: Compile binaries for npm package | ||||||
|  |         run: | | ||||||
|  |           echo "Compiling binaries for npm package..." | ||||||
|  |           deno task compile | ||||||
|  |           echo "" | ||||||
|  |           echo "Binary sizes:" | ||||||
|  |           ls -lh dist/binaries/ | ||||||
|  |  | ||||||
|  |       - name: Generate SHA256 checksums | ||||||
|  |         run: | | ||||||
|  |           cd dist/binaries | ||||||
|  |           sha256sum * > SHA256SUMS | ||||||
|  |           cat SHA256SUMS | ||||||
|  |           cd ../.. | ||||||
|  |  | ||||||
|  |       - name: Sync package.json version | ||||||
|  |         run: | | ||||||
|  |           VERSION="${{ steps.version.outputs.version_number }}" | ||||||
|  |           echo "Syncing package.json to version ${VERSION}..." | ||||||
|  |           npm version ${VERSION} --no-git-tag-version --allow-same-version | ||||||
|  |           echo "package.json version: $(grep '"version"' package.json | head -1)" | ||||||
|  |  | ||||||
|  |       - name: Create npm package | ||||||
|  |         run: | | ||||||
|  |           echo "Creating npm package..." | ||||||
|  |           npm pack | ||||||
|  |           echo "" | ||||||
|  |           echo "Package created:" | ||||||
|  |           ls -lh *.tgz | ||||||
|  |  | ||||||
|  |       - name: Test local installation | ||||||
|  |         run: | | ||||||
|  |           echo "Testing local package installation..." | ||||||
|  |           PACKAGE_FILE=$(ls *.tgz) | ||||||
|  |           npm install -g ${PACKAGE_FILE} | ||||||
|  |           echo "" | ||||||
|  |           echo "Testing nupst command:" | ||||||
|  |           nupst --version || echo "Note: Binary execution may fail in CI environment" | ||||||
|  |           echo "" | ||||||
|  |           echo "Checking installed files:" | ||||||
|  |           npm ls -g @serve.zone/nupst || true | ||||||
|  |  | ||||||
|  |       - name: Publish to npm | ||||||
|  |         env: | ||||||
|  |           NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} | ||||||
|  |         run: | | ||||||
|  |           echo "Publishing to npm registry..." | ||||||
|  |           npm publish --access public | ||||||
|  |           echo "" | ||||||
|  |           echo "✅ Successfully published @serve.zone/nupst to npm!" | ||||||
|  |           echo "" | ||||||
|  |           echo "Package info:" | ||||||
|  |           npm view @serve.zone/nupst | ||||||
|  |  | ||||||
|  |       - name: Verify npm package | ||||||
|  |         run: | | ||||||
|  |           echo "Waiting for npm propagation..." | ||||||
|  |           sleep 30 | ||||||
|  |           echo "" | ||||||
|  |           echo "Verifying published package..." | ||||||
|  |           npm view @serve.zone/nupst | ||||||
|  |           echo "" | ||||||
|  |           echo "Testing installation from npm:" | ||||||
|  |           npm install -g @serve.zone/nupst | ||||||
|  |           echo "" | ||||||
|  |           echo "Package installed successfully!" | ||||||
|  |           which nupst || echo "Binary location check skipped" | ||||||
|  |  | ||||||
|  |       - name: Publish Summary | ||||||
|  |         run: | | ||||||
|  |           echo "================================================" | ||||||
|  |           echo "  npm Publish Complete!" | ||||||
|  |           echo "================================================" | ||||||
|  |           echo "" | ||||||
|  |           echo "✅ Package: @serve.zone/nupst" | ||||||
|  |           echo "✅ Version: ${{ steps.version.outputs.version }}" | ||||||
|  |           echo "" | ||||||
|  |           echo "Installation:" | ||||||
|  |           echo "  npm install -g @serve.zone/nupst" | ||||||
|  |           echo "" | ||||||
|  |           echo "Registry:" | ||||||
|  |           echo "  https://www.npmjs.com/package/@serve.zone/nupst" | ||||||
|  |           echo "" | ||||||
| @@ -18,7 +18,7 @@ jobs: | |||||||
|       - name: Set up Deno |       - name: Set up Deno | ||||||
|         uses: denoland/setup-deno@v1 |         uses: denoland/setup-deno@v1 | ||||||
|         with: |         with: | ||||||
|           deno-version: v1.x |           deno-version: v2.x | ||||||
|  |  | ||||||
|       - name: Get version from tag |       - name: Get version from tag | ||||||
|         id: version |         id: version | ||||||
|   | |||||||
							
								
								
									
										54
									
								
								.npmignore
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								.npmignore
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,54 @@ | |||||||
|  | # Source code (not needed for binary distribution) | ||||||
|  | /ts/ | ||||||
|  | /test/ | ||||||
|  | mod.ts | ||||||
|  | *.ts | ||||||
|  |  | ||||||
|  | # Development files | ||||||
|  | .git/ | ||||||
|  | .gitea/ | ||||||
|  | .claude/ | ||||||
|  | .serena/ | ||||||
|  | .nogit/ | ||||||
|  | .github/ | ||||||
|  | deno.json | ||||||
|  | deno.lock | ||||||
|  | tsconfig.json | ||||||
|  |  | ||||||
|  | # Scripts not needed for npm | ||||||
|  | /scripts/compile-all.sh | ||||||
|  | install.sh | ||||||
|  | uninstall.sh | ||||||
|  | example-action.sh | ||||||
|  |  | ||||||
|  | # Documentation files not needed for npm package | ||||||
|  | readme.plan.md | ||||||
|  | readme.hints.md | ||||||
|  | npm-publish-instructions.md | ||||||
|  | docs/ | ||||||
|  |  | ||||||
|  | # IDE and editor files | ||||||
|  | .vscode/ | ||||||
|  | .idea/ | ||||||
|  | *.swp | ||||||
|  | *.swo | ||||||
|  | *~ | ||||||
|  | .DS_Store | ||||||
|  |  | ||||||
|  | # Keep only the install-binary.js in scripts/ | ||||||
|  | /scripts/* | ||||||
|  | !/scripts/install-binary.js | ||||||
|  |  | ||||||
|  | # Exclude all dist directory (binaries will be downloaded during install) | ||||||
|  | /dist/ | ||||||
|  |  | ||||||
|  | # Logs and temporary files | ||||||
|  | *.log | ||||||
|  | npm-debug.log* | ||||||
|  | yarn-debug.log* | ||||||
|  | yarn-error.log* | ||||||
|  |  | ||||||
|  | # Other | ||||||
|  | node_modules/ | ||||||
|  | .env | ||||||
|  | .env.* | ||||||
							
								
								
									
										108
									
								
								bin/nupst-wrapper.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										108
									
								
								bin/nupst-wrapper.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,108 @@ | |||||||
|  | #!/usr/bin/env node | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * NUPST npm wrapper | ||||||
|  |  * This script executes the appropriate pre-compiled binary based on the current platform | ||||||
|  |  */ | ||||||
|  |  | ||||||
|  | import { spawn } from 'child_process'; | ||||||
|  | import { fileURLToPath } from 'url'; | ||||||
|  | import { dirname, join } from 'path'; | ||||||
|  | import { existsSync } from 'fs'; | ||||||
|  | import { platform, arch } from 'os'; | ||||||
|  |  | ||||||
|  | const __filename = fileURLToPath(import.meta.url); | ||||||
|  | const __dirname = dirname(__filename); | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Get the binary name for the current platform | ||||||
|  |  */ | ||||||
|  | function getBinaryName() { | ||||||
|  |   const plat = platform(); | ||||||
|  |   const architecture = arch(); | ||||||
|  |  | ||||||
|  |   // Map Node's platform/arch to our binary naming | ||||||
|  |   const platformMap = { | ||||||
|  |     'darwin': 'macos', | ||||||
|  |     'linux': 'linux', | ||||||
|  |     'win32': 'windows' | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   const archMap = { | ||||||
|  |     'x64': 'x64', | ||||||
|  |     'arm64': 'arm64' | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   const mappedPlatform = platformMap[plat]; | ||||||
|  |   const mappedArch = archMap[architecture]; | ||||||
|  |  | ||||||
|  |   if (!mappedPlatform || !mappedArch) { | ||||||
|  |     console.error(`Error: Unsupported platform/architecture: ${plat}/${architecture}`); | ||||||
|  |     console.error('Supported platforms: Linux, macOS, Windows'); | ||||||
|  |     console.error('Supported architectures: x64, arm64'); | ||||||
|  |     process.exit(1); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   // Construct binary name | ||||||
|  |   let binaryName = `nupst-${mappedPlatform}-${mappedArch}`; | ||||||
|  |   if (plat === 'win32') { | ||||||
|  |     binaryName += '.exe'; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   return binaryName; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Execute the binary | ||||||
|  |  */ | ||||||
|  | function executeBinary() { | ||||||
|  |   const binaryName = getBinaryName(); | ||||||
|  |   const binaryPath = join(__dirname, '..', 'dist', 'binaries', binaryName); | ||||||
|  |  | ||||||
|  |   // Check if binary exists | ||||||
|  |   if (!existsSync(binaryPath)) { | ||||||
|  |     console.error(`Error: Binary not found at ${binaryPath}`); | ||||||
|  |     console.error('This might happen if:'); | ||||||
|  |     console.error('1. The postinstall script failed to run'); | ||||||
|  |     console.error('2. The platform is not supported'); | ||||||
|  |     console.error('3. The package was not installed correctly'); | ||||||
|  |     console.error(''); | ||||||
|  |     console.error('Try reinstalling the package:'); | ||||||
|  |     console.error('  npm uninstall -g @serve.zone/nupst'); | ||||||
|  |     console.error('  npm install -g @serve.zone/nupst'); | ||||||
|  |     process.exit(1); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   // Spawn the binary with all arguments passed through | ||||||
|  |   const child = spawn(binaryPath, process.argv.slice(2), { | ||||||
|  |     stdio: 'inherit', | ||||||
|  |     shell: false | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   // Handle child process events | ||||||
|  |   child.on('error', (err) => { | ||||||
|  |     console.error(`Error executing nupst: ${err.message}`); | ||||||
|  |     process.exit(1); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   child.on('exit', (code, signal) => { | ||||||
|  |     if (signal) { | ||||||
|  |       process.kill(process.pid, signal); | ||||||
|  |     } else { | ||||||
|  |       process.exit(code || 0); | ||||||
|  |     } | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   // Forward signals to child process | ||||||
|  |   const signals = ['SIGINT', 'SIGTERM', 'SIGHUP']; | ||||||
|  |   signals.forEach(signal => { | ||||||
|  |     process.on(signal, () => { | ||||||
|  |       if (!child.killed) { | ||||||
|  |         child.kill(signal); | ||||||
|  |       } | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Execute | ||||||
|  | executeBinary(); | ||||||
							
								
								
									
										32
									
								
								changelog.md
									
									
									
									
									
								
							
							
						
						
									
										32
									
								
								changelog.md
									
									
									
									
									
								
							| @@ -1,5 +1,37 @@ | |||||||
| # Changelog | # Changelog | ||||||
|  |  | ||||||
|  | ## 2025-10-23 - 5.1.9 - fix(dev) | ||||||
|  | Add local assistant permissions/settings file (.claude/settings.local.json) | ||||||
|  |  | ||||||
|  | - Added .claude/settings.local.json containing local assistant permission configuration used for development tasks (deno check, deno lint/format, npm/pack, running packaged binaries, etc.) | ||||||
|  | - This is a development/local configuration file and does not change runtime behavior or product code paths | ||||||
|  | - Patch version bump recommended | ||||||
|  |  | ||||||
|  | ## 2025-10-23 - 5.1.2 - fix(scripts) | ||||||
|  | Add build script to package.json and include local dev tool settings | ||||||
|  |  | ||||||
|  | - Add a 'build' script to package.json (no-op placeholder) to provide an explicit build step | ||||||
|  | - Minor scripts section formatting tidy in package.json | ||||||
|  | - Add a hidden local settings file for development tooling permissions to the repository (local-only configuration) | ||||||
|  |  | ||||||
|  | ## 2025-10-23 - 5.1.1 - fix(tooling) | ||||||
|  | Add .claude/settings.local.json with local automation permissions | ||||||
|  |  | ||||||
|  | - Add .claude/settings.local.json to specify allowed permissions for local automated tasks | ||||||
|  | - Grants permissions for various developer/CI actions (deno check/lint/fmt, npm/npm pack, selective Bash commands, WebFetch to docs.deno.com and code.foss.global, and file/read/replace helpers) | ||||||
|  | - This is a developer/local tooling config only and does not change runtime code or package behavior | ||||||
|  |  | ||||||
|  | ## 2025-10-22 - 5.1.0 - feat(packaging) | ||||||
|  | Add npm packaging and installer: wrapper, postinstall downloader, publish workflow, and packaging files | ||||||
|  |  | ||||||
|  | - Add package.json (v5.0.5) and npm packaging metadata to publish @serve.zone/nupst | ||||||
|  | - Include a small Node.js wrapper (bin/nupst-wrapper.js) to execute platform-specific precompiled binaries | ||||||
|  | - Add postinstall script (scripts/install-binary.js) that downloads the correct binary for the current platform and sets executable permissions | ||||||
|  | - Add GitHub Actions workflow (.github/workflows/npm-publish.yml) to build binaries, pack and publish to npm, and create releases | ||||||
|  | - Add .npmignore to keep source, tests and dev files out of npm package; keep only runtime installer and wrapper | ||||||
|  | - Move example action script into docs (docs/example-action.sh) and remove the top-level example-action.sh | ||||||
|  | - Include generated npm package artifact (serve.zone-nupst-5.0.5.tgz) and npmextra.json | ||||||
|  |  | ||||||
| ## 2025-10-18 - 4.0.0 - BREAKING CHANGE(core): Complete migration to Deno runtime | ## 2025-10-18 - 4.0.0 - BREAKING CHANGE(core): Complete migration to Deno runtime | ||||||
|  |  | ||||||
| **MAJOR RELEASE: NUPST v4.0 is a complete rewrite powered by Deno** | **MAJOR RELEASE: NUPST v4.0 is a complete rewrite powered by Deno** | ||||||
|   | |||||||
| @@ -1,7 +1,8 @@ | |||||||
| { | { | ||||||
|   "name": "@serve.zone/nupst", |   "name": "@serve.zone/nupst", | ||||||
|   "version": "4.0.1", |   "version": "5.1.8", | ||||||
|   "exports": "./mod.ts", |   "exports": "./mod.ts", | ||||||
|  |   "nodeModulesDir": "auto", | ||||||
|   "tasks": { |   "tasks": { | ||||||
|     "dev": "deno run --allow-all mod.ts", |     "dev": "deno run --allow-all mod.ts", | ||||||
|     "compile": "deno task compile:all", |     "compile": "deno task compile:all", | ||||||
|   | |||||||
							
								
								
									
										122
									
								
								docs/example-action.sh
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										122
									
								
								docs/example-action.sh
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,122 @@ | |||||||
|  | #!/bin/bash | ||||||
|  | # NUPST Action Script Example | ||||||
|  | # Copy this to /etc/nupst/ and customize for your needs | ||||||
|  | # | ||||||
|  | # This script is called by NUPST when power events or threshold violations occur. | ||||||
|  | # It receives UPS state information via environment variables and command-line arguments. | ||||||
|  |  | ||||||
|  | # ============================================================================== | ||||||
|  | # ARGUMENTS (positional parameters) | ||||||
|  | # ============================================================================== | ||||||
|  | # $1 = Power Status (online|onBattery|unknown) | ||||||
|  | # $2 = Battery Capacity (percentage, 0-100) | ||||||
|  | # $3 = Battery Runtime (estimated minutes remaining) | ||||||
|  |  | ||||||
|  | POWER_STATUS=$1 | ||||||
|  | BATTERY_CAPACITY=$2 | ||||||
|  | BATTERY_RUNTIME=$3 | ||||||
|  |  | ||||||
|  | # ============================================================================== | ||||||
|  | # ENVIRONMENT VARIABLES | ||||||
|  | # ============================================================================== | ||||||
|  | # NUPST_UPS_ID               - Unique UPS identifier | ||||||
|  | # NUPST_UPS_NAME             - Human-readable UPS name | ||||||
|  | # NUPST_POWER_STATUS         - Current power status | ||||||
|  | # NUPST_BATTERY_CAPACITY     - Battery percentage (0-100) | ||||||
|  | # NUPST_BATTERY_RUNTIME      - Estimated runtime in minutes | ||||||
|  | # NUPST_THRESHOLDS_EXCEEDED  - "true" if below configured thresholds | ||||||
|  | # NUPST_TRIGGER_REASON       - "powerStatusChange" or "thresholdViolation" | ||||||
|  | # NUPST_BATTERY_THRESHOLD    - Configured battery threshold percentage | ||||||
|  | # NUPST_RUNTIME_THRESHOLD    - Configured runtime threshold in minutes | ||||||
|  | # NUPST_TIMESTAMP            - Unix timestamp (milliseconds since epoch) | ||||||
|  |  | ||||||
|  | # ============================================================================== | ||||||
|  | # EXAMPLE: Log the event | ||||||
|  | # ============================================================================== | ||||||
|  | LOG_FILE="/var/log/nupst-actions.log" | ||||||
|  |  | ||||||
|  | echo "========================================" >> "$LOG_FILE" | ||||||
|  | echo "NUPST Action Triggered: $(date)" >> "$LOG_FILE" | ||||||
|  | echo "----------------------------------------" >> "$LOG_FILE" | ||||||
|  | echo "UPS: $NUPST_UPS_NAME ($NUPST_UPS_ID)" >> "$LOG_FILE" | ||||||
|  | echo "Power Status: $POWER_STATUS" >> "$LOG_FILE" | ||||||
|  | echo "Battery: $BATTERY_CAPACITY%" >> "$LOG_FILE" | ||||||
|  | echo "Runtime: $BATTERY_RUNTIME minutes" >> "$LOG_FILE" | ||||||
|  | echo "Trigger Reason: $NUPST_TRIGGER_REASON" >> "$LOG_FILE" | ||||||
|  | echo "Thresholds Exceeded: $NUPST_THRESHOLDS_EXCEEDED" >> "$LOG_FILE" | ||||||
|  | echo "========================================" >> "$LOG_FILE" | ||||||
|  |  | ||||||
|  | # ============================================================================== | ||||||
|  | # EXAMPLE: Send email notification | ||||||
|  | # ============================================================================== | ||||||
|  | # if [ "$NUPST_TRIGGER_REASON" = "thresholdViolation" ]; then | ||||||
|  | #   echo "ALERT: UPS $NUPST_UPS_NAME battery critical!" | \ | ||||||
|  | #     mail -s "UPS Battery Critical" admin@example.com | ||||||
|  | # fi | ||||||
|  |  | ||||||
|  | # ============================================================================== | ||||||
|  | # EXAMPLE: Gracefully shutdown virtual machines | ||||||
|  | # ============================================================================== | ||||||
|  | # if [ "$NUPST_POWER_STATUS" = "onBattery" ] && [ "$NUPST_THRESHOLDS_EXCEEDED" = "true" ]; then | ||||||
|  | #   echo "Shutting down VMs..." >> "$LOG_FILE" | ||||||
|  | #   # virsh shutdown vm1 | ||||||
|  | #   # virsh shutdown vm2 | ||||||
|  | #   # Wait for VMs to shutdown | ||||||
|  | #   # sleep 120 | ||||||
|  | # fi | ||||||
|  |  | ||||||
|  | # ============================================================================== | ||||||
|  | # EXAMPLE: Call external API/service | ||||||
|  | # ============================================================================== | ||||||
|  | # curl -X POST https://monitoring.example.com/ups-alert \ | ||||||
|  | #   -H "Content-Type: application/json" \ | ||||||
|  | #   -d "{ | ||||||
|  | #     \"upsId\": \"$NUPST_UPS_ID\", | ||||||
|  | #     \"upsName\": \"$NUPST_UPS_NAME\", | ||||||
|  | #     \"powerStatus\": \"$POWER_STATUS\", | ||||||
|  | #     \"batteryCapacity\": $BATTERY_CAPACITY, | ||||||
|  | #     \"batteryRuntime\": $BATTERY_RUNTIME, | ||||||
|  | #     \"triggerReason\": \"$NUPST_TRIGGER_REASON\" | ||||||
|  | #   }" | ||||||
|  |  | ||||||
|  | # ============================================================================== | ||||||
|  | # EXAMPLE: Remote shutdown via SSH with password | ||||||
|  | # ============================================================================== | ||||||
|  | # You can implement custom shutdown logic for remote systems | ||||||
|  | # that require password authentication or webhooks | ||||||
|  | # | ||||||
|  | # if [ "$NUPST_THRESHOLDS_EXCEEDED" = "true" ]; then | ||||||
|  | #   # Call a webhook with a secret password/token | ||||||
|  | #   curl -X POST "https://remote-server.local/shutdown?token=YOUR_SECRET_TOKEN" | ||||||
|  | # | ||||||
|  | #   # Or use SSH with password (requires sshpass) | ||||||
|  | #   # sshpass -p 'your-password' ssh user@remote-server 'sudo shutdown -h +5' | ||||||
|  | # fi | ||||||
|  |  | ||||||
|  | # ============================================================================== | ||||||
|  | # EXAMPLE: Conditional logic based on battery level | ||||||
|  | # ============================================================================== | ||||||
|  | # if [ "$BATTERY_CAPACITY" -lt 20 ]; then | ||||||
|  | #   echo "Battery critically low! Immediate action needed." >> "$LOG_FILE" | ||||||
|  | # elif [ "$BATTERY_CAPACITY" -lt 50 ]; then | ||||||
|  | #   echo "Battery low. Preparing for shutdown." >> "$LOG_FILE" | ||||||
|  | # else | ||||||
|  | #   echo "Battery acceptable. Monitoring." >> "$LOG_FILE" | ||||||
|  | # fi | ||||||
|  |  | ||||||
|  | # ============================================================================== | ||||||
|  | # EXAMPLE: Different actions for different trigger reasons | ||||||
|  | # ============================================================================== | ||||||
|  | # case "$NUPST_TRIGGER_REASON" in | ||||||
|  | #   powerStatusChange) | ||||||
|  | #     echo "Power status changed to: $POWER_STATUS" >> "$LOG_FILE" | ||||||
|  | #     # Send notification but don't take drastic action yet | ||||||
|  | #     ;; | ||||||
|  | #   thresholdViolation) | ||||||
|  | #     echo "Thresholds violated! Taking emergency action." >> "$LOG_FILE" | ||||||
|  | #     # Initiate graceful shutdowns, save data, etc. | ||||||
|  | #     ;; | ||||||
|  | # esac | ||||||
|  |  | ||||||
|  | # Exit with success | ||||||
|  | exit 0 | ||||||
							
								
								
									
										168
									
								
								install.sh
									
									
									
									
									
								
							
							
						
						
									
										168
									
								
								install.sh
									
									
									
									
									
								
							| @@ -1,6 +1,6 @@ | |||||||
| #!/bin/bash | #!/bin/bash | ||||||
|  |  | ||||||
| # NUPST Installer Script (v4.0+) | # NUPST Installer Script (v5.0+) | ||||||
| # Downloads and installs pre-compiled NUPST binary from Gitea releases | # Downloads and installs pre-compiled NUPST binary from Gitea releases | ||||||
| # | # | ||||||
| # Usage: | # Usage: | ||||||
| @@ -8,17 +8,9 @@ | |||||||
| #     curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash | #     curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash | ||||||
| # | # | ||||||
| #   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 v5.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 | ||||||
| @@ -61,15 +48,14 @@ while [[ $# -gt 0 ]]; do | |||||||
| done | done | ||||||
|  |  | ||||||
| if [ $SHOW_HELP -eq 1 ]; then | if [ $SHOW_HELP -eq 1 ]; then | ||||||
|   echo "NUPST Installer Script (v4.0+)" |   echo "NUPST Installer Script (v5.0+)" | ||||||
|   echo "Downloads and installs pre-compiled NUPST binary" |   echo "Downloads and installs pre-compiled NUPST binary" | ||||||
|   echo "" |   echo "" | ||||||
|   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., v5.0.0)" | ||||||
|   echo "  --install-dir DIR      Installation directory (default: /opt/nupst)" |   echo "  --install-dir DIR      Installation directory (default: /opt/nupst)" | ||||||
|   echo "" |   echo "" | ||||||
|   echo "Examples:" |   echo "Examples:" | ||||||
| @@ -77,10 +63,7 @@ if [ $SHOW_HELP -eq 1 ]; then | |||||||
|   echo "  curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash" |   echo "  curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash" | ||||||
|   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 v5.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) | ||||||
| @@ -188,7 +145,7 @@ get_latest_version() { | |||||||
|  |  | ||||||
| # Main installation process | # Main installation process | ||||||
| echo "================================================" | echo "================================================" | ||||||
| echo "  NUPST Installation Script (v4.0+)" | echo "  NUPST Installation Script (v5.0+)" | ||||||
| echo "================================================" | echo "================================================" | ||||||
| echo "" | echo "" | ||||||
|  |  | ||||||
| @@ -212,77 +169,25 @@ DOWNLOAD_URL="${GITEA_BASE_URL}/${GITEA_REPO}/releases/download/${VERSION}/${BIN | |||||||
| echo "Download URL: $DOWNLOAD_URL" | echo "Download URL: $DOWNLOAD_URL" | ||||||
| echo "" | echo "" | ||||||
|  |  | ||||||
| # Check if installation directory exists | # Check if service is running and stop it | ||||||
| SERVICE_WAS_RUNNING=0 | SERVICE_WAS_RUNNING=0 | ||||||
| OLD_NODE_INSTALL=0 |  | ||||||
|  |  | ||||||
| if [ -d "$INSTALL_DIR" ]; then |  | ||||||
|   # Check if this is an old Node.js-based installation |  | ||||||
|   if [ -f "$INSTALL_DIR/package.json" ] || [ -d "$INSTALL_DIR/node_modules" ]; then |  | ||||||
|     OLD_NODE_INSTALL=1 |  | ||||||
|     echo "Detected old Node.js-based NUPST installation (v3.x or earlier)" |  | ||||||
|     echo "This installer will migrate to the new Deno-based binary version (v4.0+)" |  | ||||||
|     echo "" |  | ||||||
|   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..." |  | ||||||
|  |  | ||||||
|   # 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 | if systemctl is-enabled --quiet nupst 2>/dev/null || systemctl is-active --quiet nupst 2>/dev/null; then | ||||||
|   SERVICE_WAS_RUNNING=1 |   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 | ||||||
|     else |  | ||||||
|       echo "Service is installed but not currently running (will be updated)..." |  | ||||||
|   fi |   fi | ||||||
| fi | fi | ||||||
|  |  | ||||||
|   # Clean up old Node.js installation files | # Clean installation directory - ensure only binary exists | ||||||
|   if [ $OLD_NODE_INSTALL -eq 1 ]; then | if [ -d "$INSTALL_DIR" ]; then | ||||||
|     echo "Cleaning up old Node.js installation files..." |   echo "Cleaning installation directory: $INSTALL_DIR" | ||||||
|     rm -rf "$INSTALL_DIR/node_modules" 2>/dev/null || true |   rm -rf "$INSTALL_DIR" | ||||||
|     rm -rf "$INSTALL_DIR/vendor" 2>/dev/null || true |  | ||||||
|     rm -rf "$INSTALL_DIR/dist_ts" 2>/dev/null || true |  | ||||||
|     rm -f "$INSTALL_DIR/package.json" 2>/dev/null || true |  | ||||||
|     rm -f "$INSTALL_DIR/package-lock.json" 2>/dev/null || true |  | ||||||
|     rm -f "$INSTALL_DIR/pnpm-lock.yaml" 2>/dev/null || true |  | ||||||
|     rm -f "$INSTALL_DIR/tsconfig.json" 2>/dev/null || true |  | ||||||
|     rm -f "$INSTALL_DIR/setup.sh" 2>/dev/null || true |  | ||||||
|     rm -rf "$INSTALL_DIR/bin" 2>/dev/null || true |  | ||||||
|     echo "Old installation files removed." |  | ||||||
|   fi |  | ||||||
| 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 | fi | ||||||
|  |  | ||||||
|  | # Create fresh installation directory | ||||||
| echo "Creating installation directory: $INSTALL_DIR" | echo "Creating installation directory: $INSTALL_DIR" | ||||||
| mkdir -p "$INSTALL_DIR" | mkdir -p "$INSTALL_DIR" | ||||||
| fi |  | ||||||
|  |  | ||||||
| # Download binary | # Download binary | ||||||
| echo "Downloading NUPST binary..." | echo "Downloading NUPST binary..." | ||||||
| @@ -311,9 +216,20 @@ fi | |||||||
| BINARY_PATH="$INSTALL_DIR/nupst" | BINARY_PATH="$INSTALL_DIR/nupst" | ||||||
| mv "$TEMP_FILE" "$BINARY_PATH" | mv "$TEMP_FILE" "$BINARY_PATH" | ||||||
|  |  | ||||||
|  | if [ $? -ne 0 ] || [ ! -f "$BINARY_PATH" ]; then | ||||||
|  |   echo "Error: Failed to move binary to $BINARY_PATH" | ||||||
|  |   rm -f "$TEMP_FILE" 2>/dev/null | ||||||
|  |   exit 1 | ||||||
|  | fi | ||||||
|  |  | ||||||
| # Make executable | # Make executable | ||||||
| chmod +x "$BINARY_PATH" | chmod +x "$BINARY_PATH" | ||||||
|  |  | ||||||
|  | if [ $? -ne 0 ]; then | ||||||
|  |   echo "Error: Failed to make binary executable" | ||||||
|  |   exit 1 | ||||||
|  | fi | ||||||
|  |  | ||||||
| echo "Binary installed successfully to: $BINARY_PATH" | echo "Binary installed successfully to: $BINARY_PATH" | ||||||
| echo "" | echo "" | ||||||
|  |  | ||||||
| @@ -325,37 +241,15 @@ 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..." | ||||||
|   systemctl start nupst |   systemctl restart nupst | ||||||
|   echo "Service restarted successfully." |   echo "Service restarted successfully." | ||||||
|   echo "" |   echo "" | ||||||
| fi | fi | ||||||
| @@ -364,20 +258,6 @@ echo "================================================" | |||||||
| echo "  NUPST Installation Complete!" | echo "  NUPST Installation Complete!" | ||||||
| echo "================================================" | echo "================================================" | ||||||
| echo "" | echo "" | ||||||
|  |  | ||||||
| if [ $OLD_NODE_INSTALL -eq 1 ]; then |  | ||||||
|   echo "Migration from v3.x to v4.0 successful!" |  | ||||||
|   echo "" |  | ||||||
|   echo "What changed:" |  | ||||||
|   echo "  • Node.js runtime removed (now a self-contained binary)" |  | ||||||
|   echo "  • Faster startup and lower memory usage" |  | ||||||
|   echo "  • CLI commands now use subcommand structure" |  | ||||||
|   echo "    (old commands still work with deprecation warnings)" |  | ||||||
|   echo "" |  | ||||||
|   echo "See readme for migration details: https://code.foss.global/serve.zone/nupst#migration-from-v3x" |  | ||||||
|   echo "" |  | ||||||
| fi |  | ||||||
|  |  | ||||||
| echo "Installation details:" | echo "Installation details:" | ||||||
| echo "  Binary location: $BINARY_PATH" | echo "  Binary location: $BINARY_PATH" | ||||||
| echo "  Symlink location: $BIN_DIR/nupst" | echo "  Symlink location: $BIN_DIR/nupst" | ||||||
|   | |||||||
							
								
								
									
										1
									
								
								npmextra.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								npmextra.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | |||||||
|  | {} | ||||||
							
								
								
									
										64
									
								
								package.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										64
									
								
								package.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,64 @@ | |||||||
|  | { | ||||||
|  |   "name": "@serve.zone/nupst", | ||||||
|  |   "version": "5.1.9", | ||||||
|  |   "description": "Network UPS Shutdown Tool - Monitor SNMP-enabled UPS devices and orchestrate graceful system shutdowns during power emergencies", | ||||||
|  |   "keywords": [ | ||||||
|  |     "ups", | ||||||
|  |     "snmp", | ||||||
|  |     "power", | ||||||
|  |     "shutdown", | ||||||
|  |     "monitoring", | ||||||
|  |     "cyberpower", | ||||||
|  |     "apc", | ||||||
|  |     "eaton", | ||||||
|  |     "tripplite", | ||||||
|  |     "liebert", | ||||||
|  |     "vertiv", | ||||||
|  |     "battery", | ||||||
|  |     "backup" | ||||||
|  |   ], | ||||||
|  |   "homepage": "https://code.foss.global/serve.zone/nupst", | ||||||
|  |   "bugs": { | ||||||
|  |     "url": "https://code.foss.global/serve.zone/nupst/issues" | ||||||
|  |   }, | ||||||
|  |   "repository": { | ||||||
|  |     "type": "git", | ||||||
|  |     "url": "git+https://code.foss.global/serve.zone/nupst.git" | ||||||
|  |   }, | ||||||
|  |   "author": "Serve Zone", | ||||||
|  |   "license": "MIT", | ||||||
|  |   "type": "module", | ||||||
|  |   "bin": { | ||||||
|  |     "nupst": "./bin/nupst-wrapper.js" | ||||||
|  |   }, | ||||||
|  |   "scripts": { | ||||||
|  |     "postinstall": "node scripts/install-binary.js", | ||||||
|  |     "prepublishOnly": "echo 'Publishing NUPST binaries to npm...'", | ||||||
|  |     "test": "echo 'Tests are run with Deno: deno task test'", | ||||||
|  |     "build": "echo 'no build needed'" | ||||||
|  |   }, | ||||||
|  |   "files": [ | ||||||
|  |     "bin/", | ||||||
|  |     "scripts/install-binary.js", | ||||||
|  |     "readme.md", | ||||||
|  |     "license", | ||||||
|  |     "changelog.md" | ||||||
|  |   ], | ||||||
|  |   "engines": { | ||||||
|  |     "node": ">=14.0.0" | ||||||
|  |   }, | ||||||
|  |   "os": [ | ||||||
|  |     "darwin", | ||||||
|  |     "linux", | ||||||
|  |     "win32" | ||||||
|  |   ], | ||||||
|  |   "cpu": [ | ||||||
|  |     "x64", | ||||||
|  |     "arm64" | ||||||
|  |   ], | ||||||
|  |   "publishConfig": { | ||||||
|  |     "access": "public", | ||||||
|  |     "registry": "https://registry.npmjs.org/" | ||||||
|  |   }, | ||||||
|  |   "packageManager": "pnpm@10.18.1+sha512.77a884a165cbba2d8d1c19e3b4880eee6d2fcabd0d879121e282196b80042351d5eb3ca0935fa599da1dc51265cc68816ad2bddd2a2de5ea9fdf92adbec7cd34" | ||||||
|  | } | ||||||
							
								
								
									
										231
									
								
								scripts/install-binary.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										231
									
								
								scripts/install-binary.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,231 @@ | |||||||
|  | #!/usr/bin/env node | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * NUPST npm postinstall script | ||||||
|  |  * Downloads the appropriate binary for the current platform from GitHub releases | ||||||
|  |  */ | ||||||
|  |  | ||||||
|  | import { platform, arch } from 'os'; | ||||||
|  | import { existsSync, mkdirSync, writeFileSync, chmodSync, unlinkSync } from 'fs'; | ||||||
|  | import { join, dirname } from 'path'; | ||||||
|  | import { fileURLToPath } from 'url'; | ||||||
|  | import https from 'https'; | ||||||
|  | import { pipeline } from 'stream'; | ||||||
|  | import { promisify } from 'util'; | ||||||
|  | import { createWriteStream } from 'fs'; | ||||||
|  |  | ||||||
|  | const __filename = fileURLToPath(import.meta.url); | ||||||
|  | const __dirname = dirname(__filename); | ||||||
|  | const streamPipeline = promisify(pipeline); | ||||||
|  |  | ||||||
|  | // Configuration | ||||||
|  | const REPO_BASE = 'https://code.foss.global/serve.zone/nupst'; | ||||||
|  | const VERSION = process.env.npm_package_version || '5.0.5'; | ||||||
|  |  | ||||||
|  | function getBinaryInfo() { | ||||||
|  |   const plat = platform(); | ||||||
|  |   const architecture = arch(); | ||||||
|  |  | ||||||
|  |   const platformMap = { | ||||||
|  |     'darwin': 'macos', | ||||||
|  |     'linux': 'linux', | ||||||
|  |     'win32': 'windows' | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   const archMap = { | ||||||
|  |     'x64': 'x64', | ||||||
|  |     'arm64': 'arm64' | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   const mappedPlatform = platformMap[plat]; | ||||||
|  |   const mappedArch = archMap[architecture]; | ||||||
|  |  | ||||||
|  |   if (!mappedPlatform || !mappedArch) { | ||||||
|  |     return { supported: false, platform: plat, arch: architecture }; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   let binaryName = `nupst-${mappedPlatform}-${mappedArch}`; | ||||||
|  |   if (plat === 'win32') { | ||||||
|  |     binaryName += '.exe'; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   return { | ||||||
|  |     supported: true, | ||||||
|  |     platform: mappedPlatform, | ||||||
|  |     arch: mappedArch, | ||||||
|  |     binaryName, | ||||||
|  |     originalPlatform: plat | ||||||
|  |   }; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function downloadFile(url, destination) { | ||||||
|  |   return new Promise((resolve, reject) => { | ||||||
|  |     console.log(`Downloading from: ${url}`); | ||||||
|  |  | ||||||
|  |     // Follow redirects | ||||||
|  |     const download = (url, redirectCount = 0) => { | ||||||
|  |       if (redirectCount > 5) { | ||||||
|  |         reject(new Error('Too many redirects')); | ||||||
|  |         return; | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       https.get(url, (response) => { | ||||||
|  |         if (response.statusCode === 301 || response.statusCode === 302) { | ||||||
|  |           console.log(`Following redirect to: ${response.headers.location}`); | ||||||
|  |           download(response.headers.location, redirectCount + 1); | ||||||
|  |           return; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (response.statusCode !== 200) { | ||||||
|  |           reject(new Error(`Failed to download: ${response.statusCode} ${response.statusMessage}`)); | ||||||
|  |           return; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         const totalSize = parseInt(response.headers['content-length'], 10); | ||||||
|  |         let downloadedSize = 0; | ||||||
|  |         let lastProgress = 0; | ||||||
|  |  | ||||||
|  |         response.on('data', (chunk) => { | ||||||
|  |           downloadedSize += chunk.length; | ||||||
|  |           const progress = Math.round((downloadedSize / totalSize) * 100); | ||||||
|  |  | ||||||
|  |           // Only log every 10% to reduce noise | ||||||
|  |           if (progress >= lastProgress + 10) { | ||||||
|  |             console.log(`Download progress: ${progress}%`); | ||||||
|  |             lastProgress = progress; | ||||||
|  |           } | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |         const file = createWriteStream(destination); | ||||||
|  |  | ||||||
|  |         pipeline(response, file, (err) => { | ||||||
|  |           if (err) { | ||||||
|  |             reject(err); | ||||||
|  |           } else { | ||||||
|  |             console.log('Download complete!'); | ||||||
|  |             resolve(); | ||||||
|  |           } | ||||||
|  |         }); | ||||||
|  |       }).on('error', reject); | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     download(url); | ||||||
|  |   }); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | async function main() { | ||||||
|  |   console.log('==========================================='); | ||||||
|  |   console.log('  NUPST - Binary Installation'); | ||||||
|  |   console.log('==========================================='); | ||||||
|  |   console.log(''); | ||||||
|  |  | ||||||
|  |   const binaryInfo = getBinaryInfo(); | ||||||
|  |  | ||||||
|  |   if (!binaryInfo.supported) { | ||||||
|  |     console.error(`❌ Error: Unsupported platform/architecture: ${binaryInfo.platform}/${binaryInfo.arch}`); | ||||||
|  |     console.error(''); | ||||||
|  |     console.error('Supported platforms:'); | ||||||
|  |     console.error('  • Linux (x64, arm64)'); | ||||||
|  |     console.error('  • macOS (x64, arm64)'); | ||||||
|  |     console.error('  • Windows (x64)'); | ||||||
|  |     console.error(''); | ||||||
|  |     console.error('If you believe your platform should be supported, please file an issue:'); | ||||||
|  |     console.error('  https://code.foss.global/serve.zone/nupst/issues'); | ||||||
|  |     process.exit(1); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   console.log(`Platform: ${binaryInfo.platform} (${binaryInfo.originalPlatform})`); | ||||||
|  |   console.log(`Architecture: ${binaryInfo.arch}`); | ||||||
|  |   console.log(`Binary: ${binaryInfo.binaryName}`); | ||||||
|  |   console.log(`Version: ${VERSION}`); | ||||||
|  |   console.log(''); | ||||||
|  |  | ||||||
|  |   // Create dist/binaries directory if it doesn't exist | ||||||
|  |   const binariesDir = join(__dirname, '..', 'dist', 'binaries'); | ||||||
|  |   if (!existsSync(binariesDir)) { | ||||||
|  |     console.log('Creating binaries directory...'); | ||||||
|  |     mkdirSync(binariesDir, { recursive: true }); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   const binaryPath = join(binariesDir, binaryInfo.binaryName); | ||||||
|  |  | ||||||
|  |   // Check if binary already exists and skip download | ||||||
|  |   if (existsSync(binaryPath)) { | ||||||
|  |     console.log('✓ Binary already exists, skipping download'); | ||||||
|  |   } else { | ||||||
|  |     // Construct download URL | ||||||
|  |     // Try release URL first, fall back to raw branch if needed | ||||||
|  |     const releaseUrl = `${REPO_BASE}/releases/download/v${VERSION}/${binaryInfo.binaryName}`; | ||||||
|  |     const fallbackUrl = `${REPO_BASE}/raw/branch/main/dist/binaries/${binaryInfo.binaryName}`; | ||||||
|  |  | ||||||
|  |     console.log('Downloading platform-specific binary...'); | ||||||
|  |     console.log('This may take a moment depending on your connection speed.'); | ||||||
|  |     console.log(''); | ||||||
|  |  | ||||||
|  |     try { | ||||||
|  |       // Try downloading from release | ||||||
|  |       await downloadFile(releaseUrl, binaryPath); | ||||||
|  |     } catch (err) { | ||||||
|  |       console.log(`Release download failed: ${err.message}`); | ||||||
|  |       console.log('Trying fallback URL...'); | ||||||
|  |  | ||||||
|  |       try { | ||||||
|  |         // Try fallback URL | ||||||
|  |         await downloadFile(fallbackUrl, binaryPath); | ||||||
|  |       } catch (fallbackErr) { | ||||||
|  |         console.error(`❌ Error: Failed to download binary`); | ||||||
|  |         console.error(`  Primary URL: ${releaseUrl}`); | ||||||
|  |         console.error(`  Fallback URL: ${fallbackUrl}`); | ||||||
|  |         console.error(''); | ||||||
|  |         console.error('This might be because:'); | ||||||
|  |         console.error('1. The release has not been created yet'); | ||||||
|  |         console.error('2. Network connectivity issues'); | ||||||
|  |         console.error('3. The version specified does not exist'); | ||||||
|  |         console.error(''); | ||||||
|  |         console.error('You can try:'); | ||||||
|  |         console.error('1. Installing from source: https://code.foss.global/serve.zone/nupst'); | ||||||
|  |         console.error('2. Downloading the binary manually from the releases page'); | ||||||
|  |         console.error('3. Using the install script: curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash'); | ||||||
|  |  | ||||||
|  |         // Clean up partial download | ||||||
|  |         if (existsSync(binaryPath)) { | ||||||
|  |           unlinkSync(binaryPath); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         process.exit(1); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     console.log(`✓ Binary downloaded successfully`); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   // On Unix-like systems, ensure the binary is executable | ||||||
|  |   if (binaryInfo.originalPlatform !== 'win32') { | ||||||
|  |     try { | ||||||
|  |       console.log('Setting executable permissions...'); | ||||||
|  |       chmodSync(binaryPath, 0o755); | ||||||
|  |       console.log('✓ Binary permissions updated'); | ||||||
|  |     } catch (err) { | ||||||
|  |       console.error(`⚠️  Warning: Could not set executable permissions: ${err.message}`); | ||||||
|  |       console.error('   You may need to manually run:'); | ||||||
|  |       console.error(`   chmod +x ${binaryPath}`); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   console.log(''); | ||||||
|  |   console.log('✅ NUPST installation completed successfully!'); | ||||||
|  |   console.log(''); | ||||||
|  |   console.log('You can now use NUPST by running:'); | ||||||
|  |   console.log('  nupst --help'); | ||||||
|  |   console.log(''); | ||||||
|  |   console.log('For initial setup, run:'); | ||||||
|  |   console.log('  sudo nupst ups add'); | ||||||
|  |   console.log(''); | ||||||
|  |   console.log('==========================================='); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Run the installation | ||||||
|  | main().catch(err => { | ||||||
|  |   console.error(`❌ Installation failed: ${err.message}`); | ||||||
|  |   process.exit(1); | ||||||
|  | }); | ||||||
							
								
								
									
										
											BIN
										
									
								
								serve.zone-nupst-5.0.5.tgz
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								serve.zone-nupst-5.0.5.tgz
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										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/test.showcase.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										233
									
								
								test/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(''); | ||||||
| @@ -1,10 +1,8 @@ | |||||||
| /** | /** | ||||||
|  * commitinfo - reads version from deno.json |  * autocreated commitinfo by @push.rocks/commitinfo | ||||||
|  */ |  */ | ||||||
| import denoConfig from '../deno.json' with { type: 'json' }; |  | ||||||
|  |  | ||||||
| export const commitinfo = { | export const commitinfo = { | ||||||
|   name: denoConfig.name, |   name: '@serve.zone/nupst', | ||||||
|   version: denoConfig.version, |   version: '5.1.9', | ||||||
|   description: 'Deno-powered UPS monitoring tool for SNMP-enabled UPS devices', |   description: 'Network UPS Shutdown Tool - Monitor SNMP-enabled UPS devices and orchestrate graceful system shutdowns during power emergencies' | ||||||
| }; | } | ||||||
|   | |||||||
							
								
								
									
										170
									
								
								ts/actions/base-action.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										170
									
								
								ts/actions/base-action.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,170 @@ | |||||||
|  | /** | ||||||
|  |  * Base classes and interfaces for the NUPST action system | ||||||
|  |  * | ||||||
|  |  * Actions are triggered on: | ||||||
|  |  * 1. Power status changes (online ↔ onBattery) | ||||||
|  |  * 2. Threshold violations (battery/runtime cross below configured thresholds) | ||||||
|  |  */ | ||||||
|  |  | ||||||
|  | export type TPowerStatus = 'online' | 'onBattery' | 'unknown'; | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Context provided to actions when they execute | ||||||
|  |  * Contains all relevant UPS state and trigger information | ||||||
|  |  */ | ||||||
|  | export interface IActionContext { | ||||||
|  |   // UPS identification | ||||||
|  |   /** Unique ID of the UPS */ | ||||||
|  |   upsId: string; | ||||||
|  |   /** Human-readable name of the UPS */ | ||||||
|  |   upsName: string; | ||||||
|  |  | ||||||
|  |   // Current state | ||||||
|  |   /** Current power status */ | ||||||
|  |   powerStatus: TPowerStatus; | ||||||
|  |   /** Current battery capacity percentage (0-100) */ | ||||||
|  |   batteryCapacity: number; | ||||||
|  |   /** Estimated battery runtime in minutes */ | ||||||
|  |   batteryRuntime: number; | ||||||
|  |  | ||||||
|  |   // State tracking | ||||||
|  |   /** Previous power status before this trigger */ | ||||||
|  |   previousPowerStatus: TPowerStatus; | ||||||
|  |  | ||||||
|  |   // Metadata | ||||||
|  |   /** Timestamp when this action was triggered (milliseconds since epoch) */ | ||||||
|  |   timestamp: number; | ||||||
|  |   /** Reason this action was triggered */ | ||||||
|  |   triggerReason: 'powerStatusChange' | 'thresholdViolation'; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Action trigger mode - determines when an action executes | ||||||
|  |  */ | ||||||
|  | export type TActionTriggerMode = | ||||||
|  |   | 'onlyPowerChanges' // Only on power status changes (online ↔ onBattery) | ||||||
|  |   | 'onlyThresholds' // Only when action's thresholds are exceeded | ||||||
|  |   | 'powerChangesAndThresholds' // On power changes OR threshold violations | ||||||
|  |   | 'anyChange'; // On every UPS poll/check (every ~30s) | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Configuration for an action | ||||||
|  |  */ | ||||||
|  | export interface IActionConfig { | ||||||
|  |   /** Type of action to execute */ | ||||||
|  |   type: 'shutdown' | 'webhook' | 'script'; | ||||||
|  |  | ||||||
|  |   // Trigger configuration | ||||||
|  |   /** | ||||||
|  |    * When should this action be triggered? | ||||||
|  |    * - onlyPowerChanges: Only on power status changes | ||||||
|  |    * - onlyThresholds: Only when thresholds exceeded | ||||||
|  |    * - powerChangesAndThresholds: On both (default) | ||||||
|  |    * - anyChange: On every check | ||||||
|  |    */ | ||||||
|  |   triggerMode?: TActionTriggerMode; | ||||||
|  |  | ||||||
|  |   // Threshold configuration (applies to all action types) | ||||||
|  |   /** Threshold settings for this action */ | ||||||
|  |   thresholds?: { | ||||||
|  |     /** Battery percentage threshold (0-100) */ | ||||||
|  |     battery: number; | ||||||
|  |     /** Runtime threshold in minutes */ | ||||||
|  |     runtime: number; | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   // Shutdown action configuration | ||||||
|  |   /** Delay before shutdown in minutes (default: 5) */ | ||||||
|  |   shutdownDelay?: number; | ||||||
|  |   /** Only execute shutdown on threshold violation, not power status changes */ | ||||||
|  |   onlyOnThresholdViolation?: boolean; | ||||||
|  |  | ||||||
|  |   // Webhook action configuration | ||||||
|  |   /** URL to call for webhook */ | ||||||
|  |   webhookUrl?: string; | ||||||
|  |   /** HTTP method to use (default: POST) */ | ||||||
|  |   webhookMethod?: 'GET' | 'POST'; | ||||||
|  |   /** Timeout for webhook request in milliseconds (default: 10000) */ | ||||||
|  |   webhookTimeout?: number; | ||||||
|  |   /** Only execute webhook on threshold violation */ | ||||||
|  |   webhookOnlyOnThresholdViolation?: boolean; | ||||||
|  |  | ||||||
|  |   // Script action configuration | ||||||
|  |   /** Path to script relative to /etc/nupst (e.g., "myaction.sh") */ | ||||||
|  |   scriptPath?: string; | ||||||
|  |   /** Timeout for script execution in milliseconds (default: 60000) */ | ||||||
|  |   scriptTimeout?: number; | ||||||
|  |   /** Only execute script on threshold violation */ | ||||||
|  |   scriptOnlyOnThresholdViolation?: boolean; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Abstract base class for all actions | ||||||
|  |  * Each action type must extend this class and implement execute() | ||||||
|  |  */ | ||||||
|  | export abstract class Action { | ||||||
|  |   /** Type identifier for this action */ | ||||||
|  |   abstract readonly type: string; | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Create a new action with the given configuration | ||||||
|  |    * @param config Action configuration | ||||||
|  |    */ | ||||||
|  |   constructor(protected config: IActionConfig) {} | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Execute this action with the given context | ||||||
|  |    * @param context Current UPS state and trigger information | ||||||
|  |    */ | ||||||
|  |   abstract execute(context: IActionContext): Promise<void>; | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Helper to check if this action should execute based on trigger mode | ||||||
|  |    * @param context Action context with current UPS state | ||||||
|  |    * @returns True if action should execute | ||||||
|  |    */ | ||||||
|  |   protected shouldExecute(context: IActionContext): boolean { | ||||||
|  |     const mode = this.config.triggerMode || 'powerChangesAndThresholds'; // Default | ||||||
|  |  | ||||||
|  |     switch (mode) { | ||||||
|  |       case 'onlyPowerChanges': | ||||||
|  |         // Only execute on power status changes | ||||||
|  |         return context.triggerReason === 'powerStatusChange'; | ||||||
|  |  | ||||||
|  |       case 'onlyThresholds': | ||||||
|  |         // Only execute when this action's thresholds are exceeded | ||||||
|  |         if (!this.config.thresholds) return false; // No thresholds = never execute | ||||||
|  |         return this.areThresholdsExceeded(context.batteryCapacity, context.batteryRuntime); | ||||||
|  |  | ||||||
|  |       case 'powerChangesAndThresholds': | ||||||
|  |         // Execute on power changes OR when thresholds exceeded | ||||||
|  |         if (context.triggerReason === 'powerStatusChange') return true; | ||||||
|  |         if (!this.config.thresholds) return false; | ||||||
|  |         return this.areThresholdsExceeded(context.batteryCapacity, context.batteryRuntime); | ||||||
|  |  | ||||||
|  |       case 'anyChange': | ||||||
|  |         // Execute on every trigger (power change or threshold check) | ||||||
|  |         return true; | ||||||
|  |  | ||||||
|  |       default: | ||||||
|  |         return true; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Check if current battery/runtime exceeds this action's thresholds | ||||||
|  |    * @param batteryCapacity Current battery percentage | ||||||
|  |    * @param batteryRuntime Current runtime in minutes | ||||||
|  |    * @returns True if thresholds are exceeded | ||||||
|  |    */ | ||||||
|  |   protected areThresholdsExceeded(batteryCapacity: number, batteryRuntime: number): boolean { | ||||||
|  |     if (!this.config.thresholds) { | ||||||
|  |       return false; // No thresholds configured | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     return ( | ||||||
|  |       batteryCapacity < this.config.thresholds.battery || | ||||||
|  |       batteryRuntime < this.config.thresholds.runtime | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										91
									
								
								ts/actions/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										91
									
								
								ts/actions/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,91 @@ | |||||||
|  | /** | ||||||
|  |  * Action system exports and ActionManager | ||||||
|  |  * | ||||||
|  |  * This module provides the central coordination for the action system. | ||||||
|  |  * The ActionManager is responsible for creating and executing actions. | ||||||
|  |  */ | ||||||
|  |  | ||||||
|  | import { logger } from '../logger.ts'; | ||||||
|  | import type { Action, IActionConfig, IActionContext } from './base-action.ts'; | ||||||
|  | import { ShutdownAction } from './shutdown-action.ts'; | ||||||
|  | import { WebhookAction } from './webhook-action.ts'; | ||||||
|  | import { ScriptAction } from './script-action.ts'; | ||||||
|  |  | ||||||
|  | // Re-export types for convenience | ||||||
|  | export type { IActionConfig, IActionContext, TPowerStatus } from './base-action.ts'; | ||||||
|  | export { Action } from './base-action.ts'; | ||||||
|  | export { ShutdownAction } from './shutdown-action.ts'; | ||||||
|  | export { WebhookAction } from './webhook-action.ts'; | ||||||
|  | export { ScriptAction } from './script-action.ts'; | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * ActionManager - Coordinates action creation and execution | ||||||
|  |  * | ||||||
|  |  * Provides factory methods for creating actions from configuration | ||||||
|  |  * and orchestrates action execution with error handling. | ||||||
|  |  */ | ||||||
|  | export class ActionManager { | ||||||
|  |   /** | ||||||
|  |    * Create an action instance from configuration | ||||||
|  |    * @param config Action configuration | ||||||
|  |    * @returns Instantiated action | ||||||
|  |    * @throws Error if action type is unknown | ||||||
|  |    */ | ||||||
|  |   static createAction(config: IActionConfig): Action { | ||||||
|  |     switch (config.type) { | ||||||
|  |       case 'shutdown': | ||||||
|  |         return new ShutdownAction(config); | ||||||
|  |       case 'webhook': | ||||||
|  |         return new WebhookAction(config); | ||||||
|  |       case 'script': | ||||||
|  |         return new ScriptAction(config); | ||||||
|  |       default: | ||||||
|  |         throw new Error(`Unknown action type: ${(config as IActionConfig).type}`); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Execute a sequence of actions with the given context | ||||||
|  |    * Each action runs sequentially, and failures are logged but don't stop the chain | ||||||
|  |    * @param actions Array of action configurations to execute | ||||||
|  |    * @param context Action context with UPS state | ||||||
|  |    */ | ||||||
|  |   static async executeActions( | ||||||
|  |     actions: IActionConfig[], | ||||||
|  |     context: IActionContext, | ||||||
|  |   ): Promise<void> { | ||||||
|  |     if (!actions || actions.length === 0) { | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     logger.log(''); | ||||||
|  |     logger.logBoxTitle(`Executing ${actions.length} Action(s)`, 60, 'info'); | ||||||
|  |     logger.logBoxLine(`Trigger: ${context.triggerReason}`); | ||||||
|  |     logger.logBoxLine(`UPS: ${context.upsName} (${context.upsId})`); | ||||||
|  |     logger.logBoxLine(`Power: ${context.powerStatus}`); | ||||||
|  |     logger.logBoxLine(`Battery: ${context.batteryCapacity}% / ${context.batteryRuntime} min`); | ||||||
|  |     logger.logBoxEnd(); | ||||||
|  |     logger.log(''); | ||||||
|  |  | ||||||
|  |     for (let i = 0; i < actions.length; i++) { | ||||||
|  |       const actionConfig = actions[i]; | ||||||
|  |       try { | ||||||
|  |         logger.info(`[${i + 1}/${actions.length}] ${actionConfig.type} action...`); | ||||||
|  |  | ||||||
|  |         const action = this.createAction(actionConfig); | ||||||
|  |         await action.execute(context); | ||||||
|  |       } catch (error) { | ||||||
|  |         logger.error( | ||||||
|  |           `Action ${actionConfig.type} failed: ${ | ||||||
|  |             error instanceof Error ? error.message : String(error) | ||||||
|  |           }`, | ||||||
|  |         ); | ||||||
|  |         // Continue with next action despite failure | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     logger.log(''); | ||||||
|  |     logger.success('Action execution completed'); | ||||||
|  |     logger.log(''); | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										167
									
								
								ts/actions/script-action.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										167
									
								
								ts/actions/script-action.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,167 @@ | |||||||
|  | import * as path from 'node:path'; | ||||||
|  | import * as fs from 'node:fs'; | ||||||
|  | import process from 'node:process'; | ||||||
|  | import { exec } from 'node:child_process'; | ||||||
|  | import { promisify } from 'node:util'; | ||||||
|  | import { Action, type IActionConfig, type IActionContext } from './base-action.ts'; | ||||||
|  | import { logger } from '../logger.ts'; | ||||||
|  |  | ||||||
|  | const execAsync = promisify(exec); | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * ScriptAction - Executes a custom shell script from /etc/nupst/ | ||||||
|  |  * | ||||||
|  |  * Runs user-provided scripts with UPS state passed as environment variables and arguments. | ||||||
|  |  * Scripts must be .sh files located in /etc/nupst/ for security. | ||||||
|  |  */ | ||||||
|  | export class ScriptAction extends Action { | ||||||
|  |   readonly type = 'script'; | ||||||
|  |  | ||||||
|  |   private static readonly SCRIPT_DIR = '/etc/nupst'; | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Execute the script action | ||||||
|  |    * @param context Action context with UPS state | ||||||
|  |    */ | ||||||
|  |   async execute(context: IActionContext): Promise<void> { | ||||||
|  |     // Check if we should execute based on trigger mode | ||||||
|  |     if (!this.shouldExecute(context)) { | ||||||
|  |       logger.info(`Script action skipped (trigger mode: ${this.config.triggerMode || 'powerChangesAndThresholds'})`); | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (!this.config.scriptPath) { | ||||||
|  |       logger.error('Script path not configured'); | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Validate and build script path | ||||||
|  |     const scriptPath = this.validateAndBuildScriptPath(this.config.scriptPath); | ||||||
|  |     if (!scriptPath) { | ||||||
|  |       logger.error(`Invalid script path: ${this.config.scriptPath}`); | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Check if script exists and is executable | ||||||
|  |     if (!fs.existsSync(scriptPath)) { | ||||||
|  |       logger.error(`Script not found: ${scriptPath}`); | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     const timeout = this.config.scriptTimeout || 60000; // Default 60 seconds | ||||||
|  |  | ||||||
|  |     logger.info(`Executing script: ${scriptPath}`); | ||||||
|  |  | ||||||
|  |     try { | ||||||
|  |       await this.executeScript(scriptPath, context, timeout); | ||||||
|  |       logger.success('Script executed successfully'); | ||||||
|  |     } catch (error) { | ||||||
|  |       logger.error( | ||||||
|  |         `Script execution failed: ${error instanceof Error ? error.message : String(error)}`, | ||||||
|  |       ); | ||||||
|  |       // Don't throw - script failures shouldn't stop other actions | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Validate script path and build full path | ||||||
|  |    * Ensures security by preventing path traversal and limiting to /etc/nupst | ||||||
|  |    * @param scriptPath Relative script path from config | ||||||
|  |    * @returns Full validated path or null if invalid | ||||||
|  |    */ | ||||||
|  |   private validateAndBuildScriptPath(scriptPath: string): string | null { | ||||||
|  |     // Remove any leading/trailing whitespace | ||||||
|  |     scriptPath = scriptPath.trim(); | ||||||
|  |  | ||||||
|  |     // Reject paths with path traversal attempts | ||||||
|  |     if (scriptPath.includes('..') || scriptPath.includes('/') || scriptPath.includes('\\')) { | ||||||
|  |       logger.error('Script path must not contain directory separators or parent references'); | ||||||
|  |       return null; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Require .sh extension | ||||||
|  |     if (!scriptPath.endsWith('.sh')) { | ||||||
|  |       logger.error('Script must have .sh extension'); | ||||||
|  |       return null; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Build full path | ||||||
|  |     return path.join(ScriptAction.SCRIPT_DIR, scriptPath); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Execute the script with UPS state as environment variables and arguments | ||||||
|  |    * @param scriptPath Full path to the script | ||||||
|  |    * @param context Action context | ||||||
|  |    * @param timeout Execution timeout in milliseconds | ||||||
|  |    */ | ||||||
|  |   private async executeScript( | ||||||
|  |     scriptPath: string, | ||||||
|  |     context: IActionContext, | ||||||
|  |     timeout: number, | ||||||
|  |   ): Promise<void> { | ||||||
|  |     // Prepare environment variables | ||||||
|  |     const env = { | ||||||
|  |       ...process.env, | ||||||
|  |       NUPST_UPS_ID: context.upsId, | ||||||
|  |       NUPST_UPS_NAME: context.upsName, | ||||||
|  |       NUPST_POWER_STATUS: context.powerStatus, | ||||||
|  |       NUPST_BATTERY_CAPACITY: String(context.batteryCapacity), | ||||||
|  |       NUPST_BATTERY_RUNTIME: String(context.batteryRuntime), | ||||||
|  |       NUPST_TRIGGER_REASON: context.triggerReason, | ||||||
|  |       NUPST_TIMESTAMP: String(context.timestamp), | ||||||
|  |       // Include action's own thresholds if configured | ||||||
|  |       NUPST_BATTERY_THRESHOLD: this.config.thresholds ? String(this.config.thresholds.battery) : '', | ||||||
|  |       NUPST_RUNTIME_THRESHOLD: this.config.thresholds ? String(this.config.thresholds.runtime) : '', | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     // Build command with arguments | ||||||
|  |     // Arguments: powerStatus batteryCapacity batteryRuntime | ||||||
|  |     const args = [ | ||||||
|  |       context.powerStatus, | ||||||
|  |       String(context.batteryCapacity), | ||||||
|  |       String(context.batteryRuntime), | ||||||
|  |     ].join(' '); | ||||||
|  |  | ||||||
|  |     const command = `bash "${scriptPath}" ${args}`; | ||||||
|  |  | ||||||
|  |     try { | ||||||
|  |       const { stdout, stderr } = await execAsync(command, { | ||||||
|  |         env, | ||||||
|  |         cwd: ScriptAction.SCRIPT_DIR, | ||||||
|  |         timeout, | ||||||
|  |       }); | ||||||
|  |  | ||||||
|  |       // Log output | ||||||
|  |       if (stdout) { | ||||||
|  |         logger.log('Script stdout:'); | ||||||
|  |         logger.dim(stdout.trim()); | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       if (stderr) { | ||||||
|  |         logger.warn('Script stderr:'); | ||||||
|  |         logger.dim(stderr.trim()); | ||||||
|  |       } | ||||||
|  |     } catch (error) { | ||||||
|  |       // Check if it was a timeout | ||||||
|  |       if (error instanceof Error && 'killed' in error && error.killed) { | ||||||
|  |         throw new Error(`Script timed out after ${timeout}ms`); | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       // Include stdout/stderr in error if available | ||||||
|  |       if (error && typeof error === 'object' && 'stdout' in error && 'stderr' in error) { | ||||||
|  |         const execError = error as { stdout: string; stderr: string }; | ||||||
|  |         if (execError.stdout) { | ||||||
|  |           logger.log('Script stdout:'); | ||||||
|  |           logger.dim(execError.stdout.trim()); | ||||||
|  |         } | ||||||
|  |         if (execError.stderr) { | ||||||
|  |           logger.warn('Script stderr:'); | ||||||
|  |           logger.dim(execError.stderr.trim()); | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       throw error; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										142
									
								
								ts/actions/shutdown-action.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										142
									
								
								ts/actions/shutdown-action.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,142 @@ | |||||||
|  | import * as fs from 'node:fs'; | ||||||
|  | import { execFile } from 'node:child_process'; | ||||||
|  | import { promisify } from 'node:util'; | ||||||
|  | import { Action, type IActionConfig, type IActionContext } from './base-action.ts'; | ||||||
|  | import { logger } from '../logger.ts'; | ||||||
|  |  | ||||||
|  | const execFileAsync = promisify(execFile); | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * ShutdownAction - Initiates system shutdown | ||||||
|  |  * | ||||||
|  |  * This action triggers a system shutdown using the standard shutdown command. | ||||||
|  |  * It includes a configurable delay to allow VMs and services to gracefully terminate. | ||||||
|  |  */ | ||||||
|  | export class ShutdownAction extends Action { | ||||||
|  |   readonly type = 'shutdown'; | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Execute the shutdown action | ||||||
|  |    * @param context Action context with UPS state | ||||||
|  |    */ | ||||||
|  |   async execute(context: IActionContext): Promise<void> { | ||||||
|  |     // Check if we should execute based on trigger mode and thresholds | ||||||
|  |     if (!this.shouldExecute(context)) { | ||||||
|  |       logger.info(`Shutdown action skipped (trigger mode: ${this.config.triggerMode || 'powerChangesAndThresholds'})`); | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     const shutdownDelay = this.config.shutdownDelay || 5; // Default 5 minutes | ||||||
|  |  | ||||||
|  |     logger.log(''); | ||||||
|  |     logger.logBoxTitle('Initiating System Shutdown', 60, 'error'); | ||||||
|  |     logger.logBoxLine(`UPS: ${context.upsName} (${context.upsId})`); | ||||||
|  |     logger.logBoxLine(`Power Status: ${context.powerStatus}`); | ||||||
|  |     logger.logBoxLine(`Battery: ${context.batteryCapacity}%`); | ||||||
|  |     logger.logBoxLine(`Runtime: ${context.batteryRuntime} minutes`); | ||||||
|  |     logger.logBoxLine(`Trigger: ${context.triggerReason}`); | ||||||
|  |     logger.logBoxLine(`Shutdown delay: ${shutdownDelay} minutes`); | ||||||
|  |     logger.logBoxEnd(); | ||||||
|  |     logger.log(''); | ||||||
|  |  | ||||||
|  |     try { | ||||||
|  |       await this.executeShutdownCommand(shutdownDelay); | ||||||
|  |     } catch (error) { | ||||||
|  |       logger.error( | ||||||
|  |         `Shutdown command failed: ${error instanceof Error ? error.message : String(error)}`, | ||||||
|  |       ); | ||||||
|  |       // Try alternative methods | ||||||
|  |       await this.tryAlternativeShutdownMethods(); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Execute the primary shutdown command | ||||||
|  |    * @param delayMinutes Minutes to delay before shutdown | ||||||
|  |    */ | ||||||
|  |   private async executeShutdownCommand(delayMinutes: number): Promise<void> { | ||||||
|  |     // Find shutdown command in common system paths | ||||||
|  |     const shutdownPaths = [ | ||||||
|  |       '/sbin/shutdown', | ||||||
|  |       '/usr/sbin/shutdown', | ||||||
|  |       '/bin/shutdown', | ||||||
|  |       '/usr/bin/shutdown', | ||||||
|  |     ]; | ||||||
|  |  | ||||||
|  |     let shutdownCmd = ''; | ||||||
|  |     for (const path of shutdownPaths) { | ||||||
|  |       try { | ||||||
|  |         if (fs.existsSync(path)) { | ||||||
|  |           shutdownCmd = path; | ||||||
|  |           logger.log(`Found shutdown command at: ${shutdownCmd}`); | ||||||
|  |           break; | ||||||
|  |         } | ||||||
|  |       } catch (_e) { | ||||||
|  |         // Continue checking other paths | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (shutdownCmd) { | ||||||
|  |       // Execute shutdown command with delay to allow for VM graceful shutdown | ||||||
|  |       const message = `UPS battery critical, shutting down in ${delayMinutes} minutes`; | ||||||
|  |       logger.log(`Executing: ${shutdownCmd} -h +${delayMinutes} "${message}"`); | ||||||
|  |  | ||||||
|  |       const { stdout } = await execFileAsync(shutdownCmd, [ | ||||||
|  |         '-h', | ||||||
|  |         `+${delayMinutes}`, | ||||||
|  |         message, | ||||||
|  |       ]); | ||||||
|  |  | ||||||
|  |       logger.log(`Shutdown initiated: ${stdout}`); | ||||||
|  |       logger.log(`Allowing ${delayMinutes} minutes for VMs to shut down safely`); | ||||||
|  |     } else { | ||||||
|  |       throw new Error('Shutdown command not found in common paths'); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Try alternative shutdown methods if primary command fails | ||||||
|  |    */ | ||||||
|  |   private async tryAlternativeShutdownMethods(): Promise<void> { | ||||||
|  |     logger.error('Trying alternative shutdown methods...'); | ||||||
|  |  | ||||||
|  |     const alternatives = [ | ||||||
|  |       { cmd: 'poweroff', args: ['--force'] }, | ||||||
|  |       { cmd: 'halt', args: ['-p'] }, | ||||||
|  |       { cmd: 'systemctl', args: ['poweroff'] }, | ||||||
|  |       { cmd: 'reboot', args: ['-p'] }, // Some systems allow reboot -p for power off | ||||||
|  |     ]; | ||||||
|  |  | ||||||
|  |     for (const alt of alternatives) { | ||||||
|  |       try { | ||||||
|  |         // First check if command exists in common system paths | ||||||
|  |         const paths = [ | ||||||
|  |           `/sbin/${alt.cmd}`, | ||||||
|  |           `/usr/sbin/${alt.cmd}`, | ||||||
|  |           `/bin/${alt.cmd}`, | ||||||
|  |           `/usr/bin/${alt.cmd}`, | ||||||
|  |         ]; | ||||||
|  |  | ||||||
|  |         let cmdPath = ''; | ||||||
|  |         for (const path of paths) { | ||||||
|  |           if (fs.existsSync(path)) { | ||||||
|  |             cmdPath = path; | ||||||
|  |             break; | ||||||
|  |           } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (cmdPath) { | ||||||
|  |           logger.log(`Trying alternative shutdown method: ${cmdPath} ${alt.args.join(' ')}`); | ||||||
|  |           await execFileAsync(cmdPath, alt.args); | ||||||
|  |           logger.log(`Alternative method ${alt.cmd} succeeded`); | ||||||
|  |           return; // Exit if successful | ||||||
|  |         } | ||||||
|  |       } catch (_altError) { | ||||||
|  |         logger.error(`Alternative method ${alt.cmd} failed`); | ||||||
|  |         // Continue to next method | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     logger.error('All shutdown methods failed'); | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										141
									
								
								ts/actions/webhook-action.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										141
									
								
								ts/actions/webhook-action.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,141 @@ | |||||||
|  | import * as http from 'node:http'; | ||||||
|  | import * as https from 'node:https'; | ||||||
|  | import { URL } from 'node:url'; | ||||||
|  | import { Action, type IActionConfig, type IActionContext } from './base-action.ts'; | ||||||
|  | import { logger } from '../logger.ts'; | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * WebhookAction - Calls an HTTP webhook with UPS state information | ||||||
|  |  * | ||||||
|  |  * Sends UPS status to a configured webhook URL via GET or POST. | ||||||
|  |  * This is useful for remote notifications and integrations with external systems. | ||||||
|  |  */ | ||||||
|  | export class WebhookAction extends Action { | ||||||
|  |   readonly type = 'webhook'; | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Execute the webhook action | ||||||
|  |    * @param context Action context with UPS state | ||||||
|  |    */ | ||||||
|  |   async execute(context: IActionContext): Promise<void> { | ||||||
|  |     // Check if we should execute based on trigger mode | ||||||
|  |     if (!this.shouldExecute(context)) { | ||||||
|  |       logger.info(`Webhook action skipped (trigger mode: ${this.config.triggerMode || 'powerChangesAndThresholds'})`); | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (!this.config.webhookUrl) { | ||||||
|  |       logger.error('Webhook URL not configured'); | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     const method = this.config.webhookMethod || 'POST'; | ||||||
|  |     const timeout = this.config.webhookTimeout || 10000; | ||||||
|  |  | ||||||
|  |     logger.info(`Calling webhook: ${method} ${this.config.webhookUrl}`); | ||||||
|  |  | ||||||
|  |     try { | ||||||
|  |       await this.callWebhook(context, method, timeout); | ||||||
|  |       logger.success('Webhook call successful'); | ||||||
|  |     } catch (error) { | ||||||
|  |       logger.error( | ||||||
|  |         `Webhook call failed: ${error instanceof Error ? error.message : String(error)}`, | ||||||
|  |       ); | ||||||
|  |       // Don't throw - webhook failures shouldn't stop other actions | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Call the webhook with UPS state data | ||||||
|  |    * @param context Action context | ||||||
|  |    * @param method HTTP method (GET or POST) | ||||||
|  |    * @param timeout Request timeout in milliseconds | ||||||
|  |    */ | ||||||
|  |   private async callWebhook( | ||||||
|  |     context: IActionContext, | ||||||
|  |     method: 'GET' | 'POST', | ||||||
|  |     timeout: number, | ||||||
|  |   ): Promise<void> { | ||||||
|  |     const payload: any = { | ||||||
|  |       upsId: context.upsId, | ||||||
|  |       upsName: context.upsName, | ||||||
|  |       powerStatus: context.powerStatus, | ||||||
|  |       batteryCapacity: context.batteryCapacity, | ||||||
|  |       batteryRuntime: context.batteryRuntime, | ||||||
|  |       triggerReason: context.triggerReason, | ||||||
|  |       timestamp: context.timestamp, | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     // Include action's own thresholds if configured | ||||||
|  |     if (this.config.thresholds) { | ||||||
|  |       payload.thresholds = { | ||||||
|  |         battery: this.config.thresholds.battery, | ||||||
|  |         runtime: this.config.thresholds.runtime, | ||||||
|  |       }; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     const url = new URL(this.config.webhookUrl!); | ||||||
|  |  | ||||||
|  |     if (method === 'GET') { | ||||||
|  |       // Append payload as query parameters for GET | ||||||
|  |       url.searchParams.append('upsId', payload.upsId); | ||||||
|  |       url.searchParams.append('upsName', payload.upsName); | ||||||
|  |       url.searchParams.append('powerStatus', payload.powerStatus); | ||||||
|  |       url.searchParams.append('batteryCapacity', String(payload.batteryCapacity)); | ||||||
|  |       url.searchParams.append('batteryRuntime', String(payload.batteryRuntime)); | ||||||
|  |        | ||||||
|  |       url.searchParams.append('triggerReason', payload.triggerReason); | ||||||
|  |       url.searchParams.append('timestamp', String(payload.timestamp)); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     return new Promise((resolve, reject) => { | ||||||
|  |       const protocol = url.protocol === 'https:' ? https : http; | ||||||
|  |  | ||||||
|  |       const options: http.RequestOptions = { | ||||||
|  |         method, | ||||||
|  |         headers: method === 'POST' | ||||||
|  |           ? { | ||||||
|  |             'Content-Type': 'application/json', | ||||||
|  |             'User-Agent': 'nupst', | ||||||
|  |           } | ||||||
|  |           : { | ||||||
|  |             'User-Agent': 'nupst', | ||||||
|  |           }, | ||||||
|  |         timeout, | ||||||
|  |       }; | ||||||
|  |  | ||||||
|  |       const req = protocol.request(url, options, (res) => { | ||||||
|  |         let data = ''; | ||||||
|  |  | ||||||
|  |         res.on('data', (chunk) => { | ||||||
|  |           data += chunk; | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |         res.on('end', () => { | ||||||
|  |           if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) { | ||||||
|  |             logger.dim(`Webhook response (${res.statusCode}): ${data.substring(0, 100)}`); | ||||||
|  |             resolve(); | ||||||
|  |           } else { | ||||||
|  |             reject(new Error(`Webhook returned status ${res.statusCode}`)); | ||||||
|  |           } | ||||||
|  |         }); | ||||||
|  |       }); | ||||||
|  |  | ||||||
|  |       req.on('error', (error) => { | ||||||
|  |         reject(error); | ||||||
|  |       }); | ||||||
|  |  | ||||||
|  |       req.on('timeout', () => { | ||||||
|  |         req.destroy(); | ||||||
|  |         reject(new Error(`Webhook request timed out after ${timeout}ms`)); | ||||||
|  |       }); | ||||||
|  |  | ||||||
|  |       // Send POST data if applicable | ||||||
|  |       if (method === 'POST') { | ||||||
|  |         req.write(JSON.stringify(payload)); | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       req.end(); | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										548
									
								
								ts/cli.ts
									
									
									
									
									
								
							
							
						
						
									
										548
									
								
								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, type ITableColumn } from './logger.ts'; | ||||||
|  | import { theme, symbols } from './colors.ts'; | ||||||
|  |  | ||||||
| /** | /** | ||||||
|  * Class for handling CLI commands |  * Class for handling CLI commands | ||||||
| @@ -71,6 +72,7 @@ export class NupstCli { | |||||||
|     const upsHandler = this.nupst.getUpsHandler(); |     const upsHandler = this.nupst.getUpsHandler(); | ||||||
|     const groupHandler = this.nupst.getGroupHandler(); |     const groupHandler = this.nupst.getGroupHandler(); | ||||||
|     const serviceHandler = this.nupst.getServiceHandler(); |     const serviceHandler = this.nupst.getServiceHandler(); | ||||||
|  |     const actionHandler = this.nupst.getActionHandler(); | ||||||
|  |  | ||||||
|     // Handle service subcommands |     // Handle service subcommands | ||||||
|     if (command === 'service') { |     if (command === 'service') { | ||||||
| @@ -125,8 +127,7 @@ export class NupstCli { | |||||||
|           break; |           break; | ||||||
|         } |         } | ||||||
|         case 'remove': |         case 'remove': | ||||||
|         case 'rm': // Alias |         case 'rm': { | ||||||
|         case 'delete': { // Backward compatibility |  | ||||||
|           const upsIdToRemove = subcommandArgs[0]; |           const upsIdToRemove = subcommandArgs[0]; | ||||||
|           if (!upsIdToRemove) { |           if (!upsIdToRemove) { | ||||||
|             logger.error('UPS ID is required for remove command'); |             logger.error('UPS ID is required for remove command'); | ||||||
| @@ -170,8 +171,7 @@ export class NupstCli { | |||||||
|           break; |           break; | ||||||
|         } |         } | ||||||
|         case 'remove': |         case 'remove': | ||||||
|         case 'rm': // Alias |         case 'rm': { | ||||||
|         case 'delete': { // Backward compatibility |  | ||||||
|           const groupIdToRemove = subcommandArgs[0]; |           const groupIdToRemove = subcommandArgs[0]; | ||||||
|           if (!groupIdToRemove) { |           if (!groupIdToRemove) { | ||||||
|             logger.error('Group ID is required for remove command'); |             logger.error('Group ID is required for remove command'); | ||||||
| @@ -192,6 +192,55 @@ export class NupstCli { | |||||||
|       return; |       return; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     // Handle action subcommands | ||||||
|  |     if (command === 'action') { | ||||||
|  |       const subcommand = commandArgs[0] || 'list'; | ||||||
|  |       const subcommandArgs = commandArgs.slice(1); | ||||||
|  |  | ||||||
|  |       switch (subcommand) { | ||||||
|  |         case 'add': { | ||||||
|  |           const upsId = subcommandArgs[0]; | ||||||
|  |           await actionHandler.add(upsId); | ||||||
|  |           break; | ||||||
|  |         } | ||||||
|  |         case 'remove': | ||||||
|  |         case 'rm': { | ||||||
|  |           const upsId = subcommandArgs[0]; | ||||||
|  |           const actionIndex = subcommandArgs[1]; | ||||||
|  |           await actionHandler.remove(upsId, actionIndex); | ||||||
|  |           break; | ||||||
|  |         } | ||||||
|  |         case 'list': | ||||||
|  |         case 'ls': { // Alias | ||||||
|  |           const upsId = subcommandArgs[0]; | ||||||
|  |           await actionHandler.list(upsId); | ||||||
|  |           break; | ||||||
|  |         } | ||||||
|  |         default: | ||||||
|  |           this.showActionHelp(); | ||||||
|  |           break; | ||||||
|  |       } | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Handle feature subcommands | ||||||
|  |     if (command === 'feature') { | ||||||
|  |       const subcommand = commandArgs[0]; | ||||||
|  |       const featureHandler = this.nupst.getFeatureHandler(); | ||||||
|  |  | ||||||
|  |       switch (subcommand) { | ||||||
|  |         case 'httpServer': | ||||||
|  |         case 'http-server': | ||||||
|  |         case 'http': | ||||||
|  |           await featureHandler.configureHttpServer(); | ||||||
|  |           break; | ||||||
|  |         default: | ||||||
|  |           this.showFeatureHelp(); | ||||||
|  |           break; | ||||||
|  |       } | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |  | ||||||
|     // Handle config subcommand |     // Handle config subcommand | ||||||
|     if (command === 'config') { |     if (command === 'config') { | ||||||
|       const subcommand = commandArgs[0] || 'show'; |       const subcommand = commandArgs[0] || 'show'; | ||||||
| @@ -208,72 +257,8 @@ export class NupstCli { | |||||||
|       return; |       return; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     // Handle top-level commands and backward compatibility |     // Handle top-level commands | ||||||
|     switch (command) { |     switch (command) { | ||||||
|       // Backward compatibility - old UPS commands |  | ||||||
|       case 'add': |  | ||||||
|         logger.log("Note: 'nupst add' is deprecated. Use 'nupst ups add' instead."); |  | ||||||
|         await upsHandler.add(); |  | ||||||
|         break; |  | ||||||
|       case 'edit': |  | ||||||
|         logger.log("Note: 'nupst edit' is deprecated. Use 'nupst ups edit' instead."); |  | ||||||
|         await upsHandler.edit(commandArgs[0]); |  | ||||||
|         break; |  | ||||||
|       case 'delete': |  | ||||||
|         logger.log("Note: 'nupst delete' is deprecated. Use 'nupst ups remove' instead."); |  | ||||||
|         if (!commandArgs[0]) { |  | ||||||
|           logger.error('UPS ID is required for delete command'); |  | ||||||
|           this.showHelp(); |  | ||||||
|           return; |  | ||||||
|         } |  | ||||||
|         await upsHandler.remove(commandArgs[0]); |  | ||||||
|         break; |  | ||||||
|       case 'list': |  | ||||||
|         logger.log("Note: 'nupst list' is deprecated. Use 'nupst ups list' instead."); |  | ||||||
|         await upsHandler.list(); |  | ||||||
|         break; |  | ||||||
|       case 'test': |  | ||||||
|         logger.log("Note: 'nupst test' is deprecated. Use 'nupst ups test' instead."); |  | ||||||
|         await upsHandler.test(debugMode); |  | ||||||
|         break; |  | ||||||
|       case 'setup': |  | ||||||
|         logger.log("Note: 'nupst setup' is deprecated. Use 'nupst ups edit' instead."); |  | ||||||
|         await upsHandler.edit(undefined); |  | ||||||
|         break; |  | ||||||
|  |  | ||||||
|       // Backward compatibility - old service commands |  | ||||||
|       case 'enable': |  | ||||||
|         logger.log("Note: 'nupst enable' is deprecated. Use 'nupst service enable' instead."); |  | ||||||
|         await serviceHandler.enable(); |  | ||||||
|         break; |  | ||||||
|       case 'disable': |  | ||||||
|         logger.log("Note: 'nupst disable' is deprecated. Use 'nupst service disable' instead."); |  | ||||||
|         await serviceHandler.disable(); |  | ||||||
|         break; |  | ||||||
|       case 'start': |  | ||||||
|         logger.log("Note: 'nupst start' is deprecated. Use 'nupst service start' instead."); |  | ||||||
|         await serviceHandler.start(); |  | ||||||
|         break; |  | ||||||
|       case 'stop': |  | ||||||
|         logger.log("Note: 'nupst stop' is deprecated. Use 'nupst service stop' instead."); |  | ||||||
|         await serviceHandler.stop(); |  | ||||||
|         break; |  | ||||||
|       case 'status': |  | ||||||
|         logger.log("Note: 'nupst status' is deprecated. Use 'nupst service status' instead."); |  | ||||||
|         await serviceHandler.status(); |  | ||||||
|         break; |  | ||||||
|       case 'logs': |  | ||||||
|         logger.log("Note: 'nupst logs' is deprecated. Use 'nupst service logs' instead."); |  | ||||||
|         await serviceHandler.logs(); |  | ||||||
|         break; |  | ||||||
|       case 'daemon-start': |  | ||||||
|         logger.log( |  | ||||||
|           "Note: 'nupst daemon-start' is deprecated. Use 'nupst service start-daemon' instead.", |  | ||||||
|         ); |  | ||||||
|         await serviceHandler.daemonStart(debugMode); |  | ||||||
|         break; |  | ||||||
|  |  | ||||||
|       // Top-level commands (no changes) |  | ||||||
|       case 'update': |       case 'update': | ||||||
|         await serviceHandler.update(); |         await serviceHandler.update(); | ||||||
|         break; |         break; | ||||||
| @@ -302,154 +287,182 @@ export class NupstCli { | |||||||
|       try { |       try { | ||||||
|         await this.nupst.getDaemon().loadConfig(); |         await this.nupst.getDaemon().loadConfig(); | ||||||
|       } catch (_error) { |       } catch (_error) { | ||||||
|         const errorBoxWidth = 45; |         logger.logBox('Configuration Error', [ | ||||||
|         logger.logBoxTitle('Configuration Error', errorBoxWidth); |           'No configuration found.', | ||||||
|         logger.logBoxLine('No configuration found.'); |           "Please run 'nupst ups add' first to create a configuration.", | ||||||
|         logger.logBoxLine("Please run 'nupst setup' first to create a configuration."); |         ], 50, 'error'); | ||||||
|         logger.logBoxEnd(); |  | ||||||
|         return; |         return; | ||||||
|       } |       } | ||||||
|  |  | ||||||
|       // Get current configuration |       // Get current configuration | ||||||
|       const config = this.nupst.getDaemon().getConfig(); |       const config = this.nupst.getDaemon().getConfig(); | ||||||
|  |  | ||||||
|       const boxWidth = 50; |  | ||||||
|       logger.logBoxTitle('NUPST Configuration', boxWidth); |  | ||||||
|  |  | ||||||
|       // Check if multi-UPS config |       // Check if multi-UPS config | ||||||
|       if (config.upsDevices && Array.isArray(config.upsDevices)) { |       if (config.upsDevices && Array.isArray(config.upsDevices)) { | ||||||
|         // Multi-UPS configuration |         // === Multi-UPS Configuration === | ||||||
|         logger.logBoxLine(`UPS Devices: ${config.upsDevices.length}`); |  | ||||||
|         logger.logBoxLine(`Groups: ${config.groups ? config.groups.length : 0}`); |  | ||||||
|         logger.logBoxLine(`Check Interval: ${config.checkInterval / 1000} seconds`); |  | ||||||
|         logger.logBoxLine(''); |  | ||||||
|         logger.logBoxLine('Configuration File Location:'); |  | ||||||
|         logger.logBoxLine('  /etc/nupst/config.json'); |  | ||||||
|         logger.logBoxEnd(); |  | ||||||
|          |          | ||||||
|         // Show UPS devices |         // Overview Box | ||||||
|  |         logger.log(''); | ||||||
|  |         logger.logBox('NUPST Configuration', [ | ||||||
|  |           `UPS Devices: ${theme.highlight(String(config.upsDevices.length))}`, | ||||||
|  |           `Groups: ${theme.highlight(String(config.groups ? config.groups.length : 0))}`, | ||||||
|  |           `Check Interval: ${theme.info(String(config.checkInterval / 1000))} seconds`, | ||||||
|  |           '', | ||||||
|  |           theme.dim('Configuration File:'), | ||||||
|  |           `  ${theme.path('/etc/nupst/config.json')}`, | ||||||
|  |         ], 60, 'info'); | ||||||
|  |  | ||||||
|  |         // HTTP Server Status (if configured) | ||||||
|  |         if (config.httpServer) { | ||||||
|  |           const serverStatus = config.httpServer.enabled | ||||||
|  |             ? theme.success('Enabled') | ||||||
|  |             : theme.dim('Disabled'); | ||||||
|  |  | ||||||
|  |           logger.log(''); | ||||||
|  |           logger.logBox('HTTP Server', [ | ||||||
|  |             `Status: ${serverStatus}`, | ||||||
|  |             ...(config.httpServer.enabled ? [ | ||||||
|  |               `Port: ${theme.highlight(String(config.httpServer.port))}`, | ||||||
|  |               `Path: ${theme.highlight(config.httpServer.path)}`, | ||||||
|  |               `Auth Token: ${theme.dim('***' + config.httpServer.authToken.slice(-4))}`, | ||||||
|  |               '', | ||||||
|  |               theme.dim('Usage:'), | ||||||
|  |               `  curl -H "Authorization: Bearer TOKEN" http://localhost:${config.httpServer.port}${config.httpServer.path}`, | ||||||
|  |             ] : []), | ||||||
|  |           ], 70, config.httpServer.enabled ? 'success' : 'default'); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // UPS Devices Table | ||||||
|         if (config.upsDevices.length > 0) { |         if (config.upsDevices.length > 0) { | ||||||
|           logger.logBoxTitle('UPS Devices', boxWidth); |           const upsRows = config.upsDevices.map((ups) => ({ | ||||||
|           for (const ups of config.upsDevices) { |             name: ups.name, | ||||||
|             logger.logBoxLine(`${ups.name} (${ups.id}):`); |             id: theme.dim(ups.id), | ||||||
|             logger.logBoxLine(`  Host: ${ups.snmp.host}:${ups.snmp.port}`); |             host: `${ups.snmp.host}:${ups.snmp.port}`, | ||||||
|             logger.logBoxLine(`  Model: ${ups.snmp.upsModel}`); |             model: ups.snmp.upsModel || 'cyberpower', | ||||||
|             logger.logBoxLine( |             actions: `${(ups.actions || []).length} configured`, | ||||||
|               `  Thresholds: ${ups.thresholds.battery}% battery, ${ups.thresholds.runtime} min runtime`, |             groups: ups.groups.length > 0 ? ups.groups.join(', ') : theme.dim('None'), | ||||||
|             ); |           })); | ||||||
|             logger.logBoxLine( |  | ||||||
|               `  Groups: ${ups.groups.length > 0 ? ups.groups.join(', ') : 'None'}`, |           const upsColumns: ITableColumn[] = [ | ||||||
|             ); |             { header: 'Name', key: 'name', align: 'left', color: theme.highlight }, | ||||||
|             logger.logBoxLine(''); |             { header: 'ID', key: 'id', align: 'left' }, | ||||||
|           } |             { header: 'Host:Port', key: 'host', align: 'left', color: theme.info }, | ||||||
|           logger.logBoxEnd(); |             { header: 'Model', key: 'model', align: 'left' }, | ||||||
|  |             { header: 'Actions', key: 'actions', align: 'left' }, | ||||||
|  |             { header: 'Groups', key: 'groups', align: 'left' }, | ||||||
|  |           ]; | ||||||
|  |  | ||||||
|  |           logger.log(''); | ||||||
|  |           logger.info(`UPS Devices (${config.upsDevices.length}):`); | ||||||
|  |           logger.log(''); | ||||||
|  |           logger.logTable(upsColumns, upsRows); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         // Show groups |         // Groups Table | ||||||
|         if (config.groups && config.groups.length > 0) { |         if (config.groups && config.groups.length > 0) { | ||||||
|           logger.logBoxTitle('UPS Groups', boxWidth); |           const groupRows = config.groups.map((group) => { | ||||||
|           for (const group of config.groups) { |  | ||||||
|             logger.logBoxLine(`${group.name} (${group.id}):`); |  | ||||||
|             logger.logBoxLine(`  Mode: ${group.mode}`); |  | ||||||
|             if (group.description) { |  | ||||||
|               logger.logBoxLine(`  Description: ${group.description}`); |  | ||||||
|             } |  | ||||||
|  |  | ||||||
|             // List UPS devices in this group |  | ||||||
|             const upsInGroup = config.upsDevices.filter((ups) => |             const upsInGroup = config.upsDevices.filter((ups) => | ||||||
|               ups.groups && ups.groups.includes(group.id) |               ups.groups && ups.groups.includes(group.id) | ||||||
|             ); |             ); | ||||||
|             logger.logBoxLine( |             return { | ||||||
|               `  UPS Devices: ${ |               name: group.name, | ||||||
|                 upsInGroup.length > 0 ? upsInGroup.map((ups) => ups.name).join(', ') : 'None' |               id: theme.dim(group.id), | ||||||
|               }`, |               mode: group.mode, | ||||||
|             ); |               upsCount: String(upsInGroup.length), | ||||||
|             logger.logBoxLine(''); |               ups: upsInGroup.length > 0  | ||||||
|           } |                 ? upsInGroup.map((ups) => ups.name).join(', ')  | ||||||
|           logger.logBoxEnd(); |                 : theme.dim('None'), | ||||||
|  |               description: group.description || theme.dim('—'), | ||||||
|  |             }; | ||||||
|  |           }); | ||||||
|  |  | ||||||
|  |           const groupColumns: ITableColumn[] = [ | ||||||
|  |             { header: 'Name', key: 'name', align: 'left', color: theme.highlight }, | ||||||
|  |             { header: 'ID', key: 'id', align: 'left' }, | ||||||
|  |             { header: 'Mode', key: 'mode', align: 'left', color: theme.info }, | ||||||
|  |             { header: 'UPS', key: 'upsCount', align: 'right' }, | ||||||
|  |             { header: 'UPS Devices', key: 'ups', align: 'left' }, | ||||||
|  |             { header: 'Description', key: 'description', align: 'left' }, | ||||||
|  |           ]; | ||||||
|  |  | ||||||
|  |           logger.log(''); | ||||||
|  |           logger.info(`UPS Groups (${config.groups.length}):`); | ||||||
|  |           logger.log(''); | ||||||
|  |           logger.logTable(groupColumns, groupRows); | ||||||
|         } |         } | ||||||
|       } else { |       } else { | ||||||
|         // Legacy single UPS configuration |         // === Legacy Single UPS Configuration === | ||||||
|  |          | ||||||
|         if (!config.snmp) { |         if (!config.snmp) { | ||||||
|           logger.logBoxLine('Error: Legacy configuration missing SNMP settings'); |           logger.logBox('Configuration Error', [ | ||||||
|         } else { |             'Error: Legacy configuration missing SNMP settings', | ||||||
|           // SNMP Settings |           ], 60, 'error'); | ||||||
|           logger.logBoxLine('SNMP Settings:'); |           return; | ||||||
|           logger.logBoxLine(`  Host: ${config.snmp.host}`); |  | ||||||
|           logger.logBoxLine(`  Port: ${config.snmp.port}`); |  | ||||||
|           logger.logBoxLine(`  Version: ${config.snmp.version}`); |  | ||||||
|           logger.logBoxLine(`  UPS Model: ${config.snmp.upsModel || 'cyberpower'}`); |  | ||||||
|  |  | ||||||
|           if (config.snmp.version === 1 || config.snmp.version === 2) { |  | ||||||
|             logger.logBoxLine(`  Community: ${config.snmp.community}`); |  | ||||||
|           } else if (config.snmp.version === 3) { |  | ||||||
|             logger.logBoxLine(`  Security Level: ${config.snmp.securityLevel}`); |  | ||||||
|             logger.logBoxLine(`  Username: ${config.snmp.username}`); |  | ||||||
|  |  | ||||||
|             // Show auth and privacy details based on security level |  | ||||||
|             if ( |  | ||||||
|               config.snmp.securityLevel === 'authNoPriv' || |  | ||||||
|               config.snmp.securityLevel === 'authPriv' |  | ||||||
|             ) { |  | ||||||
|               logger.logBoxLine(`  Auth Protocol: ${config.snmp.authProtocol || 'None'}`); |  | ||||||
|         } |         } | ||||||
|  |  | ||||||
|             if (config.snmp.securityLevel === 'authPriv') { |         logger.log(''); | ||||||
|               logger.logBoxLine(`  Privacy Protocol: ${config.snmp.privProtocol || 'None'}`); |         logger.logBox('NUPST Configuration (Legacy)', [ | ||||||
|             } |           theme.warning('Legacy single-UPS configuration format'), | ||||||
|  |           '', | ||||||
|             // Show timeout value |           theme.dim('SNMP Settings:'), | ||||||
|             logger.logBoxLine(`  Timeout: ${config.snmp.timeout / 1000} seconds`); |           `  Host: ${theme.info(config.snmp.host)}`, | ||||||
|           } |           `  Port: ${theme.info(String(config.snmp.port))}`, | ||||||
|  |           `  Version: ${config.snmp.version}`, | ||||||
|           // Show OIDs if custom model is selected |           `  UPS Model: ${config.snmp.upsModel || 'cyberpower'}`, | ||||||
|           if (config.snmp.upsModel === 'custom' && config.snmp.customOIDs) { |           ...(config.snmp.version === 1 || config.snmp.version === 2  | ||||||
|             logger.logBoxLine('Custom OIDs:'); |             ? [`  Community: ${config.snmp.community}`] | ||||||
|             logger.logBoxLine( |             : [] | ||||||
|  |           ), | ||||||
|  |           ...(config.snmp.version === 3  | ||||||
|  |             ? [ | ||||||
|  |                 `  Security Level: ${config.snmp.securityLevel}`, | ||||||
|  |                 `  Username: ${config.snmp.username}`, | ||||||
|  |                 ...(config.snmp.securityLevel === 'authNoPriv' || config.snmp.securityLevel === 'authPriv' | ||||||
|  |                   ? [`  Auth Protocol: ${config.snmp.authProtocol || 'None'}`] | ||||||
|  |                   : [] | ||||||
|  |                 ), | ||||||
|  |                 ...(config.snmp.securityLevel === 'authPriv' | ||||||
|  |                   ? [`  Privacy Protocol: ${config.snmp.privProtocol || 'None'}`] | ||||||
|  |                   : [] | ||||||
|  |                 ), | ||||||
|  |                 `  Timeout: ${config.snmp.timeout / 1000} seconds`, | ||||||
|  |               ] | ||||||
|  |             : [] | ||||||
|  |           ), | ||||||
|  |           ...(config.snmp.upsModel === 'custom' && config.snmp.customOIDs | ||||||
|  |             ? [ | ||||||
|  |                 theme.dim('Custom OIDs:'), | ||||||
|                 `  Power Status: ${config.snmp.customOIDs.POWER_STATUS || 'Not set'}`, |                 `  Power Status: ${config.snmp.customOIDs.POWER_STATUS || 'Not set'}`, | ||||||
|             ); |  | ||||||
|             logger.logBoxLine( |  | ||||||
|                 `  Battery Capacity: ${config.snmp.customOIDs.BATTERY_CAPACITY || 'Not set'}`, |                 `  Battery Capacity: ${config.snmp.customOIDs.BATTERY_CAPACITY || 'Not set'}`, | ||||||
|             ); |  | ||||||
|             logger.logBoxLine( |  | ||||||
|                 `  Battery Runtime: ${config.snmp.customOIDs.BATTERY_RUNTIME || 'Not set'}`, |                 `  Battery Runtime: ${config.snmp.customOIDs.BATTERY_RUNTIME || 'Not set'}`, | ||||||
|             ); |               ] | ||||||
|           } |             : [] | ||||||
|  |           ), | ||||||
|  |           '', | ||||||
|  |            | ||||||
|  |           `  Check Interval: ${config.checkInterval / 1000} seconds`, | ||||||
|  |           '', | ||||||
|  |           theme.dim('Configuration File:'), | ||||||
|  |           `  ${theme.path('/etc/nupst/config.json')}`, | ||||||
|  |           '', | ||||||
|  |           theme.warning('Note: Using legacy single-UPS configuration format.'), | ||||||
|  |           `Consider using ${theme.command('nupst ups add')} to migrate to multi-UPS format.`, | ||||||
|  |         ], 70, 'warning'); | ||||||
|       } |       } | ||||||
|  |  | ||||||
|         // Thresholds |       // Service Status | ||||||
|         if (!config.thresholds) { |  | ||||||
|           logger.logBoxLine('Error: Legacy configuration missing threshold settings'); |  | ||||||
|         } else { |  | ||||||
|           logger.logBoxLine('Thresholds:'); |  | ||||||
|           logger.logBoxLine(`  Battery: ${config.thresholds.battery}%`); |  | ||||||
|           logger.logBoxLine(`  Runtime: ${config.thresholds.runtime} minutes`); |  | ||||||
|         } |  | ||||||
|         logger.logBoxLine(`Check Interval: ${config.checkInterval / 1000} seconds`); |  | ||||||
|  |  | ||||||
|         // Configuration file location |  | ||||||
|         logger.logBoxLine(''); |  | ||||||
|         logger.logBoxLine('Configuration File Location:'); |  | ||||||
|         logger.logBoxLine('  /etc/nupst/config.json'); |  | ||||||
|         logger.logBoxLine(''); |  | ||||||
|         logger.logBoxLine('Note: Using legacy single-UPS configuration format.'); |  | ||||||
|         logger.logBoxLine('Consider using "nupst add" to migrate to multi-UPS format.'); |  | ||||||
|  |  | ||||||
|         logger.logBoxEnd(); |  | ||||||
|       } |  | ||||||
|  |  | ||||||
|       // Show service status |  | ||||||
|       try { |       try { | ||||||
|         const isActive = |         const isActive = | ||||||
|           execSync('systemctl is-active nupst.service || true').toString().trim() === 'active'; |           execSync('systemctl is-active nupst.service || true').toString().trim() === 'active'; | ||||||
|         const isEnabled = |         const isEnabled = | ||||||
|           execSync('systemctl is-enabled nupst.service || true').toString().trim() === 'enabled'; |           execSync('systemctl is-enabled nupst.service || true').toString().trim() === 'enabled'; | ||||||
|  |  | ||||||
|         const statusBoxWidth = 45; |         logger.log(''); | ||||||
|         logger.logBoxTitle('Service Status', statusBoxWidth); |         logger.logBox('Service Status', [ | ||||||
|         logger.logBoxLine(`Service Active: ${isActive ? 'Yes' : 'No'}`); |           `Active: ${isActive ? theme.success('Yes') : theme.dim('No')}`, | ||||||
|         logger.logBoxLine(`Service Enabled: ${isEnabled ? 'Yes' : 'No'}`); |           `Enabled: ${isEnabled ? theme.success('Yes') : theme.dim('No')}`, | ||||||
|         logger.logBoxEnd(); |         ], 50, isActive ? 'success' : 'default'); | ||||||
|  |         logger.log(''); | ||||||
|       } catch (_error) { |       } catch (_error) { | ||||||
|         // Ignore errors checking service status |         // Ignore errors checking service status | ||||||
|       } |       } | ||||||
| @@ -468,65 +481,99 @@ export class NupstCli { | |||||||
|   private showVersion(): void { |   private showVersion(): void { | ||||||
|     const version = this.nupst.getVersion(); |     const version = this.nupst.getVersion(); | ||||||
|     logger.log(`NUPST version ${version}`); |     logger.log(`NUPST version ${version}`); | ||||||
|     logger.log('Deno-powered UPS monitoring tool'); |     logger.log('Network UPS Shutdown Tool (https://nupst.serve.zone)'); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   /** |   /** | ||||||
|    * Display help message |    * Display help message | ||||||
|    */ |    */ | ||||||
|   private showHelp(): void { |   private showHelp(): void { | ||||||
|     logger.log(` |     console.log(''); | ||||||
| NUPST - UPS Shutdown Tool |     logger.highlight('NUPST - UPS Shutdown Tool'); | ||||||
|  |     logger.dim('Deno-powered UPS monitoring and shutdown automation'); | ||||||
|  |     console.log(''); | ||||||
|  |  | ||||||
| Usage: |     // Usage section | ||||||
|   nupst <command> [options] |     logger.log(theme.info('Usage:')); | ||||||
|  |     logger.log(`  ${theme.command('nupst')} ${theme.dim('<command> [options]')}`); | ||||||
|  |     console.log(''); | ||||||
|  |  | ||||||
| Commands: |     // Main commands section | ||||||
|   service <subcommand>      - Manage systemd service |     logger.log(theme.info('Commands:')); | ||||||
|   ups <subcommand>          - Manage UPS devices |     this.printCommand('service <subcommand>', 'Manage systemd service'); | ||||||
|   group <subcommand>        - Manage UPS groups |     this.printCommand('ups <subcommand>', 'Manage UPS devices'); | ||||||
|   config [show]             - Display current configuration |     this.printCommand('group <subcommand>', 'Manage UPS groups'); | ||||||
|   update                    - Update NUPST from repository (requires root) |     this.printCommand('action <subcommand>', 'Manage UPS actions'); | ||||||
|   uninstall                 - Completely remove NUPST from system (requires root) |     this.printCommand('feature <subcommand>', 'Manage optional features'); | ||||||
|   help, --help, -h          - Show this help message |     this.printCommand('config [show]', 'Display current configuration'); | ||||||
|   --version, -v             - Show version information |     this.printCommand('update', 'Update NUPST from repository', theme.dim('(requires root)')); | ||||||
|  |     this.printCommand('uninstall', 'Completely remove NUPST', theme.dim('(requires root)')); | ||||||
|  |     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: |     // Action subcommands | ||||||
|   --debug, -d              - Enable debug mode for detailed SNMP logging |     logger.log(theme.info('Action Subcommands:')); | ||||||
|                              (Example: nupst ups test --debug) |     this.printCommand('nupst action add <target-id>', 'Add a new action to a UPS or group'); | ||||||
|  |     this.printCommand('nupst action remove <target-id> <index>', 'Remove an action by index'); | ||||||
|  |     this.printCommand('nupst action list [target-id]', 'List all actions (optionally for specific target)'); | ||||||
|  |     console.log(''); | ||||||
|  |  | ||||||
| Examples: |     // Feature subcommands | ||||||
|   nupst service enable     - Install and start the service |     logger.log(theme.info('Feature Subcommands:')); | ||||||
|   nupst ups add            - Add a new UPS interactively |     this.printCommand('nupst feature httpServer', 'Configure HTTP server for JSON status export'); | ||||||
|   nupst group list         - Show all configured groups |     console.log(''); | ||||||
|   nupst config             - Display current configuration |  | ||||||
|  |  | ||||||
| Note: Old command format (e.g., 'nupst add') still works but is deprecated. |     // Options | ||||||
|       Use the new format (e.g., 'nupst ups add') going forward. |     logger.log(theme.info('Options:')); | ||||||
| `); |     this.printCommand('--debug, -d', 'Enable debug mode for detailed SNMP logging'); | ||||||
|  |     logger.dim('                     (Example: nupst ups test --debug)'); | ||||||
|  |     console.log(''); | ||||||
|  |  | ||||||
|  |     // Examples | ||||||
|  |     logger.log(theme.info('Examples:')); | ||||||
|  |     logger.dim('  nupst service enable     # Install and start the service'); | ||||||
|  |     logger.dim('  nupst ups add            # Add a new UPS interactively'); | ||||||
|  |     logger.dim('  nupst group list         # Show all configured groups'); | ||||||
|  |     logger.dim('  nupst config             # Display current configuration'); | ||||||
|  |     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 : ''}`); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   /** |   /** | ||||||
| @@ -605,6 +652,45 @@ Examples: | |||||||
|   nupst group add         - Create a new group |   nupst group add         - Create a new group | ||||||
|   nupst group edit dc-1   - Edit group with ID 'dc-1' |   nupst group edit dc-1   - Edit group with ID 'dc-1' | ||||||
|   nupst group remove dc-1 - Remove group with ID 'dc-1' |   nupst group remove dc-1 - Remove group with ID 'dc-1' | ||||||
|  | `); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   private showActionHelp(): void { | ||||||
|  |     logger.log(` | ||||||
|  | NUPST - Action Management Commands | ||||||
|  |  | ||||||
|  | Usage: | ||||||
|  |   nupst action <subcommand> [arguments] | ||||||
|  |  | ||||||
|  | Subcommands: | ||||||
|  |   add <ups-id|group-id>                   - Add a new action to a UPS or group interactively | ||||||
|  |   remove <ups-id|group-id> <index>        - Remove an action by index (alias: rm) | ||||||
|  |   list [ups-id|group-id]                  - List all actions (optionally for specific target) (alias: ls) | ||||||
|  |  | ||||||
|  | Options: | ||||||
|  |   --debug, -d                             - Enable debug mode for detailed logging | ||||||
|  |  | ||||||
|  | Examples: | ||||||
|  |   nupst action list                       - List actions for all UPS devices and groups | ||||||
|  |   nupst action list default               - List actions for UPS or group with ID 'default' | ||||||
|  |   nupst action add default                - Add a new action to UPS or group 'default' | ||||||
|  |   nupst action remove default 0           - Remove action at index 0 from UPS or group 'default' | ||||||
|  |   nupst action add dc-rack-1              - Add a new action to group 'dc-rack-1' | ||||||
|  | `); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   private showFeatureHelp(): void { | ||||||
|  |     logger.log(` | ||||||
|  | NUPST - Feature Management Commands | ||||||
|  |  | ||||||
|  | Usage: | ||||||
|  |   nupst feature <subcommand> | ||||||
|  |  | ||||||
|  | Subcommands: | ||||||
|  |   httpServer              - Configure HTTP server for JSON status export | ||||||
|  |  | ||||||
|  | Examples: | ||||||
|  |   nupst feature httpServer    - Enable/disable HTTP server with interactive setup | ||||||
| `); | `); | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										357
									
								
								ts/cli/action-handler.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										357
									
								
								ts/cli/action-handler.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,357 @@ | |||||||
|  | import process from 'node:process'; | ||||||
|  | import { Nupst } from '../nupst.ts'; | ||||||
|  | import { logger, type ITableColumn } from '../logger.ts'; | ||||||
|  | import { theme, symbols } from '../colors.ts'; | ||||||
|  | import type { IActionConfig } from '../actions/base-action.ts'; | ||||||
|  | import type { IUpsConfig, IGroupConfig } from '../daemon.ts'; | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Class for handling action-related CLI commands | ||||||
|  |  * Provides interface for managing UPS actions | ||||||
|  |  */ | ||||||
|  | export class ActionHandler { | ||||||
|  |   private readonly nupst: Nupst; | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Create a new action handler | ||||||
|  |    * @param nupst Reference to the main Nupst instance | ||||||
|  |    */ | ||||||
|  |   constructor(nupst: Nupst) { | ||||||
|  |     this.nupst = nupst; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Add a new action to a UPS or group | ||||||
|  |    */ | ||||||
|  |   public async add(targetId?: string): Promise<void> { | ||||||
|  |     try { | ||||||
|  |       if (!targetId) { | ||||||
|  |         logger.error('Target ID is required'); | ||||||
|  |         logger.log( | ||||||
|  |           `  ${theme.dim('Usage:')} ${theme.command('nupst action add <ups-id|group-id>')}`, | ||||||
|  |         ); | ||||||
|  |         logger.log(''); | ||||||
|  |         logger.log(`  ${theme.dim('List UPS devices:')} ${theme.command('nupst ups list')}`); | ||||||
|  |         logger.log(`  ${theme.dim('List groups:')} ${theme.command('nupst group list')}`); | ||||||
|  |         logger.log(''); | ||||||
|  |         process.exit(1); | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       const config = await this.nupst.getDaemon().loadConfig(); | ||||||
|  |  | ||||||
|  |       // Check if it's a UPS | ||||||
|  |       const ups = config.upsDevices.find((u) => u.id === targetId); | ||||||
|  |       // Check if it's a group | ||||||
|  |       const group = config.groups?.find((g) => g.id === targetId); | ||||||
|  |  | ||||||
|  |       if (!ups && !group) { | ||||||
|  |         logger.error(`UPS or Group with ID '${targetId}' not found`); | ||||||
|  |         logger.log(''); | ||||||
|  |         logger.log(`  ${theme.dim('List available UPS devices:')} ${theme.command('nupst ups list')}`); | ||||||
|  |         logger.log(`  ${theme.dim('List available groups:')} ${theme.command('nupst group list')}`); | ||||||
|  |         logger.log(''); | ||||||
|  |         process.exit(1); | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       const target = ups || group; | ||||||
|  |       const targetType = ups ? 'UPS' : 'Group'; | ||||||
|  |       const targetName = ups ? ups.name : group!.name; | ||||||
|  |  | ||||||
|  |       const readline = await import('node:readline'); | ||||||
|  |       const rl = readline.createInterface({ | ||||||
|  |         input: process.stdin, | ||||||
|  |         output: process.stdout, | ||||||
|  |       }); | ||||||
|  |  | ||||||
|  |       const prompt = (question: string): Promise<string> => { | ||||||
|  |         return new Promise((resolve) => { | ||||||
|  |           rl.question(question, (answer: string) => { | ||||||
|  |             resolve(answer); | ||||||
|  |           }); | ||||||
|  |         }); | ||||||
|  |       }; | ||||||
|  |  | ||||||
|  |       try { | ||||||
|  |         logger.log(''); | ||||||
|  |         logger.info(`Add Action to ${targetType} ${theme.highlight(targetName)}`); | ||||||
|  |         logger.log(''); | ||||||
|  |  | ||||||
|  |         // Action type (currently only shutdown is supported) | ||||||
|  |         const type = 'shutdown'; | ||||||
|  |         logger.log(`  ${theme.dim('Action type:')} ${theme.highlight('shutdown')}`); | ||||||
|  |  | ||||||
|  |         // Battery threshold | ||||||
|  |         const batteryStr = await prompt( | ||||||
|  |           `  ${theme.dim('Battery threshold')} ${theme.dim('(%):')} `, | ||||||
|  |         ); | ||||||
|  |         const battery = parseInt(batteryStr, 10); | ||||||
|  |         if (isNaN(battery) || battery < 0 || battery > 100) { | ||||||
|  |           logger.error('Invalid battery threshold. Must be 0-100.'); | ||||||
|  |           process.exit(1); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // Runtime threshold | ||||||
|  |         const runtimeStr = await prompt( | ||||||
|  |           `  ${theme.dim('Runtime threshold')} ${theme.dim('(minutes):')} `, | ||||||
|  |         ); | ||||||
|  |         const runtime = parseInt(runtimeStr, 10); | ||||||
|  |         if (isNaN(runtime) || runtime < 0) { | ||||||
|  |           logger.error('Invalid runtime threshold. Must be >= 0.'); | ||||||
|  |           process.exit(1); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // Trigger mode | ||||||
|  |         logger.log(''); | ||||||
|  |         logger.log(`  ${theme.dim('Trigger mode:')}`); | ||||||
|  |         logger.log(`    ${theme.dim('1)')} onlyPowerChanges - Trigger only when power status changes`); | ||||||
|  |         logger.log( | ||||||
|  |           `    ${theme.dim('2)')} onlyThresholds - Trigger only when thresholds are violated`, | ||||||
|  |         ); | ||||||
|  |         logger.log( | ||||||
|  |           `    ${theme.dim('3)')} powerChangesAndThresholds - Trigger on power change AND thresholds`, | ||||||
|  |         ); | ||||||
|  |         logger.log(`    ${theme.dim('4)')} anyChange - Trigger on any status change`); | ||||||
|  |         const triggerChoice = await prompt(`  ${theme.dim('Choice')} ${theme.dim('[2]:')} `); | ||||||
|  |         const triggerModeMap: Record<string, string> = { | ||||||
|  |           '1': 'onlyPowerChanges', | ||||||
|  |           '2': 'onlyThresholds', | ||||||
|  |           '3': 'powerChangesAndThresholds', | ||||||
|  |           '4': 'anyChange', | ||||||
|  |           '': 'onlyThresholds', // Default | ||||||
|  |         }; | ||||||
|  |         const triggerMode = triggerModeMap[triggerChoice] || 'onlyThresholds'; | ||||||
|  |  | ||||||
|  |         // Shutdown delay | ||||||
|  |         const delayStr = await prompt( | ||||||
|  |           `  ${theme.dim('Shutdown delay')} ${theme.dim('(seconds) [5]:')} `, | ||||||
|  |         ); | ||||||
|  |         const shutdownDelay = delayStr ? parseInt(delayStr, 10) : 5; | ||||||
|  |         if (isNaN(shutdownDelay) || shutdownDelay < 0) { | ||||||
|  |           logger.error('Invalid shutdown delay. Must be >= 0.'); | ||||||
|  |           process.exit(1); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // Create the action | ||||||
|  |         const newAction: IActionConfig = { | ||||||
|  |           type, | ||||||
|  |           thresholds: { | ||||||
|  |             battery, | ||||||
|  |             runtime, | ||||||
|  |           }, | ||||||
|  |           triggerMode: triggerMode as IActionConfig['triggerMode'], | ||||||
|  |           shutdownDelay, | ||||||
|  |         }; | ||||||
|  |  | ||||||
|  |         // Add to target (UPS or group) | ||||||
|  |         if (!target!.actions) { | ||||||
|  |           target!.actions = []; | ||||||
|  |         } | ||||||
|  |         target!.actions.push(newAction); | ||||||
|  |  | ||||||
|  |         await this.nupst.getDaemon().saveConfig(config); | ||||||
|  |  | ||||||
|  |         logger.log(''); | ||||||
|  |         logger.success(`Action added to ${targetType} ${targetName}`); | ||||||
|  |         logger.log(`  ${theme.dim('Changes saved and will be applied automatically')}`); | ||||||
|  |         logger.log(''); | ||||||
|  |       } finally { | ||||||
|  |         rl.close(); | ||||||
|  |       } | ||||||
|  |     } catch (error) { | ||||||
|  |       logger.error( | ||||||
|  |         `Failed to add action: ${error instanceof Error ? error.message : String(error)}`, | ||||||
|  |       ); | ||||||
|  |       process.exit(1); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Remove an action from a UPS or group | ||||||
|  |    */ | ||||||
|  |   public async remove(targetId?: string, actionIndexStr?: string): Promise<void> { | ||||||
|  |     try { | ||||||
|  |       if (!targetId || !actionIndexStr) { | ||||||
|  |         logger.error('Target ID and action index are required'); | ||||||
|  |         logger.log( | ||||||
|  |           `  ${theme.dim('Usage:')} ${theme.command('nupst action remove <ups-id|group-id> <action-index>')}`, | ||||||
|  |         ); | ||||||
|  |         logger.log(''); | ||||||
|  |         logger.log(`  ${theme.dim('List actions:')} ${theme.command('nupst action list')}`); | ||||||
|  |         logger.log(''); | ||||||
|  |         process.exit(1); | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       const actionIndex = parseInt(actionIndexStr, 10); | ||||||
|  |       if (isNaN(actionIndex) || actionIndex < 0) { | ||||||
|  |         logger.error('Invalid action index. Must be >= 0.'); | ||||||
|  |         process.exit(1); | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       const config = await this.nupst.getDaemon().loadConfig(); | ||||||
|  |  | ||||||
|  |       // Check if it's a UPS | ||||||
|  |       const ups = config.upsDevices.find((u) => u.id === targetId); | ||||||
|  |       // Check if it's a group | ||||||
|  |       const group = config.groups?.find((g) => g.id === targetId); | ||||||
|  |  | ||||||
|  |       if (!ups && !group) { | ||||||
|  |         logger.error(`UPS or Group with ID '${targetId}' not found`); | ||||||
|  |         logger.log(''); | ||||||
|  |         logger.log(`  ${theme.dim('List available UPS devices:')} ${theme.command('nupst ups list')}`); | ||||||
|  |         logger.log(`  ${theme.dim('List available groups:')} ${theme.command('nupst group list')}`); | ||||||
|  |         logger.log(''); | ||||||
|  |         process.exit(1); | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       const target = ups || group; | ||||||
|  |       const targetType = ups ? 'UPS' : 'Group'; | ||||||
|  |       const targetName = ups ? ups.name : group!.name; | ||||||
|  |  | ||||||
|  |       if (!target!.actions || target!.actions.length === 0) { | ||||||
|  |         logger.error(`No actions configured for ${targetType} '${targetName}'`); | ||||||
|  |         logger.log(''); | ||||||
|  |         process.exit(1); | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       if (actionIndex >= target!.actions.length) { | ||||||
|  |         logger.error( | ||||||
|  |           `Invalid action index. ${targetType} '${targetName}' has ${target!.actions.length} action(s) (index 0-${target!.actions.length - 1})`, | ||||||
|  |         ); | ||||||
|  |         logger.log(''); | ||||||
|  |         logger.log( | ||||||
|  |           `  ${theme.dim('List actions:')} ${theme.command(`nupst action list ${targetId}`)}`, | ||||||
|  |         ); | ||||||
|  |         logger.log(''); | ||||||
|  |         process.exit(1); | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       const removedAction = target!.actions[actionIndex]; | ||||||
|  |       target!.actions.splice(actionIndex, 1); | ||||||
|  |  | ||||||
|  |       await this.nupst.getDaemon().saveConfig(config); | ||||||
|  |  | ||||||
|  |       logger.log(''); | ||||||
|  |       logger.success(`Action removed from ${targetType} ${targetName}`); | ||||||
|  |       logger.log(`  ${theme.dim('Type:')} ${removedAction.type}`); | ||||||
|  |       if (removedAction.thresholds) { | ||||||
|  |         logger.log( | ||||||
|  |           `  ${theme.dim('Thresholds:')} Battery: ${removedAction.thresholds.battery}%, Runtime: ${removedAction.thresholds.runtime}min`, | ||||||
|  |         ); | ||||||
|  |       } | ||||||
|  |       logger.log(`  ${theme.dim('Changes saved and will be applied automatically')}`); | ||||||
|  |       logger.log(''); | ||||||
|  |     } catch (error) { | ||||||
|  |       logger.error( | ||||||
|  |         `Failed to remove action: ${error instanceof Error ? error.message : String(error)}`, | ||||||
|  |       ); | ||||||
|  |       process.exit(1); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * List all actions for a specific UPS/group or all devices | ||||||
|  |    */ | ||||||
|  |   public async list(targetId?: string): Promise<void> { | ||||||
|  |     try { | ||||||
|  |       const config = await this.nupst.getDaemon().loadConfig(); | ||||||
|  |  | ||||||
|  |       if (targetId) { | ||||||
|  |         // List actions for specific UPS or group | ||||||
|  |         const ups = config.upsDevices.find((u) => u.id === targetId); | ||||||
|  |         const group = config.groups?.find((g) => g.id === targetId); | ||||||
|  |  | ||||||
|  |         if (!ups && !group) { | ||||||
|  |           logger.error(`UPS or Group with ID '${targetId}' not found`); | ||||||
|  |           logger.log(''); | ||||||
|  |           logger.log(`  ${theme.dim('List available UPS devices:')} ${theme.command('nupst ups list')}`); | ||||||
|  |           logger.log(`  ${theme.dim('List available groups:')} ${theme.command('nupst group list')}`); | ||||||
|  |           logger.log(''); | ||||||
|  |           process.exit(1); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (ups) { | ||||||
|  |           this.displayTargetActions(ups, 'UPS'); | ||||||
|  |         } else { | ||||||
|  |           this.displayTargetActions(group!, 'Group'); | ||||||
|  |         } | ||||||
|  |       } else { | ||||||
|  |         // List actions for all UPS devices and groups | ||||||
|  |         logger.log(''); | ||||||
|  |         logger.info('Actions for All UPS Devices and Groups'); | ||||||
|  |         logger.log(''); | ||||||
|  |  | ||||||
|  |         let hasAnyActions = false; | ||||||
|  |  | ||||||
|  |         // Display UPS actions | ||||||
|  |         for (const ups of config.upsDevices) { | ||||||
|  |           if (ups.actions && ups.actions.length > 0) { | ||||||
|  |             hasAnyActions = true; | ||||||
|  |             this.displayTargetActions(ups, 'UPS'); | ||||||
|  |           } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // Display Group actions | ||||||
|  |         for (const group of config.groups || []) { | ||||||
|  |           if (group.actions && group.actions.length > 0) { | ||||||
|  |             hasAnyActions = true; | ||||||
|  |             this.displayTargetActions(group, 'Group'); | ||||||
|  |           } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (!hasAnyActions) { | ||||||
|  |           logger.log(`  ${theme.dim('No actions configured')}`); | ||||||
|  |           logger.log(''); | ||||||
|  |           logger.log( | ||||||
|  |             `  ${theme.dim('Add an action:')} ${theme.command('nupst action add <ups-id|group-id>')}`, | ||||||
|  |           ); | ||||||
|  |           logger.log(''); | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } catch (error) { | ||||||
|  |       logger.error( | ||||||
|  |         `Failed to list actions: ${error instanceof Error ? error.message : String(error)}`, | ||||||
|  |       ); | ||||||
|  |       process.exit(1); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Display actions for a single UPS or Group | ||||||
|  |    */ | ||||||
|  |   private displayTargetActions( | ||||||
|  |     target: IUpsConfig | IGroupConfig, | ||||||
|  |     targetType: 'UPS' | 'Group', | ||||||
|  |   ): void { | ||||||
|  |     logger.log( | ||||||
|  |       `${symbols.info} ${targetType} ${theme.highlight(target.name)} ${theme.dim(`(${target.id})`)}`, | ||||||
|  |     ); | ||||||
|  |     logger.log(''); | ||||||
|  |  | ||||||
|  |     if (!target.actions || target.actions.length === 0) { | ||||||
|  |       logger.log(`  ${theme.dim('No actions configured')}`); | ||||||
|  |       logger.log(''); | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     const columns: ITableColumn[] = [ | ||||||
|  |       { header: 'Index', key: 'index', align: 'right' }, | ||||||
|  |       { header: 'Type', key: 'type', align: 'left' }, | ||||||
|  |       { header: 'Battery', key: 'battery', align: 'right' }, | ||||||
|  |       { header: 'Runtime', key: 'runtime', align: 'right' }, | ||||||
|  |       { header: 'Trigger Mode', key: 'triggerMode', align: 'left' }, | ||||||
|  |       { header: 'Delay', key: 'delay', align: 'right' }, | ||||||
|  |     ]; | ||||||
|  |  | ||||||
|  |     const rows = target.actions.map((action, index) => ({ | ||||||
|  |       index: theme.dim(index.toString()), | ||||||
|  |       type: theme.highlight(action.type), | ||||||
|  |       battery: action.thresholds ? `${action.thresholds.battery}%` : theme.dim('N/A'), | ||||||
|  |       runtime: action.thresholds ? `${action.thresholds.runtime}min` : theme.dim('N/A'), | ||||||
|  |       triggerMode: theme.dim(action.triggerMode || 'onlyThresholds'), | ||||||
|  |       delay: `${action.shutdownDelay || 5}s`, | ||||||
|  |     })); | ||||||
|  |  | ||||||
|  |     logger.logTable(columns, rows); | ||||||
|  |     logger.log(''); | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										213
									
								
								ts/cli/feature-handler.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										213
									
								
								ts/cli/feature-handler.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,213 @@ | |||||||
|  | import process from 'node:process'; | ||||||
|  | import { execSync } from 'node:child_process'; | ||||||
|  | import { Nupst } from '../nupst.ts'; | ||||||
|  | import { logger } from '../logger.ts'; | ||||||
|  | import { theme } from '../colors.ts'; | ||||||
|  | import * as helpers from '../helpers/index.ts'; | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Class for handling feature-related CLI commands | ||||||
|  |  * Provides interface for managing optional features like HTTP server | ||||||
|  |  */ | ||||||
|  | export class FeatureHandler { | ||||||
|  |   private readonly nupst: Nupst; | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Create a new feature handler | ||||||
|  |    * @param nupst Reference to the main Nupst instance | ||||||
|  |    */ | ||||||
|  |   constructor(nupst: Nupst) { | ||||||
|  |     this.nupst = nupst; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Configure HTTP server feature | ||||||
|  |    */ | ||||||
|  |   public async configureHttpServer(): Promise<void> { | ||||||
|  |     try { | ||||||
|  |       const readline = await import('node:readline'); | ||||||
|  |       const rl = readline.createInterface({ | ||||||
|  |         input: process.stdin, | ||||||
|  |         output: process.stdout, | ||||||
|  |       }); | ||||||
|  |  | ||||||
|  |       const prompt = (question: string): Promise<string> => { | ||||||
|  |         return new Promise((resolve) => { | ||||||
|  |           rl.question(question, (answer: string) => { | ||||||
|  |             resolve(answer); | ||||||
|  |           }); | ||||||
|  |         }); | ||||||
|  |       }; | ||||||
|  |  | ||||||
|  |       try { | ||||||
|  |         await this.runHttpServerConfig(prompt); | ||||||
|  |       } finally { | ||||||
|  |         rl.close(); | ||||||
|  |         process.stdin.destroy(); | ||||||
|  |       } | ||||||
|  |     } catch (error) { | ||||||
|  |       logger.error(`HTTP Server config error: ${error instanceof Error ? error.message : String(error)}`); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Run the interactive HTTP server configuration process | ||||||
|  |    * @param prompt Function to prompt for user input | ||||||
|  |    */ | ||||||
|  |   private async runHttpServerConfig(prompt: (question: string) => Promise<string>): Promise<void> { | ||||||
|  |     logger.log(''); | ||||||
|  |     logger.logBoxTitle('HTTP Server Feature Configuration', 60); | ||||||
|  |     logger.logBoxLine('Configure the HTTP server to expose UPS status as JSON'); | ||||||
|  |     logger.logBoxEnd(); | ||||||
|  |     logger.log(''); | ||||||
|  |  | ||||||
|  |     // Load config | ||||||
|  |     let config; | ||||||
|  |     try { | ||||||
|  |       await this.nupst.getDaemon().loadConfig(); | ||||||
|  |       config = this.nupst.getDaemon().getConfig(); | ||||||
|  |     } catch (error) { | ||||||
|  |       logger.error('No configuration found. Please run "nupst ups add" first.'); | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Show current status | ||||||
|  |     if (config.httpServer?.enabled) { | ||||||
|  |       logger.info('HTTP Server is currently: ' + theme.success('ENABLED')); | ||||||
|  |       logger.log(`  Port: ${theme.highlight(String(config.httpServer.port))}`); | ||||||
|  |       logger.log(`  Path: ${theme.highlight(config.httpServer.path)}`); | ||||||
|  |       logger.log(`  Auth Token: ${theme.dim('***' + config.httpServer.authToken.slice(-4))}`); | ||||||
|  |       logger.log(''); | ||||||
|  |     } else { | ||||||
|  |       logger.info('HTTP Server is currently: ' + theme.dim('DISABLED')); | ||||||
|  |       logger.log(''); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Ask enable/disable | ||||||
|  |     const action = await prompt('Enable or disable HTTP server? (enable/disable/cancel): '); | ||||||
|  |  | ||||||
|  |     if (action.toLowerCase() === 'cancel' || action.toLowerCase() === 'c') { | ||||||
|  |       logger.log('Cancelled.'); | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (action.toLowerCase() === 'disable' || action.toLowerCase() === 'd') { | ||||||
|  |       // Disable HTTP server | ||||||
|  |       config.httpServer = { | ||||||
|  |         enabled: false, | ||||||
|  |         port: config.httpServer?.port || 8080, | ||||||
|  |         path: config.httpServer?.path || '/ups-status', | ||||||
|  |         authToken: config.httpServer?.authToken || '', | ||||||
|  |       }; | ||||||
|  |  | ||||||
|  |       this.nupst.getDaemon().saveConfig(config); | ||||||
|  |  | ||||||
|  |       logger.log(''); | ||||||
|  |       logger.success('HTTP Server disabled'); | ||||||
|  |       logger.log(''); | ||||||
|  |  | ||||||
|  |       await this.restartServiceIfRunning(); | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (action.toLowerCase() !== 'enable' && action.toLowerCase() !== 'e') { | ||||||
|  |       logger.error('Invalid option. Please enter "enable", "disable", or "cancel".'); | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Enable - gather configuration | ||||||
|  |     logger.log(''); | ||||||
|  |  | ||||||
|  |     const portInput = await prompt(`HTTP Server Port [${config.httpServer?.port || 8080}]: `); | ||||||
|  |     const port = portInput ? parseInt(portInput, 10) : (config.httpServer?.port || 8080); | ||||||
|  |  | ||||||
|  |     if (isNaN(port) || port < 1 || port > 65535) { | ||||||
|  |       logger.error('Invalid port number. Must be between 1 and 65535.'); | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     const pathInput = await prompt(`URL Path [${config.httpServer?.path || '/ups-status'}]: `); | ||||||
|  |     const path = pathInput || config.httpServer?.path || '/ups-status'; | ||||||
|  |  | ||||||
|  |     // Ensure path starts with / | ||||||
|  |     const finalPath = path.startsWith('/') ? path : `/${path}`; | ||||||
|  |  | ||||||
|  |     // Generate or reuse auth token | ||||||
|  |     let authToken = config.httpServer?.authToken; | ||||||
|  |     if (!authToken) { | ||||||
|  |       // Generate new random token | ||||||
|  |       authToken = helpers.shortId() + helpers.shortId() + helpers.shortId(); | ||||||
|  |       logger.log(''); | ||||||
|  |       logger.info('Generated new authentication token'); | ||||||
|  |     } else { | ||||||
|  |       const regenerate = await prompt('Regenerate authentication token? (y/N): '); | ||||||
|  |       if (regenerate.toLowerCase() === 'y' || regenerate.toLowerCase() === 'yes') { | ||||||
|  |         authToken = helpers.shortId() + helpers.shortId() + helpers.shortId(); | ||||||
|  |         logger.info('Generated new authentication token'); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Save configuration | ||||||
|  |     config.httpServer = { | ||||||
|  |       enabled: true, | ||||||
|  |       port, | ||||||
|  |       path: finalPath, | ||||||
|  |       authToken, | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     this.nupst.getDaemon().saveConfig(config); | ||||||
|  |  | ||||||
|  |     // Display summary | ||||||
|  |     logger.log(''); | ||||||
|  |     logger.logBoxTitle('HTTP Server Configuration', 70, 'success'); | ||||||
|  |     logger.logBoxLine(`Status: ${theme.success('ENABLED')}`); | ||||||
|  |     logger.logBoxLine(`Port: ${theme.highlight(String(port))}`); | ||||||
|  |     logger.logBoxLine(`Path: ${theme.highlight(finalPath)}`); | ||||||
|  |     logger.logBoxLine(`Auth Token: ${theme.warning(authToken)}`); | ||||||
|  |     logger.logBoxLine(''); | ||||||
|  |     logger.logBoxLine(theme.dim('Usage examples:')); | ||||||
|  |     logger.logBoxLine(`  curl -H "Authorization: Bearer ${authToken}" http://localhost:${port}${finalPath}`); | ||||||
|  |     logger.logBoxLine(`  curl "http://localhost:${port}${finalPath}?token=${authToken}"`); | ||||||
|  |     logger.logBoxEnd(); | ||||||
|  |     logger.log(''); | ||||||
|  |  | ||||||
|  |     logger.warn('IMPORTANT: Save the authentication token securely!'); | ||||||
|  |     logger.log(''); | ||||||
|  |  | ||||||
|  |     await this.restartServiceIfRunning(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Restart the service if it's currently running | ||||||
|  |    */ | ||||||
|  |   private async restartServiceIfRunning(): Promise<void> { | ||||||
|  |     try { | ||||||
|  |       const isActive = execSync('systemctl is-active nupst.service || true').toString().trim() === 'active'; | ||||||
|  |  | ||||||
|  |       if (isActive) { | ||||||
|  |         logger.log(''); | ||||||
|  |         const readline = await import('node:readline'); | ||||||
|  |         const rl = readline.createInterface({ | ||||||
|  |           input: process.stdin, | ||||||
|  |           output: process.stdout, | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |         const answer = await new Promise<string>((resolve) => { | ||||||
|  |           rl.question('Service is running. Restart to apply changes? (Y/n): ', resolve); | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |         rl.close(); | ||||||
|  |  | ||||||
|  |         if (!answer || answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes') { | ||||||
|  |           logger.info('Restarting service...'); | ||||||
|  |           execSync('sudo systemctl restart nupst.service'); | ||||||
|  |           logger.success('Service restarted successfully'); | ||||||
|  |         } else { | ||||||
|  |           logger.warn('Changes will take effect on next service restart'); | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } catch (error) { | ||||||
|  |       // Ignore errors - service might not be installed | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -1,6 +1,7 @@ | |||||||
| import process from 'node:process'; | import process from 'node:process'; | ||||||
| import { Nupst } from '../nupst.ts'; | import { Nupst } from '../nupst.ts'; | ||||||
| import { logger } from '../logger.ts'; | import { logger, type ITableColumn } from '../logger.ts'; | ||||||
|  | import { theme } from '../colors.ts'; | ||||||
| import * as helpers from '../helpers/index.ts'; | import * as helpers from '../helpers/index.ts'; | ||||||
| import { type IGroupConfig } from '../daemon.ts'; | import { type IGroupConfig } from '../daemon.ts'; | ||||||
|  |  | ||||||
| @@ -28,11 +29,10 @@ export class GroupHandler { | |||||||
|       try { |       try { | ||||||
|         await this.nupst.getDaemon().loadConfig(); |         await this.nupst.getDaemon().loadConfig(); | ||||||
|       } catch (error) { |       } catch (error) { | ||||||
|         const errorBoxWidth = 45; |         logger.logBox('Configuration Error', [ | ||||||
|         logger.logBoxTitle('Configuration Error', errorBoxWidth); |           'No configuration found.', | ||||||
|         logger.logBoxLine('No configuration found.'); |           "Please run 'nupst ups add' first to create a configuration.", | ||||||
|         logger.logBoxLine("Please run 'nupst setup' first to create a configuration."); |         ], 50, 'error'); | ||||||
|         logger.logBoxEnd(); |  | ||||||
|         return; |         return; | ||||||
|       } |       } | ||||||
|  |  | ||||||
| @@ -41,43 +41,53 @@ export class GroupHandler { | |||||||
|  |  | ||||||
|       // Check if multi-UPS config |       // Check if multi-UPS config | ||||||
|       if (!config.groups || !Array.isArray(config.groups)) { |       if (!config.groups || !Array.isArray(config.groups)) { | ||||||
|         // Legacy or missing groups configuration |         logger.logBox('UPS Groups', [ | ||||||
|         const boxWidth = 45; |           'No groups configured.', | ||||||
|         logger.logBoxTitle('UPS Groups', boxWidth); |           '', | ||||||
|         logger.logBoxLine('No groups configured.'); |           `${theme.dim('Run')} ${theme.command('nupst group add')} ${theme.dim('to add a group')}`, | ||||||
|         logger.logBoxLine('Use "nupst group add" to add a UPS group.'); |         ], 50, 'info'); | ||||||
|         logger.logBoxEnd(); |  | ||||||
|         return; |         return; | ||||||
|       } |       } | ||||||
|  |  | ||||||
|       // Display group list |       // Display group list with modern table | ||||||
|       const boxWidth = 60; |  | ||||||
|       logger.logBoxTitle('UPS Groups', boxWidth); |  | ||||||
|  |  | ||||||
|       if (config.groups.length === 0) { |       if (config.groups.length === 0) { | ||||||
|         logger.logBoxLine('No UPS groups configured.'); |         logger.logBox('UPS Groups', [ | ||||||
|         logger.logBoxLine('Use "nupst group add" to add a UPS group.'); |           'No UPS groups configured.', | ||||||
|       } else { |           '', | ||||||
|         logger.logBoxLine(`Found ${config.groups.length} group(s)`); |           `${theme.dim('Run')} ${theme.command('nupst group add')} ${theme.dim('to add a group')}`, | ||||||
|         logger.logBoxLine(''); |         ], 60, 'info'); | ||||||
|         logger.logBoxLine('ID         | Name                 | Mode         | UPS Devices'); |         return; | ||||||
|         logger.logBoxLine('-----------+----------------------+--------------+----------------'); |       } | ||||||
|  |  | ||||||
|         for (const group of config.groups) { |  | ||||||
|           const id = group.id.padEnd(10, ' ').substring(0, 10); |  | ||||||
|           const name = (group.name || '').padEnd(20, ' ').substring(0, 20); |  | ||||||
|           const mode = (group.mode || 'unknown').padEnd(12, ' ').substring(0, 12); |  | ||||||
|  |  | ||||||
|  |       // Prepare table data | ||||||
|  |       const rows = config.groups.map((group) => { | ||||||
|         // Count UPS devices in this group |         // Count UPS devices in this group | ||||||
|         const upsInGroup = config.upsDevices.filter((ups) => ups.groups.includes(group.id)); |         const upsInGroup = config.upsDevices.filter((ups) => ups.groups.includes(group.id)); | ||||||
|         const upsCount = upsInGroup.length; |         const upsCount = upsInGroup.length; | ||||||
|         const upsNames = upsInGroup.map((ups) => ups.name).join(', '); |         const upsNames = upsInGroup.map((ups) => ups.name).join(', '); | ||||||
|  |  | ||||||
|           logger.logBoxLine(`${id} | ${name} | ${mode} | ${upsCount > 0 ? upsNames : 'None'}`); |         return { | ||||||
|         } |           id: group.id, | ||||||
|       } |           name: group.name || '', | ||||||
|  |           mode: group.mode || 'unknown', | ||||||
|  |           count: String(upsCount), | ||||||
|  |           devices: upsCount > 0 ? upsNames : theme.dim('None'), | ||||||
|  |         }; | ||||||
|  |       }); | ||||||
|  |  | ||||||
|       logger.logBoxEnd(); |       const columns: ITableColumn[] = [ | ||||||
|  |         { header: 'ID', key: 'id', align: 'left', color: theme.highlight }, | ||||||
|  |         { header: 'Name', key: 'name', align: 'left' }, | ||||||
|  |         { header: 'Mode', key: 'mode', align: 'left', color: theme.info }, | ||||||
|  |         { header: 'UPS Count', key: 'count', align: 'right' }, | ||||||
|  |         { header: 'UPS Devices', key: 'devices', align: 'left' }, | ||||||
|  |       ]; | ||||||
|  |  | ||||||
|  |       logger.log(''); | ||||||
|  |       logger.info(`UPS Groups (${config.groups.length}):`); | ||||||
|  |       logger.log(''); | ||||||
|  |       logger.logTable(columns, rows); | ||||||
|  |       logger.log(''); | ||||||
|     } catch (error) { |     } catch (error) { | ||||||
|       logger.error( |       logger.error( | ||||||
|         `Failed to list UPS groups: ${error instanceof Error ? error.message : String(error)}`, |         `Failed to list UPS groups: ${error instanceof Error ? error.message : String(error)}`, | ||||||
| @@ -192,6 +202,7 @@ export class GroupHandler { | |||||||
|         logger.log('\nGroup setup complete!'); |         logger.log('\nGroup setup complete!'); | ||||||
|       } finally { |       } finally { | ||||||
|         rl.close(); |         rl.close(); | ||||||
|  |         process.stdin.destroy(); | ||||||
|       } |       } | ||||||
|     } catch (error) { |     } catch (error) { | ||||||
|       logger.error(`Add group error: ${error instanceof Error ? error.message : String(error)}`); |       logger.error(`Add group error: ${error instanceof Error ? error.message : String(error)}`); | ||||||
| @@ -309,6 +320,7 @@ export class GroupHandler { | |||||||
|         logger.log('\nGroup edit complete!'); |         logger.log('\nGroup edit complete!'); | ||||||
|       } finally { |       } finally { | ||||||
|         rl.close(); |         rl.close(); | ||||||
|  |         process.stdin.destroy(); | ||||||
|       } |       } | ||||||
|     } catch (error) { |     } catch (error) { | ||||||
|       logger.error(`Edit group error: ${error instanceof Error ? error.message : String(error)}`); |       logger.error(`Edit group error: ${error instanceof Error ? error.message : String(error)}`); | ||||||
| @@ -366,6 +378,7 @@ export class GroupHandler { | |||||||
|       }); |       }); | ||||||
|  |  | ||||||
|       rl.close(); |       rl.close(); | ||||||
|  |       process.stdin.destroy(); | ||||||
|  |  | ||||||
|       if (confirm !== 'y' && confirm !== 'yes') { |       if (confirm !== 'y' && confirm !== 'yes') { | ||||||
|         logger.log('Deletion cancelled.'); |         logger.log('Deletion cancelled.'); | ||||||
|   | |||||||
| @@ -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) { | ||||||
| @@ -237,9 +213,11 @@ export class ServiceHandler { | |||||||
|         }); |         }); | ||||||
|       }; |       }; | ||||||
|  |  | ||||||
|       console.log('\nNUPST Uninstaller'); |       logger.log(''); | ||||||
|       console.log('==============='); |       logger.highlight('NUPST Uninstaller'); | ||||||
|       console.log('This will completely remove NUPST from your system.\n'); |       logger.dim('==============='); | ||||||
|  |       logger.log('This will completely remove NUPST from your system.'); | ||||||
|  |       logger.log(''); | ||||||
|  |  | ||||||
|       // Ask about removing configuration |       // Ask about removing configuration | ||||||
|       const removeConfig = await prompt( |       const removeConfig = await prompt( | ||||||
| @@ -275,17 +253,20 @@ export class ServiceHandler { | |||||||
|         } |         } | ||||||
|  |  | ||||||
|         if (!uninstallScriptPath) { |         if (!uninstallScriptPath) { | ||||||
|           console.error('Could not locate uninstall.sh script. Aborting uninstall.'); |           logger.error('Could not locate uninstall.sh script. Aborting uninstall.'); | ||||||
|           rl.close(); |           rl.close(); | ||||||
|  |           process.stdin.destroy(); | ||||||
|           process.exit(1); |           process.exit(1); | ||||||
|         } |         } | ||||||
|       } |       } | ||||||
|  |  | ||||||
|       // Close readline before executing script |       // Close readline before executing script | ||||||
|       rl.close(); |       rl.close(); | ||||||
|  |       process.stdin.destroy(); | ||||||
|  |  | ||||||
|       // Execute uninstall.sh with the appropriate option |       // Execute uninstall.sh with the appropriate option | ||||||
|       console.log(`\nRunning uninstaller from ${uninstallScriptPath}...`); |       logger.log(''); | ||||||
|  |       logger.log(`Running uninstaller from ${uninstallScriptPath}...`); | ||||||
|  |  | ||||||
|       // Pass the configuration removal option as an environment variable |       // Pass the configuration removal option as an environment variable | ||||||
|       const env = { |       const env = { | ||||||
| @@ -301,7 +282,7 @@ export class ServiceHandler { | |||||||
|         stdio: 'inherit', // Show output in the terminal |         stdio: 'inherit', // Show output in the terminal | ||||||
|       }); |       }); | ||||||
|     } catch (error) { |     } catch (error) { | ||||||
|       console.error(`Uninstall failed: ${error instanceof Error ? error.message : String(error)}`); |       logger.error(`Uninstall failed: ${error instanceof Error ? error.message : String(error)}`); | ||||||
|       process.exit(1); |       process.exit(1); | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|   | |||||||
| @@ -1,7 +1,8 @@ | |||||||
| import process from 'node:process'; | import process from 'node:process'; | ||||||
| import { execSync } from 'node:child_process'; | import { execSync } from 'node:child_process'; | ||||||
| import { Nupst } from '../nupst.ts'; | import { Nupst } from '../nupst.ts'; | ||||||
| import { logger } from '../logger.ts'; | import { logger, type ITableColumn } from '../logger.ts'; | ||||||
|  | import { theme } from '../colors.ts'; | ||||||
| import * as helpers from '../helpers/index.ts'; | import * as helpers from '../helpers/index.ts'; | ||||||
| import type { TUpsModel } from '../snmp/types.ts'; | import type { TUpsModel } from '../snmp/types.ts'; | ||||||
| import type { INupstConfig } from '../daemon.ts'; | import type { INupstConfig } from '../daemon.ts'; | ||||||
| @@ -47,6 +48,7 @@ export class UpsHandler { | |||||||
|         await this.runAddProcess(prompt); |         await this.runAddProcess(prompt); | ||||||
|       } finally { |       } finally { | ||||||
|         rl.close(); |         rl.close(); | ||||||
|  |         process.stdin.destroy(); | ||||||
|       } |       } | ||||||
|     } catch (error) { |     } catch (error) { | ||||||
|       logger.error(`Add UPS error: ${error instanceof Error ? error.message : String(error)}`); |       logger.error(`Add UPS error: ${error instanceof Error ? error.message : String(error)}`); | ||||||
| @@ -77,8 +79,8 @@ export class UpsHandler { | |||||||
|             id: 'default', |             id: 'default', | ||||||
|         name: 'Default UPS', |         name: 'Default UPS', | ||||||
|         snmp: config.snmp, |         snmp: config.snmp, | ||||||
|             thresholds: config.thresholds, |  | ||||||
|         groups: [], |         groups: [], | ||||||
|  |         actions: [], | ||||||
|           }], |           }], | ||||||
|           groups: [], |           groups: [], | ||||||
|         }; |         }; | ||||||
| @@ -115,14 +117,12 @@ export class UpsHandler { | |||||||
|         runtime: 20, |         runtime: 20, | ||||||
|       }, |       }, | ||||||
|       groups: [], |       groups: [], | ||||||
|  |       actions: [], | ||||||
|     }; |     }; | ||||||
|  |  | ||||||
|     // Gather SNMP settings |     // Gather SNMP settings | ||||||
|     await this.gatherSnmpSettings(newUps.snmp, prompt); |     await this.gatherSnmpSettings(newUps.snmp, prompt); | ||||||
|  |  | ||||||
|     // Gather threshold settings |  | ||||||
|     await this.gatherThresholdSettings(newUps.thresholds, prompt); |  | ||||||
|  |  | ||||||
|     // Gather UPS model settings |     // Gather UPS model settings | ||||||
|     await this.gatherUpsModelSettings(newUps.snmp, prompt); |     await this.gatherUpsModelSettings(newUps.snmp, prompt); | ||||||
|  |  | ||||||
| @@ -134,6 +134,9 @@ export class UpsHandler { | |||||||
|       await groupHandler.assignUpsToGroups(newUps, config.groups, prompt); |       await groupHandler.assignUpsToGroups(newUps, config.groups, prompt); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  | // Gather action settings | ||||||
|  |     await this.gatherActionSettings(newUps.actions, prompt); | ||||||
|  |  | ||||||
|     // Add the new UPS to the config |     // Add the new UPS to the config | ||||||
|     config.upsDevices.push(newUps); |     config.upsDevices.push(newUps); | ||||||
|  |  | ||||||
| @@ -178,6 +181,7 @@ export class UpsHandler { | |||||||
|         await this.runEditProcess(upsId, prompt); |         await this.runEditProcess(upsId, prompt); | ||||||
|       } finally { |       } finally { | ||||||
|         rl.close(); |         rl.close(); | ||||||
|  |         process.stdin.destroy(); | ||||||
|       } |       } | ||||||
|     } catch (error) { |     } catch (error) { | ||||||
|       logger.error(`Edit UPS error: ${error instanceof Error ? error.message : String(error)}`); |       logger.error(`Edit UPS error: ${error instanceof Error ? error.message : String(error)}`); | ||||||
| @@ -218,16 +222,16 @@ export class UpsHandler { | |||||||
|     // Convert old format to new format if needed |     // Convert old format to new format if needed | ||||||
|     if (!config.upsDevices) { |     if (!config.upsDevices) { | ||||||
|       // Initialize with the current config as the first UPS |       // Initialize with the current config as the first UPS | ||||||
|       if (!config.snmp || !config.thresholds) { |       if (!config.snmp) { | ||||||
|         logger.error('Legacy configuration is missing required SNMP or threshold settings'); |         logger.error('Legacy configuration is missing required SNMP settings'); | ||||||
|         return; |         return; | ||||||
|       } |       } | ||||||
|       config.upsDevices = [{ |       config.upsDevices = [{ | ||||||
|         id: 'default', |         id: 'default', | ||||||
|         name: 'Default UPS', |         name: 'Default UPS', | ||||||
|         snmp: config.snmp, |         snmp: config.snmp, | ||||||
|         thresholds: config.thresholds, |  | ||||||
|         groups: [], |         groups: [], | ||||||
|  |         actions: [], | ||||||
|       }]; |       }]; | ||||||
|       config.groups = []; |       config.groups = []; | ||||||
|       logger.log('Converting existing configuration to multi-UPS format.'); |       logger.log('Converting existing configuration to multi-UPS format.'); | ||||||
| @@ -262,9 +266,6 @@ export class UpsHandler { | |||||||
|     // Edit SNMP settings |     // Edit SNMP settings | ||||||
|     await this.gatherSnmpSettings(upsToEdit.snmp, prompt); |     await this.gatherSnmpSettings(upsToEdit.snmp, prompt); | ||||||
|  |  | ||||||
|     // Edit threshold settings |  | ||||||
|     await this.gatherThresholdSettings(upsToEdit.thresholds, prompt); |  | ||||||
|  |  | ||||||
|     // Edit UPS model settings |     // Edit UPS model settings | ||||||
|     await this.gatherUpsModelSettings(upsToEdit.snmp, prompt); |     await this.gatherUpsModelSettings(upsToEdit.snmp, prompt); | ||||||
|  |  | ||||||
| @@ -276,6 +277,14 @@ export class UpsHandler { | |||||||
|       await groupHandler.assignUpsToGroups(upsToEdit, config.groups, prompt); |       await groupHandler.assignUpsToGroups(upsToEdit, config.groups, prompt); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     // Initialize actions array if not exists | ||||||
|  |     if (!upsToEdit.actions) { | ||||||
|  |       upsToEdit.actions = []; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Edit action settings | ||||||
|  |     await this.gatherActionSettings(upsToEdit.actions, prompt); | ||||||
|  |  | ||||||
|     // Save the configuration |     // Save the configuration | ||||||
|     await this.nupst.getDaemon().saveConfig(config); |     await this.nupst.getDaemon().saveConfig(config); | ||||||
|  |  | ||||||
| @@ -344,6 +353,7 @@ export class UpsHandler { | |||||||
|       }); |       }); | ||||||
|  |  | ||||||
|       rl.close(); |       rl.close(); | ||||||
|  |       process.stdin.destroy(); | ||||||
|  |  | ||||||
|       if (confirm !== 'y' && confirm !== 'yes') { |       if (confirm !== 'y' && confirm !== 'yes') { | ||||||
|         logger.log('Deletion cancelled.'); |         logger.log('Deletion cancelled.'); | ||||||
| @@ -376,11 +386,10 @@ export class UpsHandler { | |||||||
|       try { |       try { | ||||||
|         await this.nupst.getDaemon().loadConfig(); |         await this.nupst.getDaemon().loadConfig(); | ||||||
|       } catch (error) { |       } catch (error) { | ||||||
|         const errorBoxWidth = 45; |         logger.logBox('Configuration Error', [ | ||||||
|         logger.logBoxTitle('Configuration Error', errorBoxWidth); |           'No configuration found.', | ||||||
|         logger.logBoxLine('No configuration found.'); |           "Please run 'nupst ups add' first to create a configuration.", | ||||||
|         logger.logBoxLine("Please run 'nupst setup' first to create a configuration."); |         ], 50, 'error'); | ||||||
|         logger.logBoxEnd(); |  | ||||||
|         return; |         return; | ||||||
|       } |       } | ||||||
|  |  | ||||||
| @@ -390,58 +399,56 @@ export class UpsHandler { | |||||||
|       // Check if multi-UPS config |       // Check if multi-UPS config | ||||||
|       if (!config.upsDevices || !Array.isArray(config.upsDevices)) { |       if (!config.upsDevices || !Array.isArray(config.upsDevices)) { | ||||||
|         // Legacy single UPS configuration |         // Legacy single UPS configuration | ||||||
|         const boxWidth = 45; |         logger.logBox('UPS Devices', [ | ||||||
|         logger.logBoxTitle('UPS Devices', boxWidth); |           'Legacy single-UPS configuration detected.', | ||||||
|         logger.logBoxLine('Legacy single-UPS configuration detected.'); |           '', | ||||||
|         if (!config.snmp || !config.thresholds) { |           ...(!config.snmp  | ||||||
|           logger.logBoxLine(''); |             ? ['Error: Configuration missing SNMP settings'] | ||||||
|           logger.logBoxLine('Error: Configuration missing SNMP or threshold settings'); |             : [ | ||||||
|           logger.logBoxEnd(); |                 'Default UPS:', | ||||||
|           return; |                 `  Host: ${config.snmp.host}:${config.snmp.port}`, | ||||||
|         } |                 `  Model: ${config.snmp.upsModel || 'cyberpower'}`, | ||||||
|         logger.logBoxLine(''); |                 '', | ||||||
|         logger.logBoxLine('Default UPS:'); |                 'Use "nupst ups add" to add more UPS devices and migrate', | ||||||
|         logger.logBoxLine(`  Host: ${config.snmp.host}:${config.snmp.port}`); |                 'to the multi-UPS configuration format.', | ||||||
|         logger.logBoxLine(`  Model: ${config.snmp.upsModel || 'cyberpower'}`); |               ] | ||||||
|         logger.logBoxLine( |           ), | ||||||
|           `  Thresholds: ${config.thresholds.battery}% battery, ${config.thresholds.runtime} min runtime`, |         ], 60, 'warning'); | ||||||
|         ); |  | ||||||
|         logger.logBoxLine(''); |  | ||||||
|         logger.logBoxLine('Use "nupst add" to add more UPS devices and migrate'); |  | ||||||
|         logger.logBoxLine('to the multi-UPS configuration format.'); |  | ||||||
|         logger.logBoxEnd(); |  | ||||||
|         return; |         return; | ||||||
|       } |       } | ||||||
|  |  | ||||||
|       // Display UPS list |       // Display UPS list with modern table | ||||||
|       const boxWidth = 60; |  | ||||||
|       logger.logBoxTitle('UPS Devices', boxWidth); |  | ||||||
|  |  | ||||||
|       if (config.upsDevices.length === 0) { |       if (config.upsDevices.length === 0) { | ||||||
|         logger.logBoxLine('No UPS devices configured.'); |         logger.logBox('UPS Devices', [ | ||||||
|         logger.logBoxLine('Use "nupst add" to add a UPS device.'); |           'No UPS devices configured.', | ||||||
|       } else { |           '', | ||||||
|         logger.logBoxLine(`Found ${config.upsDevices.length} UPS device(s)`); |           `${theme.dim('Run')} ${theme.command('nupst ups add')} ${theme.dim('to add a device')}`, | ||||||
|         logger.logBoxLine(''); |         ], 60, 'info'); | ||||||
|         logger.logBoxLine( |         return; | ||||||
|           'ID         | Name                 | Host            | Mode         | Groups', |  | ||||||
|         ); |  | ||||||
|         logger.logBoxLine( |  | ||||||
|           '-----------+----------------------+-----------------+--------------+----------------', |  | ||||||
|         ); |  | ||||||
|  |  | ||||||
|         for (const ups of config.upsDevices) { |  | ||||||
|           const id = ups.id.padEnd(10, ' ').substring(0, 10); |  | ||||||
|           const name = (ups.name || '').padEnd(20, ' ').substring(0, 20); |  | ||||||
|           const host = `${ups.snmp.host}:${ups.snmp.port}`.padEnd(15, ' ').substring(0, 15); |  | ||||||
|           const model = (ups.snmp.upsModel || 'cyberpower').padEnd(12, ' ').substring(0, 12); |  | ||||||
|           const groups = ups.groups.length > 0 ? ups.groups.join(', ') : 'None'; |  | ||||||
|  |  | ||||||
|           logger.logBoxLine(`${id} | ${name} | ${host} | ${model} | ${groups}`); |  | ||||||
|         } |  | ||||||
|       } |       } | ||||||
|  |  | ||||||
|       logger.logBoxEnd(); |       // Prepare table data | ||||||
|  |       const rows = config.upsDevices.map((ups) => ({ | ||||||
|  |         id: ups.id, | ||||||
|  |         name: ups.name || '', | ||||||
|  |         host: `${ups.snmp.host}:${ups.snmp.port}`, | ||||||
|  |         model: ups.snmp.upsModel || 'cyberpower', | ||||||
|  |         groups: ups.groups.length > 0 ? ups.groups.join(', ') : theme.dim('None'), | ||||||
|  |       })); | ||||||
|  |  | ||||||
|  |       const columns: ITableColumn[] = [ | ||||||
|  |         { header: 'ID', key: 'id', align: 'left', color: theme.highlight }, | ||||||
|  |         { header: 'Name', key: 'name', align: 'left' }, | ||||||
|  |         { header: 'Host:Port', key: 'host', align: 'left', color: theme.info }, | ||||||
|  |         { header: 'Model', key: 'model', align: 'left' }, | ||||||
|  |         { header: 'Groups', key: 'groups', align: 'left' }, | ||||||
|  |       ]; | ||||||
|  |  | ||||||
|  |       logger.log(''); | ||||||
|  |       logger.info(`UPS Devices (${config.upsDevices.length}):`); | ||||||
|  |       logger.log(''); | ||||||
|  |       logger.logTable(columns, rows); | ||||||
|  |       logger.log(''); | ||||||
|     } catch (error) { |     } catch (error) { | ||||||
|       logger.error( |       logger.error( | ||||||
|         `Failed to list UPS devices: ${error instanceof Error ? error.message : String(error)}`, |         `Failed to list UPS devices: ${error instanceof Error ? error.message : String(error)}`, | ||||||
| @@ -504,9 +511,8 @@ export class UpsHandler { | |||||||
|    */ |    */ | ||||||
|   private displayTestConfig(config: any): void { |   private displayTestConfig(config: any): void { | ||||||
|     // Check if this is a UPS device or full configuration |     // Check if this is a UPS device or full configuration | ||||||
|     const isUpsConfig = config.snmp && config.thresholds; |     const isUpsConfig = config.snmp; | ||||||
|     const snmpConfig = isUpsConfig ? config.snmp : config.snmp || {}; |     const snmpConfig = isUpsConfig ? config.snmp : config.snmp || {}; | ||||||
|     const thresholds = isUpsConfig ? config.thresholds : config.thresholds || {}; |  | ||||||
|     const checkInterval = config.checkInterval || 30000; |     const checkInterval = config.checkInterval || 30000; | ||||||
|  |  | ||||||
|     // Get UPS name and ID if available |     // Get UPS name and ID if available | ||||||
| @@ -550,10 +556,6 @@ export class UpsHandler { | |||||||
|       ); |       ); | ||||||
|       logger.logBoxLine(`  Battery Runtime: ${snmpConfig.customOIDs.BATTERY_RUNTIME || 'Not set'}`); |       logger.logBoxLine(`  Battery Runtime: ${snmpConfig.customOIDs.BATTERY_RUNTIME || 'Not set'}`); | ||||||
|     } |     } | ||||||
|     logger.logBoxLine('Thresholds:'); |  | ||||||
|     logger.logBoxLine(`  Battery: ${thresholds.battery}%`); |  | ||||||
|     logger.logBoxLine(`  Runtime: ${thresholds.runtime} minutes`); |  | ||||||
|  |  | ||||||
|     // Show group assignments if this is a UPS config |     // Show group assignments if this is a UPS config | ||||||
|     if (config.groups && Array.isArray(config.groups)) { |     if (config.groups && Array.isArray(config.groups)) { | ||||||
|       logger.logBoxLine( |       logger.logBoxLine( | ||||||
| @@ -577,7 +579,6 @@ export class UpsHandler { | |||||||
|     try { |     try { | ||||||
|       // Create a test config with a short timeout |       // Create a test config with a short timeout | ||||||
|       const snmpConfig = config.snmp ? config.snmp : config.snmp; |       const snmpConfig = config.snmp ? config.snmp : config.snmp; | ||||||
|       const thresholds = config.thresholds ? config.thresholds : config.thresholds; |  | ||||||
|  |  | ||||||
|       const testConfig = { |       const testConfig = { | ||||||
|         ...snmpConfig, |         ...snmpConfig, | ||||||
| @@ -594,10 +595,7 @@ export class UpsHandler { | |||||||
|       logger.logBoxLine(`  Runtime Remaining: ${status.batteryRuntime} minutes`); |       logger.logBoxLine(`  Runtime Remaining: ${status.batteryRuntime} minutes`); | ||||||
|       logger.logBoxEnd(); |       logger.logBoxEnd(); | ||||||
|  |  | ||||||
|       // Check status against thresholds if on battery |        | ||||||
|       if (status.powerStatus === 'onBattery') { |  | ||||||
|         this.analyzeThresholds(status, thresholds); |  | ||||||
|       } |  | ||||||
|     } catch (error) { |     } catch (error) { | ||||||
|       const errorBoxWidth = 45; |       const errorBoxWidth = 45; | ||||||
|       logger.logBoxTitle(`Connection Failed: ${upsName}`, errorBoxWidth); |       logger.logBoxTitle(`Connection Failed: ${upsName}`, errorBoxWidth); | ||||||
| @@ -667,10 +665,11 @@ export class UpsHandler { | |||||||
|  |  | ||||||
|     // SNMP Version |     // SNMP Version | ||||||
|     const defaultVersion = snmpConfig.version || 1; |     const defaultVersion = snmpConfig.version || 1; | ||||||
|     console.log('\nSNMP Version:'); |     logger.log(''); | ||||||
|     console.log('  1) SNMPv1'); |     logger.info('SNMP Version:'); | ||||||
|     console.log('  2) SNMPv2c'); |     logger.dim('  1) SNMPv1'); | ||||||
|     console.log('  3) SNMPv3 (with security features)'); |     logger.dim('  2) SNMPv2c'); | ||||||
|  |     logger.dim('  3) SNMPv3 (with security features)'); | ||||||
|     const versionInput = await prompt(`Select SNMP version [${defaultVersion}]: `); |     const versionInput = await prompt(`Select SNMP version [${defaultVersion}]: `); | ||||||
|     const version = parseInt(versionInput, 10); |     const version = parseInt(versionInput, 10); | ||||||
|     snmpConfig.version = versionInput.trim() && (version === 1 || version === 2 || version === 3) |     snmpConfig.version = versionInput.trim() && (version === 1 || version === 2 || version === 3) | ||||||
| @@ -697,13 +696,15 @@ export class UpsHandler { | |||||||
|     snmpConfig: any, |     snmpConfig: any, | ||||||
|     prompt: (question: string) => Promise<string>, |     prompt: (question: string) => Promise<string>, | ||||||
|   ): Promise<void> { |   ): Promise<void> { | ||||||
|     console.log('\nSNMPv3 Security Settings:'); |     logger.log(''); | ||||||
|  |     logger.info('SNMPv3 Security Settings:'); | ||||||
|  |  | ||||||
|     // Security Level |     // Security Level | ||||||
|     console.log('\nSecurity Level:'); |     logger.log(''); | ||||||
|     console.log('  1) noAuthNoPriv (No Authentication, No Privacy)'); |     logger.info('Security Level:'); | ||||||
|     console.log('  2) authNoPriv (Authentication, No Privacy)'); |     logger.dim('  1) noAuthNoPriv (No Authentication, No Privacy)'); | ||||||
|     console.log('  3) authPriv (Authentication and Privacy)'); |     logger.dim('  2) authNoPriv (Authentication, No Privacy)'); | ||||||
|  |     logger.dim('  3) authPriv (Authentication and Privacy)'); | ||||||
|     const defaultSecLevel = snmpConfig.securityLevel |     const defaultSecLevel = snmpConfig.securityLevel | ||||||
|       ? snmpConfig.securityLevel === 'noAuthNoPriv' |       ? snmpConfig.securityLevel === 'noAuthNoPriv' | ||||||
|         ? 1 |         ? 1 | ||||||
| @@ -752,8 +753,9 @@ export class UpsHandler { | |||||||
|  |  | ||||||
|       // Allow customizing the timeout value |       // Allow customizing the timeout value | ||||||
|       const defaultTimeout = snmpConfig.timeout / 1000; // Convert from ms to seconds for display |       const defaultTimeout = snmpConfig.timeout / 1000; // Convert from ms to seconds for display | ||||||
|       console.log( |       logger.log(''); | ||||||
|         '\nSNMPv3 operations with authentication and privacy may require longer timeouts.', |       logger.info( | ||||||
|  |         'SNMPv3 operations with authentication and privacy may require longer timeouts.', | ||||||
|       ); |       ); | ||||||
|       const timeoutInput = await prompt(`SNMP Timeout in seconds [${defaultTimeout}]: `); |       const timeoutInput = await prompt(`SNMP Timeout in seconds [${defaultTimeout}]: `); | ||||||
|       const timeout = parseInt(timeoutInput, 10); |       const timeout = parseInt(timeoutInput, 10); | ||||||
| @@ -773,9 +775,10 @@ export class UpsHandler { | |||||||
|     prompt: (question: string) => Promise<string>, |     prompt: (question: string) => Promise<string>, | ||||||
|   ): Promise<void> { |   ): Promise<void> { | ||||||
|     // Authentication protocol |     // Authentication protocol | ||||||
|     console.log('\nAuthentication Protocol:'); |     logger.log(''); | ||||||
|     console.log('  1) MD5'); |     logger.info('Authentication Protocol:'); | ||||||
|     console.log('  2) SHA'); |     logger.dim('  1) MD5'); | ||||||
|  |     logger.dim('  2) SHA'); | ||||||
|     const defaultAuthProtocol = snmpConfig.authProtocol === 'SHA' ? 2 : 1; |     const defaultAuthProtocol = snmpConfig.authProtocol === 'SHA' ? 2 : 1; | ||||||
|     const authProtocolInput = await prompt( |     const authProtocolInput = await prompt( | ||||||
|       `Select Authentication Protocol [${defaultAuthProtocol}]: `, |       `Select Authentication Protocol [${defaultAuthProtocol}]: `, | ||||||
| @@ -799,9 +802,10 @@ export class UpsHandler { | |||||||
|     prompt: (question: string) => Promise<string>, |     prompt: (question: string) => Promise<string>, | ||||||
|   ): Promise<void> { |   ): Promise<void> { | ||||||
|     // Privacy protocol |     // Privacy protocol | ||||||
|     console.log('\nPrivacy Protocol:'); |     logger.log(''); | ||||||
|     console.log('  1) DES'); |     logger.info('Privacy Protocol:'); | ||||||
|     console.log('  2) AES'); |     logger.dim('  1) DES'); | ||||||
|  |     logger.dim('  2) AES'); | ||||||
|     const defaultPrivProtocol = snmpConfig.privProtocol === 'AES' ? 2 : 1; |     const defaultPrivProtocol = snmpConfig.privProtocol === 'AES' ? 2 : 1; | ||||||
|     const privProtocolInput = await prompt(`Select Privacy Protocol [${defaultPrivProtocol}]: `); |     const privProtocolInput = await prompt(`Select Privacy Protocol [${defaultPrivProtocol}]: `); | ||||||
|     const privProtocol = parseInt(privProtocolInput, 10) || defaultPrivProtocol; |     const privProtocol = parseInt(privProtocolInput, 10) || defaultPrivProtocol; | ||||||
| @@ -813,38 +817,6 @@ export class UpsHandler { | |||||||
|     snmpConfig.privKey = privKey.trim() || defaultPrivKey; |     snmpConfig.privKey = privKey.trim() || defaultPrivKey; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   /** |  | ||||||
|    * Gather threshold settings |  | ||||||
|    * @param thresholds Thresholds configuration object to update |  | ||||||
|    * @param prompt Function to prompt for user input |  | ||||||
|    */ |  | ||||||
|   private async gatherThresholdSettings( |  | ||||||
|     thresholds: any, |  | ||||||
|     prompt: (question: string) => Promise<string>, |  | ||||||
|   ): Promise<void> { |  | ||||||
|     console.log('\nShutdown Thresholds:'); |  | ||||||
|  |  | ||||||
|     // Battery threshold |  | ||||||
|     const defaultBatteryThreshold = thresholds.battery || 60; |  | ||||||
|     const batteryThresholdInput = await prompt( |  | ||||||
|       `Battery percentage threshold [${defaultBatteryThreshold}%]: `, |  | ||||||
|     ); |  | ||||||
|     const batteryThreshold = parseInt(batteryThresholdInput, 10); |  | ||||||
|     thresholds.battery = batteryThresholdInput.trim() && !isNaN(batteryThreshold) |  | ||||||
|       ? batteryThreshold |  | ||||||
|       : defaultBatteryThreshold; |  | ||||||
|  |  | ||||||
|     // Runtime threshold |  | ||||||
|     const defaultRuntimeThreshold = thresholds.runtime || 20; |  | ||||||
|     const runtimeThresholdInput = await prompt( |  | ||||||
|       `Runtime minutes threshold [${defaultRuntimeThreshold} minutes]: `, |  | ||||||
|     ); |  | ||||||
|     const runtimeThreshold = parseInt(runtimeThresholdInput, 10); |  | ||||||
|     thresholds.runtime = runtimeThresholdInput.trim() && !isNaN(runtimeThreshold) |  | ||||||
|       ? runtimeThreshold |  | ||||||
|       : defaultRuntimeThreshold; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   /** |   /** | ||||||
|    * Gather UPS model settings |    * Gather UPS model settings | ||||||
|    * @param snmpConfig SNMP configuration object to update |    * @param snmpConfig SNMP configuration object to update | ||||||
| @@ -854,13 +826,14 @@ export class UpsHandler { | |||||||
|     snmpConfig: any, |     snmpConfig: any, | ||||||
|     prompt: (question: string) => Promise<string>, |     prompt: (question: string) => Promise<string>, | ||||||
|   ): Promise<void> { |   ): Promise<void> { | ||||||
|     console.log('\nUPS Model Selection:'); |     logger.log(''); | ||||||
|     console.log('  1) CyberPower'); |     logger.info('UPS Model Selection:'); | ||||||
|     console.log('  2) APC'); |     logger.dim('  1) CyberPower'); | ||||||
|     console.log('  3) Eaton'); |     logger.dim('  2) APC'); | ||||||
|     console.log('  4) TrippLite'); |     logger.dim('  3) Eaton'); | ||||||
|     console.log('  5) Liebert/Vertiv'); |     logger.dim('  4) TrippLite'); | ||||||
|     console.log('  6) Custom (Advanced)'); |     logger.dim('  5) Liebert/Vertiv'); | ||||||
|  |     logger.dim('  6) Custom (Advanced)'); | ||||||
|  |  | ||||||
|     const defaultModelValue = snmpConfig.upsModel === 'cyberpower' |     const defaultModelValue = snmpConfig.upsModel === 'cyberpower' | ||||||
|       ? 1 |       ? 1 | ||||||
| @@ -891,8 +864,9 @@ export class UpsHandler { | |||||||
|       snmpConfig.upsModel = 'liebert'; |       snmpConfig.upsModel = 'liebert'; | ||||||
|     } else if (modelValue === 6) { |     } else if (modelValue === 6) { | ||||||
|       snmpConfig.upsModel = 'custom'; |       snmpConfig.upsModel = 'custom'; | ||||||
|       console.log('\nEnter custom OIDs for your UPS:'); |       logger.log(''); | ||||||
|       console.log('(Leave blank to use standard RFC 1628 OIDs as fallback)'); |       logger.info('Enter custom OIDs for your UPS:'); | ||||||
|  |       logger.dim('(Leave blank to use standard RFC 1628 OIDs as fallback)'); | ||||||
|  |  | ||||||
|       // Custom OIDs |       // Custom OIDs | ||||||
|       const powerStatusOID = await prompt('Power Status OID: '); |       const powerStatusOID = await prompt('Power Status OID: '); | ||||||
| @@ -908,6 +882,151 @@ export class UpsHandler { | |||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Gather action configuration settings | ||||||
|  |    * @param actions Actions array to configure | ||||||
|  |    * @param prompt Function to prompt for user input | ||||||
|  |    */ | ||||||
|  |   private async gatherActionSettings( | ||||||
|  |     actions: any[], | ||||||
|  |     prompt: (question: string) => Promise<string>, | ||||||
|  |   ): Promise<void> { | ||||||
|  |     logger.log(''); | ||||||
|  |     logger.info('Action Configuration (Optional):'); | ||||||
|  |     logger.dim('Actions are triggered on power status changes and threshold violations.'); | ||||||
|  |     logger.dim('Leave empty to use default shutdown behavior on threshold violations.'); | ||||||
|  |  | ||||||
|  |     const configureActions = await prompt('Configure custom actions? (y/N): '); | ||||||
|  |     if (configureActions.toLowerCase() !== 'y') { | ||||||
|  |       return; // Keep existing actions or use default | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Clear existing actions | ||||||
|  |     actions.length = 0; | ||||||
|  |  | ||||||
|  |     let addMore = true; | ||||||
|  |     while (addMore) { | ||||||
|  |       logger.log(''); | ||||||
|  |       logger.info('Action Type:'); | ||||||
|  |       logger.dim('  1) Shutdown (system shutdown)'); | ||||||
|  |       logger.dim('  2) Webhook (HTTP notification)'); | ||||||
|  |       logger.dim('  3) Custom Script (run .sh file from /etc/nupst)'); | ||||||
|  |  | ||||||
|  |       const typeInput = await prompt('Select action type [1]: '); | ||||||
|  |       const typeValue = parseInt(typeInput, 10) || 1; | ||||||
|  |  | ||||||
|  |       const action: any = {}; | ||||||
|  |  | ||||||
|  |       if (typeValue === 1) { | ||||||
|  |         // Shutdown action | ||||||
|  |         action.type = 'shutdown'; | ||||||
|  |  | ||||||
|  |         const delayInput = await prompt('Shutdown delay in minutes [5]: '); | ||||||
|  |         const delay = parseInt(delayInput, 10); | ||||||
|  |         if (delayInput.trim() && !isNaN(delay)) { | ||||||
|  |           action.shutdownDelay = delay; | ||||||
|  |         } | ||||||
|  |       } else if (typeValue === 2) { | ||||||
|  |         // Webhook action | ||||||
|  |         action.type = 'webhook'; | ||||||
|  |  | ||||||
|  |         const url = await prompt('Webhook URL: '); | ||||||
|  |         if (!url.trim()) { | ||||||
|  |           logger.warn('Webhook URL required, skipping action'); | ||||||
|  |           continue; | ||||||
|  |         } | ||||||
|  |         action.webhookUrl = url.trim(); | ||||||
|  |  | ||||||
|  |         logger.log(''); | ||||||
|  |         logger.info('HTTP Method:'); | ||||||
|  |         logger.dim('  1) POST (JSON body)'); | ||||||
|  |         logger.dim('  2) GET (query parameters)'); | ||||||
|  |         const methodInput = await prompt('Select method [1]: '); | ||||||
|  |         action.webhookMethod = methodInput === '2' ? 'GET' : 'POST'; | ||||||
|  |  | ||||||
|  |         const timeoutInput = await prompt('Timeout in seconds [10]: '); | ||||||
|  |         const timeout = parseInt(timeoutInput, 10); | ||||||
|  |         if (timeoutInput.trim() && !isNaN(timeout)) { | ||||||
|  |           action.webhookTimeout = timeout * 1000; // Convert to ms | ||||||
|  |         } | ||||||
|  |       } else if (typeValue === 3) { | ||||||
|  |         // Script action | ||||||
|  |         action.type = 'script'; | ||||||
|  |  | ||||||
|  |         const scriptPath = await prompt('Script filename (in /etc/nupst/, must end with .sh): '); | ||||||
|  |         if (!scriptPath.trim() || !scriptPath.trim().endsWith('.sh')) { | ||||||
|  |           logger.warn('Script path must end with .sh, skipping action'); | ||||||
|  |           continue; | ||||||
|  |         } | ||||||
|  |         action.scriptPath = scriptPath.trim(); | ||||||
|  |  | ||||||
|  |         const timeoutInput = await prompt('Script timeout in seconds [60]: '); | ||||||
|  |         const timeout = parseInt(timeoutInput, 10); | ||||||
|  |         if (timeoutInput.trim() && !isNaN(timeout)) { | ||||||
|  |           action.scriptTimeout = timeout * 1000; // Convert to ms | ||||||
|  |         } | ||||||
|  |       } else { | ||||||
|  |         logger.warn('Invalid action type, skipping'); | ||||||
|  |         continue; | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       // Configure trigger mode (applies to all action types) | ||||||
|  |       logger.log(''); | ||||||
|  |       logger.info('Trigger Mode:'); | ||||||
|  |       logger.dim('  1) Power changes + thresholds (default)'); | ||||||
|  |       logger.dim('  2) Only power status changes'); | ||||||
|  |       logger.dim('  3) Only threshold violations'); | ||||||
|  |       logger.dim('  4) Any change (every ~30s check)'); | ||||||
|  |       const triggerInput = await prompt('Select trigger mode [1]: '); | ||||||
|  |       const triggerValue = parseInt(triggerInput, 10) || 1; | ||||||
|  |        | ||||||
|  |       switch (triggerValue) { | ||||||
|  |         case 2: | ||||||
|  |           action.triggerMode = 'onlyPowerChanges'; | ||||||
|  |           break; | ||||||
|  |         case 3: | ||||||
|  |           action.triggerMode = 'onlyThresholds'; | ||||||
|  |           break; | ||||||
|  |         case 4: | ||||||
|  |           action.triggerMode = 'anyChange'; | ||||||
|  |           break; | ||||||
|  |         default: | ||||||
|  |           action.triggerMode = 'powerChangesAndThresholds'; | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       // Configure thresholds if needed for onlyThresholds or powerChangesAndThresholds modes | ||||||
|  |       if (action.triggerMode === 'onlyThresholds' || action.triggerMode === 'powerChangesAndThresholds') { | ||||||
|  |         logger.log(''); | ||||||
|  |         logger.info('Action Thresholds:'); | ||||||
|  |         logger.dim('Action will trigger when battery or runtime falls below these values (while on battery)'); | ||||||
|  |          | ||||||
|  |         const batteryInput = await prompt('Battery threshold percentage [60]: '); | ||||||
|  |         const battery = parseInt(batteryInput, 10); | ||||||
|  |         const batteryThreshold = (batteryInput.trim() && !isNaN(battery)) ? battery : 60; | ||||||
|  |  | ||||||
|  |         const runtimeInput = await prompt('Runtime threshold in minutes [20]: '); | ||||||
|  |         const runtime = parseInt(runtimeInput, 10); | ||||||
|  |         const runtimeThreshold = (runtimeInput.trim() && !isNaN(runtime)) ? runtime : 20; | ||||||
|  |  | ||||||
|  |         action.thresholds = { | ||||||
|  |           battery: batteryThreshold, | ||||||
|  |           runtime: runtimeThreshold, | ||||||
|  |         }; | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       actions.push(action); | ||||||
|  |       logger.success(`${action.type.charAt(0).toUpperCase() + action.type.slice(1)} action added (mode: ${action.triggerMode || 'powerChangesAndThresholds'})`); | ||||||
|  |  | ||||||
|  |       const more = await prompt('Add another action? (y/N): '); | ||||||
|  |       addMore = more.toLowerCase() === 'y'; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (actions.length > 0) { | ||||||
|  |       logger.log(''); | ||||||
|  |       logger.success(`${actions.length} action(s) configured`); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|   /** |   /** | ||||||
|    * Display UPS configuration summary |    * Display UPS configuration summary | ||||||
|    * @param ups UPS configuration |    * @param ups UPS configuration | ||||||
| @@ -920,9 +1039,7 @@ export class UpsHandler { | |||||||
|     logger.logBoxLine(`SNMP Host: ${ups.snmp.host}:${ups.snmp.port}`); |     logger.logBoxLine(`SNMP Host: ${ups.snmp.host}:${ups.snmp.port}`); | ||||||
|     logger.logBoxLine(`SNMP Version: ${ups.snmp.version}`); |     logger.logBoxLine(`SNMP Version: ${ups.snmp.version}`); | ||||||
|     logger.logBoxLine(`UPS Model: ${ups.snmp.upsModel}`); |     logger.logBoxLine(`UPS Model: ${ups.snmp.upsModel}`); | ||||||
|     logger.logBoxLine( |      | ||||||
|       `Thresholds: ${ups.thresholds.battery}% battery, ${ups.thresholds.runtime} min runtime`, |  | ||||||
|     ); |  | ||||||
|     if (ups.groups && ups.groups.length > 0) { |     if (ups.groups && ups.groups.length > 0) { | ||||||
|       logger.logBoxLine(`Groups: ${ups.groups.join(', ')}`); |       logger.logBoxLine(`Groups: ${ups.groups.join(', ')}`); | ||||||
|     } else { |     } else { | ||||||
|   | |||||||
							
								
								
									
										88
									
								
								ts/colors.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										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'); | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										706
									
								
								ts/daemon.ts
									
									
									
									
									
								
							
							
						
						
									
										706
									
								
								ts/daemon.ts
									
									
									
									
									
								
							| @@ -4,8 +4,13 @@ import * as path from 'node:path'; | |||||||
| import { exec, execFile } from 'node:child_process'; | import { exec, execFile } from 'node:child_process'; | ||||||
| import { promisify } from 'node:util'; | import { promisify } from 'node:util'; | ||||||
| import { NupstSnmp } from './snmp/manager.ts'; | import { NupstSnmp } from './snmp/manager.ts'; | ||||||
| import type { ISnmpConfig } from './snmp/types.ts'; | import type { ISnmpConfig, IUpsStatus as ISnmpUpsStatus } from './snmp/types.ts'; | ||||||
| import { logger } from './logger.ts'; | import { logger, type ITableColumn } from './logger.ts'; | ||||||
|  | import { MigrationRunner } from './migrations/index.ts'; | ||||||
|  | import { theme, symbols, getBatteryColor, getRuntimeColor, formatPowerStatus } from './colors.ts'; | ||||||
|  | import type { IActionConfig } from './actions/base-action.ts'; | ||||||
|  | import { ActionManager, type IActionContext, type TPowerStatus } from './actions/index.ts'; | ||||||
|  | import { NupstHttpServer } from './http-server.ts'; | ||||||
|  |  | ||||||
| const execAsync = promisify(exec); | const execAsync = promisify(exec); | ||||||
| const execFileAsync = promisify(execFile); | const execFileAsync = promisify(execFile); | ||||||
| @@ -20,15 +25,10 @@ export interface IUpsConfig { | |||||||
|   name: string; |   name: string; | ||||||
|   /** SNMP configuration settings */ |   /** SNMP configuration settings */ | ||||||
|   snmp: ISnmpConfig; |   snmp: ISnmpConfig; | ||||||
|   /** Threshold settings for initiating shutdown */ |  | ||||||
|   thresholds: { |  | ||||||
|     /** Shutdown when battery below this percentage */ |  | ||||||
|     battery: number; |  | ||||||
|     /** Shutdown when runtime below this minutes */ |  | ||||||
|     runtime: number; |  | ||||||
|   }; |  | ||||||
|   /** Group IDs this UPS belongs to */ |   /** Group IDs this UPS belongs to */ | ||||||
|   groups: string[]; |   groups: string[]; | ||||||
|  |   /** Actions to trigger on power status changes and threshold violations */ | ||||||
|  |   actions?: IActionConfig[]; | ||||||
| } | } | ||||||
|  |  | ||||||
| /** | /** | ||||||
| @@ -43,23 +43,45 @@ export interface IGroupConfig { | |||||||
|   mode: 'redundant' | 'nonRedundant'; |   mode: 'redundant' | 'nonRedundant'; | ||||||
|   /** Optional description */ |   /** Optional description */ | ||||||
|   description?: string; |   description?: string; | ||||||
|  |   /** Actions to trigger on power status changes and threshold violations */ | ||||||
|  |   actions?: IActionConfig[]; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * HTTP Server configuration interface | ||||||
|  |  */ | ||||||
|  | export interface IHttpServerConfig { | ||||||
|  |   /** Whether HTTP server is enabled */ | ||||||
|  |   enabled: boolean; | ||||||
|  |   /** Port to listen on */ | ||||||
|  |   port: number; | ||||||
|  |   /** URL path for the endpoint */ | ||||||
|  |   path: string; | ||||||
|  |   /** Authentication token */ | ||||||
|  |   authToken: string; | ||||||
| } | } | ||||||
|  |  | ||||||
| /** | /** | ||||||
|  * 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 */ | ||||||
|   groups: IGroupConfig[]; |   groups: IGroupConfig[]; | ||||||
|   /** Check interval in milliseconds */ |   /** Check interval in milliseconds */ | ||||||
|   checkInterval: number; |   checkInterval: number; | ||||||
|  |   /** HTTP Server configuration */ | ||||||
|  |   httpServer?: IHttpServerConfig; | ||||||
|  |  | ||||||
|   // Legacy fields for backward compatibility |   // Legacy fields for backward compatibility (will be migrated away) | ||||||
|   /** SNMP configuration settings (legacy) */ |   /** UPS list (v3 format - legacy) */ | ||||||
|  |   upsList?: IUpsConfig[]; | ||||||
|  |   /** SNMP configuration settings (v1 format - legacy) */ | ||||||
|   snmp?: ISnmpConfig; |   snmp?: ISnmpConfig; | ||||||
|   /** Threshold settings (legacy) */ |   /** Threshold settings (v1 format - legacy) */ | ||||||
|   thresholds?: { |   thresholds?: { | ||||||
|     /** Shutdown when battery below this percentage */ |     /** Shutdown when battery below this percentage */ | ||||||
|     battery: number; |     battery: number; | ||||||
| @@ -71,12 +93,16 @@ export interface INupstConfig { | |||||||
| /** | /** | ||||||
|  * UPS status tracking interface |  * UPS status tracking interface | ||||||
|  */ |  */ | ||||||
| interface IUpsStatus { | export interface IUpsStatus { | ||||||
|   id: string; |   id: string; | ||||||
|   name: string; |   name: string; | ||||||
|   powerStatus: 'online' | 'onBattery' | 'unknown'; |   powerStatus: 'online' | 'onBattery' | 'unknown'; | ||||||
|   batteryCapacity: number; |   batteryCapacity: number; | ||||||
|   batteryRuntime: number; |   batteryRuntime: number; | ||||||
|  |   outputLoad: number;        // Load percentage (0-100%) | ||||||
|  |   outputPower: number;        // Power in watts | ||||||
|  |   outputVoltage: number;      // Voltage in volts | ||||||
|  |   outputCurrent: number;      // Current in amps | ||||||
|   lastStatusChange: number; |   lastStatusChange: number; | ||||||
|   lastCheckTime: number; |   lastCheckTime: number; | ||||||
| } | } | ||||||
| @@ -91,6 +117,7 @@ export class NupstDaemon { | |||||||
|  |  | ||||||
|   /** Default configuration */ |   /** Default configuration */ | ||||||
|   private readonly DEFAULT_CONFIG: INupstConfig = { |   private readonly DEFAULT_CONFIG: INupstConfig = { | ||||||
|  |     version: '4.2', | ||||||
|     upsDevices: [ |     upsDevices: [ | ||||||
|       { |       { | ||||||
|         id: 'default', |         id: 'default', | ||||||
| @@ -111,21 +138,29 @@ export class NupstDaemon { | |||||||
|           // UPS model for OID selection |           // UPS model for OID selection | ||||||
|           upsModel: 'cyberpower', |           upsModel: 'cyberpower', | ||||||
|         }, |         }, | ||||||
|  |         groups: [], | ||||||
|  |         actions: [ | ||||||
|  |           { | ||||||
|  |             type: 'shutdown', | ||||||
|  |             triggerMode: 'onlyThresholds', | ||||||
|             thresholds: { |             thresholds: { | ||||||
|               battery: 60, // Shutdown when battery below 60% |               battery: 60, // Shutdown when battery below 60% | ||||||
|               runtime: 20, // Shutdown when runtime below 20 minutes |               runtime: 20, // Shutdown when runtime below 20 minutes | ||||||
|             }, |             }, | ||||||
|         groups: [], |             shutdownDelay: 5, | ||||||
|  |           }, | ||||||
|  |         ], | ||||||
|       }, |       }, | ||||||
|     ], |     ], | ||||||
|     groups: [], |     groups: [], | ||||||
|     checkInterval: 30000, // Check every 30 seconds |     checkInterval: 30000, // Check every 30 seconds | ||||||
|   }; |   } | ||||||
|  |  | ||||||
|   private config: INupstConfig; |   private config: INupstConfig; | ||||||
|   private snmp: NupstSnmp; |   private snmp: NupstSnmp; | ||||||
|   private isRunning: boolean = false; |   private isRunning: boolean = false; | ||||||
|   private upsStatus: Map<string, IUpsStatus> = new Map(); |   private upsStatus: Map<string, IUpsStatus> = new Map(); | ||||||
|  |   private httpServer?: NupstHttpServer; | ||||||
|  |  | ||||||
|   /** |   /** | ||||||
|    * Create a new daemon instance with the given SNMP manager |    * Create a new daemon instance with the given SNMP manager | ||||||
| @@ -153,29 +188,18 @@ export class NupstDaemon { | |||||||
|       const configData = fs.readFileSync(this.CONFIG_PATH, 'utf8'); |       const configData = fs.readFileSync(this.CONFIG_PATH, 'utf8'); | ||||||
|       const parsedConfig = JSON.parse(configData); |       const parsedConfig = JSON.parse(configData); | ||||||
|  |  | ||||||
|       // Handle legacy configuration format |       // Run migrations to upgrade config format if needed | ||||||
|       if (!parsedConfig.upsDevices && parsedConfig.snmp) { |       const migrationRunner = new MigrationRunner(); | ||||||
|         // Convert legacy format to new format |       const { config: migratedConfig, migrated } = await migrationRunner.run(parsedConfig); | ||||||
|         this.config = { |  | ||||||
|           upsDevices: [ |  | ||||||
|             { |  | ||||||
|               id: 'default', |  | ||||||
|               name: 'Default UPS', |  | ||||||
|               snmp: parsedConfig.snmp, |  | ||||||
|               thresholds: parsedConfig.thresholds, |  | ||||||
|               groups: [], |  | ||||||
|             }, |  | ||||||
|           ], |  | ||||||
|           groups: [], |  | ||||||
|           checkInterval: parsedConfig.checkInterval, |  | ||||||
|         }; |  | ||||||
|  |  | ||||||
|         logger.log('Legacy configuration format detected. Converting to multi-UPS format.'); |       // Save migrated config back to disk if any migrations ran | ||||||
|  |       // Cast to INupstConfig since migrations ensure the output is valid | ||||||
|         // Save the new format |       const validConfig = migratedConfig as unknown as INupstConfig; | ||||||
|  |       if (migrated) { | ||||||
|  |         this.config = validConfig; | ||||||
|         await this.saveConfig(this.config); |         await this.saveConfig(this.config); | ||||||
|       } else { |       } else { | ||||||
|         this.config = parsedConfig; |         this.config = validConfig; | ||||||
|       } |       } | ||||||
|  |  | ||||||
|       return this.config; |       return this.config; | ||||||
| @@ -202,14 +226,21 @@ export class NupstDaemon { | |||||||
|       if (!fs.existsSync(configDir)) { |       if (!fs.existsSync(configDir)) { | ||||||
|         fs.mkdirSync(configDir, { recursive: true }); |         fs.mkdirSync(configDir, { recursive: true }); | ||||||
|       } |       } | ||||||
|       fs.writeFileSync(this.CONFIG_PATH, JSON.stringify(config, null, 2)); |  | ||||||
|       this.config = config; |  | ||||||
|  |  | ||||||
|       console.log('┌─ Configuration Saved ─────────────────────┐'); |       // Ensure version is always set and remove legacy fields before saving | ||||||
|       console.log(`│ Location: ${this.CONFIG_PATH}`); |       const configToSave: INupstConfig = { | ||||||
|       console.log('└──────────────────────────────────────────┘'); |         version: '4.1', | ||||||
|  |         upsDevices: config.upsDevices, | ||||||
|  |         groups: config.groups, | ||||||
|  |         checkInterval: config.checkInterval, | ||||||
|  |       }; | ||||||
|  |  | ||||||
|  |       fs.writeFileSync(this.CONFIG_PATH, JSON.stringify(configToSave, null, 2)); | ||||||
|  |       this.config = configToSave; | ||||||
|  |  | ||||||
|  |       logger.logBox('Configuration Saved', [`Location: ${this.CONFIG_PATH}`], 45, 'success'); | ||||||
|     } catch (error) { |     } catch (error) { | ||||||
|       console.error('Error saving configuration:', error); |       logger.error(`Error saving configuration: ${error}`); | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
| @@ -217,10 +248,7 @@ export class NupstDaemon { | |||||||
|    * Helper method to log configuration errors consistently |    * Helper method to log configuration errors consistently | ||||||
|    */ |    */ | ||||||
|   private logConfigError(message: string): void { |   private logConfigError(message: string): void { | ||||||
|     console.error('┌─ Configuration Error ─────────────────────┐'); |     logger.logBox('Configuration Error', [message, "Please run 'nupst setup' first to create a configuration."], 45, 'error'); | ||||||
|     console.error(`│ ${message}`); |  | ||||||
|     console.error("│ Please run 'nupst setup' first to create a configuration."); |  | ||||||
|     console.error('└───────────────────────────────────────────┘'); |  | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   /** |   /** | ||||||
| @@ -272,6 +300,21 @@ export class NupstDaemon { | |||||||
|       // Initialize UPS status tracking |       // Initialize UPS status tracking | ||||||
|       this.initializeUpsStatus(); |       this.initializeUpsStatus(); | ||||||
|  |  | ||||||
|  |       // Start HTTP server if configured | ||||||
|  |       if (this.config.httpServer?.enabled && this.config.httpServer.authToken) { | ||||||
|  |         try { | ||||||
|  |           this.httpServer = new NupstHttpServer( | ||||||
|  |             this.config.httpServer.port, | ||||||
|  |             this.config.httpServer.path, | ||||||
|  |             this.config.httpServer.authToken, | ||||||
|  |             () => this.upsStatus | ||||||
|  |           ); | ||||||
|  |           this.httpServer.start(); | ||||||
|  |         } catch (error) { | ||||||
|  |           logger.error(`Failed to start HTTP server: ${error instanceof Error ? error.message : String(error)}`); | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |  | ||||||
|       // Start UPS monitoring |       // Start UPS monitoring | ||||||
|       this.isRunning = true; |       this.isRunning = true; | ||||||
|       await this.monitor(); |       await this.monitor(); | ||||||
| @@ -298,6 +341,10 @@ export class NupstDaemon { | |||||||
|           powerStatus: 'unknown', |           powerStatus: 'unknown', | ||||||
|           batteryCapacity: 100, |           batteryCapacity: 100, | ||||||
|           batteryRuntime: 999, // High value as default |           batteryRuntime: 999, // High value as default | ||||||
|  |           outputLoad: 0, | ||||||
|  |           outputPower: 0, | ||||||
|  |           outputVoltage: 0, | ||||||
|  |           outputCurrent: 0, | ||||||
|           lastStatusChange: Date.now(), |           lastStatusChange: Date.now(), | ||||||
|           lastCheckTime: 0, |           lastCheckTime: 0, | ||||||
|         }); |         }); | ||||||
| @@ -313,29 +360,57 @@ export class NupstDaemon { | |||||||
|    * Log the loaded configuration settings |    * Log the loaded configuration settings | ||||||
|    */ |    */ | ||||||
|   private logConfigLoaded(): void { |   private logConfigLoaded(): void { | ||||||
|     const boxWidth = 50; |  | ||||||
|     logger.logBoxTitle('Configuration Loaded', boxWidth); |  | ||||||
|  |  | ||||||
|     if (this.config.upsDevices && this.config.upsDevices.length > 0) { |  | ||||||
|       logger.logBoxLine(`UPS Devices: ${this.config.upsDevices.length}`); |  | ||||||
|       for (const ups of this.config.upsDevices) { |  | ||||||
|         logger.logBoxLine(`  - ${ups.name} (${ups.id}): ${ups.snmp.host}:${ups.snmp.port}`); |  | ||||||
|       } |  | ||||||
|     } else { |  | ||||||
|       logger.logBoxLine('No UPS devices configured'); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     if (this.config.groups && this.config.groups.length > 0) { |  | ||||||
|       logger.logBoxLine(`Groups: ${this.config.groups.length}`); |  | ||||||
|       for (const group of this.config.groups) { |  | ||||||
|         logger.logBoxLine(`  - ${group.name} (${group.id}): ${group.mode} mode`); |  | ||||||
|       } |  | ||||||
|     } else { |  | ||||||
|       logger.logBoxLine('No Groups configured'); |  | ||||||
|     } |  | ||||||
|      |      | ||||||
|  |     logger.log(''); | ||||||
|  |     logger.logBoxTitle('Configuration Loaded', 70, 'success'); | ||||||
|     logger.logBoxLine(`Check Interval: ${this.config.checkInterval / 1000} seconds`); |     logger.logBoxLine(`Check Interval: ${this.config.checkInterval / 1000} seconds`); | ||||||
|     logger.logBoxEnd(); |     logger.logBoxEnd(); | ||||||
|  |     logger.log(''); | ||||||
|  |  | ||||||
|  |     // Display UPS devices in a table | ||||||
|  |     if (this.config.upsDevices && this.config.upsDevices.length > 0) { | ||||||
|  |       logger.info(`UPS Devices (${this.config.upsDevices.length}):`); | ||||||
|  |        | ||||||
|  |       const upsColumns: Array<{ header: string; key: string; align?: 'left' | 'right'; color?: (val: string) => string }> = [ | ||||||
|  |         { header: 'Name', key: 'name', align: 'left', color: theme.highlight }, | ||||||
|  |         { header: 'ID', key: 'id', align: 'left', color: theme.dim }, | ||||||
|  |         { header: 'Host:Port', key: 'host', align: 'left', color: theme.info }, | ||||||
|  |         { header: 'Actions', key: 'actions', align: 'left' }, | ||||||
|  |       ]; | ||||||
|  |  | ||||||
|  |       const upsRows: Array<Record<string, string>> = this.config.upsDevices.map((ups) => ({ | ||||||
|  |         name: ups.name, | ||||||
|  |         id: ups.id, | ||||||
|  |         host: `${ups.snmp.host}:${ups.snmp.port}`, | ||||||
|  |         actions: `${(ups.actions || []).length} configured`, | ||||||
|  |       })); | ||||||
|  |  | ||||||
|  |       logger.logTable(upsColumns, upsRows); | ||||||
|  |       logger.log(''); | ||||||
|  |     } else { | ||||||
|  |       logger.warn('No UPS devices configured'); | ||||||
|  |       logger.log(''); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Display groups in a table | ||||||
|  |     if (this.config.groups && this.config.groups.length > 0) { | ||||||
|  |       logger.info(`Groups (${this.config.groups.length}):`); | ||||||
|  |        | ||||||
|  |       const groupColumns: Array<{ header: string; key: string; align?: 'left' | 'right'; color?: (val: string) => string }> = [ | ||||||
|  |         { header: 'Name', key: 'name', align: 'left', color: theme.highlight }, | ||||||
|  |         { header: 'ID', key: 'id', align: 'left', color: theme.dim }, | ||||||
|  |         { header: 'Mode', key: 'mode', align: 'left', color: theme.info }, | ||||||
|  |       ]; | ||||||
|  |  | ||||||
|  |       const groupRows: Array<Record<string, string>> = this.config.groups.map((group) => ({ | ||||||
|  |         name: group.name, | ||||||
|  |         id: group.id, | ||||||
|  |         mode: group.mode, | ||||||
|  |       })); | ||||||
|  |  | ||||||
|  |       logger.logTable(groupColumns, groupRows); | ||||||
|  |       logger.log(''); | ||||||
|  |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   /** |   /** | ||||||
| @@ -343,6 +418,12 @@ export class NupstDaemon { | |||||||
|    */ |    */ | ||||||
|   public stop(): void { |   public stop(): void { | ||||||
|     logger.log('Stopping NUPST daemon...'); |     logger.log('Stopping NUPST daemon...'); | ||||||
|  |  | ||||||
|  |     // Stop HTTP server if running | ||||||
|  |     if (this.httpServer) { | ||||||
|  |       this.httpServer.stop(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|     this.isRunning = false; |     this.isRunning = false; | ||||||
|   } |   } | ||||||
|  |  | ||||||
| @@ -353,8 +434,9 @@ export class NupstDaemon { | |||||||
|     logger.log('Starting UPS monitoring...'); |     logger.log('Starting UPS monitoring...'); | ||||||
|  |  | ||||||
|     if (!this.config.upsDevices || this.config.upsDevices.length === 0) { |     if (!this.config.upsDevices || this.config.upsDevices.length === 0) { | ||||||
|       logger.error('No UPS devices found in configuration. Monitoring stopped.'); |       logger.warn('No UPS devices found in configuration. Daemon will remain idle...'); | ||||||
|       this.isRunning = false; |       // Don't exit - enter idle monitoring mode instead | ||||||
|  |       await this.idleMonitoring(); | ||||||
|       return; |       return; | ||||||
|     } |     } | ||||||
|  |  | ||||||
| @@ -374,9 +456,6 @@ export class NupstDaemon { | |||||||
|           lastLogTime = currentTime; |           lastLogTime = currentTime; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         // Check if shutdown is required based on group configurations |  | ||||||
|         await this.evaluateGroupShutdownConditions(); |  | ||||||
|  |  | ||||||
|         // Wait before next check |         // Wait before next check | ||||||
|         await this.sleep(this.config.checkInterval); |         await this.sleep(this.config.checkInterval); | ||||||
|       } catch (error) { |       } catch (error) { | ||||||
| @@ -405,6 +484,10 @@ export class NupstDaemon { | |||||||
|             powerStatus: 'unknown', |             powerStatus: 'unknown', | ||||||
|             batteryCapacity: 100, |             batteryCapacity: 100, | ||||||
|             batteryRuntime: 999, |             batteryRuntime: 999, | ||||||
|  |             outputLoad: 0, | ||||||
|  |             outputPower: 0, | ||||||
|  |             outputVoltage: 0, | ||||||
|  |             outputCurrent: 0, | ||||||
|             lastStatusChange: Date.now(), |             lastStatusChange: Date.now(), | ||||||
|             lastCheckTime: 0, |             lastCheckTime: 0, | ||||||
|           }); |           }); | ||||||
| @@ -424,17 +507,52 @@ export class NupstDaemon { | |||||||
|           powerStatus: status.powerStatus, |           powerStatus: status.powerStatus, | ||||||
|           batteryCapacity: status.batteryCapacity, |           batteryCapacity: status.batteryCapacity, | ||||||
|           batteryRuntime: status.batteryRuntime, |           batteryRuntime: status.batteryRuntime, | ||||||
|  |           outputLoad: status.outputLoad, | ||||||
|  |           outputPower: status.outputPower, | ||||||
|  |           outputVoltage: status.outputVoltage, | ||||||
|  |           outputCurrent: status.outputCurrent, | ||||||
|           lastCheckTime: currentTime, |           lastCheckTime: currentTime, | ||||||
|           lastStatusChange: currentStatus?.lastStatusChange || currentTime, |           lastStatusChange: currentStatus?.lastStatusChange || currentTime, | ||||||
|         }; |         }; | ||||||
|  |  | ||||||
|         // Check if power status changed |         // Check if power status changed | ||||||
|         if (currentStatus && currentStatus.powerStatus !== status.powerStatus) { |         if (currentStatus && currentStatus.powerStatus !== status.powerStatus) { | ||||||
|           logger.logBoxTitle(`Power Status Change: ${ups.name}`, 50); |           logger.log(''); | ||||||
|           logger.logBoxLine(`Status changed: ${currentStatus.powerStatus} → ${status.powerStatus}`); |           logger.logBoxTitle(`Power Status Change: ${ups.name}`, 60, 'warning'); | ||||||
|  |           logger.logBoxLine(`Previous: ${formatPowerStatus(currentStatus.powerStatus)}`); | ||||||
|  |           logger.logBoxLine(`Current:  ${formatPowerStatus(status.powerStatus)}`); | ||||||
|  |           logger.logBoxLine(`Time: ${new Date().toISOString()}`); | ||||||
|           logger.logBoxEnd(); |           logger.logBoxEnd(); | ||||||
|  |           logger.log(''); | ||||||
|  |  | ||||||
|           updatedStatus.lastStatusChange = currentTime; |           updatedStatus.lastStatusChange = currentTime; | ||||||
|  |  | ||||||
|  |           // Trigger actions for power status change | ||||||
|  |           await this.triggerUpsActions(ups, updatedStatus, currentStatus, 'powerStatusChange'); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // Check if any action's thresholds are exceeded (for threshold violation triggers) | ||||||
|  |         // Only check when on battery power | ||||||
|  |         if (status.powerStatus === 'onBattery' && ups.actions && ups.actions.length > 0) { | ||||||
|  |           let anyThresholdExceeded = false; | ||||||
|  |            | ||||||
|  |           for (const actionConfig of ups.actions) { | ||||||
|  |             if (actionConfig.thresholds) { | ||||||
|  |               if ( | ||||||
|  |                 status.batteryCapacity < actionConfig.thresholds.battery || | ||||||
|  |                 status.batteryRuntime < actionConfig.thresholds.runtime | ||||||
|  |               ) { | ||||||
|  |                 anyThresholdExceeded = true; | ||||||
|  |                 break; | ||||||
|  |               } | ||||||
|  |             } | ||||||
|  |           } | ||||||
|  |  | ||||||
|  |           // Trigger actions with threshold violation reason if any threshold is exceeded | ||||||
|  |           // Actions will individually check their own thresholds in shouldExecute() | ||||||
|  |           if (anyThresholdExceeded) { | ||||||
|  |             await this.triggerUpsActions(ups, updatedStatus, currentStatus, 'thresholdViolation'); | ||||||
|  |           } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         // Update the status in the map |         // Update the status in the map | ||||||
| @@ -454,171 +572,100 @@ export class NupstDaemon { | |||||||
|    */ |    */ | ||||||
|   private logAllUpsStatus(): void { |   private logAllUpsStatus(): void { | ||||||
|     const timestamp = new Date().toISOString(); |     const timestamp = new Date().toISOString(); | ||||||
|     const boxWidth = 60; |      | ||||||
|     logger.logBoxTitle('Periodic Status Update', boxWidth); |     logger.log(''); | ||||||
|  |     logger.logBoxTitle('Periodic Status Update', 70, 'info'); | ||||||
|     logger.logBoxLine(`Timestamp: ${timestamp}`); |     logger.logBoxLine(`Timestamp: ${timestamp}`); | ||||||
|     logger.logBoxLine(''); |     logger.logBoxEnd(); | ||||||
|  |     logger.log(''); | ||||||
|  |  | ||||||
|  |     // Build table data | ||||||
|  |     const columns: Array<{ header: string; key: string; align?: 'left' | 'right'; color?: (val: string) => string }> = [ | ||||||
|  |       { header: 'UPS Name', key: 'name', align: 'left', color: theme.highlight }, | ||||||
|  |       { header: 'ID', key: 'id', align: 'left', color: theme.dim }, | ||||||
|  |       { header: 'Power Status', key: 'powerStatus', align: 'left' }, | ||||||
|  |       { header: 'Battery', key: 'battery', align: 'right' }, | ||||||
|  |       { header: 'Runtime', key: 'runtime', align: 'right' }, | ||||||
|  |     ]; | ||||||
|  |  | ||||||
|  |     const rows: Array<Record<string, string>> = []; | ||||||
|     for (const [id, status] of this.upsStatus.entries()) { |     for (const [id, status] of this.upsStatus.entries()) { | ||||||
|       logger.logBoxLine(`UPS: ${status.name} (${id})`); |       const batteryColor = getBatteryColor(status.batteryCapacity); | ||||||
|       logger.logBoxLine(`  Power Status: ${status.powerStatus}`); |       const runtimeColor = getRuntimeColor(status.batteryRuntime); | ||||||
|       logger.logBoxLine( |        | ||||||
|         `  Battery: ${status.batteryCapacity}% | Runtime: ${status.batteryRuntime} min`, |       rows.push({ | ||||||
|       ); |         name: status.name, | ||||||
|       logger.logBoxLine(''); |         id: id, | ||||||
|  |         powerStatus: formatPowerStatus(status.powerStatus), | ||||||
|  |         battery: batteryColor(status.batteryCapacity + '%'), | ||||||
|  |         runtime: runtimeColor(status.batteryRuntime + ' min'), | ||||||
|  |       }); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     logger.logBoxEnd(); |     logger.logTable(columns, rows); | ||||||
|  |     logger.log(''); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |    | ||||||
|  |  | ||||||
|  |      | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Build action context from UPS state | ||||||
|  |    * @param ups UPS configuration | ||||||
|  |    * @param status Current UPS status | ||||||
|  |    * @param triggerReason Why this action is being triggered | ||||||
|  |    * @returns Action context | ||||||
|  |    */ | ||||||
|  |   private buildActionContext( | ||||||
|  |     ups: IUpsConfig, | ||||||
|  |     status: IUpsStatus, | ||||||
|  |     triggerReason: 'powerStatusChange' | 'thresholdViolation', | ||||||
|  |   ): IActionContext { | ||||||
|  |     return { | ||||||
|  |       upsId: ups.id, | ||||||
|  |       upsName: ups.name, | ||||||
|  |       powerStatus: status.powerStatus as TPowerStatus, | ||||||
|  |       batteryCapacity: status.batteryCapacity, | ||||||
|  |       batteryRuntime: status.batteryRuntime, | ||||||
|  |       previousPowerStatus: 'unknown' as TPowerStatus, // Will be set from map in calling code | ||||||
|  |       timestamp: Date.now(), | ||||||
|  |       triggerReason, | ||||||
|  |     }; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   /** |   /** | ||||||
|    * Evaluate if shutdown is required based on group configurations |    * Trigger actions for a UPS device | ||||||
|  |    * @param ups UPS configuration | ||||||
|  |    * @param status Current UPS status | ||||||
|  |    * @param previousStatus Previous UPS status (for determining previousPowerStatus) | ||||||
|  |    * @param triggerReason Why actions are being triggered | ||||||
|    */ |    */ | ||||||
|   private async evaluateGroupShutdownConditions(): Promise<void> { |   private async triggerUpsActions( | ||||||
|     if (!this.config.groups || this.config.groups.length === 0) { |     ups: IUpsConfig, | ||||||
|       // No groups defined, check individual UPS conditions |     status: IUpsStatus, | ||||||
|       for (const [id, status] of this.upsStatus.entries()) { |     previousStatus: IUpsStatus | undefined, | ||||||
|         if (status.powerStatus === 'onBattery') { |     triggerReason: 'powerStatusChange' | 'thresholdViolation', | ||||||
|           // Find the UPS config |  | ||||||
|           const ups = this.config.upsDevices.find((u) => u.id === id); |  | ||||||
|           if (ups) { |  | ||||||
|             await this.evaluateUpsShutdownCondition(ups, status); |  | ||||||
|           } |  | ||||||
|         } |  | ||||||
|       } |  | ||||||
|       return; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     // Evaluate each group |  | ||||||
|     for (const group of this.config.groups) { |  | ||||||
|       // Find all UPS devices in this group |  | ||||||
|       const upsDevicesInGroup = this.config.upsDevices.filter((ups) => |  | ||||||
|         ups.groups && ups.groups.includes(group.id) |  | ||||||
|       ); |  | ||||||
|  |  | ||||||
|       if (upsDevicesInGroup.length === 0) { |  | ||||||
|         // No UPS devices in this group |  | ||||||
|         continue; |  | ||||||
|       } |  | ||||||
|  |  | ||||||
|       if (group.mode === 'redundant') { |  | ||||||
|         // Redundant mode: only shutdown if ALL UPS devices in the group are in critical condition |  | ||||||
|         await this.evaluateRedundantGroup(group, upsDevicesInGroup); |  | ||||||
|       } else { |  | ||||||
|         // Non-redundant mode: shutdown if ANY UPS device in the group is in critical condition |  | ||||||
|         await this.evaluateNonRedundantGroup(group, upsDevicesInGroup); |  | ||||||
|       } |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   /** |  | ||||||
|    * Evaluate a redundant group for shutdown conditions |  | ||||||
|    * In redundant mode, we only shut down if ALL UPS devices are in critical condition |  | ||||||
|    */ |  | ||||||
|   private async evaluateRedundantGroup( |  | ||||||
|     group: IGroupConfig, |  | ||||||
|     upsDevices: IUpsConfig[], |  | ||||||
|   ): Promise<void> { |   ): Promise<void> { | ||||||
|     // Count UPS devices on battery and in critical condition |     const actions = ups.actions || []; | ||||||
|     let upsOnBattery = 0; |  | ||||||
|     let upsInCriticalCondition = 0; |  | ||||||
|  |  | ||||||
|     for (const ups of upsDevices) { |  | ||||||
|       const status = this.upsStatus.get(ups.id); |  | ||||||
|       if (!status) continue; |  | ||||||
|  |  | ||||||
|       if (status.powerStatus === 'onBattery') { |  | ||||||
|         upsOnBattery++; |  | ||||||
|  |  | ||||||
|         // Check if this UPS is in critical condition |  | ||||||
|         if ( |  | ||||||
|           status.batteryCapacity < ups.thresholds.battery || |  | ||||||
|           status.batteryRuntime < ups.thresholds.runtime |  | ||||||
|         ) { |  | ||||||
|           upsInCriticalCondition++; |  | ||||||
|         } |  | ||||||
|       } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     // All UPS devices must be online for a redundant group to be considered healthy |  | ||||||
|     const allUpsCount = upsDevices.length; |  | ||||||
|  |  | ||||||
|     // If all UPS are on battery and in critical condition, shutdown |  | ||||||
|     if (upsOnBattery === allUpsCount && upsInCriticalCondition === allUpsCount) { |  | ||||||
|       logger.logBoxTitle(`Group Shutdown Required: ${group.name}`, 50); |  | ||||||
|       logger.logBoxLine(`Mode: Redundant`); |  | ||||||
|       logger.logBoxLine(`All ${allUpsCount} UPS devices in critical condition`); |  | ||||||
|       logger.logBoxEnd(); |  | ||||||
|  |  | ||||||
|       await this.initiateShutdown( |  | ||||||
|         `All UPS devices in redundant group "${group.name}" in critical condition`, |  | ||||||
|       ); |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   /** |  | ||||||
|    * Evaluate a non-redundant group for shutdown conditions |  | ||||||
|    * In non-redundant mode, we shut down if ANY UPS device is in critical condition |  | ||||||
|    */ |  | ||||||
|   private async evaluateNonRedundantGroup( |  | ||||||
|     group: IGroupConfig, |  | ||||||
|     upsDevices: IUpsConfig[], |  | ||||||
|   ): Promise<void> { |  | ||||||
|     for (const ups of upsDevices) { |  | ||||||
|       const status = this.upsStatus.get(ups.id); |  | ||||||
|       if (!status) continue; |  | ||||||
|  |  | ||||||
|       if (status.powerStatus === 'onBattery') { |  | ||||||
|         // Check if this UPS is in critical condition |  | ||||||
|         if ( |  | ||||||
|           status.batteryCapacity < ups.thresholds.battery || |  | ||||||
|           status.batteryRuntime < ups.thresholds.runtime |  | ||||||
|         ) { |  | ||||||
|           logger.logBoxTitle(`Group Shutdown Required: ${group.name}`, 50); |  | ||||||
|           logger.logBoxLine(`Mode: Non-Redundant`); |  | ||||||
|           logger.logBoxLine(`UPS ${ups.name} in critical condition`); |  | ||||||
|           logger.logBoxLine( |  | ||||||
|             `Battery: ${status.batteryCapacity}% (threshold: ${ups.thresholds.battery}%)`, |  | ||||||
|           ); |  | ||||||
|           logger.logBoxLine( |  | ||||||
|             `Runtime: ${status.batteryRuntime} min (threshold: ${ups.thresholds.runtime} min)`, |  | ||||||
|           ); |  | ||||||
|           logger.logBoxEnd(); |  | ||||||
|  |  | ||||||
|           await this.initiateShutdown( |  | ||||||
|             `UPS "${ups.name}" in non-redundant group "${group.name}" in critical condition`, |  | ||||||
|           ); |  | ||||||
|           return; // Exit after initiating shutdown |  | ||||||
|         } |  | ||||||
|       } |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   /** |  | ||||||
|    * Evaluate an individual UPS for shutdown conditions |  | ||||||
|    */ |  | ||||||
|   private async evaluateUpsShutdownCondition(ups: IUpsConfig, status: IUpsStatus): Promise<void> { |  | ||||||
|     // Only evaluate UPS devices not in any group |  | ||||||
|     if (ups.groups && ups.groups.length > 0) { |  | ||||||
|       return; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     // Check threshold conditions |  | ||||||
|     if ( |  | ||||||
|       status.batteryCapacity < ups.thresholds.battery || |  | ||||||
|       status.batteryRuntime < ups.thresholds.runtime |  | ||||||
|     ) { |  | ||||||
|       logger.logBoxTitle(`UPS Shutdown Required: ${ups.name}`, 50); |  | ||||||
|       logger.logBoxLine( |  | ||||||
|         `Battery: ${status.batteryCapacity}% (threshold: ${ups.thresholds.battery}%)`, |  | ||||||
|       ); |  | ||||||
|       logger.logBoxLine( |  | ||||||
|         `Runtime: ${status.batteryRuntime} min (threshold: ${ups.thresholds.runtime} min)`, |  | ||||||
|       ); |  | ||||||
|       logger.logBoxEnd(); |  | ||||||
|  |  | ||||||
|  |     // Backward compatibility: if no actions configured, use default shutdown behavior | ||||||
|  |     if (actions.length === 0 && triggerReason === 'thresholdViolation') { | ||||||
|  |       // Fall back to old shutdown logic for backward compatibility | ||||||
|       await this.initiateShutdown(`UPS "${ups.name}" battery or runtime below threshold`); |       await this.initiateShutdown(`UPS "${ups.name}" battery or runtime below threshold`); | ||||||
|  |       return; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     if (actions.length === 0) { | ||||||
|  |       return; // No actions to execute | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Build action context | ||||||
|  |     const context = this.buildActionContext(ups, status, triggerReason); | ||||||
|  |     context.previousPowerStatus = (previousStatus?.powerStatus || 'unknown') as TPowerStatus; | ||||||
|  |  | ||||||
|  |     // Execute actions | ||||||
|  |     await ActionManager.executeActions(actions, context); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   /** |   /** | ||||||
| @@ -747,38 +794,61 @@ export class NupstDaemon { | |||||||
|     const MAX_MONITORING_TIME = 5 * 60 * 1000; // Max 5 minutes of monitoring |     const MAX_MONITORING_TIME = 5 * 60 * 1000; // Max 5 minutes of monitoring | ||||||
|     const startTime = Date.now(); |     const startTime = Date.now(); | ||||||
|  |  | ||||||
|     logger.log( |     logger.log(''); | ||||||
|       `Emergency shutdown threshold: ${EMERGENCY_RUNTIME_THRESHOLD} minutes remaining battery runtime`, |     logger.logBoxTitle('Shutdown Monitoring Active', 60, 'warning'); | ||||||
|     ); |     logger.logBoxLine(`Emergency threshold: ${EMERGENCY_RUNTIME_THRESHOLD} minutes runtime`); | ||||||
|  |     logger.logBoxLine(`Check interval: ${CHECK_INTERVAL / 1000} seconds`); | ||||||
|  |     logger.logBoxLine(`Max monitoring time: ${MAX_MONITORING_TIME / 1000} seconds`); | ||||||
|  |     logger.logBoxEnd(); | ||||||
|  |     logger.log(''); | ||||||
|  |  | ||||||
|     // Continue monitoring until max monitoring time is reached |     // Continue monitoring until max monitoring time is reached | ||||||
|     while (Date.now() - startTime < MAX_MONITORING_TIME) { |     while (Date.now() - startTime < MAX_MONITORING_TIME) { | ||||||
|       try { |       try { | ||||||
|         logger.log('Checking UPS status during shutdown...'); |         logger.info('Checking UPS status during shutdown...'); | ||||||
|  |  | ||||||
|  |         // Build table for UPS status during shutdown | ||||||
|  |         const columns: Array<{ header: string; key: string; align?: 'left' | 'right'; color?: (val: string) => string }> = [ | ||||||
|  |           { header: 'UPS Name', key: 'name', align: 'left', color: theme.highlight }, | ||||||
|  |           { header: 'Battery', key: 'battery', align: 'right' }, | ||||||
|  |           { header: 'Runtime', key: 'runtime', align: 'right' }, | ||||||
|  |           { header: 'Status', key: 'status', align: 'left' }, | ||||||
|  |         ]; | ||||||
|  |  | ||||||
|  |         const rows: Array<Record<string, string>> = []; | ||||||
|  |         let emergencyDetected = false; | ||||||
|  |         let emergencyUps: { ups: IUpsConfig; status: ISnmpUpsStatus } | null = null; | ||||||
|  |  | ||||||
|         // Check all UPS devices |         // Check all UPS devices | ||||||
|         for (const ups of this.config.upsDevices) { |         for (const ups of this.config.upsDevices) { | ||||||
|           try { |           try { | ||||||
|             const status = await this.snmp.getUpsStatus(ups.snmp); |             const status = await this.snmp.getUpsStatus(ups.snmp); | ||||||
|  |  | ||||||
|             logger.log( |             const batteryColor = getBatteryColor(status.batteryCapacity); | ||||||
|               `UPS ${ups.name}: Battery ${status.batteryCapacity}%, Runtime: ${status.batteryRuntime} minutes`, |             const runtimeColor = getRuntimeColor(status.batteryRuntime); | ||||||
|             ); |  | ||||||
|  |  | ||||||
|             // If any UPS battery runtime gets critically low, force immediate shutdown |             const isCritical = status.batteryRuntime < EMERGENCY_RUNTIME_THRESHOLD; | ||||||
|             if (status.batteryRuntime < EMERGENCY_RUNTIME_THRESHOLD) { |  | ||||||
|               logger.logBoxTitle('EMERGENCY SHUTDOWN', 50); |  | ||||||
|               logger.logBoxLine( |  | ||||||
|                 `UPS ${ups.name} runtime critically low: ${status.batteryRuntime} minutes`, |  | ||||||
|               ); |  | ||||||
|               logger.logBoxLine('Forcing immediate shutdown!'); |  | ||||||
|               logger.logBoxEnd(); |  | ||||||
|              |              | ||||||
|               // Force immediate shutdown |             rows.push({ | ||||||
|               await this.forceImmediateShutdown(); |               name: ups.name, | ||||||
|               return; |               battery: batteryColor(status.batteryCapacity + '%'), | ||||||
|  |               runtime: runtimeColor(status.batteryRuntime + ' min'), | ||||||
|  |               status: isCritical ? theme.error('CRITICAL!') : theme.success('OK'), | ||||||
|  |             }); | ||||||
|  |  | ||||||
|  |             // If any UPS battery runtime gets critically low, flag for immediate shutdown | ||||||
|  |             if (isCritical && !emergencyDetected) { | ||||||
|  |               emergencyDetected = true; | ||||||
|  |               emergencyUps = { ups, status }; | ||||||
|             } |             } | ||||||
|           } catch (upsError) { |           } catch (upsError) { | ||||||
|  |             rows.push({ | ||||||
|  |               name: ups.name, | ||||||
|  |               battery: theme.error('N/A'), | ||||||
|  |               runtime: theme.error('N/A'), | ||||||
|  |               status: theme.error('ERROR'), | ||||||
|  |             }); | ||||||
|  |              | ||||||
|             logger.error( |             logger.error( | ||||||
|               `Error checking UPS ${ups.name} during shutdown: ${ |               `Error checking UPS ${ups.name} during shutdown: ${ | ||||||
|                 upsError instanceof Error ? upsError.message : String(upsError) |                 upsError instanceof Error ? upsError.message : String(upsError) | ||||||
| @@ -787,6 +857,27 @@ export class NupstDaemon { | |||||||
|           } |           } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|  |         // Display the table | ||||||
|  |         logger.logTable(columns, rows); | ||||||
|  |         logger.log(''); | ||||||
|  |  | ||||||
|  |         // If emergency detected, trigger immediate shutdown | ||||||
|  |         if (emergencyDetected && emergencyUps) { | ||||||
|  |           logger.log(''); | ||||||
|  |           logger.logBoxTitle('EMERGENCY SHUTDOWN', 60, 'error'); | ||||||
|  |           logger.logBoxLine( | ||||||
|  |             `UPS ${emergencyUps.ups.name} runtime critically low: ${emergencyUps.status.batteryRuntime} minutes`, | ||||||
|  |           ); | ||||||
|  |           logger.logBoxLine(`Emergency threshold: ${EMERGENCY_RUNTIME_THRESHOLD} minutes`); | ||||||
|  |           logger.logBoxLine('Forcing immediate shutdown!'); | ||||||
|  |           logger.logBoxEnd(); | ||||||
|  |           logger.log(''); | ||||||
|  |  | ||||||
|  |           // Force immediate shutdown | ||||||
|  |           await this.forceImmediateShutdown(); | ||||||
|  |           return; | ||||||
|  |         } | ||||||
|  |  | ||||||
|         // Wait before checking again |         // Wait before checking again | ||||||
|         await this.sleep(CHECK_INTERVAL); |         await this.sleep(CHECK_INTERVAL); | ||||||
|       } catch (error) { |       } catch (error) { | ||||||
| @@ -799,7 +890,9 @@ export class NupstDaemon { | |||||||
|       } |       } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     logger.log('UPS monitoring during shutdown completed'); |     logger.log(''); | ||||||
|  |     logger.success('UPS monitoring during shutdown completed'); | ||||||
|  |     logger.log(''); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   /** |   /** | ||||||
| @@ -890,6 +983,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 | ||||||
|    */ |    */ | ||||||
|   | |||||||
							
								
								
									
										113
									
								
								ts/http-server.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										113
									
								
								ts/http-server.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,113 @@ | |||||||
|  | import * as http from 'node:http'; | ||||||
|  | import { URL } from 'node:url'; | ||||||
|  | import { logger } from './logger.ts'; | ||||||
|  | import type { IUpsStatus } from './daemon.ts'; | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * HTTP Server for exposing UPS status as JSON | ||||||
|  |  * Serves cached data from the daemon's monitoring loop | ||||||
|  |  */ | ||||||
|  | export class NupstHttpServer { | ||||||
|  |   private server?: http.Server; | ||||||
|  |   private port: number; | ||||||
|  |   private path: string; | ||||||
|  |   private authToken: string; | ||||||
|  |   private getUpsStatus: () => Map<string, IUpsStatus>; | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Create a new HTTP server instance | ||||||
|  |    * @param port Port to listen on | ||||||
|  |    * @param path URL path for the endpoint | ||||||
|  |    * @param authToken Authentication token required for access | ||||||
|  |    * @param getUpsStatus Function to retrieve cached UPS status | ||||||
|  |    */ | ||||||
|  |   constructor( | ||||||
|  |     port: number, | ||||||
|  |     path: string, | ||||||
|  |     authToken: string, | ||||||
|  |     getUpsStatus: () => Map<string, IUpsStatus> | ||||||
|  |   ) { | ||||||
|  |     this.port = port; | ||||||
|  |     this.path = path; | ||||||
|  |     this.authToken = authToken; | ||||||
|  |     this.getUpsStatus = getUpsStatus; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Verify authentication token from request | ||||||
|  |    * Supports both Bearer token in Authorization header and token query parameter | ||||||
|  |    * @param req HTTP request | ||||||
|  |    * @returns True if authenticated, false otherwise | ||||||
|  |    */ | ||||||
|  |   private isAuthenticated(req: http.IncomingMessage): boolean { | ||||||
|  |     // Check Authorization header (Bearer token) | ||||||
|  |     const authHeader = req.headers.authorization; | ||||||
|  |     if (authHeader?.startsWith('Bearer ')) { | ||||||
|  |       const token = authHeader.substring(7); | ||||||
|  |       return token === this.authToken; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Check token query parameter | ||||||
|  |     if (req.url) { | ||||||
|  |       const url = new URL(req.url, `http://localhost:${this.port}`); | ||||||
|  |       const tokenParam = url.searchParams.get('token'); | ||||||
|  |       return tokenParam === this.authToken; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     return false; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Start the HTTP server | ||||||
|  |    */ | ||||||
|  |   public start(): void { | ||||||
|  |     this.server = http.createServer((req, res) => { | ||||||
|  |       // Parse URL | ||||||
|  |       const reqUrl = new URL(req.url || '/', `http://localhost:${this.port}`); | ||||||
|  |  | ||||||
|  |       if (reqUrl.pathname === this.path && req.method === 'GET') { | ||||||
|  |         // Check authentication | ||||||
|  |         if (!this.isAuthenticated(req)) { | ||||||
|  |           res.writeHead(401, { | ||||||
|  |             'Content-Type': 'application/json', | ||||||
|  |             'WWW-Authenticate': 'Bearer' | ||||||
|  |           }); | ||||||
|  |           res.end(JSON.stringify({ error: 'Unauthorized' })); | ||||||
|  |           return; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // Get cached status (no refresh) | ||||||
|  |         const statusMap = this.getUpsStatus(); | ||||||
|  |         const statusArray = Array.from(statusMap.values()); | ||||||
|  |  | ||||||
|  |         res.writeHead(200, { | ||||||
|  |           'Content-Type': 'application/json', | ||||||
|  |           'Cache-Control': 'no-cache' | ||||||
|  |         }); | ||||||
|  |         res.end(JSON.stringify(statusArray, null, 2)); | ||||||
|  |       } else { | ||||||
|  |         res.writeHead(404, { 'Content-Type': 'application/json' }); | ||||||
|  |         res.end(JSON.stringify({ error: 'Not Found' })); | ||||||
|  |       } | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     this.server.listen(this.port, () => { | ||||||
|  |       logger.success(`HTTP server started on port ${this.port} at ${this.path}`); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     this.server.on('error', (error: any) => { | ||||||
|  |       logger.error(`HTTP server error: ${error.message}`); | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Stop the HTTP server | ||||||
|  |    */ | ||||||
|  |   public stop(): void { | ||||||
|  |     if (this.server) { | ||||||
|  |       this.server.close(() => { | ||||||
|  |         logger.log('HTTP server stopped'); | ||||||
|  |       }); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										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 | ||||||
|   | |||||||
							
								
								
									
										67
									
								
								ts/migrations/base-migration.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										67
									
								
								ts/migrations/base-migration.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,67 @@ | |||||||
|  | /** | ||||||
|  |  * Abstract base class for configuration migrations | ||||||
|  |  * | ||||||
|  |  * Each migration represents an upgrade from one config version to another. | ||||||
|  |  * Migrations run in order based on the `order` field, allowing users to jump | ||||||
|  |  * multiple versions (e.g., v1 → v4 runs migrations 2, 3, and 4). | ||||||
|  |  */ | ||||||
|  | /** | ||||||
|  |  * Abstract base class for configuration migrations | ||||||
|  |  * | ||||||
|  |  * Each migration represents an upgrade from one config version to another. | ||||||
|  |  * Migrations run in order based on the `toVersion` field, allowing users to jump | ||||||
|  |  * multiple versions (e.g., v1 → v4 runs migrations 2, 3, and 4). | ||||||
|  |  */ | ||||||
|  | export abstract class BaseMigration { | ||||||
|  |   /** | ||||||
|  |    * Source version this migration upgrades from | ||||||
|  |    * e.g., "1.x", "3.x" | ||||||
|  |    */ | ||||||
|  |   abstract readonly fromVersion: string; | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Target version this migration upgrades to | ||||||
|  |    * e.g., "2.0", "4.0", "4.1" | ||||||
|  |    */ | ||||||
|  |   abstract readonly toVersion: string; | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Check if this migration should run on the given config | ||||||
|  |    * | ||||||
|  |    * @param config - Raw configuration object to check (unknown schema for migrations) | ||||||
|  |    * @returns True if migration should run, false otherwise | ||||||
|  |    */ | ||||||
|  |   abstract shouldRun(config: Record<string, unknown>): Promise<boolean>; | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Perform the migration on the given config | ||||||
|  |    * | ||||||
|  |    * @param config - Raw configuration object to migrate (unknown schema for migrations) | ||||||
|  |    * @returns Migrated configuration object | ||||||
|  |    */ | ||||||
|  |   abstract migrate(config: Record<string, unknown>): Promise<Record<string, unknown>>; | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Get human-readable name for this migration | ||||||
|  |    * | ||||||
|  |    * @returns Migration name | ||||||
|  |    */ | ||||||
|  |   getName(): string { | ||||||
|  |     return `Migration ${this.fromVersion} → ${this.toVersion}`; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Parse version string into a comparable number | ||||||
|  |    * Supports formats like "2.0", "4.1", etc. | ||||||
|  |    * Returns a number like 2.0, 4.1 for sorting | ||||||
|  |    * | ||||||
|  |    * @returns Parsed version number for ordering | ||||||
|  |    */ | ||||||
|  |   getVersionOrder(): number { | ||||||
|  |     const parsed = parseFloat(this.toVersion); | ||||||
|  |     if (isNaN(parsed)) { | ||||||
|  |       throw new Error(`Invalid version format: ${this.toVersion}`); | ||||||
|  |     } | ||||||
|  |     return parsed; | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										11
									
								
								ts/migrations/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								ts/migrations/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | |||||||
|  | /** | ||||||
|  |  * Configuration migrations module | ||||||
|  |  * | ||||||
|  |  * Exports the migration system for upgrading configs between versions. | ||||||
|  |  */ | ||||||
|  |  | ||||||
|  | export { BaseMigration } from './base-migration.ts'; | ||||||
|  | export { MigrationRunner } from './migration-runner.ts'; | ||||||
|  | export { MigrationV1ToV2 } from './migration-v1-to-v2.ts'; | ||||||
|  | export { MigrationV3ToV4 } from './migration-v3-to-v4.ts'; | ||||||
|  | export { MigrationV4_0ToV4_1 } from './migration-v4.0-to-v4.1.ts'; | ||||||
							
								
								
									
										75
									
								
								ts/migrations/migration-runner.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										75
									
								
								ts/migrations/migration-runner.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,75 @@ | |||||||
|  | import { BaseMigration } from './base-migration.ts'; | ||||||
|  | import { MigrationV1ToV2 } from './migration-v1-to-v2.ts'; | ||||||
|  | import { MigrationV3ToV4 } from './migration-v3-to-v4.ts'; | ||||||
|  | import { MigrationV4_0ToV4_1 } from './migration-v4.0-to-v4.1.ts'; | ||||||
|  | import { logger } from '../logger.ts'; | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Migration runner | ||||||
|  |  * | ||||||
|  |  * Discovers all available migrations, sorts them by order, | ||||||
|  |  * and runs applicable migrations in sequence. | ||||||
|  |  */ | ||||||
|  | export class MigrationRunner { | ||||||
|  |   private migrations: BaseMigration[]; | ||||||
|  |  | ||||||
|  |   constructor() { | ||||||
|  |     // Register all migrations here | ||||||
|  |     this.migrations = [ | ||||||
|  |       new MigrationV1ToV2(), | ||||||
|  |       new MigrationV3ToV4(), | ||||||
|  |       new MigrationV4_0ToV4_1(), | ||||||
|  |       // Add future migrations here (v4.3, v4.4, etc.) | ||||||
|  |     ]; | ||||||
|  |  | ||||||
|  |     // Sort by version order to ensure they run in sequence | ||||||
|  |     this.migrations.sort((a, b) => a.getVersionOrder() - b.getVersionOrder()); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Run all applicable migrations on the config | ||||||
|  |    * | ||||||
|  |    * @param config - Raw configuration object to migrate | ||||||
|  |    * @returns Migrated configuration and whether migrations ran | ||||||
|  |    */ | ||||||
|  |   async run( | ||||||
|  |     config: Record<string, unknown>, | ||||||
|  |   ): Promise<{ config: Record<string, unknown>; migrated: boolean }> { | ||||||
|  |     let currentConfig = config; | ||||||
|  |     let anyMigrationsRan = false; | ||||||
|  |  | ||||||
|  |     for (const migration of this.migrations) { | ||||||
|  |       const shouldRun = await migration.shouldRun(currentConfig); | ||||||
|  |  | ||||||
|  |       if (shouldRun) { | ||||||
|  |         // Only show "checking" message when we actually need to migrate | ||||||
|  |         if (!anyMigrationsRan) { | ||||||
|  |           logger.dim('Checking for required config migrations...'); | ||||||
|  |         } | ||||||
|  |         logger.info(`Running ${migration.getName()}...`); | ||||||
|  |         currentConfig = await migration.migrate(currentConfig); | ||||||
|  |         anyMigrationsRan = true; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (anyMigrationsRan) { | ||||||
|  |       logger.success('Configuration migrations complete'); | ||||||
|  |     } else { | ||||||
|  |       logger.success('config format ok'); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     return { | ||||||
|  |       config: currentConfig, | ||||||
|  |       migrated: anyMigrationsRan, | ||||||
|  |     }; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Get all registered migrations | ||||||
|  |    * | ||||||
|  |    * @returns Array of all migrations sorted by order | ||||||
|  |    */ | ||||||
|  |   getMigrations(): BaseMigration[] { | ||||||
|  |     return [...this.migrations]; | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										55
									
								
								ts/migrations/migration-v1-to-v2.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								ts/migrations/migration-v1-to-v2.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,55 @@ | |||||||
|  | import { BaseMigration } from './base-migration.ts'; | ||||||
|  | import { logger } from '../logger.ts'; | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Migration from v1 (single SNMP config) to v2 (upsDevices array) | ||||||
|  |  * | ||||||
|  |  * Detects old format: | ||||||
|  |  * { | ||||||
|  |  *   snmp: { ... }, | ||||||
|  |  *   thresholds: { ... }, | ||||||
|  |  *   checkInterval: 30000 | ||||||
|  |  * } | ||||||
|  |  * | ||||||
|  |  * Converts to: | ||||||
|  |  * { | ||||||
|  |  *   version: "2.0", | ||||||
|  |  *   upsDevices: [{ id: "default", name: "Default UPS", snmp: ..., thresholds: ... }], | ||||||
|  |  *   groups: [], | ||||||
|  |  *   checkInterval: 30000 | ||||||
|  |  * } | ||||||
|  |  */ | ||||||
|  | export class MigrationV1ToV2 extends BaseMigration { | ||||||
|  |   readonly fromVersion = '1.x'; | ||||||
|  |   readonly toVersion = '2.0'; | ||||||
|  |  | ||||||
|  |   async shouldRun(config: any): Promise<boolean> { | ||||||
|  |     // V1 format has snmp field directly at root, no upsDevices or upsList | ||||||
|  |     return !!config.snmp && !config.upsDevices && !config.upsList; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   async migrate(config: any): Promise<any> { | ||||||
|  |     logger.info(`${this.getName()}: Converting single SNMP config to multi-UPS format...`); | ||||||
|  |  | ||||||
|  |     const migrated = { | ||||||
|  |       version: this.toVersion, | ||||||
|  |       upsDevices: [ | ||||||
|  |         { | ||||||
|  |           id: 'default', | ||||||
|  |           name: 'Default UPS', | ||||||
|  |           snmp: config.snmp, | ||||||
|  |           thresholds: config.thresholds || { | ||||||
|  |             battery: 60, | ||||||
|  |             runtime: 20, | ||||||
|  |           }, | ||||||
|  |           groups: [], | ||||||
|  |         }, | ||||||
|  |       ], | ||||||
|  |       groups: [], | ||||||
|  |       checkInterval: config.checkInterval || 30000, | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     logger.success(`${this.getName()}: Migration complete`); | ||||||
|  |     return migrated; | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										118
									
								
								ts/migrations/migration-v3-to-v4.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										118
									
								
								ts/migrations/migration-v3-to-v4.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,118 @@ | |||||||
|  | import { BaseMigration } from './base-migration.ts'; | ||||||
|  | import { logger } from '../logger.ts'; | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Migration from v3 (upsList) to v4 (upsDevices) | ||||||
|  |  * | ||||||
|  |  * Transforms v3 format with flat SNMP config: | ||||||
|  |  * { | ||||||
|  |  *   upsList: [ | ||||||
|  |  *     { | ||||||
|  |  *       id: "ups-1", | ||||||
|  |  *       name: "UPS 1", | ||||||
|  |  *       host: "192.168.1.1", | ||||||
|  |  *       port: 161, | ||||||
|  |  *       community: "public", | ||||||
|  |  *       version: "1"  // string | ||||||
|  |  *     } | ||||||
|  |  *   ] | ||||||
|  |  * } | ||||||
|  |  * | ||||||
|  |  * To v4 format with nested SNMP config: | ||||||
|  |  * { | ||||||
|  |  *   version: "4.0", | ||||||
|  |  *   upsDevices: [ | ||||||
|  |  *     { | ||||||
|  |  *       id: "ups-1", | ||||||
|  |  *       name: "UPS 1", | ||||||
|  |  *       snmp: { | ||||||
|  |  *         host: "192.168.1.1", | ||||||
|  |  *         port: 161, | ||||||
|  |  *         community: "public", | ||||||
|  |  *         version: 1,  // number | ||||||
|  |  *         timeout: 5000 | ||||||
|  |  *       }, | ||||||
|  |  *       thresholds: { battery: 60, runtime: 20 }, | ||||||
|  |  *       groups: [] | ||||||
|  |  *     } | ||||||
|  |  *   ] | ||||||
|  |  * } | ||||||
|  |  */ | ||||||
|  | export class MigrationV3ToV4 extends BaseMigration { | ||||||
|  |   readonly fromVersion = '3.x'; | ||||||
|  |   readonly toVersion = '4.0'; | ||||||
|  |  | ||||||
|  |   async shouldRun(config: any): Promise<boolean> { | ||||||
|  |     // V3 format has upsList OR has upsDevices with flat structure (host at top level) | ||||||
|  |     if (config.upsList && !config.upsDevices) { | ||||||
|  |       return true; // Classic v3 with upsList | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Check if upsDevices exists but has flat structure (v3 format) | ||||||
|  |     if (config.upsDevices && config.upsDevices.length > 0) { | ||||||
|  |       const firstDevice = config.upsDevices[0]; | ||||||
|  |       // V3 has host at top level, v4 has it nested in snmp object | ||||||
|  |       return !!firstDevice.host && !firstDevice.snmp; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     return false; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   async migrate(config: any): Promise<any> { | ||||||
|  |     logger.info(`${this.getName()}: Migrating v3 config to v4 format...`); | ||||||
|  |     logger.dim(`  - Restructuring UPS devices (flat → nested snmp config)`); | ||||||
|  |  | ||||||
|  |     // Get devices from either upsList or upsDevices (for partially migrated configs) | ||||||
|  |     const sourceDevices = config.upsList || config.upsDevices; | ||||||
|  |  | ||||||
|  |     // Transform each UPS device from v3 flat structure to v4 nested structure | ||||||
|  |     const transformedDevices = sourceDevices.map((device: any) => { | ||||||
|  |       // Build SNMP config object | ||||||
|  |       const snmpConfig: any = { | ||||||
|  |         host: device.host, | ||||||
|  |         port: device.port || 161, | ||||||
|  |         version: typeof device.version === 'string' ? parseInt(device.version, 10) : device.version, | ||||||
|  |         timeout: device.timeout || 5000, | ||||||
|  |       }; | ||||||
|  |  | ||||||
|  |       // Add SNMPv1/v2c fields | ||||||
|  |       if (device.community) { | ||||||
|  |         snmpConfig.community = device.community; | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       // Add SNMPv3 fields | ||||||
|  |       if (device.securityLevel) snmpConfig.securityLevel = device.securityLevel; | ||||||
|  |       if (device.username) snmpConfig.username = device.username; | ||||||
|  |       if (device.authProtocol) snmpConfig.authProtocol = device.authProtocol; | ||||||
|  |       if (device.authKey) snmpConfig.authKey = device.authKey; | ||||||
|  |       if (device.privProtocol) snmpConfig.privProtocol = device.privProtocol; | ||||||
|  |       if (device.privKey) snmpConfig.privKey = device.privKey; | ||||||
|  |  | ||||||
|  |       // Add UPS model if present | ||||||
|  |       if (device.upsModel) snmpConfig.upsModel = device.upsModel; | ||||||
|  |       if (device.customOIDs) snmpConfig.customOIDs = device.customOIDs; | ||||||
|  |  | ||||||
|  |       // Return v4 format with nested structure | ||||||
|  |       return { | ||||||
|  |         id: device.id, | ||||||
|  |         name: device.name, | ||||||
|  |         snmp: snmpConfig, | ||||||
|  |         thresholds: device.thresholds || { | ||||||
|  |           battery: 60, | ||||||
|  |           runtime: 20, | ||||||
|  |         }, | ||||||
|  |         groups: device.groups || [], | ||||||
|  |       }; | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     const migrated = { | ||||||
|  |       version: this.toVersion, | ||||||
|  |       upsDevices: transformedDevices, | ||||||
|  |       groups: config.groups || [], | ||||||
|  |       checkInterval: config.checkInterval || 30000, | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     logger.success(`${this.getName()}: Migration complete (${transformedDevices.length} devices transformed)`); | ||||||
|  |     return migrated; | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										127
									
								
								ts/migrations/migration-v4.0-to-v4.1.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										127
									
								
								ts/migrations/migration-v4.0-to-v4.1.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,127 @@ | |||||||
|  | import { BaseMigration } from './base-migration.ts'; | ||||||
|  | import { logger } from '../logger.ts'; | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Migration from v4.0 to v4.1 | ||||||
|  |  * | ||||||
|  |  * Major changes: | ||||||
|  |  * 1. Moves thresholds from UPS level to action level | ||||||
|  |  * 2. Creates default shutdown action for UPS devices that had thresholds | ||||||
|  |  * 3. Adds empty actions array to UPS devices without actions | ||||||
|  |  * 4. Adds empty actions array to groups | ||||||
|  |  * | ||||||
|  |  * Transforms v4.0 format (with UPS-level thresholds): | ||||||
|  |  * { | ||||||
|  |  *   version: "4.0", | ||||||
|  |  *   upsDevices: [ | ||||||
|  |  *     { | ||||||
|  |  *       id: "ups-1", | ||||||
|  |  *       name: "UPS 1", | ||||||
|  |  *       snmp: {...}, | ||||||
|  |  *       thresholds: { battery: 60, runtime: 20 },  // UPS-level | ||||||
|  |  *       groups: [] | ||||||
|  |  *     } | ||||||
|  |  *   ] | ||||||
|  |  * } | ||||||
|  |  * | ||||||
|  |  * To v4.1 format (with action-level thresholds): | ||||||
|  |  * { | ||||||
|  |  *   version: "4.1", | ||||||
|  |  *   upsDevices: [ | ||||||
|  |  *     { | ||||||
|  |  *       id: "ups-1", | ||||||
|  |  *       name: "UPS 1", | ||||||
|  |  *       snmp: {...}, | ||||||
|  |  *       groups: [], | ||||||
|  |  *       actions: [  // Thresholds moved here | ||||||
|  |  *         { | ||||||
|  |  *           type: "shutdown", | ||||||
|  |  *           thresholds: { battery: 60, runtime: 20 }, | ||||||
|  |  *           triggerMode: "onlyThresholds", | ||||||
|  |  *           shutdownDelay: 5 | ||||||
|  |  *         } | ||||||
|  |  *       ] | ||||||
|  |  *     } | ||||||
|  |  *   ] | ||||||
|  |  * } | ||||||
|  |  */ | ||||||
|  | export class MigrationV4_0ToV4_1 extends BaseMigration { | ||||||
|  |   readonly fromVersion = '4.0'; | ||||||
|  |   readonly toVersion = '4.1'; | ||||||
|  |  | ||||||
|  |   async shouldRun(config: Record<string, unknown>): Promise<boolean> { | ||||||
|  |     // Run if config is version 4.0 | ||||||
|  |     if (config.version === '4.0') { | ||||||
|  |       return true; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Also run if config has upsDevices with thresholds at UPS level (v4.0 format) | ||||||
|  |     if (Array.isArray(config.upsDevices) && config.upsDevices.length > 0) { | ||||||
|  |       const firstDevice = config.upsDevices[0] as Record<string, unknown>; | ||||||
|  |       // v4.0 has thresholds at UPS level, v4.1 has them in actions | ||||||
|  |       return firstDevice.thresholds !== undefined; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     return false; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   async migrate(config: Record<string, unknown>): Promise<Record<string, unknown>> { | ||||||
|  |     logger.info(`${this.getName()}: Migrating v4.0 config to v4.1 format...`); | ||||||
|  |     logger.dim(`  - Moving thresholds from UPS level to action level`); | ||||||
|  |     logger.dim(`  - Creating default shutdown actions from existing thresholds`); | ||||||
|  |  | ||||||
|  |     // Migrate UPS devices | ||||||
|  |     const devices = (config.upsDevices as Array<Record<string, unknown>>) || []; | ||||||
|  |     const migratedDevices = devices.map((device) => { | ||||||
|  |       const migrated: Record<string, unknown> = { | ||||||
|  |         id: device.id, | ||||||
|  |         name: device.name, | ||||||
|  |         snmp: device.snmp, | ||||||
|  |         groups: device.groups || [], | ||||||
|  |       }; | ||||||
|  |  | ||||||
|  |       // If device has thresholds at UPS level, convert to shutdown action | ||||||
|  |       const deviceThresholds = device.thresholds as { battery: number; runtime: number } | undefined; | ||||||
|  |       if (deviceThresholds) { | ||||||
|  |         migrated.actions = [ | ||||||
|  |           { | ||||||
|  |             type: 'shutdown', | ||||||
|  |             thresholds: { | ||||||
|  |               battery: deviceThresholds.battery, | ||||||
|  |               runtime: deviceThresholds.runtime, | ||||||
|  |             }, | ||||||
|  |             triggerMode: 'onlyThresholds', // Preserve old behavior (only on threshold violation) | ||||||
|  |             shutdownDelay: 5, // Default delay | ||||||
|  |           }, | ||||||
|  |         ]; | ||||||
|  |         logger.dim( | ||||||
|  |           `    → ${device.name}: Created shutdown action (battery: ${deviceThresholds.battery}%, runtime: ${deviceThresholds.runtime}min)`, | ||||||
|  |         ); | ||||||
|  |       } else { | ||||||
|  |         // No thresholds, just add empty actions array | ||||||
|  |         migrated.actions = device.actions || []; | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       return migrated; | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     // Add actions to groups | ||||||
|  |     const groups = (config.groups as Array<Record<string, unknown>>) || []; | ||||||
|  |     const migratedGroups = groups.map((group) => ({ | ||||||
|  |       ...group, | ||||||
|  |       actions: group.actions || [], | ||||||
|  |     })); | ||||||
|  |  | ||||||
|  |     const result = { | ||||||
|  |       version: this.toVersion, | ||||||
|  |       upsDevices: migratedDevices, | ||||||
|  |       groups: migratedGroups, | ||||||
|  |       checkInterval: config.checkInterval || 30000, | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     logger.success( | ||||||
|  |       `${this.getName()}: Migration complete (${migratedDevices.length} devices, ${migratedGroups.length} groups updated)`, | ||||||
|  |     ); | ||||||
|  |     return result; | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										38
									
								
								ts/nupst.ts
									
									
									
									
									
								
							
							
						
						
									
										38
									
								
								ts/nupst.ts
									
									
									
									
									
								
							| @@ -1,11 +1,13 @@ | |||||||
| import { NupstSnmp } from './snmp/manager.ts'; | import { NupstSnmp } from './snmp/manager.ts'; | ||||||
| import { NupstDaemon } from './daemon.ts'; | import { NupstDaemon } from './daemon.ts'; | ||||||
| import { NupstSystemd } from './systemd.ts'; | import { NupstSystemd } from './systemd.ts'; | ||||||
| import { commitinfo } from './00_commitinfo_data.ts'; | import denoConfig from '../deno.json' with { type: 'json' }; | ||||||
| import { logger } from './logger.ts'; | import { logger } from './logger.ts'; | ||||||
| import { UpsHandler } from './cli/ups-handler.ts'; | import { UpsHandler } from './cli/ups-handler.ts'; | ||||||
| import { GroupHandler } from './cli/group-handler.ts'; | import { GroupHandler } from './cli/group-handler.ts'; | ||||||
| import { ServiceHandler } from './cli/service-handler.ts'; | import { ServiceHandler } from './cli/service-handler.ts'; | ||||||
|  | import { ActionHandler } from './cli/action-handler.ts'; | ||||||
|  | import { FeatureHandler } from './cli/feature-handler.ts'; | ||||||
| import * as https from 'node:https'; | import * as https from 'node:https'; | ||||||
|  |  | ||||||
| /** | /** | ||||||
| @@ -19,6 +21,8 @@ export class Nupst { | |||||||
|   private readonly upsHandler: UpsHandler; |   private readonly upsHandler: UpsHandler; | ||||||
|   private readonly groupHandler: GroupHandler; |   private readonly groupHandler: GroupHandler; | ||||||
|   private readonly serviceHandler: ServiceHandler; |   private readonly serviceHandler: ServiceHandler; | ||||||
|  |   private readonly actionHandler: ActionHandler; | ||||||
|  |   private readonly featureHandler: FeatureHandler; | ||||||
|   private updateAvailable: boolean = false; |   private updateAvailable: boolean = false; | ||||||
|   private latestVersion: string = ''; |   private latestVersion: string = ''; | ||||||
|  |  | ||||||
| @@ -36,6 +40,8 @@ export class Nupst { | |||||||
|     this.upsHandler = new UpsHandler(this); |     this.upsHandler = new UpsHandler(this); | ||||||
|     this.groupHandler = new GroupHandler(this); |     this.groupHandler = new GroupHandler(this); | ||||||
|     this.serviceHandler = new ServiceHandler(this); |     this.serviceHandler = new ServiceHandler(this); | ||||||
|  |     this.actionHandler = new ActionHandler(this); | ||||||
|  |     this.featureHandler = new FeatureHandler(this); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   /** |   /** | ||||||
| @@ -80,12 +86,26 @@ export class Nupst { | |||||||
|     return this.serviceHandler; |     return this.serviceHandler; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Get the Action handler for action management | ||||||
|  |    */ | ||||||
|  |   public getActionHandler(): ActionHandler { | ||||||
|  |     return this.actionHandler; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Get the Feature handler for feature management | ||||||
|  |    */ | ||||||
|  |   public getFeatureHandler(): FeatureHandler { | ||||||
|  |     return this.featureHandler; | ||||||
|  |   } | ||||||
|  |  | ||||||
|   /** |   /** | ||||||
|    * Get the current version of NUPST |    * Get the current version of NUPST | ||||||
|    * @returns The current version string |    * @returns The current version string | ||||||
|    */ |    */ | ||||||
|   public getVersion(): string { |   public getVersion(): string { | ||||||
|     return commitinfo.version; |     return denoConfig.version; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   /** |   /** | ||||||
| @@ -133,8 +153,8 @@ export class Nupst { | |||||||
|   private getLatestVersion(): Promise<string> { |   private getLatestVersion(): Promise<string> { | ||||||
|     return new Promise<string>((resolve, reject) => { |     return new Promise<string>((resolve, reject) => { | ||||||
|       const options = { |       const options = { | ||||||
|         hostname: 'registry.npmjs.org', |         hostname: 'code.foss.global', | ||||||
|         path: '/@serve.zone/nupst', |         path: '/api/v1/repos/serve.zone/nupst/releases/latest', | ||||||
|         method: 'GET', |         method: 'GET', | ||||||
|         headers: { |         headers: { | ||||||
|           'Accept': 'application/json', |           'Accept': 'application/json', | ||||||
| @@ -152,10 +172,14 @@ export class Nupst { | |||||||
|         res.on('end', () => { |         res.on('end', () => { | ||||||
|           try { |           try { | ||||||
|             const response = JSON.parse(data); |             const response = JSON.parse(data); | ||||||
|             if (response['dist-tags'] && response['dist-tags'].latest) { |             if (response.tag_name) { | ||||||
|               resolve(response['dist-tags'].latest); |               // Strip 'v' prefix from tag name (e.g., "v5.1.7" -> "5.1.7") | ||||||
|  |               const version = response.tag_name.startsWith('v')  | ||||||
|  |                 ? response.tag_name.substring(1)  | ||||||
|  |                 : response.tag_name; | ||||||
|  |               resolve(version); | ||||||
|             } else { |             } else { | ||||||
|               reject(new Error('Failed to parse version from npm registry response')); |               reject(new Error('Failed to parse version from Gitea API response')); | ||||||
|             } |             } | ||||||
|           } catch (error) { |           } catch (error) { | ||||||
|             reject(error); |             reject(error); | ||||||
|   | |||||||
| @@ -1,4 +1,4 @@ | |||||||
| import * as snmp from 'npm:net-snmp@3.20.0'; | import * as snmp from 'npm:net-snmp@3.26.0'; | ||||||
| import { Buffer } from 'node:buffer'; | import { Buffer } from 'node:buffer'; | ||||||
| import type { IOidSet, ISnmpConfig, IUpsStatus, TUpsModel } from './types.ts'; | import type { IOidSet, ISnmpConfig, IUpsStatus, TUpsModel } from './types.ts'; | ||||||
| import { UpsOidSets } from './oid-sets.ts'; | import { UpsOidSets } from './oid-sets.ts'; | ||||||
| @@ -304,6 +304,10 @@ export class NupstSnmp { | |||||||
|         console.log('  Power Status:', this.activeOIDs.POWER_STATUS); |         console.log('  Power Status:', this.activeOIDs.POWER_STATUS); | ||||||
|         console.log('  Battery Capacity:', this.activeOIDs.BATTERY_CAPACITY); |         console.log('  Battery Capacity:', this.activeOIDs.BATTERY_CAPACITY); | ||||||
|         console.log('  Battery Runtime:', this.activeOIDs.BATTERY_RUNTIME); |         console.log('  Battery Runtime:', this.activeOIDs.BATTERY_RUNTIME); | ||||||
|  |         console.log('  Output Load:', this.activeOIDs.OUTPUT_LOAD); | ||||||
|  |         console.log('  Output Power:', this.activeOIDs.OUTPUT_POWER); | ||||||
|  |         console.log('  Output Voltage:', this.activeOIDs.OUTPUT_VOLTAGE); | ||||||
|  |         console.log('  Output Current:', this.activeOIDs.OUTPUT_CURRENT); | ||||||
|         console.log('---------------------------------------'); |         console.log('---------------------------------------'); | ||||||
|       } |       } | ||||||
|  |  | ||||||
| @@ -324,20 +328,65 @@ export class NupstSnmp { | |||||||
|         config, |         config, | ||||||
|       ) || 0; |       ) || 0; | ||||||
|  |  | ||||||
|  |       // Get power draw metrics | ||||||
|  |       const outputLoad = await this.getSNMPValueWithRetry( | ||||||
|  |         this.activeOIDs.OUTPUT_LOAD, | ||||||
|  |         'output load', | ||||||
|  |         config, | ||||||
|  |       ) || 0; | ||||||
|  |       const outputPower = await this.getSNMPValueWithRetry( | ||||||
|  |         this.activeOIDs.OUTPUT_POWER, | ||||||
|  |         'output power', | ||||||
|  |         config, | ||||||
|  |       ) || 0; | ||||||
|  |       const outputVoltage = await this.getSNMPValueWithRetry( | ||||||
|  |         this.activeOIDs.OUTPUT_VOLTAGE, | ||||||
|  |         'output voltage', | ||||||
|  |         config, | ||||||
|  |       ) || 0; | ||||||
|  |       const outputCurrent = await this.getSNMPValueWithRetry( | ||||||
|  |         this.activeOIDs.OUTPUT_CURRENT, | ||||||
|  |         'output current', | ||||||
|  |         config, | ||||||
|  |       ) || 0; | ||||||
|  |  | ||||||
|       // Determine power status - handle different values for different UPS models |       // Determine power status - handle different values for different UPS models | ||||||
|       const powerStatus = this.determinePowerStatus(config.upsModel, powerStatusValue); |       const powerStatus = this.determinePowerStatus(config.upsModel, powerStatusValue); | ||||||
|  |  | ||||||
|       // Convert to minutes for UPS models with different time units |       // Convert to minutes for UPS models with different time units | ||||||
|       const processedRuntime = this.processRuntimeValue(config.upsModel, batteryRuntime); |       const processedRuntime = this.processRuntimeValue(config.upsModel, batteryRuntime); | ||||||
|  |  | ||||||
|  |       // Process power metrics with vendor-specific scaling | ||||||
|  |       const processedVoltage = this.processVoltageValue(config.upsModel, outputVoltage); | ||||||
|  |       const processedCurrent = this.processCurrentValue(config.upsModel, outputCurrent); | ||||||
|  |  | ||||||
|  |       // Calculate power from voltage × current if not provided by UPS | ||||||
|  |       let processedPower = outputPower; | ||||||
|  |       if (outputPower === 0 && processedVoltage > 0 && processedCurrent > 0) { | ||||||
|  |         processedPower = Math.round(processedVoltage * processedCurrent); | ||||||
|  |         if (this.debug) { | ||||||
|  |           console.log( | ||||||
|  |             `Calculated power from V×I: ${processedVoltage}V × ${processedCurrent}A = ${processedPower}W`, | ||||||
|  |           ); | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |  | ||||||
|       const result = { |       const result = { | ||||||
|         powerStatus, |         powerStatus, | ||||||
|         batteryCapacity, |         batteryCapacity, | ||||||
|         batteryRuntime: processedRuntime, |         batteryRuntime: processedRuntime, | ||||||
|  |         outputLoad, | ||||||
|  |         outputPower: processedPower, | ||||||
|  |         outputVoltage: processedVoltage, | ||||||
|  |         outputCurrent: processedCurrent, | ||||||
|         raw: { |         raw: { | ||||||
|           powerStatus: powerStatusValue, |           powerStatus: powerStatusValue, | ||||||
|           batteryCapacity, |           batteryCapacity, | ||||||
|           batteryRuntime, |           batteryRuntime, | ||||||
|  |           outputLoad, | ||||||
|  |           outputPower, | ||||||
|  |           outputVoltage, | ||||||
|  |           outputCurrent, | ||||||
|         }, |         }, | ||||||
|       }; |       }; | ||||||
|  |  | ||||||
| @@ -347,6 +396,10 @@ export class NupstSnmp { | |||||||
|         console.log('  Power Status:', result.powerStatus); |         console.log('  Power Status:', result.powerStatus); | ||||||
|         console.log('  Battery Capacity:', result.batteryCapacity + '%'); |         console.log('  Battery Capacity:', result.batteryCapacity + '%'); | ||||||
|         console.log('  Battery Runtime:', result.batteryRuntime, 'minutes'); |         console.log('  Battery Runtime:', result.batteryRuntime, 'minutes'); | ||||||
|  |         console.log('  Output Load:', result.outputLoad + '%'); | ||||||
|  |         console.log('  Output Power:', result.outputPower, 'watts'); | ||||||
|  |         console.log('  Output Voltage:', result.outputVoltage, 'volts'); | ||||||
|  |         console.log('  Output Current:', result.outputCurrent, 'amps'); | ||||||
|         console.log('---------------------------------------'); |         console.log('---------------------------------------'); | ||||||
|       } |       } | ||||||
|  |  | ||||||
| @@ -525,6 +578,7 @@ export class NupstSnmp { | |||||||
|  |  | ||||||
|   /** |   /** | ||||||
|    * Determine power status based on UPS model and raw value |    * Determine power status based on UPS model and raw value | ||||||
|  |    * Uses the value mappings defined in the OID sets | ||||||
|    * @param upsModel UPS model |    * @param upsModel UPS model | ||||||
|    * @param powerStatusValue Raw power status value |    * @param powerStatusValue Raw power status value | ||||||
|    * @returns Standardized power status |    * @returns Standardized power status | ||||||
| @@ -533,38 +587,27 @@ export class NupstSnmp { | |||||||
|     upsModel: TUpsModel | undefined, |     upsModel: TUpsModel | undefined, | ||||||
|     powerStatusValue: number, |     powerStatusValue: number, | ||||||
|   ): 'online' | 'onBattery' | 'unknown' { |   ): 'online' | 'onBattery' | 'unknown' { | ||||||
|     if (upsModel === 'cyberpower') { |     // Get the OID set for this UPS model | ||||||
|       // CyberPower RMCARD205: upsBaseOutputStatus values |     if (upsModel && upsModel !== 'custom') { | ||||||
|       // 2=onLine, 3=onBattery, 4=onBoost, 5=onSleep, 6=off, etc. |       const oidSet = UpsOidSets.getOidSet(upsModel); | ||||||
|       if (powerStatusValue === 2) { |  | ||||||
|  |       // Use the value mappings if available | ||||||
|  |       if (oidSet.POWER_STATUS_VALUES) { | ||||||
|  |         if (powerStatusValue === oidSet.POWER_STATUS_VALUES.online) { | ||||||
|           return 'online'; |           return 'online'; | ||||||
|       } else if (powerStatusValue === 3) { |         } else if (powerStatusValue === oidSet.POWER_STATUS_VALUES.onBattery) { | ||||||
|           return 'onBattery'; |           return 'onBattery'; | ||||||
|         } |         } | ||||||
|     } else if (upsModel === 'eaton') { |       } | ||||||
|       // Eaton UPS: xupsOutputSource values |     } | ||||||
|       // 3=normal/mains, 5=battery, etc. |  | ||||||
|  |     // Fallback for custom or undefined models (RFC 1628 standard) | ||||||
|  |     // upsOutputSource: 3=normal (mains), 5=battery | ||||||
|     if (powerStatusValue === 3) { |     if (powerStatusValue === 3) { | ||||||
|       return 'online'; |       return 'online'; | ||||||
|     } else if (powerStatusValue === 5) { |     } else if (powerStatusValue === 5) { | ||||||
|       return 'onBattery'; |       return 'onBattery'; | ||||||
|     } |     } | ||||||
|     } else if (upsModel === 'apc') { |  | ||||||
|       // APC UPS: upsBasicOutputStatus values |  | ||||||
|       // 2=online, 3=onBattery, etc. |  | ||||||
|       if (powerStatusValue === 2) { |  | ||||||
|         return 'online'; |  | ||||||
|       } else if (powerStatusValue === 3) { |  | ||||||
|         return 'onBattery'; |  | ||||||
|       } |  | ||||||
|     } else { |  | ||||||
|       // Default interpretation for other UPS models |  | ||||||
|       if (powerStatusValue === 1) { |  | ||||||
|         return 'online'; |  | ||||||
|       } else if (powerStatusValue === 2) { |  | ||||||
|         return 'onBattery'; |  | ||||||
|       } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     return 'unknown'; |     return 'unknown'; | ||||||
|   } |   } | ||||||
| @@ -612,4 +655,74 @@ export class NupstSnmp { | |||||||
|  |  | ||||||
|     return batteryRuntime; |     return batteryRuntime; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Process voltage value based on UPS model | ||||||
|  |    * @param upsModel UPS model | ||||||
|  |    * @param outputVoltage Raw output voltage value | ||||||
|  |    * @returns Processed voltage in volts | ||||||
|  |    */ | ||||||
|  |   private processVoltageValue( | ||||||
|  |     upsModel: TUpsModel | undefined, | ||||||
|  |     outputVoltage: number, | ||||||
|  |   ): number { | ||||||
|  |     if (this.debug) { | ||||||
|  |       console.log('Raw voltage value:', outputVoltage); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (upsModel === 'cyberpower' && outputVoltage > 0) { | ||||||
|  |       // CyberPower: Voltage is in 0.1V, convert to volts | ||||||
|  |       const volts = outputVoltage / 10; | ||||||
|  |       if (this.debug) { | ||||||
|  |         console.log( | ||||||
|  |           `Converting CyberPower voltage from ${outputVoltage} (0.1V) to ${volts} volts`, | ||||||
|  |         ); | ||||||
|  |       } | ||||||
|  |       return volts; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     return outputVoltage; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Process current value based on UPS model | ||||||
|  |    * @param upsModel UPS model | ||||||
|  |    * @param outputCurrent Raw output current value | ||||||
|  |    * @returns Processed current in amps | ||||||
|  |    */ | ||||||
|  |   private processCurrentValue( | ||||||
|  |     upsModel: TUpsModel | undefined, | ||||||
|  |     outputCurrent: number, | ||||||
|  |   ): number { | ||||||
|  |     if (this.debug) { | ||||||
|  |       console.log('Raw current value:', outputCurrent); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (upsModel === 'cyberpower' && outputCurrent > 0) { | ||||||
|  |       // CyberPower: Current is in 0.1A, convert to amps | ||||||
|  |       const amps = outputCurrent / 10; | ||||||
|  |       if (this.debug) { | ||||||
|  |         console.log( | ||||||
|  |           `Converting CyberPower current from ${outputCurrent} (0.1A) to ${amps} amps`, | ||||||
|  |         ); | ||||||
|  |       } | ||||||
|  |       return amps; | ||||||
|  |     } else if ((upsModel === 'tripplite' || upsModel === 'liebert') && outputCurrent > 0) { | ||||||
|  |       // RFC 1628 standard: Current is in 0.1A, convert to amps | ||||||
|  |       const amps = outputCurrent / 10; | ||||||
|  |       if (this.debug) { | ||||||
|  |         console.log( | ||||||
|  |           `Converting RFC 1628 current from ${outputCurrent} (0.1A) to ${amps} amps`, | ||||||
|  |         ); | ||||||
|  |       } | ||||||
|  |       return amps; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Eaton XUPS-MIB and APC PowerNet report current directly in RMS Amps (no scaling needed) | ||||||
|  |     if ((upsModel === 'eaton' || upsModel === 'apc') && this.debug && outputCurrent > 0) { | ||||||
|  |       console.log(`${upsModel.toUpperCase()} current already in RMS Amps: ${outputCurrent}A`); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     return outputCurrent; | ||||||
|  |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -11,37 +11,77 @@ export class UpsOidSets { | |||||||
|   private static readonly UPS_OID_SETS: Record<TUpsModel, IOidSet> = { |   private static readonly UPS_OID_SETS: Record<TUpsModel, IOidSet> = { | ||||||
|     // Cyberpower OIDs for RMCARD205 (based on CyberPower_MIB_v2.11) |     // Cyberpower OIDs for RMCARD205 (based on CyberPower_MIB_v2.11) | ||||||
|     cyberpower: { |     cyberpower: { | ||||||
|       POWER_STATUS: '1.3.6.1.4.1.3808.1.1.1.4.1.1.0', // upsBaseOutputStatus (2=online, 3=on battery) |       POWER_STATUS: '1.3.6.1.4.1.3808.1.1.1.4.1.1.0', // upsBaseOutputStatus | ||||||
|       BATTERY_CAPACITY: '1.3.6.1.4.1.3808.1.1.1.2.2.1.0', // upsAdvanceBatteryCapacity (percentage) |       BATTERY_CAPACITY: '1.3.6.1.4.1.3808.1.1.1.2.2.1.0', // upsAdvanceBatteryCapacity (percentage) | ||||||
|       BATTERY_RUNTIME: '1.3.6.1.4.1.3808.1.1.1.2.2.4.0', // upsAdvanceBatteryRunTimeRemaining (TimeTicks) |       BATTERY_RUNTIME: '1.3.6.1.4.1.3808.1.1.1.2.2.4.0', // upsAdvanceBatteryRunTimeRemaining (TimeTicks) | ||||||
|  |       OUTPUT_LOAD: '1.3.6.1.4.1.3808.1.1.1.4.2.3.0', // upsAdvanceOutputLoad (percentage) | ||||||
|  |       OUTPUT_POWER: '1.3.6.1.4.1.3808.1.1.1.4.2.5.0', // upsAdvanceOutputPower (watts) | ||||||
|  |       OUTPUT_VOLTAGE: '1.3.6.1.4.1.3808.1.1.1.4.2.1.0', // upsAdvanceOutputVoltage (0.1V scale) | ||||||
|  |       OUTPUT_CURRENT: '1.3.6.1.4.1.3808.1.1.1.4.2.4.0', // upsAdvanceOutputCurrent (0.1A scale) | ||||||
|  |       POWER_STATUS_VALUES: { | ||||||
|  |         online: 2, // upsBaseOutputStatus: 2=onLine | ||||||
|  |         onBattery: 3, // upsBaseOutputStatus: 3=onBattery | ||||||
|  |       }, | ||||||
|     }, |     }, | ||||||
|  |  | ||||||
|     // APC OIDs |     // APC OIDs (PowerNet MIB) | ||||||
|     apc: { |     apc: { | ||||||
|       POWER_STATUS: '1.3.6.1.4.1.318.1.1.1.4.1.1.0', // Power status (1=online, 2=on battery) |       POWER_STATUS: '1.3.6.1.4.1.318.1.1.1.4.1.1.0', // upsBasicOutputStatus | ||||||
|       BATTERY_CAPACITY: '1.3.6.1.4.1.318.1.1.1.2.2.1.0', // Battery capacity in percentage |       BATTERY_CAPACITY: '1.3.6.1.4.1.318.1.1.1.2.2.1.0', // Battery capacity in percentage | ||||||
|       BATTERY_RUNTIME: '1.3.6.1.4.1.318.1.1.1.2.2.3.0', // Remaining runtime in minutes |       BATTERY_RUNTIME: '1.3.6.1.4.1.318.1.1.1.2.2.3.0', // Remaining runtime in minutes | ||||||
|  |       OUTPUT_LOAD: '1.3.6.1.4.1.318.1.1.1.4.2.3.0', // upsAdvOutputLoad (percentage) | ||||||
|  |       OUTPUT_POWER: '1.3.6.1.4.1.318.1.1.1.4.2.8.0', // upsAdvOutputActivePower (watts) | ||||||
|  |       OUTPUT_VOLTAGE: '1.3.6.1.4.1.318.1.1.1.4.2.1.0', // upsAdvOutputVoltage | ||||||
|  |       OUTPUT_CURRENT: '1.3.6.1.4.1.318.1.1.1.4.2.4.0', // upsAdvOutputCurrent | ||||||
|  |       POWER_STATUS_VALUES: { | ||||||
|  |         online: 2, // upsBasicOutputStatus: 2=onLine | ||||||
|  |         onBattery: 3, // upsBasicOutputStatus: 3=onBattery | ||||||
|  |       }, | ||||||
|     }, |     }, | ||||||
|  |  | ||||||
|     // Eaton OIDs |     // Eaton OIDs (XUPS-MIB) | ||||||
|     eaton: { |     eaton: { | ||||||
|       POWER_STATUS: '1.3.6.1.4.1.534.1.4.4.0', // xupsOutputSource (3=normal/mains, 5=battery) |       POWER_STATUS: '1.3.6.1.4.1.534.1.4.4.0', // xupsOutputSource | ||||||
|       BATTERY_CAPACITY: '1.3.6.1.4.1.534.1.2.4.0', // xupsBatCapacity (percentage) |       BATTERY_CAPACITY: '1.3.6.1.4.1.534.1.2.4.0', // xupsBatCapacity (percentage) | ||||||
|       BATTERY_RUNTIME: '1.3.6.1.4.1.534.1.2.1.0', // xupsBatTimeRemaining (seconds) |       BATTERY_RUNTIME: '1.3.6.1.4.1.534.1.2.1.0', // xupsBatTimeRemaining (seconds) | ||||||
|  |       OUTPUT_LOAD: '1.3.6.1.4.1.534.1.4.4.1.8.1', // xupsOutputPercentLoad (phase 1) | ||||||
|  |       OUTPUT_POWER: '1.3.6.1.4.1.534.1.4.4.1.4.1', // xupsOutputWatts (phase 1) | ||||||
|  |       OUTPUT_VOLTAGE: '1.3.6.1.4.1.534.1.4.4.1.2.1', // xupsOutputVoltage (phase 1) | ||||||
|  |       OUTPUT_CURRENT: '1.3.6.1.4.1.534.1.4.4.1.3.1', // xupsOutputCurrent (phase 1) | ||||||
|  |       POWER_STATUS_VALUES: { | ||||||
|  |         online: 3, // xupsOutputSource: 3=normal (mains power) | ||||||
|  |         onBattery: 5, // xupsOutputSource: 5=battery | ||||||
|  |       }, | ||||||
|     }, |     }, | ||||||
|  |  | ||||||
|     // TrippLite OIDs |     // TrippLite OIDs | ||||||
|     tripplite: { |     tripplite: { | ||||||
|       POWER_STATUS: '1.3.6.1.4.1.850.1.1.3.1.1.1.0', // Power status |       POWER_STATUS: '1.3.6.1.4.1.850.1.1.3.1.1.1.0', // tlUpsOutputSource | ||||||
|       BATTERY_CAPACITY: '1.3.6.1.4.1.850.1.1.3.2.4.1.0', // Battery capacity in percentage |       BATTERY_CAPACITY: '1.3.6.1.4.1.850.1.1.3.2.4.1.0', // Battery capacity in percentage | ||||||
|       BATTERY_RUNTIME: '1.3.6.1.4.1.850.1.1.3.2.2.1.0', // Remaining runtime in minutes |       BATTERY_RUNTIME: '1.3.6.1.4.1.850.1.1.3.2.2.1.0', // Remaining runtime in minutes | ||||||
|  |       OUTPUT_LOAD: '1.3.6.1.2.1.33.1.4.4.1.5.1', // RFC 1628: upsOutputPercentLoad | ||||||
|  |       OUTPUT_POWER: '1.3.6.1.2.1.33.1.4.4.1.4.1', // RFC 1628: upsOutputPower (watts) | ||||||
|  |       OUTPUT_VOLTAGE: '1.3.6.1.2.1.33.1.4.4.1.2.1', // RFC 1628: upsOutputVoltage | ||||||
|  |       OUTPUT_CURRENT: '1.3.6.1.2.1.33.1.4.4.1.3.1', // RFC 1628: upsOutputCurrent (0.1A scale) | ||||||
|  |       POWER_STATUS_VALUES: { | ||||||
|  |         online: 2, // tlUpsOutputSource: 2=normal (mains power) | ||||||
|  |         onBattery: 3, // tlUpsOutputSource: 3=onBattery | ||||||
|  |       }, | ||||||
|     }, |     }, | ||||||
|  |  | ||||||
|     // Liebert/Vertiv OIDs |     // Liebert/Vertiv OIDs | ||||||
|     liebert: { |     liebert: { | ||||||
|       POWER_STATUS: '1.3.6.1.4.1.476.1.42.3.9.20.1.20.1.2.1.2.1', // Power status |       POWER_STATUS: '1.3.6.1.4.1.476.1.42.3.9.20.1.20.1.2.1.2.1', // lgpPwrOutputSource | ||||||
|       BATTERY_CAPACITY: '1.3.6.1.4.1.476.1.42.3.9.20.1.20.1.2.1.4.1', // Battery capacity in percentage |       BATTERY_CAPACITY: '1.3.6.1.4.1.476.1.42.3.9.20.1.20.1.2.1.4.1', // Battery capacity in percentage | ||||||
|       BATTERY_RUNTIME: '1.3.6.1.4.1.476.1.42.3.9.20.1.20.1.2.1.5.1', // Remaining runtime in minutes |       BATTERY_RUNTIME: '1.3.6.1.4.1.476.1.42.3.9.20.1.20.1.2.1.5.1', // Remaining runtime in minutes | ||||||
|  |       OUTPUT_LOAD: '1.3.6.1.2.1.33.1.4.4.1.5.1', // RFC 1628: upsOutputPercentLoad | ||||||
|  |       OUTPUT_POWER: '1.3.6.1.2.1.33.1.4.4.1.4.1', // RFC 1628: upsOutputPower (watts) | ||||||
|  |       OUTPUT_VOLTAGE: '1.3.6.1.2.1.33.1.4.4.1.2.1', // RFC 1628: upsOutputVoltage | ||||||
|  |       OUTPUT_CURRENT: '1.3.6.1.2.1.33.1.4.4.1.3.1', // RFC 1628: upsOutputCurrent (0.1A scale) | ||||||
|  |       POWER_STATUS_VALUES: { | ||||||
|  |         online: 2, // lgpPwrOutputSource: 2=normal (mains power) | ||||||
|  |         onBattery: 3, // lgpPwrOutputSource: 3=onBattery | ||||||
|  |       }, | ||||||
|     }, |     }, | ||||||
|  |  | ||||||
|     // Custom OIDs (to be provided by the user) |     // Custom OIDs (to be provided by the user) | ||||||
| @@ -49,6 +89,10 @@ export class UpsOidSets { | |||||||
|       POWER_STATUS: '', |       POWER_STATUS: '', | ||||||
|       BATTERY_CAPACITY: '', |       BATTERY_CAPACITY: '', | ||||||
|       BATTERY_RUNTIME: '', |       BATTERY_RUNTIME: '', | ||||||
|  |       OUTPUT_LOAD: '', | ||||||
|  |       OUTPUT_POWER: '', | ||||||
|  |       OUTPUT_VOLTAGE: '', | ||||||
|  |       OUTPUT_CURRENT: '', | ||||||
|     }, |     }, | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
| @@ -70,6 +114,10 @@ export class UpsOidSets { | |||||||
|       'power status': '1.3.6.1.2.1.33.1.4.1.0', // upsOutputSource |       'power status': '1.3.6.1.2.1.33.1.4.1.0', // upsOutputSource | ||||||
|       'battery capacity': '1.3.6.1.2.1.33.1.2.4.0', // upsEstimatedChargeRemaining |       'battery capacity': '1.3.6.1.2.1.33.1.2.4.0', // upsEstimatedChargeRemaining | ||||||
|       'battery runtime': '1.3.6.1.2.1.33.1.2.3.0', // upsEstimatedMinutesRemaining |       'battery runtime': '1.3.6.1.2.1.33.1.2.3.0', // upsEstimatedMinutesRemaining | ||||||
|  |       'output load': '1.3.6.1.2.1.33.1.4.4.1.5.1', // upsOutputPercentLoad (indexed by line) | ||||||
|  |       'output power': '1.3.6.1.2.1.33.1.4.4.1.4.1', // upsOutputPower in watts (indexed by line) | ||||||
|  |       'output voltage': '1.3.6.1.2.1.33.1.4.4.1.2.1', // upsOutputVoltage (indexed by line) | ||||||
|  |       'output current': '1.3.6.1.2.1.33.1.4.4.1.3.1', // upsOutputCurrent in 0.1A (indexed by line) | ||||||
|     }; |     }; | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -14,6 +14,14 @@ export interface IUpsStatus { | |||||||
|   batteryCapacity: number; |   batteryCapacity: number; | ||||||
|   /** Remaining runtime in minutes */ |   /** Remaining runtime in minutes */ | ||||||
|   batteryRuntime: number; |   batteryRuntime: number; | ||||||
|  |   /** Output load percentage (0-100) */ | ||||||
|  |   outputLoad: number; | ||||||
|  |   /** Output power in watts */ | ||||||
|  |   outputPower: number; | ||||||
|  |   /** Output voltage in volts */ | ||||||
|  |   outputVoltage: number; | ||||||
|  |   /** Output current in amps */ | ||||||
|  |   outputCurrent: number; | ||||||
|   /** Raw values from SNMP responses */ |   /** Raw values from SNMP responses */ | ||||||
|   raw: Record<string, any>; |   raw: Record<string, any>; | ||||||
| } | } | ||||||
| @@ -28,6 +36,21 @@ export interface IOidSet { | |||||||
|   BATTERY_CAPACITY: string; |   BATTERY_CAPACITY: string; | ||||||
|   /** OID for battery runtime */ |   /** OID for battery runtime */ | ||||||
|   BATTERY_RUNTIME: string; |   BATTERY_RUNTIME: string; | ||||||
|  |   /** OID for output load percentage */ | ||||||
|  |   OUTPUT_LOAD: string; | ||||||
|  |   /** OID for output power in watts */ | ||||||
|  |   OUTPUT_POWER: string; | ||||||
|  |   /** OID for output voltage */ | ||||||
|  |   OUTPUT_VOLTAGE: string; | ||||||
|  |   /** OID for output current */ | ||||||
|  |   OUTPUT_CURRENT: string; | ||||||
|  |   /** Power status value mappings */ | ||||||
|  |   POWER_STATUS_VALUES?: { | ||||||
|  |     /** SNMP value that indicates UPS is online (on AC power) */ | ||||||
|  |     online: number; | ||||||
|  |     /** SNMP value that indicates UPS is on battery */ | ||||||
|  |     onBattery: number; | ||||||
|  |   }; | ||||||
| } | } | ||||||
|  |  | ||||||
| /** | /** | ||||||
|   | |||||||
							
								
								
									
										327
									
								
								ts/systemd.ts
									
									
									
									
									
								
							
							
						
						
									
										327
									
								
								ts/systemd.ts
									
									
									
									
									
								
							| @@ -1,8 +1,10 @@ | |||||||
| import process from 'node:process'; | import process from 'node:process'; | ||||||
| import { promises as fs } from 'node:fs'; | import { promises as fs } from 'node:fs'; | ||||||
| import { execSync } from 'node:child_process'; | import { execSync } from 'node:child_process'; | ||||||
| import { NupstDaemon } from './daemon.ts'; | import { NupstDaemon, type IUpsConfig } from './daemon.ts'; | ||||||
|  | import { NupstSnmp } from './snmp/manager.ts'; | ||||||
| import { logger } from './logger.ts'; | import { logger } from './logger.ts'; | ||||||
|  | import { theme, symbols, getBatteryColor, getRuntimeColor, formatPowerStatus } from './colors.ts'; | ||||||
|  |  | ||||||
| /** | /** | ||||||
|  * Class for managing systemd service |  * Class for managing systemd service | ||||||
| @@ -49,11 +51,11 @@ WantedBy=multi-user.target | |||||||
|     try { |     try { | ||||||
|       await fs.access(configPath); |       await fs.access(configPath); | ||||||
|     } catch (error) { |     } catch (error) { | ||||||
|       const boxWidth = 50; |       logger.log(''); | ||||||
|       logger.logBoxTitle('Configuration Error', boxWidth); |       logger.error('No configuration found'); | ||||||
|       logger.logBoxLine(`No configuration file found at ${configPath}`); |       logger.log(`  ${theme.dim('Config file:')} ${configPath}`); | ||||||
|       logger.logBoxLine("Please run 'nupst add' first to create a UPS configuration."); |       logger.log(`  ${theme.dim('Run')} ${theme.command('nupst ups add')} ${theme.dim('to create a configuration')}`); | ||||||
|       logger.logBoxEnd(); |       logger.log(''); | ||||||
|       throw new Error('Configuration not found'); |       throw new Error('Configuration not found'); | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| @@ -133,21 +135,59 @@ WantedBy=multi-user.target | |||||||
|    * Get status of the systemd service and UPS |    * Get status of the systemd service and UPS | ||||||
|    * @param debugMode Whether to enable debug mode for SNMP |    * @param debugMode Whether to enable debug mode for SNMP | ||||||
|    */ |    */ | ||||||
|  |   /** | ||||||
|  |    * Display version information and update status | ||||||
|  |    * @private | ||||||
|  |    */ | ||||||
|  |   private async displayVersionInfo(): Promise<void> { | ||||||
|  |     try { | ||||||
|  |       const nupst = this.daemon.getNupstSnmp().getNupst(); | ||||||
|  |       const version = nupst.getVersion(); | ||||||
|  |        | ||||||
|  |       // Check for updates | ||||||
|  |       const updateAvailable = await nupst.checkForUpdates(); | ||||||
|  |        | ||||||
|  |       // Display version info | ||||||
|  |       if (updateAvailable) { | ||||||
|  |         const updateStatus = nupst.getUpdateStatus(); | ||||||
|  |         logger.log(''); | ||||||
|  |         logger.log( | ||||||
|  |           `${theme.dim('NUPST')} ${theme.dim('v' + version)}  ${symbols.warning} ${theme.statusWarning(`Update available: v${updateStatus.latestVersion}`)}`, | ||||||
|  |         ); | ||||||
|  |         logger.log(`  ${theme.dim('Run')} ${theme.command('sudo nupst update')} ${theme.dim('to upgrade')}`); | ||||||
|  |       } else { | ||||||
|  |         logger.log(''); | ||||||
|  |         logger.log( | ||||||
|  |           `${theme.dim('NUPST')} ${theme.dim('v' + version)}  ${symbols.success} ${theme.success('Up to date')}`, | ||||||
|  |         ); | ||||||
|  |       } | ||||||
|  |     } catch (error) { | ||||||
|  |       // If version check fails, show at least the current version | ||||||
|  |       try { | ||||||
|  |         const nupst = this.daemon.getNupstSnmp().getNupst(); | ||||||
|  |         const version = nupst.getVersion(); | ||||||
|  |         logger.log(''); | ||||||
|  |         logger.log(`${theme.dim('NUPST')} ${theme.dim('v' + version)}`); | ||||||
|  |       } catch (_innerError) { | ||||||
|  |         // Silently fail if we can't even get the version | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|   public async getStatus(debugMode: boolean = false): Promise<void> { |   public async getStatus(debugMode: boolean = false): Promise<void> { | ||||||
|     try { |     try { | ||||||
|       // Enable debug mode if requested |       // Enable debug mode if requested | ||||||
|       if (debugMode) { |       if (debugMode) { | ||||||
|         const boxWidth = 45; |         console.log(''); | ||||||
|         logger.logBoxTitle('Debug Mode', boxWidth); |         logger.info('Debug Mode: SNMP debugging enabled'); | ||||||
|         logger.logBoxLine('SNMP debugging enabled - detailed logs will be shown'); |         console.log(''); | ||||||
|         logger.logBoxEnd(); |  | ||||||
|         this.daemon.getNupstSnmp().enableDebug(); |         this.daemon.getNupstSnmp().enableDebug(); | ||||||
|       } |       } | ||||||
|  |  | ||||||
|       // Display version information |       // Display version and update status first | ||||||
|       this.daemon.getNupstSnmp().getNupst().logVersionInfo(); |       await this.displayVersionInfo(); | ||||||
|  |  | ||||||
|       // Check if config exists first |       // Check if config exists | ||||||
|       try { |       try { | ||||||
|         await this.checkConfigExists(); |         await this.checkConfigExists(); | ||||||
|       } catch (error) { |       } catch (error) { | ||||||
| @@ -171,18 +211,50 @@ WantedBy=multi-user.target | |||||||
|   private displayServiceStatus(): void { |   private displayServiceStatus(): void { | ||||||
|     try { |     try { | ||||||
|       const serviceStatus = execSync('systemctl status nupst.service').toString(); |       const serviceStatus = execSync('systemctl status nupst.service').toString(); | ||||||
|       const boxWidth = 45; |       const lines = serviceStatus.split('\n'); | ||||||
|       logger.logBoxTitle('Service Status', boxWidth); |  | ||||||
|       // Process each line of the status output |       // Parse key information from systemctl output | ||||||
|       serviceStatus.split('\n').forEach((line) => { |       let isActive = false; | ||||||
|         logger.logBoxLine(line); |       let pid = ''; | ||||||
|       }); |       let memory = ''; | ||||||
|       logger.logBoxEnd(); |       let cpu = ''; | ||||||
|  |  | ||||||
|  |       for (const line of lines) { | ||||||
|  |         if (line.includes('Active:')) { | ||||||
|  |           isActive = line.includes('active (running)'); | ||||||
|  |         } else if (line.includes('Main PID:')) { | ||||||
|  |           const match = line.match(/Main PID:\s+(\d+)/); | ||||||
|  |           if (match) pid = match[1]; | ||||||
|  |         } else if (line.includes('Memory:')) { | ||||||
|  |           const match = line.match(/Memory:\s+([\d.]+[A-Z])/); | ||||||
|  |           if (match) memory = match[1]; | ||||||
|  |         } else if (line.includes('CPU:')) { | ||||||
|  |           const match = line.match(/CPU:\s+([\d.]+(?:ms|s))/); | ||||||
|  |           if (match) cpu = match[1]; | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       // Display beautiful status | ||||||
|  |       logger.log(''); | ||||||
|  |       if (isActive) { | ||||||
|  |         logger.log(`${symbols.running} ${theme.success('Service:')} ${theme.statusActive('active (running)')}`); | ||||||
|  |       } else { | ||||||
|  |         logger.log(`${symbols.stopped} ${theme.dim('Service:')} ${theme.statusInactive('inactive')}`); | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       if (pid || memory || cpu) { | ||||||
|  |         const details = []; | ||||||
|  |         if (pid) details.push(`PID: ${theme.dim(pid)}`); | ||||||
|  |         if (memory) details.push(`Memory: ${theme.dim(memory)}`); | ||||||
|  |         if (cpu) details.push(`CPU: ${theme.dim(cpu)}`); | ||||||
|  |         logger.log(`  ${details.join('  ')}`); | ||||||
|  |       } | ||||||
|  |       logger.log(''); | ||||||
|  |  | ||||||
|     } catch (error) { |     } catch (error) { | ||||||
|       const boxWidth = 45; |       logger.log(''); | ||||||
|       logger.logBoxTitle('Service Status', boxWidth); |       logger.log(`${symbols.stopped} ${theme.dim('Service:')} ${theme.statusInactive('not installed')}`); | ||||||
|       logger.logBoxLine('Service is not running'); |       logger.log(''); | ||||||
|       logger.logBoxEnd(); |  | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
| @@ -199,33 +271,47 @@ WantedBy=multi-user.target | |||||||
|  |  | ||||||
|       // Check if we have the new multi-UPS config format |       // Check if we have the new multi-UPS config format | ||||||
|       if (config.upsDevices && Array.isArray(config.upsDevices) && config.upsDevices.length > 0) { |       if (config.upsDevices && Array.isArray(config.upsDevices) && config.upsDevices.length > 0) { | ||||||
|         logger.log(`Found ${config.upsDevices.length} UPS device(s) in configuration`); |         logger.info(`UPS Devices (${config.upsDevices.length}):`); | ||||||
|  |  | ||||||
|         // Show status for each UPS |         // Show status for each UPS | ||||||
|         for (const ups of config.upsDevices) { |         for (const ups of config.upsDevices) { | ||||||
|           await this.displaySingleUpsStatus(ups, snmp); |           await this.displaySingleUpsStatus(ups, snmp); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|  |         // Display groups after UPS devices | ||||||
|  |         this.displayGroupsStatus(); | ||||||
|       } else if (config.snmp) { |       } else if (config.snmp) { | ||||||
|         // Legacy single UPS configuration |         // Legacy single UPS configuration (v1/v2 format) | ||||||
|         const legacyUps = { |         logger.info('UPS Devices (1):'); | ||||||
|  |         const legacyUps: IUpsConfig = { | ||||||
|           id: 'default', |           id: 'default', | ||||||
|           name: 'Default UPS', |           name: 'Default UPS', | ||||||
|           snmp: config.snmp, |           snmp: config.snmp, | ||||||
|           thresholds: config.thresholds, |  | ||||||
|           groups: [], |           groups: [], | ||||||
|  |           actions: config.thresholds | ||||||
|  |             ? [ | ||||||
|  |                 { | ||||||
|  |                   type: 'shutdown', | ||||||
|  |                   thresholds: config.thresholds, | ||||||
|  |                   triggerMode: 'onlyThresholds', | ||||||
|  |                   shutdownDelay: 5, | ||||||
|  |                 }, | ||||||
|  |               ] | ||||||
|  |             : [], | ||||||
|         }; |         }; | ||||||
|  |  | ||||||
|         await this.displaySingleUpsStatus(legacyUps, snmp); |         await this.displaySingleUpsStatus(legacyUps, snmp); | ||||||
|       } else { |       } else { | ||||||
|         logger.error('No UPS devices found in configuration'); |         logger.log(''); | ||||||
|  |         logger.warn('No UPS devices configured'); | ||||||
|  |         logger.log(`  ${theme.dim('Run')} ${theme.command('nupst ups add')} ${theme.dim('to add a device')}`); | ||||||
|  |         logger.log(''); | ||||||
|       } |       } | ||||||
|     } catch (error) { |     } catch (error) { | ||||||
|       const boxWidth = 45; |       logger.log(''); | ||||||
|       logger.logBoxTitle('UPS Status', boxWidth); |       logger.error('Failed to retrieve UPS status'); | ||||||
|       logger.logBoxLine( |       logger.log(`  ${theme.dim(error instanceof Error ? error.message : String(error))}`); | ||||||
|         `Failed to retrieve UPS status: ${error instanceof Error ? error.message : String(error)}`, |       logger.log(''); | ||||||
|       ); |  | ||||||
|       logger.logBoxEnd(); |  | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
| @@ -234,25 +320,7 @@ WantedBy=multi-user.target | |||||||
|    * @param ups UPS configuration |    * @param ups UPS configuration | ||||||
|    * @param snmp SNMP manager |    * @param snmp SNMP manager | ||||||
|    */ |    */ | ||||||
|   private async displaySingleUpsStatus(ups: any, snmp: any): Promise<void> { |   private async displaySingleUpsStatus(ups: IUpsConfig, snmp: NupstSnmp): Promise<void> { | ||||||
|     const boxWidth = 45; |  | ||||||
|     logger.logBoxTitle(`Connecting to UPS: ${ups.name}`, boxWidth); |  | ||||||
|     logger.logBoxLine(`ID: ${ups.id}`); |  | ||||||
|     logger.logBoxLine(`Host: ${ups.snmp.host}:${ups.snmp.port}`); |  | ||||||
|     logger.logBoxLine(`UPS Model: ${ups.snmp.upsModel || 'cyberpower'}`); |  | ||||||
|  |  | ||||||
|     if (ups.groups && ups.groups.length > 0) { |  | ||||||
|       // Get group names if available |  | ||||||
|       const config = this.daemon.getConfig(); |  | ||||||
|       const groupNames = ups.groups.map((groupId: string) => { |  | ||||||
|         const group = config.groups?.find((g: { id: string }) => g.id === groupId); |  | ||||||
|         return group ? group.name : groupId; |  | ||||||
|       }); |  | ||||||
|       logger.logBoxLine(`Groups: ${groupNames.join(', ')}`); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     logger.logBoxEnd(); |  | ||||||
|  |  | ||||||
|     try { |     try { | ||||||
|       // Create a test config with a short timeout |       // Create a test config with a short timeout | ||||||
|       const testConfig = { |       const testConfig = { | ||||||
| @@ -262,32 +330,139 @@ WantedBy=multi-user.target | |||||||
|  |  | ||||||
|       const status = await snmp.getUpsStatus(testConfig); |       const status = await snmp.getUpsStatus(testConfig); | ||||||
|  |  | ||||||
|       logger.logBoxTitle(`UPS Status: ${ups.name}`, boxWidth); |       // Determine status symbol based on power status | ||||||
|       logger.logBoxLine(`Power Status: ${status.powerStatus}`); |       let statusSymbol = symbols.unknown; | ||||||
|       logger.logBoxLine(`Battery Capacity: ${status.batteryCapacity}%`); |       if (status.powerStatus === 'online') { | ||||||
|       logger.logBoxLine(`Runtime Remaining: ${status.batteryRuntime} minutes`); |         statusSymbol = symbols.running; | ||||||
|  |       } else if (status.powerStatus === 'onBattery') { | ||||||
|  |         statusSymbol = symbols.warning; | ||||||
|  |       } | ||||||
|  |  | ||||||
|       // Show threshold status |       // Display UPS name and power status | ||||||
|       logger.logBoxLine(''); |       logger.log(`  ${statusSymbol} ${theme.highlight(ups.name)} - ${formatPowerStatus(status.powerStatus)}`); | ||||||
|       logger.logBoxLine('Thresholds:'); |  | ||||||
|       logger.logBoxLine( |       // Display battery with color coding | ||||||
|         `  Battery: ${status.batteryCapacity}% / ${ups.thresholds.battery}% ${ |       const batteryColor = getBatteryColor(status.batteryCapacity); | ||||||
|           status.batteryCapacity < ups.thresholds.battery ? '⚠️' : '✓' |  | ||||||
|         }`, |       // Get threshold from actions (if any action has thresholds defined) | ||||||
|       ); |       const actionWithThresholds = ups.actions?.find((action) => action.thresholds); | ||||||
|       logger.logBoxLine( |       const batteryThreshold = actionWithThresholds?.thresholds?.battery; | ||||||
|         `  Runtime: ${status.batteryRuntime} min / ${ups.thresholds.runtime} min ${ |       const batterySymbol = batteryThreshold !== undefined && status.batteryCapacity >= batteryThreshold | ||||||
|           status.batteryRuntime < ups.thresholds.runtime ? '⚠️' : '✓' |         ? symbols.success | ||||||
|         }`, |         : batteryThreshold !== undefined | ||||||
|       ); |         ? symbols.warning | ||||||
|  |         : ''; | ||||||
|  |  | ||||||
|  |       logger.log(`    Battery: ${batteryColor(status.batteryCapacity + '%')} ${batterySymbol}  Runtime: ${getRuntimeColor(status.batteryRuntime)(status.batteryRuntime + ' min')}`); | ||||||
|  |  | ||||||
|  |       // Display power metrics | ||||||
|  |       logger.log(`    Load: ${theme.highlight(status.outputLoad + '%')}  Power: ${theme.highlight(status.outputPower + 'W')}  Voltage: ${theme.highlight(status.outputVoltage + 'V')}  Current: ${theme.highlight(status.outputCurrent + 'A')}`); | ||||||
|  |  | ||||||
|  |       // Display host info | ||||||
|  |       logger.log(`    ${theme.dim(`Host: ${ups.snmp.host}:${ups.snmp.port}`)}`); | ||||||
|  |  | ||||||
|  |       // Display groups if any | ||||||
|  |       if (ups.groups && ups.groups.length > 0) { | ||||||
|  |         const config = this.daemon.getConfig(); | ||||||
|  |         const groupNames = ups.groups.map((groupId: string) => { | ||||||
|  |           const group = config.groups?.find((g: { id: string }) => g.id === groupId); | ||||||
|  |           return group ? group.name : groupId; | ||||||
|  |         }); | ||||||
|  |         logger.log(`    ${theme.dim(`Groups: ${groupNames.join(', ')}`)}`); | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       // Display actions if any | ||||||
|  |       if (ups.actions && ups.actions.length > 0) { | ||||||
|  |         for (const action of ups.actions) { | ||||||
|  |           let actionDesc = `${action.type}`; | ||||||
|  |           if (action.thresholds) { | ||||||
|  |             actionDesc += ` (${action.triggerMode || 'onlyThresholds'}: battery<${action.thresholds.battery}%, runtime<${action.thresholds.runtime}min`; | ||||||
|  |             if (action.shutdownDelay) { | ||||||
|  |               actionDesc += `, delay=${action.shutdownDelay}s`; | ||||||
|  |             } | ||||||
|  |             actionDesc += ')'; | ||||||
|  |           } else { | ||||||
|  |             actionDesc += ` (${action.triggerMode || 'onlyPowerChanges'}`; | ||||||
|  |             if (action.shutdownDelay) { | ||||||
|  |               actionDesc += `, delay=${action.shutdownDelay}s`; | ||||||
|  |             } | ||||||
|  |             actionDesc += ')'; | ||||||
|  |           } | ||||||
|  |           logger.log(`    ${theme.dim('Action:')} ${theme.info(actionDesc)}`); | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       logger.log(''); | ||||||
|  |  | ||||||
|       logger.logBoxEnd(); |  | ||||||
|     } catch (error) { |     } catch (error) { | ||||||
|       logger.logBoxTitle(`UPS Status: ${ups.name}`, boxWidth); |       // Display error for this UPS | ||||||
|       logger.logBoxLine( |       logger.log(`  ${symbols.error} ${theme.highlight(ups.name)} - ${theme.error('Connection failed')}`); | ||||||
|         `Failed to retrieve UPS status: ${error instanceof Error ? error.message : String(error)}`, |       logger.log(`    ${theme.dim(error instanceof Error ? error.message : String(error))}`); | ||||||
|  |       logger.log(`    ${theme.dim(`Host: ${ups.snmp.host}:${ups.snmp.port}`)}`); | ||||||
|  |       logger.log(''); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Display status of all groups | ||||||
|  |    * @private | ||||||
|  |    */ | ||||||
|  |   private displayGroupsStatus(): void { | ||||||
|  |     const config = this.daemon.getConfig(); | ||||||
|  |  | ||||||
|  |     if (!config.groups || config.groups.length === 0) { | ||||||
|  |       return; // No groups to display | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     logger.log(''); | ||||||
|  |     logger.info(`Groups (${config.groups.length}):`); | ||||||
|  |  | ||||||
|  |     for (const group of config.groups) { | ||||||
|  |       // Display group name and mode | ||||||
|  |       const modeColor = group.mode === 'redundant' ? theme.success : theme.warning; | ||||||
|  |       logger.log( | ||||||
|  |         `  ${symbols.info} ${theme.highlight(group.name)} ${theme.dim(`(${modeColor(group.mode)})`)}`, | ||||||
|       ); |       ); | ||||||
|       logger.logBoxEnd(); |  | ||||||
|  |       // Display description if present | ||||||
|  |       if (group.description) { | ||||||
|  |         logger.log(`    ${theme.dim(group.description)}`); | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       // Display UPS devices in this group | ||||||
|  |       const upsInGroup = config.upsDevices.filter((ups) => | ||||||
|  |         ups.groups && ups.groups.includes(group.id) | ||||||
|  |       ); | ||||||
|  |  | ||||||
|  |       if (upsInGroup.length > 0) { | ||||||
|  |         const upsNames = upsInGroup.map((ups) => ups.name).join(', '); | ||||||
|  |         logger.log(`    ${theme.dim(`UPS Devices (${upsInGroup.length}):`)} ${upsNames}`); | ||||||
|  |       } else { | ||||||
|  |         logger.log(`    ${theme.dim('UPS Devices: None')}`); | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       // Display actions if any | ||||||
|  |       if (group.actions && group.actions.length > 0) { | ||||||
|  |         for (const action of group.actions) { | ||||||
|  |           let actionDesc = `${action.type}`; | ||||||
|  |           if (action.thresholds) { | ||||||
|  |             actionDesc += ` (${action.triggerMode || 'onlyThresholds'}: battery<${action.thresholds.battery}%, runtime<${action.thresholds.runtime}min`; | ||||||
|  |             if (action.shutdownDelay) { | ||||||
|  |               actionDesc += `, delay=${action.shutdownDelay}s`; | ||||||
|  |             } | ||||||
|  |             actionDesc += ')'; | ||||||
|  |           } else { | ||||||
|  |             actionDesc += ` (${action.triggerMode || 'onlyPowerChanges'}`; | ||||||
|  |             if (action.shutdownDelay) { | ||||||
|  |               actionDesc += `, delay=${action.shutdownDelay}s`; | ||||||
|  |             } | ||||||
|  |             actionDesc += ')'; | ||||||
|  |           } | ||||||
|  |           logger.log(`    ${theme.dim('Action:')} ${theme.info(actionDesc)}`); | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       logger.log(''); | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user