Compare commits

...

28 Commits

Author SHA1 Message Date
e38413f133 v5.11.1
All checks were successful
Release / build-and-release (push) Successful in 53s
2026-04-16 19:50:24 +00:00
ebc5eed89c fix(deps): remove unused smartchangelog dependency 2026-04-16 19:50:24 +00:00
08b20b4e7b v5.11.0
Some checks failed
Release / build-and-release (push) Failing after 11s
2026-04-16 13:09:21 +00:00
ba4e56338c feat(cli): show changelog entries before running upgrades 2026-04-16 13:09:21 +00:00
6b2fa65611 v5.10.0
All checks were successful
Release / build-and-release (push) Successful in 59s
2026-04-16 09:44:30 +00:00
c42ebb56d3 feat(cli,snmp): fix APC runtime unit defaults and add interactive action editing 2026-04-16 09:44:30 +00:00
c7b52c48d5 v5.8.0
All checks were successful
Release / build-and-release (push) Successful in 50s
2026-04-16 03:51:24 +00:00
e2cfa67fee feat(systemd): improve service status reporting with structured systemctl data 2026-04-16 03:51:24 +00:00
e916ccf3ae v5.7.0
All checks were successful
Release / build-and-release (push) Successful in 53s
2026-04-16 02:54:16 +00:00
a435bd6fed feat(monitoring): add edge-triggered threshold handling with group action orchestration and HA-aware Proxmox shutdowns 2026-04-16 02:54:16 +00:00
bf4d519428 v5.6.0
All checks were successful
Release / build-and-release (push) Successful in 54s
2026-04-14 18:47:37 +00:00
579667b3cd feat(config): add configurable default shutdown delay for shutdown actions 2026-04-14 18:47:37 +00:00
8dc0248763 v5.5.1
All checks were successful
Release / build-and-release (push) Successful in 51s
2026-04-14 14:27:29 +00:00
1f542ca271 fix(cli,daemon,snmp): normalize CLI argument parsing and extract daemon monitoring helpers with stronger SNMP typing 2026-04-14 14:27:29 +00:00
2adf1d5548 v5.5.0
All checks were successful
Release / build-and-release (push) Successful in 2m27s
2026-04-02 08:29:16 +00:00
067a7666e4 feat(proxmox): add Proxmox CLI auto-detection and interactive action setup improvements 2026-04-02 08:29:16 +00:00
0d863a1028 v5.4.1
All checks were successful
Release / build-and-release (push) Successful in 51s
2026-03-30 06:50:36 +00:00
c410a663b1 fix(deps): bump tsdeno and net-snmp patch dependencies 2026-03-30 06:50:36 +00:00
6aa1fc651f v5.4.0
Some checks failed
Release / build-and-release (push) Failing after 15s
2026-03-30 06:46:28 +00:00
11e549e68e feat(snmp): add configurable SNMP runtime units with v4.3 migration support 2026-03-30 06:46:28 +00:00
0fb9678976 v5.3.3
All checks were successful
Release / build-and-release (push) Successful in 1m24s
2026-03-18 09:49:29 +00:00
635de0d932 fix(deps): add @git.zone/tsdeno as a development dependency 2026-03-18 09:49:29 +00:00
0916effb53 v5.3.2
Some checks failed
Release / build-and-release (push) Failing after 7s
2026-03-18 09:48:16 +00:00
05242a1c7d fix(build): replace manual release compilation workflows with tsdeno-based build configuration 2026-03-18 09:48:16 +00:00
0d20dce520 v5.3.1
Some checks failed
CI / Type Check & Lint (push) Successful in 16s
CI / Build Test (Current Platform) (push) Successful in 15s
Publish to npm / npm-publish (push) Failing after 26s
CI / Build All Platforms (push) Successful in 1m8s
Release / build-and-release (push) Successful in 1m11s
2026-03-15 12:04:06 +00:00
1c50509497 fix(cli): rename the update command references to upgrade across the CLI and documentation 2026-03-15 12:04:05 +00:00
7de521078e v5.3.0
Some checks failed
CI / Type Check & Lint (push) Successful in 9s
CI / Build Test (Current Platform) (push) Successful in 10s
Publish to npm / npm-publish (push) Failing after 24s
Release / build-and-release (push) Successful in 58s
CI / Build All Platforms (push) Successful in 1m2s
2026-02-20 11:51:59 +00:00
42b8eaf6d2 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 2026-02-20 11:51:59 +00:00
61 changed files with 8607 additions and 1809 deletions

View File

@@ -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

View File

@@ -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 ""

View File

