Compare commits

...

10 Commits

Author SHA1 Message Date
7de521078e v5.3.0
Some checks failed
CI / Type Check & Lint (push) Successful in 9s
CI / Build Test (Current Platform) (push) Successful in 10s
Publish to npm / npm-publish (push) Failing after 24s
Release / build-and-release (push) Successful in 58s
CI / Build All Platforms (push) Successful in 1m2s
2026-02-20 11:51:59 +00:00
42b8eaf6d2 feat(daemon): Add UPSD (NUT) protocol support, Proxmox VM shutdown action, pause/resume monitoring, and network-loss/unreachable handling; bump config version to 4.2 2026-02-20 11:51:59 +00:00
782c8c9555 v5.2.4
Some checks failed
CI / Type Check & Lint (push) Successful in 6s
CI / Build Test (Current Platform) (push) Successful in 5s
Publish to npm / npm-publish (push) Failing after 18s
Release / build-and-release (push) Successful in 53s
CI / Build All Platforms (push) Successful in 57s
2026-01-29 17:53:08 +00:00
463c32ebba fix(): no changes 2026-01-29 17:53:08 +00:00
51aa68ff8d v5.2.3
Some checks failed
CI / Type Check & Lint (push) Successful in 5s
CI / Build Test (Current Platform) (push) Successful in 8s
Publish to npm / npm-publish (push) Failing after 19s
CI / Build All Platforms (push) Successful in 1m0s
Release / build-and-release (push) Successful in 58s
2026-01-29 17:46:23 +00:00
cb34ae5041 fix(core): fix lint/type issues and small refactors 2026-01-29 17:46:23 +00:00
165c7d29bb v5.2.2
Some checks failed
CI / Type Check & Lint (push) Successful in 5s
CI / Build Test (Current Platform) (push) Successful in 5s
Publish to npm / npm-publish (push) Failing after 19s
Release / build-and-release (push) Successful in 54s
CI / Build All Platforms (push) Successful in 59s
2026-01-29 17:10:17 +00:00
ff2dc00f31 fix(core): tidy formatting and minor fixes across CLI, SNMP, HTTP server, migrations and packaging 2026-01-29 17:10:17 +00:00
fda072d15e v5.2.1
Some checks failed
CI / Type Check & Lint (push) Successful in 5s
CI / Build Test (Current Platform) (push) Successful in 5s
Publish to npm / npm-publish (push) Failing after 22s
Release / build-and-release (push) Successful in 55s
CI / Build All Platforms (push) Successful in 1m2s
2026-01-29 17:07:57 +00:00
c7786e9626 fix(cli(ups-handler), systemd): add type guards and null checks for UPS configs; improve SNMP handling and prompts; guard version display 2026-01-29 17:07:57 +00:00
47 changed files with 2906 additions and 1027 deletions

View File

@@ -5,19 +5,23 @@ Pre-compiled binaries for multiple platforms.
### Installation
#### Option 1: Via npm (recommended)
```bash
npm install -g @serve.zone/nupst
```
#### Option 2: Via installer script
```bash
curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash
```
#### Option 3: Direct binary download
Download the appropriate binary for your platform from the assets below and make it executable.
### Supported Platforms
- Linux x86_64 (x64)
- Linux ARM64 (aarch64)
- macOS x86_64 (Intel)
@@ -25,7 +29,9 @@ Download the appropriate binary for your platform from the assets below and make
- Windows x86_64
### Checksums
SHA256 checksums are provided in `SHA256SUMS.txt` for binary verification.
### npm Package
The npm package includes automatic binary detection and installation for your platform.

View File

