Compare commits

...

47 Commits

Author SHA1 Message Date
fda072d15e v5.2.1
Some checks failed
CI / Type Check & Lint (push) Successful in 5s
CI / Build Test (Current Platform) (push) Successful in 5s
Publish to npm / npm-publish (push) Failing after 22s
Release / build-and-release (push) Successful in 55s
CI / Build All Platforms (push) Successful in 1m2s
2026-01-29 17:07:57 +00:00
c7786e9626 fix(cli(ups-handler), systemd): add type guards and null checks for UPS configs; improve SNMP handling and prompts; guard version display 2026-01-29 17:07:57 +00:00
91fe5f7ae6 v5.2.0
Some checks failed
CI / Type Check & Lint (push) Failing after 8s
CI / Build Test (Current Platform) (push) Successful in 9s
Release / build-and-release (push) Successful in 1m2s
Publish to npm / npm-publish (push) Failing after 1m7s
CI / Build All Platforms (push) Successful in 1m10s
2026-01-29 17:04:12 +00:00
07648b4880 feat(core): Centralize timeouts/constants, add CLI prompt helpers, and introduce webhook/script actions with safety and SNMP refactors 2026-01-29 17:04:12 +00:00
d0e3a4ae74 v5.1.11
Some checks failed
CI / Type Check & Lint (push) Successful in 9s
CI / Build Test (Current Platform) (push) Successful in 9s
Publish to npm / npm-publish (push) Failing after 23s
Release / build-and-release (push) Successful in 51s
CI / Build All Platforms (push) Successful in 1m0s
2025-11-09 11:30:39 +00:00
89ffd61717 fix(readme): Update README installation instructions to recommend automated installer script and clarify npm installation 2025-11-09 11:30:39 +00:00
60eadaf6a1 5.1.10
Some checks failed
CI / Type Check & Lint (push) Successful in 5s
CI / Build Test (Current Platform) (push) Successful in 5s
Publish to npm / npm-publish (push) Failing after 20s
CI / Build All Platforms (push) Successful in 57s
Release / build-and-release (push) Successful in 54s
2025-10-23 18:25:52 +00:00
bd52ba4cb2 fix(config): Synchronize deno.json version with package.json, tidy formatting, and add local tooling settings 2025-10-23 18:25:52 +00:00
a3d6a8b75d 5.1.9
Some checks failed
CI / Type Check & Lint (push) Successful in 6s
CI / Build Test (Current Platform) (push) Successful in 6s
Publish to npm / npm-publish (push) Failing after 5s
Release / build-and-release (push) Failing after 3s
CI / Build All Platforms (push) Successful in 48s
2025-10-23 18:19:51 +00:00
fbd71b1f3b fix(dev): new gitzone cli 2025-10-23 18:19:51 +00:00
6481572981 fix(version): read current version from deno.json and latest version from Gitea API
Some checks failed
CI / Type Check & Lint (push) Successful in 5s
CI / Build Test (Current Platform) (push) Successful in 5s
Publish to npm / npm-publish (push) Failing after 27s
Release / build-and-release (push) Successful in 54s
CI / Build All Platforms (push) Successful in 1m8s
- Replace static commitinfo with dynamic deno.json import
- Change version check from npm registry to Gitea releases API
- Delete obsolete ts/00_commitinfo_data.ts
- Ensures version consistency across updates
2025-10-23 16:17:30 +00:00
0dc14a6ea1 5.1.7
Some checks failed
CI / Type Check & Lint (push) Successful in 6s
CI / Build Test (Current Platform) (push) Successful in 5s
Publish to npm / npm-publish (push) Failing after 20s
Release / build-and-release (push) Successful in 51s
CI / Build All Platforms (push) Successful in 57s
2025-10-23 13:25:32 +00:00
dea344e6ba feat(status): display power metrics in service status output
Some checks failed
CI / Type Check & Lint (push) Successful in 7s
CI / Build Test (Current Platform) (push) Successful in 5s
CI / Build All Platforms (push) Has been cancelled
2025-10-23 13:24:55 +00:00
f81f5957ab 5.1.6
Some checks failed
CI / Type Check & Lint (push) Successful in 5s
CI / Build Test (Current Platform) (push) Successful in 6s
CI / Build All Platforms (push) Successful in 53s
Publish to npm / npm-publish (push) Failing after 53s
Release / build-and-release (push) Successful in 1m7s
2025-10-23 13:18:22 +00:00
281d3fbbeb fix(ci): correct setup-deno action version to install Deno 2.x
Some checks failed
CI / Type Check & Lint (push) Successful in 9s
CI / Build Test (Current Platform) (push) Successful in 8s
CI / Build All Platforms (push) Has been cancelled
2025-10-23 13:17:56 +00:00
c1cb136a7d 5.1.5
Some checks failed
CI / Type Check & Lint (push) Failing after 4s
CI / Build Test (Current Platform) (push) Failing after 3s
CI / Build All Platforms (push) Failing after 3s
Release / build-and-release (push) Failing after 4s
2025-10-23 13:15:44 +00:00
b80275a594 5.1.3
Some checks failed
CI / Type Check & Lint (push) Failing after 4s
CI / Build Test (Current Platform) (push) Failing after 4s
CI / Build All Platforms (push) Failing after 4s
Release / build-and-release (push) Failing after 4s
2025-10-23 13:03:35 +00:00
b64a515c94 set deno version 2025-10-23 13:03:29 +00:00
68c4eb6480 5.1.2
Some checks failed
CI / Type Check & Lint (push) Failing after 3s
CI / Build Test (Current Platform) (push) Failing after 3s
CI / Build All Platforms (push) Failing after 3s
Release / build-and-release (push) Failing after 3s
2025-10-23 13:00:24 +00:00
6c8f6ac33f fix(scripts): Add build script to package.json and include local dev tool settings 2025-10-23 13:00:24 +00:00
ffa491c7a1 5.1.1
Some checks failed
Release / build-and-release (push) Failing after 3s
2025-10-23 12:57:58 +00:00
777d48d82e fix(tooling): better oids and more power metrics. Also new json httpServer feature support. 2025-10-23 12:57:58 +00:00
b7a0bbcf6d fix(snmp): Update current handling for Tripplite and Liebert models; add APC current logging 2025-10-23 12:45:29 +00:00
fbe1cd64cb feat(snmp): Enhance SNMP metrics with output load, power, voltage, and current readings 2025-10-23 12:25:59 +00:00
9ba50da73c 5.1.0
Some checks failed
Release / build-and-release (push) Failing after 3s
2025-10-22 14:18:09 +00:00
684319983d feat(packaging): Add npm packaging and installer: wrapper, postinstall downloader, publish workflow, and packaging files 2025-10-22 14:18:09 +00:00
18bd9f6cda fix(install): add error checking for binary move and chmod operations
All checks were successful
CI / Type Check & Lint (push) Successful in 5s
CI / Build Test (Current Platform) (push) Successful in 4s
Release / build-and-release (push) Successful in 47s
CI / Build All Platforms (push) Successful in 50s
- Check if mv command succeeds
- Verify binary exists after move
- Check if chmod succeeds
- Exit with error instead of continuing on failure
2025-10-20 13:33:00 +00:00
f03c683d02 fix(install): correct installation order for updates
All checks were successful
CI / Type Check & Lint (push) Successful in 5s
CI / Build Test (Current Platform) (push) Successful in 5s
Release / build-and-release (push) Successful in 42s
CI / Build All Platforms (push) Successful in 47s
- Stop service first
- Remove /opt/nupst
- Create fresh directory
- Download binary
- Ensures clean installation without leaving empty directories
2025-10-20 13:28:56 +00:00
f750299780 fix(install): simplify installation to only binary in /opt/nupst
All checks were successful
CI / Type Check & Lint (push) Successful in 5s
CI / Build Test (Current Platform) (push) Successful in 5s
Release / build-and-release (push) Successful in 46s
CI / Build All Platforms (push) Successful in 48s
- Remove all conditional migration logic
- Always completely clean /opt/nupst before installation
- Ensures only NUPST binary exists in installation directory
- Simplified service restart logic
2025-10-20 13:24:03 +00:00
ca1039408d chore(release): bump version to 5.0.2
All checks were successful
CI / Type Check & Lint (push) Successful in 5s
CI / Build Test (Current Platform) (push) Successful in 4s
Release / build-and-release (push) Successful in 42s
CI / Build All Platforms (push) Successful in 47s
2025-10-20 13:09:20 +00:00
df3e0b9424 fix: import process from node:process in script-action
Some checks failed
CI / Type Check & Lint (push) Successful in 5s
CI / Build Test (Current Platform) (push) Successful in 5s
CI / Build All Platforms (push) Has been cancelled
Fixes TS2580 error where process was undefined
2025-10-20 13:08:43 +00:00
c8e5960abd chore(release): bump version to 5.0.1
Some checks failed
CI / Type Check & Lint (push) Failing after 5s
CI / Build Test (Current Platform) (push) Successful in 4s
Release / build-and-release (push) Successful in 45s
CI / Build All Platforms (push) Successful in 48s
2025-10-20 13:07:07 +00:00
7304a62357 chore(release): bump version to 5.0.0
Some checks failed
CI / Type Check & Lint (push) Failing after 4s
CI / Build Test (Current Platform) (push) Successful in 5s
Release / build-and-release (push) Successful in 41s
CI / Build All Platforms (push) Successful in 47s
BREAKING CHANGE: Deprecated CLI commands removed

This is a major version bump due to breaking changes:
- Removed all deprecated flat command structure
- Users must now use modern subcommand structure:
  - nupst service <subcommand>
  - nupst ups <subcommand>
  - nupst group <subcommand>
  - nupst action <subcommand>

New in v5.0:
- Enhanced status display showing actions and groups (v4.3.3)
- Action management system (v4.3.0)
- Improved type safety (v4.2.5)
- Config auto-reload (v4.3.2)

Updated readme version references to v5.0.0
2025-10-20 13:00:42 +00:00
a5a88e53ba docs: improve accuracy of dependency claims
Some checks failed
CI / Type Check & Lint (push) Failing after 5s
CI / Build Test (Current Platform) (push) Successful in 5s
CI / Build All Platforms (push) Successful in 45s
- Clarify 'zero runtime dependencies' means for compiled binary
- Change 'no npm' to 'no installation required'
- Update 'Zero Dependencies' to 'Self-Contained Binary'
- Improve migration table accuracy (Runtime Dependencies vs build deps)
- More honest about supply chain (reduced vs eliminated)
2025-10-20 12:59:14 +00:00
73bc271c59 docs: remove deprecated command migration documentation
Some checks failed
CI / Type Check & Lint (push) Failing after 5s
CI / Build Test (Current Platform) (push) Successful in 4s
CI / Build All Platforms (push) Successful in 45s
- Remove 'Command Migration' section showing old commands
- Users must now use modern subcommand structure
- Clean up migration guide to focus on actual changes
2025-10-20 12:57:48 +00:00
1e98181e71 feat: remove deprecated CLI commands
BREAKING CHANGE: Old flat command structure no longer supported

Removed deprecated commands:
- nupst add → use 'nupst ups add'
- nupst edit → use 'nupst ups edit'
- nupst delete → use 'nupst ups remove'
- nupst list → use 'nupst ups list'
- nupst test → use 'nupst ups test'
- nupst setup → use 'nupst ups edit'
- nupst enable → use 'nupst service enable'
- nupst disable → use 'nupst service disable'
- nupst start → use 'nupst service start'
- nupst stop → use 'nupst service stop'
- nupst status → use 'nupst service status'
- nupst logs → use 'nupst service logs'
- nupst daemon-start → use 'nupst service start-daemon'

Also removed 'delete' as alias for 'remove' (use 'rm' instead)

Modern command structure is now required:
- nupst service <subcommand>
- nupst ups <subcommand>
- nupst group <subcommand>
- nupst action <subcommand>

Kept modern aliases: rm, ls
2025-10-20 12:57:23 +00:00
eb5a8185ae docs: create comprehensive readme with v4.3 features
Some checks failed
CI / Type Check & Lint (push) Failing after 4s
CI / Build Test (Current Platform) (push) Successful in 6s
CI / Build All Platforms (push) Successful in 45s
- Add action management documentation (new feature)
- Update configuration examples to v4.1+ action-based format
- Document trigger modes and action system
- Update status command output examples with groups and actions
- Remove outdated contributing section
- Add modern emojis and engaging tone
- Update all version references to v4.3.3
- Maintain Task Venture Capital GmbH legal section
2025-10-20 12:52:26 +00:00
ef3d3f3fa3 chore(release): bump version to 4.3.3
Some checks failed
CI / Type Check & Lint (push) Failing after 5s
CI / Build Test (Current Platform) (push) Successful in 5s
Release / build-and-release (push) Successful in 43s
CI / Build All Platforms (push) Successful in 48s
2025-10-20 12:48:26 +00:00
34e6e850ad feat(status): display actions and groups in status command
- Add action display to UPS status showing trigger mode, thresholds, and delays
- Create displayGroupsStatus() method to show group information
- Display group mode, member UPS devices, and group actions
- Integrate groups section into status command output
2025-10-20 12:48:14 +00:00
992a776fd2 chore(release): bump version to 4.3.2
Some checks failed
CI / Type Check & Lint (push) Failing after 5s
CI / Build Test (Current Platform) (push) Successful in 4s
Release / build-and-release (push) Successful in 42s
CI / Build All Platforms (push) Successful in 47s
2025-10-20 12:42:34 +00:00
3e15a2d52f fix(action): correct message about config reload
The daemon already has automatic config file watching and reloads changes
without requiring a restart. Updated action handler messages to correctly
reflect this behavior.

Changed:
- 'Restart service to apply changes: nupst service restart'
  → 'Changes saved and will be applied automatically'

The config file watcher (daemon.ts:986) uses Deno.watchFs() to monitor
/etc/nupst/config.json and automatically calls reloadConfig() when changes
are detected. No restart needed.
2025-10-20 12:42:31 +00:00
d1a3576d31 chore(release): bump version to 4.3.1
Some checks failed
CI / Type Check & Lint (push) Failing after 4s
CI / Build Test (Current Platform) (push) Successful in 4s
Release / build-and-release (push) Successful in 42s
CI / Build All Platforms (push) Successful in 46s
2025-10-20 12:34:52 +00:00
1ca05e879b feat(action): add group action support
Extended action management to support groups in addition to UPS devices:

Changes:
- Auto-detects whether target ID is a UPS or group
- All action commands now work with both UPS and groups:
  * nupst action add <ups-id|group-id>
  * nupst action remove <ups-id|group-id> <index>
  * nupst action list [ups-id|group-id]

- Updated ActionHandler methods to handle both target types
- Updated help text and usage examples
- List command shows both UPS and group actions when no target specified
- Clear labeling in output distinguishes UPS actions from group actions

Example usage:
  nupst action list                 # Shows all UPS and group actions
  nupst action add dc-rack-1        # Adds action to group 'dc-rack-1'
  nupst action remove default 0     # Removes action from UPS 'default'

Groups can now have their own shutdown actions, allowing fine-grained
control over group behavior during power events.
2025-10-20 12:34:47 +00:00
9c6fa37eb8 chore(release): bump version to 4.3.0
Some checks failed
CI / Type Check & Lint (push) Failing after 5s
CI / Build Test (Current Platform) (push) Successful in 4s
Release / build-and-release (push) Successful in 43s
CI / Build All Platforms (push) Successful in 48s
2025-10-20 12:32:17 +00:00
ff433b2256 feat(cli): add action management commands
Added comprehensive action management:

Commands:
- nupst action add <ups-id> - Add a new action to a UPS interactively
- nupst action remove <ups-id> <index> - Remove an action by index
- nupst action list [ups-id] - List all actions (optionally for specific UPS)

Features:
- Interactive prompts for action configuration
- Battery and runtime threshold configuration
- Trigger mode selection (onlyPowerChanges, onlyThresholds, powerChangesAndThresholds, anyChange)
- Shutdown delay configuration
- Table-based display of actions with indices
- Support for managing actions across multiple UPS devices

Implementation:
- Created ActionHandler class in ts/cli/action-handler.ts
- Integrated with existing CLI infrastructure
- Added to nupst.ts, cli.ts, and help system
- Proper TypeScript typing throughout

Closes the gap where users had to manually edit config.json to manage actions.
2025-10-20 12:32:14 +00:00
263d69aef1 chore(release): bump version to 4.2.5
Some checks failed
CI / Type Check & Lint (push) Failing after 5s
CI / Build Test (Current Platform) (push) Successful in 5s
Release / build-and-release (push) Successful in 42s
CI / Build All Platforms (push) Successful in 47s
2025-10-20 12:27:06 +00:00
b6b7b43161 refactor: replace 'any' types with proper TypeScript interfaces
Major type safety improvements throughout the codebase:

- Updated DEFAULT_CONFIG version to 4.2
- Replaced 'any' with proper types in systemd.ts:
  * displaySingleUpsStatus now uses IUpsConfig and NupstSnmp types
  * Fixed legacy config handling to use proper IUpsConfig format
  * Removed inline 'any' type annotations

- Replaced 'any' with proper types in daemon.ts:
  * emergencyUps now properly typed as { ups: IUpsConfig, status: ISnmpUpsStatus }
  * Exported IUpsStatus interface for reuse
  * Added ISnmpUpsStatus import to disambiguate from daemon's IUpsStatus

- Replaced 'any' with Record<string, unknown> in migration system:
  * Updated BaseMigration abstract class signatures
  * Updated MigrationRunner.run() signature
  * Updated migration-v4.0-to-v4.1.ts to use proper types
  * Migrations use Record<string, unknown> because they deal with
    unknown config schemas that are being upgraded

Benefits:
- TypeScript now catches type errors at compile time
- Would have caught the ups.thresholds bug earlier
- Better IDE autocomplete and type checking
- More maintainable and self-documenting code
2025-10-20 12:27:02 +00:00
45 changed files with 3607 additions and 1119 deletions

View File

@@ -0,0 +1,31 @@
## NUPST {{VERSION}}
Pre-compiled binaries for multiple platforms.
### Installation
#### Option 1: Via npm (recommended)
```bash
npm install -g @serve.zone/nupst
```
#### Option 2: Via installer script
```bash
curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash
```
#### Option 3: Direct binary download
Download the appropriate binary for your platform from the assets below and make it executable.
### Supported Platforms
- Linux x86_64 (x64)
- Linux ARM64 (aarch64)
- macOS x86_64 (Intel)
- macOS ARM64 (Apple Silicon)
- Windows x86_64
### Checksums
SHA256 checksums are provided in `SHA256SUMS.txt` for binary verification.
### npm Package
The npm package includes automatic binary detection and installation for your platform.

View File

@@ -21,7 +21,7 @@ jobs:
- name: Set up Deno - name: Set up Deno
uses: denoland/setup-deno@v1 uses: denoland/setup-deno@v1
with: with:
deno-version: v1.x deno-version: v2.x
- name: Check TypeScript types - name: Check TypeScript types
run: deno check mod.ts run: deno check mod.ts
@@ -45,7 +45,7 @@ jobs:
- name: Set up Deno - name: Set up Deno
uses: denoland/setup-deno@v1 uses: denoland/setup-deno@v1
with: with:
deno-version: v1.x deno-version: v2.x
- name: Compile for current platform - name: Compile for current platform
run: | run: |
@@ -71,7 +71,7 @@ jobs:
- name: Set up Deno - name: Set up Deno
uses: denoland/setup-deno@v1 uses: denoland/setup-deno@v1
with: with:
deno-version: v1.x deno-version: v2.x
- name: Compile all platform binaries - name: Compile all platform binaries
run: bash scripts/compile-all.sh run: bash scripts/compile-all.sh

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

