Compare commits

...

10 Commits

Author SHA1 Message Date
jkunz bf4d519428 v5.6.0
Release / build-and-release (push) Successful in 54s
2026-04-14 18:47:37 +00:00
jkunz 579667b3cd feat(config): add configurable default shutdown delay for shutdown actions 2026-04-14 18:47:37 +00:00
jkunz 8dc0248763 v5.5.1
Release / build-and-release (push) Successful in 51s
2026-04-14 14:27:29 +00:00
jkunz 1f542ca271 fix(cli,daemon,snmp): normalize CLI argument parsing and extract daemon monitoring helpers with stronger SNMP typing 2026-04-14 14:27:29 +00:00
jkunz 2adf1d5548 v5.5.0
Release / build-and-release (push) Successful in 2m27s
2026-04-02 08:29:16 +00:00
jkunz 067a7666e4 feat(proxmox): add Proxmox CLI auto-detection and interactive action setup improvements 2026-04-02 08:29:16 +00:00
jkunz 0d863a1028 v5.4.1
Release / build-and-release (push) Successful in 51s
2026-03-30 06:50:36 +00:00
jkunz c410a663b1 fix(deps): bump tsdeno and net-snmp patch dependencies 2026-03-30 06:50:36 +00:00
jkunz 6aa1fc651f v5.4.0
Release / build-and-release (push) Failing after 15s
2026-03-30 06:46:28 +00:00
jkunz 11e549e68e feat(snmp): add configurable SNMP runtime units with v4.3 migration support 2026-03-30 06:46:28 +00:00
37 changed files with 2438 additions and 918 deletions
View File
+39
View File
@@ -1,5 +1,44 @@
# Changelog
## 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"
- 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
- 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.
- 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.
## 2026-03-18 - 5.3.3 - fix(deps)
add @git.zone/tsdeno as a development dependency
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@serve.zone/nupst",
"version": "5.3.3",
"version": "5.6.0",
"exports": "./mod.ts",
"nodeModulesDir": "auto",
"tasks": {
+1 -6
View File
@@ -25,12 +25,7 @@ import { NupstCli } from './ts/cli.ts';
*/
async function main(): Promise<void> {
const cli = new NupstCli();
// Deno.args is already 0-indexed (unlike Node's process.argv which starts at index 2)
// We need to prepend placeholder args to match the existing CLI parser expectations
const args = ['deno', 'mod.ts', ...Deno.args];
await cli.parseAndExecute(args);
await cli.parseAndExecute(Deno.args);
}
// Execute main and handle errors
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@serve.zone/nupst",
"version": "5.3.3",
"version": "5.6.0",
"description": "Network UPS Shutdown Tool - Monitor SNMP-enabled UPS devices and orchestrate graceful system shutdowns during power emergencies",
"keywords": [
"ups",
@@ -64,6 +64,6 @@
},
"packageManager": "pnpm@10.18.1+sha512.77a884a165cbba2d8d1c19e3b4880eee6d2fcabd0d879121e282196b80042351d5eb3ca0935fa599da1dc51265cc68816ad2bddd2a2de5ea9fdf92adbec7cd34",
"devDependencies": {
"@git.zone/tsdeno": "^1.2.0"
"@git.zone/tsdeno": "^1.3.1"
}
}
+26 -26
View File
@@ -9,8 +9,8 @@ importers:
.:
devDependencies:
'@git.zone/tsdeno':
specifier: ^1.2.0
version: 1.2.0
specifier: ^1.3.1
version: 1.3.1
packages:
@@ -39,8 +39,8 @@ packages:
'@design.estate/dees-element@2.2.3':
resolution: {integrity: sha512-MpAvJPrJDTDad8hUtdOzMgMFRE7n84O7INhvSlkTTLB3b84j8EKjwfUCMErGAo7Bq5zfw4LG7NnKhLYXXXjkXA==}
'@git.zone/tsdeno@1.2.0':
resolution: {integrity: sha512-Kx/9NchzKRoBhgj44V/ymF3rtspMAO+U+x7d3CFfBAyHCzVCrFtzIdVb5JULSTSR89d9a0L4VJVyVUoRdz/MUA==}
'@git.zone/tsdeno@1.3.1':
resolution: {integrity: sha512-1shZOSwMUfmonIe8OsX40EcQcS7/WFt+y+5+TQ7N6QmU+Hp6zjrfFvQwanr5Qcgh/E54WFNTfJk/PsJ26s6Oxw==}
hasBin: true
'@isaacs/cliui@9.0.0':
@@ -68,9 +68,6 @@ packages:
'@push.rocks/lik@6.3.1':
resolution: {integrity: sha512-UWDwGBaVx5yPtAFXqDDBtQZCzETUOA/7myQIXb+YBsuiIw4yQuhNZ23uY2ChQH2Zn6DLqdNSgQcYC0WywMZBNQ==}
'@push.rocks/npmextra@5.3.3':
resolution: {integrity: sha512-snLpSHwaQ5OXlZzF1KX/FY71W5LwajjBzor82Vue0smjEPnSeUPY5/JcVdMwtdprdJe13pc/EQQuIiL/zw4/yg==}
'@push.rocks/qenv@6.1.3':
resolution: {integrity: sha512-+z2hsAU/7CIgpYLFqvda8cn9rUBMHqLdQLjsFfRn5jPoD7dJ5rFlpkbhfM4Ws8mHMniwWaxGKo+q/YBhtzRBLg==}
@@ -83,6 +80,9 @@ packages:
'@push.rocks/smartclickhouse@2.2.0':
resolution: {integrity: sha512-eTzKiREIPSzL1kPkVyD6vEbn+WV/DvQqDjP67VlhNlQGbRcemnJG/eLrUUR1ytmdIqnsZGEK6UYBgyj5nhzLNQ==}
'@push.rocks/smartconfig@6.1.0':
resolution: {integrity: sha512-B+xh63PhGAsSwuRyCKXr4PAjJ4HoVKhNysi67OGY6gGqGm6uopgEW1cvrUZ7T5ZSck9KlVx7ZTugbqm6dqBK1Q==}
'@push.rocks/smartdelay@3.0.5':
resolution: {integrity: sha512-mUuI7kj2f7ztjpic96FvRIlf2RsKBa5arw81AHNsndbxO6asRcxuWL8dTVxouEIK8YsBUlj0AsrCkHhMbLQdHw==}
@@ -1025,11 +1025,11 @@ snapshots:
- supports-color
- vue
'@git.zone/tsdeno@1.2.0':
'@git.zone/tsdeno@1.3.1':
dependencies:
'@push.rocks/early': 4.0.4
'@push.rocks/npmextra': 5.3.3
'@push.rocks/smartcli': 4.0.20
'@push.rocks/smartconfig': 6.1.0
'@push.rocks/smartfs': 1.5.0
'@push.rocks/smartshell': 3.3.8
transitivePeerDependencies:
@@ -1070,23 +1070,6 @@ snapshots:
'@types/symbol-tree': 3.2.5
symbol-tree: 3.2.4
'@push.rocks/npmextra@5.3.3':
dependencies:
'@push.rocks/qenv': 6.1.3
'@push.rocks/smartfile': 11.2.7
'@push.rocks/smartjson': 5.2.0
'@push.rocks/smartlog': 3.2.1
'@push.rocks/smartpath': 6.0.0
'@push.rocks/smartpromise': 4.2.3
'@push.rocks/smartrx': 3.0.10
'@push.rocks/taskbuffer': 3.5.0
'@tsclass/tsclass': 9.4.0
transitivePeerDependencies:
- '@nuxt/kit'
- react
- supports-color
- vue
'@push.rocks/qenv@6.1.3':
dependencies:
'@api.global/typedrequest': 3.3.0
@@ -1117,6 +1100,23 @@ snapshots:
'@push.rocks/smarturl': 3.1.0
'@push.rocks/webrequest': 4.0.5
'@push.rocks/smartconfig@6.1.0':
dependencies:
'@push.rocks/qenv': 6.1.3
'@push.rocks/smartfile': 11.2.7
'@push.rocks/smartjson': 5.2.0
'@push.rocks/smartlog': 3.2.1
'@push.rocks/smartpath': 6.0.0
'@push.rocks/smartpromise': 4.2.3
'@push.rocks/smartrx': 3.0.10
'@push.rocks/taskbuffer': 3.5.0
'@tsclass/tsclass': 9.4.0
transitivePeerDependencies:
- '@nuxt/kit'
- react
- supports-color
- vue
'@push.rocks/smartdelay@3.0.5':
dependencies:
'@push.rocks/smartpromise': 4.2.3
+66 -3
View File
@@ -36,9 +36,14 @@
- Uses: `IUpsConfig`, `INupstConfig`, `ISnmpConfig`, `IActionConfig`, `IThresholds`,
`ISnmpUpsStatus`
7. **SNMP Manager Boundary Types (`ts/snmp/manager.ts`)**
- Added local wrapper interfaces for the untyped `net-snmp` package surface used by NUPST
- SNMP metric reads now coerce values explicitly instead of relying on `any`-typed responses
## Features Added (February 2026)
### Network Loss Handling
- `TPowerStatus` extended with `'unreachable'` state
- `IUpsStatus` has `consecutiveFailures` and `unreachableSince` tracking
- After `NETWORK.CONSECUTIVE_FAILURE_THRESHOLD` (3) failures, UPS transitions to `unreachable`
@@ -46,22 +51,63 @@
- Recovery is logged when UPS comes back from unreachable
### UPSD/NIS Protocol Support
- New `ts/upsd/` directory with TCP client for NUT (Network UPS Tools) servers
- `ts/protocol/` directory with `ProtocolResolver` for protocol-agnostic status queries
- `IUpsConfig.protocol` field: `'snmp'` (default) or `'upsd'`
- `IUpsConfig.snmp` is now optional (not needed for UPSD devices)
- CLI supports protocol selection during `nupst ups add`
- Config version bumped to `4.2` with migration from `4.1`
- Config version is now `4.3`, including the `4.2` -> `4.3` runtime unit migration
### Pause/Resume Command
- File-based signaling via `/etc/nupst/pause` JSON file
- `nupst pause [--duration 30m|2h|1d]` creates pause file
- `nupst resume` deletes pause file
- `ts/pause-state.ts` owns pause snapshot parsing and transition detection for daemon polling
- Daemon polls continue but actions are suppressed while paused
- Auto-resume after duration expires
- HTTP API includes pause state in response
### Shutdown Orchestration
- `ts/shutdown-executor.ts` owns command discovery and fallback execution for delayed and emergency
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
- `ts/config-watch.ts` owns file-watch event matching and config-reload transition analysis
- `ts/daemon.ts` now delegates config/pause watch event classification and reload messaging
decisions
### UPS Status Tracking
- `ts/ups-status.ts` owns the daemon UPS status shape and default status factory
- `ts/daemon.ts` now reuses a shared initializer instead of duplicating the default UPS status
object
### UPS Monitoring Transitions
- `ts/ups-monitoring.ts` owns pure UPS poll success/failure transition logic and threshold detection
- `ts/daemon.ts` now orchestrates protocol calls and logging while delegating state transitions
### Action Orchestration
- `ts/action-orchestration.ts` owns action context construction and action execution decisions
- `ts/daemon.ts` now delegates pause suppression, legacy shutdown fallback, and action context
building
### Shutdown Monitoring
- `ts/shutdown-monitoring.ts` owns shutdown-loop row building and emergency candidate selection
- `ts/daemon.ts` now keeps the shutdown loop orchestration while delegating row/emergency decisions
### Proxmox VM Shutdown Action
- New action type `'proxmox'` in `ts/actions/proxmox-action.ts`
- Uses Proxmox REST API with PVEAPIToken authentication
- Shuts down QEMU VMs and LXC containers before host shutdown
@@ -76,13 +122,30 @@
- **CLI Handlers**: All use the `helpers.withPrompt()` utility for interactive input
- **Constants**: All timing values should be referenced from `ts/constants.ts`
- **Actions**: Use `IActionConfig` from `ts/actions/base-action.ts` for action configuration
- **Config version**: Currently `4.2`, migrations run automatically
- **Action orchestration**: Use helpers from `ts/action-orchestration.ts` for action context and
execution decisions
- **Config watch logic**: Use helpers from `ts/config-watch.ts` for file event filtering and reload
transitions
- **Pause state**: Use `loadPauseSnapshot()` and `IPauseState` from `ts/pause-state.ts`
- **Shutdown execution**: Use `ShutdownExecutor` for OS-level shutdown command lookup and fallbacks
- **Shutdown monitoring**: Use helpers from `ts/shutdown-monitoring.ts` for emergency loop rows and
candidate selection
- **UPS status state**: Use `IUpsStatus` and `createInitialUpsStatus()` from `ts/ups-status.ts`
- **UPS poll transitions**: Use helpers from `ts/ups-monitoring.ts` for success/failure updates
- **Config version**: Currently `4.3`, migrations run automatically
## File Organization
```
ts/
├── constants.ts # All timing/threshold constants
├── action-orchestration.ts # Action context and execution decisions
├── config-watch.ts # File watch filters and config reload transitions
├── shutdown-monitoring.ts # Shutdown loop rows and emergency selection
├── ups-monitoring.ts # Pure UPS poll transition and threshold helpers
├── pause-state.ts # Shared pause state types and transition detection
├── shutdown-executor.ts # Delayed/emergency shutdown command execution
├── ups-status.ts # Daemon UPS status shape and initializer
├── interfaces/
│ └── nupst-accessor.ts # Interface to break circular deps
├── helpers/
@@ -103,7 +166,7 @@ ts/
│ └── index.ts
├── migrations/
│ ├── migration-runner.ts
│ └── migration-v4.1-to-v4.2.ts # Adds protocol field
│ └── migration-v4.2-to-v4.3.ts # Adds SNMP runtimeUnit defaults
└── cli/
└── ... # All handlers use helpers.withPrompt()
```
+63 -21
View File
@@ -12,7 +12,7 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
- **🔌 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
- **🖥️ Proxmox Integration** — Gracefully shut down QEMU VMs and LXC containers before host shutdown (auto-detects CLI tools — no API token needed on Proxmox hosts)
- **👥 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
@@ -219,12 +219,16 @@ nupst uninstall # Completely remove NUPST (requires root)
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.
`defaultShutdownDelay` sets the inherited delay in minutes for shutdown actions that do not define
their own `shutdownDelay`.
### Example Configuration
```json
{
"version": "4.2",
"version": "4.3",
"checkInterval": 30000,
"defaultShutdownDelay": 5,
"httpServer": {
"enabled": true,
"port": 8080,
@@ -242,17 +246,17 @@ NUPST stores configuration at `/etc/nupst/config.json`. The easiest way to confi
"community": "public",
"version": 1,
"timeout": 5000,
"upsModel": "cyberpower"
"upsModel": "cyberpower",
"runtimeUnit": "ticks"
},
"actions": [
{
"type": "proxmox",
"triggerMode": "onlyThresholds",
"thresholds": { "battery": 30, "runtime": 15 },
"proxmoxHost": "localhost",
"proxmoxPort": 8006,
"proxmoxTokenId": "root@pam!nupst",
"proxmoxTokenSecret": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
"proxmoxMode": "auto",
"proxmoxExcludeIds": [],
"proxmoxForceStop": true
},
{
"type": "shutdown",
@@ -323,6 +327,7 @@ Each UPS device has a `protocol` field:
| `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`):
@@ -362,7 +367,7 @@ Actions define automated responses to UPS conditions. They run **sequentially in
| `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 via REST API |
| `proxmox` | Shut down Proxmox QEMU VMs and LXC containers (CLI or API) |
#### Common Fields
@@ -394,7 +399,7 @@ Actions define automated responses to UPS conditions. They run **sequentially in
| Field | Description | Default |
| --------------- | ---------------------------------- | ------- |
| `shutdownDelay` | Seconds to wait before shutdown | `5` |
| `shutdownDelay` | Minutes to wait before shutdown | Inherits `defaultShutdownDelay` (`5`) |
#### Webhook Action
@@ -436,11 +441,38 @@ Actions define automated responses to UPS conditions. They run **sequentially in
Gracefully shuts down QEMU VMs and LXC containers on a Proxmox node before the host is shut down.
NUPST supports **two operation modes** for Proxmox:
| 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) | — |
> 💡 **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):
```json
{
"type": "proxmox",
"thresholds": { "battery": 30, "runtime": 15 },
"triggerMode": "onlyThresholds",
"proxmoxMode": "auto",
"proxmoxExcludeIds": [100, 101],
"proxmoxStopTimeout": 120,
"proxmoxForceStop": true
}
```
**API mode example** (for remote Proxmox hosts or non-root setups):
```json
{
"type": "proxmox",
"thresholds": { "battery": 30, "runtime": 15 },
"triggerMode": "onlyThresholds",
"proxmoxMode": "api",
"proxmoxHost": "localhost",
"proxmoxPort": 8006,
"proxmoxTokenId": "root@pam!nupst",
@@ -454,17 +486,18 @@ Gracefully shuts down QEMU VMs and LXC containers on a Proxmox node before the h
| Field | Description | Default |
| --------------------- | ----------------------------------------------- | ------------- |
| `proxmoxHost` | Proxmox API host | `localhost` |
| `proxmoxPort` | Proxmox API port | `8006` |
| `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 (e.g. `root@pam!nupst`) | Required |
| `proxmoxTokenSecret` | API token secret (UUID) | Required |
| `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 (self-signed certs) | `true` |
| `proxmoxInsecure` | Skip TLS verification (API mode only) | `true` |
**Setting up the API token on Proxmox:**
**Setting up the API token** (only needed for API mode):
```bash
# Create token with full privileges (no privilege separation)
@@ -581,16 +614,16 @@ UPS Devices (2):
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: 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
@@ -629,7 +662,7 @@ Full SNMPv3 support with authentication and encryption:
### Network Security
- Connects only to UPS devices and optionally Proxmox on local network
- 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
@@ -659,6 +692,7 @@ sha256sum -c SHA256SUMS.txt --ignore-missing
```json
{
"upsModel": "custom",
"runtimeUnit": "seconds",
"customOIDs": {
"POWER_STATUS": "1.3.6.1.4.1.1234.1.1.0",
"BATTERY_CAPACITY": "1.3.6.1.4.1.1234.1.2.0",
@@ -667,6 +701,8 @@ 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`.
### 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).
@@ -736,7 +772,13 @@ upsc ups@localhost # if NUT CLI is installed
### Proxmox VMs Not Shutting Down
```bash
# Verify API token works
# CLI mode: verify qm/pct are available and you're root
which qm pct
whoami # should be 'root'
qm list # should list VMs
pct list # should list containers
# API mode: verify API token works
curl -k -H "Authorization: PVEAPIToken=root@pam!nupst=YOUR-SECRET" \
https://localhost:8006/api2/json/nodes/$(hostname)/qemu
@@ -848,7 +890,7 @@ 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.
+3 -3
View File
@@ -229,10 +229,10 @@ console.log('');
// === 10. Update Available Example ===
logger.logBoxTitle('Update Available', 70, 'warning');
logger.logBoxLine('');
logger.logBoxLine(`Current Version: ${theme.dim('4.0.1')}`);
logger.logBoxLine(`Latest Version: ${theme.highlight('4.0.2')}`);
logger.logBoxLine(`Current Version: ${theme.dim('5.5.0')}`);
logger.logBoxLine(`Latest Version: ${theme.highlight('5.5.1')}`);
logger.logBoxLine('');
logger.logBoxLine(`Run ${theme.command('sudo nupst update')} to update`);
logger.logBoxLine(`Run ${theme.command('sudo nupst upgrade')} to update`);
logger.logBoxLine('');
logger.logBoxEnd();
+450
View File
@@ -2,9 +2,31 @@ 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 type { IOidSet, ISnmpConfig, TUpsModel } from '../ts/snmp/types.ts';
import {
analyzeConfigReload,
shouldRefreshPauseState,
shouldReloadConfig,
} from '../ts/config-watch.ts';
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 {
applyDefaultShutdownDelay,
buildUpsActionContext,
decideUpsActionExecution,
} from '../ts/action-orchestration.ts';
import {
buildShutdownErrorRow,
buildShutdownStatusRow,
selectEmergencyCandidate,
} from '../ts/shutdown-monitoring.ts';
import {
buildFailedUpsPollSnapshot,
buildSuccessfulUpsPollSnapshot,
hasThresholdViolation,
} from '../ts/ups-monitoring.ts';
import { createInitialUpsStatus } from '../ts/ups-status.ts';
import * as qenv from 'npm:@push.rocks/qenv@^6.0.0';
const testQenv = new qenv.Qenv('./', '.nogit/');
@@ -82,6 +104,434 @@ Deno.test('UI constants: box widths are ascending', () => {
assert(UI.WIDE_BOX_WIDTH < UI.EXTRA_WIDE_BOX_WIDTH);
});
// -----------------------------------------------------------------------------
// Pause State Tests
// -----------------------------------------------------------------------------
Deno.test('loadPauseSnapshot: reports paused state for valid pause file', async () => {
const tempDir = await Deno.makeTempDir();
const pauseFilePath = `${tempDir}/pause.json`;
const pauseState: IPauseState = {
pausedAt: 1000,
pausedBy: 'cli',
reason: 'maintenance',
resumeAt: 5000,
};
try {
await Deno.writeTextFile(pauseFilePath, JSON.stringify(pauseState));
const snapshot = loadPauseSnapshot(pauseFilePath, false, 2000);
assertEquals(snapshot.isPaused, true);
assertEquals(snapshot.pauseState, pauseState);
assertEquals(snapshot.transition, 'paused');
} finally {
await Deno.remove(tempDir, { recursive: true });
}
});
Deno.test('loadPauseSnapshot: auto-resumes expired pause file', async () => {
const tempDir = await Deno.makeTempDir();
const pauseFilePath = `${tempDir}/pause.json`;
const pauseState: IPauseState = {
pausedAt: 1000,
pausedBy: 'cli',
resumeAt: 1500,
};
try {
await Deno.writeTextFile(pauseFilePath, JSON.stringify(pauseState));
const snapshot = loadPauseSnapshot(pauseFilePath, true, 2000);
assertEquals(snapshot.isPaused, false);
assertEquals(snapshot.pauseState, null);
assertEquals(snapshot.transition, 'autoResumed');
let fileExists = true;
try {
await Deno.stat(pauseFilePath);
} catch {
fileExists = false;
}
assertEquals(fileExists, false);
} finally {
await Deno.remove(tempDir, { recursive: true });
}
});
Deno.test('loadPauseSnapshot: reports resumed when pause file disappears', async () => {
const tempDir = await Deno.makeTempDir();
try {
const snapshot = loadPauseSnapshot(`${tempDir}/pause.json`, true, 2000);
assertEquals(snapshot.isPaused, false);
assertEquals(snapshot.pauseState, null);
assertEquals(snapshot.transition, 'resumed');
} finally {
await Deno.remove(tempDir, { recursive: true });
}
});
// -----------------------------------------------------------------------------
// Config Watch Tests
// -----------------------------------------------------------------------------
Deno.test('shouldReloadConfig: matches modify events for config.json', () => {
assertEquals(
shouldReloadConfig({ kind: 'modify', paths: ['/etc/nupst/config.json'] }),
true,
);
assertEquals(
shouldReloadConfig({ kind: 'create', paths: ['/etc/nupst/config.json'] }),
false,
);
assertEquals(
shouldReloadConfig({ kind: 'modify', paths: ['/etc/nupst/other.json'] }),
false,
);
});
Deno.test('shouldRefreshPauseState: matches create/modify/remove pause events', () => {
assertEquals(
shouldRefreshPauseState({ kind: 'create', paths: ['/etc/nupst/pause'] }),
true,
);
assertEquals(
shouldRefreshPauseState({ kind: 'remove', paths: ['/etc/nupst/pause'] }),
true,
);
assertEquals(
shouldRefreshPauseState({ kind: 'modify', paths: ['/etc/nupst/config.json'] }),
false,
);
});
Deno.test('analyzeConfigReload: detects monitoring start and device count changes', () => {
assertEquals(analyzeConfigReload(0, 2), {
transition: 'monitoringWillStart',
message: 'Configuration reloaded! Found 2 UPS device(s)',
shouldInitializeUpsStatus: false,
shouldLogMonitoringStart: true,
});
assertEquals(analyzeConfigReload(2, 3), {
transition: 'deviceCountChanged',
message: 'Configuration reloaded! UPS devices: 2 -> 3',
shouldInitializeUpsStatus: true,
shouldLogMonitoringStart: false,
});
assertEquals(analyzeConfigReload(2, 2), {
transition: 'reloaded',
message: 'Configuration reloaded successfully',
shouldInitializeUpsStatus: false,
shouldLogMonitoringStart: false,
});
});
// -----------------------------------------------------------------------------
// UPS Status Tests
// -----------------------------------------------------------------------------
Deno.test('createInitialUpsStatus: creates default daemon UPS status shape', () => {
assertEquals(createInitialUpsStatus({ id: 'ups-1', name: 'Main UPS' }, 1234), {
id: 'ups-1',
name: 'Main UPS',
powerStatus: 'unknown',
batteryCapacity: 100,
batteryRuntime: 999,
outputLoad: 0,
outputPower: 0,
outputVoltage: 0,
outputCurrent: 0,
lastStatusChange: 1234,
lastCheckTime: 0,
consecutiveFailures: 0,
unreachableSince: 0,
});
});
// -----------------------------------------------------------------------------
// Action Orchestration Tests
// -----------------------------------------------------------------------------
Deno.test('buildUpsActionContext: includes previous power status and timestamp', () => {
const status = {
...createInitialUpsStatus({ id: 'ups-1', name: 'Main UPS' }, 1000),
powerStatus: 'onBattery' as const,
batteryCapacity: 42,
batteryRuntime: 15,
};
const previousStatus = {
...createInitialUpsStatus({ id: 'ups-1', name: 'Main UPS' }, 500),
powerStatus: 'online' as const,
};
assertEquals(
buildUpsActionContext(
{ id: 'ups-1', name: 'Main UPS' },
status,
previousStatus,
'thresholdViolation',
9999,
),
{
upsId: 'ups-1',
upsName: 'Main UPS',
powerStatus: 'onBattery',
batteryCapacity: 42,
batteryRuntime: 15,
previousPowerStatus: 'online',
timestamp: 9999,
triggerReason: 'thresholdViolation',
},
);
});
Deno.test('decideUpsActionExecution: suppresses actions while paused', () => {
const decision = decideUpsActionExecution(
true,
{ id: 'ups-1', name: 'Main UPS', actions: [{ type: 'shutdown' }] },
createInitialUpsStatus({ id: 'ups-1', name: 'Main UPS' }, 1000),
undefined,
'powerStatusChange',
9999,
);
assertEquals(decision, {
type: 'suppressed',
message: '[PAUSED] Actions suppressed for UPS Main UPS (trigger: powerStatusChange)',
});
});
Deno.test('decideUpsActionExecution: falls back to legacy shutdown without actions', () => {
const decision = decideUpsActionExecution(
false,
{ id: 'ups-1', name: 'Main UPS' },
createInitialUpsStatus({ id: 'ups-1', name: 'Main UPS' }, 1000),
undefined,
'thresholdViolation',
9999,
);
assertEquals(decision, {
type: 'legacyShutdown',
reason: 'UPS "Main UPS" battery or runtime below threshold',
});
});
Deno.test('decideUpsActionExecution: returns executable action plan when actions exist', () => {
const decision = decideUpsActionExecution(
false,
{ id: 'ups-1', name: 'Main UPS', actions: [{ type: 'shutdown' }] },
{
...createInitialUpsStatus({ id: 'ups-1', name: 'Main UPS' }, 1000),
powerStatus: 'onBattery',
batteryCapacity: 55,
batteryRuntime: 18,
},
{
...createInitialUpsStatus({ id: 'ups-1', name: 'Main UPS' }, 500),
powerStatus: 'online',
},
'powerStatusChange',
9999,
);
assertEquals(decision, {
type: 'execute',
actions: [{ type: 'shutdown' }],
context: {
upsId: 'ups-1',
upsName: 'Main UPS',
powerStatus: 'onBattery',
batteryCapacity: 55,
batteryRuntime: 18,
previousPowerStatus: 'online',
timestamp: 9999,
triggerReason: 'powerStatusChange',
},
});
});
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
// -----------------------------------------------------------------------------
Deno.test('buildShutdownStatusRow: marks critical rows below emergency runtime threshold', () => {
const snapshot = buildShutdownStatusRow(
'Main UPS',
{
powerStatus: 'onBattery',
batteryCapacity: 25,
batteryRuntime: 4,
outputLoad: 15,
outputPower: 100,
outputVoltage: 230,
outputCurrent: 0.4,
raw: {},
},
5,
{
battery: (value) => `B:${value}`,
runtime: (value) => `R:${value}`,
ok: (text) => `ok:${text}`,
critical: (text) => `critical:${text}`,
error: (text) => `error:${text}`,
},
);
assertEquals(snapshot.isCritical, true);
assertEquals(snapshot.row, {
name: 'Main UPS',
battery: 'B:25',
runtime: 'R:4',
status: 'critical:CRITICAL!',
});
});
Deno.test('buildShutdownErrorRow: builds shutdown error table row', () => {
assertEquals(buildShutdownErrorRow('Main UPS', (text) => `error:${text}`), {
name: 'Main UPS',
battery: 'error:N/A',
runtime: 'error:N/A',
status: 'error:ERROR',
});
});
Deno.test('selectEmergencyCandidate: keeps first critical UPS candidate', () => {
const firstCandidate = selectEmergencyCandidate(
null,
{ id: 'ups-1', name: 'UPS 1' },
{
powerStatus: 'onBattery',
batteryCapacity: 40,
batteryRuntime: 4,
outputLoad: 10,
outputPower: 60,
outputVoltage: 230,
outputCurrent: 0.3,
raw: {},
},
5,
);
const secondCandidate = selectEmergencyCandidate(
firstCandidate,
{ id: 'ups-2', name: 'UPS 2' },
{
powerStatus: 'onBattery',
batteryCapacity: 30,
batteryRuntime: 3,
outputLoad: 15,
outputPower: 70,
outputVoltage: 230,
outputCurrent: 0.4,
raw: {},
},
5,
);
assertEquals(secondCandidate, firstCandidate);
});
// -----------------------------------------------------------------------------
// UPS Monitoring Tests
// -----------------------------------------------------------------------------
Deno.test('buildSuccessfulUpsPollSnapshot: marks recovery from unreachable', () => {
const currentStatus = {
...createInitialUpsStatus({ id: 'ups-1', name: 'Main UPS' }, 1000),
powerStatus: 'unreachable' as const,
unreachableSince: 2000,
consecutiveFailures: 3,
};
const snapshot = buildSuccessfulUpsPollSnapshot(
{ id: 'ups-1', name: 'Main UPS' },
{
powerStatus: 'online',
batteryCapacity: 95,
batteryRuntime: 40,
outputLoad: 10,
outputPower: 50,
outputVoltage: 230,
outputCurrent: 0.5,
raw: {},
},
currentStatus,
8000,
);
assertEquals(snapshot.transition, 'recovered');
assertEquals(snapshot.downtimeSeconds, 6);
assertEquals(snapshot.updatedStatus.powerStatus, 'online');
assertEquals(snapshot.updatedStatus.consecutiveFailures, 0);
assertEquals(snapshot.updatedStatus.lastStatusChange, 8000);
});
Deno.test('buildFailedUpsPollSnapshot: marks UPS unreachable at failure threshold', () => {
const currentStatus = {
...createInitialUpsStatus({ id: 'ups-1', name: 'Main UPS' }, 1000),
powerStatus: 'onBattery' as const,
consecutiveFailures: 2,
};
const snapshot = buildFailedUpsPollSnapshot(
{ id: 'ups-1', name: 'Main UPS' },
currentStatus,
9000,
);
assertEquals(snapshot.transition, 'unreachable');
assertEquals(snapshot.failures, 3);
assertEquals(snapshot.updatedStatus.powerStatus, 'unreachable');
assertEquals(snapshot.updatedStatus.unreachableSince, 9000);
assertEquals(snapshot.updatedStatus.lastStatusChange, 9000);
});
Deno.test('hasThresholdViolation: only fires on battery when any action threshold is exceeded', () => {
assertEquals(
hasThresholdViolation('online', 40, 10, [
{ type: 'shutdown', thresholds: { battery: 50, runtime: 20 } },
]),
false,
);
assertEquals(
hasThresholdViolation('onBattery', 40, 10, [
{ type: 'shutdown', thresholds: { battery: 50, runtime: 20 } },
]),
true,
);
assertEquals(
hasThresholdViolation('onBattery', 90, 60, [
{ type: 'shutdown', thresholds: { battery: 50, runtime: 20 } },
]),
false,
);
});
// -----------------------------------------------------------------------------
// UpsOidSets Tests
// -----------------------------------------------------------------------------
+1 -1
View File
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/nupst',
version: '5.3.3',
version: '5.6.0',
description: 'Network UPS Shutdown Tool - Monitor SNMP-enabled UPS devices and orchestrate graceful system shutdowns during power emergencies'
}
+86
View File
@@ -0,0 +1,86 @@
import type { IActionConfig, IActionContext, TPowerStatus } from './actions/base-action.ts';
import type { IUpsStatus } from './ups-status.ts';
export interface IUpsActionSource {
id: string;
name: string;
actions?: IActionConfig[];
}
export type TUpsTriggerReason = IActionContext['triggerReason'];
export type TActionExecutionDecision =
| { type: 'suppressed'; message: string }
| { type: 'legacyShutdown'; reason: string }
| { type: 'skip' }
| { type: 'execute'; actions: IActionConfig[]; context: IActionContext };
export function buildUpsActionContext(
ups: IUpsActionSource,
status: IUpsStatus,
previousStatus: IUpsStatus | undefined,
triggerReason: TUpsTriggerReason,
timestamp: number = Date.now(),
): IActionContext {
return {
upsId: ups.id,
upsName: ups.name,
powerStatus: status.powerStatus as TPowerStatus,
batteryCapacity: status.batteryCapacity,
batteryRuntime: status.batteryRuntime,
previousPowerStatus: (previousStatus?.powerStatus || 'unknown') as TPowerStatus,
timestamp,
triggerReason,
};
}
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,
status: IUpsStatus,
previousStatus: IUpsStatus | undefined,
triggerReason: TUpsTriggerReason,
timestamp: number = Date.now(),
): TActionExecutionDecision {
if (isPaused) {
return {
type: 'suppressed',
message: `[PAUSED] Actions suppressed for UPS ${ups.name} (trigger: ${triggerReason})`,
};
}
const actions = ups.actions || [];
if (actions.length === 0 && triggerReason === 'thresholdViolation') {
return {
type: 'legacyShutdown',
reason: `UPS "${ups.name}" battery or runtime below threshold`,
};
}
if (actions.length === 0) {
return { type: 'skip' };
}
return {
type: 'execute',
actions,
context: buildUpsActionContext(ups, status, previousStatus, triggerReason, timestamp),
};
}
+3 -1
View File
@@ -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;
@@ -116,6 +116,8 @@ export interface IActionConfig {
proxmoxForceStop?: boolean;
/** Skip TLS verification for self-signed certificates (default: true) */
proxmoxInsecure?: boolean;
/** Proxmox operation mode: 'auto' detects CLI tools, 'cli' forces CLI, 'api' forces REST API (default: 'auto') */
proxmoxMode?: 'auto' | 'api' | 'cli';
}
/**
+298 -70
View File
@@ -1,14 +1,22 @@
import * as fs from 'node:fs';
import * as os from 'node:os';
import process from 'node:process';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { Action, type IActionContext } from './base-action.ts';
import { logger } from '../logger.ts';
import { PROXMOX, UI } from '../constants.ts';
const execFileAsync = promisify(execFile);
/**
* ProxmoxAction - Gracefully shuts down Proxmox VMs and LXC containers
*
* Uses the Proxmox REST API via HTTPS with API token authentication.
* Shuts down running QEMU VMs and LXC containers, waits for completion,
* and optionally force-stops any that don't respond.
* Supports two operation modes:
* - CLI mode: Uses qm/pct commands directly (requires running as root on a Proxmox host)
* - API mode: Uses the Proxmox REST API via HTTPS with API token authentication
*
* In 'auto' mode (default), CLI is preferred when available, falling back to API.
*
* This action should be placed BEFORE shutdown actions in the action chain
* so that VMs are stopped before the host is shut down.
@@ -16,6 +24,77 @@ import { PROXMOX, UI } from '../constants.ts';
export class ProxmoxAction extends Action {
readonly type = 'proxmox';
/**
* Check if Proxmox CLI tools (qm, pct) are available on the system
* Used by CLI wizards and by execute() for auto-detection
*/
static detectCliAvailability(): {
available: boolean;
qmPath: string | null;
pctPath: 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 isRoot = !!(process.getuid && process.getuid() === 0);
return {
available: qmPath !== null && pctPath !== null && isRoot,
qmPath,
pctPath,
isRoot,
};
}
/**
* Resolve the operation mode based on config and environment
*/
private resolveMode(): { mode: 'api' | 'cli'; qmPath: string; pctPath: string } | { mode: 'api'; qmPath?: undefined; pctPath?: undefined } {
const configuredMode = this.config.proxmoxMode || 'auto';
if (configuredMode === 'api') {
return { mode: 'api' };
}
const detection = ProxmoxAction.detectCliAvailability();
if (configuredMode === 'cli') {
if (!detection.qmPath || !detection.pctPath) {
throw new Error('CLI mode requested but qm/pct not found. Are you on a Proxmox host?');
}
if (!detection.isRoot) {
throw new Error('CLI mode requires root access');
}
return { mode: 'cli', qmPath: detection.qmPath, pctPath: detection.pctPath };
}
// Auto-detect
if (detection.available && detection.qmPath && detection.pctPath) {
return { mode: 'cli', qmPath: detection.qmPath, pctPath: detection.pctPath };
}
return { mode: 'api' };
}
/**
* Execute the Proxmox shutdown action
*/
@@ -29,30 +108,21 @@ export class ProxmoxAction extends Action {
return;
}
const host = this.config.proxmoxHost || PROXMOX.DEFAULT_HOST;
const port = this.config.proxmoxPort || PROXMOX.DEFAULT_PORT;
const resolved = this.resolveMode();
const node = this.config.proxmoxNode || os.hostname();
const tokenId = this.config.proxmoxTokenId;
const tokenSecret = this.config.proxmoxTokenSecret;
const excludeIds = new Set(this.config.proxmoxExcludeIds || []);
const stopTimeout = (this.config.proxmoxStopTimeout || PROXMOX.DEFAULT_STOP_TIMEOUT_SECONDS) * 1000;
const forceStop = this.config.proxmoxForceStop !== false; // default true
const insecure = this.config.proxmoxInsecure !== false; // default true
if (!tokenId || !tokenSecret) {
logger.error('Proxmox API token ID and secret are required');
return;
}
const baseUrl = `https://${host}:${port}${PROXMOX.API_BASE}`;
const headers: Record<string, string> = {
'Authorization': `PVEAPIToken=${tokenId}=${tokenSecret}`,
};
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(`API: ${host}:${port}`);
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})`);
logger.logBoxLine(`Trigger: ${context.triggerReason}`);
if (excludeIds.size > 0) {
@@ -62,9 +132,34 @@ export class ProxmoxAction extends Action {
logger.log('');
try {
// Collect running VMs and CTs
const runningVMs = await this.getRunningVMs(baseUrl, node, headers, insecure);
const runningCTs = await this.getRunningCTs(baseUrl, node, headers, insecure);
let runningVMs: Array<{ vmid: number; name: string }>;
let runningCTs: Array<{ vmid: number; name: string }>;
if (resolved.mode === 'cli') {
runningVMs = await this.getRunningVMsCli(resolved.qmPath);
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;
if (!tokenId || !tokenSecret) {
logger.error('Proxmox API token ID and secret are required for API mode');
logger.error('Either provide tokens or run on a Proxmox host as root for CLI mode');
return;
}
const baseUrl = `https://${host}:${port}${PROXMOX.API_BASE}`;
const headers: Record<string, string> = {
'Authorization': `PVEAPIToken=${tokenId}=${tokenSecret}`,
};
runningVMs = await this.getRunningVMsApi(baseUrl, node, headers, insecure);
runningCTs = await this.getRunningCTsApi(baseUrl, node, headers, insecure);
}
// Filter out excluded IDs
const vmsToStop = runningVMs.filter((vm) => !excludeIds.has(vm.vmid));
@@ -78,15 +173,33 @@ export class ProxmoxAction extends Action {
logger.info(`Shutting down ${vmsToStop.length} VMs and ${ctsToStop.length} containers...`);
// Send shutdown commands to all VMs and CTs
for (const vm of vmsToStop) {
await this.shutdownVM(baseUrl, node, vm.vmid, headers, insecure);
logger.dim(` Shutdown sent to VM ${vm.vmid} (${vm.name || 'unnamed'})`);
}
// Send shutdown commands
if (resolved.mode === 'cli') {
for (const vm of vmsToStop) {
await this.shutdownVMCli(resolved.qmPath, vm.vmid);
logger.dim(` Shutdown sent to VM ${vm.vmid} (${vm.name || 'unnamed'})`);
}
for (const ct of ctsToStop) {
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}`,
};
for (const ct of ctsToStop) {
await this.shutdownCT(baseUrl, node, ct.vmid, headers, insecure);
logger.dim(` Shutdown sent to CT ${ct.vmid} (${ct.name || 'unnamed'})`);
for (const vm of vmsToStop) {
await this.shutdownVMApi(baseUrl, node, vm.vmid, headers, 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);
logger.dim(` Shutdown sent to CT ${ct.vmid} (${ct.name || 'unnamed'})`);
}
}
// Poll until all stopped or timeout
@@ -95,23 +208,31 @@ export class ProxmoxAction extends Action {
...ctsToStop.map((ct) => ({ type: 'lxc' as const, vmid: ct.vmid, name: ct.name })),
];
const remaining = await this.waitForShutdown(
baseUrl,
node,
allIds,
headers,
insecure,
stopTimeout,
);
const remaining = await this.waitForShutdown(allIds, resolved, node, stopTimeout);
if (remaining.length > 0 && forceStop) {
logger.warn(`${remaining.length} VMs/CTs didn't shut down gracefully, force-stopping...`);
for (const item of remaining) {
try {
if (item.type === 'qemu') {
await this.stopVM(baseUrl, node, item.vmid, headers, insecure);
if (resolved.mode === 'cli') {
if (item.type === 'qemu') {
await this.stopVMCli(resolved.qmPath, item.vmid);
} else {
await this.stopCTCli(resolved.pctPath, item.vmid);
}
} else {
await this.stopCT(baseUrl, node, item.vmid, headers, insecure);
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}`,
};
if (item.type === 'qemu') {
await this.stopVMApi(baseUrl, node, item.vmid, headers, insecure);
} else {
await this.stopCTApi(baseUrl, node, item.vmid, headers, insecure);
}
}
logger.dim(` Force-stopped ${item.type} ${item.vmid} (${item.name || 'unnamed'})`);
} catch (error) {
@@ -134,6 +255,110 @@ export class ProxmoxAction extends Action {
}
}
// ─── CLI-based methods ─────────────────────────────────────────────
/**
* Get list of running QEMU VMs via qm list
*/
private async getRunningVMsCli(
qmPath: string,
): Promise<Array<{ vmid: number; name: string }>> {
try {
const { stdout } = await execFileAsync(qmPath, ['list']);
return this.parseQmList(stdout);
} catch (error) {
logger.error(
`Failed to list VMs via CLI: ${error instanceof Error ? error.message : String(error)}`,
);
return [];
}
}
/**
* Get list of running LXC containers via pct list
*/
private async getRunningCTsCli(
pctPath: string,
): Promise<Array<{ vmid: number; name: string }>> {
try {
const { stdout } = await execFileAsync(pctPath, ['list']);
return this.parsePctList(stdout);
} catch (error) {
logger.error(
`Failed to list CTs via CLI: ${error instanceof Error ? error.message : String(error)}`,
);
return [];
}
}
/**
* Parse qm list output
* Format: VMID NAME STATUS MEM(MB) BOOTDISK(GB) PID
*/
private parseQmList(output: string): Array<{ vmid: number; name: string }> {
const results: Array<{ vmid: number; name: string }> = [];
const lines = output.trim().split('\n');
// Skip header line
for (let i = 1; i < lines.length; i++) {
const match = lines[i].match(/^\s*(\d+)\s+(\S+)\s+(running|stopped|paused)/);
if (match && match[3] === 'running') {
results.push({ vmid: parseInt(match[1], 10), name: match[2] });
}
}
return results;
}
/**
* Parse pct list output
* Format: VMID Status Lock Name
*/
private parsePctList(output: string): Array<{ vmid: number; name: string }> {
const results: Array<{ vmid: number; name: string }> = [];
const lines = output.trim().split('\n');
// Skip header line
for (let i = 1; i < lines.length; i++) {
const match = lines[i].match(/^\s*(\d+)\s+(running|stopped)\s+\S*\s*(.*)/);
if (match && match[2] === 'running') {
results.push({ vmid: parseInt(match[1], 10), name: match[3]?.trim() || '' });
}
}
return results;
}
private async shutdownVMCli(qmPath: string, vmid: number): Promise<void> {
await execFileAsync(qmPath, ['shutdown', String(vmid)]);
}
private async shutdownCTCli(pctPath: string, vmid: number): Promise<void> {
await execFileAsync(pctPath, ['shutdown', String(vmid)]);
}
private async stopVMCli(qmPath: string, vmid: number): Promise<void> {
await execFileAsync(qmPath, ['stop', String(vmid)]);
}
private async stopCTCli(pctPath: string, vmid: number): Promise<void> {
await execFileAsync(pctPath, ['stop', String(vmid)]);
}
/**
* Get VM/CT status via CLI
* Returns the status string (e.g., 'running', 'stopped')
*/
private async getStatusCli(
toolPath: string,
vmid: number,
): Promise<string> {
const { stdout } = await execFileAsync(toolPath, ['status', String(vmid)]);
// Output format: "status: running\n"
const status = stdout.trim().split(':')[1]?.trim() || 'unknown';
return status;
}
// ─── API-based methods ─────────────────────────────────────────────
/**
* Make an API request to the Proxmox server
*/
@@ -173,9 +398,9 @@ export class ProxmoxAction extends Action {
}
/**
* Get list of running QEMU VMs
* Get list of running QEMU VMs via API
*/
private async getRunningVMs(
private async getRunningVMsApi(
baseUrl: string,
node: string,
headers: Record<string, string>,
@@ -201,9 +426,9 @@ export class ProxmoxAction extends Action {
}
/**
* Get list of running LXC containers
* Get list of running LXC containers via API
*/
private async getRunningCTs(
private async getRunningCTsApi(
baseUrl: string,
node: string,
headers: Record<string, string>,
@@ -228,10 +453,7 @@ export class ProxmoxAction extends Action {
}
}
/**
* Send graceful shutdown to a QEMU VM
*/
private async shutdownVM(
private async shutdownVMApi(
baseUrl: string,
node: string,
vmid: number,
@@ -246,10 +468,7 @@ export class ProxmoxAction extends Action {
);
}
/**
* Send graceful shutdown to an LXC container
*/
private async shutdownCT(
private async shutdownCTApi(
baseUrl: string,
node: string,
vmid: number,
@@ -264,10 +483,7 @@ export class ProxmoxAction extends Action {
);
}
/**
* Force-stop a QEMU VM
*/
private async stopVM(
private async stopVMApi(
baseUrl: string,
node: string,
vmid: number,
@@ -282,10 +498,7 @@ export class ProxmoxAction extends Action {
);
}
/**
* Force-stop an LXC container
*/
private async stopCT(
private async stopCTApi(
baseUrl: string,
node: string,
vmid: number,
@@ -300,15 +513,15 @@ export class ProxmoxAction extends Action {
);
}
// ─── Shared methods ────────────────────────────────────────────────
/**
* Wait for VMs/CTs to shut down, return any that are still running after timeout
*/
private async waitForShutdown(
baseUrl: string,
node: string,
items: Array<{ type: 'qemu' | 'lxc'; vmid: number; name: string }>,
headers: Record<string, string>,
insecure: boolean,
resolved: { mode: 'api' | 'cli'; qmPath?: string; pctPath?: string },
node: string,
timeout: number,
): Promise<Array<{ type: 'qemu' | 'lxc'; vmid: number; name: string }>> {
const startTime = Date.now();
@@ -323,12 +536,27 @@ export class ProxmoxAction extends Action {
for (const item of remaining) {
try {
const statusUrl = `${baseUrl}/nodes/${node}/${item.type}/${item.vmid}/status/current`;
const response = await this.apiRequest(statusUrl, 'GET', headers, insecure) as {
data: { status: string };
};
let status: string;
if (response.data?.status === 'running') {
if (resolved.mode === 'cli') {
const toolPath = item.type === 'qemu' ? resolved.qmPath! : resolved.pctPath!;
status = await this.getStatusCli(toolPath, 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}`,
};
const statusUrl = `${baseUrl}/nodes/${node}/${item.type}/${item.vmid}/status/current`;
const response = await this.apiRequest(statusUrl, 'GET', headers, insecure) as {
data: { status: string };
};
status = response.data?.status || 'unknown';
}
if (status === 'running') {
stillRunning.push(item);
} else {
logger.dim(` ${item.type} ${item.vmid} (${item.name}) stopped`);
+1 -1
View File
@@ -124,7 +124,7 @@ export class ShutdownAction extends Action {
return;
}
const shutdownDelay = this.config.shutdownDelay || SHUTDOWN.DEFAULT_DELAY_MINUTES;
const shutdownDelay = this.config.shutdownDelay ?? SHUTDOWN.DEFAULT_DELAY_MINUTES;
logger.log('');
logger.logBoxTitle('Initiating System Shutdown', UI.WIDE_BOX_WIDTH, 'error');
+4 -4
View File
@@ -19,7 +19,7 @@ export class NupstCli {
/**
* Parse command line arguments and execute the appropriate command
* @param args Command line arguments (process.argv)
* @param args Command line arguments excluding runtime and script path
*/
public async parseAndExecute(args: string[]): Promise<void> {
// Extract debug and version flags from any position
@@ -38,8 +38,8 @@ export class NupstCli {
}
// Get the command (default to help if none provided)
const command = debugOptions.cleanedArgs[2] || 'help';
const commandArgs = debugOptions.cleanedArgs.slice(3);
const command = debugOptions.cleanedArgs[0] || 'help';
const commandArgs = debugOptions.cleanedArgs.slice(1);
// Route to the appropriate command handler
await this.executeCommand(command, commandArgs, debugOptions.debugMode);
@@ -98,7 +98,7 @@ export class NupstCli {
await serviceHandler.start();
break;
case 'status':
await serviceHandler.status();
await serviceHandler.status(debugMode);
break;
case 'logs':
await serviceHandler.logs();
+163 -30
View File
@@ -3,6 +3,8 @@ import { Nupst } from '../nupst.ts';
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';
@@ -65,11 +67,150 @@ export class ActionHandler {
logger.info(`Add Action to ${targetType} ${theme.highlight(targetName)}`);
logger.log('');
// Action type (currently only shutdown is supported)
const type = 'shutdown';
logger.log(` ${theme.dim('Action type:')} ${theme.highlight('shutdown')}`);
// 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)`);
// Battery threshold
const typeInput = await prompt(` ${theme.dim('Select action type')} ${theme.dim('[1]:')} `);
const typeValue = parseInt(typeInput, 10) || 1;
const newAction: Partial<IActionConfig> = {};
if (typeValue === 1) {
// Shutdown action
newAction.type = 'shutdown';
const defaultShutdownDelay =
this.nupst.getDaemon().getConfig().defaultShutdownDelay ?? SHUTDOWN.DEFAULT_DELAY_MINUTES;
const delayStr = await prompt(
` ${theme.dim('Shutdown delay')} ${theme.dim(`(minutes, leave empty for default ${defaultShutdownDelay}):`)} `,
);
if (delayStr.trim()) {
const shutdownDelay = parseInt(delayStr, 10);
if (isNaN(shutdownDelay) || shutdownDelay < 0) {
logger.error('Invalid shutdown delay. Must be >= 0.');
process.exit(1);
}
newAction.shutdownDelay = shutdownDelay;
}
} else if (typeValue === 2) {
// Webhook action
newAction.type = 'webhook';
const url = await prompt(` ${theme.dim('Webhook URL:')} `);
if (!url.trim()) {
logger.error('Webhook URL is required.');
process.exit(1);
}
newAction.webhookUrl = url.trim();
logger.log('');
logger.log(` ${theme.dim('HTTP Method:')}`);
logger.log(` ${theme.dim('1)')} POST (JSON body)`);
logger.log(` ${theme.dim('2)')} GET (query parameters)`);
const methodInput = await prompt(` ${theme.dim('Select method')} ${theme.dim('[1]:')} `);
newAction.webhookMethod = methodInput === '2' ? 'GET' : 'POST';
const timeoutInput = await prompt(` ${theme.dim('Timeout in seconds')} ${theme.dim('[10]:')} `);
const timeout = parseInt(timeoutInput, 10);
if (timeoutInput.trim() && !isNaN(timeout)) {
newAction.webhookTimeout = timeout * 1000;
}
} else if (typeValue === 3) {
// Script action
newAction.type = 'script';
const scriptPath = await prompt(` ${theme.dim('Script filename (in /etc/nupst/, must end with .sh):')} `);
if (!scriptPath.trim() || !scriptPath.trim().endsWith('.sh')) {
logger.error('Script path must end with .sh.');
process.exit(1);
}
newAction.scriptPath = scriptPath.trim();
const timeoutInput = await prompt(` ${theme.dim('Script timeout in seconds')} ${theme.dim('[60]:')} `);
const timeout = parseInt(timeoutInput, 10);
if (timeoutInput.trim() && !isNaN(timeout)) {
newAction.scriptTimeout = timeout * 1000;
}
} else if (typeValue === 4) {
// Proxmox action
newAction.type = 'proxmox';
// Auto-detect CLI availability
const detection = ProxmoxAction.detectCliAvailability();
if (detection.available) {
logger.log('');
logger.success('Proxmox CLI tools detected (qm/pct). No API token needed.');
logger.dim(` qm: ${detection.qmPath}`);
logger.dim(` pct: ${detection.pctPath}`);
newAction.proxmoxMode = 'cli';
} else {
logger.log('');
if (!detection.isRoot) {
logger.warn('Not running as root - CLI mode unavailable, using API mode.');
} else {
logger.warn('Proxmox CLI tools (qm/pct) not found - using API mode.');
}
logger.log('');
logger.info('Proxmox API Settings:');
logger.dim('Create a token with: pveum user token add root@pam nupst --privsep=0');
const pxHost = await prompt(` ${theme.dim('Proxmox Host')} ${theme.dim('[localhost]:')} `);
newAction.proxmoxHost = pxHost.trim() || 'localhost';
const pxPortInput = await prompt(` ${theme.dim('Proxmox API Port')} ${theme.dim('[8006]:')} `);
const pxPort = parseInt(pxPortInput, 10);
newAction.proxmoxPort = pxPortInput.trim() && !isNaN(pxPort) ? pxPort : 8006;
const pxNode = await prompt(` ${theme.dim('Proxmox Node Name (empty = auto-detect):')} `);
if (pxNode.trim()) {
newAction.proxmoxNode = pxNode.trim();
}
const tokenId = await prompt(` ${theme.dim('API Token ID (e.g., root@pam!nupst):')} `);
if (!tokenId.trim()) {
logger.error('Token ID is required for API mode.');
process.exit(1);
}
newAction.proxmoxTokenId = tokenId.trim();
const tokenSecret = await prompt(` ${theme.dim('API Token Secret:')} `);
if (!tokenSecret.trim()) {
logger.error('Token Secret is required for API mode.');
process.exit(1);
}
newAction.proxmoxTokenSecret = tokenSecret.trim();
const insecureInput = await prompt(` ${theme.dim('Skip TLS verification (self-signed cert)?')} ${theme.dim('(Y/n):')} `);
newAction.proxmoxInsecure = insecureInput.toLowerCase() !== 'n';
newAction.proxmoxMode = 'api';
}
// Common Proxmox settings (both modes)
const excludeInput = await prompt(` ${theme.dim('VM/CT IDs to exclude (comma-separated, or empty):')} `);
if (excludeInput.trim()) {
newAction.proxmoxExcludeIds = excludeInput.split(',').map((s) => parseInt(s.trim(), 10)).filter((n) => !isNaN(n));
}
const timeoutInput = await prompt(` ${theme.dim('VM shutdown timeout in seconds')} ${theme.dim('[120]:')} `);
const stopTimeout = parseInt(timeoutInput, 10);
if (timeoutInput.trim() && !isNaN(stopTimeout)) {
newAction.proxmoxStopTimeout = stopTimeout;
}
const forceInput = await prompt(` ${theme.dim('Force-stop VMs that don\'t shut down in time?')} ${theme.dim('(Y/n):')} `);
newAction.proxmoxForceStop = forceInput.toLowerCase() !== 'n';
} 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('(%):')} `,
);
@@ -89,6 +230,8 @@ export class ActionHandler {
process.exit(1);
}
newAction.thresholds = { battery, runtime };
// Trigger mode
logger.log('');
logger.log(` ${theme.dim('Trigger mode:')}`);
@@ -113,33 +256,13 @@ export class ActionHandler {
'': 'onlyThresholds', // Default
};
const triggerMode = triggerModeMap[triggerChoice] || 'onlyThresholds';
// Shutdown delay
const delayStr = await prompt(
` ${theme.dim('Shutdown delay')} ${theme.dim('(seconds) [5]:')} `,
);
const shutdownDelay = delayStr ? parseInt(delayStr, 10) : 5;
if (isNaN(shutdownDelay) || shutdownDelay < 0) {
logger.error('Invalid shutdown delay. Must be >= 0.');
process.exit(1);
}
// Create the action
const newAction: IActionConfig = {
type,
thresholds: {
battery,
runtime,
},
triggerMode: triggerMode as IActionConfig['triggerMode'],
shutdownDelay,
};
newAction.triggerMode = triggerMode as IActionConfig['triggerMode'];
// Add to target (UPS or group)
if (!target!.actions) {
target!.actions = [];
}
target!.actions.push(newAction);
target!.actions.push(newAction as IActionConfig);
await this.nupst.getDaemon().saveConfig(config);
@@ -350,11 +473,21 @@ export class ActionHandler {
];
const rows = target.actions.map((action, index) => {
let details = `${action.shutdownDelay || 5}s delay`;
const defaultShutdownDelay =
this.nupst.getDaemon().getConfig().defaultShutdownDelay ?? SHUTDOWN.DEFAULT_DELAY_MINUTES;
let details = `${action.shutdownDelay ?? defaultShutdownDelay}min delay`;
if (action.type === 'proxmox') {
const host = action.proxmoxHost || 'localhost';
const port = action.proxmoxPort || 8006;
details = `${host}:${port}`;
const mode = action.proxmoxMode || 'auto';
if (mode === 'cli' || (mode === 'auto' && !action.proxmoxTokenId)) {
details = 'CLI mode';
} else {
const host = action.proxmoxHost || 'localhost';
const port = action.proxmoxPort || 8006;
details = `API ${host}:${port}`;
}
if (action.proxmoxExcludeIds?.length) {
details += `, excl: ${action.proxmoxExcludeIds.join(',')}`;
}
} else if (action.type === 'webhook') {
details = action.webhookUrl || theme.dim('N/A');
} else if (action.type === 'script') {
+4 -4
View File
@@ -124,7 +124,7 @@ export class GroupHandler {
await this.nupst.getDaemon().loadConfig();
} catch (error) {
logger.error(
'No configuration found. Please run "nupst setup" first to create a configuration.',
'No configuration found. Please run "nupst ups add" first to create a configuration.',
);
return;
}
@@ -219,7 +219,7 @@ export class GroupHandler {
await this.nupst.getDaemon().loadConfig();
} catch (error) {
logger.error(
'No configuration found. Please run "nupst setup" first to create a configuration.',
'No configuration found. Please run "nupst ups add" first to create a configuration.',
);
return;
}
@@ -316,7 +316,7 @@ export class GroupHandler {
await this.nupst.getDaemon().loadConfig();
} catch (error) {
logger.error(
'No configuration found. Please run "nupst setup" first to create a configuration.',
'No configuration found. Please run "nupst ups add" first to create a configuration.',
);
return;
}
@@ -484,7 +484,7 @@ export class GroupHandler {
prompt: (question: string) => Promise<string>,
): Promise<void> {
if (!config.upsDevices || config.upsDevices.length === 0) {
logger.log('No UPS devices available. Use "nupst add" to add UPS devices.');
logger.log('No UPS devices available. Use "nupst ups add" to add UPS devices.');
return;
}
+14 -23
View File
@@ -6,7 +6,7 @@ import { Nupst } from '../nupst.ts';
import { logger } from '../logger.ts';
import { theme } from '../colors.ts';
import { PAUSE } from '../constants.ts';
import type { IPauseState } from '../daemon.ts';
import type { IPauseState } from '../pause-state.ts';
import * as helpers from '../helpers/index.ts';
/**
@@ -30,7 +30,9 @@ export class ServiceHandler {
public async enable(): Promise<void> {
this.checkRootAccess('This command must be run as root.');
await this.nupst.getSystemd().install();
logger.log('NUPST service has been installed. Use "nupst start" to start the service.');
logger.log(
'NUPST service has been installed. Use "nupst service start" to start the service.',
);
}
/**
@@ -103,10 +105,8 @@ export class ServiceHandler {
/**
* Show status of the systemd service and UPS
*/
public async status(): Promise<void> {
// Extract debug options from args array
const debugOptions = this.extractDebugOptions(process.argv);
await this.nupst.getSystemd().getStatus(debugOptions.debugMode);
public async status(debugMode: boolean = false): Promise<void> {
await this.nupst.getSystemd().getStatus(debugMode);
}
/**
@@ -221,10 +221,14 @@ export class ServiceHandler {
const unit = match[2].toLowerCase();
switch (unit) {
case 'm': return value * 60 * 1000;
case 'h': return value * 60 * 60 * 1000;
case 'd': return value * 24 * 60 * 60 * 1000;
default: return null;
case 'm':
return value * 60 * 1000;
case 'h':
return value * 60 * 60 * 1000;
case 'd':
return value * 24 * 60 * 60 * 1000;
default:
return null;
}
}
@@ -398,17 +402,4 @@ export class ServiceHandler {
process.exit(1);
}
}
/**
* Extract and remove debug options from args array
* @param args Command line arguments
* @returns Object with debug flags and cleaned args
*/
private extractDebugOptions(args: string[]): { debugMode: boolean; cleanedArgs: string[] } {
const debugMode = args.includes('--debug') || args.includes('-d');
// Remove debug flags from args
const cleanedArgs = args.filter((arg) => arg !== '--debug' && arg !== '-d');
return { debugMode, cleanedArgs };
}
}
+120 -46
View File
@@ -9,7 +9,8 @@ import type { IUpsdConfig } from '../upsd/types.ts';
import type { TProtocol } from '../protocol/types.ts';
import type { INupstConfig, IUpsConfig } from '../daemon.ts';
import type { IActionConfig } from '../actions/base-action.ts';
import { UPSD } from '../constants.ts';
import { ProxmoxAction } from '../actions/proxmox-action.ts';
import { SHUTDOWN, UPSD } from '../constants.ts';
/**
* Thresholds configuration for CLI display
@@ -102,7 +103,15 @@ export class UpsHandler {
const protocol: TProtocol = protocolChoice === 2 ? 'upsd' : 'snmp';
// Create a new UPS configuration object with defaults
const newUps: Record<string, unknown> & { id: string; name: string; groups: string[]; actions: IActionConfig[]; protocol: TProtocol; snmp?: ISnmpConfig; upsd?: IUpsdConfig } = {
const newUps: Record<string, unknown> & {
id: string;
name: string;
groups: string[];
actions: IActionConfig[];
protocol: TProtocol;
snmp?: ISnmpConfig;
upsd?: IUpsdConfig;
} = {
id: upsId,
name: name || `UPS-${upsId}`,
protocol,
@@ -202,7 +211,7 @@ export class UpsHandler {
return;
} else {
// For specific UPS ID, error if config doesn't exist
logger.error('No configuration found. Please run "nupst setup" first.');
logger.error('No configuration found. Please run "nupst ups add" first.');
return;
}
}
@@ -241,7 +250,7 @@ export class UpsHandler {
} else {
// For backward compatibility, edit the first UPS if no ID specified
if (config.upsDevices.length === 0) {
logger.error('No UPS devices configured. Please run "nupst add" to add a UPS.');
logger.error('No UPS devices configured. Please run "nupst ups add" to add a UPS.');
return;
}
upsToEdit = config.upsDevices[0];
@@ -260,7 +269,9 @@ export class UpsHandler {
logger.info(`Current Protocol: ${currentProtocol.toUpperCase()}`);
logger.dim(' 1) SNMP (network UPS with SNMP agent)');
logger.dim(' 2) UPSD/NIS (local NUT server, e.g. USB-connected UPS)');
const protocolInput = await prompt(`Select protocol [${currentProtocol === 'upsd' ? '2' : '1'}]: `);
const protocolInput = await prompt(
`Select protocol [${currentProtocol === 'upsd' ? '2' : '1'}]: `,
);
const protocolChoice = parseInt(protocolInput, 10);
if (protocolChoice === 2) {
upsToEdit.protocol = 'upsd';
@@ -347,7 +358,7 @@ export class UpsHandler {
const errorBoxWidth = 45;
logger.logBoxTitle('Configuration Error', errorBoxWidth);
logger.logBoxLine('No configuration found.');
logger.logBoxLine("Please run 'nupst setup' first to create a configuration.");
logger.logBoxLine("Please run 'nupst ups add' first to create a configuration.");
logger.logBoxEnd();
return;
}
@@ -358,7 +369,7 @@ export class UpsHandler {
// Check if multi-UPS config
if (!config.upsDevices || !Array.isArray(config.upsDevices)) {
logger.error('Legacy single-UPS configuration detected. Cannot delete UPS.');
logger.log('Use "nupst add" to migrate to multi-UPS configuration format first.');
logger.log('Use "nupst ups add" to migrate to multi-UPS configuration format first.');
return;
}
@@ -526,7 +537,7 @@ export class UpsHandler {
const errorBoxWidth = 45;
logger.logBoxTitle('Configuration Error', errorBoxWidth);
logger.logBoxLine('No configuration found.');
logger.logBoxLine("Please run 'nupst setup' first to create a configuration.");
logger.logBoxLine("Please run 'nupst ups add' first to create a configuration.");
logger.logBoxEnd();
return;
}
@@ -623,7 +634,9 @@ export class UpsHandler {
logger.logBoxLine(
` Battery Capacity: ${snmpConfig.customOIDs.BATTERY_CAPACITY || 'Not set'}`,
);
logger.logBoxLine(` Battery Runtime: ${snmpConfig.customOIDs.BATTERY_RUNTIME || 'Not set'}`);
logger.logBoxLine(
` Battery Runtime: ${snmpConfig.customOIDs.BATTERY_RUNTIME || 'Not set'}`,
);
}
}
@@ -649,7 +662,9 @@ export class UpsHandler {
const upsId = isUpsConfig ? (config as IUpsConfig).id : 'default';
const upsName = isUpsConfig ? (config as IUpsConfig).name : 'Default UPS';
const protocol = isUpsConfig ? ((config as IUpsConfig).protocol || 'snmp') : 'snmp';
logger.log(`\nTesting connection to UPS: ${upsName} (${upsId}) via ${protocol.toUpperCase()}...`);
logger.log(
`\nTesting connection to UPS: ${upsName} (${upsId}) via ${protocol.toUpperCase()}...`,
);
try {
let status: ISnmpUpsStatus;
@@ -690,7 +705,9 @@ export class UpsHandler {
logger.logBoxTitle(`Connection Failed: ${upsName}`, errorBoxWidth);
logger.logBoxLine(`Error: ${error instanceof Error ? error.message : String(error)}`);
logger.logBoxEnd();
logger.log("\nPlease check your settings and run 'nupst edit' to reconfigure this UPS.");
logger.log(
`\nPlease check your settings and run 'nupst ups edit ${upsId}' to reconfigure this UPS.`,
);
}
}
@@ -974,6 +991,35 @@ export class UpsHandler {
OUTPUT_CURRENT: '',
};
}
// Runtime unit selection
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(' 2) Seconds (Eaton, HPE, many RFC 1628 devices)');
logger.dim(' 3) Ticks (CyberPower - 1/100 second increments)');
const defaultUnitValue = snmpConfig.runtimeUnit === 'seconds'
? 2
: snmpConfig.runtimeUnit === 'ticks'
? 3
: snmpConfig.upsModel === 'cyberpower'
? 3
: snmpConfig.upsModel === 'eaton'
? 2
: 1;
const unitInput = await prompt(`Select runtime unit [${defaultUnitValue}]: `);
const unitValue = parseInt(unitInput, 10) || defaultUnitValue;
if (unitValue === 2) {
snmpConfig.runtimeUnit = 'seconds';
} else if (unitValue === 3) {
snmpConfig.runtimeUnit = 'ticks';
} else {
snmpConfig.runtimeUnit = 'minutes';
}
}
/**
@@ -1106,11 +1152,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
@@ -1155,40 +1209,62 @@ export class UpsHandler {
// Proxmox action
action.type = 'proxmox';
logger.log('');
logger.info('Proxmox API Settings:');
logger.dim('Requires a Proxmox API token. Create one with:');
logger.dim(' pveum user token add root@pam nupst --privsep=0');
// Auto-detect CLI availability
const detection = ProxmoxAction.detectCliAvailability();
const pxHost = await prompt('Proxmox Host [localhost]: ');
action.proxmoxHost = pxHost.trim() || 'localhost';
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}`);
action.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 pxPortInput = await prompt('Proxmox API Port [8006]: ');
const pxPort = parseInt(pxPortInput, 10);
action.proxmoxPort = pxPortInput.trim() && !isNaN(pxPort) ? pxPort : 8006;
const pxHost = await prompt('Proxmox Host [localhost]: ');
action.proxmoxHost = pxHost.trim() || 'localhost';
const pxNode = await prompt('Proxmox Node Name (empty = auto-detect via hostname): ');
if (pxNode.trim()) {
action.proxmoxNode = pxNode.trim();
const pxPortInput = await prompt('Proxmox API Port [8006]: ');
const pxPort = parseInt(pxPortInput, 10);
action.proxmoxPort = pxPortInput.trim() && !isNaN(pxPort) ? pxPort : 8006;
const pxNode = await prompt('Proxmox Node Name (empty = auto-detect via hostname): ');
if (pxNode.trim()) {
action.proxmoxNode = pxNode.trim();
}
const tokenId = await prompt('API Token ID (e.g., root@pam!nupst): ');
if (!tokenId.trim()) {
logger.warn('Token ID is required for API mode, skipping');
continue;
}
action.proxmoxTokenId = tokenId.trim();
const tokenSecret = await prompt('API Token Secret: ');
if (!tokenSecret.trim()) {
logger.warn('Token Secret is required for API mode, skipping');
continue;
}
action.proxmoxTokenSecret = tokenSecret.trim();
const insecureInput = await prompt('Skip TLS verification (self-signed cert)? (Y/n): ');
action.proxmoxInsecure = insecureInput.toLowerCase() !== 'n';
action.proxmoxMode = 'api';
}
const tokenId = await prompt('API Token ID (e.g., root@pam!nupst): ');
if (!tokenId.trim()) {
logger.warn('Token ID is required for Proxmox action, skipping');
continue;
}
action.proxmoxTokenId = tokenId.trim();
const tokenSecret = await prompt('API Token Secret: ');
if (!tokenSecret.trim()) {
logger.warn('Token Secret is required for Proxmox action, skipping');
continue;
}
action.proxmoxTokenSecret = tokenSecret.trim();
// Common Proxmox settings (both modes)
const excludeInput = await prompt('VM/CT IDs to exclude (comma-separated, or empty): ');
if (excludeInput.trim()) {
action.proxmoxExcludeIds = excludeInput.split(',').map((s) => parseInt(s.trim(), 10)).filter((n) => !isNaN(n));
action.proxmoxExcludeIds = excludeInput.split(',').map((s) => parseInt(s.trim(), 10))
.filter((n) => !isNaN(n));
}
const timeoutInput = await prompt('VM shutdown timeout in seconds [120]: ');
@@ -1197,12 +1273,9 @@ export class UpsHandler {
action.proxmoxStopTimeout = stopTimeout;
}
const forceInput = await prompt('Force-stop VMs that don\'t shut down in time? (Y/n): ');
const forceInput = await prompt("Force-stop VMs that don't shut down in time? (Y/n): ");
action.proxmoxForceStop = forceInput.toLowerCase() !== 'n';
const insecureInput = await prompt('Skip TLS verification (self-signed cert)? (Y/n): ');
action.proxmoxInsecure = insecureInput.toLowerCase() !== 'n';
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.');
@@ -1296,6 +1369,7 @@ export class UpsHandler {
logger.logBoxLine(`SNMP Host: ${ups.snmp.host}:${ups.snmp.port}`);
logger.logBoxLine(`SNMP Version: ${ups.snmp.version}`);
logger.logBoxLine(`UPS Model: ${ups.snmp.upsModel}`);
logger.logBoxLine(`Runtime Unit: ${ups.snmp.runtimeUnit || 'auto'}`);
}
if (ups.groups && ups.groups.length > 0) {
+58
View File
@@ -0,0 +1,58 @@
export interface IWatchEventLike {
kind: string;
paths: string[];
}
export type TConfigReloadTransition = 'monitoringWillStart' | 'deviceCountChanged' | 'reloaded';
export interface IConfigReloadSnapshot {
transition: TConfigReloadTransition;
message: string;
shouldInitializeUpsStatus: boolean;
shouldLogMonitoringStart: boolean;
}
export function shouldReloadConfig(
event: IWatchEventLike,
configFileName: string = 'config.json',
): boolean {
return event.kind === 'modify' && event.paths.some((path) => path.includes(configFileName));
}
export function shouldRefreshPauseState(
event: IWatchEventLike,
pauseFileName: string = 'pause',
): boolean {
return ['create', 'modify', 'remove'].includes(event.kind) &&
event.paths.some((path) => path.includes(pauseFileName));
}
export function analyzeConfigReload(
oldDeviceCount: number,
newDeviceCount: number,
): IConfigReloadSnapshot {
if (newDeviceCount > 0 && oldDeviceCount === 0) {
return {
transition: 'monitoringWillStart',
message: `Configuration reloaded! Found ${newDeviceCount} UPS device(s)`,
shouldInitializeUpsStatus: false,
shouldLogMonitoringStart: true,
};
}
if (newDeviceCount !== oldDeviceCount) {
return {
transition: 'deviceCountChanged',
message: `Configuration reloaded! UPS devices: ${oldDeviceCount} -> ${newDeviceCount}`,
shouldInitializeUpsStatus: true,
shouldLogMonitoringStart: false,
};
}
return {
transition: 'reloaded',
message: 'Configuration reloaded successfully',
shouldInitializeUpsStatus: false,
shouldLogMonitoringStart: false,
};
}
+3
View File
@@ -157,6 +157,9 @@ export const PROXMOX = {
/** Proxmox API base path */
API_BASE: '/api2/json',
/** Common paths to search for Proxmox CLI tools (qm, pct) */
CLI_TOOL_PATHS: ['/usr/sbin', '/usr/bin', '/sbin', '/bin'] as readonly string[],
} as const;
/**
+213 -480
View File
@@ -1,8 +1,6 @@
import process from 'node:process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { exec, execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { NupstSnmp } from './snmp/manager.ts';
import type { ISnmpConfig, IUpsStatus as ISnmpUpsStatus } from './snmp/types.ts';
import { NupstUpsd } from './upsd/client.ts';
@@ -13,12 +11,33 @@ import { logger } from './logger.ts';
import { MigrationRunner } from './migrations/index.ts';
import { formatPowerStatus, getBatteryColor, getRuntimeColor, theme } from './colors.ts';
import type { IActionConfig } from './actions/base-action.ts';
import { ActionManager, type IActionContext, type TPowerStatus } from './actions/index.ts';
import { ActionManager } from './actions/index.ts';
import {
applyDefaultShutdownDelay,
decideUpsActionExecution,
type TUpsTriggerReason,
} from './action-orchestration.ts';
import { NupstHttpServer } from './http-server.ts';
import { NETWORK, PAUSE, THRESHOLDS, TIMING, UI } from './constants.ts';
const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);
import { NETWORK, PAUSE, SHUTDOWN, THRESHOLDS, TIMING, UI } from './constants.ts';
import {
analyzeConfigReload,
shouldRefreshPauseState,
shouldReloadConfig,
} from './config-watch.ts';
import { type IPauseState, loadPauseSnapshot } from './pause-state.ts';
import { ShutdownExecutor } from './shutdown-executor.ts';
import {
buildFailedUpsPollSnapshot,
buildSuccessfulUpsPollSnapshot,
ensureUpsStatus,
hasThresholdViolation,
} from './ups-monitoring.ts';
import {
buildShutdownErrorRow,
buildShutdownStatusRow,
selectEmergencyCandidate,
} from './shutdown-monitoring.ts';
import { createInitialUpsStatus, type IUpsStatus } from './ups-status.ts';
/**
* UPS configuration interface
@@ -70,20 +89,6 @@ export interface IHttpServerConfig {
authToken: string;
}
/**
* Pause state interface
*/
export interface IPauseState {
/** Timestamp when pause was activated */
pausedAt: number;
/** Who initiated the pause (e.g., 'cli', 'api') */
pausedBy: string;
/** Optional reason for pausing */
reason?: string;
/** When to auto-resume (null = indefinite, timestamp in ms) */
resumeAt?: number | null;
}
/**
* Configuration interface for the daemon
*/
@@ -96,6 +101,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;
@@ -113,25 +120,6 @@ export interface INupstConfig {
};
}
/**
* UPS status tracking interface
*/
export interface IUpsStatus {
id: string;
name: string;
powerStatus: 'online' | 'onBattery' | 'unknown' | 'unreachable';
batteryCapacity: number;
batteryRuntime: number;
outputLoad: number; // Load percentage (0-100%)
outputPower: number; // Power in watts
outputVoltage: number; // Voltage in volts
outputCurrent: number; // Current in amps
lastStatusChange: number;
lastCheckTime: number;
consecutiveFailures: number;
unreachableSince: number;
}
/**
* Daemon class for monitoring UPS and handling shutdown
* Responsible for loading/saving config and monitoring the UPS status
@@ -142,7 +130,8 @@ export class NupstDaemon {
/** Default configuration */
private readonly DEFAULT_CONFIG: INupstConfig = {
version: '4.2',
version: '4.3',
defaultShutdownDelay: SHUTDOWN.DEFAULT_DELAY_MINUTES,
upsDevices: [
{
id: 'default',
@@ -162,6 +151,7 @@ export class NupstDaemon {
privKey: '',
// UPS model for OID selection
upsModel: 'cyberpower',
runtimeUnit: 'ticks',
},
groups: [],
actions: [
@@ -172,7 +162,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,
},
],
},
@@ -190,6 +179,7 @@ export class NupstDaemon {
private pauseState: IPauseState | null = null;
private upsStatus: Map<string, IUpsStatus> = new Map();
private httpServer?: NupstHttpServer;
private readonly shutdownExecutor: ShutdownExecutor;
/**
* Create a new daemon instance with the given protocol managers
@@ -198,6 +188,7 @@ export class NupstDaemon {
this.snmp = snmp;
this.upsd = upsd;
this.protocolResolver = new ProtocolResolver(snmp, upsd);
this.shutdownExecutor = new ShutdownExecutor();
this.config = this.DEFAULT_CONFIG;
}
@@ -223,10 +214,13 @@ 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 {
@@ -260,10 +254,11 @@ export class NupstDaemon {
// Ensure version is always set and remove legacy fields before saving
const configToSave: INupstConfig = {
version: '4.2',
version: '4.3',
upsDevices: config.upsDevices,
groups: config.groups,
checkInterval: config.checkInterval,
defaultShutdownDelay: this.normalizeShutdownDelay(config.defaultShutdownDelay),
...(config.httpServer ? { httpServer: config.httpServer } : {}),
};
@@ -282,7 +277,7 @@ export class NupstDaemon {
private logConfigError(message: string): void {
logger.logBox(
'Configuration Error',
[message, "Please run 'nupst setup' first to create a configuration."],
[message, "Please run 'nupst ups add' first to create a configuration."],
45,
'error',
);
@@ -295,6 +290,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
*/
@@ -387,21 +398,7 @@ export class NupstDaemon {
if (this.config.upsDevices && this.config.upsDevices.length > 0) {
for (const ups of this.config.upsDevices) {
this.upsStatus.set(ups.id, {
id: ups.id,
name: ups.name,
powerStatus: 'unknown',
batteryCapacity: 100,
batteryRuntime: 999, // High value as default
outputLoad: 0,
outputPower: 0,
outputVoltage: 0,
outputCurrent: 0,
lastStatusChange: Date.now(),
lastCheckTime: 0,
consecutiveFailures: 0,
unreachableSince: 0,
});
this.upsStatus.set(ups.id, createInitialUpsStatus(ups));
}
logger.log(`Initialized status tracking for ${this.config.upsDevices.length} UPS devices`);
@@ -506,66 +503,39 @@ export class NupstDaemon {
* Check and update pause state from the pause file
*/
private checkPauseState(): void {
try {
if (fs.existsSync(PAUSE.FILE_PATH)) {
const data = fs.readFileSync(PAUSE.FILE_PATH, 'utf8');
const state = JSON.parse(data) as IPauseState;
const snapshot = loadPauseSnapshot(PAUSE.FILE_PATH, this.isPaused);
// Check if auto-resume time has passed
if (state.resumeAt && Date.now() >= state.resumeAt) {
// Auto-resume: delete the pause file
try {
fs.unlinkSync(PAUSE.FILE_PATH);
} catch (_e) {
// Ignore deletion errors
}
if (this.isPaused) {
logger.log('');
logger.logBoxTitle('Auto-Resume', 45, 'success');
logger.logBoxLine('Pause duration expired, resuming action monitoring');
logger.logBoxEnd();
logger.log('');
}
this.isPaused = false;
this.pauseState = null;
return;
}
if (!this.isPaused) {
logger.log('');
logger.logBoxTitle('Actions Paused', 45, 'warning');
logger.logBoxLine(`Paused by: ${state.pausedBy}`);
if (state.reason) {
logger.logBoxLine(`Reason: ${state.reason}`);
}
if (state.resumeAt) {
const remaining = Math.round((state.resumeAt - Date.now()) / 1000);
logger.logBoxLine(`Auto-resume in: ${remaining} seconds`);
} else {
logger.logBoxLine('Duration: Indefinite (run "nupst resume" to resume)');
}
logger.logBoxEnd();
logger.log('');
}
this.isPaused = true;
this.pauseState = state;
} else {
if (this.isPaused) {
logger.log('');
logger.logBoxTitle('Actions Resumed', 45, 'success');
logger.logBoxLine('Action monitoring has been resumed');
logger.logBoxEnd();
logger.log('');
}
this.isPaused = false;
this.pauseState = null;
if (snapshot.transition === 'autoResumed') {
logger.log('');
logger.logBoxTitle('Auto-Resume', 45, 'success');
logger.logBoxLine('Pause duration expired, resuming action monitoring');
logger.logBoxEnd();
logger.log('');
} else if (snapshot.transition === 'paused' && snapshot.pauseState) {
logger.log('');
logger.logBoxTitle('Actions Paused', 45, 'warning');
logger.logBoxLine(`Paused by: ${snapshot.pauseState.pausedBy}`);
if (snapshot.pauseState.reason) {
logger.logBoxLine(`Reason: ${snapshot.pauseState.reason}`);
}
} catch (_error) {
// If we can't read the pause file, assume not paused
this.isPaused = false;
this.pauseState = null;
if (snapshot.pauseState.resumeAt) {
const remaining = Math.round((snapshot.pauseState.resumeAt - Date.now()) / 1000);
logger.logBoxLine(`Auto-resume in: ${remaining} seconds`);
} else {
logger.logBoxLine('Duration: Indefinite (run "nupst resume" to resume)');
}
logger.logBoxEnd();
logger.log('');
} else if (snapshot.transition === 'resumed') {
logger.log('');
logger.logBoxTitle('Actions Resumed', 45, 'success');
logger.logBoxLine('Action monitoring has been resumed');
logger.logBoxEnd();
logger.log('');
}
this.isPaused = snapshot.isPaused;
this.pauseState = snapshot.pauseState;
}
/**
@@ -618,25 +588,8 @@ export class NupstDaemon {
private async checkAllUpsDevices(): Promise<void> {
for (const ups of this.config.upsDevices) {
try {
const upsStatus = this.upsStatus.get(ups.id);
if (!upsStatus) {
// Initialize status for this UPS if not exists
this.upsStatus.set(ups.id, {
id: ups.id,
name: ups.name,
powerStatus: 'unknown',
batteryCapacity: 100,
batteryRuntime: 999,
outputLoad: 0,
outputPower: 0,
outputVoltage: 0,
outputCurrent: 0,
lastStatusChange: Date.now(),
lastCheckTime: 0,
consecutiveFailures: 0,
unreachableSince: 0,
});
}
const initialStatus = ensureUpsStatus(this.upsStatus.get(ups.id), ups);
this.upsStatus.set(ups.id, initialStatus);
// Check UPS status via configured protocol
const protocol = ups.protocol || 'snmp';
@@ -645,129 +598,100 @@ export class NupstDaemon {
: await this.protocolResolver.getUpsStatus('snmp', ups.snmp);
const currentTime = Date.now();
// Get the current status from the map
const currentStatus = this.upsStatus.get(ups.id);
const pollSnapshot = buildSuccessfulUpsPollSnapshot(
ups,
status,
currentStatus,
currentTime,
);
// Successful query: reset consecutive failures
const wasUnreachable = currentStatus?.powerStatus === 'unreachable';
// Update status with new values
const updatedStatus: IUpsStatus = {
id: ups.id,
name: ups.name,
powerStatus: status.powerStatus,
batteryCapacity: status.batteryCapacity,
batteryRuntime: status.batteryRuntime,
outputLoad: status.outputLoad,
outputPower: status.outputPower,
outputVoltage: status.outputVoltage,
outputCurrent: status.outputCurrent,
lastCheckTime: currentTime,
lastStatusChange: currentStatus?.lastStatusChange || currentTime,
consecutiveFailures: 0,
unreachableSince: 0,
};
// If UPS was unreachable and is now reachable, log recovery
if (wasUnreachable && currentStatus) {
const downtime = Math.round((currentTime - currentStatus.unreachableSince) / 1000);
if (pollSnapshot.transition === 'recovered' && pollSnapshot.previousStatus) {
logger.log('');
logger.logBoxTitle(`UPS Recovered: ${ups.name}`, 60, 'success');
logger.logBoxLine(`UPS is reachable again after ${downtime} seconds`);
logger.logBoxLine(`UPS is reachable again after ${pollSnapshot.downtimeSeconds} seconds`);
logger.logBoxLine(`Current Status: ${formatPowerStatus(status.powerStatus)}`);
logger.logBoxLine(`Time: ${new Date().toISOString()}`);
logger.logBoxEnd();
logger.log('');
updatedStatus.lastStatusChange = currentTime;
// Trigger power status change action for recovery
await this.triggerUpsActions(ups, updatedStatus, currentStatus, 'powerStatusChange');
} else if (currentStatus && currentStatus.powerStatus !== status.powerStatus) {
// Check if power status changed
await this.triggerUpsActions(
ups,
pollSnapshot.updatedStatus,
pollSnapshot.previousStatus,
'powerStatusChange',
);
} else if (pollSnapshot.transition === 'powerStatusChange' && pollSnapshot.previousStatus) {
logger.log('');
logger.logBoxTitle(`Power Status Change: ${ups.name}`, 60, 'warning');
logger.logBoxLine(`Previous: ${formatPowerStatus(currentStatus.powerStatus)}`);
logger.logBoxLine(
`Previous: ${formatPowerStatus(pollSnapshot.previousStatus.powerStatus)}`,
);
logger.logBoxLine(`Current: ${formatPowerStatus(status.powerStatus)}`);
logger.logBoxLine(`Time: ${new Date().toISOString()}`);
logger.logBoxEnd();
logger.log('');
updatedStatus.lastStatusChange = currentTime;
// Trigger actions for power status change
await this.triggerUpsActions(ups, updatedStatus, currentStatus, 'powerStatusChange');
await this.triggerUpsActions(
ups,
pollSnapshot.updatedStatus,
pollSnapshot.previousStatus,
'powerStatusChange',
);
}
// Check if any action's thresholds are exceeded (for threshold violation triggers)
// Only check when on battery power
if (status.powerStatus === 'onBattery' && ups.actions && ups.actions.length > 0) {
let anyThresholdExceeded = false;
for (const actionConfig of ups.actions) {
if (actionConfig.thresholds) {
if (
status.batteryCapacity < actionConfig.thresholds.battery ||
status.batteryRuntime < actionConfig.thresholds.runtime
) {
anyThresholdExceeded = true;
break;
}
}
}
// Trigger actions with threshold violation reason if any threshold is exceeded
// Actions will individually check their own thresholds in shouldExecute()
if (anyThresholdExceeded) {
await this.triggerUpsActions(ups, updatedStatus, currentStatus, 'thresholdViolation');
}
if (
hasThresholdViolation(
status.powerStatus,
status.batteryCapacity,
status.batteryRuntime,
ups.actions,
)
) {
await this.triggerUpsActions(
ups,
pollSnapshot.updatedStatus,
pollSnapshot.previousStatus,
'thresholdViolation',
);
}
// Update the status in the map
this.upsStatus.set(ups.id, updatedStatus);
this.upsStatus.set(ups.id, pollSnapshot.updatedStatus);
} catch (error) {
// Network loss / query failure tracking
const currentTime = Date.now();
const currentStatus = this.upsStatus.get(ups.id);
const failures = Math.min(
(currentStatus?.consecutiveFailures || 0) + 1,
NETWORK.MAX_CONSECUTIVE_FAILURES,
);
const failureSnapshot = buildFailedUpsPollSnapshot(ups, currentStatus, currentTime);
logger.error(
`Error checking UPS ${ups.name} (${ups.id}) [failure ${failures}/${NETWORK.CONSECUTIVE_FAILURE_THRESHOLD}]: ${
`Error checking UPS ${ups.name} (${ups.id}) [failure ${failureSnapshot.failures}/${NETWORK.CONSECUTIVE_FAILURE_THRESHOLD}]: ${
error instanceof Error ? error.message : String(error)
}`,
);
// Transition to unreachable after threshold consecutive failures
if (
failures >= NETWORK.CONSECUTIVE_FAILURE_THRESHOLD &&
currentStatus &&
currentStatus.powerStatus !== 'unreachable'
) {
const currentTime = Date.now();
const previousStatus = { ...currentStatus };
currentStatus.powerStatus = 'unreachable';
currentStatus.consecutiveFailures = failures;
currentStatus.unreachableSince = currentTime;
currentStatus.lastStatusChange = currentTime;
this.upsStatus.set(ups.id, currentStatus);
if (failureSnapshot.transition === 'unreachable' && failureSnapshot.previousStatus) {
logger.log('');
logger.logBoxTitle(`UPS Unreachable: ${ups.name}`, 60, 'error');
logger.logBoxLine(`${failures} consecutive communication failures`);
logger.logBoxLine(`Last known status: ${formatPowerStatus(previousStatus.powerStatus)}`);
logger.logBoxLine(`${failureSnapshot.failures} consecutive communication failures`);
logger.logBoxLine(
`Last known status: ${formatPowerStatus(failureSnapshot.previousStatus.powerStatus)}`,
);
logger.logBoxLine(`Time: ${new Date().toISOString()}`);
logger.logBoxEnd();
logger.log('');
// Trigger power status change action for unreachable
await this.triggerUpsActions(ups, currentStatus, previousStatus, 'powerStatusChange');
} else if (currentStatus) {
currentStatus.consecutiveFailures = failures;
this.upsStatus.set(ups.id, currentStatus);
await this.triggerUpsActions(
ups,
failureSnapshot.updatedStatus,
failureSnapshot.previousStatus,
'powerStatusChange',
);
}
this.upsStatus.set(ups.id, failureSnapshot.updatedStatus);
}
}
}
@@ -780,7 +704,11 @@ export class NupstDaemon {
logger.log('');
const pauseLabel = this.isPaused ? ' [PAUSED]' : '';
logger.logBoxTitle(`Periodic Status Update${pauseLabel}`, 70, this.isPaused ? 'warning' : 'info');
logger.logBoxTitle(
`Periodic Status Update${pauseLabel}`,
70,
this.isPaused ? 'warning' : 'info',
);
logger.logBoxLine(`Timestamp: ${timestamp}`);
if (this.isPaused && this.pauseState) {
logger.logBoxLine(`Actions paused by: ${this.pauseState.pausedBy}`);
@@ -821,30 +749,6 @@ export class NupstDaemon {
logger.log('');
}
/**
* Build action context from UPS state
* @param ups UPS configuration
* @param status Current UPS status
* @param triggerReason Why this action is being triggered
* @returns Action context
*/
private buildActionContext(
ups: IUpsConfig,
status: IUpsStatus,
triggerReason: 'powerStatusChange' | 'thresholdViolation',
): IActionContext {
return {
upsId: ups.id,
upsName: ups.name,
powerStatus: status.powerStatus as TPowerStatus,
batteryCapacity: status.batteryCapacity,
batteryRuntime: status.batteryRuntime,
previousPowerStatus: 'unknown' as TPowerStatus, // Will be set from map in calling code
timestamp: Date.now(),
triggerReason,
};
}
/**
* Trigger actions for a UPS device
* @param ups UPS configuration
@@ -856,35 +760,36 @@ export class NupstDaemon {
ups: IUpsConfig,
status: IUpsStatus,
previousStatus: IUpsStatus | undefined,
triggerReason: 'powerStatusChange' | 'thresholdViolation',
triggerReason: TUpsTriggerReason,
): Promise<void> {
// Check if actions are paused
if (this.isPaused) {
logger.info(
`[PAUSED] Actions suppressed for UPS ${ups.name} (trigger: ${triggerReason})`,
);
const decision = decideUpsActionExecution(
this.isPaused,
ups,
status,
previousStatus,
triggerReason,
);
if (decision.type === 'suppressed') {
logger.info(decision.message);
return;
}
const actions = ups.actions || [];
// Backward compatibility: if no actions configured, use default shutdown behavior
if (actions.length === 0 && triggerReason === 'thresholdViolation') {
// Fall back to old shutdown logic for backward compatibility
await this.initiateShutdown(`UPS "${ups.name}" battery or runtime below threshold`);
if (decision.type === 'legacyShutdown') {
await this.initiateShutdown(decision.reason);
return;
}
if (actions.length === 0) {
return; // No actions to execute
if (decision.type === 'skip') {
return;
}
// Build action context
const context = this.buildActionContext(ups, status, triggerReason);
context.previousPowerStatus = (previousStatus?.powerStatus || 'unknown') as TPowerStatus;
const actions = applyDefaultShutdownDelay(
decision.actions,
this.getDefaultShutdownDelayMinutes(),
);
// Execute actions
await ActionManager.executeActions(actions, context);
await ActionManager.executeActions(actions, decision.context);
}
/**
@@ -894,60 +799,11 @@ 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 {
// Find shutdown command in common system paths
const shutdownPaths = [
'/sbin/shutdown',
'/usr/sbin/shutdown',
'/bin/shutdown',
'/usr/bin/shutdown',
];
let shutdownCmd = '';
for (const path of shutdownPaths) {
try {
if (fs.existsSync(path)) {
shutdownCmd = path;
logger.log(`Found shutdown command at: ${shutdownCmd}`);
break;
}
} catch (e) {
// Continue checking other paths
}
}
if (shutdownCmd) {
// Execute shutdown command with delay to allow for VM graceful shutdown
logger.log(
`Executing: ${shutdownCmd} -h +${shutdownDelayMinutes} "UPS battery critical..."`,
);
const { stdout } = await execFileAsync(shutdownCmd, [
'-h',
`+${shutdownDelayMinutes}`,
`UPS battery critical, shutting down in ${shutdownDelayMinutes} minutes`,
]);
logger.log(`Shutdown initiated: ${stdout}`);
logger.log(`Allowing ${shutdownDelayMinutes} minutes for VMs to shut down safely`);
} else {
// Try using the PATH to find shutdown
try {
logger.log('Shutdown command not found in common paths, trying via PATH...');
const { stdout } = await execAsync(
`shutdown -h +${shutdownDelayMinutes} "UPS battery critical, shutting down in ${shutdownDelayMinutes} minutes"`,
{
env: process.env, // Pass the current environment
},
);
logger.log(`Shutdown initiated: ${stdout}`);
} catch (e) {
throw new Error(
`Shutdown command not found: ${e instanceof Error ? e.message : String(e)}`,
);
}
}
await this.shutdownExecutor.scheduleShutdown(shutdownDelayMinutes);
logger.log(`Allowing ${shutdownDelayMinutes} minutes for VMs to shut down safely`);
// Monitor UPS during shutdown and force immediate shutdown if battery gets too low
logger.log('Monitoring UPS during shutdown process...');
@@ -955,51 +811,10 @@ export class NupstDaemon {
} catch (error) {
logger.error(`Failed to initiate shutdown: ${error}`);
// Try alternative shutdown methods
const alternatives = [
{ cmd: 'poweroff', args: ['--force'] },
{ cmd: 'halt', args: ['-p'] },
{ cmd: 'systemctl', args: ['poweroff'] },
{ cmd: 'reboot', args: ['-p'] }, // Some systems allow reboot -p for power off
];
for (const alt of alternatives) {
try {
// First check if command exists in common system paths
const paths = [
`/sbin/${alt.cmd}`,
`/usr/sbin/${alt.cmd}`,
`/bin/${alt.cmd}`,
`/usr/bin/${alt.cmd}`,
];
let cmdPath = '';
for (const path of paths) {
if (fs.existsSync(path)) {
cmdPath = path;
break;
}
}
if (cmdPath) {
logger.log(`Trying alternative shutdown method: ${cmdPath} ${alt.args.join(' ')}`);
await execFileAsync(cmdPath, alt.args);
return; // Exit if successful
} else {
// Try using PATH environment
logger.log(`Trying alternative via PATH: ${alt.cmd} ${alt.args.join(' ')}`);
await execAsync(`${alt.cmd} ${alt.args.join(' ')}`, {
env: process.env, // Pass the current environment
});
return; // Exit if successful
}
} catch (altError) {
logger.error(`Alternative method ${alt.cmd} failed: ${altError}`);
// Continue to next method
}
const shutdownTriggered = await this.shutdownExecutor.tryScheduledAlternatives();
if (!shutdownTriggered) {
logger.error('All shutdown methods failed');
}
logger.error('All shutdown methods failed');
}
}
@@ -1036,7 +851,6 @@ export class NupstDaemon {
];
const rows: Array<Record<string, string>> = [];
let emergencyDetected = false;
let emergencyUps: { ups: IUpsConfig; status: ISnmpUpsStatus } | null = null;
// Check all UPS devices
@@ -1046,31 +860,30 @@ export class NupstDaemon {
const status = protocol === 'upsd' && ups.upsd
? await this.protocolResolver.getUpsStatus('upsd', undefined, ups.upsd)
: await this.protocolResolver.getUpsStatus('snmp', ups.snmp);
const rowSnapshot = buildShutdownStatusRow(
ups.name,
status,
THRESHOLDS.EMERGENCY_RUNTIME_MINUTES,
{
battery: (batteryCapacity) =>
getBatteryColor(batteryCapacity)(`${batteryCapacity}%`),
runtime: (batteryRuntime) =>
getRuntimeColor(batteryRuntime)(`${batteryRuntime} min`),
ok: theme.success,
critical: theme.error,
error: theme.error,
},
);
const batteryColor = getBatteryColor(status.batteryCapacity);
const runtimeColor = getRuntimeColor(status.batteryRuntime);
const isCritical = status.batteryRuntime < THRESHOLDS.EMERGENCY_RUNTIME_MINUTES;
rows.push({
name: ups.name,
battery: batteryColor(status.batteryCapacity + '%'),
runtime: runtimeColor(status.batteryRuntime + ' min'),
status: isCritical ? theme.error('CRITICAL!') : theme.success('OK'),
});
// If any UPS battery runtime gets critically low, flag for immediate shutdown
if (isCritical && !emergencyDetected) {
emergencyDetected = true;
emergencyUps = { ups, status };
}
rows.push(rowSnapshot.row);
emergencyUps = selectEmergencyCandidate(
emergencyUps,
ups,
status,
THRESHOLDS.EMERGENCY_RUNTIME_MINUTES,
);
} catch (upsError) {
rows.push({
name: ups.name,
battery: theme.error('N/A'),
runtime: theme.error('N/A'),
status: theme.error('ERROR'),
});
rows.push(buildShutdownErrorRow(ups.name, theme.error));
logger.error(
`Error checking UPS ${ups.name} during shutdown: ${
@@ -1085,7 +898,7 @@ export class NupstDaemon {
logger.log('');
// If emergency detected, trigger immediate shutdown
if (emergencyDetected && emergencyUps) {
if (emergencyUps) {
logger.log('');
logger.logBoxTitle('EMERGENCY SHUTDOWN', 60, 'error');
logger.logBoxLine(
@@ -1123,86 +936,14 @@ export class NupstDaemon {
*/
private async forceImmediateShutdown(): Promise<void> {
try {
// Find shutdown command in common system paths
const shutdownPaths = [
'/sbin/shutdown',
'/usr/sbin/shutdown',
'/bin/shutdown',
'/usr/bin/shutdown',
];
let shutdownCmd = '';
for (const path of shutdownPaths) {
if (fs.existsSync(path)) {
shutdownCmd = path;
logger.log(`Found shutdown command at: ${shutdownCmd}`);
break;
}
}
if (shutdownCmd) {
logger.log(`Executing emergency shutdown: ${shutdownCmd} -h now`);
await execFileAsync(shutdownCmd, [
'-h',
'now',
'EMERGENCY: UPS battery critically low, shutting down NOW',
]);
} else {
// Try using the PATH to find shutdown
logger.log('Shutdown command not found in common paths, trying via PATH...');
await execAsync(
'shutdown -h now "EMERGENCY: UPS battery critically low, shutting down NOW"',
{
env: process.env, // Pass the current environment
},
);
}
await this.shutdownExecutor.forceImmediateShutdown();
} catch (error) {
logger.error('Emergency shutdown failed, trying alternative methods...');
// Try alternative shutdown methods in sequence
const alternatives = [
{ cmd: 'poweroff', args: ['--force'] },
{ cmd: 'halt', args: ['-p'] },
{ cmd: 'systemctl', args: ['poweroff'] },
];
for (const alt of alternatives) {
try {
// Check common paths
const paths = [
`/sbin/${alt.cmd}`,
`/usr/sbin/${alt.cmd}`,
`/bin/${alt.cmd}`,
`/usr/bin/${alt.cmd}`,
];
let cmdPath = '';
for (const path of paths) {
if (fs.existsSync(path)) {
cmdPath = path;
break;
}
}
if (cmdPath) {
logger.log(`Emergency: using ${cmdPath} ${alt.args.join(' ')}`);
await execFileAsync(cmdPath, alt.args);
return; // Exit if successful
} else {
// Try using PATH
logger.log(`Emergency: trying ${alt.cmd} via PATH`);
await execAsync(`${alt.cmd} ${alt.args.join(' ')}`, {
env: process.env,
});
return; // Exit if successful
}
} catch (altError) {
// Continue to next method
}
const shutdownTriggered = await this.shutdownExecutor.tryEmergencyAlternatives();
if (!shutdownTriggered) {
logger.error('All emergency shutdown methods failed');
}
logger.error('All emergency shutdown methods failed');
}
}
@@ -1275,19 +1016,13 @@ export class NupstDaemon {
for await (const event of watcher) {
// Respond to modify events on config file
if (
event.kind === 'modify' &&
event.paths.some((p) => p.includes('config.json'))
) {
if (shouldReloadConfig(event)) {
logger.info('Config file changed, reloading...');
await this.reloadConfig();
}
// Detect pause file changes
if (
(event.kind === 'create' || event.kind === 'modify' || event.kind === 'remove') &&
event.paths.some((p) => p.includes('pause'))
) {
if (shouldRefreshPauseState(event)) {
this.checkPauseState();
}
@@ -1321,18 +1056,16 @@ export class NupstDaemon {
await this.loadConfig();
const newDeviceCount = this.config.upsDevices?.length || 0;
if (newDeviceCount > 0 && oldDeviceCount === 0) {
logger.success(`Configuration reloaded! Found ${newDeviceCount} UPS device(s)`);
logger.info('Monitoring will start automatically...');
} else if (newDeviceCount !== oldDeviceCount) {
logger.success(
`Configuration reloaded! UPS devices: ${oldDeviceCount}${newDeviceCount}`,
);
const reloadSnapshot = analyzeConfigReload(oldDeviceCount, newDeviceCount);
logger.success(reloadSnapshot.message);
if (reloadSnapshot.shouldLogMonitoringStart) {
logger.info('Monitoring will start automatically...');
}
if (reloadSnapshot.shouldInitializeUpsStatus) {
// Reinitialize UPS status tracking
this.initializeUpsStatus();
} else {
logger.success('Configuration reloaded successfully');
}
} catch (error) {
logger.warn(
+2 -1
View File
@@ -1,7 +1,8 @@
import * as http from 'node:http';
import { URL } from 'node:url';
import { logger } from './logger.ts';
import type { IPauseState, IUpsStatus } from './daemon.ts';
import type { IPauseState } from './pause-state.ts';
import type { IUpsStatus } from './ups-status.ts';
/**
* HTTP Server for exposing UPS status as JSON
+1 -1
View File
@@ -10,7 +10,7 @@ import process from 'node:process';
*/
async function main() {
const cli = new NupstCli();
await cli.parseAndExecute(process.argv);
await cli.parseAndExecute(process.argv.slice(2));
}
// Run the main function and handle any errors
+1
View File
@@ -10,3 +10,4 @@ export { MigrationV1ToV2 } from './migration-v1-to-v2.ts';
export { MigrationV3ToV4 } from './migration-v3-to-v4.ts';
export { MigrationV4_0ToV4_1 } from './migration-v4.0-to-v4.1.ts';
export { MigrationV4_1ToV4_2 } from './migration-v4.1-to-v4.2.ts';
export { MigrationV4_2ToV4_3 } from './migration-v4.2-to-v4.3.ts';
+3 -1
View File
@@ -3,6 +3,7 @@ import { MigrationV1ToV2 } from './migration-v1-to-v2.ts';
import { MigrationV3ToV4 } from './migration-v3-to-v4.ts';
import { MigrationV4_0ToV4_1 } from './migration-v4.0-to-v4.1.ts';
import { MigrationV4_1ToV4_2 } from './migration-v4.1-to-v4.2.ts';
import { MigrationV4_2ToV4_3 } from './migration-v4.2-to-v4.3.ts';
import { logger } from '../logger.ts';
/**
@@ -21,6 +22,7 @@ export class MigrationRunner {
new MigrationV3ToV4(),
new MigrationV4_0ToV4_1(),
new MigrationV4_1ToV4_2(),
new MigrationV4_2ToV4_3(),
];
// Sort by version order to ensure they run in sequence
@@ -56,7 +58,7 @@ export class MigrationRunner {
if (anyMigrationsRan) {
logger.success('Configuration migrations complete');
} else {
logger.success('config format ok');
logger.success('Configuration format OK');
}
return {
+1 -3
View File
@@ -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(
+50
View File
@@ -0,0 +1,50 @@
import { BaseMigration } from './base-migration.ts';
import { logger } from '../logger.ts';
/**
* Migration from v4.2 to v4.3
*
* Changes:
* 1. Adds `runtimeUnit` to SNMP configs based on existing `upsModel`
* 2. Bumps version from '4.2' to '4.3'
*/
export class MigrationV4_2ToV4_3 extends BaseMigration {
readonly fromVersion = '4.2';
readonly toVersion = '4.3';
shouldRun(config: Record<string, unknown>): boolean {
return config.version === '4.2';
}
migrate(config: Record<string, unknown>): Record<string, unknown> {
logger.info(`${this.getName()}: Adding runtimeUnit to SNMP configs...`);
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.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';
}
logger.dim(`${device.name}: Set runtimeUnit to '${snmp.runtimeUnit}'`);
}
return device;
});
const result = {
...config,
version: this.toVersion,
upsDevices: migratedDevices,
};
logger.success(
`${this.getName()}: Migration complete (${migratedDevices.length} devices updated)`,
);
return result;
}
}
+68
View File
@@ -0,0 +1,68 @@
import * as fs from 'node:fs';
/**
* Pause state interface
*/
export interface IPauseState {
/** Timestamp when pause was activated */
pausedAt: number;
/** Who initiated the pause (e.g., 'cli', 'api') */
pausedBy: string;
/** Optional reason for pausing */
reason?: string;
/** When to auto-resume (null = indefinite, timestamp in ms) */
resumeAt?: number | null;
}
export type TPauseTransition = 'unchanged' | 'paused' | 'resumed' | 'autoResumed';
export interface IPauseSnapshot {
isPaused: boolean;
pauseState: IPauseState | null;
transition: TPauseTransition;
}
export function loadPauseSnapshot(
filePath: string,
wasPaused: boolean,
now: number = Date.now(),
): IPauseSnapshot {
try {
if (!fs.existsSync(filePath)) {
return {
isPaused: false,
pauseState: null,
transition: wasPaused ? 'resumed' : 'unchanged',
};
}
const data = fs.readFileSync(filePath, 'utf8');
const pauseState = JSON.parse(data) as IPauseState;
if (pauseState.resumeAt && now >= pauseState.resumeAt) {
try {
fs.unlinkSync(filePath);
} catch (_error) {
// Ignore deletion errors and still treat the pause as expired.
}
return {
isPaused: false,
pauseState: null,
transition: wasPaused ? 'autoResumed' : 'unchanged',
};
}
return {
isPaused: true,
pauseState,
transition: wasPaused ? 'unchanged' : 'paused',
};
} catch (_error) {
return {
isPaused: false,
pauseState: null,
transition: 'unchanged',
};
}
}
+145
View File
@@ -0,0 +1,145 @@
import process from 'node:process';
import * as fs from 'node:fs';
import { exec, execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { logger } from './logger.ts';
const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);
interface IShutdownAlternative {
cmd: string;
args: string[];
}
interface IAlternativeLogConfig {
resolvedMessage: (commandPath: string, args: string[]) => string;
pathMessage: (command: string, args: string[]) => string;
failureMessage?: (command: string, error: unknown) => string;
}
export class ShutdownExecutor {
private readonly commonCommandDirs = ['/sbin', '/usr/sbin', '/bin', '/usr/bin'];
public async scheduleShutdown(delayMinutes: number): Promise<void> {
const shutdownMessage = `UPS battery critical, shutting down in ${delayMinutes} minutes`;
const shutdownCommandPath = this.findCommandPath('shutdown');
if (shutdownCommandPath) {
logger.log(`Found shutdown command at: ${shutdownCommandPath}`);
logger.log(`Executing: ${shutdownCommandPath} -h +${delayMinutes} "UPS battery critical..."`);
const { stdout } = await execFileAsync(shutdownCommandPath, [
'-h',
`+${delayMinutes}`,
shutdownMessage,
]);
logger.log(`Shutdown initiated: ${stdout}`);
return;
}
try {
logger.log('Shutdown command not found in common paths, trying via PATH...');
const { stdout } = await execAsync(
`shutdown -h +${delayMinutes} "${shutdownMessage}"`,
{ env: process.env },
);
logger.log(`Shutdown initiated: ${stdout}`);
} catch (error) {
throw new Error(
`Shutdown command not found: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
public async forceImmediateShutdown(): Promise<void> {
const shutdownMessage = 'EMERGENCY: UPS battery critically low, shutting down NOW';
const shutdownCommandPath = this.findCommandPath('shutdown');
if (shutdownCommandPath) {
logger.log(`Found shutdown command at: ${shutdownCommandPath}`);
logger.log(`Executing emergency shutdown: ${shutdownCommandPath} -h now`);
await execFileAsync(shutdownCommandPath, ['-h', 'now', shutdownMessage]);
return;
}
logger.log('Shutdown command not found in common paths, trying via PATH...');
await execAsync(`shutdown -h now "${shutdownMessage}"`, {
env: process.env,
});
}
public async tryScheduledAlternatives(): Promise<boolean> {
return await this.tryAlternatives(
[
{ cmd: 'poweroff', args: ['--force'] },
{ cmd: 'halt', args: ['-p'] },
{ cmd: 'systemctl', args: ['poweroff'] },
{ cmd: 'reboot', args: ['-p'] },
],
{
resolvedMessage: (commandPath, args) =>
`Trying alternative shutdown method: ${commandPath} ${args.join(' ')}`,
pathMessage: (command, args) => `Trying alternative via PATH: ${command} ${args.join(' ')}`,
failureMessage: (command, error) => `Alternative method ${command} failed: ${error}`,
},
);
}
public async tryEmergencyAlternatives(): Promise<boolean> {
return await this.tryAlternatives(
[
{ cmd: 'poweroff', args: ['--force'] },
{ cmd: 'halt', args: ['-p'] },
{ cmd: 'systemctl', args: ['poweroff'] },
],
{
resolvedMessage: (commandPath, args) => `Emergency: using ${commandPath} ${args.join(' ')}`,
pathMessage: (command) => `Emergency: trying ${command} via PATH`,
},
);
}
private findCommandPath(command: string): string | null {
for (const directory of this.commonCommandDirs) {
const commandPath = `${directory}/${command}`;
try {
if (fs.existsSync(commandPath)) {
return commandPath;
}
} catch (_error) {
// Continue checking other paths.
}
}
return null;
}
private async tryAlternatives(
alternatives: IShutdownAlternative[],
logConfig: IAlternativeLogConfig,
): Promise<boolean> {
for (const alternative of alternatives) {
try {
const commandPath = this.findCommandPath(alternative.cmd);
if (commandPath) {
logger.log(logConfig.resolvedMessage(commandPath, alternative.args));
await execFileAsync(commandPath, alternative.args);
return true;
}
logger.log(logConfig.pathMessage(alternative.cmd, alternative.args));
await execAsync(`${alternative.cmd} ${alternative.args.join(' ')}`, {
env: process.env,
});
return true;
} catch (error) {
if (logConfig.failureMessage) {
logger.error(logConfig.failureMessage(alternative.cmd, error));
}
}
}
return false;
}
}
+72
View File
@@ -0,0 +1,72 @@
import type { IUpsStatus as IProtocolUpsStatus } from './snmp/types.ts';
export interface IShutdownMonitoringRow extends Record<string, string> {
name: string;
battery: string;
runtime: string;
status: string;
}
export interface IShutdownRowFormatters {
battery: (batteryCapacity: number) => string;
runtime: (batteryRuntime: number) => string;
ok: (text: string) => string;
critical: (text: string) => string;
error: (text: string) => string;
}
export interface IShutdownEmergencyCandidate<TUps> {
ups: TUps;
status: IProtocolUpsStatus;
}
export function isEmergencyRuntime(
batteryRuntime: number,
emergencyRuntimeMinutes: number,
): boolean {
return batteryRuntime < emergencyRuntimeMinutes;
}
export function buildShutdownStatusRow(
upsName: string,
status: IProtocolUpsStatus,
emergencyRuntimeMinutes: number,
formatters: IShutdownRowFormatters,
): { row: IShutdownMonitoringRow; isCritical: boolean } {
const isCritical = isEmergencyRuntime(status.batteryRuntime, emergencyRuntimeMinutes);
return {
row: {
name: upsName,
battery: formatters.battery(status.batteryCapacity),
runtime: formatters.runtime(status.batteryRuntime),
status: isCritical ? formatters.critical('CRITICAL!') : formatters.ok('OK'),
},
isCritical,
};
}
export function buildShutdownErrorRow(
upsName: string,
errorFormatter: (text: string) => string,
): IShutdownMonitoringRow {
return {
name: upsName,
battery: errorFormatter('N/A'),
runtime: errorFormatter('N/A'),
status: errorFormatter('ERROR'),
};
}
export function selectEmergencyCandidate<TUps>(
currentCandidate: IShutdownEmergencyCandidate<TUps> | null,
ups: TUps,
status: IProtocolUpsStatus,
emergencyRuntimeMinutes: number,
): IShutdownEmergencyCandidate<TUps> | null {
if (currentCandidate || !isEmergencyRuntime(status.batteryRuntime, emergencyRuntimeMinutes)) {
return currentCandidate;
}
return { ups, status };
}
+277 -181
View File
@@ -1,4 +1,4 @@
import * as snmp from 'npm:net-snmp@3.26.0';
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';
@@ -6,6 +6,73 @@ import { SNMP } from '../constants.ts';
import { logger } from '../logger.ts';
import type { INupstAccessor } from '../interfaces/index.ts';
type TSnmpMetricDescription =
| 'power status'
| 'battery capacity'
| 'battery runtime'
| 'output load'
| 'output power'
| 'output voltage'
| 'output current';
type TSnmpResponseValue = string | number | bigint | boolean | Buffer;
type TSnmpValue = string | number | boolean | Buffer;
interface ISnmpVarbind {
oid: string;
type: number;
value: TSnmpResponseValue;
}
interface ISnmpSessionOptions {
port: number;
retries: number;
timeout: number;
transport: 'udp4' | 'udp6';
idBitsSize: 16 | 32;
context: string;
version: number;
}
interface ISnmpV3User {
name: string;
level: number;
authProtocol?: string;
authKey?: string;
privProtocol?: string;
privKey?: string;
}
interface ISnmpSession {
get(oids: string[], callback: (error: Error | null, varbinds?: ISnmpVarbind[]) => void): void;
close(): void;
}
interface ISnmpModule {
Version1: number;
Version2c: number;
Version3: number;
SecurityLevel: {
noAuthNoPriv: number;
authNoPriv: number;
authPriv: number;
};
AuthProtocols: {
md5: string;
sha: string;
};
PrivProtocols: {
des: string;
aes: string;
};
createSession(target: string, community: string, options: ISnmpSessionOptions): ISnmpSession;
createV3Session(target: string, user: ISnmpV3User, options: ISnmpSessionOptions): ISnmpSession;
isVarbindError(varbind: ISnmpVarbind): boolean;
varbindError(varbind: ISnmpVarbind): string;
}
const snmpLib = snmp as unknown as ISnmpModule;
/**
* Class for SNMP communication with UPS devices
* Main entry point for SNMP functionality
@@ -84,6 +151,120 @@ export class NupstSnmp {
}
}
private createSessionOptions(config: ISnmpConfig): ISnmpSessionOptions {
return {
port: config.port,
retries: SNMP.RETRIES,
timeout: config.timeout,
transport: 'udp4',
idBitsSize: 32,
context: config.context || '',
version: config.version === 1
? snmpLib.Version1
: config.version === 2
? snmpLib.Version2c
: snmpLib.Version3,
};
}
private buildV3User(
config: ISnmpConfig,
): { user: ISnmpV3User; levelLabel: NonNullable<ISnmpConfig['securityLevel']> } {
const requestedSecurityLevel = config.securityLevel || 'noAuthNoPriv';
const user: ISnmpV3User = {
name: config.username || '',
level: snmpLib.SecurityLevel.noAuthNoPriv,
};
let levelLabel: NonNullable<ISnmpConfig['securityLevel']> = 'noAuthNoPriv';
if (requestedSecurityLevel === 'authNoPriv') {
user.level = snmpLib.SecurityLevel.authNoPriv;
levelLabel = 'authNoPriv';
if (config.authProtocol && config.authKey) {
user.authProtocol = this.resolveAuthProtocol(config.authProtocol);
user.authKey = config.authKey;
} else {
user.level = snmpLib.SecurityLevel.noAuthNoPriv;
levelLabel = 'noAuthNoPriv';
if (this.debug) {
logger.warn('Missing authProtocol or authKey, falling back to noAuthNoPriv');
}
}
} else if (requestedSecurityLevel === 'authPriv') {
user.level = snmpLib.SecurityLevel.authPriv;
levelLabel = 'authPriv';
if (config.authProtocol && config.authKey) {
user.authProtocol = this.resolveAuthProtocol(config.authProtocol);
user.authKey = config.authKey;
if (config.privProtocol && config.privKey) {
user.privProtocol = this.resolvePrivProtocol(config.privProtocol);
user.privKey = config.privKey;
} else {
user.level = snmpLib.SecurityLevel.authNoPriv;
levelLabel = 'authNoPriv';
if (this.debug) {
logger.warn('Missing privProtocol or privKey, falling back to authNoPriv');
}
}
} else {
user.level = snmpLib.SecurityLevel.noAuthNoPriv;
levelLabel = 'noAuthNoPriv';
if (this.debug) {
logger.warn('Missing authProtocol or authKey, falling back to noAuthNoPriv');
}
}
}
return { user, levelLabel };
}
private resolveAuthProtocol(protocol: NonNullable<ISnmpConfig['authProtocol']>): string {
return protocol === 'MD5' ? snmpLib.AuthProtocols.md5 : snmpLib.AuthProtocols.sha;
}
private resolvePrivProtocol(protocol: NonNullable<ISnmpConfig['privProtocol']>): string {
return protocol === 'DES' ? snmpLib.PrivProtocols.des : snmpLib.PrivProtocols.aes;
}
private normalizeSnmpValue(value: TSnmpResponseValue): TSnmpValue {
if (Buffer.isBuffer(value)) {
const isPrintableAscii = value.every((byte: number) => byte >= 32 && byte <= 126);
return isPrintableAscii ? value.toString() : value;
}
if (typeof value === 'bigint') {
return Number(value);
}
return value;
}
private coerceNumericSnmpValue(
value: TSnmpValue | 0,
description: TSnmpMetricDescription,
): number {
if (typeof value === 'number') {
return Number.isFinite(value) ? value : 0;
}
if (typeof value === 'string') {
const trimmedValue = value.trim();
const parsedValue = Number(trimmedValue);
if (trimmedValue && Number.isFinite(parsedValue)) {
return parsedValue;
}
}
if (this.debug) {
logger.warn(`Non-numeric ${description} value received from SNMP, using 0`);
}
return 0;
}
/**
* Send an SNMP GET request using the net-snmp package
* @param oid OID to query
@@ -95,130 +276,39 @@ export class NupstSnmp {
oid: string,
config = this.DEFAULT_CONFIG,
_retryCount = 0,
// deno-lint-ignore no-explicit-any
): Promise<any> {
): Promise<TSnmpValue> {
return new Promise((resolve, reject) => {
if (this.debug) {
logger.dim(
`Sending SNMP v${config.version} GET request for OID ${oid} to ${config.host}:${config.port}`,
);
logger.dim(`Using community: ${config.community}`);
}
// Create SNMP options based on configuration
// deno-lint-ignore no-explicit-any
const options: any = {
port: config.port,
retries: SNMP.RETRIES, // Number of retries
timeout: config.timeout,
transport: 'udp4',
idBitsSize: 32,
context: config.context || '',
};
// Set version based on config
if (config.version === 1) {
options.version = snmp.Version1;
} else if (config.version === 2) {
options.version = snmp.Version2c;
} else {
options.version = snmp.Version3;
}
// Create appropriate session based on SNMP version
let session;
if (config.version === 3) {
// For SNMPv3, we need to set up authentication and privacy
// For SNMPv3, we need a valid security level
const securityLevel = config.securityLevel || 'noAuthNoPriv';
// Create the user object with required structure for net-snmp
// deno-lint-ignore no-explicit-any
const user: any = {
name: config.username || '',
};
// Set security level
if (securityLevel === 'noAuthNoPriv') {
user.level = snmp.SecurityLevel.noAuthNoPriv;
} else if (securityLevel === 'authNoPriv') {
user.level = snmp.SecurityLevel.authNoPriv;
// Set auth protocol - must provide both protocol and key
if (config.authProtocol && config.authKey) {
if (config.authProtocol === 'MD5') {
user.authProtocol = snmp.AuthProtocols.md5;
} else if (config.authProtocol === 'SHA') {
user.authProtocol = snmp.AuthProtocols.sha;
}
user.authKey = config.authKey;
} else {
// Fallback to noAuthNoPriv if auth details missing
user.level = snmp.SecurityLevel.noAuthNoPriv;
if (this.debug) {
logger.warn('Missing authProtocol or authKey, falling back to noAuthNoPriv');
}
}
} else if (securityLevel === 'authPriv') {
user.level = snmp.SecurityLevel.authPriv;
// Set auth protocol - must provide both protocol and key
if (config.authProtocol && config.authKey) {
if (config.authProtocol === 'MD5') {
user.authProtocol = snmp.AuthProtocols.md5;
} else if (config.authProtocol === 'SHA') {
user.authProtocol = snmp.AuthProtocols.sha;
}
user.authKey = config.authKey;
// Set privacy protocol - must provide both protocol and key
if (config.privProtocol && config.privKey) {
if (config.privProtocol === 'DES') {
user.privProtocol = snmp.PrivProtocols.des;
} else if (config.privProtocol === 'AES') {
user.privProtocol = snmp.PrivProtocols.aes;
}
user.privKey = config.privKey;
} else {
// Fallback to authNoPriv if priv details missing
user.level = snmp.SecurityLevel.authNoPriv;
if (this.debug) {
logger.warn('Missing privProtocol or privKey, falling back to authNoPriv');
}
}
} else {
// Fallback to noAuthNoPriv if auth details missing
user.level = snmp.SecurityLevel.noAuthNoPriv;
if (this.debug) {
logger.warn('Missing authProtocol or authKey, falling back to noAuthNoPriv');
}
}
if (config.version === 1 || config.version === 2) {
logger.dim(`Using community: ${config.community}`);
}
if (this.debug) {
const levelName = Object.keys(snmp.SecurityLevel).find((key) =>
snmp.SecurityLevel[key] === user.level
);
logger.dim(
`SNMPv3 user configuration: name=${user.name}, level=${levelName}, authProtocol=${
user.authProtocol ? 'Set' : 'Not Set'
}, privProtocol=${user.privProtocol ? 'Set' : 'Not Set'}`,
);
}
session = snmp.createV3Session(config.host, user, options);
} else {
// For SNMPv1/v2c, we use the community string
session = snmp.createSession(config.host, config.community || 'public', options);
}
const options = this.createSessionOptions(config);
const session: ISnmpSession = config.version === 3
? (() => {
const { user, levelLabel } = this.buildV3User(config);
if (this.debug) {
logger.dim(
`SNMPv3 user configuration: name=${user.name}, level=${levelLabel}, authProtocol=${
user.authProtocol ? 'Set' : 'Not Set'
}, privProtocol=${user.privProtocol ? 'Set' : 'Not Set'}`,
);
}
return snmpLib.createV3Session(config.host, user, options);
})()
: snmpLib.createSession(config.host, config.community || 'public', options);
// Convert the OID string to an array of OIDs if multiple OIDs are needed
const oids = [oid];
// Send the GET request
// deno-lint-ignore no-explicit-any
session.get(oids, (error: Error | null, varbinds: any[]) => {
session.get(oids, (error: Error | null, varbinds?: ISnmpVarbind[]) => {
// Close the session to release resources
session.close();
@@ -230,7 +320,9 @@ export class NupstSnmp {
return;
}
if (!varbinds || varbinds.length === 0) {
const varbind = varbinds?.[0];
if (!varbind) {
if (this.debug) {
logger.error('No varbinds returned in response');
}
@@ -239,36 +331,20 @@ export class NupstSnmp {
}
// Check for SNMP errors in the response
if (
varbinds[0].type === snmp.ObjectType.NoSuchObject ||
varbinds[0].type === snmp.ObjectType.NoSuchInstance ||
varbinds[0].type === snmp.ObjectType.EndOfMibView
) {
if (snmpLib.isVarbindError(varbind)) {
const errorMessage = snmpLib.varbindError(varbind);
if (this.debug) {
logger.error(`SNMP error: ${snmp.ObjectType[varbinds[0].type]}`);
logger.error(`SNMP error: ${errorMessage}`);
}
reject(new Error(`SNMP error: ${snmp.ObjectType[varbinds[0].type]}`));
reject(new Error(`SNMP error: ${errorMessage}`));
return;
}
// Process the response value based on its type
let value = varbinds[0].value;
// Handle specific types that might need conversion
if (Buffer.isBuffer(value)) {
// If value is a Buffer, try to convert it to a string if it's printable ASCII
const isPrintableAscii = value.every((byte: number) => byte >= 32 && byte <= 126);
if (isPrintableAscii) {
value = value.toString();
}
} else if (typeof value === 'bigint') {
// Convert BigInt to a normal number or string if needed
value = Number(value);
}
const value = this.normalizeSnmpValue(varbind.value);
if (this.debug) {
logger.dim(
`SNMP response: oid=${varbinds[0].oid}, type=${varbinds[0].type}, value=${value}`,
`SNMP response: oid=${varbind.oid}, type=${varbind.type}, value=${value}`,
);
}
@@ -315,49 +391,50 @@ export class NupstSnmp {
}
// Get all values with independent retry logic
const powerStatusValue = await this.getSNMPValueWithRetry(
this.activeOIDs.POWER_STATUS,
const powerStatusValue = this.coerceNumericSnmpValue(
await this.getSNMPValueWithRetry(this.activeOIDs.POWER_STATUS, 'power status', config),
'power status',
config,
);
const batteryCapacity = await this.getSNMPValueWithRetry(
this.activeOIDs.BATTERY_CAPACITY,
const batteryCapacity = this.coerceNumericSnmpValue(
await this.getSNMPValueWithRetry(
this.activeOIDs.BATTERY_CAPACITY,
'battery capacity',
config,
),
'battery capacity',
config,
) || 0;
const batteryRuntime = await this.getSNMPValueWithRetry(
this.activeOIDs.BATTERY_RUNTIME,
);
const batteryRuntime = this.coerceNumericSnmpValue(
await this.getSNMPValueWithRetry(
this.activeOIDs.BATTERY_RUNTIME,
'battery runtime',
config,
),
'battery runtime',
config,
) || 0;
);
// Get power draw metrics
const outputLoad = await this.getSNMPValueWithRetry(
this.activeOIDs.OUTPUT_LOAD,
const outputLoad = this.coerceNumericSnmpValue(
await this.getSNMPValueWithRetry(this.activeOIDs.OUTPUT_LOAD, 'output load', config),
'output load',
config,
) || 0;
const outputPower = await this.getSNMPValueWithRetry(
this.activeOIDs.OUTPUT_POWER,
);
const outputPower = this.coerceNumericSnmpValue(
await this.getSNMPValueWithRetry(this.activeOIDs.OUTPUT_POWER, 'output power', config),
'output power',
config,
) || 0;
const outputVoltage = await this.getSNMPValueWithRetry(
this.activeOIDs.OUTPUT_VOLTAGE,
);
const outputVoltage = this.coerceNumericSnmpValue(
await this.getSNMPValueWithRetry(this.activeOIDs.OUTPUT_VOLTAGE, 'output voltage', config),
'output voltage',
config,
) || 0;
const outputCurrent = await this.getSNMPValueWithRetry(
this.activeOIDs.OUTPUT_CURRENT,
);
const outputCurrent = this.coerceNumericSnmpValue(
await this.getSNMPValueWithRetry(this.activeOIDs.OUTPUT_CURRENT, 'output current', config),
'output current',
config,
) || 0;
);
// Determine power status - handle different values for different UPS models
const powerStatus = this.determinePowerStatus(config.upsModel, powerStatusValue);
// Convert to minutes for UPS models with different time units
const processedRuntime = this.processRuntimeValue(config.upsModel, batteryRuntime);
const processedRuntime = this.processRuntimeValue(config, batteryRuntime);
// Process power metrics with vendor-specific scaling
const processedVoltage = this.processVoltageValue(config.upsModel, outputVoltage);
@@ -430,10 +507,9 @@ export class NupstSnmp {
*/
private async getSNMPValueWithRetry(
oid: string,
description: string,
description: TSnmpMetricDescription,
config: ISnmpConfig,
// deno-lint-ignore no-explicit-any
): Promise<any> {
): Promise<TSnmpValue | 0> {
if (oid === '') {
if (this.debug) {
logger.dim(`No OID provided for ${description}, skipping`);
@@ -485,10 +561,9 @@ export class NupstSnmp {
*/
private async tryFallbackSecurityLevels(
oid: string,
description: string,
description: TSnmpMetricDescription,
config: ISnmpConfig,
// deno-lint-ignore no-explicit-any
): Promise<any> {
): Promise<TSnmpValue | 0> {
if (this.debug) {
logger.dim(`Retrying ${description} with fallback security level...`);
}
@@ -551,10 +626,9 @@ export class NupstSnmp {
*/
private async tryStandardOids(
_oid: string,
description: string,
description: TSnmpMetricDescription,
config: ISnmpConfig,
// deno-lint-ignore no-explicit-any
): Promise<any> {
): Promise<TSnmpValue | 0> {
try {
// Try RFC 1628 standard UPS MIB OIDs
const standardOIDs = UpsOidSets.getStandardOids();
@@ -620,22 +694,46 @@ export class NupstSnmp {
}
/**
* Process runtime value based on UPS model
* @param upsModel UPS model
* Process runtime value based on config runtimeUnit or UPS model
* @param config SNMP configuration (uses runtimeUnit if set, otherwise falls back to upsModel)
* @param batteryRuntime Raw battery runtime value
* @returns Processed runtime in minutes
*/
private processRuntimeValue(
upsModel: TUpsModel | undefined,
config: ISnmpConfig,
batteryRuntime: number,
): number {
if (this.debug) {
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;
}
// Fallback: model-based detection (for configs without runtimeUnit)
const upsModel = config.upsModel;
if (upsModel === 'cyberpower' && batteryRuntime > 0) {
// CyberPower: TimeTicks is in 1/100 seconds, convert to minutes
const minutes = Math.floor(batteryRuntime / 6000); // 6000 ticks = 1 minute
const minutes = Math.floor(batteryRuntime / 6000);
if (this.debug) {
logger.dim(
`Converting CyberPower runtime from ${batteryRuntime} ticks to ${minutes} minutes`,
@@ -643,7 +741,6 @@ export class NupstSnmp {
}
return minutes;
} else if (upsModel === 'eaton' && batteryRuntime > 0) {
// Eaton: Runtime is in seconds, convert to minutes
const minutes = Math.floor(batteryRuntime / 60);
if (this.debug) {
logger.dim(
@@ -652,10 +749,9 @@ export class NupstSnmp {
}
return minutes;
} else if (batteryRuntime > 10000) {
// Generic conversion for large tick values (likely TimeTicks)
const minutes = Math.floor(batteryRuntime / 6000);
if (this.debug) {
logger.dim(`Converting ${batteryRuntime} ticks to ${minutes} minutes`);
logger.dim(`Converting ${batteryRuntime} ticks to ${minutes} minutes (heuristic)`);
}
return minutes;
}
+7
View File
@@ -58,6 +58,11 @@ export interface IOidSet {
*/
export type TUpsModel = 'cyberpower' | 'apc' | 'eaton' | 'tripplite' | 'liebert' | 'custom';
/**
* Runtime unit for battery runtime SNMP values
*/
export type TRuntimeUnit = 'minutes' | 'seconds' | 'ticks';
/**
* SNMP Configuration interface
*/
@@ -96,6 +101,8 @@ export interface ISnmpConfig {
upsModel?: TUpsModel;
/** Custom OIDs when using custom UPS model */
customOIDs?: IOidSet;
/** Unit of the battery runtime SNMP value. Overrides model-based auto-detection when set. */
runtimeUnit?: TRuntimeUnit;
}
/**
+16 -9
View File
@@ -5,6 +5,7 @@ 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';
/**
* Class for managing systemd service
@@ -316,7 +317,6 @@ WantedBy=multi-user.target
type: 'shutdown',
thresholds: config.thresholds,
triggerMode: 'onlyThresholds',
shutdownDelay: 5,
},
]
: [],
@@ -346,6 +346,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 +434,16 @@ 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`;
}
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`;
}
actionDesc += ')';
}
@@ -506,20 +510,23 @@ 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`;
}
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`;
}
actionDesc += ')';
}
+138
View File
@@ -0,0 +1,138 @@
import type { IActionConfig } from './actions/base-action.ts';
import { NETWORK } from './constants.ts';
import type { IUpsStatus as IProtocolUpsStatus } from './snmp/types.ts';
import { createInitialUpsStatus, type IUpsIdentity, type IUpsStatus } from './ups-status.ts';
export interface ISuccessfulUpsPollSnapshot {
updatedStatus: IUpsStatus;
transition: 'none' | 'recovered' | 'powerStatusChange';
previousStatus?: IUpsStatus;
downtimeSeconds?: number;
}
export interface IFailedUpsPollSnapshot {
updatedStatus: IUpsStatus;
transition: 'none' | 'unreachable';
failures: number;
previousStatus?: IUpsStatus;
}
export function ensureUpsStatus(
currentStatus: IUpsStatus | undefined,
ups: IUpsIdentity,
now: number = Date.now(),
): IUpsStatus {
return currentStatus || createInitialUpsStatus(ups, now);
}
export function buildSuccessfulUpsPollSnapshot(
ups: IUpsIdentity,
polledStatus: IProtocolUpsStatus,
currentStatus: IUpsStatus | undefined,
currentTime: number,
): ISuccessfulUpsPollSnapshot {
const previousStatus = ensureUpsStatus(currentStatus, ups, currentTime);
const updatedStatus: IUpsStatus = {
id: ups.id,
name: ups.name,
powerStatus: polledStatus.powerStatus,
batteryCapacity: polledStatus.batteryCapacity,
batteryRuntime: polledStatus.batteryRuntime,
outputLoad: polledStatus.outputLoad,
outputPower: polledStatus.outputPower,
outputVoltage: polledStatus.outputVoltage,
outputCurrent: polledStatus.outputCurrent,
lastCheckTime: currentTime,
lastStatusChange: previousStatus.lastStatusChange || currentTime,
consecutiveFailures: 0,
unreachableSince: 0,
};
if (previousStatus.powerStatus === 'unreachable') {
updatedStatus.lastStatusChange = currentTime;
return {
updatedStatus,
transition: 'recovered',
previousStatus,
downtimeSeconds: Math.round((currentTime - previousStatus.unreachableSince) / 1000),
};
}
if (previousStatus.powerStatus !== polledStatus.powerStatus) {
updatedStatus.lastStatusChange = currentTime;
return {
updatedStatus,
transition: 'powerStatusChange',
previousStatus,
};
}
return {
updatedStatus,
transition: 'none',
previousStatus: currentStatus,
};
}
export function buildFailedUpsPollSnapshot(
ups: IUpsIdentity,
currentStatus: IUpsStatus | undefined,
currentTime: number,
): IFailedUpsPollSnapshot {
const previousStatus = ensureUpsStatus(currentStatus, ups, currentTime);
const failures = Math.min(
previousStatus.consecutiveFailures + 1,
NETWORK.MAX_CONSECUTIVE_FAILURES,
);
if (
failures >= NETWORK.CONSECUTIVE_FAILURE_THRESHOLD &&
previousStatus.powerStatus !== 'unreachable'
) {
return {
updatedStatus: {
...previousStatus,
consecutiveFailures: failures,
powerStatus: 'unreachable',
unreachableSince: currentTime,
lastStatusChange: currentTime,
},
transition: 'unreachable',
failures,
previousStatus,
};
}
return {
updatedStatus: {
...previousStatus,
consecutiveFailures: failures,
},
transition: 'none',
failures,
previousStatus: currentStatus,
};
}
export function hasThresholdViolation(
powerStatus: IProtocolUpsStatus['powerStatus'],
batteryCapacity: number,
batteryRuntime: number,
actions: IActionConfig[] | undefined,
): boolean {
if (powerStatus !== 'onBattery' || !actions || actions.length === 0) {
return false;
}
for (const actionConfig of actions) {
if (
actionConfig.thresholds &&
(batteryCapacity < actionConfig.thresholds.battery ||
batteryRuntime < actionConfig.thresholds.runtime)
) {
return true;
}
}
return false;
}
+38
View File
@@ -0,0 +1,38 @@
export interface IUpsIdentity {
id: string;
name: string;
}
export interface IUpsStatus {
id: string;
name: string;
powerStatus: 'online' | 'onBattery' | 'unknown' | 'unreachable';
batteryCapacity: number;
batteryRuntime: number;
outputLoad: number;
outputPower: number;
outputVoltage: number;
outputCurrent: number;
lastStatusChange: number;
lastCheckTime: number;
consecutiveFailures: number;
unreachableSince: number;
}
export function createInitialUpsStatus(ups: IUpsIdentity, now: number = Date.now()): IUpsStatus {
return {
id: ups.id,
name: ups.name,
powerStatus: 'unknown',
batteryCapacity: 100,
batteryRuntime: 999,
outputLoad: 0,
outputPower: 0,
outputVoltage: 0,
outputCurrent: 0,
lastStatusChange: now,
lastCheckTime: 0,
consecutiveFailures: 0,
unreachableSince: 0,
};
}