@@ -9,7 +9,8 @@ import { spawn } from 'child_process';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import { existsSync } from 'fs';
import { platform, arch } from 'os';
import { arch, platform } from 'os';
import process from "node:process";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
@@ -25,12 +26,12 @@ function getBinaryName() {
const platformMap = {
'darwin': 'macos',
'linux': 'linux',
'win32': 'windows'
'win32': 'windows',
};
const archMap = {
'x64': 'x64',
'arm64': 'arm64'
'arm64': 'arm64',
};
const mappedPlatform = platformMap[plat];
@@ -76,7 +77,7 @@ function executeBinary() {
// Spawn the binary with all arguments passed through
const child = spawn(binaryPath, process.argv.slice(2), {
stdio: 'inherit',
shell: false
shell: false,
});
// Handle child process events
@@ -95,7 +96,7 @@ function executeBinary() {
// Forward signals to child process
const signals = ['SIGINT', 'SIGTERM', 'SIGHUP'];
signals.forEach(signal => {
signals.forEach((signal) => {
process.on(signal, () => {
if (!child.killed) {
child.kill(signal);

View File

@@ -1,59 +1,143 @@
# Changelog
## 2026-01-29 - 5.2.0 - feat(core)
Centralize timeouts/constants, add CLI prompt helpers, and introduce webhook/script actions with safety and SNMP refactors
## 2026-02-20 - 5.3.0 - feat(daemon)
Add UPSD (NUT) protocol support, Proxmox VM shutdown action, pause/resume monitoring, and network-loss/unreachable handling; bump config version to 4.2
- Add ts/constants.ts to centralize timing, SNMP, webhook, script, shutdown and UI constants and replace magic numbers across the codebase
- Introduce helpers/prompt.ts with createPrompt() and withPrompt() and refactor CLI handlers to use these helpers (cleaner prompt lifecycle)
- Add webhook action support: ts/actions/webhook-action.ts, IWebhookPayload type, and export from ts/actions/index.ts
- Enhance ShutdownAction safety checks (only trigger onBattery, stricter transition rules) and use constants/UI widths for displays
- Refactor SNMP manager to use logger instead of console, pull SNMP defaults from constants, improved debug output, and add INupstAccessor interface to break circular dependency (Nupst now implements the interface)
- Update many CLI and core types (stronger typing for configs/actions), expand tests and update README and npmextra.json to document new features
- Add UPSD client (ts/upsd) and ProtocolResolver (ts/protocol) to support protocol-agnostic UPS queries (snmp or upsd).
- Introduce new TProtocol and IUpsdConfig types, wire up Nupst to initialize & expose UPSD client, and route status requests through ProtocolResolver.
- Add 'unreachable' TPowerStatus plus consecutiveFailures and unreachableSince tracking; mark UPS as unreachable after NETWORK.CONSECUTIVE_FAILURE_THRESHOLD failures and suppress shutdown actions while unreachable.
- Implement pause/resume feature: PAUSE.FILE_PATH state file, CLI commands (pause/resume), daemon pause-state polling, auto-resume, and include pause state in HTTP API responses.
- Add ProxmoxAction (ts/actions/proxmox-action.ts) with Proxmox API interaction, configuration options (token, node, timeout, force, insecure) and CLI prompts to configure proxmox actions.
- CLI and UI updates: protocol selection when adding UPS, protocol/host shown in lists, action details column supports proxmox, and status displays include protocol and unreachable state.
- Add migration MigrationV4_1ToV4_2 to set protocol:'snmp' for existing devices and bump config.version to '4.2'.
- Add new constants (NETWORK, UPSD, PAUSE, PROXMOX), update package.json scripts (test/build/lint/format), and wire protocol support across daemon, systemd, http-server, and various handlers.
## 2026-01-29 - 5.2.4 - fix()
no changes
- No files changed in the provided git diff; no commit or version bump required.
## 2026-01-29 - 5.2.3 - fix(core)
fix lint/type issues and small refactors
- Add missing node:process imports in bin and scripts to ensure process is available
- Remove unused imports and unused type imports (e.g. writeFileSync, IActionConfig) to reduce noise
- Make some methods synchronous (service update, webhook call) to match actual usage
- Tighten SNMP typings and linting: added deno-lint-ignore comments, renamed unused params with leading underscore, and use `as const` for securityLevel fallbacks
- Improve error handling variable naming in systemd (use error instead of _error)
- Annotate ANSI regex with deno-lint-ignore no-control-regex and remove unused color/symbol imports across CLI/daemon/logger
## 2026-01-29 - 5.2.2 - fix(core)
tidy formatting and minor fixes across CLI, SNMP, HTTP server, migrations and packaging
- Normalize import ordering and improve logger/string formatting across many CLI handlers, daemon, systemd, actions and tests
- Apply formatting tidies: trailing commas, newline fixes, and more consistent multiline strings
- Allow BaseMigration methods to return either sync or async results (shouldRun/migrate signatures updated)
- Improve SNMP manager and HTTP server logging/error messages and tighten some typings (raw SNMP types, server error typing)
- Small robustness and messaging improvements in npm installer and wrapper (platform/arch mapping, error outputs)
- Update tests and documentation layout/formatting for readability
## 2026-01-29 - 5.2.1 - fix(cli(ups-handler), systemd)
add type guards and null checks for UPS configs; improve SNMP handling and prompts; guard version
display
- Introduce a type guard ('id' in config && 'name' in config) to distinguish IUpsConfig from legacy
INupstConfig and route fields (snmp, checkInterval, name, id) accordingly.
- displayTestConfig now handles missing SNMP by logging 'Not configured' and returning, computes
checkInterval/upsName/upsId correctly, and uses groups only for true UPS configs.
- testConnection now safely derives snmpConfig for both config types, throws if SNMP is missing, and
caps test timeout to 10s for probes.
- Clear auth/priv credentials by setting undefined (instead of empty strings) when disabling
security levels to avoid invalid/empty string values.
- Expanded customOIDs to include OUTPUT_LOAD, OUTPUT_POWER, OUTPUT_VOLTAGE, OUTPUT_CURRENT with
defaults; trim prompt input and document RFC 1628 fallbacks.
- systemd.displayVersionInfo: guard against missing nupst (silent return) and avoid errors when
printing version info; use ignored catch variables for clarity.
## 2026-01-29 - 5.2.0 - feat(core)
Centralize timeouts/constants, add CLI prompt helpers, and introduce webhook/script actions with
safety and SNMP refactors
- Add ts/constants.ts to centralize timing, SNMP, webhook, script, shutdown and UI constants and
replace magic numbers across the codebase
- Introduce helpers/prompt.ts with createPrompt() and withPrompt() and refactor CLI handlers to use
these helpers (cleaner prompt lifecycle)
- Add webhook action support: ts/actions/webhook-action.ts, IWebhookPayload type, and export from
ts/actions/index.ts
- Enhance ShutdownAction safety checks (only trigger onBattery, stricter transition rules) and use
constants/UI widths for displays
- Refactor SNMP manager to use logger instead of console, pull SNMP defaults from constants,
improved debug output, and add INupstAccessor interface to break circular dependency (Nupst now
implements the interface)
- Update many CLI and core types (stronger typing for configs/actions), expand tests and update
README and npmextra.json to document new features
## 2025-11-09 - 5.1.11 - fix(readme)
Update README installation instructions to recommend automated installer script and clarify npm installation
- Replace the previous 'Via npm (NEW! - Recommended)' section with a clear 'Automated Installer Script (Recommended)' section and example curl installer.
- Move npm installation instructions into an 'Alternative: Via npm' subsection and clarify that the npm package downloads the appropriate pre-compiled binary for the platform during installation.
Update README installation instructions to recommend automated installer script and clarify npm
installation
- Replace the previous 'Via npm (NEW! - Recommended)' section with a clear 'Automated Installer
Script (Recommended)' section and example curl installer.
- Move npm installation instructions into an 'Alternative: Via npm' subsection and clarify that the
npm package downloads the appropriate pre-compiled binary for the platform during installation.
- Remove the 'NEW!' badge and streamline notes about binary downloads and installation methods.
## 2025-10-23 - 5.1.10 - fix(config)
Synchronize deno.json version with package.json, tidy formatting, and add local tooling settings
- Bumped deno.json version to 5.1.9 to match package.json/commitinfo
- Reformatted deno.json arrays (lint, fmt, compilerOptions) for readability
- Added .claude/settings.local.json for local development/tooling permissions (no runtime behaviour changes)
- Added .claude/settings.local.json for local development/tooling permissions (no runtime behaviour
changes)
## 2025-10-23 - 5.1.9 - fix(dev)
Add local assistant permissions/settings file (.claude/settings.local.json)
- Added .claude/settings.local.json containing local assistant permission configuration used for development tasks (deno check, deno lint/format, npm/pack, running packaged binaries, etc.)
- This is a development/local configuration file and does not change runtime behavior or product code paths
- Added .claude/settings.local.json containing local assistant permission configuration used for
development tasks (deno check, deno lint/format, npm/pack, running packaged binaries, etc.)
- This is a development/local configuration file and does not change runtime behavior or product
code paths
- Patch version bump recommended
## 2025-10-23 - 5.1.2 - fix(scripts)
Add build script to package.json and include local dev tool settings
- Add a 'build' script to package.json (no-op placeholder) to provide an explicit build step
- Minor scripts section formatting tidy in package.json
- Add a hidden local settings file for development tooling permissions to the repository (local-only configuration)
- Add a hidden local settings file for development tooling permissions to the repository (local-only
configuration)
## 2025-10-23 - 5.1.1 - fix(tooling)
Add .claude/settings.local.json with local automation permissions
- Add .claude/settings.local.json to specify allowed permissions for local automated tasks
- Grants permissions for various developer/CI actions (deno check/lint/fmt, npm/npm pack, selective Bash commands, WebFetch to docs.deno.com and code.foss.global, and file/read/replace helpers)
- Grants permissions for various developer/CI actions (deno check/lint/fmt, npm/npm pack, selective
Bash commands, WebFetch to docs.deno.com and code.foss.global, and file/read/replace helpers)
- This is a developer/local tooling config only and does not change runtime code or package behavior
## 2025-10-22 - 5.1.0 - feat(packaging)
Add npm packaging and installer: wrapper, postinstall downloader, publish workflow, and packaging files
Add npm packaging and installer: wrapper, postinstall downloader, publish workflow, and packaging
files
- Add package.json (v5.0.5) and npm packaging metadata to publish @serve.zone/nupst
- Include a small Node.js wrapper (bin/nupst-wrapper.js) to execute platform-specific precompiled binaries
- Add postinstall script (scripts/install-binary.js) that downloads the correct binary for the current platform and sets executable permissions
- Add GitHub Actions workflow (.github/workflows/npm-publish.yml) to build binaries, pack and publish to npm, and create releases
- Add .npmignore to keep source, tests and dev files out of npm package; keep only runtime installer and wrapper
- Move example action script into docs (docs/example-action.sh) and remove the top-level example-action.sh
- Include a small Node.js wrapper (bin/nupst-wrapper.js) to execute platform-specific precompiled
binaries
- Add postinstall script (scripts/install-binary.js) that downloads the correct binary for the
current platform and sets executable permissions
- Add GitHub Actions workflow (.github/workflows/npm-publish.yml) to build binaries, pack and
publish to npm, and create releases
- Add .npmignore to keep source, tests and dev files out of npm package; keep only runtime installer
and wrapper
- Move example action script into docs (docs/example-action.sh) and remove the top-level
example-action.sh
- Include generated npm package artifact (serve.zone-nupst-5.0.5.tgz) and npmextra.json
## 2025-10-18 - 4.0.0 - BREAKING CHANGE(core): Complete migration to Deno runtime

View File

@@ -1,6 +1,6 @@
{
"name": "@serve.zone/nupst",
"version": "5.2.0",
"version": "5.3.0",
"exports": "./mod.ts",
"nodeModulesDir": "auto",
"tasks": {

View File

@@ -1,6 +1,6 @@
{
"name": "@serve.zone/nupst",
"version": "5.2.0",
"version": "5.3.0",
"description": "Network UPS Shutdown Tool - Monitor SNMP-enabled UPS devices and orchestrate graceful system shutdowns during power emergencies",
"keywords": [
"ups",
@@ -34,8 +34,10 @@
"scripts": {
"postinstall": "node scripts/install-binary.js",
"prepublishOnly": "echo 'Publishing NUPST binaries to npm...'",
"test": "echo 'Tests are run with Deno: deno task test'",
"build": "echo 'no build needed'"
"test": "deno task test",
"build": "deno task check",
"lint": "deno task lint",
"format": "deno task fmt"
},
"files": [
"bin/",

View File

@@ -7,12 +7,14 @@
1. **Prompt Utility (`ts/helpers/prompt.ts`)**
- Extracted readline/prompt pattern from all CLI handlers
- Provides `createPrompt()` and `withPrompt()` helper functions
- Used in: `ups-handler.ts`, `group-handler.ts`, `service-handler.ts`, `action-handler.ts`, `feature-handler.ts`
- Used in: `ups-handler.ts`, `group-handler.ts`, `service-handler.ts`, `action-handler.ts`,
`feature-handler.ts`
2. **Constants File (`ts/constants.ts`)**
- Centralized all magic numbers (timeouts, intervals, thresholds)
- Contains: `TIMING`, `SNMP`, `THRESHOLDS`, `WEBHOOK`, `SCRIPT`, `SHUTDOWN`, `HTTP_SERVER`, `UI`
- Used in: `daemon.ts`, `snmp/manager.ts`, `actions/*.ts`
- Contains: `TIMING`, `SNMP`, `THRESHOLDS`, `WEBHOOK`, `SCRIPT`, `SHUTDOWN`, `HTTP_SERVER`, `UI`,
`NETWORK`, `UPSD`, `PAUSE`, `PROXMOX`
- Used in: `daemon.ts`, `snmp/manager.ts`, `actions/*.ts`, `upsd/client.ts`
3. **Logger Consistency**
- Replaced all `console.log/console.error` in `snmp/manager.ts` with proper `logger.*` calls
@@ -21,7 +23,8 @@
### Phase 2 - Type Safety
4. **Circular Dependency Fix (`ts/interfaces/nupst-accessor.ts`)**
- Created `INupstAccessor` interface to break the circular dependency between `Nupst` and `NupstSnmp`
- Created `INupstAccessor` interface to break the circular dependency between `Nupst` and
`NupstSnmp`
- `NupstSnmp.nupst` property now uses the interface instead of `any`
5. **Webhook Payload Interface (`ts/actions/webhook-action.ts`)**
@@ -30,14 +33,50 @@
6. **CLI Handler Type Safety**
- Replaced `any` types in `ups-handler.ts` and `group-handler.ts` with proper interfaces
- Uses: `IUpsConfig`, `INupstConfig`, `ISnmpConfig`, `IActionConfig`, `IThresholds`, `ISnmpUpsStatus`
- Uses: `IUpsConfig`, `INupstConfig`, `ISnmpConfig`, `IActionConfig`, `IThresholds`,
`ISnmpUpsStatus`
## Features Added (February 2026)
### Network Loss Handling
- `TPowerStatus` extended with `'unreachable'` state
- `IUpsStatus` has `consecutiveFailures` and `unreachableSince` tracking
- After `NETWORK.CONSECUTIVE_FAILURE_THRESHOLD` (3) failures, UPS transitions to `unreachable`
- Shutdown action explicitly won't fire on `unreachable` (prevents false shutdowns)
- Recovery is logged when UPS comes back from unreachable
### UPSD/NIS Protocol Support
- New `ts/upsd/` directory with TCP client for NUT (Network UPS Tools) servers
- `ts/protocol/` directory with `ProtocolResolver` for protocol-agnostic status queries
- `IUpsConfig.protocol` field: `'snmp'` (default) or `'upsd'`
- `IUpsConfig.snmp` is now optional (not needed for UPSD devices)
- CLI supports protocol selection during `nupst ups add`
- Config version bumped to `4.2` with migration from `4.1`
### Pause/Resume Command
- File-based signaling via `/etc/nupst/pause` JSON file
- `nupst pause [--duration 30m|2h|1d]` creates pause file
- `nupst resume` deletes pause file
- Daemon polls continue but actions are suppressed while paused
- Auto-resume after duration expires
- HTTP API includes pause state in response
### Proxmox VM Shutdown Action
- New action type `'proxmox'` in `ts/actions/proxmox-action.ts`
- Uses Proxmox REST API with PVEAPIToken authentication
- Shuts down QEMU VMs and LXC containers before host shutdown
- Supports: exclude IDs, configurable timeout, force-stop, TLS skip for self-signed certs
- Should be placed BEFORE shutdown actions in the action chain
## Architecture Notes
- **SNMP Manager**: Uses `INupstAccessor` interface (not direct `Nupst` reference) to avoid circular imports
- **SNMP Manager**: Uses `INupstAccessor` interface (not direct `Nupst` reference) to avoid circular
imports
- **Protocol Resolver**: Routes to SNMP or UPSD based on `IUpsConfig.protocol`
- **CLI Handlers**: All use the `helpers.withPrompt()` utility for interactive input
- **Constants**: All timing values should be referenced from `ts/constants.ts`
- **Actions**: Use `IActionConfig` from `ts/actions/base-action.ts` for action configuration
- **Config version**: Currently `4.2`, migrations run automatically
## File Organization
@@ -50,9 +89,21 @@ ts/
│ ├── prompt.ts # Readline utility
│ └── shortid.ts # ID generation
├── actions/
│ ├── base-action.ts # Base action class and interfaces
│ ├── base-action.ts # Base action class, IActionConfig, TPowerStatus
│ ├── webhook-action.ts # Includes IWebhookPayload
│ ├── proxmox-action.ts # Proxmox VM/LXC shutdown
│ └── ...
├── upsd/
│ ├── types.ts # IUpsdConfig
│ ├── client.ts # NupstUpsd TCP client
│ └── index.ts
├── protocol/
│ ├── types.ts # TProtocol = 'snmp' | 'upsd'
│ ├── resolver.ts # ProtocolResolver
│ └── index.ts
├── migrations/
│ ├── migration-runner.ts
│ └── migration-v4.1-to-v4.2.ts # Adds protocol field
└── cli/
└── ... # All handlers use helpers.withPrompt()
```

967
readme.md

File diff suppressed because it is too large Load Diff

View File

@@ -1,18 +1,20 @@
#!/usr/bin/env node
// deno-lint-ignore-file no-unused-vars
/**
* NUPST npm postinstall script
* Downloads the appropriate binary for the current platform from GitHub releases
*/
import { platform, arch } from 'os';
import { existsSync, mkdirSync, writeFileSync, chmodSync, unlinkSync } from 'fs';
import { join, dirname } from 'path';
import { arch, platform } from 'os';
import { chmodSync, existsSync, mkdirSync, unlinkSync } from 'fs';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
import https from 'https';
import { pipeline } from 'stream';
import { promisify } from 'util';
import { createWriteStream } from 'fs';
import process from "node:process";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
@@ -29,12 +31,12 @@ function getBinaryInfo() {
const platformMap = {
'darwin': 'macos',
'linux': 'linux',
'win32': 'windows'
'win32': 'windows',
};
const archMap = {
'x64': 'x64',
'arm64': 'arm64'
'arm64': 'arm64',
};
const mappedPlatform = platformMap[plat];
@@ -54,7 +56,7 @@ function getBinaryInfo() {
platform: mappedPlatform,
arch: mappedArch,
binaryName,
originalPlatform: plat
originalPlatform: plat,
};
}
@@ -122,7 +124,9 @@ async function main() {
const binaryInfo = getBinaryInfo();
if (!binaryInfo.supported) {
console.error(`❌ Error: Unsupported platform/architecture: ${binaryInfo.platform}/${binaryInfo.arch}`);
console.error(
`❌ Error: Unsupported platform/architecture: ${binaryInfo.platform}/${binaryInfo.arch}`,
);
console.error('');
console.error('Supported platforms:');
console.error(' • Linux (x64, arm64)');
@@ -185,7 +189,9 @@ async function main() {
console.error('You can try:');
console.error('1. Installing from source: https://code.foss.global/serve.zone/nupst');
console.error('2. Downloading the binary manually from the releases page');
console.error('3. Using the install script: curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash');
console.error(
'3. Using the install script: curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash',
);
// Clean up partial download
if (existsSync(binaryPath)) {
@@ -225,7 +231,7 @@ async function main() {
}
// Run the installation
main().catch(err => {
main().catch((err) => {
console.error(`❌ Installation failed: ${err.message}`);
process.exit(1);
});

View File

@@ -1,6 +1,7 @@
# Manual Docker Testing Scripts
This directory contains scripts for manually testing NUPST installation and migration in Docker containers with systemd support.
This directory contains scripts for manually testing NUPST installation and migration in Docker
containers with systemd support.
## Prerequisites
@@ -15,12 +16,14 @@ This directory contains scripts for manually testing NUPST installation and migr
Creates a Docker container with systemd and installs NUPST v3.
**What it does:**
- Creates Ubuntu 22.04 container with systemd enabled
- Installs NUPST v3 from commit `806f81c6` (last v3 version)
- Enables and starts the systemd service
- Leaves container running for testing
**Usage:**
```bash
chmod +x 01-setup-v3-container.sh
./01-setup-v3-container.sh
@@ -33,6 +36,7 @@ chmod +x 01-setup-v3-container.sh
Tests the migration from v3 to v4.
**What it does:**
- Checks current v3 installation
- Pulls v4 code from `migration/deno-v4` branch
- Runs install.sh (should auto-detect and migrate)
@@ -40,6 +44,7 @@ Tests the migration from v3 to v4.
- Tests basic commands
**Usage:**
```bash
chmod +x 02-test-v3-to-v4-migration.sh
./02-test-v3-to-v4-migration.sh
@@ -52,6 +57,7 @@ chmod +x 02-test-v3-to-v4-migration.sh
Removes the test container.
**Usage:**
```bash
chmod +x 03-cleanup.sh
./03-cleanup.sh
@@ -134,16 +140,19 @@ docker rm -f nupst-test-v3
## Troubleshooting
### Container won't start
- Ensure Docker daemon is running
- Check you have privileged access
- Try: `docker logs nupst-test-v3`
### Systemd not working in container
- Requires Linux host (not macOS/Windows)
- Needs `--privileged` and cgroup volume mounts
- Check: `docker exec nupst-test-v3 systemctl --version`
### Migration fails
- Check logs: `docker exec nupst-test-v3 journalctl -xe`
- Verify install.sh ran: `docker exec nupst-test-v3 ls -la /opt/nupst/`
- Check service: `docker exec nupst-test-v3 systemctl status nupst`

View File

@@ -5,8 +5,8 @@
* Run with: deno run --allow-all test/showcase.ts
*/
import { logger, type ITableColumn } from '../ts/logger.ts';
import { theme, symbols, getBatteryColor, formatPowerStatus } from '../ts/colors.ts';
import { type ITableColumn, logger } from '../ts/logger.ts';
import { formatPowerStatus, getBatteryColor, symbols, theme } from '../ts/colors.ts';
console.log('');
console.log('═'.repeat(80));
@@ -38,31 +38,51 @@ logger.logBoxEnd();
console.log('');
logger.logBox('Success Box (Green)', [
'Used for successful operations',
'Installation complete, service started, etc.',
], 60, 'success');
logger.logBox(
'Success Box (Green)',
[
'Used for successful operations',
'Installation complete, service started, etc.',
],
60,
'success',
);
console.log('');
logger.logBox('Error Box (Red)', [
'Used for critical errors and failures',
'Configuration errors, connection failures, etc.',
], 60, 'error');
logger.logBox(
'Error Box (Red)',
[
'Used for critical errors and failures',
'Configuration errors, connection failures, etc.',
],
60,
'error',
);
console.log('');
logger.logBox('Warning Box (Yellow)', [
'Used for warnings and deprecations',
'Old command format, missing config, etc.',
], 60, 'warning');
logger.logBox(
'Warning Box (Yellow)',
[
'Used for warnings and deprecations',
'Old command format, missing config, etc.',
],
60,
'warning',
);
console.log('');
logger.logBox('Info Box (Cyan)', [
'Used for informational messages',
'Version info, update available, etc.',
], 60, 'info');
logger.logBox(
'Info Box (Cyan)',
[
'Used for informational messages',
'Version info, update available, etc.',
],
60,
'info',
);
console.log('');
@@ -112,15 +132,24 @@ const upsColumns: ITableColumn[] = [
{ header: 'ID', key: 'id' },
{ header: 'Name', key: 'name' },
{ header: 'Host', key: 'host' },
{ header: 'Status', key: 'status', color: (v) => {
if (v.includes('Online')) return theme.success(v);
if (v.includes('Battery')) return theme.warning(v);
return theme.dim(v);
}},
{ header: 'Battery', key: 'battery', align: 'right', color: (v) => {
const pct = parseInt(v);
return getBatteryColor(pct)(v);
}},
{
header: 'Status',
key: 'status',
color: (v) => {
if (v.includes('Online')) return theme.success(v);
if (v.includes('Battery')) return theme.warning(v);
return theme.dim(v);
},
},
{
header: 'Battery',
key: 'battery',
align: 'right',
color: (v) => {
const pct = parseInt(v);
return getBatteryColor(pct)(v);
},
},
{ header: 'Runtime', key: 'runtime', align: 'right' },
];

View File

@@ -1,9 +1,9 @@
import { assert, assertEquals, assertExists } from 'jsr:@std/assert@^1.0.0';
import { NupstSnmp } from '../ts/snmp/manager.ts';
import { UpsOidSets } from '../ts/snmp/oid-sets.ts';
import type { ISnmpConfig, TUpsModel, IOidSet } from '../ts/snmp/types.ts';
import type { IOidSet, ISnmpConfig, TUpsModel } from '../ts/snmp/types.ts';
import { shortId } from '../ts/helpers/shortid.ts';
import { TIMING, SNMP, THRESHOLDS, HTTP_SERVER, 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 * as qenv from 'npm:@push.rocks/qenv@^6.0.0';
@@ -56,8 +56,14 @@ Deno.test('SNMP constants: port is 161', () => {
});
Deno.test('SNMP constants: timeouts increase with security level', () => {
assert(SNMP.TIMEOUT_NO_AUTH_MS <= SNMP.TIMEOUT_AUTH_MS, 'Auth timeout should be >= noAuth timeout');
assert(SNMP.TIMEOUT_AUTH_MS <= SNMP.TIMEOUT_AUTH_PRIV_MS, 'AuthPriv timeout should be >= Auth timeout');
assert(
SNMP.TIMEOUT_NO_AUTH_MS <= SNMP.TIMEOUT_AUTH_MS,
'Auth timeout should be >= noAuth timeout',
);
assert(
SNMP.TIMEOUT_AUTH_MS <= SNMP.TIMEOUT_AUTH_PRIV_MS,
'AuthPriv timeout should be >= Auth timeout',
);
});
Deno.test('THRESHOLDS constants: defaults are reasonable', () => {
@@ -92,21 +98,21 @@ Deno.test('UpsOidSets: all models have OID sets', () => {
Deno.test('UpsOidSets: all non-custom models have complete OIDs', () => {
const requiredOids = ['POWER_STATUS', 'BATTERY_CAPACITY', 'BATTERY_RUNTIME', 'OUTPUT_LOAD'];
for (const model of UPS_MODELS.filter(m => m !== 'custom')) {
for (const model of UPS_MODELS.filter((m) => m !== 'custom')) {
const oidSet = UpsOidSets.getOidSet(model);
for (const oid of requiredOids) {
const value = oidSet[oid as keyof IOidSet];
assert(
typeof value === 'string' && value.length > 0,
`${model} should have non-empty ${oid}`
`${model} should have non-empty ${oid}`,
);
}
}
});
Deno.test('UpsOidSets: power status values defined for non-custom models', () => {
for (const model of UPS_MODELS.filter(m => m !== 'custom')) {
for (const model of UPS_MODELS.filter((m) => m !== 'custom')) {
const oidSet = UpsOidSets.getOidSet(model);
assertExists(oidSet.POWER_STATUS_VALUES, `${model} should have POWER_STATUS_VALUES`);
assertExists(oidSet.POWER_STATUS_VALUES?.online, `${model} should have online value`);
@@ -200,11 +206,11 @@ Deno.test('Action.shouldExecute: onlyPowerChanges mode', () => {
assertEquals(
action.testShouldExecute(createMockContext({ triggerReason: 'powerStatusChange' })),
true
true,
);
assertEquals(
action.testShouldExecute(createMockContext({ triggerReason: 'thresholdViolation' })),
false
false,
);
});
@@ -218,13 +224,13 @@ Deno.test('Action.shouldExecute: onlyThresholds mode', () => {
// Below thresholds - should execute
assertEquals(
action.testShouldExecute(createMockContext({ batteryCapacity: 50, batteryRuntime: 10 })),
true
true,
);
// Above thresholds - should not execute
assertEquals(
action.testShouldExecute(createMockContext({ batteryCapacity: 100, batteryRuntime: 60 })),
false
false,
);
});
@@ -248,7 +254,7 @@ Deno.test('Action.shouldExecute: powerChangesAndThresholds mode (default)', () =
// Power change - should execute
assertEquals(
action.testShouldExecute(createMockContext({ triggerReason: 'powerStatusChange' })),
true
true,
);
// Threshold violation - should execute
@@ -257,7 +263,7 @@ Deno.test('Action.shouldExecute: powerChangesAndThresholds mode (default)', () =
triggerReason: 'thresholdViolation',
batteryCapacity: 50,
})),
true
true,
);
// No power change and above thresholds - should not execute
@@ -267,7 +273,7 @@ Deno.test('Action.shouldExecute: powerChangesAndThresholds mode (default)', () =
batteryCapacity: 100,
batteryRuntime: 60,
})),
false
false,
);
});
@@ -279,11 +285,11 @@ Deno.test('Action.shouldExecute: anyChange mode always returns true', () => {
assertEquals(
action.testShouldExecute(createMockContext({ triggerReason: 'powerStatusChange' })),
true
true,
);
assertEquals(
action.testShouldExecute(createMockContext({ triggerReason: 'thresholdViolation' })),
true
true,
);
});
@@ -339,7 +345,7 @@ async function testUpsConnection(
assertExists(status, 'Status should exist');
assert(
['online', 'onBattery', 'unknown'].includes(status.powerStatus),
`Power status should be valid: ${status.powerStatus}`
`Power status should be valid: ${status.powerStatus}`,
);
assertEquals(typeof status.batteryCapacity, 'number', 'Battery capacity should be a number');
assertEquals(typeof status.batteryRuntime, 'number', 'Battery runtime should be a number');
@@ -347,9 +353,12 @@ async function testUpsConnection(
// Validate ranges
assert(
status.batteryCapacity >= 0 && status.batteryCapacity <= 100,
`Battery capacity should be 0-100: ${status.batteryCapacity}`
`Battery capacity should be 0-100: ${status.batteryCapacity}`,
);
assert(
status.batteryRuntime >= 0,
`Battery runtime should be non-negative: ${status.batteryRuntime}`,
);
assert(status.batteryRuntime >= 0, `Battery runtime should be non-negative: ${status.batteryRuntime}`);
}
// Create SNMP instance for integration tests

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/nupst',
version: '5.2.0',
version: '5.3.0',
description: 'Network UPS Shutdown Tool - Monitor SNMP-enabled UPS devices and orchestrate graceful system shutdowns during power emergencies'
}

View File

@@ -6,7 +6,7 @@
* 2. Threshold violations (battery/runtime cross below configured thresholds)
*/
export type TPowerStatus = 'online' | 'onBattery' | 'unknown';
export type TPowerStatus = 'online' | 'onBattery' | 'unknown' | 'unreachable';
/**
* Context provided to actions when they execute
@@ -52,7 +52,7 @@ export type TActionTriggerMode =
*/
export interface IActionConfig {
/** Type of action to execute */
type: 'shutdown' | 'webhook' | 'script';
type: 'shutdown' | 'webhook' | 'script' | 'proxmox';
// Trigger configuration
/**
@@ -96,6 +96,26 @@ export interface IActionConfig {
scriptTimeout?: number;
/** Only execute script on threshold violation */
scriptOnlyOnThresholdViolation?: boolean;
// Proxmox action configuration
/** Proxmox API host (default: localhost) */
proxmoxHost?: string;
/** Proxmox API port (default: 8006) */
proxmoxPort?: number;
/** Proxmox node name (default: auto-detect via hostname) */
proxmoxNode?: string;
/** Proxmox API token ID (e.g., 'root@pam!nupst') */
proxmoxTokenId?: string;
/** Proxmox API token secret */
proxmoxTokenSecret?: string;
/** VM/CT IDs to exclude from shutdown */
proxmoxExcludeIds?: number[];
/** Timeout for VM/CT shutdown in seconds (default: 120) */
proxmoxStopTimeout?: number;
/** Force-stop VMs that don't shut down gracefully (default: true) */
proxmoxForceStop?: boolean;
/** Skip TLS verification for self-signed certificates (default: true) */
proxmoxInsecure?: boolean;
}
/**

View File

@@ -10,6 +10,7 @@ import type { Action, IActionConfig, IActionContext } from './base-action.ts';
import { ShutdownAction } from './shutdown-action.ts';
import { WebhookAction } from './webhook-action.ts';
import { ScriptAction } from './script-action.ts';
import { ProxmoxAction } from './proxmox-action.ts';
// Re-export types for convenience
export type { IActionConfig, IActionContext, TPowerStatus } from './base-action.ts';
@@ -18,6 +19,7 @@ export { Action } from './base-action.ts';
export { ShutdownAction } from './shutdown-action.ts';
export { WebhookAction } from './webhook-action.ts';
export { ScriptAction } from './script-action.ts';
export { ProxmoxAction } from './proxmox-action.ts';
/**
* ActionManager - Coordinates action creation and execution
@@ -40,6 +42,8 @@ export class ActionManager {
return new WebhookAction(config);
case 'script':
return new ScriptAction(config);
case 'proxmox':
return new ProxmoxAction(config);
default:
throw new Error(`Unknown action type: ${(config as IActionConfig).type}`);
}

View File

@@ -0,0 +1,352 @@
import * as os from 'node:os';
import { Action, type IActionContext } from './base-action.ts';
import { logger } from '../logger.ts';
import { PROXMOX, UI } from '../constants.ts';
/**
* ProxmoxAction - Gracefully shuts down Proxmox VMs and LXC containers
*
* Uses the Proxmox REST API via HTTPS with API token authentication.
* Shuts down running QEMU VMs and LXC containers, waits for completion,
* and optionally force-stops any that don't respond.
*
* This action should be placed BEFORE shutdown actions in the action chain
* so that VMs are stopped before the host is shut down.
*/
export class ProxmoxAction extends Action {
readonly type = 'proxmox';
/**
* Execute the Proxmox shutdown action
*/
async execute(context: IActionContext): Promise<void> {
if (!this.shouldExecute(context)) {
logger.info(
`Proxmox action skipped (trigger mode: ${
this.config.triggerMode || 'powerChangesAndThresholds'
})`,
);
return;
}
const host = this.config.proxmoxHost || PROXMOX.DEFAULT_HOST;
const port = this.config.proxmoxPort || PROXMOX.DEFAULT_PORT;
const node = this.config.proxmoxNode || os.hostname();
const tokenId = this.config.proxmoxTokenId;
const tokenSecret = this.config.proxmoxTokenSecret;
const excludeIds = new Set(this.config.proxmoxExcludeIds || []);
const stopTimeout = (this.config.proxmoxStopTimeout || PROXMOX.DEFAULT_STOP_TIMEOUT_SECONDS) * 1000;
const forceStop = this.config.proxmoxForceStop !== false; // default true
const insecure = this.config.proxmoxInsecure !== false; // default true
if (!tokenId || !tokenSecret) {
logger.error('Proxmox API token ID and secret are required');
return;
}
const baseUrl = `https://${host}:${port}${PROXMOX.API_BASE}`;
const headers: Record<string, string> = {
'Authorization': `PVEAPIToken=${tokenId}=${tokenSecret}`,
};
logger.log('');
logger.logBoxTitle('Proxmox VM Shutdown', UI.WIDE_BOX_WIDTH, 'warning');
logger.logBoxLine(`Node: ${node}`);
logger.logBoxLine(`API: ${host}:${port}`);
logger.logBoxLine(`UPS: ${context.upsName} (${context.powerStatus})`);
logger.logBoxLine(`Trigger: ${context.triggerReason}`);
if (excludeIds.size > 0) {
logger.logBoxLine(`Excluded IDs: ${[...excludeIds].join(', ')}`);
}
logger.logBoxEnd();
logger.log('');
try {
// Collect running VMs and CTs
const runningVMs = await this.getRunningVMs(baseUrl, node, headers, insecure);
const runningCTs = await this.getRunningCTs(baseUrl, node, headers, insecure);
// Filter out excluded IDs
const vmsToStop = runningVMs.filter((vm) => !excludeIds.has(vm.vmid));
const ctsToStop = runningCTs.filter((ct) => !excludeIds.has(ct.vmid));
const totalToStop = vmsToStop.length + ctsToStop.length;
if (totalToStop === 0) {
logger.info('No running VMs or containers to shut down');
return;
}
logger.info(`Shutting down ${vmsToStop.length} VMs and ${ctsToStop.length} containers...`);
// Send shutdown commands to all VMs and CTs
for (const vm of vmsToStop) {
await this.shutdownVM(baseUrl, node, vm.vmid, headers, insecure);
logger.dim(` Shutdown sent to VM ${vm.vmid} (${vm.name || 'unnamed'})`);
}
for (const ct of ctsToStop) {
await this.shutdownCT(baseUrl, node, ct.vmid, headers, insecure);
logger.dim(` Shutdown sent to CT ${ct.vmid} (${ct.name || 'unnamed'})`);
}
// Poll until all stopped or timeout
const allIds = [
...vmsToStop.map((vm) => ({ type: 'qemu' as const, vmid: vm.vmid, name: vm.name })),
...ctsToStop.map((ct) => ({ type: 'lxc' as const, vmid: ct.vmid, name: ct.name })),
];
const remaining = await this.waitForShutdown(
baseUrl,
node,
allIds,
headers,
insecure,
stopTimeout,
);
if (remaining.length > 0 && forceStop) {
logger.warn(`${remaining.length} VMs/CTs didn't shut down gracefully, force-stopping...`);
for (const item of remaining) {
try {
if (item.type === 'qemu') {
await this.stopVM(baseUrl, node, item.vmid, headers, insecure);
} else {
await this.stopCT(baseUrl, node, item.vmid, headers, insecure);
}
logger.dim(` Force-stopped ${item.type} ${item.vmid} (${item.name || 'unnamed'})`);
} catch (error) {
logger.error(
` Failed to force-stop ${item.type} ${item.vmid}: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
} else if (remaining.length > 0) {
logger.warn(`${remaining.length} VMs/CTs still running (force-stop disabled)`);
}
logger.success('Proxmox shutdown sequence completed');
} catch (error) {
logger.error(
`Proxmox action failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
/**
* Make an API request to the Proxmox server
*/
private async apiRequest(
url: string,
method: string,
headers: Record<string, string>,
insecure: boolean,
): Promise<unknown> {
const fetchOptions: RequestInit = {
method,
headers,
};
// Use NODE_TLS_REJECT_UNAUTHORIZED for insecure mode (self-signed certs)
if (insecure) {
// deno-lint-ignore no-explicit-any
(globalThis as any).process?.env && ((globalThis as any).process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0');
}
try {
const response = await fetch(url, fetchOptions);
if (!response.ok) {
const body = await response.text();
throw new Error(`Proxmox API error ${response.status}: ${body}`);
}
return await response.json();
} finally {
// Restore TLS verification
if (insecure) {
// deno-lint-ignore no-explicit-any
(globalThis as any).process?.env && ((globalThis as any).process.env.NODE_TLS_REJECT_UNAUTHORIZED = '1');
}
}
}
/**
* Get list of running QEMU VMs
*/
private async getRunningVMs(
baseUrl: string,
node: string,
headers: Record<string, string>,
insecure: boolean,
): Promise<Array<{ vmid: number; name: string }>> {
try {
const response = await this.apiRequest(
`${baseUrl}/nodes/${node}/qemu`,
'GET',
headers,
insecure,
) as { data: Array<{ vmid: number; name: string; status: string }> };
return (response.data || [])
.filter((vm) => vm.status === 'running')
.map((vm) => ({ vmid: vm.vmid, name: vm.name || '' }));
} catch (error) {
logger.error(
`Failed to list VMs: ${error instanceof Error ? error.message : String(error)}`,
);
return [];
}
}
/**
* Get list of running LXC containers
*/
private async getRunningCTs(
baseUrl: string,
node: string,
headers: Record<string, string>,
insecure: boolean,
): Promise<Array<{ vmid: number; name: string }>> {
try {
const response = await this.apiRequest(
`${baseUrl}/nodes/${node}/lxc`,
'GET',
headers,
insecure,
) as { data: Array<{ vmid: number; name: string; status: string }> };
return (response.data || [])
.filter((ct) => ct.status === 'running')
.map((ct) => ({ vmid: ct.vmid, name: ct.name || '' }));
} catch (error) {
logger.error(
`Failed to list CTs: ${error instanceof Error ? error.message : String(error)}`,
);
return [];
}
}
/**
* Send graceful shutdown to a QEMU VM
*/
private async shutdownVM(
baseUrl: string,
node: string,
vmid: number,
headers: Record<string, string>,
insecure: boolean,
): Promise<void> {
await this.apiRequest(
`${baseUrl}/nodes/${node}/qemu/${vmid}/status/shutdown`,
'POST',
headers,
insecure,
);
}
/**
* Send graceful shutdown to an LXC container
*/
private async shutdownCT(
baseUrl: string,
node: string,
vmid: number,
headers: Record<string, string>,
insecure: boolean,
): Promise<void> {
await this.apiRequest(
`${baseUrl}/nodes/${node}/lxc/${vmid}/status/shutdown`,
'POST',
headers,
insecure,
);
}
/**
* Force-stop a QEMU VM
*/
private async stopVM(
baseUrl: string,
node: string,
vmid: number,
headers: Record<string, string>,
insecure: boolean,
): Promise<void> {
await this.apiRequest(
`${baseUrl}/nodes/${node}/qemu/${vmid}/status/stop`,
'POST',
headers,
insecure,
);
}
/**
* Force-stop an LXC container
*/
private async stopCT(
baseUrl: string,
node: string,
vmid: number,
headers: Record<string, string>,
insecure: boolean,
): Promise<void> {
await this.apiRequest(
`${baseUrl}/nodes/${node}/lxc/${vmid}/status/stop`,
'POST',
headers,
insecure,
);
}
/**
* Wait for VMs/CTs to shut down, return any that are still running after timeout
*/
private async waitForShutdown(
baseUrl: string,
node: string,
items: Array<{ type: 'qemu' | 'lxc'; vmid: number; name: string }>,
headers: Record<string, string>,
insecure: boolean,
timeout: number,
): Promise<Array<{ type: 'qemu' | 'lxc'; vmid: number; name: string }>> {
const startTime = Date.now();
let remaining = [...items];
while (remaining.length > 0 && (Date.now() - startTime) < timeout) {
// Wait before polling
await new Promise((resolve) => setTimeout(resolve, PROXMOX.STATUS_POLL_INTERVAL_SECONDS * 1000));
// Check which are still running
const stillRunning: typeof remaining = [];
for (const item of remaining) {
try {
const statusUrl = `${baseUrl}/nodes/${node}/${item.type}/${item.vmid}/status/current`;
const response = await this.apiRequest(statusUrl, 'GET', headers, insecure) as {
data: { status: string };
};
if (response.data?.status === 'running') {
stillRunning.push(item);
} else {
logger.dim(` ${item.type} ${item.vmid} (${item.name}) stopped`);
}
} catch (_error) {
// If we can't check status, assume it might still be running
stillRunning.push(item);
}
}
remaining = stillRunning;
if (remaining.length > 0) {
const elapsed = Math.round((Date.now() - startTime) / 1000);
logger.dim(` Waiting... ${remaining.length} still running (${elapsed}s elapsed)`);
}
}
return remaining;
}
}

View File

@@ -3,7 +3,7 @@ import * as fs from 'node:fs';
import process from 'node:process';
import { exec } from 'node:child_process';
import { promisify } from 'node:util';
import { Action, type IActionConfig, type IActionContext } from './base-action.ts';
import { Action, type IActionContext } from './base-action.ts';
import { logger } from '../logger.ts';
const execAsync = promisify(exec);
@@ -26,7 +26,11 @@ export class ScriptAction extends Action {
async execute(context: IActionContext): Promise<void> {
// Check if we should execute based on trigger mode
if (!this.shouldExecute(context)) {
logger.info(`Script action skipped (trigger mode: ${this.config.triggerMode || 'powerChangesAndThresholds'})`);
logger.info(
`Script action skipped (trigger mode: ${
this.config.triggerMode || 'powerChangesAndThresholds'
})`,
);
return;
}

View File

@@ -34,8 +34,17 @@ export class ShutdownAction extends Action {
// CRITICAL SAFETY CHECK: Shutdown should NEVER trigger unless UPS is on battery
// A low battery while on grid power is not an emergency (the battery is charging)
// When UPS is unreachable, we don't know the actual state - don't trigger false shutdown
if (context.powerStatus !== 'onBattery') {
logger.info(`Shutdown action skipped: UPS is not on battery (status: ${context.powerStatus})`);
if (context.powerStatus === 'unreachable') {
logger.info(
`Shutdown action skipped: UPS is unreachable (communication failure, actual state unknown)`,
);
} else {
logger.info(
`Shutdown action skipped: UPS is not on battery (status: ${context.powerStatus})`,
);
}
return false;
}
@@ -54,7 +63,9 @@ export class ShutdownAction extends Action {
if (context.triggerReason === 'powerStatusChange') {
// 'onlyThresholds' mode ignores power status changes
if (mode === 'onlyThresholds') {
logger.info('Shutdown action skipped: triggerMode is onlyThresholds, ignoring power change');
logger.info(
'Shutdown action skipped: triggerMode is onlyThresholds, ignoring power change',
);
return false;
}
@@ -71,15 +82,22 @@ export class ShutdownAction extends Action {
// the daemon for testing, or the UPS may have been on battery for a while.
// Only trigger if mode explicitly includes power changes.
if (prev === 'unknown') {
if (mode === 'onlyPowerChanges' || mode === 'powerChangesAndThresholds' || mode === 'anyChange') {
logger.info('Shutdown action triggered: UPS on battery at daemon startup (unknown → onBattery)');
if (
mode === 'onlyPowerChanges' || mode === 'powerChangesAndThresholds' ||
mode === 'anyChange'
) {
logger.info(
'Shutdown action triggered: UPS on battery at daemon startup (unknown → onBattery)',
);
return true;
}
return false;
}
// Other transitions (e.g., onBattery → onBattery) should not trigger
logger.info(`Shutdown action skipped: non-emergency transition (${prev}${context.powerStatus})`);
logger.info(
`Shutdown action skipped: non-emergency transition (${prev}${context.powerStatus})`,
);
return false;
}
@@ -98,7 +116,11 @@ export class ShutdownAction extends Action {
async execute(context: IActionContext): Promise<void> {
// Check if we should execute based on trigger mode and thresholds
if (!this.shouldExecute(context)) {
logger.info(`Shutdown action skipped (trigger mode: ${this.config.triggerMode || 'powerChangesAndThresholds'})`);
logger.info(
`Shutdown action skipped (trigger mode: ${
this.config.triggerMode || 'powerChangesAndThresholds'
})`,
);
return;
}

View File

@@ -1,7 +1,7 @@
import * as http from 'node:http';
import * as https from 'node:https';
import { URL } from 'node:url';
import { Action, type IActionConfig, type IActionContext } from './base-action.ts';
import { Action, type IActionContext } from './base-action.ts';
import { logger } from '../logger.ts';
import { WEBHOOK } from '../constants.ts';
@@ -14,7 +14,7 @@ export interface IWebhookPayload {
/** UPS name */
upsName: string;
/** Current power status */
powerStatus: 'online' | 'onBattery' | 'unknown';
powerStatus: 'online' | 'onBattery' | 'unknown' | 'unreachable';
/** Current battery capacity percentage */
batteryCapacity: number;
/** Current battery runtime in minutes */
@@ -46,7 +46,11 @@ export class WebhookAction extends Action {
async execute(context: IActionContext): Promise<void> {
// Check if we should execute based on trigger mode
if (!this.shouldExecute(context)) {
logger.info(`Webhook action skipped (trigger mode: ${this.config.triggerMode || 'powerChangesAndThresholds'})`);
logger.info(
`Webhook action skipped (trigger mode: ${
this.config.triggerMode || 'powerChangesAndThresholds'
})`,
);
return;
}
@@ -77,7 +81,7 @@ export class WebhookAction extends Action {
* @param method HTTP method (GET or POST)
* @param timeout Request timeout in milliseconds
*/
private async callWebhook(
private callWebhook(
context: IActionContext,
method: 'GET' | 'POST',
timeout: number,

218
ts/cli.ts
View File

@@ -1,7 +1,7 @@
import { execSync } from 'node:child_process';
import { Nupst } from './nupst.ts';
import { logger, type ITableColumn } from './logger.ts';
import { theme, symbols } from './colors.ts';
import { type ITableColumn, logger } from './logger.ts';
import { theme } from './colors.ts';
/**
* Class for handling CLI commands
@@ -26,8 +26,9 @@ export class NupstCli {
const debugOptions = this.extractDebugOptions(args);
if (debugOptions.debugMode) {
logger.log('Debug mode enabled');
// Enable debug mode in the SNMP client
// Enable debug mode in both protocol clients
this.nupst.getSnmp().enableDebug();
this.nupst.getUpsd().enableDebug();
}
// Check for version flag
@@ -259,6 +260,12 @@ export class NupstCli {
// Handle top-level commands
switch (command) {
case 'pause':
await serviceHandler.pause(commandArgs);
break;
case 'resume':
await serviceHandler.resume();
break;
case 'update':
await serviceHandler.update();
break;
@@ -287,10 +294,15 @@ export class NupstCli {
try {
await this.nupst.getDaemon().loadConfig();
} catch (_error) {
logger.logBox('Configuration Error', [
'No configuration found.',
"Please run 'nupst ups add' first to create a configuration.",
], 50, 'error');
logger.logBox(
'Configuration Error',
[
'No configuration found.',
"Please run 'nupst ups add' first to create a configuration.",
],
50,
'error',
);
return;
}
@@ -303,14 +315,19 @@ export class NupstCli {
// Overview Box
logger.log('');
logger.logBox('NUPST Configuration', [
`UPS Devices: ${theme.highlight(String(config.upsDevices.length))}`,
`Groups: ${theme.highlight(String(config.groups ? config.groups.length : 0))}`,
`Check Interval: ${theme.info(String(config.checkInterval / 1000))} seconds`,
'',
theme.dim('Configuration File:'),
` ${theme.path('/etc/nupst/config.json')}`,
], 60, 'info');
logger.logBox(
'NUPST Configuration',
[
`UPS Devices: ${theme.highlight(String(config.upsDevices.length))}`,
`Groups: ${theme.highlight(String(config.groups ? config.groups.length : 0))}`,
`Check Interval: ${theme.info(String(config.checkInterval / 1000))} seconds`,
'',
theme.dim('Configuration File:'),
` ${theme.path('/etc/nupst/config.json')}`,
],
60,
'info',
);
// HTTP Server Status (if configured)
if (config.httpServer) {
@@ -319,33 +336,54 @@ export class NupstCli {
: theme.dim('Disabled');
logger.log('');
logger.logBox('HTTP Server', [
`Status: ${serverStatus}`,
...(config.httpServer.enabled ? [
`Port: ${theme.highlight(String(config.httpServer.port))}`,
`Path: ${theme.highlight(config.httpServer.path)}`,
`Auth Token: ${theme.dim('***' + config.httpServer.authToken.slice(-4))}`,
'',
theme.dim('Usage:'),
` curl -H "Authorization: Bearer TOKEN" http://localhost:${config.httpServer.port}${config.httpServer.path}`,
] : []),
], 70, config.httpServer.enabled ? 'success' : 'default');
logger.logBox(
'HTTP Server',
[
`Status: ${serverStatus}`,
...(config.httpServer.enabled
? [
`Port: ${theme.highlight(String(config.httpServer.port))}`,
`Path: ${theme.highlight(config.httpServer.path)}`,
`Auth Token: ${theme.dim('***' + config.httpServer.authToken.slice(-4))}`,
'',
theme.dim('Usage:'),
` curl -H "Authorization: Bearer TOKEN" http://localhost:${config.httpServer.port}${config.httpServer.path}`,
]
: []),
],
70,
config.httpServer.enabled ? 'success' : 'default',
);
}
// UPS Devices Table
if (config.upsDevices.length > 0) {
const upsRows = config.upsDevices.map((ups) => ({
name: ups.name,
id: theme.dim(ups.id),
host: `${ups.snmp.host}:${ups.snmp.port}`,
model: ups.snmp.upsModel || 'cyberpower',
actions: `${(ups.actions || []).length} configured`,
groups: ups.groups.length > 0 ? ups.groups.join(', ') : theme.dim('None'),
}));
const upsRows = config.upsDevices.map((ups) => {
const protocol = ups.protocol || 'snmp';
let host = 'N/A';
let model = '';
if (protocol === 'upsd' && ups.upsd) {
host = `${ups.upsd.host}:${ups.upsd.port}`;
model = `NUT:${ups.upsd.upsName}`;
} else if (ups.snmp) {
host = `${ups.snmp.host}:${ups.snmp.port}`;
model = ups.snmp.upsModel || 'cyberpower';
}
return {
name: ups.name,
id: theme.dim(ups.id),
protocol: protocol.toUpperCase(),
host,
model,
actions: `${(ups.actions || []).length} configured`,
groups: ups.groups.length > 0 ? ups.groups.join(', ') : theme.dim('None'),
};
});
const upsColumns: ITableColumn[] = [
{ header: 'Name', key: 'name', align: 'left', color: theme.highlight },
{ header: 'ID', key: 'id', align: 'left' },
{ header: 'Protocol', key: 'protocol', align: 'left' },
{ header: 'Host:Port', key: 'host', align: 'left', color: theme.info },
{ header: 'Model', key: 'model', align: 'left' },
{ header: 'Actions', key: 'actions', align: 'left' },
@@ -394,60 +432,66 @@ export class NupstCli {
// === Legacy Single UPS Configuration ===
if (!config.snmp) {
logger.logBox('Configuration Error', [
'Error: Legacy configuration missing SNMP settings',
], 60, 'error');
logger.logBox(
'Configuration Error',
[
'Error: Legacy configuration missing SNMP settings',
],
60,
'error',
);
return;
}
logger.log('');
logger.logBox('NUPST Configuration (Legacy)', [
theme.warning('Legacy single-UPS configuration format'),
'',
theme.dim('SNMP Settings:'),
` Host: ${theme.info(config.snmp.host)}`,
` Port: ${theme.info(String(config.snmp.port))}`,
` Version: ${config.snmp.version}`,
` UPS Model: ${config.snmp.upsModel || 'cyberpower'}`,
...(config.snmp.version === 1 || config.snmp.version === 2
? [` Community: ${config.snmp.community}`]
: []
),
...(config.snmp.version === 3
? [
logger.logBox(
'NUPST Configuration (Legacy)',
[
theme.warning('Legacy single-UPS configuration format'),
'',
theme.dim('SNMP Settings:'),
` Host: ${theme.info(config.snmp.host)}`,
` Port: ${theme.info(String(config.snmp.port))}`,
` Version: ${config.snmp.version}`,
` UPS Model: ${config.snmp.upsModel || 'cyberpower'}`,
...(config.snmp.version === 1 || config.snmp.version === 2
? [` Community: ${config.snmp.community}`]
: []),
...(config.snmp.version === 3
? [
` Security Level: ${config.snmp.securityLevel}`,
` Username: ${config.snmp.username}`,
...(config.snmp.securityLevel === 'authNoPriv' || config.snmp.securityLevel === 'authPriv'
...(config.snmp.securityLevel === 'authNoPriv' ||
config.snmp.securityLevel === 'authPriv'
? [` Auth Protocol: ${config.snmp.authProtocol || 'None'}`]
: []
),
: []),
...(config.snmp.securityLevel === 'authPriv'
? [` Privacy Protocol: ${config.snmp.privProtocol || 'None'}`]
: []
),
: []),
` Timeout: ${config.snmp.timeout / 1000} seconds`,
]
: []
),
...(config.snmp.upsModel === 'custom' && config.snmp.customOIDs
? [
: []),
...(config.snmp.upsModel === 'custom' && config.snmp.customOIDs
? [
theme.dim('Custom OIDs:'),
` Power Status: ${config.snmp.customOIDs.POWER_STATUS || 'Not set'}`,
` Battery Capacity: ${config.snmp.customOIDs.BATTERY_CAPACITY || 'Not set'}`,
` Battery Runtime: ${config.snmp.customOIDs.BATTERY_RUNTIME || 'Not set'}`,
]
: []
),
'',
: []),
'',
` Check Interval: ${config.checkInterval / 1000} seconds`,
'',
theme.dim('Configuration File:'),
` ${theme.path('/etc/nupst/config.json')}`,
'',
theme.warning('Note: Using legacy single-UPS configuration format.'),
`Consider using ${theme.command('nupst ups add')} to migrate to multi-UPS format.`,
], 70, 'warning');
` Check Interval: ${config.checkInterval / 1000} seconds`,
'',
theme.dim('Configuration File:'),
` ${theme.path('/etc/nupst/config.json')}`,
'',
theme.warning('Note: Using legacy single-UPS configuration format.'),
`Consider using ${theme.command('nupst ups add')} to migrate to multi-UPS format.`,
],
70,
'warning',
);
}
// Service Status
@@ -458,10 +502,15 @@ export class NupstCli {
execSync('systemctl is-enabled nupst.service || true').toString().trim() === 'enabled';
logger.log('');
logger.logBox('Service Status', [
`Active: ${isActive ? theme.success('Yes') : theme.dim('No')}`,
`Enabled: ${isEnabled ? theme.success('Yes') : theme.dim('No')}`,
], 50, isActive ? 'success' : 'default');
logger.logBox(
'Service Status',
[
`Active: ${isActive ? theme.success('Yes') : theme.dim('No')}`,
`Enabled: ${isEnabled ? theme.success('Yes') : theme.dim('No')}`,
],
50,
isActive ? 'success' : 'default',
);
logger.log('');
} catch (_error) {
// Ignore errors checking service status
@@ -506,6 +555,8 @@ export class NupstCli {
this.printCommand('action <subcommand>', 'Manage UPS actions');
this.printCommand('feature <subcommand>', 'Manage optional features');
this.printCommand('config [show]', 'Display current configuration');
this.printCommand('pause [--duration <time>]', 'Pause action monitoring');
this.printCommand('resume', 'Resume action monitoring');
this.printCommand('update', 'Update NUPST from repository', theme.dim('(requires root)'));
this.printCommand('uninstall', 'Completely remove NUPST', theme.dim('(requires root)'));
this.printCommand('help, --help, -h', 'Show this help message');
@@ -514,8 +565,16 @@ export class NupstCli {
// Service subcommands
logger.log(theme.info('Service Subcommands:'));
this.printCommand('nupst service enable', 'Install and enable systemd service', theme.dim('(requires root)'));
this.printCommand('nupst service disable', 'Stop and disable systemd service', theme.dim('(requires root)'));
this.printCommand(
'nupst service enable',
'Install and enable systemd service',
theme.dim('(requires root)'),
);
this.printCommand(
'nupst service disable',
'Stop and disable systemd service',
theme.dim('(requires root)'),
);
this.printCommand('nupst service start', 'Start the systemd service');
this.printCommand('nupst service stop', 'Stop the systemd service');
this.printCommand('nupst service restart', 'Restart the systemd service');
@@ -545,7 +604,10 @@ export class NupstCli {
logger.log(theme.info('Action Subcommands:'));
this.printCommand('nupst action add <target-id>', 'Add a new action to a UPS or group');
this.printCommand('nupst action remove <target-id> <index>', 'Remove an action by index');
this.printCommand('nupst action list [target-id]', 'List all actions (optionally for specific target)');
this.printCommand(
'nupst action list [target-id]',
'List all actions (optionally for specific target)',
);
console.log('');
// Feature subcommands

View File

@@ -1,9 +1,9 @@
import process from 'node:process';
import { Nupst } from '../nupst.ts';
import { logger, type ITableColumn } from '../logger.ts';
import { theme, symbols } from '../colors.ts';
import { type ITableColumn, logger } from '../logger.ts';
import { symbols, theme } from '../colors.ts';
import type { IActionConfig } from '../actions/base-action.ts';
import type { IUpsConfig, IGroupConfig } from '../daemon.ts';
import type { IGroupConfig, IUpsConfig } from '../daemon.ts';
import * as helpers from '../helpers/index.ts';
/**
@@ -48,7 +48,9 @@ export class ActionHandler {
if (!ups && !group) {
logger.error(`UPS or Group with ID '${targetId}' not found`);
logger.log('');
logger.log(` ${theme.dim('List available UPS devices:')} ${theme.command('nupst ups list')}`);
logger.log(
` ${theme.dim('List available UPS devices:')} ${theme.command('nupst ups list')}`,
);
logger.log(` ${theme.dim('List available groups:')} ${theme.command('nupst group list')}`);
logger.log('');
process.exit(1);
@@ -90,12 +92,16 @@ export class ActionHandler {
// Trigger mode
logger.log('');
logger.log(` ${theme.dim('Trigger mode:')}`);
logger.log(` ${theme.dim('1)')} onlyPowerChanges - Trigger only when power status changes`);
logger.log(
` ${theme.dim('1)')} onlyPowerChanges - Trigger only when power status changes`,
);
logger.log(
` ${theme.dim('2)')} onlyThresholds - Trigger only when thresholds are violated`,
);
logger.log(
` ${theme.dim('3)')} powerChangesAndThresholds - Trigger on power change AND thresholds`,
` ${
theme.dim('3)')
} powerChangesAndThresholds - Trigger on power change AND thresholds`,
);
logger.log(` ${theme.dim('4)')} anyChange - Trigger on any status change`);
const triggerChoice = await prompt(` ${theme.dim('Choice')} ${theme.dim('[2]:')} `);
@@ -158,7 +164,9 @@ export class ActionHandler {
if (!targetId || !actionIndexStr) {
logger.error('Target ID and action index are required');
logger.log(
` ${theme.dim('Usage:')} ${theme.command('nupst action remove <ups-id|group-id> <action-index>')}`,
` ${theme.dim('Usage:')} ${
theme.command('nupst action remove <ups-id|group-id> <action-index>')
}`,
);
logger.log('');
logger.log(` ${theme.dim('List actions:')} ${theme.command('nupst action list')}`);
@@ -182,7 +190,9 @@ export class ActionHandler {
if (!ups && !group) {
logger.error(`UPS or Group with ID '${targetId}' not found`);
logger.log('');
logger.log(` ${theme.dim('List available UPS devices:')} ${theme.command('nupst ups list')}`);
logger.log(
` ${theme.dim('List available UPS devices:')} ${theme.command('nupst ups list')}`,
);
logger.log(` ${theme.dim('List available groups:')} ${theme.command('nupst group list')}`);
logger.log('');
process.exit(1);
@@ -200,7 +210,9 @@ export class ActionHandler {
if (actionIndex >= target!.actions.length) {
logger.error(
`Invalid action index. ${targetType} '${targetName}' has ${target!.actions.length} action(s) (index 0-${target!.actions.length - 1})`,
`Invalid action index. ${targetType} '${targetName}' has ${
target!.actions.length
} action(s) (index 0-${target!.actions.length - 1})`,
);
logger.log('');
logger.log(
@@ -220,7 +232,9 @@ export class ActionHandler {
logger.log(` ${theme.dim('Type:')} ${removedAction.type}`);
if (removedAction.thresholds) {
logger.log(
` ${theme.dim('Thresholds:')} Battery: ${removedAction.thresholds.battery}%, Runtime: ${removedAction.thresholds.runtime}min`,
` ${
theme.dim('Thresholds:')
} Battery: ${removedAction.thresholds.battery}%, Runtime: ${removedAction.thresholds.runtime}min`,
);
}
logger.log(` ${theme.dim('Changes saved and will be applied automatically')}`);
@@ -248,8 +262,12 @@ export class ActionHandler {
if (!ups && !group) {
logger.error(`UPS or Group with ID '${targetId}' not found`);
logger.log('');
logger.log(` ${theme.dim('List available UPS devices:')} ${theme.command('nupst ups list')}`);
logger.log(` ${theme.dim('List available groups:')} ${theme.command('nupst group list')}`);
logger.log(
` ${theme.dim('List available UPS devices:')} ${theme.command('nupst ups list')}`,
);
logger.log(
` ${theme.dim('List available groups:')} ${theme.command('nupst group list')}`,
);
logger.log('');
process.exit(1);
}
@@ -287,7 +305,9 @@ export class ActionHandler {
logger.log(` ${theme.dim('No actions configured')}`);
logger.log('');
logger.log(
` ${theme.dim('Add an action:')} ${theme.command('nupst action add <ups-id|group-id>')}`,
` ${theme.dim('Add an action:')} ${
theme.command('nupst action add <ups-id|group-id>')
}`,
);
logger.log('');
}
@@ -308,7 +328,9 @@ export class ActionHandler {
targetType: 'UPS' | 'Group',
): void {
logger.log(
`${symbols.info} ${targetType} ${theme.highlight(target.name)} ${theme.dim(`(${target.id})`)}`,
`${symbols.info} ${targetType} ${theme.highlight(target.name)} ${
theme.dim(`(${target.id})`)
}`,
);
logger.log('');
@@ -324,17 +346,30 @@ export class ActionHandler {
{ header: 'Battery', key: 'battery', align: 'right' },
{ header: 'Runtime', key: 'runtime', align: 'right' },
{ header: 'Trigger Mode', key: 'triggerMode', align: 'left' },
{ header: 'Delay', key: 'delay', align: 'right' },
{ header: 'Details', key: 'details', align: 'left' },
];
const rows = target.actions.map((action, index) => ({
index: theme.dim(index.toString()),
type: theme.highlight(action.type),
battery: action.thresholds ? `${action.thresholds.battery}%` : theme.dim('N/A'),
runtime: action.thresholds ? `${action.thresholds.runtime}min` : theme.dim('N/A'),
triggerMode: theme.dim(action.triggerMode || 'onlyThresholds'),
delay: `${action.shutdownDelay || 5}s`,
}));
const rows = target.actions.map((action, index) => {
let details = `${action.shutdownDelay || 5}s delay`;
if (action.type === 'proxmox') {
const host = action.proxmoxHost || 'localhost';
const port = action.proxmoxPort || 8006;
details = `${host}:${port}`;
} else if (action.type === 'webhook') {
details = action.webhookUrl || theme.dim('N/A');
} else if (action.type === 'script') {
details = action.scriptPath || theme.dim('N/A');
}
return {
index: theme.dim(index.toString()),
type: theme.highlight(action.type),
battery: action.thresholds ? `${action.thresholds.battery}%` : theme.dim('N/A'),
runtime: action.thresholds ? `${action.thresholds.runtime}min` : theme.dim('N/A'),
triggerMode: theme.dim(action.triggerMode || 'onlyThresholds'),
details,
};
});
logger.logTable(columns, rows);
logger.log('');

View File

@@ -1,4 +1,3 @@
import process from 'node:process';
import { execSync } from 'node:child_process';
import { Nupst } from '../nupst.ts';
import { logger } from '../logger.ts';
@@ -29,7 +28,9 @@ export class FeatureHandler {
await this.runHttpServerConfig(prompt);
});
} catch (error) {
logger.error(`HTTP Server config error: ${error instanceof Error ? error.message : String(error)}`);
logger.error(
`HTTP Server config error: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
@@ -149,7 +150,9 @@ export class FeatureHandler {
logger.logBoxLine(`Auth Token: ${theme.warning(authToken)}`);
logger.logBoxLine('');
logger.logBoxLine(theme.dim('Usage examples:'));
logger.logBoxLine(` curl -H "Authorization: Bearer ${authToken}" http://localhost:${port}${finalPath}`);
logger.logBoxLine(
` curl -H "Authorization: Bearer ${authToken}" http://localhost:${port}${finalPath}`,
);
logger.logBoxLine(` curl "http://localhost:${port}${finalPath}?token=${authToken}"`);
logger.logBoxEnd();
logger.log('');
@@ -165,7 +168,8 @@ export class FeatureHandler {
*/
private async restartServiceIfRunning(): Promise<void> {
try {
const isActive = execSync('systemctl is-active nupst.service || true').toString().trim() === 'active';
const isActive =
execSync('systemctl is-active nupst.service || true').toString().trim() === 'active';
if (isActive) {
logger.log('');

View File

@@ -1,9 +1,8 @@
import process from 'node:process';
import { Nupst } from '../nupst.ts';
import { logger, type ITableColumn } from '../logger.ts';
import { type ITableColumn, logger } from '../logger.ts';
import { theme } from '../colors.ts';
import * as helpers from '../helpers/index.ts';
import type { IGroupConfig, IUpsConfig, INupstConfig } from '../daemon.ts';
import type { IGroupConfig, INupstConfig, IUpsConfig } from '../daemon.ts';
/**
* Class for handling group-related CLI commands
@@ -29,10 +28,15 @@ export class GroupHandler {
try {
await this.nupst.getDaemon().loadConfig();
} catch (error) {
logger.logBox('Configuration Error', [
'No configuration found.',
"Please run 'nupst ups add' first to create a configuration.",
], 50, 'error');
logger.logBox(
'Configuration Error',
[
'No configuration found.',
"Please run 'nupst ups add' first to create a configuration.",
],
50,
'error',
);
return;
}
@@ -41,21 +45,35 @@ export class GroupHandler {
// Check if multi-UPS config
if (!config.groups || !Array.isArray(config.groups)) {
logger.logBox('UPS Groups', [
'No groups configured.',
'',
`${theme.dim('Run')} ${theme.command('nupst group add')} ${theme.dim('to add a group')}`,
], 50, 'info');
logger.logBox(
'UPS Groups',
[
'No groups configured.',
'',
`${theme.dim('Run')} ${theme.command('nupst group add')} ${
theme.dim('to add a group')
}`,
],
50,
'info',
);
return;
}
// Display group list with modern table
if (config.groups.length === 0) {
logger.logBox('UPS Groups', [
'No UPS groups configured.',
'',
`${theme.dim('Run')} ${theme.command('nupst group add')} ${theme.dim('to add a group')}`,
], 60, 'info');
logger.logBox(
'UPS Groups',
[
'No UPS groups configured.',
'',
`${theme.dim('Run')} ${theme.command('nupst group add')} ${
theme.dim('to add a group')
}`,
],
60,
'info',
);
return;
}

View File

@@ -1,7 +1,12 @@
import process from 'node:process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { execSync } from 'node:child_process';
import { Nupst } from '../nupst.ts';
import { logger } from '../logger.ts';
import { theme } from '../colors.ts';
import { PAUSE } from '../constants.ts';
import type { IPauseState } from '../daemon.ts';
import * as helpers from '../helpers/index.ts';
/**
@@ -104,6 +109,125 @@ export class ServiceHandler {
await this.nupst.getSystemd().getStatus(debugOptions.debugMode);
}
/**
* Pause action monitoring
* @param args Command arguments (e.g., ['--duration', '30m'])
*/
public async pause(args: string[]): Promise<void> {
try {
// Parse --duration argument
let resumeAt: number | null = null;
const durationIdx = args.indexOf('--duration');
if (durationIdx !== -1 && args[durationIdx + 1]) {
const durationStr = args[durationIdx + 1];
const durationMs = this.parseDuration(durationStr);
if (durationMs === null) {
logger.error(`Invalid duration format: ${durationStr}`);
logger.dim(' Valid formats: 30m, 2h, 1d (minutes, hours, days)');
return;
}
if (durationMs > PAUSE.MAX_DURATION_MS) {
logger.error(`Duration exceeds maximum of 24 hours`);
return;
}
resumeAt = Date.now() + durationMs;
}
// Check if already paused
if (fs.existsSync(PAUSE.FILE_PATH)) {
logger.warn('Monitoring is already paused');
try {
const data = fs.readFileSync(PAUSE.FILE_PATH, 'utf8');
const state = JSON.parse(data) as IPauseState;
logger.dim(` Paused at: ${new Date(state.pausedAt).toISOString()}`);
if (state.resumeAt) {
const remaining = Math.round((state.resumeAt - Date.now()) / 1000);
logger.dim(` Auto-resume in: ${remaining > 0 ? remaining : 0} seconds`);
}
} catch (_e) {
// Ignore parse errors
}
logger.dim(' Run "nupst resume" to resume monitoring');
return;
}
// Create pause state
const pauseState: IPauseState = {
pausedAt: Date.now(),
pausedBy: 'cli',
resumeAt,
};
// Ensure config directory exists
const pauseDir = path.dirname(PAUSE.FILE_PATH);
if (!fs.existsSync(pauseDir)) {
fs.mkdirSync(pauseDir, { recursive: true });
}
fs.writeFileSync(PAUSE.FILE_PATH, JSON.stringify(pauseState, null, 2));
logger.log('');
logger.logBoxTitle('Monitoring Paused', 45, 'warning');
logger.logBoxLine('UPS polling continues but actions are suppressed');
if (resumeAt) {
const durationStr = args[args.indexOf('--duration') + 1];
logger.logBoxLine(`Auto-resume after: ${durationStr}`);
logger.logBoxLine(`Resume at: ${new Date(resumeAt).toISOString()}`);
} else {
logger.logBoxLine('Duration: Indefinite');
logger.logBoxLine('Run "nupst resume" to resume');
}
logger.logBoxEnd();
logger.log('');
} catch (error) {
logger.error(
`Failed to pause: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
/**
* Resume action monitoring
*/
public async resume(): Promise<void> {
try {
if (!fs.existsSync(PAUSE.FILE_PATH)) {
logger.info('Monitoring is not paused');
return;
}
fs.unlinkSync(PAUSE.FILE_PATH);
logger.log('');
logger.logBoxTitle('Monitoring Resumed', 45, 'success');
logger.logBoxLine('Action monitoring has been resumed');
logger.logBoxEnd();
logger.log('');
} catch (error) {
logger.error(
`Failed to resume: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
/**
* Parse a duration string like '30m', '2h', '1d' into milliseconds
*/
private parseDuration(duration: string): number | null {
const match = duration.match(/^(\d+)\s*(m|h|d)$/i);
if (!match) return null;
const value = parseInt(match[1], 10);
const unit = match[2].toLowerCase();
switch (unit) {
case 'm': return value * 60 * 1000;
case 'h': return value * 60 * 60 * 1000;
case 'd': return value * 24 * 60 * 60 * 1000;
default: return null;
}
}
/**
* Disable the service (requires root)
*/
@@ -126,7 +250,7 @@ export class ServiceHandler {
/**
* Update NUPST from repository and refresh systemd service
*/
public async update(): Promise<void> {
public update(): void {
try {
// Check if running as root
this.checkRootAccess(
@@ -147,8 +271,12 @@ export class ServiceHandler {
const latestVersion = release.tag_name; // e.g., "v4.0.7"
// Normalize versions for comparison (ensure both have "v" prefix)
const normalizedCurrent = currentVersion.startsWith('v') ? currentVersion : `v${currentVersion}`;
const normalizedLatest = latestVersion.startsWith('v') ? latestVersion : `v${latestVersion}`;
const normalizedCurrent = currentVersion.startsWith('v')
? currentVersion
: `v${currentVersion}`;
const normalizedLatest = latestVersion.startsWith('v')
? latestVersion
: `v${latestVersion}`;
logger.dim(`Current version: ${normalizedCurrent}`);
logger.dim(`Latest version: ${normalizedLatest}`);

View File

@@ -1,12 +1,15 @@
import process from 'node:process';
import { execSync } from 'node:child_process';
import { Nupst } from '../nupst.ts';
import { logger, type ITableColumn } from '../logger.ts';
import { type ITableColumn, logger } from '../logger.ts';
import { theme } from '../colors.ts';
import * as helpers from '../helpers/index.ts';
import type { ISnmpConfig, TUpsModel, IUpsStatus as ISnmpUpsStatus } from '../snmp/types.ts';
import type { INupstConfig, IUpsConfig, IUpsStatus } from '../daemon.ts';
import type { ISnmpConfig, IUpsStatus as ISnmpUpsStatus, TUpsModel } from '../snmp/types.ts';
import type { IUpsdConfig } from '../upsd/types.ts';
import type { TProtocol } from '../protocol/types.ts';
import type { INupstConfig, IUpsConfig } from '../daemon.ts';
import type { IActionConfig } from '../actions/base-action.ts';
import { UPSD } from '../constants.ts';
/**
* Thresholds configuration for CLI display
@@ -66,10 +69,10 @@ export class UpsHandler {
checkInterval: config.checkInterval,
upsDevices: [{
id: 'default',
name: 'Default UPS',
snmp: config.snmp,
groups: [],
actions: [],
name: 'Default UPS',
snmp: config.snmp,
groups: [],
actions: [],
}],
groups: [],
};
@@ -89,31 +92,46 @@ export class UpsHandler {
const upsId = helpers.shortId();
const name = await prompt('UPS Name: ');
// Select protocol
logger.log('');
logger.info('Communication Protocol:');
logger.dim(' 1) SNMP (network UPS with SNMP agent)');
logger.dim(' 2) UPSD/NIS (local NUT server, e.g. USB-connected UPS)');
const protocolInput = await prompt('Select protocol [1]: ');
const protocolChoice = parseInt(protocolInput, 10) || 1;
const protocol: TProtocol = protocolChoice === 2 ? 'upsd' : 'snmp';
// Create a new UPS configuration object with defaults
const newUps = {
const newUps: Record<string, unknown> & { id: string; name: string; groups: string[]; actions: IActionConfig[]; protocol: TProtocol; snmp?: ISnmpConfig; upsd?: IUpsdConfig } = {
id: upsId,
name: name || `UPS-${upsId}`,
snmp: {
protocol,
groups: [],
actions: [],
};
if (protocol === 'snmp') {
newUps.snmp = {
host: '127.0.0.1',
port: 161,
community: 'public',
version: 1,
timeout: 5000,
upsModel: 'cyberpower' as TUpsModel,
},
thresholds: {
battery: 60,
runtime: 20,
},
groups: [],
actions: [],
};
// Gather SNMP settings
await this.gatherSnmpSettings(newUps.snmp, prompt);
// Gather UPS model settings
await this.gatherUpsModelSettings(newUps.snmp, prompt);
};
// Gather SNMP settings
await this.gatherSnmpSettings(newUps.snmp, prompt);
// Gather UPS model settings
await this.gatherUpsModelSettings(newUps.snmp, prompt);
} else {
newUps.upsd = {
host: '127.0.0.1',
port: UPSD.DEFAULT_PORT,
upsName: UPSD.DEFAULT_UPS_NAME,
timeout: UPSD.DEFAULT_TIMEOUT_MS,
};
await this.gatherUpsdSettings(newUps.upsd, prompt);
}
// Get access to GroupHandler for group assignments
const groupHandler = this.nupst.getGroupHandler();
@@ -123,7 +141,7 @@ export class UpsHandler {
await groupHandler.assignUpsToGroups(newUps, config.groups, prompt);
}
// Gather action settings
// Gather action settings
await this.gatherActionSettings(newUps.actions, prompt);
// Add the new UPS to the config
@@ -132,10 +150,14 @@ export class UpsHandler {
// Save the configuration
await this.nupst.getDaemon().saveConfig(config as INupstConfig);
this.displayUpsConfigSummary(newUps);
this.displayUpsConfigSummary(newUps as unknown as IUpsConfig);
// Test the connection if requested
await this.optionallyTestConnection(newUps.snmp, prompt);
if (protocol === 'snmp' && newUps.snmp) {
await this.optionallyTestConnection(newUps.snmp as ISnmpConfig, prompt);
} else if (protocol === 'upsd' && newUps.upsd) {
await this.optionallyTestUpsdConnection(newUps.upsd, prompt);
}
// Check if service is running and restart it if needed
await this.restartServiceIfRunning();
@@ -232,11 +254,51 @@ export class UpsHandler {
upsToEdit.name = newName;
}
// Edit SNMP settings
await this.gatherSnmpSettings(upsToEdit.snmp, prompt);
// Show current protocol and allow changing
const currentProtocol = upsToEdit.protocol || 'snmp';
logger.log('');
logger.info(`Current Protocol: ${currentProtocol.toUpperCase()}`);
logger.dim(' 1) SNMP (network UPS with SNMP agent)');
logger.dim(' 2) UPSD/NIS (local NUT server, e.g. USB-connected UPS)');
const protocolInput = await prompt(`Select protocol [${currentProtocol === 'upsd' ? '2' : '1'}]: `);
const protocolChoice = parseInt(protocolInput, 10);
if (protocolChoice === 2) {
upsToEdit.protocol = 'upsd';
} else if (protocolChoice === 1) {
upsToEdit.protocol = 'snmp';
}
// else keep current
// Edit UPS model settings
await this.gatherUpsModelSettings(upsToEdit.snmp, prompt);
const editProtocol = upsToEdit.protocol || 'snmp';
if (editProtocol === 'snmp') {
// Initialize SNMP config if switching from UPSD
if (!upsToEdit.snmp) {
upsToEdit.snmp = {
host: '127.0.0.1',
port: 161,
community: 'public',
version: 1,
timeout: 5000,
upsModel: 'cyberpower' as TUpsModel,
};
}
// Edit SNMP settings
await this.gatherSnmpSettings(upsToEdit.snmp, prompt);
// Edit UPS model settings
await this.gatherUpsModelSettings(upsToEdit.snmp, prompt);
} else {
// Initialize UPSD config if switching from SNMP
if (!upsToEdit.upsd) {
upsToEdit.upsd = {
host: '127.0.0.1',
port: UPSD.DEFAULT_PORT,
upsName: UPSD.DEFAULT_UPS_NAME,
timeout: UPSD.DEFAULT_TIMEOUT_MS,
};
}
await this.gatherUpsdSettings(upsToEdit.upsd, prompt);
}
// Get access to GroupHandler for group assignments
const groupHandler = this.nupst.getGroupHandler();
@@ -260,7 +322,11 @@ export class UpsHandler {
this.displayUpsConfigSummary(upsToEdit);
// Test the connection if requested
await this.optionallyTestConnection(upsToEdit.snmp, prompt);
if (editProtocol === 'snmp' && upsToEdit.snmp) {
await this.optionallyTestConnection(upsToEdit.snmp, prompt);
} else if (editProtocol === 'upsd' && upsToEdit.upsd) {
await this.optionallyTestUpsdConnection(upsToEdit.upsd, prompt);
}
// Check if service is running and restart it if needed
await this.restartServiceIfRunning();
@@ -343,10 +409,15 @@ export class UpsHandler {
try {
await this.nupst.getDaemon().loadConfig();
} catch (error) {
logger.logBox('Configuration Error', [
'No configuration found.',
"Please run 'nupst ups add' first to create a configuration.",
], 50, 'error');
logger.logBox(
'Configuration Error',
[
'No configuration found.',
"Please run 'nupst ups add' first to create a configuration.",
],
50,
'error',
);
return;
}
@@ -356,46 +427,67 @@ export class UpsHandler {
// Check if multi-UPS config
if (!config.upsDevices || !Array.isArray(config.upsDevices)) {
// Legacy single UPS configuration
logger.logBox('UPS Devices', [
'Legacy single-UPS configuration detected.',
'',
...(!config.snmp
? ['Error: Configuration missing SNMP settings']
: [
'Default UPS:',
` Host: ${config.snmp.host}:${config.snmp.port}`,
` Model: ${config.snmp.upsModel || 'cyberpower'}`,
'',
'Use "nupst ups add" to add more UPS devices and migrate',
'to the multi-UPS configuration format.',
]
),
], 60, 'warning');
logger.logBox(
'UPS Devices',
[
'Legacy single-UPS configuration detected.',
'',
...(!config.snmp ? ['Error: Configuration missing SNMP settings'] : [
'Default UPS:',
` Host: ${config.snmp.host}:${config.snmp.port}`,
` Model: ${config.snmp.upsModel || 'cyberpower'}`,
'',
'Use "nupst ups add" to add more UPS devices and migrate',
'to the multi-UPS configuration format.',
]),
],
60,
'warning',
);
return;
}
// Display UPS list with modern table
if (config.upsDevices.length === 0) {
logger.logBox('UPS Devices', [
'No UPS devices configured.',
'',
`${theme.dim('Run')} ${theme.command('nupst ups add')} ${theme.dim('to add a device')}`,
], 60, 'info');
logger.logBox(
'UPS Devices',
[
'No UPS devices configured.',
'',
`${theme.dim('Run')} ${theme.command('nupst ups add')} ${theme.dim('to add a device')}`,
],
60,
'info',
);
return;
}
// Prepare table data
const rows = config.upsDevices.map((ups) => ({
id: ups.id,
name: ups.name || '',
host: `${ups.snmp.host}:${ups.snmp.port}`,
model: ups.snmp.upsModel || 'cyberpower',
groups: ups.groups.length > 0 ? ups.groups.join(', ') : theme.dim('None'),
}));
const rows = config.upsDevices.map((ups) => {
const protocol = ups.protocol || 'snmp';
let host = 'N/A';
let model = '';
if (protocol === 'upsd' && ups.upsd) {
host = `${ups.upsd.host}:${ups.upsd.port}`;
model = `NUT:${ups.upsd.upsName}`;
} else if (ups.snmp) {
host = `${ups.snmp.host}:${ups.snmp.port}`;
model = ups.snmp.upsModel || 'cyberpower';
}
return {
id: ups.id,
name: ups.name || '',
protocol: protocol.toUpperCase(),
host,
model,
groups: ups.groups.length > 0 ? ups.groups.join(', ') : theme.dim('None'),
};
});
const columns: ITableColumn[] = [
{ header: 'ID', key: 'id', align: 'left', color: theme.highlight },
{ header: 'Name', key: 'name', align: 'left' },
{ header: 'Protocol', key: 'protocol', align: 'left' },
{ header: 'Host:Port', key: 'host', align: 'left', color: theme.info },
{ header: 'Model', key: 'model', align: 'left' },
{ header: 'Groups', key: 'groups', align: 'left' },
@@ -467,56 +559,79 @@ export class UpsHandler {
* @param config Current configuration or individual UPS configuration
*/
private displayTestConfig(config: IUpsConfig | INupstConfig): void {
// Check if this is a UPS device or full configuration
const isUpsConfig = config.snmp;
const snmpConfig = isUpsConfig ? config.snmp : config.snmp || {};
const checkInterval = config.checkInterval || 30000;
// Type guard: IUpsConfig has 'id' and 'name' at root level, INupstConfig doesn't
const isUpsConfig = 'id' in config && 'name' in config;
// Get UPS name and ID if available
const upsName = config.name ? config.name : 'Default UPS';
const upsId = config.id ? config.id : 'default';
const checkInterval = isUpsConfig ? 30000 : (config as INupstConfig).checkInterval || 30000;
const upsName = isUpsConfig ? (config as IUpsConfig).name : 'Default UPS';
const upsId = isUpsConfig ? (config as IUpsConfig).id : 'default';
const protocol = isUpsConfig ? ((config as IUpsConfig).protocol || 'snmp') : 'snmp';
const boxWidth = 45;
logger.logBoxTitle(`Testing Configuration: ${upsName}`, boxWidth);
logger.logBoxLine(`UPS ID: ${upsId}`);
logger.logBoxLine('SNMP Settings:');
logger.logBoxLine(` Host: ${snmpConfig.host}`);
logger.logBoxLine(` Port: ${snmpConfig.port}`);
logger.logBoxLine(` Version: ${snmpConfig.version}`);
logger.logBoxLine(` UPS Model: ${snmpConfig.upsModel || 'cyberpower'}`);
logger.logBoxLine(`Protocol: ${protocol.toUpperCase()}`);
if (snmpConfig.version === 1 || snmpConfig.version === 2) {
logger.logBoxLine(` Community: ${snmpConfig.community}`);
} else if (snmpConfig.version === 3) {
logger.logBoxLine(` Security Level: ${snmpConfig.securityLevel}`);
logger.logBoxLine(` Username: ${snmpConfig.username}`);
if (protocol === 'upsd' && isUpsConfig && (config as IUpsConfig).upsd) {
const upsdConfig = (config as IUpsConfig).upsd!;
logger.logBoxLine('UPSD/NIS Settings:');
logger.logBoxLine(` Host: ${upsdConfig.host}`);
logger.logBoxLine(` Port: ${upsdConfig.port}`);
logger.logBoxLine(` UPS Name: ${upsdConfig.upsName}`);
logger.logBoxLine(` Timeout: ${upsdConfig.timeout / 1000} seconds`);
if (upsdConfig.username) {
logger.logBoxLine(` Auth: ${upsdConfig.username}`);
}
} else {
// SNMP display
const snmpConfig: ISnmpConfig | undefined = isUpsConfig
? (config as IUpsConfig).snmp
: (config as INupstConfig).snmp;
// Show auth and privacy details based on security level
if (snmpConfig.securityLevel === 'authNoPriv' || snmpConfig.securityLevel === 'authPriv') {
logger.logBoxLine(` Auth Protocol: ${snmpConfig.authProtocol || 'None'}`);
if (!snmpConfig) {
logger.logBoxLine('SNMP Settings: Not configured');
logger.logBoxEnd();
return;
}
if (snmpConfig.securityLevel === 'authPriv') {
logger.logBoxLine(` Privacy Protocol: ${snmpConfig.privProtocol || 'None'}`);
logger.logBoxLine('SNMP Settings:');
logger.logBoxLine(` Host: ${snmpConfig.host}`);
logger.logBoxLine(` Port: ${snmpConfig.port}`);
logger.logBoxLine(` Version: ${snmpConfig.version}`);
logger.logBoxLine(` UPS Model: ${snmpConfig.upsModel || 'cyberpower'}`);
if (snmpConfig.version === 1 || snmpConfig.version === 2) {
logger.logBoxLine(` Community: ${snmpConfig.community}`);
} else if (snmpConfig.version === 3) {
logger.logBoxLine(` Security Level: ${snmpConfig.securityLevel}`);
logger.logBoxLine(` Username: ${snmpConfig.username}`);
if (snmpConfig.securityLevel === 'authNoPriv' || snmpConfig.securityLevel === 'authPriv') {
logger.logBoxLine(` Auth Protocol: ${snmpConfig.authProtocol || 'None'}`);
}
if (snmpConfig.securityLevel === 'authPriv') {
logger.logBoxLine(` Privacy Protocol: ${snmpConfig.privProtocol || 'None'}`);
}
logger.logBoxLine(` Timeout: ${snmpConfig.timeout / 1000} seconds`);
}
// Show timeout value
logger.logBoxLine(` Timeout: ${snmpConfig.timeout / 1000} seconds`);
if (snmpConfig.upsModel === 'custom' && snmpConfig.customOIDs) {
logger.logBoxLine('Custom OIDs:');
logger.logBoxLine(` Power Status: ${snmpConfig.customOIDs.POWER_STATUS || 'Not set'}`);
logger.logBoxLine(
` Battery Capacity: ${snmpConfig.customOIDs.BATTERY_CAPACITY || 'Not set'}`,
);
logger.logBoxLine(` Battery Runtime: ${snmpConfig.customOIDs.BATTERY_RUNTIME || 'Not set'}`);
}
}
// Show OIDs if custom model is selected
if (snmpConfig.upsModel === 'custom' && snmpConfig.customOIDs) {
logger.logBoxLine('Custom OIDs:');
logger.logBoxLine(` Power Status: ${snmpConfig.customOIDs.POWER_STATUS || 'Not set'}`);
logger.logBoxLine(
` Battery Capacity: ${snmpConfig.customOIDs.BATTERY_CAPACITY || 'Not set'}`,
);
logger.logBoxLine(` Battery Runtime: ${snmpConfig.customOIDs.BATTERY_RUNTIME || 'Not set'}`);
}
// Show group assignments if this is a UPS config
if (config.groups && Array.isArray(config.groups)) {
if (isUpsConfig) {
const groups = (config as IUpsConfig).groups;
logger.logBoxLine(
`Group Assignments: ${config.groups.length === 0 ? 'None' : config.groups.join(', ')}`,
`Group Assignments: ${groups.length === 0 ? 'None' : groups.join(', ')}`,
);
}
@@ -529,20 +644,39 @@ export class UpsHandler {
* @param config Current UPS configuration or legacy config
*/
private async testConnection(config: IUpsConfig | INupstConfig): Promise<void> {
const upsId = config.id || 'default';
const upsName = config.name || 'Default UPS';
logger.log(`\nTesting connection to UPS: ${upsName} (${upsId})...`);
// Type guard: IUpsConfig has 'id' and 'name' at root level
const isUpsConfig = 'id' in config && 'name' in config;
const upsId = isUpsConfig ? (config as IUpsConfig).id : 'default';
const upsName = isUpsConfig ? (config as IUpsConfig).name : 'Default UPS';
const protocol = isUpsConfig ? ((config as IUpsConfig).protocol || 'snmp') : 'snmp';
logger.log(`\nTesting connection to UPS: ${upsName} (${upsId}) via ${protocol.toUpperCase()}...`);
try {
// Create a test config with a short timeout
const snmpConfig = config.snmp ? config.snmp : config.snmp;
let status: ISnmpUpsStatus;
const testConfig = {
...snmpConfig,
timeout: Math.min(snmpConfig.timeout, 10000), // Use at most 10 seconds for testing
};
if (protocol === 'upsd' && isUpsConfig && (config as IUpsConfig).upsd) {
const upsdConfig = (config as IUpsConfig).upsd!;
const testConfig = {
...upsdConfig,
timeout: Math.min(upsdConfig.timeout, 10000),
};
status = await this.nupst.getUpsd().getUpsStatus(testConfig);
} else {
// SNMP protocol
const snmpConfig: ISnmpConfig | undefined = isUpsConfig
? (config as IUpsConfig).snmp
: (config as INupstConfig).snmp;
const status = await this.nupst.getSnmp().getUpsStatus(testConfig);
if (!snmpConfig) {
throw new Error('SNMP configuration not found');
}
const testConfig: ISnmpConfig = {
...snmpConfig,
timeout: Math.min(snmpConfig.timeout, 10000),
};
status = await this.nupst.getSnmp().getUpsStatus(testConfig);
}
const boxWidth = 45;
logger.logBoxTitle(`Connection Successful: ${upsName}`, boxWidth);
@@ -551,8 +685,6 @@ export class UpsHandler {
logger.logBoxLine(` Battery Capacity: ${status.batteryCapacity}%`);
logger.logBoxLine(` Runtime Remaining: ${status.batteryRuntime} minutes`);
logger.logBoxEnd();
} catch (error) {
const errorBoxWidth = 45;
logger.logBoxTitle(`Connection Failed: ${upsName}`, errorBoxWidth);
@@ -675,17 +807,17 @@ export class UpsHandler {
if (secLevel === 1) {
snmpConfig.securityLevel = 'noAuthNoPriv';
// No auth, no priv - clear out authentication and privacy settings
snmpConfig.authProtocol = '';
snmpConfig.authKey = '';
snmpConfig.privProtocol = '';
snmpConfig.privKey = '';
snmpConfig.authProtocol = undefined;
snmpConfig.authKey = undefined;
snmpConfig.privProtocol = undefined;
snmpConfig.privKey = undefined;
// Set appropriate timeout for security level
snmpConfig.timeout = 5000; // 5 seconds for basic security
} else if (secLevel === 2) {
snmpConfig.securityLevel = 'authNoPriv';
// Auth, no priv - clear out privacy settings
snmpConfig.privProtocol = '';
snmpConfig.privKey = '';
snmpConfig.privProtocol = undefined;
snmpConfig.privKey = undefined;
// Set appropriate timeout for security level
snmpConfig.timeout = 10000; // 10 seconds for authentication
} else {
@@ -825,20 +957,116 @@ export class UpsHandler {
logger.info('Enter custom OIDs for your UPS:');
logger.dim('(Leave blank to use standard RFC 1628 OIDs as fallback)');
// Custom OIDs
// Custom OIDs - prompt for essential OIDs
const powerStatusOID = await prompt('Power Status OID: ');
const batteryCapacityOID = await prompt('Battery Capacity OID: ');
const batteryRuntimeOID = await prompt('Battery Runtime OID: ');
// Create custom OIDs object
// Create custom OIDs object with all required fields
// Empty strings will use RFC 1628 fallback for non-essential OIDs
snmpConfig.customOIDs = {
POWER_STATUS: powerStatusOID.trim(),
BATTERY_CAPACITY: batteryCapacityOID.trim(),
BATTERY_RUNTIME: batteryRuntimeOID.trim(),
OUTPUT_LOAD: '',
OUTPUT_POWER: '',
OUTPUT_VOLTAGE: '',
OUTPUT_CURRENT: '',
};
}
}
/**
* Gather UPSD/NIS connection settings
* @param upsdConfig UPSD configuration object to update
* @param prompt Function to prompt for user input
*/
private async gatherUpsdSettings(
upsdConfig: IUpsdConfig,
prompt: (question: string) => Promise<string>,
): Promise<void> {
logger.log('');
logger.info('UPSD/NIS Connection Settings:');
logger.dim('Connect to a local NUT (Network UPS Tools) server');
// Host
const defaultHost = upsdConfig.host || '127.0.0.1';
const host = await prompt(`UPSD Host [${defaultHost}]: `);
upsdConfig.host = host.trim() || defaultHost;
// Port
const defaultPort = upsdConfig.port || UPSD.DEFAULT_PORT;
const portInput = await prompt(`UPSD Port [${defaultPort}]: `);
const port = parseInt(portInput, 10);
upsdConfig.port = portInput.trim() && !isNaN(port) ? port : defaultPort;
// UPS Name
const defaultUpsName = upsdConfig.upsName || UPSD.DEFAULT_UPS_NAME;
const upsName = await prompt(`NUT UPS Name [${defaultUpsName}]: `);
upsdConfig.upsName = upsName.trim() || defaultUpsName;
// Timeout
const defaultTimeout = (upsdConfig.timeout || UPSD.DEFAULT_TIMEOUT_MS) / 1000;
const timeoutInput = await prompt(`Timeout in seconds [${defaultTimeout}]: `);
const timeout = parseInt(timeoutInput, 10);
if (timeoutInput.trim() && !isNaN(timeout)) {
upsdConfig.timeout = timeout * 1000;
}
// Authentication (optional)
logger.log('');
logger.info('Authentication (optional):');
logger.dim('Leave blank if your NUT server does not require authentication');
const username = await prompt(`Username [${upsdConfig.username || ''}]: `);
if (username.trim()) {
upsdConfig.username = username.trim();
const password = await prompt(`Password: `);
if (password.trim()) {
upsdConfig.password = password.trim();
}
}
}
/**
* Optionally test UPSD connection
* @param upsdConfig UPSD configuration to test
* @param prompt Function to prompt for user input
*/
private async optionallyTestUpsdConnection(
upsdConfig: IUpsdConfig,
prompt: (question: string) => Promise<string>,
): Promise<void> {
const testConnection = await prompt(
'Would you like to test the connection to your UPS? (y/N): ',
);
if (testConnection.toLowerCase() === 'y') {
logger.log('\nTesting connection to UPSD server...');
try {
const testConfig = {
...upsdConfig,
timeout: Math.min(upsdConfig.timeout, 10000),
};
const status = await this.nupst.getUpsd().getUpsStatus(testConfig);
const boxWidth = 45;
logger.log('');
logger.logBoxTitle('Connection Successful!', boxWidth);
logger.logBoxLine('UPS Status:');
logger.logBoxLine(`✓ Power Status: ${status.powerStatus}`);
logger.logBoxLine(`✓ Battery Capacity: ${status.batteryCapacity}%`);
logger.logBoxLine(`✓ Runtime Remaining: ${status.batteryRuntime} minutes`);
logger.logBoxEnd();
} catch (error) {
const errorBoxWidth = 45;
logger.log('');
logger.logBoxTitle('Connection Failed!', errorBoxWidth);
logger.logBoxLine(`Error: ${error instanceof Error ? error.message : String(error)}`);
logger.logBoxEnd();
logger.log('\nPlease check your NUT server settings and try again.');
}
}
}
/**
* Gather action configuration settings
* @param actions Actions array to configure
@@ -868,6 +1096,7 @@ export class UpsHandler {
logger.dim(' 1) Shutdown (system shutdown)');
logger.dim(' 2) Webhook (HTTP notification)');
logger.dim(' 3) Custom Script (run .sh file from /etc/nupst)');
logger.dim(' 4) Proxmox (gracefully shut down VMs/LXCs before host shutdown)');
const typeInput = await prompt('Select action type [1]: ');
const typeValue = parseInt(typeInput, 10) || 1;
@@ -922,6 +1151,61 @@ export class UpsHandler {
if (timeoutInput.trim() && !isNaN(timeout)) {
action.scriptTimeout = timeout * 1000; // Convert to ms
}
} else if (typeValue === 4) {
// Proxmox action
action.type = 'proxmox';
logger.log('');
logger.info('Proxmox API Settings:');
logger.dim('Requires a Proxmox API token. Create one with:');
logger.dim(' pveum user token add root@pam nupst --privsep=0');
const pxHost = await prompt('Proxmox Host [localhost]: ');
action.proxmoxHost = pxHost.trim() || 'localhost';
const pxPortInput = await prompt('Proxmox API Port [8006]: ');
const pxPort = parseInt(pxPortInput, 10);
action.proxmoxPort = pxPortInput.trim() && !isNaN(pxPort) ? pxPort : 8006;
const pxNode = await prompt('Proxmox Node Name (empty = auto-detect via hostname): ');
if (pxNode.trim()) {
action.proxmoxNode = pxNode.trim();
}
const tokenId = await prompt('API Token ID (e.g., root@pam!nupst): ');
if (!tokenId.trim()) {
logger.warn('Token ID is required for 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): ');
if (excludeInput.trim()) {
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 stopTimeout = parseInt(timeoutInput, 10);
if (timeoutInput.trim() && !isNaN(stopTimeout)) {
action.proxmoxStopTimeout = stopTimeout;
}
const forceInput = await prompt('Force-stop VMs that don\'t shut down in time? (Y/n): ');
action.proxmoxForceStop = forceInput.toLowerCase() !== 'n';
const insecureInput = await prompt('Skip TLS verification (self-signed cert)? (Y/n): ');
action.proxmoxInsecure = insecureInput.toLowerCase() !== 'n';
logger.log('');
logger.info('Note: Place the Proxmox action BEFORE the shutdown action');
logger.dim('in the action chain so VMs shut down before the host.');
} else {
logger.warn('Invalid action type, skipping');
continue;
@@ -952,10 +1236,15 @@ export class UpsHandler {
}
// Configure thresholds if needed for onlyThresholds or powerChangesAndThresholds modes
if (action.triggerMode === 'onlyThresholds' || action.triggerMode === 'powerChangesAndThresholds') {
if (
action.triggerMode === 'onlyThresholds' ||
action.triggerMode === 'powerChangesAndThresholds'
) {
logger.log('');
logger.info('Action Thresholds:');
logger.dim('Action will trigger when battery or runtime falls below these values (while on battery)');
logger.dim(
'Action will trigger when battery or runtime falls below these values (while on battery)',
);
const batteryInput = await prompt('Battery threshold percentage [60]: ');
const battery = parseInt(batteryInput, 10);
@@ -972,7 +1261,11 @@ export class UpsHandler {
}
actions.push(action as IActionConfig);
logger.success(`${action.type!.charAt(0).toUpperCase() + action.type!.slice(1)} action added (mode: ${action.triggerMode || 'powerChangesAndThresholds'})`);
logger.success(
`${action.type!.charAt(0).toUpperCase() + action.type!.slice(1)} action added (mode: ${
action.triggerMode || 'powerChangesAndThresholds'
})`,
);
const more = await prompt('Add another action? (y/N): ');
addMore = more.toLowerCase() === 'y';
@@ -990,12 +1283,20 @@ export class UpsHandler {
*/
private displayUpsConfigSummary(ups: IUpsConfig): void {
const boxWidth = 45;
const protocol = ups.protocol || 'snmp';
logger.log('');
logger.logBoxTitle(`UPS Configuration: ${ups.name}`, boxWidth);
logger.logBoxLine(`UPS ID: ${ups.id}`);
logger.logBoxLine(`SNMP Host: ${ups.snmp.host}:${ups.snmp.port}`);
logger.logBoxLine(`SNMP Version: ${ups.snmp.version}`);
logger.logBoxLine(`UPS Model: ${ups.snmp.upsModel}`);
logger.logBoxLine(`Protocol: ${protocol.toUpperCase()}`);
if (protocol === 'upsd' && ups.upsd) {
logger.logBoxLine(`UPSD Host: ${ups.upsd.host}:${ups.upsd.port}`);
logger.logBoxLine(`NUT UPS Name: ${ups.upsd.upsName}`);
} else if (ups.snmp) {
logger.logBoxLine(`SNMP Host: ${ups.snmp.host}:${ups.snmp.port}`);
logger.logBoxLine(`SNMP Version: ${ups.snmp.version}`);
logger.logBoxLine(`UPS Model: ${ups.snmp.upsModel}`);
}
if (ups.groups && ups.groups.length > 0) {
logger.logBoxLine(`Groups: ${ups.groups.join(', ')}`);

View File

@@ -75,12 +75,14 @@ export function getRuntimeColor(minutes: number): (text: string) => string {
/**
* Format UPS power status with color
*/
export function formatPowerStatus(status: 'online' | 'onBattery' | 'unknown'): string {
export function formatPowerStatus(status: 'online' | 'onBattery' | 'unknown' | 'unreachable'): string {
switch (status) {
case 'online':
return theme.success('Online');
case 'onBattery':
return theme.warning('On Battery');
case 'unreachable':
return theme.error('Unreachable');
case 'unknown':
default:
return theme.dim('Unknown');

View File

@@ -103,6 +103,62 @@ export const HTTP_SERVER = {
DEFAULT_PATH: '/ups-status',
} as const;
/**
* Network failure detection constants
*/
export const NETWORK = {
/** Number of consecutive failures before marking UPS as unreachable */
CONSECUTIVE_FAILURE_THRESHOLD: 3,
/** Maximum tracked consecutive failures (prevents overflow) */
MAX_CONSECUTIVE_FAILURES: 100,
} as const;
/**
* UPSD/NIS protocol constants
*/
export const UPSD = {
/** Default UPSD port (NUT standard) */
DEFAULT_PORT: 3493,
/** Default timeout in milliseconds */
DEFAULT_TIMEOUT_MS: 5000,
/** Default NUT device name */
DEFAULT_UPS_NAME: 'ups',
} as const;
/**
* Pause/resume constants
*/
export const PAUSE = {
/** Path to the pause state file */
FILE_PATH: '/etc/nupst/pause',
/** Maximum pause duration (24 hours) */
MAX_DURATION_MS: 24 * 60 * 60 * 1000,
} as const;
/**
* Proxmox VM shutdown constants
*/
export const PROXMOX = {
/** Default Proxmox API port */
DEFAULT_PORT: 8006,
/** Default Proxmox host */
DEFAULT_HOST: 'localhost',
/** Default timeout for VM/CT shutdown in seconds */
DEFAULT_STOP_TIMEOUT_SECONDS: 120,
/** Poll interval for checking VM/CT status in seconds */
STATUS_POLL_INTERVAL_SECONDS: 5,
/** Proxmox API base path */
API_BASE: '/api2/json',
} as const;
/**
* UI/Display constants
*/

View File

@@ -5,13 +5,17 @@ import { exec, execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { NupstSnmp } from './snmp/manager.ts';
import type { ISnmpConfig, IUpsStatus as ISnmpUpsStatus } from './snmp/types.ts';
import { logger, type ITableColumn } from './logger.ts';
import { NupstUpsd } from './upsd/client.ts';
import type { IUpsdConfig } from './upsd/types.ts';
import type { TProtocol } from './protocol/types.ts';
import { ProtocolResolver } from './protocol/resolver.ts';
import { logger } from './logger.ts';
import { MigrationRunner } from './migrations/index.ts';
import { theme, symbols, getBatteryColor, getRuntimeColor, formatPowerStatus } from './colors.ts';
import { formatPowerStatus, getBatteryColor, getRuntimeColor, theme } from './colors.ts';
import type { IActionConfig } from './actions/base-action.ts';
import { ActionManager, type IActionContext, type TPowerStatus } from './actions/index.ts';
import { NupstHttpServer } from './http-server.ts';
import { TIMING, THRESHOLDS, UI } from './constants.ts';
import { NETWORK, PAUSE, THRESHOLDS, TIMING, UI } from './constants.ts';
const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);
@@ -24,8 +28,12 @@ export interface IUpsConfig {
id: string;
/** Friendly name for the UPS */
name: string;
/** SNMP configuration settings */
snmp: ISnmpConfig;
/** Communication protocol (defaults to 'snmp') */
protocol?: TProtocol;
/** SNMP configuration settings (required for 'snmp' protocol) */
snmp?: ISnmpConfig;
/** UPSD/NIS configuration settings (required for 'upsd' protocol) */
upsd?: IUpsdConfig;
/** Group IDs this UPS belongs to */
groups: string[];
/** Actions to trigger on power status changes and threshold violations */
@@ -62,6 +70,20 @@ export interface IHttpServerConfig {
authToken: string;
}
/**
* Pause state interface
*/
export interface IPauseState {
/** Timestamp when pause was activated */
pausedAt: number;
/** Who initiated the pause (e.g., 'cli', 'api') */
pausedBy: string;
/** Optional reason for pausing */
reason?: string;
/** When to auto-resume (null = indefinite, timestamp in ms) */
resumeAt?: number | null;
}
/**
* Configuration interface for the daemon
*/
@@ -97,15 +119,17 @@ export interface INupstConfig {
export interface IUpsStatus {
id: string;
name: string;
powerStatus: 'online' | 'onBattery' | 'unknown';
powerStatus: 'online' | 'onBattery' | 'unknown' | 'unreachable';
batteryCapacity: number;
batteryRuntime: number;
outputLoad: number; // Load percentage (0-100%)
outputPower: number; // Power in watts
outputVoltage: number; // Voltage in volts
outputCurrent: number; // Current in amps
outputLoad: number; // Load percentage (0-100%)
outputPower: number; // Power in watts
outputVoltage: number; // Voltage in volts
outputCurrent: number; // Current in amps
lastStatusChange: number;
lastCheckTime: number;
consecutiveFailures: number;
unreachableSince: number;
}
/**
@@ -155,19 +179,25 @@ export class NupstDaemon {
],
groups: [],
checkInterval: TIMING.CHECK_INTERVAL_MS, // Check every 30 seconds
}
};
private config: INupstConfig;
private snmp: NupstSnmp;
private upsd: NupstUpsd;
private protocolResolver: ProtocolResolver;
private isRunning: boolean = false;
private isPaused: boolean = false;
private pauseState: IPauseState | null = null;
private upsStatus: Map<string, IUpsStatus> = new Map();
private httpServer?: NupstHttpServer;
/**
* Create a new daemon instance with the given SNMP manager
* Create a new daemon instance with the given protocol managers
*/
constructor(snmp: NupstSnmp) {
constructor(snmp: NupstSnmp, upsd: NupstUpsd) {
this.snmp = snmp;
this.upsd = upsd;
this.protocolResolver = new ProtocolResolver(snmp, upsd);
this.config = this.DEFAULT_CONFIG;
}
@@ -230,10 +260,11 @@ export class NupstDaemon {
// Ensure version is always set and remove legacy fields before saving
const configToSave: INupstConfig = {
version: '4.1',
version: '4.2',
upsDevices: config.upsDevices,
groups: config.groups,
checkInterval: config.checkInterval,
...(config.httpServer ? { httpServer: config.httpServer } : {}),
};
fs.writeFileSync(this.CONFIG_PATH, JSON.stringify(configToSave, null, 2));
@@ -249,7 +280,12 @@ export class NupstDaemon {
* Helper method to log configuration errors consistently
*/
private logConfigError(message: string): void {
logger.logBox('Configuration Error', [message, "Please run 'nupst setup' first to create a configuration."], 45, 'error');
logger.logBox(
'Configuration Error',
[message, "Please run 'nupst setup' first to create a configuration."],
45,
'error',
);
}
/**
@@ -266,6 +302,13 @@ export class NupstDaemon {
return this.snmp;
}
/**
* Get the UPSD instance
*/
public getNupstUpsd(): NupstUpsd {
return this.upsd;
}
/**
* Start the monitoring daemon
*/
@@ -311,11 +354,16 @@ export class NupstDaemon {
this.config.httpServer.port,
this.config.httpServer.path,
this.config.httpServer.authToken,
() => this.upsStatus
() => this.upsStatus,
() => this.pauseState,
);
this.httpServer.start();
} catch (error) {
logger.error(`Failed to start HTTP server: ${error instanceof Error ? error.message : String(error)}`);
logger.error(
`Failed to start HTTP server: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
@@ -351,6 +399,8 @@ export class NupstDaemon {
outputCurrent: 0,
lastStatusChange: Date.now(),
lastCheckTime: 0,
consecutiveFailures: 0,
unreachableSince: 0,
});
}
@@ -364,7 +414,6 @@ export class NupstDaemon {
* Log the loaded configuration settings
*/
private logConfigLoaded(): void {
logger.log('');
logger.logBoxTitle('Configuration Loaded', 70, 'success');
logger.logBoxLine(`Check Interval: ${this.config.checkInterval / 1000} seconds`);
@@ -375,19 +424,32 @@ export class NupstDaemon {
if (this.config.upsDevices && this.config.upsDevices.length > 0) {
logger.info(`UPS Devices (${this.config.upsDevices.length}):`);
const upsColumns: Array<{ header: string; key: string; align?: 'left' | 'right'; color?: (val: string) => string }> = [
const upsColumns: Array<
{ header: string; key: string; align?: 'left' | 'right'; color?: (val: string) => string }
> = [
{ header: 'Name', key: 'name', align: 'left', color: theme.highlight },
{ header: 'ID', key: 'id', align: 'left', color: theme.dim },
{ header: 'Protocol', key: 'protocol', align: 'left' },
{ header: 'Host:Port', key: 'host', align: 'left', color: theme.info },
{ header: 'Actions', key: 'actions', align: 'left' },
];
const upsRows: Array<Record<string, string>> = this.config.upsDevices.map((ups) => ({
name: ups.name,
id: ups.id,
host: `${ups.snmp.host}:${ups.snmp.port}`,
actions: `${(ups.actions || []).length} configured`,
}));
const upsRows: Array<Record<string, string>> = this.config.upsDevices.map((ups) => {
const protocol = ups.protocol || 'snmp';
let host = 'N/A';
if (protocol === 'upsd' && ups.upsd) {
host = `${ups.upsd.host}:${ups.upsd.port}`;
} else if (ups.snmp) {
host = `${ups.snmp.host}:${ups.snmp.port}`;
}
return {
name: ups.name,
id: ups.id,
protocol: protocol.toUpperCase(),
host,
actions: `${(ups.actions || []).length} configured`,
};
});
logger.logTable(upsColumns, upsRows);
logger.log('');
@@ -400,7 +462,9 @@ export class NupstDaemon {
if (this.config.groups && this.config.groups.length > 0) {
logger.info(`Groups (${this.config.groups.length}):`);
const groupColumns: Array<{ header: string; key: string; align?: 'left' | 'right'; color?: (val: string) => string }> = [
const groupColumns: Array<
{ header: string; key: string; align?: 'left' | 'right'; color?: (val: string) => string }
> = [
{ header: 'Name', key: 'name', align: 'left', color: theme.highlight },
{ header: 'ID', key: 'id', align: 'left', color: theme.dim },
{ header: 'Mode', key: 'mode', align: 'left', color: theme.info },
@@ -431,6 +495,79 @@ export class NupstDaemon {
this.isRunning = false;
}
/**
* Get the current pause state
*/
public getPauseState(): IPauseState | null {
return this.pauseState;
}
/**
* Check and update pause state from the pause file
*/
private checkPauseState(): void {
try {
if (fs.existsSync(PAUSE.FILE_PATH)) {
const data = fs.readFileSync(PAUSE.FILE_PATH, 'utf8');
const state = JSON.parse(data) as IPauseState;
// Check if auto-resume time has passed
if (state.resumeAt && Date.now() >= state.resumeAt) {
// Auto-resume: delete the pause file
try {
fs.unlinkSync(PAUSE.FILE_PATH);
} catch (_e) {
// Ignore deletion errors
}
if (this.isPaused) {
logger.log('');
logger.logBoxTitle('Auto-Resume', 45, 'success');
logger.logBoxLine('Pause duration expired, resuming action monitoring');
logger.logBoxEnd();
logger.log('');
}
this.isPaused = false;
this.pauseState = null;
return;
}
if (!this.isPaused) {
logger.log('');
logger.logBoxTitle('Actions Paused', 45, 'warning');
logger.logBoxLine(`Paused by: ${state.pausedBy}`);
if (state.reason) {
logger.logBoxLine(`Reason: ${state.reason}`);
}
if (state.resumeAt) {
const remaining = Math.round((state.resumeAt - Date.now()) / 1000);
logger.logBoxLine(`Auto-resume in: ${remaining} seconds`);
} else {
logger.logBoxLine('Duration: Indefinite (run "nupst resume" to resume)');
}
logger.logBoxEnd();
logger.log('');
}
this.isPaused = true;
this.pauseState = state;
} else {
if (this.isPaused) {
logger.log('');
logger.logBoxTitle('Actions Resumed', 45, 'success');
logger.logBoxLine('Action monitoring has been resumed');
logger.logBoxEnd();
logger.log('');
}
this.isPaused = false;
this.pauseState = null;
}
} catch (_error) {
// If we can't read the pause file, assume not paused
this.isPaused = false;
this.pauseState = null;
}
}
/**
* Monitor the UPS status and trigger shutdown when necessary
*/
@@ -449,7 +586,10 @@ export class NupstDaemon {
// Monitor continuously
while (this.isRunning) {
try {
// Check all UPS devices
// Check pause state before each cycle
this.checkPauseState();
// Check all UPS devices (polling continues even when paused for visibility)
await this.checkAllUpsDevices();
// Log periodic status update
@@ -493,16 +633,24 @@ export class NupstDaemon {
outputCurrent: 0,
lastStatusChange: Date.now(),
lastCheckTime: 0,
consecutiveFailures: 0,
unreachableSince: 0,
});
}
// Check UPS status
const status = await this.snmp.getUpsStatus(ups.snmp);
// Check UPS status via configured protocol
const protocol = ups.protocol || 'snmp';
const status = protocol === 'upsd' && ups.upsd
? await this.protocolResolver.getUpsStatus('upsd', undefined, ups.upsd)
: await this.protocolResolver.getUpsStatus('snmp', ups.snmp);
const currentTime = Date.now();
// Get the current status from the map
const currentStatus = this.upsStatus.get(ups.id);
// Successful query: reset consecutive failures
const wasUnreachable = currentStatus?.powerStatus === 'unreachable';
// Update status with new values
const updatedStatus: IUpsStatus = {
id: ups.id,
@@ -516,10 +664,27 @@ export class NupstDaemon {
outputCurrent: status.outputCurrent,
lastCheckTime: currentTime,
lastStatusChange: currentStatus?.lastStatusChange || currentTime,
consecutiveFailures: 0,
unreachableSince: 0,
};
// Check if power status changed
if (currentStatus && currentStatus.powerStatus !== status.powerStatus) {
// If UPS was unreachable and is now reachable, log recovery
if (wasUnreachable && currentStatus) {
const downtime = Math.round((currentTime - currentStatus.unreachableSince) / 1000);
logger.log('');
logger.logBoxTitle(`UPS Recovered: ${ups.name}`, 60, 'success');
logger.logBoxLine(`UPS is reachable again after ${downtime} seconds`);
logger.logBoxLine(`Current Status: ${formatPowerStatus(status.powerStatus)}`);
logger.logBoxLine(`Time: ${new Date().toISOString()}`);
logger.logBoxEnd();
logger.log('');
updatedStatus.lastStatusChange = currentTime;
// Trigger power status change action for recovery
await this.triggerUpsActions(ups, updatedStatus, currentStatus, 'powerStatusChange');
} else if (currentStatus && currentStatus.powerStatus !== status.powerStatus) {
// Check if power status changed
logger.log('');
logger.logBoxTitle(`Power Status Change: ${ups.name}`, 60, 'warning');
logger.logBoxLine(`Previous: ${formatPowerStatus(currentStatus.powerStatus)}`);
@@ -561,11 +726,48 @@ export class NupstDaemon {
// Update the status in the map
this.upsStatus.set(ups.id, updatedStatus);
} catch (error) {
// Network loss / query failure tracking
const currentStatus = this.upsStatus.get(ups.id);
const failures = Math.min(
(currentStatus?.consecutiveFailures || 0) + 1,
NETWORK.MAX_CONSECUTIVE_FAILURES,
);
logger.error(
`Error checking UPS ${ups.name} (${ups.id}): ${
`Error checking UPS ${ups.name} (${ups.id}) [failure ${failures}/${NETWORK.CONSECUTIVE_FAILURE_THRESHOLD}]: ${
error instanceof Error ? error.message : String(error)
}`,
);
// Transition to unreachable after threshold consecutive failures
if (
failures >= NETWORK.CONSECUTIVE_FAILURE_THRESHOLD &&
currentStatus &&
currentStatus.powerStatus !== 'unreachable'
) {
const currentTime = Date.now();
const previousStatus = { ...currentStatus };
currentStatus.powerStatus = 'unreachable';
currentStatus.consecutiveFailures = failures;
currentStatus.unreachableSince = currentTime;
currentStatus.lastStatusChange = currentTime;
this.upsStatus.set(ups.id, currentStatus);
logger.log('');
logger.logBoxTitle(`UPS Unreachable: ${ups.name}`, 60, 'error');
logger.logBoxLine(`${failures} consecutive communication failures`);
logger.logBoxLine(`Last known status: ${formatPowerStatus(previousStatus.powerStatus)}`);
logger.logBoxLine(`Time: ${new Date().toISOString()}`);
logger.logBoxEnd();
logger.log('');
// Trigger power status change action for unreachable
await this.triggerUpsActions(ups, currentStatus, previousStatus, 'powerStatusChange');
} else if (currentStatus) {
currentStatus.consecutiveFailures = failures;
this.upsStatus.set(ups.id, currentStatus);
}
}
}
}
@@ -577,13 +779,23 @@ export class NupstDaemon {
const timestamp = new Date().toISOString();
logger.log('');
logger.logBoxTitle('Periodic Status Update', 70, 'info');
const pauseLabel = this.isPaused ? ' [PAUSED]' : '';
logger.logBoxTitle(`Periodic Status Update${pauseLabel}`, 70, this.isPaused ? 'warning' : 'info');
logger.logBoxLine(`Timestamp: ${timestamp}`);
if (this.isPaused && this.pauseState) {
logger.logBoxLine(`Actions paused by: ${this.pauseState.pausedBy}`);
if (this.pauseState.resumeAt) {
const remaining = Math.round((this.pauseState.resumeAt - Date.now()) / 1000);
logger.logBoxLine(`Auto-resume in: ${remaining > 0 ? remaining : 0} seconds`);
}
}
logger.logBoxEnd();
logger.log('');
// Build table data
const columns: Array<{ header: string; key: string; align?: 'left' | 'right'; color?: (val: string) => string }> = [
const columns: Array<
{ header: string; key: string; align?: 'left' | 'right'; color?: (val: string) => string }
> = [
{ header: 'UPS Name', key: 'name', align: 'left', color: theme.highlight },
{ header: 'ID', key: 'id', align: 'left', color: theme.dim },
{ header: 'Power Status', key: 'powerStatus', align: 'left' },
@@ -609,10 +821,6 @@ export class NupstDaemon {
logger.log('');
}
/**
* Build action context from UPS state
* @param ups UPS configuration
@@ -650,6 +858,14 @@ export class NupstDaemon {
previousStatus: IUpsStatus | undefined,
triggerReason: 'powerStatusChange' | 'thresholdViolation',
): Promise<void> {
// Check if actions are paused
if (this.isPaused) {
logger.info(
`[PAUSED] Actions suppressed for UPS ${ups.name} (trigger: ${triggerReason})`,
);
return;
}
const actions = ups.actions || [];
// Backward compatibility: if no actions configured, use default shutdown behavior
@@ -796,7 +1012,9 @@ export class NupstDaemon {
logger.log('');
logger.logBoxTitle('Shutdown Monitoring Active', UI.WIDE_BOX_WIDTH, 'warning');
logger.logBoxLine(`Emergency threshold: ${THRESHOLDS.EMERGENCY_RUNTIME_MINUTES} minutes runtime`);
logger.logBoxLine(
`Emergency threshold: ${THRESHOLDS.EMERGENCY_RUNTIME_MINUTES} minutes runtime`,
);
logger.logBoxLine(`Check interval: ${TIMING.SHUTDOWN_CHECK_INTERVAL_MS / 1000} seconds`);
logger.logBoxLine(`Max monitoring time: ${TIMING.MAX_SHUTDOWN_MONITORING_MS / 1000} seconds`);
logger.logBoxEnd();
@@ -808,7 +1026,9 @@ export class NupstDaemon {
logger.info('Checking UPS status during shutdown...');
// Build table for UPS status during shutdown
const columns: Array<{ header: string; key: string; align?: 'left' | 'right'; color?: (val: string) => string }> = [
const columns: Array<
{ header: string; key: string; align?: 'left' | 'right'; color?: (val: string) => string }
> = [
{ header: 'UPS Name', key: 'name', align: 'left', color: theme.highlight },
{ header: 'Battery', key: 'battery', align: 'right' },
{ header: 'Runtime', key: 'runtime', align: 'right' },
@@ -822,7 +1042,10 @@ export class NupstDaemon {
// Check all UPS devices
for (const ups of this.config.upsDevices) {
try {
const status = await this.snmp.getUpsStatus(ups.snmp);
const protocol = ups.protocol || 'snmp';
const status = protocol === 'upsd' && ups.upsd
? await this.protocolResolver.getUpsStatus('upsd', undefined, ups.upsd)
: await this.protocolResolver.getUpsStatus('snmp', ups.snmp);
const batteryColor = getBatteryColor(status.batteryCapacity);
const runtimeColor = getRuntimeColor(status.batteryRuntime);
@@ -991,7 +1214,9 @@ export class NupstDaemon {
let lastConfigCheck = Date.now();
logger.log('Entering idle monitoring mode...');
logger.log(`Daemon will check for config changes every ${TIMING.IDLE_CHECK_INTERVAL_MS / 1000} seconds`);
logger.log(
`Daemon will check for config changes every ${TIMING.IDLE_CHECK_INTERVAL_MS / 1000} seconds`,
);
// Start file watcher for hot-reload
this.watchConfigFile();
@@ -1049,7 +1274,7 @@ export class NupstDaemon {
logger.log('Config file watcher started');
for await (const event of watcher) {
// Only respond to modify events on the config file
// Respond to modify events on config file
if (
event.kind === 'modify' &&
event.paths.some((p) => p.includes('config.json'))
@@ -1058,6 +1283,14 @@ export class NupstDaemon {
await this.reloadConfig();
}
// Detect pause file changes
if (
(event.kind === 'create' || event.kind === 'modify' || event.kind === 'remove') &&
event.paths.some((p) => p.includes('pause'))
) {
this.checkPauseState();
}
// Stop watching if daemon stopped
if (!this.isRunning) {
break;

View File

@@ -1,7 +1,7 @@
import * as http from 'node:http';
import { URL } from 'node:url';
import { logger } from './logger.ts';
import type { IUpsStatus } from './daemon.ts';
import type { IPauseState, IUpsStatus } from './daemon.ts';
/**
* HTTP Server for exposing UPS status as JSON
@@ -13,6 +13,7 @@ export class NupstHttpServer {
private path: string;
private authToken: string;
private getUpsStatus: () => Map<string, IUpsStatus>;
private getPauseState: () => IPauseState | null;
/**
* Create a new HTTP server instance
@@ -20,17 +21,20 @@ export class NupstHttpServer {
* @param path URL path for the endpoint
* @param authToken Authentication token required for access
* @param getUpsStatus Function to retrieve cached UPS status
* @param getPauseState Function to retrieve current pause state
*/
constructor(
port: number,
path: string,
authToken: string,
getUpsStatus: () => Map<string, IUpsStatus>
getUpsStatus: () => Map<string, IUpsStatus>,
getPauseState: () => IPauseState | null,
) {
this.port = port;
this.path = path;
this.authToken = authToken;
this.getUpsStatus = getUpsStatus;
this.getPauseState = getPauseState;
}
/**
@@ -70,7 +74,7 @@ export class NupstHttpServer {
if (!this.isAuthenticated(req)) {
res.writeHead(401, {
'Content-Type': 'application/json',
'WWW-Authenticate': 'Bearer'
'WWW-Authenticate': 'Bearer',
});
res.end(JSON.stringify({ error: 'Unauthorized' }));
return;
@@ -79,12 +83,18 @@ export class NupstHttpServer {
// Get cached status (no refresh)
const statusMap = this.getUpsStatus();
const statusArray = Array.from(statusMap.values());
const pauseState = this.getPauseState();
const response = {
upsDevices: statusArray,
...(pauseState ? { paused: true, pauseState } : { paused: false }),
};
res.writeHead(200, {
'Content-Type': 'application/json',
'Cache-Control': 'no-cache'
'Cache-Control': 'no-cache',
});
res.end(JSON.stringify(statusArray, null, 2));
res.end(JSON.stringify(response, null, 2));
} else {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Not Found' }));
@@ -95,7 +105,7 @@ export class NupstHttpServer {
logger.success(`HTTP server started on port ${this.port} at ${this.path}`);
});
this.server.on('error', (error: any) => {
this.server.on('error', (error: Error) => {
logger.error(`HTTP server error: ${error.message}`);
});
}

View File

@@ -1,4 +1,4 @@
import { theme, symbols } from './colors.ts';
import { symbols, theme } from './colors.ts';
/**
* Table column alignment options
@@ -230,7 +230,8 @@ export class Logger {
* Strip ANSI color codes from string for accurate length calculation
*/
private stripAnsi(text: string): string {
// Remove ANSI escape codes
// Remove ANSI escape codes (intentional control character regex)
// deno-lint-ignore no-control-regex
return text.replace(/\x1b\[[0-9;]*m/g, '');
}

View File

@@ -31,7 +31,9 @@ export abstract class BaseMigration {
* @param config - Raw configuration object to check (unknown schema for migrations)
* @returns True if migration should run, false otherwise
*/
abstract shouldRun(config: Record<string, unknown>): Promise<boolean>;
abstract shouldRun(
config: Record<string, unknown>,
): boolean | Promise<boolean>;
/**
* Perform the migration on the given config
@@ -39,7 +41,9 @@ export abstract class BaseMigration {
* @param config - Raw configuration object to migrate (unknown schema for migrations)
* @returns Migrated configuration object
*/
abstract migrate(config: Record<string, unknown>): Promise<Record<string, unknown>>;
abstract migrate(
config: Record<string, unknown>,
): Record<string, unknown> | Promise<Record<string, unknown>>;
/**
* Get human-readable name for this migration

View File

@@ -9,3 +9,4 @@ export { MigrationRunner } from './migration-runner.ts';
export { MigrationV1ToV2 } from './migration-v1-to-v2.ts';
export { MigrationV3ToV4 } from './migration-v3-to-v4.ts';
export { MigrationV4_0ToV4_1 } from './migration-v4.0-to-v4.1.ts';
export { MigrationV4_1ToV4_2 } from './migration-v4.1-to-v4.2.ts';

View File

@@ -2,6 +2,7 @@ import { BaseMigration } from './base-migration.ts';
import { MigrationV1ToV2 } from './migration-v1-to-v2.ts';
import { MigrationV3ToV4 } from './migration-v3-to-v4.ts';
import { MigrationV4_0ToV4_1 } from './migration-v4.0-to-v4.1.ts';
import { MigrationV4_1ToV4_2 } from './migration-v4.1-to-v4.2.ts';
import { logger } from '../logger.ts';
/**
@@ -19,7 +20,7 @@ export class MigrationRunner {
new MigrationV1ToV2(),
new MigrationV3ToV4(),
new MigrationV4_0ToV4_1(),
// Add future migrations here (v4.3, v4.4, etc.)
new MigrationV4_1ToV4_2(),
];
// Sort by version order to ensure they run in sequence

View File

@@ -23,12 +23,12 @@ export class MigrationV1ToV2 extends BaseMigration {
readonly fromVersion = '1.x';
readonly toVersion = '2.0';
async shouldRun(config: any): Promise<boolean> {
shouldRun(config: Record<string, unknown>): boolean {
// V1 format has snmp field directly at root, no upsDevices or upsList
return !!config.snmp && !config.upsDevices && !config.upsList;
}
async migrate(config: any): Promise<any> {
migrate(config: Record<string, unknown>): Record<string, unknown> {
logger.info(`${this.getName()}: Converting single SNMP config to multi-UPS format...`);
const migrated = {

View File

@@ -42,15 +42,16 @@ export class MigrationV3ToV4 extends BaseMigration {
readonly fromVersion = '3.x';
readonly toVersion = '4.0';
async shouldRun(config: any): Promise<boolean> {
shouldRun(config: Record<string, unknown>): boolean {
// V3 format has upsList OR has upsDevices with flat structure (host at top level)
if (config.upsList && !config.upsDevices) {
return true; // Classic v3 with upsList
}
// Check if upsDevices exists but has flat structure (v3 format)
if (config.upsDevices && config.upsDevices.length > 0) {
const firstDevice = config.upsDevices[0];
const upsDevices = config.upsDevices as Array<Record<string, unknown>> | undefined;
if (upsDevices && upsDevices.length > 0) {
const firstDevice = upsDevices[0];
// V3 has host at top level, v4 has it nested in snmp object
return !!firstDevice.host && !firstDevice.snmp;
}
@@ -58,17 +59,17 @@ export class MigrationV3ToV4 extends BaseMigration {
return false;
}
async migrate(config: any): Promise<any> {
migrate(config: Record<string, unknown>): Record<string, unknown> {
logger.info(`${this.getName()}: Migrating v3 config to v4 format...`);
logger.dim(` - Restructuring UPS devices (flat → nested snmp config)`);
// Get devices from either upsList or upsDevices (for partially migrated configs)
const sourceDevices = config.upsList || config.upsDevices;
const sourceDevices = (config.upsList || config.upsDevices) as Array<Record<string, unknown>>;
// Transform each UPS device from v3 flat structure to v4 nested structure
const transformedDevices = sourceDevices.map((device: any) => {
const transformedDevices = sourceDevices.map((device: Record<string, unknown>) => {
// Build SNMP config object
const snmpConfig: any = {
const snmpConfig: Record<string, unknown> = {
host: device.host,
port: device.port || 161,
version: typeof device.version === 'string' ? parseInt(device.version, 10) : device.version,
@@ -112,7 +113,9 @@ export class MigrationV3ToV4 extends BaseMigration {
checkInterval: config.checkInterval || 30000,
};
logger.success(`${this.getName()}: Migration complete (${transformedDevices.length} devices transformed)`);
logger.success(
`${this.getName()}: Migration complete (${transformedDevices.length} devices transformed)`,
);
return migrated;
}
}

View File

@@ -49,7 +49,7 @@ export class MigrationV4_0ToV4_1 extends BaseMigration {
readonly fromVersion = '4.0';
readonly toVersion = '4.1';
async shouldRun(config: Record<string, unknown>): Promise<boolean> {
shouldRun(config: Record<string, unknown>): boolean {
// Run if config is version 4.0
if (config.version === '4.0') {
return true;
@@ -65,7 +65,7 @@ export class MigrationV4_0ToV4_1 extends BaseMigration {
return false;
}
async migrate(config: Record<string, unknown>): Promise<Record<string, unknown>> {
migrate(config: Record<string, unknown>): Record<string, unknown> {
logger.info(`${this.getName()}: Migrating v4.0 config to v4.1 format...`);
logger.dim(` - Moving thresholds from UPS level to action level`);
logger.dim(` - Creating default shutdown actions from existing thresholds`);
@@ -81,7 +81,9 @@ export class MigrationV4_0ToV4_1 extends BaseMigration {
};
// If device has thresholds at UPS level, convert to shutdown action
const deviceThresholds = device.thresholds as { battery: number; runtime: number } | undefined;
const deviceThresholds = device.thresholds as
| { battery: number; runtime: number }
| undefined;
if (deviceThresholds) {
migrated.actions = [
{

View File

@@ -0,0 +1,43 @@
import { BaseMigration } from './base-migration.ts';
import { logger } from '../logger.ts';
/**
* Migration from v4.1 to v4.2
*
* Changes:
* 1. Adds `protocol: 'snmp'` to all existing UPS devices (explicit default)
* 2. Bumps version from '4.1' to '4.2'
*/
export class MigrationV4_1ToV4_2 extends BaseMigration {
readonly fromVersion = '4.1';
readonly toVersion = '4.2';
shouldRun(config: Record<string, unknown>): boolean {
return config.version === '4.1';
}
migrate(config: Record<string, unknown>): Record<string, unknown> {
logger.info(`${this.getName()}: Adding protocol field to UPS devices...`);
const devices = (config.upsDevices as Array<Record<string, unknown>>) || [];
const migratedDevices = devices.map((device) => {
// Add protocol: 'snmp' if not already present
if (!device.protocol) {
device.protocol = 'snmp';
logger.dim(`${device.name}: Set protocol to 'snmp'`);
}
return device;
});
const result = {
...config,
version: this.toVersion,
upsDevices: migratedDevices,
};
logger.success(
`${this.getName()}: Migration complete (${migratedDevices.length} devices updated)`,
);
return result;
}
}

View File

@@ -1,4 +1,5 @@
import { NupstSnmp } from './snmp/manager.ts';
import { NupstUpsd } from './upsd/client.ts';
import { NupstDaemon } from './daemon.ts';
import { NupstSystemd } from './systemd.ts';
import denoConfig from '../deno.json' with { type: 'json' };
@@ -17,6 +18,7 @@ import type { INupstAccessor, IUpdateStatus } from './interfaces/index.ts';
*/
export class Nupst implements INupstAccessor {
private readonly snmp: NupstSnmp;
private readonly upsd: NupstUpsd;
private readonly daemon: NupstDaemon;
private readonly systemd: NupstSystemd;
private readonly upsHandler: UpsHandler;
@@ -34,7 +36,8 @@ export class Nupst implements INupstAccessor {
// Initialize core components
this.snmp = new NupstSnmp();
this.snmp.setNupst(this); // Set up bidirectional reference
this.daemon = new NupstDaemon(this.snmp);
this.upsd = new NupstUpsd();
this.daemon = new NupstDaemon(this.snmp, this.upsd);
this.systemd = new NupstSystemd(this.daemon);
// Initialize handlers
@@ -52,6 +55,13 @@ export class Nupst implements INupstAccessor {
return this.snmp;
}
/**
* Get the UPSD manager for NUT protocol communication
*/
public getUpsd(): NupstUpsd {
return this.upsd;
}
/**
* Get the daemon manager for background monitoring
*/

7
ts/protocol/index.ts Normal file
View File

@@ -0,0 +1,7 @@
/**
* Protocol abstraction module
* Re-exports public types and classes
*/
export type { TProtocol } from './types.ts';
export { ProtocolResolver } from './resolver.ts';

49
ts/protocol/resolver.ts Normal file
View File

@@ -0,0 +1,49 @@
/**
* ProtocolResolver - Routes UPS status queries to the correct protocol implementation
*
* Abstracts away SNMP vs UPSD differences so the daemon is protocol-agnostic.
* Both protocols return the same IUpsStatus interface from ts/snmp/types.ts.
*/
import type { NupstSnmp } from '../snmp/manager.ts';
import type { NupstUpsd } from '../upsd/client.ts';
import type { ISnmpConfig, IUpsStatus } from '../snmp/types.ts';
import type { IUpsdConfig } from '../upsd/types.ts';
import type { TProtocol } from './types.ts';
export class ProtocolResolver {
private snmp: NupstSnmp;
private upsd: NupstUpsd;
constructor(snmp: NupstSnmp, upsd: NupstUpsd) {
this.snmp = snmp;
this.upsd = upsd;
}
/**
* Get UPS status using the specified protocol
* @param protocol Protocol to use ('snmp' or 'upsd')
* @param snmpConfig SNMP configuration (required for 'snmp' protocol)
* @param upsdConfig UPSD configuration (required for 'upsd' protocol)
* @returns UPS status
*/
public getUpsStatus(
protocol: TProtocol,
snmpConfig?: ISnmpConfig,
upsdConfig?: IUpsdConfig,
): Promise<IUpsStatus> {
switch (protocol) {
case 'upsd':
if (!upsdConfig) {
throw new Error('UPSD configuration required for UPSD protocol');
}
return this.upsd.getUpsStatus(upsdConfig);
case 'snmp':
default:
if (!snmpConfig) {
throw new Error('SNMP configuration required for SNMP protocol');
}
return this.snmp.getUpsStatus(snmpConfig);
}
}
}

4
ts/protocol/types.ts Normal file
View File

@@ -0,0 +1,4 @@
/**
* Protocol type for UPS communication
*/
export type TProtocol = 'snmp' | 'upsd';

View File

@@ -94,7 +94,8 @@ export class NupstSnmp {
public snmpGet(
oid: string,
config = this.DEFAULT_CONFIG,
retryCount = 0,
_retryCount = 0,
// deno-lint-ignore no-explicit-any
): Promise<any> {
return new Promise((resolve, reject) => {
if (this.debug) {
@@ -105,6 +106,7 @@ export class NupstSnmp {
}
// Create SNMP options based on configuration
// deno-lint-ignore no-explicit-any
const options: any = {
port: config.port,
retries: SNMP.RETRIES, // Number of retries
@@ -132,6 +134,7 @@ export class NupstSnmp {
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 || '',
};
@@ -197,7 +200,11 @@ export class NupstSnmp {
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'}`);
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);
@@ -210,7 +217,8 @@ export class NupstSnmp {
const oids = [oid];
// Send the GET request
session.get(oids, (error: any, varbinds: any[]) => {
// deno-lint-ignore no-explicit-any
session.get(oids, (error: Error | null, varbinds: any[]) => {
// Close the session to release resources
session.close();
@@ -259,7 +267,9 @@ export class NupstSnmp {
}
if (this.debug) {
logger.dim(`SNMP response: oid=${varbinds[0].oid}, type=${varbinds[0].type}, value=${value}`);
logger.dim(
`SNMP response: oid=${varbinds[0].oid}, type=${varbinds[0].type}, value=${value}`,
);
}
resolve(value);
@@ -422,6 +432,7 @@ export class NupstSnmp {
oid: string,
description: string,
config: ISnmpConfig,
// deno-lint-ignore no-explicit-any
): Promise<any> {
if (oid === '') {
if (this.debug) {
@@ -476,6 +487,7 @@ export class NupstSnmp {
oid: string,
description: string,
config: ISnmpConfig,
// deno-lint-ignore no-explicit-any
): Promise<any> {
if (this.debug) {
logger.dim(`Retrying ${description} with fallback security level...`);
@@ -483,7 +495,7 @@ export class NupstSnmp {
// Try with authNoPriv if current level is authPriv
if (config.securityLevel === 'authPriv') {
const retryConfig = { ...config, securityLevel: 'authNoPriv' as 'authNoPriv' };
const retryConfig = { ...config, securityLevel: 'authNoPriv' as const };
try {
if (this.debug) {
logger.dim(`Retrying with authNoPriv security level`);
@@ -496,7 +508,9 @@ export class NupstSnmp {
} catch (retryError) {
if (this.debug) {
logger.error(
`Retry failed for ${description}: ${retryError instanceof Error ? retryError.message : String(retryError)}`,
`Retry failed for ${description}: ${
retryError instanceof Error ? retryError.message : String(retryError)
}`,
);
}
}
@@ -504,7 +518,7 @@ export class NupstSnmp {
// Try with noAuthNoPriv as a last resort
if (config.securityLevel === 'authPriv' || config.securityLevel === 'authNoPriv') {
const retryConfig = { ...config, securityLevel: 'noAuthNoPriv' as 'noAuthNoPriv' };
const retryConfig = { ...config, securityLevel: 'noAuthNoPriv' as const };
try {
if (this.debug) {
logger.dim(`Retrying with noAuthNoPriv security level`);
@@ -517,7 +531,9 @@ export class NupstSnmp {
} catch (retryError) {
if (this.debug) {
logger.error(
`Retry failed for ${description}: ${retryError instanceof Error ? retryError.message : String(retryError)}`,
`Retry failed for ${description}: ${
retryError instanceof Error ? retryError.message : String(retryError)
}`,
);
}
}
@@ -528,15 +544,16 @@ export class NupstSnmp {
/**
* Try standard OIDs as fallback
* @param oid OID to query
* @param _oid Original OID (unused, kept for method signature consistency)
* @param description Description of the value for logging
* @param config SNMP configuration
* @returns Promise resolving to the SNMP value
*/
private async tryStandardOids(
oid: string,
_oid: string,
description: string,
config: ISnmpConfig,
// deno-lint-ignore no-explicit-any
): Promise<any> {
try {
// Try RFC 1628 standard UPS MIB OIDs
@@ -556,7 +573,9 @@ export class NupstSnmp {
} catch (stdError) {
if (this.debug) {
logger.error(
`Standard OID retry failed for ${description}: ${stdError instanceof Error ? stdError.message : String(stdError)}`,
`Standard OID retry failed for ${description}: ${
stdError instanceof Error ? stdError.message : String(stdError)
}`,
);
}
}

View File

@@ -9,7 +9,7 @@ import { Buffer } from 'node:buffer';
*/
export interface IUpsStatus {
/** Current power status */
powerStatus: 'online' | 'onBattery' | 'unknown';
powerStatus: 'online' | 'onBattery' | 'unknown' | 'unreachable';
/** Battery capacity percentage */
batteryCapacity: number;
/** Remaining runtime in minutes */
@@ -23,7 +23,7 @@ export interface IUpsStatus {
/** Output current in amps */
outputCurrent: number;
/** Raw values from SNMP responses */
raw: Record<string, any>;
raw: Record<string, unknown>;
}
/**

View File

@@ -1,10 +1,10 @@
import process from 'node:process';
import { promises as fs } from 'node:fs';
import { execSync } from 'node:child_process';
import { NupstDaemon, type IUpsConfig } from './daemon.ts';
import { type IUpsConfig, NupstDaemon } from './daemon.ts';
import { NupstSnmp } from './snmp/manager.ts';
import { logger } from './logger.ts';
import { theme, symbols, getBatteryColor, getRuntimeColor, formatPowerStatus } from './colors.ts';
import { formatPowerStatus, getBatteryColor, getRuntimeColor, symbols, theme } from './colors.ts';
/**
* Class for managing systemd service
@@ -54,7 +54,11 @@ WantedBy=multi-user.target
logger.log('');
logger.error('No configuration found');
logger.log(` ${theme.dim('Config file:')} ${configPath}`);
logger.log(` ${theme.dim('Run')} ${theme.command('nupst ups add')} ${theme.dim('to create a configuration')}`);
logger.log(
` ${theme.dim('Run')} ${theme.command('nupst ups add')} ${
theme.dim('to create a configuration')
}`,
);
logger.log('');
throw new Error('Configuration not found');
}
@@ -142,6 +146,9 @@ WantedBy=multi-user.target
private async displayVersionInfo(): Promise<void> {
try {
const nupst = this.daemon.getNupstSnmp().getNupst();
if (!nupst) {
return;
}
const version = nupst.getVersion();
// Check for updates
@@ -152,22 +159,30 @@ WantedBy=multi-user.target
const updateStatus = nupst.getUpdateStatus();
logger.log('');
logger.log(
`${theme.dim('NUPST')} ${theme.dim('v' + version)} ${symbols.warning} ${theme.statusWarning(`Update available: v${updateStatus.latestVersion}`)}`,
`${theme.dim('NUPST')} ${theme.dim('v' + version)} ${symbols.warning} ${
theme.statusWarning(`Update available: v${updateStatus.latestVersion}`)
}`,
);
logger.log(
` ${theme.dim('Run')} ${theme.command('sudo nupst update')} ${theme.dim('to upgrade')}`,
);
logger.log(` ${theme.dim('Run')} ${theme.command('sudo nupst update')} ${theme.dim('to upgrade')}`);
} else {
logger.log('');
logger.log(
`${theme.dim('NUPST')} ${theme.dim('v' + version)} ${symbols.success} ${theme.success('Up to date')}`,
`${theme.dim('NUPST')} ${theme.dim('v' + version)} ${symbols.success} ${
theme.success('Up to date')
}`,
);
}
} catch (error) {
// If version check fails, show at least the current version
try {
const nupst = this.daemon.getNupstSnmp().getNupst();
const version = nupst.getVersion();
logger.log('');
logger.log(`${theme.dim('NUPST')} ${theme.dim('v' + version)}`);
if (nupst) {
const version = nupst.getVersion();
logger.log('');
logger.log(`${theme.dim('NUPST')} ${theme.dim('v' + version)}`);
}
} catch (_innerError) {
// Silently fail if we can't even get the version
}
@@ -237,9 +252,15 @@ WantedBy=multi-user.target
// Display beautiful status
logger.log('');
if (isActive) {
logger.log(`${symbols.running} ${theme.success('Service:')} ${theme.statusActive('active (running)')}`);
logger.log(
`${symbols.running} ${theme.success('Service:')} ${
theme.statusActive('active (running)')
}`,
);
} else {
logger.log(`${symbols.stopped} ${theme.dim('Service:')} ${theme.statusInactive('inactive')}`);
logger.log(
`${symbols.stopped} ${theme.dim('Service:')} ${theme.statusInactive('inactive')}`,
);
}
if (pid || memory || cpu) {
@@ -250,10 +271,11 @@ WantedBy=multi-user.target
logger.log(` ${details.join(' ')}`);
}
logger.log('');
} catch (error) {
logger.log('');
logger.log(`${symbols.stopped} ${theme.dim('Service:')} ${theme.statusInactive('not installed')}`);
logger.log(
`${symbols.stopped} ${theme.dim('Service:')} ${theme.statusInactive('not installed')}`,
);
logger.log('');
}
}
@@ -290,13 +312,13 @@ WantedBy=multi-user.target
groups: [],
actions: config.thresholds
? [
{
type: 'shutdown',
thresholds: config.thresholds,
triggerMode: 'onlyThresholds',
shutdownDelay: 5,
},
]
{
type: 'shutdown',
thresholds: config.thresholds,
triggerMode: 'onlyThresholds',
shutdownDelay: 5,
},
]
: [],
};
@@ -304,7 +326,9 @@ WantedBy=multi-user.target
} else {
logger.log('');
logger.warn('No UPS devices configured');
logger.log(` ${theme.dim('Run')} ${theme.command('nupst ups add')} ${theme.dim('to add a device')}`);
logger.log(
` ${theme.dim('Run')} ${theme.command('nupst ups add')} ${theme.dim('to add a device')}`,
);
logger.log('');
}
} catch (error) {
@@ -322,13 +346,24 @@ WantedBy=multi-user.target
*/
private async displaySingleUpsStatus(ups: IUpsConfig, snmp: NupstSnmp): Promise<void> {
try {
// Create a test config with a short timeout
const testConfig = {
...ups.snmp,
timeout: Math.min(ups.snmp.timeout, 10000), // Use at most 10 seconds for status check
};
const protocol = ups.protocol || 'snmp';
let status;
const status = await snmp.getUpsStatus(testConfig);
if (protocol === 'upsd' && ups.upsd) {
const testConfig = {
...ups.upsd,
timeout: Math.min(ups.upsd.timeout, 10000),
};
status = await this.daemon.getNupstUpsd().getUpsStatus(testConfig);
} else if (ups.snmp) {
const testConfig = {
...ups.snmp,
timeout: Math.min(ups.snmp.timeout, 10000),
};
status = await snmp.getUpsStatus(testConfig);
} else {
throw new Error('No protocol configuration found');
}
// Determine status symbol based on power status
let statusSymbol = symbols.unknown;
@@ -339,7 +374,9 @@ WantedBy=multi-user.target
}
// Display UPS name and power status
logger.log(` ${statusSymbol} ${theme.highlight(ups.name)} - ${formatPowerStatus(status.powerStatus)}`);
logger.log(
` ${statusSymbol} ${theme.highlight(ups.name)} - ${formatPowerStatus(status.powerStatus)}`,
);
// Display battery with color coding
const batteryColor = getBatteryColor(status.batteryCapacity);
@@ -347,19 +384,35 @@ WantedBy=multi-user.target
// Get threshold from actions (if any action has thresholds defined)
const actionWithThresholds = ups.actions?.find((action) => action.thresholds);
const batteryThreshold = actionWithThresholds?.thresholds?.battery;
const batterySymbol = batteryThreshold !== undefined && status.batteryCapacity >= batteryThreshold
? symbols.success
: batteryThreshold !== undefined
? symbols.warning
: '';
const batterySymbol =
batteryThreshold !== undefined && status.batteryCapacity >= batteryThreshold
? symbols.success
: batteryThreshold !== undefined
? symbols.warning
: '';
logger.log(` Battery: ${batteryColor(status.batteryCapacity + '%')} ${batterySymbol} Runtime: ${getRuntimeColor(status.batteryRuntime)(status.batteryRuntime + ' min')}`);
logger.log(
` Battery: ${batteryColor(status.batteryCapacity + '%')} ${batterySymbol} Runtime: ${
getRuntimeColor(status.batteryRuntime)(status.batteryRuntime + ' min')
}`,
);
// Display power metrics
logger.log(` Load: ${theme.highlight(status.outputLoad + '%')} Power: ${theme.highlight(status.outputPower + 'W')} Voltage: ${theme.highlight(status.outputVoltage + 'V')} Current: ${theme.highlight(status.outputCurrent + 'A')}`);
logger.log(
` Load: ${theme.highlight(status.outputLoad + '%')} Power: ${
theme.highlight(status.outputPower + 'W')
} Voltage: ${theme.highlight(status.outputVoltage + 'V')} Current: ${
theme.highlight(status.outputCurrent + 'A')
}`,
);
// Display host info
logger.log(` ${theme.dim(`Host: ${ups.snmp.host}:${ups.snmp.port}`)}`);
const hostInfo = protocol === 'upsd' && ups.upsd
? `${ups.upsd.host}:${ups.upsd.port} (UPSD)`
: ups.snmp
? `${ups.snmp.host}:${ups.snmp.port} (SNMP)`
: 'N/A';
logger.log(` ${theme.dim(`Host: ${hostInfo}`)}`);
// Display groups if any
if (ups.groups && ups.groups.length > 0) {
@@ -376,7 +429,9 @@ WantedBy=multi-user.target
for (const action of ups.actions) {
let actionDesc = `${action.type}`;
if (action.thresholds) {
actionDesc += ` (${action.triggerMode || 'onlyThresholds'}: battery<${action.thresholds.battery}%, runtime<${action.thresholds.runtime}min`;
actionDesc += ` (${
action.triggerMode || 'onlyThresholds'
}: battery<${action.thresholds.battery}%, runtime<${action.thresholds.runtime}min`;
if (action.shutdownDelay) {
actionDesc += `, delay=${action.shutdownDelay}s`;
}
@@ -393,12 +448,18 @@ WantedBy=multi-user.target
}
logger.log('');
} catch (error) {
// Display error for this UPS
logger.log(` ${symbols.error} ${theme.highlight(ups.name)} - ${theme.error('Connection failed')}`);
const errorHostInfo = (ups.protocol || 'snmp') === 'upsd' && ups.upsd
? `${ups.upsd.host}:${ups.upsd.port} (UPSD)`
: ups.snmp
? `${ups.snmp.host}:${ups.snmp.port} (SNMP)`
: 'N/A';
logger.log(
` ${symbols.error} ${theme.highlight(ups.name)} - ${theme.error('Connection failed')}`,
);
logger.log(` ${theme.dim(error instanceof Error ? error.message : String(error))}`);
logger.log(` ${theme.dim(`Host: ${ups.snmp.host}:${ups.snmp.port}`)}`);
logger.log(` ${theme.dim(`Host: ${errorHostInfo}`)}`);
logger.log('');
}
}
@@ -421,7 +482,9 @@ WantedBy=multi-user.target
// Display group name and mode
const modeColor = group.mode === 'redundant' ? theme.success : theme.warning;
logger.log(
` ${symbols.info} ${theme.highlight(group.name)} ${theme.dim(`(${modeColor(group.mode)})`)}`,
` ${symbols.info} ${theme.highlight(group.name)} ${
theme.dim(`(${modeColor(group.mode)})`)
}`,
);
// Display description if present
@@ -446,7 +509,9 @@ WantedBy=multi-user.target
for (const action of group.actions) {
let actionDesc = `${action.type}`;
if (action.thresholds) {
actionDesc += ` (${action.triggerMode || 'onlyThresholds'}: battery<${action.thresholds.battery}%, runtime<${action.thresholds.runtime}min`;
actionDesc += ` (${
action.triggerMode || 'onlyThresholds'
}: battery<${action.thresholds.battery}%, runtime<${action.thresholds.runtime}min`;
if (action.shutdownDelay) {
actionDesc += `, delay=${action.shutdownDelay}s`;
}

269
ts/upsd/client.ts Normal file
View File

@@ -0,0 +1,269 @@
/**
* UPSD/NIS (Network UPS Tools) TCP client
*
* Connects to a NUT upsd server via TCP and queries UPS variables
* using the NUT network protocol (RFC-style line protocol).
*
* Protocol format:
* Request: GET VAR <upsname> <varname>\n
* Response: VAR <upsname> <varname> "<value>"\n
* Logout: LOGOUT\n
*/
import * as net from 'node:net';
import { logger } from '../logger.ts';
import { UPSD } from '../constants.ts';
import type { IUpsdConfig } from './types.ts';
import type { IUpsStatus } from '../snmp/types.ts';
/**
* NupstUpsd - TCP client for the NUT UPSD protocol
*/
export class NupstUpsd {
private debug = false;
/**
* Enable debug logging
*/
public enableDebug(): void {
this.debug = true;
logger.info('UPSD debug mode enabled');
}
/**
* Get the current UPS status via UPSD protocol
* @param config UPSD connection configuration
* @returns UPS status matching the IUpsStatus interface
*/
public async getUpsStatus(config: IUpsdConfig): Promise<IUpsStatus> {
const host = config.host || '127.0.0.1';
const port = config.port || UPSD.DEFAULT_PORT;
const upsName = config.upsName || UPSD.DEFAULT_UPS_NAME;
const timeout = config.timeout || UPSD.DEFAULT_TIMEOUT_MS;
if (this.debug) {
logger.dim('---------------------------------------');
logger.dim('Getting UPS status via UPSD protocol:');
logger.dim(` Host: ${host}:${port}`);
logger.dim(` UPS Name: ${upsName}`);
logger.dim(` Timeout: ${timeout}ms`);
logger.dim('---------------------------------------');
}
// Variables to query from NUT
const varsToQuery = [
'ups.status',
'battery.charge',
'battery.runtime',
'ups.load',
'ups.realpower',
'output.voltage',
'output.current',
];
const values = new Map<string, string>();
// Open a TCP connection, query all variables, then logout
const conn = await this.connect(host, port, timeout);
try {
// Authenticate if credentials provided
if (config.username && config.password) {
await this.sendCommand(conn, `USERNAME ${config.username}`, timeout);
await this.sendCommand(conn, `PASSWORD ${config.password}`, timeout);
}
// Query each variable
for (const varName of varsToQuery) {
const value = await this.safeGetVar(conn, upsName, varName, timeout);
if (value !== null) {
values.set(varName, value);
}
}
// Logout gracefully
try {
await this.sendCommand(conn, 'LOGOUT', timeout);
} catch (_e) {
// Ignore logout errors
}
} finally {
conn.destroy();
}
// Map NUT variables to IUpsStatus
const powerStatus = this.parsePowerStatus(values.get('ups.status') || '');
const batteryCapacity = parseFloat(values.get('battery.charge') || '0');
const batteryRuntimeSeconds = parseFloat(values.get('battery.runtime') || '0');
const batteryRuntime = Math.floor(batteryRuntimeSeconds / 60); // NUT reports seconds, convert to minutes
const outputLoad = parseFloat(values.get('ups.load') || '0');
const outputPower = parseFloat(values.get('ups.realpower') || '0');
const outputVoltage = parseFloat(values.get('output.voltage') || '0');
const outputCurrent = parseFloat(values.get('output.current') || '0');
const result: IUpsStatus = {
powerStatus,
batteryCapacity: isNaN(batteryCapacity) ? 0 : batteryCapacity,
batteryRuntime: isNaN(batteryRuntime) ? 0 : batteryRuntime,
outputLoad: isNaN(outputLoad) ? 0 : outputLoad,
outputPower: isNaN(outputPower) ? 0 : outputPower,
outputVoltage: isNaN(outputVoltage) ? 0 : outputVoltage,
outputCurrent: isNaN(outputCurrent) ? 0 : outputCurrent,
raw: Object.fromEntries(values),
};
if (this.debug) {
logger.dim('---------------------------------------');
logger.dim('UPSD status result:');
logger.dim(` Power Status: ${result.powerStatus}`);
logger.dim(` Battery Capacity: ${result.batteryCapacity}%`);
logger.dim(` Battery Runtime: ${result.batteryRuntime} minutes`);
logger.dim(` Output Load: ${result.outputLoad}%`);
logger.dim(` Output Power: ${result.outputPower} watts`);
logger.dim(` Output Voltage: ${result.outputVoltage} volts`);
logger.dim(` Output Current: ${result.outputCurrent} amps`);
logger.dim('---------------------------------------');
}
return result;
}
/**
* Open a TCP connection to the UPSD server
*/
private connect(host: string, port: number, timeout: number): Promise<net.Socket> {
return new Promise((resolve, reject) => {
const socket = net.createConnection({ host, port }, () => {
if (this.debug) {
logger.dim(`Connected to UPSD at ${host}:${port}`);
}
resolve(socket);
});
socket.setTimeout(timeout);
socket.on('timeout', () => {
socket.destroy();
reject(new Error(`UPSD connection timed out after ${timeout}ms`));
});
socket.on('error', (err) => {
reject(new Error(`UPSD connection error: ${err.message}`));
});
});
}
/**
* Send a command and read the response line
*/
private sendCommand(socket: net.Socket, command: string, timeout: number): Promise<string> {
return new Promise((resolve, reject) => {
let responseData = '';
const timer = setTimeout(() => {
cleanup();
reject(new Error(`UPSD command timed out: ${command}`));
}, timeout);
const decoder = new TextDecoder();
const onData = (data: Uint8Array) => {
responseData += decoder.decode(data, { stream: true });
// Look for newline to indicate end of response
const newlineIdx = responseData.indexOf('\n');
if (newlineIdx !== -1) {
cleanup();
const line = responseData.substring(0, newlineIdx).trim();
if (this.debug) {
logger.dim(`UPSD << ${line}`);
}
resolve(line);
}
};
const onError = (err: Error) => {
cleanup();
reject(err);
};
const cleanup = () => {
clearTimeout(timer);
socket.removeListener('data', onData);
socket.removeListener('error', onError);
};
socket.on('data', onData);
socket.on('error', onError);
if (this.debug) {
logger.dim(`UPSD >> ${command}`);
}
socket.write(command + '\n');
});
}
/**
* Safely get a single NUT variable, returning null on error
*/
private async safeGetVar(
socket: net.Socket,
upsName: string,
varName: string,
timeout: number,
): Promise<string | null> {
try {
const response = await this.sendCommand(
socket,
`GET VAR ${upsName} ${varName}`,
timeout,
);
// Expected response: VAR <upsname> <varname> "<value>"
// Also handle: ERR ... for unsupported variables
if (response.startsWith('ERR')) {
if (this.debug) {
logger.dim(`UPSD variable ${varName} not available: ${response}`);
}
return null;
}
// Parse: VAR ups battery.charge "100"
const match = response.match(/^VAR\s+\S+\s+\S+\s+"(.*)"/);
if (match) {
return match[1];
}
// Some implementations don't quote the value
const parts = response.split(/\s+/);
if (parts.length >= 4 && parts[0] === 'VAR') {
return parts.slice(3).join(' ').replace(/^"/, '').replace(/"$/, '');
}
if (this.debug) {
logger.dim(`UPSD unexpected response for ${varName}: ${response}`);
}
return null;
} catch (error) {
if (this.debug) {
logger.dim(
`UPSD error getting ${varName}: ${error instanceof Error ? error.message : String(error)}`,
);
}
return null;
}
}
/**
* Parse NUT ups.status tokens into a power status
* NUT status tokens: OL (online), OB (on battery), LB (low battery),
* HB (high battery), RB (replace battery), CHRG (charging), etc.
*/
private parsePowerStatus(statusString: string): 'online' | 'onBattery' | 'unknown' {
const tokens = statusString.trim().split(/\s+/);
if (tokens.includes('OB')) {
return 'onBattery';
}
if (tokens.includes('OL')) {
return 'online';
}
return 'unknown';
}
}

7
ts/upsd/index.ts Normal file
View File

@@ -0,0 +1,7 @@
/**
* UPSD/NIS protocol module
* Re-exports public types and classes
*/
export type { IUpsdConfig } from './types.ts';
export { NupstUpsd } from './client.ts';

21
ts/upsd/types.ts Normal file
View File

@@ -0,0 +1,21 @@
/**
* Type definitions for UPSD/NIS (Network UPS Tools) protocol module
*/
/**
* UPSD connection configuration
*/
export interface IUpsdConfig {
/** UPSD server host (default: 127.0.0.1) */
host: string;
/** UPSD server port (default: 3493) */
port: number;
/** NUT device name (default: 'ups') */
upsName: string;
/** Connection timeout in milliseconds (default: 5000) */
timeout: number;
/** Optional username for authentication */
username?: string;
/** Optional password for authentication */
password?: string;
}