View File

@@ -18,7 +18,7 @@ jobs:
- name: Set up Deno - name: Set up Deno
uses: denoland/setup-deno@v1 uses: denoland/setup-deno@v1
with: with:
deno-version: v1.x deno-version: v2.x
- name: Get version from tag - name: Get version from tag
id: version id: version

54
.npmignore Normal file
View 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.*

1
.serena/.gitignore vendored
View File

@@ -1 +0,0 @@
/cache

View File

@@ -1,71 +0,0 @@
# language of the project (csharp, python, rust, java, typescript, go, cpp, or ruby)
# * For C, use cpp
# * For JavaScript, use typescript
# Special requirements:
# * csharp: Requires the presence of a .sln file in the project folder.
language: typescript
# the encoding used by text files in the project
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
encoding: 'utf-8'
# whether to use the project's gitignore file to ignore files
# Added on 2025-04-07
ignore_all_files_in_gitignore: true
# list of additional paths to ignore
# same syntax as gitignore, so you can use * and **
# Was previously called `ignored_dirs`, please update your config if you are using that.
# Added (renamed) on 2025-04-07
ignored_paths: []
# whether the project is in read-only mode
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
# Added on 2025-04-18
read_only: false
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
# Below is the complete list of tools for convenience.
# To make sure you have the latest list of tools, and to view their descriptions,
# execute `uv run scripts/print_tool_overview.py`.
#
# * `activate_project`: Activates a project by name.
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
# * `create_text_file`: Creates/overwrites a file in the project directory.
# * `delete_lines`: Deletes a range of lines within a file.
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
# * `execute_shell_command`: Executes a shell command.
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
# * `initial_instructions`: Gets the initial instructions for the current project.
# Should only be used in settings where the system prompt cannot be set,
# e.g. in clients you have no control over, like Claude Desktop.
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
# * `insert_at_line`: Inserts content at a given line in a file.
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
# * `list_memories`: Lists memories in Serena's project-specific memory store.
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
# * `read_file`: Reads a file within the project directory.
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
# * `remove_project`: Removes a project from the Serena configuration.
# * `replace_lines`: Replaces a range of lines within a file with new content.
# * `replace_symbol_body`: Replaces the full definition of a symbol.
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
# * `search_for_pattern`: Performs a search for a pattern in the project.
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
# * `switch_modes`: Activates modes by providing a list of their names
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
excluded_tools: []
# initial prompt for the project. It will always be given to the LLM upon activating the project
# (contrary to the memories, which are loaded on demand).
initial_prompt: ''
project_name: 'nupst'

108
bin/nupst-wrapper.js Normal file
View 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();

View File

@@ -1,5 +1,71 @@
# Changelog # Changelog
## 2026-01-29 - 5.2.1 - fix(cli(ups-handler), systemd)
add type guards and null checks for UPS configs; improve SNMP handling and prompts; guard version display
- Introduce a type guard ('id' in config && 'name' in config) to distinguish IUpsConfig from legacy INupstConfig and route fields (snmp, checkInterval, name, id) accordingly.
- displayTestConfig now handles missing SNMP by logging 'Not configured' and returning, computes checkInterval/upsName/upsId correctly, and uses groups only for true UPS configs.
- testConnection now safely derives snmpConfig for both config types, throws if SNMP is missing, and caps test timeout to 10s for probes.
- Clear auth/priv credentials by setting undefined (instead of empty strings) when disabling security levels to avoid invalid/empty string values.
- Expanded customOIDs to include OUTPUT_LOAD, OUTPUT_POWER, OUTPUT_VOLTAGE, OUTPUT_CURRENT with defaults; trim prompt input and document RFC 1628 fallbacks.
- systemd.displayVersionInfo: guard against missing nupst (silent return) and avoid errors when printing version info; use ignored catch variables for clarity.
## 2026-01-29 - 5.2.0 - feat(core)
Centralize timeouts/constants, add CLI prompt helpers, and introduce webhook/script actions with safety and SNMP refactors
- Add ts/constants.ts to centralize timing, SNMP, webhook, script, shutdown and UI constants and replace magic numbers across the codebase
- Introduce helpers/prompt.ts with createPrompt() and withPrompt() and refactor CLI handlers to use these helpers (cleaner prompt lifecycle)
- Add webhook action support: ts/actions/webhook-action.ts, IWebhookPayload type, and export from ts/actions/index.ts
- Enhance ShutdownAction safety checks (only trigger onBattery, stricter transition rules) and use constants/UI widths for displays
- Refactor SNMP manager to use logger instead of console, pull SNMP defaults from constants, improved debug output, and add INupstAccessor interface to break circular dependency (Nupst now implements the interface)
- Update many CLI and core types (stronger typing for configs/actions), expand tests and update README and npmextra.json to document new features
## 2025-11-09 - 5.1.11 - fix(readme)
Update README installation instructions to recommend automated installer script and clarify npm installation
- Replace the previous 'Via npm (NEW! - Recommended)' section with a clear 'Automated Installer Script (Recommended)' section and example curl installer.
- Move npm installation instructions into an 'Alternative: Via npm' subsection and clarify that the npm package downloads the appropriate pre-compiled binary for the platform during installation.
- Remove the 'NEW!' badge and streamline notes about binary downloads and installation methods.
## 2025-10-23 - 5.1.10 - fix(config)
Synchronize deno.json version with package.json, tidy formatting, and add local tooling settings
- Bumped deno.json version to 5.1.9 to match package.json/commitinfo
- Reformatted deno.json arrays (lint, fmt, compilerOptions) for readability
- Added .claude/settings.local.json for local development/tooling permissions (no runtime behaviour changes)
## 2025-10-23 - 5.1.9 - fix(dev)
Add local assistant permissions/settings file (.claude/settings.local.json)
- Added .claude/settings.local.json containing local assistant permission configuration used for development tasks (deno check, deno lint/format, npm/pack, running packaged binaries, etc.)
- This is a development/local configuration file and does not change runtime behavior or product code paths
- Patch version bump recommended
## 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 ## 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** **MAJOR RELEASE: NUPST v4.0 is a complete rewrite powered by Deno**

View File

