Compare commits

...

6 Commits

Author SHA1 Message Date
jkunz e916ccf3ae v5.7.0
Release / build-and-release (push) Successful in 53s
2026-04-16 02:54:16 +00:00
jkunz a435bd6fed feat(monitoring): add edge-triggered threshold handling with group action orchestration and HA-aware Proxmox shutdowns 2026-04-16 02:54:16 +00:00
jkunz bf4d519428 v5.6.0
Release / build-and-release (push) Successful in 54s
2026-04-14 18:47:37 +00:00
jkunz 579667b3cd feat(config): add configurable default shutdown delay for shutdown actions 2026-04-14 18:47:37 +00:00
jkunz 8dc0248763 v5.5.1
Release / build-and-release (push) Successful in 51s
2026-04-14 14:27:29 +00:00
jkunz 1f542ca271 fix(cli,daemon,snmp): normalize CLI argument parsing and extract daemon monitoring helpers with stronger SNMP typing 2026-04-14 14:27:29 +00:00
32 changed files with 2725 additions and 822 deletions
+23
View File
@@ -1,5 +1,28 @@
# Changelog # Changelog
## 2026-04-16 - 5.7.0 - feat(monitoring)
add edge-triggered threshold handling with group action orchestration and HA-aware Proxmox shutdowns
- Track per-action threshold entry state so threshold-based actions fire only when conditions are newly violated
- Add group monitoring and threshold evaluation for redundant and non-redundant UPS groups, including suppression of destructive actions when members are unreachable
- Support optional Proxmox HA stop requests for HA-managed guests and prevent duplicate Proxmox or host shutdown scheduling
## 2026-04-14 - 5.6.0 - feat(config)
add configurable default shutdown delay for shutdown actions
- introduces a top-level defaultShutdownDelay config value used by shutdown actions that do not define their own delay
- applies the configured default during action execution, daemon-initiated shutdowns, CLI prompts, and status display output
- preserves explicit shutdownDelay values including 0 minutes and normalizes invalid config values back to the built-in default
## 2026-04-14 - 5.5.1 - fix(cli,daemon,snmp)
normalize CLI argument parsing and extract daemon monitoring helpers with stronger SNMP typing
- Pass runtime arguments directly to the CLI in both Deno and Node entrypoints so commands and debug flags are parsed consistently
- Refactor daemon logic into dedicated pause state, config watch, UPS status, monitoring, action orchestration, shutdown execution, and shutdown monitoring modules
- Add explicit local typings and value coercion around net-snmp interactions to reduce untyped response handling
- Update user-facing CLI guidance to use current subcommands such as "nupst ups add", "nupst ups edit", and "nupst service start"
- Expand test coverage for extracted monitoring and pause-state helpers
## 2026-04-02 - 5.5.0 - feat(proxmox) ## 2026-04-02 - 5.5.0 - feat(proxmox)
add Proxmox CLI auto-detection and interactive action setup improvements add Proxmox CLI auto-detection and interactive action setup improvements
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@serve.zone/nupst", "name": "@serve.zone/nupst",
"version": "5.5.0", "version": "5.7.0",
"exports": "./mod.ts", "exports": "./mod.ts",
"nodeModulesDir": "auto", "nodeModulesDir": "auto",
"tasks": { "tasks": {
+1 -6
View File
@@ -25,12 +25,7 @@ import { NupstCli } from './ts/cli.ts';
*/ */
async function main(): Promise<void> { async function main(): Promise<void> {
const cli = new NupstCli(); const cli = new NupstCli();
await cli.parseAndExecute(Deno.args);
// Deno.args is already 0-indexed (unlike Node's process.argv which starts at index 2)
// We need to prepend placeholder args to match the existing CLI parser expectations
const args = ['deno', 'mod.ts', ...Deno.args];
await cli.parseAndExecute(args);
} }
// Execute main and handle errors // Execute main and handle errors
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@serve.zone/nupst", "name": "@serve.zone/nupst",
"version": "5.5.0", "version": "5.7.0",
"description": "Network UPS Shutdown Tool - Monitor SNMP-enabled UPS devices and orchestrate graceful system shutdowns during power emergencies", "description": "Network UPS Shutdown Tool - Monitor SNMP-enabled UPS devices and orchestrate graceful system shutdowns during power emergencies",
"keywords": [ "keywords": [
"ups", "ups",
+66 -3
View File
@@ -36,9 +36,14 @@
- Uses: `IUpsConfig`, `INupstConfig`, `ISnmpConfig`, `IActionConfig`, `IThresholds`, - Uses: `IUpsConfig`, `INupstConfig`, `ISnmpConfig`, `IActionConfig`, `IThresholds`,
`ISnmpUpsStatus` `ISnmpUpsStatus`
7. **SNMP Manager Boundary Types (`ts/snmp/manager.ts`)**
- Added local wrapper interfaces for the untyped `net-snmp` package surface used by NUPST
- SNMP metric reads now coerce values explicitly instead of relying on `any`-typed responses
## Features Added (February 2026) ## Features Added (February 2026)
### Network Loss Handling ### Network Loss Handling
- `TPowerStatus` extended with `'unreachable'` state - `TPowerStatus` extended with `'unreachable'` state
- `IUpsStatus` has `consecutiveFailures` and `unreachableSince` tracking - `IUpsStatus` has `consecutiveFailures` and `unreachableSince` tracking
- After `NETWORK.CONSECUTIVE_FAILURE_THRESHOLD` (3) failures, UPS transitions to `unreachable` - After `NETWORK.CONSECUTIVE_FAILURE_THRESHOLD` (3) failures, UPS transitions to `unreachable`
@@ -46,22 +51,63 @@
- Recovery is logged when UPS comes back from unreachable - Recovery is logged when UPS comes back from unreachable
### UPSD/NIS Protocol Support ### UPSD/NIS Protocol Support
- New `ts/upsd/` directory with TCP client for NUT (Network UPS Tools) servers - New `ts/upsd/` directory with TCP client for NUT (Network UPS Tools) servers
- `ts/protocol/` directory with `ProtocolResolver` for protocol-agnostic status queries - `ts/protocol/` directory with `ProtocolResolver` for protocol-agnostic status queries
- `IUpsConfig.protocol` field: `'snmp'` (default) or `'upsd'` - `IUpsConfig.protocol` field: `'snmp'` (default) or `'upsd'`
- `IUpsConfig.snmp` is now optional (not needed for UPSD devices) - `IUpsConfig.snmp` is now optional (not needed for UPSD devices)
- CLI supports protocol selection during `nupst ups add` - CLI supports protocol selection during `nupst ups add`
- Config version bumped to `4.2` with migration from `4.1` - Config version is now `4.3`, including the `4.2` -> `4.3` runtime unit migration
### Pause/Resume Command ### Pause/Resume Command
- File-based signaling via `/etc/nupst/pause` JSON file - File-based signaling via `/etc/nupst/pause` JSON file
- `nupst pause [--duration 30m|2h|1d]` creates pause file - `nupst pause [--duration 30m|2h|1d]` creates pause file
- `nupst resume` deletes pause file - `nupst resume` deletes pause file
- `ts/pause-state.ts` owns pause snapshot parsing and transition detection for daemon polling
- Daemon polls continue but actions are suppressed while paused - Daemon polls continue but actions are suppressed while paused
- Auto-resume after duration expires - Auto-resume after duration expires
- HTTP API includes pause state in response - HTTP API includes pause state in response
### Shutdown Orchestration
- `ts/shutdown-executor.ts` owns command discovery and fallback execution for delayed and emergency
shutdowns
- `ts/daemon.ts` now delegates OS shutdown execution instead of embedding command lookup logic
inline
- `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 ### Proxmox VM Shutdown Action
- New action type `'proxmox'` in `ts/actions/proxmox-action.ts` - New action type `'proxmox'` in `ts/actions/proxmox-action.ts`
- Uses Proxmox REST API with PVEAPIToken authentication - Uses Proxmox REST API with PVEAPIToken authentication
- Shuts down QEMU VMs and LXC containers before host shutdown - Shuts down QEMU VMs and LXC containers before host shutdown
@@ -76,13 +122,30 @@
- **CLI Handlers**: All use the `helpers.withPrompt()` utility for interactive input - **CLI Handlers**: All use the `helpers.withPrompt()` utility for interactive input
- **Constants**: All timing values should be referenced from `ts/constants.ts` - **Constants**: All timing values should be referenced from `ts/constants.ts`
- **Actions**: Use `IActionConfig` from `ts/actions/base-action.ts` for action configuration - **Actions**: Use `IActionConfig` from `ts/actions/base-action.ts` for action configuration
- **Config version**: Currently `4.2`, migrations run automatically - **Action orchestration**: Use helpers from `ts/action-orchestration.ts` for action context and
execution decisions
- **Config watch logic**: Use helpers from `ts/config-watch.ts` for file event filtering and reload
transitions
- **Pause state**: Use `loadPauseSnapshot()` and `IPauseState` from `ts/pause-state.ts`
- **Shutdown execution**: Use `ShutdownExecutor` for OS-level shutdown command lookup and fallbacks
- **Shutdown monitoring**: Use helpers from `ts/shutdown-monitoring.ts` for emergency loop rows and
candidate selection
- **UPS status state**: Use `IUpsStatus` and `createInitialUpsStatus()` from `ts/ups-status.ts`
- **UPS poll transitions**: Use helpers from `ts/ups-monitoring.ts` for success/failure updates
- **Config version**: Currently `4.3`, migrations run automatically
## File Organization ## File Organization
``` ```
ts/ ts/
├── constants.ts # All timing/threshold constants ├── constants.ts # All timing/threshold constants
├── action-orchestration.ts # Action context and execution decisions
├── config-watch.ts # File watch filters and config reload transitions
├── shutdown-monitoring.ts # Shutdown loop rows and emergency selection
├── ups-monitoring.ts # Pure UPS poll transition and threshold helpers
├── pause-state.ts # Shared pause state types and transition detection
├── shutdown-executor.ts # Delayed/emergency shutdown command execution
├── ups-status.ts # Daemon UPS status shape and initializer
├── interfaces/ ├── interfaces/
│ └── nupst-accessor.ts # Interface to break circular deps │ └── nupst-accessor.ts # Interface to break circular deps
├── helpers/ ├── helpers/
@@ -103,7 +166,7 @@ ts/
│ └── index.ts │ └── index.ts
├── migrations/ ├── migrations/
│ ├── migration-runner.ts │ ├── migration-runner.ts
│ └── migration-v4.1-to-v4.2.ts # Adds protocol field │ └── migration-v4.2-to-v4.3.ts # Adds SNMP runtimeUnit defaults
└── cli/ └── cli/
└── ... # All handlers use helpers.withPrompt() └── ... # All handlers use helpers.withPrompt()
``` ```
+41 -12
View File
@@ -12,12 +12,12 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
- **🔌 Multi-UPS Support** — Monitor multiple UPS devices from a single daemon - **🔌 Multi-UPS Support** — Monitor multiple UPS devices from a single daemon
- **📡 Dual Protocol Support** — SNMP (v1/v2c/v3) for network UPS + UPSD/NIS for USB-connected UPS via NUT - **📡 Dual Protocol Support** — SNMP (v1/v2c/v3) for network UPS + UPSD/NIS for USB-connected UPS via NUT
- **🖥️ Proxmox Integration** — Gracefully shut down QEMU VMs and LXC containers before host shutdown (auto-detects CLI tools — no API token needed on Proxmox hosts) - **🖥️ Proxmox Integration** — Gracefully shut down QEMU VMs and LXC containers before host shutdown, with optional HA-aware stop requests for HA-managed guests
- **👥 Group Management** — Organize UPS devices into groups with flexible operating modes - **👥 Group Management** — Organize UPS devices into groups with flexible operating modes
- **Redundant Mode** — Only trigger actions when ALL UPS devices in a group are critical - **Redundant Mode** — Only trigger actions when ALL UPS devices in a group are critical
- **Non-Redundant Mode** — Trigger actions when ANY UPS device is critical - **Non-Redundant Mode** — Trigger actions when ANY UPS device is critical
- **⚙️ Action System** — Define custom responses with flexible trigger conditions - **⚙️ Action System** — Define custom responses with flexible trigger conditions
- Battery & runtime threshold triggers - Edge-triggered battery & runtime threshold triggers
- Power status change triggers - Power status change triggers
- Webhook notifications (POST/GET) - Webhook notifications (POST/GET)
- Custom shell scripts - Custom shell scripts
@@ -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. 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 ### Example Configuration
```json ```json
{ {
"version": "4.3", "version": "4.3",
"checkInterval": 30000, "checkInterval": 30000,
"defaultShutdownDelay": 5,
"httpServer": { "httpServer": {
"enabled": true, "enabled": true,
"port": 8080, "port": 8080,
@@ -251,6 +255,7 @@ NUPST stores configuration at `/etc/nupst/config.json`. The easiest way to confi
"triggerMode": "onlyThresholds", "triggerMode": "onlyThresholds",
"thresholds": { "battery": 30, "runtime": 15 }, "thresholds": { "battery": 30, "runtime": 15 },
"proxmoxMode": "auto", "proxmoxMode": "auto",
"proxmoxHaPolicy": "haStop",
"proxmoxExcludeIds": [], "proxmoxExcludeIds": [],
"proxmoxForceStop": true "proxmoxForceStop": true
}, },
@@ -356,6 +361,10 @@ For USB-connected UPS via [NUT (Network UPS Tools)](https://networkupstools.org/
Actions define automated responses to UPS conditions. They run **sequentially in array order**, so place Proxmox actions before shutdown actions. Actions define automated responses to UPS conditions. They run **sequentially in array order**, so place Proxmox actions before shutdown actions.
Threshold-based actions are **edge-triggered**: they fire when the monitored UPS or group **enters** a threshold violation, not on every polling cycle while the threshold remains violated. If the condition clears and later re-enters, the action can fire again.
Shutdown and Proxmox actions also suppress duplicate runs where possible, so overlapping UPS and group actions do not repeatedly schedule the same host or guest shutdown workflow.
#### Action Types #### Action Types
| Type | Description | | Type | Description |
@@ -378,8 +387,8 @@ Actions define automated responses to UPS conditions. They run **sequentially in
| Mode | Description | | Mode | Description |
| ----------------------------- | -------------------------------------------------------- | | ----------------------------- | -------------------------------------------------------- |
| `onlyPowerChanges` | Only when power status changes (online ↔ onBattery) | | `onlyPowerChanges` | Only when power status changes (online ↔ onBattery) |
| `onlyThresholds` | Only when battery or runtime thresholds are violated | | `onlyThresholds` | Only when battery or runtime thresholds are newly violated |
| `powerChangesAndThresholds` | On power changes OR threshold violations (default) | | `powerChangesAndThresholds` | On power changes OR when thresholds are newly violated (default) |
| `anyChange` | On every polling cycle | | `anyChange` | On every polling cycle |
#### Shutdown Action #### Shutdown Action
@@ -395,7 +404,7 @@ Actions define automated responses to UPS conditions. They run **sequentially in
| Field | Description | Default | | Field | Description | Default |
| --------------- | ---------------------------------- | ------- | | --------------- | ---------------------------------- | ------- |
| `shutdownDelay` | Minutes to wait before shutdown | `5` | | `shutdownDelay` | Minutes to wait before shutdown | Inherits `defaultShutdownDelay` (`5`) |
#### Webhook Action #### Webhook Action
@@ -437,6 +446,8 @@ Actions define automated responses to UPS conditions. They run **sequentially in
Gracefully shuts down QEMU VMs and LXC containers on a Proxmox node before the host is shut down. Gracefully shuts down QEMU VMs and LXC containers on a Proxmox node before the host is shut down.
If you use Proxmox HA, NUPST can optionally request `state=stopped` for HA-managed guests instead of only issuing direct `qm` / `pct` shutdown commands.
NUPST supports **two operation modes** for Proxmox: NUPST supports **two operation modes** for Proxmox:
| Mode | Description | Requirements | | Mode | Description | Requirements |
@@ -455,6 +466,7 @@ NUPST supports **two operation modes** for Proxmox:
"thresholds": { "battery": 30, "runtime": 15 }, "thresholds": { "battery": 30, "runtime": 15 },
"triggerMode": "onlyThresholds", "triggerMode": "onlyThresholds",
"proxmoxMode": "auto", "proxmoxMode": "auto",
"proxmoxHaPolicy": "haStop",
"proxmoxExcludeIds": [100, 101], "proxmoxExcludeIds": [100, 101],
"proxmoxStopTimeout": 120, "proxmoxStopTimeout": 120,
"proxmoxForceStop": true "proxmoxForceStop": true
@@ -469,6 +481,7 @@ NUPST supports **two operation modes** for Proxmox:
"thresholds": { "battery": 30, "runtime": 15 }, "thresholds": { "battery": 30, "runtime": 15 },
"triggerMode": "onlyThresholds", "triggerMode": "onlyThresholds",
"proxmoxMode": "api", "proxmoxMode": "api",
"proxmoxHaPolicy": "haStop",
"proxmoxHost": "localhost", "proxmoxHost": "localhost",
"proxmoxPort": 8006, "proxmoxPort": 8006,
"proxmoxTokenId": "root@pam!nupst", "proxmoxTokenId": "root@pam!nupst",
@@ -483,6 +496,7 @@ NUPST supports **two operation modes** for Proxmox:
| Field | Description | Default | | Field | Description | Default |
| --------------------- | ----------------------------------------------- | ------------- | | --------------------- | ----------------------------------------------- | ------------- |
| `proxmoxMode` | Operation mode | `auto` | | `proxmoxMode` | Operation mode | `auto` |
| `proxmoxHaPolicy` | HA handling for HA-managed guests | `none`, `haStop` (`none` default) |
| `proxmoxHost` | Proxmox API host (API mode only) | `localhost` | | `proxmoxHost` | Proxmox API host (API mode only) | `localhost` |
| `proxmoxPort` | Proxmox API port (API mode only) | `8006` | | `proxmoxPort` | Proxmox API port (API mode only) | `8006` |
| `proxmoxNode` | Proxmox node name | Auto-detect via hostname | | `proxmoxNode` | Proxmox node name | Auto-detect via hostname |
@@ -500,11 +514,20 @@ NUPST supports **two operation modes** for Proxmox:
pveum user token add root@pam nupst --privsep=0 pveum user token add root@pam nupst --privsep=0
``` ```
**HA Policy values:**
- **`none`** — Treat HA-managed and non-HA guests the same. NUPST sends normal guest shutdown commands.
- **`haStop`** — For HA-managed guests, NUPST requests HA resource state `stopped`. Non-HA guests still use normal shutdown commands.
> ⚠️ **Important:** Place the Proxmox action **before** the shutdown action in the actions array so VMs are stopped before the host shuts down. > ⚠️ **Important:** Place the Proxmox action **before** the shutdown action in the actions array so VMs are stopped before the host shuts down.
### Group Configuration ### Group Configuration
Groups coordinate actions across multiple UPS devices: Groups coordinate actions across multiple UPS devices.
Group actions are evaluated **after all UPS devices have been refreshed for a polling cycle**.
There is **no aggregate battery math** across the group. Instead, each group action evaluates each member UPS against that action's own thresholds.
| Field | Description | Values | | Field | Description | Values |
| ------------- | ---------------------------------- | -------------------- | | ------------- | ---------------------------------- | -------------------- |
@@ -516,8 +539,10 @@ Groups coordinate actions across multiple UPS devices:
**Group Modes:** **Group Modes:**
- **`redundant`** — Actions trigger only when ALL UPS devices in the group are critical. Use for setups with backup power units. - **`redundant`** — A threshold-based action triggers only when **all** UPS devices in the group are on battery and below that action's thresholds. Use for setups with backup power units.
- **`nonRedundant`** — Actions trigger when ANY UPS device is critical. Use when all UPS units must be operational. - **`nonRedundant`** — A threshold-based action triggers when **any** UPS device in the group is on battery and below that action's thresholds. Use when all UPS units must be operational.
For threshold-based **destructive** group actions (`shutdown` and `proxmox`), NUPST suppresses execution while any group member is `unreachable`. This prevents acting on partial data during network failures.
### HTTP Server Configuration ### HTTP Server Configuration
@@ -593,6 +618,7 @@ NUPST tracks communication failures per UPS device:
- After **3 consecutive failures**, the UPS status transitions to `unreachable` - After **3 consecutive failures**, the UPS status transitions to `unreachable`
- **Shutdown actions will NOT fire** on `unreachable` — this prevents false shutdowns from network glitches - **Shutdown actions will NOT fire** on `unreachable` — this prevents false shutdowns from network glitches
- Webhook and script actions still fire, allowing you to send alerts - Webhook and script actions still fire, allowing you to send alerts
- Threshold-based destructive **group** actions are also suppressed while any required group member is `unreachable`
- When connectivity is restored, NUPST logs a recovery event with downtime duration - When connectivity is restored, NUPST logs a recovery event with downtime duration
- The failure counter is capped at 100 to prevent overflow - The failure counter is capped at 100 to prevent overflow
@@ -609,17 +635,17 @@ UPS Devices (2):
✓ Main Server UPS (online - 100%, 3840min) ✓ Main Server UPS (online - 100%, 3840min)
Host: 192.168.1.100:161 (SNMP) Host: 192.168.1.100:161 (SNMP)
Groups: Data Center Groups: Data Center
Action: proxmox (onlyThresholds: battery<30%, runtime<15min) Action: proxmox (onlyThresholds: battery<30%, runtime<15min, ha=stop)
Action: shutdown (onlyThresholds: battery<20%, runtime<10min, delay=10s) Action: shutdown (onlyThresholds: battery<20%, runtime<10min, delay=10min)
✓ Local USB UPS (online - 95%, 2400min) ✓ Local USB UPS (online - 95%, 2400min)
Host: 127.0.0.1:3493 (UPSD) 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): Groups (1):
Data Center (redundant) Data Center (redundant)
UPS Devices (1): Main Server UPS 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 ### Live Logs
@@ -780,6 +806,9 @@ curl -k -H "Authorization: PVEAPIToken=root@pam!nupst=YOUR-SECRET" \
# Check token permissions # Check token permissions
pveum user token list root@pam pveum user token list root@pam
# If using proxmoxHaPolicy: haStop
ha-manager config
``` ```
### Actions Not Triggering ### Actions Not Triggering
+3 -3
View File
@@ -229,10 +229,10 @@ console.log('');
// === 10. Update Available Example === // === 10. Update Available Example ===
logger.logBoxTitle('Update Available', 70, 'warning'); logger.logBoxTitle('Update Available', 70, 'warning');
logger.logBoxLine(''); logger.logBoxLine('');
logger.logBoxLine(`Current Version: ${theme.dim('4.0.1')}`); logger.logBoxLine(`Current Version: ${theme.dim('5.5.0')}`);
logger.logBoxLine(`Latest Version: ${theme.highlight('4.0.2')}`); logger.logBoxLine(`Latest Version: ${theme.highlight('5.5.1')}`);
logger.logBoxLine(''); logger.logBoxLine('');
logger.logBoxLine(`Run ${theme.command('sudo nupst update')} to update`); logger.logBoxLine(`Run ${theme.command('sudo nupst upgrade')} to update`);
logger.logBoxLine(''); logger.logBoxLine('');
logger.logBoxEnd(); logger.logBoxEnd();
+661
View File
@@ -2,9 +2,39 @@ import { assert, assertEquals, assertExists } from 'jsr:@std/assert@^1.0.0';
import { NupstSnmp } from '../ts/snmp/manager.ts'; import { NupstSnmp } from '../ts/snmp/manager.ts';
import { UpsOidSets } from '../ts/snmp/oid-sets.ts'; import { UpsOidSets } from '../ts/snmp/oid-sets.ts';
import type { IOidSet, ISnmpConfig, TUpsModel } from '../ts/snmp/types.ts'; import type { IOidSet, ISnmpConfig, TUpsModel } from '../ts/snmp/types.ts';
import {
analyzeConfigReload,
shouldRefreshPauseState,
shouldReloadConfig,
} from '../ts/config-watch.ts';
import { type IPauseState, loadPauseSnapshot } from '../ts/pause-state.ts';
import { shortId } from '../ts/helpers/shortid.ts'; import { shortId } from '../ts/helpers/shortid.ts';
import { HTTP_SERVER, SNMP, THRESHOLDS, TIMING, UI } from '../ts/constants.ts'; import { HTTP_SERVER, SNMP, THRESHOLDS, TIMING, UI } from '../ts/constants.ts';
import { Action, type IActionContext } from '../ts/actions/base-action.ts'; import { Action, type IActionContext } from '../ts/actions/base-action.ts';
import {
applyDefaultShutdownDelay,
buildUpsActionContext,
decideUpsActionExecution,
} from '../ts/action-orchestration.ts';
import {
buildShutdownErrorRow,
buildShutdownStatusRow,
selectEmergencyCandidate,
} from '../ts/shutdown-monitoring.ts';
import {
buildFailedUpsPollSnapshot,
buildSuccessfulUpsPollSnapshot,
getActionThresholdStates,
getEnteredThresholdIndexes,
hasThresholdViolation,
isActionThresholdExceeded,
} from '../ts/ups-monitoring.ts';
import {
buildGroupStatusSnapshot,
buildGroupThresholdContextStatus,
evaluateGroupActionThreshold,
} from '../ts/group-monitoring.ts';
import { createInitialUpsStatus } from '../ts/ups-status.ts';
import * as qenv from 'npm:@push.rocks/qenv@^6.0.0'; import * as qenv from 'npm:@push.rocks/qenv@^6.0.0';
const testQenv = new qenv.Qenv('./', '.nogit/'); const testQenv = new qenv.Qenv('./', '.nogit/');
@@ -82,6 +112,637 @@ Deno.test('UI constants: box widths are ascending', () => {
assert(UI.WIDE_BOX_WIDTH < UI.EXTRA_WIDE_BOX_WIDTH); assert(UI.WIDE_BOX_WIDTH < UI.EXTRA_WIDE_BOX_WIDTH);
}); });
// -----------------------------------------------------------------------------
// Pause State Tests
// -----------------------------------------------------------------------------
Deno.test('loadPauseSnapshot: reports paused state for valid pause file', async () => {
const tempDir = await Deno.makeTempDir();
const pauseFilePath = `${tempDir}/pause.json`;
const pauseState: IPauseState = {
pausedAt: 1000,
pausedBy: 'cli',
reason: 'maintenance',
resumeAt: 5000,
};
try {
await Deno.writeTextFile(pauseFilePath, JSON.stringify(pauseState));
const snapshot = loadPauseSnapshot(pauseFilePath, false, 2000);
assertEquals(snapshot.isPaused, true);
assertEquals(snapshot.pauseState, pauseState);
assertEquals(snapshot.transition, 'paused');
} finally {
await Deno.remove(tempDir, { recursive: true });
}
});
Deno.test('loadPauseSnapshot: auto-resumes expired pause file', async () => {
const tempDir = await Deno.makeTempDir();
const pauseFilePath = `${tempDir}/pause.json`;
const pauseState: IPauseState = {
pausedAt: 1000,
pausedBy: 'cli',
resumeAt: 1500,
};
try {
await Deno.writeTextFile(pauseFilePath, JSON.stringify(pauseState));
const snapshot = loadPauseSnapshot(pauseFilePath, true, 2000);
assertEquals(snapshot.isPaused, false);
assertEquals(snapshot.pauseState, null);
assertEquals(snapshot.transition, 'autoResumed');
let fileExists = true;
try {
await Deno.stat(pauseFilePath);
} catch {
fileExists = false;
}
assertEquals(fileExists, false);
} finally {
await Deno.remove(tempDir, { recursive: true });
}
});
Deno.test('loadPauseSnapshot: reports resumed when pause file disappears', async () => {
const tempDir = await Deno.makeTempDir();
try {
const snapshot = loadPauseSnapshot(`${tempDir}/pause.json`, true, 2000);
assertEquals(snapshot.isPaused, false);
assertEquals(snapshot.pauseState, null);
assertEquals(snapshot.transition, 'resumed');
} finally {
await Deno.remove(tempDir, { recursive: true });
}
});
// -----------------------------------------------------------------------------
// Config Watch Tests
// -----------------------------------------------------------------------------
Deno.test('shouldReloadConfig: matches modify events for config.json', () => {
assertEquals(
shouldReloadConfig({ kind: 'modify', paths: ['/etc/nupst/config.json'] }),
true,
);
assertEquals(
shouldReloadConfig({ kind: 'create', paths: ['/etc/nupst/config.json'] }),
false,
);
assertEquals(
shouldReloadConfig({ kind: 'modify', paths: ['/etc/nupst/other.json'] }),
false,
);
});
Deno.test('shouldRefreshPauseState: matches create/modify/remove pause events', () => {
assertEquals(
shouldRefreshPauseState({ kind: 'create', paths: ['/etc/nupst/pause'] }),
true,
);
assertEquals(
shouldRefreshPauseState({ kind: 'remove', paths: ['/etc/nupst/pause'] }),
true,
);
assertEquals(
shouldRefreshPauseState({ kind: 'modify', paths: ['/etc/nupst/config.json'] }),
false,
);
});
Deno.test('analyzeConfigReload: detects monitoring start and device count changes', () => {
assertEquals(analyzeConfigReload(0, 2), {
transition: 'monitoringWillStart',
message: 'Configuration reloaded! Found 2 UPS device(s)',
shouldInitializeUpsStatus: false,
shouldLogMonitoringStart: true,
});
assertEquals(analyzeConfigReload(2, 3), {
transition: 'deviceCountChanged',
message: 'Configuration reloaded! UPS devices: 2 -> 3',
shouldInitializeUpsStatus: true,
shouldLogMonitoringStart: false,
});
assertEquals(analyzeConfigReload(2, 2), {
transition: 'reloaded',
message: 'Configuration reloaded successfully',
shouldInitializeUpsStatus: false,
shouldLogMonitoringStart: false,
});
});
// -----------------------------------------------------------------------------
// UPS Status Tests
// -----------------------------------------------------------------------------
Deno.test('createInitialUpsStatus: creates default daemon UPS status shape', () => {
assertEquals(createInitialUpsStatus({ id: 'ups-1', name: 'Main UPS' }, 1234), {
id: 'ups-1',
name: 'Main UPS',
powerStatus: 'unknown',
batteryCapacity: 100,
batteryRuntime: 999,
outputLoad: 0,
outputPower: 0,
outputVoltage: 0,
outputCurrent: 0,
lastStatusChange: 1234,
lastCheckTime: 0,
consecutiveFailures: 0,
unreachableSince: 0,
});
});
// -----------------------------------------------------------------------------
// Action Orchestration Tests
// -----------------------------------------------------------------------------
Deno.test('buildUpsActionContext: includes previous power status and timestamp', () => {
const status = {
...createInitialUpsStatus({ id: 'ups-1', name: 'Main UPS' }, 1000),
powerStatus: 'onBattery' as const,
batteryCapacity: 42,
batteryRuntime: 15,
};
const previousStatus = {
...createInitialUpsStatus({ id: 'ups-1', name: 'Main UPS' }, 500),
powerStatus: 'online' as const,
};
assertEquals(
buildUpsActionContext(
{ id: 'ups-1', name: 'Main UPS' },
status,
previousStatus,
'thresholdViolation',
9999,
),
{
upsId: 'ups-1',
upsName: 'Main UPS',
powerStatus: 'onBattery',
batteryCapacity: 42,
batteryRuntime: 15,
previousPowerStatus: 'online',
timestamp: 9999,
triggerReason: 'thresholdViolation',
},
);
});
Deno.test('decideUpsActionExecution: suppresses actions while paused', () => {
const decision = decideUpsActionExecution(
true,
{ id: 'ups-1', name: 'Main UPS', actions: [{ type: 'shutdown' }] },
createInitialUpsStatus({ id: 'ups-1', name: 'Main UPS' }, 1000),
undefined,
'powerStatusChange',
9999,
);
assertEquals(decision, {
type: 'suppressed',
message: '[PAUSED] Actions suppressed for UPS Main UPS (trigger: powerStatusChange)',
});
});
Deno.test('decideUpsActionExecution: falls back to legacy shutdown without actions', () => {
const decision = decideUpsActionExecution(
false,
{ id: 'ups-1', name: 'Main UPS' },
createInitialUpsStatus({ id: 'ups-1', name: 'Main UPS' }, 1000),
undefined,
'thresholdViolation',
9999,
);
assertEquals(decision, {
type: 'legacyShutdown',
reason: 'UPS "Main UPS" battery or runtime below threshold',
});
});
Deno.test('decideUpsActionExecution: returns executable action plan when actions exist', () => {
const decision = decideUpsActionExecution(
false,
{ id: 'ups-1', name: 'Main UPS', actions: [{ type: 'shutdown' }] },
{
...createInitialUpsStatus({ id: 'ups-1', name: 'Main UPS' }, 1000),
powerStatus: 'onBattery',
batteryCapacity: 55,
batteryRuntime: 18,
},
{
...createInitialUpsStatus({ id: 'ups-1', name: 'Main UPS' }, 500),
powerStatus: 'online',
},
'powerStatusChange',
9999,
);
assertEquals(decision, {
type: 'execute',
actions: [{ type: 'shutdown' }],
context: {
upsId: 'ups-1',
upsName: 'Main UPS',
powerStatus: 'onBattery',
batteryCapacity: 55,
batteryRuntime: 18,
previousPowerStatus: 'online',
timestamp: 9999,
triggerReason: 'powerStatusChange',
},
});
});
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,
);
});
Deno.test('isActionThresholdExceeded: evaluates a single action threshold on battery only', () => {
assertEquals(
isActionThresholdExceeded(
{ type: 'shutdown', thresholds: { battery: 50, runtime: 20 } },
'online',
40,
10,
),
false,
);
assertEquals(
isActionThresholdExceeded(
{ type: 'shutdown', thresholds: { battery: 50, runtime: 20 } },
'onBattery',
40,
10,
),
true,
);
});
Deno.test('getActionThresholdStates: returns per-action threshold state array', () => {
assertEquals(
getActionThresholdStates('onBattery', 25, 8, [
{ type: 'shutdown', thresholds: { battery: 30, runtime: 10 } },
{ type: 'shutdown', thresholds: { battery: 10, runtime: 5 } },
{ type: 'webhook' },
]),
[true, false, false],
);
});
Deno.test('getEnteredThresholdIndexes: reports only newly-entered thresholds', () => {
assertEquals(getEnteredThresholdIndexes(undefined, [false, true, true]), [1, 2]);
assertEquals(getEnteredThresholdIndexes([false, true, false], [true, true, false]), [0]);
assertEquals(getEnteredThresholdIndexes([true, true], [true, false]), []);
});
// -----------------------------------------------------------------------------
// Group Monitoring Tests
// -----------------------------------------------------------------------------
Deno.test('buildGroupStatusSnapshot: redundant group stays online while one UPS remains online', () => {
const snapshot = buildGroupStatusSnapshot(
{ id: 'group-1', name: 'Group Main' },
'redundant',
[
{
...createInitialUpsStatus({ id: 'ups-1', name: 'UPS 1' }, 1000),
powerStatus: 'onBattery' as const,
batteryCapacity: 40,
batteryRuntime: 12,
},
{
...createInitialUpsStatus({ id: 'ups-2', name: 'UPS 2' }, 1000),
powerStatus: 'online' as const,
batteryCapacity: 98,
batteryRuntime: 999,
},
],
undefined,
5000,
);
assertEquals(snapshot.updatedStatus.powerStatus, 'online');
assertEquals(snapshot.transition, 'powerStatusChange');
});
Deno.test('buildGroupStatusSnapshot: nonRedundant group goes unreachable when any member is unreachable', () => {
const snapshot = buildGroupStatusSnapshot(
{ id: 'group-2', name: 'Group Edge' },
'nonRedundant',
[
{
...createInitialUpsStatus({ id: 'ups-1', name: 'UPS 1' }, 1000),
powerStatus: 'online' as const,
},
{
...createInitialUpsStatus({ id: 'ups-2', name: 'UPS 2' }, 1000),
powerStatus: 'unreachable' as const,
unreachableSince: 2000,
},
],
{
...createInitialUpsStatus({ id: 'group-2', name: 'Group Edge' }, 1000),
powerStatus: 'online' as const,
},
6000,
);
assertEquals(snapshot.updatedStatus.powerStatus, 'unreachable');
assertEquals(snapshot.transition, 'powerStatusChange');
});
Deno.test('evaluateGroupActionThreshold: redundant mode requires all members to be critical', () => {
const evaluation = evaluateGroupActionThreshold(
{ type: 'shutdown', thresholds: { battery: 50, runtime: 20 } },
'redundant',
[
{
...createInitialUpsStatus({ id: 'ups-1', name: 'UPS 1' }, 1000),
powerStatus: 'onBattery' as const,
batteryCapacity: 40,
batteryRuntime: 15,
},
{
...createInitialUpsStatus({ id: 'ups-2', name: 'UPS 2' }, 1000),
powerStatus: 'online' as const,
batteryCapacity: 95,
batteryRuntime: 999,
},
],
);
assertEquals(evaluation.exceedsThreshold, false);
});
Deno.test('evaluateGroupActionThreshold: nonRedundant mode trips on any critical member', () => {
const evaluation = evaluateGroupActionThreshold(
{ type: 'shutdown', thresholds: { battery: 50, runtime: 20 } },
'nonRedundant',
[
{
...createInitialUpsStatus({ id: 'ups-1', name: 'UPS 1' }, 1000),
powerStatus: 'onBattery' as const,
batteryCapacity: 40,
batteryRuntime: 15,
},
{
...createInitialUpsStatus({ id: 'ups-2', name: 'UPS 2' }, 1000),
powerStatus: 'online' as const,
batteryCapacity: 95,
batteryRuntime: 999,
},
],
);
assertEquals(evaluation.exceedsThreshold, true);
assertEquals(evaluation.blockedByUnreachable, false);
});
Deno.test('evaluateGroupActionThreshold: blocks destructive actions when a member is unreachable', () => {
const evaluation = evaluateGroupActionThreshold(
{ type: 'proxmox', thresholds: { battery: 50, runtime: 20 } },
'nonRedundant',
[
{
...createInitialUpsStatus({ id: 'ups-1', name: 'UPS 1' }, 1000),
powerStatus: 'onBattery' as const,
batteryCapacity: 25,
batteryRuntime: 8,
},
{
...createInitialUpsStatus({ id: 'ups-2', name: 'UPS 2' }, 1000),
powerStatus: 'unreachable' as const,
unreachableSince: 3000,
},
],
);
assertEquals(evaluation.exceedsThreshold, true);
assertEquals(evaluation.blockedByUnreachable, true);
});
Deno.test('buildGroupThresholdContextStatus: uses the worst triggering member runtime', () => {
const status = buildGroupThresholdContextStatus(
{ id: 'group-3', name: 'Group Worst' },
[
{
exceedsThreshold: true,
blockedByUnreachable: false,
representativeStatus: {
...createInitialUpsStatus({ id: 'ups-1', name: 'UPS 1' }, 1000),
powerStatus: 'onBattery' as const,
batteryCapacity: 30,
batteryRuntime: 9,
},
},
{
exceedsThreshold: true,
blockedByUnreachable: false,
representativeStatus: {
...createInitialUpsStatus({ id: 'ups-2', name: 'UPS 2' }, 1000),
powerStatus: 'onBattery' as const,
batteryCapacity: 20,
batteryRuntime: 4,
},
},
],
[0, 1],
{
...createInitialUpsStatus({ id: 'group-3', name: 'Group Worst' }, 1000),
powerStatus: 'online' as const,
},
7000,
);
assertEquals(status.powerStatus, 'onBattery');
assertEquals(status.batteryCapacity, 20);
assertEquals(status.batteryRuntime, 4);
});
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// UpsOidSets Tests // UpsOidSets Tests
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
+1 -1
View File
@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@serve.zone/nupst', name: '@serve.zone/nupst',
version: '5.5.0', version: '5.7.0',
description: 'Network UPS Shutdown Tool - Monitor SNMP-enabled UPS devices and orchestrate graceful system shutdowns during power emergencies' description: 'Network UPS Shutdown Tool - Monitor SNMP-enabled UPS devices and orchestrate graceful system shutdowns during power emergencies'
} }
+86
View File
@@ -0,0 +1,86 @@
import type { IActionConfig, IActionContext, TPowerStatus } from './actions/base-action.ts';
import type { IUpsStatus } from './ups-status.ts';
export interface IUpsActionSource {
id: string;
name: string;
actions?: IActionConfig[];
}
export type TUpsTriggerReason = IActionContext['triggerReason'];
export type TActionExecutionDecision =
| { type: 'suppressed'; message: string }
| { type: 'legacyShutdown'; reason: string }
| { type: 'skip' }
| { type: 'execute'; actions: IActionConfig[]; context: IActionContext };
export function buildUpsActionContext(
ups: IUpsActionSource,
status: IUpsStatus,
previousStatus: IUpsStatus | undefined,
triggerReason: TUpsTriggerReason,
timestamp: number = Date.now(),
): IActionContext {
return {
upsId: ups.id,
upsName: ups.name,
powerStatus: status.powerStatus as TPowerStatus,
batteryCapacity: status.batteryCapacity,
batteryRuntime: status.batteryRuntime,
previousPowerStatus: (previousStatus?.powerStatus || 'unknown') as TPowerStatus,
timestamp,
triggerReason,
};
}
export function applyDefaultShutdownDelay(
actions: IActionConfig[],
defaultDelayMinutes: number,
): IActionConfig[] {
return actions.map((action) => {
if (action.type !== 'shutdown' || action.shutdownDelay !== undefined) {
return action;
}
return {
...action,
shutdownDelay: defaultDelayMinutes,
};
});
}
export function decideUpsActionExecution(
isPaused: boolean,
ups: IUpsActionSource,
status: IUpsStatus,
previousStatus: IUpsStatus | undefined,
triggerReason: TUpsTriggerReason,
timestamp: number = Date.now(),
): TActionExecutionDecision {
if (isPaused) {
return {
type: 'suppressed',
message: `[PAUSED] Actions suppressed for UPS ${ups.name} (trigger: ${triggerReason})`,
};
}
const actions = ups.actions || [];
if (actions.length === 0 && triggerReason === 'thresholdViolation') {
return {
type: 'legacyShutdown',
reason: `UPS "${ups.name}" battery or runtime below threshold`,
};
}
if (actions.length === 0) {
return { type: 'skip' };
}
return {
type: 'execute',
actions,
context: buildUpsActionContext(ups, status, previousStatus, triggerReason, timestamp),
};
}
+3 -1
View File
@@ -74,7 +74,7 @@ export interface IActionConfig {
}; };
// Shutdown action configuration // 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; shutdownDelay?: number;
/** Only execute shutdown on threshold violation, not power status changes */ /** Only execute shutdown on threshold violation, not power status changes */
onlyOnThresholdViolation?: boolean; onlyOnThresholdViolation?: boolean;
@@ -118,6 +118,8 @@ export interface IActionConfig {
proxmoxInsecure?: boolean; proxmoxInsecure?: boolean;
/** Proxmox operation mode: 'auto' detects CLI tools, 'cli' forces CLI, 'api' forces REST API (default: 'auto') */ /** Proxmox operation mode: 'auto' detects CLI tools, 'cli' forces CLI, 'api' forces REST API (default: 'auto') */
proxmoxMode?: 'auto' | 'api' | 'cli'; proxmoxMode?: 'auto' | 'api' | 'cli';
/** How HA-managed Proxmox resources should be stopped (default: 'none') */
proxmoxHaPolicy?: 'none' | 'haStop';
} }
/** /**
+297 -65
View File
@@ -8,6 +8,11 @@ import { logger } from '../logger.ts';
import { PROXMOX, UI } from '../constants.ts'; import { PROXMOX, UI } from '../constants.ts';
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
type TNodeLikeGlobal = typeof globalThis & {
process?: {
env: Record<string, string | undefined>;
};
};
/** /**
* ProxmoxAction - Gracefully shuts down Proxmox VMs and LXC containers * ProxmoxAction - Gracefully shuts down Proxmox VMs and LXC containers
@@ -23,6 +28,22 @@ const execFileAsync = promisify(execFile);
*/ */
export class ProxmoxAction extends Action { export class ProxmoxAction extends Action {
readonly type = 'proxmox'; readonly type = 'proxmox';
private static readonly activeRunKeys = new Set<string>();
private static findCliTool(command: string): string | null {
for (const dir of PROXMOX.CLI_TOOL_PATHS) {
const candidate = `${dir}/${command}`;
try {
if (fs.existsSync(candidate)) {
return candidate;
}
} catch (_e) {
// continue
}
}
return null;
}
/** /**
* Check if Proxmox CLI tools (qm, pct) are available on the system * Check if Proxmox CLI tools (qm, pct) are available on the system
@@ -32,29 +53,12 @@ export class ProxmoxAction extends Action {
available: boolean; available: boolean;
qmPath: string | null; qmPath: string | null;
pctPath: string | null; pctPath: string | null;
haManagerPath: string | null;
isRoot: boolean; isRoot: boolean;
} { } {
let qmPath: string | null = null; const qmPath = this.findCliTool('qm');
let pctPath: string | null = null; const pctPath = this.findCliTool('pct');
const haManagerPath = this.findCliTool('ha-manager');
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); const isRoot = !!(process.getuid && process.getuid() === 0);
@@ -62,6 +66,7 @@ export class ProxmoxAction extends Action {
available: qmPath !== null && pctPath !== null && isRoot, available: qmPath !== null && pctPath !== null && isRoot,
qmPath, qmPath,
pctPath, pctPath,
haManagerPath,
isRoot, isRoot,
}; };
} }
@@ -69,7 +74,11 @@ export class ProxmoxAction extends Action {
/** /**
* Resolve the operation mode based on config and environment * Resolve the operation mode based on config and environment
*/ */
private resolveMode(): { mode: 'api' | 'cli'; qmPath: string; pctPath: string } | { mode: 'api'; qmPath?: undefined; pctPath?: undefined } { private resolveMode(): { mode: 'api' | 'cli'; qmPath: string; pctPath: string } | {
mode: 'api';
qmPath?: undefined;
pctPath?: undefined;
} {
const configuredMode = this.config.proxmoxMode || 'auto'; const configuredMode = this.config.proxmoxMode || 'auto';
if (configuredMode === 'api') { if (configuredMode === 'api') {
@@ -111,16 +120,29 @@ export class ProxmoxAction extends Action {
const resolved = this.resolveMode(); const resolved = this.resolveMode();
const node = this.config.proxmoxNode || os.hostname(); const node = this.config.proxmoxNode || os.hostname();
const excludeIds = new Set(this.config.proxmoxExcludeIds || []); const excludeIds = new Set(this.config.proxmoxExcludeIds || []);
const stopTimeout = (this.config.proxmoxStopTimeout || PROXMOX.DEFAULT_STOP_TIMEOUT_SECONDS) * 1000; const stopTimeout = (this.config.proxmoxStopTimeout || PROXMOX.DEFAULT_STOP_TIMEOUT_SECONDS) *
1000;
const forceStop = this.config.proxmoxForceStop !== false; // default true const forceStop = this.config.proxmoxForceStop !== false; // default true
const haPolicy = this.config.proxmoxHaPolicy || 'none';
const host = this.config.proxmoxHost || PROXMOX.DEFAULT_HOST;
const port = this.config.proxmoxPort || PROXMOX.DEFAULT_PORT;
const runKey = `${resolved.mode}:${node}:${
resolved.mode === 'api' ? `${host}:${port}` : 'local'
}`;
if (ProxmoxAction.activeRunKeys.has(runKey)) {
logger.info(`Proxmox action skipped: shutdown sequence already running for node ${node}`);
return;
}
ProxmoxAction.activeRunKeys.add(runKey);
logger.log(''); logger.log('');
logger.logBoxTitle('Proxmox VM Shutdown', UI.WIDE_BOX_WIDTH, 'warning'); logger.logBoxTitle('Proxmox VM Shutdown', UI.WIDE_BOX_WIDTH, 'warning');
logger.logBoxLine(`Mode: ${resolved.mode === 'cli' ? 'CLI (qm/pct)' : 'API (REST)'}`); logger.logBoxLine(`Mode: ${resolved.mode === 'cli' ? 'CLI (qm/pct)' : 'API (REST)'}`);
logger.logBoxLine(`Node: ${node}`); logger.logBoxLine(`Node: ${node}`);
logger.logBoxLine(`HA Policy: ${haPolicy}`);
if (resolved.mode === 'api') { if (resolved.mode === 'api') {
const host = this.config.proxmoxHost || PROXMOX.DEFAULT_HOST;
const port = this.config.proxmoxPort || PROXMOX.DEFAULT_PORT;
logger.logBoxLine(`API: ${host}:${port}`); logger.logBoxLine(`API: ${host}:${port}`);
} }
logger.logBoxLine(`UPS: ${context.upsName} (${context.powerStatus})`); logger.logBoxLine(`UPS: ${context.upsName} (${context.powerStatus})`);
@@ -132,6 +154,11 @@ export class ProxmoxAction extends Action {
logger.log(''); logger.log('');
try { try {
let apiContext: {
baseUrl: string;
headers: Record<string, string>;
insecure: boolean;
} | null = null;
let runningVMs: Array<{ vmid: number; name: string }>; let runningVMs: Array<{ vmid: number; name: string }>;
let runningCTs: Array<{ vmid: number; name: string }>; let runningCTs: Array<{ vmid: number; name: string }>;
@@ -140,8 +167,6 @@ export class ProxmoxAction extends Action {
runningCTs = await this.getRunningCTsCli(resolved.pctPath); runningCTs = await this.getRunningCTsCli(resolved.pctPath);
} else { } else {
// API mode - validate token // 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 tokenId = this.config.proxmoxTokenId;
const tokenSecret = this.config.proxmoxTokenSecret; const tokenSecret = this.config.proxmoxTokenSecret;
const insecure = this.config.proxmoxInsecure !== false; const insecure = this.config.proxmoxInsecure !== false;
@@ -152,13 +177,26 @@ export class ProxmoxAction extends Action {
return; return;
} }
const baseUrl = `https://${host}:${port}${PROXMOX.API_BASE}`; apiContext = {
const headers: Record<string, string> = { baseUrl: `https://${host}:${port}${PROXMOX.API_BASE}`,
headers: {
'Authorization': `PVEAPIToken=${tokenId}=${tokenSecret}`, 'Authorization': `PVEAPIToken=${tokenId}=${tokenSecret}`,
},
insecure,
}; };
runningVMs = await this.getRunningVMsApi(baseUrl, node, headers, insecure); runningVMs = await this.getRunningVMsApi(
runningCTs = await this.getRunningCTsApi(baseUrl, node, headers, insecure); apiContext.baseUrl,
node,
apiContext.headers,
apiContext.insecure,
);
runningCTs = await this.getRunningCTsApi(
apiContext.baseUrl,
node,
apiContext.headers,
apiContext.insecure,
);
} }
// Filter out excluded IDs // Filter out excluded IDs
@@ -171,33 +209,83 @@ export class ProxmoxAction extends Action {
return; return;
} }
const haManagedResources = haPolicy === 'haStop'
? await this.getHaManagedResources(resolved, apiContext)
: { qemu: new Set<number>(), lxc: new Set<number>() };
const haVmsToStop = vmsToStop.filter((vm) => haManagedResources.qemu.has(vm.vmid));
const haCtsToStop = ctsToStop.filter((ct) => haManagedResources.lxc.has(ct.vmid));
let directVmsToStop = vmsToStop.filter((vm) => !haManagedResources.qemu.has(vm.vmid));
let directCtsToStop = ctsToStop.filter((ct) => !haManagedResources.lxc.has(ct.vmid));
logger.info(`Shutting down ${vmsToStop.length} VMs and ${ctsToStop.length} containers...`); logger.info(`Shutting down ${vmsToStop.length} VMs and ${ctsToStop.length} containers...`);
// Send shutdown commands
if (resolved.mode === 'cli') { if (resolved.mode === 'cli') {
for (const vm of vmsToStop) { const { haManagerPath } = ProxmoxAction.detectCliAvailability();
if (haPolicy === 'haStop' && (haVmsToStop.length > 0 || haCtsToStop.length > 0)) {
if (!haManagerPath) {
logger.warn(
'ha-manager not found, falling back to direct guest shutdown for HA-managed resources',
);
directVmsToStop = [...haVmsToStop, ...directVmsToStop];
directCtsToStop = [...haCtsToStop, ...directCtsToStop];
} else {
for (const vm of haVmsToStop) {
await this.requestHaStopCli(haManagerPath, `vm:${vm.vmid}`);
logger.dim(` HA stop requested for VM ${vm.vmid} (${vm.name || 'unnamed'})`);
}
for (const ct of haCtsToStop) {
await this.requestHaStopCli(haManagerPath, `ct:${ct.vmid}`);
logger.dim(` HA stop requested for CT ${ct.vmid} (${ct.name || 'unnamed'})`);
}
}
}
for (const vm of directVmsToStop) {
await this.shutdownVMCli(resolved.qmPath, vm.vmid); await this.shutdownVMCli(resolved.qmPath, vm.vmid);
logger.dim(` Shutdown sent to VM ${vm.vmid} (${vm.name || 'unnamed'})`); logger.dim(` Shutdown sent to VM ${vm.vmid} (${vm.name || 'unnamed'})`);
} }
for (const ct of ctsToStop) { for (const ct of directCtsToStop) {
await this.shutdownCTCli(resolved.pctPath, ct.vmid); await this.shutdownCTCli(resolved.pctPath, ct.vmid);
logger.dim(` Shutdown sent to CT ${ct.vmid} (${ct.name || 'unnamed'})`); logger.dim(` Shutdown sent to CT ${ct.vmid} (${ct.name || 'unnamed'})`);
} }
} else { } else if (apiContext) {
const host = this.config.proxmoxHost || PROXMOX.DEFAULT_HOST; for (const vm of haVmsToStop) {
const port = this.config.proxmoxPort || PROXMOX.DEFAULT_PORT; await this.requestHaStopApi(
const insecure = this.config.proxmoxInsecure !== false; apiContext.baseUrl,
const baseUrl = `https://${host}:${port}${PROXMOX.API_BASE}`; `vm:${vm.vmid}`,
const headers: Record<string, string> = { apiContext.headers,
'Authorization': `PVEAPIToken=${this.config.proxmoxTokenId}=${this.config.proxmoxTokenSecret}`, apiContext.insecure,
}; );
logger.dim(` HA stop requested for VM ${vm.vmid} (${vm.name || 'unnamed'})`);
}
for (const ct of haCtsToStop) {
await this.requestHaStopApi(
apiContext.baseUrl,
`ct:${ct.vmid}`,
apiContext.headers,
apiContext.insecure,
);
logger.dim(` HA stop requested for CT ${ct.vmid} (${ct.name || 'unnamed'})`);
}
for (const vm of vmsToStop) { for (const vm of directVmsToStop) {
await this.shutdownVMApi(baseUrl, node, vm.vmid, headers, insecure); await this.shutdownVMApi(
apiContext.baseUrl,
node,
vm.vmid,
apiContext.headers,
apiContext.insecure,
);
logger.dim(` Shutdown sent to VM ${vm.vmid} (${vm.name || 'unnamed'})`); logger.dim(` Shutdown sent to VM ${vm.vmid} (${vm.name || 'unnamed'})`);
} }
for (const ct of ctsToStop) { for (const ct of directCtsToStop) {
await this.shutdownCTApi(baseUrl, node, ct.vmid, headers, insecure); await this.shutdownCTApi(
apiContext.baseUrl,
node,
ct.vmid,
apiContext.headers,
apiContext.insecure,
);
logger.dim(` Shutdown sent to CT ${ct.vmid} (${ct.name || 'unnamed'})`); logger.dim(` Shutdown sent to CT ${ct.vmid} (${ct.name || 'unnamed'})`);
} }
} }
@@ -220,18 +308,23 @@ export class ProxmoxAction extends Action {
} else { } else {
await this.stopCTCli(resolved.pctPath, item.vmid); await this.stopCTCli(resolved.pctPath, item.vmid);
} }
} else { } else if (apiContext) {
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') { if (item.type === 'qemu') {
await this.stopVMApi(baseUrl, node, item.vmid, headers, insecure); await this.stopVMApi(
apiContext.baseUrl,
node,
item.vmid,
apiContext.headers,
apiContext.insecure,
);
} else { } else {
await this.stopCTApi(baseUrl, node, item.vmid, headers, insecure); await this.stopCTApi(
apiContext.baseUrl,
node,
item.vmid,
apiContext.headers,
apiContext.insecure,
);
} }
} }
logger.dim(` Force-stopped ${item.type} ${item.vmid} (${item.name || 'unnamed'})`); logger.dim(` Force-stopped ${item.type} ${item.vmid} (${item.name || 'unnamed'})`);
@@ -252,6 +345,8 @@ export class ProxmoxAction extends Action {
logger.error( logger.error(
`Proxmox action failed: ${error instanceof Error ? error.message : String(error)}`, `Proxmox action failed: ${error instanceof Error ? error.message : String(error)}`,
); );
} finally {
ProxmoxAction.activeRunKeys.delete(runKey);
} }
} }
@@ -357,6 +452,77 @@ export class ProxmoxAction extends Action {
return status; return status;
} }
private async getHaManagedResources(
resolved: { mode: 'api' | 'cli'; qmPath?: string; pctPath?: string },
apiContext: {
baseUrl: string;
headers: Record<string, string>;
insecure: boolean;
} | null,
): Promise<{ qemu: Set<number>; lxc: Set<number> }> {
if (resolved.mode === 'cli') {
const { haManagerPath } = ProxmoxAction.detectCliAvailability();
if (!haManagerPath) {
return { qemu: new Set<number>(), lxc: new Set<number>() };
}
return await this.getHaManagedResourcesCli(haManagerPath);
}
if (!apiContext) {
return { qemu: new Set<number>(), lxc: new Set<number>() };
}
return await this.getHaManagedResourcesApi(
apiContext.baseUrl,
apiContext.headers,
apiContext.insecure,
);
}
private async getHaManagedResourcesCli(
haManagerPath: string,
): Promise<{ qemu: Set<number>; lxc: Set<number> }> {
try {
const { stdout } = await execFileAsync(haManagerPath, ['config']);
return this.parseHaManagerConfig(stdout);
} catch (error) {
logger.warn(
`Failed to list HA resources via CLI: ${
error instanceof Error ? error.message : String(error)
}`,
);
return { qemu: new Set<number>(), lxc: new Set<number>() };
}
}
private parseHaManagerConfig(output: string): { qemu: Set<number>; lxc: Set<number> } {
const resources = {
qemu: new Set<number>(),
lxc: new Set<number>(),
};
for (const line of output.trim().split('\n')) {
const match = line.match(/^\s*(vm|ct)\s*:\s*(\d+)\s*$/i);
if (!match) {
continue;
}
const vmid = parseInt(match[2], 10);
if (match[1].toLowerCase() === 'vm') {
resources.qemu.add(vmid);
} else {
resources.lxc.add(vmid);
}
}
return resources;
}
private async requestHaStopCli(haManagerPath: string, sid: string): Promise<void> {
await execFileAsync(haManagerPath, ['set', sid, '--state', 'stopped']);
}
// ─── API-based methods ───────────────────────────────────────────── // ─── API-based methods ─────────────────────────────────────────────
/** /**
@@ -367,16 +533,23 @@ export class ProxmoxAction extends Action {
method: string, method: string,
headers: Record<string, string>, headers: Record<string, string>,
insecure: boolean, insecure: boolean,
body?: URLSearchParams,
): Promise<unknown> { ): Promise<unknown> {
const requestHeaders = { ...headers };
const fetchOptions: RequestInit = { const fetchOptions: RequestInit = {
method, method,
headers, headers: requestHeaders,
}; };
if (body) {
requestHeaders['Content-Type'] = 'application/x-www-form-urlencoded;charset=UTF-8';
fetchOptions.body = body.toString();
}
// Use NODE_TLS_REJECT_UNAUTHORIZED for insecure mode (self-signed certs) // Use NODE_TLS_REJECT_UNAUTHORIZED for insecure mode (self-signed certs)
if (insecure) { const nodeProcess = (globalThis as TNodeLikeGlobal).process;
// deno-lint-ignore no-explicit-any if (insecure && nodeProcess?.env) {
(globalThis as any).process?.env && ((globalThis as any).process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'); nodeProcess.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
} }
try { try {
@@ -390,9 +563,8 @@ export class ProxmoxAction extends Action {
return await response.json(); return await response.json();
} finally { } finally {
// Restore TLS verification // Restore TLS verification
if (insecure) { if (insecure && nodeProcess?.env) {
// deno-lint-ignore no-explicit-any nodeProcess.env.NODE_TLS_REJECT_UNAUTHORIZED = '1';
(globalThis as any).process?.env && ((globalThis as any).process.env.NODE_TLS_REJECT_UNAUTHORIZED = '1');
} }
} }
} }
@@ -453,6 +625,63 @@ export class ProxmoxAction extends Action {
} }
} }
private async getHaManagedResourcesApi(
baseUrl: string,
headers: Record<string, string>,
insecure: boolean,
): Promise<{ qemu: Set<number>; lxc: Set<number> }> {
try {
const response = await this.apiRequest(
`${baseUrl}/cluster/ha/resources`,
'GET',
headers,
insecure,
) as { data: Array<{ sid?: string }> };
const resources = {
qemu: new Set<number>(),
lxc: new Set<number>(),
};
for (const item of response.data || []) {
const match = item.sid?.match(/^(vm|ct):(\d+)$/i);
if (!match) {
continue;
}
const vmid = parseInt(match[2], 10);
if (match[1].toLowerCase() === 'vm') {
resources.qemu.add(vmid);
} else {
resources.lxc.add(vmid);
}
}
return resources;
} catch (error) {
logger.warn(
`Failed to list HA resources via API: ${
error instanceof Error ? error.message : String(error)
}`,
);
return { qemu: new Set<number>(), lxc: new Set<number>() };
}
}
private async requestHaStopApi(
baseUrl: string,
sid: string,
headers: Record<string, string>,
insecure: boolean,
): Promise<void> {
await this.apiRequest(
`${baseUrl}/cluster/ha/resources/${encodeURIComponent(sid)}`,
'PUT',
headers,
insecure,
new URLSearchParams({ state: 'stopped' }),
);
}
private async shutdownVMApi( private async shutdownVMApi(
baseUrl: string, baseUrl: string,
node: string, node: string,
@@ -529,7 +758,9 @@ export class ProxmoxAction extends Action {
while (remaining.length > 0 && (Date.now() - startTime) < timeout) { while (remaining.length > 0 && (Date.now() - startTime) < timeout) {
// Wait before polling // Wait before polling
await new Promise((resolve) => setTimeout(resolve, PROXMOX.STATUS_POLL_INTERVAL_SECONDS * 1000)); await new Promise((resolve) =>
setTimeout(resolve, PROXMOX.STATUS_POLL_INTERVAL_SECONDS * 1000)
);
// Check which are still running // Check which are still running
const stillRunning: typeof remaining = []; const stillRunning: typeof remaining = [];
@@ -547,7 +778,8 @@ export class ProxmoxAction extends Action {
const insecure = this.config.proxmoxInsecure !== false; const insecure = this.config.proxmoxInsecure !== false;
const baseUrl = `https://${host}:${port}${PROXMOX.API_BASE}`; const baseUrl = `https://${host}:${port}${PROXMOX.API_BASE}`;
const headers: Record<string, string> = { const headers: Record<string, string> = {
'Authorization': `PVEAPIToken=${this.config.proxmoxTokenId}=${this.config.proxmoxTokenSecret}`, 'Authorization':
`PVEAPIToken=${this.config.proxmoxTokenId}=${this.config.proxmoxTokenSecret}`,
}; };
const statusUrl = `${baseUrl}/nodes/${node}/${item.type}/${item.vmid}/status/current`; const statusUrl = `${baseUrl}/nodes/${node}/${item.type}/${item.vmid}/status/current`;
const response = await this.apiRequest(statusUrl, 'GET', headers, insecure) as { const response = await this.apiRequest(statusUrl, 'GET', headers, insecure) as {
+23 -1
View File
@@ -15,6 +15,7 @@ const execFileAsync = promisify(execFile);
*/ */
export class ShutdownAction extends Action { export class ShutdownAction extends Action {
readonly type = 'shutdown'; readonly type = 'shutdown';
private static scheduledDelayMinutes: number | null = null;
/** /**
* Override shouldExecute to add shutdown-specific safety checks * Override shouldExecute to add shutdown-specific safety checks
@@ -124,7 +125,26 @@ export class ShutdownAction extends Action {
return; return;
} }
const shutdownDelay = this.config.shutdownDelay || SHUTDOWN.DEFAULT_DELAY_MINUTES; const shutdownDelay = this.config.shutdownDelay ?? SHUTDOWN.DEFAULT_DELAY_MINUTES;
if (
ShutdownAction.scheduledDelayMinutes !== null &&
ShutdownAction.scheduledDelayMinutes <= shutdownDelay
) {
logger.info(
`Shutdown action skipped: shutdown already scheduled in ${ShutdownAction.scheduledDelayMinutes} minutes`,
);
return;
}
if (
ShutdownAction.scheduledDelayMinutes !== null &&
ShutdownAction.scheduledDelayMinutes > shutdownDelay
) {
logger.warn(
`Shutdown already scheduled in ${ShutdownAction.scheduledDelayMinutes} minutes, rescheduling to ${shutdownDelay} minutes`,
);
}
logger.log(''); logger.log('');
logger.logBoxTitle('Initiating System Shutdown', UI.WIDE_BOX_WIDTH, 'error'); logger.logBoxTitle('Initiating System Shutdown', UI.WIDE_BOX_WIDTH, 'error');
@@ -139,6 +159,7 @@ export class ShutdownAction extends Action {
try { try {
await this.executeShutdownCommand(shutdownDelay); await this.executeShutdownCommand(shutdownDelay);
ShutdownAction.scheduledDelayMinutes = shutdownDelay;
} catch (error) { } catch (error) {
logger.error( logger.error(
`Shutdown command failed: ${error instanceof Error ? error.message : String(error)}`, `Shutdown command failed: ${error instanceof Error ? error.message : String(error)}`,
@@ -227,6 +248,7 @@ export class ShutdownAction extends Action {
logger.log(`Trying alternative shutdown method: ${cmdPath} ${alt.args.join(' ')}`); logger.log(`Trying alternative shutdown method: ${cmdPath} ${alt.args.join(' ')}`);
await execFileAsync(cmdPath, alt.args); await execFileAsync(cmdPath, alt.args);
logger.log(`Alternative method ${alt.cmd} succeeded`); logger.log(`Alternative method ${alt.cmd} succeeded`);
ShutdownAction.scheduledDelayMinutes = 0;
return; // Exit if successful return; // Exit if successful
} }
} catch (_altError) { } catch (_altError) {
+4 -4
View File
@@ -19,7 +19,7 @@ export class NupstCli {
/** /**
* Parse command line arguments and execute the appropriate command * Parse command line arguments and execute the appropriate command
* @param args Command line arguments (process.argv) * @param args Command line arguments excluding runtime and script path
*/ */
public async parseAndExecute(args: string[]): Promise<void> { public async parseAndExecute(args: string[]): Promise<void> {
// Extract debug and version flags from any position // Extract debug and version flags from any position
@@ -38,8 +38,8 @@ export class NupstCli {
} }
// Get the command (default to help if none provided) // Get the command (default to help if none provided)
const command = debugOptions.cleanedArgs[2] || 'help'; const command = debugOptions.cleanedArgs[0] || 'help';
const commandArgs = debugOptions.cleanedArgs.slice(3); const commandArgs = debugOptions.cleanedArgs.slice(1);
// Route to the appropriate command handler // Route to the appropriate command handler
await this.executeCommand(command, commandArgs, debugOptions.debugMode); await this.executeCommand(command, commandArgs, debugOptions.debugMode);
@@ -98,7 +98,7 @@ export class NupstCli {
await serviceHandler.start(); await serviceHandler.start();
break; break;
case 'status': case 'status':
await serviceHandler.status(); await serviceHandler.status(debugMode);
break; break;
case 'logs': case 'logs':
await serviceHandler.logs(); await serviceHandler.logs();
+60 -16
View File
@@ -4,6 +4,7 @@ import { type ITableColumn, logger } from '../logger.ts';
import { symbols, theme } from '../colors.ts'; import { symbols, theme } from '../colors.ts';
import type { IActionConfig } from '../actions/base-action.ts'; import type { IActionConfig } from '../actions/base-action.ts';
import { ProxmoxAction } from '../actions/proxmox-action.ts'; import { ProxmoxAction } from '../actions/proxmox-action.ts';
import { SHUTDOWN } from '../constants.ts';
import type { IGroupConfig, IUpsConfig } from '../daemon.ts'; import type { IGroupConfig, IUpsConfig } from '../daemon.ts';
import * as helpers from '../helpers/index.ts'; import * as helpers from '../helpers/index.ts';
@@ -71,9 +72,13 @@ export class ActionHandler {
logger.log(` ${theme.dim('1)')} Shutdown (system shutdown)`); logger.log(` ${theme.dim('1)')} Shutdown (system shutdown)`);
logger.log(` ${theme.dim('2)')} Webhook (HTTP notification)`); 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('3)')} Custom Script (run .sh file from /etc/nupst)`);
logger.log(` ${theme.dim('4)')} Proxmox (gracefully shut down VMs/LXCs before host shutdown)`); logger.log(
` ${theme.dim('4)')} Proxmox (gracefully shut down VMs/LXCs before host shutdown)`,
);
const typeInput = await prompt(` ${theme.dim('Select action type')} ${theme.dim('[1]:')} `); const typeInput = await prompt(
` ${theme.dim('Select action type')} ${theme.dim('[1]:')} `,
);
const typeValue = parseInt(typeInput, 10) || 1; const typeValue = parseInt(typeInput, 10) || 1;
const newAction: Partial<IActionConfig> = {}; const newAction: Partial<IActionConfig> = {};
@@ -81,16 +86,22 @@ export class ActionHandler {
if (typeValue === 1) { if (typeValue === 1) {
// Shutdown action // Shutdown action
newAction.type = 'shutdown'; newAction.type = 'shutdown';
const defaultShutdownDelay = this.nupst.getDaemon().getConfig().defaultShutdownDelay ??
SHUTDOWN.DEFAULT_DELAY_MINUTES;
const delayStr = await prompt( const delayStr = await prompt(
` ${theme.dim('Shutdown delay')} ${theme.dim('(minutes) [5]:')} `, ` ${theme.dim('Shutdown delay')} ${
theme.dim(`(minutes, leave empty for default ${defaultShutdownDelay}):`)
} `,
); );
const shutdownDelay = delayStr ? parseInt(delayStr, 10) : 5; if (delayStr.trim()) {
const shutdownDelay = parseInt(delayStr, 10);
if (isNaN(shutdownDelay) || shutdownDelay < 0) { if (isNaN(shutdownDelay) || shutdownDelay < 0) {
logger.error('Invalid shutdown delay. Must be >= 0.'); logger.error('Invalid shutdown delay. Must be >= 0.');
process.exit(1); process.exit(1);
} }
newAction.shutdownDelay = shutdownDelay; newAction.shutdownDelay = shutdownDelay;
}
} else if (typeValue === 2) { } else if (typeValue === 2) {
// Webhook action // Webhook action
newAction.type = 'webhook'; newAction.type = 'webhook';
@@ -109,7 +120,9 @@ export class ActionHandler {
const methodInput = await prompt(` ${theme.dim('Select method')} ${theme.dim('[1]:')} `); const methodInput = await prompt(` ${theme.dim('Select method')} ${theme.dim('[1]:')} `);
newAction.webhookMethod = methodInput === '2' ? 'GET' : 'POST'; newAction.webhookMethod = methodInput === '2' ? 'GET' : 'POST';
const timeoutInput = await prompt(` ${theme.dim('Timeout in seconds')} ${theme.dim('[10]:')} `); const timeoutInput = await prompt(
` ${theme.dim('Timeout in seconds')} ${theme.dim('[10]:')} `,
);
const timeout = parseInt(timeoutInput, 10); const timeout = parseInt(timeoutInput, 10);
if (timeoutInput.trim() && !isNaN(timeout)) { if (timeoutInput.trim() && !isNaN(timeout)) {
newAction.webhookTimeout = timeout * 1000; newAction.webhookTimeout = timeout * 1000;
@@ -118,14 +131,18 @@ export class ActionHandler {
// Script action // Script action
newAction.type = 'script'; newAction.type = 'script';
const scriptPath = await prompt(` ${theme.dim('Script filename (in /etc/nupst/, must end with .sh):')} `); const scriptPath = await prompt(
` ${theme.dim('Script filename (in /etc/nupst/, must end with .sh):')} `,
);
if (!scriptPath.trim() || !scriptPath.trim().endsWith('.sh')) { if (!scriptPath.trim() || !scriptPath.trim().endsWith('.sh')) {
logger.error('Script path must end with .sh.'); logger.error('Script path must end with .sh.');
process.exit(1); process.exit(1);
} }
newAction.scriptPath = scriptPath.trim(); newAction.scriptPath = scriptPath.trim();
const timeoutInput = await prompt(` ${theme.dim('Script timeout in seconds')} ${theme.dim('[60]:')} `); const timeoutInput = await prompt(
` ${theme.dim('Script timeout in seconds')} ${theme.dim('[60]:')} `,
);
const timeout = parseInt(timeoutInput, 10); const timeout = parseInt(timeoutInput, 10);
if (timeoutInput.trim() && !isNaN(timeout)) { if (timeoutInput.trim() && !isNaN(timeout)) {
newAction.scriptTimeout = timeout * 1000; newAction.scriptTimeout = timeout * 1000;
@@ -154,14 +171,20 @@ export class ActionHandler {
logger.info('Proxmox API Settings:'); logger.info('Proxmox API Settings:');
logger.dim('Create a token with: pveum user token add root@pam nupst --privsep=0'); 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]:')} `); const pxHost = await prompt(
` ${theme.dim('Proxmox Host')} ${theme.dim('[localhost]:')} `,
);
newAction.proxmoxHost = pxHost.trim() || 'localhost'; newAction.proxmoxHost = pxHost.trim() || 'localhost';
const pxPortInput = await prompt(` ${theme.dim('Proxmox API Port')} ${theme.dim('[8006]:')} `); const pxPortInput = await prompt(
` ${theme.dim('Proxmox API Port')} ${theme.dim('[8006]:')} `,
);
const pxPort = parseInt(pxPortInput, 10); const pxPort = parseInt(pxPortInput, 10);
newAction.proxmoxPort = pxPortInput.trim() && !isNaN(pxPort) ? pxPort : 8006; newAction.proxmoxPort = pxPortInput.trim() && !isNaN(pxPort) ? pxPort : 8006;
const pxNode = await prompt(` ${theme.dim('Proxmox Node Name (empty = auto-detect):')} `); const pxNode = await prompt(
` ${theme.dim('Proxmox Node Name (empty = auto-detect):')} `,
);
if (pxNode.trim()) { if (pxNode.trim()) {
newAction.proxmoxNode = pxNode.trim(); newAction.proxmoxNode = pxNode.trim();
} }
@@ -180,25 +203,41 @@ export class ActionHandler {
} }
newAction.proxmoxTokenSecret = tokenSecret.trim(); newAction.proxmoxTokenSecret = tokenSecret.trim();
const insecureInput = await prompt(` ${theme.dim('Skip TLS verification (self-signed cert)?')} ${theme.dim('(Y/n):')} `); const insecureInput = await prompt(
` ${theme.dim('Skip TLS verification (self-signed cert)?')} ${theme.dim('(Y/n):')} `,
);
newAction.proxmoxInsecure = insecureInput.toLowerCase() !== 'n'; newAction.proxmoxInsecure = insecureInput.toLowerCase() !== 'n';
newAction.proxmoxMode = 'api'; newAction.proxmoxMode = 'api';
} }
// Common Proxmox settings (both modes) // Common Proxmox settings (both modes)
const excludeInput = await prompt(` ${theme.dim('VM/CT IDs to exclude (comma-separated, or empty):')} `); const excludeInput = await prompt(
` ${theme.dim('VM/CT IDs to exclude (comma-separated, or empty):')} `,
);
if (excludeInput.trim()) { if (excludeInput.trim()) {
newAction.proxmoxExcludeIds = excludeInput.split(',').map((s) => parseInt(s.trim(), 10)).filter((n) => !isNaN(n)); 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 timeoutInput = await prompt(
` ${theme.dim('VM shutdown timeout in seconds')} ${theme.dim('[120]:')} `,
);
const stopTimeout = parseInt(timeoutInput, 10); const stopTimeout = parseInt(timeoutInput, 10);
if (timeoutInput.trim() && !isNaN(stopTimeout)) { if (timeoutInput.trim() && !isNaN(stopTimeout)) {
newAction.proxmoxStopTimeout = stopTimeout; newAction.proxmoxStopTimeout = stopTimeout;
} }
const forceInput = await prompt(` ${theme.dim('Force-stop VMs that don\'t shut down in time?')} ${theme.dim('(Y/n):')} `); 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'; newAction.proxmoxForceStop = forceInput.toLowerCase() !== 'n';
const haPolicyInput = await prompt(
` ${theme.dim('HA-managed guest handling')} ${theme.dim('([1] none, 2 haStop):')} `,
);
newAction.proxmoxHaPolicy = haPolicyInput.trim() === '2' ? 'haStop' : 'none';
} else { } else {
logger.error('Invalid action type.'); logger.error('Invalid action type.');
process.exit(1); process.exit(1);
@@ -468,7 +507,9 @@ export class ActionHandler {
]; ];
const rows = target.actions.map((action, index) => { const rows = target.actions.map((action, index) => {
let details = `${action.shutdownDelay || 5}min delay`; const defaultShutdownDelay = this.nupst.getDaemon().getConfig().defaultShutdownDelay ??
SHUTDOWN.DEFAULT_DELAY_MINUTES;
let details = `${action.shutdownDelay ?? defaultShutdownDelay}min delay`;
if (action.type === 'proxmox') { if (action.type === 'proxmox') {
const mode = action.proxmoxMode || 'auto'; const mode = action.proxmoxMode || 'auto';
if (mode === 'cli' || (mode === 'auto' && !action.proxmoxTokenId)) { if (mode === 'cli' || (mode === 'auto' && !action.proxmoxTokenId)) {
@@ -481,6 +522,9 @@ export class ActionHandler {
if (action.proxmoxExcludeIds?.length) { if (action.proxmoxExcludeIds?.length) {
details += `, excl: ${action.proxmoxExcludeIds.join(',')}`; details += `, excl: ${action.proxmoxExcludeIds.join(',')}`;
} }
if (action.proxmoxHaPolicy === 'haStop') {
details += ', haStop';
}
} else if (action.type === 'webhook') { } else if (action.type === 'webhook') {
details = action.webhookUrl || theme.dim('N/A'); details = action.webhookUrl || theme.dim('N/A');
} else if (action.type === 'script') { } else if (action.type === 'script') {
+4 -4
View File
@@ -124,7 +124,7 @@ export class GroupHandler {
await this.nupst.getDaemon().loadConfig(); await this.nupst.getDaemon().loadConfig();
} catch (error) { } catch (error) {
logger.error( logger.error(
'No configuration found. Please run "nupst setup" first to create a configuration.', 'No configuration found. Please run "nupst ups add" first to create a configuration.',
); );
return; return;
} }
@@ -219,7 +219,7 @@ export class GroupHandler {
await this.nupst.getDaemon().loadConfig(); await this.nupst.getDaemon().loadConfig();
} catch (error) { } catch (error) {
logger.error( logger.error(
'No configuration found. Please run "nupst setup" first to create a configuration.', 'No configuration found. Please run "nupst ups add" first to create a configuration.',
); );
return; return;
} }
@@ -316,7 +316,7 @@ export class GroupHandler {
await this.nupst.getDaemon().loadConfig(); await this.nupst.getDaemon().loadConfig();
} catch (error) { } catch (error) {
logger.error( logger.error(
'No configuration found. Please run "nupst setup" first to create a configuration.', 'No configuration found. Please run "nupst ups add" first to create a configuration.',
); );
return; return;
} }
@@ -484,7 +484,7 @@ export class GroupHandler {
prompt: (question: string) => Promise<string>, prompt: (question: string) => Promise<string>,
): Promise<void> { ): Promise<void> {
if (!config.upsDevices || config.upsDevices.length === 0) { if (!config.upsDevices || config.upsDevices.length === 0) {
logger.log('No UPS devices available. Use "nupst add" to add UPS devices.'); logger.log('No UPS devices available. Use "nupst ups add" to add UPS devices.');
return; return;
} }
+14 -23
View File
@@ -6,7 +6,7 @@ import { Nupst } from '../nupst.ts';
import { logger } from '../logger.ts'; import { logger } from '../logger.ts';
import { theme } from '../colors.ts'; import { theme } from '../colors.ts';
import { PAUSE } from '../constants.ts'; import { PAUSE } from '../constants.ts';
import type { IPauseState } from '../daemon.ts'; import type { IPauseState } from '../pause-state.ts';
import * as helpers from '../helpers/index.ts'; import * as helpers from '../helpers/index.ts';
/** /**
@@ -30,7 +30,9 @@ export class ServiceHandler {
public async enable(): Promise<void> { public async enable(): Promise<void> {
this.checkRootAccess('This command must be run as root.'); this.checkRootAccess('This command must be run as root.');
await this.nupst.getSystemd().install(); await this.nupst.getSystemd().install();
logger.log('NUPST service has been installed. Use "nupst start" to start the service.'); logger.log(
'NUPST service has been installed. Use "nupst service start" to start the service.',
);
} }
/** /**
@@ -103,10 +105,8 @@ export class ServiceHandler {
/** /**
* Show status of the systemd service and UPS * Show status of the systemd service and UPS
*/ */
public async status(): Promise<void> { public async status(debugMode: boolean = false): Promise<void> {
// Extract debug options from args array await this.nupst.getSystemd().getStatus(debugMode);
const debugOptions = this.extractDebugOptions(process.argv);
await this.nupst.getSystemd().getStatus(debugOptions.debugMode);
} }
/** /**
@@ -221,10 +221,14 @@ export class ServiceHandler {
const unit = match[2].toLowerCase(); const unit = match[2].toLowerCase();
switch (unit) { switch (unit) {
case 'm': return value * 60 * 1000; case 'm':
case 'h': return value * 60 * 60 * 1000; return value * 60 * 1000;
case 'd': return value * 24 * 60 * 60 * 1000; case 'h':
default: return null; return value * 60 * 60 * 1000;
case 'd':
return value * 24 * 60 * 60 * 1000;
default:
return null;
} }
} }
@@ -398,17 +402,4 @@ export class ServiceHandler {
process.exit(1); process.exit(1);
} }
} }
/**
* Extract and remove debug options from args array
* @param args Command line arguments
* @returns Object with debug flags and cleaned args
*/
private extractDebugOptions(args: string[]): { debugMode: boolean; cleanedArgs: string[] } {
const debugMode = args.includes('--debug') || args.includes('-d');
// Remove debug flags from args
const cleanedArgs = args.filter((arg) => arg !== '--debug' && arg !== '-d');
return { debugMode, cleanedArgs };
}
} }
+43 -15
View File
@@ -10,7 +10,7 @@ import type { TProtocol } from '../protocol/types.ts';
import type { INupstConfig, IUpsConfig } from '../daemon.ts'; import type { INupstConfig, IUpsConfig } from '../daemon.ts';
import type { IActionConfig } from '../actions/base-action.ts'; import type { IActionConfig } from '../actions/base-action.ts';
import { ProxmoxAction } from '../actions/proxmox-action.ts'; import { ProxmoxAction } from '../actions/proxmox-action.ts';
import { UPSD } from '../constants.ts'; import { SHUTDOWN, UPSD } from '../constants.ts';
/** /**
* Thresholds configuration for CLI display * Thresholds configuration for CLI display
@@ -103,7 +103,15 @@ export class UpsHandler {
const protocol: TProtocol = protocolChoice === 2 ? 'upsd' : 'snmp'; const protocol: TProtocol = protocolChoice === 2 ? 'upsd' : 'snmp';
// Create a new UPS configuration object with defaults // Create a new UPS configuration object with defaults
const newUps: Record<string, unknown> & { id: string; name: string; groups: string[]; actions: IActionConfig[]; protocol: TProtocol; snmp?: ISnmpConfig; upsd?: IUpsdConfig } = { const newUps: Record<string, unknown> & {
id: string;
name: string;
groups: string[];
actions: IActionConfig[];
protocol: TProtocol;
snmp?: ISnmpConfig;
upsd?: IUpsdConfig;
} = {
id: upsId, id: upsId,
name: name || `UPS-${upsId}`, name: name || `UPS-${upsId}`,
protocol, protocol,
@@ -203,7 +211,7 @@ export class UpsHandler {
return; return;
} else { } else {
// For specific UPS ID, error if config doesn't exist // For specific UPS ID, error if config doesn't exist
logger.error('No configuration found. Please run "nupst setup" first.'); logger.error('No configuration found. Please run "nupst ups add" first.');
return; return;
} }
} }
@@ -242,7 +250,7 @@ export class UpsHandler {
} else { } else {
// For backward compatibility, edit the first UPS if no ID specified // For backward compatibility, edit the first UPS if no ID specified
if (config.upsDevices.length === 0) { if (config.upsDevices.length === 0) {
logger.error('No UPS devices configured. Please run "nupst add" to add a UPS.'); logger.error('No UPS devices configured. Please run "nupst ups add" to add a UPS.');
return; return;
} }
upsToEdit = config.upsDevices[0]; upsToEdit = config.upsDevices[0];
@@ -261,7 +269,9 @@ export class UpsHandler {
logger.info(`Current Protocol: ${currentProtocol.toUpperCase()}`); logger.info(`Current Protocol: ${currentProtocol.toUpperCase()}`);
logger.dim(' 1) SNMP (network UPS with SNMP agent)'); logger.dim(' 1) SNMP (network UPS with SNMP agent)');
logger.dim(' 2) UPSD/NIS (local NUT server, e.g. USB-connected UPS)'); logger.dim(' 2) UPSD/NIS (local NUT server, e.g. USB-connected UPS)');
const protocolInput = await prompt(`Select protocol [${currentProtocol === 'upsd' ? '2' : '1'}]: `); const protocolInput = await prompt(
`Select protocol [${currentProtocol === 'upsd' ? '2' : '1'}]: `,
);
const protocolChoice = parseInt(protocolInput, 10); const protocolChoice = parseInt(protocolInput, 10);
if (protocolChoice === 2) { if (protocolChoice === 2) {
upsToEdit.protocol = 'upsd'; upsToEdit.protocol = 'upsd';
@@ -348,7 +358,7 @@ export class UpsHandler {
const errorBoxWidth = 45; const errorBoxWidth = 45;
logger.logBoxTitle('Configuration Error', errorBoxWidth); logger.logBoxTitle('Configuration Error', errorBoxWidth);
logger.logBoxLine('No configuration found.'); logger.logBoxLine('No configuration found.');
logger.logBoxLine("Please run 'nupst setup' first to create a configuration."); logger.logBoxLine("Please run 'nupst ups add' first to create a configuration.");
logger.logBoxEnd(); logger.logBoxEnd();
return; return;
} }
@@ -359,7 +369,7 @@ export class UpsHandler {
// Check if multi-UPS config // Check if multi-UPS config
if (!config.upsDevices || !Array.isArray(config.upsDevices)) { if (!config.upsDevices || !Array.isArray(config.upsDevices)) {
logger.error('Legacy single-UPS configuration detected. Cannot delete UPS.'); logger.error('Legacy single-UPS configuration detected. Cannot delete UPS.');
logger.log('Use "nupst add" to migrate to multi-UPS configuration format first.'); logger.log('Use "nupst ups add" to migrate to multi-UPS configuration format first.');
return; return;
} }
@@ -527,7 +537,7 @@ export class UpsHandler {
const errorBoxWidth = 45; const errorBoxWidth = 45;
logger.logBoxTitle('Configuration Error', errorBoxWidth); logger.logBoxTitle('Configuration Error', errorBoxWidth);
logger.logBoxLine('No configuration found.'); logger.logBoxLine('No configuration found.');
logger.logBoxLine("Please run 'nupst setup' first to create a configuration."); logger.logBoxLine("Please run 'nupst ups add' first to create a configuration.");
logger.logBoxEnd(); logger.logBoxEnd();
return; return;
} }
@@ -624,7 +634,9 @@ export class UpsHandler {
logger.logBoxLine( logger.logBoxLine(
` Battery Capacity: ${snmpConfig.customOIDs.BATTERY_CAPACITY || 'Not set'}`, ` Battery Capacity: ${snmpConfig.customOIDs.BATTERY_CAPACITY || 'Not set'}`,
); );
logger.logBoxLine(` Battery Runtime: ${snmpConfig.customOIDs.BATTERY_RUNTIME || 'Not set'}`); logger.logBoxLine(
` Battery Runtime: ${snmpConfig.customOIDs.BATTERY_RUNTIME || 'Not set'}`,
);
} }
} }
@@ -650,7 +662,9 @@ export class UpsHandler {
const upsId = isUpsConfig ? (config as IUpsConfig).id : 'default'; const upsId = isUpsConfig ? (config as IUpsConfig).id : 'default';
const upsName = isUpsConfig ? (config as IUpsConfig).name : 'Default UPS'; const upsName = isUpsConfig ? (config as IUpsConfig).name : 'Default UPS';
const protocol = isUpsConfig ? ((config as IUpsConfig).protocol || 'snmp') : 'snmp'; const protocol = isUpsConfig ? ((config as IUpsConfig).protocol || 'snmp') : 'snmp';
logger.log(`\nTesting connection to UPS: ${upsName} (${upsId}) via ${protocol.toUpperCase()}...`); logger.log(
`\nTesting connection to UPS: ${upsName} (${upsId}) via ${protocol.toUpperCase()}...`,
);
try { try {
let status: ISnmpUpsStatus; let status: ISnmpUpsStatus;
@@ -691,7 +705,9 @@ export class UpsHandler {
logger.logBoxTitle(`Connection Failed: ${upsName}`, errorBoxWidth); logger.logBoxTitle(`Connection Failed: ${upsName}`, errorBoxWidth);
logger.logBoxLine(`Error: ${error instanceof Error ? error.message : String(error)}`); logger.logBoxLine(`Error: ${error instanceof Error ? error.message : String(error)}`);
logger.logBoxEnd(); logger.logBoxEnd();
logger.log("\nPlease check your settings and run 'nupst edit' to reconfigure this UPS."); logger.log(
`\nPlease check your settings and run 'nupst ups edit ${upsId}' to reconfigure this UPS.`,
);
} }
} }
@@ -1136,12 +1152,20 @@ export class UpsHandler {
if (typeValue === 1) { if (typeValue === 1) {
// Shutdown action // Shutdown action
action.type = 'shutdown'; action.type = 'shutdown';
const defaultShutdownDelay = this.nupst.getDaemon().getConfig().defaultShutdownDelay ??
SHUTDOWN.DEFAULT_DELAY_MINUTES;
const delayInput = await prompt('Shutdown delay in minutes [5]: '); const delayInput = await prompt(
`Shutdown delay in minutes (leave empty for default ${defaultShutdownDelay}): `,
);
if (delayInput.trim()) {
const delay = parseInt(delayInput, 10); const delay = parseInt(delayInput, 10);
if (delayInput.trim() && !isNaN(delay)) { if (isNaN(delay) || delay < 0) {
logger.warn('Invalid shutdown delay, using configured default');
} else {
action.shutdownDelay = delay; action.shutdownDelay = delay;
} }
}
} else if (typeValue === 2) { } else if (typeValue === 2) {
// Webhook action // Webhook action
action.type = 'webhook'; action.type = 'webhook';
@@ -1239,7 +1263,8 @@ export class UpsHandler {
// Common Proxmox settings (both modes) // Common Proxmox settings (both modes)
const excludeInput = await prompt('VM/CT IDs to exclude (comma-separated, or empty): '); const excludeInput = await prompt('VM/CT IDs to exclude (comma-separated, or empty): ');
if (excludeInput.trim()) { if (excludeInput.trim()) {
action.proxmoxExcludeIds = excludeInput.split(',').map((s) => parseInt(s.trim(), 10)).filter((n) => !isNaN(n)); action.proxmoxExcludeIds = excludeInput.split(',').map((s) => parseInt(s.trim(), 10))
.filter((n) => !isNaN(n));
} }
const timeoutInput = await prompt('VM shutdown timeout in seconds [120]: '); const timeoutInput = await prompt('VM shutdown timeout in seconds [120]: ');
@@ -1248,9 +1273,12 @@ export class UpsHandler {
action.proxmoxStopTimeout = stopTimeout; action.proxmoxStopTimeout = stopTimeout;
} }
const forceInput = await prompt('Force-stop VMs that don\'t shut down in time? (Y/n): '); const forceInput = await prompt("Force-stop VMs that don't shut down in time? (Y/n): ");
action.proxmoxForceStop = forceInput.toLowerCase() !== 'n'; action.proxmoxForceStop = forceInput.toLowerCase() !== 'n';
const haPolicyInput = await prompt('HA-managed guest handling ([1] none, 2 haStop): ');
action.proxmoxHaPolicy = haPolicyInput.trim() === '2' ? 'haStop' : 'none';
logger.log(''); logger.log('');
logger.info('Note: Place the Proxmox action BEFORE the shutdown action'); logger.info('Note: Place the Proxmox action BEFORE the shutdown action');
logger.dim('in the action chain so VMs shut down before the host.'); logger.dim('in the action chain so VMs shut down before the host.');
+58
View File
@@ -0,0 +1,58 @@
export interface IWatchEventLike {
kind: string;
paths: string[];
}
export type TConfigReloadTransition = 'monitoringWillStart' | 'deviceCountChanged' | 'reloaded';
export interface IConfigReloadSnapshot {
transition: TConfigReloadTransition;
message: string;
shouldInitializeUpsStatus: boolean;
shouldLogMonitoringStart: boolean;
}
export function shouldReloadConfig(
event: IWatchEventLike,
configFileName: string = 'config.json',
): boolean {
return event.kind === 'modify' && event.paths.some((path) => path.includes(configFileName));
}
export function shouldRefreshPauseState(
event: IWatchEventLike,
pauseFileName: string = 'pause',
): boolean {
return ['create', 'modify', 'remove'].includes(event.kind) &&
event.paths.some((path) => path.includes(pauseFileName));
}
export function analyzeConfigReload(
oldDeviceCount: number,
newDeviceCount: number,
): IConfigReloadSnapshot {
if (newDeviceCount > 0 && oldDeviceCount === 0) {
return {
transition: 'monitoringWillStart',
message: `Configuration reloaded! Found ${newDeviceCount} UPS device(s)`,
shouldInitializeUpsStatus: false,
shouldLogMonitoringStart: true,
};
}
if (newDeviceCount !== oldDeviceCount) {
return {
transition: 'deviceCountChanged',
message: `Configuration reloaded! UPS devices: ${oldDeviceCount} -> ${newDeviceCount}`,
shouldInitializeUpsStatus: true,
shouldLogMonitoringStart: false,
};
}
return {
transition: 'reloaded',
message: 'Configuration reloaded successfully',
shouldInitializeUpsStatus: false,
shouldLogMonitoringStart: false,
};
}
+342 -454
View File
File diff suppressed because it is too large Load Diff
+198
View File
@@ -0,0 +1,198 @@
import type { IActionConfig, TPowerStatus } from './actions/base-action.ts';
import { createInitialUpsStatus, type IUpsIdentity, type IUpsStatus } from './ups-status.ts';
export interface IGroupStatusSnapshot {
updatedStatus: IUpsStatus;
transition: 'none' | 'powerStatusChange';
previousStatus?: IUpsStatus;
}
export interface IGroupThresholdEvaluation {
exceedsThreshold: boolean;
blockedByUnreachable: boolean;
representativeStatus?: IUpsStatus;
}
const destructiveActionTypes = new Set(['shutdown', 'proxmox']);
function getStatusSeverity(powerStatus: TPowerStatus): number {
switch (powerStatus) {
case 'unreachable':
return 3;
case 'onBattery':
return 2;
case 'unknown':
return 1;
case 'online':
default:
return 0;
}
}
export function selectWorstStatus(statuses: IUpsStatus[]): IUpsStatus | undefined {
return statuses.reduce<IUpsStatus | undefined>((worst, status) => {
if (!worst) {
return status;
}
const severityDiff = getStatusSeverity(status.powerStatus) -
getStatusSeverity(worst.powerStatus);
if (severityDiff > 0) {
return status;
}
if (severityDiff < 0) {
return worst;
}
if (status.batteryRuntime !== worst.batteryRuntime) {
return status.batteryRuntime < worst.batteryRuntime ? status : worst;
}
if (status.batteryCapacity !== worst.batteryCapacity) {
return status.batteryCapacity < worst.batteryCapacity ? status : worst;
}
return worst;
}, undefined);
}
function deriveGroupPowerStatus(
mode: 'redundant' | 'nonRedundant',
memberStatuses: IUpsStatus[],
): TPowerStatus {
if (memberStatuses.length === 0) {
return 'unknown';
}
if (memberStatuses.some((status) => status.powerStatus === 'unreachable')) {
return 'unreachable';
}
if (mode === 'redundant') {
if (memberStatuses.every((status) => status.powerStatus === 'onBattery')) {
return 'onBattery';
}
} else if (memberStatuses.some((status) => status.powerStatus === 'onBattery')) {
return 'onBattery';
}
if (memberStatuses.some((status) => status.powerStatus === 'unknown')) {
return 'unknown';
}
return 'online';
}
function pickRepresentativeStatus(
powerStatus: TPowerStatus,
memberStatuses: IUpsStatus[],
): IUpsStatus | undefined {
const matchingStatuses = memberStatuses.filter((status) => status.powerStatus === powerStatus);
return selectWorstStatus(matchingStatuses.length > 0 ? matchingStatuses : memberStatuses);
}
export function buildGroupStatusSnapshot(
group: IUpsIdentity,
mode: 'redundant' | 'nonRedundant',
memberStatuses: IUpsStatus[],
currentStatus: IUpsStatus | undefined,
currentTime: number,
): IGroupStatusSnapshot {
const previousStatus = currentStatus || createInitialUpsStatus(group, currentTime);
const powerStatus = deriveGroupPowerStatus(mode, memberStatuses);
const representative = pickRepresentativeStatus(powerStatus, memberStatuses) || previousStatus;
const updatedStatus: IUpsStatus = {
...previousStatus,
id: group.id,
name: group.name,
powerStatus,
batteryCapacity: representative.batteryCapacity,
batteryRuntime: representative.batteryRuntime,
outputLoad: representative.outputLoad,
outputPower: representative.outputPower,
outputVoltage: representative.outputVoltage,
outputCurrent: representative.outputCurrent,
lastCheckTime: currentTime,
consecutiveFailures: 0,
unreachableSince: powerStatus === 'unreachable'
? previousStatus.unreachableSince || currentTime
: 0,
lastStatusChange: previousStatus.lastStatusChange || currentTime,
};
if (previousStatus.powerStatus !== powerStatus) {
updatedStatus.lastStatusChange = currentTime;
if (powerStatus === 'unreachable') {
updatedStatus.unreachableSince = currentTime;
}
return {
updatedStatus,
transition: 'powerStatusChange',
previousStatus,
};
}
return {
updatedStatus,
transition: 'none',
previousStatus: currentStatus,
};
}
export function evaluateGroupActionThreshold(
actionConfig: IActionConfig,
mode: 'redundant' | 'nonRedundant',
memberStatuses: IUpsStatus[],
): IGroupThresholdEvaluation {
if (!actionConfig.thresholds || memberStatuses.length === 0) {
return {
exceedsThreshold: false,
blockedByUnreachable: false,
};
}
const criticalMembers = memberStatuses.filter((status) =>
status.powerStatus === 'onBattery' &&
(status.batteryCapacity < actionConfig.thresholds!.battery ||
status.batteryRuntime < actionConfig.thresholds!.runtime)
);
const exceedsThreshold = mode === 'redundant'
? criticalMembers.length === memberStatuses.length
: criticalMembers.length > 0;
return {
exceedsThreshold,
blockedByUnreachable: exceedsThreshold &&
destructiveActionTypes.has(actionConfig.type) &&
memberStatuses.some((status) => status.powerStatus === 'unreachable'),
representativeStatus: selectWorstStatus(criticalMembers),
};
}
export function buildGroupThresholdContextStatus(
group: IUpsIdentity,
evaluations: IGroupThresholdEvaluation[],
enteredActionIndexes: number[],
fallbackStatus: IUpsStatus,
currentTime: number,
): IUpsStatus {
const representativeStatuses = enteredActionIndexes
.map((index) => evaluations[index]?.representativeStatus)
.filter((status): status is IUpsStatus => !!status);
const representative = selectWorstStatus(representativeStatuses) || fallbackStatus;
return {
...fallbackStatus,
id: group.id,
name: group.name,
powerStatus: 'onBattery',
batteryCapacity: representative.batteryCapacity,
batteryRuntime: representative.batteryRuntime,
outputLoad: representative.outputLoad,
outputPower: representative.outputPower,
outputVoltage: representative.outputVoltage,
outputCurrent: representative.outputCurrent,
lastCheckTime: currentTime,
};
}
+2 -1
View File
@@ -1,7 +1,8 @@
import * as http from 'node:http'; import * as http from 'node:http';
import { URL } from 'node:url'; import { URL } from 'node:url';
import { logger } from './logger.ts'; import { logger } from './logger.ts';
import type { IPauseState, IUpsStatus } from './daemon.ts'; import type { IPauseState } from './pause-state.ts';
import type { IUpsStatus } from './ups-status.ts';
/** /**
* HTTP Server for exposing UPS status as JSON * HTTP Server for exposing UPS status as JSON
+1 -1
View File
@@ -10,7 +10,7 @@ import process from 'node:process';
*/ */
async function main() { async function main() {
const cli = new NupstCli(); const cli = new NupstCli();
await cli.parseAndExecute(process.argv); await cli.parseAndExecute(process.argv.slice(2));
} }
// Run the main function and handle any errors // Run the main function and handle any errors
+1 -1
View File
@@ -58,7 +58,7 @@ export class MigrationRunner {
if (anyMigrationsRan) { if (anyMigrationsRan) {
logger.success('Configuration migrations complete'); logger.success('Configuration migrations complete');
} else { } else {
logger.success('config format ok'); logger.success('Configuration format OK');
} }
return { return {
+1 -3
View File
@@ -37,8 +37,7 @@ import { logger } from '../logger.ts';
* { * {
* type: "shutdown", * type: "shutdown",
* thresholds: { battery: 60, runtime: 20 }, * thresholds: { battery: 60, runtime: 20 },
* triggerMode: "onlyThresholds", * triggerMode: "onlyThresholds"
* shutdownDelay: 5
* } * }
* ] * ]
* } * }
@@ -93,7 +92,6 @@ export class MigrationV4_0ToV4_1 extends BaseMigration {
runtime: deviceThresholds.runtime, runtime: deviceThresholds.runtime,
}, },
triggerMode: 'onlyThresholds', // Preserve old behavior (only on threshold violation) triggerMode: 'onlyThresholds', // Preserve old behavior (only on threshold violation)
shutdownDelay: 5, // Default delay
}, },
]; ];
logger.dim( logger.dim(
+68
View File
@@ -0,0 +1,68 @@
import * as fs from 'node:fs';
/**
* Pause state interface
*/
export interface IPauseState {
/** Timestamp when pause was activated */
pausedAt: number;
/** Who initiated the pause (e.g., 'cli', 'api') */
pausedBy: string;
/** Optional reason for pausing */
reason?: string;
/** When to auto-resume (null = indefinite, timestamp in ms) */
resumeAt?: number | null;
}
export type TPauseTransition = 'unchanged' | 'paused' | 'resumed' | 'autoResumed';
export interface IPauseSnapshot {
isPaused: boolean;
pauseState: IPauseState | null;
transition: TPauseTransition;
}
export function loadPauseSnapshot(
filePath: string,
wasPaused: boolean,
now: number = Date.now(),
): IPauseSnapshot {
try {
if (!fs.existsSync(filePath)) {
return {
isPaused: false,
pauseState: null,
transition: wasPaused ? 'resumed' : 'unchanged',
};
}
const data = fs.readFileSync(filePath, 'utf8');
const pauseState = JSON.parse(data) as IPauseState;
if (pauseState.resumeAt && now >= pauseState.resumeAt) {
try {
fs.unlinkSync(filePath);
} catch (_error) {
// Ignore deletion errors and still treat the pause as expired.
}
return {
isPaused: false,
pauseState: null,
transition: wasPaused ? 'autoResumed' : 'unchanged',
};
}
return {
isPaused: true,
pauseState,
transition: wasPaused ? 'unchanged' : 'paused',
};
} catch (_error) {
return {
isPaused: false,
pauseState: null,
transition: 'unchanged',
};
}
}
+145
View File
@@ -0,0 +1,145 @@
import process from 'node:process';
import * as fs from 'node:fs';
import { exec, execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { logger } from './logger.ts';
const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);
interface IShutdownAlternative {
cmd: string;
args: string[];
}
interface IAlternativeLogConfig {
resolvedMessage: (commandPath: string, args: string[]) => string;
pathMessage: (command: string, args: string[]) => string;
failureMessage?: (command: string, error: unknown) => string;
}
export class ShutdownExecutor {
private readonly commonCommandDirs = ['/sbin', '/usr/sbin', '/bin', '/usr/bin'];
public async scheduleShutdown(delayMinutes: number): Promise<void> {
const shutdownMessage = `UPS battery critical, shutting down in ${delayMinutes} minutes`;
const shutdownCommandPath = this.findCommandPath('shutdown');
if (shutdownCommandPath) {
logger.log(`Found shutdown command at: ${shutdownCommandPath}`);
logger.log(`Executing: ${shutdownCommandPath} -h +${delayMinutes} "UPS battery critical..."`);
const { stdout } = await execFileAsync(shutdownCommandPath, [
'-h',
`+${delayMinutes}`,
shutdownMessage,
]);
logger.log(`Shutdown initiated: ${stdout}`);
return;
}
try {
logger.log('Shutdown command not found in common paths, trying via PATH...');
const { stdout } = await execAsync(
`shutdown -h +${delayMinutes} "${shutdownMessage}"`,
{ env: process.env },
);
logger.log(`Shutdown initiated: ${stdout}`);
} catch (error) {
throw new Error(
`Shutdown command not found: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
public async forceImmediateShutdown(): Promise<void> {
const shutdownMessage = 'EMERGENCY: UPS battery critically low, shutting down NOW';
const shutdownCommandPath = this.findCommandPath('shutdown');
if (shutdownCommandPath) {
logger.log(`Found shutdown command at: ${shutdownCommandPath}`);
logger.log(`Executing emergency shutdown: ${shutdownCommandPath} -h now`);
await execFileAsync(shutdownCommandPath, ['-h', 'now', shutdownMessage]);
return;
}
logger.log('Shutdown command not found in common paths, trying via PATH...');
await execAsync(`shutdown -h now "${shutdownMessage}"`, {
env: process.env,
});
}
public async tryScheduledAlternatives(): Promise<boolean> {
return await this.tryAlternatives(
[
{ cmd: 'poweroff', args: ['--force'] },
{ cmd: 'halt', args: ['-p'] },
{ cmd: 'systemctl', args: ['poweroff'] },
{ cmd: 'reboot', args: ['-p'] },
],
{
resolvedMessage: (commandPath, args) =>
`Trying alternative shutdown method: ${commandPath} ${args.join(' ')}`,
pathMessage: (command, args) => `Trying alternative via PATH: ${command} ${args.join(' ')}`,
failureMessage: (command, error) => `Alternative method ${command} failed: ${error}`,
},
);
}
public async tryEmergencyAlternatives(): Promise<boolean> {
return await this.tryAlternatives(
[
{ cmd: 'poweroff', args: ['--force'] },
{ cmd: 'halt', args: ['-p'] },
{ cmd: 'systemctl', args: ['poweroff'] },
],
{
resolvedMessage: (commandPath, args) => `Emergency: using ${commandPath} ${args.join(' ')}`,
pathMessage: (command) => `Emergency: trying ${command} via PATH`,
},
);
}
private findCommandPath(command: string): string | null {
for (const directory of this.commonCommandDirs) {
const commandPath = `${directory}/${command}`;
try {
if (fs.existsSync(commandPath)) {
return commandPath;
}
} catch (_error) {
// Continue checking other paths.
}
}
return null;
}
private async tryAlternatives(
alternatives: IShutdownAlternative[],
logConfig: IAlternativeLogConfig,
): Promise<boolean> {
for (const alternative of alternatives) {
try {
const commandPath = this.findCommandPath(alternative.cmd);
if (commandPath) {
logger.log(logConfig.resolvedMessage(commandPath, alternative.args));
await execFileAsync(commandPath, alternative.args);
return true;
}
logger.log(logConfig.pathMessage(alternative.cmd, alternative.args));
await execAsync(`${alternative.cmd} ${alternative.args.join(' ')}`, {
env: process.env,
});
return true;
} catch (error) {
if (logConfig.failureMessage) {
logger.error(logConfig.failureMessage(alternative.cmd, error));
}
}
}
return false;
}
}
+72
View File
@@ -0,0 +1,72 @@
import type { IUpsStatus as IProtocolUpsStatus } from './snmp/types.ts';
export interface IShutdownMonitoringRow extends Record<string, string> {
name: string;
battery: string;
runtime: string;
status: string;
}
export interface IShutdownRowFormatters {
battery: (batteryCapacity: number) => string;
runtime: (batteryRuntime: number) => string;
ok: (text: string) => string;
critical: (text: string) => string;
error: (text: string) => string;
}
export interface IShutdownEmergencyCandidate<TUps> {
ups: TUps;
status: IProtocolUpsStatus;
}
export function isEmergencyRuntime(
batteryRuntime: number,
emergencyRuntimeMinutes: number,
): boolean {
return batteryRuntime < emergencyRuntimeMinutes;
}
export function buildShutdownStatusRow(
upsName: string,
status: IProtocolUpsStatus,
emergencyRuntimeMinutes: number,
formatters: IShutdownRowFormatters,
): { row: IShutdownMonitoringRow; isCritical: boolean } {
const isCritical = isEmergencyRuntime(status.batteryRuntime, emergencyRuntimeMinutes);
return {
row: {
name: upsName,
battery: formatters.battery(status.batteryCapacity),
runtime: formatters.runtime(status.batteryRuntime),
status: isCritical ? formatters.critical('CRITICAL!') : formatters.ok('OK'),
},
isCritical,
};
}
export function buildShutdownErrorRow(
upsName: string,
errorFormatter: (text: string) => string,
): IShutdownMonitoringRow {
return {
name: upsName,
battery: errorFormatter('N/A'),
runtime: errorFormatter('N/A'),
status: errorFormatter('ERROR'),
};
}
export function selectEmergencyCandidate<TUps>(
currentCandidate: IShutdownEmergencyCandidate<TUps> | null,
ups: TUps,
status: IProtocolUpsStatus,
emergencyRuntimeMinutes: number,
): IShutdownEmergencyCandidate<TUps> | null {
if (currentCandidate || !isEmergencyRuntime(status.batteryRuntime, emergencyRuntimeMinutes)) {
return currentCandidate;
}
return { ups, status };
}
+231 -157
View File
@@ -6,6 +6,73 @@ import { SNMP } from '../constants.ts';
import { logger } from '../logger.ts'; import { logger } from '../logger.ts';
import type { INupstAccessor } from '../interfaces/index.ts'; import type { INupstAccessor } from '../interfaces/index.ts';
type TSnmpMetricDescription =
| 'power status'
| 'battery capacity'
| 'battery runtime'
| 'output load'
| 'output power'
| 'output voltage'
| 'output current';
type TSnmpResponseValue = string | number | bigint | boolean | Buffer;
type TSnmpValue = string | number | boolean | Buffer;
interface ISnmpVarbind {
oid: string;
type: number;
value: TSnmpResponseValue;
}
interface ISnmpSessionOptions {
port: number;
retries: number;
timeout: number;
transport: 'udp4' | 'udp6';
idBitsSize: 16 | 32;
context: string;
version: number;
}
interface ISnmpV3User {
name: string;
level: number;
authProtocol?: string;
authKey?: string;
privProtocol?: string;
privKey?: string;
}
interface ISnmpSession {
get(oids: string[], callback: (error: Error | null, varbinds?: ISnmpVarbind[]) => void): void;
close(): void;
}
interface ISnmpModule {
Version1: number;
Version2c: number;
Version3: number;
SecurityLevel: {
noAuthNoPriv: number;
authNoPriv: number;
authPriv: number;
};
AuthProtocols: {
md5: string;
sha: string;
};
PrivProtocols: {
des: string;
aes: string;
};
createSession(target: string, community: string, options: ISnmpSessionOptions): ISnmpSession;
createV3Session(target: string, user: ISnmpV3User, options: ISnmpSessionOptions): ISnmpSession;
isVarbindError(varbind: ISnmpVarbind): boolean;
varbindError(varbind: ISnmpVarbind): string;
}
const snmpLib = snmp as unknown as ISnmpModule;
/** /**
* Class for SNMP communication with UPS devices * Class for SNMP communication with UPS devices
* Main entry point for SNMP functionality * Main entry point for SNMP functionality
@@ -84,6 +151,120 @@ export class NupstSnmp {
} }
} }
private createSessionOptions(config: ISnmpConfig): ISnmpSessionOptions {
return {
port: config.port,
retries: SNMP.RETRIES,
timeout: config.timeout,
transport: 'udp4',
idBitsSize: 32,
context: config.context || '',
version: config.version === 1
? snmpLib.Version1
: config.version === 2
? snmpLib.Version2c
: snmpLib.Version3,
};
}
private buildV3User(
config: ISnmpConfig,
): { user: ISnmpV3User; levelLabel: NonNullable<ISnmpConfig['securityLevel']> } {
const requestedSecurityLevel = config.securityLevel || 'noAuthNoPriv';
const user: ISnmpV3User = {
name: config.username || '',
level: snmpLib.SecurityLevel.noAuthNoPriv,
};
let levelLabel: NonNullable<ISnmpConfig['securityLevel']> = 'noAuthNoPriv';
if (requestedSecurityLevel === 'authNoPriv') {
user.level = snmpLib.SecurityLevel.authNoPriv;
levelLabel = 'authNoPriv';
if (config.authProtocol && config.authKey) {
user.authProtocol = this.resolveAuthProtocol(config.authProtocol);
user.authKey = config.authKey;
} else {
user.level = snmpLib.SecurityLevel.noAuthNoPriv;
levelLabel = 'noAuthNoPriv';
if (this.debug) {
logger.warn('Missing authProtocol or authKey, falling back to noAuthNoPriv');
}
}
} else if (requestedSecurityLevel === 'authPriv') {
user.level = snmpLib.SecurityLevel.authPriv;
levelLabel = 'authPriv';
if (config.authProtocol && config.authKey) {
user.authProtocol = this.resolveAuthProtocol(config.authProtocol);
user.authKey = config.authKey;
if (config.privProtocol && config.privKey) {
user.privProtocol = this.resolvePrivProtocol(config.privProtocol);
user.privKey = config.privKey;
} else {
user.level = snmpLib.SecurityLevel.authNoPriv;
levelLabel = 'authNoPriv';
if (this.debug) {
logger.warn('Missing privProtocol or privKey, falling back to authNoPriv');
}
}
} else {
user.level = snmpLib.SecurityLevel.noAuthNoPriv;
levelLabel = 'noAuthNoPriv';
if (this.debug) {
logger.warn('Missing authProtocol or authKey, falling back to noAuthNoPriv');
}
}
}
return { user, levelLabel };
}
private resolveAuthProtocol(protocol: NonNullable<ISnmpConfig['authProtocol']>): string {
return protocol === 'MD5' ? snmpLib.AuthProtocols.md5 : snmpLib.AuthProtocols.sha;
}
private resolvePrivProtocol(protocol: NonNullable<ISnmpConfig['privProtocol']>): string {
return protocol === 'DES' ? snmpLib.PrivProtocols.des : snmpLib.PrivProtocols.aes;
}
private normalizeSnmpValue(value: TSnmpResponseValue): TSnmpValue {
if (Buffer.isBuffer(value)) {
const isPrintableAscii = value.every((byte: number) => byte >= 32 && byte <= 126);
return isPrintableAscii ? value.toString() : value;
}
if (typeof value === 'bigint') {
return Number(value);
}
return value;
}
private coerceNumericSnmpValue(
value: TSnmpValue | 0,
description: TSnmpMetricDescription,
): number {
if (typeof value === 'number') {
return Number.isFinite(value) ? value : 0;
}
if (typeof value === 'string') {
const trimmedValue = value.trim();
const parsedValue = Number(trimmedValue);
if (trimmedValue && Number.isFinite(parsedValue)) {
return parsedValue;
}
}
if (this.debug) {
logger.warn(`Non-numeric ${description} value received from SNMP, using 0`);
}
return 0;
}
/** /**
* Send an SNMP GET request using the net-snmp package * Send an SNMP GET request using the net-snmp package
* @param oid OID to query * @param oid OID to query
@@ -95,130 +276,39 @@ export class NupstSnmp {
oid: string, oid: string,
config = this.DEFAULT_CONFIG, config = this.DEFAULT_CONFIG,
_retryCount = 0, _retryCount = 0,
// deno-lint-ignore no-explicit-any ): Promise<TSnmpValue> {
): Promise<any> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (this.debug) { if (this.debug) {
logger.dim( logger.dim(
`Sending SNMP v${config.version} GET request for OID ${oid} to ${config.host}:${config.port}`, `Sending SNMP v${config.version} GET request for OID ${oid} to ${config.host}:${config.port}`,
); );
if (config.version === 1 || config.version === 2) {
logger.dim(`Using community: ${config.community}`); logger.dim(`Using community: ${config.community}`);
} }
// Create SNMP options based on configuration
// deno-lint-ignore no-explicit-any
const options: any = {
port: config.port,
retries: SNMP.RETRIES, // Number of retries
timeout: config.timeout,
transport: 'udp4',
idBitsSize: 32,
context: config.context || '',
};
// Set version based on config
if (config.version === 1) {
options.version = snmp.Version1;
} else if (config.version === 2) {
options.version = snmp.Version2c;
} else {
options.version = snmp.Version3;
} }
// Create appropriate session based on SNMP version const options = this.createSessionOptions(config);
let session; const session: ISnmpSession = config.version === 3
? (() => {
if (config.version === 3) { const { user, levelLabel } = this.buildV3User(config);
// For SNMPv3, we need to set up authentication and privacy
// For SNMPv3, we need a valid security level
const securityLevel = config.securityLevel || 'noAuthNoPriv';
// Create the user object with required structure for net-snmp
// deno-lint-ignore no-explicit-any
const user: any = {
name: config.username || '',
};
// Set security level
if (securityLevel === 'noAuthNoPriv') {
user.level = snmp.SecurityLevel.noAuthNoPriv;
} else if (securityLevel === 'authNoPriv') {
user.level = snmp.SecurityLevel.authNoPriv;
// Set auth protocol - must provide both protocol and key
if (config.authProtocol && config.authKey) {
if (config.authProtocol === 'MD5') {
user.authProtocol = snmp.AuthProtocols.md5;
} else if (config.authProtocol === 'SHA') {
user.authProtocol = snmp.AuthProtocols.sha;
}
user.authKey = config.authKey;
} else {
// Fallback to noAuthNoPriv if auth details missing
user.level = snmp.SecurityLevel.noAuthNoPriv;
if (this.debug) {
logger.warn('Missing authProtocol or authKey, falling back to noAuthNoPriv');
}
}
} else if (securityLevel === 'authPriv') {
user.level = snmp.SecurityLevel.authPriv;
// Set auth protocol - must provide both protocol and key
if (config.authProtocol && config.authKey) {
if (config.authProtocol === 'MD5') {
user.authProtocol = snmp.AuthProtocols.md5;
} else if (config.authProtocol === 'SHA') {
user.authProtocol = snmp.AuthProtocols.sha;
}
user.authKey = config.authKey;
// Set privacy protocol - must provide both protocol and key
if (config.privProtocol && config.privKey) {
if (config.privProtocol === 'DES') {
user.privProtocol = snmp.PrivProtocols.des;
} else if (config.privProtocol === 'AES') {
user.privProtocol = snmp.PrivProtocols.aes;
}
user.privKey = config.privKey;
} else {
// Fallback to authNoPriv if priv details missing
user.level = snmp.SecurityLevel.authNoPriv;
if (this.debug) {
logger.warn('Missing privProtocol or privKey, falling back to authNoPriv');
}
}
} else {
// Fallback to noAuthNoPriv if auth details missing
user.level = snmp.SecurityLevel.noAuthNoPriv;
if (this.debug) {
logger.warn('Missing authProtocol or authKey, falling back to noAuthNoPriv');
}
}
}
if (this.debug) { if (this.debug) {
const levelName = Object.keys(snmp.SecurityLevel).find((key) =>
snmp.SecurityLevel[key] === user.level
);
logger.dim( logger.dim(
`SNMPv3 user configuration: name=${user.name}, level=${levelName}, authProtocol=${ `SNMPv3 user configuration: name=${user.name}, level=${levelLabel}, authProtocol=${
user.authProtocol ? 'Set' : 'Not Set' user.authProtocol ? 'Set' : 'Not Set'
}, privProtocol=${user.privProtocol ? 'Set' : 'Not Set'}`, }, privProtocol=${user.privProtocol ? 'Set' : 'Not Set'}`,
); );
} }
session = snmp.createV3Session(config.host, user, options); return snmpLib.createV3Session(config.host, user, options);
} else { })()
// For SNMPv1/v2c, we use the community string : snmpLib.createSession(config.host, config.community || 'public', options);
session = snmp.createSession(config.host, config.community || 'public', options);
}
// Convert the OID string to an array of OIDs if multiple OIDs are needed // Convert the OID string to an array of OIDs if multiple OIDs are needed
const oids = [oid]; const oids = [oid];
// Send the GET request // Send the GET request
// deno-lint-ignore no-explicit-any session.get(oids, (error: Error | null, varbinds?: ISnmpVarbind[]) => {
session.get(oids, (error: Error | null, varbinds: any[]) => {
// Close the session to release resources // Close the session to release resources
session.close(); session.close();
@@ -230,7 +320,9 @@ export class NupstSnmp {
return; return;
} }
if (!varbinds || varbinds.length === 0) { const varbind = varbinds?.[0];
if (!varbind) {
if (this.debug) { if (this.debug) {
logger.error('No varbinds returned in response'); logger.error('No varbinds returned in response');
} }
@@ -239,36 +331,20 @@ export class NupstSnmp {
} }
// Check for SNMP errors in the response // Check for SNMP errors in the response
if ( if (snmpLib.isVarbindError(varbind)) {
varbinds[0].type === snmp.ObjectType.NoSuchObject || const errorMessage = snmpLib.varbindError(varbind);
varbinds[0].type === snmp.ObjectType.NoSuchInstance ||
varbinds[0].type === snmp.ObjectType.EndOfMibView
) {
if (this.debug) { if (this.debug) {
logger.error(`SNMP error: ${snmp.ObjectType[varbinds[0].type]}`); logger.error(`SNMP error: ${errorMessage}`);
} }
reject(new Error(`SNMP error: ${snmp.ObjectType[varbinds[0].type]}`)); reject(new Error(`SNMP error: ${errorMessage}`));
return; return;
} }
// Process the response value based on its type const value = this.normalizeSnmpValue(varbind.value);
let value = varbinds[0].value;
// Handle specific types that might need conversion
if (Buffer.isBuffer(value)) {
// If value is a Buffer, try to convert it to a string if it's printable ASCII
const isPrintableAscii = value.every((byte: number) => byte >= 32 && byte <= 126);
if (isPrintableAscii) {
value = value.toString();
}
} else if (typeof value === 'bigint') {
// Convert BigInt to a normal number or string if needed
value = Number(value);
}
if (this.debug) { if (this.debug) {
logger.dim( logger.dim(
`SNMP response: oid=${varbinds[0].oid}, type=${varbinds[0].type}, value=${value}`, `SNMP response: oid=${varbind.oid}, type=${varbind.type}, value=${value}`,
); );
} }
@@ -315,43 +391,44 @@ export class NupstSnmp {
} }
// Get all values with independent retry logic // Get all values with independent retry logic
const powerStatusValue = await this.getSNMPValueWithRetry( const powerStatusValue = this.coerceNumericSnmpValue(
this.activeOIDs.POWER_STATUS, await this.getSNMPValueWithRetry(this.activeOIDs.POWER_STATUS, 'power status', config),
'power status', 'power status',
config,
); );
const batteryCapacity = await this.getSNMPValueWithRetry( const batteryCapacity = this.coerceNumericSnmpValue(
await this.getSNMPValueWithRetry(
this.activeOIDs.BATTERY_CAPACITY, this.activeOIDs.BATTERY_CAPACITY,
'battery capacity', 'battery capacity',
config, config,
) || 0; ),
const batteryRuntime = await this.getSNMPValueWithRetry( 'battery capacity',
);
const batteryRuntime = this.coerceNumericSnmpValue(
await this.getSNMPValueWithRetry(
this.activeOIDs.BATTERY_RUNTIME, this.activeOIDs.BATTERY_RUNTIME,
'battery runtime', 'battery runtime',
config, config,
) || 0; ),
'battery runtime',
);
// Get power draw metrics // Get power draw metrics
const outputLoad = await this.getSNMPValueWithRetry( const outputLoad = this.coerceNumericSnmpValue(
this.activeOIDs.OUTPUT_LOAD, await this.getSNMPValueWithRetry(this.activeOIDs.OUTPUT_LOAD, 'output load', config),
'output load', 'output load',
config, );
) || 0; const outputPower = this.coerceNumericSnmpValue(
const outputPower = await this.getSNMPValueWithRetry( await this.getSNMPValueWithRetry(this.activeOIDs.OUTPUT_POWER, 'output power', config),
this.activeOIDs.OUTPUT_POWER,
'output power', 'output power',
config, );
) || 0; const outputVoltage = this.coerceNumericSnmpValue(
const outputVoltage = await this.getSNMPValueWithRetry( await this.getSNMPValueWithRetry(this.activeOIDs.OUTPUT_VOLTAGE, 'output voltage', config),
this.activeOIDs.OUTPUT_VOLTAGE,
'output voltage', 'output voltage',
config, );
) || 0; const outputCurrent = this.coerceNumericSnmpValue(
const outputCurrent = await this.getSNMPValueWithRetry( await this.getSNMPValueWithRetry(this.activeOIDs.OUTPUT_CURRENT, 'output current', config),
this.activeOIDs.OUTPUT_CURRENT,
'output current', 'output current',
config, );
) || 0;
// Determine power status - handle different values for different UPS models // Determine power status - handle different values for different UPS models
const powerStatus = this.determinePowerStatus(config.upsModel, powerStatusValue); const powerStatus = this.determinePowerStatus(config.upsModel, powerStatusValue);
@@ -430,10 +507,9 @@ export class NupstSnmp {
*/ */
private async getSNMPValueWithRetry( private async getSNMPValueWithRetry(
oid: string, oid: string,
description: string, description: TSnmpMetricDescription,
config: ISnmpConfig, config: ISnmpConfig,
// deno-lint-ignore no-explicit-any ): Promise<TSnmpValue | 0> {
): Promise<any> {
if (oid === '') { if (oid === '') {
if (this.debug) { if (this.debug) {
logger.dim(`No OID provided for ${description}, skipping`); logger.dim(`No OID provided for ${description}, skipping`);
@@ -485,10 +561,9 @@ export class NupstSnmp {
*/ */
private async tryFallbackSecurityLevels( private async tryFallbackSecurityLevels(
oid: string, oid: string,
description: string, description: TSnmpMetricDescription,
config: ISnmpConfig, config: ISnmpConfig,
// deno-lint-ignore no-explicit-any ): Promise<TSnmpValue | 0> {
): Promise<any> {
if (this.debug) { if (this.debug) {
logger.dim(`Retrying ${description} with fallback security level...`); logger.dim(`Retrying ${description} with fallback security level...`);
} }
@@ -551,10 +626,9 @@ export class NupstSnmp {
*/ */
private async tryStandardOids( private async tryStandardOids(
_oid: string, _oid: string,
description: string, description: TSnmpMetricDescription,
config: ISnmpConfig, config: ISnmpConfig,
// deno-lint-ignore no-explicit-any ): Promise<TSnmpValue | 0> {
): Promise<any> {
try { try {
// Try RFC 1628 standard UPS MIB OIDs // Try RFC 1628 standard UPS MIB OIDs
const standardOIDs = UpsOidSets.getStandardOids(); const standardOIDs = UpsOidSets.getStandardOids();
+24 -9
View File
@@ -5,6 +5,7 @@ import { type IUpsConfig, NupstDaemon } from './daemon.ts';
import { NupstSnmp } from './snmp/manager.ts'; import { NupstSnmp } from './snmp/manager.ts';
import { logger } from './logger.ts'; import { logger } from './logger.ts';
import { formatPowerStatus, getBatteryColor, getRuntimeColor, symbols, theme } from './colors.ts'; import { formatPowerStatus, getBatteryColor, getRuntimeColor, symbols, theme } from './colors.ts';
import { SHUTDOWN } from './constants.ts';
/** /**
* Class for managing systemd service * Class for managing systemd service
@@ -316,7 +317,6 @@ WantedBy=multi-user.target
type: 'shutdown', type: 'shutdown',
thresholds: config.thresholds, thresholds: config.thresholds,
triggerMode: 'onlyThresholds', triggerMode: 'onlyThresholds',
shutdownDelay: 5,
}, },
] ]
: [], : [],
@@ -346,6 +346,8 @@ WantedBy=multi-user.target
*/ */
private async displaySingleUpsStatus(ups: IUpsConfig, snmp: NupstSnmp): Promise<void> { private async displaySingleUpsStatus(ups: IUpsConfig, snmp: NupstSnmp): Promise<void> {
try { try {
const defaultShutdownDelay = this.daemon.getConfig().defaultShutdownDelay ??
SHUTDOWN.DEFAULT_DELAY_MINUTES;
const protocol = ups.protocol || 'snmp'; const protocol = ups.protocol || 'snmp';
let status; let status;
@@ -432,14 +434,20 @@ WantedBy=multi-user.target
actionDesc += ` (${ actionDesc += ` (${
action.triggerMode || 'onlyThresholds' action.triggerMode || 'onlyThresholds'
}: battery<${action.thresholds.battery}%, runtime<${action.thresholds.runtime}min`; }: battery<${action.thresholds.battery}%, runtime<${action.thresholds.runtime}min`;
if (action.shutdownDelay) { if (action.type === 'shutdown') {
actionDesc += `, delay=${action.shutdownDelay}s`; const shutdownDelay = action.shutdownDelay ?? defaultShutdownDelay;
actionDesc += `, delay=${shutdownDelay}min`;
} else if (action.type === 'proxmox' && action.proxmoxHaPolicy === 'haStop') {
actionDesc += ', ha=stop';
} }
actionDesc += ')'; actionDesc += ')';
} else { } else {
actionDesc += ` (${action.triggerMode || 'onlyPowerChanges'}`; actionDesc += ` (${action.triggerMode || 'onlyPowerChanges'}`;
if (action.shutdownDelay) { if (action.type === 'shutdown') {
actionDesc += `, delay=${action.shutdownDelay}s`; const shutdownDelay = action.shutdownDelay ?? defaultShutdownDelay;
actionDesc += `, delay=${shutdownDelay}min`;
} else if (action.type === 'proxmox' && action.proxmoxHaPolicy === 'haStop') {
actionDesc += ', ha=stop';
} }
actionDesc += ')'; actionDesc += ')';
} }
@@ -506,20 +514,27 @@ WantedBy=multi-user.target
// Display actions if any // Display actions if any
if (group.actions && group.actions.length > 0) { if (group.actions && group.actions.length > 0) {
const defaultShutdownDelay = config.defaultShutdownDelay ?? SHUTDOWN.DEFAULT_DELAY_MINUTES;
for (const action of group.actions) { for (const action of group.actions) {
let actionDesc = `${action.type}`; let actionDesc = `${action.type}`;
if (action.thresholds) { if (action.thresholds) {
actionDesc += ` (${ actionDesc += ` (${
action.triggerMode || 'onlyThresholds' action.triggerMode || 'onlyThresholds'
}: battery<${action.thresholds.battery}%, runtime<${action.thresholds.runtime}min`; }: battery<${action.thresholds.battery}%, runtime<${action.thresholds.runtime}min`;
if (action.shutdownDelay) { if (action.type === 'shutdown') {
actionDesc += `, delay=${action.shutdownDelay}s`; const shutdownDelay = action.shutdownDelay ?? defaultShutdownDelay;
actionDesc += `, delay=${shutdownDelay}min`;
} else if (action.type === 'proxmox' && action.proxmoxHaPolicy === 'haStop') {
actionDesc += ', ha=stop';
} }
actionDesc += ')'; actionDesc += ')';
} else { } else {
actionDesc += ` (${action.triggerMode || 'onlyPowerChanges'}`; actionDesc += ` (${action.triggerMode || 'onlyPowerChanges'}`;
if (action.shutdownDelay) { if (action.type === 'shutdown') {
actionDesc += `, delay=${action.shutdownDelay}s`; const shutdownDelay = action.shutdownDelay ?? defaultShutdownDelay;
actionDesc += `, delay=${shutdownDelay}min`;
} else if (action.type === 'proxmox' && action.proxmoxHaPolicy === 'haStop') {
actionDesc += ', ha=stop';
} }
actionDesc += ')'; actionDesc += ')';
} }
+172
View File
@@ -0,0 +1,172 @@
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 {
return getActionThresholdStates(powerStatus, batteryCapacity, batteryRuntime, actions).some(
Boolean,
);
}
export function isActionThresholdExceeded(
actionConfig: IActionConfig,
powerStatus: IProtocolUpsStatus['powerStatus'],
batteryCapacity: number,
batteryRuntime: number,
): boolean {
if (powerStatus !== 'onBattery' || !actionConfig.thresholds) {
return false;
}
return (
batteryCapacity < actionConfig.thresholds.battery ||
batteryRuntime < actionConfig.thresholds.runtime
);
}
export function getActionThresholdStates(
powerStatus: IProtocolUpsStatus['powerStatus'],
batteryCapacity: number,
batteryRuntime: number,
actions: IActionConfig[] | undefined,
): boolean[] {
if (!actions || actions.length === 0) {
return [];
}
return actions.map((actionConfig) =>
isActionThresholdExceeded(actionConfig, powerStatus, batteryCapacity, batteryRuntime)
);
}
export function getEnteredThresholdIndexes(
previousStates: boolean[] | undefined,
currentStates: boolean[],
): number[] {
const enteredIndexes: number[] = [];
for (let index = 0; index < currentStates.length; index++) {
if (currentStates[index] && !previousStates?.[index]) {
enteredIndexes.push(index);
}
}
return enteredIndexes;
}
+38
View File
@@ -0,0 +1,38 @@
export interface IUpsIdentity {
id: string;
name: string;
}
export interface IUpsStatus {
id: string;
name: string;
powerStatus: 'online' | 'onBattery' | 'unknown' | 'unreachable';
batteryCapacity: number;
batteryRuntime: number;
outputLoad: number;
outputPower: number;
outputVoltage: number;
outputCurrent: number;
lastStatusChange: number;
lastCheckTime: number;
consecutiveFailures: number;
unreachableSince: number;
}
export function createInitialUpsStatus(ups: IUpsIdentity, now: number = Date.now()): IUpsStatus {
return {
id: ups.id,
name: ups.name,
powerStatus: 'unknown',
batteryCapacity: 100,
batteryRuntime: 999,
outputLoad: 0,
outputPower: 0,
outputVoltage: 0,
outputCurrent: 0,
lastStatusChange: now,
lastCheckTime: 0,
consecutiveFailures: 0,
unreachableSince: 0,
};
}