feat(cli,snmp): fix APC runtime unit defaults and add interactive action editing
This commit is contained in:
@@ -10,7 +10,7 @@ import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
import { existsSync } from 'fs';
|
||||
import { arch, platform } from 'os';
|
||||
import process from "node:process";
|
||||
import process from 'node:process';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
130
changelog.md
130
changelog.md
@@ -1,64 +1,108 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-04-16 - 5.10.0 - feat(cli,snmp)
|
||||
fix APC runtime unit defaults and add interactive action editing
|
||||
|
||||
- correct APC PowerNet runtime handling to use TimeTicks-based conversion and update default runtime unit selection for APC devices
|
||||
- add an action edit command for UPS and group actions so existing actions can be updated interactively
|
||||
- introduce a v4.3 to v4.4 config migration to correct APC runtimeUnit values in existing configurations
|
||||
|
||||
## 2026-04-16 - 5.9.0 - feat(cli,snmp)
|
||||
|
||||
fix APC runtime defaults and add interactive action editing
|
||||
|
||||
- Correct APC PowerNet runtime handling to use TimeTicks-based conversion, update runtime-unit
|
||||
defaults, and add a v4.3 to v4.4 migration for existing configs.
|
||||
- Add `nupst action edit <target-id> <index>` so UPS and group actions can be updated without
|
||||
hand-editing `config.json`.
|
||||
- Document config hot-reload behavior, APC runtime guidance, and the lack of cross-node action
|
||||
coordination when reusing configs on multiple machines.
|
||||
|
||||
## 2026-04-16 - 5.8.0 - feat(systemd)
|
||||
|
||||
improve service status reporting with structured systemctl data
|
||||
|
||||
- switch status collection from parsing `systemctl status` output to `systemctl show` properties for more reliable service state detection
|
||||
- switch status collection from parsing `systemctl status` output to `systemctl show` properties for
|
||||
more reliable service state detection
|
||||
- display a distinct "not installed" status when the unit is missing
|
||||
- format systemd memory and CPU usage values into readable output for status details
|
||||
|
||||
## 2026-04-16 - 5.7.0 - feat(monitoring)
|
||||
|
||||
add edge-triggered threshold handling with group action orchestration and HA-aware Proxmox shutdowns
|
||||
|
||||
- Track per-action threshold entry state so threshold-based actions fire only when conditions are newly violated
|
||||
- Add group monitoring and threshold evaluation for redundant and non-redundant UPS groups, including suppression of destructive actions when members are unreachable
|
||||
- Support optional Proxmox HA stop requests for HA-managed guests and prevent duplicate Proxmox or host shutdown scheduling
|
||||
- Track per-action threshold entry state so threshold-based actions fire only when conditions are
|
||||
newly violated
|
||||
- Add group monitoring and threshold evaluation for redundant and non-redundant UPS groups,
|
||||
including suppression of destructive actions when members are unreachable
|
||||
- Support optional Proxmox HA stop requests for HA-managed guests and prevent duplicate Proxmox or
|
||||
host shutdown scheduling
|
||||
|
||||
## 2026-04-14 - 5.6.0 - feat(config)
|
||||
|
||||
add configurable default shutdown delay for shutdown actions
|
||||
|
||||
- introduces a top-level defaultShutdownDelay config value used by shutdown actions that do not define their own delay
|
||||
- applies the configured default during action execution, daemon-initiated shutdowns, CLI prompts, and status display output
|
||||
- preserves explicit shutdownDelay values including 0 minutes and normalizes invalid config values back to the built-in default
|
||||
- introduces a top-level defaultShutdownDelay config value used by shutdown actions that do not
|
||||
define their own delay
|
||||
- applies the configured default during action execution, daemon-initiated shutdowns, CLI prompts,
|
||||
and status display output
|
||||
- preserves explicit shutdownDelay values including 0 minutes and normalizes invalid config values
|
||||
back to the built-in default
|
||||
|
||||
## 2026-04-14 - 5.5.1 - fix(cli,daemon,snmp)
|
||||
|
||||
normalize CLI argument parsing and extract daemon monitoring helpers with stronger SNMP typing
|
||||
|
||||
- Pass runtime arguments directly to the CLI in both Deno and Node entrypoints so commands and debug flags are parsed consistently
|
||||
- Refactor daemon logic into dedicated pause state, config watch, UPS status, monitoring, action orchestration, shutdown execution, and shutdown monitoring modules
|
||||
- Add explicit local typings and value coercion around net-snmp interactions to reduce untyped response handling
|
||||
- Update user-facing CLI guidance to use current subcommands such as "nupst ups add", "nupst ups edit", and "nupst service start"
|
||||
- Pass runtime arguments directly to the CLI in both Deno and Node entrypoints so commands and debug
|
||||
flags are parsed consistently
|
||||
- Refactor daemon logic into dedicated pause state, config watch, UPS status, monitoring, action
|
||||
orchestration, shutdown execution, and shutdown monitoring modules
|
||||
- Add explicit local typings and value coercion around net-snmp interactions to reduce untyped
|
||||
response handling
|
||||
- Update user-facing CLI guidance to use current subcommands such as "nupst ups add", "nupst ups
|
||||
edit", and "nupst service start"
|
||||
- Expand test coverage for extracted monitoring and pause-state helpers
|
||||
|
||||
## 2026-04-02 - 5.5.0 - feat(proxmox)
|
||||
|
||||
add Proxmox CLI auto-detection and interactive action setup improvements
|
||||
|
||||
- Add Proxmox action support for CLI mode using qm/pct with automatic fallback to REST API mode
|
||||
- Expose proxmoxMode configuration and update CLI wizards to auto-detect local Proxmox tools before prompting for API credentials
|
||||
- Expand interactive action creation to support shutdown, webhook, script, and Proxmox actions with improved displayed details
|
||||
- Expose proxmoxMode configuration and update CLI wizards to auto-detect local Proxmox tools before
|
||||
prompting for API credentials
|
||||
- Expand interactive action creation to support shutdown, webhook, script, and Proxmox actions with
|
||||
improved displayed details
|
||||
- Update documentation to cover Proxmox CLI/API modes and clarify shutdown delay units in minutes
|
||||
|
||||
## 2026-03-30 - 5.4.1 - fix(deps)
|
||||
|
||||
bump tsdeno and net-snmp patch dependencies
|
||||
|
||||
- update @git.zone/tsdeno from ^1.2.0 to ^1.3.1
|
||||
- update net-snmp import from 3.26.0 to 3.26.1 in the SNMP manager
|
||||
|
||||
## 2026-03-30 - 5.4.0 - feat(snmp)
|
||||
|
||||
add configurable SNMP runtime units with v4.3 migration support
|
||||
|
||||
- Adds explicit `runtimeUnit` support for SNMP devices with `minutes`, `seconds`, and `ticks` options.
|
||||
- Adds explicit `runtimeUnit` support for SNMP devices with `minutes`, `seconds`, and `ticks`
|
||||
options.
|
||||
- Updates runtime processing to prefer configured units over UPS model heuristics.
|
||||
- Introduces a v4.2 to v4.3 migration that populates `runtimeUnit` for existing SNMP device configs based on `upsModel`.
|
||||
- Extends the CLI setup and device summary output to configure and display the selected runtime unit.
|
||||
- Updates default config version to 4.3 and documents the new SNMP runtime unit setting in the README.
|
||||
- Introduces a v4.2 to v4.3 migration that populates `runtimeUnit` for existing SNMP device configs
|
||||
based on `upsModel`.
|
||||
- Extends the CLI setup and device summary output to configure and display the selected runtime
|
||||
unit.
|
||||
- Updates default config version to 4.3 and documents the new SNMP runtime unit setting in the
|
||||
README.
|
||||
|
||||
## 2026-03-18 - 5.3.3 - fix(deps)
|
||||
|
||||
add @git.zone/tsdeno as a development dependency
|
||||
|
||||
- Adds @git.zone/tsdeno@^1.2.0 to devDependencies in package.json.
|
||||
|
||||
## 2026-03-18 - 5.3.2 - fix(build)
|
||||
|
||||
replace manual release compilation workflows with tsdeno-based build configuration
|
||||
|
||||
- removes obsolete CI and npm publish workflows
|
||||
@@ -68,6 +112,7 @@ replace manual release compilation workflows with tsdeno-based build configurati
|
||||
- deletes the custom compile-all.sh script in favor of centralized build tooling
|
||||
|
||||
## 2026-03-15 - 5.3.1 - fix(cli)
|
||||
|
||||
rename the update command references to upgrade across the CLI and documentation
|
||||
|
||||
- Updates command parsing and help output to use `upgrade` instead of `update`.
|
||||
@@ -75,40 +120,61 @@ rename the update command references to upgrade across the CLI and documentation
|
||||
- Aligns README and command migration documentation with the renamed command.
|
||||
|
||||
## 2026-02-20 - 5.3.0 - feat(daemon)
|
||||
Add UPSD (NUT) protocol support, Proxmox VM shutdown action, pause/resume monitoring, and network-loss/unreachable handling; bump config version to 4.2
|
||||
|
||||
- Add UPSD client (ts/upsd) and ProtocolResolver (ts/protocol) to support protocol-agnostic UPS queries (snmp or upsd).
|
||||
- Introduce new TProtocol and IUpsdConfig types, wire up Nupst to initialize & expose UPSD client, and route status requests through ProtocolResolver.
|
||||
- Add 'unreachable' TPowerStatus plus consecutiveFailures and unreachableSince tracking; mark UPS as unreachable after NETWORK.CONSECUTIVE_FAILURE_THRESHOLD failures and suppress shutdown actions while unreachable.
|
||||
- Implement pause/resume feature: PAUSE.FILE_PATH state file, CLI commands (pause/resume), daemon pause-state polling, auto-resume, and include pause state in HTTP API responses.
|
||||
- Add ProxmoxAction (ts/actions/proxmox-action.ts) with Proxmox API interaction, configuration options (token, node, timeout, force, insecure) and CLI prompts to configure proxmox actions.
|
||||
- CLI and UI updates: protocol selection when adding UPS, protocol/host shown in lists, action details column supports proxmox, and status displays include protocol and unreachable state.
|
||||
- Add migration MigrationV4_1ToV4_2 to set protocol:'snmp' for existing devices and bump config.version to '4.2'.
|
||||
- Add new constants (NETWORK, UPSD, PAUSE, PROXMOX), update package.json scripts (test/build/lint/format), and wire protocol support across daemon, systemd, http-server, and various handlers.
|
||||
Add UPSD (NUT) protocol support, Proxmox VM shutdown action, pause/resume monitoring, and
|
||||
network-loss/unreachable handling; bump config version to 4.2
|
||||
|
||||
- Add UPSD client (ts/upsd) and ProtocolResolver (ts/protocol) to support protocol-agnostic UPS
|
||||
queries (snmp or upsd).
|
||||
- Introduce new TProtocol and IUpsdConfig types, wire up Nupst to initialize & expose UPSD client,
|
||||
and route status requests through ProtocolResolver.
|
||||
- Add 'unreachable' TPowerStatus plus consecutiveFailures and unreachableSince tracking; mark UPS as
|
||||
unreachable after NETWORK.CONSECUTIVE_FAILURE_THRESHOLD failures and suppress shutdown actions
|
||||
while unreachable.
|
||||
- Implement pause/resume feature: PAUSE.FILE_PATH state file, CLI commands (pause/resume), daemon
|
||||
pause-state polling, auto-resume, and include pause state in HTTP API responses.
|
||||
- Add ProxmoxAction (ts/actions/proxmox-action.ts) with Proxmox API interaction, configuration
|
||||
options (token, node, timeout, force, insecure) and CLI prompts to configure proxmox actions.
|
||||
- CLI and UI updates: protocol selection when adding UPS, protocol/host shown in lists, action
|
||||
details column supports proxmox, and status displays include protocol and unreachable state.
|
||||
- Add migration MigrationV4_1ToV4_2 to set protocol:'snmp' for existing devices and bump
|
||||
config.version to '4.2'.
|
||||
- Add new constants (NETWORK, UPSD, PAUSE, PROXMOX), update package.json scripts
|
||||
(test/build/lint/format), and wire protocol support across daemon, systemd, http-server, and
|
||||
various handlers.
|
||||
|
||||
## 2026-01-29 - 5.2.4 - fix()
|
||||
|
||||
no changes
|
||||
|
||||
- No files changed in the provided git diff; no commit or version bump required.
|
||||
|
||||
## 2026-01-29 - 5.2.3 - fix(core)
|
||||
|
||||
fix lint/type issues and small refactors
|
||||
|
||||
- Add missing node:process imports in bin and scripts to ensure process is available
|
||||
- Remove unused imports and unused type imports (e.g. writeFileSync, IActionConfig) to reduce noise
|
||||
- Make some methods synchronous (service update, webhook call) to match actual usage
|
||||
- Tighten SNMP typings and linting: added deno-lint-ignore comments, renamed unused params with leading underscore, and use `as const` for securityLevel fallbacks
|
||||
- Tighten SNMP typings and linting: added deno-lint-ignore comments, renamed unused params with
|
||||
leading underscore, and use `as const` for securityLevel fallbacks
|
||||
- Improve error handling variable naming in systemd (use error instead of _error)
|
||||
- Annotate ANSI regex with deno-lint-ignore no-control-regex and remove unused color/symbol imports across CLI/daemon/logger
|
||||
- Annotate ANSI regex with deno-lint-ignore no-control-regex and remove unused color/symbol imports
|
||||
across CLI/daemon/logger
|
||||
|
||||
## 2026-01-29 - 5.2.2 - fix(core)
|
||||
|
||||
tidy formatting and minor fixes across CLI, SNMP, HTTP server, migrations and packaging
|
||||
|
||||
- Normalize import ordering and improve logger/string formatting across many CLI handlers, daemon, systemd, actions and tests
|
||||
- Normalize import ordering and improve logger/string formatting across many CLI handlers, daemon,
|
||||
systemd, actions and tests
|
||||
- Apply formatting tidies: trailing commas, newline fixes, and more consistent multiline strings
|
||||
- Allow BaseMigration methods to return either sync or async results (shouldRun/migrate signatures updated)
|
||||
- Improve SNMP manager and HTTP server logging/error messages and tighten some typings (raw SNMP types, server error typing)
|
||||
- Small robustness and messaging improvements in npm installer and wrapper (platform/arch mapping, error outputs)
|
||||
- Allow BaseMigration methods to return either sync or async results (shouldRun/migrate signatures
|
||||
updated)
|
||||
- Improve SNMP manager and HTTP server logging/error messages and tighten some typings (raw SNMP
|
||||
types, server error typing)
|
||||
- Small robustness and messaging improvements in npm installer and wrapper (platform/arch mapping,
|
||||
error outputs)
|
||||
- Update tests and documentation layout/formatting for readability
|
||||
|
||||
## 2026-01-29 - 5.2.1 - fix(cli(ups-handler), systemd)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@serve.zone/nupst",
|
||||
"version": "5.8.0",
|
||||
"version": "5.9.0",
|
||||
"exports": "./mod.ts",
|
||||
"nodeModulesDir": "auto",
|
||||
"tasks": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@serve.zone/nupst",
|
||||
"version": "5.8.0",
|
||||
"version": "5.9.0",
|
||||
"description": "Network UPS Shutdown Tool - Monitor SNMP-enabled UPS devices and orchestrate graceful system shutdowns during power emergencies",
|
||||
"keywords": [
|
||||
"ups",
|
||||
|
||||
1253
pnpm-lock.yaml
generated
1253
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
357
readme.md
357
readme.md
@@ -1,18 +1,27 @@
|
||||
# ⚡ NUPST — Network UPS Shutdown Tool
|
||||
|
||||
**Keep your systems safe when the power goes out.** NUPST is a lightweight, battle-tested CLI tool that monitors UPS devices via SNMP or NUT (UPSD) and orchestrates graceful shutdowns during power emergencies — including Proxmox VMs, LXC containers, and the host itself.
|
||||
**Keep your systems safe when the power goes out.** NUPST is a lightweight, battle-tested CLI tool
|
||||
that monitors UPS devices via SNMP or NUT (UPSD) and orchestrates graceful shutdowns during power
|
||||
emergencies — including Proxmox VMs, LXC containers, and the host itself.
|
||||
|
||||
Distributed as **self-contained binaries** with zero runtime dependencies. No Node.js, no Python, no package managers. Just download and run.
|
||||
Distributed as **self-contained binaries** with zero runtime dependencies. No Node.js, no Python, no
|
||||
package managers. Just download and run.
|
||||
|
||||
## Issue Reporting and Security
|
||||
|
||||
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
|
||||
For reporting bugs, issues, or security vulnerabilities, please visit
|
||||
[community.foss.global/](https://community.foss.global/). This is the central community hub for all
|
||||
issue reporting. Developers who sign and comply with our contribution agreement and go through
|
||||
identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull
|
||||
Requests directly.
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- **🔌 Multi-UPS Support** — Monitor multiple UPS devices from a single daemon
|
||||
- **📡 Dual Protocol Support** — SNMP (v1/v2c/v3) for network UPS + UPSD/NIS for USB-connected UPS via NUT
|
||||
- **🖥️ Proxmox Integration** — Gracefully shut down QEMU VMs and LXC containers before host shutdown, with optional HA-aware stop requests for HA-managed guests
|
||||
- **📡 Dual Protocol Support** — SNMP (v1/v2c/v3) for network UPS + UPSD/NIS for USB-connected UPS
|
||||
via NUT
|
||||
- **🖥️ Proxmox Integration** — Gracefully shut down QEMU VMs and LXC containers before host
|
||||
shutdown, with optional HA-aware stop requests for HA-managed guests
|
||||
- **👥 Group Management** — Organize UPS devices into groups with flexible operating modes
|
||||
- **Redundant Mode** — Only trigger actions when ALL UPS devices in a group are critical
|
||||
- **Non-Redundant Mode** — Trigger actions when ANY UPS device is critical
|
||||
@@ -23,7 +32,8 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
||||
- Custom shell scripts
|
||||
- Proxmox VM/LXC shutdown
|
||||
- Configurable shutdown delays
|
||||
- **🏭 Multiple UPS Brands** — CyberPower, APC, Eaton, TrippLite, Liebert/Vertiv, and custom OID configurations
|
||||
- **🏭 Multiple UPS Brands** — CyberPower, APC, Eaton, TrippLite, Liebert/Vertiv, and custom OID
|
||||
configurations
|
||||
- **🌐 HTTP API** — Optional JSON status endpoint with token authentication
|
||||
- **⏸️ Pause/Resume** — Temporarily suppress actions during maintenance windows
|
||||
- **🛡️ Network Loss Detection** — Detects unreachable UPS devices and prevents false shutdowns
|
||||
@@ -91,7 +101,8 @@ curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh |
|
||||
|
||||
### Manual Installation
|
||||
|
||||
Download the binary for your platform from [releases](https://code.foss.global/serve.zone/nupst/releases):
|
||||
Download the binary for your platform from
|
||||
[releases](https://code.foss.global/serve.zone/nupst/releases):
|
||||
|
||||
| Platform | Binary |
|
||||
| ------------------- | ----------------------- |
|
||||
@@ -133,11 +144,11 @@ nupst <command> [subcommand] [options]
|
||||
|
||||
### Global Options
|
||||
|
||||
| Flag | Description |
|
||||
| ---------------- | -------------------------------------- |
|
||||
| `--version`, `-v` | Show version |
|
||||
| `--help`, `-h` | Show help |
|
||||
| `--debug`, `-d` | Enable debug mode (verbose SNMP/UPSD logging) |
|
||||
| Flag | Description |
|
||||
| ----------------- | --------------------------------------------- |
|
||||
| `--version`, `-v` | Show version |
|
||||
| `--help`, `-h` | Show help |
|
||||
| `--debug`, `-d` | Enable debug mode (verbose SNMP/UPSD logging) |
|
||||
|
||||
### Service Management
|
||||
|
||||
@@ -179,6 +190,7 @@ nupst group list # List all groups
|
||||
|
||||
```bash
|
||||
nupst action add <target-id> # Add action to a UPS or group
|
||||
nupst action edit <target-id> <idx> # Edit an action by index
|
||||
nupst action remove <target-id> <idx> # Remove an action by index
|
||||
nupst action list [target-id] # List actions (optionally for a target)
|
||||
```
|
||||
@@ -196,6 +208,7 @@ nupst resume # Resume immediately
|
||||
```
|
||||
|
||||
When paused:
|
||||
|
||||
- UPS polling continues (status is still visible)
|
||||
- All actions are suppressed (no shutdowns, webhooks, scripts)
|
||||
- The HTTP API response includes `"paused": true`
|
||||
@@ -217,7 +230,11 @@ nupst uninstall # Completely remove NUPST (requires root)
|
||||
|
||||
## ⚙️ Configuration
|
||||
|
||||
NUPST stores configuration at `/etc/nupst/config.json`. The easiest way to configure is through the interactive CLI commands, but you can also edit the JSON directly.
|
||||
NUPST stores configuration at `/etc/nupst/config.json`. The easiest way to configure is through the
|
||||
interactive CLI commands, but you can also edit the JSON directly.
|
||||
|
||||
When the daemon is running, changes to `/etc/nupst/config.json` are hot-reloaded automatically. A
|
||||
service restart is not normally required after editing the file.
|
||||
|
||||
`defaultShutdownDelay` sets the inherited delay in minutes for shutdown actions that do not define
|
||||
their own `shutdownDelay`.
|
||||
@@ -226,7 +243,7 @@ their own `shutdownDelay`.
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "4.3",
|
||||
"version": "4.4",
|
||||
"checkInterval": 30000,
|
||||
"defaultShutdownDelay": 5,
|
||||
"httpServer": {
|
||||
@@ -314,82 +331,89 @@ their own `shutdownDelay`.
|
||||
|
||||
Each UPS device has a `protocol` field:
|
||||
|
||||
| Protocol | Use Case | Default Port |
|
||||
| -------- | -------- | ------------ |
|
||||
| `snmp` | Network-attached UPS with SNMP agent | 161 |
|
||||
| `upsd` | USB-connected UPS via local NUT server | 3493 |
|
||||
| Protocol | Use Case | Default Port |
|
||||
| -------- | -------------------------------------- | ------------ |
|
||||
| `snmp` | Network-attached UPS with SNMP agent | 161 |
|
||||
| `upsd` | USB-connected UPS via local NUT server | 3493 |
|
||||
|
||||
#### SNMP Settings (`snmp` object)
|
||||
|
||||
| Field | Description | Values / Default |
|
||||
| ----------- | -------------------------- | -------------------------------------------------------------- |
|
||||
| `host` | IP address or hostname | e.g., `"192.168.1.100"` |
|
||||
| `port` | SNMP port | Default: `161` |
|
||||
| `version` | SNMP version | `1`, `2`, or `3` |
|
||||
| `timeout` | Timeout in milliseconds | Default: `5000` |
|
||||
| `upsModel` | UPS brand/model | `cyberpower`, `apc`, `eaton`, `tripplite`, `liebert`, `custom` |
|
||||
| `runtimeUnit` | Battery runtime unit | `minutes`, `seconds`, or `ticks` (1/100s). Overrides auto-detection |
|
||||
| `community` | Community string (v1/v2c) | Default: `"public"` |
|
||||
| Field | Description | Values / Default |
|
||||
| ------------- | ------------------------- | ------------------------------------------------------------------- |
|
||||
| `host` | IP address or hostname | e.g., `"192.168.1.100"` |
|
||||
| `port` | SNMP port | Default: `161` |
|
||||
| `version` | SNMP version | `1`, `2`, or `3` |
|
||||
| `timeout` | Timeout in milliseconds | Default: `5000` |
|
||||
| `upsModel` | UPS brand/model | `cyberpower`, `apc`, `eaton`, `tripplite`, `liebert`, `custom` |
|
||||
| `runtimeUnit` | Battery runtime unit | `minutes`, `seconds`, or `ticks` (1/100s). Overrides auto-detection |
|
||||
| `community` | Community string (v1/v2c) | Default: `"public"` |
|
||||
|
||||
**SNMPv3 fields** (when `version: 3`):
|
||||
|
||||
| Field | Description | Values |
|
||||
| --------------- | ------------------------ | ----------------------------------- |
|
||||
| `securityLevel` | Security level | `noAuthNoPriv`, `authNoPriv`, `authPriv` |
|
||||
| `username` | Authentication username | — |
|
||||
| `authProtocol` | Auth protocol | `MD5` or `SHA` |
|
||||
| `authKey` | Auth password | — |
|
||||
| `privProtocol` | Encryption protocol | `DES` or `AES` |
|
||||
| `privKey` | Encryption password | — |
|
||||
| Field | Description | Values |
|
||||
| --------------- | ----------------------- | ---------------------------------------- |
|
||||
| `securityLevel` | Security level | `noAuthNoPriv`, `authNoPriv`, `authPriv` |
|
||||
| `username` | Authentication username | — |
|
||||
| `authProtocol` | Auth protocol | `MD5` or `SHA` |
|
||||
| `authKey` | Auth password | — |
|
||||
| `privProtocol` | Encryption protocol | `DES` or `AES` |
|
||||
| `privKey` | Encryption password | — |
|
||||
|
||||
#### UPSD/NIS Settings (`upsd` object)
|
||||
|
||||
For USB-connected UPS via [NUT (Network UPS Tools)](https://networkupstools.org/):
|
||||
|
||||
| Field | Description | Default |
|
||||
| ---------- | --------------------------- | ------------- |
|
||||
| `host` | NUT server address | `127.0.0.1` |
|
||||
| `port` | NUT UPSD port | `3493` |
|
||||
| `upsName` | NUT device name | `ups` |
|
||||
| `timeout` | Connection timeout (ms) | `5000` |
|
||||
| `username` | Optional auth username | — |
|
||||
| `password` | Optional auth password | — |
|
||||
| Field | Description | Default |
|
||||
| ---------- | ----------------------- | ----------- |
|
||||
| `host` | NUT server address | `127.0.0.1` |
|
||||
| `port` | NUT UPSD port | `3493` |
|
||||
| `upsName` | NUT device name | `ups` |
|
||||
| `timeout` | Connection timeout (ms) | `5000` |
|
||||
| `username` | Optional auth username | — |
|
||||
| `password` | Optional auth password | — |
|
||||
|
||||
**NUT variables mapped:** `ups.status`, `battery.charge`, `battery.runtime`, `ups.load`, `ups.realpower`, `output.voltage`, `output.current`
|
||||
**NUT variables mapped:** `ups.status`, `battery.charge`, `battery.runtime`, `ups.load`,
|
||||
`ups.realpower`, `output.voltage`, `output.current`
|
||||
|
||||
### Action Configuration
|
||||
|
||||
Actions define automated responses to UPS conditions. They run **sequentially in array order**, so place Proxmox actions before shutdown actions.
|
||||
Actions define automated responses to UPS conditions. They run **sequentially in array order**, so
|
||||
place Proxmox actions before shutdown actions.
|
||||
|
||||
Threshold-based actions are **edge-triggered**: they fire when the monitored UPS or group **enters** a threshold violation, not on every polling cycle while the threshold remains violated. If the condition clears and later re-enters, the action can fire again.
|
||||
Threshold-based actions are **edge-triggered**: they fire when the monitored UPS or group **enters**
|
||||
a threshold violation, not on every polling cycle while the threshold remains violated. If the
|
||||
condition clears and later re-enters, the action can fire again.
|
||||
|
||||
Shutdown and Proxmox actions also suppress duplicate runs where possible, so overlapping UPS and group actions do not repeatedly schedule the same host or guest shutdown workflow.
|
||||
Shutdown and Proxmox actions also suppress duplicate runs where possible, so overlapping UPS and
|
||||
group actions do not repeatedly schedule the same host or guest shutdown workflow.
|
||||
|
||||
You can update an existing action in place with `nupst action edit <target-id> <index>`.
|
||||
|
||||
#### Action Types
|
||||
|
||||
| Type | Description |
|
||||
| ---------- | ------------------------------------------------------------ |
|
||||
| `shutdown` | Graceful system shutdown with configurable delay |
|
||||
| `webhook` | HTTP POST/GET notification to external services |
|
||||
| `script` | Execute custom shell scripts from `/etc/nupst/` |
|
||||
| `proxmox` | Shut down Proxmox QEMU VMs and LXC containers (CLI or API) |
|
||||
| Type | Description |
|
||||
| ---------- | ---------------------------------------------------------- |
|
||||
| `shutdown` | Graceful system shutdown with configurable delay |
|
||||
| `webhook` | HTTP POST/GET notification to external services |
|
||||
| `script` | Execute custom shell scripts from `/etc/nupst/` |
|
||||
| `proxmox` | Shut down Proxmox QEMU VMs and LXC containers (CLI or API) |
|
||||
|
||||
#### Common Fields
|
||||
|
||||
| Field | Description | Values / Default |
|
||||
| ------------- | -------------------------------- | ---------------------------------------- |
|
||||
| `type` | Action type | `shutdown`, `webhook`, `script`, `proxmox` |
|
||||
| `thresholds` | Battery and runtime limits | `{ "battery": 0-100, "runtime": minutes }` |
|
||||
| `triggerMode` | When to trigger | See Trigger Modes below |
|
||||
| Field | Description | Values / Default |
|
||||
| ------------- | -------------------------- | ------------------------------------------ |
|
||||
| `type` | Action type | `shutdown`, `webhook`, `script`, `proxmox` |
|
||||
| `thresholds` | Battery and runtime limits | `{ "battery": 0-100, "runtime": minutes }` |
|
||||
| `triggerMode` | When to trigger | See Trigger Modes below |
|
||||
|
||||
#### Trigger Modes
|
||||
|
||||
| Mode | Description |
|
||||
| ----------------------------- | -------------------------------------------------------- |
|
||||
| `onlyPowerChanges` | Only when power status changes (online ↔ onBattery) |
|
||||
| `onlyThresholds` | Only when battery or runtime thresholds are newly violated |
|
||||
| `powerChangesAndThresholds` | On power changes OR when thresholds are newly violated (default) |
|
||||
| `anyChange` | On every polling cycle |
|
||||
| Mode | Description |
|
||||
| --------------------------- | ---------------------------------------------------------------- |
|
||||
| `onlyPowerChanges` | Only when power status changes (online ↔ onBattery) |
|
||||
| `onlyThresholds` | Only when battery or runtime thresholds are newly violated |
|
||||
| `powerChangesAndThresholds` | On power changes OR when thresholds are newly violated (default) |
|
||||
| `anyChange` | On every polling cycle |
|
||||
|
||||
#### Shutdown Action
|
||||
|
||||
@@ -402,9 +426,9 @@ Shutdown and Proxmox actions also suppress duplicate runs where possible, so ove
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Description | Default |
|
||||
| --------------- | ---------------------------------- | ------- |
|
||||
| `shutdownDelay` | Minutes to wait before shutdown | Inherits `defaultShutdownDelay` (`5`) |
|
||||
| Field | Description | Default |
|
||||
| --------------- | ------------------------------- | ------------------------------------- |
|
||||
| `shutdownDelay` | Minutes to wait before shutdown | Inherits `defaultShutdownDelay` (`5`) |
|
||||
|
||||
#### Webhook Action
|
||||
|
||||
@@ -419,11 +443,11 @@ Shutdown and Proxmox actions also suppress duplicate runs where possible, so ove
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Description | Default |
|
||||
| ---------------- | -------------------- | -------- |
|
||||
| `webhookUrl` | URL to call | Required |
|
||||
| `webhookMethod` | HTTP method | `POST` |
|
||||
| `webhookTimeout` | Timeout in ms | `10000` |
|
||||
| Field | Description | Default |
|
||||
| ---------------- | ------------- | -------- |
|
||||
| `webhookUrl` | URL to call | Required |
|
||||
| `webhookMethod` | HTTP method | `POST` |
|
||||
| `webhookTimeout` | Timeout in ms | `10000` |
|
||||
|
||||
#### Script Action
|
||||
|
||||
@@ -437,26 +461,28 @@ Shutdown and Proxmox actions also suppress duplicate runs where possible, so ove
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Description | Default |
|
||||
| --------------- | -------------------------------------- | ------- |
|
||||
| `scriptPath` | Script filename in `/etc/nupst/` | Required |
|
||||
| `scriptTimeout` | Execution timeout in ms | `60000` |
|
||||
| Field | Description | Default |
|
||||
| --------------- | -------------------------------- | -------- |
|
||||
| `scriptPath` | Script filename in `/etc/nupst/` | Required |
|
||||
| `scriptTimeout` | Execution timeout in ms | `60000` |
|
||||
|
||||
#### 🖥️ Proxmox Action
|
||||
|
||||
Gracefully shuts down QEMU VMs and LXC containers on a Proxmox node before the host is shut down.
|
||||
|
||||
If you use Proxmox HA, NUPST can optionally request `state=stopped` for HA-managed guests instead of only issuing direct `qm` / `pct` shutdown commands.
|
||||
If you use Proxmox HA, NUPST can optionally request `state=stopped` for HA-managed guests instead of
|
||||
only issuing direct `qm` / `pct` shutdown commands.
|
||||
|
||||
NUPST supports **two operation modes** for Proxmox:
|
||||
|
||||
| Mode | Description | Requirements |
|
||||
| ------ | -------------------------------------------------------------- | ------------------------- |
|
||||
| Mode | Description | Requirements |
|
||||
| ------ | -------------------------------------------------------------- | ------------------------------- |
|
||||
| `cli` | Uses `qm`/`pct` commands directly — **no API token needed** 🎉 | Running as root on Proxmox host |
|
||||
| `api` | Uses Proxmox REST API via HTTPS | API token required |
|
||||
| `auto` | Prefers CLI if available, falls back to API (default) | — |
|
||||
| `api` | Uses Proxmox REST API via HTTPS | API token required |
|
||||
| `auto` | Prefers CLI if available, falls back to API (default) | — |
|
||||
|
||||
> 💡 **On a Proxmox host running as root** (the typical setup), NUPST auto-detects `qm` and `pct` CLI tools and uses them directly. No API token setup required!
|
||||
> 💡 **On a Proxmox host running as root** (the typical setup), NUPST auto-detects `qm` and `pct`
|
||||
> CLI tools and uses them directly. No API token setup required!
|
||||
|
||||
**CLI mode example** (simplest — auto-detected on Proxmox hosts):
|
||||
|
||||
@@ -493,19 +519,19 @@ NUPST supports **two operation modes** for Proxmox:
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Description | Default |
|
||||
| --------------------- | ----------------------------------------------- | ------------- |
|
||||
| `proxmoxMode` | Operation mode | `auto` |
|
||||
| `proxmoxHaPolicy` | HA handling for HA-managed guests | `none`, `haStop` (`none` default) |
|
||||
| `proxmoxHost` | Proxmox API host (API mode only) | `localhost` |
|
||||
| `proxmoxPort` | Proxmox API port (API mode only) | `8006` |
|
||||
| `proxmoxNode` | Proxmox node name | Auto-detect via hostname |
|
||||
| `proxmoxTokenId` | API token ID (API mode only) | — |
|
||||
| `proxmoxTokenSecret` | API token secret (API mode only) | — |
|
||||
| `proxmoxExcludeIds` | VM/CT IDs to skip | `[]` |
|
||||
| `proxmoxStopTimeout` | Seconds to wait for graceful shutdown | `120` |
|
||||
| `proxmoxForceStop` | Force-stop VMs/CTs that don't shut down | `true` |
|
||||
| `proxmoxInsecure` | Skip TLS verification (API mode only) | `true` |
|
||||
| Field | Description | Default |
|
||||
| -------------------- | --------------------------------------- | --------------------------------- |
|
||||
| `proxmoxMode` | Operation mode | `auto` |
|
||||
| `proxmoxHaPolicy` | HA handling for HA-managed guests | `none`, `haStop` (`none` default) |
|
||||
| `proxmoxHost` | Proxmox API host (API mode only) | `localhost` |
|
||||
| `proxmoxPort` | Proxmox API port (API mode only) | `8006` |
|
||||
| `proxmoxNode` | Proxmox node name | Auto-detect via hostname |
|
||||
| `proxmoxTokenId` | API token ID (API mode only) | — |
|
||||
| `proxmoxTokenSecret` | API token secret (API mode only) | — |
|
||||
| `proxmoxExcludeIds` | VM/CT IDs to skip | `[]` |
|
||||
| `proxmoxStopTimeout` | Seconds to wait for graceful shutdown | `120` |
|
||||
| `proxmoxForceStop` | Force-stop VMs/CTs that don't shut down | `true` |
|
||||
| `proxmoxInsecure` | Skip TLS verification (API mode only) | `true` |
|
||||
|
||||
**Setting up the API token** (only needed for API mode):
|
||||
|
||||
@@ -516,10 +542,13 @@ pveum user token add root@pam nupst --privsep=0
|
||||
|
||||
**HA Policy values:**
|
||||
|
||||
- **`none`** — Treat HA-managed and non-HA guests the same. NUPST sends normal guest shutdown commands.
|
||||
- **`haStop`** — For HA-managed guests, NUPST requests HA resource state `stopped`. Non-HA guests still use normal shutdown commands.
|
||||
- **`none`** — Treat HA-managed and non-HA guests the same. NUPST sends normal guest shutdown
|
||||
commands.
|
||||
- **`haStop`** — For HA-managed guests, NUPST requests HA resource state `stopped`. Non-HA guests
|
||||
still use normal shutdown commands.
|
||||
|
||||
> ⚠️ **Important:** Place the Proxmox action **before** the shutdown action in the actions array so VMs are stopped before the host shuts down.
|
||||
> ⚠️ **Important:** Place the Proxmox action **before** the shutdown action in the actions array so
|
||||
> VMs are stopped before the host shuts down.
|
||||
|
||||
### Group Configuration
|
||||
|
||||
@@ -527,22 +556,36 @@ Groups coordinate actions across multiple UPS devices.
|
||||
|
||||
Group actions are evaluated **after all UPS devices have been refreshed for a polling cycle**.
|
||||
|
||||
There is **no aggregate battery math** across the group. Instead, each group action evaluates each member UPS against that action's own thresholds.
|
||||
There is **no aggregate battery math** across the group. Instead, each group action evaluates each
|
||||
member UPS against that action's own thresholds.
|
||||
|
||||
| Field | Description | Values |
|
||||
| ------------- | ---------------------------------- | -------------------- |
|
||||
| `id` | Unique group identifier | — |
|
||||
| `name` | Human-readable name | — |
|
||||
| `mode` | Group operating mode | `redundant`, `nonRedundant` |
|
||||
| `description` | Optional description | — |
|
||||
| `actions` | Array of action configurations | — |
|
||||
| Field | Description | Values |
|
||||
| ------------- | ------------------------------ | --------------------------- |
|
||||
| `id` | Unique group identifier | — |
|
||||
| `name` | Human-readable name | — |
|
||||
| `mode` | Group operating mode | `redundant`, `nonRedundant` |
|
||||
| `description` | Optional description | — |
|
||||
| `actions` | Array of action configurations | — |
|
||||
|
||||
**Group Modes:**
|
||||
|
||||
- **`redundant`** — A threshold-based action triggers only when **all** UPS devices in the group are on battery and below that action's thresholds. Use for setups with backup power units.
|
||||
- **`nonRedundant`** — A threshold-based action triggers when **any** UPS device in the group is on battery and below that action's thresholds. Use when all UPS units must be operational.
|
||||
- **`redundant`** — A threshold-based action triggers only when **all** UPS devices in the group are
|
||||
on battery and below that action's thresholds. Use for setups with backup power units.
|
||||
- **`nonRedundant`** — A threshold-based action triggers when **any** UPS device in the group is on
|
||||
battery and below that action's thresholds. Use when all UPS units must be operational.
|
||||
|
||||
For threshold-based **destructive** group actions (`shutdown` and `proxmox`), NUPST suppresses execution while any group member is `unreachable`. This prevents acting on partial data during network failures.
|
||||
For threshold-based **destructive** group actions (`shutdown` and `proxmox`), NUPST suppresses
|
||||
execution while any group member is `unreachable`. This prevents acting on partial data during
|
||||
network failures.
|
||||
|
||||
### Multi-node Note
|
||||
|
||||
You can copy the same NUPST config to multiple nodes. UPS IDs and group IDs are local to each daemon
|
||||
instance, so duplicate IDs across nodes do not conflict.
|
||||
|
||||
NUPST does not coordinate actions across nodes. If multiple nodes monitor the same UPS or target the
|
||||
same Proxmox environment with the same action config, each node can trigger its own shutdown or
|
||||
Proxmox workflow. For shared UPS or Proxmox targets, prefer one controlling node per target.
|
||||
|
||||
### HTTP Server Configuration
|
||||
|
||||
@@ -616,9 +659,11 @@ When monitoring is paused:
|
||||
NUPST tracks communication failures per UPS device:
|
||||
|
||||
- After **3 consecutive failures**, the UPS status transitions to `unreachable`
|
||||
- **Shutdown actions will NOT fire** on `unreachable` — this prevents false shutdowns from network glitches
|
||||
- **Shutdown actions will NOT fire** on `unreachable` — this prevents false shutdowns from network
|
||||
glitches
|
||||
- Webhook and script actions still fire, allowing you to send alerts
|
||||
- Threshold-based destructive **group** actions are also suppressed while any required group member is `unreachable`
|
||||
- Threshold-based destructive **group** actions are also suppressed while any required group member
|
||||
is `unreachable`
|
||||
- When connectivity is restored, NUPST logs a recovery event with downtime duration
|
||||
- The failure counter is capped at 100 to prevent overflow
|
||||
|
||||
@@ -676,15 +721,16 @@ nupst service logs
|
||||
|
||||
Full SNMPv3 support with authentication and encryption:
|
||||
|
||||
| Security Level | Description |
|
||||
| -------------- | ------------------------------------------ |
|
||||
| `noAuthNoPriv` | No authentication, no encryption |
|
||||
| `authNoPriv` | MD5/SHA authentication without encryption |
|
||||
| `authPriv` | Authentication + DES/AES encryption ✅ |
|
||||
| Security Level | Description |
|
||||
| -------------- | ----------------------------------------- |
|
||||
| `noAuthNoPriv` | No authentication, no encryption |
|
||||
| `authNoPriv` | MD5/SHA authentication without encryption |
|
||||
| `authPriv` | Authentication + DES/AES encryption ✅ |
|
||||
|
||||
### Network Security
|
||||
|
||||
- Connects only to UPS devices and optionally Proxmox on local network (CLI mode uses local tools — no network needed for VM shutdown)
|
||||
- Connects only to UPS devices and optionally Proxmox on local network (CLI mode uses local tools —
|
||||
no network needed for VM shutdown)
|
||||
- HTTP API disabled by default; token-required when enabled
|
||||
- No external internet connections
|
||||
|
||||
@@ -700,14 +746,14 @@ sha256sum -c SHA256SUMS.txt --ignore-missing
|
||||
|
||||
### SNMP-based
|
||||
|
||||
| Brand | Config Value | Notes |
|
||||
| -------------- | ------------- | ------------------------------- |
|
||||
| CyberPower | `cyberpower` | Full support including power metrics |
|
||||
| APC | `apc` | Smart-UPS, Back-UPS series |
|
||||
| Eaton | `eaton` | Eaton/Powerware UPS |
|
||||
| TrippLite | `tripplite` | SmartPro and similar |
|
||||
| Liebert/Vertiv | `liebert` | GXT, PSI series |
|
||||
| Custom | `custom` | Provide your own OID mappings |
|
||||
| Brand | Config Value | Notes |
|
||||
| -------------- | ------------ | ------------------------------------ |
|
||||
| CyberPower | `cyberpower` | Full support including power metrics |
|
||||
| APC | `apc` | Smart-UPS, Back-UPS series |
|
||||
| Eaton | `eaton` | Eaton/Powerware UPS |
|
||||
| TrippLite | `tripplite` | SmartPro and similar |
|
||||
| Liebert/Vertiv | `liebert` | GXT, PSI series |
|
||||
| Custom | `custom` | Provide your own OID mappings |
|
||||
|
||||
**Custom OIDs example:**
|
||||
|
||||
@@ -723,11 +769,15 @@ sha256sum -c SHA256SUMS.txt --ignore-missing
|
||||
}
|
||||
```
|
||||
|
||||
> 💡 **Tip:** If your UPS (e.g., HPE, Huawei) reports runtime in seconds instead of minutes, set `"runtimeUnit": "seconds"`. For CyberPower-style TimeTicks (1/100 second), use `"ticks"`. When omitted, NUPST auto-detects based on `upsModel`.
|
||||
> 💡 **Tip:** If your UPS (e.g., HPE, Huawei) reports runtime in seconds instead of minutes, set
|
||||
> `"runtimeUnit": "seconds"`. For CyberPower or APC PowerNet TimeTicks (1/100 second), use
|
||||
> `"ticks"`. When omitted, NUPST auto-detects based on `upsModel`.
|
||||
|
||||
### UPSD/NIS-based
|
||||
|
||||
Any UPS supported by [NUT (Network UPS Tools)](https://networkupstools.org/) — this covers **hundreds of models** from virtually every manufacturer, including USB-connected devices. Check the [NUT hardware compatibility list](https://networkupstools.org/stable-hcl.html).
|
||||
Any UPS supported by [NUT (Network UPS Tools)](https://networkupstools.org/) — this covers
|
||||
**hundreds of models** from virtually every manufacturer, including USB-connected devices. Check the
|
||||
[NUT hardware compatibility list](https://networkupstools.org/stable-hcl.html).
|
||||
|
||||
## 🔄 Updating
|
||||
|
||||
@@ -824,13 +874,13 @@ nupst service status
|
||||
|
||||
### File System
|
||||
|
||||
| Path | Description |
|
||||
| ----------------------------------- | -------------------- |
|
||||
| `/opt/nupst/nupst` | Pre-compiled binary |
|
||||
| `/usr/local/bin/nupst` | Symlink to binary |
|
||||
| `/etc/nupst/config.json` | Configuration file |
|
||||
| Path | Description |
|
||||
| ----------------------------------- | ------------------------------ |
|
||||
| `/opt/nupst/nupst` | Pre-compiled binary |
|
||||
| `/usr/local/bin/nupst` | Symlink to binary |
|
||||
| `/etc/nupst/config.json` | Configuration file |
|
||||
| `/etc/nupst/pause` | Pause state file (when paused) |
|
||||
| `/etc/systemd/system/nupst.service` | Systemd service unit |
|
||||
| `/etc/systemd/system/nupst.service` | Systemd service unit |
|
||||
|
||||
### Services
|
||||
|
||||
@@ -851,15 +901,16 @@ nupst service status
|
||||
curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash
|
||||
```
|
||||
|
||||
The installer auto-detects v3.x installations, migrates the configuration, and swaps the binary. Your settings are preserved.
|
||||
The installer auto-detects v3.x installations, migrates the configuration, and swaps the binary.
|
||||
Your settings are preserved.
|
||||
|
||||
| Aspect | v3.x | v4.x+ |
|
||||
| ------------------------ | -------------------------- | ----------------------------- |
|
||||
| **Runtime** | Node.js + npm | Deno (self-contained) |
|
||||
| **Distribution** | Git repo + npm install | Pre-compiled binaries |
|
||||
| **Runtime Dependencies** | node_modules | Zero |
|
||||
| **Size** | ~150MB | ~80MB |
|
||||
| **Commands** | Flat (`nupst add`) | Subcommands (`nupst ups add`) |
|
||||
| Aspect | v3.x | v4.x+ |
|
||||
| ------------------------ | ---------------------- | ----------------------------- |
|
||||
| **Runtime** | Node.js + npm | Deno (self-contained) |
|
||||
| **Distribution** | Git repo + npm install | Pre-compiled binaries |
|
||||
| **Runtime Dependencies** | node_modules | Zero |
|
||||
| **Size** | ~150MB | ~80MB |
|
||||
| **Commands** | Flat (`nupst add`) | Subcommands (`nupst ups add`) |
|
||||
|
||||
## 💻 Development
|
||||
|
||||
@@ -915,21 +966,31 @@ nupst/
|
||||
|
||||
## License and Legal Information
|
||||
|
||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](./license) file.
|
||||
This repository contains open-source code licensed under the MIT License. A copy of the license can
|
||||
be found in the [license](./license) file.
|
||||
|
||||
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
||||
**Please note:** The MIT License does not grant permission to use the trade names, trademarks,
|
||||
service marks, or product names of the project, except as required for reasonable and customary use
|
||||
in describing the origin of the work and reproducing the content of the NOTICE file.
|
||||
|
||||
### Trademarks
|
||||
|
||||
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
|
||||
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated
|
||||
with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture
|
||||
Capital GmbH or third parties, and are not included within the scope of the MIT license granted
|
||||
herein.
|
||||
|
||||
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
|
||||
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the
|
||||
guidelines of the respective third-party owners, and any usage must be approved in writing.
|
||||
Third-party trademarks used herein are the property of their respective owners and used only in a
|
||||
descriptive manner, e.g. for an implementation of an API or similar.
|
||||
|
||||
### Company Information
|
||||
|
||||
Task Venture Capital GmbH
|
||||
Registered at District Court Bremen HRB 35230 HB, Germany
|
||||
Task Venture Capital GmbH Registered at District Court Bremen HRB 35230 HB, Germany
|
||||
|
||||
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
||||
|
||||
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.
|
||||
By using this repository, you acknowledge that you have read this section, agree to comply with its
|
||||
terms, and understand that the licensing of the code does not imply endorsement by Task Venture
|
||||
Capital GmbH of any derivative works.
|
||||
|
||||
@@ -14,7 +14,7 @@ import https from 'https';
|
||||
import { pipeline } from 'stream';
|
||||
import { promisify } from 'util';
|
||||
import { createWriteStream } from 'fs';
|
||||
import process from "node:process";
|
||||
import process from 'node:process';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
141
test/test.ts
141
test/test.ts
@@ -1,6 +1,10 @@
|
||||
import { assert, assertEquals, assertExists } from 'jsr:@std/assert@^1.0.0';
|
||||
import { NupstSnmp } from '../ts/snmp/manager.ts';
|
||||
import { UpsOidSets } from '../ts/snmp/oid-sets.ts';
|
||||
import {
|
||||
convertRuntimeValueToMinutes,
|
||||
getDefaultRuntimeUnitForUpsModel,
|
||||
} from '../ts/snmp/runtime-units.ts';
|
||||
import type { IOidSet, ISnmpConfig, TUpsModel } from '../ts/snmp/types.ts';
|
||||
import {
|
||||
analyzeConfigReload,
|
||||
@@ -10,7 +14,7 @@ import {
|
||||
import { type IPauseState, loadPauseSnapshot } from '../ts/pause-state.ts';
|
||||
import { shortId } from '../ts/helpers/shortid.ts';
|
||||
import { HTTP_SERVER, SNMP, THRESHOLDS, TIMING, UI } from '../ts/constants.ts';
|
||||
import { Action, type IActionContext } from '../ts/actions/base-action.ts';
|
||||
import { Action, type IActionConfig, type IActionContext } from '../ts/actions/base-action.ts';
|
||||
import {
|
||||
applyDefaultShutdownDelay,
|
||||
buildUpsActionContext,
|
||||
@@ -35,6 +39,9 @@ import {
|
||||
evaluateGroupActionThreshold,
|
||||
} from '../ts/group-monitoring.ts';
|
||||
import { createInitialUpsStatus } from '../ts/ups-status.ts';
|
||||
import { MigrationV4_2ToV4_3 } from '../ts/migrations/migration-v4.2-to-v4.3.ts';
|
||||
import { MigrationV4_3ToV4_4 } from '../ts/migrations/migration-v4.3-to-v4.4.ts';
|
||||
import { ActionHandler } from '../ts/cli/action-handler.ts';
|
||||
|
||||
import * as qenv from 'npm:@push.rocks/qenv@^6.0.0';
|
||||
const testQenv = new qenv.Qenv('./', '.nogit/');
|
||||
@@ -794,6 +801,74 @@ Deno.test('UpsOidSets: getStandardOids returns RFC 1628 OIDs', () => {
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Runtime Unit Tests
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
Deno.test('getDefaultRuntimeUnitForUpsModel: APC defaults to ticks', () => {
|
||||
assertEquals(getDefaultRuntimeUnitForUpsModel('apc'), 'ticks');
|
||||
assertEquals(getDefaultRuntimeUnitForUpsModel('cyberpower'), 'ticks');
|
||||
assertEquals(getDefaultRuntimeUnitForUpsModel('eaton'), 'seconds');
|
||||
});
|
||||
|
||||
Deno.test('convertRuntimeValueToMinutes: APC and explicit overrides convert correctly', () => {
|
||||
assertEquals(convertRuntimeValueToMinutes({ upsModel: 'apc' }, 12000), 2);
|
||||
assertEquals(convertRuntimeValueToMinutes({ upsModel: 'eaton' }, 600), 10);
|
||||
assertEquals(convertRuntimeValueToMinutes({ upsModel: 'apc', runtimeUnit: 'minutes' }, 12), 12);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Migration Tests
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
Deno.test('MigrationV4_2ToV4_3: assigns ticks to APC runtimeUnit', () => {
|
||||
const migration = new MigrationV4_2ToV4_3();
|
||||
const migrated = migration.migrate({
|
||||
version: '4.2',
|
||||
upsDevices: [
|
||||
{
|
||||
name: 'APC Rack UPS',
|
||||
snmp: {
|
||||
upsModel: 'apc',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const migratedDevice = (migrated.upsDevices as Array<Record<string, unknown>>)[0];
|
||||
const snmp = migratedDevice.snmp as Record<string, unknown>;
|
||||
assertEquals(migrated.version, '4.3');
|
||||
assertEquals(snmp.runtimeUnit, 'ticks');
|
||||
});
|
||||
|
||||
Deno.test('MigrationV4_3ToV4_4: corrects APC minutes runtimeUnit to ticks', () => {
|
||||
const migration = new MigrationV4_3ToV4_4();
|
||||
const migrated = migration.migrate({
|
||||
version: '4.3',
|
||||
upsDevices: [
|
||||
{
|
||||
name: 'APC Rack UPS',
|
||||
snmp: {
|
||||
upsModel: 'apc',
|
||||
runtimeUnit: 'minutes',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Eaton UPS',
|
||||
snmp: {
|
||||
upsModel: 'eaton',
|
||||
runtimeUnit: 'seconds',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const migratedDevices = migrated.upsDevices as Array<Record<string, unknown>>;
|
||||
assertEquals(migrated.version, '4.4');
|
||||
assertEquals((migratedDevices[0].snmp as Record<string, unknown>).runtimeUnit, 'ticks');
|
||||
assertEquals((migratedDevices[1].snmp as Record<string, unknown>).runtimeUnit, 'seconds');
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Action Base Class Tests
|
||||
// -----------------------------------------------------------------------------
|
||||
@@ -954,6 +1029,70 @@ Deno.test('Action.shouldExecute: anyChange mode always returns true', () => {
|
||||
);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Action Handler Tests
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
Deno.test('ActionHandler.runEditProcess: updates an existing shutdown action', async () => {
|
||||
const config: {
|
||||
version: string;
|
||||
defaultShutdownDelay: number;
|
||||
checkInterval: number;
|
||||
upsDevices: Array<{ id: string; name: string; groups: string[]; actions: IActionConfig[] }>;
|
||||
groups: [];
|
||||
} = {
|
||||
version: '4.4',
|
||||
defaultShutdownDelay: 5,
|
||||
checkInterval: 30000,
|
||||
upsDevices: [{
|
||||
id: 'ups-1',
|
||||
name: 'UPS 1',
|
||||
groups: [],
|
||||
actions: [{
|
||||
type: 'shutdown',
|
||||
triggerMode: 'onlyThresholds',
|
||||
thresholds: {
|
||||
battery: 40,
|
||||
runtime: 12,
|
||||
},
|
||||
}],
|
||||
}],
|
||||
groups: [],
|
||||
};
|
||||
let savedConfig: typeof config | undefined;
|
||||
|
||||
const daemonMock = {
|
||||
loadConfig: async () => config,
|
||||
saveConfig: (nextConfig: typeof config) => {
|
||||
savedConfig = JSON.parse(JSON.stringify(nextConfig));
|
||||
},
|
||||
getConfig: () => config,
|
||||
};
|
||||
|
||||
const nupstMock = {
|
||||
getDaemon: () => daemonMock,
|
||||
} as unknown as ConstructorParameters<typeof ActionHandler>[0];
|
||||
|
||||
const handler = new ActionHandler(nupstMock);
|
||||
const answers = ['', '12', '25', '8', '3'];
|
||||
let answerIndex = 0;
|
||||
const prompt = async (_question: string): Promise<string> => answers[answerIndex++] ?? '';
|
||||
|
||||
await handler.runEditProcess('ups-1', '0', prompt);
|
||||
|
||||
assertExists(savedConfig);
|
||||
assertEquals(answerIndex, answers.length);
|
||||
assertEquals(savedConfig.upsDevices[0].actions[0], {
|
||||
type: 'shutdown',
|
||||
shutdownDelay: 12,
|
||||
thresholds: {
|
||||
battery: 25,
|
||||
runtime: 8,
|
||||
},
|
||||
triggerMode: 'powerChangesAndThresholds',
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// NupstSnmp Class Tests (Unit tests - no real UPS needed)
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/nupst',
|
||||
version: '5.8.0',
|
||||
version: '5.10.0',
|
||||
description: 'Network UPS Shutdown Tool - Monitor SNMP-enabled UPS devices and orchestrate graceful system shutdowns during power emergencies'
|
||||
}
|
||||
|
||||
@@ -204,6 +204,12 @@ export class NupstCli {
|
||||
await actionHandler.add(upsId);
|
||||
break;
|
||||
}
|
||||
case 'edit': {
|
||||
const upsId = subcommandArgs[0];
|
||||
const actionIndex = subcommandArgs[1];
|
||||
await actionHandler.edit(upsId, actionIndex);
|
||||
break;
|
||||
}
|
||||
case 'remove':
|
||||
case 'rm': {
|
||||
const upsId = subcommandArgs[0];
|
||||
@@ -726,6 +732,7 @@ Usage:
|
||||
|
||||
Subcommands:
|
||||
add <ups-id|group-id> - Add a new action to a UPS or group interactively
|
||||
edit <ups-id|group-id> <index> - Edit an action by index
|
||||
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)
|
||||
|
||||
@@ -736,6 +743,7 @@ 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 edit default 0 - Edit action at index 0 on 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'
|
||||
`);
|
||||
|
||||
@@ -41,267 +41,29 @@ export class ActionHandler {
|
||||
}
|
||||
|
||||
const config = await this.nupst.getDaemon().loadConfig();
|
||||
|
||||
// Check if it's a UPS
|
||||
const ups = config.upsDevices.find((u) => u.id === targetId);
|
||||
// Check if it's a group
|
||||
const group = config.groups?.find((g) => g.id === targetId);
|
||||
|
||||
if (!ups && !group) {
|
||||
logger.error(`UPS or Group with ID '${targetId}' not found`);
|
||||
logger.log('');
|
||||
logger.log(
|
||||
` ${theme.dim('List available UPS devices:')} ${theme.command('nupst ups list')}`,
|
||||
);
|
||||
logger.log(` ${theme.dim('List available groups:')} ${theme.command('nupst group list')}`);
|
||||
logger.log('');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const target = ups || group;
|
||||
const targetType = ups ? 'UPS' : 'Group';
|
||||
const targetName = ups ? ups.name : group!.name;
|
||||
const targetSnapshot = this.resolveActionTarget(config, targetId);
|
||||
|
||||
await helpers.withPrompt(async (prompt) => {
|
||||
logger.log('');
|
||||
logger.info(`Add Action to ${targetType} ${theme.highlight(targetName)}`);
|
||||
logger.info(
|
||||
`Add Action to ${targetSnapshot.targetType} ${
|
||||
theme.highlight(targetSnapshot.targetName)
|
||||
}`,
|
||||
);
|
||||
logger.log('');
|
||||
|
||||
// Action type selection
|
||||
logger.log(` ${theme.dim('Action Type:')}`);
|
||||
logger.log(` ${theme.dim('1)')} Shutdown (system shutdown)`);
|
||||
logger.log(` ${theme.dim('2)')} Webhook (HTTP notification)`);
|
||||
logger.log(` ${theme.dim('3)')} Custom Script (run .sh file from /etc/nupst)`);
|
||||
logger.log(
|
||||
` ${theme.dim('4)')} Proxmox (gracefully shut down VMs/LXCs before host shutdown)`,
|
||||
);
|
||||
|
||||
const typeInput = await prompt(
|
||||
` ${theme.dim('Select action type')} ${theme.dim('[1]:')} `,
|
||||
);
|
||||
const typeValue = parseInt(typeInput, 10) || 1;
|
||||
|
||||
const newAction: Partial<IActionConfig> = {};
|
||||
|
||||
if (typeValue === 1) {
|
||||
// Shutdown action
|
||||
newAction.type = 'shutdown';
|
||||
const defaultShutdownDelay = this.nupst.getDaemon().getConfig().defaultShutdownDelay ??
|
||||
SHUTDOWN.DEFAULT_DELAY_MINUTES;
|
||||
|
||||
const delayStr = await prompt(
|
||||
` ${theme.dim('Shutdown delay')} ${
|
||||
theme.dim(`(minutes, leave empty for default ${defaultShutdownDelay}):`)
|
||||
} `,
|
||||
);
|
||||
if (delayStr.trim()) {
|
||||
const shutdownDelay = parseInt(delayStr, 10);
|
||||
if (isNaN(shutdownDelay) || shutdownDelay < 0) {
|
||||
logger.error('Invalid shutdown delay. Must be >= 0.');
|
||||
process.exit(1);
|
||||
}
|
||||
newAction.shutdownDelay = shutdownDelay;
|
||||
}
|
||||
} else if (typeValue === 2) {
|
||||
// Webhook action
|
||||
newAction.type = 'webhook';
|
||||
|
||||
const url = await prompt(` ${theme.dim('Webhook URL:')} `);
|
||||
if (!url.trim()) {
|
||||
logger.error('Webhook URL is required.');
|
||||
process.exit(1);
|
||||
}
|
||||
newAction.webhookUrl = url.trim();
|
||||
|
||||
logger.log('');
|
||||
logger.log(` ${theme.dim('HTTP Method:')}`);
|
||||
logger.log(` ${theme.dim('1)')} POST (JSON body)`);
|
||||
logger.log(` ${theme.dim('2)')} GET (query parameters)`);
|
||||
const methodInput = await prompt(` ${theme.dim('Select method')} ${theme.dim('[1]:')} `);
|
||||
newAction.webhookMethod = methodInput === '2' ? 'GET' : 'POST';
|
||||
|
||||
const timeoutInput = await prompt(
|
||||
` ${theme.dim('Timeout in seconds')} ${theme.dim('[10]:')} `,
|
||||
);
|
||||
const timeout = parseInt(timeoutInput, 10);
|
||||
if (timeoutInput.trim() && !isNaN(timeout)) {
|
||||
newAction.webhookTimeout = timeout * 1000;
|
||||
}
|
||||
} else if (typeValue === 3) {
|
||||
// Script action
|
||||
newAction.type = 'script';
|
||||
|
||||
const scriptPath = await prompt(
|
||||
` ${theme.dim('Script filename (in /etc/nupst/, must end with .sh):')} `,
|
||||
);
|
||||
if (!scriptPath.trim() || !scriptPath.trim().endsWith('.sh')) {
|
||||
logger.error('Script path must end with .sh.');
|
||||
process.exit(1);
|
||||
}
|
||||
newAction.scriptPath = scriptPath.trim();
|
||||
|
||||
const timeoutInput = await prompt(
|
||||
` ${theme.dim('Script timeout in seconds')} ${theme.dim('[60]:')} `,
|
||||
);
|
||||
const timeout = parseInt(timeoutInput, 10);
|
||||
if (timeoutInput.trim() && !isNaN(timeout)) {
|
||||
newAction.scriptTimeout = timeout * 1000;
|
||||
}
|
||||
} else if (typeValue === 4) {
|
||||
// Proxmox action
|
||||
newAction.type = 'proxmox';
|
||||
|
||||
// Auto-detect CLI availability
|
||||
const detection = ProxmoxAction.detectCliAvailability();
|
||||
|
||||
if (detection.available) {
|
||||
logger.log('');
|
||||
logger.success('Proxmox CLI tools detected (qm/pct). No API token needed.');
|
||||
logger.dim(` qm: ${detection.qmPath}`);
|
||||
logger.dim(` pct: ${detection.pctPath}`);
|
||||
newAction.proxmoxMode = 'cli';
|
||||
} else {
|
||||
logger.log('');
|
||||
if (!detection.isRoot) {
|
||||
logger.warn('Not running as root - CLI mode unavailable, using API mode.');
|
||||
} else {
|
||||
logger.warn('Proxmox CLI tools (qm/pct) not found - using API mode.');
|
||||
}
|
||||
logger.log('');
|
||||
logger.info('Proxmox API Settings:');
|
||||
logger.dim('Create a token with: pveum user token add root@pam nupst --privsep=0');
|
||||
|
||||
const pxHost = await prompt(
|
||||
` ${theme.dim('Proxmox Host')} ${theme.dim('[localhost]:')} `,
|
||||
);
|
||||
newAction.proxmoxHost = pxHost.trim() || 'localhost';
|
||||
|
||||
const pxPortInput = await prompt(
|
||||
` ${theme.dim('Proxmox API Port')} ${theme.dim('[8006]:')} `,
|
||||
);
|
||||
const pxPort = parseInt(pxPortInput, 10);
|
||||
newAction.proxmoxPort = pxPortInput.trim() && !isNaN(pxPort) ? pxPort : 8006;
|
||||
|
||||
const pxNode = await prompt(
|
||||
` ${theme.dim('Proxmox Node Name (empty = auto-detect):')} `,
|
||||
);
|
||||
if (pxNode.trim()) {
|
||||
newAction.proxmoxNode = pxNode.trim();
|
||||
}
|
||||
|
||||
const tokenId = await prompt(` ${theme.dim('API Token ID (e.g., root@pam!nupst):')} `);
|
||||
if (!tokenId.trim()) {
|
||||
logger.error('Token ID is required for API mode.');
|
||||
process.exit(1);
|
||||
}
|
||||
newAction.proxmoxTokenId = tokenId.trim();
|
||||
|
||||
const tokenSecret = await prompt(` ${theme.dim('API Token Secret:')} `);
|
||||
if (!tokenSecret.trim()) {
|
||||
logger.error('Token Secret is required for API mode.');
|
||||
process.exit(1);
|
||||
}
|
||||
newAction.proxmoxTokenSecret = tokenSecret.trim();
|
||||
|
||||
const insecureInput = await prompt(
|
||||
` ${theme.dim('Skip TLS verification (self-signed cert)?')} ${theme.dim('(Y/n):')} `,
|
||||
);
|
||||
newAction.proxmoxInsecure = insecureInput.toLowerCase() !== 'n';
|
||||
newAction.proxmoxMode = 'api';
|
||||
}
|
||||
|
||||
// Common Proxmox settings (both modes)
|
||||
const excludeInput = await prompt(
|
||||
` ${theme.dim('VM/CT IDs to exclude (comma-separated, or empty):')} `,
|
||||
);
|
||||
if (excludeInput.trim()) {
|
||||
newAction.proxmoxExcludeIds = excludeInput.split(',').map((s) => parseInt(s.trim(), 10))
|
||||
.filter((n) => !isNaN(n));
|
||||
}
|
||||
|
||||
const timeoutInput = await prompt(
|
||||
` ${theme.dim('VM shutdown timeout in seconds')} ${theme.dim('[120]:')} `,
|
||||
);
|
||||
const stopTimeout = parseInt(timeoutInput, 10);
|
||||
if (timeoutInput.trim() && !isNaN(stopTimeout)) {
|
||||
newAction.proxmoxStopTimeout = stopTimeout;
|
||||
}
|
||||
|
||||
const forceInput = await prompt(
|
||||
` ${theme.dim("Force-stop VMs that don't shut down in time?")} ${
|
||||
theme.dim('(Y/n):')
|
||||
} `,
|
||||
);
|
||||
newAction.proxmoxForceStop = forceInput.toLowerCase() !== 'n';
|
||||
|
||||
const haPolicyInput = await prompt(
|
||||
` ${theme.dim('HA-managed guest handling')} ${theme.dim('([1] none, 2 haStop):')} `,
|
||||
);
|
||||
newAction.proxmoxHaPolicy = haPolicyInput.trim() === '2' ? 'haStop' : 'none';
|
||||
} else {
|
||||
logger.error('Invalid action type.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Battery threshold (all action types)
|
||||
logger.log('');
|
||||
const batteryStr = await prompt(
|
||||
` ${theme.dim('Battery threshold')} ${theme.dim('(%):')} `,
|
||||
);
|
||||
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);
|
||||
}
|
||||
|
||||
newAction.thresholds = { battery, runtime };
|
||||
|
||||
// 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';
|
||||
newAction.triggerMode = triggerMode as IActionConfig['triggerMode'];
|
||||
const newAction = await this.promptForActionConfig(prompt);
|
||||
|
||||
// Add to target (UPS or group)
|
||||
if (!target!.actions) {
|
||||
target!.actions = [];
|
||||
if (!targetSnapshot.target.actions) {
|
||||
targetSnapshot.target.actions = [];
|
||||
}
|
||||
target!.actions.push(newAction as IActionConfig);
|
||||
targetSnapshot.target.actions.push(newAction);
|
||||
|
||||
await this.nupst.getDaemon().saveConfig(config);
|
||||
|
||||
logger.log('');
|
||||
logger.success(`Action added to ${targetType} ${targetName}`);
|
||||
logger.success(`Action added to ${targetSnapshot.targetType} ${targetSnapshot.targetName}`);
|
||||
logger.log(` ${theme.dim('Changes saved and will be applied automatically')}`);
|
||||
logger.log('');
|
||||
});
|
||||
@@ -313,6 +75,98 @@ export class ActionHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit an existing action on a UPS or group
|
||||
*/
|
||||
public async edit(targetId?: string, actionIndexStr?: string): Promise<void> {
|
||||
try {
|
||||
await helpers.withPrompt(async (prompt) => {
|
||||
await this.runEditProcess(targetId, actionIndexStr, prompt);
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to edit action: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the interactive process to edit an action
|
||||
*/
|
||||
public async runEditProcess(
|
||||
targetId: string | undefined,
|
||||
actionIndexStr: string | undefined,
|
||||
prompt: (question: string) => Promise<string>,
|
||||
): Promise<void> {
|
||||
if (!targetId || !actionIndexStr) {
|
||||
logger.error('Target ID and action index are required');
|
||||
logger.log(
|
||||
` ${theme.dim('Usage:')} ${
|
||||
theme.command('nupst action edit <ups-id|group-id> <action-index>')
|
||||
}`,
|
||||
);
|
||||
logger.log('');
|
||||
logger.log(` ${theme.dim('List actions:')} ${theme.command('nupst action list')}`);
|
||||
logger.log('');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const actionIndex = parseInt(actionIndexStr, 10);
|
||||
if (isNaN(actionIndex) || actionIndex < 0) {
|
||||
logger.error('Invalid action index. Must be >= 0.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const config = await this.nupst.getDaemon().loadConfig();
|
||||
const targetSnapshot = this.resolveActionTarget(config, targetId);
|
||||
|
||||
if (!targetSnapshot.target.actions || targetSnapshot.target.actions.length === 0) {
|
||||
logger.error(
|
||||
`No actions configured for ${targetSnapshot.targetType} '${targetSnapshot.targetName}'`,
|
||||
);
|
||||
logger.log('');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (actionIndex >= targetSnapshot.target.actions.length) {
|
||||
logger.error(
|
||||
`Invalid action index. ${targetSnapshot.targetType} '${targetSnapshot.targetName}' has ${targetSnapshot.target.actions.length} action(s) (index 0-${
|
||||
targetSnapshot.target.actions.length - 1
|
||||
})`,
|
||||
);
|
||||
logger.log('');
|
||||
logger.log(
|
||||
` ${theme.dim('List actions:')} ${theme.command(`nupst action list ${targetId}`)}`,
|
||||
);
|
||||
logger.log('');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const currentAction = targetSnapshot.target.actions[actionIndex];
|
||||
|
||||
logger.log('');
|
||||
logger.info(
|
||||
`Edit Action ${theme.highlight(String(actionIndex))} on ${targetSnapshot.targetType} ${
|
||||
theme.highlight(targetSnapshot.targetName)
|
||||
}`,
|
||||
);
|
||||
logger.log(` ${theme.dim('Current type:')} ${theme.highlight(currentAction.type)}`);
|
||||
logger.log('');
|
||||
|
||||
const updatedAction = await this.promptForActionConfig(prompt, currentAction);
|
||||
targetSnapshot.target.actions[actionIndex] = updatedAction;
|
||||
|
||||
await this.nupst.getDaemon().saveConfig(config);
|
||||
|
||||
logger.log('');
|
||||
logger.success(`Action updated on ${targetSnapshot.targetType} ${targetSnapshot.targetName}`);
|
||||
logger.log(` ${theme.dim('Index:')} ${actionIndex}`);
|
||||
logger.log(` ${theme.dim('Type:')} ${updatedAction.type}`);
|
||||
logger.log(` ${theme.dim('Changes saved and will be applied automatically')}`);
|
||||
logger.log('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an action from a UPS or group
|
||||
*/
|
||||
@@ -477,6 +331,408 @@ export class ActionHandler {
|
||||
}
|
||||
}
|
||||
|
||||
private resolveActionTarget(
|
||||
config: { upsDevices: IUpsConfig[]; groups?: IGroupConfig[] },
|
||||
targetId: string,
|
||||
): { target: IUpsConfig | IGroupConfig; targetType: 'UPS' | 'Group'; targetName: string } {
|
||||
const ups = config.upsDevices.find((u) => u.id === targetId);
|
||||
const group = config.groups?.find((g) => g.id === targetId);
|
||||
|
||||
if (!ups && !group) {
|
||||
logger.error(`UPS or Group with ID '${targetId}' not found`);
|
||||
logger.log('');
|
||||
logger.log(
|
||||
` ${theme.dim('List available UPS devices:')} ${theme.command('nupst ups list')}`,
|
||||
);
|
||||
logger.log(` ${theme.dim('List available groups:')} ${theme.command('nupst group list')}`);
|
||||
logger.log('');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return {
|
||||
target: (ups || group)!,
|
||||
targetType: ups ? 'UPS' : 'Group',
|
||||
targetName: ups ? ups.name : group!.name,
|
||||
};
|
||||
}
|
||||
|
||||
private isClearInput(input: string): boolean {
|
||||
return input.trim().toLowerCase() === 'clear';
|
||||
}
|
||||
|
||||
private getActionTypeValue(action?: IActionConfig): number {
|
||||
switch (action?.type) {
|
||||
case 'webhook':
|
||||
return 2;
|
||||
case 'script':
|
||||
return 3;
|
||||
case 'proxmox':
|
||||
return 4;
|
||||
case 'shutdown':
|
||||
default:
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
private getTriggerModeValue(action?: IActionConfig): number {
|
||||
switch (action?.triggerMode) {
|
||||
case 'onlyPowerChanges':
|
||||
return 1;
|
||||
case 'powerChangesAndThresholds':
|
||||
return 3;
|
||||
case 'anyChange':
|
||||
return 4;
|
||||
case 'onlyThresholds':
|
||||
default:
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
private async promptForActionConfig(
|
||||
prompt: (question: string) => Promise<string>,
|
||||
existingAction?: IActionConfig,
|
||||
): Promise<IActionConfig> {
|
||||
logger.log(` ${theme.dim('Action Type:')}`);
|
||||
logger.log(` ${theme.dim('1)')} Shutdown (system shutdown)`);
|
||||
logger.log(` ${theme.dim('2)')} Webhook (HTTP notification)`);
|
||||
logger.log(` ${theme.dim('3)')} Custom Script (run .sh file from /etc/nupst)`);
|
||||
logger.log(
|
||||
` ${theme.dim('4)')} Proxmox (gracefully shut down VMs/LXCs before host shutdown)`,
|
||||
);
|
||||
|
||||
const defaultTypeValue = this.getActionTypeValue(existingAction);
|
||||
const typeInput = await prompt(
|
||||
` ${theme.dim('Select action type')} ${theme.dim(`[${defaultTypeValue}]:`)} `,
|
||||
);
|
||||
const typeValue = parseInt(typeInput, 10) || defaultTypeValue;
|
||||
const newAction: Partial<IActionConfig> = {};
|
||||
|
||||
if (typeValue === 1) {
|
||||
const shutdownAction = existingAction?.type === 'shutdown' ? existingAction : undefined;
|
||||
const defaultShutdownDelay = this.nupst.getDaemon().getConfig().defaultShutdownDelay ??
|
||||
SHUTDOWN.DEFAULT_DELAY_MINUTES;
|
||||
|
||||
newAction.type = 'shutdown';
|
||||
|
||||
const delayPrompt = shutdownAction?.shutdownDelay !== undefined
|
||||
? ` ${theme.dim('Shutdown delay')} ${
|
||||
theme.dim(
|
||||
`(minutes, 'clear' = default ${defaultShutdownDelay}) [${shutdownAction.shutdownDelay}]:`,
|
||||
)
|
||||
} `
|
||||
: ` ${theme.dim('Shutdown delay')} ${
|
||||
theme.dim(`(minutes, leave empty for default ${defaultShutdownDelay}):`)
|
||||
} `;
|
||||
const delayInput = await prompt(delayPrompt);
|
||||
if (this.isClearInput(delayInput)) {
|
||||
// Leave unset so the config-level default is used.
|
||||
} else if (delayInput.trim()) {
|
||||
const shutdownDelay = parseInt(delayInput, 10);
|
||||
if (isNaN(shutdownDelay) || shutdownDelay < 0) {
|
||||
logger.error('Invalid shutdown delay. Must be >= 0.');
|
||||
process.exit(1);
|
||||
}
|
||||
newAction.shutdownDelay = shutdownDelay;
|
||||
} else if (shutdownAction?.shutdownDelay !== undefined) {
|
||||
newAction.shutdownDelay = shutdownAction.shutdownDelay;
|
||||
}
|
||||
} else if (typeValue === 2) {
|
||||
const webhookAction = existingAction?.type === 'webhook' ? existingAction : undefined;
|
||||
newAction.type = 'webhook';
|
||||
|
||||
const webhookUrlInput = await prompt(
|
||||
` ${theme.dim('Webhook URL')} ${
|
||||
theme.dim(webhookAction?.webhookUrl ? `[${webhookAction.webhookUrl}]:` : ':')
|
||||
} `,
|
||||
);
|
||||
const webhookUrl = webhookUrlInput.trim() || webhookAction?.webhookUrl || '';
|
||||
if (!webhookUrl) {
|
||||
logger.error('Webhook URL is required.');
|
||||
process.exit(1);
|
||||
}
|
||||
newAction.webhookUrl = webhookUrl;
|
||||
|
||||
logger.log('');
|
||||
logger.log(` ${theme.dim('HTTP Method:')}`);
|
||||
logger.log(` ${theme.dim('1)')} POST (JSON body)`);
|
||||
logger.log(` ${theme.dim('2)')} GET (query parameters)`);
|
||||
const defaultMethodValue = webhookAction?.webhookMethod === 'GET' ? 2 : 1;
|
||||
const methodInput = await prompt(
|
||||
` ${theme.dim('Select method')} ${theme.dim(`[${defaultMethodValue}]:`)} `,
|
||||
);
|
||||
const methodValue = parseInt(methodInput, 10) || defaultMethodValue;
|
||||
newAction.webhookMethod = methodValue === 2 ? 'GET' : 'POST';
|
||||
|
||||
const currentWebhookTimeout = webhookAction?.webhookTimeout;
|
||||
const timeoutPrompt = currentWebhookTimeout !== undefined
|
||||
? ` ${theme.dim('Timeout in seconds')} ${
|
||||
theme.dim(`('clear' to unset) [${Math.floor(currentWebhookTimeout / 1000)}]:`)
|
||||
} `
|
||||
: ` ${theme.dim('Timeout in seconds')} ${theme.dim('[10]:')} `;
|
||||
const timeoutInput = await prompt(timeoutPrompt);
|
||||
if (this.isClearInput(timeoutInput)) {
|
||||
// Leave unset.
|
||||
} else if (timeoutInput.trim()) {
|
||||
const timeout = parseInt(timeoutInput, 10);
|
||||
if (isNaN(timeout) || timeout < 0) {
|
||||
logger.error('Invalid webhook timeout. Must be >= 0.');
|
||||
process.exit(1);
|
||||
}
|
||||
newAction.webhookTimeout = timeout * 1000;
|
||||
} else if (currentWebhookTimeout !== undefined) {
|
||||
newAction.webhookTimeout = currentWebhookTimeout;
|
||||
}
|
||||
} else if (typeValue === 3) {
|
||||
const scriptAction = existingAction?.type === 'script' ? existingAction : undefined;
|
||||
newAction.type = 'script';
|
||||
|
||||
const scriptPathInput = await prompt(
|
||||
` ${theme.dim('Script filename (in /etc/nupst/, must end with .sh)')} ${
|
||||
theme.dim(scriptAction?.scriptPath ? `[${scriptAction.scriptPath}]:` : ':')
|
||||
} `,
|
||||
);
|
||||
const scriptPath = scriptPathInput.trim() || scriptAction?.scriptPath || '';
|
||||
if (!scriptPath || !scriptPath.endsWith('.sh')) {
|
||||
logger.error('Script path must end with .sh.');
|
||||
process.exit(1);
|
||||
}
|
||||
newAction.scriptPath = scriptPath;
|
||||
|
||||
const currentScriptTimeout = scriptAction?.scriptTimeout;
|
||||
const timeoutPrompt = currentScriptTimeout !== undefined
|
||||
? ` ${theme.dim('Script timeout in seconds')} ${
|
||||
theme.dim(`('clear' to unset) [${Math.floor(currentScriptTimeout / 1000)}]:`)
|
||||
} `
|
||||
: ` ${theme.dim('Script timeout in seconds')} ${theme.dim('[60]:')} `;
|
||||
const timeoutInput = await prompt(timeoutPrompt);
|
||||
if (this.isClearInput(timeoutInput)) {
|
||||
// Leave unset.
|
||||
} else if (timeoutInput.trim()) {
|
||||
const timeout = parseInt(timeoutInput, 10);
|
||||
if (isNaN(timeout) || timeout < 0) {
|
||||
logger.error('Invalid script timeout. Must be >= 0.');
|
||||
process.exit(1);
|
||||
}
|
||||
newAction.scriptTimeout = timeout * 1000;
|
||||
} else if (currentScriptTimeout !== undefined) {
|
||||
newAction.scriptTimeout = currentScriptTimeout;
|
||||
}
|
||||
} else if (typeValue === 4) {
|
||||
const proxmoxAction = existingAction?.type === 'proxmox' ? existingAction : undefined;
|
||||
const detection = ProxmoxAction.detectCliAvailability();
|
||||
let useApiMode = false;
|
||||
|
||||
newAction.type = 'proxmox';
|
||||
|
||||
if (detection.available) {
|
||||
logger.log('');
|
||||
logger.success('Proxmox CLI tools detected (qm/pct).');
|
||||
logger.dim(` qm: ${detection.qmPath}`);
|
||||
logger.dim(` pct: ${detection.pctPath}`);
|
||||
|
||||
if (proxmoxAction) {
|
||||
logger.log('');
|
||||
logger.log(` ${theme.dim('Proxmox mode:')}`);
|
||||
logger.log(` ${theme.dim('1)')} CLI (local qm/pct tools)`);
|
||||
logger.log(` ${theme.dim('2)')} API (REST token authentication)`);
|
||||
const defaultModeValue = proxmoxAction.proxmoxMode === 'api' ? 2 : 1;
|
||||
const modeInput = await prompt(
|
||||
` ${theme.dim('Select Proxmox mode')} ${theme.dim(`[${defaultModeValue}]:`)} `,
|
||||
);
|
||||
const modeValue = parseInt(modeInput, 10) || defaultModeValue;
|
||||
useApiMode = modeValue === 2;
|
||||
}
|
||||
} else {
|
||||
logger.log('');
|
||||
if (!detection.isRoot) {
|
||||
logger.warn('Not running as root - CLI mode unavailable, using API mode.');
|
||||
} else {
|
||||
logger.warn('Proxmox CLI tools (qm/pct) not found - using API mode.');
|
||||
}
|
||||
useApiMode = true;
|
||||
}
|
||||
|
||||
if (useApiMode) {
|
||||
logger.log('');
|
||||
logger.info('Proxmox API Settings:');
|
||||
logger.dim('Create a token with: pveum user token add root@pam nupst --privsep=0');
|
||||
|
||||
const currentHost = proxmoxAction?.proxmoxHost || 'localhost';
|
||||
const pxHost = await prompt(
|
||||
` ${theme.dim('Proxmox Host')} ${theme.dim(`[${currentHost}]:`)} `,
|
||||
);
|
||||
newAction.proxmoxHost = pxHost.trim() || currentHost;
|
||||
|
||||
const currentPort = proxmoxAction?.proxmoxPort || 8006;
|
||||
const pxPortInput = await prompt(
|
||||
` ${theme.dim('Proxmox API Port')} ${theme.dim(`[${currentPort}]:`)} `,
|
||||
);
|
||||
const pxPort = parseInt(pxPortInput, 10);
|
||||
newAction.proxmoxPort = pxPortInput.trim() && !isNaN(pxPort) ? pxPort : currentPort;
|
||||
|
||||
const pxNodePrompt = proxmoxAction?.proxmoxNode
|
||||
? ` ${theme.dim('Proxmox Node Name')} ${
|
||||
theme.dim(`('clear' = auto-detect) [${proxmoxAction.proxmoxNode}]:`)
|
||||
} `
|
||||
: ` ${theme.dim('Proxmox Node Name')} ${theme.dim('(empty = auto-detect):')} `;
|
||||
const pxNode = await prompt(pxNodePrompt);
|
||||
if (this.isClearInput(pxNode)) {
|
||||
// Leave unset so hostname auto-detection is used.
|
||||
} else if (pxNode.trim()) {
|
||||
newAction.proxmoxNode = pxNode.trim();
|
||||
} else if (proxmoxAction?.proxmoxNode) {
|
||||
newAction.proxmoxNode = proxmoxAction.proxmoxNode;
|
||||
}
|
||||
|
||||
const currentTokenId = proxmoxAction?.proxmoxTokenId || '';
|
||||
const tokenIdInput = await prompt(
|
||||
` ${theme.dim('API Token ID (e.g., root@pam!nupst)')} ${
|
||||
theme.dim(currentTokenId ? `[${currentTokenId}]:` : ':')
|
||||
} `,
|
||||
);
|
||||
const tokenId = tokenIdInput.trim() || currentTokenId;
|
||||
if (!tokenId) {
|
||||
logger.error('Token ID is required for API mode.');
|
||||
process.exit(1);
|
||||
}
|
||||
newAction.proxmoxTokenId = tokenId;
|
||||
|
||||
const currentTokenSecret = proxmoxAction?.proxmoxTokenSecret || '';
|
||||
const tokenSecretInput = await prompt(
|
||||
` ${theme.dim('API Token Secret')} ${theme.dim(currentTokenSecret ? '[*****]:' : ':')} `,
|
||||
);
|
||||
const tokenSecret = tokenSecretInput.trim() || currentTokenSecret;
|
||||
if (!tokenSecret) {
|
||||
logger.error('Token Secret is required for API mode.');
|
||||
process.exit(1);
|
||||
}
|
||||
newAction.proxmoxTokenSecret = tokenSecret;
|
||||
|
||||
const defaultInsecure = proxmoxAction?.proxmoxInsecure !== false;
|
||||
const insecureInput = await prompt(
|
||||
` ${theme.dim('Skip TLS verification (self-signed cert)?')} ${
|
||||
theme.dim(defaultInsecure ? '(Y/n):' : '(y/N):')
|
||||
} `,
|
||||
);
|
||||
newAction.proxmoxInsecure = insecureInput.trim()
|
||||
? insecureInput.toLowerCase() !== 'n'
|
||||
: defaultInsecure;
|
||||
newAction.proxmoxMode = 'api';
|
||||
} else {
|
||||
newAction.proxmoxMode = 'cli';
|
||||
}
|
||||
|
||||
const currentExcludeIds = proxmoxAction?.proxmoxExcludeIds || [];
|
||||
const excludePrompt = currentExcludeIds.length > 0
|
||||
? ` ${theme.dim('VM/CT IDs to exclude')} ${
|
||||
theme.dim(`(comma-separated, 'clear' = none) [${currentExcludeIds.join(',')}]:`)
|
||||
} `
|
||||
: ` ${theme.dim('VM/CT IDs to exclude (comma-separated, or empty):')} `;
|
||||
const excludeInput = await prompt(excludePrompt);
|
||||
if (this.isClearInput(excludeInput)) {
|
||||
newAction.proxmoxExcludeIds = [];
|
||||
} else if (excludeInput.trim()) {
|
||||
newAction.proxmoxExcludeIds = excludeInput.split(',').map((s) => parseInt(s.trim(), 10))
|
||||
.filter((n) => !isNaN(n));
|
||||
} else if (currentExcludeIds.length > 0) {
|
||||
newAction.proxmoxExcludeIds = [...currentExcludeIds];
|
||||
}
|
||||
|
||||
const currentStopTimeout = proxmoxAction?.proxmoxStopTimeout;
|
||||
const stopTimeoutPrompt = currentStopTimeout !== undefined
|
||||
? ` ${theme.dim('VM shutdown timeout in seconds')} ${
|
||||
theme.dim(`('clear' to unset) [${currentStopTimeout}]:`)
|
||||
} `
|
||||
: ` ${theme.dim('VM shutdown timeout in seconds')} ${theme.dim('[120]:')} `;
|
||||
const timeoutInput = await prompt(stopTimeoutPrompt);
|
||||
if (this.isClearInput(timeoutInput)) {
|
||||
// Leave unset.
|
||||
} else if (timeoutInput.trim()) {
|
||||
const stopTimeout = parseInt(timeoutInput, 10);
|
||||
if (isNaN(stopTimeout) || stopTimeout < 0) {
|
||||
logger.error('Invalid VM shutdown timeout. Must be >= 0.');
|
||||
process.exit(1);
|
||||
}
|
||||
newAction.proxmoxStopTimeout = stopTimeout;
|
||||
} else if (currentStopTimeout !== undefined) {
|
||||
newAction.proxmoxStopTimeout = currentStopTimeout;
|
||||
}
|
||||
|
||||
const defaultForceStop = proxmoxAction?.proxmoxForceStop !== false;
|
||||
const forceInput = await prompt(
|
||||
` ${theme.dim("Force-stop VMs that don't shut down in time?")} ${
|
||||
theme.dim(defaultForceStop ? '(Y/n):' : '(y/N):')
|
||||
} `,
|
||||
);
|
||||
newAction.proxmoxForceStop = forceInput.trim()
|
||||
? forceInput.toLowerCase() !== 'n'
|
||||
: defaultForceStop;
|
||||
|
||||
const defaultHaPolicyValue = proxmoxAction?.proxmoxHaPolicy === 'haStop' ? 2 : 1;
|
||||
const haPolicyInput = await prompt(
|
||||
` ${theme.dim('HA-managed guest handling')} ${
|
||||
theme.dim(`([1] none, 2 haStop) [${defaultHaPolicyValue}]:`)
|
||||
} `,
|
||||
);
|
||||
const haPolicyValue = parseInt(haPolicyInput, 10) || defaultHaPolicyValue;
|
||||
newAction.proxmoxHaPolicy = haPolicyValue === 2 ? 'haStop' : 'none';
|
||||
} else {
|
||||
logger.error('Invalid action type.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
logger.log('');
|
||||
const defaultBatteryThreshold = existingAction?.thresholds?.battery ?? 60;
|
||||
const batteryInput = await prompt(
|
||||
` ${theme.dim('Battery threshold')} ${theme.dim(`(%) [${defaultBatteryThreshold}]:`)} `,
|
||||
);
|
||||
const battery = batteryInput.trim() ? parseInt(batteryInput, 10) : defaultBatteryThreshold;
|
||||
if (isNaN(battery) || battery < 0 || battery > 100) {
|
||||
logger.error('Invalid battery threshold. Must be 0-100.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const defaultRuntimeThreshold = existingAction?.thresholds?.runtime ?? 20;
|
||||
const runtimeInput = await prompt(
|
||||
` ${theme.dim('Runtime threshold')} ${
|
||||
theme.dim(`(minutes) [${defaultRuntimeThreshold}]:`)
|
||||
} `,
|
||||
);
|
||||
const runtime = runtimeInput.trim() ? parseInt(runtimeInput, 10) : defaultRuntimeThreshold;
|
||||
if (isNaN(runtime) || runtime < 0) {
|
||||
logger.error('Invalid runtime threshold. Must be >= 0.');
|
||||
process.exit(1);
|
||||
}
|
||||
newAction.thresholds = { battery, runtime };
|
||||
|
||||
logger.log('');
|
||||
logger.log(` ${theme.dim('Trigger mode:')}`);
|
||||
logger.log(
|
||||
` ${theme.dim('1)')} onlyPowerChanges - Trigger only when power status changes`,
|
||||
);
|
||||
logger.log(
|
||||
` ${theme.dim('2)')} onlyThresholds - Trigger only when thresholds are violated`,
|
||||
);
|
||||
logger.log(
|
||||
` ${theme.dim('3)')} powerChangesAndThresholds - Trigger on power change AND thresholds`,
|
||||
);
|
||||
logger.log(` ${theme.dim('4)')} anyChange - Trigger on any status change`);
|
||||
const defaultTriggerValue = this.getTriggerModeValue(existingAction);
|
||||
const triggerChoice = await prompt(
|
||||
` ${theme.dim('Choice')} ${theme.dim(`[${defaultTriggerValue}]:`)} `,
|
||||
);
|
||||
const triggerValue = parseInt(triggerChoice, 10) || defaultTriggerValue;
|
||||
const triggerModeMap: Record<number, NonNullable<IActionConfig['triggerMode']>> = {
|
||||
1: 'onlyPowerChanges',
|
||||
2: 'onlyThresholds',
|
||||
3: 'powerChangesAndThresholds',
|
||||
4: 'anyChange',
|
||||
};
|
||||
newAction.triggerMode = triggerModeMap[triggerValue] || 'onlyThresholds';
|
||||
|
||||
return newAction as IActionConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display actions for a single UPS or Group
|
||||
*/
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { TProtocol } from '../protocol/types.ts';
|
||||
import type { INupstConfig, IUpsConfig } from '../daemon.ts';
|
||||
import type { IActionConfig } from '../actions/base-action.ts';
|
||||
import { ProxmoxAction } from '../actions/proxmox-action.ts';
|
||||
import { getDefaultRuntimeUnitForUpsModel } from '../snmp/runtime-units.ts';
|
||||
import { SHUTDOWN, UPSD } from '../constants.ts';
|
||||
|
||||
/**
|
||||
@@ -996,18 +997,16 @@ export class UpsHandler {
|
||||
logger.log('');
|
||||
logger.info('Battery Runtime Unit:');
|
||||
logger.dim(' Controls how NUPST interprets the runtime value from your UPS.');
|
||||
logger.dim(' 1) Minutes (APC, TrippLite, Liebert - most common)');
|
||||
logger.dim(' 1) Minutes (TrippLite, Liebert, many RFC 1628 devices)');
|
||||
logger.dim(' 2) Seconds (Eaton, HPE, many RFC 1628 devices)');
|
||||
logger.dim(' 3) Ticks (CyberPower - 1/100 second increments)');
|
||||
logger.dim(' 3) Ticks (CyberPower, APC PowerNet - 1/100 second increments)');
|
||||
|
||||
const defaultUnitValue = snmpConfig.runtimeUnit === 'seconds'
|
||||
const defaultRuntimeUnit = snmpConfig.runtimeUnit ||
|
||||
getDefaultRuntimeUnitForUpsModel(snmpConfig.upsModel);
|
||||
const defaultUnitValue = defaultRuntimeUnit === 'seconds'
|
||||
? 2
|
||||
: snmpConfig.runtimeUnit === 'ticks'
|
||||
: defaultRuntimeUnit === 'ticks'
|
||||
? 3
|
||||
: snmpConfig.upsModel === 'cyberpower'
|
||||
? 3
|
||||
: snmpConfig.upsModel === 'eaton'
|
||||
? 2
|
||||
: 1;
|
||||
|
||||
const unitInput = await prompt(`Select runtime unit [${defaultUnitValue}]: `);
|
||||
|
||||
@@ -75,7 +75,9 @@ export function getRuntimeColor(minutes: number): (text: string) => string {
|
||||
/**
|
||||
* Format UPS power status with color
|
||||
*/
|
||||
export function formatPowerStatus(status: 'online' | 'onBattery' | 'unknown' | 'unreachable'): string {
|
||||
export function formatPowerStatus(
|
||||
status: 'online' | 'onBattery' | 'unknown' | 'unreachable',
|
||||
): string {
|
||||
switch (status) {
|
||||
case 'online':
|
||||
return theme.success('Online');
|
||||
|
||||
@@ -137,7 +137,7 @@ export class NupstDaemon {
|
||||
|
||||
/** Default configuration */
|
||||
private readonly DEFAULT_CONFIG: INupstConfig = {
|
||||
version: '4.3',
|
||||
version: '4.4',
|
||||
defaultShutdownDelay: SHUTDOWN.DEFAULT_DELAY_MINUTES,
|
||||
upsDevices: [
|
||||
{
|
||||
@@ -264,7 +264,7 @@ export class NupstDaemon {
|
||||
|
||||
// Ensure version is always set and remove legacy fields before saving
|
||||
const configToSave: INupstConfig = {
|
||||
version: '4.3',
|
||||
version: '4.4',
|
||||
upsDevices: config.upsDevices,
|
||||
groups: config.groups,
|
||||
checkInterval: config.checkInterval,
|
||||
|
||||
@@ -11,3 +11,4 @@ export { MigrationV3ToV4 } from './migration-v3-to-v4.ts';
|
||||
export { MigrationV4_0ToV4_1 } from './migration-v4.0-to-v4.1.ts';
|
||||
export { MigrationV4_1ToV4_2 } from './migration-v4.1-to-v4.2.ts';
|
||||
export { MigrationV4_2ToV4_3 } from './migration-v4.2-to-v4.3.ts';
|
||||
export { MigrationV4_3ToV4_4 } from './migration-v4.3-to-v4.4.ts';
|
||||
|
||||
@@ -4,6 +4,7 @@ import { MigrationV3ToV4 } from './migration-v3-to-v4.ts';
|
||||
import { MigrationV4_0ToV4_1 } from './migration-v4.0-to-v4.1.ts';
|
||||
import { MigrationV4_1ToV4_2 } from './migration-v4.1-to-v4.2.ts';
|
||||
import { MigrationV4_2ToV4_3 } from './migration-v4.2-to-v4.3.ts';
|
||||
import { MigrationV4_3ToV4_4 } from './migration-v4.3-to-v4.4.ts';
|
||||
import { logger } from '../logger.ts';
|
||||
|
||||
/**
|
||||
@@ -23,6 +24,7 @@ export class MigrationRunner {
|
||||
new MigrationV4_0ToV4_1(),
|
||||
new MigrationV4_1ToV4_2(),
|
||||
new MigrationV4_2ToV4_3(),
|
||||
new MigrationV4_3ToV4_4(),
|
||||
];
|
||||
|
||||
// Sort by version order to ensure they run in sequence
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BaseMigration } from './base-migration.ts';
|
||||
import { logger } from '../logger.ts';
|
||||
import { getDefaultRuntimeUnitForUpsModel } from '../snmp/runtime-units.ts';
|
||||
|
||||
/**
|
||||
* Migration from v4.2 to v4.3
|
||||
@@ -23,14 +24,15 @@ export class MigrationV4_2ToV4_3 extends BaseMigration {
|
||||
const migratedDevices = devices.map((device) => {
|
||||
const snmp = device.snmp as Record<string, unknown> | undefined;
|
||||
if (snmp && !snmp.runtimeUnit) {
|
||||
const model = snmp.upsModel as string | undefined;
|
||||
if (model === 'cyberpower') {
|
||||
snmp.runtimeUnit = 'ticks';
|
||||
} else if (model === 'eaton') {
|
||||
snmp.runtimeUnit = 'seconds';
|
||||
} else {
|
||||
snmp.runtimeUnit = 'minutes';
|
||||
}
|
||||
const model = snmp.upsModel as
|
||||
| 'cyberpower'
|
||||
| 'apc'
|
||||
| 'eaton'
|
||||
| 'tripplite'
|
||||
| 'liebert'
|
||||
| 'custom'
|
||||
| undefined;
|
||||
snmp.runtimeUnit = getDefaultRuntimeUnitForUpsModel(model);
|
||||
logger.dim(` → ${device.name}: Set runtimeUnit to '${snmp.runtimeUnit}'`);
|
||||
}
|
||||
return device;
|
||||
|
||||
50
ts/migrations/migration-v4.3-to-v4.4.ts
Normal file
50
ts/migrations/migration-v4.3-to-v4.4.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { BaseMigration } from './base-migration.ts';
|
||||
import { logger } from '../logger.ts';
|
||||
|
||||
/**
|
||||
* Migration from v4.3 to v4.4
|
||||
*
|
||||
* Changes:
|
||||
* 1. Corrects APC runtimeUnit defaults from minutes to ticks
|
||||
* 2. Bumps version from '4.3' to '4.4'
|
||||
*/
|
||||
export class MigrationV4_3ToV4_4 extends BaseMigration {
|
||||
readonly fromVersion = '4.3';
|
||||
readonly toVersion = '4.4';
|
||||
|
||||
shouldRun(config: Record<string, unknown>): boolean {
|
||||
return config.version === '4.3';
|
||||
}
|
||||
|
||||
migrate(config: Record<string, unknown>): Record<string, unknown> {
|
||||
logger.info(`${this.getName()}: Correcting APC runtimeUnit defaults...`);
|
||||
|
||||
let correctedDevices = 0;
|
||||
const devices = (config.upsDevices as Array<Record<string, unknown>>) || [];
|
||||
const migratedDevices = devices.map((device) => {
|
||||
const snmp = device.snmp as Record<string, unknown> | undefined;
|
||||
if (!snmp || snmp.upsModel !== 'apc') {
|
||||
return device;
|
||||
}
|
||||
|
||||
if (!snmp.runtimeUnit || snmp.runtimeUnit === 'minutes') {
|
||||
snmp.runtimeUnit = 'ticks';
|
||||
correctedDevices += 1;
|
||||
logger.dim(` → ${device.name}: Set runtimeUnit to 'ticks'`);
|
||||
}
|
||||
|
||||
return device;
|
||||
});
|
||||
|
||||
const result = {
|
||||
...config,
|
||||
version: this.toVersion,
|
||||
upsDevices: migratedDevices,
|
||||
};
|
||||
|
||||
logger.success(
|
||||
`${this.getName()}: Migration complete (${correctedDevices} APC device(s) corrected)`,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import * as snmp from 'npm:net-snmp@3.26.1';
|
||||
import { Buffer } from 'node:buffer';
|
||||
import type { IOidSet, ISnmpConfig, IUpsStatus, TUpsModel } from './types.ts';
|
||||
import { UpsOidSets } from './oid-sets.ts';
|
||||
import { convertRuntimeValueToMinutes, getDefaultRuntimeUnitForUpsModel } from './runtime-units.ts';
|
||||
import { SNMP } from '../constants.ts';
|
||||
import { logger } from '../logger.ts';
|
||||
import type { INupstAccessor } from '../interfaces/index.ts';
|
||||
@@ -707,56 +708,20 @@ export class NupstSnmp {
|
||||
logger.dim(`Raw runtime value: ${batteryRuntime}`);
|
||||
}
|
||||
|
||||
// Explicit runtimeUnit takes precedence over model-based detection
|
||||
if (config.runtimeUnit) {
|
||||
if (config.runtimeUnit === 'seconds' && batteryRuntime > 0) {
|
||||
const minutes = Math.floor(batteryRuntime / 60);
|
||||
if (this.debug) {
|
||||
logger.dim(
|
||||
`Converting runtime from ${batteryRuntime} seconds to ${minutes} minutes (runtimeUnit: seconds)`,
|
||||
);
|
||||
}
|
||||
return minutes;
|
||||
} else if (config.runtimeUnit === 'ticks' && batteryRuntime > 0) {
|
||||
const minutes = Math.floor(batteryRuntime / 6000);
|
||||
if (this.debug) {
|
||||
logger.dim(
|
||||
`Converting runtime from ${batteryRuntime} ticks to ${minutes} minutes (runtimeUnit: ticks)`,
|
||||
);
|
||||
}
|
||||
return minutes;
|
||||
}
|
||||
// runtimeUnit === 'minutes' — return as-is
|
||||
return batteryRuntime;
|
||||
const runtimeUnit = config.runtimeUnit ||
|
||||
getDefaultRuntimeUnitForUpsModel(config.upsModel, batteryRuntime);
|
||||
const minutes = convertRuntimeValueToMinutes(config, batteryRuntime);
|
||||
|
||||
if (this.debug && minutes !== batteryRuntime) {
|
||||
const source = config.runtimeUnit
|
||||
? `runtimeUnit: ${runtimeUnit}`
|
||||
: `upsModel: ${config.upsModel || 'auto'}`;
|
||||
logger.dim(
|
||||
`Converting runtime from ${batteryRuntime} ${runtimeUnit} to ${minutes} minutes (${source})`,
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback: model-based detection (for configs without runtimeUnit)
|
||||
const upsModel = config.upsModel;
|
||||
if (upsModel === 'cyberpower' && batteryRuntime > 0) {
|
||||
const minutes = Math.floor(batteryRuntime / 6000);
|
||||
if (this.debug) {
|
||||
logger.dim(
|
||||
`Converting CyberPower runtime from ${batteryRuntime} ticks to ${minutes} minutes`,
|
||||
);
|
||||
}
|
||||
return minutes;
|
||||
} else if (upsModel === 'eaton' && batteryRuntime > 0) {
|
||||
const minutes = Math.floor(batteryRuntime / 60);
|
||||
if (this.debug) {
|
||||
logger.dim(
|
||||
`Converting Eaton runtime from ${batteryRuntime} seconds to ${minutes} minutes`,
|
||||
);
|
||||
}
|
||||
return minutes;
|
||||
} else if (batteryRuntime > 10000) {
|
||||
const minutes = Math.floor(batteryRuntime / 6000);
|
||||
if (this.debug) {
|
||||
logger.dim(`Converting ${batteryRuntime} ticks to ${minutes} minutes (heuristic)`);
|
||||
}
|
||||
return minutes;
|
||||
}
|
||||
|
||||
return batteryRuntime;
|
||||
return minutes;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -28,7 +28,7 @@ export class UpsOidSets {
|
||||
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
|
||||
BATTERY_RUNTIME: '1.3.6.1.4.1.318.1.1.1.2.2.3.0', // Remaining runtime (TimeTicks)
|
||||
OUTPUT_LOAD: '1.3.6.1.4.1.318.1.1.1.4.2.3.0', // upsAdvOutputLoad (percentage)
|
||||
OUTPUT_POWER: '1.3.6.1.4.1.318.1.1.1.4.2.8.0', // upsAdvOutputActivePower (watts)
|
||||
OUTPUT_VOLTAGE: '1.3.6.1.4.1.318.1.1.1.4.2.1.0', // upsAdvOutputVoltage
|
||||
|
||||
50
ts/snmp/runtime-units.ts
Normal file
50
ts/snmp/runtime-units.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { ISnmpConfig, TRuntimeUnit, TUpsModel } from './types.ts';
|
||||
|
||||
/**
|
||||
* Return the runtime unit that matches the bundled OID set for a UPS model.
|
||||
*/
|
||||
export function getDefaultRuntimeUnitForUpsModel(
|
||||
upsModel: TUpsModel | undefined,
|
||||
batteryRuntime?: number,
|
||||
): TRuntimeUnit {
|
||||
switch (upsModel) {
|
||||
case 'cyberpower':
|
||||
case 'apc':
|
||||
return 'ticks';
|
||||
case 'eaton':
|
||||
return 'seconds';
|
||||
case 'custom':
|
||||
case 'tripplite':
|
||||
case 'liebert':
|
||||
case undefined:
|
||||
if (batteryRuntime !== undefined && batteryRuntime > 10000) {
|
||||
return 'ticks';
|
||||
}
|
||||
return 'minutes';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an SNMP runtime value to minutes using explicit config first, then model defaults.
|
||||
*/
|
||||
export function convertRuntimeValueToMinutes(
|
||||
config: Pick<ISnmpConfig, 'runtimeUnit' | 'upsModel'>,
|
||||
batteryRuntime: number,
|
||||
): number {
|
||||
if (batteryRuntime <= 0) {
|
||||
return batteryRuntime;
|
||||
}
|
||||
|
||||
const runtimeUnit = config.runtimeUnit ||
|
||||
getDefaultRuntimeUnitForUpsModel(config.upsModel, batteryRuntime);
|
||||
|
||||
if (runtimeUnit === 'seconds') {
|
||||
return Math.floor(batteryRuntime / 60);
|
||||
}
|
||||
|
||||
if (runtimeUnit === 'ticks') {
|
||||
return Math.floor(batteryRuntime / 6000);
|
||||
}
|
||||
|
||||
return batteryRuntime;
|
||||
}
|
||||
@@ -242,7 +242,9 @@ export class NupstUpsd {
|
||||
} catch (error) {
|
||||
if (this.debug) {
|
||||
logger.dim(
|
||||
`UPSD error getting ${varName}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
`UPSD error getting ${varName}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
|
||||
Reference in New Issue
Block a user