Compare commits
81 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 08b20b4e7b | |||
| ba4e56338c | |||
| 6b2fa65611 | |||
| c42ebb56d3 | |||
| c7b52c48d5 | |||
| e2cfa67fee | |||
| e916ccf3ae | |||
| a435bd6fed | |||
| bf4d519428 | |||
| 579667b3cd | |||
| 8dc0248763 | |||
| 1f542ca271 | |||
| 2adf1d5548 | |||
| 067a7666e4 | |||
| 0d863a1028 | |||
| c410a663b1 | |||
| 6aa1fc651f | |||
| 11e549e68e | |||
| 0fb9678976 | |||
| 635de0d932 | |||
| 0916effb53 | |||
| 05242a1c7d | |||
| 0d20dce520 | |||
| 1c50509497 | |||
| 7de521078e | |||
| 42b8eaf6d2 | |||
| 782c8c9555 | |||
| 463c32ebba | |||
| 51aa68ff8d | |||
| cb34ae5041 | |||
| 165c7d29bb | |||
| ff2dc00f31 | |||
| fda072d15e | |||
| c7786e9626 | |||
| 91fe5f7ae6 | |||
| 07648b4880 | |||
| d0e3a4ae74 | |||
| 89ffd61717 | |||
| 60eadaf6a1 | |||
| bd52ba4cb2 | |||
| 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 |
@@ -0,0 +1,37 @@
|
||||
## 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.
|
||||
@@ -1,84 +0,0 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- 'migration/**'
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: Type Check & Lint
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Deno
|
||||
uses: denoland/setup-deno@v1
|
||||
with:
|
||||
deno-version: v1.x
|
||||
|
||||
- name: Check TypeScript types
|
||||
run: deno check mod.ts
|
||||
|
||||
- name: Lint code
|
||||
run: deno lint
|
||||
continue-on-error: true
|
||||
|
||||
- name: Format check
|
||||
run: deno fmt --check
|
||||
continue-on-error: true
|
||||
|
||||
build:
|
||||
name: Build Test (Current Platform)
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Deno
|
||||
uses: denoland/setup-deno@v1
|
||||
with:
|
||||
deno-version: v1.x
|
||||
|
||||
- name: Compile for current platform
|
||||
run: |
|
||||
echo "Testing compilation for Linux x86_64..."
|
||||
deno compile --allow-all --no-check \
|
||||
--output nupst-test \
|
||||
--target x86_64-unknown-linux-gnu mod.ts
|
||||
|
||||
- name: Test binary execution
|
||||
run: |
|
||||
chmod +x nupst-test
|
||||
./nupst-test --version
|
||||
./nupst-test help
|
||||
|
||||
build-all:
|
||||
name: Build All Platforms
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Deno
|
||||
uses: denoland/setup-deno@v1
|
||||
with:
|
||||
deno-version: v1.x
|
||||
|
||||
- name: Compile all platform binaries
|
||||
run: bash scripts/compile-all.sh
|
||||
|
||||
- name: Upload all binaries as artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: nupst-binaries.zip
|
||||
path: dist/binaries/*
|
||||
retention-days: 30
|
||||
@@ -8,6 +8,8 @@ on:
|
||||
jobs:
|
||||
build-and-release:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: code.foss.global/host.today/ht-docker-node:latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -18,7 +20,18 @@ jobs:
|
||||
- name: Set up Deno
|
||||
uses: denoland/setup-deno@v1
|
||||
with:
|
||||
deno-version: v1.x
|
||||
deno-version: v2.x
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Enable corepack
|
||||
run: corepack enable
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --ignore-scripts
|
||||
|
||||
- name: Get version from tag
|
||||
id: version
|
||||
@@ -41,57 +54,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Compile binaries for all platforms
|
||||
run: |
|
||||
echo "================================================"
|
||||
echo " NUPST Release Compilation"
|
||||
echo " Version: ${{ steps.version.outputs.version }}"
|
||||
echo "================================================"
|
||||
echo ""
|
||||
|
||||
# Clean up old binaries and create fresh directory
|
||||
rm -rf dist/binaries
|
||||
mkdir -p dist/binaries
|
||||
echo "→ Cleaned old binaries from dist/binaries"
|
||||
echo ""
|
||||
|
||||
# Linux x86_64
|
||||
echo "→ Compiling for Linux x86_64..."
|
||||
deno compile --allow-all --no-check \
|
||||
--output dist/binaries/nupst-linux-x64 \
|
||||
--target x86_64-unknown-linux-gnu mod.ts
|
||||
echo " ✓ Linux x86_64 complete"
|
||||
|
||||
# Linux ARM64
|
||||
echo "→ Compiling for Linux ARM64..."
|
||||
deno compile --allow-all --no-check \
|
||||
--output dist/binaries/nupst-linux-arm64 \
|
||||
--target aarch64-unknown-linux-gnu mod.ts
|
||||
echo " ✓ Linux ARM64 complete"
|
||||
|
||||
# macOS x86_64
|
||||
echo "→ Compiling for macOS x86_64..."
|
||||
deno compile --allow-all --no-check \
|
||||
--output dist/binaries/nupst-macos-x64 \
|
||||
--target x86_64-apple-darwin mod.ts
|
||||
echo " ✓ macOS x86_64 complete"
|
||||
|
||||
# macOS ARM64
|
||||
echo "→ Compiling for macOS ARM64..."
|
||||
deno compile --allow-all --no-check \
|
||||
--output dist/binaries/nupst-macos-arm64 \
|
||||
--target aarch64-apple-darwin mod.ts
|
||||
echo " ✓ macOS ARM64 complete"
|
||||
|
||||
# Windows x86_64
|
||||
echo "→ Compiling for Windows x86_64..."
|
||||
deno compile --allow-all --no-check \
|
||||
--output dist/binaries/nupst-windows-x64.exe \
|
||||
--target x86_64-pc-windows-msvc mod.ts
|
||||
echo " ✓ Windows x86_64 complete"
|
||||
|
||||
echo ""
|
||||
echo "All binaries compiled successfully!"
|
||||
ls -lh dist/binaries/
|
||||
run: mkdir -p dist/binaries && npx tsdeno compile
|
||||
|
||||
- name: Generate SHA256 checksums
|
||||
run: |
|
||||
@@ -105,7 +68,6 @@ jobs:
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
|
||||
# Check if CHANGELOG.md exists
|
||||
if [ ! -f CHANGELOG.md ]; then
|
||||
echo "No CHANGELOG.md found, using default release notes"
|
||||
cat > /tmp/release_notes.md << EOF
|
||||
@@ -133,8 +95,6 @@ jobs:
|
||||
SHA256 checksums are provided in SHA256SUMS.txt
|
||||
EOF
|
||||
else
|
||||
# Try to extract section for this version from CHANGELOG.md
|
||||
# This is a simple extraction - adjust based on your CHANGELOG format
|
||||
awk "/## \[$VERSION\]/,/## \[/" CHANGELOG.md | sed '$d' > /tmp/release_notes.md || cat > /tmp/release_notes.md << EOF
|
||||
## NUPST $VERSION
|
||||
|
||||
@@ -158,7 +118,6 @@ jobs:
|
||||
|
||||
echo "Checking for existing release $VERSION..."
|
||||
|
||||
# Try to get existing release by tag
|
||||
EXISTING_RELEASE_ID=$(curl -s \
|
||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
"https://code.foss.global/api/v1/repos/serve.zone/nupst/releases/tags/$VERSION" \
|
||||
@@ -178,9 +137,7 @@ jobs:
|
||||
- name: Create Gitea Release
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
RELEASE_NOTES=$(cat /tmp/release_notes.md)
|
||||
|
||||
# Create the release
|
||||
echo "Creating release for $VERSION..."
|
||||
RELEASE_ID=$(curl -X POST -s \
|
||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
@@ -196,7 +153,6 @@ jobs:
|
||||
|
||||
echo "Release created with ID: $RELEASE_ID"
|
||||
|
||||
# Upload binaries as release assets
|
||||
for binary in dist/binaries/*; do
|
||||
filename=$(basename "$binary")
|
||||
echo "Uploading $filename..."
|
||||
@@ -213,12 +169,10 @@ jobs:
|
||||
run: |
|
||||
echo "Cleaning up old releases (keeping only last 3)..."
|
||||
|
||||
# Fetch all releases sorted by creation date
|
||||
RELEASES=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
"https://code.foss.global/api/v1/repos/serve.zone/nupst/releases" | \
|
||||
jq -r 'sort_by(.created_at) | reverse | .[3:] | .[].id')
|
||||
|
||||
# Delete old releases
|
||||
if [ -n "$RELEASES" ]; then
|
||||
echo "Found releases to delete:"
|
||||
for release_id in $RELEASES; do
|
||||
|
||||
+54
@@ -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.*
|
||||
@@ -1 +0,0 @@
|
||||
/cache
|
||||
@@ -1,71 +0,0 @@
|
||||
# language of the project (csharp, python, rust, java, typescript, go, cpp, or ruby)
|
||||
# * For C, use cpp
|
||||
# * For JavaScript, use typescript
|
||||
# Special requirements:
|
||||
# * csharp: Requires the presence of a .sln file in the project folder.
|
||||
language: typescript
|
||||
|
||||
# the encoding used by text files in the project
|
||||
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
|
||||
encoding: 'utf-8'
|
||||
|
||||
# whether to use the project's gitignore file to ignore files
|
||||
# Added on 2025-04-07
|
||||
ignore_all_files_in_gitignore: true
|
||||
# list of additional paths to ignore
|
||||
# same syntax as gitignore, so you can use * and **
|
||||
# Was previously called `ignored_dirs`, please update your config if you are using that.
|
||||
# Added (renamed) on 2025-04-07
|
||||
ignored_paths: []
|
||||
|
||||
# whether the project is in read-only mode
|
||||
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
|
||||
# Added on 2025-04-18
|
||||
read_only: false
|
||||
|
||||
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
|
||||
# Below is the complete list of tools for convenience.
|
||||
# To make sure you have the latest list of tools, and to view their descriptions,
|
||||
# execute `uv run scripts/print_tool_overview.py`.
|
||||
#
|
||||
# * `activate_project`: Activates a project by name.
|
||||
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
|
||||
# * `create_text_file`: Creates/overwrites a file in the project directory.
|
||||
# * `delete_lines`: Deletes a range of lines within a file.
|
||||
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
|
||||
# * `execute_shell_command`: Executes a shell command.
|
||||
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
|
||||
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
|
||||
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
|
||||
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
|
||||
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
|
||||
# * `initial_instructions`: Gets the initial instructions for the current project.
|
||||
# Should only be used in settings where the system prompt cannot be set,
|
||||
# e.g. in clients you have no control over, like Claude Desktop.
|
||||
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
|
||||
# * `insert_at_line`: Inserts content at a given line in a file.
|
||||
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
|
||||
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
|
||||
# * `list_memories`: Lists memories in Serena's project-specific memory store.
|
||||
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
|
||||
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
|
||||
# * `read_file`: Reads a file within the project directory.
|
||||
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
|
||||
# * `remove_project`: Removes a project from the Serena configuration.
|
||||
# * `replace_lines`: Replaces a range of lines within a file with new content.
|
||||
# * `replace_symbol_body`: Replaces the full definition of a symbol.
|
||||
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
|
||||
# * `search_for_pattern`: Performs a search for a pattern in the project.
|
||||
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
|
||||
# * `switch_modes`: Activates modes by providing a list of their names
|
||||
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
|
||||
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
|
||||
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
|
||||
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
|
||||
excluded_tools: []
|
||||
|
||||
# initial prompt for the project. It will always be given to the LLM upon activating the project
|
||||
# (contrary to the memories, which are loaded on demand).
|
||||
initial_prompt: ''
|
||||
|
||||
project_name: 'nupst'
|
||||
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"@git.zone/cli": {
|
||||
"release": {
|
||||
"registries": [
|
||||
"https://verdaccio.lossless.digital"
|
||||
],
|
||||
"accessLevel": "public"
|
||||
},
|
||||
"projectType": "deno",
|
||||
"module": {
|
||||
"githost": "code.foss.global",
|
||||
"gitscope": "serve.zone",
|
||||
"gitrepo": "nupst",
|
||||
"description": "shut down in time when the power goes out",
|
||||
"npmPackagename": "@serve.zone/nupst",
|
||||
"license": "MIT"
|
||||
}
|
||||
},
|
||||
"@git.zone/tsdeno": {
|
||||
"compileTargets": [
|
||||
{
|
||||
"name": "nupst-linux-x64",
|
||||
"entryPoint": "mod.ts",
|
||||
"outDir": "dist/binaries",
|
||||
"target": "x86_64-unknown-linux-gnu",
|
||||
"permissions": ["--allow-all"],
|
||||
"noCheck": true
|
||||
},
|
||||
{
|
||||
"name": "nupst-linux-arm64",
|
||||
"entryPoint": "mod.ts",
|
||||
"outDir": "dist/binaries",
|
||||
"target": "aarch64-unknown-linux-gnu",
|
||||
"permissions": ["--allow-all"],
|
||||
"noCheck": true
|
||||
},
|
||||
{
|
||||
"name": "nupst-macos-x64",
|
||||
"entryPoint": "mod.ts",
|
||||
"outDir": "dist/binaries",
|
||||
"target": "x86_64-apple-darwin",
|
||||
"permissions": ["--allow-all"],
|
||||
"noCheck": true
|
||||
},
|
||||
{
|
||||
"name": "nupst-macos-arm64",
|
||||
"entryPoint": "mod.ts",
|
||||
"outDir": "dist/binaries",
|
||||
"target": "aarch64-apple-darwin",
|
||||
"permissions": ["--allow-all"],
|
||||
"noCheck": true
|
||||
},
|
||||
{
|
||||
"name": "nupst-windows-x64",
|
||||
"entryPoint": "mod.ts",
|
||||
"outDir": "dist/binaries",
|
||||
"target": "x86_64-pc-windows-msvc",
|
||||
"permissions": ["--allow-all"],
|
||||
"noCheck": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"@ship.zone/szci": {}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
#!/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 { arch, platform } from 'os';
|
||||
import process from 'node:process';
|
||||
|
||||
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();
|
||||
+287
@@ -1,5 +1,292 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-04-16 - 5.11.0 - feat(cli)
|
||||
show changelog entries before running upgrades
|
||||
|
||||
- fetch and render changelog entries between the installed and latest versions during the upgrade flow
|
||||
- add upgrade changelog parsing helper with tests for version filtering and grouped version ranges
|
||||
- document that the upgrade command displays release notes before installing
|
||||
|
||||
## 2026-04-16 - 5.10.0 - feat(cli,snmp)
|
||||
fix APC runtime unit defaults and add interactive action editing
|
||||
|
||||
- correct APC PowerNet runtime handling to use TimeTicks-based conversion and update default runtime unit selection for APC devices
|
||||
- add an action edit command for UPS and group actions so existing actions can be updated interactively
|
||||
- introduce a v4.3 to v4.4 config migration to correct APC runtimeUnit values in existing configurations
|
||||
|
||||
## 2026-04-16 - 5.9.0 - feat(cli,snmp)
|
||||
|
||||
fix APC runtime defaults and add interactive action editing
|
||||
|
||||
- Correct APC PowerNet runtime handling to use TimeTicks-based conversion, update runtime-unit
|
||||
defaults, and add a v4.3 to v4.4 migration for existing configs.
|
||||
- Add `nupst action edit <target-id> <index>` so UPS and group actions can be updated without
|
||||
hand-editing `config.json`.
|
||||
- Document config hot-reload behavior, APC runtime guidance, and the lack of cross-node action
|
||||
coordination when reusing configs on multiple machines.
|
||||
|
||||
## 2026-04-16 - 5.8.0 - feat(systemd)
|
||||
|
||||
improve service status reporting with structured systemctl data
|
||||
|
||||
- switch status collection from parsing `systemctl status` output to `systemctl show` properties for
|
||||
more reliable service state detection
|
||||
- display a distinct "not installed" status when the unit is missing
|
||||
- format systemd memory and CPU usage values into readable output for status details
|
||||
|
||||
## 2026-04-16 - 5.7.0 - feat(monitoring)
|
||||
|
||||
add edge-triggered threshold handling with group action orchestration and HA-aware Proxmox shutdowns
|
||||
|
||||
- Track per-action threshold entry state so threshold-based actions fire only when conditions are
|
||||
newly violated
|
||||
- Add group monitoring and threshold evaluation for redundant and non-redundant UPS groups,
|
||||
including suppression of destructive actions when members are unreachable
|
||||
- Support optional Proxmox HA stop requests for HA-managed guests and prevent duplicate Proxmox or
|
||||
host shutdown scheduling
|
||||
|
||||
## 2026-04-14 - 5.6.0 - feat(config)
|
||||
|
||||
add configurable default shutdown delay for shutdown actions
|
||||
|
||||
- introduces a top-level defaultShutdownDelay config value used by shutdown actions that do not
|
||||
define their own delay
|
||||
- applies the configured default during action execution, daemon-initiated shutdowns, CLI prompts,
|
||||
and status display output
|
||||
- preserves explicit shutdownDelay values including 0 minutes and normalizes invalid config values
|
||||
back to the built-in default
|
||||
|
||||
## 2026-04-14 - 5.5.1 - fix(cli,daemon,snmp)
|
||||
|
||||
normalize CLI argument parsing and extract daemon monitoring helpers with stronger SNMP typing
|
||||
|
||||
- Pass runtime arguments directly to the CLI in both Deno and Node entrypoints so commands and debug
|
||||
flags are parsed consistently
|
||||
- Refactor daemon logic into dedicated pause state, config watch, UPS status, monitoring, action
|
||||
orchestration, shutdown execution, and shutdown monitoring modules
|
||||
- Add explicit local typings and value coercion around net-snmp interactions to reduce untyped
|
||||
response handling
|
||||
- Update user-facing CLI guidance to use current subcommands such as "nupst ups add", "nupst ups
|
||||
edit", and "nupst service start"
|
||||
- Expand test coverage for extracted monitoring and pause-state helpers
|
||||
|
||||
## 2026-04-02 - 5.5.0 - feat(proxmox)
|
||||
|
||||
add Proxmox CLI auto-detection and interactive action setup improvements
|
||||
|
||||
- Add Proxmox action support for CLI mode using qm/pct with automatic fallback to REST API mode
|
||||
- Expose proxmoxMode configuration and update CLI wizards to auto-detect local Proxmox tools before
|
||||
prompting for API credentials
|
||||
- Expand interactive action creation to support shutdown, webhook, script, and Proxmox actions with
|
||||
improved displayed details
|
||||
- Update documentation to cover Proxmox CLI/API modes and clarify shutdown delay units in minutes
|
||||
|
||||
## 2026-03-30 - 5.4.1 - fix(deps)
|
||||
|
||||
bump tsdeno and net-snmp patch dependencies
|
||||
|
||||
- update @git.zone/tsdeno from ^1.2.0 to ^1.3.1
|
||||
- update net-snmp import from 3.26.0 to 3.26.1 in the SNMP manager
|
||||
|
||||
## 2026-03-30 - 5.4.0 - feat(snmp)
|
||||
|
||||
add configurable SNMP runtime units with v4.3 migration support
|
||||
|
||||
- Adds explicit `runtimeUnit` support for SNMP devices with `minutes`, `seconds`, and `ticks`
|
||||
options.
|
||||
- Updates runtime processing to prefer configured units over UPS model heuristics.
|
||||
- Introduces a v4.2 to v4.3 migration that populates `runtimeUnit` for existing SNMP device configs
|
||||
based on `upsModel`.
|
||||
- Extends the CLI setup and device summary output to configure and display the selected runtime
|
||||
unit.
|
||||
- Updates default config version to 4.3 and documents the new SNMP runtime unit setting in the
|
||||
README.
|
||||
|
||||
## 2026-03-18 - 5.3.3 - fix(deps)
|
||||
|
||||
add @git.zone/tsdeno as a development dependency
|
||||
|
||||
- Adds @git.zone/tsdeno@^1.2.0 to devDependencies in package.json.
|
||||
|
||||
## 2026-03-18 - 5.3.2 - fix(build)
|
||||
|
||||
replace manual release compilation workflows with tsdeno-based build configuration
|
||||
|
||||
- removes obsolete CI and npm publish workflows
|
||||
- switches the Deno compile task to use tsdeno
|
||||
- adds reusable multi-platform compile targets in npmextra.json
|
||||
- updates the release workflow to install Node.js and pnpm before building binaries
|
||||
- deletes the custom compile-all.sh script in favor of centralized build tooling
|
||||
|
||||
## 2026-03-15 - 5.3.1 - fix(cli)
|
||||
|
||||
rename the update command references to upgrade across the CLI and documentation
|
||||
|
||||
- Updates command parsing and help output to use `upgrade` instead of `update`.
|
||||
- Revises user-facing upgrade prompts in daemon, systemd, and runtime status messages.
|
||||
- Aligns README and command migration documentation with the renamed command.
|
||||
|
||||
## 2026-02-20 - 5.3.0 - feat(daemon)
|
||||
|
||||
Add UPSD (NUT) protocol support, Proxmox VM shutdown action, pause/resume monitoring, and
|
||||
network-loss/unreachable handling; bump config version to 4.2
|
||||
|
||||
- Add UPSD client (ts/upsd) and ProtocolResolver (ts/protocol) to support protocol-agnostic UPS
|
||||
queries (snmp or upsd).
|
||||
- Introduce new TProtocol and IUpsdConfig types, wire up Nupst to initialize & expose UPSD client,
|
||||
and route status requests through ProtocolResolver.
|
||||
- Add 'unreachable' TPowerStatus plus consecutiveFailures and unreachableSince tracking; mark UPS as
|
||||
unreachable after NETWORK.CONSECUTIVE_FAILURE_THRESHOLD failures and suppress shutdown actions
|
||||
while unreachable.
|
||||
- Implement pause/resume feature: PAUSE.FILE_PATH state file, CLI commands (pause/resume), daemon
|
||||
pause-state polling, auto-resume, and include pause state in HTTP API responses.
|
||||
- Add ProxmoxAction (ts/actions/proxmox-action.ts) with Proxmox API interaction, configuration
|
||||
options (token, node, timeout, force, insecure) and CLI prompts to configure proxmox actions.
|
||||
- CLI and UI updates: protocol selection when adding UPS, protocol/host shown in lists, action
|
||||
details column supports proxmox, and status displays include protocol and unreachable state.
|
||||
- Add migration MigrationV4_1ToV4_2 to set protocol:'snmp' for existing devices and bump
|
||||
config.version to '4.2'.
|
||||
- Add new constants (NETWORK, UPSD, PAUSE, PROXMOX), update package.json scripts
|
||||
(test/build/lint/format), and wire protocol support across daemon, systemd, http-server, and
|
||||
various handlers.
|
||||
|
||||
## 2026-01-29 - 5.2.4 - fix()
|
||||
|
||||
no changes
|
||||
|
||||
- No files changed in the provided git diff; no commit or version bump required.
|
||||
|
||||
## 2026-01-29 - 5.2.3 - fix(core)
|
||||
|
||||
fix lint/type issues and small refactors
|
||||
|
||||
- Add missing node:process imports in bin and scripts to ensure process is available
|
||||
- Remove unused imports and unused type imports (e.g. writeFileSync, IActionConfig) to reduce noise
|
||||
- Make some methods synchronous (service update, webhook call) to match actual usage
|
||||
- Tighten SNMP typings and linting: added deno-lint-ignore comments, renamed unused params with
|
||||
leading underscore, and use `as const` for securityLevel fallbacks
|
||||
- Improve error handling variable naming in systemd (use error instead of _error)
|
||||
- Annotate ANSI regex with deno-lint-ignore no-control-regex and remove unused color/symbol imports
|
||||
across CLI/daemon/logger
|
||||
|
||||
## 2026-01-29 - 5.2.2 - fix(core)
|
||||
|
||||
tidy formatting and minor fixes across CLI, SNMP, HTTP server, migrations and packaging
|
||||
|
||||
- Normalize import ordering and improve logger/string formatting across many CLI handlers, daemon,
|
||||
systemd, actions and tests
|
||||
- Apply formatting tidies: trailing commas, newline fixes, and more consistent multiline strings
|
||||
- Allow BaseMigration methods to return either sync or async results (shouldRun/migrate signatures
|
||||
updated)
|
||||
- Improve SNMP manager and HTTP server logging/error messages and tighten some typings (raw SNMP
|
||||
types, server error typing)
|
||||
- Small robustness and messaging improvements in npm installer and wrapper (platform/arch mapping,
|
||||
error outputs)
|
||||
- Update tests and documentation layout/formatting for readability
|
||||
|
||||
## 2026-01-29 - 5.2.1 - fix(cli(ups-handler), systemd)
|
||||
|
||||
add type guards and null checks for UPS configs; improve SNMP handling and prompts; guard version
|
||||
display
|
||||
|
||||
- Introduce a type guard ('id' in config && 'name' in config) to distinguish IUpsConfig from legacy
|
||||
INupstConfig and route fields (snmp, checkInterval, name, id) accordingly.
|
||||
- displayTestConfig now handles missing SNMP by logging 'Not configured' and returning, computes
|
||||
checkInterval/upsName/upsId correctly, and uses groups only for true UPS configs.
|
||||
- testConnection now safely derives snmpConfig for both config types, throws if SNMP is missing, and
|
||||
caps test timeout to 10s for probes.
|
||||
- Clear auth/priv credentials by setting undefined (instead of empty strings) when disabling
|
||||
security levels to avoid invalid/empty string values.
|
||||
- Expanded customOIDs to include OUTPUT_LOAD, OUTPUT_POWER, OUTPUT_VOLTAGE, OUTPUT_CURRENT with
|
||||
defaults; trim prompt input and document RFC 1628 fallbacks.
|
||||
- systemd.displayVersionInfo: guard against missing nupst (silent return) and avoid errors when
|
||||
printing version info; use ignored catch variables for clarity.
|
||||
|
||||
## 2026-01-29 - 5.2.0 - feat(core)
|
||||
|
||||
Centralize timeouts/constants, add CLI prompt helpers, and introduce webhook/script actions with
|
||||
safety and SNMP refactors
|
||||
|
||||
- Add ts/constants.ts to centralize timing, SNMP, webhook, script, shutdown and UI constants and
|
||||
replace magic numbers across the codebase
|
||||
- Introduce helpers/prompt.ts with createPrompt() and withPrompt() and refactor CLI handlers to use
|
||||
these helpers (cleaner prompt lifecycle)
|
||||
- Add webhook action support: ts/actions/webhook-action.ts, IWebhookPayload type, and export from
|
||||
ts/actions/index.ts
|
||||
- Enhance ShutdownAction safety checks (only trigger onBattery, stricter transition rules) and use
|
||||
constants/UI widths for displays
|
||||
- Refactor SNMP manager to use logger instead of console, pull SNMP defaults from constants,
|
||||
improved debug output, and add INupstAccessor interface to break circular dependency (Nupst now
|
||||
implements the interface)
|
||||
- Update many CLI and core types (stronger typing for configs/actions), expand tests and update
|
||||
README and npmextra.json to document new features
|
||||
|
||||
## 2025-11-09 - 5.1.11 - fix(readme)
|
||||
|
||||
Update README installation instructions to recommend automated installer script and clarify npm
|
||||
installation
|
||||
|
||||
- Replace the previous 'Via npm (NEW! - Recommended)' section with a clear 'Automated Installer
|
||||
Script (Recommended)' section and example curl installer.
|
||||
- Move npm installation instructions into an 'Alternative: Via npm' subsection and clarify that the
|
||||
npm package downloads the appropriate pre-compiled binary for the platform during installation.
|
||||
- Remove the 'NEW!' badge and streamline notes about binary downloads and installation methods.
|
||||
|
||||
## 2025-10-23 - 5.1.10 - fix(config)
|
||||
|
||||
Synchronize deno.json version with package.json, tidy formatting, and add local tooling settings
|
||||
|
||||
- Bumped deno.json version to 5.1.9 to match package.json/commitinfo
|
||||
- Reformatted deno.json arrays (lint, fmt, compilerOptions) for readability
|
||||
- Added .claude/settings.local.json for local development/tooling permissions (no runtime behaviour
|
||||
changes)
|
||||
|
||||
## 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
|
||||
|
||||
**MAJOR RELEASE: NUPST v4.0 is a complete rewrite powered by Deno**
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "@serve.zone/nupst",
|
||||
"version": "4.2.3",
|
||||
"version": "5.11.0",
|
||||
"exports": "./mod.ts",
|
||||
"nodeModulesDir": "auto",
|
||||
"tasks": {
|
||||
"dev": "deno run --allow-all mod.ts",
|
||||
"compile": "deno task compile:all",
|
||||
"compile:all": "bash scripts/compile-all.sh",
|
||||
"compile": "tsdeno compile",
|
||||
"test": "deno test --allow-all test/",
|
||||
"test:watch": "deno test --allow-all --watch test/",
|
||||
"check": "deno check mod.ts",
|
||||
@@ -14,7 +14,9 @@
|
||||
},
|
||||
"lint": {
|
||||
"rules": {
|
||||
"tags": ["recommended"]
|
||||
"tags": [
|
||||
"recommended"
|
||||
]
|
||||
}
|
||||
},
|
||||
"fmt": {
|
||||
@@ -25,7 +27,9 @@
|
||||
"singleQuote": true
|
||||
},
|
||||
"compilerOptions": {
|
||||
"lib": ["deno.window"],
|
||||
"lib": [
|
||||
"deno.window"
|
||||
],
|
||||
"strict": true
|
||||
},
|
||||
"imports": {
|
||||
|
||||
+34
-70
@@ -1,6 +1,6 @@
|
||||
#!/bin/bash
|
||||
|
||||
# NUPST Installer Script (v4.0+)
|
||||
# NUPST Installer Script (v5.0+)
|
||||
# Downloads and installs pre-compiled NUPST binary from Gitea releases
|
||||
#
|
||||
# Usage:
|
||||
@@ -8,7 +8,7 @@
|
||||
# curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash
|
||||
#
|
||||
# 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
|
||||
#
|
||||
# Options:
|
||||
# -h, --help Show this help message
|
||||
@@ -48,14 +48,14 @@ while [[ $# -gt 0 ]]; do
|
||||
done
|
||||
|
||||
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 ""
|
||||
echo "Usage: $0 [options]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
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 ""
|
||||
echo "Examples:"
|
||||
@@ -63,7 +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 ""
|
||||
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"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -145,7 +145,7 @@ get_latest_version() {
|
||||
|
||||
# Main installation process
|
||||
echo "================================================"
|
||||
echo " NUPST Installation Script (v4.0+)"
|
||||
echo " NUPST Installation Script (v5.0+)"
|
||||
echo "================================================"
|
||||
echo ""
|
||||
|
||||
@@ -169,51 +169,26 @@ DOWNLOAD_URL="${GITEA_BASE_URL}/${GITEA_REPO}/releases/download/${VERSION}/${BIN
|
||||
echo "Download URL: $DOWNLOAD_URL"
|
||||
echo ""
|
||||
|
||||
# Check if installation directory exists
|
||||
# Check if service is running and stop it
|
||||
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 ""
|
||||
if systemctl is-enabled --quiet nupst 2>/dev/null || systemctl is-active --quiet nupst 2>/dev/null; then
|
||||
SERVICE_WAS_RUNNING=1
|
||||
if systemctl is-active --quiet nupst 2>/dev/null; then
|
||||
echo "Stopping NUPST service..."
|
||||
systemctl stop nupst
|
||||
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
|
||||
SERVICE_WAS_RUNNING=1
|
||||
if systemctl is-active --quiet nupst 2>/dev/null; then
|
||||
echo "Stopping NUPST service..."
|
||||
systemctl stop nupst
|
||||
else
|
||||
echo "Service is installed but not currently running (will be updated)..."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Clean up old Node.js installation files
|
||||
if [ $OLD_NODE_INSTALL -eq 1 ]; then
|
||||
echo "Cleaning up old Node.js installation files..."
|
||||
rm -rf "$INSTALL_DIR/node_modules" 2>/dev/null || true
|
||||
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
|
||||
echo "Creating installation directory: $INSTALL_DIR"
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
fi
|
||||
|
||||
# Clean installation directory - ensure only binary exists
|
||||
if [ -d "$INSTALL_DIR" ]; then
|
||||
echo "Cleaning installation directory: $INSTALL_DIR"
|
||||
rm -rf "$INSTALL_DIR"
|
||||
fi
|
||||
|
||||
# Create fresh installation directory
|
||||
echo "Creating installation directory: $INSTALL_DIR"
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
|
||||
# Download binary
|
||||
echo "Downloading NUPST binary..."
|
||||
TEMP_FILE="$INSTALL_DIR/nupst.download"
|
||||
@@ -241,9 +216,20 @@ fi
|
||||
BINARY_PATH="$INSTALL_DIR/nupst"
|
||||
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
|
||||
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 ""
|
||||
|
||||
@@ -260,18 +246,10 @@ echo "Symlink created: $BIN_DIR/nupst -> $BINARY_PATH"
|
||||
|
||||
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
|
||||
if [ $SERVICE_WAS_RUNNING -eq 1 ]; then
|
||||
echo "Restarting NUPST service..."
|
||||
systemctl start nupst
|
||||
systemctl restart nupst
|
||||
echo "Service restarted successfully."
|
||||
echo ""
|
||||
fi
|
||||
@@ -280,20 +258,6 @@ echo "================================================"
|
||||
echo " NUPST Installation Complete!"
|
||||
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 " Binary location: $BINARY_PATH"
|
||||
echo " Symlink location: $BIN_DIR/nupst"
|
||||
|
||||
@@ -25,12 +25,7 @@ import { NupstCli } from './ts/cli.ts';
|
||||
*/
|
||||
async function main(): Promise<void> {
|
||||
const cli = new NupstCli();
|
||||
|
||||
// Deno.args is already 0-indexed (unlike Node's process.argv which starts at index 2)
|
||||
// We need to prepend placeholder args to match the existing CLI parser expectations
|
||||
const args = ['deno', 'mod.ts', ...Deno.args];
|
||||
|
||||
await cli.parseAndExecute(args);
|
||||
await cli.parseAndExecute(Deno.args);
|
||||
}
|
||||
|
||||
// Execute main and handle errors
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"name": "@serve.zone/nupst",
|
||||
"version": "5.11.0",
|
||||
"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": "deno task test",
|
||||
"build": "deno task check",
|
||||
"lint": "deno task lint",
|
||||
"format": "deno task fmt"
|
||||
},
|
||||
"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",
|
||||
"devDependencies": {
|
||||
"@git.zone/tsdeno": "^1.3.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@push.rocks/smartchangelog": "^0.1.0"
|
||||
}
|
||||
}
|
||||
Generated
+2355
File diff suppressed because it is too large
Load Diff
+172
@@ -0,0 +1,172 @@
|
||||
# NUPST Project Hints
|
||||
|
||||
## Recent Refactoring (January 2026)
|
||||
|
||||
### Phase 1 - Quick Wins
|
||||
|
||||
1. **Prompt Utility (`ts/helpers/prompt.ts`)**
|
||||
- Extracted readline/prompt pattern from all CLI handlers
|
||||
- Provides `createPrompt()` and `withPrompt()` helper functions
|
||||
- Used in: `ups-handler.ts`, `group-handler.ts`, `service-handler.ts`, `action-handler.ts`,
|
||||
`feature-handler.ts`
|
||||
|
||||
2. **Constants File (`ts/constants.ts`)**
|
||||
- Centralized all magic numbers (timeouts, intervals, thresholds)
|
||||
- Contains: `TIMING`, `SNMP`, `THRESHOLDS`, `WEBHOOK`, `SCRIPT`, `SHUTDOWN`, `HTTP_SERVER`, `UI`,
|
||||
`NETWORK`, `UPSD`, `PAUSE`, `PROXMOX`
|
||||
- Used in: `daemon.ts`, `snmp/manager.ts`, `actions/*.ts`, `upsd/client.ts`
|
||||
|
||||
3. **Logger Consistency**
|
||||
- Replaced all `console.log/console.error` in `snmp/manager.ts` with proper `logger.*` calls
|
||||
- Debug output uses `logger.dim()` for less intrusive output
|
||||
|
||||
### Phase 2 - Type Safety
|
||||
|
||||
4. **Circular Dependency Fix (`ts/interfaces/nupst-accessor.ts`)**
|
||||
- Created `INupstAccessor` interface to break the circular dependency between `Nupst` and
|
||||
`NupstSnmp`
|
||||
- `NupstSnmp.nupst` property now uses the interface instead of `any`
|
||||
|
||||
5. **Webhook Payload Interface (`ts/actions/webhook-action.ts`)**
|
||||
- Added `IWebhookPayload` interface for webhook action payloads
|
||||
- Exported from `ts/actions/index.ts`
|
||||
|
||||
6. **CLI Handler Type Safety**
|
||||
- Replaced `any` types in `ups-handler.ts` and `group-handler.ts` with proper interfaces
|
||||
- Uses: `IUpsConfig`, `INupstConfig`, `ISnmpConfig`, `IActionConfig`, `IThresholds`,
|
||||
`ISnmpUpsStatus`
|
||||
|
||||
7. **SNMP Manager Boundary Types (`ts/snmp/manager.ts`)**
|
||||
- Added local wrapper interfaces for the untyped `net-snmp` package surface used by NUPST
|
||||
- SNMP metric reads now coerce values explicitly instead of relying on `any`-typed responses
|
||||
|
||||
## Features Added (February 2026)
|
||||
|
||||
### Network Loss Handling
|
||||
|
||||
- `TPowerStatus` extended with `'unreachable'` state
|
||||
- `IUpsStatus` has `consecutiveFailures` and `unreachableSince` tracking
|
||||
- After `NETWORK.CONSECUTIVE_FAILURE_THRESHOLD` (3) failures, UPS transitions to `unreachable`
|
||||
- Shutdown action explicitly won't fire on `unreachable` (prevents false shutdowns)
|
||||
- Recovery is logged when UPS comes back from unreachable
|
||||
|
||||
### UPSD/NIS Protocol Support
|
||||
|
||||
- New `ts/upsd/` directory with TCP client for NUT (Network UPS Tools) servers
|
||||
- `ts/protocol/` directory with `ProtocolResolver` for protocol-agnostic status queries
|
||||
- `IUpsConfig.protocol` field: `'snmp'` (default) or `'upsd'`
|
||||
- `IUpsConfig.snmp` is now optional (not needed for UPSD devices)
|
||||
- CLI supports protocol selection during `nupst ups add`
|
||||
- Config version is now `4.3`, including the `4.2` -> `4.3` runtime unit migration
|
||||
|
||||
### Pause/Resume Command
|
||||
|
||||
- File-based signaling via `/etc/nupst/pause` JSON file
|
||||
- `nupst pause [--duration 30m|2h|1d]` creates pause file
|
||||
- `nupst resume` deletes pause file
|
||||
- `ts/pause-state.ts` owns pause snapshot parsing and transition detection for daemon polling
|
||||
- Daemon polls continue but actions are suppressed while paused
|
||||
- Auto-resume after duration expires
|
||||
- HTTP API includes pause state in response
|
||||
|
||||
### Shutdown Orchestration
|
||||
|
||||
- `ts/shutdown-executor.ts` owns command discovery and fallback execution for delayed and emergency
|
||||
shutdowns
|
||||
- `ts/daemon.ts` now delegates OS shutdown execution instead of embedding command lookup logic
|
||||
inline
|
||||
- `defaultShutdownDelay` in config provides the inherited delay for shutdown actions without an
|
||||
explicit `shutdownDelay` override
|
||||
|
||||
### Config Watch Handling
|
||||
|
||||
- `ts/config-watch.ts` owns file-watch event matching and config-reload transition analysis
|
||||
- `ts/daemon.ts` now delegates config/pause watch event classification and reload messaging
|
||||
decisions
|
||||
|
||||
### UPS Status Tracking
|
||||
|
||||
- `ts/ups-status.ts` owns the daemon UPS status shape and default status factory
|
||||
- `ts/daemon.ts` now reuses a shared initializer instead of duplicating the default UPS status
|
||||
object
|
||||
|
||||
### UPS Monitoring Transitions
|
||||
|
||||
- `ts/ups-monitoring.ts` owns pure UPS poll success/failure transition logic and threshold detection
|
||||
- `ts/daemon.ts` now orchestrates protocol calls and logging while delegating state transitions
|
||||
|
||||
### Action Orchestration
|
||||
|
||||
- `ts/action-orchestration.ts` owns action context construction and action execution decisions
|
||||
- `ts/daemon.ts` now delegates pause suppression, legacy shutdown fallback, and action context
|
||||
building
|
||||
|
||||
### Shutdown Monitoring
|
||||
|
||||
- `ts/shutdown-monitoring.ts` owns shutdown-loop row building and emergency candidate selection
|
||||
- `ts/daemon.ts` now keeps the shutdown loop orchestration while delegating row/emergency decisions
|
||||
|
||||
### Proxmox VM Shutdown Action
|
||||
|
||||
- New action type `'proxmox'` in `ts/actions/proxmox-action.ts`
|
||||
- Uses Proxmox REST API with PVEAPIToken authentication
|
||||
- Shuts down QEMU VMs and LXC containers before host shutdown
|
||||
- Supports: exclude IDs, configurable timeout, force-stop, TLS skip for self-signed certs
|
||||
- Should be placed BEFORE shutdown actions in the action chain
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
- **SNMP Manager**: Uses `INupstAccessor` interface (not direct `Nupst` reference) to avoid circular
|
||||
imports
|
||||
- **Protocol Resolver**: Routes to SNMP or UPSD based on `IUpsConfig.protocol`
|
||||
- **CLI Handlers**: All use the `helpers.withPrompt()` utility for interactive input
|
||||
- **Constants**: All timing values should be referenced from `ts/constants.ts`
|
||||
- **Actions**: Use `IActionConfig` from `ts/actions/base-action.ts` for action configuration
|
||||
- **Action orchestration**: Use helpers from `ts/action-orchestration.ts` for action context and
|
||||
execution decisions
|
||||
- **Config watch logic**: Use helpers from `ts/config-watch.ts` for file event filtering and reload
|
||||
transitions
|
||||
- **Pause state**: Use `loadPauseSnapshot()` and `IPauseState` from `ts/pause-state.ts`
|
||||
- **Shutdown execution**: Use `ShutdownExecutor` for OS-level shutdown command lookup and fallbacks
|
||||
- **Shutdown monitoring**: Use helpers from `ts/shutdown-monitoring.ts` for emergency loop rows and
|
||||
candidate selection
|
||||
- **UPS status state**: Use `IUpsStatus` and `createInitialUpsStatus()` from `ts/ups-status.ts`
|
||||
- **UPS poll transitions**: Use helpers from `ts/ups-monitoring.ts` for success/failure updates
|
||||
- **Config version**: Currently `4.3`, migrations run automatically
|
||||
|
||||
## File Organization
|
||||
|
||||
```
|
||||
ts/
|
||||
├── constants.ts # All timing/threshold constants
|
||||
├── action-orchestration.ts # Action context and execution decisions
|
||||
├── config-watch.ts # File watch filters and config reload transitions
|
||||
├── shutdown-monitoring.ts # Shutdown loop rows and emergency selection
|
||||
├── ups-monitoring.ts # Pure UPS poll transition and threshold helpers
|
||||
├── pause-state.ts # Shared pause state types and transition detection
|
||||
├── shutdown-executor.ts # Delayed/emergency shutdown command execution
|
||||
├── ups-status.ts # Daemon UPS status shape and initializer
|
||||
├── interfaces/
|
||||
│ └── nupst-accessor.ts # Interface to break circular deps
|
||||
├── helpers/
|
||||
│ ├── prompt.ts # Readline utility
|
||||
│ └── shortid.ts # ID generation
|
||||
├── actions/
|
||||
│ ├── base-action.ts # Base action class, IActionConfig, TPowerStatus
|
||||
│ ├── webhook-action.ts # Includes IWebhookPayload
|
||||
│ ├── proxmox-action.ts # Proxmox VM/LXC shutdown
|
||||
│ └── ...
|
||||
├── upsd/
|
||||
│ ├── types.ts # IUpsdConfig
|
||||
│ ├── client.ts # NupstUpsd TCP client
|
||||
│ └── index.ts
|
||||
├── protocol/
|
||||
│ ├── types.ts # TProtocol = 'snmp' | 'upsd'
|
||||
│ ├── resolver.ts # ProtocolResolver
|
||||
│ └── index.ts
|
||||
├── migrations/
|
||||
│ ├── migration-runner.ts
|
||||
│ └── migration-v4.2-to-v4.3.ts # Adds SNMP runtimeUnit defaults
|
||||
└── cli/
|
||||
└── ... # All handlers use helpers.withPrompt()
|
||||
```
|
||||
|
||||
+1
-1
@@ -195,7 +195,7 @@ nupst group edit <id> → nupst group edit <id>
|
||||
nupst group delete <id> → nupst group remove <id>
|
||||
|
||||
nupst config → nupst config show
|
||||
nupst update → nupst update
|
||||
nupst upgrade → nupst upgrade
|
||||
nupst uninstall → nupst uninstall
|
||||
nupst help → nupst help / nupst --help
|
||||
(new) → nupst --version
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Get version from deno.json
|
||||
VERSION=$(cat deno.json | grep -o '"version": *"[^"]*"' | cut -d'"' -f4)
|
||||
BINARY_DIR="dist/binaries"
|
||||
|
||||
echo "================================================"
|
||||
echo " NUPST Compilation Script"
|
||||
echo " Version: ${VERSION}"
|
||||
echo "================================================"
|
||||
echo ""
|
||||
echo "Compiling for all supported platforms..."
|
||||
echo ""
|
||||
|
||||
# Clean up old binaries and create fresh directory
|
||||
rm -rf "$BINARY_DIR"
|
||||
mkdir -p "$BINARY_DIR"
|
||||
echo "→ Cleaned old binaries from $BINARY_DIR"
|
||||
echo ""
|
||||
|
||||
# Linux x86_64
|
||||
echo "→ Compiling for Linux x86_64..."
|
||||
deno compile --allow-all --no-check --output "$BINARY_DIR/nupst-linux-x64" \
|
||||
--target x86_64-unknown-linux-gnu mod.ts
|
||||
echo " ✓ Linux x86_64 complete"
|
||||
echo ""
|
||||
|
||||
# Linux ARM64
|
||||
echo "→ Compiling for Linux ARM64..."
|
||||
deno compile --allow-all --no-check --output "$BINARY_DIR/nupst-linux-arm64" \
|
||||
--target aarch64-unknown-linux-gnu mod.ts
|
||||
echo " ✓ Linux ARM64 complete"
|
||||
echo ""
|
||||
|
||||
# macOS x86_64
|
||||
echo "→ Compiling for macOS x86_64..."
|
||||
deno compile --allow-all --no-check --output "$BINARY_DIR/nupst-macos-x64" \
|
||||
--target x86_64-apple-darwin mod.ts
|
||||
echo " ✓ macOS x86_64 complete"
|
||||
echo ""
|
||||
|
||||
# macOS ARM64
|
||||
echo "→ Compiling for macOS ARM64..."
|
||||
deno compile --allow-all --no-check --output "$BINARY_DIR/nupst-macos-arm64" \
|
||||
--target aarch64-apple-darwin mod.ts
|
||||
echo " ✓ macOS ARM64 complete"
|
||||
echo ""
|
||||
|
||||
# Windows x86_64
|
||||
echo "→ Compiling for Windows x86_64..."
|
||||
deno compile --allow-all --no-check --output "$BINARY_DIR/nupst-windows-x64.exe" \
|
||||
--target x86_64-pc-windows-msvc mod.ts
|
||||
echo " ✓ Windows x86_64 complete"
|
||||
echo ""
|
||||
|
||||
echo "================================================"
|
||||
echo " Compilation Summary"
|
||||
echo "================================================"
|
||||
echo ""
|
||||
ls -lh "$BINARY_DIR/" | tail -n +2
|
||||
echo ""
|
||||
echo "✓ All binaries compiled successfully!"
|
||||
echo ""
|
||||
echo "Binary location: $BINARY_DIR/"
|
||||
echo ""
|
||||
@@ -0,0 +1,237 @@
|
||||
#!/usr/bin/env node
|
||||
// deno-lint-ignore-file no-unused-vars
|
||||
|
||||
/**
|
||||
* NUPST npm postinstall script
|
||||
* Downloads the appropriate binary for the current platform from GitHub releases
|
||||
*/
|
||||
|
||||
import { arch, platform } from 'os';
|
||||
import { chmodSync, existsSync, mkdirSync, unlinkSync } from 'fs';
|
||||
import { dirname, join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import https from 'https';
|
||||
import { pipeline } from 'stream';
|
||||
import { promisify } from 'util';
|
||||
import { createWriteStream } from 'fs';
|
||||
import process from 'node:process';
|
||||
|
||||
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);
|
||||
});
|
||||
Binary file not shown.
@@ -1,6 +1,7 @@
|
||||
# Manual Docker Testing Scripts
|
||||
|
||||
This directory contains scripts for manually testing NUPST installation and migration in Docker containers with systemd support.
|
||||
This directory contains scripts for manually testing NUPST installation and migration in Docker
|
||||
containers with systemd support.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -15,12 +16,14 @@ This directory contains scripts for manually testing NUPST installation and migr
|
||||
Creates a Docker container with systemd and installs NUPST v3.
|
||||
|
||||
**What it does:**
|
||||
|
||||
- Creates Ubuntu 22.04 container with systemd enabled
|
||||
- Installs NUPST v3 from commit `806f81c6` (last v3 version)
|
||||
- Enables and starts the systemd service
|
||||
- Leaves container running for testing
|
||||
|
||||
**Usage:**
|
||||
|
||||
```bash
|
||||
chmod +x 01-setup-v3-container.sh
|
||||
./01-setup-v3-container.sh
|
||||
@@ -33,6 +36,7 @@ chmod +x 01-setup-v3-container.sh
|
||||
Tests the migration from v3 to v4.
|
||||
|
||||
**What it does:**
|
||||
|
||||
- Checks current v3 installation
|
||||
- Pulls v4 code from `migration/deno-v4` branch
|
||||
- Runs install.sh (should auto-detect and migrate)
|
||||
@@ -40,6 +44,7 @@ Tests the migration from v3 to v4.
|
||||
- Tests basic commands
|
||||
|
||||
**Usage:**
|
||||
|
||||
```bash
|
||||
chmod +x 02-test-v3-to-v4-migration.sh
|
||||
./02-test-v3-to-v4-migration.sh
|
||||
@@ -52,6 +57,7 @@ chmod +x 02-test-v3-to-v4-migration.sh
|
||||
Removes the test container.
|
||||
|
||||
**Usage:**
|
||||
|
||||
```bash
|
||||
chmod +x 03-cleanup.sh
|
||||
./03-cleanup.sh
|
||||
@@ -134,16 +140,19 @@ docker rm -f nupst-test-v3
|
||||
## Troubleshooting
|
||||
|
||||
### Container won't start
|
||||
|
||||
- Ensure Docker daemon is running
|
||||
- Check you have privileged access
|
||||
- Try: `docker logs nupst-test-v3`
|
||||
|
||||
### Systemd not working in container
|
||||
|
||||
- Requires Linux host (not macOS/Windows)
|
||||
- Needs `--privileged` and cgroup volume mounts
|
||||
- Check: `docker exec nupst-test-v3 systemctl --version`
|
||||
|
||||
### Migration fails
|
||||
|
||||
- Check logs: `docker exec nupst-test-v3 journalctl -xe`
|
||||
- Verify install.sh ran: `docker exec nupst-test-v3 ls -la /opt/nupst/`
|
||||
- Check service: `docker exec nupst-test-v3 systemctl status nupst`
|
||||
|
||||
+59
-30
@@ -5,8 +5,8 @@
|
||||
* 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';
|
||||
import { type ITableColumn, logger } from '../ts/logger.ts';
|
||||
import { formatPowerStatus, getBatteryColor, symbols, theme } from '../ts/colors.ts';
|
||||
|
||||
console.log('');
|
||||
console.log('═'.repeat(80));
|
||||
@@ -38,31 +38,51 @@ logger.logBoxEnd();
|
||||
|
||||
console.log('');
|
||||
|
||||
logger.logBox('Success Box (Green)', [
|
||||
'Used for successful operations',
|
||||
'Installation complete, service started, etc.',
|
||||
], 60, 'success');
|
||||
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');
|
||||
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');
|
||||
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');
|
||||
logger.logBox(
|
||||
'Info Box (Cyan)',
|
||||
[
|
||||
'Used for informational messages',
|
||||
'Version info, update available, etc.',
|
||||
],
|
||||
60,
|
||||
'info',
|
||||
);
|
||||
|
||||
console.log('');
|
||||
|
||||
@@ -112,15 +132,24 @@ 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: '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' },
|
||||
];
|
||||
|
||||
@@ -200,10 +229,10 @@ 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(`Current Version: ${theme.dim('5.5.0')}`);
|
||||
logger.logBoxLine(`Latest Version: ${theme.highlight('5.5.1')}`);
|
||||
logger.logBoxLine('');
|
||||
logger.logBoxLine(`Run ${theme.command('sudo nupst update')} to update`);
|
||||
logger.logBoxLine(`Run ${theme.command('sudo nupst upgrade')} to update`);
|
||||
logger.logBoxLine('');
|
||||
logger.logBoxEnd();
|
||||
|
||||
|
||||
+1197
-79
File diff suppressed because it is too large
Load Diff
@@ -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 = {
|
||||
name: denoConfig.name,
|
||||
version: denoConfig.version,
|
||||
description: 'Network UPS Shutdown Tool (https://nupst.serve.zone)',
|
||||
};
|
||||
name: '@serve.zone/nupst',
|
||||
version: '5.11.0',
|
||||
description: 'Network UPS Shutdown Tool - Monitor SNMP-enabled UPS devices and orchestrate graceful system shutdowns during power emergencies'
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { IActionConfig, IActionContext, TPowerStatus } from './actions/base-action.ts';
|
||||
import type { IUpsStatus } from './ups-status.ts';
|
||||
|
||||
export interface IUpsActionSource {
|
||||
id: string;
|
||||
name: string;
|
||||
actions?: IActionConfig[];
|
||||
}
|
||||
|
||||
export type TUpsTriggerReason = IActionContext['triggerReason'];
|
||||
|
||||
export type TActionExecutionDecision =
|
||||
| { type: 'suppressed'; message: string }
|
||||
| { type: 'legacyShutdown'; reason: string }
|
||||
| { type: 'skip' }
|
||||
| { type: 'execute'; actions: IActionConfig[]; context: IActionContext };
|
||||
|
||||
export function buildUpsActionContext(
|
||||
ups: IUpsActionSource,
|
||||
status: IUpsStatus,
|
||||
previousStatus: IUpsStatus | undefined,
|
||||
triggerReason: TUpsTriggerReason,
|
||||
timestamp: number = Date.now(),
|
||||
): IActionContext {
|
||||
return {
|
||||
upsId: ups.id,
|
||||
upsName: ups.name,
|
||||
powerStatus: status.powerStatus as TPowerStatus,
|
||||
batteryCapacity: status.batteryCapacity,
|
||||
batteryRuntime: status.batteryRuntime,
|
||||
previousPowerStatus: (previousStatus?.powerStatus || 'unknown') as TPowerStatus,
|
||||
timestamp,
|
||||
triggerReason,
|
||||
};
|
||||
}
|
||||
|
||||
export function applyDefaultShutdownDelay(
|
||||
actions: IActionConfig[],
|
||||
defaultDelayMinutes: number,
|
||||
): IActionConfig[] {
|
||||
return actions.map((action) => {
|
||||
if (action.type !== 'shutdown' || action.shutdownDelay !== undefined) {
|
||||
return action;
|
||||
}
|
||||
|
||||
return {
|
||||
...action,
|
||||
shutdownDelay: defaultDelayMinutes,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function decideUpsActionExecution(
|
||||
isPaused: boolean,
|
||||
ups: IUpsActionSource,
|
||||
status: IUpsStatus,
|
||||
previousStatus: IUpsStatus | undefined,
|
||||
triggerReason: TUpsTriggerReason,
|
||||
timestamp: number = Date.now(),
|
||||
): TActionExecutionDecision {
|
||||
if (isPaused) {
|
||||
return {
|
||||
type: 'suppressed',
|
||||
message: `[PAUSED] Actions suppressed for UPS ${ups.name} (trigger: ${triggerReason})`,
|
||||
};
|
||||
}
|
||||
|
||||
const actions = ups.actions || [];
|
||||
|
||||
if (actions.length === 0 && triggerReason === 'thresholdViolation') {
|
||||
return {
|
||||
type: 'legacyShutdown',
|
||||
reason: `UPS "${ups.name}" battery or runtime below threshold`,
|
||||
};
|
||||
}
|
||||
|
||||
if (actions.length === 0) {
|
||||
return { type: 'skip' };
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'execute',
|
||||
actions,
|
||||
context: buildUpsActionContext(ups, status, previousStatus, triggerReason, timestamp),
|
||||
};
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
* 2. Threshold violations (battery/runtime cross below configured thresholds)
|
||||
*/
|
||||
|
||||
export type TPowerStatus = 'online' | 'onBattery' | 'unknown';
|
||||
export type TPowerStatus = 'online' | 'onBattery' | 'unknown' | 'unreachable';
|
||||
|
||||
/**
|
||||
* Context provided to actions when they execute
|
||||
@@ -52,7 +52,7 @@ export type TActionTriggerMode =
|
||||
*/
|
||||
export interface IActionConfig {
|
||||
/** Type of action to execute */
|
||||
type: 'shutdown' | 'webhook' | 'script';
|
||||
type: 'shutdown' | 'webhook' | 'script' | 'proxmox';
|
||||
|
||||
// Trigger configuration
|
||||
/**
|
||||
@@ -74,7 +74,7 @@ export interface IActionConfig {
|
||||
};
|
||||
|
||||
// Shutdown action configuration
|
||||
/** Delay before shutdown in minutes (default: 5) */
|
||||
/** Delay before shutdown in minutes (defaults to the config-level shutdown delay, or 5) */
|
||||
shutdownDelay?: number;
|
||||
/** Only execute shutdown on threshold violation, not power status changes */
|
||||
onlyOnThresholdViolation?: boolean;
|
||||
@@ -96,6 +96,30 @@ export interface IActionConfig {
|
||||
scriptTimeout?: number;
|
||||
/** Only execute script on threshold violation */
|
||||
scriptOnlyOnThresholdViolation?: boolean;
|
||||
|
||||
// Proxmox action configuration
|
||||
/** Proxmox API host (default: localhost) */
|
||||
proxmoxHost?: string;
|
||||
/** Proxmox API port (default: 8006) */
|
||||
proxmoxPort?: number;
|
||||
/** Proxmox node name (default: auto-detect via hostname) */
|
||||
proxmoxNode?: string;
|
||||
/** Proxmox API token ID (e.g., 'root@pam!nupst') */
|
||||
proxmoxTokenId?: string;
|
||||
/** Proxmox API token secret */
|
||||
proxmoxTokenSecret?: string;
|
||||
/** VM/CT IDs to exclude from shutdown */
|
||||
proxmoxExcludeIds?: number[];
|
||||
/** Timeout for VM/CT shutdown in seconds (default: 120) */
|
||||
proxmoxStopTimeout?: number;
|
||||
/** Force-stop VMs that don't shut down gracefully (default: true) */
|
||||
proxmoxForceStop?: boolean;
|
||||
/** Skip TLS verification for self-signed certificates (default: true) */
|
||||
proxmoxInsecure?: boolean;
|
||||
/** Proxmox operation mode: 'auto' detects CLI tools, 'cli' forces CLI, 'api' forces REST API (default: 'auto') */
|
||||
proxmoxMode?: 'auto' | 'api' | 'cli';
|
||||
/** How HA-managed Proxmox resources should be stopped (default: 'none') */
|
||||
proxmoxHaPolicy?: 'none' | 'haStop';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,13 +10,16 @@ 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';
|
||||
import { ProxmoxAction } from './proxmox-action.ts';
|
||||
|
||||
// Re-export types for convenience
|
||||
export type { IActionConfig, IActionContext, TPowerStatus } from './base-action.ts';
|
||||
export type { IWebhookPayload } from './webhook-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';
|
||||
export { ProxmoxAction } from './proxmox-action.ts';
|
||||
|
||||
/**
|
||||
* ActionManager - Coordinates action creation and execution
|
||||
@@ -39,6 +42,8 @@ export class ActionManager {
|
||||
return new WebhookAction(config);
|
||||
case 'script':
|
||||
return new ScriptAction(config);
|
||||
case 'proxmox':
|
||||
return new ProxmoxAction(config);
|
||||
default:
|
||||
throw new Error(`Unknown action type: ${(config as IActionConfig).type}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,812 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import process from 'node:process';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { Action, type IActionContext } from './base-action.ts';
|
||||
import { logger } from '../logger.ts';
|
||||
import { PROXMOX, UI } from '../constants.ts';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
type TNodeLikeGlobal = typeof globalThis & {
|
||||
process?: {
|
||||
env: Record<string, string | undefined>;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* ProxmoxAction - Gracefully shuts down Proxmox VMs and LXC containers
|
||||
*
|
||||
* Supports two operation modes:
|
||||
* - CLI mode: Uses qm/pct commands directly (requires running as root on a Proxmox host)
|
||||
* - API mode: Uses the Proxmox REST API via HTTPS with API token authentication
|
||||
*
|
||||
* In 'auto' mode (default), CLI is preferred when available, falling back to API.
|
||||
*
|
||||
* This action should be placed BEFORE shutdown actions in the action chain
|
||||
* so that VMs are stopped before the host is shut down.
|
||||
*/
|
||||
export class ProxmoxAction extends Action {
|
||||
readonly type = 'proxmox';
|
||||
private static readonly activeRunKeys = new Set<string>();
|
||||
|
||||
private static findCliTool(command: string): string | null {
|
||||
for (const dir of PROXMOX.CLI_TOOL_PATHS) {
|
||||
const candidate = `${dir}/${command}`;
|
||||
try {
|
||||
if (fs.existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
} catch (_e) {
|
||||
// continue
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Proxmox CLI tools (qm, pct) are available on the system
|
||||
* Used by CLI wizards and by execute() for auto-detection
|
||||
*/
|
||||
static detectCliAvailability(): {
|
||||
available: boolean;
|
||||
qmPath: string | null;
|
||||
pctPath: string | null;
|
||||
haManagerPath: string | null;
|
||||
isRoot: boolean;
|
||||
} {
|
||||
const qmPath = this.findCliTool('qm');
|
||||
const pctPath = this.findCliTool('pct');
|
||||
const haManagerPath = this.findCliTool('ha-manager');
|
||||
|
||||
const isRoot = !!(process.getuid && process.getuid() === 0);
|
||||
|
||||
return {
|
||||
available: qmPath !== null && pctPath !== null && isRoot,
|
||||
qmPath,
|
||||
pctPath,
|
||||
haManagerPath,
|
||||
isRoot,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the operation mode based on config and environment
|
||||
*/
|
||||
private resolveMode(): { mode: 'api' | 'cli'; qmPath: string; pctPath: string } | {
|
||||
mode: 'api';
|
||||
qmPath?: undefined;
|
||||
pctPath?: undefined;
|
||||
} {
|
||||
const configuredMode = this.config.proxmoxMode || 'auto';
|
||||
|
||||
if (configuredMode === 'api') {
|
||||
return { mode: 'api' };
|
||||
}
|
||||
|
||||
const detection = ProxmoxAction.detectCliAvailability();
|
||||
|
||||
if (configuredMode === 'cli') {
|
||||
if (!detection.qmPath || !detection.pctPath) {
|
||||
throw new Error('CLI mode requested but qm/pct not found. Are you on a Proxmox host?');
|
||||
}
|
||||
if (!detection.isRoot) {
|
||||
throw new Error('CLI mode requires root access');
|
||||
}
|
||||
return { mode: 'cli', qmPath: detection.qmPath, pctPath: detection.pctPath };
|
||||
}
|
||||
|
||||
// Auto-detect
|
||||
if (detection.available && detection.qmPath && detection.pctPath) {
|
||||
return { mode: 'cli', qmPath: detection.qmPath, pctPath: detection.pctPath };
|
||||
}
|
||||
return { mode: 'api' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the Proxmox shutdown action
|
||||
*/
|
||||
async execute(context: IActionContext): Promise<void> {
|
||||
if (!this.shouldExecute(context)) {
|
||||
logger.info(
|
||||
`Proxmox action skipped (trigger mode: ${
|
||||
this.config.triggerMode || 'powerChangesAndThresholds'
|
||||
})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const resolved = this.resolveMode();
|
||||
const node = this.config.proxmoxNode || os.hostname();
|
||||
const excludeIds = new Set(this.config.proxmoxExcludeIds || []);
|
||||
const stopTimeout = (this.config.proxmoxStopTimeout || PROXMOX.DEFAULT_STOP_TIMEOUT_SECONDS) *
|
||||
1000;
|
||||
const forceStop = this.config.proxmoxForceStop !== false; // default true
|
||||
const haPolicy = this.config.proxmoxHaPolicy || 'none';
|
||||
const host = this.config.proxmoxHost || PROXMOX.DEFAULT_HOST;
|
||||
const port = this.config.proxmoxPort || PROXMOX.DEFAULT_PORT;
|
||||
const runKey = `${resolved.mode}:${node}:${
|
||||
resolved.mode === 'api' ? `${host}:${port}` : 'local'
|
||||
}`;
|
||||
|
||||
if (ProxmoxAction.activeRunKeys.has(runKey)) {
|
||||
logger.info(`Proxmox action skipped: shutdown sequence already running for node ${node}`);
|
||||
return;
|
||||
}
|
||||
|
||||
ProxmoxAction.activeRunKeys.add(runKey);
|
||||
|
||||
logger.log('');
|
||||
logger.logBoxTitle('Proxmox VM Shutdown', UI.WIDE_BOX_WIDTH, 'warning');
|
||||
logger.logBoxLine(`Mode: ${resolved.mode === 'cli' ? 'CLI (qm/pct)' : 'API (REST)'}`);
|
||||
logger.logBoxLine(`Node: ${node}`);
|
||||
logger.logBoxLine(`HA Policy: ${haPolicy}`);
|
||||
if (resolved.mode === 'api') {
|
||||
logger.logBoxLine(`API: ${host}:${port}`);
|
||||
}
|
||||
logger.logBoxLine(`UPS: ${context.upsName} (${context.powerStatus})`);
|
||||
logger.logBoxLine(`Trigger: ${context.triggerReason}`);
|
||||
if (excludeIds.size > 0) {
|
||||
logger.logBoxLine(`Excluded IDs: ${[...excludeIds].join(', ')}`);
|
||||
}
|
||||
logger.logBoxEnd();
|
||||
logger.log('');
|
||||
|
||||
try {
|
||||
let apiContext: {
|
||||
baseUrl: string;
|
||||
headers: Record<string, string>;
|
||||
insecure: boolean;
|
||||
} | null = null;
|
||||
let runningVMs: Array<{ vmid: number; name: string }>;
|
||||
let runningCTs: Array<{ vmid: number; name: string }>;
|
||||
|
||||
if (resolved.mode === 'cli') {
|
||||
runningVMs = await this.getRunningVMsCli(resolved.qmPath);
|
||||
runningCTs = await this.getRunningCTsCli(resolved.pctPath);
|
||||
} else {
|
||||
// API mode - validate token
|
||||
const tokenId = this.config.proxmoxTokenId;
|
||||
const tokenSecret = this.config.proxmoxTokenSecret;
|
||||
const insecure = this.config.proxmoxInsecure !== false;
|
||||
|
||||
if (!tokenId || !tokenSecret) {
|
||||
logger.error('Proxmox API token ID and secret are required for API mode');
|
||||
logger.error('Either provide tokens or run on a Proxmox host as root for CLI mode');
|
||||
return;
|
||||
}
|
||||
|
||||
apiContext = {
|
||||
baseUrl: `https://${host}:${port}${PROXMOX.API_BASE}`,
|
||||
headers: {
|
||||
'Authorization': `PVEAPIToken=${tokenId}=${tokenSecret}`,
|
||||
},
|
||||
insecure,
|
||||
};
|
||||
|
||||
runningVMs = await this.getRunningVMsApi(
|
||||
apiContext.baseUrl,
|
||||
node,
|
||||
apiContext.headers,
|
||||
apiContext.insecure,
|
||||
);
|
||||
runningCTs = await this.getRunningCTsApi(
|
||||
apiContext.baseUrl,
|
||||
node,
|
||||
apiContext.headers,
|
||||
apiContext.insecure,
|
||||
);
|
||||
}
|
||||
|
||||
// Filter out excluded IDs
|
||||
const vmsToStop = runningVMs.filter((vm) => !excludeIds.has(vm.vmid));
|
||||
const ctsToStop = runningCTs.filter((ct) => !excludeIds.has(ct.vmid));
|
||||
|
||||
const totalToStop = vmsToStop.length + ctsToStop.length;
|
||||
if (totalToStop === 0) {
|
||||
logger.info('No running VMs or containers to shut down');
|
||||
return;
|
||||
}
|
||||
|
||||
const haManagedResources = haPolicy === 'haStop'
|
||||
? await this.getHaManagedResources(resolved, apiContext)
|
||||
: { qemu: new Set<number>(), lxc: new Set<number>() };
|
||||
const haVmsToStop = vmsToStop.filter((vm) => haManagedResources.qemu.has(vm.vmid));
|
||||
const haCtsToStop = ctsToStop.filter((ct) => haManagedResources.lxc.has(ct.vmid));
|
||||
let directVmsToStop = vmsToStop.filter((vm) => !haManagedResources.qemu.has(vm.vmid));
|
||||
let directCtsToStop = ctsToStop.filter((ct) => !haManagedResources.lxc.has(ct.vmid));
|
||||
|
||||
logger.info(`Shutting down ${vmsToStop.length} VMs and ${ctsToStop.length} containers...`);
|
||||
|
||||
if (resolved.mode === 'cli') {
|
||||
const { haManagerPath } = ProxmoxAction.detectCliAvailability();
|
||||
if (haPolicy === 'haStop' && (haVmsToStop.length > 0 || haCtsToStop.length > 0)) {
|
||||
if (!haManagerPath) {
|
||||
logger.warn(
|
||||
'ha-manager not found, falling back to direct guest shutdown for HA-managed resources',
|
||||
);
|
||||
directVmsToStop = [...haVmsToStop, ...directVmsToStop];
|
||||
directCtsToStop = [...haCtsToStop, ...directCtsToStop];
|
||||
} else {
|
||||
for (const vm of haVmsToStop) {
|
||||
await this.requestHaStopCli(haManagerPath, `vm:${vm.vmid}`);
|
||||
logger.dim(` HA stop requested for VM ${vm.vmid} (${vm.name || 'unnamed'})`);
|
||||
}
|
||||
for (const ct of haCtsToStop) {
|
||||
await this.requestHaStopCli(haManagerPath, `ct:${ct.vmid}`);
|
||||
logger.dim(` HA stop requested for CT ${ct.vmid} (${ct.name || 'unnamed'})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const vm of directVmsToStop) {
|
||||
await this.shutdownVMCli(resolved.qmPath, vm.vmid);
|
||||
logger.dim(` Shutdown sent to VM ${vm.vmid} (${vm.name || 'unnamed'})`);
|
||||
}
|
||||
for (const ct of directCtsToStop) {
|
||||
await this.shutdownCTCli(resolved.pctPath, ct.vmid);
|
||||
logger.dim(` Shutdown sent to CT ${ct.vmid} (${ct.name || 'unnamed'})`);
|
||||
}
|
||||
} else if (apiContext) {
|
||||
for (const vm of haVmsToStop) {
|
||||
await this.requestHaStopApi(
|
||||
apiContext.baseUrl,
|
||||
`vm:${vm.vmid}`,
|
||||
apiContext.headers,
|
||||
apiContext.insecure,
|
||||
);
|
||||
logger.dim(` HA stop requested for VM ${vm.vmid} (${vm.name || 'unnamed'})`);
|
||||
}
|
||||
for (const ct of haCtsToStop) {
|
||||
await this.requestHaStopApi(
|
||||
apiContext.baseUrl,
|
||||
`ct:${ct.vmid}`,
|
||||
apiContext.headers,
|
||||
apiContext.insecure,
|
||||
);
|
||||
logger.dim(` HA stop requested for CT ${ct.vmid} (${ct.name || 'unnamed'})`);
|
||||
}
|
||||
|
||||
for (const vm of directVmsToStop) {
|
||||
await this.shutdownVMApi(
|
||||
apiContext.baseUrl,
|
||||
node,
|
||||
vm.vmid,
|
||||
apiContext.headers,
|
||||
apiContext.insecure,
|
||||
);
|
||||
logger.dim(` Shutdown sent to VM ${vm.vmid} (${vm.name || 'unnamed'})`);
|
||||
}
|
||||
for (const ct of directCtsToStop) {
|
||||
await this.shutdownCTApi(
|
||||
apiContext.baseUrl,
|
||||
node,
|
||||
ct.vmid,
|
||||
apiContext.headers,
|
||||
apiContext.insecure,
|
||||
);
|
||||
logger.dim(` Shutdown sent to CT ${ct.vmid} (${ct.name || 'unnamed'})`);
|
||||
}
|
||||
}
|
||||
|
||||
// Poll until all stopped or timeout
|
||||
const allIds = [
|
||||
...vmsToStop.map((vm) => ({ type: 'qemu' as const, vmid: vm.vmid, name: vm.name })),
|
||||
...ctsToStop.map((ct) => ({ type: 'lxc' as const, vmid: ct.vmid, name: ct.name })),
|
||||
];
|
||||
|
||||
const remaining = await this.waitForShutdown(allIds, resolved, node, stopTimeout);
|
||||
|
||||
if (remaining.length > 0 && forceStop) {
|
||||
logger.warn(`${remaining.length} VMs/CTs didn't shut down gracefully, force-stopping...`);
|
||||
for (const item of remaining) {
|
||||
try {
|
||||
if (resolved.mode === 'cli') {
|
||||
if (item.type === 'qemu') {
|
||||
await this.stopVMCli(resolved.qmPath, item.vmid);
|
||||
} else {
|
||||
await this.stopCTCli(resolved.pctPath, item.vmid);
|
||||
}
|
||||
} else if (apiContext) {
|
||||
if (item.type === 'qemu') {
|
||||
await this.stopVMApi(
|
||||
apiContext.baseUrl,
|
||||
node,
|
||||
item.vmid,
|
||||
apiContext.headers,
|
||||
apiContext.insecure,
|
||||
);
|
||||
} else {
|
||||
await this.stopCTApi(
|
||||
apiContext.baseUrl,
|
||||
node,
|
||||
item.vmid,
|
||||
apiContext.headers,
|
||||
apiContext.insecure,
|
||||
);
|
||||
}
|
||||
}
|
||||
logger.dim(` Force-stopped ${item.type} ${item.vmid} (${item.name || 'unnamed'})`);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
` Failed to force-stop ${item.type} ${item.vmid}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (remaining.length > 0) {
|
||||
logger.warn(`${remaining.length} VMs/CTs still running (force-stop disabled)`);
|
||||
}
|
||||
|
||||
logger.success('Proxmox shutdown sequence completed');
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Proxmox action failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
} finally {
|
||||
ProxmoxAction.activeRunKeys.delete(runKey);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── CLI-based methods ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get list of running QEMU VMs via qm list
|
||||
*/
|
||||
private async getRunningVMsCli(
|
||||
qmPath: string,
|
||||
): Promise<Array<{ vmid: number; name: string }>> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(qmPath, ['list']);
|
||||
return this.parseQmList(stdout);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to list VMs via CLI: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of running LXC containers via pct list
|
||||
*/
|
||||
private async getRunningCTsCli(
|
||||
pctPath: string,
|
||||
): Promise<Array<{ vmid: number; name: string }>> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(pctPath, ['list']);
|
||||
return this.parsePctList(stdout);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to list CTs via CLI: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse qm list output
|
||||
* Format: VMID NAME STATUS MEM(MB) BOOTDISK(GB) PID
|
||||
*/
|
||||
private parseQmList(output: string): Array<{ vmid: number; name: string }> {
|
||||
const results: Array<{ vmid: number; name: string }> = [];
|
||||
const lines = output.trim().split('\n');
|
||||
|
||||
// Skip header line
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const match = lines[i].match(/^\s*(\d+)\s+(\S+)\s+(running|stopped|paused)/);
|
||||
if (match && match[3] === 'running') {
|
||||
results.push({ vmid: parseInt(match[1], 10), name: match[2] });
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse pct list output
|
||||
* Format: VMID Status Lock Name
|
||||
*/
|
||||
private parsePctList(output: string): Array<{ vmid: number; name: string }> {
|
||||
const results: Array<{ vmid: number; name: string }> = [];
|
||||
const lines = output.trim().split('\n');
|
||||
|
||||
// Skip header line
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const match = lines[i].match(/^\s*(\d+)\s+(running|stopped)\s+\S*\s*(.*)/);
|
||||
if (match && match[2] === 'running') {
|
||||
results.push({ vmid: parseInt(match[1], 10), name: match[3]?.trim() || '' });
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private async shutdownVMCli(qmPath: string, vmid: number): Promise<void> {
|
||||
await execFileAsync(qmPath, ['shutdown', String(vmid)]);
|
||||
}
|
||||
|
||||
private async shutdownCTCli(pctPath: string, vmid: number): Promise<void> {
|
||||
await execFileAsync(pctPath, ['shutdown', String(vmid)]);
|
||||
}
|
||||
|
||||
private async stopVMCli(qmPath: string, vmid: number): Promise<void> {
|
||||
await execFileAsync(qmPath, ['stop', String(vmid)]);
|
||||
}
|
||||
|
||||
private async stopCTCli(pctPath: string, vmid: number): Promise<void> {
|
||||
await execFileAsync(pctPath, ['stop', String(vmid)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get VM/CT status via CLI
|
||||
* Returns the status string (e.g., 'running', 'stopped')
|
||||
*/
|
||||
private async getStatusCli(
|
||||
toolPath: string,
|
||||
vmid: number,
|
||||
): Promise<string> {
|
||||
const { stdout } = await execFileAsync(toolPath, ['status', String(vmid)]);
|
||||
// Output format: "status: running\n"
|
||||
const status = stdout.trim().split(':')[1]?.trim() || 'unknown';
|
||||
return status;
|
||||
}
|
||||
|
||||
private async getHaManagedResources(
|
||||
resolved: { mode: 'api' | 'cli'; qmPath?: string; pctPath?: string },
|
||||
apiContext: {
|
||||
baseUrl: string;
|
||||
headers: Record<string, string>;
|
||||
insecure: boolean;
|
||||
} | null,
|
||||
): Promise<{ qemu: Set<number>; lxc: Set<number> }> {
|
||||
if (resolved.mode === 'cli') {
|
||||
const { haManagerPath } = ProxmoxAction.detectCliAvailability();
|
||||
if (!haManagerPath) {
|
||||
return { qemu: new Set<number>(), lxc: new Set<number>() };
|
||||
}
|
||||
|
||||
return await this.getHaManagedResourcesCli(haManagerPath);
|
||||
}
|
||||
|
||||
if (!apiContext) {
|
||||
return { qemu: new Set<number>(), lxc: new Set<number>() };
|
||||
}
|
||||
|
||||
return await this.getHaManagedResourcesApi(
|
||||
apiContext.baseUrl,
|
||||
apiContext.headers,
|
||||
apiContext.insecure,
|
||||
);
|
||||
}
|
||||
|
||||
private async getHaManagedResourcesCli(
|
||||
haManagerPath: string,
|
||||
): Promise<{ qemu: Set<number>; lxc: Set<number> }> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(haManagerPath, ['config']);
|
||||
return this.parseHaManagerConfig(stdout);
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`Failed to list HA resources via CLI: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
return { qemu: new Set<number>(), lxc: new Set<number>() };
|
||||
}
|
||||
}
|
||||
|
||||
private parseHaManagerConfig(output: string): { qemu: Set<number>; lxc: Set<number> } {
|
||||
const resources = {
|
||||
qemu: new Set<number>(),
|
||||
lxc: new Set<number>(),
|
||||
};
|
||||
|
||||
for (const line of output.trim().split('\n')) {
|
||||
const match = line.match(/^\s*(vm|ct)\s*:\s*(\d+)\s*$/i);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const vmid = parseInt(match[2], 10);
|
||||
if (match[1].toLowerCase() === 'vm') {
|
||||
resources.qemu.add(vmid);
|
||||
} else {
|
||||
resources.lxc.add(vmid);
|
||||
}
|
||||
}
|
||||
|
||||
return resources;
|
||||
}
|
||||
|
||||
private async requestHaStopCli(haManagerPath: string, sid: string): Promise<void> {
|
||||
await execFileAsync(haManagerPath, ['set', sid, '--state', 'stopped']);
|
||||
}
|
||||
|
||||
// ─── API-based methods ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Make an API request to the Proxmox server
|
||||
*/
|
||||
private async apiRequest(
|
||||
url: string,
|
||||
method: string,
|
||||
headers: Record<string, string>,
|
||||
insecure: boolean,
|
||||
body?: URLSearchParams,
|
||||
): Promise<unknown> {
|
||||
const requestHeaders = { ...headers };
|
||||
const fetchOptions: RequestInit = {
|
||||
method,
|
||||
headers: requestHeaders,
|
||||
};
|
||||
|
||||
if (body) {
|
||||
requestHeaders['Content-Type'] = 'application/x-www-form-urlencoded;charset=UTF-8';
|
||||
fetchOptions.body = body.toString();
|
||||
}
|
||||
|
||||
// Use NODE_TLS_REJECT_UNAUTHORIZED for insecure mode (self-signed certs)
|
||||
const nodeProcess = (globalThis as TNodeLikeGlobal).process;
|
||||
if (insecure && nodeProcess?.env) {
|
||||
nodeProcess.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, fetchOptions);
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
throw new Error(`Proxmox API error ${response.status}: ${body}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} finally {
|
||||
// Restore TLS verification
|
||||
if (insecure && nodeProcess?.env) {
|
||||
nodeProcess.env.NODE_TLS_REJECT_UNAUTHORIZED = '1';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of running QEMU VMs via API
|
||||
*/
|
||||
private async getRunningVMsApi(
|
||||
baseUrl: string,
|
||||
node: string,
|
||||
headers: Record<string, string>,
|
||||
insecure: boolean,
|
||||
): Promise<Array<{ vmid: number; name: string }>> {
|
||||
try {
|
||||
const response = await this.apiRequest(
|
||||
`${baseUrl}/nodes/${node}/qemu`,
|
||||
'GET',
|
||||
headers,
|
||||
insecure,
|
||||
) as { data: Array<{ vmid: number; name: string; status: string }> };
|
||||
|
||||
return (response.data || [])
|
||||
.filter((vm) => vm.status === 'running')
|
||||
.map((vm) => ({ vmid: vm.vmid, name: vm.name || '' }));
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to list VMs: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of running LXC containers via API
|
||||
*/
|
||||
private async getRunningCTsApi(
|
||||
baseUrl: string,
|
||||
node: string,
|
||||
headers: Record<string, string>,
|
||||
insecure: boolean,
|
||||
): Promise<Array<{ vmid: number; name: string }>> {
|
||||
try {
|
||||
const response = await this.apiRequest(
|
||||
`${baseUrl}/nodes/${node}/lxc`,
|
||||
'GET',
|
||||
headers,
|
||||
insecure,
|
||||
) as { data: Array<{ vmid: number; name: string; status: string }> };
|
||||
|
||||
return (response.data || [])
|
||||
.filter((ct) => ct.status === 'running')
|
||||
.map((ct) => ({ vmid: ct.vmid, name: ct.name || '' }));
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to list CTs: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private async getHaManagedResourcesApi(
|
||||
baseUrl: string,
|
||||
headers: Record<string, string>,
|
||||
insecure: boolean,
|
||||
): Promise<{ qemu: Set<number>; lxc: Set<number> }> {
|
||||
try {
|
||||
const response = await this.apiRequest(
|
||||
`${baseUrl}/cluster/ha/resources`,
|
||||
'GET',
|
||||
headers,
|
||||
insecure,
|
||||
) as { data: Array<{ sid?: string }> };
|
||||
const resources = {
|
||||
qemu: new Set<number>(),
|
||||
lxc: new Set<number>(),
|
||||
};
|
||||
|
||||
for (const item of response.data || []) {
|
||||
const match = item.sid?.match(/^(vm|ct):(\d+)$/i);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const vmid = parseInt(match[2], 10);
|
||||
if (match[1].toLowerCase() === 'vm') {
|
||||
resources.qemu.add(vmid);
|
||||
} else {
|
||||
resources.lxc.add(vmid);
|
||||
}
|
||||
}
|
||||
|
||||
return resources;
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`Failed to list HA resources via API: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
return { qemu: new Set<number>(), lxc: new Set<number>() };
|
||||
}
|
||||
}
|
||||
|
||||
private async requestHaStopApi(
|
||||
baseUrl: string,
|
||||
sid: string,
|
||||
headers: Record<string, string>,
|
||||
insecure: boolean,
|
||||
): Promise<void> {
|
||||
await this.apiRequest(
|
||||
`${baseUrl}/cluster/ha/resources/${encodeURIComponent(sid)}`,
|
||||
'PUT',
|
||||
headers,
|
||||
insecure,
|
||||
new URLSearchParams({ state: 'stopped' }),
|
||||
);
|
||||
}
|
||||
|
||||
private async shutdownVMApi(
|
||||
baseUrl: string,
|
||||
node: string,
|
||||
vmid: number,
|
||||
headers: Record<string, string>,
|
||||
insecure: boolean,
|
||||
): Promise<void> {
|
||||
await this.apiRequest(
|
||||
`${baseUrl}/nodes/${node}/qemu/${vmid}/status/shutdown`,
|
||||
'POST',
|
||||
headers,
|
||||
insecure,
|
||||
);
|
||||
}
|
||||
|
||||
private async shutdownCTApi(
|
||||
baseUrl: string,
|
||||
node: string,
|
||||
vmid: number,
|
||||
headers: Record<string, string>,
|
||||
insecure: boolean,
|
||||
): Promise<void> {
|
||||
await this.apiRequest(
|
||||
`${baseUrl}/nodes/${node}/lxc/${vmid}/status/shutdown`,
|
||||
'POST',
|
||||
headers,
|
||||
insecure,
|
||||
);
|
||||
}
|
||||
|
||||
private async stopVMApi(
|
||||
baseUrl: string,
|
||||
node: string,
|
||||
vmid: number,
|
||||
headers: Record<string, string>,
|
||||
insecure: boolean,
|
||||
): Promise<void> {
|
||||
await this.apiRequest(
|
||||
`${baseUrl}/nodes/${node}/qemu/${vmid}/status/stop`,
|
||||
'POST',
|
||||
headers,
|
||||
insecure,
|
||||
);
|
||||
}
|
||||
|
||||
private async stopCTApi(
|
||||
baseUrl: string,
|
||||
node: string,
|
||||
vmid: number,
|
||||
headers: Record<string, string>,
|
||||
insecure: boolean,
|
||||
): Promise<void> {
|
||||
await this.apiRequest(
|
||||
`${baseUrl}/nodes/${node}/lxc/${vmid}/status/stop`,
|
||||
'POST',
|
||||
headers,
|
||||
insecure,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Shared methods ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Wait for VMs/CTs to shut down, return any that are still running after timeout
|
||||
*/
|
||||
private async waitForShutdown(
|
||||
items: Array<{ type: 'qemu' | 'lxc'; vmid: number; name: string }>,
|
||||
resolved: { mode: 'api' | 'cli'; qmPath?: string; pctPath?: string },
|
||||
node: string,
|
||||
timeout: number,
|
||||
): Promise<Array<{ type: 'qemu' | 'lxc'; vmid: number; name: string }>> {
|
||||
const startTime = Date.now();
|
||||
let remaining = [...items];
|
||||
|
||||
while (remaining.length > 0 && (Date.now() - startTime) < timeout) {
|
||||
// Wait before polling
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, PROXMOX.STATUS_POLL_INTERVAL_SECONDS * 1000)
|
||||
);
|
||||
|
||||
// Check which are still running
|
||||
const stillRunning: typeof remaining = [];
|
||||
|
||||
for (const item of remaining) {
|
||||
try {
|
||||
let status: string;
|
||||
|
||||
if (resolved.mode === 'cli') {
|
||||
const toolPath = item.type === 'qemu' ? resolved.qmPath! : resolved.pctPath!;
|
||||
status = await this.getStatusCli(toolPath, item.vmid);
|
||||
} else {
|
||||
const host = this.config.proxmoxHost || PROXMOX.DEFAULT_HOST;
|
||||
const port = this.config.proxmoxPort || PROXMOX.DEFAULT_PORT;
|
||||
const insecure = this.config.proxmoxInsecure !== false;
|
||||
const baseUrl = `https://${host}:${port}${PROXMOX.API_BASE}`;
|
||||
const headers: Record<string, string> = {
|
||||
'Authorization':
|
||||
`PVEAPIToken=${this.config.proxmoxTokenId}=${this.config.proxmoxTokenSecret}`,
|
||||
};
|
||||
const statusUrl = `${baseUrl}/nodes/${node}/${item.type}/${item.vmid}/status/current`;
|
||||
const response = await this.apiRequest(statusUrl, 'GET', headers, insecure) as {
|
||||
data: { status: string };
|
||||
};
|
||||
status = response.data?.status || 'unknown';
|
||||
}
|
||||
|
||||
if (status === 'running') {
|
||||
stillRunning.push(item);
|
||||
} else {
|
||||
logger.dim(` ${item.type} ${item.vmid} (${item.name}) stopped`);
|
||||
}
|
||||
} catch (_error) {
|
||||
// If we can't check status, assume it might still be running
|
||||
stillRunning.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
remaining = stillRunning;
|
||||
|
||||
if (remaining.length > 0) {
|
||||
const elapsed = Math.round((Date.now() - startTime) / 1000);
|
||||
logger.dim(` Waiting... ${remaining.length} still running (${elapsed}s elapsed)`);
|
||||
}
|
||||
}
|
||||
|
||||
return remaining;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
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 { Action, type IActionContext } from './base-action.ts';
|
||||
import { logger } from '../logger.ts';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
@@ -25,7 +26,11 @@ export class ScriptAction extends Action {
|
||||
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'})`);
|
||||
logger.info(
|
||||
`Script action skipped (trigger mode: ${
|
||||
this.config.triggerMode || 'powerChangesAndThresholds'
|
||||
})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
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 { Action, type IActionContext } from './base-action.ts';
|
||||
import { logger } from '../logger.ts';
|
||||
import { SHUTDOWN, UI } from '../constants.ts';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
@@ -14,6 +15,100 @@ const execFileAsync = promisify(execFile);
|
||||
*/
|
||||
export class ShutdownAction extends Action {
|
||||
readonly type = 'shutdown';
|
||||
private static scheduledDelayMinutes: number | null = null;
|
||||
|
||||
/**
|
||||
* Override shouldExecute to add shutdown-specific safety checks
|
||||
*
|
||||
* Key safety rules:
|
||||
* 1. Shutdown should NEVER trigger unless UPS is actually on battery
|
||||
* (low battery while on grid power is not an emergency - it's charging)
|
||||
* 2. For power status changes, only trigger on transitions TO onBattery from online
|
||||
* (ignore unknown → online at startup, and power restoration events)
|
||||
* 3. For threshold violations, verify UPS is on battery before acting
|
||||
*
|
||||
* @param context Action context with UPS state
|
||||
* @returns True if shutdown should execute
|
||||
*/
|
||||
protected override shouldExecute(context: IActionContext): boolean {
|
||||
const mode = this.config.triggerMode || 'powerChangesAndThresholds';
|
||||
|
||||
// CRITICAL SAFETY CHECK: Shutdown should NEVER trigger unless UPS is on battery
|
||||
// A low battery while on grid power is not an emergency (the battery is charging)
|
||||
// When UPS is unreachable, we don't know the actual state - don't trigger false shutdown
|
||||
if (context.powerStatus !== 'onBattery') {
|
||||
if (context.powerStatus === 'unreachable') {
|
||||
logger.info(
|
||||
`Shutdown action skipped: UPS is unreachable (communication failure, actual state unknown)`,
|
||||
);
|
||||
} else {
|
||||
logger.info(
|
||||
`Shutdown action skipped: UPS is not on battery (status: ${context.powerStatus})`,
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Handle threshold violations (UPS is confirmed on battery at this point)
|
||||
if (context.triggerReason === 'thresholdViolation') {
|
||||
// 'onlyPowerChanges' mode ignores thresholds
|
||||
if (mode === 'onlyPowerChanges') {
|
||||
logger.info('Shutdown action skipped: triggerMode is onlyPowerChanges, ignoring threshold');
|
||||
return false;
|
||||
}
|
||||
// Check if thresholds are actually exceeded
|
||||
return this.areThresholdsExceeded(context.batteryCapacity, context.batteryRuntime);
|
||||
}
|
||||
|
||||
// Handle power status changes
|
||||
if (context.triggerReason === 'powerStatusChange') {
|
||||
// 'onlyThresholds' mode ignores power status changes
|
||||
if (mode === 'onlyThresholds') {
|
||||
logger.info(
|
||||
'Shutdown action skipped: triggerMode is onlyThresholds, ignoring power change',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const prev = context.previousPowerStatus;
|
||||
|
||||
// Only trigger on transitions TO onBattery from online (real power loss)
|
||||
if (prev === 'online') {
|
||||
logger.info('Shutdown action triggered: power loss detected (online → onBattery)');
|
||||
return true;
|
||||
}
|
||||
|
||||
// For unknown → onBattery (daemon started while on battery):
|
||||
// This is a startup scenario - be cautious. The user may have just started
|
||||
// the daemon for testing, or the UPS may have been on battery for a while.
|
||||
// Only trigger if mode explicitly includes power changes.
|
||||
if (prev === 'unknown') {
|
||||
if (
|
||||
mode === 'onlyPowerChanges' || mode === 'powerChangesAndThresholds' ||
|
||||
mode === 'anyChange'
|
||||
) {
|
||||
logger.info(
|
||||
'Shutdown action triggered: UPS on battery at daemon startup (unknown → onBattery)',
|
||||
);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Other transitions (e.g., onBattery → onBattery) should not trigger
|
||||
logger.info(
|
||||
`Shutdown action skipped: non-emergency transition (${prev} → ${context.powerStatus})`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// For 'anyChange' mode, always execute (UPS is already confirmed on battery)
|
||||
if (mode === 'anyChange') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the shutdown action
|
||||
@@ -22,14 +117,37 @@ export class ShutdownAction extends Action {
|
||||
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'})`);
|
||||
logger.info(
|
||||
`Shutdown action skipped (trigger mode: ${
|
||||
this.config.triggerMode || 'powerChangesAndThresholds'
|
||||
})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const shutdownDelay = this.config.shutdownDelay || 5; // Default 5 minutes
|
||||
const shutdownDelay = this.config.shutdownDelay ?? SHUTDOWN.DEFAULT_DELAY_MINUTES;
|
||||
|
||||
if (
|
||||
ShutdownAction.scheduledDelayMinutes !== null &&
|
||||
ShutdownAction.scheduledDelayMinutes <= shutdownDelay
|
||||
) {
|
||||
logger.info(
|
||||
`Shutdown action skipped: shutdown already scheduled in ${ShutdownAction.scheduledDelayMinutes} minutes`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
ShutdownAction.scheduledDelayMinutes !== null &&
|
||||
ShutdownAction.scheduledDelayMinutes > shutdownDelay
|
||||
) {
|
||||
logger.warn(
|
||||
`Shutdown already scheduled in ${ShutdownAction.scheduledDelayMinutes} minutes, rescheduling to ${shutdownDelay} minutes`,
|
||||
);
|
||||
}
|
||||
|
||||
logger.log('');
|
||||
logger.logBoxTitle('Initiating System Shutdown', 60, 'error');
|
||||
logger.logBoxTitle('Initiating System Shutdown', UI.WIDE_BOX_WIDTH, 'error');
|
||||
logger.logBoxLine(`UPS: ${context.upsName} (${context.upsId})`);
|
||||
logger.logBoxLine(`Power Status: ${context.powerStatus}`);
|
||||
logger.logBoxLine(`Battery: ${context.batteryCapacity}%`);
|
||||
@@ -41,6 +159,7 @@ export class ShutdownAction extends Action {
|
||||
|
||||
try {
|
||||
await this.executeShutdownCommand(shutdownDelay);
|
||||
ShutdownAction.scheduledDelayMinutes = shutdownDelay;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Shutdown command failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
@@ -129,6 +248,7 @@ export class ShutdownAction extends Action {
|
||||
logger.log(`Trying alternative shutdown method: ${cmdPath} ${alt.args.join(' ')}`);
|
||||
await execFileAsync(cmdPath, alt.args);
|
||||
logger.log(`Alternative method ${alt.cmd} succeeded`);
|
||||
ShutdownAction.scheduledDelayMinutes = 0;
|
||||
return; // Exit if successful
|
||||
}
|
||||
} catch (_altError) {
|
||||
|
||||
@@ -1,8 +1,34 @@
|
||||
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 { Action, type IActionContext } from './base-action.ts';
|
||||
import { logger } from '../logger.ts';
|
||||
import { WEBHOOK } from '../constants.ts';
|
||||
|
||||
/**
|
||||
* Payload sent to webhook endpoints
|
||||
*/
|
||||
export interface IWebhookPayload {
|
||||
/** UPS ID */
|
||||
upsId: string;
|
||||
/** UPS name */
|
||||
upsName: string;
|
||||
/** Current power status */
|
||||
powerStatus: 'online' | 'onBattery' | 'unknown' | 'unreachable';
|
||||
/** Current battery capacity percentage */
|
||||
batteryCapacity: number;
|
||||
/** Current battery runtime in minutes */
|
||||
batteryRuntime: number;
|
||||
/** Reason this webhook was triggered */
|
||||
triggerReason: 'powerStatusChange' | 'thresholdViolation';
|
||||
/** Timestamp when webhook was triggered */
|
||||
timestamp: number;
|
||||
/** Thresholds configured for this action (if any) */
|
||||
thresholds?: {
|
||||
battery: number;
|
||||
runtime: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* WebhookAction - Calls an HTTP webhook with UPS state information
|
||||
@@ -20,7 +46,11 @@ export class WebhookAction extends Action {
|
||||
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'})`);
|
||||
logger.info(
|
||||
`Webhook action skipped (trigger mode: ${
|
||||
this.config.triggerMode || 'powerChangesAndThresholds'
|
||||
})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -30,7 +60,7 @@ export class WebhookAction extends Action {
|
||||
}
|
||||
|
||||
const method = this.config.webhookMethod || 'POST';
|
||||
const timeout = this.config.webhookTimeout || 10000;
|
||||
const timeout = this.config.webhookTimeout || WEBHOOK.DEFAULT_TIMEOUT_MS;
|
||||
|
||||
logger.info(`Calling webhook: ${method} ${this.config.webhookUrl}`);
|
||||
|
||||
@@ -51,12 +81,12 @@ export class WebhookAction extends Action {
|
||||
* @param method HTTP method (GET or POST)
|
||||
* @param timeout Request timeout in milliseconds
|
||||
*/
|
||||
private async callWebhook(
|
||||
private callWebhook(
|
||||
context: IActionContext,
|
||||
method: 'GET' | 'POST',
|
||||
timeout: number,
|
||||
): Promise<void> {
|
||||
const payload: any = {
|
||||
const payload: IWebhookPayload = {
|
||||
upsId: context.upsId,
|
||||
upsName: context.upsName,
|
||||
powerStatus: context.powerStatus,
|
||||
@@ -83,7 +113,7 @@ export class WebhookAction extends Action {
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { execSync } from 'node:child_process';
|
||||
import { Nupst } from './nupst.ts';
|
||||
import { logger, type ITableColumn } from './logger.ts';
|
||||
import { theme, symbols } from './colors.ts';
|
||||
import { type ITableColumn, logger } from './logger.ts';
|
||||
import { theme } from './colors.ts';
|
||||
|
||||
/**
|
||||
* Class for handling CLI commands
|
||||
@@ -19,15 +19,16 @@ export class NupstCli {
|
||||
|
||||
/**
|
||||
* Parse command line arguments and execute the appropriate command
|
||||
* @param args Command line arguments (process.argv)
|
||||
* @param args Command line arguments excluding runtime and script path
|
||||
*/
|
||||
public async parseAndExecute(args: string[]): Promise<void> {
|
||||
// Extract debug and version flags from any position
|
||||
const debugOptions = this.extractDebugOptions(args);
|
||||
if (debugOptions.debugMode) {
|
||||
logger.log('Debug mode enabled');
|
||||
// Enable debug mode in the SNMP client
|
||||
// Enable debug mode in both protocol clients
|
||||
this.nupst.getSnmp().enableDebug();
|
||||
this.nupst.getUpsd().enableDebug();
|
||||
}
|
||||
|
||||
// Check for version flag
|
||||
@@ -37,8 +38,8 @@ export class NupstCli {
|
||||
}
|
||||
|
||||
// Get the command (default to help if none provided)
|
||||
const command = debugOptions.cleanedArgs[2] || 'help';
|
||||
const commandArgs = debugOptions.cleanedArgs.slice(3);
|
||||
const command = debugOptions.cleanedArgs[0] || 'help';
|
||||
const commandArgs = debugOptions.cleanedArgs.slice(1);
|
||||
|
||||
// Route to the appropriate command handler
|
||||
await this.executeCommand(command, commandArgs, debugOptions.debugMode);
|
||||
@@ -72,6 +73,7 @@ export class NupstCli {
|
||||
const upsHandler = this.nupst.getUpsHandler();
|
||||
const groupHandler = this.nupst.getGroupHandler();
|
||||
const serviceHandler = this.nupst.getServiceHandler();
|
||||
const actionHandler = this.nupst.getActionHandler();
|
||||
|
||||
// Handle service subcommands
|
||||
if (command === 'service') {
|
||||
@@ -96,7 +98,7 @@ export class NupstCli {
|
||||
await serviceHandler.start();
|
||||
break;
|
||||
case 'status':
|
||||
await serviceHandler.status();
|
||||
await serviceHandler.status(debugMode);
|
||||
break;
|
||||
case 'logs':
|
||||
await serviceHandler.logs();
|
||||
@@ -126,8 +128,7 @@ export class NupstCli {
|
||||
break;
|
||||
}
|
||||
case 'remove':
|
||||
case 'rm': // Alias
|
||||
case 'delete': { // Backward compatibility
|
||||
case 'rm': {
|
||||
const upsIdToRemove = subcommandArgs[0];
|
||||
if (!upsIdToRemove) {
|
||||
logger.error('UPS ID is required for remove command');
|
||||
@@ -171,8 +172,7 @@ export class NupstCli {
|
||||
break;
|
||||
}
|
||||
case 'remove':
|
||||
case 'rm': // Alias
|
||||
case 'delete': { // Backward compatibility
|
||||
case 'rm': {
|
||||
const groupIdToRemove = subcommandArgs[0];
|
||||
if (!groupIdToRemove) {
|
||||
logger.error('Group ID is required for remove command');
|
||||
@@ -193,6 +193,61 @@ export class NupstCli {
|
||||
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 'edit': {
|
||||
const upsId = subcommandArgs[0];
|
||||
const actionIndex = subcommandArgs[1];
|
||||
await actionHandler.edit(upsId, actionIndex);
|
||||
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
|
||||
if (command === 'config') {
|
||||
const subcommand = commandArgs[0] || 'show';
|
||||
@@ -209,73 +264,15 @@ export class NupstCli {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle top-level commands and backward compatibility
|
||||
// Handle top-level commands
|
||||
switch (command) {
|
||||
// Backward compatibility - old UPS commands
|
||||
case 'add':
|
||||
logger.log("Note: 'nupst add' is deprecated. Use 'nupst ups add' instead.");
|
||||
await upsHandler.add();
|
||||
case 'pause':
|
||||
await serviceHandler.pause(commandArgs);
|
||||
break;
|
||||
case 'edit':
|
||||
logger.log("Note: 'nupst edit' is deprecated. Use 'nupst ups edit' instead.");
|
||||
await upsHandler.edit(commandArgs[0]);
|
||||
case 'resume':
|
||||
await serviceHandler.resume();
|
||||
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 'upgrade':
|
||||
await serviceHandler.update();
|
||||
break;
|
||||
case 'uninstall':
|
||||
@@ -303,10 +300,15 @@ export class NupstCli {
|
||||
try {
|
||||
await this.nupst.getDaemon().loadConfig();
|
||||
} catch (_error) {
|
||||
logger.logBox('Configuration Error', [
|
||||
'No configuration found.',
|
||||
"Please run 'nupst ups add' first to create a configuration.",
|
||||
], 50, 'error');
|
||||
logger.logBox(
|
||||
'Configuration Error',
|
||||
[
|
||||
'No configuration found.',
|
||||
"Please run 'nupst ups add' first to create a configuration.",
|
||||
],
|
||||
50,
|
||||
'error',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -316,32 +318,78 @@ export class NupstCli {
|
||||
// Check if multi-UPS config
|
||||
if (config.upsDevices && Array.isArray(config.upsDevices)) {
|
||||
// === Multi-UPS Configuration ===
|
||||
|
||||
|
||||
// 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');
|
||||
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) {
|
||||
const upsRows = config.upsDevices.map((ups) => ({
|
||||
name: ups.name,
|
||||
id: theme.dim(ups.id),
|
||||
host: `${ups.snmp.host}:${ups.snmp.port}`,
|
||||
model: ups.snmp.upsModel || 'cyberpower',
|
||||
actions: `${(ups.actions || []).length} configured`,
|
||||
groups: ups.groups.length > 0 ? ups.groups.join(', ') : theme.dim('None'),
|
||||
}));
|
||||
const upsRows = config.upsDevices.map((ups) => {
|
||||
const protocol = ups.protocol || 'snmp';
|
||||
let host = 'N/A';
|
||||
let model = '';
|
||||
if (protocol === 'upsd' && ups.upsd) {
|
||||
host = `${ups.upsd.host}:${ups.upsd.port}`;
|
||||
model = `NUT:${ups.upsd.upsName}`;
|
||||
} else if (ups.snmp) {
|
||||
host = `${ups.snmp.host}:${ups.snmp.port}`;
|
||||
model = ups.snmp.upsModel || 'cyberpower';
|
||||
}
|
||||
return {
|
||||
name: ups.name,
|
||||
id: theme.dim(ups.id),
|
||||
protocol: protocol.toUpperCase(),
|
||||
host,
|
||||
model,
|
||||
actions: `${(ups.actions || []).length} configured`,
|
||||
groups: ups.groups.length > 0 ? ups.groups.join(', ') : theme.dim('None'),
|
||||
};
|
||||
});
|
||||
|
||||
const upsColumns: ITableColumn[] = [
|
||||
{ header: 'Name', key: 'name', align: 'left', color: theme.highlight },
|
||||
{ header: 'ID', key: 'id', align: 'left' },
|
||||
{ header: 'Protocol', key: 'protocol', align: 'left' },
|
||||
{ header: 'Host:Port', key: 'host', align: 'left', color: theme.info },
|
||||
{ header: 'Model', key: 'model', align: 'left' },
|
||||
{ header: 'Actions', key: 'actions', align: 'left' },
|
||||
@@ -365,8 +413,8 @@ export class NupstCli {
|
||||
id: theme.dim(group.id),
|
||||
mode: group.mode,
|
||||
upsCount: String(upsInGroup.length),
|
||||
ups: upsInGroup.length > 0
|
||||
? upsInGroup.map((ups) => ups.name).join(', ')
|
||||
ups: upsInGroup.length > 0
|
||||
? upsInGroup.map((ups) => ups.name).join(', ')
|
||||
: theme.dim('None'),
|
||||
description: group.description || theme.dim('—'),
|
||||
};
|
||||
@@ -388,62 +436,68 @@ export class NupstCli {
|
||||
}
|
||||
} else {
|
||||
// === Legacy Single UPS Configuration ===
|
||||
|
||||
|
||||
if (!config.snmp) {
|
||||
logger.logBox('Configuration Error', [
|
||||
'Error: Legacy configuration missing SNMP settings',
|
||||
], 60, 'error');
|
||||
logger.logBox(
|
||||
'Configuration Error',
|
||||
[
|
||||
'Error: Legacy configuration missing SNMP settings',
|
||||
],
|
||||
60,
|
||||
'error',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.log('');
|
||||
logger.logBox('NUPST Configuration (Legacy)', [
|
||||
theme.warning('Legacy single-UPS configuration format'),
|
||||
'',
|
||||
theme.dim('SNMP Settings:'),
|
||||
` Host: ${theme.info(config.snmp.host)}`,
|
||||
` Port: ${theme.info(String(config.snmp.port))}`,
|
||||
` Version: ${config.snmp.version}`,
|
||||
` UPS Model: ${config.snmp.upsModel || 'cyberpower'}`,
|
||||
...(config.snmp.version === 1 || config.snmp.version === 2
|
||||
? [` Community: ${config.snmp.community}`]
|
||||
: []
|
||||
),
|
||||
...(config.snmp.version === 3
|
||||
? [
|
||||
logger.logBox(
|
||||
'NUPST Configuration (Legacy)',
|
||||
[
|
||||
theme.warning('Legacy single-UPS configuration format'),
|
||||
'',
|
||||
theme.dim('SNMP Settings:'),
|
||||
` Host: ${theme.info(config.snmp.host)}`,
|
||||
` Port: ${theme.info(String(config.snmp.port))}`,
|
||||
` Version: ${config.snmp.version}`,
|
||||
` UPS Model: ${config.snmp.upsModel || 'cyberpower'}`,
|
||||
...(config.snmp.version === 1 || config.snmp.version === 2
|
||||
? [` Community: ${config.snmp.community}`]
|
||||
: []),
|
||||
...(config.snmp.version === 3
|
||||
? [
|
||||
` Security Level: ${config.snmp.securityLevel}`,
|
||||
` Username: ${config.snmp.username}`,
|
||||
...(config.snmp.securityLevel === 'authNoPriv' || config.snmp.securityLevel === 'authPriv'
|
||||
...(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
|
||||
? [
|
||||
: []),
|
||||
...(config.snmp.upsModel === 'custom' && config.snmp.customOIDs
|
||||
? [
|
||||
theme.dim('Custom OIDs:'),
|
||||
` Power Status: ${config.snmp.customOIDs.POWER_STATUS || 'Not set'}`,
|
||||
` Battery Capacity: ${config.snmp.customOIDs.BATTERY_CAPACITY || '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');
|
||||
: []),
|
||||
'',
|
||||
|
||||
` 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',
|
||||
);
|
||||
}
|
||||
|
||||
// Service Status
|
||||
@@ -454,10 +508,15 @@ export class NupstCli {
|
||||
execSync('systemctl is-enabled nupst.service || true').toString().trim() === 'enabled';
|
||||
|
||||
logger.log('');
|
||||
logger.logBox('Service Status', [
|
||||
`Active: ${isActive ? theme.success('Yes') : theme.dim('No')}`,
|
||||
`Enabled: ${isEnabled ? theme.success('Yes') : theme.dim('No')}`,
|
||||
], 50, isActive ? 'success' : 'default');
|
||||
logger.logBox(
|
||||
'Service Status',
|
||||
[
|
||||
`Active: ${isActive ? theme.success('Yes') : theme.dim('No')}`,
|
||||
`Enabled: ${isEnabled ? theme.success('Yes') : theme.dim('No')}`,
|
||||
],
|
||||
50,
|
||||
isActive ? 'success' : 'default',
|
||||
);
|
||||
logger.log('');
|
||||
} catch (_error) {
|
||||
// Ignore errors checking service status
|
||||
@@ -499,8 +558,12 @@ export class NupstCli {
|
||||
this.printCommand('service <subcommand>', 'Manage systemd service');
|
||||
this.printCommand('ups <subcommand>', 'Manage UPS devices');
|
||||
this.printCommand('group <subcommand>', 'Manage UPS groups');
|
||||
this.printCommand('action <subcommand>', 'Manage UPS actions');
|
||||
this.printCommand('feature <subcommand>', 'Manage optional features');
|
||||
this.printCommand('config [show]', 'Display current configuration');
|
||||
this.printCommand('update', 'Update NUPST from repository', theme.dim('(requires root)'));
|
||||
this.printCommand('pause [--duration <time>]', 'Pause action monitoring');
|
||||
this.printCommand('resume', 'Resume action monitoring');
|
||||
this.printCommand('upgrade', 'Upgrade 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');
|
||||
@@ -508,8 +571,16 @@ export class NupstCli {
|
||||
|
||||
// Service subcommands
|
||||
logger.log(theme.info('Service Subcommands:'));
|
||||
this.printCommand('nupst service enable', 'Install and enable systemd service', theme.dim('(requires root)'));
|
||||
this.printCommand('nupst service disable', 'Stop and disable systemd service', theme.dim('(requires root)'));
|
||||
this.printCommand(
|
||||
'nupst service enable',
|
||||
'Install and enable systemd service',
|
||||
theme.dim('(requires root)'),
|
||||
);
|
||||
this.printCommand(
|
||||
'nupst service disable',
|
||||
'Stop and disable systemd service',
|
||||
theme.dim('(requires root)'),
|
||||
);
|
||||
this.printCommand('nupst service start', 'Start the systemd service');
|
||||
this.printCommand('nupst service stop', 'Stop the systemd service');
|
||||
this.printCommand('nupst service restart', 'Restart the systemd service');
|
||||
@@ -535,6 +606,21 @@ export class NupstCli {
|
||||
this.printCommand('nupst group list (or ls)', 'List all UPS groups');
|
||||
console.log('');
|
||||
|
||||
// Action subcommands
|
||||
logger.log(theme.info('Action Subcommands:'));
|
||||
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('');
|
||||
|
||||
// Feature subcommands
|
||||
logger.log(theme.info('Feature Subcommands:'));
|
||||
this.printCommand('nupst feature httpServer', 'Configure HTTP server for JSON status export');
|
||||
console.log('');
|
||||
|
||||
// Options
|
||||
logger.log(theme.info('Options:'));
|
||||
this.printCommand('--debug, -d', 'Enable debug mode for detailed SNMP logging');
|
||||
@@ -548,11 +634,6 @@ export class NupstCli {
|
||||
logger.dim(' nupst group list # Show all configured groups');
|
||||
logger.dim(' nupst config # Display current configuration');
|
||||
console.log('');
|
||||
|
||||
// Note about deprecated commands
|
||||
logger.warn('Note: Old command format (e.g., \'nupst add\') still works but is deprecated.');
|
||||
logger.dim(' Use the new format (e.g., \'nupst ups add\') going forward.');
|
||||
console.log('');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -639,6 +720,47 @@ Examples:
|
||||
nupst group add - Create a new group
|
||||
nupst group edit dc-1 - Edit 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
|
||||
edit <ups-id|group-id> <index> - Edit an action by index
|
||||
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 edit default 0 - Edit action at index 0 on 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
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,803 @@
|
||||
import process from 'node:process';
|
||||
import { Nupst } from '../nupst.ts';
|
||||
import { type ITableColumn, logger } from '../logger.ts';
|
||||
import { symbols, theme } from '../colors.ts';
|
||||
import type { IActionConfig } from '../actions/base-action.ts';
|
||||
import { ProxmoxAction } from '../actions/proxmox-action.ts';
|
||||
import { SHUTDOWN } from '../constants.ts';
|
||||
import type { IGroupConfig, IUpsConfig } from '../daemon.ts';
|
||||
import * as helpers from '../helpers/index.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();
|
||||
const targetSnapshot = this.resolveActionTarget(config, targetId);
|
||||
|
||||
await helpers.withPrompt(async (prompt) => {
|
||||
logger.log('');
|
||||
logger.info(
|
||||
`Add Action to ${targetSnapshot.targetType} ${
|
||||
theme.highlight(targetSnapshot.targetName)
|
||||
}`,
|
||||
);
|
||||
logger.log('');
|
||||
|
||||
const newAction = await this.promptForActionConfig(prompt);
|
||||
|
||||
// Add to target (UPS or group)
|
||||
if (!targetSnapshot.target.actions) {
|
||||
targetSnapshot.target.actions = [];
|
||||
}
|
||||
targetSnapshot.target.actions.push(newAction);
|
||||
|
||||
await this.nupst.getDaemon().saveConfig(config);
|
||||
|
||||
logger.log('');
|
||||
logger.success(`Action added to ${targetSnapshot.targetType} ${targetSnapshot.targetName}`);
|
||||
logger.log(` ${theme.dim('Changes saved and will be applied automatically')}`);
|
||||
logger.log('');
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to add action: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit an existing action on a UPS or group
|
||||
*/
|
||||
public async edit(targetId?: string, actionIndexStr?: string): Promise<void> {
|
||||
try {
|
||||
await helpers.withPrompt(async (prompt) => {
|
||||
await this.runEditProcess(targetId, actionIndexStr, prompt);
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to edit action: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the interactive process to edit an action
|
||||
*/
|
||||
public async runEditProcess(
|
||||
targetId: string | undefined,
|
||||
actionIndexStr: string | undefined,
|
||||
prompt: (question: string) => Promise<string>,
|
||||
): Promise<void> {
|
||||
if (!targetId || !actionIndexStr) {
|
||||
logger.error('Target ID and action index are required');
|
||||
logger.log(
|
||||
` ${theme.dim('Usage:')} ${
|
||||
theme.command('nupst action edit <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();
|
||||
const targetSnapshot = this.resolveActionTarget(config, targetId);
|
||||
|
||||
if (!targetSnapshot.target.actions || targetSnapshot.target.actions.length === 0) {
|
||||
logger.error(
|
||||
`No actions configured for ${targetSnapshot.targetType} '${targetSnapshot.targetName}'`,
|
||||
);
|
||||
logger.log('');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (actionIndex >= targetSnapshot.target.actions.length) {
|
||||
logger.error(
|
||||
`Invalid action index. ${targetSnapshot.targetType} '${targetSnapshot.targetName}' has ${targetSnapshot.target.actions.length} action(s) (index 0-${
|
||||
targetSnapshot.target.actions.length - 1
|
||||
})`,
|
||||
);
|
||||
logger.log('');
|
||||
logger.log(
|
||||
` ${theme.dim('List actions:')} ${theme.command(`nupst action list ${targetId}`)}`,
|
||||
);
|
||||
logger.log('');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const currentAction = targetSnapshot.target.actions[actionIndex];
|
||||
|
||||
logger.log('');
|
||||
logger.info(
|
||||
`Edit Action ${theme.highlight(String(actionIndex))} on ${targetSnapshot.targetType} ${
|
||||
theme.highlight(targetSnapshot.targetName)
|
||||
}`,
|
||||
);
|
||||
logger.log(` ${theme.dim('Current type:')} ${theme.highlight(currentAction.type)}`);
|
||||
logger.log('');
|
||||
|
||||
const updatedAction = await this.promptForActionConfig(prompt, currentAction);
|
||||
targetSnapshot.target.actions[actionIndex] = updatedAction;
|
||||
|
||||
await this.nupst.getDaemon().saveConfig(config);
|
||||
|
||||
logger.log('');
|
||||
logger.success(`Action updated on ${targetSnapshot.targetType} ${targetSnapshot.targetName}`);
|
||||
logger.log(` ${theme.dim('Index:')} ${actionIndex}`);
|
||||
logger.log(` ${theme.dim('Type:')} ${updatedAction.type}`);
|
||||
logger.log(` ${theme.dim('Changes saved and will be applied automatically')}`);
|
||||
logger.log('');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
|
||||
private resolveActionTarget(
|
||||
config: { upsDevices: IUpsConfig[]; groups?: IGroupConfig[] },
|
||||
targetId: string,
|
||||
): { target: IUpsConfig | IGroupConfig; targetType: 'UPS' | 'Group'; targetName: string } {
|
||||
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);
|
||||
}
|
||||
|
||||
return {
|
||||
target: (ups || group)!,
|
||||
targetType: ups ? 'UPS' : 'Group',
|
||||
targetName: ups ? ups.name : group!.name,
|
||||
};
|
||||
}
|
||||
|
||||
private isClearInput(input: string): boolean {
|
||||
return input.trim().toLowerCase() === 'clear';
|
||||
}
|
||||
|
||||
private getActionTypeValue(action?: IActionConfig): number {
|
||||
switch (action?.type) {
|
||||
case 'webhook':
|
||||
return 2;
|
||||
case 'script':
|
||||
return 3;
|
||||
case 'proxmox':
|
||||
return 4;
|
||||
case 'shutdown':
|
||||
default:
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
private getTriggerModeValue(action?: IActionConfig): number {
|
||||
switch (action?.triggerMode) {
|
||||
case 'onlyPowerChanges':
|
||||
return 1;
|
||||
case 'powerChangesAndThresholds':
|
||||
return 3;
|
||||
case 'anyChange':
|
||||
return 4;
|
||||
case 'onlyThresholds':
|
||||
default:
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
private async promptForActionConfig(
|
||||
prompt: (question: string) => Promise<string>,
|
||||
existingAction?: IActionConfig,
|
||||
): Promise<IActionConfig> {
|
||||
logger.log(` ${theme.dim('Action Type:')}`);
|
||||
logger.log(` ${theme.dim('1)')} Shutdown (system shutdown)`);
|
||||
logger.log(` ${theme.dim('2)')} Webhook (HTTP notification)`);
|
||||
logger.log(` ${theme.dim('3)')} Custom Script (run .sh file from /etc/nupst)`);
|
||||
logger.log(
|
||||
` ${theme.dim('4)')} Proxmox (gracefully shut down VMs/LXCs before host shutdown)`,
|
||||
);
|
||||
|
||||
const defaultTypeValue = this.getActionTypeValue(existingAction);
|
||||
const typeInput = await prompt(
|
||||
` ${theme.dim('Select action type')} ${theme.dim(`[${defaultTypeValue}]:`)} `,
|
||||
);
|
||||
const typeValue = parseInt(typeInput, 10) || defaultTypeValue;
|
||||
const newAction: Partial<IActionConfig> = {};
|
||||
|
||||
if (typeValue === 1) {
|
||||
const shutdownAction = existingAction?.type === 'shutdown' ? existingAction : undefined;
|
||||
const defaultShutdownDelay = this.nupst.getDaemon().getConfig().defaultShutdownDelay ??
|
||||
SHUTDOWN.DEFAULT_DELAY_MINUTES;
|
||||
|
||||
newAction.type = 'shutdown';
|
||||
|
||||
const delayPrompt = shutdownAction?.shutdownDelay !== undefined
|
||||
? ` ${theme.dim('Shutdown delay')} ${
|
||||
theme.dim(
|
||||
`(minutes, 'clear' = default ${defaultShutdownDelay}) [${shutdownAction.shutdownDelay}]:`,
|
||||
)
|
||||
} `
|
||||
: ` ${theme.dim('Shutdown delay')} ${
|
||||
theme.dim(`(minutes, leave empty for default ${defaultShutdownDelay}):`)
|
||||
} `;
|
||||
const delayInput = await prompt(delayPrompt);
|
||||
if (this.isClearInput(delayInput)) {
|
||||
// Leave unset so the config-level default is used.
|
||||
} else if (delayInput.trim()) {
|
||||
const shutdownDelay = parseInt(delayInput, 10);
|
||||
if (isNaN(shutdownDelay) || shutdownDelay < 0) {
|
||||
logger.error('Invalid shutdown delay. Must be >= 0.');
|
||||
process.exit(1);
|
||||
}
|
||||
newAction.shutdownDelay = shutdownDelay;
|
||||
} else if (shutdownAction?.shutdownDelay !== undefined) {
|
||||
newAction.shutdownDelay = shutdownAction.shutdownDelay;
|
||||
}
|
||||
} else if (typeValue === 2) {
|
||||
const webhookAction = existingAction?.type === 'webhook' ? existingAction : undefined;
|
||||
newAction.type = 'webhook';
|
||||
|
||||
const webhookUrlInput = await prompt(
|
||||
` ${theme.dim('Webhook URL')} ${
|
||||
theme.dim(webhookAction?.webhookUrl ? `[${webhookAction.webhookUrl}]:` : ':')
|
||||
} `,
|
||||
);
|
||||
const webhookUrl = webhookUrlInput.trim() || webhookAction?.webhookUrl || '';
|
||||
if (!webhookUrl) {
|
||||
logger.error('Webhook URL is required.');
|
||||
process.exit(1);
|
||||
}
|
||||
newAction.webhookUrl = webhookUrl;
|
||||
|
||||
logger.log('');
|
||||
logger.log(` ${theme.dim('HTTP Method:')}`);
|
||||
logger.log(` ${theme.dim('1)')} POST (JSON body)`);
|
||||
logger.log(` ${theme.dim('2)')} GET (query parameters)`);
|
||||
const defaultMethodValue = webhookAction?.webhookMethod === 'GET' ? 2 : 1;
|
||||
const methodInput = await prompt(
|
||||
` ${theme.dim('Select method')} ${theme.dim(`[${defaultMethodValue}]:`)} `,
|
||||
);
|
||||
const methodValue = parseInt(methodInput, 10) || defaultMethodValue;
|
||||
newAction.webhookMethod = methodValue === 2 ? 'GET' : 'POST';
|
||||
|
||||
const currentWebhookTimeout = webhookAction?.webhookTimeout;
|
||||
const timeoutPrompt = currentWebhookTimeout !== undefined
|
||||
? ` ${theme.dim('Timeout in seconds')} ${
|
||||
theme.dim(`('clear' to unset) [${Math.floor(currentWebhookTimeout / 1000)}]:`)
|
||||
} `
|
||||
: ` ${theme.dim('Timeout in seconds')} ${theme.dim('[10]:')} `;
|
||||
const timeoutInput = await prompt(timeoutPrompt);
|
||||
if (this.isClearInput(timeoutInput)) {
|
||||
// Leave unset.
|
||||
} else if (timeoutInput.trim()) {
|
||||
const timeout = parseInt(timeoutInput, 10);
|
||||
if (isNaN(timeout) || timeout < 0) {
|
||||
logger.error('Invalid webhook timeout. Must be >= 0.');
|
||||
process.exit(1);
|
||||
}
|
||||
newAction.webhookTimeout = timeout * 1000;
|
||||
} else if (currentWebhookTimeout !== undefined) {
|
||||
newAction.webhookTimeout = currentWebhookTimeout;
|
||||
}
|
||||
} else if (typeValue === 3) {
|
||||
const scriptAction = existingAction?.type === 'script' ? existingAction : undefined;
|
||||
newAction.type = 'script';
|
||||
|
||||
const scriptPathInput = await prompt(
|
||||
` ${theme.dim('Script filename (in /etc/nupst/, must end with .sh)')} ${
|
||||
theme.dim(scriptAction?.scriptPath ? `[${scriptAction.scriptPath}]:` : ':')
|
||||
} `,
|
||||
);
|
||||
const scriptPath = scriptPathInput.trim() || scriptAction?.scriptPath || '';
|
||||
if (!scriptPath || !scriptPath.endsWith('.sh')) {
|
||||
logger.error('Script path must end with .sh.');
|
||||
process.exit(1);
|
||||
}
|
||||
newAction.scriptPath = scriptPath;
|
||||
|
||||
const currentScriptTimeout = scriptAction?.scriptTimeout;
|
||||
const timeoutPrompt = currentScriptTimeout !== undefined
|
||||
? ` ${theme.dim('Script timeout in seconds')} ${
|
||||
theme.dim(`('clear' to unset) [${Math.floor(currentScriptTimeout / 1000)}]:`)
|
||||
} `
|
||||
: ` ${theme.dim('Script timeout in seconds')} ${theme.dim('[60]:')} `;
|
||||
const timeoutInput = await prompt(timeoutPrompt);
|
||||
if (this.isClearInput(timeoutInput)) {
|
||||
// Leave unset.
|
||||
} else if (timeoutInput.trim()) {
|
||||
const timeout = parseInt(timeoutInput, 10);
|
||||
if (isNaN(timeout) || timeout < 0) {
|
||||
logger.error('Invalid script timeout. Must be >= 0.');
|
||||
process.exit(1);
|
||||
}
|
||||
newAction.scriptTimeout = timeout * 1000;
|
||||
} else if (currentScriptTimeout !== undefined) {
|
||||
newAction.scriptTimeout = currentScriptTimeout;
|
||||
}
|
||||
} else if (typeValue === 4) {
|
||||
const proxmoxAction = existingAction?.type === 'proxmox' ? existingAction : undefined;
|
||||
const detection = ProxmoxAction.detectCliAvailability();
|
||||
let useApiMode = false;
|
||||
|
||||
newAction.type = 'proxmox';
|
||||
|
||||
if (detection.available) {
|
||||
logger.log('');
|
||||
logger.success('Proxmox CLI tools detected (qm/pct).');
|
||||
logger.dim(` qm: ${detection.qmPath}`);
|
||||
logger.dim(` pct: ${detection.pctPath}`);
|
||||
|
||||
if (proxmoxAction) {
|
||||
logger.log('');
|
||||
logger.log(` ${theme.dim('Proxmox mode:')}`);
|
||||
logger.log(` ${theme.dim('1)')} CLI (local qm/pct tools)`);
|
||||
logger.log(` ${theme.dim('2)')} API (REST token authentication)`);
|
||||
const defaultModeValue = proxmoxAction.proxmoxMode === 'api' ? 2 : 1;
|
||||
const modeInput = await prompt(
|
||||
` ${theme.dim('Select Proxmox mode')} ${theme.dim(`[${defaultModeValue}]:`)} `,
|
||||
);
|
||||
const modeValue = parseInt(modeInput, 10) || defaultModeValue;
|
||||
useApiMode = modeValue === 2;
|
||||
}
|
||||
} else {
|
||||
logger.log('');
|
||||
if (!detection.isRoot) {
|
||||
logger.warn('Not running as root - CLI mode unavailable, using API mode.');
|
||||
} else {
|
||||
logger.warn('Proxmox CLI tools (qm/pct) not found - using API mode.');
|
||||
}
|
||||
useApiMode = true;
|
||||
}
|
||||
|
||||
if (useApiMode) {
|
||||
logger.log('');
|
||||
logger.info('Proxmox API Settings:');
|
||||
logger.dim('Create a token with: pveum user token add root@pam nupst --privsep=0');
|
||||
|
||||
const currentHost = proxmoxAction?.proxmoxHost || 'localhost';
|
||||
const pxHost = await prompt(
|
||||
` ${theme.dim('Proxmox Host')} ${theme.dim(`[${currentHost}]:`)} `,
|
||||
);
|
||||
newAction.proxmoxHost = pxHost.trim() || currentHost;
|
||||
|
||||
const currentPort = proxmoxAction?.proxmoxPort || 8006;
|
||||
const pxPortInput = await prompt(
|
||||
` ${theme.dim('Proxmox API Port')} ${theme.dim(`[${currentPort}]:`)} `,
|
||||
);
|
||||
const pxPort = parseInt(pxPortInput, 10);
|
||||
newAction.proxmoxPort = pxPortInput.trim() && !isNaN(pxPort) ? pxPort : currentPort;
|
||||
|
||||
const pxNodePrompt = proxmoxAction?.proxmoxNode
|
||||
? ` ${theme.dim('Proxmox Node Name')} ${
|
||||
theme.dim(`('clear' = auto-detect) [${proxmoxAction.proxmoxNode}]:`)
|
||||
} `
|
||||
: ` ${theme.dim('Proxmox Node Name')} ${theme.dim('(empty = auto-detect):')} `;
|
||||
const pxNode = await prompt(pxNodePrompt);
|
||||
if (this.isClearInput(pxNode)) {
|
||||
// Leave unset so hostname auto-detection is used.
|
||||
} else if (pxNode.trim()) {
|
||||
newAction.proxmoxNode = pxNode.trim();
|
||||
} else if (proxmoxAction?.proxmoxNode) {
|
||||
newAction.proxmoxNode = proxmoxAction.proxmoxNode;
|
||||
}
|
||||
|
||||
const currentTokenId = proxmoxAction?.proxmoxTokenId || '';
|
||||
const tokenIdInput = await prompt(
|
||||
` ${theme.dim('API Token ID (e.g., root@pam!nupst)')} ${
|
||||
theme.dim(currentTokenId ? `[${currentTokenId}]:` : ':')
|
||||
} `,
|
||||
);
|
||||
const tokenId = tokenIdInput.trim() || currentTokenId;
|
||||
if (!tokenId) {
|
||||
logger.error('Token ID is required for API mode.');
|
||||
process.exit(1);
|
||||
}
|
||||
newAction.proxmoxTokenId = tokenId;
|
||||
|
||||
const currentTokenSecret = proxmoxAction?.proxmoxTokenSecret || '';
|
||||
const tokenSecretInput = await prompt(
|
||||
` ${theme.dim('API Token Secret')} ${theme.dim(currentTokenSecret ? '[*****]:' : ':')} `,
|
||||
);
|
||||
const tokenSecret = tokenSecretInput.trim() || currentTokenSecret;
|
||||
if (!tokenSecret) {
|
||||
logger.error('Token Secret is required for API mode.');
|
||||
process.exit(1);
|
||||
}
|
||||
newAction.proxmoxTokenSecret = tokenSecret;
|
||||
|
||||
const defaultInsecure = proxmoxAction?.proxmoxInsecure !== false;
|
||||
const insecureInput = await prompt(
|
||||
` ${theme.dim('Skip TLS verification (self-signed cert)?')} ${
|
||||
theme.dim(defaultInsecure ? '(Y/n):' : '(y/N):')
|
||||
} `,
|
||||
);
|
||||
newAction.proxmoxInsecure = insecureInput.trim()
|
||||
? insecureInput.toLowerCase() !== 'n'
|
||||
: defaultInsecure;
|
||||
newAction.proxmoxMode = 'api';
|
||||
} else {
|
||||
newAction.proxmoxMode = 'cli';
|
||||
}
|
||||
|
||||
const currentExcludeIds = proxmoxAction?.proxmoxExcludeIds || [];
|
||||
const excludePrompt = currentExcludeIds.length > 0
|
||||
? ` ${theme.dim('VM/CT IDs to exclude')} ${
|
||||
theme.dim(`(comma-separated, 'clear' = none) [${currentExcludeIds.join(',')}]:`)
|
||||
} `
|
||||
: ` ${theme.dim('VM/CT IDs to exclude (comma-separated, or empty):')} `;
|
||||
const excludeInput = await prompt(excludePrompt);
|
||||
if (this.isClearInput(excludeInput)) {
|
||||
newAction.proxmoxExcludeIds = [];
|
||||
} else if (excludeInput.trim()) {
|
||||
newAction.proxmoxExcludeIds = excludeInput.split(',').map((s) => parseInt(s.trim(), 10))
|
||||
.filter((n) => !isNaN(n));
|
||||
} else if (currentExcludeIds.length > 0) {
|
||||
newAction.proxmoxExcludeIds = [...currentExcludeIds];
|
||||
}
|
||||
|
||||
const currentStopTimeout = proxmoxAction?.proxmoxStopTimeout;
|
||||
const stopTimeoutPrompt = currentStopTimeout !== undefined
|
||||
? ` ${theme.dim('VM shutdown timeout in seconds')} ${
|
||||
theme.dim(`('clear' to unset) [${currentStopTimeout}]:`)
|
||||
} `
|
||||
: ` ${theme.dim('VM shutdown timeout in seconds')} ${theme.dim('[120]:')} `;
|
||||
const timeoutInput = await prompt(stopTimeoutPrompt);
|
||||
if (this.isClearInput(timeoutInput)) {
|
||||
// Leave unset.
|
||||
} else if (timeoutInput.trim()) {
|
||||
const stopTimeout = parseInt(timeoutInput, 10);
|
||||
if (isNaN(stopTimeout) || stopTimeout < 0) {
|
||||
logger.error('Invalid VM shutdown timeout. Must be >= 0.');
|
||||
process.exit(1);
|
||||
}
|
||||
newAction.proxmoxStopTimeout = stopTimeout;
|
||||
} else if (currentStopTimeout !== undefined) {
|
||||
newAction.proxmoxStopTimeout = currentStopTimeout;
|
||||
}
|
||||
|
||||
const defaultForceStop = proxmoxAction?.proxmoxForceStop !== false;
|
||||
const forceInput = await prompt(
|
||||
` ${theme.dim("Force-stop VMs that don't shut down in time?")} ${
|
||||
theme.dim(defaultForceStop ? '(Y/n):' : '(y/N):')
|
||||
} `,
|
||||
);
|
||||
newAction.proxmoxForceStop = forceInput.trim()
|
||||
? forceInput.toLowerCase() !== 'n'
|
||||
: defaultForceStop;
|
||||
|
||||
const defaultHaPolicyValue = proxmoxAction?.proxmoxHaPolicy === 'haStop' ? 2 : 1;
|
||||
const haPolicyInput = await prompt(
|
||||
` ${theme.dim('HA-managed guest handling')} ${
|
||||
theme.dim(`([1] none, 2 haStop) [${defaultHaPolicyValue}]:`)
|
||||
} `,
|
||||
);
|
||||
const haPolicyValue = parseInt(haPolicyInput, 10) || defaultHaPolicyValue;
|
||||
newAction.proxmoxHaPolicy = haPolicyValue === 2 ? 'haStop' : 'none';
|
||||
} else {
|
||||
logger.error('Invalid action type.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
logger.log('');
|
||||
const defaultBatteryThreshold = existingAction?.thresholds?.battery ?? 60;
|
||||
const batteryInput = await prompt(
|
||||
` ${theme.dim('Battery threshold')} ${theme.dim(`(%) [${defaultBatteryThreshold}]:`)} `,
|
||||
);
|
||||
const battery = batteryInput.trim() ? parseInt(batteryInput, 10) : defaultBatteryThreshold;
|
||||
if (isNaN(battery) || battery < 0 || battery > 100) {
|
||||
logger.error('Invalid battery threshold. Must be 0-100.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const defaultRuntimeThreshold = existingAction?.thresholds?.runtime ?? 20;
|
||||
const runtimeInput = await prompt(
|
||||
` ${theme.dim('Runtime threshold')} ${
|
||||
theme.dim(`(minutes) [${defaultRuntimeThreshold}]:`)
|
||||
} `,
|
||||
);
|
||||
const runtime = runtimeInput.trim() ? parseInt(runtimeInput, 10) : defaultRuntimeThreshold;
|
||||
if (isNaN(runtime) || runtime < 0) {
|
||||
logger.error('Invalid runtime threshold. Must be >= 0.');
|
||||
process.exit(1);
|
||||
}
|
||||
newAction.thresholds = { battery, runtime };
|
||||
|
||||
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 defaultTriggerValue = this.getTriggerModeValue(existingAction);
|
||||
const triggerChoice = await prompt(
|
||||
` ${theme.dim('Choice')} ${theme.dim(`[${defaultTriggerValue}]:`)} `,
|
||||
);
|
||||
const triggerValue = parseInt(triggerChoice, 10) || defaultTriggerValue;
|
||||
const triggerModeMap: Record<number, NonNullable<IActionConfig['triggerMode']>> = {
|
||||
1: 'onlyPowerChanges',
|
||||
2: 'onlyThresholds',
|
||||
3: 'powerChangesAndThresholds',
|
||||
4: 'anyChange',
|
||||
};
|
||||
newAction.triggerMode = triggerModeMap[triggerValue] || 'onlyThresholds';
|
||||
|
||||
return newAction as IActionConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: 'Details', key: 'details', align: 'left' },
|
||||
];
|
||||
|
||||
const rows = target.actions.map((action, index) => {
|
||||
const defaultShutdownDelay = this.nupst.getDaemon().getConfig().defaultShutdownDelay ??
|
||||
SHUTDOWN.DEFAULT_DELAY_MINUTES;
|
||||
let details = `${action.shutdownDelay ?? defaultShutdownDelay}min delay`;
|
||||
if (action.type === 'proxmox') {
|
||||
const mode = action.proxmoxMode || 'auto';
|
||||
if (mode === 'cli' || (mode === 'auto' && !action.proxmoxTokenId)) {
|
||||
details = 'CLI mode';
|
||||
} else {
|
||||
const host = action.proxmoxHost || 'localhost';
|
||||
const port = action.proxmoxPort || 8006;
|
||||
details = `API ${host}:${port}`;
|
||||
}
|
||||
if (action.proxmoxExcludeIds?.length) {
|
||||
details += `, excl: ${action.proxmoxExcludeIds.join(',')}`;
|
||||
}
|
||||
if (action.proxmoxHaPolicy === 'haStop') {
|
||||
details += ', haStop';
|
||||
}
|
||||
} else if (action.type === 'webhook') {
|
||||
details = action.webhookUrl || theme.dim('N/A');
|
||||
} else if (action.type === 'script') {
|
||||
details = action.scriptPath || theme.dim('N/A');
|
||||
}
|
||||
|
||||
return {
|
||||
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'),
|
||||
details,
|
||||
};
|
||||
});
|
||||
|
||||
logger.logTable(columns, rows);
|
||||
logger.log('');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
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 {
|
||||
await helpers.withPrompt(async (prompt) => {
|
||||
await this.runHttpServerConfig(prompt);
|
||||
});
|
||||
} 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 { prompt, close } = await helpers.createPrompt();
|
||||
const answer = await prompt('Service is running. Restart to apply changes? (Y/n): ');
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
+53
-87
@@ -1,9 +1,8 @@
|
||||
import process from 'node:process';
|
||||
import { Nupst } from '../nupst.ts';
|
||||
import { logger, type ITableColumn } from '../logger.ts';
|
||||
import { type ITableColumn, logger } from '../logger.ts';
|
||||
import { theme } from '../colors.ts';
|
||||
import * as helpers from '../helpers/index.ts';
|
||||
import { type IGroupConfig } from '../daemon.ts';
|
||||
import type { IGroupConfig, INupstConfig, IUpsConfig } from '../daemon.ts';
|
||||
|
||||
/**
|
||||
* Class for handling group-related CLI commands
|
||||
@@ -29,10 +28,15 @@ export class GroupHandler {
|
||||
try {
|
||||
await this.nupst.getDaemon().loadConfig();
|
||||
} catch (error) {
|
||||
logger.logBox('Configuration Error', [
|
||||
'No configuration found.',
|
||||
"Please run 'nupst ups add' first to create a configuration.",
|
||||
], 50, 'error');
|
||||
logger.logBox(
|
||||
'Configuration Error',
|
||||
[
|
||||
'No configuration found.',
|
||||
"Please run 'nupst ups add' first to create a configuration.",
|
||||
],
|
||||
50,
|
||||
'error',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -41,21 +45,35 @@ export class GroupHandler {
|
||||
|
||||
// Check if multi-UPS config
|
||||
if (!config.groups || !Array.isArray(config.groups)) {
|
||||
logger.logBox('UPS Groups', [
|
||||
'No groups configured.',
|
||||
'',
|
||||
`${theme.dim('Run')} ${theme.command('nupst group add')} ${theme.dim('to add a group')}`,
|
||||
], 50, 'info');
|
||||
logger.logBox(
|
||||
'UPS Groups',
|
||||
[
|
||||
'No groups configured.',
|
||||
'',
|
||||
`${theme.dim('Run')} ${theme.command('nupst group add')} ${
|
||||
theme.dim('to add a group')
|
||||
}`,
|
||||
],
|
||||
50,
|
||||
'info',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Display group list with modern table
|
||||
if (config.groups.length === 0) {
|
||||
logger.logBox('UPS Groups', [
|
||||
'No UPS groups configured.',
|
||||
'',
|
||||
`${theme.dim('Run')} ${theme.command('nupst group add')} ${theme.dim('to add a group')}`,
|
||||
], 60, 'info');
|
||||
logger.logBox(
|
||||
'UPS Groups',
|
||||
[
|
||||
'No UPS groups configured.',
|
||||
'',
|
||||
`${theme.dim('Run')} ${theme.command('nupst group add')} ${
|
||||
theme.dim('to add a group')
|
||||
}`,
|
||||
],
|
||||
60,
|
||||
'info',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -100,30 +118,13 @@ export class GroupHandler {
|
||||
*/
|
||||
public async add(): Promise<void> {
|
||||
try {
|
||||
// Import readline module for user input
|
||||
const readline = await import('node:readline');
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
// Helper function to prompt for input
|
||||
const prompt = (question: string): Promise<string> => {
|
||||
return new Promise((resolve) => {
|
||||
rl.question(question, (answer: string) => {
|
||||
resolve(answer);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
await helpers.withPrompt(async (prompt) => {
|
||||
// Try to load configuration
|
||||
try {
|
||||
await this.nupst.getDaemon().loadConfig();
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
'No configuration found. Please run "nupst setup" first to create a configuration.',
|
||||
'No configuration found. Please run "nupst ups add" first to create a configuration.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -200,10 +201,7 @@ export class GroupHandler {
|
||||
this.nupst.getUpsHandler().restartServiceIfRunning();
|
||||
|
||||
logger.log('\nGroup setup complete!');
|
||||
} finally {
|
||||
rl.close();
|
||||
process.stdin.destroy();
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`Add group error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
@@ -215,30 +213,13 @@ export class GroupHandler {
|
||||
*/
|
||||
public async edit(groupId: string): Promise<void> {
|
||||
try {
|
||||
// Import readline module for user input
|
||||
const readline = await import('node:readline');
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
// Helper function to prompt for input
|
||||
const prompt = (question: string): Promise<string> => {
|
||||
return new Promise((resolve) => {
|
||||
rl.question(question, (answer: string) => {
|
||||
resolve(answer);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
await helpers.withPrompt(async (prompt) => {
|
||||
// Try to load configuration
|
||||
try {
|
||||
await this.nupst.getDaemon().loadConfig();
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
'No configuration found. Please run "nupst setup" first to create a configuration.',
|
||||
'No configuration found. Please run "nupst ups add" first to create a configuration.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -318,10 +299,7 @@ export class GroupHandler {
|
||||
this.nupst.getUpsHandler().restartServiceIfRunning();
|
||||
|
||||
logger.log('\nGroup edit complete!');
|
||||
} finally {
|
||||
rl.close();
|
||||
process.stdin.destroy();
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`Edit group error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
@@ -338,7 +316,7 @@ export class GroupHandler {
|
||||
await this.nupst.getDaemon().loadConfig();
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
'No configuration found. Please run "nupst setup" first to create a configuration.',
|
||||
'No configuration found. Please run "nupst ups add" first to create a configuration.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -362,23 +340,11 @@ export class GroupHandler {
|
||||
const groupToDelete = config.groups[groupIndex];
|
||||
|
||||
// Get confirmation before deleting
|
||||
const readline = await import('node:readline');
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
const confirm = await new Promise<string>((resolve) => {
|
||||
rl.question(
|
||||
`Are you sure you want to delete group "${groupToDelete.name}" (${groupId})? [y/N]: `,
|
||||
(answer) => {
|
||||
resolve(answer.toLowerCase());
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
rl.close();
|
||||
process.stdin.destroy();
|
||||
const { prompt, close } = await helpers.createPrompt();
|
||||
const confirm = (await prompt(
|
||||
`Are you sure you want to delete group "${groupToDelete.name}" (${groupId})? [y/N]: `,
|
||||
)).toLowerCase();
|
||||
close();
|
||||
|
||||
if (confirm !== 'y' && confirm !== 'yes') {
|
||||
logger.log('Deletion cancelled.');
|
||||
@@ -419,8 +385,8 @@ export class GroupHandler {
|
||||
* @param prompt Function to prompt for user input
|
||||
*/
|
||||
public async assignUpsToGroups(
|
||||
ups: any,
|
||||
groups: any[],
|
||||
ups: IUpsConfig,
|
||||
groups: IGroupConfig[],
|
||||
prompt: (question: string) => Promise<string>,
|
||||
): Promise<void> {
|
||||
// Initialize groups array if it doesn't exist
|
||||
@@ -514,15 +480,15 @@ export class GroupHandler {
|
||||
*/
|
||||
public async assignUpsToGroup(
|
||||
groupId: string,
|
||||
config: any,
|
||||
config: INupstConfig,
|
||||
prompt: (question: string) => Promise<string>,
|
||||
): Promise<void> {
|
||||
if (!config.upsDevices || config.upsDevices.length === 0) {
|
||||
logger.log('No UPS devices available. Use "nupst add" to add UPS devices.');
|
||||
logger.log('No UPS devices available. Use "nupst ups add" to add UPS devices.');
|
||||
return;
|
||||
}
|
||||
|
||||
const group = config.groups.find((g: { id: string }) => g.id === groupId);
|
||||
const group = config.groups.find((g) => g.id === groupId);
|
||||
if (!group) {
|
||||
logger.error(`Group with ID "${groupId}" not found.`);
|
||||
return;
|
||||
@@ -530,7 +496,7 @@ export class GroupHandler {
|
||||
|
||||
// Show current assignments
|
||||
logger.log(`\nUPS devices in group "${group.name}" (${group.id}):`);
|
||||
const upsInGroup = config.upsDevices.filter((ups: { groups?: string[] }) =>
|
||||
const upsInGroup = config.upsDevices.filter((ups) =>
|
||||
ups.groups && ups.groups.includes(groupId)
|
||||
);
|
||||
if (upsInGroup.length === 0) {
|
||||
|
||||
+184
-45
@@ -1,7 +1,14 @@
|
||||
import process from 'node:process';
|
||||
import { execSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { execFileSync, execSync } from 'node:child_process';
|
||||
import { Nupst } from '../nupst.ts';
|
||||
import { logger } from '../logger.ts';
|
||||
import { theme } from '../colors.ts';
|
||||
import { PAUSE } from '../constants.ts';
|
||||
import type { IPauseState } from '../pause-state.ts';
|
||||
import * as helpers from '../helpers/index.ts';
|
||||
import { renderUpgradeChangelog } from '../upgrade-changelog.ts';
|
||||
|
||||
/**
|
||||
* Class for handling service-related CLI commands
|
||||
@@ -24,7 +31,9 @@ export class ServiceHandler {
|
||||
public async enable(): Promise<void> {
|
||||
this.checkRootAccess('This command must be run as root.');
|
||||
await this.nupst.getSystemd().install();
|
||||
logger.log('NUPST service has been installed. Use "nupst start" to start the service.');
|
||||
logger.log(
|
||||
'NUPST service has been installed. Use "nupst service start" to start the service.',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -97,10 +106,131 @@ export class ServiceHandler {
|
||||
/**
|
||||
* Show status of the systemd service and UPS
|
||||
*/
|
||||
public async status(): Promise<void> {
|
||||
// Extract debug options from args array
|
||||
const debugOptions = this.extractDebugOptions(process.argv);
|
||||
await this.nupst.getSystemd().getStatus(debugOptions.debugMode);
|
||||
public async status(debugMode: boolean = false): Promise<void> {
|
||||
await this.nupst.getSystemd().getStatus(debugMode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause action monitoring
|
||||
* @param args Command arguments (e.g., ['--duration', '30m'])
|
||||
*/
|
||||
public async pause(args: string[]): Promise<void> {
|
||||
try {
|
||||
// Parse --duration argument
|
||||
let resumeAt: number | null = null;
|
||||
const durationIdx = args.indexOf('--duration');
|
||||
if (durationIdx !== -1 && args[durationIdx + 1]) {
|
||||
const durationStr = args[durationIdx + 1];
|
||||
const durationMs = this.parseDuration(durationStr);
|
||||
if (durationMs === null) {
|
||||
logger.error(`Invalid duration format: ${durationStr}`);
|
||||
logger.dim(' Valid formats: 30m, 2h, 1d (minutes, hours, days)');
|
||||
return;
|
||||
}
|
||||
if (durationMs > PAUSE.MAX_DURATION_MS) {
|
||||
logger.error(`Duration exceeds maximum of 24 hours`);
|
||||
return;
|
||||
}
|
||||
resumeAt = Date.now() + durationMs;
|
||||
}
|
||||
|
||||
// Check if already paused
|
||||
if (fs.existsSync(PAUSE.FILE_PATH)) {
|
||||
logger.warn('Monitoring is already paused');
|
||||
try {
|
||||
const data = fs.readFileSync(PAUSE.FILE_PATH, 'utf8');
|
||||
const state = JSON.parse(data) as IPauseState;
|
||||
logger.dim(` Paused at: ${new Date(state.pausedAt).toISOString()}`);
|
||||
if (state.resumeAt) {
|
||||
const remaining = Math.round((state.resumeAt - Date.now()) / 1000);
|
||||
logger.dim(` Auto-resume in: ${remaining > 0 ? remaining : 0} seconds`);
|
||||
}
|
||||
} catch (_e) {
|
||||
// Ignore parse errors
|
||||
}
|
||||
logger.dim(' Run "nupst resume" to resume monitoring');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create pause state
|
||||
const pauseState: IPauseState = {
|
||||
pausedAt: Date.now(),
|
||||
pausedBy: 'cli',
|
||||
resumeAt,
|
||||
};
|
||||
|
||||
// Ensure config directory exists
|
||||
const pauseDir = path.dirname(PAUSE.FILE_PATH);
|
||||
if (!fs.existsSync(pauseDir)) {
|
||||
fs.mkdirSync(pauseDir, { recursive: true });
|
||||
}
|
||||
|
||||
fs.writeFileSync(PAUSE.FILE_PATH, JSON.stringify(pauseState, null, 2));
|
||||
|
||||
logger.log('');
|
||||
logger.logBoxTitle('Monitoring Paused', 45, 'warning');
|
||||
logger.logBoxLine('UPS polling continues but actions are suppressed');
|
||||
if (resumeAt) {
|
||||
const durationStr = args[args.indexOf('--duration') + 1];
|
||||
logger.logBoxLine(`Auto-resume after: ${durationStr}`);
|
||||
logger.logBoxLine(`Resume at: ${new Date(resumeAt).toISOString()}`);
|
||||
} else {
|
||||
logger.logBoxLine('Duration: Indefinite');
|
||||
logger.logBoxLine('Run "nupst resume" to resume');
|
||||
}
|
||||
logger.logBoxEnd();
|
||||
logger.log('');
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to pause: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume action monitoring
|
||||
*/
|
||||
public async resume(): Promise<void> {
|
||||
try {
|
||||
if (!fs.existsSync(PAUSE.FILE_PATH)) {
|
||||
logger.info('Monitoring is not paused');
|
||||
return;
|
||||
}
|
||||
|
||||
fs.unlinkSync(PAUSE.FILE_PATH);
|
||||
|
||||
logger.log('');
|
||||
logger.logBoxTitle('Monitoring Resumed', 45, 'success');
|
||||
logger.logBoxLine('Action monitoring has been resumed');
|
||||
logger.logBoxEnd();
|
||||
logger.log('');
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to resume: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a duration string like '30m', '2h', '1d' into milliseconds
|
||||
*/
|
||||
private parseDuration(duration: string): number | null {
|
||||
const match = duration.match(/^(\d+)\s*(m|h|d)$/i);
|
||||
if (!match) return null;
|
||||
|
||||
const value = parseInt(match[1], 10);
|
||||
const unit = match[2].toLowerCase();
|
||||
|
||||
switch (unit) {
|
||||
case 'm':
|
||||
return value * 60 * 1000;
|
||||
case 'h':
|
||||
return value * 60 * 60 * 1000;
|
||||
case 'd':
|
||||
return value * 24 * 60 * 60 * 1000;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -125,11 +255,11 @@ export class ServiceHandler {
|
||||
/**
|
||||
* Update NUPST from repository and refresh systemd service
|
||||
*/
|
||||
public async update(): Promise<void> {
|
||||
public update(): void {
|
||||
try {
|
||||
// Check if running as root
|
||||
this.checkRootAccess(
|
||||
'This command must be run as root to update NUPST.',
|
||||
'This command must be run as root to upgrade NUPST.',
|
||||
);
|
||||
|
||||
console.log('');
|
||||
@@ -141,13 +271,17 @@ export class ServiceHandler {
|
||||
|
||||
// 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 response = this.fetchRemoteText(apiUrl);
|
||||
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}`;
|
||||
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}`);
|
||||
@@ -161,6 +295,7 @@ export class ServiceHandler {
|
||||
}
|
||||
|
||||
logger.info(`New version available: ${latestVersion}`);
|
||||
this.showUpgradeChangelog(normalizedCurrent, normalizedLatest);
|
||||
logger.dim('Downloading and installing...');
|
||||
console.log('');
|
||||
|
||||
@@ -188,6 +323,40 @@ export class ServiceHandler {
|
||||
}
|
||||
}
|
||||
|
||||
private fetchRemoteText(url: string): string {
|
||||
return execFileSync('curl', ['-fsSL', url], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
}
|
||||
|
||||
private showUpgradeChangelog(currentVersion: string, latestVersion: string): void {
|
||||
const changelogUrl = 'https://code.foss.global/serve.zone/nupst/raw/branch/main/changelog.md';
|
||||
|
||||
try {
|
||||
const changelogMarkdown = this.fetchRemoteText(changelogUrl);
|
||||
const renderedChanges = renderUpgradeChangelog(
|
||||
changelogMarkdown,
|
||||
currentVersion,
|
||||
latestVersion,
|
||||
);
|
||||
|
||||
if (!renderedChanges) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`What's changed:`);
|
||||
logger.log('');
|
||||
for (const line of renderedChanges.split('\n')) {
|
||||
logger.log(line);
|
||||
}
|
||||
logger.log('');
|
||||
} catch (error) {
|
||||
logger.warn('Could not load changelog for this upgrade. Continuing anyway.');
|
||||
logger.dim(`${error instanceof Error ? error.message : String(error)}`);
|
||||
logger.log('');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Completely uninstall NUPST from the system
|
||||
*/
|
||||
@@ -196,22 +365,7 @@ export class ServiceHandler {
|
||||
this.checkRootAccess('This command must be run as root.');
|
||||
|
||||
try {
|
||||
// Import readline module for user input
|
||||
const readline = await import('readline');
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
// Helper function to prompt for input
|
||||
const prompt = (question: string): Promise<string> => {
|
||||
return new Promise((resolve) => {
|
||||
rl.question(question, (answer: string) => {
|
||||
resolve(answer);
|
||||
});
|
||||
});
|
||||
};
|
||||
const { prompt, close } = await helpers.createPrompt();
|
||||
|
||||
logger.log('');
|
||||
logger.highlight('NUPST Uninstaller');
|
||||
@@ -254,15 +408,13 @@ export class ServiceHandler {
|
||||
|
||||
if (!uninstallScriptPath) {
|
||||
logger.error('Could not locate uninstall.sh script. Aborting uninstall.');
|
||||
rl.close();
|
||||
process.stdin.destroy();
|
||||
close();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Close readline before executing script
|
||||
rl.close();
|
||||
process.stdin.destroy();
|
||||
// Close prompt before executing script
|
||||
close();
|
||||
|
||||
// Execute uninstall.sh with the appropriate option
|
||||
logger.log('');
|
||||
@@ -286,17 +438,4 @@ export class ServiceHandler {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract and remove debug options from args array
|
||||
* @param args Command line arguments
|
||||
* @returns Object with debug flags and cleaned args
|
||||
*/
|
||||
private extractDebugOptions(args: string[]): { debugMode: boolean; cleanedArgs: string[] } {
|
||||
const debugMode = args.includes('--debug') || args.includes('-d');
|
||||
// Remove debug flags from args
|
||||
const cleanedArgs = args.filter((arg) => arg !== '--debug' && arg !== '-d');
|
||||
|
||||
return { debugMode, cleanedArgs };
|
||||
}
|
||||
}
|
||||
|
||||
+544
-210
File diff suppressed because it is too large
Load Diff
+5
-1
@@ -75,12 +75,16 @@ export function getRuntimeColor(minutes: number): (text: string) => string {
|
||||
/**
|
||||
* Format UPS power status with color
|
||||
*/
|
||||
export function formatPowerStatus(status: 'online' | 'onBattery' | 'unknown'): string {
|
||||
export function formatPowerStatus(
|
||||
status: 'online' | 'onBattery' | 'unknown' | 'unreachable',
|
||||
): string {
|
||||
switch (status) {
|
||||
case 'online':
|
||||
return theme.success('Online');
|
||||
case 'onBattery':
|
||||
return theme.warning('On Battery');
|
||||
case 'unreachable':
|
||||
return theme.error('Unreachable');
|
||||
case 'unknown':
|
||||
default:
|
||||
return theme.dim('Unknown');
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
export interface IWatchEventLike {
|
||||
kind: string;
|
||||
paths: string[];
|
||||
}
|
||||
|
||||
export type TConfigReloadTransition = 'monitoringWillStart' | 'deviceCountChanged' | 'reloaded';
|
||||
|
||||
export interface IConfigReloadSnapshot {
|
||||
transition: TConfigReloadTransition;
|
||||
message: string;
|
||||
shouldInitializeUpsStatus: boolean;
|
||||
shouldLogMonitoringStart: boolean;
|
||||
}
|
||||
|
||||
export function shouldReloadConfig(
|
||||
event: IWatchEventLike,
|
||||
configFileName: string = 'config.json',
|
||||
): boolean {
|
||||
return event.kind === 'modify' && event.paths.some((path) => path.includes(configFileName));
|
||||
}
|
||||
|
||||
export function shouldRefreshPauseState(
|
||||
event: IWatchEventLike,
|
||||
pauseFileName: string = 'pause',
|
||||
): boolean {
|
||||
return ['create', 'modify', 'remove'].includes(event.kind) &&
|
||||
event.paths.some((path) => path.includes(pauseFileName));
|
||||
}
|
||||
|
||||
export function analyzeConfigReload(
|
||||
oldDeviceCount: number,
|
||||
newDeviceCount: number,
|
||||
): IConfigReloadSnapshot {
|
||||
if (newDeviceCount > 0 && oldDeviceCount === 0) {
|
||||
return {
|
||||
transition: 'monitoringWillStart',
|
||||
message: `Configuration reloaded! Found ${newDeviceCount} UPS device(s)`,
|
||||
shouldInitializeUpsStatus: false,
|
||||
shouldLogMonitoringStart: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (newDeviceCount !== oldDeviceCount) {
|
||||
return {
|
||||
transition: 'deviceCountChanged',
|
||||
message: `Configuration reloaded! UPS devices: ${oldDeviceCount} -> ${newDeviceCount}`,
|
||||
shouldInitializeUpsStatus: true,
|
||||
shouldLogMonitoringStart: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
transition: 'reloaded',
|
||||
message: 'Configuration reloaded successfully',
|
||||
shouldInitializeUpsStatus: false,
|
||||
shouldLogMonitoringStart: false,
|
||||
};
|
||||
}
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* NUPST Constants
|
||||
*
|
||||
* Central location for all timeout, interval, and threshold values.
|
||||
* This makes configuration easier and code more self-documenting.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Default timing values in milliseconds
|
||||
*/
|
||||
export const TIMING = {
|
||||
/** Default interval between UPS status checks (30 seconds) */
|
||||
CHECK_INTERVAL_MS: 30000,
|
||||
|
||||
/** Interval for idle monitoring mode (60 seconds) */
|
||||
IDLE_CHECK_INTERVAL_MS: 60000,
|
||||
|
||||
/** Interval for checking config file changes (60 seconds) */
|
||||
CONFIG_CHECK_INTERVAL_MS: 60000,
|
||||
|
||||
/** Interval for logging periodic status updates (5 minutes) */
|
||||
LOG_INTERVAL_MS: 5 * 60 * 1000,
|
||||
|
||||
/** Maximum time to monitor during shutdown (5 minutes) */
|
||||
MAX_SHUTDOWN_MONITORING_MS: 5 * 60 * 1000,
|
||||
|
||||
/** Interval for UPS checks during shutdown (30 seconds) */
|
||||
SHUTDOWN_CHECK_INTERVAL_MS: 30000,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* SNMP-related constants
|
||||
*/
|
||||
export const SNMP = {
|
||||
/** Default SNMP port */
|
||||
DEFAULT_PORT: 161,
|
||||
|
||||
/** Default SNMP timeout (5 seconds) */
|
||||
DEFAULT_TIMEOUT_MS: 5000,
|
||||
|
||||
/** Number of SNMP retries */
|
||||
RETRIES: 2,
|
||||
|
||||
/** Timeout for noAuthNoPriv security level (5 seconds) */
|
||||
TIMEOUT_NO_AUTH_MS: 5000,
|
||||
|
||||
/** Timeout for authNoPriv security level (10 seconds) */
|
||||
TIMEOUT_AUTH_MS: 10000,
|
||||
|
||||
/** Timeout for authPriv security level (15 seconds) */
|
||||
TIMEOUT_AUTH_PRIV_MS: 15000,
|
||||
|
||||
/** Maximum timeout for connection tests (10 seconds) */
|
||||
MAX_TEST_TIMEOUT_MS: 10000,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Default threshold values
|
||||
*/
|
||||
export const THRESHOLDS = {
|
||||
/** Default battery capacity threshold for shutdown (60%) */
|
||||
DEFAULT_BATTERY_PERCENT: 60,
|
||||
|
||||
/** Default runtime threshold for shutdown (20 minutes) */
|
||||
DEFAULT_RUNTIME_MINUTES: 20,
|
||||
|
||||
/** Emergency runtime threshold during shutdown (5 minutes) */
|
||||
EMERGENCY_RUNTIME_MINUTES: 5,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Webhook action constants
|
||||
*/
|
||||
export const WEBHOOK = {
|
||||
/** Default webhook request timeout (10 seconds) */
|
||||
DEFAULT_TIMEOUT_MS: 10000,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Script action constants
|
||||
*/
|
||||
export const SCRIPT = {
|
||||
/** Default script execution timeout (60 seconds) */
|
||||
DEFAULT_TIMEOUT_MS: 60000,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Shutdown action constants
|
||||
*/
|
||||
export const SHUTDOWN = {
|
||||
/** Default shutdown delay (5 minutes) */
|
||||
DEFAULT_DELAY_MINUTES: 5,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* HTTP Server constants
|
||||
*/
|
||||
export const HTTP_SERVER = {
|
||||
/** Default HTTP server port */
|
||||
DEFAULT_PORT: 8080,
|
||||
|
||||
/** Default URL path for UPS status endpoint */
|
||||
DEFAULT_PATH: '/ups-status',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Network failure detection constants
|
||||
*/
|
||||
export const NETWORK = {
|
||||
/** Number of consecutive failures before marking UPS as unreachable */
|
||||
CONSECUTIVE_FAILURE_THRESHOLD: 3,
|
||||
|
||||
/** Maximum tracked consecutive failures (prevents overflow) */
|
||||
MAX_CONSECUTIVE_FAILURES: 100,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* UPSD/NIS protocol constants
|
||||
*/
|
||||
export const UPSD = {
|
||||
/** Default UPSD port (NUT standard) */
|
||||
DEFAULT_PORT: 3493,
|
||||
|
||||
/** Default timeout in milliseconds */
|
||||
DEFAULT_TIMEOUT_MS: 5000,
|
||||
|
||||
/** Default NUT device name */
|
||||
DEFAULT_UPS_NAME: 'ups',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Pause/resume constants
|
||||
*/
|
||||
export const PAUSE = {
|
||||
/** Path to the pause state file */
|
||||
FILE_PATH: '/etc/nupst/pause',
|
||||
|
||||
/** Maximum pause duration (24 hours) */
|
||||
MAX_DURATION_MS: 24 * 60 * 60 * 1000,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Proxmox VM shutdown constants
|
||||
*/
|
||||
export const PROXMOX = {
|
||||
/** Default Proxmox API port */
|
||||
DEFAULT_PORT: 8006,
|
||||
|
||||
/** Default Proxmox host */
|
||||
DEFAULT_HOST: 'localhost',
|
||||
|
||||
/** Default timeout for VM/CT shutdown in seconds */
|
||||
DEFAULT_STOP_TIMEOUT_SECONDS: 120,
|
||||
|
||||
/** Poll interval for checking VM/CT status in seconds */
|
||||
STATUS_POLL_INTERVAL_SECONDS: 5,
|
||||
|
||||
/** Proxmox API base path */
|
||||
API_BASE: '/api2/json',
|
||||
|
||||
/** Common paths to search for Proxmox CLI tools (qm, pct) */
|
||||
CLI_TOOL_PATHS: ['/usr/sbin', '/usr/bin', '/sbin', '/bin'] as readonly string[],
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* UI/Display constants
|
||||
*/
|
||||
export const UI = {
|
||||
/** Default width for log boxes */
|
||||
DEFAULT_BOX_WIDTH: 45,
|
||||
|
||||
/** Wide box width for status displays */
|
||||
WIDE_BOX_WIDTH: 60,
|
||||
|
||||
/** Extra wide box width for detailed info */
|
||||
EXTRA_WIDE_BOX_WIDTH: 70,
|
||||
} as const;
|
||||
+582
-405
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,198 @@
|
||||
import type { IActionConfig, TPowerStatus } from './actions/base-action.ts';
|
||||
import { createInitialUpsStatus, type IUpsIdentity, type IUpsStatus } from './ups-status.ts';
|
||||
|
||||
export interface IGroupStatusSnapshot {
|
||||
updatedStatus: IUpsStatus;
|
||||
transition: 'none' | 'powerStatusChange';
|
||||
previousStatus?: IUpsStatus;
|
||||
}
|
||||
|
||||
export interface IGroupThresholdEvaluation {
|
||||
exceedsThreshold: boolean;
|
||||
blockedByUnreachable: boolean;
|
||||
representativeStatus?: IUpsStatus;
|
||||
}
|
||||
|
||||
const destructiveActionTypes = new Set(['shutdown', 'proxmox']);
|
||||
|
||||
function getStatusSeverity(powerStatus: TPowerStatus): number {
|
||||
switch (powerStatus) {
|
||||
case 'unreachable':
|
||||
return 3;
|
||||
case 'onBattery':
|
||||
return 2;
|
||||
case 'unknown':
|
||||
return 1;
|
||||
case 'online':
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export function selectWorstStatus(statuses: IUpsStatus[]): IUpsStatus | undefined {
|
||||
return statuses.reduce<IUpsStatus | undefined>((worst, status) => {
|
||||
if (!worst) {
|
||||
return status;
|
||||
}
|
||||
|
||||
const severityDiff = getStatusSeverity(status.powerStatus) -
|
||||
getStatusSeverity(worst.powerStatus);
|
||||
if (severityDiff > 0) {
|
||||
return status;
|
||||
}
|
||||
if (severityDiff < 0) {
|
||||
return worst;
|
||||
}
|
||||
|
||||
if (status.batteryRuntime !== worst.batteryRuntime) {
|
||||
return status.batteryRuntime < worst.batteryRuntime ? status : worst;
|
||||
}
|
||||
|
||||
if (status.batteryCapacity !== worst.batteryCapacity) {
|
||||
return status.batteryCapacity < worst.batteryCapacity ? status : worst;
|
||||
}
|
||||
|
||||
return worst;
|
||||
}, undefined);
|
||||
}
|
||||
|
||||
function deriveGroupPowerStatus(
|
||||
mode: 'redundant' | 'nonRedundant',
|
||||
memberStatuses: IUpsStatus[],
|
||||
): TPowerStatus {
|
||||
if (memberStatuses.length === 0) {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
if (memberStatuses.some((status) => status.powerStatus === 'unreachable')) {
|
||||
return 'unreachable';
|
||||
}
|
||||
|
||||
if (mode === 'redundant') {
|
||||
if (memberStatuses.every((status) => status.powerStatus === 'onBattery')) {
|
||||
return 'onBattery';
|
||||
}
|
||||
} else if (memberStatuses.some((status) => status.powerStatus === 'onBattery')) {
|
||||
return 'onBattery';
|
||||
}
|
||||
|
||||
if (memberStatuses.some((status) => status.powerStatus === 'unknown')) {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
return 'online';
|
||||
}
|
||||
|
||||
function pickRepresentativeStatus(
|
||||
powerStatus: TPowerStatus,
|
||||
memberStatuses: IUpsStatus[],
|
||||
): IUpsStatus | undefined {
|
||||
const matchingStatuses = memberStatuses.filter((status) => status.powerStatus === powerStatus);
|
||||
return selectWorstStatus(matchingStatuses.length > 0 ? matchingStatuses : memberStatuses);
|
||||
}
|
||||
|
||||
export function buildGroupStatusSnapshot(
|
||||
group: IUpsIdentity,
|
||||
mode: 'redundant' | 'nonRedundant',
|
||||
memberStatuses: IUpsStatus[],
|
||||
currentStatus: IUpsStatus | undefined,
|
||||
currentTime: number,
|
||||
): IGroupStatusSnapshot {
|
||||
const previousStatus = currentStatus || createInitialUpsStatus(group, currentTime);
|
||||
const powerStatus = deriveGroupPowerStatus(mode, memberStatuses);
|
||||
const representative = pickRepresentativeStatus(powerStatus, memberStatuses) || previousStatus;
|
||||
const updatedStatus: IUpsStatus = {
|
||||
...previousStatus,
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
powerStatus,
|
||||
batteryCapacity: representative.batteryCapacity,
|
||||
batteryRuntime: representative.batteryRuntime,
|
||||
outputLoad: representative.outputLoad,
|
||||
outputPower: representative.outputPower,
|
||||
outputVoltage: representative.outputVoltage,
|
||||
outputCurrent: representative.outputCurrent,
|
||||
lastCheckTime: currentTime,
|
||||
consecutiveFailures: 0,
|
||||
unreachableSince: powerStatus === 'unreachable'
|
||||
? previousStatus.unreachableSince || currentTime
|
||||
: 0,
|
||||
lastStatusChange: previousStatus.lastStatusChange || currentTime,
|
||||
};
|
||||
|
||||
if (previousStatus.powerStatus !== powerStatus) {
|
||||
updatedStatus.lastStatusChange = currentTime;
|
||||
if (powerStatus === 'unreachable') {
|
||||
updatedStatus.unreachableSince = currentTime;
|
||||
}
|
||||
return {
|
||||
updatedStatus,
|
||||
transition: 'powerStatusChange',
|
||||
previousStatus,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
updatedStatus,
|
||||
transition: 'none',
|
||||
previousStatus: currentStatus,
|
||||
};
|
||||
}
|
||||
|
||||
export function evaluateGroupActionThreshold(
|
||||
actionConfig: IActionConfig,
|
||||
mode: 'redundant' | 'nonRedundant',
|
||||
memberStatuses: IUpsStatus[],
|
||||
): IGroupThresholdEvaluation {
|
||||
if (!actionConfig.thresholds || memberStatuses.length === 0) {
|
||||
return {
|
||||
exceedsThreshold: false,
|
||||
blockedByUnreachable: false,
|
||||
};
|
||||
}
|
||||
|
||||
const criticalMembers = memberStatuses.filter((status) =>
|
||||
status.powerStatus === 'onBattery' &&
|
||||
(status.batteryCapacity < actionConfig.thresholds!.battery ||
|
||||
status.batteryRuntime < actionConfig.thresholds!.runtime)
|
||||
);
|
||||
const exceedsThreshold = mode === 'redundant'
|
||||
? criticalMembers.length === memberStatuses.length
|
||||
: criticalMembers.length > 0;
|
||||
|
||||
return {
|
||||
exceedsThreshold,
|
||||
blockedByUnreachable: exceedsThreshold &&
|
||||
destructiveActionTypes.has(actionConfig.type) &&
|
||||
memberStatuses.some((status) => status.powerStatus === 'unreachable'),
|
||||
representativeStatus: selectWorstStatus(criticalMembers),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildGroupThresholdContextStatus(
|
||||
group: IUpsIdentity,
|
||||
evaluations: IGroupThresholdEvaluation[],
|
||||
enteredActionIndexes: number[],
|
||||
fallbackStatus: IUpsStatus,
|
||||
currentTime: number,
|
||||
): IUpsStatus {
|
||||
const representativeStatuses = enteredActionIndexes
|
||||
.map((index) => evaluations[index]?.representativeStatus)
|
||||
.filter((status): status is IUpsStatus => !!status);
|
||||
|
||||
const representative = selectWorstStatus(representativeStatuses) || fallbackStatus;
|
||||
|
||||
return {
|
||||
...fallbackStatus,
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
powerStatus: 'onBattery',
|
||||
batteryCapacity: representative.batteryCapacity,
|
||||
batteryRuntime: representative.batteryRuntime,
|
||||
outputLoad: representative.outputLoad,
|
||||
outputPower: representative.outputPower,
|
||||
outputVoltage: representative.outputVoltage,
|
||||
outputCurrent: representative.outputCurrent,
|
||||
lastCheckTime: currentTime,
|
||||
};
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
export * from './shortid.ts';
|
||||
export * from './prompt.ts';
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import process from 'node:process';
|
||||
|
||||
/**
|
||||
* Result from creating a prompt interface
|
||||
*/
|
||||
export interface IPromptInterface {
|
||||
/** Function to prompt for user input */
|
||||
prompt: (question: string) => Promise<string>;
|
||||
/** Function to close the prompt interface */
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a readline prompt interface for interactive CLI input
|
||||
* @returns Promise resolving to prompt function and close function
|
||||
*/
|
||||
export async function createPrompt(): Promise<IPromptInterface> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const close = (): void => {
|
||||
rl.close();
|
||||
process.stdin.destroy();
|
||||
};
|
||||
|
||||
return { prompt, close };
|
||||
}
|
||||
|
||||
/**
|
||||
* Run an async function with a prompt interface, ensuring cleanup
|
||||
* @param fn Function to run with the prompt interface
|
||||
* @returns Promise resolving to the function's return value
|
||||
*/
|
||||
export async function withPrompt<T>(
|
||||
fn: (prompt: (question: string) => Promise<string>) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const { prompt, close } = await createPrompt();
|
||||
try {
|
||||
return await fn(prompt);
|
||||
} finally {
|
||||
close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import * as http from 'node:http';
|
||||
import { URL } from 'node:url';
|
||||
import { logger } from './logger.ts';
|
||||
import type { IPauseState } from './pause-state.ts';
|
||||
import type { IUpsStatus } from './ups-status.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>;
|
||||
private getPauseState: () => IPauseState | null;
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @param getPauseState Function to retrieve current pause state
|
||||
*/
|
||||
constructor(
|
||||
port: number,
|
||||
path: string,
|
||||
authToken: string,
|
||||
getUpsStatus: () => Map<string, IUpsStatus>,
|
||||
getPauseState: () => IPauseState | null,
|
||||
) {
|
||||
this.port = port;
|
||||
this.path = path;
|
||||
this.authToken = authToken;
|
||||
this.getUpsStatus = getUpsStatus;
|
||||
this.getPauseState = getPauseState;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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());
|
||||
const pauseState = this.getPauseState();
|
||||
|
||||
const response = {
|
||||
upsDevices: statusArray,
|
||||
...(pauseState ? { paused: true, pauseState } : { paused: false }),
|
||||
};
|
||||
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-cache',
|
||||
});
|
||||
res.end(JSON.stringify(response, 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: Error) => {
|
||||
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');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -10,7 +10,7 @@ import process from 'node:process';
|
||||
*/
|
||||
async function main() {
|
||||
const cli = new NupstCli();
|
||||
await cli.parseAndExecute(process.argv);
|
||||
await cli.parseAndExecute(process.argv.slice(2));
|
||||
}
|
||||
|
||||
// Run the main function and handle any errors
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from './nupst-accessor.ts';
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { NupstDaemon } from '../daemon.ts';
|
||||
|
||||
/**
|
||||
* Update status information
|
||||
*/
|
||||
export interface IUpdateStatus {
|
||||
currentVersion: string;
|
||||
latestVersion: string;
|
||||
updateAvailable: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for accessing Nupst functionality from SNMP manager
|
||||
* This breaks the circular dependency between Nupst and NupstSnmp
|
||||
*/
|
||||
export interface INupstAccessor {
|
||||
/**
|
||||
* Get the daemon manager for background monitoring
|
||||
*/
|
||||
getDaemon(): NupstDaemon;
|
||||
|
||||
/**
|
||||
* Get the current version of NUPST
|
||||
*/
|
||||
getVersion(): string;
|
||||
|
||||
/**
|
||||
* Check if an update is available
|
||||
*/
|
||||
checkForUpdates(): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Get update status information
|
||||
*/
|
||||
getUpdateStatus(): IUpdateStatus;
|
||||
|
||||
/**
|
||||
* Log the current version and update status
|
||||
*/
|
||||
logVersionInfo(checkForUpdates?: boolean): void;
|
||||
}
|
||||
+3
-2
@@ -1,4 +1,4 @@
|
||||
import { theme, symbols } from './colors.ts';
|
||||
import { symbols, theme } from './colors.ts';
|
||||
|
||||
/**
|
||||
* Table column alignment options
|
||||
@@ -230,7 +230,8 @@ export class Logger {
|
||||
* Strip ANSI color codes from string for accurate length calculation
|
||||
*/
|
||||
private stripAnsi(text: string): string {
|
||||
// Remove ANSI escape codes
|
||||
// Remove ANSI escape codes (intentional control character regex)
|
||||
// deno-lint-ignore no-control-regex
|
||||
return text.replace(/\x1b\[[0-9;]*m/g, '');
|
||||
}
|
||||
|
||||
|
||||
@@ -28,18 +28,22 @@ export abstract class BaseMigration {
|
||||
/**
|
||||
* Check if this migration should run on the given config
|
||||
*
|
||||
* @param config - Raw configuration object to check
|
||||
* @param config - Raw configuration object to check (unknown schema for migrations)
|
||||
* @returns True if migration should run, false otherwise
|
||||
*/
|
||||
abstract shouldRun(config: any): Promise<boolean>;
|
||||
abstract shouldRun(
|
||||
config: Record<string, unknown>,
|
||||
): boolean | Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Perform the migration on the given config
|
||||
*
|
||||
* @param config - Raw configuration object to migrate
|
||||
* @param config - Raw configuration object to migrate (unknown schema for migrations)
|
||||
* @returns Migrated configuration object
|
||||
*/
|
||||
abstract migrate(config: any): Promise<any>;
|
||||
abstract migrate(
|
||||
config: Record<string, unknown>,
|
||||
): Record<string, unknown> | Promise<Record<string, unknown>>;
|
||||
|
||||
/**
|
||||
* Get human-readable name for this migration
|
||||
|
||||
@@ -9,3 +9,6 @@ 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';
|
||||
export { MigrationV4_1ToV4_2 } from './migration-v4.1-to-v4.2.ts';
|
||||
export { MigrationV4_2ToV4_3 } from './migration-v4.2-to-v4.3.ts';
|
||||
export { MigrationV4_3ToV4_4 } from './migration-v4.3-to-v4.4.ts';
|
||||
|
||||
@@ -2,6 +2,9 @@ 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 { MigrationV4_1ToV4_2 } from './migration-v4.1-to-v4.2.ts';
|
||||
import { MigrationV4_2ToV4_3 } from './migration-v4.2-to-v4.3.ts';
|
||||
import { MigrationV4_3ToV4_4 } from './migration-v4.3-to-v4.4.ts';
|
||||
import { logger } from '../logger.ts';
|
||||
|
||||
/**
|
||||
@@ -19,7 +22,9 @@ export class MigrationRunner {
|
||||
new MigrationV1ToV2(),
|
||||
new MigrationV3ToV4(),
|
||||
new MigrationV4_0ToV4_1(),
|
||||
// Add future migrations here (v4.3, v4.4, etc.)
|
||||
new MigrationV4_1ToV4_2(),
|
||||
new MigrationV4_2ToV4_3(),
|
||||
new MigrationV4_3ToV4_4(),
|
||||
];
|
||||
|
||||
// Sort by version order to ensure they run in sequence
|
||||
@@ -32,7 +37,9 @@ export class MigrationRunner {
|
||||
* @param config - Raw configuration object to migrate
|
||||
* @returns Migrated configuration and whether migrations ran
|
||||
*/
|
||||
async run(config: any): Promise<{ config: any; migrated: boolean }> {
|
||||
async run(
|
||||
config: Record<string, unknown>,
|
||||
): Promise<{ config: Record<string, unknown>; migrated: boolean }> {
|
||||
let currentConfig = config;
|
||||
let anyMigrationsRan = false;
|
||||
|
||||
@@ -53,7 +60,7 @@ export class MigrationRunner {
|
||||
if (anyMigrationsRan) {
|
||||
logger.success('Configuration migrations complete');
|
||||
} else {
|
||||
logger.success('config format ok');
|
||||
logger.success('Configuration format OK');
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -23,12 +23,12 @@ export class MigrationV1ToV2 extends BaseMigration {
|
||||
readonly fromVersion = '1.x';
|
||||
readonly toVersion = '2.0';
|
||||
|
||||
async shouldRun(config: any): Promise<boolean> {
|
||||
shouldRun(config: Record<string, unknown>): 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> {
|
||||
migrate(config: Record<string, unknown>): Record<string, unknown> {
|
||||
logger.info(`${this.getName()}: Converting single SNMP config to multi-UPS format...`);
|
||||
|
||||
const migrated = {
|
||||
|
||||
@@ -42,15 +42,16 @@ export class MigrationV3ToV4 extends BaseMigration {
|
||||
readonly fromVersion = '3.x';
|
||||
readonly toVersion = '4.0';
|
||||
|
||||
async shouldRun(config: any): Promise<boolean> {
|
||||
shouldRun(config: Record<string, unknown>): 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];
|
||||
const upsDevices = config.upsDevices as Array<Record<string, unknown>> | undefined;
|
||||
if (upsDevices && upsDevices.length > 0) {
|
||||
const firstDevice = upsDevices[0];
|
||||
// V3 has host at top level, v4 has it nested in snmp object
|
||||
return !!firstDevice.host && !firstDevice.snmp;
|
||||
}
|
||||
@@ -58,17 +59,17 @@ export class MigrationV3ToV4 extends BaseMigration {
|
||||
return false;
|
||||
}
|
||||
|
||||
async migrate(config: any): Promise<any> {
|
||||
migrate(config: Record<string, unknown>): Record<string, unknown> {
|
||||
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;
|
||||
const sourceDevices = (config.upsList || config.upsDevices) as Array<Record<string, unknown>>;
|
||||
|
||||
// Transform each UPS device from v3 flat structure to v4 nested structure
|
||||
const transformedDevices = sourceDevices.map((device: any) => {
|
||||
const transformedDevices = sourceDevices.map((device: Record<string, unknown>) => {
|
||||
// Build SNMP config object
|
||||
const snmpConfig: any = {
|
||||
const snmpConfig: Record<string, unknown> = {
|
||||
host: device.host,
|
||||
port: device.port || 161,
|
||||
version: typeof device.version === 'string' ? parseInt(device.version, 10) : device.version,
|
||||
@@ -112,7 +113,9 @@ export class MigrationV3ToV4 extends BaseMigration {
|
||||
checkInterval: config.checkInterval || 30000,
|
||||
};
|
||||
|
||||
logger.success(`${this.getName()}: Migration complete (${transformedDevices.length} devices transformed)`);
|
||||
logger.success(
|
||||
`${this.getName()}: Migration complete (${transformedDevices.length} devices transformed)`,
|
||||
);
|
||||
return migrated;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,8 +37,7 @@ import { logger } from '../logger.ts';
|
||||
* {
|
||||
* type: "shutdown",
|
||||
* thresholds: { battery: 60, runtime: 20 },
|
||||
* triggerMode: "onlyThresholds",
|
||||
* shutdownDelay: 5
|
||||
* triggerMode: "onlyThresholds"
|
||||
* }
|
||||
* ]
|
||||
* }
|
||||
@@ -49,15 +48,15 @@ export class MigrationV4_0ToV4_1 extends BaseMigration {
|
||||
readonly fromVersion = '4.0';
|
||||
readonly toVersion = '4.1';
|
||||
|
||||
async shouldRun(config: any): Promise<boolean> {
|
||||
shouldRun(config: Record<string, unknown>): 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 (config.upsDevices && config.upsDevices.length > 0) {
|
||||
const firstDevice = config.upsDevices[0];
|
||||
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;
|
||||
}
|
||||
@@ -65,14 +64,15 @@ export class MigrationV4_0ToV4_1 extends BaseMigration {
|
||||
return false;
|
||||
}
|
||||
|
||||
async migrate(config: any): Promise<any> {
|
||||
migrate(config: Record<string, unknown>): 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 migratedDevices = (config.upsDevices || []).map((device: any) => {
|
||||
const migrated: any = {
|
||||
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,
|
||||
@@ -80,20 +80,22 @@ export class MigrationV4_0ToV4_1 extends BaseMigration {
|
||||
};
|
||||
|
||||
// If device has thresholds at UPS level, convert to shutdown action
|
||||
if (device.thresholds) {
|
||||
const deviceThresholds = device.thresholds as
|
||||
| { battery: number; runtime: number }
|
||||
| undefined;
|
||||
if (deviceThresholds) {
|
||||
migrated.actions = [
|
||||
{
|
||||
type: 'shutdown',
|
||||
thresholds: {
|
||||
battery: device.thresholds.battery,
|
||||
runtime: device.thresholds.runtime,
|
||||
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: ${device.thresholds.battery}%, runtime: ${device.thresholds.runtime}min)`,
|
||||
` → ${device.name}: Created shutdown action (battery: ${deviceThresholds.battery}%, runtime: ${deviceThresholds.runtime}min)`,
|
||||
);
|
||||
} else {
|
||||
// No thresholds, just add empty actions array
|
||||
@@ -104,7 +106,8 @@ export class MigrationV4_0ToV4_1 extends BaseMigration {
|
||||
});
|
||||
|
||||
// Add actions to groups
|
||||
const migratedGroups = (config.groups || []).map((group: any) => ({
|
||||
const groups = (config.groups as Array<Record<string, unknown>>) || [];
|
||||
const migratedGroups = groups.map((group) => ({
|
||||
...group,
|
||||
actions: group.actions || [],
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { BaseMigration } from './base-migration.ts';
|
||||
import { logger } from '../logger.ts';
|
||||
|
||||
/**
|
||||
* Migration from v4.1 to v4.2
|
||||
*
|
||||
* Changes:
|
||||
* 1. Adds `protocol: 'snmp'` to all existing UPS devices (explicit default)
|
||||
* 2. Bumps version from '4.1' to '4.2'
|
||||
*/
|
||||
export class MigrationV4_1ToV4_2 extends BaseMigration {
|
||||
readonly fromVersion = '4.1';
|
||||
readonly toVersion = '4.2';
|
||||
|
||||
shouldRun(config: Record<string, unknown>): boolean {
|
||||
return config.version === '4.1';
|
||||
}
|
||||
|
||||
migrate(config: Record<string, unknown>): Record<string, unknown> {
|
||||
logger.info(`${this.getName()}: Adding protocol field to UPS devices...`);
|
||||
|
||||
const devices = (config.upsDevices as Array<Record<string, unknown>>) || [];
|
||||
const migratedDevices = devices.map((device) => {
|
||||
// Add protocol: 'snmp' if not already present
|
||||
if (!device.protocol) {
|
||||
device.protocol = 'snmp';
|
||||
logger.dim(` → ${device.name}: Set protocol to 'snmp'`);
|
||||
}
|
||||
return device;
|
||||
});
|
||||
|
||||
const result = {
|
||||
...config,
|
||||
version: this.toVersion,
|
||||
upsDevices: migratedDevices,
|
||||
};
|
||||
|
||||
logger.success(
|
||||
`${this.getName()}: Migration complete (${migratedDevices.length} devices updated)`,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { BaseMigration } from './base-migration.ts';
|
||||
import { logger } from '../logger.ts';
|
||||
import { getDefaultRuntimeUnitForUpsModel } from '../snmp/runtime-units.ts';
|
||||
|
||||
/**
|
||||
* Migration from v4.2 to v4.3
|
||||
*
|
||||
* Changes:
|
||||
* 1. Adds `runtimeUnit` to SNMP configs based on existing `upsModel`
|
||||
* 2. Bumps version from '4.2' to '4.3'
|
||||
*/
|
||||
export class MigrationV4_2ToV4_3 extends BaseMigration {
|
||||
readonly fromVersion = '4.2';
|
||||
readonly toVersion = '4.3';
|
||||
|
||||
shouldRun(config: Record<string, unknown>): boolean {
|
||||
return config.version === '4.2';
|
||||
}
|
||||
|
||||
migrate(config: Record<string, unknown>): Record<string, unknown> {
|
||||
logger.info(`${this.getName()}: Adding runtimeUnit to SNMP configs...`);
|
||||
|
||||
const devices = (config.upsDevices as Array<Record<string, unknown>>) || [];
|
||||
const migratedDevices = devices.map((device) => {
|
||||
const snmp = device.snmp as Record<string, unknown> | undefined;
|
||||
if (snmp && !snmp.runtimeUnit) {
|
||||
const model = snmp.upsModel as
|
||||
| 'cyberpower'
|
||||
| 'apc'
|
||||
| 'eaton'
|
||||
| 'tripplite'
|
||||
| 'liebert'
|
||||
| 'custom'
|
||||
| undefined;
|
||||
snmp.runtimeUnit = getDefaultRuntimeUnitForUpsModel(model);
|
||||
logger.dim(` → ${device.name}: Set runtimeUnit to '${snmp.runtimeUnit}'`);
|
||||
}
|
||||
return device;
|
||||
});
|
||||
|
||||
const result = {
|
||||
...config,
|
||||
version: this.toVersion,
|
||||
upsDevices: migratedDevices,
|
||||
};
|
||||
|
||||
logger.success(
|
||||
`${this.getName()}: Migration complete (${migratedDevices.length} devices updated)`,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { BaseMigration } from './base-migration.ts';
|
||||
import { logger } from '../logger.ts';
|
||||
|
||||
/**
|
||||
* Migration from v4.3 to v4.4
|
||||
*
|
||||
* Changes:
|
||||
* 1. Corrects APC runtimeUnit defaults from minutes to ticks
|
||||
* 2. Bumps version from '4.3' to '4.4'
|
||||
*/
|
||||
export class MigrationV4_3ToV4_4 extends BaseMigration {
|
||||
readonly fromVersion = '4.3';
|
||||
readonly toVersion = '4.4';
|
||||
|
||||
shouldRun(config: Record<string, unknown>): boolean {
|
||||
return config.version === '4.3';
|
||||
}
|
||||
|
||||
migrate(config: Record<string, unknown>): Record<string, unknown> {
|
||||
logger.info(`${this.getName()}: Correcting APC runtimeUnit defaults...`);
|
||||
|
||||
let correctedDevices = 0;
|
||||
const devices = (config.upsDevices as Array<Record<string, unknown>>) || [];
|
||||
const migratedDevices = devices.map((device) => {
|
||||
const snmp = device.snmp as Record<string, unknown> | undefined;
|
||||
if (!snmp || snmp.upsModel !== 'apc') {
|
||||
return device;
|
||||
}
|
||||
|
||||
if (!snmp.runtimeUnit || snmp.runtimeUnit === 'minutes') {
|
||||
snmp.runtimeUnit = 'ticks';
|
||||
correctedDevices += 1;
|
||||
logger.dim(` → ${device.name}: Set runtimeUnit to 'ticks'`);
|
||||
}
|
||||
|
||||
return device;
|
||||
});
|
||||
|
||||
const result = {
|
||||
...config,
|
||||
version: this.toVersion,
|
||||
upsDevices: migratedDevices,
|
||||
};
|
||||
|
||||
logger.success(
|
||||
`${this.getName()}: Migration complete (${correctedDevices} APC device(s) corrected)`,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
+47
-16
@@ -1,24 +1,31 @@
|
||||
import { NupstSnmp } from './snmp/manager.ts';
|
||||
import { NupstUpsd } from './upsd/client.ts';
|
||||
import { NupstDaemon } from './daemon.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 { UpsHandler } from './cli/ups-handler.ts';
|
||||
import { GroupHandler } from './cli/group-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 type { INupstAccessor, IUpdateStatus } from './interfaces/index.ts';
|
||||
|
||||
/**
|
||||
* Main Nupst class that coordinates all components
|
||||
* Acts as a facade to access SNMP, Daemon, and Systemd functionality
|
||||
*/
|
||||
export class Nupst {
|
||||
export class Nupst implements INupstAccessor {
|
||||
private readonly snmp: NupstSnmp;
|
||||
private readonly upsd: NupstUpsd;
|
||||
private readonly daemon: NupstDaemon;
|
||||
private readonly systemd: NupstSystemd;
|
||||
private readonly upsHandler: UpsHandler;
|
||||
private readonly groupHandler: GroupHandler;
|
||||
private readonly serviceHandler: ServiceHandler;
|
||||
private readonly actionHandler: ActionHandler;
|
||||
private readonly featureHandler: FeatureHandler;
|
||||
private updateAvailable: boolean = false;
|
||||
private latestVersion: string = '';
|
||||
|
||||
@@ -29,13 +36,16 @@ export class Nupst {
|
||||
// Initialize core components
|
||||
this.snmp = new NupstSnmp();
|
||||
this.snmp.setNupst(this); // Set up bidirectional reference
|
||||
this.daemon = new NupstDaemon(this.snmp);
|
||||
this.upsd = new NupstUpsd();
|
||||
this.daemon = new NupstDaemon(this.snmp, this.upsd);
|
||||
this.systemd = new NupstSystemd(this.daemon);
|
||||
|
||||
// Initialize handlers
|
||||
this.upsHandler = new UpsHandler(this);
|
||||
this.groupHandler = new GroupHandler(this);
|
||||
this.serviceHandler = new ServiceHandler(this);
|
||||
this.actionHandler = new ActionHandler(this);
|
||||
this.featureHandler = new FeatureHandler(this);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -45,6 +55,13 @@ export class Nupst {
|
||||
return this.snmp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the UPSD manager for NUT protocol communication
|
||||
*/
|
||||
public getUpsd(): NupstUpsd {
|
||||
return this.upsd;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the daemon manager for background monitoring
|
||||
*/
|
||||
@@ -80,12 +97,26 @@ export class Nupst {
|
||||
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
|
||||
* @returns The current version string
|
||||
*/
|
||||
public getVersion(): string {
|
||||
return commitinfo.version;
|
||||
return denoConfig.version;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -114,11 +145,7 @@ export class Nupst {
|
||||
* Get update status information
|
||||
* @returns Object with update status information
|
||||
*/
|
||||
public getUpdateStatus(): {
|
||||
currentVersion: string;
|
||||
latestVersion: string;
|
||||
updateAvailable: boolean;
|
||||
} {
|
||||
public getUpdateStatus(): IUpdateStatus {
|
||||
return {
|
||||
currentVersion: this.getVersion(),
|
||||
latestVersion: this.latestVersion || this.getVersion(),
|
||||
@@ -133,8 +160,8 @@ export class Nupst {
|
||||
private getLatestVersion(): Promise<string> {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const options = {
|
||||
hostname: 'registry.npmjs.org',
|
||||
path: '/@serve.zone/nupst',
|
||||
hostname: 'code.foss.global',
|
||||
path: '/api/v1/repos/serve.zone/nupst/releases/latest',
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
@@ -152,10 +179,14 @@ export class Nupst {
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const response = JSON.parse(data);
|
||||
if (response['dist-tags'] && response['dist-tags'].latest) {
|
||||
resolve(response['dist-tags'].latest);
|
||||
if (response.tag_name) {
|
||||
// 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 {
|
||||
reject(new Error('Failed to parse version from npm registry response'));
|
||||
reject(new Error('Failed to parse version from Gitea API response'));
|
||||
}
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
@@ -204,7 +235,7 @@ export class Nupst {
|
||||
|
||||
if (this.updateAvailable && this.latestVersion) {
|
||||
logger.logBoxLine(`Update Available: ${this.latestVersion}`);
|
||||
logger.logBoxLine('Run "sudo nupst update" to update');
|
||||
logger.logBoxLine('Run "sudo nupst upgrade" to upgrade');
|
||||
logger.logBoxEnd();
|
||||
} else if (checkForUpdates) {
|
||||
logger.logBoxLine('Checking for updates...');
|
||||
@@ -213,7 +244,7 @@ export class Nupst {
|
||||
this.checkForUpdates().then((updateAvailable) => {
|
||||
if (updateAvailable) {
|
||||
logger.logBoxLine(`Update Available: ${this.latestVersion}`);
|
||||
logger.logBoxLine('Run "sudo nupst update" to update');
|
||||
logger.logBoxLine('Run "sudo nupst upgrade" to upgrade');
|
||||
} else {
|
||||
logger.logBoxLine('You are running the latest version');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import * as fs from 'node:fs';
|
||||
|
||||
/**
|
||||
* Pause state interface
|
||||
*/
|
||||
export interface IPauseState {
|
||||
/** Timestamp when pause was activated */
|
||||
pausedAt: number;
|
||||
/** Who initiated the pause (e.g., 'cli', 'api') */
|
||||
pausedBy: string;
|
||||
/** Optional reason for pausing */
|
||||
reason?: string;
|
||||
/** When to auto-resume (null = indefinite, timestamp in ms) */
|
||||
resumeAt?: number | null;
|
||||
}
|
||||
|
||||
export type TPauseTransition = 'unchanged' | 'paused' | 'resumed' | 'autoResumed';
|
||||
|
||||
export interface IPauseSnapshot {
|
||||
isPaused: boolean;
|
||||
pauseState: IPauseState | null;
|
||||
transition: TPauseTransition;
|
||||
}
|
||||
|
||||
export function loadPauseSnapshot(
|
||||
filePath: string,
|
||||
wasPaused: boolean,
|
||||
now: number = Date.now(),
|
||||
): IPauseSnapshot {
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return {
|
||||
isPaused: false,
|
||||
pauseState: null,
|
||||
transition: wasPaused ? 'resumed' : 'unchanged',
|
||||
};
|
||||
}
|
||||
|
||||
const data = fs.readFileSync(filePath, 'utf8');
|
||||
const pauseState = JSON.parse(data) as IPauseState;
|
||||
|
||||
if (pauseState.resumeAt && now >= pauseState.resumeAt) {
|
||||
try {
|
||||
fs.unlinkSync(filePath);
|
||||
} catch (_error) {
|
||||
// Ignore deletion errors and still treat the pause as expired.
|
||||
}
|
||||
|
||||
return {
|
||||
isPaused: false,
|
||||
pauseState: null,
|
||||
transition: wasPaused ? 'autoResumed' : 'unchanged',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isPaused: true,
|
||||
pauseState,
|
||||
transition: wasPaused ? 'unchanged' : 'paused',
|
||||
};
|
||||
} catch (_error) {
|
||||
return {
|
||||
isPaused: false,
|
||||
pauseState: null,
|
||||
transition: 'unchanged',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Protocol abstraction module
|
||||
* Re-exports public types and classes
|
||||
*/
|
||||
|
||||
export type { TProtocol } from './types.ts';
|
||||
export { ProtocolResolver } from './resolver.ts';
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* ProtocolResolver - Routes UPS status queries to the correct protocol implementation
|
||||
*
|
||||
* Abstracts away SNMP vs UPSD differences so the daemon is protocol-agnostic.
|
||||
* Both protocols return the same IUpsStatus interface from ts/snmp/types.ts.
|
||||
*/
|
||||
|
||||
import type { NupstSnmp } from '../snmp/manager.ts';
|
||||
import type { NupstUpsd } from '../upsd/client.ts';
|
||||
import type { ISnmpConfig, IUpsStatus } from '../snmp/types.ts';
|
||||
import type { IUpsdConfig } from '../upsd/types.ts';
|
||||
import type { TProtocol } from './types.ts';
|
||||
|
||||
export class ProtocolResolver {
|
||||
private snmp: NupstSnmp;
|
||||
private upsd: NupstUpsd;
|
||||
|
||||
constructor(snmp: NupstSnmp, upsd: NupstUpsd) {
|
||||
this.snmp = snmp;
|
||||
this.upsd = upsd;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get UPS status using the specified protocol
|
||||
* @param protocol Protocol to use ('snmp' or 'upsd')
|
||||
* @param snmpConfig SNMP configuration (required for 'snmp' protocol)
|
||||
* @param upsdConfig UPSD configuration (required for 'upsd' protocol)
|
||||
* @returns UPS status
|
||||
*/
|
||||
public getUpsStatus(
|
||||
protocol: TProtocol,
|
||||
snmpConfig?: ISnmpConfig,
|
||||
upsdConfig?: IUpsdConfig,
|
||||
): Promise<IUpsStatus> {
|
||||
switch (protocol) {
|
||||
case 'upsd':
|
||||
if (!upsdConfig) {
|
||||
throw new Error('UPSD configuration required for UPSD protocol');
|
||||
}
|
||||
return this.upsd.getUpsStatus(upsdConfig);
|
||||
case 'snmp':
|
||||
default:
|
||||
if (!snmpConfig) {
|
||||
throw new Error('SNMP configuration required for SNMP protocol');
|
||||
}
|
||||
return this.snmp.getUpsStatus(snmpConfig);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* Protocol type for UPS communication
|
||||
*/
|
||||
export type TProtocol = 'snmp' | 'upsd';
|
||||
@@ -0,0 +1,145 @@
|
||||
import process from 'node:process';
|
||||
import * as fs from 'node:fs';
|
||||
import { exec, execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { logger } from './logger.ts';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
interface IShutdownAlternative {
|
||||
cmd: string;
|
||||
args: string[];
|
||||
}
|
||||
|
||||
interface IAlternativeLogConfig {
|
||||
resolvedMessage: (commandPath: string, args: string[]) => string;
|
||||
pathMessage: (command: string, args: string[]) => string;
|
||||
failureMessage?: (command: string, error: unknown) => string;
|
||||
}
|
||||
|
||||
export class ShutdownExecutor {
|
||||
private readonly commonCommandDirs = ['/sbin', '/usr/sbin', '/bin', '/usr/bin'];
|
||||
|
||||
public async scheduleShutdown(delayMinutes: number): Promise<void> {
|
||||
const shutdownMessage = `UPS battery critical, shutting down in ${delayMinutes} minutes`;
|
||||
const shutdownCommandPath = this.findCommandPath('shutdown');
|
||||
|
||||
if (shutdownCommandPath) {
|
||||
logger.log(`Found shutdown command at: ${shutdownCommandPath}`);
|
||||
logger.log(`Executing: ${shutdownCommandPath} -h +${delayMinutes} "UPS battery critical..."`);
|
||||
const { stdout } = await execFileAsync(shutdownCommandPath, [
|
||||
'-h',
|
||||
`+${delayMinutes}`,
|
||||
shutdownMessage,
|
||||
]);
|
||||
logger.log(`Shutdown initiated: ${stdout}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
logger.log('Shutdown command not found in common paths, trying via PATH...');
|
||||
const { stdout } = await execAsync(
|
||||
`shutdown -h +${delayMinutes} "${shutdownMessage}"`,
|
||||
{ env: process.env },
|
||||
);
|
||||
logger.log(`Shutdown initiated: ${stdout}`);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Shutdown command not found: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async forceImmediateShutdown(): Promise<void> {
|
||||
const shutdownMessage = 'EMERGENCY: UPS battery critically low, shutting down NOW';
|
||||
const shutdownCommandPath = this.findCommandPath('shutdown');
|
||||
|
||||
if (shutdownCommandPath) {
|
||||
logger.log(`Found shutdown command at: ${shutdownCommandPath}`);
|
||||
logger.log(`Executing emergency shutdown: ${shutdownCommandPath} -h now`);
|
||||
await execFileAsync(shutdownCommandPath, ['-h', 'now', shutdownMessage]);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.log('Shutdown command not found in common paths, trying via PATH...');
|
||||
await execAsync(`shutdown -h now "${shutdownMessage}"`, {
|
||||
env: process.env,
|
||||
});
|
||||
}
|
||||
|
||||
public async tryScheduledAlternatives(): Promise<boolean> {
|
||||
return await this.tryAlternatives(
|
||||
[
|
||||
{ cmd: 'poweroff', args: ['--force'] },
|
||||
{ cmd: 'halt', args: ['-p'] },
|
||||
{ cmd: 'systemctl', args: ['poweroff'] },
|
||||
{ cmd: 'reboot', args: ['-p'] },
|
||||
],
|
||||
{
|
||||
resolvedMessage: (commandPath, args) =>
|
||||
`Trying alternative shutdown method: ${commandPath} ${args.join(' ')}`,
|
||||
pathMessage: (command, args) => `Trying alternative via PATH: ${command} ${args.join(' ')}`,
|
||||
failureMessage: (command, error) => `Alternative method ${command} failed: ${error}`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
public async tryEmergencyAlternatives(): Promise<boolean> {
|
||||
return await this.tryAlternatives(
|
||||
[
|
||||
{ cmd: 'poweroff', args: ['--force'] },
|
||||
{ cmd: 'halt', args: ['-p'] },
|
||||
{ cmd: 'systemctl', args: ['poweroff'] },
|
||||
],
|
||||
{
|
||||
resolvedMessage: (commandPath, args) => `Emergency: using ${commandPath} ${args.join(' ')}`,
|
||||
pathMessage: (command) => `Emergency: trying ${command} via PATH`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private findCommandPath(command: string): string | null {
|
||||
for (const directory of this.commonCommandDirs) {
|
||||
const commandPath = `${directory}/${command}`;
|
||||
try {
|
||||
if (fs.existsSync(commandPath)) {
|
||||
return commandPath;
|
||||
}
|
||||
} catch (_error) {
|
||||
// Continue checking other paths.
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async tryAlternatives(
|
||||
alternatives: IShutdownAlternative[],
|
||||
logConfig: IAlternativeLogConfig,
|
||||
): Promise<boolean> {
|
||||
for (const alternative of alternatives) {
|
||||
try {
|
||||
const commandPath = this.findCommandPath(alternative.cmd);
|
||||
|
||||
if (commandPath) {
|
||||
logger.log(logConfig.resolvedMessage(commandPath, alternative.args));
|
||||
await execFileAsync(commandPath, alternative.args);
|
||||
return true;
|
||||
}
|
||||
|
||||
logger.log(logConfig.pathMessage(alternative.cmd, alternative.args));
|
||||
await execAsync(`${alternative.cmd} ${alternative.args.join(' ')}`, {
|
||||
env: process.env,
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (logConfig.failureMessage) {
|
||||
logger.error(logConfig.failureMessage(alternative.cmd, error));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { IUpsStatus as IProtocolUpsStatus } from './snmp/types.ts';
|
||||
|
||||
export interface IShutdownMonitoringRow extends Record<string, string> {
|
||||
name: string;
|
||||
battery: string;
|
||||
runtime: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface IShutdownRowFormatters {
|
||||
battery: (batteryCapacity: number) => string;
|
||||
runtime: (batteryRuntime: number) => string;
|
||||
ok: (text: string) => string;
|
||||
critical: (text: string) => string;
|
||||
error: (text: string) => string;
|
||||
}
|
||||
|
||||
export interface IShutdownEmergencyCandidate<TUps> {
|
||||
ups: TUps;
|
||||
status: IProtocolUpsStatus;
|
||||
}
|
||||
|
||||
export function isEmergencyRuntime(
|
||||
batteryRuntime: number,
|
||||
emergencyRuntimeMinutes: number,
|
||||
): boolean {
|
||||
return batteryRuntime < emergencyRuntimeMinutes;
|
||||
}
|
||||
|
||||
export function buildShutdownStatusRow(
|
||||
upsName: string,
|
||||
status: IProtocolUpsStatus,
|
||||
emergencyRuntimeMinutes: number,
|
||||
formatters: IShutdownRowFormatters,
|
||||
): { row: IShutdownMonitoringRow; isCritical: boolean } {
|
||||
const isCritical = isEmergencyRuntime(status.batteryRuntime, emergencyRuntimeMinutes);
|
||||
|
||||
return {
|
||||
row: {
|
||||
name: upsName,
|
||||
battery: formatters.battery(status.batteryCapacity),
|
||||
runtime: formatters.runtime(status.batteryRuntime),
|
||||
status: isCritical ? formatters.critical('CRITICAL!') : formatters.ok('OK'),
|
||||
},
|
||||
isCritical,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildShutdownErrorRow(
|
||||
upsName: string,
|
||||
errorFormatter: (text: string) => string,
|
||||
): IShutdownMonitoringRow {
|
||||
return {
|
||||
name: upsName,
|
||||
battery: errorFormatter('N/A'),
|
||||
runtime: errorFormatter('N/A'),
|
||||
status: errorFormatter('ERROR'),
|
||||
};
|
||||
}
|
||||
|
||||
export function selectEmergencyCandidate<TUps>(
|
||||
currentCandidate: IShutdownEmergencyCandidate<TUps> | null,
|
||||
ups: TUps,
|
||||
status: IProtocolUpsStatus,
|
||||
emergencyRuntimeMinutes: number,
|
||||
): IShutdownEmergencyCandidate<TUps> | null {
|
||||
if (currentCandidate || !isEmergencyRuntime(status.batteryRuntime, emergencyRuntimeMinutes)) {
|
||||
return currentCandidate;
|
||||
}
|
||||
|
||||
return { ups, status };
|
||||
}
|
||||
+445
-254
@@ -1,7 +1,78 @@
|
||||
import * as snmp from 'npm:net-snmp@3.20.0';
|
||||
import * as snmp from 'npm:net-snmp@3.26.1';
|
||||
import { Buffer } from 'node:buffer';
|
||||
import type { IOidSet, ISnmpConfig, IUpsStatus, TUpsModel } from './types.ts';
|
||||
import { UpsOidSets } from './oid-sets.ts';
|
||||
import { convertRuntimeValueToMinutes, getDefaultRuntimeUnitForUpsModel } from './runtime-units.ts';
|
||||
import { SNMP } from '../constants.ts';
|
||||
import { logger } from '../logger.ts';
|
||||
import type { INupstAccessor } from '../interfaces/index.ts';
|
||||
|
||||
type TSnmpMetricDescription =
|
||||
| 'power status'
|
||||
| 'battery capacity'
|
||||
| 'battery runtime'
|
||||
| 'output load'
|
||||
| 'output power'
|
||||
| 'output voltage'
|
||||
| 'output current';
|
||||
|
||||
type TSnmpResponseValue = string | number | bigint | boolean | Buffer;
|
||||
type TSnmpValue = string | number | boolean | Buffer;
|
||||
|
||||
interface ISnmpVarbind {
|
||||
oid: string;
|
||||
type: number;
|
||||
value: TSnmpResponseValue;
|
||||
}
|
||||
|
||||
interface ISnmpSessionOptions {
|
||||
port: number;
|
||||
retries: number;
|
||||
timeout: number;
|
||||
transport: 'udp4' | 'udp6';
|
||||
idBitsSize: 16 | 32;
|
||||
context: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
interface ISnmpV3User {
|
||||
name: string;
|
||||
level: number;
|
||||
authProtocol?: string;
|
||||
authKey?: string;
|
||||
privProtocol?: string;
|
||||
privKey?: string;
|
||||
}
|
||||
|
||||
interface ISnmpSession {
|
||||
get(oids: string[], callback: (error: Error | null, varbinds?: ISnmpVarbind[]) => void): void;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
interface ISnmpModule {
|
||||
Version1: number;
|
||||
Version2c: number;
|
||||
Version3: number;
|
||||
SecurityLevel: {
|
||||
noAuthNoPriv: number;
|
||||
authNoPriv: number;
|
||||
authPriv: number;
|
||||
};
|
||||
AuthProtocols: {
|
||||
md5: string;
|
||||
sha: string;
|
||||
};
|
||||
PrivProtocols: {
|
||||
des: string;
|
||||
aes: string;
|
||||
};
|
||||
createSession(target: string, community: string, options: ISnmpSessionOptions): ISnmpSession;
|
||||
createV3Session(target: string, user: ISnmpV3User, options: ISnmpSessionOptions): ISnmpSession;
|
||||
isVarbindError(varbind: ISnmpVarbind): boolean;
|
||||
varbindError(varbind: ISnmpVarbind): string;
|
||||
}
|
||||
|
||||
const snmpLib = snmp as unknown as ISnmpModule;
|
||||
|
||||
/**
|
||||
* Class for SNMP communication with UPS devices
|
||||
@@ -10,18 +81,18 @@ import { UpsOidSets } from './oid-sets.ts';
|
||||
export class NupstSnmp {
|
||||
// Active OID set
|
||||
private activeOIDs: IOidSet;
|
||||
// Reference to the parent Nupst instance
|
||||
private nupst: any; // Type 'any' to avoid circular dependency
|
||||
// Reference to the parent Nupst instance (uses interface to avoid circular dependency)
|
||||
private nupst: INupstAccessor | null = null;
|
||||
// Debug mode flag
|
||||
private debug: boolean = false;
|
||||
|
||||
// Default SNMP configuration
|
||||
private readonly DEFAULT_CONFIG: ISnmpConfig = {
|
||||
host: '127.0.0.1', // Default to localhost
|
||||
port: 161, // Default SNMP port
|
||||
port: SNMP.DEFAULT_PORT, // Default SNMP port
|
||||
community: 'public', // Default community string for v1/v2c
|
||||
version: 1, // SNMPv1
|
||||
timeout: 5000, // 5 seconds timeout
|
||||
timeout: SNMP.DEFAULT_TIMEOUT_MS, // 5 seconds timeout
|
||||
upsModel: 'cyberpower', // Default UPS model
|
||||
};
|
||||
|
||||
@@ -39,14 +110,14 @@ export class NupstSnmp {
|
||||
* Set reference to the main Nupst instance
|
||||
* @param nupst Reference to the main Nupst instance
|
||||
*/
|
||||
public setNupst(nupst: any): void {
|
||||
public setNupst(nupst: INupstAccessor): void {
|
||||
this.nupst = nupst;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get reference to the main Nupst instance
|
||||
*/
|
||||
public getNupst(): any {
|
||||
public getNupst(): INupstAccessor | null {
|
||||
return this.nupst;
|
||||
}
|
||||
|
||||
@@ -55,7 +126,7 @@ export class NupstSnmp {
|
||||
*/
|
||||
public enableDebug(): void {
|
||||
this.debug = true;
|
||||
console.log('SNMP debug mode enabled - detailed logs will be shown');
|
||||
logger.info('SNMP debug mode enabled - detailed logs will be shown');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -67,7 +138,7 @@ export class NupstSnmp {
|
||||
if (config.upsModel === 'custom' && config.customOIDs) {
|
||||
this.activeOIDs = config.customOIDs;
|
||||
if (this.debug) {
|
||||
console.log('Using custom OIDs:', this.activeOIDs);
|
||||
logger.dim(`Using custom OIDs: ${JSON.stringify(this.activeOIDs)}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -77,10 +148,124 @@ export class NupstSnmp {
|
||||
this.activeOIDs = UpsOidSets.getOidSet(model);
|
||||
|
||||
if (this.debug) {
|
||||
console.log(`Using OIDs for UPS model: ${model}`);
|
||||
logger.dim(`Using OIDs for UPS model: ${model}`);
|
||||
}
|
||||
}
|
||||
|
||||
private createSessionOptions(config: ISnmpConfig): ISnmpSessionOptions {
|
||||
return {
|
||||
port: config.port,
|
||||
retries: SNMP.RETRIES,
|
||||
timeout: config.timeout,
|
||||
transport: 'udp4',
|
||||
idBitsSize: 32,
|
||||
context: config.context || '',
|
||||
version: config.version === 1
|
||||
? snmpLib.Version1
|
||||
: config.version === 2
|
||||
? snmpLib.Version2c
|
||||
: snmpLib.Version3,
|
||||
};
|
||||
}
|
||||
|
||||
private buildV3User(
|
||||
config: ISnmpConfig,
|
||||
): { user: ISnmpV3User; levelLabel: NonNullable<ISnmpConfig['securityLevel']> } {
|
||||
const requestedSecurityLevel = config.securityLevel || 'noAuthNoPriv';
|
||||
const user: ISnmpV3User = {
|
||||
name: config.username || '',
|
||||
level: snmpLib.SecurityLevel.noAuthNoPriv,
|
||||
};
|
||||
let levelLabel: NonNullable<ISnmpConfig['securityLevel']> = 'noAuthNoPriv';
|
||||
|
||||
if (requestedSecurityLevel === 'authNoPriv') {
|
||||
user.level = snmpLib.SecurityLevel.authNoPriv;
|
||||
levelLabel = 'authNoPriv';
|
||||
|
||||
if (config.authProtocol && config.authKey) {
|
||||
user.authProtocol = this.resolveAuthProtocol(config.authProtocol);
|
||||
user.authKey = config.authKey;
|
||||
} else {
|
||||
user.level = snmpLib.SecurityLevel.noAuthNoPriv;
|
||||
levelLabel = 'noAuthNoPriv';
|
||||
if (this.debug) {
|
||||
logger.warn('Missing authProtocol or authKey, falling back to noAuthNoPriv');
|
||||
}
|
||||
}
|
||||
} else if (requestedSecurityLevel === 'authPriv') {
|
||||
user.level = snmpLib.SecurityLevel.authPriv;
|
||||
levelLabel = 'authPriv';
|
||||
|
||||
if (config.authProtocol && config.authKey) {
|
||||
user.authProtocol = this.resolveAuthProtocol(config.authProtocol);
|
||||
user.authKey = config.authKey;
|
||||
|
||||
if (config.privProtocol && config.privKey) {
|
||||
user.privProtocol = this.resolvePrivProtocol(config.privProtocol);
|
||||
user.privKey = config.privKey;
|
||||
} else {
|
||||
user.level = snmpLib.SecurityLevel.authNoPriv;
|
||||
levelLabel = 'authNoPriv';
|
||||
if (this.debug) {
|
||||
logger.warn('Missing privProtocol or privKey, falling back to authNoPriv');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
user.level = snmpLib.SecurityLevel.noAuthNoPriv;
|
||||
levelLabel = 'noAuthNoPriv';
|
||||
if (this.debug) {
|
||||
logger.warn('Missing authProtocol or authKey, falling back to noAuthNoPriv');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { user, levelLabel };
|
||||
}
|
||||
|
||||
private resolveAuthProtocol(protocol: NonNullable<ISnmpConfig['authProtocol']>): string {
|
||||
return protocol === 'MD5' ? snmpLib.AuthProtocols.md5 : snmpLib.AuthProtocols.sha;
|
||||
}
|
||||
|
||||
private resolvePrivProtocol(protocol: NonNullable<ISnmpConfig['privProtocol']>): string {
|
||||
return protocol === 'DES' ? snmpLib.PrivProtocols.des : snmpLib.PrivProtocols.aes;
|
||||
}
|
||||
|
||||
private normalizeSnmpValue(value: TSnmpResponseValue): TSnmpValue {
|
||||
if (Buffer.isBuffer(value)) {
|
||||
const isPrintableAscii = value.every((byte: number) => byte >= 32 && byte <= 126);
|
||||
return isPrintableAscii ? value.toString() : value;
|
||||
}
|
||||
|
||||
if (typeof value === 'bigint') {
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
private coerceNumericSnmpValue(
|
||||
value: TSnmpValue | 0,
|
||||
description: TSnmpMetricDescription,
|
||||
): number {
|
||||
if (typeof value === 'number') {
|
||||
return Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const trimmedValue = value.trim();
|
||||
const parsedValue = Number(trimmedValue);
|
||||
if (trimmedValue && Number.isFinite(parsedValue)) {
|
||||
return parsedValue;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.debug) {
|
||||
logger.warn(`Non-numeric ${description} value received from SNMP, using 0`);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an SNMP GET request using the net-snmp package
|
||||
* @param oid OID to query
|
||||
@@ -91,182 +276,77 @@ export class NupstSnmp {
|
||||
public snmpGet(
|
||||
oid: string,
|
||||
config = this.DEFAULT_CONFIG,
|
||||
retryCount = 0,
|
||||
): Promise<any> {
|
||||
_retryCount = 0,
|
||||
): Promise<TSnmpValue> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (this.debug) {
|
||||
console.log(
|
||||
logger.dim(
|
||||
`Sending SNMP v${config.version} GET request for OID ${oid} to ${config.host}:${config.port}`,
|
||||
);
|
||||
console.log('Using community:', config.community);
|
||||
}
|
||||
|
||||
// Create SNMP options based on configuration
|
||||
const options: any = {
|
||||
port: config.port,
|
||||
retries: 2, // Number of retries
|
||||
timeout: config.timeout,
|
||||
transport: 'udp4',
|
||||
idBitsSize: 32,
|
||||
context: config.context || '',
|
||||
};
|
||||
|
||||
// Set version based on config
|
||||
if (config.version === 1) {
|
||||
options.version = snmp.Version1;
|
||||
} else if (config.version === 2) {
|
||||
options.version = snmp.Version2c;
|
||||
} else {
|
||||
options.version = snmp.Version3;
|
||||
}
|
||||
|
||||
// Create appropriate session based on SNMP version
|
||||
let session;
|
||||
|
||||
if (config.version === 3) {
|
||||
// For SNMPv3, we need to set up authentication and privacy
|
||||
// For SNMPv3, we need a valid security level
|
||||
const securityLevel = config.securityLevel || 'noAuthNoPriv';
|
||||
|
||||
// Create the user object with required structure for net-snmp
|
||||
const user: any = {
|
||||
name: config.username || '',
|
||||
};
|
||||
|
||||
// Set security level
|
||||
if (securityLevel === 'noAuthNoPriv') {
|
||||
user.level = snmp.SecurityLevel.noAuthNoPriv;
|
||||
} else if (securityLevel === 'authNoPriv') {
|
||||
user.level = snmp.SecurityLevel.authNoPriv;
|
||||
|
||||
// Set auth protocol - must provide both protocol and key
|
||||
if (config.authProtocol && config.authKey) {
|
||||
if (config.authProtocol === 'MD5') {
|
||||
user.authProtocol = snmp.AuthProtocols.md5;
|
||||
} else if (config.authProtocol === 'SHA') {
|
||||
user.authProtocol = snmp.AuthProtocols.sha;
|
||||
}
|
||||
user.authKey = config.authKey;
|
||||
} else {
|
||||
// Fallback to noAuthNoPriv if auth details missing
|
||||
user.level = snmp.SecurityLevel.noAuthNoPriv;
|
||||
if (this.debug) {
|
||||
console.log('Warning: Missing authProtocol or authKey, falling back to noAuthNoPriv');
|
||||
}
|
||||
}
|
||||
} else if (securityLevel === 'authPriv') {
|
||||
user.level = snmp.SecurityLevel.authPriv;
|
||||
|
||||
// Set auth protocol - must provide both protocol and key
|
||||
if (config.authProtocol && config.authKey) {
|
||||
if (config.authProtocol === 'MD5') {
|
||||
user.authProtocol = snmp.AuthProtocols.md5;
|
||||
} else if (config.authProtocol === 'SHA') {
|
||||
user.authProtocol = snmp.AuthProtocols.sha;
|
||||
}
|
||||
user.authKey = config.authKey;
|
||||
|
||||
// Set privacy protocol - must provide both protocol and key
|
||||
if (config.privProtocol && config.privKey) {
|
||||
if (config.privProtocol === 'DES') {
|
||||
user.privProtocol = snmp.PrivProtocols.des;
|
||||
} else if (config.privProtocol === 'AES') {
|
||||
user.privProtocol = snmp.PrivProtocols.aes;
|
||||
}
|
||||
user.privKey = config.privKey;
|
||||
} else {
|
||||
// Fallback to authNoPriv if priv details missing
|
||||
user.level = snmp.SecurityLevel.authNoPriv;
|
||||
if (this.debug) {
|
||||
console.log('Warning: Missing privProtocol or privKey, falling back to authNoPriv');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback to noAuthNoPriv if auth details missing
|
||||
user.level = snmp.SecurityLevel.noAuthNoPriv;
|
||||
if (this.debug) {
|
||||
console.log('Warning: Missing authProtocol or authKey, falling back to noAuthNoPriv');
|
||||
}
|
||||
}
|
||||
if (config.version === 1 || config.version === 2) {
|
||||
logger.dim(`Using community: ${config.community}`);
|
||||
}
|
||||
|
||||
if (this.debug) {
|
||||
console.log('SNMPv3 user configuration:', {
|
||||
name: user.name,
|
||||
level: Object.keys(snmp.SecurityLevel).find((key) =>
|
||||
snmp.SecurityLevel[key] === user.level
|
||||
),
|
||||
authProtocol: user.authProtocol ? 'Set' : 'Not Set',
|
||||
authKey: user.authKey ? 'Set' : 'Not Set',
|
||||
privProtocol: user.privProtocol ? 'Set' : 'Not Set',
|
||||
privKey: user.privKey ? 'Set' : 'Not Set',
|
||||
});
|
||||
}
|
||||
|
||||
session = snmp.createV3Session(config.host, user, options);
|
||||
} else {
|
||||
// For SNMPv1/v2c, we use the community string
|
||||
session = snmp.createSession(config.host, config.community || 'public', options);
|
||||
}
|
||||
|
||||
const options = this.createSessionOptions(config);
|
||||
const session: ISnmpSession = config.version === 3
|
||||
? (() => {
|
||||
const { user, levelLabel } = this.buildV3User(config);
|
||||
|
||||
if (this.debug) {
|
||||
logger.dim(
|
||||
`SNMPv3 user configuration: name=${user.name}, level=${levelLabel}, authProtocol=${
|
||||
user.authProtocol ? 'Set' : 'Not Set'
|
||||
}, privProtocol=${user.privProtocol ? 'Set' : 'Not Set'}`,
|
||||
);
|
||||
}
|
||||
|
||||
return snmpLib.createV3Session(config.host, user, options);
|
||||
})()
|
||||
: snmpLib.createSession(config.host, config.community || 'public', options);
|
||||
|
||||
// Convert the OID string to an array of OIDs if multiple OIDs are needed
|
||||
const oids = [oid];
|
||||
|
||||
// Send the GET request
|
||||
session.get(oids, (error: any, varbinds: any[]) => {
|
||||
session.get(oids, (error: Error | null, varbinds?: ISnmpVarbind[]) => {
|
||||
// Close the session to release resources
|
||||
session.close();
|
||||
|
||||
if (error) {
|
||||
if (this.debug) {
|
||||
console.error('SNMP GET error:', error);
|
||||
logger.error(`SNMP GET error: ${error}`);
|
||||
}
|
||||
reject(new Error(`SNMP GET error: ${error.message || error}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!varbinds || varbinds.length === 0) {
|
||||
const varbind = varbinds?.[0];
|
||||
|
||||
if (!varbind) {
|
||||
if (this.debug) {
|
||||
console.error('No varbinds returned in response');
|
||||
logger.error('No varbinds returned in response');
|
||||
}
|
||||
reject(new Error('No varbinds returned in response'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for SNMP errors in the response
|
||||
if (
|
||||
varbinds[0].type === snmp.ObjectType.NoSuchObject ||
|
||||
varbinds[0].type === snmp.ObjectType.NoSuchInstance ||
|
||||
varbinds[0].type === snmp.ObjectType.EndOfMibView
|
||||
) {
|
||||
if (snmpLib.isVarbindError(varbind)) {
|
||||
const errorMessage = snmpLib.varbindError(varbind);
|
||||
if (this.debug) {
|
||||
console.error('SNMP error:', snmp.ObjectType[varbinds[0].type]);
|
||||
logger.error(`SNMP error: ${errorMessage}`);
|
||||
}
|
||||
reject(new Error(`SNMP error: ${snmp.ObjectType[varbinds[0].type]}`));
|
||||
reject(new Error(`SNMP error: ${errorMessage}`));
|
||||
return;
|
||||
}
|
||||
|
||||
// Process the response value based on its type
|
||||
let value = varbinds[0].value;
|
||||
|
||||
// Handle specific types that might need conversion
|
||||
if (Buffer.isBuffer(value)) {
|
||||
// If value is a Buffer, try to convert it to a string if it's printable ASCII
|
||||
const isPrintableAscii = value.every((byte: number) => byte >= 32 && byte <= 126);
|
||||
if (isPrintableAscii) {
|
||||
value = value.toString();
|
||||
}
|
||||
} else if (typeof value === 'bigint') {
|
||||
// Convert BigInt to a normal number or string if needed
|
||||
value = Number(value);
|
||||
}
|
||||
const value = this.normalizeSnmpValue(varbind.value);
|
||||
|
||||
if (this.debug) {
|
||||
console.log('SNMP response:', {
|
||||
oid: varbinds[0].oid,
|
||||
type: varbinds[0].type,
|
||||
value: value,
|
||||
});
|
||||
logger.dim(
|
||||
`SNMP response: oid=${varbind.oid}, type=${varbind.type}, value=${value}`,
|
||||
);
|
||||
}
|
||||
|
||||
resolve(value);
|
||||
@@ -285,80 +365,133 @@ export class NupstSnmp {
|
||||
this.setActiveOIDs(config);
|
||||
|
||||
if (this.debug) {
|
||||
console.log('---------------------------------------');
|
||||
console.log('Getting UPS status with config:');
|
||||
console.log(' Host:', config.host);
|
||||
console.log(' Port:', config.port);
|
||||
console.log(' Version:', config.version);
|
||||
console.log(' Timeout:', config.timeout, 'ms');
|
||||
console.log(' UPS Model:', config.upsModel || 'cyberpower');
|
||||
logger.dim('---------------------------------------');
|
||||
logger.dim('Getting UPS status with config:');
|
||||
logger.dim(` Host: ${config.host}`);
|
||||
logger.dim(` Port: ${config.port}`);
|
||||
logger.dim(` Version: ${config.version}`);
|
||||
logger.dim(` Timeout: ${config.timeout} ms`);
|
||||
logger.dim(` UPS Model: ${config.upsModel || 'cyberpower'}`);
|
||||
if (config.version === 1 || config.version === 2) {
|
||||
console.log(' Community:', config.community);
|
||||
logger.dim(` Community: ${config.community}`);
|
||||
} else if (config.version === 3) {
|
||||
console.log(' Security Level:', config.securityLevel);
|
||||
console.log(' Username:', config.username);
|
||||
console.log(' Auth Protocol:', config.authProtocol || 'None');
|
||||
console.log(' Privacy Protocol:', config.privProtocol || 'None');
|
||||
logger.dim(` Security Level: ${config.securityLevel}`);
|
||||
logger.dim(` Username: ${config.username}`);
|
||||
logger.dim(` Auth Protocol: ${config.authProtocol || 'None'}`);
|
||||
logger.dim(` Privacy Protocol: ${config.privProtocol || 'None'}`);
|
||||
}
|
||||
console.log('Using OIDs:');
|
||||
console.log(' Power Status:', this.activeOIDs.POWER_STATUS);
|
||||
console.log(' Battery Capacity:', this.activeOIDs.BATTERY_CAPACITY);
|
||||
console.log(' Battery Runtime:', this.activeOIDs.BATTERY_RUNTIME);
|
||||
console.log('---------------------------------------');
|
||||
logger.dim('Using OIDs:');
|
||||
logger.dim(` Power Status: ${this.activeOIDs.POWER_STATUS}`);
|
||||
logger.dim(` Battery Capacity: ${this.activeOIDs.BATTERY_CAPACITY}`);
|
||||
logger.dim(` Battery Runtime: ${this.activeOIDs.BATTERY_RUNTIME}`);
|
||||
logger.dim(` Output Load: ${this.activeOIDs.OUTPUT_LOAD}`);
|
||||
logger.dim(` Output Power: ${this.activeOIDs.OUTPUT_POWER}`);
|
||||
logger.dim(` Output Voltage: ${this.activeOIDs.OUTPUT_VOLTAGE}`);
|
||||
logger.dim(` Output Current: ${this.activeOIDs.OUTPUT_CURRENT}`);
|
||||
logger.dim('---------------------------------------');
|
||||
}
|
||||
|
||||
// Get all values with independent retry logic
|
||||
const powerStatusValue = await this.getSNMPValueWithRetry(
|
||||
this.activeOIDs.POWER_STATUS,
|
||||
const powerStatusValue = this.coerceNumericSnmpValue(
|
||||
await this.getSNMPValueWithRetry(this.activeOIDs.POWER_STATUS, 'power status', config),
|
||||
'power status',
|
||||
config,
|
||||
);
|
||||
const batteryCapacity = await this.getSNMPValueWithRetry(
|
||||
this.activeOIDs.BATTERY_CAPACITY,
|
||||
const batteryCapacity = this.coerceNumericSnmpValue(
|
||||
await this.getSNMPValueWithRetry(
|
||||
this.activeOIDs.BATTERY_CAPACITY,
|
||||
'battery capacity',
|
||||
config,
|
||||
),
|
||||
'battery capacity',
|
||||
config,
|
||||
) || 0;
|
||||
const batteryRuntime = await this.getSNMPValueWithRetry(
|
||||
this.activeOIDs.BATTERY_RUNTIME,
|
||||
);
|
||||
const batteryRuntime = this.coerceNumericSnmpValue(
|
||||
await this.getSNMPValueWithRetry(
|
||||
this.activeOIDs.BATTERY_RUNTIME,
|
||||
'battery runtime',
|
||||
config,
|
||||
),
|
||||
'battery runtime',
|
||||
config,
|
||||
) || 0;
|
||||
);
|
||||
|
||||
// Get power draw metrics
|
||||
const outputLoad = this.coerceNumericSnmpValue(
|
||||
await this.getSNMPValueWithRetry(this.activeOIDs.OUTPUT_LOAD, 'output load', config),
|
||||
'output load',
|
||||
);
|
||||
const outputPower = this.coerceNumericSnmpValue(
|
||||
await this.getSNMPValueWithRetry(this.activeOIDs.OUTPUT_POWER, 'output power', config),
|
||||
'output power',
|
||||
);
|
||||
const outputVoltage = this.coerceNumericSnmpValue(
|
||||
await this.getSNMPValueWithRetry(this.activeOIDs.OUTPUT_VOLTAGE, 'output voltage', config),
|
||||
'output voltage',
|
||||
);
|
||||
const outputCurrent = this.coerceNumericSnmpValue(
|
||||
await this.getSNMPValueWithRetry(this.activeOIDs.OUTPUT_CURRENT, 'output current', config),
|
||||
'output current',
|
||||
);
|
||||
|
||||
// Determine power status - handle different values for different UPS models
|
||||
const powerStatus = this.determinePowerStatus(config.upsModel, powerStatusValue);
|
||||
|
||||
// Convert to minutes for UPS models with different time units
|
||||
const processedRuntime = this.processRuntimeValue(config.upsModel, batteryRuntime);
|
||||
const processedRuntime = this.processRuntimeValue(config, 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) {
|
||||
logger.dim(
|
||||
`Calculated power from V×I: ${processedVoltage}V × ${processedCurrent}A = ${processedPower}W`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const result = {
|
||||
powerStatus,
|
||||
batteryCapacity,
|
||||
batteryRuntime: processedRuntime,
|
||||
outputLoad,
|
||||
outputPower: processedPower,
|
||||
outputVoltage: processedVoltage,
|
||||
outputCurrent: processedCurrent,
|
||||
raw: {
|
||||
powerStatus: powerStatusValue,
|
||||
batteryCapacity,
|
||||
batteryRuntime,
|
||||
outputLoad,
|
||||
outputPower,
|
||||
outputVoltage,
|
||||
outputCurrent,
|
||||
},
|
||||
};
|
||||
|
||||
if (this.debug) {
|
||||
console.log('---------------------------------------');
|
||||
console.log('UPS status result:');
|
||||
console.log(' Power Status:', result.powerStatus);
|
||||
console.log(' Battery Capacity:', result.batteryCapacity + '%');
|
||||
console.log(' Battery Runtime:', result.batteryRuntime, 'minutes');
|
||||
console.log('---------------------------------------');
|
||||
logger.dim('---------------------------------------');
|
||||
logger.dim('UPS status result:');
|
||||
logger.dim(` Power Status: ${result.powerStatus}`);
|
||||
logger.dim(` Battery Capacity: ${result.batteryCapacity}%`);
|
||||
logger.dim(` Battery Runtime: ${result.batteryRuntime} minutes`);
|
||||
logger.dim(` Output Load: ${result.outputLoad}%`);
|
||||
logger.dim(` Output Power: ${result.outputPower} watts`);
|
||||
logger.dim(` Output Voltage: ${result.outputVoltage} volts`);
|
||||
logger.dim(` Output Current: ${result.outputCurrent} amps`);
|
||||
logger.dim('---------------------------------------');
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (this.debug) {
|
||||
console.error('---------------------------------------');
|
||||
console.error(
|
||||
'Error getting UPS status:',
|
||||
error instanceof Error ? error.message : String(error),
|
||||
logger.error('---------------------------------------');
|
||||
logger.error(
|
||||
`Error getting UPS status: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
console.error('---------------------------------------');
|
||||
logger.error('---------------------------------------');
|
||||
}
|
||||
throw new Error(
|
||||
`Failed to get UPS status: ${error instanceof Error ? error.message : String(error)}`,
|
||||
@@ -375,31 +508,30 @@ export class NupstSnmp {
|
||||
*/
|
||||
private async getSNMPValueWithRetry(
|
||||
oid: string,
|
||||
description: string,
|
||||
description: TSnmpMetricDescription,
|
||||
config: ISnmpConfig,
|
||||
): Promise<any> {
|
||||
): Promise<TSnmpValue | 0> {
|
||||
if (oid === '') {
|
||||
if (this.debug) {
|
||||
console.log(`No OID provided for ${description}, skipping`);
|
||||
logger.dim(`No OID provided for ${description}, skipping`);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (this.debug) {
|
||||
console.log(`Getting ${description} OID: ${oid}`);
|
||||
logger.dim(`Getting ${description} OID: ${oid}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const value = await this.snmpGet(oid, config);
|
||||
if (this.debug) {
|
||||
console.log(`${description} value:`, value);
|
||||
logger.dim(`${description} value: ${value}`);
|
||||
}
|
||||
return value;
|
||||
} catch (error) {
|
||||
if (this.debug) {
|
||||
console.error(
|
||||
`Error getting ${description}:`,
|
||||
error instanceof Error ? error.message : String(error),
|
||||
logger.error(
|
||||
`Error getting ${description}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -415,7 +547,7 @@ export class NupstSnmp {
|
||||
|
||||
// Return a default value if all attempts fail
|
||||
if (this.debug) {
|
||||
console.log(`Using default value 0 for ${description}`);
|
||||
logger.dim(`Using default value 0 for ${description}`);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -430,30 +562,31 @@ export class NupstSnmp {
|
||||
*/
|
||||
private async tryFallbackSecurityLevels(
|
||||
oid: string,
|
||||
description: string,
|
||||
description: TSnmpMetricDescription,
|
||||
config: ISnmpConfig,
|
||||
): Promise<any> {
|
||||
): Promise<TSnmpValue | 0> {
|
||||
if (this.debug) {
|
||||
console.log(`Retrying ${description} with fallback security level...`);
|
||||
logger.dim(`Retrying ${description} with fallback security level...`);
|
||||
}
|
||||
|
||||
// Try with authNoPriv if current level is authPriv
|
||||
if (config.securityLevel === 'authPriv') {
|
||||
const retryConfig = { ...config, securityLevel: 'authNoPriv' as 'authNoPriv' };
|
||||
const retryConfig = { ...config, securityLevel: 'authNoPriv' as const };
|
||||
try {
|
||||
if (this.debug) {
|
||||
console.log(`Retrying with authNoPriv security level`);
|
||||
logger.dim(`Retrying with authNoPriv security level`);
|
||||
}
|
||||
const value = await this.snmpGet(oid, retryConfig);
|
||||
if (this.debug) {
|
||||
console.log(`${description} retry value:`, value);
|
||||
logger.dim(`${description} retry value: ${value}`);
|
||||
}
|
||||
return value;
|
||||
} catch (retryError) {
|
||||
if (this.debug) {
|
||||
console.error(
|
||||
`Retry failed for ${description}:`,
|
||||
retryError instanceof Error ? retryError.message : String(retryError),
|
||||
logger.error(
|
||||
`Retry failed for ${description}: ${
|
||||
retryError instanceof Error ? retryError.message : String(retryError)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -461,21 +594,22 @@ export class NupstSnmp {
|
||||
|
||||
// Try with noAuthNoPriv as a last resort
|
||||
if (config.securityLevel === 'authPriv' || config.securityLevel === 'authNoPriv') {
|
||||
const retryConfig = { ...config, securityLevel: 'noAuthNoPriv' as 'noAuthNoPriv' };
|
||||
const retryConfig = { ...config, securityLevel: 'noAuthNoPriv' as const };
|
||||
try {
|
||||
if (this.debug) {
|
||||
console.log(`Retrying with noAuthNoPriv security level`);
|
||||
logger.dim(`Retrying with noAuthNoPriv security level`);
|
||||
}
|
||||
const value = await this.snmpGet(oid, retryConfig);
|
||||
if (this.debug) {
|
||||
console.log(`${description} retry value:`, value);
|
||||
logger.dim(`${description} retry value: ${value}`);
|
||||
}
|
||||
return value;
|
||||
} catch (retryError) {
|
||||
if (this.debug) {
|
||||
console.error(
|
||||
`Retry failed for ${description}:`,
|
||||
retryError instanceof Error ? retryError.message : String(retryError),
|
||||
logger.error(
|
||||
`Retry failed for ${description}: ${
|
||||
retryError instanceof Error ? retryError.message : String(retryError)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -486,36 +620,37 @@ export class NupstSnmp {
|
||||
|
||||
/**
|
||||
* Try standard OIDs as fallback
|
||||
* @param oid OID to query
|
||||
* @param _oid Original OID (unused, kept for method signature consistency)
|
||||
* @param description Description of the value for logging
|
||||
* @param config SNMP configuration
|
||||
* @returns Promise resolving to the SNMP value
|
||||
*/
|
||||
private async tryStandardOids(
|
||||
oid: string,
|
||||
description: string,
|
||||
_oid: string,
|
||||
description: TSnmpMetricDescription,
|
||||
config: ISnmpConfig,
|
||||
): Promise<any> {
|
||||
): Promise<TSnmpValue | 0> {
|
||||
try {
|
||||
// Try RFC 1628 standard UPS MIB OIDs
|
||||
const standardOIDs = UpsOidSets.getStandardOids();
|
||||
|
||||
if (this.debug) {
|
||||
console.log(
|
||||
logger.dim(
|
||||
`Trying standard RFC 1628 OID for ${description}: ${standardOIDs[description]}`,
|
||||
);
|
||||
}
|
||||
|
||||
const standardValue = await this.snmpGet(standardOIDs[description], config);
|
||||
if (this.debug) {
|
||||
console.log(`${description} standard OID value:`, standardValue);
|
||||
logger.dim(`${description} standard OID value: ${standardValue}`);
|
||||
}
|
||||
return standardValue;
|
||||
} catch (stdError) {
|
||||
if (this.debug) {
|
||||
console.error(
|
||||
`Standard OID retry failed for ${description}:`,
|
||||
stdError instanceof Error ? stdError.message : String(stdError),
|
||||
logger.error(
|
||||
`Standard OID retry failed for ${description}: ${
|
||||
stdError instanceof Error ? stdError.message : String(stdError)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -560,46 +695,102 @@ export class NupstSnmp {
|
||||
}
|
||||
|
||||
/**
|
||||
* Process runtime value based on UPS model
|
||||
* @param upsModel UPS model
|
||||
* Process runtime value based on config runtimeUnit or UPS model
|
||||
* @param config SNMP configuration (uses runtimeUnit if set, otherwise falls back to upsModel)
|
||||
* @param batteryRuntime Raw battery runtime value
|
||||
* @returns Processed runtime in minutes
|
||||
*/
|
||||
private processRuntimeValue(
|
||||
upsModel: TUpsModel | undefined,
|
||||
config: ISnmpConfig,
|
||||
batteryRuntime: number,
|
||||
): number {
|
||||
if (this.debug) {
|
||||
console.log('Raw runtime value:', batteryRuntime);
|
||||
logger.dim(`Raw runtime value: ${batteryRuntime}`);
|
||||
}
|
||||
|
||||
if (upsModel === 'cyberpower' && batteryRuntime > 0) {
|
||||
// CyberPower: TimeTicks is in 1/100 seconds, convert to minutes
|
||||
const minutes = Math.floor(batteryRuntime / 6000); // 6000 ticks = 1 minute
|
||||
if (this.debug) {
|
||||
console.log(
|
||||
`Converting CyberPower runtime from ${batteryRuntime} ticks to ${minutes} minutes`,
|
||||
);
|
||||
}
|
||||
return minutes;
|
||||
} else if (upsModel === 'eaton' && batteryRuntime > 0) {
|
||||
// Eaton: Runtime is in seconds, convert to minutes
|
||||
const minutes = Math.floor(batteryRuntime / 60);
|
||||
if (this.debug) {
|
||||
console.log(
|
||||
`Converting Eaton runtime from ${batteryRuntime} seconds to ${minutes} minutes`,
|
||||
);
|
||||
}
|
||||
return minutes;
|
||||
} else if (batteryRuntime > 10000) {
|
||||
// Generic conversion for large tick values (likely TimeTicks)
|
||||
const minutes = Math.floor(batteryRuntime / 6000);
|
||||
if (this.debug) {
|
||||
console.log(`Converting ${batteryRuntime} ticks to ${minutes} minutes`);
|
||||
}
|
||||
return minutes;
|
||||
const runtimeUnit = config.runtimeUnit ||
|
||||
getDefaultRuntimeUnitForUpsModel(config.upsModel, batteryRuntime);
|
||||
const minutes = convertRuntimeValueToMinutes(config, batteryRuntime);
|
||||
|
||||
if (this.debug && minutes !== batteryRuntime) {
|
||||
const source = config.runtimeUnit
|
||||
? `runtimeUnit: ${runtimeUnit}`
|
||||
: `upsModel: ${config.upsModel || 'auto'}`;
|
||||
logger.dim(
|
||||
`Converting runtime from ${batteryRuntime} ${runtimeUnit} to ${minutes} minutes (${source})`,
|
||||
);
|
||||
}
|
||||
|
||||
return batteryRuntime;
|
||||
return minutes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
logger.dim(`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) {
|
||||
logger.dim(
|
||||
`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) {
|
||||
logger.dim(`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) {
|
||||
logger.dim(
|
||||
`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) {
|
||||
logger.dim(
|
||||
`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) {
|
||||
logger.dim(`${upsModel.toUpperCase()} current already in RMS Amps: ${outputCurrent}A`);
|
||||
}
|
||||
|
||||
return outputCurrent;
|
||||
}
|
||||
}
|
||||
|
||||
+31
-3
@@ -14,28 +14,40 @@ export class UpsOidSets {
|
||||
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_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: {
|
||||
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_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 (TimeTicks)
|
||||
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: {
|
||||
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_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
|
||||
@@ -47,6 +59,10 @@ export class UpsOidSets {
|
||||
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_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
|
||||
@@ -58,6 +74,10 @@ export class UpsOidSets {
|
||||
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_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
|
||||
@@ -69,6 +89,10 @@ export class UpsOidSets {
|
||||
POWER_STATUS: '',
|
||||
BATTERY_CAPACITY: '',
|
||||
BATTERY_RUNTIME: '',
|
||||
OUTPUT_LOAD: '',
|
||||
OUTPUT_POWER: '',
|
||||
OUTPUT_VOLTAGE: '',
|
||||
OUTPUT_CURRENT: '',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -90,6 +114,10 @@ export class UpsOidSets {
|
||||
'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 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)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { ISnmpConfig, TRuntimeUnit, TUpsModel } from './types.ts';
|
||||
|
||||
/**
|
||||
* Return the runtime unit that matches the bundled OID set for a UPS model.
|
||||
*/
|
||||
export function getDefaultRuntimeUnitForUpsModel(
|
||||
upsModel: TUpsModel | undefined,
|
||||
batteryRuntime?: number,
|
||||
): TRuntimeUnit {
|
||||
switch (upsModel) {
|
||||
case 'cyberpower':
|
||||
case 'apc':
|
||||
return 'ticks';
|
||||
case 'eaton':
|
||||
return 'seconds';
|
||||
case 'custom':
|
||||
case 'tripplite':
|
||||
case 'liebert':
|
||||
case undefined:
|
||||
if (batteryRuntime !== undefined && batteryRuntime > 10000) {
|
||||
return 'ticks';
|
||||
}
|
||||
return 'minutes';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an SNMP runtime value to minutes using explicit config first, then model defaults.
|
||||
*/
|
||||
export function convertRuntimeValueToMinutes(
|
||||
config: Pick<ISnmpConfig, 'runtimeUnit' | 'upsModel'>,
|
||||
batteryRuntime: number,
|
||||
): number {
|
||||
if (batteryRuntime <= 0) {
|
||||
return batteryRuntime;
|
||||
}
|
||||
|
||||
const runtimeUnit = config.runtimeUnit ||
|
||||
getDefaultRuntimeUnitForUpsModel(config.upsModel, batteryRuntime);
|
||||
|
||||
if (runtimeUnit === 'seconds') {
|
||||
return Math.floor(batteryRuntime / 60);
|
||||
}
|
||||
|
||||
if (runtimeUnit === 'ticks') {
|
||||
return Math.floor(batteryRuntime / 6000);
|
||||
}
|
||||
|
||||
return batteryRuntime;
|
||||
}
|
||||
+25
-2
@@ -9,13 +9,21 @@ import { Buffer } from 'node:buffer';
|
||||
*/
|
||||
export interface IUpsStatus {
|
||||
/** Current power status */
|
||||
powerStatus: 'online' | 'onBattery' | 'unknown';
|
||||
powerStatus: 'online' | 'onBattery' | 'unknown' | 'unreachable';
|
||||
/** Battery capacity percentage */
|
||||
batteryCapacity: number;
|
||||
/** Remaining runtime in minutes */
|
||||
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: Record<string, any>;
|
||||
raw: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -28,6 +36,14 @@ export interface IOidSet {
|
||||
BATTERY_CAPACITY: string;
|
||||
/** OID for battery runtime */
|
||||
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) */
|
||||
@@ -42,6 +58,11 @@ export interface IOidSet {
|
||||
*/
|
||||
export type TUpsModel = 'cyberpower' | 'apc' | 'eaton' | 'tripplite' | 'liebert' | 'custom';
|
||||
|
||||
/**
|
||||
* Runtime unit for battery runtime SNMP values
|
||||
*/
|
||||
export type TRuntimeUnit = 'minutes' | 'seconds' | 'ticks';
|
||||
|
||||
/**
|
||||
* SNMP Configuration interface
|
||||
*/
|
||||
@@ -80,6 +101,8 @@ export interface ISnmpConfig {
|
||||
upsModel?: TUpsModel;
|
||||
/** Custom OIDs when using custom UPS model */
|
||||
customOIDs?: IOidSet;
|
||||
/** Unit of the battery runtime SNMP value. Overrides model-based auto-detection when set. */
|
||||
runtimeUnit?: TRuntimeUnit;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+329
-62
@@ -1,9 +1,71 @@
|
||||
import process from 'node:process';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { NupstDaemon } from './daemon.ts';
|
||||
import { execFileSync, execSync } from 'node:child_process';
|
||||
import { type IUpsConfig, NupstDaemon } from './daemon.ts';
|
||||
import { NupstSnmp } from './snmp/manager.ts';
|
||||
import { logger } from './logger.ts';
|
||||
import { theme, symbols, getBatteryColor, getRuntimeColor, formatPowerStatus } from './colors.ts';
|
||||
import { formatPowerStatus, getBatteryColor, getRuntimeColor, symbols, theme } from './colors.ts';
|
||||
import { SHUTDOWN } from './constants.ts';
|
||||
|
||||
interface IServiceStatusSnapshot {
|
||||
loadState: string;
|
||||
activeState: string;
|
||||
subState: string;
|
||||
pid: string;
|
||||
memory: string;
|
||||
cpu: string;
|
||||
}
|
||||
|
||||
function formatSystemdMemory(memoryBytes: string): string {
|
||||
const bytes = Number(memoryBytes);
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const units = ['B', 'K', 'M', 'G', 'T', 'P'];
|
||||
let value = bytes;
|
||||
let unitIndex = 0;
|
||||
|
||||
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||
value /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
if (unitIndex === 0) {
|
||||
return `${Math.round(value)}B`;
|
||||
}
|
||||
|
||||
return `${value.toFixed(1).replace(/\.0$/, '')}${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
function formatSystemdCpu(cpuNanoseconds: string): string {
|
||||
const nanoseconds = Number(cpuNanoseconds);
|
||||
if (!Number.isFinite(nanoseconds) || nanoseconds <= 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const milliseconds = nanoseconds / 1_000_000;
|
||||
if (milliseconds < 1000) {
|
||||
return `${Math.round(milliseconds)}ms`;
|
||||
}
|
||||
|
||||
const seconds = milliseconds / 1000;
|
||||
if (seconds < 60) {
|
||||
return `${seconds.toFixed(seconds >= 10 ? 1 : 3).replace(/\.?0+$/, '')}s`;
|
||||
}
|
||||
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
if (minutes < 60) {
|
||||
return `${minutes}min ${
|
||||
remainingSeconds.toFixed(remainingSeconds >= 10 ? 1 : 3).replace(/\.?0+$/, '')
|
||||
}s`;
|
||||
}
|
||||
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const remainingMinutes = minutes % 60;
|
||||
return `${hours}h ${remainingMinutes}min`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Class for managing systemd service
|
||||
@@ -53,7 +115,11 @@ WantedBy=multi-user.target
|
||||
logger.log('');
|
||||
logger.error('No configuration found');
|
||||
logger.log(` ${theme.dim('Config file:')} ${configPath}`);
|
||||
logger.log(` ${theme.dim('Run')} ${theme.command('nupst ups add')} ${theme.dim('to create a configuration')}`);
|
||||
logger.log(
|
||||
` ${theme.dim('Run')} ${theme.command('nupst ups add')} ${
|
||||
theme.dim('to create a configuration')
|
||||
}`,
|
||||
);
|
||||
logger.log('');
|
||||
throw new Error('Configuration not found');
|
||||
}
|
||||
@@ -141,32 +207,43 @@ WantedBy=multi-user.target
|
||||
private async displayVersionInfo(): Promise<void> {
|
||||
try {
|
||||
const nupst = this.daemon.getNupstSnmp().getNupst();
|
||||
if (!nupst) {
|
||||
return;
|
||||
}
|
||||
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}`)}`,
|
||||
`${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 upgrade')} ${theme.dim('to upgrade')}`,
|
||||
);
|
||||
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')}`,
|
||||
`${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)}`);
|
||||
if (nupst) {
|
||||
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
|
||||
}
|
||||
@@ -207,52 +284,77 @@ WantedBy=multi-user.target
|
||||
* Display the systemd service status
|
||||
* @private
|
||||
*/
|
||||
private getServiceStatusSnapshot(): IServiceStatusSnapshot {
|
||||
const output = execFileSync(
|
||||
'systemctl',
|
||||
[
|
||||
'show',
|
||||
'nupst.service',
|
||||
'--property=LoadState,ActiveState,SubState,MainPID,MemoryCurrent,CPUUsageNSec',
|
||||
],
|
||||
{ encoding: 'utf8' },
|
||||
);
|
||||
|
||||
const properties = new Map<string, string>();
|
||||
for (const line of output.split('\n')) {
|
||||
const separatorIndex = line.indexOf('=');
|
||||
if (separatorIndex === -1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
properties.set(line.slice(0, separatorIndex), line.slice(separatorIndex + 1));
|
||||
}
|
||||
|
||||
const pid = properties.get('MainPID') || '';
|
||||
return {
|
||||
loadState: properties.get('LoadState') || '',
|
||||
activeState: properties.get('ActiveState') || '',
|
||||
subState: properties.get('SubState') || '',
|
||||
pid: pid !== '0' ? pid : '',
|
||||
memory: formatSystemdMemory(properties.get('MemoryCurrent') || ''),
|
||||
cpu: formatSystemdCpu(properties.get('CPUUsageNSec') || ''),
|
||||
};
|
||||
}
|
||||
|
||||
private displayServiceStatus(): void {
|
||||
try {
|
||||
const serviceStatus = execSync('systemctl status nupst.service').toString();
|
||||
const lines = serviceStatus.split('\n');
|
||||
|
||||
// Parse key information from systemctl output
|
||||
let isActive = false;
|
||||
let pid = '';
|
||||
let memory = '';
|
||||
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];
|
||||
}
|
||||
}
|
||||
const snapshot = this.getServiceStatusSnapshot();
|
||||
|
||||
// Display beautiful status
|
||||
logger.log('');
|
||||
if (isActive) {
|
||||
logger.log(`${symbols.running} ${theme.success('Service:')} ${theme.statusActive('active (running)')}`);
|
||||
if (snapshot.loadState === 'not-found') {
|
||||
logger.log(
|
||||
`${symbols.stopped} ${theme.dim('Service:')} ${theme.statusInactive('not installed')}`,
|
||||
);
|
||||
} else if (snapshot.activeState === 'active') {
|
||||
const serviceState = snapshot.subState
|
||||
? `${snapshot.activeState} (${snapshot.subState})`
|
||||
: snapshot.activeState;
|
||||
logger.log(
|
||||
`${symbols.running} ${theme.success('Service:')} ${theme.statusActive(serviceState)}`,
|
||||
);
|
||||
} else {
|
||||
logger.log(`${symbols.stopped} ${theme.dim('Service:')} ${theme.statusInactive('inactive')}`);
|
||||
const serviceState = snapshot.subState && snapshot.subState !== snapshot.activeState
|
||||
? `${snapshot.activeState} (${snapshot.subState})`
|
||||
: snapshot.activeState || 'inactive';
|
||||
logger.log(
|
||||
`${symbols.stopped} ${theme.dim('Service:')} ${theme.statusInactive(serviceState)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (pid || memory || cpu) {
|
||||
if (snapshot.pid || snapshot.memory || snapshot.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)}`);
|
||||
if (snapshot.pid) details.push(`PID: ${theme.dim(snapshot.pid)}`);
|
||||
if (snapshot.memory) details.push(`Memory: ${theme.dim(snapshot.memory)}`);
|
||||
if (snapshot.cpu) details.push(`CPU: ${theme.dim(snapshot.cpu)}`);
|
||||
logger.log(` ${details.join(' ')}`);
|
||||
}
|
||||
logger.log('');
|
||||
|
||||
} catch (error) {
|
||||
logger.log('');
|
||||
logger.log(`${symbols.stopped} ${theme.dim('Service:')} ${theme.statusInactive('not installed')}`);
|
||||
logger.log(
|
||||
`${symbols.stopped} ${theme.dim('Service:')} ${theme.statusInactive('not installed')}`,
|
||||
);
|
||||
logger.log('');
|
||||
}
|
||||
}
|
||||
@@ -276,22 +378,35 @@ WantedBy=multi-user.target
|
||||
for (const ups of config.upsDevices) {
|
||||
await this.displaySingleUpsStatus(ups, snmp);
|
||||
}
|
||||
|
||||
// Display groups after UPS devices
|
||||
this.displayGroupsStatus();
|
||||
} else if (config.snmp) {
|
||||
// Legacy single UPS configuration
|
||||
// Legacy single UPS configuration (v1/v2 format)
|
||||
logger.info('UPS Devices (1):');
|
||||
const legacyUps = {
|
||||
const legacyUps: IUpsConfig = {
|
||||
id: 'default',
|
||||
name: 'Default UPS',
|
||||
snmp: config.snmp,
|
||||
thresholds: config.thresholds,
|
||||
groups: [],
|
||||
actions: config.thresholds
|
||||
? [
|
||||
{
|
||||
type: 'shutdown',
|
||||
thresholds: config.thresholds,
|
||||
triggerMode: 'onlyThresholds',
|
||||
},
|
||||
]
|
||||
: [],
|
||||
};
|
||||
|
||||
await this.displaySingleUpsStatus(legacyUps, snmp);
|
||||
} else {
|
||||
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(
|
||||
` ${theme.dim('Run')} ${theme.command('nupst ups add')} ${theme.dim('to add a device')}`,
|
||||
);
|
||||
logger.log('');
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -307,15 +422,28 @@ WantedBy=multi-user.target
|
||||
* @param ups UPS configuration
|
||||
* @param snmp SNMP manager
|
||||
*/
|
||||
private async displaySingleUpsStatus(ups: any, snmp: any): Promise<void> {
|
||||
private async displaySingleUpsStatus(ups: IUpsConfig, snmp: NupstSnmp): Promise<void> {
|
||||
try {
|
||||
// Create a test config with a short timeout
|
||||
const testConfig = {
|
||||
...ups.snmp,
|
||||
timeout: Math.min(ups.snmp.timeout, 10000), // Use at most 10 seconds for status check
|
||||
};
|
||||
const defaultShutdownDelay = this.daemon.getConfig().defaultShutdownDelay ??
|
||||
SHUTDOWN.DEFAULT_DELAY_MINUTES;
|
||||
const protocol = ups.protocol || 'snmp';
|
||||
let status;
|
||||
|
||||
const status = await snmp.getUpsStatus(testConfig);
|
||||
if (protocol === 'upsd' && ups.upsd) {
|
||||
const testConfig = {
|
||||
...ups.upsd,
|
||||
timeout: Math.min(ups.upsd.timeout, 10000),
|
||||
};
|
||||
status = await this.daemon.getNupstUpsd().getUpsStatus(testConfig);
|
||||
} else if (ups.snmp) {
|
||||
const testConfig = {
|
||||
...ups.snmp,
|
||||
timeout: Math.min(ups.snmp.timeout, 10000),
|
||||
};
|
||||
status = await snmp.getUpsStatus(testConfig);
|
||||
} else {
|
||||
throw new Error('No protocol configuration found');
|
||||
}
|
||||
|
||||
// Determine status symbol based on power status
|
||||
let statusSymbol = symbols.unknown;
|
||||
@@ -326,15 +454,45 @@ WantedBy=multi-user.target
|
||||
}
|
||||
|
||||
// Display UPS name and power status
|
||||
logger.log(` ${statusSymbol} ${theme.highlight(ups.name)} - ${formatPowerStatus(status.powerStatus)}`);
|
||||
logger.log(
|
||||
` ${statusSymbol} ${theme.highlight(ups.name)} - ${formatPowerStatus(status.powerStatus)}`,
|
||||
);
|
||||
|
||||
// Display battery with color coding
|
||||
const batteryColor = getBatteryColor(status.batteryCapacity);
|
||||
const batterySymbol = status.batteryCapacity >= ups.thresholds.battery ? symbols.success : symbols.warning;
|
||||
logger.log(` Battery: ${batteryColor(status.batteryCapacity + '%')} ${batterySymbol} Runtime: ${getRuntimeColor(status.batteryRuntime)(status.batteryRuntime + ' min')}`);
|
||||
|
||||
// Get threshold from actions (if any action has thresholds defined)
|
||||
const actionWithThresholds = ups.actions?.find((action) => action.thresholds);
|
||||
const batteryThreshold = actionWithThresholds?.thresholds?.battery;
|
||||
const batterySymbol =
|
||||
batteryThreshold !== undefined && status.batteryCapacity >= batteryThreshold
|
||||
? 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}`)}`);
|
||||
const hostInfo = protocol === 'upsd' && ups.upsd
|
||||
? `${ups.upsd.host}:${ups.upsd.port} (UPSD)`
|
||||
: ups.snmp
|
||||
? `${ups.snmp.host}:${ups.snmp.port} (SNMP)`
|
||||
: 'N/A';
|
||||
logger.log(` ${theme.dim(`Host: ${hostInfo}`)}`);
|
||||
|
||||
// Display groups if any
|
||||
if (ups.groups && ups.groups.length > 0) {
|
||||
@@ -346,13 +504,122 @@ WantedBy=multi-user.target
|
||||
logger.log(` ${theme.dim(`Groups: ${groupNames.join(', ')}`)}`);
|
||||
}
|
||||
|
||||
logger.log('');
|
||||
// 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.type === 'shutdown') {
|
||||
const shutdownDelay = action.shutdownDelay ?? defaultShutdownDelay;
|
||||
actionDesc += `, delay=${shutdownDelay}min`;
|
||||
} else if (action.type === 'proxmox' && action.proxmoxHaPolicy === 'haStop') {
|
||||
actionDesc += ', ha=stop';
|
||||
}
|
||||
actionDesc += ')';
|
||||
} else {
|
||||
actionDesc += ` (${action.triggerMode || 'onlyPowerChanges'}`;
|
||||
if (action.type === 'shutdown') {
|
||||
const shutdownDelay = action.shutdownDelay ?? defaultShutdownDelay;
|
||||
actionDesc += `, delay=${shutdownDelay}min`;
|
||||
} else if (action.type === 'proxmox' && action.proxmoxHaPolicy === 'haStop') {
|
||||
actionDesc += ', ha=stop';
|
||||
}
|
||||
actionDesc += ')';
|
||||
}
|
||||
logger.log(` ${theme.dim('Action:')} ${theme.info(actionDesc)}`);
|
||||
}
|
||||
}
|
||||
|
||||
logger.log('');
|
||||
} catch (error) {
|
||||
// Display error for this UPS
|
||||
logger.log(` ${symbols.error} ${theme.highlight(ups.name)} - ${theme.error('Connection failed')}`);
|
||||
const errorHostInfo = (ups.protocol || 'snmp') === 'upsd' && ups.upsd
|
||||
? `${ups.upsd.host}:${ups.upsd.port} (UPSD)`
|
||||
: ups.snmp
|
||||
? `${ups.snmp.host}:${ups.snmp.port} (SNMP)`
|
||||
: 'N/A';
|
||||
logger.log(
|
||||
` ${symbols.error} ${theme.highlight(ups.name)} - ${theme.error('Connection failed')}`,
|
||||
);
|
||||
logger.log(` ${theme.dim(error instanceof Error ? error.message : String(error))}`);
|
||||
logger.log(` ${theme.dim(`Host: ${ups.snmp.host}:${ups.snmp.port}`)}`);
|
||||
logger.log(` ${theme.dim(`Host: ${errorHostInfo}`)}`);
|
||||
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)})`)
|
||||
}`,
|
||||
);
|
||||
|
||||
// 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) {
|
||||
const defaultShutdownDelay = config.defaultShutdownDelay ?? SHUTDOWN.DEFAULT_DELAY_MINUTES;
|
||||
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.type === 'shutdown') {
|
||||
const shutdownDelay = action.shutdownDelay ?? defaultShutdownDelay;
|
||||
actionDesc += `, delay=${shutdownDelay}min`;
|
||||
} else if (action.type === 'proxmox' && action.proxmoxHaPolicy === 'haStop') {
|
||||
actionDesc += ', ha=stop';
|
||||
}
|
||||
actionDesc += ')';
|
||||
} else {
|
||||
actionDesc += ` (${action.triggerMode || 'onlyPowerChanges'}`;
|
||||
if (action.type === 'shutdown') {
|
||||
const shutdownDelay = action.shutdownDelay ?? defaultShutdownDelay;
|
||||
actionDesc += `, delay=${shutdownDelay}min`;
|
||||
} else if (action.type === 'proxmox' && action.proxmoxHaPolicy === 'haStop') {
|
||||
actionDesc += ', ha=stop';
|
||||
}
|
||||
actionDesc += ')';
|
||||
}
|
||||
logger.log(` ${theme.dim('Action:')} ${theme.info(actionDesc)}`);
|
||||
}
|
||||
}
|
||||
|
||||
logger.log('');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { SmartChangelog } from 'npm:@push.rocks/smartchangelog@^0.1.0';
|
||||
|
||||
export const renderUpgradeChangelog = (
|
||||
changelogMarkdown: string,
|
||||
currentVersion: string,
|
||||
latestVersion: string,
|
||||
): string => {
|
||||
const changelog = SmartChangelog.fromMarkdown(changelogMarkdown);
|
||||
const entries = changelog.getEntriesBetween(currentVersion, latestVersion);
|
||||
|
||||
if (entries.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return entries.map((entry) => entry.toCliString()).join('\n\n');
|
||||
};
|
||||
@@ -0,0 +1,172 @@
|
||||
import type { IActionConfig } from './actions/base-action.ts';
|
||||
import { NETWORK } from './constants.ts';
|
||||
import type { IUpsStatus as IProtocolUpsStatus } from './snmp/types.ts';
|
||||
import { createInitialUpsStatus, type IUpsIdentity, type IUpsStatus } from './ups-status.ts';
|
||||
|
||||
export interface ISuccessfulUpsPollSnapshot {
|
||||
updatedStatus: IUpsStatus;
|
||||
transition: 'none' | 'recovered' | 'powerStatusChange';
|
||||
previousStatus?: IUpsStatus;
|
||||
downtimeSeconds?: number;
|
||||
}
|
||||
|
||||
export interface IFailedUpsPollSnapshot {
|
||||
updatedStatus: IUpsStatus;
|
||||
transition: 'none' | 'unreachable';
|
||||
failures: number;
|
||||
previousStatus?: IUpsStatus;
|
||||
}
|
||||
|
||||
export function ensureUpsStatus(
|
||||
currentStatus: IUpsStatus | undefined,
|
||||
ups: IUpsIdentity,
|
||||
now: number = Date.now(),
|
||||
): IUpsStatus {
|
||||
return currentStatus || createInitialUpsStatus(ups, now);
|
||||
}
|
||||
|
||||
export function buildSuccessfulUpsPollSnapshot(
|
||||
ups: IUpsIdentity,
|
||||
polledStatus: IProtocolUpsStatus,
|
||||
currentStatus: IUpsStatus | undefined,
|
||||
currentTime: number,
|
||||
): ISuccessfulUpsPollSnapshot {
|
||||
const previousStatus = ensureUpsStatus(currentStatus, ups, currentTime);
|
||||
const updatedStatus: IUpsStatus = {
|
||||
id: ups.id,
|
||||
name: ups.name,
|
||||
powerStatus: polledStatus.powerStatus,
|
||||
batteryCapacity: polledStatus.batteryCapacity,
|
||||
batteryRuntime: polledStatus.batteryRuntime,
|
||||
outputLoad: polledStatus.outputLoad,
|
||||
outputPower: polledStatus.outputPower,
|
||||
outputVoltage: polledStatus.outputVoltage,
|
||||
outputCurrent: polledStatus.outputCurrent,
|
||||
lastCheckTime: currentTime,
|
||||
lastStatusChange: previousStatus.lastStatusChange || currentTime,
|
||||
consecutiveFailures: 0,
|
||||
unreachableSince: 0,
|
||||
};
|
||||
|
||||
if (previousStatus.powerStatus === 'unreachable') {
|
||||
updatedStatus.lastStatusChange = currentTime;
|
||||
return {
|
||||
updatedStatus,
|
||||
transition: 'recovered',
|
||||
previousStatus,
|
||||
downtimeSeconds: Math.round((currentTime - previousStatus.unreachableSince) / 1000),
|
||||
};
|
||||
}
|
||||
|
||||
if (previousStatus.powerStatus !== polledStatus.powerStatus) {
|
||||
updatedStatus.lastStatusChange = currentTime;
|
||||
return {
|
||||
updatedStatus,
|
||||
transition: 'powerStatusChange',
|
||||
previousStatus,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
updatedStatus,
|
||||
transition: 'none',
|
||||
previousStatus: currentStatus,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildFailedUpsPollSnapshot(
|
||||
ups: IUpsIdentity,
|
||||
currentStatus: IUpsStatus | undefined,
|
||||
currentTime: number,
|
||||
): IFailedUpsPollSnapshot {
|
||||
const previousStatus = ensureUpsStatus(currentStatus, ups, currentTime);
|
||||
const failures = Math.min(
|
||||
previousStatus.consecutiveFailures + 1,
|
||||
NETWORK.MAX_CONSECUTIVE_FAILURES,
|
||||
);
|
||||
|
||||
if (
|
||||
failures >= NETWORK.CONSECUTIVE_FAILURE_THRESHOLD &&
|
||||
previousStatus.powerStatus !== 'unreachable'
|
||||
) {
|
||||
return {
|
||||
updatedStatus: {
|
||||
...previousStatus,
|
||||
consecutiveFailures: failures,
|
||||
powerStatus: 'unreachable',
|
||||
unreachableSince: currentTime,
|
||||
lastStatusChange: currentTime,
|
||||
},
|
||||
transition: 'unreachable',
|
||||
failures,
|
||||
previousStatus,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
updatedStatus: {
|
||||
...previousStatus,
|
||||
consecutiveFailures: failures,
|
||||
},
|
||||
transition: 'none',
|
||||
failures,
|
||||
previousStatus: currentStatus,
|
||||
};
|
||||
}
|
||||
|
||||
export function hasThresholdViolation(
|
||||
powerStatus: IProtocolUpsStatus['powerStatus'],
|
||||
batteryCapacity: number,
|
||||
batteryRuntime: number,
|
||||
actions: IActionConfig[] | undefined,
|
||||
): boolean {
|
||||
return getActionThresholdStates(powerStatus, batteryCapacity, batteryRuntime, actions).some(
|
||||
Boolean,
|
||||
);
|
||||
}
|
||||
|
||||
export function isActionThresholdExceeded(
|
||||
actionConfig: IActionConfig,
|
||||
powerStatus: IProtocolUpsStatus['powerStatus'],
|
||||
batteryCapacity: number,
|
||||
batteryRuntime: number,
|
||||
): boolean {
|
||||
if (powerStatus !== 'onBattery' || !actionConfig.thresholds) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
batteryCapacity < actionConfig.thresholds.battery ||
|
||||
batteryRuntime < actionConfig.thresholds.runtime
|
||||
);
|
||||
}
|
||||
|
||||
export function getActionThresholdStates(
|
||||
powerStatus: IProtocolUpsStatus['powerStatus'],
|
||||
batteryCapacity: number,
|
||||
batteryRuntime: number,
|
||||
actions: IActionConfig[] | undefined,
|
||||
): boolean[] {
|
||||
if (!actions || actions.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return actions.map((actionConfig) =>
|
||||
isActionThresholdExceeded(actionConfig, powerStatus, batteryCapacity, batteryRuntime)
|
||||
);
|
||||
}
|
||||
|
||||
export function getEnteredThresholdIndexes(
|
||||
previousStates: boolean[] | undefined,
|
||||
currentStates: boolean[],
|
||||
): number[] {
|
||||
const enteredIndexes: number[] = [];
|
||||
|
||||
for (let index = 0; index < currentStates.length; index++) {
|
||||
if (currentStates[index] && !previousStates?.[index]) {
|
||||
enteredIndexes.push(index);
|
||||
}
|
||||
}
|
||||
|
||||
return enteredIndexes;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
export interface IUpsIdentity {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface IUpsStatus {
|
||||
id: string;
|
||||
name: string;
|
||||
powerStatus: 'online' | 'onBattery' | 'unknown' | 'unreachable';
|
||||
batteryCapacity: number;
|
||||
batteryRuntime: number;
|
||||
outputLoad: number;
|
||||
outputPower: number;
|
||||
outputVoltage: number;
|
||||
outputCurrent: number;
|
||||
lastStatusChange: number;
|
||||
lastCheckTime: number;
|
||||
consecutiveFailures: number;
|
||||
unreachableSince: number;
|
||||
}
|
||||
|
||||
export function createInitialUpsStatus(ups: IUpsIdentity, now: number = Date.now()): IUpsStatus {
|
||||
return {
|
||||
id: ups.id,
|
||||
name: ups.name,
|
||||
powerStatus: 'unknown',
|
||||
batteryCapacity: 100,
|
||||
batteryRuntime: 999,
|
||||
outputLoad: 0,
|
||||
outputPower: 0,
|
||||
outputVoltage: 0,
|
||||
outputCurrent: 0,
|
||||
lastStatusChange: now,
|
||||
lastCheckTime: 0,
|
||||
consecutiveFailures: 0,
|
||||
unreachableSince: 0,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* UPSD/NIS (Network UPS Tools) TCP client
|
||||
*
|
||||
* Connects to a NUT upsd server via TCP and queries UPS variables
|
||||
* using the NUT network protocol (RFC-style line protocol).
|
||||
*
|
||||
* Protocol format:
|
||||
* Request: GET VAR <upsname> <varname>\n
|
||||
* Response: VAR <upsname> <varname> "<value>"\n
|
||||
* Logout: LOGOUT\n
|
||||
*/
|
||||
|
||||
import * as net from 'node:net';
|
||||
import { logger } from '../logger.ts';
|
||||
import { UPSD } from '../constants.ts';
|
||||
import type { IUpsdConfig } from './types.ts';
|
||||
import type { IUpsStatus } from '../snmp/types.ts';
|
||||
|
||||
/**
|
||||
* NupstUpsd - TCP client for the NUT UPSD protocol
|
||||
*/
|
||||
export class NupstUpsd {
|
||||
private debug = false;
|
||||
|
||||
/**
|
||||
* Enable debug logging
|
||||
*/
|
||||
public enableDebug(): void {
|
||||
this.debug = true;
|
||||
logger.info('UPSD debug mode enabled');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current UPS status via UPSD protocol
|
||||
* @param config UPSD connection configuration
|
||||
* @returns UPS status matching the IUpsStatus interface
|
||||
*/
|
||||
public async getUpsStatus(config: IUpsdConfig): Promise<IUpsStatus> {
|
||||
const host = config.host || '127.0.0.1';
|
||||
const port = config.port || UPSD.DEFAULT_PORT;
|
||||
const upsName = config.upsName || UPSD.DEFAULT_UPS_NAME;
|
||||
const timeout = config.timeout || UPSD.DEFAULT_TIMEOUT_MS;
|
||||
|
||||
if (this.debug) {
|
||||
logger.dim('---------------------------------------');
|
||||
logger.dim('Getting UPS status via UPSD protocol:');
|
||||
logger.dim(` Host: ${host}:${port}`);
|
||||
logger.dim(` UPS Name: ${upsName}`);
|
||||
logger.dim(` Timeout: ${timeout}ms`);
|
||||
logger.dim('---------------------------------------');
|
||||
}
|
||||
|
||||
// Variables to query from NUT
|
||||
const varsToQuery = [
|
||||
'ups.status',
|
||||
'battery.charge',
|
||||
'battery.runtime',
|
||||
'ups.load',
|
||||
'ups.realpower',
|
||||
'output.voltage',
|
||||
'output.current',
|
||||
];
|
||||
|
||||
const values = new Map<string, string>();
|
||||
|
||||
// Open a TCP connection, query all variables, then logout
|
||||
const conn = await this.connect(host, port, timeout);
|
||||
|
||||
try {
|
||||
// Authenticate if credentials provided
|
||||
if (config.username && config.password) {
|
||||
await this.sendCommand(conn, `USERNAME ${config.username}`, timeout);
|
||||
await this.sendCommand(conn, `PASSWORD ${config.password}`, timeout);
|
||||
}
|
||||
|
||||
// Query each variable
|
||||
for (const varName of varsToQuery) {
|
||||
const value = await this.safeGetVar(conn, upsName, varName, timeout);
|
||||
if (value !== null) {
|
||||
values.set(varName, value);
|
||||
}
|
||||
}
|
||||
|
||||
// Logout gracefully
|
||||
try {
|
||||
await this.sendCommand(conn, 'LOGOUT', timeout);
|
||||
} catch (_e) {
|
||||
// Ignore logout errors
|
||||
}
|
||||
} finally {
|
||||
conn.destroy();
|
||||
}
|
||||
|
||||
// Map NUT variables to IUpsStatus
|
||||
const powerStatus = this.parsePowerStatus(values.get('ups.status') || '');
|
||||
const batteryCapacity = parseFloat(values.get('battery.charge') || '0');
|
||||
const batteryRuntimeSeconds = parseFloat(values.get('battery.runtime') || '0');
|
||||
const batteryRuntime = Math.floor(batteryRuntimeSeconds / 60); // NUT reports seconds, convert to minutes
|
||||
const outputLoad = parseFloat(values.get('ups.load') || '0');
|
||||
const outputPower = parseFloat(values.get('ups.realpower') || '0');
|
||||
const outputVoltage = parseFloat(values.get('output.voltage') || '0');
|
||||
const outputCurrent = parseFloat(values.get('output.current') || '0');
|
||||
|
||||
const result: IUpsStatus = {
|
||||
powerStatus,
|
||||
batteryCapacity: isNaN(batteryCapacity) ? 0 : batteryCapacity,
|
||||
batteryRuntime: isNaN(batteryRuntime) ? 0 : batteryRuntime,
|
||||
outputLoad: isNaN(outputLoad) ? 0 : outputLoad,
|
||||
outputPower: isNaN(outputPower) ? 0 : outputPower,
|
||||
outputVoltage: isNaN(outputVoltage) ? 0 : outputVoltage,
|
||||
outputCurrent: isNaN(outputCurrent) ? 0 : outputCurrent,
|
||||
raw: Object.fromEntries(values),
|
||||
};
|
||||
|
||||
if (this.debug) {
|
||||
logger.dim('---------------------------------------');
|
||||
logger.dim('UPSD status result:');
|
||||
logger.dim(` Power Status: ${result.powerStatus}`);
|
||||
logger.dim(` Battery Capacity: ${result.batteryCapacity}%`);
|
||||
logger.dim(` Battery Runtime: ${result.batteryRuntime} minutes`);
|
||||
logger.dim(` Output Load: ${result.outputLoad}%`);
|
||||
logger.dim(` Output Power: ${result.outputPower} watts`);
|
||||
logger.dim(` Output Voltage: ${result.outputVoltage} volts`);
|
||||
logger.dim(` Output Current: ${result.outputCurrent} amps`);
|
||||
logger.dim('---------------------------------------');
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a TCP connection to the UPSD server
|
||||
*/
|
||||
private connect(host: string, port: number, timeout: number): Promise<net.Socket> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = net.createConnection({ host, port }, () => {
|
||||
if (this.debug) {
|
||||
logger.dim(`Connected to UPSD at ${host}:${port}`);
|
||||
}
|
||||
resolve(socket);
|
||||
});
|
||||
|
||||
socket.setTimeout(timeout);
|
||||
socket.on('timeout', () => {
|
||||
socket.destroy();
|
||||
reject(new Error(`UPSD connection timed out after ${timeout}ms`));
|
||||
});
|
||||
socket.on('error', (err) => {
|
||||
reject(new Error(`UPSD connection error: ${err.message}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a command and read the response line
|
||||
*/
|
||||
private sendCommand(socket: net.Socket, command: string, timeout: number): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let responseData = '';
|
||||
const timer = setTimeout(() => {
|
||||
cleanup();
|
||||
reject(new Error(`UPSD command timed out: ${command}`));
|
||||
}, timeout);
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
const onData = (data: Uint8Array) => {
|
||||
responseData += decoder.decode(data, { stream: true });
|
||||
// Look for newline to indicate end of response
|
||||
const newlineIdx = responseData.indexOf('\n');
|
||||
if (newlineIdx !== -1) {
|
||||
cleanup();
|
||||
const line = responseData.substring(0, newlineIdx).trim();
|
||||
if (this.debug) {
|
||||
logger.dim(`UPSD << ${line}`);
|
||||
}
|
||||
resolve(line);
|
||||
}
|
||||
};
|
||||
|
||||
const onError = (err: Error) => {
|
||||
cleanup();
|
||||
reject(err);
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timer);
|
||||
socket.removeListener('data', onData);
|
||||
socket.removeListener('error', onError);
|
||||
};
|
||||
|
||||
socket.on('data', onData);
|
||||
socket.on('error', onError);
|
||||
|
||||
if (this.debug) {
|
||||
logger.dim(`UPSD >> ${command}`);
|
||||
}
|
||||
socket.write(command + '\n');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely get a single NUT variable, returning null on error
|
||||
*/
|
||||
private async safeGetVar(
|
||||
socket: net.Socket,
|
||||
upsName: string,
|
||||
varName: string,
|
||||
timeout: number,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const response = await this.sendCommand(
|
||||
socket,
|
||||
`GET VAR ${upsName} ${varName}`,
|
||||
timeout,
|
||||
);
|
||||
|
||||
// Expected response: VAR <upsname> <varname> "<value>"
|
||||
// Also handle: ERR ... for unsupported variables
|
||||
if (response.startsWith('ERR')) {
|
||||
if (this.debug) {
|
||||
logger.dim(`UPSD variable ${varName} not available: ${response}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Parse: VAR ups battery.charge "100"
|
||||
const match = response.match(/^VAR\s+\S+\s+\S+\s+"(.*)"/);
|
||||
if (match) {
|
||||
return match[1];
|
||||
}
|
||||
|
||||
// Some implementations don't quote the value
|
||||
const parts = response.split(/\s+/);
|
||||
if (parts.length >= 4 && parts[0] === 'VAR') {
|
||||
return parts.slice(3).join(' ').replace(/^"/, '').replace(/"$/, '');
|
||||
}
|
||||
|
||||
if (this.debug) {
|
||||
logger.dim(`UPSD unexpected response for ${varName}: ${response}`);
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
if (this.debug) {
|
||||
logger.dim(
|
||||
`UPSD error getting ${varName}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse NUT ups.status tokens into a power status
|
||||
* NUT status tokens: OL (online), OB (on battery), LB (low battery),
|
||||
* HB (high battery), RB (replace battery), CHRG (charging), etc.
|
||||
*/
|
||||
private parsePowerStatus(statusString: string): 'online' | 'onBattery' | 'unknown' {
|
||||
const tokens = statusString.trim().split(/\s+/);
|
||||
|
||||
if (tokens.includes('OB')) {
|
||||
return 'onBattery';
|
||||
}
|
||||
if (tokens.includes('OL')) {
|
||||
return 'online';
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* UPSD/NIS protocol module
|
||||
* Re-exports public types and classes
|
||||
*/
|
||||
|
||||
export type { IUpsdConfig } from './types.ts';
|
||||
export { NupstUpsd } from './client.ts';
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Type definitions for UPSD/NIS (Network UPS Tools) protocol module
|
||||
*/
|
||||
|
||||
/**
|
||||
* UPSD connection configuration
|
||||
*/
|
||||
export interface IUpsdConfig {
|
||||
/** UPSD server host (default: 127.0.0.1) */
|
||||
host: string;
|
||||
/** UPSD server port (default: 3493) */
|
||||
port: number;
|
||||
/** NUT device name (default: 'ups') */
|
||||
upsName: string;
|
||||
/** Connection timeout in milliseconds (default: 5000) */
|
||||
timeout: number;
|
||||
/** Optional username for authentication */
|
||||
username?: string;
|
||||
/** Optional password for authentication */
|
||||
password?: string;
|
||||
}
|
||||
Reference in New Issue
Block a user