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