Compare commits

...

8 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
47 changed files with 2869 additions and 1028 deletions

View File

@@ -5,19 +5,23 @@ Pre-compiled binaries for multiple platforms.
### Installation ### Installation
#### Option 1: Via npm (recommended) #### Option 1: Via npm (recommended)
```bash ```bash
npm install -g @serve.zone/nupst npm install -g @serve.zone/nupst
``` ```
#### Option 2: Via installer script #### Option 2: Via installer script
```bash ```bash
curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash
``` ```
#### Option 3: Direct binary download #### Option 3: Direct binary download
Download the appropriate binary for your platform from the assets below and make it executable. Download the appropriate binary for your platform from the assets below and make it executable.
### Supported Platforms ### Supported Platforms
- Linux x86_64 (x64) - Linux x86_64 (x64)
- Linux ARM64 (aarch64) - Linux ARM64 (aarch64)
- macOS x86_64 (Intel) - macOS x86_64 (Intel)
@@ -25,7 +29,9 @@ Download the appropriate binary for your platform from the assets below and make
- Windows x86_64 - Windows x86_64
### Checksums ### Checksums
SHA256 checksums are provided in `SHA256SUMS.txt` for binary verification. SHA256 checksums are provided in `SHA256SUMS.txt` for binary verification.
### npm Package ### npm Package
The npm package includes automatic binary detection and installation for your platform. 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 { fileURLToPath } from 'url';
import { dirname, join } from 'path'; import { dirname, join } from 'path';
import { existsSync } from 'fs'; 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 __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename); const __dirname = dirname(__filename);
@@ -25,12 +26,12 @@ function getBinaryName() {
const platformMap = { const platformMap = {
'darwin': 'macos', 'darwin': 'macos',
'linux': 'linux', 'linux': 'linux',
'win32': 'windows' 'win32': 'windows',
}; };
const archMap = { const archMap = {
'x64': 'x64', 'x64': 'x64',
'arm64': 'arm64' 'arm64': 'arm64',
}; };
const mappedPlatform = platformMap[plat]; const mappedPlatform = platformMap[plat];
@@ -76,7 +77,7 @@ function executeBinary() {
// Spawn the binary with all arguments passed through // Spawn the binary with all arguments passed through
const child = spawn(binaryPath, process.argv.slice(2), { const child = spawn(binaryPath, process.argv.slice(2), {
stdio: 'inherit', stdio: 'inherit',
shell: false shell: false,
}); });
// Handle child process events // Handle child process events
@@ -95,7 +96,7 @@ function executeBinary() {
// Forward signals to child process // Forward signals to child process
const signals = ['SIGINT', 'SIGTERM', 'SIGHUP']; const signals = ['SIGINT', 'SIGTERM', 'SIGHUP'];
signals.forEach(signal => { signals.forEach((signal) => {
process.on(signal, () => { process.on(signal, () => {
if (!child.killed) { if (!child.killed) {
child.kill(signal); child.kill(signal);
@@ -105,4 +106,4 @@ function executeBinary() {
} }
// Execute // Execute
executeBinary(); executeBinary();

View File

@@ -1,69 +1,143 @@
# Changelog # Changelog
## 2026-01-29 - 5.2.1 - fix(cli(ups-handler), systemd) ## 2026-02-20 - 5.3.0 - feat(daemon)
add type guards and null checks for UPS configs; improve SNMP handling and prompts; guard version display Add UPSD (NUT) protocol support, Proxmox VM shutdown action, pause/resume monitoring, and network-loss/unreachable handling; bump config version to 4.2
- Introduce a type guard ('id' in config && 'name' in config) to distinguish IUpsConfig from legacy INupstConfig and route fields (snmp, checkInterval, name, id) accordingly. - Add UPSD client (ts/upsd) and ProtocolResolver (ts/protocol) to support protocol-agnostic UPS queries (snmp or upsd).
- displayTestConfig now handles missing SNMP by logging 'Not configured' and returning, computes checkInterval/upsName/upsId correctly, and uses groups only for true UPS configs. - Introduce new TProtocol and IUpsdConfig types, wire up Nupst to initialize & expose UPSD client, and route status requests through ProtocolResolver.
- testConnection now safely derives snmpConfig for both config types, throws if SNMP is missing, and caps test timeout to 10s for probes. - Add 'unreachable' TPowerStatus plus consecutiveFailures and unreachableSince tracking; mark UPS as unreachable after NETWORK.CONSECUTIVE_FAILURE_THRESHOLD failures and suppress shutdown actions while unreachable.
- Clear auth/priv credentials by setting undefined (instead of empty strings) when disabling security levels to avoid invalid/empty string values. - 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.
- Expanded customOIDs to include OUTPUT_LOAD, OUTPUT_POWER, OUTPUT_VOLTAGE, OUTPUT_CURRENT with defaults; trim prompt input and document RFC 1628 fallbacks. - 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.
- systemd.displayVersionInfo: guard against missing nupst (silent return) and avoid errors when printing version info; use ignored catch variables for clarity. - 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) ## 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 Centralize timeouts/constants, add CLI prompt helpers, and introduce webhook/script actions with
- Introduce helpers/prompt.ts with createPrompt() and withPrompt() and refactor CLI handlers to use these helpers (cleaner prompt lifecycle) safety and SNMP refactors
- 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 - Add ts/constants.ts to centralize timing, SNMP, webhook, script, shutdown and UI constants and
- 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) replace magic numbers across the codebase
- Update many CLI and core types (stronger typing for configs/actions), expand tests and update README and npmextra.json to document new features - 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) ## 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. Update README installation instructions to recommend automated installer script and clarify npm
- 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. 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. - Remove the 'NEW!' badge and streamline notes about binary downloads and installation methods.
## 2025-10-23 - 5.1.10 - fix(config) ## 2025-10-23 - 5.1.10 - fix(config)
Synchronize deno.json version with package.json, tidy formatting, and add local tooling settings 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 - Bumped deno.json version to 5.1.9 to match package.json/commitinfo
- Reformatted deno.json arrays (lint, fmt, compilerOptions) for readability - 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) ## 2025-10-23 - 5.1.9 - fix(dev)
Add local assistant permissions/settings file (.claude/settings.local.json) 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.) - Added .claude/settings.local.json containing local assistant permission configuration used for
- This is a development/local configuration file and does not change runtime behavior or product code paths 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 - Patch version bump recommended
## 2025-10-23 - 5.1.2 - fix(scripts) ## 2025-10-23 - 5.1.2 - fix(scripts)
Add build script to package.json and include local dev tool settings 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 - Add a 'build' script to package.json (no-op placeholder) to provide an explicit build step
- Minor scripts section formatting tidy in package.json - 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) ## 2025-10-23 - 5.1.1 - fix(tooling)
Add .claude/settings.local.json with local automation permissions Add .claude/settings.local.json with local automation permissions
- Add .claude/settings.local.json to specify allowed permissions for local automated tasks - 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 - 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) ## 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 - 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 - Include a small Node.js wrapper (bin/nupst-wrapper.js) to execute platform-specific precompiled
- Add postinstall script (scripts/install-binary.js) that downloads the correct binary for the current platform and sets executable permissions binaries
- Add GitHub Actions workflow (.github/workflows/npm-publish.yml) to build binaries, pack and publish to npm, and create releases - Add postinstall script (scripts/install-binary.js) that downloads the correct binary for the
- Add .npmignore to keep source, tests and dev files out of npm package; keep only runtime installer and wrapper current platform and sets executable permissions
- Move example action script into docs (docs/example-action.sh) and remove the top-level example-action.sh - 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 - 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 ## 2025-10-18 - 4.0.0 - BREAKING CHANGE(core): Complete migration to Deno runtime

View File

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

View File

@@ -17,4 +17,4 @@
} }
}, },
"@ship.zone/szci": {} "@ship.zone/szci": {}
} }

View File

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

View File

