Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2adf1d5548 | |||
| 067a7666e4 | |||
| 0d863a1028 | |||
| c410a663b1 | |||
| 6aa1fc651f | |||
| 11e549e68e | |||
| 0fb9678976 | |||
| 635de0d932 | |||
| 0916effb53 | |||
| 05242a1c7d |
@@ -1,84 +0,0 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- 'migration/**'
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: Type Check & Lint
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Deno
|
||||
uses: denoland/setup-deno@v1
|
||||
with:
|
||||
deno-version: v2.x
|
||||
|
||||
- name: Check TypeScript types
|
||||
run: deno check mod.ts
|
||||
|
||||
- name: Lint code
|
||||
run: deno lint
|
||||
continue-on-error: true
|
||||
|
||||
- name: Format check
|
||||
run: deno fmt --check
|
||||
continue-on-error: true
|
||||
|
||||
build:
|
||||
name: Build Test (Current Platform)
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Deno
|
||||
uses: denoland/setup-deno@v1
|
||||
with:
|
||||
deno-version: v2.x
|
||||
|
||||
- name: Compile for current platform
|
||||
run: |
|
||||
echo "Testing compilation for Linux x86_64..."
|
||||
deno compile --allow-all --no-check \
|
||||
--output nupst-test \
|
||||
--target x86_64-unknown-linux-gnu mod.ts
|
||||
|
||||
- name: Test binary execution
|
||||
run: |
|
||||
chmod +x nupst-test
|
||||
./nupst-test --version
|
||||
./nupst-test help
|
||||
|
||||
build-all:
|
||||
name: Build All Platforms
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Deno
|
||||
uses: denoland/setup-deno@v1
|
||||
with:
|
||||
deno-version: v2.x
|
||||
|
||||
- name: Compile all platform binaries
|
||||
run: bash scripts/compile-all.sh
|
||||
|
||||
- name: Upload all binaries as artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: nupst-binaries.zip
|
||||
path: dist/binaries/*
|
||||
retention-days: 30
|
||||
@@ -1,129 +0,0 @@
|
||||
name: Publish to npm
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
npm-publish:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Deno
|
||||
uses: denoland/setup-deno@v1
|
||||
with:
|
||||
deno-version: v2.x
|
||||
|
||||
- name: Setup Node.js for npm publishing
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18.x'
|
||||
registry-url: 'https://registry.npmjs.org/'
|
||||
|
||||
- name: Get version from tag
|
||||
id: version
|
||||
run: |
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "version_number=${VERSION#v}" >> $GITHUB_OUTPUT
|
||||
echo "Publishing version: $VERSION"
|
||||
|
||||
- name: Verify deno.json version matches tag
|
||||
run: |
|
||||
DENO_VERSION=$(grep -o '"version": "[^"]*"' deno.json | cut -d'"' -f4)
|
||||
TAG_VERSION="${{ steps.version.outputs.version_number }}"
|
||||
echo "deno.json version: $DENO_VERSION"
|
||||
echo "Tag version: $TAG_VERSION"
|
||||
if [ "$DENO_VERSION" != "$TAG_VERSION" ]; then
|
||||
echo "ERROR: Version mismatch!"
|
||||
echo "deno.json has version $DENO_VERSION but tag is $TAG_VERSION"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Compile binaries for npm package
|
||||
run: |
|
||||
echo "Compiling binaries for npm package..."
|
||||
deno task compile
|
||||
echo ""
|
||||
echo "Binary sizes:"
|
||||
ls -lh dist/binaries/
|
||||
|
||||
- name: Generate SHA256 checksums
|
||||
run: |
|
||||
cd dist/binaries
|
||||
sha256sum * > SHA256SUMS
|
||||
cat SHA256SUMS
|
||||
cd ../..
|
||||
|
||||
- name: Sync package.json version
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version_number }}"
|
||||
echo "Syncing package.json to version ${VERSION}..."
|
||||
npm version ${VERSION} --no-git-tag-version --allow-same-version
|
||||
echo "package.json version: $(grep '"version"' package.json | head -1)"
|
||||
|
||||
- name: Create npm package
|
||||
run: |
|
||||
echo "Creating npm package..."
|
||||
npm pack
|
||||
echo ""
|
||||
echo "Package created:"
|
||||
ls -lh *.tgz
|
||||
|
||||
- name: Test local installation
|
||||
run: |
|
||||
echo "Testing local package installation..."
|
||||
PACKAGE_FILE=$(ls *.tgz)
|
||||
npm install -g ${PACKAGE_FILE}
|
||||
echo ""
|
||||
echo "Testing nupst command:"
|
||||
nupst --version || echo "Note: Binary execution may fail in CI environment"
|
||||
echo ""
|
||||
echo "Checking installed files:"
|
||||
npm ls -g @serve.zone/nupst || true
|
||||
|
||||
- name: Publish to npm
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: |
|
||||
echo "Publishing to npm registry..."
|
||||
npm publish --access public
|
||||
echo ""
|
||||
echo "✅ Successfully published @serve.zone/nupst to npm!"
|
||||
echo ""
|
||||
echo "Package info:"
|
||||
npm view @serve.zone/nupst
|
||||
|
||||
- name: Verify npm package
|
||||
run: |
|
||||
echo "Waiting for npm propagation..."
|
||||
sleep 30
|
||||
echo ""
|
||||
echo "Verifying published package..."
|
||||
npm view @serve.zone/nupst
|
||||
echo ""
|
||||
echo "Testing installation from npm:"
|
||||
npm install -g @serve.zone/nupst
|
||||
echo ""
|
||||
echo "Package installed successfully!"
|
||||
which nupst || echo "Binary location check skipped"
|
||||
|
||||
- name: Publish Summary
|
||||
run: |
|
||||
echo "================================================"
|
||||
echo " npm Publish Complete!"
|
||||
echo "================================================"
|
||||
echo ""
|
||||
echo "✅ Package: @serve.zone/nupst"
|
||||
echo "✅ Version: ${{ steps.version.outputs.version }}"
|
||||
echo ""
|
||||
echo "Installation:"
|
||||
echo " npm install -g @serve.zone/nupst"
|
||||
echo ""
|
||||
echo "Registry:"
|
||||
echo " https://www.npmjs.com/package/@serve.zone/nupst"
|
||||
echo ""
|
||||
@@ -8,6 +8,8 @@ on:
|
||||
jobs:
|
||||
build-and-release:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: code.foss.global/host.today/ht-docker-node:latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -20,6 +22,17 @@ jobs:
|
||||
with:
|
||||
deno-version: v2.x
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Enable corepack
|
||||
run: corepack enable
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --ignore-scripts
|
||||
|
||||
- name: Get version from tag
|
||||
id: version
|
||||
run: |
|
||||
@@ -41,57 +54,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Compile binaries for all platforms
|
||||
run: |
|
||||
echo "================================================"
|
||||
echo " NUPST Release Compilation"
|
||||
echo " Version: ${{ steps.version.outputs.version }}"
|
||||
echo "================================================"
|
||||
echo ""
|
||||
|
||||
# Clean up old binaries and create fresh directory
|
||||
rm -rf dist/binaries
|
||||
mkdir -p dist/binaries
|
||||
echo "→ Cleaned old binaries from dist/binaries"
|
||||
echo ""
|
||||
|
||||
# Linux x86_64
|
||||
echo "→ Compiling for Linux x86_64..."
|
||||
deno compile --allow-all --no-check \
|
||||
--output dist/binaries/nupst-linux-x64 \
|
||||
--target x86_64-unknown-linux-gnu mod.ts
|
||||
echo " ✓ Linux x86_64 complete"
|
||||
|
||||
# Linux ARM64
|
||||
echo "→ Compiling for Linux ARM64..."
|
||||
deno compile --allow-all --no-check \
|
||||
--output dist/binaries/nupst-linux-arm64 \
|
||||
--target aarch64-unknown-linux-gnu mod.ts
|
||||
echo " ✓ Linux ARM64 complete"
|
||||
|
||||
# macOS x86_64
|
||||
echo "→ Compiling for macOS x86_64..."
|
||||
deno compile --allow-all --no-check \
|
||||
--output dist/binaries/nupst-macos-x64 \
|
||||
--target x86_64-apple-darwin mod.ts
|
||||
echo " ✓ macOS x86_64 complete"
|
||||
|
||||
# macOS ARM64
|
||||
echo "→ Compiling for macOS ARM64..."
|
||||
deno compile --allow-all --no-check \
|
||||
--output dist/binaries/nupst-macos-arm64 \
|
||||
--target aarch64-apple-darwin mod.ts
|
||||
echo " ✓ macOS ARM64 complete"
|
||||
|
||||
# Windows x86_64
|
||||
echo "→ Compiling for Windows x86_64..."
|
||||
deno compile --allow-all --no-check \
|
||||
--output dist/binaries/nupst-windows-x64.exe \
|
||||
--target x86_64-pc-windows-msvc mod.ts
|
||||
echo " ✓ Windows x86_64 complete"
|
||||
|
||||
echo ""
|
||||
echo "All binaries compiled successfully!"
|
||||
ls -lh dist/binaries/
|
||||
run: mkdir -p dist/binaries && npx tsdeno compile
|
||||
|
||||
- name: Generate SHA256 checksums
|
||||
run: |
|
||||
@@ -105,7 +68,6 @@ jobs:
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
|
||||
# Check if CHANGELOG.md exists
|
||||
if [ ! -f CHANGELOG.md ]; then
|
||||
echo "No CHANGELOG.md found, using default release notes"
|
||||
cat > /tmp/release_notes.md << EOF
|
||||
@@ -133,8 +95,6 @@ jobs:
|
||||
SHA256 checksums are provided in SHA256SUMS.txt
|
||||
EOF
|
||||
else
|
||||
# Try to extract section for this version from CHANGELOG.md
|
||||
# This is a simple extraction - adjust based on your CHANGELOG format
|
||||
awk "/## \[$VERSION\]/,/## \[/" CHANGELOG.md | sed '$d' > /tmp/release_notes.md || cat > /tmp/release_notes.md << EOF
|
||||
## NUPST $VERSION
|
||||
|
||||
@@ -158,7 +118,6 @@ jobs:
|
||||
|
||||
echo "Checking for existing release $VERSION..."
|
||||
|
||||
# Try to get existing release by tag
|
||||
EXISTING_RELEASE_ID=$(curl -s \
|
||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
"https://code.foss.global/api/v1/repos/serve.zone/nupst/releases/tags/$VERSION" \
|
||||
@@ -178,9 +137,7 @@ jobs:
|
||||
- name: Create Gitea Release
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
RELEASE_NOTES=$(cat /tmp/release_notes.md)
|
||||
|
||||
# Create the release
|
||||
echo "Creating release for $VERSION..."
|
||||
RELEASE_ID=$(curl -X POST -s \
|
||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
@@ -196,7 +153,6 @@ jobs:
|
||||
|
||||
echo "Release created with ID: $RELEASE_ID"
|
||||
|
||||
# Upload binaries as release assets
|
||||
for binary in dist/binaries/*; do
|
||||
filename=$(basename "$binary")
|
||||
echo "Uploading $filename..."
|
||||
@@ -213,12 +169,10 @@ jobs:
|
||||
run: |
|
||||
echo "Cleaning up old releases (keeping only last 3)..."
|
||||
|
||||
# Fetch all releases sorted by creation date
|
||||
RELEASES=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
"https://code.foss.global/api/v1/repos/serve.zone/nupst/releases" | \
|
||||
jq -r 'sort_by(.created_at) | reverse | .[3:] | .[].id')
|
||||
|
||||
# Delete old releases
|
||||
if [ -n "$RELEASES" ]; then
|
||||
echo "Found releases to delete:"
|
||||
for release_id in $RELEASES; do
|
||||
|
||||
64
.smartconfig.json
Normal file
64
.smartconfig.json
Normal 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": {}
|
||||
}
|
||||
37
changelog.md
37
changelog.md
@@ -1,5 +1,42 @@
|
||||
# Changelog
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
{
|
||||
"name": "@serve.zone/nupst",
|
||||
"version": "5.3.1",
|
||||
"version": "5.5.0",
|
||||
"exports": "./mod.ts",
|
||||
"nodeModulesDir": "auto",
|
||||
"tasks": {
|
||||
"dev": "deno run --allow-all mod.ts",
|
||||
"compile": "deno task compile:all",
|
||||
"compile:all": "bash scripts/compile-all.sh",
|
||||
"compile": "tsdeno compile",
|
||||
"test": "deno test --allow-all test/",
|
||||
"test:watch": "deno test --allow-all --watch test/",
|
||||
"check": "deno check mod.ts",
|
||||
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@serve.zone/nupst",
|
||||
"version": "5.3.1",
|
||||
"version": "5.5.0",
|
||||
"description": "Network UPS Shutdown Tool - Monitor SNMP-enabled UPS devices and orchestrate graceful system shutdowns during power emergencies",
|
||||
"keywords": [
|
||||
"ups",
|
||||
@@ -62,5 +62,8 @@
|
||||
"access": "public",
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
},
|
||||
"packageManager": "pnpm@10.18.1+sha512.77a884a165cbba2d8d1c19e3b4880eee6d2fcabd0d879121e282196b80042351d5eb3ca0935fa599da1dc51265cc68816ad2bddd2a2de5ea9fdf92adbec7cd34"
|
||||
"packageManager": "pnpm@10.18.1+sha512.77a884a165cbba2d8d1c19e3b4880eee6d2fcabd0d879121e282196b80042351d5eb3ca0935fa599da1dc51265cc68816ad2bddd2a2de5ea9fdf92adbec7cd34",
|
||||
"devDependencies": {
|
||||
"@git.zone/tsdeno": "^1.3.1"
|
||||
}
|
||||
}
|
||||
|
||||
2324
pnpm-lock.yaml
generated
Normal file
2324
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
74
readme.md
74
readme.md
@@ -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
|
||||
- **📡 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
|
||||
- **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
|
||||
@@ -223,7 +223,7 @@ NUPST stores configuration at `/etc/nupst/config.json`. The easiest way to confi
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "4.2",
|
||||
"version": "4.3",
|
||||
"checkInterval": 30000,
|
||||
"httpServer": {
|
||||
"enabled": true,
|
||||
@@ -242,17 +242,17 @@ NUPST stores configuration at `/etc/nupst/config.json`. The easiest way to confi
|
||||
"community": "public",
|
||||
"version": 1,
|
||||
"timeout": 5000,
|
||||
"upsModel": "cyberpower"
|
||||
"upsModel": "cyberpower",
|
||||
"runtimeUnit": "ticks"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"type": "proxmox",
|
||||
"triggerMode": "onlyThresholds",
|
||||
"thresholds": { "battery": 30, "runtime": 15 },
|
||||
"proxmoxHost": "localhost",
|
||||
"proxmoxPort": 8006,
|
||||
"proxmoxTokenId": "root@pam!nupst",
|
||||
"proxmoxTokenSecret": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
||||
"proxmoxMode": "auto",
|
||||
"proxmoxExcludeIds": [],
|
||||
"proxmoxForceStop": true
|
||||
},
|
||||
{
|
||||
"type": "shutdown",
|
||||
@@ -323,6 +323,7 @@ Each UPS device has a `protocol` field:
|
||||
| `version` | SNMP version | `1`, `2`, or `3` |
|
||||
| `timeout` | Timeout in milliseconds | Default: `5000` |
|
||||
| `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"` |
|
||||
|
||||
**SNMPv3 fields** (when `version: 3`):
|
||||
@@ -362,7 +363,7 @@ Actions define automated responses to UPS conditions. They run **sequentially in
|
||||
| `shutdown` | Graceful system shutdown with configurable delay |
|
||||
| `webhook` | HTTP POST/GET notification to external services |
|
||||
| `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
|
||||
|
||||
@@ -394,7 +395,7 @@ Actions define automated responses to UPS conditions. They run **sequentially in
|
||||
|
||||
| Field | Description | Default |
|
||||
| --------------- | ---------------------------------- | ------- |
|
||||
| `shutdownDelay` | Seconds to wait before shutdown | `5` |
|
||||
| `shutdownDelay` | Minutes to wait before shutdown | `5` |
|
||||
|
||||
#### Webhook Action
|
||||
|
||||
@@ -436,11 +437,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.
|
||||
|
||||
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
|
||||
{
|
||||
"type": "proxmox",
|
||||
"thresholds": { "battery": 30, "runtime": 15 },
|
||||
"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",
|
||||
"proxmoxPort": 8006,
|
||||
"proxmoxTokenId": "root@pam!nupst",
|
||||
@@ -454,17 +482,18 @@ Gracefully shuts down QEMU VMs and LXC containers on a Proxmox node before the h
|
||||
|
||||
| Field | Description | Default |
|
||||
| --------------------- | ----------------------------------------------- | ------------- |
|
||||
| `proxmoxHost` | Proxmox API host | `localhost` |
|
||||
| `proxmoxPort` | Proxmox API port | `8006` |
|
||||
| `proxmoxMode` | Operation mode | `auto` |
|
||||
| `proxmoxHost` | Proxmox API host (API mode only) | `localhost` |
|
||||
| `proxmoxPort` | Proxmox API port (API mode only) | `8006` |
|
||||
| `proxmoxNode` | Proxmox node name | Auto-detect via hostname |
|
||||
| `proxmoxTokenId` | API token ID (e.g. `root@pam!nupst`) | Required |
|
||||
| `proxmoxTokenSecret` | API token secret (UUID) | Required |
|
||||
| `proxmoxTokenId` | API token ID (API mode only) | — |
|
||||
| `proxmoxTokenSecret` | API token secret (API mode only) | — |
|
||||
| `proxmoxExcludeIds` | VM/CT IDs to skip | `[]` |
|
||||
| `proxmoxStopTimeout` | Seconds to wait for graceful shutdown | `120` |
|
||||
| `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
|
||||
# Create token with full privileges (no privilege separation)
|
||||
@@ -629,7 +658,7 @@ Full SNMPv3 support with authentication and encryption:
|
||||
|
||||
### 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
|
||||
- No external internet connections
|
||||
|
||||
@@ -659,6 +688,7 @@ sha256sum -c SHA256SUMS.txt --ignore-missing
|
||||
```json
|
||||
{
|
||||
"upsModel": "custom",
|
||||
"runtimeUnit": "seconds",
|
||||
"customOIDs": {
|
||||
"POWER_STATUS": "1.3.6.1.4.1.1234.1.1.0",
|
||||
"BATTERY_CAPACITY": "1.3.6.1.4.1.1234.1.2.0",
|
||||
@@ -667,6 +697,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
|
||||
|
||||
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).
|
||||
@@ -736,7 +768,13 @@ upsc ups@localhost # if NUT CLI is installed
|
||||
### Proxmox VMs Not Shutting Down
|
||||
|
||||
```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" \
|
||||
https://localhost:8006/api2/json/nodes/$(hostname)/qemu
|
||||
|
||||
@@ -848,7 +886,7 @@ nupst/
|
||||
|
||||
## 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.
|
||||
|
||||
|
||||
@@ -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,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/nupst',
|
||||
version: '5.3.1',
|
||||
version: '5.5.0',
|
||||
description: 'Network UPS Shutdown Tool - Monitor SNMP-enabled UPS devices and orchestrate graceful system shutdowns during power emergencies'
|
||||
}
|
||||
|
||||
@@ -116,6 +116,8 @@ export interface IActionConfig {
|
||||
proxmoxForceStop?: boolean;
|
||||
/** Skip TLS verification for self-signed certificates (default: true) */
|
||||
proxmoxInsecure?: boolean;
|
||||
/** Proxmox operation mode: 'auto' detects CLI tools, 'cli' forces CLI, 'api' forces REST API (default: 'auto') */
|
||||
proxmoxMode?: 'auto' | 'api' | 'cli';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import process from 'node:process';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { Action, type IActionContext } from './base-action.ts';
|
||||
import { logger } from '../logger.ts';
|
||||
import { PROXMOX, UI } from '../constants.ts';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/**
|
||||
* ProxmoxAction - Gracefully shuts down Proxmox VMs and LXC containers
|
||||
*
|
||||
* Uses the Proxmox REST API via HTTPS with API token authentication.
|
||||
* Shuts down running QEMU VMs and LXC containers, waits for completion,
|
||||
* and optionally force-stops any that don't respond.
|
||||
* Supports two operation modes:
|
||||
* - CLI mode: Uses qm/pct commands directly (requires running as root on a Proxmox host)
|
||||
* - API mode: Uses the Proxmox REST API via HTTPS with API token authentication
|
||||
*
|
||||
* In 'auto' mode (default), CLI is preferred when available, falling back to API.
|
||||
*
|
||||
* This action should be placed BEFORE shutdown actions in the action chain
|
||||
* so that VMs are stopped before the host is shut down.
|
||||
@@ -16,6 +24,77 @@ import { PROXMOX, UI } from '../constants.ts';
|
||||
export class ProxmoxAction extends Action {
|
||||
readonly type = 'proxmox';
|
||||
|
||||
/**
|
||||
* Check if Proxmox CLI tools (qm, pct) are available on the system
|
||||
* Used by CLI wizards and by execute() for auto-detection
|
||||
*/
|
||||
static detectCliAvailability(): {
|
||||
available: boolean;
|
||||
qmPath: string | null;
|
||||
pctPath: string | null;
|
||||
isRoot: boolean;
|
||||
} {
|
||||
let qmPath: string | null = null;
|
||||
let pctPath: string | null = null;
|
||||
|
||||
for (const dir of PROXMOX.CLI_TOOL_PATHS) {
|
||||
if (!qmPath) {
|
||||
const p = `${dir}/qm`;
|
||||
try {
|
||||
if (fs.existsSync(p)) qmPath = p;
|
||||
} catch (_e) {
|
||||
// continue
|
||||
}
|
||||
}
|
||||
if (!pctPath) {
|
||||
const p = `${dir}/pct`;
|
||||
try {
|
||||
if (fs.existsSync(p)) pctPath = p;
|
||||
} catch (_e) {
|
||||
// continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isRoot = !!(process.getuid && process.getuid() === 0);
|
||||
|
||||
return {
|
||||
available: qmPath !== null && pctPath !== null && isRoot,
|
||||
qmPath,
|
||||
pctPath,
|
||||
isRoot,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the operation mode based on config and environment
|
||||
*/
|
||||
private resolveMode(): { mode: 'api' | 'cli'; qmPath: string; pctPath: string } | { mode: 'api'; qmPath?: undefined; pctPath?: undefined } {
|
||||
const configuredMode = this.config.proxmoxMode || 'auto';
|
||||
|
||||
if (configuredMode === 'api') {
|
||||
return { mode: 'api' };
|
||||
}
|
||||
|
||||
const detection = ProxmoxAction.detectCliAvailability();
|
||||
|
||||
if (configuredMode === 'cli') {
|
||||
if (!detection.qmPath || !detection.pctPath) {
|
||||
throw new Error('CLI mode requested but qm/pct not found. Are you on a Proxmox host?');
|
||||
}
|
||||
if (!detection.isRoot) {
|
||||
throw new Error('CLI mode requires root access');
|
||||
}
|
||||
return { mode: 'cli', qmPath: detection.qmPath, pctPath: detection.pctPath };
|
||||
}
|
||||
|
||||
// Auto-detect
|
||||
if (detection.available && detection.qmPath && detection.pctPath) {
|
||||
return { mode: 'cli', qmPath: detection.qmPath, pctPath: detection.pctPath };
|
||||
}
|
||||
return { mode: 'api' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the Proxmox shutdown action
|
||||
*/
|
||||
@@ -29,30 +108,21 @@ export class ProxmoxAction extends Action {
|
||||
return;
|
||||
}
|
||||
|
||||
const host = this.config.proxmoxHost || PROXMOX.DEFAULT_HOST;
|
||||
const port = this.config.proxmoxPort || PROXMOX.DEFAULT_PORT;
|
||||
const resolved = this.resolveMode();
|
||||
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 stopTimeout = (this.config.proxmoxStopTimeout || PROXMOX.DEFAULT_STOP_TIMEOUT_SECONDS) * 1000;
|
||||
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.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(`API: ${host}:${port}`);
|
||||
if (resolved.mode === 'api') {
|
||||
const host = this.config.proxmoxHost || PROXMOX.DEFAULT_HOST;
|
||||
const port = this.config.proxmoxPort || PROXMOX.DEFAULT_PORT;
|
||||
logger.logBoxLine(`API: ${host}:${port}`);
|
||||
}
|
||||
logger.logBoxLine(`UPS: ${context.upsName} (${context.powerStatus})`);
|
||||
logger.logBoxLine(`Trigger: ${context.triggerReason}`);
|
||||
if (excludeIds.size > 0) {
|
||||
@@ -62,9 +132,34 @@ export class ProxmoxAction extends Action {
|
||||
logger.log('');
|
||||
|
||||
try {
|
||||
// Collect running VMs and CTs
|
||||
const runningVMs = await this.getRunningVMs(baseUrl, node, headers, insecure);
|
||||
const runningCTs = await this.getRunningCTs(baseUrl, node, headers, insecure);
|
||||
let runningVMs: Array<{ vmid: number; name: string }>;
|
||||
let runningCTs: Array<{ vmid: number; name: string }>;
|
||||
|
||||
if (resolved.mode === 'cli') {
|
||||
runningVMs = await this.getRunningVMsCli(resolved.qmPath);
|
||||
runningCTs = await this.getRunningCTsCli(resolved.pctPath);
|
||||
} else {
|
||||
// API mode - validate token
|
||||
const host = this.config.proxmoxHost || PROXMOX.DEFAULT_HOST;
|
||||
const port = this.config.proxmoxPort || PROXMOX.DEFAULT_PORT;
|
||||
const tokenId = this.config.proxmoxTokenId;
|
||||
const tokenSecret = this.config.proxmoxTokenSecret;
|
||||
const insecure = this.config.proxmoxInsecure !== false;
|
||||
|
||||
if (!tokenId || !tokenSecret) {
|
||||
logger.error('Proxmox API token ID and secret are required for API mode');
|
||||
logger.error('Either provide tokens or run on a Proxmox host as root for CLI mode');
|
||||
return;
|
||||
}
|
||||
|
||||
const baseUrl = `https://${host}:${port}${PROXMOX.API_BASE}`;
|
||||
const headers: Record<string, string> = {
|
||||
'Authorization': `PVEAPIToken=${tokenId}=${tokenSecret}`,
|
||||
};
|
||||
|
||||
runningVMs = await this.getRunningVMsApi(baseUrl, node, headers, insecure);
|
||||
runningCTs = await this.getRunningCTsApi(baseUrl, node, headers, insecure);
|
||||
}
|
||||
|
||||
// Filter out excluded IDs
|
||||
const vmsToStop = runningVMs.filter((vm) => !excludeIds.has(vm.vmid));
|
||||
@@ -78,15 +173,33 @@ export class ProxmoxAction extends Action {
|
||||
|
||||
logger.info(`Shutting down ${vmsToStop.length} VMs and ${ctsToStop.length} containers...`);
|
||||
|
||||
// Send shutdown commands to all VMs and CTs
|
||||
for (const vm of vmsToStop) {
|
||||
await this.shutdownVM(baseUrl, node, vm.vmid, headers, insecure);
|
||||
logger.dim(` Shutdown sent to VM ${vm.vmid} (${vm.name || 'unnamed'})`);
|
||||
}
|
||||
// Send shutdown commands
|
||||
if (resolved.mode === 'cli') {
|
||||
for (const vm of vmsToStop) {
|
||||
await this.shutdownVMCli(resolved.qmPath, vm.vmid);
|
||||
logger.dim(` Shutdown sent to VM ${vm.vmid} (${vm.name || 'unnamed'})`);
|
||||
}
|
||||
for (const ct of ctsToStop) {
|
||||
await this.shutdownCTCli(resolved.pctPath, ct.vmid);
|
||||
logger.dim(` Shutdown sent to CT ${ct.vmid} (${ct.name || 'unnamed'})`);
|
||||
}
|
||||
} else {
|
||||
const host = this.config.proxmoxHost || PROXMOX.DEFAULT_HOST;
|
||||
const port = this.config.proxmoxPort || PROXMOX.DEFAULT_PORT;
|
||||
const insecure = this.config.proxmoxInsecure !== false;
|
||||
const baseUrl = `https://${host}:${port}${PROXMOX.API_BASE}`;
|
||||
const headers: Record<string, string> = {
|
||||
'Authorization': `PVEAPIToken=${this.config.proxmoxTokenId}=${this.config.proxmoxTokenSecret}`,
|
||||
};
|
||||
|
||||
for (const ct of ctsToStop) {
|
||||
await this.shutdownCT(baseUrl, node, ct.vmid, headers, insecure);
|
||||
logger.dim(` Shutdown sent to CT ${ct.vmid} (${ct.name || 'unnamed'})`);
|
||||
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
|
||||
@@ -95,23 +208,31 @@ export class ProxmoxAction extends Action {
|
||||
...ctsToStop.map((ct) => ({ type: 'lxc' as const, vmid: ct.vmid, name: ct.name })),
|
||||
];
|
||||
|
||||
const remaining = await this.waitForShutdown(
|
||||
baseUrl,
|
||||
node,
|
||||
allIds,
|
||||
headers,
|
||||
insecure,
|
||||
stopTimeout,
|
||||
);
|
||||
const remaining = await this.waitForShutdown(allIds, resolved, node, stopTimeout);
|
||||
|
||||
if (remaining.length > 0 && forceStop) {
|
||||
logger.warn(`${remaining.length} VMs/CTs didn't shut down gracefully, force-stopping...`);
|
||||
for (const item of remaining) {
|
||||
try {
|
||||
if (item.type === 'qemu') {
|
||||
await this.stopVM(baseUrl, node, item.vmid, headers, insecure);
|
||||
if (resolved.mode === 'cli') {
|
||||
if (item.type === 'qemu') {
|
||||
await this.stopVMCli(resolved.qmPath, item.vmid);
|
||||
} else {
|
||||
await this.stopCTCli(resolved.pctPath, item.vmid);
|
||||
}
|
||||
} else {
|
||||
await this.stopCT(baseUrl, node, item.vmid, headers, insecure);
|
||||
const host = this.config.proxmoxHost || PROXMOX.DEFAULT_HOST;
|
||||
const port = this.config.proxmoxPort || PROXMOX.DEFAULT_PORT;
|
||||
const insecure = this.config.proxmoxInsecure !== false;
|
||||
const baseUrl = `https://${host}:${port}${PROXMOX.API_BASE}`;
|
||||
const headers: Record<string, string> = {
|
||||
'Authorization': `PVEAPIToken=${this.config.proxmoxTokenId}=${this.config.proxmoxTokenSecret}`,
|
||||
};
|
||||
if (item.type === 'qemu') {
|
||||
await this.stopVMApi(baseUrl, node, item.vmid, headers, insecure);
|
||||
} else {
|
||||
await this.stopCTApi(baseUrl, node, item.vmid, headers, insecure);
|
||||
}
|
||||
}
|
||||
logger.dim(` Force-stopped ${item.type} ${item.vmid} (${item.name || 'unnamed'})`);
|
||||
} catch (error) {
|
||||
@@ -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
|
||||
*/
|
||||
@@ -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,
|
||||
node: 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,
|
||||
node: string,
|
||||
headers: Record<string, string>,
|
||||
@@ -228,10 +453,7 @@ export class ProxmoxAction extends Action {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send graceful shutdown to a QEMU VM
|
||||
*/
|
||||
private async shutdownVM(
|
||||
private async shutdownVMApi(
|
||||
baseUrl: string,
|
||||
node: string,
|
||||
vmid: number,
|
||||
@@ -246,10 +468,7 @@ export class ProxmoxAction extends Action {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send graceful shutdown to an LXC container
|
||||
*/
|
||||
private async shutdownCT(
|
||||
private async shutdownCTApi(
|
||||
baseUrl: string,
|
||||
node: string,
|
||||
vmid: number,
|
||||
@@ -264,10 +483,7 @@ export class ProxmoxAction extends Action {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Force-stop a QEMU VM
|
||||
*/
|
||||
private async stopVM(
|
||||
private async stopVMApi(
|
||||
baseUrl: string,
|
||||
node: string,
|
||||
vmid: number,
|
||||
@@ -282,10 +498,7 @@ export class ProxmoxAction extends Action {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Force-stop an LXC container
|
||||
*/
|
||||
private async stopCT(
|
||||
private async stopCTApi(
|
||||
baseUrl: string,
|
||||
node: string,
|
||||
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
|
||||
*/
|
||||
private async waitForShutdown(
|
||||
baseUrl: string,
|
||||
node: string,
|
||||
items: Array<{ type: 'qemu' | 'lxc'; vmid: number; name: string }>,
|
||||
headers: Record<string, string>,
|
||||
insecure: boolean,
|
||||
resolved: { mode: 'api' | 'cli'; qmPath?: string; pctPath?: string },
|
||||
node: string,
|
||||
timeout: number,
|
||||
): Promise<Array<{ type: 'qemu' | 'lxc'; vmid: number; name: string }>> {
|
||||
const startTime = Date.now();
|
||||
@@ -323,12 +536,27 @@ export class ProxmoxAction extends Action {
|
||||
|
||||
for (const item of remaining) {
|
||||
try {
|
||||
const statusUrl = `${baseUrl}/nodes/${node}/${item.type}/${item.vmid}/status/current`;
|
||||
const response = await this.apiRequest(statusUrl, 'GET', headers, insecure) as {
|
||||
data: { status: string };
|
||||
};
|
||||
let status: string;
|
||||
|
||||
if (response.data?.status === 'running') {
|
||||
if (resolved.mode === 'cli') {
|
||||
const toolPath = item.type === 'qemu' ? resolved.qmPath! : resolved.pctPath!;
|
||||
status = await this.getStatusCli(toolPath, item.vmid);
|
||||
} else {
|
||||
const host = this.config.proxmoxHost || PROXMOX.DEFAULT_HOST;
|
||||
const port = this.config.proxmoxPort || PROXMOX.DEFAULT_PORT;
|
||||
const insecure = this.config.proxmoxInsecure !== false;
|
||||
const baseUrl = `https://${host}:${port}${PROXMOX.API_BASE}`;
|
||||
const headers: Record<string, string> = {
|
||||
'Authorization': `PVEAPIToken=${this.config.proxmoxTokenId}=${this.config.proxmoxTokenSecret}`,
|
||||
};
|
||||
const statusUrl = `${baseUrl}/nodes/${node}/${item.type}/${item.vmid}/status/current`;
|
||||
const response = await this.apiRequest(statusUrl, 'GET', headers, insecure) as {
|
||||
data: { status: string };
|
||||
};
|
||||
status = response.data?.status || 'unknown';
|
||||
}
|
||||
|
||||
if (status === 'running') {
|
||||
stillRunning.push(item);
|
||||
} else {
|
||||
logger.dim(` ${item.type} ${item.vmid} (${item.name}) stopped`);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Nupst } from '../nupst.ts';
|
||||
import { type ITableColumn, logger } from '../logger.ts';
|
||||
import { symbols, theme } from '../colors.ts';
|
||||
import type { IActionConfig } from '../actions/base-action.ts';
|
||||
import { ProxmoxAction } from '../actions/proxmox-action.ts';
|
||||
import type { IGroupConfig, IUpsConfig } from '../daemon.ts';
|
||||
import * as helpers from '../helpers/index.ts';
|
||||
|
||||
@@ -65,11 +66,146 @@ export class ActionHandler {
|
||||
logger.info(`Add Action to ${targetType} ${theme.highlight(targetName)}`);
|
||||
logger.log('');
|
||||
|
||||
// Action type (currently only shutdown is supported)
|
||||
const type = 'shutdown';
|
||||
logger.log(` ${theme.dim('Action type:')} ${theme.highlight('shutdown')}`);
|
||||
// Action type selection
|
||||
logger.log(` ${theme.dim('Action Type:')}`);
|
||||
logger.log(` ${theme.dim('1)')} Shutdown (system shutdown)`);
|
||||
logger.log(` ${theme.dim('2)')} Webhook (HTTP notification)`);
|
||||
logger.log(` ${theme.dim('3)')} Custom Script (run .sh file from /etc/nupst)`);
|
||||
logger.log(` ${theme.dim('4)')} Proxmox (gracefully shut down VMs/LXCs before host shutdown)`);
|
||||
|
||||
// Battery threshold
|
||||
const typeInput = await prompt(` ${theme.dim('Select action type')} ${theme.dim('[1]:')} `);
|
||||
const typeValue = parseInt(typeInput, 10) || 1;
|
||||
|
||||
const newAction: Partial<IActionConfig> = {};
|
||||
|
||||
if (typeValue === 1) {
|
||||
// Shutdown action
|
||||
newAction.type = 'shutdown';
|
||||
|
||||
const delayStr = await prompt(
|
||||
` ${theme.dim('Shutdown delay')} ${theme.dim('(minutes) [5]:')} `,
|
||||
);
|
||||
const shutdownDelay = delayStr ? parseInt(delayStr, 10) : 5;
|
||||
if (isNaN(shutdownDelay) || shutdownDelay < 0) {
|
||||
logger.error('Invalid shutdown delay. Must be >= 0.');
|
||||
process.exit(1);
|
||||
}
|
||||
newAction.shutdownDelay = shutdownDelay;
|
||||
} else if (typeValue === 2) {
|
||||
// Webhook action
|
||||
newAction.type = 'webhook';
|
||||
|
||||
const url = await prompt(` ${theme.dim('Webhook URL:')} `);
|
||||
if (!url.trim()) {
|
||||
logger.error('Webhook URL is required.');
|
||||
process.exit(1);
|
||||
}
|
||||
newAction.webhookUrl = url.trim();
|
||||
|
||||
logger.log('');
|
||||
logger.log(` ${theme.dim('HTTP Method:')}`);
|
||||
logger.log(` ${theme.dim('1)')} POST (JSON body)`);
|
||||
logger.log(` ${theme.dim('2)')} GET (query parameters)`);
|
||||
const methodInput = await prompt(` ${theme.dim('Select method')} ${theme.dim('[1]:')} `);
|
||||
newAction.webhookMethod = methodInput === '2' ? 'GET' : 'POST';
|
||||
|
||||
const timeoutInput = await prompt(` ${theme.dim('Timeout in seconds')} ${theme.dim('[10]:')} `);
|
||||
const timeout = parseInt(timeoutInput, 10);
|
||||
if (timeoutInput.trim() && !isNaN(timeout)) {
|
||||
newAction.webhookTimeout = timeout * 1000;
|
||||
}
|
||||
} else if (typeValue === 3) {
|
||||
// Script action
|
||||
newAction.type = 'script';
|
||||
|
||||
const scriptPath = await prompt(` ${theme.dim('Script filename (in /etc/nupst/, must end with .sh):')} `);
|
||||
if (!scriptPath.trim() || !scriptPath.trim().endsWith('.sh')) {
|
||||
logger.error('Script path must end with .sh.');
|
||||
process.exit(1);
|
||||
}
|
||||
newAction.scriptPath = scriptPath.trim();
|
||||
|
||||
const timeoutInput = await prompt(` ${theme.dim('Script timeout in seconds')} ${theme.dim('[60]:')} `);
|
||||
const timeout = parseInt(timeoutInput, 10);
|
||||
if (timeoutInput.trim() && !isNaN(timeout)) {
|
||||
newAction.scriptTimeout = timeout * 1000;
|
||||
}
|
||||
} else if (typeValue === 4) {
|
||||
// Proxmox action
|
||||
newAction.type = 'proxmox';
|
||||
|
||||
// Auto-detect CLI availability
|
||||
const detection = ProxmoxAction.detectCliAvailability();
|
||||
|
||||
if (detection.available) {
|
||||
logger.log('');
|
||||
logger.success('Proxmox CLI tools detected (qm/pct). No API token needed.');
|
||||
logger.dim(` qm: ${detection.qmPath}`);
|
||||
logger.dim(` pct: ${detection.pctPath}`);
|
||||
newAction.proxmoxMode = 'cli';
|
||||
} else {
|
||||
logger.log('');
|
||||
if (!detection.isRoot) {
|
||||
logger.warn('Not running as root - CLI mode unavailable, using API mode.');
|
||||
} else {
|
||||
logger.warn('Proxmox CLI tools (qm/pct) not found - using API mode.');
|
||||
}
|
||||
logger.log('');
|
||||
logger.info('Proxmox API Settings:');
|
||||
logger.dim('Create a token with: pveum user token add root@pam nupst --privsep=0');
|
||||
|
||||
const pxHost = await prompt(` ${theme.dim('Proxmox Host')} ${theme.dim('[localhost]:')} `);
|
||||
newAction.proxmoxHost = pxHost.trim() || 'localhost';
|
||||
|
||||
const pxPortInput = await prompt(` ${theme.dim('Proxmox API Port')} ${theme.dim('[8006]:')} `);
|
||||
const pxPort = parseInt(pxPortInput, 10);
|
||||
newAction.proxmoxPort = pxPortInput.trim() && !isNaN(pxPort) ? pxPort : 8006;
|
||||
|
||||
const pxNode = await prompt(` ${theme.dim('Proxmox Node Name (empty = auto-detect):')} `);
|
||||
if (pxNode.trim()) {
|
||||
newAction.proxmoxNode = pxNode.trim();
|
||||
}
|
||||
|
||||
const tokenId = await prompt(` ${theme.dim('API Token ID (e.g., root@pam!nupst):')} `);
|
||||
if (!tokenId.trim()) {
|
||||
logger.error('Token ID is required for API mode.');
|
||||
process.exit(1);
|
||||
}
|
||||
newAction.proxmoxTokenId = tokenId.trim();
|
||||
|
||||
const tokenSecret = await prompt(` ${theme.dim('API Token Secret:')} `);
|
||||
if (!tokenSecret.trim()) {
|
||||
logger.error('Token Secret is required for API mode.');
|
||||
process.exit(1);
|
||||
}
|
||||
newAction.proxmoxTokenSecret = tokenSecret.trim();
|
||||
|
||||
const insecureInput = await prompt(` ${theme.dim('Skip TLS verification (self-signed cert)?')} ${theme.dim('(Y/n):')} `);
|
||||
newAction.proxmoxInsecure = insecureInput.toLowerCase() !== 'n';
|
||||
newAction.proxmoxMode = 'api';
|
||||
}
|
||||
|
||||
// Common Proxmox settings (both modes)
|
||||
const excludeInput = await prompt(` ${theme.dim('VM/CT IDs to exclude (comma-separated, or empty):')} `);
|
||||
if (excludeInput.trim()) {
|
||||
newAction.proxmoxExcludeIds = excludeInput.split(',').map((s) => parseInt(s.trim(), 10)).filter((n) => !isNaN(n));
|
||||
}
|
||||
|
||||
const timeoutInput = await prompt(` ${theme.dim('VM shutdown timeout in seconds')} ${theme.dim('[120]:')} `);
|
||||
const stopTimeout = parseInt(timeoutInput, 10);
|
||||
if (timeoutInput.trim() && !isNaN(stopTimeout)) {
|
||||
newAction.proxmoxStopTimeout = stopTimeout;
|
||||
}
|
||||
|
||||
const forceInput = await prompt(` ${theme.dim('Force-stop VMs that don\'t shut down in time?')} ${theme.dim('(Y/n):')} `);
|
||||
newAction.proxmoxForceStop = forceInput.toLowerCase() !== 'n';
|
||||
} else {
|
||||
logger.error('Invalid action type.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Battery threshold (all action types)
|
||||
logger.log('');
|
||||
const batteryStr = await prompt(
|
||||
` ${theme.dim('Battery threshold')} ${theme.dim('(%):')} `,
|
||||
);
|
||||
@@ -89,6 +225,8 @@ export class ActionHandler {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
newAction.thresholds = { battery, runtime };
|
||||
|
||||
// Trigger mode
|
||||
logger.log('');
|
||||
logger.log(` ${theme.dim('Trigger mode:')}`);
|
||||
@@ -113,33 +251,13 @@ export class ActionHandler {
|
||||
'': 'onlyThresholds', // Default
|
||||
};
|
||||
const triggerMode = triggerModeMap[triggerChoice] || 'onlyThresholds';
|
||||
|
||||
// Shutdown delay
|
||||
const delayStr = await prompt(
|
||||
` ${theme.dim('Shutdown delay')} ${theme.dim('(seconds) [5]:')} `,
|
||||
);
|
||||
const shutdownDelay = delayStr ? parseInt(delayStr, 10) : 5;
|
||||
if (isNaN(shutdownDelay) || shutdownDelay < 0) {
|
||||
logger.error('Invalid shutdown delay. Must be >= 0.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Create the action
|
||||
const newAction: IActionConfig = {
|
||||
type,
|
||||
thresholds: {
|
||||
battery,
|
||||
runtime,
|
||||
},
|
||||
triggerMode: triggerMode as IActionConfig['triggerMode'],
|
||||
shutdownDelay,
|
||||
};
|
||||
newAction.triggerMode = triggerMode as IActionConfig['triggerMode'];
|
||||
|
||||
// Add to target (UPS or group)
|
||||
if (!target!.actions) {
|
||||
target!.actions = [];
|
||||
}
|
||||
target!.actions.push(newAction);
|
||||
target!.actions.push(newAction as IActionConfig);
|
||||
|
||||
await this.nupst.getDaemon().saveConfig(config);
|
||||
|
||||
@@ -350,11 +468,19 @@ export class ActionHandler {
|
||||
];
|
||||
|
||||
const rows = target.actions.map((action, index) => {
|
||||
let details = `${action.shutdownDelay || 5}s delay`;
|
||||
let details = `${action.shutdownDelay || 5}min delay`;
|
||||
if (action.type === 'proxmox') {
|
||||
const host = action.proxmoxHost || 'localhost';
|
||||
const port = action.proxmoxPort || 8006;
|
||||
details = `${host}:${port}`;
|
||||
const mode = action.proxmoxMode || 'auto';
|
||||
if (mode === 'cli' || (mode === 'auto' && !action.proxmoxTokenId)) {
|
||||
details = 'CLI mode';
|
||||
} else {
|
||||
const host = action.proxmoxHost || 'localhost';
|
||||
const port = action.proxmoxPort || 8006;
|
||||
details = `API ${host}:${port}`;
|
||||
}
|
||||
if (action.proxmoxExcludeIds?.length) {
|
||||
details += `, excl: ${action.proxmoxExcludeIds.join(',')}`;
|
||||
}
|
||||
} else if (action.type === 'webhook') {
|
||||
details = action.webhookUrl || theme.dim('N/A');
|
||||
} else if (action.type === 'script') {
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { IUpsdConfig } from '../upsd/types.ts';
|
||||
import type { TProtocol } from '../protocol/types.ts';
|
||||
import type { INupstConfig, IUpsConfig } from '../daemon.ts';
|
||||
import type { IActionConfig } from '../actions/base-action.ts';
|
||||
import { ProxmoxAction } from '../actions/proxmox-action.ts';
|
||||
import { UPSD } from '../constants.ts';
|
||||
|
||||
/**
|
||||
@@ -974,6 +975,35 @@ export class UpsHandler {
|
||||
OUTPUT_CURRENT: '',
|
||||
};
|
||||
}
|
||||
|
||||
// Runtime unit selection
|
||||
logger.log('');
|
||||
logger.info('Battery Runtime Unit:');
|
||||
logger.dim(' Controls how NUPST interprets the runtime value from your UPS.');
|
||||
logger.dim(' 1) Minutes (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';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1155,37 +1185,58 @@ export class UpsHandler {
|
||||
// Proxmox action
|
||||
action.type = 'proxmox';
|
||||
|
||||
logger.log('');
|
||||
logger.info('Proxmox API Settings:');
|
||||
logger.dim('Requires a Proxmox API token. Create one with:');
|
||||
logger.dim(' pveum user token add root@pam nupst --privsep=0');
|
||||
// Auto-detect CLI availability
|
||||
const detection = ProxmoxAction.detectCliAvailability();
|
||||
|
||||
const pxHost = await prompt('Proxmox Host [localhost]: ');
|
||||
action.proxmoxHost = pxHost.trim() || 'localhost';
|
||||
if (detection.available) {
|
||||
logger.log('');
|
||||
logger.success('Proxmox CLI tools detected (qm/pct). No API token needed.');
|
||||
logger.dim(` qm: ${detection.qmPath}`);
|
||||
logger.dim(` pct: ${detection.pctPath}`);
|
||||
action.proxmoxMode = 'cli';
|
||||
} else {
|
||||
logger.log('');
|
||||
if (!detection.isRoot) {
|
||||
logger.warn('Not running as root - CLI mode unavailable, using API mode.');
|
||||
} else {
|
||||
logger.warn('Proxmox CLI tools (qm/pct) not found - using API mode.');
|
||||
}
|
||||
logger.log('');
|
||||
logger.info('Proxmox API Settings:');
|
||||
logger.dim('Create a token with: pveum user token add root@pam nupst --privsep=0');
|
||||
|
||||
const pxPortInput = await prompt('Proxmox API Port [8006]: ');
|
||||
const pxPort = parseInt(pxPortInput, 10);
|
||||
action.proxmoxPort = pxPortInput.trim() && !isNaN(pxPort) ? pxPort : 8006;
|
||||
const pxHost = await prompt('Proxmox Host [localhost]: ');
|
||||
action.proxmoxHost = pxHost.trim() || 'localhost';
|
||||
|
||||
const pxNode = await prompt('Proxmox Node Name (empty = auto-detect via hostname): ');
|
||||
if (pxNode.trim()) {
|
||||
action.proxmoxNode = pxNode.trim();
|
||||
const pxPortInput = await prompt('Proxmox API Port [8006]: ');
|
||||
const pxPort = parseInt(pxPortInput, 10);
|
||||
action.proxmoxPort = pxPortInput.trim() && !isNaN(pxPort) ? pxPort : 8006;
|
||||
|
||||
const pxNode = await prompt('Proxmox Node Name (empty = auto-detect via hostname): ');
|
||||
if (pxNode.trim()) {
|
||||
action.proxmoxNode = pxNode.trim();
|
||||
}
|
||||
|
||||
const tokenId = await prompt('API Token ID (e.g., root@pam!nupst): ');
|
||||
if (!tokenId.trim()) {
|
||||
logger.warn('Token ID is required for API mode, skipping');
|
||||
continue;
|
||||
}
|
||||
action.proxmoxTokenId = tokenId.trim();
|
||||
|
||||
const tokenSecret = await prompt('API Token Secret: ');
|
||||
if (!tokenSecret.trim()) {
|
||||
logger.warn('Token Secret is required for API mode, skipping');
|
||||
continue;
|
||||
}
|
||||
action.proxmoxTokenSecret = tokenSecret.trim();
|
||||
|
||||
const insecureInput = await prompt('Skip TLS verification (self-signed cert)? (Y/n): ');
|
||||
action.proxmoxInsecure = insecureInput.toLowerCase() !== 'n';
|
||||
action.proxmoxMode = 'api';
|
||||
}
|
||||
|
||||
const tokenId = await prompt('API Token ID (e.g., root@pam!nupst): ');
|
||||
if (!tokenId.trim()) {
|
||||
logger.warn('Token ID is required for Proxmox action, skipping');
|
||||
continue;
|
||||
}
|
||||
action.proxmoxTokenId = tokenId.trim();
|
||||
|
||||
const tokenSecret = await prompt('API Token Secret: ');
|
||||
if (!tokenSecret.trim()) {
|
||||
logger.warn('Token Secret is required for Proxmox action, skipping');
|
||||
continue;
|
||||
}
|
||||
action.proxmoxTokenSecret = tokenSecret.trim();
|
||||
|
||||
// Common Proxmox settings (both modes)
|
||||
const excludeInput = await prompt('VM/CT IDs to exclude (comma-separated, or empty): ');
|
||||
if (excludeInput.trim()) {
|
||||
action.proxmoxExcludeIds = excludeInput.split(',').map((s) => parseInt(s.trim(), 10)).filter((n) => !isNaN(n));
|
||||
@@ -1200,9 +1251,6 @@ export class UpsHandler {
|
||||
const forceInput = await prompt('Force-stop VMs that don\'t shut down in time? (Y/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.info('Note: Place the Proxmox action BEFORE the shutdown action');
|
||||
logger.dim('in the action chain so VMs shut down before the host.');
|
||||
@@ -1296,6 +1344,7 @@ export class UpsHandler {
|
||||
logger.logBoxLine(`SNMP Host: ${ups.snmp.host}:${ups.snmp.port}`);
|
||||
logger.logBoxLine(`SNMP Version: ${ups.snmp.version}`);
|
||||
logger.logBoxLine(`UPS Model: ${ups.snmp.upsModel}`);
|
||||
logger.logBoxLine(`Runtime Unit: ${ups.snmp.runtimeUnit || 'auto'}`);
|
||||
}
|
||||
|
||||
if (ups.groups && ups.groups.length > 0) {
|
||||
|
||||
@@ -157,6 +157,9 @@ export const PROXMOX = {
|
||||
|
||||
/** Proxmox API base path */
|
||||
API_BASE: '/api2/json',
|
||||
|
||||
/** Common paths to search for Proxmox CLI tools (qm, pct) */
|
||||
CLI_TOOL_PATHS: ['/usr/sbin', '/usr/bin', '/sbin', '/bin'] as readonly string[],
|
||||
} as const;
|
||||
|
||||
/**
|
||||
|
||||
@@ -142,7 +142,7 @@ export class NupstDaemon {
|
||||
|
||||
/** Default configuration */
|
||||
private readonly DEFAULT_CONFIG: INupstConfig = {
|
||||
version: '4.2',
|
||||
version: '4.3',
|
||||
upsDevices: [
|
||||
{
|
||||
id: 'default',
|
||||
@@ -162,6 +162,7 @@ export class NupstDaemon {
|
||||
privKey: '',
|
||||
// UPS model for OID selection
|
||||
upsModel: 'cyberpower',
|
||||
runtimeUnit: 'ticks',
|
||||
},
|
||||
groups: [],
|
||||
actions: [
|
||||
@@ -260,7 +261,7 @@ export class NupstDaemon {
|
||||
|
||||
// Ensure version is always set and remove legacy fields before saving
|
||||
const configToSave: INupstConfig = {
|
||||
version: '4.2',
|
||||
version: '4.3',
|
||||
upsDevices: config.upsDevices,
|
||||
groups: config.groups,
|
||||
checkInterval: config.checkInterval,
|
||||
|
||||
@@ -10,3 +10,4 @@ export { MigrationV1ToV2 } from './migration-v1-to-v2.ts';
|
||||
export { MigrationV3ToV4 } from './migration-v3-to-v4.ts';
|
||||
export { MigrationV4_0ToV4_1 } from './migration-v4.0-to-v4.1.ts';
|
||||
export { MigrationV4_1ToV4_2 } from './migration-v4.1-to-v4.2.ts';
|
||||
export { MigrationV4_2ToV4_3 } from './migration-v4.2-to-v4.3.ts';
|
||||
|
||||
@@ -3,6 +3,7 @@ import { MigrationV1ToV2 } from './migration-v1-to-v2.ts';
|
||||
import { MigrationV3ToV4 } from './migration-v3-to-v4.ts';
|
||||
import { MigrationV4_0ToV4_1 } from './migration-v4.0-to-v4.1.ts';
|
||||
import { MigrationV4_1ToV4_2 } from './migration-v4.1-to-v4.2.ts';
|
||||
import { MigrationV4_2ToV4_3 } from './migration-v4.2-to-v4.3.ts';
|
||||
import { logger } from '../logger.ts';
|
||||
|
||||
/**
|
||||
@@ -21,6 +22,7 @@ export class MigrationRunner {
|
||||
new MigrationV3ToV4(),
|
||||
new MigrationV4_0ToV4_1(),
|
||||
new MigrationV4_1ToV4_2(),
|
||||
new MigrationV4_2ToV4_3(),
|
||||
];
|
||||
|
||||
// Sort by version order to ensure they run in sequence
|
||||
|
||||
50
ts/migrations/migration-v4.2-to-v4.3.ts
Normal file
50
ts/migrations/migration-v4.2-to-v4.3.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 type { IOidSet, ISnmpConfig, IUpsStatus, TUpsModel } from './types.ts';
|
||||
import { UpsOidSets } from './oid-sets.ts';
|
||||
@@ -357,7 +357,7 @@ export class NupstSnmp {
|
||||
const powerStatus = this.determinePowerStatus(config.upsModel, powerStatusValue);
|
||||
|
||||
// Convert to minutes for UPS models with different time units
|
||||
const processedRuntime = this.processRuntimeValue(config.upsModel, batteryRuntime);
|
||||
const processedRuntime = this.processRuntimeValue(config, batteryRuntime);
|
||||
|
||||
// Process power metrics with vendor-specific scaling
|
||||
const processedVoltage = this.processVoltageValue(config.upsModel, outputVoltage);
|
||||
@@ -620,22 +620,46 @@ export class NupstSnmp {
|
||||
}
|
||||
|
||||
/**
|
||||
* Process runtime value based on UPS model
|
||||
* @param upsModel UPS model
|
||||
* Process runtime value based on config runtimeUnit or UPS model
|
||||
* @param config SNMP configuration (uses runtimeUnit if set, otherwise falls back to upsModel)
|
||||
* @param batteryRuntime Raw battery runtime value
|
||||
* @returns Processed runtime in minutes
|
||||
*/
|
||||
private processRuntimeValue(
|
||||
upsModel: TUpsModel | undefined,
|
||||
config: ISnmpConfig,
|
||||
batteryRuntime: number,
|
||||
): number {
|
||||
if (this.debug) {
|
||||
logger.dim(`Raw runtime value: ${batteryRuntime}`);
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// CyberPower: TimeTicks is in 1/100 seconds, convert to minutes
|
||||
const minutes = Math.floor(batteryRuntime / 6000); // 6000 ticks = 1 minute
|
||||
const minutes = Math.floor(batteryRuntime / 6000);
|
||||
if (this.debug) {
|
||||
logger.dim(
|
||||
`Converting CyberPower runtime from ${batteryRuntime} ticks to ${minutes} minutes`,
|
||||
@@ -643,7 +667,6 @@ export class NupstSnmp {
|
||||
}
|
||||
return minutes;
|
||||
} else if (upsModel === 'eaton' && batteryRuntime > 0) {
|
||||
// Eaton: Runtime is in seconds, convert to minutes
|
||||
const minutes = Math.floor(batteryRuntime / 60);
|
||||
if (this.debug) {
|
||||
logger.dim(
|
||||
@@ -652,10 +675,9 @@ export class NupstSnmp {
|
||||
}
|
||||
return minutes;
|
||||
} else if (batteryRuntime > 10000) {
|
||||
// Generic conversion for large tick values (likely TimeTicks)
|
||||
const minutes = Math.floor(batteryRuntime / 6000);
|
||||
if (this.debug) {
|
||||
logger.dim(`Converting ${batteryRuntime} ticks to ${minutes} minutes`);
|
||||
logger.dim(`Converting ${batteryRuntime} ticks to ${minutes} minutes (heuristic)`);
|
||||
}
|
||||
return minutes;
|
||||
}
|
||||
|
||||
@@ -58,6 +58,11 @@ export interface IOidSet {
|
||||
*/
|
||||
export type TUpsModel = 'cyberpower' | 'apc' | 'eaton' | 'tripplite' | 'liebert' | 'custom';
|
||||
|
||||
/**
|
||||
* Runtime unit for battery runtime SNMP values
|
||||
*/
|
||||
export type TRuntimeUnit = 'minutes' | 'seconds' | 'ticks';
|
||||
|
||||
/**
|
||||
* SNMP Configuration interface
|
||||
*/
|
||||
@@ -96,6 +101,8 @@ export interface ISnmpConfig {
|
||||
upsModel?: TUpsModel;
|
||||
/** Custom OIDs when using custom UPS model */
|
||||
customOIDs?: IOidSet;
|
||||
/** Unit of the battery runtime SNMP value. Overrides model-based auto-detection when set. */
|
||||
runtimeUnit?: TRuntimeUnit;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user