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

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
uses: denoland/setup-deno@v1
with:
deno-version: v1.x
deno-version: v2.x
- name: Get version from tag
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
## 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
**MAJOR RELEASE: NUPST v4.0 is a complete rewrite powered by Deno**

View File

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

View File

@@ -1,6 +1,6 @@
#!/bin/bash
# NUPST Installer Script (v4.0+)
# NUPST Installer Script (v5.0+)
# Downloads and installs pre-compiled NUPST binary from Gitea releases
#
# Usage:
@@ -8,7 +8,7 @@
# curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash
#
# With version specification:
# curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash -s -- --version v4.0.0
# curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash -s -- --version v5.0.0
#
# Options:
# -h, --help Show this help message
@@ -48,14 +48,14 @@ while [[ $# -gt 0 ]]; do
done
if [ $SHOW_HELP -eq 1 ]; then
echo "NUPST Installer Script (v4.0+)"
echo "NUPST Installer Script (v5.0+)"
echo "Downloads and installs pre-compiled NUPST binary"
echo ""
echo "Usage: $0 [options]"
echo ""
echo "Options:"
echo " -h, --help Show this help message"
echo " --version VERSION Install specific version (e.g., v4.0.0)"
echo " --version VERSION Install specific version (e.g., v5.0.0)"
echo " --install-dir DIR Installation directory (default: /opt/nupst)"
echo ""
echo "Examples:"
@@ -63,7 +63,7 @@ if [ $SHOW_HELP -eq 1 ]; then
echo " curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash"
echo ""
echo " # Install specific version"
echo " curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash -s -- --version v4.0.0"
echo " curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash -s -- --version v5.0.0"
exit 0
fi
@@ -145,7 +145,7 @@ get_latest_version() {
# Main installation process
echo "================================================"
echo " NUPST Installation Script (v4.0+)"
echo " NUPST Installation Script (v5.0+)"
echo "================================================"
echo ""
@@ -169,51 +169,26 @@ DOWNLOAD_URL="${GITEA_BASE_URL}/${GITEA_REPO}/releases/download/${VERSION}/${BIN
echo "Download URL: $DOWNLOAD_URL"
echo ""
# Check if installation directory exists
# Check if service is running and stop it
SERVICE_WAS_RUNNING=0
OLD_NODE_INSTALL=0
if [ -d "$INSTALL_DIR" ]; then
# Check if this is an old Node.js-based installation
if [ -f "$INSTALL_DIR/package.json" ] || [ -d "$INSTALL_DIR/node_modules" ]; then
OLD_NODE_INSTALL=1
echo "Detected old Node.js-based NUPST installation (v3.x or earlier)"
echo "This installer will migrate to the new Deno-based binary version (v4.0+)"
echo ""
if systemctl is-enabled --quiet nupst 2>/dev/null || systemctl is-active --quiet nupst 2>/dev/null; then
SERVICE_WAS_RUNNING=1
if systemctl is-active --quiet nupst 2>/dev/null; then
echo "Stopping NUPST service..."
systemctl stop nupst
fi
echo "Updating existing installation at $INSTALL_DIR..."
# Check if service exists (enabled or running) and stop it if active
if systemctl is-enabled --quiet nupst 2>/dev/null || systemctl is-active --quiet nupst 2>/dev/null; then
SERVICE_WAS_RUNNING=1
if systemctl is-active --quiet nupst 2>/dev/null; then
echo "Stopping NUPST service..."
systemctl stop nupst
else
echo "Service is installed but not currently running (will be updated)..."
fi
fi
# Clean up old Node.js installation files
if [ $OLD_NODE_INSTALL -eq 1 ]; then
echo "Cleaning up old Node.js installation files..."
rm -rf "$INSTALL_DIR/node_modules" 2>/dev/null || true
rm -rf "$INSTALL_DIR/vendor" 2>/dev/null || true
rm -rf "$INSTALL_DIR/dist_ts" 2>/dev/null || true
rm -f "$INSTALL_DIR/package.json" 2>/dev/null || true
rm -f "$INSTALL_DIR/package-lock.json" 2>/dev/null || true
rm -f "$INSTALL_DIR/pnpm-lock.yaml" 2>/dev/null || true
rm -f "$INSTALL_DIR/tsconfig.json" 2>/dev/null || true
rm -f "$INSTALL_DIR/setup.sh" 2>/dev/null || true
rm -rf "$INSTALL_DIR/bin" 2>/dev/null || true
echo "Old installation files removed."
fi
else
echo "Creating installation directory: $INSTALL_DIR"
mkdir -p "$INSTALL_DIR"
fi
# Clean installation directory - ensure only binary exists
if [ -d "$INSTALL_DIR" ]; then
echo "Cleaning installation directory: $INSTALL_DIR"
rm -rf "$INSTALL_DIR"
fi
# Create fresh installation directory
echo "Creating installation directory: $INSTALL_DIR"
mkdir -p "$INSTALL_DIR"
# Download binary
echo "Downloading NUPST binary..."
TEMP_FILE="$INSTALL_DIR/nupst.download"
@@ -241,9 +216,20 @@ fi
BINARY_PATH="$INSTALL_DIR/nupst"
mv "$TEMP_FILE" "$BINARY_PATH"
if [ $? -ne 0 ] || [ ! -f "$BINARY_PATH" ]; then
echo "Error: Failed to move binary to $BINARY_PATH"
rm -f "$TEMP_FILE" 2>/dev/null
exit 1
fi
# Make executable
chmod +x "$BINARY_PATH"
if [ $? -ne 0 ]; then
echo "Error: Failed to make binary executable"
exit 1
fi
echo "Binary installed successfully to: $BINARY_PATH"
echo ""
@@ -260,18 +246,10 @@ echo "Symlink created: $BIN_DIR/nupst -> $BINARY_PATH"
echo ""
# Update systemd service file if migrating from v3
if [ $SERVICE_WAS_RUNNING -eq 1 ] && [ $OLD_NODE_INSTALL -eq 1 ]; then
echo "Updating systemd service file for v4..."
$BINARY_PATH service enable > /dev/null 2>&1
echo "Service file updated."
echo ""
fi
# Restart service if it was running before update
if [ $SERVICE_WAS_RUNNING -eq 1 ]; then
echo "Restarting NUPST service..."
systemctl start nupst
systemctl restart nupst
echo "Service restarted successfully."
echo ""
fi
@@ -280,20 +258,6 @@ echo "================================================"
echo " NUPST Installation Complete!"
echo "================================================"
echo ""
if [ $OLD_NODE_INSTALL -eq 1 ]; then
echo "Migration from v3.x to v4.0 successful!"
echo ""
echo "What changed:"
echo " • Node.js runtime removed (now a self-contained binary)"
echo " • Faster startup and lower memory usage"
echo " • CLI commands now use subcommand structure"
echo " (old commands still work with deprecation warnings)"
echo ""
echo "See readme for migration details: https://code.foss.global/serve.zone/nupst#migration-from-v3x"
echo ""
fi
echo "Installation details:"
echo " Binary location: $BINARY_PATH"
echo " Symlink location: $BIN_DIR/nupst"

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()
```

1177
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 { 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';
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);
// Load the test configuration from .nogit/env.json
// Load test configurations
const testConfigV1 = await testQenv.getEnvVarOnDemandAsObject('testConfigV1');
const testConfigV3 = await testQenv.getEnvVarOnDemandAsObject('testConfigV3');
Deno.test('should log config', () => {
console.log(testConfigV1);
assert(true);
Deno.test('Integration: Real UPS test v1', async () => {
await testUpsConnection(snmp, testConfigV1, 'SNMPv1 connection');
});
// Test with real UPS using the configuration from .nogit/env.json
Deno.test('Real UPS test v1', async () => {
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');
}
Deno.test('Integration: Real UPS test v3', async () => {
await testUpsConnection(snmp, testConfigV3, 'SNMPv3 connection');
});

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 = {
name: denoConfig.name,
version: denoConfig.version,
description: 'Network UPS Shutdown Tool (https://nupst.serve.zone)',
};
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'
}

View File

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

View File

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

View File

@@ -1,8 +1,9 @@
import * as fs from 'node:fs';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { Action, type IActionConfig, type IActionContext } from './base-action.ts';
import { Action, type IActionContext } from './base-action.ts';
import { logger } from '../logger.ts';
import { SHUTDOWN, UI } from '../constants.ts';
const execFileAsync = promisify(execFile);
@@ -15,6 +16,81 @@ const execFileAsync = promisify(execFile);
export class ShutdownAction extends Action {
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
* @param context Action context with UPS state
@@ -26,10 +102,10 @@ export class ShutdownAction extends Action {
return;
}
const shutdownDelay = this.config.shutdownDelay || 5; // Default 5 minutes
const shutdownDelay = this.config.shutdownDelay || SHUTDOWN.DEFAULT_DELAY_MINUTES;
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(`Power Status: ${context.powerStatus}`);
logger.logBoxLine(`Battery: ${context.batteryCapacity}%`);

View File

@@ -3,6 +3,32 @@ import * as https from 'node:https';
import { URL } from 'node:url';
import { Action, type IActionConfig, type IActionContext } from './base-action.ts';
import { logger } from '../logger.ts';
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
@@ -30,7 +56,7 @@ export class WebhookAction extends Action {
}
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}`);
@@ -56,7 +82,7 @@ export class WebhookAction extends Action {
method: 'GET' | 'POST',
timeout: number,
): Promise<void> {
const payload: any = {
const payload: IWebhookPayload = {
upsId: context.upsId,
upsName: context.upsName,
powerStatus: context.powerStatus,

200
ts/cli.ts
View File

@@ -72,6 +72,7 @@ export class NupstCli {
const upsHandler = this.nupst.getUpsHandler();
const groupHandler = this.nupst.getGroupHandler();
const serviceHandler = this.nupst.getServiceHandler();
const actionHandler = this.nupst.getActionHandler();
// Handle service subcommands
if (command === 'service') {
@@ -126,8 +127,7 @@ export class NupstCli {
break;
}
case 'remove':
case 'rm': // Alias
case 'delete': { // Backward compatibility
case 'rm': {
const upsIdToRemove = subcommandArgs[0];
if (!upsIdToRemove) {
logger.error('UPS ID is required for remove command');
@@ -171,8 +171,7 @@ export class NupstCli {
break;
}
case 'remove':
case 'rm': // Alias
case 'delete': { // Backward compatibility
case 'rm': {
const groupIdToRemove = subcommandArgs[0];
if (!groupIdToRemove) {
logger.error('Group ID is required for remove command');
@@ -193,6 +192,55 @@ export class NupstCli {
return;
}
// Handle action subcommands
if (command === 'action') {
const subcommand = commandArgs[0] || 'list';
const subcommandArgs = commandArgs.slice(1);
switch (subcommand) {
case 'add': {
const upsId = subcommandArgs[0];
await actionHandler.add(upsId);
break;
}
case 'remove':
case 'rm': {
const upsId = subcommandArgs[0];
const actionIndex = subcommandArgs[1];
await actionHandler.remove(upsId, actionIndex);
break;
}
case 'list':
case 'ls': { // Alias
const upsId = subcommandArgs[0];
await actionHandler.list(upsId);
break;
}
default:
this.showActionHelp();
break;
}
return;
}
// Handle feature subcommands
if (command === 'feature') {
const subcommand = commandArgs[0];
const featureHandler = this.nupst.getFeatureHandler();
switch (subcommand) {
case 'httpServer':
case 'http-server':
case 'http':
await featureHandler.configureHttpServer();
break;
default:
this.showFeatureHelp();
break;
}
return;
}
// Handle config subcommand
if (command === 'config') {
const subcommand = commandArgs[0] || 'show';
@@ -209,72 +257,8 @@ export class NupstCli {
return;
}
// Handle top-level commands and backward compatibility
// Handle top-level commands
switch (command) {
// Backward compatibility - old UPS commands
case 'add':
logger.log("Note: 'nupst add' is deprecated. Use 'nupst ups add' instead.");
await upsHandler.add();
break;
case 'edit':
logger.log("Note: 'nupst edit' is deprecated. Use 'nupst ups edit' instead.");
await upsHandler.edit(commandArgs[0]);
break;
case 'delete':
logger.log("Note: 'nupst delete' is deprecated. Use 'nupst ups remove' instead.");
if (!commandArgs[0]) {
logger.error('UPS ID is required for delete command');
this.showHelp();
return;
}
await upsHandler.remove(commandArgs[0]);
break;
case 'list':
logger.log("Note: 'nupst list' is deprecated. Use 'nupst ups list' instead.");
await upsHandler.list();
break;
case 'test':
logger.log("Note: 'nupst test' is deprecated. Use 'nupst ups test' instead.");
await upsHandler.test(debugMode);
break;
case 'setup':
logger.log("Note: 'nupst setup' is deprecated. Use 'nupst ups edit' instead.");
await upsHandler.edit(undefined);
break;
// Backward compatibility - old service commands
case 'enable':
logger.log("Note: 'nupst enable' is deprecated. Use 'nupst service enable' instead.");
await serviceHandler.enable();
break;
case 'disable':
logger.log("Note: 'nupst disable' is deprecated. Use 'nupst service disable' instead.");
await serviceHandler.disable();
break;
case 'start':
logger.log("Note: 'nupst start' is deprecated. Use 'nupst service start' instead.");
await serviceHandler.start();
break;
case 'stop':
logger.log("Note: 'nupst stop' is deprecated. Use 'nupst service stop' instead.");
await serviceHandler.stop();
break;
case 'status':
logger.log("Note: 'nupst status' is deprecated. Use 'nupst service status' instead.");
await serviceHandler.status();
break;
case 'logs':
logger.log("Note: 'nupst logs' is deprecated. Use 'nupst service logs' instead.");
await serviceHandler.logs();
break;
case 'daemon-start':
logger.log(
"Note: 'nupst daemon-start' is deprecated. Use 'nupst service start-daemon' instead.",
);
await serviceHandler.daemonStart(debugMode);
break;
// Top-level commands (no changes)
case 'update':
await serviceHandler.update();
break;
@@ -328,6 +312,26 @@ export class NupstCli {
` ${theme.path('/etc/nupst/config.json')}`,
], 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
if (config.upsDevices.length > 0) {
const upsRows = config.upsDevices.map((ups) => ({
@@ -499,6 +503,8 @@ export class NupstCli {
this.printCommand('service <subcommand>', 'Manage systemd service');
this.printCommand('ups <subcommand>', 'Manage UPS devices');
this.printCommand('group <subcommand>', 'Manage UPS groups');
this.printCommand('action <subcommand>', 'Manage UPS actions');
this.printCommand('feature <subcommand>', 'Manage optional features');
this.printCommand('config [show]', 'Display current configuration');
this.printCommand('update', 'Update NUPST from repository', theme.dim('(requires root)'));
this.printCommand('uninstall', 'Completely remove NUPST', theme.dim('(requires root)'));
@@ -535,6 +541,18 @@ export class NupstCli {
this.printCommand('nupst group list (or ls)', 'List all UPS groups');
console.log('');
// Action subcommands
logger.log(theme.info('Action Subcommands:'));
this.printCommand('nupst action add <target-id>', 'Add a new action to a UPS or group');
this.printCommand('nupst action remove <target-id> <index>', 'Remove an action by index');
this.printCommand('nupst action list [target-id]', 'List all actions (optionally for specific target)');
console.log('');
// Feature subcommands
logger.log(theme.info('Feature Subcommands:'));
this.printCommand('nupst feature httpServer', 'Configure HTTP server for JSON status export');
console.log('');
// Options
logger.log(theme.info('Options:'));
this.printCommand('--debug, -d', 'Enable debug mode for detailed SNMP logging');
@@ -548,11 +566,6 @@ export class NupstCli {
logger.dim(' nupst group list # Show all configured groups');
logger.dim(' nupst config # Display current configuration');
console.log('');
// Note about deprecated commands
logger.warn('Note: Old command format (e.g., \'nupst add\') still works but is deprecated.');
logger.dim(' Use the new format (e.g., \'nupst ups add\') going forward.');
console.log('');
}
/**
@@ -639,6 +652,45 @@ Examples:
nupst group add - Create a new group
nupst group edit dc-1 - Edit group with ID 'dc-1'
nupst group remove dc-1 - Remove group with ID 'dc-1'
`);
}
private showActionHelp(): void {
logger.log(`
NUPST - Action Management Commands
Usage:
nupst action <subcommand> [arguments]
Subcommands:
add <ups-id|group-id> - Add a new action to a UPS or group interactively
remove <ups-id|group-id> <index> - Remove an action by index (alias: rm)
list [ups-id|group-id] - List all actions (optionally for specific target) (alias: ls)
Options:
--debug, -d - Enable debug mode for detailed logging
Examples:
nupst action list - List actions for all UPS devices and groups
nupst action list default - List actions for UPS or group with ID 'default'
nupst action add default - Add a new action to UPS or group 'default'
nupst action remove default 0 - Remove action at index 0 from UPS or group 'default'
nupst action add dc-rack-1 - Add a new action to group 'dc-rack-1'
`);
}
private showFeatureHelp(): void {
logger.log(`
NUPST - Feature Management Commands
Usage:
nupst feature <subcommand>
Subcommands:
httpServer - Configure HTTP server for JSON status export
Examples:
nupst feature httpServer - Enable/disable HTTP server with interactive setup
`);
}
}

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

View File

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

View File

@@ -4,8 +4,17 @@ import { Nupst } from '../nupst.ts';
import { logger, type ITableColumn } from '../logger.ts';
import { theme } from '../colors.ts';
import * as helpers from '../helpers/index.ts';
import type { TUpsModel } from '../snmp/types.ts';
import type { INupstConfig } from '../daemon.ts';
import type { ISnmpConfig, TUpsModel, IUpsStatus as ISnmpUpsStatus } from '../snmp/types.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
@@ -27,29 +36,9 @@ export class UpsHandler {
*/
public async add(): Promise<void> {
try {
// Import readline module for user input
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 helpers.withPrompt(async (prompt) => {
await this.runAddProcess(prompt);
} finally {
rl.close();
process.stdin.destroy();
}
});
} catch (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> {
try {
// Import readline module for user input
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 helpers.withPrompt(async (prompt) => {
await this.runEditProcess(upsId, prompt);
} finally {
rl.close();
process.stdin.destroy();
}
});
} catch (error) {
logger.error(`Edit UPS error: ${error instanceof Error ? error.message : String(error)}`);
}
@@ -337,23 +306,11 @@ export class UpsHandler {
const upsToDelete = config.upsDevices[upsIndex];
// Get confirmation before deleting
const readline = await import('node:readline');
const rl = readline.createInterface({
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]: `,
(answer) => {
resolve(answer.toLowerCase());
},
);
});
rl.close();
process.stdin.destroy();
const { prompt, close } = await helpers.createPrompt();
const confirm = (await prompt(
`Are you sure you want to delete UPS "${upsToDelete.name}" (${upsId})? [y/N]: `,
)).toLowerCase();
close();
if (confirm !== 'y' && confirm !== 'yes') {
logger.log('Deletion cancelled.');
@@ -509,19 +466,28 @@ export class UpsHandler {
* Display the configuration for testing
* @param config Current configuration or individual UPS configuration
*/
private displayTestConfig(config: any): void {
// Check if this is a UPS device or full configuration
const isUpsConfig = config.snmp;
const snmpConfig = isUpsConfig ? config.snmp : config.snmp || {};
const checkInterval = config.checkInterval || 30000;
private displayTestConfig(config: IUpsConfig | INupstConfig): void {
// Type guard: IUpsConfig has 'id' and 'name' at root level, INupstConfig doesn't
const isUpsConfig = 'id' in config && 'name' in config;
// Get UPS name and ID if available
const upsName = config.name ? config.name : 'Default UPS';
const upsId = config.id ? config.id : 'default';
// Get SNMP config and other values based on config type
const snmpConfig: ISnmpConfig | undefined = isUpsConfig
? (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;
logger.logBoxTitle(`Testing Configuration: ${upsName}`, boxWidth);
logger.logBoxLine(`UPS ID: ${upsId}`);
if (!snmpConfig) {
logger.logBoxLine('SNMP Settings: Not configured');
logger.logBoxEnd();
return;
}
logger.logBoxLine('SNMP Settings:');
logger.logBoxLine(` Host: ${snmpConfig.host}`);
logger.logBoxLine(` Port: ${snmpConfig.port}`);
@@ -557,9 +523,10 @@ export class UpsHandler {
logger.logBoxLine(` Battery Runtime: ${snmpConfig.customOIDs.BATTERY_RUNTIME || 'Not set'}`);
}
// 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(
`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
* @param config Current UPS configuration or legacy config
*/
private async testConnection(config: any): Promise<void> {
const upsId = config.id || 'default';
const upsName = config.name || 'Default UPS';
private async testConnection(config: IUpsConfig | INupstConfig): Promise<void> {
// Type guard: IUpsConfig has 'id' and 'name' at root level
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})...`);
try {
// Create a test config with a short timeout
const snmpConfig = config.snmp ? config.snmp : config.snmp;
// Get SNMP config based on config type
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,
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 thresholds Threshold configuration
*/
private analyzeThresholds(status: any, thresholds: any): void {
private analyzeThresholds(status: ISnmpUpsStatus, thresholds: IThresholds): void {
const boxWidth = 45;
logger.logBoxTitle('Threshold Analysis', boxWidth);
@@ -649,7 +624,7 @@ export class UpsHandler {
* @param prompt Function to prompt for user input
*/
private async gatherSnmpSettings(
snmpConfig: any,
snmpConfig: Partial<ISnmpConfig>,
prompt: (question: string) => Promise<string>,
): Promise<void> {
// SNMP IP Address
@@ -693,7 +668,7 @@ export class UpsHandler {
* @param prompt Function to prompt for user input
*/
private async gatherSnmpV3Settings(
snmpConfig: any,
snmpConfig: Partial<ISnmpConfig>,
prompt: (question: string) => Promise<string>,
): Promise<void> {
logger.log('');
@@ -718,17 +693,17 @@ export class UpsHandler {
if (secLevel === 1) {
snmpConfig.securityLevel = 'noAuthNoPriv';
// No auth, no priv - clear out authentication and privacy settings
snmpConfig.authProtocol = '';
snmpConfig.authKey = '';
snmpConfig.privProtocol = '';
snmpConfig.privKey = '';
snmpConfig.authProtocol = undefined;
snmpConfig.authKey = undefined;
snmpConfig.privProtocol = undefined;
snmpConfig.privKey = undefined;
// Set appropriate timeout for security level
snmpConfig.timeout = 5000; // 5 seconds for basic security
} else if (secLevel === 2) {
snmpConfig.securityLevel = 'authNoPriv';
// Auth, no priv - clear out privacy settings
snmpConfig.privProtocol = '';
snmpConfig.privKey = '';
snmpConfig.privProtocol = undefined;
snmpConfig.privKey = undefined;
// Set appropriate timeout for security level
snmpConfig.timeout = 10000; // 10 seconds for authentication
} else {
@@ -771,7 +746,7 @@ export class UpsHandler {
* @param prompt Function to prompt for user input
*/
private async gatherAuthenticationSettings(
snmpConfig: any,
snmpConfig: Partial<ISnmpConfig>,
prompt: (question: string) => Promise<string>,
): Promise<void> {
// Authentication protocol
@@ -798,7 +773,7 @@ export class UpsHandler {
* @param prompt Function to prompt for user input
*/
private async gatherPrivacySettings(
snmpConfig: any,
snmpConfig: Partial<ISnmpConfig>,
prompt: (question: string) => Promise<string>,
): Promise<void> {
// Privacy protocol
@@ -823,7 +798,7 @@ export class UpsHandler {
* @param prompt Function to prompt for user input
*/
private async gatherUpsModelSettings(
snmpConfig: any,
snmpConfig: Partial<ISnmpConfig>,
prompt: (question: string) => Promise<string>,
): Promise<void> {
logger.log('');
@@ -868,16 +843,21 @@ export class UpsHandler {
logger.info('Enter custom OIDs for your UPS:');
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 batteryCapacityOID = await prompt('Battery Capacity 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 = {
POWER_STATUS: powerStatusOID.trim(),
BATTERY_CAPACITY: batteryCapacityOID.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
*/
private async gatherActionSettings(
actions: any[],
actions: IActionConfig[],
prompt: (question: string) => Promise<string>,
): Promise<void> {
logger.log('');
@@ -915,7 +895,7 @@ export class UpsHandler {
const typeInput = await prompt('Select action type [1]: ');
const typeValue = parseInt(typeInput, 10) || 1;
const action: any = {};
const action: Partial<IActionConfig> = {};
if (typeValue === 1) {
// Shutdown action
@@ -1014,8 +994,8 @@ export class UpsHandler {
};
}
actions.push(action);
logger.success(`${action.type.charAt(0).toUpperCase() + action.type.slice(1)} action added (mode: ${action.triggerMode || 'powerChangesAndThresholds'})`);
actions.push(action as IActionConfig);
logger.success(`${action.type!.charAt(0).toUpperCase() + action.type!.slice(1)} action added (mode: ${action.triggerMode || 'powerChangesAndThresholds'})`);
const more = await prompt('Add another action? (y/N): ');
addMore = more.toLowerCase() === 'y';
@@ -1031,7 +1011,7 @@ export class UpsHandler {
* Display UPS configuration summary
* @param ups UPS configuration
*/
private displayUpsConfigSummary(ups: any): void {
private displayUpsConfigSummary(ups: IUpsConfig): void {
const boxWidth = 45;
logger.log('');
logger.logBoxTitle(`UPS Configuration: ${ups.name}`, boxWidth);
@@ -1055,7 +1035,7 @@ export class UpsHandler {
* @param prompt Function to prompt for user input
*/
private async optionallyTestConnection(
snmpConfig: any,
snmpConfig: ISnmpConfig,
prompt: (question: string) => Promise<string>,
): Promise<void> {
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 { promisify } from 'node:util';
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 { MigrationRunner } from './migrations/index.ts';
import { theme, symbols, getBatteryColor, getRuntimeColor, formatPowerStatus } from './colors.ts';
import type { IActionConfig } from './actions/base-action.ts';
import { ActionManager, type IActionContext, type TPowerStatus } from './actions/index.ts';
import { NupstHttpServer } from './http-server.ts';
import { TIMING, THRESHOLDS, UI } from './constants.ts';
const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);
@@ -46,6 +48,20 @@ export interface IGroupConfig {
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
*/
@@ -58,6 +74,8 @@ export interface INupstConfig {
groups: IGroupConfig[];
/** Check interval in milliseconds */
checkInterval: number;
/** HTTP Server configuration */
httpServer?: IHttpServerConfig;
// Legacy fields for backward compatibility (will be migrated away)
/** UPS list (v3 format - legacy) */
@@ -76,12 +94,16 @@ export interface INupstConfig {
/**
* UPS status tracking interface
*/
interface IUpsStatus {
export interface IUpsStatus {
id: string;
name: string;
powerStatus: 'online' | 'onBattery' | 'unknown';
batteryCapacity: number;
batteryRuntime: number;
outputLoad: number; // Load percentage (0-100%)
outputPower: number; // Power in watts
outputVoltage: number; // Voltage in volts
outputCurrent: number; // Current in amps
lastStatusChange: number;
lastCheckTime: number;
}
@@ -96,7 +118,7 @@ export class NupstDaemon {
/** Default configuration */
private readonly DEFAULT_CONFIG: INupstConfig = {
version: '4.1',
version: '4.2',
upsDevices: [
{
id: 'default',
@@ -123,8 +145,8 @@ export class NupstDaemon {
type: 'shutdown',
triggerMode: 'onlyThresholds',
thresholds: {
battery: 60, // Shutdown when battery below 60%
runtime: 20, // Shutdown when runtime below 20 minutes
battery: THRESHOLDS.DEFAULT_BATTERY_PERCENT, // Shutdown when battery below 60%
runtime: THRESHOLDS.DEFAULT_RUNTIME_MINUTES, // Shutdown when runtime below 20 minutes
},
shutdownDelay: 5,
},
@@ -132,13 +154,14 @@ export class NupstDaemon {
},
],
groups: [],
checkInterval: 30000, // Check every 30 seconds
checkInterval: TIMING.CHECK_INTERVAL_MS, // Check every 30 seconds
}
private config: INupstConfig;
private snmp: NupstSnmp;
private isRunning: boolean = false;
private upsStatus: Map<string, IUpsStatus> = new Map();
private httpServer?: NupstHttpServer;
/**
* Create a new daemon instance with the given SNMP manager
@@ -171,11 +194,13 @@ export class NupstDaemon {
const { config: migratedConfig, migrated } = await migrationRunner.run(parsedConfig);
// Save migrated config back to disk if any migrations ran
// Cast to INupstConfig since migrations ensure the output is valid
const validConfig = migratedConfig as unknown as INupstConfig;
if (migrated) {
this.config = migratedConfig;
this.config = validConfig;
await this.saveConfig(this.config);
} else {
this.config = migratedConfig;
this.config = validConfig;
}
return this.config;
@@ -258,24 +283,42 @@ export class NupstDaemon {
this.logConfigLoaded();
// 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
this.snmp.getNupst().checkForUpdates().then((updateAvailable: boolean) => {
if (updateAvailable) {
const updateStatus = this.snmp.getNupst().getUpdateStatus();
const boxWidth = 45;
logger.logBoxTitle('Update Available', boxWidth);
logger.logBoxLine(`Current Version: ${updateStatus.currentVersion}`);
logger.logBoxLine(`Latest Version: ${updateStatus.latestVersion}`);
logger.logBoxLine('Run "sudo nupst update" to update');
logger.logBoxEnd();
}
}).catch(() => {}); // Ignore errors checking for updates
// Check for updates in the background
nupst.checkForUpdates().then((updateAvailable: boolean) => {
if (updateAvailable) {
const updateStatus = nupst.getUpdateStatus();
const boxWidth = 45;
logger.logBoxTitle('Update Available', boxWidth);
logger.logBoxLine(`Current Version: ${updateStatus.currentVersion}`);
logger.logBoxLine(`Latest Version: ${updateStatus.latestVersion}`);
logger.logBoxLine('Run "sudo nupst update" to update');
logger.logBoxEnd();
}
}).catch(() => {}); // Ignore errors checking for updates
}
// Initialize UPS status tracking
this.initializeUpsStatus();
// Start HTTP server if configured
if (this.config.httpServer?.enabled && this.config.httpServer.authToken) {
try {
this.httpServer = new NupstHttpServer(
this.config.httpServer.port,
this.config.httpServer.path,
this.config.httpServer.authToken,
() => this.upsStatus
);
this.httpServer.start();
} catch (error) {
logger.error(`Failed to start HTTP server: ${error instanceof Error ? error.message : String(error)}`);
}
}
// Start UPS monitoring
this.isRunning = true;
await this.monitor();
@@ -302,6 +345,10 @@ export class NupstDaemon {
powerStatus: 'unknown',
batteryCapacity: 100,
batteryRuntime: 999, // High value as default
outputLoad: 0,
outputPower: 0,
outputVoltage: 0,
outputCurrent: 0,
lastStatusChange: Date.now(),
lastCheckTime: 0,
});
@@ -375,6 +422,12 @@ export class NupstDaemon {
*/
public stop(): void {
logger.log('Stopping NUPST daemon...');
// Stop HTTP server if running
if (this.httpServer) {
this.httpServer.stop();
}
this.isRunning = false;
}
@@ -392,7 +445,6 @@ export class NupstDaemon {
}
let lastLogTime = 0; // Track when we last logged status
const LOG_INTERVAL = 5 * 60 * 1000; // Log at least every 5 minutes (300000ms)
// Monitor continuously
while (this.isRunning) {
@@ -402,7 +454,7 @@ export class NupstDaemon {
// Log periodic status update
const currentTime = Date.now();
if (currentTime - lastLogTime >= LOG_INTERVAL) {
if (currentTime - lastLogTime >= TIMING.LOG_INTERVAL_MS) {
this.logAllUpsStatus();
lastLogTime = currentTime;
}
@@ -435,6 +487,10 @@ export class NupstDaemon {
powerStatus: 'unknown',
batteryCapacity: 100,
batteryRuntime: 999,
outputLoad: 0,
outputPower: 0,
outputVoltage: 0,
outputCurrent: 0,
lastStatusChange: Date.now(),
lastCheckTime: 0,
});
@@ -454,6 +510,10 @@ export class NupstDaemon {
powerStatus: status.powerStatus,
batteryCapacity: status.batteryCapacity,
batteryRuntime: status.batteryRuntime,
outputLoad: status.outputLoad,
outputPower: status.outputPower,
outputVoltage: status.outputVoltage,
outputCurrent: status.outputCurrent,
lastCheckTime: currentTime,
lastStatusChange: currentStatus?.lastStatusChange || currentTime,
};
@@ -732,21 +792,18 @@ export class NupstDaemon {
* Force immediate shutdown if any UPS gets critically low
*/
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();
logger.log('');
logger.logBoxTitle('Shutdown Monitoring Active', 60, 'warning');
logger.logBoxLine(`Emergency threshold: ${EMERGENCY_RUNTIME_THRESHOLD} minutes runtime`);
logger.logBoxLine(`Check interval: ${CHECK_INTERVAL / 1000} seconds`);
logger.logBoxLine(`Max monitoring time: ${MAX_MONITORING_TIME / 1000} seconds`);
logger.logBoxTitle('Shutdown Monitoring Active', UI.WIDE_BOX_WIDTH, 'warning');
logger.logBoxLine(`Emergency threshold: ${THRESHOLDS.EMERGENCY_RUNTIME_MINUTES} minutes runtime`);
logger.logBoxLine(`Check interval: ${TIMING.SHUTDOWN_CHECK_INTERVAL_MS / 1000} seconds`);
logger.logBoxLine(`Max monitoring time: ${TIMING.MAX_SHUTDOWN_MONITORING_MS / 1000} seconds`);
logger.logBoxEnd();
logger.log('');
// 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 {
logger.info('Checking UPS status during shutdown...');
@@ -760,7 +817,7 @@ export class NupstDaemon {
const rows: Array<Record<string, string>> = [];
let emergencyDetected = false;
let emergencyUps: any = null;
let emergencyUps: { ups: IUpsConfig; status: ISnmpUpsStatus } | null = null;
// Check all UPS devices
for (const ups of this.config.upsDevices) {
@@ -770,7 +827,7 @@ export class NupstDaemon {
const batteryColor = getBatteryColor(status.batteryCapacity);
const runtimeColor = getRuntimeColor(status.batteryRuntime);
const isCritical = status.batteryRuntime < EMERGENCY_RUNTIME_THRESHOLD;
const isCritical = status.batteryRuntime < THRESHOLDS.EMERGENCY_RUNTIME_MINUTES;
rows.push({
name: ups.name,
@@ -811,7 +868,7 @@ export class NupstDaemon {
logger.logBoxLine(
`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.logBoxEnd();
logger.log('');
@@ -822,14 +879,14 @@ export class NupstDaemon {
}
// Wait before checking again
await this.sleep(CHECK_INTERVAL);
await this.sleep(TIMING.SHUTDOWN_CHECK_INTERVAL_MS);
} catch (error) {
logger.error(
`Error monitoring UPS during shutdown: ${
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
*/
private async idleMonitoring(): Promise<void> {
const IDLE_CHECK_INTERVAL = 60000; // Check every 60 seconds
let lastConfigCheck = Date.now();
const CONFIG_CHECK_INTERVAL = 60000; // Check config every minute
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
this.watchConfigFile();
@@ -946,7 +1001,7 @@ export class NupstDaemon {
const currentTime = Date.now();
// Periodically check if config has been updated
if (currentTime - lastConfigCheck >= CONFIG_CHECK_INTERVAL) {
if (currentTime - lastConfigCheck >= TIMING.CONFIG_CHECK_INTERVAL_MS) {
try {
// Try to load config
const newConfig = await this.loadConfig();
@@ -966,12 +1021,12 @@ export class NupstDaemon {
lastConfigCheck = currentTime;
}
await this.sleep(IDLE_CHECK_INTERVAL);
await this.sleep(TIMING.IDLE_CHECK_INTERVAL_MS);
} catch (error) {
logger.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 './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
*
* @param config - Raw configuration object to check
* @param config - Raw configuration object to check (unknown schema for migrations)
* @returns True if migration should run, false otherwise
*/
abstract shouldRun(config: any): Promise<boolean>;
abstract shouldRun(config: Record<string, unknown>): Promise<boolean>;
/**
* Perform the migration on the given config
*
* @param config - Raw configuration object to migrate
* @param config - Raw configuration object to migrate (unknown schema for migrations)
* @returns Migrated configuration object
*/
abstract migrate(config: any): Promise<any>;
abstract migrate(config: Record<string, unknown>): Promise<Record<string, unknown>>;
/**
* Get human-readable name for this migration

View File

@@ -32,7 +32,9 @@ export class MigrationRunner {
* @param config - Raw configuration object to migrate
* @returns Migrated configuration and whether migrations ran
*/
async run(config: any): Promise<{ config: any; migrated: boolean }> {
async run(
config: Record<string, unknown>,
): Promise<{ config: Record<string, unknown>; migrated: boolean }> {
let currentConfig = config;
let anyMigrationsRan = false;

View File

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

View File

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

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 type { IOidSet, ISnmpConfig, IUpsStatus, TUpsModel } from './types.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
@@ -10,18 +13,18 @@ import { UpsOidSets } from './oid-sets.ts';
export class NupstSnmp {
// Active OID set
private activeOIDs: IOidSet;
// Reference to the parent Nupst instance
private nupst: any; // Type 'any' to avoid circular dependency
// Reference to the parent Nupst instance (uses interface to avoid circular dependency)
private nupst: INupstAccessor | null = null;
// Debug mode flag
private debug: boolean = false;
// Default SNMP configuration
private readonly DEFAULT_CONFIG: ISnmpConfig = {
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
version: 1, // SNMPv1
timeout: 5000, // 5 seconds timeout
timeout: SNMP.DEFAULT_TIMEOUT_MS, // 5 seconds timeout
upsModel: 'cyberpower', // Default UPS model
};
@@ -39,14 +42,14 @@ export class NupstSnmp {
* Set 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;
}
/**
* Get reference to the main Nupst instance
*/
public getNupst(): any {
public getNupst(): INupstAccessor | null {
return this.nupst;
}
@@ -55,7 +58,7 @@ export class NupstSnmp {
*/
public enableDebug(): void {
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) {
this.activeOIDs = config.customOIDs;
if (this.debug) {
console.log('Using custom OIDs:', this.activeOIDs);
logger.dim(`Using custom OIDs: ${JSON.stringify(this.activeOIDs)}`);
}
return;
}
@@ -77,7 +80,7 @@ export class NupstSnmp {
this.activeOIDs = UpsOidSets.getOidSet(model);
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> {
return new Promise((resolve, reject) => {
if (this.debug) {
console.log(
logger.dim(
`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
const options: any = {
port: config.port,
retries: 2, // Number of retries
retries: SNMP.RETRIES, // Number of retries
timeout: config.timeout,
transport: 'udp4',
idBitsSize: 32,
@@ -151,7 +154,7 @@ export class NupstSnmp {
// Fallback to noAuthNoPriv if auth details missing
user.level = snmp.SecurityLevel.noAuthNoPriv;
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') {
@@ -178,29 +181,23 @@ export class NupstSnmp {
// Fallback to authNoPriv if priv details missing
user.level = snmp.SecurityLevel.authNoPriv;
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 {
// Fallback to noAuthNoPriv if auth details missing
user.level = snmp.SecurityLevel.noAuthNoPriv;
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) {
console.log('SNMPv3 user configuration:', {
name: user.name,
level: Object.keys(snmp.SecurityLevel).find((key) =>
snmp.SecurityLevel[key] === user.level
),
authProtocol: user.authProtocol ? 'Set' : 'Not Set',
authKey: user.authKey ? 'Set' : 'Not Set',
privProtocol: user.privProtocol ? 'Set' : 'Not Set',
privKey: user.privKey ? 'Set' : 'Not Set',
});
const levelName = Object.keys(snmp.SecurityLevel).find((key) =>
snmp.SecurityLevel[key] === user.level
);
logger.dim(`SNMPv3 user configuration: name=${user.name}, level=${levelName}, authProtocol=${user.authProtocol ? 'Set' : 'Not Set'}, privProtocol=${user.privProtocol ? 'Set' : 'Not Set'}`);
}
session = snmp.createV3Session(config.host, user, options);
@@ -219,7 +216,7 @@ export class NupstSnmp {
if (error) {
if (this.debug) {
console.error('SNMP GET error:', error);
logger.error(`SNMP GET error: ${error}`);
}
reject(new Error(`SNMP GET error: ${error.message || error}`));
return;
@@ -227,7 +224,7 @@ export class NupstSnmp {
if (!varbinds || varbinds.length === 0) {
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'));
return;
@@ -240,7 +237,7 @@ export class NupstSnmp {
varbinds[0].type === snmp.ObjectType.EndOfMibView
) {
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]}`));
return;
@@ -262,11 +259,7 @@ export class NupstSnmp {
}
if (this.debug) {
console.log('SNMP response:', {
oid: varbinds[0].oid,
type: varbinds[0].type,
value: value,
});
logger.dim(`SNMP response: oid=${varbinds[0].oid}, type=${varbinds[0].type}, value=${value}`);
}
resolve(value);
@@ -285,26 +278,30 @@ export class NupstSnmp {
this.setActiveOIDs(config);
if (this.debug) {
console.log('---------------------------------------');
console.log('Getting UPS status with config:');
console.log(' Host:', config.host);
console.log(' Port:', config.port);
console.log(' Version:', config.version);
console.log(' Timeout:', config.timeout, 'ms');
console.log(' UPS Model:', config.upsModel || 'cyberpower');
logger.dim('---------------------------------------');
logger.dim('Getting UPS status with config:');
logger.dim(` Host: ${config.host}`);
logger.dim(` Port: ${config.port}`);
logger.dim(` Version: ${config.version}`);
logger.dim(` Timeout: ${config.timeout} ms`);
logger.dim(` UPS Model: ${config.upsModel || 'cyberpower'}`);
if (config.version === 1 || config.version === 2) {
console.log(' Community:', config.community);
logger.dim(` Community: ${config.community}`);
} else if (config.version === 3) {
console.log(' Security Level:', config.securityLevel);
console.log(' Username:', config.username);
console.log(' Auth Protocol:', config.authProtocol || 'None');
console.log(' Privacy Protocol:', config.privProtocol || 'None');
logger.dim(` Security Level: ${config.securityLevel}`);
logger.dim(` Username: ${config.username}`);
logger.dim(` Auth Protocol: ${config.authProtocol || 'None'}`);
logger.dim(` Privacy Protocol: ${config.privProtocol || 'None'}`);
}
console.log('Using OIDs:');
console.log(' Power Status:', this.activeOIDs.POWER_STATUS);
console.log(' Battery Capacity:', this.activeOIDs.BATTERY_CAPACITY);
console.log(' Battery Runtime:', this.activeOIDs.BATTERY_RUNTIME);
console.log('---------------------------------------');
logger.dim('Using OIDs:');
logger.dim(` Power Status: ${this.activeOIDs.POWER_STATUS}`);
logger.dim(` Battery Capacity: ${this.activeOIDs.BATTERY_CAPACITY}`);
logger.dim(` Battery Runtime: ${this.activeOIDs.BATTERY_RUNTIME}`);
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
@@ -324,41 +321,89 @@ export class NupstSnmp {
config,
) || 0;
// Get power draw metrics
const outputLoad = await this.getSNMPValueWithRetry(
this.activeOIDs.OUTPUT_LOAD,
'output load',
config,
) || 0;
const outputPower = await this.getSNMPValueWithRetry(
this.activeOIDs.OUTPUT_POWER,
'output power',
config,
) || 0;
const outputVoltage = await this.getSNMPValueWithRetry(
this.activeOIDs.OUTPUT_VOLTAGE,
'output voltage',
config,
) || 0;
const outputCurrent = await this.getSNMPValueWithRetry(
this.activeOIDs.OUTPUT_CURRENT,
'output current',
config,
) || 0;
// Determine power status - handle different values for different UPS models
const powerStatus = this.determinePowerStatus(config.upsModel, powerStatusValue);
// Convert to minutes for UPS models with different time units
const processedRuntime = this.processRuntimeValue(config.upsModel, batteryRuntime);
// Process power metrics with vendor-specific scaling
const processedVoltage = this.processVoltageValue(config.upsModel, outputVoltage);
const processedCurrent = this.processCurrentValue(config.upsModel, outputCurrent);
// Calculate power from voltage × current if not provided by UPS
let processedPower = outputPower;
if (outputPower === 0 && processedVoltage > 0 && processedCurrent > 0) {
processedPower = Math.round(processedVoltage * processedCurrent);
if (this.debug) {
logger.dim(
`Calculated power from V×I: ${processedVoltage}V × ${processedCurrent}A = ${processedPower}W`,
);
}
}
const result = {
powerStatus,
batteryCapacity,
batteryRuntime: processedRuntime,
outputLoad,
outputPower: processedPower,
outputVoltage: processedVoltage,
outputCurrent: processedCurrent,
raw: {
powerStatus: powerStatusValue,
batteryCapacity,
batteryRuntime,
outputLoad,
outputPower,
outputVoltage,
outputCurrent,
},
};
if (this.debug) {
console.log('---------------------------------------');
console.log('UPS status result:');
console.log(' Power Status:', result.powerStatus);
console.log(' Battery Capacity:', result.batteryCapacity + '%');
console.log(' Battery Runtime:', result.batteryRuntime, 'minutes');
console.log('---------------------------------------');
logger.dim('---------------------------------------');
logger.dim('UPS status result:');
logger.dim(` Power Status: ${result.powerStatus}`);
logger.dim(` Battery Capacity: ${result.batteryCapacity}%`);
logger.dim(` Battery Runtime: ${result.batteryRuntime} minutes`);
logger.dim(` Output Load: ${result.outputLoad}%`);
logger.dim(` Output Power: ${result.outputPower} watts`);
logger.dim(` Output Voltage: ${result.outputVoltage} volts`);
logger.dim(` Output Current: ${result.outputCurrent} amps`);
logger.dim('---------------------------------------');
}
return result;
} catch (error) {
if (this.debug) {
console.error('---------------------------------------');
console.error(
'Error getting UPS status:',
error instanceof Error ? error.message : String(error),
logger.error('---------------------------------------');
logger.error(
`Error getting UPS status: ${error instanceof Error ? error.message : String(error)}`,
);
console.error('---------------------------------------');
logger.error('---------------------------------------');
}
throw new Error(
`Failed to get UPS status: ${error instanceof Error ? error.message : String(error)}`,
@@ -380,26 +425,25 @@ export class NupstSnmp {
): Promise<any> {
if (oid === '') {
if (this.debug) {
console.log(`No OID provided for ${description}, skipping`);
logger.dim(`No OID provided for ${description}, skipping`);
}
return 0;
}
if (this.debug) {
console.log(`Getting ${description} OID: ${oid}`);
logger.dim(`Getting ${description} OID: ${oid}`);
}
try {
const value = await this.snmpGet(oid, config);
if (this.debug) {
console.log(`${description} value:`, value);
logger.dim(`${description} value: ${value}`);
}
return value;
} catch (error) {
if (this.debug) {
console.error(
`Error getting ${description}:`,
error instanceof Error ? error.message : String(error),
logger.error(
`Error getting ${description}: ${error instanceof Error ? error.message : String(error)}`,
);
}
@@ -415,7 +459,7 @@ export class NupstSnmp {
// Return a default value if all attempts fail
if (this.debug) {
console.log(`Using default value 0 for ${description}`);
logger.dim(`Using default value 0 for ${description}`);
}
return 0;
}
@@ -434,7 +478,7 @@ export class NupstSnmp {
config: ISnmpConfig,
): Promise<any> {
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
@@ -442,18 +486,17 @@ export class NupstSnmp {
const retryConfig = { ...config, securityLevel: 'authNoPriv' as 'authNoPriv' };
try {
if (this.debug) {
console.log(`Retrying with authNoPriv security level`);
logger.dim(`Retrying with authNoPriv security level`);
}
const value = await this.snmpGet(oid, retryConfig);
if (this.debug) {
console.log(`${description} retry value:`, value);
logger.dim(`${description} retry value: ${value}`);
}
return value;
} catch (retryError) {
if (this.debug) {
console.error(
`Retry failed for ${description}:`,
retryError instanceof Error ? retryError.message : String(retryError),
logger.error(
`Retry failed for ${description}: ${retryError instanceof Error ? retryError.message : String(retryError)}`,
);
}
}
@@ -464,18 +507,17 @@ export class NupstSnmp {
const retryConfig = { ...config, securityLevel: 'noAuthNoPriv' as 'noAuthNoPriv' };
try {
if (this.debug) {
console.log(`Retrying with noAuthNoPriv security level`);
logger.dim(`Retrying with noAuthNoPriv security level`);
}
const value = await this.snmpGet(oid, retryConfig);
if (this.debug) {
console.log(`${description} retry value:`, value);
logger.dim(`${description} retry value: ${value}`);
}
return value;
} catch (retryError) {
if (this.debug) {
console.error(
`Retry failed for ${description}:`,
retryError instanceof Error ? retryError.message : String(retryError),
logger.error(
`Retry failed for ${description}: ${retryError instanceof Error ? retryError.message : String(retryError)}`,
);
}
}
@@ -501,21 +543,20 @@ export class NupstSnmp {
const standardOIDs = UpsOidSets.getStandardOids();
if (this.debug) {
console.log(
logger.dim(
`Trying standard RFC 1628 OID for ${description}: ${standardOIDs[description]}`,
);
}
const standardValue = await this.snmpGet(standardOIDs[description], config);
if (this.debug) {
console.log(`${description} standard OID value:`, standardValue);
logger.dim(`${description} standard OID value: ${standardValue}`);
}
return standardValue;
} catch (stdError) {
if (this.debug) {
console.error(
`Standard OID retry failed for ${description}:`,
stdError instanceof Error ? stdError.message : String(stdError),
logger.error(
`Standard OID retry failed for ${description}: ${stdError instanceof Error ? stdError.message : String(stdError)}`,
);
}
}
@@ -570,14 +611,14 @@ export class NupstSnmp {
batteryRuntime: number,
): number {
if (this.debug) {
console.log('Raw runtime value:', batteryRuntime);
logger.dim(`Raw runtime value: ${batteryRuntime}`);
}
if (upsModel === 'cyberpower' && batteryRuntime > 0) {
// CyberPower: TimeTicks is in 1/100 seconds, convert to minutes
const minutes = Math.floor(batteryRuntime / 6000); // 6000 ticks = 1 minute
if (this.debug) {
console.log(
logger.dim(
`Converting CyberPower runtime from ${batteryRuntime} ticks to ${minutes} minutes`,
);
}
@@ -586,7 +627,7 @@ export class NupstSnmp {
// Eaton: Runtime is in seconds, convert to minutes
const minutes = Math.floor(batteryRuntime / 60);
if (this.debug) {
console.log(
logger.dim(
`Converting Eaton runtime from ${batteryRuntime} seconds to ${minutes} minutes`,
);
}
@@ -595,11 +636,81 @@ export class NupstSnmp {
// Generic conversion for large tick values (likely TimeTicks)
const minutes = Math.floor(batteryRuntime / 6000);
if (this.debug) {
console.log(`Converting ${batteryRuntime} ticks to ${minutes} minutes`);
logger.dim(`Converting ${batteryRuntime} ticks to ${minutes} minutes`);
}
return minutes;
}
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
BATTERY_CAPACITY: '1.3.6.1.4.1.3808.1.1.1.2.2.1.0', // upsAdvanceBatteryCapacity (percentage)
BATTERY_RUNTIME: '1.3.6.1.4.1.3808.1.1.1.2.2.4.0', // upsAdvanceBatteryRunTimeRemaining (TimeTicks)
OUTPUT_LOAD: '1.3.6.1.4.1.3808.1.1.1.4.2.3.0', // upsAdvanceOutputLoad (percentage)
OUTPUT_POWER: '1.3.6.1.4.1.3808.1.1.1.4.2.5.0', // upsAdvanceOutputPower (watts)
OUTPUT_VOLTAGE: '1.3.6.1.4.1.3808.1.1.1.4.2.1.0', // upsAdvanceOutputVoltage (0.1V scale)
OUTPUT_CURRENT: '1.3.6.1.4.1.3808.1.1.1.4.2.4.0', // upsAdvanceOutputCurrent (0.1A scale)
POWER_STATUS_VALUES: {
online: 2, // upsBaseOutputStatus: 2=onLine
onBattery: 3, // upsBaseOutputStatus: 3=onBattery
},
},
// APC OIDs
// APC OIDs (PowerNet MIB)
apc: {
POWER_STATUS: '1.3.6.1.4.1.318.1.1.1.4.1.1.0', // upsBasicOutputStatus
BATTERY_CAPACITY: '1.3.6.1.4.1.318.1.1.1.2.2.1.0', // Battery capacity in percentage
BATTERY_RUNTIME: '1.3.6.1.4.1.318.1.1.1.2.2.3.0', // Remaining runtime in minutes
OUTPUT_LOAD: '1.3.6.1.4.1.318.1.1.1.4.2.3.0', // upsAdvOutputLoad (percentage)
OUTPUT_POWER: '1.3.6.1.4.1.318.1.1.1.4.2.8.0', // upsAdvOutputActivePower (watts)
OUTPUT_VOLTAGE: '1.3.6.1.4.1.318.1.1.1.4.2.1.0', // upsAdvOutputVoltage
OUTPUT_CURRENT: '1.3.6.1.4.1.318.1.1.1.4.2.4.0', // upsAdvOutputCurrent
POWER_STATUS_VALUES: {
online: 2, // upsBasicOutputStatus: 2=onLine
onBattery: 3, // upsBasicOutputStatus: 3=onBattery
},
},
// Eaton OIDs
// Eaton OIDs (XUPS-MIB)
eaton: {
POWER_STATUS: '1.3.6.1.4.1.534.1.4.4.0', // xupsOutputSource
BATTERY_CAPACITY: '1.3.6.1.4.1.534.1.2.4.0', // xupsBatCapacity (percentage)
BATTERY_RUNTIME: '1.3.6.1.4.1.534.1.2.1.0', // xupsBatTimeRemaining (seconds)
OUTPUT_LOAD: '1.3.6.1.4.1.534.1.4.4.1.8.1', // xupsOutputPercentLoad (phase 1)
OUTPUT_POWER: '1.3.6.1.4.1.534.1.4.4.1.4.1', // xupsOutputWatts (phase 1)
OUTPUT_VOLTAGE: '1.3.6.1.4.1.534.1.4.4.1.2.1', // xupsOutputVoltage (phase 1)
OUTPUT_CURRENT: '1.3.6.1.4.1.534.1.4.4.1.3.1', // xupsOutputCurrent (phase 1)
POWER_STATUS_VALUES: {
online: 3, // xupsOutputSource: 3=normal (mains power)
onBattery: 5, // xupsOutputSource: 5=battery
@@ -47,6 +59,10 @@ export class UpsOidSets {
POWER_STATUS: '1.3.6.1.4.1.850.1.1.3.1.1.1.0', // tlUpsOutputSource
BATTERY_CAPACITY: '1.3.6.1.4.1.850.1.1.3.2.4.1.0', // Battery capacity in percentage
BATTERY_RUNTIME: '1.3.6.1.4.1.850.1.1.3.2.2.1.0', // Remaining runtime in minutes
OUTPUT_LOAD: '1.3.6.1.2.1.33.1.4.4.1.5.1', // RFC 1628: upsOutputPercentLoad
OUTPUT_POWER: '1.3.6.1.2.1.33.1.4.4.1.4.1', // RFC 1628: upsOutputPower (watts)
OUTPUT_VOLTAGE: '1.3.6.1.2.1.33.1.4.4.1.2.1', // RFC 1628: upsOutputVoltage
OUTPUT_CURRENT: '1.3.6.1.2.1.33.1.4.4.1.3.1', // RFC 1628: upsOutputCurrent (0.1A scale)
POWER_STATUS_VALUES: {
online: 2, // tlUpsOutputSource: 2=normal (mains power)
onBattery: 3, // tlUpsOutputSource: 3=onBattery
@@ -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
BATTERY_CAPACITY: '1.3.6.1.4.1.476.1.42.3.9.20.1.20.1.2.1.4.1', // Battery capacity in percentage
BATTERY_RUNTIME: '1.3.6.1.4.1.476.1.42.3.9.20.1.20.1.2.1.5.1', // Remaining runtime in minutes
OUTPUT_LOAD: '1.3.6.1.2.1.33.1.4.4.1.5.1', // RFC 1628: upsOutputPercentLoad
OUTPUT_POWER: '1.3.6.1.2.1.33.1.4.4.1.4.1', // RFC 1628: upsOutputPower (watts)
OUTPUT_VOLTAGE: '1.3.6.1.2.1.33.1.4.4.1.2.1', // RFC 1628: upsOutputVoltage
OUTPUT_CURRENT: '1.3.6.1.2.1.33.1.4.4.1.3.1', // RFC 1628: upsOutputCurrent (0.1A scale)
POWER_STATUS_VALUES: {
online: 2, // lgpPwrOutputSource: 2=normal (mains power)
onBattery: 3, // lgpPwrOutputSource: 3=onBattery
@@ -69,6 +89,10 @@ export class UpsOidSets {
POWER_STATUS: '',
BATTERY_CAPACITY: '',
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
'battery capacity': '1.3.6.1.2.1.33.1.2.4.0', // upsEstimatedChargeRemaining
'battery runtime': '1.3.6.1.2.1.33.1.2.3.0', // upsEstimatedMinutesRemaining
'output load': '1.3.6.1.2.1.33.1.4.4.1.5.1', // upsOutputPercentLoad (indexed by line)
'output power': '1.3.6.1.2.1.33.1.4.4.1.4.1', // upsOutputPower in watts (indexed by line)
'output voltage': '1.3.6.1.2.1.33.1.4.4.1.2.1', // upsOutputVoltage (indexed by line)
'output current': '1.3.6.1.2.1.33.1.4.4.1.3.1', // upsOutputCurrent in 0.1A (indexed by line)
};
}
}

View File

@@ -14,6 +14,14 @@ export interface IUpsStatus {
batteryCapacity: number;
/** Remaining runtime in minutes */
batteryRuntime: number;
/** Output load percentage (0-100) */
outputLoad: number;
/** Output power in watts */
outputPower: number;
/** Output voltage in volts */
outputVoltage: number;
/** Output current in amps */
outputCurrent: number;
/** Raw values from SNMP responses */
raw: Record<string, any>;
}
@@ -28,6 +36,14 @@ export interface IOidSet {
BATTERY_CAPACITY: string;
/** OID for battery runtime */
BATTERY_RUNTIME: string;
/** OID for output load percentage */
OUTPUT_LOAD: string;
/** OID for output power in watts */
OUTPUT_POWER: string;
/** OID for output voltage */
OUTPUT_VOLTAGE: string;
/** OID for output current */
OUTPUT_CURRENT: string;
/** Power status value mappings */
POWER_STATUS_VALUES?: {
/** SNMP value that indicates UPS is online (on AC power) */

View File

@@ -1,7 +1,8 @@
import process from 'node:process';
import { promises as fs } from 'node:fs';
import { execSync } from 'node:child_process';
import { NupstDaemon } from './daemon.ts';
import { NupstDaemon, type IUpsConfig } from './daemon.ts';
import { NupstSnmp } from './snmp/manager.ts';
import { logger } from './logger.ts';
import { theme, symbols, getBatteryColor, getRuntimeColor, formatPowerStatus } from './colors.ts';
@@ -141,11 +142,14 @@ WantedBy=multi-user.target
private async displayVersionInfo(): Promise<void> {
try {
const nupst = this.daemon.getNupstSnmp().getNupst();
if (!nupst) {
return;
}
const version = nupst.getVersion();
// Check for updates
const updateAvailable = await nupst.checkForUpdates();
// Display version info
if (updateAvailable) {
const updateStatus = nupst.getUpdateStatus();
@@ -160,13 +164,15 @@ WantedBy=multi-user.target
`${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
try {
const nupst = this.daemon.getNupstSnmp().getNupst();
const version = nupst.getVersion();
logger.log('');
logger.log(`${theme.dim('NUPST')} ${theme.dim('v' + version)}`);
if (nupst) {
const version = nupst.getVersion();
logger.log('');
logger.log(`${theme.dim('NUPST')} ${theme.dim('v' + version)}`);
}
} catch (_innerError) {
// Silently fail if we can't even get the version
}
@@ -276,15 +282,27 @@ WantedBy=multi-user.target
for (const ups of config.upsDevices) {
await this.displaySingleUpsStatus(ups, snmp);
}
// Display groups after UPS devices
this.displayGroupsStatus();
} else if (config.snmp) {
// Legacy single UPS configuration
// Legacy single UPS configuration (v1/v2 format)
logger.info('UPS Devices (1):');
const legacyUps = {
const legacyUps: IUpsConfig = {
id: 'default',
name: 'Default UPS',
snmp: config.snmp,
thresholds: config.thresholds,
groups: [],
actions: config.thresholds
? [
{
type: 'shutdown',
thresholds: config.thresholds,
triggerMode: 'onlyThresholds',
shutdownDelay: 5,
},
]
: [],
};
await this.displaySingleUpsStatus(legacyUps, snmp);
@@ -307,7 +325,7 @@ WantedBy=multi-user.target
* @param ups UPS configuration
* @param snmp SNMP manager
*/
private async displaySingleUpsStatus(ups: any, snmp: any): Promise<void> {
private async displaySingleUpsStatus(ups: IUpsConfig, snmp: NupstSnmp): Promise<void> {
try {
// Create a test config with a short timeout
const testConfig = {
@@ -332,7 +350,7 @@ WantedBy=multi-user.target
const batteryColor = getBatteryColor(status.batteryCapacity);
// 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 batterySymbol = batteryThreshold !== undefined && status.batteryCapacity >= batteryThreshold
? symbols.success
@@ -342,6 +360,9 @@ WantedBy=multi-user.target
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
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(', ')}`)}`);
}
// Display actions if any
if (ups.actions && ups.actions.length > 0) {
for (const action of ups.actions) {
let actionDesc = `${action.type}`;
if (action.thresholds) {
actionDesc += ` (${action.triggerMode || 'onlyThresholds'}: battery<${action.thresholds.battery}%, runtime<${action.thresholds.runtime}min`;
if (action.shutdownDelay) {
actionDesc += `, delay=${action.shutdownDelay}s`;
}
actionDesc += ')';
} else {
actionDesc += ` (${action.triggerMode || 'onlyPowerChanges'}`;
if (action.shutdownDelay) {
actionDesc += `, delay=${action.shutdownDelay}s`;
}
actionDesc += ')';
}
logger.log(` ${theme.dim('Action:')} ${theme.info(actionDesc)}`);
}
}
logger.log('');
} catch (error) {
@@ -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
* @throws Error if disabling fails