Compare commits
58 Commits
Author | SHA1 | Date | |
---|---|---|---|
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 |
@@ -21,7 +21,7 @@ jobs:
|
||||
- name: Set up Deno
|
||||
uses: denoland/setup-deno@v1
|
||||
with:
|
||||
deno-version: v1.x
|
||||
deno-version: v2.x
|
||||
|
||||
- name: Check TypeScript types
|
||||
run: deno check mod.ts
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
- name: Set up Deno
|
||||
uses: denoland/setup-deno@v1
|
||||
with:
|
||||
deno-version: v1.x
|
||||
deno-version: v2.x
|
||||
|
||||
- name: Compile for current platform
|
||||
run: |
|
||||
@@ -71,7 +71,7 @@ jobs:
|
||||
- name: Set up Deno
|
||||
uses: denoland/setup-deno@v1
|
||||
with:
|
||||
deno-version: v1.x
|
||||
deno-version: v2.x
|
||||
|
||||
- name: Compile all platform binaries
|
||||
run: bash scripts/compile-all.sh
|
||||
|
129
.gitea/workflows/npm-publish.yml
Normal file
129
.gitea/workflows/npm-publish.yml
Normal file
@@ -0,0 +1,129 @@
|
||||
name: Publish to npm
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
npm-publish:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Deno
|
||||
uses: denoland/setup-deno@v1
|
||||
with:
|
||||
deno-version: v2.x
|
||||
|
||||
- name: Setup Node.js for npm publishing
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18.x'
|
||||
registry-url: 'https://registry.npmjs.org/'
|
||||
|
||||
- name: Get version from tag
|
||||
id: version
|
||||
run: |
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "version_number=${VERSION#v}" >> $GITHUB_OUTPUT
|
||||
echo "Publishing version: $VERSION"
|
||||
|
||||
- name: Verify deno.json version matches tag
|
||||
run: |
|
||||
DENO_VERSION=$(grep -o '"version": "[^"]*"' deno.json | cut -d'"' -f4)
|
||||
TAG_VERSION="${{ steps.version.outputs.version_number }}"
|
||||
echo "deno.json version: $DENO_VERSION"
|
||||
echo "Tag version: $TAG_VERSION"
|
||||
if [ "$DENO_VERSION" != "$TAG_VERSION" ]; then
|
||||
echo "ERROR: Version mismatch!"
|
||||
echo "deno.json has version $DENO_VERSION but tag is $TAG_VERSION"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Compile binaries for npm package
|
||||
run: |
|
||||
echo "Compiling binaries for npm package..."
|
||||
deno task compile
|
||||
echo ""
|
||||
echo "Binary sizes:"
|
||||
ls -lh dist/binaries/
|
||||
|
||||
- name: Generate SHA256 checksums
|
||||
run: |
|
||||
cd dist/binaries
|
||||
sha256sum * > SHA256SUMS
|
||||
cat SHA256SUMS
|
||||
cd ../..
|
||||
|
||||
- name: Sync package.json version
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version_number }}"
|
||||
echo "Syncing package.json to version ${VERSION}..."
|
||||
npm version ${VERSION} --no-git-tag-version --allow-same-version
|
||||
echo "package.json version: $(grep '"version"' package.json | head -1)"
|
||||
|
||||
- name: Create npm package
|
||||
run: |
|
||||
echo "Creating npm package..."
|
||||
npm pack
|
||||
echo ""
|
||||
echo "Package created:"
|
||||
ls -lh *.tgz
|
||||
|
||||
- name: Test local installation
|
||||
run: |
|
||||
echo "Testing local package installation..."
|
||||
PACKAGE_FILE=$(ls *.tgz)
|
||||
npm install -g ${PACKAGE_FILE}
|
||||
echo ""
|
||||
echo "Testing nupst command:"
|
||||
nupst --version || echo "Note: Binary execution may fail in CI environment"
|
||||
echo ""
|
||||
echo "Checking installed files:"
|
||||
npm ls -g @serve.zone/nupst || true
|
||||
|
||||
- name: Publish to npm
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: |
|
||||
echo "Publishing to npm registry..."
|
||||
npm publish --access public
|
||||
echo ""
|
||||
echo "✅ Successfully published @serve.zone/nupst to npm!"
|
||||
echo ""
|
||||
echo "Package info:"
|
||||
npm view @serve.zone/nupst
|
||||
|
||||
- name: Verify npm package
|
||||
run: |
|
||||
echo "Waiting for npm propagation..."
|
||||
sleep 30
|
||||
echo ""
|
||||
echo "Verifying published package..."
|
||||
npm view @serve.zone/nupst
|
||||
echo ""
|
||||
echo "Testing installation from npm:"
|
||||
npm install -g @serve.zone/nupst
|
||||
echo ""
|
||||
echo "Package installed successfully!"
|
||||
which nupst || echo "Binary location check skipped"
|
||||
|
||||
- name: Publish Summary
|
||||
run: |
|
||||
echo "================================================"
|
||||
echo " npm Publish Complete!"
|
||||
echo "================================================"
|
||||
echo ""
|
||||
echo "✅ Package: @serve.zone/nupst"
|
||||
echo "✅ Version: ${{ steps.version.outputs.version }}"
|
||||
echo ""
|
||||
echo "Installation:"
|
||||
echo " npm install -g @serve.zone/nupst"
|
||||
echo ""
|
||||
echo "Registry:"
|
||||
echo " https://www.npmjs.com/package/@serve.zone/nupst"
|
||||
echo ""
|
@@ -18,7 +18,7 @@ jobs:
|
||||
- name: Set up Deno
|
||||
uses: denoland/setup-deno@v1
|
||||
with:
|
||||
deno-version: v1.x
|
||||
deno-version: v2.x
|
||||
|
||||
- name: Get version from tag
|
||||
id: version
|
||||
|
183
.github/workflows/npm-publish.yml
vendored
Normal file
183
.github/workflows/npm-publish.yml
vendored
Normal file
@@ -0,0 +1,183 @@
|
||||
name: Publish to npm
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version to publish (e.g., 5.0.6)'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
build-and-publish:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
# Checkout the repository
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Setup Deno
|
||||
- name: Setup Deno
|
||||
uses: denoland/setup-deno@v1
|
||||
with:
|
||||
deno-version: v1.x
|
||||
|
||||
# Setup Node.js for npm publishing
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18.x'
|
||||
registry-url: 'https://registry.npmjs.org/'
|
||||
|
||||
# Compile binaries for all platforms
|
||||
- name: Compile binaries
|
||||
run: |
|
||||
echo "Compiling binaries for all platforms..."
|
||||
deno task compile
|
||||
echo ""
|
||||
echo "Binary sizes:"
|
||||
ls -lh dist/binaries/
|
||||
|
||||
# Update version in package.json if triggered manually
|
||||
- name: Update version in package.json
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
run: |
|
||||
VERSION=${{ github.event.inputs.version }}
|
||||
echo "Updating package.json to version ${VERSION}"
|
||||
npm version ${VERSION} --no-git-tag-version
|
||||
|
||||
# Extract version from tag if triggered by tag push
|
||||
- name: Extract version from tag
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
run: |
|
||||
VERSION=${GITHUB_REF#refs/tags/v}
|
||||
echo "VERSION=${VERSION}" >> $GITHUB_ENV
|
||||
echo "Extracted version: ${VERSION}"
|
||||
|
||||
# Ensure versions are synchronized
|
||||
- name: Sync versions
|
||||
run: |
|
||||
if [ -n "${VERSION}" ]; then
|
||||
echo "Syncing version ${VERSION} across files..."
|
||||
|
||||
# Update deno.json
|
||||
sed -i "s/\"version\": \".*\"/\"version\": \"${VERSION}\"/" deno.json
|
||||
|
||||
# Update package.json
|
||||
npm version ${VERSION} --no-git-tag-version --allow-same-version
|
||||
|
||||
echo "Updated versions:"
|
||||
echo "deno.json: $(grep '"version"' deno.json)"
|
||||
echo "package.json: $(grep '"version"' package.json | head -1)"
|
||||
fi
|
||||
|
||||
# Generate SHA256 checksums for binaries
|
||||
- name: Generate checksums
|
||||
run: |
|
||||
cd dist/binaries
|
||||
sha256sum * > SHA256SUMS
|
||||
echo "Checksums generated:"
|
||||
cat SHA256SUMS
|
||||
cd ../..
|
||||
|
||||
# Create npm package
|
||||
- name: Create npm package
|
||||
run: |
|
||||
echo "Creating npm package..."
|
||||
npm pack
|
||||
echo ""
|
||||
echo "Package created:"
|
||||
ls -lh *.tgz
|
||||
|
||||
# Test package installation locally
|
||||
- name: Test local installation
|
||||
run: |
|
||||
echo "Testing local package installation..."
|
||||
PACKAGE_FILE=$(ls *.tgz)
|
||||
npm install -g ${PACKAGE_FILE}
|
||||
|
||||
echo ""
|
||||
echo "Testing nupst command:"
|
||||
nupst --version || echo "Note: Binary execution may fail in CI environment"
|
||||
|
||||
echo ""
|
||||
echo "Checking installed files:"
|
||||
npm ls -g @serve.zone/nupst
|
||||
|
||||
# Publish to npm (only on tag push or manual trigger)
|
||||
- name: Publish to npm
|
||||
if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch'
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: |
|
||||
echo "Publishing to npm registry..."
|
||||
npm publish --access public
|
||||
|
||||
echo ""
|
||||
echo "✅ Successfully published @serve.zone/nupst to npm!"
|
||||
echo ""
|
||||
echo "Package info:"
|
||||
npm view @serve.zone/nupst
|
||||
|
||||
# Create GitHub Release (only on tag push)
|
||||
- name: Create GitHub Release
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
files: |
|
||||
dist/binaries/nupst-*
|
||||
dist/binaries/SHA256SUMS
|
||||
*.tgz
|
||||
generate_release_notes: true
|
||||
body: |
|
||||
## NUPST ${{ env.VERSION }}
|
||||
|
||||
### Installation
|
||||
|
||||
#### Via npm (recommended)
|
||||
```bash
|
||||
npm install -g @serve.zone/nupst
|
||||
```
|
||||
|
||||
#### Direct download
|
||||
Download the appropriate binary for your platform from the assets below.
|
||||
|
||||
### Platform Support
|
||||
- Linux x64 / ARM64
|
||||
- macOS x64 / ARM64 (Apple Silicon)
|
||||
- Windows x64
|
||||
|
||||
### Checksums
|
||||
SHA256 checksums are available in `SHA256SUMS` file.
|
||||
|
||||
# Verify the published package
|
||||
verify:
|
||||
needs: build-and-publish
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch'
|
||||
|
||||
steps:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18.x'
|
||||
|
||||
- name: Wait for npm propagation
|
||||
run: sleep 30
|
||||
|
||||
- name: Verify npm package
|
||||
run: |
|
||||
echo "Verifying published package..."
|
||||
npm view @serve.zone/nupst
|
||||
|
||||
echo ""
|
||||
echo "Testing installation from npm:"
|
||||
npm install -g @serve.zone/nupst
|
||||
|
||||
echo ""
|
||||
echo "Package installed successfully!"
|
||||
which nupst || echo "Binary location check skipped"
|
54
.npmignore
Normal file
54
.npmignore
Normal file
@@ -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.*
|
108
bin/nupst-wrapper.js
Normal file
108
bin/nupst-wrapper.js
Normal file
@@ -0,0 +1,108 @@
|
||||
#!/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 { platform, arch } from 'os';
|
||||
|
||||
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();
|
25
changelog.md
25
changelog.md
@@ -1,5 +1,30 @@
|
||||
# Changelog
|
||||
|
||||
## 2025-10-23 - 5.1.2 - fix(scripts)
|
||||
Add build script to package.json and include local dev tool settings
|
||||
|
||||
- Add a 'build' script to package.json (no-op placeholder) to provide an explicit build step
|
||||
- Minor scripts section formatting tidy in package.json
|
||||
- Add a hidden local settings file for development tooling permissions to the repository (local-only configuration)
|
||||
|
||||
## 2025-10-23 - 5.1.1 - fix(tooling)
|
||||
Add .claude/settings.local.json with local automation permissions
|
||||
|
||||
- Add .claude/settings.local.json to specify allowed permissions for local automated tasks
|
||||
- Grants permissions for various developer/CI actions (deno check/lint/fmt, npm/npm pack, selective Bash commands, WebFetch to docs.deno.com and code.foss.global, and file/read/replace helpers)
|
||||
- This is a developer/local tooling config only and does not change runtime code or package behavior
|
||||
|
||||
## 2025-10-22 - 5.1.0 - feat(packaging)
|
||||
Add npm packaging and installer: wrapper, postinstall downloader, publish workflow, and packaging files
|
||||
|
||||
- Add package.json (v5.0.5) and npm packaging metadata to publish @serve.zone/nupst
|
||||
- Include a small Node.js wrapper (bin/nupst-wrapper.js) to execute platform-specific precompiled binaries
|
||||
- Add postinstall script (scripts/install-binary.js) that downloads the correct binary for the current platform and sets executable permissions
|
||||
- Add GitHub Actions workflow (.github/workflows/npm-publish.yml) to build binaries, pack and publish to npm, and create releases
|
||||
- Add .npmignore to keep source, tests and dev files out of npm package; keep only runtime installer and wrapper
|
||||
- Move example action script into docs (docs/example-action.sh) and remove the top-level example-action.sh
|
||||
- Include generated npm package artifact (serve.zone-nupst-5.0.5.tgz) and npmextra.json
|
||||
|
||||
## 2025-10-18 - 4.0.0 - BREAKING CHANGE(core): Complete migration to Deno runtime
|
||||
|
||||
**MAJOR RELEASE: NUPST v4.0 is a complete rewrite powered by Deno**
|
||||
|
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"name": "@serve.zone/nupst",
|
||||
"version": "4.0.8",
|
||||
"version": "5.1.6",
|
||||
"exports": "./mod.ts",
|
||||
"nodeModulesDir": "auto",
|
||||
"tasks": {
|
||||
"dev": "deno run --allow-all mod.ts",
|
||||
"compile": "deno task compile:all",
|
||||
|
122
docs/example-action.sh
Normal file
122
docs/example-action.sh
Normal file
@@ -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
|
102
install.sh
102
install.sh
@@ -1,6 +1,6 @@
|
||||
#!/bin/bash
|
||||
|
||||
# NUPST Installer Script (v4.0+)
|
||||
# NUPST Installer Script (v5.0+)
|
||||
# Downloads and installs pre-compiled NUPST binary from Gitea releases
|
||||
#
|
||||
# Usage:
|
||||
@@ -8,7 +8,7 @@
|
||||
# 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 v4.0.0
|
||||
# curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash -s -- --version v5.0.0
|
||||
#
|
||||
# Options:
|
||||
# -h, --help Show this help message
|
||||
@@ -48,14 +48,14 @@ while [[ $# -gt 0 ]]; do
|
||||
done
|
||||
|
||||
if [ $SHOW_HELP -eq 1 ]; then
|
||||
echo "NUPST Installer Script (v4.0+)"
|
||||
echo "NUPST Installer Script (v5.0+)"
|
||||
echo "Downloads and installs pre-compiled NUPST binary"
|
||||
echo ""
|
||||
echo "Usage: $0 [options]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " -h, --help Show this help message"
|
||||
echo " --version VERSION Install specific version (e.g., v4.0.0)"
|
||||
echo " --version VERSION Install specific version (e.g., v5.0.0)"
|
||||
echo " --install-dir DIR Installation directory (default: /opt/nupst)"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
@@ -63,7 +63,7 @@ if [ $SHOW_HELP -eq 1 ]; then
|
||||
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 v4.0.0"
|
||||
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
|
||||
|
||||
@@ -145,7 +145,7 @@ get_latest_version() {
|
||||
|
||||
# Main installation process
|
||||
echo "================================================"
|
||||
echo " NUPST Installation Script (v4.0+)"
|
||||
echo " NUPST Installation Script (v5.0+)"
|
||||
echo "================================================"
|
||||
echo ""
|
||||
|
||||
@@ -169,51 +169,26 @@ DOWNLOAD_URL="${GITEA_BASE_URL}/${GITEA_REPO}/releases/download/${VERSION}/${BIN
|
||||
echo "Download URL: $DOWNLOAD_URL"
|
||||
echo ""
|
||||
|
||||
# Check if installation directory exists
|
||||
# Check if service is running and stop it
|
||||
SERVICE_WAS_RUNNING=0
|
||||
OLD_NODE_INSTALL=0
|
||||
|
||||
if [ -d "$INSTALL_DIR" ]; then
|
||||
# Check if this is an old Node.js-based installation
|
||||
if [ -f "$INSTALL_DIR/package.json" ] || [ -d "$INSTALL_DIR/node_modules" ]; then
|
||||
OLD_NODE_INSTALL=1
|
||||
echo "Detected old Node.js-based NUPST installation (v3.x or earlier)"
|
||||
echo "This installer will migrate to the new Deno-based binary version (v4.0+)"
|
||||
echo ""
|
||||
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
|
||||
|
||||
echo "Updating existing installation at $INSTALL_DIR..."
|
||||
|
||||
# Check if service exists (enabled or running) and stop it if active
|
||||
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
|
||||
else
|
||||
echo "Service is installed but not currently running (will be updated)..."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Clean up old Node.js installation files
|
||||
if [ $OLD_NODE_INSTALL -eq 1 ]; then
|
||||
echo "Cleaning up old Node.js installation files..."
|
||||
rm -rf "$INSTALL_DIR/node_modules" 2>/dev/null || true
|
||||
rm -rf "$INSTALL_DIR/vendor" 2>/dev/null || true
|
||||
rm -rf "$INSTALL_DIR/dist_ts" 2>/dev/null || true
|
||||
rm -f "$INSTALL_DIR/package.json" 2>/dev/null || true
|
||||
rm -f "$INSTALL_DIR/package-lock.json" 2>/dev/null || true
|
||||
rm -f "$INSTALL_DIR/pnpm-lock.yaml" 2>/dev/null || true
|
||||
rm -f "$INSTALL_DIR/tsconfig.json" 2>/dev/null || true
|
||||
rm -f "$INSTALL_DIR/setup.sh" 2>/dev/null || true
|
||||
rm -rf "$INSTALL_DIR/bin" 2>/dev/null || true
|
||||
echo "Old installation files removed."
|
||||
fi
|
||||
else
|
||||
echo "Creating installation directory: $INSTALL_DIR"
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
fi
|
||||
|
||||
# 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"
|
||||
@@ -241,9 +216,20 @@ fi
|
||||
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 ""
|
||||
|
||||
@@ -260,14 +246,6 @@ echo "Symlink created: $BIN_DIR/nupst -> $BINARY_PATH"
|
||||
|
||||
echo ""
|
||||
|
||||
# Update systemd service file if migrating from v3
|
||||
if [ $SERVICE_WAS_RUNNING -eq 1 ] && [ $OLD_NODE_INSTALL -eq 1 ]; then
|
||||
echo "Updating systemd service file for v4..."
|
||||
$BINARY_PATH service enable > /dev/null 2>&1
|
||||
echo "Service file updated."
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Restart service if it was running before update
|
||||
if [ $SERVICE_WAS_RUNNING -eq 1 ]; then
|
||||
echo "Restarting NUPST service..."
|
||||
@@ -280,20 +258,6 @@ echo "================================================"
|
||||
echo " NUPST Installation Complete!"
|
||||
echo "================================================"
|
||||
echo ""
|
||||
|
||||
if [ $OLD_NODE_INSTALL -eq 1 ]; then
|
||||
echo "Migration from v3.x to v4.0 successful!"
|
||||
echo ""
|
||||
echo "What changed:"
|
||||
echo " • Node.js runtime removed (now a self-contained binary)"
|
||||
echo " • Faster startup and lower memory usage"
|
||||
echo " • CLI commands now use subcommand structure"
|
||||
echo " (old commands still work with deprecation warnings)"
|
||||
echo ""
|
||||
echo "See readme for migration details: https://code.foss.global/serve.zone/nupst#migration-from-v3x"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
echo "Installation details:"
|
||||
echo " Binary location: $BINARY_PATH"
|
||||
echo " Symlink location: $BIN_DIR/nupst"
|
||||
|
1
npmextra.json
Normal file
1
npmextra.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
64
package.json
Normal file
64
package.json
Normal file
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"name": "@serve.zone/nupst",
|
||||
"version": "5.1.6",
|
||||
"description": "Network UPS Shutdown Tool - Monitor SNMP-enabled UPS devices and orchestrate graceful system shutdowns during power emergencies",
|
||||
"keywords": [
|
||||
"ups",
|
||||
"snmp",
|
||||
"power",
|
||||
"shutdown",
|
||||
"monitoring",
|
||||
"cyberpower",
|
||||
"apc",
|
||||
"eaton",
|
||||
"tripplite",
|
||||
"liebert",
|
||||
"vertiv",
|
||||
"battery",
|
||||
"backup"
|
||||
],
|
||||
"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",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"nupst": "./bin/nupst-wrapper.js"
|
||||
},
|
||||
"scripts": {
|
||||
"postinstall": "node scripts/install-binary.js",
|
||||
"prepublishOnly": "echo 'Publishing NUPST binaries to npm...'",
|
||||
"test": "echo 'Tests are run with Deno: deno task test'",
|
||||
"build": "echo 'no build needed'"
|
||||
},
|
||||
"files": [
|
||||
"bin/",
|
||||
"scripts/install-binary.js",
|
||||
"readme.md",
|
||||
"license",
|
||||
"changelog.md"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"os": [
|
||||
"darwin",
|
||||
"linux",
|
||||
"win32"
|
||||
],
|
||||
"cpu": [
|
||||
"x64",
|
||||
"arm64"
|
||||
],
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
},
|
||||
"packageManager": "pnpm@10.18.1+sha512.77a884a165cbba2d8d1c19e3b4880eee6d2fcabd0d879121e282196b80042351d5eb3ca0935fa599da1dc51265cc68816ad2bddd2a2de5ea9fdf92adbec7cd34"
|
||||
}
|
231
scripts/install-binary.js
Normal file
231
scripts/install-binary.js
Normal file
@@ -0,0 +1,231 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* NUPST npm postinstall script
|
||||
* Downloads the appropriate binary for the current platform from GitHub releases
|
||||
*/
|
||||
|
||||
import { platform, arch } from 'os';
|
||||
import { existsSync, mkdirSync, writeFileSync, chmodSync, unlinkSync } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import https from 'https';
|
||||
import { pipeline } from 'stream';
|
||||
import { promisify } from 'util';
|
||||
import { createWriteStream } from 'fs';
|
||||
|
||||
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);
|
||||
});
|
BIN
serve.zone-nupst-5.0.5.tgz
Normal file
BIN
serve.zone-nupst-5.0.5.tgz
Normal file
Binary file not shown.
@@ -1,10 +1,8 @@
|
||||
/**
|
||||
* commitinfo - reads version from deno.json
|
||||
* autocreated commitinfo by @push.rocks/commitinfo
|
||||
*/
|
||||
import denoConfig from '../deno.json' with { type: 'json' };
|
||||
|
||||
export const commitinfo = {
|
||||
name: denoConfig.name,
|
||||
version: denoConfig.version,
|
||||
description: 'Deno-powered UPS monitoring tool for SNMP-enabled UPS devices',
|
||||
};
|
||||
name: '@serve.zone/nupst',
|
||||
version: '5.1.2',
|
||||
description: 'Network UPS Shutdown Tool - Monitor SNMP-enabled UPS devices and orchestrate graceful system shutdowns during power emergencies'
|
||||
}
|
||||
|
170
ts/actions/base-action.ts
Normal file
170
ts/actions/base-action.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
/**
|
||||
* 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';
|
||||
|
||||
// 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 (default: 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
);
|
||||
}
|
||||
}
|
91
ts/actions/index.ts
Normal file
91
ts/actions/index.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
// Re-export types for convenience
|
||||
export type { IActionConfig, IActionContext, TPowerStatus } from './base-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';
|
||||
|
||||
/**
|
||||
* 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);
|
||||
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('');
|
||||
}
|
||||
}
|
167
ts/actions/script-action.ts
Normal file
167
ts/actions/script-action.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
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 IActionConfig, 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;
|
||||
}
|
||||
}
|
||||
}
|
142
ts/actions/shutdown-action.ts
Normal file
142
ts/actions/shutdown-action.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import * as fs from 'node:fs';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { Action, type IActionConfig, type IActionContext } from './base-action.ts';
|
||||
import { logger } from '../logger.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';
|
||||
|
||||
/**
|
||||
* 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 || 5; // Default 5 minutes
|
||||
|
||||
logger.log('');
|
||||
logger.logBoxTitle('Initiating System Shutdown', 60, '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);
|
||||
} 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`);
|
||||
return; // Exit if successful
|
||||
}
|
||||
} catch (_altError) {
|
||||
logger.error(`Alternative method ${alt.cmd} failed`);
|
||||
// Continue to next method
|
||||
}
|
||||
}
|
||||
|
||||
logger.error('All shutdown methods failed');
|
||||
}
|
||||
}
|
141
ts/actions/webhook-action.ts
Normal file
141
ts/actions/webhook-action.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import * as http from 'node:http';
|
||||
import * as https from 'node:https';
|
||||
import { URL } from 'node:url';
|
||||
import { Action, type IActionConfig, type IActionContext } from './base-action.ts';
|
||||
import { logger } from '../logger.ts';
|
||||
|
||||
/**
|
||||
* 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 || 10000;
|
||||
|
||||
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 async callWebhook(
|
||||
context: IActionContext,
|
||||
method: 'GET' | 'POST',
|
||||
timeout: number,
|
||||
): Promise<void> {
|
||||
const payload: any = {
|
||||
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();
|
||||
});
|
||||
}
|
||||
}
|
452
ts/cli.ts
452
ts/cli.ts
@@ -1,6 +1,6 @@
|
||||
import { execSync } from 'node:child_process';
|
||||
import { Nupst } from './nupst.ts';
|
||||
import { logger } from './logger.ts';
|
||||
import { logger, type ITableColumn } from './logger.ts';
|
||||
import { theme, symbols } from './colors.ts';
|
||||
|
||||
/**
|
||||
@@ -72,6 +72,7 @@ export class NupstCli {
|
||||
const upsHandler = this.nupst.getUpsHandler();
|
||||
const groupHandler = this.nupst.getGroupHandler();
|
||||
const serviceHandler = this.nupst.getServiceHandler();
|
||||
const actionHandler = this.nupst.getActionHandler();
|
||||
|
||||
// Handle service subcommands
|
||||
if (command === 'service') {
|
||||
@@ -126,8 +127,7 @@ export class NupstCli {
|
||||
break;
|
||||
}
|
||||
case 'remove':
|
||||
case 'rm': // Alias
|
||||
case 'delete': { // Backward compatibility
|
||||
case 'rm': {
|
||||
const upsIdToRemove = subcommandArgs[0];
|
||||
if (!upsIdToRemove) {
|
||||
logger.error('UPS ID is required for remove command');
|
||||
@@ -171,8 +171,7 @@ export class NupstCli {
|
||||
break;
|
||||
}
|
||||
case 'remove':
|
||||
case 'rm': // Alias
|
||||
case 'delete': { // Backward compatibility
|
||||
case 'rm': {
|
||||
const groupIdToRemove = subcommandArgs[0];
|
||||
if (!groupIdToRemove) {
|
||||
logger.error('Group ID is required for remove command');
|
||||
@@ -193,6 +192,55 @@ export class NupstCli {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle action subcommands
|
||||
if (command === 'action') {
|
||||
const subcommand = commandArgs[0] || 'list';
|
||||
const subcommandArgs = commandArgs.slice(1);
|
||||
|
||||
switch (subcommand) {
|
||||
case 'add': {
|
||||
const upsId = subcommandArgs[0];
|
||||
await actionHandler.add(upsId);
|
||||
break;
|
||||
}
|
||||
case 'remove':
|
||||
case 'rm': {
|
||||
const upsId = subcommandArgs[0];
|
||||
const actionIndex = subcommandArgs[1];
|
||||
await actionHandler.remove(upsId, actionIndex);
|
||||
break;
|
||||
}
|
||||
case 'list':
|
||||
case 'ls': { // Alias
|
||||
const upsId = subcommandArgs[0];
|
||||
await actionHandler.list(upsId);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
this.showActionHelp();
|
||||
break;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle feature subcommands
|
||||
if (command === 'feature') {
|
||||
const subcommand = commandArgs[0];
|
||||
const featureHandler = this.nupst.getFeatureHandler();
|
||||
|
||||
switch (subcommand) {
|
||||
case 'httpServer':
|
||||
case 'http-server':
|
||||
case 'http':
|
||||
await featureHandler.configureHttpServer();
|
||||
break;
|
||||
default:
|
||||
this.showFeatureHelp();
|
||||
break;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle config subcommand
|
||||
if (command === 'config') {
|
||||
const subcommand = commandArgs[0] || 'show';
|
||||
@@ -209,72 +257,8 @@ export class NupstCli {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle top-level commands and backward compatibility
|
||||
// Handle top-level commands
|
||||
switch (command) {
|
||||
// Backward compatibility - old UPS commands
|
||||
case 'add':
|
||||
logger.log("Note: 'nupst add' is deprecated. Use 'nupst ups add' instead.");
|
||||
await upsHandler.add();
|
||||
break;
|
||||
case 'edit':
|
||||
logger.log("Note: 'nupst edit' is deprecated. Use 'nupst ups edit' instead.");
|
||||
await upsHandler.edit(commandArgs[0]);
|
||||
break;
|
||||
case 'delete':
|
||||
logger.log("Note: 'nupst delete' is deprecated. Use 'nupst ups remove' instead.");
|
||||
if (!commandArgs[0]) {
|
||||
logger.error('UPS ID is required for delete command');
|
||||
this.showHelp();
|
||||
return;
|
||||
}
|
||||
await upsHandler.remove(commandArgs[0]);
|
||||
break;
|
||||
case 'list':
|
||||
logger.log("Note: 'nupst list' is deprecated. Use 'nupst ups list' instead.");
|
||||
await upsHandler.list();
|
||||
break;
|
||||
case 'test':
|
||||
logger.log("Note: 'nupst test' is deprecated. Use 'nupst ups test' instead.");
|
||||
await upsHandler.test(debugMode);
|
||||
break;
|
||||
case 'setup':
|
||||
logger.log("Note: 'nupst setup' is deprecated. Use 'nupst ups edit' instead.");
|
||||
await upsHandler.edit(undefined);
|
||||
break;
|
||||
|
||||
// Backward compatibility - old service commands
|
||||
case 'enable':
|
||||
logger.log("Note: 'nupst enable' is deprecated. Use 'nupst service enable' instead.");
|
||||
await serviceHandler.enable();
|
||||
break;
|
||||
case 'disable':
|
||||
logger.log("Note: 'nupst disable' is deprecated. Use 'nupst service disable' instead.");
|
||||
await serviceHandler.disable();
|
||||
break;
|
||||
case 'start':
|
||||
logger.log("Note: 'nupst start' is deprecated. Use 'nupst service start' instead.");
|
||||
await serviceHandler.start();
|
||||
break;
|
||||
case 'stop':
|
||||
logger.log("Note: 'nupst stop' is deprecated. Use 'nupst service stop' instead.");
|
||||
await serviceHandler.stop();
|
||||
break;
|
||||
case 'status':
|
||||
logger.log("Note: 'nupst status' is deprecated. Use 'nupst service status' instead.");
|
||||
await serviceHandler.status();
|
||||
break;
|
||||
case 'logs':
|
||||
logger.log("Note: 'nupst logs' is deprecated. Use 'nupst service logs' instead.");
|
||||
await serviceHandler.logs();
|
||||
break;
|
||||
case 'daemon-start':
|
||||
logger.log(
|
||||
"Note: 'nupst daemon-start' is deprecated. Use 'nupst service start-daemon' instead.",
|
||||
);
|
||||
await serviceHandler.daemonStart(debugMode);
|
||||
break;
|
||||
|
||||
// Top-level commands (no changes)
|
||||
case 'update':
|
||||
await serviceHandler.update();
|
||||
break;
|
||||
@@ -303,154 +287,182 @@ export class NupstCli {
|
||||
try {
|
||||
await this.nupst.getDaemon().loadConfig();
|
||||
} catch (_error) {
|
||||
const errorBoxWidth = 45;
|
||||
logger.logBoxTitle('Configuration Error', errorBoxWidth);
|
||||
logger.logBoxLine('No configuration found.');
|
||||
logger.logBoxLine("Please run 'nupst setup' first to create a configuration.");
|
||||
logger.logBoxEnd();
|
||||
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();
|
||||
|
||||
const boxWidth = 50;
|
||||
logger.logBoxTitle('NUPST Configuration', boxWidth);
|
||||
|
||||
// Check if multi-UPS config
|
||||
if (config.upsDevices && Array.isArray(config.upsDevices)) {
|
||||
// Multi-UPS configuration
|
||||
logger.logBoxLine(`UPS Devices: ${config.upsDevices.length}`);
|
||||
logger.logBoxLine(`Groups: ${config.groups ? config.groups.length : 0}`);
|
||||
logger.logBoxLine(`Check Interval: ${config.checkInterval / 1000} seconds`);
|
||||
logger.logBoxLine('');
|
||||
logger.logBoxLine('Configuration File Location:');
|
||||
logger.logBoxLine(' /etc/nupst/config.json');
|
||||
logger.logBoxEnd();
|
||||
// === Multi-UPS Configuration ===
|
||||
|
||||
// Overview Box
|
||||
logger.log('');
|
||||
logger.logBox('NUPST Configuration', [
|
||||
`UPS Devices: ${theme.highlight(String(config.upsDevices.length))}`,
|
||||
`Groups: ${theme.highlight(String(config.groups ? config.groups.length : 0))}`,
|
||||
`Check Interval: ${theme.info(String(config.checkInterval / 1000))} seconds`,
|
||||
'',
|
||||
theme.dim('Configuration File:'),
|
||||
` ${theme.path('/etc/nupst/config.json')}`,
|
||||
], 60, 'info');
|
||||
|
||||
// Show UPS devices
|
||||
if (config.upsDevices.length > 0) {
|
||||
logger.logBoxTitle('UPS Devices', boxWidth);
|
||||
for (const ups of config.upsDevices) {
|
||||
logger.logBoxLine(`${ups.name} (${ups.id}):`);
|
||||
logger.logBoxLine(` Host: ${ups.snmp.host}:${ups.snmp.port}`);
|
||||
logger.logBoxLine(` Model: ${ups.snmp.upsModel}`);
|
||||
logger.logBoxLine(
|
||||
` Thresholds: ${ups.thresholds.battery}% battery, ${ups.thresholds.runtime} min runtime`,
|
||||
);
|
||||
logger.logBoxLine(
|
||||
` Groups: ${ups.groups.length > 0 ? ups.groups.join(', ') : 'None'}`,
|
||||
);
|
||||
logger.logBoxLine('');
|
||||
}
|
||||
logger.logBoxEnd();
|
||||
// HTTP Server Status (if configured)
|
||||
if (config.httpServer) {
|
||||
const serverStatus = config.httpServer.enabled
|
||||
? theme.success('Enabled')
|
||||
: theme.dim('Disabled');
|
||||
|
||||
logger.log('');
|
||||
logger.logBox('HTTP Server', [
|
||||
`Status: ${serverStatus}`,
|
||||
...(config.httpServer.enabled ? [
|
||||
`Port: ${theme.highlight(String(config.httpServer.port))}`,
|
||||
`Path: ${theme.highlight(config.httpServer.path)}`,
|
||||
`Auth Token: ${theme.dim('***' + config.httpServer.authToken.slice(-4))}`,
|
||||
'',
|
||||
theme.dim('Usage:'),
|
||||
` curl -H "Authorization: Bearer TOKEN" http://localhost:${config.httpServer.port}${config.httpServer.path}`,
|
||||
] : []),
|
||||
], 70, config.httpServer.enabled ? 'success' : 'default');
|
||||
}
|
||||
|
||||
// Show groups
|
||||
if (config.groups && config.groups.length > 0) {
|
||||
logger.logBoxTitle('UPS Groups', boxWidth);
|
||||
for (const group of config.groups) {
|
||||
logger.logBoxLine(`${group.name} (${group.id}):`);
|
||||
logger.logBoxLine(` Mode: ${group.mode}`);
|
||||
if (group.description) {
|
||||
logger.logBoxLine(` Description: ${group.description}`);
|
||||
}
|
||||
// UPS Devices Table
|
||||
if (config.upsDevices.length > 0) {
|
||||
const upsRows = config.upsDevices.map((ups) => ({
|
||||
name: ups.name,
|
||||
id: theme.dim(ups.id),
|
||||
host: `${ups.snmp.host}:${ups.snmp.port}`,
|
||||
model: ups.snmp.upsModel || 'cyberpower',
|
||||
actions: `${(ups.actions || []).length} configured`,
|
||||
groups: ups.groups.length > 0 ? ups.groups.join(', ') : theme.dim('None'),
|
||||
}));
|
||||
|
||||
// List UPS devices in this group
|
||||
const upsColumns: ITableColumn[] = [
|
||||
{ header: 'Name', key: 'name', align: 'left', color: theme.highlight },
|
||||
{ header: 'ID', key: 'id', align: 'left' },
|
||||
{ header: 'Host:Port', key: 'host', align: 'left', color: theme.info },
|
||||
{ header: 'Model', key: 'model', align: 'left' },
|
||||
{ header: 'Actions', key: 'actions', align: 'left' },
|
||||
{ header: 'Groups', key: 'groups', align: 'left' },
|
||||
];
|
||||
|
||||
logger.log('');
|
||||
logger.info(`UPS Devices (${config.upsDevices.length}):`);
|
||||
logger.log('');
|
||||
logger.logTable(upsColumns, upsRows);
|
||||
}
|
||||
|
||||
// Groups Table
|
||||
if (config.groups && config.groups.length > 0) {
|
||||
const groupRows = config.groups.map((group) => {
|
||||
const upsInGroup = config.upsDevices.filter((ups) =>
|
||||
ups.groups && ups.groups.includes(group.id)
|
||||
);
|
||||
logger.logBoxLine(
|
||||
` UPS Devices: ${
|
||||
upsInGroup.length > 0 ? upsInGroup.map((ups) => ups.name).join(', ') : 'None'
|
||||
}`,
|
||||
);
|
||||
logger.logBoxLine('');
|
||||
}
|
||||
logger.logBoxEnd();
|
||||
return {
|
||||
name: group.name,
|
||||
id: theme.dim(group.id),
|
||||
mode: group.mode,
|
||||
upsCount: String(upsInGroup.length),
|
||||
ups: upsInGroup.length > 0
|
||||
? upsInGroup.map((ups) => ups.name).join(', ')
|
||||
: theme.dim('None'),
|
||||
description: group.description || theme.dim('—'),
|
||||
};
|
||||
});
|
||||
|
||||
const groupColumns: ITableColumn[] = [
|
||||
{ header: 'Name', key: 'name', align: 'left', color: theme.highlight },
|
||||
{ header: 'ID', key: 'id', align: 'left' },
|
||||
{ header: 'Mode', key: 'mode', align: 'left', color: theme.info },
|
||||
{ header: 'UPS', key: 'upsCount', align: 'right' },
|
||||
{ header: 'UPS Devices', key: 'ups', align: 'left' },
|
||||
{ header: 'Description', key: 'description', align: 'left' },
|
||||
];
|
||||
|
||||
logger.log('');
|
||||
logger.info(`UPS Groups (${config.groups.length}):`);
|
||||
logger.log('');
|
||||
logger.logTable(groupColumns, groupRows);
|
||||
}
|
||||
} else {
|
||||
// Legacy single UPS configuration
|
||||
// === Legacy Single UPS Configuration ===
|
||||
|
||||
if (!config.snmp) {
|
||||
logger.logBoxLine('Error: Legacy configuration missing SNMP settings');
|
||||
} else {
|
||||
// SNMP Settings
|
||||
logger.logBoxLine('SNMP Settings:');
|
||||
logger.logBoxLine(` Host: ${config.snmp.host}`);
|
||||
logger.logBoxLine(` Port: ${config.snmp.port}`);
|
||||
logger.logBoxLine(` Version: ${config.snmp.version}`);
|
||||
logger.logBoxLine(` UPS Model: ${config.snmp.upsModel || 'cyberpower'}`);
|
||||
|
||||
if (config.snmp.version === 1 || config.snmp.version === 2) {
|
||||
logger.logBoxLine(` Community: ${config.snmp.community}`);
|
||||
} else if (config.snmp.version === 3) {
|
||||
logger.logBoxLine(` Security Level: ${config.snmp.securityLevel}`);
|
||||
logger.logBoxLine(` Username: ${config.snmp.username}`);
|
||||
|
||||
// Show auth and privacy details based on security level
|
||||
if (
|
||||
config.snmp.securityLevel === 'authNoPriv' ||
|
||||
config.snmp.securityLevel === 'authPriv'
|
||||
) {
|
||||
logger.logBoxLine(` Auth Protocol: ${config.snmp.authProtocol || 'None'}`);
|
||||
}
|
||||
|
||||
if (config.snmp.securityLevel === 'authPriv') {
|
||||
logger.logBoxLine(` Privacy Protocol: ${config.snmp.privProtocol || 'None'}`);
|
||||
}
|
||||
|
||||
// Show timeout value
|
||||
logger.logBoxLine(` Timeout: ${config.snmp.timeout / 1000} seconds`);
|
||||
}
|
||||
|
||||
// Show OIDs if custom model is selected
|
||||
if (config.snmp.upsModel === 'custom' && config.snmp.customOIDs) {
|
||||
logger.logBoxLine('Custom OIDs:');
|
||||
logger.logBoxLine(
|
||||
` Power Status: ${config.snmp.customOIDs.POWER_STATUS || 'Not set'}`,
|
||||
);
|
||||
logger.logBoxLine(
|
||||
` Battery Capacity: ${config.snmp.customOIDs.BATTERY_CAPACITY || 'Not set'}`,
|
||||
);
|
||||
logger.logBoxLine(
|
||||
` Battery Runtime: ${config.snmp.customOIDs.BATTERY_RUNTIME || 'Not set'}`,
|
||||
);
|
||||
}
|
||||
logger.logBox('Configuration Error', [
|
||||
'Error: Legacy configuration missing SNMP settings',
|
||||
], 60, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Thresholds
|
||||
if (!config.thresholds) {
|
||||
logger.logBoxLine('Error: Legacy configuration missing threshold settings');
|
||||
} else {
|
||||
logger.logBoxLine('Thresholds:');
|
||||
logger.logBoxLine(` Battery: ${config.thresholds.battery}%`);
|
||||
logger.logBoxLine(` Runtime: ${config.thresholds.runtime} minutes`);
|
||||
}
|
||||
logger.logBoxLine(`Check Interval: ${config.checkInterval / 1000} seconds`);
|
||||
|
||||
// Configuration file location
|
||||
logger.logBoxLine('');
|
||||
logger.logBoxLine('Configuration File Location:');
|
||||
logger.logBoxLine(' /etc/nupst/config.json');
|
||||
logger.logBoxLine('');
|
||||
logger.logBoxLine('Note: Using legacy single-UPS configuration format.');
|
||||
logger.logBoxLine('Consider using "nupst add" to migrate to multi-UPS format.');
|
||||
|
||||
logger.logBoxEnd();
|
||||
logger.log('');
|
||||
logger.logBox('NUPST Configuration (Legacy)', [
|
||||
theme.warning('Legacy single-UPS configuration format'),
|
||||
'',
|
||||
theme.dim('SNMP Settings:'),
|
||||
` Host: ${theme.info(config.snmp.host)}`,
|
||||
` Port: ${theme.info(String(config.snmp.port))}`,
|
||||
` Version: ${config.snmp.version}`,
|
||||
` UPS Model: ${config.snmp.upsModel || 'cyberpower'}`,
|
||||
...(config.snmp.version === 1 || config.snmp.version === 2
|
||||
? [` Community: ${config.snmp.community}`]
|
||||
: []
|
||||
),
|
||||
...(config.snmp.version === 3
|
||||
? [
|
||||
` Security Level: ${config.snmp.securityLevel}`,
|
||||
` Username: ${config.snmp.username}`,
|
||||
...(config.snmp.securityLevel === 'authNoPriv' || config.snmp.securityLevel === 'authPriv'
|
||||
? [` Auth Protocol: ${config.snmp.authProtocol || 'None'}`]
|
||||
: []
|
||||
),
|
||||
...(config.snmp.securityLevel === 'authPriv'
|
||||
? [` Privacy Protocol: ${config.snmp.privProtocol || 'None'}`]
|
||||
: []
|
||||
),
|
||||
` Timeout: ${config.snmp.timeout / 1000} seconds`,
|
||||
]
|
||||
: []
|
||||
),
|
||||
...(config.snmp.upsModel === 'custom' && config.snmp.customOIDs
|
||||
? [
|
||||
theme.dim('Custom OIDs:'),
|
||||
` Power Status: ${config.snmp.customOIDs.POWER_STATUS || 'Not set'}`,
|
||||
` Battery Capacity: ${config.snmp.customOIDs.BATTERY_CAPACITY || 'Not set'}`,
|
||||
` Battery Runtime: ${config.snmp.customOIDs.BATTERY_RUNTIME || 'Not set'}`,
|
||||
]
|
||||
: []
|
||||
),
|
||||
'',
|
||||
|
||||
` Check Interval: ${config.checkInterval / 1000} seconds`,
|
||||
'',
|
||||
theme.dim('Configuration File:'),
|
||||
` ${theme.path('/etc/nupst/config.json')}`,
|
||||
'',
|
||||
theme.warning('Note: Using legacy single-UPS configuration format.'),
|
||||
`Consider using ${theme.command('nupst ups add')} to migrate to multi-UPS format.`,
|
||||
], 70, 'warning');
|
||||
}
|
||||
|
||||
// Show service status
|
||||
// Service Status
|
||||
try {
|
||||
const isActive =
|
||||
execSync('systemctl is-active nupst.service || true').toString().trim() === 'active';
|
||||
const isEnabled =
|
||||
execSync('systemctl is-enabled nupst.service || true').toString().trim() === 'enabled';
|
||||
|
||||
const statusBoxWidth = 45;
|
||||
logger.logBoxTitle('Service Status', statusBoxWidth);
|
||||
logger.logBoxLine(`Service Active: ${isActive ? 'Yes' : 'No'}`);
|
||||
logger.logBoxLine(`Service Enabled: ${isEnabled ? 'Yes' : 'No'}`);
|
||||
logger.logBoxEnd();
|
||||
logger.log('');
|
||||
logger.logBox('Service Status', [
|
||||
`Active: ${isActive ? theme.success('Yes') : theme.dim('No')}`,
|
||||
`Enabled: ${isEnabled ? theme.success('Yes') : theme.dim('No')}`,
|
||||
], 50, isActive ? 'success' : 'default');
|
||||
logger.log('');
|
||||
} catch (_error) {
|
||||
// Ignore errors checking service status
|
||||
}
|
||||
@@ -469,7 +481,7 @@ export class NupstCli {
|
||||
private showVersion(): void {
|
||||
const version = this.nupst.getVersion();
|
||||
logger.log(`NUPST version ${version}`);
|
||||
logger.log('Deno-powered UPS monitoring tool');
|
||||
logger.log('Network UPS Shutdown Tool (https://nupst.serve.zone)');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -491,6 +503,8 @@ export class NupstCli {
|
||||
this.printCommand('service <subcommand>', 'Manage systemd service');
|
||||
this.printCommand('ups <subcommand>', 'Manage UPS devices');
|
||||
this.printCommand('group <subcommand>', 'Manage UPS groups');
|
||||
this.printCommand('action <subcommand>', 'Manage UPS actions');
|
||||
this.printCommand('feature <subcommand>', 'Manage optional features');
|
||||
this.printCommand('config [show]', 'Display current configuration');
|
||||
this.printCommand('update', 'Update NUPST from repository', theme.dim('(requires root)'));
|
||||
this.printCommand('uninstall', 'Completely remove NUPST', theme.dim('(requires root)'));
|
||||
@@ -527,6 +541,18 @@ export class NupstCli {
|
||||
this.printCommand('nupst group list (or ls)', 'List all UPS groups');
|
||||
console.log('');
|
||||
|
||||
// Action subcommands
|
||||
logger.log(theme.info('Action Subcommands:'));
|
||||
this.printCommand('nupst action add <target-id>', 'Add a new action to a UPS or group');
|
||||
this.printCommand('nupst action remove <target-id> <index>', 'Remove an action by index');
|
||||
this.printCommand('nupst action list [target-id]', 'List all actions (optionally for specific target)');
|
||||
console.log('');
|
||||
|
||||
// Feature subcommands
|
||||
logger.log(theme.info('Feature Subcommands:'));
|
||||
this.printCommand('nupst feature httpServer', 'Configure HTTP server for JSON status export');
|
||||
console.log('');
|
||||
|
||||
// Options
|
||||
logger.log(theme.info('Options:'));
|
||||
this.printCommand('--debug, -d', 'Enable debug mode for detailed SNMP logging');
|
||||
@@ -540,11 +566,6 @@ export class NupstCli {
|
||||
logger.dim(' nupst group list # Show all configured groups');
|
||||
logger.dim(' nupst config # Display current configuration');
|
||||
console.log('');
|
||||
|
||||
// Note about deprecated commands
|
||||
logger.warn('Note: Old command format (e.g., \'nupst add\') still works but is deprecated.');
|
||||
logger.dim(' Use the new format (e.g., \'nupst ups add\') going forward.');
|
||||
console.log('');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -631,6 +652,45 @@ Examples:
|
||||
nupst group add - Create a new group
|
||||
nupst group edit dc-1 - Edit group with ID 'dc-1'
|
||||
nupst group remove dc-1 - Remove group with ID 'dc-1'
|
||||
`);
|
||||
}
|
||||
|
||||
private showActionHelp(): void {
|
||||
logger.log(`
|
||||
NUPST - Action Management Commands
|
||||
|
||||
Usage:
|
||||
nupst action <subcommand> [arguments]
|
||||
|
||||
Subcommands:
|
||||
add <ups-id|group-id> - Add a new action to a UPS or group interactively
|
||||
remove <ups-id|group-id> <index> - Remove an action by index (alias: rm)
|
||||
list [ups-id|group-id] - List all actions (optionally for specific target) (alias: ls)
|
||||
|
||||
Options:
|
||||
--debug, -d - Enable debug mode for detailed logging
|
||||
|
||||
Examples:
|
||||
nupst action list - List actions for all UPS devices and groups
|
||||
nupst action list default - List actions for UPS or group with ID 'default'
|
||||
nupst action add default - Add a new action to UPS or group 'default'
|
||||
nupst action remove default 0 - Remove action at index 0 from UPS or group 'default'
|
||||
nupst action add dc-rack-1 - Add a new action to group 'dc-rack-1'
|
||||
`);
|
||||
}
|
||||
|
||||
private showFeatureHelp(): void {
|
||||
logger.log(`
|
||||
NUPST - Feature Management Commands
|
||||
|
||||
Usage:
|
||||
nupst feature <subcommand>
|
||||
|
||||
Subcommands:
|
||||
httpServer - Configure HTTP server for JSON status export
|
||||
|
||||
Examples:
|
||||
nupst feature httpServer - Enable/disable HTTP server with interactive setup
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
357
ts/cli/action-handler.ts
Normal file
357
ts/cli/action-handler.ts
Normal file
@@ -0,0 +1,357 @@
|
||||
import process from 'node:process';
|
||||
import { Nupst } from '../nupst.ts';
|
||||
import { logger, type ITableColumn } from '../logger.ts';
|
||||
import { theme, symbols } from '../colors.ts';
|
||||
import type { IActionConfig } from '../actions/base-action.ts';
|
||||
import type { IUpsConfig, IGroupConfig } from '../daemon.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();
|
||||
|
||||
// Check if it's a UPS
|
||||
const ups = config.upsDevices.find((u) => u.id === targetId);
|
||||
// Check if it's a group
|
||||
const group = config.groups?.find((g) => g.id === targetId);
|
||||
|
||||
if (!ups && !group) {
|
||||
logger.error(`UPS or Group with ID '${targetId}' not found`);
|
||||
logger.log('');
|
||||
logger.log(` ${theme.dim('List available UPS devices:')} ${theme.command('nupst ups list')}`);
|
||||
logger.log(` ${theme.dim('List available groups:')} ${theme.command('nupst group list')}`);
|
||||
logger.log('');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const target = ups || group;
|
||||
const targetType = ups ? 'UPS' : 'Group';
|
||||
const targetName = ups ? ups.name : group!.name;
|
||||
|
||||
const 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);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
logger.log('');
|
||||
logger.info(`Add Action to ${targetType} ${theme.highlight(targetName)}`);
|
||||
logger.log('');
|
||||
|
||||
// Action type (currently only shutdown is supported)
|
||||
const type = 'shutdown';
|
||||
logger.log(` ${theme.dim('Action type:')} ${theme.highlight('shutdown')}`);
|
||||
|
||||
// Battery threshold
|
||||
const batteryStr = await prompt(
|
||||
` ${theme.dim('Battery threshold')} ${theme.dim('(%):')} `,
|
||||
);
|
||||
const battery = parseInt(batteryStr, 10);
|
||||
if (isNaN(battery) || battery < 0 || battery > 100) {
|
||||
logger.error('Invalid battery threshold. Must be 0-100.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Runtime threshold
|
||||
const runtimeStr = await prompt(
|
||||
` ${theme.dim('Runtime threshold')} ${theme.dim('(minutes):')} `,
|
||||
);
|
||||
const runtime = parseInt(runtimeStr, 10);
|
||||
if (isNaN(runtime) || runtime < 0) {
|
||||
logger.error('Invalid runtime threshold. Must be >= 0.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Trigger mode
|
||||
logger.log('');
|
||||
logger.log(` ${theme.dim('Trigger mode:')}`);
|
||||
logger.log(` ${theme.dim('1)')} onlyPowerChanges - Trigger only when power status changes`);
|
||||
logger.log(
|
||||
` ${theme.dim('2)')} onlyThresholds - Trigger only when thresholds are violated`,
|
||||
);
|
||||
logger.log(
|
||||
` ${theme.dim('3)')} powerChangesAndThresholds - Trigger on power change AND thresholds`,
|
||||
);
|
||||
logger.log(` ${theme.dim('4)')} anyChange - Trigger on any status change`);
|
||||
const triggerChoice = await prompt(` ${theme.dim('Choice')} ${theme.dim('[2]:')} `);
|
||||
const triggerModeMap: Record<string, string> = {
|
||||
'1': 'onlyPowerChanges',
|
||||
'2': 'onlyThresholds',
|
||||
'3': 'powerChangesAndThresholds',
|
||||
'4': 'anyChange',
|
||||
'': 'onlyThresholds', // Default
|
||||
};
|
||||
const triggerMode = triggerModeMap[triggerChoice] || 'onlyThresholds';
|
||||
|
||||
// Shutdown delay
|
||||
const delayStr = await prompt(
|
||||
` ${theme.dim('Shutdown delay')} ${theme.dim('(seconds) [5]:')} `,
|
||||
);
|
||||
const shutdownDelay = delayStr ? parseInt(delayStr, 10) : 5;
|
||||
if (isNaN(shutdownDelay) || shutdownDelay < 0) {
|
||||
logger.error('Invalid shutdown delay. Must be >= 0.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Create the action
|
||||
const newAction: IActionConfig = {
|
||||
type,
|
||||
thresholds: {
|
||||
battery,
|
||||
runtime,
|
||||
},
|
||||
triggerMode: triggerMode as IActionConfig['triggerMode'],
|
||||
shutdownDelay,
|
||||
};
|
||||
|
||||
// Add to target (UPS or group)
|
||||
if (!target!.actions) {
|
||||
target!.actions = [];
|
||||
}
|
||||
target!.actions.push(newAction);
|
||||
|
||||
await this.nupst.getDaemon().saveConfig(config);
|
||||
|
||||
logger.log('');
|
||||
logger.success(`Action added to ${targetType} ${targetName}`);
|
||||
logger.log(` ${theme.dim('Changes saved and will be applied automatically')}`);
|
||||
logger.log('');
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to add action: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: 'Delay', key: 'delay', align: 'right' },
|
||||
];
|
||||
|
||||
const rows = target.actions.map((action, index) => ({
|
||||
index: theme.dim(index.toString()),
|
||||
type: theme.highlight(action.type),
|
||||
battery: action.thresholds ? `${action.thresholds.battery}%` : theme.dim('N/A'),
|
||||
runtime: action.thresholds ? `${action.thresholds.runtime}min` : theme.dim('N/A'),
|
||||
triggerMode: theme.dim(action.triggerMode || 'onlyThresholds'),
|
||||
delay: `${action.shutdownDelay || 5}s`,
|
||||
}));
|
||||
|
||||
logger.logTable(columns, rows);
|
||||
logger.log('');
|
||||
}
|
||||
}
|
213
ts/cli/feature-handler.ts
Normal file
213
ts/cli/feature-handler.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import process from 'node:process';
|
||||
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 {
|
||||
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);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
await this.runHttpServerConfig(prompt);
|
||||
} finally {
|
||||
rl.close();
|
||||
process.stdin.destroy();
|
||||
}
|
||||
} 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 readline = await import('node:readline');
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
const answer = await new Promise<string>((resolve) => {
|
||||
rl.question('Service is running. Restart to apply changes? (Y/n): ', resolve);
|
||||
});
|
||||
|
||||
rl.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
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,6 +1,7 @@
|
||||
import process from 'node:process';
|
||||
import { Nupst } from '../nupst.ts';
|
||||
import { logger } from '../logger.ts';
|
||||
import { logger, type ITableColumn } from '../logger.ts';
|
||||
import { theme } from '../colors.ts';
|
||||
import * as helpers from '../helpers/index.ts';
|
||||
import { type IGroupConfig } from '../daemon.ts';
|
||||
|
||||
@@ -28,11 +29,10 @@ export class GroupHandler {
|
||||
try {
|
||||
await this.nupst.getDaemon().loadConfig();
|
||||
} catch (error) {
|
||||
const errorBoxWidth = 45;
|
||||
logger.logBoxTitle('Configuration Error', errorBoxWidth);
|
||||
logger.logBoxLine('No configuration found.');
|
||||
logger.logBoxLine("Please run 'nupst setup' first to create a configuration.");
|
||||
logger.logBoxEnd();
|
||||
logger.logBox('Configuration Error', [
|
||||
'No configuration found.',
|
||||
"Please run 'nupst ups add' first to create a configuration.",
|
||||
], 50, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -41,43 +41,53 @@ export class GroupHandler {
|
||||
|
||||
// Check if multi-UPS config
|
||||
if (!config.groups || !Array.isArray(config.groups)) {
|
||||
// Legacy or missing groups configuration
|
||||
const boxWidth = 45;
|
||||
logger.logBoxTitle('UPS Groups', boxWidth);
|
||||
logger.logBoxLine('No groups configured.');
|
||||
logger.logBoxLine('Use "nupst group add" to add a UPS group.');
|
||||
logger.logBoxEnd();
|
||||
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
|
||||
const boxWidth = 60;
|
||||
logger.logBoxTitle('UPS Groups', boxWidth);
|
||||
|
||||
// Display group list with modern table
|
||||
if (config.groups.length === 0) {
|
||||
logger.logBoxLine('No UPS groups configured.');
|
||||
logger.logBoxLine('Use "nupst group add" to add a UPS group.');
|
||||
} else {
|
||||
logger.logBoxLine(`Found ${config.groups.length} group(s)`);
|
||||
logger.logBoxLine('');
|
||||
logger.logBoxLine('ID | Name | Mode | UPS Devices');
|
||||
logger.logBoxLine('-----------+----------------------+--------------+----------------');
|
||||
|
||||
for (const group of config.groups) {
|
||||
const id = group.id.padEnd(10, ' ').substring(0, 10);
|
||||
const name = (group.name || '').padEnd(20, ' ').substring(0, 20);
|
||||
const mode = (group.mode || 'unknown').padEnd(12, ' ').substring(0, 12);
|
||||
|
||||
// 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(', ');
|
||||
|
||||
logger.logBoxLine(`${id} | ${name} | ${mode} | ${upsCount > 0 ? upsNames : 'None'}`);
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
logger.logBoxEnd();
|
||||
// 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)}`,
|
||||
@@ -192,6 +202,7 @@ export class GroupHandler {
|
||||
logger.log('\nGroup setup complete!');
|
||||
} finally {
|
||||
rl.close();
|
||||
process.stdin.destroy();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Add group error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
@@ -309,6 +320,7 @@ export class GroupHandler {
|
||||
logger.log('\nGroup edit complete!');
|
||||
} finally {
|
||||
rl.close();
|
||||
process.stdin.destroy();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Edit group error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
@@ -366,6 +378,7 @@ export class GroupHandler {
|
||||
});
|
||||
|
||||
rl.close();
|
||||
process.stdin.destroy();
|
||||
|
||||
if (confirm !== 'y' && confirm !== 'yes') {
|
||||
logger.log('Deletion cancelled.');
|
||||
|
@@ -145,12 +145,16 @@ export class ServiceHandler {
|
||||
const release = JSON.parse(response);
|
||||
const latestVersion = release.tag_name; // e.g., "v4.0.7"
|
||||
|
||||
logger.dim(`Current version: ${currentVersion}`);
|
||||
logger.dim(`Latest version: ${latestVersion}`);
|
||||
// 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 versions (both are in format "v4.0.7")
|
||||
if (currentVersion === latestVersion) {
|
||||
// Compare normalized versions
|
||||
if (normalizedCurrent === normalizedLatest) {
|
||||
logger.success('Already up to date!');
|
||||
console.log('');
|
||||
return;
|
||||
@@ -209,9 +213,11 @@ export class ServiceHandler {
|
||||
});
|
||||
};
|
||||
|
||||
console.log('\nNUPST Uninstaller');
|
||||
console.log('===============');
|
||||
console.log('This will completely remove NUPST from your system.\n');
|
||||
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(
|
||||
@@ -247,17 +253,20 @@ export class ServiceHandler {
|
||||
}
|
||||
|
||||
if (!uninstallScriptPath) {
|
||||
console.error('Could not locate uninstall.sh script. Aborting uninstall.');
|
||||
logger.error('Could not locate uninstall.sh script. Aborting uninstall.');
|
||||
rl.close();
|
||||
process.stdin.destroy();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Close readline before executing script
|
||||
rl.close();
|
||||
process.stdin.destroy();
|
||||
|
||||
// Execute uninstall.sh with the appropriate option
|
||||
console.log(`\nRunning uninstaller from ${uninstallScriptPath}...`);
|
||||
logger.log('');
|
||||
logger.log(`Running uninstaller from ${uninstallScriptPath}...`);
|
||||
|
||||
// Pass the configuration removal option as an environment variable
|
||||
const env = {
|
||||
@@ -273,7 +282,7 @@ export class ServiceHandler {
|
||||
stdio: 'inherit', // Show output in the terminal
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Uninstall failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
logger.error(`Uninstall failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
@@ -1,7 +1,8 @@
|
||||
import process from 'node:process';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { Nupst } from '../nupst.ts';
|
||||
import { logger } from '../logger.ts';
|
||||
import { logger, type ITableColumn } from '../logger.ts';
|
||||
import { theme } from '../colors.ts';
|
||||
import * as helpers from '../helpers/index.ts';
|
||||
import type { TUpsModel } from '../snmp/types.ts';
|
||||
import type { INupstConfig } from '../daemon.ts';
|
||||
@@ -47,6 +48,7 @@ export class UpsHandler {
|
||||
await this.runAddProcess(prompt);
|
||||
} finally {
|
||||
rl.close();
|
||||
process.stdin.destroy();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Add UPS error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
@@ -75,10 +77,10 @@ export class UpsHandler {
|
||||
checkInterval: config.checkInterval,
|
||||
upsDevices: [{
|
||||
id: 'default',
|
||||
name: 'Default UPS',
|
||||
snmp: config.snmp,
|
||||
thresholds: config.thresholds,
|
||||
groups: [],
|
||||
name: 'Default UPS',
|
||||
snmp: config.snmp,
|
||||
groups: [],
|
||||
actions: [],
|
||||
}],
|
||||
groups: [],
|
||||
};
|
||||
@@ -115,14 +117,12 @@ export class UpsHandler {
|
||||
runtime: 20,
|
||||
},
|
||||
groups: [],
|
||||
actions: [],
|
||||
};
|
||||
|
||||
// Gather SNMP settings
|
||||
await this.gatherSnmpSettings(newUps.snmp, prompt);
|
||||
|
||||
// Gather threshold settings
|
||||
await this.gatherThresholdSettings(newUps.thresholds, prompt);
|
||||
|
||||
// Gather UPS model settings
|
||||
await this.gatherUpsModelSettings(newUps.snmp, prompt);
|
||||
|
||||
@@ -134,6 +134,9 @@ export class UpsHandler {
|
||||
await groupHandler.assignUpsToGroups(newUps, config.groups, prompt);
|
||||
}
|
||||
|
||||
// Gather action settings
|
||||
await this.gatherActionSettings(newUps.actions, prompt);
|
||||
|
||||
// Add the new UPS to the config
|
||||
config.upsDevices.push(newUps);
|
||||
|
||||
@@ -178,6 +181,7 @@ export class UpsHandler {
|
||||
await this.runEditProcess(upsId, prompt);
|
||||
} finally {
|
||||
rl.close();
|
||||
process.stdin.destroy();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Edit UPS error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
@@ -218,16 +222,16 @@ export class UpsHandler {
|
||||
// Convert old format to new format if needed
|
||||
if (!config.upsDevices) {
|
||||
// Initialize with the current config as the first UPS
|
||||
if (!config.snmp || !config.thresholds) {
|
||||
logger.error('Legacy configuration is missing required SNMP or threshold settings');
|
||||
if (!config.snmp) {
|
||||
logger.error('Legacy configuration is missing required SNMP settings');
|
||||
return;
|
||||
}
|
||||
config.upsDevices = [{
|
||||
id: 'default',
|
||||
name: 'Default UPS',
|
||||
snmp: config.snmp,
|
||||
thresholds: config.thresholds,
|
||||
groups: [],
|
||||
actions: [],
|
||||
}];
|
||||
config.groups = [];
|
||||
logger.log('Converting existing configuration to multi-UPS format.');
|
||||
@@ -262,9 +266,6 @@ export class UpsHandler {
|
||||
// Edit SNMP settings
|
||||
await this.gatherSnmpSettings(upsToEdit.snmp, prompt);
|
||||
|
||||
// Edit threshold settings
|
||||
await this.gatherThresholdSettings(upsToEdit.thresholds, prompt);
|
||||
|
||||
// Edit UPS model settings
|
||||
await this.gatherUpsModelSettings(upsToEdit.snmp, prompt);
|
||||
|
||||
@@ -276,6 +277,14 @@ export class UpsHandler {
|
||||
await groupHandler.assignUpsToGroups(upsToEdit, config.groups, prompt);
|
||||
}
|
||||
|
||||
// Initialize actions array if not exists
|
||||
if (!upsToEdit.actions) {
|
||||
upsToEdit.actions = [];
|
||||
}
|
||||
|
||||
// Edit action settings
|
||||
await this.gatherActionSettings(upsToEdit.actions, prompt);
|
||||
|
||||
// Save the configuration
|
||||
await this.nupst.getDaemon().saveConfig(config);
|
||||
|
||||
@@ -344,6 +353,7 @@ export class UpsHandler {
|
||||
});
|
||||
|
||||
rl.close();
|
||||
process.stdin.destroy();
|
||||
|
||||
if (confirm !== 'y' && confirm !== 'yes') {
|
||||
logger.log('Deletion cancelled.');
|
||||
@@ -376,11 +386,10 @@ export class UpsHandler {
|
||||
try {
|
||||
await this.nupst.getDaemon().loadConfig();
|
||||
} catch (error) {
|
||||
const errorBoxWidth = 45;
|
||||
logger.logBoxTitle('Configuration Error', errorBoxWidth);
|
||||
logger.logBoxLine('No configuration found.');
|
||||
logger.logBoxLine("Please run 'nupst setup' first to create a configuration.");
|
||||
logger.logBoxEnd();
|
||||
logger.logBox('Configuration Error', [
|
||||
'No configuration found.',
|
||||
"Please run 'nupst ups add' first to create a configuration.",
|
||||
], 50, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -390,58 +399,56 @@ export class UpsHandler {
|
||||
// Check if multi-UPS config
|
||||
if (!config.upsDevices || !Array.isArray(config.upsDevices)) {
|
||||
// Legacy single UPS configuration
|
||||
const boxWidth = 45;
|
||||
logger.logBoxTitle('UPS Devices', boxWidth);
|
||||
logger.logBoxLine('Legacy single-UPS configuration detected.');
|
||||
if (!config.snmp || !config.thresholds) {
|
||||
logger.logBoxLine('');
|
||||
logger.logBoxLine('Error: Configuration missing SNMP or threshold settings');
|
||||
logger.logBoxEnd();
|
||||
return;
|
||||
}
|
||||
logger.logBoxLine('');
|
||||
logger.logBoxLine('Default UPS:');
|
||||
logger.logBoxLine(` Host: ${config.snmp.host}:${config.snmp.port}`);
|
||||
logger.logBoxLine(` Model: ${config.snmp.upsModel || 'cyberpower'}`);
|
||||
logger.logBoxLine(
|
||||
` Thresholds: ${config.thresholds.battery}% battery, ${config.thresholds.runtime} min runtime`,
|
||||
);
|
||||
logger.logBoxLine('');
|
||||
logger.logBoxLine('Use "nupst add" to add more UPS devices and migrate');
|
||||
logger.logBoxLine('to the multi-UPS configuration format.');
|
||||
logger.logBoxEnd();
|
||||
logger.logBox('UPS Devices', [
|
||||
'Legacy single-UPS configuration detected.',
|
||||
'',
|
||||
...(!config.snmp
|
||||
? ['Error: Configuration missing SNMP settings']
|
||||
: [
|
||||
'Default UPS:',
|
||||
` Host: ${config.snmp.host}:${config.snmp.port}`,
|
||||
` Model: ${config.snmp.upsModel || 'cyberpower'}`,
|
||||
'',
|
||||
'Use "nupst ups add" to add more UPS devices and migrate',
|
||||
'to the multi-UPS configuration format.',
|
||||
]
|
||||
),
|
||||
], 60, 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
// Display UPS list
|
||||
const boxWidth = 60;
|
||||
logger.logBoxTitle('UPS Devices', boxWidth);
|
||||
|
||||
// Display UPS list with modern table
|
||||
if (config.upsDevices.length === 0) {
|
||||
logger.logBoxLine('No UPS devices configured.');
|
||||
logger.logBoxLine('Use "nupst add" to add a UPS device.');
|
||||
} else {
|
||||
logger.logBoxLine(`Found ${config.upsDevices.length} UPS device(s)`);
|
||||
logger.logBoxLine('');
|
||||
logger.logBoxLine(
|
||||
'ID | Name | Host | Mode | Groups',
|
||||
);
|
||||
logger.logBoxLine(
|
||||
'-----------+----------------------+-----------------+--------------+----------------',
|
||||
);
|
||||
|
||||
for (const ups of config.upsDevices) {
|
||||
const id = ups.id.padEnd(10, ' ').substring(0, 10);
|
||||
const name = (ups.name || '').padEnd(20, ' ').substring(0, 20);
|
||||
const host = `${ups.snmp.host}:${ups.snmp.port}`.padEnd(15, ' ').substring(0, 15);
|
||||
const model = (ups.snmp.upsModel || 'cyberpower').padEnd(12, ' ').substring(0, 12);
|
||||
const groups = ups.groups.length > 0 ? ups.groups.join(', ') : 'None';
|
||||
|
||||
logger.logBoxLine(`${id} | ${name} | ${host} | ${model} | ${groups}`);
|
||||
}
|
||||
logger.logBox('UPS Devices', [
|
||||
'No UPS devices configured.',
|
||||
'',
|
||||
`${theme.dim('Run')} ${theme.command('nupst ups add')} ${theme.dim('to add a device')}`,
|
||||
], 60, 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.logBoxEnd();
|
||||
// Prepare table data
|
||||
const rows = config.upsDevices.map((ups) => ({
|
||||
id: ups.id,
|
||||
name: ups.name || '',
|
||||
host: `${ups.snmp.host}:${ups.snmp.port}`,
|
||||
model: ups.snmp.upsModel || 'cyberpower',
|
||||
groups: ups.groups.length > 0 ? ups.groups.join(', ') : theme.dim('None'),
|
||||
}));
|
||||
|
||||
const columns: ITableColumn[] = [
|
||||
{ header: 'ID', key: 'id', align: 'left', color: theme.highlight },
|
||||
{ header: 'Name', key: 'name', align: 'left' },
|
||||
{ header: 'Host:Port', key: 'host', align: 'left', color: theme.info },
|
||||
{ header: 'Model', key: 'model', align: 'left' },
|
||||
{ header: 'Groups', key: 'groups', align: 'left' },
|
||||
];
|
||||
|
||||
logger.log('');
|
||||
logger.info(`UPS Devices (${config.upsDevices.length}):`);
|
||||
logger.log('');
|
||||
logger.logTable(columns, rows);
|
||||
logger.log('');
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to list UPS devices: ${error instanceof Error ? error.message : String(error)}`,
|
||||
@@ -504,9 +511,8 @@ export class UpsHandler {
|
||||
*/
|
||||
private displayTestConfig(config: any): void {
|
||||
// Check if this is a UPS device or full configuration
|
||||
const isUpsConfig = config.snmp && config.thresholds;
|
||||
const isUpsConfig = config.snmp;
|
||||
const snmpConfig = isUpsConfig ? config.snmp : config.snmp || {};
|
||||
const thresholds = isUpsConfig ? config.thresholds : config.thresholds || {};
|
||||
const checkInterval = config.checkInterval || 30000;
|
||||
|
||||
// Get UPS name and ID if available
|
||||
@@ -550,10 +556,6 @@ export class UpsHandler {
|
||||
);
|
||||
logger.logBoxLine(` Battery Runtime: ${snmpConfig.customOIDs.BATTERY_RUNTIME || 'Not set'}`);
|
||||
}
|
||||
logger.logBoxLine('Thresholds:');
|
||||
logger.logBoxLine(` Battery: ${thresholds.battery}%`);
|
||||
logger.logBoxLine(` Runtime: ${thresholds.runtime} minutes`);
|
||||
|
||||
// Show group assignments if this is a UPS config
|
||||
if (config.groups && Array.isArray(config.groups)) {
|
||||
logger.logBoxLine(
|
||||
@@ -577,7 +579,6 @@ export class UpsHandler {
|
||||
try {
|
||||
// Create a test config with a short timeout
|
||||
const snmpConfig = config.snmp ? config.snmp : config.snmp;
|
||||
const thresholds = config.thresholds ? config.thresholds : config.thresholds;
|
||||
|
||||
const testConfig = {
|
||||
...snmpConfig,
|
||||
@@ -594,10 +595,7 @@ export class UpsHandler {
|
||||
logger.logBoxLine(` Runtime Remaining: ${status.batteryRuntime} minutes`);
|
||||
logger.logBoxEnd();
|
||||
|
||||
// Check status against thresholds if on battery
|
||||
if (status.powerStatus === 'onBattery') {
|
||||
this.analyzeThresholds(status, thresholds);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
const errorBoxWidth = 45;
|
||||
logger.logBoxTitle(`Connection Failed: ${upsName}`, errorBoxWidth);
|
||||
@@ -667,10 +665,11 @@ export class UpsHandler {
|
||||
|
||||
// SNMP Version
|
||||
const defaultVersion = snmpConfig.version || 1;
|
||||
console.log('\nSNMP Version:');
|
||||
console.log(' 1) SNMPv1');
|
||||
console.log(' 2) SNMPv2c');
|
||||
console.log(' 3) SNMPv3 (with security features)');
|
||||
logger.log('');
|
||||
logger.info('SNMP Version:');
|
||||
logger.dim(' 1) SNMPv1');
|
||||
logger.dim(' 2) SNMPv2c');
|
||||
logger.dim(' 3) SNMPv3 (with security features)');
|
||||
const versionInput = await prompt(`Select SNMP version [${defaultVersion}]: `);
|
||||
const version = parseInt(versionInput, 10);
|
||||
snmpConfig.version = versionInput.trim() && (version === 1 || version === 2 || version === 3)
|
||||
@@ -697,13 +696,15 @@ export class UpsHandler {
|
||||
snmpConfig: any,
|
||||
prompt: (question: string) => Promise<string>,
|
||||
): Promise<void> {
|
||||
console.log('\nSNMPv3 Security Settings:');
|
||||
logger.log('');
|
||||
logger.info('SNMPv3 Security Settings:');
|
||||
|
||||
// Security Level
|
||||
console.log('\nSecurity Level:');
|
||||
console.log(' 1) noAuthNoPriv (No Authentication, No Privacy)');
|
||||
console.log(' 2) authNoPriv (Authentication, No Privacy)');
|
||||
console.log(' 3) authPriv (Authentication and Privacy)');
|
||||
logger.log('');
|
||||
logger.info('Security Level:');
|
||||
logger.dim(' 1) noAuthNoPriv (No Authentication, No Privacy)');
|
||||
logger.dim(' 2) authNoPriv (Authentication, No Privacy)');
|
||||
logger.dim(' 3) authPriv (Authentication and Privacy)');
|
||||
const defaultSecLevel = snmpConfig.securityLevel
|
||||
? snmpConfig.securityLevel === 'noAuthNoPriv'
|
||||
? 1
|
||||
@@ -752,8 +753,9 @@ export class UpsHandler {
|
||||
|
||||
// Allow customizing the timeout value
|
||||
const defaultTimeout = snmpConfig.timeout / 1000; // Convert from ms to seconds for display
|
||||
console.log(
|
||||
'\nSNMPv3 operations with authentication and privacy may require longer timeouts.',
|
||||
logger.log('');
|
||||
logger.info(
|
||||
'SNMPv3 operations with authentication and privacy may require longer timeouts.',
|
||||
);
|
||||
const timeoutInput = await prompt(`SNMP Timeout in seconds [${defaultTimeout}]: `);
|
||||
const timeout = parseInt(timeoutInput, 10);
|
||||
@@ -773,9 +775,10 @@ export class UpsHandler {
|
||||
prompt: (question: string) => Promise<string>,
|
||||
): Promise<void> {
|
||||
// Authentication protocol
|
||||
console.log('\nAuthentication Protocol:');
|
||||
console.log(' 1) MD5');
|
||||
console.log(' 2) SHA');
|
||||
logger.log('');
|
||||
logger.info('Authentication Protocol:');
|
||||
logger.dim(' 1) MD5');
|
||||
logger.dim(' 2) SHA');
|
||||
const defaultAuthProtocol = snmpConfig.authProtocol === 'SHA' ? 2 : 1;
|
||||
const authProtocolInput = await prompt(
|
||||
`Select Authentication Protocol [${defaultAuthProtocol}]: `,
|
||||
@@ -799,9 +802,10 @@ export class UpsHandler {
|
||||
prompt: (question: string) => Promise<string>,
|
||||
): Promise<void> {
|
||||
// Privacy protocol
|
||||
console.log('\nPrivacy Protocol:');
|
||||
console.log(' 1) DES');
|
||||
console.log(' 2) AES');
|
||||
logger.log('');
|
||||
logger.info('Privacy Protocol:');
|
||||
logger.dim(' 1) DES');
|
||||
logger.dim(' 2) AES');
|
||||
const defaultPrivProtocol = snmpConfig.privProtocol === 'AES' ? 2 : 1;
|
||||
const privProtocolInput = await prompt(`Select Privacy Protocol [${defaultPrivProtocol}]: `);
|
||||
const privProtocol = parseInt(privProtocolInput, 10) || defaultPrivProtocol;
|
||||
@@ -813,38 +817,6 @@ export class UpsHandler {
|
||||
snmpConfig.privKey = privKey.trim() || defaultPrivKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gather threshold settings
|
||||
* @param thresholds Thresholds configuration object to update
|
||||
* @param prompt Function to prompt for user input
|
||||
*/
|
||||
private async gatherThresholdSettings(
|
||||
thresholds: any,
|
||||
prompt: (question: string) => Promise<string>,
|
||||
): Promise<void> {
|
||||
console.log('\nShutdown Thresholds:');
|
||||
|
||||
// Battery threshold
|
||||
const defaultBatteryThreshold = thresholds.battery || 60;
|
||||
const batteryThresholdInput = await prompt(
|
||||
`Battery percentage threshold [${defaultBatteryThreshold}%]: `,
|
||||
);
|
||||
const batteryThreshold = parseInt(batteryThresholdInput, 10);
|
||||
thresholds.battery = batteryThresholdInput.trim() && !isNaN(batteryThreshold)
|
||||
? batteryThreshold
|
||||
: defaultBatteryThreshold;
|
||||
|
||||
// Runtime threshold
|
||||
const defaultRuntimeThreshold = thresholds.runtime || 20;
|
||||
const runtimeThresholdInput = await prompt(
|
||||
`Runtime minutes threshold [${defaultRuntimeThreshold} minutes]: `,
|
||||
);
|
||||
const runtimeThreshold = parseInt(runtimeThresholdInput, 10);
|
||||
thresholds.runtime = runtimeThresholdInput.trim() && !isNaN(runtimeThreshold)
|
||||
? runtimeThreshold
|
||||
: defaultRuntimeThreshold;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gather UPS model settings
|
||||
* @param snmpConfig SNMP configuration object to update
|
||||
@@ -854,13 +826,14 @@ export class UpsHandler {
|
||||
snmpConfig: any,
|
||||
prompt: (question: string) => Promise<string>,
|
||||
): Promise<void> {
|
||||
console.log('\nUPS Model Selection:');
|
||||
console.log(' 1) CyberPower');
|
||||
console.log(' 2) APC');
|
||||
console.log(' 3) Eaton');
|
||||
console.log(' 4) TrippLite');
|
||||
console.log(' 5) Liebert/Vertiv');
|
||||
console.log(' 6) Custom (Advanced)');
|
||||
logger.log('');
|
||||
logger.info('UPS Model Selection:');
|
||||
logger.dim(' 1) CyberPower');
|
||||
logger.dim(' 2) APC');
|
||||
logger.dim(' 3) Eaton');
|
||||
logger.dim(' 4) TrippLite');
|
||||
logger.dim(' 5) Liebert/Vertiv');
|
||||
logger.dim(' 6) Custom (Advanced)');
|
||||
|
||||
const defaultModelValue = snmpConfig.upsModel === 'cyberpower'
|
||||
? 1
|
||||
@@ -891,8 +864,9 @@ export class UpsHandler {
|
||||
snmpConfig.upsModel = 'liebert';
|
||||
} else if (modelValue === 6) {
|
||||
snmpConfig.upsModel = 'custom';
|
||||
console.log('\nEnter custom OIDs for your UPS:');
|
||||
console.log('(Leave blank to use standard RFC 1628 OIDs as fallback)');
|
||||
logger.log('');
|
||||
logger.info('Enter custom OIDs for your UPS:');
|
||||
logger.dim('(Leave blank to use standard RFC 1628 OIDs as fallback)');
|
||||
|
||||
// Custom OIDs
|
||||
const powerStatusOID = await prompt('Power Status OID: ');
|
||||
@@ -908,6 +882,151 @@ export class UpsHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gather action configuration settings
|
||||
* @param actions Actions array to configure
|
||||
* @param prompt Function to prompt for user input
|
||||
*/
|
||||
private async gatherActionSettings(
|
||||
actions: any[],
|
||||
prompt: (question: string) => Promise<string>,
|
||||
): Promise<void> {
|
||||
logger.log('');
|
||||
logger.info('Action Configuration (Optional):');
|
||||
logger.dim('Actions are triggered on power status changes and threshold violations.');
|
||||
logger.dim('Leave empty to use default shutdown behavior on threshold violations.');
|
||||
|
||||
const configureActions = await prompt('Configure custom actions? (y/N): ');
|
||||
if (configureActions.toLowerCase() !== 'y') {
|
||||
return; // Keep existing actions or use default
|
||||
}
|
||||
|
||||
// Clear existing actions
|
||||
actions.length = 0;
|
||||
|
||||
let addMore = true;
|
||||
while (addMore) {
|
||||
logger.log('');
|
||||
logger.info('Action Type:');
|
||||
logger.dim(' 1) Shutdown (system shutdown)');
|
||||
logger.dim(' 2) Webhook (HTTP notification)');
|
||||
logger.dim(' 3) Custom Script (run .sh file from /etc/nupst)');
|
||||
|
||||
const typeInput = await prompt('Select action type [1]: ');
|
||||
const typeValue = parseInt(typeInput, 10) || 1;
|
||||
|
||||
const action: any = {};
|
||||
|
||||
if (typeValue === 1) {
|
||||
// Shutdown action
|
||||
action.type = 'shutdown';
|
||||
|
||||
const delayInput = await prompt('Shutdown delay in minutes [5]: ');
|
||||
const delay = parseInt(delayInput, 10);
|
||||
if (delayInput.trim() && !isNaN(delay)) {
|
||||
action.shutdownDelay = delay;
|
||||
}
|
||||
} else if (typeValue === 2) {
|
||||
// Webhook action
|
||||
action.type = 'webhook';
|
||||
|
||||
const url = await prompt('Webhook URL: ');
|
||||
if (!url.trim()) {
|
||||
logger.warn('Webhook URL required, skipping action');
|
||||
continue;
|
||||
}
|
||||
action.webhookUrl = url.trim();
|
||||
|
||||
logger.log('');
|
||||
logger.info('HTTP Method:');
|
||||
logger.dim(' 1) POST (JSON body)');
|
||||
logger.dim(' 2) GET (query parameters)');
|
||||
const methodInput = await prompt('Select method [1]: ');
|
||||
action.webhookMethod = methodInput === '2' ? 'GET' : 'POST';
|
||||
|
||||
const timeoutInput = await prompt('Timeout in seconds [10]: ');
|
||||
const timeout = parseInt(timeoutInput, 10);
|
||||
if (timeoutInput.trim() && !isNaN(timeout)) {
|
||||
action.webhookTimeout = timeout * 1000; // Convert to ms
|
||||
}
|
||||
} else if (typeValue === 3) {
|
||||
// Script action
|
||||
action.type = 'script';
|
||||
|
||||
const scriptPath = await prompt('Script filename (in /etc/nupst/, must end with .sh): ');
|
||||
if (!scriptPath.trim() || !scriptPath.trim().endsWith('.sh')) {
|
||||
logger.warn('Script path must end with .sh, skipping action');
|
||||
continue;
|
||||
}
|
||||
action.scriptPath = scriptPath.trim();
|
||||
|
||||
const timeoutInput = await prompt('Script timeout in seconds [60]: ');
|
||||
const timeout = parseInt(timeoutInput, 10);
|
||||
if (timeoutInput.trim() && !isNaN(timeout)) {
|
||||
action.scriptTimeout = timeout * 1000; // Convert to ms
|
||||
}
|
||||
} else {
|
||||
logger.warn('Invalid action type, skipping');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Configure trigger mode (applies to all action types)
|
||||
logger.log('');
|
||||
logger.info('Trigger Mode:');
|
||||
logger.dim(' 1) Power changes + thresholds (default)');
|
||||
logger.dim(' 2) Only power status changes');
|
||||
logger.dim(' 3) Only threshold violations');
|
||||
logger.dim(' 4) Any change (every ~30s check)');
|
||||
const triggerInput = await prompt('Select trigger mode [1]: ');
|
||||
const triggerValue = parseInt(triggerInput, 10) || 1;
|
||||
|
||||
switch (triggerValue) {
|
||||
case 2:
|
||||
action.triggerMode = 'onlyPowerChanges';
|
||||
break;
|
||||
case 3:
|
||||
action.triggerMode = 'onlyThresholds';
|
||||
break;
|
||||
case 4:
|
||||
action.triggerMode = 'anyChange';
|
||||
break;
|
||||
default:
|
||||
action.triggerMode = 'powerChangesAndThresholds';
|
||||
}
|
||||
|
||||
// Configure thresholds if needed for onlyThresholds or powerChangesAndThresholds modes
|
||||
if (action.triggerMode === 'onlyThresholds' || action.triggerMode === 'powerChangesAndThresholds') {
|
||||
logger.log('');
|
||||
logger.info('Action Thresholds:');
|
||||
logger.dim('Action will trigger when battery or runtime falls below these values (while on battery)');
|
||||
|
||||
const batteryInput = await prompt('Battery threshold percentage [60]: ');
|
||||
const battery = parseInt(batteryInput, 10);
|
||||
const batteryThreshold = (batteryInput.trim() && !isNaN(battery)) ? battery : 60;
|
||||
|
||||
const runtimeInput = await prompt('Runtime threshold in minutes [20]: ');
|
||||
const runtime = parseInt(runtimeInput, 10);
|
||||
const runtimeThreshold = (runtimeInput.trim() && !isNaN(runtime)) ? runtime : 20;
|
||||
|
||||
action.thresholds = {
|
||||
battery: batteryThreshold,
|
||||
runtime: runtimeThreshold,
|
||||
};
|
||||
}
|
||||
|
||||
actions.push(action);
|
||||
logger.success(`${action.type.charAt(0).toUpperCase() + action.type.slice(1)} action added (mode: ${action.triggerMode || 'powerChangesAndThresholds'})`);
|
||||
|
||||
const more = await prompt('Add another action? (y/N): ');
|
||||
addMore = more.toLowerCase() === 'y';
|
||||
}
|
||||
|
||||
if (actions.length > 0) {
|
||||
logger.log('');
|
||||
logger.success(`${actions.length} action(s) configured`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display UPS configuration summary
|
||||
* @param ups UPS configuration
|
||||
@@ -920,9 +1039,7 @@ export class UpsHandler {
|
||||
logger.logBoxLine(`SNMP Host: ${ups.snmp.host}:${ups.snmp.port}`);
|
||||
logger.logBoxLine(`SNMP Version: ${ups.snmp.version}`);
|
||||
logger.logBoxLine(`UPS Model: ${ups.snmp.upsModel}`);
|
||||
logger.logBoxLine(
|
||||
`Thresholds: ${ups.thresholds.battery}% battery, ${ups.thresholds.runtime} min runtime`,
|
||||
);
|
||||
|
||||
if (ups.groups && ups.groups.length > 0) {
|
||||
logger.logBoxLine(`Groups: ${ups.groups.join(', ')}`);
|
||||
} else {
|
||||
|
544
ts/daemon.ts
544
ts/daemon.ts
@@ -4,9 +4,13 @@ import * as path from 'node:path';
|
||||
import { exec, execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { NupstSnmp } from './snmp/manager.ts';
|
||||
import type { ISnmpConfig } from './snmp/types.ts';
|
||||
import { logger } from './logger.ts';
|
||||
import type { ISnmpConfig, IUpsStatus as ISnmpUpsStatus } from './snmp/types.ts';
|
||||
import { logger, type ITableColumn } from './logger.ts';
|
||||
import { MigrationRunner } from './migrations/index.ts';
|
||||
import { theme, symbols, getBatteryColor, getRuntimeColor, formatPowerStatus } from './colors.ts';
|
||||
import type { IActionConfig } from './actions/base-action.ts';
|
||||
import { ActionManager, type IActionContext, type TPowerStatus } from './actions/index.ts';
|
||||
import { NupstHttpServer } from './http-server.ts';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const execFileAsync = promisify(execFile);
|
||||
@@ -21,15 +25,10 @@ export interface IUpsConfig {
|
||||
name: string;
|
||||
/** SNMP configuration settings */
|
||||
snmp: ISnmpConfig;
|
||||
/** Threshold settings for initiating shutdown */
|
||||
thresholds: {
|
||||
/** Shutdown when battery below this percentage */
|
||||
battery: number;
|
||||
/** Shutdown when runtime below this minutes */
|
||||
runtime: number;
|
||||
};
|
||||
/** Group IDs this UPS belongs to */
|
||||
groups: string[];
|
||||
/** Actions to trigger on power status changes and threshold violations */
|
||||
actions?: IActionConfig[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -44,6 +43,22 @@ export interface IGroupConfig {
|
||||
mode: 'redundant' | 'nonRedundant';
|
||||
/** Optional description */
|
||||
description?: string;
|
||||
/** Actions to trigger on power status changes and threshold violations */
|
||||
actions?: IActionConfig[];
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP Server configuration interface
|
||||
*/
|
||||
export interface IHttpServerConfig {
|
||||
/** Whether HTTP server is enabled */
|
||||
enabled: boolean;
|
||||
/** Port to listen on */
|
||||
port: number;
|
||||
/** URL path for the endpoint */
|
||||
path: string;
|
||||
/** Authentication token */
|
||||
authToken: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -58,6 +73,8 @@ export interface INupstConfig {
|
||||
groups: IGroupConfig[];
|
||||
/** Check interval in milliseconds */
|
||||
checkInterval: number;
|
||||
/** HTTP Server configuration */
|
||||
httpServer?: IHttpServerConfig;
|
||||
|
||||
// Legacy fields for backward compatibility (will be migrated away)
|
||||
/** UPS list (v3 format - legacy) */
|
||||
@@ -76,12 +93,16 @@ export interface INupstConfig {
|
||||
/**
|
||||
* UPS status tracking interface
|
||||
*/
|
||||
interface IUpsStatus {
|
||||
export interface IUpsStatus {
|
||||
id: string;
|
||||
name: string;
|
||||
powerStatus: 'online' | 'onBattery' | 'unknown';
|
||||
batteryCapacity: number;
|
||||
batteryRuntime: number;
|
||||
outputLoad: number; // Load percentage (0-100%)
|
||||
outputPower: number; // Power in watts
|
||||
outputVoltage: number; // Voltage in volts
|
||||
outputCurrent: number; // Current in amps
|
||||
lastStatusChange: number;
|
||||
lastCheckTime: number;
|
||||
}
|
||||
@@ -96,7 +117,7 @@ export class NupstDaemon {
|
||||
|
||||
/** Default configuration */
|
||||
private readonly DEFAULT_CONFIG: INupstConfig = {
|
||||
version: '4.0',
|
||||
version: '4.2',
|
||||
upsDevices: [
|
||||
{
|
||||
id: 'default',
|
||||
@@ -117,21 +138,29 @@ export class NupstDaemon {
|
||||
// UPS model for OID selection
|
||||
upsModel: 'cyberpower',
|
||||
},
|
||||
thresholds: {
|
||||
battery: 60, // Shutdown when battery below 60%
|
||||
runtime: 20, // Shutdown when runtime below 20 minutes
|
||||
},
|
||||
groups: [],
|
||||
actions: [
|
||||
{
|
||||
type: 'shutdown',
|
||||
triggerMode: 'onlyThresholds',
|
||||
thresholds: {
|
||||
battery: 60, // Shutdown when battery below 60%
|
||||
runtime: 20, // Shutdown when runtime below 20 minutes
|
||||
},
|
||||
shutdownDelay: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
groups: [],
|
||||
checkInterval: 30000, // Check every 30 seconds
|
||||
};
|
||||
}
|
||||
|
||||
private config: INupstConfig;
|
||||
private snmp: NupstSnmp;
|
||||
private isRunning: boolean = false;
|
||||
private upsStatus: Map<string, IUpsStatus> = new Map();
|
||||
private httpServer?: NupstHttpServer;
|
||||
|
||||
/**
|
||||
* Create a new daemon instance with the given SNMP manager
|
||||
@@ -164,11 +193,13 @@ export class NupstDaemon {
|
||||
const { config: migratedConfig, migrated } = await migrationRunner.run(parsedConfig);
|
||||
|
||||
// Save migrated config back to disk if any migrations ran
|
||||
// Cast to INupstConfig since migrations ensure the output is valid
|
||||
const validConfig = migratedConfig as unknown as INupstConfig;
|
||||
if (migrated) {
|
||||
this.config = migratedConfig;
|
||||
this.config = validConfig;
|
||||
await this.saveConfig(this.config);
|
||||
} else {
|
||||
this.config = migratedConfig;
|
||||
this.config = validConfig;
|
||||
}
|
||||
|
||||
return this.config;
|
||||
@@ -198,7 +229,7 @@ export class NupstDaemon {
|
||||
|
||||
// Ensure version is always set and remove legacy fields before saving
|
||||
const configToSave: INupstConfig = {
|
||||
version: '4.0',
|
||||
version: '4.1',
|
||||
upsDevices: config.upsDevices,
|
||||
groups: config.groups,
|
||||
checkInterval: config.checkInterval,
|
||||
@@ -207,11 +238,9 @@ export class NupstDaemon {
|
||||
fs.writeFileSync(this.CONFIG_PATH, JSON.stringify(configToSave, null, 2));
|
||||
this.config = configToSave;
|
||||
|
||||
console.log('┌─ Configuration Saved ─────────────────────┐');
|
||||
console.log(`│ Location: ${this.CONFIG_PATH}`);
|
||||
console.log('└──────────────────────────────────────────┘');
|
||||
logger.logBox('Configuration Saved', [`Location: ${this.CONFIG_PATH}`], 45, 'success');
|
||||
} catch (error) {
|
||||
console.error('Error saving configuration:', error);
|
||||
logger.error(`Error saving configuration: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,10 +248,7 @@ export class NupstDaemon {
|
||||
* Helper method to log configuration errors consistently
|
||||
*/
|
||||
private logConfigError(message: string): void {
|
||||
console.error('┌─ Configuration Error ─────────────────────┐');
|
||||
console.error(`│ ${message}`);
|
||||
console.error("│ Please run 'nupst setup' first to create a configuration.");
|
||||
console.error('└───────────────────────────────────────────┘');
|
||||
logger.logBox('Configuration Error', [message, "Please run 'nupst setup' first to create a configuration."], 45, 'error');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -274,6 +300,21 @@ export class NupstDaemon {
|
||||
// Initialize UPS status tracking
|
||||
this.initializeUpsStatus();
|
||||
|
||||
// Start HTTP server if configured
|
||||
if (this.config.httpServer?.enabled && this.config.httpServer.authToken) {
|
||||
try {
|
||||
this.httpServer = new NupstHttpServer(
|
||||
this.config.httpServer.port,
|
||||
this.config.httpServer.path,
|
||||
this.config.httpServer.authToken,
|
||||
() => this.upsStatus
|
||||
);
|
||||
this.httpServer.start();
|
||||
} catch (error) {
|
||||
logger.error(`Failed to start HTTP server: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Start UPS monitoring
|
||||
this.isRunning = true;
|
||||
await this.monitor();
|
||||
@@ -300,6 +341,10 @@ export class NupstDaemon {
|
||||
powerStatus: 'unknown',
|
||||
batteryCapacity: 100,
|
||||
batteryRuntime: 999, // High value as default
|
||||
outputLoad: 0,
|
||||
outputPower: 0,
|
||||
outputVoltage: 0,
|
||||
outputCurrent: 0,
|
||||
lastStatusChange: Date.now(),
|
||||
lastCheckTime: 0,
|
||||
});
|
||||
@@ -315,29 +360,57 @@ export class NupstDaemon {
|
||||
* Log the loaded configuration settings
|
||||
*/
|
||||
private logConfigLoaded(): void {
|
||||
const boxWidth = 50;
|
||||
logger.logBoxTitle('Configuration Loaded', boxWidth);
|
||||
|
||||
if (this.config.upsDevices && this.config.upsDevices.length > 0) {
|
||||
logger.logBoxLine(`UPS Devices: ${this.config.upsDevices.length}`);
|
||||
for (const ups of this.config.upsDevices) {
|
||||
logger.logBoxLine(` - ${ups.name} (${ups.id}): ${ups.snmp.host}:${ups.snmp.port}`);
|
||||
}
|
||||
} else {
|
||||
logger.logBoxLine('No UPS devices configured');
|
||||
}
|
||||
|
||||
if (this.config.groups && this.config.groups.length > 0) {
|
||||
logger.logBoxLine(`Groups: ${this.config.groups.length}`);
|
||||
for (const group of this.config.groups) {
|
||||
logger.logBoxLine(` - ${group.name} (${group.id}): ${group.mode} mode`);
|
||||
}
|
||||
} else {
|
||||
logger.logBoxLine('No Groups configured');
|
||||
}
|
||||
|
||||
|
||||
logger.log('');
|
||||
logger.logBoxTitle('Configuration Loaded', 70, 'success');
|
||||
logger.logBoxLine(`Check Interval: ${this.config.checkInterval / 1000} seconds`);
|
||||
logger.logBoxEnd();
|
||||
logger.log('');
|
||||
|
||||
// Display UPS devices in a table
|
||||
if (this.config.upsDevices && this.config.upsDevices.length > 0) {
|
||||
logger.info(`UPS Devices (${this.config.upsDevices.length}):`);
|
||||
|
||||
const upsColumns: Array<{ header: string; key: string; align?: 'left' | 'right'; color?: (val: string) => string }> = [
|
||||
{ header: 'Name', key: 'name', align: 'left', color: theme.highlight },
|
||||
{ header: 'ID', key: 'id', align: 'left', color: theme.dim },
|
||||
{ header: 'Host:Port', key: 'host', align: 'left', color: theme.info },
|
||||
{ header: 'Actions', key: 'actions', align: 'left' },
|
||||
];
|
||||
|
||||
const upsRows: Array<Record<string, string>> = this.config.upsDevices.map((ups) => ({
|
||||
name: ups.name,
|
||||
id: ups.id,
|
||||
host: `${ups.snmp.host}:${ups.snmp.port}`,
|
||||
actions: `${(ups.actions || []).length} configured`,
|
||||
}));
|
||||
|
||||
logger.logTable(upsColumns, upsRows);
|
||||
logger.log('');
|
||||
} else {
|
||||
logger.warn('No UPS devices configured');
|
||||
logger.log('');
|
||||
}
|
||||
|
||||
// Display groups in a table
|
||||
if (this.config.groups && this.config.groups.length > 0) {
|
||||
logger.info(`Groups (${this.config.groups.length}):`);
|
||||
|
||||
const groupColumns: Array<{ header: string; key: string; align?: 'left' | 'right'; color?: (val: string) => string }> = [
|
||||
{ header: 'Name', key: 'name', align: 'left', color: theme.highlight },
|
||||
{ header: 'ID', key: 'id', align: 'left', color: theme.dim },
|
||||
{ header: 'Mode', key: 'mode', align: 'left', color: theme.info },
|
||||
];
|
||||
|
||||
const groupRows: Array<Record<string, string>> = this.config.groups.map((group) => ({
|
||||
name: group.name,
|
||||
id: group.id,
|
||||
mode: group.mode,
|
||||
}));
|
||||
|
||||
logger.logTable(groupColumns, groupRows);
|
||||
logger.log('');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -345,6 +418,12 @@ export class NupstDaemon {
|
||||
*/
|
||||
public stop(): void {
|
||||
logger.log('Stopping NUPST daemon...');
|
||||
|
||||
// Stop HTTP server if running
|
||||
if (this.httpServer) {
|
||||
this.httpServer.stop();
|
||||
}
|
||||
|
||||
this.isRunning = false;
|
||||
}
|
||||
|
||||
@@ -377,9 +456,6 @@ export class NupstDaemon {
|
||||
lastLogTime = currentTime;
|
||||
}
|
||||
|
||||
// Check if shutdown is required based on group configurations
|
||||
await this.evaluateGroupShutdownConditions();
|
||||
|
||||
// Wait before next check
|
||||
await this.sleep(this.config.checkInterval);
|
||||
} catch (error) {
|
||||
@@ -408,6 +484,10 @@ export class NupstDaemon {
|
||||
powerStatus: 'unknown',
|
||||
batteryCapacity: 100,
|
||||
batteryRuntime: 999,
|
||||
outputLoad: 0,
|
||||
outputPower: 0,
|
||||
outputVoltage: 0,
|
||||
outputCurrent: 0,
|
||||
lastStatusChange: Date.now(),
|
||||
lastCheckTime: 0,
|
||||
});
|
||||
@@ -427,17 +507,52 @@ export class NupstDaemon {
|
||||
powerStatus: status.powerStatus,
|
||||
batteryCapacity: status.batteryCapacity,
|
||||
batteryRuntime: status.batteryRuntime,
|
||||
outputLoad: status.outputLoad,
|
||||
outputPower: status.outputPower,
|
||||
outputVoltage: status.outputVoltage,
|
||||
outputCurrent: status.outputCurrent,
|
||||
lastCheckTime: currentTime,
|
||||
lastStatusChange: currentStatus?.lastStatusChange || currentTime,
|
||||
};
|
||||
|
||||
// Check if power status changed
|
||||
if (currentStatus && currentStatus.powerStatus !== status.powerStatus) {
|
||||
logger.logBoxTitle(`Power Status Change: ${ups.name}`, 50);
|
||||
logger.logBoxLine(`Status changed: ${currentStatus.powerStatus} → ${status.powerStatus}`);
|
||||
logger.log('');
|
||||
logger.logBoxTitle(`Power Status Change: ${ups.name}`, 60, 'warning');
|
||||
logger.logBoxLine(`Previous: ${formatPowerStatus(currentStatus.powerStatus)}`);
|
||||
logger.logBoxLine(`Current: ${formatPowerStatus(status.powerStatus)}`);
|
||||
logger.logBoxLine(`Time: ${new Date().toISOString()}`);
|
||||
logger.logBoxEnd();
|
||||
logger.log('');
|
||||
|
||||
updatedStatus.lastStatusChange = currentTime;
|
||||
|
||||
// Trigger actions for power status change
|
||||
await this.triggerUpsActions(ups, updatedStatus, currentStatus, 'powerStatusChange');
|
||||
}
|
||||
|
||||
// Check if any action's thresholds are exceeded (for threshold violation triggers)
|
||||
// Only check when on battery power
|
||||
if (status.powerStatus === 'onBattery' && ups.actions && ups.actions.length > 0) {
|
||||
let anyThresholdExceeded = false;
|
||||
|
||||
for (const actionConfig of ups.actions) {
|
||||
if (actionConfig.thresholds) {
|
||||
if (
|
||||
status.batteryCapacity < actionConfig.thresholds.battery ||
|
||||
status.batteryRuntime < actionConfig.thresholds.runtime
|
||||
) {
|
||||
anyThresholdExceeded = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger actions with threshold violation reason if any threshold is exceeded
|
||||
// Actions will individually check their own thresholds in shouldExecute()
|
||||
if (anyThresholdExceeded) {
|
||||
await this.triggerUpsActions(ups, updatedStatus, currentStatus, 'thresholdViolation');
|
||||
}
|
||||
}
|
||||
|
||||
// Update the status in the map
|
||||
@@ -457,171 +572,100 @@ export class NupstDaemon {
|
||||
*/
|
||||
private logAllUpsStatus(): void {
|
||||
const timestamp = new Date().toISOString();
|
||||
const boxWidth = 60;
|
||||
logger.logBoxTitle('Periodic Status Update', boxWidth);
|
||||
|
||||
logger.log('');
|
||||
logger.logBoxTitle('Periodic Status Update', 70, 'info');
|
||||
logger.logBoxLine(`Timestamp: ${timestamp}`);
|
||||
logger.logBoxLine('');
|
||||
|
||||
for (const [id, status] of this.upsStatus.entries()) {
|
||||
logger.logBoxLine(`UPS: ${status.name} (${id})`);
|
||||
logger.logBoxLine(` Power Status: ${status.powerStatus}`);
|
||||
logger.logBoxLine(
|
||||
` Battery: ${status.batteryCapacity}% | Runtime: ${status.batteryRuntime} min`,
|
||||
);
|
||||
logger.logBoxLine('');
|
||||
}
|
||||
|
||||
logger.logBoxEnd();
|
||||
logger.log('');
|
||||
|
||||
// Build table data
|
||||
const columns: Array<{ header: string; key: string; align?: 'left' | 'right'; color?: (val: string) => string }> = [
|
||||
{ header: 'UPS Name', key: 'name', align: 'left', color: theme.highlight },
|
||||
{ header: 'ID', key: 'id', align: 'left', color: theme.dim },
|
||||
{ header: 'Power Status', key: 'powerStatus', align: 'left' },
|
||||
{ header: 'Battery', key: 'battery', align: 'right' },
|
||||
{ header: 'Runtime', key: 'runtime', align: 'right' },
|
||||
];
|
||||
|
||||
const rows: Array<Record<string, string>> = [];
|
||||
for (const [id, status] of this.upsStatus.entries()) {
|
||||
const batteryColor = getBatteryColor(status.batteryCapacity);
|
||||
const runtimeColor = getRuntimeColor(status.batteryRuntime);
|
||||
|
||||
rows.push({
|
||||
name: status.name,
|
||||
id: id,
|
||||
powerStatus: formatPowerStatus(status.powerStatus),
|
||||
battery: batteryColor(status.batteryCapacity + '%'),
|
||||
runtime: runtimeColor(status.batteryRuntime + ' min'),
|
||||
});
|
||||
}
|
||||
|
||||
logger.logTable(columns, rows);
|
||||
logger.log('');
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Build action context from UPS state
|
||||
* @param ups UPS configuration
|
||||
* @param status Current UPS status
|
||||
* @param triggerReason Why this action is being triggered
|
||||
* @returns Action context
|
||||
*/
|
||||
private buildActionContext(
|
||||
ups: IUpsConfig,
|
||||
status: IUpsStatus,
|
||||
triggerReason: 'powerStatusChange' | 'thresholdViolation',
|
||||
): IActionContext {
|
||||
return {
|
||||
upsId: ups.id,
|
||||
upsName: ups.name,
|
||||
powerStatus: status.powerStatus as TPowerStatus,
|
||||
batteryCapacity: status.batteryCapacity,
|
||||
batteryRuntime: status.batteryRuntime,
|
||||
previousPowerStatus: 'unknown' as TPowerStatus, // Will be set from map in calling code
|
||||
timestamp: Date.now(),
|
||||
triggerReason,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate if shutdown is required based on group configurations
|
||||
* Trigger actions for a UPS device
|
||||
* @param ups UPS configuration
|
||||
* @param status Current UPS status
|
||||
* @param previousStatus Previous UPS status (for determining previousPowerStatus)
|
||||
* @param triggerReason Why actions are being triggered
|
||||
*/
|
||||
private async evaluateGroupShutdownConditions(): Promise<void> {
|
||||
if (!this.config.groups || this.config.groups.length === 0) {
|
||||
// No groups defined, check individual UPS conditions
|
||||
for (const [id, status] of this.upsStatus.entries()) {
|
||||
if (status.powerStatus === 'onBattery') {
|
||||
// Find the UPS config
|
||||
const ups = this.config.upsDevices.find((u) => u.id === id);
|
||||
if (ups) {
|
||||
await this.evaluateUpsShutdownCondition(ups, status);
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Evaluate each group
|
||||
for (const group of this.config.groups) {
|
||||
// Find all UPS devices in this group
|
||||
const upsDevicesInGroup = this.config.upsDevices.filter((ups) =>
|
||||
ups.groups && ups.groups.includes(group.id)
|
||||
);
|
||||
|
||||
if (upsDevicesInGroup.length === 0) {
|
||||
// No UPS devices in this group
|
||||
continue;
|
||||
}
|
||||
|
||||
if (group.mode === 'redundant') {
|
||||
// Redundant mode: only shutdown if ALL UPS devices in the group are in critical condition
|
||||
await this.evaluateRedundantGroup(group, upsDevicesInGroup);
|
||||
} else {
|
||||
// Non-redundant mode: shutdown if ANY UPS device in the group is in critical condition
|
||||
await this.evaluateNonRedundantGroup(group, upsDevicesInGroup);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a redundant group for shutdown conditions
|
||||
* In redundant mode, we only shut down if ALL UPS devices are in critical condition
|
||||
*/
|
||||
private async evaluateRedundantGroup(
|
||||
group: IGroupConfig,
|
||||
upsDevices: IUpsConfig[],
|
||||
private async triggerUpsActions(
|
||||
ups: IUpsConfig,
|
||||
status: IUpsStatus,
|
||||
previousStatus: IUpsStatus | undefined,
|
||||
triggerReason: 'powerStatusChange' | 'thresholdViolation',
|
||||
): Promise<void> {
|
||||
// Count UPS devices on battery and in critical condition
|
||||
let upsOnBattery = 0;
|
||||
let upsInCriticalCondition = 0;
|
||||
|
||||
for (const ups of upsDevices) {
|
||||
const status = this.upsStatus.get(ups.id);
|
||||
if (!status) continue;
|
||||
|
||||
if (status.powerStatus === 'onBattery') {
|
||||
upsOnBattery++;
|
||||
|
||||
// Check if this UPS is in critical condition
|
||||
if (
|
||||
status.batteryCapacity < ups.thresholds.battery ||
|
||||
status.batteryRuntime < ups.thresholds.runtime
|
||||
) {
|
||||
upsInCriticalCondition++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// All UPS devices must be online for a redundant group to be considered healthy
|
||||
const allUpsCount = upsDevices.length;
|
||||
|
||||
// If all UPS are on battery and in critical condition, shutdown
|
||||
if (upsOnBattery === allUpsCount && upsInCriticalCondition === allUpsCount) {
|
||||
logger.logBoxTitle(`Group Shutdown Required: ${group.name}`, 50);
|
||||
logger.logBoxLine(`Mode: Redundant`);
|
||||
logger.logBoxLine(`All ${allUpsCount} UPS devices in critical condition`);
|
||||
logger.logBoxEnd();
|
||||
|
||||
await this.initiateShutdown(
|
||||
`All UPS devices in redundant group "${group.name}" in critical condition`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a non-redundant group for shutdown conditions
|
||||
* In non-redundant mode, we shut down if ANY UPS device is in critical condition
|
||||
*/
|
||||
private async evaluateNonRedundantGroup(
|
||||
group: IGroupConfig,
|
||||
upsDevices: IUpsConfig[],
|
||||
): Promise<void> {
|
||||
for (const ups of upsDevices) {
|
||||
const status = this.upsStatus.get(ups.id);
|
||||
if (!status) continue;
|
||||
|
||||
if (status.powerStatus === 'onBattery') {
|
||||
// Check if this UPS is in critical condition
|
||||
if (
|
||||
status.batteryCapacity < ups.thresholds.battery ||
|
||||
status.batteryRuntime < ups.thresholds.runtime
|
||||
) {
|
||||
logger.logBoxTitle(`Group Shutdown Required: ${group.name}`, 50);
|
||||
logger.logBoxLine(`Mode: Non-Redundant`);
|
||||
logger.logBoxLine(`UPS ${ups.name} in critical condition`);
|
||||
logger.logBoxLine(
|
||||
`Battery: ${status.batteryCapacity}% (threshold: ${ups.thresholds.battery}%)`,
|
||||
);
|
||||
logger.logBoxLine(
|
||||
`Runtime: ${status.batteryRuntime} min (threshold: ${ups.thresholds.runtime} min)`,
|
||||
);
|
||||
logger.logBoxEnd();
|
||||
|
||||
await this.initiateShutdown(
|
||||
`UPS "${ups.name}" in non-redundant group "${group.name}" in critical condition`,
|
||||
);
|
||||
return; // Exit after initiating shutdown
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate an individual UPS for shutdown conditions
|
||||
*/
|
||||
private async evaluateUpsShutdownCondition(ups: IUpsConfig, status: IUpsStatus): Promise<void> {
|
||||
// Only evaluate UPS devices not in any group
|
||||
if (ups.groups && ups.groups.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check threshold conditions
|
||||
if (
|
||||
status.batteryCapacity < ups.thresholds.battery ||
|
||||
status.batteryRuntime < ups.thresholds.runtime
|
||||
) {
|
||||
logger.logBoxTitle(`UPS Shutdown Required: ${ups.name}`, 50);
|
||||
logger.logBoxLine(
|
||||
`Battery: ${status.batteryCapacity}% (threshold: ${ups.thresholds.battery}%)`,
|
||||
);
|
||||
logger.logBoxLine(
|
||||
`Runtime: ${status.batteryRuntime} min (threshold: ${ups.thresholds.runtime} min)`,
|
||||
);
|
||||
logger.logBoxEnd();
|
||||
const actions = ups.actions || [];
|
||||
|
||||
// Backward compatibility: if no actions configured, use default shutdown behavior
|
||||
if (actions.length === 0 && triggerReason === 'thresholdViolation') {
|
||||
// Fall back to old shutdown logic for backward compatibility
|
||||
await this.initiateShutdown(`UPS "${ups.name}" battery or runtime below threshold`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (actions.length === 0) {
|
||||
return; // No actions to execute
|
||||
}
|
||||
|
||||
// Build action context
|
||||
const context = this.buildActionContext(ups, status, triggerReason);
|
||||
context.previousPowerStatus = (previousStatus?.powerStatus || 'unknown') as TPowerStatus;
|
||||
|
||||
// Execute actions
|
||||
await ActionManager.executeActions(actions, context);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -750,38 +794,61 @@ export class NupstDaemon {
|
||||
const MAX_MONITORING_TIME = 5 * 60 * 1000; // Max 5 minutes of monitoring
|
||||
const startTime = Date.now();
|
||||
|
||||
logger.log(
|
||||
`Emergency shutdown threshold: ${EMERGENCY_RUNTIME_THRESHOLD} minutes remaining battery runtime`,
|
||||
);
|
||||
logger.log('');
|
||||
logger.logBoxTitle('Shutdown Monitoring Active', 60, 'warning');
|
||||
logger.logBoxLine(`Emergency threshold: ${EMERGENCY_RUNTIME_THRESHOLD} minutes runtime`);
|
||||
logger.logBoxLine(`Check interval: ${CHECK_INTERVAL / 1000} seconds`);
|
||||
logger.logBoxLine(`Max monitoring time: ${MAX_MONITORING_TIME / 1000} seconds`);
|
||||
logger.logBoxEnd();
|
||||
logger.log('');
|
||||
|
||||
// Continue monitoring until max monitoring time is reached
|
||||
while (Date.now() - startTime < MAX_MONITORING_TIME) {
|
||||
try {
|
||||
logger.log('Checking UPS status during shutdown...');
|
||||
logger.info('Checking UPS status during shutdown...');
|
||||
|
||||
// Build table for UPS status during shutdown
|
||||
const columns: Array<{ header: string; key: string; align?: 'left' | 'right'; color?: (val: string) => string }> = [
|
||||
{ header: 'UPS Name', key: 'name', align: 'left', color: theme.highlight },
|
||||
{ header: 'Battery', key: 'battery', align: 'right' },
|
||||
{ header: 'Runtime', key: 'runtime', align: 'right' },
|
||||
{ header: 'Status', key: 'status', align: 'left' },
|
||||
];
|
||||
|
||||
const rows: Array<Record<string, string>> = [];
|
||||
let emergencyDetected = false;
|
||||
let emergencyUps: { ups: IUpsConfig; status: ISnmpUpsStatus } | null = null;
|
||||
|
||||
// Check all UPS devices
|
||||
for (const ups of this.config.upsDevices) {
|
||||
try {
|
||||
const status = await this.snmp.getUpsStatus(ups.snmp);
|
||||
|
||||
logger.log(
|
||||
`UPS ${ups.name}: Battery ${status.batteryCapacity}%, Runtime: ${status.batteryRuntime} minutes`,
|
||||
);
|
||||
const batteryColor = getBatteryColor(status.batteryCapacity);
|
||||
const runtimeColor = getRuntimeColor(status.batteryRuntime);
|
||||
|
||||
// If any UPS battery runtime gets critically low, force immediate shutdown
|
||||
if (status.batteryRuntime < EMERGENCY_RUNTIME_THRESHOLD) {
|
||||
logger.logBoxTitle('EMERGENCY SHUTDOWN', 50);
|
||||
logger.logBoxLine(
|
||||
`UPS ${ups.name} runtime critically low: ${status.batteryRuntime} minutes`,
|
||||
);
|
||||
logger.logBoxLine('Forcing immediate shutdown!');
|
||||
logger.logBoxEnd();
|
||||
const isCritical = status.batteryRuntime < EMERGENCY_RUNTIME_THRESHOLD;
|
||||
|
||||
rows.push({
|
||||
name: ups.name,
|
||||
battery: batteryColor(status.batteryCapacity + '%'),
|
||||
runtime: runtimeColor(status.batteryRuntime + ' min'),
|
||||
status: isCritical ? theme.error('CRITICAL!') : theme.success('OK'),
|
||||
});
|
||||
|
||||
// Force immediate shutdown
|
||||
await this.forceImmediateShutdown();
|
||||
return;
|
||||
// If any UPS battery runtime gets critically low, flag for immediate shutdown
|
||||
if (isCritical && !emergencyDetected) {
|
||||
emergencyDetected = true;
|
||||
emergencyUps = { ups, status };
|
||||
}
|
||||
} catch (upsError) {
|
||||
rows.push({
|
||||
name: ups.name,
|
||||
battery: theme.error('N/A'),
|
||||
runtime: theme.error('N/A'),
|
||||
status: theme.error('ERROR'),
|
||||
});
|
||||
|
||||
logger.error(
|
||||
`Error checking UPS ${ups.name} during shutdown: ${
|
||||
upsError instanceof Error ? upsError.message : String(upsError)
|
||||
@@ -790,6 +857,27 @@ export class NupstDaemon {
|
||||
}
|
||||
}
|
||||
|
||||
// Display the table
|
||||
logger.logTable(columns, rows);
|
||||
logger.log('');
|
||||
|
||||
// If emergency detected, trigger immediate shutdown
|
||||
if (emergencyDetected && emergencyUps) {
|
||||
logger.log('');
|
||||
logger.logBoxTitle('EMERGENCY SHUTDOWN', 60, 'error');
|
||||
logger.logBoxLine(
|
||||
`UPS ${emergencyUps.ups.name} runtime critically low: ${emergencyUps.status.batteryRuntime} minutes`,
|
||||
);
|
||||
logger.logBoxLine(`Emergency threshold: ${EMERGENCY_RUNTIME_THRESHOLD} minutes`);
|
||||
logger.logBoxLine('Forcing immediate shutdown!');
|
||||
logger.logBoxEnd();
|
||||
logger.log('');
|
||||
|
||||
// Force immediate shutdown
|
||||
await this.forceImmediateShutdown();
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait before checking again
|
||||
await this.sleep(CHECK_INTERVAL);
|
||||
} catch (error) {
|
||||
@@ -802,7 +890,9 @@ export class NupstDaemon {
|
||||
}
|
||||
}
|
||||
|
||||
logger.log('UPS monitoring during shutdown completed');
|
||||
logger.log('');
|
||||
logger.success('UPS monitoring during shutdown completed');
|
||||
logger.log('');
|
||||
}
|
||||
|
||||
/**
|
||||
|
113
ts/http-server.ts
Normal file
113
ts/http-server.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import * as http from 'node:http';
|
||||
import { URL } from 'node:url';
|
||||
import { logger } from './logger.ts';
|
||||
import type { IUpsStatus } from './daemon.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>;
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
constructor(
|
||||
port: number,
|
||||
path: string,
|
||||
authToken: string,
|
||||
getUpsStatus: () => Map<string, IUpsStatus>
|
||||
) {
|
||||
this.port = port;
|
||||
this.path = path;
|
||||
this.authToken = authToken;
|
||||
this.getUpsStatus = getUpsStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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());
|
||||
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-cache'
|
||||
});
|
||||
res.end(JSON.stringify(statusArray, 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: any) => {
|
||||
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');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
@@ -5,16 +5,14 @@
|
||||
* 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 {
|
||||
/**
|
||||
* Migration order number
|
||||
* - Order 2: v1 → v2
|
||||
* - Order 3: v2 → v3
|
||||
* - Order 4: v3 → v4
|
||||
* etc.
|
||||
*/
|
||||
abstract readonly order: number;
|
||||
|
||||
/**
|
||||
* Source version this migration upgrades from
|
||||
* e.g., "1.x", "3.x"
|
||||
@@ -23,25 +21,25 @@ export abstract class BaseMigration {
|
||||
|
||||
/**
|
||||
* Target version this migration upgrades to
|
||||
* e.g., "2.0", "4.0"
|
||||
* 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
|
||||
* @param config - Raw configuration object to check (unknown schema for migrations)
|
||||
* @returns True if migration should run, false otherwise
|
||||
*/
|
||||
abstract shouldRun(config: any): Promise<boolean>;
|
||||
abstract shouldRun(config: Record<string, unknown>): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Perform the migration on the given config
|
||||
*
|
||||
* @param config - Raw configuration object to migrate
|
||||
* @param config - Raw configuration object to migrate (unknown schema for migrations)
|
||||
* @returns Migrated configuration object
|
||||
*/
|
||||
abstract migrate(config: any): Promise<any>;
|
||||
abstract migrate(config: Record<string, unknown>): Promise<Record<string, unknown>>;
|
||||
|
||||
/**
|
||||
* Get human-readable name for this migration
|
||||
@@ -51,4 +49,19 @@ export abstract class BaseMigration {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
@@ -8,3 +8,4 @@ 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';
|
||||
|
@@ -1,6 +1,7 @@
|
||||
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 { logger } from '../logger.ts';
|
||||
|
||||
/**
|
||||
@@ -17,11 +18,12 @@ export class MigrationRunner {
|
||||
this.migrations = [
|
||||
new MigrationV1ToV2(),
|
||||
new MigrationV3ToV4(),
|
||||
// Add future migrations here (v4→v5, v5→v6, etc.)
|
||||
new MigrationV4_0ToV4_1(),
|
||||
// Add future migrations here (v4.3, v4.4, etc.)
|
||||
];
|
||||
|
||||
// Sort by order to ensure they run in sequence
|
||||
this.migrations.sort((a, b) => a.order - b.order);
|
||||
// Sort by version order to ensure they run in sequence
|
||||
this.migrations.sort((a, b) => a.getVersionOrder() - b.getVersionOrder());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -30,16 +32,20 @@ export class MigrationRunner {
|
||||
* @param config - Raw configuration object to migrate
|
||||
* @returns Migrated configuration and whether migrations ran
|
||||
*/
|
||||
async run(config: any): Promise<{ config: any; migrated: boolean }> {
|
||||
async run(
|
||||
config: Record<string, unknown>,
|
||||
): Promise<{ config: Record<string, unknown>; migrated: boolean }> {
|
||||
let currentConfig = config;
|
||||
let anyMigrationsRan = false;
|
||||
|
||||
logger.dim('Checking for required config migrations...');
|
||||
|
||||
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;
|
||||
@@ -49,7 +55,7 @@ export class MigrationRunner {
|
||||
if (anyMigrationsRan) {
|
||||
logger.success('Configuration migrations complete');
|
||||
} else {
|
||||
logger.dim('No migrations needed');
|
||||
logger.success('config format ok');
|
||||
}
|
||||
|
||||
return {
|
||||
|
@@ -20,7 +20,6 @@ import { logger } from '../logger.ts';
|
||||
* }
|
||||
*/
|
||||
export class MigrationV1ToV2 extends BaseMigration {
|
||||
readonly order = 2;
|
||||
readonly fromVersion = '1.x';
|
||||
readonly toVersion = '2.0';
|
||||
|
||||
|
@@ -39,7 +39,6 @@ import { logger } from '../logger.ts';
|
||||
* }
|
||||
*/
|
||||
export class MigrationV3ToV4 extends BaseMigration {
|
||||
readonly order = 4;
|
||||
readonly fromVersion = '3.x';
|
||||
readonly toVersion = '4.0';
|
||||
|
||||
|
127
ts/migrations/migration-v4.0-to-v4.1.ts
Normal file
127
ts/migrations/migration-v4.0-to-v4.1.ts
Normal file
@@ -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",
|
||||
* shutdownDelay: 5
|
||||
* }
|
||||
* ]
|
||||
* }
|
||||
* ]
|
||||
* }
|
||||
*/
|
||||
export class MigrationV4_0ToV4_1 extends BaseMigration {
|
||||
readonly fromVersion = '4.0';
|
||||
readonly toVersion = '4.1';
|
||||
|
||||
async shouldRun(config: Record<string, unknown>): Promise<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;
|
||||
}
|
||||
|
||||
async migrate(config: Record<string, unknown>): Promise<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)
|
||||
shutdownDelay: 5, // Default delay
|
||||
},
|
||||
];
|
||||
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;
|
||||
}
|
||||
}
|
20
ts/nupst.ts
20
ts/nupst.ts
@@ -6,6 +6,8 @@ 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';
|
||||
|
||||
/**
|
||||
@@ -19,6 +21,8 @@ export class Nupst {
|
||||
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 = '';
|
||||
|
||||
@@ -36,6 +40,8 @@ export class Nupst {
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -80,6 +86,20 @@ export class Nupst {
|
||||
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
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import * as snmp from 'npm:net-snmp@3.20.0';
|
||||
import * as snmp from 'npm:net-snmp@3.26.0';
|
||||
import { Buffer } from 'node:buffer';
|
||||
import type { IOidSet, ISnmpConfig, IUpsStatus, TUpsModel } from './types.ts';
|
||||
import { UpsOidSets } from './oid-sets.ts';
|
||||
@@ -304,6 +304,10 @@ export class NupstSnmp {
|
||||
console.log(' Power Status:', this.activeOIDs.POWER_STATUS);
|
||||
console.log(' Battery Capacity:', this.activeOIDs.BATTERY_CAPACITY);
|
||||
console.log(' Battery Runtime:', this.activeOIDs.BATTERY_RUNTIME);
|
||||
console.log(' Output Load:', this.activeOIDs.OUTPUT_LOAD);
|
||||
console.log(' Output Power:', this.activeOIDs.OUTPUT_POWER);
|
||||
console.log(' Output Voltage:', this.activeOIDs.OUTPUT_VOLTAGE);
|
||||
console.log(' Output Current:', this.activeOIDs.OUTPUT_CURRENT);
|
||||
console.log('---------------------------------------');
|
||||
}
|
||||
|
||||
@@ -324,20 +328,65 @@ export class NupstSnmp {
|
||||
config,
|
||||
) || 0;
|
||||
|
||||
// Get power draw metrics
|
||||
const outputLoad = await this.getSNMPValueWithRetry(
|
||||
this.activeOIDs.OUTPUT_LOAD,
|
||||
'output load',
|
||||
config,
|
||||
) || 0;
|
||||
const outputPower = await this.getSNMPValueWithRetry(
|
||||
this.activeOIDs.OUTPUT_POWER,
|
||||
'output power',
|
||||
config,
|
||||
) || 0;
|
||||
const outputVoltage = await this.getSNMPValueWithRetry(
|
||||
this.activeOIDs.OUTPUT_VOLTAGE,
|
||||
'output voltage',
|
||||
config,
|
||||
) || 0;
|
||||
const outputCurrent = await this.getSNMPValueWithRetry(
|
||||
this.activeOIDs.OUTPUT_CURRENT,
|
||||
'output current',
|
||||
config,
|
||||
) || 0;
|
||||
|
||||
// Determine power status - handle different values for different UPS models
|
||||
const powerStatus = this.determinePowerStatus(config.upsModel, powerStatusValue);
|
||||
|
||||
// Convert to minutes for UPS models with different time units
|
||||
const processedRuntime = this.processRuntimeValue(config.upsModel, batteryRuntime);
|
||||
|
||||
// Process power metrics with vendor-specific scaling
|
||||
const processedVoltage = this.processVoltageValue(config.upsModel, outputVoltage);
|
||||
const processedCurrent = this.processCurrentValue(config.upsModel, outputCurrent);
|
||||
|
||||
// Calculate power from voltage × current if not provided by UPS
|
||||
let processedPower = outputPower;
|
||||
if (outputPower === 0 && processedVoltage > 0 && processedCurrent > 0) {
|
||||
processedPower = Math.round(processedVoltage * processedCurrent);
|
||||
if (this.debug) {
|
||||
console.log(
|
||||
`Calculated power from V×I: ${processedVoltage}V × ${processedCurrent}A = ${processedPower}W`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const result = {
|
||||
powerStatus,
|
||||
batteryCapacity,
|
||||
batteryRuntime: processedRuntime,
|
||||
outputLoad,
|
||||
outputPower: processedPower,
|
||||
outputVoltage: processedVoltage,
|
||||
outputCurrent: processedCurrent,
|
||||
raw: {
|
||||
powerStatus: powerStatusValue,
|
||||
batteryCapacity,
|
||||
batteryRuntime,
|
||||
outputLoad,
|
||||
outputPower,
|
||||
outputVoltage,
|
||||
outputCurrent,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -347,6 +396,10 @@ export class NupstSnmp {
|
||||
console.log(' Power Status:', result.powerStatus);
|
||||
console.log(' Battery Capacity:', result.batteryCapacity + '%');
|
||||
console.log(' Battery Runtime:', result.batteryRuntime, 'minutes');
|
||||
console.log(' Output Load:', result.outputLoad + '%');
|
||||
console.log(' Output Power:', result.outputPower, 'watts');
|
||||
console.log(' Output Voltage:', result.outputVoltage, 'volts');
|
||||
console.log(' Output Current:', result.outputCurrent, 'amps');
|
||||
console.log('---------------------------------------');
|
||||
}
|
||||
|
||||
@@ -525,6 +578,7 @@ export class NupstSnmp {
|
||||
|
||||
/**
|
||||
* Determine power status based on UPS model and raw value
|
||||
* Uses the value mappings defined in the OID sets
|
||||
* @param upsModel UPS model
|
||||
* @param powerStatusValue Raw power status value
|
||||
* @returns Standardized power status
|
||||
@@ -533,39 +587,28 @@ export class NupstSnmp {
|
||||
upsModel: TUpsModel | undefined,
|
||||
powerStatusValue: number,
|
||||
): 'online' | 'onBattery' | 'unknown' {
|
||||
if (upsModel === 'cyberpower') {
|
||||
// CyberPower RMCARD205: upsBaseOutputStatus values
|
||||
// 2=onLine, 3=onBattery, 4=onBoost, 5=onSleep, 6=off, etc.
|
||||
if (powerStatusValue === 2) {
|
||||
return 'online';
|
||||
} else if (powerStatusValue === 3) {
|
||||
return 'onBattery';
|
||||
}
|
||||
} else if (upsModel === 'eaton') {
|
||||
// Eaton UPS: xupsOutputSource values
|
||||
// 3=normal/mains, 5=battery, etc.
|
||||
if (powerStatusValue === 3) {
|
||||
return 'online';
|
||||
} else if (powerStatusValue === 5) {
|
||||
return 'onBattery';
|
||||
}
|
||||
} else if (upsModel === 'apc') {
|
||||
// APC UPS: upsBasicOutputStatus values
|
||||
// 2=online, 3=onBattery, etc.
|
||||
if (powerStatusValue === 2) {
|
||||
return 'online';
|
||||
} else if (powerStatusValue === 3) {
|
||||
return 'onBattery';
|
||||
}
|
||||
} else {
|
||||
// Default interpretation for other UPS models
|
||||
if (powerStatusValue === 1) {
|
||||
return 'online';
|
||||
} else if (powerStatusValue === 2) {
|
||||
return 'onBattery';
|
||||
// Get the OID set for this UPS model
|
||||
if (upsModel && upsModel !== 'custom') {
|
||||
const oidSet = UpsOidSets.getOidSet(upsModel);
|
||||
|
||||
// Use the value mappings if available
|
||||
if (oidSet.POWER_STATUS_VALUES) {
|
||||
if (powerStatusValue === oidSet.POWER_STATUS_VALUES.online) {
|
||||
return 'online';
|
||||
} else if (powerStatusValue === oidSet.POWER_STATUS_VALUES.onBattery) {
|
||||
return 'onBattery';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for custom or undefined models (RFC 1628 standard)
|
||||
// upsOutputSource: 3=normal (mains), 5=battery
|
||||
if (powerStatusValue === 3) {
|
||||
return 'online';
|
||||
} else if (powerStatusValue === 5) {
|
||||
return 'onBattery';
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
@@ -612,4 +655,74 @@ export class NupstSnmp {
|
||||
|
||||
return batteryRuntime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process voltage value based on UPS model
|
||||
* @param upsModel UPS model
|
||||
* @param outputVoltage Raw output voltage value
|
||||
* @returns Processed voltage in volts
|
||||
*/
|
||||
private processVoltageValue(
|
||||
upsModel: TUpsModel | undefined,
|
||||
outputVoltage: number,
|
||||
): number {
|
||||
if (this.debug) {
|
||||
console.log('Raw voltage value:', outputVoltage);
|
||||
}
|
||||
|
||||
if (upsModel === 'cyberpower' && outputVoltage > 0) {
|
||||
// CyberPower: Voltage is in 0.1V, convert to volts
|
||||
const volts = outputVoltage / 10;
|
||||
if (this.debug) {
|
||||
console.log(
|
||||
`Converting CyberPower voltage from ${outputVoltage} (0.1V) to ${volts} volts`,
|
||||
);
|
||||
}
|
||||
return volts;
|
||||
}
|
||||
|
||||
return outputVoltage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process current value based on UPS model
|
||||
* @param upsModel UPS model
|
||||
* @param outputCurrent Raw output current value
|
||||
* @returns Processed current in amps
|
||||
*/
|
||||
private processCurrentValue(
|
||||
upsModel: TUpsModel | undefined,
|
||||
outputCurrent: number,
|
||||
): number {
|
||||
if (this.debug) {
|
||||
console.log('Raw current value:', outputCurrent);
|
||||
}
|
||||
|
||||
if (upsModel === 'cyberpower' && outputCurrent > 0) {
|
||||
// CyberPower: Current is in 0.1A, convert to amps
|
||||
const amps = outputCurrent / 10;
|
||||
if (this.debug) {
|
||||
console.log(
|
||||
`Converting CyberPower current from ${outputCurrent} (0.1A) to ${amps} amps`,
|
||||
);
|
||||
}
|
||||
return amps;
|
||||
} else if ((upsModel === 'tripplite' || upsModel === 'liebert') && outputCurrent > 0) {
|
||||
// RFC 1628 standard: Current is in 0.1A, convert to amps
|
||||
const amps = outputCurrent / 10;
|
||||
if (this.debug) {
|
||||
console.log(
|
||||
`Converting RFC 1628 current from ${outputCurrent} (0.1A) to ${amps} amps`,
|
||||
);
|
||||
}
|
||||
return amps;
|
||||
}
|
||||
|
||||
// Eaton XUPS-MIB and APC PowerNet report current directly in RMS Amps (no scaling needed)
|
||||
if ((upsModel === 'eaton' || upsModel === 'apc') && this.debug && outputCurrent > 0) {
|
||||
console.log(`${upsModel.toUpperCase()} current already in RMS Amps: ${outputCurrent}A`);
|
||||
}
|
||||
|
||||
return outputCurrent;
|
||||
}
|
||||
}
|
||||
|
@@ -11,37 +11,77 @@ 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
|
||||
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.4.4.0', // xupsOutputSource (3=normal/mains, 5=battery)
|
||||
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)
|
||||
@@ -49,6 +89,10 @@ export class UpsOidSets {
|
||||
POWER_STATUS: '',
|
||||
BATTERY_CAPACITY: '',
|
||||
BATTERY_RUNTIME: '',
|
||||
OUTPUT_LOAD: '',
|
||||
OUTPUT_POWER: '',
|
||||
OUTPUT_VOLTAGE: '',
|
||||
OUTPUT_CURRENT: '',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -70,6 +114,10 @@ export class UpsOidSets {
|
||||
'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)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@@ -14,6 +14,14 @@ export interface IUpsStatus {
|
||||
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>;
|
||||
}
|
||||
@@ -28,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;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
234
ts/systemd.ts
234
ts/systemd.ts
@@ -1,7 +1,8 @@
|
||||
import process from 'node:process';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { NupstDaemon } from './daemon.ts';
|
||||
import { NupstDaemon, type IUpsConfig } from './daemon.ts';
|
||||
import { NupstSnmp } from './snmp/manager.ts';
|
||||
import { logger } from './logger.ts';
|
||||
import { theme, symbols, getBatteryColor, getRuntimeColor, formatPowerStatus } from './colors.ts';
|
||||
|
||||
@@ -50,11 +51,11 @@ WantedBy=multi-user.target
|
||||
try {
|
||||
await fs.access(configPath);
|
||||
} catch (error) {
|
||||
const boxWidth = 50;
|
||||
logger.logBoxTitle('Configuration Error', boxWidth);
|
||||
logger.logBoxLine(`No configuration file found at ${configPath}`);
|
||||
logger.logBoxLine("Please run 'nupst add' first to create a UPS configuration.");
|
||||
logger.logBoxEnd();
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -134,21 +135,59 @@ 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();
|
||||
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 update')} ${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();
|
||||
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) {
|
||||
const boxWidth = 45;
|
||||
logger.logBoxTitle('Debug Mode', boxWidth);
|
||||
logger.logBoxLine('SNMP debugging enabled - detailed logs will be shown');
|
||||
logger.logBoxEnd();
|
||||
console.log('');
|
||||
logger.info('Debug Mode: SNMP debugging enabled');
|
||||
console.log('');
|
||||
this.daemon.getNupstSnmp().enableDebug();
|
||||
}
|
||||
|
||||
// Display version information
|
||||
this.daemon.getNupstSnmp().getNupst().logVersionInfo();
|
||||
// Display version and update status first
|
||||
await this.displayVersionInfo();
|
||||
|
||||
// Check if config exists first
|
||||
// Check if config exists
|
||||
try {
|
||||
await this.checkConfigExists();
|
||||
} catch (error) {
|
||||
@@ -196,11 +235,11 @@ WantedBy=multi-user.target
|
||||
}
|
||||
|
||||
// Display beautiful status
|
||||
console.log('');
|
||||
logger.log('');
|
||||
if (isActive) {
|
||||
console.log(`${symbols.running} ${theme.success('Service:')} ${theme.statusActive('active (running)')}`);
|
||||
logger.log(`${symbols.running} ${theme.success('Service:')} ${theme.statusActive('active (running)')}`);
|
||||
} else {
|
||||
console.log(`${symbols.stopped} ${theme.dim('Service:')} ${theme.statusInactive('inactive')}`);
|
||||
logger.log(`${symbols.stopped} ${theme.dim('Service:')} ${theme.statusInactive('inactive')}`);
|
||||
}
|
||||
|
||||
if (pid || memory || cpu) {
|
||||
@@ -208,14 +247,14 @@ WantedBy=multi-user.target
|
||||
if (pid) details.push(`PID: ${theme.dim(pid)}`);
|
||||
if (memory) details.push(`Memory: ${theme.dim(memory)}`);
|
||||
if (cpu) details.push(`CPU: ${theme.dim(cpu)}`);
|
||||
console.log(` ${details.join(' ')}`);
|
||||
logger.log(` ${details.join(' ')}`);
|
||||
}
|
||||
console.log('');
|
||||
logger.log('');
|
||||
|
||||
} catch (error) {
|
||||
console.log('');
|
||||
console.log(`${symbols.stopped} ${theme.dim('Service:')} ${theme.statusInactive('not installed')}`);
|
||||
console.log('');
|
||||
logger.log('');
|
||||
logger.log(`${symbols.stopped} ${theme.dim('Service:')} ${theme.statusInactive('not installed')}`);
|
||||
logger.log('');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,35 +271,47 @@ WantedBy=multi-user.target
|
||||
|
||||
// Check if we have the new multi-UPS config format
|
||||
if (config.upsDevices && Array.isArray(config.upsDevices) && config.upsDevices.length > 0) {
|
||||
console.log(theme.info(`UPS Devices (${config.upsDevices.length}):`));
|
||||
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
|
||||
console.log(theme.info('UPS Devices (1):'));
|
||||
const legacyUps = {
|
||||
// Legacy single UPS configuration (v1/v2 format)
|
||||
logger.info('UPS Devices (1):');
|
||||
const legacyUps: IUpsConfig = {
|
||||
id: 'default',
|
||||
name: 'Default UPS',
|
||||
snmp: config.snmp,
|
||||
thresholds: config.thresholds,
|
||||
groups: [],
|
||||
actions: config.thresholds
|
||||
? [
|
||||
{
|
||||
type: 'shutdown',
|
||||
thresholds: config.thresholds,
|
||||
triggerMode: 'onlyThresholds',
|
||||
shutdownDelay: 5,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
};
|
||||
|
||||
await this.displaySingleUpsStatus(legacyUps, snmp);
|
||||
} else {
|
||||
console.log('');
|
||||
console.log(`${symbols.warning} ${theme.warning('No UPS devices configured')}`);
|
||||
console.log(` ${theme.dim('Run')} ${theme.command('nupst ups add')} ${theme.dim('to add a device')}`);
|
||||
console.log('');
|
||||
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.log('');
|
||||
console.log(`${symbols.error} ${theme.error('Failed to retrieve UPS status')}`);
|
||||
console.log(` ${theme.dim(error instanceof Error ? error.message : String(error))}`);
|
||||
console.log('');
|
||||
logger.log('');
|
||||
logger.error('Failed to retrieve UPS status');
|
||||
logger.log(` ${theme.dim(error instanceof Error ? error.message : String(error))}`);
|
||||
logger.log('');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,7 +320,7 @@ WantedBy=multi-user.target
|
||||
* @param ups UPS configuration
|
||||
* @param snmp SNMP manager
|
||||
*/
|
||||
private async displaySingleUpsStatus(ups: any, snmp: any): Promise<void> {
|
||||
private async displaySingleUpsStatus(ups: IUpsConfig, snmp: NupstSnmp): Promise<void> {
|
||||
try {
|
||||
// Create a test config with a short timeout
|
||||
const testConfig = {
|
||||
@@ -288,15 +339,24 @@ WantedBy=multi-user.target
|
||||
}
|
||||
|
||||
// Display UPS name and power status
|
||||
console.log(` ${statusSymbol} ${theme.highlight(ups.name)} - ${formatPowerStatus(status.powerStatus)}`);
|
||||
logger.log(` ${statusSymbol} ${theme.highlight(ups.name)} - ${formatPowerStatus(status.powerStatus)}`);
|
||||
|
||||
// Display battery with color coding
|
||||
const batteryColor = getBatteryColor(status.batteryCapacity);
|
||||
const batterySymbol = status.batteryCapacity >= ups.thresholds.battery ? symbols.success : symbols.warning;
|
||||
console.log(` Battery: ${batteryColor(status.batteryCapacity + '%')} ${batterySymbol} Runtime: ${getRuntimeColor(status.batteryRuntime)(status.batteryRuntime + ' min')}`);
|
||||
|
||||
// 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 host info
|
||||
console.log(` ${theme.dim(`Host: ${ups.snmp.host}:${ups.snmp.port}`)}`);
|
||||
logger.log(` ${theme.dim(`Host: ${ups.snmp.host}:${ups.snmp.port}`)}`);
|
||||
|
||||
// Display groups if any
|
||||
if (ups.groups && ups.groups.length > 0) {
|
||||
@@ -305,17 +365,101 @@ WantedBy=multi-user.target
|
||||
const group = config.groups?.find((g: { id: string }) => g.id === groupId);
|
||||
return group ? group.name : groupId;
|
||||
});
|
||||
console.log(` ${theme.dim(`Groups: ${groupNames.join(', ')}`)}`);
|
||||
logger.log(` ${theme.dim(`Groups: ${groupNames.join(', ')}`)}`);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
// 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.shutdownDelay) {
|
||||
actionDesc += `, delay=${action.shutdownDelay}s`;
|
||||
}
|
||||
actionDesc += ')';
|
||||
} else {
|
||||
actionDesc += ` (${action.triggerMode || 'onlyPowerChanges'}`;
|
||||
if (action.shutdownDelay) {
|
||||
actionDesc += `, delay=${action.shutdownDelay}s`;
|
||||
}
|
||||
actionDesc += ')';
|
||||
}
|
||||
logger.log(` ${theme.dim('Action:')} ${theme.info(actionDesc)}`);
|
||||
}
|
||||
}
|
||||
|
||||
logger.log('');
|
||||
|
||||
} catch (error) {
|
||||
// Display error for this UPS
|
||||
console.log(` ${symbols.error} ${theme.highlight(ups.name)} - ${theme.error('Connection failed')}`);
|
||||
console.log(` ${theme.dim(error instanceof Error ? error.message : String(error))}`);
|
||||
console.log(` ${theme.dim(`Host: ${ups.snmp.host}:${ups.snmp.port}`)}`);
|
||||
console.log('');
|
||||
logger.log(` ${symbols.error} ${theme.highlight(ups.name)} - ${theme.error('Connection failed')}`);
|
||||
logger.log(` ${theme.dim(error instanceof Error ? error.message : String(error))}`);
|
||||
logger.log(` ${theme.dim(`Host: ${ups.snmp.host}:${ups.snmp.port}`)}`);
|
||||
logger.log('');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
for (const action of group.actions) {
|
||||
let actionDesc = `${action.type}`;
|
||||
if (action.thresholds) {
|
||||
actionDesc += ` (${action.triggerMode || 'onlyThresholds'}: battery<${action.thresholds.battery}%, runtime<${action.thresholds.runtime}min`;
|
||||
if (action.shutdownDelay) {
|
||||
actionDesc += `, delay=${action.shutdownDelay}s`;
|
||||
}
|
||||
actionDesc += ')';
|
||||
} else {
|
||||
actionDesc += ` (${action.triggerMode || 'onlyPowerChanges'}`;
|
||||
if (action.shutdownDelay) {
|
||||
actionDesc += `, delay=${action.shutdownDelay}s`;
|
||||
}
|
||||
actionDesc += ')';
|
||||
}
|
||||
logger.log(` ${theme.dim('Action:')} ${theme.info(actionDesc)}`);
|
||||
}
|
||||
}
|
||||
|
||||
logger.log('');
|
||||
}
|
||||
}
|
||||
|
||||
|
Reference in New Issue
Block a user