Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7de521078e | |||
| 42b8eaf6d2 | |||
| 782c8c9555 | |||
| 463c32ebba | |||
| 51aa68ff8d | |||
| cb34ae5041 | |||
| 165c7d29bb | |||
| ff2dc00f31 |
@@ -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.
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
132
changelog.md
132
changelog.md
@@ -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
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -17,4 +17,4 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"@ship.zone/szci": {}
|
"@ship.zone/szci": {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/",
|
||||||
|
|||||||
@@ -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()
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
47
test/test.ts
47
test/test.ts
@@ -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
|
||||||
|
|||||||
@@ -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'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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}`);
|
||||||
}
|
}
|
||||||
|
|||||||
352
ts/actions/proxmox-action.ts
Normal file
352
ts/actions/proxmox-action.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
228
ts/cli.ts
@@ -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
|
||||||
|
|||||||
@@ -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('');
|
||||||
|
|||||||
@@ -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('');
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
333
ts/daemon.ts
333
ts/daemon.ts
@@ -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;
|
||||||
|
|||||||
@@ -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}`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = [
|
||||||
{
|
{
|
||||||
|
|||||||
43
ts/migrations/migration-v4.1-to-v4.2.ts
Normal file
43
ts/migrations/migration-v4.1-to-v4.2.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
16
ts/nupst.ts
16
ts/nupst.ts
@@ -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
7
ts/protocol/index.ts
Normal 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
49
ts/protocol/resolver.ts
Normal 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
4
ts/protocol/types.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
/**
|
||||||
|
* Protocol type for UPS communication
|
||||||
|
*/
|
||||||
|
export type TProtocol = 'snmp' | 'upsd';
|
||||||
@@ -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)
|
||||||
|
}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
140
ts/systemd.ts
140
ts/systemd.ts
@@ -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
269
ts/upsd/client.ts
Normal 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
7
ts/upsd/index.ts
Normal 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
21
ts/upsd/types.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user