Compare commits
197 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f0f7c06e5c | |||
| e38413f133 | |||
| ebc5eed89c | |||
| 08b20b4e7b | |||
| ba4e56338c | |||
| 6b2fa65611 | |||
| c42ebb56d3 | |||
| c7b52c48d5 | |||
| e2cfa67fee | |||
| e916ccf3ae | |||
| a435bd6fed | |||
| bf4d519428 | |||
| 579667b3cd | |||
| 8dc0248763 | |||
| 1f542ca271 | |||
| 2adf1d5548 | |||
| 067a7666e4 | |||
| 0d863a1028 | |||
| c410a663b1 | |||
| 6aa1fc651f | |||
| 11e549e68e | |||
| 0fb9678976 | |||
| 635de0d932 | |||
| 0916effb53 | |||
| 05242a1c7d | |||
| 0d20dce520 | |||
| 1c50509497 | |||
| 7de521078e | |||
| 42b8eaf6d2 | |||
| 782c8c9555 | |||
| 463c32ebba | |||
| 51aa68ff8d | |||
| cb34ae5041 | |||
| 165c7d29bb | |||
| ff2dc00f31 | |||
| fda072d15e | |||
| c7786e9626 | |||
| 91fe5f7ae6 | |||
| 07648b4880 | |||
| d0e3a4ae74 | |||
| 89ffd61717 | |||
| 60eadaf6a1 | |||
| bd52ba4cb2 | |||
| a3d6a8b75d | |||
| fbd71b1f3b | |||
| 6481572981 | |||
| 0dc14a6ea1 | |||
| dea344e6ba | |||
| f81f5957ab | |||
| 281d3fbbeb | |||
| c1cb136a7d | |||
| b80275a594 | |||
| b64a515c94 | |||
| 68c4eb6480 | |||
| 6c8f6ac33f | |||
| ffa491c7a1 | |||
| 777d48d82e | |||
| b7a0bbcf6d | |||
| fbe1cd64cb | |||
| 9ba50da73c | |||
| 684319983d | |||
| 18bd9f6cda | |||
| f03c683d02 | |||
| f750299780 | |||
| ca1039408d | |||
| df3e0b9424 | |||
| c8e5960abd | |||
| 7304a62357 | |||
| a5a88e53ba | |||
| 73bc271c59 | |||
| 1e98181e71 | |||
| eb5a8185ae | |||
| ef3d3f3fa3 | |||
| 34e6e850ad | |||
| 992a776fd2 | |||
| 3e15a2d52f | |||
| d1a3576d31 | |||
| 1ca05e879b | |||
| 9c6fa37eb8 | |||
| ff433b2256 | |||
| 263d69aef1 | |||
| b6b7b43161 | |||
| 316c66c344 | |||
| 4debda856b | |||
| 0e7bcab499 | |||
| 7bf65d8495 | |||
| f2ce0180d3 | |||
| 8c1be6555f | |||
| 1a5558e91f | |||
| 611a9ddd19 | |||
| afd026d08c | |||
| 2c8ea44d40 | |||
| 32bd27b849 | |||
| a7113d0387 | |||
| 61d4e9037a | |||
| caced2718f | |||
| 8516056f84 | |||
| 07ec9d7595 | |||
| d14ba1dd65 | |||
| 7d595fa175 | |||
| df417432b0 | |||
| e5f1ebf343 | |||
| 3ff0dd7ac8 | |||
| bb87316dd3 | |||
| d6e0a1a274 | |||
| 95fa4f8b0b | |||
| c2f2f1e2ee | |||
| 936f86c346 | |||
| 7ff1a7da36 | |||
| a87710144c | |||
| 23fd5cc5cd | |||
| fb4d776bdd | |||
| 88ad16c638 | |||
| 016681b77b | |||
| 49f7a7da8b | |||
| f8269a1cb7 | |||
| b37e1aae6c | |||
| 7076829747 | |||
| 1387ca262b | |||
| 684f034aee | |||
| a63ec16d63 | |||
| 85f34cf96a | |||
| 4d28614e08 | |||
| 567c7be7c5 | |||
| a897a7c780 | |||
| accf137216 | |||
| c3441946cb | |||
| 37ccbf58fd | |||
| 071ded9c41 | |||
| b935087d50 | |||
| e1383097b2 | |||
| dff0ea610b | |||
| 4faa10c494 | |||
| c2d39cc19a | |||
| 9ccbbbdc37 | |||
| 1705ffe2be | |||
| 968cbbd8fc | |||
| a2ae9960b6 | |||
| df6a44d5d9 | |||
| 9efcc4b437 | |||
| 5903ae71be | |||
| a649c598ad | |||
| 5f4f3ecbc3 | |||
| 806f81c6a0 | |||
| 88e353eec6 | |||
| 80ff1b1230 | |||
| 1075335497 | |||
| eafb5207a4 | |||
| 9969e0f703 | |||
| ac4b2c95f3 | |||
| c593d76ead | |||
| 01ccf2d080 | |||
| 0e55f22dad | |||
| bd3042de25 | |||
| 456351ca34 | |||
| 00afa317ef | |||
| 45ee8208b5 | |||
| 39bf3e2239 | |||
| f3de3f0618 | |||
| 03056d279d | |||
| f860f39e59 | |||
| fa4516de3b | |||
| 539547beb8 | |||
| 6eb92959ec | |||
| 4af9af0845 | |||
| f7e12cdcbb | |||
| 002498b91b | |||
| 459911fe5f | |||
| 9859a02ea2 | |||
| 65444b6d25 | |||
| d049e8741f | |||
| 1123a99aea | |||
| d01e878310 | |||
| 588aeabf4b | |||
| 87005e72f1 | |||
| f799c2ee66 | |||
| 1a029ba493 | |||
| 5b756dd223 | |||
| 4cac599a58 | |||
| be6a7314c3 | |||
| 83ba9c2611 | |||
| 22ab472e58 | |||
| 9a77030377 | |||
| ceff285ff5 | |||
| d8bfbf0be3 | |||
| 3e6b883b38 | |||
| 47ef918128 | |||
| 5951638967 | |||
| b06e2b2273 | |||
| cc1cfe894c | |||
| da49b7a5bf | |||
| 4de6081a74 | |||
| 5a13e49803 | |||
| 2737fca294 | |||
| 896233914f | |||
| 5bb775b17d | |||
| ae8219acf7 |
@@ -0,0 +1,37 @@
|
||||
## NUPST {{VERSION}}
|
||||
|
||||
Pre-compiled binaries for multiple platforms.
|
||||
|
||||
### Installation
|
||||
|
||||
#### Option 1: Via npm (recommended)
|
||||
|
||||
```bash
|
||||
npm install -g @serve.zone/nupst
|
||||
```
|
||||
|
||||
#### Option 2: Via installer script
|
||||
|
||||
```bash
|
||||
curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash
|
||||
```
|
||||
|
||||
#### Option 3: Direct binary download
|
||||
|
||||
Download the appropriate binary for your platform from the assets below and make it executable.
|
||||
|
||||
### Supported Platforms
|
||||
|
||||
- Linux x86_64 (x64)
|
||||
- Linux ARM64 (aarch64)
|
||||
- macOS x86_64 (Intel)
|
||||
- macOS ARM64 (Apple Silicon)
|
||||
- Windows x86_64
|
||||
|
||||
### Checksums
|
||||
|
||||
SHA256 checksums are provided in `SHA256SUMS.txt` for binary verification.
|
||||
|
||||
### npm Package
|
||||
|
||||
The npm package includes automatic binary detection and installation for your platform.
|
||||
@@ -0,0 +1,179 @@
|
||||
# Gitea Actions Workflows
|
||||
|
||||
This directory contains automated workflows for NUPST's CI/CD pipeline.
|
||||
|
||||
## Workflows
|
||||
|
||||
### CI Workflow (`ci.yml`)
|
||||
|
||||
**Triggers:**
|
||||
|
||||
- Push to `main` branch
|
||||
- Push to `migration/**` branches
|
||||
- Pull requests to `main`
|
||||
|
||||
**Jobs:**
|
||||
|
||||
1. **Type Check & Lint**
|
||||
- Runs `deno check` for TypeScript validation
|
||||
- Runs `deno lint` (continues on error)
|
||||
- Runs `deno fmt --check` (continues on error)
|
||||
|
||||
2. **Build Test (Current Platform)**
|
||||
- Compiles for Linux x86_64 (host platform)
|
||||
- Tests binary execution (`--version` and `help`)
|
||||
|
||||
3. **Build All Platforms** (Main/Tags only)
|
||||
- Compiles all 5 platform binaries
|
||||
- Uploads as artifacts (30-day retention)
|
||||
- Only runs on `main` branch or tags
|
||||
|
||||
### Release Workflow (`release.yml`)
|
||||
|
||||
**Triggers:**
|
||||
|
||||
- Push tags matching `v*` (e.g., `v4.0.0`)
|
||||
|
||||
**Jobs:**
|
||||
|
||||
1. **Version Verification**
|
||||
- Extracts version from tag
|
||||
- Verifies `deno.json` version matches tag
|
||||
- Fails if mismatch detected
|
||||
|
||||
2. **Compilation**
|
||||
- Compiles binaries for all 5 platforms:
|
||||
- `nupst-linux-x64` (Linux x86_64)
|
||||
- `nupst-linux-arm64` (Linux ARM64)
|
||||
- `nupst-macos-x64` (macOS Intel)
|
||||
- `nupst-macos-arm64` (macOS Apple Silicon)
|
||||
- `nupst-windows-x64.exe` (Windows x64)
|
||||
|
||||
3. **Checksums**
|
||||
- Generates SHA256 checksums for all binaries
|
||||
- Creates `SHA256SUMS.txt`
|
||||
|
||||
4. **Release Creation**
|
||||
- Creates Gitea release with tag
|
||||
- Extracts release notes from CHANGELOG.md (if exists)
|
||||
- Uploads all binaries + checksums as release assets
|
||||
|
||||
## Creating a Release
|
||||
|
||||
### Prerequisites
|
||||
|
||||
1. Update version in `deno.json`:
|
||||
```json
|
||||
{
|
||||
"version": "4.0.0"
|
||||
}
|
||||
```
|
||||
|
||||
2. Update `CHANGELOG.md` with release notes (optional but recommended)
|
||||
|
||||
3. Commit all changes:
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "chore: bump version to 4.0.0"
|
||||
```
|
||||
|
||||
### Release Process
|
||||
|
||||
1. Create and push a tag matching the version:
|
||||
```bash
|
||||
git tag v4.0.0
|
||||
git push origin v4.0.0
|
||||
```
|
||||
|
||||
2. Gitea Actions will automatically:
|
||||
- Verify version consistency
|
||||
- Compile all platform binaries
|
||||
- Generate checksums
|
||||
- Create release with binaries attached
|
||||
|
||||
3. Monitor the workflow:
|
||||
- Go to: `https://code.foss.global/serve.zone/nupst/actions`
|
||||
- Check the "Release" workflow run
|
||||
|
||||
### Manual Release (Fallback)
|
||||
|
||||
If workflows fail, you can create a release manually:
|
||||
|
||||
```bash
|
||||
# Compile all binaries
|
||||
bash scripts/compile-all.sh
|
||||
|
||||
# Generate checksums
|
||||
cd dist/binaries
|
||||
sha256sum * > SHA256SUMS.txt
|
||||
cd ../..
|
||||
|
||||
# Create release on Gitea UI
|
||||
# Upload binaries manually
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Version Mismatch Error
|
||||
|
||||
If the release workflow fails with "Version mismatch":
|
||||
|
||||
- Ensure `deno.json` version matches the git tag
|
||||
- Example: tag `v4.0.0` requires `"version": "4.0.0"` in deno.json
|
||||
|
||||
### Compilation Errors
|
||||
|
||||
If compilation fails:
|
||||
|
||||
1. Test locally: `bash scripts/compile-all.sh`
|
||||
2. Check Deno version compatibility
|
||||
3. Review TypeScript errors: `deno check mod.ts`
|
||||
|
||||
### Upload Failures
|
||||
|
||||
If binary upload fails:
|
||||
|
||||
1. Check Gitea Actions permissions
|
||||
2. Verify `GITHUB_TOKEN` secret exists (auto-provided by Gitea)
|
||||
3. Try manual release creation
|
||||
|
||||
## Workflow Secrets
|
||||
|
||||
The workflows use the following secrets:
|
||||
|
||||
- `GITHUB_TOKEN` - Auto-provided by Gitea Actions (no setup needed)
|
||||
|
||||
## Development
|
||||
|
||||
### Testing Workflows Locally
|
||||
|
||||
You can test compilation locally:
|
||||
|
||||
```bash
|
||||
# Install Deno
|
||||
curl -fsSL https://deno.land/install.sh | sh
|
||||
|
||||
# Test type checking
|
||||
deno check mod.ts
|
||||
|
||||
# Test compilation
|
||||
bash scripts/compile-all.sh
|
||||
|
||||
# Test binary
|
||||
./dist/binaries/nupst-linux-x64 --version
|
||||
```
|
||||
|
||||
### Modifying Workflows
|
||||
|
||||
After modifying workflows:
|
||||
|
||||
1. Test syntax: Use a YAML validator
|
||||
2. Commit changes: `git add .gitea/workflows/`
|
||||
3. Push to feature branch first to test CI
|
||||
4. Merge to main once verified
|
||||
|
||||
## Links
|
||||
|
||||
- Gitea Actions Documentation: https://docs.gitea.com/usage/actions/overview
|
||||
- Deno Compile Documentation: https://docs.deno.com/runtime/manual/tools/compiler
|
||||
- NUPST Repository: https://code.foss.global/serve.zone/nupst
|
||||
@@ -0,0 +1,203 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
build-and-release:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: code.foss.global/host.today/ht-docker-node:latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Deno
|
||||
uses: denoland/setup-deno@v1
|
||||
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: |
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "version_number=${VERSION#v}" >> $GITHUB_OUTPUT
|
||||
echo "Building 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 all platforms
|
||||
run: mkdir -p dist/binaries && npx tsdeno compile
|
||||
|
||||
- name: Generate SHA256 checksums
|
||||
run: |
|
||||
cd dist/binaries
|
||||
sha256sum * > SHA256SUMS.txt
|
||||
cat SHA256SUMS.txt
|
||||
cd ../..
|
||||
|
||||
- name: Extract changelog for this version
|
||||
id: changelog
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
|
||||
if [ ! -f CHANGELOG.md ]; then
|
||||
echo "No CHANGELOG.md found, using default release notes"
|
||||
cat > /tmp/release_notes.md << EOF
|
||||
## NUPST $VERSION
|
||||
|
||||
Pre-compiled binaries for multiple platforms.
|
||||
|
||||
### Installation
|
||||
|
||||
Use the installation script:
|
||||
\`\`\`bash
|
||||
curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash
|
||||
\`\`\`
|
||||
|
||||
Or download the binary for your platform and make it executable.
|
||||
|
||||
### Supported Platforms
|
||||
- Linux x86_64 (x64)
|
||||
- Linux ARM64 (aarch64)
|
||||
- macOS x86_64 (Intel)
|
||||
- macOS ARM64 (Apple Silicon)
|
||||
- Windows x86_64
|
||||
|
||||
### Checksums
|
||||
SHA256 checksums are provided in SHA256SUMS.txt
|
||||
EOF
|
||||
else
|
||||
awk "/## \[$VERSION\]/,/## \[/" CHANGELOG.md | sed '$d' > /tmp/release_notes.md || cat > /tmp/release_notes.md << EOF
|
||||
## NUPST $VERSION
|
||||
|
||||
See CHANGELOG.md for full details.
|
||||
|
||||
### Installation
|
||||
|
||||
Use the installation script:
|
||||
\`\`\`bash
|
||||
curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash
|
||||
\`\`\`
|
||||
EOF
|
||||
fi
|
||||
|
||||
echo "Release notes:"
|
||||
cat /tmp/release_notes.md
|
||||
|
||||
- name: Delete existing release if it exists
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
|
||||
echo "Checking for existing release $VERSION..."
|
||||
|
||||
EXISTING_RELEASE_ID=$(curl -s \
|
||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
"https://code.foss.global/api/v1/repos/serve.zone/nupst/releases/tags/$VERSION" \
|
||||
| jq -r '.id // empty')
|
||||
|
||||
if [ -n "$EXISTING_RELEASE_ID" ]; then
|
||||
echo "Found existing release (ID: $EXISTING_RELEASE_ID), deleting..."
|
||||
curl -X DELETE -s \
|
||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
"https://code.foss.global/api/v1/repos/serve.zone/nupst/releases/$EXISTING_RELEASE_ID"
|
||||
echo "Existing release deleted"
|
||||
sleep 2
|
||||
else
|
||||
echo "No existing release found, proceeding with creation"
|
||||
fi
|
||||
|
||||
- name: Create Gitea Release
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
|
||||
echo "Creating release for $VERSION..."
|
||||
RELEASE_ID=$(curl -X POST -s \
|
||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"https://code.foss.global/api/v1/repos/serve.zone/nupst/releases" \
|
||||
-d "{
|
||||
\"tag_name\": \"$VERSION\",
|
||||
\"name\": \"NUPST $VERSION\",
|
||||
\"body\": $(jq -Rs . /tmp/release_notes.md),
|
||||
\"draft\": false,
|
||||
\"prerelease\": false
|
||||
}" | jq -r '.id')
|
||||
|
||||
echo "Release created with ID: $RELEASE_ID"
|
||||
|
||||
for binary in dist/binaries/*; do
|
||||
filename=$(basename "$binary")
|
||||
echo "Uploading $filename..."
|
||||
curl -X POST -s \
|
||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@$binary" \
|
||||
"https://code.foss.global/api/v1/repos/serve.zone/nupst/releases/$RELEASE_ID/assets?name=$filename"
|
||||
done
|
||||
|
||||
echo "All assets uploaded successfully"
|
||||
|
||||
- name: Clean up old releases
|
||||
run: |
|
||||
echo "Cleaning up old releases (keeping only last 3)..."
|
||||
|
||||
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')
|
||||
|
||||
if [ -n "$RELEASES" ]; then
|
||||
echo "Found releases to delete:"
|
||||
for release_id in $RELEASES; do
|
||||
echo " Deleting release ID: $release_id"
|
||||
curl -X DELETE -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
"https://code.foss.global/api/v1/repos/serve.zone/nupst/releases/$release_id"
|
||||
done
|
||||
echo "Old releases deleted successfully"
|
||||
else
|
||||
echo "No old releases to delete (less than 4 releases total)"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
- name: Release Summary
|
||||
run: |
|
||||
echo "================================================"
|
||||
echo " Release ${{ steps.version.outputs.version }} Complete!"
|
||||
echo "================================================"
|
||||
echo ""
|
||||
echo "Binaries published:"
|
||||
ls -lh dist/binaries/
|
||||
echo ""
|
||||
echo "Release URL:"
|
||||
echo "https://code.foss.global/serve.zone/nupst/releases/tag/${{ steps.version.outputs.version }}"
|
||||
echo ""
|
||||
echo "Installation command:"
|
||||
echo "curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash"
|
||||
echo ""
|
||||
+11
-7
@@ -1,15 +1,18 @@
|
||||
# Build
|
||||
dist*/
|
||||
# Compiled Deno binaries (built by scripts/compile-all.sh)
|
||||
dist/binaries/
|
||||
|
||||
# Dependencies
|
||||
# Deno cache and lock file
|
||||
.deno/
|
||||
deno.lock
|
||||
|
||||
# Legacy Node.js artifacts (v3.x and earlier - kept for safety)
|
||||
node_modules/
|
||||
|
||||
# Bundled Node.js binaries
|
||||
vendor/
|
||||
dist_ts/
|
||||
npm-debug.log*
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# Environment
|
||||
.env
|
||||
@@ -18,4 +21,5 @@ npm-debug.log*
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
.nogit/
|
||||
# Development
|
||||
.nogit/
|
||||
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
# Source code (not needed for binary distribution)
|
||||
/ts/
|
||||
/test/
|
||||
mod.ts
|
||||
*.ts
|
||||
|
||||
# Development files
|
||||
.git/
|
||||
.gitea/
|
||||
.claude/
|
||||
.serena/
|
||||
.nogit/
|
||||
.github/
|
||||
deno.json
|
||||
deno.lock
|
||||
tsconfig.json
|
||||
|
||||
# Scripts not needed for npm
|
||||
/scripts/compile-all.sh
|
||||
install.sh
|
||||
uninstall.sh
|
||||
example-action.sh
|
||||
|
||||
# Documentation files not needed for npm package
|
||||
readme.plan.md
|
||||
readme.hints.md
|
||||
npm-publish-instructions.md
|
||||
docs/
|
||||
|
||||
# IDE and editor files
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
|
||||
# Keep only the install-binary.js in scripts/
|
||||
/scripts/*
|
||||
!/scripts/install-binary.js
|
||||
|
||||
# Exclude all dist directory (binaries will be downloaded during install)
|
||||
/dist/
|
||||
|
||||
# Logs and temporary files
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Other
|
||||
node_modules/
|
||||
.env
|
||||
.env.*
|
||||
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"@git.zone/cli": {
|
||||
"release": {
|
||||
"registries": [
|
||||
"https://verdaccio.lossless.digital"
|
||||
],
|
||||
"accessLevel": "public"
|
||||
},
|
||||
"projectType": "deno",
|
||||
"module": {
|
||||
"githost": "code.foss.global",
|
||||
"gitscope": "serve.zone",
|
||||
"gitrepo": "nupst",
|
||||
"description": "shut down in time when the power goes out",
|
||||
"npmPackagename": "@serve.zone/nupst",
|
||||
"license": "MIT"
|
||||
}
|
||||
},
|
||||
"@git.zone/tsdeno": {
|
||||
"compileTargets": [
|
||||
{
|
||||
"name": "nupst-linux-x64",
|
||||
"entryPoint": "mod.ts",
|
||||
"outDir": "dist/binaries",
|
||||
"target": "x86_64-unknown-linux-gnu",
|
||||
"permissions": ["--allow-all"],
|
||||
"noCheck": true
|
||||
},
|
||||
{
|
||||
"name": "nupst-linux-arm64",
|
||||
"entryPoint": "mod.ts",
|
||||
"outDir": "dist/binaries",
|
||||
"target": "aarch64-unknown-linux-gnu",
|
||||
"permissions": ["--allow-all"],
|
||||
"noCheck": true
|
||||
},
|
||||
{
|
||||
"name": "nupst-macos-x64",
|
||||
"entryPoint": "mod.ts",
|
||||
"outDir": "dist/binaries",
|
||||
"target": "x86_64-apple-darwin",
|
||||
"permissions": ["--allow-all"],
|
||||
"noCheck": true
|
||||
},
|
||||
{
|
||||
"name": "nupst-macos-arm64",
|
||||
"entryPoint": "mod.ts",
|
||||
"outDir": "dist/binaries",
|
||||
"target": "aarch64-apple-darwin",
|
||||
"permissions": ["--allow-all"],
|
||||
"noCheck": true
|
||||
},
|
||||
{
|
||||
"name": "nupst-windows-x64",
|
||||
"entryPoint": "mod.ts",
|
||||
"outDir": "dist/binaries",
|
||||
"target": "x86_64-pc-windows-msvc",
|
||||
"permissions": ["--allow-all"],
|
||||
"noCheck": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"@ship.zone/szci": {}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# NUPST Launcher Script
|
||||
# This script detects architecture and OS, then runs NUPST with the appropriate Node.js binary
|
||||
|
||||
# First, handle symlinks correctly
|
||||
REAL_SCRIPT_PATH=$(readlink -f "${BASH_SOURCE[0]}")
|
||||
SCRIPT_DIR=$(dirname "$REAL_SCRIPT_PATH")
|
||||
|
||||
# For debugging
|
||||
# echo "Script path: $REAL_SCRIPT_PATH"
|
||||
# echo "Script dir: $SCRIPT_DIR"
|
||||
|
||||
# If we're run via symlink from /usr/local/bin, use the hardcoded installation path
|
||||
if [[ "$SCRIPT_DIR" == "/usr/local/bin" ]]; then
|
||||
PROJECT_ROOT="/opt/nupst"
|
||||
else
|
||||
# Otherwise, use relative path from script location
|
||||
PROJECT_ROOT="$( cd "$SCRIPT_DIR/.." &> /dev/null && pwd )"
|
||||
fi
|
||||
|
||||
# For debugging
|
||||
# echo "Project root: $PROJECT_ROOT"
|
||||
|
||||
# Set Node.js binary path directly
|
||||
NODE_BIN="$PROJECT_ROOT/vendor/node-linux-x64/bin/node"
|
||||
|
||||
# If binary doesn't exist, try system Node as fallback
|
||||
if [ ! -f "$NODE_BIN" ]; then
|
||||
if command -v node &> /dev/null; then
|
||||
NODE_BIN="node"
|
||||
echo "Using system Node.js installation"
|
||||
else
|
||||
echo "Error: Node.js binary not found at $NODE_BIN"
|
||||
echo "Please run the setup script or install Node.js manually."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Run NUPST with the Node.js binary
|
||||
if [ -f "$PROJECT_ROOT/dist_ts/index.js" ]; then
|
||||
exec "$NODE_BIN" "$PROJECT_ROOT/dist_ts/index.js" "$@"
|
||||
elif [ -f "$PROJECT_ROOT/dist/index.js" ]; then
|
||||
exec "$NODE_BIN" "$PROJECT_ROOT/dist/index.js" "$@"
|
||||
else
|
||||
echo "Error: Could not find NUPST's index.js file."
|
||||
echo "Please run the setup script to download the required files."
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,109 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* NUPST npm wrapper
|
||||
* This script executes the appropriate pre-compiled binary based on the current platform
|
||||
*/
|
||||
|
||||
import { spawn } from 'child_process';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
import { existsSync } from 'fs';
|
||||
import { arch, platform } from 'os';
|
||||
import process from 'node:process';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
/**
|
||||
* Get the binary name for the current platform
|
||||
*/
|
||||
function getBinaryName() {
|
||||
const plat = platform();
|
||||
const architecture = arch();
|
||||
|
||||
// Map Node's platform/arch to our binary naming
|
||||
const platformMap = {
|
||||
'darwin': 'macos',
|
||||
'linux': 'linux',
|
||||
'win32': 'windows',
|
||||
};
|
||||
|
||||
const archMap = {
|
||||
'x64': 'x64',
|
||||
'arm64': 'arm64',
|
||||
};
|
||||
|
||||
const mappedPlatform = platformMap[plat];
|
||||
const mappedArch = archMap[architecture];
|
||||
|
||||
if (!mappedPlatform || !mappedArch) {
|
||||
console.error(`Error: Unsupported platform/architecture: ${plat}/${architecture}`);
|
||||
console.error('Supported platforms: Linux, macOS, Windows');
|
||||
console.error('Supported architectures: x64, arm64');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Construct binary name
|
||||
let binaryName = `nupst-${mappedPlatform}-${mappedArch}`;
|
||||
if (plat === 'win32') {
|
||||
binaryName += '.exe';
|
||||
}
|
||||
|
||||
return binaryName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the binary
|
||||
*/
|
||||
function executeBinary() {
|
||||
const binaryName = getBinaryName();
|
||||
const binaryPath = join(__dirname, '..', 'dist', 'binaries', binaryName);
|
||||
|
||||
// Check if binary exists
|
||||
if (!existsSync(binaryPath)) {
|
||||
console.error(`Error: Binary not found at ${binaryPath}`);
|
||||
console.error('This might happen if:');
|
||||
console.error('1. The postinstall script failed to run');
|
||||
console.error('2. The platform is not supported');
|
||||
console.error('3. The package was not installed correctly');
|
||||
console.error('');
|
||||
console.error('Try reinstalling the package:');
|
||||
console.error(' npm uninstall -g @serve.zone/nupst');
|
||||
console.error(' npm install -g @serve.zone/nupst');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Spawn the binary with all arguments passed through
|
||||
const child = spawn(binaryPath, process.argv.slice(2), {
|
||||
stdio: 'inherit',
|
||||
shell: false,
|
||||
});
|
||||
|
||||
// Handle child process events
|
||||
child.on('error', (err) => {
|
||||
console.error(`Error executing nupst: ${err.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
if (signal) {
|
||||
process.kill(process.pid, signal);
|
||||
} else {
|
||||
process.exit(code || 0);
|
||||
}
|
||||
});
|
||||
|
||||
// Forward signals to child process
|
||||
const signals = ['SIGINT', 'SIGTERM', 'SIGHUP'];
|
||||
signals.forEach((signal) => {
|
||||
process.on(signal, () => {
|
||||
if (!child.killed) {
|
||||
child.kill(signal);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Execute
|
||||
executeBinary();
|
||||
+803
-39
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "@serve.zone/nupst",
|
||||
"version": "5.11.1",
|
||||
"exports": "./mod.ts",
|
||||
"nodeModulesDir": "auto",
|
||||
"tasks": {
|
||||
"dev": "deno run --allow-all mod.ts",
|
||||
"compile": "tsdeno compile",
|
||||
"test": "deno test --allow-all test/",
|
||||
"test:watch": "deno test --allow-all --watch test/",
|
||||
"check": "deno check mod.ts",
|
||||
"fmt": "deno fmt",
|
||||
"lint": "deno lint"
|
||||
},
|
||||
"lint": {
|
||||
"rules": {
|
||||
"tags": [
|
||||
"recommended"
|
||||
]
|
||||
}
|
||||
},
|
||||
"fmt": {
|
||||
"useTabs": false,
|
||||
"lineWidth": 100,
|
||||
"indentWidth": 2,
|
||||
"semiColons": true,
|
||||
"singleQuote": true
|
||||
},
|
||||
"compilerOptions": {
|
||||
"lib": [
|
||||
"deno.window"
|
||||
],
|
||||
"strict": true
|
||||
},
|
||||
"imports": {
|
||||
"@std/cli": "jsr:@std/cli@^1.0.0",
|
||||
"@std/fmt": "jsr:@std/fmt@^1.0.0",
|
||||
"@std/path": "jsr:@std/path@^1.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
#!/bin/bash
|
||||
# NUPST Action Script Example
|
||||
# Copy this to /etc/nupst/ and customize for your needs
|
||||
#
|
||||
# This script is called by NUPST when power events or threshold violations occur.
|
||||
# It receives UPS state information via environment variables and command-line arguments.
|
||||
|
||||
# ==============================================================================
|
||||
# ARGUMENTS (positional parameters)
|
||||
# ==============================================================================
|
||||
# $1 = Power Status (online|onBattery|unknown)
|
||||
# $2 = Battery Capacity (percentage, 0-100)
|
||||
# $3 = Battery Runtime (estimated minutes remaining)
|
||||
|
||||
POWER_STATUS=$1
|
||||
BATTERY_CAPACITY=$2
|
||||
BATTERY_RUNTIME=$3
|
||||
|
||||
# ==============================================================================
|
||||
# ENVIRONMENT VARIABLES
|
||||
# ==============================================================================
|
||||
# NUPST_UPS_ID - Unique UPS identifier
|
||||
# NUPST_UPS_NAME - Human-readable UPS name
|
||||
# NUPST_POWER_STATUS - Current power status
|
||||
# NUPST_BATTERY_CAPACITY - Battery percentage (0-100)
|
||||
# NUPST_BATTERY_RUNTIME - Estimated runtime in minutes
|
||||
# NUPST_THRESHOLDS_EXCEEDED - "true" if below configured thresholds
|
||||
# NUPST_TRIGGER_REASON - "powerStatusChange" or "thresholdViolation"
|
||||
# NUPST_BATTERY_THRESHOLD - Configured battery threshold percentage
|
||||
# NUPST_RUNTIME_THRESHOLD - Configured runtime threshold in minutes
|
||||
# NUPST_TIMESTAMP - Unix timestamp (milliseconds since epoch)
|
||||
|
||||
# ==============================================================================
|
||||
# EXAMPLE: Log the event
|
||||
# ==============================================================================
|
||||
LOG_FILE="/var/log/nupst-actions.log"
|
||||
|
||||
echo "========================================" >> "$LOG_FILE"
|
||||
echo "NUPST Action Triggered: $(date)" >> "$LOG_FILE"
|
||||
echo "----------------------------------------" >> "$LOG_FILE"
|
||||
echo "UPS: $NUPST_UPS_NAME ($NUPST_UPS_ID)" >> "$LOG_FILE"
|
||||
echo "Power Status: $POWER_STATUS" >> "$LOG_FILE"
|
||||
echo "Battery: $BATTERY_CAPACITY%" >> "$LOG_FILE"
|
||||
echo "Runtime: $BATTERY_RUNTIME minutes" >> "$LOG_FILE"
|
||||
echo "Trigger Reason: $NUPST_TRIGGER_REASON" >> "$LOG_FILE"
|
||||
echo "Thresholds Exceeded: $NUPST_THRESHOLDS_EXCEEDED" >> "$LOG_FILE"
|
||||
echo "========================================" >> "$LOG_FILE"
|
||||
|
||||
# ==============================================================================
|
||||
# EXAMPLE: Send email notification
|
||||
# ==============================================================================
|
||||
# if [ "$NUPST_TRIGGER_REASON" = "thresholdViolation" ]; then
|
||||
# echo "ALERT: UPS $NUPST_UPS_NAME battery critical!" | \
|
||||
# mail -s "UPS Battery Critical" admin@example.com
|
||||
# fi
|
||||
|
||||
# ==============================================================================
|
||||
# EXAMPLE: Gracefully shutdown virtual machines
|
||||
# ==============================================================================
|
||||
# if [ "$NUPST_POWER_STATUS" = "onBattery" ] && [ "$NUPST_THRESHOLDS_EXCEEDED" = "true" ]; then
|
||||
# echo "Shutting down VMs..." >> "$LOG_FILE"
|
||||
# # virsh shutdown vm1
|
||||
# # virsh shutdown vm2
|
||||
# # Wait for VMs to shutdown
|
||||
# # sleep 120
|
||||
# fi
|
||||
|
||||
# ==============================================================================
|
||||
# EXAMPLE: Call external API/service
|
||||
# ==============================================================================
|
||||
# curl -X POST https://monitoring.example.com/ups-alert \
|
||||
# -H "Content-Type: application/json" \
|
||||
# -d "{
|
||||
# \"upsId\": \"$NUPST_UPS_ID\",
|
||||
# \"upsName\": \"$NUPST_UPS_NAME\",
|
||||
# \"powerStatus\": \"$POWER_STATUS\",
|
||||
# \"batteryCapacity\": $BATTERY_CAPACITY,
|
||||
# \"batteryRuntime\": $BATTERY_RUNTIME,
|
||||
# \"triggerReason\": \"$NUPST_TRIGGER_REASON\"
|
||||
# }"
|
||||
|
||||
# ==============================================================================
|
||||
# EXAMPLE: Remote shutdown via SSH with password
|
||||
# ==============================================================================
|
||||
# You can implement custom shutdown logic for remote systems
|
||||
# that require password authentication or webhooks
|
||||
#
|
||||
# if [ "$NUPST_THRESHOLDS_EXCEEDED" = "true" ]; then
|
||||
# # Call a webhook with a secret password/token
|
||||
# curl -X POST "https://remote-server.local/shutdown?token=YOUR_SECRET_TOKEN"
|
||||
#
|
||||
# # Or use SSH with password (requires sshpass)
|
||||
# # sshpass -p 'your-password' ssh user@remote-server 'sudo shutdown -h +5'
|
||||
# fi
|
||||
|
||||
# ==============================================================================
|
||||
# EXAMPLE: Conditional logic based on battery level
|
||||
# ==============================================================================
|
||||
# if [ "$BATTERY_CAPACITY" -lt 20 ]; then
|
||||
# echo "Battery critically low! Immediate action needed." >> "$LOG_FILE"
|
||||
# elif [ "$BATTERY_CAPACITY" -lt 50 ]; then
|
||||
# echo "Battery low. Preparing for shutdown." >> "$LOG_FILE"
|
||||
# else
|
||||
# echo "Battery acceptable. Monitoring." >> "$LOG_FILE"
|
||||
# fi
|
||||
|
||||
# ==============================================================================
|
||||
# EXAMPLE: Different actions for different trigger reasons
|
||||
# ==============================================================================
|
||||
# case "$NUPST_TRIGGER_REASON" in
|
||||
# powerStatusChange)
|
||||
# echo "Power status changed to: $POWER_STATUS" >> "$LOG_FILE"
|
||||
# # Send notification but don't take drastic action yet
|
||||
# ;;
|
||||
# thresholdViolation)
|
||||
# echo "Thresholds violated! Taking emergency action." >> "$LOG_FILE"
|
||||
# # Initiate graceful shutdowns, save data, etc.
|
||||
# ;;
|
||||
# esac
|
||||
|
||||
# Exit with success
|
||||
exit 0
|
||||
+230
-242
@@ -1,44 +1,69 @@
|
||||
#!/bin/bash
|
||||
|
||||
# NUPST Installer Script
|
||||
# Downloads and installs NUPST globally on the system
|
||||
# Can be used directly with curl:
|
||||
# Without auto-installing dependencies:
|
||||
# curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash
|
||||
# With auto-installing dependencies:
|
||||
# curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash -s -- -y
|
||||
#
|
||||
# NUPST Installer Script (v5.0+)
|
||||
# Downloads and installs pre-compiled NUPST binary from Gitea releases
|
||||
#
|
||||
# Usage:
|
||||
# Direct piped installation (recommended):
|
||||
# curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash
|
||||
#
|
||||
# With version specification:
|
||||
# curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash -s -- --version v5.0.0
|
||||
#
|
||||
# Options:
|
||||
# -y, --yes Automatically answer yes to all prompts
|
||||
# -h, --help Show this help message
|
||||
# -h, --help Show this help message
|
||||
# --version VERSION Install specific version (e.g., v4.0.0)
|
||||
# --install-dir DIR Installation directory (default: /opt/nupst)
|
||||
|
||||
set -e
|
||||
|
||||
# Default values
|
||||
SHOW_HELP=0
|
||||
SPECIFIED_VERSION=""
|
||||
INSTALL_DIR="/opt/nupst"
|
||||
GITEA_BASE_URL="https://code.foss.global"
|
||||
GITEA_REPO="serve.zone/nupst"
|
||||
|
||||
# Parse command line arguments
|
||||
AUTO_YES=0
|
||||
SHOW_HELP=0
|
||||
|
||||
for arg in "$@"; do
|
||||
case $arg in
|
||||
-y|--yes)
|
||||
AUTO_YES=1
|
||||
shift
|
||||
;;
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
-h|--help)
|
||||
SHOW_HELP=1
|
||||
shift
|
||||
;;
|
||||
--version)
|
||||
SPECIFIED_VERSION="$2"
|
||||
shift 2
|
||||
;;
|
||||
--install-dir)
|
||||
INSTALL_DIR="$2"
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
# Unknown option
|
||||
echo "Unknown option: $1"
|
||||
echo "Use -h or --help for usage information"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ $SHOW_HELP -eq 1 ]; then
|
||||
echo "NUPST Installer Script"
|
||||
echo "NUPST Installer Script (v5.0+)"
|
||||
echo "Downloads and installs pre-compiled NUPST binary"
|
||||
echo ""
|
||||
echo "Usage: $0 [options]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " -y, --yes Automatically answer yes to all prompts"
|
||||
echo " -h, --help Show this help message"
|
||||
echo " -h, --help Show this help message"
|
||||
echo " --version VERSION Install specific version (e.g., v5.0.0)"
|
||||
echo " --install-dir DIR Installation directory (default: /opt/nupst)"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " # Install latest version"
|
||||
echo " curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash"
|
||||
echo ""
|
||||
echo " # Install specific version"
|
||||
echo " curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash -s -- --version v5.0.0"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -48,249 +73,212 @@ if [ "$EUID" -ne 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Detect if script is being piped or run directly
|
||||
PIPED=0
|
||||
INTERACTIVE=1
|
||||
if [ ! -t 0 ]; then
|
||||
# Being piped, need to clone the repo
|
||||
PIPED=1
|
||||
fi
|
||||
# Helper function to detect OS and architecture
|
||||
detect_platform() {
|
||||
local os=$(uname -s)
|
||||
local arch=$(uname -m)
|
||||
|
||||
# Check if stdin is a terminal
|
||||
if [ ! -t 0 ] || [ ! -t 1 ]; then
|
||||
# Either stdin or stdout is not a terminal, check if -y was provided
|
||||
if [ $AUTO_YES -ne 1 ]; then
|
||||
echo "Script detected it's running in a non-interactive environment without -y flag."
|
||||
echo "Attempting to find a controlling terminal for interactive prompts..."
|
||||
# Try to use a controlling terminal for user input
|
||||
if [ -t 1 ]; then
|
||||
# Stdout is a terminal, use it
|
||||
exec < /dev/tty 2>/dev/null || INTERACTIVE=0
|
||||
else
|
||||
# Try to find controlling terminal
|
||||
exec < /dev/tty 2>/dev/null || INTERACTIVE=0
|
||||
fi
|
||||
|
||||
if [ $INTERACTIVE -eq 0 ]; then
|
||||
echo "ERROR: No controlling terminal available for interactive prompts."
|
||||
echo "For interactive installation (RECOMMENDED):"
|
||||
echo " curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh -o nupst-install.sh"
|
||||
echo " sudo bash nupst-install.sh"
|
||||
echo ""
|
||||
echo "For non-interactive installation with automatic dependency installation:"
|
||||
echo " curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash -s -- -y"
|
||||
exit 1
|
||||
else
|
||||
echo "Interactive terminal found, continuing with prompts..."
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Helper function to detect OS type
|
||||
detect_os() {
|
||||
if [ -f /etc/os-release ]; then
|
||||
. /etc/os-release
|
||||
OS=$ID
|
||||
elif type lsb_release >/dev/null 2>&1; then
|
||||
OS=$(lsb_release -si | tr '[:upper:]' '[:lower:]')
|
||||
elif [ -f /etc/lsb-release ]; then
|
||||
. /etc/lsb-release
|
||||
OS=$DISTRIB_ID
|
||||
elif [ -f /etc/debian_version ]; then
|
||||
OS="debian"
|
||||
elif [ -f /etc/redhat-release ]; then
|
||||
if grep -q "CentOS" /etc/redhat-release; then
|
||||
OS="centos"
|
||||
elif grep -q "Fedora" /etc/redhat-release; then
|
||||
OS="fedora"
|
||||
else
|
||||
OS="rhel"
|
||||
fi
|
||||
else
|
||||
OS=$(uname -s)
|
||||
fi
|
||||
echo $OS
|
||||
}
|
||||
|
||||
# Helper function to install git
|
||||
install_git() {
|
||||
OS=$(detect_os)
|
||||
echo "Detected OS: $OS"
|
||||
|
||||
case "$OS" in
|
||||
ubuntu|debian|pop|mint|elementary|kali|zorin)
|
||||
echo "Installing git using apt..."
|
||||
apt-get update && apt-get install -y git
|
||||
# Map OS
|
||||
case "$os" in
|
||||
Linux)
|
||||
os_name="linux"
|
||||
;;
|
||||
fedora|rhel|centos|almalinux|rocky)
|
||||
echo "Installing git using dnf/yum..."
|
||||
if command -v dnf &> /dev/null; then
|
||||
dnf install -y git
|
||||
else
|
||||
yum install -y git
|
||||
fi
|
||||
Darwin)
|
||||
os_name="macos"
|
||||
;;
|
||||
arch|manjaro|endeavouros|garuda)
|
||||
echo "Installing git using pacman..."
|
||||
pacman -Sy --noconfirm git
|
||||
;;
|
||||
opensuse*|suse|sles)
|
||||
echo "Installing git using zypper..."
|
||||
zypper install -y git
|
||||
;;
|
||||
alpine)
|
||||
echo "Installing git using apk..."
|
||||
apk add git
|
||||
MINGW*|MSYS*|CYGWIN*)
|
||||
os_name="windows"
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported OS: $OS"
|
||||
echo "Please install git manually and run the installer again."
|
||||
echo "Error: Unsupported operating system: $os"
|
||||
echo "Supported: Linux, macOS, Windows"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Check if git was installed successfully
|
||||
if ! command -v git &> /dev/null; then
|
||||
echo "Failed to install git. Please install git manually and run the installer again."
|
||||
exit 1
|
||||
|
||||
# Map architecture
|
||||
case "$arch" in
|
||||
x86_64|amd64)
|
||||
arch_name="x64"
|
||||
;;
|
||||
aarch64|arm64)
|
||||
arch_name="arm64"
|
||||
;;
|
||||
*)
|
||||
echo "Error: Unsupported architecture: $arch"
|
||||
echo "Supported: x86_64/amd64 (x64), aarch64/arm64 (arm64)"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Construct binary name
|
||||
if [ "$os_name" = "windows" ]; then
|
||||
echo "nupst-${os_name}-${arch_name}.exe"
|
||||
else
|
||||
echo "nupst-${os_name}-${arch_name}"
|
||||
fi
|
||||
|
||||
echo "Git installed successfully."
|
||||
}
|
||||
|
||||
# Define installation directory
|
||||
INSTALL_DIR="/opt/nupst"
|
||||
REPO_URL="https://code.foss.global/serve.zone/nupst.git"
|
||||
# Get latest release version from Gitea API
|
||||
get_latest_version() {
|
||||
echo "Fetching latest release version from Gitea..." >&2
|
||||
|
||||
# Check if git is installed - needed for both piped and direct execution
|
||||
if ! command -v git &> /dev/null; then
|
||||
echo "Git is required but not installed."
|
||||
|
||||
if [ $AUTO_YES -eq 1 ]; then
|
||||
echo "Auto-installing git (-y flag provided)..."
|
||||
install_git
|
||||
elif [ $INTERACTIVE -eq 1 ]; then
|
||||
# If interactive and no -y flag, ask the user
|
||||
echo "Would you like to install git now? (y/N): "
|
||||
read -r install_git_prompt
|
||||
|
||||
if [[ "$install_git_prompt" =~ ^[Yy]$ ]]; then
|
||||
install_git
|
||||
else
|
||||
echo "Git installation skipped. Please install git manually and run the installer again."
|
||||
echo "Alternatively, you can run the installer with -y flag to automatically install git:"
|
||||
echo " sudo bash install.sh -y"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
# Non-interactive mode without -y flag
|
||||
echo "Error: Git is required but not installed."
|
||||
echo "In non-interactive mode, use -y flag to auto-install dependencies:"
|
||||
echo " curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash -s -- -y"
|
||||
local api_url="${GITEA_BASE_URL}/api/v1/repos/${GITEA_REPO}/releases/latest"
|
||||
local response=$(curl -sSL "$api_url" 2>/dev/null)
|
||||
|
||||
if [ $? -ne 0 ] || [ -z "$response" ]; then
|
||||
echo "Error: Failed to fetch latest release information from Gitea API" >&2
|
||||
echo "URL: $api_url" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ $PIPED -eq 1 ]; then
|
||||
echo "Installing NUPST from remote repository..."
|
||||
|
||||
# Check if installation directory exists
|
||||
if [ -d "$INSTALL_DIR" ] && [ -d "$INSTALL_DIR/.git" ]; then
|
||||
echo "Existing installation found at $INSTALL_DIR. Updating..."
|
||||
cd "$INSTALL_DIR"
|
||||
|
||||
# Try to update the repository
|
||||
git fetch origin
|
||||
git reset --hard origin/main
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Failed to update repository. Reinstalling..."
|
||||
cd /
|
||||
rm -rf "$INSTALL_DIR"
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
git clone --depth 1 $REPO_URL "$INSTALL_DIR"
|
||||
else
|
||||
echo "Repository updated successfully."
|
||||
fi
|
||||
else
|
||||
# Fresh installation
|
||||
if [ -d "$INSTALL_DIR" ]; then
|
||||
echo "Removing previous installation at $INSTALL_DIR..."
|
||||
rm -rf "$INSTALL_DIR"
|
||||
fi
|
||||
|
||||
# Create installation directory
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
|
||||
# Clone the repository
|
||||
echo "Cloning NUPST repository to $INSTALL_DIR..."
|
||||
git clone --depth 1 $REPO_URL "$INSTALL_DIR"
|
||||
fi
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Failed to clone/update repository. Please check your internet connection."
|
||||
# Extract tag_name from JSON response
|
||||
local version=$(echo "$response" | grep -o '"tag_name":"[^"]*"' | cut -d'"' -f4)
|
||||
|
||||
if [ -z "$version" ]; then
|
||||
echo "Error: Could not determine latest version from API response" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Set script directory to the cloned repo
|
||||
SCRIPT_DIR="$INSTALL_DIR"
|
||||
|
||||
echo "$version"
|
||||
}
|
||||
|
||||
# Main installation process
|
||||
echo "================================================"
|
||||
echo " NUPST Installation Script (v5.0+)"
|
||||
echo "================================================"
|
||||
echo ""
|
||||
|
||||
# Detect platform
|
||||
BINARY_NAME=$(detect_platform)
|
||||
echo "Detected platform: $BINARY_NAME"
|
||||
echo ""
|
||||
|
||||
# Determine version to install
|
||||
if [ -n "$SPECIFIED_VERSION" ]; then
|
||||
VERSION="$SPECIFIED_VERSION"
|
||||
echo "Installing specified version: $VERSION"
|
||||
else
|
||||
# Running directly from within the repo or downloaded script
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
|
||||
|
||||
# When running from a downloaded script in a different location
|
||||
# we need to clone the repository first
|
||||
if [ ! -f "$SCRIPT_DIR/setup.sh" ]; then
|
||||
echo "Running installer from downloaded script outside repository."
|
||||
echo "Will clone the repository to $INSTALL_DIR..."
|
||||
|
||||
# Create installation directory if needed
|
||||
if [ -d "$INSTALL_DIR" ]; then
|
||||
echo "Removing previous installation at $INSTALL_DIR..."
|
||||
rm -rf "$INSTALL_DIR"
|
||||
fi
|
||||
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
|
||||
# Clone the repository
|
||||
echo "Cloning NUPST repository to $INSTALL_DIR..."
|
||||
git clone --depth 1 $REPO_URL "$INSTALL_DIR"
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Failed to clone repository. Please check your internet connection."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Update script directory to use the cloned repo
|
||||
SCRIPT_DIR="$INSTALL_DIR"
|
||||
VERSION=$(get_latest_version)
|
||||
echo "Installing latest version: $VERSION"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Construct download URL
|
||||
DOWNLOAD_URL="${GITEA_BASE_URL}/${GITEA_REPO}/releases/download/${VERSION}/${BINARY_NAME}"
|
||||
echo "Download URL: $DOWNLOAD_URL"
|
||||
echo ""
|
||||
|
||||
# Check if service is running and stop it
|
||||
SERVICE_WAS_RUNNING=0
|
||||
if systemctl is-enabled --quiet nupst 2>/dev/null || systemctl is-active --quiet nupst 2>/dev/null; then
|
||||
SERVICE_WAS_RUNNING=1
|
||||
if systemctl is-active --quiet nupst 2>/dev/null; then
|
||||
echo "Stopping NUPST service..."
|
||||
systemctl stop nupst
|
||||
fi
|
||||
fi
|
||||
|
||||
# Run setup script
|
||||
echo "Running setup script..."
|
||||
if [ ! -f "$SCRIPT_DIR/setup.sh" ]; then
|
||||
echo "ERROR: Setup script not found at $SCRIPT_DIR/setup.sh"
|
||||
echo "Current directory: $(pwd)"
|
||||
echo "Script directory: $SCRIPT_DIR"
|
||||
ls -la "$SCRIPT_DIR"
|
||||
# Clean installation directory - ensure only binary exists
|
||||
if [ -d "$INSTALL_DIR" ]; then
|
||||
echo "Cleaning installation directory: $INSTALL_DIR"
|
||||
rm -rf "$INSTALL_DIR"
|
||||
fi
|
||||
|
||||
# Create fresh installation directory
|
||||
echo "Creating installation directory: $INSTALL_DIR"
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
|
||||
# Download binary
|
||||
echo "Downloading NUPST binary..."
|
||||
TEMP_FILE="$INSTALL_DIR/nupst.download"
|
||||
curl -sSL "$DOWNLOAD_URL" -o "$TEMP_FILE"
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Error: Failed to download binary from $DOWNLOAD_URL"
|
||||
echo ""
|
||||
echo "Please check:"
|
||||
echo " 1. Your internet connection"
|
||||
echo " 2. The specified version exists: ${GITEA_BASE_URL}/${GITEA_REPO}/releases"
|
||||
echo " 3. The platform binary is available for this release"
|
||||
rm -f "$TEMP_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
bash "$SCRIPT_DIR/setup.sh"
|
||||
|
||||
# Install globally
|
||||
echo "Installing NUPST globally..."
|
||||
ln -sf "$SCRIPT_DIR/bin/nupst" /usr/local/bin/nupst
|
||||
|
||||
# Installation completed
|
||||
if [ $PIPED -eq 1 ]; then
|
||||
echo "NUPST has been installed globally at $INSTALL_DIR"
|
||||
else
|
||||
echo "NUPST has been installed globally."
|
||||
# Check if download was successful (file exists and not empty)
|
||||
if [ ! -s "$TEMP_FILE" ]; then
|
||||
echo "Error: Downloaded file is empty or does not exist"
|
||||
rm -f "$TEMP_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "You can now run 'nupst' from anywhere."
|
||||
# Move to final location
|
||||
BINARY_PATH="$INSTALL_DIR/nupst"
|
||||
mv "$TEMP_FILE" "$BINARY_PATH"
|
||||
|
||||
if [ $? -ne 0 ] || [ ! -f "$BINARY_PATH" ]; then
|
||||
echo "Error: Failed to move binary to $BINARY_PATH"
|
||||
rm -f "$TEMP_FILE" 2>/dev/null
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Make executable
|
||||
chmod +x "$BINARY_PATH"
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Error: Failed to make binary executable"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Binary installed successfully to: $BINARY_PATH"
|
||||
echo ""
|
||||
|
||||
# Check if /usr/local/bin is in PATH
|
||||
if [[ ":$PATH:" == *":/usr/local/bin:"* ]]; then
|
||||
BIN_DIR="/usr/local/bin"
|
||||
else
|
||||
BIN_DIR="/usr/bin"
|
||||
fi
|
||||
|
||||
# Create symlink for global access
|
||||
ln -sf "$BINARY_PATH" "$BIN_DIR/nupst"
|
||||
echo "Symlink created: $BIN_DIR/nupst -> $BINARY_PATH"
|
||||
|
||||
echo ""
|
||||
|
||||
# Restart service if it was running before update
|
||||
if [ $SERVICE_WAS_RUNNING -eq 1 ]; then
|
||||
echo "Restarting NUPST service..."
|
||||
systemctl restart nupst
|
||||
echo "Service restarted successfully."
|
||||
echo ""
|
||||
fi
|
||||
|
||||
echo "================================================"
|
||||
echo " NUPST Installation Complete!"
|
||||
echo "================================================"
|
||||
echo ""
|
||||
echo "Installation details:"
|
||||
echo " Binary location: $BINARY_PATH"
|
||||
echo " Symlink location: $BIN_DIR/nupst"
|
||||
echo " Version: $VERSION"
|
||||
echo ""
|
||||
|
||||
# Check if configuration exists
|
||||
if [ -f "/etc/nupst/config.json" ]; then
|
||||
echo "Configuration: /etc/nupst/config.json (preserved)"
|
||||
echo ""
|
||||
echo "Your existing configuration has been preserved."
|
||||
if [ $SERVICE_WAS_RUNNING -eq 1 ]; then
|
||||
echo "The service has been restarted with your current settings."
|
||||
else
|
||||
echo "Start the service with: sudo nupst service start"
|
||||
fi
|
||||
else
|
||||
echo "Get started:"
|
||||
echo " nupst --version"
|
||||
echo " nupst help"
|
||||
echo " nupst ups add # Add a UPS device"
|
||||
echo " nupst service enable # Enable systemd service"
|
||||
fi
|
||||
echo ""
|
||||
echo "To get started, try:"
|
||||
echo " nupst help"
|
||||
echo " nupst setup # To configure your UPS connection"
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env -S deno run --allow-all
|
||||
|
||||
/**
|
||||
* NUPST - UPS Shutdown Tool
|
||||
*
|
||||
* A command-line tool for monitoring SNMP-enabled UPS devices and
|
||||
* initiating system shutdown when power conditions are critical.
|
||||
*
|
||||
* Required Permissions:
|
||||
* - --allow-net: SNMP communication with UPS devices
|
||||
* - --allow-read: Read configuration files (/etc/nupst/config.json)
|
||||
* - --allow-write: Write configuration files
|
||||
* - --allow-run: Execute system commands (systemctl, shutdown, git, bash)
|
||||
* - --allow-sys: Access system information (hostname, OS details)
|
||||
* - --allow-env: Read environment variables
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { NupstCli } from './ts/cli.ts';
|
||||
|
||||
/**
|
||||
* Main entry point for the NUPST application
|
||||
* Parses command-line arguments and executes the requested command
|
||||
*/
|
||||
async function main(): Promise<void> {
|
||||
const cli = new NupstCli();
|
||||
await cli.parseAndExecute(Deno.args);
|
||||
}
|
||||
|
||||
// Execute main and handle errors
|
||||
if (import.meta.main) {
|
||||
try {
|
||||
await main();
|
||||
} catch (error) {
|
||||
console.error(`Error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
Deno.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
+56
-45
@@ -1,58 +1,69 @@
|
||||
{
|
||||
"name": "@serve.zone/nupst",
|
||||
"version": "2.4.8",
|
||||
"description": "Node.js UPS Shutdown Tool for SNMP-enabled UPS devices",
|
||||
"main": "dist/index.js",
|
||||
"bin": {
|
||||
"nupst": "bin/nupst"
|
||||
},
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsbuild tsfolders --allowimplicitany",
|
||||
"start": "bin/nupst",
|
||||
"setup": "bash setup.sh",
|
||||
"test": "tstest test/",
|
||||
"install-global": "sudo bash install.sh",
|
||||
"uninstall": "sudo bash uninstall.sh"
|
||||
},
|
||||
"version": "5.11.1",
|
||||
"description": "Network UPS Shutdown Tool - Monitor SNMP-enabled UPS devices and orchestrate graceful system shutdowns during power emergencies",
|
||||
"keywords": [
|
||||
"ups",
|
||||
"snmp",
|
||||
"power",
|
||||
"shutdown",
|
||||
"node",
|
||||
"cli"
|
||||
"monitoring",
|
||||
"cyberpower",
|
||||
"apc",
|
||||
"eaton",
|
||||
"tripplite",
|
||||
"liebert",
|
||||
"vertiv",
|
||||
"battery",
|
||||
"backup"
|
||||
],
|
||||
"files": [
|
||||
"ts/**/*",
|
||||
"ts_web/**/*",
|
||||
"dist/**/*",
|
||||
"dist_*/**/*",
|
||||
"dist_ts/**/*",
|
||||
"dist_ts_web/**/*",
|
||||
"assets/**/*",
|
||||
"cli.js",
|
||||
"npmextra.json",
|
||||
"readme.md"
|
||||
],
|
||||
"author": "",
|
||||
"homepage": "https://code.foss.global/serve.zone/nupst",
|
||||
"bugs": {
|
||||
"url": "https://code.foss.global/serve.zone/nupst/issues"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://code.foss.global/serve.zone/nupst.git"
|
||||
},
|
||||
"author": "Serve Zone",
|
||||
"license": "MIT",
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
"@git.zone/tsbuild": "^2.3.2",
|
||||
"@git.zone/tsrun": "^1.3.3",
|
||||
"@git.zone/tstest": "^1.0.96",
|
||||
"@push.rocks/qenv": "^6.1.0",
|
||||
"@push.rocks/tapbundle": "^5.6.0",
|
||||
"@types/node": "^20.11.0"
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"nupst": "./bin/nupst-wrapper.js"
|
||||
},
|
||||
"scripts": {
|
||||
"postinstall": "node scripts/install-binary.js",
|
||||
"prepublishOnly": "echo 'Publishing NUPST binaries to npm...'",
|
||||
"test": "deno task test",
|
||||
"build": "deno task check",
|
||||
"lint": "deno task lint",
|
||||
"format": "deno task fmt"
|
||||
},
|
||||
"files": [
|
||||
"bin/",
|
||||
"scripts/install-binary.js",
|
||||
"readme.md",
|
||||
"license",
|
||||
"changelog.md"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"esbuild",
|
||||
"mongodb-memory-server",
|
||||
"puppeteer"
|
||||
]
|
||||
"os": [
|
||||
"darwin",
|
||||
"linux",
|
||||
"win32"
|
||||
],
|
||||
"cpu": [
|
||||
"x64",
|
||||
"arm64"
|
||||
],
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
},
|
||||
"packageManager": "pnpm@10.18.1+sha512.77a884a165cbba2d8d1c19e3b4880eee6d2fcabd0d879121e282196b80042351d5eb3ca0935fa599da1dc51265cc68816ad2bddd2a2de5ea9fdf92adbec7cd34",
|
||||
"devDependencies": {
|
||||
"@git.zone/tsdeno": "^1.3.1"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+476
-8339
File diff suppressed because it is too large
Load Diff
+172
@@ -0,0 +1,172 @@
|
||||
# NUPST Project Hints
|
||||
|
||||
## Recent Refactoring (January 2026)
|
||||
|
||||
### Phase 1 - Quick Wins
|
||||
|
||||
1. **Prompt Utility (`ts/helpers/prompt.ts`)**
|
||||
- Extracted readline/prompt pattern from all CLI handlers
|
||||
- Provides `createPrompt()` and `withPrompt()` helper functions
|
||||
- Used in: `ups-handler.ts`, `group-handler.ts`, `service-handler.ts`, `action-handler.ts`,
|
||||
`feature-handler.ts`
|
||||
|
||||
2. **Constants File (`ts/constants.ts`)**
|
||||
- Centralized all magic numbers (timeouts, intervals, thresholds)
|
||||
- Contains: `TIMING`, `SNMP`, `THRESHOLDS`, `WEBHOOK`, `SCRIPT`, `SHUTDOWN`, `HTTP_SERVER`, `UI`,
|
||||
`NETWORK`, `UPSD`, `PAUSE`, `PROXMOX`
|
||||
- Used in: `daemon.ts`, `snmp/manager.ts`, `actions/*.ts`, `upsd/client.ts`
|
||||
|
||||
3. **Logger Consistency**
|
||||
- Replaced all `console.log/console.error` in `snmp/manager.ts` with proper `logger.*` calls
|
||||
- Debug output uses `logger.dim()` for less intrusive output
|
||||
|
||||
### Phase 2 - Type Safety
|
||||
|
||||
4. **Circular Dependency Fix (`ts/interfaces/nupst-accessor.ts`)**
|
||||
- Created `INupstAccessor` interface to break the circular dependency between `Nupst` and
|
||||
`NupstSnmp`
|
||||
- `NupstSnmp.nupst` property now uses the interface instead of `any`
|
||||
|
||||
5. **Webhook Payload Interface (`ts/actions/webhook-action.ts`)**
|
||||
- Added `IWebhookPayload` interface for webhook action payloads
|
||||
- Exported from `ts/actions/index.ts`
|
||||
|
||||
6. **CLI Handler Type Safety**
|
||||
- Replaced `any` types in `ups-handler.ts` and `group-handler.ts` with proper interfaces
|
||||
- Uses: `IUpsConfig`, `INupstConfig`, `ISnmpConfig`, `IActionConfig`, `IThresholds`,
|
||||
`ISnmpUpsStatus`
|
||||
|
||||
7. **SNMP Manager Boundary Types (`ts/snmp/manager.ts`)**
|
||||
- Added local wrapper interfaces for the untyped `net-snmp` package surface used by NUPST
|
||||
- SNMP metric reads now coerce values explicitly instead of relying on `any`-typed responses
|
||||
|
||||
## Features Added (February 2026)
|
||||
|
||||
### Network Loss Handling
|
||||
|
||||
- `TPowerStatus` extended with `'unreachable'` state
|
||||
- `IUpsStatus` has `consecutiveFailures` and `unreachableSince` tracking
|
||||
- After `NETWORK.CONSECUTIVE_FAILURE_THRESHOLD` (3) failures, UPS transitions to `unreachable`
|
||||
- Shutdown action explicitly won't fire on `unreachable` (prevents false shutdowns)
|
||||
- Recovery is logged when UPS comes back from unreachable
|
||||
|
||||
### UPSD/NIS Protocol Support
|
||||
|
||||
- New `ts/upsd/` directory with TCP client for NUT (Network UPS Tools) servers
|
||||
- `ts/protocol/` directory with `ProtocolResolver` for protocol-agnostic status queries
|
||||
- `IUpsConfig.protocol` field: `'snmp'` (default) or `'upsd'`
|
||||
- `IUpsConfig.snmp` is now optional (not needed for UPSD devices)
|
||||
- CLI supports protocol selection during `nupst ups add`
|
||||
- Config version is now `4.3`, including the `4.2` -> `4.3` runtime unit migration
|
||||
|
||||
### Pause/Resume Command
|
||||
|
||||
- File-based signaling via `/etc/nupst/pause` JSON file
|
||||
- `nupst pause [--duration 30m|2h|1d]` creates pause file
|
||||
- `nupst resume` deletes pause file
|
||||
- `ts/pause-state.ts` owns pause snapshot parsing and transition detection for daemon polling
|
||||
- Daemon polls continue but actions are suppressed while paused
|
||||
- Auto-resume after duration expires
|
||||
- HTTP API includes pause state in response
|
||||
|
||||
### Shutdown Orchestration
|
||||
|
||||
- `ts/shutdown-executor.ts` owns command discovery and fallback execution for delayed and emergency
|
||||
shutdowns
|
||||
- `ts/daemon.ts` now delegates OS shutdown execution instead of embedding command lookup logic
|
||||
inline
|
||||
- `defaultShutdownDelay` in config provides the inherited delay for shutdown actions without an
|
||||
explicit `shutdownDelay` override
|
||||
|
||||
### Config Watch Handling
|
||||
|
||||
- `ts/config-watch.ts` owns file-watch event matching and config-reload transition analysis
|
||||
- `ts/daemon.ts` now delegates config/pause watch event classification and reload messaging
|
||||
decisions
|
||||
|
||||
### UPS Status Tracking
|
||||
|
||||
- `ts/ups-status.ts` owns the daemon UPS status shape and default status factory
|
||||
- `ts/daemon.ts` now reuses a shared initializer instead of duplicating the default UPS status
|
||||
object
|
||||
|
||||
### UPS Monitoring Transitions
|
||||
|
||||
- `ts/ups-monitoring.ts` owns pure UPS poll success/failure transition logic and threshold detection
|
||||
- `ts/daemon.ts` now orchestrates protocol calls and logging while delegating state transitions
|
||||
|
||||
### Action Orchestration
|
||||
|
||||
- `ts/action-orchestration.ts` owns action context construction and action execution decisions
|
||||
- `ts/daemon.ts` now delegates pause suppression, legacy shutdown fallback, and action context
|
||||
building
|
||||
|
||||
### Shutdown Monitoring
|
||||
|
||||
- `ts/shutdown-monitoring.ts` owns shutdown-loop row building and emergency candidate selection
|
||||
- `ts/daemon.ts` now keeps the shutdown loop orchestration while delegating row/emergency decisions
|
||||
|
||||
### Proxmox VM Shutdown Action
|
||||
|
||||
- New action type `'proxmox'` in `ts/actions/proxmox-action.ts`
|
||||
- Uses Proxmox REST API with PVEAPIToken authentication
|
||||
- Shuts down QEMU VMs and LXC containers before host shutdown
|
||||
- Supports: exclude IDs, configurable timeout, force-stop, TLS skip for self-signed certs
|
||||
- Should be placed BEFORE shutdown actions in the action chain
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
- **SNMP Manager**: Uses `INupstAccessor` interface (not direct `Nupst` reference) to avoid circular
|
||||
imports
|
||||
- **Protocol Resolver**: Routes to SNMP or UPSD based on `IUpsConfig.protocol`
|
||||
- **CLI Handlers**: All use the `helpers.withPrompt()` utility for interactive input
|
||||
- **Constants**: All timing values should be referenced from `ts/constants.ts`
|
||||
- **Actions**: Use `IActionConfig` from `ts/actions/base-action.ts` for action configuration
|
||||
- **Action orchestration**: Use helpers from `ts/action-orchestration.ts` for action context and
|
||||
execution decisions
|
||||
- **Config watch logic**: Use helpers from `ts/config-watch.ts` for file event filtering and reload
|
||||
transitions
|
||||
- **Pause state**: Use `loadPauseSnapshot()` and `IPauseState` from `ts/pause-state.ts`
|
||||
- **Shutdown execution**: Use `ShutdownExecutor` for OS-level shutdown command lookup and fallbacks
|
||||
- **Shutdown monitoring**: Use helpers from `ts/shutdown-monitoring.ts` for emergency loop rows and
|
||||
candidate selection
|
||||
- **UPS status state**: Use `IUpsStatus` and `createInitialUpsStatus()` from `ts/ups-status.ts`
|
||||
- **UPS poll transitions**: Use helpers from `ts/ups-monitoring.ts` for success/failure updates
|
||||
- **Config version**: Currently `4.3`, migrations run automatically
|
||||
|
||||
## File Organization
|
||||
|
||||
```
|
||||
ts/
|
||||
├── constants.ts # All timing/threshold constants
|
||||
├── action-orchestration.ts # Action context and execution decisions
|
||||
├── config-watch.ts # File watch filters and config reload transitions
|
||||
├── shutdown-monitoring.ts # Shutdown loop rows and emergency selection
|
||||
├── ups-monitoring.ts # Pure UPS poll transition and threshold helpers
|
||||
├── pause-state.ts # Shared pause state types and transition detection
|
||||
├── shutdown-executor.ts # Delayed/emergency shutdown command execution
|
||||
├── ups-status.ts # Daemon UPS status shape and initializer
|
||||
├── interfaces/
|
||||
│ └── nupst-accessor.ts # Interface to break circular deps
|
||||
├── helpers/
|
||||
│ ├── prompt.ts # Readline utility
|
||||
│ └── shortid.ts # ID generation
|
||||
├── actions/
|
||||
│ ├── base-action.ts # Base action class, IActionConfig, TPowerStatus
|
||||
│ ├── webhook-action.ts # Includes IWebhookPayload
|
||||
│ ├── proxmox-action.ts # Proxmox VM/LXC shutdown
|
||||
│ └── ...
|
||||
├── upsd/
|
||||
│ ├── types.ts # IUpsdConfig
|
||||
│ ├── client.ts # NupstUpsd TCP client
|
||||
│ └── index.ts
|
||||
├── protocol/
|
||||
│ ├── types.ts # TProtocol = 'snmp' | 'upsd'
|
||||
│ ├── resolver.ts # ProtocolResolver
|
||||
│ └── index.ts
|
||||
├── migrations/
|
||||
│ ├── migration-runner.ts
|
||||
│ └── migration-v4.2-to-v4.3.ts # Adds SNMP runtimeUnit defaults
|
||||
└── cli/
|
||||
└── ... # All handlers use helpers.withPrompt()
|
||||
```
|
||||
|
||||
@@ -1,280 +1,384 @@
|
||||
# NUPST - Node.js UPS Shutdown Tool
|
||||
# @serve.zone/nupst
|
||||
|
||||
NUPST is a command-line tool that monitors SNMP-enabled UPS devices and initiates system shutdown when power outages are detected and battery levels are low.
|
||||
NUPST is a Network UPS Shutdown Tool for servers that need predictable behavior when power gets ugly. It monitors UPS devices through SNMP or NUT/UPSD, evaluates per-device and grouped power conditions, and runs ordered actions such as Proxmox guest shutdown, webhooks, scripts, and host shutdown.
|
||||
|
||||
## Features
|
||||
## Issue Reporting and Security
|
||||
|
||||
- Monitors UPS devices using SNMP (v1, v2c, and v3 supported)
|
||||
- Automatic shutdown when battery level falls below threshold
|
||||
- Automatic shutdown when runtime remaining falls below threshold
|
||||
- Supports multiple UPS brands (CyberPower, APC, Eaton, TrippLite, Liebert/Vertiv)
|
||||
- Simple systemd service integration
|
||||
- Regular status logging for monitoring
|
||||
- Real-time log viewing with journalctl
|
||||
- Version checking and automatic updates
|
||||
- Self-contained - includes its own Node.js runtime
|
||||
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
|
||||
|
||||
## Why It Exists
|
||||
|
||||
Power-failure automation must be boring, explicit, and local-first. NUPST is built as a single compiled Deno binary that can sit on a Proxmox host or another Linux server, poll one or more UPS devices, and execute a controlled shutdown sequence before batteries are exhausted.
|
||||
|
||||
It is useful when you need:
|
||||
|
||||
- SNMP monitoring for network UPS units.
|
||||
- NUT/UPSD monitoring for USB UPS units exposed by Network UPS Tools.
|
||||
- Multiple UPS devices on one daemon.
|
||||
- Redundant or non-redundant UPS groups.
|
||||
- Proxmox VM/LXC shutdown before host shutdown.
|
||||
- Webhook or script notifications.
|
||||
- A small authenticated HTTP JSON status endpoint.
|
||||
- Pause/resume for maintenance windows.
|
||||
- Configuration migration from older NUPST formats.
|
||||
|
||||
## Installation
|
||||
|
||||
### Quick Install (One-line command)
|
||||
Install the released binary:
|
||||
|
||||
```bash
|
||||
# Method 1: Download and run (most reliable across all environments)
|
||||
curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh -o nupst-install.sh && sudo bash nupst-install.sh && rm nupst-install.sh
|
||||
curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash
|
||||
```
|
||||
|
||||
Install through the npm package wrapper with pnpm:
|
||||
|
||||
```bash
|
||||
# Method 2: Pipe with automatic yes for dependencies (non-interactive)
|
||||
curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash -s -- -y
|
||||
pnpm add --global @serve.zone/nupst
|
||||
```
|
||||
|
||||
The installer places the binary under `/opt/nupst/nupst`, creates a symlink in `/usr/local/bin` or `/usr/bin`, and preserves existing `/etc/nupst/config.json` when upgrading.
|
||||
|
||||
## Quick Start
|
||||
|
||||
Create the first UPS config interactively:
|
||||
|
||||
```bash
|
||||
# Method 3: Process substitution (only on systems that support /dev/fd/)
|
||||
# Note: This may fail on some systems with "No such file or directory" errors
|
||||
sudo bash <(curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh)
|
||||
sudo nupst ups add
|
||||
```
|
||||
|
||||
### Direct from Git
|
||||
Test connectivity:
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://code.foss.global/serve.zone/nupst.git
|
||||
cd nupst
|
||||
|
||||
# Option 1: Quick install (requires root privileges)
|
||||
sudo ./install.sh
|
||||
|
||||
# Option 1a: Quick install with auto-yes for dependencies
|
||||
sudo ./install.sh -y
|
||||
|
||||
# Option 2: Manual setup
|
||||
./setup.sh
|
||||
sudo ln -s $(pwd)/bin/nupst /usr/local/bin/nupst
|
||||
nupst ups test --debug
|
||||
```
|
||||
|
||||
### Installation Options
|
||||
|
||||
The installer script (`install.sh`) supports the following options:
|
||||
|
||||
```
|
||||
-y, --yes Automatically answer yes to all prompts (like installing git)
|
||||
-h, --help Show the help message
|
||||
```
|
||||
|
||||
### From NPM
|
||||
Install and start the systemd service:
|
||||
|
||||
```bash
|
||||
npm install -g @serve.zone/nupst
|
||||
sudo nupst service enable
|
||||
sudo nupst service start
|
||||
```
|
||||
|
||||
## System Changes
|
||||
|
||||
When installed, NUPST makes the following changes to your system:
|
||||
|
||||
### File System Changes
|
||||
|
||||
| Path | Description |
|
||||
|------|-------------|
|
||||
| `/opt/nupst/` | Main installation directory containing the NUPST files |
|
||||
| `/etc/nupst/config.json` | Configuration file |
|
||||
| `/usr/local/bin/nupst` | Symlink to the NUPST executable |
|
||||
| `/etc/systemd/system/nupst.service` | Systemd service file (when enabled) |
|
||||
|
||||
### Service Changes
|
||||
|
||||
- Creates and enables a systemd service called `nupst.service` (when enabled with `nupst enable`)
|
||||
- The service runs with root permissions to allow system shutdown capabilities
|
||||
|
||||
### Network Access
|
||||
|
||||
- NUPST only communicates with your UPS device via SNMP (default port 161)
|
||||
- Brief connections to npmjs.org to check for updates
|
||||
|
||||
## Uninstallation
|
||||
Inspect status and logs:
|
||||
|
||||
```bash
|
||||
# Using the CLI tool:
|
||||
sudo nupst uninstall
|
||||
|
||||
# If installed from git repository:
|
||||
cd /path/to/nupst
|
||||
sudo ./uninstall.sh
|
||||
|
||||
# If installed from npm:
|
||||
npm uninstall -g @serve.zone/nupst
|
||||
nupst service status
|
||||
nupst service logs
|
||||
```
|
||||
|
||||
The uninstaller will:
|
||||
- Stop and disable the systemd service (if installed)
|
||||
- Remove the systemd service file from `/etc/systemd/system/nupst.service`
|
||||
- Remove the symlink from `/usr/local/bin/nupst`
|
||||
- Optionally remove configuration files from `/etc/nupst/`
|
||||
- Remove the repository directory from `/opt/nupst/` (when using `nupst uninstall`)
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
NUPST - Node.js UPS Shutdown Tool
|
||||
|
||||
Usage:
|
||||
nupst enable - Install and enable the systemd service (requires root)
|
||||
nupst disable - Stop and uninstall the systemd service (requires root)
|
||||
nupst daemon-start - Start the daemon process directly
|
||||
nupst logs - Show logs of the systemd service in real-time
|
||||
nupst stop - Stop the systemd service
|
||||
nupst start - Start the systemd service
|
||||
nupst status - Show status of the systemd service and UPS status
|
||||
nupst setup - Run the interactive setup to configure SNMP settings
|
||||
nupst test - Test the current configuration by connecting to the UPS
|
||||
nupst config - Display the current configuration
|
||||
nupst update - Update NUPST from repository and refresh systemd service (requires root)
|
||||
nupst uninstall - Completely uninstall NUPST from the system (requires root)
|
||||
nupst help - Show this help message
|
||||
|
||||
Options:
|
||||
--debug, -d - Enable debug mode for detailed SNMP logging
|
||||
(Example: nupst test --debug)
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
NUPST provides an interactive setup to configure your UPS:
|
||||
## CLI Reference
|
||||
|
||||
```bash
|
||||
nupst setup
|
||||
nupst <command> [subcommand] [options]
|
||||
```
|
||||
|
||||
This will guide you through setting up:
|
||||
- UPS IP address and SNMP settings
|
||||
- Shutdown thresholds for battery percentage and runtime
|
||||
- Monitoring interval
|
||||
- Test the connection to your UPS
|
||||
Global options:
|
||||
|
||||
Alternatively, you can manually edit the configuration file at `/etc/nupst/config.json`. A default configuration will be created on first run:
|
||||
| Option | Purpose |
|
||||
| --- | --- |
|
||||
| `--version`, `-v` | Show version. |
|
||||
| `--help`, `-h` | Show help. |
|
||||
| `--debug`, `-d` | Enable detailed SNMP/UPSD logging. |
|
||||
|
||||
Service commands:
|
||||
|
||||
| Command | Purpose |
|
||||
| --- | --- |
|
||||
| `service enable` | Install and enable the systemd service. Requires root. |
|
||||
| `service disable` | Stop, disable, and remove the service. Requires root. |
|
||||
| `service start` | Start `nupst.service`. |
|
||||
| `service stop` | Stop `nupst.service`. |
|
||||
| `service restart` | Restart the service. |
|
||||
| `service status` | Show service and UPS state. |
|
||||
| `service logs` | Follow live service logs. |
|
||||
| `service start-daemon` | Run the daemon process directly. Used by systemd/testing. |
|
||||
|
||||
Configuration commands:
|
||||
|
||||
| Command | Purpose |
|
||||
| --- | --- |
|
||||
| `ups add` | Add a UPS device interactively. |
|
||||
| `ups edit [id]` | Edit a UPS device. |
|
||||
| `ups remove <id>` | Remove a UPS device. Alias: `rm`. |
|
||||
| `ups list` | List UPS devices. Alias: `ls`. |
|
||||
| `ups test` | Test configured UPS connections. |
|
||||
| `group add` | Create a UPS group. |
|
||||
| `group edit <id>` | Edit a group. |
|
||||
| `group remove <id>` | Remove a group. Alias: `rm`. |
|
||||
| `group list` | List groups. Alias: `ls`. |
|
||||
| `action add <target-id>` | Add an action to a UPS or group. |
|
||||
| `action edit <target-id> <index>` | Edit an action by array index. |
|
||||
| `action remove <target-id> <index>` | Remove an action. Alias: `rm`. |
|
||||
| `action list [target-id]` | List actions globally or for one target. Alias: `ls`. |
|
||||
| `feature httpServer` | Configure the optional JSON status endpoint. Aliases: `http-server`, `http`. |
|
||||
| `config show` | Display the active configuration. |
|
||||
| `pause [--duration <time>]` | Suppress actions while polling continues. |
|
||||
| `resume` | Resume action execution. |
|
||||
| `upgrade` | Upgrade NUPST from the latest release. Requires root. |
|
||||
| `uninstall` | Remove NUPST. Requires root. |
|
||||
|
||||
Pause durations support values such as `30m`, `2h`, and `1d`. The maximum pause duration is 24 hours.
|
||||
|
||||
## Configuration Model
|
||||
|
||||
NUPST stores configuration at:
|
||||
|
||||
```text
|
||||
/etc/nupst/config.json
|
||||
```
|
||||
|
||||
The daemon loads and migrates this file at startup. It also watches for configuration changes and can reload normal config edits without a service restart.
|
||||
|
||||
Current normalized config version is `4.4`.
|
||||
|
||||
Minimal shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"snmp": {
|
||||
"host": "192.168.1.100",
|
||||
"port": 161,
|
||||
"community": "public",
|
||||
"version": 1,
|
||||
"timeout": 5000,
|
||||
"upsModel": "cyberpower"
|
||||
},
|
||||
"thresholds": {
|
||||
"battery": 60,
|
||||
"runtime": 20
|
||||
},
|
||||
"checkInterval": 30000
|
||||
"version": "4.4",
|
||||
"checkInterval": 30000,
|
||||
"defaultShutdownDelay": 5,
|
||||
"upsDevices": [
|
||||
{
|
||||
"id": "ups-main",
|
||||
"name": "Main UPS",
|
||||
"protocol": "snmp",
|
||||
"snmp": {
|
||||
"host": "192.168.1.100",
|
||||
"port": 161,
|
||||
"version": 2,
|
||||
"community": "public",
|
||||
"timeout": 5000,
|
||||
"upsModel": "apc",
|
||||
"runtimeUnit": "ticks"
|
||||
},
|
||||
"groups": ["rack-a"],
|
||||
"actions": [
|
||||
{
|
||||
"type": "proxmox",
|
||||
"triggerMode": "onlyThresholds",
|
||||
"thresholds": { "battery": 30, "runtime": 15 },
|
||||
"proxmoxMode": "auto",
|
||||
"proxmoxHaPolicy": "haStop"
|
||||
},
|
||||
{
|
||||
"type": "shutdown",
|
||||
"triggerMode": "onlyThresholds",
|
||||
"thresholds": { "battery": 20, "runtime": 10 },
|
||||
"shutdownDelay": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"groups": [
|
||||
{
|
||||
"id": "rack-a",
|
||||
"name": "Rack A",
|
||||
"mode": "nonRedundant",
|
||||
"actions": []
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `snmp`: SNMP connection settings
|
||||
- `host`: IP address of your UPS (default: 127.0.0.1)
|
||||
- `port`: SNMP port (default: 161)
|
||||
- `version`: SNMP version (1, 2, or 3)
|
||||
- `timeout`: Timeout in milliseconds (default: 5000)
|
||||
- `upsModel`: The UPS model ('cyberpower', 'apc', 'eaton', 'tripplite', 'liebert', or 'custom')
|
||||
- For SNMPv1/v2c:
|
||||
- `community`: SNMP community string (default: public)
|
||||
- For SNMPv3:
|
||||
- `securityLevel`: Security level ('noAuthNoPriv', 'authNoPriv', or 'authPriv')
|
||||
- `username`: SNMPv3 username
|
||||
- `authProtocol`: Authentication protocol ('MD5' or 'SHA')
|
||||
- `authKey`: Authentication password/key
|
||||
- `privProtocol`: Privacy/encryption protocol ('DES' or 'AES')
|
||||
- `privKey`: Privacy password/key
|
||||
- For custom UPS models:
|
||||
- `customOIDs`: Object containing custom OIDs for your UPS:
|
||||
- `POWER_STATUS`: OID for power status
|
||||
- `BATTERY_CAPACITY`: OID for battery capacity percentage
|
||||
- `BATTERY_RUNTIME`: OID for runtime remaining in minutes
|
||||
- `thresholds`: When to trigger shutdown
|
||||
- `battery`: Battery percentage threshold (default: 60%)
|
||||
- `runtime`: Runtime minutes threshold (default: 20 minutes)
|
||||
- `checkInterval`: How often to check UPS status in milliseconds (default: 30000)
|
||||
## Protocols
|
||||
|
||||
## Setup as a Service
|
||||
SNMP supports these UPS model profiles:
|
||||
|
||||
To set up NUPST as a systemd service:
|
||||
| Config value | Typical use |
|
||||
| --- | --- |
|
||||
| `cyberpower` | CyberPower devices. |
|
||||
| `apc` | APC/PowerNet devices. |
|
||||
| `eaton` | Eaton/Powerware devices. |
|
||||
| `tripplite` | Tripp Lite devices. |
|
||||
| `liebert` | Liebert/Vertiv devices. |
|
||||
| `custom` | Custom OID mapping. |
|
||||
|
||||
```bash
|
||||
sudo nupst enable
|
||||
sudo nupst start
|
||||
SNMP versions `1`, `2`, and `3` are supported. SNMPv3 supports `noAuthNoPriv`, `authNoPriv`, and `authPriv` with MD5/SHA authentication and DES/AES privacy.
|
||||
|
||||
UPSD/NIS supports UPS devices exposed by NUT:
|
||||
|
||||
```json
|
||||
{
|
||||
"protocol": "upsd",
|
||||
"upsd": {
|
||||
"host": "127.0.0.1",
|
||||
"port": 3493,
|
||||
"upsName": "ups",
|
||||
"timeout": 5000,
|
||||
"username": "optional",
|
||||
"password": "optional"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
To check the status:
|
||||
## Action System
|
||||
|
||||
Actions are evaluated in array order. Put Proxmox shutdown actions before host shutdown actions so guests get time to stop cleanly.
|
||||
|
||||
Action types:
|
||||
|
||||
| Type | Purpose |
|
||||
| --- | --- |
|
||||
| `shutdown` | Schedule host shutdown with an optional delay. |
|
||||
| `webhook` | Send an HTTP GET or POST request. |
|
||||
| `script` | Execute a script from `/etc/nupst/`. |
|
||||
| `proxmox` | Shut down Proxmox VMs and LXC containers. |
|
||||
|
||||
Trigger modes:
|
||||
|
||||
| Mode | Behavior |
|
||||
| --- | --- |
|
||||
| `onlyPowerChanges` | Run only when status changes between online and battery states. |
|
||||
| `onlyThresholds` | Run only when battery/runtime thresholds are newly violated. |
|
||||
| `powerChangesAndThresholds` | Run on power changes or threshold violations. This is the default. |
|
||||
| `anyChange` | Run on every poll/check. |
|
||||
|
||||
Threshold-based actions are edge-triggered. They fire when the condition is entered, not on every poll while the condition remains true. If the condition clears and later re-enters, the action can fire again.
|
||||
|
||||
## Groups
|
||||
|
||||
Groups coordinate conditions across multiple UPS devices:
|
||||
|
||||
| Mode | Behavior |
|
||||
| --- | --- |
|
||||
| `redundant` | Threshold actions trigger only when all group members are on battery and below the action thresholds. |
|
||||
| `nonRedundant` | Threshold actions trigger when any group member is on battery and below the action thresholds. |
|
||||
|
||||
For destructive threshold-based group actions such as `shutdown` and `proxmox`, NUPST suppresses execution while any required group member is `unreachable`. This avoids acting on incomplete data during network failures.
|
||||
|
||||
## Proxmox Integration
|
||||
|
||||
The Proxmox action supports three modes:
|
||||
|
||||
| Mode | Behavior |
|
||||
| --- | --- |
|
||||
| `auto` | Prefer local `qm`/`pct` CLI tools when available as root, otherwise use the API. |
|
||||
| `cli` | Force local CLI mode. Requires root on a Proxmox host. |
|
||||
| `api` | Force Proxmox REST API mode with token auth. |
|
||||
|
||||
Useful options:
|
||||
|
||||
| Option | Purpose |
|
||||
| --- | --- |
|
||||
| `proxmoxHost` | API host. Defaults to `localhost`. |
|
||||
| `proxmoxPort` | API port. Defaults to `8006`. |
|
||||
| `proxmoxNode` | Node name. Defaults to system hostname. |
|
||||
| `proxmoxTokenId` | API token ID, for API mode. |
|
||||
| `proxmoxTokenSecret` | API token secret, for API mode. |
|
||||
| `proxmoxExcludeIds` | VM/CT IDs to skip. |
|
||||
| `proxmoxStopTimeout` | Graceful stop timeout in seconds. Defaults to `120`. |
|
||||
| `proxmoxForceStop` | Force-stop guests that do not shut down. Defaults to `true`. |
|
||||
| `proxmoxInsecure` | Skip TLS verification in API mode. Defaults to `true`. |
|
||||
| `proxmoxHaPolicy` | `none` or `haStop` for HA-managed resources. |
|
||||
|
||||
## HTTP Status API
|
||||
|
||||
Enable interactively:
|
||||
|
||||
```bash
|
||||
nupst status
|
||||
sudo nupst feature httpServer
|
||||
```
|
||||
|
||||
To view logs in real-time:
|
||||
When enabled, the endpoint returns cached daemon status and requires a bearer token or `token` query parameter.
|
||||
|
||||
```bash
|
||||
nupst logs
|
||||
curl -H "Authorization: Bearer <token>" http://localhost:8080/ups-status
|
||||
curl "http://localhost:8080/ups-status?token=<token>"
|
||||
```
|
||||
|
||||
## Updating NUPST
|
||||
Response shape:
|
||||
|
||||
NUPST checks for updates automatically and will notify you when an update is available. To update to the latest version:
|
||||
```json
|
||||
{
|
||||
"upsDevices": [
|
||||
{
|
||||
"id": "ups-main",
|
||||
"name": "Main UPS",
|
||||
"powerStatus": "online",
|
||||
"batteryCapacity": 100,
|
||||
"batteryRuntime": 45,
|
||||
"outputLoad": 23,
|
||||
"outputPower": 115,
|
||||
"outputVoltage": 230,
|
||||
"outputCurrent": 0.5
|
||||
}
|
||||
],
|
||||
"paused": false
|
||||
}
|
||||
```
|
||||
|
||||
## Runtime Behavior
|
||||
|
||||
- Default polling interval is 30 seconds.
|
||||
- UPS devices become `unreachable` after 3 consecutive polling failures.
|
||||
- Failure counters are capped at 100.
|
||||
- Shutdown delay defaults to 5 minutes unless overridden per action.
|
||||
- Pause state is stored at `/etc/nupst/pause`.
|
||||
- Normal monitoring talks to configured UPS, NUT, and Proxmox targets. Upgrade and release checks contact `code.foss.global`.
|
||||
|
||||
Installed system paths:
|
||||
|
||||
| Path | Purpose |
|
||||
| --- | --- |
|
||||
| `/opt/nupst/nupst` | Installed binary. |
|
||||
| `/usr/local/bin/nupst` or `/usr/bin/nupst` | Symlink to the binary. |
|
||||
| `/etc/nupst/config.json` | Main configuration. |
|
||||
| `/etc/nupst/pause` | Pause state. |
|
||||
| `/etc/systemd/system/nupst.service` | systemd unit. |
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
sudo nupst update
|
||||
deno task dev
|
||||
deno task check
|
||||
deno task lint
|
||||
deno task fmt
|
||||
deno task test
|
||||
deno task compile
|
||||
```
|
||||
|
||||
This will:
|
||||
1. Pull the latest changes from the git repository
|
||||
2. Run the installation scripts
|
||||
3. Refresh the systemd service configuration
|
||||
4. Restart the service if it was running
|
||||
Package scripts are also available:
|
||||
|
||||
## Security
|
||||
```bash
|
||||
pnpm build
|
||||
pnpm test
|
||||
pnpm run lint
|
||||
pnpm run format
|
||||
```
|
||||
|
||||
NUPST was designed with security in mind:
|
||||
Source map:
|
||||
|
||||
### Minimal Dependencies
|
||||
|
||||
- **Zero Runtime NPM Dependencies**: NUPST is built without any external NPM packages to minimize the attack surface and avoid supply chain risks.
|
||||
- **Self-contained Node.js**: NUPST ships with its own Node.js binary, isolated from the system's Node.js installation. This ensures:
|
||||
- No dependency on system Node.js versions
|
||||
- Zero external libraries that could become compromised
|
||||
- Consistent, tested environment for execution
|
||||
- Reduced risk of dependency-based attacks
|
||||
|
||||
### Implementation Security
|
||||
|
||||
- **Privilege Separation**: Only specific commands that require elevated permissions (`enable`, `disable`, `update`) check for root access; all other functionality runs with minimal privileges.
|
||||
- **Limited Network Access**: NUPST only communicates with the UPS device over SNMP and contacts npmjs.org only to check for updates.
|
||||
- **Secure SNMPv3 Support**: Supports encrypted authentication and privacy for secure communication with the UPS device.
|
||||
- **Isolated Execution**: The application runs in its working directory (`/opt/nupst`) or specified installation location, minimizing the impact on the rest of the system.
|
||||
|
||||
### Installation Security
|
||||
|
||||
- The installation script can be reviewed before execution (`curl -sSL [url] | less`)
|
||||
- All setup scripts download only verified versions and check integrity
|
||||
- Installation is transparent and places files in standard locations (`/opt/nupst`, `/usr/local/bin`, `/etc/systemd/system`)
|
||||
|
||||
### Audit and Review
|
||||
|
||||
The codebase is small, focused, and designed to be easily auditable. All code is open source and available for review.
|
||||
| Path | Purpose |
|
||||
| --- | --- |
|
||||
| `mod.ts` | CLI entry point. |
|
||||
| `ts/cli.ts` | Command parser and help output. |
|
||||
| `ts/nupst.ts` | Main facade wiring protocol clients, daemon, systemd, and handlers. |
|
||||
| `ts/daemon.ts` | Config loading, migration, polling loop, and action orchestration. |
|
||||
| `ts/snmp/` | SNMP protocol implementation and OID sets. |
|
||||
| `ts/upsd/` | NUT/UPSD client. |
|
||||
| `ts/actions/` | Shutdown, webhook, script, and Proxmox actions. |
|
||||
| `ts/cli/` | Interactive command handlers. |
|
||||
| `ts/migrations/` | Config format migrations. |
|
||||
| `ts/http-server.ts` | Optional authenticated JSON status endpoint. |
|
||||
|
||||
## License and Legal Information
|
||||
|
||||
This repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository.
|
||||
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.
|
||||
|
||||
### Trademarks
|
||||
|
||||
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH.
|
||||
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
|
||||
|
||||
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
|
||||
|
||||
### Company Information
|
||||
|
||||
Task Venture Capital GmbH
|
||||
Registered at District court Bremen HRB 35230 HB, Germany
|
||||
Task Venture Capital GmbH\
|
||||
Registered at District Court Bremen HRB 35230 HB, Germany
|
||||
|
||||
For any legal inquiries or if you require further information, please contact us via email at hello@task.vc.
|
||||
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
||||
|
||||
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.
|
||||
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.
|
||||
|
||||
+613
@@ -0,0 +1,613 @@
|
||||
# NUPST Migration Plan: Node.js → Deno v4.0.0
|
||||
|
||||
**Migration Goal**: Convert NUPST from Node.js to Deno with single-executable distribution
|
||||
**Version**: 3.1.2 → 4.0.0 (breaking changes) **Platforms**: Linux x64/ARM64, macOS x64/ARM64,
|
||||
Windows x64
|
||||
|
||||
---
|
||||
|
||||
## Phase 0: Planning & Preparation
|
||||
|
||||
- [x] Research Deno compilation targets and npm: specifier support
|
||||
- [x] Analyze current codebase structure and dependencies
|
||||
- [x] Define CLI command structure simplification
|
||||
- [x] Create detailed migration task list
|
||||
- [ ] Create feature branch: `migration/deno-v4`
|
||||
- [ ] Backup current working state with git tag: `v3.1.2-pre-deno-migration`
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Dependency Migration (4-6 hours)
|
||||
|
||||
### 1.1 Analyze Current Dependencies
|
||||
|
||||
- [ ] List all production dependencies from `package.json`
|
||||
- Current: `net-snmp@3.20.0`
|
||||
- [ ] List all dev dependencies to be removed
|
||||
- `@git.zone/tsbuild`, `@git.zone/tsrun`, `@git.zone/tstest`, `@push.rocks/qenv`,
|
||||
`@push.rocks/tapbundle`, `@types/node`
|
||||
- [ ] Identify Node.js built-in module usage
|
||||
- `child_process` (execSync)
|
||||
- `https` (for version checking)
|
||||
- `fs` (readFileSync, writeFileSync, existsSync, mkdirSync)
|
||||
- `path` (join, dirname, resolve)
|
||||
|
||||
### 1.2 Create Deno Configuration
|
||||
|
||||
- [ ] Create `deno.json` with project configuration
|
||||
```json
|
||||
{
|
||||
"name": "@serve.zone/nupst",
|
||||
"version": "4.0.0",
|
||||
"exports": "./mod.ts",
|
||||
"tasks": {
|
||||
"dev": "deno run --allow-all mod.ts",
|
||||
"compile": "deno task compile:all",
|
||||
"compile:all": "bash scripts/compile-all.sh",
|
||||
"test": "deno test --allow-all tests/",
|
||||
"check": "deno check mod.ts"
|
||||
},
|
||||
"lint": {
|
||||
"rules": {
|
||||
"tags": ["recommended"]
|
||||
}
|
||||
},
|
||||
"fmt": {
|
||||
"useTabs": false,
|
||||
"lineWidth": 100,
|
||||
"indentWidth": 2,
|
||||
"semiColons": true
|
||||
},
|
||||
"compilerOptions": {
|
||||
"lib": ["deno.window"],
|
||||
"strict": true
|
||||
},
|
||||
"imports": {
|
||||
"@std/cli": "jsr:@std/cli@^1.0.0",
|
||||
"@std/fmt": "jsr:@std/fmt@^1.0.0",
|
||||
"@std/path": "jsr:@std/path@^1.0.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 1.3 Update Import Statements
|
||||
|
||||
- [ ] `ts/snmp/manager.ts`: Change `import * as snmp from 'net-snmp'` to
|
||||
`import * as snmp from "npm:net-snmp@3.20.0"`
|
||||
- [ ] `ts/cli.ts`: Change `import { execSync } from 'child_process'` to
|
||||
`import { execSync } from "node:child_process"`
|
||||
- [ ] `ts/nupst.ts`: Change `import * as https from 'https'` to
|
||||
`import * as https from "node:https"`
|
||||
- [ ] Search for all `fs` imports and update to `node:fs`
|
||||
- [ ] Search for all `path` imports and update to `node:path`
|
||||
- [ ] Update all relative imports to use `.ts` extension instead of `.js`
|
||||
- Example: `'./nupst.js'` → `'./nupst.ts'`
|
||||
|
||||
### 1.4 Test npm: Specifier Compatibility
|
||||
|
||||
- [ ] Create test file: `tests/snmp_compatibility_test.ts`
|
||||
- [ ] Test SNMP v1 connection with npm:net-snmp
|
||||
- [ ] Test SNMP v2c connection with npm:net-snmp
|
||||
- [ ] Test SNMP v3 connection with npm:net-snmp
|
||||
- [ ] Verify native addon loading works in compiled binary
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Code Structure Refactoring (3-4 hours)
|
||||
|
||||
### 2.1 Create Main Entry Point
|
||||
|
||||
- [ ] Create `mod.ts` as main Deno entry point:
|
||||
```typescript
|
||||
#!/usr/bin/env -S deno run --allow-all
|
||||
|
||||
/**
|
||||
* NUPST - UPS Shutdown Tool for Deno
|
||||
*
|
||||
* Required Permissions:
|
||||
* --allow-net: SNMP communication with UPS devices
|
||||
* --allow-read: Configuration file access (/etc/nupst/config.json)
|
||||
* --allow-write: Configuration file updates
|
||||
* --allow-run: System commands (systemctl, shutdown)
|
||||
* --allow-sys: System information (hostname, OS info)
|
||||
* --allow-env: Environment variables
|
||||
*/
|
||||
|
||||
import { NupstCli } from './ts/cli.ts';
|
||||
|
||||
const cli = new NupstCli();
|
||||
await cli.parseAndExecute(Deno.args);
|
||||
```
|
||||
|
||||
### 2.2 Update All Import Extensions
|
||||
|
||||
Files to update (change .js → .ts in imports):
|
||||
|
||||
- [ ] `ts/index.ts`
|
||||
- [ ] `ts/cli.ts` (imports from ./nupst.js, ./logger.js)
|
||||
- [ ] `ts/nupst.ts` (imports from ./snmp/manager.js, ./daemon.js, etc.)
|
||||
- [ ] `ts/daemon.ts` (imports from ./snmp/manager.js, ./logger.js, ./helpers/)
|
||||
- [ ] `ts/systemd.ts` (imports from ./daemon.js, ./logger.js)
|
||||
- [ ] `ts/cli/service-handler.ts`
|
||||
- [ ] `ts/cli/group-handler.ts`
|
||||
- [ ] `ts/cli/ups-handler.ts`
|
||||
- [ ] `ts/snmp/index.ts`
|
||||
- [ ] `ts/snmp/manager.ts` (imports from ./types.js, ./oid-sets.js)
|
||||
- [ ] `ts/snmp/oid-sets.ts` (imports from ./types.js)
|
||||
- [ ] `ts/helpers/index.ts`
|
||||
- [ ] `ts/logger.ts`
|
||||
|
||||
### 2.3 Update process.argv References
|
||||
|
||||
- [ ] `ts/cli.ts`: Replace `process.argv` with `Deno.args` (adjust indexing: process.argv[2] →
|
||||
Deno.args[0])
|
||||
- [ ] Update parseAndExecute method to work with Deno.args (0-indexed vs 2-indexed)
|
||||
|
||||
### 2.4 Update File System Operations
|
||||
|
||||
- [ ] Search for `fs.readFileSync()` → Consider using `Deno.readTextFile()` or keep node:fs
|
||||
- [ ] Search for `fs.writeFileSync()` → Consider using `Deno.writeTextFile()` or keep node:fs
|
||||
- [ ] Search for `fs.existsSync()` → Keep node:fs or use Deno.stat
|
||||
- [ ] Search for `fs.mkdirSync()` → Keep node:fs or use Deno.mkdir
|
||||
- [ ] Decision: Keep node:fs for consistency or migrate to Deno APIs?
|
||||
|
||||
### 2.5 Update Path Operations
|
||||
|
||||
- [ ] Verify all `path.join()`, `path.resolve()`, `path.dirname()` work with node:path
|
||||
- [ ] Consider using `@std/path` from JSR for better Deno integration
|
||||
|
||||
### 2.6 Handle __dirname and __filename
|
||||
|
||||
- [ ] Find all `__dirname` usage
|
||||
- [ ] Replace with `import.meta.dirname` (Deno) or `dirname(fromFileUrl(import.meta.url))`
|
||||
- [ ] Find all `__filename` usage
|
||||
- [ ] Replace with `import.meta.filename` or `fromFileUrl(import.meta.url)`
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: CLI Command Simplification (3-4 hours)
|
||||
|
||||
### 3.1 Design New Command Structure
|
||||
|
||||
Current → New mapping:
|
||||
|
||||
```
|
||||
OLD NEW
|
||||
=== ===
|
||||
nupst enable → nupst service enable
|
||||
nupst disable → nupst service disable
|
||||
nupst daemon-start → nupst service start-daemon
|
||||
nupst logs → nupst service logs
|
||||
nupst stop → nupst service stop
|
||||
nupst start → nupst service start
|
||||
nupst status → nupst service status
|
||||
|
||||
nupst add → nupst ups add
|
||||
nupst edit [id] → nupst ups edit [id]
|
||||
nupst delete <id> → nupst ups remove <id>
|
||||
nupst list → nupst ups list
|
||||
nupst setup → nupst ups edit (removed alias)
|
||||
nupst test → nupst ups test
|
||||
|
||||
nupst group list → nupst group list
|
||||
nupst group add → nupst group add
|
||||
nupst group edit <id> → nupst group edit <id>
|
||||
nupst group delete <id> → nupst group remove <id>
|
||||
|
||||
nupst config → nupst config show
|
||||
nupst upgrade → nupst upgrade
|
||||
nupst uninstall → nupst uninstall
|
||||
nupst help → nupst help / nupst --help
|
||||
(new) → nupst --version
|
||||
```
|
||||
|
||||
### 3.2 Update CLI Parser (ts/cli.ts)
|
||||
|
||||
- [ ] Refactor `parseAndExecute()` to handle new command structure
|
||||
- [ ] Add `service` subcommand handler
|
||||
- [ ] Add `ups` subcommand handler
|
||||
- [ ] Keep `group` subcommand handler (already exists, just update delete→remove)
|
||||
- [ ] Add `config` subcommand handler with `show` default
|
||||
- [ ] Add `--version` flag handler
|
||||
- [ ] Update `help` command to show new structure
|
||||
- [ ] Add command aliases: `rm` → `remove`, `ls` → `list`
|
||||
- [ ] Add `--json` flag for machine-readable output (future enhancement)
|
||||
|
||||
### 3.3 Update Command Handlers
|
||||
|
||||
- [ ] `ts/cli/service-handler.ts`: Update method names if needed
|
||||
- [ ] `ts/cli/ups-handler.ts`: Rename `delete()` → `remove()`, remove `setup` method
|
||||
- [ ] `ts/cli/group-handler.ts`: Rename `delete()` → `remove()`
|
||||
|
||||
### 3.4 Improve Help Messages
|
||||
|
||||
- [ ] Update `showHelp()` in ts/cli.ts with new command structure
|
||||
- [ ] Update `showGroupHelp()` in ts/cli.ts
|
||||
- [ ] Add `showServiceHelp()` method
|
||||
- [ ] Add `showUpsHelp()` method
|
||||
- [ ] Add `showConfigHelp()` method
|
||||
- [ ] Include usage examples in help text
|
||||
|
||||
### 3.5 Add Version Command
|
||||
|
||||
- [ ] Read version from deno.json
|
||||
- [ ] Create `--version` handler in CLI
|
||||
- [ ] Display version with build info
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Compilation & Distribution (2-3 hours)
|
||||
|
||||
### 4.1 Create Compilation Script
|
||||
|
||||
- [ ] Create directory: `scripts/`
|
||||
- [ ] Create `scripts/compile-all.sh`:
|
||||
```bash
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
VERSION=$(cat deno.json | jq -r '.version')
|
||||
BINARY_DIR="dist/binaries"
|
||||
|
||||
echo "Compiling NUPST v${VERSION} for all platforms..."
|
||||
mkdir -p "$BINARY_DIR"
|
||||
|
||||
# Linux x86_64
|
||||
echo "→ Linux x86_64..."
|
||||
deno compile --allow-all --output "$BINARY_DIR/nupst-linux-x64" \
|
||||
--target x86_64-unknown-linux-gnu mod.ts
|
||||
|
||||
# Linux ARM64
|
||||
echo "→ Linux ARM64..."
|
||||
deno compile --allow-all --output "$BINARY_DIR/nupst-linux-arm64" \
|
||||
--target aarch64-unknown-linux-gnu mod.ts
|
||||
|
||||
# macOS x86_64
|
||||
echo "→ macOS x86_64..."
|
||||
deno compile --allow-all --output "$BINARY_DIR/nupst-macos-x64" \
|
||||
--target x86_64-apple-darwin mod.ts
|
||||
|
||||
# macOS ARM64
|
||||
echo "→ macOS ARM64..."
|
||||
deno compile --allow-all --output "$BINARY_DIR/nupst-macos-arm64" \
|
||||
--target aarch64-apple-darwin mod.ts
|
||||
|
||||
# Windows x86_64
|
||||
echo "→ Windows x86_64..."
|
||||
deno compile --allow-all --output "$BINARY_DIR/nupst-windows-x64.exe" \
|
||||
--target x86_64-pc-windows-msvc mod.ts
|
||||
|
||||
echo ""
|
||||
echo "✓ Compilation complete!"
|
||||
ls -lh "$BINARY_DIR/"
|
||||
```
|
||||
- [ ] Make script executable: `chmod +x scripts/compile-all.sh`
|
||||
|
||||
### 4.2 Test Local Compilation
|
||||
|
||||
- [ ] Run `deno task compile` to compile for all platforms
|
||||
- [ ] Verify all 5 binaries are created
|
||||
- [ ] Check binary sizes (should be reasonable, < 100MB each)
|
||||
- [ ] Test local binary on current platform: `./dist/binaries/nupst-linux-x64 --version`
|
||||
|
||||
### 4.3 Update Installation Scripts
|
||||
|
||||
- [ ] Update `install.sh`:
|
||||
- Remove Node.js download logic (lines dealing with vendor/node-*)
|
||||
- Add detection for binary download from GitHub releases
|
||||
- Simplify to download appropriate binary based on OS/arch
|
||||
- Place binary in `/opt/nupst/bin/nupst`
|
||||
- Create symlink: `/usr/local/bin/nupst → /opt/nupst/bin/nupst`
|
||||
- Update to v4.0.0 in script
|
||||
- [ ] Simplify or remove `setup.sh` (no longer needed without Node.js)
|
||||
- [ ] Update `bin/nupst` launcher:
|
||||
- Option A: Keep as simple wrapper
|
||||
- Option B: Remove and symlink directly to binary
|
||||
- [ ] Update `uninstall.sh`:
|
||||
- Remove vendor directory cleanup
|
||||
- Update paths to new binary location
|
||||
|
||||
### 4.4 Update Systemd Service
|
||||
|
||||
- [ ] Update systemd service file path in `ts/systemd.ts`
|
||||
- [ ] Verify ExecStart points to correct binary location: `/opt/nupst/bin/nupst daemon-start`
|
||||
- [ ] Remove Node.js environment variables if any
|
||||
- [ ] Test service installation and startup
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Testing & Validation (4-6 hours)
|
||||
|
||||
### 5.1 Create Deno Test Suite
|
||||
|
||||
- [ ] Create `tests/` directory (or migrate from existing `test/`)
|
||||
- [ ] Create `tests/snmp_test.ts`: Test SNMP manager functionality
|
||||
- [ ] Create `tests/config_test.ts`: Test configuration loading/saving
|
||||
- [ ] Create `tests/cli_test.ts`: Test CLI parsing and command routing
|
||||
- [ ] Create `tests/daemon_test.ts`: Test daemon logic
|
||||
- [ ] Remove dependency on @git.zone/tstest and @push.rocks/tapbundle
|
||||
- [ ] Use Deno's built-in test runner (`Deno.test()`)
|
||||
|
||||
### 5.2 Unit Tests
|
||||
|
||||
- [ ] Test SNMP connection with mock responses
|
||||
- [ ] Test configuration validation
|
||||
- [ ] Test UPS status parsing for different models
|
||||
- [ ] Test group logic (redundant/non-redundant modes)
|
||||
- [ ] Test threshold checking
|
||||
- [ ] Test version comparison logic
|
||||
|
||||
### 5.3 Integration Tests
|
||||
|
||||
- [ ] Test CLI command parsing for all commands
|
||||
- [ ] Test config file creation and updates
|
||||
- [ ] Test UPS add/edit/remove operations
|
||||
- [ ] Test group add/edit/remove operations
|
||||
- [ ] Mock systemd operations for testing
|
||||
|
||||
### 5.4 Binary Testing
|
||||
|
||||
- [ ] Test compiled binary on Linux x64
|
||||
- [ ] Test compiled binary on Linux ARM64 (if available)
|
||||
- [ ] Test compiled binary on macOS x64 (if available)
|
||||
- [ ] Test compiled binary on macOS ARM64 (if available)
|
||||
- [ ] Test compiled binary on Windows x64 (if available)
|
||||
- [ ] Verify SNMP functionality works in compiled binary
|
||||
- [ ] Verify config file operations work in compiled binary
|
||||
- [ ] Test systemd integration with compiled binary
|
||||
|
||||
### 5.5 Performance Testing
|
||||
|
||||
- [ ] Measure binary size for each platform
|
||||
- [ ] Measure startup time: `time ./nupst-linux-x64 --version`
|
||||
- [ ] Measure memory footprint during daemon operation
|
||||
- [ ] Compare with Node.js version performance
|
||||
- [ ] Document performance metrics
|
||||
|
||||
### 5.6 Upgrade Path Testing
|
||||
|
||||
- [ ] Create test with v3.x config
|
||||
- [ ] Verify v4.x can read existing config
|
||||
- [ ] Test migration from old commands to new commands
|
||||
- [ ] Verify systemd service upgrade path
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Distribution Strategy (2-3 hours)
|
||||
|
||||
### 6.1 GitHub Actions Workflow
|
||||
|
||||
- [ ] Create `.github/workflows/release.yml`:
|
||||
```yaml
|
||||
name: Release
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: denoland/setup-deno@v1
|
||||
with:
|
||||
deno-version: v1.x
|
||||
- name: Compile binaries
|
||||
run: deno task compile
|
||||
- name: Generate checksums
|
||||
run: |
|
||||
cd dist/binaries
|
||||
sha256sum * > SHA256SUMS
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
files: dist/binaries/*
|
||||
generate_release_notes: true
|
||||
```
|
||||
|
||||
### 6.2 Update package.json for npm
|
||||
|
||||
- [ ] Update version to 4.0.0
|
||||
- [ ] Update description to mention Deno
|
||||
- [ ] Add postinstall script to symlink appropriate binary:
|
||||
```json
|
||||
{
|
||||
"name": "@serve.zone/nupst",
|
||||
"version": "4.0.0",
|
||||
"description": "UPS Shutdown Tool - Deno-based single executable",
|
||||
"bin": {
|
||||
"nupst": "bin/nupst-npm-wrapper.js"
|
||||
},
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"postinstall": "node bin/setup-npm-binary.js"
|
||||
},
|
||||
"files": [
|
||||
"dist/binaries/*",
|
||||
"bin/*"
|
||||
]
|
||||
}
|
||||
```
|
||||
- [ ] Create `bin/setup-npm-binary.js` to symlink correct binary
|
||||
- [ ] Create `bin/nupst-npm-wrapper.js` as entry point
|
||||
|
||||
### 6.3 Verify Distribution Methods
|
||||
|
||||
- [ ] Test GitHub release download and installation
|
||||
- [ ] Test npm install from tarball
|
||||
- [ ] Test direct install.sh script
|
||||
- [ ] Verify all methods create working installation
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Documentation Updates (2-3 hours)
|
||||
|
||||
### 7.1 Update README.md
|
||||
|
||||
- [ ] Remove Node.js requirements section
|
||||
- [ ] Update features list (mention Deno, single executable)
|
||||
- [ ] Update installation methods:
|
||||
- Method 1: Quick install script (updated)
|
||||
- Method 2: GitHub releases (new)
|
||||
- Method 3: npm (updated with notes)
|
||||
- [ ] Update usage section with new command structure
|
||||
- [ ] Add command mapping table (v3 → v4)
|
||||
- [ ] Update platform support matrix (note: no Windows ARM)
|
||||
- [ ] Update "System Changes" section (no vendor directory)
|
||||
- [ ] Update security section (remove Node.js mentions)
|
||||
- [ ] Update uninstallation instructions
|
||||
|
||||
### 7.2 Create MIGRATION.md
|
||||
|
||||
- [ ] Create detailed migration guide from v3.x to v4.x
|
||||
- [ ] List all breaking changes:
|
||||
1. CLI command structure reorganization
|
||||
2. No Node.js requirement
|
||||
3. Windows ARM not supported
|
||||
4. Installation path changes
|
||||
- [ ] Provide command mapping table
|
||||
- [ ] Explain config compatibility
|
||||
- [ ] Document upgrade procedure
|
||||
- [ ] Add rollback instructions
|
||||
|
||||
### 7.3 Update CHANGELOG.md
|
||||
|
||||
- [ ] Add v4.0.0 section with all breaking changes
|
||||
- [ ] List new features (Deno, single executable)
|
||||
- [ ] List improvements (startup time, binary size)
|
||||
- [ ] List removed features (Windows ARM, setup command alias)
|
||||
- [ ] Migration guide reference
|
||||
|
||||
### 7.4 Update Help Text
|
||||
|
||||
- [ ] Ensure all help commands show new structure
|
||||
- [ ] Add examples for common operations
|
||||
- [ ] Include migration notes in help output
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: Cleanup & Finalization (1 hour)
|
||||
|
||||
### 8.1 Remove Obsolete Files
|
||||
|
||||
- [ ] Delete `vendor/` directory (Node.js binaries)
|
||||
- [ ] Delete `dist/` directory (old compiled JS)
|
||||
- [ ] Delete `dist_ts/` directory (old compiled TS)
|
||||
- [ ] Delete `node_modules/` directory
|
||||
- [ ] Remove or update `tsconfig.json` (decide if needed for npm compatibility)
|
||||
- [ ] Remove `setup.sh` if no longer needed
|
||||
- [ ] Remove old test files in `test/` if migrated to `tests/`
|
||||
- [ ] Delete `pnpm-lock.yaml`
|
||||
|
||||
### 8.2 Update Git Configuration
|
||||
|
||||
- [ ] Update `.gitignore`:
|
||||
```
|
||||
# Deno
|
||||
.deno/
|
||||
deno.lock
|
||||
|
||||
# Compiled binaries
|
||||
dist/binaries/
|
||||
|
||||
# Old Node.js artifacts (to be removed)
|
||||
node_modules/
|
||||
vendor/
|
||||
dist/
|
||||
dist_ts/
|
||||
pnpm-lock.yaml
|
||||
```
|
||||
- [ ] Add `deno.lock` to version control
|
||||
- [ ] Create `.denoignore` if needed
|
||||
|
||||
### 8.3 Final Validation
|
||||
|
||||
- [ ] Run `deno check mod.ts` - verify no type errors
|
||||
- [ ] Run `deno lint` - verify code quality
|
||||
- [ ] Run `deno fmt --check` - verify formatting
|
||||
- [ ] Run `deno task test` - verify all tests pass
|
||||
- [ ] Run `deno task compile` - verify all binaries compile
|
||||
- [ ] Test each binary manually
|
||||
|
||||
### 8.4 Prepare for Release
|
||||
|
||||
- [ ] Create git tag: `v4.0.0`
|
||||
- [ ] Push to main branch
|
||||
- [ ] Push tags to trigger release workflow
|
||||
- [ ] Verify GitHub Actions workflow succeeds
|
||||
- [ ] Verify binaries are attached to release
|
||||
- [ ] Test installation from GitHub release
|
||||
- [ ] Publish to npm: `npm publish`
|
||||
- [ ] Test npm installation
|
||||
|
||||
---
|
||||
|
||||
## Rollback Strategy
|
||||
|
||||
If critical issues are discovered:
|
||||
|
||||
- [ ] Keep `v3.1.2` tag available for rollback
|
||||
- [ ] Create `v3-stable` branch for continued v3 maintenance
|
||||
- [ ] Update install.sh to offer v3/v4 choice
|
||||
- [ ] Document known issues in GitHub Issues
|
||||
- [ ] Provide downgrade instructions in docs
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria Checklist
|
||||
|
||||
- [ ] ✅ All 5 platform binaries compile successfully
|
||||
- [ ] ✅ Binary sizes are reasonable (< 100MB per platform)
|
||||
- [ ] ✅ Startup time < 2 seconds
|
||||
- [ ] ✅ SNMP v1/v2c/v3 functionality verified on real UPS device
|
||||
- [ ] ✅ All CLI commands work with new structure
|
||||
- [ ] ✅ Config file compatibility maintained
|
||||
- [ ] ✅ Systemd integration works on Linux
|
||||
- [ ] ✅ Installation scripts work on fresh systems
|
||||
- [ ] ✅ npm package still installable and functional
|
||||
- [ ] ✅ All tests pass
|
||||
- [ ] ✅ Documentation is complete and accurate
|
||||
- [ ] ✅ GitHub release created with binaries
|
||||
- [ ] ✅ Migration guide tested by following it step-by-step
|
||||
|
||||
---
|
||||
|
||||
## Timeline
|
||||
|
||||
- **Phase 0**: 1 hour ✓ (in progress)
|
||||
- **Phase 1**: 4-6 hours
|
||||
- **Phase 2**: 3-4 hours
|
||||
- **Phase 3**: 3-4 hours
|
||||
- **Phase 4**: 2-3 hours
|
||||
- **Phase 5**: 4-6 hours
|
||||
- **Phase 6**: 2-3 hours
|
||||
- **Phase 7**: 2-3 hours
|
||||
- **Phase 8**: 1 hour
|
||||
|
||||
**Total Estimate**: 22-31 hours
|
||||
|
||||
---
|
||||
|
||||
## Notes & Decisions
|
||||
|
||||
### Key Decisions Made:
|
||||
|
||||
1. ✅ Use npm:net-snmp (no pure Deno SNMP library available)
|
||||
2. ✅ Major version bump to 4.0.0 (breaking changes)
|
||||
3. ✅ CLI reorganization with subcommands
|
||||
4. ✅ Keep npm publishing alongside binary distribution
|
||||
5. ✅ 5 platform targets (Windows ARM not supported by Deno yet)
|
||||
|
||||
### Open Questions:
|
||||
|
||||
- [ ] Should we keep tsconfig.json for npm package compatibility?
|
||||
- [ ] Should we fully migrate to Deno APIs (Deno.readFile) or keep node:fs?
|
||||
- [ ] Should we remove the `bin/nupst` wrapper or keep it?
|
||||
- [ ] Should setup.sh be completely removed or kept for dependencies?
|
||||
|
||||
### Risk Areas:
|
||||
|
||||
- ⚠️ SNMP native addon compatibility in compiled binaries (HIGH PRIORITY TO TEST)
|
||||
- ⚠️ Systemd integration with new binary structure
|
||||
- ⚠️ Config migration from v3 to v4
|
||||
- ⚠️ npm package installation with embedded binaries
|
||||
@@ -0,0 +1,237 @@
|
||||
#!/usr/bin/env node
|
||||
// deno-lint-ignore-file no-unused-vars
|
||||
|
||||
/**
|
||||
* NUPST npm postinstall script
|
||||
* Downloads the appropriate binary for the current platform from GitHub releases
|
||||
*/
|
||||
|
||||
import { arch, platform } from 'os';
|
||||
import { chmodSync, existsSync, mkdirSync, unlinkSync } from 'fs';
|
||||
import { dirname, join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import https from 'https';
|
||||
import { pipeline } from 'stream';
|
||||
import { promisify } from 'util';
|
||||
import { createWriteStream } from 'fs';
|
||||
import process from 'node:process';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const streamPipeline = promisify(pipeline);
|
||||
|
||||
// Configuration
|
||||
const REPO_BASE = 'https://code.foss.global/serve.zone/nupst';
|
||||
const VERSION = process.env.npm_package_version || '5.0.5';
|
||||
|
||||
function getBinaryInfo() {
|
||||
const plat = platform();
|
||||
const architecture = arch();
|
||||
|
||||
const platformMap = {
|
||||
'darwin': 'macos',
|
||||
'linux': 'linux',
|
||||
'win32': 'windows',
|
||||
};
|
||||
|
||||
const archMap = {
|
||||
'x64': 'x64',
|
||||
'arm64': 'arm64',
|
||||
};
|
||||
|
||||
const mappedPlatform = platformMap[plat];
|
||||
const mappedArch = archMap[architecture];
|
||||
|
||||
if (!mappedPlatform || !mappedArch) {
|
||||
return { supported: false, platform: plat, arch: architecture };
|
||||
}
|
||||
|
||||
let binaryName = `nupst-${mappedPlatform}-${mappedArch}`;
|
||||
if (plat === 'win32') {
|
||||
binaryName += '.exe';
|
||||
}
|
||||
|
||||
return {
|
||||
supported: true,
|
||||
platform: mappedPlatform,
|
||||
arch: mappedArch,
|
||||
binaryName,
|
||||
originalPlatform: plat,
|
||||
};
|
||||
}
|
||||
|
||||
function downloadFile(url, destination) {
|
||||
return new Promise((resolve, reject) => {
|
||||
console.log(`Downloading from: ${url}`);
|
||||
|
||||
// Follow redirects
|
||||
const download = (url, redirectCount = 0) => {
|
||||
if (redirectCount > 5) {
|
||||
reject(new Error('Too many redirects'));
|
||||
return;
|
||||
}
|
||||
|
||||
https.get(url, (response) => {
|
||||
if (response.statusCode === 301 || response.statusCode === 302) {
|
||||
console.log(`Following redirect to: ${response.headers.location}`);
|
||||
download(response.headers.location, redirectCount + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.statusCode !== 200) {
|
||||
reject(new Error(`Failed to download: ${response.statusCode} ${response.statusMessage}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const totalSize = parseInt(response.headers['content-length'], 10);
|
||||
let downloadedSize = 0;
|
||||
let lastProgress = 0;
|
||||
|
||||
response.on('data', (chunk) => {
|
||||
downloadedSize += chunk.length;
|
||||
const progress = Math.round((downloadedSize / totalSize) * 100);
|
||||
|
||||
// Only log every 10% to reduce noise
|
||||
if (progress >= lastProgress + 10) {
|
||||
console.log(`Download progress: ${progress}%`);
|
||||
lastProgress = progress;
|
||||
}
|
||||
});
|
||||
|
||||
const file = createWriteStream(destination);
|
||||
|
||||
pipeline(response, file, (err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
console.log('Download complete!');
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}).on('error', reject);
|
||||
};
|
||||
|
||||
download(url);
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('===========================================');
|
||||
console.log(' NUPST - Binary Installation');
|
||||
console.log('===========================================');
|
||||
console.log('');
|
||||
|
||||
const binaryInfo = getBinaryInfo();
|
||||
|
||||
if (!binaryInfo.supported) {
|
||||
console.error(
|
||||
`❌ Error: Unsupported platform/architecture: ${binaryInfo.platform}/${binaryInfo.arch}`,
|
||||
);
|
||||
console.error('');
|
||||
console.error('Supported platforms:');
|
||||
console.error(' • Linux (x64, arm64)');
|
||||
console.error(' • macOS (x64, arm64)');
|
||||
console.error(' • Windows (x64)');
|
||||
console.error('');
|
||||
console.error('If you believe your platform should be supported, please file an issue:');
|
||||
console.error(' https://code.foss.global/serve.zone/nupst/issues');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Platform: ${binaryInfo.platform} (${binaryInfo.originalPlatform})`);
|
||||
console.log(`Architecture: ${binaryInfo.arch}`);
|
||||
console.log(`Binary: ${binaryInfo.binaryName}`);
|
||||
console.log(`Version: ${VERSION}`);
|
||||
console.log('');
|
||||
|
||||
// Create dist/binaries directory if it doesn't exist
|
||||
const binariesDir = join(__dirname, '..', 'dist', 'binaries');
|
||||
if (!existsSync(binariesDir)) {
|
||||
console.log('Creating binaries directory...');
|
||||
mkdirSync(binariesDir, { recursive: true });
|
||||
}
|
||||
|
||||
const binaryPath = join(binariesDir, binaryInfo.binaryName);
|
||||
|
||||
// Check if binary already exists and skip download
|
||||
if (existsSync(binaryPath)) {
|
||||
console.log('✓ Binary already exists, skipping download');
|
||||
} else {
|
||||
// Construct download URL
|
||||
// Try release URL first, fall back to raw branch if needed
|
||||
const releaseUrl = `${REPO_BASE}/releases/download/v${VERSION}/${binaryInfo.binaryName}`;
|
||||
const fallbackUrl = `${REPO_BASE}/raw/branch/main/dist/binaries/${binaryInfo.binaryName}`;
|
||||
|
||||
console.log('Downloading platform-specific binary...');
|
||||
console.log('This may take a moment depending on your connection speed.');
|
||||
console.log('');
|
||||
|
||||
try {
|
||||
// Try downloading from release
|
||||
await downloadFile(releaseUrl, binaryPath);
|
||||
} catch (err) {
|
||||
console.log(`Release download failed: ${err.message}`);
|
||||
console.log('Trying fallback URL...');
|
||||
|
||||
try {
|
||||
// Try fallback URL
|
||||
await downloadFile(fallbackUrl, binaryPath);
|
||||
} catch (fallbackErr) {
|
||||
console.error(`❌ Error: Failed to download binary`);
|
||||
console.error(` Primary URL: ${releaseUrl}`);
|
||||
console.error(` Fallback URL: ${fallbackUrl}`);
|
||||
console.error('');
|
||||
console.error('This might be because:');
|
||||
console.error('1. The release has not been created yet');
|
||||
console.error('2. Network connectivity issues');
|
||||
console.error('3. The version specified does not exist');
|
||||
console.error('');
|
||||
console.error('You can try:');
|
||||
console.error('1. Installing from source: https://code.foss.global/serve.zone/nupst');
|
||||
console.error('2. Downloading the binary manually from the releases page');
|
||||
console.error(
|
||||
'3. Using the install script: curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash',
|
||||
);
|
||||
|
||||
// Clean up partial download
|
||||
if (existsSync(binaryPath)) {
|
||||
unlinkSync(binaryPath);
|
||||
}
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`✓ Binary downloaded successfully`);
|
||||
}
|
||||
|
||||
// On Unix-like systems, ensure the binary is executable
|
||||
if (binaryInfo.originalPlatform !== 'win32') {
|
||||
try {
|
||||
console.log('Setting executable permissions...');
|
||||
chmodSync(binaryPath, 0o755);
|
||||
console.log('✓ Binary permissions updated');
|
||||
} catch (err) {
|
||||
console.error(`⚠️ Warning: Could not set executable permissions: ${err.message}`);
|
||||
console.error(' You may need to manually run:');
|
||||
console.error(` chmod +x ${binaryPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log('✅ NUPST installation completed successfully!');
|
||||
console.log('');
|
||||
console.log('You can now use NUPST by running:');
|
||||
console.log(' nupst --help');
|
||||
console.log('');
|
||||
console.log('For initial setup, run:');
|
||||
console.log(' sudo nupst ups add');
|
||||
console.log('');
|
||||
console.log('===========================================');
|
||||
}
|
||||
|
||||
// Run the installation
|
||||
main().catch((err) => {
|
||||
console.error(`❌ Installation failed: ${err.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
Binary file not shown.
@@ -1,227 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# NUPST Setup Script
|
||||
# Downloads the appropriate Node.js binary for the current platform
|
||||
|
||||
# Find the directory where this script is located
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
|
||||
|
||||
# Create vendor directory if it doesn't exist
|
||||
mkdir -p "$SCRIPT_DIR/vendor"
|
||||
|
||||
# Get the latest LTS Node.js version
|
||||
echo "Determining latest LTS Node.js version..."
|
||||
NODE_VERSIONS_JSON=$(curl -s https://nodejs.org/dist/index.json)
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Warning: Could not fetch latest Node.js versions. Using fallback version."
|
||||
NODE_VERSION="20.11.1" # Fallback to a recent LTS version
|
||||
else
|
||||
# Extract the latest LTS version (those marked with lts field)
|
||||
NODE_VERSION=$(echo "$NODE_VERSIONS_JSON" | grep -o '"version":"v[0-9.]*".*"lts":[^,]*' | grep -v '"lts":false' | grep -o 'v[0-9.]*' | head -1 | cut -c 2-)
|
||||
|
||||
if [ -z "$NODE_VERSION" ]; then
|
||||
echo "Warning: Could not determine latest LTS version. Using fallback version."
|
||||
NODE_VERSION="20.11.1" # Fallback to a recent LTS version
|
||||
else
|
||||
echo "Latest Node.js LTS version: $NODE_VERSION"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Detect architecture
|
||||
ARCH=$(uname -m)
|
||||
OS=$(uname -s)
|
||||
|
||||
# Map architecture and OS to Node.js download URL
|
||||
NODE_URL=""
|
||||
NODE_DIR=""
|
||||
case "$OS" in
|
||||
Linux)
|
||||
case "$ARCH" in
|
||||
x86_64)
|
||||
NODE_URL="https://nodejs.org/dist/v$NODE_VERSION/node-v$NODE_VERSION-linux-x64.tar.gz"
|
||||
NODE_DIR="node-linux-x64"
|
||||
;;
|
||||
aarch64|arm64)
|
||||
NODE_URL="https://nodejs.org/dist/v$NODE_VERSION/node-v$NODE_VERSION-linux-arm64.tar.gz"
|
||||
NODE_DIR="node-linux-arm64"
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported architecture: $ARCH. Please install Node.js manually."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
Darwin)
|
||||
case "$ARCH" in
|
||||
x86_64)
|
||||
NODE_URL="https://nodejs.org/dist/v$NODE_VERSION/node-v$NODE_VERSION-darwin-x64.tar.gz"
|
||||
NODE_DIR="node-darwin-x64"
|
||||
;;
|
||||
arm64)
|
||||
NODE_URL="https://nodejs.org/dist/v$NODE_VERSION/node-v$NODE_VERSION-darwin-arm64.tar.gz"
|
||||
NODE_DIR="node-darwin-arm64"
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported architecture: $ARCH. Please install Node.js manually."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported operating system: $OS. Please install Node.js manually."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Check if we already have the Node.js binary
|
||||
if [ -f "$SCRIPT_DIR/vendor/$NODE_DIR/bin/node" ]; then
|
||||
echo "Node.js binary already exists for $OS-$ARCH. Skipping download."
|
||||
else
|
||||
echo "Downloading Node.js v$NODE_VERSION for $OS-$ARCH..."
|
||||
|
||||
# Download and extract Node.js
|
||||
TMP_FILE="$SCRIPT_DIR/vendor/node.tar.gz"
|
||||
curl -L "$NODE_URL" -o "$TMP_FILE"
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Error downloading Node.js. Please check your internet connection and try again."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create target directory
|
||||
mkdir -p "$SCRIPT_DIR/vendor/$NODE_DIR"
|
||||
|
||||
# Extract Node.js
|
||||
tar -xzf "$TMP_FILE" -C "$SCRIPT_DIR/vendor"
|
||||
|
||||
# Move extracted files to the target directory
|
||||
NODE_EXTRACT_DIR=$(find "$SCRIPT_DIR/vendor" -maxdepth 1 -name "node-v*" -type d | head -n 1)
|
||||
if [ -d "$NODE_EXTRACT_DIR" ]; then
|
||||
cp -R "$NODE_EXTRACT_DIR"/* "$SCRIPT_DIR/vendor/$NODE_DIR/"
|
||||
rm -rf "$NODE_EXTRACT_DIR"
|
||||
else
|
||||
echo "Error extracting Node.js. Please try again."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Clean up
|
||||
rm "$TMP_FILE"
|
||||
|
||||
echo "Node.js v$NODE_VERSION for $OS-$ARCH has been downloaded and extracted."
|
||||
fi
|
||||
|
||||
# Remove any existing dist_ts directory
|
||||
if [ -d "$SCRIPT_DIR/dist_ts" ]; then
|
||||
echo "Removing existing dist_ts directory..."
|
||||
rm -rf "$SCRIPT_DIR/dist_ts"
|
||||
fi
|
||||
|
||||
# Download dist_ts from npm registry
|
||||
echo "Downloading dist_ts from npm registry..."
|
||||
|
||||
# Create temp directory
|
||||
TEMP_DIR=$(mktemp -d)
|
||||
|
||||
# Get version from package.json
|
||||
if [ -f "$SCRIPT_DIR/package.json" ]; then
|
||||
echo "Reading version from package.json..."
|
||||
# Extract version using grep and cut
|
||||
VERSION=$(grep -o '"version": "[^"]*"' "$SCRIPT_DIR/package.json" | cut -d'"' -f4)
|
||||
|
||||
if [ -z "$VERSION" ]; then
|
||||
echo "Error: Could not determine version from package.json."
|
||||
rm -rf "$TEMP_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Package version is $VERSION. Downloading matching package tarball..."
|
||||
else
|
||||
echo "Warning: package.json not found. Getting latest version from npm registry..."
|
||||
VERSION=$(curl -s https://registry.npmjs.org/@serve.zone/nupst | grep -o '"latest":"[^"]*"' | cut -d'"' -f4)
|
||||
|
||||
if [ -z "$VERSION" ]; then
|
||||
echo "Error: Could not determine version from npm registry."
|
||||
rm -rf "$TEMP_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Latest version is $VERSION. Using as fallback."
|
||||
fi
|
||||
|
||||
# First try to download with the version from package.json
|
||||
TARBALL_URL="https://registry.npmjs.org/@serve.zone/nupst/-/nupst-$VERSION.tgz"
|
||||
TARBALL_PATH="$TEMP_DIR/nupst.tgz"
|
||||
|
||||
echo "Attempting to download version $VERSION from $TARBALL_URL..."
|
||||
curl -sL "$TARBALL_URL" -o "$TARBALL_PATH"
|
||||
|
||||
# If download fails or file is empty, try to get the latest version from npm
|
||||
if [ $? -ne 0 ] || [ ! -s "$TARBALL_PATH" ]; then
|
||||
echo "Package version $VERSION not found on npm registry."
|
||||
echo "Fetching latest version information from npm registry..."
|
||||
|
||||
# Get latest version from npm registry
|
||||
NPM_REGISTRY_INFO=$(curl -s https://registry.npmjs.org/@serve.zone/nupst)
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Error: Could not connect to npm registry."
|
||||
echo "Will attempt to build from source instead."
|
||||
rm -rf "$TEMP_DIR"
|
||||
mkdir -p "$SCRIPT_DIR/dist_ts"
|
||||
BUILD_FROM_SOURCE=1
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Extract latest version
|
||||
LATEST_VERSION=$(echo "$NPM_REGISTRY_INFO" | grep -o '"latest":"[^"]*"' | cut -d'"' -f4)
|
||||
|
||||
if [ -z "$LATEST_VERSION" ]; then
|
||||
echo "Error: Could not determine latest version from npm registry."
|
||||
echo "Will attempt to build from source instead."
|
||||
rm -rf "$TEMP_DIR"
|
||||
mkdir -p "$SCRIPT_DIR/dist_ts"
|
||||
BUILD_FROM_SOURCE=1
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "Found latest version: $LATEST_VERSION. Downloading..."
|
||||
|
||||
TARBALL_URL="https://registry.npmjs.org/@serve.zone/nupst/-/nupst-$LATEST_VERSION.tgz"
|
||||
TARBALL_PATH="$TEMP_DIR/nupst.tgz"
|
||||
|
||||
curl -sL "$TARBALL_URL" -o "$TARBALL_PATH"
|
||||
|
||||
if [ $? -ne 0 ] || [ ! -s "$TARBALL_PATH" ]; then
|
||||
echo "Error: Failed to download any package version from npm registry."
|
||||
echo "Installation cannot continue without the dist_ts directory."
|
||||
rm -rf "$TEMP_DIR"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Extract the tarball
|
||||
mkdir -p "$TEMP_DIR/extract"
|
||||
tar -xzf "$TARBALL_PATH" -C "$TEMP_DIR/extract"
|
||||
|
||||
# Copy dist_ts to the installation directory
|
||||
if [ -d "$TEMP_DIR/extract/package/dist_ts" ]; then
|
||||
echo "Copying dist_ts directory to installation..."
|
||||
mkdir -p "$SCRIPT_DIR/dist_ts"
|
||||
cp -R "$TEMP_DIR/extract/package/dist_ts/"* "$SCRIPT_DIR/dist_ts/"
|
||||
else
|
||||
echo "Error: dist_ts directory not found in the downloaded npm package."
|
||||
rm -rf "$TEMP_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Clean up
|
||||
rm -rf "$TEMP_DIR"
|
||||
|
||||
echo "dist_ts directory successfully downloaded from npm registry."
|
||||
|
||||
# Make launcher script executable
|
||||
chmod +x "$SCRIPT_DIR/bin/nupst"
|
||||
|
||||
echo "NUPST setup completed successfully."
|
||||
echo "You can now run NUPST using: $SCRIPT_DIR/bin/nupst"
|
||||
echo "To install NUPST globally, run: sudo ln -s $SCRIPT_DIR/bin/nupst /usr/local/bin/nupst"
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Test fresh v4 installation from scratch
|
||||
# Tests the most common user scenario: clean install using curl | bash
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
CONTAINER_NAME="nupst-test-fresh-v4"
|
||||
|
||||
echo "================================================"
|
||||
echo " NUPST Fresh v4 Installation Test"
|
||||
echo "================================================"
|
||||
echo ""
|
||||
|
||||
# Check if container already exists
|
||||
if docker ps -a --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
|
||||
echo "⚠️ Container ${CONTAINER_NAME} already exists"
|
||||
read -p "Remove and recreate? (y/N): " -n 1 -r
|
||||
echo
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
echo "→ Stopping and removing existing container..."
|
||||
docker stop ${CONTAINER_NAME} 2>/dev/null || true
|
||||
docker rm ${CONTAINER_NAME} 2>/dev/null || true
|
||||
else
|
||||
echo "Exiting. Remove manually with: docker rm -f ${CONTAINER_NAME}"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "→ Creating Docker container with systemd..."
|
||||
docker run -d \
|
||||
--name ${CONTAINER_NAME} \
|
||||
--privileged \
|
||||
--cgroupns=host \
|
||||
-v /sys/fs/cgroup:/sys/fs/cgroup:rw \
|
||||
ubuntu:22.04 \
|
||||
/bin/bash -c "apt-get update && apt-get install -y systemd systemd-sysv && exec /sbin/init"
|
||||
|
||||
echo "→ Waiting for systemd to initialize..."
|
||||
sleep 10
|
||||
|
||||
echo "→ Waiting for dpkg lock to be released..."
|
||||
docker exec ${CONTAINER_NAME} bash -c "
|
||||
while fuser /var/lib/dpkg/lock-frontend >/dev/null 2>&1; do
|
||||
echo ' Waiting for dpkg lock...'
|
||||
sleep 2
|
||||
done
|
||||
echo ' dpkg lock released'
|
||||
"
|
||||
|
||||
echo "→ Installing prerequisites (curl)..."
|
||||
docker exec ${CONTAINER_NAME} bash -c "
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq curl
|
||||
"
|
||||
|
||||
echo ""
|
||||
echo "→ Installing NUPST v4 using curl | bash..."
|
||||
echo " Command: curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | bash -s -- -y"
|
||||
echo ""
|
||||
|
||||
docker exec ${CONTAINER_NAME} bash -c "
|
||||
curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | bash -s -- -y
|
||||
"
|
||||
|
||||
echo ""
|
||||
echo "================================================"
|
||||
echo " Verifying Installation"
|
||||
echo "================================================"
|
||||
echo ""
|
||||
|
||||
echo "→ Checking binary location..."
|
||||
docker exec ${CONTAINER_NAME} bash -c "
|
||||
if [ -f /opt/nupst/nupst ]; then
|
||||
echo ' ✓ Binary exists at /opt/nupst/nupst'
|
||||
ls -lh /opt/nupst/nupst
|
||||
else
|
||||
echo ' ✗ Binary not found at /opt/nupst/nupst'
|
||||
exit 1
|
||||
fi
|
||||
"
|
||||
|
||||
echo ""
|
||||
echo "→ Checking symlink..."
|
||||
docker exec ${CONTAINER_NAME} bash -c "
|
||||
if [ -L /usr/local/bin/nupst ]; then
|
||||
echo ' ✓ Symlink exists at /usr/local/bin/nupst'
|
||||
ls -lh /usr/local/bin/nupst
|
||||
elif [ -L /usr/bin/nupst ]; then
|
||||
echo ' ✓ Symlink exists at /usr/bin/nupst'
|
||||
ls -lh /usr/bin/nupst
|
||||
else
|
||||
echo ' ✗ Symlink not found in /usr/local/bin or /usr/bin'
|
||||
exit 1
|
||||
fi
|
||||
"
|
||||
|
||||
echo ""
|
||||
echo "→ Checking PATH integration..."
|
||||
docker exec ${CONTAINER_NAME} bash -c "
|
||||
NUPST_PATH=\$(which nupst 2>/dev/null)
|
||||
if [ -n \"\$NUPST_PATH\" ]; then
|
||||
echo ' ✓ nupst found in PATH at: '\$NUPST_PATH
|
||||
else
|
||||
echo ' ✗ nupst not found in PATH'
|
||||
echo ' PATH contents:'
|
||||
echo \$PATH
|
||||
exit 1
|
||||
fi
|
||||
"
|
||||
|
||||
echo ""
|
||||
echo "→ Testing nupst command execution..."
|
||||
docker exec ${CONTAINER_NAME} nupst --version
|
||||
|
||||
echo ""
|
||||
echo "→ Creating minimal config for service test..."
|
||||
docker exec ${CONTAINER_NAME} bash -c "
|
||||
mkdir -p /etc/nupst
|
||||
cat > /etc/nupst/config.json << 'EOF'
|
||||
{
|
||||
\"version\": \"4.0\",
|
||||
\"upsDevices\": [],
|
||||
\"groups\": [],
|
||||
\"checkInterval\": 30000
|
||||
}
|
||||
EOF
|
||||
echo ' ✓ Minimal config created'
|
||||
"
|
||||
|
||||
echo ""
|
||||
echo "→ Testing service creation..."
|
||||
docker exec ${CONTAINER_NAME} bash -c "
|
||||
echo ' Running: nupst service enable'
|
||||
nupst service enable
|
||||
|
||||
if [ -f /etc/systemd/system/nupst.service ]; then
|
||||
echo ' ✓ Service file created successfully'
|
||||
else
|
||||
echo ' ✗ Service file creation failed'
|
||||
exit 1
|
||||
fi
|
||||
"
|
||||
|
||||
echo ""
|
||||
echo "→ Checking if service is enabled..."
|
||||
docker exec ${CONTAINER_NAME} systemctl is-enabled nupst
|
||||
|
||||
echo ""
|
||||
echo "================================================"
|
||||
echo " ✓ Fresh v4 Installation Test Complete"
|
||||
echo "================================================"
|
||||
echo ""
|
||||
echo "Installation verified successfully:"
|
||||
echo " • Binary installed to /opt/nupst/nupst"
|
||||
echo " • Symlink created for global access"
|
||||
echo " • nupst command available in PATH"
|
||||
echo " • Command executes correctly"
|
||||
echo " • Systemd service file created"
|
||||
echo ""
|
||||
echo "Useful commands:"
|
||||
echo " docker exec -it ${CONTAINER_NAME} bash"
|
||||
echo " docker exec ${CONTAINER_NAME} nupst --help"
|
||||
echo " docker exec ${CONTAINER_NAME} nupst service status"
|
||||
echo " docker stop ${CONTAINER_NAME}"
|
||||
echo " docker rm -f ${CONTAINER_NAME}"
|
||||
echo ""
|
||||
Executable
+148
@@ -0,0 +1,148 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Setup Docker container with systemd and install NUPST v3
|
||||
# This creates a container from commit 806f81c6a057a2a5da586b96a231d391f12eb1bb (v3)
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
CONTAINER_NAME="nupst-test-v3"
|
||||
V3_COMMIT="806f81c6a057a2a5da586b96a231d391f12eb1bb"
|
||||
|
||||
echo "================================================"
|
||||
echo " NUPST v3 Test Container Setup"
|
||||
echo "================================================"
|
||||
echo ""
|
||||
|
||||
# Check if container already exists
|
||||
if docker ps -a --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
|
||||
echo "⚠️ Container ${CONTAINER_NAME} already exists"
|
||||
read -p "Remove and recreate? (y/N): " -n 1 -r
|
||||
echo
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
echo "→ Stopping and removing existing container..."
|
||||
docker stop ${CONTAINER_NAME} 2>/dev/null || true
|
||||
docker rm ${CONTAINER_NAME} 2>/dev/null || true
|
||||
else
|
||||
echo "Exiting. Remove manually with: docker rm -f ${CONTAINER_NAME}"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "→ Creating Docker container (will install systemd)..."
|
||||
docker run -d \
|
||||
--name ${CONTAINER_NAME} \
|
||||
--privileged \
|
||||
--cgroupns=host \
|
||||
-v /sys/fs/cgroup:/sys/fs/cgroup:rw \
|
||||
ubuntu:22.04 \
|
||||
/bin/bash -c "apt-get update && apt-get install -y systemd systemd-sysv && exec /sbin/init"
|
||||
|
||||
echo "→ Waiting for systemd to initialize..."
|
||||
sleep 10
|
||||
|
||||
echo "→ Waiting for dpkg lock to be released..."
|
||||
docker exec ${CONTAINER_NAME} bash -c "
|
||||
while fuser /var/lib/dpkg/lock-frontend >/dev/null 2>&1; do
|
||||
echo ' Waiting for dpkg lock...'
|
||||
sleep 2
|
||||
done
|
||||
echo ' dpkg lock released'
|
||||
"
|
||||
|
||||
echo "→ Installing prerequisites in container..."
|
||||
docker exec ${CONTAINER_NAME} bash -c "
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq git curl sudo jq
|
||||
"
|
||||
|
||||
echo "→ Cloning NUPST v3 (commit ${V3_COMMIT})..."
|
||||
docker exec ${CONTAINER_NAME} bash -c "
|
||||
cd /opt
|
||||
git clone https://code.foss.global/serve.zone/nupst.git
|
||||
cd nupst
|
||||
git checkout ${V3_COMMIT}
|
||||
echo 'Checked out commit:'
|
||||
git log -1 --oneline
|
||||
"
|
||||
|
||||
echo "→ Running NUPST v3 installation directly (bypassing install.sh auto-update)..."
|
||||
docker exec ${CONTAINER_NAME} bash -c "
|
||||
cd /opt/nupst
|
||||
# Run setup.sh directly to avoid install.sh trying to update to v4
|
||||
bash setup.sh -y
|
||||
"
|
||||
|
||||
echo "→ Creating NUPST configuration using real UPS data from .nogit/env.json..."
|
||||
|
||||
# Check if .nogit/env.json exists
|
||||
if [ ! -f "../../.nogit/env.json" ]; then
|
||||
echo "❌ Error: .nogit/env.json not found"
|
||||
echo "This file contains test UPS credentials and is required for testing"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Read UPS data from .nogit/env.json and create v3 config
|
||||
docker exec ${CONTAINER_NAME} bash -c "mkdir -p /etc/nupst"
|
||||
|
||||
# Generate config from .nogit/env.json using jq
|
||||
cat ../../.nogit/env.json | jq -r '
|
||||
{
|
||||
"upsList": [
|
||||
{
|
||||
"id": "test-ups-v1",
|
||||
"name": "Test UPS (SNMP v1)",
|
||||
"host": .testConfigV1.snmp.host,
|
||||
"port": .testConfigV1.snmp.port,
|
||||
"community": .testConfigV1.snmp.community,
|
||||
"version": (.testConfigV1.snmp.version | tostring),
|
||||
"batteryLowOID": "1.3.6.1.4.1.935.1.1.1.3.3.1.0",
|
||||
"onBatteryOID": "1.3.6.1.4.1.935.1.1.1.3.3.2.0",
|
||||
"shutdownCommand": "echo \"Shutdown triggered for test-ups-v1\""
|
||||
},
|
||||
{
|
||||
"id": "test-ups-v3",
|
||||
"name": "Test UPS (SNMP v3)",
|
||||
"host": .testConfigV3.snmp.host,
|
||||
"port": .testConfigV3.snmp.port,
|
||||
"version": (.testConfigV3.snmp.version | tostring),
|
||||
"securityLevel": .testConfigV3.snmp.securityLevel,
|
||||
"username": .testConfigV3.snmp.username,
|
||||
"authProtocol": .testConfigV3.snmp.authProtocol,
|
||||
"authKey": .testConfigV3.snmp.authKey,
|
||||
"batteryLowOID": "1.3.6.1.4.1.935.1.1.1.3.3.1.0",
|
||||
"onBatteryOID": "1.3.6.1.4.1.935.1.1.1.3.3.2.0",
|
||||
"shutdownCommand": "echo \"Shutdown triggered for test-ups-v3\""
|
||||
}
|
||||
],
|
||||
"groups": []
|
||||
}' | docker exec -i ${CONTAINER_NAME} tee /etc/nupst/config.json > /dev/null
|
||||
|
||||
echo " ✓ Real UPS config created at /etc/nupst/config.json (from .nogit/env.json)"
|
||||
|
||||
echo "→ Enabling NUPST systemd service..."
|
||||
docker exec ${CONTAINER_NAME} bash -c "
|
||||
nupst enable
|
||||
"
|
||||
|
||||
echo "→ Starting NUPST service..."
|
||||
docker exec ${CONTAINER_NAME} bash -c "
|
||||
nupst start
|
||||
"
|
||||
|
||||
echo ""
|
||||
echo "================================================"
|
||||
echo " ✓ NUPST v3 Container Ready"
|
||||
echo "================================================"
|
||||
echo ""
|
||||
echo "Container name: ${CONTAINER_NAME}"
|
||||
echo "NUPST version: v3 (commit ${V3_COMMIT})"
|
||||
echo ""
|
||||
echo "Useful commands:"
|
||||
echo " docker exec -it ${CONTAINER_NAME} bash"
|
||||
echo " docker exec ${CONTAINER_NAME} systemctl status nupst"
|
||||
echo " docker exec ${CONTAINER_NAME} nupst --version"
|
||||
echo " docker stop ${CONTAINER_NAME}"
|
||||
echo " docker start ${CONTAINER_NAME}"
|
||||
echo " docker rm -f ${CONTAINER_NAME}"
|
||||
echo ""
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Test migration from v3 to v4
|
||||
# Run this after 01-setup-v3-container.sh
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
CONTAINER_NAME="nupst-test-v3"
|
||||
|
||||
echo "================================================"
|
||||
echo " NUPST v3 → v4 Migration Test"
|
||||
echo "================================================"
|
||||
echo ""
|
||||
|
||||
# Check if container exists
|
||||
if ! docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
|
||||
echo "❌ Container ${CONTAINER_NAME} is not running"
|
||||
echo "Run ./01-setup-v3-container.sh first"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "→ Checking current NUPST status..."
|
||||
docker exec ${CONTAINER_NAME} systemctl status nupst --no-pager || true
|
||||
echo ""
|
||||
|
||||
echo "→ Checking current version..."
|
||||
docker exec ${CONTAINER_NAME} nupst --version
|
||||
echo ""
|
||||
|
||||
echo "→ Stopping v3 service..."
|
||||
docker exec ${CONTAINER_NAME} systemctl stop nupst
|
||||
echo ""
|
||||
|
||||
echo "→ Running v4 installation from main branch (should auto-detect v3 and migrate)..."
|
||||
echo " Using: curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash"
|
||||
docker exec ${CONTAINER_NAME} bash -c "
|
||||
curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | bash -s -- -y
|
||||
"
|
||||
|
||||
echo "→ Checking service status after migration..."
|
||||
docker exec ${CONTAINER_NAME} systemctl status nupst --no-pager || true
|
||||
echo ""
|
||||
|
||||
echo "→ Checking new version..."
|
||||
docker exec ${CONTAINER_NAME} nupst --version
|
||||
echo ""
|
||||
|
||||
echo "→ Testing service commands..."
|
||||
docker exec ${CONTAINER_NAME} nupst service status || true
|
||||
echo ""
|
||||
|
||||
echo "================================================"
|
||||
echo " ✓ Migration Test Complete"
|
||||
echo "================================================"
|
||||
echo ""
|
||||
echo "Check logs with:"
|
||||
echo " docker exec ${CONTAINER_NAME} nupst service logs"
|
||||
echo ""
|
||||
Executable
+28
@@ -0,0 +1,28 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Cleanup test container
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
CONTAINER_NAME="nupst-test-v3"
|
||||
|
||||
echo "================================================"
|
||||
echo " Cleanup NUPST Test Container"
|
||||
echo "================================================"
|
||||
echo ""
|
||||
|
||||
if docker ps -a --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
|
||||
echo "→ Stopping container..."
|
||||
docker stop ${CONTAINER_NAME} 2>/dev/null || true
|
||||
|
||||
echo "→ Removing container..."
|
||||
docker rm ${CONTAINER_NAME} 2>/dev/null || true
|
||||
|
||||
echo ""
|
||||
echo "✓ Container ${CONTAINER_NAME} removed"
|
||||
else
|
||||
echo "Container ${CONTAINER_NAME} not found"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
@@ -0,0 +1,158 @@
|
||||
# Manual Docker Testing Scripts
|
||||
|
||||
This directory contains scripts for manually testing NUPST installation and migration in Docker
|
||||
containers with systemd support.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Docker installed and running
|
||||
- Privileged access (for systemd in container)
|
||||
- Linux host (systemd container requirements)
|
||||
|
||||
## Test Scripts
|
||||
|
||||
### 1. `01-setup-v3-container.sh`
|
||||
|
||||
Creates a Docker container with systemd and installs NUPST v3.
|
||||
|
||||
**What it does:**
|
||||
|
||||
- Creates Ubuntu 22.04 container with systemd enabled
|
||||
- Installs NUPST v3 from commit `806f81c6` (last v3 version)
|
||||
- Enables and starts the systemd service
|
||||
- Leaves container running for testing
|
||||
|
||||
**Usage:**
|
||||
|
||||
```bash
|
||||
chmod +x 01-setup-v3-container.sh
|
||||
./01-setup-v3-container.sh
|
||||
```
|
||||
|
||||
**Container name:** `nupst-test-v3`
|
||||
|
||||
### 2. `02-test-v3-to-v4-migration.sh`
|
||||
|
||||
Tests the migration from v3 to v4.
|
||||
|
||||
**What it does:**
|
||||
|
||||
- Checks current v3 installation
|
||||
- Pulls v4 code from `migration/deno-v4` branch
|
||||
- Runs install.sh (should auto-detect and migrate)
|
||||
- Verifies service is running with v4
|
||||
- Tests basic commands
|
||||
|
||||
**Usage:**
|
||||
|
||||
```bash
|
||||
chmod +x 02-test-v3-to-v4-migration.sh
|
||||
./02-test-v3-to-v4-migration.sh
|
||||
```
|
||||
|
||||
**Prerequisites:** Must run `01-setup-v3-container.sh` first
|
||||
|
||||
### 3. `03-cleanup.sh`
|
||||
|
||||
Removes the test container.
|
||||
|
||||
**Usage:**
|
||||
|
||||
```bash
|
||||
chmod +x 03-cleanup.sh
|
||||
./03-cleanup.sh
|
||||
```
|
||||
|
||||
## Manual Testing Workflow
|
||||
|
||||
### Full Migration Test
|
||||
|
||||
1. **Set up v3 environment:**
|
||||
```bash
|
||||
./01-setup-v3-container.sh
|
||||
```
|
||||
|
||||
2. **Verify v3 is working:**
|
||||
```bash
|
||||
docker exec nupst-test-v3 nupst --version
|
||||
docker exec nupst-test-v3 systemctl status nupst
|
||||
```
|
||||
|
||||
3. **Test migration to v4:**
|
||||
```bash
|
||||
./02-test-v3-to-v4-migration.sh
|
||||
```
|
||||
|
||||
4. **Manual verification:**
|
||||
```bash
|
||||
# Enter container
|
||||
docker exec -it nupst-test-v3 bash
|
||||
|
||||
# Inside container:
|
||||
nupst --version # Should show v4.0.0
|
||||
nupst service status # Should show running service
|
||||
cat /etc/nupst/config.json # Config should be preserved
|
||||
systemctl status nupst # Service should be active
|
||||
```
|
||||
|
||||
5. **Cleanup:**
|
||||
```bash
|
||||
./03-cleanup.sh
|
||||
```
|
||||
|
||||
## Useful Docker Commands
|
||||
|
||||
```bash
|
||||
# Enter container shell
|
||||
docker exec -it nupst-test-v3 bash
|
||||
|
||||
# Check service status
|
||||
docker exec nupst-test-v3 systemctl status nupst
|
||||
|
||||
# View service logs
|
||||
docker exec nupst-test-v3 journalctl -u nupst -n 50
|
||||
|
||||
# Check NUPST version
|
||||
docker exec nupst-test-v3 nupst --version
|
||||
|
||||
# Run NUPST commands
|
||||
docker exec nupst-test-v3 nupst service status
|
||||
docker exec nupst-test-v3 nupst ups list
|
||||
|
||||
# Stop container
|
||||
docker stop nupst-test-v3
|
||||
|
||||
# Start container
|
||||
docker start nupst-test-v3
|
||||
|
||||
# Remove container
|
||||
docker rm -f nupst-test-v3
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- The container runs with `--privileged` flag for systemd support
|
||||
- Container uses Ubuntu 22.04 as base image
|
||||
- v3 installation is from commit `806f81c6a057a2a5da586b96a231d391f12eb1bb`
|
||||
- v4 migration pulls from `migration/deno-v4` branch
|
||||
- All scripts are designed to be idempotent where possible
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Container won't start
|
||||
|
||||
- Ensure Docker daemon is running
|
||||
- Check you have privileged access
|
||||
- Try: `docker logs nupst-test-v3`
|
||||
|
||||
### Systemd not working in container
|
||||
|
||||
- Requires Linux host (not macOS/Windows)
|
||||
- Needs `--privileged` and cgroup volume mounts
|
||||
- Check: `docker exec nupst-test-v3 systemctl --version`
|
||||
|
||||
### Migration fails
|
||||
|
||||
- Check logs: `docker exec nupst-test-v3 journalctl -xe`
|
||||
- Verify install.sh ran: `docker exec nupst-test-v3 ls -la /opt/nupst/`
|
||||
- Check service: `docker exec nupst-test-v3 systemctl status nupst`
|
||||
@@ -0,0 +1,157 @@
|
||||
import { assert, assertEquals } from 'jsr:@std/assert@^1.0.0';
|
||||
import { Logger } from '../ts/logger.ts';
|
||||
|
||||
// Create a Logger instance for testing
|
||||
const logger = new Logger();
|
||||
|
||||
Deno.test('should create a logger instance', () => {
|
||||
assert(logger instanceof Logger);
|
||||
});
|
||||
|
||||
Deno.test('should log messages with different log levels', () => {
|
||||
// We're not testing console output directly, just ensuring no errors
|
||||
logger.log('Regular log message');
|
||||
logger.error('Error message');
|
||||
logger.warn('Warning message');
|
||||
logger.success('Success message');
|
||||
|
||||
// Just assert that the test runs without errors
|
||||
assert(true);
|
||||
});
|
||||
|
||||
Deno.test('should create a logbox with title, content, and end', () => {
|
||||
// Just ensuring no errors occur
|
||||
logger.logBoxTitle('Test Box', 40);
|
||||
logger.logBoxLine('This is a test line');
|
||||
logger.logBoxEnd();
|
||||
|
||||
// Just assert that the test runs without errors
|
||||
assert(true);
|
||||
});
|
||||
|
||||
Deno.test('should handle width persistence between logbox calls', () => {
|
||||
logger.logBoxTitle('Width Test', 45);
|
||||
|
||||
// These should use the width from the title
|
||||
logger.logBoxLine('Line 1');
|
||||
logger.logBoxLine('Line 2');
|
||||
logger.logBoxEnd();
|
||||
|
||||
let errorThrown = false;
|
||||
|
||||
try {
|
||||
// This should work fine after the reset in logBoxEnd
|
||||
logger.logBoxTitle('New Box', 30);
|
||||
logger.logBoxLine('New line');
|
||||
logger.logBoxEnd();
|
||||
} catch (_error) {
|
||||
errorThrown = true;
|
||||
}
|
||||
|
||||
assertEquals(errorThrown, false);
|
||||
});
|
||||
|
||||
Deno.test('should use default width when no width is specified', () => {
|
||||
// This should automatically use the default width instead of throwing
|
||||
let errorThrown = false;
|
||||
|
||||
try {
|
||||
logger.logBoxLine('This should use default width');
|
||||
logger.logBoxEnd();
|
||||
} catch (_error) {
|
||||
errorThrown = true;
|
||||
}
|
||||
|
||||
// Verify no error was thrown
|
||||
assertEquals(errorThrown, false);
|
||||
});
|
||||
|
||||
Deno.test('should create a complete logbox in one call', () => {
|
||||
// Just ensuring no errors occur
|
||||
logger.logBox('Complete Box', [
|
||||
'Line 1',
|
||||
'Line 2',
|
||||
'Line 3',
|
||||
], 40);
|
||||
|
||||
// Just assert that the test runs without errors
|
||||
assert(true);
|
||||
});
|
||||
|
||||
Deno.test('should handle content that exceeds box width', () => {
|
||||
// Just ensuring no errors occur when content is too long
|
||||
logger.logBox('Truncation Test', [
|
||||
'This line is way too long and should be truncated because it exceeds the available space',
|
||||
], 30);
|
||||
|
||||
// Just assert that the test runs without errors
|
||||
assert(true);
|
||||
});
|
||||
|
||||
Deno.test('should create dividers with custom characters', () => {
|
||||
// Just ensuring no errors occur
|
||||
logger.logDivider(30);
|
||||
logger.logDivider(20, '*');
|
||||
|
||||
// Just assert that the test runs without errors
|
||||
assert(true);
|
||||
});
|
||||
|
||||
Deno.test('should create divider with default width', () => {
|
||||
// This should use the default width
|
||||
logger.logDivider(undefined, '-');
|
||||
|
||||
// Just assert that the test runs without errors
|
||||
assert(true);
|
||||
});
|
||||
|
||||
Deno.test('Logger Demo', () => {
|
||||
console.log('\n=== LOGGER DEMO ===\n');
|
||||
|
||||
// Basic logging
|
||||
logger.log('Regular log message');
|
||||
logger.error('Error message');
|
||||
logger.warn('Warning message');
|
||||
logger.success('Success message');
|
||||
|
||||
// Logbox with title, content lines, and end
|
||||
logger.logBoxTitle('Configuration Loaded', 50);
|
||||
logger.logBoxLine('SNMP Settings:');
|
||||
logger.logBoxLine(' Host: 127.0.0.1');
|
||||
logger.logBoxLine(' Port: 161');
|
||||
logger.logBoxLine(' Version: 1');
|
||||
logger.logBoxEnd();
|
||||
|
||||
// Complete logbox in one call
|
||||
logger.logBox('UPS Status', [
|
||||
'Power Status: onBattery',
|
||||
'Battery Capacity: 75%',
|
||||
'Runtime Remaining: 30 minutes',
|
||||
], 45);
|
||||
|
||||
// Logbox with content that's too long for the width
|
||||
logger.logBox('Truncation Example', [
|
||||
'This line is short enough to fit within the box width',
|
||||
'This line is way too long and will be truncated because it exceeds the available space for content within the logbox',
|
||||
], 40);
|
||||
|
||||
// Demonstrating logbox width being remembered
|
||||
logger.logBoxTitle('Width Persistence Example', 60);
|
||||
logger.logBoxLine('These lines use the width from the title');
|
||||
logger.logBoxLine('No need to specify the width again');
|
||||
logger.logBoxEnd();
|
||||
|
||||
// Demonstrating default width
|
||||
console.log('\nDefault Width Example:');
|
||||
logger.logBoxLine('This line uses the default width');
|
||||
logger.logBoxLine('Still using default width');
|
||||
logger.logBoxEnd();
|
||||
|
||||
// Divider example
|
||||
logger.log('\nDivider example:');
|
||||
logger.logDivider(30);
|
||||
logger.logDivider(30, '*');
|
||||
logger.logDivider(undefined, '=');
|
||||
|
||||
assert(true);
|
||||
});
|
||||
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* Showcase test for NUPST CLI outputs
|
||||
* Demonstrates all the beautiful colored output features
|
||||
*
|
||||
* Run with: deno run --allow-all test/showcase.ts
|
||||
*/
|
||||
|
||||
import { type ITableColumn, logger } from '../ts/logger.ts';
|
||||
import { formatPowerStatus, getBatteryColor, symbols, theme } from '../ts/colors.ts';
|
||||
|
||||
console.log('');
|
||||
console.log('═'.repeat(80));
|
||||
logger.highlight('NUPST CLI OUTPUT SHOWCASE');
|
||||
logger.dim('Demonstrating beautiful, colored terminal output');
|
||||
console.log('═'.repeat(80));
|
||||
console.log('');
|
||||
|
||||
// === 1. Basic Logging Methods ===
|
||||
logger.logBoxTitle('Basic Logging Methods', 60, 'info');
|
||||
logger.logBoxLine('');
|
||||
logger.log('Normal log message (default color)');
|
||||
logger.success('Success message with ✓ symbol');
|
||||
logger.error('Error message with ✗ symbol');
|
||||
logger.warn('Warning message with ⚠ symbol');
|
||||
logger.info('Info message with ℹ symbol');
|
||||
logger.dim('Dim/secondary text for less important info');
|
||||
logger.highlight('Highlighted/bold text for emphasis');
|
||||
logger.logBoxLine('');
|
||||
logger.logBoxEnd();
|
||||
|
||||
console.log('');
|
||||
|
||||
// === 2. Colored Boxes ===
|
||||
logger.logBoxTitle('Colored Box Styles', 60);
|
||||
logger.logBoxLine('');
|
||||
logger.logBoxLine('Boxes can be styled with different colors:');
|
||||
logger.logBoxEnd();
|
||||
|
||||
console.log('');
|
||||
|
||||
logger.logBox(
|
||||
'Success Box (Green)',
|
||||
[
|
||||
'Used for successful operations',
|
||||
'Installation complete, service started, etc.',
|
||||
],
|
||||
60,
|
||||
'success',
|
||||
);
|
||||
|
||||
console.log('');
|
||||
|
||||
logger.logBox(
|
||||
'Error Box (Red)',
|
||||
[
|
||||
'Used for critical errors and failures',
|
||||
'Configuration errors, connection failures, etc.',
|
||||
],
|
||||
60,
|
||||
'error',
|
||||
);
|
||||
|
||||
console.log('');
|
||||
|
||||
logger.logBox(
|
||||
'Warning Box (Yellow)',
|
||||
[
|
||||
'Used for warnings and deprecations',
|
||||
'Old command format, missing config, etc.',
|
||||
],
|
||||
60,
|
||||
'warning',
|
||||
);
|
||||
|
||||
console.log('');
|
||||
|
||||
logger.logBox(
|
||||
'Info Box (Cyan)',
|
||||
[
|
||||
'Used for informational messages',
|
||||
'Version info, update available, etc.',
|
||||
],
|
||||
60,
|
||||
'info',
|
||||
);
|
||||
|
||||
console.log('');
|
||||
|
||||
// === 3. Status Symbols ===
|
||||
logger.logBoxTitle('Status Symbols', 60, 'info');
|
||||
logger.logBoxLine('');
|
||||
logger.logBoxLine(`${symbols.running} Service Running`);
|
||||
logger.logBoxLine(`${symbols.stopped} Service Stopped`);
|
||||
logger.logBoxLine(`${symbols.starting} Service Starting`);
|
||||
logger.logBoxLine(`${symbols.unknown} Status Unknown`);
|
||||
logger.logBoxLine('');
|
||||
logger.logBoxLine(`${symbols.success} Operation Successful`);
|
||||
logger.logBoxLine(`${symbols.error} Operation Failed`);
|
||||
logger.logBoxLine(`${symbols.warning} Warning Condition`);
|
||||
logger.logBoxLine(`${symbols.info} Information`);
|
||||
logger.logBoxLine('');
|
||||
logger.logBoxEnd();
|
||||
|
||||
console.log('');
|
||||
|
||||
// === 4. Battery Level Colors ===
|
||||
logger.logBoxTitle('Battery Level Color Coding', 60, 'info');
|
||||
logger.logBoxLine('');
|
||||
logger.logBoxLine('Battery levels are color-coded:');
|
||||
logger.logBoxLine('');
|
||||
logger.logBoxLine(` ${getBatteryColor(85)('85%')} - Good (green, ≥60%)`);
|
||||
logger.logBoxLine(` ${getBatteryColor(45)('45%')} - Medium (yellow, 30-60%)`);
|
||||
logger.logBoxLine(` ${getBatteryColor(15)('15%')} - Critical (red, <30%)`);
|
||||
logger.logBoxLine('');
|
||||
logger.logBoxEnd();
|
||||
|
||||
console.log('');
|
||||
|
||||
// === 5. Power Status Formatting ===
|
||||
logger.logBoxTitle('Power Status Formatting', 60, 'info');
|
||||
logger.logBoxLine('');
|
||||
logger.logBoxLine(`Status: ${formatPowerStatus('online')}`);
|
||||
logger.logBoxLine(`Status: ${formatPowerStatus('onBattery')}`);
|
||||
logger.logBoxLine(`Status: ${formatPowerStatus('unknown')}`);
|
||||
logger.logBoxLine('');
|
||||
logger.logBoxEnd();
|
||||
|
||||
console.log('');
|
||||
|
||||
// === 6. Table Formatting ===
|
||||
const upsColumns: ITableColumn[] = [
|
||||
{ header: 'ID', key: 'id' },
|
||||
{ header: 'Name', key: 'name' },
|
||||
{ header: 'Host', key: 'host' },
|
||||
{
|
||||
header: 'Status',
|
||||
key: 'status',
|
||||
color: (v) => {
|
||||
if (v.includes('Online')) return theme.success(v);
|
||||
if (v.includes('Battery')) return theme.warning(v);
|
||||
return theme.dim(v);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Battery',
|
||||
key: 'battery',
|
||||
align: 'right',
|
||||
color: (v) => {
|
||||
const pct = parseInt(v);
|
||||
return getBatteryColor(pct)(v);
|
||||
},
|
||||
},
|
||||
{ header: 'Runtime', key: 'runtime', align: 'right' },
|
||||
];
|
||||
|
||||
const upsData = [
|
||||
{
|
||||
id: 'ups-1',
|
||||
name: 'Main UPS',
|
||||
host: '192.168.1.10',
|
||||
status: 'Online',
|
||||
battery: '95%',
|
||||
runtime: '45 min',
|
||||
},
|
||||
{
|
||||
id: 'ups-2',
|
||||
name: 'Backup UPS',
|
||||
host: '192.168.1.11',
|
||||
status: 'On Battery',
|
||||
battery: '42%',
|
||||
runtime: '12 min',
|
||||
},
|
||||
{
|
||||
id: 'ups-3',
|
||||
name: 'Critical UPS',
|
||||
host: '192.168.1.12',
|
||||
status: 'On Battery',
|
||||
battery: '18%',
|
||||
runtime: '5 min',
|
||||
},
|
||||
];
|
||||
|
||||
logger.logTable(upsColumns, upsData, 'UPS Devices');
|
||||
|
||||
console.log('');
|
||||
|
||||
// === 7. Group Table ===
|
||||
const groupColumns: ITableColumn[] = [
|
||||
{ header: 'ID', key: 'id' },
|
||||
{ header: 'Name', key: 'name' },
|
||||
{ header: 'Mode', key: 'mode' },
|
||||
{ header: 'UPS Count', key: 'count', align: 'right' },
|
||||
];
|
||||
|
||||
const groupData = [
|
||||
{ id: 'dc-1', name: 'Data Center 1', mode: 'redundant', count: '3' },
|
||||
{ id: 'office', name: 'Office Servers', mode: 'nonRedundant', count: '2' },
|
||||
];
|
||||
|
||||
logger.logTable(groupColumns, groupData, 'UPS Groups');
|
||||
|
||||
console.log('');
|
||||
|
||||
// === 8. Service Status Example ===
|
||||
logger.logBoxTitle('Service Status', 70, 'success');
|
||||
logger.logBoxLine('');
|
||||
logger.logBoxLine(`Status: ${symbols.running} ${theme.statusActive('Active (Running)')}`);
|
||||
logger.logBoxLine(`Enabled: ${symbols.success} ${theme.success('Yes')}`);
|
||||
logger.logBoxLine(`Uptime: 2 days, 5 hours, 23 minutes`);
|
||||
logger.logBoxLine(`PID: ${theme.dim('12345')}`);
|
||||
logger.logBoxLine(`Memory: ${theme.dim('45.2 MB')}`);
|
||||
logger.logBoxLine('');
|
||||
logger.logBoxEnd();
|
||||
|
||||
console.log('');
|
||||
|
||||
// === 9. Configuration Example ===
|
||||
logger.logBoxTitle('Configuration', 70);
|
||||
logger.logBoxLine('');
|
||||
logger.logBoxLine(`UPS Devices: ${theme.highlight('3')}`);
|
||||
logger.logBoxLine(`Groups: ${theme.highlight('2')}`);
|
||||
logger.logBoxLine(`Check Interval: ${theme.dim('30 seconds')}`);
|
||||
logger.logBoxLine(`Config File: ${theme.path('/etc/nupst/config.json')}`);
|
||||
logger.logBoxLine('');
|
||||
logger.logBoxEnd();
|
||||
|
||||
console.log('');
|
||||
|
||||
// === 10. Update Available Example ===
|
||||
logger.logBoxTitle('Update Available', 70, 'warning');
|
||||
logger.logBoxLine('');
|
||||
logger.logBoxLine(`Current Version: ${theme.dim('5.5.0')}`);
|
||||
logger.logBoxLine(`Latest Version: ${theme.highlight('5.5.1')}`);
|
||||
logger.logBoxLine('');
|
||||
logger.logBoxLine(`Run ${theme.command('sudo nupst upgrade')} to update`);
|
||||
logger.logBoxLine('');
|
||||
logger.logBoxEnd();
|
||||
|
||||
console.log('');
|
||||
|
||||
// === 11. Error Example ===
|
||||
logger.logBoxTitle('Error Example', 70, 'error');
|
||||
logger.logBoxLine('');
|
||||
logger.logBoxLine(`${symbols.error} Failed to connect to UPS at 192.168.1.10`);
|
||||
logger.logBoxLine('');
|
||||
logger.logBoxLine('Possible causes:');
|
||||
logger.logBoxLine(` ${theme.dim('• UPS is offline or unreachable')}`);
|
||||
logger.logBoxLine(` ${theme.dim('• Incorrect SNMP community string')}`);
|
||||
logger.logBoxLine(` ${theme.dim('• Firewall blocking port 161')}`);
|
||||
logger.logBoxLine('');
|
||||
logger.logBoxLine(`Try: ${theme.command('nupst ups test --debug')}`);
|
||||
logger.logBoxLine('');
|
||||
logger.logBoxEnd();
|
||||
|
||||
console.log('');
|
||||
|
||||
// === Final Summary ===
|
||||
console.log('═'.repeat(80));
|
||||
logger.success('CLI Output Showcase Complete!');
|
||||
logger.dim('All color and formatting features demonstrated');
|
||||
console.log('═'.repeat(80));
|
||||
console.log('');
|
||||
+1196
-321
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/nupst',
|
||||
version: '2.4.8',
|
||||
description: 'Node.js UPS Shutdown Tool for SNMP-enabled UPS devices'
|
||||
version: '5.11.1',
|
||||
description: 'Network UPS Shutdown Tool - Monitor SNMP-enabled UPS devices and orchestrate graceful system shutdowns during power emergencies'
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { IActionConfig, IActionContext, TPowerStatus } from './actions/base-action.ts';
|
||||
import type { IUpsStatus } from './ups-status.ts';
|
||||
|
||||
export interface IUpsActionSource {
|
||||
id: string;
|
||||
name: string;
|
||||
actions?: IActionConfig[];
|
||||
}
|
||||
|
||||
export type TUpsTriggerReason = IActionContext['triggerReason'];
|
||||
|
||||
export type TActionExecutionDecision =
|
||||
| { type: 'suppressed'; message: string }
|
||||
| { type: 'legacyShutdown'; reason: string }
|
||||
| { type: 'skip' }
|
||||
| { type: 'execute'; actions: IActionConfig[]; context: IActionContext };
|
||||
|
||||
export function buildUpsActionContext(
|
||||
ups: IUpsActionSource,
|
||||
status: IUpsStatus,
|
||||
previousStatus: IUpsStatus | undefined,
|
||||
triggerReason: TUpsTriggerReason,
|
||||
timestamp: number = Date.now(),
|
||||
): IActionContext {
|
||||
return {
|
||||
upsId: ups.id,
|
||||
upsName: ups.name,
|
||||
powerStatus: status.powerStatus as TPowerStatus,
|
||||
batteryCapacity: status.batteryCapacity,
|
||||
batteryRuntime: status.batteryRuntime,
|
||||
previousPowerStatus: (previousStatus?.powerStatus || 'unknown') as TPowerStatus,
|
||||
timestamp,
|
||||
triggerReason,
|
||||
};
|
||||
}
|
||||
|
||||
export function applyDefaultShutdownDelay(
|
||||
actions: IActionConfig[],
|
||||
defaultDelayMinutes: number,
|
||||
): IActionConfig[] {
|
||||
return actions.map((action) => {
|
||||
if (action.type !== 'shutdown' || action.shutdownDelay !== undefined) {
|
||||
return action;
|
||||
}
|
||||
|
||||
return {
|
||||
...action,
|
||||
shutdownDelay: defaultDelayMinutes,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function decideUpsActionExecution(
|
||||
isPaused: boolean,
|
||||
ups: IUpsActionSource,
|
||||
status: IUpsStatus,
|
||||
previousStatus: IUpsStatus | undefined,
|
||||
triggerReason: TUpsTriggerReason,
|
||||
timestamp: number = Date.now(),
|
||||
): TActionExecutionDecision {
|
||||
if (isPaused) {
|
||||
return {
|
||||
type: 'suppressed',
|
||||
message: `[PAUSED] Actions suppressed for UPS ${ups.name} (trigger: ${triggerReason})`,
|
||||
};
|
||||
}
|
||||
|
||||
const actions = ups.actions || [];
|
||||
|
||||
if (actions.length === 0 && triggerReason === 'thresholdViolation') {
|
||||
return {
|
||||
type: 'legacyShutdown',
|
||||
reason: `UPS "${ups.name}" battery or runtime below threshold`,
|
||||
};
|
||||
}
|
||||
|
||||
if (actions.length === 0) {
|
||||
return { type: 'skip' };
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'execute',
|
||||
actions,
|
||||
context: buildUpsActionContext(ups, status, previousStatus, triggerReason, timestamp),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Base classes and interfaces for the NUPST action system
|
||||
*
|
||||
* Actions are triggered on:
|
||||
* 1. Power status changes (online ↔ onBattery)
|
||||
* 2. Threshold violations (battery/runtime cross below configured thresholds)
|
||||
*/
|
||||
|
||||
export type TPowerStatus = 'online' | 'onBattery' | 'unknown' | 'unreachable';
|
||||
|
||||
/**
|
||||
* Context provided to actions when they execute
|
||||
* Contains all relevant UPS state and trigger information
|
||||
*/
|
||||
export interface IActionContext {
|
||||
// UPS identification
|
||||
/** Unique ID of the UPS */
|
||||
upsId: string;
|
||||
/** Human-readable name of the UPS */
|
||||
upsName: string;
|
||||
|
||||
// Current state
|
||||
/** Current power status */
|
||||
powerStatus: TPowerStatus;
|
||||
/** Current battery capacity percentage (0-100) */
|
||||
batteryCapacity: number;
|
||||
/** Estimated battery runtime in minutes */
|
||||
batteryRuntime: number;
|
||||
|
||||
// State tracking
|
||||
/** Previous power status before this trigger */
|
||||
previousPowerStatus: TPowerStatus;
|
||||
|
||||
// Metadata
|
||||
/** Timestamp when this action was triggered (milliseconds since epoch) */
|
||||
timestamp: number;
|
||||
/** Reason this action was triggered */
|
||||
triggerReason: 'powerStatusChange' | 'thresholdViolation';
|
||||
}
|
||||
|
||||
/**
|
||||
* Action trigger mode - determines when an action executes
|
||||
*/
|
||||
export type TActionTriggerMode =
|
||||
| 'onlyPowerChanges' // Only on power status changes (online ↔ onBattery)
|
||||
| 'onlyThresholds' // Only when action's thresholds are exceeded
|
||||
| 'powerChangesAndThresholds' // On power changes OR threshold violations
|
||||
| 'anyChange'; // On every UPS poll/check (every ~30s)
|
||||
|
||||
/**
|
||||
* Configuration for an action
|
||||
*/
|
||||
export interface IActionConfig {
|
||||
/** Type of action to execute */
|
||||
type: 'shutdown' | 'webhook' | 'script' | 'proxmox';
|
||||
|
||||
// Trigger configuration
|
||||
/**
|
||||
* When should this action be triggered?
|
||||
* - onlyPowerChanges: Only on power status changes
|
||||
* - onlyThresholds: Only when thresholds exceeded
|
||||
* - powerChangesAndThresholds: On both (default)
|
||||
* - anyChange: On every check
|
||||
*/
|
||||
triggerMode?: TActionTriggerMode;
|
||||
|
||||
// Threshold configuration (applies to all action types)
|
||||
/** Threshold settings for this action */
|
||||
thresholds?: {
|
||||
/** Battery percentage threshold (0-100) */
|
||||
battery: number;
|
||||
/** Runtime threshold in minutes */
|
||||
runtime: number;
|
||||
};
|
||||
|
||||
// Shutdown action configuration
|
||||
/** Delay before shutdown in minutes (defaults to the config-level shutdown delay, or 5) */
|
||||
shutdownDelay?: number;
|
||||
/** Only execute shutdown on threshold violation, not power status changes */
|
||||
onlyOnThresholdViolation?: boolean;
|
||||
|
||||
// Webhook action configuration
|
||||
/** URL to call for webhook */
|
||||
webhookUrl?: string;
|
||||
/** HTTP method to use (default: POST) */
|
||||
webhookMethod?: 'GET' | 'POST';
|
||||
/** Timeout for webhook request in milliseconds (default: 10000) */
|
||||
webhookTimeout?: number;
|
||||
/** Only execute webhook on threshold violation */
|
||||
webhookOnlyOnThresholdViolation?: boolean;
|
||||
|
||||
// Script action configuration
|
||||
/** Path to script relative to /etc/nupst (e.g., "myaction.sh") */
|
||||
scriptPath?: string;
|
||||
/** Timeout for script execution in milliseconds (default: 60000) */
|
||||
scriptTimeout?: number;
|
||||
/** Only execute script on threshold violation */
|
||||
scriptOnlyOnThresholdViolation?: boolean;
|
||||
|
||||
// Proxmox action configuration
|
||||
/** Proxmox API host (default: localhost) */
|
||||
proxmoxHost?: string;
|
||||
/** Proxmox API port (default: 8006) */
|
||||
proxmoxPort?: number;
|
||||
/** Proxmox node name (default: auto-detect via hostname) */
|
||||
proxmoxNode?: string;
|
||||
/** Proxmox API token ID (e.g., 'root@pam!nupst') */
|
||||
proxmoxTokenId?: string;
|
||||
/** Proxmox API token secret */
|
||||
proxmoxTokenSecret?: string;
|
||||
/** VM/CT IDs to exclude from shutdown */
|
||||
proxmoxExcludeIds?: number[];
|
||||
/** Timeout for VM/CT shutdown in seconds (default: 120) */
|
||||
proxmoxStopTimeout?: number;
|
||||
/** Force-stop VMs that don't shut down gracefully (default: true) */
|
||||
proxmoxForceStop?: boolean;
|
||||
/** Skip TLS verification for self-signed certificates (default: true) */
|
||||
proxmoxInsecure?: boolean;
|
||||
/** Proxmox operation mode: 'auto' detects CLI tools, 'cli' forces CLI, 'api' forces REST API (default: 'auto') */
|
||||
proxmoxMode?: 'auto' | 'api' | 'cli';
|
||||
/** How HA-managed Proxmox resources should be stopped (default: 'none') */
|
||||
proxmoxHaPolicy?: 'none' | 'haStop';
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract base class for all actions
|
||||
* Each action type must extend this class and implement execute()
|
||||
*/
|
||||
export abstract class Action {
|
||||
/** Type identifier for this action */
|
||||
abstract readonly type: string;
|
||||
|
||||
/**
|
||||
* Create a new action with the given configuration
|
||||
* @param config Action configuration
|
||||
*/
|
||||
constructor(protected config: IActionConfig) {}
|
||||
|
||||
/**
|
||||
* Execute this action with the given context
|
||||
* @param context Current UPS state and trigger information
|
||||
*/
|
||||
abstract execute(context: IActionContext): Promise<void>;
|
||||
|
||||
/**
|
||||
* Helper to check if this action should execute based on trigger mode
|
||||
* @param context Action context with current UPS state
|
||||
* @returns True if action should execute
|
||||
*/
|
||||
protected shouldExecute(context: IActionContext): boolean {
|
||||
const mode = this.config.triggerMode || 'powerChangesAndThresholds'; // Default
|
||||
|
||||
switch (mode) {
|
||||
case 'onlyPowerChanges':
|
||||
// Only execute on power status changes
|
||||
return context.triggerReason === 'powerStatusChange';
|
||||
|
||||
case 'onlyThresholds':
|
||||
// Only execute when this action's thresholds are exceeded
|
||||
if (!this.config.thresholds) return false; // No thresholds = never execute
|
||||
return this.areThresholdsExceeded(context.batteryCapacity, context.batteryRuntime);
|
||||
|
||||
case 'powerChangesAndThresholds':
|
||||
// Execute on power changes OR when thresholds exceeded
|
||||
if (context.triggerReason === 'powerStatusChange') return true;
|
||||
if (!this.config.thresholds) return false;
|
||||
return this.areThresholdsExceeded(context.batteryCapacity, context.batteryRuntime);
|
||||
|
||||
case 'anyChange':
|
||||
// Execute on every trigger (power change or threshold check)
|
||||
return true;
|
||||
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if current battery/runtime exceeds this action's thresholds
|
||||
* @param batteryCapacity Current battery percentage
|
||||
* @param batteryRuntime Current runtime in minutes
|
||||
* @returns True if thresholds are exceeded
|
||||
*/
|
||||
protected areThresholdsExceeded(batteryCapacity: number, batteryRuntime: number): boolean {
|
||||
if (!this.config.thresholds) {
|
||||
return false; // No thresholds configured
|
||||
}
|
||||
|
||||
return (
|
||||
batteryCapacity < this.config.thresholds.battery ||
|
||||
batteryRuntime < this.config.thresholds.runtime
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Action system exports and ActionManager
|
||||
*
|
||||
* This module provides the central coordination for the action system.
|
||||
* The ActionManager is responsible for creating and executing actions.
|
||||
*/
|
||||
|
||||
import { logger } from '../logger.ts';
|
||||
import type { Action, IActionConfig, IActionContext } from './base-action.ts';
|
||||
import { ShutdownAction } from './shutdown-action.ts';
|
||||
import { WebhookAction } from './webhook-action.ts';
|
||||
import { ScriptAction } from './script-action.ts';
|
||||
import { ProxmoxAction } from './proxmox-action.ts';
|
||||
|
||||
// Re-export types for convenience
|
||||
export type { IActionConfig, IActionContext, TPowerStatus } from './base-action.ts';
|
||||
export type { IWebhookPayload } from './webhook-action.ts';
|
||||
export { Action } from './base-action.ts';
|
||||
export { ShutdownAction } from './shutdown-action.ts';
|
||||
export { WebhookAction } from './webhook-action.ts';
|
||||
export { ScriptAction } from './script-action.ts';
|
||||
export { ProxmoxAction } from './proxmox-action.ts';
|
||||
|
||||
/**
|
||||
* ActionManager - Coordinates action creation and execution
|
||||
*
|
||||
* Provides factory methods for creating actions from configuration
|
||||
* and orchestrates action execution with error handling.
|
||||
*/
|
||||
export class ActionManager {
|
||||
/**
|
||||
* Create an action instance from configuration
|
||||
* @param config Action configuration
|
||||
* @returns Instantiated action
|
||||
* @throws Error if action type is unknown
|
||||
*/
|
||||
static createAction(config: IActionConfig): Action {
|
||||
switch (config.type) {
|
||||
case 'shutdown':
|
||||
return new ShutdownAction(config);
|
||||
case 'webhook':
|
||||
return new WebhookAction(config);
|
||||
case 'script':
|
||||
return new ScriptAction(config);
|
||||
case 'proxmox':
|
||||
return new ProxmoxAction(config);
|
||||
default:
|
||||
throw new Error(`Unknown action type: ${(config as IActionConfig).type}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a sequence of actions with the given context
|
||||
* Each action runs sequentially, and failures are logged but don't stop the chain
|
||||
* @param actions Array of action configurations to execute
|
||||
* @param context Action context with UPS state
|
||||
*/
|
||||
static async executeActions(
|
||||
actions: IActionConfig[],
|
||||
context: IActionContext,
|
||||
): Promise<void> {
|
||||
if (!actions || actions.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.log('');
|
||||
logger.logBoxTitle(`Executing ${actions.length} Action(s)`, 60, 'info');
|
||||
logger.logBoxLine(`Trigger: ${context.triggerReason}`);
|
||||
logger.logBoxLine(`UPS: ${context.upsName} (${context.upsId})`);
|
||||
logger.logBoxLine(`Power: ${context.powerStatus}`);
|
||||
logger.logBoxLine(`Battery: ${context.batteryCapacity}% / ${context.batteryRuntime} min`);
|
||||
logger.logBoxEnd();
|
||||
logger.log('');
|
||||
|
||||
for (let i = 0; i < actions.length; i++) {
|
||||
const actionConfig = actions[i];
|
||||
try {
|
||||
logger.info(`[${i + 1}/${actions.length}] ${actionConfig.type} action...`);
|
||||
|
||||
const action = this.createAction(actionConfig);
|
||||
await action.execute(context);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Action ${actionConfig.type} failed: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
// Continue with next action despite failure
|
||||
}
|
||||
}
|
||||
|
||||
logger.log('');
|
||||
logger.success('Action execution completed');
|
||||
logger.log('');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,812 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import process from 'node:process';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { Action, type IActionContext } from './base-action.ts';
|
||||
import { logger } from '../logger.ts';
|
||||
import { PROXMOX, UI } from '../constants.ts';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
type TNodeLikeGlobal = typeof globalThis & {
|
||||
process?: {
|
||||
env: Record<string, string | undefined>;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* ProxmoxAction - Gracefully shuts down Proxmox VMs and LXC containers
|
||||
*
|
||||
* Supports two operation modes:
|
||||
* - CLI mode: Uses qm/pct commands directly (requires running as root on a Proxmox host)
|
||||
* - API mode: Uses the Proxmox REST API via HTTPS with API token authentication
|
||||
*
|
||||
* In 'auto' mode (default), CLI is preferred when available, falling back to API.
|
||||
*
|
||||
* This action should be placed BEFORE shutdown actions in the action chain
|
||||
* so that VMs are stopped before the host is shut down.
|
||||
*/
|
||||
export class ProxmoxAction extends Action {
|
||||
readonly type = 'proxmox';
|
||||
private static readonly activeRunKeys = new Set<string>();
|
||||
|
||||
private static findCliTool(command: string): string | null {
|
||||
for (const dir of PROXMOX.CLI_TOOL_PATHS) {
|
||||
const candidate = `${dir}/${command}`;
|
||||
try {
|
||||
if (fs.existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
} catch (_e) {
|
||||
// continue
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Proxmox CLI tools (qm, pct) are available on the system
|
||||
* Used by CLI wizards and by execute() for auto-detection
|
||||
*/
|
||||
static detectCliAvailability(): {
|
||||
available: boolean;
|
||||
qmPath: string | null;
|
||||
pctPath: string | null;
|
||||
haManagerPath: string | null;
|
||||
isRoot: boolean;
|
||||
} {
|
||||
const qmPath = this.findCliTool('qm');
|
||||
const pctPath = this.findCliTool('pct');
|
||||
const haManagerPath = this.findCliTool('ha-manager');
|
||||
|
||||
const isRoot = !!(process.getuid && process.getuid() === 0);
|
||||
|
||||
return {
|
||||
available: qmPath !== null && pctPath !== null && isRoot,
|
||||
qmPath,
|
||||
pctPath,
|
||||
haManagerPath,
|
||||
isRoot,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the operation mode based on config and environment
|
||||
*/
|
||||
private resolveMode(): { mode: 'api' | 'cli'; qmPath: string; pctPath: string } | {
|
||||
mode: 'api';
|
||||
qmPath?: undefined;
|
||||
pctPath?: undefined;
|
||||
} {
|
||||
const configuredMode = this.config.proxmoxMode || 'auto';
|
||||
|
||||
if (configuredMode === 'api') {
|
||||
return { mode: 'api' };
|
||||
}
|
||||
|
||||
const detection = ProxmoxAction.detectCliAvailability();
|
||||
|
||||
if (configuredMode === 'cli') {
|
||||
if (!detection.qmPath || !detection.pctPath) {
|
||||
throw new Error('CLI mode requested but qm/pct not found. Are you on a Proxmox host?');
|
||||
}
|
||||
if (!detection.isRoot) {
|
||||
throw new Error('CLI mode requires root access');
|
||||
}
|
||||
return { mode: 'cli', qmPath: detection.qmPath, pctPath: detection.pctPath };
|
||||
}
|
||||
|
||||
// Auto-detect
|
||||
if (detection.available && detection.qmPath && detection.pctPath) {
|
||||
return { mode: 'cli', qmPath: detection.qmPath, pctPath: detection.pctPath };
|
||||
}
|
||||
return { mode: 'api' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the Proxmox shutdown action
|
||||
*/
|
||||
async execute(context: IActionContext): Promise<void> {
|
||||
if (!this.shouldExecute(context)) {
|
||||
logger.info(
|
||||
`Proxmox action skipped (trigger mode: ${
|
||||
this.config.triggerMode || 'powerChangesAndThresholds'
|
||||
})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const resolved = this.resolveMode();
|
||||
const node = this.config.proxmoxNode || os.hostname();
|
||||
const excludeIds = new Set(this.config.proxmoxExcludeIds || []);
|
||||
const stopTimeout = (this.config.proxmoxStopTimeout || PROXMOX.DEFAULT_STOP_TIMEOUT_SECONDS) *
|
||||
1000;
|
||||
const forceStop = this.config.proxmoxForceStop !== false; // default true
|
||||
const haPolicy = this.config.proxmoxHaPolicy || 'none';
|
||||
const host = this.config.proxmoxHost || PROXMOX.DEFAULT_HOST;
|
||||
const port = this.config.proxmoxPort || PROXMOX.DEFAULT_PORT;
|
||||
const runKey = `${resolved.mode}:${node}:${
|
||||
resolved.mode === 'api' ? `${host}:${port}` : 'local'
|
||||
}`;
|
||||
|
||||
if (ProxmoxAction.activeRunKeys.has(runKey)) {
|
||||
logger.info(`Proxmox action skipped: shutdown sequence already running for node ${node}`);
|
||||
return;
|
||||
}
|
||||
|
||||
ProxmoxAction.activeRunKeys.add(runKey);
|
||||
|
||||
logger.log('');
|
||||
logger.logBoxTitle('Proxmox VM Shutdown', UI.WIDE_BOX_WIDTH, 'warning');
|
||||
logger.logBoxLine(`Mode: ${resolved.mode === 'cli' ? 'CLI (qm/pct)' : 'API (REST)'}`);
|
||||
logger.logBoxLine(`Node: ${node}`);
|
||||
logger.logBoxLine(`HA Policy: ${haPolicy}`);
|
||||
if (resolved.mode === 'api') {
|
||||
logger.logBoxLine(`API: ${host}:${port}`);
|
||||
}
|
||||
logger.logBoxLine(`UPS: ${context.upsName} (${context.powerStatus})`);
|
||||
logger.logBoxLine(`Trigger: ${context.triggerReason}`);
|
||||
if (excludeIds.size > 0) {
|
||||
logger.logBoxLine(`Excluded IDs: ${[...excludeIds].join(', ')}`);
|
||||
}
|
||||
logger.logBoxEnd();
|
||||
logger.log('');
|
||||
|
||||
try {
|
||||
let apiContext: {
|
||||
baseUrl: string;
|
||||
headers: Record<string, string>;
|
||||
insecure: boolean;
|
||||
} | null = null;
|
||||
let runningVMs: Array<{ vmid: number; name: string }>;
|
||||
let runningCTs: Array<{ vmid: number; name: string }>;
|
||||
|
||||
if (resolved.mode === 'cli') {
|
||||
runningVMs = await this.getRunningVMsCli(resolved.qmPath);
|
||||
runningCTs = await this.getRunningCTsCli(resolved.pctPath);
|
||||
} else {
|
||||
// API mode - validate token
|
||||
const tokenId = this.config.proxmoxTokenId;
|
||||
const tokenSecret = this.config.proxmoxTokenSecret;
|
||||
const insecure = this.config.proxmoxInsecure !== false;
|
||||
|
||||
if (!tokenId || !tokenSecret) {
|
||||
logger.error('Proxmox API token ID and secret are required for API mode');
|
||||
logger.error('Either provide tokens or run on a Proxmox host as root for CLI mode');
|
||||
return;
|
||||
}
|
||||
|
||||
apiContext = {
|
||||
baseUrl: `https://${host}:${port}${PROXMOX.API_BASE}`,
|
||||
headers: {
|
||||
'Authorization': `PVEAPIToken=${tokenId}=${tokenSecret}`,
|
||||
},
|
||||
insecure,
|
||||
};
|
||||
|
||||
runningVMs = await this.getRunningVMsApi(
|
||||
apiContext.baseUrl,
|
||||
node,
|
||||
apiContext.headers,
|
||||
apiContext.insecure,
|
||||
);
|
||||
runningCTs = await this.getRunningCTsApi(
|
||||
apiContext.baseUrl,
|
||||
node,
|
||||
apiContext.headers,
|
||||
apiContext.insecure,
|
||||
);
|
||||
}
|
||||
|
||||
// Filter out excluded IDs
|
||||
const vmsToStop = runningVMs.filter((vm) => !excludeIds.has(vm.vmid));
|
||||
const ctsToStop = runningCTs.filter((ct) => !excludeIds.has(ct.vmid));
|
||||
|
||||
const totalToStop = vmsToStop.length + ctsToStop.length;
|
||||
if (totalToStop === 0) {
|
||||
logger.info('No running VMs or containers to shut down');
|
||||
return;
|
||||
}
|
||||
|
||||
const haManagedResources = haPolicy === 'haStop'
|
||||
? await this.getHaManagedResources(resolved, apiContext)
|
||||
: { qemu: new Set<number>(), lxc: new Set<number>() };
|
||||
const haVmsToStop = vmsToStop.filter((vm) => haManagedResources.qemu.has(vm.vmid));
|
||||
const haCtsToStop = ctsToStop.filter((ct) => haManagedResources.lxc.has(ct.vmid));
|
||||
let directVmsToStop = vmsToStop.filter((vm) => !haManagedResources.qemu.has(vm.vmid));
|
||||
let directCtsToStop = ctsToStop.filter((ct) => !haManagedResources.lxc.has(ct.vmid));
|
||||
|
||||
logger.info(`Shutting down ${vmsToStop.length} VMs and ${ctsToStop.length} containers...`);
|
||||
|
||||
if (resolved.mode === 'cli') {
|
||||
const { haManagerPath } = ProxmoxAction.detectCliAvailability();
|
||||
if (haPolicy === 'haStop' && (haVmsToStop.length > 0 || haCtsToStop.length > 0)) {
|
||||
if (!haManagerPath) {
|
||||
logger.warn(
|
||||
'ha-manager not found, falling back to direct guest shutdown for HA-managed resources',
|
||||
);
|
||||
directVmsToStop = [...haVmsToStop, ...directVmsToStop];
|
||||
directCtsToStop = [...haCtsToStop, ...directCtsToStop];
|
||||
} else {
|
||||
for (const vm of haVmsToStop) {
|
||||
await this.requestHaStopCli(haManagerPath, `vm:${vm.vmid}`);
|
||||
logger.dim(` HA stop requested for VM ${vm.vmid} (${vm.name || 'unnamed'})`);
|
||||
}
|
||||
for (const ct of haCtsToStop) {
|
||||
await this.requestHaStopCli(haManagerPath, `ct:${ct.vmid}`);
|
||||
logger.dim(` HA stop requested for CT ${ct.vmid} (${ct.name || 'unnamed'})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const vm of directVmsToStop) {
|
||||
await this.shutdownVMCli(resolved.qmPath, vm.vmid);
|
||||
logger.dim(` Shutdown sent to VM ${vm.vmid} (${vm.name || 'unnamed'})`);
|
||||
}
|
||||
for (const ct of directCtsToStop) {
|
||||
await this.shutdownCTCli(resolved.pctPath, ct.vmid);
|
||||
logger.dim(` Shutdown sent to CT ${ct.vmid} (${ct.name || 'unnamed'})`);
|
||||
}
|
||||
} else if (apiContext) {
|
||||
for (const vm of haVmsToStop) {
|
||||
await this.requestHaStopApi(
|
||||
apiContext.baseUrl,
|
||||
`vm:${vm.vmid}`,
|
||||
apiContext.headers,
|
||||
apiContext.insecure,
|
||||
);
|
||||
logger.dim(` HA stop requested for VM ${vm.vmid} (${vm.name || 'unnamed'})`);
|
||||
}
|
||||
for (const ct of haCtsToStop) {
|
||||
await this.requestHaStopApi(
|
||||
apiContext.baseUrl,
|
||||
`ct:${ct.vmid}`,
|
||||
apiContext.headers,
|
||||
apiContext.insecure,
|
||||
);
|
||||
logger.dim(` HA stop requested for CT ${ct.vmid} (${ct.name || 'unnamed'})`);
|
||||
}
|
||||
|
||||
for (const vm of directVmsToStop) {
|
||||
await this.shutdownVMApi(
|
||||
apiContext.baseUrl,
|
||||
node,
|
||||
vm.vmid,
|
||||
apiContext.headers,
|
||||
apiContext.insecure,
|
||||
);
|
||||
logger.dim(` Shutdown sent to VM ${vm.vmid} (${vm.name || 'unnamed'})`);
|
||||
}
|
||||
for (const ct of directCtsToStop) {
|
||||
await this.shutdownCTApi(
|
||||
apiContext.baseUrl,
|
||||
node,
|
||||
ct.vmid,
|
||||
apiContext.headers,
|
||||
apiContext.insecure,
|
||||
);
|
||||
logger.dim(` Shutdown sent to CT ${ct.vmid} (${ct.name || 'unnamed'})`);
|
||||
}
|
||||
}
|
||||
|
||||
// Poll until all stopped or timeout
|
||||
const allIds = [
|
||||
...vmsToStop.map((vm) => ({ type: 'qemu' as const, vmid: vm.vmid, name: vm.name })),
|
||||
...ctsToStop.map((ct) => ({ type: 'lxc' as const, vmid: ct.vmid, name: ct.name })),
|
||||
];
|
||||
|
||||
const remaining = await this.waitForShutdown(allIds, resolved, node, stopTimeout);
|
||||
|
||||
if (remaining.length > 0 && forceStop) {
|
||||
logger.warn(`${remaining.length} VMs/CTs didn't shut down gracefully, force-stopping...`);
|
||||
for (const item of remaining) {
|
||||
try {
|
||||
if (resolved.mode === 'cli') {
|
||||
if (item.type === 'qemu') {
|
||||
await this.stopVMCli(resolved.qmPath, item.vmid);
|
||||
} else {
|
||||
await this.stopCTCli(resolved.pctPath, item.vmid);
|
||||
}
|
||||
} else if (apiContext) {
|
||||
if (item.type === 'qemu') {
|
||||
await this.stopVMApi(
|
||||
apiContext.baseUrl,
|
||||
node,
|
||||
item.vmid,
|
||||
apiContext.headers,
|
||||
apiContext.insecure,
|
||||
);
|
||||
} else {
|
||||
await this.stopCTApi(
|
||||
apiContext.baseUrl,
|
||||
node,
|
||||
item.vmid,
|
||||
apiContext.headers,
|
||||
apiContext.insecure,
|
||||
);
|
||||
}
|
||||
}
|
||||
logger.dim(` Force-stopped ${item.type} ${item.vmid} (${item.name || 'unnamed'})`);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
` Failed to force-stop ${item.type} ${item.vmid}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (remaining.length > 0) {
|
||||
logger.warn(`${remaining.length} VMs/CTs still running (force-stop disabled)`);
|
||||
}
|
||||
|
||||
logger.success('Proxmox shutdown sequence completed');
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Proxmox action failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
} finally {
|
||||
ProxmoxAction.activeRunKeys.delete(runKey);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── CLI-based methods ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get list of running QEMU VMs via qm list
|
||||
*/
|
||||
private async getRunningVMsCli(
|
||||
qmPath: string,
|
||||
): Promise<Array<{ vmid: number; name: string }>> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(qmPath, ['list']);
|
||||
return this.parseQmList(stdout);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to list VMs via CLI: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of running LXC containers via pct list
|
||||
*/
|
||||
private async getRunningCTsCli(
|
||||
pctPath: string,
|
||||
): Promise<Array<{ vmid: number; name: string }>> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(pctPath, ['list']);
|
||||
return this.parsePctList(stdout);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to list CTs via CLI: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse qm list output
|
||||
* Format: VMID NAME STATUS MEM(MB) BOOTDISK(GB) PID
|
||||
*/
|
||||
private parseQmList(output: string): Array<{ vmid: number; name: string }> {
|
||||
const results: Array<{ vmid: number; name: string }> = [];
|
||||
const lines = output.trim().split('\n');
|
||||
|
||||
// Skip header line
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const match = lines[i].match(/^\s*(\d+)\s+(\S+)\s+(running|stopped|paused)/);
|
||||
if (match && match[3] === 'running') {
|
||||
results.push({ vmid: parseInt(match[1], 10), name: match[2] });
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse pct list output
|
||||
* Format: VMID Status Lock Name
|
||||
*/
|
||||
private parsePctList(output: string): Array<{ vmid: number; name: string }> {
|
||||
const results: Array<{ vmid: number; name: string }> = [];
|
||||
const lines = output.trim().split('\n');
|
||||
|
||||
// Skip header line
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const match = lines[i].match(/^\s*(\d+)\s+(running|stopped)\s+\S*\s*(.*)/);
|
||||
if (match && match[2] === 'running') {
|
||||
results.push({ vmid: parseInt(match[1], 10), name: match[3]?.trim() || '' });
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private async shutdownVMCli(qmPath: string, vmid: number): Promise<void> {
|
||||
await execFileAsync(qmPath, ['shutdown', String(vmid)]);
|
||||
}
|
||||
|
||||
private async shutdownCTCli(pctPath: string, vmid: number): Promise<void> {
|
||||
await execFileAsync(pctPath, ['shutdown', String(vmid)]);
|
||||
}
|
||||
|
||||
private async stopVMCli(qmPath: string, vmid: number): Promise<void> {
|
||||
await execFileAsync(qmPath, ['stop', String(vmid)]);
|
||||
}
|
||||
|
||||
private async stopCTCli(pctPath: string, vmid: number): Promise<void> {
|
||||
await execFileAsync(pctPath, ['stop', String(vmid)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get VM/CT status via CLI
|
||||
* Returns the status string (e.g., 'running', 'stopped')
|
||||
*/
|
||||
private async getStatusCli(
|
||||
toolPath: string,
|
||||
vmid: number,
|
||||
): Promise<string> {
|
||||
const { stdout } = await execFileAsync(toolPath, ['status', String(vmid)]);
|
||||
// Output format: "status: running\n"
|
||||
const status = stdout.trim().split(':')[1]?.trim() || 'unknown';
|
||||
return status;
|
||||
}
|
||||
|
||||
private async getHaManagedResources(
|
||||
resolved: { mode: 'api' | 'cli'; qmPath?: string; pctPath?: string },
|
||||
apiContext: {
|
||||
baseUrl: string;
|
||||
headers: Record<string, string>;
|
||||
insecure: boolean;
|
||||
} | null,
|
||||
): Promise<{ qemu: Set<number>; lxc: Set<number> }> {
|
||||
if (resolved.mode === 'cli') {
|
||||
const { haManagerPath } = ProxmoxAction.detectCliAvailability();
|
||||
if (!haManagerPath) {
|
||||
return { qemu: new Set<number>(), lxc: new Set<number>() };
|
||||
}
|
||||
|
||||
return await this.getHaManagedResourcesCli(haManagerPath);
|
||||
}
|
||||
|
||||
if (!apiContext) {
|
||||
return { qemu: new Set<number>(), lxc: new Set<number>() };
|
||||
}
|
||||
|
||||
return await this.getHaManagedResourcesApi(
|
||||
apiContext.baseUrl,
|
||||
apiContext.headers,
|
||||
apiContext.insecure,
|
||||
);
|
||||
}
|
||||
|
||||
private async getHaManagedResourcesCli(
|
||||
haManagerPath: string,
|
||||
): Promise<{ qemu: Set<number>; lxc: Set<number> }> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(haManagerPath, ['config']);
|
||||
return this.parseHaManagerConfig(stdout);
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`Failed to list HA resources via CLI: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
return { qemu: new Set<number>(), lxc: new Set<number>() };
|
||||
}
|
||||
}
|
||||
|
||||
private parseHaManagerConfig(output: string): { qemu: Set<number>; lxc: Set<number> } {
|
||||
const resources = {
|
||||
qemu: new Set<number>(),
|
||||
lxc: new Set<number>(),
|
||||
};
|
||||
|
||||
for (const line of output.trim().split('\n')) {
|
||||
const match = line.match(/^\s*(vm|ct)\s*:\s*(\d+)\s*$/i);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const vmid = parseInt(match[2], 10);
|
||||
if (match[1].toLowerCase() === 'vm') {
|
||||
resources.qemu.add(vmid);
|
||||
} else {
|
||||
resources.lxc.add(vmid);
|
||||
}
|
||||
}
|
||||
|
||||
return resources;
|
||||
}
|
||||
|
||||
private async requestHaStopCli(haManagerPath: string, sid: string): Promise<void> {
|
||||
await execFileAsync(haManagerPath, ['set', sid, '--state', 'stopped']);
|
||||
}
|
||||
|
||||
// ─── API-based methods ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Make an API request to the Proxmox server
|
||||
*/
|
||||
private async apiRequest(
|
||||
url: string,
|
||||
method: string,
|
||||
headers: Record<string, string>,
|
||||
insecure: boolean,
|
||||
body?: URLSearchParams,
|
||||
): Promise<unknown> {
|
||||
const requestHeaders = { ...headers };
|
||||
const fetchOptions: RequestInit = {
|
||||
method,
|
||||
headers: requestHeaders,
|
||||
};
|
||||
|
||||
if (body) {
|
||||
requestHeaders['Content-Type'] = 'application/x-www-form-urlencoded;charset=UTF-8';
|
||||
fetchOptions.body = body.toString();
|
||||
}
|
||||
|
||||
// Use NODE_TLS_REJECT_UNAUTHORIZED for insecure mode (self-signed certs)
|
||||
const nodeProcess = (globalThis as TNodeLikeGlobal).process;
|
||||
if (insecure && nodeProcess?.env) {
|
||||
nodeProcess.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, fetchOptions);
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
throw new Error(`Proxmox API error ${response.status}: ${body}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} finally {
|
||||
// Restore TLS verification
|
||||
if (insecure && nodeProcess?.env) {
|
||||
nodeProcess.env.NODE_TLS_REJECT_UNAUTHORIZED = '1';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of running QEMU VMs via API
|
||||
*/
|
||||
private async getRunningVMsApi(
|
||||
baseUrl: string,
|
||||
node: string,
|
||||
headers: Record<string, string>,
|
||||
insecure: boolean,
|
||||
): Promise<Array<{ vmid: number; name: string }>> {
|
||||
try {
|
||||
const response = await this.apiRequest(
|
||||
`${baseUrl}/nodes/${node}/qemu`,
|
||||
'GET',
|
||||
headers,
|
||||
insecure,
|
||||
) as { data: Array<{ vmid: number; name: string; status: string }> };
|
||||
|
||||
return (response.data || [])
|
||||
.filter((vm) => vm.status === 'running')
|
||||
.map((vm) => ({ vmid: vm.vmid, name: vm.name || '' }));
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to list VMs: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of running LXC containers via API
|
||||
*/
|
||||
private async getRunningCTsApi(
|
||||
baseUrl: string,
|
||||
node: string,
|
||||
headers: Record<string, string>,
|
||||
insecure: boolean,
|
||||
): Promise<Array<{ vmid: number; name: string }>> {
|
||||
try {
|
||||
const response = await this.apiRequest(
|
||||
`${baseUrl}/nodes/${node}/lxc`,
|
||||
'GET',
|
||||
headers,
|
||||
insecure,
|
||||
) as { data: Array<{ vmid: number; name: string; status: string }> };
|
||||
|
||||
return (response.data || [])
|
||||
.filter((ct) => ct.status === 'running')
|
||||
.map((ct) => ({ vmid: ct.vmid, name: ct.name || '' }));
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to list CTs: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private async getHaManagedResourcesApi(
|
||||
baseUrl: string,
|
||||
headers: Record<string, string>,
|
||||
insecure: boolean,
|
||||
): Promise<{ qemu: Set<number>; lxc: Set<number> }> {
|
||||
try {
|
||||
const response = await this.apiRequest(
|
||||
`${baseUrl}/cluster/ha/resources`,
|
||||
'GET',
|
||||
headers,
|
||||
insecure,
|
||||
) as { data: Array<{ sid?: string }> };
|
||||
const resources = {
|
||||
qemu: new Set<number>(),
|
||||
lxc: new Set<number>(),
|
||||
};
|
||||
|
||||
for (const item of response.data || []) {
|
||||
const match = item.sid?.match(/^(vm|ct):(\d+)$/i);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const vmid = parseInt(match[2], 10);
|
||||
if (match[1].toLowerCase() === 'vm') {
|
||||
resources.qemu.add(vmid);
|
||||
} else {
|
||||
resources.lxc.add(vmid);
|
||||
}
|
||||
}
|
||||
|
||||
return resources;
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`Failed to list HA resources via API: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
return { qemu: new Set<number>(), lxc: new Set<number>() };
|
||||
}
|
||||
}
|
||||
|
||||
private async requestHaStopApi(
|
||||
baseUrl: string,
|
||||
sid: string,
|
||||
headers: Record<string, string>,
|
||||
insecure: boolean,
|
||||
): Promise<void> {
|
||||
await this.apiRequest(
|
||||
`${baseUrl}/cluster/ha/resources/${encodeURIComponent(sid)}`,
|
||||
'PUT',
|
||||
headers,
|
||||
insecure,
|
||||
new URLSearchParams({ state: 'stopped' }),
|
||||
);
|
||||
}
|
||||
|
||||
private async shutdownVMApi(
|
||||
baseUrl: string,
|
||||
node: string,
|
||||
vmid: number,
|
||||
headers: Record<string, string>,
|
||||
insecure: boolean,
|
||||
): Promise<void> {
|
||||
await this.apiRequest(
|
||||
`${baseUrl}/nodes/${node}/qemu/${vmid}/status/shutdown`,
|
||||
'POST',
|
||||
headers,
|
||||
insecure,
|
||||
);
|
||||
}
|
||||
|
||||
private async shutdownCTApi(
|
||||
baseUrl: string,
|
||||
node: string,
|
||||
vmid: number,
|
||||
headers: Record<string, string>,
|
||||
insecure: boolean,
|
||||
): Promise<void> {
|
||||
await this.apiRequest(
|
||||
`${baseUrl}/nodes/${node}/lxc/${vmid}/status/shutdown`,
|
||||
'POST',
|
||||
headers,
|
||||
insecure,
|
||||
);
|
||||
}
|
||||
|
||||
private async stopVMApi(
|
||||
baseUrl: string,
|
||||
node: string,
|
||||
vmid: number,
|
||||
headers: Record<string, string>,
|
||||
insecure: boolean,
|
||||
): Promise<void> {
|
||||
await this.apiRequest(
|
||||
`${baseUrl}/nodes/${node}/qemu/${vmid}/status/stop`,
|
||||
'POST',
|
||||
headers,
|
||||
insecure,
|
||||
);
|
||||
}
|
||||
|
||||
private async stopCTApi(
|
||||
baseUrl: string,
|
||||
node: string,
|
||||
vmid: number,
|
||||
headers: Record<string, string>,
|
||||
insecure: boolean,
|
||||
): Promise<void> {
|
||||
await this.apiRequest(
|
||||
`${baseUrl}/nodes/${node}/lxc/${vmid}/status/stop`,
|
||||
'POST',
|
||||
headers,
|
||||
insecure,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Shared methods ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Wait for VMs/CTs to shut down, return any that are still running after timeout
|
||||
*/
|
||||
private async waitForShutdown(
|
||||
items: Array<{ type: 'qemu' | 'lxc'; vmid: number; name: string }>,
|
||||
resolved: { mode: 'api' | 'cli'; qmPath?: string; pctPath?: string },
|
||||
node: string,
|
||||
timeout: number,
|
||||
): Promise<Array<{ type: 'qemu' | 'lxc'; vmid: number; name: string }>> {
|
||||
const startTime = Date.now();
|
||||
let remaining = [...items];
|
||||
|
||||
while (remaining.length > 0 && (Date.now() - startTime) < timeout) {
|
||||
// Wait before polling
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, PROXMOX.STATUS_POLL_INTERVAL_SECONDS * 1000)
|
||||
);
|
||||
|
||||
// Check which are still running
|
||||
const stillRunning: typeof remaining = [];
|
||||
|
||||
for (const item of remaining) {
|
||||
try {
|
||||
let status: string;
|
||||
|
||||
if (resolved.mode === 'cli') {
|
||||
const toolPath = item.type === 'qemu' ? resolved.qmPath! : resolved.pctPath!;
|
||||
status = await this.getStatusCli(toolPath, item.vmid);
|
||||
} else {
|
||||
const host = this.config.proxmoxHost || PROXMOX.DEFAULT_HOST;
|
||||
const port = this.config.proxmoxPort || PROXMOX.DEFAULT_PORT;
|
||||
const insecure = this.config.proxmoxInsecure !== false;
|
||||
const baseUrl = `https://${host}:${port}${PROXMOX.API_BASE}`;
|
||||
const headers: Record<string, string> = {
|
||||
'Authorization':
|
||||
`PVEAPIToken=${this.config.proxmoxTokenId}=${this.config.proxmoxTokenSecret}`,
|
||||
};
|
||||
const statusUrl = `${baseUrl}/nodes/${node}/${item.type}/${item.vmid}/status/current`;
|
||||
const response = await this.apiRequest(statusUrl, 'GET', headers, insecure) as {
|
||||
data: { status: string };
|
||||
};
|
||||
status = response.data?.status || 'unknown';
|
||||
}
|
||||
|
||||
if (status === 'running') {
|
||||
stillRunning.push(item);
|
||||
} else {
|
||||
logger.dim(` ${item.type} ${item.vmid} (${item.name}) stopped`);
|
||||
}
|
||||
} catch (_error) {
|
||||
// If we can't check status, assume it might still be running
|
||||
stillRunning.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
remaining = stillRunning;
|
||||
|
||||
if (remaining.length > 0) {
|
||||
const elapsed = Math.round((Date.now() - startTime) / 1000);
|
||||
logger.dim(` Waiting... ${remaining.length} still running (${elapsed}s elapsed)`);
|
||||
}
|
||||
}
|
||||
|
||||
return remaining;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import * as path from 'node:path';
|
||||
import * as fs from 'node:fs';
|
||||
import process from 'node:process';
|
||||
import { exec } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { Action, type IActionContext } from './base-action.ts';
|
||||
import { logger } from '../logger.ts';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
/**
|
||||
* ScriptAction - Executes a custom shell script from /etc/nupst/
|
||||
*
|
||||
* Runs user-provided scripts with UPS state passed as environment variables and arguments.
|
||||
* Scripts must be .sh files located in /etc/nupst/ for security.
|
||||
*/
|
||||
export class ScriptAction extends Action {
|
||||
readonly type = 'script';
|
||||
|
||||
private static readonly SCRIPT_DIR = '/etc/nupst';
|
||||
|
||||
/**
|
||||
* Execute the script action
|
||||
* @param context Action context with UPS state
|
||||
*/
|
||||
async execute(context: IActionContext): Promise<void> {
|
||||
// Check if we should execute based on trigger mode
|
||||
if (!this.shouldExecute(context)) {
|
||||
logger.info(
|
||||
`Script action skipped (trigger mode: ${
|
||||
this.config.triggerMode || 'powerChangesAndThresholds'
|
||||
})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.config.scriptPath) {
|
||||
logger.error('Script path not configured');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate and build script path
|
||||
const scriptPath = this.validateAndBuildScriptPath(this.config.scriptPath);
|
||||
if (!scriptPath) {
|
||||
logger.error(`Invalid script path: ${this.config.scriptPath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if script exists and is executable
|
||||
if (!fs.existsSync(scriptPath)) {
|
||||
logger.error(`Script not found: ${scriptPath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = this.config.scriptTimeout || 60000; // Default 60 seconds
|
||||
|
||||
logger.info(`Executing script: ${scriptPath}`);
|
||||
|
||||
try {
|
||||
await this.executeScript(scriptPath, context, timeout);
|
||||
logger.success('Script executed successfully');
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Script execution failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
// Don't throw - script failures shouldn't stop other actions
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate script path and build full path
|
||||
* Ensures security by preventing path traversal and limiting to /etc/nupst
|
||||
* @param scriptPath Relative script path from config
|
||||
* @returns Full validated path or null if invalid
|
||||
*/
|
||||
private validateAndBuildScriptPath(scriptPath: string): string | null {
|
||||
// Remove any leading/trailing whitespace
|
||||
scriptPath = scriptPath.trim();
|
||||
|
||||
// Reject paths with path traversal attempts
|
||||
if (scriptPath.includes('..') || scriptPath.includes('/') || scriptPath.includes('\\')) {
|
||||
logger.error('Script path must not contain directory separators or parent references');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Require .sh extension
|
||||
if (!scriptPath.endsWith('.sh')) {
|
||||
logger.error('Script must have .sh extension');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build full path
|
||||
return path.join(ScriptAction.SCRIPT_DIR, scriptPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the script with UPS state as environment variables and arguments
|
||||
* @param scriptPath Full path to the script
|
||||
* @param context Action context
|
||||
* @param timeout Execution timeout in milliseconds
|
||||
*/
|
||||
private async executeScript(
|
||||
scriptPath: string,
|
||||
context: IActionContext,
|
||||
timeout: number,
|
||||
): Promise<void> {
|
||||
// Prepare environment variables
|
||||
const env = {
|
||||
...process.env,
|
||||
NUPST_UPS_ID: context.upsId,
|
||||
NUPST_UPS_NAME: context.upsName,
|
||||
NUPST_POWER_STATUS: context.powerStatus,
|
||||
NUPST_BATTERY_CAPACITY: String(context.batteryCapacity),
|
||||
NUPST_BATTERY_RUNTIME: String(context.batteryRuntime),
|
||||
NUPST_TRIGGER_REASON: context.triggerReason,
|
||||
NUPST_TIMESTAMP: String(context.timestamp),
|
||||
// Include action's own thresholds if configured
|
||||
NUPST_BATTERY_THRESHOLD: this.config.thresholds ? String(this.config.thresholds.battery) : '',
|
||||
NUPST_RUNTIME_THRESHOLD: this.config.thresholds ? String(this.config.thresholds.runtime) : '',
|
||||
};
|
||||
|
||||
// Build command with arguments
|
||||
// Arguments: powerStatus batteryCapacity batteryRuntime
|
||||
const args = [
|
||||
context.powerStatus,
|
||||
String(context.batteryCapacity),
|
||||
String(context.batteryRuntime),
|
||||
].join(' ');
|
||||
|
||||
const command = `bash "${scriptPath}" ${args}`;
|
||||
|
||||
try {
|
||||
const { stdout, stderr } = await execAsync(command, {
|
||||
env,
|
||||
cwd: ScriptAction.SCRIPT_DIR,
|
||||
timeout,
|
||||
});
|
||||
|
||||
// Log output
|
||||
if (stdout) {
|
||||
logger.log('Script stdout:');
|
||||
logger.dim(stdout.trim());
|
||||
}
|
||||
|
||||
if (stderr) {
|
||||
logger.warn('Script stderr:');
|
||||
logger.dim(stderr.trim());
|
||||
}
|
||||
} catch (error) {
|
||||
// Check if it was a timeout
|
||||
if (error instanceof Error && 'killed' in error && error.killed) {
|
||||
throw new Error(`Script timed out after ${timeout}ms`);
|
||||
}
|
||||
|
||||
// Include stdout/stderr in error if available
|
||||
if (error && typeof error === 'object' && 'stdout' in error && 'stderr' in error) {
|
||||
const execError = error as { stdout: string; stderr: string };
|
||||
if (execError.stdout) {
|
||||
logger.log('Script stdout:');
|
||||
logger.dim(execError.stdout.trim());
|
||||
}
|
||||
if (execError.stderr) {
|
||||
logger.warn('Script stderr:');
|
||||
logger.dim(execError.stderr.trim());
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import * as fs from 'node:fs';
|
||||
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 { SHUTDOWN, UI } from '../constants.ts';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/**
|
||||
* ShutdownAction - Initiates system shutdown
|
||||
*
|
||||
* This action triggers a system shutdown using the standard shutdown command.
|
||||
* It includes a configurable delay to allow VMs and services to gracefully terminate.
|
||||
*/
|
||||
export class ShutdownAction extends Action {
|
||||
readonly type = 'shutdown';
|
||||
private static scheduledDelayMinutes: number | null = null;
|
||||
|
||||
/**
|
||||
* Override shouldExecute to add shutdown-specific safety checks
|
||||
*
|
||||
* Key safety rules:
|
||||
* 1. Shutdown should NEVER trigger unless UPS is actually on battery
|
||||
* (low battery while on grid power is not an emergency - it's charging)
|
||||
* 2. For power status changes, only trigger on transitions TO onBattery from online
|
||||
* (ignore unknown → online at startup, and power restoration events)
|
||||
* 3. For threshold violations, verify UPS is on battery before acting
|
||||
*
|
||||
* @param context Action context with UPS state
|
||||
* @returns True if shutdown should execute
|
||||
*/
|
||||
protected override shouldExecute(context: IActionContext): boolean {
|
||||
const mode = this.config.triggerMode || 'powerChangesAndThresholds';
|
||||
|
||||
// CRITICAL SAFETY CHECK: Shutdown should NEVER trigger unless UPS is on battery
|
||||
// A low battery while on grid power is not an emergency (the battery is charging)
|
||||
// When UPS is unreachable, we don't know the actual state - don't trigger false shutdown
|
||||
if (context.powerStatus !== 'onBattery') {
|
||||
if (context.powerStatus === 'unreachable') {
|
||||
logger.info(
|
||||
`Shutdown action skipped: UPS is unreachable (communication failure, actual state unknown)`,
|
||||
);
|
||||
} else {
|
||||
logger.info(
|
||||
`Shutdown action skipped: UPS is not on battery (status: ${context.powerStatus})`,
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Handle threshold violations (UPS is confirmed on battery at this point)
|
||||
if (context.triggerReason === 'thresholdViolation') {
|
||||
// 'onlyPowerChanges' mode ignores thresholds
|
||||
if (mode === 'onlyPowerChanges') {
|
||||
logger.info('Shutdown action skipped: triggerMode is onlyPowerChanges, ignoring threshold');
|
||||
return false;
|
||||
}
|
||||
// Check if thresholds are actually exceeded
|
||||
return this.areThresholdsExceeded(context.batteryCapacity, context.batteryRuntime);
|
||||
}
|
||||
|
||||
// Handle power status changes
|
||||
if (context.triggerReason === 'powerStatusChange') {
|
||||
// 'onlyThresholds' mode ignores power status changes
|
||||
if (mode === 'onlyThresholds') {
|
||||
logger.info(
|
||||
'Shutdown action skipped: triggerMode is onlyThresholds, ignoring power change',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const prev = context.previousPowerStatus;
|
||||
|
||||
// Only trigger on transitions TO onBattery from online (real power loss)
|
||||
if (prev === 'online') {
|
||||
logger.info('Shutdown action triggered: power loss detected (online → onBattery)');
|
||||
return true;
|
||||
}
|
||||
|
||||
// For unknown → onBattery (daemon started while on battery):
|
||||
// This is a startup scenario - be cautious. The user may have just started
|
||||
// the daemon for testing, or the UPS may have been on battery for a while.
|
||||
// Only trigger if mode explicitly includes power changes.
|
||||
if (prev === 'unknown') {
|
||||
if (
|
||||
mode === 'onlyPowerChanges' || mode === 'powerChangesAndThresholds' ||
|
||||
mode === 'anyChange'
|
||||
) {
|
||||
logger.info(
|
||||
'Shutdown action triggered: UPS on battery at daemon startup (unknown → onBattery)',
|
||||
);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Other transitions (e.g., onBattery → onBattery) should not trigger
|
||||
logger.info(
|
||||
`Shutdown action skipped: non-emergency transition (${prev} → ${context.powerStatus})`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// For 'anyChange' mode, always execute (UPS is already confirmed on battery)
|
||||
if (mode === 'anyChange') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the shutdown action
|
||||
* @param context Action context with UPS state
|
||||
*/
|
||||
async execute(context: IActionContext): Promise<void> {
|
||||
// Check if we should execute based on trigger mode and thresholds
|
||||
if (!this.shouldExecute(context)) {
|
||||
logger.info(
|
||||
`Shutdown action skipped (trigger mode: ${
|
||||
this.config.triggerMode || 'powerChangesAndThresholds'
|
||||
})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const shutdownDelay = this.config.shutdownDelay ?? SHUTDOWN.DEFAULT_DELAY_MINUTES;
|
||||
|
||||
if (
|
||||
ShutdownAction.scheduledDelayMinutes !== null &&
|
||||
ShutdownAction.scheduledDelayMinutes <= shutdownDelay
|
||||
) {
|
||||
logger.info(
|
||||
`Shutdown action skipped: shutdown already scheduled in ${ShutdownAction.scheduledDelayMinutes} minutes`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
ShutdownAction.scheduledDelayMinutes !== null &&
|
||||
ShutdownAction.scheduledDelayMinutes > shutdownDelay
|
||||
) {
|
||||
logger.warn(
|
||||
`Shutdown already scheduled in ${ShutdownAction.scheduledDelayMinutes} minutes, rescheduling to ${shutdownDelay} minutes`,
|
||||
);
|
||||
}
|
||||
|
||||
logger.log('');
|
||||
logger.logBoxTitle('Initiating System Shutdown', UI.WIDE_BOX_WIDTH, 'error');
|
||||
logger.logBoxLine(`UPS: ${context.upsName} (${context.upsId})`);
|
||||
logger.logBoxLine(`Power Status: ${context.powerStatus}`);
|
||||
logger.logBoxLine(`Battery: ${context.batteryCapacity}%`);
|
||||
logger.logBoxLine(`Runtime: ${context.batteryRuntime} minutes`);
|
||||
logger.logBoxLine(`Trigger: ${context.triggerReason}`);
|
||||
logger.logBoxLine(`Shutdown delay: ${shutdownDelay} minutes`);
|
||||
logger.logBoxEnd();
|
||||
logger.log('');
|
||||
|
||||
try {
|
||||
await this.executeShutdownCommand(shutdownDelay);
|
||||
ShutdownAction.scheduledDelayMinutes = shutdownDelay;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Shutdown command failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
// Try alternative methods
|
||||
await this.tryAlternativeShutdownMethods();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the primary shutdown command
|
||||
* @param delayMinutes Minutes to delay before shutdown
|
||||
*/
|
||||
private async executeShutdownCommand(delayMinutes: number): Promise<void> {
|
||||
// Find shutdown command in common system paths
|
||||
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
|
||||
const message = `UPS battery critical, shutting down in ${delayMinutes} minutes`;
|
||||
logger.log(`Executing: ${shutdownCmd} -h +${delayMinutes} "${message}"`);
|
||||
|
||||
const { stdout } = await execFileAsync(shutdownCmd, [
|
||||
'-h',
|
||||
`+${delayMinutes}`,
|
||||
message,
|
||||
]);
|
||||
|
||||
logger.log(`Shutdown initiated: ${stdout}`);
|
||||
logger.log(`Allowing ${delayMinutes} minutes for VMs to shut down safely`);
|
||||
} else {
|
||||
throw new Error('Shutdown command not found in common paths');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Try alternative shutdown methods if primary command fails
|
||||
*/
|
||||
private async tryAlternativeShutdownMethods(): Promise<void> {
|
||||
logger.error('Trying alternative shutdown methods...');
|
||||
|
||||
const alternatives = [
|
||||
{ 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);
|
||||
logger.log(`Alternative method ${alt.cmd} succeeded`);
|
||||
ShutdownAction.scheduledDelayMinutes = 0;
|
||||
return; // Exit if successful
|
||||
}
|
||||
} catch (_altError) {
|
||||
logger.error(`Alternative method ${alt.cmd} failed`);
|
||||
// Continue to next method
|
||||
}
|
||||
}
|
||||
|
||||
logger.error('All shutdown methods failed');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import * as http from 'node:http';
|
||||
import * as https from 'node:https';
|
||||
import { URL } from 'node:url';
|
||||
import { Action, type IActionContext } from './base-action.ts';
|
||||
import { logger } from '../logger.ts';
|
||||
import { WEBHOOK } from '../constants.ts';
|
||||
|
||||
/**
|
||||
* Payload sent to webhook endpoints
|
||||
*/
|
||||
export interface IWebhookPayload {
|
||||
/** UPS ID */
|
||||
upsId: string;
|
||||
/** UPS name */
|
||||
upsName: string;
|
||||
/** Current power status */
|
||||
powerStatus: 'online' | 'onBattery' | 'unknown' | 'unreachable';
|
||||
/** Current battery capacity percentage */
|
||||
batteryCapacity: number;
|
||||
/** Current battery runtime in minutes */
|
||||
batteryRuntime: number;
|
||||
/** Reason this webhook was triggered */
|
||||
triggerReason: 'powerStatusChange' | 'thresholdViolation';
|
||||
/** Timestamp when webhook was triggered */
|
||||
timestamp: number;
|
||||
/** Thresholds configured for this action (if any) */
|
||||
thresholds?: {
|
||||
battery: number;
|
||||
runtime: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* WebhookAction - Calls an HTTP webhook with UPS state information
|
||||
*
|
||||
* Sends UPS status to a configured webhook URL via GET or POST.
|
||||
* This is useful for remote notifications and integrations with external systems.
|
||||
*/
|
||||
export class WebhookAction extends Action {
|
||||
readonly type = 'webhook';
|
||||
|
||||
/**
|
||||
* Execute the webhook action
|
||||
* @param context Action context with UPS state
|
||||
*/
|
||||
async execute(context: IActionContext): Promise<void> {
|
||||
// Check if we should execute based on trigger mode
|
||||
if (!this.shouldExecute(context)) {
|
||||
logger.info(
|
||||
`Webhook action skipped (trigger mode: ${
|
||||
this.config.triggerMode || 'powerChangesAndThresholds'
|
||||
})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.config.webhookUrl) {
|
||||
logger.error('Webhook URL not configured');
|
||||
return;
|
||||
}
|
||||
|
||||
const method = this.config.webhookMethod || 'POST';
|
||||
const timeout = this.config.webhookTimeout || WEBHOOK.DEFAULT_TIMEOUT_MS;
|
||||
|
||||
logger.info(`Calling webhook: ${method} ${this.config.webhookUrl}`);
|
||||
|
||||
try {
|
||||
await this.callWebhook(context, method, timeout);
|
||||
logger.success('Webhook call successful');
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Webhook call failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
// Don't throw - webhook failures shouldn't stop other actions
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Call the webhook with UPS state data
|
||||
* @param context Action context
|
||||
* @param method HTTP method (GET or POST)
|
||||
* @param timeout Request timeout in milliseconds
|
||||
*/
|
||||
private callWebhook(
|
||||
context: IActionContext,
|
||||
method: 'GET' | 'POST',
|
||||
timeout: number,
|
||||
): Promise<void> {
|
||||
const payload: IWebhookPayload = {
|
||||
upsId: context.upsId,
|
||||
upsName: context.upsName,
|
||||
powerStatus: context.powerStatus,
|
||||
batteryCapacity: context.batteryCapacity,
|
||||
batteryRuntime: context.batteryRuntime,
|
||||
triggerReason: context.triggerReason,
|
||||
timestamp: context.timestamp,
|
||||
};
|
||||
|
||||
// Include action's own thresholds if configured
|
||||
if (this.config.thresholds) {
|
||||
payload.thresholds = {
|
||||
battery: this.config.thresholds.battery,
|
||||
runtime: this.config.thresholds.runtime,
|
||||
};
|
||||
}
|
||||
|
||||
const url = new URL(this.config.webhookUrl!);
|
||||
|
||||
if (method === 'GET') {
|
||||
// Append payload as query parameters for GET
|
||||
url.searchParams.append('upsId', payload.upsId);
|
||||
url.searchParams.append('upsName', payload.upsName);
|
||||
url.searchParams.append('powerStatus', payload.powerStatus);
|
||||
url.searchParams.append('batteryCapacity', String(payload.batteryCapacity));
|
||||
url.searchParams.append('batteryRuntime', String(payload.batteryRuntime));
|
||||
|
||||
url.searchParams.append('triggerReason', payload.triggerReason);
|
||||
url.searchParams.append('timestamp', String(payload.timestamp));
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const protocol = url.protocol === 'https:' ? https : http;
|
||||
|
||||
const options: http.RequestOptions = {
|
||||
method,
|
||||
headers: method === 'POST'
|
||||
? {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'nupst',
|
||||
}
|
||||
: {
|
||||
'User-Agent': 'nupst',
|
||||
},
|
||||
timeout,
|
||||
};
|
||||
|
||||
const req = protocol.request(url, options, (res) => {
|
||||
let data = '';
|
||||
|
||||
res.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
|
||||
res.on('end', () => {
|
||||
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
|
||||
logger.dim(`Webhook response (${res.statusCode}): ${data.substring(0, 100)}`);
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Webhook returned status ${res.statusCode}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (error) => {
|
||||
reject(error);
|
||||
});
|
||||
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
reject(new Error(`Webhook request timed out after ${timeout}ms`));
|
||||
});
|
||||
|
||||
// Send POST data if applicable
|
||||
if (method === 'POST') {
|
||||
req.write(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,803 @@
|
||||
import process from 'node:process';
|
||||
import { Nupst } from '../nupst.ts';
|
||||
import { type ITableColumn, logger } from '../logger.ts';
|
||||
import { symbols, theme } from '../colors.ts';
|
||||
import type { IActionConfig } from '../actions/base-action.ts';
|
||||
import { ProxmoxAction } from '../actions/proxmox-action.ts';
|
||||
import { SHUTDOWN } from '../constants.ts';
|
||||
import type { IGroupConfig, IUpsConfig } from '../daemon.ts';
|
||||
import * as helpers from '../helpers/index.ts';
|
||||
|
||||
/**
|
||||
* Class for handling action-related CLI commands
|
||||
* Provides interface for managing UPS actions
|
||||
*/
|
||||
export class ActionHandler {
|
||||
private readonly nupst: Nupst;
|
||||
|
||||
/**
|
||||
* Create a new action handler
|
||||
* @param nupst Reference to the main Nupst instance
|
||||
*/
|
||||
constructor(nupst: Nupst) {
|
||||
this.nupst = nupst;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new action to a UPS or group
|
||||
*/
|
||||
public async add(targetId?: string): Promise<void> {
|
||||
try {
|
||||
if (!targetId) {
|
||||
logger.error('Target ID is required');
|
||||
logger.log(
|
||||
` ${theme.dim('Usage:')} ${theme.command('nupst action add <ups-id|group-id>')}`,
|
||||
);
|
||||
logger.log('');
|
||||
logger.log(` ${theme.dim('List UPS devices:')} ${theme.command('nupst ups list')}`);
|
||||
logger.log(` ${theme.dim('List groups:')} ${theme.command('nupst group list')}`);
|
||||
logger.log('');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const config = await this.nupst.getDaemon().loadConfig();
|
||||
const targetSnapshot = this.resolveActionTarget(config, targetId);
|
||||
|
||||
await helpers.withPrompt(async (prompt) => {
|
||||
logger.log('');
|
||||
logger.info(
|
||||
`Add Action to ${targetSnapshot.targetType} ${
|
||||
theme.highlight(targetSnapshot.targetName)
|
||||
}`,
|
||||
);
|
||||
logger.log('');
|
||||
|
||||
const newAction = await this.promptForActionConfig(prompt);
|
||||
|
||||
// Add to target (UPS or group)
|
||||
if (!targetSnapshot.target.actions) {
|
||||
targetSnapshot.target.actions = [];
|
||||
}
|
||||
targetSnapshot.target.actions.push(newAction);
|
||||
|
||||
await this.nupst.getDaemon().saveConfig(config);
|
||||
|
||||
logger.log('');
|
||||
logger.success(`Action added to ${targetSnapshot.targetType} ${targetSnapshot.targetName}`);
|
||||
logger.log(` ${theme.dim('Changes saved and will be applied automatically')}`);
|
||||
logger.log('');
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to add action: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit an existing action on a UPS or group
|
||||
*/
|
||||
public async edit(targetId?: string, actionIndexStr?: string): Promise<void> {
|
||||
try {
|
||||
await helpers.withPrompt(async (prompt) => {
|
||||
await this.runEditProcess(targetId, actionIndexStr, prompt);
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to edit action: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the interactive process to edit an action
|
||||
*/
|
||||
public async runEditProcess(
|
||||
targetId: string | undefined,
|
||||
actionIndexStr: string | undefined,
|
||||
prompt: (question: string) => Promise<string>,
|
||||
): Promise<void> {
|
||||
if (!targetId || !actionIndexStr) {
|
||||
logger.error('Target ID and action index are required');
|
||||
logger.log(
|
||||
` ${theme.dim('Usage:')} ${
|
||||
theme.command('nupst action edit <ups-id|group-id> <action-index>')
|
||||
}`,
|
||||
);
|
||||
logger.log('');
|
||||
logger.log(` ${theme.dim('List actions:')} ${theme.command('nupst action list')}`);
|
||||
logger.log('');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const actionIndex = parseInt(actionIndexStr, 10);
|
||||
if (isNaN(actionIndex) || actionIndex < 0) {
|
||||
logger.error('Invalid action index. Must be >= 0.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const config = await this.nupst.getDaemon().loadConfig();
|
||||
const targetSnapshot = this.resolveActionTarget(config, targetId);
|
||||
|
||||
if (!targetSnapshot.target.actions || targetSnapshot.target.actions.length === 0) {
|
||||
logger.error(
|
||||
`No actions configured for ${targetSnapshot.targetType} '${targetSnapshot.targetName}'`,
|
||||
);
|
||||
logger.log('');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (actionIndex >= targetSnapshot.target.actions.length) {
|
||||
logger.error(
|
||||
`Invalid action index. ${targetSnapshot.targetType} '${targetSnapshot.targetName}' has ${targetSnapshot.target.actions.length} action(s) (index 0-${
|
||||
targetSnapshot.target.actions.length - 1
|
||||
})`,
|
||||
);
|
||||
logger.log('');
|
||||
logger.log(
|
||||
` ${theme.dim('List actions:')} ${theme.command(`nupst action list ${targetId}`)}`,
|
||||
);
|
||||
logger.log('');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const currentAction = targetSnapshot.target.actions[actionIndex];
|
||||
|
||||
logger.log('');
|
||||
logger.info(
|
||||
`Edit Action ${theme.highlight(String(actionIndex))} on ${targetSnapshot.targetType} ${
|
||||
theme.highlight(targetSnapshot.targetName)
|
||||
}`,
|
||||
);
|
||||
logger.log(` ${theme.dim('Current type:')} ${theme.highlight(currentAction.type)}`);
|
||||
logger.log('');
|
||||
|
||||
const updatedAction = await this.promptForActionConfig(prompt, currentAction);
|
||||
targetSnapshot.target.actions[actionIndex] = updatedAction;
|
||||
|
||||
await this.nupst.getDaemon().saveConfig(config);
|
||||
|
||||
logger.log('');
|
||||
logger.success(`Action updated on ${targetSnapshot.targetType} ${targetSnapshot.targetName}`);
|
||||
logger.log(` ${theme.dim('Index:')} ${actionIndex}`);
|
||||
logger.log(` ${theme.dim('Type:')} ${updatedAction.type}`);
|
||||
logger.log(` ${theme.dim('Changes saved and will be applied automatically')}`);
|
||||
logger.log('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an action from a UPS or group
|
||||
*/
|
||||
public async remove(targetId?: string, actionIndexStr?: string): Promise<void> {
|
||||
try {
|
||||
if (!targetId || !actionIndexStr) {
|
||||
logger.error('Target ID and action index are required');
|
||||
logger.log(
|
||||
` ${theme.dim('Usage:')} ${
|
||||
theme.command('nupst action remove <ups-id|group-id> <action-index>')
|
||||
}`,
|
||||
);
|
||||
logger.log('');
|
||||
logger.log(` ${theme.dim('List actions:')} ${theme.command('nupst action list')}`);
|
||||
logger.log('');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const actionIndex = parseInt(actionIndexStr, 10);
|
||||
if (isNaN(actionIndex) || actionIndex < 0) {
|
||||
logger.error('Invalid action index. Must be >= 0.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const config = await this.nupst.getDaemon().loadConfig();
|
||||
|
||||
// Check if it's a UPS
|
||||
const ups = config.upsDevices.find((u) => u.id === targetId);
|
||||
// Check if it's a group
|
||||
const group = config.groups?.find((g) => g.id === targetId);
|
||||
|
||||
if (!ups && !group) {
|
||||
logger.error(`UPS or Group with ID '${targetId}' not found`);
|
||||
logger.log('');
|
||||
logger.log(
|
||||
` ${theme.dim('List available UPS devices:')} ${theme.command('nupst ups list')}`,
|
||||
);
|
||||
logger.log(` ${theme.dim('List available groups:')} ${theme.command('nupst group list')}`);
|
||||
logger.log('');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const target = ups || group;
|
||||
const targetType = ups ? 'UPS' : 'Group';
|
||||
const targetName = ups ? ups.name : group!.name;
|
||||
|
||||
if (!target!.actions || target!.actions.length === 0) {
|
||||
logger.error(`No actions configured for ${targetType} '${targetName}'`);
|
||||
logger.log('');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (actionIndex >= target!.actions.length) {
|
||||
logger.error(
|
||||
`Invalid action index. ${targetType} '${targetName}' has ${
|
||||
target!.actions.length
|
||||
} action(s) (index 0-${target!.actions.length - 1})`,
|
||||
);
|
||||
logger.log('');
|
||||
logger.log(
|
||||
` ${theme.dim('List actions:')} ${theme.command(`nupst action list ${targetId}`)}`,
|
||||
);
|
||||
logger.log('');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const removedAction = target!.actions[actionIndex];
|
||||
target!.actions.splice(actionIndex, 1);
|
||||
|
||||
await this.nupst.getDaemon().saveConfig(config);
|
||||
|
||||
logger.log('');
|
||||
logger.success(`Action removed from ${targetType} ${targetName}`);
|
||||
logger.log(` ${theme.dim('Type:')} ${removedAction.type}`);
|
||||
if (removedAction.thresholds) {
|
||||
logger.log(
|
||||
` ${
|
||||
theme.dim('Thresholds:')
|
||||
} Battery: ${removedAction.thresholds.battery}%, Runtime: ${removedAction.thresholds.runtime}min`,
|
||||
);
|
||||
}
|
||||
logger.log(` ${theme.dim('Changes saved and will be applied automatically')}`);
|
||||
logger.log('');
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to remove action: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all actions for a specific UPS/group or all devices
|
||||
*/
|
||||
public async list(targetId?: string): Promise<void> {
|
||||
try {
|
||||
const config = await this.nupst.getDaemon().loadConfig();
|
||||
|
||||
if (targetId) {
|
||||
// List actions for specific UPS or group
|
||||
const ups = config.upsDevices.find((u) => u.id === targetId);
|
||||
const group = config.groups?.find((g) => g.id === targetId);
|
||||
|
||||
if (!ups && !group) {
|
||||
logger.error(`UPS or Group with ID '${targetId}' not found`);
|
||||
logger.log('');
|
||||
logger.log(
|
||||
` ${theme.dim('List available UPS devices:')} ${theme.command('nupst ups list')}`,
|
||||
);
|
||||
logger.log(
|
||||
` ${theme.dim('List available groups:')} ${theme.command('nupst group list')}`,
|
||||
);
|
||||
logger.log('');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (ups) {
|
||||
this.displayTargetActions(ups, 'UPS');
|
||||
} else {
|
||||
this.displayTargetActions(group!, 'Group');
|
||||
}
|
||||
} else {
|
||||
// List actions for all UPS devices and groups
|
||||
logger.log('');
|
||||
logger.info('Actions for All UPS Devices and Groups');
|
||||
logger.log('');
|
||||
|
||||
let hasAnyActions = false;
|
||||
|
||||
// Display UPS actions
|
||||
for (const ups of config.upsDevices) {
|
||||
if (ups.actions && ups.actions.length > 0) {
|
||||
hasAnyActions = true;
|
||||
this.displayTargetActions(ups, 'UPS');
|
||||
}
|
||||
}
|
||||
|
||||
// Display Group actions
|
||||
for (const group of config.groups || []) {
|
||||
if (group.actions && group.actions.length > 0) {
|
||||
hasAnyActions = true;
|
||||
this.displayTargetActions(group, 'Group');
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasAnyActions) {
|
||||
logger.log(` ${theme.dim('No actions configured')}`);
|
||||
logger.log('');
|
||||
logger.log(
|
||||
` ${theme.dim('Add an action:')} ${
|
||||
theme.command('nupst action add <ups-id|group-id>')
|
||||
}`,
|
||||
);
|
||||
logger.log('');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to list actions: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
private resolveActionTarget(
|
||||
config: { upsDevices: IUpsConfig[]; groups?: IGroupConfig[] },
|
||||
targetId: string,
|
||||
): { target: IUpsConfig | IGroupConfig; targetType: 'UPS' | 'Group'; targetName: string } {
|
||||
const ups = config.upsDevices.find((u) => u.id === targetId);
|
||||
const group = config.groups?.find((g) => g.id === targetId);
|
||||
|
||||
if (!ups && !group) {
|
||||
logger.error(`UPS or Group with ID '${targetId}' not found`);
|
||||
logger.log('');
|
||||
logger.log(
|
||||
` ${theme.dim('List available UPS devices:')} ${theme.command('nupst ups list')}`,
|
||||
);
|
||||
logger.log(` ${theme.dim('List available groups:')} ${theme.command('nupst group list')}`);
|
||||
logger.log('');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return {
|
||||
target: (ups || group)!,
|
||||
targetType: ups ? 'UPS' : 'Group',
|
||||
targetName: ups ? ups.name : group!.name,
|
||||
};
|
||||
}
|
||||
|
||||
private isClearInput(input: string): boolean {
|
||||
return input.trim().toLowerCase() === 'clear';
|
||||
}
|
||||
|
||||
private getActionTypeValue(action?: IActionConfig): number {
|
||||
switch (action?.type) {
|
||||
case 'webhook':
|
||||
return 2;
|
||||
case 'script':
|
||||
return 3;
|
||||
case 'proxmox':
|
||||
return 4;
|
||||
case 'shutdown':
|
||||
default:
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
private getTriggerModeValue(action?: IActionConfig): number {
|
||||
switch (action?.triggerMode) {
|
||||
case 'onlyPowerChanges':
|
||||
return 1;
|
||||
case 'powerChangesAndThresholds':
|
||||
return 3;
|
||||
case 'anyChange':
|
||||
return 4;
|
||||
case 'onlyThresholds':
|
||||
default:
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
private async promptForActionConfig(
|
||||
prompt: (question: string) => Promise<string>,
|
||||
existingAction?: IActionConfig,
|
||||
): Promise<IActionConfig> {
|
||||
logger.log(` ${theme.dim('Action Type:')}`);
|
||||
logger.log(` ${theme.dim('1)')} Shutdown (system shutdown)`);
|
||||
logger.log(` ${theme.dim('2)')} Webhook (HTTP notification)`);
|
||||
logger.log(` ${theme.dim('3)')} Custom Script (run .sh file from /etc/nupst)`);
|
||||
logger.log(
|
||||
` ${theme.dim('4)')} Proxmox (gracefully shut down VMs/LXCs before host shutdown)`,
|
||||
);
|
||||
|
||||
const defaultTypeValue = this.getActionTypeValue(existingAction);
|
||||
const typeInput = await prompt(
|
||||
` ${theme.dim('Select action type')} ${theme.dim(`[${defaultTypeValue}]:`)} `,
|
||||
);
|
||||
const typeValue = parseInt(typeInput, 10) || defaultTypeValue;
|
||||
const newAction: Partial<IActionConfig> = {};
|
||||
|
||||
if (typeValue === 1) {
|
||||
const shutdownAction = existingAction?.type === 'shutdown' ? existingAction : undefined;
|
||||
const defaultShutdownDelay = this.nupst.getDaemon().getConfig().defaultShutdownDelay ??
|
||||
SHUTDOWN.DEFAULT_DELAY_MINUTES;
|
||||
|
||||
newAction.type = 'shutdown';
|
||||
|
||||
const delayPrompt = shutdownAction?.shutdownDelay !== undefined
|
||||
? ` ${theme.dim('Shutdown delay')} ${
|
||||
theme.dim(
|
||||
`(minutes, 'clear' = default ${defaultShutdownDelay}) [${shutdownAction.shutdownDelay}]:`,
|
||||
)
|
||||
} `
|
||||
: ` ${theme.dim('Shutdown delay')} ${
|
||||
theme.dim(`(minutes, leave empty for default ${defaultShutdownDelay}):`)
|
||||
} `;
|
||||
const delayInput = await prompt(delayPrompt);
|
||||
if (this.isClearInput(delayInput)) {
|
||||
// Leave unset so the config-level default is used.
|
||||
} else if (delayInput.trim()) {
|
||||
const shutdownDelay = parseInt(delayInput, 10);
|
||||
if (isNaN(shutdownDelay) || shutdownDelay < 0) {
|
||||
logger.error('Invalid shutdown delay. Must be >= 0.');
|
||||
process.exit(1);
|
||||
}
|
||||
newAction.shutdownDelay = shutdownDelay;
|
||||
} else if (shutdownAction?.shutdownDelay !== undefined) {
|
||||
newAction.shutdownDelay = shutdownAction.shutdownDelay;
|
||||
}
|
||||
} else if (typeValue === 2) {
|
||||
const webhookAction = existingAction?.type === 'webhook' ? existingAction : undefined;
|
||||
newAction.type = 'webhook';
|
||||
|
||||
const webhookUrlInput = await prompt(
|
||||
` ${theme.dim('Webhook URL')} ${
|
||||
theme.dim(webhookAction?.webhookUrl ? `[${webhookAction.webhookUrl}]:` : ':')
|
||||
} `,
|
||||
);
|
||||
const webhookUrl = webhookUrlInput.trim() || webhookAction?.webhookUrl || '';
|
||||
if (!webhookUrl) {
|
||||
logger.error('Webhook URL is required.');
|
||||
process.exit(1);
|
||||
}
|
||||
newAction.webhookUrl = webhookUrl;
|
||||
|
||||
logger.log('');
|
||||
logger.log(` ${theme.dim('HTTP Method:')}`);
|
||||
logger.log(` ${theme.dim('1)')} POST (JSON body)`);
|
||||
logger.log(` ${theme.dim('2)')} GET (query parameters)`);
|
||||
const defaultMethodValue = webhookAction?.webhookMethod === 'GET' ? 2 : 1;
|
||||
const methodInput = await prompt(
|
||||
` ${theme.dim('Select method')} ${theme.dim(`[${defaultMethodValue}]:`)} `,
|
||||
);
|
||||
const methodValue = parseInt(methodInput, 10) || defaultMethodValue;
|
||||
newAction.webhookMethod = methodValue === 2 ? 'GET' : 'POST';
|
||||
|
||||
const currentWebhookTimeout = webhookAction?.webhookTimeout;
|
||||
const timeoutPrompt = currentWebhookTimeout !== undefined
|
||||
? ` ${theme.dim('Timeout in seconds')} ${
|
||||
theme.dim(`('clear' to unset) [${Math.floor(currentWebhookTimeout / 1000)}]:`)
|
||||
} `
|
||||
: ` ${theme.dim('Timeout in seconds')} ${theme.dim('[10]:')} `;
|
||||
const timeoutInput = await prompt(timeoutPrompt);
|
||||
if (this.isClearInput(timeoutInput)) {
|
||||
// Leave unset.
|
||||
} else if (timeoutInput.trim()) {
|
||||
const timeout = parseInt(timeoutInput, 10);
|
||||
if (isNaN(timeout) || timeout < 0) {
|
||||
logger.error('Invalid webhook timeout. Must be >= 0.');
|
||||
process.exit(1);
|
||||
}
|
||||
newAction.webhookTimeout = timeout * 1000;
|
||||
} else if (currentWebhookTimeout !== undefined) {
|
||||
newAction.webhookTimeout = currentWebhookTimeout;
|
||||
}
|
||||
} else if (typeValue === 3) {
|
||||
const scriptAction = existingAction?.type === 'script' ? existingAction : undefined;
|
||||
newAction.type = 'script';
|
||||
|
||||
const scriptPathInput = await prompt(
|
||||
` ${theme.dim('Script filename (in /etc/nupst/, must end with .sh)')} ${
|
||||
theme.dim(scriptAction?.scriptPath ? `[${scriptAction.scriptPath}]:` : ':')
|
||||
} `,
|
||||
);
|
||||
const scriptPath = scriptPathInput.trim() || scriptAction?.scriptPath || '';
|
||||
if (!scriptPath || !scriptPath.endsWith('.sh')) {
|
||||
logger.error('Script path must end with .sh.');
|
||||
process.exit(1);
|
||||
}
|
||||
newAction.scriptPath = scriptPath;
|
||||
|
||||
const currentScriptTimeout = scriptAction?.scriptTimeout;
|
||||
const timeoutPrompt = currentScriptTimeout !== undefined
|
||||
? ` ${theme.dim('Script timeout in seconds')} ${
|
||||
theme.dim(`('clear' to unset) [${Math.floor(currentScriptTimeout / 1000)}]:`)
|
||||
} `
|
||||
: ` ${theme.dim('Script timeout in seconds')} ${theme.dim('[60]:')} `;
|
||||
const timeoutInput = await prompt(timeoutPrompt);
|
||||
if (this.isClearInput(timeoutInput)) {
|
||||
// Leave unset.
|
||||
} else if (timeoutInput.trim()) {
|
||||
const timeout = parseInt(timeoutInput, 10);
|
||||
if (isNaN(timeout) || timeout < 0) {
|
||||
logger.error('Invalid script timeout. Must be >= 0.');
|
||||
process.exit(1);
|
||||
}
|
||||
newAction.scriptTimeout = timeout * 1000;
|
||||
} else if (currentScriptTimeout !== undefined) {
|
||||
newAction.scriptTimeout = currentScriptTimeout;
|
||||
}
|
||||
} else if (typeValue === 4) {
|
||||
const proxmoxAction = existingAction?.type === 'proxmox' ? existingAction : undefined;
|
||||
const detection = ProxmoxAction.detectCliAvailability();
|
||||
let useApiMode = false;
|
||||
|
||||
newAction.type = 'proxmox';
|
||||
|
||||
if (detection.available) {
|
||||
logger.log('');
|
||||
logger.success('Proxmox CLI tools detected (qm/pct).');
|
||||
logger.dim(` qm: ${detection.qmPath}`);
|
||||
logger.dim(` pct: ${detection.pctPath}`);
|
||||
|
||||
if (proxmoxAction) {
|
||||
logger.log('');
|
||||
logger.log(` ${theme.dim('Proxmox mode:')}`);
|
||||
logger.log(` ${theme.dim('1)')} CLI (local qm/pct tools)`);
|
||||
logger.log(` ${theme.dim('2)')} API (REST token authentication)`);
|
||||
const defaultModeValue = proxmoxAction.proxmoxMode === 'api' ? 2 : 1;
|
||||
const modeInput = await prompt(
|
||||
` ${theme.dim('Select Proxmox mode')} ${theme.dim(`[${defaultModeValue}]:`)} `,
|
||||
);
|
||||
const modeValue = parseInt(modeInput, 10) || defaultModeValue;
|
||||
useApiMode = modeValue === 2;
|
||||
}
|
||||
} else {
|
||||
logger.log('');
|
||||
if (!detection.isRoot) {
|
||||
logger.warn('Not running as root - CLI mode unavailable, using API mode.');
|
||||
} else {
|
||||
logger.warn('Proxmox CLI tools (qm/pct) not found - using API mode.');
|
||||
}
|
||||
useApiMode = true;
|
||||
}
|
||||
|
||||
if (useApiMode) {
|
||||
logger.log('');
|
||||
logger.info('Proxmox API Settings:');
|
||||
logger.dim('Create a token with: pveum user token add root@pam nupst --privsep=0');
|
||||
|
||||
const currentHost = proxmoxAction?.proxmoxHost || 'localhost';
|
||||
const pxHost = await prompt(
|
||||
` ${theme.dim('Proxmox Host')} ${theme.dim(`[${currentHost}]:`)} `,
|
||||
);
|
||||
newAction.proxmoxHost = pxHost.trim() || currentHost;
|
||||
|
||||
const currentPort = proxmoxAction?.proxmoxPort || 8006;
|
||||
const pxPortInput = await prompt(
|
||||
` ${theme.dim('Proxmox API Port')} ${theme.dim(`[${currentPort}]:`)} `,
|
||||
);
|
||||
const pxPort = parseInt(pxPortInput, 10);
|
||||
newAction.proxmoxPort = pxPortInput.trim() && !isNaN(pxPort) ? pxPort : currentPort;
|
||||
|
||||
const pxNodePrompt = proxmoxAction?.proxmoxNode
|
||||
? ` ${theme.dim('Proxmox Node Name')} ${
|
||||
theme.dim(`('clear' = auto-detect) [${proxmoxAction.proxmoxNode}]:`)
|
||||
} `
|
||||
: ` ${theme.dim('Proxmox Node Name')} ${theme.dim('(empty = auto-detect):')} `;
|
||||
const pxNode = await prompt(pxNodePrompt);
|
||||
if (this.isClearInput(pxNode)) {
|
||||
// Leave unset so hostname auto-detection is used.
|
||||
} else if (pxNode.trim()) {
|
||||
newAction.proxmoxNode = pxNode.trim();
|
||||
} else if (proxmoxAction?.proxmoxNode) {
|
||||
newAction.proxmoxNode = proxmoxAction.proxmoxNode;
|
||||
}
|
||||
|
||||
const currentTokenId = proxmoxAction?.proxmoxTokenId || '';
|
||||
const tokenIdInput = await prompt(
|
||||
` ${theme.dim('API Token ID (e.g., root@pam!nupst)')} ${
|
||||
theme.dim(currentTokenId ? `[${currentTokenId}]:` : ':')
|
||||
} `,
|
||||
);
|
||||
const tokenId = tokenIdInput.trim() || currentTokenId;
|
||||
if (!tokenId) {
|
||||
logger.error('Token ID is required for API mode.');
|
||||
process.exit(1);
|
||||
}
|
||||
newAction.proxmoxTokenId = tokenId;
|
||||
|
||||
const currentTokenSecret = proxmoxAction?.proxmoxTokenSecret || '';
|
||||
const tokenSecretInput = await prompt(
|
||||
` ${theme.dim('API Token Secret')} ${theme.dim(currentTokenSecret ? '[*****]:' : ':')} `,
|
||||
);
|
||||
const tokenSecret = tokenSecretInput.trim() || currentTokenSecret;
|
||||
if (!tokenSecret) {
|
||||
logger.error('Token Secret is required for API mode.');
|
||||
process.exit(1);
|
||||
}
|
||||
newAction.proxmoxTokenSecret = tokenSecret;
|
||||
|
||||
const defaultInsecure = proxmoxAction?.proxmoxInsecure !== false;
|
||||
const insecureInput = await prompt(
|
||||
` ${theme.dim('Skip TLS verification (self-signed cert)?')} ${
|
||||
theme.dim(defaultInsecure ? '(Y/n):' : '(y/N):')
|
||||
} `,
|
||||
);
|
||||
newAction.proxmoxInsecure = insecureInput.trim()
|
||||
? insecureInput.toLowerCase() !== 'n'
|
||||
: defaultInsecure;
|
||||
newAction.proxmoxMode = 'api';
|
||||
} else {
|
||||
newAction.proxmoxMode = 'cli';
|
||||
}
|
||||
|
||||
const currentExcludeIds = proxmoxAction?.proxmoxExcludeIds || [];
|
||||
const excludePrompt = currentExcludeIds.length > 0
|
||||
? ` ${theme.dim('VM/CT IDs to exclude')} ${
|
||||
theme.dim(`(comma-separated, 'clear' = none) [${currentExcludeIds.join(',')}]:`)
|
||||
} `
|
||||
: ` ${theme.dim('VM/CT IDs to exclude (comma-separated, or empty):')} `;
|
||||
const excludeInput = await prompt(excludePrompt);
|
||||
if (this.isClearInput(excludeInput)) {
|
||||
newAction.proxmoxExcludeIds = [];
|
||||
} else if (excludeInput.trim()) {
|
||||
newAction.proxmoxExcludeIds = excludeInput.split(',').map((s) => parseInt(s.trim(), 10))
|
||||
.filter((n) => !isNaN(n));
|
||||
} else if (currentExcludeIds.length > 0) {
|
||||
newAction.proxmoxExcludeIds = [...currentExcludeIds];
|
||||
}
|
||||
|
||||
const currentStopTimeout = proxmoxAction?.proxmoxStopTimeout;
|
||||
const stopTimeoutPrompt = currentStopTimeout !== undefined
|
||||
? ` ${theme.dim('VM shutdown timeout in seconds')} ${
|
||||
theme.dim(`('clear' to unset) [${currentStopTimeout}]:`)
|
||||
} `
|
||||
: ` ${theme.dim('VM shutdown timeout in seconds')} ${theme.dim('[120]:')} `;
|
||||
const timeoutInput = await prompt(stopTimeoutPrompt);
|
||||
if (this.isClearInput(timeoutInput)) {
|
||||
// Leave unset.
|
||||
} else if (timeoutInput.trim()) {
|
||||
const stopTimeout = parseInt(timeoutInput, 10);
|
||||
if (isNaN(stopTimeout) || stopTimeout < 0) {
|
||||
logger.error('Invalid VM shutdown timeout. Must be >= 0.');
|
||||
process.exit(1);
|
||||
}
|
||||
newAction.proxmoxStopTimeout = stopTimeout;
|
||||
} else if (currentStopTimeout !== undefined) {
|
||||
newAction.proxmoxStopTimeout = currentStopTimeout;
|
||||
}
|
||||
|
||||
const defaultForceStop = proxmoxAction?.proxmoxForceStop !== false;
|
||||
const forceInput = await prompt(
|
||||
` ${theme.dim("Force-stop VMs that don't shut down in time?")} ${
|
||||
theme.dim(defaultForceStop ? '(Y/n):' : '(y/N):')
|
||||
} `,
|
||||
);
|
||||
newAction.proxmoxForceStop = forceInput.trim()
|
||||
? forceInput.toLowerCase() !== 'n'
|
||||
: defaultForceStop;
|
||||
|
||||
const defaultHaPolicyValue = proxmoxAction?.proxmoxHaPolicy === 'haStop' ? 2 : 1;
|
||||
const haPolicyInput = await prompt(
|
||||
` ${theme.dim('HA-managed guest handling')} ${
|
||||
theme.dim(`([1] none, 2 haStop) [${defaultHaPolicyValue}]:`)
|
||||
} `,
|
||||
);
|
||||
const haPolicyValue = parseInt(haPolicyInput, 10) || defaultHaPolicyValue;
|
||||
newAction.proxmoxHaPolicy = haPolicyValue === 2 ? 'haStop' : 'none';
|
||||
} else {
|
||||
logger.error('Invalid action type.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
logger.log('');
|
||||
const defaultBatteryThreshold = existingAction?.thresholds?.battery ?? 60;
|
||||
const batteryInput = await prompt(
|
||||
` ${theme.dim('Battery threshold')} ${theme.dim(`(%) [${defaultBatteryThreshold}]:`)} `,
|
||||
);
|
||||
const battery = batteryInput.trim() ? parseInt(batteryInput, 10) : defaultBatteryThreshold;
|
||||
if (isNaN(battery) || battery < 0 || battery > 100) {
|
||||
logger.error('Invalid battery threshold. Must be 0-100.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const defaultRuntimeThreshold = existingAction?.thresholds?.runtime ?? 20;
|
||||
const runtimeInput = await prompt(
|
||||
` ${theme.dim('Runtime threshold')} ${
|
||||
theme.dim(`(minutes) [${defaultRuntimeThreshold}]:`)
|
||||
} `,
|
||||
);
|
||||
const runtime = runtimeInput.trim() ? parseInt(runtimeInput, 10) : defaultRuntimeThreshold;
|
||||
if (isNaN(runtime) || runtime < 0) {
|
||||
logger.error('Invalid runtime threshold. Must be >= 0.');
|
||||
process.exit(1);
|
||||
}
|
||||
newAction.thresholds = { battery, runtime };
|
||||
|
||||
logger.log('');
|
||||
logger.log(` ${theme.dim('Trigger mode:')}`);
|
||||
logger.log(
|
||||
` ${theme.dim('1)')} onlyPowerChanges - Trigger only when power status changes`,
|
||||
);
|
||||
logger.log(
|
||||
` ${theme.dim('2)')} onlyThresholds - Trigger only when thresholds are violated`,
|
||||
);
|
||||
logger.log(
|
||||
` ${theme.dim('3)')} powerChangesAndThresholds - Trigger on power change AND thresholds`,
|
||||
);
|
||||
logger.log(` ${theme.dim('4)')} anyChange - Trigger on any status change`);
|
||||
const defaultTriggerValue = this.getTriggerModeValue(existingAction);
|
||||
const triggerChoice = await prompt(
|
||||
` ${theme.dim('Choice')} ${theme.dim(`[${defaultTriggerValue}]:`)} `,
|
||||
);
|
||||
const triggerValue = parseInt(triggerChoice, 10) || defaultTriggerValue;
|
||||
const triggerModeMap: Record<number, NonNullable<IActionConfig['triggerMode']>> = {
|
||||
1: 'onlyPowerChanges',
|
||||
2: 'onlyThresholds',
|
||||
3: 'powerChangesAndThresholds',
|
||||
4: 'anyChange',
|
||||
};
|
||||
newAction.triggerMode = triggerModeMap[triggerValue] || 'onlyThresholds';
|
||||
|
||||
return newAction as IActionConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display actions for a single UPS or Group
|
||||
*/
|
||||
private displayTargetActions(
|
||||
target: IUpsConfig | IGroupConfig,
|
||||
targetType: 'UPS' | 'Group',
|
||||
): void {
|
||||
logger.log(
|
||||
`${symbols.info} ${targetType} ${theme.highlight(target.name)} ${
|
||||
theme.dim(`(${target.id})`)
|
||||
}`,
|
||||
);
|
||||
logger.log('');
|
||||
|
||||
if (!target.actions || target.actions.length === 0) {
|
||||
logger.log(` ${theme.dim('No actions configured')}`);
|
||||
logger.log('');
|
||||
return;
|
||||
}
|
||||
|
||||
const columns: ITableColumn[] = [
|
||||
{ header: 'Index', key: 'index', align: 'right' },
|
||||
{ header: 'Type', key: 'type', align: 'left' },
|
||||
{ header: 'Battery', key: 'battery', align: 'right' },
|
||||
{ header: 'Runtime', key: 'runtime', align: 'right' },
|
||||
{ header: 'Trigger Mode', key: 'triggerMode', align: 'left' },
|
||||
{ header: 'Details', key: 'details', align: 'left' },
|
||||
];
|
||||
|
||||
const rows = target.actions.map((action, index) => {
|
||||
const defaultShutdownDelay = this.nupst.getDaemon().getConfig().defaultShutdownDelay ??
|
||||
SHUTDOWN.DEFAULT_DELAY_MINUTES;
|
||||
let details = `${action.shutdownDelay ?? defaultShutdownDelay}min delay`;
|
||||
if (action.type === 'proxmox') {
|
||||
const mode = action.proxmoxMode || 'auto';
|
||||
if (mode === 'cli' || (mode === 'auto' && !action.proxmoxTokenId)) {
|
||||
details = 'CLI mode';
|
||||
} else {
|
||||
const host = action.proxmoxHost || 'localhost';
|
||||
const port = action.proxmoxPort || 8006;
|
||||
details = `API ${host}:${port}`;
|
||||
}
|
||||
if (action.proxmoxExcludeIds?.length) {
|
||||
details += `, excl: ${action.proxmoxExcludeIds.join(',')}`;
|
||||
}
|
||||
if (action.proxmoxHaPolicy === 'haStop') {
|
||||
details += ', haStop';
|
||||
}
|
||||
} else if (action.type === 'webhook') {
|
||||
details = action.webhookUrl || theme.dim('N/A');
|
||||
} else if (action.type === 'script') {
|
||||
details = action.scriptPath || theme.dim('N/A');
|
||||
}
|
||||
|
||||
return {
|
||||
index: theme.dim(index.toString()),
|
||||
type: theme.highlight(action.type),
|
||||
battery: action.thresholds ? `${action.thresholds.battery}%` : theme.dim('N/A'),
|
||||
runtime: action.thresholds ? `${action.thresholds.runtime}min` : theme.dim('N/A'),
|
||||
triggerMode: theme.dim(action.triggerMode || 'onlyThresholds'),
|
||||
details,
|
||||
};
|
||||
});
|
||||
|
||||
logger.logTable(columns, rows);
|
||||
logger.log('');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { execSync } from 'node:child_process';
|
||||
import { Nupst } from '../nupst.ts';
|
||||
import { logger } from '../logger.ts';
|
||||
import { theme } from '../colors.ts';
|
||||
import * as helpers from '../helpers/index.ts';
|
||||
|
||||
/**
|
||||
* Class for handling feature-related CLI commands
|
||||
* Provides interface for managing optional features like HTTP server
|
||||
*/
|
||||
export class FeatureHandler {
|
||||
private readonly nupst: Nupst;
|
||||
|
||||
/**
|
||||
* Create a new feature handler
|
||||
* @param nupst Reference to the main Nupst instance
|
||||
*/
|
||||
constructor(nupst: Nupst) {
|
||||
this.nupst = nupst;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure HTTP server feature
|
||||
*/
|
||||
public async configureHttpServer(): Promise<void> {
|
||||
try {
|
||||
await helpers.withPrompt(async (prompt) => {
|
||||
await this.runHttpServerConfig(prompt);
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`HTTP Server config error: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the interactive HTTP server configuration process
|
||||
* @param prompt Function to prompt for user input
|
||||
*/
|
||||
private async runHttpServerConfig(prompt: (question: string) => Promise<string>): Promise<void> {
|
||||
logger.log('');
|
||||
logger.logBoxTitle('HTTP Server Feature Configuration', 60);
|
||||
logger.logBoxLine('Configure the HTTP server to expose UPS status as JSON');
|
||||
logger.logBoxEnd();
|
||||
logger.log('');
|
||||
|
||||
// Load config
|
||||
let config;
|
||||
try {
|
||||
await this.nupst.getDaemon().loadConfig();
|
||||
config = this.nupst.getDaemon().getConfig();
|
||||
} catch (error) {
|
||||
logger.error('No configuration found. Please run "nupst ups add" first.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show current status
|
||||
if (config.httpServer?.enabled) {
|
||||
logger.info('HTTP Server is currently: ' + theme.success('ENABLED'));
|
||||
logger.log(` Port: ${theme.highlight(String(config.httpServer.port))}`);
|
||||
logger.log(` Path: ${theme.highlight(config.httpServer.path)}`);
|
||||
logger.log(` Auth Token: ${theme.dim('***' + config.httpServer.authToken.slice(-4))}`);
|
||||
logger.log('');
|
||||
} else {
|
||||
logger.info('HTTP Server is currently: ' + theme.dim('DISABLED'));
|
||||
logger.log('');
|
||||
}
|
||||
|
||||
// Ask enable/disable
|
||||
const action = await prompt('Enable or disable HTTP server? (enable/disable/cancel): ');
|
||||
|
||||
if (action.toLowerCase() === 'cancel' || action.toLowerCase() === 'c') {
|
||||
logger.log('Cancelled.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (action.toLowerCase() === 'disable' || action.toLowerCase() === 'd') {
|
||||
// Disable HTTP server
|
||||
config.httpServer = {
|
||||
enabled: false,
|
||||
port: config.httpServer?.port || 8080,
|
||||
path: config.httpServer?.path || '/ups-status',
|
||||
authToken: config.httpServer?.authToken || '',
|
||||
};
|
||||
|
||||
this.nupst.getDaemon().saveConfig(config);
|
||||
|
||||
logger.log('');
|
||||
logger.success('HTTP Server disabled');
|
||||
logger.log('');
|
||||
|
||||
await this.restartServiceIfRunning();
|
||||
return;
|
||||
}
|
||||
|
||||
if (action.toLowerCase() !== 'enable' && action.toLowerCase() !== 'e') {
|
||||
logger.error('Invalid option. Please enter "enable", "disable", or "cancel".');
|
||||
return;
|
||||
}
|
||||
|
||||
// Enable - gather configuration
|
||||
logger.log('');
|
||||
|
||||
const portInput = await prompt(`HTTP Server Port [${config.httpServer?.port || 8080}]: `);
|
||||
const port = portInput ? parseInt(portInput, 10) : (config.httpServer?.port || 8080);
|
||||
|
||||
if (isNaN(port) || port < 1 || port > 65535) {
|
||||
logger.error('Invalid port number. Must be between 1 and 65535.');
|
||||
return;
|
||||
}
|
||||
|
||||
const pathInput = await prompt(`URL Path [${config.httpServer?.path || '/ups-status'}]: `);
|
||||
const path = pathInput || config.httpServer?.path || '/ups-status';
|
||||
|
||||
// Ensure path starts with /
|
||||
const finalPath = path.startsWith('/') ? path : `/${path}`;
|
||||
|
||||
// Generate or reuse auth token
|
||||
let authToken = config.httpServer?.authToken;
|
||||
if (!authToken) {
|
||||
// Generate new random token
|
||||
authToken = helpers.shortId() + helpers.shortId() + helpers.shortId();
|
||||
logger.log('');
|
||||
logger.info('Generated new authentication token');
|
||||
} else {
|
||||
const regenerate = await prompt('Regenerate authentication token? (y/N): ');
|
||||
if (regenerate.toLowerCase() === 'y' || regenerate.toLowerCase() === 'yes') {
|
||||
authToken = helpers.shortId() + helpers.shortId() + helpers.shortId();
|
||||
logger.info('Generated new authentication token');
|
||||
}
|
||||
}
|
||||
|
||||
// Save configuration
|
||||
config.httpServer = {
|
||||
enabled: true,
|
||||
port,
|
||||
path: finalPath,
|
||||
authToken,
|
||||
};
|
||||
|
||||
this.nupst.getDaemon().saveConfig(config);
|
||||
|
||||
// Display summary
|
||||
logger.log('');
|
||||
logger.logBoxTitle('HTTP Server Configuration', 70, 'success');
|
||||
logger.logBoxLine(`Status: ${theme.success('ENABLED')}`);
|
||||
logger.logBoxLine(`Port: ${theme.highlight(String(port))}`);
|
||||
logger.logBoxLine(`Path: ${theme.highlight(finalPath)}`);
|
||||
logger.logBoxLine(`Auth Token: ${theme.warning(authToken)}`);
|
||||
logger.logBoxLine('');
|
||||
logger.logBoxLine(theme.dim('Usage examples:'));
|
||||
logger.logBoxLine(
|
||||
` curl -H "Authorization: Bearer ${authToken}" http://localhost:${port}${finalPath}`,
|
||||
);
|
||||
logger.logBoxLine(` curl "http://localhost:${port}${finalPath}?token=${authToken}"`);
|
||||
logger.logBoxEnd();
|
||||
logger.log('');
|
||||
|
||||
logger.warn('IMPORTANT: Save the authentication token securely!');
|
||||
logger.log('');
|
||||
|
||||
await this.restartServiceIfRunning();
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart the service if it's currently running
|
||||
*/
|
||||
private async restartServiceIfRunning(): Promise<void> {
|
||||
try {
|
||||
const isActive =
|
||||
execSync('systemctl is-active nupst.service || true').toString().trim() === 'active';
|
||||
|
||||
if (isActive) {
|
||||
logger.log('');
|
||||
const { prompt, close } = await helpers.createPrompt();
|
||||
const answer = await prompt('Service is running. Restart to apply changes? (Y/n): ');
|
||||
close();
|
||||
|
||||
if (!answer || answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes') {
|
||||
logger.info('Restarting service...');
|
||||
execSync('sudo systemctl restart nupst.service');
|
||||
logger.success('Service restarted successfully');
|
||||
} else {
|
||||
logger.warn('Changes will take effect on next service restart');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore errors - service might not be installed
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,572 @@
|
||||
import { Nupst } from '../nupst.ts';
|
||||
import { type ITableColumn, logger } from '../logger.ts';
|
||||
import { theme } from '../colors.ts';
|
||||
import * as helpers from '../helpers/index.ts';
|
||||
import type { IGroupConfig, INupstConfig, IUpsConfig } from '../daemon.ts';
|
||||
|
||||
/**
|
||||
* Class for handling group-related CLI commands
|
||||
* Provides interface for managing UPS groups
|
||||
*/
|
||||
export class GroupHandler {
|
||||
private readonly nupst: Nupst;
|
||||
|
||||
/**
|
||||
* Create a new Group handler
|
||||
* @param nupst Reference to the main Nupst instance
|
||||
*/
|
||||
constructor(nupst: Nupst) {
|
||||
this.nupst = nupst;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all UPS groups
|
||||
*/
|
||||
public async list(): Promise<void> {
|
||||
try {
|
||||
// Try to load configuration
|
||||
try {
|
||||
await this.nupst.getDaemon().loadConfig();
|
||||
} catch (error) {
|
||||
logger.logBox(
|
||||
'Configuration Error',
|
||||
[
|
||||
'No configuration found.',
|
||||
"Please run 'nupst ups add' first to create a configuration.",
|
||||
],
|
||||
50,
|
||||
'error',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current configuration
|
||||
const config = this.nupst.getDaemon().getConfig();
|
||||
|
||||
// Check if multi-UPS config
|
||||
if (!config.groups || !Array.isArray(config.groups)) {
|
||||
logger.logBox(
|
||||
'UPS Groups',
|
||||
[
|
||||
'No groups configured.',
|
||||
'',
|
||||
`${theme.dim('Run')} ${theme.command('nupst group add')} ${
|
||||
theme.dim('to add a group')
|
||||
}`,
|
||||
],
|
||||
50,
|
||||
'info',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Display group list with modern table
|
||||
if (config.groups.length === 0) {
|
||||
logger.logBox(
|
||||
'UPS Groups',
|
||||
[
|
||||
'No UPS groups configured.',
|
||||
'',
|
||||
`${theme.dim('Run')} ${theme.command('nupst group add')} ${
|
||||
theme.dim('to add a group')
|
||||
}`,
|
||||
],
|
||||
60,
|
||||
'info',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare table data
|
||||
const rows = config.groups.map((group) => {
|
||||
// Count UPS devices in this group
|
||||
const upsInGroup = config.upsDevices.filter((ups) => ups.groups.includes(group.id));
|
||||
const upsCount = upsInGroup.length;
|
||||
const upsNames = upsInGroup.map((ups) => ups.name).join(', ');
|
||||
|
||||
return {
|
||||
id: group.id,
|
||||
name: group.name || '',
|
||||
mode: group.mode || 'unknown',
|
||||
count: String(upsCount),
|
||||
devices: upsCount > 0 ? upsNames : theme.dim('None'),
|
||||
};
|
||||
});
|
||||
|
||||
const columns: ITableColumn[] = [
|
||||
{ header: 'ID', key: 'id', align: 'left', color: theme.highlight },
|
||||
{ header: 'Name', key: 'name', align: 'left' },
|
||||
{ header: 'Mode', key: 'mode', align: 'left', color: theme.info },
|
||||
{ header: 'UPS Count', key: 'count', align: 'right' },
|
||||
{ header: 'UPS Devices', key: 'devices', align: 'left' },
|
||||
];
|
||||
|
||||
logger.log('');
|
||||
logger.info(`UPS Groups (${config.groups.length}):`);
|
||||
logger.log('');
|
||||
logger.logTable(columns, rows);
|
||||
logger.log('');
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to list UPS groups: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new UPS group
|
||||
*/
|
||||
public async add(): Promise<void> {
|
||||
try {
|
||||
await helpers.withPrompt(async (prompt) => {
|
||||
// Try to load configuration
|
||||
try {
|
||||
await this.nupst.getDaemon().loadConfig();
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
'No configuration found. Please run "nupst ups add" first to create a configuration.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current configuration
|
||||
const config = this.nupst.getDaemon().getConfig();
|
||||
|
||||
// Initialize groups array if not exists
|
||||
if (!config.groups) {
|
||||
config.groups = [];
|
||||
}
|
||||
|
||||
// Check if upsDevices is initialized
|
||||
if (!config.upsDevices) {
|
||||
config.upsDevices = [];
|
||||
}
|
||||
|
||||
logger.log('\nNUPST Add Group');
|
||||
logger.log('==============\n');
|
||||
logger.log('This will guide you through creating a new UPS group.\n');
|
||||
|
||||
// Generate a new unique group ID
|
||||
const groupId = helpers.shortId();
|
||||
|
||||
// Get group name
|
||||
const name = await prompt('Group Name: ');
|
||||
|
||||
// Get group mode
|
||||
const modeInput = await prompt('Group Mode (redundant/nonRedundant) [redundant]: ');
|
||||
const mode = modeInput.toLowerCase() === 'nonredundant' ? 'nonRedundant' : 'redundant';
|
||||
|
||||
// Get optional description
|
||||
const description = await prompt('Group Description (optional): ');
|
||||
|
||||
// Create the new group
|
||||
const newGroup: IGroupConfig = {
|
||||
id: groupId,
|
||||
name: name || `Group-${groupId}`,
|
||||
mode,
|
||||
description: description || undefined,
|
||||
};
|
||||
|
||||
// Add the group to the configuration
|
||||
config.groups.push(newGroup);
|
||||
|
||||
// Save the configuration
|
||||
await this.nupst.getDaemon().saveConfig(config);
|
||||
|
||||
// Display summary
|
||||
const boxWidth = 45;
|
||||
logger.logBoxTitle('Group Created', boxWidth);
|
||||
logger.logBoxLine(`ID: ${newGroup.id}`);
|
||||
logger.logBoxLine(`Name: ${newGroup.name}`);
|
||||
logger.logBoxLine(`Mode: ${newGroup.mode}`);
|
||||
if (newGroup.description) {
|
||||
logger.logBoxLine(`Description: ${newGroup.description}`);
|
||||
}
|
||||
logger.logBoxEnd();
|
||||
|
||||
// Check if there are UPS devices to assign to this group
|
||||
if (config.upsDevices.length > 0) {
|
||||
const assignUps = await prompt(
|
||||
'Would you like to assign UPS devices to this group now? (y/N): ',
|
||||
);
|
||||
if (assignUps.toLowerCase() === 'y') {
|
||||
await this.assignUpsToGroup(newGroup.id, config, prompt);
|
||||
|
||||
// Save again after assigning UPS devices
|
||||
await this.nupst.getDaemon().saveConfig(config);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if service is running and restart it if needed
|
||||
this.nupst.getUpsHandler().restartServiceIfRunning();
|
||||
|
||||
logger.log('\nGroup setup complete!');
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`Add group error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit an existing UPS group
|
||||
* @param groupId ID of the group to edit
|
||||
*/
|
||||
public async edit(groupId: string): Promise<void> {
|
||||
try {
|
||||
await helpers.withPrompt(async (prompt) => {
|
||||
// Try to load configuration
|
||||
try {
|
||||
await this.nupst.getDaemon().loadConfig();
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
'No configuration found. Please run "nupst ups add" first to create a configuration.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current configuration
|
||||
const config = this.nupst.getDaemon().getConfig();
|
||||
|
||||
// Check if groups are initialized
|
||||
if (!config.groups || !Array.isArray(config.groups)) {
|
||||
logger.error(
|
||||
'No groups configured. Please run "nupst group add" first to create a group.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the group to edit
|
||||
const groupIndex = config.groups.findIndex((group) => group.id === groupId);
|
||||
if (groupIndex === -1) {
|
||||
logger.error(`Group with ID "${groupId}" not found.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const group = config.groups[groupIndex];
|
||||
|
||||
logger.log(`\nNUPST Edit Group: ${group.name} (${group.id})`);
|
||||
logger.log('==============================================\n');
|
||||
|
||||
// Edit group name
|
||||
const newName = await prompt(`Group Name [${group.name}]: `);
|
||||
if (newName.trim()) {
|
||||
group.name = newName;
|
||||
}
|
||||
|
||||
// Edit group mode
|
||||
const currentMode = group.mode || 'redundant';
|
||||
const modeInput = await prompt(`Group Mode (redundant/nonRedundant) [${currentMode}]: `);
|
||||
if (modeInput.trim()) {
|
||||
group.mode = modeInput.toLowerCase() === 'nonredundant' ? 'nonRedundant' : 'redundant';
|
||||
}
|
||||
|
||||
// Edit description
|
||||
const currentDesc = group.description || '';
|
||||
const newDesc = await prompt(`Group Description [${currentDesc}]: `);
|
||||
if (newDesc.trim() || newDesc === '') {
|
||||
group.description = newDesc.trim() || undefined;
|
||||
}
|
||||
|
||||
// Update the group in the configuration
|
||||
config.groups[groupIndex] = group;
|
||||
|
||||
// Save the configuration
|
||||
await this.nupst.getDaemon().saveConfig(config);
|
||||
|
||||
// Display summary
|
||||
const boxWidth = 45;
|
||||
logger.logBoxTitle('Group Updated', boxWidth);
|
||||
logger.logBoxLine(`ID: ${group.id}`);
|
||||
logger.logBoxLine(`Name: ${group.name}`);
|
||||
logger.logBoxLine(`Mode: ${group.mode}`);
|
||||
if (group.description) {
|
||||
logger.logBoxLine(`Description: ${group.description}`);
|
||||
}
|
||||
logger.logBoxEnd();
|
||||
|
||||
// Edit UPS assignments if requested
|
||||
const editAssignments = await prompt(
|
||||
'Would you like to edit UPS assignments for this group? (y/N): ',
|
||||
);
|
||||
if (editAssignments.toLowerCase() === 'y') {
|
||||
await this.assignUpsToGroup(group.id, config, prompt);
|
||||
|
||||
// Save again after editing assignments
|
||||
await this.nupst.getDaemon().saveConfig(config);
|
||||
}
|
||||
|
||||
// Check if service is running and restart it if needed
|
||||
this.nupst.getUpsHandler().restartServiceIfRunning();
|
||||
|
||||
logger.log('\nGroup edit complete!');
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`Edit group error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an existing UPS group
|
||||
* @param groupId ID of the group to delete
|
||||
*/
|
||||
public async remove(groupId: string): Promise<void> {
|
||||
try {
|
||||
// Try to load configuration
|
||||
try {
|
||||
await this.nupst.getDaemon().loadConfig();
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
'No configuration found. Please run "nupst ups add" first to create a configuration.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current configuration
|
||||
const config = this.nupst.getDaemon().getConfig();
|
||||
|
||||
// Check if groups are initialized
|
||||
if (!config.groups || !Array.isArray(config.groups)) {
|
||||
logger.error('No groups configured.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the group to delete
|
||||
const groupIndex = config.groups.findIndex((group) => group.id === groupId);
|
||||
if (groupIndex === -1) {
|
||||
logger.error(`Group with ID "${groupId}" not found.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const groupToDelete = config.groups[groupIndex];
|
||||
|
||||
// Get confirmation before deleting
|
||||
const { prompt, close } = await helpers.createPrompt();
|
||||
const confirm = (await prompt(
|
||||
`Are you sure you want to delete group "${groupToDelete.name}" (${groupId})? [y/N]: `,
|
||||
)).toLowerCase();
|
||||
close();
|
||||
|
||||
if (confirm !== 'y' && confirm !== 'yes') {
|
||||
logger.log('Deletion cancelled.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove this group from all UPS device group assignments
|
||||
if (config.upsDevices && Array.isArray(config.upsDevices)) {
|
||||
for (const ups of config.upsDevices) {
|
||||
const groupIndex = ups.groups.indexOf(groupId);
|
||||
if (groupIndex !== -1) {
|
||||
ups.groups.splice(groupIndex, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the group from the array
|
||||
config.groups.splice(groupIndex, 1);
|
||||
|
||||
// Save the configuration
|
||||
await this.nupst.getDaemon().saveConfig(config);
|
||||
|
||||
logger.log(`Group "${groupToDelete.name}" (${groupId}) has been deleted.`);
|
||||
|
||||
// Check if service is running and restart it if needed
|
||||
this.nupst.getUpsHandler().restartServiceIfRunning();
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to delete group: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign UPS devices to groups
|
||||
* @param ups UPS configuration to update
|
||||
* @param groups Available groups
|
||||
* @param prompt Function to prompt for user input
|
||||
*/
|
||||
public async assignUpsToGroups(
|
||||
ups: IUpsConfig,
|
||||
groups: IGroupConfig[],
|
||||
prompt: (question: string) => Promise<string>,
|
||||
): Promise<void> {
|
||||
// Initialize groups array if it doesn't exist
|
||||
if (!ups.groups) {
|
||||
ups.groups = [];
|
||||
}
|
||||
|
||||
// Show current group assignments
|
||||
logger.log('\nCurrent Group Assignments:');
|
||||
if (ups.groups && ups.groups.length > 0) {
|
||||
for (const groupId of ups.groups) {
|
||||
const group = groups.find((g) => g.id === groupId);
|
||||
if (group) {
|
||||
logger.log(`- ${group.name} (${group.id})`);
|
||||
} else {
|
||||
logger.log(`- Unknown group (${groupId})`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.log('- None');
|
||||
}
|
||||
|
||||
// Show available groups
|
||||
logger.log('\nAvailable Groups:');
|
||||
if (groups.length === 0) {
|
||||
logger.log('- No groups available. Use "nupst group add" to create groups.');
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < groups.length; i++) {
|
||||
const group = groups[i];
|
||||
const assigned = ups.groups && ups.groups.includes(group.id);
|
||||
logger.log(
|
||||
`${i + 1}) ${group.name} (${group.id}) [${assigned ? 'Assigned' : 'Not Assigned'}]`,
|
||||
);
|
||||
}
|
||||
|
||||
// Prompt for group selection
|
||||
const selection = await prompt(
|
||||
'\nSelect groups to assign/unassign (comma-separated numbers, or "clear" to remove all): ',
|
||||
);
|
||||
|
||||
if (selection.toLowerCase() === 'clear') {
|
||||
// Clear all group assignments
|
||||
ups.groups = [];
|
||||
logger.log('All group assignments cleared.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selection.trim()) {
|
||||
// No change if empty input
|
||||
return;
|
||||
}
|
||||
|
||||
// Process selections
|
||||
const selections = selection.split(',').map((s) => s.trim());
|
||||
|
||||
for (const sel of selections) {
|
||||
const index = parseInt(sel, 10) - 1;
|
||||
if (isNaN(index) || index < 0 || index >= groups.length) {
|
||||
logger.error(`Invalid selection: ${sel}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const group = groups[index];
|
||||
|
||||
// Initialize groups array if needed (should already be done above)
|
||||
if (!ups.groups) {
|
||||
ups.groups = [];
|
||||
}
|
||||
|
||||
// Toggle assignment
|
||||
const groupIndex = ups.groups.indexOf(group.id);
|
||||
if (groupIndex === -1) {
|
||||
// Add to group
|
||||
ups.groups.push(group.id);
|
||||
logger.log(`Added to group: ${group.name} (${group.id})`);
|
||||
} else {
|
||||
// Remove from group
|
||||
ups.groups.splice(groupIndex, 1);
|
||||
logger.log(`Removed from group: ${group.name} (${group.id})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign UPS devices to a specific group
|
||||
* @param groupId Group ID to assign UPS devices to
|
||||
* @param config Full configuration
|
||||
* @param prompt Function to prompt for user input
|
||||
*/
|
||||
public async assignUpsToGroup(
|
||||
groupId: string,
|
||||
config: INupstConfig,
|
||||
prompt: (question: string) => Promise<string>,
|
||||
): Promise<void> {
|
||||
if (!config.upsDevices || config.upsDevices.length === 0) {
|
||||
logger.log('No UPS devices available. Use "nupst ups add" to add UPS devices.');
|
||||
return;
|
||||
}
|
||||
|
||||
const group = config.groups.find((g) => g.id === groupId);
|
||||
if (!group) {
|
||||
logger.error(`Group with ID "${groupId}" not found.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Show current assignments
|
||||
logger.log(`\nUPS devices in group "${group.name}" (${group.id}):`);
|
||||
const upsInGroup = config.upsDevices.filter((ups) =>
|
||||
ups.groups && ups.groups.includes(groupId)
|
||||
);
|
||||
if (upsInGroup.length === 0) {
|
||||
logger.log('- None');
|
||||
} else {
|
||||
for (const ups of upsInGroup) {
|
||||
logger.log(`- ${ups.name} (${ups.id})`);
|
||||
}
|
||||
}
|
||||
|
||||
// Show all UPS devices
|
||||
logger.log('\nAvailable UPS devices:');
|
||||
for (let i = 0; i < config.upsDevices.length; i++) {
|
||||
const ups = config.upsDevices[i];
|
||||
const assigned = ups.groups && ups.groups.includes(groupId);
|
||||
logger.log(`${i + 1}) ${ups.name} (${ups.id}) [${assigned ? 'Assigned' : 'Not Assigned'}]`);
|
||||
}
|
||||
|
||||
// Prompt for UPS selection
|
||||
const selection = await prompt(
|
||||
'\nSelect UPS devices to assign/unassign (comma-separated numbers, or "clear" to remove all): ',
|
||||
);
|
||||
|
||||
if (selection.toLowerCase() === 'clear') {
|
||||
// Clear all UPS from this group
|
||||
for (const ups of config.upsDevices) {
|
||||
if (ups.groups) {
|
||||
const groupIndex = ups.groups.indexOf(groupId);
|
||||
if (groupIndex !== -1) {
|
||||
ups.groups.splice(groupIndex, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
logger.log(`All UPS devices removed from group "${group.name}".`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selection.trim()) {
|
||||
// No change if empty input
|
||||
return;
|
||||
}
|
||||
|
||||
// Process selections
|
||||
const selections = selection.split(',').map((s) => s.trim());
|
||||
|
||||
for (const sel of selections) {
|
||||
const index = parseInt(sel, 10) - 1;
|
||||
if (isNaN(index) || index < 0 || index >= config.upsDevices.length) {
|
||||
logger.error(`Invalid selection: ${sel}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const ups = config.upsDevices[index];
|
||||
|
||||
// Initialize groups array if needed
|
||||
if (!ups.groups) {
|
||||
ups.groups = [];
|
||||
}
|
||||
|
||||
// Toggle assignment
|
||||
const groupIndex = ups.groups.indexOf(groupId);
|
||||
if (groupIndex === -1) {
|
||||
// Add to group
|
||||
ups.groups.push(groupId);
|
||||
logger.log(`Added "${ups.name}" to group "${group.name}"`);
|
||||
} else {
|
||||
// Remove from group
|
||||
ups.groups.splice(groupIndex, 1);
|
||||
logger.log(`Removed "${ups.name}" from group "${group.name}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
import process from 'node:process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { execFileSync, execSync } from 'node:child_process';
|
||||
import { Nupst } from '../nupst.ts';
|
||||
import { logger } from '../logger.ts';
|
||||
import { theme } from '../colors.ts';
|
||||
import { PAUSE } from '../constants.ts';
|
||||
import type { IPauseState } from '../pause-state.ts';
|
||||
import * as helpers from '../helpers/index.ts';
|
||||
import { renderUpgradeChangelog } from '../upgrade-changelog.ts';
|
||||
|
||||
/**
|
||||
* Class for handling service-related CLI commands
|
||||
* Provides interface for managing systemd service
|
||||
*/
|
||||
export class ServiceHandler {
|
||||
private readonly nupst: Nupst;
|
||||
|
||||
/**
|
||||
* Create a new Service handler
|
||||
* @param nupst Reference to the main Nupst instance
|
||||
*/
|
||||
constructor(nupst: Nupst) {
|
||||
this.nupst = nupst;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable the service (requires root)
|
||||
*/
|
||||
public async enable(): Promise<void> {
|
||||
this.checkRootAccess('This command must be run as root.');
|
||||
await this.nupst.getSystemd().install();
|
||||
logger.log(
|
||||
'NUPST service has been installed. Use "nupst service start" to start the service.',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the daemon directly
|
||||
* @param debugMode Whether to enable debug mode
|
||||
*/
|
||||
public async daemonStart(debugMode: boolean = false): Promise<void> {
|
||||
logger.log('Starting NUPST daemon...');
|
||||
try {
|
||||
// Enable debug mode for SNMP if requested
|
||||
if (debugMode) {
|
||||
this.nupst.getSnmp().enableDebug();
|
||||
logger.log('SNMP debug mode enabled');
|
||||
}
|
||||
await this.nupst.getDaemon().start();
|
||||
} catch (error) {
|
||||
// Error is already logged and process.exit is called in daemon.start()
|
||||
// No need to handle it here
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show logs of the systemd service
|
||||
*/
|
||||
public async logs(): Promise<void> {
|
||||
try {
|
||||
// Use exec with spawn to properly follow logs in real-time
|
||||
const { spawn } = await import('child_process');
|
||||
logger.log('Tailing nupst service logs (Ctrl+C to exit)...\n');
|
||||
|
||||
const journalctl = spawn('journalctl', ['-u', 'nupst.service', '-n', '50', '-f'], {
|
||||
stdio: ['ignore', 'inherit', 'inherit'],
|
||||
});
|
||||
|
||||
// Forward signals to child process
|
||||
process.on('SIGINT', () => {
|
||||
journalctl.kill('SIGINT');
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// Wait for process to exit
|
||||
await new Promise<void>((resolve) => {
|
||||
journalctl.on('exit', () => resolve());
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`Failed to retrieve logs: ${error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the systemd service
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
await this.nupst.getSystemd().stop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the systemd service
|
||||
*/
|
||||
public async start(): Promise<void> {
|
||||
try {
|
||||
await this.nupst.getSystemd().start();
|
||||
} catch (error) {
|
||||
// Error will be displayed by systemd.start()
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show status of the systemd service and UPS
|
||||
*/
|
||||
public async status(debugMode: boolean = false): Promise<void> {
|
||||
await this.nupst.getSystemd().getStatus(debugMode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause action monitoring
|
||||
* @param args Command arguments (e.g., ['--duration', '30m'])
|
||||
*/
|
||||
public async pause(args: string[]): Promise<void> {
|
||||
try {
|
||||
// Parse --duration argument
|
||||
let resumeAt: number | null = null;
|
||||
const durationIdx = args.indexOf('--duration');
|
||||
if (durationIdx !== -1 && args[durationIdx + 1]) {
|
||||
const durationStr = args[durationIdx + 1];
|
||||
const durationMs = this.parseDuration(durationStr);
|
||||
if (durationMs === null) {
|
||||
logger.error(`Invalid duration format: ${durationStr}`);
|
||||
logger.dim(' Valid formats: 30m, 2h, 1d (minutes, hours, days)');
|
||||
return;
|
||||
}
|
||||
if (durationMs > PAUSE.MAX_DURATION_MS) {
|
||||
logger.error(`Duration exceeds maximum of 24 hours`);
|
||||
return;
|
||||
}
|
||||
resumeAt = Date.now() + durationMs;
|
||||
}
|
||||
|
||||
// Check if already paused
|
||||
if (fs.existsSync(PAUSE.FILE_PATH)) {
|
||||
logger.warn('Monitoring is already paused');
|
||||
try {
|
||||
const data = fs.readFileSync(PAUSE.FILE_PATH, 'utf8');
|
||||
const state = JSON.parse(data) as IPauseState;
|
||||
logger.dim(` Paused at: ${new Date(state.pausedAt).toISOString()}`);
|
||||
if (state.resumeAt) {
|
||||
const remaining = Math.round((state.resumeAt - Date.now()) / 1000);
|
||||
logger.dim(` Auto-resume in: ${remaining > 0 ? remaining : 0} seconds`);
|
||||
}
|
||||
} catch (_e) {
|
||||
// Ignore parse errors
|
||||
}
|
||||
logger.dim(' Run "nupst resume" to resume monitoring');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create pause state
|
||||
const pauseState: IPauseState = {
|
||||
pausedAt: Date.now(),
|
||||
pausedBy: 'cli',
|
||||
resumeAt,
|
||||
};
|
||||
|
||||
// Ensure config directory exists
|
||||
const pauseDir = path.dirname(PAUSE.FILE_PATH);
|
||||
if (!fs.existsSync(pauseDir)) {
|
||||
fs.mkdirSync(pauseDir, { recursive: true });
|
||||
}
|
||||
|
||||
fs.writeFileSync(PAUSE.FILE_PATH, JSON.stringify(pauseState, null, 2));
|
||||
|
||||
logger.log('');
|
||||
logger.logBoxTitle('Monitoring Paused', 45, 'warning');
|
||||
logger.logBoxLine('UPS polling continues but actions are suppressed');
|
||||
if (resumeAt) {
|
||||
const durationStr = args[args.indexOf('--duration') + 1];
|
||||
logger.logBoxLine(`Auto-resume after: ${durationStr}`);
|
||||
logger.logBoxLine(`Resume at: ${new Date(resumeAt).toISOString()}`);
|
||||
} else {
|
||||
logger.logBoxLine('Duration: Indefinite');
|
||||
logger.logBoxLine('Run "nupst resume" to resume');
|
||||
}
|
||||
logger.logBoxEnd();
|
||||
logger.log('');
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to pause: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume action monitoring
|
||||
*/
|
||||
public async resume(): Promise<void> {
|
||||
try {
|
||||
if (!fs.existsSync(PAUSE.FILE_PATH)) {
|
||||
logger.info('Monitoring is not paused');
|
||||
return;
|
||||
}
|
||||
|
||||
fs.unlinkSync(PAUSE.FILE_PATH);
|
||||
|
||||
logger.log('');
|
||||
logger.logBoxTitle('Monitoring Resumed', 45, 'success');
|
||||
logger.logBoxLine('Action monitoring has been resumed');
|
||||
logger.logBoxEnd();
|
||||
logger.log('');
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to resume: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a duration string like '30m', '2h', '1d' into milliseconds
|
||||
*/
|
||||
private parseDuration(duration: string): number | null {
|
||||
const match = duration.match(/^(\d+)\s*(m|h|d)$/i);
|
||||
if (!match) return null;
|
||||
|
||||
const value = parseInt(match[1], 10);
|
||||
const unit = match[2].toLowerCase();
|
||||
|
||||
switch (unit) {
|
||||
case 'm':
|
||||
return value * 60 * 1000;
|
||||
case 'h':
|
||||
return value * 60 * 60 * 1000;
|
||||
case 'd':
|
||||
return value * 24 * 60 * 60 * 1000;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable the service (requires root)
|
||||
*/
|
||||
public async disable(): Promise<void> {
|
||||
this.checkRootAccess('This command must be run as root.');
|
||||
await this.nupst.getSystemd().disable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the user has root access
|
||||
* @param errorMessage Error message to display if not root
|
||||
*/
|
||||
private checkRootAccess(errorMessage: string): void {
|
||||
if (process.getuid && process.getuid() !== 0) {
|
||||
logger.error(errorMessage);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update NUPST from repository and refresh systemd service
|
||||
*/
|
||||
public update(): void {
|
||||
try {
|
||||
// Check if running as root
|
||||
this.checkRootAccess(
|
||||
'This command must be run as root to upgrade NUPST.',
|
||||
);
|
||||
|
||||
console.log('');
|
||||
logger.info('Checking for updates...');
|
||||
|
||||
try {
|
||||
// Get current version
|
||||
const currentVersion = this.nupst.getVersion();
|
||||
|
||||
// Fetch latest version from Gitea API
|
||||
const apiUrl = 'https://code.foss.global/api/v1/repos/serve.zone/nupst/releases/latest';
|
||||
const response = this.fetchRemoteText(apiUrl);
|
||||
const release = JSON.parse(response);
|
||||
const latestVersion = release.tag_name; // e.g., "v4.0.7"
|
||||
|
||||
// Normalize versions for comparison (ensure both have "v" prefix)
|
||||
const normalizedCurrent = currentVersion.startsWith('v')
|
||||
? currentVersion
|
||||
: `v${currentVersion}`;
|
||||
const normalizedLatest = latestVersion.startsWith('v')
|
||||
? latestVersion
|
||||
: `v${latestVersion}`;
|
||||
|
||||
logger.dim(`Current version: ${normalizedCurrent}`);
|
||||
logger.dim(`Latest version: ${normalizedLatest}`);
|
||||
console.log('');
|
||||
|
||||
// Compare normalized versions
|
||||
if (normalizedCurrent === normalizedLatest) {
|
||||
logger.success('Already up to date!');
|
||||
console.log('');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`New version available: ${latestVersion}`);
|
||||
this.showUpgradeChangelog(normalizedCurrent, normalizedLatest);
|
||||
logger.dim('Downloading and installing...');
|
||||
console.log('');
|
||||
|
||||
// Download and run the install script
|
||||
// This handles everything: download binary, stop service, replace, restart
|
||||
const installUrl = 'https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh';
|
||||
|
||||
execSync(`curl -sSL ${installUrl} | bash`, {
|
||||
stdio: 'inherit', // Show install script output to user
|
||||
});
|
||||
|
||||
console.log('');
|
||||
logger.success(`Updated to ${latestVersion}`);
|
||||
console.log('');
|
||||
} catch (error) {
|
||||
console.log('');
|
||||
logger.error('Update failed');
|
||||
logger.dim(`${error instanceof Error ? error.message : String(error)}`);
|
||||
console.log('');
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Update failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
private fetchRemoteText(url: string): string {
|
||||
return execFileSync('curl', ['-fsSL', url], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
}
|
||||
|
||||
private showUpgradeChangelog(currentVersion: string, latestVersion: string): void {
|
||||
const changelogUrl = 'https://code.foss.global/serve.zone/nupst/raw/branch/main/changelog.md';
|
||||
|
||||
try {
|
||||
const changelogMarkdown = this.fetchRemoteText(changelogUrl);
|
||||
const renderedChanges = renderUpgradeChangelog(
|
||||
changelogMarkdown,
|
||||
currentVersion,
|
||||
latestVersion,
|
||||
);
|
||||
|
||||
if (!renderedChanges) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`What's changed:`);
|
||||
logger.log('');
|
||||
for (const line of renderedChanges.split('\n')) {
|
||||
logger.log(line);
|
||||
}
|
||||
logger.log('');
|
||||
} catch (error) {
|
||||
logger.warn('Could not load changelog for this upgrade. Continuing anyway.');
|
||||
logger.dim(`${error instanceof Error ? error.message : String(error)}`);
|
||||
logger.log('');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Completely uninstall NUPST from the system
|
||||
*/
|
||||
public async uninstall(): Promise<void> {
|
||||
// Check if running as root
|
||||
this.checkRootAccess('This command must be run as root.');
|
||||
|
||||
try {
|
||||
const { prompt, close } = await helpers.createPrompt();
|
||||
|
||||
logger.log('');
|
||||
logger.highlight('NUPST Uninstaller');
|
||||
logger.dim('===============');
|
||||
logger.log('This will completely remove NUPST from your system.');
|
||||
logger.log('');
|
||||
|
||||
// Ask about removing configuration
|
||||
const removeConfig = await prompt(
|
||||
'Do you want to remove the NUPST configuration files? (y/N): ',
|
||||
);
|
||||
|
||||
// Find the uninstall.sh script location
|
||||
let uninstallScriptPath: string;
|
||||
|
||||
// Try to determine script location based on executable path
|
||||
try {
|
||||
// For ESM, we can use import.meta.url, but since we might be in CJS
|
||||
// we'll use a more reliable approach based on process.argv[1]
|
||||
const binPath = process.argv[1];
|
||||
const { dirname, join } = await import('path');
|
||||
const modulePath = dirname(dirname(binPath));
|
||||
uninstallScriptPath = join(modulePath, 'uninstall.sh');
|
||||
|
||||
// Check if the script exists
|
||||
const { access } = await import('fs/promises');
|
||||
await access(uninstallScriptPath);
|
||||
} catch (error) {
|
||||
// If we can't find it in the expected location, try common installation paths
|
||||
const commonPaths = ['/opt/nupst/uninstall.sh', `${process.cwd()}/uninstall.sh`];
|
||||
const { existsSync } = await import('fs');
|
||||
|
||||
uninstallScriptPath = '';
|
||||
for (const path of commonPaths) {
|
||||
if (existsSync(path)) {
|
||||
uninstallScriptPath = path;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!uninstallScriptPath) {
|
||||
logger.error('Could not locate uninstall.sh script. Aborting uninstall.');
|
||||
close();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Close prompt before executing script
|
||||
close();
|
||||
|
||||
// Execute uninstall.sh with the appropriate option
|
||||
logger.log('');
|
||||
logger.log(`Running uninstaller from ${uninstallScriptPath}...`);
|
||||
|
||||
// Pass the configuration removal option as an environment variable
|
||||
const env = {
|
||||
...process.env,
|
||||
REMOVE_CONFIG: removeConfig.toLowerCase() === 'y' ? 'yes' : 'no',
|
||||
REMOVE_REPO: 'yes', // Always remove repo as requested
|
||||
NUPST_CLI_CALL: 'true', // Flag to indicate this is being called from CLI
|
||||
};
|
||||
|
||||
// Run the uninstall script with sudo
|
||||
execSync(`sudo bash ${uninstallScriptPath}`, {
|
||||
env,
|
||||
stdio: 'inherit', // Show output in the terminal
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`Uninstall failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Color theme and styling utilities for NUPST CLI
|
||||
* Uses Deno standard library colors module
|
||||
*/
|
||||
import * as colors from '@std/fmt/colors';
|
||||
|
||||
/**
|
||||
* Color theme for consistent CLI styling
|
||||
*/
|
||||
export const theme = {
|
||||
// Message types
|
||||
error: colors.red,
|
||||
warning: colors.yellow,
|
||||
success: colors.green,
|
||||
info: colors.cyan,
|
||||
dim: colors.dim,
|
||||
highlight: colors.bold,
|
||||
|
||||
// Status indicators
|
||||
statusActive: (text: string) => colors.green(colors.bold(text)),
|
||||
statusInactive: (text: string) => colors.red(text),
|
||||
statusWarning: (text: string) => colors.yellow(text),
|
||||
statusUnknown: (text: string) => colors.dim(text),
|
||||
|
||||
// Battery level colors
|
||||
batteryGood: colors.green, // > 60%
|
||||
batteryMedium: colors.yellow, // 30-60%
|
||||
batteryCritical: colors.red, // < 30%
|
||||
|
||||
// Box borders
|
||||
borderSuccess: colors.green,
|
||||
borderError: colors.red,
|
||||
borderWarning: colors.yellow,
|
||||
borderInfo: colors.cyan,
|
||||
borderDefault: (text: string) => text, // No color
|
||||
|
||||
// Command/code highlighting
|
||||
command: colors.cyan,
|
||||
code: colors.dim,
|
||||
path: colors.blue,
|
||||
};
|
||||
|
||||
/**
|
||||
* Status symbols with colors
|
||||
*/
|
||||
export const symbols = {
|
||||
success: colors.green('✓'),
|
||||
error: colors.red('✗'),
|
||||
warning: colors.yellow('⚠'),
|
||||
info: colors.cyan('ℹ'),
|
||||
running: colors.green('●'),
|
||||
stopped: colors.red('○'),
|
||||
starting: colors.yellow('◐'),
|
||||
unknown: colors.dim('◯'),
|
||||
};
|
||||
|
||||
/**
|
||||
* Get color for battery level
|
||||
*/
|
||||
export function getBatteryColor(percentage: number): (text: string) => string {
|
||||
if (percentage >= 60) return theme.batteryGood;
|
||||
if (percentage >= 30) return theme.batteryMedium;
|
||||
return theme.batteryCritical;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get color for runtime remaining
|
||||
*/
|
||||
export function getRuntimeColor(minutes: number): (text: string) => string {
|
||||
if (minutes >= 20) return theme.batteryGood;
|
||||
if (minutes >= 10) return theme.batteryMedium;
|
||||
return theme.batteryCritical;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format UPS power status with color
|
||||
*/
|
||||
export function formatPowerStatus(
|
||||
status: 'online' | 'onBattery' | 'unknown' | 'unreachable',
|
||||
): string {
|
||||
switch (status) {
|
||||
case 'online':
|
||||
return theme.success('Online');
|
||||
case 'onBattery':
|
||||
return theme.warning('On Battery');
|
||||
case 'unreachable':
|
||||
return theme.error('Unreachable');
|
||||
case 'unknown':
|
||||
default:
|
||||
return theme.dim('Unknown');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
export interface IWatchEventLike {
|
||||
kind: string;
|
||||
paths: string[];
|
||||
}
|
||||
|
||||
export type TConfigReloadTransition = 'monitoringWillStart' | 'deviceCountChanged' | 'reloaded';
|
||||
|
||||
export interface IConfigReloadSnapshot {
|
||||
transition: TConfigReloadTransition;
|
||||
message: string;
|
||||
shouldInitializeUpsStatus: boolean;
|
||||
shouldLogMonitoringStart: boolean;
|
||||
}
|
||||
|
||||
export function shouldReloadConfig(
|
||||
event: IWatchEventLike,
|
||||
configFileName: string = 'config.json',
|
||||
): boolean {
|
||||
return event.kind === 'modify' && event.paths.some((path) => path.includes(configFileName));
|
||||
}
|
||||
|
||||
export function shouldRefreshPauseState(
|
||||
event: IWatchEventLike,
|
||||
pauseFileName: string = 'pause',
|
||||
): boolean {
|
||||
return ['create', 'modify', 'remove'].includes(event.kind) &&
|
||||
event.paths.some((path) => path.includes(pauseFileName));
|
||||
}
|
||||
|
||||
export function analyzeConfigReload(
|
||||
oldDeviceCount: number,
|
||||
newDeviceCount: number,
|
||||
): IConfigReloadSnapshot {
|
||||
if (newDeviceCount > 0 && oldDeviceCount === 0) {
|
||||
return {
|
||||
transition: 'monitoringWillStart',
|
||||
message: `Configuration reloaded! Found ${newDeviceCount} UPS device(s)`,
|
||||
shouldInitializeUpsStatus: false,
|
||||
shouldLogMonitoringStart: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (newDeviceCount !== oldDeviceCount) {
|
||||
return {
|
||||
transition: 'deviceCountChanged',
|
||||
message: `Configuration reloaded! UPS devices: ${oldDeviceCount} -> ${newDeviceCount}`,
|
||||
shouldInitializeUpsStatus: true,
|
||||
shouldLogMonitoringStart: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
transition: 'reloaded',
|
||||
message: 'Configuration reloaded successfully',
|
||||
shouldInitializeUpsStatus: false,
|
||||
shouldLogMonitoringStart: false,
|
||||
};
|
||||
}
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* NUPST Constants
|
||||
*
|
||||
* Central location for all timeout, interval, and threshold values.
|
||||
* This makes configuration easier and code more self-documenting.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Default timing values in milliseconds
|
||||
*/
|
||||
export const TIMING = {
|
||||
/** Default interval between UPS status checks (30 seconds) */
|
||||
CHECK_INTERVAL_MS: 30000,
|
||||
|
||||
/** Interval for idle monitoring mode (60 seconds) */
|
||||
IDLE_CHECK_INTERVAL_MS: 60000,
|
||||
|
||||
/** Interval for checking config file changes (60 seconds) */
|
||||
CONFIG_CHECK_INTERVAL_MS: 60000,
|
||||
|
||||
/** Interval for logging periodic status updates (5 minutes) */
|
||||
LOG_INTERVAL_MS: 5 * 60 * 1000,
|
||||
|
||||
/** Maximum time to monitor during shutdown (5 minutes) */
|
||||
MAX_SHUTDOWN_MONITORING_MS: 5 * 60 * 1000,
|
||||
|
||||
/** Interval for UPS checks during shutdown (30 seconds) */
|
||||
SHUTDOWN_CHECK_INTERVAL_MS: 30000,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* SNMP-related constants
|
||||
*/
|
||||
export const SNMP = {
|
||||
/** Default SNMP port */
|
||||
DEFAULT_PORT: 161,
|
||||
|
||||
/** Default SNMP timeout (5 seconds) */
|
||||
DEFAULT_TIMEOUT_MS: 5000,
|
||||
|
||||
/** Number of SNMP retries */
|
||||
RETRIES: 2,
|
||||
|
||||
/** Timeout for noAuthNoPriv security level (5 seconds) */
|
||||
TIMEOUT_NO_AUTH_MS: 5000,
|
||||
|
||||
/** Timeout for authNoPriv security level (10 seconds) */
|
||||
TIMEOUT_AUTH_MS: 10000,
|
||||
|
||||
/** Timeout for authPriv security level (15 seconds) */
|
||||
TIMEOUT_AUTH_PRIV_MS: 15000,
|
||||
|
||||
/** Maximum timeout for connection tests (10 seconds) */
|
||||
MAX_TEST_TIMEOUT_MS: 10000,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Default threshold values
|
||||
*/
|
||||
export const THRESHOLDS = {
|
||||
/** Default battery capacity threshold for shutdown (60%) */
|
||||
DEFAULT_BATTERY_PERCENT: 60,
|
||||
|
||||
/** Default runtime threshold for shutdown (20 minutes) */
|
||||
DEFAULT_RUNTIME_MINUTES: 20,
|
||||
|
||||
/** Emergency runtime threshold during shutdown (5 minutes) */
|
||||
EMERGENCY_RUNTIME_MINUTES: 5,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Webhook action constants
|
||||
*/
|
||||
export const WEBHOOK = {
|
||||
/** Default webhook request timeout (10 seconds) */
|
||||
DEFAULT_TIMEOUT_MS: 10000,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Script action constants
|
||||
*/
|
||||
export const SCRIPT = {
|
||||
/** Default script execution timeout (60 seconds) */
|
||||
DEFAULT_TIMEOUT_MS: 60000,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Shutdown action constants
|
||||
*/
|
||||
export const SHUTDOWN = {
|
||||
/** Default shutdown delay (5 minutes) */
|
||||
DEFAULT_DELAY_MINUTES: 5,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* HTTP Server constants
|
||||
*/
|
||||
export const HTTP_SERVER = {
|
||||
/** Default HTTP server port */
|
||||
DEFAULT_PORT: 8080,
|
||||
|
||||
/** Default URL path for UPS status endpoint */
|
||||
DEFAULT_PATH: '/ups-status',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Network failure detection constants
|
||||
*/
|
||||
export const NETWORK = {
|
||||
/** Number of consecutive failures before marking UPS as unreachable */
|
||||
CONSECUTIVE_FAILURE_THRESHOLD: 3,
|
||||
|
||||
/** Maximum tracked consecutive failures (prevents overflow) */
|
||||
MAX_CONSECUTIVE_FAILURES: 100,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* UPSD/NIS protocol constants
|
||||
*/
|
||||
export const UPSD = {
|
||||
/** Default UPSD port (NUT standard) */
|
||||
DEFAULT_PORT: 3493,
|
||||
|
||||
/** Default timeout in milliseconds */
|
||||
DEFAULT_TIMEOUT_MS: 5000,
|
||||
|
||||
/** Default NUT device name */
|
||||
DEFAULT_UPS_NAME: 'ups',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Pause/resume constants
|
||||
*/
|
||||
export const PAUSE = {
|
||||
/** Path to the pause state file */
|
||||
FILE_PATH: '/etc/nupst/pause',
|
||||
|
||||
/** Maximum pause duration (24 hours) */
|
||||
MAX_DURATION_MS: 24 * 60 * 60 * 1000,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Proxmox VM shutdown constants
|
||||
*/
|
||||
export const PROXMOX = {
|
||||
/** Default Proxmox API port */
|
||||
DEFAULT_PORT: 8006,
|
||||
|
||||
/** Default Proxmox host */
|
||||
DEFAULT_HOST: 'localhost',
|
||||
|
||||
/** Default timeout for VM/CT shutdown in seconds */
|
||||
DEFAULT_STOP_TIMEOUT_SECONDS: 120,
|
||||
|
||||
/** Poll interval for checking VM/CT status in seconds */
|
||||
STATUS_POLL_INTERVAL_SECONDS: 5,
|
||||
|
||||
/** Proxmox API base path */
|
||||
API_BASE: '/api2/json',
|
||||
|
||||
/** Common paths to search for Proxmox CLI tools (qm, pct) */
|
||||
CLI_TOOL_PATHS: ['/usr/sbin', '/usr/bin', '/sbin', '/bin'] as readonly string[],
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* UI/Display constants
|
||||
*/
|
||||
export const UI = {
|
||||
/** Default width for log boxes */
|
||||
DEFAULT_BOX_WIDTH: 45,
|
||||
|
||||
/** Wide box width for status displays */
|
||||
WIDE_BOX_WIDTH: 60,
|
||||
|
||||
/** Extra wide box width for detailed info */
|
||||
EXTRA_WIDE_BOX_WIDTH: 70,
|
||||
} as const;
|
||||
+1057
-193
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,198 @@
|
||||
import type { IActionConfig, TPowerStatus } from './actions/base-action.ts';
|
||||
import { createInitialUpsStatus, type IUpsIdentity, type IUpsStatus } from './ups-status.ts';
|
||||
|
||||
export interface IGroupStatusSnapshot {
|
||||
updatedStatus: IUpsStatus;
|
||||
transition: 'none' | 'powerStatusChange';
|
||||
previousStatus?: IUpsStatus;
|
||||
}
|
||||
|
||||
export interface IGroupThresholdEvaluation {
|
||||
exceedsThreshold: boolean;
|
||||
blockedByUnreachable: boolean;
|
||||
representativeStatus?: IUpsStatus;
|
||||
}
|
||||
|
||||
const destructiveActionTypes = new Set(['shutdown', 'proxmox']);
|
||||
|
||||
function getStatusSeverity(powerStatus: TPowerStatus): number {
|
||||
switch (powerStatus) {
|
||||
case 'unreachable':
|
||||
return 3;
|
||||
case 'onBattery':
|
||||
return 2;
|
||||
case 'unknown':
|
||||
return 1;
|
||||
case 'online':
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export function selectWorstStatus(statuses: IUpsStatus[]): IUpsStatus | undefined {
|
||||
return statuses.reduce<IUpsStatus | undefined>((worst, status) => {
|
||||
if (!worst) {
|
||||
return status;
|
||||
}
|
||||
|
||||
const severityDiff = getStatusSeverity(status.powerStatus) -
|
||||
getStatusSeverity(worst.powerStatus);
|
||||
if (severityDiff > 0) {
|
||||
return status;
|
||||
}
|
||||
if (severityDiff < 0) {
|
||||
return worst;
|
||||
}
|
||||
|
||||
if (status.batteryRuntime !== worst.batteryRuntime) {
|
||||
return status.batteryRuntime < worst.batteryRuntime ? status : worst;
|
||||
}
|
||||
|
||||
if (status.batteryCapacity !== worst.batteryCapacity) {
|
||||
return status.batteryCapacity < worst.batteryCapacity ? status : worst;
|
||||
}
|
||||
|
||||
return worst;
|
||||
}, undefined);
|
||||
}
|
||||
|
||||
function deriveGroupPowerStatus(
|
||||
mode: 'redundant' | 'nonRedundant',
|
||||
memberStatuses: IUpsStatus[],
|
||||
): TPowerStatus {
|
||||
if (memberStatuses.length === 0) {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
if (memberStatuses.some((status) => status.powerStatus === 'unreachable')) {
|
||||
return 'unreachable';
|
||||
}
|
||||
|
||||
if (mode === 'redundant') {
|
||||
if (memberStatuses.every((status) => status.powerStatus === 'onBattery')) {
|
||||
return 'onBattery';
|
||||
}
|
||||
} else if (memberStatuses.some((status) => status.powerStatus === 'onBattery')) {
|
||||
return 'onBattery';
|
||||
}
|
||||
|
||||
if (memberStatuses.some((status) => status.powerStatus === 'unknown')) {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
return 'online';
|
||||
}
|
||||
|
||||
function pickRepresentativeStatus(
|
||||
powerStatus: TPowerStatus,
|
||||
memberStatuses: IUpsStatus[],
|
||||
): IUpsStatus | undefined {
|
||||
const matchingStatuses = memberStatuses.filter((status) => status.powerStatus === powerStatus);
|
||||
return selectWorstStatus(matchingStatuses.length > 0 ? matchingStatuses : memberStatuses);
|
||||
}
|
||||
|
||||
export function buildGroupStatusSnapshot(
|
||||
group: IUpsIdentity,
|
||||
mode: 'redundant' | 'nonRedundant',
|
||||
memberStatuses: IUpsStatus[],
|
||||
currentStatus: IUpsStatus | undefined,
|
||||
currentTime: number,
|
||||
): IGroupStatusSnapshot {
|
||||
const previousStatus = currentStatus || createInitialUpsStatus(group, currentTime);
|
||||
const powerStatus = deriveGroupPowerStatus(mode, memberStatuses);
|
||||
const representative = pickRepresentativeStatus(powerStatus, memberStatuses) || previousStatus;
|
||||
const updatedStatus: IUpsStatus = {
|
||||
...previousStatus,
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
powerStatus,
|
||||
batteryCapacity: representative.batteryCapacity,
|
||||
batteryRuntime: representative.batteryRuntime,
|
||||
outputLoad: representative.outputLoad,
|
||||
outputPower: representative.outputPower,
|
||||
outputVoltage: representative.outputVoltage,
|
||||
outputCurrent: representative.outputCurrent,
|
||||
lastCheckTime: currentTime,
|
||||
consecutiveFailures: 0,
|
||||
unreachableSince: powerStatus === 'unreachable'
|
||||
? previousStatus.unreachableSince || currentTime
|
||||
: 0,
|
||||
lastStatusChange: previousStatus.lastStatusChange || currentTime,
|
||||
};
|
||||
|
||||
if (previousStatus.powerStatus !== powerStatus) {
|
||||
updatedStatus.lastStatusChange = currentTime;
|
||||
if (powerStatus === 'unreachable') {
|
||||
updatedStatus.unreachableSince = currentTime;
|
||||
}
|
||||
return {
|
||||
updatedStatus,
|
||||
transition: 'powerStatusChange',
|
||||
previousStatus,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
updatedStatus,
|
||||
transition: 'none',
|
||||
previousStatus: currentStatus,
|
||||
};
|
||||
}
|
||||
|
||||
export function evaluateGroupActionThreshold(
|
||||
actionConfig: IActionConfig,
|
||||
mode: 'redundant' | 'nonRedundant',
|
||||
memberStatuses: IUpsStatus[],
|
||||
): IGroupThresholdEvaluation {
|
||||
if (!actionConfig.thresholds || memberStatuses.length === 0) {
|
||||
return {
|
||||
exceedsThreshold: false,
|
||||
blockedByUnreachable: false,
|
||||
};
|
||||
}
|
||||
|
||||
const criticalMembers = memberStatuses.filter((status) =>
|
||||
status.powerStatus === 'onBattery' &&
|
||||
(status.batteryCapacity < actionConfig.thresholds!.battery ||
|
||||
status.batteryRuntime < actionConfig.thresholds!.runtime)
|
||||
);
|
||||
const exceedsThreshold = mode === 'redundant'
|
||||
? criticalMembers.length === memberStatuses.length
|
||||
: criticalMembers.length > 0;
|
||||
|
||||
return {
|
||||
exceedsThreshold,
|
||||
blockedByUnreachable: exceedsThreshold &&
|
||||
destructiveActionTypes.has(actionConfig.type) &&
|
||||
memberStatuses.some((status) => status.powerStatus === 'unreachable'),
|
||||
representativeStatus: selectWorstStatus(criticalMembers),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildGroupThresholdContextStatus(
|
||||
group: IUpsIdentity,
|
||||
evaluations: IGroupThresholdEvaluation[],
|
||||
enteredActionIndexes: number[],
|
||||
fallbackStatus: IUpsStatus,
|
||||
currentTime: number,
|
||||
): IUpsStatus {
|
||||
const representativeStatuses = enteredActionIndexes
|
||||
.map((index) => evaluations[index]?.representativeStatus)
|
||||
.filter((status): status is IUpsStatus => !!status);
|
||||
|
||||
const representative = selectWorstStatus(representativeStatuses) || fallbackStatus;
|
||||
|
||||
return {
|
||||
...fallbackStatus,
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
powerStatus: 'onBattery',
|
||||
batteryCapacity: representative.batteryCapacity,
|
||||
batteryRuntime: representative.batteryRuntime,
|
||||
outputLoad: representative.outputLoad,
|
||||
outputPower: representative.outputPower,
|
||||
outputVoltage: representative.outputVoltage,
|
||||
outputCurrent: representative.outputCurrent,
|
||||
lastCheckTime: currentTime,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './shortid.ts';
|
||||
export * from './prompt.ts';
|
||||
@@ -0,0 +1,55 @@
|
||||
import process from 'node:process';
|
||||
|
||||
/**
|
||||
* Result from creating a prompt interface
|
||||
*/
|
||||
export interface IPromptInterface {
|
||||
/** Function to prompt for user input */
|
||||
prompt: (question: string) => Promise<string>;
|
||||
/** Function to close the prompt interface */
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a readline prompt interface for interactive CLI input
|
||||
* @returns Promise resolving to prompt function and close function
|
||||
*/
|
||||
export async function createPrompt(): Promise<IPromptInterface> {
|
||||
const readline = await import('node:readline');
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
const prompt = (question: string): Promise<string> => {
|
||||
return new Promise((resolve) => {
|
||||
rl.question(question, (answer: string) => {
|
||||
resolve(answer);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const close = (): void => {
|
||||
rl.close();
|
||||
process.stdin.destroy();
|
||||
};
|
||||
|
||||
return { prompt, close };
|
||||
}
|
||||
|
||||
/**
|
||||
* Run an async function with a prompt interface, ensuring cleanup
|
||||
* @param fn Function to run with the prompt interface
|
||||
* @returns Promise resolving to the function's return value
|
||||
*/
|
||||
export async function withPrompt<T>(
|
||||
fn: (prompt: (question: string) => Promise<string>) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const { prompt, close } = await createPrompt();
|
||||
try {
|
||||
return await fn(prompt);
|
||||
} finally {
|
||||
close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Generate a short unique ID of 6 alphanumeric characters
|
||||
* @returns A 6-character alphanumeric string
|
||||
*/
|
||||
export function shortId(): string {
|
||||
// Define the character set: a-z, A-Z, 0-9
|
||||
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
|
||||
// Generate cryptographically secure random values
|
||||
const randomValues = new Uint8Array(6);
|
||||
crypto.getRandomValues(randomValues);
|
||||
|
||||
// Map each random value to a character in our set
|
||||
let result = '';
|
||||
for (let i = 0; i < 6; i++) {
|
||||
// Use modulo to map the random byte to a character index
|
||||
const index = randomValues[i] % chars.length;
|
||||
result += chars[index];
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import * as http from 'node:http';
|
||||
import { URL } from 'node:url';
|
||||
import { logger } from './logger.ts';
|
||||
import type { IPauseState } from './pause-state.ts';
|
||||
import type { IUpsStatus } from './ups-status.ts';
|
||||
|
||||
/**
|
||||
* HTTP Server for exposing UPS status as JSON
|
||||
* Serves cached data from the daemon's monitoring loop
|
||||
*/
|
||||
export class NupstHttpServer {
|
||||
private server?: http.Server;
|
||||
private port: number;
|
||||
private path: string;
|
||||
private authToken: string;
|
||||
private getUpsStatus: () => Map<string, IUpsStatus>;
|
||||
private getPauseState: () => IPauseState | null;
|
||||
|
||||
/**
|
||||
* Create a new HTTP server instance
|
||||
* @param port Port to listen on
|
||||
* @param path URL path for the endpoint
|
||||
* @param authToken Authentication token required for access
|
||||
* @param getUpsStatus Function to retrieve cached UPS status
|
||||
* @param getPauseState Function to retrieve current pause state
|
||||
*/
|
||||
constructor(
|
||||
port: number,
|
||||
path: string,
|
||||
authToken: string,
|
||||
getUpsStatus: () => Map<string, IUpsStatus>,
|
||||
getPauseState: () => IPauseState | null,
|
||||
) {
|
||||
this.port = port;
|
||||
this.path = path;
|
||||
this.authToken = authToken;
|
||||
this.getUpsStatus = getUpsStatus;
|
||||
this.getPauseState = getPauseState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify authentication token from request
|
||||
* Supports both Bearer token in Authorization header and token query parameter
|
||||
* @param req HTTP request
|
||||
* @returns True if authenticated, false otherwise
|
||||
*/
|
||||
private isAuthenticated(req: http.IncomingMessage): boolean {
|
||||
// Check Authorization header (Bearer token)
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader?.startsWith('Bearer ')) {
|
||||
const token = authHeader.substring(7);
|
||||
return token === this.authToken;
|
||||
}
|
||||
|
||||
// Check token query parameter
|
||||
if (req.url) {
|
||||
const url = new URL(req.url, `http://localhost:${this.port}`);
|
||||
const tokenParam = url.searchParams.get('token');
|
||||
return tokenParam === this.authToken;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the HTTP server
|
||||
*/
|
||||
public start(): void {
|
||||
this.server = http.createServer((req, res) => {
|
||||
// Parse URL
|
||||
const reqUrl = new URL(req.url || '/', `http://localhost:${this.port}`);
|
||||
|
||||
if (reqUrl.pathname === this.path && req.method === 'GET') {
|
||||
// Check authentication
|
||||
if (!this.isAuthenticated(req)) {
|
||||
res.writeHead(401, {
|
||||
'Content-Type': 'application/json',
|
||||
'WWW-Authenticate': 'Bearer',
|
||||
});
|
||||
res.end(JSON.stringify({ error: 'Unauthorized' }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Get cached status (no refresh)
|
||||
const statusMap = this.getUpsStatus();
|
||||
const statusArray = Array.from(statusMap.values());
|
||||
const pauseState = this.getPauseState();
|
||||
|
||||
const response = {
|
||||
upsDevices: statusArray,
|
||||
...(pauseState ? { paused: true, pauseState } : { paused: false }),
|
||||
};
|
||||
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-cache',
|
||||
});
|
||||
res.end(JSON.stringify(response, null, 2));
|
||||
} else {
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Not Found' }));
|
||||
}
|
||||
});
|
||||
|
||||
this.server.listen(this.port, () => {
|
||||
logger.success(`HTTP server started on port ${this.port} at ${this.path}`);
|
||||
});
|
||||
|
||||
this.server.on('error', (error: Error) => {
|
||||
logger.error(`HTTP server error: ${error.message}`);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the HTTP server
|
||||
*/
|
||||
public stop(): void {
|
||||
if (this.server) {
|
||||
this.server.close(() => {
|
||||
logger.log('HTTP server stopped');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-4
@@ -1,6 +1,8 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { NupstCli } from './cli.js';
|
||||
import { NupstCli } from './cli.ts';
|
||||
import { logger } from './logger.ts';
|
||||
import process from 'node:process';
|
||||
|
||||
/**
|
||||
* Main entry point for NUPST
|
||||
@@ -8,11 +10,11 @@ import { NupstCli } from './cli.js';
|
||||
*/
|
||||
async function main() {
|
||||
const cli = new NupstCli();
|
||||
await cli.parseAndExecute(process.argv);
|
||||
await cli.parseAndExecute(process.argv.slice(2));
|
||||
}
|
||||
|
||||
// Run the main function and handle any errors
|
||||
main().catch(error => {
|
||||
console.error('Error:', error);
|
||||
main().catch((error) => {
|
||||
logger.error(`Error: ${error}`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from './nupst-accessor.ts';
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { NupstDaemon } from '../daemon.ts';
|
||||
|
||||
/**
|
||||
* Update status information
|
||||
*/
|
||||
export interface IUpdateStatus {
|
||||
currentVersion: string;
|
||||
latestVersion: string;
|
||||
updateAvailable: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for accessing Nupst functionality from SNMP manager
|
||||
* This breaks the circular dependency between Nupst and NupstSnmp
|
||||
*/
|
||||
export interface INupstAccessor {
|
||||
/**
|
||||
* Get the daemon manager for background monitoring
|
||||
*/
|
||||
getDaemon(): NupstDaemon;
|
||||
|
||||
/**
|
||||
* Get the current version of NUPST
|
||||
*/
|
||||
getVersion(): string;
|
||||
|
||||
/**
|
||||
* Check if an update is available
|
||||
*/
|
||||
checkForUpdates(): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Get update status information
|
||||
*/
|
||||
getUpdateStatus(): IUpdateStatus;
|
||||
|
||||
/**
|
||||
* Log the current version and update status
|
||||
*/
|
||||
logVersionInfo(checkForUpdates?: boolean): void;
|
||||
}
|
||||
+334
@@ -0,0 +1,334 @@
|
||||
import { symbols, theme } from './colors.ts';
|
||||
|
||||
/**
|
||||
* Table column alignment options
|
||||
*/
|
||||
export type TColumnAlign = 'left' | 'right' | 'center';
|
||||
|
||||
/**
|
||||
* Table column definition
|
||||
*/
|
||||
export interface ITableColumn {
|
||||
/** Column header text */
|
||||
header: string;
|
||||
/** Column key in data object */
|
||||
key: string;
|
||||
/** Column alignment (default: left) */
|
||||
align?: TColumnAlign;
|
||||
/** Column width (auto-calculated if not specified) */
|
||||
width?: number;
|
||||
/** Color function to apply to cell values */
|
||||
color?: (value: string) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Box style types with colors
|
||||
*/
|
||||
export type TBoxStyle = 'default' | 'success' | 'error' | 'warning' | 'info';
|
||||
|
||||
/**
|
||||
* A simple logger class that provides consistent formatting for log messages
|
||||
* including support for logboxes with title, lines, and closing
|
||||
*/
|
||||
export class Logger {
|
||||
private currentBoxWidth: number | null = null;
|
||||
private currentBoxStyle: TBoxStyle = 'default';
|
||||
private static instance: Logger;
|
||||
|
||||
/** Default width to use when no width is specified */
|
||||
private readonly DEFAULT_WIDTH = 60;
|
||||
|
||||
/**
|
||||
* Creates a new Logger instance
|
||||
*/
|
||||
constructor() {
|
||||
this.currentBoxWidth = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the singleton logger instance
|
||||
* @returns The singleton logger instance
|
||||
*/
|
||||
public static getInstance(): Logger {
|
||||
if (!Logger.instance) {
|
||||
Logger.instance = new Logger();
|
||||
}
|
||||
return Logger.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a message
|
||||
* @param message Message to log
|
||||
*/
|
||||
public log(message: string): void {
|
||||
console.log(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an error message (red with ✗ symbol)
|
||||
* @param message Error message to log
|
||||
*/
|
||||
public error(message: string): void {
|
||||
console.error(`${symbols.error} ${theme.error(message)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a warning message (yellow with ⚠ symbol)
|
||||
* @param message Warning message to log
|
||||
*/
|
||||
public warn(message: string): void {
|
||||
console.warn(`${symbols.warning} ${theme.warning(message)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a success message (green with ✓ symbol)
|
||||
* @param message Success message to log
|
||||
*/
|
||||
public success(message: string): void {
|
||||
console.log(`${symbols.success} ${theme.success(message)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an info message (cyan with ℹ symbol)
|
||||
* @param message Info message to log
|
||||
*/
|
||||
public info(message: string): void {
|
||||
console.log(`${symbols.info} ${theme.info(message)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a dim/secondary message
|
||||
* @param message Message to log in dim style
|
||||
*/
|
||||
public dim(message: string): void {
|
||||
console.log(theme.dim(message));
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a highlighted/bold message
|
||||
* @param message Message to highlight
|
||||
*/
|
||||
public highlight(message: string): void {
|
||||
console.log(theme.highlight(message));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get color function for box based on style
|
||||
*/
|
||||
private getBoxColor(style: TBoxStyle): (text: string) => string {
|
||||
switch (style) {
|
||||
case 'success':
|
||||
return theme.borderSuccess;
|
||||
case 'error':
|
||||
return theme.borderError;
|
||||
case 'warning':
|
||||
return theme.borderWarning;
|
||||
case 'info':
|
||||
return theme.borderInfo;
|
||||
case 'default':
|
||||
default:
|
||||
return theme.borderDefault;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a logbox title and set the current box width
|
||||
* @param title Title of the logbox
|
||||
* @param width Width of the logbox (including borders), defaults to DEFAULT_WIDTH
|
||||
* @param style Box style for coloring (default, success, error, warning, info)
|
||||
*/
|
||||
public logBoxTitle(title: string, width?: number, style?: TBoxStyle): void {
|
||||
this.currentBoxWidth = width || this.DEFAULT_WIDTH;
|
||||
this.currentBoxStyle = style || 'default';
|
||||
|
||||
const colorFn = this.getBoxColor(this.currentBoxStyle);
|
||||
|
||||
// Create the title line with appropriate padding
|
||||
const paddedTitle = ` ${title} `;
|
||||
const remainingSpace = this.currentBoxWidth - 3 - paddedTitle.length;
|
||||
|
||||
// Title line: ┌─ Title ───┐
|
||||
const titleLine = `┌─${paddedTitle}${'─'.repeat(Math.max(0, remainingSpace))}┐`;
|
||||
|
||||
console.log(colorFn(titleLine));
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a logbox line
|
||||
* @param content Content of the line
|
||||
* @param width Optional width override. If not provided, uses the current box width or DEFAULT_WIDTH.
|
||||
*/
|
||||
public logBoxLine(content: string, width?: number): void {
|
||||
if (!this.currentBoxWidth && !width) {
|
||||
// No current width and no width provided, use default width
|
||||
this.logBoxTitle('', this.DEFAULT_WIDTH);
|
||||
}
|
||||
|
||||
const boxWidth = width || this.currentBoxWidth || this.DEFAULT_WIDTH;
|
||||
const colorFn = this.getBoxColor(this.currentBoxStyle);
|
||||
|
||||
// Calculate the available space for content (use visible length)
|
||||
const availableSpace = boxWidth - 2; // Account for left and right borders
|
||||
const visibleLen = this.visibleLength(content);
|
||||
|
||||
if (visibleLen <= availableSpace - 1) {
|
||||
// If content fits with at least one space for the right border stripe
|
||||
const padding = availableSpace - visibleLen - 1;
|
||||
const line = `│ ${content}${' '.repeat(padding)}│`;
|
||||
console.log(colorFn(line));
|
||||
} else {
|
||||
// Content is too long, let it flow out of boundaries.
|
||||
const line = `│ ${content}`;
|
||||
console.log(colorFn(line));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a logbox end
|
||||
* @param width Optional width override. If not provided, uses the current box width or DEFAULT_WIDTH.
|
||||
*/
|
||||
public logBoxEnd(width?: number): void {
|
||||
const boxWidth = width || this.currentBoxWidth || this.DEFAULT_WIDTH;
|
||||
const colorFn = this.getBoxColor(this.currentBoxStyle);
|
||||
|
||||
// Create the bottom border: └────────┘
|
||||
const bottomLine = `└${'─'.repeat(boxWidth - 2)}┘`;
|
||||
console.log(colorFn(bottomLine));
|
||||
|
||||
// Reset the current box width and style
|
||||
this.currentBoxWidth = null;
|
||||
this.currentBoxStyle = 'default';
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a complete logbox with title, content lines, and ending
|
||||
* @param title Title of the logbox
|
||||
* @param lines Array of content lines
|
||||
* @param width Width of the logbox, defaults to DEFAULT_WIDTH
|
||||
* @param style Box style for coloring
|
||||
*/
|
||||
public logBox(title: string, lines: string[], width?: number, style?: TBoxStyle): void {
|
||||
this.logBoxTitle(title, width || this.DEFAULT_WIDTH, style);
|
||||
|
||||
for (const line of lines) {
|
||||
this.logBoxLine(line);
|
||||
}
|
||||
|
||||
this.logBoxEnd();
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a divider line
|
||||
* @param width Width of the divider, defaults to DEFAULT_WIDTH
|
||||
* @param character Character to use for the divider (default: ─)
|
||||
*/
|
||||
public logDivider(width?: number, character: string = '─'): void {
|
||||
console.log(character.repeat(width || this.DEFAULT_WIDTH));
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip ANSI color codes from string for accurate length calculation
|
||||
*/
|
||||
private stripAnsi(text: string): string {
|
||||
// Remove ANSI escape codes (intentional control character regex)
|
||||
// deno-lint-ignore no-control-regex
|
||||
return text.replace(/\x1b\[[0-9;]*m/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get visible length of string (excluding ANSI codes)
|
||||
*/
|
||||
private visibleLength(text: string): number {
|
||||
return this.stripAnsi(text).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Align text within a column (handles ANSI color codes correctly)
|
||||
*/
|
||||
private alignText(text: string, width: number, align: TColumnAlign = 'left'): string {
|
||||
const visibleLen = this.visibleLength(text);
|
||||
|
||||
if (visibleLen >= width) {
|
||||
// Text is too long, truncate the visible part
|
||||
const stripped = this.stripAnsi(text);
|
||||
return stripped.substring(0, width);
|
||||
}
|
||||
|
||||
const padding = width - visibleLen;
|
||||
|
||||
switch (align) {
|
||||
case 'right':
|
||||
return ' '.repeat(padding) + text;
|
||||
case 'center': {
|
||||
const leftPad = Math.floor(padding / 2);
|
||||
const rightPad = padding - leftPad;
|
||||
return ' '.repeat(leftPad) + text + ' '.repeat(rightPad);
|
||||
}
|
||||
case 'left':
|
||||
default:
|
||||
return text + ' '.repeat(padding);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a formatted table
|
||||
* @param columns Column definitions
|
||||
* @param rows Array of data objects
|
||||
* @param title Optional table title
|
||||
*/
|
||||
public logTable(columns: ITableColumn[], rows: Record<string, string>[], title?: string): void {
|
||||
if (rows.length === 0) {
|
||||
this.dim('No data to display');
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate column widths
|
||||
const columnWidths = columns.map((col) => {
|
||||
if (col.width) return col.width;
|
||||
|
||||
// Auto-calculate width based on header and data (use visible length)
|
||||
let maxWidth = this.visibleLength(col.header);
|
||||
for (const row of rows) {
|
||||
const value = String(row[col.key] || '');
|
||||
maxWidth = Math.max(maxWidth, this.visibleLength(value));
|
||||
}
|
||||
return maxWidth;
|
||||
});
|
||||
|
||||
// Calculate total table width
|
||||
const totalWidth = columnWidths.reduce((sum, w) => sum + w, 0) + (columns.length * 3) + 1;
|
||||
|
||||
// Print title if provided
|
||||
if (title) {
|
||||
this.logBoxTitle(title, totalWidth);
|
||||
} else {
|
||||
// Print top border
|
||||
console.log('┌' + columnWidths.map((w) => '─'.repeat(w + 2)).join('┬') + '┐');
|
||||
}
|
||||
|
||||
// Print header row
|
||||
const headerCells = columns.map((col, i) =>
|
||||
theme.highlight(this.alignText(col.header, columnWidths[i], col.align))
|
||||
);
|
||||
console.log('│ ' + headerCells.join(' │ ') + ' │');
|
||||
|
||||
// Print separator
|
||||
console.log('├' + columnWidths.map((w) => '─'.repeat(w + 2)).join('┼') + '┤');
|
||||
|
||||
// Print data rows
|
||||
for (const row of rows) {
|
||||
const cells = columns.map((col, i) => {
|
||||
const value = String(row[col.key] || '');
|
||||
const aligned = this.alignText(value, columnWidths[i], col.align);
|
||||
return col.color ? col.color(aligned) : aligned;
|
||||
});
|
||||
console.log('│ ' + cells.join(' │ ') + ' │');
|
||||
}
|
||||
|
||||
// Print bottom border
|
||||
console.log('└' + columnWidths.map((w) => '─'.repeat(w + 2)).join('┴') + '┘');
|
||||
}
|
||||
}
|
||||
|
||||
// Export a singleton instance for easy use
|
||||
export const logger = Logger.getInstance();
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Abstract base class for configuration migrations
|
||||
*
|
||||
* Each migration represents an upgrade from one config version to another.
|
||||
* Migrations run in order based on the `order` field, allowing users to jump
|
||||
* multiple versions (e.g., v1 → v4 runs migrations 2, 3, and 4).
|
||||
*/
|
||||
/**
|
||||
* Abstract base class for configuration migrations
|
||||
*
|
||||
* Each migration represents an upgrade from one config version to another.
|
||||
* Migrations run in order based on the `toVersion` field, allowing users to jump
|
||||
* multiple versions (e.g., v1 → v4 runs migrations 2, 3, and 4).
|
||||
*/
|
||||
export abstract class BaseMigration {
|
||||
/**
|
||||
* Source version this migration upgrades from
|
||||
* e.g., "1.x", "3.x"
|
||||
*/
|
||||
abstract readonly fromVersion: string;
|
||||
|
||||
/**
|
||||
* Target version this migration upgrades to
|
||||
* e.g., "2.0", "4.0", "4.1"
|
||||
*/
|
||||
abstract readonly toVersion: string;
|
||||
|
||||
/**
|
||||
* Check if this migration should run on the given config
|
||||
*
|
||||
* @param config - Raw configuration object to check (unknown schema for migrations)
|
||||
* @returns True if migration should run, false otherwise
|
||||
*/
|
||||
abstract shouldRun(
|
||||
config: Record<string, unknown>,
|
||||
): boolean | Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Perform the migration on the given config
|
||||
*
|
||||
* @param config - Raw configuration object to migrate (unknown schema for migrations)
|
||||
* @returns Migrated configuration object
|
||||
*/
|
||||
abstract migrate(
|
||||
config: Record<string, unknown>,
|
||||
): Record<string, unknown> | Promise<Record<string, unknown>>;
|
||||
|
||||
/**
|
||||
* Get human-readable name for this migration
|
||||
*
|
||||
* @returns Migration name
|
||||
*/
|
||||
getName(): string {
|
||||
return `Migration ${this.fromVersion} → ${this.toVersion}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse version string into a comparable number
|
||||
* Supports formats like "2.0", "4.1", etc.
|
||||
* Returns a number like 2.0, 4.1 for sorting
|
||||
*
|
||||
* @returns Parsed version number for ordering
|
||||
*/
|
||||
getVersionOrder(): number {
|
||||
const parsed = parseFloat(this.toVersion);
|
||||
if (isNaN(parsed)) {
|
||||
throw new Error(`Invalid version format: ${this.toVersion}`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Configuration migrations module
|
||||
*
|
||||
* Exports the migration system for upgrading configs between versions.
|
||||
*/
|
||||
|
||||
export { BaseMigration } from './base-migration.ts';
|
||||
export { MigrationRunner } from './migration-runner.ts';
|
||||
export { MigrationV1ToV2 } from './migration-v1-to-v2.ts';
|
||||
export { MigrationV3ToV4 } from './migration-v3-to-v4.ts';
|
||||
export { MigrationV4_0ToV4_1 } from './migration-v4.0-to-v4.1.ts';
|
||||
export { MigrationV4_1ToV4_2 } from './migration-v4.1-to-v4.2.ts';
|
||||
export { MigrationV4_2ToV4_3 } from './migration-v4.2-to-v4.3.ts';
|
||||
export { MigrationV4_3ToV4_4 } from './migration-v4.3-to-v4.4.ts';
|
||||
@@ -0,0 +1,80 @@
|
||||
import { BaseMigration } from './base-migration.ts';
|
||||
import { MigrationV1ToV2 } from './migration-v1-to-v2.ts';
|
||||
import { MigrationV3ToV4 } from './migration-v3-to-v4.ts';
|
||||
import { MigrationV4_0ToV4_1 } from './migration-v4.0-to-v4.1.ts';
|
||||
import { MigrationV4_1ToV4_2 } from './migration-v4.1-to-v4.2.ts';
|
||||
import { MigrationV4_2ToV4_3 } from './migration-v4.2-to-v4.3.ts';
|
||||
import { MigrationV4_3ToV4_4 } from './migration-v4.3-to-v4.4.ts';
|
||||
import { logger } from '../logger.ts';
|
||||
|
||||
/**
|
||||
* Migration runner
|
||||
*
|
||||
* Discovers all available migrations, sorts them by order,
|
||||
* and runs applicable migrations in sequence.
|
||||
*/
|
||||
export class MigrationRunner {
|
||||
private migrations: BaseMigration[];
|
||||
|
||||
constructor() {
|
||||
// Register all migrations here
|
||||
this.migrations = [
|
||||
new MigrationV1ToV2(),
|
||||
new MigrationV3ToV4(),
|
||||
new MigrationV4_0ToV4_1(),
|
||||
new MigrationV4_1ToV4_2(),
|
||||
new MigrationV4_2ToV4_3(),
|
||||
new MigrationV4_3ToV4_4(),
|
||||
];
|
||||
|
||||
// Sort by version order to ensure they run in sequence
|
||||
this.migrations.sort((a, b) => a.getVersionOrder() - b.getVersionOrder());
|
||||
}
|
||||
|
||||
/**
|
||||
* Run all applicable migrations on the config
|
||||
*
|
||||
* @param config - Raw configuration object to migrate
|
||||
* @returns Migrated configuration and whether migrations ran
|
||||
*/
|
||||
async run(
|
||||
config: Record<string, unknown>,
|
||||
): Promise<{ config: Record<string, unknown>; migrated: boolean }> {
|
||||
let currentConfig = config;
|
||||
let anyMigrationsRan = false;
|
||||
|
||||
for (const migration of this.migrations) {
|
||||
const shouldRun = await migration.shouldRun(currentConfig);
|
||||
|
||||
if (shouldRun) {
|
||||
// Only show "checking" message when we actually need to migrate
|
||||
if (!anyMigrationsRan) {
|
||||
logger.dim('Checking for required config migrations...');
|
||||
}
|
||||
logger.info(`Running ${migration.getName()}...`);
|
||||
currentConfig = await migration.migrate(currentConfig);
|
||||
anyMigrationsRan = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (anyMigrationsRan) {
|
||||
logger.success('Configuration migrations complete');
|
||||
} else {
|
||||
logger.success('Configuration format OK');
|
||||
}
|
||||
|
||||
return {
|
||||
config: currentConfig,
|
||||
migrated: anyMigrationsRan,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered migrations
|
||||
*
|
||||
* @returns Array of all migrations sorted by order
|
||||
*/
|
||||
getMigrations(): BaseMigration[] {
|
||||
return [...this.migrations];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { BaseMigration } from './base-migration.ts';
|
||||
import { logger } from '../logger.ts';
|
||||
|
||||
/**
|
||||
* Migration from v1 (single SNMP config) to v2 (upsDevices array)
|
||||
*
|
||||
* Detects old format:
|
||||
* {
|
||||
* snmp: { ... },
|
||||
* thresholds: { ... },
|
||||
* checkInterval: 30000
|
||||
* }
|
||||
*
|
||||
* Converts to:
|
||||
* {
|
||||
* version: "2.0",
|
||||
* upsDevices: [{ id: "default", name: "Default UPS", snmp: ..., thresholds: ... }],
|
||||
* groups: [],
|
||||
* checkInterval: 30000
|
||||
* }
|
||||
*/
|
||||
export class MigrationV1ToV2 extends BaseMigration {
|
||||
readonly fromVersion = '1.x';
|
||||
readonly toVersion = '2.0';
|
||||
|
||||
shouldRun(config: Record<string, unknown>): boolean {
|
||||
// V1 format has snmp field directly at root, no upsDevices or upsList
|
||||
return !!config.snmp && !config.upsDevices && !config.upsList;
|
||||
}
|
||||
|
||||
migrate(config: Record<string, unknown>): Record<string, unknown> {
|
||||
logger.info(`${this.getName()}: Converting single SNMP config to multi-UPS format...`);
|
||||
|
||||
const migrated = {
|
||||
version: this.toVersion,
|
||||
upsDevices: [
|
||||
{
|
||||
id: 'default',
|
||||
name: 'Default UPS',
|
||||
snmp: config.snmp,
|
||||
thresholds: config.thresholds || {
|
||||
battery: 60,
|
||||
runtime: 20,
|
||||
},
|
||||
groups: [],
|
||||
},
|
||||
],
|
||||
groups: [],
|
||||
checkInterval: config.checkInterval || 30000,
|
||||
};
|
||||
|
||||
logger.success(`${this.getName()}: Migration complete`);
|
||||
return migrated;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { BaseMigration } from './base-migration.ts';
|
||||
import { logger } from '../logger.ts';
|
||||
|
||||
/**
|
||||
* Migration from v3 (upsList) to v4 (upsDevices)
|
||||
*
|
||||
* Transforms v3 format with flat SNMP config:
|
||||
* {
|
||||
* upsList: [
|
||||
* {
|
||||
* id: "ups-1",
|
||||
* name: "UPS 1",
|
||||
* host: "192.168.1.1",
|
||||
* port: 161,
|
||||
* community: "public",
|
||||
* version: "1" // string
|
||||
* }
|
||||
* ]
|
||||
* }
|
||||
*
|
||||
* To v4 format with nested SNMP config:
|
||||
* {
|
||||
* version: "4.0",
|
||||
* upsDevices: [
|
||||
* {
|
||||
* id: "ups-1",
|
||||
* name: "UPS 1",
|
||||
* snmp: {
|
||||
* host: "192.168.1.1",
|
||||
* port: 161,
|
||||
* community: "public",
|
||||
* version: 1, // number
|
||||
* timeout: 5000
|
||||
* },
|
||||
* thresholds: { battery: 60, runtime: 20 },
|
||||
* groups: []
|
||||
* }
|
||||
* ]
|
||||
* }
|
||||
*/
|
||||
export class MigrationV3ToV4 extends BaseMigration {
|
||||
readonly fromVersion = '3.x';
|
||||
readonly toVersion = '4.0';
|
||||
|
||||
shouldRun(config: Record<string, unknown>): boolean {
|
||||
// V3 format has upsList OR has upsDevices with flat structure (host at top level)
|
||||
if (config.upsList && !config.upsDevices) {
|
||||
return true; // Classic v3 with upsList
|
||||
}
|
||||
|
||||
// Check if upsDevices exists but has flat structure (v3 format)
|
||||
const upsDevices = config.upsDevices as Array<Record<string, unknown>> | undefined;
|
||||
if (upsDevices && upsDevices.length > 0) {
|
||||
const firstDevice = upsDevices[0];
|
||||
// V3 has host at top level, v4 has it nested in snmp object
|
||||
return !!firstDevice.host && !firstDevice.snmp;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
migrate(config: Record<string, unknown>): Record<string, unknown> {
|
||||
logger.info(`${this.getName()}: Migrating v3 config to v4 format...`);
|
||||
logger.dim(` - Restructuring UPS devices (flat → nested snmp config)`);
|
||||
|
||||
// Get devices from either upsList or upsDevices (for partially migrated configs)
|
||||
const sourceDevices = (config.upsList || config.upsDevices) as Array<Record<string, unknown>>;
|
||||
|
||||
// Transform each UPS device from v3 flat structure to v4 nested structure
|
||||
const transformedDevices = sourceDevices.map((device: Record<string, unknown>) => {
|
||||
// Build SNMP config object
|
||||
const snmpConfig: Record<string, unknown> = {
|
||||
host: device.host,
|
||||
port: device.port || 161,
|
||||
version: typeof device.version === 'string' ? parseInt(device.version, 10) : device.version,
|
||||
timeout: device.timeout || 5000,
|
||||
};
|
||||
|
||||
// Add SNMPv1/v2c fields
|
||||
if (device.community) {
|
||||
snmpConfig.community = device.community;
|
||||
}
|
||||
|
||||
// Add SNMPv3 fields
|
||||
if (device.securityLevel) snmpConfig.securityLevel = device.securityLevel;
|
||||
if (device.username) snmpConfig.username = device.username;
|
||||
if (device.authProtocol) snmpConfig.authProtocol = device.authProtocol;
|
||||
if (device.authKey) snmpConfig.authKey = device.authKey;
|
||||
if (device.privProtocol) snmpConfig.privProtocol = device.privProtocol;
|
||||
if (device.privKey) snmpConfig.privKey = device.privKey;
|
||||
|
||||
// Add UPS model if present
|
||||
if (device.upsModel) snmpConfig.upsModel = device.upsModel;
|
||||
if (device.customOIDs) snmpConfig.customOIDs = device.customOIDs;
|
||||
|
||||
// Return v4 format with nested structure
|
||||
return {
|
||||
id: device.id,
|
||||
name: device.name,
|
||||
snmp: snmpConfig,
|
||||
thresholds: device.thresholds || {
|
||||
battery: 60,
|
||||
runtime: 20,
|
||||
},
|
||||
groups: device.groups || [],
|
||||
};
|
||||
});
|
||||
|
||||
const migrated = {
|
||||
version: this.toVersion,
|
||||
upsDevices: transformedDevices,
|
||||
groups: config.groups || [],
|
||||
checkInterval: config.checkInterval || 30000,
|
||||
};
|
||||
|
||||
logger.success(
|
||||
`${this.getName()}: Migration complete (${transformedDevices.length} devices transformed)`,
|
||||
);
|
||||
return migrated;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { BaseMigration } from './base-migration.ts';
|
||||
import { logger } from '../logger.ts';
|
||||
|
||||
/**
|
||||
* Migration from v4.0 to v4.1
|
||||
*
|
||||
* Major changes:
|
||||
* 1. Moves thresholds from UPS level to action level
|
||||
* 2. Creates default shutdown action for UPS devices that had thresholds
|
||||
* 3. Adds empty actions array to UPS devices without actions
|
||||
* 4. Adds empty actions array to groups
|
||||
*
|
||||
* Transforms v4.0 format (with UPS-level thresholds):
|
||||
* {
|
||||
* version: "4.0",
|
||||
* upsDevices: [
|
||||
* {
|
||||
* id: "ups-1",
|
||||
* name: "UPS 1",
|
||||
* snmp: {...},
|
||||
* thresholds: { battery: 60, runtime: 20 }, // UPS-level
|
||||
* groups: []
|
||||
* }
|
||||
* ]
|
||||
* }
|
||||
*
|
||||
* To v4.1 format (with action-level thresholds):
|
||||
* {
|
||||
* version: "4.1",
|
||||
* upsDevices: [
|
||||
* {
|
||||
* id: "ups-1",
|
||||
* name: "UPS 1",
|
||||
* snmp: {...},
|
||||
* groups: [],
|
||||
* actions: [ // Thresholds moved here
|
||||
* {
|
||||
* type: "shutdown",
|
||||
* thresholds: { battery: 60, runtime: 20 },
|
||||
* triggerMode: "onlyThresholds"
|
||||
* }
|
||||
* ]
|
||||
* }
|
||||
* ]
|
||||
* }
|
||||
*/
|
||||
export class MigrationV4_0ToV4_1 extends BaseMigration {
|
||||
readonly fromVersion = '4.0';
|
||||
readonly toVersion = '4.1';
|
||||
|
||||
shouldRun(config: Record<string, unknown>): boolean {
|
||||
// Run if config is version 4.0
|
||||
if (config.version === '4.0') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Also run if config has upsDevices with thresholds at UPS level (v4.0 format)
|
||||
if (Array.isArray(config.upsDevices) && config.upsDevices.length > 0) {
|
||||
const firstDevice = config.upsDevices[0] as Record<string, unknown>;
|
||||
// v4.0 has thresholds at UPS level, v4.1 has them in actions
|
||||
return firstDevice.thresholds !== undefined;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
migrate(config: Record<string, unknown>): Record<string, unknown> {
|
||||
logger.info(`${this.getName()}: Migrating v4.0 config to v4.1 format...`);
|
||||
logger.dim(` - Moving thresholds from UPS level to action level`);
|
||||
logger.dim(` - Creating default shutdown actions from existing thresholds`);
|
||||
|
||||
// Migrate UPS devices
|
||||
const devices = (config.upsDevices as Array<Record<string, unknown>>) || [];
|
||||
const migratedDevices = devices.map((device) => {
|
||||
const migrated: Record<string, unknown> = {
|
||||
id: device.id,
|
||||
name: device.name,
|
||||
snmp: device.snmp,
|
||||
groups: device.groups || [],
|
||||
};
|
||||
|
||||
// If device has thresholds at UPS level, convert to shutdown action
|
||||
const deviceThresholds = device.thresholds as
|
||||
| { battery: number; runtime: number }
|
||||
| undefined;
|
||||
if (deviceThresholds) {
|
||||
migrated.actions = [
|
||||
{
|
||||
type: 'shutdown',
|
||||
thresholds: {
|
||||
battery: deviceThresholds.battery,
|
||||
runtime: deviceThresholds.runtime,
|
||||
},
|
||||
triggerMode: 'onlyThresholds', // Preserve old behavior (only on threshold violation)
|
||||
},
|
||||
];
|
||||
logger.dim(
|
||||
` → ${device.name}: Created shutdown action (battery: ${deviceThresholds.battery}%, runtime: ${deviceThresholds.runtime}min)`,
|
||||
);
|
||||
} else {
|
||||
// No thresholds, just add empty actions array
|
||||
migrated.actions = device.actions || [];
|
||||
}
|
||||
|
||||
return migrated;
|
||||
});
|
||||
|
||||
// Add actions to groups
|
||||
const groups = (config.groups as Array<Record<string, unknown>>) || [];
|
||||
const migratedGroups = groups.map((group) => ({
|
||||
...group,
|
||||
actions: group.actions || [],
|
||||
}));
|
||||
|
||||
const result = {
|
||||
version: this.toVersion,
|
||||
upsDevices: migratedDevices,
|
||||
groups: migratedGroups,
|
||||
checkInterval: config.checkInterval || 30000,
|
||||
};
|
||||
|
||||
logger.success(
|
||||
`${this.getName()}: Migration complete (${migratedDevices.length} devices, ${migratedGroups.length} groups updated)`,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { BaseMigration } from './base-migration.ts';
|
||||
import { logger } from '../logger.ts';
|
||||
|
||||
/**
|
||||
* Migration from v4.1 to v4.2
|
||||
*
|
||||
* Changes:
|
||||
* 1. Adds `protocol: 'snmp'` to all existing UPS devices (explicit default)
|
||||
* 2. Bumps version from '4.1' to '4.2'
|
||||
*/
|
||||
export class MigrationV4_1ToV4_2 extends BaseMigration {
|
||||
readonly fromVersion = '4.1';
|
||||
readonly toVersion = '4.2';
|
||||
|
||||
shouldRun(config: Record<string, unknown>): boolean {
|
||||
return config.version === '4.1';
|
||||
}
|
||||
|
||||
migrate(config: Record<string, unknown>): Record<string, unknown> {
|
||||
logger.info(`${this.getName()}: Adding protocol field to UPS devices...`);
|
||||
|
||||
const devices = (config.upsDevices as Array<Record<string, unknown>>) || [];
|
||||
const migratedDevices = devices.map((device) => {
|
||||
// Add protocol: 'snmp' if not already present
|
||||
if (!device.protocol) {
|
||||
device.protocol = 'snmp';
|
||||
logger.dim(` → ${device.name}: Set protocol to 'snmp'`);
|
||||
}
|
||||
return device;
|
||||
});
|
||||
|
||||
const result = {
|
||||
...config,
|
||||
version: this.toVersion,
|
||||
upsDevices: migratedDevices,
|
||||
};
|
||||
|
||||
logger.success(
|
||||
`${this.getName()}: Migration complete (${migratedDevices.length} devices updated)`,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { BaseMigration } from './base-migration.ts';
|
||||
import { logger } from '../logger.ts';
|
||||
import { getDefaultRuntimeUnitForUpsModel } from '../snmp/runtime-units.ts';
|
||||
|
||||
/**
|
||||
* Migration from v4.2 to v4.3
|
||||
*
|
||||
* Changes:
|
||||
* 1. Adds `runtimeUnit` to SNMP configs based on existing `upsModel`
|
||||
* 2. Bumps version from '4.2' to '4.3'
|
||||
*/
|
||||
export class MigrationV4_2ToV4_3 extends BaseMigration {
|
||||
readonly fromVersion = '4.2';
|
||||
readonly toVersion = '4.3';
|
||||
|
||||
shouldRun(config: Record<string, unknown>): boolean {
|
||||
return config.version === '4.2';
|
||||
}
|
||||
|
||||
migrate(config: Record<string, unknown>): Record<string, unknown> {
|
||||
logger.info(`${this.getName()}: Adding runtimeUnit to SNMP configs...`);
|
||||
|
||||
const devices = (config.upsDevices as Array<Record<string, unknown>>) || [];
|
||||
const migratedDevices = devices.map((device) => {
|
||||
const snmp = device.snmp as Record<string, unknown> | undefined;
|
||||
if (snmp && !snmp.runtimeUnit) {
|
||||
const model = snmp.upsModel as
|
||||
| 'cyberpower'
|
||||
| 'apc'
|
||||
| 'eaton'
|
||||
| 'tripplite'
|
||||
| 'liebert'
|
||||
| 'custom'
|
||||
| undefined;
|
||||
snmp.runtimeUnit = getDefaultRuntimeUnitForUpsModel(model);
|
||||
logger.dim(` → ${device.name}: Set runtimeUnit to '${snmp.runtimeUnit}'`);
|
||||
}
|
||||
return device;
|
||||
});
|
||||
|
||||
const result = {
|
||||
...config,
|
||||
version: this.toVersion,
|
||||
upsDevices: migratedDevices,
|
||||
};
|
||||
|
||||
logger.success(
|
||||
`${this.getName()}: Migration complete (${migratedDevices.length} devices updated)`,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { BaseMigration } from './base-migration.ts';
|
||||
import { logger } from '../logger.ts';
|
||||
|
||||
/**
|
||||
* Migration from v4.3 to v4.4
|
||||
*
|
||||
* Changes:
|
||||
* 1. Corrects APC runtimeUnit defaults from minutes to ticks
|
||||
* 2. Bumps version from '4.3' to '4.4'
|
||||
*/
|
||||
export class MigrationV4_3ToV4_4 extends BaseMigration {
|
||||
readonly fromVersion = '4.3';
|
||||
readonly toVersion = '4.4';
|
||||
|
||||
shouldRun(config: Record<string, unknown>): boolean {
|
||||
return config.version === '4.3';
|
||||
}
|
||||
|
||||
migrate(config: Record<string, unknown>): Record<string, unknown> {
|
||||
logger.info(`${this.getName()}: Correcting APC runtimeUnit defaults...`);
|
||||
|
||||
let correctedDevices = 0;
|
||||
const devices = (config.upsDevices as Array<Record<string, unknown>>) || [];
|
||||
const migratedDevices = devices.map((device) => {
|
||||
const snmp = device.snmp as Record<string, unknown> | undefined;
|
||||
if (!snmp || snmp.upsModel !== 'apc') {
|
||||
return device;
|
||||
}
|
||||
|
||||
if (!snmp.runtimeUnit || snmp.runtimeUnit === 'minutes') {
|
||||
snmp.runtimeUnit = 'ticks';
|
||||
correctedDevices += 1;
|
||||
logger.dim(` → ${device.name}: Set runtimeUnit to 'ticks'`);
|
||||
}
|
||||
|
||||
return device;
|
||||
});
|
||||
|
||||
const result = {
|
||||
...config,
|
||||
version: this.toVersion,
|
||||
upsDevices: migratedDevices,
|
||||
};
|
||||
|
||||
logger.success(
|
||||
`${this.getName()}: Migration complete (${correctedDevices} APC device(s) corrected)`,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
+127
-56
@@ -1,18 +1,31 @@
|
||||
import { NupstSnmp } from './snmp.js';
|
||||
import { NupstDaemon } from './daemon.js';
|
||||
import { NupstSystemd } from './systemd.js';
|
||||
import { commitinfo } from './00_commitinfo_data.js';
|
||||
import { spawn } from 'child_process';
|
||||
import * as https from 'https';
|
||||
import { NupstSnmp } from './snmp/manager.ts';
|
||||
import { NupstUpsd } from './upsd/client.ts';
|
||||
import { NupstDaemon } from './daemon.ts';
|
||||
import { NupstSystemd } from './systemd.ts';
|
||||
import denoConfig from '../deno.json' with { type: 'json' };
|
||||
import { logger } from './logger.ts';
|
||||
import { UpsHandler } from './cli/ups-handler.ts';
|
||||
import { GroupHandler } from './cli/group-handler.ts';
|
||||
import { ServiceHandler } from './cli/service-handler.ts';
|
||||
import { ActionHandler } from './cli/action-handler.ts';
|
||||
import { FeatureHandler } from './cli/feature-handler.ts';
|
||||
import * as https from 'node:https';
|
||||
import type { INupstAccessor, IUpdateStatus } from './interfaces/index.ts';
|
||||
|
||||
/**
|
||||
* Main Nupst class that coordinates all components
|
||||
* Acts as a facade to access SNMP, Daemon, and Systemd functionality
|
||||
*/
|
||||
export class Nupst {
|
||||
export class Nupst implements INupstAccessor {
|
||||
private readonly snmp: NupstSnmp;
|
||||
private readonly upsd: NupstUpsd;
|
||||
private readonly daemon: NupstDaemon;
|
||||
private readonly systemd: NupstSystemd;
|
||||
private readonly upsHandler: UpsHandler;
|
||||
private readonly groupHandler: GroupHandler;
|
||||
private readonly serviceHandler: ServiceHandler;
|
||||
private readonly actionHandler: ActionHandler;
|
||||
private readonly featureHandler: FeatureHandler;
|
||||
private updateAvailable: boolean = false;
|
||||
private latestVersion: string = '';
|
||||
|
||||
@@ -20,10 +33,19 @@ export class Nupst {
|
||||
* Create a new Nupst instance with all necessary components
|
||||
*/
|
||||
constructor() {
|
||||
// Initialize core components
|
||||
this.snmp = new NupstSnmp();
|
||||
this.snmp.setNupst(this); // Set up bidirectional reference
|
||||
this.daemon = new NupstDaemon(this.snmp);
|
||||
this.upsd = new NupstUpsd();
|
||||
this.daemon = new NupstDaemon(this.snmp, this.upsd);
|
||||
this.systemd = new NupstSystemd(this.daemon);
|
||||
|
||||
// Initialize handlers
|
||||
this.upsHandler = new UpsHandler(this);
|
||||
this.groupHandler = new GroupHandler(this);
|
||||
this.serviceHandler = new ServiceHandler(this);
|
||||
this.actionHandler = new ActionHandler(this);
|
||||
this.featureHandler = new FeatureHandler(this);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -33,6 +55,13 @@ export class Nupst {
|
||||
return this.snmp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the UPSD manager for NUT protocol communication
|
||||
*/
|
||||
public getUpsd(): NupstUpsd {
|
||||
return this.upsd;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the daemon manager for background monitoring
|
||||
*/
|
||||
@@ -46,15 +75,50 @@ export class Nupst {
|
||||
public getSystemd(): NupstSystemd {
|
||||
return this.systemd;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get the UPS handler for UPS management
|
||||
*/
|
||||
public getUpsHandler(): UpsHandler {
|
||||
return this.upsHandler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Group handler for group management
|
||||
*/
|
||||
public getGroupHandler(): GroupHandler {
|
||||
return this.groupHandler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Service handler for service management
|
||||
*/
|
||||
public getServiceHandler(): ServiceHandler {
|
||||
return this.serviceHandler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Action handler for action management
|
||||
*/
|
||||
public getActionHandler(): ActionHandler {
|
||||
return this.actionHandler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Feature handler for feature management
|
||||
*/
|
||||
public getFeatureHandler(): FeatureHandler {
|
||||
return this.featureHandler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current version of NUPST
|
||||
* @returns The current version string
|
||||
*/
|
||||
public getVersion(): string {
|
||||
return commitinfo.version;
|
||||
return denoConfig.version;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Check if an update is available
|
||||
* @returns Promise resolving to true if an update is available
|
||||
@@ -63,79 +127,81 @@ export class Nupst {
|
||||
try {
|
||||
const latestVersion = await this.getLatestVersion();
|
||||
const currentVersion = this.getVersion();
|
||||
|
||||
|
||||
// Compare versions
|
||||
this.updateAvailable = this.compareVersions(latestVersion, currentVersion) > 0;
|
||||
this.latestVersion = latestVersion;
|
||||
|
||||
|
||||
return this.updateAvailable;
|
||||
} catch (error) {
|
||||
console.error(`Error checking for updates: ${error.message}`);
|
||||
logger.error(
|
||||
`Error checking for updates: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get update status information
|
||||
* @returns Object with update status information
|
||||
*/
|
||||
public getUpdateStatus(): {
|
||||
currentVersion: string,
|
||||
latestVersion: string,
|
||||
updateAvailable: boolean
|
||||
} {
|
||||
public getUpdateStatus(): IUpdateStatus {
|
||||
return {
|
||||
currentVersion: this.getVersion(),
|
||||
latestVersion: this.latestVersion || this.getVersion(),
|
||||
updateAvailable: this.updateAvailable
|
||||
updateAvailable: this.updateAvailable,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get the latest version from npm registry
|
||||
* @returns Promise resolving to the latest version string
|
||||
*/
|
||||
private async getLatestVersion(): Promise<string> {
|
||||
private getLatestVersion(): Promise<string> {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const options = {
|
||||
hostname: 'registry.npmjs.org',
|
||||
path: '/@serve.zone/nupst',
|
||||
hostname: 'code.foss.global',
|
||||
path: '/api/v1/repos/serve.zone/nupst/releases/latest',
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'User-Agent': `nupst/${this.getVersion()}`
|
||||
}
|
||||
'User-Agent': `nupst/${this.getVersion()}`,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
const req = https.request(options, (res) => {
|
||||
let data = '';
|
||||
|
||||
|
||||
res.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
|
||||
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const response = JSON.parse(data);
|
||||
if (response['dist-tags'] && response['dist-tags'].latest) {
|
||||
resolve(response['dist-tags'].latest);
|
||||
if (response.tag_name) {
|
||||
// Strip 'v' prefix from tag name (e.g., "v5.1.7" -> "5.1.7")
|
||||
const version = response.tag_name.startsWith('v')
|
||||
? response.tag_name.substring(1)
|
||||
: response.tag_name;
|
||||
resolve(version);
|
||||
} else {
|
||||
reject(new Error('Failed to parse version from npm registry response'));
|
||||
reject(new Error('Failed to parse version from Gitea API response'));
|
||||
}
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
req.on('error', (error) => {
|
||||
reject(error);
|
||||
});
|
||||
|
||||
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Compare two semantic version strings
|
||||
* @param versionA First version
|
||||
@@ -143,47 +209,52 @@ export class Nupst {
|
||||
* @returns -1 if versionA < versionB, 0 if equal, 1 if versionA > versionB
|
||||
*/
|
||||
private compareVersions(versionA: string, versionB: string): number {
|
||||
const partsA = versionA.split('.').map(part => parseInt(part, 10));
|
||||
const partsB = versionB.split('.').map(part => parseInt(part, 10));
|
||||
|
||||
const partsA = versionA.split('.').map((part) => parseInt(part, 10));
|
||||
const partsB = versionB.split('.').map((part) => parseInt(part, 10));
|
||||
|
||||
for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) {
|
||||
const partA = i < partsA.length ? partsA[i] : 0;
|
||||
const partB = i < partsB.length ? partsB[i] : 0;
|
||||
|
||||
|
||||
if (partA > partB) return 1;
|
||||
if (partA < partB) return -1;
|
||||
}
|
||||
|
||||
|
||||
return 0; // Versions are equal
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Log the current version and update status
|
||||
*/
|
||||
public logVersionInfo(checkForUpdates: boolean = true): void {
|
||||
const version = this.getVersion();
|
||||
console.log('┌─ NUPST Version ────────────────────────┐');
|
||||
console.log(`│ Current Version: ${version}`);
|
||||
|
||||
const boxWidth = 45;
|
||||
|
||||
logger.logBoxTitle('NUPST Version', boxWidth);
|
||||
logger.logBoxLine(`Current Version: ${version}`);
|
||||
|
||||
if (this.updateAvailable && this.latestVersion) {
|
||||
console.log(`│ Update Available: ${this.latestVersion}`);
|
||||
console.log('│ Run "sudo nupst update" to update');
|
||||
logger.logBoxLine(`Update Available: ${this.latestVersion}`);
|
||||
logger.logBoxLine('Run "sudo nupst upgrade" to upgrade');
|
||||
logger.logBoxEnd();
|
||||
} else if (checkForUpdates) {
|
||||
console.log('│ Checking for updates...');
|
||||
this.checkForUpdates().then(updateAvailable => {
|
||||
logger.logBoxLine('Checking for updates...');
|
||||
|
||||
// We can't end the box yet since we're in an async operation
|
||||
this.checkForUpdates().then((updateAvailable) => {
|
||||
if (updateAvailable) {
|
||||
console.log(`│ Update Available: ${this.latestVersion}`);
|
||||
console.log('│ Run "sudo nupst update" to update');
|
||||
logger.logBoxLine(`Update Available: ${this.latestVersion}`);
|
||||
logger.logBoxLine('Run "sudo nupst upgrade" to upgrade');
|
||||
} else {
|
||||
console.log('│ You are running the latest version');
|
||||
logger.logBoxLine('You are running the latest version');
|
||||
}
|
||||
console.log('└──────────────────────────────────────────┘');
|
||||
logger.logBoxEnd();
|
||||
}).catch(() => {
|
||||
console.log('│ Could not check for updates');
|
||||
console.log('└──────────────────────────────────────────┘');
|
||||
logger.logBoxLine('Could not check for updates');
|
||||
logger.logBoxEnd();
|
||||
});
|
||||
} else {
|
||||
console.log('└──────────────────────────────────────────┘');
|
||||
logger.logBoxEnd();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import * as fs from 'node:fs';
|
||||
|
||||
/**
|
||||
* Pause state interface
|
||||
*/
|
||||
export interface IPauseState {
|
||||
/** Timestamp when pause was activated */
|
||||
pausedAt: number;
|
||||
/** Who initiated the pause (e.g., 'cli', 'api') */
|
||||
pausedBy: string;
|
||||
/** Optional reason for pausing */
|
||||
reason?: string;
|
||||
/** When to auto-resume (null = indefinite, timestamp in ms) */
|
||||
resumeAt?: number | null;
|
||||
}
|
||||
|
||||
export type TPauseTransition = 'unchanged' | 'paused' | 'resumed' | 'autoResumed';
|
||||
|
||||
export interface IPauseSnapshot {
|
||||
isPaused: boolean;
|
||||
pauseState: IPauseState | null;
|
||||
transition: TPauseTransition;
|
||||
}
|
||||
|
||||
export function loadPauseSnapshot(
|
||||
filePath: string,
|
||||
wasPaused: boolean,
|
||||
now: number = Date.now(),
|
||||
): IPauseSnapshot {
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return {
|
||||
isPaused: false,
|
||||
pauseState: null,
|
||||
transition: wasPaused ? 'resumed' : 'unchanged',
|
||||
};
|
||||
}
|
||||
|
||||
const data = fs.readFileSync(filePath, 'utf8');
|
||||
const pauseState = JSON.parse(data) as IPauseState;
|
||||
|
||||
if (pauseState.resumeAt && now >= pauseState.resumeAt) {
|
||||
try {
|
||||
fs.unlinkSync(filePath);
|
||||
} catch (_error) {
|
||||
// Ignore deletion errors and still treat the pause as expired.
|
||||
}
|
||||
|
||||
return {
|
||||
isPaused: false,
|
||||
pauseState: null,
|
||||
transition: wasPaused ? 'autoResumed' : 'unchanged',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isPaused: true,
|
||||
pauseState,
|
||||
transition: wasPaused ? 'unchanged' : 'paused',
|
||||
};
|
||||
} catch (_error) {
|
||||
return {
|
||||
isPaused: false,
|
||||
pauseState: null,
|
||||
transition: 'unchanged',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Protocol abstraction module
|
||||
* Re-exports public types and classes
|
||||
*/
|
||||
|
||||
export type { TProtocol } from './types.ts';
|
||||
export { ProtocolResolver } from './resolver.ts';
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* ProtocolResolver - Routes UPS status queries to the correct protocol implementation
|
||||
*
|
||||
* Abstracts away SNMP vs UPSD differences so the daemon is protocol-agnostic.
|
||||
* Both protocols return the same IUpsStatus interface from ts/snmp/types.ts.
|
||||
*/
|
||||
|
||||
import type { NupstSnmp } from '../snmp/manager.ts';
|
||||
import type { NupstUpsd } from '../upsd/client.ts';
|
||||
import type { ISnmpConfig, IUpsStatus } from '../snmp/types.ts';
|
||||
import type { IUpsdConfig } from '../upsd/types.ts';
|
||||
import type { TProtocol } from './types.ts';
|
||||
|
||||
export class ProtocolResolver {
|
||||
private snmp: NupstSnmp;
|
||||
private upsd: NupstUpsd;
|
||||
|
||||
constructor(snmp: NupstSnmp, upsd: NupstUpsd) {
|
||||
this.snmp = snmp;
|
||||
this.upsd = upsd;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get UPS status using the specified protocol
|
||||
* @param protocol Protocol to use ('snmp' or 'upsd')
|
||||
* @param snmpConfig SNMP configuration (required for 'snmp' protocol)
|
||||
* @param upsdConfig UPSD configuration (required for 'upsd' protocol)
|
||||
* @returns UPS status
|
||||
*/
|
||||
public getUpsStatus(
|
||||
protocol: TProtocol,
|
||||
snmpConfig?: ISnmpConfig,
|
||||
upsdConfig?: IUpsdConfig,
|
||||
): Promise<IUpsStatus> {
|
||||
switch (protocol) {
|
||||
case 'upsd':
|
||||
if (!upsdConfig) {
|
||||
throw new Error('UPSD configuration required for UPSD protocol');
|
||||
}
|
||||
return this.upsd.getUpsStatus(upsdConfig);
|
||||
case 'snmp':
|
||||
default:
|
||||
if (!snmpConfig) {
|
||||
throw new Error('SNMP configuration required for SNMP protocol');
|
||||
}
|
||||
return this.snmp.getUpsStatus(snmpConfig);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* Protocol type for UPS communication
|
||||
*/
|
||||
export type TProtocol = 'snmp' | 'upsd';
|
||||
@@ -0,0 +1,145 @@
|
||||
import process from 'node:process';
|
||||
import * as fs from 'node:fs';
|
||||
import { exec, execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { logger } from './logger.ts';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
interface IShutdownAlternative {
|
||||
cmd: string;
|
||||
args: string[];
|
||||
}
|
||||
|
||||
interface IAlternativeLogConfig {
|
||||
resolvedMessage: (commandPath: string, args: string[]) => string;
|
||||
pathMessage: (command: string, args: string[]) => string;
|
||||
failureMessage?: (command: string, error: unknown) => string;
|
||||
}
|
||||
|
||||
export class ShutdownExecutor {
|
||||
private readonly commonCommandDirs = ['/sbin', '/usr/sbin', '/bin', '/usr/bin'];
|
||||
|
||||
public async scheduleShutdown(delayMinutes: number): Promise<void> {
|
||||
const shutdownMessage = `UPS battery critical, shutting down in ${delayMinutes} minutes`;
|
||||
const shutdownCommandPath = this.findCommandPath('shutdown');
|
||||
|
||||
if (shutdownCommandPath) {
|
||||
logger.log(`Found shutdown command at: ${shutdownCommandPath}`);
|
||||
logger.log(`Executing: ${shutdownCommandPath} -h +${delayMinutes} "UPS battery critical..."`);
|
||||
const { stdout } = await execFileAsync(shutdownCommandPath, [
|
||||
'-h',
|
||||
`+${delayMinutes}`,
|
||||
shutdownMessage,
|
||||
]);
|
||||
logger.log(`Shutdown initiated: ${stdout}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
logger.log('Shutdown command not found in common paths, trying via PATH...');
|
||||
const { stdout } = await execAsync(
|
||||
`shutdown -h +${delayMinutes} "${shutdownMessage}"`,
|
||||
{ env: process.env },
|
||||
);
|
||||
logger.log(`Shutdown initiated: ${stdout}`);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Shutdown command not found: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async forceImmediateShutdown(): Promise<void> {
|
||||
const shutdownMessage = 'EMERGENCY: UPS battery critically low, shutting down NOW';
|
||||
const shutdownCommandPath = this.findCommandPath('shutdown');
|
||||
|
||||
if (shutdownCommandPath) {
|
||||
logger.log(`Found shutdown command at: ${shutdownCommandPath}`);
|
||||
logger.log(`Executing emergency shutdown: ${shutdownCommandPath} -h now`);
|
||||
await execFileAsync(shutdownCommandPath, ['-h', 'now', shutdownMessage]);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.log('Shutdown command not found in common paths, trying via PATH...');
|
||||
await execAsync(`shutdown -h now "${shutdownMessage}"`, {
|
||||
env: process.env,
|
||||
});
|
||||
}
|
||||
|
||||
public async tryScheduledAlternatives(): Promise<boolean> {
|
||||
return await this.tryAlternatives(
|
||||
[
|
||||
{ cmd: 'poweroff', args: ['--force'] },
|
||||
{ cmd: 'halt', args: ['-p'] },
|
||||
{ cmd: 'systemctl', args: ['poweroff'] },
|
||||
{ cmd: 'reboot', args: ['-p'] },
|
||||
],
|
||||
{
|
||||
resolvedMessage: (commandPath, args) =>
|
||||
`Trying alternative shutdown method: ${commandPath} ${args.join(' ')}`,
|
||||
pathMessage: (command, args) => `Trying alternative via PATH: ${command} ${args.join(' ')}`,
|
||||
failureMessage: (command, error) => `Alternative method ${command} failed: ${error}`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
public async tryEmergencyAlternatives(): Promise<boolean> {
|
||||
return await this.tryAlternatives(
|
||||
[
|
||||
{ cmd: 'poweroff', args: ['--force'] },
|
||||
{ cmd: 'halt', args: ['-p'] },
|
||||
{ cmd: 'systemctl', args: ['poweroff'] },
|
||||
],
|
||||
{
|
||||
resolvedMessage: (commandPath, args) => `Emergency: using ${commandPath} ${args.join(' ')}`,
|
||||
pathMessage: (command) => `Emergency: trying ${command} via PATH`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private findCommandPath(command: string): string | null {
|
||||
for (const directory of this.commonCommandDirs) {
|
||||
const commandPath = `${directory}/${command}`;
|
||||
try {
|
||||
if (fs.existsSync(commandPath)) {
|
||||
return commandPath;
|
||||
}
|
||||
} catch (_error) {
|
||||
// Continue checking other paths.
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async tryAlternatives(
|
||||
alternatives: IShutdownAlternative[],
|
||||
logConfig: IAlternativeLogConfig,
|
||||
): Promise<boolean> {
|
||||
for (const alternative of alternatives) {
|
||||
try {
|
||||
const commandPath = this.findCommandPath(alternative.cmd);
|
||||
|
||||
if (commandPath) {
|
||||
logger.log(logConfig.resolvedMessage(commandPath, alternative.args));
|
||||
await execFileAsync(commandPath, alternative.args);
|
||||
return true;
|
||||
}
|
||||
|
||||
logger.log(logConfig.pathMessage(alternative.cmd, alternative.args));
|
||||
await execAsync(`${alternative.cmd} ${alternative.args.join(' ')}`, {
|
||||
env: process.env,
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (logConfig.failureMessage) {
|
||||
logger.error(logConfig.failureMessage(alternative.cmd, error));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { IUpsStatus as IProtocolUpsStatus } from './snmp/types.ts';
|
||||
|
||||
export interface IShutdownMonitoringRow extends Record<string, string> {
|
||||
name: string;
|
||||
battery: string;
|
||||
runtime: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface IShutdownRowFormatters {
|
||||
battery: (batteryCapacity: number) => string;
|
||||
runtime: (batteryRuntime: number) => string;
|
||||
ok: (text: string) => string;
|
||||
critical: (text: string) => string;
|
||||
error: (text: string) => string;
|
||||
}
|
||||
|
||||
export interface IShutdownEmergencyCandidate<TUps> {
|
||||
ups: TUps;
|
||||
status: IProtocolUpsStatus;
|
||||
}
|
||||
|
||||
export function isEmergencyRuntime(
|
||||
batteryRuntime: number,
|
||||
emergencyRuntimeMinutes: number,
|
||||
): boolean {
|
||||
return batteryRuntime < emergencyRuntimeMinutes;
|
||||
}
|
||||
|
||||
export function buildShutdownStatusRow(
|
||||
upsName: string,
|
||||
status: IProtocolUpsStatus,
|
||||
emergencyRuntimeMinutes: number,
|
||||
formatters: IShutdownRowFormatters,
|
||||
): { row: IShutdownMonitoringRow; isCritical: boolean } {
|
||||
const isCritical = isEmergencyRuntime(status.batteryRuntime, emergencyRuntimeMinutes);
|
||||
|
||||
return {
|
||||
row: {
|
||||
name: upsName,
|
||||
battery: formatters.battery(status.batteryCapacity),
|
||||
runtime: formatters.runtime(status.batteryRuntime),
|
||||
status: isCritical ? formatters.critical('CRITICAL!') : formatters.ok('OK'),
|
||||
},
|
||||
isCritical,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildShutdownErrorRow(
|
||||
upsName: string,
|
||||
errorFormatter: (text: string) => string,
|
||||
): IShutdownMonitoringRow {
|
||||
return {
|
||||
name: upsName,
|
||||
battery: errorFormatter('N/A'),
|
||||
runtime: errorFormatter('N/A'),
|
||||
status: errorFormatter('ERROR'),
|
||||
};
|
||||
}
|
||||
|
||||
export function selectEmergencyCandidate<TUps>(
|
||||
currentCandidate: IShutdownEmergencyCandidate<TUps> | null,
|
||||
ups: TUps,
|
||||
status: IProtocolUpsStatus,
|
||||
emergencyRuntimeMinutes: number,
|
||||
): IShutdownEmergencyCandidate<TUps> | null {
|
||||
if (currentCandidate || !isEmergencyRuntime(status.batteryRuntime, emergencyRuntimeMinutes)) {
|
||||
return currentCandidate;
|
||||
}
|
||||
|
||||
return { ups, status };
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
/**
|
||||
* Re-export from the snmp module
|
||||
* This file is kept for backward compatibility
|
||||
*/
|
||||
|
||||
export * from './snmp/index.js';
|
||||
@@ -1,98 +0,0 @@
|
||||
/**
|
||||
* SNMP encoding utilities
|
||||
* Contains helper methods for encoding SNMP data
|
||||
*/
|
||||
export class SnmpEncoder {
|
||||
/**
|
||||
* Convert OID string to array of integers
|
||||
* @param oid OID string in dotted notation (e.g. "1.3.6.1.2.1")
|
||||
* @returns Array of integers representing the OID
|
||||
*/
|
||||
public static oidToArray(oid: string): number[] {
|
||||
return oid.split('.').map(n => parseInt(n, 10));
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode an SNMP integer
|
||||
* @param value Integer value to encode
|
||||
* @returns Buffer containing the encoded integer
|
||||
*/
|
||||
public static encodeInteger(value: number): Buffer {
|
||||
const buf = Buffer.alloc(4);
|
||||
buf.writeInt32BE(value, 0);
|
||||
|
||||
// Find first non-zero byte
|
||||
let start = 0;
|
||||
while (start < 3 && buf[start] === 0) {
|
||||
start++;
|
||||
}
|
||||
|
||||
// Handle negative values
|
||||
if (value < 0 && buf[start] === 0) {
|
||||
start--;
|
||||
}
|
||||
|
||||
return buf.slice(start);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode an OID
|
||||
* @param oid Array of integers representing the OID
|
||||
* @returns Buffer containing the encoded OID
|
||||
*/
|
||||
public static encodeOID(oid: number[]): Buffer {
|
||||
// First two numbers are encoded as 40*x+y
|
||||
let encodedOid = Buffer.from([40 * (oid[0] || 0) + (oid[1] || 0)]);
|
||||
|
||||
// Encode remaining numbers
|
||||
for (let i = 2; i < oid.length; i++) {
|
||||
const n = oid[i];
|
||||
|
||||
if (n < 128) {
|
||||
// Simple case: number fits in one byte
|
||||
encodedOid = Buffer.concat([encodedOid, Buffer.from([n])]);
|
||||
} else {
|
||||
// Number needs multiple bytes
|
||||
const bytes = [];
|
||||
let value = n;
|
||||
|
||||
// Create bytes array in reverse order
|
||||
do {
|
||||
bytes.unshift(value & 0x7F);
|
||||
value >>= 7;
|
||||
} while (value > 0);
|
||||
|
||||
// Set high bit on all but the last byte
|
||||
for (let j = 0; j < bytes.length - 1; j++) {
|
||||
bytes[j] |= 0x80;
|
||||
}
|
||||
|
||||
encodedOid = Buffer.concat([encodedOid, Buffer.from(bytes)]);
|
||||
}
|
||||
}
|
||||
|
||||
return encodedOid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode an ASN.1 integer
|
||||
* @param buffer Buffer containing the encoded integer
|
||||
* @param offset Offset in the buffer
|
||||
* @param length Length of the integer in bytes
|
||||
* @returns Decoded integer value
|
||||
*/
|
||||
public static decodeInteger(buffer: Buffer, offset: number, length: number): number {
|
||||
if (length === 1) {
|
||||
return buffer[offset];
|
||||
} else if (length === 2) {
|
||||
return buffer.readInt16BE(offset);
|
||||
} else if (length === 3) {
|
||||
return (buffer[offset] << 16) | (buffer[offset + 1] << 8) | buffer[offset + 2];
|
||||
} else if (length === 4) {
|
||||
return buffer.readInt32BE(offset);
|
||||
} else {
|
||||
// For longer integers, we'll just return a simple value
|
||||
return buffer[offset];
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
// Re-export all public types
|
||||
export type { IUpsStatus, IOidSet, TUpsModel, ISnmpConfig } from './types.js';
|
||||
export type { IOidSet, ISnmpConfig, IUpsStatus, TUpsModel } from './types.ts';
|
||||
|
||||
// Re-export the SNMP manager class
|
||||
export { NupstSnmp } from './manager.js';
|
||||
export { NupstSnmp } from './manager.ts';
|
||||
|
||||
+676
-387
File diff suppressed because it is too large
Load Diff
+69
-21
@@ -1,4 +1,4 @@
|
||||
import type { IOidSet, TUpsModel } from './types.js';
|
||||
import type { IOidSet, TUpsModel } from './types.ts';
|
||||
|
||||
/**
|
||||
* OID sets for different UPS models
|
||||
@@ -11,45 +11,89 @@ export class UpsOidSets {
|
||||
private static readonly UPS_OID_SETS: Record<TUpsModel, IOidSet> = {
|
||||
// Cyberpower OIDs for RMCARD205 (based on CyberPower_MIB_v2.11)
|
||||
cyberpower: {
|
||||
POWER_STATUS: '1.3.6.1.4.1.3808.1.1.1.4.1.1.0', // upsBaseOutputStatus (2=online, 3=on battery)
|
||||
POWER_STATUS: '1.3.6.1.4.1.3808.1.1.1.4.1.1.0', // upsBaseOutputStatus
|
||||
BATTERY_CAPACITY: '1.3.6.1.4.1.3808.1.1.1.2.2.1.0', // upsAdvanceBatteryCapacity (percentage)
|
||||
BATTERY_RUNTIME: '1.3.6.1.4.1.3808.1.1.1.2.2.4.0', // upsAdvanceBatteryRunTimeRemaining (TimeTicks)
|
||||
OUTPUT_LOAD: '1.3.6.1.4.1.3808.1.1.1.4.2.3.0', // upsAdvanceOutputLoad (percentage)
|
||||
OUTPUT_POWER: '1.3.6.1.4.1.3808.1.1.1.4.2.5.0', // upsAdvanceOutputPower (watts)
|
||||
OUTPUT_VOLTAGE: '1.3.6.1.4.1.3808.1.1.1.4.2.1.0', // upsAdvanceOutputVoltage (0.1V scale)
|
||||
OUTPUT_CURRENT: '1.3.6.1.4.1.3808.1.1.1.4.2.4.0', // upsAdvanceOutputCurrent (0.1A scale)
|
||||
POWER_STATUS_VALUES: {
|
||||
online: 2, // upsBaseOutputStatus: 2=onLine
|
||||
onBattery: 3, // upsBaseOutputStatus: 3=onBattery
|
||||
},
|
||||
},
|
||||
|
||||
// APC OIDs
|
||||
|
||||
// APC OIDs (PowerNet MIB)
|
||||
apc: {
|
||||
POWER_STATUS: '1.3.6.1.4.1.318.1.1.1.4.1.1.0', // Power status (1=online, 2=on battery)
|
||||
POWER_STATUS: '1.3.6.1.4.1.318.1.1.1.4.1.1.0', // upsBasicOutputStatus
|
||||
BATTERY_CAPACITY: '1.3.6.1.4.1.318.1.1.1.2.2.1.0', // Battery capacity in percentage
|
||||
BATTERY_RUNTIME: '1.3.6.1.4.1.318.1.1.1.2.2.3.0', // Remaining runtime in minutes
|
||||
BATTERY_RUNTIME: '1.3.6.1.4.1.318.1.1.1.2.2.3.0', // Remaining runtime (TimeTicks)
|
||||
OUTPUT_LOAD: '1.3.6.1.4.1.318.1.1.1.4.2.3.0', // upsAdvOutputLoad (percentage)
|
||||
OUTPUT_POWER: '1.3.6.1.4.1.318.1.1.1.4.2.8.0', // upsAdvOutputActivePower (watts)
|
||||
OUTPUT_VOLTAGE: '1.3.6.1.4.1.318.1.1.1.4.2.1.0', // upsAdvOutputVoltage
|
||||
OUTPUT_CURRENT: '1.3.6.1.4.1.318.1.1.1.4.2.4.0', // upsAdvOutputCurrent
|
||||
POWER_STATUS_VALUES: {
|
||||
online: 2, // upsBasicOutputStatus: 2=onLine
|
||||
onBattery: 3, // upsBasicOutputStatus: 3=onBattery
|
||||
},
|
||||
},
|
||||
|
||||
// Eaton OIDs
|
||||
|
||||
// Eaton OIDs (XUPS-MIB)
|
||||
eaton: {
|
||||
POWER_STATUS: '1.3.6.1.4.1.534.1.1.2.0', // Power status
|
||||
BATTERY_CAPACITY: '1.3.6.1.4.1.534.1.2.4.0', // Battery capacity in percentage
|
||||
BATTERY_RUNTIME: '1.3.6.1.4.1.534.1.2.1.0', // Remaining runtime in minutes
|
||||
POWER_STATUS: '1.3.6.1.4.1.534.1.4.4.0', // xupsOutputSource
|
||||
BATTERY_CAPACITY: '1.3.6.1.4.1.534.1.2.4.0', // xupsBatCapacity (percentage)
|
||||
BATTERY_RUNTIME: '1.3.6.1.4.1.534.1.2.1.0', // xupsBatTimeRemaining (seconds)
|
||||
OUTPUT_LOAD: '1.3.6.1.4.1.534.1.4.4.1.8.1', // xupsOutputPercentLoad (phase 1)
|
||||
OUTPUT_POWER: '1.3.6.1.4.1.534.1.4.4.1.4.1', // xupsOutputWatts (phase 1)
|
||||
OUTPUT_VOLTAGE: '1.3.6.1.4.1.534.1.4.4.1.2.1', // xupsOutputVoltage (phase 1)
|
||||
OUTPUT_CURRENT: '1.3.6.1.4.1.534.1.4.4.1.3.1', // xupsOutputCurrent (phase 1)
|
||||
POWER_STATUS_VALUES: {
|
||||
online: 3, // xupsOutputSource: 3=normal (mains power)
|
||||
onBattery: 5, // xupsOutputSource: 5=battery
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
// TrippLite OIDs
|
||||
tripplite: {
|
||||
POWER_STATUS: '1.3.6.1.4.1.850.1.1.3.1.1.1.0', // Power status
|
||||
POWER_STATUS: '1.3.6.1.4.1.850.1.1.3.1.1.1.0', // tlUpsOutputSource
|
||||
BATTERY_CAPACITY: '1.3.6.1.4.1.850.1.1.3.2.4.1.0', // Battery capacity in percentage
|
||||
BATTERY_RUNTIME: '1.3.6.1.4.1.850.1.1.3.2.2.1.0', // Remaining runtime in minutes
|
||||
OUTPUT_LOAD: '1.3.6.1.2.1.33.1.4.4.1.5.1', // RFC 1628: upsOutputPercentLoad
|
||||
OUTPUT_POWER: '1.3.6.1.2.1.33.1.4.4.1.4.1', // RFC 1628: upsOutputPower (watts)
|
||||
OUTPUT_VOLTAGE: '1.3.6.1.2.1.33.1.4.4.1.2.1', // RFC 1628: upsOutputVoltage
|
||||
OUTPUT_CURRENT: '1.3.6.1.2.1.33.1.4.4.1.3.1', // RFC 1628: upsOutputCurrent (0.1A scale)
|
||||
POWER_STATUS_VALUES: {
|
||||
online: 2, // tlUpsOutputSource: 2=normal (mains power)
|
||||
onBattery: 3, // tlUpsOutputSource: 3=onBattery
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
// Liebert/Vertiv OIDs
|
||||
liebert: {
|
||||
POWER_STATUS: '1.3.6.1.4.1.476.1.42.3.9.20.1.20.1.2.1.2.1', // Power status
|
||||
POWER_STATUS: '1.3.6.1.4.1.476.1.42.3.9.20.1.20.1.2.1.2.1', // lgpPwrOutputSource
|
||||
BATTERY_CAPACITY: '1.3.6.1.4.1.476.1.42.3.9.20.1.20.1.2.1.4.1', // Battery capacity in percentage
|
||||
BATTERY_RUNTIME: '1.3.6.1.4.1.476.1.42.3.9.20.1.20.1.2.1.5.1', // Remaining runtime in minutes
|
||||
OUTPUT_LOAD: '1.3.6.1.2.1.33.1.4.4.1.5.1', // RFC 1628: upsOutputPercentLoad
|
||||
OUTPUT_POWER: '1.3.6.1.2.1.33.1.4.4.1.4.1', // RFC 1628: upsOutputPower (watts)
|
||||
OUTPUT_VOLTAGE: '1.3.6.1.2.1.33.1.4.4.1.2.1', // RFC 1628: upsOutputVoltage
|
||||
OUTPUT_CURRENT: '1.3.6.1.2.1.33.1.4.4.1.3.1', // RFC 1628: upsOutputCurrent (0.1A scale)
|
||||
POWER_STATUS_VALUES: {
|
||||
online: 2, // lgpPwrOutputSource: 2=normal (mains power)
|
||||
onBattery: 3, // lgpPwrOutputSource: 3=onBattery
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
// Custom OIDs (to be provided by the user)
|
||||
custom: {
|
||||
POWER_STATUS: '',
|
||||
BATTERY_CAPACITY: '',
|
||||
BATTERY_RUNTIME: '',
|
||||
}
|
||||
OUTPUT_LOAD: '',
|
||||
OUTPUT_POWER: '',
|
||||
OUTPUT_VOLTAGE: '',
|
||||
OUTPUT_CURRENT: '',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -67,9 +111,13 @@ export class UpsOidSets {
|
||||
*/
|
||||
public static getStandardOids(): Record<string, string> {
|
||||
return {
|
||||
'power status': '1.3.6.1.2.1.33.1.4.1.0', // upsOutputSource
|
||||
'battery capacity': '1.3.6.1.2.1.33.1.2.4.0', // upsEstimatedChargeRemaining
|
||||
'battery runtime': '1.3.6.1.2.1.33.1.2.3.0' // upsEstimatedMinutesRemaining
|
||||
'power status': '1.3.6.1.2.1.33.1.4.1.0', // upsOutputSource
|
||||
'battery capacity': '1.3.6.1.2.1.33.1.2.4.0', // upsEstimatedChargeRemaining
|
||||
'battery runtime': '1.3.6.1.2.1.33.1.2.3.0', // upsEstimatedMinutesRemaining
|
||||
'output load': '1.3.6.1.2.1.33.1.4.4.1.5.1', // upsOutputPercentLoad (indexed by line)
|
||||
'output power': '1.3.6.1.2.1.33.1.4.4.1.4.1', // upsOutputPower in watts (indexed by line)
|
||||
'output voltage': '1.3.6.1.2.1.33.1.4.4.1.2.1', // upsOutputVoltage (indexed by line)
|
||||
'output current': '1.3.6.1.2.1.33.1.4.4.1.3.1', // upsOutputCurrent in 0.1A (indexed by line)
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,651 +0,0 @@
|
||||
import * as crypto from 'crypto';
|
||||
import type { ISnmpConfig, ISnmpV3SecurityParams } from './types.js';
|
||||
import { SnmpEncoder } from './encoder.js';
|
||||
|
||||
/**
|
||||
* SNMP packet creation utilities
|
||||
* Creates SNMP request packets for different SNMP versions
|
||||
*/
|
||||
export class SnmpPacketCreator {
|
||||
/**
|
||||
* Create an SNMPv1 GET request
|
||||
* @param oid OID to query
|
||||
* @param community Community string
|
||||
* @param debug Whether to enable debug output
|
||||
* @returns Buffer containing the SNMP request
|
||||
*/
|
||||
public static createSnmpGetRequest(oid: string, community: string, debug: boolean = false): Buffer {
|
||||
const oidArray = SnmpEncoder.oidToArray(oid);
|
||||
const encodedOid = SnmpEncoder.encodeOID(oidArray);
|
||||
|
||||
if (debug) {
|
||||
console.log('OID array length:', oidArray.length);
|
||||
console.log('OID array:', oidArray);
|
||||
}
|
||||
|
||||
// SNMP message structure
|
||||
// Sequence
|
||||
// Version (Integer)
|
||||
// Community (String)
|
||||
// PDU (GetRequest)
|
||||
// Request ID (Integer)
|
||||
// Error Status (Integer)
|
||||
// Error Index (Integer)
|
||||
// Variable Bindings (Sequence)
|
||||
// Variable (Sequence)
|
||||
// OID (ObjectIdentifier)
|
||||
// Value (Null)
|
||||
|
||||
// Use the standard method from our test that is known to work
|
||||
// Create a fixed request ID (0x00000001) to ensure deterministic behavior
|
||||
const requestId = Buffer.from([0x00, 0x00, 0x00, 0x01]);
|
||||
|
||||
// Encode values
|
||||
const versionBuf = Buffer.concat([
|
||||
Buffer.from([0x02, 0x01]), // ASN.1 Integer, length 1
|
||||
Buffer.from([0x00]) // SNMP version 1 (0)
|
||||
]);
|
||||
|
||||
const communityBuf = Buffer.concat([
|
||||
Buffer.from([0x04, community.length]), // ASN.1 Octet String, length
|
||||
Buffer.from(community) // Community string
|
||||
]);
|
||||
|
||||
const requestIdBuf = Buffer.concat([
|
||||
Buffer.from([0x02, 0x04]), // ASN.1 Integer, length 4
|
||||
requestId // Fixed Request ID
|
||||
]);
|
||||
|
||||
const errorStatusBuf = Buffer.concat([
|
||||
Buffer.from([0x02, 0x01]), // ASN.1 Integer, length 1
|
||||
Buffer.from([0x00]) // Error Status (0 = no error)
|
||||
]);
|
||||
|
||||
const errorIndexBuf = Buffer.concat([
|
||||
Buffer.from([0x02, 0x01]), // ASN.1 Integer, length 1
|
||||
Buffer.from([0x00]) // Error Index (0)
|
||||
]);
|
||||
|
||||
const oidValueBuf = Buffer.concat([
|
||||
Buffer.from([0x30]), // ASN.1 Sequence
|
||||
Buffer.from([encodedOid.length + 2]), // Length
|
||||
Buffer.from([0x06]), // ASN.1 Object Identifier
|
||||
Buffer.from([encodedOid.length]), // Length
|
||||
encodedOid, // OID
|
||||
Buffer.from([0x05, 0x00]) // Null value
|
||||
]);
|
||||
|
||||
const varBindingsBuf = Buffer.concat([
|
||||
Buffer.from([0x30]), // ASN.1 Sequence
|
||||
Buffer.from([oidValueBuf.length]), // Length
|
||||
oidValueBuf // Variable binding
|
||||
]);
|
||||
|
||||
const pduBuf = Buffer.concat([
|
||||
Buffer.from([0xa0]), // ASN.1 Context-specific Constructed 0 (GetRequest)
|
||||
Buffer.from([requestIdBuf.length + errorStatusBuf.length + errorIndexBuf.length + varBindingsBuf.length]), // Length
|
||||
requestIdBuf, // Request ID
|
||||
errorStatusBuf, // Error Status
|
||||
errorIndexBuf, // Error Index
|
||||
varBindingsBuf // Variable Bindings
|
||||
]);
|
||||
|
||||
const messageBuf = Buffer.concat([
|
||||
Buffer.from([0x30]), // ASN.1 Sequence
|
||||
Buffer.from([versionBuf.length + communityBuf.length + pduBuf.length]), // Length
|
||||
versionBuf, // Version
|
||||
communityBuf, // Community
|
||||
pduBuf // PDU
|
||||
]);
|
||||
|
||||
if (debug) {
|
||||
console.log('SNMP Request buffer:', messageBuf.toString('hex'));
|
||||
}
|
||||
|
||||
return messageBuf;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an SNMPv3 GET request
|
||||
* @param oid OID to query
|
||||
* @param config SNMP configuration
|
||||
* @param engineID Engine ID
|
||||
* @param engineBoots Engine boots counter
|
||||
* @param engineTime Engine time counter
|
||||
* @param requestID Request ID
|
||||
* @param debug Whether to enable debug output
|
||||
* @returns Buffer containing the SNMP request
|
||||
*/
|
||||
public static createSnmpV3GetRequest(
|
||||
oid: string,
|
||||
config: ISnmpConfig,
|
||||
engineID: Buffer,
|
||||
engineBoots: number,
|
||||
engineTime: number,
|
||||
requestID: number,
|
||||
debug: boolean = false
|
||||
): Buffer {
|
||||
if (debug) {
|
||||
console.log('Creating SNMPv3 GET request for OID:', oid);
|
||||
console.log('With config:', {
|
||||
...config,
|
||||
authKey: config.authKey ? '***' : undefined,
|
||||
privKey: config.privKey ? '***' : undefined
|
||||
});
|
||||
}
|
||||
|
||||
const oidArray = SnmpEncoder.oidToArray(oid);
|
||||
const encodedOid = SnmpEncoder.encodeOID(oidArray);
|
||||
|
||||
if (debug) {
|
||||
console.log('Using engine ID:', engineID.toString('hex'));
|
||||
console.log('Engine boots:', engineBoots);
|
||||
console.log('Engine time:', engineTime);
|
||||
console.log('Request ID:', requestID);
|
||||
}
|
||||
|
||||
// Create security parameters
|
||||
const securityParams: ISnmpV3SecurityParams = {
|
||||
msgAuthoritativeEngineID: engineID,
|
||||
msgAuthoritativeEngineBoots: engineBoots,
|
||||
msgAuthoritativeEngineTime: engineTime,
|
||||
msgUserName: config.username || '',
|
||||
msgAuthenticationParameters: Buffer.alloc(12, 0), // Will be filled in later for auth
|
||||
msgPrivacyParameters: Buffer.alloc(8, 0), // For privacy
|
||||
};
|
||||
|
||||
// Create the PDU (Protocol Data Unit)
|
||||
// This is wrapped within the security parameters
|
||||
const requestIdBuf = Buffer.concat([
|
||||
Buffer.from([0x02, 0x04]), // ASN.1 Integer, length 4
|
||||
SnmpEncoder.encodeInteger(requestID) // Request ID
|
||||
]);
|
||||
|
||||
const errorStatusBuf = Buffer.concat([
|
||||
Buffer.from([0x02, 0x01]), // ASN.1 Integer, length 1
|
||||
Buffer.from([0x00]) // Error Status (0 = no error)
|
||||
]);
|
||||
|
||||
const errorIndexBuf = Buffer.concat([
|
||||
Buffer.from([0x02, 0x01]), // ASN.1 Integer, length 1
|
||||
Buffer.from([0x00]) // Error Index (0)
|
||||
]);
|
||||
|
||||
const oidValueBuf = Buffer.concat([
|
||||
Buffer.from([0x30]), // ASN.1 Sequence
|
||||
Buffer.from([encodedOid.length + 2]), // Length
|
||||
Buffer.from([0x06]), // ASN.1 Object Identifier
|
||||
Buffer.from([encodedOid.length]), // Length
|
||||
encodedOid, // OID
|
||||
Buffer.from([0x05, 0x00]) // Null value
|
||||
]);
|
||||
|
||||
const varBindingsBuf = Buffer.concat([
|
||||
Buffer.from([0x30]), // ASN.1 Sequence
|
||||
Buffer.from([oidValueBuf.length]), // Length
|
||||
oidValueBuf // Variable binding
|
||||
]);
|
||||
|
||||
const pduBuf = Buffer.concat([
|
||||
Buffer.from([0xa0]), // ASN.1 Context-specific Constructed 0 (GetRequest)
|
||||
Buffer.from([requestIdBuf.length + errorStatusBuf.length + errorIndexBuf.length + varBindingsBuf.length]), // Length
|
||||
requestIdBuf, // Request ID
|
||||
errorStatusBuf, // Error Status
|
||||
errorIndexBuf, // Error Index
|
||||
varBindingsBuf // Variable Bindings
|
||||
]);
|
||||
|
||||
// Create the security parameters
|
||||
const engineIdBuf = Buffer.concat([
|
||||
Buffer.from([0x04, securityParams.msgAuthoritativeEngineID.length]), // ASN.1 Octet String
|
||||
securityParams.msgAuthoritativeEngineID
|
||||
]);
|
||||
|
||||
const engineBootsBuf = Buffer.concat([
|
||||
Buffer.from([0x02, 0x04]), // ASN.1 Integer, length 4
|
||||
SnmpEncoder.encodeInteger(securityParams.msgAuthoritativeEngineBoots)
|
||||
]);
|
||||
|
||||
const engineTimeBuf = Buffer.concat([
|
||||
Buffer.from([0x02, 0x04]), // ASN.1 Integer, length 4
|
||||
SnmpEncoder.encodeInteger(securityParams.msgAuthoritativeEngineTime)
|
||||
]);
|
||||
|
||||
const userNameBuf = Buffer.concat([
|
||||
Buffer.from([0x04, securityParams.msgUserName.length]), // ASN.1 Octet String
|
||||
Buffer.from(securityParams.msgUserName)
|
||||
]);
|
||||
|
||||
const authParamsBuf = Buffer.concat([
|
||||
Buffer.from([0x04, securityParams.msgAuthenticationParameters.length]), // ASN.1 Octet String
|
||||
securityParams.msgAuthenticationParameters
|
||||
]);
|
||||
|
||||
const privParamsBuf = Buffer.concat([
|
||||
Buffer.from([0x04, securityParams.msgPrivacyParameters.length]), // ASN.1 Octet String
|
||||
securityParams.msgPrivacyParameters
|
||||
]);
|
||||
|
||||
// Security parameters sequence
|
||||
const securityParamsBuf = Buffer.concat([
|
||||
Buffer.from([0x30]), // ASN.1 Sequence
|
||||
Buffer.from([engineIdBuf.length + engineBootsBuf.length + engineTimeBuf.length +
|
||||
userNameBuf.length + authParamsBuf.length + privParamsBuf.length]), // Length
|
||||
engineIdBuf,
|
||||
engineBootsBuf,
|
||||
engineTimeBuf,
|
||||
userNameBuf,
|
||||
authParamsBuf,
|
||||
privParamsBuf
|
||||
]);
|
||||
|
||||
// Determine security level flags
|
||||
let securityFlags = 0;
|
||||
if (config.securityLevel === 'authNoPriv' || config.securityLevel === 'authPriv') {
|
||||
securityFlags |= 0x01; // Authentication flag
|
||||
}
|
||||
if (config.securityLevel === 'authPriv') {
|
||||
securityFlags |= 0x02; // Privacy flag
|
||||
}
|
||||
|
||||
// Set reportable flag - required for SNMPv3
|
||||
securityFlags |= 0x04; // Reportable flag
|
||||
|
||||
// Create SNMPv3 header
|
||||
const msgIdBuf = Buffer.concat([
|
||||
Buffer.from([0x02, 0x04]), // ASN.1 Integer, length 4
|
||||
SnmpEncoder.encodeInteger(requestID) // Message ID (same as request ID for simplicity)
|
||||
]);
|
||||
|
||||
const msgMaxSizeBuf = Buffer.concat([
|
||||
Buffer.from([0x02, 0x04]), // ASN.1 Integer, length 4
|
||||
SnmpEncoder.encodeInteger(65507) // Max message size
|
||||
]);
|
||||
|
||||
const msgFlagsBuf = Buffer.concat([
|
||||
Buffer.from([0x04, 0x01]), // ASN.1 Octet String, length 1
|
||||
Buffer.from([securityFlags])
|
||||
]);
|
||||
|
||||
const msgSecModelBuf = Buffer.concat([
|
||||
Buffer.from([0x02, 0x01]), // ASN.1 Integer, length 1
|
||||
Buffer.from([0x03]) // Security model (3 = USM)
|
||||
]);
|
||||
|
||||
// SNMPv3 header
|
||||
const msgHeaderBuf = Buffer.concat([
|
||||
Buffer.from([0x30]), // ASN.1 Sequence
|
||||
Buffer.from([msgIdBuf.length + msgMaxSizeBuf.length + msgFlagsBuf.length + msgSecModelBuf.length]), // Length
|
||||
msgIdBuf,
|
||||
msgMaxSizeBuf,
|
||||
msgFlagsBuf,
|
||||
msgSecModelBuf
|
||||
]);
|
||||
|
||||
// SNMPv3 security parameters
|
||||
const msgSecurityBuf = Buffer.concat([
|
||||
Buffer.from([0x04]), // ASN.1 Octet String
|
||||
Buffer.from([securityParamsBuf.length]), // Length
|
||||
securityParamsBuf
|
||||
]);
|
||||
|
||||
// Create scopedPDU
|
||||
// In SNMPv3, the PDU is wrapped in a "scoped PDU" structure
|
||||
const contextEngineBuf = Buffer.concat([
|
||||
Buffer.from([0x04, engineID.length]), // ASN.1 Octet String
|
||||
engineID
|
||||
]);
|
||||
|
||||
const contextNameBuf = Buffer.concat([
|
||||
Buffer.from([0x04, 0x00]), // ASN.1 Octet String, length 0 (empty context name)
|
||||
]);
|
||||
|
||||
const scopedPduBuf = Buffer.concat([
|
||||
Buffer.from([0x30]), // ASN.1 Sequence
|
||||
Buffer.from([contextEngineBuf.length + contextNameBuf.length + pduBuf.length]), // Length
|
||||
contextEngineBuf,
|
||||
contextNameBuf,
|
||||
pduBuf
|
||||
]);
|
||||
|
||||
// For authPriv, we need to encrypt the scopedPDU
|
||||
let encryptedPdu = scopedPduBuf;
|
||||
if (config.securityLevel === 'authPriv' && config.privKey) {
|
||||
// In a real implementation, encryption would be applied here
|
||||
// For this example, we'll just simulate it
|
||||
encryptedPdu = this.simulateEncryption(scopedPduBuf, config);
|
||||
}
|
||||
|
||||
// Final scopedPDU (encrypted or not)
|
||||
const finalScopedPduBuf = Buffer.concat([
|
||||
Buffer.from([0x04]), // ASN.1 Octet String
|
||||
Buffer.from([encryptedPdu.length]), // Length
|
||||
encryptedPdu
|
||||
]);
|
||||
|
||||
// Combine everything for the final message
|
||||
const versionBuf = Buffer.concat([
|
||||
Buffer.from([0x02, 0x01]), // ASN.1 Integer, length 1
|
||||
Buffer.from([0x03]) // SNMP version 3 (3)
|
||||
]);
|
||||
|
||||
const messageBuf = Buffer.concat([
|
||||
Buffer.from([0x30]), // ASN.1 Sequence
|
||||
Buffer.from([versionBuf.length + msgHeaderBuf.length + msgSecurityBuf.length + finalScopedPduBuf.length]), // Length
|
||||
versionBuf,
|
||||
msgHeaderBuf,
|
||||
msgSecurityBuf,
|
||||
finalScopedPduBuf
|
||||
]);
|
||||
|
||||
// If using authentication, calculate and insert the authentication parameters
|
||||
if ((config.securityLevel === 'authNoPriv' || config.securityLevel === 'authPriv') &&
|
||||
config.authKey && config.authProtocol) {
|
||||
const authenticatedMsg = this.addAuthentication(messageBuf, config, authParamsBuf);
|
||||
|
||||
if (debug) {
|
||||
console.log('Created authenticated SNMPv3 message');
|
||||
console.log('Final message length:', authenticatedMsg.length);
|
||||
}
|
||||
|
||||
return authenticatedMsg;
|
||||
}
|
||||
|
||||
if (debug) {
|
||||
console.log('Created SNMPv3 message without authentication');
|
||||
console.log('Final message length:', messageBuf.length);
|
||||
}
|
||||
|
||||
return messageBuf;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulate encryption for authPriv security level
|
||||
* In a real implementation, this would use the specified privacy protocol (DES/AES)
|
||||
* @param data Data to encrypt
|
||||
* @param config SNMP configuration
|
||||
* @returns Encrypted data
|
||||
*/
|
||||
private static simulateEncryption(data: Buffer, config: ISnmpConfig): Buffer {
|
||||
// This is a placeholder - in a real implementation, you would:
|
||||
// 1. Generate an initialization vector (IV)
|
||||
// 2. Use the privacy key derived from the privKey
|
||||
// 3. Apply the appropriate encryption algorithm (DES/AES)
|
||||
|
||||
// For demonstration purposes only
|
||||
if (config.privProtocol === 'AES' && config.privKey) {
|
||||
try {
|
||||
// Create a deterministic IV for demo purposes (not secure for production)
|
||||
const iv = Buffer.alloc(16, 0);
|
||||
const engineID = Buffer.from([0x80, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06]);
|
||||
for (let i = 0; i < 8; i++) {
|
||||
iv[i] = engineID[i % engineID.length];
|
||||
}
|
||||
|
||||
// Create a key from the privKey (proper key localization should be used in production)
|
||||
const key = crypto.createHash('md5').update(config.privKey).digest();
|
||||
|
||||
// Create cipher and encrypt
|
||||
const cipher = crypto.createCipheriv('aes-128-cfb', key, iv);
|
||||
const encrypted = Buffer.concat([cipher.update(data), cipher.final()]);
|
||||
|
||||
return encrypted;
|
||||
} catch (error) {
|
||||
console.warn('AES encryption failed, falling back to plaintext:', error);
|
||||
return data;
|
||||
}
|
||||
} else if (config.privProtocol === 'DES' && config.privKey) {
|
||||
try {
|
||||
// Create a deterministic IV for demo purposes (not secure for production)
|
||||
const iv = Buffer.alloc(8, 0);
|
||||
const engineID = Buffer.from([0x80, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06]);
|
||||
for (let i = 0; i < 8; i++) {
|
||||
iv[i] = engineID[i % engineID.length];
|
||||
}
|
||||
|
||||
// Create a key from the privKey (proper key localization should be used in production)
|
||||
const key = crypto.createHash('md5').update(config.privKey).digest().slice(0, 8);
|
||||
|
||||
// Create cipher and encrypt
|
||||
const cipher = crypto.createCipheriv('des-cbc', key, iv);
|
||||
const encrypted = Buffer.concat([cipher.update(data), cipher.final()]);
|
||||
|
||||
return encrypted;
|
||||
} catch (error) {
|
||||
console.warn('DES encryption failed, falling back to plaintext:', error);
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
return data; // Return unencrypted data as fallback
|
||||
}
|
||||
|
||||
/**
|
||||
* Add authentication to SNMPv3 message
|
||||
* @param message Message to authenticate
|
||||
* @param config SNMP configuration
|
||||
* @param authParamsBuf Authentication parameters buffer
|
||||
* @returns Authenticated message
|
||||
*/
|
||||
private static addAuthentication(message: Buffer, config: ISnmpConfig, authParamsBuf: Buffer): Buffer {
|
||||
// In a real implementation, this would:
|
||||
// 1. Zero out the authentication parameters field
|
||||
// 2. Calculate HMAC-MD5 or HMAC-SHA1 over the entire message
|
||||
// 3. Insert the HMAC into the authentication parameters field
|
||||
|
||||
if (!config.authKey) {
|
||||
return message;
|
||||
}
|
||||
|
||||
try {
|
||||
// Find position of auth parameters in the message
|
||||
// This is a more reliable way to find the exact position
|
||||
let authParamsPos = -1;
|
||||
for (let i = 0; i < message.length - 16; i++) {
|
||||
// Look for the auth params pattern: 0x04 0x0C 0x00 0x00...
|
||||
if (message[i] === 0x04 && message[i + 1] === 0x0C) {
|
||||
// Check if next 12 bytes are all zeros
|
||||
let allZeros = true;
|
||||
for (let j = 0; j < 12; j++) {
|
||||
if (message[i + 2 + j] !== 0) {
|
||||
allZeros = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (allZeros) {
|
||||
authParamsPos = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (authParamsPos === -1) {
|
||||
return message;
|
||||
}
|
||||
|
||||
// Create a copy of the message with zeroed auth parameters
|
||||
const msgCopy = Buffer.from(message);
|
||||
|
||||
// Prepare the authentication key according to RFC3414
|
||||
// We should use the standard key localization process
|
||||
const localizedKey = this.localizeAuthKey(config.authKey,
|
||||
Buffer.from([0x80, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06]),
|
||||
config.authProtocol);
|
||||
|
||||
// Calculate HMAC
|
||||
let hmac;
|
||||
if (config.authProtocol === 'SHA') {
|
||||
hmac = crypto.createHmac('sha1', localizedKey).update(msgCopy).digest().slice(0, 12);
|
||||
} else {
|
||||
// Default to MD5
|
||||
hmac = crypto.createHmac('md5', localizedKey).update(msgCopy).digest().slice(0, 12);
|
||||
}
|
||||
|
||||
// Copy HMAC into original message
|
||||
hmac.copy(message, authParamsPos + 2);
|
||||
|
||||
return message;
|
||||
} catch (error) {
|
||||
console.warn('Authentication failed:', error);
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Localize authentication key according to RFC3414
|
||||
* @param key Authentication key
|
||||
* @param engineId Engine ID
|
||||
* @param authProtocol Authentication protocol
|
||||
* @returns Localized key
|
||||
*/
|
||||
private static localizeAuthKey(key: string, engineId: Buffer, authProtocol: string = 'MD5'): Buffer {
|
||||
try {
|
||||
// Convert password to key using hash
|
||||
let initialHash;
|
||||
if (authProtocol === 'SHA') {
|
||||
initialHash = crypto.createHash('sha1');
|
||||
} else {
|
||||
initialHash = crypto.createHash('md5');
|
||||
}
|
||||
|
||||
// Generate the initial key - repeated hashing of password + padding
|
||||
const password = Buffer.from(key);
|
||||
let passwordIndex = 0;
|
||||
|
||||
// Create a buffer of 1MB (1048576 bytes) filled with the password
|
||||
const buffer = Buffer.alloc(1048576);
|
||||
for (let i = 0; i < 1048576; i++) {
|
||||
buffer[i] = password[passwordIndex];
|
||||
passwordIndex = (passwordIndex + 1) % password.length;
|
||||
}
|
||||
|
||||
initialHash.update(buffer);
|
||||
let initialKey = initialHash.digest();
|
||||
|
||||
// Localize the key with engine ID
|
||||
let localHash;
|
||||
if (authProtocol === 'SHA') {
|
||||
localHash = crypto.createHash('sha1');
|
||||
} else {
|
||||
localHash = crypto.createHash('md5');
|
||||
}
|
||||
|
||||
localHash.update(initialKey);
|
||||
localHash.update(engineId);
|
||||
localHash.update(initialKey);
|
||||
|
||||
return localHash.digest();
|
||||
} catch (error) {
|
||||
console.error('Error localizing auth key:', error);
|
||||
// Return a fallback key
|
||||
return Buffer.from(key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a discovery message for SNMPv3 engine ID discovery
|
||||
* @param config SNMP configuration
|
||||
* @param requestID Request ID
|
||||
* @returns Discovery message
|
||||
*/
|
||||
public static createDiscoveryMessage(config: ISnmpConfig, requestID: number): Buffer {
|
||||
// Basic SNMPv3 header for discovery
|
||||
const msgIdBuf = Buffer.concat([
|
||||
Buffer.from([0x02, 0x04]), // ASN.1 Integer, length 4
|
||||
SnmpEncoder.encodeInteger(requestID)
|
||||
]);
|
||||
|
||||
const msgMaxSizeBuf = Buffer.concat([
|
||||
Buffer.from([0x02, 0x04]), // ASN.1 Integer, length 4
|
||||
SnmpEncoder.encodeInteger(65507) // Max message size
|
||||
]);
|
||||
|
||||
const msgFlagsBuf = Buffer.concat([
|
||||
Buffer.from([0x04, 0x01]), // ASN.1 Octet String, length 1
|
||||
Buffer.from([0x00]) // No authentication or privacy
|
||||
]);
|
||||
|
||||
const msgSecModelBuf = Buffer.concat([
|
||||
Buffer.from([0x02, 0x01]), // ASN.1 Integer, length 1
|
||||
Buffer.from([0x03]) // Security model (3 = USM)
|
||||
]);
|
||||
|
||||
// SNMPv3 header
|
||||
const msgHeaderBuf = Buffer.concat([
|
||||
Buffer.from([0x30]), // ASN.1 Sequence
|
||||
Buffer.from([msgIdBuf.length + msgMaxSizeBuf.length + msgFlagsBuf.length + msgSecModelBuf.length]), // Length
|
||||
msgIdBuf,
|
||||
msgMaxSizeBuf,
|
||||
msgFlagsBuf,
|
||||
msgSecModelBuf
|
||||
]);
|
||||
|
||||
// Simple security parameters for discovery
|
||||
const securityBuf = Buffer.concat([
|
||||
Buffer.from([0x04, 0x00]), // Empty octet string
|
||||
]);
|
||||
|
||||
// Simple Get request for discovery
|
||||
const requestIdBuf = Buffer.concat([
|
||||
Buffer.from([0x02, 0x04]), // ASN.1 Integer, length 4
|
||||
SnmpEncoder.encodeInteger(requestID + 1)
|
||||
]);
|
||||
|
||||
const errorStatusBuf = Buffer.concat([
|
||||
Buffer.from([0x02, 0x01]), // ASN.1 Integer, length 1
|
||||
Buffer.from([0x00]) // Error Status (0 = no error)
|
||||
]);
|
||||
|
||||
const errorIndexBuf = Buffer.concat([
|
||||
Buffer.from([0x02, 0x01]), // ASN.1 Integer, length 1
|
||||
Buffer.from([0x00]) // Error Index (0)
|
||||
]);
|
||||
|
||||
// Empty varbinds for discovery
|
||||
const varBindingsBuf = Buffer.concat([
|
||||
Buffer.from([0x30, 0x00]), // Empty sequence
|
||||
]);
|
||||
|
||||
const pduBuf = Buffer.concat([
|
||||
Buffer.from([0xa0]), // GetRequest
|
||||
Buffer.from([requestIdBuf.length + errorStatusBuf.length + errorIndexBuf.length + varBindingsBuf.length]),
|
||||
requestIdBuf,
|
||||
errorStatusBuf,
|
||||
errorIndexBuf,
|
||||
varBindingsBuf
|
||||
]);
|
||||
|
||||
// Context data
|
||||
const contextEngineBuf = Buffer.concat([
|
||||
Buffer.from([0x04, 0x00]), // Empty octet string
|
||||
]);
|
||||
|
||||
const contextNameBuf = Buffer.concat([
|
||||
Buffer.from([0x04, 0x00]), // Empty octet string
|
||||
]);
|
||||
|
||||
const scopedPduBuf = Buffer.concat([
|
||||
Buffer.from([0x30]),
|
||||
Buffer.from([contextEngineBuf.length + contextNameBuf.length + pduBuf.length]),
|
||||
contextEngineBuf,
|
||||
contextNameBuf,
|
||||
pduBuf
|
||||
]);
|
||||
|
||||
// Version
|
||||
const versionBuf = Buffer.concat([
|
||||
Buffer.from([0x02, 0x01]), // ASN.1 Integer, length 1
|
||||
Buffer.from([0x03]) // SNMP version 3 (3)
|
||||
]);
|
||||
|
||||
// Complete message
|
||||
return Buffer.concat([
|
||||
Buffer.from([0x30]),
|
||||
Buffer.from([versionBuf.length + msgHeaderBuf.length + securityBuf.length + scopedPduBuf.length]),
|
||||
versionBuf,
|
||||
msgHeaderBuf,
|
||||
securityBuf,
|
||||
scopedPduBuf
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,553 +0,0 @@
|
||||
import type { ISnmpConfig } from './types.js';
|
||||
import { SnmpEncoder } from './encoder.js';
|
||||
|
||||
/**
|
||||
* SNMP packet parsing utilities
|
||||
* Parses SNMP response packets
|
||||
*/
|
||||
export class SnmpPacketParser {
|
||||
/**
|
||||
* Parse an SNMP response
|
||||
* @param buffer Response buffer
|
||||
* @param config SNMP configuration
|
||||
* @param debug Whether to enable debug output
|
||||
* @returns Parsed value or null if parsing failed
|
||||
*/
|
||||
public static parseSnmpResponse(buffer: Buffer, config: ISnmpConfig, debug: boolean = false): any {
|
||||
// Check if we have a response packet
|
||||
if (buffer[0] !== 0x30) {
|
||||
throw new Error('Invalid SNMP response format');
|
||||
}
|
||||
|
||||
// For SNMPv3, we need to handle the message differently
|
||||
if (config.version === 3) {
|
||||
return this.parseSnmpV3Response(buffer, debug);
|
||||
}
|
||||
|
||||
if (debug) {
|
||||
console.log('Parsing SNMPv1/v2 response: ', buffer.toString('hex'));
|
||||
}
|
||||
|
||||
try {
|
||||
// Enhanced structured parsing approach
|
||||
// SEQUENCE header
|
||||
let pos = 0;
|
||||
if (buffer[pos] !== 0x30) {
|
||||
throw new Error('Missing SEQUENCE at start of response');
|
||||
}
|
||||
// Skip SEQUENCE header - assume length is in single byte for simplicity
|
||||
// In a more robust implementation, we'd handle multi-byte lengths
|
||||
pos += 2;
|
||||
|
||||
// VERSION
|
||||
if (buffer[pos] !== 0x02) {
|
||||
throw new Error('Missing INTEGER for version');
|
||||
}
|
||||
const versionLength = buffer[pos + 1];
|
||||
pos += 2 + versionLength;
|
||||
|
||||
// COMMUNITY STRING
|
||||
if (buffer[pos] !== 0x04) {
|
||||
throw new Error('Missing OCTET STRING for community');
|
||||
}
|
||||
const communityLength = buffer[pos + 1];
|
||||
pos += 2 + communityLength;
|
||||
|
||||
// PDU TYPE - should be RESPONSE (0xA2)
|
||||
if (buffer[pos] !== 0xA2) {
|
||||
throw new Error(`Unexpected PDU type: 0x${buffer[pos].toString(16)}, expected 0xA2`);
|
||||
}
|
||||
// Skip PDU header
|
||||
pos += 2;
|
||||
|
||||
// REQUEST ID
|
||||
if (buffer[pos] !== 0x02) {
|
||||
throw new Error('Missing INTEGER for request ID');
|
||||
}
|
||||
const requestIdLength = buffer[pos + 1];
|
||||
pos += 2 + requestIdLength;
|
||||
|
||||
// ERROR STATUS
|
||||
if (buffer[pos] !== 0x02) {
|
||||
throw new Error('Missing INTEGER for error status');
|
||||
}
|
||||
const errorStatusLength = buffer[pos + 1];
|
||||
const errorStatus = SnmpEncoder.decodeInteger(buffer, pos + 2, errorStatusLength);
|
||||
|
||||
if (errorStatus !== 0) {
|
||||
throw new Error(`SNMP error status: ${errorStatus}`);
|
||||
}
|
||||
pos += 2 + errorStatusLength;
|
||||
|
||||
// ERROR INDEX
|
||||
if (buffer[pos] !== 0x02) {
|
||||
throw new Error('Missing INTEGER for error index');
|
||||
}
|
||||
const errorIndexLength = buffer[pos + 1];
|
||||
pos += 2 + errorIndexLength;
|
||||
|
||||
// VARBIND LIST
|
||||
if (buffer[pos] !== 0x30) {
|
||||
throw new Error('Missing SEQUENCE for varbind list');
|
||||
}
|
||||
// Skip varbind list header
|
||||
pos += 2;
|
||||
|
||||
// VARBIND
|
||||
if (buffer[pos] !== 0x30) {
|
||||
throw new Error('Missing SEQUENCE for varbind');
|
||||
}
|
||||
// Skip varbind header
|
||||
pos += 2;
|
||||
|
||||
// OID
|
||||
if (buffer[pos] !== 0x06) {
|
||||
throw new Error('Missing OBJECT IDENTIFIER for OID');
|
||||
}
|
||||
const oidLength = buffer[pos + 1];
|
||||
pos += 2 + oidLength;
|
||||
|
||||
// VALUE - this is what we want
|
||||
const valueType = buffer[pos];
|
||||
const valueLength = buffer[pos + 1];
|
||||
|
||||
if (debug) {
|
||||
console.log(`Found value type: 0x${valueType.toString(16)}, length: ${valueLength}`);
|
||||
}
|
||||
|
||||
return this.parseValueByType(valueType, valueLength, buffer, pos, debug);
|
||||
} catch (error) {
|
||||
if (debug) {
|
||||
console.error('Error in structured parsing:', error);
|
||||
console.error('Falling back to scan-based parsing method');
|
||||
}
|
||||
|
||||
return this.scanBasedParsing(buffer, debug);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse value by ASN.1 type
|
||||
* @param valueType ASN.1 type
|
||||
* @param valueLength Value length
|
||||
* @param buffer Buffer containing the value
|
||||
* @param pos Position of the value in the buffer
|
||||
* @param debug Whether to enable debug output
|
||||
* @returns Parsed value
|
||||
*/
|
||||
private static parseValueByType(
|
||||
valueType: number,
|
||||
valueLength: number,
|
||||
buffer: Buffer,
|
||||
pos: number,
|
||||
debug: boolean
|
||||
): any {
|
||||
switch (valueType) {
|
||||
case 0x02: // INTEGER
|
||||
{
|
||||
const value = SnmpEncoder.decodeInteger(buffer, pos + 2, valueLength);
|
||||
if (debug) {
|
||||
console.log('Parsed INTEGER value:', value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
case 0x04: // OCTET STRING
|
||||
{
|
||||
const value = buffer.slice(pos + 2, pos + 2 + valueLength).toString();
|
||||
if (debug) {
|
||||
console.log('Parsed OCTET STRING value:', value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
case 0x05: // NULL
|
||||
if (debug) {
|
||||
console.log('Parsed NULL value');
|
||||
}
|
||||
return null;
|
||||
|
||||
case 0x06: // OBJECT IDENTIFIER (rare in a value position)
|
||||
{
|
||||
// Usually this would be encoded as a string representation
|
||||
const value = buffer.slice(pos + 2, pos + 2 + valueLength).toString('hex');
|
||||
if (debug) {
|
||||
console.log('Parsed OBJECT IDENTIFIER value (hex):', value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
case 0x40: // IP ADDRESS
|
||||
{
|
||||
if (valueLength !== 4) {
|
||||
throw new Error(`Invalid IP address length: ${valueLength}, expected 4`);
|
||||
}
|
||||
const octets = [];
|
||||
for (let i = 0; i < 4; i++) {
|
||||
octets.push(buffer[pos + 2 + i]);
|
||||
}
|
||||
const value = octets.join('.');
|
||||
if (debug) {
|
||||
console.log('Parsed IP ADDRESS value:', value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
case 0x41: // COUNTER
|
||||
case 0x42: // GAUGE32
|
||||
case 0x43: // TIMETICKS
|
||||
case 0x44: // OPAQUE
|
||||
{
|
||||
// All these are essentially unsigned 32-bit integers
|
||||
const value = SnmpEncoder.decodeInteger(buffer, pos + 2, valueLength);
|
||||
if (debug) {
|
||||
console.log(`Parsed ${valueType === 0x41 ? 'COUNTER'
|
||||
: valueType === 0x42 ? 'GAUGE32'
|
||||
: valueType === 0x43 ? 'TIMETICKS'
|
||||
: 'OPAQUE'} value:`, value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
default:
|
||||
if (debug) {
|
||||
console.log(`Unknown value type: 0x${valueType.toString(16)}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback scan-based parsing method
|
||||
* @param buffer Buffer containing the SNMP response
|
||||
* @param debug Whether to enable debug output
|
||||
* @returns Parsed value or null if parsing failed
|
||||
*/
|
||||
private static scanBasedParsing(buffer: Buffer, debug: boolean): any {
|
||||
// Look for various data types in the response
|
||||
// The value is near the end of the packet after the OID
|
||||
|
||||
// We're looking for one of these:
|
||||
// 0x02 - Integer - can be at the end of a varbind
|
||||
// 0x04 - OctetString
|
||||
// 0x05 - Null
|
||||
// 0x42 - Gauge32 - special type for unsigned 32-bit integers
|
||||
// 0x43 - Timeticks - special type for time values
|
||||
|
||||
// This algorithm performs a thorough search for data types
|
||||
// by iterating from the start and watching for varbind structures
|
||||
|
||||
// Walk through the buffer looking for varbinds
|
||||
let i = 0;
|
||||
|
||||
// First, find the varbinds section (0x30 sequence)
|
||||
while (i < buffer.length - 2) {
|
||||
// Look for a varbinds sequence
|
||||
if (buffer[i] === 0x30) {
|
||||
const varbindsLength = buffer[i + 1];
|
||||
const varbindsEnd = i + 2 + varbindsLength;
|
||||
|
||||
// Now search within the varbinds for the value
|
||||
let j = i + 2;
|
||||
while (j < varbindsEnd - 2) {
|
||||
// Look for a varbind (0x30 sequence)
|
||||
if (buffer[j] === 0x30) {
|
||||
const varbindLength = buffer[j + 1];
|
||||
const varbindEnd = j + 2 + varbindLength;
|
||||
|
||||
// Skip over the OID and find the value within this varbind
|
||||
let k = j + 2;
|
||||
while (k < varbindEnd - 1) {
|
||||
// First find the OID
|
||||
if (buffer[k] === 0x06) { // OID
|
||||
const oidLength = buffer[k + 1];
|
||||
k += 2 + oidLength; // Skip past the OID
|
||||
|
||||
// We should now be at the value
|
||||
// Check what type it is
|
||||
if (k < varbindEnd - 1) {
|
||||
return this.parseValueAtPosition(buffer, k, debug);
|
||||
}
|
||||
|
||||
// If we didn't find a value, move to next byte
|
||||
k++;
|
||||
} else {
|
||||
// Move to next byte
|
||||
k++;
|
||||
}
|
||||
}
|
||||
|
||||
// Move to next varbind
|
||||
j = varbindEnd;
|
||||
} else {
|
||||
// Move to next byte
|
||||
j++;
|
||||
}
|
||||
}
|
||||
|
||||
// Move to next sequence
|
||||
i = varbindsEnd;
|
||||
} else {
|
||||
// Move to next byte
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
if (debug) {
|
||||
console.log('No valid value found in SNMP response');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse value at a specific position in the buffer
|
||||
* @param buffer Buffer containing the SNMP response
|
||||
* @param pos Position of the value in the buffer
|
||||
* @param debug Whether to enable debug output
|
||||
* @returns Parsed value or null if parsing failed
|
||||
*/
|
||||
private static parseValueAtPosition(buffer: Buffer, pos: number, debug: boolean): any {
|
||||
if (buffer[pos] === 0x02) { // Integer
|
||||
const valueLength = buffer[pos + 1];
|
||||
const value = SnmpEncoder.decodeInteger(buffer, pos + 2, valueLength);
|
||||
if (debug) {
|
||||
console.log('Found Integer value:', value);
|
||||
}
|
||||
return value;
|
||||
} else if (buffer[pos] === 0x42) { // Gauge32
|
||||
const valueLength = buffer[pos + 1];
|
||||
const value = SnmpEncoder.decodeInteger(buffer, pos + 2, valueLength);
|
||||
if (debug) {
|
||||
console.log('Found Gauge32 value:', value);
|
||||
}
|
||||
return value;
|
||||
} else if (buffer[pos] === 0x43) { // TimeTicks
|
||||
const valueLength = buffer[pos + 1];
|
||||
const value = SnmpEncoder.decodeInteger(buffer, pos + 2, valueLength);
|
||||
if (debug) {
|
||||
console.log('Found Timeticks value:', value);
|
||||
}
|
||||
return value;
|
||||
} else if (buffer[pos] === 0x04) { // OctetString
|
||||
const valueLength = buffer[pos + 1];
|
||||
if (debug) {
|
||||
console.log('Found OctetString value');
|
||||
}
|
||||
// Just return the string value as-is
|
||||
return buffer.slice(pos + 2, pos + 2 + valueLength).toString();
|
||||
} else if (buffer[pos] === 0x05) { // Null
|
||||
if (debug) {
|
||||
console.log('Found Null value');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an SNMPv3 response
|
||||
* @param buffer Buffer containing the SNMP response
|
||||
* @param debug Whether to enable debug output
|
||||
* @returns Parsed value or null if parsing failed
|
||||
*/
|
||||
public static parseSnmpV3Response(buffer: Buffer, debug: boolean = false): any {
|
||||
// SNMPv3 parsing is complex. In a real implementation, we would:
|
||||
// 1. Parse the header and get the security parameters
|
||||
// 2. Verify authentication if used
|
||||
// 3. Decrypt the PDU if privacy was used
|
||||
// 4. Extract the PDU and parse it
|
||||
|
||||
if (debug) {
|
||||
console.log('Parsing SNMPv3 response: ', buffer.toString('hex'));
|
||||
}
|
||||
|
||||
// Find the scopedPDU - it should be the last OCTET STRING in the message
|
||||
let scopedPduPos = -1;
|
||||
for (let i = buffer.length - 50; i >= 0; i--) {
|
||||
if (buffer[i] === 0x04 && buffer[i + 1] > 10) { // OCTET STRING with reasonable length
|
||||
scopedPduPos = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (scopedPduPos === -1) {
|
||||
if (debug) {
|
||||
console.log('Could not find scoped PDU in SNMPv3 response');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Skip to the PDU content
|
||||
let pduContent = buffer.slice(scopedPduPos + 2); // Skip OCTET STRING header
|
||||
|
||||
// This improved algorithm performs a more thorough search for varbinds
|
||||
// in the scoped PDU
|
||||
|
||||
// First, look for the response PDU (sequence with tag 0xa2)
|
||||
let responsePdu = null;
|
||||
for (let i = 0; i < pduContent.length - 3; i++) {
|
||||
if (pduContent[i] === 0xa2) {
|
||||
// Found the response PDU
|
||||
const pduLength = pduContent[i + 1];
|
||||
responsePdu = pduContent.slice(i, i + 2 + pduLength);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!responsePdu) {
|
||||
// Try to find the varbinds directly
|
||||
for (let i = 0; i < pduContent.length - 3; i++) {
|
||||
if (pduContent[i] === 0x30) {
|
||||
const seqLength = pduContent[i + 1];
|
||||
if (i + 2 + seqLength <= pduContent.length) {
|
||||
// Check if this sequence might be the varbinds
|
||||
const possibleVarbinds = pduContent.slice(i, i + 2 + seqLength);
|
||||
|
||||
// Look for varbind structure inside
|
||||
for (let j = 0; j < possibleVarbinds.length - 3; j++) {
|
||||
if (possibleVarbinds[j] === 0x30) {
|
||||
// Might be a varbind - look for an OID inside
|
||||
for (let k = j; k < j + 10 && k < possibleVarbinds.length - 1; k++) {
|
||||
if (possibleVarbinds[k] === 0x06) {
|
||||
// Found an OID, so this is likely the varbinds sequence
|
||||
responsePdu = possibleVarbinds;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (responsePdu) break;
|
||||
}
|
||||
}
|
||||
|
||||
if (responsePdu) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!responsePdu) {
|
||||
if (debug) {
|
||||
console.log('Could not find response PDU in SNMPv3 response');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Now that we have the response PDU, search for varbinds
|
||||
// Skip the first few bytes to get past the header fields
|
||||
let varbindsPos = -1;
|
||||
for (let i = 10; i < responsePdu.length - 3; i++) {
|
||||
if (responsePdu[i] === 0x30) {
|
||||
// Check if this is the start of the varbinds
|
||||
// by seeing if it contains a varbind sequence
|
||||
for (let j = i + 2; j < i + 10 && j < responsePdu.length - 3; j++) {
|
||||
if (responsePdu[j] === 0x30) {
|
||||
varbindsPos = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (varbindsPos !== -1) break;
|
||||
}
|
||||
}
|
||||
|
||||
if (varbindsPos === -1) {
|
||||
if (debug) {
|
||||
console.log('Could not find varbinds in SNMPv3 response');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get the varbinds
|
||||
const varbindsLength = responsePdu[varbindsPos + 1];
|
||||
const varbinds = responsePdu.slice(varbindsPos, varbindsPos + 2 + varbindsLength);
|
||||
|
||||
// Now search for values inside the varbinds
|
||||
for (let i = 2; i < varbinds.length - 3; i++) {
|
||||
// Look for a varbind sequence
|
||||
if (varbinds[i] === 0x30) {
|
||||
const varbindLength = varbinds[i + 1];
|
||||
const varbind = varbinds.slice(i, i + 2 + varbindLength);
|
||||
|
||||
// Inside the varbind, look for the OID and then the value
|
||||
for (let j = 0; j < varbind.length - 3; j++) {
|
||||
if (varbind[j] === 0x06) { // OID
|
||||
const oidLength = varbind[j + 1];
|
||||
|
||||
// The value should be right after the OID
|
||||
const valuePos = j + 2 + oidLength;
|
||||
if (valuePos < varbind.length - 1) {
|
||||
// Check what type of value it is
|
||||
if (varbind[valuePos] === 0x02) { // INTEGER
|
||||
const valueLength = varbind[valuePos + 1];
|
||||
const value = SnmpEncoder.decodeInteger(varbind, valuePos + 2, valueLength);
|
||||
if (debug) {
|
||||
console.log('Found INTEGER value in SNMPv3 response:', value);
|
||||
}
|
||||
return value;
|
||||
} else if (varbind[valuePos] === 0x42) { // Gauge32
|
||||
const valueLength = varbind[valuePos + 1];
|
||||
const value = SnmpEncoder.decodeInteger(varbind, valuePos + 2, valueLength);
|
||||
if (debug) {
|
||||
console.log('Found Gauge32 value in SNMPv3 response:', value);
|
||||
}
|
||||
return value;
|
||||
} else if (varbind[valuePos] === 0x43) { // TimeTicks
|
||||
const valueLength = varbind[valuePos + 1];
|
||||
const value = SnmpEncoder.decodeInteger(varbind, valuePos + 2, valueLength);
|
||||
if (debug) {
|
||||
console.log('Found TimeTicks value in SNMPv3 response:', value);
|
||||
}
|
||||
return value;
|
||||
} else if (varbind[valuePos] === 0x04) { // OctetString
|
||||
const valueLength = varbind[valuePos + 1];
|
||||
const value = varbind.slice(valuePos + 2, valuePos + 2 + valueLength).toString();
|
||||
if (debug) {
|
||||
console.log('Found OctetString value in SNMPv3 response:', value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (debug) {
|
||||
console.log('No valid value found in SNMPv3 response');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract engine ID from SNMPv3 response
|
||||
* @param buffer Buffer containing the SNMP response
|
||||
* @param debug Whether to enable debug output
|
||||
* @returns Extracted engine ID or null if extraction failed
|
||||
*/
|
||||
public static extractEngineId(buffer: Buffer, debug: boolean = false): Buffer | null {
|
||||
try {
|
||||
// Simple parsing to find the engine ID
|
||||
// Look for the first octet string with appropriate length
|
||||
for (let i = 0; i < buffer.length - 10; i++) {
|
||||
if (buffer[i] === 0x04) { // Octet string
|
||||
const len = buffer[i + 1];
|
||||
if (len >= 5 && len <= 32) { // Engine IDs are typically 5-32 bytes
|
||||
// Verify this looks like an engine ID (usually starts with 0x80)
|
||||
if (buffer[i + 2] === 0x80) {
|
||||
if (debug) {
|
||||
console.log('Found engine ID at position', i);
|
||||
console.log('Engine ID:', buffer.slice(i + 2, i + 2 + len).toString('hex'));
|
||||
}
|
||||
return buffer.slice(i + 2, i + 2 + len);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error extracting engine ID:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { ISnmpConfig, TRuntimeUnit, TUpsModel } from './types.ts';
|
||||
|
||||
/**
|
||||
* Return the runtime unit that matches the bundled OID set for a UPS model.
|
||||
*/
|
||||
export function getDefaultRuntimeUnitForUpsModel(
|
||||
upsModel: TUpsModel | undefined,
|
||||
batteryRuntime?: number,
|
||||
): TRuntimeUnit {
|
||||
switch (upsModel) {
|
||||
case 'cyberpower':
|
||||
case 'apc':
|
||||
return 'ticks';
|
||||
case 'eaton':
|
||||
return 'seconds';
|
||||
case 'custom':
|
||||
case 'tripplite':
|
||||
case 'liebert':
|
||||
case undefined:
|
||||
if (batteryRuntime !== undefined && batteryRuntime > 10000) {
|
||||
return 'ticks';
|
||||
}
|
||||
return 'minutes';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an SNMP runtime value to minutes using explicit config first, then model defaults.
|
||||
*/
|
||||
export function convertRuntimeValueToMinutes(
|
||||
config: Pick<ISnmpConfig, 'runtimeUnit' | 'upsModel'>,
|
||||
batteryRuntime: number,
|
||||
): number {
|
||||
if (batteryRuntime <= 0) {
|
||||
return batteryRuntime;
|
||||
}
|
||||
|
||||
const runtimeUnit = config.runtimeUnit ||
|
||||
getDefaultRuntimeUnitForUpsModel(config.upsModel, batteryRuntime);
|
||||
|
||||
if (runtimeUnit === 'seconds') {
|
||||
return Math.floor(batteryRuntime / 60);
|
||||
}
|
||||
|
||||
if (runtimeUnit === 'ticks') {
|
||||
return Math.floor(batteryRuntime / 6000);
|
||||
}
|
||||
|
||||
return batteryRuntime;
|
||||
}
|
||||
+40
-6
@@ -2,18 +2,28 @@
|
||||
* Type definitions for SNMP module
|
||||
*/
|
||||
|
||||
import { Buffer } from 'node:buffer';
|
||||
|
||||
/**
|
||||
* UPS status interface
|
||||
*/
|
||||
export interface IUpsStatus {
|
||||
/** Current power status */
|
||||
powerStatus: 'online' | 'onBattery' | 'unknown';
|
||||
powerStatus: 'online' | 'onBattery' | 'unknown' | 'unreachable';
|
||||
/** Battery capacity percentage */
|
||||
batteryCapacity: number;
|
||||
/** Remaining runtime in minutes */
|
||||
batteryRuntime: number;
|
||||
/** Output load percentage (0-100) */
|
||||
outputLoad: number;
|
||||
/** Output power in watts */
|
||||
outputPower: number;
|
||||
/** Output voltage in volts */
|
||||
outputVoltage: number;
|
||||
/** Output current in amps */
|
||||
outputCurrent: number;
|
||||
/** Raw values from SNMP responses */
|
||||
raw: Record<string, any>;
|
||||
raw: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -26,6 +36,21 @@ export interface IOidSet {
|
||||
BATTERY_CAPACITY: string;
|
||||
/** OID for battery runtime */
|
||||
BATTERY_RUNTIME: string;
|
||||
/** OID for output load percentage */
|
||||
OUTPUT_LOAD: string;
|
||||
/** OID for output power in watts */
|
||||
OUTPUT_POWER: string;
|
||||
/** OID for output voltage */
|
||||
OUTPUT_VOLTAGE: string;
|
||||
/** OID for output current */
|
||||
OUTPUT_CURRENT: string;
|
||||
/** Power status value mappings */
|
||||
POWER_STATUS_VALUES?: {
|
||||
/** SNMP value that indicates UPS is online (on AC power) */
|
||||
online: number;
|
||||
/** SNMP value that indicates UPS is on battery */
|
||||
onBattery: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -33,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
|
||||
*/
|
||||
@@ -45,11 +75,13 @@ export interface ISnmpConfig {
|
||||
version: number;
|
||||
/** Timeout in milliseconds */
|
||||
timeout: number;
|
||||
|
||||
|
||||
context?: string;
|
||||
|
||||
// SNMPv1/v2c
|
||||
/** Community string for SNMPv1/v2c */
|
||||
community?: string;
|
||||
|
||||
|
||||
// SNMPv3
|
||||
/** Security level for SNMPv3 */
|
||||
securityLevel?: 'noAuthNoPriv' | 'authNoPriv' | 'authPriv';
|
||||
@@ -63,12 +95,14 @@ export interface ISnmpConfig {
|
||||
privProtocol?: 'DES' | 'AES';
|
||||
/** Privacy key for SNMPv3 */
|
||||
privKey?: string;
|
||||
|
||||
|
||||
// UPS model and custom OIDs
|
||||
/** UPS model for OID selection */
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,4 +121,4 @@ export interface ISnmpV3SecurityParams {
|
||||
msgAuthenticationParameters: Buffer;
|
||||
/** Privacy parameters */
|
||||
msgPrivacyParameters: Buffer;
|
||||
}
|
||||
}
|
||||
|
||||
+500
-85
@@ -1,6 +1,71 @@
|
||||
import { promises as fs } from 'fs';
|
||||
import { execSync } from 'child_process';
|
||||
import { NupstDaemon } from './daemon.js';
|
||||
import process from 'node:process';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import { execFileSync, execSync } from 'node:child_process';
|
||||
import { type IUpsConfig, NupstDaemon } from './daemon.ts';
|
||||
import { NupstSnmp } from './snmp/manager.ts';
|
||||
import { logger } from './logger.ts';
|
||||
import { formatPowerStatus, getBatteryColor, getRuntimeColor, symbols, theme } from './colors.ts';
|
||||
import { SHUTDOWN } from './constants.ts';
|
||||
|
||||
interface IServiceStatusSnapshot {
|
||||
loadState: string;
|
||||
activeState: string;
|
||||
subState: string;
|
||||
pid: string;
|
||||
memory: string;
|
||||
cpu: string;
|
||||
}
|
||||
|
||||
function formatSystemdMemory(memoryBytes: string): string {
|
||||
const bytes = Number(memoryBytes);
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const units = ['B', 'K', 'M', 'G', 'T', 'P'];
|
||||
let value = bytes;
|
||||
let unitIndex = 0;
|
||||
|
||||
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||
value /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
if (unitIndex === 0) {
|
||||
return `${Math.round(value)}B`;
|
||||
}
|
||||
|
||||
return `${value.toFixed(1).replace(/\.0$/, '')}${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
function formatSystemdCpu(cpuNanoseconds: string): string {
|
||||
const nanoseconds = Number(cpuNanoseconds);
|
||||
if (!Number.isFinite(nanoseconds) || nanoseconds <= 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const milliseconds = nanoseconds / 1_000_000;
|
||||
if (milliseconds < 1000) {
|
||||
return `${Math.round(milliseconds)}ms`;
|
||||
}
|
||||
|
||||
const seconds = milliseconds / 1000;
|
||||
if (seconds < 60) {
|
||||
return `${seconds.toFixed(seconds >= 10 ? 1 : 3).replace(/\.?0+$/, '')}s`;
|
||||
}
|
||||
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
if (minutes < 60) {
|
||||
return `${minutes}min ${
|
||||
remainingSeconds.toFixed(remainingSeconds >= 10 ? 1 : 3).replace(/\.?0+$/, '')
|
||||
}s`;
|
||||
}
|
||||
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const remainingMinutes = minutes % 60;
|
||||
return `${hours}h ${remainingMinutes}min`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Class for managing systemd service
|
||||
@@ -13,17 +78,17 @@ export class NupstSystemd {
|
||||
|
||||
/** Template for the systemd service file */
|
||||
private readonly serviceTemplate = `[Unit]
|
||||
Description=Node.js UPS Shutdown Tool
|
||||
Description=NUPST - Deno-powered UPS Monitoring Tool
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
ExecStart=/opt/nupst/bin/nupst daemon-start
|
||||
ExecStart=/usr/local/bin/nupst service start-daemon
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
User=root
|
||||
Group=root
|
||||
Environment=PATH=/usr/bin:/usr/local/bin
|
||||
Environment=NODE_ENV=production
|
||||
WorkingDirectory=/tmp
|
||||
WorkingDirectory=/opt/nupst
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -47,10 +112,15 @@ WantedBy=multi-user.target
|
||||
try {
|
||||
await fs.access(configPath);
|
||||
} catch (error) {
|
||||
console.error('┌─ Configuration Error ─────────────────────┐');
|
||||
console.error(`│ No configuration file found at ${configPath}`);
|
||||
console.error('│ Please run \'nupst setup\' first to create a configuration.');
|
||||
console.error('└──────────────────────────────────────────┘');
|
||||
logger.log('');
|
||||
logger.error('No configuration found');
|
||||
logger.log(` ${theme.dim('Config file:')} ${configPath}`);
|
||||
logger.log(
|
||||
` ${theme.dim('Run')} ${theme.command('nupst ups add')} ${
|
||||
theme.dim('to create a configuration')
|
||||
}`,
|
||||
);
|
||||
logger.log('');
|
||||
throw new Error('Configuration not found');
|
||||
}
|
||||
}
|
||||
@@ -63,26 +133,27 @@ WantedBy=multi-user.target
|
||||
try {
|
||||
// Check if configuration exists before installing
|
||||
await this.checkConfigExists();
|
||||
|
||||
|
||||
// Write the service file
|
||||
await fs.writeFile(this.serviceFilePath, this.serviceTemplate);
|
||||
console.log('┌─ Service Installation ─────────────────────┐');
|
||||
console.log(`│ Service file created at ${this.serviceFilePath}`);
|
||||
const boxWidth = 50;
|
||||
logger.logBoxTitle('Service Installation', boxWidth);
|
||||
logger.logBoxLine(`Service file created at ${this.serviceFilePath}`);
|
||||
|
||||
// Reload systemd daemon
|
||||
execSync('systemctl daemon-reload');
|
||||
console.log('│ Systemd daemon reloaded');
|
||||
logger.logBoxLine('Systemd daemon reloaded');
|
||||
|
||||
// Enable the service
|
||||
execSync('systemctl enable nupst.service');
|
||||
console.log('│ Service enabled to start on boot');
|
||||
console.log('└──────────────────────────────────────────┘');
|
||||
logger.logBoxLine('Service enabled to start on boot');
|
||||
logger.logBoxEnd();
|
||||
} catch (error) {
|
||||
if (error.message === 'Configuration not found') {
|
||||
if (error instanceof Error && error.message === 'Configuration not found') {
|
||||
// Just rethrow the error as the message has already been displayed
|
||||
throw error;
|
||||
}
|
||||
console.error('Failed to install systemd service:', error);
|
||||
logger.error(`Failed to install systemd service: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -95,17 +166,18 @@ WantedBy=multi-user.target
|
||||
try {
|
||||
// Check if configuration exists before starting
|
||||
await this.checkConfigExists();
|
||||
|
||||
|
||||
execSync('systemctl start nupst.service');
|
||||
console.log('┌─ Service Status ─────────────────────────┐');
|
||||
console.log('│ NUPST service started successfully');
|
||||
console.log('└──────────────────────────────────────────┘');
|
||||
const boxWidth = 45;
|
||||
logger.logBoxTitle('Service Status', boxWidth);
|
||||
logger.logBoxLine('NUPST service started successfully');
|
||||
logger.logBoxEnd();
|
||||
} catch (error) {
|
||||
if (error.message === 'Configuration not found') {
|
||||
if (error instanceof Error && error.message === 'Configuration not found') {
|
||||
// Exit with error code since configuration is required
|
||||
process.exit(1);
|
||||
}
|
||||
console.error('Failed to start service:', error);
|
||||
logger.error(`Failed to start service: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -114,12 +186,12 @@ WantedBy=multi-user.target
|
||||
* Stop the systemd service
|
||||
* @throws Error if stop fails
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
public stop(): void {
|
||||
try {
|
||||
execSync('systemctl stop nupst.service');
|
||||
console.log('NUPST service stopped');
|
||||
logger.success('NUPST service stopped');
|
||||
} catch (error) {
|
||||
console.error('Failed to stop service:', error);
|
||||
logger.error(`Failed to stop service: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -128,31 +200,83 @@ WantedBy=multi-user.target
|
||||
* Get status of the systemd service and UPS
|
||||
* @param debugMode Whether to enable debug mode for SNMP
|
||||
*/
|
||||
/**
|
||||
* Display version information and update status
|
||||
* @private
|
||||
*/
|
||||
private async displayVersionInfo(): Promise<void> {
|
||||
try {
|
||||
const nupst = this.daemon.getNupstSnmp().getNupst();
|
||||
if (!nupst) {
|
||||
return;
|
||||
}
|
||||
const version = nupst.getVersion();
|
||||
|
||||
// Check for updates
|
||||
const updateAvailable = await nupst.checkForUpdates();
|
||||
|
||||
// Display version info
|
||||
if (updateAvailable) {
|
||||
const updateStatus = nupst.getUpdateStatus();
|
||||
logger.log('');
|
||||
logger.log(
|
||||
`${theme.dim('NUPST')} ${theme.dim('v' + version)} ${symbols.warning} ${
|
||||
theme.statusWarning(`Update available: v${updateStatus.latestVersion}`)
|
||||
}`,
|
||||
);
|
||||
logger.log(
|
||||
` ${theme.dim('Run')} ${theme.command('sudo nupst upgrade')} ${theme.dim('to upgrade')}`,
|
||||
);
|
||||
} else {
|
||||
logger.log('');
|
||||
logger.log(
|
||||
`${theme.dim('NUPST')} ${theme.dim('v' + version)} ${symbols.success} ${
|
||||
theme.success('Up to date')
|
||||
}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
// If version check fails, show at least the current version
|
||||
try {
|
||||
const nupst = this.daemon.getNupstSnmp().getNupst();
|
||||
if (nupst) {
|
||||
const version = nupst.getVersion();
|
||||
logger.log('');
|
||||
logger.log(`${theme.dim('NUPST')} ${theme.dim('v' + version)}`);
|
||||
}
|
||||
} catch (_innerError) {
|
||||
// Silently fail if we can't even get the version
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async getStatus(debugMode: boolean = false): Promise<void> {
|
||||
try {
|
||||
// Enable debug mode if requested
|
||||
if (debugMode) {
|
||||
console.log('┌─ Debug Mode ─────────────────────────────┐');
|
||||
console.log('│ SNMP debugging enabled - detailed logs will be shown');
|
||||
console.log('└──────────────────────────────────────────┘');
|
||||
console.log('');
|
||||
logger.info('Debug Mode: SNMP debugging enabled');
|
||||
console.log('');
|
||||
this.daemon.getNupstSnmp().enableDebug();
|
||||
}
|
||||
|
||||
// Display version information
|
||||
this.daemon.getNupstSnmp().getNupst().logVersionInfo();
|
||||
|
||||
// Check if config exists first
|
||||
|
||||
// Display version and update status first
|
||||
await this.displayVersionInfo();
|
||||
|
||||
// Check if config exists
|
||||
try {
|
||||
await this.checkConfigExists();
|
||||
} catch (error) {
|
||||
// Error message already displayed by checkConfigExists
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
await this.displayServiceStatus();
|
||||
await this.displayUpsStatus();
|
||||
await this.displayAllUpsStatus();
|
||||
} catch (error) {
|
||||
console.error(`Failed to get status: ${error.message}`);
|
||||
logger.error(
|
||||
`Failed to get status: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,52 +284,343 @@ WantedBy=multi-user.target
|
||||
* Display the systemd service status
|
||||
* @private
|
||||
*/
|
||||
private async displayServiceStatus(): Promise<void> {
|
||||
private getServiceStatusSnapshot(): IServiceStatusSnapshot {
|
||||
const output = execFileSync(
|
||||
'systemctl',
|
||||
[
|
||||
'show',
|
||||
'nupst.service',
|
||||
'--property=LoadState,ActiveState,SubState,MainPID,MemoryCurrent,CPUUsageNSec',
|
||||
],
|
||||
{ encoding: 'utf8' },
|
||||
);
|
||||
|
||||
const properties = new Map<string, string>();
|
||||
for (const line of output.split('\n')) {
|
||||
const separatorIndex = line.indexOf('=');
|
||||
if (separatorIndex === -1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
properties.set(line.slice(0, separatorIndex), line.slice(separatorIndex + 1));
|
||||
}
|
||||
|
||||
const pid = properties.get('MainPID') || '';
|
||||
return {
|
||||
loadState: properties.get('LoadState') || '',
|
||||
activeState: properties.get('ActiveState') || '',
|
||||
subState: properties.get('SubState') || '',
|
||||
pid: pid !== '0' ? pid : '',
|
||||
memory: formatSystemdMemory(properties.get('MemoryCurrent') || ''),
|
||||
cpu: formatSystemdCpu(properties.get('CPUUsageNSec') || ''),
|
||||
};
|
||||
}
|
||||
|
||||
private displayServiceStatus(): void {
|
||||
try {
|
||||
const serviceStatus = execSync('systemctl status nupst.service').toString();
|
||||
console.log('┌─ Service Status ─────────────────────────┐');
|
||||
console.log(serviceStatus.split('\n').map(line => `│ ${line}`).join('\n'));
|
||||
console.log('└──────────────────────────────────────────┘');
|
||||
const snapshot = this.getServiceStatusSnapshot();
|
||||
|
||||
// Display beautiful status
|
||||
logger.log('');
|
||||
if (snapshot.loadState === 'not-found') {
|
||||
logger.log(
|
||||
`${symbols.stopped} ${theme.dim('Service:')} ${theme.statusInactive('not installed')}`,
|
||||
);
|
||||
} else if (snapshot.activeState === 'active') {
|
||||
const serviceState = snapshot.subState
|
||||
? `${snapshot.activeState} (${snapshot.subState})`
|
||||
: snapshot.activeState;
|
||||
logger.log(
|
||||
`${symbols.running} ${theme.success('Service:')} ${theme.statusActive(serviceState)}`,
|
||||
);
|
||||
} else {
|
||||
const serviceState = snapshot.subState && snapshot.subState !== snapshot.activeState
|
||||
? `${snapshot.activeState} (${snapshot.subState})`
|
||||
: snapshot.activeState || 'inactive';
|
||||
logger.log(
|
||||
`${symbols.stopped} ${theme.dim('Service:')} ${theme.statusInactive(serviceState)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (snapshot.pid || snapshot.memory || snapshot.cpu) {
|
||||
const details = [];
|
||||
if (snapshot.pid) details.push(`PID: ${theme.dim(snapshot.pid)}`);
|
||||
if (snapshot.memory) details.push(`Memory: ${theme.dim(snapshot.memory)}`);
|
||||
if (snapshot.cpu) details.push(`CPU: ${theme.dim(snapshot.cpu)}`);
|
||||
logger.log(` ${details.join(' ')}`);
|
||||
}
|
||||
logger.log('');
|
||||
} catch (error) {
|
||||
console.error('┌─ Service Status ─────────────────────────┐');
|
||||
console.error('│ Service is not running');
|
||||
console.error('└──────────────────────────────────────────┘');
|
||||
logger.log('');
|
||||
logger.log(
|
||||
`${symbols.stopped} ${theme.dim('Service:')} ${theme.statusInactive('not installed')}`,
|
||||
);
|
||||
logger.log('');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the UPS status
|
||||
* Display all UPS statuses
|
||||
* @private
|
||||
*/
|
||||
private async displayUpsStatus(): Promise<void> {
|
||||
private async displayAllUpsStatus(): Promise<void> {
|
||||
try {
|
||||
// Explicitly load the configuration first to ensure it's up-to-date
|
||||
await this.daemon.loadConfig();
|
||||
const config = this.daemon.getConfig();
|
||||
const snmp = this.daemon.getNupstSnmp();
|
||||
|
||||
// Create a test config with appropriate timeout, similar to the test command
|
||||
const snmpConfig = {
|
||||
...config.snmp,
|
||||
timeout: Math.min(config.snmp.timeout, 10000) // Use at most 10 seconds for status check
|
||||
};
|
||||
|
||||
console.log('┌─ Connecting to UPS... ────────────────────┐');
|
||||
console.log(`│ Host: ${config.snmp.host}:${config.snmp.port}`);
|
||||
console.log(`│ UPS Model: ${config.snmp.upsModel || 'cyberpower'}`);
|
||||
console.log('└──────────────────────────────────────────┘');
|
||||
|
||||
const status = await snmp.getUpsStatus(snmpConfig);
|
||||
|
||||
console.log('┌─ UPS Status ───────────────────────────────┐');
|
||||
console.log(`│ Power Status: ${status.powerStatus}`);
|
||||
console.log(`│ Battery Capacity: ${status.batteryCapacity}%`);
|
||||
console.log(`│ Runtime Remaining: ${status.batteryRuntime} minutes`);
|
||||
console.log('└──────────────────────────────────────────┘');
|
||||
|
||||
// Check if we have the new multi-UPS config format
|
||||
if (config.upsDevices && Array.isArray(config.upsDevices) && config.upsDevices.length > 0) {
|
||||
logger.info(`UPS Devices (${config.upsDevices.length}):`);
|
||||
|
||||
// Show status for each UPS
|
||||
for (const ups of config.upsDevices) {
|
||||
await this.displaySingleUpsStatus(ups, snmp);
|
||||
}
|
||||
|
||||
// Display groups after UPS devices
|
||||
this.displayGroupsStatus();
|
||||
} else if (config.snmp) {
|
||||
// Legacy single UPS configuration (v1/v2 format)
|
||||
logger.info('UPS Devices (1):');
|
||||
const legacyUps: IUpsConfig = {
|
||||
id: 'default',
|
||||
name: 'Default UPS',
|
||||
snmp: config.snmp,
|
||||
groups: [],
|
||||
actions: config.thresholds
|
||||
? [
|
||||
{
|
||||
type: 'shutdown',
|
||||
thresholds: config.thresholds,
|
||||
triggerMode: 'onlyThresholds',
|
||||
},
|
||||
]
|
||||
: [],
|
||||
};
|
||||
|
||||
await this.displaySingleUpsStatus(legacyUps, snmp);
|
||||
} else {
|
||||
logger.log('');
|
||||
logger.warn('No UPS devices configured');
|
||||
logger.log(
|
||||
` ${theme.dim('Run')} ${theme.command('nupst ups add')} ${theme.dim('to add a device')}`,
|
||||
);
|
||||
logger.log('');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('┌─ UPS Status ───────────────────────────────┐');
|
||||
console.error(`│ Failed to retrieve UPS status: ${error.message}`);
|
||||
console.error('└──────────────────────────────────────────┘');
|
||||
logger.log('');
|
||||
logger.error('Failed to retrieve UPS status');
|
||||
logger.log(` ${theme.dim(error instanceof Error ? error.message : String(error))}`);
|
||||
logger.log('');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display status of a single UPS
|
||||
* @param ups UPS configuration
|
||||
* @param snmp SNMP manager
|
||||
*/
|
||||
private async displaySingleUpsStatus(ups: IUpsConfig, snmp: NupstSnmp): Promise<void> {
|
||||
try {
|
||||
const defaultShutdownDelay = this.daemon.getConfig().defaultShutdownDelay ??
|
||||
SHUTDOWN.DEFAULT_DELAY_MINUTES;
|
||||
const protocol = ups.protocol || 'snmp';
|
||||
let status;
|
||||
|
||||
if (protocol === 'upsd' && ups.upsd) {
|
||||
const testConfig = {
|
||||
...ups.upsd,
|
||||
timeout: Math.min(ups.upsd.timeout, 10000),
|
||||
};
|
||||
status = await this.daemon.getNupstUpsd().getUpsStatus(testConfig);
|
||||
} else if (ups.snmp) {
|
||||
const testConfig = {
|
||||
...ups.snmp,
|
||||
timeout: Math.min(ups.snmp.timeout, 10000),
|
||||
};
|
||||
status = await snmp.getUpsStatus(testConfig);
|
||||
} else {
|
||||
throw new Error('No protocol configuration found');
|
||||
}
|
||||
|
||||
// Determine status symbol based on power status
|
||||
let statusSymbol = symbols.unknown;
|
||||
if (status.powerStatus === 'online') {
|
||||
statusSymbol = symbols.running;
|
||||
} else if (status.powerStatus === 'onBattery') {
|
||||
statusSymbol = symbols.warning;
|
||||
}
|
||||
|
||||
// Display UPS name and power status
|
||||
logger.log(
|
||||
` ${statusSymbol} ${theme.highlight(ups.name)} - ${formatPowerStatus(status.powerStatus)}`,
|
||||
);
|
||||
|
||||
// Display battery with color coding
|
||||
const batteryColor = getBatteryColor(status.batteryCapacity);
|
||||
|
||||
// Get threshold from actions (if any action has thresholds defined)
|
||||
const actionWithThresholds = ups.actions?.find((action) => action.thresholds);
|
||||
const batteryThreshold = actionWithThresholds?.thresholds?.battery;
|
||||
const batterySymbol =
|
||||
batteryThreshold !== undefined && status.batteryCapacity >= batteryThreshold
|
||||
? symbols.success
|
||||
: batteryThreshold !== undefined
|
||||
? symbols.warning
|
||||
: '';
|
||||
|
||||
logger.log(
|
||||
` Battery: ${batteryColor(status.batteryCapacity + '%')} ${batterySymbol} Runtime: ${
|
||||
getRuntimeColor(status.batteryRuntime)(status.batteryRuntime + ' min')
|
||||
}`,
|
||||
);
|
||||
|
||||
// Display power metrics
|
||||
logger.log(
|
||||
` Load: ${theme.highlight(status.outputLoad + '%')} Power: ${
|
||||
theme.highlight(status.outputPower + 'W')
|
||||
} Voltage: ${theme.highlight(status.outputVoltage + 'V')} Current: ${
|
||||
theme.highlight(status.outputCurrent + 'A')
|
||||
}`,
|
||||
);
|
||||
|
||||
// Display host info
|
||||
const hostInfo = protocol === 'upsd' && ups.upsd
|
||||
? `${ups.upsd.host}:${ups.upsd.port} (UPSD)`
|
||||
: ups.snmp
|
||||
? `${ups.snmp.host}:${ups.snmp.port} (SNMP)`
|
||||
: 'N/A';
|
||||
logger.log(` ${theme.dim(`Host: ${hostInfo}`)}`);
|
||||
|
||||
// Display groups if any
|
||||
if (ups.groups && ups.groups.length > 0) {
|
||||
const config = this.daemon.getConfig();
|
||||
const groupNames = ups.groups.map((groupId: string) => {
|
||||
const group = config.groups?.find((g: { id: string }) => g.id === groupId);
|
||||
return group ? group.name : groupId;
|
||||
});
|
||||
logger.log(` ${theme.dim(`Groups: ${groupNames.join(', ')}`)}`);
|
||||
}
|
||||
|
||||
// Display actions if any
|
||||
if (ups.actions && ups.actions.length > 0) {
|
||||
for (const action of ups.actions) {
|
||||
let actionDesc = `${action.type}`;
|
||||
if (action.thresholds) {
|
||||
actionDesc += ` (${
|
||||
action.triggerMode || 'onlyThresholds'
|
||||
}: battery<${action.thresholds.battery}%, runtime<${action.thresholds.runtime}min`;
|
||||
if (action.type === 'shutdown') {
|
||||
const shutdownDelay = action.shutdownDelay ?? defaultShutdownDelay;
|
||||
actionDesc += `, delay=${shutdownDelay}min`;
|
||||
} else if (action.type === 'proxmox' && action.proxmoxHaPolicy === 'haStop') {
|
||||
actionDesc += ', ha=stop';
|
||||
}
|
||||
actionDesc += ')';
|
||||
} else {
|
||||
actionDesc += ` (${action.triggerMode || 'onlyPowerChanges'}`;
|
||||
if (action.type === 'shutdown') {
|
||||
const shutdownDelay = action.shutdownDelay ?? defaultShutdownDelay;
|
||||
actionDesc += `, delay=${shutdownDelay}min`;
|
||||
} else if (action.type === 'proxmox' && action.proxmoxHaPolicy === 'haStop') {
|
||||
actionDesc += ', ha=stop';
|
||||
}
|
||||
actionDesc += ')';
|
||||
}
|
||||
logger.log(` ${theme.dim('Action:')} ${theme.info(actionDesc)}`);
|
||||
}
|
||||
}
|
||||
|
||||
logger.log('');
|
||||
} catch (error) {
|
||||
// Display error for this UPS
|
||||
const errorHostInfo = (ups.protocol || 'snmp') === 'upsd' && ups.upsd
|
||||
? `${ups.upsd.host}:${ups.upsd.port} (UPSD)`
|
||||
: ups.snmp
|
||||
? `${ups.snmp.host}:${ups.snmp.port} (SNMP)`
|
||||
: 'N/A';
|
||||
logger.log(
|
||||
` ${symbols.error} ${theme.highlight(ups.name)} - ${theme.error('Connection failed')}`,
|
||||
);
|
||||
logger.log(` ${theme.dim(error instanceof Error ? error.message : String(error))}`);
|
||||
logger.log(` ${theme.dim(`Host: ${errorHostInfo}`)}`);
|
||||
logger.log('');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display status of all groups
|
||||
* @private
|
||||
*/
|
||||
private displayGroupsStatus(): void {
|
||||
const config = this.daemon.getConfig();
|
||||
|
||||
if (!config.groups || config.groups.length === 0) {
|
||||
return; // No groups to display
|
||||
}
|
||||
|
||||
logger.log('');
|
||||
logger.info(`Groups (${config.groups.length}):`);
|
||||
|
||||
for (const group of config.groups) {
|
||||
// Display group name and mode
|
||||
const modeColor = group.mode === 'redundant' ? theme.success : theme.warning;
|
||||
logger.log(
|
||||
` ${symbols.info} ${theme.highlight(group.name)} ${
|
||||
theme.dim(`(${modeColor(group.mode)})`)
|
||||
}`,
|
||||
);
|
||||
|
||||
// Display description if present
|
||||
if (group.description) {
|
||||
logger.log(` ${theme.dim(group.description)}`);
|
||||
}
|
||||
|
||||
// Display UPS devices in this group
|
||||
const upsInGroup = config.upsDevices.filter((ups) =>
|
||||
ups.groups && ups.groups.includes(group.id)
|
||||
);
|
||||
|
||||
if (upsInGroup.length > 0) {
|
||||
const upsNames = upsInGroup.map((ups) => ups.name).join(', ');
|
||||
logger.log(` ${theme.dim(`UPS Devices (${upsInGroup.length}):`)} ${upsNames}`);
|
||||
} else {
|
||||
logger.log(` ${theme.dim('UPS Devices: None')}`);
|
||||
}
|
||||
|
||||
// Display actions if any
|
||||
if (group.actions && group.actions.length > 0) {
|
||||
const defaultShutdownDelay = config.defaultShutdownDelay ?? SHUTDOWN.DEFAULT_DELAY_MINUTES;
|
||||
for (const action of group.actions) {
|
||||
let actionDesc = `${action.type}`;
|
||||
if (action.thresholds) {
|
||||
actionDesc += ` (${
|
||||
action.triggerMode || 'onlyThresholds'
|
||||
}: battery<${action.thresholds.battery}%, runtime<${action.thresholds.runtime}min`;
|
||||
if (action.type === 'shutdown') {
|
||||
const shutdownDelay = action.shutdownDelay ?? defaultShutdownDelay;
|
||||
actionDesc += `, delay=${shutdownDelay}min`;
|
||||
} else if (action.type === 'proxmox' && action.proxmoxHaPolicy === 'haStop') {
|
||||
actionDesc += ', ha=stop';
|
||||
}
|
||||
actionDesc += ')';
|
||||
} else {
|
||||
actionDesc += ` (${action.triggerMode || 'onlyPowerChanges'}`;
|
||||
if (action.type === 'shutdown') {
|
||||
const shutdownDelay = action.shutdownDelay ?? defaultShutdownDelay;
|
||||
actionDesc += `, delay=${shutdownDelay}min`;
|
||||
} else if (action.type === 'proxmox' && action.proxmoxHaPolicy === 'haStop') {
|
||||
actionDesc += ', ha=stop';
|
||||
}
|
||||
actionDesc += ')';
|
||||
}
|
||||
logger.log(` ${theme.dim('Action:')} ${theme.info(actionDesc)}`);
|
||||
}
|
||||
}
|
||||
|
||||
logger.log('');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,13 +633,13 @@ WantedBy=multi-user.target
|
||||
await this.stopService();
|
||||
await this.disableService();
|
||||
await this.removeServiceFile();
|
||||
|
||||
|
||||
// Reload systemd daemon
|
||||
execSync('systemctl daemon-reload');
|
||||
console.log('Systemd daemon reloaded');
|
||||
console.log('NUPST service has been successfully uninstalled');
|
||||
logger.log('Systemd daemon reloaded');
|
||||
logger.success('NUPST service has been successfully uninstalled');
|
||||
} catch (error) {
|
||||
console.error('Failed to disable and uninstall service:', error);
|
||||
logger.error(`Failed to disable and uninstall service: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -233,13 +648,13 @@ WantedBy=multi-user.target
|
||||
* Stop the service if it's running
|
||||
* @private
|
||||
*/
|
||||
private async stopService(): Promise<void> {
|
||||
private stopService(): void {
|
||||
try {
|
||||
console.log('Stopping NUPST service...');
|
||||
logger.log('Stopping NUPST service...');
|
||||
execSync('systemctl stop nupst.service');
|
||||
} catch (error) {
|
||||
// Service might not be running, that's okay
|
||||
console.log('Service was not running or could not be stopped');
|
||||
logger.log('Service was not running or could not be stopped');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,12 +662,12 @@ WantedBy=multi-user.target
|
||||
* Disable the service
|
||||
* @private
|
||||
*/
|
||||
private async disableService(): Promise<void> {
|
||||
private disableService(): void {
|
||||
try {
|
||||
console.log('Disabling NUPST service...');
|
||||
logger.log('Disabling NUPST service...');
|
||||
execSync('systemctl disable nupst.service');
|
||||
} catch (error) {
|
||||
console.log('Service was not enabled or could not be disabled');
|
||||
logger.log('Service was not enabled or could not be disabled');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,11 +677,11 @@ WantedBy=multi-user.target
|
||||
*/
|
||||
private async removeServiceFile(): Promise<void> {
|
||||
if (await fs.stat(this.serviceFilePath).catch(() => null)) {
|
||||
console.log(`Removing service file ${this.serviceFilePath}...`);
|
||||
logger.log(`Removing service file ${this.serviceFilePath}...`);
|
||||
await fs.unlink(this.serviceFilePath);
|
||||
console.log('Service file removed');
|
||||
logger.log('Service file removed');
|
||||
} else {
|
||||
console.log('Service file did not exist');
|
||||
logger.log('Service file did not exist');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { SmartChangelog } from 'npm:@push.rocks/smartchangelog@^0.1.0';
|
||||
|
||||
export const renderUpgradeChangelog = (
|
||||
changelogMarkdown: string,
|
||||
currentVersion: string,
|
||||
latestVersion: string,
|
||||
): string => {
|
||||
const changelog = SmartChangelog.fromMarkdown(changelogMarkdown);
|
||||
const entries = changelog.getEntriesBetween(currentVersion, latestVersion);
|
||||
|
||||
if (entries.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return entries.map((entry) => entry.toCliString()).join('\n\n');
|
||||
};
|
||||
@@ -0,0 +1,172 @@
|
||||
import type { IActionConfig } from './actions/base-action.ts';
|
||||
import { NETWORK } from './constants.ts';
|
||||
import type { IUpsStatus as IProtocolUpsStatus } from './snmp/types.ts';
|
||||
import { createInitialUpsStatus, type IUpsIdentity, type IUpsStatus } from './ups-status.ts';
|
||||
|
||||
export interface ISuccessfulUpsPollSnapshot {
|
||||
updatedStatus: IUpsStatus;
|
||||
transition: 'none' | 'recovered' | 'powerStatusChange';
|
||||
previousStatus?: IUpsStatus;
|
||||
downtimeSeconds?: number;
|
||||
}
|
||||
|
||||
export interface IFailedUpsPollSnapshot {
|
||||
updatedStatus: IUpsStatus;
|
||||
transition: 'none' | 'unreachable';
|
||||
failures: number;
|
||||
previousStatus?: IUpsStatus;
|
||||
}
|
||||
|
||||
export function ensureUpsStatus(
|
||||
currentStatus: IUpsStatus | undefined,
|
||||
ups: IUpsIdentity,
|
||||
now: number = Date.now(),
|
||||
): IUpsStatus {
|
||||
return currentStatus || createInitialUpsStatus(ups, now);
|
||||
}
|
||||
|
||||
export function buildSuccessfulUpsPollSnapshot(
|
||||
ups: IUpsIdentity,
|
||||
polledStatus: IProtocolUpsStatus,
|
||||
currentStatus: IUpsStatus | undefined,
|
||||
currentTime: number,
|
||||
): ISuccessfulUpsPollSnapshot {
|
||||
const previousStatus = ensureUpsStatus(currentStatus, ups, currentTime);
|
||||
const updatedStatus: IUpsStatus = {
|
||||
id: ups.id,
|
||||
name: ups.name,
|
||||
powerStatus: polledStatus.powerStatus,
|
||||
batteryCapacity: polledStatus.batteryCapacity,
|
||||
batteryRuntime: polledStatus.batteryRuntime,
|
||||
outputLoad: polledStatus.outputLoad,
|
||||
outputPower: polledStatus.outputPower,
|
||||
outputVoltage: polledStatus.outputVoltage,
|
||||
outputCurrent: polledStatus.outputCurrent,
|
||||
lastCheckTime: currentTime,
|
||||
lastStatusChange: previousStatus.lastStatusChange || currentTime,
|
||||
consecutiveFailures: 0,
|
||||
unreachableSince: 0,
|
||||
};
|
||||
|
||||
if (previousStatus.powerStatus === 'unreachable') {
|
||||
updatedStatus.lastStatusChange = currentTime;
|
||||
return {
|
||||
updatedStatus,
|
||||
transition: 'recovered',
|
||||
previousStatus,
|
||||
downtimeSeconds: Math.round((currentTime - previousStatus.unreachableSince) / 1000),
|
||||
};
|
||||
}
|
||||
|
||||
if (previousStatus.powerStatus !== polledStatus.powerStatus) {
|
||||
updatedStatus.lastStatusChange = currentTime;
|
||||
return {
|
||||
updatedStatus,
|
||||
transition: 'powerStatusChange',
|
||||
previousStatus,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
updatedStatus,
|
||||
transition: 'none',
|
||||
previousStatus: currentStatus,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildFailedUpsPollSnapshot(
|
||||
ups: IUpsIdentity,
|
||||
currentStatus: IUpsStatus | undefined,
|
||||
currentTime: number,
|
||||
): IFailedUpsPollSnapshot {
|
||||
const previousStatus = ensureUpsStatus(currentStatus, ups, currentTime);
|
||||
const failures = Math.min(
|
||||
previousStatus.consecutiveFailures + 1,
|
||||
NETWORK.MAX_CONSECUTIVE_FAILURES,
|
||||
);
|
||||
|
||||
if (
|
||||
failures >= NETWORK.CONSECUTIVE_FAILURE_THRESHOLD &&
|
||||
previousStatus.powerStatus !== 'unreachable'
|
||||
) {
|
||||
return {
|
||||
updatedStatus: {
|
||||
...previousStatus,
|
||||
consecutiveFailures: failures,
|
||||
powerStatus: 'unreachable',
|
||||
unreachableSince: currentTime,
|
||||
lastStatusChange: currentTime,
|
||||
},
|
||||
transition: 'unreachable',
|
||||
failures,
|
||||
previousStatus,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
updatedStatus: {
|
||||
...previousStatus,
|
||||
consecutiveFailures: failures,
|
||||
},
|
||||
transition: 'none',
|
||||
failures,
|
||||
previousStatus: currentStatus,
|
||||
};
|
||||
}
|
||||
|
||||
export function hasThresholdViolation(
|
||||
powerStatus: IProtocolUpsStatus['powerStatus'],
|
||||
batteryCapacity: number,
|
||||
batteryRuntime: number,
|
||||
actions: IActionConfig[] | undefined,
|
||||
): boolean {
|
||||
return getActionThresholdStates(powerStatus, batteryCapacity, batteryRuntime, actions).some(
|
||||
Boolean,
|
||||
);
|
||||
}
|
||||
|
||||
export function isActionThresholdExceeded(
|
||||
actionConfig: IActionConfig,
|
||||
powerStatus: IProtocolUpsStatus['powerStatus'],
|
||||
batteryCapacity: number,
|
||||
batteryRuntime: number,
|
||||
): boolean {
|
||||
if (powerStatus !== 'onBattery' || !actionConfig.thresholds) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
batteryCapacity < actionConfig.thresholds.battery ||
|
||||
batteryRuntime < actionConfig.thresholds.runtime
|
||||
);
|
||||
}
|
||||
|
||||
export function getActionThresholdStates(
|
||||
powerStatus: IProtocolUpsStatus['powerStatus'],
|
||||
batteryCapacity: number,
|
||||
batteryRuntime: number,
|
||||
actions: IActionConfig[] | undefined,
|
||||
): boolean[] {
|
||||
if (!actions || actions.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return actions.map((actionConfig) =>
|
||||
isActionThresholdExceeded(actionConfig, powerStatus, batteryCapacity, batteryRuntime)
|
||||
);
|
||||
}
|
||||
|
||||
export function getEnteredThresholdIndexes(
|
||||
previousStates: boolean[] | undefined,
|
||||
currentStates: boolean[],
|
||||
): number[] {
|
||||
const enteredIndexes: number[] = [];
|
||||
|
||||
for (let index = 0; index < currentStates.length; index++) {
|
||||
if (currentStates[index] && !previousStates?.[index]) {
|
||||
enteredIndexes.push(index);
|
||||
}
|
||||
}
|
||||
|
||||
return enteredIndexes;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
export interface IUpsIdentity {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface IUpsStatus {
|
||||
id: string;
|
||||
name: string;
|
||||
powerStatus: 'online' | 'onBattery' | 'unknown' | 'unreachable';
|
||||
batteryCapacity: number;
|
||||
batteryRuntime: number;
|
||||
outputLoad: number;
|
||||
outputPower: number;
|
||||
outputVoltage: number;
|
||||
outputCurrent: number;
|
||||
lastStatusChange: number;
|
||||
lastCheckTime: number;
|
||||
consecutiveFailures: number;
|
||||
unreachableSince: number;
|
||||
}
|
||||
|
||||
export function createInitialUpsStatus(ups: IUpsIdentity, now: number = Date.now()): IUpsStatus {
|
||||
return {
|
||||
id: ups.id,
|
||||
name: ups.name,
|
||||
powerStatus: 'unknown',
|
||||
batteryCapacity: 100,
|
||||
batteryRuntime: 999,
|
||||
outputLoad: 0,
|
||||
outputPower: 0,
|
||||
outputVoltage: 0,
|
||||
outputCurrent: 0,
|
||||
lastStatusChange: now,
|
||||
lastCheckTime: 0,
|
||||
consecutiveFailures: 0,
|
||||
unreachableSince: 0,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* UPSD/NIS (Network UPS Tools) TCP client
|
||||
*
|
||||
* Connects to a NUT upsd server via TCP and queries UPS variables
|
||||
* using the NUT network protocol (RFC-style line protocol).
|
||||
*
|
||||
* Protocol format:
|
||||
* Request: GET VAR <upsname> <varname>\n
|
||||
* Response: VAR <upsname> <varname> "<value>"\n
|
||||
* Logout: LOGOUT\n
|
||||
*/
|
||||
|
||||
import * as net from 'node:net';
|
||||
import { logger } from '../logger.ts';
|
||||
import { UPSD } from '../constants.ts';
|
||||
import type { IUpsdConfig } from './types.ts';
|
||||
import type { IUpsStatus } from '../snmp/types.ts';
|
||||
|
||||
/**
|
||||
* NupstUpsd - TCP client for the NUT UPSD protocol
|
||||
*/
|
||||
export class NupstUpsd {
|
||||
private debug = false;
|
||||
|
||||
/**
|
||||
* Enable debug logging
|
||||
*/
|
||||
public enableDebug(): void {
|
||||
this.debug = true;
|
||||
logger.info('UPSD debug mode enabled');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current UPS status via UPSD protocol
|
||||
* @param config UPSD connection configuration
|
||||
* @returns UPS status matching the IUpsStatus interface
|
||||
*/
|
||||
public async getUpsStatus(config: IUpsdConfig): Promise<IUpsStatus> {
|
||||
const host = config.host || '127.0.0.1';
|
||||
const port = config.port || UPSD.DEFAULT_PORT;
|
||||
const upsName = config.upsName || UPSD.DEFAULT_UPS_NAME;
|
||||
const timeout = config.timeout || UPSD.DEFAULT_TIMEOUT_MS;
|
||||
|
||||
if (this.debug) {
|
||||
logger.dim('---------------------------------------');
|
||||
logger.dim('Getting UPS status via UPSD protocol:');
|
||||
logger.dim(` Host: ${host}:${port}`);
|
||||
logger.dim(` UPS Name: ${upsName}`);
|
||||
logger.dim(` Timeout: ${timeout}ms`);
|
||||
logger.dim('---------------------------------------');
|
||||
}
|
||||
|
||||
// Variables to query from NUT
|
||||
const varsToQuery = [
|
||||
'ups.status',
|
||||
'battery.charge',
|
||||
'battery.runtime',
|
||||
'ups.load',
|
||||
'ups.realpower',
|
||||
'output.voltage',
|
||||
'output.current',
|
||||
];
|
||||
|
||||
const values = new Map<string, string>();
|
||||
|
||||
// Open a TCP connection, query all variables, then logout
|
||||
const conn = await this.connect(host, port, timeout);
|
||||
|
||||
try {
|
||||
// Authenticate if credentials provided
|
||||
if (config.username && config.password) {
|
||||
await this.sendCommand(conn, `USERNAME ${config.username}`, timeout);
|
||||
await this.sendCommand(conn, `PASSWORD ${config.password}`, timeout);
|
||||
}
|
||||
|
||||
// Query each variable
|
||||
for (const varName of varsToQuery) {
|
||||
const value = await this.safeGetVar(conn, upsName, varName, timeout);
|
||||
if (value !== null) {
|
||||
values.set(varName, value);
|
||||
}
|
||||
}
|
||||
|
||||
// Logout gracefully
|
||||
try {
|
||||
await this.sendCommand(conn, 'LOGOUT', timeout);
|
||||
} catch (_e) {
|
||||
// Ignore logout errors
|
||||
}
|
||||
} finally {
|
||||
conn.destroy();
|
||||
}
|
||||
|
||||
// Map NUT variables to IUpsStatus
|
||||
const powerStatus = this.parsePowerStatus(values.get('ups.status') || '');
|
||||
const batteryCapacity = parseFloat(values.get('battery.charge') || '0');
|
||||
const batteryRuntimeSeconds = parseFloat(values.get('battery.runtime') || '0');
|
||||
const batteryRuntime = Math.floor(batteryRuntimeSeconds / 60); // NUT reports seconds, convert to minutes
|
||||
const outputLoad = parseFloat(values.get('ups.load') || '0');
|
||||
const outputPower = parseFloat(values.get('ups.realpower') || '0');
|
||||
const outputVoltage = parseFloat(values.get('output.voltage') || '0');
|
||||
const outputCurrent = parseFloat(values.get('output.current') || '0');
|
||||
|
||||
const result: IUpsStatus = {
|
||||
powerStatus,
|
||||
batteryCapacity: isNaN(batteryCapacity) ? 0 : batteryCapacity,
|
||||
batteryRuntime: isNaN(batteryRuntime) ? 0 : batteryRuntime,
|
||||
outputLoad: isNaN(outputLoad) ? 0 : outputLoad,
|
||||
outputPower: isNaN(outputPower) ? 0 : outputPower,
|
||||
outputVoltage: isNaN(outputVoltage) ? 0 : outputVoltage,
|
||||
outputCurrent: isNaN(outputCurrent) ? 0 : outputCurrent,
|
||||
raw: Object.fromEntries(values),
|
||||
};
|
||||
|
||||
if (this.debug) {
|
||||
logger.dim('---------------------------------------');
|
||||
logger.dim('UPSD status result:');
|
||||
logger.dim(` Power Status: ${result.powerStatus}`);
|
||||
logger.dim(` Battery Capacity: ${result.batteryCapacity}%`);
|
||||
logger.dim(` Battery Runtime: ${result.batteryRuntime} minutes`);
|
||||
logger.dim(` Output Load: ${result.outputLoad}%`);
|
||||
logger.dim(` Output Power: ${result.outputPower} watts`);
|
||||
logger.dim(` Output Voltage: ${result.outputVoltage} volts`);
|
||||
logger.dim(` Output Current: ${result.outputCurrent} amps`);
|
||||
logger.dim('---------------------------------------');
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a TCP connection to the UPSD server
|
||||
*/
|
||||
private connect(host: string, port: number, timeout: number): Promise<net.Socket> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = net.createConnection({ host, port }, () => {
|
||||
if (this.debug) {
|
||||
logger.dim(`Connected to UPSD at ${host}:${port}`);
|
||||
}
|
||||
resolve(socket);
|
||||
});
|
||||
|
||||
socket.setTimeout(timeout);
|
||||
socket.on('timeout', () => {
|
||||
socket.destroy();
|
||||
reject(new Error(`UPSD connection timed out after ${timeout}ms`));
|
||||
});
|
||||
socket.on('error', (err) => {
|
||||
reject(new Error(`UPSD connection error: ${err.message}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a command and read the response line
|
||||
*/
|
||||
private sendCommand(socket: net.Socket, command: string, timeout: number): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let responseData = '';
|
||||
const timer = setTimeout(() => {
|
||||
cleanup();
|
||||
reject(new Error(`UPSD command timed out: ${command}`));
|
||||
}, timeout);
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
const onData = (data: Uint8Array) => {
|
||||
responseData += decoder.decode(data, { stream: true });
|
||||
// Look for newline to indicate end of response
|
||||
const newlineIdx = responseData.indexOf('\n');
|
||||
if (newlineIdx !== -1) {
|
||||
cleanup();
|
||||
const line = responseData.substring(0, newlineIdx).trim();
|
||||
if (this.debug) {
|
||||
logger.dim(`UPSD << ${line}`);
|
||||
}
|
||||
resolve(line);
|
||||
}
|
||||
};
|
||||
|
||||
const onError = (err: Error) => {
|
||||
cleanup();
|
||||
reject(err);
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timer);
|
||||
socket.removeListener('data', onData);
|
||||
socket.removeListener('error', onError);
|
||||
};
|
||||
|
||||
socket.on('data', onData);
|
||||
socket.on('error', onError);
|
||||
|
||||
if (this.debug) {
|
||||
logger.dim(`UPSD >> ${command}`);
|
||||
}
|
||||
socket.write(command + '\n');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely get a single NUT variable, returning null on error
|
||||
*/
|
||||
private async safeGetVar(
|
||||
socket: net.Socket,
|
||||
upsName: string,
|
||||
varName: string,
|
||||
timeout: number,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const response = await this.sendCommand(
|
||||
socket,
|
||||
`GET VAR ${upsName} ${varName}`,
|
||||
timeout,
|
||||
);
|
||||
|
||||
// Expected response: VAR <upsname> <varname> "<value>"
|
||||
// Also handle: ERR ... for unsupported variables
|
||||
if (response.startsWith('ERR')) {
|
||||
if (this.debug) {
|
||||
logger.dim(`UPSD variable ${varName} not available: ${response}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Parse: VAR ups battery.charge "100"
|
||||
const match = response.match(/^VAR\s+\S+\s+\S+\s+"(.*)"/);
|
||||
if (match) {
|
||||
return match[1];
|
||||
}
|
||||
|
||||
// Some implementations don't quote the value
|
||||
const parts = response.split(/\s+/);
|
||||
if (parts.length >= 4 && parts[0] === 'VAR') {
|
||||
return parts.slice(3).join(' ').replace(/^"/, '').replace(/"$/, '');
|
||||
}
|
||||
|
||||
if (this.debug) {
|
||||
logger.dim(`UPSD unexpected response for ${varName}: ${response}`);
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
if (this.debug) {
|
||||
logger.dim(
|
||||
`UPSD error getting ${varName}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse NUT ups.status tokens into a power status
|
||||
* NUT status tokens: OL (online), OB (on battery), LB (low battery),
|
||||
* HB (high battery), RB (replace battery), CHRG (charging), etc.
|
||||
*/
|
||||
private parsePowerStatus(statusString: string): 'online' | 'onBattery' | 'unknown' {
|
||||
const tokens = statusString.trim().split(/\s+/);
|
||||
|
||||
if (tokens.includes('OB')) {
|
||||
return 'onBattery';
|
||||
}
|
||||
if (tokens.includes('OL')) {
|
||||
return 'online';
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* UPSD/NIS protocol module
|
||||
* Re-exports public types and classes
|
||||
*/
|
||||
|
||||
export type { IUpsdConfig } from './types.ts';
|
||||
export { NupstUpsd } from './client.ts';
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Type definitions for UPSD/NIS (Network UPS Tools) protocol module
|
||||
*/
|
||||
|
||||
/**
|
||||
* UPSD connection configuration
|
||||
*/
|
||||
export interface IUpsdConfig {
|
||||
/** UPSD server host (default: 127.0.0.1) */
|
||||
host: string;
|
||||
/** UPSD server port (default: 3493) */
|
||||
port: number;
|
||||
/** NUT device name (default: 'ups') */
|
||||
upsName: string;
|
||||
/** Connection timeout in milliseconds (default: 5000) */
|
||||
timeout: number;
|
||||
/** Optional username for authentication */
|
||||
username?: string;
|
||||
/** Optional password for authentication */
|
||||
password?: string;
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"useDefineForClassFields": false,
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"esModuleInterop": true,
|
||||
"verbatimModuleSyntax": true
|
||||
},
|
||||
"exclude": [
|
||||
"dist_*/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user