@@ -8,6 +8,8 @@ on:
jobs:
build-and-release:
runs-on: ubuntu-latest
container:
image: code.foss.global/host.today/ht-docker-node:latest
steps:
- name: Checkout code
@@ -20,6 +22,17 @@ jobs:
with:
deno-version: v2.x
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Enable corepack
run: corepack enable
- name: Install dependencies
run: pnpm install --ignore-scripts
- name: Get version from tag
id: version
run: |
@@ -41,57 +54,7 @@ jobs:
fi
- name: Compile binaries for all platforms
run: |
echo "================================================"
echo " NUPST Release Compilation"
echo " Version: ${{ steps.version.outputs.version }}"
echo "================================================"
echo ""
# Clean up old binaries and create fresh directory
rm -rf dist/binaries
mkdir -p dist/binaries
echo "→ Cleaned old binaries from dist/binaries"
echo ""
# Linux x86_64
echo "→ Compiling for Linux x86_64..."
deno compile --allow-all --no-check \
--output dist/binaries/nupst-linux-x64 \
--target x86_64-unknown-linux-gnu mod.ts
echo " ✓ Linux x86_64 complete"
# Linux ARM64
echo "→ Compiling for Linux ARM64..."
deno compile --allow-all --no-check \
--output dist/binaries/nupst-linux-arm64 \
--target aarch64-unknown-linux-gnu mod.ts
echo " ✓ Linux ARM64 complete"
# macOS x86_64
echo "→ Compiling for macOS x86_64..."
deno compile --allow-all --no-check \
--output dist/binaries/nupst-macos-x64 \
--target x86_64-apple-darwin mod.ts
echo " ✓ macOS x86_64 complete"
# macOS ARM64
echo "→ Compiling for macOS ARM64..."
deno compile --allow-all --no-check \
--output dist/binaries/nupst-macos-arm64 \
--target aarch64-apple-darwin mod.ts
echo " ✓ macOS ARM64 complete"
# Windows x86_64
echo "→ Compiling for Windows x86_64..."
deno compile --allow-all --no-check \
--output dist/binaries/nupst-windows-x64.exe \
--target x86_64-pc-windows-msvc mod.ts
echo " ✓ Windows x86_64 complete"
echo ""
echo "All binaries compiled successfully!"
ls -lh dist/binaries/
run: mkdir -p dist/binaries && npx tsdeno compile
- name: Generate SHA256 checksums
run: |
@@ -105,7 +68,6 @@ jobs:
run: |
VERSION="${{ steps.version.outputs.version }}"
# Check if CHANGELOG.md exists
if [ ! -f CHANGELOG.md ]; then
echo "No CHANGELOG.md found, using default release notes"
cat > /tmp/release_notes.md << EOF
@@ -133,8 +95,6 @@ jobs:
SHA256 checksums are provided in SHA256SUMS.txt
EOF
else
# Try to extract section for this version from CHANGELOG.md
# This is a simple extraction - adjust based on your CHANGELOG format
awk "/## \[$VERSION\]/,/## \[/" CHANGELOG.md | sed '$d' > /tmp/release_notes.md || cat > /tmp/release_notes.md << EOF
## NUPST $VERSION
@@ -158,7 +118,6 @@ jobs:
echo "Checking for existing release $VERSION..."
# Try to get existing release by tag
EXISTING_RELEASE_ID=$(curl -s \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
"https://code.foss.global/api/v1/repos/serve.zone/nupst/releases/tags/$VERSION" \
@@ -178,9 +137,7 @@ jobs:
- name: Create Gitea Release
run: |
VERSION="${{ steps.version.outputs.version }}"
RELEASE_NOTES=$(cat /tmp/release_notes.md)
# Create the release
echo "Creating release for $VERSION..."
RELEASE_ID=$(curl -X POST -s \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
@@ -196,7 +153,6 @@ jobs:
echo "Release created with ID: $RELEASE_ID"
# Upload binaries as release assets
for binary in dist/binaries/*; do
filename=$(basename "$binary")
echo "Uploading $filename..."
@@ -213,12 +169,10 @@ jobs:
run: |
echo "Cleaning up old releases (keeping only last 3)..."
# Fetch all releases sorted by creation date
RELEASES=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
"https://code.foss.global/api/v1/repos/serve.zone/nupst/releases" | \
jq -r 'sort_by(.created_at) | reverse | .[3:] | .[].id')
# Delete old releases
if [ -n "$RELEASES" ]; then
echo "Found releases to delete:"
for release_id in $RELEASES; do

64
.smartconfig.json Normal file
View File

@@ -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": {}
}

View File

@@ -10,7 +10,7 @@ import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import { existsSync } from 'fs';
import { arch, platform } from 'os';
import process from "node:process";
import process from 'node:process';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

View File

@@ -1,28 +1,193 @@
# Changelog
## 2026-04-16 - 5.11.1 - fix(deps)
remove unused smartchangelog dependency
- Drops @push.rocks/smartchangelog from package.json dependencies.
- Keeps the package manifest aligned with the actual runtime requirements.
## 2026-04-16 - 5.11.0 - feat(cli)
show changelog entries before running upgrades
- fetch and render changelog entries between the installed and latest versions during the upgrade flow
- add upgrade changelog parsing helper with tests for version filtering and grouped version ranges
- document that the upgrade command displays release notes before installing
## 2026-04-16 - 5.10.0 - feat(cli,snmp)
fix APC runtime unit defaults and add interactive action editing
- correct APC PowerNet runtime handling to use TimeTicks-based conversion and update default runtime unit selection for APC devices
- add an action edit command for UPS and group actions so existing actions can be updated interactively
- introduce a v4.3 to v4.4 config migration to correct APC runtimeUnit values in existing configurations
## 2026-04-16 - 5.9.0 - feat(cli,snmp)
fix APC runtime defaults and add interactive action editing
- Correct APC PowerNet runtime handling to use TimeTicks-based conversion, update runtime-unit
defaults, and add a v4.3 to v4.4 migration for existing configs.
- Add `nupst action edit <target-id> <index>` so UPS and group actions can be updated without
hand-editing `config.json`.
- Document config hot-reload behavior, APC runtime guidance, and the lack of cross-node action
coordination when reusing configs on multiple machines.
## 2026-04-16 - 5.8.0 - feat(systemd)
improve service status reporting with structured systemctl data
- switch status collection from parsing `systemctl status` output to `systemctl show` properties for
more reliable service state detection
- display a distinct "not installed" status when the unit is missing
- format systemd memory and CPU usage values into readable output for status details
## 2026-04-16 - 5.7.0 - feat(monitoring)
add edge-triggered threshold handling with group action orchestration and HA-aware Proxmox shutdowns
- Track per-action threshold entry state so threshold-based actions fire only when conditions are
newly violated
- Add group monitoring and threshold evaluation for redundant and non-redundant UPS groups,
including suppression of destructive actions when members are unreachable
- Support optional Proxmox HA stop requests for HA-managed guests and prevent duplicate Proxmox or
host shutdown scheduling
## 2026-04-14 - 5.6.0 - feat(config)
add configurable default shutdown delay for shutdown actions
- introduces a top-level defaultShutdownDelay config value used by shutdown actions that do not
define their own delay
- applies the configured default during action execution, daemon-initiated shutdowns, CLI prompts,
and status display output
- preserves explicit shutdownDelay values including 0 minutes and normalizes invalid config values
back to the built-in default
## 2026-04-14 - 5.5.1 - fix(cli,daemon,snmp)
normalize CLI argument parsing and extract daemon monitoring helpers with stronger SNMP typing
- Pass runtime arguments directly to the CLI in both Deno and Node entrypoints so commands and debug
flags are parsed consistently
- Refactor daemon logic into dedicated pause state, config watch, UPS status, monitoring, action
orchestration, shutdown execution, and shutdown monitoring modules
- Add explicit local typings and value coercion around net-snmp interactions to reduce untyped
response handling
- Update user-facing CLI guidance to use current subcommands such as "nupst ups add", "nupst ups
edit", and "nupst service start"
- Expand test coverage for extracted monitoring and pause-state helpers
## 2026-04-02 - 5.5.0 - feat(proxmox)
add Proxmox CLI auto-detection and interactive action setup improvements
- Add Proxmox action support for CLI mode using qm/pct with automatic fallback to REST API mode
- Expose proxmoxMode configuration and update CLI wizards to auto-detect local Proxmox tools before
prompting for API credentials
- Expand interactive action creation to support shutdown, webhook, script, and Proxmox actions with
improved displayed details
- Update documentation to cover Proxmox CLI/API modes and clarify shutdown delay units in minutes
## 2026-03-30 - 5.4.1 - fix(deps)
bump tsdeno and net-snmp patch dependencies
- update @git.zone/tsdeno from ^1.2.0 to ^1.3.1
- update net-snmp import from 3.26.0 to 3.26.1 in the SNMP manager
## 2026-03-30 - 5.4.0 - feat(snmp)
add configurable SNMP runtime units with v4.3 migration support
- Adds explicit `runtimeUnit` support for SNMP devices with `minutes`, `seconds`, and `ticks`
options.
- Updates runtime processing to prefer configured units over UPS model heuristics.
- Introduces a v4.2 to v4.3 migration that populates `runtimeUnit` for existing SNMP device configs
based on `upsModel`.
- Extends the CLI setup and device summary output to configure and display the selected runtime
unit.
- Updates default config version to 4.3 and documents the new SNMP runtime unit setting in the
README.
## 2026-03-18 - 5.3.3 - fix(deps)
add @git.zone/tsdeno as a development dependency
- Adds @git.zone/tsdeno@^1.2.0 to devDependencies in package.json.
## 2026-03-18 - 5.3.2 - fix(build)
replace manual release compilation workflows with tsdeno-based build configuration
- removes obsolete CI and npm publish workflows
- switches the Deno compile task to use tsdeno
- adds reusable multi-platform compile targets in npmextra.json
- updates the release workflow to install Node.js and pnpm before building binaries
- deletes the custom compile-all.sh script in favor of centralized build tooling
## 2026-03-15 - 5.3.1 - fix(cli)
rename the update command references to upgrade across the CLI and documentation
- Updates command parsing and help output to use `upgrade` instead of `update`.
- Revises user-facing upgrade prompts in daemon, systemd, and runtime status messages.
- Aligns README and command migration documentation with the renamed command.
## 2026-02-20 - 5.3.0 - feat(daemon)
Add UPSD (NUT) protocol support, Proxmox VM shutdown action, pause/resume monitoring, and
network-loss/unreachable handling; bump config version to 4.2
- Add UPSD client (ts/upsd) and ProtocolResolver (ts/protocol) to support protocol-agnostic UPS
queries (snmp or upsd).
- Introduce new TProtocol and IUpsdConfig types, wire up Nupst to initialize & expose UPSD client,
and route status requests through ProtocolResolver.
- Add 'unreachable' TPowerStatus plus consecutiveFailures and unreachableSince tracking; mark UPS as
unreachable after NETWORK.CONSECUTIVE_FAILURE_THRESHOLD failures and suppress shutdown actions
while unreachable.
- Implement pause/resume feature: PAUSE.FILE_PATH state file, CLI commands (pause/resume), daemon
pause-state polling, auto-resume, and include pause state in HTTP API responses.
- Add ProxmoxAction (ts/actions/proxmox-action.ts) with Proxmox API interaction, configuration
options (token, node, timeout, force, insecure) and CLI prompts to configure proxmox actions.
- CLI and UI updates: protocol selection when adding UPS, protocol/host shown in lists, action
details column supports proxmox, and status displays include protocol and unreachable state.
- Add migration MigrationV4_1ToV4_2 to set protocol:'snmp' for existing devices and bump
config.version to '4.2'.
- Add new constants (NETWORK, UPSD, PAUSE, PROXMOX), update package.json scripts
(test/build/lint/format), and wire protocol support across daemon, systemd, http-server, and
various handlers.
## 2026-01-29 - 5.2.4 - fix()
no changes
- No files changed in the provided git diff; no commit or version bump required.
## 2026-01-29 - 5.2.3 - fix(core)
fix lint/type issues and small refactors
- Add missing node:process imports in bin and scripts to ensure process is available
- Remove unused imports and unused type imports (e.g. writeFileSync, IActionConfig) to reduce noise
- Make some methods synchronous (service update, webhook call) to match actual usage
- Tighten SNMP typings and linting: added deno-lint-ignore comments, renamed unused params with leading underscore, and use `as const` for securityLevel fallbacks
- Tighten SNMP typings and linting: added deno-lint-ignore comments, renamed unused params with
leading underscore, and use `as const` for securityLevel fallbacks
- Improve error handling variable naming in systemd (use error instead of _error)
- Annotate ANSI regex with deno-lint-ignore no-control-regex and remove unused color/symbol imports across CLI/daemon/logger
- Annotate ANSI regex with deno-lint-ignore no-control-regex and remove unused color/symbol imports
across CLI/daemon/logger
## 2026-01-29 - 5.2.2 - fix(core)
tidy formatting and minor fixes across CLI, SNMP, HTTP server, migrations and packaging
- Normalize import ordering and improve logger/string formatting across many CLI handlers, daemon, systemd, actions and tests
- Normalize import ordering and improve logger/string formatting across many CLI handlers, daemon,
systemd, actions and tests
- Apply formatting tidies: trailing commas, newline fixes, and more consistent multiline strings
- Allow BaseMigration methods to return either sync or async results (shouldRun/migrate signatures updated)
- Improve SNMP manager and HTTP server logging/error messages and tighten some typings (raw SNMP types, server error typing)
- Small robustness and messaging improvements in npm installer and wrapper (platform/arch mapping, error outputs)
- Allow BaseMigration methods to return either sync or async results (shouldRun/migrate signatures
updated)
- Improve SNMP manager and HTTP server logging/error messages and tighten some typings (raw SNMP
types, server error typing)
- Small robustness and messaging improvements in npm installer and wrapper (platform/arch mapping,
error outputs)
- Update tests and documentation layout/formatting for readability
## 2026-01-29 - 5.2.1 - fix(cli(ups-handler), systemd)

View File

@@ -1,12 +1,11 @@
{
"name": "@serve.zone/nupst",
"version": "5.2.4",
"version": "5.11.1",
"exports": "./mod.ts",
"nodeModulesDir": "auto",
"tasks": {
"dev": "deno run --allow-all mod.ts",
"compile": "deno task compile:all",
"compile:all": "bash scripts/compile-all.sh",
"compile": "tsdeno compile",
"test": "deno test --allow-all test/",
"test:watch": "deno test --allow-all --watch test/",
"check": "deno check mod.ts",

7
mod.ts
View File

@@ -25,12 +25,7 @@ import { NupstCli } from './ts/cli.ts';
*/
async function main(): Promise<void> {
const cli = new NupstCli();
// Deno.args is already 0-indexed (unlike Node's process.argv which starts at index 2)
// We need to prepend placeholder args to match the existing CLI parser expectations
const args = ['deno', 'mod.ts', ...Deno.args];
await cli.parseAndExecute(args);
await cli.parseAndExecute(Deno.args);
}
// Execute main and handle errors

View File

@@ -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": {}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@serve.zone/nupst",
"version": "5.2.4",
"version": "5.11.1",
"description": "Network UPS Shutdown Tool - Monitor SNMP-enabled UPS devices and orchestrate graceful system shutdowns during power emergencies",
"keywords": [
"ups",
@@ -34,8 +34,10 @@
"scripts": {
"postinstall": "node scripts/install-binary.js",
"prepublishOnly": "echo 'Publishing NUPST binaries to npm...'",
"test": "echo 'Tests are run with Deno: deno task test'",
"build": "echo 'no build needed'"
"test": "deno task test",
"build": "deno task check",
"lint": "deno task lint",
"format": "deno task fmt"
},
"files": [
"bin/",
@@ -60,5 +62,8 @@
"access": "public",
"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"
}
}

2324
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -12,8 +12,9 @@
2. **Constants File (`ts/constants.ts`)**
- Centralized all magic numbers (timeouts, intervals, thresholds)
- Contains: `TIMING`, `SNMP`, `THRESHOLDS`, `WEBHOOK`, `SCRIPT`, `SHUTDOWN`, `HTTP_SERVER`, `UI`
- Used in: `daemon.ts`, `snmp/manager.ts`, `actions/*.ts`
- Contains: `TIMING`, `SNMP`, `THRESHOLDS`, `WEBHOOK`, `SCRIPT`, `SHUTDOWN`, `HTTP_SERVER`, `UI`,
`NETWORK`, `UPSD`, `PAUSE`, `PROXMOX`
- Used in: `daemon.ts`, `snmp/manager.ts`, `actions/*.ts`, `upsd/client.ts`
3. **Logger Consistency**
- Replaced all `console.log/console.error` in `snmp/manager.ts` with proper `logger.*` calls
@@ -35,28 +36,137 @@
- Uses: `IUpsConfig`, `INupstConfig`, `ISnmpConfig`, `IActionConfig`, `IThresholds`,
`ISnmpUpsStatus`
7. **SNMP Manager Boundary Types (`ts/snmp/manager.ts`)**
- Added local wrapper interfaces for the untyped `net-snmp` package surface used by NUPST
- SNMP metric reads now coerce values explicitly instead of relying on `any`-typed responses
## Features Added (February 2026)
### Network Loss Handling
- `TPowerStatus` extended with `'unreachable'` state
- `IUpsStatus` has `consecutiveFailures` and `unreachableSince` tracking
- After `NETWORK.CONSECUTIVE_FAILURE_THRESHOLD` (3) failures, UPS transitions to `unreachable`
- Shutdown action explicitly won't fire on `unreachable` (prevents false shutdowns)
- Recovery is logged when UPS comes back from unreachable
### UPSD/NIS Protocol Support
- New `ts/upsd/` directory with TCP client for NUT (Network UPS Tools) servers
- `ts/protocol/` directory with `ProtocolResolver` for protocol-agnostic status queries
- `IUpsConfig.protocol` field: `'snmp'` (default) or `'upsd'`
- `IUpsConfig.snmp` is now optional (not needed for UPSD devices)
- CLI supports protocol selection during `nupst ups add`
- Config version is now `4.3`, including the `4.2` -> `4.3` runtime unit migration
### Pause/Resume Command
- File-based signaling via `/etc/nupst/pause` JSON file
- `nupst pause [--duration 30m|2h|1d]` creates pause file
- `nupst resume` deletes pause file
- `ts/pause-state.ts` owns pause snapshot parsing and transition detection for daemon polling
- Daemon polls continue but actions are suppressed while paused
- Auto-resume after duration expires
- HTTP API includes pause state in response
### Shutdown Orchestration
- `ts/shutdown-executor.ts` owns command discovery and fallback execution for delayed and emergency
shutdowns
- `ts/daemon.ts` now delegates OS shutdown execution instead of embedding command lookup logic
inline
- `defaultShutdownDelay` in config provides the inherited delay for shutdown actions without an
explicit `shutdownDelay` override
### Config Watch Handling
- `ts/config-watch.ts` owns file-watch event matching and config-reload transition analysis
- `ts/daemon.ts` now delegates config/pause watch event classification and reload messaging
decisions
### UPS Status Tracking
- `ts/ups-status.ts` owns the daemon UPS status shape and default status factory
- `ts/daemon.ts` now reuses a shared initializer instead of duplicating the default UPS status
object
### UPS Monitoring Transitions
- `ts/ups-monitoring.ts` owns pure UPS poll success/failure transition logic and threshold detection
- `ts/daemon.ts` now orchestrates protocol calls and logging while delegating state transitions
### Action Orchestration
- `ts/action-orchestration.ts` owns action context construction and action execution decisions
- `ts/daemon.ts` now delegates pause suppression, legacy shutdown fallback, and action context
building
### Shutdown Monitoring
- `ts/shutdown-monitoring.ts` owns shutdown-loop row building and emergency candidate selection
- `ts/daemon.ts` now keeps the shutdown loop orchestration while delegating row/emergency decisions
### Proxmox VM Shutdown Action
- New action type `'proxmox'` in `ts/actions/proxmox-action.ts`
- Uses Proxmox REST API with PVEAPIToken authentication
- Shuts down QEMU VMs and LXC containers before host shutdown
- Supports: exclude IDs, configurable timeout, force-stop, TLS skip for self-signed certs
- Should be placed BEFORE shutdown actions in the action chain
## Architecture Notes
- **SNMP Manager**: Uses `INupstAccessor` interface (not direct `Nupst` reference) to avoid circular
imports
- **Protocol Resolver**: Routes to SNMP or UPSD based on `IUpsConfig.protocol`
- **CLI Handlers**: All use the `helpers.withPrompt()` utility for interactive input
- **Constants**: All timing values should be referenced from `ts/constants.ts`
- **Actions**: Use `IActionConfig` from `ts/actions/base-action.ts` for action configuration
- **Action orchestration**: Use helpers from `ts/action-orchestration.ts` for action context and
execution decisions
- **Config watch logic**: Use helpers from `ts/config-watch.ts` for file event filtering and reload
transitions
- **Pause state**: Use `loadPauseSnapshot()` and `IPauseState` from `ts/pause-state.ts`
- **Shutdown execution**: Use `ShutdownExecutor` for OS-level shutdown command lookup and fallbacks
- **Shutdown monitoring**: Use helpers from `ts/shutdown-monitoring.ts` for emergency loop rows and
candidate selection
- **UPS status state**: Use `IUpsStatus` and `createInitialUpsStatus()` from `ts/ups-status.ts`
- **UPS poll transitions**: Use helpers from `ts/ups-monitoring.ts` for success/failure updates
- **Config version**: Currently `4.3`, migrations run automatically
## File Organization
```
ts/
├── constants.ts # All timing/threshold constants
├── action-orchestration.ts # Action context and execution decisions
├── config-watch.ts # File watch filters and config reload transitions
├── shutdown-monitoring.ts # Shutdown loop rows and emergency selection
├── ups-monitoring.ts # Pure UPS poll transition and threshold helpers
├── pause-state.ts # Shared pause state types and transition detection
├── shutdown-executor.ts # Delayed/emergency shutdown command execution
├── ups-status.ts # Daemon UPS status shape and initializer
├── interfaces/
│ └── nupst-accessor.ts # Interface to break circular deps
├── helpers/
│ ├── prompt.ts # Readline utility
│ └── shortid.ts # ID generation
├── actions/
│ ├── base-action.ts # Base action class and interfaces
│ ├── base-action.ts # Base action class, IActionConfig, TPowerStatus
│ ├── webhook-action.ts # Includes IWebhookPayload
│ ├── proxmox-action.ts # Proxmox VM/LXC shutdown
│ └── ...
├── upsd/
│ ├── types.ts # IUpsdConfig
│ ├── client.ts # NupstUpsd TCP client
│ └── index.ts
├── protocol/
│ ├── types.ts # TProtocol = 'snmp' | 'upsd'
│ ├── resolver.ts # ProtocolResolver
│ └── index.ts
├── migrations/
│ ├── migration-runner.ts
│ └── migration-v4.2-to-v4.3.ts # Adds SNMP runtimeUnit defaults
└── cli/
└── ... # All handlers use helpers.withPrompt()
```

1117
readme.md

File diff suppressed because it is too large Load Diff

View File

@@ -195,7 +195,7 @@ nupst group edit <id> → nupst group edit <id>
nupst group delete <id> → nupst group remove <id>
nupst config → nupst config show
nupst update → nupst update
nupst upgrade → nupst upgrade
nupst uninstall → nupst uninstall
nupst help → nupst help / nupst --help
(new) → nupst --version

View File

@@ -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 ""

View File

@@ -14,7 +14,7 @@ import https from 'https';
import { pipeline } from 'stream';
import { promisify } from 'util';
import { createWriteStream } from 'fs';
import process from "node:process";
import process from 'node:process';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

View File

@@ -229,10 +229,10 @@ console.log('');
// === 10. Update Available Example ===
logger.logBoxTitle('Update Available', 70, 'warning');
logger.logBoxLine('');
logger.logBoxLine(`Current Version: ${theme.dim('4.0.1')}`);
logger.logBoxLine(`Latest Version: ${theme.highlight('4.0.2')}`);
logger.logBoxLine(`Current Version: ${theme.dim('5.5.0')}`);
logger.logBoxLine(`Latest Version: ${theme.highlight('5.5.1')}`);
logger.logBoxLine('');
logger.logBoxLine(`Run ${theme.command('sudo nupst update')} to update`);
logger.logBoxLine(`Run ${theme.command('sudo nupst upgrade')} to update`);
logger.logBoxLine('');
logger.logBoxEnd();

View File

@@ -1,10 +1,48 @@
import { assert, assertEquals, assertExists } from 'jsr:@std/assert@^1.0.0';
import { NupstSnmp } from '../ts/snmp/manager.ts';
import { UpsOidSets } from '../ts/snmp/oid-sets.ts';
import {
convertRuntimeValueToMinutes,
getDefaultRuntimeUnitForUpsModel,
} from '../ts/snmp/runtime-units.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 { HTTP_SERVER, SNMP, THRESHOLDS, TIMING, UI } from '../ts/constants.ts';
import { Action, type IActionContext } from '../ts/actions/base-action.ts';
import { Action, type IActionConfig, type IActionContext } from '../ts/actions/base-action.ts';
import {
applyDefaultShutdownDelay,
buildUpsActionContext,
decideUpsActionExecution,
} from '../ts/action-orchestration.ts';
import {
buildShutdownErrorRow,
buildShutdownStatusRow,
selectEmergencyCandidate,
} from '../ts/shutdown-monitoring.ts';
import {
buildFailedUpsPollSnapshot,
buildSuccessfulUpsPollSnapshot,
getActionThresholdStates,
getEnteredThresholdIndexes,
hasThresholdViolation,
isActionThresholdExceeded,
} from '../ts/ups-monitoring.ts';
import {
buildGroupStatusSnapshot,
buildGroupThresholdContextStatus,
evaluateGroupActionThreshold,
} from '../ts/group-monitoring.ts';
import { createInitialUpsStatus } from '../ts/ups-status.ts';
import { MigrationV4_2ToV4_3 } from '../ts/migrations/migration-v4.2-to-v4.3.ts';
import { MigrationV4_3ToV4_4 } from '../ts/migrations/migration-v4.3-to-v4.4.ts';
import { ActionHandler } from '../ts/cli/action-handler.ts';
import { renderUpgradeChangelog } from '../ts/upgrade-changelog.ts';
import * as qenv from 'npm:@push.rocks/qenv@^6.0.0';
const testQenv = new qenv.Qenv('./', '.nogit/');
@@ -82,6 +120,637 @@ Deno.test('UI constants: box widths are ascending', () => {
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',
},
});
});
Deno.test('applyDefaultShutdownDelay: applies only to shutdown actions without explicit delay', () => {
const actions = [
{ type: 'shutdown' as const },
{ type: 'shutdown' as const, shutdownDelay: 0 },
{ type: 'shutdown' as const, shutdownDelay: 9 },
{ type: 'webhook' as const },
];
assertEquals(applyDefaultShutdownDelay(actions, 7), [
{ type: 'shutdown', shutdownDelay: 7 },
{ type: 'shutdown', shutdownDelay: 0 },
{ type: 'shutdown', shutdownDelay: 9 },
{ type: 'webhook' },
]);
});
// -----------------------------------------------------------------------------
// 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,
);
});
Deno.test('isActionThresholdExceeded: evaluates a single action threshold on battery only', () => {
assertEquals(
isActionThresholdExceeded(
{ type: 'shutdown', thresholds: { battery: 50, runtime: 20 } },
'online',
40,
10,
),
false,
);
assertEquals(
isActionThresholdExceeded(
{ type: 'shutdown', thresholds: { battery: 50, runtime: 20 } },
'onBattery',
40,
10,
),
true,
);
});
Deno.test('getActionThresholdStates: returns per-action threshold state array', () => {
assertEquals(
getActionThresholdStates('onBattery', 25, 8, [
{ type: 'shutdown', thresholds: { battery: 30, runtime: 10 } },
{ type: 'shutdown', thresholds: { battery: 10, runtime: 5 } },
{ type: 'webhook' },
]),
[true, false, false],
);
});
Deno.test('getEnteredThresholdIndexes: reports only newly-entered thresholds', () => {
assertEquals(getEnteredThresholdIndexes(undefined, [false, true, true]), [1, 2]);
assertEquals(getEnteredThresholdIndexes([false, true, false], [true, true, false]), [0]);
assertEquals(getEnteredThresholdIndexes([true, true], [true, false]), []);
});
// -----------------------------------------------------------------------------
// Group Monitoring Tests
// -----------------------------------------------------------------------------
Deno.test('buildGroupStatusSnapshot: redundant group stays online while one UPS remains online', () => {
const snapshot = buildGroupStatusSnapshot(
{ id: 'group-1', name: 'Group Main' },
'redundant',
[
{
...createInitialUpsStatus({ id: 'ups-1', name: 'UPS 1' }, 1000),
powerStatus: 'onBattery' as const,
batteryCapacity: 40,
batteryRuntime: 12,
},
{
...createInitialUpsStatus({ id: 'ups-2', name: 'UPS 2' }, 1000),
powerStatus: 'online' as const,
batteryCapacity: 98,
batteryRuntime: 999,
},
],
undefined,
5000,
);
assertEquals(snapshot.updatedStatus.powerStatus, 'online');
assertEquals(snapshot.transition, 'powerStatusChange');
});
Deno.test('buildGroupStatusSnapshot: nonRedundant group goes unreachable when any member is unreachable', () => {
const snapshot = buildGroupStatusSnapshot(
{ id: 'group-2', name: 'Group Edge' },
'nonRedundant',
[
{
...createInitialUpsStatus({ id: 'ups-1', name: 'UPS 1' }, 1000),
powerStatus: 'online' as const,
},
{
...createInitialUpsStatus({ id: 'ups-2', name: 'UPS 2' }, 1000),
powerStatus: 'unreachable' as const,
unreachableSince: 2000,
},
],
{
...createInitialUpsStatus({ id: 'group-2', name: 'Group Edge' }, 1000),
powerStatus: 'online' as const,
},
6000,
);
assertEquals(snapshot.updatedStatus.powerStatus, 'unreachable');
assertEquals(snapshot.transition, 'powerStatusChange');
});
Deno.test('evaluateGroupActionThreshold: redundant mode requires all members to be critical', () => {
const evaluation = evaluateGroupActionThreshold(
{ type: 'shutdown', thresholds: { battery: 50, runtime: 20 } },
'redundant',
[
{
...createInitialUpsStatus({ id: 'ups-1', name: 'UPS 1' }, 1000),
powerStatus: 'onBattery' as const,
batteryCapacity: 40,
batteryRuntime: 15,
},
{
...createInitialUpsStatus({ id: 'ups-2', name: 'UPS 2' }, 1000),
powerStatus: 'online' as const,
batteryCapacity: 95,
batteryRuntime: 999,
},
],
);
assertEquals(evaluation.exceedsThreshold, false);
});
Deno.test('evaluateGroupActionThreshold: nonRedundant mode trips on any critical member', () => {
const evaluation = evaluateGroupActionThreshold(
{ type: 'shutdown', thresholds: { battery: 50, runtime: 20 } },
'nonRedundant',
[
{
...createInitialUpsStatus({ id: 'ups-1', name: 'UPS 1' }, 1000),
powerStatus: 'onBattery' as const,
batteryCapacity: 40,
batteryRuntime: 15,
},
{
...createInitialUpsStatus({ id: 'ups-2', name: 'UPS 2' }, 1000),
powerStatus: 'online' as const,
batteryCapacity: 95,
batteryRuntime: 999,
},
],
);
assertEquals(evaluation.exceedsThreshold, true);
assertEquals(evaluation.blockedByUnreachable, false);
});
Deno.test('evaluateGroupActionThreshold: blocks destructive actions when a member is unreachable', () => {
const evaluation = evaluateGroupActionThreshold(
{ type: 'proxmox', thresholds: { battery: 50, runtime: 20 } },
'nonRedundant',
[
{
...createInitialUpsStatus({ id: 'ups-1', name: 'UPS 1' }, 1000),
powerStatus: 'onBattery' as const,
batteryCapacity: 25,
batteryRuntime: 8,
},
{
...createInitialUpsStatus({ id: 'ups-2', name: 'UPS 2' }, 1000),
powerStatus: 'unreachable' as const,
unreachableSince: 3000,
},
],
);
assertEquals(evaluation.exceedsThreshold, true);
assertEquals(evaluation.blockedByUnreachable, true);
});
Deno.test('buildGroupThresholdContextStatus: uses the worst triggering member runtime', () => {
const status = buildGroupThresholdContextStatus(
{ id: 'group-3', name: 'Group Worst' },
[
{
exceedsThreshold: true,
blockedByUnreachable: false,
representativeStatus: {
...createInitialUpsStatus({ id: 'ups-1', name: 'UPS 1' }, 1000),
powerStatus: 'onBattery' as const,
batteryCapacity: 30,
batteryRuntime: 9,
},
},
{
exceedsThreshold: true,
blockedByUnreachable: false,
representativeStatus: {
...createInitialUpsStatus({ id: 'ups-2', name: 'UPS 2' }, 1000),
powerStatus: 'onBattery' as const,
batteryCapacity: 20,
batteryRuntime: 4,
},
},
],
[0, 1],
{
...createInitialUpsStatus({ id: 'group-3', name: 'Group Worst' }, 1000),
powerStatus: 'online' as const,
},
7000,
);
assertEquals(status.powerStatus, 'onBattery');
assertEquals(status.batteryCapacity, 20);
assertEquals(status.batteryRuntime, 4);
});
// -----------------------------------------------------------------------------
// UpsOidSets Tests
// -----------------------------------------------------------------------------
@@ -133,6 +802,107 @@ Deno.test('UpsOidSets: getStandardOids returns RFC 1628 OIDs', () => {
}
});
// -----------------------------------------------------------------------------
// Runtime Unit Tests
// -----------------------------------------------------------------------------
Deno.test('getDefaultRuntimeUnitForUpsModel: APC defaults to ticks', () => {
assertEquals(getDefaultRuntimeUnitForUpsModel('apc'), 'ticks');
assertEquals(getDefaultRuntimeUnitForUpsModel('cyberpower'), 'ticks');
assertEquals(getDefaultRuntimeUnitForUpsModel('eaton'), 'seconds');
});
Deno.test('convertRuntimeValueToMinutes: APC and explicit overrides convert correctly', () => {
assertEquals(convertRuntimeValueToMinutes({ upsModel: 'apc' }, 12000), 2);
assertEquals(convertRuntimeValueToMinutes({ upsModel: 'eaton' }, 600), 10);
assertEquals(convertRuntimeValueToMinutes({ upsModel: 'apc', runtimeUnit: 'minutes' }, 12), 12);
});
Deno.test('renderUpgradeChangelog: renders only versions between current and latest', () => {
const changelogMarkdown = `# Changelog
## 2026-04-16 - 5.10.0 - feat(cli,snmp)
fix APC runtime unit defaults and add interactive action editing
- correct APC runtime handling
## 2026-04-16 - 5.8.0 - feat(systemd)
improve service status reporting with structured systemctl data
- switch status collection to systemctl show
`;
const rendered = renderUpgradeChangelog(changelogMarkdown, '5.8.0', '5.10.0');
assert(rendered.includes('5.10.0 - feat(cli,snmp)'));
assert(rendered.includes('fix APC runtime unit defaults and add interactive action editing'));
assert(!rendered.includes('5.8.0 - feat(systemd)'));
});
Deno.test('renderUpgradeChangelog: includes grouped version ranges when they intersect', () => {
const changelogMarkdown = `# Changelog
## 2020-06-01 - 4.0.3-4.0.5 - core
Grouped maintenance releases with repeated core update work.
- 4.0.5 introduced a breaking change by switching core packaging behavior toward ESM compatibility
`;
const rendered = renderUpgradeChangelog(changelogMarkdown, '4.0.4', '4.0.5');
assert(rendered.includes('4.0.3-4.0.5 - core'));
});
// -----------------------------------------------------------------------------
// Migration Tests
// -----------------------------------------------------------------------------
Deno.test('MigrationV4_2ToV4_3: assigns ticks to APC runtimeUnit', () => {
const migration = new MigrationV4_2ToV4_3();
const migrated = migration.migrate({
version: '4.2',
upsDevices: [
{
name: 'APC Rack UPS',
snmp: {
upsModel: 'apc',
},
},
],
});
const migratedDevice = (migrated.upsDevices as Array<Record<string, unknown>>)[0];
const snmp = migratedDevice.snmp as Record<string, unknown>;
assertEquals(migrated.version, '4.3');
assertEquals(snmp.runtimeUnit, 'ticks');
});
Deno.test('MigrationV4_3ToV4_4: corrects APC minutes runtimeUnit to ticks', () => {
const migration = new MigrationV4_3ToV4_4();
const migrated = migration.migrate({
version: '4.3',
upsDevices: [
{
name: 'APC Rack UPS',
snmp: {
upsModel: 'apc',
runtimeUnit: 'minutes',
},
},
{
name: 'Eaton UPS',
snmp: {
upsModel: 'eaton',
runtimeUnit: 'seconds',
},
},
],
});
const migratedDevices = migrated.upsDevices as Array<Record<string, unknown>>;
assertEquals(migrated.version, '4.4');
assertEquals((migratedDevices[0].snmp as Record<string, unknown>).runtimeUnit, 'ticks');
assertEquals((migratedDevices[1].snmp as Record<string, unknown>).runtimeUnit, 'seconds');
});
// -----------------------------------------------------------------------------
// Action Base Class Tests
// -----------------------------------------------------------------------------
@@ -293,6 +1063,70 @@ Deno.test('Action.shouldExecute: anyChange mode always returns true', () => {
);
});
// -----------------------------------------------------------------------------
// Action Handler Tests
// -----------------------------------------------------------------------------
Deno.test('ActionHandler.runEditProcess: updates an existing shutdown action', async () => {
const config: {
version: string;
defaultShutdownDelay: number;
checkInterval: number;
upsDevices: Array<{ id: string; name: string; groups: string[]; actions: IActionConfig[] }>;
groups: [];
} = {
version: '4.4',
defaultShutdownDelay: 5,
checkInterval: 30000,
upsDevices: [{
id: 'ups-1',
name: 'UPS 1',
groups: [],
actions: [{
type: 'shutdown',
triggerMode: 'onlyThresholds',
thresholds: {
battery: 40,
runtime: 12,
},
}],
}],
groups: [],
};
let savedConfig: typeof config | undefined;
const daemonMock = {
loadConfig: async () => config,
saveConfig: (nextConfig: typeof config) => {
savedConfig = JSON.parse(JSON.stringify(nextConfig));
},
getConfig: () => config,
};
const nupstMock = {
getDaemon: () => daemonMock,
} as unknown as ConstructorParameters<typeof ActionHandler>[0];
const handler = new ActionHandler(nupstMock);
const answers = ['', '12', '25', '8', '3'];
let answerIndex = 0;
const prompt = async (_question: string): Promise<string> => answers[answerIndex++] ?? '';
await handler.runEditProcess('ups-1', '0', prompt);
assertExists(savedConfig);
assertEquals(answerIndex, answers.length);
assertEquals(savedConfig.upsDevices[0].actions[0], {
type: 'shutdown',
shutdownDelay: 12,
thresholds: {
battery: 25,
runtime: 8,
},
triggerMode: 'powerChangesAndThresholds',
});
});
// -----------------------------------------------------------------------------
// NupstSnmp Class Tests (Unit tests - no real UPS needed)
// -----------------------------------------------------------------------------

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/nupst',
version: '5.2.4',
version: '5.11.1',
description: 'Network UPS Shutdown Tool - Monitor SNMP-enabled UPS devices and orchestrate graceful system shutdowns during power emergencies'
}

View File

@@ -0,0 +1,86 @@
import type { IActionConfig, IActionContext, TPowerStatus } from './actions/base-action.ts';
import type { IUpsStatus } from './ups-status.ts';
export interface IUpsActionSource {
id: string;
name: string;
actions?: IActionConfig[];
}
export type TUpsTriggerReason = IActionContext['triggerReason'];
export type TActionExecutionDecision =
| { type: 'suppressed'; message: string }
| { type: 'legacyShutdown'; reason: string }
| { type: 'skip' }
| { type: 'execute'; actions: IActionConfig[]; context: IActionContext };
export function buildUpsActionContext(
ups: IUpsActionSource,
status: IUpsStatus,
previousStatus: IUpsStatus | undefined,
triggerReason: TUpsTriggerReason,
timestamp: number = Date.now(),
): IActionContext {
return {
upsId: ups.id,
upsName: ups.name,
powerStatus: status.powerStatus as TPowerStatus,
batteryCapacity: status.batteryCapacity,
batteryRuntime: status.batteryRuntime,
previousPowerStatus: (previousStatus?.powerStatus || 'unknown') as TPowerStatus,
timestamp,
triggerReason,
};
}
export function applyDefaultShutdownDelay(
actions: IActionConfig[],
defaultDelayMinutes: number,
): IActionConfig[] {
return actions.map((action) => {
if (action.type !== 'shutdown' || action.shutdownDelay !== undefined) {
return action;
}
return {
...action,
shutdownDelay: defaultDelayMinutes,
};
});
}
export function decideUpsActionExecution(
isPaused: boolean,
ups: IUpsActionSource,
status: IUpsStatus,
previousStatus: IUpsStatus | undefined,
triggerReason: TUpsTriggerReason,
timestamp: number = Date.now(),
): TActionExecutionDecision {
if (isPaused) {
return {
type: 'suppressed',
message: `[PAUSED] Actions suppressed for UPS ${ups.name} (trigger: ${triggerReason})`,
};
}
const actions = ups.actions || [];
if (actions.length === 0 && triggerReason === 'thresholdViolation') {
return {
type: 'legacyShutdown',
reason: `UPS "${ups.name}" battery or runtime below threshold`,
};
}
if (actions.length === 0) {
return { type: 'skip' };
}
return {
type: 'execute',
actions,
context: buildUpsActionContext(ups, status, previousStatus, triggerReason, timestamp),
};
}

