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