@@ -7,12 +7,14 @@
1. **Prompt Utility (`ts/helpers/prompt.ts`)** 1. **Prompt Utility (`ts/helpers/prompt.ts`)**
- Extracted readline/prompt pattern from all CLI handlers - Extracted readline/prompt pattern from all CLI handlers
- Provides `createPrompt()` and `withPrompt()` helper functions - 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`)** 2. **Constants File (`ts/constants.ts`)**
- Centralized all magic numbers (timeouts, intervals, thresholds) - Centralized all magic numbers (timeouts, intervals, thresholds)
- Contains: `TIMING`, `SNMP`, `THRESHOLDS`, `WEBHOOK`, `SCRIPT`, `SHUTDOWN`, `HTTP_SERVER`, `UI` - Contains: `TIMING`, `SNMP`, `THRESHOLDS`, `WEBHOOK`, `SCRIPT`, `SHUTDOWN`, `HTTP_SERVER`, `UI`,
- Used in: `daemon.ts`, `snmp/manager.ts`, `actions/*.ts` `NETWORK`, `UPSD`, `PAUSE`, `PROXMOX`
- Used in: `daemon.ts`, `snmp/manager.ts`, `actions/*.ts`, `upsd/client.ts`
3. **Logger Consistency** 3. **Logger Consistency**
- Replaced all `console.log/console.error` in `snmp/manager.ts` with proper `logger.*` calls - Replaced all `console.log/console.error` in `snmp/manager.ts` with proper `logger.*` calls
@@ -21,7 +23,8 @@
### Phase 2 - Type Safety ### Phase 2 - Type Safety
4. **Circular Dependency Fix (`ts/interfaces/nupst-accessor.ts`)** 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` - `NupstSnmp.nupst` property now uses the interface instead of `any`
5. **Webhook Payload Interface (`ts/actions/webhook-action.ts`)** 5. **Webhook Payload Interface (`ts/actions/webhook-action.ts`)**
@@ -30,14 +33,50 @@
6. **CLI Handler Type Safety** 6. **CLI Handler Type Safety**
- Replaced `any` types in `ups-handler.ts` and `group-handler.ts` with proper interfaces - 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 ## 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 - **CLI Handlers**: All use the `helpers.withPrompt()` utility for interactive input
- **Constants**: All timing values should be referenced from `ts/constants.ts` - **Constants**: All timing values should be referenced from `ts/constants.ts`
- **Actions**: Use `IActionConfig` from `ts/actions/base-action.ts` for action configuration - **Actions**: Use `IActionConfig` from `ts/actions/base-action.ts` for action configuration
- **Config version**: Currently `4.2`, migrations run automatically
## File Organization ## File Organization
@@ -50,9 +89,21 @@ ts/
│ ├── prompt.ts # Readline utility │ ├── prompt.ts # Readline utility
│ └── shortid.ts # ID generation │ └── shortid.ts # ID generation
├── actions/ ├── actions/
│ ├── base-action.ts # Base action class and interfaces │ ├── base-action.ts # Base action class, IActionConfig, TPowerStatus
│ ├── webhook-action.ts # Includes IWebhookPayload │ ├── 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/ └── cli/
└── ... # All handlers use helpers.withPrompt() └── ... # 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 #!/usr/bin/env node
// deno-lint-ignore-file no-unused-vars
/** /**
* NUPST npm postinstall script * NUPST npm postinstall script
* Downloads the appropriate binary for the current platform from GitHub releases * Downloads the appropriate binary for the current platform from GitHub releases
*/ */
import { platform, arch } from 'os'; import { arch, platform } from 'os';
import { existsSync, mkdirSync, writeFileSync, chmodSync, unlinkSync } from 'fs'; import { chmodSync, existsSync, mkdirSync, unlinkSync } from 'fs';
import { join, dirname } from 'path'; import { dirname, join } from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import https from 'https'; import https from 'https';
import { pipeline } from 'stream'; import { pipeline } from 'stream';
import { promisify } from 'util'; import { promisify } from 'util';
import { createWriteStream } from 'fs'; import { createWriteStream } from 'fs';
import process from "node:process";
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename); const __dirname = dirname(__filename);
@@ -29,12 +31,12 @@ function getBinaryInfo() {
const platformMap = { const platformMap = {
'darwin': 'macos', 'darwin': 'macos',
'linux': 'linux', 'linux': 'linux',
'win32': 'windows' 'win32': 'windows',
}; };
const archMap = { const archMap = {
'x64': 'x64', 'x64': 'x64',
'arm64': 'arm64' 'arm64': 'arm64',
}; };
const mappedPlatform = platformMap[plat]; const mappedPlatform = platformMap[plat];
@@ -54,7 +56,7 @@ function getBinaryInfo() {
platform: mappedPlatform, platform: mappedPlatform,
arch: mappedArch, arch: mappedArch,
binaryName, binaryName,
originalPlatform: plat originalPlatform: plat,
}; };
} }
@@ -122,7 +124,9 @@ async function main() {
const binaryInfo = getBinaryInfo(); const binaryInfo = getBinaryInfo();
if (!binaryInfo.supported) { 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('');
console.error('Supported platforms:'); console.error('Supported platforms:');
console.error(' • Linux (x64, arm64)'); console.error(' • Linux (x64, arm64)');
@@ -185,7 +189,9 @@ async function main() {
console.error('You can try:'); console.error('You can try:');
console.error('1. Installing from source: https://code.foss.global/serve.zone/nupst'); 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('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 // Clean up partial download
if (existsSync(binaryPath)) { if (existsSync(binaryPath)) {
@@ -225,7 +231,7 @@ async function main() {
} }
// Run the installation // Run the installation
main().catch(err => { main().catch((err) => {
console.error(`❌ Installation failed: ${err.message}`); console.error(`❌ Installation failed: ${err.message}`);
process.exit(1); process.exit(1);
}); });

View File

@@ -1,6 +1,7 @@
# Manual Docker Testing Scripts # 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 ## 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. Creates a Docker container with systemd and installs NUPST v3.
**What it does:** **What it does:**
- Creates Ubuntu 22.04 container with systemd enabled - Creates Ubuntu 22.04 container with systemd enabled
- Installs NUPST v3 from commit `806f81c6` (last v3 version) - Installs NUPST v3 from commit `806f81c6` (last v3 version)
- Enables and starts the systemd service - Enables and starts the systemd service
- Leaves container running for testing - Leaves container running for testing
**Usage:** **Usage:**
```bash ```bash
chmod +x 01-setup-v3-container.sh chmod +x 01-setup-v3-container.sh
./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. Tests the migration from v3 to v4.
**What it does:** **What it does:**
- Checks current v3 installation - Checks current v3 installation
- Pulls v4 code from `migration/deno-v4` branch - Pulls v4 code from `migration/deno-v4` branch
- Runs install.sh (should auto-detect and migrate) - Runs install.sh (should auto-detect and migrate)
@@ -40,6 +44,7 @@ Tests the migration from v3 to v4.
- Tests basic commands - Tests basic commands
**Usage:** **Usage:**
```bash ```bash
chmod +x 02-test-v3-to-v4-migration.sh chmod +x 02-test-v3-to-v4-migration.sh
./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. Removes the test container.
**Usage:** **Usage:**
```bash ```bash
chmod +x 03-cleanup.sh chmod +x 03-cleanup.sh
./03-cleanup.sh ./03-cleanup.sh
@@ -134,16 +140,19 @@ docker rm -f nupst-test-v3
## Troubleshooting ## Troubleshooting
### Container won't start ### Container won't start
- Ensure Docker daemon is running - Ensure Docker daemon is running
- Check you have privileged access - Check you have privileged access
- Try: `docker logs nupst-test-v3` - Try: `docker logs nupst-test-v3`
### Systemd not working in container ### Systemd not working in container
- Requires Linux host (not macOS/Windows) - Requires Linux host (not macOS/Windows)
- Needs `--privileged` and cgroup volume mounts - Needs `--privileged` and cgroup volume mounts
- Check: `docker exec nupst-test-v3 systemctl --version` - Check: `docker exec nupst-test-v3 systemctl --version`
### Migration fails ### Migration fails
- Check logs: `docker exec nupst-test-v3 journalctl -xe` - Check logs: `docker exec nupst-test-v3 journalctl -xe`
- Verify install.sh ran: `docker exec nupst-test-v3 ls -la /opt/nupst/` - Verify install.sh ran: `docker exec nupst-test-v3 ls -la /opt/nupst/`
- Check service: `docker exec nupst-test-v3 systemctl status 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 * Run with: deno run --allow-all test/showcase.ts
*/ */
import { logger, type ITableColumn } from '../ts/logger.ts'; import { type ITableColumn, logger } from '../ts/logger.ts';
import { theme, symbols, getBatteryColor, formatPowerStatus } from '../ts/colors.ts'; import { formatPowerStatus, getBatteryColor, symbols, theme } from '../ts/colors.ts';
console.log(''); console.log('');
console.log('═'.repeat(80)); console.log('═'.repeat(80));
@@ -38,31 +38,51 @@ logger.logBoxEnd();
console.log(''); console.log('');
logger.logBox('Success Box (Green)', [ logger.logBox(
'Used for successful operations', 'Success Box (Green)',
'Installation complete, service started, etc.', [
], 60, 'success'); 'Used for successful operations',
'Installation complete, service started, etc.',
],
60,
'success',
);
console.log(''); console.log('');
logger.logBox('Error Box (Red)', [ logger.logBox(
'Used for critical errors and failures', 'Error Box (Red)',
'Configuration errors, connection failures, etc.', [
], 60, 'error'); 'Used for critical errors and failures',
'Configuration errors, connection failures, etc.',
],
60,
'error',
);
console.log(''); console.log('');
logger.logBox('Warning Box (Yellow)', [ logger.logBox(
'Used for warnings and deprecations', 'Warning Box (Yellow)',
'Old command format, missing config, etc.', [
], 60, 'warning'); 'Used for warnings and deprecations',
'Old command format, missing config, etc.',
],
60,
'warning',
);
console.log(''); console.log('');
logger.logBox('Info Box (Cyan)', [ logger.logBox(
'Used for informational messages', 'Info Box (Cyan)',
'Version info, update available, etc.', [
], 60, 'info'); 'Used for informational messages',
'Version info, update available, etc.',
],
60,
'info',
);
console.log(''); console.log('');
@@ -112,15 +132,24 @@ const upsColumns: ITableColumn[] = [
{ header: 'ID', key: 'id' }, { header: 'ID', key: 'id' },
{ header: 'Name', key: 'name' }, { header: 'Name', key: 'name' },
{ header: 'Host', key: 'host' }, { header: 'Host', key: 'host' },
{ header: 'Status', key: 'status', color: (v) => { {
if (v.includes('Online')) return theme.success(v); header: 'Status',
if (v.includes('Battery')) return theme.warning(v); key: 'status',
return theme.dim(v); color: (v) => {
}}, if (v.includes('Online')) return theme.success(v);
{ header: 'Battery', key: 'battery', align: 'right', color: (v) => { if (v.includes('Battery')) return theme.warning(v);
const pct = parseInt(v); return theme.dim(v);
return getBatteryColor(pct)(v); },
}}, },
{
header: 'Battery',
key: 'battery',
align: 'right',
color: (v) => {
const pct = parseInt(v);
return getBatteryColor(pct)(v);
},
},
{ header: 'Runtime', key: 'runtime', align: 'right' }, { header: 'Runtime', key: 'runtime', align: 'right' },
]; ];

View File

@@ -1,9 +1,9 @@
import { assert, assertEquals, assertExists } from 'jsr:@std/assert@^1.0.0'; import { assert, assertEquals, assertExists } from 'jsr:@std/assert@^1.0.0';
import { NupstSnmp } from '../ts/snmp/manager.ts'; import { NupstSnmp } from '../ts/snmp/manager.ts';
import { UpsOidSets } from '../ts/snmp/oid-sets.ts'; import { UpsOidSets } from '../ts/snmp/oid-sets.ts';
import type { 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 { 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 { Action, type IActionContext } from '../ts/actions/base-action.ts';
import * as qenv from 'npm:@push.rocks/qenv@^6.0.0'; 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', () => { 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(
assert(SNMP.TIMEOUT_AUTH_MS <= SNMP.TIMEOUT_AUTH_PRIV_MS, 'AuthPriv timeout should be >= Auth timeout'); 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', () => { 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', () => { Deno.test('UpsOidSets: all non-custom models have complete OIDs', () => {
const requiredOids = ['POWER_STATUS', 'BATTERY_CAPACITY', 'BATTERY_RUNTIME', 'OUTPUT_LOAD']; 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); const oidSet = UpsOidSets.getOidSet(model);
for (const oid of requiredOids) { for (const oid of requiredOids) {
const value = oidSet[oid as keyof IOidSet]; const value = oidSet[oid as keyof IOidSet];
assert( assert(
typeof value === 'string' && value.length > 0, 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', () => { 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); const oidSet = UpsOidSets.getOidSet(model);
assertExists(oidSet.POWER_STATUS_VALUES, `${model} should have POWER_STATUS_VALUES`); assertExists(oidSet.POWER_STATUS_VALUES, `${model} should have POWER_STATUS_VALUES`);
assertExists(oidSet.POWER_STATUS_VALUES?.online, `${model} should have online value`); assertExists(oidSet.POWER_STATUS_VALUES?.online, `${model} should have online value`);
@@ -200,11 +206,11 @@ Deno.test('Action.shouldExecute: onlyPowerChanges mode', () => {
assertEquals( assertEquals(
action.testShouldExecute(createMockContext({ triggerReason: 'powerStatusChange' })), action.testShouldExecute(createMockContext({ triggerReason: 'powerStatusChange' })),
true true,
); );
assertEquals( assertEquals(
action.testShouldExecute(createMockContext({ triggerReason: 'thresholdViolation' })), action.testShouldExecute(createMockContext({ triggerReason: 'thresholdViolation' })),
false false,
); );
}); });
@@ -218,13 +224,13 @@ Deno.test('Action.shouldExecute: onlyThresholds mode', () => {
// Below thresholds - should execute // Below thresholds - should execute
assertEquals( assertEquals(
action.testShouldExecute(createMockContext({ batteryCapacity: 50, batteryRuntime: 10 })), action.testShouldExecute(createMockContext({ batteryCapacity: 50, batteryRuntime: 10 })),
true true,
); );
// Above thresholds - should not execute // Above thresholds - should not execute
assertEquals( assertEquals(
action.testShouldExecute(createMockContext({ batteryCapacity: 100, batteryRuntime: 60 })), action.testShouldExecute(createMockContext({ batteryCapacity: 100, batteryRuntime: 60 })),
false false,
); );
}); });
@@ -248,7 +254,7 @@ Deno.test('Action.shouldExecute: powerChangesAndThresholds mode (default)', () =
// Power change - should execute // Power change - should execute
assertEquals( assertEquals(
action.testShouldExecute(createMockContext({ triggerReason: 'powerStatusChange' })), action.testShouldExecute(createMockContext({ triggerReason: 'powerStatusChange' })),
true true,
); );
// Threshold violation - should execute // Threshold violation - should execute
@@ -257,7 +263,7 @@ Deno.test('Action.shouldExecute: powerChangesAndThresholds mode (default)', () =
triggerReason: 'thresholdViolation', triggerReason: 'thresholdViolation',
batteryCapacity: 50, batteryCapacity: 50,
})), })),
true true,
); );
// No power change and above thresholds - should not execute // No power change and above thresholds - should not execute
@@ -267,7 +273,7 @@ Deno.test('Action.shouldExecute: powerChangesAndThresholds mode (default)', () =
batteryCapacity: 100, batteryCapacity: 100,
batteryRuntime: 60, batteryRuntime: 60,
})), })),
false false,
); );
}); });
@@ -279,11 +285,11 @@ Deno.test('Action.shouldExecute: anyChange mode always returns true', () => {
assertEquals( assertEquals(
action.testShouldExecute(createMockContext({ triggerReason: 'powerStatusChange' })), action.testShouldExecute(createMockContext({ triggerReason: 'powerStatusChange' })),
true true,
); );
assertEquals( assertEquals(
action.testShouldExecute(createMockContext({ triggerReason: 'thresholdViolation' })), action.testShouldExecute(createMockContext({ triggerReason: 'thresholdViolation' })),
true true,
); );
}); });
@@ -339,7 +345,7 @@ async function testUpsConnection(
assertExists(status, 'Status should exist'); assertExists(status, 'Status should exist');
assert( assert(
['online', 'onBattery', 'unknown'].includes(status.powerStatus), ['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.batteryCapacity, 'number', 'Battery capacity should be a number');
assertEquals(typeof status.batteryRuntime, 'number', 'Battery runtime should be a number'); assertEquals(typeof status.batteryRuntime, 'number', 'Battery runtime should be a number');
@@ -347,9 +353,12 @@ async function testUpsConnection(
// Validate ranges // Validate ranges
assert( assert(
status.batteryCapacity >= 0 && status.batteryCapacity <= 100, 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 // Create SNMP instance for integration tests

View File

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

View File

@@ -6,7 +6,7 @@
* 2. Threshold violations (battery/runtime cross below configured thresholds) * 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 * Context provided to actions when they execute
@@ -52,7 +52,7 @@ export type TActionTriggerMode =
*/ */
export interface IActionConfig { export interface IActionConfig {
/** Type of action to execute */ /** Type of action to execute */
type: 'shutdown' | 'webhook' | 'script'; type: 'shutdown' | 'webhook' | 'script' | 'proxmox';
// Trigger configuration // Trigger configuration
/** /**
@@ -96,6 +96,26 @@ export interface IActionConfig {
scriptTimeout?: number; scriptTimeout?: number;
/** Only execute script on threshold violation */ /** Only execute script on threshold violation */
scriptOnlyOnThresholdViolation?: boolean; 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 { ShutdownAction } from './shutdown-action.ts';
import { WebhookAction } from './webhook-action.ts'; import { WebhookAction } from './webhook-action.ts';
import { ScriptAction } from './script-action.ts'; import { ScriptAction } from './script-action.ts';
import { ProxmoxAction } from './proxmox-action.ts';
// Re-export types for convenience // Re-export types for convenience
export type { IActionConfig, IActionContext, TPowerStatus } from './base-action.ts'; 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 { ShutdownAction } from './shutdown-action.ts';
export { WebhookAction } from './webhook-action.ts'; export { WebhookAction } from './webhook-action.ts';
export { ScriptAction } from './script-action.ts'; export { ScriptAction } from './script-action.ts';
export { ProxmoxAction } from './proxmox-action.ts';
/** /**
* ActionManager - Coordinates action creation and execution * ActionManager - Coordinates action creation and execution
@@ -40,6 +42,8 @@ export class ActionManager {
return new WebhookAction(config); return new WebhookAction(config);
case 'script': case 'script':
return new ScriptAction(config); return new ScriptAction(config);
case 'proxmox':
return new ProxmoxAction(config);
default: default:
throw new Error(`Unknown action type: ${(config as IActionConfig).type}`); 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 process from 'node:process';
import { exec } from 'node:child_process'; import { exec } from 'node:child_process';
import { promisify } from 'node:util'; 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'; import { logger } from '../logger.ts';
const execAsync = promisify(exec); const execAsync = promisify(exec);
@@ -26,7 +26,11 @@ export class ScriptAction extends Action {
async execute(context: IActionContext): Promise<void> { async execute(context: IActionContext): Promise<void> {
// Check if we should execute based on trigger mode // Check if we should execute based on trigger mode
if (!this.shouldExecute(context)) { 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; return;
} }

View File

@@ -34,8 +34,17 @@ export class ShutdownAction extends Action {
// CRITICAL SAFETY CHECK: Shutdown should NEVER trigger unless UPS is on battery // 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) // 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') { 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; return false;
} }
@@ -54,7 +63,9 @@ export class ShutdownAction extends Action {
if (context.triggerReason === 'powerStatusChange') { if (context.triggerReason === 'powerStatusChange') {
// 'onlyThresholds' mode ignores power status changes // 'onlyThresholds' mode ignores power status changes
if (mode === 'onlyThresholds') { 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; 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. // the daemon for testing, or the UPS may have been on battery for a while.
// Only trigger if mode explicitly includes power changes. // Only trigger if mode explicitly includes power changes.
if (prev === 'unknown') { if (prev === 'unknown') {
if (mode === 'onlyPowerChanges' || mode === 'powerChangesAndThresholds' || mode === 'anyChange') { if (
logger.info('Shutdown action triggered: UPS on battery at daemon startup (unknown → onBattery)'); mode === 'onlyPowerChanges' || mode === 'powerChangesAndThresholds' ||
mode === 'anyChange'
) {
logger.info(
'Shutdown action triggered: UPS on battery at daemon startup (unknown → onBattery)',
);
return true; return true;
} }
return false; return false;
} }
// Other transitions (e.g., onBattery → onBattery) should not trigger // 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; return false;
} }
@@ -98,7 +116,11 @@ export class ShutdownAction extends Action {
async execute(context: IActionContext): Promise<void> { async execute(context: IActionContext): Promise<void> {
// Check if we should execute based on trigger mode and thresholds // Check if we should execute based on trigger mode and thresholds
if (!this.shouldExecute(context)) { 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; return;
} }

View File

@@ -1,7 +1,7 @@
import * as http from 'node:http'; import * as http from 'node:http';
import * as https from 'node:https'; import * as https from 'node:https';
import { URL } from 'node:url'; 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 { logger } from '../logger.ts';
import { WEBHOOK } from '../constants.ts'; import { WEBHOOK } from '../constants.ts';
@@ -14,7 +14,7 @@ export interface IWebhookPayload {
/** UPS name */ /** UPS name */
upsName: string; upsName: string;
/** Current power status */ /** Current power status */
powerStatus: 'online' | 'onBattery' | 'unknown'; powerStatus: 'online' | 'onBattery' | 'unknown' | 'unreachable';
/** Current battery capacity percentage */ /** Current battery capacity percentage */
batteryCapacity: number; batteryCapacity: number;
/** Current battery runtime in minutes */ /** Current battery runtime in minutes */
@@ -46,7 +46,11 @@ export class WebhookAction extends Action {
async execute(context: IActionContext): Promise<void> { async execute(context: IActionContext): Promise<void> {
// Check if we should execute based on trigger mode // Check if we should execute based on trigger mode
if (!this.shouldExecute(context)) { 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; return;
} }
@@ -77,7 +81,7 @@ export class WebhookAction extends Action {
* @param method HTTP method (GET or POST) * @param method HTTP method (GET or POST)
* @param timeout Request timeout in milliseconds * @param timeout Request timeout in milliseconds
*/ */
private async callWebhook( private callWebhook(
context: IActionContext, context: IActionContext,
method: 'GET' | 'POST', method: 'GET' | 'POST',
timeout: number, timeout: number,
@@ -109,7 +113,7 @@ export class WebhookAction extends Action {
url.searchParams.append('powerStatus', payload.powerStatus); url.searchParams.append('powerStatus', payload.powerStatus);
url.searchParams.append('batteryCapacity', String(payload.batteryCapacity)); url.searchParams.append('batteryCapacity', String(payload.batteryCapacity));
url.searchParams.append('batteryRuntime', String(payload.batteryRuntime)); url.searchParams.append('batteryRuntime', String(payload.batteryRuntime));
url.searchParams.append('triggerReason', payload.triggerReason); url.searchParams.append('triggerReason', payload.triggerReason);
url.searchParams.append('timestamp', String(payload.timestamp)); url.searchParams.append('timestamp', String(payload.timestamp));
} }

228
ts/cli.ts
View File

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

View File

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

View File

@@ -1,4 +1,3 @@
import process from 'node:process';
import { execSync } from 'node:child_process'; import { execSync } from 'node:child_process';
import { Nupst } from '../nupst.ts'; import { Nupst } from '../nupst.ts';
import { logger } from '../logger.ts'; import { logger } from '../logger.ts';
@@ -29,7 +28,9 @@ export class FeatureHandler {
await this.runHttpServerConfig(prompt); await this.runHttpServerConfig(prompt);
}); });
} catch (error) { } 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(`Auth Token: ${theme.warning(authToken)}`);
logger.logBoxLine(''); logger.logBoxLine('');
logger.logBoxLine(theme.dim('Usage examples:')); 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.logBoxLine(` curl "http://localhost:${port}${finalPath}?token=${authToken}"`);
logger.logBoxEnd(); logger.logBoxEnd();
logger.log(''); logger.log('');
@@ -165,7 +168,8 @@ export class FeatureHandler {
*/ */
private async restartServiceIfRunning(): Promise<void> { private async restartServiceIfRunning(): Promise<void> {
try { 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) { if (isActive) {
logger.log(''); logger.log('');

View File

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

View File

@@ -1,7 +1,12 @@
import process from 'node:process'; import process from 'node:process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { execSync } from 'node:child_process'; import { execSync } from 'node:child_process';
import { Nupst } from '../nupst.ts'; import { Nupst } from '../nupst.ts';
import { logger } from '../logger.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'; import * as helpers from '../helpers/index.ts';
/** /**
@@ -104,6 +109,125 @@ export class ServiceHandler {
await this.nupst.getSystemd().getStatus(debugOptions.debugMode); 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) * Disable the service (requires root)
*/ */
@@ -126,7 +250,7 @@ export class ServiceHandler {
/** /**
* Update NUPST from repository and refresh systemd service * Update NUPST from repository and refresh systemd service
*/ */
public async update(): Promise<void> { public update(): void {
try { try {
// Check if running as root // Check if running as root
this.checkRootAccess( this.checkRootAccess(
@@ -147,8 +271,12 @@ export class ServiceHandler {
const latestVersion = release.tag_name; // e.g., "v4.0.7" const latestVersion = release.tag_name; // e.g., "v4.0.7"
// Normalize versions for comparison (ensure both have "v" prefix) // Normalize versions for comparison (ensure both have "v" prefix)
const normalizedCurrent = currentVersion.startsWith('v') ? currentVersion : `v${currentVersion}`; const normalizedCurrent = currentVersion.startsWith('v')
const normalizedLatest = latestVersion.startsWith('v') ? latestVersion : `v${latestVersion}`; ? currentVersion
: `v${currentVersion}`;
const normalizedLatest = latestVersion.startsWith('v')
? latestVersion
: `v${latestVersion}`;
logger.dim(`Current version: ${normalizedCurrent}`); logger.dim(`Current version: ${normalizedCurrent}`);
logger.dim(`Latest version: ${normalizedLatest}`); logger.dim(`Latest version: ${normalizedLatest}`);

View File

@@ -1,12 +1,15 @@
import process from 'node:process'; import process from 'node:process';
import { execSync } from 'node:child_process'; import { execSync } from 'node:child_process';
import { Nupst } from '../nupst.ts'; 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 { theme } from '../colors.ts';
import * as helpers from '../helpers/index.ts'; import * as helpers from '../helpers/index.ts';
import type { ISnmpConfig, TUpsModel, IUpsStatus as ISnmpUpsStatus } from '../snmp/types.ts'; import type { ISnmpConfig, IUpsStatus as ISnmpUpsStatus, TUpsModel } from '../snmp/types.ts';
import type { INupstConfig, IUpsConfig, IUpsStatus } from '../daemon.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 type { IActionConfig } from '../actions/base-action.ts';
import { UPSD } from '../constants.ts';
/** /**
* Thresholds configuration for CLI display * Thresholds configuration for CLI display
@@ -66,10 +69,10 @@ export class UpsHandler {
checkInterval: config.checkInterval, checkInterval: config.checkInterval,
upsDevices: [{ upsDevices: [{
id: 'default', id: 'default',
name: 'Default UPS', name: 'Default UPS',
snmp: config.snmp, snmp: config.snmp,
groups: [], groups: [],
actions: [], actions: [],
}], }],
groups: [], groups: [],
}; };
@@ -89,31 +92,46 @@ export class UpsHandler {
const upsId = helpers.shortId(); const upsId = helpers.shortId();
const name = await prompt('UPS Name: '); 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 // 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, id: upsId,
name: name || `UPS-${upsId}`, name: name || `UPS-${upsId}`,
snmp: { protocol,
groups: [],
actions: [],
};
if (protocol === 'snmp') {
newUps.snmp = {
host: '127.0.0.1', host: '127.0.0.1',
port: 161, port: 161,
community: 'public', community: 'public',
version: 1, version: 1,
timeout: 5000, timeout: 5000,
upsModel: 'cyberpower' as TUpsModel, upsModel: 'cyberpower' as TUpsModel,
}, };
thresholds: { // Gather SNMP settings
battery: 60, await this.gatherSnmpSettings(newUps.snmp, prompt);
runtime: 20, // Gather UPS model settings
}, await this.gatherUpsModelSettings(newUps.snmp, prompt);
groups: [], } else {
actions: [], newUps.upsd = {
}; host: '127.0.0.1',
port: UPSD.DEFAULT_PORT,
// Gather SNMP settings upsName: UPSD.DEFAULT_UPS_NAME,
await this.gatherSnmpSettings(newUps.snmp, prompt); timeout: UPSD.DEFAULT_TIMEOUT_MS,
};
// Gather UPS model settings await this.gatherUpsdSettings(newUps.upsd, prompt);
await this.gatherUpsModelSettings(newUps.snmp, prompt); }
// Get access to GroupHandler for group assignments // Get access to GroupHandler for group assignments
const groupHandler = this.nupst.getGroupHandler(); const groupHandler = this.nupst.getGroupHandler();
@@ -123,7 +141,7 @@ export class UpsHandler {
await groupHandler.assignUpsToGroups(newUps, config.groups, prompt); await groupHandler.assignUpsToGroups(newUps, config.groups, prompt);
} }
// Gather action settings // Gather action settings
await this.gatherActionSettings(newUps.actions, prompt); await this.gatherActionSettings(newUps.actions, prompt);
// Add the new UPS to the config // Add the new UPS to the config
@@ -132,10 +150,14 @@ export class UpsHandler {
// Save the configuration // Save the configuration
await this.nupst.getDaemon().saveConfig(config as INupstConfig); await this.nupst.getDaemon().saveConfig(config as INupstConfig);
this.displayUpsConfigSummary(newUps); this.displayUpsConfigSummary(newUps as unknown as IUpsConfig);
// Test the connection if requested // 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 // Check if service is running and restart it if needed
await this.restartServiceIfRunning(); await this.restartServiceIfRunning();
@@ -232,11 +254,51 @@ export class UpsHandler {
upsToEdit.name = newName; upsToEdit.name = newName;
} }
// Edit SNMP settings // Show current protocol and allow changing
await this.gatherSnmpSettings(upsToEdit.snmp, prompt); 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 const editProtocol = upsToEdit.protocol || 'snmp';
await this.gatherUpsModelSettings(upsToEdit.snmp, prompt);
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 // Get access to GroupHandler for group assignments
const groupHandler = this.nupst.getGroupHandler(); const groupHandler = this.nupst.getGroupHandler();
@@ -260,7 +322,11 @@ export class UpsHandler {
this.displayUpsConfigSummary(upsToEdit); this.displayUpsConfigSummary(upsToEdit);
// Test the connection if requested // 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 // Check if service is running and restart it if needed
await this.restartServiceIfRunning(); await this.restartServiceIfRunning();
@@ -343,10 +409,15 @@ export class UpsHandler {
try { try {
await this.nupst.getDaemon().loadConfig(); await this.nupst.getDaemon().loadConfig();
} catch (error) { } catch (error) {
logger.logBox('Configuration Error', [ logger.logBox(
'No configuration found.', 'Configuration Error',
"Please run 'nupst ups add' first to create a configuration.", [
], 50, 'error'); 'No configuration found.',
"Please run 'nupst ups add' first to create a configuration.",
],
50,
'error',
);
return; return;
} }
@@ -356,46 +427,67 @@ export class UpsHandler {
// Check if multi-UPS config // Check if multi-UPS config
if (!config.upsDevices || !Array.isArray(config.upsDevices)) { if (!config.upsDevices || !Array.isArray(config.upsDevices)) {
// Legacy single UPS configuration // Legacy single UPS configuration
logger.logBox('UPS Devices', [ logger.logBox(
'Legacy single-UPS configuration detected.', 'UPS Devices',
'', [
...(!config.snmp 'Legacy single-UPS configuration detected.',
? ['Error: Configuration missing SNMP settings'] '',
: [ ...(!config.snmp ? ['Error: Configuration missing SNMP settings'] : [
'Default UPS:', 'Default UPS:',
` Host: ${config.snmp.host}:${config.snmp.port}`, ` Host: ${config.snmp.host}:${config.snmp.port}`,
` Model: ${config.snmp.upsModel || 'cyberpower'}`, ` Model: ${config.snmp.upsModel || 'cyberpower'}`,
'', '',
'Use "nupst ups add" to add more UPS devices and migrate', 'Use "nupst ups add" to add more UPS devices and migrate',
'to the multi-UPS configuration format.', 'to the multi-UPS configuration format.',
] ]),
), ],
], 60, 'warning'); 60,
'warning',
);
return; return;
} }
// Display UPS list with modern table // Display UPS list with modern table
if (config.upsDevices.length === 0) { if (config.upsDevices.length === 0) {
logger.logBox('UPS Devices', [ logger.logBox(
'No UPS devices configured.', 'UPS Devices',
'', [
`${theme.dim('Run')} ${theme.command('nupst ups add')} ${theme.dim('to add a device')}`, 'No UPS devices configured.',
], 60, 'info'); '',
`${theme.dim('Run')} ${theme.command('nupst ups add')} ${theme.dim('to add a device')}`,
],
60,
'info',
);
return; return;
} }
// Prepare table data // Prepare table data
const rows = config.upsDevices.map((ups) => ({ const rows = config.upsDevices.map((ups) => {
id: ups.id, const protocol = ups.protocol || 'snmp';
name: ups.name || '', let host = 'N/A';
host: `${ups.snmp.host}:${ups.snmp.port}`, let model = '';
model: ups.snmp.upsModel || 'cyberpower', if (protocol === 'upsd' && ups.upsd) {
groups: ups.groups.length > 0 ? ups.groups.join(', ') : theme.dim('None'), 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[] = [ const columns: ITableColumn[] = [
{ header: 'ID', key: 'id', align: 'left', color: theme.highlight }, { header: 'ID', key: 'id', align: 'left', color: theme.highlight },
{ header: 'Name', key: 'name', align: 'left' }, { header: 'Name', key: 'name', align: 'left' },
{ header: 'Protocol', key: 'protocol', align: 'left' },
{ header: 'Host:Port', key: 'host', align: 'left', color: theme.info }, { header: 'Host:Port', key: 'host', align: 'left', color: theme.info },
{ header: 'Model', key: 'model', align: 'left' }, { header: 'Model', key: 'model', align: 'left' },
{ header: 'Groups', key: 'groups', align: 'left' }, { header: 'Groups', key: 'groups', align: 'left' },
@@ -470,58 +562,71 @@ export class UpsHandler {
// Type guard: IUpsConfig has 'id' and 'name' at root level, INupstConfig doesn't // Type guard: IUpsConfig has 'id' and 'name' at root level, INupstConfig doesn't
const isUpsConfig = 'id' in config && 'name' in config; const isUpsConfig = 'id' in config && 'name' in config;
// Get SNMP config and other values based on config type
const snmpConfig: ISnmpConfig | undefined = isUpsConfig
? (config as IUpsConfig).snmp
: (config as INupstConfig).snmp;
const checkInterval = isUpsConfig ? 30000 : (config as INupstConfig).checkInterval || 30000; const checkInterval = isUpsConfig ? 30000 : (config as INupstConfig).checkInterval || 30000;
const upsName = isUpsConfig ? (config as IUpsConfig).name : 'Default UPS'; const upsName = isUpsConfig ? (config as IUpsConfig).name : 'Default UPS';
const upsId = isUpsConfig ? (config as IUpsConfig).id : 'default'; const upsId = isUpsConfig ? (config as IUpsConfig).id : 'default';
const protocol = isUpsConfig ? ((config as IUpsConfig).protocol || 'snmp') : 'snmp';
const boxWidth = 45; const boxWidth = 45;
logger.logBoxTitle(`Testing Configuration: ${upsName}`, boxWidth); logger.logBoxTitle(`Testing Configuration: ${upsName}`, boxWidth);
logger.logBoxLine(`UPS ID: ${upsId}`); logger.logBoxLine(`UPS ID: ${upsId}`);
logger.logBoxLine(`Protocol: ${protocol.toUpperCase()}`);
if (!snmpConfig) { if (protocol === 'upsd' && isUpsConfig && (config as IUpsConfig).upsd) {
logger.logBoxLine('SNMP Settings: Not configured'); const upsdConfig = (config as IUpsConfig).upsd!;
logger.logBoxEnd(); logger.logBoxLine('UPSD/NIS Settings:');
return; 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;
logger.logBoxLine('SNMP Settings:'); if (!snmpConfig) {
logger.logBoxLine(` Host: ${snmpConfig.host}`); logger.logBoxLine('SNMP Settings: Not configured');
logger.logBoxLine(` Port: ${snmpConfig.port}`); logger.logBoxEnd();
logger.logBoxLine(` Version: ${snmpConfig.version}`); return;
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}`);
// 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.securityLevel === 'authPriv') { logger.logBoxLine('SNMP Settings:');
logger.logBoxLine(` Privacy Protocol: ${snmpConfig.privProtocol || 'None'}`); 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 if (snmpConfig.upsModel === 'custom' && snmpConfig.customOIDs) {
logger.logBoxLine(` Timeout: ${snmpConfig.timeout / 1000} seconds`); 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 // Show group assignments if this is a UPS config
if (isUpsConfig) { if (isUpsConfig) {
const groups = (config as IUpsConfig).groups; const groups = (config as IUpsConfig).groups;
@@ -543,25 +648,36 @@ export class UpsHandler {
const isUpsConfig = 'id' in config && 'name' in config; const isUpsConfig = 'id' in config && 'name' in config;
const upsId = isUpsConfig ? (config as IUpsConfig).id : 'default'; const upsId = isUpsConfig ? (config as IUpsConfig).id : 'default';
const upsName = isUpsConfig ? (config as IUpsConfig).name : 'Default UPS'; const upsName = isUpsConfig ? (config as IUpsConfig).name : 'Default UPS';
logger.log(`\nTesting connection to UPS: ${upsName} (${upsId})...`); const protocol = isUpsConfig ? ((config as IUpsConfig).protocol || 'snmp') : 'snmp';
logger.log(`\nTesting connection to UPS: ${upsName} (${upsId}) via ${protocol.toUpperCase()}...`);
try { try {
// Get SNMP config based on config type let status: ISnmpUpsStatus;
const snmpConfig: ISnmpConfig | undefined = isUpsConfig
? (config as IUpsConfig).snmp
: (config as INupstConfig).snmp;
if (!snmpConfig) { if (protocol === 'upsd' && isUpsConfig && (config as IUpsConfig).upsd) {
throw new Error('SNMP configuration not found'); 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;
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 testConfig: ISnmpConfig = {
...snmpConfig,
timeout: Math.min(snmpConfig.timeout, 10000), // Use at most 10 seconds for testing
};
const status = await this.nupst.getSnmp().getUpsStatus(testConfig);
const boxWidth = 45; const boxWidth = 45;
logger.logBoxTitle(`Connection Successful: ${upsName}`, boxWidth); logger.logBoxTitle(`Connection Successful: ${upsName}`, boxWidth);
logger.logBoxLine('UPS Status:'); logger.logBoxLine('UPS Status:');
@@ -569,8 +685,6 @@ export class UpsHandler {
logger.logBoxLine(` Battery Capacity: ${status.batteryCapacity}%`); logger.logBoxLine(` Battery Capacity: ${status.batteryCapacity}%`);
logger.logBoxLine(` Runtime Remaining: ${status.batteryRuntime} minutes`); logger.logBoxLine(` Runtime Remaining: ${status.batteryRuntime} minutes`);
logger.logBoxEnd(); logger.logBoxEnd();
} catch (error) { } catch (error) {
const errorBoxWidth = 45; const errorBoxWidth = 45;
logger.logBoxTitle(`Connection Failed: ${upsName}`, errorBoxWidth); logger.logBoxTitle(`Connection Failed: ${upsName}`, errorBoxWidth);
@@ -862,6 +976,97 @@ export class UpsHandler {
} }
} }
/**
* 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 * Gather action configuration settings
* @param actions Actions array to configure * @param actions Actions array to configure
@@ -891,6 +1096,7 @@ export class UpsHandler {
logger.dim(' 1) Shutdown (system shutdown)'); logger.dim(' 1) Shutdown (system shutdown)');
logger.dim(' 2) Webhook (HTTP notification)'); logger.dim(' 2) Webhook (HTTP notification)');
logger.dim(' 3) Custom Script (run .sh file from /etc/nupst)'); 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 typeInput = await prompt('Select action type [1]: ');
const typeValue = parseInt(typeInput, 10) || 1; const typeValue = parseInt(typeInput, 10) || 1;
@@ -945,6 +1151,61 @@ export class UpsHandler {
if (timeoutInput.trim() && !isNaN(timeout)) { if (timeoutInput.trim() && !isNaN(timeout)) {
action.scriptTimeout = timeout * 1000; // Convert to ms 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 { } else {
logger.warn('Invalid action type, skipping'); logger.warn('Invalid action type, skipping');
continue; continue;
@@ -959,7 +1220,7 @@ export class UpsHandler {
logger.dim(' 4) Any change (every ~30s check)'); logger.dim(' 4) Any change (every ~30s check)');
const triggerInput = await prompt('Select trigger mode [1]: '); const triggerInput = await prompt('Select trigger mode [1]: ');
const triggerValue = parseInt(triggerInput, 10) || 1; const triggerValue = parseInt(triggerInput, 10) || 1;
switch (triggerValue) { switch (triggerValue) {
case 2: case 2:
action.triggerMode = 'onlyPowerChanges'; action.triggerMode = 'onlyPowerChanges';
@@ -975,11 +1236,16 @@ export class UpsHandler {
} }
// Configure thresholds if needed for onlyThresholds or powerChangesAndThresholds modes // 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.log('');
logger.info('Action Thresholds:'); 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 batteryInput = await prompt('Battery threshold percentage [60]: ');
const battery = parseInt(batteryInput, 10); const battery = parseInt(batteryInput, 10);
const batteryThreshold = (batteryInput.trim() && !isNaN(battery)) ? battery : 60; const batteryThreshold = (batteryInput.trim() && !isNaN(battery)) ? battery : 60;
@@ -995,7 +1261,11 @@ export class UpsHandler {
} }
actions.push(action as IActionConfig); 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): '); const more = await prompt('Add another action? (y/N): ');
addMore = more.toLowerCase() === 'y'; addMore = more.toLowerCase() === 'y';
@@ -1013,13 +1283,21 @@ export class UpsHandler {
*/ */
private displayUpsConfigSummary(ups: IUpsConfig): void { private displayUpsConfigSummary(ups: IUpsConfig): void {
const boxWidth = 45; const boxWidth = 45;
const protocol = ups.protocol || 'snmp';
logger.log(''); logger.log('');
logger.logBoxTitle(`UPS Configuration: ${ups.name}`, boxWidth); logger.logBoxTitle(`UPS Configuration: ${ups.name}`, boxWidth);
logger.logBoxLine(`UPS ID: ${ups.id}`); logger.logBoxLine(`UPS ID: ${ups.id}`);
logger.logBoxLine(`SNMP Host: ${ups.snmp.host}:${ups.snmp.port}`); logger.logBoxLine(`Protocol: ${protocol.toUpperCase()}`);
logger.logBoxLine(`SNMP Version: ${ups.snmp.version}`);
logger.logBoxLine(`UPS Model: ${ups.snmp.upsModel}`); 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) { if (ups.groups && ups.groups.length > 0) {
logger.logBoxLine(`Groups: ${ups.groups.join(', ')}`); logger.logBoxLine(`Groups: ${ups.groups.join(', ')}`);
} else { } else {

View File

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

View File

@@ -103,6 +103,62 @@ export const HTTP_SERVER = {
DEFAULT_PATH: '/ups-status', DEFAULT_PATH: '/ups-status',
} as const; } 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 * UI/Display constants
*/ */

View File

@@ -5,13 +5,17 @@ import { exec, execFile } from 'node:child_process';
import { promisify } from 'node:util'; import { promisify } from 'node:util';
import { NupstSnmp } from './snmp/manager.ts'; import { NupstSnmp } from './snmp/manager.ts';
import type { ISnmpConfig, IUpsStatus as ISnmpUpsStatus } from './snmp/types.ts'; import type { ISnmpConfig, IUpsStatus as ISnmpUpsStatus } from './snmp/types.ts';
import { 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 { 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 type { IActionConfig } from './actions/base-action.ts';
import { ActionManager, type IActionContext, type TPowerStatus } from './actions/index.ts'; import { ActionManager, type IActionContext, type TPowerStatus } from './actions/index.ts';
import { NupstHttpServer } from './http-server.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 execAsync = promisify(exec);
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
@@ -24,8 +28,12 @@ export interface IUpsConfig {
id: string; id: string;
/** Friendly name for the UPS */ /** Friendly name for the UPS */
name: string; name: string;
/** SNMP configuration settings */ /** Communication protocol (defaults to 'snmp') */
snmp: ISnmpConfig; 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 */ /** Group IDs this UPS belongs to */
groups: string[]; groups: string[];
/** Actions to trigger on power status changes and threshold violations */ /** Actions to trigger on power status changes and threshold violations */
@@ -62,6 +70,20 @@ export interface IHttpServerConfig {
authToken: string; authToken: string;
} }
/**
* Pause state interface
*/
export interface IPauseState {
/** Timestamp when pause was activated */
pausedAt: number;
/** Who initiated the pause (e.g., 'cli', 'api') */
pausedBy: string;
/** Optional reason for pausing */
reason?: string;
/** When to auto-resume (null = indefinite, timestamp in ms) */
resumeAt?: number | null;
}
/** /**
* Configuration interface for the daemon * Configuration interface for the daemon
*/ */
@@ -97,15 +119,17 @@ export interface INupstConfig {
export interface IUpsStatus { export interface IUpsStatus {
id: string; id: string;
name: string; name: string;
powerStatus: 'online' | 'onBattery' | 'unknown'; powerStatus: 'online' | 'onBattery' | 'unknown' | 'unreachable';
batteryCapacity: number; batteryCapacity: number;
batteryRuntime: number; batteryRuntime: number;
outputLoad: number; // Load percentage (0-100%) outputLoad: number; // Load percentage (0-100%)
outputPower: number; // Power in watts outputPower: number; // Power in watts
outputVoltage: number; // Voltage in volts outputVoltage: number; // Voltage in volts
outputCurrent: number; // Current in amps outputCurrent: number; // Current in amps
lastStatusChange: number; lastStatusChange: number;
lastCheckTime: number; lastCheckTime: number;
consecutiveFailures: number;
unreachableSince: number;
} }
/** /**
@@ -155,19 +179,25 @@ export class NupstDaemon {
], ],
groups: [], groups: [],
checkInterval: TIMING.CHECK_INTERVAL_MS, // Check every 30 seconds checkInterval: TIMING.CHECK_INTERVAL_MS, // Check every 30 seconds
} };
private config: INupstConfig; private config: INupstConfig;
private snmp: NupstSnmp; private snmp: NupstSnmp;
private upsd: NupstUpsd;
private protocolResolver: ProtocolResolver;
private isRunning: boolean = false; private isRunning: boolean = false;
private isPaused: boolean = false;
private pauseState: IPauseState | null = null;
private upsStatus: Map<string, IUpsStatus> = new Map(); private upsStatus: Map<string, IUpsStatus> = new Map();
private httpServer?: NupstHttpServer; private httpServer?: NupstHttpServer;
/** /**
* 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.snmp = snmp;
this.upsd = upsd;
this.protocolResolver = new ProtocolResolver(snmp, upsd);
this.config = this.DEFAULT_CONFIG; this.config = this.DEFAULT_CONFIG;
} }
@@ -230,10 +260,11 @@ export class NupstDaemon {
// Ensure version is always set and remove legacy fields before saving // Ensure version is always set and remove legacy fields before saving
const configToSave: INupstConfig = { const configToSave: INupstConfig = {
version: '4.1', version: '4.2',
upsDevices: config.upsDevices, upsDevices: config.upsDevices,
groups: config.groups, groups: config.groups,
checkInterval: config.checkInterval, checkInterval: config.checkInterval,
...(config.httpServer ? { httpServer: config.httpServer } : {}),
}; };
fs.writeFileSync(this.CONFIG_PATH, JSON.stringify(configToSave, null, 2)); fs.writeFileSync(this.CONFIG_PATH, JSON.stringify(configToSave, null, 2));
@@ -249,7 +280,12 @@ export class NupstDaemon {
* Helper method to log configuration errors consistently * Helper method to log configuration errors consistently
*/ */
private logConfigError(message: string): void { 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; return this.snmp;
} }
/**
* Get the UPSD instance
*/
public getNupstUpsd(): NupstUpsd {
return this.upsd;
}
/** /**
* Start the monitoring daemon * Start the monitoring daemon
*/ */
@@ -311,11 +354,16 @@ export class NupstDaemon {
this.config.httpServer.port, this.config.httpServer.port,
this.config.httpServer.path, this.config.httpServer.path,
this.config.httpServer.authToken, this.config.httpServer.authToken,
() => this.upsStatus () => this.upsStatus,
() => this.pauseState,
); );
this.httpServer.start(); this.httpServer.start();
} catch (error) { } 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, outputCurrent: 0,
lastStatusChange: Date.now(), lastStatusChange: Date.now(),
lastCheckTime: 0, lastCheckTime: 0,
consecutiveFailures: 0,
unreachableSince: 0,
}); });
} }
@@ -364,7 +414,6 @@ export class NupstDaemon {
* Log the loaded configuration settings * Log the loaded configuration settings
*/ */
private logConfigLoaded(): void { private logConfigLoaded(): void {
logger.log(''); logger.log('');
logger.logBoxTitle('Configuration Loaded', 70, 'success'); logger.logBoxTitle('Configuration Loaded', 70, 'success');
logger.logBoxLine(`Check Interval: ${this.config.checkInterval / 1000} seconds`); logger.logBoxLine(`Check Interval: ${this.config.checkInterval / 1000} seconds`);
@@ -374,20 +423,33 @@ export class NupstDaemon {
// Display UPS devices in a table // Display UPS devices in a table
if (this.config.upsDevices && this.config.upsDevices.length > 0) { if (this.config.upsDevices && this.config.upsDevices.length > 0) {
logger.info(`UPS Devices (${this.config.upsDevices.length}):`); 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: 'Name', key: 'name', align: 'left', color: theme.highlight },
{ header: 'ID', key: 'id', align: 'left', color: theme.dim }, { 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: 'Host:Port', key: 'host', align: 'left', color: theme.info },
{ header: 'Actions', key: 'actions', align: 'left' }, { header: 'Actions', key: 'actions', align: 'left' },
]; ];
const upsRows: Array<Record<string, string>> = this.config.upsDevices.map((ups) => ({ const upsRows: Array<Record<string, string>> = this.config.upsDevices.map((ups) => {
name: ups.name, const protocol = ups.protocol || 'snmp';
id: ups.id, let host = 'N/A';
host: `${ups.snmp.host}:${ups.snmp.port}`, if (protocol === 'upsd' && ups.upsd) {
actions: `${(ups.actions || []).length} configured`, 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.logTable(upsColumns, upsRows);
logger.log(''); logger.log('');
@@ -399,8 +461,10 @@ export class NupstDaemon {
// Display groups in a table // Display groups in a table
if (this.config.groups && this.config.groups.length > 0) { if (this.config.groups && this.config.groups.length > 0) {
logger.info(`Groups (${this.config.groups.length}):`); 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: 'Name', key: 'name', align: 'left', color: theme.highlight },
{ header: 'ID', key: 'id', align: 'left', color: theme.dim }, { header: 'ID', key: 'id', align: 'left', color: theme.dim },
{ header: 'Mode', key: 'mode', align: 'left', color: theme.info }, { header: 'Mode', key: 'mode', align: 'left', color: theme.info },
@@ -431,6 +495,79 @@ export class NupstDaemon {
this.isRunning = false; 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 * Monitor the UPS status and trigger shutdown when necessary
*/ */
@@ -449,7 +586,10 @@ export class NupstDaemon {
// Monitor continuously // Monitor continuously
while (this.isRunning) { while (this.isRunning) {
try { 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(); await this.checkAllUpsDevices();
// Log periodic status update // Log periodic status update
@@ -493,16 +633,24 @@ export class NupstDaemon {
outputCurrent: 0, outputCurrent: 0,
lastStatusChange: Date.now(), lastStatusChange: Date.now(),
lastCheckTime: 0, lastCheckTime: 0,
consecutiveFailures: 0,
unreachableSince: 0,
}); });
} }
// Check UPS status // Check UPS status via configured protocol
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 currentTime = Date.now(); const currentTime = Date.now();
// Get the current status from the map // Get the current status from the map
const currentStatus = this.upsStatus.get(ups.id); const currentStatus = this.upsStatus.get(ups.id);
// Successful query: reset consecutive failures
const wasUnreachable = currentStatus?.powerStatus === 'unreachable';
// Update status with new values // Update status with new values
const updatedStatus: IUpsStatus = { const updatedStatus: IUpsStatus = {
id: ups.id, id: ups.id,
@@ -516,10 +664,27 @@ export class NupstDaemon {
outputCurrent: status.outputCurrent, outputCurrent: status.outputCurrent,
lastCheckTime: currentTime, lastCheckTime: currentTime,
lastStatusChange: currentStatus?.lastStatusChange || currentTime, lastStatusChange: currentStatus?.lastStatusChange || currentTime,
consecutiveFailures: 0,
unreachableSince: 0,
}; };
// Check if power status changed // If UPS was unreachable and is now reachable, log recovery
if (currentStatus && currentStatus.powerStatus !== status.powerStatus) { 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.log('');
logger.logBoxTitle(`Power Status Change: ${ups.name}`, 60, 'warning'); logger.logBoxTitle(`Power Status Change: ${ups.name}`, 60, 'warning');
logger.logBoxLine(`Previous: ${formatPowerStatus(currentStatus.powerStatus)}`); logger.logBoxLine(`Previous: ${formatPowerStatus(currentStatus.powerStatus)}`);
@@ -538,7 +703,7 @@ export class NupstDaemon {
// Only check when on battery power // Only check when on battery power
if (status.powerStatus === 'onBattery' && ups.actions && ups.actions.length > 0) { if (status.powerStatus === 'onBattery' && ups.actions && ups.actions.length > 0) {
let anyThresholdExceeded = false; let anyThresholdExceeded = false;
for (const actionConfig of ups.actions) { for (const actionConfig of ups.actions) {
if (actionConfig.thresholds) { if (actionConfig.thresholds) {
if ( if (
@@ -561,11 +726,48 @@ export class NupstDaemon {
// Update the status in the map // Update the status in the map
this.upsStatus.set(ups.id, updatedStatus); this.upsStatus.set(ups.id, updatedStatus);
} catch (error) { } 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( 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) 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);
}
} }
} }
} }
@@ -575,15 +777,25 @@ export class NupstDaemon {
*/ */
private logAllUpsStatus(): void { private logAllUpsStatus(): void {
const timestamp = new Date().toISOString(); const timestamp = new Date().toISOString();
logger.log(''); 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}`); 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.logBoxEnd();
logger.log(''); logger.log('');
// Build table data // 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: 'UPS Name', key: 'name', align: 'left', color: theme.highlight },
{ header: 'ID', key: 'id', align: 'left', color: theme.dim }, { header: 'ID', key: 'id', align: 'left', color: theme.dim },
{ header: 'Power Status', key: 'powerStatus', align: 'left' }, { header: 'Power Status', key: 'powerStatus', align: 'left' },
@@ -595,7 +807,7 @@ export class NupstDaemon {
for (const [id, status] of this.upsStatus.entries()) { for (const [id, status] of this.upsStatus.entries()) {
const batteryColor = getBatteryColor(status.batteryCapacity); const batteryColor = getBatteryColor(status.batteryCapacity);
const runtimeColor = getRuntimeColor(status.batteryRuntime); const runtimeColor = getRuntimeColor(status.batteryRuntime);
rows.push({ rows.push({
name: status.name, name: status.name,
id: id, id: id,
@@ -609,10 +821,6 @@ export class NupstDaemon {
logger.log(''); logger.log('');
} }
/** /**
* Build action context from UPS state * Build action context from UPS state
* @param ups UPS configuration * @param ups UPS configuration
@@ -650,6 +858,14 @@ export class NupstDaemon {
previousStatus: IUpsStatus | undefined, previousStatus: IUpsStatus | undefined,
triggerReason: 'powerStatusChange' | 'thresholdViolation', triggerReason: 'powerStatusChange' | 'thresholdViolation',
): Promise<void> { ): 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 || []; const actions = ups.actions || [];
// Backward compatibility: if no actions configured, use default shutdown behavior // Backward compatibility: if no actions configured, use default shutdown behavior
@@ -796,7 +1012,9 @@ export class NupstDaemon {
logger.log(''); logger.log('');
logger.logBoxTitle('Shutdown Monitoring Active', UI.WIDE_BOX_WIDTH, 'warning'); 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(`Check interval: ${TIMING.SHUTDOWN_CHECK_INTERVAL_MS / 1000} seconds`);
logger.logBoxLine(`Max monitoring time: ${TIMING.MAX_SHUTDOWN_MONITORING_MS / 1000} seconds`); logger.logBoxLine(`Max monitoring time: ${TIMING.MAX_SHUTDOWN_MONITORING_MS / 1000} seconds`);
logger.logBoxEnd(); logger.logBoxEnd();
@@ -808,7 +1026,9 @@ export class NupstDaemon {
logger.info('Checking UPS status during shutdown...'); logger.info('Checking UPS status during shutdown...');
// Build table for 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: 'UPS Name', key: 'name', align: 'left', color: theme.highlight },
{ header: 'Battery', key: 'battery', align: 'right' }, { header: 'Battery', key: 'battery', align: 'right' },
{ header: 'Runtime', key: 'runtime', align: 'right' }, { header: 'Runtime', key: 'runtime', align: 'right' },
@@ -822,13 +1042,16 @@ export class NupstDaemon {
// Check all UPS devices // Check all UPS devices
for (const ups of this.config.upsDevices) { for (const ups of this.config.upsDevices) {
try { 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 batteryColor = getBatteryColor(status.batteryCapacity);
const runtimeColor = getRuntimeColor(status.batteryRuntime); const runtimeColor = getRuntimeColor(status.batteryRuntime);
const isCritical = status.batteryRuntime < THRESHOLDS.EMERGENCY_RUNTIME_MINUTES; const isCritical = status.batteryRuntime < THRESHOLDS.EMERGENCY_RUNTIME_MINUTES;
rows.push({ rows.push({
name: ups.name, name: ups.name,
battery: batteryColor(status.batteryCapacity + '%'), battery: batteryColor(status.batteryCapacity + '%'),
@@ -848,7 +1071,7 @@ export class NupstDaemon {
runtime: theme.error('N/A'), runtime: theme.error('N/A'),
status: theme.error('ERROR'), status: theme.error('ERROR'),
}); });
logger.error( logger.error(
`Error checking UPS ${ups.name} during shutdown: ${ `Error checking UPS ${ups.name} during shutdown: ${
upsError instanceof Error ? upsError.message : String(upsError) upsError instanceof Error ? upsError.message : String(upsError)
@@ -991,7 +1214,9 @@ export class NupstDaemon {
let lastConfigCheck = Date.now(); let lastConfigCheck = Date.now();
logger.log('Entering idle monitoring mode...'); 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 // Start file watcher for hot-reload
this.watchConfigFile(); this.watchConfigFile();
@@ -1049,7 +1274,7 @@ export class NupstDaemon {
logger.log('Config file watcher started'); logger.log('Config file watcher started');
for await (const event of watcher) { for await (const event of watcher) {
// Only respond to modify events on the config file // Respond to modify events on config file
if ( if (
event.kind === 'modify' && event.kind === 'modify' &&
event.paths.some((p) => p.includes('config.json')) event.paths.some((p) => p.includes('config.json'))
@@ -1058,6 +1283,14 @@ export class NupstDaemon {
await this.reloadConfig(); 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 // Stop watching if daemon stopped
if (!this.isRunning) { if (!this.isRunning) {
break; break;

View File

@@ -1,7 +1,7 @@
import * as http from 'node:http'; import * as http from 'node:http';
import { URL } from 'node:url'; import { URL } from 'node:url';
import { logger } from './logger.ts'; import { logger } from './logger.ts';
import type { IUpsStatus } from './daemon.ts'; import type { IPauseState, IUpsStatus } from './daemon.ts';
/** /**
* HTTP Server for exposing UPS status as JSON * HTTP Server for exposing UPS status as JSON
@@ -13,6 +13,7 @@ export class NupstHttpServer {
private path: string; private path: string;
private authToken: string; private authToken: string;
private getUpsStatus: () => Map<string, IUpsStatus>; private getUpsStatus: () => Map<string, IUpsStatus>;
private getPauseState: () => IPauseState | null;
/** /**
* Create a new HTTP server instance * Create a new HTTP server instance
@@ -20,17 +21,20 @@ export class NupstHttpServer {
* @param path URL path for the endpoint * @param path URL path for the endpoint
* @param authToken Authentication token required for access * @param authToken Authentication token required for access
* @param getUpsStatus Function to retrieve cached UPS status * @param getUpsStatus Function to retrieve cached UPS status
* @param getPauseState Function to retrieve current pause state
*/ */
constructor( constructor(
port: number, port: number,
path: string, path: string,
authToken: string, authToken: string,
getUpsStatus: () => Map<string, IUpsStatus> getUpsStatus: () => Map<string, IUpsStatus>,
getPauseState: () => IPauseState | null,
) { ) {
this.port = port; this.port = port;
this.path = path; this.path = path;
this.authToken = authToken; this.authToken = authToken;
this.getUpsStatus = getUpsStatus; this.getUpsStatus = getUpsStatus;
this.getPauseState = getPauseState;
} }
/** /**
@@ -70,7 +74,7 @@ export class NupstHttpServer {
if (!this.isAuthenticated(req)) { if (!this.isAuthenticated(req)) {
res.writeHead(401, { res.writeHead(401, {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'WWW-Authenticate': 'Bearer' 'WWW-Authenticate': 'Bearer',
}); });
res.end(JSON.stringify({ error: 'Unauthorized' })); res.end(JSON.stringify({ error: 'Unauthorized' }));
return; return;
@@ -79,12 +83,18 @@ export class NupstHttpServer {
// Get cached status (no refresh) // Get cached status (no refresh)
const statusMap = this.getUpsStatus(); const statusMap = this.getUpsStatus();
const statusArray = Array.from(statusMap.values()); const statusArray = Array.from(statusMap.values());
const pauseState = this.getPauseState();
const response = {
upsDevices: statusArray,
...(pauseState ? { paused: true, pauseState } : { paused: false }),
};
res.writeHead(200, { res.writeHead(200, {
'Content-Type': 'application/json', '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 { } else {
res.writeHead(404, { 'Content-Type': 'application/json' }); res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Not Found' })); 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}`); 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}`); 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 * Table column alignment options
@@ -230,7 +230,8 @@ export class Logger {
* Strip ANSI color codes from string for accurate length calculation * Strip ANSI color codes from string for accurate length calculation
*/ */
private stripAnsi(text: string): string { 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, ''); 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) * @param config - Raw configuration object to check (unknown schema for migrations)
* @returns True if migration should run, false otherwise * @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 * 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) * @param config - Raw configuration object to migrate (unknown schema for migrations)
* @returns Migrated configuration object * @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 * 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 { MigrationV1ToV2 } from './migration-v1-to-v2.ts';
export { MigrationV3ToV4 } from './migration-v3-to-v4.ts'; export { MigrationV3ToV4 } from './migration-v3-to-v4.ts';
export { MigrationV4_0ToV4_1 } from './migration-v4.0-to-v4.1.ts'; export { MigrationV4_0ToV4_1 } from './migration-v4.0-to-v4.1.ts';
export { MigrationV4_1ToV4_2 } from './migration-v4.1-to-v4.2.ts';

View File

@@ -2,6 +2,7 @@ import { BaseMigration } from './base-migration.ts';
import { MigrationV1ToV2 } from './migration-v1-to-v2.ts'; import { MigrationV1ToV2 } from './migration-v1-to-v2.ts';
import { MigrationV3ToV4 } from './migration-v3-to-v4.ts'; import { MigrationV3ToV4 } from './migration-v3-to-v4.ts';
import { MigrationV4_0ToV4_1 } from './migration-v4.0-to-v4.1.ts'; import { MigrationV4_0ToV4_1 } from './migration-v4.0-to-v4.1.ts';
import { MigrationV4_1ToV4_2 } from './migration-v4.1-to-v4.2.ts';
import { logger } from '../logger.ts'; import { logger } from '../logger.ts';
/** /**
@@ -19,7 +20,7 @@ export class MigrationRunner {
new MigrationV1ToV2(), new MigrationV1ToV2(),
new MigrationV3ToV4(), new MigrationV3ToV4(),
new MigrationV4_0ToV4_1(), 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 // 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 fromVersion = '1.x';
readonly toVersion = '2.0'; 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 // V1 format has snmp field directly at root, no upsDevices or upsList
return !!config.snmp && !config.upsDevices && !config.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...`); logger.info(`${this.getName()}: Converting single SNMP config to multi-UPS format...`);
const migrated = { const migrated = {

View File

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

View File

@@ -49,7 +49,7 @@ export class MigrationV4_0ToV4_1 extends BaseMigration {
readonly fromVersion = '4.0'; readonly fromVersion = '4.0';
readonly toVersion = '4.1'; 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 // Run if config is version 4.0
if (config.version === '4.0') { if (config.version === '4.0') {
return true; return true;
@@ -65,7 +65,7 @@ export class MigrationV4_0ToV4_1 extends BaseMigration {
return false; 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.info(`${this.getName()}: Migrating v4.0 config to v4.1 format...`);
logger.dim(` - Moving thresholds from UPS level to action level`); logger.dim(` - Moving thresholds from UPS level to action level`);
logger.dim(` - Creating default shutdown actions from existing thresholds`); 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 // 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) { if (deviceThresholds) {
migrated.actions = [ 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 { NupstSnmp } from './snmp/manager.ts';
import { NupstUpsd } from './upsd/client.ts';
import { NupstDaemon } from './daemon.ts'; import { NupstDaemon } from './daemon.ts';
import { NupstSystemd } from './systemd.ts'; import { NupstSystemd } from './systemd.ts';
import denoConfig from '../deno.json' with { type: 'json' }; 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 { export class Nupst implements INupstAccessor {
private readonly snmp: NupstSnmp; private readonly snmp: NupstSnmp;
private readonly upsd: NupstUpsd;
private readonly daemon: NupstDaemon; private readonly daemon: NupstDaemon;
private readonly systemd: NupstSystemd; private readonly systemd: NupstSystemd;
private readonly upsHandler: UpsHandler; private readonly upsHandler: UpsHandler;
@@ -34,7 +36,8 @@ export class Nupst implements INupstAccessor {
// Initialize core components // Initialize core components
this.snmp = new NupstSnmp(); this.snmp = new NupstSnmp();
this.snmp.setNupst(this); // Set up bidirectional reference 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); this.systemd = new NupstSystemd(this.daemon);
// Initialize handlers // Initialize handlers
@@ -52,6 +55,13 @@ export class Nupst implements INupstAccessor {
return this.snmp; return this.snmp;
} }
/**
* Get the UPSD manager for NUT protocol communication
*/
public getUpsd(): NupstUpsd {
return this.upsd;
}
/** /**
* Get the daemon manager for background monitoring * Get the daemon manager for background monitoring
*/ */
@@ -171,8 +181,8 @@ export class Nupst implements INupstAccessor {
const response = JSON.parse(data); const response = JSON.parse(data);
if (response.tag_name) { if (response.tag_name) {
// Strip 'v' prefix from tag name (e.g., "v5.1.7" -> "5.1.7") // Strip 'v' prefix from tag name (e.g., "v5.1.7" -> "5.1.7")
const version = response.tag_name.startsWith('v') const version = response.tag_name.startsWith('v')
? response.tag_name.substring(1) ? response.tag_name.substring(1)
: response.tag_name; : response.tag_name;
resolve(version); resolve(version);
} else { } else {

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( public snmpGet(
oid: string, oid: string,
config = this.DEFAULT_CONFIG, config = this.DEFAULT_CONFIG,
retryCount = 0, _retryCount = 0,
// deno-lint-ignore no-explicit-any
): Promise<any> { ): Promise<any> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (this.debug) { if (this.debug) {
@@ -105,6 +106,7 @@ export class NupstSnmp {
} }
// Create SNMP options based on configuration // Create SNMP options based on configuration
// deno-lint-ignore no-explicit-any
const options: any = { const options: any = {
port: config.port, port: config.port,
retries: SNMP.RETRIES, // Number of retries retries: SNMP.RETRIES, // Number of retries
@@ -132,6 +134,7 @@ export class NupstSnmp {
const securityLevel = config.securityLevel || 'noAuthNoPriv'; const securityLevel = config.securityLevel || 'noAuthNoPriv';
// Create the user object with required structure for net-snmp // Create the user object with required structure for net-snmp
// deno-lint-ignore no-explicit-any
const user: any = { const user: any = {
name: config.username || '', name: config.username || '',
}; };
@@ -197,7 +200,11 @@ export class NupstSnmp {
const levelName = Object.keys(snmp.SecurityLevel).find((key) => const levelName = Object.keys(snmp.SecurityLevel).find((key) =>
snmp.SecurityLevel[key] === user.level 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); session = snmp.createV3Session(config.host, user, options);
@@ -210,7 +217,8 @@ export class NupstSnmp {
const oids = [oid]; const oids = [oid];
// Send the GET request // 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 // Close the session to release resources
session.close(); session.close();
@@ -259,7 +267,9 @@ export class NupstSnmp {
} }
if (this.debug) { 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); resolve(value);
@@ -422,6 +432,7 @@ export class NupstSnmp {
oid: string, oid: string,
description: string, description: string,
config: ISnmpConfig, config: ISnmpConfig,
// deno-lint-ignore no-explicit-any
): Promise<any> { ): Promise<any> {
if (oid === '') { if (oid === '') {
if (this.debug) { if (this.debug) {
@@ -476,6 +487,7 @@ export class NupstSnmp {
oid: string, oid: string,
description: string, description: string,
config: ISnmpConfig, config: ISnmpConfig,
// deno-lint-ignore no-explicit-any
): Promise<any> { ): Promise<any> {
if (this.debug) { if (this.debug) {
logger.dim(`Retrying ${description} with fallback security level...`); logger.dim(`Retrying ${description} with fallback security level...`);
@@ -483,7 +495,7 @@ export class NupstSnmp {
// Try with authNoPriv if current level is authPriv // Try with authNoPriv if current level is authPriv
if (config.securityLevel === 'authPriv') { if (config.securityLevel === 'authPriv') {
const retryConfig = { ...config, securityLevel: 'authNoPriv' as 'authNoPriv' }; const retryConfig = { ...config, securityLevel: 'authNoPriv' as const };
try { try {
if (this.debug) { if (this.debug) {
logger.dim(`Retrying with authNoPriv security level`); logger.dim(`Retrying with authNoPriv security level`);
@@ -496,7 +508,9 @@ export class NupstSnmp {
} catch (retryError) { } catch (retryError) {
if (this.debug) { if (this.debug) {
logger.error( 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 // Try with noAuthNoPriv as a last resort
if (config.securityLevel === 'authPriv' || config.securityLevel === 'authNoPriv') { if (config.securityLevel === 'authPriv' || config.securityLevel === 'authNoPriv') {
const retryConfig = { ...config, securityLevel: 'noAuthNoPriv' as 'noAuthNoPriv' }; const retryConfig = { ...config, securityLevel: 'noAuthNoPriv' as const };
try { try {
if (this.debug) { if (this.debug) {
logger.dim(`Retrying with noAuthNoPriv security level`); logger.dim(`Retrying with noAuthNoPriv security level`);
@@ -517,7 +531,9 @@ export class NupstSnmp {
} catch (retryError) { } catch (retryError) {
if (this.debug) { if (this.debug) {
logger.error( 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 * 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 description Description of the value for logging
* @param config SNMP configuration * @param config SNMP configuration
* @returns Promise resolving to the SNMP value * @returns Promise resolving to the SNMP value
*/ */
private async tryStandardOids( private async tryStandardOids(
oid: string, _oid: string,
description: string, description: string,
config: ISnmpConfig, config: ISnmpConfig,
// deno-lint-ignore no-explicit-any
): Promise<any> { ): Promise<any> {
try { try {
// Try RFC 1628 standard UPS MIB OIDs // Try RFC 1628 standard UPS MIB OIDs
@@ -556,7 +573,9 @@ export class NupstSnmp {
} catch (stdError) { } catch (stdError) {
if (this.debug) { if (this.debug) {
logger.error( 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 { export interface IUpsStatus {
/** Current power status */ /** Current power status */
powerStatus: 'online' | 'onBattery' | 'unknown'; powerStatus: 'online' | 'onBattery' | 'unknown' | 'unreachable';
/** Battery capacity percentage */ /** Battery capacity percentage */
batteryCapacity: number; batteryCapacity: number;
/** Remaining runtime in minutes */ /** Remaining runtime in minutes */
@@ -23,7 +23,7 @@ export interface IUpsStatus {
/** Output current in amps */ /** Output current in amps */
outputCurrent: number; outputCurrent: number;
/** Raw values from SNMP responses */ /** 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 process from 'node:process';
import { promises as fs } from 'node:fs'; import { promises as fs } from 'node:fs';
import { execSync } from 'node:child_process'; import { 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 { NupstSnmp } from './snmp/manager.ts';
import { logger } from './logger.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 * Class for managing systemd service
@@ -54,7 +54,11 @@ WantedBy=multi-user.target
logger.log(''); logger.log('');
logger.error('No configuration found'); logger.error('No configuration found');
logger.log(` ${theme.dim('Config file:')} ${configPath}`); 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(''); logger.log('');
throw new Error('Configuration not found'); throw new Error('Configuration not found');
} }
@@ -155,16 +159,22 @@ WantedBy=multi-user.target
const updateStatus = nupst.getUpdateStatus(); const updateStatus = nupst.getUpdateStatus();
logger.log(''); logger.log('');
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 { } else {
logger.log(''); logger.log('');
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) { } catch (error) {
// If version check fails, show at least the current version // If version check fails, show at least the current version
try { try {
const nupst = this.daemon.getNupstSnmp().getNupst(); const nupst = this.daemon.getNupstSnmp().getNupst();
@@ -242,9 +252,15 @@ WantedBy=multi-user.target
// Display beautiful status // Display beautiful status
logger.log(''); logger.log('');
if (isActive) { 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 { } 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) { if (pid || memory || cpu) {
@@ -255,10 +271,11 @@ WantedBy=multi-user.target
logger.log(` ${details.join(' ')}`); logger.log(` ${details.join(' ')}`);
} }
logger.log(''); logger.log('');
} catch (error) { } catch (error) {
logger.log(''); 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(''); logger.log('');
} }
} }
@@ -295,13 +312,13 @@ WantedBy=multi-user.target
groups: [], groups: [],
actions: config.thresholds actions: config.thresholds
? [ ? [
{ {
type: 'shutdown', type: 'shutdown',
thresholds: config.thresholds, thresholds: config.thresholds,
triggerMode: 'onlyThresholds', triggerMode: 'onlyThresholds',
shutdownDelay: 5, shutdownDelay: 5,
}, },
] ]
: [], : [],
}; };
@@ -309,7 +326,9 @@ WantedBy=multi-user.target
} else { } else {
logger.log(''); logger.log('');
logger.warn('No UPS devices configured'); 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(''); logger.log('');
} }
} catch (error) { } catch (error) {
@@ -327,13 +346,24 @@ WantedBy=multi-user.target
*/ */
private async displaySingleUpsStatus(ups: IUpsConfig, snmp: NupstSnmp): Promise<void> { private async displaySingleUpsStatus(ups: IUpsConfig, snmp: NupstSnmp): Promise<void> {
try { try {
// Create a test config with a short timeout const protocol = ups.protocol || 'snmp';
const testConfig = { let status;
...ups.snmp,
timeout: Math.min(ups.snmp.timeout, 10000), // Use at most 10 seconds for status check
};
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 // Determine status symbol based on power status
let statusSymbol = symbols.unknown; let statusSymbol = symbols.unknown;
@@ -344,7 +374,9 @@ WantedBy=multi-user.target
} }
// Display UPS name and power status // 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 // Display battery with color coding
const batteryColor = getBatteryColor(status.batteryCapacity); const batteryColor = getBatteryColor(status.batteryCapacity);
@@ -352,19 +384,35 @@ WantedBy=multi-user.target
// Get threshold from actions (if any action has thresholds defined) // Get threshold from actions (if any action has thresholds defined)
const actionWithThresholds = ups.actions?.find((action) => action.thresholds); const actionWithThresholds = ups.actions?.find((action) => action.thresholds);
const batteryThreshold = actionWithThresholds?.thresholds?.battery; const batteryThreshold = actionWithThresholds?.thresholds?.battery;
const batterySymbol = batteryThreshold !== undefined && status.batteryCapacity >= batteryThreshold const batterySymbol =
? symbols.success batteryThreshold !== undefined && status.batteryCapacity >= batteryThreshold
: batteryThreshold !== undefined ? symbols.success
? symbols.warning : 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 // 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 // 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 // Display groups if any
if (ups.groups && ups.groups.length > 0) { if (ups.groups && ups.groups.length > 0) {
@@ -381,7 +429,9 @@ WantedBy=multi-user.target
for (const action of ups.actions) { for (const action of ups.actions) {
let actionDesc = `${action.type}`; let actionDesc = `${action.type}`;
if (action.thresholds) { 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) { if (action.shutdownDelay) {
actionDesc += `, delay=${action.shutdownDelay}s`; actionDesc += `, delay=${action.shutdownDelay}s`;
} }
@@ -398,12 +448,18 @@ WantedBy=multi-user.target
} }
logger.log(''); logger.log('');
} catch (error) { } catch (error) {
// Display error for this UPS // 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(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(''); logger.log('');
} }
} }
@@ -426,7 +482,9 @@ WantedBy=multi-user.target
// Display group name and mode // Display group name and mode
const modeColor = group.mode === 'redundant' ? theme.success : theme.warning; const modeColor = group.mode === 'redundant' ? theme.success : theme.warning;
logger.log( 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 // Display description if present
@@ -451,7 +509,9 @@ WantedBy=multi-user.target
for (const action of group.actions) { for (const action of group.actions) {
let actionDesc = `${action.type}`; let actionDesc = `${action.type}`;
if (action.thresholds) { if (action.thresholds) {
actionDesc += ` (${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) { if (action.shutdownDelay) {
actionDesc += `, delay=${action.shutdownDelay}s`; 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;
}