Compare commits

...

8 Commits

Author SHA1 Message Date
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
34 changed files with 2311 additions and 890 deletions
View File
+32
View File
@@ -1,5 +1,37 @@
# Changelog # Changelog
## 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) ## 2026-03-18 - 5.3.3 - fix(deps)
add @git.zone/tsdeno as a development dependency add @git.zone/tsdeno as a development dependency
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@serve.zone/nupst", "name": "@serve.zone/nupst",
"version": "5.3.3", "version": "5.5.1",
"exports": "./mod.ts", "exports": "./mod.ts",
"nodeModulesDir": "auto", "nodeModulesDir": "auto",
"tasks": { "tasks": {
+1 -6
View File
@@ -25,12 +25,7 @@ import { NupstCli } from './ts/cli.ts';
*/ */
async function main(): Promise<void> { async function main(): Promise<void> {
const cli = new NupstCli(); const cli = new NupstCli();
await cli.parseAndExecute(Deno.args);
// 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);
} }
// Execute main and handle errors // Execute main and handle errors
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@serve.zone/nupst", "name": "@serve.zone/nupst",
"version": "5.3.3", "version": "5.5.1",
"description": "Network UPS Shutdown Tool - Monitor SNMP-enabled UPS devices and orchestrate graceful system shutdowns during power emergencies", "description": "Network UPS Shutdown Tool - Monitor SNMP-enabled UPS devices and orchestrate graceful system shutdowns during power emergencies",
"keywords": [ "keywords": [
"ups", "ups",
@@ -64,6 +64,6 @@
}, },
"packageManager": "pnpm@10.18.1+sha512.77a884a165cbba2d8d1c19e3b4880eee6d2fcabd0d879121e282196b80042351d5eb3ca0935fa599da1dc51265cc68816ad2bddd2a2de5ea9fdf92adbec7cd34", "packageManager": "pnpm@10.18.1+sha512.77a884a165cbba2d8d1c19e3b4880eee6d2fcabd0d879121e282196b80042351d5eb3ca0935fa599da1dc51265cc68816ad2bddd2a2de5ea9fdf92adbec7cd34",
"devDependencies": { "devDependencies": {
"@git.zone/tsdeno": "^1.2.0" "@git.zone/tsdeno": "^1.3.1"
} }
} }
+26 -26
View File
@@ -9,8 +9,8 @@ importers:
.: .:
devDependencies: devDependencies:
'@git.zone/tsdeno': '@git.zone/tsdeno':
specifier: ^1.2.0 specifier: ^1.3.1
version: 1.2.0 version: 1.3.1
packages: packages:
@@ -39,8 +39,8 @@ packages:
'@design.estate/dees-element@2.2.3': '@design.estate/dees-element@2.2.3':
resolution: {integrity: sha512-MpAvJPrJDTDad8hUtdOzMgMFRE7n84O7INhvSlkTTLB3b84j8EKjwfUCMErGAo7Bq5zfw4LG7NnKhLYXXXjkXA==} resolution: {integrity: sha512-MpAvJPrJDTDad8hUtdOzMgMFRE7n84O7INhvSlkTTLB3b84j8EKjwfUCMErGAo7Bq5zfw4LG7NnKhLYXXXjkXA==}
'@git.zone/tsdeno@1.2.0': '@git.zone/tsdeno@1.3.1':
resolution: {integrity: sha512-Kx/9NchzKRoBhgj44V/ymF3rtspMAO+U+x7d3CFfBAyHCzVCrFtzIdVb5JULSTSR89d9a0L4VJVyVUoRdz/MUA==} resolution: {integrity: sha512-1shZOSwMUfmonIe8OsX40EcQcS7/WFt+y+5+TQ7N6QmU+Hp6zjrfFvQwanr5Qcgh/E54WFNTfJk/PsJ26s6Oxw==}
hasBin: true hasBin: true
'@isaacs/cliui@9.0.0': '@isaacs/cliui@9.0.0':
@@ -68,9 +68,6 @@ packages:
'@push.rocks/lik@6.3.1': '@push.rocks/lik@6.3.1':
resolution: {integrity: sha512-UWDwGBaVx5yPtAFXqDDBtQZCzETUOA/7myQIXb+YBsuiIw4yQuhNZ23uY2ChQH2Zn6DLqdNSgQcYC0WywMZBNQ==} 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': '@push.rocks/qenv@6.1.3':
resolution: {integrity: sha512-+z2hsAU/7CIgpYLFqvda8cn9rUBMHqLdQLjsFfRn5jPoD7dJ5rFlpkbhfM4Ws8mHMniwWaxGKo+q/YBhtzRBLg==} resolution: {integrity: sha512-+z2hsAU/7CIgpYLFqvda8cn9rUBMHqLdQLjsFfRn5jPoD7dJ5rFlpkbhfM4Ws8mHMniwWaxGKo+q/YBhtzRBLg==}
@@ -83,6 +80,9 @@ packages:
'@push.rocks/smartclickhouse@2.2.0': '@push.rocks/smartclickhouse@2.2.0':
resolution: {integrity: sha512-eTzKiREIPSzL1kPkVyD6vEbn+WV/DvQqDjP67VlhNlQGbRcemnJG/eLrUUR1ytmdIqnsZGEK6UYBgyj5nhzLNQ==} resolution: {integrity: sha512-eTzKiREIPSzL1kPkVyD6vEbn+WV/DvQqDjP67VlhNlQGbRcemnJG/eLrUUR1ytmdIqnsZGEK6UYBgyj5nhzLNQ==}
'@push.rocks/smartconfig@6.1.0':
resolution: {integrity: sha512-B+xh63PhGAsSwuRyCKXr4PAjJ4HoVKhNysi67OGY6gGqGm6uopgEW1cvrUZ7T5ZSck9KlVx7ZTugbqm6dqBK1Q==}
'@push.rocks/smartdelay@3.0.5': '@push.rocks/smartdelay@3.0.5':
resolution: {integrity: sha512-mUuI7kj2f7ztjpic96FvRIlf2RsKBa5arw81AHNsndbxO6asRcxuWL8dTVxouEIK8YsBUlj0AsrCkHhMbLQdHw==} resolution: {integrity: sha512-mUuI7kj2f7ztjpic96FvRIlf2RsKBa5arw81AHNsndbxO6asRcxuWL8dTVxouEIK8YsBUlj0AsrCkHhMbLQdHw==}
@@ -1025,11 +1025,11 @@ snapshots:
- supports-color - supports-color
- vue - vue
'@git.zone/tsdeno@1.2.0': '@git.zone/tsdeno@1.3.1':
dependencies: dependencies:
'@push.rocks/early': 4.0.4 '@push.rocks/early': 4.0.4
'@push.rocks/npmextra': 5.3.3
'@push.rocks/smartcli': 4.0.20 '@push.rocks/smartcli': 4.0.20
'@push.rocks/smartconfig': 6.1.0
'@push.rocks/smartfs': 1.5.0 '@push.rocks/smartfs': 1.5.0
'@push.rocks/smartshell': 3.3.8 '@push.rocks/smartshell': 3.3.8
transitivePeerDependencies: transitivePeerDependencies:
@@ -1070,23 +1070,6 @@ snapshots:
'@types/symbol-tree': 3.2.5 '@types/symbol-tree': 3.2.5
symbol-tree: 3.2.4 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': '@push.rocks/qenv@6.1.3':
dependencies: dependencies:
'@api.global/typedrequest': 3.3.0 '@api.global/typedrequest': 3.3.0
@@ -1117,6 +1100,23 @@ snapshots:
'@push.rocks/smarturl': 3.1.0 '@push.rocks/smarturl': 3.1.0
'@push.rocks/webrequest': 4.0.5 '@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': '@push.rocks/smartdelay@3.0.5':
dependencies: dependencies:
'@push.rocks/smartpromise': 4.2.3 '@push.rocks/smartpromise': 4.2.3
+64 -3
View File
@@ -36,9 +36,14 @@
- Uses: `IUpsConfig`, `INupstConfig`, `ISnmpConfig`, `IActionConfig`, `IThresholds`, - Uses: `IUpsConfig`, `INupstConfig`, `ISnmpConfig`, `IActionConfig`, `IThresholds`,
`ISnmpUpsStatus` `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) ## Features Added (February 2026)
### Network Loss Handling ### Network Loss Handling
- `TPowerStatus` extended with `'unreachable'` state - `TPowerStatus` extended with `'unreachable'` state
- `IUpsStatus` has `consecutiveFailures` and `unreachableSince` tracking - `IUpsStatus` has `consecutiveFailures` and `unreachableSince` tracking
- After `NETWORK.CONSECUTIVE_FAILURE_THRESHOLD` (3) failures, UPS transitions to `unreachable` - After `NETWORK.CONSECUTIVE_FAILURE_THRESHOLD` (3) failures, UPS transitions to `unreachable`
@@ -46,22 +51,61 @@
- Recovery is logged when UPS comes back from unreachable - Recovery is logged when UPS comes back from unreachable
### UPSD/NIS Protocol Support ### UPSD/NIS Protocol Support
- New `ts/upsd/` directory with TCP client for NUT (Network UPS Tools) servers - New `ts/upsd/` directory with TCP client for NUT (Network UPS Tools) servers
- `ts/protocol/` directory with `ProtocolResolver` for protocol-agnostic status queries - `ts/protocol/` directory with `ProtocolResolver` for protocol-agnostic status queries
- `IUpsConfig.protocol` field: `'snmp'` (default) or `'upsd'` - `IUpsConfig.protocol` field: `'snmp'` (default) or `'upsd'`
- `IUpsConfig.snmp` is now optional (not needed for UPSD devices) - `IUpsConfig.snmp` is now optional (not needed for UPSD devices)
- CLI supports protocol selection during `nupst ups add` - 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 ### Pause/Resume Command
- File-based signaling via `/etc/nupst/pause` JSON file - File-based signaling via `/etc/nupst/pause` JSON file
- `nupst pause [--duration 30m|2h|1d]` creates pause file - `nupst pause [--duration 30m|2h|1d]` creates pause file
- `nupst resume` deletes 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 - Daemon polls continue but actions are suppressed while paused
- Auto-resume after duration expires - Auto-resume after duration expires
- HTTP API includes pause state in response - 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
### 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 ### Proxmox VM Shutdown Action
- New action type `'proxmox'` in `ts/actions/proxmox-action.ts` - New action type `'proxmox'` in `ts/actions/proxmox-action.ts`
- Uses Proxmox REST API with PVEAPIToken authentication - Uses Proxmox REST API with PVEAPIToken authentication
- Shuts down QEMU VMs and LXC containers before host shutdown - Shuts down QEMU VMs and LXC containers before host shutdown
@@ -76,13 +120,30 @@
- **CLI Handlers**: All use the `helpers.withPrompt()` utility for interactive input - **CLI Handlers**: All use the `helpers.withPrompt()` utility for interactive input
- **Constants**: All timing values should be referenced from `ts/constants.ts` - **Constants**: All timing values should be referenced from `ts/constants.ts`
- **Actions**: Use `IActionConfig` from `ts/actions/base-action.ts` for action configuration - **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 ## File Organization
``` ```
ts/ ts/
├── constants.ts # All timing/threshold constants ├── 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/ ├── interfaces/
│ └── nupst-accessor.ts # Interface to break circular deps │ └── nupst-accessor.ts # Interface to break circular deps
├── helpers/ ├── helpers/
@@ -103,7 +164,7 @@ ts/
│ └── index.ts │ └── index.ts
├── migrations/ ├── migrations/
│ ├── migration-runner.ts │ ├── 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/ └── cli/
└── ... # All handlers use helpers.withPrompt() └── ... # All handlers use helpers.withPrompt()
``` ```
+56 -18
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 - **🔌 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 - **📡 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 - **👥 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 - **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 - **Non-Redundant Mode** — Trigger actions when ANY UPS device is critical
@@ -223,7 +223,7 @@ NUPST stores configuration at `/etc/nupst/config.json`. The easiest way to confi
```json ```json
{ {
"version": "4.2", "version": "4.3",
"checkInterval": 30000, "checkInterval": 30000,
"httpServer": { "httpServer": {
"enabled": true, "enabled": true,
@@ -242,17 +242,17 @@ NUPST stores configuration at `/etc/nupst/config.json`. The easiest way to confi
"community": "public", "community": "public",
"version": 1, "version": 1,
"timeout": 5000, "timeout": 5000,
"upsModel": "cyberpower" "upsModel": "cyberpower",
"runtimeUnit": "ticks"
}, },
"actions": [ "actions": [
{ {
"type": "proxmox", "type": "proxmox",
"triggerMode": "onlyThresholds", "triggerMode": "onlyThresholds",
"thresholds": { "battery": 30, "runtime": 15 }, "thresholds": { "battery": 30, "runtime": 15 },
"proxmoxHost": "localhost", "proxmoxMode": "auto",
"proxmoxPort": 8006, "proxmoxExcludeIds": [],
"proxmoxTokenId": "root@pam!nupst", "proxmoxForceStop": true
"proxmoxTokenSecret": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}, },
{ {
"type": "shutdown", "type": "shutdown",
@@ -323,6 +323,7 @@ Each UPS device has a `protocol` field:
| `version` | SNMP version | `1`, `2`, or `3` | | `version` | SNMP version | `1`, `2`, or `3` |
| `timeout` | Timeout in milliseconds | Default: `5000` | | `timeout` | Timeout in milliseconds | Default: `5000` |
| `upsModel` | UPS brand/model | `cyberpower`, `apc`, `eaton`, `tripplite`, `liebert`, `custom` | | `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"` | | `community` | Community string (v1/v2c) | Default: `"public"` |
**SNMPv3 fields** (when `version: 3`): **SNMPv3 fields** (when `version: 3`):
@@ -362,7 +363,7 @@ Actions define automated responses to UPS conditions. They run **sequentially in
| `shutdown` | Graceful system shutdown with configurable delay | | `shutdown` | Graceful system shutdown with configurable delay |
| `webhook` | HTTP POST/GET notification to external services | | `webhook` | HTTP POST/GET notification to external services |
| `script` | Execute custom shell scripts from `/etc/nupst/` | | `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 #### Common Fields
@@ -394,7 +395,7 @@ Actions define automated responses to UPS conditions. They run **sequentially in
| Field | Description | Default | | Field | Description | Default |
| --------------- | ---------------------------------- | ------- | | --------------- | ---------------------------------- | ------- |
| `shutdownDelay` | Seconds to wait before shutdown | `5` | | `shutdownDelay` | Minutes to wait before shutdown | `5` |
#### Webhook Action #### Webhook Action
@@ -436,11 +437,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. 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 ```json
{ {
"type": "proxmox", "type": "proxmox",
"thresholds": { "battery": 30, "runtime": 15 }, "thresholds": { "battery": 30, "runtime": 15 },
"triggerMode": "onlyThresholds", "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", "proxmoxHost": "localhost",
"proxmoxPort": 8006, "proxmoxPort": 8006,
"proxmoxTokenId": "root@pam!nupst", "proxmoxTokenId": "root@pam!nupst",
@@ -454,17 +482,18 @@ Gracefully shuts down QEMU VMs and LXC containers on a Proxmox node before the h
| Field | Description | Default | | Field | Description | Default |
| --------------------- | ----------------------------------------------- | ------------- | | --------------------- | ----------------------------------------------- | ------------- |
| `proxmoxHost` | Proxmox API host | `localhost` | | `proxmoxMode` | Operation mode | `auto` |
| `proxmoxPort` | Proxmox API port | `8006` | | `proxmoxHost` | Proxmox API host (API mode only) | `localhost` |
| `proxmoxPort` | Proxmox API port (API mode only) | `8006` |
| `proxmoxNode` | Proxmox node name | Auto-detect via hostname | | `proxmoxNode` | Proxmox node name | Auto-detect via hostname |
| `proxmoxTokenId` | API token ID (e.g. `root@pam!nupst`) | Required | | `proxmoxTokenId` | API token ID (API mode only) | |
| `proxmoxTokenSecret` | API token secret (UUID) | Required | | `proxmoxTokenSecret` | API token secret (API mode only) | |
| `proxmoxExcludeIds` | VM/CT IDs to skip | `[]` | | `proxmoxExcludeIds` | VM/CT IDs to skip | `[]` |
| `proxmoxStopTimeout` | Seconds to wait for graceful shutdown | `120` | | `proxmoxStopTimeout` | Seconds to wait for graceful shutdown | `120` |
| `proxmoxForceStop` | Force-stop VMs/CTs that don't shut down | `true` | | `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 ```bash
# Create token with full privileges (no privilege separation) # Create token with full privileges (no privilege separation)
@@ -629,7 +658,7 @@ Full SNMPv3 support with authentication and encryption:
### Network Security ### 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 - HTTP API disabled by default; token-required when enabled
- No external internet connections - No external internet connections
@@ -659,6 +688,7 @@ sha256sum -c SHA256SUMS.txt --ignore-missing
```json ```json
{ {
"upsModel": "custom", "upsModel": "custom",
"runtimeUnit": "seconds",
"customOIDs": { "customOIDs": {
"POWER_STATUS": "1.3.6.1.4.1.1234.1.1.0", "POWER_STATUS": "1.3.6.1.4.1.1234.1.1.0",
"BATTERY_CAPACITY": "1.3.6.1.4.1.1234.1.2.0", "BATTERY_CAPACITY": "1.3.6.1.4.1.1234.1.2.0",
@@ -667,6 +697,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 ### UPSD/NIS-based
Any UPS supported by [NUT (Network UPS Tools)](https://networkupstools.org/) — this covers **hundreds of models** from virtually every manufacturer, including USB-connected devices. Check the [NUT hardware compatibility list](https://networkupstools.org/stable-hcl.html). Any UPS supported by [NUT (Network UPS Tools)](https://networkupstools.org/) — this covers **hundreds of models** from virtually every manufacturer, including USB-connected devices. Check the [NUT hardware compatibility list](https://networkupstools.org/stable-hcl.html).
@@ -736,7 +768,13 @@ upsc ups@localhost # if NUT CLI is installed
### Proxmox VMs Not Shutting Down ### Proxmox VMs Not Shutting Down
```bash ```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" \ curl -k -H "Authorization: PVEAPIToken=root@pam!nupst=YOUR-SECRET" \
https://localhost:8006/api2/json/nodes/$(hostname)/qemu https://localhost:8006/api2/json/nodes/$(hostname)/qemu
@@ -848,7 +886,7 @@ nupst/
## License and Legal Information ## License and Legal Information
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./LICENSE) file. This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](./license) file.
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file. **Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
+3 -3
View File
@@ -229,10 +229,10 @@ console.log('');
// === 10. Update Available Example === // === 10. Update Available Example ===
logger.logBoxTitle('Update Available', 70, 'warning'); logger.logBoxTitle('Update Available', 70, 'warning');
logger.logBoxLine(''); logger.logBoxLine('');
logger.logBoxLine(`Current Version: ${theme.dim('4.0.1')}`); logger.logBoxLine(`Current Version: ${theme.dim('5.5.0')}`);
logger.logBoxLine(`Latest Version: ${theme.highlight('4.0.2')}`); logger.logBoxLine(`Latest Version: ${theme.highlight('5.5.1')}`);
logger.logBoxLine(''); 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.logBoxLine('');
logger.logBoxEnd(); logger.logBoxEnd();
+430
View File
@@ -2,9 +2,27 @@ import { assert, assertEquals, assertExists } from 'jsr:@std/assert@^1.0.0';
import { NupstSnmp } from '../ts/snmp/manager.ts'; import { NupstSnmp } from '../ts/snmp/manager.ts';
import { UpsOidSets } from '../ts/snmp/oid-sets.ts'; import { UpsOidSets } from '../ts/snmp/oid-sets.ts';
import type { IOidSet, ISnmpConfig, TUpsModel } from '../ts/snmp/types.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 { shortId } from '../ts/helpers/shortid.ts';
import { HTTP_SERVER, SNMP, THRESHOLDS, TIMING, UI } from '../ts/constants.ts'; import { HTTP_SERVER, SNMP, THRESHOLDS, TIMING, UI } from '../ts/constants.ts';
import { Action, type IActionContext } from '../ts/actions/base-action.ts'; import { Action, type IActionContext } from '../ts/actions/base-action.ts';
import { 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'; import * as qenv from 'npm:@push.rocks/qenv@^6.0.0';
const testQenv = new qenv.Qenv('./', '.nogit/'); const testQenv = new qenv.Qenv('./', '.nogit/');
@@ -82,6 +100,418 @@ Deno.test('UI constants: box widths are ascending', () => {
assert(UI.WIDE_BOX_WIDTH < UI.EXTRA_WIDE_BOX_WIDTH); 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',
},
});
});
// -----------------------------------------------------------------------------
// 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 // UpsOidSets Tests
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
+1 -1
View File
@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@serve.zone/nupst', name: '@serve.zone/nupst',
version: '5.3.3', version: '5.5.1',
description: 'Network UPS Shutdown Tool - Monitor SNMP-enabled UPS devices and orchestrate graceful system shutdowns during power emergencies' description: 'Network UPS Shutdown Tool - Monitor SNMP-enabled UPS devices and orchestrate graceful system shutdowns during power emergencies'
} }
+70
View File
@@ -0,0 +1,70 @@
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 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),
};
}
+2
View File
@@ -116,6 +116,8 @@ export interface IActionConfig {
proxmoxForceStop?: boolean; proxmoxForceStop?: boolean;
/** Skip TLS verification for self-signed certificates (default: true) */ /** Skip TLS verification for self-signed certificates (default: true) */
proxmoxInsecure?: boolean; proxmoxInsecure?: boolean;
/** Proxmox operation mode: 'auto' detects CLI tools, 'cli' forces CLI, 'api' forces REST API (default: 'auto') */
proxmoxMode?: 'auto' | 'api' | 'cli';
} }
/** /**
+288 -60
View File
@@ -1,14 +1,22 @@
import * as fs from 'node:fs';
import * as os from 'node:os'; 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 { Action, type IActionContext } from './base-action.ts';
import { logger } from '../logger.ts'; import { logger } from '../logger.ts';
import { PROXMOX, UI } from '../constants.ts'; import { PROXMOX, UI } from '../constants.ts';
const execFileAsync = promisify(execFile);
/** /**
* ProxmoxAction - Gracefully shuts down Proxmox VMs and LXC containers * ProxmoxAction - Gracefully shuts down Proxmox VMs and LXC containers
* *
* Uses the Proxmox REST API via HTTPS with API token authentication. * Supports two operation modes:
* Shuts down running QEMU VMs and LXC containers, waits for completion, * - CLI mode: Uses qm/pct commands directly (requires running as root on a Proxmox host)
* and optionally force-stops any that don't respond. * - 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 * This action should be placed BEFORE shutdown actions in the action chain
* so that VMs are stopped before the host is shut down. * 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 { export class ProxmoxAction extends Action {
readonly type = 'proxmox'; 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 * Execute the Proxmox shutdown action
*/ */
@@ -29,30 +108,21 @@ export class ProxmoxAction extends Action {
return; return;
} }
const host = this.config.proxmoxHost || PROXMOX.DEFAULT_HOST; const resolved = this.resolveMode();
const port = this.config.proxmoxPort || PROXMOX.DEFAULT_PORT;
const node = this.config.proxmoxNode || os.hostname(); 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 excludeIds = new Set(this.config.proxmoxExcludeIds || []);
const stopTimeout = (this.config.proxmoxStopTimeout || PROXMOX.DEFAULT_STOP_TIMEOUT_SECONDS) * 1000; const stopTimeout = (this.config.proxmoxStopTimeout || PROXMOX.DEFAULT_STOP_TIMEOUT_SECONDS) * 1000;
const forceStop = this.config.proxmoxForceStop !== false; // default true const 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.log('');
logger.logBoxTitle('Proxmox VM Shutdown', UI.WIDE_BOX_WIDTH, 'warning'); 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(`Node: ${node}`);
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(`API: ${host}:${port}`);
}
logger.logBoxLine(`UPS: ${context.upsName} (${context.powerStatus})`); logger.logBoxLine(`UPS: ${context.upsName} (${context.powerStatus})`);
logger.logBoxLine(`Trigger: ${context.triggerReason}`); logger.logBoxLine(`Trigger: ${context.triggerReason}`);
if (excludeIds.size > 0) { if (excludeIds.size > 0) {
@@ -62,9 +132,34 @@ export class ProxmoxAction extends Action {
logger.log(''); logger.log('');
try { try {
// Collect running VMs and CTs let runningVMs: Array<{ vmid: number; name: string }>;
const runningVMs = await this.getRunningVMs(baseUrl, node, headers, insecure); let runningCTs: Array<{ vmid: number; name: string }>;
const runningCTs = await this.getRunningCTs(baseUrl, node, headers, insecure);
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 // Filter out excluded IDs
const vmsToStop = runningVMs.filter((vm) => !excludeIds.has(vm.vmid)); const vmsToStop = runningVMs.filter((vm) => !excludeIds.has(vm.vmid));
@@ -78,16 +173,34 @@ export class ProxmoxAction extends Action {
logger.info(`Shutting down ${vmsToStop.length} VMs and ${ctsToStop.length} containers...`); logger.info(`Shutting down ${vmsToStop.length} VMs and ${ctsToStop.length} containers...`);
// Send shutdown commands to all VMs and CTs // Send shutdown commands
if (resolved.mode === 'cli') {
for (const vm of vmsToStop) { for (const vm of vmsToStop) {
await this.shutdownVM(baseUrl, node, vm.vmid, headers, insecure); await this.shutdownVMCli(resolved.qmPath, vm.vmid);
logger.dim(` Shutdown sent to VM ${vm.vmid} (${vm.name || 'unnamed'})`); logger.dim(` Shutdown sent to VM ${vm.vmid} (${vm.name || 'unnamed'})`);
} }
for (const ct of ctsToStop) { for (const ct of ctsToStop) {
await this.shutdownCT(baseUrl, node, ct.vmid, headers, insecure); await this.shutdownCTCli(resolved.pctPath, ct.vmid);
logger.dim(` Shutdown sent to CT ${ct.vmid} (${ct.name || 'unnamed'})`); 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 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 // Poll until all stopped or timeout
const allIds = [ const allIds = [
@@ -95,23 +208,31 @@ export class ProxmoxAction extends Action {
...ctsToStop.map((ct) => ({ type: 'lxc' as const, vmid: ct.vmid, name: ct.name })), ...ctsToStop.map((ct) => ({ type: 'lxc' as const, vmid: ct.vmid, name: ct.name })),
]; ];
const remaining = await this.waitForShutdown( const remaining = await this.waitForShutdown(allIds, resolved, node, stopTimeout);
baseUrl,
node,
allIds,
headers,
insecure,
stopTimeout,
);
if (remaining.length > 0 && forceStop) { if (remaining.length > 0 && forceStop) {
logger.warn(`${remaining.length} VMs/CTs didn't shut down gracefully, force-stopping...`); logger.warn(`${remaining.length} VMs/CTs didn't shut down gracefully, force-stopping...`);
for (const item of remaining) { for (const item of remaining) {
try { try {
if (resolved.mode === 'cli') {
if (item.type === 'qemu') { if (item.type === 'qemu') {
await this.stopVM(baseUrl, node, item.vmid, headers, insecure); await this.stopVMCli(resolved.qmPath, item.vmid);
} else { } else {
await this.stopCT(baseUrl, node, item.vmid, headers, insecure); await this.stopCTCli(resolved.pctPath, item.vmid);
}
} else {
const host = this.config.proxmoxHost || PROXMOX.DEFAULT_HOST;
const port = this.config.proxmoxPort || PROXMOX.DEFAULT_PORT;
const insecure = this.config.proxmoxInsecure !== false;
const baseUrl = `https://${host}:${port}${PROXMOX.API_BASE}`;
const headers: Record<string, string> = {
'Authorization': `PVEAPIToken=${this.config.proxmoxTokenId}=${this.config.proxmoxTokenSecret}`,
};
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'})`); logger.dim(` Force-stopped ${item.type} ${item.vmid} (${item.name || 'unnamed'})`);
} catch (error) { } 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 * 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, baseUrl: string,
node: string, node: string,
headers: Record<string, 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, baseUrl: string,
node: string, node: string,
headers: Record<string, string>, headers: Record<string, string>,
@@ -228,10 +453,7 @@ export class ProxmoxAction extends Action {
} }
} }
/** private async shutdownVMApi(
* Send graceful shutdown to a QEMU VM
*/
private async shutdownVM(
baseUrl: string, baseUrl: string,
node: string, node: string,
vmid: number, vmid: number,
@@ -246,10 +468,7 @@ export class ProxmoxAction extends Action {
); );
} }
/** private async shutdownCTApi(
* Send graceful shutdown to an LXC container
*/
private async shutdownCT(
baseUrl: string, baseUrl: string,
node: string, node: string,
vmid: number, vmid: number,
@@ -264,10 +483,7 @@ export class ProxmoxAction extends Action {
); );
} }
/** private async stopVMApi(
* Force-stop a QEMU VM
*/
private async stopVM(
baseUrl: string, baseUrl: string,
node: string, node: string,
vmid: number, vmid: number,
@@ -282,10 +498,7 @@ export class ProxmoxAction extends Action {
); );
} }
/** private async stopCTApi(
* Force-stop an LXC container
*/
private async stopCT(
baseUrl: string, baseUrl: string,
node: string, node: string,
vmid: number, 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 * Wait for VMs/CTs to shut down, return any that are still running after timeout
*/ */
private async waitForShutdown( private async waitForShutdown(
baseUrl: string,
node: string,
items: Array<{ type: 'qemu' | 'lxc'; vmid: number; name: string }>, items: Array<{ type: 'qemu' | 'lxc'; vmid: number; name: string }>,
headers: Record<string, string>, resolved: { mode: 'api' | 'cli'; qmPath?: string; pctPath?: string },
insecure: boolean, node: string,
timeout: number, timeout: number,
): Promise<Array<{ type: 'qemu' | 'lxc'; vmid: number; name: string }>> { ): Promise<Array<{ type: 'qemu' | 'lxc'; vmid: number; name: string }>> {
const startTime = Date.now(); const startTime = Date.now();
@@ -323,12 +536,27 @@ export class ProxmoxAction extends Action {
for (const item of remaining) { for (const item of remaining) {
try { try {
let status: string;
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 statusUrl = `${baseUrl}/nodes/${node}/${item.type}/${item.vmid}/status/current`;
const response = await this.apiRequest(statusUrl, 'GET', headers, insecure) as { const response = await this.apiRequest(statusUrl, 'GET', headers, insecure) as {
data: { status: string }; data: { status: string };
}; };
status = response.data?.status || 'unknown';
}
if (response.data?.status === 'running') { if (status === 'running') {
stillRunning.push(item); stillRunning.push(item);
} else { } else {
logger.dim(` ${item.type} ${item.vmid} (${item.name}) stopped`); logger.dim(` ${item.type} ${item.vmid} (${item.name}) stopped`);
+4 -4
View File
@@ -19,7 +19,7 @@ export class NupstCli {
/** /**
* Parse command line arguments and execute the appropriate command * 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> { public async parseAndExecute(args: string[]): Promise<void> {
// Extract debug and version flags from any position // Extract debug and version flags from any position
@@ -38,8 +38,8 @@ export class NupstCli {
} }
// Get the command (default to help if none provided) // Get the command (default to help if none provided)
const command = debugOptions.cleanedArgs[2] || 'help'; const command = debugOptions.cleanedArgs[0] || 'help';
const commandArgs = debugOptions.cleanedArgs.slice(3); const commandArgs = debugOptions.cleanedArgs.slice(1);
// Route to the appropriate command handler // Route to the appropriate command handler
await this.executeCommand(command, commandArgs, debugOptions.debugMode); await this.executeCommand(command, commandArgs, debugOptions.debugMode);
@@ -98,7 +98,7 @@ export class NupstCli {
await serviceHandler.start(); await serviceHandler.start();
break; break;
case 'status': case 'status':
await serviceHandler.status(); await serviceHandler.status(debugMode);
break; break;
case 'logs': case 'logs':
await serviceHandler.logs(); await serviceHandler.logs();
+154 -28
View File
@@ -3,6 +3,7 @@ import { Nupst } from '../nupst.ts';
import { type ITableColumn, logger } from '../logger.ts'; import { type ITableColumn, logger } from '../logger.ts';
import { symbols, theme } from '../colors.ts'; import { symbols, theme } from '../colors.ts';
import type { IActionConfig } from '../actions/base-action.ts'; import type { IActionConfig } from '../actions/base-action.ts';
import { ProxmoxAction } from '../actions/proxmox-action.ts';
import type { IGroupConfig, IUpsConfig } from '../daemon.ts'; import type { IGroupConfig, IUpsConfig } from '../daemon.ts';
import * as helpers from '../helpers/index.ts'; import * as helpers from '../helpers/index.ts';
@@ -65,11 +66,146 @@ export class ActionHandler {
logger.info(`Add Action to ${targetType} ${theme.highlight(targetName)}`); logger.info(`Add Action to ${targetType} ${theme.highlight(targetName)}`);
logger.log(''); logger.log('');
// Action type (currently only shutdown is supported) // Action type selection
const type = 'shutdown'; logger.log(` ${theme.dim('Action Type:')}`);
logger.log(` ${theme.dim('Action type:')} ${theme.highlight('shutdown')}`); 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 delayStr = await prompt(
` ${theme.dim('Shutdown delay')} ${theme.dim('(minutes) [5]:')} `,
);
const shutdownDelay = delayStr ? parseInt(delayStr, 10) : 5;
if (isNaN(shutdownDelay) || shutdownDelay < 0) {
logger.error('Invalid shutdown delay. Must be >= 0.');
process.exit(1);
}
newAction.shutdownDelay = shutdownDelay;
} else if (typeValue === 2) {
// Webhook action
newAction.type = 'webhook';
const url = await prompt(` ${theme.dim('Webhook URL:')} `);
if (!url.trim()) {
logger.error('Webhook URL is required.');
process.exit(1);
}
newAction.webhookUrl = url.trim();
logger.log('');
logger.log(` ${theme.dim('HTTP Method:')}`);
logger.log(` ${theme.dim('1)')} POST (JSON body)`);
logger.log(` ${theme.dim('2)')} GET (query parameters)`);
const methodInput = await prompt(` ${theme.dim('Select method')} ${theme.dim('[1]:')} `);
newAction.webhookMethod = methodInput === '2' ? 'GET' : 'POST';
const timeoutInput = await prompt(` ${theme.dim('Timeout in seconds')} ${theme.dim('[10]:')} `);
const timeout = parseInt(timeoutInput, 10);
if (timeoutInput.trim() && !isNaN(timeout)) {
newAction.webhookTimeout = timeout * 1000;
}
} else if (typeValue === 3) {
// Script action
newAction.type = 'script';
const scriptPath = await prompt(` ${theme.dim('Script filename (in /etc/nupst/, must end with .sh):')} `);
if (!scriptPath.trim() || !scriptPath.trim().endsWith('.sh')) {
logger.error('Script path must end with .sh.');
process.exit(1);
}
newAction.scriptPath = scriptPath.trim();
const timeoutInput = await prompt(` ${theme.dim('Script timeout in seconds')} ${theme.dim('[60]:')} `);
const timeout = parseInt(timeoutInput, 10);
if (timeoutInput.trim() && !isNaN(timeout)) {
newAction.scriptTimeout = timeout * 1000;
}
} else if (typeValue === 4) {
// Proxmox action
newAction.type = 'proxmox';
// Auto-detect CLI availability
const detection = ProxmoxAction.detectCliAvailability();
if (detection.available) {
logger.log('');
logger.success('Proxmox CLI tools detected (qm/pct). No API token needed.');
logger.dim(` qm: ${detection.qmPath}`);
logger.dim(` pct: ${detection.pctPath}`);
newAction.proxmoxMode = 'cli';
} else {
logger.log('');
if (!detection.isRoot) {
logger.warn('Not running as root - CLI mode unavailable, using API mode.');
} else {
logger.warn('Proxmox CLI tools (qm/pct) not found - using API mode.');
}
logger.log('');
logger.info('Proxmox API Settings:');
logger.dim('Create a token with: pveum user token add root@pam nupst --privsep=0');
const pxHost = await prompt(` ${theme.dim('Proxmox Host')} ${theme.dim('[localhost]:')} `);
newAction.proxmoxHost = pxHost.trim() || 'localhost';
const pxPortInput = await prompt(` ${theme.dim('Proxmox API Port')} ${theme.dim('[8006]:')} `);
const pxPort = parseInt(pxPortInput, 10);
newAction.proxmoxPort = pxPortInput.trim() && !isNaN(pxPort) ? pxPort : 8006;
const pxNode = await prompt(` ${theme.dim('Proxmox Node Name (empty = auto-detect):')} `);
if (pxNode.trim()) {
newAction.proxmoxNode = pxNode.trim();
}
const tokenId = await prompt(` ${theme.dim('API Token ID (e.g., root@pam!nupst):')} `);
if (!tokenId.trim()) {
logger.error('Token ID is required for API mode.');
process.exit(1);
}
newAction.proxmoxTokenId = tokenId.trim();
const tokenSecret = await prompt(` ${theme.dim('API Token Secret:')} `);
if (!tokenSecret.trim()) {
logger.error('Token Secret is required for API mode.');
process.exit(1);
}
newAction.proxmoxTokenSecret = tokenSecret.trim();
const insecureInput = await prompt(` ${theme.dim('Skip TLS verification (self-signed cert)?')} ${theme.dim('(Y/n):')} `);
newAction.proxmoxInsecure = insecureInput.toLowerCase() !== 'n';
newAction.proxmoxMode = 'api';
}
// Common Proxmox settings (both modes)
const excludeInput = await prompt(` ${theme.dim('VM/CT IDs to exclude (comma-separated, or empty):')} `);
if (excludeInput.trim()) {
newAction.proxmoxExcludeIds = excludeInput.split(',').map((s) => parseInt(s.trim(), 10)).filter((n) => !isNaN(n));
}
const timeoutInput = await prompt(` ${theme.dim('VM shutdown timeout in seconds')} ${theme.dim('[120]:')} `);
const stopTimeout = parseInt(timeoutInput, 10);
if (timeoutInput.trim() && !isNaN(stopTimeout)) {
newAction.proxmoxStopTimeout = stopTimeout;
}
const forceInput = await prompt(` ${theme.dim('Force-stop VMs that don\'t shut down in time?')} ${theme.dim('(Y/n):')} `);
newAction.proxmoxForceStop = forceInput.toLowerCase() !== 'n';
} else {
logger.error('Invalid action type.');
process.exit(1);
}
// Battery threshold (all action types)
logger.log('');
const batteryStr = await prompt( const batteryStr = await prompt(
` ${theme.dim('Battery threshold')} ${theme.dim('(%):')} `, ` ${theme.dim('Battery threshold')} ${theme.dim('(%):')} `,
); );
@@ -89,6 +225,8 @@ export class ActionHandler {
process.exit(1); process.exit(1);
} }
newAction.thresholds = { battery, runtime };
// Trigger mode // Trigger mode
logger.log(''); logger.log('');
logger.log(` ${theme.dim('Trigger mode:')}`); logger.log(` ${theme.dim('Trigger mode:')}`);
@@ -113,33 +251,13 @@ export class ActionHandler {
'': 'onlyThresholds', // Default '': 'onlyThresholds', // Default
}; };
const triggerMode = triggerModeMap[triggerChoice] || 'onlyThresholds'; const triggerMode = triggerModeMap[triggerChoice] || 'onlyThresholds';
newAction.triggerMode = triggerMode as IActionConfig['triggerMode'];
// Shutdown delay
const delayStr = await prompt(
` ${theme.dim('Shutdown delay')} ${theme.dim('(seconds) [5]:')} `,
);
const shutdownDelay = delayStr ? parseInt(delayStr, 10) : 5;
if (isNaN(shutdownDelay) || shutdownDelay < 0) {
logger.error('Invalid shutdown delay. Must be >= 0.');
process.exit(1);
}
// Create the action
const newAction: IActionConfig = {
type,
thresholds: {
battery,
runtime,
},
triggerMode: triggerMode as IActionConfig['triggerMode'],
shutdownDelay,
};
// Add to target (UPS or group) // Add to target (UPS or group)
if (!target!.actions) { if (!target!.actions) {
target!.actions = []; target!.actions = [];
} }
target!.actions.push(newAction); target!.actions.push(newAction as IActionConfig);
await this.nupst.getDaemon().saveConfig(config); await this.nupst.getDaemon().saveConfig(config);
@@ -350,11 +468,19 @@ export class ActionHandler {
]; ];
const rows = target.actions.map((action, index) => { const rows = target.actions.map((action, index) => {
let details = `${action.shutdownDelay || 5}s delay`; let details = `${action.shutdownDelay || 5}min delay`;
if (action.type === 'proxmox') { if (action.type === 'proxmox') {
const mode = action.proxmoxMode || 'auto';
if (mode === 'cli' || (mode === 'auto' && !action.proxmoxTokenId)) {
details = 'CLI mode';
} else {
const host = action.proxmoxHost || 'localhost'; const host = action.proxmoxHost || 'localhost';
const port = action.proxmoxPort || 8006; const port = action.proxmoxPort || 8006;
details = `${host}:${port}`; details = `API ${host}:${port}`;
}
if (action.proxmoxExcludeIds?.length) {
details += `, excl: ${action.proxmoxExcludeIds.join(',')}`;
}
} else if (action.type === 'webhook') { } else if (action.type === 'webhook') {
details = action.webhookUrl || theme.dim('N/A'); details = action.webhookUrl || theme.dim('N/A');
} else if (action.type === 'script') { } else if (action.type === 'script') {
+4 -4
View File
@@ -124,7 +124,7 @@ export class GroupHandler {
await this.nupst.getDaemon().loadConfig(); await this.nupst.getDaemon().loadConfig();
} catch (error) { } catch (error) {
logger.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; return;
} }
@@ -219,7 +219,7 @@ export class GroupHandler {
await this.nupst.getDaemon().loadConfig(); await this.nupst.getDaemon().loadConfig();
} catch (error) { } catch (error) {
logger.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; return;
} }
@@ -316,7 +316,7 @@ export class GroupHandler {
await this.nupst.getDaemon().loadConfig(); await this.nupst.getDaemon().loadConfig();
} catch (error) { } catch (error) {
logger.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; return;
} }
@@ -484,7 +484,7 @@ export class GroupHandler {
prompt: (question: string) => Promise<string>, prompt: (question: string) => Promise<string>,
): Promise<void> { ): Promise<void> {
if (!config.upsDevices || config.upsDevices.length === 0) { if (!config.upsDevices || config.upsDevices.length === 0) {
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; return;
} }
+14 -23
View File
@@ -6,7 +6,7 @@ import { Nupst } from '../nupst.ts';
import { logger } from '../logger.ts'; import { logger } from '../logger.ts';
import { theme } from '../colors.ts'; import { theme } from '../colors.ts';
import { PAUSE } from '../constants.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'; import * as helpers from '../helpers/index.ts';
/** /**
@@ -30,7 +30,9 @@ export class ServiceHandler {
public async enable(): Promise<void> { public async enable(): Promise<void> {
this.checkRootAccess('This command must be run as root.'); this.checkRootAccess('This command must be run as root.');
await this.nupst.getSystemd().install(); 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 * Show status of the systemd service and UPS
*/ */
public async status(): Promise<void> { public async status(debugMode: boolean = false): Promise<void> {
// Extract debug options from args array await this.nupst.getSystemd().getStatus(debugMode);
const debugOptions = this.extractDebugOptions(process.argv);
await this.nupst.getSystemd().getStatus(debugOptions.debugMode);
} }
/** /**
@@ -221,10 +221,14 @@ export class ServiceHandler {
const unit = match[2].toLowerCase(); const unit = match[2].toLowerCase();
switch (unit) { switch (unit) {
case 'm': return value * 60 * 1000; case 'm':
case 'h': return value * 60 * 60 * 1000; return value * 60 * 1000;
case 'd': return value * 24 * 60 * 60 * 1000; case 'h':
default: return null; 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); 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 };
}
} }
+85 -19
View File
@@ -9,6 +9,7 @@ import type { IUpsdConfig } from '../upsd/types.ts';
import type { TProtocol } from '../protocol/types.ts'; import type { TProtocol } from '../protocol/types.ts';
import type { INupstConfig, IUpsConfig } from '../daemon.ts'; import type { INupstConfig, IUpsConfig } from '../daemon.ts';
import type { IActionConfig } from '../actions/base-action.ts'; import type { IActionConfig } from '../actions/base-action.ts';
import { ProxmoxAction } from '../actions/proxmox-action.ts';
import { UPSD } from '../constants.ts'; import { UPSD } from '../constants.ts';
/** /**
@@ -102,7 +103,15 @@ export class UpsHandler {
const protocol: TProtocol = protocolChoice === 2 ? 'upsd' : 'snmp'; const protocol: TProtocol = protocolChoice === 2 ? 'upsd' : 'snmp';
// Create a new UPS configuration object with defaults // 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, id: upsId,
name: name || `UPS-${upsId}`, name: name || `UPS-${upsId}`,
protocol, protocol,
@@ -202,7 +211,7 @@ export class UpsHandler {
return; return;
} else { } else {
// For specific UPS ID, error if config doesn't exist // 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; return;
} }
} }
@@ -241,7 +250,7 @@ export class UpsHandler {
} else { } else {
// For backward compatibility, edit the first UPS if no ID specified // For backward compatibility, edit the first UPS if no ID specified
if (config.upsDevices.length === 0) { 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; return;
} }
upsToEdit = config.upsDevices[0]; upsToEdit = config.upsDevices[0];
@@ -260,7 +269,9 @@ export class UpsHandler {
logger.info(`Current Protocol: ${currentProtocol.toUpperCase()}`); logger.info(`Current Protocol: ${currentProtocol.toUpperCase()}`);
logger.dim(' 1) SNMP (network UPS with SNMP agent)'); logger.dim(' 1) SNMP (network UPS with SNMP agent)');
logger.dim(' 2) UPSD/NIS (local NUT server, e.g. USB-connected UPS)'); 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); const protocolChoice = parseInt(protocolInput, 10);
if (protocolChoice === 2) { if (protocolChoice === 2) {
upsToEdit.protocol = 'upsd'; upsToEdit.protocol = 'upsd';
@@ -347,7 +358,7 @@ export class UpsHandler {
const errorBoxWidth = 45; const errorBoxWidth = 45;
logger.logBoxTitle('Configuration Error', errorBoxWidth); logger.logBoxTitle('Configuration Error', errorBoxWidth);
logger.logBoxLine('No configuration found.'); 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(); logger.logBoxEnd();
return; return;
} }
@@ -358,7 +369,7 @@ export class UpsHandler {
// Check if multi-UPS config // Check if multi-UPS config
if (!config.upsDevices || !Array.isArray(config.upsDevices)) { if (!config.upsDevices || !Array.isArray(config.upsDevices)) {
logger.error('Legacy single-UPS configuration detected. Cannot delete UPS.'); 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; return;
} }
@@ -526,7 +537,7 @@ export class UpsHandler {
const errorBoxWidth = 45; const errorBoxWidth = 45;
logger.logBoxTitle('Configuration Error', errorBoxWidth); logger.logBoxTitle('Configuration Error', errorBoxWidth);
logger.logBoxLine('No configuration found.'); 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(); logger.logBoxEnd();
return; return;
} }
@@ -623,7 +634,9 @@ export class UpsHandler {
logger.logBoxLine( logger.logBoxLine(
` Battery Capacity: ${snmpConfig.customOIDs.BATTERY_CAPACITY || 'Not set'}`, ` 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 upsId = isUpsConfig ? (config as IUpsConfig).id : 'default';
const upsName = isUpsConfig ? (config as IUpsConfig).name : 'Default UPS'; const upsName = isUpsConfig ? (config as IUpsConfig).name : 'Default UPS';
const protocol = isUpsConfig ? ((config as IUpsConfig).protocol || 'snmp') : 'snmp'; 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 { try {
let status: ISnmpUpsStatus; let status: ISnmpUpsStatus;
@@ -690,7 +705,9 @@ export class UpsHandler {
logger.logBoxTitle(`Connection Failed: ${upsName}`, errorBoxWidth); logger.logBoxTitle(`Connection Failed: ${upsName}`, errorBoxWidth);
logger.logBoxLine(`Error: ${error instanceof Error ? error.message : String(error)}`); logger.logBoxLine(`Error: ${error instanceof Error ? error.message : String(error)}`);
logger.logBoxEnd(); 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: '', 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';
}
} }
/** /**
@@ -1155,10 +1201,25 @@ export class UpsHandler {
// Proxmox action // Proxmox action
action.type = 'proxmox'; action.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}`);
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.log('');
logger.info('Proxmox API Settings:'); logger.info('Proxmox API Settings:');
logger.dim('Requires a Proxmox API token. Create one with:'); logger.dim('Create a token with: pveum user token add root@pam nupst --privsep=0');
logger.dim(' pveum user token add root@pam nupst --privsep=0');
const pxHost = await prompt('Proxmox Host [localhost]: '); const pxHost = await prompt('Proxmox Host [localhost]: ');
action.proxmoxHost = pxHost.trim() || 'localhost'; action.proxmoxHost = pxHost.trim() || 'localhost';
@@ -1174,21 +1235,28 @@ export class UpsHandler {
const tokenId = await prompt('API Token ID (e.g., root@pam!nupst): '); const tokenId = await prompt('API Token ID (e.g., root@pam!nupst): ');
if (!tokenId.trim()) { if (!tokenId.trim()) {
logger.warn('Token ID is required for Proxmox action, skipping'); logger.warn('Token ID is required for API mode, skipping');
continue; continue;
} }
action.proxmoxTokenId = tokenId.trim(); action.proxmoxTokenId = tokenId.trim();
const tokenSecret = await prompt('API Token Secret: '); const tokenSecret = await prompt('API Token Secret: ');
if (!tokenSecret.trim()) { if (!tokenSecret.trim()) {
logger.warn('Token Secret is required for Proxmox action, skipping'); logger.warn('Token Secret is required for API mode, skipping');
continue; continue;
} }
action.proxmoxTokenSecret = tokenSecret.trim(); action.proxmoxTokenSecret = tokenSecret.trim();
const insecureInput = await prompt('Skip TLS verification (self-signed cert)? (Y/n): ');
action.proxmoxInsecure = insecureInput.toLowerCase() !== 'n';
action.proxmoxMode = 'api';
}
// Common Proxmox settings (both modes)
const excludeInput = await prompt('VM/CT IDs to exclude (comma-separated, or empty): '); const excludeInput = await prompt('VM/CT IDs to exclude (comma-separated, or empty): ');
if (excludeInput.trim()) { 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]: '); const timeoutInput = await prompt('VM shutdown timeout in seconds [120]: ');
@@ -1197,12 +1265,9 @@ export class UpsHandler {
action.proxmoxStopTimeout = stopTimeout; 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'; 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.log('');
logger.info('Note: Place the Proxmox action BEFORE the shutdown action'); logger.info('Note: Place the Proxmox action BEFORE the shutdown action');
logger.dim('in the action chain so VMs shut down before the host.'); logger.dim('in the action chain so VMs shut down before the host.');
@@ -1296,6 +1361,7 @@ export class UpsHandler {
logger.logBoxLine(`SNMP Host: ${ups.snmp.host}:${ups.snmp.port}`); logger.logBoxLine(`SNMP Host: ${ups.snmp.host}:${ups.snmp.port}`);
logger.logBoxLine(`SNMP Version: ${ups.snmp.version}`); logger.logBoxLine(`SNMP Version: ${ups.snmp.version}`);
logger.logBoxLine(`UPS Model: ${ups.snmp.upsModel}`); logger.logBoxLine(`UPS Model: ${ups.snmp.upsModel}`);
logger.logBoxLine(`Runtime Unit: ${ups.snmp.runtimeUnit || 'auto'}`);
} }
if (ups.groups && ups.groups.length > 0) { 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 */ /** Proxmox API base path */
API_BASE: '/api2/json', 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; } as const;
/** /**
+155 -452
View File
@@ -1,8 +1,6 @@
import process from 'node:process'; import process from 'node:process';
import * as fs from 'node:fs'; import * as fs from 'node:fs';
import * as path from 'node:path'; 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 { NupstSnmp } from './snmp/manager.ts';
import type { ISnmpConfig, IUpsStatus as ISnmpUpsStatus } from './snmp/types.ts'; import type { ISnmpConfig, IUpsStatus as ISnmpUpsStatus } from './snmp/types.ts';
import { NupstUpsd } from './upsd/client.ts'; import { NupstUpsd } from './upsd/client.ts';
@@ -13,12 +11,29 @@ import { logger } from './logger.ts';
import { MigrationRunner } from './migrations/index.ts'; import { MigrationRunner } from './migrations/index.ts';
import { formatPowerStatus, getBatteryColor, getRuntimeColor, theme } from './colors.ts'; import { formatPowerStatus, getBatteryColor, getRuntimeColor, theme } from './colors.ts';
import type { IActionConfig } from './actions/base-action.ts'; import type { IActionConfig } from './actions/base-action.ts';
import { ActionManager, type IActionContext, type TPowerStatus } from './actions/index.ts'; import { ActionManager } from './actions/index.ts';
import { decideUpsActionExecution, type TUpsTriggerReason } from './action-orchestration.ts';
import { NupstHttpServer } from './http-server.ts'; import { NupstHttpServer } from './http-server.ts';
import { NETWORK, PAUSE, THRESHOLDS, TIMING, UI } from './constants.ts'; import { NETWORK, PAUSE, THRESHOLDS, TIMING, UI } from './constants.ts';
import {
const execAsync = promisify(exec); analyzeConfigReload,
const execFileAsync = promisify(execFile); 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 * UPS configuration interface
@@ -70,20 +85,6 @@ export interface IHttpServerConfig {
authToken: string; 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 * Configuration interface for the daemon
*/ */
@@ -113,25 +114,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 * Daemon class for monitoring UPS and handling shutdown
* Responsible for loading/saving config and monitoring the UPS status * Responsible for loading/saving config and monitoring the UPS status
@@ -142,7 +124,7 @@ export class NupstDaemon {
/** Default configuration */ /** Default configuration */
private readonly DEFAULT_CONFIG: INupstConfig = { private readonly DEFAULT_CONFIG: INupstConfig = {
version: '4.2', version: '4.3',
upsDevices: [ upsDevices: [
{ {
id: 'default', id: 'default',
@@ -162,6 +144,7 @@ export class NupstDaemon {
privKey: '', privKey: '',
// UPS model for OID selection // UPS model for OID selection
upsModel: 'cyberpower', upsModel: 'cyberpower',
runtimeUnit: 'ticks',
}, },
groups: [], groups: [],
actions: [ actions: [
@@ -190,6 +173,7 @@ export class NupstDaemon {
private pauseState: IPauseState | null = null; private pauseState: IPauseState | null = null;
private upsStatus: Map<string, IUpsStatus> = new Map(); private upsStatus: Map<string, IUpsStatus> = new Map();
private httpServer?: NupstHttpServer; private httpServer?: NupstHttpServer;
private readonly shutdownExecutor: ShutdownExecutor;
/** /**
* Create a new daemon instance with the given protocol managers * Create a new daemon instance with the given protocol managers
@@ -198,6 +182,7 @@ export class NupstDaemon {
this.snmp = snmp; this.snmp = snmp;
this.upsd = upsd; this.upsd = upsd;
this.protocolResolver = new ProtocolResolver(snmp, upsd); this.protocolResolver = new ProtocolResolver(snmp, upsd);
this.shutdownExecutor = new ShutdownExecutor();
this.config = this.DEFAULT_CONFIG; this.config = this.DEFAULT_CONFIG;
} }
@@ -260,7 +245,7 @@ export class NupstDaemon {
// Ensure version is always set and remove legacy fields before saving // Ensure version is always set and remove legacy fields before saving
const configToSave: INupstConfig = { const configToSave: INupstConfig = {
version: '4.2', version: '4.3',
upsDevices: config.upsDevices, upsDevices: config.upsDevices,
groups: config.groups, groups: config.groups,
checkInterval: config.checkInterval, checkInterval: config.checkInterval,
@@ -282,7 +267,7 @@ export class NupstDaemon {
private logConfigError(message: string): void { private logConfigError(message: string): void {
logger.logBox( logger.logBox(
'Configuration Error', 'Configuration Error',
[message, "Please run 'nupst setup' first to create a configuration."], [message, "Please run 'nupst ups add' first to create a configuration."],
45, 45,
'error', 'error',
); );
@@ -387,21 +372,7 @@ export class NupstDaemon {
if (this.config.upsDevices && this.config.upsDevices.length > 0) { if (this.config.upsDevices && this.config.upsDevices.length > 0) {
for (const ups of this.config.upsDevices) { for (const ups of this.config.upsDevices) {
this.upsStatus.set(ups.id, { this.upsStatus.set(ups.id, createInitialUpsStatus(ups));
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,
});
} }
logger.log(`Initialized status tracking for ${this.config.upsDevices.length} UPS devices`); logger.log(`Initialized status tracking for ${this.config.upsDevices.length} UPS devices`);
@@ -506,66 +477,39 @@ export class NupstDaemon {
* Check and update pause state from the pause file * Check and update pause state from the pause file
*/ */
private checkPauseState(): void { private checkPauseState(): void {
try { const snapshot = loadPauseSnapshot(PAUSE.FILE_PATH, this.isPaused);
if (fs.existsSync(PAUSE.FILE_PATH)) {
const data = fs.readFileSync(PAUSE.FILE_PATH, 'utf8');
const state = JSON.parse(data) as IPauseState;
// Check if auto-resume time has passed if (snapshot.transition === 'autoResumed') {
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.log('');
logger.logBoxTitle('Auto-Resume', 45, 'success'); logger.logBoxTitle('Auto-Resume', 45, 'success');
logger.logBoxLine('Pause duration expired, resuming action monitoring'); logger.logBoxLine('Pause duration expired, resuming action monitoring');
logger.logBoxEnd(); logger.logBoxEnd();
logger.log(''); logger.log('');
} } else if (snapshot.transition === 'paused' && snapshot.pauseState) {
this.isPaused = false;
this.pauseState = null;
return;
}
if (!this.isPaused) {
logger.log(''); logger.log('');
logger.logBoxTitle('Actions Paused', 45, 'warning'); logger.logBoxTitle('Actions Paused', 45, 'warning');
logger.logBoxLine(`Paused by: ${state.pausedBy}`); logger.logBoxLine(`Paused by: ${snapshot.pauseState.pausedBy}`);
if (state.reason) { if (snapshot.pauseState.reason) {
logger.logBoxLine(`Reason: ${state.reason}`); logger.logBoxLine(`Reason: ${snapshot.pauseState.reason}`);
} }
if (state.resumeAt) { if (snapshot.pauseState.resumeAt) {
const remaining = Math.round((state.resumeAt - Date.now()) / 1000); const remaining = Math.round((snapshot.pauseState.resumeAt - Date.now()) / 1000);
logger.logBoxLine(`Auto-resume in: ${remaining} seconds`); logger.logBoxLine(`Auto-resume in: ${remaining} seconds`);
} else { } else {
logger.logBoxLine('Duration: Indefinite (run "nupst resume" to resume)'); logger.logBoxLine('Duration: Indefinite (run "nupst resume" to resume)');
} }
logger.logBoxEnd(); logger.logBoxEnd();
logger.log(''); logger.log('');
} } else if (snapshot.transition === 'resumed') {
this.isPaused = true;
this.pauseState = state;
} else {
if (this.isPaused) {
logger.log(''); logger.log('');
logger.logBoxTitle('Actions Resumed', 45, 'success'); logger.logBoxTitle('Actions Resumed', 45, 'success');
logger.logBoxLine('Action monitoring has been resumed'); logger.logBoxLine('Action monitoring has been resumed');
logger.logBoxEnd(); logger.logBoxEnd();
logger.log(''); logger.log('');
} }
this.isPaused = false;
this.pauseState = null; this.isPaused = snapshot.isPaused;
} this.pauseState = snapshot.pauseState;
} catch (_error) {
// If we can't read the pause file, assume not paused
this.isPaused = false;
this.pauseState = null;
}
} }
/** /**
@@ -618,25 +562,8 @@ export class NupstDaemon {
private async checkAllUpsDevices(): Promise<void> { private async checkAllUpsDevices(): Promise<void> {
for (const ups of this.config.upsDevices) { for (const ups of this.config.upsDevices) {
try { try {
const upsStatus = this.upsStatus.get(ups.id); const initialStatus = ensureUpsStatus(this.upsStatus.get(ups.id), ups);
if (!upsStatus) { this.upsStatus.set(ups.id, initialStatus);
// 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,
});
}
// Check UPS status via configured protocol // Check UPS status via configured protocol
const protocol = ups.protocol || 'snmp'; const protocol = ups.protocol || 'snmp';
@@ -645,129 +572,100 @@ export class NupstDaemon {
: await this.protocolResolver.getUpsStatus('snmp', ups.snmp); : await this.protocolResolver.getUpsStatus('snmp', ups.snmp);
const currentTime = Date.now(); const currentTime = Date.now();
// Get the current status from the map
const currentStatus = this.upsStatus.get(ups.id); const currentStatus = this.upsStatus.get(ups.id);
const pollSnapshot = buildSuccessfulUpsPollSnapshot(
ups,
status,
currentStatus,
currentTime,
);
// Successful query: reset consecutive failures if (pollSnapshot.transition === 'recovered' && pollSnapshot.previousStatus) {
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);
logger.log(''); logger.log('');
logger.logBoxTitle(`UPS Recovered: ${ups.name}`, 60, 'success'); 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(`Current Status: ${formatPowerStatus(status.powerStatus)}`);
logger.logBoxLine(`Time: ${new Date().toISOString()}`); logger.logBoxLine(`Time: ${new Date().toISOString()}`);
logger.logBoxEnd(); logger.logBoxEnd();
logger.log(''); logger.log('');
updatedStatus.lastStatusChange = currentTime;
// Trigger power status change action for recovery // Trigger power status change action for recovery
await this.triggerUpsActions(ups, updatedStatus, currentStatus, 'powerStatusChange'); await this.triggerUpsActions(
} else if (currentStatus && currentStatus.powerStatus !== status.powerStatus) { ups,
// Check if power status changed pollSnapshot.updatedStatus,
pollSnapshot.previousStatus,
'powerStatusChange',
);
} else if (pollSnapshot.transition === 'powerStatusChange' && pollSnapshot.previousStatus) {
logger.log(''); logger.log('');
logger.logBoxTitle(`Power Status Change: ${ups.name}`, 60, 'warning'); 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(`Current: ${formatPowerStatus(status.powerStatus)}`);
logger.logBoxLine(`Time: ${new Date().toISOString()}`); logger.logBoxLine(`Time: ${new Date().toISOString()}`);
logger.logBoxEnd(); logger.logBoxEnd();
logger.log(''); logger.log('');
updatedStatus.lastStatusChange = currentTime;
// Trigger actions for power status change // 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 ( if (
status.batteryCapacity < actionConfig.thresholds.battery || hasThresholdViolation(
status.batteryRuntime < actionConfig.thresholds.runtime status.powerStatus,
status.batteryCapacity,
status.batteryRuntime,
ups.actions,
)
) { ) {
anyThresholdExceeded = true; await this.triggerUpsActions(
break; ups,
} pollSnapshot.updatedStatus,
} pollSnapshot.previousStatus,
} 'thresholdViolation',
);
// 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');
}
} }
// Update the status in the map // Update the status in the map
this.upsStatus.set(ups.id, updatedStatus); this.upsStatus.set(ups.id, pollSnapshot.updatedStatus);
} catch (error) { } catch (error) {
// Network loss / query failure tracking const currentTime = Date.now();
const currentStatus = this.upsStatus.get(ups.id); const currentStatus = this.upsStatus.get(ups.id);
const failures = Math.min( const failureSnapshot = buildFailedUpsPollSnapshot(ups, currentStatus, currentTime);
(currentStatus?.consecutiveFailures || 0) + 1,
NETWORK.MAX_CONSECUTIVE_FAILURES,
);
logger.error( 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) error instanceof Error ? error.message : String(error)
}`, }`,
); );
// Transition to unreachable after threshold consecutive failures if (failureSnapshot.transition === 'unreachable' && failureSnapshot.previousStatus) {
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);
logger.log(''); logger.log('');
logger.logBoxTitle(`UPS Unreachable: ${ups.name}`, 60, 'error'); logger.logBoxTitle(`UPS Unreachable: ${ups.name}`, 60, 'error');
logger.logBoxLine(`${failures} consecutive communication failures`); logger.logBoxLine(`${failureSnapshot.failures} consecutive communication failures`);
logger.logBoxLine(`Last known status: ${formatPowerStatus(previousStatus.powerStatus)}`); logger.logBoxLine(
`Last known status: ${formatPowerStatus(failureSnapshot.previousStatus.powerStatus)}`,
);
logger.logBoxLine(`Time: ${new Date().toISOString()}`); logger.logBoxLine(`Time: ${new Date().toISOString()}`);
logger.logBoxEnd(); logger.logBoxEnd();
logger.log(''); logger.log('');
// Trigger power status change action for unreachable // Trigger power status change action for unreachable
await this.triggerUpsActions(ups, currentStatus, previousStatus, 'powerStatusChange'); await this.triggerUpsActions(
} else if (currentStatus) { ups,
currentStatus.consecutiveFailures = failures; failureSnapshot.updatedStatus,
this.upsStatus.set(ups.id, currentStatus); failureSnapshot.previousStatus,
'powerStatusChange',
);
} }
this.upsStatus.set(ups.id, failureSnapshot.updatedStatus);
} }
} }
} }
@@ -780,7 +678,11 @@ export class NupstDaemon {
logger.log(''); logger.log('');
const pauseLabel = this.isPaused ? ' [PAUSED]' : ''; 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}`); logger.logBoxLine(`Timestamp: ${timestamp}`);
if (this.isPaused && this.pauseState) { if (this.isPaused && this.pauseState) {
logger.logBoxLine(`Actions paused by: ${this.pauseState.pausedBy}`); logger.logBoxLine(`Actions paused by: ${this.pauseState.pausedBy}`);
@@ -821,30 +723,6 @@ export class NupstDaemon {
logger.log(''); 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 * Trigger actions for a UPS device
* @param ups UPS configuration * @param ups UPS configuration
@@ -856,35 +734,31 @@ export class NupstDaemon {
ups: IUpsConfig, ups: IUpsConfig,
status: IUpsStatus, status: IUpsStatus,
previousStatus: IUpsStatus | undefined, previousStatus: IUpsStatus | undefined,
triggerReason: 'powerStatusChange' | 'thresholdViolation', triggerReason: TUpsTriggerReason,
): Promise<void> { ): Promise<void> {
// Check if actions are paused const decision = decideUpsActionExecution(
if (this.isPaused) { this.isPaused,
logger.info( ups,
`[PAUSED] Actions suppressed for UPS ${ups.name} (trigger: ${triggerReason})`, status,
previousStatus,
triggerReason,
); );
if (decision.type === 'suppressed') {
logger.info(decision.message);
return; return;
} }
const actions = ups.actions || []; if (decision.type === 'legacyShutdown') {
await this.initiateShutdown(decision.reason);
// 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`);
return; return;
} }
if (actions.length === 0) { if (decision.type === 'skip') {
return; // No actions to execute return;
} }
// Build action context await ActionManager.executeActions(decision.actions, decision.context);
const context = this.buildActionContext(ups, status, triggerReason);
context.previousPowerStatus = (previousStatus?.powerStatus || 'unknown') as TPowerStatus;
// Execute actions
await ActionManager.executeActions(actions, context);
} }
/** /**
@@ -898,56 +772,8 @@ export class NupstDaemon {
const shutdownDelayMinutes = 5; const shutdownDelayMinutes = 5;
try { try {
// Find shutdown command in common system paths await this.shutdownExecutor.scheduleShutdown(shutdownDelayMinutes);
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`); 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)}`,
);
}
}
// Monitor UPS during shutdown and force immediate shutdown if battery gets too low // Monitor UPS during shutdown and force immediate shutdown if battery gets too low
logger.log('Monitoring UPS during shutdown process...'); logger.log('Monitoring UPS during shutdown process...');
@@ -955,53 +781,12 @@ export class NupstDaemon {
} catch (error) { } catch (error) {
logger.error(`Failed to initiate shutdown: ${error}`); logger.error(`Failed to initiate shutdown: ${error}`);
// Try alternative shutdown methods const shutdownTriggered = await this.shutdownExecutor.tryScheduledAlternatives();
const alternatives = [ if (!shutdownTriggered) {
{ 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
}
}
logger.error('All shutdown methods failed'); logger.error('All shutdown methods failed');
} }
} }
}
/** /**
* Monitor UPS during system shutdown * Monitor UPS during system shutdown
@@ -1036,7 +821,6 @@ export class NupstDaemon {
]; ];
const rows: Array<Record<string, string>> = []; const rows: Array<Record<string, string>> = [];
let emergencyDetected = false;
let emergencyUps: { ups: IUpsConfig; status: ISnmpUpsStatus } | null = null; let emergencyUps: { ups: IUpsConfig; status: ISnmpUpsStatus } | null = null;
// Check all UPS devices // Check all UPS devices
@@ -1046,31 +830,30 @@ export class NupstDaemon {
const status = protocol === 'upsd' && ups.upsd const status = protocol === 'upsd' && ups.upsd
? await this.protocolResolver.getUpsStatus('upsd', undefined, ups.upsd) ? await this.protocolResolver.getUpsStatus('upsd', undefined, ups.upsd)
: await this.protocolResolver.getUpsStatus('snmp', ups.snmp); : 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); rows.push(rowSnapshot.row);
const runtimeColor = getRuntimeColor(status.batteryRuntime); emergencyUps = selectEmergencyCandidate(
emergencyUps,
const isCritical = status.batteryRuntime < THRESHOLDS.EMERGENCY_RUNTIME_MINUTES; ups,
status,
rows.push({ THRESHOLDS.EMERGENCY_RUNTIME_MINUTES,
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 };
}
} catch (upsError) { } catch (upsError) {
rows.push({ rows.push(buildShutdownErrorRow(ups.name, theme.error));
name: ups.name,
battery: theme.error('N/A'),
runtime: theme.error('N/A'),
status: theme.error('ERROR'),
});
logger.error( logger.error(
`Error checking UPS ${ups.name} during shutdown: ${ `Error checking UPS ${ups.name} during shutdown: ${
@@ -1085,7 +868,7 @@ export class NupstDaemon {
logger.log(''); logger.log('');
// If emergency detected, trigger immediate shutdown // If emergency detected, trigger immediate shutdown
if (emergencyDetected && emergencyUps) { if (emergencyUps) {
logger.log(''); logger.log('');
logger.logBoxTitle('EMERGENCY SHUTDOWN', 60, 'error'); logger.logBoxTitle('EMERGENCY SHUTDOWN', 60, 'error');
logger.logBoxLine( logger.logBoxLine(
@@ -1123,88 +906,16 @@ export class NupstDaemon {
*/ */
private async forceImmediateShutdown(): Promise<void> { private async forceImmediateShutdown(): Promise<void> {
try { try {
// Find shutdown command in common system paths await this.shutdownExecutor.forceImmediateShutdown();
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
},
);
}
} catch (error) { } catch (error) {
logger.error('Emergency shutdown failed, trying alternative methods...'); logger.error('Emergency shutdown failed, trying alternative methods...');
// Try alternative shutdown methods in sequence const shutdownTriggered = await this.shutdownExecutor.tryEmergencyAlternatives();
const alternatives = [ if (!shutdownTriggered) {
{ 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
}
}
logger.error('All emergency shutdown methods failed'); logger.error('All emergency shutdown methods failed');
} }
} }
}
/** /**
* Idle monitoring loop when no UPS devices are configured * Idle monitoring loop when no UPS devices are configured
@@ -1275,19 +986,13 @@ export class NupstDaemon {
for await (const event of watcher) { for await (const event of watcher) {
// Respond to modify events on config file // Respond to modify events on config file
if ( if (shouldReloadConfig(event)) {
event.kind === 'modify' &&
event.paths.some((p) => p.includes('config.json'))
) {
logger.info('Config file changed, reloading...'); logger.info('Config file changed, reloading...');
await this.reloadConfig(); await this.reloadConfig();
} }
// Detect pause file changes // Detect pause file changes
if ( if (shouldRefreshPauseState(event)) {
(event.kind === 'create' || event.kind === 'modify' || event.kind === 'remove') &&
event.paths.some((p) => p.includes('pause'))
) {
this.checkPauseState(); this.checkPauseState();
} }
@@ -1321,18 +1026,16 @@ export class NupstDaemon {
await this.loadConfig(); await this.loadConfig();
const newDeviceCount = this.config.upsDevices?.length || 0; const newDeviceCount = this.config.upsDevices?.length || 0;
if (newDeviceCount > 0 && oldDeviceCount === 0) { const reloadSnapshot = analyzeConfigReload(oldDeviceCount, newDeviceCount);
logger.success(`Configuration reloaded! Found ${newDeviceCount} UPS device(s)`); logger.success(reloadSnapshot.message);
logger.info('Monitoring will start automatically...');
} else if (newDeviceCount !== oldDeviceCount) {
logger.success(
`Configuration reloaded! UPS devices: ${oldDeviceCount}${newDeviceCount}`,
);
if (reloadSnapshot.shouldLogMonitoringStart) {
logger.info('Monitoring will start automatically...');
}
if (reloadSnapshot.shouldInitializeUpsStatus) {
// Reinitialize UPS status tracking // Reinitialize UPS status tracking
this.initializeUpsStatus(); this.initializeUpsStatus();
} else {
logger.success('Configuration reloaded successfully');
} }
} catch (error) { } catch (error) {
logger.warn( logger.warn(
+2 -1
View File
@@ -1,7 +1,8 @@
import * as http from 'node:http'; import * as http from 'node:http';
import { URL } from 'node:url'; import { URL } from 'node:url';
import { logger } from './logger.ts'; 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 * HTTP Server for exposing UPS status as JSON
+1 -1
View File
@@ -10,7 +10,7 @@ import process from 'node:process';
*/ */
async function main() { async function main() {
const cli = new NupstCli(); const cli = new NupstCli();
await cli.parseAndExecute(process.argv); await cli.parseAndExecute(process.argv.slice(2));
} }
// Run the main function and handle any errors // 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 { MigrationV3ToV4 } from './migration-v3-to-v4.ts';
export { MigrationV4_0ToV4_1 } from './migration-v4.0-to-v4.1.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_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 { MigrationV3ToV4 } from './migration-v3-to-v4.ts';
import { MigrationV4_0ToV4_1 } from './migration-v4.0-to-v4.1.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_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'; import { logger } from '../logger.ts';
/** /**
@@ -21,6 +22,7 @@ export class MigrationRunner {
new MigrationV3ToV4(), new MigrationV3ToV4(),
new MigrationV4_0ToV4_1(), new MigrationV4_0ToV4_1(),
new MigrationV4_1ToV4_2(), new MigrationV4_1ToV4_2(),
new MigrationV4_2ToV4_3(),
]; ];
// Sort by version order to ensure they run in sequence // Sort by version order to ensure they run in sequence
@@ -56,7 +58,7 @@ export class MigrationRunner {
if (anyMigrationsRan) { if (anyMigrationsRan) {
logger.success('Configuration migrations complete'); logger.success('Configuration migrations complete');
} else { } else {
logger.success('config format ok'); logger.success('Configuration format OK');
} }
return { return {
+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 };
}
+263 -167
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 { Buffer } from 'node:buffer';
import type { IOidSet, ISnmpConfig, IUpsStatus, TUpsModel } from './types.ts'; import type { IOidSet, ISnmpConfig, IUpsStatus, TUpsModel } from './types.ts';
import { UpsOidSets } from './oid-sets.ts'; import { UpsOidSets } from './oid-sets.ts';
@@ -6,6 +6,73 @@ import { SNMP } from '../constants.ts';
import { logger } from '../logger.ts'; import { logger } from '../logger.ts';
import type { INupstAccessor } from '../interfaces/index.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 * Class for SNMP communication with UPS devices
* Main entry point for SNMP functionality * 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 * Send an SNMP GET request using the net-snmp package
* @param oid OID to query * @param oid OID to query
@@ -95,130 +276,39 @@ export class NupstSnmp {
oid: string, oid: string,
config = this.DEFAULT_CONFIG, config = this.DEFAULT_CONFIG,
_retryCount = 0, _retryCount = 0,
// deno-lint-ignore no-explicit-any ): Promise<TSnmpValue> {
): Promise<any> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (this.debug) { if (this.debug) {
logger.dim( logger.dim(
`Sending SNMP v${config.version} GET request for OID ${oid} to ${config.host}:${config.port}`, `Sending SNMP v${config.version} GET request for OID ${oid} to ${config.host}:${config.port}`,
); );
if (config.version === 1 || config.version === 2) {
logger.dim(`Using community: ${config.community}`); 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 const options = this.createSessionOptions(config);
let session; const session: ISnmpSession = config.version === 3
? (() => {
if (config.version === 3) { const { user, levelLabel } = this.buildV3User(config);
// 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 (this.debug) { if (this.debug) {
const levelName = Object.keys(snmp.SecurityLevel).find((key) =>
snmp.SecurityLevel[key] === user.level
);
logger.dim( logger.dim(
`SNMPv3 user configuration: name=${user.name}, level=${levelName}, authProtocol=${ `SNMPv3 user configuration: name=${user.name}, level=${levelLabel}, authProtocol=${
user.authProtocol ? 'Set' : 'Not Set' user.authProtocol ? 'Set' : 'Not Set'
}, privProtocol=${user.privProtocol ? 'Set' : 'Not Set'}`, }, privProtocol=${user.privProtocol ? 'Set' : 'Not Set'}`,
); );
} }
session = snmp.createV3Session(config.host, user, options); return snmpLib.createV3Session(config.host, user, options);
} else { })()
// For SNMPv1/v2c, we use the community string : snmpLib.createSession(config.host, config.community || 'public', options);
session = snmp.createSession(config.host, config.community || 'public', options);
}
// Convert the OID string to an array of OIDs if multiple OIDs are needed // Convert the OID string to an array of OIDs if multiple OIDs are needed
const oids = [oid]; const oids = [oid];
// Send the GET request // Send the GET request
// deno-lint-ignore no-explicit-any session.get(oids, (error: Error | null, varbinds?: ISnmpVarbind[]) => {
session.get(oids, (error: Error | null, varbinds: any[]) => {
// Close the session to release resources // Close the session to release resources
session.close(); session.close();
@@ -230,7 +320,9 @@ export class NupstSnmp {
return; return;
} }
if (!varbinds || varbinds.length === 0) { const varbind = varbinds?.[0];
if (!varbind) {
if (this.debug) { if (this.debug) {
logger.error('No varbinds returned in response'); logger.error('No varbinds returned in response');
} }
@@ -239,36 +331,20 @@ export class NupstSnmp {
} }
// Check for SNMP errors in the response // Check for SNMP errors in the response
if ( if (snmpLib.isVarbindError(varbind)) {
varbinds[0].type === snmp.ObjectType.NoSuchObject || const errorMessage = snmpLib.varbindError(varbind);
varbinds[0].type === snmp.ObjectType.NoSuchInstance ||
varbinds[0].type === snmp.ObjectType.EndOfMibView
) {
if (this.debug) { 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; return;
} }
// Process the response value based on its type const value = this.normalizeSnmpValue(varbind.value);
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);
}
if (this.debug) { if (this.debug) {
logger.dim( 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 // Get all values with independent retry logic
const powerStatusValue = await this.getSNMPValueWithRetry( const powerStatusValue = this.coerceNumericSnmpValue(
this.activeOIDs.POWER_STATUS, await this.getSNMPValueWithRetry(this.activeOIDs.POWER_STATUS, 'power status', config),
'power status', 'power status',
config,
); );
const batteryCapacity = await this.getSNMPValueWithRetry( const batteryCapacity = this.coerceNumericSnmpValue(
await this.getSNMPValueWithRetry(
this.activeOIDs.BATTERY_CAPACITY, this.activeOIDs.BATTERY_CAPACITY,
'battery capacity', 'battery capacity',
config, config,
) || 0; ),
const batteryRuntime = await this.getSNMPValueWithRetry( 'battery capacity',
);
const batteryRuntime = this.coerceNumericSnmpValue(
await this.getSNMPValueWithRetry(
this.activeOIDs.BATTERY_RUNTIME, this.activeOIDs.BATTERY_RUNTIME,
'battery runtime', 'battery runtime',
config, config,
) || 0; ),
'battery runtime',
);
// Get power draw metrics // Get power draw metrics
const outputLoad = await this.getSNMPValueWithRetry( const outputLoad = this.coerceNumericSnmpValue(
this.activeOIDs.OUTPUT_LOAD, await this.getSNMPValueWithRetry(this.activeOIDs.OUTPUT_LOAD, 'output load', config),
'output load', 'output load',
config, );
) || 0; const outputPower = this.coerceNumericSnmpValue(
const outputPower = await this.getSNMPValueWithRetry( await this.getSNMPValueWithRetry(this.activeOIDs.OUTPUT_POWER, 'output power', config),
this.activeOIDs.OUTPUT_POWER,
'output power', 'output power',
config, );
) || 0; const outputVoltage = this.coerceNumericSnmpValue(
const outputVoltage = await this.getSNMPValueWithRetry( await this.getSNMPValueWithRetry(this.activeOIDs.OUTPUT_VOLTAGE, 'output voltage', config),
this.activeOIDs.OUTPUT_VOLTAGE,
'output voltage', 'output voltage',
config, );
) || 0; const outputCurrent = this.coerceNumericSnmpValue(
const outputCurrent = await this.getSNMPValueWithRetry( await this.getSNMPValueWithRetry(this.activeOIDs.OUTPUT_CURRENT, 'output current', config),
this.activeOIDs.OUTPUT_CURRENT,
'output current', 'output current',
config, );
) || 0;
// Determine power status - handle different values for different UPS models // Determine power status - handle different values for different UPS models
const powerStatus = this.determinePowerStatus(config.upsModel, powerStatusValue); const powerStatus = this.determinePowerStatus(config.upsModel, powerStatusValue);
// Convert to minutes for UPS models with different time units // Convert to minutes for UPS models with different time units
const processedRuntime = this.processRuntimeValue(config.upsModel, batteryRuntime); const processedRuntime = this.processRuntimeValue(config, batteryRuntime);
// Process power metrics with vendor-specific scaling // Process power metrics with vendor-specific scaling
const processedVoltage = this.processVoltageValue(config.upsModel, outputVoltage); const processedVoltage = this.processVoltageValue(config.upsModel, outputVoltage);
@@ -430,10 +507,9 @@ export class NupstSnmp {
*/ */
private async getSNMPValueWithRetry( private async getSNMPValueWithRetry(
oid: string, oid: string,
description: string, description: TSnmpMetricDescription,
config: ISnmpConfig, config: ISnmpConfig,
// deno-lint-ignore no-explicit-any ): Promise<TSnmpValue | 0> {
): Promise<any> {
if (oid === '') { if (oid === '') {
if (this.debug) { if (this.debug) {
logger.dim(`No OID provided for ${description}, skipping`); logger.dim(`No OID provided for ${description}, skipping`);
@@ -485,10 +561,9 @@ export class NupstSnmp {
*/ */
private async tryFallbackSecurityLevels( private async tryFallbackSecurityLevels(
oid: string, oid: string,
description: string, description: TSnmpMetricDescription,
config: ISnmpConfig, config: ISnmpConfig,
// deno-lint-ignore no-explicit-any ): Promise<TSnmpValue | 0> {
): Promise<any> {
if (this.debug) { if (this.debug) {
logger.dim(`Retrying ${description} with fallback security level...`); logger.dim(`Retrying ${description} with fallback security level...`);
} }
@@ -551,10 +626,9 @@ export class NupstSnmp {
*/ */
private async tryStandardOids( private async tryStandardOids(
_oid: string, _oid: string,
description: string, description: TSnmpMetricDescription,
config: ISnmpConfig, config: ISnmpConfig,
// deno-lint-ignore no-explicit-any ): Promise<TSnmpValue | 0> {
): Promise<any> {
try { try {
// Try RFC 1628 standard UPS MIB OIDs // Try RFC 1628 standard UPS MIB OIDs
const standardOIDs = UpsOidSets.getStandardOids(); const standardOIDs = UpsOidSets.getStandardOids();
@@ -620,22 +694,46 @@ export class NupstSnmp {
} }
/** /**
* Process runtime value based on UPS model * Process runtime value based on config runtimeUnit or UPS model
* @param upsModel UPS model * @param config SNMP configuration (uses runtimeUnit if set, otherwise falls back to upsModel)
* @param batteryRuntime Raw battery runtime value * @param batteryRuntime Raw battery runtime value
* @returns Processed runtime in minutes * @returns Processed runtime in minutes
*/ */
private processRuntimeValue( private processRuntimeValue(
upsModel: TUpsModel | undefined, config: ISnmpConfig,
batteryRuntime: number, batteryRuntime: number,
): number { ): number {
if (this.debug) { if (this.debug) {
logger.dim(`Raw runtime value: ${batteryRuntime}`); 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) { if (upsModel === 'cyberpower' && batteryRuntime > 0) {
// CyberPower: TimeTicks is in 1/100 seconds, convert to minutes const minutes = Math.floor(batteryRuntime / 6000);
const minutes = Math.floor(batteryRuntime / 6000); // 6000 ticks = 1 minute
if (this.debug) { if (this.debug) {
logger.dim( logger.dim(
`Converting CyberPower runtime from ${batteryRuntime} ticks to ${minutes} minutes`, `Converting CyberPower runtime from ${batteryRuntime} ticks to ${minutes} minutes`,
@@ -643,7 +741,6 @@ export class NupstSnmp {
} }
return minutes; return minutes;
} else if (upsModel === 'eaton' && batteryRuntime > 0) { } else if (upsModel === 'eaton' && batteryRuntime > 0) {
// Eaton: Runtime is in seconds, convert to minutes
const minutes = Math.floor(batteryRuntime / 60); const minutes = Math.floor(batteryRuntime / 60);
if (this.debug) { if (this.debug) {
logger.dim( logger.dim(
@@ -652,10 +749,9 @@ export class NupstSnmp {
} }
return minutes; return minutes;
} else if (batteryRuntime > 10000) { } else if (batteryRuntime > 10000) {
// Generic conversion for large tick values (likely TimeTicks)
const minutes = Math.floor(batteryRuntime / 6000); const minutes = Math.floor(batteryRuntime / 6000);
if (this.debug) { if (this.debug) {
logger.dim(`Converting ${batteryRuntime} ticks to ${minutes} minutes`); logger.dim(`Converting ${batteryRuntime} ticks to ${minutes} minutes (heuristic)`);
} }
return minutes; return minutes;
} }
+7
View File
@@ -58,6 +58,11 @@ export interface IOidSet {
*/ */
export type TUpsModel = 'cyberpower' | 'apc' | 'eaton' | 'tripplite' | 'liebert' | 'custom'; 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 * SNMP Configuration interface
*/ */
@@ -96,6 +101,8 @@ export interface ISnmpConfig {
upsModel?: TUpsModel; upsModel?: TUpsModel;
/** Custom OIDs when using custom UPS model */ /** Custom OIDs when using custom UPS model */
customOIDs?: IOidSet; customOIDs?: IOidSet;
/** Unit of the battery runtime SNMP value. Overrides model-based auto-detection when set. */
runtimeUnit?: TRuntimeUnit;
} }
/** /**
+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,
};
}