View File

@@ -6,7 +6,7 @@
* 2. Threshold violations (battery/runtime cross below configured thresholds)
*/
export type TPowerStatus = 'online' | 'onBattery' | 'unknown';
export type TPowerStatus = 'online' | 'onBattery' | 'unknown' | 'unreachable';
/**
* Context provided to actions when they execute
@@ -52,7 +52,7 @@ export type TActionTriggerMode =
*/
export interface IActionConfig {
/** Type of action to execute */
type: 'shutdown' | 'webhook' | 'script';
type: 'shutdown' | 'webhook' | 'script' | 'proxmox';
// Trigger configuration
/**
@@ -74,7 +74,7 @@ export interface IActionConfig {
};
// Shutdown action configuration
/** Delay before shutdown in minutes (default: 5) */
/** Delay before shutdown in minutes (defaults to the config-level shutdown delay, or 5) */
shutdownDelay?: number;
/** Only execute shutdown on threshold violation, not power status changes */
onlyOnThresholdViolation?: boolean;
@@ -96,6 +96,30 @@ export interface IActionConfig {
scriptTimeout?: number;
/** Only execute script on threshold violation */
scriptOnlyOnThresholdViolation?: boolean;
// Proxmox action configuration
/** Proxmox API host (default: localhost) */
proxmoxHost?: string;
/** Proxmox API port (default: 8006) */
proxmoxPort?: number;
/** Proxmox node name (default: auto-detect via hostname) */
proxmoxNode?: string;
/** Proxmox API token ID (e.g., 'root@pam!nupst') */
proxmoxTokenId?: string;
/** Proxmox API token secret */
proxmoxTokenSecret?: string;
/** VM/CT IDs to exclude from shutdown */
proxmoxExcludeIds?: number[];
/** Timeout for VM/CT shutdown in seconds (default: 120) */
proxmoxStopTimeout?: number;
/** Force-stop VMs that don't shut down gracefully (default: true) */
proxmoxForceStop?: boolean;
/** Skip TLS verification for self-signed certificates (default: true) */
proxmoxInsecure?: boolean;
/** Proxmox operation mode: 'auto' detects CLI tools, 'cli' forces CLI, 'api' forces REST API (default: 'auto') */
proxmoxMode?: 'auto' | 'api' | 'cli';
/** How HA-managed Proxmox resources should be stopped (default: 'none') */
proxmoxHaPolicy?: 'none' | 'haStop';
}
/**

View File

@@ -10,6 +10,7 @@ import type { Action, IActionConfig, IActionContext } from './base-action.ts';
import { ShutdownAction } from './shutdown-action.ts';
import { WebhookAction } from './webhook-action.ts';
import { ScriptAction } from './script-action.ts';
import { ProxmoxAction } from './proxmox-action.ts';
// Re-export types for convenience
export type { IActionConfig, IActionContext, TPowerStatus } from './base-action.ts';
@@ -18,6 +19,7 @@ export { Action } from './base-action.ts';
export { ShutdownAction } from './shutdown-action.ts';
export { WebhookAction } from './webhook-action.ts';
export { ScriptAction } from './script-action.ts';
export { ProxmoxAction } from './proxmox-action.ts';
/**
* ActionManager - Coordinates action creation and execution
@@ -40,6 +42,8 @@ export class ActionManager {
return new WebhookAction(config);
case 'script':
return new ScriptAction(config);
case 'proxmox':
return new ProxmoxAction(config);
default:
throw new Error(`Unknown action type: ${(config as IActionConfig).type}`);
}

View File

@@ -0,0 +1,812 @@
import * as fs from 'node:fs';
import * as os from 'node:os';
import process from 'node:process';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { Action, type IActionContext } from './base-action.ts';
import { logger } from '../logger.ts';
import { PROXMOX, UI } from '../constants.ts';
const execFileAsync = promisify(execFile);
type TNodeLikeGlobal = typeof globalThis & {
process?: {
env: Record<string, string | undefined>;
};
};
/**
* ProxmoxAction - Gracefully shuts down Proxmox VMs and LXC containers
*
* Supports two operation modes:
* - CLI mode: Uses qm/pct commands directly (requires running as root on a Proxmox host)
* - API mode: Uses the Proxmox REST API via HTTPS with API token authentication
*
* In 'auto' mode (default), CLI is preferred when available, falling back to API.
*
* This action should be placed BEFORE shutdown actions in the action chain
* so that VMs are stopped before the host is shut down.
*/
export class ProxmoxAction extends Action {
readonly type = 'proxmox';
private static readonly activeRunKeys = new Set<string>();
private static findCliTool(command: string): string | null {
for (const dir of PROXMOX.CLI_TOOL_PATHS) {
const candidate = `${dir}/${command}`;
try {
if (fs.existsSync(candidate)) {
return candidate;
}
} catch (_e) {
// continue
}
}
return null;
}
/**
* Check if Proxmox CLI tools (qm, pct) are available on the system
* Used by CLI wizards and by execute() for auto-detection
*/
static detectCliAvailability(): {
available: boolean;
qmPath: string | null;
pctPath: string | null;
haManagerPath: string | null;
isRoot: boolean;
} {
const qmPath = this.findCliTool('qm');
const pctPath = this.findCliTool('pct');
const haManagerPath = this.findCliTool('ha-manager');
const isRoot = !!(process.getuid && process.getuid() === 0);
return {
available: qmPath !== null && pctPath !== null && isRoot,
qmPath,
pctPath,
haManagerPath,
isRoot,
};
}
/**
* Resolve the operation mode based on config and environment
*/
private resolveMode(): { mode: 'api' | 'cli'; qmPath: string; pctPath: string } | {
mode: 'api';
qmPath?: undefined;
pctPath?: undefined;
} {
const configuredMode = this.config.proxmoxMode || 'auto';
if (configuredMode === 'api') {
return { mode: 'api' };
}
const detection = ProxmoxAction.detectCliAvailability();
if (configuredMode === 'cli') {
if (!detection.qmPath || !detection.pctPath) {
throw new Error('CLI mode requested but qm/pct not found. Are you on a Proxmox host?');
}
if (!detection.isRoot) {
throw new Error('CLI mode requires root access');
}
return { mode: 'cli', qmPath: detection.qmPath, pctPath: detection.pctPath };
}
// Auto-detect
if (detection.available && detection.qmPath && detection.pctPath) {
return { mode: 'cli', qmPath: detection.qmPath, pctPath: detection.pctPath };
}
return { mode: 'api' };
}
/**
* Execute the Proxmox shutdown action
*/
async execute(context: IActionContext): Promise<void> {
if (!this.shouldExecute(context)) {
logger.info(
`Proxmox action skipped (trigger mode: ${
this.config.triggerMode || 'powerChangesAndThresholds'
})`,
);
return;
}
const resolved = this.resolveMode();
const node = this.config.proxmoxNode || os.hostname();
const excludeIds = new Set(this.config.proxmoxExcludeIds || []);
const stopTimeout = (this.config.proxmoxStopTimeout || PROXMOX.DEFAULT_STOP_TIMEOUT_SECONDS) *
1000;
const forceStop = this.config.proxmoxForceStop !== false; // default true
const haPolicy = this.config.proxmoxHaPolicy || 'none';
const host = this.config.proxmoxHost || PROXMOX.DEFAULT_HOST;
const port = this.config.proxmoxPort || PROXMOX.DEFAULT_PORT;
const runKey = `${resolved.mode}:${node}:${
resolved.mode === 'api' ? `${host}:${port}` : 'local'
}`;
if (ProxmoxAction.activeRunKeys.has(runKey)) {
logger.info(`Proxmox action skipped: shutdown sequence already running for node ${node}`);
return;
}
ProxmoxAction.activeRunKeys.add(runKey);
logger.log('');
logger.logBoxTitle('Proxmox VM Shutdown', UI.WIDE_BOX_WIDTH, 'warning');
logger.logBoxLine(`Mode: ${resolved.mode === 'cli' ? 'CLI (qm/pct)' : 'API (REST)'}`);
logger.logBoxLine(`Node: ${node}`);
logger.logBoxLine(`HA Policy: ${haPolicy}`);
if (resolved.mode === 'api') {
logger.logBoxLine(`API: ${host}:${port}`);
}
logger.logBoxLine(`UPS: ${context.upsName} (${context.powerStatus})`);
logger.logBoxLine(`Trigger: ${context.triggerReason}`);
if (excludeIds.size > 0) {
logger.logBoxLine(`Excluded IDs: ${[...excludeIds].join(', ')}`);
}
logger.logBoxEnd();
logger.log('');
try {
let apiContext: {
baseUrl: string;
headers: Record<string, string>;
insecure: boolean;
} | null = null;
let runningVMs: Array<{ vmid: number; name: string }>;
let runningCTs: Array<{ vmid: number; name: string }>;
if (resolved.mode === 'cli') {
runningVMs = await this.getRunningVMsCli(resolved.qmPath);
runningCTs = await this.getRunningCTsCli(resolved.pctPath);
} else {
// API mode - validate token
const tokenId = this.config.proxmoxTokenId;
const tokenSecret = this.config.proxmoxTokenSecret;
const insecure = this.config.proxmoxInsecure !== false;
if (!tokenId || !tokenSecret) {
logger.error('Proxmox API token ID and secret are required for API mode');
logger.error('Either provide tokens or run on a Proxmox host as root for CLI mode');
return;
}
apiContext = {
baseUrl: `https://${host}:${port}${PROXMOX.API_BASE}`,
headers: {
'Authorization': `PVEAPIToken=${tokenId}=${tokenSecret}`,
},
insecure,
};
runningVMs = await this.getRunningVMsApi(
apiContext.baseUrl,
node,
apiContext.headers,
apiContext.insecure,
);
runningCTs = await this.getRunningCTsApi(
apiContext.baseUrl,
node,
apiContext.headers,
apiContext.insecure,
);
}
// Filter out excluded IDs
const vmsToStop = runningVMs.filter((vm) => !excludeIds.has(vm.vmid));
const ctsToStop = runningCTs.filter((ct) => !excludeIds.has(ct.vmid));
const totalToStop = vmsToStop.length + ctsToStop.length;
if (totalToStop === 0) {
logger.info('No running VMs or containers to shut down');
return;
}
const haManagedResources = haPolicy === 'haStop'
? await this.getHaManagedResources(resolved, apiContext)
: { qemu: new Set<number>(), lxc: new Set<number>() };
const haVmsToStop = vmsToStop.filter((vm) => haManagedResources.qemu.has(vm.vmid));
const haCtsToStop = ctsToStop.filter((ct) => haManagedResources.lxc.has(ct.vmid));
let directVmsToStop = vmsToStop.filter((vm) => !haManagedResources.qemu.has(vm.vmid));
let directCtsToStop = ctsToStop.filter((ct) => !haManagedResources.lxc.has(ct.vmid));
logger.info(`Shutting down ${vmsToStop.length} VMs and ${ctsToStop.length} containers...`);
if (resolved.mode === 'cli') {
const { haManagerPath } = ProxmoxAction.detectCliAvailability();
if (haPolicy === 'haStop' && (haVmsToStop.length > 0 || haCtsToStop.length > 0)) {
if (!haManagerPath) {
logger.warn(
'ha-manager not found, falling back to direct guest shutdown for HA-managed resources',
);
directVmsToStop = [...haVmsToStop, ...directVmsToStop];
directCtsToStop = [...haCtsToStop, ...directCtsToStop];
} else {
for (const vm of haVmsToStop) {
await this.requestHaStopCli(haManagerPath, `vm:${vm.vmid}`);
logger.dim(` HA stop requested for VM ${vm.vmid} (${vm.name || 'unnamed'})`);
}
for (const ct of haCtsToStop) {
await this.requestHaStopCli(haManagerPath, `ct:${ct.vmid}`);
logger.dim(` HA stop requested for CT ${ct.vmid} (${ct.name || 'unnamed'})`);
}
}
}
for (const vm of directVmsToStop) {
await this.shutdownVMCli(resolved.qmPath, vm.vmid);
logger.dim(` Shutdown sent to VM ${vm.vmid} (${vm.name || 'unnamed'})`);
}
for (const ct of directCtsToStop) {
await this.shutdownCTCli(resolved.pctPath, ct.vmid);
logger.dim(` Shutdown sent to CT ${ct.vmid} (${ct.name || 'unnamed'})`);
}
} else if (apiContext) {
for (const vm of haVmsToStop) {
await this.requestHaStopApi(
apiContext.baseUrl,
`vm:${vm.vmid}`,
apiContext.headers,
apiContext.insecure,
);
logger.dim(` HA stop requested for VM ${vm.vmid} (${vm.name || 'unnamed'})`);
}
for (const ct of haCtsToStop) {
await this.requestHaStopApi(
apiContext.baseUrl,
`ct:${ct.vmid}`,
apiContext.headers,
apiContext.insecure,
);
logger.dim(` HA stop requested for CT ${ct.vmid} (${ct.name || 'unnamed'})`);
}
for (const vm of directVmsToStop) {
await this.shutdownVMApi(
apiContext.baseUrl,
node,
vm.vmid,
apiContext.headers,
apiContext.insecure,
);
logger.dim(` Shutdown sent to VM ${vm.vmid} (${vm.name || 'unnamed'})`);
}
for (const ct of directCtsToStop) {
await this.shutdownCTApi(
apiContext.baseUrl,
node,
ct.vmid,
apiContext.headers,
apiContext.insecure,
);
logger.dim(` Shutdown sent to CT ${ct.vmid} (${ct.name || 'unnamed'})`);
}
}
// Poll until all stopped or timeout
const allIds = [
...vmsToStop.map((vm) => ({ type: 'qemu' as const, vmid: vm.vmid, name: vm.name })),
...ctsToStop.map((ct) => ({ type: 'lxc' as const, vmid: ct.vmid, name: ct.name })),
];
const remaining = await this.waitForShutdown(allIds, resolved, node, stopTimeout);
if (remaining.length > 0 && forceStop) {
logger.warn(`${remaining.length} VMs/CTs didn't shut down gracefully, force-stopping...`);
for (const item of remaining) {
try {
if (resolved.mode === 'cli') {
if (item.type === 'qemu') {
await this.stopVMCli(resolved.qmPath, item.vmid);
} else {
await this.stopCTCli(resolved.pctPath, item.vmid);
}
} else if (apiContext) {
if (item.type === 'qemu') {
await this.stopVMApi(
apiContext.baseUrl,
node,
item.vmid,
apiContext.headers,
apiContext.insecure,
);
} else {
await this.stopCTApi(
apiContext.baseUrl,
node,
item.vmid,
apiContext.headers,
apiContext.insecure,
);
}
}
logger.dim(` Force-stopped ${item.type} ${item.vmid} (${item.name || 'unnamed'})`);
} catch (error) {
logger.error(
` Failed to force-stop ${item.type} ${item.vmid}: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
} else if (remaining.length > 0) {
logger.warn(`${remaining.length} VMs/CTs still running (force-stop disabled)`);
}
logger.success('Proxmox shutdown sequence completed');
} catch (error) {
logger.error(
`Proxmox action failed: ${error instanceof Error ? error.message : String(error)}`,
);
} finally {
ProxmoxAction.activeRunKeys.delete(runKey);
}
}
// ─── CLI-based methods ─────────────────────────────────────────────
/**
* Get list of running QEMU VMs via qm list
*/
private async getRunningVMsCli(
qmPath: string,
): Promise<Array<{ vmid: number; name: string }>> {
try {
const { stdout } = await execFileAsync(qmPath, ['list']);
return this.parseQmList(stdout);
} catch (error) {
logger.error(
`Failed to list VMs via CLI: ${error instanceof Error ? error.message : String(error)}`,
);
return [];
}
}
/**
* Get list of running LXC containers via pct list
*/
private async getRunningCTsCli(
pctPath: string,
): Promise<Array<{ vmid: number; name: string }>> {
try {
const { stdout } = await execFileAsync(pctPath, ['list']);
return this.parsePctList(stdout);
} catch (error) {
logger.error(
`Failed to list CTs via CLI: ${error instanceof Error ? error.message : String(error)}`,
);
return [];
}
}
/**
* Parse qm list output
* Format: VMID NAME STATUS MEM(MB) BOOTDISK(GB) PID
*/
private parseQmList(output: string): Array<{ vmid: number; name: string }> {
const results: Array<{ vmid: number; name: string }> = [];
const lines = output.trim().split('\n');
// Skip header line
for (let i = 1; i < lines.length; i++) {
const match = lines[i].match(/^\s*(\d+)\s+(\S+)\s+(running|stopped|paused)/);
if (match && match[3] === 'running') {
results.push({ vmid: parseInt(match[1], 10), name: match[2] });
}
}
return results;
}
/**
* Parse pct list output
* Format: VMID Status Lock Name
*/
private parsePctList(output: string): Array<{ vmid: number; name: string }> {
const results: Array<{ vmid: number; name: string }> = [];
const lines = output.trim().split('\n');
// Skip header line
for (let i = 1; i < lines.length; i++) {
const match = lines[i].match(/^\s*(\d+)\s+(running|stopped)\s+\S*\s*(.*)/);
if (match && match[2] === 'running') {
results.push({ vmid: parseInt(match[1], 10), name: match[3]?.trim() || '' });
}
}
return results;
}
private async shutdownVMCli(qmPath: string, vmid: number): Promise<void> {
await execFileAsync(qmPath, ['shutdown', String(vmid)]);
}
private async shutdownCTCli(pctPath: string, vmid: number): Promise<void> {
await execFileAsync(pctPath, ['shutdown', String(vmid)]);
}
private async stopVMCli(qmPath: string, vmid: number): Promise<void> {
await execFileAsync(qmPath, ['stop', String(vmid)]);
}
private async stopCTCli(pctPath: string, vmid: number): Promise<void> {
await execFileAsync(pctPath, ['stop', String(vmid)]);
}
/**
* Get VM/CT status via CLI
* Returns the status string (e.g., 'running', 'stopped')
*/
private async getStatusCli(
toolPath: string,
vmid: number,
): Promise<string> {
const { stdout } = await execFileAsync(toolPath, ['status', String(vmid)]);
// Output format: "status: running\n"
const status = stdout.trim().split(':')[1]?.trim() || 'unknown';
return status;
}
private async getHaManagedResources(
resolved: { mode: 'api' | 'cli'; qmPath?: string; pctPath?: string },
apiContext: {
baseUrl: string;
headers: Record<string, string>;
insecure: boolean;
} | null,
): Promise<{ qemu: Set<number>; lxc: Set<number> }> {
if (resolved.mode === 'cli') {
const { haManagerPath } = ProxmoxAction.detectCliAvailability();
if (!haManagerPath) {
return { qemu: new Set<number>(), lxc: new Set<number>() };
}
return await this.getHaManagedResourcesCli(haManagerPath);
}
if (!apiContext) {
return { qemu: new Set<number>(), lxc: new Set<number>() };
}
return await this.getHaManagedResourcesApi(
apiContext.baseUrl,
apiContext.headers,
apiContext.insecure,
);
}
private async getHaManagedResourcesCli(
haManagerPath: string,
): Promise<{ qemu: Set<number>; lxc: Set<number> }> {
try {
const { stdout } = await execFileAsync(haManagerPath, ['config']);
return this.parseHaManagerConfig(stdout);
} catch (error) {
logger.warn(
`Failed to list HA resources via CLI: ${
error instanceof Error ? error.message : String(error)
}`,
);
return { qemu: new Set<number>(), lxc: new Set<number>() };
}
}
private parseHaManagerConfig(output: string): { qemu: Set<number>; lxc: Set<number> } {
const resources = {
qemu: new Set<number>(),
lxc: new Set<number>(),
};
for (const line of output.trim().split('\n')) {
const match = line.match(/^\s*(vm|ct)\s*:\s*(\d+)\s*$/i);
if (!match) {
continue;
}
const vmid = parseInt(match[2], 10);
if (match[1].toLowerCase() === 'vm') {
resources.qemu.add(vmid);
} else {
resources.lxc.add(vmid);
}
}
return resources;
}
private async requestHaStopCli(haManagerPath: string, sid: string): Promise<void> {
await execFileAsync(haManagerPath, ['set', sid, '--state', 'stopped']);
}
// ─── API-based methods ─────────────────────────────────────────────
/**
* Make an API request to the Proxmox server
*/
private async apiRequest(
url: string,
method: string,
headers: Record<string, string>,
insecure: boolean,
body?: URLSearchParams,
): Promise<unknown> {
const requestHeaders = { ...headers };
const fetchOptions: RequestInit = {
method,
headers: requestHeaders,
};
if (body) {
requestHeaders['Content-Type'] = 'application/x-www-form-urlencoded;charset=UTF-8';
fetchOptions.body = body.toString();
}
// Use NODE_TLS_REJECT_UNAUTHORIZED for insecure mode (self-signed certs)
const nodeProcess = (globalThis as TNodeLikeGlobal).process;
if (insecure && nodeProcess?.env) {
nodeProcess.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
}
try {
const response = await fetch(url, fetchOptions);
if (!response.ok) {
const body = await response.text();
throw new Error(`Proxmox API error ${response.status}: ${body}`);
}
return await response.json();
} finally {
// Restore TLS verification
if (insecure && nodeProcess?.env) {
nodeProcess.env.NODE_TLS_REJECT_UNAUTHORIZED = '1';
}
}
}
/**
* Get list of running QEMU VMs via API
*/
private async getRunningVMsApi(
baseUrl: string,
node: string,
headers: Record<string, string>,
insecure: boolean,
): Promise<Array<{ vmid: number; name: string }>> {
try {
const response = await this.apiRequest(
`${baseUrl}/nodes/${node}/qemu`,
'GET',
headers,
insecure,
) as { data: Array<{ vmid: number; name: string; status: string }> };
return (response.data || [])
.filter((vm) => vm.status === 'running')
.map((vm) => ({ vmid: vm.vmid, name: vm.name || '' }));
} catch (error) {
logger.error(
`Failed to list VMs: ${error instanceof Error ? error.message : String(error)}`,
);
return [];
}
}
/**
* Get list of running LXC containers via API
*/
private async getRunningCTsApi(
baseUrl: string,
node: string,
headers: Record<string, string>,
insecure: boolean,
): Promise<Array<{ vmid: number; name: string }>> {
try {
const response = await this.apiRequest(
`${baseUrl}/nodes/${node}/lxc`,
'GET',
headers,
insecure,
) as { data: Array<{ vmid: number; name: string; status: string }> };
return (response.data || [])
.filter((ct) => ct.status === 'running')
.map((ct) => ({ vmid: ct.vmid, name: ct.name || '' }));
} catch (error) {
logger.error(
`Failed to list CTs: ${error instanceof Error ? error.message : String(error)}`,
);
return [];
}
}
private async getHaManagedResourcesApi(
baseUrl: string,
headers: Record<string, string>,
insecure: boolean,
): Promise<{ qemu: Set<number>; lxc: Set<number> }> {
try {
const response = await this.apiRequest(
`${baseUrl}/cluster/ha/resources`,
'GET',
headers,
insecure,
) as { data: Array<{ sid?: string }> };
const resources = {
qemu: new Set<number>(),
lxc: new Set<number>(),
};
for (const item of response.data || []) {
const match = item.sid?.match(/^(vm|ct):(\d+)$/i);
if (!match) {
continue;
}
const vmid = parseInt(match[2], 10);
if (match[1].toLowerCase() === 'vm') {
resources.qemu.add(vmid);
} else {
resources.lxc.add(vmid);
}
}
return resources;
} catch (error) {
logger.warn(
`Failed to list HA resources via API: ${
error instanceof Error ? error.message : String(error)
}`,
);
return { qemu: new Set<number>(), lxc: new Set<number>() };
}
}
private async requestHaStopApi(
baseUrl: string,
sid: string,
headers: Record<string, string>,
insecure: boolean,
): Promise<void> {
await this.apiRequest(
`${baseUrl}/cluster/ha/resources/${encodeURIComponent(sid)}`,
'PUT',
headers,
insecure,
new URLSearchParams({ state: 'stopped' }),
);
}
private async shutdownVMApi(
baseUrl: string,
node: string,
vmid: number,
headers: Record<string, string>,
insecure: boolean,
): Promise<void> {
await this.apiRequest(
`${baseUrl}/nodes/${node}/qemu/${vmid}/status/shutdown`,
'POST',
headers,
insecure,
);
}
private async shutdownCTApi(
baseUrl: string,
node: string,
vmid: number,
headers: Record<string, string>,
insecure: boolean,
): Promise<void> {
await this.apiRequest(
`${baseUrl}/nodes/${node}/lxc/${vmid}/status/shutdown`,
'POST',
headers,
insecure,
);
}
private async stopVMApi(
baseUrl: string,
node: string,
vmid: number,
headers: Record<string, string>,
insecure: boolean,
): Promise<void> {
await this.apiRequest(
`${baseUrl}/nodes/${node}/qemu/${vmid}/status/stop`,
'POST',
headers,
insecure,
);
}
private async stopCTApi(
baseUrl: string,
node: string,
vmid: number,
headers: Record<string, string>,
insecure: boolean,
): Promise<void> {
await this.apiRequest(
`${baseUrl}/nodes/${node}/lxc/${vmid}/status/stop`,
'POST',
headers,
insecure,
);
}
// ─── Shared methods ────────────────────────────────────────────────
/**
* Wait for VMs/CTs to shut down, return any that are still running after timeout
*/
private async waitForShutdown(
items: Array<{ type: 'qemu' | 'lxc'; vmid: number; name: string }>,
resolved: { mode: 'api' | 'cli'; qmPath?: string; pctPath?: string },
node: string,
timeout: number,
): Promise<Array<{ type: 'qemu' | 'lxc'; vmid: number; name: string }>> {
const startTime = Date.now();
let remaining = [...items];
while (remaining.length > 0 && (Date.now() - startTime) < timeout) {
// Wait before polling
await new Promise((resolve) =>
setTimeout(resolve, PROXMOX.STATUS_POLL_INTERVAL_SECONDS * 1000)
);
// Check which are still running
const stillRunning: typeof remaining = [];
for (const item of remaining) {
try {
let status: string;
if (resolved.mode === 'cli') {
const toolPath = item.type === 'qemu' ? resolved.qmPath! : resolved.pctPath!;
status = await this.getStatusCli(toolPath, item.vmid);
} else {
const host = this.config.proxmoxHost || PROXMOX.DEFAULT_HOST;
const port = this.config.proxmoxPort || PROXMOX.DEFAULT_PORT;
const insecure = this.config.proxmoxInsecure !== false;
const baseUrl = `https://${host}:${port}${PROXMOX.API_BASE}`;
const headers: Record<string, string> = {
'Authorization':
`PVEAPIToken=${this.config.proxmoxTokenId}=${this.config.proxmoxTokenSecret}`,
};
const statusUrl = `${baseUrl}/nodes/${node}/${item.type}/${item.vmid}/status/current`;
const response = await this.apiRequest(statusUrl, 'GET', headers, insecure) as {
data: { status: string };
};
status = response.data?.status || 'unknown';
}
if (status === 'running') {
stillRunning.push(item);
} else {
logger.dim(` ${item.type} ${item.vmid} (${item.name}) stopped`);
}
} catch (_error) {
// If we can't check status, assume it might still be running
stillRunning.push(item);
}
}
remaining = stillRunning;
if (remaining.length > 0) {
const elapsed = Math.round((Date.now() - startTime) / 1000);
logger.dim(` Waiting... ${remaining.length} still running (${elapsed}s elapsed)`);
}
}
return remaining;
}
}

View File

@@ -15,6 +15,7 @@ const execFileAsync = promisify(execFile);
*/
export class ShutdownAction extends Action {
readonly type = 'shutdown';
private static scheduledDelayMinutes: number | null = null;
/**
* Override shouldExecute to add shutdown-specific safety checks
@@ -34,10 +35,17 @@ export class ShutdownAction extends Action {
// CRITICAL SAFETY CHECK: Shutdown should NEVER trigger unless UPS is on battery
// A low battery while on grid power is not an emergency (the battery is charging)
// When UPS is unreachable, we don't know the actual state - don't trigger false shutdown
if (context.powerStatus !== 'onBattery') {
logger.info(
`Shutdown action skipped: UPS is not on battery (status: ${context.powerStatus})`,
);
if (context.powerStatus === 'unreachable') {
logger.info(
`Shutdown action skipped: UPS is unreachable (communication failure, actual state unknown)`,
);
} else {
logger.info(
`Shutdown action skipped: UPS is not on battery (status: ${context.powerStatus})`,
);
}
return false;
}
@@ -117,7 +125,26 @@ export class ShutdownAction extends Action {
return;
}
const shutdownDelay = this.config.shutdownDelay || SHUTDOWN.DEFAULT_DELAY_MINUTES;
const shutdownDelay = this.config.shutdownDelay ?? SHUTDOWN.DEFAULT_DELAY_MINUTES;
if (
ShutdownAction.scheduledDelayMinutes !== null &&
ShutdownAction.scheduledDelayMinutes <= shutdownDelay
) {
logger.info(
`Shutdown action skipped: shutdown already scheduled in ${ShutdownAction.scheduledDelayMinutes} minutes`,
);
return;
}
if (
ShutdownAction.scheduledDelayMinutes !== null &&
ShutdownAction.scheduledDelayMinutes > shutdownDelay
) {
logger.warn(
`Shutdown already scheduled in ${ShutdownAction.scheduledDelayMinutes} minutes, rescheduling to ${shutdownDelay} minutes`,
);
}
logger.log('');
logger.logBoxTitle('Initiating System Shutdown', UI.WIDE_BOX_WIDTH, 'error');
@@ -132,6 +159,7 @@ export class ShutdownAction extends Action {
try {
await this.executeShutdownCommand(shutdownDelay);
ShutdownAction.scheduledDelayMinutes = shutdownDelay;
} catch (error) {
logger.error(
`Shutdown command failed: ${error instanceof Error ? error.message : String(error)}`,
@@ -220,6 +248,7 @@ export class ShutdownAction extends Action {
logger.log(`Trying alternative shutdown method: ${cmdPath} ${alt.args.join(' ')}`);
await execFileAsync(cmdPath, alt.args);
logger.log(`Alternative method ${alt.cmd} succeeded`);
ShutdownAction.scheduledDelayMinutes = 0;
return; // Exit if successful
}
} catch (_altError) {

View File

@@ -14,7 +14,7 @@ export interface IWebhookPayload {
/** UPS name */
upsName: string;
/** Current power status */
powerStatus: 'online' | 'onBattery' | 'unknown';
powerStatus: 'online' | 'onBattery' | 'unknown' | 'unreachable';
/** Current battery capacity percentage */
batteryCapacity: number;
/** Current battery runtime in minutes */

View File

@@ -19,15 +19,16 @@ export class NupstCli {
/**
* Parse command line arguments and execute the appropriate command
* @param args Command line arguments (process.argv)
* @param args Command line arguments excluding runtime and script path
*/
public async parseAndExecute(args: string[]): Promise<void> {
// Extract debug and version flags from any position
const debugOptions = this.extractDebugOptions(args);
if (debugOptions.debugMode) {
logger.log('Debug mode enabled');
// Enable debug mode in the SNMP client
// Enable debug mode in both protocol clients
this.nupst.getSnmp().enableDebug();
this.nupst.getUpsd().enableDebug();
}
// Check for version flag
@@ -37,8 +38,8 @@ export class NupstCli {
}
// Get the command (default to help if none provided)
const command = debugOptions.cleanedArgs[2] || 'help';
const commandArgs = debugOptions.cleanedArgs.slice(3);
const command = debugOptions.cleanedArgs[0] || 'help';
const commandArgs = debugOptions.cleanedArgs.slice(1);
// Route to the appropriate command handler
await this.executeCommand(command, commandArgs, debugOptions.debugMode);
@@ -97,7 +98,7 @@ export class NupstCli {
await serviceHandler.start();
break;
case 'status':
await serviceHandler.status();
await serviceHandler.status(debugMode);
break;
case 'logs':
await serviceHandler.logs();
@@ -203,6 +204,12 @@ export class NupstCli {
await actionHandler.add(upsId);
break;
}
case 'edit': {
const upsId = subcommandArgs[0];
const actionIndex = subcommandArgs[1];
await actionHandler.edit(upsId, actionIndex);
break;
}
case 'remove':
case 'rm': {
const upsId = subcommandArgs[0];
@@ -259,7 +266,13 @@ export class NupstCli {
// Handle top-level commands
switch (command) {
case 'update':
case 'pause':
await serviceHandler.pause(commandArgs);
break;
case 'resume':
await serviceHandler.resume();
break;
case 'upgrade':
await serviceHandler.update();
break;
case 'uninstall':
@@ -351,18 +364,32 @@ export class NupstCli {
// UPS Devices Table
if (config.upsDevices.length > 0) {
const upsRows = config.upsDevices.map((ups) => ({
name: ups.name,
id: theme.dim(ups.id),
host: `${ups.snmp.host}:${ups.snmp.port}`,
model: ups.snmp.upsModel || 'cyberpower',
actions: `${(ups.actions || []).length} configured`,
groups: ups.groups.length > 0 ? ups.groups.join(', ') : theme.dim('None'),
}));
const upsRows = config.upsDevices.map((ups) => {
const protocol = ups.protocol || 'snmp';
let host = 'N/A';
let model = '';
if (protocol === 'upsd' && ups.upsd) {
host = `${ups.upsd.host}:${ups.upsd.port}`;
model = `NUT:${ups.upsd.upsName}`;
} else if (ups.snmp) {
host = `${ups.snmp.host}:${ups.snmp.port}`;
model = ups.snmp.upsModel || 'cyberpower';
}
return {
name: ups.name,
id: theme.dim(ups.id),
protocol: protocol.toUpperCase(),
host,
model,
actions: `${(ups.actions || []).length} configured`,
groups: ups.groups.length > 0 ? ups.groups.join(', ') : theme.dim('None'),
};
});
const upsColumns: ITableColumn[] = [
{ header: 'Name', key: 'name', align: 'left', color: theme.highlight },
{ header: 'ID', key: 'id', align: 'left' },
{ header: 'Protocol', key: 'protocol', align: 'left' },
{ header: 'Host:Port', key: 'host', align: 'left', color: theme.info },
{ header: 'Model', key: 'model', align: 'left' },
{ header: 'Actions', key: 'actions', align: 'left' },
@@ -534,7 +561,9 @@ export class NupstCli {
this.printCommand('action <subcommand>', 'Manage UPS actions');
this.printCommand('feature <subcommand>', 'Manage optional features');
this.printCommand('config [show]', 'Display current configuration');
this.printCommand('update', 'Update NUPST from repository', theme.dim('(requires root)'));
this.printCommand('pause [--duration <time>]', 'Pause action monitoring');
this.printCommand('resume', 'Resume action monitoring');
this.printCommand('upgrade', 'Upgrade NUPST from repository', theme.dim('(requires root)'));
this.printCommand('uninstall', 'Completely remove NUPST', theme.dim('(requires root)'));
this.printCommand('help, --help, -h', 'Show this help message');
this.printCommand('--version, -v', 'Show version information');
@@ -703,6 +732,7 @@ Usage:
Subcommands:
add <ups-id|group-id> - Add a new action to a UPS or group interactively
edit <ups-id|group-id> <index> - Edit an action by index
remove <ups-id|group-id> <index> - Remove an action by index (alias: rm)
list [ups-id|group-id] - List all actions (optionally for specific target) (alias: ls)
@@ -713,6 +743,7 @@ Examples:
nupst action list - List actions for all UPS devices and groups
nupst action list default - List actions for UPS or group with ID 'default'
nupst action add default - Add a new action to UPS or group 'default'
nupst action edit default 0 - Edit action at index 0 on UPS or group 'default'
nupst action remove default 0 - Remove action at index 0 from UPS or group 'default'
nupst action add dc-rack-1 - Add a new action to group 'dc-rack-1'
`);

View File

@@ -3,6 +3,8 @@ import { Nupst } from '../nupst.ts';
import { type ITableColumn, logger } from '../logger.ts';
import { symbols, theme } from '../colors.ts';
import type { IActionConfig } from '../actions/base-action.ts';
import { ProxmoxAction } from '../actions/proxmox-action.ts';
import { SHUTDOWN } from '../constants.ts';
import type { IGroupConfig, IUpsConfig } from '../daemon.ts';
import * as helpers from '../helpers/index.ts';
@@ -39,112 +41,29 @@ export class ActionHandler {
}
const config = await this.nupst.getDaemon().loadConfig();
// Check if it's a UPS
const ups = config.upsDevices.find((u) => u.id === targetId);
// Check if it's a group
const group = config.groups?.find((g) => g.id === targetId);
if (!ups && !group) {
logger.error(`UPS or Group with ID '${targetId}' not found`);
logger.log('');
logger.log(
` ${theme.dim('List available UPS devices:')} ${theme.command('nupst ups list')}`,
);
logger.log(` ${theme.dim('List available groups:')} ${theme.command('nupst group list')}`);
logger.log('');
process.exit(1);
}
const target = ups || group;
const targetType = ups ? 'UPS' : 'Group';
const targetName = ups ? ups.name : group!.name;
const targetSnapshot = this.resolveActionTarget(config, targetId);
await helpers.withPrompt(async (prompt) => {
logger.log('');
logger.info(`Add Action to ${targetType} ${theme.highlight(targetName)}`);
logger.info(
`Add Action to ${targetSnapshot.targetType} ${
theme.highlight(targetSnapshot.targetName)
}`,
);
logger.log('');
// Action type (currently only shutdown is supported)
const type = 'shutdown';
logger.log(` ${theme.dim('Action type:')} ${theme.highlight('shutdown')}`);
// Battery threshold
const batteryStr = await prompt(
` ${theme.dim('Battery threshold')} ${theme.dim('(%):')} `,
);
const battery = parseInt(batteryStr, 10);
if (isNaN(battery) || battery < 0 || battery > 100) {
logger.error('Invalid battery threshold. Must be 0-100.');
process.exit(1);
}
// Runtime threshold
const runtimeStr = await prompt(
` ${theme.dim('Runtime threshold')} ${theme.dim('(minutes):')} `,
);
const runtime = parseInt(runtimeStr, 10);
if (isNaN(runtime) || runtime < 0) {
logger.error('Invalid runtime threshold. Must be >= 0.');
process.exit(1);
}
// Trigger mode
logger.log('');
logger.log(` ${theme.dim('Trigger mode:')}`);
logger.log(
` ${theme.dim('1)')} onlyPowerChanges - Trigger only when power status changes`,
);
logger.log(
` ${theme.dim('2)')} onlyThresholds - Trigger only when thresholds are violated`,
);
logger.log(
` ${
theme.dim('3)')
} powerChangesAndThresholds - Trigger on power change AND thresholds`,
);
logger.log(` ${theme.dim('4)')} anyChange - Trigger on any status change`);
const triggerChoice = await prompt(` ${theme.dim('Choice')} ${theme.dim('[2]:')} `);
const triggerModeMap: Record<string, string> = {
'1': 'onlyPowerChanges',
'2': 'onlyThresholds',
'3': 'powerChangesAndThresholds',
'4': 'anyChange',
'': 'onlyThresholds', // Default
};
const triggerMode = triggerModeMap[triggerChoice] || 'onlyThresholds';
// Shutdown delay
const delayStr = await prompt(
` ${theme.dim('Shutdown delay')} ${theme.dim('(seconds) [5]:')} `,
);
const shutdownDelay = delayStr ? parseInt(delayStr, 10) : 5;
if (isNaN(shutdownDelay) || shutdownDelay < 0) {
logger.error('Invalid shutdown delay. Must be >= 0.');
process.exit(1);
}
// Create the action
const newAction: IActionConfig = {
type,
thresholds: {
battery,
runtime,
},
triggerMode: triggerMode as IActionConfig['triggerMode'],
shutdownDelay,
};
const newAction = await this.promptForActionConfig(prompt);
// Add to target (UPS or group)
if (!target!.actions) {
target!.actions = [];
if (!targetSnapshot.target.actions) {
targetSnapshot.target.actions = [];
}
target!.actions.push(newAction);
targetSnapshot.target.actions.push(newAction);
await this.nupst.getDaemon().saveConfig(config);
logger.log('');
logger.success(`Action added to ${targetType} ${targetName}`);
logger.success(`Action added to ${targetSnapshot.targetType} ${targetSnapshot.targetName}`);
logger.log(` ${theme.dim('Changes saved and will be applied automatically')}`);
logger.log('');
});
@@ -156,6 +75,98 @@ export class ActionHandler {
}
}
/**
* Edit an existing action on a UPS or group
*/
public async edit(targetId?: string, actionIndexStr?: string): Promise<void> {
try {
await helpers.withPrompt(async (prompt) => {
await this.runEditProcess(targetId, actionIndexStr, prompt);
});
} catch (error) {
logger.error(
`Failed to edit action: ${error instanceof Error ? error.message : String(error)}`,
);
process.exit(1);
}
}
/**
* Run the interactive process to edit an action
*/
public async runEditProcess(
targetId: string | undefined,
actionIndexStr: string | undefined,
prompt: (question: string) => Promise<string>,
): Promise<void> {
if (!targetId || !actionIndexStr) {
logger.error('Target ID and action index are required');
logger.log(
` ${theme.dim('Usage:')} ${
theme.command('nupst action edit <ups-id|group-id> <action-index>')
}`,
);
logger.log('');
logger.log(` ${theme.dim('List actions:')} ${theme.command('nupst action list')}`);
logger.log('');
process.exit(1);
}
const actionIndex = parseInt(actionIndexStr, 10);
if (isNaN(actionIndex) || actionIndex < 0) {
logger.error('Invalid action index. Must be >= 0.');
process.exit(1);
}
const config = await this.nupst.getDaemon().loadConfig();
const targetSnapshot = this.resolveActionTarget(config, targetId);
if (!targetSnapshot.target.actions || targetSnapshot.target.actions.length === 0) {
logger.error(
`No actions configured for ${targetSnapshot.targetType} '${targetSnapshot.targetName}'`,
);
logger.log('');
process.exit(1);
}
if (actionIndex >= targetSnapshot.target.actions.length) {
logger.error(
`Invalid action index. ${targetSnapshot.targetType} '${targetSnapshot.targetName}' has ${targetSnapshot.target.actions.length} action(s) (index 0-${
targetSnapshot.target.actions.length - 1
})`,
);
logger.log('');
logger.log(
` ${theme.dim('List actions:')} ${theme.command(`nupst action list ${targetId}`)}`,
);
logger.log('');
process.exit(1);
}
const currentAction = targetSnapshot.target.actions[actionIndex];
logger.log('');
logger.info(
`Edit Action ${theme.highlight(String(actionIndex))} on ${targetSnapshot.targetType} ${
theme.highlight(targetSnapshot.targetName)
}`,
);
logger.log(` ${theme.dim('Current type:')} ${theme.highlight(currentAction.type)}`);
logger.log('');
const updatedAction = await this.promptForActionConfig(prompt, currentAction);
targetSnapshot.target.actions[actionIndex] = updatedAction;
await this.nupst.getDaemon().saveConfig(config);
logger.log('');
logger.success(`Action updated on ${targetSnapshot.targetType} ${targetSnapshot.targetName}`);
logger.log(` ${theme.dim('Index:')} ${actionIndex}`);
logger.log(` ${theme.dim('Type:')} ${updatedAction.type}`);
logger.log(` ${theme.dim('Changes saved and will be applied automatically')}`);
logger.log('');
}
/**
* Remove an action from a UPS or group
*/
@@ -320,6 +331,408 @@ export class ActionHandler {
}
}
private resolveActionTarget(
config: { upsDevices: IUpsConfig[]; groups?: IGroupConfig[] },
targetId: string,
): { target: IUpsConfig | IGroupConfig; targetType: 'UPS' | 'Group'; targetName: string } {
const ups = config.upsDevices.find((u) => u.id === targetId);
const group = config.groups?.find((g) => g.id === targetId);
if (!ups && !group) {
logger.error(`UPS or Group with ID '${targetId}' not found`);
logger.log('');
logger.log(
` ${theme.dim('List available UPS devices:')} ${theme.command('nupst ups list')}`,
);
logger.log(` ${theme.dim('List available groups:')} ${theme.command('nupst group list')}`);
logger.log('');
process.exit(1);
}
return {
target: (ups || group)!,
targetType: ups ? 'UPS' : 'Group',
targetName: ups ? ups.name : group!.name,
};
}
private isClearInput(input: string): boolean {
return input.trim().toLowerCase() === 'clear';
}
private getActionTypeValue(action?: IActionConfig): number {
switch (action?.type) {
case 'webhook':
return 2;
case 'script':
return 3;
case 'proxmox':
return 4;
case 'shutdown':
default:
return 1;
}
}
private getTriggerModeValue(action?: IActionConfig): number {
switch (action?.triggerMode) {
case 'onlyPowerChanges':
return 1;
case 'powerChangesAndThresholds':
return 3;
case 'anyChange':
return 4;
case 'onlyThresholds':
default:
return 2;
}
}
private async promptForActionConfig(
prompt: (question: string) => Promise<string>,
existingAction?: IActionConfig,
): Promise<IActionConfig> {
logger.log(` ${theme.dim('Action Type:')}`);
logger.log(` ${theme.dim('1)')} Shutdown (system shutdown)`);
logger.log(` ${theme.dim('2)')} Webhook (HTTP notification)`);
logger.log(` ${theme.dim('3)')} Custom Script (run .sh file from /etc/nupst)`);
logger.log(
` ${theme.dim('4)')} Proxmox (gracefully shut down VMs/LXCs before host shutdown)`,
);
const defaultTypeValue = this.getActionTypeValue(existingAction);
const typeInput = await prompt(
` ${theme.dim('Select action type')} ${theme.dim(`[${defaultTypeValue}]:`)} `,
);
const typeValue = parseInt(typeInput, 10) || defaultTypeValue;
const newAction: Partial<IActionConfig> = {};
if (typeValue === 1) {
const shutdownAction = existingAction?.type === 'shutdown' ? existingAction : undefined;
const defaultShutdownDelay = this.nupst.getDaemon().getConfig().defaultShutdownDelay ??
SHUTDOWN.DEFAULT_DELAY_MINUTES;
newAction.type = 'shutdown';
const delayPrompt = shutdownAction?.shutdownDelay !== undefined
? ` ${theme.dim('Shutdown delay')} ${
theme.dim(
`(minutes, 'clear' = default ${defaultShutdownDelay}) [${shutdownAction.shutdownDelay}]:`,
)
} `
: ` ${theme.dim('Shutdown delay')} ${
theme.dim(`(minutes, leave empty for default ${defaultShutdownDelay}):`)
} `;
const delayInput = await prompt(delayPrompt);
if (this.isClearInput(delayInput)) {
// Leave unset so the config-level default is used.
} else if (delayInput.trim()) {
const shutdownDelay = parseInt(delayInput, 10);
if (isNaN(shutdownDelay) || shutdownDelay < 0) {
logger.error('Invalid shutdown delay. Must be >= 0.');
process.exit(1);
}
newAction.shutdownDelay = shutdownDelay;
} else if (shutdownAction?.shutdownDelay !== undefined) {
newAction.shutdownDelay = shutdownAction.shutdownDelay;
}
} else if (typeValue === 2) {
const webhookAction = existingAction?.type === 'webhook' ? existingAction : undefined;
newAction.type = 'webhook';
const webhookUrlInput = await prompt(
` ${theme.dim('Webhook URL')} ${
theme.dim(webhookAction?.webhookUrl ? `[${webhookAction.webhookUrl}]:` : ':')
} `,
);
const webhookUrl = webhookUrlInput.trim() || webhookAction?.webhookUrl || '';
if (!webhookUrl) {
logger.error('Webhook URL is required.');
process.exit(1);
}
newAction.webhookUrl = webhookUrl;
logger.log('');
logger.log(` ${theme.dim('HTTP Method:')}`);
logger.log(` ${theme.dim('1)')} POST (JSON body)`);
logger.log(` ${theme.dim('2)')} GET (query parameters)`);
const defaultMethodValue = webhookAction?.webhookMethod === 'GET' ? 2 : 1;
const methodInput = await prompt(
` ${theme.dim('Select method')} ${theme.dim(`[${defaultMethodValue}]:`)} `,
);
const methodValue = parseInt(methodInput, 10) || defaultMethodValue;
newAction.webhookMethod = methodValue === 2 ? 'GET' : 'POST';
const currentWebhookTimeout = webhookAction?.webhookTimeout;
const timeoutPrompt = currentWebhookTimeout !== undefined
? ` ${theme.dim('Timeout in seconds')} ${
theme.dim(`('clear' to unset) [${Math.floor(currentWebhookTimeout / 1000)}]:`)
} `
: ` ${theme.dim('Timeout in seconds')} ${theme.dim('[10]:')} `;
const timeoutInput = await prompt(timeoutPrompt);
if (this.isClearInput(timeoutInput)) {
// Leave unset.
} else if (timeoutInput.trim()) {
const timeout = parseInt(timeoutInput, 10);
if (isNaN(timeout) || timeout < 0) {
logger.error('Invalid webhook timeout. Must be >= 0.');
process.exit(1);
}
newAction.webhookTimeout = timeout * 1000;
} else if (currentWebhookTimeout !== undefined) {
newAction.webhookTimeout = currentWebhookTimeout;
}
} else if (typeValue === 3) {
const scriptAction = existingAction?.type === 'script' ? existingAction : undefined;
newAction.type = 'script';
const scriptPathInput = await prompt(
` ${theme.dim('Script filename (in /etc/nupst/, must end with .sh)')} ${
theme.dim(scriptAction?.scriptPath ? `[${scriptAction.scriptPath}]:` : ':')
} `,
);
const scriptPath = scriptPathInput.trim() || scriptAction?.scriptPath || '';
if (!scriptPath || !scriptPath.endsWith('.sh')) {
logger.error('Script path must end with .sh.');
process.exit(1);
}
newAction.scriptPath = scriptPath;
const currentScriptTimeout = scriptAction?.scriptTimeout;
const timeoutPrompt = currentScriptTimeout !== undefined
? ` ${theme.dim('Script timeout in seconds')} ${
theme.dim(`('clear' to unset) [${Math.floor(currentScriptTimeout / 1000)}]:`)
} `
: ` ${theme.dim('Script timeout in seconds')} ${theme.dim('[60]:')} `;
const timeoutInput = await prompt(timeoutPrompt);
if (this.isClearInput(timeoutInput)) {
// Leave unset.
} else if (timeoutInput.trim()) {
const timeout = parseInt(timeoutInput, 10);
if (isNaN(timeout) || timeout < 0) {
logger.error('Invalid script timeout. Must be >= 0.');
process.exit(1);
}
newAction.scriptTimeout = timeout * 1000;
} else if (currentScriptTimeout !== undefined) {
newAction.scriptTimeout = currentScriptTimeout;
}
} else if (typeValue === 4) {
const proxmoxAction = existingAction?.type === 'proxmox' ? existingAction : undefined;
const detection = ProxmoxAction.detectCliAvailability();
let useApiMode = false;
newAction.type = 'proxmox';
if (detection.available) {
logger.log('');
logger.success('Proxmox CLI tools detected (qm/pct).');
logger.dim(` qm: ${detection.qmPath}`);
logger.dim(` pct: ${detection.pctPath}`);
if (proxmoxAction) {
logger.log('');
logger.log(` ${theme.dim('Proxmox mode:')}`);
logger.log(` ${theme.dim('1)')} CLI (local qm/pct tools)`);
logger.log(` ${theme.dim('2)')} API (REST token authentication)`);
const defaultModeValue = proxmoxAction.proxmoxMode === 'api' ? 2 : 1;
const modeInput = await prompt(
` ${theme.dim('Select Proxmox mode')} ${theme.dim(`[${defaultModeValue}]:`)} `,
);
const modeValue = parseInt(modeInput, 10) || defaultModeValue;
useApiMode = modeValue === 2;
}
} else {
logger.log('');
if (!detection.isRoot) {
logger.warn('Not running as root - CLI mode unavailable, using API mode.');
} else {
logger.warn('Proxmox CLI tools (qm/pct) not found - using API mode.');
}
useApiMode = true;
}
if (useApiMode) {
logger.log('');
logger.info('Proxmox API Settings:');
logger.dim('Create a token with: pveum user token add root@pam nupst --privsep=0');
const currentHost = proxmoxAction?.proxmoxHost || 'localhost';
const pxHost = await prompt(
` ${theme.dim('Proxmox Host')} ${theme.dim(`[${currentHost}]:`)} `,
);
newAction.proxmoxHost = pxHost.trim() || currentHost;
const currentPort = proxmoxAction?.proxmoxPort || 8006;
const pxPortInput = await prompt(
` ${theme.dim('Proxmox API Port')} ${theme.dim(`[${currentPort}]:`)} `,
);
const pxPort = parseInt(pxPortInput, 10);
newAction.proxmoxPort = pxPortInput.trim() && !isNaN(pxPort) ? pxPort : currentPort;
const pxNodePrompt = proxmoxAction?.proxmoxNode
? ` ${theme.dim('Proxmox Node Name')} ${
theme.dim(`('clear' = auto-detect) [${proxmoxAction.proxmoxNode}]:`)
} `
: ` ${theme.dim('Proxmox Node Name')} ${theme.dim('(empty = auto-detect):')} `;
const pxNode = await prompt(pxNodePrompt);
if (this.isClearInput(pxNode)) {
// Leave unset so hostname auto-detection is used.
} else if (pxNode.trim()) {
newAction.proxmoxNode = pxNode.trim();
} else if (proxmoxAction?.proxmoxNode) {
newAction.proxmoxNode = proxmoxAction.proxmoxNode;
}
const currentTokenId = proxmoxAction?.proxmoxTokenId || '';
const tokenIdInput = await prompt(
` ${theme.dim('API Token ID (e.g., root@pam!nupst)')} ${
theme.dim(currentTokenId ? `[${currentTokenId}]:` : ':')
} `,
);
const tokenId = tokenIdInput.trim() || currentTokenId;
if (!tokenId) {
logger.error('Token ID is required for API mode.');
process.exit(1);
}
newAction.proxmoxTokenId = tokenId;
const currentTokenSecret = proxmoxAction?.proxmoxTokenSecret || '';
const tokenSecretInput = await prompt(
` ${theme.dim('API Token Secret')} ${theme.dim(currentTokenSecret ? '[*****]:' : ':')} `,
);
const tokenSecret = tokenSecretInput.trim() || currentTokenSecret;
if (!tokenSecret) {
logger.error('Token Secret is required for API mode.');
process.exit(1);
}
newAction.proxmoxTokenSecret = tokenSecret;
const defaultInsecure = proxmoxAction?.proxmoxInsecure !== false;
const insecureInput = await prompt(
` ${theme.dim('Skip TLS verification (self-signed cert)?')} ${
theme.dim(defaultInsecure ? '(Y/n):' : '(y/N):')
} `,
);
newAction.proxmoxInsecure = insecureInput.trim()
? insecureInput.toLowerCase() !== 'n'
: defaultInsecure;
newAction.proxmoxMode = 'api';
} else {
newAction.proxmoxMode = 'cli';
}
const currentExcludeIds = proxmoxAction?.proxmoxExcludeIds || [];
const excludePrompt = currentExcludeIds.length > 0
? ` ${theme.dim('VM/CT IDs to exclude')} ${
theme.dim(`(comma-separated, 'clear' = none) [${currentExcludeIds.join(',')}]:`)
} `
: ` ${theme.dim('VM/CT IDs to exclude (comma-separated, or empty):')} `;
const excludeInput = await prompt(excludePrompt);
if (this.isClearInput(excludeInput)) {
newAction.proxmoxExcludeIds = [];
} else if (excludeInput.trim()) {
newAction.proxmoxExcludeIds = excludeInput.split(',').map((s) => parseInt(s.trim(), 10))
.filter((n) => !isNaN(n));
} else if (currentExcludeIds.length > 0) {
newAction.proxmoxExcludeIds = [...currentExcludeIds];
}
const currentStopTimeout = proxmoxAction?.proxmoxStopTimeout;
const stopTimeoutPrompt = currentStopTimeout !== undefined
? ` ${theme.dim('VM shutdown timeout in seconds')} ${
theme.dim(`('clear' to unset) [${currentStopTimeout}]:`)
} `
: ` ${theme.dim('VM shutdown timeout in seconds')} ${theme.dim('[120]:')} `;
const timeoutInput = await prompt(stopTimeoutPrompt);
if (this.isClearInput(timeoutInput)) {
// Leave unset.
} else if (timeoutInput.trim()) {
const stopTimeout = parseInt(timeoutInput, 10);
if (isNaN(stopTimeout) || stopTimeout < 0) {
logger.error('Invalid VM shutdown timeout. Must be >= 0.');
process.exit(1);
}
newAction.proxmoxStopTimeout = stopTimeout;
} else if (currentStopTimeout !== undefined) {
newAction.proxmoxStopTimeout = currentStopTimeout;
}
const defaultForceStop = proxmoxAction?.proxmoxForceStop !== false;
const forceInput = await prompt(
` ${theme.dim("Force-stop VMs that don't shut down in time?")} ${
theme.dim(defaultForceStop ? '(Y/n):' : '(y/N):')
} `,
);
newAction.proxmoxForceStop = forceInput.trim()
? forceInput.toLowerCase() !== 'n'
: defaultForceStop;
const defaultHaPolicyValue = proxmoxAction?.proxmoxHaPolicy === 'haStop' ? 2 : 1;
const haPolicyInput = await prompt(
` ${theme.dim('HA-managed guest handling')} ${
theme.dim(`([1] none, 2 haStop) [${defaultHaPolicyValue}]:`)
} `,
);
const haPolicyValue = parseInt(haPolicyInput, 10) || defaultHaPolicyValue;
newAction.proxmoxHaPolicy = haPolicyValue === 2 ? 'haStop' : 'none';
} else {
logger.error('Invalid action type.');
process.exit(1);
}
logger.log('');
const defaultBatteryThreshold = existingAction?.thresholds?.battery ?? 60;
const batteryInput = await prompt(
` ${theme.dim('Battery threshold')} ${theme.dim(`(%) [${defaultBatteryThreshold}]:`)} `,
);
const battery = batteryInput.trim() ? parseInt(batteryInput, 10) : defaultBatteryThreshold;
if (isNaN(battery) || battery < 0 || battery > 100) {
logger.error('Invalid battery threshold. Must be 0-100.');
process.exit(1);
}
const defaultRuntimeThreshold = existingAction?.thresholds?.runtime ?? 20;
const runtimeInput = await prompt(
` ${theme.dim('Runtime threshold')} ${
theme.dim(`(minutes) [${defaultRuntimeThreshold}]:`)
} `,
);
const runtime = runtimeInput.trim() ? parseInt(runtimeInput, 10) : defaultRuntimeThreshold;
if (isNaN(runtime) || runtime < 0) {
logger.error('Invalid runtime threshold. Must be >= 0.');
process.exit(1);
}
newAction.thresholds = { battery, runtime };
logger.log('');
logger.log(` ${theme.dim('Trigger mode:')}`);
logger.log(
` ${theme.dim('1)')} onlyPowerChanges - Trigger only when power status changes`,
);
logger.log(
` ${theme.dim('2)')} onlyThresholds - Trigger only when thresholds are violated`,
);
logger.log(
` ${theme.dim('3)')} powerChangesAndThresholds - Trigger on power change AND thresholds`,
);
logger.log(` ${theme.dim('4)')} anyChange - Trigger on any status change`);
const defaultTriggerValue = this.getTriggerModeValue(existingAction);
const triggerChoice = await prompt(
` ${theme.dim('Choice')} ${theme.dim(`[${defaultTriggerValue}]:`)} `,
);
const triggerValue = parseInt(triggerChoice, 10) || defaultTriggerValue;
const triggerModeMap: Record<number, NonNullable<IActionConfig['triggerMode']>> = {
1: 'onlyPowerChanges',
2: 'onlyThresholds',
3: 'powerChangesAndThresholds',
4: 'anyChange',
};
newAction.triggerMode = triggerModeMap[triggerValue] || 'onlyThresholds';
return newAction as IActionConfig;
}
/**
* Display actions for a single UPS or Group
*/
@@ -346,17 +759,43 @@ export class ActionHandler {
{ header: 'Battery', key: 'battery', align: 'right' },
{ header: 'Runtime', key: 'runtime', align: 'right' },
{ header: 'Trigger Mode', key: 'triggerMode', align: 'left' },
{ header: 'Delay', key: 'delay', align: 'right' },
{ header: 'Details', key: 'details', align: 'left' },
];
const rows = target.actions.map((action, index) => ({
index: theme.dim(index.toString()),
type: theme.highlight(action.type),
battery: action.thresholds ? `${action.thresholds.battery}%` : theme.dim('N/A'),
runtime: action.thresholds ? `${action.thresholds.runtime}min` : theme.dim('N/A'),
triggerMode: theme.dim(action.triggerMode || 'onlyThresholds'),
delay: `${action.shutdownDelay || 5}s`,
}));
const rows = target.actions.map((action, index) => {
const defaultShutdownDelay = this.nupst.getDaemon().getConfig().defaultShutdownDelay ??
SHUTDOWN.DEFAULT_DELAY_MINUTES;
let details = `${action.shutdownDelay ?? defaultShutdownDelay}min delay`;
if (action.type === 'proxmox') {
const mode = action.proxmoxMode || 'auto';
if (mode === 'cli' || (mode === 'auto' && !action.proxmoxTokenId)) {
details = 'CLI mode';
} else {
const host = action.proxmoxHost || 'localhost';
const port = action.proxmoxPort || 8006;
details = `API ${host}:${port}`;
}
if (action.proxmoxExcludeIds?.length) {
details += `, excl: ${action.proxmoxExcludeIds.join(',')}`;
}
if (action.proxmoxHaPolicy === 'haStop') {
details += ', haStop';
}
} else if (action.type === 'webhook') {
details = action.webhookUrl || theme.dim('N/A');
} else if (action.type === 'script') {
details = action.scriptPath || theme.dim('N/A');
}
return {
index: theme.dim(index.toString()),
type: theme.highlight(action.type),
battery: action.thresholds ? `${action.thresholds.battery}%` : theme.dim('N/A'),
runtime: action.thresholds ? `${action.thresholds.runtime}min` : theme.dim('N/A'),
triggerMode: theme.dim(action.triggerMode || 'onlyThresholds'),
details,
};
});
logger.logTable(columns, rows);
logger.log('');

View File

@@ -124,7 +124,7 @@ export class GroupHandler {
await this.nupst.getDaemon().loadConfig();
} catch (error) {
logger.error(
'No configuration found. Please run "nupst setup" first to create a configuration.',
'No configuration found. Please run "nupst ups add" first to create a configuration.',
);
return;
}
@@ -219,7 +219,7 @@ export class GroupHandler {
await this.nupst.getDaemon().loadConfig();
} catch (error) {
logger.error(
'No configuration found. Please run "nupst setup" first to create a configuration.',
'No configuration found. Please run "nupst ups add" first to create a configuration.',
);
return;
}
@@ -316,7 +316,7 @@ export class GroupHandler {
await this.nupst.getDaemon().loadConfig();
} catch (error) {
logger.error(
'No configuration found. Please run "nupst setup" first to create a configuration.',
'No configuration found. Please run "nupst ups add" first to create a configuration.',
);
return;
}
@@ -484,7 +484,7 @@ export class GroupHandler {
prompt: (question: string) => Promise<string>,
): Promise<void> {
if (!config.upsDevices || config.upsDevices.length === 0) {
logger.log('No UPS devices available. Use "nupst add" to add UPS devices.');
logger.log('No UPS devices available. Use "nupst ups add" to add UPS devices.');
return;
}

View File

@@ -1,8 +1,14 @@
import process from 'node:process';
import { execSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { execFileSync, execSync } from 'node:child_process';
import { Nupst } from '../nupst.ts';
import { logger } from '../logger.ts';
import { theme } from '../colors.ts';
import { PAUSE } from '../constants.ts';
import type { IPauseState } from '../pause-state.ts';
import * as helpers from '../helpers/index.ts';
import { renderUpgradeChangelog } from '../upgrade-changelog.ts';
/**
* Class for handling service-related CLI commands
@@ -25,7 +31,9 @@ export class ServiceHandler {
public async enable(): Promise<void> {
this.checkRootAccess('This command must be run as root.');
await this.nupst.getSystemd().install();
logger.log('NUPST service has been installed. Use "nupst start" to start the service.');
logger.log(
'NUPST service has been installed. Use "nupst service start" to start the service.',
);
}
/**
@@ -98,10 +106,131 @@ export class ServiceHandler {
/**
* Show status of the systemd service and UPS
*/
public async status(): Promise<void> {
// Extract debug options from args array
const debugOptions = this.extractDebugOptions(process.argv);
await this.nupst.getSystemd().getStatus(debugOptions.debugMode);
public async status(debugMode: boolean = false): Promise<void> {
await this.nupst.getSystemd().getStatus(debugMode);
}
/**
* Pause action monitoring
* @param args Command arguments (e.g., ['--duration', '30m'])
*/
public async pause(args: string[]): Promise<void> {
try {
// Parse --duration argument
let resumeAt: number | null = null;
const durationIdx = args.indexOf('--duration');
if (durationIdx !== -1 && args[durationIdx + 1]) {
const durationStr = args[durationIdx + 1];
const durationMs = this.parseDuration(durationStr);
if (durationMs === null) {
logger.error(`Invalid duration format: ${durationStr}`);
logger.dim(' Valid formats: 30m, 2h, 1d (minutes, hours, days)');
return;
}
if (durationMs > PAUSE.MAX_DURATION_MS) {
logger.error(`Duration exceeds maximum of 24 hours`);
return;
}
resumeAt = Date.now() + durationMs;
}
// Check if already paused
if (fs.existsSync(PAUSE.FILE_PATH)) {
logger.warn('Monitoring is already paused');
try {
const data = fs.readFileSync(PAUSE.FILE_PATH, 'utf8');
const state = JSON.parse(data) as IPauseState;
logger.dim(` Paused at: ${new Date(state.pausedAt).toISOString()}`);
if (state.resumeAt) {
const remaining = Math.round((state.resumeAt - Date.now()) / 1000);
logger.dim(` Auto-resume in: ${remaining > 0 ? remaining : 0} seconds`);
}
} catch (_e) {
// Ignore parse errors
}
logger.dim(' Run "nupst resume" to resume monitoring');
return;
}
// Create pause state
const pauseState: IPauseState = {
pausedAt: Date.now(),
pausedBy: 'cli',
resumeAt,
};
// Ensure config directory exists
const pauseDir = path.dirname(PAUSE.FILE_PATH);
if (!fs.existsSync(pauseDir)) {
fs.mkdirSync(pauseDir, { recursive: true });
}
fs.writeFileSync(PAUSE.FILE_PATH, JSON.stringify(pauseState, null, 2));
logger.log('');
logger.logBoxTitle('Monitoring Paused', 45, 'warning');
logger.logBoxLine('UPS polling continues but actions are suppressed');
if (resumeAt) {
const durationStr = args[args.indexOf('--duration') + 1];
logger.logBoxLine(`Auto-resume after: ${durationStr}`);
logger.logBoxLine(`Resume at: ${new Date(resumeAt).toISOString()}`);
} else {
logger.logBoxLine('Duration: Indefinite');
logger.logBoxLine('Run "nupst resume" to resume');
}
logger.logBoxEnd();
logger.log('');
} catch (error) {
logger.error(
`Failed to pause: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
/**
* Resume action monitoring
*/
public async resume(): Promise<void> {
try {
if (!fs.existsSync(PAUSE.FILE_PATH)) {
logger.info('Monitoring is not paused');
return;
}
fs.unlinkSync(PAUSE.FILE_PATH);
logger.log('');
logger.logBoxTitle('Monitoring Resumed', 45, 'success');
logger.logBoxLine('Action monitoring has been resumed');
logger.logBoxEnd();
logger.log('');
} catch (error) {
logger.error(
`Failed to resume: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
/**
* Parse a duration string like '30m', '2h', '1d' into milliseconds
*/
private parseDuration(duration: string): number | null {
const match = duration.match(/^(\d+)\s*(m|h|d)$/i);
if (!match) return null;
const value = parseInt(match[1], 10);
const unit = match[2].toLowerCase();
switch (unit) {
case 'm':
return value * 60 * 1000;
case 'h':
return value * 60 * 60 * 1000;
case 'd':
return value * 24 * 60 * 60 * 1000;
default:
return null;
}
}
/**
@@ -130,7 +259,7 @@ export class ServiceHandler {
try {
// Check if running as root
this.checkRootAccess(
'This command must be run as root to update NUPST.',
'This command must be run as root to upgrade NUPST.',
);
console.log('');
@@ -142,7 +271,7 @@ export class ServiceHandler {
// Fetch latest version from Gitea API
const apiUrl = 'https://code.foss.global/api/v1/repos/serve.zone/nupst/releases/latest';
const response = execSync(`curl -sSL ${apiUrl}`).toString();
const response = this.fetchRemoteText(apiUrl);
const release = JSON.parse(response);
const latestVersion = release.tag_name; // e.g., "v4.0.7"
@@ -166,6 +295,7 @@ export class ServiceHandler {
}
logger.info(`New version available: ${latestVersion}`);
this.showUpgradeChangelog(normalizedCurrent, normalizedLatest);
logger.dim('Downloading and installing...');
console.log('');
@@ -193,6 +323,40 @@ export class ServiceHandler {
}
}
private fetchRemoteText(url: string): string {
return execFileSync('curl', ['-fsSL', url], {
encoding: 'utf8',
});
}
private showUpgradeChangelog(currentVersion: string, latestVersion: string): void {
const changelogUrl = 'https://code.foss.global/serve.zone/nupst/raw/branch/main/changelog.md';
try {
const changelogMarkdown = this.fetchRemoteText(changelogUrl);
const renderedChanges = renderUpgradeChangelog(
changelogMarkdown,
currentVersion,
latestVersion,
);
if (!renderedChanges) {
return;
}
logger.info(`What's changed:`);
logger.log('');
for (const line of renderedChanges.split('\n')) {
logger.log(line);
}
logger.log('');
} catch (error) {
logger.warn('Could not load changelog for this upgrade. Continuing anyway.');
logger.dim(`${error instanceof Error ? error.message : String(error)}`);
logger.log('');
}
}
/**
* Completely uninstall NUPST from the system
*/
@@ -274,17 +438,4 @@ export class ServiceHandler {
process.exit(1);
}
}
/**
* Extract and remove debug options from args array
* @param args Command line arguments
* @returns Object with debug flags and cleaned args
*/
private extractDebugOptions(args: string[]): { debugMode: boolean; cleanedArgs: string[] } {
const debugMode = args.includes('--debug') || args.includes('-d');
// Remove debug flags from args
const cleanedArgs = args.filter((arg) => arg !== '--debug' && arg !== '-d');
return { debugMode, cleanedArgs };
}
}

View File

@@ -5,8 +5,13 @@ import { type ITableColumn, logger } from '../logger.ts';
import { theme } from '../colors.ts';
import * as helpers from '../helpers/index.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 { IActionConfig } from '../actions/base-action.ts';
import { ProxmoxAction } from '../actions/proxmox-action.ts';
import { getDefaultRuntimeUnitForUpsModel } from '../snmp/runtime-units.ts';
import { SHUTDOWN, UPSD } from '../constants.ts';
/**
* Thresholds configuration for CLI display
@@ -89,31 +94,54 @@ export class UpsHandler {
const upsId = helpers.shortId();
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
const newUps = {
const newUps: Record<string, unknown> & {
id: string;
name: string;
groups: string[];
actions: IActionConfig[];
protocol: TProtocol;
snmp?: ISnmpConfig;
upsd?: IUpsdConfig;
} = {
id: upsId,
name: name || `UPS-${upsId}`,
snmp: {
protocol,
groups: [],
actions: [],
};
if (protocol === 'snmp') {
newUps.snmp = {
host: '127.0.0.1',
port: 161,
community: 'public',
version: 1,
timeout: 5000,
upsModel: 'cyberpower' as TUpsModel,
},
thresholds: {
battery: 60,
runtime: 20,
},
groups: [],
actions: [],
};
// Gather SNMP settings
await this.gatherSnmpSettings(newUps.snmp, prompt);
// Gather UPS model settings
await this.gatherUpsModelSettings(newUps.snmp, prompt);
};
// Gather SNMP settings
await this.gatherSnmpSettings(newUps.snmp, prompt);
// Gather UPS model settings
await this.gatherUpsModelSettings(newUps.snmp, prompt);
} else {
newUps.upsd = {
host: '127.0.0.1',
port: UPSD.DEFAULT_PORT,
upsName: UPSD.DEFAULT_UPS_NAME,
timeout: UPSD.DEFAULT_TIMEOUT_MS,
};
await this.gatherUpsdSettings(newUps.upsd, prompt);
}
// Get access to GroupHandler for group assignments
const groupHandler = this.nupst.getGroupHandler();
@@ -132,10 +160,14 @@ export class UpsHandler {
// Save the configuration
await this.nupst.getDaemon().saveConfig(config as INupstConfig);
this.displayUpsConfigSummary(newUps);
this.displayUpsConfigSummary(newUps as unknown as IUpsConfig);
// 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
await this.restartServiceIfRunning();
@@ -180,7 +212,7 @@ export class UpsHandler {
return;
} else {
// 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;
}
}
@@ -219,7 +251,7 @@ export class UpsHandler {
} else {
// For backward compatibility, edit the first UPS if no ID specified
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;
}
upsToEdit = config.upsDevices[0];
@@ -232,11 +264,53 @@ export class UpsHandler {
upsToEdit.name = newName;
}
// Edit SNMP settings
await this.gatherSnmpSettings(upsToEdit.snmp, prompt);
// Show current protocol and allow changing
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
await this.gatherUpsModelSettings(upsToEdit.snmp, prompt);
const editProtocol = upsToEdit.protocol || 'snmp';
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
const groupHandler = this.nupst.getGroupHandler();
@@ -260,7 +334,11 @@ export class UpsHandler {
this.displayUpsConfigSummary(upsToEdit);
// 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
await this.restartServiceIfRunning();
@@ -281,7 +359,7 @@ export class UpsHandler {
const errorBoxWidth = 45;
logger.logBoxTitle('Configuration Error', errorBoxWidth);
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();
return;
}
@@ -292,7 +370,7 @@ export class UpsHandler {
// Check if multi-UPS config
if (!config.upsDevices || !Array.isArray(config.upsDevices)) {
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;
}
@@ -397,17 +475,31 @@ export class UpsHandler {
}
// Prepare table data
const rows = config.upsDevices.map((ups) => ({
id: ups.id,
name: ups.name || '',
host: `${ups.snmp.host}:${ups.snmp.port}`,
model: ups.snmp.upsModel || 'cyberpower',
groups: ups.groups.length > 0 ? ups.groups.join(', ') : theme.dim('None'),
}));
const rows = config.upsDevices.map((ups) => {
const protocol = ups.protocol || 'snmp';
let host = 'N/A';
let model = '';
if (protocol === 'upsd' && ups.upsd) {
host = `${ups.upsd.host}:${ups.upsd.port}`;
model = `NUT:${ups.upsd.upsName}`;
} else if (ups.snmp) {
host = `${ups.snmp.host}:${ups.snmp.port}`;
model = ups.snmp.upsModel || 'cyberpower';
}
return {
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[] = [
{ header: 'ID', key: 'id', align: 'left', color: theme.highlight },
{ header: 'Name', key: 'name', align: 'left' },
{ header: 'Protocol', key: 'protocol', align: 'left' },
{ header: 'Host:Port', key: 'host', align: 'left', color: theme.info },
{ header: 'Model', key: 'model', align: 'left' },
{ header: 'Groups', key: 'groups', align: 'left' },
@@ -446,7 +538,7 @@ export class UpsHandler {
const errorBoxWidth = 45;
logger.logBoxTitle('Configuration Error', errorBoxWidth);
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();
return;
}
@@ -482,58 +574,73 @@ export class UpsHandler {
// Type guard: IUpsConfig has 'id' and 'name' at root level, INupstConfig doesn't
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 upsName = isUpsConfig ? (config as IUpsConfig).name : 'Default UPS';
const upsId = isUpsConfig ? (config as IUpsConfig).id : 'default';
const protocol = isUpsConfig ? ((config as IUpsConfig).protocol || 'snmp') : 'snmp';
const boxWidth = 45;
logger.logBoxTitle(`Testing Configuration: ${upsName}`, boxWidth);
logger.logBoxLine(`UPS ID: ${upsId}`);
logger.logBoxLine(`Protocol: ${protocol.toUpperCase()}`);
if (!snmpConfig) {
logger.logBoxLine('SNMP Settings: Not configured');
logger.logBoxEnd();
return;
}
if (protocol === 'upsd' && isUpsConfig && (config as IUpsConfig).upsd) {
const upsdConfig = (config as IUpsConfig).upsd!;
logger.logBoxLine('UPSD/NIS Settings:');
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:');
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}`);
// 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) {
logger.logBoxLine('SNMP Settings: Not configured');
logger.logBoxEnd();
return;
}
if (snmpConfig.securityLevel === 'authPriv') {
logger.logBoxLine(` Privacy Protocol: ${snmpConfig.privProtocol || 'None'}`);
logger.logBoxLine('SNMP Settings:');
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
logger.logBoxLine(` Timeout: ${snmpConfig.timeout / 1000} seconds`);
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 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
if (isUpsConfig) {
const groups = (config as IUpsConfig).groups;
@@ -555,25 +662,38 @@ export class UpsHandler {
const isUpsConfig = 'id' in config && 'name' in config;
const upsId = isUpsConfig ? (config as IUpsConfig).id : 'default';
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 {
// Get SNMP config based on config type
const snmpConfig: ISnmpConfig | undefined = isUpsConfig
? (config as IUpsConfig).snmp
: (config as INupstConfig).snmp;
let status: ISnmpUpsStatus;
if (!snmpConfig) {
throw new Error('SNMP configuration not found');
if (protocol === 'upsd' && isUpsConfig && (config as IUpsConfig).upsd) {
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;
logger.logBoxTitle(`Connection Successful: ${upsName}`, boxWidth);
logger.logBoxLine('UPS Status:');
@@ -586,7 +706,9 @@ export class UpsHandler {
logger.logBoxTitle(`Connection Failed: ${upsName}`, errorBoxWidth);
logger.logBoxLine(`Error: ${error instanceof Error ? error.message : String(error)}`);
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 +992,124 @@ export class UpsHandler {
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 (TrippLite, Liebert, many RFC 1628 devices)');
logger.dim(' 2) Seconds (Eaton, HPE, many RFC 1628 devices)');
logger.dim(' 3) Ticks (CyberPower, APC PowerNet - 1/100 second increments)');
const defaultRuntimeUnit = snmpConfig.runtimeUnit ||
getDefaultRuntimeUnitForUpsModel(snmpConfig.upsModel);
const defaultUnitValue = defaultRuntimeUnit === 'seconds'
? 2
: defaultRuntimeUnit === 'ticks'
? 3
: 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 +1141,7 @@ export class UpsHandler {
logger.dim(' 1) Shutdown (system shutdown)');
logger.dim(' 2) Webhook (HTTP notification)');
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 typeValue = parseInt(typeInput, 10) || 1;
@@ -910,11 +1151,19 @@ export class UpsHandler {
if (typeValue === 1) {
// Shutdown action
action.type = 'shutdown';
const defaultShutdownDelay = this.nupst.getDaemon().getConfig().defaultShutdownDelay ??
SHUTDOWN.DEFAULT_DELAY_MINUTES;
const delayInput = await prompt('Shutdown delay in minutes [5]: ');
const delay = parseInt(delayInput, 10);
if (delayInput.trim() && !isNaN(delay)) {
action.shutdownDelay = delay;
const delayInput = await prompt(
`Shutdown delay in minutes (leave empty for default ${defaultShutdownDelay}): `,
);
if (delayInput.trim()) {
const delay = parseInt(delayInput, 10);
if (isNaN(delay) || delay < 0) {
logger.warn('Invalid shutdown delay, using configured default');
} else {
action.shutdownDelay = delay;
}
}
} else if (typeValue === 2) {
// Webhook action
@@ -955,6 +1204,83 @@ export class UpsHandler {
if (timeoutInput.trim() && !isNaN(timeout)) {
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';
const haPolicyInput = await prompt('HA-managed guest handling ([1] none, 2 haStop): ');
action.proxmoxHaPolicy = haPolicyInput.trim() === '2' ? 'haStop' : 'none';
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 {
logger.warn('Invalid action type, skipping');
continue;
@@ -1032,12 +1358,21 @@ export class UpsHandler {
*/
private displayUpsConfigSummary(ups: IUpsConfig): void {
const boxWidth = 45;
const protocol = ups.protocol || 'snmp';
logger.log('');
logger.logBoxTitle(`UPS Configuration: ${ups.name}`, boxWidth);
logger.logBoxLine(`UPS ID: ${ups.id}`);
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(`Protocol: ${protocol.toUpperCase()}`);
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) {
logger.logBoxLine(`Groups: ${ups.groups.join(', ')}`);

View File

@@ -75,12 +75,16 @@ export function getRuntimeColor(minutes: number): (text: string) => string {
/**
* Format UPS power status with color
*/
export function formatPowerStatus(status: 'online' | 'onBattery' | 'unknown'): string {
export function formatPowerStatus(
status: 'online' | 'onBattery' | 'unknown' | 'unreachable',
): string {
switch (status) {
case 'online':
return theme.success('Online');
case 'onBattery':
return theme.warning('On Battery');
case 'unreachable':
return theme.error('Unreachable');
case 'unknown':
default:
return theme.dim('Unknown');

58
ts/config-watch.ts Normal file
View File

@@ -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,
};
}

View File

@@ -103,6 +103,65 @@ export const HTTP_SERVER = {
DEFAULT_PATH: '/ups-status',
} as const;
/**
* Network failure detection constants
*/
export const NETWORK = {
/** Number of consecutive failures before marking UPS as unreachable */
CONSECUTIVE_FAILURE_THRESHOLD: 3,
/** Maximum tracked consecutive failures (prevents overflow) */
MAX_CONSECUTIVE_FAILURES: 100,
} as const;
/**
* UPSD/NIS protocol constants
*/
export const UPSD = {
/** Default UPSD port (NUT standard) */
DEFAULT_PORT: 3493,
/** Default timeout in milliseconds */
DEFAULT_TIMEOUT_MS: 5000,
/** Default NUT device name */
DEFAULT_UPS_NAME: 'ups',
} as const;
/**
* Pause/resume constants
*/
export const PAUSE = {
/** Path to the pause state file */
FILE_PATH: '/etc/nupst/pause',
/** Maximum pause duration (24 hours) */
MAX_DURATION_MS: 24 * 60 * 60 * 1000,
} as const;
/**
* Proxmox VM shutdown constants
*/
export const PROXMOX = {
/** Default Proxmox API port */
DEFAULT_PORT: 8006,
/** Default Proxmox host */
DEFAULT_HOST: 'localhost',
/** Default timeout for VM/CT shutdown in seconds */
DEFAULT_STOP_TIMEOUT_SECONDS: 120,
/** Poll interval for checking VM/CT status in seconds */
STATUS_POLL_INTERVAL_SECONDS: 5,
/** Proxmox API base path */
API_BASE: '/api2/json',
/** Common paths to search for Proxmox CLI tools (qm, pct) */
CLI_TOOL_PATHS: ['/usr/sbin', '/usr/bin', '/sbin', '/bin'] as readonly string[],
} as const;
/**
* UI/Display constants
*/

File diff suppressed because it is too large Load Diff

198
ts/group-monitoring.ts Normal file
View File

@@ -0,0 +1,198 @@
import type { IActionConfig, TPowerStatus } from './actions/base-action.ts';
import { createInitialUpsStatus, type IUpsIdentity, type IUpsStatus } from './ups-status.ts';
export interface IGroupStatusSnapshot {
updatedStatus: IUpsStatus;
transition: 'none' | 'powerStatusChange';
previousStatus?: IUpsStatus;
}
export interface IGroupThresholdEvaluation {
exceedsThreshold: boolean;
blockedByUnreachable: boolean;
representativeStatus?: IUpsStatus;
}
const destructiveActionTypes = new Set(['shutdown', 'proxmox']);
function getStatusSeverity(powerStatus: TPowerStatus): number {
switch (powerStatus) {
case 'unreachable':
return 3;
case 'onBattery':
return 2;
case 'unknown':
return 1;
case 'online':
default:
return 0;
}
}
export function selectWorstStatus(statuses: IUpsStatus[]): IUpsStatus | undefined {
return statuses.reduce<IUpsStatus | undefined>((worst, status) => {
if (!worst) {
return status;
}
const severityDiff = getStatusSeverity(status.powerStatus) -
getStatusSeverity(worst.powerStatus);
if (severityDiff > 0) {
return status;
}
if (severityDiff < 0) {
return worst;
}
if (status.batteryRuntime !== worst.batteryRuntime) {
return status.batteryRuntime < worst.batteryRuntime ? status : worst;
}
if (status.batteryCapacity !== worst.batteryCapacity) {
return status.batteryCapacity < worst.batteryCapacity ? status : worst;
}
return worst;
}, undefined);
}
function deriveGroupPowerStatus(
mode: 'redundant' | 'nonRedundant',
memberStatuses: IUpsStatus[],
): TPowerStatus {
if (memberStatuses.length === 0) {
return 'unknown';
}
if (memberStatuses.some((status) => status.powerStatus === 'unreachable')) {
return 'unreachable';
}
if (mode === 'redundant') {
if (memberStatuses.every((status) => status.powerStatus === 'onBattery')) {
return 'onBattery';
}
} else if (memberStatuses.some((status) => status.powerStatus === 'onBattery')) {
return 'onBattery';
}
if (memberStatuses.some((status) => status.powerStatus === 'unknown')) {
return 'unknown';
}
return 'online';
}
function pickRepresentativeStatus(
powerStatus: TPowerStatus,
memberStatuses: IUpsStatus[],
): IUpsStatus | undefined {
const matchingStatuses = memberStatuses.filter((status) => status.powerStatus === powerStatus);
return selectWorstStatus(matchingStatuses.length > 0 ? matchingStatuses : memberStatuses);
}
export function buildGroupStatusSnapshot(
group: IUpsIdentity,
mode: 'redundant' | 'nonRedundant',
memberStatuses: IUpsStatus[],
currentStatus: IUpsStatus | undefined,
currentTime: number,
): IGroupStatusSnapshot {
const previousStatus = currentStatus || createInitialUpsStatus(group, currentTime);
const powerStatus = deriveGroupPowerStatus(mode, memberStatuses);
const representative = pickRepresentativeStatus(powerStatus, memberStatuses) || previousStatus;
const updatedStatus: IUpsStatus = {
...previousStatus,
id: group.id,
name: group.name,
powerStatus,
batteryCapacity: representative.batteryCapacity,
batteryRuntime: representative.batteryRuntime,
outputLoad: representative.outputLoad,
outputPower: representative.outputPower,
outputVoltage: representative.outputVoltage,
outputCurrent: representative.outputCurrent,
lastCheckTime: currentTime,
consecutiveFailures: 0,
unreachableSince: powerStatus === 'unreachable'
? previousStatus.unreachableSince || currentTime
: 0,
lastStatusChange: previousStatus.lastStatusChange || currentTime,
};
if (previousStatus.powerStatus !== powerStatus) {
updatedStatus.lastStatusChange = currentTime;
if (powerStatus === 'unreachable') {
updatedStatus.unreachableSince = currentTime;
}
return {
updatedStatus,
transition: 'powerStatusChange',
previousStatus,
};
}
return {
updatedStatus,
transition: 'none',
previousStatus: currentStatus,
};
}
export function evaluateGroupActionThreshold(
actionConfig: IActionConfig,
mode: 'redundant' | 'nonRedundant',
memberStatuses: IUpsStatus[],
): IGroupThresholdEvaluation {
if (!actionConfig.thresholds || memberStatuses.length === 0) {
return {
exceedsThreshold: false,
blockedByUnreachable: false,
};
}
const criticalMembers = memberStatuses.filter((status) =>
status.powerStatus === 'onBattery' &&
(status.batteryCapacity < actionConfig.thresholds!.battery ||
status.batteryRuntime < actionConfig.thresholds!.runtime)
);
const exceedsThreshold = mode === 'redundant'
? criticalMembers.length === memberStatuses.length
: criticalMembers.length > 0;
return {
exceedsThreshold,
blockedByUnreachable: exceedsThreshold &&
destructiveActionTypes.has(actionConfig.type) &&
memberStatuses.some((status) => status.powerStatus === 'unreachable'),
representativeStatus: selectWorstStatus(criticalMembers),
};
}
export function buildGroupThresholdContextStatus(
group: IUpsIdentity,
evaluations: IGroupThresholdEvaluation[],
enteredActionIndexes: number[],
fallbackStatus: IUpsStatus,
currentTime: number,
): IUpsStatus {
const representativeStatuses = enteredActionIndexes
.map((index) => evaluations[index]?.representativeStatus)
.filter((status): status is IUpsStatus => !!status);
const representative = selectWorstStatus(representativeStatuses) || fallbackStatus;
return {
...fallbackStatus,
id: group.id,
name: group.name,
powerStatus: 'onBattery',
batteryCapacity: representative.batteryCapacity,
batteryRuntime: representative.batteryRuntime,
outputLoad: representative.outputLoad,
outputPower: representative.outputPower,
outputVoltage: representative.outputVoltage,
outputCurrent: representative.outputCurrent,
lastCheckTime: currentTime,
};
}

View File

@@ -1,7 +1,8 @@
import * as http from 'node:http';
import { URL } from 'node:url';
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
@@ -13,6 +14,7 @@ export class NupstHttpServer {
private path: string;
private authToken: string;
private getUpsStatus: () => Map<string, IUpsStatus>;
private getPauseState: () => IPauseState | null;
/**
* Create a new HTTP server instance
@@ -20,17 +22,20 @@ export class NupstHttpServer {
* @param path URL path for the endpoint
* @param authToken Authentication token required for access
* @param getUpsStatus Function to retrieve cached UPS status
* @param getPauseState Function to retrieve current pause state
*/
constructor(
port: number,
path: string,
authToken: string,
getUpsStatus: () => Map<string, IUpsStatus>,
getPauseState: () => IPauseState | null,
) {
this.port = port;
this.path = path;
this.authToken = authToken;
this.getUpsStatus = getUpsStatus;
this.getPauseState = getPauseState;
}
/**
@@ -79,12 +84,18 @@ export class NupstHttpServer {
// Get cached status (no refresh)
const statusMap = this.getUpsStatus();
const statusArray = Array.from(statusMap.values());
const pauseState = this.getPauseState();
const response = {
upsDevices: statusArray,
...(pauseState ? { paused: true, pauseState } : { paused: false }),
};
res.writeHead(200, {
'Content-Type': 'application/json',
'Cache-Control': 'no-cache',
});
res.end(JSON.stringify(statusArray, null, 2));
res.end(JSON.stringify(response, null, 2));
} else {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Not Found' }));

View File

@@ -10,7 +10,7 @@ import process from 'node:process';
*/
async function main() {
const cli = new NupstCli();
await cli.parseAndExecute(process.argv);
await cli.parseAndExecute(process.argv.slice(2));
}
// Run the main function and handle any errors

View File

@@ -9,3 +9,6 @@ export { MigrationRunner } from './migration-runner.ts';
export { MigrationV1ToV2 } from './migration-v1-to-v2.ts';
export { MigrationV3ToV4 } from './migration-v3-to-v4.ts';
export { MigrationV4_0ToV4_1 } from './migration-v4.0-to-v4.1.ts';
export { MigrationV4_1ToV4_2 } from './migration-v4.1-to-v4.2.ts';
export { MigrationV4_2ToV4_3 } from './migration-v4.2-to-v4.3.ts';
export { MigrationV4_3ToV4_4 } from './migration-v4.3-to-v4.4.ts';

View File

@@ -2,6 +2,9 @@ import { BaseMigration } from './base-migration.ts';
import { MigrationV1ToV2 } from './migration-v1-to-v2.ts';
import { MigrationV3ToV4 } from './migration-v3-to-v4.ts';
import { MigrationV4_0ToV4_1 } from './migration-v4.0-to-v4.1.ts';
import { MigrationV4_1ToV4_2 } from './migration-v4.1-to-v4.2.ts';
import { MigrationV4_2ToV4_3 } from './migration-v4.2-to-v4.3.ts';
import { MigrationV4_3ToV4_4 } from './migration-v4.3-to-v4.4.ts';
import { logger } from '../logger.ts';
/**
@@ -19,7 +22,9 @@ export class MigrationRunner {
new MigrationV1ToV2(),
new MigrationV3ToV4(),
new MigrationV4_0ToV4_1(),
// Add future migrations here (v4.3, v4.4, etc.)
new MigrationV4_1ToV4_2(),
new MigrationV4_2ToV4_3(),
new MigrationV4_3ToV4_4(),
];
// Sort by version order to ensure they run in sequence
@@ -55,7 +60,7 @@ export class MigrationRunner {
if (anyMigrationsRan) {
logger.success('Configuration migrations complete');
} else {
logger.success('config format ok');
logger.success('Configuration format OK');
}
return {

View File

@@ -37,8 +37,7 @@ import { logger } from '../logger.ts';
* {
* type: "shutdown",
* thresholds: { battery: 60, runtime: 20 },
* triggerMode: "onlyThresholds",
* shutdownDelay: 5
* triggerMode: "onlyThresholds"
* }
* ]
* }
@@ -93,7 +92,6 @@ export class MigrationV4_0ToV4_1 extends BaseMigration {
runtime: deviceThresholds.runtime,
},
triggerMode: 'onlyThresholds', // Preserve old behavior (only on threshold violation)
shutdownDelay: 5, // Default delay
},
];
logger.dim(

View File

@@ -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;
}
}

View File

@@ -0,0 +1,52 @@
import { BaseMigration } from './base-migration.ts';
import { logger } from '../logger.ts';
import { getDefaultRuntimeUnitForUpsModel } from '../snmp/runtime-units.ts';
/**
* Migration from v4.2 to v4.3
*
* Changes:
* 1. Adds `runtimeUnit` to SNMP configs based on existing `upsModel`
* 2. Bumps version from '4.2' to '4.3'
*/
export class MigrationV4_2ToV4_3 extends BaseMigration {
readonly fromVersion = '4.2';
readonly toVersion = '4.3';
shouldRun(config: Record<string, unknown>): boolean {
return config.version === '4.2';
}
migrate(config: Record<string, unknown>): Record<string, unknown> {
logger.info(`${this.getName()}: Adding runtimeUnit to SNMP configs...`);
const devices = (config.upsDevices as Array<Record<string, unknown>>) || [];
const migratedDevices = devices.map((device) => {
const snmp = device.snmp as Record<string, unknown> | undefined;
if (snmp && !snmp.runtimeUnit) {
const model = snmp.upsModel as
| 'cyberpower'
| 'apc'
| 'eaton'
| 'tripplite'
| 'liebert'
| 'custom'
| undefined;
snmp.runtimeUnit = getDefaultRuntimeUnitForUpsModel(model);
logger.dim(`${device.name}: Set runtimeUnit to '${snmp.runtimeUnit}'`);
}
return device;
});
const result = {
...config,
version: this.toVersion,
upsDevices: migratedDevices,
};
logger.success(
`${this.getName()}: Migration complete (${migratedDevices.length} devices updated)`,
);
return result;
}
}

View File

@@ -0,0 +1,50 @@
import { BaseMigration } from './base-migration.ts';
import { logger } from '../logger.ts';
/**
* Migration from v4.3 to v4.4
*
* Changes:
* 1. Corrects APC runtimeUnit defaults from minutes to ticks
* 2. Bumps version from '4.3' to '4.4'
*/
export class MigrationV4_3ToV4_4 extends BaseMigration {
readonly fromVersion = '4.3';
readonly toVersion = '4.4';
shouldRun(config: Record<string, unknown>): boolean {
return config.version === '4.3';
}
migrate(config: Record<string, unknown>): Record<string, unknown> {
logger.info(`${this.getName()}: Correcting APC runtimeUnit defaults...`);
let correctedDevices = 0;
const devices = (config.upsDevices as Array<Record<string, unknown>>) || [];
const migratedDevices = devices.map((device) => {
const snmp = device.snmp as Record<string, unknown> | undefined;
if (!snmp || snmp.upsModel !== 'apc') {
return device;
}
if (!snmp.runtimeUnit || snmp.runtimeUnit === 'minutes') {
snmp.runtimeUnit = 'ticks';
correctedDevices += 1;
logger.dim(`${device.name}: Set runtimeUnit to 'ticks'`);
}
return device;
});
const result = {
...config,
version: this.toVersion,
upsDevices: migratedDevices,
};
logger.success(
`${this.getName()}: Migration complete (${correctedDevices} APC device(s) corrected)`,
);
return result;
}
}

View File

@@ -1,4 +1,5 @@
import { NupstSnmp } from './snmp/manager.ts';
import { NupstUpsd } from './upsd/client.ts';
import { NupstDaemon } from './daemon.ts';
import { NupstSystemd } from './systemd.ts';
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 {
private readonly snmp: NupstSnmp;
private readonly upsd: NupstUpsd;
private readonly daemon: NupstDaemon;
private readonly systemd: NupstSystemd;
private readonly upsHandler: UpsHandler;
@@ -34,7 +36,8 @@ export class Nupst implements INupstAccessor {
// Initialize core components
this.snmp = new NupstSnmp();
this.snmp.setNupst(this); // Set up bidirectional reference
this.daemon = new NupstDaemon(this.snmp);
this.upsd = new NupstUpsd();
this.daemon = new NupstDaemon(this.snmp, this.upsd);
this.systemd = new NupstSystemd(this.daemon);
// Initialize handlers
@@ -52,6 +55,13 @@ export class Nupst implements INupstAccessor {
return this.snmp;
}
/**
* Get the UPSD manager for NUT protocol communication
*/
public getUpsd(): NupstUpsd {
return this.upsd;
}
/**
* Get the daemon manager for background monitoring
*/
@@ -225,7 +235,7 @@ export class Nupst implements INupstAccessor {
if (this.updateAvailable && this.latestVersion) {
logger.logBoxLine(`Update Available: ${this.latestVersion}`);
logger.logBoxLine('Run "sudo nupst update" to update');
logger.logBoxLine('Run "sudo nupst upgrade" to upgrade');
logger.logBoxEnd();
} else if (checkForUpdates) {
logger.logBoxLine('Checking for updates...');
@@ -234,7 +244,7 @@ export class Nupst implements INupstAccessor {
this.checkForUpdates().then((updateAvailable) => {
if (updateAvailable) {
logger.logBoxLine(`Update Available: ${this.latestVersion}`);
logger.logBoxLine('Run "sudo nupst update" to update');
logger.logBoxLine('Run "sudo nupst upgrade" to upgrade');
} else {
logger.logBoxLine('You are running the latest version');
}

68
ts/pause-state.ts Normal file
View File

@@ -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',
};
}
}

7
ts/protocol/index.ts Normal file
View File

@@ -0,0 +1,7 @@
/**
* Protocol abstraction module
* Re-exports public types and classes
*/
export type { TProtocol } from './types.ts';
export { ProtocolResolver } from './resolver.ts';

49
ts/protocol/resolver.ts Normal file
View File

@@ -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);
}
}
}

4
ts/protocol/types.ts Normal file
View File

@@ -0,0 +1,4 @@
/**
* Protocol type for UPS communication
*/
export type TProtocol = 'snmp' | 'upsd';

145
ts/shutdown-executor.ts Normal file
View File

@@ -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;
}
}

72
ts/shutdown-monitoring.ts Normal file
View File

@@ -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 };
}

View File

@@ -1,11 +1,79 @@
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 type { IOidSet, ISnmpConfig, IUpsStatus, TUpsModel } from './types.ts';
import { UpsOidSets } from './oid-sets.ts';
import { convertRuntimeValueToMinutes, getDefaultRuntimeUnitForUpsModel } from './runtime-units.ts';
import { SNMP } from '../constants.ts';
import { logger } from '../logger.ts';
import type { INupstAccessor } from '../interfaces/index.ts';
type TSnmpMetricDescription =
| 'power status'
| 'battery capacity'
| 'battery runtime'
| 'output load'
| 'output power'
| 'output voltage'
| 'output current';
type TSnmpResponseValue = string | number | bigint | boolean | Buffer;
type TSnmpValue = string | number | boolean | Buffer;
interface ISnmpVarbind {
oid: string;
type: number;
value: TSnmpResponseValue;
}
interface ISnmpSessionOptions {
port: number;
retries: number;
timeout: number;
transport: 'udp4' | 'udp6';
idBitsSize: 16 | 32;
context: string;
version: number;
}
interface ISnmpV3User {
name: string;
level: number;
authProtocol?: string;
authKey?: string;
privProtocol?: string;
privKey?: string;
}
interface ISnmpSession {
get(oids: string[], callback: (error: Error | null, varbinds?: ISnmpVarbind[]) => void): void;
close(): void;
}
interface ISnmpModule {
Version1: number;
Version2c: number;
Version3: number;
SecurityLevel: {
noAuthNoPriv: number;
authNoPriv: number;
authPriv: number;
};
AuthProtocols: {
md5: string;
sha: string;
};
PrivProtocols: {
des: string;
aes: string;
};
createSession(target: string, community: string, options: ISnmpSessionOptions): ISnmpSession;
createV3Session(target: string, user: ISnmpV3User, options: ISnmpSessionOptions): ISnmpSession;
isVarbindError(varbind: ISnmpVarbind): boolean;
varbindError(varbind: ISnmpVarbind): string;
}
const snmpLib = snmp as unknown as ISnmpModule;
/**
* Class for SNMP communication with UPS devices
* Main entry point for SNMP functionality
@@ -84,6 +152,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
* @param oid OID to query
@@ -95,130 +277,39 @@ export class NupstSnmp {
oid: string,
config = this.DEFAULT_CONFIG,
_retryCount = 0,
// deno-lint-ignore no-explicit-any
): Promise<any> {
): Promise<TSnmpValue> {
return new Promise((resolve, reject) => {
if (this.debug) {
logger.dim(
`Sending SNMP v${config.version} GET request for OID ${oid} to ${config.host}:${config.port}`,
);
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 (config.version === 1 || config.version === 2) {
logger.dim(`Using community: ${config.community}`);
}
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
const oids = [oid];
// Send the GET request
// deno-lint-ignore no-explicit-any
session.get(oids, (error: Error | null, varbinds: any[]) => {
session.get(oids, (error: Error | null, varbinds?: ISnmpVarbind[]) => {
// Close the session to release resources
session.close();
@@ -230,7 +321,9 @@ export class NupstSnmp {
return;
}
if (!varbinds || varbinds.length === 0) {
const varbind = varbinds?.[0];
if (!varbind) {
if (this.debug) {
logger.error('No varbinds returned in response');
}
@@ -239,36 +332,20 @@ export class NupstSnmp {
}
// Check for SNMP errors in the response
if (
varbinds[0].type === snmp.ObjectType.NoSuchObject ||
varbinds[0].type === snmp.ObjectType.NoSuchInstance ||
varbinds[0].type === snmp.ObjectType.EndOfMibView
) {
if (snmpLib.isVarbindError(varbind)) {
const errorMessage = snmpLib.varbindError(varbind);
if (this.debug) {
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;
}
// Process the response value based on its type
let value = varbinds[0].value;
// Handle specific types that might need conversion
if (Buffer.isBuffer(value)) {
// If value is a Buffer, try to convert it to a string if it's printable ASCII
const isPrintableAscii = value.every((byte: number) => byte >= 32 && byte <= 126);
if (isPrintableAscii) {
value = value.toString();
}
} else if (typeof value === 'bigint') {
// Convert BigInt to a normal number or string if needed
value = Number(value);
}
const value = this.normalizeSnmpValue(varbind.value);
if (this.debug) {
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 +392,50 @@ export class NupstSnmp {
}
// Get all values with independent retry logic
const powerStatusValue = await this.getSNMPValueWithRetry(
this.activeOIDs.POWER_STATUS,
const powerStatusValue = this.coerceNumericSnmpValue(
await this.getSNMPValueWithRetry(this.activeOIDs.POWER_STATUS, 'power status', config),
'power status',
config,
);
const batteryCapacity = await this.getSNMPValueWithRetry(
this.activeOIDs.BATTERY_CAPACITY,
const batteryCapacity = this.coerceNumericSnmpValue(
await this.getSNMPValueWithRetry(
this.activeOIDs.BATTERY_CAPACITY,
'battery capacity',
config,
),
'battery capacity',
config,
) || 0;
const batteryRuntime = await this.getSNMPValueWithRetry(
this.activeOIDs.BATTERY_RUNTIME,
);
const batteryRuntime = this.coerceNumericSnmpValue(
await this.getSNMPValueWithRetry(
this.activeOIDs.BATTERY_RUNTIME,
'battery runtime',
config,
),
'battery runtime',
config,
) || 0;
);
// Get power draw metrics
const outputLoad = await this.getSNMPValueWithRetry(
this.activeOIDs.OUTPUT_LOAD,
const outputLoad = this.coerceNumericSnmpValue(
await this.getSNMPValueWithRetry(this.activeOIDs.OUTPUT_LOAD, 'output load', config),
'output load',
config,
) || 0;
const outputPower = await this.getSNMPValueWithRetry(
this.activeOIDs.OUTPUT_POWER,
);
const outputPower = this.coerceNumericSnmpValue(
await this.getSNMPValueWithRetry(this.activeOIDs.OUTPUT_POWER, 'output power', config),
'output power',
config,
) || 0;
const outputVoltage = await this.getSNMPValueWithRetry(
this.activeOIDs.OUTPUT_VOLTAGE,
);
const outputVoltage = this.coerceNumericSnmpValue(
await this.getSNMPValueWithRetry(this.activeOIDs.OUTPUT_VOLTAGE, 'output voltage', config),
'output voltage',
config,
) || 0;
const outputCurrent = await this.getSNMPValueWithRetry(
this.activeOIDs.OUTPUT_CURRENT,
);
const outputCurrent = this.coerceNumericSnmpValue(
await this.getSNMPValueWithRetry(this.activeOIDs.OUTPUT_CURRENT, 'output current', config),
'output current',
config,
) || 0;
);
// Determine power status - handle different values for different UPS models
const powerStatus = this.determinePowerStatus(config.upsModel, powerStatusValue);
// Convert to minutes for UPS models with different time units
const processedRuntime = this.processRuntimeValue(config.upsModel, batteryRuntime);
const processedRuntime = this.processRuntimeValue(config, batteryRuntime);
// Process power metrics with vendor-specific scaling
const processedVoltage = this.processVoltageValue(config.upsModel, outputVoltage);
@@ -430,10 +508,9 @@ export class NupstSnmp {
*/
private async getSNMPValueWithRetry(
oid: string,
description: string,
description: TSnmpMetricDescription,
config: ISnmpConfig,
// deno-lint-ignore no-explicit-any
): Promise<any> {
): Promise<TSnmpValue | 0> {
if (oid === '') {
if (this.debug) {
logger.dim(`No OID provided for ${description}, skipping`);
@@ -485,10 +562,9 @@ export class NupstSnmp {
*/
private async tryFallbackSecurityLevels(
oid: string,
description: string,
description: TSnmpMetricDescription,
config: ISnmpConfig,
// deno-lint-ignore no-explicit-any
): Promise<any> {
): Promise<TSnmpValue | 0> {
if (this.debug) {
logger.dim(`Retrying ${description} with fallback security level...`);
}
@@ -551,10 +627,9 @@ export class NupstSnmp {
*/
private async tryStandardOids(
_oid: string,
description: string,
description: TSnmpMetricDescription,
config: ISnmpConfig,
// deno-lint-ignore no-explicit-any
): Promise<any> {
): Promise<TSnmpValue | 0> {
try {
// Try RFC 1628 standard UPS MIB OIDs
const standardOIDs = UpsOidSets.getStandardOids();
@@ -620,47 +695,33 @@ export class NupstSnmp {
}
/**
* Process runtime value based on UPS model
* @param upsModel UPS model
* Process runtime value based on config runtimeUnit or UPS model
* @param config SNMP configuration (uses runtimeUnit if set, otherwise falls back to upsModel)
* @param batteryRuntime Raw battery runtime value
* @returns Processed runtime in minutes
*/
private processRuntimeValue(
upsModel: TUpsModel | undefined,
config: ISnmpConfig,
batteryRuntime: number,
): number {
if (this.debug) {
logger.dim(`Raw runtime value: ${batteryRuntime}`);
}
if (upsModel === 'cyberpower' && batteryRuntime > 0) {
// CyberPower: TimeTicks is in 1/100 seconds, convert to minutes
const minutes = Math.floor(batteryRuntime / 6000); // 6000 ticks = 1 minute
if (this.debug) {
logger.dim(
`Converting CyberPower runtime from ${batteryRuntime} ticks to ${minutes} minutes`,
);
}
return minutes;
} else if (upsModel === 'eaton' && batteryRuntime > 0) {
// Eaton: Runtime is in seconds, convert to minutes
const minutes = Math.floor(batteryRuntime / 60);
if (this.debug) {
logger.dim(
`Converting Eaton runtime from ${batteryRuntime} seconds to ${minutes} minutes`,
);
}
return minutes;
} else if (batteryRuntime > 10000) {
// Generic conversion for large tick values (likely TimeTicks)
const minutes = Math.floor(batteryRuntime / 6000);
if (this.debug) {
logger.dim(`Converting ${batteryRuntime} ticks to ${minutes} minutes`);
}
return minutes;
const runtimeUnit = config.runtimeUnit ||
getDefaultRuntimeUnitForUpsModel(config.upsModel, batteryRuntime);
const minutes = convertRuntimeValueToMinutes(config, batteryRuntime);
if (this.debug && minutes !== batteryRuntime) {
const source = config.runtimeUnit
? `runtimeUnit: ${runtimeUnit}`
: `upsModel: ${config.upsModel || 'auto'}`;
logger.dim(
`Converting runtime from ${batteryRuntime} ${runtimeUnit} to ${minutes} minutes (${source})`,
);
}
return batteryRuntime;
return minutes;
}
/**

View File

@@ -28,7 +28,7 @@ export class UpsOidSets {
apc: {
POWER_STATUS: '1.3.6.1.4.1.318.1.1.1.4.1.1.0', // upsBasicOutputStatus
BATTERY_CAPACITY: '1.3.6.1.4.1.318.1.1.1.2.2.1.0', // Battery capacity in percentage
BATTERY_RUNTIME: '1.3.6.1.4.1.318.1.1.1.2.2.3.0', // Remaining runtime in minutes
BATTERY_RUNTIME: '1.3.6.1.4.1.318.1.1.1.2.2.3.0', // Remaining runtime (TimeTicks)
OUTPUT_LOAD: '1.3.6.1.4.1.318.1.1.1.4.2.3.0', // upsAdvOutputLoad (percentage)
OUTPUT_POWER: '1.3.6.1.4.1.318.1.1.1.4.2.8.0', // upsAdvOutputActivePower (watts)
OUTPUT_VOLTAGE: '1.3.6.1.4.1.318.1.1.1.4.2.1.0', // upsAdvOutputVoltage

50
ts/snmp/runtime-units.ts Normal file
View File

@@ -0,0 +1,50 @@
import type { ISnmpConfig, TRuntimeUnit, TUpsModel } from './types.ts';
/**
* Return the runtime unit that matches the bundled OID set for a UPS model.
*/
export function getDefaultRuntimeUnitForUpsModel(
upsModel: TUpsModel | undefined,
batteryRuntime?: number,
): TRuntimeUnit {
switch (upsModel) {
case 'cyberpower':
case 'apc':
return 'ticks';
case 'eaton':
return 'seconds';
case 'custom':
case 'tripplite':
case 'liebert':
case undefined:
if (batteryRuntime !== undefined && batteryRuntime > 10000) {
return 'ticks';
}
return 'minutes';
}
}
/**
* Convert an SNMP runtime value to minutes using explicit config first, then model defaults.
*/
export function convertRuntimeValueToMinutes(
config: Pick<ISnmpConfig, 'runtimeUnit' | 'upsModel'>,
batteryRuntime: number,
): number {
if (batteryRuntime <= 0) {
return batteryRuntime;
}
const runtimeUnit = config.runtimeUnit ||
getDefaultRuntimeUnitForUpsModel(config.upsModel, batteryRuntime);
if (runtimeUnit === 'seconds') {
return Math.floor(batteryRuntime / 60);
}
if (runtimeUnit === 'ticks') {
return Math.floor(batteryRuntime / 6000);
}
return batteryRuntime;
}

View File

@@ -9,7 +9,7 @@ import { Buffer } from 'node:buffer';
*/
export interface IUpsStatus {
/** Current power status */
powerStatus: 'online' | 'onBattery' | 'unknown';
powerStatus: 'online' | 'onBattery' | 'unknown' | 'unreachable';
/** Battery capacity percentage */
batteryCapacity: number;
/** Remaining runtime in minutes */
@@ -58,6 +58,11 @@ export interface IOidSet {
*/
export type TUpsModel = 'cyberpower' | 'apc' | 'eaton' | 'tripplite' | 'liebert' | 'custom';
/**
* Runtime unit for battery runtime SNMP values
*/
export type TRuntimeUnit = 'minutes' | 'seconds' | 'ticks';
/**
* SNMP Configuration interface
*/
@@ -96,6 +101,8 @@ export interface ISnmpConfig {
upsModel?: TUpsModel;
/** Custom OIDs when using custom UPS model */
customOIDs?: IOidSet;
/** Unit of the battery runtime SNMP value. Overrides model-based auto-detection when set. */
runtimeUnit?: TRuntimeUnit;
}
/**

View File

@@ -1,10 +1,71 @@
import process from 'node:process';
import { promises as fs } from 'node:fs';
import { execSync } from 'node:child_process';
import { execFileSync, execSync } from 'node:child_process';
import { type IUpsConfig, NupstDaemon } from './daemon.ts';
import { NupstSnmp } from './snmp/manager.ts';
import { logger } from './logger.ts';
import { formatPowerStatus, getBatteryColor, getRuntimeColor, symbols, theme } from './colors.ts';
import { SHUTDOWN } from './constants.ts';
interface IServiceStatusSnapshot {
loadState: string;
activeState: string;
subState: string;
pid: string;
memory: string;
cpu: string;
}
function formatSystemdMemory(memoryBytes: string): string {
const bytes = Number(memoryBytes);
if (!Number.isFinite(bytes) || bytes <= 0) {
return '';
}
const units = ['B', 'K', 'M', 'G', 'T', 'P'];
let value = bytes;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex++;
}
if (unitIndex === 0) {
return `${Math.round(value)}B`;
}
return `${value.toFixed(1).replace(/\.0$/, '')}${units[unitIndex]}`;
}
function formatSystemdCpu(cpuNanoseconds: string): string {
const nanoseconds = Number(cpuNanoseconds);
if (!Number.isFinite(nanoseconds) || nanoseconds <= 0) {
return '';
}
const milliseconds = nanoseconds / 1_000_000;
if (milliseconds < 1000) {
return `${Math.round(milliseconds)}ms`;
}
const seconds = milliseconds / 1000;
if (seconds < 60) {
return `${seconds.toFixed(seconds >= 10 ? 1 : 3).replace(/\.?0+$/, '')}s`;
}
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
if (minutes < 60) {
return `${minutes}min ${
remainingSeconds.toFixed(remainingSeconds >= 10 ? 1 : 3).replace(/\.?0+$/, '')
}s`;
}
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
return `${hours}h ${remainingMinutes}min`;
}
/**
* Class for managing systemd service
@@ -164,7 +225,7 @@ WantedBy=multi-user.target
}`,
);
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 {
logger.log('');
@@ -223,51 +284,69 @@ WantedBy=multi-user.target
* Display the systemd service status
* @private
*/
private getServiceStatusSnapshot(): IServiceStatusSnapshot {
const output = execFileSync(
'systemctl',
[
'show',
'nupst.service',
'--property=LoadState,ActiveState,SubState,MainPID,MemoryCurrent,CPUUsageNSec',
],
{ encoding: 'utf8' },
);
const properties = new Map<string, string>();
for (const line of output.split('\n')) {
const separatorIndex = line.indexOf('=');
if (separatorIndex === -1) {
continue;
}
properties.set(line.slice(0, separatorIndex), line.slice(separatorIndex + 1));
}
const pid = properties.get('MainPID') || '';
return {
loadState: properties.get('LoadState') || '',
activeState: properties.get('ActiveState') || '',
subState: properties.get('SubState') || '',
pid: pid !== '0' ? pid : '',
memory: formatSystemdMemory(properties.get('MemoryCurrent') || ''),
cpu: formatSystemdCpu(properties.get('CPUUsageNSec') || ''),
};
}
private displayServiceStatus(): void {
try {
const serviceStatus = execSync('systemctl status nupst.service').toString();
const lines = serviceStatus.split('\n');
// Parse key information from systemctl output
let isActive = false;
let pid = '';
let memory = '';
let cpu = '';
for (const line of lines) {
if (line.includes('Active:')) {
isActive = line.includes('active (running)');
} else if (line.includes('Main PID:')) {
const match = line.match(/Main PID:\s+(\d+)/);
if (match) pid = match[1];
} else if (line.includes('Memory:')) {
const match = line.match(/Memory:\s+([\d.]+[A-Z])/);
if (match) memory = match[1];
} else if (line.includes('CPU:')) {
const match = line.match(/CPU:\s+([\d.]+(?:ms|s))/);
if (match) cpu = match[1];
}
}
const snapshot = this.getServiceStatusSnapshot();
// Display beautiful status
logger.log('');
if (isActive) {
if (snapshot.loadState === 'not-found') {
logger.log(
`${symbols.running} ${theme.success('Service:')} ${
theme.statusActive('active (running)')
}`,
`${symbols.stopped} ${theme.dim('Service:')} ${theme.statusInactive('not installed')}`,
);
} else if (snapshot.activeState === 'active') {
const serviceState = snapshot.subState
? `${snapshot.activeState} (${snapshot.subState})`
: snapshot.activeState;
logger.log(
`${symbols.running} ${theme.success('Service:')} ${theme.statusActive(serviceState)}`,
);
} else {
const serviceState = snapshot.subState && snapshot.subState !== snapshot.activeState
? `${snapshot.activeState} (${snapshot.subState})`
: snapshot.activeState || 'inactive';
logger.log(
`${symbols.stopped} ${theme.dim('Service:')} ${theme.statusInactive('inactive')}`,
`${symbols.stopped} ${theme.dim('Service:')} ${theme.statusInactive(serviceState)}`,
);
}
if (pid || memory || cpu) {
if (snapshot.pid || snapshot.memory || snapshot.cpu) {
const details = [];
if (pid) details.push(`PID: ${theme.dim(pid)}`);
if (memory) details.push(`Memory: ${theme.dim(memory)}`);
if (cpu) details.push(`CPU: ${theme.dim(cpu)}`);
if (snapshot.pid) details.push(`PID: ${theme.dim(snapshot.pid)}`);
if (snapshot.memory) details.push(`Memory: ${theme.dim(snapshot.memory)}`);
if (snapshot.cpu) details.push(`CPU: ${theme.dim(snapshot.cpu)}`);
logger.log(` ${details.join(' ')}`);
}
logger.log('');
@@ -316,7 +395,6 @@ WantedBy=multi-user.target
type: 'shutdown',
thresholds: config.thresholds,
triggerMode: 'onlyThresholds',
shutdownDelay: 5,
},
]
: [],
@@ -346,13 +424,26 @@ WantedBy=multi-user.target
*/
private async displaySingleUpsStatus(ups: IUpsConfig, snmp: NupstSnmp): Promise<void> {
try {
// Create a test config with a short timeout
const testConfig = {
...ups.snmp,
timeout: Math.min(ups.snmp.timeout, 10000), // Use at most 10 seconds for status check
};
const defaultShutdownDelay = this.daemon.getConfig().defaultShutdownDelay ??
SHUTDOWN.DEFAULT_DELAY_MINUTES;
const protocol = ups.protocol || 'snmp';
let status;
const status = await snmp.getUpsStatus(testConfig);
if (protocol === 'upsd' && ups.upsd) {
const testConfig = {
...ups.upsd,
timeout: Math.min(ups.upsd.timeout, 10000),
};
status = await this.daemon.getNupstUpsd().getUpsStatus(testConfig);
} else if (ups.snmp) {
const testConfig = {
...ups.snmp,
timeout: Math.min(ups.snmp.timeout, 10000),
};
status = await snmp.getUpsStatus(testConfig);
} else {
throw new Error('No protocol configuration found');
}
// Determine status symbol based on power status
let statusSymbol = symbols.unknown;
@@ -396,7 +487,12 @@ WantedBy=multi-user.target
);
// Display host info
logger.log(` ${theme.dim(`Host: ${ups.snmp.host}:${ups.snmp.port}`)}`);
const hostInfo = protocol === 'upsd' && ups.upsd
? `${ups.upsd.host}:${ups.upsd.port} (UPSD)`
: ups.snmp
? `${ups.snmp.host}:${ups.snmp.port} (SNMP)`
: 'N/A';
logger.log(` ${theme.dim(`Host: ${hostInfo}`)}`);
// Display groups if any
if (ups.groups && ups.groups.length > 0) {
@@ -416,14 +512,20 @@ WantedBy=multi-user.target
actionDesc += ` (${
action.triggerMode || 'onlyThresholds'
}: battery<${action.thresholds.battery}%, runtime<${action.thresholds.runtime}min`;
if (action.shutdownDelay) {
actionDesc += `, delay=${action.shutdownDelay}s`;
if (action.type === 'shutdown') {
const shutdownDelay = action.shutdownDelay ?? defaultShutdownDelay;
actionDesc += `, delay=${shutdownDelay}min`;
} else if (action.type === 'proxmox' && action.proxmoxHaPolicy === 'haStop') {
actionDesc += ', ha=stop';
}
actionDesc += ')';
} else {
actionDesc += ` (${action.triggerMode || 'onlyPowerChanges'}`;
if (action.shutdownDelay) {
actionDesc += `, delay=${action.shutdownDelay}s`;
if (action.type === 'shutdown') {
const shutdownDelay = action.shutdownDelay ?? defaultShutdownDelay;
actionDesc += `, delay=${shutdownDelay}min`;
} else if (action.type === 'proxmox' && action.proxmoxHaPolicy === 'haStop') {
actionDesc += ', ha=stop';
}
actionDesc += ')';
}
@@ -434,11 +536,16 @@ WantedBy=multi-user.target
logger.log('');
} catch (error) {
// 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(
` ${symbols.error} ${theme.highlight(ups.name)} - ${theme.error('Connection failed')}`,
);
logger.log(` ${theme.dim(error instanceof Error ? error.message : String(error))}`);
logger.log(` ${theme.dim(`Host: ${ups.snmp.host}:${ups.snmp.port}`)}`);
logger.log(` ${theme.dim(`Host: ${errorHostInfo}`)}`);
logger.log('');
}
}
@@ -485,20 +592,27 @@ WantedBy=multi-user.target
// Display actions if any
if (group.actions && group.actions.length > 0) {
const defaultShutdownDelay = config.defaultShutdownDelay ?? SHUTDOWN.DEFAULT_DELAY_MINUTES;
for (const action of group.actions) {
let actionDesc = `${action.type}`;
if (action.thresholds) {
actionDesc += ` (${
action.triggerMode || 'onlyThresholds'
}: battery<${action.thresholds.battery}%, runtime<${action.thresholds.runtime}min`;
if (action.shutdownDelay) {
actionDesc += `, delay=${action.shutdownDelay}s`;
if (action.type === 'shutdown') {
const shutdownDelay = action.shutdownDelay ?? defaultShutdownDelay;
actionDesc += `, delay=${shutdownDelay}min`;
} else if (action.type === 'proxmox' && action.proxmoxHaPolicy === 'haStop') {
actionDesc += ', ha=stop';
}
actionDesc += ')';
} else {
actionDesc += ` (${action.triggerMode || 'onlyPowerChanges'}`;
if (action.shutdownDelay) {
actionDesc += `, delay=${action.shutdownDelay}s`;
if (action.type === 'shutdown') {
const shutdownDelay = action.shutdownDelay ?? defaultShutdownDelay;
actionDesc += `, delay=${shutdownDelay}min`;
} else if (action.type === 'proxmox' && action.proxmoxHaPolicy === 'haStop') {
actionDesc += ', ha=stop';
}
actionDesc += ')';
}

16
ts/upgrade-changelog.ts Normal file
View File

@@ -0,0 +1,16 @@
import { SmartChangelog } from 'npm:@push.rocks/smartchangelog@^0.1.0';
export const renderUpgradeChangelog = (
changelogMarkdown: string,
currentVersion: string,
latestVersion: string,
): string => {
const changelog = SmartChangelog.fromMarkdown(changelogMarkdown);
const entries = changelog.getEntriesBetween(currentVersion, latestVersion);
if (entries.length === 0) {
return '';
}
return entries.map((entry) => entry.toCliString()).join('\n\n');
};

172
ts/ups-monitoring.ts Normal file
View File

@@ -0,0 +1,172 @@
import type { IActionConfig } from './actions/base-action.ts';
import { NETWORK } from './constants.ts';
import type { IUpsStatus as IProtocolUpsStatus } from './snmp/types.ts';
import { createInitialUpsStatus, type IUpsIdentity, type IUpsStatus } from './ups-status.ts';
export interface ISuccessfulUpsPollSnapshot {
updatedStatus: IUpsStatus;
transition: 'none' | 'recovered' | 'powerStatusChange';
previousStatus?: IUpsStatus;
downtimeSeconds?: number;
}
export interface IFailedUpsPollSnapshot {
updatedStatus: IUpsStatus;
transition: 'none' | 'unreachable';
failures: number;
previousStatus?: IUpsStatus;
}
export function ensureUpsStatus(
currentStatus: IUpsStatus | undefined,
ups: IUpsIdentity,
now: number = Date.now(),
): IUpsStatus {
return currentStatus || createInitialUpsStatus(ups, now);
}
export function buildSuccessfulUpsPollSnapshot(
ups: IUpsIdentity,
polledStatus: IProtocolUpsStatus,
currentStatus: IUpsStatus | undefined,
currentTime: number,
): ISuccessfulUpsPollSnapshot {
const previousStatus = ensureUpsStatus(currentStatus, ups, currentTime);
const updatedStatus: IUpsStatus = {
id: ups.id,
name: ups.name,
powerStatus: polledStatus.powerStatus,
batteryCapacity: polledStatus.batteryCapacity,
batteryRuntime: polledStatus.batteryRuntime,
outputLoad: polledStatus.outputLoad,
outputPower: polledStatus.outputPower,
outputVoltage: polledStatus.outputVoltage,
outputCurrent: polledStatus.outputCurrent,
lastCheckTime: currentTime,
lastStatusChange: previousStatus.lastStatusChange || currentTime,
consecutiveFailures: 0,
unreachableSince: 0,
};
if (previousStatus.powerStatus === 'unreachable') {
updatedStatus.lastStatusChange = currentTime;
return {
updatedStatus,
transition: 'recovered',
previousStatus,
downtimeSeconds: Math.round((currentTime - previousStatus.unreachableSince) / 1000),
};
}
if (previousStatus.powerStatus !== polledStatus.powerStatus) {
updatedStatus.lastStatusChange = currentTime;
return {
updatedStatus,
transition: 'powerStatusChange',
previousStatus,
};
}
return {
updatedStatus,
transition: 'none',
previousStatus: currentStatus,
};
}
export function buildFailedUpsPollSnapshot(
ups: IUpsIdentity,
currentStatus: IUpsStatus | undefined,
currentTime: number,
): IFailedUpsPollSnapshot {
const previousStatus = ensureUpsStatus(currentStatus, ups, currentTime);
const failures = Math.min(
previousStatus.consecutiveFailures + 1,
NETWORK.MAX_CONSECUTIVE_FAILURES,
);
if (
failures >= NETWORK.CONSECUTIVE_FAILURE_THRESHOLD &&
previousStatus.powerStatus !== 'unreachable'
) {
return {
updatedStatus: {
...previousStatus,
consecutiveFailures: failures,
powerStatus: 'unreachable',
unreachableSince: currentTime,
lastStatusChange: currentTime,
},
transition: 'unreachable',
failures,
previousStatus,
};
}
return {
updatedStatus: {
...previousStatus,
consecutiveFailures: failures,
},
transition: 'none',
failures,
previousStatus: currentStatus,
};
}
export function hasThresholdViolation(
powerStatus: IProtocolUpsStatus['powerStatus'],
batteryCapacity: number,
batteryRuntime: number,
actions: IActionConfig[] | undefined,
): boolean {
return getActionThresholdStates(powerStatus, batteryCapacity, batteryRuntime, actions).some(
Boolean,
);
}
export function isActionThresholdExceeded(
actionConfig: IActionConfig,
powerStatus: IProtocolUpsStatus['powerStatus'],
batteryCapacity: number,
batteryRuntime: number,
): boolean {
if (powerStatus !== 'onBattery' || !actionConfig.thresholds) {
return false;
}
return (
batteryCapacity < actionConfig.thresholds.battery ||
batteryRuntime < actionConfig.thresholds.runtime
);
}
export function getActionThresholdStates(
powerStatus: IProtocolUpsStatus['powerStatus'],
batteryCapacity: number,
batteryRuntime: number,
actions: IActionConfig[] | undefined,
): boolean[] {
if (!actions || actions.length === 0) {
return [];
}
return actions.map((actionConfig) =>
isActionThresholdExceeded(actionConfig, powerStatus, batteryCapacity, batteryRuntime)
);
}
export function getEnteredThresholdIndexes(
previousStates: boolean[] | undefined,
currentStates: boolean[],
): number[] {
const enteredIndexes: number[] = [];
for (let index = 0; index < currentStates.length; index++) {
if (currentStates[index] && !previousStates?.[index]) {
enteredIndexes.push(index);
}
}
return enteredIndexes;
}

38
ts/ups-status.ts Normal file
View File

@@ -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,
};
}

271
ts/upsd/client.ts Normal file
View File

@@ -0,0 +1,271 @@
/**
* UPSD/NIS (Network UPS Tools) TCP client
*
* Connects to a NUT upsd server via TCP and queries UPS variables
* using the NUT network protocol (RFC-style line protocol).
*
* Protocol format:
* Request: GET VAR <upsname> <varname>\n
* Response: VAR <upsname> <varname> "<value>"\n
* Logout: LOGOUT\n
*/
import * as net from 'node:net';
import { logger } from '../logger.ts';
import { UPSD } from '../constants.ts';
import type { IUpsdConfig } from './types.ts';
import type { IUpsStatus } from '../snmp/types.ts';
/**
* NupstUpsd - TCP client for the NUT UPSD protocol
*/
export class NupstUpsd {
private debug = false;
/**
* Enable debug logging
*/
public enableDebug(): void {
this.debug = true;
logger.info('UPSD debug mode enabled');
}
/**
* Get the current UPS status via UPSD protocol
* @param config UPSD connection configuration
* @returns UPS status matching the IUpsStatus interface
*/
public async getUpsStatus(config: IUpsdConfig): Promise<IUpsStatus> {
const host = config.host || '127.0.0.1';
const port = config.port || UPSD.DEFAULT_PORT;
const upsName = config.upsName || UPSD.DEFAULT_UPS_NAME;
const timeout = config.timeout || UPSD.DEFAULT_TIMEOUT_MS;
if (this.debug) {
logger.dim('---------------------------------------');
logger.dim('Getting UPS status via UPSD protocol:');
logger.dim(` Host: ${host}:${port}`);
logger.dim(` UPS Name: ${upsName}`);
logger.dim(` Timeout: ${timeout}ms`);
logger.dim('---------------------------------------');
}
// Variables to query from NUT
const varsToQuery = [
'ups.status',
'battery.charge',
'battery.runtime',
'ups.load',
'ups.realpower',
'output.voltage',
'output.current',
];
const values = new Map<string, string>();
// Open a TCP connection, query all variables, then logout
const conn = await this.connect(host, port, timeout);
try {
// Authenticate if credentials provided
if (config.username && config.password) {
await this.sendCommand(conn, `USERNAME ${config.username}`, timeout);
await this.sendCommand(conn, `PASSWORD ${config.password}`, timeout);
}
// Query each variable
for (const varName of varsToQuery) {
const value = await this.safeGetVar(conn, upsName, varName, timeout);
if (value !== null) {
values.set(varName, value);
}
}
// Logout gracefully
try {
await this.sendCommand(conn, 'LOGOUT', timeout);
} catch (_e) {
// Ignore logout errors
}
} finally {
conn.destroy();
}
// Map NUT variables to IUpsStatus
const powerStatus = this.parsePowerStatus(values.get('ups.status') || '');
const batteryCapacity = parseFloat(values.get('battery.charge') || '0');
const batteryRuntimeSeconds = parseFloat(values.get('battery.runtime') || '0');
const batteryRuntime = Math.floor(batteryRuntimeSeconds / 60); // NUT reports seconds, convert to minutes
const outputLoad = parseFloat(values.get('ups.load') || '0');
const outputPower = parseFloat(values.get('ups.realpower') || '0');
const outputVoltage = parseFloat(values.get('output.voltage') || '0');
const outputCurrent = parseFloat(values.get('output.current') || '0');
const result: IUpsStatus = {
powerStatus,
batteryCapacity: isNaN(batteryCapacity) ? 0 : batteryCapacity,
batteryRuntime: isNaN(batteryRuntime) ? 0 : batteryRuntime,
outputLoad: isNaN(outputLoad) ? 0 : outputLoad,
outputPower: isNaN(outputPower) ? 0 : outputPower,
outputVoltage: isNaN(outputVoltage) ? 0 : outputVoltage,
outputCurrent: isNaN(outputCurrent) ? 0 : outputCurrent,
raw: Object.fromEntries(values),
};
if (this.debug) {
logger.dim('---------------------------------------');
logger.dim('UPSD status result:');
logger.dim(` Power Status: ${result.powerStatus}`);
logger.dim(` Battery Capacity: ${result.batteryCapacity}%`);
logger.dim(` Battery Runtime: ${result.batteryRuntime} minutes`);
logger.dim(` Output Load: ${result.outputLoad}%`);
logger.dim(` Output Power: ${result.outputPower} watts`);
logger.dim(` Output Voltage: ${result.outputVoltage} volts`);
logger.dim(` Output Current: ${result.outputCurrent} amps`);
logger.dim('---------------------------------------');
}
return result;
}
/**
* Open a TCP connection to the UPSD server
*/
private connect(host: string, port: number, timeout: number): Promise<net.Socket> {
return new Promise((resolve, reject) => {
const socket = net.createConnection({ host, port }, () => {
if (this.debug) {
logger.dim(`Connected to UPSD at ${host}:${port}`);
}
resolve(socket);
});
socket.setTimeout(timeout);
socket.on('timeout', () => {
socket.destroy();
reject(new Error(`UPSD connection timed out after ${timeout}ms`));
});
socket.on('error', (err) => {
reject(new Error(`UPSD connection error: ${err.message}`));
});
});
}
/**
* Send a command and read the response line
*/
private sendCommand(socket: net.Socket, command: string, timeout: number): Promise<string> {
return new Promise((resolve, reject) => {
let responseData = '';
const timer = setTimeout(() => {
cleanup();
reject(new Error(`UPSD command timed out: ${command}`));
}, timeout);
const decoder = new TextDecoder();
const onData = (data: Uint8Array) => {
responseData += decoder.decode(data, { stream: true });
// Look for newline to indicate end of response
const newlineIdx = responseData.indexOf('\n');
if (newlineIdx !== -1) {
cleanup();
const line = responseData.substring(0, newlineIdx).trim();
if (this.debug) {
logger.dim(`UPSD << ${line}`);
}
resolve(line);
}
};
const onError = (err: Error) => {
cleanup();
reject(err);
};
const cleanup = () => {
clearTimeout(timer);
socket.removeListener('data', onData);
socket.removeListener('error', onError);
};
socket.on('data', onData);
socket.on('error', onError);
if (this.debug) {
logger.dim(`UPSD >> ${command}`);
}
socket.write(command + '\n');
});
}
/**
* Safely get a single NUT variable, returning null on error
*/
private async safeGetVar(
socket: net.Socket,
upsName: string,
varName: string,
timeout: number,
): Promise<string | null> {
try {
const response = await this.sendCommand(
socket,
`GET VAR ${upsName} ${varName}`,
timeout,
);
// Expected response: VAR <upsname> <varname> "<value>"
// Also handle: ERR ... for unsupported variables
if (response.startsWith('ERR')) {
if (this.debug) {
logger.dim(`UPSD variable ${varName} not available: ${response}`);
}
return null;
}
// Parse: VAR ups battery.charge "100"
const match = response.match(/^VAR\s+\S+\s+\S+\s+"(.*)"/);
if (match) {
return match[1];
}
// Some implementations don't quote the value
const parts = response.split(/\s+/);
if (parts.length >= 4 && parts[0] === 'VAR') {
return parts.slice(3).join(' ').replace(/^"/, '').replace(/"$/, '');
}
if (this.debug) {
logger.dim(`UPSD unexpected response for ${varName}: ${response}`);
}
return null;
} catch (error) {
if (this.debug) {
logger.dim(
`UPSD error getting ${varName}: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
return null;
}
}
/**
* Parse NUT ups.status tokens into a power status
* NUT status tokens: OL (online), OB (on battery), LB (low battery),
* HB (high battery), RB (replace battery), CHRG (charging), etc.
*/
private parsePowerStatus(statusString: string): 'online' | 'onBattery' | 'unknown' {
const tokens = statusString.trim().split(/\s+/);
if (tokens.includes('OB')) {
return 'onBattery';
}
if (tokens.includes('OL')) {
return 'online';
}
return 'unknown';
}
}

7
ts/upsd/index.ts Normal file
View File

@@ -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';

21
ts/upsd/types.ts Normal file
View File

@@ -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;
}