Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b2fa65611 | |||
| c42ebb56d3 | |||
| c7b52c48d5 | |||
| e2cfa67fee | |||
| e916ccf3ae | |||
| a435bd6fed | |||
| bf4d519428 | |||
| 579667b3cd |
@@ -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);
|
||||
|
||||
+112
-25
@@ -1,43 +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
|
||||
- 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
|
||||
|
||||
## 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
|
||||
|
||||
## 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
|
||||
@@ -47,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`.
|
||||
@@ -54,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.5.1",
|
||||
"version": "5.10.0",
|
||||
"exports": "./mod.ts",
|
||||
"nodeModulesDir": "auto",
|
||||
"tasks": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@serve.zone/nupst",
|
||||
"version": "5.5.1",
|
||||
"version": "5.10.0",
|
||||
"description": "Network UPS Shutdown Tool - Monitor SNMP-enabled UPS devices and orchestrate graceful system shutdowns during power emergencies",
|
||||
"keywords": [
|
||||
"ups",
|
||||
|
||||
Generated
+894
-359
File diff suppressed because it is too large
Load Diff
@@ -75,6 +75,8 @@
|
||||
shutdowns
|
||||
- `ts/daemon.ts` now delegates OS shutdown execution instead of embedding command lookup logic
|
||||
inline
|
||||
- `defaultShutdownDelay` in config provides the inherited delay for shutdown actions without an
|
||||
explicit `shutdownDelay` override
|
||||
|
||||
### Config Watch Handling
|
||||
|
||||
|
||||
@@ -1,29 +1,39 @@
|
||||
# ⚡ 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 (auto-detects CLI tools — no API token needed on Proxmox hosts)
|
||||
- **📡 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
|
||||
- **⚙️ Action System** — Define custom responses with flexible trigger conditions
|
||||
- Battery & runtime threshold triggers
|
||||
- Edge-triggered battery & runtime threshold triggers
|
||||
- Power status change triggers
|
||||
- Webhook notifications (POST/GET)
|
||||
- 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,14 +230,22 @@ 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`.
|
||||
|
||||
### Example Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "4.3",
|
||||
"version": "4.4",
|
||||
"checkInterval": 30000,
|
||||
"defaultShutdownDelay": 5,
|
||||
"httpServer": {
|
||||
"enabled": true,
|
||||
"port": 8080,
|
||||
@@ -251,6 +272,7 @@ NUPST stores configuration at `/etc/nupst/config.json`. The easiest way to confi
|
||||
"triggerMode": "onlyThresholds",
|
||||
"thresholds": { "battery": 30, "runtime": 15 },
|
||||
"proxmoxMode": "auto",
|
||||
"proxmoxHaPolicy": "haStop",
|
||||
"proxmoxExcludeIds": [],
|
||||
"proxmoxForceStop": true
|
||||
},
|
||||
@@ -309,78 +331,89 @@ NUPST stores configuration at `/etc/nupst/config.json`. The easiest way to confi
|
||||
|
||||
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.
|
||||
|
||||
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 violated |
|
||||
| `powerChangesAndThresholds` | On power changes OR threshold violations (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
|
||||
|
||||
@@ -393,9 +426,9 @@ Actions define automated responses to UPS conditions. They run **sequentially in
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Description | Default |
|
||||
| --------------- | ---------------------------------- | ------- |
|
||||
| `shutdownDelay` | Minutes to wait before shutdown | `5` |
|
||||
| Field | Description | Default |
|
||||
| --------------- | ------------------------------- | ------------------------------------- |
|
||||
| `shutdownDelay` | Minutes to wait before shutdown | Inherits `defaultShutdownDelay` (`5`) |
|
||||
|
||||
#### Webhook Action
|
||||
|
||||
@@ -410,11 +443,11 @@ Actions define automated responses to UPS conditions. They run **sequentially in
|
||||
}
|
||||
```
|
||||
|
||||
| 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
|
||||
|
||||
@@ -428,24 +461,28 @@ Actions define automated responses to UPS conditions. They run **sequentially in
|
||||
}
|
||||
```
|
||||
|
||||
| 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.
|
||||
|
||||
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):
|
||||
|
||||
@@ -455,6 +492,7 @@ NUPST supports **two operation modes** for Proxmox:
|
||||
"thresholds": { "battery": 30, "runtime": 15 },
|
||||
"triggerMode": "onlyThresholds",
|
||||
"proxmoxMode": "auto",
|
||||
"proxmoxHaPolicy": "haStop",
|
||||
"proxmoxExcludeIds": [100, 101],
|
||||
"proxmoxStopTimeout": 120,
|
||||
"proxmoxForceStop": true
|
||||
@@ -469,6 +507,7 @@ NUPST supports **two operation modes** for Proxmox:
|
||||
"thresholds": { "battery": 30, "runtime": 15 },
|
||||
"triggerMode": "onlyThresholds",
|
||||
"proxmoxMode": "api",
|
||||
"proxmoxHaPolicy": "haStop",
|
||||
"proxmoxHost": "localhost",
|
||||
"proxmoxPort": 8006,
|
||||
"proxmoxTokenId": "root@pam!nupst",
|
||||
@@ -480,18 +519,19 @@ NUPST supports **two operation modes** for Proxmox:
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Description | Default |
|
||||
| --------------------- | ----------------------------------------------- | ------------- |
|
||||
| `proxmoxMode` | Operation mode | `auto` |
|
||||
| `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):
|
||||
|
||||
@@ -500,24 +540,52 @@ NUPST supports **two operation modes** for Proxmox:
|
||||
pveum user token add root@pam nupst --privsep=0
|
||||
```
|
||||
|
||||
> ⚠️ **Important:** Place the Proxmox action **before** the shutdown action in the actions array so VMs are stopped before the host shuts down.
|
||||
**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.
|
||||
|
||||
> ⚠️ **Important:** Place the Proxmox action **before** the shutdown action in the actions array so
|
||||
> VMs are stopped before the host shuts down.
|
||||
|
||||
### Group Configuration
|
||||
|
||||
Groups coordinate actions across multiple UPS devices:
|
||||
Groups coordinate actions across multiple UPS devices.
|
||||
|
||||
| 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 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.
|
||||
|
||||
| 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`** — Actions trigger only when ALL UPS devices in the group are critical. Use for setups with backup power units.
|
||||
- **`nonRedundant`** — Actions trigger when ANY UPS device is critical. 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.
|
||||
|
||||
### 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
|
||||
|
||||
@@ -591,8 +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`
|
||||
- When connectivity is restored, NUPST logs a recovery event with downtime duration
|
||||
- The failure counter is capped at 100 to prevent overflow
|
||||
|
||||
@@ -609,17 +680,17 @@ UPS Devices (2):
|
||||
✓ Main Server UPS (online - 100%, 3840min)
|
||||
Host: 192.168.1.100:161 (SNMP)
|
||||
Groups: Data Center
|
||||
Action: proxmox (onlyThresholds: battery<30%, runtime<15min)
|
||||
Action: shutdown (onlyThresholds: battery<20%, runtime<10min, delay=10s)
|
||||
Action: proxmox (onlyThresholds: battery<30%, runtime<15min, ha=stop)
|
||||
Action: shutdown (onlyThresholds: battery<20%, runtime<10min, delay=10min)
|
||||
|
||||
✓ Local USB UPS (online - 95%, 2400min)
|
||||
Host: 127.0.0.1:3493 (UPSD)
|
||||
Action: shutdown (onlyThresholds: battery<15%, runtime<5min, delay=5s)
|
||||
Action: shutdown (onlyThresholds: battery<15%, runtime<5min, delay=5min)
|
||||
|
||||
Groups (1):
|
||||
ℹ Data Center (redundant)
|
||||
UPS Devices (1): Main Server UPS
|
||||
Action: shutdown (onlyThresholds: battery<10%, runtime<5min, delay=15s)
|
||||
Action: shutdown (onlyThresholds: battery<10%, runtime<5min, delay=15min)
|
||||
```
|
||||
|
||||
### Live Logs
|
||||
@@ -650,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
|
||||
|
||||
@@ -674,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:**
|
||||
|
||||
@@ -697,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
|
||||
|
||||
@@ -780,6 +856,9 @@ curl -k -H "Authorization: PVEAPIToken=root@pam!nupst=YOUR-SECRET" \
|
||||
|
||||
# Check token permissions
|
||||
pveum user token list root@pam
|
||||
|
||||
# If using proxmoxHaPolicy: haStop
|
||||
ha-manager config
|
||||
```
|
||||
|
||||
### Actions Not Triggering
|
||||
@@ -795,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
|
||||
|
||||
@@ -822,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
|
||||
|
||||
@@ -886,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);
|
||||
|
||||
+372
-2
@@ -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,8 +14,12 @@ 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 { buildUpsActionContext, decideUpsActionExecution } from '../ts/action-orchestration.ts';
|
||||
import { Action, type IActionConfig, type IActionContext } from '../ts/actions/base-action.ts';
|
||||
import {
|
||||
applyDefaultShutdownDelay,
|
||||
buildUpsActionContext,
|
||||
decideUpsActionExecution,
|
||||
} from '../ts/action-orchestration.ts';
|
||||
import {
|
||||
buildShutdownErrorRow,
|
||||
buildShutdownStatusRow,
|
||||
@@ -20,9 +28,20 @@ import {
|
||||
import {
|
||||
buildFailedUpsPollSnapshot,
|
||||
buildSuccessfulUpsPollSnapshot,
|
||||
getActionThresholdStates,
|
||||
getEnteredThresholdIndexes,
|
||||
hasThresholdViolation,
|
||||
isActionThresholdExceeded,
|
||||
} from '../ts/ups-monitoring.ts';
|
||||
import {
|
||||
buildGroupStatusSnapshot,
|
||||
buildGroupThresholdContextStatus,
|
||||
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/');
|
||||
@@ -353,6 +372,22 @@ Deno.test('decideUpsActionExecution: returns executable action plan when actions
|
||||
});
|
||||
});
|
||||
|
||||
Deno.test('applyDefaultShutdownDelay: applies only to shutdown actions without explicit delay', () => {
|
||||
const actions = [
|
||||
{ type: 'shutdown' as const },
|
||||
{ type: 'shutdown' as const, shutdownDelay: 0 },
|
||||
{ type: 'shutdown' as const, shutdownDelay: 9 },
|
||||
{ type: 'webhook' as const },
|
||||
];
|
||||
|
||||
assertEquals(applyDefaultShutdownDelay(actions, 7), [
|
||||
{ type: 'shutdown', shutdownDelay: 7 },
|
||||
{ type: 'shutdown', shutdownDelay: 0 },
|
||||
{ type: 'shutdown', shutdownDelay: 9 },
|
||||
{ type: 'webhook' },
|
||||
]);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Shutdown Monitoring Tests
|
||||
// -----------------------------------------------------------------------------
|
||||
@@ -512,6 +547,209 @@ Deno.test('hasThresholdViolation: only fires on battery when any action threshol
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test('isActionThresholdExceeded: evaluates a single action threshold on battery only', () => {
|
||||
assertEquals(
|
||||
isActionThresholdExceeded(
|
||||
{ type: 'shutdown', thresholds: { battery: 50, runtime: 20 } },
|
||||
'online',
|
||||
40,
|
||||
10,
|
||||
),
|
||||
false,
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
isActionThresholdExceeded(
|
||||
{ type: 'shutdown', thresholds: { battery: 50, runtime: 20 } },
|
||||
'onBattery',
|
||||
40,
|
||||
10,
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test('getActionThresholdStates: returns per-action threshold state array', () => {
|
||||
assertEquals(
|
||||
getActionThresholdStates('onBattery', 25, 8, [
|
||||
{ type: 'shutdown', thresholds: { battery: 30, runtime: 10 } },
|
||||
{ type: 'shutdown', thresholds: { battery: 10, runtime: 5 } },
|
||||
{ type: 'webhook' },
|
||||
]),
|
||||
[true, false, false],
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test('getEnteredThresholdIndexes: reports only newly-entered thresholds', () => {
|
||||
assertEquals(getEnteredThresholdIndexes(undefined, [false, true, true]), [1, 2]);
|
||||
assertEquals(getEnteredThresholdIndexes([false, true, false], [true, true, false]), [0]);
|
||||
assertEquals(getEnteredThresholdIndexes([true, true], [true, false]), []);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Group Monitoring Tests
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
Deno.test('buildGroupStatusSnapshot: redundant group stays online while one UPS remains online', () => {
|
||||
const snapshot = buildGroupStatusSnapshot(
|
||||
{ id: 'group-1', name: 'Group Main' },
|
||||
'redundant',
|
||||
[
|
||||
{
|
||||
...createInitialUpsStatus({ id: 'ups-1', name: 'UPS 1' }, 1000),
|
||||
powerStatus: 'onBattery' as const,
|
||||
batteryCapacity: 40,
|
||||
batteryRuntime: 12,
|
||||
},
|
||||
{
|
||||
...createInitialUpsStatus({ id: 'ups-2', name: 'UPS 2' }, 1000),
|
||||
powerStatus: 'online' as const,
|
||||
batteryCapacity: 98,
|
||||
batteryRuntime: 999,
|
||||
},
|
||||
],
|
||||
undefined,
|
||||
5000,
|
||||
);
|
||||
|
||||
assertEquals(snapshot.updatedStatus.powerStatus, 'online');
|
||||
assertEquals(snapshot.transition, 'powerStatusChange');
|
||||
});
|
||||
|
||||
Deno.test('buildGroupStatusSnapshot: nonRedundant group goes unreachable when any member is unreachable', () => {
|
||||
const snapshot = buildGroupStatusSnapshot(
|
||||
{ id: 'group-2', name: 'Group Edge' },
|
||||
'nonRedundant',
|
||||
[
|
||||
{
|
||||
...createInitialUpsStatus({ id: 'ups-1', name: 'UPS 1' }, 1000),
|
||||
powerStatus: 'online' as const,
|
||||
},
|
||||
{
|
||||
...createInitialUpsStatus({ id: 'ups-2', name: 'UPS 2' }, 1000),
|
||||
powerStatus: 'unreachable' as const,
|
||||
unreachableSince: 2000,
|
||||
},
|
||||
],
|
||||
{
|
||||
...createInitialUpsStatus({ id: 'group-2', name: 'Group Edge' }, 1000),
|
||||
powerStatus: 'online' as const,
|
||||
},
|
||||
6000,
|
||||
);
|
||||
|
||||
assertEquals(snapshot.updatedStatus.powerStatus, 'unreachable');
|
||||
assertEquals(snapshot.transition, 'powerStatusChange');
|
||||
});
|
||||
|
||||
Deno.test('evaluateGroupActionThreshold: redundant mode requires all members to be critical', () => {
|
||||
const evaluation = evaluateGroupActionThreshold(
|
||||
{ type: 'shutdown', thresholds: { battery: 50, runtime: 20 } },
|
||||
'redundant',
|
||||
[
|
||||
{
|
||||
...createInitialUpsStatus({ id: 'ups-1', name: 'UPS 1' }, 1000),
|
||||
powerStatus: 'onBattery' as const,
|
||||
batteryCapacity: 40,
|
||||
batteryRuntime: 15,
|
||||
},
|
||||
{
|
||||
...createInitialUpsStatus({ id: 'ups-2', name: 'UPS 2' }, 1000),
|
||||
powerStatus: 'online' as const,
|
||||
batteryCapacity: 95,
|
||||
batteryRuntime: 999,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
assertEquals(evaluation.exceedsThreshold, false);
|
||||
});
|
||||
|
||||
Deno.test('evaluateGroupActionThreshold: nonRedundant mode trips on any critical member', () => {
|
||||
const evaluation = evaluateGroupActionThreshold(
|
||||
{ type: 'shutdown', thresholds: { battery: 50, runtime: 20 } },
|
||||
'nonRedundant',
|
||||
[
|
||||
{
|
||||
...createInitialUpsStatus({ id: 'ups-1', name: 'UPS 1' }, 1000),
|
||||
powerStatus: 'onBattery' as const,
|
||||
batteryCapacity: 40,
|
||||
batteryRuntime: 15,
|
||||
},
|
||||
{
|
||||
...createInitialUpsStatus({ id: 'ups-2', name: 'UPS 2' }, 1000),
|
||||
powerStatus: 'online' as const,
|
||||
batteryCapacity: 95,
|
||||
batteryRuntime: 999,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
assertEquals(evaluation.exceedsThreshold, true);
|
||||
assertEquals(evaluation.blockedByUnreachable, false);
|
||||
});
|
||||
|
||||
Deno.test('evaluateGroupActionThreshold: blocks destructive actions when a member is unreachable', () => {
|
||||
const evaluation = evaluateGroupActionThreshold(
|
||||
{ type: 'proxmox', thresholds: { battery: 50, runtime: 20 } },
|
||||
'nonRedundant',
|
||||
[
|
||||
{
|
||||
...createInitialUpsStatus({ id: 'ups-1', name: 'UPS 1' }, 1000),
|
||||
powerStatus: 'onBattery' as const,
|
||||
batteryCapacity: 25,
|
||||
batteryRuntime: 8,
|
||||
},
|
||||
{
|
||||
...createInitialUpsStatus({ id: 'ups-2', name: 'UPS 2' }, 1000),
|
||||
powerStatus: 'unreachable' as const,
|
||||
unreachableSince: 3000,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
assertEquals(evaluation.exceedsThreshold, true);
|
||||
assertEquals(evaluation.blockedByUnreachable, true);
|
||||
});
|
||||
|
||||
Deno.test('buildGroupThresholdContextStatus: uses the worst triggering member runtime', () => {
|
||||
const status = buildGroupThresholdContextStatus(
|
||||
{ id: 'group-3', name: 'Group Worst' },
|
||||
[
|
||||
{
|
||||
exceedsThreshold: true,
|
||||
blockedByUnreachable: false,
|
||||
representativeStatus: {
|
||||
...createInitialUpsStatus({ id: 'ups-1', name: 'UPS 1' }, 1000),
|
||||
powerStatus: 'onBattery' as const,
|
||||
batteryCapacity: 30,
|
||||
batteryRuntime: 9,
|
||||
},
|
||||
},
|
||||
{
|
||||
exceedsThreshold: true,
|
||||
blockedByUnreachable: false,
|
||||
representativeStatus: {
|
||||
...createInitialUpsStatus({ id: 'ups-2', name: 'UPS 2' }, 1000),
|
||||
powerStatus: 'onBattery' as const,
|
||||
batteryCapacity: 20,
|
||||
batteryRuntime: 4,
|
||||
},
|
||||
},
|
||||
],
|
||||
[0, 1],
|
||||
{
|
||||
...createInitialUpsStatus({ id: 'group-3', name: 'Group Worst' }, 1000),
|
||||
powerStatus: 'online' as const,
|
||||
},
|
||||
7000,
|
||||
);
|
||||
|
||||
assertEquals(status.powerStatus, 'onBattery');
|
||||
assertEquals(status.batteryCapacity, 20);
|
||||
assertEquals(status.batteryRuntime, 4);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// UpsOidSets Tests
|
||||
// -----------------------------------------------------------------------------
|
||||
@@ -563,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
|
||||
// -----------------------------------------------------------------------------
|
||||
@@ -723,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.5.1',
|
||||
version: '5.10.0',
|
||||
description: 'Network UPS Shutdown Tool - Monitor SNMP-enabled UPS devices and orchestrate graceful system shutdowns during power emergencies'
|
||||
}
|
||||
|
||||
@@ -34,6 +34,22 @@ export function buildUpsActionContext(
|
||||
};
|
||||
}
|
||||
|
||||
export function applyDefaultShutdownDelay(
|
||||
actions: IActionConfig[],
|
||||
defaultDelayMinutes: number,
|
||||
): IActionConfig[] {
|
||||
return actions.map((action) => {
|
||||
if (action.type !== 'shutdown' || action.shutdownDelay !== undefined) {
|
||||
return action;
|
||||
}
|
||||
|
||||
return {
|
||||
...action,
|
||||
shutdownDelay: defaultDelayMinutes,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function decideUpsActionExecution(
|
||||
isPaused: boolean,
|
||||
ups: IUpsActionSource,
|
||||
|
||||
@@ -74,7 +74,7 @@ export interface IActionConfig {
|
||||
};
|
||||
|
||||
// Shutdown action configuration
|
||||
/** Delay before shutdown in minutes (default: 5) */
|
||||
/** Delay before shutdown in minutes (defaults to the config-level shutdown delay, or 5) */
|
||||
shutdownDelay?: number;
|
||||
/** Only execute shutdown on threshold violation, not power status changes */
|
||||
onlyOnThresholdViolation?: boolean;
|
||||
@@ -118,6 +118,8 @@ export interface IActionConfig {
|
||||
proxmoxInsecure?: boolean;
|
||||
/** Proxmox operation mode: 'auto' detects CLI tools, 'cli' forces CLI, 'api' forces REST API (default: 'auto') */
|
||||
proxmoxMode?: 'auto' | 'api' | 'cli';
|
||||
/** How HA-managed Proxmox resources should be stopped (default: 'none') */
|
||||
proxmoxHaPolicy?: 'none' | 'haStop';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+298
-66
@@ -8,6 +8,11 @@ import { logger } from '../logger.ts';
|
||||
import { PROXMOX, UI } from '../constants.ts';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
type TNodeLikeGlobal = typeof globalThis & {
|
||||
process?: {
|
||||
env: Record<string, string | undefined>;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* ProxmoxAction - Gracefully shuts down Proxmox VMs and LXC containers
|
||||
@@ -23,6 +28,22 @@ const execFileAsync = promisify(execFile);
|
||||
*/
|
||||
export class ProxmoxAction extends Action {
|
||||
readonly type = 'proxmox';
|
||||
private static readonly activeRunKeys = new Set<string>();
|
||||
|
||||
private static findCliTool(command: string): string | null {
|
||||
for (const dir of PROXMOX.CLI_TOOL_PATHS) {
|
||||
const candidate = `${dir}/${command}`;
|
||||
try {
|
||||
if (fs.existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
} catch (_e) {
|
||||
// continue
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Proxmox CLI tools (qm, pct) are available on the system
|
||||
@@ -32,29 +53,12 @@ export class ProxmoxAction extends Action {
|
||||
available: boolean;
|
||||
qmPath: string | null;
|
||||
pctPath: string | null;
|
||||
haManagerPath: string | null;
|
||||
isRoot: boolean;
|
||||
} {
|
||||
let qmPath: string | null = null;
|
||||
let pctPath: string | null = null;
|
||||
|
||||
for (const dir of PROXMOX.CLI_TOOL_PATHS) {
|
||||
if (!qmPath) {
|
||||
const p = `${dir}/qm`;
|
||||
try {
|
||||
if (fs.existsSync(p)) qmPath = p;
|
||||
} catch (_e) {
|
||||
// continue
|
||||
}
|
||||
}
|
||||
if (!pctPath) {
|
||||
const p = `${dir}/pct`;
|
||||
try {
|
||||
if (fs.existsSync(p)) pctPath = p;
|
||||
} catch (_e) {
|
||||
// continue
|
||||
}
|
||||
}
|
||||
}
|
||||
const qmPath = this.findCliTool('qm');
|
||||
const pctPath = this.findCliTool('pct');
|
||||
const haManagerPath = this.findCliTool('ha-manager');
|
||||
|
||||
const isRoot = !!(process.getuid && process.getuid() === 0);
|
||||
|
||||
@@ -62,6 +66,7 @@ export class ProxmoxAction extends Action {
|
||||
available: qmPath !== null && pctPath !== null && isRoot,
|
||||
qmPath,
|
||||
pctPath,
|
||||
haManagerPath,
|
||||
isRoot,
|
||||
};
|
||||
}
|
||||
@@ -69,7 +74,11 @@ export class ProxmoxAction extends Action {
|
||||
/**
|
||||
* Resolve the operation mode based on config and environment
|
||||
*/
|
||||
private resolveMode(): { mode: 'api' | 'cli'; qmPath: string; pctPath: string } | { mode: 'api'; qmPath?: undefined; pctPath?: undefined } {
|
||||
private resolveMode(): { mode: 'api' | 'cli'; qmPath: string; pctPath: string } | {
|
||||
mode: 'api';
|
||||
qmPath?: undefined;
|
||||
pctPath?: undefined;
|
||||
} {
|
||||
const configuredMode = this.config.proxmoxMode || 'auto';
|
||||
|
||||
if (configuredMode === 'api') {
|
||||
@@ -111,16 +120,29 @@ export class ProxmoxAction extends Action {
|
||||
const resolved = this.resolveMode();
|
||||
const node = this.config.proxmoxNode || os.hostname();
|
||||
const excludeIds = new Set(this.config.proxmoxExcludeIds || []);
|
||||
const stopTimeout = (this.config.proxmoxStopTimeout || PROXMOX.DEFAULT_STOP_TIMEOUT_SECONDS) * 1000;
|
||||
const stopTimeout = (this.config.proxmoxStopTimeout || PROXMOX.DEFAULT_STOP_TIMEOUT_SECONDS) *
|
||||
1000;
|
||||
const forceStop = this.config.proxmoxForceStop !== false; // default true
|
||||
const haPolicy = this.config.proxmoxHaPolicy || 'none';
|
||||
const host = this.config.proxmoxHost || PROXMOX.DEFAULT_HOST;
|
||||
const port = this.config.proxmoxPort || PROXMOX.DEFAULT_PORT;
|
||||
const runKey = `${resolved.mode}:${node}:${
|
||||
resolved.mode === 'api' ? `${host}:${port}` : 'local'
|
||||
}`;
|
||||
|
||||
if (ProxmoxAction.activeRunKeys.has(runKey)) {
|
||||
logger.info(`Proxmox action skipped: shutdown sequence already running for node ${node}`);
|
||||
return;
|
||||
}
|
||||
|
||||
ProxmoxAction.activeRunKeys.add(runKey);
|
||||
|
||||
logger.log('');
|
||||
logger.logBoxTitle('Proxmox VM Shutdown', UI.WIDE_BOX_WIDTH, 'warning');
|
||||
logger.logBoxLine(`Mode: ${resolved.mode === 'cli' ? 'CLI (qm/pct)' : 'API (REST)'}`);
|
||||
logger.logBoxLine(`Node: ${node}`);
|
||||
logger.logBoxLine(`HA Policy: ${haPolicy}`);
|
||||
if (resolved.mode === 'api') {
|
||||
const host = this.config.proxmoxHost || PROXMOX.DEFAULT_HOST;
|
||||
const port = this.config.proxmoxPort || PROXMOX.DEFAULT_PORT;
|
||||
logger.logBoxLine(`API: ${host}:${port}`);
|
||||
}
|
||||
logger.logBoxLine(`UPS: ${context.upsName} (${context.powerStatus})`);
|
||||
@@ -132,6 +154,11 @@ export class ProxmoxAction extends Action {
|
||||
logger.log('');
|
||||
|
||||
try {
|
||||
let apiContext: {
|
||||
baseUrl: string;
|
||||
headers: Record<string, string>;
|
||||
insecure: boolean;
|
||||
} | null = null;
|
||||
let runningVMs: Array<{ vmid: number; name: string }>;
|
||||
let runningCTs: Array<{ vmid: number; name: string }>;
|
||||
|
||||
@@ -140,8 +167,6 @@ export class ProxmoxAction extends Action {
|
||||
runningCTs = await this.getRunningCTsCli(resolved.pctPath);
|
||||
} else {
|
||||
// API mode - validate token
|
||||
const host = this.config.proxmoxHost || PROXMOX.DEFAULT_HOST;
|
||||
const port = this.config.proxmoxPort || PROXMOX.DEFAULT_PORT;
|
||||
const tokenId = this.config.proxmoxTokenId;
|
||||
const tokenSecret = this.config.proxmoxTokenSecret;
|
||||
const insecure = this.config.proxmoxInsecure !== false;
|
||||
@@ -152,13 +177,26 @@ export class ProxmoxAction extends Action {
|
||||
return;
|
||||
}
|
||||
|
||||
const baseUrl = `https://${host}:${port}${PROXMOX.API_BASE}`;
|
||||
const headers: Record<string, string> = {
|
||||
'Authorization': `PVEAPIToken=${tokenId}=${tokenSecret}`,
|
||||
apiContext = {
|
||||
baseUrl: `https://${host}:${port}${PROXMOX.API_BASE}`,
|
||||
headers: {
|
||||
'Authorization': `PVEAPIToken=${tokenId}=${tokenSecret}`,
|
||||
},
|
||||
insecure,
|
||||
};
|
||||
|
||||
runningVMs = await this.getRunningVMsApi(baseUrl, node, headers, insecure);
|
||||
runningCTs = await this.getRunningCTsApi(baseUrl, node, headers, insecure);
|
||||
runningVMs = await this.getRunningVMsApi(
|
||||
apiContext.baseUrl,
|
||||
node,
|
||||
apiContext.headers,
|
||||
apiContext.insecure,
|
||||
);
|
||||
runningCTs = await this.getRunningCTsApi(
|
||||
apiContext.baseUrl,
|
||||
node,
|
||||
apiContext.headers,
|
||||
apiContext.insecure,
|
||||
);
|
||||
}
|
||||
|
||||
// Filter out excluded IDs
|
||||
@@ -171,33 +209,83 @@ export class ProxmoxAction extends Action {
|
||||
return;
|
||||
}
|
||||
|
||||
const haManagedResources = haPolicy === 'haStop'
|
||||
? await this.getHaManagedResources(resolved, apiContext)
|
||||
: { qemu: new Set<number>(), lxc: new Set<number>() };
|
||||
const haVmsToStop = vmsToStop.filter((vm) => haManagedResources.qemu.has(vm.vmid));
|
||||
const haCtsToStop = ctsToStop.filter((ct) => haManagedResources.lxc.has(ct.vmid));
|
||||
let directVmsToStop = vmsToStop.filter((vm) => !haManagedResources.qemu.has(vm.vmid));
|
||||
let directCtsToStop = ctsToStop.filter((ct) => !haManagedResources.lxc.has(ct.vmid));
|
||||
|
||||
logger.info(`Shutting down ${vmsToStop.length} VMs and ${ctsToStop.length} containers...`);
|
||||
|
||||
// Send shutdown commands
|
||||
if (resolved.mode === 'cli') {
|
||||
for (const vm of vmsToStop) {
|
||||
const { haManagerPath } = ProxmoxAction.detectCliAvailability();
|
||||
if (haPolicy === 'haStop' && (haVmsToStop.length > 0 || haCtsToStop.length > 0)) {
|
||||
if (!haManagerPath) {
|
||||
logger.warn(
|
||||
'ha-manager not found, falling back to direct guest shutdown for HA-managed resources',
|
||||
);
|
||||
directVmsToStop = [...haVmsToStop, ...directVmsToStop];
|
||||
directCtsToStop = [...haCtsToStop, ...directCtsToStop];
|
||||
} else {
|
||||
for (const vm of haVmsToStop) {
|
||||
await this.requestHaStopCli(haManagerPath, `vm:${vm.vmid}`);
|
||||
logger.dim(` HA stop requested for VM ${vm.vmid} (${vm.name || 'unnamed'})`);
|
||||
}
|
||||
for (const ct of haCtsToStop) {
|
||||
await this.requestHaStopCli(haManagerPath, `ct:${ct.vmid}`);
|
||||
logger.dim(` HA stop requested for CT ${ct.vmid} (${ct.name || 'unnamed'})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const vm of directVmsToStop) {
|
||||
await this.shutdownVMCli(resolved.qmPath, vm.vmid);
|
||||
logger.dim(` Shutdown sent to VM ${vm.vmid} (${vm.name || 'unnamed'})`);
|
||||
}
|
||||
for (const ct of ctsToStop) {
|
||||
for (const ct of directCtsToStop) {
|
||||
await this.shutdownCTCli(resolved.pctPath, ct.vmid);
|
||||
logger.dim(` Shutdown sent to CT ${ct.vmid} (${ct.name || 'unnamed'})`);
|
||||
}
|
||||
} else {
|
||||
const host = this.config.proxmoxHost || PROXMOX.DEFAULT_HOST;
|
||||
const port = this.config.proxmoxPort || PROXMOX.DEFAULT_PORT;
|
||||
const insecure = this.config.proxmoxInsecure !== false;
|
||||
const baseUrl = `https://${host}:${port}${PROXMOX.API_BASE}`;
|
||||
const headers: Record<string, string> = {
|
||||
'Authorization': `PVEAPIToken=${this.config.proxmoxTokenId}=${this.config.proxmoxTokenSecret}`,
|
||||
};
|
||||
} else if (apiContext) {
|
||||
for (const vm of haVmsToStop) {
|
||||
await this.requestHaStopApi(
|
||||
apiContext.baseUrl,
|
||||
`vm:${vm.vmid}`,
|
||||
apiContext.headers,
|
||||
apiContext.insecure,
|
||||
);
|
||||
logger.dim(` HA stop requested for VM ${vm.vmid} (${vm.name || 'unnamed'})`);
|
||||
}
|
||||
for (const ct of haCtsToStop) {
|
||||
await this.requestHaStopApi(
|
||||
apiContext.baseUrl,
|
||||
`ct:${ct.vmid}`,
|
||||
apiContext.headers,
|
||||
apiContext.insecure,
|
||||
);
|
||||
logger.dim(` HA stop requested for CT ${ct.vmid} (${ct.name || 'unnamed'})`);
|
||||
}
|
||||
|
||||
for (const vm of vmsToStop) {
|
||||
await this.shutdownVMApi(baseUrl, node, vm.vmid, headers, insecure);
|
||||
for (const vm of directVmsToStop) {
|
||||
await this.shutdownVMApi(
|
||||
apiContext.baseUrl,
|
||||
node,
|
||||
vm.vmid,
|
||||
apiContext.headers,
|
||||
apiContext.insecure,
|
||||
);
|
||||
logger.dim(` Shutdown sent to VM ${vm.vmid} (${vm.name || 'unnamed'})`);
|
||||
}
|
||||
for (const ct of ctsToStop) {
|
||||
await this.shutdownCTApi(baseUrl, node, ct.vmid, headers, insecure);
|
||||
for (const ct of directCtsToStop) {
|
||||
await this.shutdownCTApi(
|
||||
apiContext.baseUrl,
|
||||
node,
|
||||
ct.vmid,
|
||||
apiContext.headers,
|
||||
apiContext.insecure,
|
||||
);
|
||||
logger.dim(` Shutdown sent to CT ${ct.vmid} (${ct.name || 'unnamed'})`);
|
||||
}
|
||||
}
|
||||
@@ -220,18 +308,23 @@ export class ProxmoxAction extends Action {
|
||||
} else {
|
||||
await this.stopCTCli(resolved.pctPath, item.vmid);
|
||||
}
|
||||
} else {
|
||||
const host = this.config.proxmoxHost || PROXMOX.DEFAULT_HOST;
|
||||
const port = this.config.proxmoxPort || PROXMOX.DEFAULT_PORT;
|
||||
const insecure = this.config.proxmoxInsecure !== false;
|
||||
const baseUrl = `https://${host}:${port}${PROXMOX.API_BASE}`;
|
||||
const headers: Record<string, string> = {
|
||||
'Authorization': `PVEAPIToken=${this.config.proxmoxTokenId}=${this.config.proxmoxTokenSecret}`,
|
||||
};
|
||||
} else if (apiContext) {
|
||||
if (item.type === 'qemu') {
|
||||
await this.stopVMApi(baseUrl, node, item.vmid, headers, insecure);
|
||||
await this.stopVMApi(
|
||||
apiContext.baseUrl,
|
||||
node,
|
||||
item.vmid,
|
||||
apiContext.headers,
|
||||
apiContext.insecure,
|
||||
);
|
||||
} else {
|
||||
await this.stopCTApi(baseUrl, node, item.vmid, headers, insecure);
|
||||
await this.stopCTApi(
|
||||
apiContext.baseUrl,
|
||||
node,
|
||||
item.vmid,
|
||||
apiContext.headers,
|
||||
apiContext.insecure,
|
||||
);
|
||||
}
|
||||
}
|
||||
logger.dim(` Force-stopped ${item.type} ${item.vmid} (${item.name || 'unnamed'})`);
|
||||
@@ -252,6 +345,8 @@ export class ProxmoxAction extends Action {
|
||||
logger.error(
|
||||
`Proxmox action failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
} finally {
|
||||
ProxmoxAction.activeRunKeys.delete(runKey);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -357,6 +452,77 @@ export class ProxmoxAction extends Action {
|
||||
return status;
|
||||
}
|
||||
|
||||
private async getHaManagedResources(
|
||||
resolved: { mode: 'api' | 'cli'; qmPath?: string; pctPath?: string },
|
||||
apiContext: {
|
||||
baseUrl: string;
|
||||
headers: Record<string, string>;
|
||||
insecure: boolean;
|
||||
} | null,
|
||||
): Promise<{ qemu: Set<number>; lxc: Set<number> }> {
|
||||
if (resolved.mode === 'cli') {
|
||||
const { haManagerPath } = ProxmoxAction.detectCliAvailability();
|
||||
if (!haManagerPath) {
|
||||
return { qemu: new Set<number>(), lxc: new Set<number>() };
|
||||
}
|
||||
|
||||
return await this.getHaManagedResourcesCli(haManagerPath);
|
||||
}
|
||||
|
||||
if (!apiContext) {
|
||||
return { qemu: new Set<number>(), lxc: new Set<number>() };
|
||||
}
|
||||
|
||||
return await this.getHaManagedResourcesApi(
|
||||
apiContext.baseUrl,
|
||||
apiContext.headers,
|
||||
apiContext.insecure,
|
||||
);
|
||||
}
|
||||
|
||||
private async getHaManagedResourcesCli(
|
||||
haManagerPath: string,
|
||||
): Promise<{ qemu: Set<number>; lxc: Set<number> }> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(haManagerPath, ['config']);
|
||||
return this.parseHaManagerConfig(stdout);
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`Failed to list HA resources via CLI: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
return { qemu: new Set<number>(), lxc: new Set<number>() };
|
||||
}
|
||||
}
|
||||
|
||||
private parseHaManagerConfig(output: string): { qemu: Set<number>; lxc: Set<number> } {
|
||||
const resources = {
|
||||
qemu: new Set<number>(),
|
||||
lxc: new Set<number>(),
|
||||
};
|
||||
|
||||
for (const line of output.trim().split('\n')) {
|
||||
const match = line.match(/^\s*(vm|ct)\s*:\s*(\d+)\s*$/i);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const vmid = parseInt(match[2], 10);
|
||||
if (match[1].toLowerCase() === 'vm') {
|
||||
resources.qemu.add(vmid);
|
||||
} else {
|
||||
resources.lxc.add(vmid);
|
||||
}
|
||||
}
|
||||
|
||||
return resources;
|
||||
}
|
||||
|
||||
private async requestHaStopCli(haManagerPath: string, sid: string): Promise<void> {
|
||||
await execFileAsync(haManagerPath, ['set', sid, '--state', 'stopped']);
|
||||
}
|
||||
|
||||
// ─── API-based methods ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -367,16 +533,23 @@ export class ProxmoxAction extends Action {
|
||||
method: string,
|
||||
headers: Record<string, string>,
|
||||
insecure: boolean,
|
||||
body?: URLSearchParams,
|
||||
): Promise<unknown> {
|
||||
const requestHeaders = { ...headers };
|
||||
const fetchOptions: RequestInit = {
|
||||
method,
|
||||
headers,
|
||||
headers: requestHeaders,
|
||||
};
|
||||
|
||||
if (body) {
|
||||
requestHeaders['Content-Type'] = 'application/x-www-form-urlencoded;charset=UTF-8';
|
||||
fetchOptions.body = body.toString();
|
||||
}
|
||||
|
||||
// Use NODE_TLS_REJECT_UNAUTHORIZED for insecure mode (self-signed certs)
|
||||
if (insecure) {
|
||||
// deno-lint-ignore no-explicit-any
|
||||
(globalThis as any).process?.env && ((globalThis as any).process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0');
|
||||
const nodeProcess = (globalThis as TNodeLikeGlobal).process;
|
||||
if (insecure && nodeProcess?.env) {
|
||||
nodeProcess.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -390,9 +563,8 @@ export class ProxmoxAction extends Action {
|
||||
return await response.json();
|
||||
} finally {
|
||||
// Restore TLS verification
|
||||
if (insecure) {
|
||||
// deno-lint-ignore no-explicit-any
|
||||
(globalThis as any).process?.env && ((globalThis as any).process.env.NODE_TLS_REJECT_UNAUTHORIZED = '1');
|
||||
if (insecure && nodeProcess?.env) {
|
||||
nodeProcess.env.NODE_TLS_REJECT_UNAUTHORIZED = '1';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -453,6 +625,63 @@ export class ProxmoxAction extends Action {
|
||||
}
|
||||
}
|
||||
|
||||
private async getHaManagedResourcesApi(
|
||||
baseUrl: string,
|
||||
headers: Record<string, string>,
|
||||
insecure: boolean,
|
||||
): Promise<{ qemu: Set<number>; lxc: Set<number> }> {
|
||||
try {
|
||||
const response = await this.apiRequest(
|
||||
`${baseUrl}/cluster/ha/resources`,
|
||||
'GET',
|
||||
headers,
|
||||
insecure,
|
||||
) as { data: Array<{ sid?: string }> };
|
||||
const resources = {
|
||||
qemu: new Set<number>(),
|
||||
lxc: new Set<number>(),
|
||||
};
|
||||
|
||||
for (const item of response.data || []) {
|
||||
const match = item.sid?.match(/^(vm|ct):(\d+)$/i);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const vmid = parseInt(match[2], 10);
|
||||
if (match[1].toLowerCase() === 'vm') {
|
||||
resources.qemu.add(vmid);
|
||||
} else {
|
||||
resources.lxc.add(vmid);
|
||||
}
|
||||
}
|
||||
|
||||
return resources;
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`Failed to list HA resources via API: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
return { qemu: new Set<number>(), lxc: new Set<number>() };
|
||||
}
|
||||
}
|
||||
|
||||
private async requestHaStopApi(
|
||||
baseUrl: string,
|
||||
sid: string,
|
||||
headers: Record<string, string>,
|
||||
insecure: boolean,
|
||||
): Promise<void> {
|
||||
await this.apiRequest(
|
||||
`${baseUrl}/cluster/ha/resources/${encodeURIComponent(sid)}`,
|
||||
'PUT',
|
||||
headers,
|
||||
insecure,
|
||||
new URLSearchParams({ state: 'stopped' }),
|
||||
);
|
||||
}
|
||||
|
||||
private async shutdownVMApi(
|
||||
baseUrl: string,
|
||||
node: string,
|
||||
@@ -529,7 +758,9 @@ export class ProxmoxAction extends Action {
|
||||
|
||||
while (remaining.length > 0 && (Date.now() - startTime) < timeout) {
|
||||
// Wait before polling
|
||||
await new Promise((resolve) => setTimeout(resolve, PROXMOX.STATUS_POLL_INTERVAL_SECONDS * 1000));
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, PROXMOX.STATUS_POLL_INTERVAL_SECONDS * 1000)
|
||||
);
|
||||
|
||||
// Check which are still running
|
||||
const stillRunning: typeof remaining = [];
|
||||
@@ -547,7 +778,8 @@ export class ProxmoxAction extends Action {
|
||||
const insecure = this.config.proxmoxInsecure !== false;
|
||||
const baseUrl = `https://${host}:${port}${PROXMOX.API_BASE}`;
|
||||
const headers: Record<string, string> = {
|
||||
'Authorization': `PVEAPIToken=${this.config.proxmoxTokenId}=${this.config.proxmoxTokenSecret}`,
|
||||
'Authorization':
|
||||
`PVEAPIToken=${this.config.proxmoxTokenId}=${this.config.proxmoxTokenSecret}`,
|
||||
};
|
||||
const statusUrl = `${baseUrl}/nodes/${node}/${item.type}/${item.vmid}/status/current`;
|
||||
const response = await this.apiRequest(statusUrl, 'GET', headers, insecure) as {
|
||||
|
||||
@@ -15,6 +15,7 @@ const execFileAsync = promisify(execFile);
|
||||
*/
|
||||
export class ShutdownAction extends Action {
|
||||
readonly type = 'shutdown';
|
||||
private static scheduledDelayMinutes: number | null = null;
|
||||
|
||||
/**
|
||||
* Override shouldExecute to add shutdown-specific safety checks
|
||||
@@ -124,7 +125,26 @@ export class ShutdownAction extends Action {
|
||||
return;
|
||||
}
|
||||
|
||||
const shutdownDelay = this.config.shutdownDelay || SHUTDOWN.DEFAULT_DELAY_MINUTES;
|
||||
const shutdownDelay = this.config.shutdownDelay ?? SHUTDOWN.DEFAULT_DELAY_MINUTES;
|
||||
|
||||
if (
|
||||
ShutdownAction.scheduledDelayMinutes !== null &&
|
||||
ShutdownAction.scheduledDelayMinutes <= shutdownDelay
|
||||
) {
|
||||
logger.info(
|
||||
`Shutdown action skipped: shutdown already scheduled in ${ShutdownAction.scheduledDelayMinutes} minutes`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
ShutdownAction.scheduledDelayMinutes !== null &&
|
||||
ShutdownAction.scheduledDelayMinutes > shutdownDelay
|
||||
) {
|
||||
logger.warn(
|
||||
`Shutdown already scheduled in ${ShutdownAction.scheduledDelayMinutes} minutes, rescheduling to ${shutdownDelay} minutes`,
|
||||
);
|
||||
}
|
||||
|
||||
logger.log('');
|
||||
logger.logBoxTitle('Initiating System Shutdown', UI.WIDE_BOX_WIDTH, 'error');
|
||||
@@ -139,6 +159,7 @@ export class ShutdownAction extends Action {
|
||||
|
||||
try {
|
||||
await this.executeShutdownCommand(shutdownDelay);
|
||||
ShutdownAction.scheduledDelayMinutes = shutdownDelay;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Shutdown command failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
@@ -227,6 +248,7 @@ export class ShutdownAction extends Action {
|
||||
logger.log(`Trying alternative shutdown method: ${cmdPath} ${alt.args.join(' ')}`);
|
||||
await execFileAsync(cmdPath, alt.args);
|
||||
logger.log(`Alternative method ${alt.cmd} succeeded`);
|
||||
ShutdownAction.scheduledDelayMinutes = 0;
|
||||
return; // Exit if successful
|
||||
}
|
||||
} catch (_altError) {
|
||||
|
||||
@@ -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'
|
||||
`);
|
||||
|
||||
+512
-212
@@ -4,6 +4,7 @@ import { type ITableColumn, logger } from '../logger.ts';
|
||||
import { symbols, theme } from '../colors.ts';
|
||||
import type { IActionConfig } from '../actions/base-action.ts';
|
||||
import { ProxmoxAction } from '../actions/proxmox-action.ts';
|
||||
import { SHUTDOWN } from '../constants.ts';
|
||||
import type { IGroupConfig, IUpsConfig } from '../daemon.ts';
|
||||
import * as helpers from '../helpers/index.ts';
|
||||
|
||||
@@ -40,229 +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 delayStr = await prompt(
|
||||
` ${theme.dim('Shutdown delay')} ${theme.dim('(minutes) [5]:')} `,
|
||||
);
|
||||
const shutdownDelay = delayStr ? parseInt(delayStr, 10) : 5;
|
||||
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';
|
||||
} 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('');
|
||||
});
|
||||
@@ -274,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
|
||||
*/
|
||||
@@ -438,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
|
||||
*/
|
||||
@@ -468,7 +763,9 @@ export class ActionHandler {
|
||||
];
|
||||
|
||||
const rows = target.actions.map((action, index) => {
|
||||
let details = `${action.shutdownDelay || 5}min delay`;
|
||||
const defaultShutdownDelay = this.nupst.getDaemon().getConfig().defaultShutdownDelay ??
|
||||
SHUTDOWN.DEFAULT_DELAY_MINUTES;
|
||||
let details = `${action.shutdownDelay ?? defaultShutdownDelay}min delay`;
|
||||
if (action.type === 'proxmox') {
|
||||
const mode = action.proxmoxMode || 'auto';
|
||||
if (mode === 'cli' || (mode === 'auto' && !action.proxmoxTokenId)) {
|
||||
@@ -481,6 +778,9 @@ export class ActionHandler {
|
||||
if (action.proxmoxExcludeIds?.length) {
|
||||
details += `, excl: ${action.proxmoxExcludeIds.join(',')}`;
|
||||
}
|
||||
if (action.proxmoxHaPolicy === 'haStop') {
|
||||
details += ', haStop';
|
||||
}
|
||||
} else if (action.type === 'webhook') {
|
||||
details = action.webhookUrl || theme.dim('N/A');
|
||||
} else if (action.type === 'script') {
|
||||
|
||||
+23
-13
@@ -10,7 +10,8 @@ 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 { UPSD } from '../constants.ts';
|
||||
import { getDefaultRuntimeUnitForUpsModel } from '../snmp/runtime-units.ts';
|
||||
import { SHUTDOWN, UPSD } from '../constants.ts';
|
||||
|
||||
/**
|
||||
* Thresholds configuration for CLI display
|
||||
@@ -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}]: `);
|
||||
@@ -1152,11 +1151,19 @@ export class UpsHandler {
|
||||
if (typeValue === 1) {
|
||||
// Shutdown action
|
||||
action.type = 'shutdown';
|
||||
const defaultShutdownDelay = this.nupst.getDaemon().getConfig().defaultShutdownDelay ??
|
||||
SHUTDOWN.DEFAULT_DELAY_MINUTES;
|
||||
|
||||
const delayInput = await prompt('Shutdown delay in minutes [5]: ');
|
||||
const delay = parseInt(delayInput, 10);
|
||||
if (delayInput.trim() && !isNaN(delay)) {
|
||||
action.shutdownDelay = delay;
|
||||
const delayInput = await prompt(
|
||||
`Shutdown delay in minutes (leave empty for default ${defaultShutdownDelay}): `,
|
||||
);
|
||||
if (delayInput.trim()) {
|
||||
const delay = parseInt(delayInput, 10);
|
||||
if (isNaN(delay) || delay < 0) {
|
||||
logger.warn('Invalid shutdown delay, using configured default');
|
||||
} else {
|
||||
action.shutdownDelay = delay;
|
||||
}
|
||||
}
|
||||
} else if (typeValue === 2) {
|
||||
// Webhook action
|
||||
@@ -1268,6 +1275,9 @@ export class UpsHandler {
|
||||
const forceInput = await prompt("Force-stop VMs that don't shut down in time? (Y/n): ");
|
||||
action.proxmoxForceStop = forceInput.toLowerCase() !== 'n';
|
||||
|
||||
const haPolicyInput = await prompt('HA-managed guest handling ([1] none, 2 haStop): ');
|
||||
action.proxmoxHaPolicy = haPolicyInput.trim() === '2' ? 'haStop' : 'none';
|
||||
|
||||
logger.log('');
|
||||
logger.info('Note: Place the Proxmox action BEFORE the shutdown action');
|
||||
logger.dim('in the action chain so VMs shut down before the host.');
|
||||
|
||||
+3
-1
@@ -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');
|
||||
|
||||
+206
-20
@@ -12,9 +12,14 @@ import { MigrationRunner } from './migrations/index.ts';
|
||||
import { formatPowerStatus, getBatteryColor, getRuntimeColor, theme } from './colors.ts';
|
||||
import type { IActionConfig } from './actions/base-action.ts';
|
||||
import { ActionManager } from './actions/index.ts';
|
||||
import { decideUpsActionExecution, type TUpsTriggerReason } from './action-orchestration.ts';
|
||||
import {
|
||||
applyDefaultShutdownDelay,
|
||||
buildUpsActionContext,
|
||||
decideUpsActionExecution,
|
||||
type TUpsTriggerReason,
|
||||
} from './action-orchestration.ts';
|
||||
import { NupstHttpServer } from './http-server.ts';
|
||||
import { NETWORK, PAUSE, THRESHOLDS, TIMING, UI } from './constants.ts';
|
||||
import { NETWORK, PAUSE, SHUTDOWN, THRESHOLDS, TIMING, UI } from './constants.ts';
|
||||
import {
|
||||
analyzeConfigReload,
|
||||
shouldRefreshPauseState,
|
||||
@@ -22,11 +27,17 @@ import {
|
||||
} from './config-watch.ts';
|
||||
import { type IPauseState, loadPauseSnapshot } from './pause-state.ts';
|
||||
import { ShutdownExecutor } from './shutdown-executor.ts';
|
||||
import {
|
||||
buildGroupStatusSnapshot,
|
||||
buildGroupThresholdContextStatus,
|
||||
evaluateGroupActionThreshold,
|
||||
} from './group-monitoring.ts';
|
||||
import {
|
||||
buildFailedUpsPollSnapshot,
|
||||
buildSuccessfulUpsPollSnapshot,
|
||||
ensureUpsStatus,
|
||||
hasThresholdViolation,
|
||||
getActionThresholdStates,
|
||||
getEnteredThresholdIndexes,
|
||||
} from './ups-monitoring.ts';
|
||||
import {
|
||||
buildShutdownErrorRow,
|
||||
@@ -97,6 +108,8 @@ export interface INupstConfig {
|
||||
groups: IGroupConfig[];
|
||||
/** Check interval in milliseconds */
|
||||
checkInterval: number;
|
||||
/** Default delay in minutes for shutdown actions without an override */
|
||||
defaultShutdownDelay?: number;
|
||||
/** HTTP Server configuration */
|
||||
httpServer?: IHttpServerConfig;
|
||||
|
||||
@@ -124,7 +137,8 @@ export class NupstDaemon {
|
||||
|
||||
/** Default configuration */
|
||||
private readonly DEFAULT_CONFIG: INupstConfig = {
|
||||
version: '4.3',
|
||||
version: '4.4',
|
||||
defaultShutdownDelay: SHUTDOWN.DEFAULT_DELAY_MINUTES,
|
||||
upsDevices: [
|
||||
{
|
||||
id: 'default',
|
||||
@@ -155,7 +169,6 @@ export class NupstDaemon {
|
||||
battery: THRESHOLDS.DEFAULT_BATTERY_PERCENT, // Shutdown when battery below 60%
|
||||
runtime: THRESHOLDS.DEFAULT_RUNTIME_MINUTES, // Shutdown when runtime below 20 minutes
|
||||
},
|
||||
shutdownDelay: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -172,6 +185,8 @@ export class NupstDaemon {
|
||||
private isPaused: boolean = false;
|
||||
private pauseState: IPauseState | null = null;
|
||||
private upsStatus: Map<string, IUpsStatus> = new Map();
|
||||
private groupStatus: Map<string, IUpsStatus> = new Map();
|
||||
private thresholdState: Map<string, boolean[]> = new Map();
|
||||
private httpServer?: NupstHttpServer;
|
||||
private readonly shutdownExecutor: ShutdownExecutor;
|
||||
|
||||
@@ -208,10 +223,14 @@ export class NupstDaemon {
|
||||
const migrationRunner = new MigrationRunner();
|
||||
const { config: migratedConfig, migrated } = await migrationRunner.run(parsedConfig);
|
||||
|
||||
// Save migrated config back to disk if any migrations ran
|
||||
// Cast to INupstConfig since migrations ensure the output is valid
|
||||
// Save migrated or normalized config back to disk when needed.
|
||||
// Cast to INupstConfig since migrations ensure the output is valid.
|
||||
const validConfig = migratedConfig as unknown as INupstConfig;
|
||||
if (migrated) {
|
||||
const normalizedShutdownDelay = this.normalizeShutdownDelay(validConfig.defaultShutdownDelay);
|
||||
const shouldPersistNormalizedConfig =
|
||||
validConfig.defaultShutdownDelay !== normalizedShutdownDelay;
|
||||
validConfig.defaultShutdownDelay = normalizedShutdownDelay;
|
||||
if (migrated || shouldPersistNormalizedConfig) {
|
||||
this.config = validConfig;
|
||||
await this.saveConfig(this.config);
|
||||
} else {
|
||||
@@ -245,10 +264,11 @@ 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,
|
||||
defaultShutdownDelay: this.normalizeShutdownDelay(config.defaultShutdownDelay),
|
||||
...(config.httpServer ? { httpServer: config.httpServer } : {}),
|
||||
};
|
||||
|
||||
@@ -280,6 +300,22 @@ export class NupstDaemon {
|
||||
return this.config;
|
||||
}
|
||||
|
||||
private normalizeShutdownDelay(delayMinutes: number | undefined): number {
|
||||
if (
|
||||
typeof delayMinutes !== 'number' ||
|
||||
!Number.isFinite(delayMinutes) ||
|
||||
delayMinutes < 0
|
||||
) {
|
||||
return SHUTDOWN.DEFAULT_DELAY_MINUTES;
|
||||
}
|
||||
|
||||
return delayMinutes;
|
||||
}
|
||||
|
||||
private getDefaultShutdownDelayMinutes(): number {
|
||||
return this.normalizeShutdownDelay(this.config.defaultShutdownDelay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the SNMP instance
|
||||
*/
|
||||
@@ -616,19 +652,24 @@ export class NupstDaemon {
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
hasThresholdViolation(
|
||||
status.powerStatus,
|
||||
status.batteryCapacity,
|
||||
status.batteryRuntime,
|
||||
ups.actions,
|
||||
)
|
||||
) {
|
||||
const thresholdStates = getActionThresholdStates(
|
||||
status.powerStatus,
|
||||
status.batteryCapacity,
|
||||
status.batteryRuntime,
|
||||
ups.actions,
|
||||
);
|
||||
const enteredThresholdIndexes = this.trackEnteredThresholdIndexes(
|
||||
`ups:${ups.id}`,
|
||||
thresholdStates,
|
||||
);
|
||||
|
||||
if (enteredThresholdIndexes.length > 0) {
|
||||
await this.triggerUpsActions(
|
||||
ups,
|
||||
pollSnapshot.updatedStatus,
|
||||
pollSnapshot.previousStatus,
|
||||
'thresholdViolation',
|
||||
enteredThresholdIndexes,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -668,6 +709,95 @@ export class NupstDaemon {
|
||||
this.upsStatus.set(ups.id, failureSnapshot.updatedStatus);
|
||||
}
|
||||
}
|
||||
|
||||
await this.checkGroupActions();
|
||||
}
|
||||
|
||||
private trackEnteredThresholdIndexes(sourceKey: string, currentStates: boolean[]): number[] {
|
||||
const previousStates = this.thresholdState.get(sourceKey);
|
||||
const enteredIndexes = getEnteredThresholdIndexes(previousStates, currentStates);
|
||||
this.thresholdState.set(sourceKey, [...currentStates]);
|
||||
return enteredIndexes;
|
||||
}
|
||||
|
||||
private getGroupActionIdentity(group: IGroupConfig): { id: string; name: string } {
|
||||
return {
|
||||
id: group.id,
|
||||
name: `Group ${group.name}`,
|
||||
};
|
||||
}
|
||||
|
||||
private async checkGroupActions(): Promise<void> {
|
||||
for (const group of this.config.groups || []) {
|
||||
const groupIdentity = this.getGroupActionIdentity(group);
|
||||
const memberStatuses = this.config.upsDevices
|
||||
.filter((ups) => ups.groups?.includes(group.id))
|
||||
.map((ups) => this.upsStatus.get(ups.id))
|
||||
.filter((status): status is IUpsStatus => !!status);
|
||||
|
||||
if (memberStatuses.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const currentTime = Date.now();
|
||||
const pollSnapshot = buildGroupStatusSnapshot(
|
||||
groupIdentity,
|
||||
group.mode,
|
||||
memberStatuses,
|
||||
this.groupStatus.get(group.id),
|
||||
currentTime,
|
||||
);
|
||||
|
||||
if (pollSnapshot.transition === 'powerStatusChange' && pollSnapshot.previousStatus) {
|
||||
logger.log('');
|
||||
logger.logBoxTitle(`Group Power Status Change: ${group.name}`, 60, 'warning');
|
||||
logger.logBoxLine(
|
||||
`Previous: ${formatPowerStatus(pollSnapshot.previousStatus.powerStatus)}`,
|
||||
);
|
||||
logger.logBoxLine(`Current: ${formatPowerStatus(pollSnapshot.updatedStatus.powerStatus)}`);
|
||||
logger.logBoxLine(`Members: ${memberStatuses.map((status) => status.name).join(', ')}`);
|
||||
logger.logBoxLine(`Time: ${new Date().toISOString()}`);
|
||||
logger.logBoxEnd();
|
||||
logger.log('');
|
||||
|
||||
await this.triggerGroupActions(
|
||||
group,
|
||||
pollSnapshot.updatedStatus,
|
||||
pollSnapshot.previousStatus,
|
||||
'powerStatusChange',
|
||||
);
|
||||
}
|
||||
|
||||
const thresholdEvaluations = (group.actions || []).map((action) =>
|
||||
evaluateGroupActionThreshold(action, group.mode, memberStatuses)
|
||||
);
|
||||
const thresholdStates = thresholdEvaluations.map((evaluation) =>
|
||||
evaluation.exceedsThreshold && !evaluation.blockedByUnreachable
|
||||
);
|
||||
const enteredThresholdIndexes = this.trackEnteredThresholdIndexes(
|
||||
`group:${group.id}`,
|
||||
thresholdStates,
|
||||
);
|
||||
|
||||
if (enteredThresholdIndexes.length > 0) {
|
||||
const thresholdStatus = buildGroupThresholdContextStatus(
|
||||
groupIdentity,
|
||||
thresholdEvaluations,
|
||||
enteredThresholdIndexes,
|
||||
pollSnapshot.updatedStatus,
|
||||
currentTime,
|
||||
);
|
||||
await this.triggerGroupActions(
|
||||
group,
|
||||
thresholdStatus,
|
||||
pollSnapshot.previousStatus,
|
||||
'thresholdViolation',
|
||||
enteredThresholdIndexes,
|
||||
);
|
||||
}
|
||||
|
||||
this.groupStatus.set(group.id, pollSnapshot.updatedStatus);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -735,6 +865,7 @@ export class NupstDaemon {
|
||||
status: IUpsStatus,
|
||||
previousStatus: IUpsStatus | undefined,
|
||||
triggerReason: TUpsTriggerReason,
|
||||
actionIndexes?: number[],
|
||||
): Promise<void> {
|
||||
const decision = decideUpsActionExecution(
|
||||
this.isPaused,
|
||||
@@ -758,7 +889,61 @@ export class NupstDaemon {
|
||||
return;
|
||||
}
|
||||
|
||||
await ActionManager.executeActions(decision.actions, decision.context);
|
||||
const selectedActions = actionIndexes
|
||||
? decision.actions.filter((_action, index) => actionIndexes.includes(index))
|
||||
: decision.actions;
|
||||
|
||||
if (selectedActions.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const actions = applyDefaultShutdownDelay(
|
||||
selectedActions,
|
||||
this.getDefaultShutdownDelayMinutes(),
|
||||
);
|
||||
|
||||
await ActionManager.executeActions(actions, decision.context);
|
||||
}
|
||||
|
||||
private async triggerGroupActions(
|
||||
group: IGroupConfig,
|
||||
status: IUpsStatus,
|
||||
previousStatus: IUpsStatus | undefined,
|
||||
triggerReason: TUpsTriggerReason,
|
||||
actionIndexes?: number[],
|
||||
): Promise<void> {
|
||||
if (this.isPaused) {
|
||||
logger.info(
|
||||
`[PAUSED] Actions suppressed for Group ${group.name} (trigger: ${triggerReason})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const configuredActions = group.actions || [];
|
||||
if (configuredActions.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedActions = actionIndexes
|
||||
? configuredActions.filter((_action, index) => actionIndexes.includes(index))
|
||||
: configuredActions;
|
||||
|
||||
if (selectedActions.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const actions = applyDefaultShutdownDelay(
|
||||
selectedActions,
|
||||
this.getDefaultShutdownDelayMinutes(),
|
||||
);
|
||||
const context = buildUpsActionContext(
|
||||
this.getGroupActionIdentity(group),
|
||||
status,
|
||||
previousStatus,
|
||||
triggerReason,
|
||||
);
|
||||
|
||||
await ActionManager.executeActions(actions, context);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -768,8 +953,7 @@ export class NupstDaemon {
|
||||
public async initiateShutdown(reason: string): Promise<void> {
|
||||
logger.log(`Initiating system shutdown due to: ${reason}`);
|
||||
|
||||
// Set a longer delay for shutdown to allow VMs and services to close
|
||||
const shutdownDelayMinutes = 5;
|
||||
const shutdownDelayMinutes = this.getDefaultShutdownDelayMinutes();
|
||||
|
||||
try {
|
||||
await this.shutdownExecutor.scheduleShutdown(shutdownDelayMinutes);
|
||||
@@ -1024,6 +1208,8 @@ export class NupstDaemon {
|
||||
|
||||
// Load the new configuration
|
||||
await this.loadConfig();
|
||||
this.thresholdState.clear();
|
||||
this.groupStatus.clear();
|
||||
const newDeviceCount = this.config.upsDevices?.length || 0;
|
||||
|
||||
const reloadSnapshot = analyzeConfigReload(oldDeviceCount, newDeviceCount);
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import type { IActionConfig, TPowerStatus } from './actions/base-action.ts';
|
||||
import { createInitialUpsStatus, type IUpsIdentity, type IUpsStatus } from './ups-status.ts';
|
||||
|
||||
export interface IGroupStatusSnapshot {
|
||||
updatedStatus: IUpsStatus;
|
||||
transition: 'none' | 'powerStatusChange';
|
||||
previousStatus?: IUpsStatus;
|
||||
}
|
||||
|
||||
export interface IGroupThresholdEvaluation {
|
||||
exceedsThreshold: boolean;
|
||||
blockedByUnreachable: boolean;
|
||||
representativeStatus?: IUpsStatus;
|
||||
}
|
||||
|
||||
const destructiveActionTypes = new Set(['shutdown', 'proxmox']);
|
||||
|
||||
function getStatusSeverity(powerStatus: TPowerStatus): number {
|
||||
switch (powerStatus) {
|
||||
case 'unreachable':
|
||||
return 3;
|
||||
case 'onBattery':
|
||||
return 2;
|
||||
case 'unknown':
|
||||
return 1;
|
||||
case 'online':
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export function selectWorstStatus(statuses: IUpsStatus[]): IUpsStatus | undefined {
|
||||
return statuses.reduce<IUpsStatus | undefined>((worst, status) => {
|
||||
if (!worst) {
|
||||
return status;
|
||||
}
|
||||
|
||||
const severityDiff = getStatusSeverity(status.powerStatus) -
|
||||
getStatusSeverity(worst.powerStatus);
|
||||
if (severityDiff > 0) {
|
||||
return status;
|
||||
}
|
||||
if (severityDiff < 0) {
|
||||
return worst;
|
||||
}
|
||||
|
||||
if (status.batteryRuntime !== worst.batteryRuntime) {
|
||||
return status.batteryRuntime < worst.batteryRuntime ? status : worst;
|
||||
}
|
||||
|
||||
if (status.batteryCapacity !== worst.batteryCapacity) {
|
||||
return status.batteryCapacity < worst.batteryCapacity ? status : worst;
|
||||
}
|
||||
|
||||
return worst;
|
||||
}, undefined);
|
||||
}
|
||||
|
||||
function deriveGroupPowerStatus(
|
||||
mode: 'redundant' | 'nonRedundant',
|
||||
memberStatuses: IUpsStatus[],
|
||||
): TPowerStatus {
|
||||
if (memberStatuses.length === 0) {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
if (memberStatuses.some((status) => status.powerStatus === 'unreachable')) {
|
||||
return 'unreachable';
|
||||
}
|
||||
|
||||
if (mode === 'redundant') {
|
||||
if (memberStatuses.every((status) => status.powerStatus === 'onBattery')) {
|
||||
return 'onBattery';
|
||||
}
|
||||
} else if (memberStatuses.some((status) => status.powerStatus === 'onBattery')) {
|
||||
return 'onBattery';
|
||||
}
|
||||
|
||||
if (memberStatuses.some((status) => status.powerStatus === 'unknown')) {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
return 'online';
|
||||
}
|
||||
|
||||
function pickRepresentativeStatus(
|
||||
powerStatus: TPowerStatus,
|
||||
memberStatuses: IUpsStatus[],
|
||||
): IUpsStatus | undefined {
|
||||
const matchingStatuses = memberStatuses.filter((status) => status.powerStatus === powerStatus);
|
||||
return selectWorstStatus(matchingStatuses.length > 0 ? matchingStatuses : memberStatuses);
|
||||
}
|
||||
|
||||
export function buildGroupStatusSnapshot(
|
||||
group: IUpsIdentity,
|
||||
mode: 'redundant' | 'nonRedundant',
|
||||
memberStatuses: IUpsStatus[],
|
||||
currentStatus: IUpsStatus | undefined,
|
||||
currentTime: number,
|
||||
): IGroupStatusSnapshot {
|
||||
const previousStatus = currentStatus || createInitialUpsStatus(group, currentTime);
|
||||
const powerStatus = deriveGroupPowerStatus(mode, memberStatuses);
|
||||
const representative = pickRepresentativeStatus(powerStatus, memberStatuses) || previousStatus;
|
||||
const updatedStatus: IUpsStatus = {
|
||||
...previousStatus,
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
powerStatus,
|
||||
batteryCapacity: representative.batteryCapacity,
|
||||
batteryRuntime: representative.batteryRuntime,
|
||||
outputLoad: representative.outputLoad,
|
||||
outputPower: representative.outputPower,
|
||||
outputVoltage: representative.outputVoltage,
|
||||
outputCurrent: representative.outputCurrent,
|
||||
lastCheckTime: currentTime,
|
||||
consecutiveFailures: 0,
|
||||
unreachableSince: powerStatus === 'unreachable'
|
||||
? previousStatus.unreachableSince || currentTime
|
||||
: 0,
|
||||
lastStatusChange: previousStatus.lastStatusChange || currentTime,
|
||||
};
|
||||
|
||||
if (previousStatus.powerStatus !== powerStatus) {
|
||||
updatedStatus.lastStatusChange = currentTime;
|
||||
if (powerStatus === 'unreachable') {
|
||||
updatedStatus.unreachableSince = currentTime;
|
||||
}
|
||||
return {
|
||||
updatedStatus,
|
||||
transition: 'powerStatusChange',
|
||||
previousStatus,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
updatedStatus,
|
||||
transition: 'none',
|
||||
previousStatus: currentStatus,
|
||||
};
|
||||
}
|
||||
|
||||
export function evaluateGroupActionThreshold(
|
||||
actionConfig: IActionConfig,
|
||||
mode: 'redundant' | 'nonRedundant',
|
||||
memberStatuses: IUpsStatus[],
|
||||
): IGroupThresholdEvaluation {
|
||||
if (!actionConfig.thresholds || memberStatuses.length === 0) {
|
||||
return {
|
||||
exceedsThreshold: false,
|
||||
blockedByUnreachable: false,
|
||||
};
|
||||
}
|
||||
|
||||
const criticalMembers = memberStatuses.filter((status) =>
|
||||
status.powerStatus === 'onBattery' &&
|
||||
(status.batteryCapacity < actionConfig.thresholds!.battery ||
|
||||
status.batteryRuntime < actionConfig.thresholds!.runtime)
|
||||
);
|
||||
const exceedsThreshold = mode === 'redundant'
|
||||
? criticalMembers.length === memberStatuses.length
|
||||
: criticalMembers.length > 0;
|
||||
|
||||
return {
|
||||
exceedsThreshold,
|
||||
blockedByUnreachable: exceedsThreshold &&
|
||||
destructiveActionTypes.has(actionConfig.type) &&
|
||||
memberStatuses.some((status) => status.powerStatus === 'unreachable'),
|
||||
representativeStatus: selectWorstStatus(criticalMembers),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildGroupThresholdContextStatus(
|
||||
group: IUpsIdentity,
|
||||
evaluations: IGroupThresholdEvaluation[],
|
||||
enteredActionIndexes: number[],
|
||||
fallbackStatus: IUpsStatus,
|
||||
currentTime: number,
|
||||
): IUpsStatus {
|
||||
const representativeStatuses = enteredActionIndexes
|
||||
.map((index) => evaluations[index]?.representativeStatus)
|
||||
.filter((status): status is IUpsStatus => !!status);
|
||||
|
||||
const representative = selectWorstStatus(representativeStatuses) || fallbackStatus;
|
||||
|
||||
return {
|
||||
...fallbackStatus,
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
powerStatus: 'onBattery',
|
||||
batteryCapacity: representative.batteryCapacity,
|
||||
batteryRuntime: representative.batteryRuntime,
|
||||
outputLoad: representative.outputLoad,
|
||||
outputPower: representative.outputPower,
|
||||
outputVoltage: representative.outputVoltage,
|
||||
outputCurrent: representative.outputCurrent,
|
||||
lastCheckTime: currentTime,
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -37,8 +37,7 @@ import { logger } from '../logger.ts';
|
||||
* {
|
||||
* type: "shutdown",
|
||||
* thresholds: { battery: 60, runtime: 20 },
|
||||
* triggerMode: "onlyThresholds",
|
||||
* shutdownDelay: 5
|
||||
* triggerMode: "onlyThresholds"
|
||||
* }
|
||||
* ]
|
||||
* }
|
||||
@@ -93,7 +92,6 @@ export class MigrationV4_0ToV4_1 extends BaseMigration {
|
||||
runtime: deviceThresholds.runtime,
|
||||
},
|
||||
triggerMode: 'onlyThresholds', // Preserve old behavior (only on threshold violation)
|
||||
shutdownDelay: 5, // Default delay
|
||||
},
|
||||
];
|
||||
logger.dim(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+13
-48
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+135
-42
@@ -1,10 +1,71 @@
|
||||
import process from 'node:process';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { execFileSync, execSync } from 'node:child_process';
|
||||
import { type IUpsConfig, NupstDaemon } from './daemon.ts';
|
||||
import { NupstSnmp } from './snmp/manager.ts';
|
||||
import { logger } from './logger.ts';
|
||||
import { formatPowerStatus, getBatteryColor, getRuntimeColor, symbols, theme } from './colors.ts';
|
||||
import { SHUTDOWN } from './constants.ts';
|
||||
|
||||
interface IServiceStatusSnapshot {
|
||||
loadState: string;
|
||||
activeState: string;
|
||||
subState: string;
|
||||
pid: string;
|
||||
memory: string;
|
||||
cpu: string;
|
||||
}
|
||||
|
||||
function formatSystemdMemory(memoryBytes: string): string {
|
||||
const bytes = Number(memoryBytes);
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const units = ['B', 'K', 'M', 'G', 'T', 'P'];
|
||||
let value = bytes;
|
||||
let unitIndex = 0;
|
||||
|
||||
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||
value /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
if (unitIndex === 0) {
|
||||
return `${Math.round(value)}B`;
|
||||
}
|
||||
|
||||
return `${value.toFixed(1).replace(/\.0$/, '')}${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
function formatSystemdCpu(cpuNanoseconds: string): string {
|
||||
const nanoseconds = Number(cpuNanoseconds);
|
||||
if (!Number.isFinite(nanoseconds) || nanoseconds <= 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const milliseconds = nanoseconds / 1_000_000;
|
||||
if (milliseconds < 1000) {
|
||||
return `${Math.round(milliseconds)}ms`;
|
||||
}
|
||||
|
||||
const seconds = milliseconds / 1000;
|
||||
if (seconds < 60) {
|
||||
return `${seconds.toFixed(seconds >= 10 ? 1 : 3).replace(/\.?0+$/, '')}s`;
|
||||
}
|
||||
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
if (minutes < 60) {
|
||||
return `${minutes}min ${
|
||||
remainingSeconds.toFixed(remainingSeconds >= 10 ? 1 : 3).replace(/\.?0+$/, '')
|
||||
}s`;
|
||||
}
|
||||
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const remainingMinutes = minutes % 60;
|
||||
return `${hours}h ${remainingMinutes}min`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Class for managing systemd service
|
||||
@@ -223,51 +284,69 @@ WantedBy=multi-user.target
|
||||
* Display the systemd service status
|
||||
* @private
|
||||
*/
|
||||
private getServiceStatusSnapshot(): IServiceStatusSnapshot {
|
||||
const output = execFileSync(
|
||||
'systemctl',
|
||||
[
|
||||
'show',
|
||||
'nupst.service',
|
||||
'--property=LoadState,ActiveState,SubState,MainPID,MemoryCurrent,CPUUsageNSec',
|
||||
],
|
||||
{ encoding: 'utf8' },
|
||||
);
|
||||
|
||||
const properties = new Map<string, string>();
|
||||
for (const line of output.split('\n')) {
|
||||
const separatorIndex = line.indexOf('=');
|
||||
if (separatorIndex === -1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
properties.set(line.slice(0, separatorIndex), line.slice(separatorIndex + 1));
|
||||
}
|
||||
|
||||
const pid = properties.get('MainPID') || '';
|
||||
return {
|
||||
loadState: properties.get('LoadState') || '',
|
||||
activeState: properties.get('ActiveState') || '',
|
||||
subState: properties.get('SubState') || '',
|
||||
pid: pid !== '0' ? pid : '',
|
||||
memory: formatSystemdMemory(properties.get('MemoryCurrent') || ''),
|
||||
cpu: formatSystemdCpu(properties.get('CPUUsageNSec') || ''),
|
||||
};
|
||||
}
|
||||
|
||||
private displayServiceStatus(): void {
|
||||
try {
|
||||
const serviceStatus = execSync('systemctl status nupst.service').toString();
|
||||
const lines = serviceStatus.split('\n');
|
||||
|
||||
// Parse key information from systemctl output
|
||||
let isActive = false;
|
||||
let pid = '';
|
||||
let memory = '';
|
||||
let cpu = '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.includes('Active:')) {
|
||||
isActive = line.includes('active (running)');
|
||||
} else if (line.includes('Main PID:')) {
|
||||
const match = line.match(/Main PID:\s+(\d+)/);
|
||||
if (match) pid = match[1];
|
||||
} else if (line.includes('Memory:')) {
|
||||
const match = line.match(/Memory:\s+([\d.]+[A-Z])/);
|
||||
if (match) memory = match[1];
|
||||
} else if (line.includes('CPU:')) {
|
||||
const match = line.match(/CPU:\s+([\d.]+(?:ms|s))/);
|
||||
if (match) cpu = match[1];
|
||||
}
|
||||
}
|
||||
const snapshot = this.getServiceStatusSnapshot();
|
||||
|
||||
// Display beautiful status
|
||||
logger.log('');
|
||||
if (isActive) {
|
||||
if (snapshot.loadState === 'not-found') {
|
||||
logger.log(
|
||||
`${symbols.running} ${theme.success('Service:')} ${
|
||||
theme.statusActive('active (running)')
|
||||
}`,
|
||||
`${symbols.stopped} ${theme.dim('Service:')} ${theme.statusInactive('not installed')}`,
|
||||
);
|
||||
} else if (snapshot.activeState === 'active') {
|
||||
const serviceState = snapshot.subState
|
||||
? `${snapshot.activeState} (${snapshot.subState})`
|
||||
: snapshot.activeState;
|
||||
logger.log(
|
||||
`${symbols.running} ${theme.success('Service:')} ${theme.statusActive(serviceState)}`,
|
||||
);
|
||||
} else {
|
||||
const serviceState = snapshot.subState && snapshot.subState !== snapshot.activeState
|
||||
? `${snapshot.activeState} (${snapshot.subState})`
|
||||
: snapshot.activeState || 'inactive';
|
||||
logger.log(
|
||||
`${symbols.stopped} ${theme.dim('Service:')} ${theme.statusInactive('inactive')}`,
|
||||
`${symbols.stopped} ${theme.dim('Service:')} ${theme.statusInactive(serviceState)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (pid || memory || cpu) {
|
||||
if (snapshot.pid || snapshot.memory || snapshot.cpu) {
|
||||
const details = [];
|
||||
if (pid) details.push(`PID: ${theme.dim(pid)}`);
|
||||
if (memory) details.push(`Memory: ${theme.dim(memory)}`);
|
||||
if (cpu) details.push(`CPU: ${theme.dim(cpu)}`);
|
||||
if (snapshot.pid) details.push(`PID: ${theme.dim(snapshot.pid)}`);
|
||||
if (snapshot.memory) details.push(`Memory: ${theme.dim(snapshot.memory)}`);
|
||||
if (snapshot.cpu) details.push(`CPU: ${theme.dim(snapshot.cpu)}`);
|
||||
logger.log(` ${details.join(' ')}`);
|
||||
}
|
||||
logger.log('');
|
||||
@@ -316,7 +395,6 @@ WantedBy=multi-user.target
|
||||
type: 'shutdown',
|
||||
thresholds: config.thresholds,
|
||||
triggerMode: 'onlyThresholds',
|
||||
shutdownDelay: 5,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
@@ -346,6 +424,8 @@ WantedBy=multi-user.target
|
||||
*/
|
||||
private async displaySingleUpsStatus(ups: IUpsConfig, snmp: NupstSnmp): Promise<void> {
|
||||
try {
|
||||
const defaultShutdownDelay = this.daemon.getConfig().defaultShutdownDelay ??
|
||||
SHUTDOWN.DEFAULT_DELAY_MINUTES;
|
||||
const protocol = ups.protocol || 'snmp';
|
||||
let status;
|
||||
|
||||
@@ -432,14 +512,20 @@ WantedBy=multi-user.target
|
||||
actionDesc += ` (${
|
||||
action.triggerMode || 'onlyThresholds'
|
||||
}: battery<${action.thresholds.battery}%, runtime<${action.thresholds.runtime}min`;
|
||||
if (action.shutdownDelay) {
|
||||
actionDesc += `, delay=${action.shutdownDelay}s`;
|
||||
if (action.type === 'shutdown') {
|
||||
const shutdownDelay = action.shutdownDelay ?? defaultShutdownDelay;
|
||||
actionDesc += `, delay=${shutdownDelay}min`;
|
||||
} else if (action.type === 'proxmox' && action.proxmoxHaPolicy === 'haStop') {
|
||||
actionDesc += ', ha=stop';
|
||||
}
|
||||
actionDesc += ')';
|
||||
} else {
|
||||
actionDesc += ` (${action.triggerMode || 'onlyPowerChanges'}`;
|
||||
if (action.shutdownDelay) {
|
||||
actionDesc += `, delay=${action.shutdownDelay}s`;
|
||||
if (action.type === 'shutdown') {
|
||||
const shutdownDelay = action.shutdownDelay ?? defaultShutdownDelay;
|
||||
actionDesc += `, delay=${shutdownDelay}min`;
|
||||
} else if (action.type === 'proxmox' && action.proxmoxHaPolicy === 'haStop') {
|
||||
actionDesc += ', ha=stop';
|
||||
}
|
||||
actionDesc += ')';
|
||||
}
|
||||
@@ -506,20 +592,27 @@ WantedBy=multi-user.target
|
||||
|
||||
// Display actions if any
|
||||
if (group.actions && group.actions.length > 0) {
|
||||
const defaultShutdownDelay = config.defaultShutdownDelay ?? SHUTDOWN.DEFAULT_DELAY_MINUTES;
|
||||
for (const action of group.actions) {
|
||||
let actionDesc = `${action.type}`;
|
||||
if (action.thresholds) {
|
||||
actionDesc += ` (${
|
||||
action.triggerMode || 'onlyThresholds'
|
||||
}: battery<${action.thresholds.battery}%, runtime<${action.thresholds.runtime}min`;
|
||||
if (action.shutdownDelay) {
|
||||
actionDesc += `, delay=${action.shutdownDelay}s`;
|
||||
if (action.type === 'shutdown') {
|
||||
const shutdownDelay = action.shutdownDelay ?? defaultShutdownDelay;
|
||||
actionDesc += `, delay=${shutdownDelay}min`;
|
||||
} else if (action.type === 'proxmox' && action.proxmoxHaPolicy === 'haStop') {
|
||||
actionDesc += ', ha=stop';
|
||||
}
|
||||
actionDesc += ')';
|
||||
} else {
|
||||
actionDesc += ` (${action.triggerMode || 'onlyPowerChanges'}`;
|
||||
if (action.shutdownDelay) {
|
||||
actionDesc += `, delay=${action.shutdownDelay}s`;
|
||||
if (action.type === 'shutdown') {
|
||||
const shutdownDelay = action.shutdownDelay ?? defaultShutdownDelay;
|
||||
actionDesc += `, delay=${shutdownDelay}min`;
|
||||
} else if (action.type === 'proxmox' && action.proxmoxHaPolicy === 'haStop') {
|
||||
actionDesc += ', ha=stop';
|
||||
}
|
||||
actionDesc += ')';
|
||||
}
|
||||
|
||||
+43
-9
@@ -120,19 +120,53 @@ export function hasThresholdViolation(
|
||||
batteryRuntime: number,
|
||||
actions: IActionConfig[] | undefined,
|
||||
): boolean {
|
||||
if (powerStatus !== 'onBattery' || !actions || actions.length === 0) {
|
||||
return getActionThresholdStates(powerStatus, batteryCapacity, batteryRuntime, actions).some(
|
||||
Boolean,
|
||||
);
|
||||
}
|
||||
|
||||
export function isActionThresholdExceeded(
|
||||
actionConfig: IActionConfig,
|
||||
powerStatus: IProtocolUpsStatus['powerStatus'],
|
||||
batteryCapacity: number,
|
||||
batteryRuntime: number,
|
||||
): boolean {
|
||||
if (powerStatus !== 'onBattery' || !actionConfig.thresholds) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const actionConfig of actions) {
|
||||
if (
|
||||
actionConfig.thresholds &&
|
||||
(batteryCapacity < actionConfig.thresholds.battery ||
|
||||
batteryRuntime < actionConfig.thresholds.runtime)
|
||||
) {
|
||||
return true;
|
||||
return (
|
||||
batteryCapacity < actionConfig.thresholds.battery ||
|
||||
batteryRuntime < actionConfig.thresholds.runtime
|
||||
);
|
||||
}
|
||||
|
||||
export function getActionThresholdStates(
|
||||
powerStatus: IProtocolUpsStatus['powerStatus'],
|
||||
batteryCapacity: number,
|
||||
batteryRuntime: number,
|
||||
actions: IActionConfig[] | undefined,
|
||||
): boolean[] {
|
||||
if (!actions || actions.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return actions.map((actionConfig) =>
|
||||
isActionThresholdExceeded(actionConfig, powerStatus, batteryCapacity, batteryRuntime)
|
||||
);
|
||||
}
|
||||
|
||||
export function getEnteredThresholdIndexes(
|
||||
previousStates: boolean[] | undefined,
|
||||
currentStates: boolean[],
|
||||
): number[] {
|
||||
const enteredIndexes: number[] = [];
|
||||
|
||||
for (let index = 0; index < currentStates.length; index++) {
|
||||
if (currentStates[index] && !previousStates?.[index]) {
|
||||
enteredIndexes.push(index);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
return enteredIndexes;
|
||||
}
|
||||
|
||||
+3
-1
@@ -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