Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8dc0248763 | |||
| 1f542ca271 | |||
| 2adf1d5548 | |||
| 067a7666e4 | |||
| 0d863a1028 | |||
| c410a663b1 | |||
| 6aa1fc651f | |||
| 11e549e68e | |||
| 0fb9678976 | |||
| 635de0d932 | |||
| 0916effb53 | |||
| 05242a1c7d | |||
| 0d20dce520 | |||
| 1c50509497 | |||
| 7de521078e | |||
| 42b8eaf6d2 | |||
| 782c8c9555 | |||
| 463c32ebba |
@@ -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: v2.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: v2.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: v2.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
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
name: Publish to npm
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- 'v*'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
npm-publish:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up Deno
|
|
||||||
uses: denoland/setup-deno@v1
|
|
||||||
with:
|
|
||||||
deno-version: v2.x
|
|
||||||
|
|
||||||
- name: Setup Node.js for npm publishing
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '18.x'
|
|
||||||
registry-url: 'https://registry.npmjs.org/'
|
|
||||||
|
|
||||||
- name: Get version from tag
|
|
||||||
id: version
|
|
||||||
run: |
|
|
||||||
VERSION=${GITHUB_REF#refs/tags/}
|
|
||||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
|
||||||
echo "version_number=${VERSION#v}" >> $GITHUB_OUTPUT
|
|
||||||
echo "Publishing version: $VERSION"
|
|
||||||
|
|
||||||
- name: Verify deno.json version matches tag
|
|
||||||
run: |
|
|
||||||
DENO_VERSION=$(grep -o '"version": "[^"]*"' deno.json | cut -d'"' -f4)
|
|
||||||
TAG_VERSION="${{ steps.version.outputs.version_number }}"
|
|
||||||
echo "deno.json version: $DENO_VERSION"
|
|
||||||
echo "Tag version: $TAG_VERSION"
|
|
||||||
if [ "$DENO_VERSION" != "$TAG_VERSION" ]; then
|
|
||||||
echo "ERROR: Version mismatch!"
|
|
||||||
echo "deno.json has version $DENO_VERSION but tag is $TAG_VERSION"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Compile binaries for npm package
|
|
||||||
run: |
|
|
||||||
echo "Compiling binaries for npm package..."
|
|
||||||
deno task compile
|
|
||||||
echo ""
|
|
||||||
echo "Binary sizes:"
|
|
||||||
ls -lh dist/binaries/
|
|
||||||
|
|
||||||
- name: Generate SHA256 checksums
|
|
||||||
run: |
|
|
||||||
cd dist/binaries
|
|
||||||
sha256sum * > SHA256SUMS
|
|
||||||
cat SHA256SUMS
|
|
||||||
cd ../..
|
|
||||||
|
|
||||||
- name: Sync package.json version
|
|
||||||
run: |
|
|
||||||
VERSION="${{ steps.version.outputs.version_number }}"
|
|
||||||
echo "Syncing package.json to version ${VERSION}..."
|
|
||||||
npm version ${VERSION} --no-git-tag-version --allow-same-version
|
|
||||||
echo "package.json version: $(grep '"version"' package.json | head -1)"
|
|
||||||
|
|
||||||
- name: Create npm package
|
|
||||||
run: |
|
|
||||||
echo "Creating npm package..."
|
|
||||||
npm pack
|
|
||||||
echo ""
|
|
||||||
echo "Package created:"
|
|
||||||
ls -lh *.tgz
|
|
||||||
|
|
||||||
- name: Test local installation
|
|
||||||
run: |
|
|
||||||
echo "Testing local package installation..."
|
|
||||||
PACKAGE_FILE=$(ls *.tgz)
|
|
||||||
npm install -g ${PACKAGE_FILE}
|
|
||||||
echo ""
|
|
||||||
echo "Testing nupst command:"
|
|
||||||
nupst --version || echo "Note: Binary execution may fail in CI environment"
|
|
||||||
echo ""
|
|
||||||
echo "Checking installed files:"
|
|
||||||
npm ls -g @serve.zone/nupst || true
|
|
||||||
|
|
||||||
- name: Publish to npm
|
|
||||||
env:
|
|
||||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
||||||
run: |
|
|
||||||
echo "Publishing to npm registry..."
|
|
||||||
npm publish --access public
|
|
||||||
echo ""
|
|
||||||
echo "✅ Successfully published @serve.zone/nupst to npm!"
|
|
||||||
echo ""
|
|
||||||
echo "Package info:"
|
|
||||||
npm view @serve.zone/nupst
|
|
||||||
|
|
||||||
- name: Verify npm package
|
|
||||||
run: |
|
|
||||||
echo "Waiting for npm propagation..."
|
|
||||||
sleep 30
|
|
||||||
echo ""
|
|
||||||
echo "Verifying published package..."
|
|
||||||
npm view @serve.zone/nupst
|
|
||||||
echo ""
|
|
||||||
echo "Testing installation from npm:"
|
|
||||||
npm install -g @serve.zone/nupst
|
|
||||||
echo ""
|
|
||||||
echo "Package installed successfully!"
|
|
||||||
which nupst || echo "Binary location check skipped"
|
|
||||||
|
|
||||||
- name: Publish Summary
|
|
||||||
run: |
|
|
||||||
echo "================================================"
|
|
||||||
echo " npm Publish Complete!"
|
|
||||||
echo "================================================"
|
|
||||||
echo ""
|
|
||||||
echo "✅ Package: @serve.zone/nupst"
|
|
||||||
echo "✅ Version: ${{ steps.version.outputs.version }}"
|
|
||||||
echo ""
|
|
||||||
echo "Installation:"
|
|
||||||
echo " npm install -g @serve.zone/nupst"
|
|
||||||
echo ""
|
|
||||||
echo "Registry:"
|
|
||||||
echo " https://www.npmjs.com/package/@serve.zone/nupst"
|
|
||||||
echo ""
|
|
||||||
@@ -8,6 +8,8 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
build-and-release:
|
build-and-release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: code.foss.global/host.today/ht-docker-node:latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
@@ -20,6 +22,17 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
deno-version: v2.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
|
- name: Get version from tag
|
||||||
id: version
|
id: version
|
||||||
run: |
|
run: |
|
||||||
@@ -41,57 +54,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Compile binaries for all platforms
|
- name: Compile binaries for all platforms
|
||||||
run: |
|
run: mkdir -p dist/binaries && npx tsdeno compile
|
||||||
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/
|
|
||||||
|
|
||||||
- name: Generate SHA256 checksums
|
- name: Generate SHA256 checksums
|
||||||
run: |
|
run: |
|
||||||
@@ -105,7 +68,6 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
VERSION="${{ steps.version.outputs.version }}"
|
VERSION="${{ steps.version.outputs.version }}"
|
||||||
|
|
||||||
# Check if CHANGELOG.md exists
|
|
||||||
if [ ! -f CHANGELOG.md ]; then
|
if [ ! -f CHANGELOG.md ]; then
|
||||||
echo "No CHANGELOG.md found, using default release notes"
|
echo "No CHANGELOG.md found, using default release notes"
|
||||||
cat > /tmp/release_notes.md << EOF
|
cat > /tmp/release_notes.md << EOF
|
||||||
@@ -133,8 +95,6 @@ jobs:
|
|||||||
SHA256 checksums are provided in SHA256SUMS.txt
|
SHA256 checksums are provided in SHA256SUMS.txt
|
||||||
EOF
|
EOF
|
||||||
else
|
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
|
awk "/## \[$VERSION\]/,/## \[/" CHANGELOG.md | sed '$d' > /tmp/release_notes.md || cat > /tmp/release_notes.md << EOF
|
||||||
## NUPST $VERSION
|
## NUPST $VERSION
|
||||||
|
|
||||||
@@ -158,7 +118,6 @@ jobs:
|
|||||||
|
|
||||||
echo "Checking for existing release $VERSION..."
|
echo "Checking for existing release $VERSION..."
|
||||||
|
|
||||||
# Try to get existing release by tag
|
|
||||||
EXISTING_RELEASE_ID=$(curl -s \
|
EXISTING_RELEASE_ID=$(curl -s \
|
||||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||||
"https://code.foss.global/api/v1/repos/serve.zone/nupst/releases/tags/$VERSION" \
|
"https://code.foss.global/api/v1/repos/serve.zone/nupst/releases/tags/$VERSION" \
|
||||||
@@ -178,9 +137,7 @@ jobs:
|
|||||||
- name: Create Gitea Release
|
- name: Create Gitea Release
|
||||||
run: |
|
run: |
|
||||||
VERSION="${{ steps.version.outputs.version }}"
|
VERSION="${{ steps.version.outputs.version }}"
|
||||||
RELEASE_NOTES=$(cat /tmp/release_notes.md)
|
|
||||||
|
|
||||||
# Create the release
|
|
||||||
echo "Creating release for $VERSION..."
|
echo "Creating release for $VERSION..."
|
||||||
RELEASE_ID=$(curl -X POST -s \
|
RELEASE_ID=$(curl -X POST -s \
|
||||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||||
@@ -196,7 +153,6 @@ jobs:
|
|||||||
|
|
||||||
echo "Release created with ID: $RELEASE_ID"
|
echo "Release created with ID: $RELEASE_ID"
|
||||||
|
|
||||||
# Upload binaries as release assets
|
|
||||||
for binary in dist/binaries/*; do
|
for binary in dist/binaries/*; do
|
||||||
filename=$(basename "$binary")
|
filename=$(basename "$binary")
|
||||||
echo "Uploading $filename..."
|
echo "Uploading $filename..."
|
||||||
@@ -213,12 +169,10 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
echo "Cleaning up old releases (keeping only last 3)..."
|
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 }}" \
|
RELEASES=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||||
"https://code.foss.global/api/v1/repos/serve.zone/nupst/releases" | \
|
"https://code.foss.global/api/v1/repos/serve.zone/nupst/releases" | \
|
||||||
jq -r 'sort_by(.created_at) | reverse | .[3:] | .[].id')
|
jq -r 'sort_by(.created_at) | reverse | .[3:] | .[].id')
|
||||||
|
|
||||||
# Delete old releases
|
|
||||||
if [ -n "$RELEASES" ]; then
|
if [ -n "$RELEASES" ]; then
|
||||||
echo "Found releases to delete:"
|
echo "Found releases to delete:"
|
||||||
for release_id in $RELEASES; do
|
for release_id in $RELEASES; do
|
||||||
|
|||||||
@@ -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": {}
|
||||||
|
}
|
||||||
@@ -1,5 +1,75 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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)
|
## 2026-01-29 - 5.2.3 - fix(core)
|
||||||
fix lint/type issues and small refactors
|
fix lint/type issues and small refactors
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "@serve.zone/nupst",
|
"name": "@serve.zone/nupst",
|
||||||
"version": "5.2.3",
|
"version": "5.5.1",
|
||||||
"exports": "./mod.ts",
|
"exports": "./mod.ts",
|
||||||
"nodeModulesDir": "auto",
|
"nodeModulesDir": "auto",
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"dev": "deno run --allow-all mod.ts",
|
"dev": "deno run --allow-all mod.ts",
|
||||||
"compile": "deno task compile:all",
|
"compile": "tsdeno compile",
|
||||||
"compile:all": "bash scripts/compile-all.sh",
|
|
||||||
"test": "deno test --allow-all test/",
|
"test": "deno test --allow-all test/",
|
||||||
"test:watch": "deno test --allow-all --watch test/",
|
"test:watch": "deno test --allow-all --watch test/",
|
||||||
"check": "deno check mod.ts",
|
"check": "deno check mod.ts",
|
||||||
|
|||||||
@@ -25,12 +25,7 @@ import { NupstCli } from './ts/cli.ts';
|
|||||||
*/
|
*/
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
const cli = new NupstCli();
|
const cli = new NupstCli();
|
||||||
|
await cli.parseAndExecute(Deno.args);
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute main and handle errors
|
// Execute main and handle errors
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
{
|
|
||||||
"@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"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"@ship.zone/szci": {}
|
|
||||||
}
|
|
||||||
+9
-4
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@serve.zone/nupst",
|
"name": "@serve.zone/nupst",
|
||||||
"version": "5.2.3",
|
"version": "5.5.1",
|
||||||
"description": "Network UPS Shutdown Tool - Monitor SNMP-enabled UPS devices and orchestrate graceful system shutdowns during power emergencies",
|
"description": "Network UPS Shutdown Tool - Monitor SNMP-enabled UPS devices and orchestrate graceful system shutdowns during power emergencies",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"ups",
|
"ups",
|
||||||
@@ -34,8 +34,10 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"postinstall": "node scripts/install-binary.js",
|
"postinstall": "node scripts/install-binary.js",
|
||||||
"prepublishOnly": "echo 'Publishing NUPST binaries to npm...'",
|
"prepublishOnly": "echo 'Publishing NUPST binaries to npm...'",
|
||||||
"test": "echo 'Tests are run with Deno: deno task test'",
|
"test": "deno task test",
|
||||||
"build": "echo 'no build needed'"
|
"build": "deno task check",
|
||||||
|
"lint": "deno task lint",
|
||||||
|
"format": "deno task fmt"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"bin/",
|
"bin/",
|
||||||
@@ -60,5 +62,8 @@
|
|||||||
"access": "public",
|
"access": "public",
|
||||||
"registry": "https://registry.npmjs.org/"
|
"registry": "https://registry.npmjs.org/"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.18.1+sha512.77a884a165cbba2d8d1c19e3b4880eee6d2fcabd0d879121e282196b80042351d5eb3ca0935fa599da1dc51265cc68816ad2bddd2a2de5ea9fdf92adbec7cd34"
|
"packageManager": "pnpm@10.18.1+sha512.77a884a165cbba2d8d1c19e3b4880eee6d2fcabd0d879121e282196b80042351d5eb3ca0935fa599da1dc51265cc68816ad2bddd2a2de5ea9fdf92adbec7cd34",
|
||||||
|
"devDependencies": {
|
||||||
|
"@git.zone/tsdeno": "^1.3.1"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+2324
File diff suppressed because it is too large
Load Diff
+111
-3
@@ -12,8 +12,9 @@
|
|||||||
|
|
||||||
2. **Constants File (`ts/constants.ts`)**
|
2. **Constants File (`ts/constants.ts`)**
|
||||||
- Centralized all magic numbers (timeouts, intervals, thresholds)
|
- Centralized all magic numbers (timeouts, intervals, thresholds)
|
||||||
- Contains: `TIMING`, `SNMP`, `THRESHOLDS`, `WEBHOOK`, `SCRIPT`, `SHUTDOWN`, `HTTP_SERVER`, `UI`
|
- Contains: `TIMING`, `SNMP`, `THRESHOLDS`, `WEBHOOK`, `SCRIPT`, `SHUTDOWN`, `HTTP_SERVER`, `UI`,
|
||||||
- Used in: `daemon.ts`, `snmp/manager.ts`, `actions/*.ts`
|
`NETWORK`, `UPSD`, `PAUSE`, `PROXMOX`
|
||||||
|
- Used in: `daemon.ts`, `snmp/manager.ts`, `actions/*.ts`, `upsd/client.ts`
|
||||||
|
|
||||||
3. **Logger Consistency**
|
3. **Logger Consistency**
|
||||||
- Replaced all `console.log/console.error` in `snmp/manager.ts` with proper `logger.*` calls
|
- Replaced all `console.log/console.error` in `snmp/manager.ts` with proper `logger.*` calls
|
||||||
@@ -35,28 +36,135 @@
|
|||||||
- Uses: `IUpsConfig`, `INupstConfig`, `ISnmpConfig`, `IActionConfig`, `IThresholds`,
|
- Uses: `IUpsConfig`, `INupstConfig`, `ISnmpConfig`, `IActionConfig`, `IThresholds`,
|
||||||
`ISnmpUpsStatus`
|
`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
|
||||||
|
|
||||||
|
### 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
|
## Architecture Notes
|
||||||
|
|
||||||
- **SNMP Manager**: Uses `INupstAccessor` interface (not direct `Nupst` reference) to avoid circular
|
- **SNMP Manager**: Uses `INupstAccessor` interface (not direct `Nupst` reference) to avoid circular
|
||||||
imports
|
imports
|
||||||
|
- **Protocol Resolver**: Routes to SNMP or UPSD based on `IUpsConfig.protocol`
|
||||||
- **CLI Handlers**: All use the `helpers.withPrompt()` utility for interactive input
|
- **CLI Handlers**: All use the `helpers.withPrompt()` utility for interactive input
|
||||||
- **Constants**: All timing values should be referenced from `ts/constants.ts`
|
- **Constants**: All timing values should be referenced from `ts/constants.ts`
|
||||||
- **Actions**: Use `IActionConfig` from `ts/actions/base-action.ts` for action configuration
|
- **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
|
## File Organization
|
||||||
|
|
||||||
```
|
```
|
||||||
ts/
|
ts/
|
||||||
├── constants.ts # All timing/threshold constants
|
├── 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/
|
├── interfaces/
|
||||||
│ └── nupst-accessor.ts # Interface to break circular deps
|
│ └── nupst-accessor.ts # Interface to break circular deps
|
||||||
├── helpers/
|
├── helpers/
|
||||||
│ ├── prompt.ts # Readline utility
|
│ ├── prompt.ts # Readline utility
|
||||||
│ └── shortid.ts # ID generation
|
│ └── shortid.ts # ID generation
|
||||||
├── actions/
|
├── actions/
|
||||||
│ ├── base-action.ts # Base action class and interfaces
|
│ ├── base-action.ts # Base action class, IActionConfig, TPowerStatus
|
||||||
│ ├── webhook-action.ts # Includes IWebhookPayload
|
│ ├── 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/
|
└── cli/
|
||||||
└── ... # All handlers use helpers.withPrompt()
|
└── ... # 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 group delete <id> → nupst group remove <id>
|
||||||
|
|
||||||
nupst config → nupst config show
|
nupst config → nupst config show
|
||||||
nupst update → nupst update
|
nupst upgrade → nupst upgrade
|
||||||
nupst uninstall → nupst uninstall
|
nupst uninstall → nupst uninstall
|
||||||
nupst help → nupst help / nupst --help
|
nupst help → nupst help / nupst --help
|
||||||
(new) → nupst --version
|
(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 ""
|
|
||||||
@@ -229,10 +229,10 @@ console.log('');
|
|||||||
// === 10. Update Available Example ===
|
// === 10. Update Available Example ===
|
||||||
logger.logBoxTitle('Update Available', 70, 'warning');
|
logger.logBoxTitle('Update Available', 70, 'warning');
|
||||||
logger.logBoxLine('');
|
logger.logBoxLine('');
|
||||||
logger.logBoxLine(`Current Version: ${theme.dim('4.0.1')}`);
|
logger.logBoxLine(`Current Version: ${theme.dim('5.5.0')}`);
|
||||||
logger.logBoxLine(`Latest Version: ${theme.highlight('4.0.2')}`);
|
logger.logBoxLine(`Latest Version: ${theme.highlight('5.5.1')}`);
|
||||||
logger.logBoxLine('');
|
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.logBoxLine('');
|
||||||
logger.logBoxEnd();
|
logger.logBoxEnd();
|
||||||
|
|
||||||
|
|||||||
+430
@@ -2,9 +2,27 @@ import { assert, assertEquals, assertExists } from 'jsr:@std/assert@^1.0.0';
|
|||||||
import { NupstSnmp } from '../ts/snmp/manager.ts';
|
import { NupstSnmp } from '../ts/snmp/manager.ts';
|
||||||
import { UpsOidSets } from '../ts/snmp/oid-sets.ts';
|
import { UpsOidSets } from '../ts/snmp/oid-sets.ts';
|
||||||
import type { IOidSet, ISnmpConfig, TUpsModel } from '../ts/snmp/types.ts';
|
import type { IOidSet, ISnmpConfig, TUpsModel } from '../ts/snmp/types.ts';
|
||||||
|
import {
|
||||||
|
analyzeConfigReload,
|
||||||
|
shouldRefreshPauseState,
|
||||||
|
shouldReloadConfig,
|
||||||
|
} from '../ts/config-watch.ts';
|
||||||
|
import { type IPauseState, loadPauseSnapshot } from '../ts/pause-state.ts';
|
||||||
import { shortId } from '../ts/helpers/shortid.ts';
|
import { shortId } from '../ts/helpers/shortid.ts';
|
||||||
import { HTTP_SERVER, SNMP, THRESHOLDS, TIMING, UI } from '../ts/constants.ts';
|
import { HTTP_SERVER, SNMP, THRESHOLDS, TIMING, UI } from '../ts/constants.ts';
|
||||||
import { Action, type IActionContext } from '../ts/actions/base-action.ts';
|
import { Action, type IActionContext } from '../ts/actions/base-action.ts';
|
||||||
|
import { buildUpsActionContext, decideUpsActionExecution } from '../ts/action-orchestration.ts';
|
||||||
|
import {
|
||||||
|
buildShutdownErrorRow,
|
||||||
|
buildShutdownStatusRow,
|
||||||
|
selectEmergencyCandidate,
|
||||||
|
} from '../ts/shutdown-monitoring.ts';
|
||||||
|
import {
|
||||||
|
buildFailedUpsPollSnapshot,
|
||||||
|
buildSuccessfulUpsPollSnapshot,
|
||||||
|
hasThresholdViolation,
|
||||||
|
} from '../ts/ups-monitoring.ts';
|
||||||
|
import { createInitialUpsStatus } from '../ts/ups-status.ts';
|
||||||
|
|
||||||
import * as qenv from 'npm:@push.rocks/qenv@^6.0.0';
|
import * as qenv from 'npm:@push.rocks/qenv@^6.0.0';
|
||||||
const testQenv = new qenv.Qenv('./', '.nogit/');
|
const testQenv = new qenv.Qenv('./', '.nogit/');
|
||||||
@@ -82,6 +100,418 @@ Deno.test('UI constants: box widths are ascending', () => {
|
|||||||
assert(UI.WIDE_BOX_WIDTH < UI.EXTRA_WIDE_BOX_WIDTH);
|
assert(UI.WIDE_BOX_WIDTH < UI.EXTRA_WIDE_BOX_WIDTH);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Pause State Tests
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
Deno.test('loadPauseSnapshot: reports paused state for valid pause file', async () => {
|
||||||
|
const tempDir = await Deno.makeTempDir();
|
||||||
|
const pauseFilePath = `${tempDir}/pause.json`;
|
||||||
|
const pauseState: IPauseState = {
|
||||||
|
pausedAt: 1000,
|
||||||
|
pausedBy: 'cli',
|
||||||
|
reason: 'maintenance',
|
||||||
|
resumeAt: 5000,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Deno.writeTextFile(pauseFilePath, JSON.stringify(pauseState));
|
||||||
|
|
||||||
|
const snapshot = loadPauseSnapshot(pauseFilePath, false, 2000);
|
||||||
|
|
||||||
|
assertEquals(snapshot.isPaused, true);
|
||||||
|
assertEquals(snapshot.pauseState, pauseState);
|
||||||
|
assertEquals(snapshot.transition, 'paused');
|
||||||
|
} finally {
|
||||||
|
await Deno.remove(tempDir, { recursive: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('loadPauseSnapshot: auto-resumes expired pause file', async () => {
|
||||||
|
const tempDir = await Deno.makeTempDir();
|
||||||
|
const pauseFilePath = `${tempDir}/pause.json`;
|
||||||
|
const pauseState: IPauseState = {
|
||||||
|
pausedAt: 1000,
|
||||||
|
pausedBy: 'cli',
|
||||||
|
resumeAt: 1500,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Deno.writeTextFile(pauseFilePath, JSON.stringify(pauseState));
|
||||||
|
|
||||||
|
const snapshot = loadPauseSnapshot(pauseFilePath, true, 2000);
|
||||||
|
|
||||||
|
assertEquals(snapshot.isPaused, false);
|
||||||
|
assertEquals(snapshot.pauseState, null);
|
||||||
|
assertEquals(snapshot.transition, 'autoResumed');
|
||||||
|
|
||||||
|
let fileExists = true;
|
||||||
|
try {
|
||||||
|
await Deno.stat(pauseFilePath);
|
||||||
|
} catch {
|
||||||
|
fileExists = false;
|
||||||
|
}
|
||||||
|
assertEquals(fileExists, false);
|
||||||
|
} finally {
|
||||||
|
await Deno.remove(tempDir, { recursive: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('loadPauseSnapshot: reports resumed when pause file disappears', async () => {
|
||||||
|
const tempDir = await Deno.makeTempDir();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const snapshot = loadPauseSnapshot(`${tempDir}/pause.json`, true, 2000);
|
||||||
|
|
||||||
|
assertEquals(snapshot.isPaused, false);
|
||||||
|
assertEquals(snapshot.pauseState, null);
|
||||||
|
assertEquals(snapshot.transition, 'resumed');
|
||||||
|
} finally {
|
||||||
|
await Deno.remove(tempDir, { recursive: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Config Watch Tests
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
Deno.test('shouldReloadConfig: matches modify events for config.json', () => {
|
||||||
|
assertEquals(
|
||||||
|
shouldReloadConfig({ kind: 'modify', paths: ['/etc/nupst/config.json'] }),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
shouldReloadConfig({ kind: 'create', paths: ['/etc/nupst/config.json'] }),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
shouldReloadConfig({ kind: 'modify', paths: ['/etc/nupst/other.json'] }),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('shouldRefreshPauseState: matches create/modify/remove pause events', () => {
|
||||||
|
assertEquals(
|
||||||
|
shouldRefreshPauseState({ kind: 'create', paths: ['/etc/nupst/pause'] }),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
shouldRefreshPauseState({ kind: 'remove', paths: ['/etc/nupst/pause'] }),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
shouldRefreshPauseState({ kind: 'modify', paths: ['/etc/nupst/config.json'] }),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('analyzeConfigReload: detects monitoring start and device count changes', () => {
|
||||||
|
assertEquals(analyzeConfigReload(0, 2), {
|
||||||
|
transition: 'monitoringWillStart',
|
||||||
|
message: 'Configuration reloaded! Found 2 UPS device(s)',
|
||||||
|
shouldInitializeUpsStatus: false,
|
||||||
|
shouldLogMonitoringStart: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
assertEquals(analyzeConfigReload(2, 3), {
|
||||||
|
transition: 'deviceCountChanged',
|
||||||
|
message: 'Configuration reloaded! UPS devices: 2 -> 3',
|
||||||
|
shouldInitializeUpsStatus: true,
|
||||||
|
shouldLogMonitoringStart: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
assertEquals(analyzeConfigReload(2, 2), {
|
||||||
|
transition: 'reloaded',
|
||||||
|
message: 'Configuration reloaded successfully',
|
||||||
|
shouldInitializeUpsStatus: false,
|
||||||
|
shouldLogMonitoringStart: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// UPS Status Tests
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
Deno.test('createInitialUpsStatus: creates default daemon UPS status shape', () => {
|
||||||
|
assertEquals(createInitialUpsStatus({ id: 'ups-1', name: 'Main UPS' }, 1234), {
|
||||||
|
id: 'ups-1',
|
||||||
|
name: 'Main UPS',
|
||||||
|
powerStatus: 'unknown',
|
||||||
|
batteryCapacity: 100,
|
||||||
|
batteryRuntime: 999,
|
||||||
|
outputLoad: 0,
|
||||||
|
outputPower: 0,
|
||||||
|
outputVoltage: 0,
|
||||||
|
outputCurrent: 0,
|
||||||
|
lastStatusChange: 1234,
|
||||||
|
lastCheckTime: 0,
|
||||||
|
consecutiveFailures: 0,
|
||||||
|
unreachableSince: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Action Orchestration Tests
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
Deno.test('buildUpsActionContext: includes previous power status and timestamp', () => {
|
||||||
|
const status = {
|
||||||
|
...createInitialUpsStatus({ id: 'ups-1', name: 'Main UPS' }, 1000),
|
||||||
|
powerStatus: 'onBattery' as const,
|
||||||
|
batteryCapacity: 42,
|
||||||
|
batteryRuntime: 15,
|
||||||
|
};
|
||||||
|
const previousStatus = {
|
||||||
|
...createInitialUpsStatus({ id: 'ups-1', name: 'Main UPS' }, 500),
|
||||||
|
powerStatus: 'online' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
buildUpsActionContext(
|
||||||
|
{ id: 'ups-1', name: 'Main UPS' },
|
||||||
|
status,
|
||||||
|
previousStatus,
|
||||||
|
'thresholdViolation',
|
||||||
|
9999,
|
||||||
|
),
|
||||||
|
{
|
||||||
|
upsId: 'ups-1',
|
||||||
|
upsName: 'Main UPS',
|
||||||
|
powerStatus: 'onBattery',
|
||||||
|
batteryCapacity: 42,
|
||||||
|
batteryRuntime: 15,
|
||||||
|
previousPowerStatus: 'online',
|
||||||
|
timestamp: 9999,
|
||||||
|
triggerReason: 'thresholdViolation',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('decideUpsActionExecution: suppresses actions while paused', () => {
|
||||||
|
const decision = decideUpsActionExecution(
|
||||||
|
true,
|
||||||
|
{ id: 'ups-1', name: 'Main UPS', actions: [{ type: 'shutdown' }] },
|
||||||
|
createInitialUpsStatus({ id: 'ups-1', name: 'Main UPS' }, 1000),
|
||||||
|
undefined,
|
||||||
|
'powerStatusChange',
|
||||||
|
9999,
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(decision, {
|
||||||
|
type: 'suppressed',
|
||||||
|
message: '[PAUSED] Actions suppressed for UPS Main UPS (trigger: powerStatusChange)',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('decideUpsActionExecution: falls back to legacy shutdown without actions', () => {
|
||||||
|
const decision = decideUpsActionExecution(
|
||||||
|
false,
|
||||||
|
{ id: 'ups-1', name: 'Main UPS' },
|
||||||
|
createInitialUpsStatus({ id: 'ups-1', name: 'Main UPS' }, 1000),
|
||||||
|
undefined,
|
||||||
|
'thresholdViolation',
|
||||||
|
9999,
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(decision, {
|
||||||
|
type: 'legacyShutdown',
|
||||||
|
reason: 'UPS "Main UPS" battery or runtime below threshold',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('decideUpsActionExecution: returns executable action plan when actions exist', () => {
|
||||||
|
const decision = decideUpsActionExecution(
|
||||||
|
false,
|
||||||
|
{ id: 'ups-1', name: 'Main UPS', actions: [{ type: 'shutdown' }] },
|
||||||
|
{
|
||||||
|
...createInitialUpsStatus({ id: 'ups-1', name: 'Main UPS' }, 1000),
|
||||||
|
powerStatus: 'onBattery',
|
||||||
|
batteryCapacity: 55,
|
||||||
|
batteryRuntime: 18,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...createInitialUpsStatus({ id: 'ups-1', name: 'Main UPS' }, 500),
|
||||||
|
powerStatus: 'online',
|
||||||
|
},
|
||||||
|
'powerStatusChange',
|
||||||
|
9999,
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(decision, {
|
||||||
|
type: 'execute',
|
||||||
|
actions: [{ type: 'shutdown' }],
|
||||||
|
context: {
|
||||||
|
upsId: 'ups-1',
|
||||||
|
upsName: 'Main UPS',
|
||||||
|
powerStatus: 'onBattery',
|
||||||
|
batteryCapacity: 55,
|
||||||
|
batteryRuntime: 18,
|
||||||
|
previousPowerStatus: 'online',
|
||||||
|
timestamp: 9999,
|
||||||
|
triggerReason: 'powerStatusChange',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Shutdown Monitoring Tests
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
Deno.test('buildShutdownStatusRow: marks critical rows below emergency runtime threshold', () => {
|
||||||
|
const snapshot = buildShutdownStatusRow(
|
||||||
|
'Main UPS',
|
||||||
|
{
|
||||||
|
powerStatus: 'onBattery',
|
||||||
|
batteryCapacity: 25,
|
||||||
|
batteryRuntime: 4,
|
||||||
|
outputLoad: 15,
|
||||||
|
outputPower: 100,
|
||||||
|
outputVoltage: 230,
|
||||||
|
outputCurrent: 0.4,
|
||||||
|
raw: {},
|
||||||
|
},
|
||||||
|
5,
|
||||||
|
{
|
||||||
|
battery: (value) => `B:${value}`,
|
||||||
|
runtime: (value) => `R:${value}`,
|
||||||
|
ok: (text) => `ok:${text}`,
|
||||||
|
critical: (text) => `critical:${text}`,
|
||||||
|
error: (text) => `error:${text}`,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(snapshot.isCritical, true);
|
||||||
|
assertEquals(snapshot.row, {
|
||||||
|
name: 'Main UPS',
|
||||||
|
battery: 'B:25',
|
||||||
|
runtime: 'R:4',
|
||||||
|
status: 'critical:CRITICAL!',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('buildShutdownErrorRow: builds shutdown error table row', () => {
|
||||||
|
assertEquals(buildShutdownErrorRow('Main UPS', (text) => `error:${text}`), {
|
||||||
|
name: 'Main UPS',
|
||||||
|
battery: 'error:N/A',
|
||||||
|
runtime: 'error:N/A',
|
||||||
|
status: 'error:ERROR',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('selectEmergencyCandidate: keeps first critical UPS candidate', () => {
|
||||||
|
const firstCandidate = selectEmergencyCandidate(
|
||||||
|
null,
|
||||||
|
{ id: 'ups-1', name: 'UPS 1' },
|
||||||
|
{
|
||||||
|
powerStatus: 'onBattery',
|
||||||
|
batteryCapacity: 40,
|
||||||
|
batteryRuntime: 4,
|
||||||
|
outputLoad: 10,
|
||||||
|
outputPower: 60,
|
||||||
|
outputVoltage: 230,
|
||||||
|
outputCurrent: 0.3,
|
||||||
|
raw: {},
|
||||||
|
},
|
||||||
|
5,
|
||||||
|
);
|
||||||
|
|
||||||
|
const secondCandidate = selectEmergencyCandidate(
|
||||||
|
firstCandidate,
|
||||||
|
{ id: 'ups-2', name: 'UPS 2' },
|
||||||
|
{
|
||||||
|
powerStatus: 'onBattery',
|
||||||
|
batteryCapacity: 30,
|
||||||
|
batteryRuntime: 3,
|
||||||
|
outputLoad: 15,
|
||||||
|
outputPower: 70,
|
||||||
|
outputVoltage: 230,
|
||||||
|
outputCurrent: 0.4,
|
||||||
|
raw: {},
|
||||||
|
},
|
||||||
|
5,
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(secondCandidate, firstCandidate);
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// UPS Monitoring Tests
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
Deno.test('buildSuccessfulUpsPollSnapshot: marks recovery from unreachable', () => {
|
||||||
|
const currentStatus = {
|
||||||
|
...createInitialUpsStatus({ id: 'ups-1', name: 'Main UPS' }, 1000),
|
||||||
|
powerStatus: 'unreachable' as const,
|
||||||
|
unreachableSince: 2000,
|
||||||
|
consecutiveFailures: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
const snapshot = buildSuccessfulUpsPollSnapshot(
|
||||||
|
{ id: 'ups-1', name: 'Main UPS' },
|
||||||
|
{
|
||||||
|
powerStatus: 'online',
|
||||||
|
batteryCapacity: 95,
|
||||||
|
batteryRuntime: 40,
|
||||||
|
outputLoad: 10,
|
||||||
|
outputPower: 50,
|
||||||
|
outputVoltage: 230,
|
||||||
|
outputCurrent: 0.5,
|
||||||
|
raw: {},
|
||||||
|
},
|
||||||
|
currentStatus,
|
||||||
|
8000,
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(snapshot.transition, 'recovered');
|
||||||
|
assertEquals(snapshot.downtimeSeconds, 6);
|
||||||
|
assertEquals(snapshot.updatedStatus.powerStatus, 'online');
|
||||||
|
assertEquals(snapshot.updatedStatus.consecutiveFailures, 0);
|
||||||
|
assertEquals(snapshot.updatedStatus.lastStatusChange, 8000);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('buildFailedUpsPollSnapshot: marks UPS unreachable at failure threshold', () => {
|
||||||
|
const currentStatus = {
|
||||||
|
...createInitialUpsStatus({ id: 'ups-1', name: 'Main UPS' }, 1000),
|
||||||
|
powerStatus: 'onBattery' as const,
|
||||||
|
consecutiveFailures: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
const snapshot = buildFailedUpsPollSnapshot(
|
||||||
|
{ id: 'ups-1', name: 'Main UPS' },
|
||||||
|
currentStatus,
|
||||||
|
9000,
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(snapshot.transition, 'unreachable');
|
||||||
|
assertEquals(snapshot.failures, 3);
|
||||||
|
assertEquals(snapshot.updatedStatus.powerStatus, 'unreachable');
|
||||||
|
assertEquals(snapshot.updatedStatus.unreachableSince, 9000);
|
||||||
|
assertEquals(snapshot.updatedStatus.lastStatusChange, 9000);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('hasThresholdViolation: only fires on battery when any action threshold is exceeded', () => {
|
||||||
|
assertEquals(
|
||||||
|
hasThresholdViolation('online', 40, 10, [
|
||||||
|
{ type: 'shutdown', thresholds: { battery: 50, runtime: 20 } },
|
||||||
|
]),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
hasThresholdViolation('onBattery', 40, 10, [
|
||||||
|
{ type: 'shutdown', thresholds: { battery: 50, runtime: 20 } },
|
||||||
|
]),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
hasThresholdViolation('onBattery', 90, 60, [
|
||||||
|
{ type: 'shutdown', thresholds: { battery: 50, runtime: 20 } },
|
||||||
|
]),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// UpsOidSets Tests
|
// UpsOidSets Tests
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/nupst',
|
name: '@serve.zone/nupst',
|
||||||
version: '5.2.3',
|
version: '5.5.1',
|
||||||
description: 'Network UPS Shutdown Tool - Monitor SNMP-enabled UPS devices and orchestrate graceful system shutdowns during power emergencies'
|
description: 'Network UPS Shutdown Tool - Monitor SNMP-enabled UPS devices and orchestrate graceful system shutdowns during power emergencies'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
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 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)
|
* 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
|
* Context provided to actions when they execute
|
||||||
@@ -52,7 +52,7 @@ export type TActionTriggerMode =
|
|||||||
*/
|
*/
|
||||||
export interface IActionConfig {
|
export interface IActionConfig {
|
||||||
/** Type of action to execute */
|
/** Type of action to execute */
|
||||||
type: 'shutdown' | 'webhook' | 'script';
|
type: 'shutdown' | 'webhook' | 'script' | 'proxmox';
|
||||||
|
|
||||||
// Trigger configuration
|
// Trigger configuration
|
||||||
/**
|
/**
|
||||||
@@ -96,6 +96,28 @@ export interface IActionConfig {
|
|||||||
scriptTimeout?: number;
|
scriptTimeout?: number;
|
||||||
/** Only execute script on threshold violation */
|
/** Only execute script on threshold violation */
|
||||||
scriptOnlyOnThresholdViolation?: boolean;
|
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';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type { Action, IActionConfig, IActionContext } from './base-action.ts';
|
|||||||
import { ShutdownAction } from './shutdown-action.ts';
|
import { ShutdownAction } from './shutdown-action.ts';
|
||||||
import { WebhookAction } from './webhook-action.ts';
|
import { WebhookAction } from './webhook-action.ts';
|
||||||
import { ScriptAction } from './script-action.ts';
|
import { ScriptAction } from './script-action.ts';
|
||||||
|
import { ProxmoxAction } from './proxmox-action.ts';
|
||||||
|
|
||||||
// Re-export types for convenience
|
// Re-export types for convenience
|
||||||
export type { IActionConfig, IActionContext, TPowerStatus } from './base-action.ts';
|
export type { IActionConfig, IActionContext, TPowerStatus } from './base-action.ts';
|
||||||
@@ -18,6 +19,7 @@ export { Action } from './base-action.ts';
|
|||||||
export { ShutdownAction } from './shutdown-action.ts';
|
export { ShutdownAction } from './shutdown-action.ts';
|
||||||
export { WebhookAction } from './webhook-action.ts';
|
export { WebhookAction } from './webhook-action.ts';
|
||||||
export { ScriptAction } from './script-action.ts';
|
export { ScriptAction } from './script-action.ts';
|
||||||
|
export { ProxmoxAction } from './proxmox-action.ts';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ActionManager - Coordinates action creation and execution
|
* ActionManager - Coordinates action creation and execution
|
||||||
@@ -40,6 +42,8 @@ export class ActionManager {
|
|||||||
return new WebhookAction(config);
|
return new WebhookAction(config);
|
||||||
case 'script':
|
case 'script':
|
||||||
return new ScriptAction(config);
|
return new ScriptAction(config);
|
||||||
|
case 'proxmox':
|
||||||
|
return new ProxmoxAction(config);
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unknown action type: ${(config as IActionConfig).type}`);
|
throw new Error(`Unknown action type: ${(config as IActionConfig).type}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,580 @@
|
|||||||
|
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);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
isRoot: boolean;
|
||||||
|
} {
|
||||||
|
let qmPath: string | null = null;
|
||||||
|
let pctPath: string | null = null;
|
||||||
|
|
||||||
|
for (const dir of PROXMOX.CLI_TOOL_PATHS) {
|
||||||
|
if (!qmPath) {
|
||||||
|
const p = `${dir}/qm`;
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(p)) qmPath = p;
|
||||||
|
} catch (_e) {
|
||||||
|
// continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!pctPath) {
|
||||||
|
const p = `${dir}/pct`;
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(p)) pctPath = p;
|
||||||
|
} catch (_e) {
|
||||||
|
// continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isRoot = !!(process.getuid && process.getuid() === 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
available: qmPath !== null && pctPath !== null && isRoot,
|
||||||
|
qmPath,
|
||||||
|
pctPath,
|
||||||
|
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
|
||||||
|
|
||||||
|
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}`);
|
||||||
|
if (resolved.mode === 'api') {
|
||||||
|
const host = this.config.proxmoxHost || PROXMOX.DEFAULT_HOST;
|
||||||
|
const port = this.config.proxmoxPort || PROXMOX.DEFAULT_PORT;
|
||||||
|
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 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 host = this.config.proxmoxHost || PROXMOX.DEFAULT_HOST;
|
||||||
|
const port = this.config.proxmoxPort || PROXMOX.DEFAULT_PORT;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = `https://${host}:${port}${PROXMOX.API_BASE}`;
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Authorization': `PVEAPIToken=${tokenId}=${tokenSecret}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
runningVMs = await this.getRunningVMsApi(baseUrl, node, headers, insecure);
|
||||||
|
runningCTs = await this.getRunningCTsApi(baseUrl, node, headers, 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Shutting down ${vmsToStop.length} VMs and ${ctsToStop.length} containers...`);
|
||||||
|
|
||||||
|
// Send shutdown commands
|
||||||
|
if (resolved.mode === 'cli') {
|
||||||
|
for (const vm of vmsToStop) {
|
||||||
|
await this.shutdownVMCli(resolved.qmPath, vm.vmid);
|
||||||
|
logger.dim(` Shutdown sent to VM ${vm.vmid} (${vm.name || 'unnamed'})`);
|
||||||
|
}
|
||||||
|
for (const ct of ctsToStop) {
|
||||||
|
await this.shutdownCTCli(resolved.pctPath, ct.vmid);
|
||||||
|
logger.dim(` Shutdown sent to CT ${ct.vmid} (${ct.name || 'unnamed'})`);
|
||||||
|
}
|
||||||
|
} 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}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const vm of vmsToStop) {
|
||||||
|
await this.shutdownVMApi(baseUrl, node, vm.vmid, headers, insecure);
|
||||||
|
logger.dim(` Shutdown sent to VM ${vm.vmid} (${vm.name || 'unnamed'})`);
|
||||||
|
}
|
||||||
|
for (const ct of ctsToStop) {
|
||||||
|
await this.shutdownCTApi(baseUrl, node, ct.vmid, headers, 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 {
|
||||||
|
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}`,
|
||||||
|
};
|
||||||
|
if (item.type === 'qemu') {
|
||||||
|
await this.stopVMApi(baseUrl, node, item.vmid, headers, insecure);
|
||||||
|
} else {
|
||||||
|
await this.stopCTApi(baseUrl, node, item.vmid, headers, 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)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── API-based methods ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make an API request to the Proxmox server
|
||||||
|
*/
|
||||||
|
private async apiRequest(
|
||||||
|
url: string,
|
||||||
|
method: string,
|
||||||
|
headers: Record<string, string>,
|
||||||
|
insecure: boolean,
|
||||||
|
): Promise<unknown> {
|
||||||
|
const fetchOptions: RequestInit = {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use NODE_TLS_REJECT_UNAUTHORIZED for insecure mode (self-signed certs)
|
||||||
|
if (insecure) {
|
||||||
|
// deno-lint-ignore no-explicit-any
|
||||||
|
(globalThis as any).process?.env && ((globalThis as any).process.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) {
|
||||||
|
// deno-lint-ignore no-explicit-any
|
||||||
|
(globalThis as any).process?.env && ((globalThis as any).process.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 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,10 +34,17 @@ export class ShutdownAction extends Action {
|
|||||||
|
|
||||||
// CRITICAL SAFETY CHECK: Shutdown should NEVER trigger unless UPS is on battery
|
// 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)
|
// 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 !== 'onBattery') {
|
||||||
logger.info(
|
if (context.powerStatus === 'unreachable') {
|
||||||
`Shutdown action skipped: UPS is not on battery (status: ${context.powerStatus})`,
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export interface IWebhookPayload {
|
|||||||
/** UPS name */
|
/** UPS name */
|
||||||
upsName: string;
|
upsName: string;
|
||||||
/** Current power status */
|
/** Current power status */
|
||||||
powerStatus: 'online' | 'onBattery' | 'unknown';
|
powerStatus: 'online' | 'onBattery' | 'unknown' | 'unreachable';
|
||||||
/** Current battery capacity percentage */
|
/** Current battery capacity percentage */
|
||||||
batteryCapacity: number;
|
batteryCapacity: number;
|
||||||
/** Current battery runtime in minutes */
|
/** Current battery runtime in minutes */
|
||||||
|
|||||||
@@ -19,15 +19,16 @@ export class NupstCli {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse command line arguments and execute the appropriate command
|
* 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> {
|
public async parseAndExecute(args: string[]): Promise<void> {
|
||||||
// Extract debug and version flags from any position
|
// Extract debug and version flags from any position
|
||||||
const debugOptions = this.extractDebugOptions(args);
|
const debugOptions = this.extractDebugOptions(args);
|
||||||
if (debugOptions.debugMode) {
|
if (debugOptions.debugMode) {
|
||||||
logger.log('Debug mode enabled');
|
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.getSnmp().enableDebug();
|
||||||
|
this.nupst.getUpsd().enableDebug();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for version flag
|
// Check for version flag
|
||||||
@@ -37,8 +38,8 @@ export class NupstCli {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get the command (default to help if none provided)
|
// Get the command (default to help if none provided)
|
||||||
const command = debugOptions.cleanedArgs[2] || 'help';
|
const command = debugOptions.cleanedArgs[0] || 'help';
|
||||||
const commandArgs = debugOptions.cleanedArgs.slice(3);
|
const commandArgs = debugOptions.cleanedArgs.slice(1);
|
||||||
|
|
||||||
// Route to the appropriate command handler
|
// Route to the appropriate command handler
|
||||||
await this.executeCommand(command, commandArgs, debugOptions.debugMode);
|
await this.executeCommand(command, commandArgs, debugOptions.debugMode);
|
||||||
@@ -97,7 +98,7 @@ export class NupstCli {
|
|||||||
await serviceHandler.start();
|
await serviceHandler.start();
|
||||||
break;
|
break;
|
||||||
case 'status':
|
case 'status':
|
||||||
await serviceHandler.status();
|
await serviceHandler.status(debugMode);
|
||||||
break;
|
break;
|
||||||
case 'logs':
|
case 'logs':
|
||||||
await serviceHandler.logs();
|
await serviceHandler.logs();
|
||||||
@@ -259,7 +260,13 @@ export class NupstCli {
|
|||||||
|
|
||||||
// Handle top-level commands
|
// Handle top-level commands
|
||||||
switch (command) {
|
switch (command) {
|
||||||
case 'update':
|
case 'pause':
|
||||||
|
await serviceHandler.pause(commandArgs);
|
||||||
|
break;
|
||||||
|
case 'resume':
|
||||||
|
await serviceHandler.resume();
|
||||||
|
break;
|
||||||
|
case 'upgrade':
|
||||||
await serviceHandler.update();
|
await serviceHandler.update();
|
||||||
break;
|
break;
|
||||||
case 'uninstall':
|
case 'uninstall':
|
||||||
@@ -351,18 +358,32 @@ export class NupstCli {
|
|||||||
|
|
||||||
// UPS Devices Table
|
// UPS Devices Table
|
||||||
if (config.upsDevices.length > 0) {
|
if (config.upsDevices.length > 0) {
|
||||||
const upsRows = config.upsDevices.map((ups) => ({
|
const upsRows = config.upsDevices.map((ups) => {
|
||||||
name: ups.name,
|
const protocol = ups.protocol || 'snmp';
|
||||||
id: theme.dim(ups.id),
|
let host = 'N/A';
|
||||||
host: `${ups.snmp.host}:${ups.snmp.port}`,
|
let model = '';
|
||||||
model: ups.snmp.upsModel || 'cyberpower',
|
if (protocol === 'upsd' && ups.upsd) {
|
||||||
actions: `${(ups.actions || []).length} configured`,
|
host = `${ups.upsd.host}:${ups.upsd.port}`;
|
||||||
groups: ups.groups.length > 0 ? ups.groups.join(', ') : theme.dim('None'),
|
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[] = [
|
const upsColumns: ITableColumn[] = [
|
||||||
{ header: 'Name', key: 'name', align: 'left', color: theme.highlight },
|
{ header: 'Name', key: 'name', align: 'left', color: theme.highlight },
|
||||||
{ header: 'ID', key: 'id', align: 'left' },
|
{ header: 'ID', key: 'id', align: 'left' },
|
||||||
|
{ header: 'Protocol', key: 'protocol', align: 'left' },
|
||||||
{ header: 'Host:Port', key: 'host', align: 'left', color: theme.info },
|
{ header: 'Host:Port', key: 'host', align: 'left', color: theme.info },
|
||||||
{ header: 'Model', key: 'model', align: 'left' },
|
{ header: 'Model', key: 'model', align: 'left' },
|
||||||
{ header: 'Actions', key: 'actions', align: 'left' },
|
{ header: 'Actions', key: 'actions', align: 'left' },
|
||||||
@@ -534,7 +555,9 @@ export class NupstCli {
|
|||||||
this.printCommand('action <subcommand>', 'Manage UPS actions');
|
this.printCommand('action <subcommand>', 'Manage UPS actions');
|
||||||
this.printCommand('feature <subcommand>', 'Manage optional features');
|
this.printCommand('feature <subcommand>', 'Manage optional features');
|
||||||
this.printCommand('config [show]', 'Display current configuration');
|
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('uninstall', 'Completely remove NUPST', theme.dim('(requires root)'));
|
||||||
this.printCommand('help, --help, -h', 'Show this help message');
|
this.printCommand('help, --help, -h', 'Show this help message');
|
||||||
this.printCommand('--version, -v', 'Show version information');
|
this.printCommand('--version, -v', 'Show version information');
|
||||||
|
|||||||
+174
-35
@@ -3,6 +3,7 @@ import { Nupst } from '../nupst.ts';
|
|||||||
import { type ITableColumn, logger } from '../logger.ts';
|
import { type ITableColumn, logger } from '../logger.ts';
|
||||||
import { symbols, theme } from '../colors.ts';
|
import { symbols, theme } from '../colors.ts';
|
||||||
import type { IActionConfig } from '../actions/base-action.ts';
|
import type { IActionConfig } from '../actions/base-action.ts';
|
||||||
|
import { ProxmoxAction } from '../actions/proxmox-action.ts';
|
||||||
import type { IGroupConfig, IUpsConfig } from '../daemon.ts';
|
import type { IGroupConfig, IUpsConfig } from '../daemon.ts';
|
||||||
import * as helpers from '../helpers/index.ts';
|
import * as helpers from '../helpers/index.ts';
|
||||||
|
|
||||||
@@ -65,11 +66,146 @@ export class ActionHandler {
|
|||||||
logger.info(`Add Action to ${targetType} ${theme.highlight(targetName)}`);
|
logger.info(`Add Action to ${targetType} ${theme.highlight(targetName)}`);
|
||||||
logger.log('');
|
logger.log('');
|
||||||
|
|
||||||
// Action type (currently only shutdown is supported)
|
// Action type selection
|
||||||
const type = 'shutdown';
|
logger.log(` ${theme.dim('Action Type:')}`);
|
||||||
logger.log(` ${theme.dim('Action type:')} ${theme.highlight('shutdown')}`);
|
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)`);
|
||||||
|
|
||||||
// Battery threshold
|
const typeInput = await prompt(` ${theme.dim('Select action type')} ${theme.dim('[1]:')} `);
|
||||||
|
const typeValue = parseInt(typeInput, 10) || 1;
|
||||||
|
|
||||||
|
const newAction: Partial<IActionConfig> = {};
|
||||||
|
|
||||||
|
if (typeValue === 1) {
|
||||||
|
// Shutdown action
|
||||||
|
newAction.type = 'shutdown';
|
||||||
|
|
||||||
|
const delayStr = await prompt(
|
||||||
|
` ${theme.dim('Shutdown delay')} ${theme.dim('(minutes) [5]:')} `,
|
||||||
|
);
|
||||||
|
const shutdownDelay = delayStr ? parseInt(delayStr, 10) : 5;
|
||||||
|
if (isNaN(shutdownDelay) || shutdownDelay < 0) {
|
||||||
|
logger.error('Invalid shutdown delay. Must be >= 0.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
newAction.shutdownDelay = shutdownDelay;
|
||||||
|
} else if (typeValue === 2) {
|
||||||
|
// Webhook action
|
||||||
|
newAction.type = 'webhook';
|
||||||
|
|
||||||
|
const url = await prompt(` ${theme.dim('Webhook URL:')} `);
|
||||||
|
if (!url.trim()) {
|
||||||
|
logger.error('Webhook URL is required.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
newAction.webhookUrl = url.trim();
|
||||||
|
|
||||||
|
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 methodInput = await prompt(` ${theme.dim('Select method')} ${theme.dim('[1]:')} `);
|
||||||
|
newAction.webhookMethod = methodInput === '2' ? 'GET' : 'POST';
|
||||||
|
|
||||||
|
const timeoutInput = await prompt(` ${theme.dim('Timeout in seconds')} ${theme.dim('[10]:')} `);
|
||||||
|
const timeout = parseInt(timeoutInput, 10);
|
||||||
|
if (timeoutInput.trim() && !isNaN(timeout)) {
|
||||||
|
newAction.webhookTimeout = timeout * 1000;
|
||||||
|
}
|
||||||
|
} else if (typeValue === 3) {
|
||||||
|
// Script action
|
||||||
|
newAction.type = 'script';
|
||||||
|
|
||||||
|
const scriptPath = await prompt(` ${theme.dim('Script filename (in /etc/nupst/, must end with .sh):')} `);
|
||||||
|
if (!scriptPath.trim() || !scriptPath.trim().endsWith('.sh')) {
|
||||||
|
logger.error('Script path must end with .sh.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
newAction.scriptPath = scriptPath.trim();
|
||||||
|
|
||||||
|
const timeoutInput = await prompt(` ${theme.dim('Script timeout in seconds')} ${theme.dim('[60]:')} `);
|
||||||
|
const timeout = parseInt(timeoutInput, 10);
|
||||||
|
if (timeoutInput.trim() && !isNaN(timeout)) {
|
||||||
|
newAction.scriptTimeout = timeout * 1000;
|
||||||
|
}
|
||||||
|
} else if (typeValue === 4) {
|
||||||
|
// Proxmox action
|
||||||
|
newAction.type = 'proxmox';
|
||||||
|
|
||||||
|
// Auto-detect CLI availability
|
||||||
|
const detection = ProxmoxAction.detectCliAvailability();
|
||||||
|
|
||||||
|
if (detection.available) {
|
||||||
|
logger.log('');
|
||||||
|
logger.success('Proxmox CLI tools detected (qm/pct). No API token needed.');
|
||||||
|
logger.dim(` qm: ${detection.qmPath}`);
|
||||||
|
logger.dim(` pct: ${detection.pctPath}`);
|
||||||
|
newAction.proxmoxMode = 'cli';
|
||||||
|
} 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.');
|
||||||
|
}
|
||||||
|
logger.log('');
|
||||||
|
logger.info('Proxmox API Settings:');
|
||||||
|
logger.dim('Create a token with: pveum user token add root@pam nupst --privsep=0');
|
||||||
|
|
||||||
|
const pxHost = await prompt(` ${theme.dim('Proxmox Host')} ${theme.dim('[localhost]:')} `);
|
||||||
|
newAction.proxmoxHost = pxHost.trim() || 'localhost';
|
||||||
|
|
||||||
|
const pxPortInput = await prompt(` ${theme.dim('Proxmox API Port')} ${theme.dim('[8006]:')} `);
|
||||||
|
const pxPort = parseInt(pxPortInput, 10);
|
||||||
|
newAction.proxmoxPort = pxPortInput.trim() && !isNaN(pxPort) ? pxPort : 8006;
|
||||||
|
|
||||||
|
const pxNode = await prompt(` ${theme.dim('Proxmox Node Name (empty = auto-detect):')} `);
|
||||||
|
if (pxNode.trim()) {
|
||||||
|
newAction.proxmoxNode = pxNode.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenId = await prompt(` ${theme.dim('API Token ID (e.g., root@pam!nupst):')} `);
|
||||||
|
if (!tokenId.trim()) {
|
||||||
|
logger.error('Token ID is required for API mode.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
newAction.proxmoxTokenId = tokenId.trim();
|
||||||
|
|
||||||
|
const tokenSecret = await prompt(` ${theme.dim('API Token Secret:')} `);
|
||||||
|
if (!tokenSecret.trim()) {
|
||||||
|
logger.error('Token Secret is required for API mode.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
newAction.proxmoxTokenSecret = tokenSecret.trim();
|
||||||
|
|
||||||
|
const insecureInput = await prompt(` ${theme.dim('Skip TLS verification (self-signed cert)?')} ${theme.dim('(Y/n):')} `);
|
||||||
|
newAction.proxmoxInsecure = insecureInput.toLowerCase() !== 'n';
|
||||||
|
newAction.proxmoxMode = 'api';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Common Proxmox settings (both modes)
|
||||||
|
const excludeInput = await prompt(` ${theme.dim('VM/CT IDs to exclude (comma-separated, or empty):')} `);
|
||||||
|
if (excludeInput.trim()) {
|
||||||
|
newAction.proxmoxExcludeIds = excludeInput.split(',').map((s) => parseInt(s.trim(), 10)).filter((n) => !isNaN(n));
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutInput = await prompt(` ${theme.dim('VM shutdown timeout in seconds')} ${theme.dim('[120]:')} `);
|
||||||
|
const stopTimeout = parseInt(timeoutInput, 10);
|
||||||
|
if (timeoutInput.trim() && !isNaN(stopTimeout)) {
|
||||||
|
newAction.proxmoxStopTimeout = stopTimeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
const forceInput = await prompt(` ${theme.dim('Force-stop VMs that don\'t shut down in time?')} ${theme.dim('(Y/n):')} `);
|
||||||
|
newAction.proxmoxForceStop = forceInput.toLowerCase() !== 'n';
|
||||||
|
} else {
|
||||||
|
logger.error('Invalid action type.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Battery threshold (all action types)
|
||||||
|
logger.log('');
|
||||||
const batteryStr = await prompt(
|
const batteryStr = await prompt(
|
||||||
` ${theme.dim('Battery threshold')} ${theme.dim('(%):')} `,
|
` ${theme.dim('Battery threshold')} ${theme.dim('(%):')} `,
|
||||||
);
|
);
|
||||||
@@ -89,6 +225,8 @@ export class ActionHandler {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
newAction.thresholds = { battery, runtime };
|
||||||
|
|
||||||
// Trigger mode
|
// Trigger mode
|
||||||
logger.log('');
|
logger.log('');
|
||||||
logger.log(` ${theme.dim('Trigger mode:')}`);
|
logger.log(` ${theme.dim('Trigger mode:')}`);
|
||||||
@@ -113,33 +251,13 @@ export class ActionHandler {
|
|||||||
'': 'onlyThresholds', // Default
|
'': 'onlyThresholds', // Default
|
||||||
};
|
};
|
||||||
const triggerMode = triggerModeMap[triggerChoice] || 'onlyThresholds';
|
const triggerMode = triggerModeMap[triggerChoice] || 'onlyThresholds';
|
||||||
|
newAction.triggerMode = triggerMode as IActionConfig['triggerMode'];
|
||||||
// Shutdown delay
|
|
||||||
const delayStr = await prompt(
|
|
||||||
` ${theme.dim('Shutdown delay')} ${theme.dim('(seconds) [5]:')} `,
|
|
||||||
);
|
|
||||||
const shutdownDelay = delayStr ? parseInt(delayStr, 10) : 5;
|
|
||||||
if (isNaN(shutdownDelay) || shutdownDelay < 0) {
|
|
||||||
logger.error('Invalid shutdown delay. Must be >= 0.');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create the action
|
|
||||||
const newAction: IActionConfig = {
|
|
||||||
type,
|
|
||||||
thresholds: {
|
|
||||||
battery,
|
|
||||||
runtime,
|
|
||||||
},
|
|
||||||
triggerMode: triggerMode as IActionConfig['triggerMode'],
|
|
||||||
shutdownDelay,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add to target (UPS or group)
|
// Add to target (UPS or group)
|
||||||
if (!target!.actions) {
|
if (!target!.actions) {
|
||||||
target!.actions = [];
|
target!.actions = [];
|
||||||
}
|
}
|
||||||
target!.actions.push(newAction);
|
target!.actions.push(newAction as IActionConfig);
|
||||||
|
|
||||||
await this.nupst.getDaemon().saveConfig(config);
|
await this.nupst.getDaemon().saveConfig(config);
|
||||||
|
|
||||||
@@ -346,17 +464,38 @@ export class ActionHandler {
|
|||||||
{ header: 'Battery', key: 'battery', align: 'right' },
|
{ header: 'Battery', key: 'battery', align: 'right' },
|
||||||
{ header: 'Runtime', key: 'runtime', align: 'right' },
|
{ header: 'Runtime', key: 'runtime', align: 'right' },
|
||||||
{ header: 'Trigger Mode', key: 'triggerMode', align: 'left' },
|
{ header: 'Trigger Mode', key: 'triggerMode', align: 'left' },
|
||||||
{ header: 'Delay', key: 'delay', align: 'right' },
|
{ header: 'Details', key: 'details', align: 'left' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const rows = target.actions.map((action, index) => ({
|
const rows = target.actions.map((action, index) => {
|
||||||
index: theme.dim(index.toString()),
|
let details = `${action.shutdownDelay || 5}min delay`;
|
||||||
type: theme.highlight(action.type),
|
if (action.type === 'proxmox') {
|
||||||
battery: action.thresholds ? `${action.thresholds.battery}%` : theme.dim('N/A'),
|
const mode = action.proxmoxMode || 'auto';
|
||||||
runtime: action.thresholds ? `${action.thresholds.runtime}min` : theme.dim('N/A'),
|
if (mode === 'cli' || (mode === 'auto' && !action.proxmoxTokenId)) {
|
||||||
triggerMode: theme.dim(action.triggerMode || 'onlyThresholds'),
|
details = 'CLI mode';
|
||||||
delay: `${action.shutdownDelay || 5}s`,
|
} else {
|
||||||
}));
|
const host = action.proxmoxHost || 'localhost';
|
||||||
|
const port = action.proxmoxPort || 8006;
|
||||||
|
details = `API ${host}:${port}`;
|
||||||
|
}
|
||||||
|
if (action.proxmoxExcludeIds?.length) {
|
||||||
|
details += `, excl: ${action.proxmoxExcludeIds.join(',')}`;
|
||||||
|
}
|
||||||
|
} 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.logTable(columns, rows);
|
||||||
logger.log('');
|
logger.log('');
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ export class GroupHandler {
|
|||||||
await this.nupst.getDaemon().loadConfig();
|
await this.nupst.getDaemon().loadConfig();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.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;
|
return;
|
||||||
}
|
}
|
||||||
@@ -219,7 +219,7 @@ export class GroupHandler {
|
|||||||
await this.nupst.getDaemon().loadConfig();
|
await this.nupst.getDaemon().loadConfig();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.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;
|
return;
|
||||||
}
|
}
|
||||||
@@ -316,7 +316,7 @@ export class GroupHandler {
|
|||||||
await this.nupst.getDaemon().loadConfig();
|
await this.nupst.getDaemon().loadConfig();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.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;
|
return;
|
||||||
}
|
}
|
||||||
@@ -484,7 +484,7 @@ export class GroupHandler {
|
|||||||
prompt: (question: string) => Promise<string>,
|
prompt: (question: string) => Promise<string>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!config.upsDevices || config.upsDevices.length === 0) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+134
-19
@@ -1,7 +1,12 @@
|
|||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
|
import * as fs from 'node:fs';
|
||||||
|
import * as path from 'node:path';
|
||||||
import { execSync } from 'node:child_process';
|
import { execSync } from 'node:child_process';
|
||||||
import { Nupst } from '../nupst.ts';
|
import { Nupst } from '../nupst.ts';
|
||||||
import { logger } from '../logger.ts';
|
import { logger } from '../logger.ts';
|
||||||
|
import { theme } from '../colors.ts';
|
||||||
|
import { PAUSE } from '../constants.ts';
|
||||||
|
import type { IPauseState } from '../pause-state.ts';
|
||||||
import * as helpers from '../helpers/index.ts';
|
import * as helpers from '../helpers/index.ts';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -25,7 +30,9 @@ export class ServiceHandler {
|
|||||||
public async enable(): Promise<void> {
|
public async enable(): Promise<void> {
|
||||||
this.checkRootAccess('This command must be run as root.');
|
this.checkRootAccess('This command must be run as root.');
|
||||||
await this.nupst.getSystemd().install();
|
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.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -98,10 +105,131 @@ export class ServiceHandler {
|
|||||||
/**
|
/**
|
||||||
* Show status of the systemd service and UPS
|
* Show status of the systemd service and UPS
|
||||||
*/
|
*/
|
||||||
public async status(): Promise<void> {
|
public async status(debugMode: boolean = false): Promise<void> {
|
||||||
// Extract debug options from args array
|
await this.nupst.getSystemd().getStatus(debugMode);
|
||||||
const debugOptions = this.extractDebugOptions(process.argv);
|
}
|
||||||
await this.nupst.getSystemd().getStatus(debugOptions.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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -130,7 +258,7 @@ export class ServiceHandler {
|
|||||||
try {
|
try {
|
||||||
// Check if running as root
|
// Check if running as root
|
||||||
this.checkRootAccess(
|
this.checkRootAccess(
|
||||||
'This command must be run as root to update NUPST.',
|
'This command must be run as root to upgrade NUPST.',
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log('');
|
console.log('');
|
||||||
@@ -274,17 +402,4 @@ export class ServiceHandler {
|
|||||||
process.exit(1);
|
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 };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+415
-90
@@ -5,8 +5,12 @@ import { type ITableColumn, logger } from '../logger.ts';
|
|||||||
import { theme } from '../colors.ts';
|
import { theme } from '../colors.ts';
|
||||||
import * as helpers from '../helpers/index.ts';
|
import * as helpers from '../helpers/index.ts';
|
||||||
import type { ISnmpConfig, IUpsStatus as ISnmpUpsStatus, TUpsModel } from '../snmp/types.ts';
|
import type { ISnmpConfig, IUpsStatus as ISnmpUpsStatus, TUpsModel } from '../snmp/types.ts';
|
||||||
|
import type { IUpsdConfig } from '../upsd/types.ts';
|
||||||
|
import type { TProtocol } from '../protocol/types.ts';
|
||||||
import type { INupstConfig, IUpsConfig } from '../daemon.ts';
|
import type { INupstConfig, IUpsConfig } from '../daemon.ts';
|
||||||
import type { IActionConfig } from '../actions/base-action.ts';
|
import type { IActionConfig } from '../actions/base-action.ts';
|
||||||
|
import { ProxmoxAction } from '../actions/proxmox-action.ts';
|
||||||
|
import { UPSD } from '../constants.ts';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Thresholds configuration for CLI display
|
* Thresholds configuration for CLI display
|
||||||
@@ -89,31 +93,54 @@ export class UpsHandler {
|
|||||||
const upsId = helpers.shortId();
|
const upsId = helpers.shortId();
|
||||||
const name = await prompt('UPS Name: ');
|
const name = await prompt('UPS Name: ');
|
||||||
|
|
||||||
|
// Select protocol
|
||||||
|
logger.log('');
|
||||||
|
logger.info('Communication Protocol:');
|
||||||
|
logger.dim(' 1) SNMP (network UPS with SNMP agent)');
|
||||||
|
logger.dim(' 2) UPSD/NIS (local NUT server, e.g. USB-connected UPS)');
|
||||||
|
const protocolInput = await prompt('Select protocol [1]: ');
|
||||||
|
const protocolChoice = parseInt(protocolInput, 10) || 1;
|
||||||
|
const protocol: TProtocol = protocolChoice === 2 ? 'upsd' : 'snmp';
|
||||||
|
|
||||||
// Create a new UPS configuration object with defaults
|
// Create a new UPS configuration object with defaults
|
||||||
const newUps = {
|
const newUps: Record<string, unknown> & {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
groups: string[];
|
||||||
|
actions: IActionConfig[];
|
||||||
|
protocol: TProtocol;
|
||||||
|
snmp?: ISnmpConfig;
|
||||||
|
upsd?: IUpsdConfig;
|
||||||
|
} = {
|
||||||
id: upsId,
|
id: upsId,
|
||||||
name: name || `UPS-${upsId}`,
|
name: name || `UPS-${upsId}`,
|
||||||
snmp: {
|
protocol,
|
||||||
|
groups: [],
|
||||||
|
actions: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (protocol === 'snmp') {
|
||||||
|
newUps.snmp = {
|
||||||
host: '127.0.0.1',
|
host: '127.0.0.1',
|
||||||
port: 161,
|
port: 161,
|
||||||
community: 'public',
|
community: 'public',
|
||||||
version: 1,
|
version: 1,
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
upsModel: 'cyberpower' as TUpsModel,
|
upsModel: 'cyberpower' as TUpsModel,
|
||||||
},
|
};
|
||||||
thresholds: {
|
// Gather SNMP settings
|
||||||
battery: 60,
|
await this.gatherSnmpSettings(newUps.snmp, prompt);
|
||||||
runtime: 20,
|
// Gather UPS model settings
|
||||||
},
|
await this.gatherUpsModelSettings(newUps.snmp, prompt);
|
||||||
groups: [],
|
} else {
|
||||||
actions: [],
|
newUps.upsd = {
|
||||||
};
|
host: '127.0.0.1',
|
||||||
|
port: UPSD.DEFAULT_PORT,
|
||||||
// Gather SNMP settings
|
upsName: UPSD.DEFAULT_UPS_NAME,
|
||||||
await this.gatherSnmpSettings(newUps.snmp, prompt);
|
timeout: UPSD.DEFAULT_TIMEOUT_MS,
|
||||||
|
};
|
||||||
// Gather UPS model settings
|
await this.gatherUpsdSettings(newUps.upsd, prompt);
|
||||||
await this.gatherUpsModelSettings(newUps.snmp, prompt);
|
}
|
||||||
|
|
||||||
// Get access to GroupHandler for group assignments
|
// Get access to GroupHandler for group assignments
|
||||||
const groupHandler = this.nupst.getGroupHandler();
|
const groupHandler = this.nupst.getGroupHandler();
|
||||||
@@ -132,10 +159,14 @@ export class UpsHandler {
|
|||||||
// Save the configuration
|
// Save the configuration
|
||||||
await this.nupst.getDaemon().saveConfig(config as INupstConfig);
|
await this.nupst.getDaemon().saveConfig(config as INupstConfig);
|
||||||
|
|
||||||
this.displayUpsConfigSummary(newUps);
|
this.displayUpsConfigSummary(newUps as unknown as IUpsConfig);
|
||||||
|
|
||||||
// Test the connection if requested
|
// Test the connection if requested
|
||||||
await this.optionallyTestConnection(newUps.snmp, prompt);
|
if (protocol === 'snmp' && newUps.snmp) {
|
||||||
|
await this.optionallyTestConnection(newUps.snmp as ISnmpConfig, prompt);
|
||||||
|
} else if (protocol === 'upsd' && newUps.upsd) {
|
||||||
|
await this.optionallyTestUpsdConnection(newUps.upsd, prompt);
|
||||||
|
}
|
||||||
|
|
||||||
// Check if service is running and restart it if needed
|
// Check if service is running and restart it if needed
|
||||||
await this.restartServiceIfRunning();
|
await this.restartServiceIfRunning();
|
||||||
@@ -180,7 +211,7 @@ export class UpsHandler {
|
|||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
// For specific UPS ID, error if config doesn't exist
|
// For specific UPS ID, error if config doesn't exist
|
||||||
logger.error('No configuration found. Please run "nupst setup" first.');
|
logger.error('No configuration found. Please run "nupst ups add" first.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -219,7 +250,7 @@ export class UpsHandler {
|
|||||||
} else {
|
} else {
|
||||||
// For backward compatibility, edit the first UPS if no ID specified
|
// For backward compatibility, edit the first UPS if no ID specified
|
||||||
if (config.upsDevices.length === 0) {
|
if (config.upsDevices.length === 0) {
|
||||||
logger.error('No UPS devices configured. Please run "nupst add" to add a UPS.');
|
logger.error('No UPS devices configured. Please run "nupst ups add" to add a UPS.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
upsToEdit = config.upsDevices[0];
|
upsToEdit = config.upsDevices[0];
|
||||||
@@ -232,11 +263,53 @@ export class UpsHandler {
|
|||||||
upsToEdit.name = newName;
|
upsToEdit.name = newName;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Edit SNMP settings
|
// Show current protocol and allow changing
|
||||||
await this.gatherSnmpSettings(upsToEdit.snmp, prompt);
|
const currentProtocol = upsToEdit.protocol || 'snmp';
|
||||||
|
logger.log('');
|
||||||
|
logger.info(`Current Protocol: ${currentProtocol.toUpperCase()}`);
|
||||||
|
logger.dim(' 1) SNMP (network UPS with SNMP agent)');
|
||||||
|
logger.dim(' 2) UPSD/NIS (local NUT server, e.g. USB-connected UPS)');
|
||||||
|
const protocolInput = await prompt(
|
||||||
|
`Select protocol [${currentProtocol === 'upsd' ? '2' : '1'}]: `,
|
||||||
|
);
|
||||||
|
const protocolChoice = parseInt(protocolInput, 10);
|
||||||
|
if (protocolChoice === 2) {
|
||||||
|
upsToEdit.protocol = 'upsd';
|
||||||
|
} else if (protocolChoice === 1) {
|
||||||
|
upsToEdit.protocol = 'snmp';
|
||||||
|
}
|
||||||
|
// else keep current
|
||||||
|
|
||||||
// Edit UPS model settings
|
const editProtocol = upsToEdit.protocol || 'snmp';
|
||||||
await this.gatherUpsModelSettings(upsToEdit.snmp, prompt);
|
|
||||||
|
if (editProtocol === 'snmp') {
|
||||||
|
// Initialize SNMP config if switching from UPSD
|
||||||
|
if (!upsToEdit.snmp) {
|
||||||
|
upsToEdit.snmp = {
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: 161,
|
||||||
|
community: 'public',
|
||||||
|
version: 1,
|
||||||
|
timeout: 5000,
|
||||||
|
upsModel: 'cyberpower' as TUpsModel,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Edit SNMP settings
|
||||||
|
await this.gatherSnmpSettings(upsToEdit.snmp, prompt);
|
||||||
|
// Edit UPS model settings
|
||||||
|
await this.gatherUpsModelSettings(upsToEdit.snmp, prompt);
|
||||||
|
} else {
|
||||||
|
// Initialize UPSD config if switching from SNMP
|
||||||
|
if (!upsToEdit.upsd) {
|
||||||
|
upsToEdit.upsd = {
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: UPSD.DEFAULT_PORT,
|
||||||
|
upsName: UPSD.DEFAULT_UPS_NAME,
|
||||||
|
timeout: UPSD.DEFAULT_TIMEOUT_MS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
await this.gatherUpsdSettings(upsToEdit.upsd, prompt);
|
||||||
|
}
|
||||||
|
|
||||||
// Get access to GroupHandler for group assignments
|
// Get access to GroupHandler for group assignments
|
||||||
const groupHandler = this.nupst.getGroupHandler();
|
const groupHandler = this.nupst.getGroupHandler();
|
||||||
@@ -260,7 +333,11 @@ export class UpsHandler {
|
|||||||
this.displayUpsConfigSummary(upsToEdit);
|
this.displayUpsConfigSummary(upsToEdit);
|
||||||
|
|
||||||
// Test the connection if requested
|
// Test the connection if requested
|
||||||
await this.optionallyTestConnection(upsToEdit.snmp, prompt);
|
if (editProtocol === 'snmp' && upsToEdit.snmp) {
|
||||||
|
await this.optionallyTestConnection(upsToEdit.snmp, prompt);
|
||||||
|
} else if (editProtocol === 'upsd' && upsToEdit.upsd) {
|
||||||
|
await this.optionallyTestUpsdConnection(upsToEdit.upsd, prompt);
|
||||||
|
}
|
||||||
|
|
||||||
// Check if service is running and restart it if needed
|
// Check if service is running and restart it if needed
|
||||||
await this.restartServiceIfRunning();
|
await this.restartServiceIfRunning();
|
||||||
@@ -281,7 +358,7 @@ export class UpsHandler {
|
|||||||
const errorBoxWidth = 45;
|
const errorBoxWidth = 45;
|
||||||
logger.logBoxTitle('Configuration Error', errorBoxWidth);
|
logger.logBoxTitle('Configuration Error', errorBoxWidth);
|
||||||
logger.logBoxLine('No configuration found.');
|
logger.logBoxLine('No configuration found.');
|
||||||
logger.logBoxLine("Please run 'nupst setup' first to create a configuration.");
|
logger.logBoxLine("Please run 'nupst ups add' first to create a configuration.");
|
||||||
logger.logBoxEnd();
|
logger.logBoxEnd();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -292,7 +369,7 @@ export class UpsHandler {
|
|||||||
// Check if multi-UPS config
|
// Check if multi-UPS config
|
||||||
if (!config.upsDevices || !Array.isArray(config.upsDevices)) {
|
if (!config.upsDevices || !Array.isArray(config.upsDevices)) {
|
||||||
logger.error('Legacy single-UPS configuration detected. Cannot delete UPS.');
|
logger.error('Legacy single-UPS configuration detected. Cannot delete UPS.');
|
||||||
logger.log('Use "nupst add" to migrate to multi-UPS configuration format first.');
|
logger.log('Use "nupst ups add" to migrate to multi-UPS configuration format first.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,17 +474,31 @@ export class UpsHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Prepare table data
|
// Prepare table data
|
||||||
const rows = config.upsDevices.map((ups) => ({
|
const rows = config.upsDevices.map((ups) => {
|
||||||
id: ups.id,
|
const protocol = ups.protocol || 'snmp';
|
||||||
name: ups.name || '',
|
let host = 'N/A';
|
||||||
host: `${ups.snmp.host}:${ups.snmp.port}`,
|
let model = '';
|
||||||
model: ups.snmp.upsModel || 'cyberpower',
|
if (protocol === 'upsd' && ups.upsd) {
|
||||||
groups: ups.groups.length > 0 ? ups.groups.join(', ') : theme.dim('None'),
|
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 {
|
||||||
|
id: ups.id,
|
||||||
|
name: ups.name || '',
|
||||||
|
protocol: protocol.toUpperCase(),
|
||||||
|
host,
|
||||||
|
model,
|
||||||
|
groups: ups.groups.length > 0 ? ups.groups.join(', ') : theme.dim('None'),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const columns: ITableColumn[] = [
|
const columns: ITableColumn[] = [
|
||||||
{ header: 'ID', key: 'id', align: 'left', color: theme.highlight },
|
{ header: 'ID', key: 'id', align: 'left', color: theme.highlight },
|
||||||
{ header: 'Name', key: 'name', align: 'left' },
|
{ header: 'Name', key: 'name', align: 'left' },
|
||||||
|
{ header: 'Protocol', key: 'protocol', align: 'left' },
|
||||||
{ header: 'Host:Port', key: 'host', align: 'left', color: theme.info },
|
{ header: 'Host:Port', key: 'host', align: 'left', color: theme.info },
|
||||||
{ header: 'Model', key: 'model', align: 'left' },
|
{ header: 'Model', key: 'model', align: 'left' },
|
||||||
{ header: 'Groups', key: 'groups', align: 'left' },
|
{ header: 'Groups', key: 'groups', align: 'left' },
|
||||||
@@ -446,7 +537,7 @@ export class UpsHandler {
|
|||||||
const errorBoxWidth = 45;
|
const errorBoxWidth = 45;
|
||||||
logger.logBoxTitle('Configuration Error', errorBoxWidth);
|
logger.logBoxTitle('Configuration Error', errorBoxWidth);
|
||||||
logger.logBoxLine('No configuration found.');
|
logger.logBoxLine('No configuration found.');
|
||||||
logger.logBoxLine("Please run 'nupst setup' first to create a configuration.");
|
logger.logBoxLine("Please run 'nupst ups add' first to create a configuration.");
|
||||||
logger.logBoxEnd();
|
logger.logBoxEnd();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -482,58 +573,73 @@ export class UpsHandler {
|
|||||||
// Type guard: IUpsConfig has 'id' and 'name' at root level, INupstConfig doesn't
|
// Type guard: IUpsConfig has 'id' and 'name' at root level, INupstConfig doesn't
|
||||||
const isUpsConfig = 'id' in config && 'name' in config;
|
const isUpsConfig = 'id' in config && 'name' in config;
|
||||||
|
|
||||||
// Get SNMP config and other values based on config type
|
|
||||||
const snmpConfig: ISnmpConfig | undefined = isUpsConfig
|
|
||||||
? (config as IUpsConfig).snmp
|
|
||||||
: (config as INupstConfig).snmp;
|
|
||||||
const checkInterval = isUpsConfig ? 30000 : (config as INupstConfig).checkInterval || 30000;
|
const checkInterval = isUpsConfig ? 30000 : (config as INupstConfig).checkInterval || 30000;
|
||||||
const upsName = isUpsConfig ? (config as IUpsConfig).name : 'Default UPS';
|
const upsName = isUpsConfig ? (config as IUpsConfig).name : 'Default UPS';
|
||||||
const upsId = isUpsConfig ? (config as IUpsConfig).id : 'default';
|
const upsId = isUpsConfig ? (config as IUpsConfig).id : 'default';
|
||||||
|
const protocol = isUpsConfig ? ((config as IUpsConfig).protocol || 'snmp') : 'snmp';
|
||||||
|
|
||||||
const boxWidth = 45;
|
const boxWidth = 45;
|
||||||
logger.logBoxTitle(`Testing Configuration: ${upsName}`, boxWidth);
|
logger.logBoxTitle(`Testing Configuration: ${upsName}`, boxWidth);
|
||||||
logger.logBoxLine(`UPS ID: ${upsId}`);
|
logger.logBoxLine(`UPS ID: ${upsId}`);
|
||||||
|
logger.logBoxLine(`Protocol: ${protocol.toUpperCase()}`);
|
||||||
|
|
||||||
if (!snmpConfig) {
|
if (protocol === 'upsd' && isUpsConfig && (config as IUpsConfig).upsd) {
|
||||||
logger.logBoxLine('SNMP Settings: Not configured');
|
const upsdConfig = (config as IUpsConfig).upsd!;
|
||||||
logger.logBoxEnd();
|
logger.logBoxLine('UPSD/NIS Settings:');
|
||||||
return;
|
logger.logBoxLine(` Host: ${upsdConfig.host}`);
|
||||||
}
|
logger.logBoxLine(` Port: ${upsdConfig.port}`);
|
||||||
|
logger.logBoxLine(` UPS Name: ${upsdConfig.upsName}`);
|
||||||
|
logger.logBoxLine(` Timeout: ${upsdConfig.timeout / 1000} seconds`);
|
||||||
|
if (upsdConfig.username) {
|
||||||
|
logger.logBoxLine(` Auth: ${upsdConfig.username}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// SNMP display
|
||||||
|
const snmpConfig: ISnmpConfig | undefined = isUpsConfig
|
||||||
|
? (config as IUpsConfig).snmp
|
||||||
|
: (config as INupstConfig).snmp;
|
||||||
|
|
||||||
logger.logBoxLine('SNMP Settings:');
|
if (!snmpConfig) {
|
||||||
logger.logBoxLine(` Host: ${snmpConfig.host}`);
|
logger.logBoxLine('SNMP Settings: Not configured');
|
||||||
logger.logBoxLine(` Port: ${snmpConfig.port}`);
|
logger.logBoxEnd();
|
||||||
logger.logBoxLine(` Version: ${snmpConfig.version}`);
|
return;
|
||||||
logger.logBoxLine(` UPS Model: ${snmpConfig.upsModel || 'cyberpower'}`);
|
|
||||||
|
|
||||||
if (snmpConfig.version === 1 || snmpConfig.version === 2) {
|
|
||||||
logger.logBoxLine(` Community: ${snmpConfig.community}`);
|
|
||||||
} else if (snmpConfig.version === 3) {
|
|
||||||
logger.logBoxLine(` Security Level: ${snmpConfig.securityLevel}`);
|
|
||||||
logger.logBoxLine(` Username: ${snmpConfig.username}`);
|
|
||||||
|
|
||||||
// Show auth and privacy details based on security level
|
|
||||||
if (snmpConfig.securityLevel === 'authNoPriv' || snmpConfig.securityLevel === 'authPriv') {
|
|
||||||
logger.logBoxLine(` Auth Protocol: ${snmpConfig.authProtocol || 'None'}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (snmpConfig.securityLevel === 'authPriv') {
|
logger.logBoxLine('SNMP Settings:');
|
||||||
logger.logBoxLine(` Privacy Protocol: ${snmpConfig.privProtocol || 'None'}`);
|
logger.logBoxLine(` Host: ${snmpConfig.host}`);
|
||||||
|
logger.logBoxLine(` Port: ${snmpConfig.port}`);
|
||||||
|
logger.logBoxLine(` Version: ${snmpConfig.version}`);
|
||||||
|
logger.logBoxLine(` UPS Model: ${snmpConfig.upsModel || 'cyberpower'}`);
|
||||||
|
|
||||||
|
if (snmpConfig.version === 1 || snmpConfig.version === 2) {
|
||||||
|
logger.logBoxLine(` Community: ${snmpConfig.community}`);
|
||||||
|
} else if (snmpConfig.version === 3) {
|
||||||
|
logger.logBoxLine(` Security Level: ${snmpConfig.securityLevel}`);
|
||||||
|
logger.logBoxLine(` Username: ${snmpConfig.username}`);
|
||||||
|
|
||||||
|
if (snmpConfig.securityLevel === 'authNoPriv' || snmpConfig.securityLevel === 'authPriv') {
|
||||||
|
logger.logBoxLine(` Auth Protocol: ${snmpConfig.authProtocol || 'None'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snmpConfig.securityLevel === 'authPriv') {
|
||||||
|
logger.logBoxLine(` Privacy Protocol: ${snmpConfig.privProtocol || 'None'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.logBoxLine(` Timeout: ${snmpConfig.timeout / 1000} seconds`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show timeout value
|
if (snmpConfig.upsModel === 'custom' && snmpConfig.customOIDs) {
|
||||||
logger.logBoxLine(` Timeout: ${snmpConfig.timeout / 1000} seconds`);
|
logger.logBoxLine('Custom OIDs:');
|
||||||
|
logger.logBoxLine(` Power Status: ${snmpConfig.customOIDs.POWER_STATUS || 'Not set'}`);
|
||||||
|
logger.logBoxLine(
|
||||||
|
` Battery Capacity: ${snmpConfig.customOIDs.BATTERY_CAPACITY || 'Not set'}`,
|
||||||
|
);
|
||||||
|
logger.logBoxLine(
|
||||||
|
` Battery Runtime: ${snmpConfig.customOIDs.BATTERY_RUNTIME || 'Not set'}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show OIDs if custom model is selected
|
|
||||||
if (snmpConfig.upsModel === 'custom' && snmpConfig.customOIDs) {
|
|
||||||
logger.logBoxLine('Custom OIDs:');
|
|
||||||
logger.logBoxLine(` Power Status: ${snmpConfig.customOIDs.POWER_STATUS || 'Not set'}`);
|
|
||||||
logger.logBoxLine(
|
|
||||||
` Battery Capacity: ${snmpConfig.customOIDs.BATTERY_CAPACITY || 'Not set'}`,
|
|
||||||
);
|
|
||||||
logger.logBoxLine(` Battery Runtime: ${snmpConfig.customOIDs.BATTERY_RUNTIME || 'Not set'}`);
|
|
||||||
}
|
|
||||||
// Show group assignments if this is a UPS config
|
// Show group assignments if this is a UPS config
|
||||||
if (isUpsConfig) {
|
if (isUpsConfig) {
|
||||||
const groups = (config as IUpsConfig).groups;
|
const groups = (config as IUpsConfig).groups;
|
||||||
@@ -555,25 +661,38 @@ export class UpsHandler {
|
|||||||
const isUpsConfig = 'id' in config && 'name' in config;
|
const isUpsConfig = 'id' in config && 'name' in config;
|
||||||
const upsId = isUpsConfig ? (config as IUpsConfig).id : 'default';
|
const upsId = isUpsConfig ? (config as IUpsConfig).id : 'default';
|
||||||
const upsName = isUpsConfig ? (config as IUpsConfig).name : 'Default UPS';
|
const upsName = isUpsConfig ? (config as IUpsConfig).name : 'Default UPS';
|
||||||
logger.log(`\nTesting connection to UPS: ${upsName} (${upsId})...`);
|
const protocol = isUpsConfig ? ((config as IUpsConfig).protocol || 'snmp') : 'snmp';
|
||||||
|
logger.log(
|
||||||
|
`\nTesting connection to UPS: ${upsName} (${upsId}) via ${protocol.toUpperCase()}...`,
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get SNMP config based on config type
|
let status: ISnmpUpsStatus;
|
||||||
const snmpConfig: ISnmpConfig | undefined = isUpsConfig
|
|
||||||
? (config as IUpsConfig).snmp
|
|
||||||
: (config as INupstConfig).snmp;
|
|
||||||
|
|
||||||
if (!snmpConfig) {
|
if (protocol === 'upsd' && isUpsConfig && (config as IUpsConfig).upsd) {
|
||||||
throw new Error('SNMP configuration not found');
|
const upsdConfig = (config as IUpsConfig).upsd!;
|
||||||
|
const testConfig = {
|
||||||
|
...upsdConfig,
|
||||||
|
timeout: Math.min(upsdConfig.timeout, 10000),
|
||||||
|
};
|
||||||
|
status = await this.nupst.getUpsd().getUpsStatus(testConfig);
|
||||||
|
} else {
|
||||||
|
// SNMP protocol
|
||||||
|
const snmpConfig: ISnmpConfig | undefined = isUpsConfig
|
||||||
|
? (config as IUpsConfig).snmp
|
||||||
|
: (config as INupstConfig).snmp;
|
||||||
|
|
||||||
|
if (!snmpConfig) {
|
||||||
|
throw new Error('SNMP configuration not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const testConfig: ISnmpConfig = {
|
||||||
|
...snmpConfig,
|
||||||
|
timeout: Math.min(snmpConfig.timeout, 10000),
|
||||||
|
};
|
||||||
|
status = await this.nupst.getSnmp().getUpsStatus(testConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
const testConfig: ISnmpConfig = {
|
|
||||||
...snmpConfig,
|
|
||||||
timeout: Math.min(snmpConfig.timeout, 10000), // Use at most 10 seconds for testing
|
|
||||||
};
|
|
||||||
|
|
||||||
const status = await this.nupst.getSnmp().getUpsStatus(testConfig);
|
|
||||||
|
|
||||||
const boxWidth = 45;
|
const boxWidth = 45;
|
||||||
logger.logBoxTitle(`Connection Successful: ${upsName}`, boxWidth);
|
logger.logBoxTitle(`Connection Successful: ${upsName}`, boxWidth);
|
||||||
logger.logBoxLine('UPS Status:');
|
logger.logBoxLine('UPS Status:');
|
||||||
@@ -586,7 +705,9 @@ export class UpsHandler {
|
|||||||
logger.logBoxTitle(`Connection Failed: ${upsName}`, errorBoxWidth);
|
logger.logBoxTitle(`Connection Failed: ${upsName}`, errorBoxWidth);
|
||||||
logger.logBoxLine(`Error: ${error instanceof Error ? error.message : String(error)}`);
|
logger.logBoxLine(`Error: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
logger.logBoxEnd();
|
logger.logBoxEnd();
|
||||||
logger.log("\nPlease check your settings and run 'nupst edit' to reconfigure this UPS.");
|
logger.log(
|
||||||
|
`\nPlease check your settings and run 'nupst ups edit ${upsId}' to reconfigure this UPS.`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -870,6 +991,126 @@ export class UpsHandler {
|
|||||||
OUTPUT_CURRENT: '',
|
OUTPUT_CURRENT: '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Runtime unit selection
|
||||||
|
logger.log('');
|
||||||
|
logger.info('Battery Runtime Unit:');
|
||||||
|
logger.dim(' Controls how NUPST interprets the runtime value from your UPS.');
|
||||||
|
logger.dim(' 1) Minutes (APC, TrippLite, Liebert - most common)');
|
||||||
|
logger.dim(' 2) Seconds (Eaton, HPE, many RFC 1628 devices)');
|
||||||
|
logger.dim(' 3) Ticks (CyberPower - 1/100 second increments)');
|
||||||
|
|
||||||
|
const defaultUnitValue = snmpConfig.runtimeUnit === 'seconds'
|
||||||
|
? 2
|
||||||
|
: snmpConfig.runtimeUnit === 'ticks'
|
||||||
|
? 3
|
||||||
|
: snmpConfig.upsModel === 'cyberpower'
|
||||||
|
? 3
|
||||||
|
: snmpConfig.upsModel === 'eaton'
|
||||||
|
? 2
|
||||||
|
: 1;
|
||||||
|
|
||||||
|
const unitInput = await prompt(`Select runtime unit [${defaultUnitValue}]: `);
|
||||||
|
const unitValue = parseInt(unitInput, 10) || defaultUnitValue;
|
||||||
|
|
||||||
|
if (unitValue === 2) {
|
||||||
|
snmpConfig.runtimeUnit = 'seconds';
|
||||||
|
} else if (unitValue === 3) {
|
||||||
|
snmpConfig.runtimeUnit = 'ticks';
|
||||||
|
} else {
|
||||||
|
snmpConfig.runtimeUnit = 'minutes';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gather UPSD/NIS connection settings
|
||||||
|
* @param upsdConfig UPSD configuration object to update
|
||||||
|
* @param prompt Function to prompt for user input
|
||||||
|
*/
|
||||||
|
private async gatherUpsdSettings(
|
||||||
|
upsdConfig: IUpsdConfig,
|
||||||
|
prompt: (question: string) => Promise<string>,
|
||||||
|
): Promise<void> {
|
||||||
|
logger.log('');
|
||||||
|
logger.info('UPSD/NIS Connection Settings:');
|
||||||
|
logger.dim('Connect to a local NUT (Network UPS Tools) server');
|
||||||
|
|
||||||
|
// Host
|
||||||
|
const defaultHost = upsdConfig.host || '127.0.0.1';
|
||||||
|
const host = await prompt(`UPSD Host [${defaultHost}]: `);
|
||||||
|
upsdConfig.host = host.trim() || defaultHost;
|
||||||
|
|
||||||
|
// Port
|
||||||
|
const defaultPort = upsdConfig.port || UPSD.DEFAULT_PORT;
|
||||||
|
const portInput = await prompt(`UPSD Port [${defaultPort}]: `);
|
||||||
|
const port = parseInt(portInput, 10);
|
||||||
|
upsdConfig.port = portInput.trim() && !isNaN(port) ? port : defaultPort;
|
||||||
|
|
||||||
|
// UPS Name
|
||||||
|
const defaultUpsName = upsdConfig.upsName || UPSD.DEFAULT_UPS_NAME;
|
||||||
|
const upsName = await prompt(`NUT UPS Name [${defaultUpsName}]: `);
|
||||||
|
upsdConfig.upsName = upsName.trim() || defaultUpsName;
|
||||||
|
|
||||||
|
// Timeout
|
||||||
|
const defaultTimeout = (upsdConfig.timeout || UPSD.DEFAULT_TIMEOUT_MS) / 1000;
|
||||||
|
const timeoutInput = await prompt(`Timeout in seconds [${defaultTimeout}]: `);
|
||||||
|
const timeout = parseInt(timeoutInput, 10);
|
||||||
|
if (timeoutInput.trim() && !isNaN(timeout)) {
|
||||||
|
upsdConfig.timeout = timeout * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authentication (optional)
|
||||||
|
logger.log('');
|
||||||
|
logger.info('Authentication (optional):');
|
||||||
|
logger.dim('Leave blank if your NUT server does not require authentication');
|
||||||
|
const username = await prompt(`Username [${upsdConfig.username || ''}]: `);
|
||||||
|
if (username.trim()) {
|
||||||
|
upsdConfig.username = username.trim();
|
||||||
|
const password = await prompt(`Password: `);
|
||||||
|
if (password.trim()) {
|
||||||
|
upsdConfig.password = password.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optionally test UPSD connection
|
||||||
|
* @param upsdConfig UPSD configuration to test
|
||||||
|
* @param prompt Function to prompt for user input
|
||||||
|
*/
|
||||||
|
private async optionallyTestUpsdConnection(
|
||||||
|
upsdConfig: IUpsdConfig,
|
||||||
|
prompt: (question: string) => Promise<string>,
|
||||||
|
): Promise<void> {
|
||||||
|
const testConnection = await prompt(
|
||||||
|
'Would you like to test the connection to your UPS? (y/N): ',
|
||||||
|
);
|
||||||
|
if (testConnection.toLowerCase() === 'y') {
|
||||||
|
logger.log('\nTesting connection to UPSD server...');
|
||||||
|
try {
|
||||||
|
const testConfig = {
|
||||||
|
...upsdConfig,
|
||||||
|
timeout: Math.min(upsdConfig.timeout, 10000),
|
||||||
|
};
|
||||||
|
|
||||||
|
const status = await this.nupst.getUpsd().getUpsStatus(testConfig);
|
||||||
|
const boxWidth = 45;
|
||||||
|
logger.log('');
|
||||||
|
logger.logBoxTitle('Connection Successful!', boxWidth);
|
||||||
|
logger.logBoxLine('UPS Status:');
|
||||||
|
logger.logBoxLine(`✓ Power Status: ${status.powerStatus}`);
|
||||||
|
logger.logBoxLine(`✓ Battery Capacity: ${status.batteryCapacity}%`);
|
||||||
|
logger.logBoxLine(`✓ Runtime Remaining: ${status.batteryRuntime} minutes`);
|
||||||
|
logger.logBoxEnd();
|
||||||
|
} catch (error) {
|
||||||
|
const errorBoxWidth = 45;
|
||||||
|
logger.log('');
|
||||||
|
logger.logBoxTitle('Connection Failed!', errorBoxWidth);
|
||||||
|
logger.logBoxLine(`Error: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
logger.logBoxEnd();
|
||||||
|
logger.log('\nPlease check your NUT server settings and try again.');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -901,6 +1142,7 @@ export class UpsHandler {
|
|||||||
logger.dim(' 1) Shutdown (system shutdown)');
|
logger.dim(' 1) Shutdown (system shutdown)');
|
||||||
logger.dim(' 2) Webhook (HTTP notification)');
|
logger.dim(' 2) Webhook (HTTP notification)');
|
||||||
logger.dim(' 3) Custom Script (run .sh file from /etc/nupst)');
|
logger.dim(' 3) Custom Script (run .sh file from /etc/nupst)');
|
||||||
|
logger.dim(' 4) Proxmox (gracefully shut down VMs/LXCs before host shutdown)');
|
||||||
|
|
||||||
const typeInput = await prompt('Select action type [1]: ');
|
const typeInput = await prompt('Select action type [1]: ');
|
||||||
const typeValue = parseInt(typeInput, 10) || 1;
|
const typeValue = parseInt(typeInput, 10) || 1;
|
||||||
@@ -955,6 +1197,80 @@ export class UpsHandler {
|
|||||||
if (timeoutInput.trim() && !isNaN(timeout)) {
|
if (timeoutInput.trim() && !isNaN(timeout)) {
|
||||||
action.scriptTimeout = timeout * 1000; // Convert to ms
|
action.scriptTimeout = timeout * 1000; // Convert to ms
|
||||||
}
|
}
|
||||||
|
} else if (typeValue === 4) {
|
||||||
|
// Proxmox action
|
||||||
|
action.type = 'proxmox';
|
||||||
|
|
||||||
|
// Auto-detect CLI availability
|
||||||
|
const detection = ProxmoxAction.detectCliAvailability();
|
||||||
|
|
||||||
|
if (detection.available) {
|
||||||
|
logger.log('');
|
||||||
|
logger.success('Proxmox CLI tools detected (qm/pct). No API token needed.');
|
||||||
|
logger.dim(` qm: ${detection.qmPath}`);
|
||||||
|
logger.dim(` pct: ${detection.pctPath}`);
|
||||||
|
action.proxmoxMode = 'cli';
|
||||||
|
} 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.');
|
||||||
|
}
|
||||||
|
logger.log('');
|
||||||
|
logger.info('Proxmox API Settings:');
|
||||||
|
logger.dim('Create a token with: pveum user token add root@pam nupst --privsep=0');
|
||||||
|
|
||||||
|
const pxHost = await prompt('Proxmox Host [localhost]: ');
|
||||||
|
action.proxmoxHost = pxHost.trim() || 'localhost';
|
||||||
|
|
||||||
|
const pxPortInput = await prompt('Proxmox API Port [8006]: ');
|
||||||
|
const pxPort = parseInt(pxPortInput, 10);
|
||||||
|
action.proxmoxPort = pxPortInput.trim() && !isNaN(pxPort) ? pxPort : 8006;
|
||||||
|
|
||||||
|
const pxNode = await prompt('Proxmox Node Name (empty = auto-detect via hostname): ');
|
||||||
|
if (pxNode.trim()) {
|
||||||
|
action.proxmoxNode = pxNode.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenId = await prompt('API Token ID (e.g., root@pam!nupst): ');
|
||||||
|
if (!tokenId.trim()) {
|
||||||
|
logger.warn('Token ID is required for API mode, skipping');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
action.proxmoxTokenId = tokenId.trim();
|
||||||
|
|
||||||
|
const tokenSecret = await prompt('API Token Secret: ');
|
||||||
|
if (!tokenSecret.trim()) {
|
||||||
|
logger.warn('Token Secret is required for API mode, skipping');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
action.proxmoxTokenSecret = tokenSecret.trim();
|
||||||
|
|
||||||
|
const insecureInput = await prompt('Skip TLS verification (self-signed cert)? (Y/n): ');
|
||||||
|
action.proxmoxInsecure = insecureInput.toLowerCase() !== 'n';
|
||||||
|
action.proxmoxMode = 'api';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Common Proxmox settings (both modes)
|
||||||
|
const excludeInput = await prompt('VM/CT IDs to exclude (comma-separated, or empty): ');
|
||||||
|
if (excludeInput.trim()) {
|
||||||
|
action.proxmoxExcludeIds = excludeInput.split(',').map((s) => parseInt(s.trim(), 10))
|
||||||
|
.filter((n) => !isNaN(n));
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutInput = await prompt('VM shutdown timeout in seconds [120]: ');
|
||||||
|
const stopTimeout = parseInt(timeoutInput, 10);
|
||||||
|
if (timeoutInput.trim() && !isNaN(stopTimeout)) {
|
||||||
|
action.proxmoxStopTimeout = stopTimeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
const forceInput = await prompt("Force-stop VMs that don't shut down in time? (Y/n): ");
|
||||||
|
action.proxmoxForceStop = forceInput.toLowerCase() !== 'n';
|
||||||
|
|
||||||
|
logger.log('');
|
||||||
|
logger.info('Note: Place the Proxmox action BEFORE the shutdown action');
|
||||||
|
logger.dim('in the action chain so VMs shut down before the host.');
|
||||||
} else {
|
} else {
|
||||||
logger.warn('Invalid action type, skipping');
|
logger.warn('Invalid action type, skipping');
|
||||||
continue;
|
continue;
|
||||||
@@ -1032,12 +1348,21 @@ export class UpsHandler {
|
|||||||
*/
|
*/
|
||||||
private displayUpsConfigSummary(ups: IUpsConfig): void {
|
private displayUpsConfigSummary(ups: IUpsConfig): void {
|
||||||
const boxWidth = 45;
|
const boxWidth = 45;
|
||||||
|
const protocol = ups.protocol || 'snmp';
|
||||||
logger.log('');
|
logger.log('');
|
||||||
logger.logBoxTitle(`UPS Configuration: ${ups.name}`, boxWidth);
|
logger.logBoxTitle(`UPS Configuration: ${ups.name}`, boxWidth);
|
||||||
logger.logBoxLine(`UPS ID: ${ups.id}`);
|
logger.logBoxLine(`UPS ID: ${ups.id}`);
|
||||||
logger.logBoxLine(`SNMP Host: ${ups.snmp.host}:${ups.snmp.port}`);
|
logger.logBoxLine(`Protocol: ${protocol.toUpperCase()}`);
|
||||||
logger.logBoxLine(`SNMP Version: ${ups.snmp.version}`);
|
|
||||||
logger.logBoxLine(`UPS Model: ${ups.snmp.upsModel}`);
|
if (protocol === 'upsd' && ups.upsd) {
|
||||||
|
logger.logBoxLine(`UPSD Host: ${ups.upsd.host}:${ups.upsd.port}`);
|
||||||
|
logger.logBoxLine(`NUT UPS Name: ${ups.upsd.upsName}`);
|
||||||
|
} else if (ups.snmp) {
|
||||||
|
logger.logBoxLine(`SNMP Host: ${ups.snmp.host}:${ups.snmp.port}`);
|
||||||
|
logger.logBoxLine(`SNMP Version: ${ups.snmp.version}`);
|
||||||
|
logger.logBoxLine(`UPS Model: ${ups.snmp.upsModel}`);
|
||||||
|
logger.logBoxLine(`Runtime Unit: ${ups.snmp.runtimeUnit || 'auto'}`);
|
||||||
|
}
|
||||||
|
|
||||||
if (ups.groups && ups.groups.length > 0) {
|
if (ups.groups && ups.groups.length > 0) {
|
||||||
logger.logBoxLine(`Groups: ${ups.groups.join(', ')}`);
|
logger.logBoxLine(`Groups: ${ups.groups.join(', ')}`);
|
||||||
|
|||||||
+3
-1
@@ -75,12 +75,14 @@ export function getRuntimeColor(minutes: number): (text: string) => string {
|
|||||||
/**
|
/**
|
||||||
* Format UPS power status with color
|
* 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) {
|
switch (status) {
|
||||||
case 'online':
|
case 'online':
|
||||||
return theme.success('Online');
|
return theme.success('Online');
|
||||||
case 'onBattery':
|
case 'onBattery':
|
||||||
return theme.warning('On Battery');
|
return theme.warning('On Battery');
|
||||||
|
case 'unreachable':
|
||||||
|
return theme.error('Unreachable');
|
||||||
case 'unknown':
|
case 'unknown':
|
||||||
default:
|
default:
|
||||||
return theme.dim('Unknown');
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -103,6 +103,65 @@ export const HTTP_SERVER = {
|
|||||||
DEFAULT_PATH: '/ups-status',
|
DEFAULT_PATH: '/ups-status',
|
||||||
} as const;
|
} 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
|
* UI/Display constants
|
||||||
*/
|
*/
|
||||||
|
|||||||
+284
-364
@@ -1,20 +1,39 @@
|
|||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
import * as fs from 'node:fs';
|
import * as fs from 'node:fs';
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
import { exec, execFile } from 'node:child_process';
|
|
||||||
import { promisify } from 'node:util';
|
|
||||||
import { NupstSnmp } from './snmp/manager.ts';
|
import { NupstSnmp } from './snmp/manager.ts';
|
||||||
import type { ISnmpConfig, IUpsStatus as ISnmpUpsStatus } from './snmp/types.ts';
|
import type { ISnmpConfig, IUpsStatus as ISnmpUpsStatus } from './snmp/types.ts';
|
||||||
|
import { NupstUpsd } from './upsd/client.ts';
|
||||||
|
import type { IUpsdConfig } from './upsd/types.ts';
|
||||||
|
import type { TProtocol } from './protocol/types.ts';
|
||||||
|
import { ProtocolResolver } from './protocol/resolver.ts';
|
||||||
import { logger } from './logger.ts';
|
import { logger } from './logger.ts';
|
||||||
import { MigrationRunner } from './migrations/index.ts';
|
import { MigrationRunner } from './migrations/index.ts';
|
||||||
import { formatPowerStatus, getBatteryColor, getRuntimeColor, theme } from './colors.ts';
|
import { formatPowerStatus, getBatteryColor, getRuntimeColor, theme } from './colors.ts';
|
||||||
import type { IActionConfig } from './actions/base-action.ts';
|
import type { IActionConfig } from './actions/base-action.ts';
|
||||||
import { ActionManager, type IActionContext, type TPowerStatus } from './actions/index.ts';
|
import { ActionManager } from './actions/index.ts';
|
||||||
|
import { decideUpsActionExecution, type TUpsTriggerReason } from './action-orchestration.ts';
|
||||||
import { NupstHttpServer } from './http-server.ts';
|
import { NupstHttpServer } from './http-server.ts';
|
||||||
import { THRESHOLDS, TIMING, UI } from './constants.ts';
|
import { NETWORK, PAUSE, THRESHOLDS, TIMING, UI } from './constants.ts';
|
||||||
|
import {
|
||||||
const execAsync = promisify(exec);
|
analyzeConfigReload,
|
||||||
const execFileAsync = promisify(execFile);
|
shouldRefreshPauseState,
|
||||||
|
shouldReloadConfig,
|
||||||
|
} from './config-watch.ts';
|
||||||
|
import { type IPauseState, loadPauseSnapshot } from './pause-state.ts';
|
||||||
|
import { ShutdownExecutor } from './shutdown-executor.ts';
|
||||||
|
import {
|
||||||
|
buildFailedUpsPollSnapshot,
|
||||||
|
buildSuccessfulUpsPollSnapshot,
|
||||||
|
ensureUpsStatus,
|
||||||
|
hasThresholdViolation,
|
||||||
|
} from './ups-monitoring.ts';
|
||||||
|
import {
|
||||||
|
buildShutdownErrorRow,
|
||||||
|
buildShutdownStatusRow,
|
||||||
|
selectEmergencyCandidate,
|
||||||
|
} from './shutdown-monitoring.ts';
|
||||||
|
import { createInitialUpsStatus, type IUpsStatus } from './ups-status.ts';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* UPS configuration interface
|
* UPS configuration interface
|
||||||
@@ -24,8 +43,12 @@ export interface IUpsConfig {
|
|||||||
id: string;
|
id: string;
|
||||||
/** Friendly name for the UPS */
|
/** Friendly name for the UPS */
|
||||||
name: string;
|
name: string;
|
||||||
/** SNMP configuration settings */
|
/** Communication protocol (defaults to 'snmp') */
|
||||||
snmp: ISnmpConfig;
|
protocol?: TProtocol;
|
||||||
|
/** SNMP configuration settings (required for 'snmp' protocol) */
|
||||||
|
snmp?: ISnmpConfig;
|
||||||
|
/** UPSD/NIS configuration settings (required for 'upsd' protocol) */
|
||||||
|
upsd?: IUpsdConfig;
|
||||||
/** Group IDs this UPS belongs to */
|
/** Group IDs this UPS belongs to */
|
||||||
groups: string[];
|
groups: string[];
|
||||||
/** Actions to trigger on power status changes and threshold violations */
|
/** Actions to trigger on power status changes and threshold violations */
|
||||||
@@ -91,23 +114,6 @@ export interface INupstConfig {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* UPS status tracking interface
|
|
||||||
*/
|
|
||||||
export interface IUpsStatus {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
powerStatus: 'online' | 'onBattery' | 'unknown';
|
|
||||||
batteryCapacity: number;
|
|
||||||
batteryRuntime: number;
|
|
||||||
outputLoad: number; // Load percentage (0-100%)
|
|
||||||
outputPower: number; // Power in watts
|
|
||||||
outputVoltage: number; // Voltage in volts
|
|
||||||
outputCurrent: number; // Current in amps
|
|
||||||
lastStatusChange: number;
|
|
||||||
lastCheckTime: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Daemon class for monitoring UPS and handling shutdown
|
* Daemon class for monitoring UPS and handling shutdown
|
||||||
* Responsible for loading/saving config and monitoring the UPS status
|
* Responsible for loading/saving config and monitoring the UPS status
|
||||||
@@ -118,7 +124,7 @@ export class NupstDaemon {
|
|||||||
|
|
||||||
/** Default configuration */
|
/** Default configuration */
|
||||||
private readonly DEFAULT_CONFIG: INupstConfig = {
|
private readonly DEFAULT_CONFIG: INupstConfig = {
|
||||||
version: '4.2',
|
version: '4.3',
|
||||||
upsDevices: [
|
upsDevices: [
|
||||||
{
|
{
|
||||||
id: 'default',
|
id: 'default',
|
||||||
@@ -138,6 +144,7 @@ export class NupstDaemon {
|
|||||||
privKey: '',
|
privKey: '',
|
||||||
// UPS model for OID selection
|
// UPS model for OID selection
|
||||||
upsModel: 'cyberpower',
|
upsModel: 'cyberpower',
|
||||||
|
runtimeUnit: 'ticks',
|
||||||
},
|
},
|
||||||
groups: [],
|
groups: [],
|
||||||
actions: [
|
actions: [
|
||||||
@@ -159,15 +166,23 @@ export class NupstDaemon {
|
|||||||
|
|
||||||
private config: INupstConfig;
|
private config: INupstConfig;
|
||||||
private snmp: NupstSnmp;
|
private snmp: NupstSnmp;
|
||||||
|
private upsd: NupstUpsd;
|
||||||
|
private protocolResolver: ProtocolResolver;
|
||||||
private isRunning: boolean = false;
|
private isRunning: boolean = false;
|
||||||
|
private isPaused: boolean = false;
|
||||||
|
private pauseState: IPauseState | null = null;
|
||||||
private upsStatus: Map<string, IUpsStatus> = new Map();
|
private upsStatus: Map<string, IUpsStatus> = new Map();
|
||||||
private httpServer?: NupstHttpServer;
|
private httpServer?: NupstHttpServer;
|
||||||
|
private readonly shutdownExecutor: ShutdownExecutor;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new daemon instance with the given SNMP manager
|
* Create a new daemon instance with the given protocol managers
|
||||||
*/
|
*/
|
||||||
constructor(snmp: NupstSnmp) {
|
constructor(snmp: NupstSnmp, upsd: NupstUpsd) {
|
||||||
this.snmp = snmp;
|
this.snmp = snmp;
|
||||||
|
this.upsd = upsd;
|
||||||
|
this.protocolResolver = new ProtocolResolver(snmp, upsd);
|
||||||
|
this.shutdownExecutor = new ShutdownExecutor();
|
||||||
this.config = this.DEFAULT_CONFIG;
|
this.config = this.DEFAULT_CONFIG;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,10 +245,11 @@ export class NupstDaemon {
|
|||||||
|
|
||||||
// Ensure version is always set and remove legacy fields before saving
|
// Ensure version is always set and remove legacy fields before saving
|
||||||
const configToSave: INupstConfig = {
|
const configToSave: INupstConfig = {
|
||||||
version: '4.1',
|
version: '4.3',
|
||||||
upsDevices: config.upsDevices,
|
upsDevices: config.upsDevices,
|
||||||
groups: config.groups,
|
groups: config.groups,
|
||||||
checkInterval: config.checkInterval,
|
checkInterval: config.checkInterval,
|
||||||
|
...(config.httpServer ? { httpServer: config.httpServer } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
fs.writeFileSync(this.CONFIG_PATH, JSON.stringify(configToSave, null, 2));
|
fs.writeFileSync(this.CONFIG_PATH, JSON.stringify(configToSave, null, 2));
|
||||||
@@ -251,7 +267,7 @@ export class NupstDaemon {
|
|||||||
private logConfigError(message: string): void {
|
private logConfigError(message: string): void {
|
||||||
logger.logBox(
|
logger.logBox(
|
||||||
'Configuration Error',
|
'Configuration Error',
|
||||||
[message, "Please run 'nupst setup' first to create a configuration."],
|
[message, "Please run 'nupst ups add' first to create a configuration."],
|
||||||
45,
|
45,
|
||||||
'error',
|
'error',
|
||||||
);
|
);
|
||||||
@@ -271,6 +287,13 @@ export class NupstDaemon {
|
|||||||
return this.snmp;
|
return this.snmp;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the UPSD instance
|
||||||
|
*/
|
||||||
|
public getNupstUpsd(): NupstUpsd {
|
||||||
|
return this.upsd;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start the monitoring daemon
|
* Start the monitoring daemon
|
||||||
*/
|
*/
|
||||||
@@ -300,7 +323,7 @@ export class NupstDaemon {
|
|||||||
logger.logBoxTitle('Update Available', boxWidth);
|
logger.logBoxTitle('Update Available', boxWidth);
|
||||||
logger.logBoxLine(`Current Version: ${updateStatus.currentVersion}`);
|
logger.logBoxLine(`Current Version: ${updateStatus.currentVersion}`);
|
||||||
logger.logBoxLine(`Latest Version: ${updateStatus.latestVersion}`);
|
logger.logBoxLine(`Latest Version: ${updateStatus.latestVersion}`);
|
||||||
logger.logBoxLine('Run "sudo nupst update" to update');
|
logger.logBoxLine('Run "sudo nupst upgrade" to upgrade');
|
||||||
logger.logBoxEnd();
|
logger.logBoxEnd();
|
||||||
}
|
}
|
||||||
}).catch(() => {}); // Ignore errors checking for updates
|
}).catch(() => {}); // Ignore errors checking for updates
|
||||||
@@ -317,6 +340,7 @@ export class NupstDaemon {
|
|||||||
this.config.httpServer.path,
|
this.config.httpServer.path,
|
||||||
this.config.httpServer.authToken,
|
this.config.httpServer.authToken,
|
||||||
() => this.upsStatus,
|
() => this.upsStatus,
|
||||||
|
() => this.pauseState,
|
||||||
);
|
);
|
||||||
this.httpServer.start();
|
this.httpServer.start();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -348,19 +372,7 @@ export class NupstDaemon {
|
|||||||
|
|
||||||
if (this.config.upsDevices && this.config.upsDevices.length > 0) {
|
if (this.config.upsDevices && this.config.upsDevices.length > 0) {
|
||||||
for (const ups of this.config.upsDevices) {
|
for (const ups of this.config.upsDevices) {
|
||||||
this.upsStatus.set(ups.id, {
|
this.upsStatus.set(ups.id, createInitialUpsStatus(ups));
|
||||||
id: ups.id,
|
|
||||||
name: ups.name,
|
|
||||||
powerStatus: 'unknown',
|
|
||||||
batteryCapacity: 100,
|
|
||||||
batteryRuntime: 999, // High value as default
|
|
||||||
outputLoad: 0,
|
|
||||||
outputPower: 0,
|
|
||||||
outputVoltage: 0,
|
|
||||||
outputCurrent: 0,
|
|
||||||
lastStatusChange: Date.now(),
|
|
||||||
lastCheckTime: 0,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.log(`Initialized status tracking for ${this.config.upsDevices.length} UPS devices`);
|
logger.log(`Initialized status tracking for ${this.config.upsDevices.length} UPS devices`);
|
||||||
@@ -388,16 +400,27 @@ export class NupstDaemon {
|
|||||||
> = [
|
> = [
|
||||||
{ header: 'Name', key: 'name', align: 'left', color: theme.highlight },
|
{ header: 'Name', key: 'name', align: 'left', color: theme.highlight },
|
||||||
{ header: 'ID', key: 'id', align: 'left', color: theme.dim },
|
{ header: 'ID', key: 'id', align: 'left', color: theme.dim },
|
||||||
|
{ header: 'Protocol', key: 'protocol', align: 'left' },
|
||||||
{ header: 'Host:Port', key: 'host', align: 'left', color: theme.info },
|
{ header: 'Host:Port', key: 'host', align: 'left', color: theme.info },
|
||||||
{ header: 'Actions', key: 'actions', align: 'left' },
|
{ header: 'Actions', key: 'actions', align: 'left' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const upsRows: Array<Record<string, string>> = this.config.upsDevices.map((ups) => ({
|
const upsRows: Array<Record<string, string>> = this.config.upsDevices.map((ups) => {
|
||||||
name: ups.name,
|
const protocol = ups.protocol || 'snmp';
|
||||||
id: ups.id,
|
let host = 'N/A';
|
||||||
host: `${ups.snmp.host}:${ups.snmp.port}`,
|
if (protocol === 'upsd' && ups.upsd) {
|
||||||
actions: `${(ups.actions || []).length} configured`,
|
host = `${ups.upsd.host}:${ups.upsd.port}`;
|
||||||
}));
|
} else if (ups.snmp) {
|
||||||
|
host = `${ups.snmp.host}:${ups.snmp.port}`;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name: ups.name,
|
||||||
|
id: ups.id,
|
||||||
|
protocol: protocol.toUpperCase(),
|
||||||
|
host,
|
||||||
|
actions: `${(ups.actions || []).length} configured`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
logger.logTable(upsColumns, upsRows);
|
logger.logTable(upsColumns, upsRows);
|
||||||
logger.log('');
|
logger.log('');
|
||||||
@@ -443,6 +466,52 @@ export class NupstDaemon {
|
|||||||
this.isRunning = false;
|
this.isRunning = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current pause state
|
||||||
|
*/
|
||||||
|
public getPauseState(): IPauseState | null {
|
||||||
|
return this.pauseState;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check and update pause state from the pause file
|
||||||
|
*/
|
||||||
|
private checkPauseState(): void {
|
||||||
|
const snapshot = loadPauseSnapshot(PAUSE.FILE_PATH, this.isPaused);
|
||||||
|
|
||||||
|
if (snapshot.transition === 'autoResumed') {
|
||||||
|
logger.log('');
|
||||||
|
logger.logBoxTitle('Auto-Resume', 45, 'success');
|
||||||
|
logger.logBoxLine('Pause duration expired, resuming action monitoring');
|
||||||
|
logger.logBoxEnd();
|
||||||
|
logger.log('');
|
||||||
|
} else if (snapshot.transition === 'paused' && snapshot.pauseState) {
|
||||||
|
logger.log('');
|
||||||
|
logger.logBoxTitle('Actions Paused', 45, 'warning');
|
||||||
|
logger.logBoxLine(`Paused by: ${snapshot.pauseState.pausedBy}`);
|
||||||
|
if (snapshot.pauseState.reason) {
|
||||||
|
logger.logBoxLine(`Reason: ${snapshot.pauseState.reason}`);
|
||||||
|
}
|
||||||
|
if (snapshot.pauseState.resumeAt) {
|
||||||
|
const remaining = Math.round((snapshot.pauseState.resumeAt - Date.now()) / 1000);
|
||||||
|
logger.logBoxLine(`Auto-resume in: ${remaining} seconds`);
|
||||||
|
} else {
|
||||||
|
logger.logBoxLine('Duration: Indefinite (run "nupst resume" to resume)');
|
||||||
|
}
|
||||||
|
logger.logBoxEnd();
|
||||||
|
logger.log('');
|
||||||
|
} else if (snapshot.transition === 'resumed') {
|
||||||
|
logger.log('');
|
||||||
|
logger.logBoxTitle('Actions Resumed', 45, 'success');
|
||||||
|
logger.logBoxLine('Action monitoring has been resumed');
|
||||||
|
logger.logBoxEnd();
|
||||||
|
logger.log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isPaused = snapshot.isPaused;
|
||||||
|
this.pauseState = snapshot.pauseState;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Monitor the UPS status and trigger shutdown when necessary
|
* Monitor the UPS status and trigger shutdown when necessary
|
||||||
*/
|
*/
|
||||||
@@ -461,7 +530,10 @@ export class NupstDaemon {
|
|||||||
// Monitor continuously
|
// Monitor continuously
|
||||||
while (this.isRunning) {
|
while (this.isRunning) {
|
||||||
try {
|
try {
|
||||||
// Check all UPS devices
|
// Check pause state before each cycle
|
||||||
|
this.checkPauseState();
|
||||||
|
|
||||||
|
// Check all UPS devices (polling continues even when paused for visibility)
|
||||||
await this.checkAllUpsDevices();
|
await this.checkAllUpsDevices();
|
||||||
|
|
||||||
// Log periodic status update
|
// Log periodic status update
|
||||||
@@ -490,94 +562,110 @@ export class NupstDaemon {
|
|||||||
private async checkAllUpsDevices(): Promise<void> {
|
private async checkAllUpsDevices(): Promise<void> {
|
||||||
for (const ups of this.config.upsDevices) {
|
for (const ups of this.config.upsDevices) {
|
||||||
try {
|
try {
|
||||||
const upsStatus = this.upsStatus.get(ups.id);
|
const initialStatus = ensureUpsStatus(this.upsStatus.get(ups.id), ups);
|
||||||
if (!upsStatus) {
|
this.upsStatus.set(ups.id, initialStatus);
|
||||||
// Initialize status for this UPS if not exists
|
|
||||||
this.upsStatus.set(ups.id, {
|
|
||||||
id: ups.id,
|
|
||||||
name: ups.name,
|
|
||||||
powerStatus: 'unknown',
|
|
||||||
batteryCapacity: 100,
|
|
||||||
batteryRuntime: 999,
|
|
||||||
outputLoad: 0,
|
|
||||||
outputPower: 0,
|
|
||||||
outputVoltage: 0,
|
|
||||||
outputCurrent: 0,
|
|
||||||
lastStatusChange: Date.now(),
|
|
||||||
lastCheckTime: 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check UPS status
|
// Check UPS status via configured protocol
|
||||||
const status = await this.snmp.getUpsStatus(ups.snmp);
|
const protocol = ups.protocol || 'snmp';
|
||||||
|
const status = protocol === 'upsd' && ups.upsd
|
||||||
|
? await this.protocolResolver.getUpsStatus('upsd', undefined, ups.upsd)
|
||||||
|
: await this.protocolResolver.getUpsStatus('snmp', ups.snmp);
|
||||||
const currentTime = Date.now();
|
const currentTime = Date.now();
|
||||||
|
|
||||||
// Get the current status from the map
|
|
||||||
const currentStatus = this.upsStatus.get(ups.id);
|
const currentStatus = this.upsStatus.get(ups.id);
|
||||||
|
const pollSnapshot = buildSuccessfulUpsPollSnapshot(
|
||||||
|
ups,
|
||||||
|
status,
|
||||||
|
currentStatus,
|
||||||
|
currentTime,
|
||||||
|
);
|
||||||
|
|
||||||
// Update status with new values
|
if (pollSnapshot.transition === 'recovered' && pollSnapshot.previousStatus) {
|
||||||
const updatedStatus: IUpsStatus = {
|
logger.log('');
|
||||||
id: ups.id,
|
logger.logBoxTitle(`UPS Recovered: ${ups.name}`, 60, 'success');
|
||||||
name: ups.name,
|
logger.logBoxLine(`UPS is reachable again after ${pollSnapshot.downtimeSeconds} seconds`);
|
||||||
powerStatus: status.powerStatus,
|
logger.logBoxLine(`Current Status: ${formatPowerStatus(status.powerStatus)}`);
|
||||||
batteryCapacity: status.batteryCapacity,
|
logger.logBoxLine(`Time: ${new Date().toISOString()}`);
|
||||||
batteryRuntime: status.batteryRuntime,
|
logger.logBoxEnd();
|
||||||
outputLoad: status.outputLoad,
|
logger.log('');
|
||||||
outputPower: status.outputPower,
|
|
||||||
outputVoltage: status.outputVoltage,
|
|
||||||
outputCurrent: status.outputCurrent,
|
|
||||||
lastCheckTime: currentTime,
|
|
||||||
lastStatusChange: currentStatus?.lastStatusChange || currentTime,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check if power status changed
|
// Trigger power status change action for recovery
|
||||||
if (currentStatus && currentStatus.powerStatus !== status.powerStatus) {
|
await this.triggerUpsActions(
|
||||||
|
ups,
|
||||||
|
pollSnapshot.updatedStatus,
|
||||||
|
pollSnapshot.previousStatus,
|
||||||
|
'powerStatusChange',
|
||||||
|
);
|
||||||
|
} else if (pollSnapshot.transition === 'powerStatusChange' && pollSnapshot.previousStatus) {
|
||||||
logger.log('');
|
logger.log('');
|
||||||
logger.logBoxTitle(`Power Status Change: ${ups.name}`, 60, 'warning');
|
logger.logBoxTitle(`Power Status Change: ${ups.name}`, 60, 'warning');
|
||||||
logger.logBoxLine(`Previous: ${formatPowerStatus(currentStatus.powerStatus)}`);
|
logger.logBoxLine(
|
||||||
|
`Previous: ${formatPowerStatus(pollSnapshot.previousStatus.powerStatus)}`,
|
||||||
|
);
|
||||||
logger.logBoxLine(`Current: ${formatPowerStatus(status.powerStatus)}`);
|
logger.logBoxLine(`Current: ${formatPowerStatus(status.powerStatus)}`);
|
||||||
logger.logBoxLine(`Time: ${new Date().toISOString()}`);
|
logger.logBoxLine(`Time: ${new Date().toISOString()}`);
|
||||||
logger.logBoxEnd();
|
logger.logBoxEnd();
|
||||||
logger.log('');
|
logger.log('');
|
||||||
|
|
||||||
updatedStatus.lastStatusChange = currentTime;
|
|
||||||
|
|
||||||
// Trigger actions for power status change
|
// Trigger actions for power status change
|
||||||
await this.triggerUpsActions(ups, updatedStatus, currentStatus, 'powerStatusChange');
|
await this.triggerUpsActions(
|
||||||
|
ups,
|
||||||
|
pollSnapshot.updatedStatus,
|
||||||
|
pollSnapshot.previousStatus,
|
||||||
|
'powerStatusChange',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if any action's thresholds are exceeded (for threshold violation triggers)
|
if (
|
||||||
// Only check when on battery power
|
hasThresholdViolation(
|
||||||
if (status.powerStatus === 'onBattery' && ups.actions && ups.actions.length > 0) {
|
status.powerStatus,
|
||||||
let anyThresholdExceeded = false;
|
status.batteryCapacity,
|
||||||
|
status.batteryRuntime,
|
||||||
for (const actionConfig of ups.actions) {
|
ups.actions,
|
||||||
if (actionConfig.thresholds) {
|
)
|
||||||
if (
|
) {
|
||||||
status.batteryCapacity < actionConfig.thresholds.battery ||
|
await this.triggerUpsActions(
|
||||||
status.batteryRuntime < actionConfig.thresholds.runtime
|
ups,
|
||||||
) {
|
pollSnapshot.updatedStatus,
|
||||||
anyThresholdExceeded = true;
|
pollSnapshot.previousStatus,
|
||||||
break;
|
'thresholdViolation',
|
||||||
}
|
);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Trigger actions with threshold violation reason if any threshold is exceeded
|
|
||||||
// Actions will individually check their own thresholds in shouldExecute()
|
|
||||||
if (anyThresholdExceeded) {
|
|
||||||
await this.triggerUpsActions(ups, updatedStatus, currentStatus, 'thresholdViolation');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the status in the map
|
// Update the status in the map
|
||||||
this.upsStatus.set(ups.id, updatedStatus);
|
this.upsStatus.set(ups.id, pollSnapshot.updatedStatus);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
const currentTime = Date.now();
|
||||||
|
const currentStatus = this.upsStatus.get(ups.id);
|
||||||
|
const failureSnapshot = buildFailedUpsPollSnapshot(ups, currentStatus, currentTime);
|
||||||
|
|
||||||
logger.error(
|
logger.error(
|
||||||
`Error checking UPS ${ups.name} (${ups.id}): ${
|
`Error checking UPS ${ups.name} (${ups.id}) [failure ${failureSnapshot.failures}/${NETWORK.CONSECUTIVE_FAILURE_THRESHOLD}]: ${
|
||||||
error instanceof Error ? error.message : String(error)
|
error instanceof Error ? error.message : String(error)
|
||||||
}`,
|
}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (failureSnapshot.transition === 'unreachable' && failureSnapshot.previousStatus) {
|
||||||
|
logger.log('');
|
||||||
|
logger.logBoxTitle(`UPS Unreachable: ${ups.name}`, 60, 'error');
|
||||||
|
logger.logBoxLine(`${failureSnapshot.failures} consecutive communication failures`);
|
||||||
|
logger.logBoxLine(
|
||||||
|
`Last known status: ${formatPowerStatus(failureSnapshot.previousStatus.powerStatus)}`,
|
||||||
|
);
|
||||||
|
logger.logBoxLine(`Time: ${new Date().toISOString()}`);
|
||||||
|
logger.logBoxEnd();
|
||||||
|
logger.log('');
|
||||||
|
|
||||||
|
// Trigger power status change action for unreachable
|
||||||
|
await this.triggerUpsActions(
|
||||||
|
ups,
|
||||||
|
failureSnapshot.updatedStatus,
|
||||||
|
failureSnapshot.previousStatus,
|
||||||
|
'powerStatusChange',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.upsStatus.set(ups.id, failureSnapshot.updatedStatus);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -589,8 +677,20 @@ export class NupstDaemon {
|
|||||||
const timestamp = new Date().toISOString();
|
const timestamp = new Date().toISOString();
|
||||||
|
|
||||||
logger.log('');
|
logger.log('');
|
||||||
logger.logBoxTitle('Periodic Status Update', 70, 'info');
|
const pauseLabel = this.isPaused ? ' [PAUSED]' : '';
|
||||||
|
logger.logBoxTitle(
|
||||||
|
`Periodic Status Update${pauseLabel}`,
|
||||||
|
70,
|
||||||
|
this.isPaused ? 'warning' : 'info',
|
||||||
|
);
|
||||||
logger.logBoxLine(`Timestamp: ${timestamp}`);
|
logger.logBoxLine(`Timestamp: ${timestamp}`);
|
||||||
|
if (this.isPaused && this.pauseState) {
|
||||||
|
logger.logBoxLine(`Actions paused by: ${this.pauseState.pausedBy}`);
|
||||||
|
if (this.pauseState.resumeAt) {
|
||||||
|
const remaining = Math.round((this.pauseState.resumeAt - Date.now()) / 1000);
|
||||||
|
logger.logBoxLine(`Auto-resume in: ${remaining > 0 ? remaining : 0} seconds`);
|
||||||
|
}
|
||||||
|
}
|
||||||
logger.logBoxEnd();
|
logger.logBoxEnd();
|
||||||
logger.log('');
|
logger.log('');
|
||||||
|
|
||||||
@@ -623,30 +723,6 @@ export class NupstDaemon {
|
|||||||
logger.log('');
|
logger.log('');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Build action context from UPS state
|
|
||||||
* @param ups UPS configuration
|
|
||||||
* @param status Current UPS status
|
|
||||||
* @param triggerReason Why this action is being triggered
|
|
||||||
* @returns Action context
|
|
||||||
*/
|
|
||||||
private buildActionContext(
|
|
||||||
ups: IUpsConfig,
|
|
||||||
status: IUpsStatus,
|
|
||||||
triggerReason: 'powerStatusChange' | 'thresholdViolation',
|
|
||||||
): IActionContext {
|
|
||||||
return {
|
|
||||||
upsId: ups.id,
|
|
||||||
upsName: ups.name,
|
|
||||||
powerStatus: status.powerStatus as TPowerStatus,
|
|
||||||
batteryCapacity: status.batteryCapacity,
|
|
||||||
batteryRuntime: status.batteryRuntime,
|
|
||||||
previousPowerStatus: 'unknown' as TPowerStatus, // Will be set from map in calling code
|
|
||||||
timestamp: Date.now(),
|
|
||||||
triggerReason,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Trigger actions for a UPS device
|
* Trigger actions for a UPS device
|
||||||
* @param ups UPS configuration
|
* @param ups UPS configuration
|
||||||
@@ -658,27 +734,31 @@ export class NupstDaemon {
|
|||||||
ups: IUpsConfig,
|
ups: IUpsConfig,
|
||||||
status: IUpsStatus,
|
status: IUpsStatus,
|
||||||
previousStatus: IUpsStatus | undefined,
|
previousStatus: IUpsStatus | undefined,
|
||||||
triggerReason: 'powerStatusChange' | 'thresholdViolation',
|
triggerReason: TUpsTriggerReason,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const actions = ups.actions || [];
|
const decision = decideUpsActionExecution(
|
||||||
|
this.isPaused,
|
||||||
|
ups,
|
||||||
|
status,
|
||||||
|
previousStatus,
|
||||||
|
triggerReason,
|
||||||
|
);
|
||||||
|
|
||||||
// Backward compatibility: if no actions configured, use default shutdown behavior
|
if (decision.type === 'suppressed') {
|
||||||
if (actions.length === 0 && triggerReason === 'thresholdViolation') {
|
logger.info(decision.message);
|
||||||
// Fall back to old shutdown logic for backward compatibility
|
|
||||||
await this.initiateShutdown(`UPS "${ups.name}" battery or runtime below threshold`);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (actions.length === 0) {
|
if (decision.type === 'legacyShutdown') {
|
||||||
return; // No actions to execute
|
await this.initiateShutdown(decision.reason);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build action context
|
if (decision.type === 'skip') {
|
||||||
const context = this.buildActionContext(ups, status, triggerReason);
|
return;
|
||||||
context.previousPowerStatus = (previousStatus?.powerStatus || 'unknown') as TPowerStatus;
|
}
|
||||||
|
|
||||||
// Execute actions
|
await ActionManager.executeActions(decision.actions, decision.context);
|
||||||
await ActionManager.executeActions(actions, context);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -692,56 +772,8 @@ export class NupstDaemon {
|
|||||||
const shutdownDelayMinutes = 5;
|
const shutdownDelayMinutes = 5;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Find shutdown command in common system paths
|
await this.shutdownExecutor.scheduleShutdown(shutdownDelayMinutes);
|
||||||
const shutdownPaths = [
|
logger.log(`Allowing ${shutdownDelayMinutes} minutes for VMs to shut down safely`);
|
||||||
'/sbin/shutdown',
|
|
||||||
'/usr/sbin/shutdown',
|
|
||||||
'/bin/shutdown',
|
|
||||||
'/usr/bin/shutdown',
|
|
||||||
];
|
|
||||||
|
|
||||||
let shutdownCmd = '';
|
|
||||||
for (const path of shutdownPaths) {
|
|
||||||
try {
|
|
||||||
if (fs.existsSync(path)) {
|
|
||||||
shutdownCmd = path;
|
|
||||||
logger.log(`Found shutdown command at: ${shutdownCmd}`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// Continue checking other paths
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shutdownCmd) {
|
|
||||||
// Execute shutdown command with delay to allow for VM graceful shutdown
|
|
||||||
logger.log(
|
|
||||||
`Executing: ${shutdownCmd} -h +${shutdownDelayMinutes} "UPS battery critical..."`,
|
|
||||||
);
|
|
||||||
const { stdout } = await execFileAsync(shutdownCmd, [
|
|
||||||
'-h',
|
|
||||||
`+${shutdownDelayMinutes}`,
|
|
||||||
`UPS battery critical, shutting down in ${shutdownDelayMinutes} minutes`,
|
|
||||||
]);
|
|
||||||
logger.log(`Shutdown initiated: ${stdout}`);
|
|
||||||
logger.log(`Allowing ${shutdownDelayMinutes} minutes for VMs to shut down safely`);
|
|
||||||
} else {
|
|
||||||
// Try using the PATH to find shutdown
|
|
||||||
try {
|
|
||||||
logger.log('Shutdown command not found in common paths, trying via PATH...');
|
|
||||||
const { stdout } = await execAsync(
|
|
||||||
`shutdown -h +${shutdownDelayMinutes} "UPS battery critical, shutting down in ${shutdownDelayMinutes} minutes"`,
|
|
||||||
{
|
|
||||||
env: process.env, // Pass the current environment
|
|
||||||
},
|
|
||||||
);
|
|
||||||
logger.log(`Shutdown initiated: ${stdout}`);
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error(
|
|
||||||
`Shutdown command not found: ${e instanceof Error ? e.message : String(e)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Monitor UPS during shutdown and force immediate shutdown if battery gets too low
|
// Monitor UPS during shutdown and force immediate shutdown if battery gets too low
|
||||||
logger.log('Monitoring UPS during shutdown process...');
|
logger.log('Monitoring UPS during shutdown process...');
|
||||||
@@ -749,51 +781,10 @@ export class NupstDaemon {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to initiate shutdown: ${error}`);
|
logger.error(`Failed to initiate shutdown: ${error}`);
|
||||||
|
|
||||||
// Try alternative shutdown methods
|
const shutdownTriggered = await this.shutdownExecutor.tryScheduledAlternatives();
|
||||||
const alternatives = [
|
if (!shutdownTriggered) {
|
||||||
{ cmd: 'poweroff', args: ['--force'] },
|
logger.error('All shutdown methods failed');
|
||||||
{ cmd: 'halt', args: ['-p'] },
|
|
||||||
{ cmd: 'systemctl', args: ['poweroff'] },
|
|
||||||
{ cmd: 'reboot', args: ['-p'] }, // Some systems allow reboot -p for power off
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const alt of alternatives) {
|
|
||||||
try {
|
|
||||||
// First check if command exists in common system paths
|
|
||||||
const paths = [
|
|
||||||
`/sbin/${alt.cmd}`,
|
|
||||||
`/usr/sbin/${alt.cmd}`,
|
|
||||||
`/bin/${alt.cmd}`,
|
|
||||||
`/usr/bin/${alt.cmd}`,
|
|
||||||
];
|
|
||||||
|
|
||||||
let cmdPath = '';
|
|
||||||
for (const path of paths) {
|
|
||||||
if (fs.existsSync(path)) {
|
|
||||||
cmdPath = path;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cmdPath) {
|
|
||||||
logger.log(`Trying alternative shutdown method: ${cmdPath} ${alt.args.join(' ')}`);
|
|
||||||
await execFileAsync(cmdPath, alt.args);
|
|
||||||
return; // Exit if successful
|
|
||||||
} else {
|
|
||||||
// Try using PATH environment
|
|
||||||
logger.log(`Trying alternative via PATH: ${alt.cmd} ${alt.args.join(' ')}`);
|
|
||||||
await execAsync(`${alt.cmd} ${alt.args.join(' ')}`, {
|
|
||||||
env: process.env, // Pass the current environment
|
|
||||||
});
|
|
||||||
return; // Exit if successful
|
|
||||||
}
|
|
||||||
} catch (altError) {
|
|
||||||
logger.error(`Alternative method ${alt.cmd} failed: ${altError}`);
|
|
||||||
// Continue to next method
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.error('All shutdown methods failed');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -830,38 +821,39 @@ export class NupstDaemon {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const rows: Array<Record<string, string>> = [];
|
const rows: Array<Record<string, string>> = [];
|
||||||
let emergencyDetected = false;
|
|
||||||
let emergencyUps: { ups: IUpsConfig; status: ISnmpUpsStatus } | null = null;
|
let emergencyUps: { ups: IUpsConfig; status: ISnmpUpsStatus } | null = null;
|
||||||
|
|
||||||
// Check all UPS devices
|
// Check all UPS devices
|
||||||
for (const ups of this.config.upsDevices) {
|
for (const ups of this.config.upsDevices) {
|
||||||
try {
|
try {
|
||||||
const status = await this.snmp.getUpsStatus(ups.snmp);
|
const protocol = ups.protocol || 'snmp';
|
||||||
|
const status = protocol === 'upsd' && ups.upsd
|
||||||
|
? await this.protocolResolver.getUpsStatus('upsd', undefined, ups.upsd)
|
||||||
|
: await this.protocolResolver.getUpsStatus('snmp', ups.snmp);
|
||||||
|
const rowSnapshot = buildShutdownStatusRow(
|
||||||
|
ups.name,
|
||||||
|
status,
|
||||||
|
THRESHOLDS.EMERGENCY_RUNTIME_MINUTES,
|
||||||
|
{
|
||||||
|
battery: (batteryCapacity) =>
|
||||||
|
getBatteryColor(batteryCapacity)(`${batteryCapacity}%`),
|
||||||
|
runtime: (batteryRuntime) =>
|
||||||
|
getRuntimeColor(batteryRuntime)(`${batteryRuntime} min`),
|
||||||
|
ok: theme.success,
|
||||||
|
critical: theme.error,
|
||||||
|
error: theme.error,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const batteryColor = getBatteryColor(status.batteryCapacity);
|
rows.push(rowSnapshot.row);
|
||||||
const runtimeColor = getRuntimeColor(status.batteryRuntime);
|
emergencyUps = selectEmergencyCandidate(
|
||||||
|
emergencyUps,
|
||||||
const isCritical = status.batteryRuntime < THRESHOLDS.EMERGENCY_RUNTIME_MINUTES;
|
ups,
|
||||||
|
status,
|
||||||
rows.push({
|
THRESHOLDS.EMERGENCY_RUNTIME_MINUTES,
|
||||||
name: ups.name,
|
);
|
||||||
battery: batteryColor(status.batteryCapacity + '%'),
|
|
||||||
runtime: runtimeColor(status.batteryRuntime + ' min'),
|
|
||||||
status: isCritical ? theme.error('CRITICAL!') : theme.success('OK'),
|
|
||||||
});
|
|
||||||
|
|
||||||
// If any UPS battery runtime gets critically low, flag for immediate shutdown
|
|
||||||
if (isCritical && !emergencyDetected) {
|
|
||||||
emergencyDetected = true;
|
|
||||||
emergencyUps = { ups, status };
|
|
||||||
}
|
|
||||||
} catch (upsError) {
|
} catch (upsError) {
|
||||||
rows.push({
|
rows.push(buildShutdownErrorRow(ups.name, theme.error));
|
||||||
name: ups.name,
|
|
||||||
battery: theme.error('N/A'),
|
|
||||||
runtime: theme.error('N/A'),
|
|
||||||
status: theme.error('ERROR'),
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.error(
|
logger.error(
|
||||||
`Error checking UPS ${ups.name} during shutdown: ${
|
`Error checking UPS ${ups.name} during shutdown: ${
|
||||||
@@ -876,7 +868,7 @@ export class NupstDaemon {
|
|||||||
logger.log('');
|
logger.log('');
|
||||||
|
|
||||||
// If emergency detected, trigger immediate shutdown
|
// If emergency detected, trigger immediate shutdown
|
||||||
if (emergencyDetected && emergencyUps) {
|
if (emergencyUps) {
|
||||||
logger.log('');
|
logger.log('');
|
||||||
logger.logBoxTitle('EMERGENCY SHUTDOWN', 60, 'error');
|
logger.logBoxTitle('EMERGENCY SHUTDOWN', 60, 'error');
|
||||||
logger.logBoxLine(
|
logger.logBoxLine(
|
||||||
@@ -914,86 +906,14 @@ export class NupstDaemon {
|
|||||||
*/
|
*/
|
||||||
private async forceImmediateShutdown(): Promise<void> {
|
private async forceImmediateShutdown(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Find shutdown command in common system paths
|
await this.shutdownExecutor.forceImmediateShutdown();
|
||||||
const shutdownPaths = [
|
|
||||||
'/sbin/shutdown',
|
|
||||||
'/usr/sbin/shutdown',
|
|
||||||
'/bin/shutdown',
|
|
||||||
'/usr/bin/shutdown',
|
|
||||||
];
|
|
||||||
|
|
||||||
let shutdownCmd = '';
|
|
||||||
for (const path of shutdownPaths) {
|
|
||||||
if (fs.existsSync(path)) {
|
|
||||||
shutdownCmd = path;
|
|
||||||
logger.log(`Found shutdown command at: ${shutdownCmd}`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shutdownCmd) {
|
|
||||||
logger.log(`Executing emergency shutdown: ${shutdownCmd} -h now`);
|
|
||||||
await execFileAsync(shutdownCmd, [
|
|
||||||
'-h',
|
|
||||||
'now',
|
|
||||||
'EMERGENCY: UPS battery critically low, shutting down NOW',
|
|
||||||
]);
|
|
||||||
} else {
|
|
||||||
// Try using the PATH to find shutdown
|
|
||||||
logger.log('Shutdown command not found in common paths, trying via PATH...');
|
|
||||||
await execAsync(
|
|
||||||
'shutdown -h now "EMERGENCY: UPS battery critically low, shutting down NOW"',
|
|
||||||
{
|
|
||||||
env: process.env, // Pass the current environment
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Emergency shutdown failed, trying alternative methods...');
|
logger.error('Emergency shutdown failed, trying alternative methods...');
|
||||||
|
|
||||||
// Try alternative shutdown methods in sequence
|
const shutdownTriggered = await this.shutdownExecutor.tryEmergencyAlternatives();
|
||||||
const alternatives = [
|
if (!shutdownTriggered) {
|
||||||
{ cmd: 'poweroff', args: ['--force'] },
|
logger.error('All emergency shutdown methods failed');
|
||||||
{ cmd: 'halt', args: ['-p'] },
|
|
||||||
{ cmd: 'systemctl', args: ['poweroff'] },
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const alt of alternatives) {
|
|
||||||
try {
|
|
||||||
// Check common paths
|
|
||||||
const paths = [
|
|
||||||
`/sbin/${alt.cmd}`,
|
|
||||||
`/usr/sbin/${alt.cmd}`,
|
|
||||||
`/bin/${alt.cmd}`,
|
|
||||||
`/usr/bin/${alt.cmd}`,
|
|
||||||
];
|
|
||||||
|
|
||||||
let cmdPath = '';
|
|
||||||
for (const path of paths) {
|
|
||||||
if (fs.existsSync(path)) {
|
|
||||||
cmdPath = path;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cmdPath) {
|
|
||||||
logger.log(`Emergency: using ${cmdPath} ${alt.args.join(' ')}`);
|
|
||||||
await execFileAsync(cmdPath, alt.args);
|
|
||||||
return; // Exit if successful
|
|
||||||
} else {
|
|
||||||
// Try using PATH
|
|
||||||
logger.log(`Emergency: trying ${alt.cmd} via PATH`);
|
|
||||||
await execAsync(`${alt.cmd} ${alt.args.join(' ')}`, {
|
|
||||||
env: process.env,
|
|
||||||
});
|
|
||||||
return; // Exit if successful
|
|
||||||
}
|
|
||||||
} catch (altError) {
|
|
||||||
// Continue to next method
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.error('All emergency shutdown methods failed');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1065,15 +985,17 @@ export class NupstDaemon {
|
|||||||
logger.log('Config file watcher started');
|
logger.log('Config file watcher started');
|
||||||
|
|
||||||
for await (const event of watcher) {
|
for await (const event of watcher) {
|
||||||
// Only respond to modify events on the config file
|
// Respond to modify events on config file
|
||||||
if (
|
if (shouldReloadConfig(event)) {
|
||||||
event.kind === 'modify' &&
|
|
||||||
event.paths.some((p) => p.includes('config.json'))
|
|
||||||
) {
|
|
||||||
logger.info('Config file changed, reloading...');
|
logger.info('Config file changed, reloading...');
|
||||||
await this.reloadConfig();
|
await this.reloadConfig();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Detect pause file changes
|
||||||
|
if (shouldRefreshPauseState(event)) {
|
||||||
|
this.checkPauseState();
|
||||||
|
}
|
||||||
|
|
||||||
// Stop watching if daemon stopped
|
// Stop watching if daemon stopped
|
||||||
if (!this.isRunning) {
|
if (!this.isRunning) {
|
||||||
break;
|
break;
|
||||||
@@ -1104,18 +1026,16 @@ export class NupstDaemon {
|
|||||||
await this.loadConfig();
|
await this.loadConfig();
|
||||||
const newDeviceCount = this.config.upsDevices?.length || 0;
|
const newDeviceCount = this.config.upsDevices?.length || 0;
|
||||||
|
|
||||||
if (newDeviceCount > 0 && oldDeviceCount === 0) {
|
const reloadSnapshot = analyzeConfigReload(oldDeviceCount, newDeviceCount);
|
||||||
logger.success(`Configuration reloaded! Found ${newDeviceCount} UPS device(s)`);
|
logger.success(reloadSnapshot.message);
|
||||||
logger.info('Monitoring will start automatically...');
|
|
||||||
} else if (newDeviceCount !== oldDeviceCount) {
|
|
||||||
logger.success(
|
|
||||||
`Configuration reloaded! UPS devices: ${oldDeviceCount} → ${newDeviceCount}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
|
if (reloadSnapshot.shouldLogMonitoringStart) {
|
||||||
|
logger.info('Monitoring will start automatically...');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reloadSnapshot.shouldInitializeUpsStatus) {
|
||||||
// Reinitialize UPS status tracking
|
// Reinitialize UPS status tracking
|
||||||
this.initializeUpsStatus();
|
this.initializeUpsStatus();
|
||||||
} else {
|
|
||||||
logger.success('Configuration reloaded successfully');
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
|
|||||||
+13
-2
@@ -1,7 +1,8 @@
|
|||||||
import * as http from 'node:http';
|
import * as http from 'node:http';
|
||||||
import { URL } from 'node:url';
|
import { URL } from 'node:url';
|
||||||
import { logger } from './logger.ts';
|
import { logger } from './logger.ts';
|
||||||
import type { IUpsStatus } from './daemon.ts';
|
import type { IPauseState } from './pause-state.ts';
|
||||||
|
import type { IUpsStatus } from './ups-status.ts';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP Server for exposing UPS status as JSON
|
* HTTP Server for exposing UPS status as JSON
|
||||||
@@ -13,6 +14,7 @@ export class NupstHttpServer {
|
|||||||
private path: string;
|
private path: string;
|
||||||
private authToken: string;
|
private authToken: string;
|
||||||
private getUpsStatus: () => Map<string, IUpsStatus>;
|
private getUpsStatus: () => Map<string, IUpsStatus>;
|
||||||
|
private getPauseState: () => IPauseState | null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new HTTP server instance
|
* Create a new HTTP server instance
|
||||||
@@ -20,17 +22,20 @@ export class NupstHttpServer {
|
|||||||
* @param path URL path for the endpoint
|
* @param path URL path for the endpoint
|
||||||
* @param authToken Authentication token required for access
|
* @param authToken Authentication token required for access
|
||||||
* @param getUpsStatus Function to retrieve cached UPS status
|
* @param getUpsStatus Function to retrieve cached UPS status
|
||||||
|
* @param getPauseState Function to retrieve current pause state
|
||||||
*/
|
*/
|
||||||
constructor(
|
constructor(
|
||||||
port: number,
|
port: number,
|
||||||
path: string,
|
path: string,
|
||||||
authToken: string,
|
authToken: string,
|
||||||
getUpsStatus: () => Map<string, IUpsStatus>,
|
getUpsStatus: () => Map<string, IUpsStatus>,
|
||||||
|
getPauseState: () => IPauseState | null,
|
||||||
) {
|
) {
|
||||||
this.port = port;
|
this.port = port;
|
||||||
this.path = path;
|
this.path = path;
|
||||||
this.authToken = authToken;
|
this.authToken = authToken;
|
||||||
this.getUpsStatus = getUpsStatus;
|
this.getUpsStatus = getUpsStatus;
|
||||||
|
this.getPauseState = getPauseState;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -79,12 +84,18 @@ export class NupstHttpServer {
|
|||||||
// Get cached status (no refresh)
|
// Get cached status (no refresh)
|
||||||
const statusMap = this.getUpsStatus();
|
const statusMap = this.getUpsStatus();
|
||||||
const statusArray = Array.from(statusMap.values());
|
const statusArray = Array.from(statusMap.values());
|
||||||
|
const pauseState = this.getPauseState();
|
||||||
|
|
||||||
|
const response = {
|
||||||
|
upsDevices: statusArray,
|
||||||
|
...(pauseState ? { paused: true, pauseState } : { paused: false }),
|
||||||
|
};
|
||||||
|
|
||||||
res.writeHead(200, {
|
res.writeHead(200, {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Cache-Control': 'no-cache',
|
'Cache-Control': 'no-cache',
|
||||||
});
|
});
|
||||||
res.end(JSON.stringify(statusArray, null, 2));
|
res.end(JSON.stringify(response, null, 2));
|
||||||
} else {
|
} else {
|
||||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||||
res.end(JSON.stringify({ error: 'Not Found' }));
|
res.end(JSON.stringify({ error: 'Not Found' }));
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@ import process from 'node:process';
|
|||||||
*/
|
*/
|
||||||
async function main() {
|
async function main() {
|
||||||
const cli = new NupstCli();
|
const cli = new NupstCli();
|
||||||
await cli.parseAndExecute(process.argv);
|
await cli.parseAndExecute(process.argv.slice(2));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run the main function and handle any errors
|
// Run the main function and handle any errors
|
||||||
|
|||||||
@@ -9,3 +9,5 @@ export { MigrationRunner } from './migration-runner.ts';
|
|||||||
export { MigrationV1ToV2 } from './migration-v1-to-v2.ts';
|
export { MigrationV1ToV2 } from './migration-v1-to-v2.ts';
|
||||||
export { MigrationV3ToV4 } from './migration-v3-to-v4.ts';
|
export { MigrationV3ToV4 } from './migration-v3-to-v4.ts';
|
||||||
export { MigrationV4_0ToV4_1 } from './migration-v4.0-to-v4.1.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';
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { BaseMigration } from './base-migration.ts';
|
|||||||
import { MigrationV1ToV2 } from './migration-v1-to-v2.ts';
|
import { MigrationV1ToV2 } from './migration-v1-to-v2.ts';
|
||||||
import { MigrationV3ToV4 } from './migration-v3-to-v4.ts';
|
import { MigrationV3ToV4 } from './migration-v3-to-v4.ts';
|
||||||
import { MigrationV4_0ToV4_1 } from './migration-v4.0-to-v4.1.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 { logger } from '../logger.ts';
|
import { logger } from '../logger.ts';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -19,7 +21,8 @@ export class MigrationRunner {
|
|||||||
new MigrationV1ToV2(),
|
new MigrationV1ToV2(),
|
||||||
new MigrationV3ToV4(),
|
new MigrationV3ToV4(),
|
||||||
new MigrationV4_0ToV4_1(),
|
new MigrationV4_0ToV4_1(),
|
||||||
// Add future migrations here (v4.3, v4.4, etc.)
|
new MigrationV4_1ToV4_2(),
|
||||||
|
new MigrationV4_2ToV4_3(),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Sort by version order to ensure they run in sequence
|
// Sort by version order to ensure they run in sequence
|
||||||
@@ -55,7 +58,7 @@ export class MigrationRunner {
|
|||||||
if (anyMigrationsRan) {
|
if (anyMigrationsRan) {
|
||||||
logger.success('Configuration migrations complete');
|
logger.success('Configuration migrations complete');
|
||||||
} else {
|
} else {
|
||||||
logger.success('config format ok');
|
logger.success('Configuration format OK');
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -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,50 @@
|
|||||||
|
import { BaseMigration } from './base-migration.ts';
|
||||||
|
import { logger } from '../logger.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 string | undefined;
|
||||||
|
if (model === 'cyberpower') {
|
||||||
|
snmp.runtimeUnit = 'ticks';
|
||||||
|
} else if (model === 'eaton') {
|
||||||
|
snmp.runtimeUnit = 'seconds';
|
||||||
|
} else {
|
||||||
|
snmp.runtimeUnit = 'minutes';
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
-3
@@ -1,4 +1,5 @@
|
|||||||
import { NupstSnmp } from './snmp/manager.ts';
|
import { NupstSnmp } from './snmp/manager.ts';
|
||||||
|
import { NupstUpsd } from './upsd/client.ts';
|
||||||
import { NupstDaemon } from './daemon.ts';
|
import { NupstDaemon } from './daemon.ts';
|
||||||
import { NupstSystemd } from './systemd.ts';
|
import { NupstSystemd } from './systemd.ts';
|
||||||
import denoConfig from '../deno.json' with { type: 'json' };
|
import denoConfig from '../deno.json' with { type: 'json' };
|
||||||
@@ -17,6 +18,7 @@ import type { INupstAccessor, IUpdateStatus } from './interfaces/index.ts';
|
|||||||
*/
|
*/
|
||||||
export class Nupst implements INupstAccessor {
|
export class Nupst implements INupstAccessor {
|
||||||
private readonly snmp: NupstSnmp;
|
private readonly snmp: NupstSnmp;
|
||||||
|
private readonly upsd: NupstUpsd;
|
||||||
private readonly daemon: NupstDaemon;
|
private readonly daemon: NupstDaemon;
|
||||||
private readonly systemd: NupstSystemd;
|
private readonly systemd: NupstSystemd;
|
||||||
private readonly upsHandler: UpsHandler;
|
private readonly upsHandler: UpsHandler;
|
||||||
@@ -34,7 +36,8 @@ export class Nupst implements INupstAccessor {
|
|||||||
// Initialize core components
|
// Initialize core components
|
||||||
this.snmp = new NupstSnmp();
|
this.snmp = new NupstSnmp();
|
||||||
this.snmp.setNupst(this); // Set up bidirectional reference
|
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);
|
this.systemd = new NupstSystemd(this.daemon);
|
||||||
|
|
||||||
// Initialize handlers
|
// Initialize handlers
|
||||||
@@ -52,6 +55,13 @@ export class Nupst implements INupstAccessor {
|
|||||||
return this.snmp;
|
return this.snmp;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the UPSD manager for NUT protocol communication
|
||||||
|
*/
|
||||||
|
public getUpsd(): NupstUpsd {
|
||||||
|
return this.upsd;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the daemon manager for background monitoring
|
* Get the daemon manager for background monitoring
|
||||||
*/
|
*/
|
||||||
@@ -225,7 +235,7 @@ export class Nupst implements INupstAccessor {
|
|||||||
|
|
||||||
if (this.updateAvailable && this.latestVersion) {
|
if (this.updateAvailable && this.latestVersion) {
|
||||||
logger.logBoxLine(`Update Available: ${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();
|
logger.logBoxEnd();
|
||||||
} else if (checkForUpdates) {
|
} else if (checkForUpdates) {
|
||||||
logger.logBoxLine('Checking for updates...');
|
logger.logBoxLine('Checking for updates...');
|
||||||
@@ -234,7 +244,7 @@ export class Nupst implements INupstAccessor {
|
|||||||
this.checkForUpdates().then((updateAvailable) => {
|
this.checkForUpdates().then((updateAvailable) => {
|
||||||
if (updateAvailable) {
|
if (updateAvailable) {
|
||||||
logger.logBoxLine(`Update Available: ${this.latestVersion}`);
|
logger.logBoxLine(`Update Available: ${this.latestVersion}`);
|
||||||
logger.logBoxLine('Run "sudo nupst update" to update');
|
logger.logBoxLine('Run "sudo nupst upgrade" to upgrade');
|
||||||
} else {
|
} else {
|
||||||
logger.logBoxLine('You are running the latest version');
|
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 };
|
||||||
|
}
|
||||||
+277
-181
@@ -1,4 +1,4 @@
|
|||||||
import * as snmp from 'npm:net-snmp@3.26.0';
|
import * as snmp from 'npm:net-snmp@3.26.1';
|
||||||
import { Buffer } from 'node:buffer';
|
import { Buffer } from 'node:buffer';
|
||||||
import type { IOidSet, ISnmpConfig, IUpsStatus, TUpsModel } from './types.ts';
|
import type { IOidSet, ISnmpConfig, IUpsStatus, TUpsModel } from './types.ts';
|
||||||
import { UpsOidSets } from './oid-sets.ts';
|
import { UpsOidSets } from './oid-sets.ts';
|
||||||
@@ -6,6 +6,73 @@ import { SNMP } from '../constants.ts';
|
|||||||
import { logger } from '../logger.ts';
|
import { logger } from '../logger.ts';
|
||||||
import type { INupstAccessor } from '../interfaces/index.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
|
* Class for SNMP communication with UPS devices
|
||||||
* Main entry point for SNMP functionality
|
* Main entry point for SNMP functionality
|
||||||
@@ -84,6 +151,120 @@ export class NupstSnmp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
* Send an SNMP GET request using the net-snmp package
|
||||||
* @param oid OID to query
|
* @param oid OID to query
|
||||||
@@ -95,130 +276,39 @@ export class NupstSnmp {
|
|||||||
oid: string,
|
oid: string,
|
||||||
config = this.DEFAULT_CONFIG,
|
config = this.DEFAULT_CONFIG,
|
||||||
_retryCount = 0,
|
_retryCount = 0,
|
||||||
// deno-lint-ignore no-explicit-any
|
): Promise<TSnmpValue> {
|
||||||
): Promise<any> {
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (this.debug) {
|
if (this.debug) {
|
||||||
logger.dim(
|
logger.dim(
|
||||||
`Sending SNMP v${config.version} GET request for OID ${oid} to ${config.host}:${config.port}`,
|
`Sending SNMP v${config.version} GET request for OID ${oid} to ${config.host}:${config.port}`,
|
||||||
);
|
);
|
||||||
logger.dim(`Using community: ${config.community}`);
|
if (config.version === 1 || config.version === 2) {
|
||||||
}
|
logger.dim(`Using community: ${config.community}`);
|
||||||
|
|
||||||
// Create SNMP options based on configuration
|
|
||||||
// deno-lint-ignore no-explicit-any
|
|
||||||
const options: any = {
|
|
||||||
port: config.port,
|
|
||||||
retries: SNMP.RETRIES, // 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
|
|
||||||
// deno-lint-ignore no-explicit-any
|
|
||||||
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) {
|
|
||||||
logger.warn('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) {
|
|
||||||
logger.warn('Missing privProtocol or privKey, falling back to authNoPriv');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Fallback to noAuthNoPriv if auth details missing
|
|
||||||
user.level = snmp.SecurityLevel.noAuthNoPriv;
|
|
||||||
if (this.debug) {
|
|
||||||
logger.warn('Missing authProtocol or authKey, falling back to noAuthNoPriv');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.debug) {
|
|
||||||
const levelName = Object.keys(snmp.SecurityLevel).find((key) =>
|
|
||||||
snmp.SecurityLevel[key] === user.level
|
|
||||||
);
|
|
||||||
logger.dim(
|
|
||||||
`SNMPv3 user configuration: name=${user.name}, level=${levelName}, authProtocol=${
|
|
||||||
user.authProtocol ? 'Set' : 'Not Set'
|
|
||||||
}, privProtocol=${user.privProtocol ? '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
|
// Convert the OID string to an array of OIDs if multiple OIDs are needed
|
||||||
const oids = [oid];
|
const oids = [oid];
|
||||||
|
|
||||||
// Send the GET request
|
// Send the GET request
|
||||||
// deno-lint-ignore no-explicit-any
|
session.get(oids, (error: Error | null, varbinds?: ISnmpVarbind[]) => {
|
||||||
session.get(oids, (error: Error | null, varbinds: any[]) => {
|
|
||||||
// Close the session to release resources
|
// Close the session to release resources
|
||||||
session.close();
|
session.close();
|
||||||
|
|
||||||
@@ -230,7 +320,9 @@ export class NupstSnmp {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!varbinds || varbinds.length === 0) {
|
const varbind = varbinds?.[0];
|
||||||
|
|
||||||
|
if (!varbind) {
|
||||||
if (this.debug) {
|
if (this.debug) {
|
||||||
logger.error('No varbinds returned in response');
|
logger.error('No varbinds returned in response');
|
||||||
}
|
}
|
||||||
@@ -239,36 +331,20 @@ export class NupstSnmp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check for SNMP errors in the response
|
// Check for SNMP errors in the response
|
||||||
if (
|
if (snmpLib.isVarbindError(varbind)) {
|
||||||
varbinds[0].type === snmp.ObjectType.NoSuchObject ||
|
const errorMessage = snmpLib.varbindError(varbind);
|
||||||
varbinds[0].type === snmp.ObjectType.NoSuchInstance ||
|
|
||||||
varbinds[0].type === snmp.ObjectType.EndOfMibView
|
|
||||||
) {
|
|
||||||
if (this.debug) {
|
if (this.debug) {
|
||||||
logger.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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process the response value based on its type
|
const value = this.normalizeSnmpValue(varbind.value);
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.debug) {
|
if (this.debug) {
|
||||||
logger.dim(
|
logger.dim(
|
||||||
`SNMP response: oid=${varbinds[0].oid}, type=${varbinds[0].type}, value=${value}`,
|
`SNMP response: oid=${varbind.oid}, type=${varbind.type}, value=${value}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,49 +391,50 @@ export class NupstSnmp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get all values with independent retry logic
|
// Get all values with independent retry logic
|
||||||
const powerStatusValue = await this.getSNMPValueWithRetry(
|
const powerStatusValue = this.coerceNumericSnmpValue(
|
||||||
this.activeOIDs.POWER_STATUS,
|
await this.getSNMPValueWithRetry(this.activeOIDs.POWER_STATUS, 'power status', config),
|
||||||
'power status',
|
'power status',
|
||||||
config,
|
|
||||||
);
|
);
|
||||||
const batteryCapacity = await this.getSNMPValueWithRetry(
|
const batteryCapacity = this.coerceNumericSnmpValue(
|
||||||
this.activeOIDs.BATTERY_CAPACITY,
|
await this.getSNMPValueWithRetry(
|
||||||
|
this.activeOIDs.BATTERY_CAPACITY,
|
||||||
|
'battery capacity',
|
||||||
|
config,
|
||||||
|
),
|
||||||
'battery capacity',
|
'battery capacity',
|
||||||
config,
|
);
|
||||||
) || 0;
|
const batteryRuntime = this.coerceNumericSnmpValue(
|
||||||
const batteryRuntime = await this.getSNMPValueWithRetry(
|
await this.getSNMPValueWithRetry(
|
||||||
this.activeOIDs.BATTERY_RUNTIME,
|
this.activeOIDs.BATTERY_RUNTIME,
|
||||||
|
'battery runtime',
|
||||||
|
config,
|
||||||
|
),
|
||||||
'battery runtime',
|
'battery runtime',
|
||||||
config,
|
);
|
||||||
) || 0;
|
|
||||||
|
|
||||||
// Get power draw metrics
|
// Get power draw metrics
|
||||||
const outputLoad = await this.getSNMPValueWithRetry(
|
const outputLoad = this.coerceNumericSnmpValue(
|
||||||
this.activeOIDs.OUTPUT_LOAD,
|
await this.getSNMPValueWithRetry(this.activeOIDs.OUTPUT_LOAD, 'output load', config),
|
||||||
'output load',
|
'output load',
|
||||||
config,
|
);
|
||||||
) || 0;
|
const outputPower = this.coerceNumericSnmpValue(
|
||||||
const outputPower = await this.getSNMPValueWithRetry(
|
await this.getSNMPValueWithRetry(this.activeOIDs.OUTPUT_POWER, 'output power', config),
|
||||||
this.activeOIDs.OUTPUT_POWER,
|
|
||||||
'output power',
|
'output power',
|
||||||
config,
|
);
|
||||||
) || 0;
|
const outputVoltage = this.coerceNumericSnmpValue(
|
||||||
const outputVoltage = await this.getSNMPValueWithRetry(
|
await this.getSNMPValueWithRetry(this.activeOIDs.OUTPUT_VOLTAGE, 'output voltage', config),
|
||||||
this.activeOIDs.OUTPUT_VOLTAGE,
|
|
||||||
'output voltage',
|
'output voltage',
|
||||||
config,
|
);
|
||||||
) || 0;
|
const outputCurrent = this.coerceNumericSnmpValue(
|
||||||
const outputCurrent = await this.getSNMPValueWithRetry(
|
await this.getSNMPValueWithRetry(this.activeOIDs.OUTPUT_CURRENT, 'output current', config),
|
||||||
this.activeOIDs.OUTPUT_CURRENT,
|
|
||||||
'output current',
|
'output current',
|
||||||
config,
|
);
|
||||||
) || 0;
|
|
||||||
|
|
||||||
// Determine power status - handle different values for different UPS models
|
// Determine power status - handle different values for different UPS models
|
||||||
const powerStatus = this.determinePowerStatus(config.upsModel, powerStatusValue);
|
const powerStatus = this.determinePowerStatus(config.upsModel, powerStatusValue);
|
||||||
|
|
||||||
// Convert to minutes for UPS models with different time units
|
// Convert to minutes for UPS models with different time units
|
||||||
const processedRuntime = this.processRuntimeValue(config.upsModel, batteryRuntime);
|
const processedRuntime = this.processRuntimeValue(config, batteryRuntime);
|
||||||
|
|
||||||
// Process power metrics with vendor-specific scaling
|
// Process power metrics with vendor-specific scaling
|
||||||
const processedVoltage = this.processVoltageValue(config.upsModel, outputVoltage);
|
const processedVoltage = this.processVoltageValue(config.upsModel, outputVoltage);
|
||||||
@@ -430,10 +507,9 @@ export class NupstSnmp {
|
|||||||
*/
|
*/
|
||||||
private async getSNMPValueWithRetry(
|
private async getSNMPValueWithRetry(
|
||||||
oid: string,
|
oid: string,
|
||||||
description: string,
|
description: TSnmpMetricDescription,
|
||||||
config: ISnmpConfig,
|
config: ISnmpConfig,
|
||||||
// deno-lint-ignore no-explicit-any
|
): Promise<TSnmpValue | 0> {
|
||||||
): Promise<any> {
|
|
||||||
if (oid === '') {
|
if (oid === '') {
|
||||||
if (this.debug) {
|
if (this.debug) {
|
||||||
logger.dim(`No OID provided for ${description}, skipping`);
|
logger.dim(`No OID provided for ${description}, skipping`);
|
||||||
@@ -485,10 +561,9 @@ export class NupstSnmp {
|
|||||||
*/
|
*/
|
||||||
private async tryFallbackSecurityLevels(
|
private async tryFallbackSecurityLevels(
|
||||||
oid: string,
|
oid: string,
|
||||||
description: string,
|
description: TSnmpMetricDescription,
|
||||||
config: ISnmpConfig,
|
config: ISnmpConfig,
|
||||||
// deno-lint-ignore no-explicit-any
|
): Promise<TSnmpValue | 0> {
|
||||||
): Promise<any> {
|
|
||||||
if (this.debug) {
|
if (this.debug) {
|
||||||
logger.dim(`Retrying ${description} with fallback security level...`);
|
logger.dim(`Retrying ${description} with fallback security level...`);
|
||||||
}
|
}
|
||||||
@@ -551,10 +626,9 @@ export class NupstSnmp {
|
|||||||
*/
|
*/
|
||||||
private async tryStandardOids(
|
private async tryStandardOids(
|
||||||
_oid: string,
|
_oid: string,
|
||||||
description: string,
|
description: TSnmpMetricDescription,
|
||||||
config: ISnmpConfig,
|
config: ISnmpConfig,
|
||||||
// deno-lint-ignore no-explicit-any
|
): Promise<TSnmpValue | 0> {
|
||||||
): Promise<any> {
|
|
||||||
try {
|
try {
|
||||||
// Try RFC 1628 standard UPS MIB OIDs
|
// Try RFC 1628 standard UPS MIB OIDs
|
||||||
const standardOIDs = UpsOidSets.getStandardOids();
|
const standardOIDs = UpsOidSets.getStandardOids();
|
||||||
@@ -620,22 +694,46 @@ export class NupstSnmp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Process runtime value based on UPS model
|
* Process runtime value based on config runtimeUnit or UPS model
|
||||||
* @param upsModel UPS model
|
* @param config SNMP configuration (uses runtimeUnit if set, otherwise falls back to upsModel)
|
||||||
* @param batteryRuntime Raw battery runtime value
|
* @param batteryRuntime Raw battery runtime value
|
||||||
* @returns Processed runtime in minutes
|
* @returns Processed runtime in minutes
|
||||||
*/
|
*/
|
||||||
private processRuntimeValue(
|
private processRuntimeValue(
|
||||||
upsModel: TUpsModel | undefined,
|
config: ISnmpConfig,
|
||||||
batteryRuntime: number,
|
batteryRuntime: number,
|
||||||
): number {
|
): number {
|
||||||
if (this.debug) {
|
if (this.debug) {
|
||||||
logger.dim(`Raw runtime value: ${batteryRuntime}`);
|
logger.dim(`Raw runtime value: ${batteryRuntime}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Explicit runtimeUnit takes precedence over model-based detection
|
||||||
|
if (config.runtimeUnit) {
|
||||||
|
if (config.runtimeUnit === 'seconds' && batteryRuntime > 0) {
|
||||||
|
const minutes = Math.floor(batteryRuntime / 60);
|
||||||
|
if (this.debug) {
|
||||||
|
logger.dim(
|
||||||
|
`Converting runtime from ${batteryRuntime} seconds to ${minutes} minutes (runtimeUnit: seconds)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return minutes;
|
||||||
|
} else if (config.runtimeUnit === 'ticks' && batteryRuntime > 0) {
|
||||||
|
const minutes = Math.floor(batteryRuntime / 6000);
|
||||||
|
if (this.debug) {
|
||||||
|
logger.dim(
|
||||||
|
`Converting runtime from ${batteryRuntime} ticks to ${minutes} minutes (runtimeUnit: ticks)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return minutes;
|
||||||
|
}
|
||||||
|
// runtimeUnit === 'minutes' — return as-is
|
||||||
|
return batteryRuntime;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: model-based detection (for configs without runtimeUnit)
|
||||||
|
const upsModel = config.upsModel;
|
||||||
if (upsModel === 'cyberpower' && batteryRuntime > 0) {
|
if (upsModel === 'cyberpower' && batteryRuntime > 0) {
|
||||||
// CyberPower: TimeTicks is in 1/100 seconds, convert to minutes
|
const minutes = Math.floor(batteryRuntime / 6000);
|
||||||
const minutes = Math.floor(batteryRuntime / 6000); // 6000 ticks = 1 minute
|
|
||||||
if (this.debug) {
|
if (this.debug) {
|
||||||
logger.dim(
|
logger.dim(
|
||||||
`Converting CyberPower runtime from ${batteryRuntime} ticks to ${minutes} minutes`,
|
`Converting CyberPower runtime from ${batteryRuntime} ticks to ${minutes} minutes`,
|
||||||
@@ -643,7 +741,6 @@ export class NupstSnmp {
|
|||||||
}
|
}
|
||||||
return minutes;
|
return minutes;
|
||||||
} else if (upsModel === 'eaton' && batteryRuntime > 0) {
|
} else if (upsModel === 'eaton' && batteryRuntime > 0) {
|
||||||
// Eaton: Runtime is in seconds, convert to minutes
|
|
||||||
const minutes = Math.floor(batteryRuntime / 60);
|
const minutes = Math.floor(batteryRuntime / 60);
|
||||||
if (this.debug) {
|
if (this.debug) {
|
||||||
logger.dim(
|
logger.dim(
|
||||||
@@ -652,10 +749,9 @@ export class NupstSnmp {
|
|||||||
}
|
}
|
||||||
return minutes;
|
return minutes;
|
||||||
} else if (batteryRuntime > 10000) {
|
} else if (batteryRuntime > 10000) {
|
||||||
// Generic conversion for large tick values (likely TimeTicks)
|
|
||||||
const minutes = Math.floor(batteryRuntime / 6000);
|
const minutes = Math.floor(batteryRuntime / 6000);
|
||||||
if (this.debug) {
|
if (this.debug) {
|
||||||
logger.dim(`Converting ${batteryRuntime} ticks to ${minutes} minutes`);
|
logger.dim(`Converting ${batteryRuntime} ticks to ${minutes} minutes (heuristic)`);
|
||||||
}
|
}
|
||||||
return minutes;
|
return minutes;
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-1
@@ -9,7 +9,7 @@ import { Buffer } from 'node:buffer';
|
|||||||
*/
|
*/
|
||||||
export interface IUpsStatus {
|
export interface IUpsStatus {
|
||||||
/** Current power status */
|
/** Current power status */
|
||||||
powerStatus: 'online' | 'onBattery' | 'unknown';
|
powerStatus: 'online' | 'onBattery' | 'unknown' | 'unreachable';
|
||||||
/** Battery capacity percentage */
|
/** Battery capacity percentage */
|
||||||
batteryCapacity: number;
|
batteryCapacity: number;
|
||||||
/** Remaining runtime in minutes */
|
/** Remaining runtime in minutes */
|
||||||
@@ -58,6 +58,11 @@ export interface IOidSet {
|
|||||||
*/
|
*/
|
||||||
export type TUpsModel = 'cyberpower' | 'apc' | 'eaton' | 'tripplite' | 'liebert' | 'custom';
|
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
|
* SNMP Configuration interface
|
||||||
*/
|
*/
|
||||||
@@ -96,6 +101,8 @@ export interface ISnmpConfig {
|
|||||||
upsModel?: TUpsModel;
|
upsModel?: TUpsModel;
|
||||||
/** Custom OIDs when using custom UPS model */
|
/** Custom OIDs when using custom UPS model */
|
||||||
customOIDs?: IOidSet;
|
customOIDs?: IOidSet;
|
||||||
|
/** Unit of the battery runtime SNMP value. Overrides model-based auto-detection when set. */
|
||||||
|
runtimeUnit?: TRuntimeUnit;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+30
-9
@@ -164,7 +164,7 @@ WantedBy=multi-user.target
|
|||||||
}`,
|
}`,
|
||||||
);
|
);
|
||||||
logger.log(
|
logger.log(
|
||||||
` ${theme.dim('Run')} ${theme.command('sudo nupst update')} ${theme.dim('to upgrade')}`,
|
` ${theme.dim('Run')} ${theme.command('sudo nupst upgrade')} ${theme.dim('to upgrade')}`,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
logger.log('');
|
logger.log('');
|
||||||
@@ -346,13 +346,24 @@ WantedBy=multi-user.target
|
|||||||
*/
|
*/
|
||||||
private async displaySingleUpsStatus(ups: IUpsConfig, snmp: NupstSnmp): Promise<void> {
|
private async displaySingleUpsStatus(ups: IUpsConfig, snmp: NupstSnmp): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Create a test config with a short timeout
|
const protocol = ups.protocol || 'snmp';
|
||||||
const testConfig = {
|
let status;
|
||||||
...ups.snmp,
|
|
||||||
timeout: Math.min(ups.snmp.timeout, 10000), // Use at most 10 seconds for status check
|
|
||||||
};
|
|
||||||
|
|
||||||
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
|
// Determine status symbol based on power status
|
||||||
let statusSymbol = symbols.unknown;
|
let statusSymbol = symbols.unknown;
|
||||||
@@ -396,7 +407,12 @@ WantedBy=multi-user.target
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Display host info
|
// 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
|
// Display groups if any
|
||||||
if (ups.groups && ups.groups.length > 0) {
|
if (ups.groups && ups.groups.length > 0) {
|
||||||
@@ -434,11 +450,16 @@ WantedBy=multi-user.target
|
|||||||
logger.log('');
|
logger.log('');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Display error for this UPS
|
// Display error for this UPS
|
||||||
|
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(
|
logger.log(
|
||||||
` ${symbols.error} ${theme.highlight(ups.name)} - ${theme.error('Connection failed')}`,
|
` ${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(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('');
|
logger.log('');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
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 {
|
||||||
|
if (powerStatus !== 'onBattery' || !actions || actions.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const actionConfig of actions) {
|
||||||
|
if (
|
||||||
|
actionConfig.thresholds &&
|
||||||
|
(batteryCapacity < actionConfig.thresholds.battery ||
|
||||||
|
batteryRuntime < actionConfig.thresholds.runtime)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@@ -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,269 @@
|
|||||||
|
/**
|
||||||
|
* 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