Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c7b52c48d5 | |||
| e2cfa67fee | |||
| e916ccf3ae | |||
| a435bd6fed | |||
| bf4d519428 | |||
| 579667b3cd | |||
| 8dc0248763 | |||
| 1f542ca271 | |||
| 2adf1d5548 | |||
| 067a7666e4 |
@@ -1,5 +1,43 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-04-16 - 5.8.0 - feat(systemd)
|
||||||
|
improve service status reporting with structured systemctl data
|
||||||
|
|
||||||
|
- switch status collection from parsing `systemctl status` output to `systemctl show` properties for more reliable service state detection
|
||||||
|
- display a distinct "not installed" status when the unit is missing
|
||||||
|
- format systemd memory and CPU usage values into readable output for status details
|
||||||
|
|
||||||
|
## 2026-04-16 - 5.7.0 - feat(monitoring)
|
||||||
|
add edge-triggered threshold handling with group action orchestration and HA-aware Proxmox shutdowns
|
||||||
|
|
||||||
|
- Track per-action threshold entry state so threshold-based actions fire only when conditions are newly violated
|
||||||
|
- Add group monitoring and threshold evaluation for redundant and non-redundant UPS groups, including suppression of destructive actions when members are unreachable
|
||||||
|
- Support optional Proxmox HA stop requests for HA-managed guests and prevent duplicate Proxmox or host shutdown scheduling
|
||||||
|
|
||||||
|
## 2026-04-14 - 5.6.0 - feat(config)
|
||||||
|
add configurable default shutdown delay for shutdown actions
|
||||||
|
|
||||||
|
- introduces a top-level defaultShutdownDelay config value used by shutdown actions that do not define their own delay
|
||||||
|
- applies the configured default during action execution, daemon-initiated shutdowns, CLI prompts, and status display output
|
||||||
|
- preserves explicit shutdownDelay values including 0 minutes and normalizes invalid config values back to the built-in default
|
||||||
|
|
||||||
|
## 2026-04-14 - 5.5.1 - fix(cli,daemon,snmp)
|
||||||
|
normalize CLI argument parsing and extract daemon monitoring helpers with stronger SNMP typing
|
||||||
|
|
||||||
|
- Pass runtime arguments directly to the CLI in both Deno and Node entrypoints so commands and debug flags are parsed consistently
|
||||||
|
- Refactor daemon logic into dedicated pause state, config watch, UPS status, monitoring, action orchestration, shutdown execution, and shutdown monitoring modules
|
||||||
|
- Add explicit local typings and value coercion around net-snmp interactions to reduce untyped response handling
|
||||||
|
- Update user-facing CLI guidance to use current subcommands such as "nupst ups add", "nupst ups edit", and "nupst service start"
|
||||||
|
- Expand test coverage for extracted monitoring and pause-state helpers
|
||||||
|
|
||||||
|
## 2026-04-02 - 5.5.0 - feat(proxmox)
|
||||||
|
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)
|
## 2026-03-30 - 5.4.1 - fix(deps)
|
||||||
bump tsdeno and net-snmp patch dependencies
|
bump tsdeno and net-snmp patch dependencies
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@serve.zone/nupst",
|
"name": "@serve.zone/nupst",
|
||||||
"version": "5.4.1",
|
"version": "5.8.0",
|
||||||
"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
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@serve.zone/nupst",
|
"name": "@serve.zone/nupst",
|
||||||
"version": "5.4.1",
|
"version": "5.8.0",
|
||||||
"description": "Network UPS Shutdown Tool - Monitor SNMP-enabled UPS devices and orchestrate graceful system shutdowns during power emergencies",
|
"description": "Network UPS Shutdown Tool - Monitor SNMP-enabled UPS devices and orchestrate graceful system shutdowns during power emergencies",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"ups",
|
"ups",
|
||||||
|
|||||||
+66
-3
@@ -36,9 +36,14 @@
|
|||||||
- Uses: `IUpsConfig`, `INupstConfig`, `ISnmpConfig`, `IActionConfig`, `IThresholds`,
|
- Uses: `IUpsConfig`, `INupstConfig`, `ISnmpConfig`, `IActionConfig`, `IThresholds`,
|
||||||
`ISnmpUpsStatus`
|
`ISnmpUpsStatus`
|
||||||
|
|
||||||
|
7. **SNMP Manager Boundary Types (`ts/snmp/manager.ts`)**
|
||||||
|
- Added local wrapper interfaces for the untyped `net-snmp` package surface used by NUPST
|
||||||
|
- SNMP metric reads now coerce values explicitly instead of relying on `any`-typed responses
|
||||||
|
|
||||||
## Features Added (February 2026)
|
## Features Added (February 2026)
|
||||||
|
|
||||||
### Network Loss Handling
|
### Network Loss Handling
|
||||||
|
|
||||||
- `TPowerStatus` extended with `'unreachable'` state
|
- `TPowerStatus` extended with `'unreachable'` state
|
||||||
- `IUpsStatus` has `consecutiveFailures` and `unreachableSince` tracking
|
- `IUpsStatus` has `consecutiveFailures` and `unreachableSince` tracking
|
||||||
- After `NETWORK.CONSECUTIVE_FAILURE_THRESHOLD` (3) failures, UPS transitions to `unreachable`
|
- After `NETWORK.CONSECUTIVE_FAILURE_THRESHOLD` (3) failures, UPS transitions to `unreachable`
|
||||||
@@ -46,22 +51,63 @@
|
|||||||
- Recovery is logged when UPS comes back from unreachable
|
- Recovery is logged when UPS comes back from unreachable
|
||||||
|
|
||||||
### UPSD/NIS Protocol Support
|
### UPSD/NIS Protocol Support
|
||||||
|
|
||||||
- New `ts/upsd/` directory with TCP client for NUT (Network UPS Tools) servers
|
- New `ts/upsd/` directory with TCP client for NUT (Network UPS Tools) servers
|
||||||
- `ts/protocol/` directory with `ProtocolResolver` for protocol-agnostic status queries
|
- `ts/protocol/` directory with `ProtocolResolver` for protocol-agnostic status queries
|
||||||
- `IUpsConfig.protocol` field: `'snmp'` (default) or `'upsd'`
|
- `IUpsConfig.protocol` field: `'snmp'` (default) or `'upsd'`
|
||||||
- `IUpsConfig.snmp` is now optional (not needed for UPSD devices)
|
- `IUpsConfig.snmp` is now optional (not needed for UPSD devices)
|
||||||
- CLI supports protocol selection during `nupst ups add`
|
- CLI supports protocol selection during `nupst ups add`
|
||||||
- Config version bumped to `4.2` with migration from `4.1`
|
- Config version is now `4.3`, including the `4.2` -> `4.3` runtime unit migration
|
||||||
|
|
||||||
### Pause/Resume Command
|
### Pause/Resume Command
|
||||||
|
|
||||||
- File-based signaling via `/etc/nupst/pause` JSON file
|
- File-based signaling via `/etc/nupst/pause` JSON file
|
||||||
- `nupst pause [--duration 30m|2h|1d]` creates pause file
|
- `nupst pause [--duration 30m|2h|1d]` creates pause file
|
||||||
- `nupst resume` deletes pause file
|
- `nupst resume` deletes pause file
|
||||||
|
- `ts/pause-state.ts` owns pause snapshot parsing and transition detection for daemon polling
|
||||||
- Daemon polls continue but actions are suppressed while paused
|
- Daemon polls continue but actions are suppressed while paused
|
||||||
- Auto-resume after duration expires
|
- Auto-resume after duration expires
|
||||||
- HTTP API includes pause state in response
|
- HTTP API includes pause state in response
|
||||||
|
|
||||||
|
### Shutdown Orchestration
|
||||||
|
|
||||||
|
- `ts/shutdown-executor.ts` owns command discovery and fallback execution for delayed and emergency
|
||||||
|
shutdowns
|
||||||
|
- `ts/daemon.ts` now delegates OS shutdown execution instead of embedding command lookup logic
|
||||||
|
inline
|
||||||
|
- `defaultShutdownDelay` in config provides the inherited delay for shutdown actions without an
|
||||||
|
explicit `shutdownDelay` override
|
||||||
|
|
||||||
|
### Config Watch Handling
|
||||||
|
|
||||||
|
- `ts/config-watch.ts` owns file-watch event matching and config-reload transition analysis
|
||||||
|
- `ts/daemon.ts` now delegates config/pause watch event classification and reload messaging
|
||||||
|
decisions
|
||||||
|
|
||||||
|
### UPS Status Tracking
|
||||||
|
|
||||||
|
- `ts/ups-status.ts` owns the daemon UPS status shape and default status factory
|
||||||
|
- `ts/daemon.ts` now reuses a shared initializer instead of duplicating the default UPS status
|
||||||
|
object
|
||||||
|
|
||||||
|
### UPS Monitoring Transitions
|
||||||
|
|
||||||
|
- `ts/ups-monitoring.ts` owns pure UPS poll success/failure transition logic and threshold detection
|
||||||
|
- `ts/daemon.ts` now orchestrates protocol calls and logging while delegating state transitions
|
||||||
|
|
||||||
|
### Action Orchestration
|
||||||
|
|
||||||
|
- `ts/action-orchestration.ts` owns action context construction and action execution decisions
|
||||||
|
- `ts/daemon.ts` now delegates pause suppression, legacy shutdown fallback, and action context
|
||||||
|
building
|
||||||
|
|
||||||
|
### Shutdown Monitoring
|
||||||
|
|
||||||
|
- `ts/shutdown-monitoring.ts` owns shutdown-loop row building and emergency candidate selection
|
||||||
|
- `ts/daemon.ts` now keeps the shutdown loop orchestration while delegating row/emergency decisions
|
||||||
|
|
||||||
### Proxmox VM Shutdown Action
|
### Proxmox VM Shutdown Action
|
||||||
|
|
||||||
- New action type `'proxmox'` in `ts/actions/proxmox-action.ts`
|
- New action type `'proxmox'` in `ts/actions/proxmox-action.ts`
|
||||||
- Uses Proxmox REST API with PVEAPIToken authentication
|
- Uses Proxmox REST API with PVEAPIToken authentication
|
||||||
- Shuts down QEMU VMs and LXC containers before host shutdown
|
- Shuts down QEMU VMs and LXC containers before host shutdown
|
||||||
@@ -76,13 +122,30 @@
|
|||||||
- **CLI Handlers**: All use the `helpers.withPrompt()` utility for interactive input
|
- **CLI Handlers**: All use the `helpers.withPrompt()` utility for interactive input
|
||||||
- **Constants**: All timing values should be referenced from `ts/constants.ts`
|
- **Constants**: All timing values should be referenced from `ts/constants.ts`
|
||||||
- **Actions**: Use `IActionConfig` from `ts/actions/base-action.ts` for action configuration
|
- **Actions**: Use `IActionConfig` from `ts/actions/base-action.ts` for action configuration
|
||||||
- **Config version**: Currently `4.2`, migrations run automatically
|
- **Action orchestration**: Use helpers from `ts/action-orchestration.ts` for action context and
|
||||||
|
execution decisions
|
||||||
|
- **Config watch logic**: Use helpers from `ts/config-watch.ts` for file event filtering and reload
|
||||||
|
transitions
|
||||||
|
- **Pause state**: Use `loadPauseSnapshot()` and `IPauseState` from `ts/pause-state.ts`
|
||||||
|
- **Shutdown execution**: Use `ShutdownExecutor` for OS-level shutdown command lookup and fallbacks
|
||||||
|
- **Shutdown monitoring**: Use helpers from `ts/shutdown-monitoring.ts` for emergency loop rows and
|
||||||
|
candidate selection
|
||||||
|
- **UPS status state**: Use `IUpsStatus` and `createInitialUpsStatus()` from `ts/ups-status.ts`
|
||||||
|
- **UPS poll transitions**: Use helpers from `ts/ups-monitoring.ts` for success/failure updates
|
||||||
|
- **Config version**: Currently `4.3`, migrations run automatically
|
||||||
|
|
||||||
## File Organization
|
## File Organization
|
||||||
|
|
||||||
```
|
```
|
||||||
ts/
|
ts/
|
||||||
├── constants.ts # All timing/threshold constants
|
├── constants.ts # All timing/threshold constants
|
||||||
|
├── action-orchestration.ts # Action context and execution decisions
|
||||||
|
├── config-watch.ts # File watch filters and config reload transitions
|
||||||
|
├── shutdown-monitoring.ts # Shutdown loop rows and emergency selection
|
||||||
|
├── ups-monitoring.ts # Pure UPS poll transition and threshold helpers
|
||||||
|
├── pause-state.ts # Shared pause state types and transition detection
|
||||||
|
├── shutdown-executor.ts # Delayed/emergency shutdown command execution
|
||||||
|
├── ups-status.ts # Daemon UPS status shape and initializer
|
||||||
├── interfaces/
|
├── interfaces/
|
||||||
│ └── nupst-accessor.ts # Interface to break circular deps
|
│ └── nupst-accessor.ts # Interface to break circular deps
|
||||||
├── helpers/
|
├── helpers/
|
||||||
@@ -103,7 +166,7 @@ ts/
|
|||||||
│ └── index.ts
|
│ └── index.ts
|
||||||
├── migrations/
|
├── migrations/
|
||||||
│ ├── migration-runner.ts
|
│ ├── migration-runner.ts
|
||||||
│ └── migration-v4.1-to-v4.2.ts # Adds protocol field
|
│ └── migration-v4.2-to-v4.3.ts # Adds SNMP runtimeUnit defaults
|
||||||
└── cli/
|
└── cli/
|
||||||
└── ... # All handlers use helpers.withPrompt()
|
└── ... # All handlers use helpers.withPrompt()
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -12,12 +12,12 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
|||||||
|
|
||||||
- **🔌 Multi-UPS Support** — Monitor multiple UPS devices from a single daemon
|
- **🔌 Multi-UPS Support** — Monitor multiple UPS devices from a single daemon
|
||||||
- **📡 Dual Protocol Support** — SNMP (v1/v2c/v3) for network UPS + UPSD/NIS for USB-connected UPS via NUT
|
- **📡 Dual Protocol Support** — SNMP (v1/v2c/v3) for network UPS + UPSD/NIS for USB-connected UPS via NUT
|
||||||
- **🖥️ Proxmox Integration** — Gracefully shut down QEMU VMs and LXC containers before host shutdown
|
- **🖥️ Proxmox Integration** — Gracefully shut down QEMU VMs and LXC containers before host shutdown, with optional HA-aware stop requests for HA-managed guests
|
||||||
- **👥 Group Management** — Organize UPS devices into groups with flexible operating modes
|
- **👥 Group Management** — Organize UPS devices into groups with flexible operating modes
|
||||||
- **Redundant Mode** — Only trigger actions when ALL UPS devices in a group are critical
|
- **Redundant Mode** — Only trigger actions when ALL UPS devices in a group are critical
|
||||||
- **Non-Redundant Mode** — Trigger actions when ANY UPS device is critical
|
- **Non-Redundant Mode** — Trigger actions when ANY UPS device is critical
|
||||||
- **⚙️ Action System** — Define custom responses with flexible trigger conditions
|
- **⚙️ Action System** — Define custom responses with flexible trigger conditions
|
||||||
- Battery & runtime threshold triggers
|
- Edge-triggered battery & runtime threshold triggers
|
||||||
- Power status change triggers
|
- Power status change triggers
|
||||||
- Webhook notifications (POST/GET)
|
- Webhook notifications (POST/GET)
|
||||||
- Custom shell scripts
|
- Custom shell scripts
|
||||||
@@ -219,12 +219,16 @@ nupst uninstall # Completely remove NUPST (requires root)
|
|||||||
|
|
||||||
NUPST stores configuration at `/etc/nupst/config.json`. The easiest way to configure is through the interactive CLI commands, but you can also edit the JSON directly.
|
NUPST stores configuration at `/etc/nupst/config.json`. The easiest way to configure is through the interactive CLI commands, but you can also edit the JSON directly.
|
||||||
|
|
||||||
|
`defaultShutdownDelay` sets the inherited delay in minutes for shutdown actions that do not define
|
||||||
|
their own `shutdownDelay`.
|
||||||
|
|
||||||
### Example Configuration
|
### Example Configuration
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"version": "4.3",
|
"version": "4.3",
|
||||||
"checkInterval": 30000,
|
"checkInterval": 30000,
|
||||||
|
"defaultShutdownDelay": 5,
|
||||||
"httpServer": {
|
"httpServer": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"port": 8080,
|
"port": 8080,
|
||||||
@@ -250,10 +254,10 @@ NUPST stores configuration at `/etc/nupst/config.json`. The easiest way to confi
|
|||||||
"type": "proxmox",
|
"type": "proxmox",
|
||||||
"triggerMode": "onlyThresholds",
|
"triggerMode": "onlyThresholds",
|
||||||
"thresholds": { "battery": 30, "runtime": 15 },
|
"thresholds": { "battery": 30, "runtime": 15 },
|
||||||
"proxmoxHost": "localhost",
|
"proxmoxMode": "auto",
|
||||||
"proxmoxPort": 8006,
|
"proxmoxHaPolicy": "haStop",
|
||||||
"proxmoxTokenId": "root@pam!nupst",
|
"proxmoxExcludeIds": [],
|
||||||
"proxmoxTokenSecret": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
"proxmoxForceStop": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "shutdown",
|
"type": "shutdown",
|
||||||
@@ -357,6 +361,10 @@ For USB-connected UPS via [NUT (Network UPS Tools)](https://networkupstools.org/
|
|||||||
|
|
||||||
Actions define automated responses to UPS conditions. They run **sequentially in array order**, so place Proxmox actions before shutdown actions.
|
Actions define automated responses to UPS conditions. They run **sequentially in array order**, so place Proxmox actions before shutdown actions.
|
||||||
|
|
||||||
|
Threshold-based actions are **edge-triggered**: they fire when the monitored UPS or group **enters** a threshold violation, not on every polling cycle while the threshold remains violated. If the condition clears and later re-enters, the action can fire again.
|
||||||
|
|
||||||
|
Shutdown and Proxmox actions also suppress duplicate runs where possible, so overlapping UPS and group actions do not repeatedly schedule the same host or guest shutdown workflow.
|
||||||
|
|
||||||
#### Action Types
|
#### Action Types
|
||||||
|
|
||||||
| Type | Description |
|
| Type | Description |
|
||||||
@@ -364,7 +372,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
|
||||||
|
|
||||||
@@ -379,8 +387,8 @@ Actions define automated responses to UPS conditions. They run **sequentially in
|
|||||||
| Mode | Description |
|
| Mode | Description |
|
||||||
| ----------------------------- | -------------------------------------------------------- |
|
| ----------------------------- | -------------------------------------------------------- |
|
||||||
| `onlyPowerChanges` | Only when power status changes (online ↔ onBattery) |
|
| `onlyPowerChanges` | Only when power status changes (online ↔ onBattery) |
|
||||||
| `onlyThresholds` | Only when battery or runtime thresholds are violated |
|
| `onlyThresholds` | Only when battery or runtime thresholds are newly violated |
|
||||||
| `powerChangesAndThresholds` | On power changes OR threshold violations (default) |
|
| `powerChangesAndThresholds` | On power changes OR when thresholds are newly violated (default) |
|
||||||
| `anyChange` | On every polling cycle |
|
| `anyChange` | On every polling cycle |
|
||||||
|
|
||||||
#### Shutdown Action
|
#### Shutdown Action
|
||||||
@@ -396,7 +404,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 | Inherits `defaultShutdownDelay` (`5`) |
|
||||||
|
|
||||||
#### Webhook Action
|
#### Webhook Action
|
||||||
|
|
||||||
@@ -438,11 +446,42 @@ Actions define automated responses to UPS conditions. They run **sequentially in
|
|||||||
|
|
||||||
Gracefully shuts down QEMU VMs and LXC containers on a Proxmox node before the host is shut down.
|
Gracefully shuts down QEMU VMs and LXC containers on a Proxmox node before the host is shut down.
|
||||||
|
|
||||||
|
If you use Proxmox HA, NUPST can optionally request `state=stopped` for HA-managed guests instead of only issuing direct `qm` / `pct` shutdown commands.
|
||||||
|
|
||||||
|
NUPST supports **two operation modes** for Proxmox:
|
||||||
|
|
||||||
|
| 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",
|
||||||
|
"proxmoxHaPolicy": "haStop",
|
||||||
|
"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",
|
||||||
|
"proxmoxHaPolicy": "haStop",
|
||||||
"proxmoxHost": "localhost",
|
"proxmoxHost": "localhost",
|
||||||
"proxmoxPort": 8006,
|
"proxmoxPort": 8006,
|
||||||
"proxmoxTokenId": "root@pam!nupst",
|
"proxmoxTokenId": "root@pam!nupst",
|
||||||
@@ -456,28 +495,39 @@ 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` |
|
| `proxmoxHaPolicy` | HA handling for HA-managed guests | `none`, `haStop` (`none` default) |
|
||||||
|
| `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)
|
||||||
pveum user token add root@pam nupst --privsep=0
|
pveum user token add root@pam nupst --privsep=0
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**HA Policy values:**
|
||||||
|
|
||||||
|
- **`none`** — Treat HA-managed and non-HA guests the same. NUPST sends normal guest shutdown commands.
|
||||||
|
- **`haStop`** — For HA-managed guests, NUPST requests HA resource state `stopped`. Non-HA guests still use normal shutdown commands.
|
||||||
|
|
||||||
> ⚠️ **Important:** Place the Proxmox action **before** the shutdown action in the actions array so VMs are stopped before the host shuts down.
|
> ⚠️ **Important:** Place the Proxmox action **before** the shutdown action in the actions array so VMs are stopped before the host shuts down.
|
||||||
|
|
||||||
### Group Configuration
|
### Group Configuration
|
||||||
|
|
||||||
Groups coordinate actions across multiple UPS devices:
|
Groups coordinate actions across multiple UPS devices.
|
||||||
|
|
||||||
|
Group actions are evaluated **after all UPS devices have been refreshed for a polling cycle**.
|
||||||
|
|
||||||
|
There is **no aggregate battery math** across the group. Instead, each group action evaluates each member UPS against that action's own thresholds.
|
||||||
|
|
||||||
| Field | Description | Values |
|
| Field | Description | Values |
|
||||||
| ------------- | ---------------------------------- | -------------------- |
|
| ------------- | ---------------------------------- | -------------------- |
|
||||||
@@ -489,8 +539,10 @@ Groups coordinate actions across multiple UPS devices:
|
|||||||
|
|
||||||
**Group Modes:**
|
**Group Modes:**
|
||||||
|
|
||||||
- **`redundant`** — Actions trigger only when ALL UPS devices in the group are critical. Use for setups with backup power units.
|
- **`redundant`** — A threshold-based action triggers only when **all** UPS devices in the group are on battery and below that action's thresholds. Use for setups with backup power units.
|
||||||
- **`nonRedundant`** — Actions trigger when ANY UPS device is critical. Use when all UPS units must be operational.
|
- **`nonRedundant`** — A threshold-based action triggers when **any** UPS device in the group is on battery and below that action's thresholds. Use when all UPS units must be operational.
|
||||||
|
|
||||||
|
For threshold-based **destructive** group actions (`shutdown` and `proxmox`), NUPST suppresses execution while any group member is `unreachable`. This prevents acting on partial data during network failures.
|
||||||
|
|
||||||
### HTTP Server Configuration
|
### HTTP Server Configuration
|
||||||
|
|
||||||
@@ -566,6 +618,7 @@ NUPST tracks communication failures per UPS device:
|
|||||||
- After **3 consecutive failures**, the UPS status transitions to `unreachable`
|
- After **3 consecutive failures**, the UPS status transitions to `unreachable`
|
||||||
- **Shutdown actions will NOT fire** on `unreachable` — this prevents false shutdowns from network glitches
|
- **Shutdown actions will NOT fire** on `unreachable` — this prevents false shutdowns from network glitches
|
||||||
- Webhook and script actions still fire, allowing you to send alerts
|
- Webhook and script actions still fire, allowing you to send alerts
|
||||||
|
- Threshold-based destructive **group** actions are also suppressed while any required group member is `unreachable`
|
||||||
- When connectivity is restored, NUPST logs a recovery event with downtime duration
|
- When connectivity is restored, NUPST logs a recovery event with downtime duration
|
||||||
- The failure counter is capped at 100 to prevent overflow
|
- The failure counter is capped at 100 to prevent overflow
|
||||||
|
|
||||||
@@ -582,17 +635,17 @@ UPS Devices (2):
|
|||||||
✓ Main Server UPS (online - 100%, 3840min)
|
✓ Main Server UPS (online - 100%, 3840min)
|
||||||
Host: 192.168.1.100:161 (SNMP)
|
Host: 192.168.1.100:161 (SNMP)
|
||||||
Groups: Data Center
|
Groups: Data Center
|
||||||
Action: proxmox (onlyThresholds: battery<30%, runtime<15min)
|
Action: proxmox (onlyThresholds: battery<30%, runtime<15min, ha=stop)
|
||||||
Action: shutdown (onlyThresholds: battery<20%, runtime<10min, delay=10s)
|
Action: shutdown (onlyThresholds: battery<20%, runtime<10min, delay=10min)
|
||||||
|
|
||||||
✓ Local USB UPS (online - 95%, 2400min)
|
✓ Local USB UPS (online - 95%, 2400min)
|
||||||
Host: 127.0.0.1:3493 (UPSD)
|
Host: 127.0.0.1:3493 (UPSD)
|
||||||
Action: shutdown (onlyThresholds: battery<15%, runtime<5min, delay=5s)
|
Action: shutdown (onlyThresholds: battery<15%, runtime<5min, delay=5min)
|
||||||
|
|
||||||
Groups (1):
|
Groups (1):
|
||||||
ℹ Data Center (redundant)
|
ℹ Data Center (redundant)
|
||||||
UPS Devices (1): Main Server UPS
|
UPS Devices (1): Main Server UPS
|
||||||
Action: shutdown (onlyThresholds: battery<10%, runtime<5min, delay=15s)
|
Action: shutdown (onlyThresholds: battery<10%, runtime<5min, delay=15min)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Live Logs
|
### Live Logs
|
||||||
@@ -631,7 +684,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
|
||||||
|
|
||||||
@@ -741,12 +794,21 @@ 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
|
||||||
|
|
||||||
# Check token permissions
|
# Check token permissions
|
||||||
pveum user token list root@pam
|
pveum user token list root@pam
|
||||||
|
|
||||||
|
# If using proxmoxHaPolicy: haStop
|
||||||
|
ha-manager config
|
||||||
```
|
```
|
||||||
|
|
||||||
### Actions Not Triggering
|
### Actions Not Triggering
|
||||||
|
|||||||
@@ -229,10 +229,10 @@ console.log('');
|
|||||||
// === 10. Update Available Example ===
|
// === 10. Update Available Example ===
|
||||||
logger.logBoxTitle('Update Available', 70, 'warning');
|
logger.logBoxTitle('Update Available', 70, 'warning');
|
||||||
logger.logBoxLine('');
|
logger.logBoxLine('');
|
||||||
logger.logBoxLine(`Current Version: ${theme.dim('4.0.1')}`);
|
logger.logBoxLine(`Current Version: ${theme.dim('5.5.0')}`);
|
||||||
logger.logBoxLine(`Latest Version: ${theme.highlight('4.0.2')}`);
|
logger.logBoxLine(`Latest Version: ${theme.highlight('5.5.1')}`);
|
||||||
logger.logBoxLine('');
|
logger.logBoxLine('');
|
||||||
logger.logBoxLine(`Run ${theme.command('sudo nupst update')} to update`);
|
logger.logBoxLine(`Run ${theme.command('sudo nupst upgrade')} to update`);
|
||||||
logger.logBoxLine('');
|
logger.logBoxLine('');
|
||||||
logger.logBoxEnd();
|
logger.logBoxEnd();
|
||||||
|
|
||||||
|
|||||||
+661
@@ -2,9 +2,39 @@ import { assert, assertEquals, assertExists } from 'jsr:@std/assert@^1.0.0';
|
|||||||
import { NupstSnmp } from '../ts/snmp/manager.ts';
|
import { NupstSnmp } from '../ts/snmp/manager.ts';
|
||||||
import { UpsOidSets } from '../ts/snmp/oid-sets.ts';
|
import { UpsOidSets } from '../ts/snmp/oid-sets.ts';
|
||||||
import type { IOidSet, ISnmpConfig, TUpsModel } from '../ts/snmp/types.ts';
|
import type { IOidSet, ISnmpConfig, TUpsModel } from '../ts/snmp/types.ts';
|
||||||
|
import {
|
||||||
|
analyzeConfigReload,
|
||||||
|
shouldRefreshPauseState,
|
||||||
|
shouldReloadConfig,
|
||||||
|
} from '../ts/config-watch.ts';
|
||||||
|
import { type IPauseState, loadPauseSnapshot } from '../ts/pause-state.ts';
|
||||||
import { shortId } from '../ts/helpers/shortid.ts';
|
import { shortId } from '../ts/helpers/shortid.ts';
|
||||||
import { HTTP_SERVER, SNMP, THRESHOLDS, TIMING, UI } from '../ts/constants.ts';
|
import { HTTP_SERVER, SNMP, THRESHOLDS, TIMING, UI } from '../ts/constants.ts';
|
||||||
import { Action, type IActionContext } from '../ts/actions/base-action.ts';
|
import { Action, type IActionContext } from '../ts/actions/base-action.ts';
|
||||||
|
import {
|
||||||
|
applyDefaultShutdownDelay,
|
||||||
|
buildUpsActionContext,
|
||||||
|
decideUpsActionExecution,
|
||||||
|
} from '../ts/action-orchestration.ts';
|
||||||
|
import {
|
||||||
|
buildShutdownErrorRow,
|
||||||
|
buildShutdownStatusRow,
|
||||||
|
selectEmergencyCandidate,
|
||||||
|
} from '../ts/shutdown-monitoring.ts';
|
||||||
|
import {
|
||||||
|
buildFailedUpsPollSnapshot,
|
||||||
|
buildSuccessfulUpsPollSnapshot,
|
||||||
|
getActionThresholdStates,
|
||||||
|
getEnteredThresholdIndexes,
|
||||||
|
hasThresholdViolation,
|
||||||
|
isActionThresholdExceeded,
|
||||||
|
} from '../ts/ups-monitoring.ts';
|
||||||
|
import {
|
||||||
|
buildGroupStatusSnapshot,
|
||||||
|
buildGroupThresholdContextStatus,
|
||||||
|
evaluateGroupActionThreshold,
|
||||||
|
} from '../ts/group-monitoring.ts';
|
||||||
|
import { createInitialUpsStatus } from '../ts/ups-status.ts';
|
||||||
|
|
||||||
import * as qenv from 'npm:@push.rocks/qenv@^6.0.0';
|
import * as qenv from 'npm:@push.rocks/qenv@^6.0.0';
|
||||||
const testQenv = new qenv.Qenv('./', '.nogit/');
|
const testQenv = new qenv.Qenv('./', '.nogit/');
|
||||||
@@ -82,6 +112,637 @@ Deno.test('UI constants: box widths are ascending', () => {
|
|||||||
assert(UI.WIDE_BOX_WIDTH < UI.EXTRA_WIDE_BOX_WIDTH);
|
assert(UI.WIDE_BOX_WIDTH < UI.EXTRA_WIDE_BOX_WIDTH);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Pause State Tests
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
Deno.test('loadPauseSnapshot: reports paused state for valid pause file', async () => {
|
||||||
|
const tempDir = await Deno.makeTempDir();
|
||||||
|
const pauseFilePath = `${tempDir}/pause.json`;
|
||||||
|
const pauseState: IPauseState = {
|
||||||
|
pausedAt: 1000,
|
||||||
|
pausedBy: 'cli',
|
||||||
|
reason: 'maintenance',
|
||||||
|
resumeAt: 5000,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Deno.writeTextFile(pauseFilePath, JSON.stringify(pauseState));
|
||||||
|
|
||||||
|
const snapshot = loadPauseSnapshot(pauseFilePath, false, 2000);
|
||||||
|
|
||||||
|
assertEquals(snapshot.isPaused, true);
|
||||||
|
assertEquals(snapshot.pauseState, pauseState);
|
||||||
|
assertEquals(snapshot.transition, 'paused');
|
||||||
|
} finally {
|
||||||
|
await Deno.remove(tempDir, { recursive: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('loadPauseSnapshot: auto-resumes expired pause file', async () => {
|
||||||
|
const tempDir = await Deno.makeTempDir();
|
||||||
|
const pauseFilePath = `${tempDir}/pause.json`;
|
||||||
|
const pauseState: IPauseState = {
|
||||||
|
pausedAt: 1000,
|
||||||
|
pausedBy: 'cli',
|
||||||
|
resumeAt: 1500,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Deno.writeTextFile(pauseFilePath, JSON.stringify(pauseState));
|
||||||
|
|
||||||
|
const snapshot = loadPauseSnapshot(pauseFilePath, true, 2000);
|
||||||
|
|
||||||
|
assertEquals(snapshot.isPaused, false);
|
||||||
|
assertEquals(snapshot.pauseState, null);
|
||||||
|
assertEquals(snapshot.transition, 'autoResumed');
|
||||||
|
|
||||||
|
let fileExists = true;
|
||||||
|
try {
|
||||||
|
await Deno.stat(pauseFilePath);
|
||||||
|
} catch {
|
||||||
|
fileExists = false;
|
||||||
|
}
|
||||||
|
assertEquals(fileExists, false);
|
||||||
|
} finally {
|
||||||
|
await Deno.remove(tempDir, { recursive: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('loadPauseSnapshot: reports resumed when pause file disappears', async () => {
|
||||||
|
const tempDir = await Deno.makeTempDir();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const snapshot = loadPauseSnapshot(`${tempDir}/pause.json`, true, 2000);
|
||||||
|
|
||||||
|
assertEquals(snapshot.isPaused, false);
|
||||||
|
assertEquals(snapshot.pauseState, null);
|
||||||
|
assertEquals(snapshot.transition, 'resumed');
|
||||||
|
} finally {
|
||||||
|
await Deno.remove(tempDir, { recursive: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Config Watch Tests
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
Deno.test('shouldReloadConfig: matches modify events for config.json', () => {
|
||||||
|
assertEquals(
|
||||||
|
shouldReloadConfig({ kind: 'modify', paths: ['/etc/nupst/config.json'] }),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
shouldReloadConfig({ kind: 'create', paths: ['/etc/nupst/config.json'] }),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
shouldReloadConfig({ kind: 'modify', paths: ['/etc/nupst/other.json'] }),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('shouldRefreshPauseState: matches create/modify/remove pause events', () => {
|
||||||
|
assertEquals(
|
||||||
|
shouldRefreshPauseState({ kind: 'create', paths: ['/etc/nupst/pause'] }),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
shouldRefreshPauseState({ kind: 'remove', paths: ['/etc/nupst/pause'] }),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
shouldRefreshPauseState({ kind: 'modify', paths: ['/etc/nupst/config.json'] }),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('analyzeConfigReload: detects monitoring start and device count changes', () => {
|
||||||
|
assertEquals(analyzeConfigReload(0, 2), {
|
||||||
|
transition: 'monitoringWillStart',
|
||||||
|
message: 'Configuration reloaded! Found 2 UPS device(s)',
|
||||||
|
shouldInitializeUpsStatus: false,
|
||||||
|
shouldLogMonitoringStart: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
assertEquals(analyzeConfigReload(2, 3), {
|
||||||
|
transition: 'deviceCountChanged',
|
||||||
|
message: 'Configuration reloaded! UPS devices: 2 -> 3',
|
||||||
|
shouldInitializeUpsStatus: true,
|
||||||
|
shouldLogMonitoringStart: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
assertEquals(analyzeConfigReload(2, 2), {
|
||||||
|
transition: 'reloaded',
|
||||||
|
message: 'Configuration reloaded successfully',
|
||||||
|
shouldInitializeUpsStatus: false,
|
||||||
|
shouldLogMonitoringStart: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// UPS Status Tests
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
Deno.test('createInitialUpsStatus: creates default daemon UPS status shape', () => {
|
||||||
|
assertEquals(createInitialUpsStatus({ id: 'ups-1', name: 'Main UPS' }, 1234), {
|
||||||
|
id: 'ups-1',
|
||||||
|
name: 'Main UPS',
|
||||||
|
powerStatus: 'unknown',
|
||||||
|
batteryCapacity: 100,
|
||||||
|
batteryRuntime: 999,
|
||||||
|
outputLoad: 0,
|
||||||
|
outputPower: 0,
|
||||||
|
outputVoltage: 0,
|
||||||
|
outputCurrent: 0,
|
||||||
|
lastStatusChange: 1234,
|
||||||
|
lastCheckTime: 0,
|
||||||
|
consecutiveFailures: 0,
|
||||||
|
unreachableSince: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Action Orchestration Tests
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
Deno.test('buildUpsActionContext: includes previous power status and timestamp', () => {
|
||||||
|
const status = {
|
||||||
|
...createInitialUpsStatus({ id: 'ups-1', name: 'Main UPS' }, 1000),
|
||||||
|
powerStatus: 'onBattery' as const,
|
||||||
|
batteryCapacity: 42,
|
||||||
|
batteryRuntime: 15,
|
||||||
|
};
|
||||||
|
const previousStatus = {
|
||||||
|
...createInitialUpsStatus({ id: 'ups-1', name: 'Main UPS' }, 500),
|
||||||
|
powerStatus: 'online' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
buildUpsActionContext(
|
||||||
|
{ id: 'ups-1', name: 'Main UPS' },
|
||||||
|
status,
|
||||||
|
previousStatus,
|
||||||
|
'thresholdViolation',
|
||||||
|
9999,
|
||||||
|
),
|
||||||
|
{
|
||||||
|
upsId: 'ups-1',
|
||||||
|
upsName: 'Main UPS',
|
||||||
|
powerStatus: 'onBattery',
|
||||||
|
batteryCapacity: 42,
|
||||||
|
batteryRuntime: 15,
|
||||||
|
previousPowerStatus: 'online',
|
||||||
|
timestamp: 9999,
|
||||||
|
triggerReason: 'thresholdViolation',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('decideUpsActionExecution: suppresses actions while paused', () => {
|
||||||
|
const decision = decideUpsActionExecution(
|
||||||
|
true,
|
||||||
|
{ id: 'ups-1', name: 'Main UPS', actions: [{ type: 'shutdown' }] },
|
||||||
|
createInitialUpsStatus({ id: 'ups-1', name: 'Main UPS' }, 1000),
|
||||||
|
undefined,
|
||||||
|
'powerStatusChange',
|
||||||
|
9999,
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(decision, {
|
||||||
|
type: 'suppressed',
|
||||||
|
message: '[PAUSED] Actions suppressed for UPS Main UPS (trigger: powerStatusChange)',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('decideUpsActionExecution: falls back to legacy shutdown without actions', () => {
|
||||||
|
const decision = decideUpsActionExecution(
|
||||||
|
false,
|
||||||
|
{ id: 'ups-1', name: 'Main UPS' },
|
||||||
|
createInitialUpsStatus({ id: 'ups-1', name: 'Main UPS' }, 1000),
|
||||||
|
undefined,
|
||||||
|
'thresholdViolation',
|
||||||
|
9999,
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(decision, {
|
||||||
|
type: 'legacyShutdown',
|
||||||
|
reason: 'UPS "Main UPS" battery or runtime below threshold',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('decideUpsActionExecution: returns executable action plan when actions exist', () => {
|
||||||
|
const decision = decideUpsActionExecution(
|
||||||
|
false,
|
||||||
|
{ id: 'ups-1', name: 'Main UPS', actions: [{ type: 'shutdown' }] },
|
||||||
|
{
|
||||||
|
...createInitialUpsStatus({ id: 'ups-1', name: 'Main UPS' }, 1000),
|
||||||
|
powerStatus: 'onBattery',
|
||||||
|
batteryCapacity: 55,
|
||||||
|
batteryRuntime: 18,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...createInitialUpsStatus({ id: 'ups-1', name: 'Main UPS' }, 500),
|
||||||
|
powerStatus: 'online',
|
||||||
|
},
|
||||||
|
'powerStatusChange',
|
||||||
|
9999,
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(decision, {
|
||||||
|
type: 'execute',
|
||||||
|
actions: [{ type: 'shutdown' }],
|
||||||
|
context: {
|
||||||
|
upsId: 'ups-1',
|
||||||
|
upsName: 'Main UPS',
|
||||||
|
powerStatus: 'onBattery',
|
||||||
|
batteryCapacity: 55,
|
||||||
|
batteryRuntime: 18,
|
||||||
|
previousPowerStatus: 'online',
|
||||||
|
timestamp: 9999,
|
||||||
|
triggerReason: 'powerStatusChange',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('applyDefaultShutdownDelay: applies only to shutdown actions without explicit delay', () => {
|
||||||
|
const actions = [
|
||||||
|
{ type: 'shutdown' as const },
|
||||||
|
{ type: 'shutdown' as const, shutdownDelay: 0 },
|
||||||
|
{ type: 'shutdown' as const, shutdownDelay: 9 },
|
||||||
|
{ type: 'webhook' as const },
|
||||||
|
];
|
||||||
|
|
||||||
|
assertEquals(applyDefaultShutdownDelay(actions, 7), [
|
||||||
|
{ type: 'shutdown', shutdownDelay: 7 },
|
||||||
|
{ type: 'shutdown', shutdownDelay: 0 },
|
||||||
|
{ type: 'shutdown', shutdownDelay: 9 },
|
||||||
|
{ type: 'webhook' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Shutdown Monitoring Tests
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
Deno.test('buildShutdownStatusRow: marks critical rows below emergency runtime threshold', () => {
|
||||||
|
const snapshot = buildShutdownStatusRow(
|
||||||
|
'Main UPS',
|
||||||
|
{
|
||||||
|
powerStatus: 'onBattery',
|
||||||
|
batteryCapacity: 25,
|
||||||
|
batteryRuntime: 4,
|
||||||
|
outputLoad: 15,
|
||||||
|
outputPower: 100,
|
||||||
|
outputVoltage: 230,
|
||||||
|
outputCurrent: 0.4,
|
||||||
|
raw: {},
|
||||||
|
},
|
||||||
|
5,
|
||||||
|
{
|
||||||
|
battery: (value) => `B:${value}`,
|
||||||
|
runtime: (value) => `R:${value}`,
|
||||||
|
ok: (text) => `ok:${text}`,
|
||||||
|
critical: (text) => `critical:${text}`,
|
||||||
|
error: (text) => `error:${text}`,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(snapshot.isCritical, true);
|
||||||
|
assertEquals(snapshot.row, {
|
||||||
|
name: 'Main UPS',
|
||||||
|
battery: 'B:25',
|
||||||
|
runtime: 'R:4',
|
||||||
|
status: 'critical:CRITICAL!',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('buildShutdownErrorRow: builds shutdown error table row', () => {
|
||||||
|
assertEquals(buildShutdownErrorRow('Main UPS', (text) => `error:${text}`), {
|
||||||
|
name: 'Main UPS',
|
||||||
|
battery: 'error:N/A',
|
||||||
|
runtime: 'error:N/A',
|
||||||
|
status: 'error:ERROR',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('selectEmergencyCandidate: keeps first critical UPS candidate', () => {
|
||||||
|
const firstCandidate = selectEmergencyCandidate(
|
||||||
|
null,
|
||||||
|
{ id: 'ups-1', name: 'UPS 1' },
|
||||||
|
{
|
||||||
|
powerStatus: 'onBattery',
|
||||||
|
batteryCapacity: 40,
|
||||||
|
batteryRuntime: 4,
|
||||||
|
outputLoad: 10,
|
||||||
|
outputPower: 60,
|
||||||
|
outputVoltage: 230,
|
||||||
|
outputCurrent: 0.3,
|
||||||
|
raw: {},
|
||||||
|
},
|
||||||
|
5,
|
||||||
|
);
|
||||||
|
|
||||||
|
const secondCandidate = selectEmergencyCandidate(
|
||||||
|
firstCandidate,
|
||||||
|
{ id: 'ups-2', name: 'UPS 2' },
|
||||||
|
{
|
||||||
|
powerStatus: 'onBattery',
|
||||||
|
batteryCapacity: 30,
|
||||||
|
batteryRuntime: 3,
|
||||||
|
outputLoad: 15,
|
||||||
|
outputPower: 70,
|
||||||
|
outputVoltage: 230,
|
||||||
|
outputCurrent: 0.4,
|
||||||
|
raw: {},
|
||||||
|
},
|
||||||
|
5,
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(secondCandidate, firstCandidate);
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// UPS Monitoring Tests
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
Deno.test('buildSuccessfulUpsPollSnapshot: marks recovery from unreachable', () => {
|
||||||
|
const currentStatus = {
|
||||||
|
...createInitialUpsStatus({ id: 'ups-1', name: 'Main UPS' }, 1000),
|
||||||
|
powerStatus: 'unreachable' as const,
|
||||||
|
unreachableSince: 2000,
|
||||||
|
consecutiveFailures: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
const snapshot = buildSuccessfulUpsPollSnapshot(
|
||||||
|
{ id: 'ups-1', name: 'Main UPS' },
|
||||||
|
{
|
||||||
|
powerStatus: 'online',
|
||||||
|
batteryCapacity: 95,
|
||||||
|
batteryRuntime: 40,
|
||||||
|
outputLoad: 10,
|
||||||
|
outputPower: 50,
|
||||||
|
outputVoltage: 230,
|
||||||
|
outputCurrent: 0.5,
|
||||||
|
raw: {},
|
||||||
|
},
|
||||||
|
currentStatus,
|
||||||
|
8000,
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(snapshot.transition, 'recovered');
|
||||||
|
assertEquals(snapshot.downtimeSeconds, 6);
|
||||||
|
assertEquals(snapshot.updatedStatus.powerStatus, 'online');
|
||||||
|
assertEquals(snapshot.updatedStatus.consecutiveFailures, 0);
|
||||||
|
assertEquals(snapshot.updatedStatus.lastStatusChange, 8000);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('buildFailedUpsPollSnapshot: marks UPS unreachable at failure threshold', () => {
|
||||||
|
const currentStatus = {
|
||||||
|
...createInitialUpsStatus({ id: 'ups-1', name: 'Main UPS' }, 1000),
|
||||||
|
powerStatus: 'onBattery' as const,
|
||||||
|
consecutiveFailures: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
const snapshot = buildFailedUpsPollSnapshot(
|
||||||
|
{ id: 'ups-1', name: 'Main UPS' },
|
||||||
|
currentStatus,
|
||||||
|
9000,
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(snapshot.transition, 'unreachable');
|
||||||
|
assertEquals(snapshot.failures, 3);
|
||||||
|
assertEquals(snapshot.updatedStatus.powerStatus, 'unreachable');
|
||||||
|
assertEquals(snapshot.updatedStatus.unreachableSince, 9000);
|
||||||
|
assertEquals(snapshot.updatedStatus.lastStatusChange, 9000);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('hasThresholdViolation: only fires on battery when any action threshold is exceeded', () => {
|
||||||
|
assertEquals(
|
||||||
|
hasThresholdViolation('online', 40, 10, [
|
||||||
|
{ type: 'shutdown', thresholds: { battery: 50, runtime: 20 } },
|
||||||
|
]),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
hasThresholdViolation('onBattery', 40, 10, [
|
||||||
|
{ type: 'shutdown', thresholds: { battery: 50, runtime: 20 } },
|
||||||
|
]),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
hasThresholdViolation('onBattery', 90, 60, [
|
||||||
|
{ type: 'shutdown', thresholds: { battery: 50, runtime: 20 } },
|
||||||
|
]),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('isActionThresholdExceeded: evaluates a single action threshold on battery only', () => {
|
||||||
|
assertEquals(
|
||||||
|
isActionThresholdExceeded(
|
||||||
|
{ type: 'shutdown', thresholds: { battery: 50, runtime: 20 } },
|
||||||
|
'online',
|
||||||
|
40,
|
||||||
|
10,
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
isActionThresholdExceeded(
|
||||||
|
{ type: 'shutdown', thresholds: { battery: 50, runtime: 20 } },
|
||||||
|
'onBattery',
|
||||||
|
40,
|
||||||
|
10,
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('getActionThresholdStates: returns per-action threshold state array', () => {
|
||||||
|
assertEquals(
|
||||||
|
getActionThresholdStates('onBattery', 25, 8, [
|
||||||
|
{ type: 'shutdown', thresholds: { battery: 30, runtime: 10 } },
|
||||||
|
{ type: 'shutdown', thresholds: { battery: 10, runtime: 5 } },
|
||||||
|
{ type: 'webhook' },
|
||||||
|
]),
|
||||||
|
[true, false, false],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('getEnteredThresholdIndexes: reports only newly-entered thresholds', () => {
|
||||||
|
assertEquals(getEnteredThresholdIndexes(undefined, [false, true, true]), [1, 2]);
|
||||||
|
assertEquals(getEnteredThresholdIndexes([false, true, false], [true, true, false]), [0]);
|
||||||
|
assertEquals(getEnteredThresholdIndexes([true, true], [true, false]), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Group Monitoring Tests
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
Deno.test('buildGroupStatusSnapshot: redundant group stays online while one UPS remains online', () => {
|
||||||
|
const snapshot = buildGroupStatusSnapshot(
|
||||||
|
{ id: 'group-1', name: 'Group Main' },
|
||||||
|
'redundant',
|
||||||
|
[
|
||||||
|
{
|
||||||
|
...createInitialUpsStatus({ id: 'ups-1', name: 'UPS 1' }, 1000),
|
||||||
|
powerStatus: 'onBattery' as const,
|
||||||
|
batteryCapacity: 40,
|
||||||
|
batteryRuntime: 12,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...createInitialUpsStatus({ id: 'ups-2', name: 'UPS 2' }, 1000),
|
||||||
|
powerStatus: 'online' as const,
|
||||||
|
batteryCapacity: 98,
|
||||||
|
batteryRuntime: 999,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
undefined,
|
||||||
|
5000,
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(snapshot.updatedStatus.powerStatus, 'online');
|
||||||
|
assertEquals(snapshot.transition, 'powerStatusChange');
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('buildGroupStatusSnapshot: nonRedundant group goes unreachable when any member is unreachable', () => {
|
||||||
|
const snapshot = buildGroupStatusSnapshot(
|
||||||
|
{ id: 'group-2', name: 'Group Edge' },
|
||||||
|
'nonRedundant',
|
||||||
|
[
|
||||||
|
{
|
||||||
|
...createInitialUpsStatus({ id: 'ups-1', name: 'UPS 1' }, 1000),
|
||||||
|
powerStatus: 'online' as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...createInitialUpsStatus({ id: 'ups-2', name: 'UPS 2' }, 1000),
|
||||||
|
powerStatus: 'unreachable' as const,
|
||||||
|
unreachableSince: 2000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{
|
||||||
|
...createInitialUpsStatus({ id: 'group-2', name: 'Group Edge' }, 1000),
|
||||||
|
powerStatus: 'online' as const,
|
||||||
|
},
|
||||||
|
6000,
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(snapshot.updatedStatus.powerStatus, 'unreachable');
|
||||||
|
assertEquals(snapshot.transition, 'powerStatusChange');
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('evaluateGroupActionThreshold: redundant mode requires all members to be critical', () => {
|
||||||
|
const evaluation = evaluateGroupActionThreshold(
|
||||||
|
{ type: 'shutdown', thresholds: { battery: 50, runtime: 20 } },
|
||||||
|
'redundant',
|
||||||
|
[
|
||||||
|
{
|
||||||
|
...createInitialUpsStatus({ id: 'ups-1', name: 'UPS 1' }, 1000),
|
||||||
|
powerStatus: 'onBattery' as const,
|
||||||
|
batteryCapacity: 40,
|
||||||
|
batteryRuntime: 15,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...createInitialUpsStatus({ id: 'ups-2', name: 'UPS 2' }, 1000),
|
||||||
|
powerStatus: 'online' as const,
|
||||||
|
batteryCapacity: 95,
|
||||||
|
batteryRuntime: 999,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(evaluation.exceedsThreshold, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('evaluateGroupActionThreshold: nonRedundant mode trips on any critical member', () => {
|
||||||
|
const evaluation = evaluateGroupActionThreshold(
|
||||||
|
{ type: 'shutdown', thresholds: { battery: 50, runtime: 20 } },
|
||||||
|
'nonRedundant',
|
||||||
|
[
|
||||||
|
{
|
||||||
|
...createInitialUpsStatus({ id: 'ups-1', name: 'UPS 1' }, 1000),
|
||||||
|
powerStatus: 'onBattery' as const,
|
||||||
|
batteryCapacity: 40,
|
||||||
|
batteryRuntime: 15,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...createInitialUpsStatus({ id: 'ups-2', name: 'UPS 2' }, 1000),
|
||||||
|
powerStatus: 'online' as const,
|
||||||
|
batteryCapacity: 95,
|
||||||
|
batteryRuntime: 999,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(evaluation.exceedsThreshold, true);
|
||||||
|
assertEquals(evaluation.blockedByUnreachable, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('evaluateGroupActionThreshold: blocks destructive actions when a member is unreachable', () => {
|
||||||
|
const evaluation = evaluateGroupActionThreshold(
|
||||||
|
{ type: 'proxmox', thresholds: { battery: 50, runtime: 20 } },
|
||||||
|
'nonRedundant',
|
||||||
|
[
|
||||||
|
{
|
||||||
|
...createInitialUpsStatus({ id: 'ups-1', name: 'UPS 1' }, 1000),
|
||||||
|
powerStatus: 'onBattery' as const,
|
||||||
|
batteryCapacity: 25,
|
||||||
|
batteryRuntime: 8,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...createInitialUpsStatus({ id: 'ups-2', name: 'UPS 2' }, 1000),
|
||||||
|
powerStatus: 'unreachable' as const,
|
||||||
|
unreachableSince: 3000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(evaluation.exceedsThreshold, true);
|
||||||
|
assertEquals(evaluation.blockedByUnreachable, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('buildGroupThresholdContextStatus: uses the worst triggering member runtime', () => {
|
||||||
|
const status = buildGroupThresholdContextStatus(
|
||||||
|
{ id: 'group-3', name: 'Group Worst' },
|
||||||
|
[
|
||||||
|
{
|
||||||
|
exceedsThreshold: true,
|
||||||
|
blockedByUnreachable: false,
|
||||||
|
representativeStatus: {
|
||||||
|
...createInitialUpsStatus({ id: 'ups-1', name: 'UPS 1' }, 1000),
|
||||||
|
powerStatus: 'onBattery' as const,
|
||||||
|
batteryCapacity: 30,
|
||||||
|
batteryRuntime: 9,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exceedsThreshold: true,
|
||||||
|
blockedByUnreachable: false,
|
||||||
|
representativeStatus: {
|
||||||
|
...createInitialUpsStatus({ id: 'ups-2', name: 'UPS 2' }, 1000),
|
||||||
|
powerStatus: 'onBattery' as const,
|
||||||
|
batteryCapacity: 20,
|
||||||
|
batteryRuntime: 4,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[0, 1],
|
||||||
|
{
|
||||||
|
...createInitialUpsStatus({ id: 'group-3', name: 'Group Worst' }, 1000),
|
||||||
|
powerStatus: 'online' as const,
|
||||||
|
},
|
||||||
|
7000,
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(status.powerStatus, 'onBattery');
|
||||||
|
assertEquals(status.batteryCapacity, 20);
|
||||||
|
assertEquals(status.batteryRuntime, 4);
|
||||||
|
});
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// UpsOidSets Tests
|
// UpsOidSets Tests
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/nupst',
|
name: '@serve.zone/nupst',
|
||||||
version: '5.4.1',
|
version: '5.8.0',
|
||||||
description: 'Network UPS Shutdown Tool - Monitor SNMP-enabled UPS devices and orchestrate graceful system shutdowns during power emergencies'
|
description: 'Network UPS Shutdown Tool - Monitor SNMP-enabled UPS devices and orchestrate graceful system shutdowns during power emergencies'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import type { IActionConfig, IActionContext, TPowerStatus } from './actions/base-action.ts';
|
||||||
|
import type { IUpsStatus } from './ups-status.ts';
|
||||||
|
|
||||||
|
export interface IUpsActionSource {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
actions?: IActionConfig[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TUpsTriggerReason = IActionContext['triggerReason'];
|
||||||
|
|
||||||
|
export type TActionExecutionDecision =
|
||||||
|
| { type: 'suppressed'; message: string }
|
||||||
|
| { type: 'legacyShutdown'; reason: string }
|
||||||
|
| { type: 'skip' }
|
||||||
|
| { type: 'execute'; actions: IActionConfig[]; context: IActionContext };
|
||||||
|
|
||||||
|
export function buildUpsActionContext(
|
||||||
|
ups: IUpsActionSource,
|
||||||
|
status: IUpsStatus,
|
||||||
|
previousStatus: IUpsStatus | undefined,
|
||||||
|
triggerReason: TUpsTriggerReason,
|
||||||
|
timestamp: number = Date.now(),
|
||||||
|
): IActionContext {
|
||||||
|
return {
|
||||||
|
upsId: ups.id,
|
||||||
|
upsName: ups.name,
|
||||||
|
powerStatus: status.powerStatus as TPowerStatus,
|
||||||
|
batteryCapacity: status.batteryCapacity,
|
||||||
|
batteryRuntime: status.batteryRuntime,
|
||||||
|
previousPowerStatus: (previousStatus?.powerStatus || 'unknown') as TPowerStatus,
|
||||||
|
timestamp,
|
||||||
|
triggerReason,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyDefaultShutdownDelay(
|
||||||
|
actions: IActionConfig[],
|
||||||
|
defaultDelayMinutes: number,
|
||||||
|
): IActionConfig[] {
|
||||||
|
return actions.map((action) => {
|
||||||
|
if (action.type !== 'shutdown' || action.shutdownDelay !== undefined) {
|
||||||
|
return action;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...action,
|
||||||
|
shutdownDelay: defaultDelayMinutes,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decideUpsActionExecution(
|
||||||
|
isPaused: boolean,
|
||||||
|
ups: IUpsActionSource,
|
||||||
|
status: IUpsStatus,
|
||||||
|
previousStatus: IUpsStatus | undefined,
|
||||||
|
triggerReason: TUpsTriggerReason,
|
||||||
|
timestamp: number = Date.now(),
|
||||||
|
): TActionExecutionDecision {
|
||||||
|
if (isPaused) {
|
||||||
|
return {
|
||||||
|
type: 'suppressed',
|
||||||
|
message: `[PAUSED] Actions suppressed for UPS ${ups.name} (trigger: ${triggerReason})`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const actions = ups.actions || [];
|
||||||
|
|
||||||
|
if (actions.length === 0 && triggerReason === 'thresholdViolation') {
|
||||||
|
return {
|
||||||
|
type: 'legacyShutdown',
|
||||||
|
reason: `UPS "${ups.name}" battery or runtime below threshold`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actions.length === 0) {
|
||||||
|
return { type: 'skip' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'execute',
|
||||||
|
actions,
|
||||||
|
context: buildUpsActionContext(ups, status, previousStatus, triggerReason, timestamp),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -74,7 +74,7 @@ export interface IActionConfig {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Shutdown action configuration
|
// Shutdown action configuration
|
||||||
/** Delay before shutdown in minutes (default: 5) */
|
/** Delay before shutdown in minutes (defaults to the config-level shutdown delay, or 5) */
|
||||||
shutdownDelay?: number;
|
shutdownDelay?: number;
|
||||||
/** Only execute shutdown on threshold violation, not power status changes */
|
/** Only execute shutdown on threshold violation, not power status changes */
|
||||||
onlyOnThresholdViolation?: boolean;
|
onlyOnThresholdViolation?: boolean;
|
||||||
@@ -116,6 +116,10 @@ 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';
|
||||||
|
/** How HA-managed Proxmox resources should be stopped (default: 'none') */
|
||||||
|
proxmoxHaPolicy?: 'none' | 'haStop';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+537
-77
@@ -1,20 +1,108 @@
|
|||||||
|
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);
|
||||||
|
type TNodeLikeGlobal = typeof globalThis & {
|
||||||
|
process?: {
|
||||||
|
env: Record<string, string | undefined>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ProxmoxAction - Gracefully shuts down Proxmox VMs and LXC containers
|
* ProxmoxAction - Gracefully shuts down Proxmox VMs and LXC containers
|
||||||
*
|
*
|
||||||
* 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.
|
||||||
*/
|
*/
|
||||||
export class ProxmoxAction extends Action {
|
export class ProxmoxAction extends Action {
|
||||||
readonly type = 'proxmox';
|
readonly type = 'proxmox';
|
||||||
|
private static readonly activeRunKeys = new Set<string>();
|
||||||
|
|
||||||
|
private static findCliTool(command: string): string | null {
|
||||||
|
for (const dir of PROXMOX.CLI_TOOL_PATHS) {
|
||||||
|
const candidate = `${dir}/${command}`;
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(candidate)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
} catch (_e) {
|
||||||
|
// continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if Proxmox CLI tools (qm, pct) are available on the system
|
||||||
|
* Used by CLI wizards and by execute() for auto-detection
|
||||||
|
*/
|
||||||
|
static detectCliAvailability(): {
|
||||||
|
available: boolean;
|
||||||
|
qmPath: string | null;
|
||||||
|
pctPath: string | null;
|
||||||
|
haManagerPath: string | null;
|
||||||
|
isRoot: boolean;
|
||||||
|
} {
|
||||||
|
const qmPath = this.findCliTool('qm');
|
||||||
|
const pctPath = this.findCliTool('pct');
|
||||||
|
const haManagerPath = this.findCliTool('ha-manager');
|
||||||
|
|
||||||
|
const isRoot = !!(process.getuid && process.getuid() === 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
available: qmPath !== null && pctPath !== null && isRoot,
|
||||||
|
qmPath,
|
||||||
|
pctPath,
|
||||||
|
haManagerPath,
|
||||||
|
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 +117,34 @@ export class ProxmoxAction extends Action {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resolved = this.resolveMode();
|
||||||
|
const node = this.config.proxmoxNode || os.hostname();
|
||||||
|
const excludeIds = new Set(this.config.proxmoxExcludeIds || []);
|
||||||
|
const stopTimeout = (this.config.proxmoxStopTimeout || PROXMOX.DEFAULT_STOP_TIMEOUT_SECONDS) *
|
||||||
|
1000;
|
||||||
|
const forceStop = this.config.proxmoxForceStop !== false; // default true
|
||||||
|
const haPolicy = this.config.proxmoxHaPolicy || 'none';
|
||||||
const host = this.config.proxmoxHost || PROXMOX.DEFAULT_HOST;
|
const host = this.config.proxmoxHost || PROXMOX.DEFAULT_HOST;
|
||||||
const port = this.config.proxmoxPort || PROXMOX.DEFAULT_PORT;
|
const port = this.config.proxmoxPort || PROXMOX.DEFAULT_PORT;
|
||||||
const node = this.config.proxmoxNode || os.hostname();
|
const runKey = `${resolved.mode}:${node}:${
|
||||||
const tokenId = this.config.proxmoxTokenId;
|
resolved.mode === 'api' ? `${host}:${port}` : 'local'
|
||||||
const tokenSecret = this.config.proxmoxTokenSecret;
|
}`;
|
||||||
const excludeIds = new Set(this.config.proxmoxExcludeIds || []);
|
|
||||||
const stopTimeout = (this.config.proxmoxStopTimeout || PROXMOX.DEFAULT_STOP_TIMEOUT_SECONDS) * 1000;
|
|
||||||
const forceStop = this.config.proxmoxForceStop !== false; // default true
|
|
||||||
const insecure = this.config.proxmoxInsecure !== false; // default true
|
|
||||||
|
|
||||||
if (!tokenId || !tokenSecret) {
|
if (ProxmoxAction.activeRunKeys.has(runKey)) {
|
||||||
logger.error('Proxmox API token ID and secret are required');
|
logger.info(`Proxmox action skipped: shutdown sequence already running for node ${node}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseUrl = `https://${host}:${port}${PROXMOX.API_BASE}`;
|
ProxmoxAction.activeRunKeys.add(runKey);
|
||||||
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}`);
|
||||||
logger.logBoxLine(`API: ${host}:${port}`);
|
logger.logBoxLine(`HA Policy: ${haPolicy}`);
|
||||||
|
if (resolved.mode === 'api') {
|
||||||
|
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 +154,50 @@ export class ProxmoxAction extends Action {
|
|||||||
logger.log('');
|
logger.log('');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Collect running VMs and CTs
|
let apiContext: {
|
||||||
const runningVMs = await this.getRunningVMs(baseUrl, node, headers, insecure);
|
baseUrl: string;
|
||||||
const runningCTs = await this.getRunningCTs(baseUrl, node, headers, insecure);
|
headers: Record<string, string>;
|
||||||
|
insecure: boolean;
|
||||||
|
} | null = null;
|
||||||
|
let runningVMs: Array<{ vmid: number; name: string }>;
|
||||||
|
let runningCTs: Array<{ vmid: number; name: string }>;
|
||||||
|
|
||||||
|
if (resolved.mode === 'cli') {
|
||||||
|
runningVMs = await this.getRunningVMsCli(resolved.qmPath);
|
||||||
|
runningCTs = await this.getRunningCTsCli(resolved.pctPath);
|
||||||
|
} else {
|
||||||
|
// API mode - validate token
|
||||||
|
const 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
apiContext = {
|
||||||
|
baseUrl: `https://${host}:${port}${PROXMOX.API_BASE}`,
|
||||||
|
headers: {
|
||||||
|
'Authorization': `PVEAPIToken=${tokenId}=${tokenSecret}`,
|
||||||
|
},
|
||||||
|
insecure,
|
||||||
|
};
|
||||||
|
|
||||||
|
runningVMs = await this.getRunningVMsApi(
|
||||||
|
apiContext.baseUrl,
|
||||||
|
node,
|
||||||
|
apiContext.headers,
|
||||||
|
apiContext.insecure,
|
||||||
|
);
|
||||||
|
runningCTs = await this.getRunningCTsApi(
|
||||||
|
apiContext.baseUrl,
|
||||||
|
node,
|
||||||
|
apiContext.headers,
|
||||||
|
apiContext.insecure,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Filter out excluded IDs
|
// Filter out excluded IDs
|
||||||
const vmsToStop = runningVMs.filter((vm) => !excludeIds.has(vm.vmid));
|
const vmsToStop = runningVMs.filter((vm) => !excludeIds.has(vm.vmid));
|
||||||
@@ -76,17 +209,85 @@ export class ProxmoxAction extends Action {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const haManagedResources = haPolicy === 'haStop'
|
||||||
|
? await this.getHaManagedResources(resolved, apiContext)
|
||||||
|
: { qemu: new Set<number>(), lxc: new Set<number>() };
|
||||||
|
const haVmsToStop = vmsToStop.filter((vm) => haManagedResources.qemu.has(vm.vmid));
|
||||||
|
const haCtsToStop = ctsToStop.filter((ct) => haManagedResources.lxc.has(ct.vmid));
|
||||||
|
let directVmsToStop = vmsToStop.filter((vm) => !haManagedResources.qemu.has(vm.vmid));
|
||||||
|
let directCtsToStop = ctsToStop.filter((ct) => !haManagedResources.lxc.has(ct.vmid));
|
||||||
|
|
||||||
logger.info(`Shutting down ${vmsToStop.length} VMs and ${ctsToStop.length} containers...`);
|
logger.info(`Shutting down ${vmsToStop.length} VMs and ${ctsToStop.length} containers...`);
|
||||||
|
|
||||||
// Send shutdown commands to all VMs and CTs
|
if (resolved.mode === 'cli') {
|
||||||
for (const vm of vmsToStop) {
|
const { haManagerPath } = ProxmoxAction.detectCliAvailability();
|
||||||
await this.shutdownVM(baseUrl, node, vm.vmid, headers, insecure);
|
if (haPolicy === 'haStop' && (haVmsToStop.length > 0 || haCtsToStop.length > 0)) {
|
||||||
logger.dim(` Shutdown sent to VM ${vm.vmid} (${vm.name || 'unnamed'})`);
|
if (!haManagerPath) {
|
||||||
}
|
logger.warn(
|
||||||
|
'ha-manager not found, falling back to direct guest shutdown for HA-managed resources',
|
||||||
|
);
|
||||||
|
directVmsToStop = [...haVmsToStop, ...directVmsToStop];
|
||||||
|
directCtsToStop = [...haCtsToStop, ...directCtsToStop];
|
||||||
|
} else {
|
||||||
|
for (const vm of haVmsToStop) {
|
||||||
|
await this.requestHaStopCli(haManagerPath, `vm:${vm.vmid}`);
|
||||||
|
logger.dim(` HA stop requested for VM ${vm.vmid} (${vm.name || 'unnamed'})`);
|
||||||
|
}
|
||||||
|
for (const ct of haCtsToStop) {
|
||||||
|
await this.requestHaStopCli(haManagerPath, `ct:${ct.vmid}`);
|
||||||
|
logger.dim(` HA stop requested for CT ${ct.vmid} (${ct.name || 'unnamed'})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const ct of ctsToStop) {
|
for (const vm of directVmsToStop) {
|
||||||
await this.shutdownCT(baseUrl, node, ct.vmid, headers, insecure);
|
await this.shutdownVMCli(resolved.qmPath, vm.vmid);
|
||||||
logger.dim(` Shutdown sent to CT ${ct.vmid} (${ct.name || 'unnamed'})`);
|
logger.dim(` Shutdown sent to VM ${vm.vmid} (${vm.name || 'unnamed'})`);
|
||||||
|
}
|
||||||
|
for (const ct of directCtsToStop) {
|
||||||
|
await this.shutdownCTCli(resolved.pctPath, ct.vmid);
|
||||||
|
logger.dim(` Shutdown sent to CT ${ct.vmid} (${ct.name || 'unnamed'})`);
|
||||||
|
}
|
||||||
|
} else if (apiContext) {
|
||||||
|
for (const vm of haVmsToStop) {
|
||||||
|
await this.requestHaStopApi(
|
||||||
|
apiContext.baseUrl,
|
||||||
|
`vm:${vm.vmid}`,
|
||||||
|
apiContext.headers,
|
||||||
|
apiContext.insecure,
|
||||||
|
);
|
||||||
|
logger.dim(` HA stop requested for VM ${vm.vmid} (${vm.name || 'unnamed'})`);
|
||||||
|
}
|
||||||
|
for (const ct of haCtsToStop) {
|
||||||
|
await this.requestHaStopApi(
|
||||||
|
apiContext.baseUrl,
|
||||||
|
`ct:${ct.vmid}`,
|
||||||
|
apiContext.headers,
|
||||||
|
apiContext.insecure,
|
||||||
|
);
|
||||||
|
logger.dim(` HA stop requested for CT ${ct.vmid} (${ct.name || 'unnamed'})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const vm of directVmsToStop) {
|
||||||
|
await this.shutdownVMApi(
|
||||||
|
apiContext.baseUrl,
|
||||||
|
node,
|
||||||
|
vm.vmid,
|
||||||
|
apiContext.headers,
|
||||||
|
apiContext.insecure,
|
||||||
|
);
|
||||||
|
logger.dim(` Shutdown sent to VM ${vm.vmid} (${vm.name || 'unnamed'})`);
|
||||||
|
}
|
||||||
|
for (const ct of directCtsToStop) {
|
||||||
|
await this.shutdownCTApi(
|
||||||
|
apiContext.baseUrl,
|
||||||
|
node,
|
||||||
|
ct.vmid,
|
||||||
|
apiContext.headers,
|
||||||
|
apiContext.insecure,
|
||||||
|
);
|
||||||
|
logger.dim(` Shutdown sent to CT ${ct.vmid} (${ct.name || 'unnamed'})`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Poll until all stopped or timeout
|
// Poll until all stopped or timeout
|
||||||
@@ -95,23 +296,36 @@ 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 (item.type === 'qemu') {
|
if (resolved.mode === 'cli') {
|
||||||
await this.stopVM(baseUrl, node, item.vmid, headers, insecure);
|
if (item.type === 'qemu') {
|
||||||
} else {
|
await this.stopVMCli(resolved.qmPath, item.vmid);
|
||||||
await this.stopCT(baseUrl, node, item.vmid, headers, insecure);
|
} else {
|
||||||
|
await this.stopCTCli(resolved.pctPath, item.vmid);
|
||||||
|
}
|
||||||
|
} else if (apiContext) {
|
||||||
|
if (item.type === 'qemu') {
|
||||||
|
await this.stopVMApi(
|
||||||
|
apiContext.baseUrl,
|
||||||
|
node,
|
||||||
|
item.vmid,
|
||||||
|
apiContext.headers,
|
||||||
|
apiContext.insecure,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await this.stopCTApi(
|
||||||
|
apiContext.baseUrl,
|
||||||
|
node,
|
||||||
|
item.vmid,
|
||||||
|
apiContext.headers,
|
||||||
|
apiContext.insecure,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
logger.dim(` Force-stopped ${item.type} ${item.vmid} (${item.name || 'unnamed'})`);
|
logger.dim(` Force-stopped ${item.type} ${item.vmid} (${item.name || 'unnamed'})`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -131,9 +345,186 @@ export class ProxmoxAction extends Action {
|
|||||||
logger.error(
|
logger.error(
|
||||||
`Proxmox action failed: ${error instanceof Error ? error.message : String(error)}`,
|
`Proxmox action failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
);
|
);
|
||||||
|
} finally {
|
||||||
|
ProxmoxAction.activeRunKeys.delete(runKey);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getHaManagedResources(
|
||||||
|
resolved: { mode: 'api' | 'cli'; qmPath?: string; pctPath?: string },
|
||||||
|
apiContext: {
|
||||||
|
baseUrl: string;
|
||||||
|
headers: Record<string, string>;
|
||||||
|
insecure: boolean;
|
||||||
|
} | null,
|
||||||
|
): Promise<{ qemu: Set<number>; lxc: Set<number> }> {
|
||||||
|
if (resolved.mode === 'cli') {
|
||||||
|
const { haManagerPath } = ProxmoxAction.detectCliAvailability();
|
||||||
|
if (!haManagerPath) {
|
||||||
|
return { qemu: new Set<number>(), lxc: new Set<number>() };
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.getHaManagedResourcesCli(haManagerPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!apiContext) {
|
||||||
|
return { qemu: new Set<number>(), lxc: new Set<number>() };
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.getHaManagedResourcesApi(
|
||||||
|
apiContext.baseUrl,
|
||||||
|
apiContext.headers,
|
||||||
|
apiContext.insecure,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getHaManagedResourcesCli(
|
||||||
|
haManagerPath: string,
|
||||||
|
): Promise<{ qemu: Set<number>; lxc: Set<number> }> {
|
||||||
|
try {
|
||||||
|
const { stdout } = await execFileAsync(haManagerPath, ['config']);
|
||||||
|
return this.parseHaManagerConfig(stdout);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(
|
||||||
|
`Failed to list HA resources via CLI: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
return { qemu: new Set<number>(), lxc: new Set<number>() };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseHaManagerConfig(output: string): { qemu: Set<number>; lxc: Set<number> } {
|
||||||
|
const resources = {
|
||||||
|
qemu: new Set<number>(),
|
||||||
|
lxc: new Set<number>(),
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const line of output.trim().split('\n')) {
|
||||||
|
const match = line.match(/^\s*(vm|ct)\s*:\s*(\d+)\s*$/i);
|
||||||
|
if (!match) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const vmid = parseInt(match[2], 10);
|
||||||
|
if (match[1].toLowerCase() === 'vm') {
|
||||||
|
resources.qemu.add(vmid);
|
||||||
|
} else {
|
||||||
|
resources.lxc.add(vmid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return resources;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requestHaStopCli(haManagerPath: string, sid: string): Promise<void> {
|
||||||
|
await execFileAsync(haManagerPath, ['set', sid, '--state', 'stopped']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── API-based methods ─────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Make an API request to the Proxmox server
|
* Make an API request to the Proxmox server
|
||||||
*/
|
*/
|
||||||
@@ -142,16 +533,23 @@ export class ProxmoxAction extends Action {
|
|||||||
method: string,
|
method: string,
|
||||||
headers: Record<string, string>,
|
headers: Record<string, string>,
|
||||||
insecure: boolean,
|
insecure: boolean,
|
||||||
|
body?: URLSearchParams,
|
||||||
): Promise<unknown> {
|
): Promise<unknown> {
|
||||||
|
const requestHeaders = { ...headers };
|
||||||
const fetchOptions: RequestInit = {
|
const fetchOptions: RequestInit = {
|
||||||
method,
|
method,
|
||||||
headers,
|
headers: requestHeaders,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (body) {
|
||||||
|
requestHeaders['Content-Type'] = 'application/x-www-form-urlencoded;charset=UTF-8';
|
||||||
|
fetchOptions.body = body.toString();
|
||||||
|
}
|
||||||
|
|
||||||
// Use NODE_TLS_REJECT_UNAUTHORIZED for insecure mode (self-signed certs)
|
// Use NODE_TLS_REJECT_UNAUTHORIZED for insecure mode (self-signed certs)
|
||||||
if (insecure) {
|
const nodeProcess = (globalThis as TNodeLikeGlobal).process;
|
||||||
// deno-lint-ignore no-explicit-any
|
if (insecure && nodeProcess?.env) {
|
||||||
(globalThis as any).process?.env && ((globalThis as any).process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0');
|
nodeProcess.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -165,17 +563,16 @@ export class ProxmoxAction extends Action {
|
|||||||
return await response.json();
|
return await response.json();
|
||||||
} finally {
|
} finally {
|
||||||
// Restore TLS verification
|
// Restore TLS verification
|
||||||
if (insecure) {
|
if (insecure && nodeProcess?.env) {
|
||||||
// deno-lint-ignore no-explicit-any
|
nodeProcess.env.NODE_TLS_REJECT_UNAUTHORIZED = '1';
|
||||||
(globalThis as any).process?.env && ((globalThis as any).process.env.NODE_TLS_REJECT_UNAUTHORIZED = '1');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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 +598,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 +625,64 @@ export class ProxmoxAction extends Action {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private async getHaManagedResourcesApi(
|
||||||
* Send graceful shutdown to a QEMU VM
|
baseUrl: string,
|
||||||
*/
|
headers: Record<string, string>,
|
||||||
private async shutdownVM(
|
insecure: boolean,
|
||||||
|
): Promise<{ qemu: Set<number>; lxc: Set<number> }> {
|
||||||
|
try {
|
||||||
|
const response = await this.apiRequest(
|
||||||
|
`${baseUrl}/cluster/ha/resources`,
|
||||||
|
'GET',
|
||||||
|
headers,
|
||||||
|
insecure,
|
||||||
|
) as { data: Array<{ sid?: string }> };
|
||||||
|
const resources = {
|
||||||
|
qemu: new Set<number>(),
|
||||||
|
lxc: new Set<number>(),
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const item of response.data || []) {
|
||||||
|
const match = item.sid?.match(/^(vm|ct):(\d+)$/i);
|
||||||
|
if (!match) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const vmid = parseInt(match[2], 10);
|
||||||
|
if (match[1].toLowerCase() === 'vm') {
|
||||||
|
resources.qemu.add(vmid);
|
||||||
|
} else {
|
||||||
|
resources.lxc.add(vmid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return resources;
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(
|
||||||
|
`Failed to list HA resources via API: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
return { qemu: new Set<number>(), lxc: new Set<number>() };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requestHaStopApi(
|
||||||
|
baseUrl: string,
|
||||||
|
sid: string,
|
||||||
|
headers: Record<string, string>,
|
||||||
|
insecure: boolean,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.apiRequest(
|
||||||
|
`${baseUrl}/cluster/ha/resources/${encodeURIComponent(sid)}`,
|
||||||
|
'PUT',
|
||||||
|
headers,
|
||||||
|
insecure,
|
||||||
|
new URLSearchParams({ state: 'stopped' }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async shutdownVMApi(
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
node: string,
|
node: string,
|
||||||
vmid: number,
|
vmid: number,
|
||||||
@@ -246,10 +697,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 +712,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 +727,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 +742,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();
|
||||||
@@ -316,19 +758,37 @@ export class ProxmoxAction extends Action {
|
|||||||
|
|
||||||
while (remaining.length > 0 && (Date.now() - startTime) < timeout) {
|
while (remaining.length > 0 && (Date.now() - startTime) < timeout) {
|
||||||
// Wait before polling
|
// Wait before polling
|
||||||
await new Promise((resolve) => setTimeout(resolve, PROXMOX.STATUS_POLL_INTERVAL_SECONDS * 1000));
|
await new Promise((resolve) =>
|
||||||
|
setTimeout(resolve, PROXMOX.STATUS_POLL_INTERVAL_SECONDS * 1000)
|
||||||
|
);
|
||||||
|
|
||||||
// Check which are still running
|
// Check which are still running
|
||||||
const stillRunning: typeof remaining = [];
|
const stillRunning: typeof remaining = [];
|
||||||
|
|
||||||
for (const item of remaining) {
|
for (const item of remaining) {
|
||||||
try {
|
try {
|
||||||
const statusUrl = `${baseUrl}/nodes/${node}/${item.type}/${item.vmid}/status/current`;
|
let status: string;
|
||||||
const response = await this.apiRequest(statusUrl, 'GET', headers, insecure) as {
|
|
||||||
data: { status: string };
|
|
||||||
};
|
|
||||||
|
|
||||||
if (response.data?.status === 'running') {
|
if (resolved.mode === 'cli') {
|
||||||
|
const toolPath = item.type === 'qemu' ? resolved.qmPath! : resolved.pctPath!;
|
||||||
|
status = await this.getStatusCli(toolPath, item.vmid);
|
||||||
|
} else {
|
||||||
|
const host = this.config.proxmoxHost || PROXMOX.DEFAULT_HOST;
|
||||||
|
const port = this.config.proxmoxPort || PROXMOX.DEFAULT_PORT;
|
||||||
|
const insecure = this.config.proxmoxInsecure !== false;
|
||||||
|
const baseUrl = `https://${host}:${port}${PROXMOX.API_BASE}`;
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Authorization':
|
||||||
|
`PVEAPIToken=${this.config.proxmoxTokenId}=${this.config.proxmoxTokenSecret}`,
|
||||||
|
};
|
||||||
|
const statusUrl = `${baseUrl}/nodes/${node}/${item.type}/${item.vmid}/status/current`;
|
||||||
|
const response = await this.apiRequest(statusUrl, 'GET', headers, insecure) as {
|
||||||
|
data: { status: string };
|
||||||
|
};
|
||||||
|
status = response.data?.status || 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'running') {
|
||||||
stillRunning.push(item);
|
stillRunning.push(item);
|
||||||
} else {
|
} else {
|
||||||
logger.dim(` ${item.type} ${item.vmid} (${item.name}) stopped`);
|
logger.dim(` ${item.type} ${item.vmid} (${item.name}) stopped`);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const execFileAsync = promisify(execFile);
|
|||||||
*/
|
*/
|
||||||
export class ShutdownAction extends Action {
|
export class ShutdownAction extends Action {
|
||||||
readonly type = 'shutdown';
|
readonly type = 'shutdown';
|
||||||
|
private static scheduledDelayMinutes: number | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Override shouldExecute to add shutdown-specific safety checks
|
* Override shouldExecute to add shutdown-specific safety checks
|
||||||
@@ -124,7 +125,26 @@ export class ShutdownAction extends Action {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const shutdownDelay = this.config.shutdownDelay || SHUTDOWN.DEFAULT_DELAY_MINUTES;
|
const shutdownDelay = this.config.shutdownDelay ?? SHUTDOWN.DEFAULT_DELAY_MINUTES;
|
||||||
|
|
||||||
|
if (
|
||||||
|
ShutdownAction.scheduledDelayMinutes !== null &&
|
||||||
|
ShutdownAction.scheduledDelayMinutes <= shutdownDelay
|
||||||
|
) {
|
||||||
|
logger.info(
|
||||||
|
`Shutdown action skipped: shutdown already scheduled in ${ShutdownAction.scheduledDelayMinutes} minutes`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
ShutdownAction.scheduledDelayMinutes !== null &&
|
||||||
|
ShutdownAction.scheduledDelayMinutes > shutdownDelay
|
||||||
|
) {
|
||||||
|
logger.warn(
|
||||||
|
`Shutdown already scheduled in ${ShutdownAction.scheduledDelayMinutes} minutes, rescheduling to ${shutdownDelay} minutes`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
logger.log('');
|
logger.log('');
|
||||||
logger.logBoxTitle('Initiating System Shutdown', UI.WIDE_BOX_WIDTH, 'error');
|
logger.logBoxTitle('Initiating System Shutdown', UI.WIDE_BOX_WIDTH, 'error');
|
||||||
@@ -139,6 +159,7 @@ export class ShutdownAction extends Action {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await this.executeShutdownCommand(shutdownDelay);
|
await this.executeShutdownCommand(shutdownDelay);
|
||||||
|
ShutdownAction.scheduledDelayMinutes = shutdownDelay;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`Shutdown command failed: ${error instanceof Error ? error.message : String(error)}`,
|
`Shutdown command failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
@@ -227,6 +248,7 @@ export class ShutdownAction extends Action {
|
|||||||
logger.log(`Trying alternative shutdown method: ${cmdPath} ${alt.args.join(' ')}`);
|
logger.log(`Trying alternative shutdown method: ${cmdPath} ${alt.args.join(' ')}`);
|
||||||
await execFileAsync(cmdPath, alt.args);
|
await execFileAsync(cmdPath, alt.args);
|
||||||
logger.log(`Alternative method ${alt.cmd} succeeded`);
|
logger.log(`Alternative method ${alt.cmd} succeeded`);
|
||||||
|
ShutdownAction.scheduledDelayMinutes = 0;
|
||||||
return; // Exit if successful
|
return; // Exit if successful
|
||||||
}
|
}
|
||||||
} catch (_altError) {
|
} catch (_altError) {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
+200
-30
@@ -3,6 +3,8 @@ 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 { SHUTDOWN } from '../constants.ts';
|
||||||
import type { IGroupConfig, IUpsConfig } from '../daemon.ts';
|
import type { IGroupConfig, IUpsConfig } from '../daemon.ts';
|
||||||
import * as helpers from '../helpers/index.ts';
|
import * as helpers from '../helpers/index.ts';
|
||||||
|
|
||||||
@@ -65,11 +67,184 @@ 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 defaultShutdownDelay = this.nupst.getDaemon().getConfig().defaultShutdownDelay ??
|
||||||
|
SHUTDOWN.DEFAULT_DELAY_MINUTES;
|
||||||
|
|
||||||
|
const delayStr = await prompt(
|
||||||
|
` ${theme.dim('Shutdown delay')} ${
|
||||||
|
theme.dim(`(minutes, leave empty for default ${defaultShutdownDelay}):`)
|
||||||
|
} `,
|
||||||
|
);
|
||||||
|
if (delayStr.trim()) {
|
||||||
|
const shutdownDelay = parseInt(delayStr, 10);
|
||||||
|
if (isNaN(shutdownDelay) || shutdownDelay < 0) {
|
||||||
|
logger.error('Invalid shutdown delay. Must be >= 0.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
newAction.shutdownDelay = shutdownDelay;
|
||||||
|
}
|
||||||
|
} else if (typeValue === 2) {
|
||||||
|
// Webhook action
|
||||||
|
newAction.type = 'webhook';
|
||||||
|
|
||||||
|
const url = await prompt(` ${theme.dim('Webhook URL:')} `);
|
||||||
|
if (!url.trim()) {
|
||||||
|
logger.error('Webhook URL is required.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
newAction.webhookUrl = url.trim();
|
||||||
|
|
||||||
|
logger.log('');
|
||||||
|
logger.log(` ${theme.dim('HTTP Method:')}`);
|
||||||
|
logger.log(` ${theme.dim('1)')} POST (JSON body)`);
|
||||||
|
logger.log(` ${theme.dim('2)')} GET (query parameters)`);
|
||||||
|
const methodInput = await prompt(` ${theme.dim('Select method')} ${theme.dim('[1]:')} `);
|
||||||
|
newAction.webhookMethod = methodInput === '2' ? 'GET' : 'POST';
|
||||||
|
|
||||||
|
const timeoutInput = await prompt(
|
||||||
|
` ${theme.dim('Timeout in seconds')} ${theme.dim('[10]:')} `,
|
||||||
|
);
|
||||||
|
const timeout = parseInt(timeoutInput, 10);
|
||||||
|
if (timeoutInput.trim() && !isNaN(timeout)) {
|
||||||
|
newAction.webhookTimeout = timeout * 1000;
|
||||||
|
}
|
||||||
|
} else if (typeValue === 3) {
|
||||||
|
// Script action
|
||||||
|
newAction.type = 'script';
|
||||||
|
|
||||||
|
const scriptPath = await prompt(
|
||||||
|
` ${theme.dim('Script filename (in /etc/nupst/, must end with .sh):')} `,
|
||||||
|
);
|
||||||
|
if (!scriptPath.trim() || !scriptPath.trim().endsWith('.sh')) {
|
||||||
|
logger.error('Script path must end with .sh.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
newAction.scriptPath = scriptPath.trim();
|
||||||
|
|
||||||
|
const timeoutInput = await prompt(
|
||||||
|
` ${theme.dim('Script timeout in seconds')} ${theme.dim('[60]:')} `,
|
||||||
|
);
|
||||||
|
const timeout = parseInt(timeoutInput, 10);
|
||||||
|
if (timeoutInput.trim() && !isNaN(timeout)) {
|
||||||
|
newAction.scriptTimeout = timeout * 1000;
|
||||||
|
}
|
||||||
|
} else if (typeValue === 4) {
|
||||||
|
// Proxmox action
|
||||||
|
newAction.type = 'proxmox';
|
||||||
|
|
||||||
|
// Auto-detect CLI availability
|
||||||
|
const detection = ProxmoxAction.detectCliAvailability();
|
||||||
|
|
||||||
|
if (detection.available) {
|
||||||
|
logger.log('');
|
||||||
|
logger.success('Proxmox CLI tools detected (qm/pct). No API token needed.');
|
||||||
|
logger.dim(` qm: ${detection.qmPath}`);
|
||||||
|
logger.dim(` pct: ${detection.pctPath}`);
|
||||||
|
newAction.proxmoxMode = 'cli';
|
||||||
|
} else {
|
||||||
|
logger.log('');
|
||||||
|
if (!detection.isRoot) {
|
||||||
|
logger.warn('Not running as root - CLI mode unavailable, using API mode.');
|
||||||
|
} else {
|
||||||
|
logger.warn('Proxmox CLI tools (qm/pct) not found - using API mode.');
|
||||||
|
}
|
||||||
|
logger.log('');
|
||||||
|
logger.info('Proxmox API Settings:');
|
||||||
|
logger.dim('Create a token with: pveum user token add root@pam nupst --privsep=0');
|
||||||
|
|
||||||
|
const pxHost = await prompt(
|
||||||
|
` ${theme.dim('Proxmox Host')} ${theme.dim('[localhost]:')} `,
|
||||||
|
);
|
||||||
|
newAction.proxmoxHost = pxHost.trim() || 'localhost';
|
||||||
|
|
||||||
|
const pxPortInput = await prompt(
|
||||||
|
` ${theme.dim('Proxmox API Port')} ${theme.dim('[8006]:')} `,
|
||||||
|
);
|
||||||
|
const pxPort = parseInt(pxPortInput, 10);
|
||||||
|
newAction.proxmoxPort = pxPortInput.trim() && !isNaN(pxPort) ? pxPort : 8006;
|
||||||
|
|
||||||
|
const pxNode = await prompt(
|
||||||
|
` ${theme.dim('Proxmox Node Name (empty = auto-detect):')} `,
|
||||||
|
);
|
||||||
|
if (pxNode.trim()) {
|
||||||
|
newAction.proxmoxNode = pxNode.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenId = await prompt(` ${theme.dim('API Token ID (e.g., root@pam!nupst):')} `);
|
||||||
|
if (!tokenId.trim()) {
|
||||||
|
logger.error('Token ID is required for API mode.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
newAction.proxmoxTokenId = tokenId.trim();
|
||||||
|
|
||||||
|
const tokenSecret = await prompt(` ${theme.dim('API Token Secret:')} `);
|
||||||
|
if (!tokenSecret.trim()) {
|
||||||
|
logger.error('Token Secret is required for API mode.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
newAction.proxmoxTokenSecret = tokenSecret.trim();
|
||||||
|
|
||||||
|
const insecureInput = await prompt(
|
||||||
|
` ${theme.dim('Skip TLS verification (self-signed cert)?')} ${theme.dim('(Y/n):')} `,
|
||||||
|
);
|
||||||
|
newAction.proxmoxInsecure = insecureInput.toLowerCase() !== 'n';
|
||||||
|
newAction.proxmoxMode = 'api';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Common Proxmox settings (both modes)
|
||||||
|
const excludeInput = await prompt(
|
||||||
|
` ${theme.dim('VM/CT IDs to exclude (comma-separated, or empty):')} `,
|
||||||
|
);
|
||||||
|
if (excludeInput.trim()) {
|
||||||
|
newAction.proxmoxExcludeIds = excludeInput.split(',').map((s) => parseInt(s.trim(), 10))
|
||||||
|
.filter((n) => !isNaN(n));
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutInput = await prompt(
|
||||||
|
` ${theme.dim('VM shutdown timeout in seconds')} ${theme.dim('[120]:')} `,
|
||||||
|
);
|
||||||
|
const stopTimeout = parseInt(timeoutInput, 10);
|
||||||
|
if (timeoutInput.trim() && !isNaN(stopTimeout)) {
|
||||||
|
newAction.proxmoxStopTimeout = stopTimeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
const forceInput = await prompt(
|
||||||
|
` ${theme.dim("Force-stop VMs that don't shut down in time?")} ${
|
||||||
|
theme.dim('(Y/n):')
|
||||||
|
} `,
|
||||||
|
);
|
||||||
|
newAction.proxmoxForceStop = forceInput.toLowerCase() !== 'n';
|
||||||
|
|
||||||
|
const haPolicyInput = await prompt(
|
||||||
|
` ${theme.dim('HA-managed guest handling')} ${theme.dim('([1] none, 2 haStop):')} `,
|
||||||
|
);
|
||||||
|
newAction.proxmoxHaPolicy = haPolicyInput.trim() === '2' ? 'haStop' : 'none';
|
||||||
|
} 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 +264,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 +290,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 +507,24 @@ export class ActionHandler {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const rows = target.actions.map((action, index) => {
|
const rows = target.actions.map((action, index) => {
|
||||||
let details = `${action.shutdownDelay || 5}s delay`;
|
const defaultShutdownDelay = this.nupst.getDaemon().getConfig().defaultShutdownDelay ??
|
||||||
|
SHUTDOWN.DEFAULT_DELAY_MINUTES;
|
||||||
|
let details = `${action.shutdownDelay ?? defaultShutdownDelay}min delay`;
|
||||||
if (action.type === 'proxmox') {
|
if (action.type === 'proxmox') {
|
||||||
const host = action.proxmoxHost || 'localhost';
|
const mode = action.proxmoxMode || 'auto';
|
||||||
const port = action.proxmoxPort || 8006;
|
if (mode === 'cli' || (mode === 'auto' && !action.proxmoxTokenId)) {
|
||||||
details = `${host}:${port}`;
|
details = 'CLI mode';
|
||||||
|
} else {
|
||||||
|
const host = action.proxmoxHost || 'localhost';
|
||||||
|
const port = action.proxmoxPort || 8006;
|
||||||
|
details = `API ${host}:${port}`;
|
||||||
|
}
|
||||||
|
if (action.proxmoxExcludeIds?.length) {
|
||||||
|
details += `, excl: ${action.proxmoxExcludeIds.join(',')}`;
|
||||||
|
}
|
||||||
|
if (action.proxmoxHaPolicy === 'haStop') {
|
||||||
|
details += ', haStop';
|
||||||
|
}
|
||||||
} else if (action.type === 'webhook') {
|
} else if (action.type === 'webhook') {
|
||||||
details = action.webhookUrl || theme.dim('N/A');
|
details = action.webhookUrl || theme.dim('N/A');
|
||||||
} else if (action.type === 'script') {
|
} else if (action.type === 'script') {
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+92
-45
@@ -9,7 +9,8 @@ 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 { UPSD } from '../constants.ts';
|
import { ProxmoxAction } from '../actions/proxmox-action.ts';
|
||||||
|
import { SHUTDOWN, UPSD } from '../constants.ts';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Thresholds configuration for CLI display
|
* Thresholds configuration for CLI display
|
||||||
@@ -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.`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1135,11 +1152,19 @@ export class UpsHandler {
|
|||||||
if (typeValue === 1) {
|
if (typeValue === 1) {
|
||||||
// Shutdown action
|
// Shutdown action
|
||||||
action.type = 'shutdown';
|
action.type = 'shutdown';
|
||||||
|
const defaultShutdownDelay = this.nupst.getDaemon().getConfig().defaultShutdownDelay ??
|
||||||
|
SHUTDOWN.DEFAULT_DELAY_MINUTES;
|
||||||
|
|
||||||
const delayInput = await prompt('Shutdown delay in minutes [5]: ');
|
const delayInput = await prompt(
|
||||||
const delay = parseInt(delayInput, 10);
|
`Shutdown delay in minutes (leave empty for default ${defaultShutdownDelay}): `,
|
||||||
if (delayInput.trim() && !isNaN(delay)) {
|
);
|
||||||
action.shutdownDelay = delay;
|
if (delayInput.trim()) {
|
||||||
|
const delay = parseInt(delayInput, 10);
|
||||||
|
if (isNaN(delay) || delay < 0) {
|
||||||
|
logger.warn('Invalid shutdown delay, using configured default');
|
||||||
|
} else {
|
||||||
|
action.shutdownDelay = delay;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (typeValue === 2) {
|
} else if (typeValue === 2) {
|
||||||
// Webhook action
|
// Webhook action
|
||||||
@@ -1184,40 +1209,62 @@ export class UpsHandler {
|
|||||||
// Proxmox action
|
// Proxmox action
|
||||||
action.type = 'proxmox';
|
action.type = 'proxmox';
|
||||||
|
|
||||||
logger.log('');
|
// Auto-detect CLI availability
|
||||||
logger.info('Proxmox API Settings:');
|
const detection = ProxmoxAction.detectCliAvailability();
|
||||||
logger.dim('Requires a Proxmox API token. Create one with:');
|
|
||||||
logger.dim(' pveum user token add root@pam nupst --privsep=0');
|
|
||||||
|
|
||||||
const pxHost = await prompt('Proxmox Host [localhost]: ');
|
if (detection.available) {
|
||||||
action.proxmoxHost = pxHost.trim() || 'localhost';
|
logger.log('');
|
||||||
|
logger.success('Proxmox CLI tools detected (qm/pct). No API token needed.');
|
||||||
|
logger.dim(` qm: ${detection.qmPath}`);
|
||||||
|
logger.dim(` pct: ${detection.pctPath}`);
|
||||||
|
action.proxmoxMode = 'cli';
|
||||||
|
} else {
|
||||||
|
logger.log('');
|
||||||
|
if (!detection.isRoot) {
|
||||||
|
logger.warn('Not running as root - CLI mode unavailable, using API mode.');
|
||||||
|
} else {
|
||||||
|
logger.warn('Proxmox CLI tools (qm/pct) not found - using API mode.');
|
||||||
|
}
|
||||||
|
logger.log('');
|
||||||
|
logger.info('Proxmox API Settings:');
|
||||||
|
logger.dim('Create a token with: pveum user token add root@pam nupst --privsep=0');
|
||||||
|
|
||||||
const pxPortInput = await prompt('Proxmox API Port [8006]: ');
|
const pxHost = await prompt('Proxmox Host [localhost]: ');
|
||||||
const pxPort = parseInt(pxPortInput, 10);
|
action.proxmoxHost = pxHost.trim() || 'localhost';
|
||||||
action.proxmoxPort = pxPortInput.trim() && !isNaN(pxPort) ? pxPort : 8006;
|
|
||||||
|
|
||||||
const pxNode = await prompt('Proxmox Node Name (empty = auto-detect via hostname): ');
|
const pxPortInput = await prompt('Proxmox API Port [8006]: ');
|
||||||
if (pxNode.trim()) {
|
const pxPort = parseInt(pxPortInput, 10);
|
||||||
action.proxmoxNode = pxNode.trim();
|
action.proxmoxPort = pxPortInput.trim() && !isNaN(pxPort) ? pxPort : 8006;
|
||||||
|
|
||||||
|
const pxNode = await prompt('Proxmox Node Name (empty = auto-detect via hostname): ');
|
||||||
|
if (pxNode.trim()) {
|
||||||
|
action.proxmoxNode = pxNode.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenId = await prompt('API Token ID (e.g., root@pam!nupst): ');
|
||||||
|
if (!tokenId.trim()) {
|
||||||
|
logger.warn('Token ID is required for API mode, skipping');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
action.proxmoxTokenId = tokenId.trim();
|
||||||
|
|
||||||
|
const tokenSecret = await prompt('API Token Secret: ');
|
||||||
|
if (!tokenSecret.trim()) {
|
||||||
|
logger.warn('Token Secret is required for API mode, skipping');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
action.proxmoxTokenSecret = tokenSecret.trim();
|
||||||
|
|
||||||
|
const insecureInput = await prompt('Skip TLS verification (self-signed cert)? (Y/n): ');
|
||||||
|
action.proxmoxInsecure = insecureInput.toLowerCase() !== 'n';
|
||||||
|
action.proxmoxMode = 'api';
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokenId = await prompt('API Token ID (e.g., root@pam!nupst): ');
|
// Common Proxmox settings (both modes)
|
||||||
if (!tokenId.trim()) {
|
|
||||||
logger.warn('Token ID is required for Proxmox action, skipping');
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
action.proxmoxTokenId = tokenId.trim();
|
|
||||||
|
|
||||||
const tokenSecret = await prompt('API Token Secret: ');
|
|
||||||
if (!tokenSecret.trim()) {
|
|
||||||
logger.warn('Token Secret is required for Proxmox action, skipping');
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
action.proxmoxTokenSecret = tokenSecret.trim();
|
|
||||||
|
|
||||||
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]: ');
|
||||||
@@ -1226,11 +1273,11 @@ 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): ');
|
const haPolicyInput = await prompt('HA-managed guest handling ([1] none, 2 haStop): ');
|
||||||
action.proxmoxInsecure = insecureInput.toLowerCase() !== 'n';
|
action.proxmoxHaPolicy = haPolicyInput.trim() === '2' ? 'haStop' : 'none';
|
||||||
|
|
||||||
logger.log('');
|
logger.log('');
|
||||||
logger.info('Note: Place the Proxmox action BEFORE the shutdown action');
|
logger.info('Note: Place the Proxmox action BEFORE the shutdown action');
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+361
-473
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,198 @@
|
|||||||
|
import type { IActionConfig, TPowerStatus } from './actions/base-action.ts';
|
||||||
|
import { createInitialUpsStatus, type IUpsIdentity, type IUpsStatus } from './ups-status.ts';
|
||||||
|
|
||||||
|
export interface IGroupStatusSnapshot {
|
||||||
|
updatedStatus: IUpsStatus;
|
||||||
|
transition: 'none' | 'powerStatusChange';
|
||||||
|
previousStatus?: IUpsStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IGroupThresholdEvaluation {
|
||||||
|
exceedsThreshold: boolean;
|
||||||
|
blockedByUnreachable: boolean;
|
||||||
|
representativeStatus?: IUpsStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
const destructiveActionTypes = new Set(['shutdown', 'proxmox']);
|
||||||
|
|
||||||
|
function getStatusSeverity(powerStatus: TPowerStatus): number {
|
||||||
|
switch (powerStatus) {
|
||||||
|
case 'unreachable':
|
||||||
|
return 3;
|
||||||
|
case 'onBattery':
|
||||||
|
return 2;
|
||||||
|
case 'unknown':
|
||||||
|
return 1;
|
||||||
|
case 'online':
|
||||||
|
default:
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectWorstStatus(statuses: IUpsStatus[]): IUpsStatus | undefined {
|
||||||
|
return statuses.reduce<IUpsStatus | undefined>((worst, status) => {
|
||||||
|
if (!worst) {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
const severityDiff = getStatusSeverity(status.powerStatus) -
|
||||||
|
getStatusSeverity(worst.powerStatus);
|
||||||
|
if (severityDiff > 0) {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
if (severityDiff < 0) {
|
||||||
|
return worst;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.batteryRuntime !== worst.batteryRuntime) {
|
||||||
|
return status.batteryRuntime < worst.batteryRuntime ? status : worst;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.batteryCapacity !== worst.batteryCapacity) {
|
||||||
|
return status.batteryCapacity < worst.batteryCapacity ? status : worst;
|
||||||
|
}
|
||||||
|
|
||||||
|
return worst;
|
||||||
|
}, undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveGroupPowerStatus(
|
||||||
|
mode: 'redundant' | 'nonRedundant',
|
||||||
|
memberStatuses: IUpsStatus[],
|
||||||
|
): TPowerStatus {
|
||||||
|
if (memberStatuses.length === 0) {
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (memberStatuses.some((status) => status.powerStatus === 'unreachable')) {
|
||||||
|
return 'unreachable';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'redundant') {
|
||||||
|
if (memberStatuses.every((status) => status.powerStatus === 'onBattery')) {
|
||||||
|
return 'onBattery';
|
||||||
|
}
|
||||||
|
} else if (memberStatuses.some((status) => status.powerStatus === 'onBattery')) {
|
||||||
|
return 'onBattery';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (memberStatuses.some((status) => status.powerStatus === 'unknown')) {
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'online';
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickRepresentativeStatus(
|
||||||
|
powerStatus: TPowerStatus,
|
||||||
|
memberStatuses: IUpsStatus[],
|
||||||
|
): IUpsStatus | undefined {
|
||||||
|
const matchingStatuses = memberStatuses.filter((status) => status.powerStatus === powerStatus);
|
||||||
|
return selectWorstStatus(matchingStatuses.length > 0 ? matchingStatuses : memberStatuses);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildGroupStatusSnapshot(
|
||||||
|
group: IUpsIdentity,
|
||||||
|
mode: 'redundant' | 'nonRedundant',
|
||||||
|
memberStatuses: IUpsStatus[],
|
||||||
|
currentStatus: IUpsStatus | undefined,
|
||||||
|
currentTime: number,
|
||||||
|
): IGroupStatusSnapshot {
|
||||||
|
const previousStatus = currentStatus || createInitialUpsStatus(group, currentTime);
|
||||||
|
const powerStatus = deriveGroupPowerStatus(mode, memberStatuses);
|
||||||
|
const representative = pickRepresentativeStatus(powerStatus, memberStatuses) || previousStatus;
|
||||||
|
const updatedStatus: IUpsStatus = {
|
||||||
|
...previousStatus,
|
||||||
|
id: group.id,
|
||||||
|
name: group.name,
|
||||||
|
powerStatus,
|
||||||
|
batteryCapacity: representative.batteryCapacity,
|
||||||
|
batteryRuntime: representative.batteryRuntime,
|
||||||
|
outputLoad: representative.outputLoad,
|
||||||
|
outputPower: representative.outputPower,
|
||||||
|
outputVoltage: representative.outputVoltage,
|
||||||
|
outputCurrent: representative.outputCurrent,
|
||||||
|
lastCheckTime: currentTime,
|
||||||
|
consecutiveFailures: 0,
|
||||||
|
unreachableSince: powerStatus === 'unreachable'
|
||||||
|
? previousStatus.unreachableSince || currentTime
|
||||||
|
: 0,
|
||||||
|
lastStatusChange: previousStatus.lastStatusChange || currentTime,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (previousStatus.powerStatus !== powerStatus) {
|
||||||
|
updatedStatus.lastStatusChange = currentTime;
|
||||||
|
if (powerStatus === 'unreachable') {
|
||||||
|
updatedStatus.unreachableSince = currentTime;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
updatedStatus,
|
||||||
|
transition: 'powerStatusChange',
|
||||||
|
previousStatus,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
updatedStatus,
|
||||||
|
transition: 'none',
|
||||||
|
previousStatus: currentStatus,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function evaluateGroupActionThreshold(
|
||||||
|
actionConfig: IActionConfig,
|
||||||
|
mode: 'redundant' | 'nonRedundant',
|
||||||
|
memberStatuses: IUpsStatus[],
|
||||||
|
): IGroupThresholdEvaluation {
|
||||||
|
if (!actionConfig.thresholds || memberStatuses.length === 0) {
|
||||||
|
return {
|
||||||
|
exceedsThreshold: false,
|
||||||
|
blockedByUnreachable: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const criticalMembers = memberStatuses.filter((status) =>
|
||||||
|
status.powerStatus === 'onBattery' &&
|
||||||
|
(status.batteryCapacity < actionConfig.thresholds!.battery ||
|
||||||
|
status.batteryRuntime < actionConfig.thresholds!.runtime)
|
||||||
|
);
|
||||||
|
const exceedsThreshold = mode === 'redundant'
|
||||||
|
? criticalMembers.length === memberStatuses.length
|
||||||
|
: criticalMembers.length > 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
exceedsThreshold,
|
||||||
|
blockedByUnreachable: exceedsThreshold &&
|
||||||
|
destructiveActionTypes.has(actionConfig.type) &&
|
||||||
|
memberStatuses.some((status) => status.powerStatus === 'unreachable'),
|
||||||
|
representativeStatus: selectWorstStatus(criticalMembers),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildGroupThresholdContextStatus(
|
||||||
|
group: IUpsIdentity,
|
||||||
|
evaluations: IGroupThresholdEvaluation[],
|
||||||
|
enteredActionIndexes: number[],
|
||||||
|
fallbackStatus: IUpsStatus,
|
||||||
|
currentTime: number,
|
||||||
|
): IUpsStatus {
|
||||||
|
const representativeStatuses = enteredActionIndexes
|
||||||
|
.map((index) => evaluations[index]?.representativeStatus)
|
||||||
|
.filter((status): status is IUpsStatus => !!status);
|
||||||
|
|
||||||
|
const representative = selectWorstStatus(representativeStatuses) || fallbackStatus;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...fallbackStatus,
|
||||||
|
id: group.id,
|
||||||
|
name: group.name,
|
||||||
|
powerStatus: 'onBattery',
|
||||||
|
batteryCapacity: representative.batteryCapacity,
|
||||||
|
batteryRuntime: representative.batteryRuntime,
|
||||||
|
outputLoad: representative.outputLoad,
|
||||||
|
outputPower: representative.outputPower,
|
||||||
|
outputVoltage: representative.outputVoltage,
|
||||||
|
outputCurrent: representative.outputCurrent,
|
||||||
|
lastCheckTime: currentTime,
|
||||||
|
};
|
||||||
|
}
|
||||||
+2
-1
@@ -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
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export class MigrationRunner {
|
|||||||
if (anyMigrationsRan) {
|
if (anyMigrationsRan) {
|
||||||
logger.success('Configuration migrations complete');
|
logger.success('Configuration migrations complete');
|
||||||
} else {
|
} else {
|
||||||
logger.success('config format ok');
|
logger.success('Configuration format OK');
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -37,8 +37,7 @@ import { logger } from '../logger.ts';
|
|||||||
* {
|
* {
|
||||||
* type: "shutdown",
|
* type: "shutdown",
|
||||||
* thresholds: { battery: 60, runtime: 20 },
|
* thresholds: { battery: 60, runtime: 20 },
|
||||||
* triggerMode: "onlyThresholds",
|
* triggerMode: "onlyThresholds"
|
||||||
* shutdownDelay: 5
|
|
||||||
* }
|
* }
|
||||||
* ]
|
* ]
|
||||||
* }
|
* }
|
||||||
@@ -93,7 +92,6 @@ export class MigrationV4_0ToV4_1 extends BaseMigration {
|
|||||||
runtime: deviceThresholds.runtime,
|
runtime: deviceThresholds.runtime,
|
||||||
},
|
},
|
||||||
triggerMode: 'onlyThresholds', // Preserve old behavior (only on threshold violation)
|
triggerMode: 'onlyThresholds', // Preserve old behavior (only on threshold violation)
|
||||||
shutdownDelay: 5, // Default delay
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
logger.dim(
|
logger.dim(
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
+245
-171
@@ -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}`,
|
||||||
);
|
);
|
||||||
logger.dim(`Using community: ${config.community}`);
|
if (config.version === 1 || config.version === 2) {
|
||||||
}
|
logger.dim(`Using community: ${config.community}`);
|
||||||
|
|
||||||
// Create SNMP options based on configuration
|
|
||||||
// deno-lint-ignore no-explicit-any
|
|
||||||
const options: any = {
|
|
||||||
port: config.port,
|
|
||||||
retries: SNMP.RETRIES, // Number of retries
|
|
||||||
timeout: config.timeout,
|
|
||||||
transport: 'udp4',
|
|
||||||
idBitsSize: 32,
|
|
||||||
context: config.context || '',
|
|
||||||
};
|
|
||||||
|
|
||||||
// Set version based on config
|
|
||||||
if (config.version === 1) {
|
|
||||||
options.version = snmp.Version1;
|
|
||||||
} else if (config.version === 2) {
|
|
||||||
options.version = snmp.Version2c;
|
|
||||||
} else {
|
|
||||||
options.version = snmp.Version3;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create appropriate session based on SNMP version
|
|
||||||
let session;
|
|
||||||
|
|
||||||
if (config.version === 3) {
|
|
||||||
// For SNMPv3, we need to set up authentication and privacy
|
|
||||||
// For SNMPv3, we need a valid security level
|
|
||||||
const securityLevel = config.securityLevel || 'noAuthNoPriv';
|
|
||||||
|
|
||||||
// Create the user object with required structure for net-snmp
|
|
||||||
// deno-lint-ignore no-explicit-any
|
|
||||||
const user: any = {
|
|
||||||
name: config.username || '',
|
|
||||||
};
|
|
||||||
|
|
||||||
// Set security level
|
|
||||||
if (securityLevel === 'noAuthNoPriv') {
|
|
||||||
user.level = snmp.SecurityLevel.noAuthNoPriv;
|
|
||||||
} else if (securityLevel === 'authNoPriv') {
|
|
||||||
user.level = snmp.SecurityLevel.authNoPriv;
|
|
||||||
|
|
||||||
// Set auth protocol - must provide both protocol and key
|
|
||||||
if (config.authProtocol && config.authKey) {
|
|
||||||
if (config.authProtocol === 'MD5') {
|
|
||||||
user.authProtocol = snmp.AuthProtocols.md5;
|
|
||||||
} else if (config.authProtocol === 'SHA') {
|
|
||||||
user.authProtocol = snmp.AuthProtocols.sha;
|
|
||||||
}
|
|
||||||
user.authKey = config.authKey;
|
|
||||||
} else {
|
|
||||||
// Fallback to noAuthNoPriv if auth details missing
|
|
||||||
user.level = snmp.SecurityLevel.noAuthNoPriv;
|
|
||||||
if (this.debug) {
|
|
||||||
logger.warn('Missing authProtocol or authKey, falling back to noAuthNoPriv');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (securityLevel === 'authPriv') {
|
|
||||||
user.level = snmp.SecurityLevel.authPriv;
|
|
||||||
|
|
||||||
// Set auth protocol - must provide both protocol and key
|
|
||||||
if (config.authProtocol && config.authKey) {
|
|
||||||
if (config.authProtocol === 'MD5') {
|
|
||||||
user.authProtocol = snmp.AuthProtocols.md5;
|
|
||||||
} else if (config.authProtocol === 'SHA') {
|
|
||||||
user.authProtocol = snmp.AuthProtocols.sha;
|
|
||||||
}
|
|
||||||
user.authKey = config.authKey;
|
|
||||||
|
|
||||||
// Set privacy protocol - must provide both protocol and key
|
|
||||||
if (config.privProtocol && config.privKey) {
|
|
||||||
if (config.privProtocol === 'DES') {
|
|
||||||
user.privProtocol = snmp.PrivProtocols.des;
|
|
||||||
} else if (config.privProtocol === 'AES') {
|
|
||||||
user.privProtocol = snmp.PrivProtocols.aes;
|
|
||||||
}
|
|
||||||
user.privKey = config.privKey;
|
|
||||||
} else {
|
|
||||||
// Fallback to authNoPriv if priv details missing
|
|
||||||
user.level = snmp.SecurityLevel.authNoPriv;
|
|
||||||
if (this.debug) {
|
|
||||||
logger.warn('Missing privProtocol or privKey, falling back to authNoPriv');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Fallback to noAuthNoPriv if auth details missing
|
|
||||||
user.level = snmp.SecurityLevel.noAuthNoPriv;
|
|
||||||
if (this.debug) {
|
|
||||||
logger.warn('Missing authProtocol or authKey, falling back to noAuthNoPriv');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.debug) {
|
|
||||||
const levelName = Object.keys(snmp.SecurityLevel).find((key) =>
|
|
||||||
snmp.SecurityLevel[key] === user.level
|
|
||||||
);
|
|
||||||
logger.dim(
|
|
||||||
`SNMPv3 user configuration: name=${user.name}, level=${levelName}, authProtocol=${
|
|
||||||
user.authProtocol ? 'Set' : 'Not Set'
|
|
||||||
}, privProtocol=${user.privProtocol ? 'Set' : 'Not Set'}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
session = snmp.createV3Session(config.host, user, options);
|
|
||||||
} else {
|
|
||||||
// For SNMPv1/v2c, we use the community string
|
|
||||||
session = snmp.createSession(config.host, config.community || 'public', options);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const options = this.createSessionOptions(config);
|
||||||
|
const session: ISnmpSession = config.version === 3
|
||||||
|
? (() => {
|
||||||
|
const { user, levelLabel } = this.buildV3User(config);
|
||||||
|
|
||||||
|
if (this.debug) {
|
||||||
|
logger.dim(
|
||||||
|
`SNMPv3 user configuration: name=${user.name}, level=${levelLabel}, authProtocol=${
|
||||||
|
user.authProtocol ? 'Set' : 'Not Set'
|
||||||
|
}, privProtocol=${user.privProtocol ? 'Set' : 'Not Set'}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return snmpLib.createV3Session(config.host, user, options);
|
||||||
|
})()
|
||||||
|
: snmpLib.createSession(config.host, config.community || 'public', options);
|
||||||
|
|
||||||
// Convert the OID string to an array of OIDs if multiple OIDs are needed
|
// Convert the OID string to an array of OIDs if multiple OIDs are needed
|
||||||
const oids = [oid];
|
const oids = [oid];
|
||||||
|
|
||||||
// Send the GET request
|
// Send the GET request
|
||||||
// deno-lint-ignore no-explicit-any
|
session.get(oids, (error: Error | null, varbinds?: ISnmpVarbind[]) => {
|
||||||
session.get(oids, (error: Error | null, varbinds: any[]) => {
|
|
||||||
// Close the session to release resources
|
// Close the session to release resources
|
||||||
session.close();
|
session.close();
|
||||||
|
|
||||||
@@ -230,7 +320,9 @@ export class NupstSnmp {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!varbinds || varbinds.length === 0) {
|
const varbind = varbinds?.[0];
|
||||||
|
|
||||||
|
if (!varbind) {
|
||||||
if (this.debug) {
|
if (this.debug) {
|
||||||
logger.error('No varbinds returned in response');
|
logger.error('No varbinds returned in response');
|
||||||
}
|
}
|
||||||
@@ -239,36 +331,20 @@ export class NupstSnmp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check for SNMP errors in the response
|
// Check for SNMP errors in the response
|
||||||
if (
|
if (snmpLib.isVarbindError(varbind)) {
|
||||||
varbinds[0].type === snmp.ObjectType.NoSuchObject ||
|
const errorMessage = snmpLib.varbindError(varbind);
|
||||||
varbinds[0].type === snmp.ObjectType.NoSuchInstance ||
|
|
||||||
varbinds[0].type === snmp.ObjectType.EndOfMibView
|
|
||||||
) {
|
|
||||||
if (this.debug) {
|
if (this.debug) {
|
||||||
logger.error(`SNMP error: ${snmp.ObjectType[varbinds[0].type]}`);
|
logger.error(`SNMP error: ${errorMessage}`);
|
||||||
}
|
}
|
||||||
reject(new Error(`SNMP error: ${snmp.ObjectType[varbinds[0].type]}`));
|
reject(new Error(`SNMP error: ${errorMessage}`));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process the response value based on its type
|
const value = this.normalizeSnmpValue(varbind.value);
|
||||||
let value = varbinds[0].value;
|
|
||||||
|
|
||||||
// Handle specific types that might need conversion
|
|
||||||
if (Buffer.isBuffer(value)) {
|
|
||||||
// If value is a Buffer, try to convert it to a string if it's printable ASCII
|
|
||||||
const isPrintableAscii = value.every((byte: number) => byte >= 32 && byte <= 126);
|
|
||||||
if (isPrintableAscii) {
|
|
||||||
value = value.toString();
|
|
||||||
}
|
|
||||||
} else if (typeof value === 'bigint') {
|
|
||||||
// Convert BigInt to a normal number or string if needed
|
|
||||||
value = Number(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.debug) {
|
if (this.debug) {
|
||||||
logger.dim(
|
logger.dim(
|
||||||
`SNMP response: oid=${varbinds[0].oid}, type=${varbinds[0].type}, value=${value}`,
|
`SNMP response: oid=${varbind.oid}, type=${varbind.type}, value=${value}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,43 +391,44 @@ export class NupstSnmp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get all values with independent retry logic
|
// Get all values with independent retry logic
|
||||||
const powerStatusValue = await this.getSNMPValueWithRetry(
|
const powerStatusValue = this.coerceNumericSnmpValue(
|
||||||
this.activeOIDs.POWER_STATUS,
|
await this.getSNMPValueWithRetry(this.activeOIDs.POWER_STATUS, 'power status', config),
|
||||||
'power status',
|
'power status',
|
||||||
config,
|
|
||||||
);
|
);
|
||||||
const batteryCapacity = await this.getSNMPValueWithRetry(
|
const batteryCapacity = this.coerceNumericSnmpValue(
|
||||||
this.activeOIDs.BATTERY_CAPACITY,
|
await this.getSNMPValueWithRetry(
|
||||||
|
this.activeOIDs.BATTERY_CAPACITY,
|
||||||
|
'battery capacity',
|
||||||
|
config,
|
||||||
|
),
|
||||||
'battery capacity',
|
'battery capacity',
|
||||||
config,
|
);
|
||||||
) || 0;
|
const batteryRuntime = this.coerceNumericSnmpValue(
|
||||||
const batteryRuntime = await this.getSNMPValueWithRetry(
|
await this.getSNMPValueWithRetry(
|
||||||
this.activeOIDs.BATTERY_RUNTIME,
|
this.activeOIDs.BATTERY_RUNTIME,
|
||||||
|
'battery runtime',
|
||||||
|
config,
|
||||||
|
),
|
||||||
'battery runtime',
|
'battery runtime',
|
||||||
config,
|
);
|
||||||
) || 0;
|
|
||||||
|
|
||||||
// Get power draw metrics
|
// Get power draw metrics
|
||||||
const outputLoad = await this.getSNMPValueWithRetry(
|
const outputLoad = this.coerceNumericSnmpValue(
|
||||||
this.activeOIDs.OUTPUT_LOAD,
|
await this.getSNMPValueWithRetry(this.activeOIDs.OUTPUT_LOAD, 'output load', config),
|
||||||
'output load',
|
'output load',
|
||||||
config,
|
);
|
||||||
) || 0;
|
const outputPower = this.coerceNumericSnmpValue(
|
||||||
const outputPower = await this.getSNMPValueWithRetry(
|
await this.getSNMPValueWithRetry(this.activeOIDs.OUTPUT_POWER, 'output power', config),
|
||||||
this.activeOIDs.OUTPUT_POWER,
|
|
||||||
'output power',
|
'output power',
|
||||||
config,
|
);
|
||||||
) || 0;
|
const outputVoltage = this.coerceNumericSnmpValue(
|
||||||
const outputVoltage = await this.getSNMPValueWithRetry(
|
await this.getSNMPValueWithRetry(this.activeOIDs.OUTPUT_VOLTAGE, 'output voltage', config),
|
||||||
this.activeOIDs.OUTPUT_VOLTAGE,
|
|
||||||
'output voltage',
|
'output voltage',
|
||||||
config,
|
);
|
||||||
) || 0;
|
const outputCurrent = this.coerceNumericSnmpValue(
|
||||||
const outputCurrent = await this.getSNMPValueWithRetry(
|
await this.getSNMPValueWithRetry(this.activeOIDs.OUTPUT_CURRENT, 'output current', config),
|
||||||
this.activeOIDs.OUTPUT_CURRENT,
|
|
||||||
'output current',
|
'output current',
|
||||||
config,
|
);
|
||||||
) || 0;
|
|
||||||
|
|
||||||
// Determine power status - handle different values for different UPS models
|
// Determine power status - handle different values for different UPS models
|
||||||
const powerStatus = this.determinePowerStatus(config.upsModel, powerStatusValue);
|
const powerStatus = this.determinePowerStatus(config.upsModel, powerStatusValue);
|
||||||
@@ -430,10 +507,9 @@ export class NupstSnmp {
|
|||||||
*/
|
*/
|
||||||
private async getSNMPValueWithRetry(
|
private async getSNMPValueWithRetry(
|
||||||
oid: string,
|
oid: string,
|
||||||
description: string,
|
description: TSnmpMetricDescription,
|
||||||
config: ISnmpConfig,
|
config: ISnmpConfig,
|
||||||
// deno-lint-ignore no-explicit-any
|
): Promise<TSnmpValue | 0> {
|
||||||
): Promise<any> {
|
|
||||||
if (oid === '') {
|
if (oid === '') {
|
||||||
if (this.debug) {
|
if (this.debug) {
|
||||||
logger.dim(`No OID provided for ${description}, skipping`);
|
logger.dim(`No OID provided for ${description}, skipping`);
|
||||||
@@ -485,10 +561,9 @@ export class NupstSnmp {
|
|||||||
*/
|
*/
|
||||||
private async tryFallbackSecurityLevels(
|
private async tryFallbackSecurityLevels(
|
||||||
oid: string,
|
oid: string,
|
||||||
description: string,
|
description: TSnmpMetricDescription,
|
||||||
config: ISnmpConfig,
|
config: ISnmpConfig,
|
||||||
// deno-lint-ignore no-explicit-any
|
): Promise<TSnmpValue | 0> {
|
||||||
): Promise<any> {
|
|
||||||
if (this.debug) {
|
if (this.debug) {
|
||||||
logger.dim(`Retrying ${description} with fallback security level...`);
|
logger.dim(`Retrying ${description} with fallback security level...`);
|
||||||
}
|
}
|
||||||
@@ -551,10 +626,9 @@ export class NupstSnmp {
|
|||||||
*/
|
*/
|
||||||
private async tryStandardOids(
|
private async tryStandardOids(
|
||||||
_oid: string,
|
_oid: string,
|
||||||
description: string,
|
description: TSnmpMetricDescription,
|
||||||
config: ISnmpConfig,
|
config: ISnmpConfig,
|
||||||
// deno-lint-ignore no-explicit-any
|
): Promise<TSnmpValue | 0> {
|
||||||
): Promise<any> {
|
|
||||||
try {
|
try {
|
||||||
// Try RFC 1628 standard UPS MIB OIDs
|
// Try RFC 1628 standard UPS MIB OIDs
|
||||||
const standardOIDs = UpsOidSets.getStandardOids();
|
const standardOIDs = UpsOidSets.getStandardOids();
|
||||||
|
|||||||
+135
-42
@@ -1,10 +1,71 @@
|
|||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
import { promises as fs } from 'node:fs';
|
import { promises as fs } from 'node:fs';
|
||||||
import { execSync } from 'node:child_process';
|
import { execFileSync, execSync } from 'node:child_process';
|
||||||
import { type IUpsConfig, NupstDaemon } from './daemon.ts';
|
import { type IUpsConfig, NupstDaemon } from './daemon.ts';
|
||||||
import { NupstSnmp } from './snmp/manager.ts';
|
import { NupstSnmp } from './snmp/manager.ts';
|
||||||
import { logger } from './logger.ts';
|
import { logger } from './logger.ts';
|
||||||
import { formatPowerStatus, getBatteryColor, getRuntimeColor, symbols, theme } from './colors.ts';
|
import { formatPowerStatus, getBatteryColor, getRuntimeColor, symbols, theme } from './colors.ts';
|
||||||
|
import { SHUTDOWN } from './constants.ts';
|
||||||
|
|
||||||
|
interface IServiceStatusSnapshot {
|
||||||
|
loadState: string;
|
||||||
|
activeState: string;
|
||||||
|
subState: string;
|
||||||
|
pid: string;
|
||||||
|
memory: string;
|
||||||
|
cpu: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSystemdMemory(memoryBytes: string): string {
|
||||||
|
const bytes = Number(memoryBytes);
|
||||||
|
if (!Number.isFinite(bytes) || bytes <= 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const units = ['B', 'K', 'M', 'G', 'T', 'P'];
|
||||||
|
let value = bytes;
|
||||||
|
let unitIndex = 0;
|
||||||
|
|
||||||
|
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||||
|
value /= 1024;
|
||||||
|
unitIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unitIndex === 0) {
|
||||||
|
return `${Math.round(value)}B`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${value.toFixed(1).replace(/\.0$/, '')}${units[unitIndex]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSystemdCpu(cpuNanoseconds: string): string {
|
||||||
|
const nanoseconds = Number(cpuNanoseconds);
|
||||||
|
if (!Number.isFinite(nanoseconds) || nanoseconds <= 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const milliseconds = nanoseconds / 1_000_000;
|
||||||
|
if (milliseconds < 1000) {
|
||||||
|
return `${Math.round(milliseconds)}ms`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const seconds = milliseconds / 1000;
|
||||||
|
if (seconds < 60) {
|
||||||
|
return `${seconds.toFixed(seconds >= 10 ? 1 : 3).replace(/\.?0+$/, '')}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const remainingSeconds = seconds % 60;
|
||||||
|
if (minutes < 60) {
|
||||||
|
return `${minutes}min ${
|
||||||
|
remainingSeconds.toFixed(remainingSeconds >= 10 ? 1 : 3).replace(/\.?0+$/, '')
|
||||||
|
}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const remainingMinutes = minutes % 60;
|
||||||
|
return `${hours}h ${remainingMinutes}min`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Class for managing systemd service
|
* Class for managing systemd service
|
||||||
@@ -223,51 +284,69 @@ WantedBy=multi-user.target
|
|||||||
* Display the systemd service status
|
* Display the systemd service status
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
|
private getServiceStatusSnapshot(): IServiceStatusSnapshot {
|
||||||
|
const output = execFileSync(
|
||||||
|
'systemctl',
|
||||||
|
[
|
||||||
|
'show',
|
||||||
|
'nupst.service',
|
||||||
|
'--property=LoadState,ActiveState,SubState,MainPID,MemoryCurrent,CPUUsageNSec',
|
||||||
|
],
|
||||||
|
{ encoding: 'utf8' },
|
||||||
|
);
|
||||||
|
|
||||||
|
const properties = new Map<string, string>();
|
||||||
|
for (const line of output.split('\n')) {
|
||||||
|
const separatorIndex = line.indexOf('=');
|
||||||
|
if (separatorIndex === -1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
properties.set(line.slice(0, separatorIndex), line.slice(separatorIndex + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
const pid = properties.get('MainPID') || '';
|
||||||
|
return {
|
||||||
|
loadState: properties.get('LoadState') || '',
|
||||||
|
activeState: properties.get('ActiveState') || '',
|
||||||
|
subState: properties.get('SubState') || '',
|
||||||
|
pid: pid !== '0' ? pid : '',
|
||||||
|
memory: formatSystemdMemory(properties.get('MemoryCurrent') || ''),
|
||||||
|
cpu: formatSystemdCpu(properties.get('CPUUsageNSec') || ''),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private displayServiceStatus(): void {
|
private displayServiceStatus(): void {
|
||||||
try {
|
try {
|
||||||
const serviceStatus = execSync('systemctl status nupst.service').toString();
|
const snapshot = this.getServiceStatusSnapshot();
|
||||||
const lines = serviceStatus.split('\n');
|
|
||||||
|
|
||||||
// Parse key information from systemctl output
|
|
||||||
let isActive = false;
|
|
||||||
let pid = '';
|
|
||||||
let memory = '';
|
|
||||||
let cpu = '';
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
if (line.includes('Active:')) {
|
|
||||||
isActive = line.includes('active (running)');
|
|
||||||
} else if (line.includes('Main PID:')) {
|
|
||||||
const match = line.match(/Main PID:\s+(\d+)/);
|
|
||||||
if (match) pid = match[1];
|
|
||||||
} else if (line.includes('Memory:')) {
|
|
||||||
const match = line.match(/Memory:\s+([\d.]+[A-Z])/);
|
|
||||||
if (match) memory = match[1];
|
|
||||||
} else if (line.includes('CPU:')) {
|
|
||||||
const match = line.match(/CPU:\s+([\d.]+(?:ms|s))/);
|
|
||||||
if (match) cpu = match[1];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Display beautiful status
|
// Display beautiful status
|
||||||
logger.log('');
|
logger.log('');
|
||||||
if (isActive) {
|
if (snapshot.loadState === 'not-found') {
|
||||||
logger.log(
|
logger.log(
|
||||||
`${symbols.running} ${theme.success('Service:')} ${
|
`${symbols.stopped} ${theme.dim('Service:')} ${theme.statusInactive('not installed')}`,
|
||||||
theme.statusActive('active (running)')
|
);
|
||||||
}`,
|
} else if (snapshot.activeState === 'active') {
|
||||||
|
const serviceState = snapshot.subState
|
||||||
|
? `${snapshot.activeState} (${snapshot.subState})`
|
||||||
|
: snapshot.activeState;
|
||||||
|
logger.log(
|
||||||
|
`${symbols.running} ${theme.success('Service:')} ${theme.statusActive(serviceState)}`,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
const serviceState = snapshot.subState && snapshot.subState !== snapshot.activeState
|
||||||
|
? `${snapshot.activeState} (${snapshot.subState})`
|
||||||
|
: snapshot.activeState || 'inactive';
|
||||||
logger.log(
|
logger.log(
|
||||||
`${symbols.stopped} ${theme.dim('Service:')} ${theme.statusInactive('inactive')}`,
|
`${symbols.stopped} ${theme.dim('Service:')} ${theme.statusInactive(serviceState)}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pid || memory || cpu) {
|
if (snapshot.pid || snapshot.memory || snapshot.cpu) {
|
||||||
const details = [];
|
const details = [];
|
||||||
if (pid) details.push(`PID: ${theme.dim(pid)}`);
|
if (snapshot.pid) details.push(`PID: ${theme.dim(snapshot.pid)}`);
|
||||||
if (memory) details.push(`Memory: ${theme.dim(memory)}`);
|
if (snapshot.memory) details.push(`Memory: ${theme.dim(snapshot.memory)}`);
|
||||||
if (cpu) details.push(`CPU: ${theme.dim(cpu)}`);
|
if (snapshot.cpu) details.push(`CPU: ${theme.dim(snapshot.cpu)}`);
|
||||||
logger.log(` ${details.join(' ')}`);
|
logger.log(` ${details.join(' ')}`);
|
||||||
}
|
}
|
||||||
logger.log('');
|
logger.log('');
|
||||||
@@ -316,7 +395,6 @@ WantedBy=multi-user.target
|
|||||||
type: 'shutdown',
|
type: 'shutdown',
|
||||||
thresholds: config.thresholds,
|
thresholds: config.thresholds,
|
||||||
triggerMode: 'onlyThresholds',
|
triggerMode: 'onlyThresholds',
|
||||||
shutdownDelay: 5,
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: [],
|
: [],
|
||||||
@@ -346,6 +424,8 @@ WantedBy=multi-user.target
|
|||||||
*/
|
*/
|
||||||
private async displaySingleUpsStatus(ups: IUpsConfig, snmp: NupstSnmp): Promise<void> {
|
private async displaySingleUpsStatus(ups: IUpsConfig, snmp: NupstSnmp): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
const defaultShutdownDelay = this.daemon.getConfig().defaultShutdownDelay ??
|
||||||
|
SHUTDOWN.DEFAULT_DELAY_MINUTES;
|
||||||
const protocol = ups.protocol || 'snmp';
|
const protocol = ups.protocol || 'snmp';
|
||||||
let status;
|
let status;
|
||||||
|
|
||||||
@@ -432,14 +512,20 @@ WantedBy=multi-user.target
|
|||||||
actionDesc += ` (${
|
actionDesc += ` (${
|
||||||
action.triggerMode || 'onlyThresholds'
|
action.triggerMode || 'onlyThresholds'
|
||||||
}: battery<${action.thresholds.battery}%, runtime<${action.thresholds.runtime}min`;
|
}: battery<${action.thresholds.battery}%, runtime<${action.thresholds.runtime}min`;
|
||||||
if (action.shutdownDelay) {
|
if (action.type === 'shutdown') {
|
||||||
actionDesc += `, delay=${action.shutdownDelay}s`;
|
const shutdownDelay = action.shutdownDelay ?? defaultShutdownDelay;
|
||||||
|
actionDesc += `, delay=${shutdownDelay}min`;
|
||||||
|
} else if (action.type === 'proxmox' && action.proxmoxHaPolicy === 'haStop') {
|
||||||
|
actionDesc += ', ha=stop';
|
||||||
}
|
}
|
||||||
actionDesc += ')';
|
actionDesc += ')';
|
||||||
} else {
|
} else {
|
||||||
actionDesc += ` (${action.triggerMode || 'onlyPowerChanges'}`;
|
actionDesc += ` (${action.triggerMode || 'onlyPowerChanges'}`;
|
||||||
if (action.shutdownDelay) {
|
if (action.type === 'shutdown') {
|
||||||
actionDesc += `, delay=${action.shutdownDelay}s`;
|
const shutdownDelay = action.shutdownDelay ?? defaultShutdownDelay;
|
||||||
|
actionDesc += `, delay=${shutdownDelay}min`;
|
||||||
|
} else if (action.type === 'proxmox' && action.proxmoxHaPolicy === 'haStop') {
|
||||||
|
actionDesc += ', ha=stop';
|
||||||
}
|
}
|
||||||
actionDesc += ')';
|
actionDesc += ')';
|
||||||
}
|
}
|
||||||
@@ -506,20 +592,27 @@ WantedBy=multi-user.target
|
|||||||
|
|
||||||
// Display actions if any
|
// Display actions if any
|
||||||
if (group.actions && group.actions.length > 0) {
|
if (group.actions && group.actions.length > 0) {
|
||||||
|
const defaultShutdownDelay = config.defaultShutdownDelay ?? SHUTDOWN.DEFAULT_DELAY_MINUTES;
|
||||||
for (const action of group.actions) {
|
for (const action of group.actions) {
|
||||||
let actionDesc = `${action.type}`;
|
let actionDesc = `${action.type}`;
|
||||||
if (action.thresholds) {
|
if (action.thresholds) {
|
||||||
actionDesc += ` (${
|
actionDesc += ` (${
|
||||||
action.triggerMode || 'onlyThresholds'
|
action.triggerMode || 'onlyThresholds'
|
||||||
}: battery<${action.thresholds.battery}%, runtime<${action.thresholds.runtime}min`;
|
}: battery<${action.thresholds.battery}%, runtime<${action.thresholds.runtime}min`;
|
||||||
if (action.shutdownDelay) {
|
if (action.type === 'shutdown') {
|
||||||
actionDesc += `, delay=${action.shutdownDelay}s`;
|
const shutdownDelay = action.shutdownDelay ?? defaultShutdownDelay;
|
||||||
|
actionDesc += `, delay=${shutdownDelay}min`;
|
||||||
|
} else if (action.type === 'proxmox' && action.proxmoxHaPolicy === 'haStop') {
|
||||||
|
actionDesc += ', ha=stop';
|
||||||
}
|
}
|
||||||
actionDesc += ')';
|
actionDesc += ')';
|
||||||
} else {
|
} else {
|
||||||
actionDesc += ` (${action.triggerMode || 'onlyPowerChanges'}`;
|
actionDesc += ` (${action.triggerMode || 'onlyPowerChanges'}`;
|
||||||
if (action.shutdownDelay) {
|
if (action.type === 'shutdown') {
|
||||||
actionDesc += `, delay=${action.shutdownDelay}s`;
|
const shutdownDelay = action.shutdownDelay ?? defaultShutdownDelay;
|
||||||
|
actionDesc += `, delay=${shutdownDelay}min`;
|
||||||
|
} else if (action.type === 'proxmox' && action.proxmoxHaPolicy === 'haStop') {
|
||||||
|
actionDesc += ', ha=stop';
|
||||||
}
|
}
|
||||||
actionDesc += ')';
|
actionDesc += ')';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import type { IActionConfig } from './actions/base-action.ts';
|
||||||
|
import { NETWORK } from './constants.ts';
|
||||||
|
import type { IUpsStatus as IProtocolUpsStatus } from './snmp/types.ts';
|
||||||
|
import { createInitialUpsStatus, type IUpsIdentity, type IUpsStatus } from './ups-status.ts';
|
||||||
|
|
||||||
|
export interface ISuccessfulUpsPollSnapshot {
|
||||||
|
updatedStatus: IUpsStatus;
|
||||||
|
transition: 'none' | 'recovered' | 'powerStatusChange';
|
||||||
|
previousStatus?: IUpsStatus;
|
||||||
|
downtimeSeconds?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IFailedUpsPollSnapshot {
|
||||||
|
updatedStatus: IUpsStatus;
|
||||||
|
transition: 'none' | 'unreachable';
|
||||||
|
failures: number;
|
||||||
|
previousStatus?: IUpsStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureUpsStatus(
|
||||||
|
currentStatus: IUpsStatus | undefined,
|
||||||
|
ups: IUpsIdentity,
|
||||||
|
now: number = Date.now(),
|
||||||
|
): IUpsStatus {
|
||||||
|
return currentStatus || createInitialUpsStatus(ups, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSuccessfulUpsPollSnapshot(
|
||||||
|
ups: IUpsIdentity,
|
||||||
|
polledStatus: IProtocolUpsStatus,
|
||||||
|
currentStatus: IUpsStatus | undefined,
|
||||||
|
currentTime: number,
|
||||||
|
): ISuccessfulUpsPollSnapshot {
|
||||||
|
const previousStatus = ensureUpsStatus(currentStatus, ups, currentTime);
|
||||||
|
const updatedStatus: IUpsStatus = {
|
||||||
|
id: ups.id,
|
||||||
|
name: ups.name,
|
||||||
|
powerStatus: polledStatus.powerStatus,
|
||||||
|
batteryCapacity: polledStatus.batteryCapacity,
|
||||||
|
batteryRuntime: polledStatus.batteryRuntime,
|
||||||
|
outputLoad: polledStatus.outputLoad,
|
||||||
|
outputPower: polledStatus.outputPower,
|
||||||
|
outputVoltage: polledStatus.outputVoltage,
|
||||||
|
outputCurrent: polledStatus.outputCurrent,
|
||||||
|
lastCheckTime: currentTime,
|
||||||
|
lastStatusChange: previousStatus.lastStatusChange || currentTime,
|
||||||
|
consecutiveFailures: 0,
|
||||||
|
unreachableSince: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (previousStatus.powerStatus === 'unreachable') {
|
||||||
|
updatedStatus.lastStatusChange = currentTime;
|
||||||
|
return {
|
||||||
|
updatedStatus,
|
||||||
|
transition: 'recovered',
|
||||||
|
previousStatus,
|
||||||
|
downtimeSeconds: Math.round((currentTime - previousStatus.unreachableSince) / 1000),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previousStatus.powerStatus !== polledStatus.powerStatus) {
|
||||||
|
updatedStatus.lastStatusChange = currentTime;
|
||||||
|
return {
|
||||||
|
updatedStatus,
|
||||||
|
transition: 'powerStatusChange',
|
||||||
|
previousStatus,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
updatedStatus,
|
||||||
|
transition: 'none',
|
||||||
|
previousStatus: currentStatus,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildFailedUpsPollSnapshot(
|
||||||
|
ups: IUpsIdentity,
|
||||||
|
currentStatus: IUpsStatus | undefined,
|
||||||
|
currentTime: number,
|
||||||
|
): IFailedUpsPollSnapshot {
|
||||||
|
const previousStatus = ensureUpsStatus(currentStatus, ups, currentTime);
|
||||||
|
const failures = Math.min(
|
||||||
|
previousStatus.consecutiveFailures + 1,
|
||||||
|
NETWORK.MAX_CONSECUTIVE_FAILURES,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
failures >= NETWORK.CONSECUTIVE_FAILURE_THRESHOLD &&
|
||||||
|
previousStatus.powerStatus !== 'unreachable'
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
updatedStatus: {
|
||||||
|
...previousStatus,
|
||||||
|
consecutiveFailures: failures,
|
||||||
|
powerStatus: 'unreachable',
|
||||||
|
unreachableSince: currentTime,
|
||||||
|
lastStatusChange: currentTime,
|
||||||
|
},
|
||||||
|
transition: 'unreachable',
|
||||||
|
failures,
|
||||||
|
previousStatus,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
updatedStatus: {
|
||||||
|
...previousStatus,
|
||||||
|
consecutiveFailures: failures,
|
||||||
|
},
|
||||||
|
transition: 'none',
|
||||||
|
failures,
|
||||||
|
previousStatus: currentStatus,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasThresholdViolation(
|
||||||
|
powerStatus: IProtocolUpsStatus['powerStatus'],
|
||||||
|
batteryCapacity: number,
|
||||||
|
batteryRuntime: number,
|
||||||
|
actions: IActionConfig[] | undefined,
|
||||||
|
): boolean {
|
||||||
|
return getActionThresholdStates(powerStatus, batteryCapacity, batteryRuntime, actions).some(
|
||||||
|
Boolean,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isActionThresholdExceeded(
|
||||||
|
actionConfig: IActionConfig,
|
||||||
|
powerStatus: IProtocolUpsStatus['powerStatus'],
|
||||||
|
batteryCapacity: number,
|
||||||
|
batteryRuntime: number,
|
||||||
|
): boolean {
|
||||||
|
if (powerStatus !== 'onBattery' || !actionConfig.thresholds) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
batteryCapacity < actionConfig.thresholds.battery ||
|
||||||
|
batteryRuntime < actionConfig.thresholds.runtime
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getActionThresholdStates(
|
||||||
|
powerStatus: IProtocolUpsStatus['powerStatus'],
|
||||||
|
batteryCapacity: number,
|
||||||
|
batteryRuntime: number,
|
||||||
|
actions: IActionConfig[] | undefined,
|
||||||
|
): boolean[] {
|
||||||
|
if (!actions || actions.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return actions.map((actionConfig) =>
|
||||||
|
isActionThresholdExceeded(actionConfig, powerStatus, batteryCapacity, batteryRuntime)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEnteredThresholdIndexes(
|
||||||
|
previousStates: boolean[] | undefined,
|
||||||
|
currentStates: boolean[],
|
||||||
|
): number[] {
|
||||||
|
const enteredIndexes: number[] = [];
|
||||||
|
|
||||||
|
for (let index = 0; index < currentStates.length; index++) {
|
||||||
|
if (currentStates[index] && !previousStates?.[index]) {
|
||||||
|
enteredIndexes.push(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return enteredIndexes;
|
||||||
|
}
|
||||||
@@ -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