Compare commits

...

16 Commits

Author SHA1 Message Date
jkunz bf4d519428 v5.6.0
Release / build-and-release (push) Successful in 54s
2026-04-14 18:47:37 +00:00
jkunz 579667b3cd feat(config): add configurable default shutdown delay for shutdown actions 2026-04-14 18:47:37 +00:00
jkunz 8dc0248763 v5.5.1
Release / build-and-release (push) Successful in 51s
2026-04-14 14:27:29 +00:00
jkunz 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
jkunz 2adf1d5548 v5.5.0
Release / build-and-release (push) Successful in 2m27s
2026-04-02 08:29:16 +00:00
jkunz 067a7666e4 feat(proxmox): add Proxmox CLI auto-detection and interactive action setup improvements 2026-04-02 08:29:16 +00:00
jkunz 0d863a1028 v5.4.1
Release / build-and-release (push) Successful in 51s
2026-03-30 06:50:36 +00:00
jkunz c410a663b1 fix(deps): bump tsdeno and net-snmp patch dependencies 2026-03-30 06:50:36 +00:00
jkunz 6aa1fc651f v5.4.0
Release / build-and-release (push) Failing after 15s
2026-03-30 06:46:28 +00:00
jkunz 11e549e68e feat(snmp): add configurable SNMP runtime units with v4.3 migration support 2026-03-30 06:46:28 +00:00
jkunz 0fb9678976 v5.3.3
Release / build-and-release (push) Successful in 1m24s
2026-03-18 09:49:29 +00:00
jkunz 635de0d932 fix(deps): add @git.zone/tsdeno as a development dependency 2026-03-18 09:49:29 +00:00
jkunz 0916effb53 v5.3.2
Release / build-and-release (push) Failing after 7s
2026-03-18 09:48:16 +00:00
jkunz 05242a1c7d fix(build): replace manual release compilation workflows with tsdeno-based build configuration 2026-03-18 09:48:16 +00:00
jkunz 0d20dce520 v5.3.1
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
jkunz 1c50509497 fix(cli): rename the update command references to upgrade across the CLI and documentation 2026-03-15 12:04:05 +00:00
44 changed files with 4849 additions and 1263 deletions
-84
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
-129
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 ""
+14 -60
View File
@@ -8,6 +8,8 @@ on:
jobs: jobs:
build-and-release: build-and-release:
runs-on: ubuntu-latest runs-on: ubuntu-latest
container:
image: code.foss.global/host.today/ht-docker-node:latest
steps: steps:
- name: Checkout code - name: Checkout code
@@ -20,6 +22,17 @@ jobs:
with: with:
deno-version: v2.x deno-version: v2.x
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Enable corepack
run: corepack enable
- name: Install dependencies
run: pnpm install --ignore-scripts
- name: Get version from tag - name: Get version from tag
id: version id: version
run: | run: |
@@ -41,57 +54,7 @@ jobs:
fi fi
- name: Compile binaries for all platforms - name: Compile binaries for all platforms
run: | run: mkdir -p dist/binaries && npx tsdeno compile
echo "================================================"
echo " NUPST Release Compilation"
echo " Version: ${{ steps.version.outputs.version }}"
echo "================================================"
echo ""
# Clean up old binaries and create fresh directory
rm -rf dist/binaries
mkdir -p dist/binaries
echo "→ Cleaned old binaries from dist/binaries"
echo ""
# Linux x86_64
echo "→ Compiling for Linux x86_64..."
deno compile --allow-all --no-check \
--output dist/binaries/nupst-linux-x64 \
--target x86_64-unknown-linux-gnu mod.ts
echo " ✓ Linux x86_64 complete"
# Linux ARM64
echo "→ Compiling for Linux ARM64..."
deno compile --allow-all --no-check \
--output dist/binaries/nupst-linux-arm64 \
--target aarch64-unknown-linux-gnu mod.ts
echo " ✓ Linux ARM64 complete"
# macOS x86_64
echo "→ Compiling for macOS x86_64..."
deno compile --allow-all --no-check \
--output dist/binaries/nupst-macos-x64 \
--target x86_64-apple-darwin mod.ts
echo " ✓ macOS x86_64 complete"
# macOS ARM64
echo "→ Compiling for macOS ARM64..."
deno compile --allow-all --no-check \
--output dist/binaries/nupst-macos-arm64 \
--target aarch64-apple-darwin mod.ts
echo " ✓ macOS ARM64 complete"
# Windows x86_64
echo "→ Compiling for Windows x86_64..."
deno compile --allow-all --no-check \
--output dist/binaries/nupst-windows-x64.exe \
--target x86_64-pc-windows-msvc mod.ts
echo " ✓ Windows x86_64 complete"
echo ""
echo "All binaries compiled successfully!"
ls -lh dist/binaries/
- name: Generate SHA256 checksums - name: Generate SHA256 checksums
run: | run: |
@@ -105,7 +68,6 @@ jobs:
run: | run: |
VERSION="${{ steps.version.outputs.version }}" VERSION="${{ steps.version.outputs.version }}"
# Check if CHANGELOG.md exists
if [ ! -f CHANGELOG.md ]; then if [ ! -f CHANGELOG.md ]; then
echo "No CHANGELOG.md found, using default release notes" echo "No CHANGELOG.md found, using default release notes"
cat > /tmp/release_notes.md << EOF cat > /tmp/release_notes.md << EOF
@@ -133,8 +95,6 @@ jobs:
SHA256 checksums are provided in SHA256SUMS.txt SHA256 checksums are provided in SHA256SUMS.txt
EOF EOF
else else
# Try to extract section for this version from CHANGELOG.md
# This is a simple extraction - adjust based on your CHANGELOG format
awk "/## \[$VERSION\]/,/## \[/" CHANGELOG.md | sed '$d' > /tmp/release_notes.md || cat > /tmp/release_notes.md << EOF awk "/## \[$VERSION\]/,/## \[/" CHANGELOG.md | sed '$d' > /tmp/release_notes.md || cat > /tmp/release_notes.md << EOF
## NUPST $VERSION ## NUPST $VERSION
@@ -158,7 +118,6 @@ jobs:
echo "Checking for existing release $VERSION..." echo "Checking for existing release $VERSION..."
# Try to get existing release by tag
EXISTING_RELEASE_ID=$(curl -s \ EXISTING_RELEASE_ID=$(curl -s \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
"https://code.foss.global/api/v1/repos/serve.zone/nupst/releases/tags/$VERSION" \ "https://code.foss.global/api/v1/repos/serve.zone/nupst/releases/tags/$VERSION" \
@@ -178,9 +137,7 @@ jobs:
- name: Create Gitea Release - name: Create Gitea Release
run: | run: |
VERSION="${{ steps.version.outputs.version }}" VERSION="${{ steps.version.outputs.version }}"
RELEASE_NOTES=$(cat /tmp/release_notes.md)
# Create the release
echo "Creating release for $VERSION..." echo "Creating release for $VERSION..."
RELEASE_ID=$(curl -X POST -s \ RELEASE_ID=$(curl -X POST -s \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
@@ -196,7 +153,6 @@ jobs:
echo "Release created with ID: $RELEASE_ID" echo "Release created with ID: $RELEASE_ID"
# Upload binaries as release assets
for binary in dist/binaries/*; do for binary in dist/binaries/*; do
filename=$(basename "$binary") filename=$(basename "$binary")
echo "Uploading $filename..." echo "Uploading $filename..."
@@ -213,12 +169,10 @@ jobs:
run: | run: |
echo "Cleaning up old releases (keeping only last 3)..." echo "Cleaning up old releases (keeping only last 3)..."
# Fetch all releases sorted by creation date
RELEASES=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ RELEASES=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
"https://code.foss.global/api/v1/repos/serve.zone/nupst/releases" | \ "https://code.foss.global/api/v1/repos/serve.zone/nupst/releases" | \
jq -r 'sort_by(.created_at) | reverse | .[3:] | .[].id') jq -r 'sort_by(.created_at) | reverse | .[3:] | .[].id')
# Delete old releases
if [ -n "$RELEASES" ]; then if [ -n "$RELEASES" ]; then
echo "Found releases to delete:" echo "Found releases to delete:"
for release_id in $RELEASES; do for release_id in $RELEASES; do
+64
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": {}
}
+60
View File
@@ -1,5 +1,65 @@
# Changelog # Changelog
## 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) ## 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 (NUT) protocol support, Proxmox VM shutdown action, pause/resume monitoring, and network-loss/unreachable handling; bump config version to 4.2
+2 -3
View File
@@ -1,12 +1,11 @@
{ {
"name": "@serve.zone/nupst", "name": "@serve.zone/nupst",
"version": "5.3.0", "version": "5.6.0",
"exports": "./mod.ts", "exports": "./mod.ts",
"nodeModulesDir": "auto", "nodeModulesDir": "auto",
"tasks": { "tasks": {
"dev": "deno run --allow-all mod.ts", "dev": "deno run --allow-all mod.ts",
"compile": "deno task compile:all", "compile": "tsdeno compile",
"compile:all": "bash scripts/compile-all.sh",
"test": "deno test --allow-all test/", "test": "deno test --allow-all test/",
"test:watch": "deno test --allow-all --watch test/", "test:watch": "deno test --allow-all --watch test/",
"check": "deno check mod.ts", "check": "deno check mod.ts",
+1 -6
View File
@@ -25,12 +25,7 @@ import { NupstCli } from './ts/cli.ts';
*/ */
async function main(): Promise<void> { async function main(): Promise<void> {
const cli = new NupstCli(); const cli = new NupstCli();
await cli.parseAndExecute(Deno.args);
// Deno.args is already 0-indexed (unlike Node's process.argv which starts at index 2)
// We need to prepend placeholder args to match the existing CLI parser expectations
const args = ['deno', 'mod.ts', ...Deno.args];
await cli.parseAndExecute(args);
} }
// Execute main and handle errors // Execute main and handle errors
-20
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": {}
}
+5 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@serve.zone/nupst", "name": "@serve.zone/nupst",
"version": "5.3.0", "version": "5.6.0",
"description": "Network UPS Shutdown Tool - Monitor SNMP-enabled UPS devices and orchestrate graceful system shutdowns during power emergencies", "description": "Network UPS Shutdown Tool - Monitor SNMP-enabled UPS devices and orchestrate graceful system shutdowns during power emergencies",
"keywords": [ "keywords": [
"ups", "ups",
@@ -62,5 +62,8 @@
"access": "public", "access": "public",
"registry": "https://registry.npmjs.org/" "registry": "https://registry.npmjs.org/"
}, },
"packageManager": "pnpm@10.18.1+sha512.77a884a165cbba2d8d1c19e3b4880eee6d2fcabd0d879121e282196b80042351d5eb3ca0935fa599da1dc51265cc68816ad2bddd2a2de5ea9fdf92adbec7cd34" "packageManager": "pnpm@10.18.1+sha512.77a884a165cbba2d8d1c19e3b4880eee6d2fcabd0d879121e282196b80042351d5eb3ca0935fa599da1dc51265cc68816ad2bddd2a2de5ea9fdf92adbec7cd34",
"devDependencies": {
"@git.zone/tsdeno": "^1.3.1"
}
} }
+2324
View File
File diff suppressed because it is too large Load Diff
+66 -3
View File
@@ -36,9 +36,14 @@
- Uses: `IUpsConfig`, `INupstConfig`, `ISnmpConfig`, `IActionConfig`, `IThresholds`, - Uses: `IUpsConfig`, `INupstConfig`, `ISnmpConfig`, `IActionConfig`, `IThresholds`,
`ISnmpUpsStatus` `ISnmpUpsStatus`
7. **SNMP Manager Boundary Types (`ts/snmp/manager.ts`)**
- Added local wrapper interfaces for the untyped `net-snmp` package surface used by NUPST
- SNMP metric reads now coerce values explicitly instead of relying on `any`-typed responses
## Features Added (February 2026) ## Features Added (February 2026)
### Network Loss Handling ### Network Loss Handling
- `TPowerStatus` extended with `'unreachable'` state - `TPowerStatus` extended with `'unreachable'` state
- `IUpsStatus` has `consecutiveFailures` and `unreachableSince` tracking - `IUpsStatus` has `consecutiveFailures` and `unreachableSince` tracking
- After `NETWORK.CONSECUTIVE_FAILURE_THRESHOLD` (3) failures, UPS transitions to `unreachable` - After `NETWORK.CONSECUTIVE_FAILURE_THRESHOLD` (3) failures, UPS transitions to `unreachable`
@@ -46,22 +51,63 @@
- Recovery is logged when UPS comes back from unreachable - Recovery is logged when UPS comes back from unreachable
### UPSD/NIS Protocol Support ### UPSD/NIS Protocol Support
- New `ts/upsd/` directory with TCP client for NUT (Network UPS Tools) servers - New `ts/upsd/` directory with TCP client for NUT (Network UPS Tools) servers
- `ts/protocol/` directory with `ProtocolResolver` for protocol-agnostic status queries - `ts/protocol/` directory with `ProtocolResolver` for protocol-agnostic status queries
- `IUpsConfig.protocol` field: `'snmp'` (default) or `'upsd'` - `IUpsConfig.protocol` field: `'snmp'` (default) or `'upsd'`
- `IUpsConfig.snmp` is now optional (not needed for UPSD devices) - `IUpsConfig.snmp` is now optional (not needed for UPSD devices)
- CLI supports protocol selection during `nupst ups add` - CLI supports protocol selection during `nupst ups add`
- Config version bumped to `4.2` with migration from `4.1` - Config version is now `4.3`, including the `4.2` -> `4.3` runtime unit migration
### Pause/Resume Command ### Pause/Resume Command
- File-based signaling via `/etc/nupst/pause` JSON file - File-based signaling via `/etc/nupst/pause` JSON file
- `nupst pause [--duration 30m|2h|1d]` creates pause file - `nupst pause [--duration 30m|2h|1d]` creates pause file
- `nupst resume` deletes 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 - Daemon polls continue but actions are suppressed while paused
- Auto-resume after duration expires - Auto-resume after duration expires
- HTTP API includes pause state in response - 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 ### Proxmox VM Shutdown Action
- New action type `'proxmox'` in `ts/actions/proxmox-action.ts` - New action type `'proxmox'` in `ts/actions/proxmox-action.ts`
- Uses Proxmox REST API with PVEAPIToken authentication - Uses Proxmox REST API with PVEAPIToken authentication
- Shuts down QEMU VMs and LXC containers before host shutdown - Shuts down QEMU VMs and LXC containers before host shutdown
@@ -76,13 +122,30 @@
- **CLI Handlers**: All use the `helpers.withPrompt()` utility for interactive input - **CLI Handlers**: All use the `helpers.withPrompt()` utility for interactive input
- **Constants**: All timing values should be referenced from `ts/constants.ts` - **Constants**: All timing values should be referenced from `ts/constants.ts`
- **Actions**: Use `IActionConfig` from `ts/actions/base-action.ts` for action configuration - **Actions**: Use `IActionConfig` from `ts/actions/base-action.ts` for action configuration
- **Config version**: Currently `4.2`, migrations run automatically - **Action orchestration**: Use helpers from `ts/action-orchestration.ts` for action context and
execution decisions
- **Config watch logic**: Use helpers from `ts/config-watch.ts` for file event filtering and reload
transitions
- **Pause state**: Use `loadPauseSnapshot()` and `IPauseState` from `ts/pause-state.ts`
- **Shutdown execution**: Use `ShutdownExecutor` for OS-level shutdown command lookup and fallbacks
- **Shutdown monitoring**: Use helpers from `ts/shutdown-monitoring.ts` for emergency loop rows and
candidate selection
- **UPS status state**: Use `IUpsStatus` and `createInitialUpsStatus()` from `ts/ups-status.ts`
- **UPS poll transitions**: Use helpers from `ts/ups-monitoring.ts` for success/failure updates
- **Config version**: Currently `4.3`, migrations run automatically
## File Organization ## File Organization
``` ```
ts/ ts/
├── constants.ts # All timing/threshold constants ├── constants.ts # All timing/threshold constants
├── action-orchestration.ts # Action context and execution decisions
├── config-watch.ts # File watch filters and config reload transitions
├── shutdown-monitoring.ts # Shutdown loop rows and emergency selection
├── ups-monitoring.ts # Pure UPS poll transition and threshold helpers
├── pause-state.ts # Shared pause state types and transition detection
├── shutdown-executor.ts # Delayed/emergency shutdown command execution
├── ups-status.ts # Daemon UPS status shape and initializer
├── interfaces/ ├── interfaces/
│ └── nupst-accessor.ts # Interface to break circular deps │ └── nupst-accessor.ts # Interface to break circular deps
├── helpers/ ├── helpers/
@@ -103,7 +166,7 @@ ts/
│ └── index.ts │ └── index.ts
├── migrations/ ├── migrations/
│ ├── migration-runner.ts │ ├── migration-runner.ts
│ └── migration-v4.1-to-v4.2.ts # Adds protocol field │ └── migration-v4.2-to-v4.3.ts # Adds SNMP runtimeUnit defaults
└── cli/ └── cli/
└── ... # All handlers use helpers.withPrompt() └── ... # All handlers use helpers.withPrompt()
``` ```
+65 -23
View File
@@ -12,7 +12,7 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
- **🔌 Multi-UPS Support** — Monitor multiple UPS devices from a single daemon - **🔌 Multi-UPS Support** — Monitor multiple UPS devices from a single daemon
- **📡 Dual Protocol Support** — SNMP (v1/v2c/v3) for network UPS + UPSD/NIS for USB-connected UPS via NUT - **📡 Dual Protocol Support** — SNMP (v1/v2c/v3) for network UPS + UPSD/NIS for USB-connected UPS via NUT
- **🖥️ Proxmox Integration** — Gracefully shut down QEMU VMs and LXC containers before host shutdown - **🖥️ Proxmox Integration** — Gracefully shut down QEMU VMs and LXC containers before host shutdown (auto-detects CLI tools — no API token needed on Proxmox hosts)
- **👥 Group Management** — Organize UPS devices into groups with flexible operating modes - **👥 Group Management** — Organize UPS devices into groups with flexible operating modes
- **Redundant Mode** — Only trigger actions when ALL UPS devices in a group are critical - **Redundant Mode** — Only trigger actions when ALL UPS devices in a group are critical
- **Non-Redundant Mode** — Trigger actions when ANY UPS device is critical - **Non-Redundant Mode** — Trigger actions when ANY UPS device is critical
@@ -211,7 +211,7 @@ nupst feature httpServer # Configure HTTP JSON status API
```bash ```bash
nupst config show # Display current configuration nupst config show # Display current configuration
nupst update # Update to latest version (requires root) nupst upgrade # Upgrade to latest version (requires root)
nupst uninstall # Completely remove NUPST (requires root) nupst uninstall # Completely remove NUPST (requires root)
``` ```
@@ -219,12 +219,16 @@ nupst uninstall # Completely remove NUPST (requires root)
NUPST stores configuration at `/etc/nupst/config.json`. The easiest way to configure is through the interactive CLI commands, but you can also edit the JSON directly. NUPST stores configuration at `/etc/nupst/config.json`. The easiest way to configure is through the interactive CLI commands, but you can also edit the JSON directly.
`defaultShutdownDelay` sets the inherited delay in minutes for shutdown actions that do not define
their own `shutdownDelay`.
### Example Configuration ### Example Configuration
```json ```json
{ {
"version": "4.2", "version": "4.3",
"checkInterval": 30000, "checkInterval": 30000,
"defaultShutdownDelay": 5,
"httpServer": { "httpServer": {
"enabled": true, "enabled": true,
"port": 8080, "port": 8080,
@@ -242,17 +246,17 @@ NUPST stores configuration at `/etc/nupst/config.json`. The easiest way to confi
"community": "public", "community": "public",
"version": 1, "version": 1,
"timeout": 5000, "timeout": 5000,
"upsModel": "cyberpower" "upsModel": "cyberpower",
"runtimeUnit": "ticks"
}, },
"actions": [ "actions": [
{ {
"type": "proxmox", "type": "proxmox",
"triggerMode": "onlyThresholds", "triggerMode": "onlyThresholds",
"thresholds": { "battery": 30, "runtime": 15 }, "thresholds": { "battery": 30, "runtime": 15 },
"proxmoxHost": "localhost", "proxmoxMode": "auto",
"proxmoxPort": 8006, "proxmoxExcludeIds": [],
"proxmoxTokenId": "root@pam!nupst", "proxmoxForceStop": true
"proxmoxTokenSecret": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}, },
{ {
"type": "shutdown", "type": "shutdown",
@@ -323,6 +327,7 @@ Each UPS device has a `protocol` field:
| `version` | SNMP version | `1`, `2`, or `3` | | `version` | SNMP version | `1`, `2`, or `3` |
| `timeout` | Timeout in milliseconds | Default: `5000` | | `timeout` | Timeout in milliseconds | Default: `5000` |
| `upsModel` | UPS brand/model | `cyberpower`, `apc`, `eaton`, `tripplite`, `liebert`, `custom` | | `upsModel` | UPS brand/model | `cyberpower`, `apc`, `eaton`, `tripplite`, `liebert`, `custom` |
| `runtimeUnit` | Battery runtime unit | `minutes`, `seconds`, or `ticks` (1/100s). Overrides auto-detection |
| `community` | Community string (v1/v2c) | Default: `"public"` | | `community` | Community string (v1/v2c) | Default: `"public"` |
**SNMPv3 fields** (when `version: 3`): **SNMPv3 fields** (when `version: 3`):
@@ -362,7 +367,7 @@ Actions define automated responses to UPS conditions. They run **sequentially in
| `shutdown` | Graceful system shutdown with configurable delay | | `shutdown` | Graceful system shutdown with configurable delay |
| `webhook` | HTTP POST/GET notification to external services | | `webhook` | HTTP POST/GET notification to external services |
| `script` | Execute custom shell scripts from `/etc/nupst/` | | `script` | Execute custom shell scripts from `/etc/nupst/` |
| `proxmox` | Shut down Proxmox QEMU VMs and LXC containers via REST API | | `proxmox` | Shut down Proxmox QEMU VMs and LXC containers (CLI or API) |
#### Common Fields #### Common Fields
@@ -394,7 +399,7 @@ Actions define automated responses to UPS conditions. They run **sequentially in
| Field | Description | Default | | Field | Description | Default |
| --------------- | ---------------------------------- | ------- | | --------------- | ---------------------------------- | ------- |
| `shutdownDelay` | Seconds to wait before shutdown | `5` | | `shutdownDelay` | Minutes to wait before shutdown | Inherits `defaultShutdownDelay` (`5`) |
#### Webhook Action #### Webhook Action
@@ -436,11 +441,38 @@ Actions define automated responses to UPS conditions. They run **sequentially in
Gracefully shuts down QEMU VMs and LXC containers on a Proxmox node before the host is shut down. Gracefully shuts down QEMU VMs and LXC containers on a Proxmox node before the host is shut down.
NUPST supports **two operation modes** for Proxmox:
| Mode | Description | Requirements |
| ------ | -------------------------------------------------------------- | ------------------------- |
| `cli` | Uses `qm`/`pct` commands directly — **no API token needed** 🎉 | Running as root on Proxmox host |
| `api` | Uses Proxmox REST API via HTTPS | API token required |
| `auto` | Prefers CLI if available, falls back to API (default) | — |
> 💡 **On a Proxmox host running as root** (the typical setup), NUPST auto-detects `qm` and `pct` CLI tools and uses them directly. No API token setup required!
**CLI mode example** (simplest — auto-detected on Proxmox hosts):
```json ```json
{ {
"type": "proxmox", "type": "proxmox",
"thresholds": { "battery": 30, "runtime": 15 }, "thresholds": { "battery": 30, "runtime": 15 },
"triggerMode": "onlyThresholds", "triggerMode": "onlyThresholds",
"proxmoxMode": "auto",
"proxmoxExcludeIds": [100, 101],
"proxmoxStopTimeout": 120,
"proxmoxForceStop": true
}
```
**API mode example** (for remote Proxmox hosts or non-root setups):
```json
{
"type": "proxmox",
"thresholds": { "battery": 30, "runtime": 15 },
"triggerMode": "onlyThresholds",
"proxmoxMode": "api",
"proxmoxHost": "localhost", "proxmoxHost": "localhost",
"proxmoxPort": 8006, "proxmoxPort": 8006,
"proxmoxTokenId": "root@pam!nupst", "proxmoxTokenId": "root@pam!nupst",
@@ -454,17 +486,18 @@ Gracefully shuts down QEMU VMs and LXC containers on a Proxmox node before the h
| Field | Description | Default | | Field | Description | Default |
| --------------------- | ----------------------------------------------- | ------------- | | --------------------- | ----------------------------------------------- | ------------- |
| `proxmoxHost` | Proxmox API host | `localhost` | | `proxmoxMode` | Operation mode | `auto` |
| `proxmoxPort` | Proxmox API port | `8006` | | `proxmoxHost` | Proxmox API host (API mode only) | `localhost` |
| `proxmoxPort` | Proxmox API port (API mode only) | `8006` |
| `proxmoxNode` | Proxmox node name | Auto-detect via hostname | | `proxmoxNode` | Proxmox node name | Auto-detect via hostname |
| `proxmoxTokenId` | API token ID (e.g. `root@pam!nupst`) | Required | | `proxmoxTokenId` | API token ID (API mode only) | |
| `proxmoxTokenSecret` | API token secret (UUID) | Required | | `proxmoxTokenSecret` | API token secret (API mode only) | |
| `proxmoxExcludeIds` | VM/CT IDs to skip | `[]` | | `proxmoxExcludeIds` | VM/CT IDs to skip | `[]` |
| `proxmoxStopTimeout` | Seconds to wait for graceful shutdown | `120` | | `proxmoxStopTimeout` | Seconds to wait for graceful shutdown | `120` |
| `proxmoxForceStop` | Force-stop VMs/CTs that don't shut down | `true` | | `proxmoxForceStop` | Force-stop VMs/CTs that don't shut down | `true` |
| `proxmoxInsecure` | Skip TLS verification (self-signed certs) | `true` | | `proxmoxInsecure` | Skip TLS verification (API mode only) | `true` |
**Setting up the API token on Proxmox:** **Setting up the API token** (only needed for API mode):
```bash ```bash
# Create token with full privileges (no privilege separation) # Create token with full privileges (no privilege separation)
@@ -581,16 +614,16 @@ UPS Devices (2):
Host: 192.168.1.100:161 (SNMP) Host: 192.168.1.100:161 (SNMP)
Groups: Data Center Groups: Data Center
Action: proxmox (onlyThresholds: battery<30%, runtime<15min) Action: proxmox (onlyThresholds: battery<30%, runtime<15min)
Action: shutdown (onlyThresholds: battery<20%, runtime<10min, delay=10s) Action: shutdown (onlyThresholds: battery<20%, runtime<10min, delay=10min)
✓ Local USB UPS (online - 95%, 2400min) ✓ Local USB UPS (online - 95%, 2400min)
Host: 127.0.0.1:3493 (UPSD) Host: 127.0.0.1:3493 (UPSD)
Action: shutdown (onlyThresholds: battery<15%, runtime<5min, delay=5s) Action: shutdown (onlyThresholds: battery<15%, runtime<5min, delay=5min)
Groups (1): Groups (1):
Data Center (redundant) Data Center (redundant)
UPS Devices (1): Main Server UPS UPS Devices (1): Main Server UPS
Action: shutdown (onlyThresholds: battery<10%, runtime<5min, delay=15s) Action: shutdown (onlyThresholds: battery<10%, runtime<5min, delay=15min)
``` ```
### Live Logs ### Live Logs
@@ -629,7 +662,7 @@ Full SNMPv3 support with authentication and encryption:
### Network Security ### Network Security
- Connects only to UPS devices and optionally Proxmox on local network - Connects only to UPS devices and optionally Proxmox on local network (CLI mode uses local tools — no network needed for VM shutdown)
- HTTP API disabled by default; token-required when enabled - HTTP API disabled by default; token-required when enabled
- No external internet connections - No external internet connections
@@ -659,6 +692,7 @@ sha256sum -c SHA256SUMS.txt --ignore-missing
```json ```json
{ {
"upsModel": "custom", "upsModel": "custom",
"runtimeUnit": "seconds",
"customOIDs": { "customOIDs": {
"POWER_STATUS": "1.3.6.1.4.1.1234.1.1.0", "POWER_STATUS": "1.3.6.1.4.1.1234.1.1.0",
"BATTERY_CAPACITY": "1.3.6.1.4.1.1234.1.2.0", "BATTERY_CAPACITY": "1.3.6.1.4.1.1234.1.2.0",
@@ -667,6 +701,8 @@ sha256sum -c SHA256SUMS.txt --ignore-missing
} }
``` ```
> 💡 **Tip:** If your UPS (e.g., HPE, Huawei) reports runtime in seconds instead of minutes, set `"runtimeUnit": "seconds"`. For CyberPower-style TimeTicks (1/100 second), use `"ticks"`. When omitted, NUPST auto-detects based on `upsModel`.
### UPSD/NIS-based ### UPSD/NIS-based
Any UPS supported by [NUT (Network UPS Tools)](https://networkupstools.org/) — this covers **hundreds of models** from virtually every manufacturer, including USB-connected devices. Check the [NUT hardware compatibility list](https://networkupstools.org/stable-hcl.html). Any UPS supported by [NUT (Network UPS Tools)](https://networkupstools.org/) — this covers **hundreds of models** from virtually every manufacturer, including USB-connected devices. Check the [NUT hardware compatibility list](https://networkupstools.org/stable-hcl.html).
@@ -676,7 +712,7 @@ Any UPS supported by [NUT (Network UPS Tools)](https://networkupstools.org/) —
### Built-in Update ### Built-in Update
```bash ```bash
sudo nupst update sudo nupst upgrade
``` ```
### Re-run Installer ### Re-run Installer
@@ -736,7 +772,13 @@ upsc ups@localhost # if NUT CLI is installed
### Proxmox VMs Not Shutting Down ### Proxmox VMs Not Shutting Down
```bash ```bash
# Verify API token works # CLI mode: verify qm/pct are available and you're root
which qm pct
whoami # should be 'root'
qm list # should list VMs
pct list # should list containers
# API mode: verify API token works
curl -k -H "Authorization: PVEAPIToken=root@pam!nupst=YOUR-SECRET" \ curl -k -H "Authorization: PVEAPIToken=root@pam!nupst=YOUR-SECRET" \
https://localhost:8006/api2/json/nodes/$(hostname)/qemu https://localhost:8006/api2/json/nodes/$(hostname)/qemu
@@ -848,7 +890,7 @@ nupst/
## License and Legal Information ## License and Legal Information
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./LICENSE) file. This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](./license) file.
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file. **Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
+1 -1
View File
@@ -195,7 +195,7 @@ nupst group edit <id> → nupst group edit <id>
nupst group delete <id> → nupst group remove <id> nupst group delete <id> → nupst group remove <id>
nupst config → nupst config show nupst config → nupst config show
nupst update → nupst update nupst upgrade → nupst upgrade
nupst uninstall → nupst uninstall nupst uninstall → nupst uninstall
nupst help → nupst help / nupst --help nupst help → nupst help / nupst --help
(new) → nupst --version (new) → nupst --version
-66
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 ""
+3 -3
View File
@@ -229,10 +229,10 @@ console.log('');
// === 10. Update Available Example === // === 10. Update Available Example ===
logger.logBoxTitle('Update Available', 70, 'warning'); logger.logBoxTitle('Update Available', 70, 'warning');
logger.logBoxLine(''); logger.logBoxLine('');
logger.logBoxLine(`Current Version: ${theme.dim('4.0.1')}`); logger.logBoxLine(`Current Version: ${theme.dim('5.5.0')}`);
logger.logBoxLine(`Latest Version: ${theme.highlight('4.0.2')}`); logger.logBoxLine(`Latest Version: ${theme.highlight('5.5.1')}`);
logger.logBoxLine(''); logger.logBoxLine('');
logger.logBoxLine(`Run ${theme.command('sudo nupst update')} to update`); logger.logBoxLine(`Run ${theme.command('sudo nupst upgrade')} to update`);
logger.logBoxLine(''); logger.logBoxLine('');
logger.logBoxEnd(); logger.logBoxEnd();
+450
View File
@@ -2,9 +2,31 @@ import { assert, assertEquals, assertExists } from 'jsr:@std/assert@^1.0.0';
import { NupstSnmp } from '../ts/snmp/manager.ts'; import { NupstSnmp } from '../ts/snmp/manager.ts';
import { UpsOidSets } from '../ts/snmp/oid-sets.ts'; import { UpsOidSets } from '../ts/snmp/oid-sets.ts';
import type { IOidSet, ISnmpConfig, TUpsModel } from '../ts/snmp/types.ts'; import type { IOidSet, ISnmpConfig, TUpsModel } from '../ts/snmp/types.ts';
import {
analyzeConfigReload,
shouldRefreshPauseState,
shouldReloadConfig,
} from '../ts/config-watch.ts';
import { type IPauseState, loadPauseSnapshot } from '../ts/pause-state.ts';
import { shortId } from '../ts/helpers/shortid.ts'; import { shortId } from '../ts/helpers/shortid.ts';
import { HTTP_SERVER, SNMP, THRESHOLDS, TIMING, UI } from '../ts/constants.ts'; import { HTTP_SERVER, SNMP, THRESHOLDS, TIMING, UI } from '../ts/constants.ts';
import { Action, type IActionContext } from '../ts/actions/base-action.ts'; import { Action, type IActionContext } from '../ts/actions/base-action.ts';
import {
applyDefaultShutdownDelay,
buildUpsActionContext,
decideUpsActionExecution,
} from '../ts/action-orchestration.ts';
import {
buildShutdownErrorRow,
buildShutdownStatusRow,
selectEmergencyCandidate,
} from '../ts/shutdown-monitoring.ts';
import {
buildFailedUpsPollSnapshot,
buildSuccessfulUpsPollSnapshot,
hasThresholdViolation,
} from '../ts/ups-monitoring.ts';
import { createInitialUpsStatus } from '../ts/ups-status.ts';
import * as qenv from 'npm:@push.rocks/qenv@^6.0.0'; import * as qenv from 'npm:@push.rocks/qenv@^6.0.0';
const testQenv = new qenv.Qenv('./', '.nogit/'); const testQenv = new qenv.Qenv('./', '.nogit/');
@@ -82,6 +104,434 @@ Deno.test('UI constants: box widths are ascending', () => {
assert(UI.WIDE_BOX_WIDTH < UI.EXTRA_WIDE_BOX_WIDTH); assert(UI.WIDE_BOX_WIDTH < UI.EXTRA_WIDE_BOX_WIDTH);
}); });
// -----------------------------------------------------------------------------
// Pause State Tests
// -----------------------------------------------------------------------------
Deno.test('loadPauseSnapshot: reports paused state for valid pause file', async () => {
const tempDir = await Deno.makeTempDir();
const pauseFilePath = `${tempDir}/pause.json`;
const pauseState: IPauseState = {
pausedAt: 1000,
pausedBy: 'cli',
reason: 'maintenance',
resumeAt: 5000,
};
try {
await Deno.writeTextFile(pauseFilePath, JSON.stringify(pauseState));
const snapshot = loadPauseSnapshot(pauseFilePath, false, 2000);
assertEquals(snapshot.isPaused, true);
assertEquals(snapshot.pauseState, pauseState);
assertEquals(snapshot.transition, 'paused');
} finally {
await Deno.remove(tempDir, { recursive: true });
}
});
Deno.test('loadPauseSnapshot: auto-resumes expired pause file', async () => {
const tempDir = await Deno.makeTempDir();
const pauseFilePath = `${tempDir}/pause.json`;
const pauseState: IPauseState = {
pausedAt: 1000,
pausedBy: 'cli',
resumeAt: 1500,
};
try {
await Deno.writeTextFile(pauseFilePath, JSON.stringify(pauseState));
const snapshot = loadPauseSnapshot(pauseFilePath, true, 2000);
assertEquals(snapshot.isPaused, false);
assertEquals(snapshot.pauseState, null);
assertEquals(snapshot.transition, 'autoResumed');
let fileExists = true;
try {
await Deno.stat(pauseFilePath);
} catch {
fileExists = false;
}
assertEquals(fileExists, false);
} finally {
await Deno.remove(tempDir, { recursive: true });
}
});
Deno.test('loadPauseSnapshot: reports resumed when pause file disappears', async () => {
const tempDir = await Deno.makeTempDir();
try {
const snapshot = loadPauseSnapshot(`${tempDir}/pause.json`, true, 2000);
assertEquals(snapshot.isPaused, false);
assertEquals(snapshot.pauseState, null);
assertEquals(snapshot.transition, 'resumed');
} finally {
await Deno.remove(tempDir, { recursive: true });
}
});
// -----------------------------------------------------------------------------
// Config Watch Tests
// -----------------------------------------------------------------------------
Deno.test('shouldReloadConfig: matches modify events for config.json', () => {
assertEquals(
shouldReloadConfig({ kind: 'modify', paths: ['/etc/nupst/config.json'] }),
true,
);
assertEquals(
shouldReloadConfig({ kind: 'create', paths: ['/etc/nupst/config.json'] }),
false,
);
assertEquals(
shouldReloadConfig({ kind: 'modify', paths: ['/etc/nupst/other.json'] }),
false,
);
});
Deno.test('shouldRefreshPauseState: matches create/modify/remove pause events', () => {
assertEquals(
shouldRefreshPauseState({ kind: 'create', paths: ['/etc/nupst/pause'] }),
true,
);
assertEquals(
shouldRefreshPauseState({ kind: 'remove', paths: ['/etc/nupst/pause'] }),
true,
);
assertEquals(
shouldRefreshPauseState({ kind: 'modify', paths: ['/etc/nupst/config.json'] }),
false,
);
});
Deno.test('analyzeConfigReload: detects monitoring start and device count changes', () => {
assertEquals(analyzeConfigReload(0, 2), {
transition: 'monitoringWillStart',
message: 'Configuration reloaded! Found 2 UPS device(s)',
shouldInitializeUpsStatus: false,
shouldLogMonitoringStart: true,
});
assertEquals(analyzeConfigReload(2, 3), {
transition: 'deviceCountChanged',
message: 'Configuration reloaded! UPS devices: 2 -> 3',
shouldInitializeUpsStatus: true,
shouldLogMonitoringStart: false,
});
assertEquals(analyzeConfigReload(2, 2), {
transition: 'reloaded',
message: 'Configuration reloaded successfully',
shouldInitializeUpsStatus: false,
shouldLogMonitoringStart: false,
});
});
// -----------------------------------------------------------------------------
// UPS Status Tests
// -----------------------------------------------------------------------------
Deno.test('createInitialUpsStatus: creates default daemon UPS status shape', () => {
assertEquals(createInitialUpsStatus({ id: 'ups-1', name: 'Main UPS' }, 1234), {
id: 'ups-1',
name: 'Main UPS',
powerStatus: 'unknown',
batteryCapacity: 100,
batteryRuntime: 999,
outputLoad: 0,
outputPower: 0,
outputVoltage: 0,
outputCurrent: 0,
lastStatusChange: 1234,
lastCheckTime: 0,
consecutiveFailures: 0,
unreachableSince: 0,
});
});
// -----------------------------------------------------------------------------
// Action Orchestration Tests
// -----------------------------------------------------------------------------
Deno.test('buildUpsActionContext: includes previous power status and timestamp', () => {
const status = {
...createInitialUpsStatus({ id: 'ups-1', name: 'Main UPS' }, 1000),
powerStatus: 'onBattery' as const,
batteryCapacity: 42,
batteryRuntime: 15,
};
const previousStatus = {
...createInitialUpsStatus({ id: 'ups-1', name: 'Main UPS' }, 500),
powerStatus: 'online' as const,
};
assertEquals(
buildUpsActionContext(
{ id: 'ups-1', name: 'Main UPS' },
status,
previousStatus,
'thresholdViolation',
9999,
),
{
upsId: 'ups-1',
upsName: 'Main UPS',
powerStatus: 'onBattery',
batteryCapacity: 42,
batteryRuntime: 15,
previousPowerStatus: 'online',
timestamp: 9999,
triggerReason: 'thresholdViolation',
},
);
});
Deno.test('decideUpsActionExecution: suppresses actions while paused', () => {
const decision = decideUpsActionExecution(
true,
{ id: 'ups-1', name: 'Main UPS', actions: [{ type: 'shutdown' }] },
createInitialUpsStatus({ id: 'ups-1', name: 'Main UPS' }, 1000),
undefined,
'powerStatusChange',
9999,
);
assertEquals(decision, {
type: 'suppressed',
message: '[PAUSED] Actions suppressed for UPS Main UPS (trigger: powerStatusChange)',
});
});
Deno.test('decideUpsActionExecution: falls back to legacy shutdown without actions', () => {
const decision = decideUpsActionExecution(
false,
{ id: 'ups-1', name: 'Main UPS' },
createInitialUpsStatus({ id: 'ups-1', name: 'Main UPS' }, 1000),
undefined,
'thresholdViolation',
9999,
);
assertEquals(decision, {
type: 'legacyShutdown',
reason: 'UPS "Main UPS" battery or runtime below threshold',
});
});
Deno.test('decideUpsActionExecution: returns executable action plan when actions exist', () => {
const decision = decideUpsActionExecution(
false,
{ id: 'ups-1', name: 'Main UPS', actions: [{ type: 'shutdown' }] },
{
...createInitialUpsStatus({ id: 'ups-1', name: 'Main UPS' }, 1000),
powerStatus: 'onBattery',
batteryCapacity: 55,
batteryRuntime: 18,
},
{
...createInitialUpsStatus({ id: 'ups-1', name: 'Main UPS' }, 500),
powerStatus: 'online',
},
'powerStatusChange',
9999,
);
assertEquals(decision, {
type: 'execute',
actions: [{ type: 'shutdown' }],
context: {
upsId: 'ups-1',
upsName: 'Main UPS',
powerStatus: 'onBattery',
batteryCapacity: 55,
batteryRuntime: 18,
previousPowerStatus: 'online',
timestamp: 9999,
triggerReason: 'powerStatusChange',
},
});
});
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,
);
});
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// UpsOidSets Tests // UpsOidSets Tests
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
+1 -1
View File
@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@serve.zone/nupst', name: '@serve.zone/nupst',
version: '5.3.0', version: '5.6.0',
description: 'Network UPS Shutdown Tool - Monitor SNMP-enabled UPS devices and orchestrate graceful system shutdowns during power emergencies' description: 'Network UPS Shutdown Tool - Monitor SNMP-enabled UPS devices and orchestrate graceful system shutdowns during power emergencies'
} }
+86
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),
};
}
+3 -1
View File
@@ -74,7 +74,7 @@ export interface IActionConfig {
}; };
// Shutdown action configuration // 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; shutdownDelay?: number;
/** Only execute shutdown on threshold violation, not power status changes */ /** Only execute shutdown on threshold violation, not power status changes */
onlyOnThresholdViolation?: boolean; onlyOnThresholdViolation?: boolean;
@@ -116,6 +116,8 @@ export interface IActionConfig {
proxmoxForceStop?: boolean; proxmoxForceStop?: boolean;
/** Skip TLS verification for self-signed certificates (default: true) */ /** Skip TLS verification for self-signed certificates (default: true) */
proxmoxInsecure?: boolean; proxmoxInsecure?: boolean;
/** Proxmox operation mode: 'auto' detects CLI tools, 'cli' forces CLI, 'api' forces REST API (default: 'auto') */
proxmoxMode?: 'auto' | 'api' | 'cli';
} }
/** /**
+288 -60
View File
@@ -1,14 +1,22 @@
import * as fs from 'node:fs';
import * as os from 'node:os'; 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 { Action, type IActionContext } from './base-action.ts';
import { logger } from '../logger.ts'; import { logger } from '../logger.ts';
import { PROXMOX, UI } from '../constants.ts'; import { PROXMOX, UI } from '../constants.ts';
const execFileAsync = promisify(execFile);
/** /**
* ProxmoxAction - Gracefully shuts down Proxmox VMs and LXC containers * ProxmoxAction - Gracefully shuts down Proxmox VMs and LXC containers
* *
* Uses the Proxmox REST API via HTTPS with API token authentication. * Supports two operation modes:
* Shuts down running QEMU VMs and LXC containers, waits for completion, * - CLI mode: Uses qm/pct commands directly (requires running as root on a Proxmox host)
* and optionally force-stops any that don't respond. * - 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 * This action should be placed BEFORE shutdown actions in the action chain
* so that VMs are stopped before the host is shut down. * so that VMs are stopped before the host is shut down.
@@ -16,6 +24,77 @@ import { PROXMOX, UI } from '../constants.ts';
export class ProxmoxAction extends Action { export class ProxmoxAction extends Action {
readonly type = 'proxmox'; readonly type = 'proxmox';
/**
* Check if Proxmox CLI tools (qm, pct) are available on the system
* Used by CLI wizards and by execute() for auto-detection
*/
static detectCliAvailability(): {
available: boolean;
qmPath: string | null;
pctPath: string | null;
isRoot: boolean;
} {
let qmPath: string | null = null;
let pctPath: string | null = null;
for (const dir of PROXMOX.CLI_TOOL_PATHS) {
if (!qmPath) {
const p = `${dir}/qm`;
try {
if (fs.existsSync(p)) qmPath = p;
} catch (_e) {
// continue
}
}
if (!pctPath) {
const p = `${dir}/pct`;
try {
if (fs.existsSync(p)) pctPath = p;
} catch (_e) {
// continue
}
}
}
const isRoot = !!(process.getuid && process.getuid() === 0);
return {
available: qmPath !== null && pctPath !== null && isRoot,
qmPath,
pctPath,
isRoot,
};
}
/**
* Resolve the operation mode based on config and environment
*/
private resolveMode(): { mode: 'api' | 'cli'; qmPath: string; pctPath: string } | { mode: 'api'; qmPath?: undefined; pctPath?: undefined } {
const configuredMode = this.config.proxmoxMode || 'auto';
if (configuredMode === 'api') {
return { mode: 'api' };
}
const detection = ProxmoxAction.detectCliAvailability();
if (configuredMode === 'cli') {
if (!detection.qmPath || !detection.pctPath) {
throw new Error('CLI mode requested but qm/pct not found. Are you on a Proxmox host?');
}
if (!detection.isRoot) {
throw new Error('CLI mode requires root access');
}
return { mode: 'cli', qmPath: detection.qmPath, pctPath: detection.pctPath };
}
// Auto-detect
if (detection.available && detection.qmPath && detection.pctPath) {
return { mode: 'cli', qmPath: detection.qmPath, pctPath: detection.pctPath };
}
return { mode: 'api' };
}
/** /**
* Execute the Proxmox shutdown action * Execute the Proxmox shutdown action
*/ */
@@ -29,30 +108,21 @@ export class ProxmoxAction extends Action {
return; return;
} }
const host = this.config.proxmoxHost || PROXMOX.DEFAULT_HOST; const resolved = this.resolveMode();
const port = this.config.proxmoxPort || PROXMOX.DEFAULT_PORT;
const node = this.config.proxmoxNode || os.hostname(); const node = this.config.proxmoxNode || os.hostname();
const tokenId = this.config.proxmoxTokenId;
const tokenSecret = this.config.proxmoxTokenSecret;
const excludeIds = new Set(this.config.proxmoxExcludeIds || []); const excludeIds = new Set(this.config.proxmoxExcludeIds || []);
const stopTimeout = (this.config.proxmoxStopTimeout || PROXMOX.DEFAULT_STOP_TIMEOUT_SECONDS) * 1000; const stopTimeout = (this.config.proxmoxStopTimeout || PROXMOX.DEFAULT_STOP_TIMEOUT_SECONDS) * 1000;
const forceStop = this.config.proxmoxForceStop !== false; // default true const forceStop = this.config.proxmoxForceStop !== false; // default true
const insecure = this.config.proxmoxInsecure !== false; // default true
if (!tokenId || !tokenSecret) {
logger.error('Proxmox API token ID and secret are required');
return;
}
const baseUrl = `https://${host}:${port}${PROXMOX.API_BASE}`;
const headers: Record<string, string> = {
'Authorization': `PVEAPIToken=${tokenId}=${tokenSecret}`,
};
logger.log(''); logger.log('');
logger.logBoxTitle('Proxmox VM Shutdown', UI.WIDE_BOX_WIDTH, 'warning'); 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(`Node: ${node}`);
if (resolved.mode === 'api') {
const host = this.config.proxmoxHost || PROXMOX.DEFAULT_HOST;
const port = this.config.proxmoxPort || PROXMOX.DEFAULT_PORT;
logger.logBoxLine(`API: ${host}:${port}`); logger.logBoxLine(`API: ${host}:${port}`);
}
logger.logBoxLine(`UPS: ${context.upsName} (${context.powerStatus})`); logger.logBoxLine(`UPS: ${context.upsName} (${context.powerStatus})`);
logger.logBoxLine(`Trigger: ${context.triggerReason}`); logger.logBoxLine(`Trigger: ${context.triggerReason}`);
if (excludeIds.size > 0) { if (excludeIds.size > 0) {
@@ -62,9 +132,34 @@ export class ProxmoxAction extends Action {
logger.log(''); logger.log('');
try { try {
// Collect running VMs and CTs let runningVMs: Array<{ vmid: number; name: string }>;
const runningVMs = await this.getRunningVMs(baseUrl, node, headers, insecure); let runningCTs: Array<{ vmid: number; name: string }>;
const runningCTs = await this.getRunningCTs(baseUrl, node, headers, insecure);
if (resolved.mode === 'cli') {
runningVMs = await this.getRunningVMsCli(resolved.qmPath);
runningCTs = await this.getRunningCTsCli(resolved.pctPath);
} else {
// API mode - validate token
const host = this.config.proxmoxHost || PROXMOX.DEFAULT_HOST;
const port = this.config.proxmoxPort || PROXMOX.DEFAULT_PORT;
const tokenId = this.config.proxmoxTokenId;
const tokenSecret = this.config.proxmoxTokenSecret;
const insecure = this.config.proxmoxInsecure !== false;
if (!tokenId || !tokenSecret) {
logger.error('Proxmox API token ID and secret are required for API mode');
logger.error('Either provide tokens or run on a Proxmox host as root for CLI mode');
return;
}
const baseUrl = `https://${host}:${port}${PROXMOX.API_BASE}`;
const headers: Record<string, string> = {
'Authorization': `PVEAPIToken=${tokenId}=${tokenSecret}`,
};
runningVMs = await this.getRunningVMsApi(baseUrl, node, headers, insecure);
runningCTs = await this.getRunningCTsApi(baseUrl, node, headers, insecure);
}
// Filter out excluded IDs // Filter out excluded IDs
const vmsToStop = runningVMs.filter((vm) => !excludeIds.has(vm.vmid)); const vmsToStop = runningVMs.filter((vm) => !excludeIds.has(vm.vmid));
@@ -78,16 +173,34 @@ export class ProxmoxAction extends Action {
logger.info(`Shutting down ${vmsToStop.length} VMs and ${ctsToStop.length} containers...`); logger.info(`Shutting down ${vmsToStop.length} VMs and ${ctsToStop.length} containers...`);
// Send shutdown commands to all VMs and CTs // Send shutdown commands
if (resolved.mode === 'cli') {
for (const vm of vmsToStop) { for (const vm of vmsToStop) {
await this.shutdownVM(baseUrl, node, vm.vmid, headers, insecure); await this.shutdownVMCli(resolved.qmPath, vm.vmid);
logger.dim(` Shutdown sent to VM ${vm.vmid} (${vm.name || 'unnamed'})`); logger.dim(` Shutdown sent to VM ${vm.vmid} (${vm.name || 'unnamed'})`);
} }
for (const ct of ctsToStop) { for (const ct of ctsToStop) {
await this.shutdownCT(baseUrl, node, ct.vmid, headers, insecure); await this.shutdownCTCli(resolved.pctPath, ct.vmid);
logger.dim(` Shutdown sent to CT ${ct.vmid} (${ct.name || 'unnamed'})`); logger.dim(` Shutdown sent to CT ${ct.vmid} (${ct.name || 'unnamed'})`);
} }
} else {
const host = this.config.proxmoxHost || PROXMOX.DEFAULT_HOST;
const port = this.config.proxmoxPort || PROXMOX.DEFAULT_PORT;
const insecure = this.config.proxmoxInsecure !== false;
const baseUrl = `https://${host}:${port}${PROXMOX.API_BASE}`;
const headers: Record<string, string> = {
'Authorization': `PVEAPIToken=${this.config.proxmoxTokenId}=${this.config.proxmoxTokenSecret}`,
};
for (const vm of vmsToStop) {
await this.shutdownVMApi(baseUrl, node, vm.vmid, headers, insecure);
logger.dim(` Shutdown sent to VM ${vm.vmid} (${vm.name || 'unnamed'})`);
}
for (const ct of ctsToStop) {
await this.shutdownCTApi(baseUrl, node, ct.vmid, headers, insecure);
logger.dim(` Shutdown sent to CT ${ct.vmid} (${ct.name || 'unnamed'})`);
}
}
// Poll until all stopped or timeout // Poll until all stopped or timeout
const allIds = [ const allIds = [
@@ -95,23 +208,31 @@ export class ProxmoxAction extends Action {
...ctsToStop.map((ct) => ({ type: 'lxc' as const, vmid: ct.vmid, name: ct.name })), ...ctsToStop.map((ct) => ({ type: 'lxc' as const, vmid: ct.vmid, name: ct.name })),
]; ];
const remaining = await this.waitForShutdown( const remaining = await this.waitForShutdown(allIds, resolved, node, stopTimeout);
baseUrl,
node,
allIds,
headers,
insecure,
stopTimeout,
);
if (remaining.length > 0 && forceStop) { if (remaining.length > 0 && forceStop) {
logger.warn(`${remaining.length} VMs/CTs didn't shut down gracefully, force-stopping...`); logger.warn(`${remaining.length} VMs/CTs didn't shut down gracefully, force-stopping...`);
for (const item of remaining) { for (const item of remaining) {
try { try {
if (resolved.mode === 'cli') {
if (item.type === 'qemu') { if (item.type === 'qemu') {
await this.stopVM(baseUrl, node, item.vmid, headers, insecure); await this.stopVMCli(resolved.qmPath, item.vmid);
} else { } else {
await this.stopCT(baseUrl, node, item.vmid, headers, insecure); await this.stopCTCli(resolved.pctPath, item.vmid);
}
} else {
const host = this.config.proxmoxHost || PROXMOX.DEFAULT_HOST;
const port = this.config.proxmoxPort || PROXMOX.DEFAULT_PORT;
const insecure = this.config.proxmoxInsecure !== false;
const baseUrl = `https://${host}:${port}${PROXMOX.API_BASE}`;
const headers: Record<string, string> = {
'Authorization': `PVEAPIToken=${this.config.proxmoxTokenId}=${this.config.proxmoxTokenSecret}`,
};
if (item.type === 'qemu') {
await this.stopVMApi(baseUrl, node, item.vmid, headers, insecure);
} else {
await this.stopCTApi(baseUrl, node, item.vmid, headers, insecure);
}
} }
logger.dim(` Force-stopped ${item.type} ${item.vmid} (${item.name || 'unnamed'})`); logger.dim(` Force-stopped ${item.type} ${item.vmid} (${item.name || 'unnamed'})`);
} catch (error) { } catch (error) {
@@ -134,6 +255,110 @@ export class ProxmoxAction extends Action {
} }
} }
// ─── CLI-based methods ─────────────────────────────────────────────
/**
* Get list of running QEMU VMs via qm list
*/
private async getRunningVMsCli(
qmPath: string,
): Promise<Array<{ vmid: number; name: string }>> {
try {
const { stdout } = await execFileAsync(qmPath, ['list']);
return this.parseQmList(stdout);
} catch (error) {
logger.error(
`Failed to list VMs via CLI: ${error instanceof Error ? error.message : String(error)}`,
);
return [];
}
}
/**
* Get list of running LXC containers via pct list
*/
private async getRunningCTsCli(
pctPath: string,
): Promise<Array<{ vmid: number; name: string }>> {
try {
const { stdout } = await execFileAsync(pctPath, ['list']);
return this.parsePctList(stdout);
} catch (error) {
logger.error(
`Failed to list CTs via CLI: ${error instanceof Error ? error.message : String(error)}`,
);
return [];
}
}
/**
* Parse qm list output
* Format: VMID NAME STATUS MEM(MB) BOOTDISK(GB) PID
*/
private parseQmList(output: string): Array<{ vmid: number; name: string }> {
const results: Array<{ vmid: number; name: string }> = [];
const lines = output.trim().split('\n');
// Skip header line
for (let i = 1; i < lines.length; i++) {
const match = lines[i].match(/^\s*(\d+)\s+(\S+)\s+(running|stopped|paused)/);
if (match && match[3] === 'running') {
results.push({ vmid: parseInt(match[1], 10), name: match[2] });
}
}
return results;
}
/**
* Parse pct list output
* Format: VMID Status Lock Name
*/
private parsePctList(output: string): Array<{ vmid: number; name: string }> {
const results: Array<{ vmid: number; name: string }> = [];
const lines = output.trim().split('\n');
// Skip header line
for (let i = 1; i < lines.length; i++) {
const match = lines[i].match(/^\s*(\d+)\s+(running|stopped)\s+\S*\s*(.*)/);
if (match && match[2] === 'running') {
results.push({ vmid: parseInt(match[1], 10), name: match[3]?.trim() || '' });
}
}
return results;
}
private async shutdownVMCli(qmPath: string, vmid: number): Promise<void> {
await execFileAsync(qmPath, ['shutdown', String(vmid)]);
}
private async shutdownCTCli(pctPath: string, vmid: number): Promise<void> {
await execFileAsync(pctPath, ['shutdown', String(vmid)]);
}
private async stopVMCli(qmPath: string, vmid: number): Promise<void> {
await execFileAsync(qmPath, ['stop', String(vmid)]);
}
private async stopCTCli(pctPath: string, vmid: number): Promise<void> {
await execFileAsync(pctPath, ['stop', String(vmid)]);
}
/**
* Get VM/CT status via CLI
* Returns the status string (e.g., 'running', 'stopped')
*/
private async getStatusCli(
toolPath: string,
vmid: number,
): Promise<string> {
const { stdout } = await execFileAsync(toolPath, ['status', String(vmid)]);
// Output format: "status: running\n"
const status = stdout.trim().split(':')[1]?.trim() || 'unknown';
return status;
}
// ─── API-based methods ─────────────────────────────────────────────
/** /**
* Make an API request to the Proxmox server * Make an API request to the Proxmox server
*/ */
@@ -173,9 +398,9 @@ export class ProxmoxAction extends Action {
} }
/** /**
* Get list of running QEMU VMs * Get list of running QEMU VMs via API
*/ */
private async getRunningVMs( private async getRunningVMsApi(
baseUrl: string, baseUrl: string,
node: string, node: string,
headers: Record<string, string>, headers: Record<string, string>,
@@ -201,9 +426,9 @@ export class ProxmoxAction extends Action {
} }
/** /**
* Get list of running LXC containers * Get list of running LXC containers via API
*/ */
private async getRunningCTs( private async getRunningCTsApi(
baseUrl: string, baseUrl: string,
node: string, node: string,
headers: Record<string, string>, headers: Record<string, string>,
@@ -228,10 +453,7 @@ export class ProxmoxAction extends Action {
} }
} }
/** private async shutdownVMApi(
* Send graceful shutdown to a QEMU VM
*/
private async shutdownVM(
baseUrl: string, baseUrl: string,
node: string, node: string,
vmid: number, vmid: number,
@@ -246,10 +468,7 @@ export class ProxmoxAction extends Action {
); );
} }
/** private async shutdownCTApi(
* Send graceful shutdown to an LXC container
*/
private async shutdownCT(
baseUrl: string, baseUrl: string,
node: string, node: string,
vmid: number, vmid: number,
@@ -264,10 +483,7 @@ export class ProxmoxAction extends Action {
); );
} }
/** private async stopVMApi(
* Force-stop a QEMU VM
*/
private async stopVM(
baseUrl: string, baseUrl: string,
node: string, node: string,
vmid: number, vmid: number,
@@ -282,10 +498,7 @@ export class ProxmoxAction extends Action {
); );
} }
/** private async stopCTApi(
* Force-stop an LXC container
*/
private async stopCT(
baseUrl: string, baseUrl: string,
node: string, node: string,
vmid: number, vmid: number,
@@ -300,15 +513,15 @@ export class ProxmoxAction extends Action {
); );
} }
// ─── Shared methods ────────────────────────────────────────────────
/** /**
* Wait for VMs/CTs to shut down, return any that are still running after timeout * Wait for VMs/CTs to shut down, return any that are still running after timeout
*/ */
private async waitForShutdown( private async waitForShutdown(
baseUrl: string,
node: string,
items: Array<{ type: 'qemu' | 'lxc'; vmid: number; name: string }>, items: Array<{ type: 'qemu' | 'lxc'; vmid: number; name: string }>,
headers: Record<string, string>, resolved: { mode: 'api' | 'cli'; qmPath?: string; pctPath?: string },
insecure: boolean, node: string,
timeout: number, timeout: number,
): Promise<Array<{ type: 'qemu' | 'lxc'; vmid: number; name: string }>> { ): Promise<Array<{ type: 'qemu' | 'lxc'; vmid: number; name: string }>> {
const startTime = Date.now(); const startTime = Date.now();
@@ -323,12 +536,27 @@ export class ProxmoxAction extends Action {
for (const item of remaining) { for (const item of remaining) {
try { 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 statusUrl = `${baseUrl}/nodes/${node}/${item.type}/${item.vmid}/status/current`;
const response = await this.apiRequest(statusUrl, 'GET', headers, insecure) as { const response = await this.apiRequest(statusUrl, 'GET', headers, insecure) as {
data: { status: string }; data: { status: string };
}; };
status = response.data?.status || 'unknown';
}
if (response.data?.status === 'running') { if (status === 'running') {
stillRunning.push(item); stillRunning.push(item);
} else { } else {
logger.dim(` ${item.type} ${item.vmid} (${item.name}) stopped`); logger.dim(` ${item.type} ${item.vmid} (${item.name}) stopped`);
+1 -1
View File
@@ -124,7 +124,7 @@ export class ShutdownAction extends Action {
return; return;
} }
const shutdownDelay = this.config.shutdownDelay || SHUTDOWN.DEFAULT_DELAY_MINUTES; const shutdownDelay = this.config.shutdownDelay ?? SHUTDOWN.DEFAULT_DELAY_MINUTES;
logger.log(''); logger.log('');
logger.logBoxTitle('Initiating System Shutdown', UI.WIDE_BOX_WIDTH, 'error'); logger.logBoxTitle('Initiating System Shutdown', UI.WIDE_BOX_WIDTH, 'error');
+6 -6
View File
@@ -19,7 +19,7 @@ export class NupstCli {
/** /**
* Parse command line arguments and execute the appropriate command * Parse command line arguments and execute the appropriate command
* @param args Command line arguments (process.argv) * @param args Command line arguments excluding runtime and script path
*/ */
public async parseAndExecute(args: string[]): Promise<void> { public async parseAndExecute(args: string[]): Promise<void> {
// Extract debug and version flags from any position // Extract debug and version flags from any position
@@ -38,8 +38,8 @@ export class NupstCli {
} }
// Get the command (default to help if none provided) // Get the command (default to help if none provided)
const command = debugOptions.cleanedArgs[2] || 'help'; const command = debugOptions.cleanedArgs[0] || 'help';
const commandArgs = debugOptions.cleanedArgs.slice(3); const commandArgs = debugOptions.cleanedArgs.slice(1);
// Route to the appropriate command handler // Route to the appropriate command handler
await this.executeCommand(command, commandArgs, debugOptions.debugMode); await this.executeCommand(command, commandArgs, debugOptions.debugMode);
@@ -98,7 +98,7 @@ export class NupstCli {
await serviceHandler.start(); await serviceHandler.start();
break; break;
case 'status': case 'status':
await serviceHandler.status(); await serviceHandler.status(debugMode);
break; break;
case 'logs': case 'logs':
await serviceHandler.logs(); await serviceHandler.logs();
@@ -266,7 +266,7 @@ export class NupstCli {
case 'resume': case 'resume':
await serviceHandler.resume(); await serviceHandler.resume();
break; break;
case 'update': case 'upgrade':
await serviceHandler.update(); await serviceHandler.update();
break; break;
case 'uninstall': case 'uninstall':
@@ -557,7 +557,7 @@ export class NupstCli {
this.printCommand('config [show]', 'Display current configuration'); this.printCommand('config [show]', 'Display current configuration');
this.printCommand('pause [--duration <time>]', 'Pause action monitoring'); this.printCommand('pause [--duration <time>]', 'Pause action monitoring');
this.printCommand('resume', 'Resume action monitoring'); this.printCommand('resume', 'Resume action monitoring');
this.printCommand('update', 'Update NUPST from repository', theme.dim('(requires root)')); this.printCommand('upgrade', 'Upgrade NUPST from repository', theme.dim('(requires root)'));
this.printCommand('uninstall', 'Completely remove NUPST', theme.dim('(requires root)')); this.printCommand('uninstall', 'Completely remove NUPST', theme.dim('(requires root)'));
this.printCommand('help, --help, -h', 'Show this help message'); this.printCommand('help, --help, -h', 'Show this help message');
this.printCommand('--version, -v', 'Show version information'); this.printCommand('--version, -v', 'Show version information');
+161 -28
View File
@@ -3,6 +3,8 @@ import { Nupst } from '../nupst.ts';
import { type ITableColumn, logger } from '../logger.ts'; import { type ITableColumn, logger } from '../logger.ts';
import { symbols, theme } from '../colors.ts'; import { symbols, theme } from '../colors.ts';
import type { IActionConfig } from '../actions/base-action.ts'; import type { IActionConfig } from '../actions/base-action.ts';
import { ProxmoxAction } from '../actions/proxmox-action.ts';
import { SHUTDOWN } from '../constants.ts';
import type { IGroupConfig, IUpsConfig } from '../daemon.ts'; import type { IGroupConfig, IUpsConfig } from '../daemon.ts';
import * as helpers from '../helpers/index.ts'; import * as helpers from '../helpers/index.ts';
@@ -65,11 +67,150 @@ export class ActionHandler {
logger.info(`Add Action to ${targetType} ${theme.highlight(targetName)}`); logger.info(`Add Action to ${targetType} ${theme.highlight(targetName)}`);
logger.log(''); logger.log('');
// Action type (currently only shutdown is supported) // Action type selection
const type = 'shutdown'; logger.log(` ${theme.dim('Action Type:')}`);
logger.log(` ${theme.dim('Action type:')} ${theme.highlight('shutdown')}`); logger.log(` ${theme.dim('1)')} Shutdown (system shutdown)`);
logger.log(` ${theme.dim('2)')} Webhook (HTTP notification)`);
logger.log(` ${theme.dim('3)')} Custom Script (run .sh file from /etc/nupst)`);
logger.log(` ${theme.dim('4)')} Proxmox (gracefully shut down VMs/LXCs before host shutdown)`);
// Battery threshold const typeInput = await prompt(` ${theme.dim('Select action type')} ${theme.dim('[1]:')} `);
const typeValue = parseInt(typeInput, 10) || 1;
const newAction: Partial<IActionConfig> = {};
if (typeValue === 1) {
// Shutdown action
newAction.type = 'shutdown';
const defaultShutdownDelay =
this.nupst.getDaemon().getConfig().defaultShutdownDelay ?? SHUTDOWN.DEFAULT_DELAY_MINUTES;
const delayStr = await prompt(
` ${theme.dim('Shutdown delay')} ${theme.dim(`(minutes, leave empty for default ${defaultShutdownDelay}):`)} `,
);
if (delayStr.trim()) {
const shutdownDelay = parseInt(delayStr, 10);
if (isNaN(shutdownDelay) || shutdownDelay < 0) {
logger.error('Invalid shutdown delay. Must be >= 0.');
process.exit(1);
}
newAction.shutdownDelay = shutdownDelay;
}
} else if (typeValue === 2) {
// Webhook action
newAction.type = 'webhook';
const url = await prompt(` ${theme.dim('Webhook URL:')} `);
if (!url.trim()) {
logger.error('Webhook URL is required.');
process.exit(1);
}
newAction.webhookUrl = url.trim();
logger.log('');
logger.log(` ${theme.dim('HTTP Method:')}`);
logger.log(` ${theme.dim('1)')} POST (JSON body)`);
logger.log(` ${theme.dim('2)')} GET (query parameters)`);
const methodInput = await prompt(` ${theme.dim('Select method')} ${theme.dim('[1]:')} `);
newAction.webhookMethod = methodInput === '2' ? 'GET' : 'POST';
const timeoutInput = await prompt(` ${theme.dim('Timeout in seconds')} ${theme.dim('[10]:')} `);
const timeout = parseInt(timeoutInput, 10);
if (timeoutInput.trim() && !isNaN(timeout)) {
newAction.webhookTimeout = timeout * 1000;
}
} else if (typeValue === 3) {
// Script action
newAction.type = 'script';
const scriptPath = await prompt(` ${theme.dim('Script filename (in /etc/nupst/, must end with .sh):')} `);
if (!scriptPath.trim() || !scriptPath.trim().endsWith('.sh')) {
logger.error('Script path must end with .sh.');
process.exit(1);
}
newAction.scriptPath = scriptPath.trim();
const timeoutInput = await prompt(` ${theme.dim('Script timeout in seconds')} ${theme.dim('[60]:')} `);
const timeout = parseInt(timeoutInput, 10);
if (timeoutInput.trim() && !isNaN(timeout)) {
newAction.scriptTimeout = timeout * 1000;
}
} else if (typeValue === 4) {
// Proxmox action
newAction.type = 'proxmox';
// Auto-detect CLI availability
const detection = ProxmoxAction.detectCliAvailability();
if (detection.available) {
logger.log('');
logger.success('Proxmox CLI tools detected (qm/pct). No API token needed.');
logger.dim(` qm: ${detection.qmPath}`);
logger.dim(` pct: ${detection.pctPath}`);
newAction.proxmoxMode = 'cli';
} else {
logger.log('');
if (!detection.isRoot) {
logger.warn('Not running as root - CLI mode unavailable, using API mode.');
} else {
logger.warn('Proxmox CLI tools (qm/pct) not found - using API mode.');
}
logger.log('');
logger.info('Proxmox API Settings:');
logger.dim('Create a token with: pveum user token add root@pam nupst --privsep=0');
const pxHost = await prompt(` ${theme.dim('Proxmox Host')} ${theme.dim('[localhost]:')} `);
newAction.proxmoxHost = pxHost.trim() || 'localhost';
const pxPortInput = await prompt(` ${theme.dim('Proxmox API Port')} ${theme.dim('[8006]:')} `);
const pxPort = parseInt(pxPortInput, 10);
newAction.proxmoxPort = pxPortInput.trim() && !isNaN(pxPort) ? pxPort : 8006;
const pxNode = await prompt(` ${theme.dim('Proxmox Node Name (empty = auto-detect):')} `);
if (pxNode.trim()) {
newAction.proxmoxNode = pxNode.trim();
}
const tokenId = await prompt(` ${theme.dim('API Token ID (e.g., root@pam!nupst):')} `);
if (!tokenId.trim()) {
logger.error('Token ID is required for API mode.');
process.exit(1);
}
newAction.proxmoxTokenId = tokenId.trim();
const tokenSecret = await prompt(` ${theme.dim('API Token Secret:')} `);
if (!tokenSecret.trim()) {
logger.error('Token Secret is required for API mode.');
process.exit(1);
}
newAction.proxmoxTokenSecret = tokenSecret.trim();
const insecureInput = await prompt(` ${theme.dim('Skip TLS verification (self-signed cert)?')} ${theme.dim('(Y/n):')} `);
newAction.proxmoxInsecure = insecureInput.toLowerCase() !== 'n';
newAction.proxmoxMode = 'api';
}
// Common Proxmox settings (both modes)
const excludeInput = await prompt(` ${theme.dim('VM/CT IDs to exclude (comma-separated, or empty):')} `);
if (excludeInput.trim()) {
newAction.proxmoxExcludeIds = excludeInput.split(',').map((s) => parseInt(s.trim(), 10)).filter((n) => !isNaN(n));
}
const timeoutInput = await prompt(` ${theme.dim('VM shutdown timeout in seconds')} ${theme.dim('[120]:')} `);
const stopTimeout = parseInt(timeoutInput, 10);
if (timeoutInput.trim() && !isNaN(stopTimeout)) {
newAction.proxmoxStopTimeout = stopTimeout;
}
const forceInput = await prompt(` ${theme.dim('Force-stop VMs that don\'t shut down in time?')} ${theme.dim('(Y/n):')} `);
newAction.proxmoxForceStop = forceInput.toLowerCase() !== 'n';
} else {
logger.error('Invalid action type.');
process.exit(1);
}
// Battery threshold (all action types)
logger.log('');
const batteryStr = await prompt( const batteryStr = await prompt(
` ${theme.dim('Battery threshold')} ${theme.dim('(%):')} `, ` ${theme.dim('Battery threshold')} ${theme.dim('(%):')} `,
); );
@@ -89,6 +230,8 @@ export class ActionHandler {
process.exit(1); process.exit(1);
} }
newAction.thresholds = { battery, runtime };
// Trigger mode // Trigger mode
logger.log(''); logger.log('');
logger.log(` ${theme.dim('Trigger mode:')}`); logger.log(` ${theme.dim('Trigger mode:')}`);
@@ -113,33 +256,13 @@ export class ActionHandler {
'': 'onlyThresholds', // Default '': 'onlyThresholds', // Default
}; };
const triggerMode = triggerModeMap[triggerChoice] || 'onlyThresholds'; const triggerMode = triggerModeMap[triggerChoice] || 'onlyThresholds';
newAction.triggerMode = triggerMode as IActionConfig['triggerMode'];
// Shutdown delay
const delayStr = await prompt(
` ${theme.dim('Shutdown delay')} ${theme.dim('(seconds) [5]:')} `,
);
const shutdownDelay = delayStr ? parseInt(delayStr, 10) : 5;
if (isNaN(shutdownDelay) || shutdownDelay < 0) {
logger.error('Invalid shutdown delay. Must be >= 0.');
process.exit(1);
}
// Create the action
const newAction: IActionConfig = {
type,
thresholds: {
battery,
runtime,
},
triggerMode: triggerMode as IActionConfig['triggerMode'],
shutdownDelay,
};
// Add to target (UPS or group) // Add to target (UPS or group)
if (!target!.actions) { if (!target!.actions) {
target!.actions = []; target!.actions = [];
} }
target!.actions.push(newAction); target!.actions.push(newAction as IActionConfig);
await this.nupst.getDaemon().saveConfig(config); await this.nupst.getDaemon().saveConfig(config);
@@ -350,11 +473,21 @@ export class ActionHandler {
]; ];
const rows = target.actions.map((action, index) => { const rows = target.actions.map((action, index) => {
let details = `${action.shutdownDelay || 5}s delay`; const defaultShutdownDelay =
this.nupst.getDaemon().getConfig().defaultShutdownDelay ?? SHUTDOWN.DEFAULT_DELAY_MINUTES;
let details = `${action.shutdownDelay ?? defaultShutdownDelay}min delay`;
if (action.type === 'proxmox') { 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 host = action.proxmoxHost || 'localhost';
const port = action.proxmoxPort || 8006; const port = action.proxmoxPort || 8006;
details = `${host}:${port}`; details = `API ${host}:${port}`;
}
if (action.proxmoxExcludeIds?.length) {
details += `, excl: ${action.proxmoxExcludeIds.join(',')}`;
}
} else if (action.type === 'webhook') { } else if (action.type === 'webhook') {
details = action.webhookUrl || theme.dim('N/A'); details = action.webhookUrl || theme.dim('N/A');
} else if (action.type === 'script') { } else if (action.type === 'script') {
+4 -4
View File
@@ -124,7 +124,7 @@ export class GroupHandler {
await this.nupst.getDaemon().loadConfig(); await this.nupst.getDaemon().loadConfig();
} catch (error) { } catch (error) {
logger.error( logger.error(
'No configuration found. Please run "nupst setup" first to create a configuration.', 'No configuration found. Please run "nupst ups add" first to create a configuration.',
); );
return; return;
} }
@@ -219,7 +219,7 @@ export class GroupHandler {
await this.nupst.getDaemon().loadConfig(); await this.nupst.getDaemon().loadConfig();
} catch (error) { } catch (error) {
logger.error( logger.error(
'No configuration found. Please run "nupst setup" first to create a configuration.', 'No configuration found. Please run "nupst ups add" first to create a configuration.',
); );
return; return;
} }
@@ -316,7 +316,7 @@ export class GroupHandler {
await this.nupst.getDaemon().loadConfig(); await this.nupst.getDaemon().loadConfig();
} catch (error) { } catch (error) {
logger.error( logger.error(
'No configuration found. Please run "nupst setup" first to create a configuration.', 'No configuration found. Please run "nupst ups add" first to create a configuration.',
); );
return; return;
} }
@@ -484,7 +484,7 @@ export class GroupHandler {
prompt: (question: string) => Promise<string>, prompt: (question: string) => Promise<string>,
): Promise<void> { ): Promise<void> {
if (!config.upsDevices || config.upsDevices.length === 0) { if (!config.upsDevices || config.upsDevices.length === 0) {
logger.log('No UPS devices available. Use "nupst add" to add UPS devices.'); logger.log('No UPS devices available. Use "nupst ups add" to add UPS devices.');
return; return;
} }
+15 -24
View File
@@ -6,7 +6,7 @@ import { Nupst } from '../nupst.ts';
import { logger } from '../logger.ts'; import { logger } from '../logger.ts';
import { theme } from '../colors.ts'; import { theme } from '../colors.ts';
import { PAUSE } from '../constants.ts'; import { PAUSE } from '../constants.ts';
import type { IPauseState } from '../daemon.ts'; import type { IPauseState } from '../pause-state.ts';
import * as helpers from '../helpers/index.ts'; import * as helpers from '../helpers/index.ts';
/** /**
@@ -30,7 +30,9 @@ export class ServiceHandler {
public async enable(): Promise<void> { public async enable(): Promise<void> {
this.checkRootAccess('This command must be run as root.'); this.checkRootAccess('This command must be run as root.');
await this.nupst.getSystemd().install(); await this.nupst.getSystemd().install();
logger.log('NUPST service has been installed. Use "nupst start" to start the service.'); logger.log(
'NUPST service has been installed. Use "nupst service start" to start the service.',
);
} }
/** /**
@@ -103,10 +105,8 @@ export class ServiceHandler {
/** /**
* Show status of the systemd service and UPS * Show status of the systemd service and UPS
*/ */
public async status(): Promise<void> { public async status(debugMode: boolean = false): Promise<void> {
// Extract debug options from args array await this.nupst.getSystemd().getStatus(debugMode);
const debugOptions = this.extractDebugOptions(process.argv);
await this.nupst.getSystemd().getStatus(debugOptions.debugMode);
} }
/** /**
@@ -221,10 +221,14 @@ export class ServiceHandler {
const unit = match[2].toLowerCase(); const unit = match[2].toLowerCase();
switch (unit) { switch (unit) {
case 'm': return value * 60 * 1000; case 'm':
case 'h': return value * 60 * 60 * 1000; return value * 60 * 1000;
case 'd': return value * 24 * 60 * 60 * 1000; case 'h':
default: return null; return value * 60 * 60 * 1000;
case 'd':
return value * 24 * 60 * 60 * 1000;
default:
return null;
} }
} }
@@ -254,7 +258,7 @@ export class ServiceHandler {
try { try {
// Check if running as root // Check if running as root
this.checkRootAccess( this.checkRootAccess(
'This command must be run as root to update NUPST.', 'This command must be run as root to upgrade NUPST.',
); );
console.log(''); console.log('');
@@ -398,17 +402,4 @@ export class ServiceHandler {
process.exit(1); process.exit(1);
} }
} }
/**
* Extract and remove debug options from args array
* @param args Command line arguments
* @returns Object with debug flags and cleaned args
*/
private extractDebugOptions(args: string[]): { debugMode: boolean; cleanedArgs: string[] } {
const debugMode = args.includes('--debug') || args.includes('-d');
// Remove debug flags from args
const cleanedArgs = args.filter((arg) => arg !== '--debug' && arg !== '-d');
return { debugMode, cleanedArgs };
}
} }
+96 -22
View File
@@ -9,7 +9,8 @@ import type { IUpsdConfig } from '../upsd/types.ts';
import type { TProtocol } from '../protocol/types.ts'; import type { TProtocol } from '../protocol/types.ts';
import type { INupstConfig, IUpsConfig } from '../daemon.ts'; import type { INupstConfig, IUpsConfig } from '../daemon.ts';
import type { IActionConfig } from '../actions/base-action.ts'; import type { IActionConfig } from '../actions/base-action.ts';
import { UPSD } from '../constants.ts'; import { ProxmoxAction } from '../actions/proxmox-action.ts';
import { SHUTDOWN, UPSD } from '../constants.ts';
/** /**
* Thresholds configuration for CLI display * Thresholds configuration for CLI display
@@ -102,7 +103,15 @@ export class UpsHandler {
const protocol: TProtocol = protocolChoice === 2 ? 'upsd' : 'snmp'; const protocol: TProtocol = protocolChoice === 2 ? 'upsd' : 'snmp';
// Create a new UPS configuration object with defaults // Create a new UPS configuration object with defaults
const newUps: Record<string, unknown> & { id: string; name: string; groups: string[]; actions: IActionConfig[]; protocol: TProtocol; snmp?: ISnmpConfig; upsd?: IUpsdConfig } = { const newUps: Record<string, unknown> & {
id: string;
name: string;
groups: string[];
actions: IActionConfig[];
protocol: TProtocol;
snmp?: ISnmpConfig;
upsd?: IUpsdConfig;
} = {
id: upsId, id: upsId,
name: name || `UPS-${upsId}`, name: name || `UPS-${upsId}`,
protocol, protocol,
@@ -202,7 +211,7 @@ export class UpsHandler {
return; return;
} else { } else {
// For specific UPS ID, error if config doesn't exist // For specific UPS ID, error if config doesn't exist
logger.error('No configuration found. Please run "nupst setup" first.'); logger.error('No configuration found. Please run "nupst ups add" first.');
return; return;
} }
} }
@@ -241,7 +250,7 @@ export class UpsHandler {
} else { } else {
// For backward compatibility, edit the first UPS if no ID specified // For backward compatibility, edit the first UPS if no ID specified
if (config.upsDevices.length === 0) { if (config.upsDevices.length === 0) {
logger.error('No UPS devices configured. Please run "nupst add" to add a UPS.'); logger.error('No UPS devices configured. Please run "nupst ups add" to add a UPS.');
return; return;
} }
upsToEdit = config.upsDevices[0]; upsToEdit = config.upsDevices[0];
@@ -260,7 +269,9 @@ export class UpsHandler {
logger.info(`Current Protocol: ${currentProtocol.toUpperCase()}`); logger.info(`Current Protocol: ${currentProtocol.toUpperCase()}`);
logger.dim(' 1) SNMP (network UPS with SNMP agent)'); logger.dim(' 1) SNMP (network UPS with SNMP agent)');
logger.dim(' 2) UPSD/NIS (local NUT server, e.g. USB-connected UPS)'); logger.dim(' 2) UPSD/NIS (local NUT server, e.g. USB-connected UPS)');
const protocolInput = await prompt(`Select protocol [${currentProtocol === 'upsd' ? '2' : '1'}]: `); const protocolInput = await prompt(
`Select protocol [${currentProtocol === 'upsd' ? '2' : '1'}]: `,
);
const protocolChoice = parseInt(protocolInput, 10); const protocolChoice = parseInt(protocolInput, 10);
if (protocolChoice === 2) { if (protocolChoice === 2) {
upsToEdit.protocol = 'upsd'; upsToEdit.protocol = 'upsd';
@@ -347,7 +358,7 @@ export class UpsHandler {
const errorBoxWidth = 45; const errorBoxWidth = 45;
logger.logBoxTitle('Configuration Error', errorBoxWidth); logger.logBoxTitle('Configuration Error', errorBoxWidth);
logger.logBoxLine('No configuration found.'); logger.logBoxLine('No configuration found.');
logger.logBoxLine("Please run 'nupst setup' first to create a configuration."); logger.logBoxLine("Please run 'nupst ups add' first to create a configuration.");
logger.logBoxEnd(); logger.logBoxEnd();
return; return;
} }
@@ -358,7 +369,7 @@ export class UpsHandler {
// Check if multi-UPS config // Check if multi-UPS config
if (!config.upsDevices || !Array.isArray(config.upsDevices)) { if (!config.upsDevices || !Array.isArray(config.upsDevices)) {
logger.error('Legacy single-UPS configuration detected. Cannot delete UPS.'); logger.error('Legacy single-UPS configuration detected. Cannot delete UPS.');
logger.log('Use "nupst add" to migrate to multi-UPS configuration format first.'); logger.log('Use "nupst ups add" to migrate to multi-UPS configuration format first.');
return; return;
} }
@@ -526,7 +537,7 @@ export class UpsHandler {
const errorBoxWidth = 45; const errorBoxWidth = 45;
logger.logBoxTitle('Configuration Error', errorBoxWidth); logger.logBoxTitle('Configuration Error', errorBoxWidth);
logger.logBoxLine('No configuration found.'); logger.logBoxLine('No configuration found.');
logger.logBoxLine("Please run 'nupst setup' first to create a configuration."); logger.logBoxLine("Please run 'nupst ups add' first to create a configuration.");
logger.logBoxEnd(); logger.logBoxEnd();
return; return;
} }
@@ -623,7 +634,9 @@ export class UpsHandler {
logger.logBoxLine( logger.logBoxLine(
` Battery Capacity: ${snmpConfig.customOIDs.BATTERY_CAPACITY || 'Not set'}`, ` Battery Capacity: ${snmpConfig.customOIDs.BATTERY_CAPACITY || 'Not set'}`,
); );
logger.logBoxLine(` Battery Runtime: ${snmpConfig.customOIDs.BATTERY_RUNTIME || 'Not set'}`); logger.logBoxLine(
` Battery Runtime: ${snmpConfig.customOIDs.BATTERY_RUNTIME || 'Not set'}`,
);
} }
} }
@@ -649,7 +662,9 @@ export class UpsHandler {
const upsId = isUpsConfig ? (config as IUpsConfig).id : 'default'; const upsId = isUpsConfig ? (config as IUpsConfig).id : 'default';
const upsName = isUpsConfig ? (config as IUpsConfig).name : 'Default UPS'; const upsName = isUpsConfig ? (config as IUpsConfig).name : 'Default UPS';
const protocol = isUpsConfig ? ((config as IUpsConfig).protocol || 'snmp') : 'snmp'; const protocol = isUpsConfig ? ((config as IUpsConfig).protocol || 'snmp') : 'snmp';
logger.log(`\nTesting connection to UPS: ${upsName} (${upsId}) via ${protocol.toUpperCase()}...`); logger.log(
`\nTesting connection to UPS: ${upsName} (${upsId}) via ${protocol.toUpperCase()}...`,
);
try { try {
let status: ISnmpUpsStatus; let status: ISnmpUpsStatus;
@@ -690,7 +705,9 @@ export class UpsHandler {
logger.logBoxTitle(`Connection Failed: ${upsName}`, errorBoxWidth); logger.logBoxTitle(`Connection Failed: ${upsName}`, errorBoxWidth);
logger.logBoxLine(`Error: ${error instanceof Error ? error.message : String(error)}`); logger.logBoxLine(`Error: ${error instanceof Error ? error.message : String(error)}`);
logger.logBoxEnd(); logger.logBoxEnd();
logger.log("\nPlease check your settings and run 'nupst edit' to reconfigure this UPS."); logger.log(
`\nPlease check your settings and run 'nupst ups edit ${upsId}' to reconfigure this UPS.`,
);
} }
} }
@@ -974,6 +991,35 @@ export class UpsHandler {
OUTPUT_CURRENT: '', OUTPUT_CURRENT: '',
}; };
} }
// Runtime unit selection
logger.log('');
logger.info('Battery Runtime Unit:');
logger.dim(' Controls how NUPST interprets the runtime value from your UPS.');
logger.dim(' 1) Minutes (APC, TrippLite, Liebert - most common)');
logger.dim(' 2) Seconds (Eaton, HPE, many RFC 1628 devices)');
logger.dim(' 3) Ticks (CyberPower - 1/100 second increments)');
const defaultUnitValue = snmpConfig.runtimeUnit === 'seconds'
? 2
: snmpConfig.runtimeUnit === 'ticks'
? 3
: snmpConfig.upsModel === 'cyberpower'
? 3
: snmpConfig.upsModel === 'eaton'
? 2
: 1;
const unitInput = await prompt(`Select runtime unit [${defaultUnitValue}]: `);
const unitValue = parseInt(unitInput, 10) || defaultUnitValue;
if (unitValue === 2) {
snmpConfig.runtimeUnit = 'seconds';
} else if (unitValue === 3) {
snmpConfig.runtimeUnit = 'ticks';
} else {
snmpConfig.runtimeUnit = 'minutes';
}
} }
/** /**
@@ -1106,12 +1152,20 @@ export class UpsHandler {
if (typeValue === 1) { if (typeValue === 1) {
// Shutdown action // Shutdown action
action.type = 'shutdown'; action.type = 'shutdown';
const defaultShutdownDelay =
this.nupst.getDaemon().getConfig().defaultShutdownDelay ?? SHUTDOWN.DEFAULT_DELAY_MINUTES;
const delayInput = await prompt('Shutdown delay in minutes [5]: '); const delayInput = await prompt(
`Shutdown delay in minutes (leave empty for default ${defaultShutdownDelay}): `,
);
if (delayInput.trim()) {
const delay = parseInt(delayInput, 10); const delay = parseInt(delayInput, 10);
if (delayInput.trim() && !isNaN(delay)) { if (isNaN(delay) || delay < 0) {
logger.warn('Invalid shutdown delay, using configured default');
} else {
action.shutdownDelay = delay; action.shutdownDelay = delay;
} }
}
} else if (typeValue === 2) { } else if (typeValue === 2) {
// Webhook action // Webhook action
action.type = 'webhook'; action.type = 'webhook';
@@ -1155,10 +1209,25 @@ export class UpsHandler {
// Proxmox action // Proxmox action
action.type = 'proxmox'; 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.log('');
logger.info('Proxmox API Settings:'); logger.info('Proxmox API Settings:');
logger.dim('Requires a Proxmox API token. Create one with:'); logger.dim('Create a token with: pveum user token add root@pam nupst --privsep=0');
logger.dim(' pveum user token add root@pam nupst --privsep=0');
const pxHost = await prompt('Proxmox Host [localhost]: '); const pxHost = await prompt('Proxmox Host [localhost]: ');
action.proxmoxHost = pxHost.trim() || 'localhost'; action.proxmoxHost = pxHost.trim() || 'localhost';
@@ -1174,21 +1243,28 @@ export class UpsHandler {
const tokenId = await prompt('API Token ID (e.g., root@pam!nupst): '); const tokenId = await prompt('API Token ID (e.g., root@pam!nupst): ');
if (!tokenId.trim()) { if (!tokenId.trim()) {
logger.warn('Token ID is required for Proxmox action, skipping'); logger.warn('Token ID is required for API mode, skipping');
continue; continue;
} }
action.proxmoxTokenId = tokenId.trim(); action.proxmoxTokenId = tokenId.trim();
const tokenSecret = await prompt('API Token Secret: '); const tokenSecret = await prompt('API Token Secret: ');
if (!tokenSecret.trim()) { if (!tokenSecret.trim()) {
logger.warn('Token Secret is required for Proxmox action, skipping'); logger.warn('Token Secret is required for API mode, skipping');
continue; continue;
} }
action.proxmoxTokenSecret = tokenSecret.trim(); 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): '); const excludeInput = await prompt('VM/CT IDs to exclude (comma-separated, or empty): ');
if (excludeInput.trim()) { if (excludeInput.trim()) {
action.proxmoxExcludeIds = excludeInput.split(',').map((s) => parseInt(s.trim(), 10)).filter((n) => !isNaN(n)); 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 timeoutInput = await prompt('VM shutdown timeout in seconds [120]: ');
@@ -1197,12 +1273,9 @@ export class UpsHandler {
action.proxmoxStopTimeout = stopTimeout; action.proxmoxStopTimeout = stopTimeout;
} }
const forceInput = await prompt('Force-stop VMs that don\'t shut down in time? (Y/n): '); const forceInput = await prompt("Force-stop VMs that don't shut down in time? (Y/n): ");
action.proxmoxForceStop = forceInput.toLowerCase() !== 'n'; action.proxmoxForceStop = forceInput.toLowerCase() !== 'n';
const insecureInput = await prompt('Skip TLS verification (self-signed cert)? (Y/n): ');
action.proxmoxInsecure = insecureInput.toLowerCase() !== 'n';
logger.log(''); logger.log('');
logger.info('Note: Place the Proxmox action BEFORE the shutdown action'); logger.info('Note: Place the Proxmox action BEFORE the shutdown action');
logger.dim('in the action chain so VMs shut down before the host.'); logger.dim('in the action chain so VMs shut down before the host.');
@@ -1296,6 +1369,7 @@ export class UpsHandler {
logger.logBoxLine(`SNMP Host: ${ups.snmp.host}:${ups.snmp.port}`); logger.logBoxLine(`SNMP Host: ${ups.snmp.host}:${ups.snmp.port}`);
logger.logBoxLine(`SNMP Version: ${ups.snmp.version}`); logger.logBoxLine(`SNMP Version: ${ups.snmp.version}`);
logger.logBoxLine(`UPS Model: ${ups.snmp.upsModel}`); logger.logBoxLine(`UPS Model: ${ups.snmp.upsModel}`);
logger.logBoxLine(`Runtime Unit: ${ups.snmp.runtimeUnit || 'auto'}`);
} }
if (ups.groups && ups.groups.length > 0) { if (ups.groups && ups.groups.length > 0) {
+58
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,
};
}
+3
View File
@@ -157,6 +157,9 @@ export const PROXMOX = {
/** Proxmox API base path */ /** Proxmox API base path */
API_BASE: '/api2/json', 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; } as const;
/** /**
+192 -459
View File
@@ -1,8 +1,6 @@
import process from 'node:process'; import process from 'node:process';
import * as fs from 'node:fs'; import * as fs from 'node:fs';
import * as path from 'node:path'; import * as path from 'node:path';
import { exec, execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { NupstSnmp } from './snmp/manager.ts'; import { NupstSnmp } from './snmp/manager.ts';
import type { ISnmpConfig, IUpsStatus as ISnmpUpsStatus } from './snmp/types.ts'; import type { ISnmpConfig, IUpsStatus as ISnmpUpsStatus } from './snmp/types.ts';
import { NupstUpsd } from './upsd/client.ts'; import { NupstUpsd } from './upsd/client.ts';
@@ -13,12 +11,33 @@ import { logger } from './logger.ts';
import { MigrationRunner } from './migrations/index.ts'; import { MigrationRunner } from './migrations/index.ts';
import { formatPowerStatus, getBatteryColor, getRuntimeColor, theme } from './colors.ts'; import { formatPowerStatus, getBatteryColor, getRuntimeColor, theme } from './colors.ts';
import type { IActionConfig } from './actions/base-action.ts'; import type { IActionConfig } from './actions/base-action.ts';
import { ActionManager, type IActionContext, type TPowerStatus } from './actions/index.ts'; import { ActionManager } from './actions/index.ts';
import {
applyDefaultShutdownDelay,
decideUpsActionExecution,
type TUpsTriggerReason,
} from './action-orchestration.ts';
import { NupstHttpServer } from './http-server.ts'; import { NupstHttpServer } from './http-server.ts';
import { NETWORK, PAUSE, THRESHOLDS, TIMING, UI } from './constants.ts'; import { NETWORK, PAUSE, SHUTDOWN, THRESHOLDS, TIMING, UI } from './constants.ts';
import {
const execAsync = promisify(exec); analyzeConfigReload,
const execFileAsync = promisify(execFile); shouldRefreshPauseState,
shouldReloadConfig,
} from './config-watch.ts';
import { type IPauseState, loadPauseSnapshot } from './pause-state.ts';
import { ShutdownExecutor } from './shutdown-executor.ts';
import {
buildFailedUpsPollSnapshot,
buildSuccessfulUpsPollSnapshot,
ensureUpsStatus,
hasThresholdViolation,
} from './ups-monitoring.ts';
import {
buildShutdownErrorRow,
buildShutdownStatusRow,
selectEmergencyCandidate,
} from './shutdown-monitoring.ts';
import { createInitialUpsStatus, type IUpsStatus } from './ups-status.ts';
/** /**
* UPS configuration interface * UPS configuration interface
@@ -70,20 +89,6 @@ export interface IHttpServerConfig {
authToken: string; authToken: string;
} }
/**
* 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;
}
/** /**
* Configuration interface for the daemon * Configuration interface for the daemon
*/ */
@@ -96,6 +101,8 @@ export interface INupstConfig {
groups: IGroupConfig[]; groups: IGroupConfig[];
/** Check interval in milliseconds */ /** Check interval in milliseconds */
checkInterval: number; checkInterval: number;
/** Default delay in minutes for shutdown actions without an override */
defaultShutdownDelay?: number;
/** HTTP Server configuration */ /** HTTP Server configuration */
httpServer?: IHttpServerConfig; httpServer?: IHttpServerConfig;
@@ -113,25 +120,6 @@ export interface INupstConfig {
}; };
} }
/**
* UPS status tracking interface
*/
export interface IUpsStatus {
id: string;
name: string;
powerStatus: 'online' | 'onBattery' | 'unknown' | 'unreachable';
batteryCapacity: number;
batteryRuntime: number;
outputLoad: number; // Load percentage (0-100%)
outputPower: number; // Power in watts
outputVoltage: number; // Voltage in volts
outputCurrent: number; // Current in amps
lastStatusChange: number;
lastCheckTime: number;
consecutiveFailures: number;
unreachableSince: number;
}
/** /**
* Daemon class for monitoring UPS and handling shutdown * Daemon class for monitoring UPS and handling shutdown
* Responsible for loading/saving config and monitoring the UPS status * Responsible for loading/saving config and monitoring the UPS status
@@ -142,7 +130,8 @@ export class NupstDaemon {
/** Default configuration */ /** Default configuration */
private readonly DEFAULT_CONFIG: INupstConfig = { private readonly DEFAULT_CONFIG: INupstConfig = {
version: '4.2', version: '4.3',
defaultShutdownDelay: SHUTDOWN.DEFAULT_DELAY_MINUTES,
upsDevices: [ upsDevices: [
{ {
id: 'default', id: 'default',
@@ -162,6 +151,7 @@ export class NupstDaemon {
privKey: '', privKey: '',
// UPS model for OID selection // UPS model for OID selection
upsModel: 'cyberpower', upsModel: 'cyberpower',
runtimeUnit: 'ticks',
}, },
groups: [], groups: [],
actions: [ actions: [
@@ -172,7 +162,6 @@ export class NupstDaemon {
battery: THRESHOLDS.DEFAULT_BATTERY_PERCENT, // Shutdown when battery below 60% battery: THRESHOLDS.DEFAULT_BATTERY_PERCENT, // Shutdown when battery below 60%
runtime: THRESHOLDS.DEFAULT_RUNTIME_MINUTES, // Shutdown when runtime below 20 minutes runtime: THRESHOLDS.DEFAULT_RUNTIME_MINUTES, // Shutdown when runtime below 20 minutes
}, },
shutdownDelay: 5,
}, },
], ],
}, },
@@ -190,6 +179,7 @@ export class NupstDaemon {
private pauseState: IPauseState | null = null; private pauseState: IPauseState | null = null;
private upsStatus: Map<string, IUpsStatus> = new Map(); private upsStatus: Map<string, IUpsStatus> = new Map();
private httpServer?: NupstHttpServer; private httpServer?: NupstHttpServer;
private readonly shutdownExecutor: ShutdownExecutor;
/** /**
* Create a new daemon instance with the given protocol managers * Create a new daemon instance with the given protocol managers
@@ -198,6 +188,7 @@ export class NupstDaemon {
this.snmp = snmp; this.snmp = snmp;
this.upsd = upsd; this.upsd = upsd;
this.protocolResolver = new ProtocolResolver(snmp, upsd); this.protocolResolver = new ProtocolResolver(snmp, upsd);
this.shutdownExecutor = new ShutdownExecutor();
this.config = this.DEFAULT_CONFIG; this.config = this.DEFAULT_CONFIG;
} }
@@ -223,10 +214,13 @@ export class NupstDaemon {
const migrationRunner = new MigrationRunner(); const migrationRunner = new MigrationRunner();
const { config: migratedConfig, migrated } = await migrationRunner.run(parsedConfig); const { config: migratedConfig, migrated } = await migrationRunner.run(parsedConfig);
// Save migrated config back to disk if any migrations ran // Save migrated or normalized config back to disk when needed.
// Cast to INupstConfig since migrations ensure the output is valid // Cast to INupstConfig since migrations ensure the output is valid.
const validConfig = migratedConfig as unknown as INupstConfig; const validConfig = migratedConfig as unknown as INupstConfig;
if (migrated) { const normalizedShutdownDelay = this.normalizeShutdownDelay(validConfig.defaultShutdownDelay);
const shouldPersistNormalizedConfig = validConfig.defaultShutdownDelay !== normalizedShutdownDelay;
validConfig.defaultShutdownDelay = normalizedShutdownDelay;
if (migrated || shouldPersistNormalizedConfig) {
this.config = validConfig; this.config = validConfig;
await this.saveConfig(this.config); await this.saveConfig(this.config);
} else { } else {
@@ -260,10 +254,11 @@ export class NupstDaemon {
// Ensure version is always set and remove legacy fields before saving // Ensure version is always set and remove legacy fields before saving
const configToSave: INupstConfig = { const configToSave: INupstConfig = {
version: '4.2', version: '4.3',
upsDevices: config.upsDevices, upsDevices: config.upsDevices,
groups: config.groups, groups: config.groups,
checkInterval: config.checkInterval, checkInterval: config.checkInterval,
defaultShutdownDelay: this.normalizeShutdownDelay(config.defaultShutdownDelay),
...(config.httpServer ? { httpServer: config.httpServer } : {}), ...(config.httpServer ? { httpServer: config.httpServer } : {}),
}; };
@@ -282,7 +277,7 @@ export class NupstDaemon {
private logConfigError(message: string): void { private logConfigError(message: string): void {
logger.logBox( logger.logBox(
'Configuration Error', 'Configuration Error',
[message, "Please run 'nupst setup' first to create a configuration."], [message, "Please run 'nupst ups add' first to create a configuration."],
45, 45,
'error', 'error',
); );
@@ -295,6 +290,22 @@ export class NupstDaemon {
return this.config; return this.config;
} }
private normalizeShutdownDelay(delayMinutes: number | undefined): number {
if (
typeof delayMinutes !== 'number' ||
!Number.isFinite(delayMinutes) ||
delayMinutes < 0
) {
return SHUTDOWN.DEFAULT_DELAY_MINUTES;
}
return delayMinutes;
}
private getDefaultShutdownDelayMinutes(): number {
return this.normalizeShutdownDelay(this.config.defaultShutdownDelay);
}
/** /**
* Get the SNMP instance * Get the SNMP instance
*/ */
@@ -338,7 +349,7 @@ export class NupstDaemon {
logger.logBoxTitle('Update Available', boxWidth); logger.logBoxTitle('Update Available', boxWidth);
logger.logBoxLine(`Current Version: ${updateStatus.currentVersion}`); logger.logBoxLine(`Current Version: ${updateStatus.currentVersion}`);
logger.logBoxLine(`Latest Version: ${updateStatus.latestVersion}`); logger.logBoxLine(`Latest Version: ${updateStatus.latestVersion}`);
logger.logBoxLine('Run "sudo nupst update" to update'); logger.logBoxLine('Run "sudo nupst upgrade" to upgrade');
logger.logBoxEnd(); logger.logBoxEnd();
} }
}).catch(() => {}); // Ignore errors checking for updates }).catch(() => {}); // Ignore errors checking for updates
@@ -387,21 +398,7 @@ export class NupstDaemon {
if (this.config.upsDevices && this.config.upsDevices.length > 0) { if (this.config.upsDevices && this.config.upsDevices.length > 0) {
for (const ups of this.config.upsDevices) { for (const ups of this.config.upsDevices) {
this.upsStatus.set(ups.id, { this.upsStatus.set(ups.id, createInitialUpsStatus(ups));
id: ups.id,
name: ups.name,
powerStatus: 'unknown',
batteryCapacity: 100,
batteryRuntime: 999, // High value as default
outputLoad: 0,
outputPower: 0,
outputVoltage: 0,
outputCurrent: 0,
lastStatusChange: Date.now(),
lastCheckTime: 0,
consecutiveFailures: 0,
unreachableSince: 0,
});
} }
logger.log(`Initialized status tracking for ${this.config.upsDevices.length} UPS devices`); logger.log(`Initialized status tracking for ${this.config.upsDevices.length} UPS devices`);
@@ -506,66 +503,39 @@ export class NupstDaemon {
* Check and update pause state from the pause file * Check and update pause state from the pause file
*/ */
private checkPauseState(): void { private checkPauseState(): void {
try { const snapshot = loadPauseSnapshot(PAUSE.FILE_PATH, this.isPaused);
if (fs.existsSync(PAUSE.FILE_PATH)) {
const data = fs.readFileSync(PAUSE.FILE_PATH, 'utf8');
const state = JSON.parse(data) as IPauseState;
// Check if auto-resume time has passed if (snapshot.transition === 'autoResumed') {
if (state.resumeAt && Date.now() >= state.resumeAt) {
// Auto-resume: delete the pause file
try {
fs.unlinkSync(PAUSE.FILE_PATH);
} catch (_e) {
// Ignore deletion errors
}
if (this.isPaused) {
logger.log(''); logger.log('');
logger.logBoxTitle('Auto-Resume', 45, 'success'); logger.logBoxTitle('Auto-Resume', 45, 'success');
logger.logBoxLine('Pause duration expired, resuming action monitoring'); logger.logBoxLine('Pause duration expired, resuming action monitoring');
logger.logBoxEnd(); logger.logBoxEnd();
logger.log(''); logger.log('');
} } else if (snapshot.transition === 'paused' && snapshot.pauseState) {
this.isPaused = false;
this.pauseState = null;
return;
}
if (!this.isPaused) {
logger.log(''); logger.log('');
logger.logBoxTitle('Actions Paused', 45, 'warning'); logger.logBoxTitle('Actions Paused', 45, 'warning');
logger.logBoxLine(`Paused by: ${state.pausedBy}`); logger.logBoxLine(`Paused by: ${snapshot.pauseState.pausedBy}`);
if (state.reason) { if (snapshot.pauseState.reason) {
logger.logBoxLine(`Reason: ${state.reason}`); logger.logBoxLine(`Reason: ${snapshot.pauseState.reason}`);
} }
if (state.resumeAt) { if (snapshot.pauseState.resumeAt) {
const remaining = Math.round((state.resumeAt - Date.now()) / 1000); const remaining = Math.round((snapshot.pauseState.resumeAt - Date.now()) / 1000);
logger.logBoxLine(`Auto-resume in: ${remaining} seconds`); logger.logBoxLine(`Auto-resume in: ${remaining} seconds`);
} else { } else {
logger.logBoxLine('Duration: Indefinite (run "nupst resume" to resume)'); logger.logBoxLine('Duration: Indefinite (run "nupst resume" to resume)');
} }
logger.logBoxEnd(); logger.logBoxEnd();
logger.log(''); logger.log('');
} } else if (snapshot.transition === 'resumed') {
this.isPaused = true;
this.pauseState = state;
} else {
if (this.isPaused) {
logger.log(''); logger.log('');
logger.logBoxTitle('Actions Resumed', 45, 'success'); logger.logBoxTitle('Actions Resumed', 45, 'success');
logger.logBoxLine('Action monitoring has been resumed'); logger.logBoxLine('Action monitoring has been resumed');
logger.logBoxEnd(); logger.logBoxEnd();
logger.log(''); logger.log('');
} }
this.isPaused = false;
this.pauseState = null; this.isPaused = snapshot.isPaused;
} this.pauseState = snapshot.pauseState;
} catch (_error) {
// If we can't read the pause file, assume not paused
this.isPaused = false;
this.pauseState = null;
}
} }
/** /**
@@ -618,25 +588,8 @@ export class NupstDaemon {
private async checkAllUpsDevices(): Promise<void> { private async checkAllUpsDevices(): Promise<void> {
for (const ups of this.config.upsDevices) { for (const ups of this.config.upsDevices) {
try { try {
const upsStatus = this.upsStatus.get(ups.id); const initialStatus = ensureUpsStatus(this.upsStatus.get(ups.id), ups);
if (!upsStatus) { this.upsStatus.set(ups.id, initialStatus);
// Initialize status for this UPS if not exists
this.upsStatus.set(ups.id, {
id: ups.id,
name: ups.name,
powerStatus: 'unknown',
batteryCapacity: 100,
batteryRuntime: 999,
outputLoad: 0,
outputPower: 0,
outputVoltage: 0,
outputCurrent: 0,
lastStatusChange: Date.now(),
lastCheckTime: 0,
consecutiveFailures: 0,
unreachableSince: 0,
});
}
// Check UPS status via configured protocol // Check UPS status via configured protocol
const protocol = ups.protocol || 'snmp'; const protocol = ups.protocol || 'snmp';
@@ -645,129 +598,100 @@ export class NupstDaemon {
: await this.protocolResolver.getUpsStatus('snmp', ups.snmp); : await this.protocolResolver.getUpsStatus('snmp', ups.snmp);
const currentTime = Date.now(); const currentTime = Date.now();
// Get the current status from the map
const currentStatus = this.upsStatus.get(ups.id); const currentStatus = this.upsStatus.get(ups.id);
const pollSnapshot = buildSuccessfulUpsPollSnapshot(
ups,
status,
currentStatus,
currentTime,
);
// Successful query: reset consecutive failures if (pollSnapshot.transition === 'recovered' && pollSnapshot.previousStatus) {
const wasUnreachable = currentStatus?.powerStatus === 'unreachable';
// Update status with new values
const updatedStatus: IUpsStatus = {
id: ups.id,
name: ups.name,
powerStatus: status.powerStatus,
batteryCapacity: status.batteryCapacity,
batteryRuntime: status.batteryRuntime,
outputLoad: status.outputLoad,
outputPower: status.outputPower,
outputVoltage: status.outputVoltage,
outputCurrent: status.outputCurrent,
lastCheckTime: currentTime,
lastStatusChange: currentStatus?.lastStatusChange || currentTime,
consecutiveFailures: 0,
unreachableSince: 0,
};
// If UPS was unreachable and is now reachable, log recovery
if (wasUnreachable && currentStatus) {
const downtime = Math.round((currentTime - currentStatus.unreachableSince) / 1000);
logger.log(''); logger.log('');
logger.logBoxTitle(`UPS Recovered: ${ups.name}`, 60, 'success'); logger.logBoxTitle(`UPS Recovered: ${ups.name}`, 60, 'success');
logger.logBoxLine(`UPS is reachable again after ${downtime} seconds`); logger.logBoxLine(`UPS is reachable again after ${pollSnapshot.downtimeSeconds} seconds`);
logger.logBoxLine(`Current Status: ${formatPowerStatus(status.powerStatus)}`); logger.logBoxLine(`Current Status: ${formatPowerStatus(status.powerStatus)}`);
logger.logBoxLine(`Time: ${new Date().toISOString()}`); logger.logBoxLine(`Time: ${new Date().toISOString()}`);
logger.logBoxEnd(); logger.logBoxEnd();
logger.log(''); logger.log('');
updatedStatus.lastStatusChange = currentTime;
// Trigger power status change action for recovery // Trigger power status change action for recovery
await this.triggerUpsActions(ups, updatedStatus, currentStatus, 'powerStatusChange'); await this.triggerUpsActions(
} else if (currentStatus && currentStatus.powerStatus !== status.powerStatus) { ups,
// Check if power status changed pollSnapshot.updatedStatus,
pollSnapshot.previousStatus,
'powerStatusChange',
);
} else if (pollSnapshot.transition === 'powerStatusChange' && pollSnapshot.previousStatus) {
logger.log(''); logger.log('');
logger.logBoxTitle(`Power Status Change: ${ups.name}`, 60, 'warning'); logger.logBoxTitle(`Power Status Change: ${ups.name}`, 60, 'warning');
logger.logBoxLine(`Previous: ${formatPowerStatus(currentStatus.powerStatus)}`); logger.logBoxLine(
`Previous: ${formatPowerStatus(pollSnapshot.previousStatus.powerStatus)}`,
);
logger.logBoxLine(`Current: ${formatPowerStatus(status.powerStatus)}`); logger.logBoxLine(`Current: ${formatPowerStatus(status.powerStatus)}`);
logger.logBoxLine(`Time: ${new Date().toISOString()}`); logger.logBoxLine(`Time: ${new Date().toISOString()}`);
logger.logBoxEnd(); logger.logBoxEnd();
logger.log(''); logger.log('');
updatedStatus.lastStatusChange = currentTime;
// Trigger actions for power status change // Trigger actions for power status change
await this.triggerUpsActions(ups, updatedStatus, currentStatus, 'powerStatusChange'); await this.triggerUpsActions(
ups,
pollSnapshot.updatedStatus,
pollSnapshot.previousStatus,
'powerStatusChange',
);
} }
// Check if any action's thresholds are exceeded (for threshold violation triggers)
// Only check when on battery power
if (status.powerStatus === 'onBattery' && ups.actions && ups.actions.length > 0) {
let anyThresholdExceeded = false;
for (const actionConfig of ups.actions) {
if (actionConfig.thresholds) {
if ( if (
status.batteryCapacity < actionConfig.thresholds.battery || hasThresholdViolation(
status.batteryRuntime < actionConfig.thresholds.runtime status.powerStatus,
status.batteryCapacity,
status.batteryRuntime,
ups.actions,
)
) { ) {
anyThresholdExceeded = true; await this.triggerUpsActions(
break; ups,
} pollSnapshot.updatedStatus,
} pollSnapshot.previousStatus,
} 'thresholdViolation',
);
// Trigger actions with threshold violation reason if any threshold is exceeded
// Actions will individually check their own thresholds in shouldExecute()
if (anyThresholdExceeded) {
await this.triggerUpsActions(ups, updatedStatus, currentStatus, 'thresholdViolation');
}
} }
// Update the status in the map // Update the status in the map
this.upsStatus.set(ups.id, updatedStatus); this.upsStatus.set(ups.id, pollSnapshot.updatedStatus);
} catch (error) { } catch (error) {
// Network loss / query failure tracking const currentTime = Date.now();
const currentStatus = this.upsStatus.get(ups.id); const currentStatus = this.upsStatus.get(ups.id);
const failures = Math.min( const failureSnapshot = buildFailedUpsPollSnapshot(ups, currentStatus, currentTime);
(currentStatus?.consecutiveFailures || 0) + 1,
NETWORK.MAX_CONSECUTIVE_FAILURES,
);
logger.error( logger.error(
`Error checking UPS ${ups.name} (${ups.id}) [failure ${failures}/${NETWORK.CONSECUTIVE_FAILURE_THRESHOLD}]: ${ `Error checking UPS ${ups.name} (${ups.id}) [failure ${failureSnapshot.failures}/${NETWORK.CONSECUTIVE_FAILURE_THRESHOLD}]: ${
error instanceof Error ? error.message : String(error) error instanceof Error ? error.message : String(error)
}`, }`,
); );
// Transition to unreachable after threshold consecutive failures if (failureSnapshot.transition === 'unreachable' && failureSnapshot.previousStatus) {
if (
failures >= NETWORK.CONSECUTIVE_FAILURE_THRESHOLD &&
currentStatus &&
currentStatus.powerStatus !== 'unreachable'
) {
const currentTime = Date.now();
const previousStatus = { ...currentStatus };
currentStatus.powerStatus = 'unreachable';
currentStatus.consecutiveFailures = failures;
currentStatus.unreachableSince = currentTime;
currentStatus.lastStatusChange = currentTime;
this.upsStatus.set(ups.id, currentStatus);
logger.log(''); logger.log('');
logger.logBoxTitle(`UPS Unreachable: ${ups.name}`, 60, 'error'); logger.logBoxTitle(`UPS Unreachable: ${ups.name}`, 60, 'error');
logger.logBoxLine(`${failures} consecutive communication failures`); logger.logBoxLine(`${failureSnapshot.failures} consecutive communication failures`);
logger.logBoxLine(`Last known status: ${formatPowerStatus(previousStatus.powerStatus)}`); logger.logBoxLine(
`Last known status: ${formatPowerStatus(failureSnapshot.previousStatus.powerStatus)}`,
);
logger.logBoxLine(`Time: ${new Date().toISOString()}`); logger.logBoxLine(`Time: ${new Date().toISOString()}`);
logger.logBoxEnd(); logger.logBoxEnd();
logger.log(''); logger.log('');
// Trigger power status change action for unreachable // Trigger power status change action for unreachable
await this.triggerUpsActions(ups, currentStatus, previousStatus, 'powerStatusChange'); await this.triggerUpsActions(
} else if (currentStatus) { ups,
currentStatus.consecutiveFailures = failures; failureSnapshot.updatedStatus,
this.upsStatus.set(ups.id, currentStatus); failureSnapshot.previousStatus,
'powerStatusChange',
);
} }
this.upsStatus.set(ups.id, failureSnapshot.updatedStatus);
} }
} }
} }
@@ -780,7 +704,11 @@ export class NupstDaemon {
logger.log(''); logger.log('');
const pauseLabel = this.isPaused ? ' [PAUSED]' : ''; const pauseLabel = this.isPaused ? ' [PAUSED]' : '';
logger.logBoxTitle(`Periodic Status Update${pauseLabel}`, 70, this.isPaused ? 'warning' : 'info'); logger.logBoxTitle(
`Periodic Status Update${pauseLabel}`,
70,
this.isPaused ? 'warning' : 'info',
);
logger.logBoxLine(`Timestamp: ${timestamp}`); logger.logBoxLine(`Timestamp: ${timestamp}`);
if (this.isPaused && this.pauseState) { if (this.isPaused && this.pauseState) {
logger.logBoxLine(`Actions paused by: ${this.pauseState.pausedBy}`); logger.logBoxLine(`Actions paused by: ${this.pauseState.pausedBy}`);
@@ -821,30 +749,6 @@ export class NupstDaemon {
logger.log(''); logger.log('');
} }
/**
* Build action context from UPS state
* @param ups UPS configuration
* @param status Current UPS status
* @param triggerReason Why this action is being triggered
* @returns Action context
*/
private buildActionContext(
ups: IUpsConfig,
status: IUpsStatus,
triggerReason: 'powerStatusChange' | 'thresholdViolation',
): IActionContext {
return {
upsId: ups.id,
upsName: ups.name,
powerStatus: status.powerStatus as TPowerStatus,
batteryCapacity: status.batteryCapacity,
batteryRuntime: status.batteryRuntime,
previousPowerStatus: 'unknown' as TPowerStatus, // Will be set from map in calling code
timestamp: Date.now(),
triggerReason,
};
}
/** /**
* Trigger actions for a UPS device * Trigger actions for a UPS device
* @param ups UPS configuration * @param ups UPS configuration
@@ -856,35 +760,36 @@ export class NupstDaemon {
ups: IUpsConfig, ups: IUpsConfig,
status: IUpsStatus, status: IUpsStatus,
previousStatus: IUpsStatus | undefined, previousStatus: IUpsStatus | undefined,
triggerReason: 'powerStatusChange' | 'thresholdViolation', triggerReason: TUpsTriggerReason,
): Promise<void> { ): Promise<void> {
// Check if actions are paused const decision = decideUpsActionExecution(
if (this.isPaused) { this.isPaused,
logger.info( ups,
`[PAUSED] Actions suppressed for UPS ${ups.name} (trigger: ${triggerReason})`, status,
previousStatus,
triggerReason,
); );
if (decision.type === 'suppressed') {
logger.info(decision.message);
return; return;
} }
const actions = ups.actions || []; if (decision.type === 'legacyShutdown') {
await this.initiateShutdown(decision.reason);
// Backward compatibility: if no actions configured, use default shutdown behavior
if (actions.length === 0 && triggerReason === 'thresholdViolation') {
// Fall back to old shutdown logic for backward compatibility
await this.initiateShutdown(`UPS "${ups.name}" battery or runtime below threshold`);
return; return;
} }
if (actions.length === 0) { if (decision.type === 'skip') {
return; // No actions to execute return;
} }
// Build action context const actions = applyDefaultShutdownDelay(
const context = this.buildActionContext(ups, status, triggerReason); decision.actions,
context.previousPowerStatus = (previousStatus?.powerStatus || 'unknown') as TPowerStatus; this.getDefaultShutdownDelayMinutes(),
);
// Execute actions await ActionManager.executeActions(actions, decision.context);
await ActionManager.executeActions(actions, context);
} }
/** /**
@@ -894,60 +799,11 @@ export class NupstDaemon {
public async initiateShutdown(reason: string): Promise<void> { public async initiateShutdown(reason: string): Promise<void> {
logger.log(`Initiating system shutdown due to: ${reason}`); logger.log(`Initiating system shutdown due to: ${reason}`);
// Set a longer delay for shutdown to allow VMs and services to close const shutdownDelayMinutes = this.getDefaultShutdownDelayMinutes();
const shutdownDelayMinutes = 5;
try { try {
// Find shutdown command in common system paths await this.shutdownExecutor.scheduleShutdown(shutdownDelayMinutes);
const shutdownPaths = [
'/sbin/shutdown',
'/usr/sbin/shutdown',
'/bin/shutdown',
'/usr/bin/shutdown',
];
let shutdownCmd = '';
for (const path of shutdownPaths) {
try {
if (fs.existsSync(path)) {
shutdownCmd = path;
logger.log(`Found shutdown command at: ${shutdownCmd}`);
break;
}
} catch (e) {
// Continue checking other paths
}
}
if (shutdownCmd) {
// Execute shutdown command with delay to allow for VM graceful shutdown
logger.log(
`Executing: ${shutdownCmd} -h +${shutdownDelayMinutes} "UPS battery critical..."`,
);
const { stdout } = await execFileAsync(shutdownCmd, [
'-h',
`+${shutdownDelayMinutes}`,
`UPS battery critical, shutting down in ${shutdownDelayMinutes} minutes`,
]);
logger.log(`Shutdown initiated: ${stdout}`);
logger.log(`Allowing ${shutdownDelayMinutes} minutes for VMs to shut down safely`); logger.log(`Allowing ${shutdownDelayMinutes} minutes for VMs to shut down safely`);
} else {
// Try using the PATH to find shutdown
try {
logger.log('Shutdown command not found in common paths, trying via PATH...');
const { stdout } = await execAsync(
`shutdown -h +${shutdownDelayMinutes} "UPS battery critical, shutting down in ${shutdownDelayMinutes} minutes"`,
{
env: process.env, // Pass the current environment
},
);
logger.log(`Shutdown initiated: ${stdout}`);
} catch (e) {
throw new Error(
`Shutdown command not found: ${e instanceof Error ? e.message : String(e)}`,
);
}
}
// Monitor UPS during shutdown and force immediate shutdown if battery gets too low // Monitor UPS during shutdown and force immediate shutdown if battery gets too low
logger.log('Monitoring UPS during shutdown process...'); logger.log('Monitoring UPS during shutdown process...');
@@ -955,53 +811,12 @@ export class NupstDaemon {
} catch (error) { } catch (error) {
logger.error(`Failed to initiate shutdown: ${error}`); logger.error(`Failed to initiate shutdown: ${error}`);
// Try alternative shutdown methods const shutdownTriggered = await this.shutdownExecutor.tryScheduledAlternatives();
const alternatives = [ if (!shutdownTriggered) {
{ cmd: 'poweroff', args: ['--force'] },
{ cmd: 'halt', args: ['-p'] },
{ cmd: 'systemctl', args: ['poweroff'] },
{ cmd: 'reboot', args: ['-p'] }, // Some systems allow reboot -p for power off
];
for (const alt of alternatives) {
try {
// First check if command exists in common system paths
const paths = [
`/sbin/${alt.cmd}`,
`/usr/sbin/${alt.cmd}`,
`/bin/${alt.cmd}`,
`/usr/bin/${alt.cmd}`,
];
let cmdPath = '';
for (const path of paths) {
if (fs.existsSync(path)) {
cmdPath = path;
break;
}
}
if (cmdPath) {
logger.log(`Trying alternative shutdown method: ${cmdPath} ${alt.args.join(' ')}`);
await execFileAsync(cmdPath, alt.args);
return; // Exit if successful
} else {
// Try using PATH environment
logger.log(`Trying alternative via PATH: ${alt.cmd} ${alt.args.join(' ')}`);
await execAsync(`${alt.cmd} ${alt.args.join(' ')}`, {
env: process.env, // Pass the current environment
});
return; // Exit if successful
}
} catch (altError) {
logger.error(`Alternative method ${alt.cmd} failed: ${altError}`);
// Continue to next method
}
}
logger.error('All shutdown methods failed'); logger.error('All shutdown methods failed');
} }
} }
}
/** /**
* Monitor UPS during system shutdown * Monitor UPS during system shutdown
@@ -1036,7 +851,6 @@ export class NupstDaemon {
]; ];
const rows: Array<Record<string, string>> = []; const rows: Array<Record<string, string>> = [];
let emergencyDetected = false;
let emergencyUps: { ups: IUpsConfig; status: ISnmpUpsStatus } | null = null; let emergencyUps: { ups: IUpsConfig; status: ISnmpUpsStatus } | null = null;
// Check all UPS devices // Check all UPS devices
@@ -1046,31 +860,30 @@ export class NupstDaemon {
const status = protocol === 'upsd' && ups.upsd const status = protocol === 'upsd' && ups.upsd
? await this.protocolResolver.getUpsStatus('upsd', undefined, ups.upsd) ? await this.protocolResolver.getUpsStatus('upsd', undefined, ups.upsd)
: await this.protocolResolver.getUpsStatus('snmp', ups.snmp); : await this.protocolResolver.getUpsStatus('snmp', ups.snmp);
const rowSnapshot = buildShutdownStatusRow(
ups.name,
status,
THRESHOLDS.EMERGENCY_RUNTIME_MINUTES,
{
battery: (batteryCapacity) =>
getBatteryColor(batteryCapacity)(`${batteryCapacity}%`),
runtime: (batteryRuntime) =>
getRuntimeColor(batteryRuntime)(`${batteryRuntime} min`),
ok: theme.success,
critical: theme.error,
error: theme.error,
},
);
const batteryColor = getBatteryColor(status.batteryCapacity); rows.push(rowSnapshot.row);
const runtimeColor = getRuntimeColor(status.batteryRuntime); emergencyUps = selectEmergencyCandidate(
emergencyUps,
const isCritical = status.batteryRuntime < THRESHOLDS.EMERGENCY_RUNTIME_MINUTES; ups,
status,
rows.push({ THRESHOLDS.EMERGENCY_RUNTIME_MINUTES,
name: ups.name, );
battery: batteryColor(status.batteryCapacity + '%'),
runtime: runtimeColor(status.batteryRuntime + ' min'),
status: isCritical ? theme.error('CRITICAL!') : theme.success('OK'),
});
// If any UPS battery runtime gets critically low, flag for immediate shutdown
if (isCritical && !emergencyDetected) {
emergencyDetected = true;
emergencyUps = { ups, status };
}
} catch (upsError) { } catch (upsError) {
rows.push({ rows.push(buildShutdownErrorRow(ups.name, theme.error));
name: ups.name,
battery: theme.error('N/A'),
runtime: theme.error('N/A'),
status: theme.error('ERROR'),
});
logger.error( logger.error(
`Error checking UPS ${ups.name} during shutdown: ${ `Error checking UPS ${ups.name} during shutdown: ${
@@ -1085,7 +898,7 @@ export class NupstDaemon {
logger.log(''); logger.log('');
// If emergency detected, trigger immediate shutdown // If emergency detected, trigger immediate shutdown
if (emergencyDetected && emergencyUps) { if (emergencyUps) {
logger.log(''); logger.log('');
logger.logBoxTitle('EMERGENCY SHUTDOWN', 60, 'error'); logger.logBoxTitle('EMERGENCY SHUTDOWN', 60, 'error');
logger.logBoxLine( logger.logBoxLine(
@@ -1123,88 +936,16 @@ export class NupstDaemon {
*/ */
private async forceImmediateShutdown(): Promise<void> { private async forceImmediateShutdown(): Promise<void> {
try { try {
// Find shutdown command in common system paths await this.shutdownExecutor.forceImmediateShutdown();
const shutdownPaths = [
'/sbin/shutdown',
'/usr/sbin/shutdown',
'/bin/shutdown',
'/usr/bin/shutdown',
];
let shutdownCmd = '';
for (const path of shutdownPaths) {
if (fs.existsSync(path)) {
shutdownCmd = path;
logger.log(`Found shutdown command at: ${shutdownCmd}`);
break;
}
}
if (shutdownCmd) {
logger.log(`Executing emergency shutdown: ${shutdownCmd} -h now`);
await execFileAsync(shutdownCmd, [
'-h',
'now',
'EMERGENCY: UPS battery critically low, shutting down NOW',
]);
} else {
// Try using the PATH to find shutdown
logger.log('Shutdown command not found in common paths, trying via PATH...');
await execAsync(
'shutdown -h now "EMERGENCY: UPS battery critically low, shutting down NOW"',
{
env: process.env, // Pass the current environment
},
);
}
} catch (error) { } catch (error) {
logger.error('Emergency shutdown failed, trying alternative methods...'); logger.error('Emergency shutdown failed, trying alternative methods...');
// Try alternative shutdown methods in sequence const shutdownTriggered = await this.shutdownExecutor.tryEmergencyAlternatives();
const alternatives = [ if (!shutdownTriggered) {
{ cmd: 'poweroff', args: ['--force'] },
{ cmd: 'halt', args: ['-p'] },
{ cmd: 'systemctl', args: ['poweroff'] },
];
for (const alt of alternatives) {
try {
// Check common paths
const paths = [
`/sbin/${alt.cmd}`,
`/usr/sbin/${alt.cmd}`,
`/bin/${alt.cmd}`,
`/usr/bin/${alt.cmd}`,
];
let cmdPath = '';
for (const path of paths) {
if (fs.existsSync(path)) {
cmdPath = path;
break;
}
}
if (cmdPath) {
logger.log(`Emergency: using ${cmdPath} ${alt.args.join(' ')}`);
await execFileAsync(cmdPath, alt.args);
return; // Exit if successful
} else {
// Try using PATH
logger.log(`Emergency: trying ${alt.cmd} via PATH`);
await execAsync(`${alt.cmd} ${alt.args.join(' ')}`, {
env: process.env,
});
return; // Exit if successful
}
} catch (altError) {
// Continue to next method
}
}
logger.error('All emergency shutdown methods failed'); logger.error('All emergency shutdown methods failed');
} }
} }
}
/** /**
* Idle monitoring loop when no UPS devices are configured * Idle monitoring loop when no UPS devices are configured
@@ -1275,19 +1016,13 @@ export class NupstDaemon {
for await (const event of watcher) { for await (const event of watcher) {
// Respond to modify events on config file // Respond to modify events on config file
if ( if (shouldReloadConfig(event)) {
event.kind === 'modify' &&
event.paths.some((p) => p.includes('config.json'))
) {
logger.info('Config file changed, reloading...'); logger.info('Config file changed, reloading...');
await this.reloadConfig(); await this.reloadConfig();
} }
// Detect pause file changes // Detect pause file changes
if ( if (shouldRefreshPauseState(event)) {
(event.kind === 'create' || event.kind === 'modify' || event.kind === 'remove') &&
event.paths.some((p) => p.includes('pause'))
) {
this.checkPauseState(); this.checkPauseState();
} }
@@ -1321,18 +1056,16 @@ export class NupstDaemon {
await this.loadConfig(); await this.loadConfig();
const newDeviceCount = this.config.upsDevices?.length || 0; const newDeviceCount = this.config.upsDevices?.length || 0;
if (newDeviceCount > 0 && oldDeviceCount === 0) { const reloadSnapshot = analyzeConfigReload(oldDeviceCount, newDeviceCount);
logger.success(`Configuration reloaded! Found ${newDeviceCount} UPS device(s)`); logger.success(reloadSnapshot.message);
logger.info('Monitoring will start automatically...');
} else if (newDeviceCount !== oldDeviceCount) {
logger.success(
`Configuration reloaded! UPS devices: ${oldDeviceCount}${newDeviceCount}`,
);
if (reloadSnapshot.shouldLogMonitoringStart) {
logger.info('Monitoring will start automatically...');
}
if (reloadSnapshot.shouldInitializeUpsStatus) {
// Reinitialize UPS status tracking // Reinitialize UPS status tracking
this.initializeUpsStatus(); this.initializeUpsStatus();
} else {
logger.success('Configuration reloaded successfully');
} }
} catch (error) { } catch (error) {
logger.warn( logger.warn(
+2 -1
View File
@@ -1,7 +1,8 @@
import * as http from 'node:http'; import * as http from 'node:http';
import { URL } from 'node:url'; import { URL } from 'node:url';
import { logger } from './logger.ts'; import { logger } from './logger.ts';
import type { IPauseState, IUpsStatus } from './daemon.ts'; import type { IPauseState } from './pause-state.ts';
import type { IUpsStatus } from './ups-status.ts';
/** /**
* HTTP Server for exposing UPS status as JSON * HTTP Server for exposing UPS status as JSON
+1 -1
View File
@@ -10,7 +10,7 @@ import process from 'node:process';
*/ */
async function main() { async function main() {
const cli = new NupstCli(); const cli = new NupstCli();
await cli.parseAndExecute(process.argv); await cli.parseAndExecute(process.argv.slice(2));
} }
// Run the main function and handle any errors // Run the main function and handle any errors
+1
View File
@@ -10,3 +10,4 @@ export { MigrationV1ToV2 } from './migration-v1-to-v2.ts';
export { MigrationV3ToV4 } from './migration-v3-to-v4.ts'; export { MigrationV3ToV4 } from './migration-v3-to-v4.ts';
export { MigrationV4_0ToV4_1 } from './migration-v4.0-to-v4.1.ts'; export { MigrationV4_0ToV4_1 } from './migration-v4.0-to-v4.1.ts';
export { MigrationV4_1ToV4_2 } from './migration-v4.1-to-v4.2.ts'; export { MigrationV4_1ToV4_2 } from './migration-v4.1-to-v4.2.ts';
export { MigrationV4_2ToV4_3 } from './migration-v4.2-to-v4.3.ts';
+3 -1
View File
@@ -3,6 +3,7 @@ import { MigrationV1ToV2 } from './migration-v1-to-v2.ts';
import { MigrationV3ToV4 } from './migration-v3-to-v4.ts'; import { MigrationV3ToV4 } from './migration-v3-to-v4.ts';
import { MigrationV4_0ToV4_1 } from './migration-v4.0-to-v4.1.ts'; import { MigrationV4_0ToV4_1 } from './migration-v4.0-to-v4.1.ts';
import { MigrationV4_1ToV4_2 } from './migration-v4.1-to-v4.2.ts'; import { MigrationV4_1ToV4_2 } from './migration-v4.1-to-v4.2.ts';
import { MigrationV4_2ToV4_3 } from './migration-v4.2-to-v4.3.ts';
import { logger } from '../logger.ts'; import { logger } from '../logger.ts';
/** /**
@@ -21,6 +22,7 @@ export class MigrationRunner {
new MigrationV3ToV4(), new MigrationV3ToV4(),
new MigrationV4_0ToV4_1(), new MigrationV4_0ToV4_1(),
new MigrationV4_1ToV4_2(), new MigrationV4_1ToV4_2(),
new MigrationV4_2ToV4_3(),
]; ];
// Sort by version order to ensure they run in sequence // Sort by version order to ensure they run in sequence
@@ -56,7 +58,7 @@ export class MigrationRunner {
if (anyMigrationsRan) { if (anyMigrationsRan) {
logger.success('Configuration migrations complete'); logger.success('Configuration migrations complete');
} else { } else {
logger.success('config format ok'); logger.success('Configuration format OK');
} }
return { return {
+1 -3
View File
@@ -37,8 +37,7 @@ import { logger } from '../logger.ts';
* { * {
* type: "shutdown", * type: "shutdown",
* thresholds: { battery: 60, runtime: 20 }, * thresholds: { battery: 60, runtime: 20 },
* triggerMode: "onlyThresholds", * triggerMode: "onlyThresholds"
* shutdownDelay: 5
* } * }
* ] * ]
* } * }
@@ -93,7 +92,6 @@ export class MigrationV4_0ToV4_1 extends BaseMigration {
runtime: deviceThresholds.runtime, runtime: deviceThresholds.runtime,
}, },
triggerMode: 'onlyThresholds', // Preserve old behavior (only on threshold violation) triggerMode: 'onlyThresholds', // Preserve old behavior (only on threshold violation)
shutdownDelay: 5, // Default delay
}, },
]; ];
logger.dim( logger.dim(
+50
View File
@@ -0,0 +1,50 @@
import { BaseMigration } from './base-migration.ts';
import { logger } from '../logger.ts';
/**
* Migration from v4.2 to v4.3
*
* Changes:
* 1. Adds `runtimeUnit` to SNMP configs based on existing `upsModel`
* 2. Bumps version from '4.2' to '4.3'
*/
export class MigrationV4_2ToV4_3 extends BaseMigration {
readonly fromVersion = '4.2';
readonly toVersion = '4.3';
shouldRun(config: Record<string, unknown>): boolean {
return config.version === '4.2';
}
migrate(config: Record<string, unknown>): Record<string, unknown> {
logger.info(`${this.getName()}: Adding runtimeUnit to SNMP configs...`);
const devices = (config.upsDevices as Array<Record<string, unknown>>) || [];
const migratedDevices = devices.map((device) => {
const snmp = device.snmp as Record<string, unknown> | undefined;
if (snmp && !snmp.runtimeUnit) {
const model = snmp.upsModel as string | undefined;
if (model === 'cyberpower') {
snmp.runtimeUnit = 'ticks';
} else if (model === 'eaton') {
snmp.runtimeUnit = 'seconds';
} else {
snmp.runtimeUnit = 'minutes';
}
logger.dim(`${device.name}: Set runtimeUnit to '${snmp.runtimeUnit}'`);
}
return device;
});
const result = {
...config,
version: this.toVersion,
upsDevices: migratedDevices,
};
logger.success(
`${this.getName()}: Migration complete (${migratedDevices.length} devices updated)`,
);
return result;
}
}
+2 -2
View File
@@ -235,7 +235,7 @@ export class Nupst implements INupstAccessor {
if (this.updateAvailable && this.latestVersion) { if (this.updateAvailable && this.latestVersion) {
logger.logBoxLine(`Update Available: ${this.latestVersion}`); logger.logBoxLine(`Update Available: ${this.latestVersion}`);
logger.logBoxLine('Run "sudo nupst update" to update'); logger.logBoxLine('Run "sudo nupst upgrade" to upgrade');
logger.logBoxEnd(); logger.logBoxEnd();
} else if (checkForUpdates) { } else if (checkForUpdates) {
logger.logBoxLine('Checking for updates...'); logger.logBoxLine('Checking for updates...');
@@ -244,7 +244,7 @@ export class Nupst implements INupstAccessor {
this.checkForUpdates().then((updateAvailable) => { this.checkForUpdates().then((updateAvailable) => {
if (updateAvailable) { if (updateAvailable) {
logger.logBoxLine(`Update Available: ${this.latestVersion}`); logger.logBoxLine(`Update Available: ${this.latestVersion}`);
logger.logBoxLine('Run "sudo nupst update" to update'); logger.logBoxLine('Run "sudo nupst upgrade" to upgrade');
} else { } else {
logger.logBoxLine('You are running the latest version'); logger.logBoxLine('You are running the latest version');
} }
+68
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',
};
}
}
+145
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
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 };
}
+263 -167
View File
@@ -1,4 +1,4 @@
import * as snmp from 'npm:net-snmp@3.26.0'; import * as snmp from 'npm:net-snmp@3.26.1';
import { Buffer } from 'node:buffer'; import { Buffer } from 'node:buffer';
import type { IOidSet, ISnmpConfig, IUpsStatus, TUpsModel } from './types.ts'; import type { IOidSet, ISnmpConfig, IUpsStatus, TUpsModel } from './types.ts';
import { UpsOidSets } from './oid-sets.ts'; import { UpsOidSets } from './oid-sets.ts';
@@ -6,6 +6,73 @@ import { SNMP } from '../constants.ts';
import { logger } from '../logger.ts'; import { logger } from '../logger.ts';
import type { INupstAccessor } from '../interfaces/index.ts'; import type { INupstAccessor } from '../interfaces/index.ts';
type TSnmpMetricDescription =
| 'power status'
| 'battery capacity'
| 'battery runtime'
| 'output load'
| 'output power'
| 'output voltage'
| 'output current';
type TSnmpResponseValue = string | number | bigint | boolean | Buffer;
type TSnmpValue = string | number | boolean | Buffer;
interface ISnmpVarbind {
oid: string;
type: number;
value: TSnmpResponseValue;
}
interface ISnmpSessionOptions {
port: number;
retries: number;
timeout: number;
transport: 'udp4' | 'udp6';
idBitsSize: 16 | 32;
context: string;
version: number;
}
interface ISnmpV3User {
name: string;
level: number;
authProtocol?: string;
authKey?: string;
privProtocol?: string;
privKey?: string;
}
interface ISnmpSession {
get(oids: string[], callback: (error: Error | null, varbinds?: ISnmpVarbind[]) => void): void;
close(): void;
}
interface ISnmpModule {
Version1: number;
Version2c: number;
Version3: number;
SecurityLevel: {
noAuthNoPriv: number;
authNoPriv: number;
authPriv: number;
};
AuthProtocols: {
md5: string;
sha: string;
};
PrivProtocols: {
des: string;
aes: string;
};
createSession(target: string, community: string, options: ISnmpSessionOptions): ISnmpSession;
createV3Session(target: string, user: ISnmpV3User, options: ISnmpSessionOptions): ISnmpSession;
isVarbindError(varbind: ISnmpVarbind): boolean;
varbindError(varbind: ISnmpVarbind): string;
}
const snmpLib = snmp as unknown as ISnmpModule;
/** /**
* Class for SNMP communication with UPS devices * Class for SNMP communication with UPS devices
* Main entry point for SNMP functionality * Main entry point for SNMP functionality
@@ -84,6 +151,120 @@ export class NupstSnmp {
} }
} }
private createSessionOptions(config: ISnmpConfig): ISnmpSessionOptions {
return {
port: config.port,
retries: SNMP.RETRIES,
timeout: config.timeout,
transport: 'udp4',
idBitsSize: 32,
context: config.context || '',
version: config.version === 1
? snmpLib.Version1
: config.version === 2
? snmpLib.Version2c
: snmpLib.Version3,
};
}
private buildV3User(
config: ISnmpConfig,
): { user: ISnmpV3User; levelLabel: NonNullable<ISnmpConfig['securityLevel']> } {
const requestedSecurityLevel = config.securityLevel || 'noAuthNoPriv';
const user: ISnmpV3User = {
name: config.username || '',
level: snmpLib.SecurityLevel.noAuthNoPriv,
};
let levelLabel: NonNullable<ISnmpConfig['securityLevel']> = 'noAuthNoPriv';
if (requestedSecurityLevel === 'authNoPriv') {
user.level = snmpLib.SecurityLevel.authNoPriv;
levelLabel = 'authNoPriv';
if (config.authProtocol && config.authKey) {
user.authProtocol = this.resolveAuthProtocol(config.authProtocol);
user.authKey = config.authKey;
} else {
user.level = snmpLib.SecurityLevel.noAuthNoPriv;
levelLabel = 'noAuthNoPriv';
if (this.debug) {
logger.warn('Missing authProtocol or authKey, falling back to noAuthNoPriv');
}
}
} else if (requestedSecurityLevel === 'authPriv') {
user.level = snmpLib.SecurityLevel.authPriv;
levelLabel = 'authPriv';
if (config.authProtocol && config.authKey) {
user.authProtocol = this.resolveAuthProtocol(config.authProtocol);
user.authKey = config.authKey;
if (config.privProtocol && config.privKey) {
user.privProtocol = this.resolvePrivProtocol(config.privProtocol);
user.privKey = config.privKey;
} else {
user.level = snmpLib.SecurityLevel.authNoPriv;
levelLabel = 'authNoPriv';
if (this.debug) {
logger.warn('Missing privProtocol or privKey, falling back to authNoPriv');
}
}
} else {
user.level = snmpLib.SecurityLevel.noAuthNoPriv;
levelLabel = 'noAuthNoPriv';
if (this.debug) {
logger.warn('Missing authProtocol or authKey, falling back to noAuthNoPriv');
}
}
}
return { user, levelLabel };
}
private resolveAuthProtocol(protocol: NonNullable<ISnmpConfig['authProtocol']>): string {
return protocol === 'MD5' ? snmpLib.AuthProtocols.md5 : snmpLib.AuthProtocols.sha;
}
private resolvePrivProtocol(protocol: NonNullable<ISnmpConfig['privProtocol']>): string {
return protocol === 'DES' ? snmpLib.PrivProtocols.des : snmpLib.PrivProtocols.aes;
}
private normalizeSnmpValue(value: TSnmpResponseValue): TSnmpValue {
if (Buffer.isBuffer(value)) {
const isPrintableAscii = value.every((byte: number) => byte >= 32 && byte <= 126);
return isPrintableAscii ? value.toString() : value;
}
if (typeof value === 'bigint') {
return Number(value);
}
return value;
}
private coerceNumericSnmpValue(
value: TSnmpValue | 0,
description: TSnmpMetricDescription,
): number {
if (typeof value === 'number') {
return Number.isFinite(value) ? value : 0;
}
if (typeof value === 'string') {
const trimmedValue = value.trim();
const parsedValue = Number(trimmedValue);
if (trimmedValue && Number.isFinite(parsedValue)) {
return parsedValue;
}
}
if (this.debug) {
logger.warn(`Non-numeric ${description} value received from SNMP, using 0`);
}
return 0;
}
/** /**
* Send an SNMP GET request using the net-snmp package * Send an SNMP GET request using the net-snmp package
* @param oid OID to query * @param oid OID to query
@@ -95,130 +276,39 @@ export class NupstSnmp {
oid: string, oid: string,
config = this.DEFAULT_CONFIG, config = this.DEFAULT_CONFIG,
_retryCount = 0, _retryCount = 0,
// deno-lint-ignore no-explicit-any ): Promise<TSnmpValue> {
): Promise<any> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (this.debug) { if (this.debug) {
logger.dim( logger.dim(
`Sending SNMP v${config.version} GET request for OID ${oid} to ${config.host}:${config.port}`, `Sending SNMP v${config.version} GET request for OID ${oid} to ${config.host}:${config.port}`,
); );
if (config.version === 1 || config.version === 2) {
logger.dim(`Using community: ${config.community}`); 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 const options = this.createSessionOptions(config);
let session; const session: ISnmpSession = config.version === 3
? (() => {
if (config.version === 3) { const { user, levelLabel } = this.buildV3User(config);
// For SNMPv3, we need to set up authentication and privacy
// For SNMPv3, we need a valid security level
const securityLevel = config.securityLevel || 'noAuthNoPriv';
// Create the user object with required structure for net-snmp
// deno-lint-ignore no-explicit-any
const user: any = {
name: config.username || '',
};
// Set security level
if (securityLevel === 'noAuthNoPriv') {
user.level = snmp.SecurityLevel.noAuthNoPriv;
} else if (securityLevel === 'authNoPriv') {
user.level = snmp.SecurityLevel.authNoPriv;
// Set auth protocol - must provide both protocol and key
if (config.authProtocol && config.authKey) {
if (config.authProtocol === 'MD5') {
user.authProtocol = snmp.AuthProtocols.md5;
} else if (config.authProtocol === 'SHA') {
user.authProtocol = snmp.AuthProtocols.sha;
}
user.authKey = config.authKey;
} else {
// Fallback to noAuthNoPriv if auth details missing
user.level = snmp.SecurityLevel.noAuthNoPriv;
if (this.debug) {
logger.warn('Missing authProtocol or authKey, falling back to noAuthNoPriv');
}
}
} else if (securityLevel === 'authPriv') {
user.level = snmp.SecurityLevel.authPriv;
// Set auth protocol - must provide both protocol and key
if (config.authProtocol && config.authKey) {
if (config.authProtocol === 'MD5') {
user.authProtocol = snmp.AuthProtocols.md5;
} else if (config.authProtocol === 'SHA') {
user.authProtocol = snmp.AuthProtocols.sha;
}
user.authKey = config.authKey;
// Set privacy protocol - must provide both protocol and key
if (config.privProtocol && config.privKey) {
if (config.privProtocol === 'DES') {
user.privProtocol = snmp.PrivProtocols.des;
} else if (config.privProtocol === 'AES') {
user.privProtocol = snmp.PrivProtocols.aes;
}
user.privKey = config.privKey;
} else {
// Fallback to authNoPriv if priv details missing
user.level = snmp.SecurityLevel.authNoPriv;
if (this.debug) {
logger.warn('Missing privProtocol or privKey, falling back to authNoPriv');
}
}
} else {
// Fallback to noAuthNoPriv if auth details missing
user.level = snmp.SecurityLevel.noAuthNoPriv;
if (this.debug) {
logger.warn('Missing authProtocol or authKey, falling back to noAuthNoPriv');
}
}
}
if (this.debug) { if (this.debug) {
const levelName = Object.keys(snmp.SecurityLevel).find((key) =>
snmp.SecurityLevel[key] === user.level
);
logger.dim( logger.dim(
`SNMPv3 user configuration: name=${user.name}, level=${levelName}, authProtocol=${ `SNMPv3 user configuration: name=${user.name}, level=${levelLabel}, authProtocol=${
user.authProtocol ? 'Set' : 'Not Set' user.authProtocol ? 'Set' : 'Not Set'
}, privProtocol=${user.privProtocol ? 'Set' : 'Not Set'}`, }, privProtocol=${user.privProtocol ? 'Set' : 'Not Set'}`,
); );
} }
session = snmp.createV3Session(config.host, user, options); return snmpLib.createV3Session(config.host, user, options);
} else { })()
// For SNMPv1/v2c, we use the community string : snmpLib.createSession(config.host, config.community || 'public', options);
session = snmp.createSession(config.host, config.community || 'public', options);
}
// Convert the OID string to an array of OIDs if multiple OIDs are needed // Convert the OID string to an array of OIDs if multiple OIDs are needed
const oids = [oid]; const oids = [oid];
// Send the GET request // Send the GET request
// deno-lint-ignore no-explicit-any session.get(oids, (error: Error | null, varbinds?: ISnmpVarbind[]) => {
session.get(oids, (error: Error | null, varbinds: any[]) => {
// Close the session to release resources // Close the session to release resources
session.close(); session.close();
@@ -230,7 +320,9 @@ export class NupstSnmp {
return; return;
} }
if (!varbinds || varbinds.length === 0) { const varbind = varbinds?.[0];
if (!varbind) {
if (this.debug) { if (this.debug) {
logger.error('No varbinds returned in response'); logger.error('No varbinds returned in response');
} }
@@ -239,36 +331,20 @@ export class NupstSnmp {
} }
// Check for SNMP errors in the response // Check for SNMP errors in the response
if ( if (snmpLib.isVarbindError(varbind)) {
varbinds[0].type === snmp.ObjectType.NoSuchObject || const errorMessage = snmpLib.varbindError(varbind);
varbinds[0].type === snmp.ObjectType.NoSuchInstance ||
varbinds[0].type === snmp.ObjectType.EndOfMibView
) {
if (this.debug) { if (this.debug) {
logger.error(`SNMP error: ${snmp.ObjectType[varbinds[0].type]}`); logger.error(`SNMP error: ${errorMessage}`);
} }
reject(new Error(`SNMP error: ${snmp.ObjectType[varbinds[0].type]}`)); reject(new Error(`SNMP error: ${errorMessage}`));
return; return;
} }
// Process the response value based on its type const value = this.normalizeSnmpValue(varbind.value);
let value = varbinds[0].value;
// Handle specific types that might need conversion
if (Buffer.isBuffer(value)) {
// If value is a Buffer, try to convert it to a string if it's printable ASCII
const isPrintableAscii = value.every((byte: number) => byte >= 32 && byte <= 126);
if (isPrintableAscii) {
value = value.toString();
}
} else if (typeof value === 'bigint') {
// Convert BigInt to a normal number or string if needed
value = Number(value);
}
if (this.debug) { if (this.debug) {
logger.dim( logger.dim(
`SNMP response: oid=${varbinds[0].oid}, type=${varbinds[0].type}, value=${value}`, `SNMP response: oid=${varbind.oid}, type=${varbind.type}, value=${value}`,
); );
} }
@@ -315,49 +391,50 @@ export class NupstSnmp {
} }
// Get all values with independent retry logic // Get all values with independent retry logic
const powerStatusValue = await this.getSNMPValueWithRetry( const powerStatusValue = this.coerceNumericSnmpValue(
this.activeOIDs.POWER_STATUS, await this.getSNMPValueWithRetry(this.activeOIDs.POWER_STATUS, 'power status', config),
'power status', 'power status',
config,
); );
const batteryCapacity = await this.getSNMPValueWithRetry( const batteryCapacity = this.coerceNumericSnmpValue(
await this.getSNMPValueWithRetry(
this.activeOIDs.BATTERY_CAPACITY, this.activeOIDs.BATTERY_CAPACITY,
'battery capacity', 'battery capacity',
config, config,
) || 0; ),
const batteryRuntime = await this.getSNMPValueWithRetry( 'battery capacity',
);
const batteryRuntime = this.coerceNumericSnmpValue(
await this.getSNMPValueWithRetry(
this.activeOIDs.BATTERY_RUNTIME, this.activeOIDs.BATTERY_RUNTIME,
'battery runtime', 'battery runtime',
config, config,
) || 0; ),
'battery runtime',
);
// Get power draw metrics // Get power draw metrics
const outputLoad = await this.getSNMPValueWithRetry( const outputLoad = this.coerceNumericSnmpValue(
this.activeOIDs.OUTPUT_LOAD, await this.getSNMPValueWithRetry(this.activeOIDs.OUTPUT_LOAD, 'output load', config),
'output load', 'output load',
config, );
) || 0; const outputPower = this.coerceNumericSnmpValue(
const outputPower = await this.getSNMPValueWithRetry( await this.getSNMPValueWithRetry(this.activeOIDs.OUTPUT_POWER, 'output power', config),
this.activeOIDs.OUTPUT_POWER,
'output power', 'output power',
config, );
) || 0; const outputVoltage = this.coerceNumericSnmpValue(
const outputVoltage = await this.getSNMPValueWithRetry( await this.getSNMPValueWithRetry(this.activeOIDs.OUTPUT_VOLTAGE, 'output voltage', config),
this.activeOIDs.OUTPUT_VOLTAGE,
'output voltage', 'output voltage',
config, );
) || 0; const outputCurrent = this.coerceNumericSnmpValue(
const outputCurrent = await this.getSNMPValueWithRetry( await this.getSNMPValueWithRetry(this.activeOIDs.OUTPUT_CURRENT, 'output current', config),
this.activeOIDs.OUTPUT_CURRENT,
'output current', 'output current',
config, );
) || 0;
// Determine power status - handle different values for different UPS models // Determine power status - handle different values for different UPS models
const powerStatus = this.determinePowerStatus(config.upsModel, powerStatusValue); const powerStatus = this.determinePowerStatus(config.upsModel, powerStatusValue);
// Convert to minutes for UPS models with different time units // Convert to minutes for UPS models with different time units
const processedRuntime = this.processRuntimeValue(config.upsModel, batteryRuntime); const processedRuntime = this.processRuntimeValue(config, batteryRuntime);
// Process power metrics with vendor-specific scaling // Process power metrics with vendor-specific scaling
const processedVoltage = this.processVoltageValue(config.upsModel, outputVoltage); const processedVoltage = this.processVoltageValue(config.upsModel, outputVoltage);
@@ -430,10 +507,9 @@ export class NupstSnmp {
*/ */
private async getSNMPValueWithRetry( private async getSNMPValueWithRetry(
oid: string, oid: string,
description: string, description: TSnmpMetricDescription,
config: ISnmpConfig, config: ISnmpConfig,
// deno-lint-ignore no-explicit-any ): Promise<TSnmpValue | 0> {
): Promise<any> {
if (oid === '') { if (oid === '') {
if (this.debug) { if (this.debug) {
logger.dim(`No OID provided for ${description}, skipping`); logger.dim(`No OID provided for ${description}, skipping`);
@@ -485,10 +561,9 @@ export class NupstSnmp {
*/ */
private async tryFallbackSecurityLevels( private async tryFallbackSecurityLevels(
oid: string, oid: string,
description: string, description: TSnmpMetricDescription,
config: ISnmpConfig, config: ISnmpConfig,
// deno-lint-ignore no-explicit-any ): Promise<TSnmpValue | 0> {
): Promise<any> {
if (this.debug) { if (this.debug) {
logger.dim(`Retrying ${description} with fallback security level...`); logger.dim(`Retrying ${description} with fallback security level...`);
} }
@@ -551,10 +626,9 @@ export class NupstSnmp {
*/ */
private async tryStandardOids( private async tryStandardOids(
_oid: string, _oid: string,
description: string, description: TSnmpMetricDescription,
config: ISnmpConfig, config: ISnmpConfig,
// deno-lint-ignore no-explicit-any ): Promise<TSnmpValue | 0> {
): Promise<any> {
try { try {
// Try RFC 1628 standard UPS MIB OIDs // Try RFC 1628 standard UPS MIB OIDs
const standardOIDs = UpsOidSets.getStandardOids(); const standardOIDs = UpsOidSets.getStandardOids();
@@ -620,22 +694,46 @@ export class NupstSnmp {
} }
/** /**
* Process runtime value based on UPS model * Process runtime value based on config runtimeUnit or UPS model
* @param upsModel UPS model * @param config SNMP configuration (uses runtimeUnit if set, otherwise falls back to upsModel)
* @param batteryRuntime Raw battery runtime value * @param batteryRuntime Raw battery runtime value
* @returns Processed runtime in minutes * @returns Processed runtime in minutes
*/ */
private processRuntimeValue( private processRuntimeValue(
upsModel: TUpsModel | undefined, config: ISnmpConfig,
batteryRuntime: number, batteryRuntime: number,
): number { ): number {
if (this.debug) { if (this.debug) {
logger.dim(`Raw runtime value: ${batteryRuntime}`); logger.dim(`Raw runtime value: ${batteryRuntime}`);
} }
// Explicit runtimeUnit takes precedence over model-based detection
if (config.runtimeUnit) {
if (config.runtimeUnit === 'seconds' && batteryRuntime > 0) {
const minutes = Math.floor(batteryRuntime / 60);
if (this.debug) {
logger.dim(
`Converting runtime from ${batteryRuntime} seconds to ${minutes} minutes (runtimeUnit: seconds)`,
);
}
return minutes;
} else if (config.runtimeUnit === 'ticks' && batteryRuntime > 0) {
const minutes = Math.floor(batteryRuntime / 6000);
if (this.debug) {
logger.dim(
`Converting runtime from ${batteryRuntime} ticks to ${minutes} minutes (runtimeUnit: ticks)`,
);
}
return minutes;
}
// runtimeUnit === 'minutes' — return as-is
return batteryRuntime;
}
// Fallback: model-based detection (for configs without runtimeUnit)
const upsModel = config.upsModel;
if (upsModel === 'cyberpower' && batteryRuntime > 0) { if (upsModel === 'cyberpower' && batteryRuntime > 0) {
// CyberPower: TimeTicks is in 1/100 seconds, convert to minutes const minutes = Math.floor(batteryRuntime / 6000);
const minutes = Math.floor(batteryRuntime / 6000); // 6000 ticks = 1 minute
if (this.debug) { if (this.debug) {
logger.dim( logger.dim(
`Converting CyberPower runtime from ${batteryRuntime} ticks to ${minutes} minutes`, `Converting CyberPower runtime from ${batteryRuntime} ticks to ${minutes} minutes`,
@@ -643,7 +741,6 @@ export class NupstSnmp {
} }
return minutes; return minutes;
} else if (upsModel === 'eaton' && batteryRuntime > 0) { } else if (upsModel === 'eaton' && batteryRuntime > 0) {
// Eaton: Runtime is in seconds, convert to minutes
const minutes = Math.floor(batteryRuntime / 60); const minutes = Math.floor(batteryRuntime / 60);
if (this.debug) { if (this.debug) {
logger.dim( logger.dim(
@@ -652,10 +749,9 @@ export class NupstSnmp {
} }
return minutes; return minutes;
} else if (batteryRuntime > 10000) { } else if (batteryRuntime > 10000) {
// Generic conversion for large tick values (likely TimeTicks)
const minutes = Math.floor(batteryRuntime / 6000); const minutes = Math.floor(batteryRuntime / 6000);
if (this.debug) { if (this.debug) {
logger.dim(`Converting ${batteryRuntime} ticks to ${minutes} minutes`); logger.dim(`Converting ${batteryRuntime} ticks to ${minutes} minutes (heuristic)`);
} }
return minutes; return minutes;
} }
+7
View File
@@ -58,6 +58,11 @@ export interface IOidSet {
*/ */
export type TUpsModel = 'cyberpower' | 'apc' | 'eaton' | 'tripplite' | 'liebert' | 'custom'; export type TUpsModel = 'cyberpower' | 'apc' | 'eaton' | 'tripplite' | 'liebert' | 'custom';
/**
* Runtime unit for battery runtime SNMP values
*/
export type TRuntimeUnit = 'minutes' | 'seconds' | 'ticks';
/** /**
* SNMP Configuration interface * SNMP Configuration interface
*/ */
@@ -96,6 +101,8 @@ export interface ISnmpConfig {
upsModel?: TUpsModel; upsModel?: TUpsModel;
/** Custom OIDs when using custom UPS model */ /** Custom OIDs when using custom UPS model */
customOIDs?: IOidSet; customOIDs?: IOidSet;
/** Unit of the battery runtime SNMP value. Overrides model-based auto-detection when set. */
runtimeUnit?: TRuntimeUnit;
} }
/** /**
+17 -10
View File
@@ -5,6 +5,7 @@ import { type IUpsConfig, NupstDaemon } from './daemon.ts';
import { NupstSnmp } from './snmp/manager.ts'; import { NupstSnmp } from './snmp/manager.ts';
import { logger } from './logger.ts'; import { logger } from './logger.ts';
import { formatPowerStatus, getBatteryColor, getRuntimeColor, symbols, theme } from './colors.ts'; import { formatPowerStatus, getBatteryColor, getRuntimeColor, symbols, theme } from './colors.ts';
import { SHUTDOWN } from './constants.ts';
/** /**
* Class for managing systemd service * Class for managing systemd service
@@ -164,7 +165,7 @@ WantedBy=multi-user.target
}`, }`,
); );
logger.log( logger.log(
` ${theme.dim('Run')} ${theme.command('sudo nupst update')} ${theme.dim('to upgrade')}`, ` ${theme.dim('Run')} ${theme.command('sudo nupst upgrade')} ${theme.dim('to upgrade')}`,
); );
} else { } else {
logger.log(''); logger.log('');
@@ -316,7 +317,6 @@ WantedBy=multi-user.target
type: 'shutdown', type: 'shutdown',
thresholds: config.thresholds, thresholds: config.thresholds,
triggerMode: 'onlyThresholds', triggerMode: 'onlyThresholds',
shutdownDelay: 5,
}, },
] ]
: [], : [],
@@ -346,6 +346,8 @@ WantedBy=multi-user.target
*/ */
private async displaySingleUpsStatus(ups: IUpsConfig, snmp: NupstSnmp): Promise<void> { private async displaySingleUpsStatus(ups: IUpsConfig, snmp: NupstSnmp): Promise<void> {
try { try {
const defaultShutdownDelay =
this.daemon.getConfig().defaultShutdownDelay ?? SHUTDOWN.DEFAULT_DELAY_MINUTES;
const protocol = ups.protocol || 'snmp'; const protocol = ups.protocol || 'snmp';
let status; let status;
@@ -432,14 +434,16 @@ WantedBy=multi-user.target
actionDesc += ` (${ actionDesc += ` (${
action.triggerMode || 'onlyThresholds' action.triggerMode || 'onlyThresholds'
}: battery<${action.thresholds.battery}%, runtime<${action.thresholds.runtime}min`; }: battery<${action.thresholds.battery}%, runtime<${action.thresholds.runtime}min`;
if (action.shutdownDelay) { if (action.type === 'shutdown') {
actionDesc += `, delay=${action.shutdownDelay}s`; const shutdownDelay = action.shutdownDelay ?? defaultShutdownDelay;
actionDesc += `, delay=${shutdownDelay}min`;
} }
actionDesc += ')'; actionDesc += ')';
} else { } else {
actionDesc += ` (${action.triggerMode || 'onlyPowerChanges'}`; actionDesc += ` (${action.triggerMode || 'onlyPowerChanges'}`;
if (action.shutdownDelay) { if (action.type === 'shutdown') {
actionDesc += `, delay=${action.shutdownDelay}s`; const shutdownDelay = action.shutdownDelay ?? defaultShutdownDelay;
actionDesc += `, delay=${shutdownDelay}min`;
} }
actionDesc += ')'; actionDesc += ')';
} }
@@ -506,20 +510,23 @@ WantedBy=multi-user.target
// Display actions if any // Display actions if any
if (group.actions && group.actions.length > 0) { if (group.actions && group.actions.length > 0) {
const defaultShutdownDelay = config.defaultShutdownDelay ?? SHUTDOWN.DEFAULT_DELAY_MINUTES;
for (const action of group.actions) { for (const action of group.actions) {
let actionDesc = `${action.type}`; let actionDesc = `${action.type}`;
if (action.thresholds) { if (action.thresholds) {
actionDesc += ` (${ actionDesc += ` (${
action.triggerMode || 'onlyThresholds' action.triggerMode || 'onlyThresholds'
}: battery<${action.thresholds.battery}%, runtime<${action.thresholds.runtime}min`; }: battery<${action.thresholds.battery}%, runtime<${action.thresholds.runtime}min`;
if (action.shutdownDelay) { if (action.type === 'shutdown') {
actionDesc += `, delay=${action.shutdownDelay}s`; const shutdownDelay = action.shutdownDelay ?? defaultShutdownDelay;
actionDesc += `, delay=${shutdownDelay}min`;
} }
actionDesc += ')'; actionDesc += ')';
} else { } else {
actionDesc += ` (${action.triggerMode || 'onlyPowerChanges'}`; actionDesc += ` (${action.triggerMode || 'onlyPowerChanges'}`;
if (action.shutdownDelay) { if (action.type === 'shutdown') {
actionDesc += `, delay=${action.shutdownDelay}s`; const shutdownDelay = action.shutdownDelay ?? defaultShutdownDelay;
actionDesc += `, delay=${shutdownDelay}min`;
} }
actionDesc += ')'; actionDesc += ')';
} }
+138
View File
@@ -0,0 +1,138 @@
import type { IActionConfig } from './actions/base-action.ts';
import { NETWORK } from './constants.ts';
import type { IUpsStatus as IProtocolUpsStatus } from './snmp/types.ts';
import { createInitialUpsStatus, type IUpsIdentity, type IUpsStatus } from './ups-status.ts';
export interface ISuccessfulUpsPollSnapshot {
updatedStatus: IUpsStatus;
transition: 'none' | 'recovered' | 'powerStatusChange';
previousStatus?: IUpsStatus;
downtimeSeconds?: number;
}
export interface IFailedUpsPollSnapshot {
updatedStatus: IUpsStatus;
transition: 'none' | 'unreachable';
failures: number;
previousStatus?: IUpsStatus;
}
export function ensureUpsStatus(
currentStatus: IUpsStatus | undefined,
ups: IUpsIdentity,
now: number = Date.now(),
): IUpsStatus {
return currentStatus || createInitialUpsStatus(ups, now);
}
export function buildSuccessfulUpsPollSnapshot(
ups: IUpsIdentity,
polledStatus: IProtocolUpsStatus,
currentStatus: IUpsStatus | undefined,
currentTime: number,
): ISuccessfulUpsPollSnapshot {
const previousStatus = ensureUpsStatus(currentStatus, ups, currentTime);
const updatedStatus: IUpsStatus = {
id: ups.id,
name: ups.name,
powerStatus: polledStatus.powerStatus,
batteryCapacity: polledStatus.batteryCapacity,
batteryRuntime: polledStatus.batteryRuntime,
outputLoad: polledStatus.outputLoad,
outputPower: polledStatus.outputPower,
outputVoltage: polledStatus.outputVoltage,
outputCurrent: polledStatus.outputCurrent,
lastCheckTime: currentTime,
lastStatusChange: previousStatus.lastStatusChange || currentTime,
consecutiveFailures: 0,
unreachableSince: 0,
};
if (previousStatus.powerStatus === 'unreachable') {
updatedStatus.lastStatusChange = currentTime;
return {
updatedStatus,
transition: 'recovered',
previousStatus,
downtimeSeconds: Math.round((currentTime - previousStatus.unreachableSince) / 1000),
};
}
if (previousStatus.powerStatus !== polledStatus.powerStatus) {
updatedStatus.lastStatusChange = currentTime;
return {
updatedStatus,
transition: 'powerStatusChange',
previousStatus,
};
}
return {
updatedStatus,
transition: 'none',
previousStatus: currentStatus,
};
}
export function buildFailedUpsPollSnapshot(
ups: IUpsIdentity,
currentStatus: IUpsStatus | undefined,
currentTime: number,
): IFailedUpsPollSnapshot {
const previousStatus = ensureUpsStatus(currentStatus, ups, currentTime);
const failures = Math.min(
previousStatus.consecutiveFailures + 1,
NETWORK.MAX_CONSECUTIVE_FAILURES,
);
if (
failures >= NETWORK.CONSECUTIVE_FAILURE_THRESHOLD &&
previousStatus.powerStatus !== 'unreachable'
) {
return {
updatedStatus: {
...previousStatus,
consecutiveFailures: failures,
powerStatus: 'unreachable',
unreachableSince: currentTime,
lastStatusChange: currentTime,
},
transition: 'unreachable',
failures,
previousStatus,
};
}
return {
updatedStatus: {
...previousStatus,
consecutiveFailures: failures,
},
transition: 'none',
failures,
previousStatus: currentStatus,
};
}
export function hasThresholdViolation(
powerStatus: IProtocolUpsStatus['powerStatus'],
batteryCapacity: number,
batteryRuntime: number,
actions: IActionConfig[] | undefined,
): boolean {
if (powerStatus !== 'onBattery' || !actions || actions.length === 0) {
return false;
}
for (const actionConfig of actions) {
if (
actionConfig.thresholds &&
(batteryCapacity < actionConfig.thresholds.battery ||
batteryRuntime < actionConfig.thresholds.runtime)
) {
return true;
}
}
return false;
}
+38
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,
};
}