@@ -1,7 +1,8 @@
{ {
"name": "@serve.zone/nupst", "name": "@serve.zone/nupst",
"version": "4.2.4", "version": "5.2.1",
"exports": "./mod.ts", "exports": "./mod.ts",
"nodeModulesDir": "auto",
"tasks": { "tasks": {
"dev": "deno run --allow-all mod.ts", "dev": "deno run --allow-all mod.ts",
"compile": "deno task compile:all", "compile": "deno task compile:all",
@@ -14,7 +15,9 @@
}, },
"lint": { "lint": {
"rules": { "rules": {
"tags": ["recommended"] "tags": [
"recommended"
]
} }
}, },
"fmt": { "fmt": {
@@ -25,7 +28,9 @@
"singleQuote": true "singleQuote": true
}, },
"compilerOptions": { "compilerOptions": {
"lib": ["deno.window"], "lib": [
"deno.window"
],
"strict": true "strict": true
}, },
"imports": { "imports": {

View File

@@ -1,6 +1,6 @@
#!/bin/bash #!/bin/bash
# NUPST Installer Script (v4.0+) # NUPST Installer Script (v5.0+)
# Downloads and installs pre-compiled NUPST binary from Gitea releases # Downloads and installs pre-compiled NUPST binary from Gitea releases
# #
# Usage: # Usage:
@@ -8,7 +8,7 @@
# curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash # curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash
# #
# With version specification: # 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: # Options:
# -h, --help Show this help message # -h, --help Show this help message
@@ -48,14 +48,14 @@ while [[ $# -gt 0 ]]; do
done done
if [ $SHOW_HELP -eq 1 ]; then 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 "Downloads and installs pre-compiled NUPST binary"
echo "" echo ""
echo "Usage: $0 [options]" echo "Usage: $0 [options]"
echo "" echo ""
echo "Options:" echo "Options:"
echo " -h, --help Show this help message" 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 " --install-dir DIR Installation directory (default: /opt/nupst)"
echo "" echo ""
echo "Examples:" 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 " curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash"
echo "" echo ""
echo " # Install specific version" 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 exit 0
fi fi
@@ -145,7 +145,7 @@ get_latest_version() {
# Main installation process # Main installation process
echo "================================================" echo "================================================"
echo " NUPST Installation Script (v4.0+)" echo " NUPST Installation Script (v5.0+)"
echo "================================================" echo "================================================"
echo "" echo ""
@@ -169,50 +169,25 @@ DOWNLOAD_URL="${GITEA_BASE_URL}/${GITEA_REPO}/releases/download/${VERSION}/${BIN
echo "Download URL: $DOWNLOAD_URL" echo "Download URL: $DOWNLOAD_URL"
echo "" echo ""
# Check if installation directory exists # Check if service is running and stop it
SERVICE_WAS_RUNNING=0 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 ""
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 if systemctl is-enabled --quiet nupst 2>/dev/null || systemctl is-active --quiet nupst 2>/dev/null; then
SERVICE_WAS_RUNNING=1 SERVICE_WAS_RUNNING=1
if systemctl is-active --quiet nupst 2>/dev/null; then if systemctl is-active --quiet nupst 2>/dev/null; then
echo "Stopping NUPST service..." echo "Stopping NUPST service..."
systemctl stop nupst systemctl stop nupst
else
echo "Service is installed but not currently running (will be updated)..."
fi fi
fi fi
# Clean up old Node.js installation files # Clean installation directory - ensure only binary exists
if [ $OLD_NODE_INSTALL -eq 1 ]; then if [ -d "$INSTALL_DIR" ]; then
echo "Cleaning up old Node.js installation files..." echo "Cleaning installation directory: $INSTALL_DIR"
rm -rf "$INSTALL_DIR/node_modules" 2>/dev/null || true rm -rf "$INSTALL_DIR"
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 fi
else
# Create fresh installation directory
echo "Creating installation directory: $INSTALL_DIR" echo "Creating installation directory: $INSTALL_DIR"
mkdir -p "$INSTALL_DIR" mkdir -p "$INSTALL_DIR"
fi
# Download binary # Download binary
echo "Downloading NUPST binary..." echo "Downloading NUPST binary..."
@@ -241,9 +216,20 @@ fi
BINARY_PATH="$INSTALL_DIR/nupst" BINARY_PATH="$INSTALL_DIR/nupst"
mv "$TEMP_FILE" "$BINARY_PATH" 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 # Make executable
chmod +x "$BINARY_PATH" 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 "Binary installed successfully to: $BINARY_PATH"
echo "" echo ""
@@ -260,18 +246,10 @@ echo "Symlink created: $BIN_DIR/nupst -> $BINARY_PATH"
echo "" 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 # Restart service if it was running before update
if [ $SERVICE_WAS_RUNNING -eq 1 ]; then if [ $SERVICE_WAS_RUNNING -eq 1 ]; then
echo "Restarting NUPST service..." echo "Restarting NUPST service..."
systemctl start nupst systemctl restart nupst
echo "Service restarted successfully." echo "Service restarted successfully."
echo "" echo ""
fi fi
@@ -280,20 +258,6 @@ echo "================================================"
echo " NUPST Installation Complete!" echo " NUPST Installation Complete!"
echo "================================================" echo "================================================"
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 "Installation details:"
echo " Binary location: $BINARY_PATH" echo " Binary location: $BINARY_PATH"
echo " Symlink location: $BIN_DIR/nupst" echo " Symlink location: $BIN_DIR/nupst"

20
npmextra.json Normal file
View File

@@ -0,0 +1,20 @@
{
"@git.zone/cli": {
"release": {
"registries": [
"https://verdaccio.lossless.digital"
],
"accessLevel": "public"
},
"projectType": "deno",
"module": {
"githost": "code.foss.global",
"gitscope": "serve.zone",
"gitrepo": "nupst",
"description": "shut down in time when the power goes out",
"npmPackagename": "@serve.zone/nupst",
"license": "MIT"
}
},
"@ship.zone/szci": {}
}

64
package.json Normal file
View File

@@ -0,0 +1,64 @@
{
"name": "@serve.zone/nupst",
"version": "5.2.1",
"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"
}

View File

@@ -0,0 +1,58 @@
# NUPST Project Hints
## Recent Refactoring (January 2026)
### Phase 1 - Quick Wins
1. **Prompt Utility (`ts/helpers/prompt.ts`)**
- Extracted readline/prompt pattern from all CLI handlers
- Provides `createPrompt()` and `withPrompt()` helper functions
- Used in: `ups-handler.ts`, `group-handler.ts`, `service-handler.ts`, `action-handler.ts`, `feature-handler.ts`
2. **Constants File (`ts/constants.ts`)**
- Centralized all magic numbers (timeouts, intervals, thresholds)
- Contains: `TIMING`, `SNMP`, `THRESHOLDS`, `WEBHOOK`, `SCRIPT`, `SHUTDOWN`, `HTTP_SERVER`, `UI`
- Used in: `daemon.ts`, `snmp/manager.ts`, `actions/*.ts`
3. **Logger Consistency**
- Replaced all `console.log/console.error` in `snmp/manager.ts` with proper `logger.*` calls
- Debug output uses `logger.dim()` for less intrusive output
### Phase 2 - Type Safety
4. **Circular Dependency Fix (`ts/interfaces/nupst-accessor.ts`)**
- Created `INupstAccessor` interface to break the circular dependency between `Nupst` and `NupstSnmp`
- `NupstSnmp.nupst` property now uses the interface instead of `any`
5. **Webhook Payload Interface (`ts/actions/webhook-action.ts`)**
- Added `IWebhookPayload` interface for webhook action payloads
- Exported from `ts/actions/index.ts`
6. **CLI Handler Type Safety**
- Replaced `any` types in `ups-handler.ts` and `group-handler.ts` with proper interfaces
- Uses: `IUpsConfig`, `INupstConfig`, `ISnmpConfig`, `IActionConfig`, `IThresholds`, `ISnmpUpsStatus`
## Architecture Notes
- **SNMP Manager**: Uses `INupstAccessor` interface (not direct `Nupst` reference) to avoid circular imports
- **CLI Handlers**: All use the `helpers.withPrompt()` utility for interactive input
- **Constants**: All timing values should be referenced from `ts/constants.ts`
- **Actions**: Use `IActionConfig` from `ts/actions/base-action.ts` for action configuration
## File Organization
```
ts/
├── constants.ts # All timing/threshold constants
├── interfaces/
│ └── nupst-accessor.ts # Interface to break circular deps
├── helpers/
│ ├── prompt.ts # Readline utility
│ └── shortid.ts # ID generation
├── actions/
│ ├── base-action.ts # Base action class and interfaces
│ ├── webhook-action.ts # Includes IWebhookPayload
│ └── ...
└── cli/
└── ... # All handlers use helpers.withPrompt()
```

1161
readme.md

File diff suppressed because it is too large Load Diff

231
scripts/install-binary.js Normal file
View 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

Binary file not shown.

View File

@@ -1,93 +1,368 @@
import { assert, assertEquals, assertExists } from 'jsr:@std/assert@^1.0.0'; import { assert, assertEquals, assertExists } from 'jsr:@std/assert@^1.0.0';
import { NupstSnmp } from '../ts/snmp/manager.ts'; import { NupstSnmp } from '../ts/snmp/manager.ts';
import type { ISnmpConfig } from '../ts/snmp/types.ts'; import { UpsOidSets } from '../ts/snmp/oid-sets.ts';
import type { ISnmpConfig, TUpsModel, IOidSet } from '../ts/snmp/types.ts';
import { shortId } from '../ts/helpers/shortid.ts';
import { TIMING, SNMP, THRESHOLDS, HTTP_SERVER, UI } from '../ts/constants.ts';
import { Action, type IActionContext } from '../ts/actions/base-action.ts';
import * as qenv from 'npm:@push.rocks/qenv@^6.0.0'; import * as qenv from 'npm:@push.rocks/qenv@^6.0.0';
const testQenv = new qenv.Qenv('./', '.nogit/'); const testQenv = new qenv.Qenv('./', '.nogit/');
// Create an SNMP instance with debug enabled // =============================================================================
// UNIT TESTS - No external dependencies required
// =============================================================================
// -----------------------------------------------------------------------------
// shortId() Tests
// -----------------------------------------------------------------------------
Deno.test('shortId: generates 6-character string', () => {
const id = shortId();
assertEquals(id.length, 6);
});
Deno.test('shortId: contains only alphanumeric characters', () => {
const id = shortId();
const alphanumericRegex = /^[a-zA-Z0-9]+$/;
assert(alphanumericRegex.test(id), `ID "${id}" contains non-alphanumeric characters`);
});
Deno.test('shortId: generates unique IDs', () => {
const ids = new Set<string>();
const count = 100;
for (let i = 0; i < count; i++) {
ids.add(shortId());
}
// All IDs should be unique (statistically extremely likely for 100 IDs)
assertEquals(ids.size, count, 'Generated IDs should be unique');
});
// -----------------------------------------------------------------------------
// Constants Tests
// -----------------------------------------------------------------------------
Deno.test('TIMING constants: all values are positive numbers', () => {
for (const [key, value] of Object.entries(TIMING)) {
assert(typeof value === 'number', `TIMING.${key} should be a number`);
assert(value > 0, `TIMING.${key} should be positive`);
}
});
Deno.test('SNMP constants: port is 161', () => {
assertEquals(SNMP.DEFAULT_PORT, 161);
});
Deno.test('SNMP constants: timeouts increase with security level', () => {
assert(SNMP.TIMEOUT_NO_AUTH_MS <= SNMP.TIMEOUT_AUTH_MS, 'Auth timeout should be >= noAuth timeout');
assert(SNMP.TIMEOUT_AUTH_MS <= SNMP.TIMEOUT_AUTH_PRIV_MS, 'AuthPriv timeout should be >= Auth timeout');
});
Deno.test('THRESHOLDS constants: defaults are reasonable', () => {
assert(THRESHOLDS.DEFAULT_BATTERY_PERCENT > 0 && THRESHOLDS.DEFAULT_BATTERY_PERCENT <= 100);
assert(THRESHOLDS.DEFAULT_RUNTIME_MINUTES > 0);
assert(THRESHOLDS.EMERGENCY_RUNTIME_MINUTES < THRESHOLDS.DEFAULT_RUNTIME_MINUTES);
});
Deno.test('HTTP_SERVER constants: valid defaults', () => {
assertEquals(HTTP_SERVER.DEFAULT_PORT, 8080);
assert(HTTP_SERVER.DEFAULT_PATH.startsWith('/'));
});
Deno.test('UI constants: box widths are ascending', () => {
assert(UI.DEFAULT_BOX_WIDTH < UI.WIDE_BOX_WIDTH);
assert(UI.WIDE_BOX_WIDTH < UI.EXTRA_WIDE_BOX_WIDTH);
});
// -----------------------------------------------------------------------------
// UpsOidSets Tests
// -----------------------------------------------------------------------------
const UPS_MODELS: TUpsModel[] = ['cyberpower', 'apc', 'eaton', 'tripplite', 'liebert', 'custom'];
Deno.test('UpsOidSets: all models have OID sets', () => {
for (const model of UPS_MODELS) {
const oidSet = UpsOidSets.getOidSet(model);
assertExists(oidSet, `OID set for ${model} should exist`);
}
});
Deno.test('UpsOidSets: all non-custom models have complete OIDs', () => {
const requiredOids = ['POWER_STATUS', 'BATTERY_CAPACITY', 'BATTERY_RUNTIME', 'OUTPUT_LOAD'];
for (const model of UPS_MODELS.filter(m => m !== 'custom')) {
const oidSet = UpsOidSets.getOidSet(model);
for (const oid of requiredOids) {
const value = oidSet[oid as keyof IOidSet];
assert(
typeof value === 'string' && value.length > 0,
`${model} should have non-empty ${oid}`
);
}
}
});
Deno.test('UpsOidSets: power status values defined for non-custom models', () => {
for (const model of UPS_MODELS.filter(m => m !== 'custom')) {
const oidSet = UpsOidSets.getOidSet(model);
assertExists(oidSet.POWER_STATUS_VALUES, `${model} should have POWER_STATUS_VALUES`);
assertExists(oidSet.POWER_STATUS_VALUES?.online, `${model} should have online value`);
assertExists(oidSet.POWER_STATUS_VALUES?.onBattery, `${model} should have onBattery value`);
}
});
Deno.test('UpsOidSets: getStandardOids returns RFC 1628 OIDs', () => {
const standardOids = UpsOidSets.getStandardOids();
assert('power status' in standardOids);
assert('battery capacity' in standardOids);
assert('battery runtime' in standardOids);
// RFC 1628 OIDs start with 1.3.6.1.2.1.33
for (const oid of Object.values(standardOids)) {
assert(oid.startsWith('1.3.6.1.2.1.33'), `Standard OID should be RFC 1628: ${oid}`);
}
});
// -----------------------------------------------------------------------------
// Action Base Class Tests
// -----------------------------------------------------------------------------
// Create a concrete implementation for testing
class TestAction extends Action {
readonly type = 'test';
executeCallCount = 0;
execute(_context: IActionContext): Promise<void> {
this.executeCallCount++;
return Promise.resolve();
}
// Expose protected methods for testing
public testShouldExecute(context: IActionContext): boolean {
return this.shouldExecute(context);
}
public testAreThresholdsExceeded(batteryCapacity: number, batteryRuntime: number): boolean {
return this.areThresholdsExceeded(batteryCapacity, batteryRuntime);
}
}
function createMockContext(overrides: Partial<IActionContext> = {}): IActionContext {
return {
upsId: 'test-ups',
upsName: 'Test UPS',
powerStatus: 'online',
batteryCapacity: 100,
batteryRuntime: 60,
previousPowerStatus: 'online',
timestamp: Date.now(),
triggerReason: 'powerStatusChange',
...overrides,
};
}
Deno.test('Action.areThresholdsExceeded: returns false when no thresholds configured', () => {
const action = new TestAction({ type: 'shutdown' });
assertEquals(action.testAreThresholdsExceeded(50, 30), false);
});
Deno.test('Action.areThresholdsExceeded: returns true when battery below threshold', () => {
const action = new TestAction({
type: 'shutdown',
thresholds: { battery: 60, runtime: 20 },
});
assertEquals(action.testAreThresholdsExceeded(59, 30), true); // Battery below
assertEquals(action.testAreThresholdsExceeded(60, 30), false); // Battery at threshold
assertEquals(action.testAreThresholdsExceeded(100, 30), false); // Battery above
});
Deno.test('Action.areThresholdsExceeded: returns true when runtime below threshold', () => {
const action = new TestAction({
type: 'shutdown',
thresholds: { battery: 60, runtime: 20 },
});
assertEquals(action.testAreThresholdsExceeded(100, 19), true); // Runtime below
assertEquals(action.testAreThresholdsExceeded(100, 20), false); // Runtime at threshold
assertEquals(action.testAreThresholdsExceeded(100, 60), false); // Runtime above
});
Deno.test('Action.shouldExecute: onlyPowerChanges mode', () => {
const action = new TestAction({
type: 'shutdown',
triggerMode: 'onlyPowerChanges',
});
assertEquals(
action.testShouldExecute(createMockContext({ triggerReason: 'powerStatusChange' })),
true
);
assertEquals(
action.testShouldExecute(createMockContext({ triggerReason: 'thresholdViolation' })),
false
);
});
Deno.test('Action.shouldExecute: onlyThresholds mode', () => {
const action = new TestAction({
type: 'shutdown',
triggerMode: 'onlyThresholds',
thresholds: { battery: 60, runtime: 20 },
});
// Below thresholds - should execute
assertEquals(
action.testShouldExecute(createMockContext({ batteryCapacity: 50, batteryRuntime: 10 })),
true
);
// Above thresholds - should not execute
assertEquals(
action.testShouldExecute(createMockContext({ batteryCapacity: 100, batteryRuntime: 60 })),
false
);
});
Deno.test('Action.shouldExecute: onlyThresholds mode without thresholds returns false', () => {
const action = new TestAction({
type: 'shutdown',
triggerMode: 'onlyThresholds',
// No thresholds configured
});
assertEquals(action.testShouldExecute(createMockContext()), false);
});
Deno.test('Action.shouldExecute: powerChangesAndThresholds mode (default)', () => {
const action = new TestAction({
type: 'shutdown',
thresholds: { battery: 60, runtime: 20 },
// No triggerMode = defaults to powerChangesAndThresholds
});
// Power change - should execute
assertEquals(
action.testShouldExecute(createMockContext({ triggerReason: 'powerStatusChange' })),
true
);
// Threshold violation - should execute
assertEquals(
action.testShouldExecute(createMockContext({
triggerReason: 'thresholdViolation',
batteryCapacity: 50,
})),
true
);
// No power change and above thresholds - should not execute
assertEquals(
action.testShouldExecute(createMockContext({
triggerReason: 'thresholdViolation',
batteryCapacity: 100,
batteryRuntime: 60,
})),
false
);
});
Deno.test('Action.shouldExecute: anyChange mode always returns true', () => {
const action = new TestAction({
type: 'shutdown',
triggerMode: 'anyChange',
});
assertEquals(
action.testShouldExecute(createMockContext({ triggerReason: 'powerStatusChange' })),
true
);
assertEquals(
action.testShouldExecute(createMockContext({ triggerReason: 'thresholdViolation' })),
true
);
});
// -----------------------------------------------------------------------------
// NupstSnmp Class Tests (Unit tests - no real UPS needed)
// -----------------------------------------------------------------------------
Deno.test('NupstSnmp: can be instantiated', () => {
const snmp = new NupstSnmp(false);
assertExists(snmp);
});
Deno.test('NupstSnmp: debug mode can be enabled', () => {
const snmpDebug = new NupstSnmp(true);
const snmpNormal = new NupstSnmp(false);
assertExists(snmpDebug);
assertExists(snmpNormal);
});
// =============================================================================
// INTEGRATION TESTS - Require real UPS (loaded from .nogit/env.json)
// =============================================================================
// Helper function to run UPS test with config
async function testUpsConnection(
snmp: NupstSnmp,
config: Record<string, unknown>,
description: string,
): Promise<void> {
console.log(`Testing ${description}...`);
const snmpConfig = config.snmp as ISnmpConfig;
console.log('SNMP Config:');
console.log(` Host: ${snmpConfig.host}:${snmpConfig.port}`);
console.log(` Version: SNMPv${snmpConfig.version}`);
console.log(` UPS Model: ${snmpConfig.upsModel}`);
// Use a reasonable timeout for testing
const testSnmpConfig = {
...snmpConfig,
timeout: Math.min(snmpConfig.timeout, SNMP.MAX_TEST_TIMEOUT_MS),
};
const status = await snmp.getUpsStatus(testSnmpConfig);
console.log('UPS Status:');
console.log(` Power Status: ${status.powerStatus}`);
console.log(` Battery Capacity: ${status.batteryCapacity}%`);
console.log(` Runtime Remaining: ${status.batteryRuntime} minutes`);
// Validate response structure
assertExists(status, 'Status should exist');
assert(
['online', 'onBattery', 'unknown'].includes(status.powerStatus),
`Power status should be valid: ${status.powerStatus}`
);
assertEquals(typeof status.batteryCapacity, 'number', 'Battery capacity should be a number');
assertEquals(typeof status.batteryRuntime, 'number', 'Battery runtime should be a number');
// Validate ranges
assert(
status.batteryCapacity >= 0 && status.batteryCapacity <= 100,
`Battery capacity should be 0-100: ${status.batteryCapacity}`
);
assert(status.batteryRuntime >= 0, `Battery runtime should be non-negative: ${status.batteryRuntime}`);
}
// Create SNMP instance for integration tests
const snmp = new NupstSnmp(true); const snmp = new NupstSnmp(true);
// Load the test configuration from .nogit/env.json // Load test configurations
const testConfigV1 = await testQenv.getEnvVarOnDemandAsObject('testConfigV1'); const testConfigV1 = await testQenv.getEnvVarOnDemandAsObject('testConfigV1');
const testConfigV3 = await testQenv.getEnvVarOnDemandAsObject('testConfigV3'); const testConfigV3 = await testQenv.getEnvVarOnDemandAsObject('testConfigV3');
Deno.test('should log config', () => { Deno.test('Integration: Real UPS test v1', async () => {
console.log(testConfigV1); await testUpsConnection(snmp, testConfigV1, 'SNMPv1 connection');
assert(true);
}); });
// Test with real UPS using the configuration from .nogit/env.json Deno.test('Integration: Real UPS test v3', async () => {
Deno.test('Real UPS test v1', async () => { await testUpsConnection(snmp, testConfigV3, 'SNMPv3 connection');
try {
console.log('Testing with real UPS configuration...');
// Extract the correct SNMP config from the test configuration
const snmpConfig = testConfigV1.snmp as ISnmpConfig;
console.log('SNMP Config:');
console.log(` Host: ${snmpConfig.host}:${snmpConfig.port}`);
console.log(` Version: SNMPv${snmpConfig.version}`);
console.log(` UPS Model: ${snmpConfig.upsModel}`);
// Use a short timeout for testing
const testSnmpConfig = {
...snmpConfig,
timeout: Math.min(snmpConfig.timeout, 10000), // Use at most 10 seconds for testing
};
// Try to get the UPS status
const status = await snmp.getUpsStatus(testSnmpConfig);
console.log('UPS Status:');
console.log(` Power Status: ${status.powerStatus}`);
console.log(` Battery Capacity: ${status.batteryCapacity}%`);
console.log(` Runtime Remaining: ${status.batteryRuntime} minutes`);
// Just make sure we got valid data types back
assertExists(status);
assert(['online', 'onBattery', 'unknown'].includes(status.powerStatus));
assertEquals(typeof status.batteryCapacity, 'number');
assertEquals(typeof status.batteryRuntime, 'number');
} catch (error) {
console.log('Real UPS test failed:', error);
// Skip the test if we can't connect to the real UPS
console.log('Skipping this test since the UPS might not be available');
}
});
Deno.test('Real UPS test v3', async () => {
try {
console.log('Testing with real UPS configuration...');
// Extract the correct SNMP config from the test configuration
const snmpConfig = testConfigV3.snmp as ISnmpConfig;
console.log('SNMP Config:');
console.log(` Host: ${snmpConfig.host}:${snmpConfig.port}`);
console.log(` Version: SNMPv${snmpConfig.version}`);
console.log(` UPS Model: ${snmpConfig.upsModel}`);
// Use a short timeout for testing
const testSnmpConfig = {
...snmpConfig,
timeout: Math.min(snmpConfig.timeout, 10000), // Use at most 10 seconds for testing
};
// Try to get the UPS status
const status = await snmp.getUpsStatus(testSnmpConfig);
console.log('UPS Status:');
console.log(` Power Status: ${status.powerStatus}`);
console.log(` Battery Capacity: ${status.batteryCapacity}%`);
console.log(` Runtime Remaining: ${status.batteryRuntime} minutes`);
// Just make sure we got valid data types back
assertExists(status);
assert(['online', 'onBattery', 'unknown'].includes(status.powerStatus));
assertEquals(typeof status.batteryCapacity, 'number');
assertEquals(typeof status.batteryRuntime, 'number');
} catch (error) {
console.log('Real UPS test failed:', error);
// Skip the test if we can't connect to the real UPS
console.log('Skipping this test since the UPS might not be available');
}
}); });

View File

@@ -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 = { export const commitinfo = {
name: denoConfig.name, name: '@serve.zone/nupst',
version: denoConfig.version, version: '5.2.1',
description: 'Network UPS Shutdown Tool (https://nupst.serve.zone)', description: 'Network UPS Shutdown Tool - Monitor SNMP-enabled UPS devices and orchestrate graceful system shutdowns during power emergencies'
}; }

View File

@@ -13,6 +13,7 @@ import { ScriptAction } from './script-action.ts';
// Re-export types for convenience // Re-export types for convenience
export type { IActionConfig, IActionContext, TPowerStatus } from './base-action.ts'; export type { IActionConfig, IActionContext, TPowerStatus } from './base-action.ts';
export type { IWebhookPayload } from './webhook-action.ts';
export { Action } from './base-action.ts'; export { Action } from './base-action.ts';
export { ShutdownAction } from './shutdown-action.ts'; export { ShutdownAction } from './shutdown-action.ts';
export { WebhookAction } from './webhook-action.ts'; export { WebhookAction } from './webhook-action.ts';

View File

@@ -1,5 +1,6 @@
import * as path from 'node:path'; import * as path from 'node:path';
import * as fs from 'node:fs'; import * as fs from 'node:fs';
import process from 'node:process';
import { exec } from 'node:child_process'; import { exec } from 'node:child_process';
import { promisify } from 'node:util'; import { promisify } from 'node:util';
import { Action, type IActionConfig, type IActionContext } from './base-action.ts'; import { Action, type IActionConfig, type IActionContext } from './base-action.ts';

View File

@@ -1,8 +1,9 @@
import * as fs from 'node:fs'; import * as fs from 'node:fs';
import { execFile } from 'node:child_process'; import { execFile } from 'node:child_process';
import { promisify } from 'node:util'; import { promisify } from 'node:util';
import { Action, type IActionConfig, type IActionContext } from './base-action.ts'; import { Action, type IActionContext } from './base-action.ts';
import { logger } from '../logger.ts'; import { logger } from '../logger.ts';
import { SHUTDOWN, UI } from '../constants.ts';
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
@@ -15,6 +16,81 @@ const execFileAsync = promisify(execFile);
export class ShutdownAction extends Action { export class ShutdownAction extends Action {
readonly type = 'shutdown'; readonly type = 'shutdown';
/**
* Override shouldExecute to add shutdown-specific safety checks
*
* Key safety rules:
* 1. Shutdown should NEVER trigger unless UPS is actually on battery
* (low battery while on grid power is not an emergency - it's charging)
* 2. For power status changes, only trigger on transitions TO onBattery from online
* (ignore unknown → online at startup, and power restoration events)
* 3. For threshold violations, verify UPS is on battery before acting
*
* @param context Action context with UPS state
* @returns True if shutdown should execute
*/
protected override shouldExecute(context: IActionContext): boolean {
const mode = this.config.triggerMode || 'powerChangesAndThresholds';
// CRITICAL SAFETY CHECK: Shutdown should NEVER trigger unless UPS is on battery
// A low battery while on grid power is not an emergency (the battery is charging)
if (context.powerStatus !== 'onBattery') {
logger.info(`Shutdown action skipped: UPS is not on battery (status: ${context.powerStatus})`);
return false;
}
// Handle threshold violations (UPS is confirmed on battery at this point)
if (context.triggerReason === 'thresholdViolation') {
// 'onlyPowerChanges' mode ignores thresholds
if (mode === 'onlyPowerChanges') {
logger.info('Shutdown action skipped: triggerMode is onlyPowerChanges, ignoring threshold');
return false;
}
// Check if thresholds are actually exceeded
return this.areThresholdsExceeded(context.batteryCapacity, context.batteryRuntime);
}
// Handle power status changes
if (context.triggerReason === 'powerStatusChange') {
// 'onlyThresholds' mode ignores power status changes
if (mode === 'onlyThresholds') {
logger.info('Shutdown action skipped: triggerMode is onlyThresholds, ignoring power change');
return false;
}
const prev = context.previousPowerStatus;
// Only trigger on transitions TO onBattery from online (real power loss)
if (prev === 'online') {
logger.info('Shutdown action triggered: power loss detected (online → onBattery)');
return true;
}
// For unknown → onBattery (daemon started while on battery):
// This is a startup scenario - be cautious. The user may have just started
// the daemon for testing, or the UPS may have been on battery for a while.
// Only trigger if mode explicitly includes power changes.
if (prev === 'unknown') {
if (mode === 'onlyPowerChanges' || mode === 'powerChangesAndThresholds' || mode === 'anyChange') {
logger.info('Shutdown action triggered: UPS on battery at daemon startup (unknown → onBattery)');
return true;
}
return false;
}
// Other transitions (e.g., onBattery → onBattery) should not trigger
logger.info(`Shutdown action skipped: non-emergency transition (${prev}${context.powerStatus})`);
return false;
}
// For 'anyChange' mode, always execute (UPS is already confirmed on battery)
if (mode === 'anyChange') {
return true;
}
return false;
}
/** /**
* Execute the shutdown action * Execute the shutdown action
* @param context Action context with UPS state * @param context Action context with UPS state
@@ -26,10 +102,10 @@ export class ShutdownAction extends Action {
return; return;
} }
const shutdownDelay = this.config.shutdownDelay || 5; // Default 5 minutes const shutdownDelay = this.config.shutdownDelay || SHUTDOWN.DEFAULT_DELAY_MINUTES;
logger.log(''); logger.log('');
logger.logBoxTitle('Initiating System Shutdown', 60, 'error'); logger.logBoxTitle('Initiating System Shutdown', UI.WIDE_BOX_WIDTH, 'error');
logger.logBoxLine(`UPS: ${context.upsName} (${context.upsId})`); logger.logBoxLine(`UPS: ${context.upsName} (${context.upsId})`);
logger.logBoxLine(`Power Status: ${context.powerStatus}`); logger.logBoxLine(`Power Status: ${context.powerStatus}`);
logger.logBoxLine(`Battery: ${context.batteryCapacity}%`); logger.logBoxLine(`Battery: ${context.batteryCapacity}%`);

View File

@@ -3,6 +3,32 @@ import * as https from 'node:https';
import { URL } from 'node:url'; import { URL } from 'node:url';
import { Action, type IActionConfig, type IActionContext } from './base-action.ts'; import { Action, type IActionConfig, type IActionContext } from './base-action.ts';
import { logger } from '../logger.ts'; import { logger } from '../logger.ts';
import { WEBHOOK } from '../constants.ts';
/**
* Payload sent to webhook endpoints
*/
export interface IWebhookPayload {
/** UPS ID */
upsId: string;
/** UPS name */
upsName: string;
/** Current power status */
powerStatus: 'online' | 'onBattery' | 'unknown';
/** Current battery capacity percentage */
batteryCapacity: number;
/** Current battery runtime in minutes */
batteryRuntime: number;
/** Reason this webhook was triggered */
triggerReason: 'powerStatusChange' | 'thresholdViolation';
/** Timestamp when webhook was triggered */
timestamp: number;
/** Thresholds configured for this action (if any) */
thresholds?: {
battery: number;
runtime: number;
};
}
/** /**
* WebhookAction - Calls an HTTP webhook with UPS state information * WebhookAction - Calls an HTTP webhook with UPS state information
@@ -30,7 +56,7 @@ export class WebhookAction extends Action {
} }
const method = this.config.webhookMethod || 'POST'; const method = this.config.webhookMethod || 'POST';
const timeout = this.config.webhookTimeout || 10000; const timeout = this.config.webhookTimeout || WEBHOOK.DEFAULT_TIMEOUT_MS;
logger.info(`Calling webhook: ${method} ${this.config.webhookUrl}`); logger.info(`Calling webhook: ${method} ${this.config.webhookUrl}`);
@@ -56,7 +82,7 @@ export class WebhookAction extends Action {
method: 'GET' | 'POST', method: 'GET' | 'POST',
timeout: number, timeout: number,
): Promise<void> { ): Promise<void> {
const payload: any = { const payload: IWebhookPayload = {
upsId: context.upsId, upsId: context.upsId,
upsName: context.upsName, upsName: context.upsName,
powerStatus: context.powerStatus, powerStatus: context.powerStatus,

200
ts/cli.ts
View File

@@ -72,6 +72,7 @@ export class NupstCli {
const upsHandler = this.nupst.getUpsHandler(); const upsHandler = this.nupst.getUpsHandler();
const groupHandler = this.nupst.getGroupHandler(); const groupHandler = this.nupst.getGroupHandler();
const serviceHandler = this.nupst.getServiceHandler(); const serviceHandler = this.nupst.getServiceHandler();
const actionHandler = this.nupst.getActionHandler();
// Handle service subcommands // Handle service subcommands
if (command === 'service') { if (command === 'service') {
@@ -126,8 +127,7 @@ export class NupstCli {
break; break;
} }
case 'remove': case 'remove':
case 'rm': // Alias case 'rm': {
case 'delete': { // Backward compatibility
const upsIdToRemove = subcommandArgs[0]; const upsIdToRemove = subcommandArgs[0];
if (!upsIdToRemove) { if (!upsIdToRemove) {
logger.error('UPS ID is required for remove command'); logger.error('UPS ID is required for remove command');
@@ -171,8 +171,7 @@ export class NupstCli {
break; break;
} }
case 'remove': case 'remove':
case 'rm': // Alias case 'rm': {
case 'delete': { // Backward compatibility
const groupIdToRemove = subcommandArgs[0]; const groupIdToRemove = subcommandArgs[0];
if (!groupIdToRemove) { if (!groupIdToRemove) {
logger.error('Group ID is required for remove command'); logger.error('Group ID is required for remove command');
@@ -193,6 +192,55 @@ export class NupstCli {
return; 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 // Handle config subcommand
if (command === 'config') { if (command === 'config') {
const subcommand = commandArgs[0] || 'show'; const subcommand = commandArgs[0] || 'show';
@@ -209,72 +257,8 @@ export class NupstCli {
return; return;
} }
// Handle top-level commands and backward compatibility // Handle top-level commands
switch (command) { 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': case 'update':
await serviceHandler.update(); await serviceHandler.update();
break; break;
@@ -328,6 +312,26 @@ export class NupstCli {
` ${theme.path('/etc/nupst/config.json')}`, ` ${theme.path('/etc/nupst/config.json')}`,
], 60, 'info'); ], 60, 'info');
// 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');
}
// UPS Devices Table // UPS Devices Table
if (config.upsDevices.length > 0) { if (config.upsDevices.length > 0) {
const upsRows = config.upsDevices.map((ups) => ({ const upsRows = config.upsDevices.map((ups) => ({
@@ -499,6 +503,8 @@ export class NupstCli {
this.printCommand('service <subcommand>', 'Manage systemd service'); this.printCommand('service <subcommand>', 'Manage systemd service');
this.printCommand('ups <subcommand>', 'Manage UPS devices'); this.printCommand('ups <subcommand>', 'Manage UPS devices');
this.printCommand('group <subcommand>', 'Manage UPS groups'); 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('config [show]', 'Display current configuration');
this.printCommand('update', 'Update NUPST from repository', theme.dim('(requires root)')); this.printCommand('update', 'Update NUPST from repository', theme.dim('(requires root)'));
this.printCommand('uninstall', 'Completely remove NUPST', theme.dim('(requires root)')); this.printCommand('uninstall', 'Completely remove NUPST', theme.dim('(requires root)'));
@@ -535,6 +541,18 @@ export class NupstCli {
this.printCommand('nupst group list (or ls)', 'List all UPS groups'); this.printCommand('nupst group list (or ls)', 'List all UPS groups');
console.log(''); 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 // Options
logger.log(theme.info('Options:')); logger.log(theme.info('Options:'));
this.printCommand('--debug, -d', 'Enable debug mode for detailed SNMP logging'); this.printCommand('--debug, -d', 'Enable debug mode for detailed SNMP logging');
@@ -548,11 +566,6 @@ export class NupstCli {
logger.dim(' nupst group list # Show all configured groups'); logger.dim(' nupst group list # Show all configured groups');
logger.dim(' nupst config # Display current configuration'); logger.dim(' nupst config # Display current configuration');
console.log(''); 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('');
} }
/** /**
@@ -639,6 +652,45 @@ Examples:
nupst group add - Create a new group nupst group add - Create a new group
nupst group edit dc-1 - Edit group with ID 'dc-1' nupst group edit dc-1 - Edit group with ID 'dc-1'
nupst group remove dc-1 - Remove 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
`); `);
} }
} }

342
ts/cli/action-handler.ts Normal file
View File

@@ -0,0 +1,342 @@
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';
import * as helpers from '../helpers/index.ts';
/**
* Class for handling action-related CLI commands
* Provides interface for managing UPS actions
*/
export class ActionHandler {
private readonly nupst: Nupst;
/**
* Create a new action handler
* @param nupst Reference to the main Nupst instance
*/
constructor(nupst: Nupst) {
this.nupst = nupst;
}
/**
* Add a new action to a UPS or group
*/
public async add(targetId?: string): Promise<void> {
try {
if (!targetId) {
logger.error('Target ID is required');
logger.log(
` ${theme.dim('Usage:')} ${theme.command('nupst action add <ups-id|group-id>')}`,
);
logger.log('');
logger.log(` ${theme.dim('List UPS devices:')} ${theme.command('nupst ups list')}`);
logger.log(` ${theme.dim('List groups:')} ${theme.command('nupst group list')}`);
logger.log('');
process.exit(1);
}
const config = await this.nupst.getDaemon().loadConfig();
// 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;
await helpers.withPrompt(async (prompt) => {
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('');
});
} 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('');
}
}

188
ts/cli/feature-handler.ts Normal file
View File

@@ -0,0 +1,188 @@
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 {
await helpers.withPrompt(async (prompt) => {
await this.runHttpServerConfig(prompt);
});
} catch (error) {
logger.error(`HTTP Server config error: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Run the interactive HTTP server configuration process
* @param prompt Function to prompt for user input
*/
private async runHttpServerConfig(prompt: (question: string) => Promise<string>): Promise<void> {
logger.log('');
logger.logBoxTitle('HTTP Server Feature Configuration', 60);
logger.logBoxLine('Configure the HTTP server to expose UPS status as JSON');
logger.logBoxEnd();
logger.log('');
// Load config
let config;
try {
await this.nupst.getDaemon().loadConfig();
config = this.nupst.getDaemon().getConfig();
} catch (error) {
logger.error('No configuration found. Please run "nupst ups add" first.');
return;
}
// Show current status
if (config.httpServer?.enabled) {
logger.info('HTTP Server is currently: ' + theme.success('ENABLED'));
logger.log(` Port: ${theme.highlight(String(config.httpServer.port))}`);
logger.log(` Path: ${theme.highlight(config.httpServer.path)}`);
logger.log(` Auth Token: ${theme.dim('***' + config.httpServer.authToken.slice(-4))}`);
logger.log('');
} else {
logger.info('HTTP Server is currently: ' + theme.dim('DISABLED'));
logger.log('');
}
// Ask enable/disable
const action = await prompt('Enable or disable HTTP server? (enable/disable/cancel): ');
if (action.toLowerCase() === 'cancel' || action.toLowerCase() === 'c') {
logger.log('Cancelled.');
return;
}
if (action.toLowerCase() === 'disable' || action.toLowerCase() === 'd') {
// Disable HTTP server
config.httpServer = {
enabled: false,
port: config.httpServer?.port || 8080,
path: config.httpServer?.path || '/ups-status',
authToken: config.httpServer?.authToken || '',
};
this.nupst.getDaemon().saveConfig(config);
logger.log('');
logger.success('HTTP Server disabled');
logger.log('');
await this.restartServiceIfRunning();
return;
}
if (action.toLowerCase() !== 'enable' && action.toLowerCase() !== 'e') {
logger.error('Invalid option. Please enter "enable", "disable", or "cancel".');
return;
}
// Enable - gather configuration
logger.log('');
const portInput = await prompt(`HTTP Server Port [${config.httpServer?.port || 8080}]: `);
const port = portInput ? parseInt(portInput, 10) : (config.httpServer?.port || 8080);
if (isNaN(port) || port < 1 || port > 65535) {
logger.error('Invalid port number. Must be between 1 and 65535.');
return;
}
const pathInput = await prompt(`URL Path [${config.httpServer?.path || '/ups-status'}]: `);
const path = pathInput || config.httpServer?.path || '/ups-status';
// Ensure path starts with /
const finalPath = path.startsWith('/') ? path : `/${path}`;
// Generate or reuse auth token
let authToken = config.httpServer?.authToken;
if (!authToken) {
// Generate new random token
authToken = helpers.shortId() + helpers.shortId() + helpers.shortId();
logger.log('');
logger.info('Generated new authentication token');
} else {
const regenerate = await prompt('Regenerate authentication token? (y/N): ');
if (regenerate.toLowerCase() === 'y' || regenerate.toLowerCase() === 'yes') {
authToken = helpers.shortId() + helpers.shortId() + helpers.shortId();
logger.info('Generated new authentication token');
}
}
// Save configuration
config.httpServer = {
enabled: true,
port,
path: finalPath,
authToken,
};
this.nupst.getDaemon().saveConfig(config);
// Display summary
logger.log('');
logger.logBoxTitle('HTTP Server Configuration', 70, 'success');
logger.logBoxLine(`Status: ${theme.success('ENABLED')}`);
logger.logBoxLine(`Port: ${theme.highlight(String(port))}`);
logger.logBoxLine(`Path: ${theme.highlight(finalPath)}`);
logger.logBoxLine(`Auth Token: ${theme.warning(authToken)}`);
logger.logBoxLine('');
logger.logBoxLine(theme.dim('Usage examples:'));
logger.logBoxLine(` curl -H "Authorization: Bearer ${authToken}" http://localhost:${port}${finalPath}`);
logger.logBoxLine(` curl "http://localhost:${port}${finalPath}?token=${authToken}"`);
logger.logBoxEnd();
logger.log('');
logger.warn('IMPORTANT: Save the authentication token securely!');
logger.log('');
await this.restartServiceIfRunning();
}
/**
* Restart the service if it's currently running
*/
private async restartServiceIfRunning(): Promise<void> {
try {
const isActive = execSync('systemctl is-active nupst.service || true').toString().trim() === 'active';
if (isActive) {
logger.log('');
const { prompt, close } = await helpers.createPrompt();
const answer = await prompt('Service is running. Restart to apply changes? (Y/n): ');
close();
if (!answer || answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes') {
logger.info('Restarting service...');
execSync('sudo systemctl restart nupst.service');
logger.success('Service restarted successfully');
} else {
logger.warn('Changes will take effect on next service restart');
}
}
} catch (error) {
// Ignore errors - service might not be installed
}
}
}

View File

@@ -3,7 +3,7 @@ import { Nupst } from '../nupst.ts';
import { logger, type ITableColumn } from '../logger.ts'; import { logger, type ITableColumn } from '../logger.ts';
import { theme } from '../colors.ts'; import { theme } from '../colors.ts';
import * as helpers from '../helpers/index.ts'; import * as helpers from '../helpers/index.ts';
import { type IGroupConfig } from '../daemon.ts'; import type { IGroupConfig, IUpsConfig, INupstConfig } from '../daemon.ts';
/** /**
* Class for handling group-related CLI commands * Class for handling group-related CLI commands
@@ -100,24 +100,7 @@ export class GroupHandler {
*/ */
public async add(): Promise<void> { public async add(): Promise<void> {
try { try {
// Import readline module for user input await helpers.withPrompt(async (prompt) => {
const readline = await import('node:readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
// Helper function to prompt for input
const prompt = (question: string): Promise<string> => {
return new Promise((resolve) => {
rl.question(question, (answer: string) => {
resolve(answer);
});
});
};
try {
// Try to load configuration // Try to load configuration
try { try {
await this.nupst.getDaemon().loadConfig(); await this.nupst.getDaemon().loadConfig();
@@ -200,10 +183,7 @@ export class GroupHandler {
this.nupst.getUpsHandler().restartServiceIfRunning(); this.nupst.getUpsHandler().restartServiceIfRunning();
logger.log('\nGroup setup complete!'); logger.log('\nGroup setup complete!');
} finally { });
rl.close();
process.stdin.destroy();
}
} catch (error) { } catch (error) {
logger.error(`Add group error: ${error instanceof Error ? error.message : String(error)}`); logger.error(`Add group error: ${error instanceof Error ? error.message : String(error)}`);
} }
@@ -215,24 +195,7 @@ export class GroupHandler {
*/ */
public async edit(groupId: string): Promise<void> { public async edit(groupId: string): Promise<void> {
try { try {
// Import readline module for user input await helpers.withPrompt(async (prompt) => {
const readline = await import('node:readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
// Helper function to prompt for input
const prompt = (question: string): Promise<string> => {
return new Promise((resolve) => {
rl.question(question, (answer: string) => {
resolve(answer);
});
});
};
try {
// Try to load configuration // Try to load configuration
try { try {
await this.nupst.getDaemon().loadConfig(); await this.nupst.getDaemon().loadConfig();
@@ -318,10 +281,7 @@ export class GroupHandler {
this.nupst.getUpsHandler().restartServiceIfRunning(); this.nupst.getUpsHandler().restartServiceIfRunning();
logger.log('\nGroup edit complete!'); logger.log('\nGroup edit complete!');
} finally { });
rl.close();
process.stdin.destroy();
}
} catch (error) { } catch (error) {
logger.error(`Edit group error: ${error instanceof Error ? error.message : String(error)}`); logger.error(`Edit group error: ${error instanceof Error ? error.message : String(error)}`);
} }
@@ -362,23 +322,11 @@ export class GroupHandler {
const groupToDelete = config.groups[groupIndex]; const groupToDelete = config.groups[groupIndex];
// Get confirmation before deleting // Get confirmation before deleting
const readline = await import('node:readline'); const { prompt, close } = await helpers.createPrompt();
const rl = readline.createInterface({ const confirm = (await prompt(
input: process.stdin,
output: process.stdout,
});
const confirm = await new Promise<string>((resolve) => {
rl.question(
`Are you sure you want to delete group "${groupToDelete.name}" (${groupId})? [y/N]: `, `Are you sure you want to delete group "${groupToDelete.name}" (${groupId})? [y/N]: `,
(answer) => { )).toLowerCase();
resolve(answer.toLowerCase()); close();
},
);
});
rl.close();
process.stdin.destroy();
if (confirm !== 'y' && confirm !== 'yes') { if (confirm !== 'y' && confirm !== 'yes') {
logger.log('Deletion cancelled.'); logger.log('Deletion cancelled.');
@@ -419,8 +367,8 @@ export class GroupHandler {
* @param prompt Function to prompt for user input * @param prompt Function to prompt for user input
*/ */
public async assignUpsToGroups( public async assignUpsToGroups(
ups: any, ups: IUpsConfig,
groups: any[], groups: IGroupConfig[],
prompt: (question: string) => Promise<string>, prompt: (question: string) => Promise<string>,
): Promise<void> { ): Promise<void> {
// Initialize groups array if it doesn't exist // Initialize groups array if it doesn't exist
@@ -514,7 +462,7 @@ export class GroupHandler {
*/ */
public async assignUpsToGroup( public async assignUpsToGroup(
groupId: string, groupId: string,
config: any, config: INupstConfig,
prompt: (question: string) => Promise<string>, prompt: (question: string) => Promise<string>,
): Promise<void> { ): Promise<void> {
if (!config.upsDevices || config.upsDevices.length === 0) { if (!config.upsDevices || config.upsDevices.length === 0) {
@@ -522,7 +470,7 @@ export class GroupHandler {
return; return;
} }
const group = config.groups.find((g: { id: string }) => g.id === groupId); const group = config.groups.find((g) => g.id === groupId);
if (!group) { if (!group) {
logger.error(`Group with ID "${groupId}" not found.`); logger.error(`Group with ID "${groupId}" not found.`);
return; return;
@@ -530,7 +478,7 @@ export class GroupHandler {
// Show current assignments // Show current assignments
logger.log(`\nUPS devices in group "${group.name}" (${group.id}):`); logger.log(`\nUPS devices in group "${group.name}" (${group.id}):`);
const upsInGroup = config.upsDevices.filter((ups: { groups?: string[] }) => const upsInGroup = config.upsDevices.filter((ups) =>
ups.groups && ups.groups.includes(groupId) ups.groups && ups.groups.includes(groupId)
); );
if (upsInGroup.length === 0) { if (upsInGroup.length === 0) {

View File

@@ -2,6 +2,7 @@ import process from 'node:process';
import { execSync } from 'node:child_process'; import { execSync } from 'node:child_process';
import { Nupst } from '../nupst.ts'; import { Nupst } from '../nupst.ts';
import { logger } from '../logger.ts'; import { logger } from '../logger.ts';
import * as helpers from '../helpers/index.ts';
/** /**
* Class for handling service-related CLI commands * Class for handling service-related CLI commands
@@ -196,22 +197,7 @@ export class ServiceHandler {
this.checkRootAccess('This command must be run as root.'); this.checkRootAccess('This command must be run as root.');
try { try {
// Import readline module for user input const { prompt, close } = await helpers.createPrompt();
const readline = await import('readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
// Helper function to prompt for input
const prompt = (question: string): Promise<string> => {
return new Promise((resolve) => {
rl.question(question, (answer: string) => {
resolve(answer);
});
});
};
logger.log(''); logger.log('');
logger.highlight('NUPST Uninstaller'); logger.highlight('NUPST Uninstaller');
@@ -254,15 +240,13 @@ export class ServiceHandler {
if (!uninstallScriptPath) { if (!uninstallScriptPath) {
logger.error('Could not locate uninstall.sh script. Aborting uninstall.'); logger.error('Could not locate uninstall.sh script. Aborting uninstall.');
rl.close(); close();
process.stdin.destroy();
process.exit(1); process.exit(1);
} }
} }
// Close readline before executing script // Close prompt before executing script
rl.close(); close();
process.stdin.destroy();
// Execute uninstall.sh with the appropriate option // Execute uninstall.sh with the appropriate option
logger.log(''); logger.log('');

View File

@@ -4,8 +4,17 @@ import { Nupst } from '../nupst.ts';
import { logger, type ITableColumn } from '../logger.ts'; import { logger, type ITableColumn } from '../logger.ts';
import { theme } from '../colors.ts'; import { theme } from '../colors.ts';
import * as helpers from '../helpers/index.ts'; import * as helpers from '../helpers/index.ts';
import type { TUpsModel } from '../snmp/types.ts'; import type { ISnmpConfig, TUpsModel, IUpsStatus as ISnmpUpsStatus } from '../snmp/types.ts';
import type { INupstConfig } from '../daemon.ts'; import type { INupstConfig, IUpsConfig, IUpsStatus } from '../daemon.ts';
import type { IActionConfig } from '../actions/base-action.ts';
/**
* Thresholds configuration for CLI display
*/
interface IThresholds {
battery: number;
runtime: number;
}
/** /**
* Class for handling UPS-related CLI commands * Class for handling UPS-related CLI commands
@@ -27,29 +36,9 @@ export class UpsHandler {
*/ */
public async add(): Promise<void> { public async add(): Promise<void> {
try { try {
// Import readline module for user input await helpers.withPrompt(async (prompt) => {
const readline = await import('node:readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
// Helper function to prompt for input
const prompt = (question: string): Promise<string> => {
return new Promise((resolve) => {
rl.question(question, (answer: string) => {
resolve(answer);
});
});
};
try {
await this.runAddProcess(prompt); await this.runAddProcess(prompt);
} finally { });
rl.close();
process.stdin.destroy();
}
} catch (error) { } catch (error) {
logger.error(`Add UPS error: ${error instanceof Error ? error.message : String(error)}`); logger.error(`Add UPS error: ${error instanceof Error ? error.message : String(error)}`);
} }
@@ -160,29 +149,9 @@ export class UpsHandler {
*/ */
public async edit(upsId?: string): Promise<void> { public async edit(upsId?: string): Promise<void> {
try { try {
// Import readline module for user input await helpers.withPrompt(async (prompt) => {
const readline = await import('node:readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
// Helper function to prompt for input
const prompt = (question: string): Promise<string> => {
return new Promise((resolve) => {
rl.question(question, (answer: string) => {
resolve(answer);
});
});
};
try {
await this.runEditProcess(upsId, prompt); await this.runEditProcess(upsId, prompt);
} finally { });
rl.close();
process.stdin.destroy();
}
} catch (error) { } catch (error) {
logger.error(`Edit UPS error: ${error instanceof Error ? error.message : String(error)}`); logger.error(`Edit UPS error: ${error instanceof Error ? error.message : String(error)}`);
} }
@@ -337,23 +306,11 @@ export class UpsHandler {
const upsToDelete = config.upsDevices[upsIndex]; const upsToDelete = config.upsDevices[upsIndex];
// Get confirmation before deleting // Get confirmation before deleting
const readline = await import('node:readline'); const { prompt, close } = await helpers.createPrompt();
const rl = readline.createInterface({ const confirm = (await prompt(
input: process.stdin,
output: process.stdout,
});
const confirm = await new Promise<string>((resolve) => {
rl.question(
`Are you sure you want to delete UPS "${upsToDelete.name}" (${upsId})? [y/N]: `, `Are you sure you want to delete UPS "${upsToDelete.name}" (${upsId})? [y/N]: `,
(answer) => { )).toLowerCase();
resolve(answer.toLowerCase()); close();
},
);
});
rl.close();
process.stdin.destroy();
if (confirm !== 'y' && confirm !== 'yes') { if (confirm !== 'y' && confirm !== 'yes') {
logger.log('Deletion cancelled.'); logger.log('Deletion cancelled.');
@@ -509,19 +466,28 @@ export class UpsHandler {
* Display the configuration for testing * Display the configuration for testing
* @param config Current configuration or individual UPS configuration * @param config Current configuration or individual UPS configuration
*/ */
private displayTestConfig(config: any): void { private displayTestConfig(config: IUpsConfig | INupstConfig): void {
// Check if this is a UPS device or full configuration // Type guard: IUpsConfig has 'id' and 'name' at root level, INupstConfig doesn't
const isUpsConfig = config.snmp; const isUpsConfig = 'id' in config && 'name' in config;
const snmpConfig = isUpsConfig ? config.snmp : config.snmp || {};
const checkInterval = config.checkInterval || 30000;
// Get UPS name and ID if available // Get SNMP config and other values based on config type
const upsName = config.name ? config.name : 'Default UPS'; const snmpConfig: ISnmpConfig | undefined = isUpsConfig
const upsId = config.id ? config.id : 'default'; ? (config as IUpsConfig).snmp
: (config as INupstConfig).snmp;
const checkInterval = isUpsConfig ? 30000 : (config as INupstConfig).checkInterval || 30000;
const upsName = isUpsConfig ? (config as IUpsConfig).name : 'Default UPS';
const upsId = isUpsConfig ? (config as IUpsConfig).id : 'default';
const boxWidth = 45; const boxWidth = 45;
logger.logBoxTitle(`Testing Configuration: ${upsName}`, boxWidth); logger.logBoxTitle(`Testing Configuration: ${upsName}`, boxWidth);
logger.logBoxLine(`UPS ID: ${upsId}`); logger.logBoxLine(`UPS ID: ${upsId}`);
if (!snmpConfig) {
logger.logBoxLine('SNMP Settings: Not configured');
logger.logBoxEnd();
return;
}
logger.logBoxLine('SNMP Settings:'); logger.logBoxLine('SNMP Settings:');
logger.logBoxLine(` Host: ${snmpConfig.host}`); logger.logBoxLine(` Host: ${snmpConfig.host}`);
logger.logBoxLine(` Port: ${snmpConfig.port}`); logger.logBoxLine(` Port: ${snmpConfig.port}`);
@@ -557,9 +523,10 @@ export class UpsHandler {
logger.logBoxLine(` Battery Runtime: ${snmpConfig.customOIDs.BATTERY_RUNTIME || 'Not set'}`); logger.logBoxLine(` Battery Runtime: ${snmpConfig.customOIDs.BATTERY_RUNTIME || 'Not set'}`);
} }
// Show group assignments if this is a UPS config // Show group assignments if this is a UPS config
if (config.groups && Array.isArray(config.groups)) { if (isUpsConfig) {
const groups = (config as IUpsConfig).groups;
logger.logBoxLine( logger.logBoxLine(
`Group Assignments: ${config.groups.length === 0 ? 'None' : config.groups.join(', ')}`, `Group Assignments: ${groups.length === 0 ? 'None' : groups.join(', ')}`,
); );
} }
@@ -571,16 +538,24 @@ export class UpsHandler {
* Test connection to the UPS * Test connection to the UPS
* @param config Current UPS configuration or legacy config * @param config Current UPS configuration or legacy config
*/ */
private async testConnection(config: any): Promise<void> { private async testConnection(config: IUpsConfig | INupstConfig): Promise<void> {
const upsId = config.id || 'default'; // Type guard: IUpsConfig has 'id' and 'name' at root level
const upsName = config.name || 'Default UPS'; const isUpsConfig = 'id' in config && 'name' in config;
const upsId = isUpsConfig ? (config as IUpsConfig).id : 'default';
const upsName = isUpsConfig ? (config as IUpsConfig).name : 'Default UPS';
logger.log(`\nTesting connection to UPS: ${upsName} (${upsId})...`); logger.log(`\nTesting connection to UPS: ${upsName} (${upsId})...`);
try { try {
// Create a test config with a short timeout // Get SNMP config based on config type
const snmpConfig = config.snmp ? config.snmp : config.snmp; const snmpConfig: ISnmpConfig | undefined = isUpsConfig
? (config as IUpsConfig).snmp
: (config as INupstConfig).snmp;
const testConfig = { if (!snmpConfig) {
throw new Error('SNMP configuration not found');
}
const testConfig: ISnmpConfig = {
...snmpConfig, ...snmpConfig,
timeout: Math.min(snmpConfig.timeout, 10000), // Use at most 10 seconds for testing timeout: Math.min(snmpConfig.timeout, 10000), // Use at most 10 seconds for testing
}; };
@@ -610,7 +585,7 @@ export class UpsHandler {
* @param status UPS status * @param status UPS status
* @param thresholds Threshold configuration * @param thresholds Threshold configuration
*/ */
private analyzeThresholds(status: any, thresholds: any): void { private analyzeThresholds(status: ISnmpUpsStatus, thresholds: IThresholds): void {
const boxWidth = 45; const boxWidth = 45;
logger.logBoxTitle('Threshold Analysis', boxWidth); logger.logBoxTitle('Threshold Analysis', boxWidth);
@@ -649,7 +624,7 @@ export class UpsHandler {
* @param prompt Function to prompt for user input * @param prompt Function to prompt for user input
*/ */
private async gatherSnmpSettings( private async gatherSnmpSettings(
snmpConfig: any, snmpConfig: Partial<ISnmpConfig>,
prompt: (question: string) => Promise<string>, prompt: (question: string) => Promise<string>,
): Promise<void> { ): Promise<void> {
// SNMP IP Address // SNMP IP Address
@@ -693,7 +668,7 @@ export class UpsHandler {
* @param prompt Function to prompt for user input * @param prompt Function to prompt for user input
*/ */
private async gatherSnmpV3Settings( private async gatherSnmpV3Settings(
snmpConfig: any, snmpConfig: Partial<ISnmpConfig>,
prompt: (question: string) => Promise<string>, prompt: (question: string) => Promise<string>,
): Promise<void> { ): Promise<void> {
logger.log(''); logger.log('');
@@ -718,17 +693,17 @@ export class UpsHandler {
if (secLevel === 1) { if (secLevel === 1) {
snmpConfig.securityLevel = 'noAuthNoPriv'; snmpConfig.securityLevel = 'noAuthNoPriv';
// No auth, no priv - clear out authentication and privacy settings // No auth, no priv - clear out authentication and privacy settings
snmpConfig.authProtocol = ''; snmpConfig.authProtocol = undefined;
snmpConfig.authKey = ''; snmpConfig.authKey = undefined;
snmpConfig.privProtocol = ''; snmpConfig.privProtocol = undefined;
snmpConfig.privKey = ''; snmpConfig.privKey = undefined;
// Set appropriate timeout for security level // Set appropriate timeout for security level
snmpConfig.timeout = 5000; // 5 seconds for basic security snmpConfig.timeout = 5000; // 5 seconds for basic security
} else if (secLevel === 2) { } else if (secLevel === 2) {
snmpConfig.securityLevel = 'authNoPriv'; snmpConfig.securityLevel = 'authNoPriv';
// Auth, no priv - clear out privacy settings // Auth, no priv - clear out privacy settings
snmpConfig.privProtocol = ''; snmpConfig.privProtocol = undefined;
snmpConfig.privKey = ''; snmpConfig.privKey = undefined;
// Set appropriate timeout for security level // Set appropriate timeout for security level
snmpConfig.timeout = 10000; // 10 seconds for authentication snmpConfig.timeout = 10000; // 10 seconds for authentication
} else { } else {
@@ -771,7 +746,7 @@ export class UpsHandler {
* @param prompt Function to prompt for user input * @param prompt Function to prompt for user input
*/ */
private async gatherAuthenticationSettings( private async gatherAuthenticationSettings(
snmpConfig: any, snmpConfig: Partial<ISnmpConfig>,
prompt: (question: string) => Promise<string>, prompt: (question: string) => Promise<string>,
): Promise<void> { ): Promise<void> {
// Authentication protocol // Authentication protocol
@@ -798,7 +773,7 @@ export class UpsHandler {
* @param prompt Function to prompt for user input * @param prompt Function to prompt for user input
*/ */
private async gatherPrivacySettings( private async gatherPrivacySettings(
snmpConfig: any, snmpConfig: Partial<ISnmpConfig>,
prompt: (question: string) => Promise<string>, prompt: (question: string) => Promise<string>,
): Promise<void> { ): Promise<void> {
// Privacy protocol // Privacy protocol
@@ -823,7 +798,7 @@ export class UpsHandler {
* @param prompt Function to prompt for user input * @param prompt Function to prompt for user input
*/ */
private async gatherUpsModelSettings( private async gatherUpsModelSettings(
snmpConfig: any, snmpConfig: Partial<ISnmpConfig>,
prompt: (question: string) => Promise<string>, prompt: (question: string) => Promise<string>,
): Promise<void> { ): Promise<void> {
logger.log(''); logger.log('');
@@ -868,16 +843,21 @@ export class UpsHandler {
logger.info('Enter custom OIDs for your UPS:'); logger.info('Enter custom OIDs for your UPS:');
logger.dim('(Leave blank to use standard RFC 1628 OIDs as fallback)'); logger.dim('(Leave blank to use standard RFC 1628 OIDs as fallback)');
// Custom OIDs // Custom OIDs - prompt for essential OIDs
const powerStatusOID = await prompt('Power Status OID: '); const powerStatusOID = await prompt('Power Status OID: ');
const batteryCapacityOID = await prompt('Battery Capacity OID: '); const batteryCapacityOID = await prompt('Battery Capacity OID: ');
const batteryRuntimeOID = await prompt('Battery Runtime OID: '); const batteryRuntimeOID = await prompt('Battery Runtime OID: ');
// Create custom OIDs object // Create custom OIDs object with all required fields
// Empty strings will use RFC 1628 fallback for non-essential OIDs
snmpConfig.customOIDs = { snmpConfig.customOIDs = {
POWER_STATUS: powerStatusOID.trim(), POWER_STATUS: powerStatusOID.trim(),
BATTERY_CAPACITY: batteryCapacityOID.trim(), BATTERY_CAPACITY: batteryCapacityOID.trim(),
BATTERY_RUNTIME: batteryRuntimeOID.trim(), BATTERY_RUNTIME: batteryRuntimeOID.trim(),
OUTPUT_LOAD: '',
OUTPUT_POWER: '',
OUTPUT_VOLTAGE: '',
OUTPUT_CURRENT: '',
}; };
} }
} }
@@ -888,7 +868,7 @@ export class UpsHandler {
* @param prompt Function to prompt for user input * @param prompt Function to prompt for user input
*/ */
private async gatherActionSettings( private async gatherActionSettings(
actions: any[], actions: IActionConfig[],
prompt: (question: string) => Promise<string>, prompt: (question: string) => Promise<string>,
): Promise<void> { ): Promise<void> {
logger.log(''); logger.log('');
@@ -915,7 +895,7 @@ export class UpsHandler {
const typeInput = await prompt('Select action type [1]: '); const typeInput = await prompt('Select action type [1]: ');
const typeValue = parseInt(typeInput, 10) || 1; const typeValue = parseInt(typeInput, 10) || 1;
const action: any = {}; const action: Partial<IActionConfig> = {};
if (typeValue === 1) { if (typeValue === 1) {
// Shutdown action // Shutdown action
@@ -1014,8 +994,8 @@ export class UpsHandler {
}; };
} }
actions.push(action); actions.push(action as IActionConfig);
logger.success(`${action.type.charAt(0).toUpperCase() + action.type.slice(1)} action added (mode: ${action.triggerMode || 'powerChangesAndThresholds'})`); 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): '); const more = await prompt('Add another action? (y/N): ');
addMore = more.toLowerCase() === 'y'; addMore = more.toLowerCase() === 'y';
@@ -1031,7 +1011,7 @@ export class UpsHandler {
* Display UPS configuration summary * Display UPS configuration summary
* @param ups UPS configuration * @param ups UPS configuration
*/ */
private displayUpsConfigSummary(ups: any): void { private displayUpsConfigSummary(ups: IUpsConfig): void {
const boxWidth = 45; const boxWidth = 45;
logger.log(''); logger.log('');
logger.logBoxTitle(`UPS Configuration: ${ups.name}`, boxWidth); logger.logBoxTitle(`UPS Configuration: ${ups.name}`, boxWidth);
@@ -1055,7 +1035,7 @@ export class UpsHandler {
* @param prompt Function to prompt for user input * @param prompt Function to prompt for user input
*/ */
private async optionallyTestConnection( private async optionallyTestConnection(
snmpConfig: any, snmpConfig: ISnmpConfig,
prompt: (question: string) => Promise<string>, prompt: (question: string) => Promise<string>,
): Promise<void> { ): Promise<void> {
const testConnection = await prompt( const testConnection = await prompt(

118
ts/constants.ts Normal file
View File

@@ -0,0 +1,118 @@
/**
* NUPST Constants
*
* Central location for all timeout, interval, and threshold values.
* This makes configuration easier and code more self-documenting.
*/
/**
* Default timing values in milliseconds
*/
export const TIMING = {
/** Default interval between UPS status checks (30 seconds) */
CHECK_INTERVAL_MS: 30000,
/** Interval for idle monitoring mode (60 seconds) */
IDLE_CHECK_INTERVAL_MS: 60000,
/** Interval for checking config file changes (60 seconds) */
CONFIG_CHECK_INTERVAL_MS: 60000,
/** Interval for logging periodic status updates (5 minutes) */
LOG_INTERVAL_MS: 5 * 60 * 1000,
/** Maximum time to monitor during shutdown (5 minutes) */
MAX_SHUTDOWN_MONITORING_MS: 5 * 60 * 1000,
/** Interval for UPS checks during shutdown (30 seconds) */
SHUTDOWN_CHECK_INTERVAL_MS: 30000,
} as const;
/**
* SNMP-related constants
*/
export const SNMP = {
/** Default SNMP port */
DEFAULT_PORT: 161,
/** Default SNMP timeout (5 seconds) */
DEFAULT_TIMEOUT_MS: 5000,
/** Number of SNMP retries */
RETRIES: 2,
/** Timeout for noAuthNoPriv security level (5 seconds) */
TIMEOUT_NO_AUTH_MS: 5000,
/** Timeout for authNoPriv security level (10 seconds) */
TIMEOUT_AUTH_MS: 10000,
/** Timeout for authPriv security level (15 seconds) */
TIMEOUT_AUTH_PRIV_MS: 15000,
/** Maximum timeout for connection tests (10 seconds) */
MAX_TEST_TIMEOUT_MS: 10000,
} as const;
/**
* Default threshold values
*/
export const THRESHOLDS = {
/** Default battery capacity threshold for shutdown (60%) */
DEFAULT_BATTERY_PERCENT: 60,
/** Default runtime threshold for shutdown (20 minutes) */
DEFAULT_RUNTIME_MINUTES: 20,
/** Emergency runtime threshold during shutdown (5 minutes) */
EMERGENCY_RUNTIME_MINUTES: 5,
} as const;
/**
* Webhook action constants
*/
export const WEBHOOK = {
/** Default webhook request timeout (10 seconds) */
DEFAULT_TIMEOUT_MS: 10000,
} as const;
/**
* Script action constants
*/
export const SCRIPT = {
/** Default script execution timeout (60 seconds) */
DEFAULT_TIMEOUT_MS: 60000,
} as const;
/**
* Shutdown action constants
*/
export const SHUTDOWN = {
/** Default shutdown delay (5 minutes) */
DEFAULT_DELAY_MINUTES: 5,
} as const;
/**
* HTTP Server constants
*/
export const HTTP_SERVER = {
/** Default HTTP server port */
DEFAULT_PORT: 8080,
/** Default URL path for UPS status endpoint */
DEFAULT_PATH: '/ups-status',
} as const;
/**
* UI/Display constants
*/
export const UI = {
/** Default width for log boxes */
DEFAULT_BOX_WIDTH: 45,
/** Wide box width for status displays */
WIDE_BOX_WIDTH: 60,
/** Extra wide box width for detailed info */
EXTRA_WIDE_BOX_WIDTH: 70,
} as const;

View File

@@ -4,12 +4,14 @@ import * as path from 'node:path';
import { exec, execFile } from 'node:child_process'; import { exec, execFile } from 'node:child_process';
import { promisify } from 'node:util'; import { promisify } from 'node:util';
import { NupstSnmp } from './snmp/manager.ts'; import { NupstSnmp } from './snmp/manager.ts';
import type { ISnmpConfig } from './snmp/types.ts'; import type { ISnmpConfig, IUpsStatus as ISnmpUpsStatus } from './snmp/types.ts';
import { logger, type ITableColumn } from './logger.ts'; import { logger, type ITableColumn } from './logger.ts';
import { MigrationRunner } from './migrations/index.ts'; import { MigrationRunner } from './migrations/index.ts';
import { theme, symbols, getBatteryColor, getRuntimeColor, formatPowerStatus } from './colors.ts'; import { theme, symbols, getBatteryColor, getRuntimeColor, formatPowerStatus } from './colors.ts';
import type { IActionConfig } from './actions/base-action.ts'; import type { IActionConfig } from './actions/base-action.ts';
import { ActionManager, type IActionContext, type TPowerStatus } from './actions/index.ts'; import { ActionManager, type IActionContext, type TPowerStatus } from './actions/index.ts';
import { NupstHttpServer } from './http-server.ts';
import { TIMING, THRESHOLDS, UI } from './constants.ts';
const execAsync = promisify(exec); const execAsync = promisify(exec);
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
@@ -46,6 +48,20 @@ export interface IGroupConfig {
actions?: IActionConfig[]; 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;
}
/** /**
* Configuration interface for the daemon * Configuration interface for the daemon
*/ */
@@ -58,6 +74,8 @@ export interface INupstConfig {
groups: IGroupConfig[]; groups: IGroupConfig[];
/** Check interval in milliseconds */ /** Check interval in milliseconds */
checkInterval: number; checkInterval: number;
/** HTTP Server configuration */
httpServer?: IHttpServerConfig;
// Legacy fields for backward compatibility (will be migrated away) // Legacy fields for backward compatibility (will be migrated away)
/** UPS list (v3 format - legacy) */ /** UPS list (v3 format - legacy) */
@@ -76,12 +94,16 @@ export interface INupstConfig {
/** /**
* UPS status tracking interface * UPS status tracking interface
*/ */
interface IUpsStatus { export interface IUpsStatus {
id: string; id: string;
name: string; name: string;
powerStatus: 'online' | 'onBattery' | 'unknown'; powerStatus: 'online' | 'onBattery' | 'unknown';
batteryCapacity: number; batteryCapacity: number;
batteryRuntime: 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; lastStatusChange: number;
lastCheckTime: number; lastCheckTime: number;
} }
@@ -96,7 +118,7 @@ export class NupstDaemon {
/** Default configuration */ /** Default configuration */
private readonly DEFAULT_CONFIG: INupstConfig = { private readonly DEFAULT_CONFIG: INupstConfig = {
version: '4.1', version: '4.2',
upsDevices: [ upsDevices: [
{ {
id: 'default', id: 'default',
@@ -123,8 +145,8 @@ export class NupstDaemon {
type: 'shutdown', type: 'shutdown',
triggerMode: 'onlyThresholds', triggerMode: 'onlyThresholds',
thresholds: { thresholds: {
battery: 60, // Shutdown when battery below 60% battery: THRESHOLDS.DEFAULT_BATTERY_PERCENT, // Shutdown when battery below 60%
runtime: 20, // Shutdown when runtime below 20 minutes runtime: THRESHOLDS.DEFAULT_RUNTIME_MINUTES, // Shutdown when runtime below 20 minutes
}, },
shutdownDelay: 5, shutdownDelay: 5,
}, },
@@ -132,13 +154,14 @@ export class NupstDaemon {
}, },
], ],
groups: [], groups: [],
checkInterval: 30000, // Check every 30 seconds checkInterval: TIMING.CHECK_INTERVAL_MS, // Check every 30 seconds
} }
private config: INupstConfig; private config: INupstConfig;
private snmp: NupstSnmp; private snmp: NupstSnmp;
private isRunning: boolean = false; private isRunning: boolean = false;
private upsStatus: Map<string, IUpsStatus> = new Map(); private upsStatus: Map<string, IUpsStatus> = new Map();
private httpServer?: NupstHttpServer;
/** /**
* Create a new daemon instance with the given SNMP manager * Create a new daemon instance with the given SNMP manager
@@ -171,11 +194,13 @@ export class NupstDaemon {
const { config: migratedConfig, migrated } = await migrationRunner.run(parsedConfig); const { config: migratedConfig, migrated } = await migrationRunner.run(parsedConfig);
// Save migrated config back to disk if any migrations ran // Save migrated 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) { if (migrated) {
this.config = migratedConfig; this.config = validConfig;
await this.saveConfig(this.config); await this.saveConfig(this.config);
} else { } else {
this.config = migratedConfig; this.config = validConfig;
} }
return this.config; return this.config;
@@ -258,12 +283,14 @@ export class NupstDaemon {
this.logConfigLoaded(); this.logConfigLoaded();
// Log version information // Log version information
this.snmp.getNupst().logVersionInfo(false); // Don't check for updates immediately on startup const nupst = this.snmp.getNupst();
if (nupst) {
nupst.logVersionInfo(false); // Don't check for updates immediately on startup
// Check for updates in the background // Check for updates in the background
this.snmp.getNupst().checkForUpdates().then((updateAvailable: boolean) => { nupst.checkForUpdates().then((updateAvailable: boolean) => {
if (updateAvailable) { if (updateAvailable) {
const updateStatus = this.snmp.getNupst().getUpdateStatus(); const updateStatus = nupst.getUpdateStatus();
const boxWidth = 45; const boxWidth = 45;
logger.logBoxTitle('Update Available', boxWidth); logger.logBoxTitle('Update Available', boxWidth);
logger.logBoxLine(`Current Version: ${updateStatus.currentVersion}`); logger.logBoxLine(`Current Version: ${updateStatus.currentVersion}`);
@@ -272,10 +299,26 @@ export class NupstDaemon {
logger.logBoxEnd(); logger.logBoxEnd();
} }
}).catch(() => {}); // Ignore errors checking for updates }).catch(() => {}); // Ignore errors checking for updates
}
// Initialize UPS status tracking // Initialize UPS status tracking
this.initializeUpsStatus(); 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 // Start UPS monitoring
this.isRunning = true; this.isRunning = true;
await this.monitor(); await this.monitor();
@@ -302,6 +345,10 @@ export class NupstDaemon {
powerStatus: 'unknown', powerStatus: 'unknown',
batteryCapacity: 100, batteryCapacity: 100,
batteryRuntime: 999, // High value as default batteryRuntime: 999, // High value as default
outputLoad: 0,
outputPower: 0,
outputVoltage: 0,
outputCurrent: 0,
lastStatusChange: Date.now(), lastStatusChange: Date.now(),
lastCheckTime: 0, lastCheckTime: 0,
}); });
@@ -375,6 +422,12 @@ export class NupstDaemon {
*/ */
public stop(): void { public stop(): void {
logger.log('Stopping NUPST daemon...'); logger.log('Stopping NUPST daemon...');
// Stop HTTP server if running
if (this.httpServer) {
this.httpServer.stop();
}
this.isRunning = false; this.isRunning = false;
} }
@@ -392,7 +445,6 @@ export class NupstDaemon {
} }
let lastLogTime = 0; // Track when we last logged status let lastLogTime = 0; // Track when we last logged status
const LOG_INTERVAL = 5 * 60 * 1000; // Log at least every 5 minutes (300000ms)
// Monitor continuously // Monitor continuously
while (this.isRunning) { while (this.isRunning) {
@@ -402,7 +454,7 @@ export class NupstDaemon {
// Log periodic status update // Log periodic status update
const currentTime = Date.now(); const currentTime = Date.now();
if (currentTime - lastLogTime >= LOG_INTERVAL) { if (currentTime - lastLogTime >= TIMING.LOG_INTERVAL_MS) {
this.logAllUpsStatus(); this.logAllUpsStatus();
lastLogTime = currentTime; lastLogTime = currentTime;
} }
@@ -435,6 +487,10 @@ export class NupstDaemon {
powerStatus: 'unknown', powerStatus: 'unknown',
batteryCapacity: 100, batteryCapacity: 100,
batteryRuntime: 999, batteryRuntime: 999,
outputLoad: 0,
outputPower: 0,
outputVoltage: 0,
outputCurrent: 0,
lastStatusChange: Date.now(), lastStatusChange: Date.now(),
lastCheckTime: 0, lastCheckTime: 0,
}); });
@@ -454,6 +510,10 @@ export class NupstDaemon {
powerStatus: status.powerStatus, powerStatus: status.powerStatus,
batteryCapacity: status.batteryCapacity, batteryCapacity: status.batteryCapacity,
batteryRuntime: status.batteryRuntime, batteryRuntime: status.batteryRuntime,
outputLoad: status.outputLoad,
outputPower: status.outputPower,
outputVoltage: status.outputVoltage,
outputCurrent: status.outputCurrent,
lastCheckTime: currentTime, lastCheckTime: currentTime,
lastStatusChange: currentStatus?.lastStatusChange || currentTime, lastStatusChange: currentStatus?.lastStatusChange || currentTime,
}; };
@@ -732,21 +792,18 @@ export class NupstDaemon {
* Force immediate shutdown if any UPS gets critically low * Force immediate shutdown if any UPS gets critically low
*/ */
private async monitorDuringShutdown(): Promise<void> { private async monitorDuringShutdown(): Promise<void> {
const EMERGENCY_RUNTIME_THRESHOLD = 5; // 5 minutes remaining is critical
const CHECK_INTERVAL = 30000; // Check every 30 seconds during shutdown
const MAX_MONITORING_TIME = 5 * 60 * 1000; // Max 5 minutes of monitoring
const startTime = Date.now(); const startTime = Date.now();
logger.log(''); logger.log('');
logger.logBoxTitle('Shutdown Monitoring Active', 60, 'warning'); logger.logBoxTitle('Shutdown Monitoring Active', UI.WIDE_BOX_WIDTH, 'warning');
logger.logBoxLine(`Emergency threshold: ${EMERGENCY_RUNTIME_THRESHOLD} minutes runtime`); logger.logBoxLine(`Emergency threshold: ${THRESHOLDS.EMERGENCY_RUNTIME_MINUTES} minutes runtime`);
logger.logBoxLine(`Check interval: ${CHECK_INTERVAL / 1000} seconds`); logger.logBoxLine(`Check interval: ${TIMING.SHUTDOWN_CHECK_INTERVAL_MS / 1000} seconds`);
logger.logBoxLine(`Max monitoring time: ${MAX_MONITORING_TIME / 1000} seconds`); logger.logBoxLine(`Max monitoring time: ${TIMING.MAX_SHUTDOWN_MONITORING_MS / 1000} seconds`);
logger.logBoxEnd(); logger.logBoxEnd();
logger.log(''); logger.log('');
// Continue monitoring until max monitoring time is reached // Continue monitoring until max monitoring time is reached
while (Date.now() - startTime < MAX_MONITORING_TIME) { while (Date.now() - startTime < TIMING.MAX_SHUTDOWN_MONITORING_MS) {
try { try {
logger.info('Checking UPS status during shutdown...'); logger.info('Checking UPS status during shutdown...');
@@ -760,7 +817,7 @@ export class NupstDaemon {
const rows: Array<Record<string, string>> = []; const rows: Array<Record<string, string>> = [];
let emergencyDetected = false; let emergencyDetected = false;
let emergencyUps: any = null; let emergencyUps: { ups: IUpsConfig; status: ISnmpUpsStatus } | null = null;
// Check all UPS devices // Check all UPS devices
for (const ups of this.config.upsDevices) { for (const ups of this.config.upsDevices) {
@@ -770,7 +827,7 @@ export class NupstDaemon {
const batteryColor = getBatteryColor(status.batteryCapacity); const batteryColor = getBatteryColor(status.batteryCapacity);
const runtimeColor = getRuntimeColor(status.batteryRuntime); const runtimeColor = getRuntimeColor(status.batteryRuntime);
const isCritical = status.batteryRuntime < EMERGENCY_RUNTIME_THRESHOLD; const isCritical = status.batteryRuntime < THRESHOLDS.EMERGENCY_RUNTIME_MINUTES;
rows.push({ rows.push({
name: ups.name, name: ups.name,
@@ -811,7 +868,7 @@ export class NupstDaemon {
logger.logBoxLine( logger.logBoxLine(
`UPS ${emergencyUps.ups.name} runtime critically low: ${emergencyUps.status.batteryRuntime} minutes`, `UPS ${emergencyUps.ups.name} runtime critically low: ${emergencyUps.status.batteryRuntime} minutes`,
); );
logger.logBoxLine(`Emergency threshold: ${EMERGENCY_RUNTIME_THRESHOLD} minutes`); logger.logBoxLine(`Emergency threshold: ${THRESHOLDS.EMERGENCY_RUNTIME_MINUTES} minutes`);
logger.logBoxLine('Forcing immediate shutdown!'); logger.logBoxLine('Forcing immediate shutdown!');
logger.logBoxEnd(); logger.logBoxEnd();
logger.log(''); logger.log('');
@@ -822,14 +879,14 @@ export class NupstDaemon {
} }
// Wait before checking again // Wait before checking again
await this.sleep(CHECK_INTERVAL); await this.sleep(TIMING.SHUTDOWN_CHECK_INTERVAL_MS);
} catch (error) { } catch (error) {
logger.error( logger.error(
`Error monitoring UPS during shutdown: ${ `Error monitoring UPS during shutdown: ${
error instanceof Error ? error.message : String(error) error instanceof Error ? error.message : String(error)
}`, }`,
); );
await this.sleep(CHECK_INTERVAL); await this.sleep(TIMING.SHUTDOWN_CHECK_INTERVAL_MS);
} }
} }
@@ -931,12 +988,10 @@ export class NupstDaemon {
* Watches for config changes and reloads when detected * Watches for config changes and reloads when detected
*/ */
private async idleMonitoring(): Promise<void> { private async idleMonitoring(): Promise<void> {
const IDLE_CHECK_INTERVAL = 60000; // Check every 60 seconds
let lastConfigCheck = Date.now(); let lastConfigCheck = Date.now();
const CONFIG_CHECK_INTERVAL = 60000; // Check config every minute
logger.log('Entering idle monitoring mode...'); logger.log('Entering idle monitoring mode...');
logger.log('Daemon will check for config changes every 60 seconds'); logger.log(`Daemon will check for config changes every ${TIMING.IDLE_CHECK_INTERVAL_MS / 1000} seconds`);
// Start file watcher for hot-reload // Start file watcher for hot-reload
this.watchConfigFile(); this.watchConfigFile();
@@ -946,7 +1001,7 @@ export class NupstDaemon {
const currentTime = Date.now(); const currentTime = Date.now();
// Periodically check if config has been updated // Periodically check if config has been updated
if (currentTime - lastConfigCheck >= CONFIG_CHECK_INTERVAL) { if (currentTime - lastConfigCheck >= TIMING.CONFIG_CHECK_INTERVAL_MS) {
try { try {
// Try to load config // Try to load config
const newConfig = await this.loadConfig(); const newConfig = await this.loadConfig();
@@ -966,12 +1021,12 @@ export class NupstDaemon {
lastConfigCheck = currentTime; lastConfigCheck = currentTime;
} }
await this.sleep(IDLE_CHECK_INTERVAL); await this.sleep(TIMING.IDLE_CHECK_INTERVAL_MS);
} catch (error) { } catch (error) {
logger.error( logger.error(
`Error during idle monitoring: ${error instanceof Error ? error.message : String(error)}`, `Error during idle monitoring: ${error instanceof Error ? error.message : String(error)}`,
); );
await this.sleep(IDLE_CHECK_INTERVAL); await this.sleep(TIMING.IDLE_CHECK_INTERVAL_MS);
} }
} }

View File

@@ -1 +1,2 @@
export * from './shortid.ts'; export * from './shortid.ts';
export * from './prompt.ts';

55
ts/helpers/prompt.ts Normal file
View File

@@ -0,0 +1,55 @@
import process from 'node:process';
/**
* Result from creating a prompt interface
*/
export interface IPromptInterface {
/** Function to prompt for user input */
prompt: (question: string) => Promise<string>;
/** Function to close the prompt interface */
close: () => void;
}
/**
* Create a readline prompt interface for interactive CLI input
* @returns Promise resolving to prompt function and close function
*/
export async function createPrompt(): Promise<IPromptInterface> {
const readline = await import('node:readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const prompt = (question: string): Promise<string> => {
return new Promise((resolve) => {
rl.question(question, (answer: string) => {
resolve(answer);
});
});
};
const close = (): void => {
rl.close();
process.stdin.destroy();
};
return { prompt, close };
}
/**
* Run an async function with a prompt interface, ensuring cleanup
* @param fn Function to run with the prompt interface
* @returns Promise resolving to the function's return value
*/
export async function withPrompt<T>(
fn: (prompt: (question: string) => Promise<string>) => Promise<T>,
): Promise<T> {
const { prompt, close } = await createPrompt();
try {
return await fn(prompt);
} finally {
close();
}
}

113
ts/http-server.ts Normal file
View 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');
});
}
}
}

1
ts/interfaces/index.ts Normal file
View File

@@ -0,0 +1 @@
export * from './nupst-accessor.ts';

View File

@@ -0,0 +1,41 @@
import type { NupstDaemon } from '../daemon.ts';
/**
* Update status information
*/
export interface IUpdateStatus {
currentVersion: string;
latestVersion: string;
updateAvailable: boolean;
}
/**
* Interface for accessing Nupst functionality from SNMP manager
* This breaks the circular dependency between Nupst and NupstSnmp
*/
export interface INupstAccessor {
/**
* Get the daemon manager for background monitoring
*/
getDaemon(): NupstDaemon;
/**
* Get the current version of NUPST
*/
getVersion(): string;
/**
* Check if an update is available
*/
checkForUpdates(): Promise<boolean>;
/**
* Get update status information
*/
getUpdateStatus(): IUpdateStatus;
/**
* Log the current version and update status
*/
logVersionInfo(checkForUpdates?: boolean): void;
}

View File

@@ -28,18 +28,18 @@ export abstract class BaseMigration {
/** /**
* Check if this migration should run on the given config * 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 * @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 * 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 * @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 * Get human-readable name for this migration

View File

@@ -32,7 +32,9 @@ export class MigrationRunner {
* @param config - Raw configuration object to migrate * @param config - Raw configuration object to migrate
* @returns Migrated configuration and whether migrations ran * @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 currentConfig = config;
let anyMigrationsRan = false; let anyMigrationsRan = false;

View File

@@ -49,15 +49,15 @@ export class MigrationV4_0ToV4_1 extends BaseMigration {
readonly fromVersion = '4.0'; readonly fromVersion = '4.0';
readonly toVersion = '4.1'; readonly toVersion = '4.1';
async shouldRun(config: any): Promise<boolean> { async shouldRun(config: Record<string, unknown>): Promise<boolean> {
// Run if config is version 4.0 // Run if config is version 4.0
if (config.version === '4.0') { if (config.version === '4.0') {
return true; return true;
} }
// Also run if config has upsDevices with thresholds at UPS level (v4.0 format) // Also run if config has upsDevices with thresholds at UPS level (v4.0 format)
if (config.upsDevices && config.upsDevices.length > 0) { if (Array.isArray(config.upsDevices) && config.upsDevices.length > 0) {
const firstDevice = config.upsDevices[0]; const firstDevice = config.upsDevices[0] as Record<string, unknown>;
// v4.0 has thresholds at UPS level, v4.1 has them in actions // v4.0 has thresholds at UPS level, v4.1 has them in actions
return firstDevice.thresholds !== undefined; return firstDevice.thresholds !== undefined;
} }
@@ -65,14 +65,15 @@ export class MigrationV4_0ToV4_1 extends BaseMigration {
return false; return false;
} }
async migrate(config: any): Promise<any> { async migrate(config: Record<string, unknown>): Promise<Record<string, unknown>> {
logger.info(`${this.getName()}: Migrating v4.0 config to v4.1 format...`); logger.info(`${this.getName()}: Migrating v4.0 config to v4.1 format...`);
logger.dim(` - Moving thresholds from UPS level to action level`); logger.dim(` - Moving thresholds from UPS level to action level`);
logger.dim(` - Creating default shutdown actions from existing thresholds`); logger.dim(` - Creating default shutdown actions from existing thresholds`);
// Migrate UPS devices // Migrate UPS devices
const migratedDevices = (config.upsDevices || []).map((device: any) => { const devices = (config.upsDevices as Array<Record<string, unknown>>) || [];
const migrated: any = { const migratedDevices = devices.map((device) => {
const migrated: Record<string, unknown> = {
id: device.id, id: device.id,
name: device.name, name: device.name,
snmp: device.snmp, snmp: device.snmp,
@@ -80,20 +81,21 @@ export class MigrationV4_0ToV4_1 extends BaseMigration {
}; };
// If device has thresholds at UPS level, convert to shutdown action // If device has thresholds at UPS level, convert to shutdown action
if (device.thresholds) { const deviceThresholds = device.thresholds as { battery: number; runtime: number } | undefined;
if (deviceThresholds) {
migrated.actions = [ migrated.actions = [
{ {
type: 'shutdown', type: 'shutdown',
thresholds: { thresholds: {
battery: device.thresholds.battery, battery: deviceThresholds.battery,
runtime: device.thresholds.runtime, runtime: deviceThresholds.runtime,
}, },
triggerMode: 'onlyThresholds', // Preserve old behavior (only on threshold violation) triggerMode: 'onlyThresholds', // Preserve old behavior (only on threshold violation)
shutdownDelay: 5, // Default delay shutdownDelay: 5, // Default delay
}, },
]; ];
logger.dim( logger.dim(
`${device.name}: Created shutdown action (battery: ${device.thresholds.battery}%, runtime: ${device.thresholds.runtime}min)`, `${device.name}: Created shutdown action (battery: ${deviceThresholds.battery}%, runtime: ${deviceThresholds.runtime}min)`,
); );
} else { } else {
// No thresholds, just add empty actions array // No thresholds, just add empty actions array
@@ -104,7 +106,8 @@ export class MigrationV4_0ToV4_1 extends BaseMigration {
}); });
// Add actions to groups // Add actions to groups
const migratedGroups = (config.groups || []).map((group: any) => ({ const groups = (config.groups as Array<Record<string, unknown>>) || [];
const migratedGroups = groups.map((group) => ({
...group, ...group,
actions: group.actions || [], actions: group.actions || [],
})); }));

View File

@@ -1,24 +1,29 @@
import { NupstSnmp } from './snmp/manager.ts'; import { NupstSnmp } from './snmp/manager.ts';
import { NupstDaemon } from './daemon.ts'; import { NupstDaemon } from './daemon.ts';
import { NupstSystemd } from './systemd.ts'; import { NupstSystemd } from './systemd.ts';
import { commitinfo } from './00_commitinfo_data.ts'; import denoConfig from '../deno.json' with { type: 'json' };
import { logger } from './logger.ts'; import { logger } from './logger.ts';
import { UpsHandler } from './cli/ups-handler.ts'; import { UpsHandler } from './cli/ups-handler.ts';
import { GroupHandler } from './cli/group-handler.ts'; import { GroupHandler } from './cli/group-handler.ts';
import { ServiceHandler } from './cli/service-handler.ts'; import { ServiceHandler } from './cli/service-handler.ts';
import { ActionHandler } from './cli/action-handler.ts';
import { FeatureHandler } from './cli/feature-handler.ts';
import * as https from 'node:https'; import * as https from 'node:https';
import type { INupstAccessor, IUpdateStatus } from './interfaces/index.ts';
/** /**
* Main Nupst class that coordinates all components * Main Nupst class that coordinates all components
* Acts as a facade to access SNMP, Daemon, and Systemd functionality * Acts as a facade to access SNMP, Daemon, and Systemd functionality
*/ */
export class Nupst { export class Nupst implements INupstAccessor {
private readonly snmp: NupstSnmp; private readonly snmp: NupstSnmp;
private readonly daemon: NupstDaemon; private readonly daemon: NupstDaemon;
private readonly systemd: NupstSystemd; private readonly systemd: NupstSystemd;
private readonly upsHandler: UpsHandler; private readonly upsHandler: UpsHandler;
private readonly groupHandler: GroupHandler; private readonly groupHandler: GroupHandler;
private readonly serviceHandler: ServiceHandler; private readonly serviceHandler: ServiceHandler;
private readonly actionHandler: ActionHandler;
private readonly featureHandler: FeatureHandler;
private updateAvailable: boolean = false; private updateAvailable: boolean = false;
private latestVersion: string = ''; private latestVersion: string = '';
@@ -36,6 +41,8 @@ export class Nupst {
this.upsHandler = new UpsHandler(this); this.upsHandler = new UpsHandler(this);
this.groupHandler = new GroupHandler(this); this.groupHandler = new GroupHandler(this);
this.serviceHandler = new ServiceHandler(this); this.serviceHandler = new ServiceHandler(this);
this.actionHandler = new ActionHandler(this);
this.featureHandler = new FeatureHandler(this);
} }
/** /**
@@ -80,12 +87,26 @@ export class Nupst {
return this.serviceHandler; return this.serviceHandler;
} }
/**
* Get the Action handler for action management
*/
public getActionHandler(): ActionHandler {
return this.actionHandler;
}
/**
* Get the Feature handler for feature management
*/
public getFeatureHandler(): FeatureHandler {
return this.featureHandler;
}
/** /**
* Get the current version of NUPST * Get the current version of NUPST
* @returns The current version string * @returns The current version string
*/ */
public getVersion(): string { public getVersion(): string {
return commitinfo.version; return denoConfig.version;
} }
/** /**
@@ -114,11 +135,7 @@ export class Nupst {
* Get update status information * Get update status information
* @returns Object with update status information * @returns Object with update status information
*/ */
public getUpdateStatus(): { public getUpdateStatus(): IUpdateStatus {
currentVersion: string;
latestVersion: string;
updateAvailable: boolean;
} {
return { return {
currentVersion: this.getVersion(), currentVersion: this.getVersion(),
latestVersion: this.latestVersion || this.getVersion(), latestVersion: this.latestVersion || this.getVersion(),
@@ -133,8 +150,8 @@ export class Nupst {
private getLatestVersion(): Promise<string> { private getLatestVersion(): Promise<string> {
return new Promise<string>((resolve, reject) => { return new Promise<string>((resolve, reject) => {
const options = { const options = {
hostname: 'registry.npmjs.org', hostname: 'code.foss.global',
path: '/@serve.zone/nupst', path: '/api/v1/repos/serve.zone/nupst/releases/latest',
method: 'GET', method: 'GET',
headers: { headers: {
'Accept': 'application/json', 'Accept': 'application/json',
@@ -152,10 +169,14 @@ export class Nupst {
res.on('end', () => { res.on('end', () => {
try { try {
const response = JSON.parse(data); const response = JSON.parse(data);
if (response['dist-tags'] && response['dist-tags'].latest) { if (response.tag_name) {
resolve(response['dist-tags'].latest); // Strip 'v' prefix from tag name (e.g., "v5.1.7" -> "5.1.7")
const version = response.tag_name.startsWith('v')
? response.tag_name.substring(1)
: response.tag_name;
resolve(version);
} else { } else {
reject(new Error('Failed to parse version from npm registry response')); reject(new Error('Failed to parse version from Gitea API response'));
} }
} catch (error) { } catch (error) {
reject(error); reject(error);

View File

@@ -1,7 +1,10 @@
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 { Buffer } from 'node:buffer';
import type { IOidSet, ISnmpConfig, IUpsStatus, TUpsModel } from './types.ts'; import type { IOidSet, ISnmpConfig, IUpsStatus, TUpsModel } from './types.ts';
import { UpsOidSets } from './oid-sets.ts'; import { UpsOidSets } from './oid-sets.ts';
import { SNMP } from '../constants.ts';
import { logger } from '../logger.ts';
import type { INupstAccessor } from '../interfaces/index.ts';
/** /**
* Class for SNMP communication with UPS devices * Class for SNMP communication with UPS devices
@@ -10,18 +13,18 @@ import { UpsOidSets } from './oid-sets.ts';
export class NupstSnmp { export class NupstSnmp {
// Active OID set // Active OID set
private activeOIDs: IOidSet; private activeOIDs: IOidSet;
// Reference to the parent Nupst instance // Reference to the parent Nupst instance (uses interface to avoid circular dependency)
private nupst: any; // Type 'any' to avoid circular dependency private nupst: INupstAccessor | null = null;
// Debug mode flag // Debug mode flag
private debug: boolean = false; private debug: boolean = false;
// Default SNMP configuration // Default SNMP configuration
private readonly DEFAULT_CONFIG: ISnmpConfig = { private readonly DEFAULT_CONFIG: ISnmpConfig = {
host: '127.0.0.1', // Default to localhost host: '127.0.0.1', // Default to localhost
port: 161, // Default SNMP port port: SNMP.DEFAULT_PORT, // Default SNMP port
community: 'public', // Default community string for v1/v2c community: 'public', // Default community string for v1/v2c
version: 1, // SNMPv1 version: 1, // SNMPv1
timeout: 5000, // 5 seconds timeout timeout: SNMP.DEFAULT_TIMEOUT_MS, // 5 seconds timeout
upsModel: 'cyberpower', // Default UPS model upsModel: 'cyberpower', // Default UPS model
}; };
@@ -39,14 +42,14 @@ export class NupstSnmp {
* Set reference to the main Nupst instance * Set reference to the main Nupst instance
* @param nupst Reference to the main Nupst instance * @param nupst Reference to the main Nupst instance
*/ */
public setNupst(nupst: any): void { public setNupst(nupst: INupstAccessor): void {
this.nupst = nupst; this.nupst = nupst;
} }
/** /**
* Get reference to the main Nupst instance * Get reference to the main Nupst instance
*/ */
public getNupst(): any { public getNupst(): INupstAccessor | null {
return this.nupst; return this.nupst;
} }
@@ -55,7 +58,7 @@ export class NupstSnmp {
*/ */
public enableDebug(): void { public enableDebug(): void {
this.debug = true; this.debug = true;
console.log('SNMP debug mode enabled - detailed logs will be shown'); logger.info('SNMP debug mode enabled - detailed logs will be shown');
} }
/** /**
@@ -67,7 +70,7 @@ export class NupstSnmp {
if (config.upsModel === 'custom' && config.customOIDs) { if (config.upsModel === 'custom' && config.customOIDs) {
this.activeOIDs = config.customOIDs; this.activeOIDs = config.customOIDs;
if (this.debug) { if (this.debug) {
console.log('Using custom OIDs:', this.activeOIDs); logger.dim(`Using custom OIDs: ${JSON.stringify(this.activeOIDs)}`);
} }
return; return;
} }
@@ -77,7 +80,7 @@ export class NupstSnmp {
this.activeOIDs = UpsOidSets.getOidSet(model); this.activeOIDs = UpsOidSets.getOidSet(model);
if (this.debug) { if (this.debug) {
console.log(`Using OIDs for UPS model: ${model}`); logger.dim(`Using OIDs for UPS model: ${model}`);
} }
} }
@@ -95,16 +98,16 @@ export class NupstSnmp {
): Promise<any> { ): Promise<any> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (this.debug) { if (this.debug) {
console.log( logger.dim(
`Sending SNMP v${config.version} GET request for OID ${oid} to ${config.host}:${config.port}`, `Sending SNMP v${config.version} GET request for OID ${oid} to ${config.host}:${config.port}`,
); );
console.log('Using community:', config.community); logger.dim(`Using community: ${config.community}`);
} }
// Create SNMP options based on configuration // Create SNMP options based on configuration
const options: any = { const options: any = {
port: config.port, port: config.port,
retries: 2, // Number of retries retries: SNMP.RETRIES, // Number of retries
timeout: config.timeout, timeout: config.timeout,
transport: 'udp4', transport: 'udp4',
idBitsSize: 32, idBitsSize: 32,
@@ -151,7 +154,7 @@ export class NupstSnmp {
// Fallback to noAuthNoPriv if auth details missing // Fallback to noAuthNoPriv if auth details missing
user.level = snmp.SecurityLevel.noAuthNoPriv; user.level = snmp.SecurityLevel.noAuthNoPriv;
if (this.debug) { if (this.debug) {
console.log('Warning: Missing authProtocol or authKey, falling back to noAuthNoPriv'); logger.warn('Missing authProtocol or authKey, falling back to noAuthNoPriv');
} }
} }
} else if (securityLevel === 'authPriv') { } else if (securityLevel === 'authPriv') {
@@ -178,29 +181,23 @@ export class NupstSnmp {
// Fallback to authNoPriv if priv details missing // Fallback to authNoPriv if priv details missing
user.level = snmp.SecurityLevel.authNoPriv; user.level = snmp.SecurityLevel.authNoPriv;
if (this.debug) { if (this.debug) {
console.log('Warning: Missing privProtocol or privKey, falling back to authNoPriv'); logger.warn('Missing privProtocol or privKey, falling back to authNoPriv');
} }
} }
} else { } else {
// Fallback to noAuthNoPriv if auth details missing // Fallback to noAuthNoPriv if auth details missing
user.level = snmp.SecurityLevel.noAuthNoPriv; user.level = snmp.SecurityLevel.noAuthNoPriv;
if (this.debug) { if (this.debug) {
console.log('Warning: Missing authProtocol or authKey, falling back to noAuthNoPriv'); logger.warn('Missing authProtocol or authKey, falling back to noAuthNoPriv');
} }
} }
} }
if (this.debug) { if (this.debug) {
console.log('SNMPv3 user configuration:', { const levelName = Object.keys(snmp.SecurityLevel).find((key) =>
name: user.name,
level: Object.keys(snmp.SecurityLevel).find((key) =>
snmp.SecurityLevel[key] === user.level snmp.SecurityLevel[key] === user.level
), );
authProtocol: user.authProtocol ? 'Set' : 'Not Set', logger.dim(`SNMPv3 user configuration: name=${user.name}, level=${levelName}, authProtocol=${user.authProtocol ? 'Set' : 'Not Set'}, privProtocol=${user.privProtocol ? 'Set' : 'Not Set'}`);
authKey: user.authKey ? 'Set' : 'Not Set',
privProtocol: user.privProtocol ? 'Set' : 'Not Set',
privKey: user.privKey ? 'Set' : 'Not Set',
});
} }
session = snmp.createV3Session(config.host, user, options); session = snmp.createV3Session(config.host, user, options);
@@ -219,7 +216,7 @@ export class NupstSnmp {
if (error) { if (error) {
if (this.debug) { if (this.debug) {
console.error('SNMP GET error:', error); logger.error(`SNMP GET error: ${error}`);
} }
reject(new Error(`SNMP GET error: ${error.message || error}`)); reject(new Error(`SNMP GET error: ${error.message || error}`));
return; return;
@@ -227,7 +224,7 @@ export class NupstSnmp {
if (!varbinds || varbinds.length === 0) { if (!varbinds || varbinds.length === 0) {
if (this.debug) { if (this.debug) {
console.error('No varbinds returned in response'); logger.error('No varbinds returned in response');
} }
reject(new Error('No varbinds returned in response')); reject(new Error('No varbinds returned in response'));
return; return;
@@ -240,7 +237,7 @@ export class NupstSnmp {
varbinds[0].type === snmp.ObjectType.EndOfMibView varbinds[0].type === snmp.ObjectType.EndOfMibView
) { ) {
if (this.debug) { if (this.debug) {
console.error('SNMP error:', snmp.ObjectType[varbinds[0].type]); logger.error(`SNMP error: ${snmp.ObjectType[varbinds[0].type]}`);
} }
reject(new Error(`SNMP error: ${snmp.ObjectType[varbinds[0].type]}`)); reject(new Error(`SNMP error: ${snmp.ObjectType[varbinds[0].type]}`));
return; return;
@@ -262,11 +259,7 @@ export class NupstSnmp {
} }
if (this.debug) { if (this.debug) {
console.log('SNMP response:', { logger.dim(`SNMP response: oid=${varbinds[0].oid}, type=${varbinds[0].type}, value=${value}`);
oid: varbinds[0].oid,
type: varbinds[0].type,
value: value,
});
} }
resolve(value); resolve(value);
@@ -285,26 +278,30 @@ export class NupstSnmp {
this.setActiveOIDs(config); this.setActiveOIDs(config);
if (this.debug) { if (this.debug) {
console.log('---------------------------------------'); logger.dim('---------------------------------------');
console.log('Getting UPS status with config:'); logger.dim('Getting UPS status with config:');
console.log(' Host:', config.host); logger.dim(` Host: ${config.host}`);
console.log(' Port:', config.port); logger.dim(` Port: ${config.port}`);
console.log(' Version:', config.version); logger.dim(` Version: ${config.version}`);
console.log(' Timeout:', config.timeout, 'ms'); logger.dim(` Timeout: ${config.timeout} ms`);
console.log(' UPS Model:', config.upsModel || 'cyberpower'); logger.dim(` UPS Model: ${config.upsModel || 'cyberpower'}`);
if (config.version === 1 || config.version === 2) { if (config.version === 1 || config.version === 2) {
console.log(' Community:', config.community); logger.dim(` Community: ${config.community}`);
} else if (config.version === 3) { } else if (config.version === 3) {
console.log(' Security Level:', config.securityLevel); logger.dim(` Security Level: ${config.securityLevel}`);
console.log(' Username:', config.username); logger.dim(` Username: ${config.username}`);
console.log(' Auth Protocol:', config.authProtocol || 'None'); logger.dim(` Auth Protocol: ${config.authProtocol || 'None'}`);
console.log(' Privacy Protocol:', config.privProtocol || 'None'); logger.dim(` Privacy Protocol: ${config.privProtocol || 'None'}`);
} }
console.log('Using OIDs:'); logger.dim('Using OIDs:');
console.log(' Power Status:', this.activeOIDs.POWER_STATUS); logger.dim(` Power Status: ${this.activeOIDs.POWER_STATUS}`);
console.log(' Battery Capacity:', this.activeOIDs.BATTERY_CAPACITY); logger.dim(` Battery Capacity: ${this.activeOIDs.BATTERY_CAPACITY}`);
console.log(' Battery Runtime:', this.activeOIDs.BATTERY_RUNTIME); logger.dim(` Battery Runtime: ${this.activeOIDs.BATTERY_RUNTIME}`);
console.log('---------------------------------------'); logger.dim(` Output Load: ${this.activeOIDs.OUTPUT_LOAD}`);
logger.dim(` Output Power: ${this.activeOIDs.OUTPUT_POWER}`);
logger.dim(` Output Voltage: ${this.activeOIDs.OUTPUT_VOLTAGE}`);
logger.dim(` Output Current: ${this.activeOIDs.OUTPUT_CURRENT}`);
logger.dim('---------------------------------------');
} }
// Get all values with independent retry logic // Get all values with independent retry logic
@@ -324,41 +321,89 @@ export class NupstSnmp {
config, config,
) || 0; ) || 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 // Determine power status - handle different values for different UPS models
const powerStatus = this.determinePowerStatus(config.upsModel, powerStatusValue); const powerStatus = this.determinePowerStatus(config.upsModel, powerStatusValue);
// Convert to minutes for UPS models with different time units // Convert to minutes for UPS models with different time units
const processedRuntime = this.processRuntimeValue(config.upsModel, batteryRuntime); const processedRuntime = this.processRuntimeValue(config.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) {
logger.dim(
`Calculated power from V×I: ${processedVoltage}V × ${processedCurrent}A = ${processedPower}W`,
);
}
}
const result = { const result = {
powerStatus, powerStatus,
batteryCapacity, batteryCapacity,
batteryRuntime: processedRuntime, batteryRuntime: processedRuntime,
outputLoad,
outputPower: processedPower,
outputVoltage: processedVoltage,
outputCurrent: processedCurrent,
raw: { raw: {
powerStatus: powerStatusValue, powerStatus: powerStatusValue,
batteryCapacity, batteryCapacity,
batteryRuntime, batteryRuntime,
outputLoad,
outputPower,
outputVoltage,
outputCurrent,
}, },
}; };
if (this.debug) { if (this.debug) {
console.log('---------------------------------------'); logger.dim('---------------------------------------');
console.log('UPS status result:'); logger.dim('UPS status result:');
console.log(' Power Status:', result.powerStatus); logger.dim(` Power Status: ${result.powerStatus}`);
console.log(' Battery Capacity:', result.batteryCapacity + '%'); logger.dim(` Battery Capacity: ${result.batteryCapacity}%`);
console.log(' Battery Runtime:', result.batteryRuntime, 'minutes'); logger.dim(` Battery Runtime: ${result.batteryRuntime} minutes`);
console.log('---------------------------------------'); logger.dim(` Output Load: ${result.outputLoad}%`);
logger.dim(` Output Power: ${result.outputPower} watts`);
logger.dim(` Output Voltage: ${result.outputVoltage} volts`);
logger.dim(` Output Current: ${result.outputCurrent} amps`);
logger.dim('---------------------------------------');
} }
return result; return result;
} catch (error) { } catch (error) {
if (this.debug) { if (this.debug) {
console.error('---------------------------------------'); logger.error('---------------------------------------');
console.error( logger.error(
'Error getting UPS status:', `Error getting UPS status: ${error instanceof Error ? error.message : String(error)}`,
error instanceof Error ? error.message : String(error),
); );
console.error('---------------------------------------'); logger.error('---------------------------------------');
} }
throw new Error( throw new Error(
`Failed to get UPS status: ${error instanceof Error ? error.message : String(error)}`, `Failed to get UPS status: ${error instanceof Error ? error.message : String(error)}`,
@@ -380,26 +425,25 @@ export class NupstSnmp {
): Promise<any> { ): Promise<any> {
if (oid === '') { if (oid === '') {
if (this.debug) { if (this.debug) {
console.log(`No OID provided for ${description}, skipping`); logger.dim(`No OID provided for ${description}, skipping`);
} }
return 0; return 0;
} }
if (this.debug) { if (this.debug) {
console.log(`Getting ${description} OID: ${oid}`); logger.dim(`Getting ${description} OID: ${oid}`);
} }
try { try {
const value = await this.snmpGet(oid, config); const value = await this.snmpGet(oid, config);
if (this.debug) { if (this.debug) {
console.log(`${description} value:`, value); logger.dim(`${description} value: ${value}`);
} }
return value; return value;
} catch (error) { } catch (error) {
if (this.debug) { if (this.debug) {
console.error( logger.error(
`Error getting ${description}:`, `Error getting ${description}: ${error instanceof Error ? error.message : String(error)}`,
error instanceof Error ? error.message : String(error),
); );
} }
@@ -415,7 +459,7 @@ export class NupstSnmp {
// Return a default value if all attempts fail // Return a default value if all attempts fail
if (this.debug) { if (this.debug) {
console.log(`Using default value 0 for ${description}`); logger.dim(`Using default value 0 for ${description}`);
} }
return 0; return 0;
} }
@@ -434,7 +478,7 @@ export class NupstSnmp {
config: ISnmpConfig, config: ISnmpConfig,
): Promise<any> { ): Promise<any> {
if (this.debug) { if (this.debug) {
console.log(`Retrying ${description} with fallback security level...`); logger.dim(`Retrying ${description} with fallback security level...`);
} }
// Try with authNoPriv if current level is authPriv // Try with authNoPriv if current level is authPriv
@@ -442,18 +486,17 @@ export class NupstSnmp {
const retryConfig = { ...config, securityLevel: 'authNoPriv' as 'authNoPriv' }; const retryConfig = { ...config, securityLevel: 'authNoPriv' as 'authNoPriv' };
try { try {
if (this.debug) { if (this.debug) {
console.log(`Retrying with authNoPriv security level`); logger.dim(`Retrying with authNoPriv security level`);
} }
const value = await this.snmpGet(oid, retryConfig); const value = await this.snmpGet(oid, retryConfig);
if (this.debug) { if (this.debug) {
console.log(`${description} retry value:`, value); logger.dim(`${description} retry value: ${value}`);
} }
return value; return value;
} catch (retryError) { } catch (retryError) {
if (this.debug) { if (this.debug) {
console.error( logger.error(
`Retry failed for ${description}:`, `Retry failed for ${description}: ${retryError instanceof Error ? retryError.message : String(retryError)}`,
retryError instanceof Error ? retryError.message : String(retryError),
); );
} }
} }
@@ -464,18 +507,17 @@ export class NupstSnmp {
const retryConfig = { ...config, securityLevel: 'noAuthNoPriv' as 'noAuthNoPriv' }; const retryConfig = { ...config, securityLevel: 'noAuthNoPriv' as 'noAuthNoPriv' };
try { try {
if (this.debug) { if (this.debug) {
console.log(`Retrying with noAuthNoPriv security level`); logger.dim(`Retrying with noAuthNoPriv security level`);
} }
const value = await this.snmpGet(oid, retryConfig); const value = await this.snmpGet(oid, retryConfig);
if (this.debug) { if (this.debug) {
console.log(`${description} retry value:`, value); logger.dim(`${description} retry value: ${value}`);
} }
return value; return value;
} catch (retryError) { } catch (retryError) {
if (this.debug) { if (this.debug) {
console.error( logger.error(
`Retry failed for ${description}:`, `Retry failed for ${description}: ${retryError instanceof Error ? retryError.message : String(retryError)}`,
retryError instanceof Error ? retryError.message : String(retryError),
); );
} }
} }
@@ -501,21 +543,20 @@ export class NupstSnmp {
const standardOIDs = UpsOidSets.getStandardOids(); const standardOIDs = UpsOidSets.getStandardOids();
if (this.debug) { if (this.debug) {
console.log( logger.dim(
`Trying standard RFC 1628 OID for ${description}: ${standardOIDs[description]}`, `Trying standard RFC 1628 OID for ${description}: ${standardOIDs[description]}`,
); );
} }
const standardValue = await this.snmpGet(standardOIDs[description], config); const standardValue = await this.snmpGet(standardOIDs[description], config);
if (this.debug) { if (this.debug) {
console.log(`${description} standard OID value:`, standardValue); logger.dim(`${description} standard OID value: ${standardValue}`);
} }
return standardValue; return standardValue;
} catch (stdError) { } catch (stdError) {
if (this.debug) { if (this.debug) {
console.error( logger.error(
`Standard OID retry failed for ${description}:`, `Standard OID retry failed for ${description}: ${stdError instanceof Error ? stdError.message : String(stdError)}`,
stdError instanceof Error ? stdError.message : String(stdError),
); );
} }
} }
@@ -570,14 +611,14 @@ export class NupstSnmp {
batteryRuntime: number, batteryRuntime: number,
): number { ): number {
if (this.debug) { if (this.debug) {
console.log('Raw runtime value:', batteryRuntime); logger.dim(`Raw runtime value: ${batteryRuntime}`);
} }
if (upsModel === 'cyberpower' && batteryRuntime > 0) { if (upsModel === 'cyberpower' && batteryRuntime > 0) {
// CyberPower: TimeTicks is in 1/100 seconds, convert to minutes // CyberPower: TimeTicks is in 1/100 seconds, convert to minutes
const minutes = Math.floor(batteryRuntime / 6000); // 6000 ticks = 1 minute const minutes = Math.floor(batteryRuntime / 6000); // 6000 ticks = 1 minute
if (this.debug) { if (this.debug) {
console.log( logger.dim(
`Converting CyberPower runtime from ${batteryRuntime} ticks to ${minutes} minutes`, `Converting CyberPower runtime from ${batteryRuntime} ticks to ${minutes} minutes`,
); );
} }
@@ -586,7 +627,7 @@ export class NupstSnmp {
// Eaton: Runtime is in seconds, convert to minutes // Eaton: Runtime is in seconds, convert to minutes
const minutes = Math.floor(batteryRuntime / 60); const minutes = Math.floor(batteryRuntime / 60);
if (this.debug) { if (this.debug) {
console.log( logger.dim(
`Converting Eaton runtime from ${batteryRuntime} seconds to ${minutes} minutes`, `Converting Eaton runtime from ${batteryRuntime} seconds to ${minutes} minutes`,
); );
} }
@@ -595,11 +636,81 @@ export class NupstSnmp {
// Generic conversion for large tick values (likely TimeTicks) // Generic conversion for large tick values (likely TimeTicks)
const minutes = Math.floor(batteryRuntime / 6000); const minutes = Math.floor(batteryRuntime / 6000);
if (this.debug) { if (this.debug) {
console.log(`Converting ${batteryRuntime} ticks to ${minutes} minutes`); logger.dim(`Converting ${batteryRuntime} ticks to ${minutes} minutes`);
} }
return minutes; return minutes;
} }
return batteryRuntime; 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) {
logger.dim(`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) {
logger.dim(
`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) {
logger.dim(`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) {
logger.dim(
`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) {
logger.dim(
`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) {
logger.dim(`${upsModel.toUpperCase()} current already in RMS Amps: ${outputCurrent}A`);
}
return outputCurrent;
}
} }

View File

@@ -14,28 +14,40 @@ export class UpsOidSets {
POWER_STATUS: '1.3.6.1.4.1.3808.1.1.1.4.1.1.0', // upsBaseOutputStatus 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_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) 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: { POWER_STATUS_VALUES: {
online: 2, // upsBaseOutputStatus: 2=onLine online: 2, // upsBaseOutputStatus: 2=onLine
onBattery: 3, // upsBaseOutputStatus: 3=onBattery onBattery: 3, // upsBaseOutputStatus: 3=onBattery
}, },
}, },
// APC OIDs // APC OIDs (PowerNet MIB)
apc: { apc: {
POWER_STATUS: '1.3.6.1.4.1.318.1.1.1.4.1.1.0', // upsBasicOutputStatus 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_CAPACITY: '1.3.6.1.4.1.318.1.1.1.2.2.1.0', // Battery capacity in percentage
BATTERY_RUNTIME: '1.3.6.1.4.1.318.1.1.1.2.2.3.0', // Remaining runtime in minutes BATTERY_RUNTIME: '1.3.6.1.4.1.318.1.1.1.2.2.3.0', // Remaining runtime 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: { POWER_STATUS_VALUES: {
online: 2, // upsBasicOutputStatus: 2=onLine online: 2, // upsBasicOutputStatus: 2=onLine
onBattery: 3, // upsBasicOutputStatus: 3=onBattery onBattery: 3, // upsBasicOutputStatus: 3=onBattery
}, },
}, },
// Eaton OIDs // Eaton OIDs (XUPS-MIB)
eaton: { eaton: {
POWER_STATUS: '1.3.6.1.4.1.534.1.4.4.0', // xupsOutputSource 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_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) 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: { POWER_STATUS_VALUES: {
online: 3, // xupsOutputSource: 3=normal (mains power) online: 3, // xupsOutputSource: 3=normal (mains power)
onBattery: 5, // xupsOutputSource: 5=battery onBattery: 5, // xupsOutputSource: 5=battery
@@ -47,6 +59,10 @@ export class UpsOidSets {
POWER_STATUS: '1.3.6.1.4.1.850.1.1.3.1.1.1.0', // tlUpsOutputSource 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_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 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: { POWER_STATUS_VALUES: {
online: 2, // tlUpsOutputSource: 2=normal (mains power) online: 2, // tlUpsOutputSource: 2=normal (mains power)
onBattery: 3, // tlUpsOutputSource: 3=onBattery onBattery: 3, // tlUpsOutputSource: 3=onBattery
@@ -58,6 +74,10 @@ export class UpsOidSets {
POWER_STATUS: '1.3.6.1.4.1.476.1.42.3.9.20.1.20.1.2.1.2.1', // lgpPwrOutputSource 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_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 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: { POWER_STATUS_VALUES: {
online: 2, // lgpPwrOutputSource: 2=normal (mains power) online: 2, // lgpPwrOutputSource: 2=normal (mains power)
onBattery: 3, // lgpPwrOutputSource: 3=onBattery onBattery: 3, // lgpPwrOutputSource: 3=onBattery
@@ -69,6 +89,10 @@ export class UpsOidSets {
POWER_STATUS: '', POWER_STATUS: '',
BATTERY_CAPACITY: '', BATTERY_CAPACITY: '',
BATTERY_RUNTIME: '', BATTERY_RUNTIME: '',
OUTPUT_LOAD: '',
OUTPUT_POWER: '',
OUTPUT_VOLTAGE: '',
OUTPUT_CURRENT: '',
}, },
}; };
@@ -90,6 +114,10 @@ export class UpsOidSets {
'power status': '1.3.6.1.2.1.33.1.4.1.0', // upsOutputSource '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 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 '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)
}; };
} }
} }

View File

@@ -14,6 +14,14 @@ export interface IUpsStatus {
batteryCapacity: number; batteryCapacity: number;
/** Remaining runtime in minutes */ /** Remaining runtime in minutes */
batteryRuntime: number; 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 values from SNMP responses */
raw: Record<string, any>; raw: Record<string, any>;
} }
@@ -28,6 +36,14 @@ export interface IOidSet {
BATTERY_CAPACITY: string; BATTERY_CAPACITY: string;
/** OID for battery runtime */ /** OID for battery runtime */
BATTERY_RUNTIME: string; 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 value mappings */
POWER_STATUS_VALUES?: { POWER_STATUS_VALUES?: {
/** SNMP value that indicates UPS is online (on AC power) */ /** SNMP value that indicates UPS is online (on AC power) */

View File

@@ -1,7 +1,8 @@
import process from 'node:process'; import process from 'node:process';
import { promises as fs } from 'node:fs'; import { promises as fs } from 'node:fs';
import { execSync } from 'node:child_process'; 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 { logger } from './logger.ts';
import { theme, symbols, getBatteryColor, getRuntimeColor, formatPowerStatus } from './colors.ts'; import { theme, symbols, getBatteryColor, getRuntimeColor, formatPowerStatus } from './colors.ts';
@@ -141,6 +142,9 @@ WantedBy=multi-user.target
private async displayVersionInfo(): Promise<void> { private async displayVersionInfo(): Promise<void> {
try { try {
const nupst = this.daemon.getNupstSnmp().getNupst(); const nupst = this.daemon.getNupstSnmp().getNupst();
if (!nupst) {
return;
}
const version = nupst.getVersion(); const version = nupst.getVersion();
// Check for updates // Check for updates
@@ -160,13 +164,15 @@ WantedBy=multi-user.target
`${theme.dim('NUPST')} ${theme.dim('v' + version)} ${symbols.success} ${theme.success('Up to date')}`, `${theme.dim('NUPST')} ${theme.dim('v' + version)} ${symbols.success} ${theme.success('Up to date')}`,
); );
} }
} catch (error) { } catch (_error) {
// If version check fails, show at least the current version // If version check fails, show at least the current version
try { try {
const nupst = this.daemon.getNupstSnmp().getNupst(); const nupst = this.daemon.getNupstSnmp().getNupst();
if (nupst) {
const version = nupst.getVersion(); const version = nupst.getVersion();
logger.log(''); logger.log('');
logger.log(`${theme.dim('NUPST')} ${theme.dim('v' + version)}`); logger.log(`${theme.dim('NUPST')} ${theme.dim('v' + version)}`);
}
} catch (_innerError) { } catch (_innerError) {
// Silently fail if we can't even get the version // Silently fail if we can't even get the version
} }
@@ -276,15 +282,27 @@ WantedBy=multi-user.target
for (const ups of config.upsDevices) { for (const ups of config.upsDevices) {
await this.displaySingleUpsStatus(ups, snmp); await this.displaySingleUpsStatus(ups, snmp);
} }
// Display groups after UPS devices
this.displayGroupsStatus();
} else if (config.snmp) { } else if (config.snmp) {
// Legacy single UPS configuration // Legacy single UPS configuration (v1/v2 format)
logger.info('UPS Devices (1):'); logger.info('UPS Devices (1):');
const legacyUps = { const legacyUps: IUpsConfig = {
id: 'default', id: 'default',
name: 'Default UPS', name: 'Default UPS',
snmp: config.snmp, snmp: config.snmp,
thresholds: config.thresholds,
groups: [], groups: [],
actions: config.thresholds
? [
{
type: 'shutdown',
thresholds: config.thresholds,
triggerMode: 'onlyThresholds',
shutdownDelay: 5,
},
]
: [],
}; };
await this.displaySingleUpsStatus(legacyUps, snmp); await this.displaySingleUpsStatus(legacyUps, snmp);
@@ -307,7 +325,7 @@ WantedBy=multi-user.target
* @param ups UPS configuration * @param ups UPS configuration
* @param snmp SNMP manager * @param snmp SNMP manager
*/ */
private async displaySingleUpsStatus(ups: any, snmp: any): Promise<void> { private async displaySingleUpsStatus(ups: IUpsConfig, snmp: NupstSnmp): Promise<void> {
try { try {
// Create a test config with a short timeout // Create a test config with a short timeout
const testConfig = { const testConfig = {
@@ -332,7 +350,7 @@ WantedBy=multi-user.target
const batteryColor = getBatteryColor(status.batteryCapacity); const batteryColor = getBatteryColor(status.batteryCapacity);
// Get threshold from actions (if any action has thresholds defined) // Get threshold from actions (if any action has thresholds defined)
const actionWithThresholds = ups.actions?.find((action: any) => action.thresholds); const actionWithThresholds = ups.actions?.find((action) => action.thresholds);
const batteryThreshold = actionWithThresholds?.thresholds?.battery; const batteryThreshold = actionWithThresholds?.thresholds?.battery;
const batterySymbol = batteryThreshold !== undefined && status.batteryCapacity >= batteryThreshold const batterySymbol = batteryThreshold !== undefined && status.batteryCapacity >= batteryThreshold
? symbols.success ? symbols.success
@@ -342,6 +360,9 @@ WantedBy=multi-user.target
logger.log(` Battery: ${batteryColor(status.batteryCapacity + '%')} ${batterySymbol} Runtime: ${getRuntimeColor(status.batteryRuntime)(status.batteryRuntime + ' min')}`); logger.log(` Battery: ${batteryColor(status.batteryCapacity + '%')} ${batterySymbol} Runtime: ${getRuntimeColor(status.batteryRuntime)(status.batteryRuntime + ' min')}`);
// Display power metrics
logger.log(` Load: ${theme.highlight(status.outputLoad + '%')} Power: ${theme.highlight(status.outputPower + 'W')} Voltage: ${theme.highlight(status.outputVoltage + 'V')} Current: ${theme.highlight(status.outputCurrent + 'A')}`);
// Display host info // Display host info
logger.log(` ${theme.dim(`Host: ${ups.snmp.host}:${ups.snmp.port}`)}`); logger.log(` ${theme.dim(`Host: ${ups.snmp.host}:${ups.snmp.port}`)}`);
@@ -355,6 +376,27 @@ WantedBy=multi-user.target
logger.log(` ${theme.dim(`Groups: ${groupNames.join(', ')}`)}`); logger.log(` ${theme.dim(`Groups: ${groupNames.join(', ')}`)}`);
} }
// Display actions if any
if (ups.actions && ups.actions.length > 0) {
for (const action of ups.actions) {
let actionDesc = `${action.type}`;
if (action.thresholds) {
actionDesc += ` (${action.triggerMode || 'onlyThresholds'}: battery<${action.thresholds.battery}%, runtime<${action.thresholds.runtime}min`;
if (action.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(''); logger.log('');
} catch (error) { } catch (error) {
@@ -366,6 +408,69 @@ WantedBy=multi-user.target
} }
} }
/**
* 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('');
}
}
/** /**
* Disable and uninstall the systemd service * Disable and uninstall the systemd service
* @throws Error if disabling fails * @throws Error if disabling fails