Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 782c8c9555 | |||
| 463c32ebba | |||
| 51aa68ff8d | |||
| cb34ae5041 | |||
| 165c7d29bb | |||
| ff2dc00f31 | |||
| fda072d15e | |||
| c7786e9626 | |||
| 91fe5f7ae6 | |||
| 07648b4880 | |||
| d0e3a4ae74 | |||
| 89ffd61717 | |||
| 60eadaf6a1 | |||
| bd52ba4cb2 | |||
| a3d6a8b75d | |||
| fbd71b1f3b |
@@ -5,19 +5,23 @@ Pre-compiled binaries for multiple platforms.
|
||||
### Installation
|
||||
|
||||
#### Option 1: Via npm (recommended)
|
||||
|
||||
```bash
|
||||
npm install -g @serve.zone/nupst
|
||||
```
|
||||
|
||||
#### Option 2: Via installer script
|
||||
|
||||
```bash
|
||||
curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash
|
||||
```
|
||||
|
||||
#### Option 3: Direct binary download
|
||||
|
||||
Download the appropriate binary for your platform from the assets below and make it executable.
|
||||
|
||||
### Supported Platforms
|
||||
|
||||
- Linux x86_64 (x64)
|
||||
- Linux ARM64 (aarch64)
|
||||
- macOS x86_64 (Intel)
|
||||
@@ -25,7 +29,9 @@ Download the appropriate binary for your platform from the assets below and make
|
||||
- Windows x86_64
|
||||
|
||||
### Checksums
|
||||
|
||||
SHA256 checksums are provided in `SHA256SUMS.txt` for binary verification.
|
||||
|
||||
### npm Package
|
||||
|
||||
The npm package includes automatic binary detection and installation for your platform.
|
||||
|
||||
1
.serena/.gitignore
vendored
1
.serena/.gitignore
vendored
@@ -1 +0,0 @@
|
||||
/cache
|
||||
@@ -1,71 +0,0 @@
|
||||
# language of the project (csharp, python, rust, java, typescript, go, cpp, or ruby)
|
||||
# * For C, use cpp
|
||||
# * For JavaScript, use typescript
|
||||
# Special requirements:
|
||||
# * csharp: Requires the presence of a .sln file in the project folder.
|
||||
language: typescript
|
||||
|
||||
# the encoding used by text files in the project
|
||||
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
|
||||
encoding: 'utf-8'
|
||||
|
||||
# whether to use the project's gitignore file to ignore files
|
||||
# Added on 2025-04-07
|
||||
ignore_all_files_in_gitignore: true
|
||||
# list of additional paths to ignore
|
||||
# same syntax as gitignore, so you can use * and **
|
||||
# Was previously called `ignored_dirs`, please update your config if you are using that.
|
||||
# Added (renamed) on 2025-04-07
|
||||
ignored_paths: []
|
||||
|
||||
# whether the project is in read-only mode
|
||||
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
|
||||
# Added on 2025-04-18
|
||||
read_only: false
|
||||
|
||||
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
|
||||
# Below is the complete list of tools for convenience.
|
||||
# To make sure you have the latest list of tools, and to view their descriptions,
|
||||
# execute `uv run scripts/print_tool_overview.py`.
|
||||
#
|
||||
# * `activate_project`: Activates a project by name.
|
||||
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
|
||||
# * `create_text_file`: Creates/overwrites a file in the project directory.
|
||||
# * `delete_lines`: Deletes a range of lines within a file.
|
||||
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
|
||||
# * `execute_shell_command`: Executes a shell command.
|
||||
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
|
||||
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
|
||||
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
|
||||
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
|
||||
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
|
||||
# * `initial_instructions`: Gets the initial instructions for the current project.
|
||||
# Should only be used in settings where the system prompt cannot be set,
|
||||
# e.g. in clients you have no control over, like Claude Desktop.
|
||||
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
|
||||
# * `insert_at_line`: Inserts content at a given line in a file.
|
||||
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
|
||||
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
|
||||
# * `list_memories`: Lists memories in Serena's project-specific memory store.
|
||||
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
|
||||
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
|
||||
# * `read_file`: Reads a file within the project directory.
|
||||
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
|
||||
# * `remove_project`: Removes a project from the Serena configuration.
|
||||
# * `replace_lines`: Replaces a range of lines within a file with new content.
|
||||
# * `replace_symbol_body`: Replaces the full definition of a symbol.
|
||||
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
|
||||
# * `search_for_pattern`: Performs a search for a pattern in the project.
|
||||
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
|
||||
# * `switch_modes`: Activates modes by providing a list of their names
|
||||
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
|
||||
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
|
||||
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
|
||||
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
|
||||
excluded_tools: []
|
||||
|
||||
# initial prompt for the project. It will always be given to the LLM upon activating the project
|
||||
# (contrary to the memories, which are loaded on demand).
|
||||
initial_prompt: ''
|
||||
|
||||
project_name: 'nupst'
|
||||
@@ -9,7 +9,8 @@ import { spawn } from 'child_process';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
import { existsSync } from 'fs';
|
||||
import { platform, arch } from 'os';
|
||||
import { arch, platform } from 'os';
|
||||
import process from "node:process";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
@@ -25,12 +26,12 @@ function getBinaryName() {
|
||||
const platformMap = {
|
||||
'darwin': 'macos',
|
||||
'linux': 'linux',
|
||||
'win32': 'windows'
|
||||
'win32': 'windows',
|
||||
};
|
||||
|
||||
const archMap = {
|
||||
'x64': 'x64',
|
||||
'arm64': 'arm64'
|
||||
'arm64': 'arm64',
|
||||
};
|
||||
|
||||
const mappedPlatform = platformMap[plat];
|
||||
@@ -76,7 +77,7 @@ function executeBinary() {
|
||||
// Spawn the binary with all arguments passed through
|
||||
const child = spawn(binaryPath, process.argv.slice(2), {
|
||||
stdio: 'inherit',
|
||||
shell: false
|
||||
shell: false,
|
||||
});
|
||||
|
||||
// Handle child process events
|
||||
@@ -95,7 +96,7 @@ function executeBinary() {
|
||||
|
||||
// Forward signals to child process
|
||||
const signals = ['SIGINT', 'SIGTERM', 'SIGHUP'];
|
||||
signals.forEach(signal => {
|
||||
signals.forEach((signal) => {
|
||||
process.on(signal, () => {
|
||||
if (!child.killed) {
|
||||
child.kill(signal);
|
||||
@@ -105,4 +106,4 @@ function executeBinary() {
|
||||
}
|
||||
|
||||
// Execute
|
||||
executeBinary();
|
||||
executeBinary();
|
||||
|
||||
119
changelog.md
119
changelog.md
@@ -1,28 +1,131 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-01-29 - 5.2.4 - fix()
|
||||
no changes
|
||||
|
||||
- No files changed in the provided git diff; no commit or version bump required.
|
||||
|
||||
## 2026-01-29 - 5.2.3 - fix(core)
|
||||
fix lint/type issues and small refactors
|
||||
|
||||
- Add missing node:process imports in bin and scripts to ensure process is available
|
||||
- Remove unused imports and unused type imports (e.g. writeFileSync, IActionConfig) to reduce noise
|
||||
- Make some methods synchronous (service update, webhook call) to match actual usage
|
||||
- Tighten SNMP typings and linting: added deno-lint-ignore comments, renamed unused params with leading underscore, and use `as const` for securityLevel fallbacks
|
||||
- Improve error handling variable naming in systemd (use error instead of _error)
|
||||
- Annotate ANSI regex with deno-lint-ignore no-control-regex and remove unused color/symbol imports across CLI/daemon/logger
|
||||
|
||||
## 2026-01-29 - 5.2.2 - fix(core)
|
||||
tidy formatting and minor fixes across CLI, SNMP, HTTP server, migrations and packaging
|
||||
|
||||
- Normalize import ordering and improve logger/string formatting across many CLI handlers, daemon, systemd, actions and tests
|
||||
- Apply formatting tidies: trailing commas, newline fixes, and more consistent multiline strings
|
||||
- Allow BaseMigration methods to return either sync or async results (shouldRun/migrate signatures updated)
|
||||
- Improve SNMP manager and HTTP server logging/error messages and tighten some typings (raw SNMP types, server error typing)
|
||||
- Small robustness and messaging improvements in npm installer and wrapper (platform/arch mapping, error outputs)
|
||||
- Update tests and documentation layout/formatting for readability
|
||||
|
||||
## 2026-01-29 - 5.2.1 - fix(cli(ups-handler), systemd)
|
||||
|
||||
add type guards and null checks for UPS configs; improve SNMP handling and prompts; guard version
|
||||
display
|
||||
|
||||
- Introduce a type guard ('id' in config && 'name' in config) to distinguish IUpsConfig from legacy
|
||||
INupstConfig and route fields (snmp, checkInterval, name, id) accordingly.
|
||||
- displayTestConfig now handles missing SNMP by logging 'Not configured' and returning, computes
|
||||
checkInterval/upsName/upsId correctly, and uses groups only for true UPS configs.
|
||||
- testConnection now safely derives snmpConfig for both config types, throws if SNMP is missing, and
|
||||
caps test timeout to 10s for probes.
|
||||
- Clear auth/priv credentials by setting undefined (instead of empty strings) when disabling
|
||||
security levels to avoid invalid/empty string values.
|
||||
- Expanded customOIDs to include OUTPUT_LOAD, OUTPUT_POWER, OUTPUT_VOLTAGE, OUTPUT_CURRENT with
|
||||
defaults; trim prompt input and document RFC 1628 fallbacks.
|
||||
- systemd.displayVersionInfo: guard against missing nupst (silent return) and avoid errors when
|
||||
printing version info; use ignored catch variables for clarity.
|
||||
|
||||
## 2026-01-29 - 5.2.0 - feat(core)
|
||||
|
||||
Centralize timeouts/constants, add CLI prompt helpers, and introduce webhook/script actions with
|
||||
safety and SNMP refactors
|
||||
|
||||
- Add ts/constants.ts to centralize timing, SNMP, webhook, script, shutdown and UI constants and
|
||||
replace magic numbers across the codebase
|
||||
- Introduce helpers/prompt.ts with createPrompt() and withPrompt() and refactor CLI handlers to use
|
||||
these helpers (cleaner prompt lifecycle)
|
||||
- Add webhook action support: ts/actions/webhook-action.ts, IWebhookPayload type, and export from
|
||||
ts/actions/index.ts
|
||||
- Enhance ShutdownAction safety checks (only trigger onBattery, stricter transition rules) and use
|
||||
constants/UI widths for displays
|
||||
- Refactor SNMP manager to use logger instead of console, pull SNMP defaults from constants,
|
||||
improved debug output, and add INupstAccessor interface to break circular dependency (Nupst now
|
||||
implements the interface)
|
||||
- Update many CLI and core types (stronger typing for configs/actions), expand tests and update
|
||||
README and npmextra.json to document new features
|
||||
|
||||
## 2025-11-09 - 5.1.11 - fix(readme)
|
||||
|
||||
Update README installation instructions to recommend automated installer script and clarify npm
|
||||
installation
|
||||
|
||||
- Replace the previous 'Via npm (NEW! - Recommended)' section with a clear 'Automated Installer
|
||||
Script (Recommended)' section and example curl installer.
|
||||
- Move npm installation instructions into an 'Alternative: Via npm' subsection and clarify that the
|
||||
npm package downloads the appropriate pre-compiled binary for the platform during installation.
|
||||
- Remove the 'NEW!' badge and streamline notes about binary downloads and installation methods.
|
||||
|
||||
## 2025-10-23 - 5.1.10 - fix(config)
|
||||
|
||||
Synchronize deno.json version with package.json, tidy formatting, and add local tooling settings
|
||||
|
||||
- Bumped deno.json version to 5.1.9 to match package.json/commitinfo
|
||||
- Reformatted deno.json arrays (lint, fmt, compilerOptions) for readability
|
||||
- Added .claude/settings.local.json for local development/tooling permissions (no runtime behaviour
|
||||
changes)
|
||||
|
||||
## 2025-10-23 - 5.1.9 - fix(dev)
|
||||
|
||||
Add local assistant permissions/settings file (.claude/settings.local.json)
|
||||
|
||||
- Added .claude/settings.local.json containing local assistant permission configuration used for
|
||||
development tasks (deno check, deno lint/format, npm/pack, running packaged binaries, etc.)
|
||||
- This is a development/local configuration file and does not change runtime behavior or product
|
||||
code paths
|
||||
- Patch version bump recommended
|
||||
|
||||
## 2025-10-23 - 5.1.2 - fix(scripts)
|
||||
|
||||
Add build script to package.json and include local dev tool settings
|
||||
|
||||
- Add a 'build' script to package.json (no-op placeholder) to provide an explicit build step
|
||||
- Minor scripts section formatting tidy in package.json
|
||||
- Add a hidden local settings file for development tooling permissions to the repository (local-only configuration)
|
||||
- Add a hidden local settings file for development tooling permissions to the repository (local-only
|
||||
configuration)
|
||||
|
||||
## 2025-10-23 - 5.1.1 - fix(tooling)
|
||||
|
||||
Add .claude/settings.local.json with local automation permissions
|
||||
|
||||
- Add .claude/settings.local.json to specify allowed permissions for local automated tasks
|
||||
- Grants permissions for various developer/CI actions (deno check/lint/fmt, npm/npm pack, selective Bash commands, WebFetch to docs.deno.com and code.foss.global, and file/read/replace helpers)
|
||||
- Grants permissions for various developer/CI actions (deno check/lint/fmt, npm/npm pack, selective
|
||||
Bash commands, WebFetch to docs.deno.com and code.foss.global, and file/read/replace helpers)
|
||||
- This is a developer/local tooling config only and does not change runtime code or package behavior
|
||||
|
||||
## 2025-10-22 - 5.1.0 - feat(packaging)
|
||||
Add npm packaging and installer: wrapper, postinstall downloader, publish workflow, and packaging files
|
||||
|
||||
Add npm packaging and installer: wrapper, postinstall downloader, publish workflow, and packaging
|
||||
files
|
||||
|
||||
- Add package.json (v5.0.5) and npm packaging metadata to publish @serve.zone/nupst
|
||||
- Include a small Node.js wrapper (bin/nupst-wrapper.js) to execute platform-specific precompiled binaries
|
||||
- Add postinstall script (scripts/install-binary.js) that downloads the correct binary for the current platform and sets executable permissions
|
||||
- Add GitHub Actions workflow (.github/workflows/npm-publish.yml) to build binaries, pack and publish to npm, and create releases
|
||||
- Add .npmignore to keep source, tests and dev files out of npm package; keep only runtime installer and wrapper
|
||||
- Move example action script into docs (docs/example-action.sh) and remove the top-level example-action.sh
|
||||
- Include a small Node.js wrapper (bin/nupst-wrapper.js) to execute platform-specific precompiled
|
||||
binaries
|
||||
- Add postinstall script (scripts/install-binary.js) that downloads the correct binary for the
|
||||
current platform and sets executable permissions
|
||||
- Add GitHub Actions workflow (.github/workflows/npm-publish.yml) to build binaries, pack and
|
||||
publish to npm, and create releases
|
||||
- Add .npmignore to keep source, tests and dev files out of npm package; keep only runtime installer
|
||||
and wrapper
|
||||
- Move example action script into docs (docs/example-action.sh) and remove the top-level
|
||||
example-action.sh
|
||||
- Include generated npm package artifact (serve.zone-nupst-5.0.5.tgz) and npmextra.json
|
||||
|
||||
## 2025-10-18 - 4.0.0 - BREAKING CHANGE(core): Complete migration to Deno runtime
|
||||
|
||||
10
deno.json
10
deno.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@serve.zone/nupst",
|
||||
"version": "5.1.8",
|
||||
"version": "5.2.4",
|
||||
"exports": "./mod.ts",
|
||||
"nodeModulesDir": "auto",
|
||||
"tasks": {
|
||||
@@ -15,7 +15,9 @@
|
||||
},
|
||||
"lint": {
|
||||
"rules": {
|
||||
"tags": ["recommended"]
|
||||
"tags": [
|
||||
"recommended"
|
||||
]
|
||||
}
|
||||
},
|
||||
"fmt": {
|
||||
@@ -26,7 +28,9 @@
|
||||
"singleQuote": true
|
||||
},
|
||||
"compilerOptions": {
|
||||
"lib": ["deno.window"],
|
||||
"lib": [
|
||||
"deno.window"
|
||||
],
|
||||
"strict": true
|
||||
},
|
||||
"imports": {
|
||||
|
||||
@@ -1 +1,20 @@
|
||||
{}
|
||||
{
|
||||
"@git.zone/cli": {
|
||||
"release": {
|
||||
"registries": [
|
||||
"https://verdaccio.lossless.digital"
|
||||
],
|
||||
"accessLevel": "public"
|
||||
},
|
||||
"projectType": "deno",
|
||||
"module": {
|
||||
"githost": "code.foss.global",
|
||||
"gitscope": "serve.zone",
|
||||
"gitrepo": "nupst",
|
||||
"description": "shut down in time when the power goes out",
|
||||
"npmPackagename": "@serve.zone/nupst",
|
||||
"license": "MIT"
|
||||
}
|
||||
},
|
||||
"@ship.zone/szci": {}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@serve.zone/nupst",
|
||||
"version": "5.1.8",
|
||||
"version": "5.2.4",
|
||||
"description": "Network UPS Shutdown Tool - Monitor SNMP-enabled UPS devices and orchestrate graceful system shutdowns during power emergencies",
|
||||
"keywords": [
|
||||
"ups",
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# NUPST Project Hints
|
||||
|
||||
## Recent Refactoring (January 2026)
|
||||
|
||||
### Phase 1 - Quick Wins
|
||||
|
||||
1. **Prompt Utility (`ts/helpers/prompt.ts`)**
|
||||
- Extracted readline/prompt pattern from all CLI handlers
|
||||
- Provides `createPrompt()` and `withPrompt()` helper functions
|
||||
- Used in: `ups-handler.ts`, `group-handler.ts`, `service-handler.ts`, `action-handler.ts`,
|
||||
`feature-handler.ts`
|
||||
|
||||
2. **Constants File (`ts/constants.ts`)**
|
||||
- Centralized all magic numbers (timeouts, intervals, thresholds)
|
||||
- Contains: `TIMING`, `SNMP`, `THRESHOLDS`, `WEBHOOK`, `SCRIPT`, `SHUTDOWN`, `HTTP_SERVER`, `UI`
|
||||
- Used in: `daemon.ts`, `snmp/manager.ts`, `actions/*.ts`
|
||||
|
||||
3. **Logger Consistency**
|
||||
- Replaced all `console.log/console.error` in `snmp/manager.ts` with proper `logger.*` calls
|
||||
- Debug output uses `logger.dim()` for less intrusive output
|
||||
|
||||
### Phase 2 - Type Safety
|
||||
|
||||
4. **Circular Dependency Fix (`ts/interfaces/nupst-accessor.ts`)**
|
||||
- Created `INupstAccessor` interface to break the circular dependency between `Nupst` and
|
||||
`NupstSnmp`
|
||||
- `NupstSnmp.nupst` property now uses the interface instead of `any`
|
||||
|
||||
5. **Webhook Payload Interface (`ts/actions/webhook-action.ts`)**
|
||||
- Added `IWebhookPayload` interface for webhook action payloads
|
||||
- Exported from `ts/actions/index.ts`
|
||||
|
||||
6. **CLI Handler Type Safety**
|
||||
- Replaced `any` types in `ups-handler.ts` and `group-handler.ts` with proper interfaces
|
||||
- Uses: `IUpsConfig`, `INupstConfig`, `ISnmpConfig`, `IActionConfig`, `IThresholds`,
|
||||
`ISnmpUpsStatus`
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
- **SNMP Manager**: Uses `INupstAccessor` interface (not direct `Nupst` reference) to avoid circular
|
||||
imports
|
||||
- **CLI Handlers**: All use the `helpers.withPrompt()` utility for interactive input
|
||||
- **Constants**: All timing values should be referenced from `ts/constants.ts`
|
||||
- **Actions**: Use `IActionConfig` from `ts/actions/base-action.ts` for action configuration
|
||||
|
||||
## File Organization
|
||||
|
||||
```
|
||||
ts/
|
||||
├── constants.ts # All timing/threshold constants
|
||||
├── interfaces/
|
||||
│ └── nupst-accessor.ts # Interface to break circular deps
|
||||
├── helpers/
|
||||
│ ├── prompt.ts # Readline utility
|
||||
│ └── shortid.ts # ID generation
|
||||
├── actions/
|
||||
│ ├── base-action.ts # Base action class and interfaces
|
||||
│ ├── webhook-action.ts # Includes IWebhookPayload
|
||||
│ └── ...
|
||||
└── cli/
|
||||
└── ... # All handlers use helpers.withPrompt()
|
||||
```
|
||||
|
||||
167
readme.md
167
readme.md
@@ -8,6 +8,14 @@ maximum reliability.
|
||||
**Version 5.0+** is powered by Deno and distributed as single pre-compiled binaries—no installation,
|
||||
no setup, just run.
|
||||
|
||||
## Issue Reporting and Security
|
||||
|
||||
For reporting bugs, issues, or security vulnerabilities, please visit
|
||||
[community.foss.global/](https://community.foss.global/). This is the central community hub for all
|
||||
issue reporting. Developers who sign and comply with our contribution agreement and go through
|
||||
identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull
|
||||
Requests directly.
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- **🔌 Multi-UPS Support**: Monitor multiple UPS devices from a single installation
|
||||
@@ -18,6 +26,8 @@ no setup, just run.
|
||||
- Battery threshold triggers
|
||||
- Runtime threshold triggers
|
||||
- Power status change triggers
|
||||
- Webhook notifications
|
||||
- Custom shell scripts
|
||||
- Configurable shutdown delays
|
||||
- **🌐 Universal SNMP Support**: Full support for SNMP v1, v2c, and v3 with authentication and
|
||||
encryption
|
||||
@@ -62,26 +72,7 @@ nupst service status
|
||||
|
||||
## 📥 Installation
|
||||
|
||||
### Via npm (NEW! - Recommended)
|
||||
|
||||
Install NUPST globally using npm:
|
||||
|
||||
```bash
|
||||
npm install -g @serve.zone/nupst
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- Automatic platform detection and binary download
|
||||
- Downloads only the binary for your platform (~400-500MB)
|
||||
- Easy updates via `npm update -g @serve.zone/nupst`
|
||||
- Version management with npm
|
||||
- Works with Node.js >=14
|
||||
|
||||
**Note:** The installation will download the appropriate binary from GitHub releases during the
|
||||
postinstall step.
|
||||
|
||||
### Automated Installer Script
|
||||
### Automated Installer Script (Recommended)
|
||||
|
||||
The installer script handles everything automatically:
|
||||
|
||||
@@ -136,6 +127,17 @@ chmod +x nupst
|
||||
sudo mv nupst /usr/local/bin/nupst
|
||||
```
|
||||
|
||||
### Alternative: Via npm
|
||||
|
||||
Alternatively, NUPST can be installed via npm:
|
||||
|
||||
```bash
|
||||
npm install -g @serve.zone/nupst
|
||||
```
|
||||
|
||||
**Note:** This method downloads the appropriate pre-compiled binary for your platform during
|
||||
installation.
|
||||
|
||||
### Verify Installation
|
||||
|
||||
```bash
|
||||
@@ -187,7 +189,7 @@ nupst group remove <id> # Remove a group
|
||||
nupst group list # List all groups
|
||||
```
|
||||
|
||||
### Action Management 🆕
|
||||
### Action Management
|
||||
|
||||
Actions define what happens when UPS conditions are met. Actions can be attached to individual UPS
|
||||
devices or to groups.
|
||||
@@ -206,6 +208,14 @@ nupst action list
|
||||
nupst action list <ups-id|group-id>
|
||||
```
|
||||
|
||||
**Supported Action Types:**
|
||||
|
||||
| Type | Description |
|
||||
| ---------- | ------------------------------------------------ |
|
||||
| `shutdown` | Graceful system shutdown with configurable delay |
|
||||
| `webhook` | HTTP POST/GET notification to external services |
|
||||
| `script` | Execute custom shell scripts from `/etc/nupst/` |
|
||||
|
||||
**Example: Adding an action**
|
||||
|
||||
```bash
|
||||
@@ -230,7 +240,7 @@ Add Action to UPS Main Server UPS
|
||||
Changes saved and will be applied automatically
|
||||
```
|
||||
|
||||
### Feature Management 🆕
|
||||
### Feature Management
|
||||
|
||||
Optional features like the HTTP server for JSON status export:
|
||||
|
||||
@@ -321,7 +331,7 @@ nupst config show # Display current configuration
|
||||
NUPST stores configuration at `/etc/nupst/config.json`. The easiest way to configure is through
|
||||
interactive commands, but you can also edit the JSON directly.
|
||||
|
||||
### Example Configuration (v4.1+)
|
||||
### Example Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -462,11 +472,26 @@ Actions define automated responses to UPS conditions:
|
||||
|
||||
| Field | Description | Values |
|
||||
| --------------- | -------------------------------- | -------------------------------------- |
|
||||
| `type` | Action type | Currently only 'shutdown' |
|
||||
| `type` | Action type | 'shutdown', 'webhook', 'script' |
|
||||
| `thresholds` | Battery and runtime limits | `{ battery: 0-100, runtime: minutes }` |
|
||||
| `triggerMode` | When to trigger action | See Trigger Modes below |
|
||||
| `shutdownDelay` | Delay before executing (seconds) | Default: 5 |
|
||||
|
||||
**Webhook-Specific Fields:**
|
||||
|
||||
| Field | Description | Values |
|
||||
| ---------------- | --------------------- | --------------- |
|
||||
| `webhookUrl` | URL to call | HTTP/HTTPS URL |
|
||||
| `webhookMethod` | HTTP method | 'POST' or 'GET' |
|
||||
| `webhookTimeout` | Request timeout in ms | Default: 10000 |
|
||||
|
||||
**Script-Specific Fields:**
|
||||
|
||||
| Field | Description | Values |
|
||||
| --------------- | -------------------------------- | ------------------- |
|
||||
| `scriptPath` | Script filename in `/etc/nupst/` | Must end with `.sh` |
|
||||
| `scriptTimeout` | Execution timeout in ms | Default: 60000 |
|
||||
|
||||
**Trigger Modes:**
|
||||
|
||||
| Mode | Description |
|
||||
@@ -497,7 +522,7 @@ Groups allow coordinated management of multiple UPS devices:
|
||||
- **`nonRedundant`**: System shuts down when ANY UPS device in the group is critical. Used when all
|
||||
UPS devices must be operational.
|
||||
|
||||
#### HTTP Server Configuration 🆕
|
||||
#### HTTP Server Configuration
|
||||
|
||||
Enable optional HTTP server for JSON status export with authentication:
|
||||
|
||||
@@ -825,7 +850,7 @@ When installed, NUPST makes the following changes:
|
||||
|
||||
## 🚀 Migration from v3.x
|
||||
|
||||
Upgrading from NUPST v3.x (Node.js) to v4.x (Deno) is seamless:
|
||||
Upgrading from NUPST v3.x (Node.js) to v4.x+ (Deno) is seamless:
|
||||
|
||||
```bash
|
||||
# One command to migrate everything
|
||||
@@ -837,12 +862,12 @@ curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh |
|
||||
- Detects v3.x installation
|
||||
- Stops the service
|
||||
- Replaces Node.js version with Deno binary
|
||||
- Migrates configuration (v4.0 → v4.1 format if needed)
|
||||
- Migrates configuration (v4.0 → v4.2 format if needed)
|
||||
- Restarts the service
|
||||
|
||||
### Key Changes in v4.x
|
||||
### Key Changes in v4.x+
|
||||
|
||||
| Aspect | v3.x | v4.x |
|
||||
| Aspect | v3.x | v4.x+ |
|
||||
| ------------------------ | -------------------------- | ----------------------------- |
|
||||
| **Runtime** | Node.js + npm | Deno |
|
||||
| **Distribution** | Git repo + npm install | Pre-compiled binaries |
|
||||
@@ -854,38 +879,8 @@ curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh |
|
||||
|
||||
### Configuration Compatibility
|
||||
|
||||
Your v3.x configuration is **fully compatible**. The migration system automatically converts:
|
||||
|
||||
**v4.0 format** (UPS-level thresholds):
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "4.0",
|
||||
"upsDevices": [{
|
||||
"id": "ups-1",
|
||||
"thresholds": { "battery": 60, "runtime": 20 }
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
**v4.1 format** (action-based thresholds):
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "4.1",
|
||||
"upsDevices": [{
|
||||
"id": "ups-1",
|
||||
"actions": [{
|
||||
"type": "shutdown",
|
||||
"thresholds": { "battery": 60, "runtime": 20 },
|
||||
"triggerMode": "onlyThresholds",
|
||||
"shutdownDelay": 5
|
||||
}]
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
Migration happens automatically on first run—no manual changes needed.
|
||||
Your v3.x configuration is **fully compatible**. The migration system automatically converts older
|
||||
formats to the current version.
|
||||
|
||||
## 💻 Development
|
||||
|
||||
@@ -920,29 +915,26 @@ deno task compile
|
||||
nupst/
|
||||
├── mod.ts # Entry point
|
||||
├── ts/
|
||||
│ ├── cli.ts # CLI command routing
|
||||
│ ├── nupst.ts # Main coordinator class
|
||||
│ ├── daemon.ts # Background monitoring daemon
|
||||
│ ├── systemd.ts # Systemd service management
|
||||
│ ├── snmp/ # SNMP implementation
|
||||
│ ├── actions/ # Action system
|
||||
│ ├── migrations/ # Config migration system
|
||||
│ └── cli/ # CLI handlers
|
||||
├── test/ # Test files
|
||||
├── scripts/ # Build scripts
|
||||
└── deno.json # Deno configuration
|
||||
│ ├── cli.ts # CLI command routing
|
||||
│ ├── nupst.ts # Main coordinator class
|
||||
│ ├── daemon.ts # Background monitoring daemon
|
||||
│ ├── systemd.ts # Systemd service management
|
||||
│ ├── constants.ts # Centralized configuration constants
|
||||
│ ├── interfaces/ # TypeScript interfaces
|
||||
│ ├── snmp/ # SNMP implementation
|
||||
│ ├── actions/ # Action system (shutdown, webhook, script)
|
||||
│ ├── helpers/ # Utility functions
|
||||
│ ├── migrations/ # Config migration system
|
||||
│ └── cli/ # CLI handlers
|
||||
├── test/ # Test files
|
||||
├── scripts/ # Build scripts
|
||||
└── deno.json # Deno configuration
|
||||
```
|
||||
|
||||
## 📞 Support
|
||||
|
||||
- **Issues**: [Report bugs or request features](https://code.foss.global/serve.zone/nupst/issues)
|
||||
- **Documentation**: [Full documentation](https://code.foss.global/serve.zone/nupst)
|
||||
- **Source Code**: [View source](https://code.foss.global/serve.zone/nupst)
|
||||
|
||||
## License and Legal Information
|
||||
|
||||
This repository contains open-source code that is licensed under the MIT License. A copy of the MIT
|
||||
License can be found in the [license](license) file within this repository.
|
||||
This repository contains open-source code licensed under the MIT License. A copy of the license can
|
||||
be found in the [LICENSE](./LICENSE) file.
|
||||
|
||||
**Please note:** The MIT License does not grant permission to use the trade names, trademarks,
|
||||
service marks, or product names of the project, except as required for reasonable and customary use
|
||||
@@ -952,16 +944,19 @@ in describing the origin of the work and reproducing the content of the NOTICE f
|
||||
|
||||
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated
|
||||
with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture
|
||||
Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these
|
||||
trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be
|
||||
approved in writing by Task Venture Capital GmbH.
|
||||
Capital GmbH or third parties, and are not included within the scope of the MIT license granted
|
||||
herein.
|
||||
|
||||
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the
|
||||
guidelines of the respective third-party owners, and any usage must be approved in writing.
|
||||
Third-party trademarks used herein are the property of their respective owners and used only in a
|
||||
descriptive manner, e.g. for an implementation of an API or similar.
|
||||
|
||||
### Company Information
|
||||
|
||||
Task Venture Capital GmbH Registered at District court Bremen HRB 35230 HB, Germany
|
||||
Task Venture Capital GmbH Registered at District Court Bremen HRB 35230 HB, Germany
|
||||
|
||||
For any legal inquiries or if you require further information, please contact us via email at
|
||||
hello@task.vc.
|
||||
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
||||
|
||||
By using this repository, you acknowledge that you have read this section, agree to comply with its
|
||||
terms, and understand that the licensing of the code does not imply endorsement by Task Venture
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
#!/usr/bin/env node
|
||||
// deno-lint-ignore-file no-unused-vars
|
||||
|
||||
/**
|
||||
* NUPST npm postinstall script
|
||||
* Downloads the appropriate binary for the current platform from GitHub releases
|
||||
*/
|
||||
|
||||
import { platform, arch } from 'os';
|
||||
import { existsSync, mkdirSync, writeFileSync, chmodSync, unlinkSync } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
import { arch, platform } from 'os';
|
||||
import { chmodSync, existsSync, mkdirSync, unlinkSync } from 'fs';
|
||||
import { dirname, join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import https from 'https';
|
||||
import { pipeline } from 'stream';
|
||||
import { promisify } from 'util';
|
||||
import { createWriteStream } from 'fs';
|
||||
import process from "node:process";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
@@ -29,12 +31,12 @@ function getBinaryInfo() {
|
||||
const platformMap = {
|
||||
'darwin': 'macos',
|
||||
'linux': 'linux',
|
||||
'win32': 'windows'
|
||||
'win32': 'windows',
|
||||
};
|
||||
|
||||
const archMap = {
|
||||
'x64': 'x64',
|
||||
'arm64': 'arm64'
|
||||
'arm64': 'arm64',
|
||||
};
|
||||
|
||||
const mappedPlatform = platformMap[plat];
|
||||
@@ -54,7 +56,7 @@ function getBinaryInfo() {
|
||||
platform: mappedPlatform,
|
||||
arch: mappedArch,
|
||||
binaryName,
|
||||
originalPlatform: plat
|
||||
originalPlatform: plat,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -122,7 +124,9 @@ async function main() {
|
||||
const binaryInfo = getBinaryInfo();
|
||||
|
||||
if (!binaryInfo.supported) {
|
||||
console.error(`❌ Error: Unsupported platform/architecture: ${binaryInfo.platform}/${binaryInfo.arch}`);
|
||||
console.error(
|
||||
`❌ Error: Unsupported platform/architecture: ${binaryInfo.platform}/${binaryInfo.arch}`,
|
||||
);
|
||||
console.error('');
|
||||
console.error('Supported platforms:');
|
||||
console.error(' • Linux (x64, arm64)');
|
||||
@@ -185,7 +189,9 @@ async function main() {
|
||||
console.error('You can try:');
|
||||
console.error('1. Installing from source: https://code.foss.global/serve.zone/nupst');
|
||||
console.error('2. Downloading the binary manually from the releases page');
|
||||
console.error('3. Using the install script: curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash');
|
||||
console.error(
|
||||
'3. Using the install script: curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash',
|
||||
);
|
||||
|
||||
// Clean up partial download
|
||||
if (existsSync(binaryPath)) {
|
||||
@@ -225,7 +231,7 @@ async function main() {
|
||||
}
|
||||
|
||||
// Run the installation
|
||||
main().catch(err => {
|
||||
main().catch((err) => {
|
||||
console.error(`❌ Installation failed: ${err.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Manual Docker Testing Scripts
|
||||
|
||||
This directory contains scripts for manually testing NUPST installation and migration in Docker containers with systemd support.
|
||||
This directory contains scripts for manually testing NUPST installation and migration in Docker
|
||||
containers with systemd support.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -15,12 +16,14 @@ This directory contains scripts for manually testing NUPST installation and migr
|
||||
Creates a Docker container with systemd and installs NUPST v3.
|
||||
|
||||
**What it does:**
|
||||
|
||||
- Creates Ubuntu 22.04 container with systemd enabled
|
||||
- Installs NUPST v3 from commit `806f81c6` (last v3 version)
|
||||
- Enables and starts the systemd service
|
||||
- Leaves container running for testing
|
||||
|
||||
**Usage:**
|
||||
|
||||
```bash
|
||||
chmod +x 01-setup-v3-container.sh
|
||||
./01-setup-v3-container.sh
|
||||
@@ -33,6 +36,7 @@ chmod +x 01-setup-v3-container.sh
|
||||
Tests the migration from v3 to v4.
|
||||
|
||||
**What it does:**
|
||||
|
||||
- Checks current v3 installation
|
||||
- Pulls v4 code from `migration/deno-v4` branch
|
||||
- Runs install.sh (should auto-detect and migrate)
|
||||
@@ -40,6 +44,7 @@ Tests the migration from v3 to v4.
|
||||
- Tests basic commands
|
||||
|
||||
**Usage:**
|
||||
|
||||
```bash
|
||||
chmod +x 02-test-v3-to-v4-migration.sh
|
||||
./02-test-v3-to-v4-migration.sh
|
||||
@@ -52,6 +57,7 @@ chmod +x 02-test-v3-to-v4-migration.sh
|
||||
Removes the test container.
|
||||
|
||||
**Usage:**
|
||||
|
||||
```bash
|
||||
chmod +x 03-cleanup.sh
|
||||
./03-cleanup.sh
|
||||
@@ -134,16 +140,19 @@ docker rm -f nupst-test-v3
|
||||
## Troubleshooting
|
||||
|
||||
### Container won't start
|
||||
|
||||
- Ensure Docker daemon is running
|
||||
- Check you have privileged access
|
||||
- Try: `docker logs nupst-test-v3`
|
||||
|
||||
### Systemd not working in container
|
||||
|
||||
- Requires Linux host (not macOS/Windows)
|
||||
- Needs `--privileged` and cgroup volume mounts
|
||||
- Check: `docker exec nupst-test-v3 systemctl --version`
|
||||
|
||||
### Migration fails
|
||||
|
||||
- Check logs: `docker exec nupst-test-v3 journalctl -xe`
|
||||
- Verify install.sh ran: `docker exec nupst-test-v3 ls -la /opt/nupst/`
|
||||
- Check service: `docker exec nupst-test-v3 systemctl status nupst`
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
* Run with: deno run --allow-all test/showcase.ts
|
||||
*/
|
||||
|
||||
import { logger, type ITableColumn } from '../ts/logger.ts';
|
||||
import { theme, symbols, getBatteryColor, formatPowerStatus } from '../ts/colors.ts';
|
||||
import { type ITableColumn, logger } from '../ts/logger.ts';
|
||||
import { formatPowerStatus, getBatteryColor, symbols, theme } from '../ts/colors.ts';
|
||||
|
||||
console.log('');
|
||||
console.log('═'.repeat(80));
|
||||
@@ -38,31 +38,51 @@ logger.logBoxEnd();
|
||||
|
||||
console.log('');
|
||||
|
||||
logger.logBox('Success Box (Green)', [
|
||||
'Used for successful operations',
|
||||
'Installation complete, service started, etc.',
|
||||
], 60, 'success');
|
||||
logger.logBox(
|
||||
'Success Box (Green)',
|
||||
[
|
||||
'Used for successful operations',
|
||||
'Installation complete, service started, etc.',
|
||||
],
|
||||
60,
|
||||
'success',
|
||||
);
|
||||
|
||||
console.log('');
|
||||
|
||||
logger.logBox('Error Box (Red)', [
|
||||
'Used for critical errors and failures',
|
||||
'Configuration errors, connection failures, etc.',
|
||||
], 60, 'error');
|
||||
logger.logBox(
|
||||
'Error Box (Red)',
|
||||
[
|
||||
'Used for critical errors and failures',
|
||||
'Configuration errors, connection failures, etc.',
|
||||
],
|
||||
60,
|
||||
'error',
|
||||
);
|
||||
|
||||
console.log('');
|
||||
|
||||
logger.logBox('Warning Box (Yellow)', [
|
||||
'Used for warnings and deprecations',
|
||||
'Old command format, missing config, etc.',
|
||||
], 60, 'warning');
|
||||
logger.logBox(
|
||||
'Warning Box (Yellow)',
|
||||
[
|
||||
'Used for warnings and deprecations',
|
||||
'Old command format, missing config, etc.',
|
||||
],
|
||||
60,
|
||||
'warning',
|
||||
);
|
||||
|
||||
console.log('');
|
||||
|
||||
logger.logBox('Info Box (Cyan)', [
|
||||
'Used for informational messages',
|
||||
'Version info, update available, etc.',
|
||||
], 60, 'info');
|
||||
logger.logBox(
|
||||
'Info Box (Cyan)',
|
||||
[
|
||||
'Used for informational messages',
|
||||
'Version info, update available, etc.',
|
||||
],
|
||||
60,
|
||||
'info',
|
||||
);
|
||||
|
||||
console.log('');
|
||||
|
||||
@@ -112,15 +132,24 @@ const upsColumns: ITableColumn[] = [
|
||||
{ header: 'ID', key: 'id' },
|
||||
{ header: 'Name', key: 'name' },
|
||||
{ header: 'Host', key: 'host' },
|
||||
{ header: 'Status', key: 'status', color: (v) => {
|
||||
if (v.includes('Online')) return theme.success(v);
|
||||
if (v.includes('Battery')) return theme.warning(v);
|
||||
return theme.dim(v);
|
||||
}},
|
||||
{ header: 'Battery', key: 'battery', align: 'right', color: (v) => {
|
||||
const pct = parseInt(v);
|
||||
return getBatteryColor(pct)(v);
|
||||
}},
|
||||
{
|
||||
header: 'Status',
|
||||
key: 'status',
|
||||
color: (v) => {
|
||||
if (v.includes('Online')) return theme.success(v);
|
||||
if (v.includes('Battery')) return theme.warning(v);
|
||||
return theme.dim(v);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Battery',
|
||||
key: 'battery',
|
||||
align: 'right',
|
||||
color: (v) => {
|
||||
const pct = parseInt(v);
|
||||
return getBatteryColor(pct)(v);
|
||||
},
|
||||
},
|
||||
{ header: 'Runtime', key: 'runtime', align: 'right' },
|
||||
];
|
||||
|
||||
|
||||
442
test/test.ts
442
test/test.ts
@@ -1,93 +1,377 @@
|
||||
import { assert, assertEquals, assertExists } from 'jsr:@std/assert@^1.0.0';
|
||||
import { NupstSnmp } from '../ts/snmp/manager.ts';
|
||||
import type { ISnmpConfig } from '../ts/snmp/types.ts';
|
||||
import { UpsOidSets } from '../ts/snmp/oid-sets.ts';
|
||||
import type { IOidSet, ISnmpConfig, TUpsModel } from '../ts/snmp/types.ts';
|
||||
import { shortId } from '../ts/helpers/shortid.ts';
|
||||
import { HTTP_SERVER, SNMP, THRESHOLDS, TIMING, UI } from '../ts/constants.ts';
|
||||
import { Action, type IActionContext } from '../ts/actions/base-action.ts';
|
||||
|
||||
import * as qenv from 'npm:@push.rocks/qenv@^6.0.0';
|
||||
const testQenv = new qenv.Qenv('./', '.nogit/');
|
||||
|
||||
// Create an SNMP instance with debug enabled
|
||||
// =============================================================================
|
||||
// UNIT TESTS - No external dependencies required
|
||||
// =============================================================================
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// shortId() Tests
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
Deno.test('shortId: generates 6-character string', () => {
|
||||
const id = shortId();
|
||||
assertEquals(id.length, 6);
|
||||
});
|
||||
|
||||
Deno.test('shortId: contains only alphanumeric characters', () => {
|
||||
const id = shortId();
|
||||
const alphanumericRegex = /^[a-zA-Z0-9]+$/;
|
||||
assert(alphanumericRegex.test(id), `ID "${id}" contains non-alphanumeric characters`);
|
||||
});
|
||||
|
||||
Deno.test('shortId: generates unique IDs', () => {
|
||||
const ids = new Set<string>();
|
||||
const count = 100;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
ids.add(shortId());
|
||||
}
|
||||
|
||||
// All IDs should be unique (statistically extremely likely for 100 IDs)
|
||||
assertEquals(ids.size, count, 'Generated IDs should be unique');
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Constants Tests
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
Deno.test('TIMING constants: all values are positive numbers', () => {
|
||||
for (const [key, value] of Object.entries(TIMING)) {
|
||||
assert(typeof value === 'number', `TIMING.${key} should be a number`);
|
||||
assert(value > 0, `TIMING.${key} should be positive`);
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test('SNMP constants: port is 161', () => {
|
||||
assertEquals(SNMP.DEFAULT_PORT, 161);
|
||||
});
|
||||
|
||||
Deno.test('SNMP constants: timeouts increase with security level', () => {
|
||||
assert(
|
||||
SNMP.TIMEOUT_NO_AUTH_MS <= SNMP.TIMEOUT_AUTH_MS,
|
||||
'Auth timeout should be >= noAuth timeout',
|
||||
);
|
||||
assert(
|
||||
SNMP.TIMEOUT_AUTH_MS <= SNMP.TIMEOUT_AUTH_PRIV_MS,
|
||||
'AuthPriv timeout should be >= Auth timeout',
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test('THRESHOLDS constants: defaults are reasonable', () => {
|
||||
assert(THRESHOLDS.DEFAULT_BATTERY_PERCENT > 0 && THRESHOLDS.DEFAULT_BATTERY_PERCENT <= 100);
|
||||
assert(THRESHOLDS.DEFAULT_RUNTIME_MINUTES > 0);
|
||||
assert(THRESHOLDS.EMERGENCY_RUNTIME_MINUTES < THRESHOLDS.DEFAULT_RUNTIME_MINUTES);
|
||||
});
|
||||
|
||||
Deno.test('HTTP_SERVER constants: valid defaults', () => {
|
||||
assertEquals(HTTP_SERVER.DEFAULT_PORT, 8080);
|
||||
assert(HTTP_SERVER.DEFAULT_PATH.startsWith('/'));
|
||||
});
|
||||
|
||||
Deno.test('UI constants: box widths are ascending', () => {
|
||||
assert(UI.DEFAULT_BOX_WIDTH < UI.WIDE_BOX_WIDTH);
|
||||
assert(UI.WIDE_BOX_WIDTH < UI.EXTRA_WIDE_BOX_WIDTH);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// UpsOidSets Tests
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
const UPS_MODELS: TUpsModel[] = ['cyberpower', 'apc', 'eaton', 'tripplite', 'liebert', 'custom'];
|
||||
|
||||
Deno.test('UpsOidSets: all models have OID sets', () => {
|
||||
for (const model of UPS_MODELS) {
|
||||
const oidSet = UpsOidSets.getOidSet(model);
|
||||
assertExists(oidSet, `OID set for ${model} should exist`);
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test('UpsOidSets: all non-custom models have complete OIDs', () => {
|
||||
const requiredOids = ['POWER_STATUS', 'BATTERY_CAPACITY', 'BATTERY_RUNTIME', 'OUTPUT_LOAD'];
|
||||
|
||||
for (const model of UPS_MODELS.filter((m) => m !== 'custom')) {
|
||||
const oidSet = UpsOidSets.getOidSet(model);
|
||||
|
||||
for (const oid of requiredOids) {
|
||||
const value = oidSet[oid as keyof IOidSet];
|
||||
assert(
|
||||
typeof value === 'string' && value.length > 0,
|
||||
`${model} should have non-empty ${oid}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test('UpsOidSets: power status values defined for non-custom models', () => {
|
||||
for (const model of UPS_MODELS.filter((m) => m !== 'custom')) {
|
||||
const oidSet = UpsOidSets.getOidSet(model);
|
||||
assertExists(oidSet.POWER_STATUS_VALUES, `${model} should have POWER_STATUS_VALUES`);
|
||||
assertExists(oidSet.POWER_STATUS_VALUES?.online, `${model} should have online value`);
|
||||
assertExists(oidSet.POWER_STATUS_VALUES?.onBattery, `${model} should have onBattery value`);
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test('UpsOidSets: getStandardOids returns RFC 1628 OIDs', () => {
|
||||
const standardOids = UpsOidSets.getStandardOids();
|
||||
|
||||
assert('power status' in standardOids);
|
||||
assert('battery capacity' in standardOids);
|
||||
assert('battery runtime' in standardOids);
|
||||
|
||||
// RFC 1628 OIDs start with 1.3.6.1.2.1.33
|
||||
for (const oid of Object.values(standardOids)) {
|
||||
assert(oid.startsWith('1.3.6.1.2.1.33'), `Standard OID should be RFC 1628: ${oid}`);
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Action Base Class Tests
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// Create a concrete implementation for testing
|
||||
class TestAction extends Action {
|
||||
readonly type = 'test';
|
||||
executeCallCount = 0;
|
||||
|
||||
execute(_context: IActionContext): Promise<void> {
|
||||
this.executeCallCount++;
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// Expose protected methods for testing
|
||||
public testShouldExecute(context: IActionContext): boolean {
|
||||
return this.shouldExecute(context);
|
||||
}
|
||||
|
||||
public testAreThresholdsExceeded(batteryCapacity: number, batteryRuntime: number): boolean {
|
||||
return this.areThresholdsExceeded(batteryCapacity, batteryRuntime);
|
||||
}
|
||||
}
|
||||
|
||||
function createMockContext(overrides: Partial<IActionContext> = {}): IActionContext {
|
||||
return {
|
||||
upsId: 'test-ups',
|
||||
upsName: 'Test UPS',
|
||||
powerStatus: 'online',
|
||||
batteryCapacity: 100,
|
||||
batteryRuntime: 60,
|
||||
previousPowerStatus: 'online',
|
||||
timestamp: Date.now(),
|
||||
triggerReason: 'powerStatusChange',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
Deno.test('Action.areThresholdsExceeded: returns false when no thresholds configured', () => {
|
||||
const action = new TestAction({ type: 'shutdown' });
|
||||
assertEquals(action.testAreThresholdsExceeded(50, 30), false);
|
||||
});
|
||||
|
||||
Deno.test('Action.areThresholdsExceeded: returns true when battery below threshold', () => {
|
||||
const action = new TestAction({
|
||||
type: 'shutdown',
|
||||
thresholds: { battery: 60, runtime: 20 },
|
||||
});
|
||||
|
||||
assertEquals(action.testAreThresholdsExceeded(59, 30), true); // Battery below
|
||||
assertEquals(action.testAreThresholdsExceeded(60, 30), false); // Battery at threshold
|
||||
assertEquals(action.testAreThresholdsExceeded(100, 30), false); // Battery above
|
||||
});
|
||||
|
||||
Deno.test('Action.areThresholdsExceeded: returns true when runtime below threshold', () => {
|
||||
const action = new TestAction({
|
||||
type: 'shutdown',
|
||||
thresholds: { battery: 60, runtime: 20 },
|
||||
});
|
||||
|
||||
assertEquals(action.testAreThresholdsExceeded(100, 19), true); // Runtime below
|
||||
assertEquals(action.testAreThresholdsExceeded(100, 20), false); // Runtime at threshold
|
||||
assertEquals(action.testAreThresholdsExceeded(100, 60), false); // Runtime above
|
||||
});
|
||||
|
||||
Deno.test('Action.shouldExecute: onlyPowerChanges mode', () => {
|
||||
const action = new TestAction({
|
||||
type: 'shutdown',
|
||||
triggerMode: 'onlyPowerChanges',
|
||||
});
|
||||
|
||||
assertEquals(
|
||||
action.testShouldExecute(createMockContext({ triggerReason: 'powerStatusChange' })),
|
||||
true,
|
||||
);
|
||||
assertEquals(
|
||||
action.testShouldExecute(createMockContext({ triggerReason: 'thresholdViolation' })),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test('Action.shouldExecute: onlyThresholds mode', () => {
|
||||
const action = new TestAction({
|
||||
type: 'shutdown',
|
||||
triggerMode: 'onlyThresholds',
|
||||
thresholds: { battery: 60, runtime: 20 },
|
||||
});
|
||||
|
||||
// Below thresholds - should execute
|
||||
assertEquals(
|
||||
action.testShouldExecute(createMockContext({ batteryCapacity: 50, batteryRuntime: 10 })),
|
||||
true,
|
||||
);
|
||||
|
||||
// Above thresholds - should not execute
|
||||
assertEquals(
|
||||
action.testShouldExecute(createMockContext({ batteryCapacity: 100, batteryRuntime: 60 })),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test('Action.shouldExecute: onlyThresholds mode without thresholds returns false', () => {
|
||||
const action = new TestAction({
|
||||
type: 'shutdown',
|
||||
triggerMode: 'onlyThresholds',
|
||||
// No thresholds configured
|
||||
});
|
||||
|
||||
assertEquals(action.testShouldExecute(createMockContext()), false);
|
||||
});
|
||||
|
||||
Deno.test('Action.shouldExecute: powerChangesAndThresholds mode (default)', () => {
|
||||
const action = new TestAction({
|
||||
type: 'shutdown',
|
||||
thresholds: { battery: 60, runtime: 20 },
|
||||
// No triggerMode = defaults to powerChangesAndThresholds
|
||||
});
|
||||
|
||||
// Power change - should execute
|
||||
assertEquals(
|
||||
action.testShouldExecute(createMockContext({ triggerReason: 'powerStatusChange' })),
|
||||
true,
|
||||
);
|
||||
|
||||
// Threshold violation - should execute
|
||||
assertEquals(
|
||||
action.testShouldExecute(createMockContext({
|
||||
triggerReason: 'thresholdViolation',
|
||||
batteryCapacity: 50,
|
||||
})),
|
||||
true,
|
||||
);
|
||||
|
||||
// No power change and above thresholds - should not execute
|
||||
assertEquals(
|
||||
action.testShouldExecute(createMockContext({
|
||||
triggerReason: 'thresholdViolation',
|
||||
batteryCapacity: 100,
|
||||
batteryRuntime: 60,
|
||||
})),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test('Action.shouldExecute: anyChange mode always returns true', () => {
|
||||
const action = new TestAction({
|
||||
type: 'shutdown',
|
||||
triggerMode: 'anyChange',
|
||||
});
|
||||
|
||||
assertEquals(
|
||||
action.testShouldExecute(createMockContext({ triggerReason: 'powerStatusChange' })),
|
||||
true,
|
||||
);
|
||||
assertEquals(
|
||||
action.testShouldExecute(createMockContext({ triggerReason: 'thresholdViolation' })),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// NupstSnmp Class Tests (Unit tests - no real UPS needed)
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
Deno.test('NupstSnmp: can be instantiated', () => {
|
||||
const snmp = new NupstSnmp(false);
|
||||
assertExists(snmp);
|
||||
});
|
||||
|
||||
Deno.test('NupstSnmp: debug mode can be enabled', () => {
|
||||
const snmpDebug = new NupstSnmp(true);
|
||||
const snmpNormal = new NupstSnmp(false);
|
||||
|
||||
assertExists(snmpDebug);
|
||||
assertExists(snmpNormal);
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// INTEGRATION TESTS - Require real UPS (loaded from .nogit/env.json)
|
||||
// =============================================================================
|
||||
|
||||
// Helper function to run UPS test with config
|
||||
async function testUpsConnection(
|
||||
snmp: NupstSnmp,
|
||||
config: Record<string, unknown>,
|
||||
description: string,
|
||||
): Promise<void> {
|
||||
console.log(`Testing ${description}...`);
|
||||
|
||||
const snmpConfig = config.snmp as ISnmpConfig;
|
||||
console.log('SNMP Config:');
|
||||
console.log(` Host: ${snmpConfig.host}:${snmpConfig.port}`);
|
||||
console.log(` Version: SNMPv${snmpConfig.version}`);
|
||||
console.log(` UPS Model: ${snmpConfig.upsModel}`);
|
||||
|
||||
// Use a reasonable timeout for testing
|
||||
const testSnmpConfig = {
|
||||
...snmpConfig,
|
||||
timeout: Math.min(snmpConfig.timeout, SNMP.MAX_TEST_TIMEOUT_MS),
|
||||
};
|
||||
|
||||
const status = await snmp.getUpsStatus(testSnmpConfig);
|
||||
|
||||
console.log('UPS Status:');
|
||||
console.log(` Power Status: ${status.powerStatus}`);
|
||||
console.log(` Battery Capacity: ${status.batteryCapacity}%`);
|
||||
console.log(` Runtime Remaining: ${status.batteryRuntime} minutes`);
|
||||
|
||||
// Validate response structure
|
||||
assertExists(status, 'Status should exist');
|
||||
assert(
|
||||
['online', 'onBattery', 'unknown'].includes(status.powerStatus),
|
||||
`Power status should be valid: ${status.powerStatus}`,
|
||||
);
|
||||
assertEquals(typeof status.batteryCapacity, 'number', 'Battery capacity should be a number');
|
||||
assertEquals(typeof status.batteryRuntime, 'number', 'Battery runtime should be a number');
|
||||
|
||||
// Validate ranges
|
||||
assert(
|
||||
status.batteryCapacity >= 0 && status.batteryCapacity <= 100,
|
||||
`Battery capacity should be 0-100: ${status.batteryCapacity}`,
|
||||
);
|
||||
assert(
|
||||
status.batteryRuntime >= 0,
|
||||
`Battery runtime should be non-negative: ${status.batteryRuntime}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Create SNMP instance for integration tests
|
||||
const snmp = new NupstSnmp(true);
|
||||
|
||||
// Load the test configuration from .nogit/env.json
|
||||
// Load test configurations
|
||||
const testConfigV1 = await testQenv.getEnvVarOnDemandAsObject('testConfigV1');
|
||||
const testConfigV3 = await testQenv.getEnvVarOnDemandAsObject('testConfigV3');
|
||||
|
||||
Deno.test('should log config', () => {
|
||||
console.log(testConfigV1);
|
||||
assert(true);
|
||||
Deno.test('Integration: Real UPS test v1', async () => {
|
||||
await testUpsConnection(snmp, testConfigV1, 'SNMPv1 connection');
|
||||
});
|
||||
|
||||
// Test with real UPS using the configuration from .nogit/env.json
|
||||
Deno.test('Real UPS test v1', async () => {
|
||||
try {
|
||||
console.log('Testing with real UPS configuration...');
|
||||
|
||||
// Extract the correct SNMP config from the test configuration
|
||||
const snmpConfig = testConfigV1.snmp as ISnmpConfig;
|
||||
console.log('SNMP Config:');
|
||||
console.log(` Host: ${snmpConfig.host}:${snmpConfig.port}`);
|
||||
console.log(` Version: SNMPv${snmpConfig.version}`);
|
||||
console.log(` UPS Model: ${snmpConfig.upsModel}`);
|
||||
|
||||
// Use a short timeout for testing
|
||||
const testSnmpConfig = {
|
||||
...snmpConfig,
|
||||
timeout: Math.min(snmpConfig.timeout, 10000), // Use at most 10 seconds for testing
|
||||
};
|
||||
|
||||
// Try to get the UPS status
|
||||
const status = await snmp.getUpsStatus(testSnmpConfig);
|
||||
|
||||
console.log('UPS Status:');
|
||||
console.log(` Power Status: ${status.powerStatus}`);
|
||||
console.log(` Battery Capacity: ${status.batteryCapacity}%`);
|
||||
console.log(` Runtime Remaining: ${status.batteryRuntime} minutes`);
|
||||
|
||||
// Just make sure we got valid data types back
|
||||
assertExists(status);
|
||||
assert(['online', 'onBattery', 'unknown'].includes(status.powerStatus));
|
||||
assertEquals(typeof status.batteryCapacity, 'number');
|
||||
assertEquals(typeof status.batteryRuntime, 'number');
|
||||
} catch (error) {
|
||||
console.log('Real UPS test failed:', error);
|
||||
// Skip the test if we can't connect to the real UPS
|
||||
console.log('Skipping this test since the UPS might not be available');
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test('Real UPS test v3', async () => {
|
||||
try {
|
||||
console.log('Testing with real UPS configuration...');
|
||||
|
||||
// Extract the correct SNMP config from the test configuration
|
||||
const snmpConfig = testConfigV3.snmp as ISnmpConfig;
|
||||
console.log('SNMP Config:');
|
||||
console.log(` Host: ${snmpConfig.host}:${snmpConfig.port}`);
|
||||
console.log(` Version: SNMPv${snmpConfig.version}`);
|
||||
console.log(` UPS Model: ${snmpConfig.upsModel}`);
|
||||
|
||||
// Use a short timeout for testing
|
||||
const testSnmpConfig = {
|
||||
...snmpConfig,
|
||||
timeout: Math.min(snmpConfig.timeout, 10000), // Use at most 10 seconds for testing
|
||||
};
|
||||
|
||||
// Try to get the UPS status
|
||||
const status = await snmp.getUpsStatus(testSnmpConfig);
|
||||
|
||||
console.log('UPS Status:');
|
||||
console.log(` Power Status: ${status.powerStatus}`);
|
||||
console.log(` Battery Capacity: ${status.batteryCapacity}%`);
|
||||
console.log(` Runtime Remaining: ${status.batteryRuntime} minutes`);
|
||||
|
||||
// Just make sure we got valid data types back
|
||||
assertExists(status);
|
||||
assert(['online', 'onBattery', 'unknown'].includes(status.powerStatus));
|
||||
assertEquals(typeof status.batteryCapacity, 'number');
|
||||
assertEquals(typeof status.batteryRuntime, 'number');
|
||||
} catch (error) {
|
||||
console.log('Real UPS test failed:', error);
|
||||
// Skip the test if we can't connect to the real UPS
|
||||
console.log('Skipping this test since the UPS might not be available');
|
||||
}
|
||||
Deno.test('Integration: Real UPS test v3', async () => {
|
||||
await testUpsConnection(snmp, testConfigV3, 'SNMPv3 connection');
|
||||
});
|
||||
|
||||
8
ts/00_commitinfo_data.ts
Normal file
8
ts/00_commitinfo_data.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* autocreated commitinfo by @push.rocks/commitinfo
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/nupst',
|
||||
version: '5.2.4',
|
||||
description: 'Network UPS Shutdown Tool - Monitor SNMP-enabled UPS devices and orchestrate graceful system shutdowns during power emergencies'
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { ScriptAction } from './script-action.ts';
|
||||
|
||||
// Re-export types for convenience
|
||||
export type { IActionConfig, IActionContext, TPowerStatus } from './base-action.ts';
|
||||
export type { IWebhookPayload } from './webhook-action.ts';
|
||||
export { Action } from './base-action.ts';
|
||||
export { ShutdownAction } from './shutdown-action.ts';
|
||||
export { WebhookAction } from './webhook-action.ts';
|
||||
|
||||
@@ -3,7 +3,7 @@ import * as fs from 'node:fs';
|
||||
import process from 'node:process';
|
||||
import { exec } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { Action, type IActionConfig, type IActionContext } from './base-action.ts';
|
||||
import { Action, type IActionContext } from './base-action.ts';
|
||||
import { logger } from '../logger.ts';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
@@ -26,7 +26,11 @@ export class ScriptAction extends Action {
|
||||
async execute(context: IActionContext): Promise<void> {
|
||||
// Check if we should execute based on trigger mode
|
||||
if (!this.shouldExecute(context)) {
|
||||
logger.info(`Script action skipped (trigger mode: ${this.config.triggerMode || 'powerChangesAndThresholds'})`);
|
||||
logger.info(
|
||||
`Script action skipped (trigger mode: ${
|
||||
this.config.triggerMode || 'powerChangesAndThresholds'
|
||||
})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import * as fs from 'node:fs';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { Action, type IActionConfig, type IActionContext } from './base-action.ts';
|
||||
import { Action, type IActionContext } from './base-action.ts';
|
||||
import { logger } from '../logger.ts';
|
||||
import { SHUTDOWN, UI } from '../constants.ts';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
@@ -15,6 +16,92 @@ const execFileAsync = promisify(execFile);
|
||||
export class ShutdownAction extends Action {
|
||||
readonly type = 'shutdown';
|
||||
|
||||
/**
|
||||
* Override shouldExecute to add shutdown-specific safety checks
|
||||
*
|
||||
* Key safety rules:
|
||||
* 1. Shutdown should NEVER trigger unless UPS is actually on battery
|
||||
* (low battery while on grid power is not an emergency - it's charging)
|
||||
* 2. For power status changes, only trigger on transitions TO onBattery from online
|
||||
* (ignore unknown → online at startup, and power restoration events)
|
||||
* 3. For threshold violations, verify UPS is on battery before acting
|
||||
*
|
||||
* @param context Action context with UPS state
|
||||
* @returns True if shutdown should execute
|
||||
*/
|
||||
protected override shouldExecute(context: IActionContext): boolean {
|
||||
const mode = this.config.triggerMode || 'powerChangesAndThresholds';
|
||||
|
||||
// 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)
|
||||
if (context.powerStatus !== 'onBattery') {
|
||||
logger.info(
|
||||
`Shutdown action skipped: UPS is not on battery (status: ${context.powerStatus})`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Handle threshold violations (UPS is confirmed on battery at this point)
|
||||
if (context.triggerReason === 'thresholdViolation') {
|
||||
// 'onlyPowerChanges' mode ignores thresholds
|
||||
if (mode === 'onlyPowerChanges') {
|
||||
logger.info('Shutdown action skipped: triggerMode is onlyPowerChanges, ignoring threshold');
|
||||
return false;
|
||||
}
|
||||
// Check if thresholds are actually exceeded
|
||||
return this.areThresholdsExceeded(context.batteryCapacity, context.batteryRuntime);
|
||||
}
|
||||
|
||||
// Handle power status changes
|
||||
if (context.triggerReason === 'powerStatusChange') {
|
||||
// 'onlyThresholds' mode ignores power status changes
|
||||
if (mode === 'onlyThresholds') {
|
||||
logger.info(
|
||||
'Shutdown action skipped: triggerMode is onlyThresholds, ignoring power change',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const prev = context.previousPowerStatus;
|
||||
|
||||
// Only trigger on transitions TO onBattery from online (real power loss)
|
||||
if (prev === 'online') {
|
||||
logger.info('Shutdown action triggered: power loss detected (online → onBattery)');
|
||||
return true;
|
||||
}
|
||||
|
||||
// For unknown → onBattery (daemon started while on battery):
|
||||
// This is a startup scenario - be cautious. The user may have just started
|
||||
// the daemon for testing, or the UPS may have been on battery for a while.
|
||||
// Only trigger if mode explicitly includes power changes.
|
||||
if (prev === 'unknown') {
|
||||
if (
|
||||
mode === 'onlyPowerChanges' || mode === 'powerChangesAndThresholds' ||
|
||||
mode === 'anyChange'
|
||||
) {
|
||||
logger.info(
|
||||
'Shutdown action triggered: UPS on battery at daemon startup (unknown → onBattery)',
|
||||
);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Other transitions (e.g., onBattery → onBattery) should not trigger
|
||||
logger.info(
|
||||
`Shutdown action skipped: non-emergency transition (${prev} → ${context.powerStatus})`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// For 'anyChange' mode, always execute (UPS is already confirmed on battery)
|
||||
if (mode === 'anyChange') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the shutdown action
|
||||
* @param context Action context with UPS state
|
||||
@@ -22,14 +109,18 @@ export class ShutdownAction extends Action {
|
||||
async execute(context: IActionContext): Promise<void> {
|
||||
// Check if we should execute based on trigger mode and thresholds
|
||||
if (!this.shouldExecute(context)) {
|
||||
logger.info(`Shutdown action skipped (trigger mode: ${this.config.triggerMode || 'powerChangesAndThresholds'})`);
|
||||
logger.info(
|
||||
`Shutdown action skipped (trigger mode: ${
|
||||
this.config.triggerMode || 'powerChangesAndThresholds'
|
||||
})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const shutdownDelay = this.config.shutdownDelay || 5; // Default 5 minutes
|
||||
const shutdownDelay = this.config.shutdownDelay || SHUTDOWN.DEFAULT_DELAY_MINUTES;
|
||||
|
||||
logger.log('');
|
||||
logger.logBoxTitle('Initiating System Shutdown', 60, 'error');
|
||||
logger.logBoxTitle('Initiating System Shutdown', UI.WIDE_BOX_WIDTH, 'error');
|
||||
logger.logBoxLine(`UPS: ${context.upsName} (${context.upsId})`);
|
||||
logger.logBoxLine(`Power Status: ${context.powerStatus}`);
|
||||
logger.logBoxLine(`Battery: ${context.batteryCapacity}%`);
|
||||
|
||||
@@ -1,8 +1,34 @@
|
||||
import * as http from 'node:http';
|
||||
import * as https from 'node:https';
|
||||
import { URL } from 'node:url';
|
||||
import { Action, type IActionConfig, type IActionContext } from './base-action.ts';
|
||||
import { Action, type IActionContext } from './base-action.ts';
|
||||
import { logger } from '../logger.ts';
|
||||
import { WEBHOOK } from '../constants.ts';
|
||||
|
||||
/**
|
||||
* Payload sent to webhook endpoints
|
||||
*/
|
||||
export interface IWebhookPayload {
|
||||
/** UPS ID */
|
||||
upsId: string;
|
||||
/** UPS name */
|
||||
upsName: string;
|
||||
/** Current power status */
|
||||
powerStatus: 'online' | 'onBattery' | 'unknown';
|
||||
/** Current battery capacity percentage */
|
||||
batteryCapacity: number;
|
||||
/** Current battery runtime in minutes */
|
||||
batteryRuntime: number;
|
||||
/** Reason this webhook was triggered */
|
||||
triggerReason: 'powerStatusChange' | 'thresholdViolation';
|
||||
/** Timestamp when webhook was triggered */
|
||||
timestamp: number;
|
||||
/** Thresholds configured for this action (if any) */
|
||||
thresholds?: {
|
||||
battery: number;
|
||||
runtime: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* WebhookAction - Calls an HTTP webhook with UPS state information
|
||||
@@ -20,7 +46,11 @@ export class WebhookAction extends Action {
|
||||
async execute(context: IActionContext): Promise<void> {
|
||||
// Check if we should execute based on trigger mode
|
||||
if (!this.shouldExecute(context)) {
|
||||
logger.info(`Webhook action skipped (trigger mode: ${this.config.triggerMode || 'powerChangesAndThresholds'})`);
|
||||
logger.info(
|
||||
`Webhook action skipped (trigger mode: ${
|
||||
this.config.triggerMode || 'powerChangesAndThresholds'
|
||||
})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -30,7 +60,7 @@ export class WebhookAction extends Action {
|
||||
}
|
||||
|
||||
const method = this.config.webhookMethod || 'POST';
|
||||
const timeout = this.config.webhookTimeout || 10000;
|
||||
const timeout = this.config.webhookTimeout || WEBHOOK.DEFAULT_TIMEOUT_MS;
|
||||
|
||||
logger.info(`Calling webhook: ${method} ${this.config.webhookUrl}`);
|
||||
|
||||
@@ -51,12 +81,12 @@ export class WebhookAction extends Action {
|
||||
* @param method HTTP method (GET or POST)
|
||||
* @param timeout Request timeout in milliseconds
|
||||
*/
|
||||
private async callWebhook(
|
||||
private callWebhook(
|
||||
context: IActionContext,
|
||||
method: 'GET' | 'POST',
|
||||
timeout: number,
|
||||
): Promise<void> {
|
||||
const payload: any = {
|
||||
const payload: IWebhookPayload = {
|
||||
upsId: context.upsId,
|
||||
upsName: context.upsName,
|
||||
powerStatus: context.powerStatus,
|
||||
@@ -83,7 +113,7 @@ export class WebhookAction extends Action {
|
||||
url.searchParams.append('powerStatus', payload.powerStatus);
|
||||
url.searchParams.append('batteryCapacity', String(payload.batteryCapacity));
|
||||
url.searchParams.append('batteryRuntime', String(payload.batteryRuntime));
|
||||
|
||||
|
||||
url.searchParams.append('triggerReason', payload.triggerReason);
|
||||
url.searchParams.append('timestamp', String(payload.timestamp));
|
||||
}
|
||||
|
||||
187
ts/cli.ts
187
ts/cli.ts
@@ -1,7 +1,7 @@
|
||||
import { execSync } from 'node:child_process';
|
||||
import { Nupst } from './nupst.ts';
|
||||
import { logger, type ITableColumn } from './logger.ts';
|
||||
import { theme, symbols } from './colors.ts';
|
||||
import { type ITableColumn, logger } from './logger.ts';
|
||||
import { theme } from './colors.ts';
|
||||
|
||||
/**
|
||||
* Class for handling CLI commands
|
||||
@@ -287,10 +287,15 @@ export class NupstCli {
|
||||
try {
|
||||
await this.nupst.getDaemon().loadConfig();
|
||||
} catch (_error) {
|
||||
logger.logBox('Configuration Error', [
|
||||
'No configuration found.',
|
||||
"Please run 'nupst ups add' first to create a configuration.",
|
||||
], 50, 'error');
|
||||
logger.logBox(
|
||||
'Configuration Error',
|
||||
[
|
||||
'No configuration found.',
|
||||
"Please run 'nupst ups add' first to create a configuration.",
|
||||
],
|
||||
50,
|
||||
'error',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -300,17 +305,22 @@ export class NupstCli {
|
||||
// Check if multi-UPS config
|
||||
if (config.upsDevices && Array.isArray(config.upsDevices)) {
|
||||
// === Multi-UPS Configuration ===
|
||||
|
||||
|
||||
// Overview Box
|
||||
logger.log('');
|
||||
logger.logBox('NUPST Configuration', [
|
||||
`UPS Devices: ${theme.highlight(String(config.upsDevices.length))}`,
|
||||
`Groups: ${theme.highlight(String(config.groups ? config.groups.length : 0))}`,
|
||||
`Check Interval: ${theme.info(String(config.checkInterval / 1000))} seconds`,
|
||||
'',
|
||||
theme.dim('Configuration File:'),
|
||||
` ${theme.path('/etc/nupst/config.json')}`,
|
||||
], 60, 'info');
|
||||
logger.logBox(
|
||||
'NUPST Configuration',
|
||||
[
|
||||
`UPS Devices: ${theme.highlight(String(config.upsDevices.length))}`,
|
||||
`Groups: ${theme.highlight(String(config.groups ? config.groups.length : 0))}`,
|
||||
`Check Interval: ${theme.info(String(config.checkInterval / 1000))} seconds`,
|
||||
'',
|
||||
theme.dim('Configuration File:'),
|
||||
` ${theme.path('/etc/nupst/config.json')}`,
|
||||
],
|
||||
60,
|
||||
'info',
|
||||
);
|
||||
|
||||
// HTTP Server Status (if configured)
|
||||
if (config.httpServer) {
|
||||
@@ -319,17 +329,24 @@ export class NupstCli {
|
||||
: theme.dim('Disabled');
|
||||
|
||||
logger.log('');
|
||||
logger.logBox('HTTP Server', [
|
||||
`Status: ${serverStatus}`,
|
||||
...(config.httpServer.enabled ? [
|
||||
`Port: ${theme.highlight(String(config.httpServer.port))}`,
|
||||
`Path: ${theme.highlight(config.httpServer.path)}`,
|
||||
`Auth Token: ${theme.dim('***' + config.httpServer.authToken.slice(-4))}`,
|
||||
'',
|
||||
theme.dim('Usage:'),
|
||||
` curl -H "Authorization: Bearer TOKEN" http://localhost:${config.httpServer.port}${config.httpServer.path}`,
|
||||
] : []),
|
||||
], 70, config.httpServer.enabled ? 'success' : 'default');
|
||||
logger.logBox(
|
||||
'HTTP Server',
|
||||
[
|
||||
`Status: ${serverStatus}`,
|
||||
...(config.httpServer.enabled
|
||||
? [
|
||||
`Port: ${theme.highlight(String(config.httpServer.port))}`,
|
||||
`Path: ${theme.highlight(config.httpServer.path)}`,
|
||||
`Auth Token: ${theme.dim('***' + config.httpServer.authToken.slice(-4))}`,
|
||||
'',
|
||||
theme.dim('Usage:'),
|
||||
` curl -H "Authorization: Bearer TOKEN" http://localhost:${config.httpServer.port}${config.httpServer.path}`,
|
||||
]
|
||||
: []),
|
||||
],
|
||||
70,
|
||||
config.httpServer.enabled ? 'success' : 'default',
|
||||
);
|
||||
}
|
||||
|
||||
// UPS Devices Table
|
||||
@@ -369,8 +386,8 @@ export class NupstCli {
|
||||
id: theme.dim(group.id),
|
||||
mode: group.mode,
|
||||
upsCount: String(upsInGroup.length),
|
||||
ups: upsInGroup.length > 0
|
||||
? upsInGroup.map((ups) => ups.name).join(', ')
|
||||
ups: upsInGroup.length > 0
|
||||
? upsInGroup.map((ups) => ups.name).join(', ')
|
||||
: theme.dim('None'),
|
||||
description: group.description || theme.dim('—'),
|
||||
};
|
||||
@@ -392,62 +409,68 @@ export class NupstCli {
|
||||
}
|
||||
} else {
|
||||
// === Legacy Single UPS Configuration ===
|
||||
|
||||
|
||||
if (!config.snmp) {
|
||||
logger.logBox('Configuration Error', [
|
||||
'Error: Legacy configuration missing SNMP settings',
|
||||
], 60, 'error');
|
||||
logger.logBox(
|
||||
'Configuration Error',
|
||||
[
|
||||
'Error: Legacy configuration missing SNMP settings',
|
||||
],
|
||||
60,
|
||||
'error',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.log('');
|
||||
logger.logBox('NUPST Configuration (Legacy)', [
|
||||
theme.warning('Legacy single-UPS configuration format'),
|
||||
'',
|
||||
theme.dim('SNMP Settings:'),
|
||||
` Host: ${theme.info(config.snmp.host)}`,
|
||||
` Port: ${theme.info(String(config.snmp.port))}`,
|
||||
` Version: ${config.snmp.version}`,
|
||||
` UPS Model: ${config.snmp.upsModel || 'cyberpower'}`,
|
||||
...(config.snmp.version === 1 || config.snmp.version === 2
|
||||
? [` Community: ${config.snmp.community}`]
|
||||
: []
|
||||
),
|
||||
...(config.snmp.version === 3
|
||||
? [
|
||||
logger.logBox(
|
||||
'NUPST Configuration (Legacy)',
|
||||
[
|
||||
theme.warning('Legacy single-UPS configuration format'),
|
||||
'',
|
||||
theme.dim('SNMP Settings:'),
|
||||
` Host: ${theme.info(config.snmp.host)}`,
|
||||
` Port: ${theme.info(String(config.snmp.port))}`,
|
||||
` Version: ${config.snmp.version}`,
|
||||
` UPS Model: ${config.snmp.upsModel || 'cyberpower'}`,
|
||||
...(config.snmp.version === 1 || config.snmp.version === 2
|
||||
? [` Community: ${config.snmp.community}`]
|
||||
: []),
|
||||
...(config.snmp.version === 3
|
||||
? [
|
||||
` Security Level: ${config.snmp.securityLevel}`,
|
||||
` Username: ${config.snmp.username}`,
|
||||
...(config.snmp.securityLevel === 'authNoPriv' || config.snmp.securityLevel === 'authPriv'
|
||||
...(config.snmp.securityLevel === 'authNoPriv' ||
|
||||
config.snmp.securityLevel === 'authPriv'
|
||||
? [` Auth Protocol: ${config.snmp.authProtocol || 'None'}`]
|
||||
: []
|
||||
),
|
||||
: []),
|
||||
...(config.snmp.securityLevel === 'authPriv'
|
||||
? [` Privacy Protocol: ${config.snmp.privProtocol || 'None'}`]
|
||||
: []
|
||||
),
|
||||
: []),
|
||||
` Timeout: ${config.snmp.timeout / 1000} seconds`,
|
||||
]
|
||||
: []
|
||||
),
|
||||
...(config.snmp.upsModel === 'custom' && config.snmp.customOIDs
|
||||
? [
|
||||
: []),
|
||||
...(config.snmp.upsModel === 'custom' && config.snmp.customOIDs
|
||||
? [
|
||||
theme.dim('Custom OIDs:'),
|
||||
` Power Status: ${config.snmp.customOIDs.POWER_STATUS || 'Not set'}`,
|
||||
` Battery Capacity: ${config.snmp.customOIDs.BATTERY_CAPACITY || 'Not set'}`,
|
||||
` Battery Runtime: ${config.snmp.customOIDs.BATTERY_RUNTIME || 'Not set'}`,
|
||||
]
|
||||
: []
|
||||
),
|
||||
'',
|
||||
|
||||
` Check Interval: ${config.checkInterval / 1000} seconds`,
|
||||
'',
|
||||
theme.dim('Configuration File:'),
|
||||
` ${theme.path('/etc/nupst/config.json')}`,
|
||||
'',
|
||||
theme.warning('Note: Using legacy single-UPS configuration format.'),
|
||||
`Consider using ${theme.command('nupst ups add')} to migrate to multi-UPS format.`,
|
||||
], 70, 'warning');
|
||||
: []),
|
||||
'',
|
||||
|
||||
` Check Interval: ${config.checkInterval / 1000} seconds`,
|
||||
'',
|
||||
theme.dim('Configuration File:'),
|
||||
` ${theme.path('/etc/nupst/config.json')}`,
|
||||
'',
|
||||
theme.warning('Note: Using legacy single-UPS configuration format.'),
|
||||
`Consider using ${theme.command('nupst ups add')} to migrate to multi-UPS format.`,
|
||||
],
|
||||
70,
|
||||
'warning',
|
||||
);
|
||||
}
|
||||
|
||||
// Service Status
|
||||
@@ -458,10 +481,15 @@ export class NupstCli {
|
||||
execSync('systemctl is-enabled nupst.service || true').toString().trim() === 'enabled';
|
||||
|
||||
logger.log('');
|
||||
logger.logBox('Service Status', [
|
||||
`Active: ${isActive ? theme.success('Yes') : theme.dim('No')}`,
|
||||
`Enabled: ${isEnabled ? theme.success('Yes') : theme.dim('No')}`,
|
||||
], 50, isActive ? 'success' : 'default');
|
||||
logger.logBox(
|
||||
'Service Status',
|
||||
[
|
||||
`Active: ${isActive ? theme.success('Yes') : theme.dim('No')}`,
|
||||
`Enabled: ${isEnabled ? theme.success('Yes') : theme.dim('No')}`,
|
||||
],
|
||||
50,
|
||||
isActive ? 'success' : 'default',
|
||||
);
|
||||
logger.log('');
|
||||
} catch (_error) {
|
||||
// Ignore errors checking service status
|
||||
@@ -514,8 +542,16 @@ export class NupstCli {
|
||||
|
||||
// Service subcommands
|
||||
logger.log(theme.info('Service Subcommands:'));
|
||||
this.printCommand('nupst service enable', 'Install and enable systemd service', theme.dim('(requires root)'));
|
||||
this.printCommand('nupst service disable', 'Stop and disable systemd service', theme.dim('(requires root)'));
|
||||
this.printCommand(
|
||||
'nupst service enable',
|
||||
'Install and enable systemd service',
|
||||
theme.dim('(requires root)'),
|
||||
);
|
||||
this.printCommand(
|
||||
'nupst service disable',
|
||||
'Stop and disable systemd service',
|
||||
theme.dim('(requires root)'),
|
||||
);
|
||||
this.printCommand('nupst service start', 'Start the systemd service');
|
||||
this.printCommand('nupst service stop', 'Stop the systemd service');
|
||||
this.printCommand('nupst service restart', 'Restart the systemd service');
|
||||
@@ -545,7 +581,10 @@ export class NupstCli {
|
||||
logger.log(theme.info('Action Subcommands:'));
|
||||
this.printCommand('nupst action add <target-id>', 'Add a new action to a UPS or group');
|
||||
this.printCommand('nupst action remove <target-id> <index>', 'Remove an action by index');
|
||||
this.printCommand('nupst action list [target-id]', 'List all actions (optionally for specific target)');
|
||||
this.printCommand(
|
||||
'nupst action list [target-id]',
|
||||
'List all actions (optionally for specific target)',
|
||||
);
|
||||
console.log('');
|
||||
|
||||
// Feature subcommands
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import process from 'node:process';
|
||||
import { Nupst } from '../nupst.ts';
|
||||
import { logger, type ITableColumn } from '../logger.ts';
|
||||
import { theme, symbols } from '../colors.ts';
|
||||
import { type ITableColumn, logger } from '../logger.ts';
|
||||
import { symbols, theme } from '../colors.ts';
|
||||
import type { IActionConfig } from '../actions/base-action.ts';
|
||||
import type { IUpsConfig, IGroupConfig } from '../daemon.ts';
|
||||
import type { IGroupConfig, IUpsConfig } from '../daemon.ts';
|
||||
import * as helpers from '../helpers/index.ts';
|
||||
|
||||
/**
|
||||
* Class for handling action-related CLI commands
|
||||
@@ -47,7 +48,9 @@ export class ActionHandler {
|
||||
if (!ups && !group) {
|
||||
logger.error(`UPS or Group with ID '${targetId}' not found`);
|
||||
logger.log('');
|
||||
logger.log(` ${theme.dim('List available UPS devices:')} ${theme.command('nupst ups list')}`);
|
||||
logger.log(
|
||||
` ${theme.dim('List available UPS devices:')} ${theme.command('nupst ups list')}`,
|
||||
);
|
||||
logger.log(` ${theme.dim('List available groups:')} ${theme.command('nupst group list')}`);
|
||||
logger.log('');
|
||||
process.exit(1);
|
||||
@@ -57,21 +60,7 @@ export class ActionHandler {
|
||||
const targetType = ups ? 'UPS' : 'Group';
|
||||
const targetName = ups ? ups.name : group!.name;
|
||||
|
||||
const readline = await import('node:readline');
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
const prompt = (question: string): Promise<string> => {
|
||||
return new Promise((resolve) => {
|
||||
rl.question(question, (answer: string) => {
|
||||
resolve(answer);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
await helpers.withPrompt(async (prompt) => {
|
||||
logger.log('');
|
||||
logger.info(`Add Action to ${targetType} ${theme.highlight(targetName)}`);
|
||||
logger.log('');
|
||||
@@ -103,12 +92,16 @@ export class ActionHandler {
|
||||
// Trigger mode
|
||||
logger.log('');
|
||||
logger.log(` ${theme.dim('Trigger mode:')}`);
|
||||
logger.log(` ${theme.dim('1)')} onlyPowerChanges - Trigger only when power status changes`);
|
||||
logger.log(
|
||||
` ${theme.dim('1)')} onlyPowerChanges - Trigger only when power status changes`,
|
||||
);
|
||||
logger.log(
|
||||
` ${theme.dim('2)')} onlyThresholds - Trigger only when thresholds are violated`,
|
||||
);
|
||||
logger.log(
|
||||
` ${theme.dim('3)')} powerChangesAndThresholds - Trigger on power change AND thresholds`,
|
||||
` ${
|
||||
theme.dim('3)')
|
||||
} powerChangesAndThresholds - Trigger on power change AND thresholds`,
|
||||
);
|
||||
logger.log(` ${theme.dim('4)')} anyChange - Trigger on any status change`);
|
||||
const triggerChoice = await prompt(` ${theme.dim('Choice')} ${theme.dim('[2]:')} `);
|
||||
@@ -154,9 +147,7 @@ export class ActionHandler {
|
||||
logger.success(`Action added to ${targetType} ${targetName}`);
|
||||
logger.log(` ${theme.dim('Changes saved and will be applied automatically')}`);
|
||||
logger.log('');
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to add action: ${error instanceof Error ? error.message : String(error)}`,
|
||||
@@ -173,7 +164,9 @@ export class ActionHandler {
|
||||
if (!targetId || !actionIndexStr) {
|
||||
logger.error('Target ID and action index are required');
|
||||
logger.log(
|
||||
` ${theme.dim('Usage:')} ${theme.command('nupst action remove <ups-id|group-id> <action-index>')}`,
|
||||
` ${theme.dim('Usage:')} ${
|
||||
theme.command('nupst action remove <ups-id|group-id> <action-index>')
|
||||
}`,
|
||||
);
|
||||
logger.log('');
|
||||
logger.log(` ${theme.dim('List actions:')} ${theme.command('nupst action list')}`);
|
||||
@@ -197,7 +190,9 @@ export class ActionHandler {
|
||||
if (!ups && !group) {
|
||||
logger.error(`UPS or Group with ID '${targetId}' not found`);
|
||||
logger.log('');
|
||||
logger.log(` ${theme.dim('List available UPS devices:')} ${theme.command('nupst ups list')}`);
|
||||
logger.log(
|
||||
` ${theme.dim('List available UPS devices:')} ${theme.command('nupst ups list')}`,
|
||||
);
|
||||
logger.log(` ${theme.dim('List available groups:')} ${theme.command('nupst group list')}`);
|
||||
logger.log('');
|
||||
process.exit(1);
|
||||
@@ -215,7 +210,9 @@ export class ActionHandler {
|
||||
|
||||
if (actionIndex >= target!.actions.length) {
|
||||
logger.error(
|
||||
`Invalid action index. ${targetType} '${targetName}' has ${target!.actions.length} action(s) (index 0-${target!.actions.length - 1})`,
|
||||
`Invalid action index. ${targetType} '${targetName}' has ${
|
||||
target!.actions.length
|
||||
} action(s) (index 0-${target!.actions.length - 1})`,
|
||||
);
|
||||
logger.log('');
|
||||
logger.log(
|
||||
@@ -235,7 +232,9 @@ export class ActionHandler {
|
||||
logger.log(` ${theme.dim('Type:')} ${removedAction.type}`);
|
||||
if (removedAction.thresholds) {
|
||||
logger.log(
|
||||
` ${theme.dim('Thresholds:')} Battery: ${removedAction.thresholds.battery}%, Runtime: ${removedAction.thresholds.runtime}min`,
|
||||
` ${
|
||||
theme.dim('Thresholds:')
|
||||
} Battery: ${removedAction.thresholds.battery}%, Runtime: ${removedAction.thresholds.runtime}min`,
|
||||
);
|
||||
}
|
||||
logger.log(` ${theme.dim('Changes saved and will be applied automatically')}`);
|
||||
@@ -263,8 +262,12 @@ export class ActionHandler {
|
||||
if (!ups && !group) {
|
||||
logger.error(`UPS or Group with ID '${targetId}' not found`);
|
||||
logger.log('');
|
||||
logger.log(` ${theme.dim('List available UPS devices:')} ${theme.command('nupst ups list')}`);
|
||||
logger.log(` ${theme.dim('List available groups:')} ${theme.command('nupst group list')}`);
|
||||
logger.log(
|
||||
` ${theme.dim('List available UPS devices:')} ${theme.command('nupst ups list')}`,
|
||||
);
|
||||
logger.log(
|
||||
` ${theme.dim('List available groups:')} ${theme.command('nupst group list')}`,
|
||||
);
|
||||
logger.log('');
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -302,7 +305,9 @@ export class ActionHandler {
|
||||
logger.log(` ${theme.dim('No actions configured')}`);
|
||||
logger.log('');
|
||||
logger.log(
|
||||
` ${theme.dim('Add an action:')} ${theme.command('nupst action add <ups-id|group-id>')}`,
|
||||
` ${theme.dim('Add an action:')} ${
|
||||
theme.command('nupst action add <ups-id|group-id>')
|
||||
}`,
|
||||
);
|
||||
logger.log('');
|
||||
}
|
||||
@@ -323,7 +328,9 @@ export class ActionHandler {
|
||||
targetType: 'UPS' | 'Group',
|
||||
): void {
|
||||
logger.log(
|
||||
`${symbols.info} ${targetType} ${theme.highlight(target.name)} ${theme.dim(`(${target.id})`)}`,
|
||||
`${symbols.info} ${targetType} ${theme.highlight(target.name)} ${
|
||||
theme.dim(`(${target.id})`)
|
||||
}`,
|
||||
);
|
||||
logger.log('');
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import process from 'node:process';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { Nupst } from '../nupst.ts';
|
||||
import { logger } from '../logger.ts';
|
||||
@@ -25,28 +24,13 @@ export class FeatureHandler {
|
||||
*/
|
||||
public async configureHttpServer(): Promise<void> {
|
||||
try {
|
||||
const readline = await import('node:readline');
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
const prompt = (question: string): Promise<string> => {
|
||||
return new Promise((resolve) => {
|
||||
rl.question(question, (answer: string) => {
|
||||
resolve(answer);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
await helpers.withPrompt(async (prompt) => {
|
||||
await this.runHttpServerConfig(prompt);
|
||||
} finally {
|
||||
rl.close();
|
||||
process.stdin.destroy();
|
||||
}
|
||||
});
|
||||
} 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)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,7 +150,9 @@ export class FeatureHandler {
|
||||
logger.logBoxLine(`Auth Token: ${theme.warning(authToken)}`);
|
||||
logger.logBoxLine('');
|
||||
logger.logBoxLine(theme.dim('Usage examples:'));
|
||||
logger.logBoxLine(` curl -H "Authorization: Bearer ${authToken}" http://localhost:${port}${finalPath}`);
|
||||
logger.logBoxLine(
|
||||
` curl -H "Authorization: Bearer ${authToken}" http://localhost:${port}${finalPath}`,
|
||||
);
|
||||
logger.logBoxLine(` curl "http://localhost:${port}${finalPath}?token=${authToken}"`);
|
||||
logger.logBoxEnd();
|
||||
logger.log('');
|
||||
@@ -182,21 +168,14 @@ export class FeatureHandler {
|
||||
*/
|
||||
private async restartServiceIfRunning(): Promise<void> {
|
||||
try {
|
||||
const isActive = execSync('systemctl is-active nupst.service || true').toString().trim() === 'active';
|
||||
const isActive =
|
||||
execSync('systemctl is-active nupst.service || true').toString().trim() === 'active';
|
||||
|
||||
if (isActive) {
|
||||
logger.log('');
|
||||
const readline = await import('node:readline');
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
const answer = await new Promise<string>((resolve) => {
|
||||
rl.question('Service is running. Restart to apply changes? (Y/n): ', resolve);
|
||||
});
|
||||
|
||||
rl.close();
|
||||
const { prompt, close } = await helpers.createPrompt();
|
||||
const answer = await prompt('Service is running. Restart to apply changes? (Y/n): ');
|
||||
close();
|
||||
|
||||
if (!answer || answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes') {
|
||||
logger.info('Restarting service...');
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import process from 'node:process';
|
||||
import { Nupst } from '../nupst.ts';
|
||||
import { logger, type ITableColumn } from '../logger.ts';
|
||||
import { type ITableColumn, logger } from '../logger.ts';
|
||||
import { theme } from '../colors.ts';
|
||||
import * as helpers from '../helpers/index.ts';
|
||||
import { type IGroupConfig } from '../daemon.ts';
|
||||
import type { IGroupConfig, INupstConfig, IUpsConfig } from '../daemon.ts';
|
||||
|
||||
/**
|
||||
* Class for handling group-related CLI commands
|
||||
@@ -29,10 +28,15 @@ export class GroupHandler {
|
||||
try {
|
||||
await this.nupst.getDaemon().loadConfig();
|
||||
} catch (error) {
|
||||
logger.logBox('Configuration Error', [
|
||||
'No configuration found.',
|
||||
"Please run 'nupst ups add' first to create a configuration.",
|
||||
], 50, 'error');
|
||||
logger.logBox(
|
||||
'Configuration Error',
|
||||
[
|
||||
'No configuration found.',
|
||||
"Please run 'nupst ups add' first to create a configuration.",
|
||||
],
|
||||
50,
|
||||
'error',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -41,21 +45,35 @@ export class GroupHandler {
|
||||
|
||||
// Check if multi-UPS config
|
||||
if (!config.groups || !Array.isArray(config.groups)) {
|
||||
logger.logBox('UPS Groups', [
|
||||
'No groups configured.',
|
||||
'',
|
||||
`${theme.dim('Run')} ${theme.command('nupst group add')} ${theme.dim('to add a group')}`,
|
||||
], 50, 'info');
|
||||
logger.logBox(
|
||||
'UPS Groups',
|
||||
[
|
||||
'No groups configured.',
|
||||
'',
|
||||
`${theme.dim('Run')} ${theme.command('nupst group add')} ${
|
||||
theme.dim('to add a group')
|
||||
}`,
|
||||
],
|
||||
50,
|
||||
'info',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Display group list with modern table
|
||||
if (config.groups.length === 0) {
|
||||
logger.logBox('UPS Groups', [
|
||||
'No UPS groups configured.',
|
||||
'',
|
||||
`${theme.dim('Run')} ${theme.command('nupst group add')} ${theme.dim('to add a group')}`,
|
||||
], 60, 'info');
|
||||
logger.logBox(
|
||||
'UPS Groups',
|
||||
[
|
||||
'No UPS groups configured.',
|
||||
'',
|
||||
`${theme.dim('Run')} ${theme.command('nupst group add')} ${
|
||||
theme.dim('to add a group')
|
||||
}`,
|
||||
],
|
||||
60,
|
||||
'info',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -100,24 +118,7 @@ export class GroupHandler {
|
||||
*/
|
||||
public async add(): Promise<void> {
|
||||
try {
|
||||
// Import readline module for user input
|
||||
const readline = await import('node:readline');
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
// Helper function to prompt for input
|
||||
const prompt = (question: string): Promise<string> => {
|
||||
return new Promise((resolve) => {
|
||||
rl.question(question, (answer: string) => {
|
||||
resolve(answer);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
await helpers.withPrompt(async (prompt) => {
|
||||
// Try to load configuration
|
||||
try {
|
||||
await this.nupst.getDaemon().loadConfig();
|
||||
@@ -200,10 +201,7 @@ export class GroupHandler {
|
||||
this.nupst.getUpsHandler().restartServiceIfRunning();
|
||||
|
||||
logger.log('\nGroup setup complete!');
|
||||
} finally {
|
||||
rl.close();
|
||||
process.stdin.destroy();
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`Add group error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
@@ -215,24 +213,7 @@ export class GroupHandler {
|
||||
*/
|
||||
public async edit(groupId: string): Promise<void> {
|
||||
try {
|
||||
// Import readline module for user input
|
||||
const readline = await import('node:readline');
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
// Helper function to prompt for input
|
||||
const prompt = (question: string): Promise<string> => {
|
||||
return new Promise((resolve) => {
|
||||
rl.question(question, (answer: string) => {
|
||||
resolve(answer);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
await helpers.withPrompt(async (prompt) => {
|
||||
// Try to load configuration
|
||||
try {
|
||||
await this.nupst.getDaemon().loadConfig();
|
||||
@@ -318,10 +299,7 @@ export class GroupHandler {
|
||||
this.nupst.getUpsHandler().restartServiceIfRunning();
|
||||
|
||||
logger.log('\nGroup edit complete!');
|
||||
} finally {
|
||||
rl.close();
|
||||
process.stdin.destroy();
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`Edit group error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
@@ -362,23 +340,11 @@ export class GroupHandler {
|
||||
const groupToDelete = config.groups[groupIndex];
|
||||
|
||||
// Get confirmation before deleting
|
||||
const readline = await import('node:readline');
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
const confirm = await new Promise<string>((resolve) => {
|
||||
rl.question(
|
||||
`Are you sure you want to delete group "${groupToDelete.name}" (${groupId})? [y/N]: `,
|
||||
(answer) => {
|
||||
resolve(answer.toLowerCase());
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
rl.close();
|
||||
process.stdin.destroy();
|
||||
const { prompt, close } = await helpers.createPrompt();
|
||||
const confirm = (await prompt(
|
||||
`Are you sure you want to delete group "${groupToDelete.name}" (${groupId})? [y/N]: `,
|
||||
)).toLowerCase();
|
||||
close();
|
||||
|
||||
if (confirm !== 'y' && confirm !== 'yes') {
|
||||
logger.log('Deletion cancelled.');
|
||||
@@ -419,8 +385,8 @@ export class GroupHandler {
|
||||
* @param prompt Function to prompt for user input
|
||||
*/
|
||||
public async assignUpsToGroups(
|
||||
ups: any,
|
||||
groups: any[],
|
||||
ups: IUpsConfig,
|
||||
groups: IGroupConfig[],
|
||||
prompt: (question: string) => Promise<string>,
|
||||
): Promise<void> {
|
||||
// Initialize groups array if it doesn't exist
|
||||
@@ -514,7 +480,7 @@ export class GroupHandler {
|
||||
*/
|
||||
public async assignUpsToGroup(
|
||||
groupId: string,
|
||||
config: any,
|
||||
config: INupstConfig,
|
||||
prompt: (question: string) => Promise<string>,
|
||||
): Promise<void> {
|
||||
if (!config.upsDevices || config.upsDevices.length === 0) {
|
||||
@@ -522,7 +488,7 @@ export class GroupHandler {
|
||||
return;
|
||||
}
|
||||
|
||||
const group = config.groups.find((g: { id: string }) => g.id === groupId);
|
||||
const group = config.groups.find((g) => g.id === groupId);
|
||||
if (!group) {
|
||||
logger.error(`Group with ID "${groupId}" not found.`);
|
||||
return;
|
||||
@@ -530,7 +496,7 @@ export class GroupHandler {
|
||||
|
||||
// Show current assignments
|
||||
logger.log(`\nUPS devices in group "${group.name}" (${group.id}):`);
|
||||
const upsInGroup = config.upsDevices.filter((ups: { groups?: string[] }) =>
|
||||
const upsInGroup = config.upsDevices.filter((ups) =>
|
||||
ups.groups && ups.groups.includes(groupId)
|
||||
);
|
||||
if (upsInGroup.length === 0) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import process from 'node:process';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { Nupst } from '../nupst.ts';
|
||||
import { logger } from '../logger.ts';
|
||||
import * as helpers from '../helpers/index.ts';
|
||||
|
||||
/**
|
||||
* Class for handling service-related CLI commands
|
||||
@@ -125,7 +126,7 @@ export class ServiceHandler {
|
||||
/**
|
||||
* Update NUPST from repository and refresh systemd service
|
||||
*/
|
||||
public async update(): Promise<void> {
|
||||
public update(): void {
|
||||
try {
|
||||
// Check if running as root
|
||||
this.checkRootAccess(
|
||||
@@ -146,8 +147,12 @@ export class ServiceHandler {
|
||||
const latestVersion = release.tag_name; // e.g., "v4.0.7"
|
||||
|
||||
// Normalize versions for comparison (ensure both have "v" prefix)
|
||||
const normalizedCurrent = currentVersion.startsWith('v') ? currentVersion : `v${currentVersion}`;
|
||||
const normalizedLatest = latestVersion.startsWith('v') ? latestVersion : `v${latestVersion}`;
|
||||
const normalizedCurrent = currentVersion.startsWith('v')
|
||||
? currentVersion
|
||||
: `v${currentVersion}`;
|
||||
const normalizedLatest = latestVersion.startsWith('v')
|
||||
? latestVersion
|
||||
: `v${latestVersion}`;
|
||||
|
||||
logger.dim(`Current version: ${normalizedCurrent}`);
|
||||
logger.dim(`Latest version: ${normalizedLatest}`);
|
||||
@@ -196,22 +201,7 @@ export class ServiceHandler {
|
||||
this.checkRootAccess('This command must be run as root.');
|
||||
|
||||
try {
|
||||
// Import readline module for user input
|
||||
const readline = await import('readline');
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
// Helper function to prompt for input
|
||||
const prompt = (question: string): Promise<string> => {
|
||||
return new Promise((resolve) => {
|
||||
rl.question(question, (answer: string) => {
|
||||
resolve(answer);
|
||||
});
|
||||
});
|
||||
};
|
||||
const { prompt, close } = await helpers.createPrompt();
|
||||
|
||||
logger.log('');
|
||||
logger.highlight('NUPST Uninstaller');
|
||||
@@ -254,15 +244,13 @@ export class ServiceHandler {
|
||||
|
||||
if (!uninstallScriptPath) {
|
||||
logger.error('Could not locate uninstall.sh script. Aborting uninstall.');
|
||||
rl.close();
|
||||
process.stdin.destroy();
|
||||
close();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Close readline before executing script
|
||||
rl.close();
|
||||
process.stdin.destroy();
|
||||
// Close prompt before executing script
|
||||
close();
|
||||
|
||||
// Execute uninstall.sh with the appropriate option
|
||||
logger.log('');
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
import process from 'node:process';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { Nupst } from '../nupst.ts';
|
||||
import { logger, type ITableColumn } from '../logger.ts';
|
||||
import { type ITableColumn, logger } from '../logger.ts';
|
||||
import { theme } from '../colors.ts';
|
||||
import * as helpers from '../helpers/index.ts';
|
||||
import type { TUpsModel } from '../snmp/types.ts';
|
||||
import type { INupstConfig } from '../daemon.ts';
|
||||
import type { ISnmpConfig, IUpsStatus as ISnmpUpsStatus, TUpsModel } from '../snmp/types.ts';
|
||||
import type { INupstConfig, IUpsConfig } from '../daemon.ts';
|
||||
import type { IActionConfig } from '../actions/base-action.ts';
|
||||
|
||||
/**
|
||||
* Thresholds configuration for CLI display
|
||||
*/
|
||||
interface IThresholds {
|
||||
battery: number;
|
||||
runtime: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Class for handling UPS-related CLI commands
|
||||
@@ -27,29 +36,9 @@ export class UpsHandler {
|
||||
*/
|
||||
public async add(): Promise<void> {
|
||||
try {
|
||||
// Import readline module for user input
|
||||
const readline = await import('node:readline');
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
// Helper function to prompt for input
|
||||
const prompt = (question: string): Promise<string> => {
|
||||
return new Promise((resolve) => {
|
||||
rl.question(question, (answer: string) => {
|
||||
resolve(answer);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
await helpers.withPrompt(async (prompt) => {
|
||||
await this.runAddProcess(prompt);
|
||||
} finally {
|
||||
rl.close();
|
||||
process.stdin.destroy();
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`Add UPS error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
@@ -77,10 +66,10 @@ export class UpsHandler {
|
||||
checkInterval: config.checkInterval,
|
||||
upsDevices: [{
|
||||
id: 'default',
|
||||
name: 'Default UPS',
|
||||
snmp: config.snmp,
|
||||
groups: [],
|
||||
actions: [],
|
||||
name: 'Default UPS',
|
||||
snmp: config.snmp,
|
||||
groups: [],
|
||||
actions: [],
|
||||
}],
|
||||
groups: [],
|
||||
};
|
||||
@@ -134,7 +123,7 @@ export class UpsHandler {
|
||||
await groupHandler.assignUpsToGroups(newUps, config.groups, prompt);
|
||||
}
|
||||
|
||||
// Gather action settings
|
||||
// Gather action settings
|
||||
await this.gatherActionSettings(newUps.actions, prompt);
|
||||
|
||||
// Add the new UPS to the config
|
||||
@@ -160,29 +149,9 @@ export class UpsHandler {
|
||||
*/
|
||||
public async edit(upsId?: string): Promise<void> {
|
||||
try {
|
||||
// Import readline module for user input
|
||||
const readline = await import('node:readline');
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
// Helper function to prompt for input
|
||||
const prompt = (question: string): Promise<string> => {
|
||||
return new Promise((resolve) => {
|
||||
rl.question(question, (answer: string) => {
|
||||
resolve(answer);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
await helpers.withPrompt(async (prompt) => {
|
||||
await this.runEditProcess(upsId, prompt);
|
||||
} finally {
|
||||
rl.close();
|
||||
process.stdin.destroy();
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`Edit UPS error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
@@ -337,23 +306,11 @@ export class UpsHandler {
|
||||
const upsToDelete = config.upsDevices[upsIndex];
|
||||
|
||||
// Get confirmation before deleting
|
||||
const readline = await import('node:readline');
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
const confirm = await new Promise<string>((resolve) => {
|
||||
rl.question(
|
||||
`Are you sure you want to delete UPS "${upsToDelete.name}" (${upsId})? [y/N]: `,
|
||||
(answer) => {
|
||||
resolve(answer.toLowerCase());
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
rl.close();
|
||||
process.stdin.destroy();
|
||||
const { prompt, close } = await helpers.createPrompt();
|
||||
const confirm = (await prompt(
|
||||
`Are you sure you want to delete UPS "${upsToDelete.name}" (${upsId})? [y/N]: `,
|
||||
)).toLowerCase();
|
||||
close();
|
||||
|
||||
if (confirm !== 'y' && confirm !== 'yes') {
|
||||
logger.log('Deletion cancelled.');
|
||||
@@ -386,10 +343,15 @@ export class UpsHandler {
|
||||
try {
|
||||
await this.nupst.getDaemon().loadConfig();
|
||||
} catch (error) {
|
||||
logger.logBox('Configuration Error', [
|
||||
'No configuration found.',
|
||||
"Please run 'nupst ups add' first to create a configuration.",
|
||||
], 50, 'error');
|
||||
logger.logBox(
|
||||
'Configuration Error',
|
||||
[
|
||||
'No configuration found.',
|
||||
"Please run 'nupst ups add' first to create a configuration.",
|
||||
],
|
||||
50,
|
||||
'error',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -399,31 +361,38 @@ export class UpsHandler {
|
||||
// Check if multi-UPS config
|
||||
if (!config.upsDevices || !Array.isArray(config.upsDevices)) {
|
||||
// Legacy single UPS configuration
|
||||
logger.logBox('UPS Devices', [
|
||||
'Legacy single-UPS configuration detected.',
|
||||
'',
|
||||
...(!config.snmp
|
||||
? ['Error: Configuration missing SNMP settings']
|
||||
: [
|
||||
'Default UPS:',
|
||||
` Host: ${config.snmp.host}:${config.snmp.port}`,
|
||||
` Model: ${config.snmp.upsModel || 'cyberpower'}`,
|
||||
'',
|
||||
'Use "nupst ups add" to add more UPS devices and migrate',
|
||||
'to the multi-UPS configuration format.',
|
||||
]
|
||||
),
|
||||
], 60, 'warning');
|
||||
logger.logBox(
|
||||
'UPS Devices',
|
||||
[
|
||||
'Legacy single-UPS configuration detected.',
|
||||
'',
|
||||
...(!config.snmp ? ['Error: Configuration missing SNMP settings'] : [
|
||||
'Default UPS:',
|
||||
` Host: ${config.snmp.host}:${config.snmp.port}`,
|
||||
` Model: ${config.snmp.upsModel || 'cyberpower'}`,
|
||||
'',
|
||||
'Use "nupst ups add" to add more UPS devices and migrate',
|
||||
'to the multi-UPS configuration format.',
|
||||
]),
|
||||
],
|
||||
60,
|
||||
'warning',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Display UPS list with modern table
|
||||
if (config.upsDevices.length === 0) {
|
||||
logger.logBox('UPS Devices', [
|
||||
'No UPS devices configured.',
|
||||
'',
|
||||
`${theme.dim('Run')} ${theme.command('nupst ups add')} ${theme.dim('to add a device')}`,
|
||||
], 60, 'info');
|
||||
logger.logBox(
|
||||
'UPS Devices',
|
||||
[
|
||||
'No UPS devices configured.',
|
||||
'',
|
||||
`${theme.dim('Run')} ${theme.command('nupst ups add')} ${theme.dim('to add a device')}`,
|
||||
],
|
||||
60,
|
||||
'info',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -509,19 +478,28 @@ export class UpsHandler {
|
||||
* Display the configuration for testing
|
||||
* @param config Current configuration or individual UPS configuration
|
||||
*/
|
||||
private displayTestConfig(config: any): void {
|
||||
// Check if this is a UPS device or full configuration
|
||||
const isUpsConfig = config.snmp;
|
||||
const snmpConfig = isUpsConfig ? config.snmp : config.snmp || {};
|
||||
const checkInterval = config.checkInterval || 30000;
|
||||
private displayTestConfig(config: IUpsConfig | INupstConfig): void {
|
||||
// Type guard: IUpsConfig has 'id' and 'name' at root level, INupstConfig doesn't
|
||||
const isUpsConfig = 'id' in config && 'name' in config;
|
||||
|
||||
// Get UPS name and ID if available
|
||||
const upsName = config.name ? config.name : 'Default UPS';
|
||||
const upsId = config.id ? config.id : 'default';
|
||||
// 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 upsName = isUpsConfig ? (config as IUpsConfig).name : 'Default UPS';
|
||||
const upsId = isUpsConfig ? (config as IUpsConfig).id : 'default';
|
||||
|
||||
const boxWidth = 45;
|
||||
logger.logBoxTitle(`Testing Configuration: ${upsName}`, boxWidth);
|
||||
logger.logBoxLine(`UPS ID: ${upsId}`);
|
||||
|
||||
if (!snmpConfig) {
|
||||
logger.logBoxLine('SNMP Settings: Not configured');
|
||||
logger.logBoxEnd();
|
||||
return;
|
||||
}
|
||||
|
||||
logger.logBoxLine('SNMP Settings:');
|
||||
logger.logBoxLine(` Host: ${snmpConfig.host}`);
|
||||
logger.logBoxLine(` Port: ${snmpConfig.port}`);
|
||||
@@ -557,9 +535,10 @@ export class UpsHandler {
|
||||
logger.logBoxLine(` Battery Runtime: ${snmpConfig.customOIDs.BATTERY_RUNTIME || 'Not set'}`);
|
||||
}
|
||||
// Show group assignments if this is a UPS config
|
||||
if (config.groups && Array.isArray(config.groups)) {
|
||||
if (isUpsConfig) {
|
||||
const groups = (config as IUpsConfig).groups;
|
||||
logger.logBoxLine(
|
||||
`Group Assignments: ${config.groups.length === 0 ? 'None' : config.groups.join(', ')}`,
|
||||
`Group Assignments: ${groups.length === 0 ? 'None' : groups.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -571,16 +550,24 @@ export class UpsHandler {
|
||||
* Test connection to the UPS
|
||||
* @param config Current UPS configuration or legacy config
|
||||
*/
|
||||
private async testConnection(config: any): Promise<void> {
|
||||
const upsId = config.id || 'default';
|
||||
const upsName = config.name || 'Default UPS';
|
||||
private async testConnection(config: IUpsConfig | INupstConfig): Promise<void> {
|
||||
// Type guard: IUpsConfig has 'id' and 'name' at root level
|
||||
const isUpsConfig = 'id' in config && 'name' in config;
|
||||
const upsId = isUpsConfig ? (config as IUpsConfig).id : 'default';
|
||||
const upsName = isUpsConfig ? (config as IUpsConfig).name : 'Default UPS';
|
||||
logger.log(`\nTesting connection to UPS: ${upsName} (${upsId})...`);
|
||||
|
||||
try {
|
||||
// Create a test config with a short timeout
|
||||
const snmpConfig = config.snmp ? config.snmp : config.snmp;
|
||||
// Get SNMP config based on config type
|
||||
const snmpConfig: ISnmpConfig | undefined = isUpsConfig
|
||||
? (config as IUpsConfig).snmp
|
||||
: (config as INupstConfig).snmp;
|
||||
|
||||
const testConfig = {
|
||||
if (!snmpConfig) {
|
||||
throw new Error('SNMP configuration not found');
|
||||
}
|
||||
|
||||
const testConfig: ISnmpConfig = {
|
||||
...snmpConfig,
|
||||
timeout: Math.min(snmpConfig.timeout, 10000), // Use at most 10 seconds for testing
|
||||
};
|
||||
@@ -594,8 +581,6 @@ export class UpsHandler {
|
||||
logger.logBoxLine(` Battery Capacity: ${status.batteryCapacity}%`);
|
||||
logger.logBoxLine(` Runtime Remaining: ${status.batteryRuntime} minutes`);
|
||||
logger.logBoxEnd();
|
||||
|
||||
|
||||
} catch (error) {
|
||||
const errorBoxWidth = 45;
|
||||
logger.logBoxTitle(`Connection Failed: ${upsName}`, errorBoxWidth);
|
||||
@@ -610,7 +595,7 @@ export class UpsHandler {
|
||||
* @param status UPS status
|
||||
* @param thresholds Threshold configuration
|
||||
*/
|
||||
private analyzeThresholds(status: any, thresholds: any): void {
|
||||
private analyzeThresholds(status: ISnmpUpsStatus, thresholds: IThresholds): void {
|
||||
const boxWidth = 45;
|
||||
logger.logBoxTitle('Threshold Analysis', boxWidth);
|
||||
|
||||
@@ -649,7 +634,7 @@ export class UpsHandler {
|
||||
* @param prompt Function to prompt for user input
|
||||
*/
|
||||
private async gatherSnmpSettings(
|
||||
snmpConfig: any,
|
||||
snmpConfig: Partial<ISnmpConfig>,
|
||||
prompt: (question: string) => Promise<string>,
|
||||
): Promise<void> {
|
||||
// SNMP IP Address
|
||||
@@ -693,7 +678,7 @@ export class UpsHandler {
|
||||
* @param prompt Function to prompt for user input
|
||||
*/
|
||||
private async gatherSnmpV3Settings(
|
||||
snmpConfig: any,
|
||||
snmpConfig: Partial<ISnmpConfig>,
|
||||
prompt: (question: string) => Promise<string>,
|
||||
): Promise<void> {
|
||||
logger.log('');
|
||||
@@ -718,17 +703,17 @@ export class UpsHandler {
|
||||
if (secLevel === 1) {
|
||||
snmpConfig.securityLevel = 'noAuthNoPriv';
|
||||
// No auth, no priv - clear out authentication and privacy settings
|
||||
snmpConfig.authProtocol = '';
|
||||
snmpConfig.authKey = '';
|
||||
snmpConfig.privProtocol = '';
|
||||
snmpConfig.privKey = '';
|
||||
snmpConfig.authProtocol = undefined;
|
||||
snmpConfig.authKey = undefined;
|
||||
snmpConfig.privProtocol = undefined;
|
||||
snmpConfig.privKey = undefined;
|
||||
// Set appropriate timeout for security level
|
||||
snmpConfig.timeout = 5000; // 5 seconds for basic security
|
||||
} else if (secLevel === 2) {
|
||||
snmpConfig.securityLevel = 'authNoPriv';
|
||||
// Auth, no priv - clear out privacy settings
|
||||
snmpConfig.privProtocol = '';
|
||||
snmpConfig.privKey = '';
|
||||
snmpConfig.privProtocol = undefined;
|
||||
snmpConfig.privKey = undefined;
|
||||
// Set appropriate timeout for security level
|
||||
snmpConfig.timeout = 10000; // 10 seconds for authentication
|
||||
} else {
|
||||
@@ -771,7 +756,7 @@ export class UpsHandler {
|
||||
* @param prompt Function to prompt for user input
|
||||
*/
|
||||
private async gatherAuthenticationSettings(
|
||||
snmpConfig: any,
|
||||
snmpConfig: Partial<ISnmpConfig>,
|
||||
prompt: (question: string) => Promise<string>,
|
||||
): Promise<void> {
|
||||
// Authentication protocol
|
||||
@@ -798,7 +783,7 @@ export class UpsHandler {
|
||||
* @param prompt Function to prompt for user input
|
||||
*/
|
||||
private async gatherPrivacySettings(
|
||||
snmpConfig: any,
|
||||
snmpConfig: Partial<ISnmpConfig>,
|
||||
prompt: (question: string) => Promise<string>,
|
||||
): Promise<void> {
|
||||
// Privacy protocol
|
||||
@@ -823,7 +808,7 @@ export class UpsHandler {
|
||||
* @param prompt Function to prompt for user input
|
||||
*/
|
||||
private async gatherUpsModelSettings(
|
||||
snmpConfig: any,
|
||||
snmpConfig: Partial<ISnmpConfig>,
|
||||
prompt: (question: string) => Promise<string>,
|
||||
): Promise<void> {
|
||||
logger.log('');
|
||||
@@ -868,16 +853,21 @@ export class UpsHandler {
|
||||
logger.info('Enter custom OIDs for your UPS:');
|
||||
logger.dim('(Leave blank to use standard RFC 1628 OIDs as fallback)');
|
||||
|
||||
// Custom OIDs
|
||||
// Custom OIDs - prompt for essential OIDs
|
||||
const powerStatusOID = await prompt('Power Status OID: ');
|
||||
const batteryCapacityOID = await prompt('Battery Capacity OID: ');
|
||||
const batteryRuntimeOID = await prompt('Battery Runtime OID: ');
|
||||
|
||||
// Create custom OIDs object
|
||||
// Create custom OIDs object with all required fields
|
||||
// Empty strings will use RFC 1628 fallback for non-essential OIDs
|
||||
snmpConfig.customOIDs = {
|
||||
POWER_STATUS: powerStatusOID.trim(),
|
||||
BATTERY_CAPACITY: batteryCapacityOID.trim(),
|
||||
BATTERY_RUNTIME: batteryRuntimeOID.trim(),
|
||||
OUTPUT_LOAD: '',
|
||||
OUTPUT_POWER: '',
|
||||
OUTPUT_VOLTAGE: '',
|
||||
OUTPUT_CURRENT: '',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -888,7 +878,7 @@ export class UpsHandler {
|
||||
* @param prompt Function to prompt for user input
|
||||
*/
|
||||
private async gatherActionSettings(
|
||||
actions: any[],
|
||||
actions: IActionConfig[],
|
||||
prompt: (question: string) => Promise<string>,
|
||||
): Promise<void> {
|
||||
logger.log('');
|
||||
@@ -915,7 +905,7 @@ export class UpsHandler {
|
||||
const typeInput = await prompt('Select action type [1]: ');
|
||||
const typeValue = parseInt(typeInput, 10) || 1;
|
||||
|
||||
const action: any = {};
|
||||
const action: Partial<IActionConfig> = {};
|
||||
|
||||
if (typeValue === 1) {
|
||||
// Shutdown action
|
||||
@@ -979,7 +969,7 @@ export class UpsHandler {
|
||||
logger.dim(' 4) Any change (every ~30s check)');
|
||||
const triggerInput = await prompt('Select trigger mode [1]: ');
|
||||
const triggerValue = parseInt(triggerInput, 10) || 1;
|
||||
|
||||
|
||||
switch (triggerValue) {
|
||||
case 2:
|
||||
action.triggerMode = 'onlyPowerChanges';
|
||||
@@ -995,11 +985,16 @@ export class UpsHandler {
|
||||
}
|
||||
|
||||
// Configure thresholds if needed for onlyThresholds or powerChangesAndThresholds modes
|
||||
if (action.triggerMode === 'onlyThresholds' || action.triggerMode === 'powerChangesAndThresholds') {
|
||||
if (
|
||||
action.triggerMode === 'onlyThresholds' ||
|
||||
action.triggerMode === 'powerChangesAndThresholds'
|
||||
) {
|
||||
logger.log('');
|
||||
logger.info('Action Thresholds:');
|
||||
logger.dim('Action will trigger when battery or runtime falls below these values (while on battery)');
|
||||
|
||||
logger.dim(
|
||||
'Action will trigger when battery or runtime falls below these values (while on battery)',
|
||||
);
|
||||
|
||||
const batteryInput = await prompt('Battery threshold percentage [60]: ');
|
||||
const battery = parseInt(batteryInput, 10);
|
||||
const batteryThreshold = (batteryInput.trim() && !isNaN(battery)) ? battery : 60;
|
||||
@@ -1014,8 +1009,12 @@ export class UpsHandler {
|
||||
};
|
||||
}
|
||||
|
||||
actions.push(action);
|
||||
logger.success(`${action.type.charAt(0).toUpperCase() + action.type.slice(1)} action added (mode: ${action.triggerMode || 'powerChangesAndThresholds'})`);
|
||||
actions.push(action as IActionConfig);
|
||||
logger.success(
|
||||
`${action.type!.charAt(0).toUpperCase() + action.type!.slice(1)} action added (mode: ${
|
||||
action.triggerMode || 'powerChangesAndThresholds'
|
||||
})`,
|
||||
);
|
||||
|
||||
const more = await prompt('Add another action? (y/N): ');
|
||||
addMore = more.toLowerCase() === 'y';
|
||||
@@ -1031,7 +1030,7 @@ export class UpsHandler {
|
||||
* Display UPS configuration summary
|
||||
* @param ups UPS configuration
|
||||
*/
|
||||
private displayUpsConfigSummary(ups: any): void {
|
||||
private displayUpsConfigSummary(ups: IUpsConfig): void {
|
||||
const boxWidth = 45;
|
||||
logger.log('');
|
||||
logger.logBoxTitle(`UPS Configuration: ${ups.name}`, boxWidth);
|
||||
@@ -1039,7 +1038,7 @@ export class UpsHandler {
|
||||
logger.logBoxLine(`SNMP Host: ${ups.snmp.host}:${ups.snmp.port}`);
|
||||
logger.logBoxLine(`SNMP Version: ${ups.snmp.version}`);
|
||||
logger.logBoxLine(`UPS Model: ${ups.snmp.upsModel}`);
|
||||
|
||||
|
||||
if (ups.groups && ups.groups.length > 0) {
|
||||
logger.logBoxLine(`Groups: ${ups.groups.join(', ')}`);
|
||||
} else {
|
||||
@@ -1055,7 +1054,7 @@ export class UpsHandler {
|
||||
* @param prompt Function to prompt for user input
|
||||
*/
|
||||
private async optionallyTestConnection(
|
||||
snmpConfig: any,
|
||||
snmpConfig: ISnmpConfig,
|
||||
prompt: (question: string) => Promise<string>,
|
||||
): Promise<void> {
|
||||
const testConnection = await prompt(
|
||||
|
||||
118
ts/constants.ts
Normal file
118
ts/constants.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* NUPST Constants
|
||||
*
|
||||
* Central location for all timeout, interval, and threshold values.
|
||||
* This makes configuration easier and code more self-documenting.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Default timing values in milliseconds
|
||||
*/
|
||||
export const TIMING = {
|
||||
/** Default interval between UPS status checks (30 seconds) */
|
||||
CHECK_INTERVAL_MS: 30000,
|
||||
|
||||
/** Interval for idle monitoring mode (60 seconds) */
|
||||
IDLE_CHECK_INTERVAL_MS: 60000,
|
||||
|
||||
/** Interval for checking config file changes (60 seconds) */
|
||||
CONFIG_CHECK_INTERVAL_MS: 60000,
|
||||
|
||||
/** Interval for logging periodic status updates (5 minutes) */
|
||||
LOG_INTERVAL_MS: 5 * 60 * 1000,
|
||||
|
||||
/** Maximum time to monitor during shutdown (5 minutes) */
|
||||
MAX_SHUTDOWN_MONITORING_MS: 5 * 60 * 1000,
|
||||
|
||||
/** Interval for UPS checks during shutdown (30 seconds) */
|
||||
SHUTDOWN_CHECK_INTERVAL_MS: 30000,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* SNMP-related constants
|
||||
*/
|
||||
export const SNMP = {
|
||||
/** Default SNMP port */
|
||||
DEFAULT_PORT: 161,
|
||||
|
||||
/** Default SNMP timeout (5 seconds) */
|
||||
DEFAULT_TIMEOUT_MS: 5000,
|
||||
|
||||
/** Number of SNMP retries */
|
||||
RETRIES: 2,
|
||||
|
||||
/** Timeout for noAuthNoPriv security level (5 seconds) */
|
||||
TIMEOUT_NO_AUTH_MS: 5000,
|
||||
|
||||
/** Timeout for authNoPriv security level (10 seconds) */
|
||||
TIMEOUT_AUTH_MS: 10000,
|
||||
|
||||
/** Timeout for authPriv security level (15 seconds) */
|
||||
TIMEOUT_AUTH_PRIV_MS: 15000,
|
||||
|
||||
/** Maximum timeout for connection tests (10 seconds) */
|
||||
MAX_TEST_TIMEOUT_MS: 10000,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Default threshold values
|
||||
*/
|
||||
export const THRESHOLDS = {
|
||||
/** Default battery capacity threshold for shutdown (60%) */
|
||||
DEFAULT_BATTERY_PERCENT: 60,
|
||||
|
||||
/** Default runtime threshold for shutdown (20 minutes) */
|
||||
DEFAULT_RUNTIME_MINUTES: 20,
|
||||
|
||||
/** Emergency runtime threshold during shutdown (5 minutes) */
|
||||
EMERGENCY_RUNTIME_MINUTES: 5,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Webhook action constants
|
||||
*/
|
||||
export const WEBHOOK = {
|
||||
/** Default webhook request timeout (10 seconds) */
|
||||
DEFAULT_TIMEOUT_MS: 10000,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Script action constants
|
||||
*/
|
||||
export const SCRIPT = {
|
||||
/** Default script execution timeout (60 seconds) */
|
||||
DEFAULT_TIMEOUT_MS: 60000,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Shutdown action constants
|
||||
*/
|
||||
export const SHUTDOWN = {
|
||||
/** Default shutdown delay (5 minutes) */
|
||||
DEFAULT_DELAY_MINUTES: 5,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* HTTP Server constants
|
||||
*/
|
||||
export const HTTP_SERVER = {
|
||||
/** Default HTTP server port */
|
||||
DEFAULT_PORT: 8080,
|
||||
|
||||
/** Default URL path for UPS status endpoint */
|
||||
DEFAULT_PATH: '/ups-status',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* UI/Display constants
|
||||
*/
|
||||
export const UI = {
|
||||
/** Default width for log boxes */
|
||||
DEFAULT_BOX_WIDTH: 45,
|
||||
|
||||
/** Wide box width for status displays */
|
||||
WIDE_BOX_WIDTH: 60,
|
||||
|
||||
/** Extra wide box width for detailed info */
|
||||
EXTRA_WIDE_BOX_WIDTH: 70,
|
||||
} as const;
|
||||
138
ts/daemon.ts
138
ts/daemon.ts
@@ -5,12 +5,13 @@ import { exec, execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { NupstSnmp } from './snmp/manager.ts';
|
||||
import type { ISnmpConfig, IUpsStatus as ISnmpUpsStatus } from './snmp/types.ts';
|
||||
import { logger, type ITableColumn } from './logger.ts';
|
||||
import { logger } from './logger.ts';
|
||||
import { MigrationRunner } from './migrations/index.ts';
|
||||
import { theme, symbols, getBatteryColor, getRuntimeColor, formatPowerStatus } from './colors.ts';
|
||||
import { formatPowerStatus, getBatteryColor, getRuntimeColor, theme } from './colors.ts';
|
||||
import type { IActionConfig } from './actions/base-action.ts';
|
||||
import { ActionManager, type IActionContext, type TPowerStatus } from './actions/index.ts';
|
||||
import { NupstHttpServer } from './http-server.ts';
|
||||
import { THRESHOLDS, TIMING, UI } from './constants.ts';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const execFileAsync = promisify(execFile);
|
||||
@@ -99,10 +100,10 @@ export interface IUpsStatus {
|
||||
powerStatus: 'online' | 'onBattery' | 'unknown';
|
||||
batteryCapacity: number;
|
||||
batteryRuntime: number;
|
||||
outputLoad: number; // Load percentage (0-100%)
|
||||
outputPower: number; // Power in watts
|
||||
outputVoltage: number; // Voltage in volts
|
||||
outputCurrent: number; // Current in amps
|
||||
outputLoad: number; // Load percentage (0-100%)
|
||||
outputPower: number; // Power in watts
|
||||
outputVoltage: number; // Voltage in volts
|
||||
outputCurrent: number; // Current in amps
|
||||
lastStatusChange: number;
|
||||
lastCheckTime: number;
|
||||
}
|
||||
@@ -144,8 +145,8 @@ export class NupstDaemon {
|
||||
type: 'shutdown',
|
||||
triggerMode: 'onlyThresholds',
|
||||
thresholds: {
|
||||
battery: 60, // Shutdown when battery below 60%
|
||||
runtime: 20, // Shutdown when runtime below 20 minutes
|
||||
battery: THRESHOLDS.DEFAULT_BATTERY_PERCENT, // Shutdown when battery below 60%
|
||||
runtime: THRESHOLDS.DEFAULT_RUNTIME_MINUTES, // Shutdown when runtime below 20 minutes
|
||||
},
|
||||
shutdownDelay: 5,
|
||||
},
|
||||
@@ -153,8 +154,8 @@ export class NupstDaemon {
|
||||
},
|
||||
],
|
||||
groups: [],
|
||||
checkInterval: 30000, // Check every 30 seconds
|
||||
}
|
||||
checkInterval: TIMING.CHECK_INTERVAL_MS, // Check every 30 seconds
|
||||
};
|
||||
|
||||
private config: INupstConfig;
|
||||
private snmp: NupstSnmp;
|
||||
@@ -248,7 +249,12 @@ export class NupstDaemon {
|
||||
* Helper method to log configuration errors consistently
|
||||
*/
|
||||
private logConfigError(message: string): void {
|
||||
logger.logBox('Configuration Error', [message, "Please run 'nupst setup' first to create a configuration."], 45, 'error');
|
||||
logger.logBox(
|
||||
'Configuration Error',
|
||||
[message, "Please run 'nupst setup' first to create a configuration."],
|
||||
45,
|
||||
'error',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -282,20 +288,23 @@ export class NupstDaemon {
|
||||
this.logConfigLoaded();
|
||||
|
||||
// Log version information
|
||||
this.snmp.getNupst().logVersionInfo(false); // Don't check for updates immediately on startup
|
||||
const nupst = this.snmp.getNupst();
|
||||
if (nupst) {
|
||||
nupst.logVersionInfo(false); // Don't check for updates immediately on startup
|
||||
|
||||
// Check for updates in the background
|
||||
this.snmp.getNupst().checkForUpdates().then((updateAvailable: boolean) => {
|
||||
if (updateAvailable) {
|
||||
const updateStatus = this.snmp.getNupst().getUpdateStatus();
|
||||
const boxWidth = 45;
|
||||
logger.logBoxTitle('Update Available', boxWidth);
|
||||
logger.logBoxLine(`Current Version: ${updateStatus.currentVersion}`);
|
||||
logger.logBoxLine(`Latest Version: ${updateStatus.latestVersion}`);
|
||||
logger.logBoxLine('Run "sudo nupst update" to update');
|
||||
logger.logBoxEnd();
|
||||
}
|
||||
}).catch(() => {}); // Ignore errors checking for updates
|
||||
// Check for updates in the background
|
||||
nupst.checkForUpdates().then((updateAvailable: boolean) => {
|
||||
if (updateAvailable) {
|
||||
const updateStatus = nupst.getUpdateStatus();
|
||||
const boxWidth = 45;
|
||||
logger.logBoxTitle('Update Available', boxWidth);
|
||||
logger.logBoxLine(`Current Version: ${updateStatus.currentVersion}`);
|
||||
logger.logBoxLine(`Latest Version: ${updateStatus.latestVersion}`);
|
||||
logger.logBoxLine('Run "sudo nupst update" to update');
|
||||
logger.logBoxEnd();
|
||||
}
|
||||
}).catch(() => {}); // Ignore errors checking for updates
|
||||
}
|
||||
|
||||
// Initialize UPS status tracking
|
||||
this.initializeUpsStatus();
|
||||
@@ -307,11 +316,15 @@ export class NupstDaemon {
|
||||
this.config.httpServer.port,
|
||||
this.config.httpServer.path,
|
||||
this.config.httpServer.authToken,
|
||||
() => this.upsStatus
|
||||
() => this.upsStatus,
|
||||
);
|
||||
this.httpServer.start();
|
||||
} catch (error) {
|
||||
logger.error(`Failed to start HTTP server: ${error instanceof Error ? error.message : String(error)}`);
|
||||
logger.error(
|
||||
`Failed to start HTTP server: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -360,7 +373,6 @@ export class NupstDaemon {
|
||||
* Log the loaded configuration settings
|
||||
*/
|
||||
private logConfigLoaded(): void {
|
||||
|
||||
logger.log('');
|
||||
logger.logBoxTitle('Configuration Loaded', 70, 'success');
|
||||
logger.logBoxLine(`Check Interval: ${this.config.checkInterval / 1000} seconds`);
|
||||
@@ -370,8 +382,10 @@ export class NupstDaemon {
|
||||
// Display UPS devices in a table
|
||||
if (this.config.upsDevices && this.config.upsDevices.length > 0) {
|
||||
logger.info(`UPS Devices (${this.config.upsDevices.length}):`);
|
||||
|
||||
const upsColumns: Array<{ header: string; key: string; align?: 'left' | 'right'; color?: (val: string) => string }> = [
|
||||
|
||||
const upsColumns: Array<
|
||||
{ header: string; key: string; align?: 'left' | 'right'; color?: (val: string) => string }
|
||||
> = [
|
||||
{ header: 'Name', key: 'name', align: 'left', color: theme.highlight },
|
||||
{ header: 'ID', key: 'id', align: 'left', color: theme.dim },
|
||||
{ header: 'Host:Port', key: 'host', align: 'left', color: theme.info },
|
||||
@@ -395,8 +409,10 @@ export class NupstDaemon {
|
||||
// Display groups in a table
|
||||
if (this.config.groups && this.config.groups.length > 0) {
|
||||
logger.info(`Groups (${this.config.groups.length}):`);
|
||||
|
||||
const groupColumns: Array<{ header: string; key: string; align?: 'left' | 'right'; color?: (val: string) => string }> = [
|
||||
|
||||
const groupColumns: Array<
|
||||
{ header: string; key: string; align?: 'left' | 'right'; color?: (val: string) => string }
|
||||
> = [
|
||||
{ header: 'Name', key: 'name', align: 'left', color: theme.highlight },
|
||||
{ header: 'ID', key: 'id', align: 'left', color: theme.dim },
|
||||
{ header: 'Mode', key: 'mode', align: 'left', color: theme.info },
|
||||
@@ -441,7 +457,6 @@ export class NupstDaemon {
|
||||
}
|
||||
|
||||
let lastLogTime = 0; // Track when we last logged status
|
||||
const LOG_INTERVAL = 5 * 60 * 1000; // Log at least every 5 minutes (300000ms)
|
||||
|
||||
// Monitor continuously
|
||||
while (this.isRunning) {
|
||||
@@ -451,7 +466,7 @@ export class NupstDaemon {
|
||||
|
||||
// Log periodic status update
|
||||
const currentTime = Date.now();
|
||||
if (currentTime - lastLogTime >= LOG_INTERVAL) {
|
||||
if (currentTime - lastLogTime >= TIMING.LOG_INTERVAL_MS) {
|
||||
this.logAllUpsStatus();
|
||||
lastLogTime = currentTime;
|
||||
}
|
||||
@@ -535,7 +550,7 @@ export class NupstDaemon {
|
||||
// Only check when on battery power
|
||||
if (status.powerStatus === 'onBattery' && ups.actions && ups.actions.length > 0) {
|
||||
let anyThresholdExceeded = false;
|
||||
|
||||
|
||||
for (const actionConfig of ups.actions) {
|
||||
if (actionConfig.thresholds) {
|
||||
if (
|
||||
@@ -572,7 +587,7 @@ export class NupstDaemon {
|
||||
*/
|
||||
private logAllUpsStatus(): void {
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
|
||||
logger.log('');
|
||||
logger.logBoxTitle('Periodic Status Update', 70, 'info');
|
||||
logger.logBoxLine(`Timestamp: ${timestamp}`);
|
||||
@@ -580,7 +595,9 @@ export class NupstDaemon {
|
||||
logger.log('');
|
||||
|
||||
// Build table data
|
||||
const columns: Array<{ header: string; key: string; align?: 'left' | 'right'; color?: (val: string) => string }> = [
|
||||
const columns: Array<
|
||||
{ header: string; key: string; align?: 'left' | 'right'; color?: (val: string) => string }
|
||||
> = [
|
||||
{ header: 'UPS Name', key: 'name', align: 'left', color: theme.highlight },
|
||||
{ header: 'ID', key: 'id', align: 'left', color: theme.dim },
|
||||
{ header: 'Power Status', key: 'powerStatus', align: 'left' },
|
||||
@@ -592,7 +609,7 @@ export class NupstDaemon {
|
||||
for (const [id, status] of this.upsStatus.entries()) {
|
||||
const batteryColor = getBatteryColor(status.batteryCapacity);
|
||||
const runtimeColor = getRuntimeColor(status.batteryRuntime);
|
||||
|
||||
|
||||
rows.push({
|
||||
name: status.name,
|
||||
id: id,
|
||||
@@ -606,10 +623,6 @@ export class NupstDaemon {
|
||||
logger.log('');
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Build action context from UPS state
|
||||
* @param ups UPS configuration
|
||||
@@ -789,26 +802,27 @@ export class NupstDaemon {
|
||||
* Force immediate shutdown if any UPS gets critically low
|
||||
*/
|
||||
private async monitorDuringShutdown(): Promise<void> {
|
||||
const EMERGENCY_RUNTIME_THRESHOLD = 5; // 5 minutes remaining is critical
|
||||
const CHECK_INTERVAL = 30000; // Check every 30 seconds during shutdown
|
||||
const MAX_MONITORING_TIME = 5 * 60 * 1000; // Max 5 minutes of monitoring
|
||||
const startTime = Date.now();
|
||||
|
||||
logger.log('');
|
||||
logger.logBoxTitle('Shutdown Monitoring Active', 60, 'warning');
|
||||
logger.logBoxLine(`Emergency threshold: ${EMERGENCY_RUNTIME_THRESHOLD} minutes runtime`);
|
||||
logger.logBoxLine(`Check interval: ${CHECK_INTERVAL / 1000} seconds`);
|
||||
logger.logBoxLine(`Max monitoring time: ${MAX_MONITORING_TIME / 1000} seconds`);
|
||||
logger.logBoxTitle('Shutdown Monitoring Active', UI.WIDE_BOX_WIDTH, 'warning');
|
||||
logger.logBoxLine(
|
||||
`Emergency threshold: ${THRESHOLDS.EMERGENCY_RUNTIME_MINUTES} minutes runtime`,
|
||||
);
|
||||
logger.logBoxLine(`Check interval: ${TIMING.SHUTDOWN_CHECK_INTERVAL_MS / 1000} seconds`);
|
||||
logger.logBoxLine(`Max monitoring time: ${TIMING.MAX_SHUTDOWN_MONITORING_MS / 1000} seconds`);
|
||||
logger.logBoxEnd();
|
||||
logger.log('');
|
||||
|
||||
// Continue monitoring until max monitoring time is reached
|
||||
while (Date.now() - startTime < MAX_MONITORING_TIME) {
|
||||
while (Date.now() - startTime < TIMING.MAX_SHUTDOWN_MONITORING_MS) {
|
||||
try {
|
||||
logger.info('Checking UPS status during shutdown...');
|
||||
|
||||
// Build table for UPS status during shutdown
|
||||
const columns: Array<{ header: string; key: string; align?: 'left' | 'right'; color?: (val: string) => string }> = [
|
||||
const columns: Array<
|
||||
{ header: string; key: string; align?: 'left' | 'right'; color?: (val: string) => string }
|
||||
> = [
|
||||
{ header: 'UPS Name', key: 'name', align: 'left', color: theme.highlight },
|
||||
{ header: 'Battery', key: 'battery', align: 'right' },
|
||||
{ header: 'Runtime', key: 'runtime', align: 'right' },
|
||||
@@ -827,8 +841,8 @@ export class NupstDaemon {
|
||||
const batteryColor = getBatteryColor(status.batteryCapacity);
|
||||
const runtimeColor = getRuntimeColor(status.batteryRuntime);
|
||||
|
||||
const isCritical = status.batteryRuntime < EMERGENCY_RUNTIME_THRESHOLD;
|
||||
|
||||
const isCritical = status.batteryRuntime < THRESHOLDS.EMERGENCY_RUNTIME_MINUTES;
|
||||
|
||||
rows.push({
|
||||
name: ups.name,
|
||||
battery: batteryColor(status.batteryCapacity + '%'),
|
||||
@@ -848,7 +862,7 @@ export class NupstDaemon {
|
||||
runtime: theme.error('N/A'),
|
||||
status: theme.error('ERROR'),
|
||||
});
|
||||
|
||||
|
||||
logger.error(
|
||||
`Error checking UPS ${ups.name} during shutdown: ${
|
||||
upsError instanceof Error ? upsError.message : String(upsError)
|
||||
@@ -868,7 +882,7 @@ export class NupstDaemon {
|
||||
logger.logBoxLine(
|
||||
`UPS ${emergencyUps.ups.name} runtime critically low: ${emergencyUps.status.batteryRuntime} minutes`,
|
||||
);
|
||||
logger.logBoxLine(`Emergency threshold: ${EMERGENCY_RUNTIME_THRESHOLD} minutes`);
|
||||
logger.logBoxLine(`Emergency threshold: ${THRESHOLDS.EMERGENCY_RUNTIME_MINUTES} minutes`);
|
||||
logger.logBoxLine('Forcing immediate shutdown!');
|
||||
logger.logBoxEnd();
|
||||
logger.log('');
|
||||
@@ -879,14 +893,14 @@ export class NupstDaemon {
|
||||
}
|
||||
|
||||
// Wait before checking again
|
||||
await this.sleep(CHECK_INTERVAL);
|
||||
await this.sleep(TIMING.SHUTDOWN_CHECK_INTERVAL_MS);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Error monitoring UPS during shutdown: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
await this.sleep(CHECK_INTERVAL);
|
||||
await this.sleep(TIMING.SHUTDOWN_CHECK_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -988,12 +1002,12 @@ export class NupstDaemon {
|
||||
* Watches for config changes and reloads when detected
|
||||
*/
|
||||
private async idleMonitoring(): Promise<void> {
|
||||
const IDLE_CHECK_INTERVAL = 60000; // Check every 60 seconds
|
||||
let lastConfigCheck = Date.now();
|
||||
const CONFIG_CHECK_INTERVAL = 60000; // Check config every minute
|
||||
|
||||
logger.log('Entering idle monitoring mode...');
|
||||
logger.log('Daemon will check for config changes every 60 seconds');
|
||||
logger.log(
|
||||
`Daemon will check for config changes every ${TIMING.IDLE_CHECK_INTERVAL_MS / 1000} seconds`,
|
||||
);
|
||||
|
||||
// Start file watcher for hot-reload
|
||||
this.watchConfigFile();
|
||||
@@ -1003,7 +1017,7 @@ export class NupstDaemon {
|
||||
const currentTime = Date.now();
|
||||
|
||||
// Periodically check if config has been updated
|
||||
if (currentTime - lastConfigCheck >= CONFIG_CHECK_INTERVAL) {
|
||||
if (currentTime - lastConfigCheck >= TIMING.CONFIG_CHECK_INTERVAL_MS) {
|
||||
try {
|
||||
// Try to load config
|
||||
const newConfig = await this.loadConfig();
|
||||
@@ -1023,12 +1037,12 @@ export class NupstDaemon {
|
||||
lastConfigCheck = currentTime;
|
||||
}
|
||||
|
||||
await this.sleep(IDLE_CHECK_INTERVAL);
|
||||
await this.sleep(TIMING.IDLE_CHECK_INTERVAL_MS);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Error during idle monitoring: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
await this.sleep(IDLE_CHECK_INTERVAL);
|
||||
await this.sleep(TIMING.IDLE_CHECK_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from './shortid.ts';
|
||||
export * from './prompt.ts';
|
||||
|
||||
55
ts/helpers/prompt.ts
Normal file
55
ts/helpers/prompt.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import process from 'node:process';
|
||||
|
||||
/**
|
||||
* Result from creating a prompt interface
|
||||
*/
|
||||
export interface IPromptInterface {
|
||||
/** Function to prompt for user input */
|
||||
prompt: (question: string) => Promise<string>;
|
||||
/** Function to close the prompt interface */
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a readline prompt interface for interactive CLI input
|
||||
* @returns Promise resolving to prompt function and close function
|
||||
*/
|
||||
export async function createPrompt(): Promise<IPromptInterface> {
|
||||
const readline = await import('node:readline');
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
const prompt = (question: string): Promise<string> => {
|
||||
return new Promise((resolve) => {
|
||||
rl.question(question, (answer: string) => {
|
||||
resolve(answer);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const close = (): void => {
|
||||
rl.close();
|
||||
process.stdin.destroy();
|
||||
};
|
||||
|
||||
return { prompt, close };
|
||||
}
|
||||
|
||||
/**
|
||||
* Run an async function with a prompt interface, ensuring cleanup
|
||||
* @param fn Function to run with the prompt interface
|
||||
* @returns Promise resolving to the function's return value
|
||||
*/
|
||||
export async function withPrompt<T>(
|
||||
fn: (prompt: (question: string) => Promise<string>) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const { prompt, close } = await createPrompt();
|
||||
try {
|
||||
return await fn(prompt);
|
||||
} finally {
|
||||
close();
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ export class NupstHttpServer {
|
||||
port: number,
|
||||
path: string,
|
||||
authToken: string,
|
||||
getUpsStatus: () => Map<string, IUpsStatus>
|
||||
getUpsStatus: () => Map<string, IUpsStatus>,
|
||||
) {
|
||||
this.port = port;
|
||||
this.path = path;
|
||||
@@ -70,7 +70,7 @@ export class NupstHttpServer {
|
||||
if (!this.isAuthenticated(req)) {
|
||||
res.writeHead(401, {
|
||||
'Content-Type': 'application/json',
|
||||
'WWW-Authenticate': 'Bearer'
|
||||
'WWW-Authenticate': 'Bearer',
|
||||
});
|
||||
res.end(JSON.stringify({ error: 'Unauthorized' }));
|
||||
return;
|
||||
@@ -82,7 +82,7 @@ export class NupstHttpServer {
|
||||
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-cache'
|
||||
'Cache-Control': 'no-cache',
|
||||
});
|
||||
res.end(JSON.stringify(statusArray, null, 2));
|
||||
} else {
|
||||
@@ -95,7 +95,7 @@ export class NupstHttpServer {
|
||||
logger.success(`HTTP server started on port ${this.port} at ${this.path}`);
|
||||
});
|
||||
|
||||
this.server.on('error', (error: any) => {
|
||||
this.server.on('error', (error: Error) => {
|
||||
logger.error(`HTTP server error: ${error.message}`);
|
||||
});
|
||||
}
|
||||
|
||||
1
ts/interfaces/index.ts
Normal file
1
ts/interfaces/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './nupst-accessor.ts';
|
||||
41
ts/interfaces/nupst-accessor.ts
Normal file
41
ts/interfaces/nupst-accessor.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { NupstDaemon } from '../daemon.ts';
|
||||
|
||||
/**
|
||||
* Update status information
|
||||
*/
|
||||
export interface IUpdateStatus {
|
||||
currentVersion: string;
|
||||
latestVersion: string;
|
||||
updateAvailable: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for accessing Nupst functionality from SNMP manager
|
||||
* This breaks the circular dependency between Nupst and NupstSnmp
|
||||
*/
|
||||
export interface INupstAccessor {
|
||||
/**
|
||||
* Get the daemon manager for background monitoring
|
||||
*/
|
||||
getDaemon(): NupstDaemon;
|
||||
|
||||
/**
|
||||
* Get the current version of NUPST
|
||||
*/
|
||||
getVersion(): string;
|
||||
|
||||
/**
|
||||
* Check if an update is available
|
||||
*/
|
||||
checkForUpdates(): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Get update status information
|
||||
*/
|
||||
getUpdateStatus(): IUpdateStatus;
|
||||
|
||||
/**
|
||||
* Log the current version and update status
|
||||
*/
|
||||
logVersionInfo(checkForUpdates?: boolean): void;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { theme, symbols } from './colors.ts';
|
||||
import { symbols, theme } from './colors.ts';
|
||||
|
||||
/**
|
||||
* Table column alignment options
|
||||
@@ -230,7 +230,8 @@ export class Logger {
|
||||
* Strip ANSI color codes from string for accurate length calculation
|
||||
*/
|
||||
private stripAnsi(text: string): string {
|
||||
// Remove ANSI escape codes
|
||||
// Remove ANSI escape codes (intentional control character regex)
|
||||
// deno-lint-ignore no-control-regex
|
||||
return text.replace(/\x1b\[[0-9;]*m/g, '');
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,9 @@ export abstract class BaseMigration {
|
||||
* @param config - Raw configuration object to check (unknown schema for migrations)
|
||||
* @returns True if migration should run, false otherwise
|
||||
*/
|
||||
abstract shouldRun(config: Record<string, unknown>): Promise<boolean>;
|
||||
abstract shouldRun(
|
||||
config: Record<string, unknown>,
|
||||
): boolean | Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Perform the migration on the given config
|
||||
@@ -39,7 +41,9 @@ export abstract class BaseMigration {
|
||||
* @param config - Raw configuration object to migrate (unknown schema for migrations)
|
||||
* @returns Migrated configuration object
|
||||
*/
|
||||
abstract migrate(config: Record<string, unknown>): Promise<Record<string, unknown>>;
|
||||
abstract migrate(
|
||||
config: Record<string, unknown>,
|
||||
): Record<string, unknown> | Promise<Record<string, unknown>>;
|
||||
|
||||
/**
|
||||
* Get human-readable name for this migration
|
||||
|
||||
@@ -23,12 +23,12 @@ export class MigrationV1ToV2 extends BaseMigration {
|
||||
readonly fromVersion = '1.x';
|
||||
readonly toVersion = '2.0';
|
||||
|
||||
async shouldRun(config: any): Promise<boolean> {
|
||||
shouldRun(config: Record<string, unknown>): boolean {
|
||||
// V1 format has snmp field directly at root, no upsDevices or upsList
|
||||
return !!config.snmp && !config.upsDevices && !config.upsList;
|
||||
}
|
||||
|
||||
async migrate(config: any): Promise<any> {
|
||||
migrate(config: Record<string, unknown>): Record<string, unknown> {
|
||||
logger.info(`${this.getName()}: Converting single SNMP config to multi-UPS format...`);
|
||||
|
||||
const migrated = {
|
||||
|
||||
@@ -42,15 +42,16 @@ export class MigrationV3ToV4 extends BaseMigration {
|
||||
readonly fromVersion = '3.x';
|
||||
readonly toVersion = '4.0';
|
||||
|
||||
async shouldRun(config: any): Promise<boolean> {
|
||||
shouldRun(config: Record<string, unknown>): boolean {
|
||||
// V3 format has upsList OR has upsDevices with flat structure (host at top level)
|
||||
if (config.upsList && !config.upsDevices) {
|
||||
return true; // Classic v3 with upsList
|
||||
}
|
||||
|
||||
// Check if upsDevices exists but has flat structure (v3 format)
|
||||
if (config.upsDevices && config.upsDevices.length > 0) {
|
||||
const firstDevice = config.upsDevices[0];
|
||||
const upsDevices = config.upsDevices as Array<Record<string, unknown>> | undefined;
|
||||
if (upsDevices && upsDevices.length > 0) {
|
||||
const firstDevice = upsDevices[0];
|
||||
// V3 has host at top level, v4 has it nested in snmp object
|
||||
return !!firstDevice.host && !firstDevice.snmp;
|
||||
}
|
||||
@@ -58,17 +59,17 @@ export class MigrationV3ToV4 extends BaseMigration {
|
||||
return false;
|
||||
}
|
||||
|
||||
async migrate(config: any): Promise<any> {
|
||||
migrate(config: Record<string, unknown>): Record<string, unknown> {
|
||||
logger.info(`${this.getName()}: Migrating v3 config to v4 format...`);
|
||||
logger.dim(` - Restructuring UPS devices (flat → nested snmp config)`);
|
||||
|
||||
// Get devices from either upsList or upsDevices (for partially migrated configs)
|
||||
const sourceDevices = config.upsList || config.upsDevices;
|
||||
const sourceDevices = (config.upsList || config.upsDevices) as Array<Record<string, unknown>>;
|
||||
|
||||
// Transform each UPS device from v3 flat structure to v4 nested structure
|
||||
const transformedDevices = sourceDevices.map((device: any) => {
|
||||
const transformedDevices = sourceDevices.map((device: Record<string, unknown>) => {
|
||||
// Build SNMP config object
|
||||
const snmpConfig: any = {
|
||||
const snmpConfig: Record<string, unknown> = {
|
||||
host: device.host,
|
||||
port: device.port || 161,
|
||||
version: typeof device.version === 'string' ? parseInt(device.version, 10) : device.version,
|
||||
@@ -112,7 +113,9 @@ export class MigrationV3ToV4 extends BaseMigration {
|
||||
checkInterval: config.checkInterval || 30000,
|
||||
};
|
||||
|
||||
logger.success(`${this.getName()}: Migration complete (${transformedDevices.length} devices transformed)`);
|
||||
logger.success(
|
||||
`${this.getName()}: Migration complete (${transformedDevices.length} devices transformed)`,
|
||||
);
|
||||
return migrated;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ export class MigrationV4_0ToV4_1 extends BaseMigration {
|
||||
readonly fromVersion = '4.0';
|
||||
readonly toVersion = '4.1';
|
||||
|
||||
async shouldRun(config: Record<string, unknown>): Promise<boolean> {
|
||||
shouldRun(config: Record<string, unknown>): boolean {
|
||||
// Run if config is version 4.0
|
||||
if (config.version === '4.0') {
|
||||
return true;
|
||||
@@ -65,7 +65,7 @@ export class MigrationV4_0ToV4_1 extends BaseMigration {
|
||||
return false;
|
||||
}
|
||||
|
||||
async migrate(config: Record<string, unknown>): Promise<Record<string, unknown>> {
|
||||
migrate(config: Record<string, unknown>): Record<string, unknown> {
|
||||
logger.info(`${this.getName()}: Migrating v4.0 config to v4.1 format...`);
|
||||
logger.dim(` - Moving thresholds from UPS level to action level`);
|
||||
logger.dim(` - Creating default shutdown actions from existing thresholds`);
|
||||
@@ -81,7 +81,9 @@ export class MigrationV4_0ToV4_1 extends BaseMigration {
|
||||
};
|
||||
|
||||
// If device has thresholds at UPS level, convert to shutdown action
|
||||
const deviceThresholds = device.thresholds as { battery: number; runtime: number } | undefined;
|
||||
const deviceThresholds = device.thresholds as
|
||||
| { battery: number; runtime: number }
|
||||
| undefined;
|
||||
if (deviceThresholds) {
|
||||
migrated.actions = [
|
||||
{
|
||||
|
||||
13
ts/nupst.ts
13
ts/nupst.ts
@@ -9,12 +9,13 @@ import { ServiceHandler } from './cli/service-handler.ts';
|
||||
import { ActionHandler } from './cli/action-handler.ts';
|
||||
import { FeatureHandler } from './cli/feature-handler.ts';
|
||||
import * as https from 'node:https';
|
||||
import type { INupstAccessor, IUpdateStatus } from './interfaces/index.ts';
|
||||
|
||||
/**
|
||||
* Main Nupst class that coordinates all components
|
||||
* Acts as a facade to access SNMP, Daemon, and Systemd functionality
|
||||
*/
|
||||
export class Nupst {
|
||||
export class Nupst implements INupstAccessor {
|
||||
private readonly snmp: NupstSnmp;
|
||||
private readonly daemon: NupstDaemon;
|
||||
private readonly systemd: NupstSystemd;
|
||||
@@ -134,11 +135,7 @@ export class Nupst {
|
||||
* Get update status information
|
||||
* @returns Object with update status information
|
||||
*/
|
||||
public getUpdateStatus(): {
|
||||
currentVersion: string;
|
||||
latestVersion: string;
|
||||
updateAvailable: boolean;
|
||||
} {
|
||||
public getUpdateStatus(): IUpdateStatus {
|
||||
return {
|
||||
currentVersion: this.getVersion(),
|
||||
latestVersion: this.latestVersion || this.getVersion(),
|
||||
@@ -174,8 +171,8 @@ export class Nupst {
|
||||
const response = JSON.parse(data);
|
||||
if (response.tag_name) {
|
||||
// Strip 'v' prefix from tag name (e.g., "v5.1.7" -> "5.1.7")
|
||||
const version = response.tag_name.startsWith('v')
|
||||
? response.tag_name.substring(1)
|
||||
const version = response.tag_name.startsWith('v')
|
||||
? response.tag_name.substring(1)
|
||||
: response.tag_name;
|
||||
resolve(version);
|
||||
} else {
|
||||
|
||||
@@ -2,6 +2,9 @@ import * as snmp from 'npm:net-snmp@3.26.0';
|
||||
import { Buffer } from 'node:buffer';
|
||||
import type { IOidSet, ISnmpConfig, IUpsStatus, TUpsModel } from './types.ts';
|
||||
import { UpsOidSets } from './oid-sets.ts';
|
||||
import { SNMP } from '../constants.ts';
|
||||
import { logger } from '../logger.ts';
|
||||
import type { INupstAccessor } from '../interfaces/index.ts';
|
||||
|
||||
/**
|
||||
* Class for SNMP communication with UPS devices
|
||||
@@ -10,18 +13,18 @@ import { UpsOidSets } from './oid-sets.ts';
|
||||
export class NupstSnmp {
|
||||
// Active OID set
|
||||
private activeOIDs: IOidSet;
|
||||
// Reference to the parent Nupst instance
|
||||
private nupst: any; // Type 'any' to avoid circular dependency
|
||||
// Reference to the parent Nupst instance (uses interface to avoid circular dependency)
|
||||
private nupst: INupstAccessor | null = null;
|
||||
// Debug mode flag
|
||||
private debug: boolean = false;
|
||||
|
||||
// Default SNMP configuration
|
||||
private readonly DEFAULT_CONFIG: ISnmpConfig = {
|
||||
host: '127.0.0.1', // Default to localhost
|
||||
port: 161, // Default SNMP port
|
||||
port: SNMP.DEFAULT_PORT, // Default SNMP port
|
||||
community: 'public', // Default community string for v1/v2c
|
||||
version: 1, // SNMPv1
|
||||
timeout: 5000, // 5 seconds timeout
|
||||
timeout: SNMP.DEFAULT_TIMEOUT_MS, // 5 seconds timeout
|
||||
upsModel: 'cyberpower', // Default UPS model
|
||||
};
|
||||
|
||||
@@ -39,14 +42,14 @@ export class NupstSnmp {
|
||||
* Set reference to the main Nupst instance
|
||||
* @param nupst Reference to the main Nupst instance
|
||||
*/
|
||||
public setNupst(nupst: any): void {
|
||||
public setNupst(nupst: INupstAccessor): void {
|
||||
this.nupst = nupst;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get reference to the main Nupst instance
|
||||
*/
|
||||
public getNupst(): any {
|
||||
public getNupst(): INupstAccessor | null {
|
||||
return this.nupst;
|
||||
}
|
||||
|
||||
@@ -55,7 +58,7 @@ export class NupstSnmp {
|
||||
*/
|
||||
public enableDebug(): void {
|
||||
this.debug = true;
|
||||
console.log('SNMP debug mode enabled - detailed logs will be shown');
|
||||
logger.info('SNMP debug mode enabled - detailed logs will be shown');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -67,7 +70,7 @@ export class NupstSnmp {
|
||||
if (config.upsModel === 'custom' && config.customOIDs) {
|
||||
this.activeOIDs = config.customOIDs;
|
||||
if (this.debug) {
|
||||
console.log('Using custom OIDs:', this.activeOIDs);
|
||||
logger.dim(`Using custom OIDs: ${JSON.stringify(this.activeOIDs)}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -77,7 +80,7 @@ export class NupstSnmp {
|
||||
this.activeOIDs = UpsOidSets.getOidSet(model);
|
||||
|
||||
if (this.debug) {
|
||||
console.log(`Using OIDs for UPS model: ${model}`);
|
||||
logger.dim(`Using OIDs for UPS model: ${model}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,20 +94,22 @@ export class NupstSnmp {
|
||||
public snmpGet(
|
||||
oid: string,
|
||||
config = this.DEFAULT_CONFIG,
|
||||
retryCount = 0,
|
||||
_retryCount = 0,
|
||||
// deno-lint-ignore no-explicit-any
|
||||
): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (this.debug) {
|
||||
console.log(
|
||||
logger.dim(
|
||||
`Sending SNMP v${config.version} GET request for OID ${oid} to ${config.host}:${config.port}`,
|
||||
);
|
||||
console.log('Using community:', config.community);
|
||||
logger.dim(`Using community: ${config.community}`);
|
||||
}
|
||||
|
||||
// Create SNMP options based on configuration
|
||||
// deno-lint-ignore no-explicit-any
|
||||
const options: any = {
|
||||
port: config.port,
|
||||
retries: 2, // Number of retries
|
||||
retries: SNMP.RETRIES, // Number of retries
|
||||
timeout: config.timeout,
|
||||
transport: 'udp4',
|
||||
idBitsSize: 32,
|
||||
@@ -129,6 +134,7 @@ export class NupstSnmp {
|
||||
const securityLevel = config.securityLevel || 'noAuthNoPriv';
|
||||
|
||||
// Create the user object with required structure for net-snmp
|
||||
// deno-lint-ignore no-explicit-any
|
||||
const user: any = {
|
||||
name: config.username || '',
|
||||
};
|
||||
@@ -151,7 +157,7 @@ export class NupstSnmp {
|
||||
// Fallback to noAuthNoPriv if auth details missing
|
||||
user.level = snmp.SecurityLevel.noAuthNoPriv;
|
||||
if (this.debug) {
|
||||
console.log('Warning: Missing authProtocol or authKey, falling back to noAuthNoPriv');
|
||||
logger.warn('Missing authProtocol or authKey, falling back to noAuthNoPriv');
|
||||
}
|
||||
}
|
||||
} else if (securityLevel === 'authPriv') {
|
||||
@@ -178,29 +184,27 @@ export class NupstSnmp {
|
||||
// Fallback to authNoPriv if priv details missing
|
||||
user.level = snmp.SecurityLevel.authNoPriv;
|
||||
if (this.debug) {
|
||||
console.log('Warning: Missing privProtocol or privKey, falling back to authNoPriv');
|
||||
logger.warn('Missing privProtocol or privKey, falling back to authNoPriv');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback to noAuthNoPriv if auth details missing
|
||||
user.level = snmp.SecurityLevel.noAuthNoPriv;
|
||||
if (this.debug) {
|
||||
console.log('Warning: Missing authProtocol or authKey, falling back to noAuthNoPriv');
|
||||
logger.warn('Missing authProtocol or authKey, falling back to noAuthNoPriv');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.debug) {
|
||||
console.log('SNMPv3 user configuration:', {
|
||||
name: user.name,
|
||||
level: Object.keys(snmp.SecurityLevel).find((key) =>
|
||||
snmp.SecurityLevel[key] === user.level
|
||||
),
|
||||
authProtocol: user.authProtocol ? 'Set' : 'Not Set',
|
||||
authKey: user.authKey ? 'Set' : 'Not Set',
|
||||
privProtocol: user.privProtocol ? 'Set' : 'Not Set',
|
||||
privKey: user.privKey ? 'Set' : 'Not Set',
|
||||
});
|
||||
const levelName = Object.keys(snmp.SecurityLevel).find((key) =>
|
||||
snmp.SecurityLevel[key] === user.level
|
||||
);
|
||||
logger.dim(
|
||||
`SNMPv3 user configuration: name=${user.name}, level=${levelName}, authProtocol=${
|
||||
user.authProtocol ? 'Set' : 'Not Set'
|
||||
}, privProtocol=${user.privProtocol ? 'Set' : 'Not Set'}`,
|
||||
);
|
||||
}
|
||||
|
||||
session = snmp.createV3Session(config.host, user, options);
|
||||
@@ -213,13 +217,14 @@ export class NupstSnmp {
|
||||
const oids = [oid];
|
||||
|
||||
// Send the GET request
|
||||
session.get(oids, (error: any, varbinds: any[]) => {
|
||||
// deno-lint-ignore no-explicit-any
|
||||
session.get(oids, (error: Error | null, varbinds: any[]) => {
|
||||
// Close the session to release resources
|
||||
session.close();
|
||||
|
||||
if (error) {
|
||||
if (this.debug) {
|
||||
console.error('SNMP GET error:', error);
|
||||
logger.error(`SNMP GET error: ${error}`);
|
||||
}
|
||||
reject(new Error(`SNMP GET error: ${error.message || error}`));
|
||||
return;
|
||||
@@ -227,7 +232,7 @@ export class NupstSnmp {
|
||||
|
||||
if (!varbinds || varbinds.length === 0) {
|
||||
if (this.debug) {
|
||||
console.error('No varbinds returned in response');
|
||||
logger.error('No varbinds returned in response');
|
||||
}
|
||||
reject(new Error('No varbinds returned in response'));
|
||||
return;
|
||||
@@ -240,7 +245,7 @@ export class NupstSnmp {
|
||||
varbinds[0].type === snmp.ObjectType.EndOfMibView
|
||||
) {
|
||||
if (this.debug) {
|
||||
console.error('SNMP error:', snmp.ObjectType[varbinds[0].type]);
|
||||
logger.error(`SNMP error: ${snmp.ObjectType[varbinds[0].type]}`);
|
||||
}
|
||||
reject(new Error(`SNMP error: ${snmp.ObjectType[varbinds[0].type]}`));
|
||||
return;
|
||||
@@ -262,11 +267,9 @@ export class NupstSnmp {
|
||||
}
|
||||
|
||||
if (this.debug) {
|
||||
console.log('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);
|
||||
@@ -285,30 +288,30 @@ export class NupstSnmp {
|
||||
this.setActiveOIDs(config);
|
||||
|
||||
if (this.debug) {
|
||||
console.log('---------------------------------------');
|
||||
console.log('Getting UPS status with config:');
|
||||
console.log(' Host:', config.host);
|
||||
console.log(' Port:', config.port);
|
||||
console.log(' Version:', config.version);
|
||||
console.log(' Timeout:', config.timeout, 'ms');
|
||||
console.log(' UPS Model:', config.upsModel || 'cyberpower');
|
||||
logger.dim('---------------------------------------');
|
||||
logger.dim('Getting UPS status with config:');
|
||||
logger.dim(` Host: ${config.host}`);
|
||||
logger.dim(` Port: ${config.port}`);
|
||||
logger.dim(` Version: ${config.version}`);
|
||||
logger.dim(` Timeout: ${config.timeout} ms`);
|
||||
logger.dim(` UPS Model: ${config.upsModel || 'cyberpower'}`);
|
||||
if (config.version === 1 || config.version === 2) {
|
||||
console.log(' Community:', config.community);
|
||||
logger.dim(` Community: ${config.community}`);
|
||||
} else if (config.version === 3) {
|
||||
console.log(' Security Level:', config.securityLevel);
|
||||
console.log(' Username:', config.username);
|
||||
console.log(' Auth Protocol:', config.authProtocol || 'None');
|
||||
console.log(' Privacy Protocol:', config.privProtocol || 'None');
|
||||
logger.dim(` Security Level: ${config.securityLevel}`);
|
||||
logger.dim(` Username: ${config.username}`);
|
||||
logger.dim(` Auth Protocol: ${config.authProtocol || 'None'}`);
|
||||
logger.dim(` Privacy Protocol: ${config.privProtocol || 'None'}`);
|
||||
}
|
||||
console.log('Using OIDs:');
|
||||
console.log(' Power Status:', this.activeOIDs.POWER_STATUS);
|
||||
console.log(' Battery Capacity:', this.activeOIDs.BATTERY_CAPACITY);
|
||||
console.log(' Battery Runtime:', this.activeOIDs.BATTERY_RUNTIME);
|
||||
console.log(' Output Load:', this.activeOIDs.OUTPUT_LOAD);
|
||||
console.log(' Output Power:', this.activeOIDs.OUTPUT_POWER);
|
||||
console.log(' Output Voltage:', this.activeOIDs.OUTPUT_VOLTAGE);
|
||||
console.log(' Output Current:', this.activeOIDs.OUTPUT_CURRENT);
|
||||
console.log('---------------------------------------');
|
||||
logger.dim('Using OIDs:');
|
||||
logger.dim(` Power Status: ${this.activeOIDs.POWER_STATUS}`);
|
||||
logger.dim(` Battery Capacity: ${this.activeOIDs.BATTERY_CAPACITY}`);
|
||||
logger.dim(` Battery Runtime: ${this.activeOIDs.BATTERY_RUNTIME}`);
|
||||
logger.dim(` Output Load: ${this.activeOIDs.OUTPUT_LOAD}`);
|
||||
logger.dim(` Output Power: ${this.activeOIDs.OUTPUT_POWER}`);
|
||||
logger.dim(` Output Voltage: ${this.activeOIDs.OUTPUT_VOLTAGE}`);
|
||||
logger.dim(` Output Current: ${this.activeOIDs.OUTPUT_CURRENT}`);
|
||||
logger.dim('---------------------------------------');
|
||||
}
|
||||
|
||||
// Get all values with independent retry logic
|
||||
@@ -365,7 +368,7 @@ export class NupstSnmp {
|
||||
if (outputPower === 0 && processedVoltage > 0 && processedCurrent > 0) {
|
||||
processedPower = Math.round(processedVoltage * processedCurrent);
|
||||
if (this.debug) {
|
||||
console.log(
|
||||
logger.dim(
|
||||
`Calculated power from V×I: ${processedVoltage}V × ${processedCurrent}A = ${processedPower}W`,
|
||||
);
|
||||
}
|
||||
@@ -391,27 +394,26 @@ export class NupstSnmp {
|
||||
};
|
||||
|
||||
if (this.debug) {
|
||||
console.log('---------------------------------------');
|
||||
console.log('UPS status result:');
|
||||
console.log(' Power Status:', result.powerStatus);
|
||||
console.log(' Battery Capacity:', result.batteryCapacity + '%');
|
||||
console.log(' Battery Runtime:', result.batteryRuntime, 'minutes');
|
||||
console.log(' Output Load:', result.outputLoad + '%');
|
||||
console.log(' Output Power:', result.outputPower, 'watts');
|
||||
console.log(' Output Voltage:', result.outputVoltage, 'volts');
|
||||
console.log(' Output Current:', result.outputCurrent, 'amps');
|
||||
console.log('---------------------------------------');
|
||||
logger.dim('---------------------------------------');
|
||||
logger.dim('UPS 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;
|
||||
} catch (error) {
|
||||
if (this.debug) {
|
||||
console.error('---------------------------------------');
|
||||
console.error(
|
||||
'Error getting UPS status:',
|
||||
error instanceof Error ? error.message : String(error),
|
||||
logger.error('---------------------------------------');
|
||||
logger.error(
|
||||
`Error getting UPS status: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
console.error('---------------------------------------');
|
||||
logger.error('---------------------------------------');
|
||||
}
|
||||
throw new Error(
|
||||
`Failed to get UPS status: ${error instanceof Error ? error.message : String(error)}`,
|
||||
@@ -430,29 +432,29 @@ export class NupstSnmp {
|
||||
oid: string,
|
||||
description: string,
|
||||
config: ISnmpConfig,
|
||||
// deno-lint-ignore no-explicit-any
|
||||
): Promise<any> {
|
||||
if (oid === '') {
|
||||
if (this.debug) {
|
||||
console.log(`No OID provided for ${description}, skipping`);
|
||||
logger.dim(`No OID provided for ${description}, skipping`);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (this.debug) {
|
||||
console.log(`Getting ${description} OID: ${oid}`);
|
||||
logger.dim(`Getting ${description} OID: ${oid}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const value = await this.snmpGet(oid, config);
|
||||
if (this.debug) {
|
||||
console.log(`${description} value:`, value);
|
||||
logger.dim(`${description} value: ${value}`);
|
||||
}
|
||||
return value;
|
||||
} catch (error) {
|
||||
if (this.debug) {
|
||||
console.error(
|
||||
`Error getting ${description}:`,
|
||||
error instanceof Error ? error.message : String(error),
|
||||
logger.error(
|
||||
`Error getting ${description}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -468,7 +470,7 @@ export class NupstSnmp {
|
||||
|
||||
// Return a default value if all attempts fail
|
||||
if (this.debug) {
|
||||
console.log(`Using default value 0 for ${description}`);
|
||||
logger.dim(`Using default value 0 for ${description}`);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -485,28 +487,30 @@ export class NupstSnmp {
|
||||
oid: string,
|
||||
description: string,
|
||||
config: ISnmpConfig,
|
||||
// deno-lint-ignore no-explicit-any
|
||||
): Promise<any> {
|
||||
if (this.debug) {
|
||||
console.log(`Retrying ${description} with fallback security level...`);
|
||||
logger.dim(`Retrying ${description} with fallback security level...`);
|
||||
}
|
||||
|
||||
// Try with authNoPriv if current level is authPriv
|
||||
if (config.securityLevel === 'authPriv') {
|
||||
const retryConfig = { ...config, securityLevel: 'authNoPriv' as 'authNoPriv' };
|
||||
const retryConfig = { ...config, securityLevel: 'authNoPriv' as const };
|
||||
try {
|
||||
if (this.debug) {
|
||||
console.log(`Retrying with authNoPriv security level`);
|
||||
logger.dim(`Retrying with authNoPriv security level`);
|
||||
}
|
||||
const value = await this.snmpGet(oid, retryConfig);
|
||||
if (this.debug) {
|
||||
console.log(`${description} retry value:`, value);
|
||||
logger.dim(`${description} retry value: ${value}`);
|
||||
}
|
||||
return value;
|
||||
} catch (retryError) {
|
||||
if (this.debug) {
|
||||
console.error(
|
||||
`Retry failed for ${description}:`,
|
||||
retryError instanceof Error ? retryError.message : String(retryError),
|
||||
logger.error(
|
||||
`Retry failed for ${description}: ${
|
||||
retryError instanceof Error ? retryError.message : String(retryError)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -514,21 +518,22 @@ export class NupstSnmp {
|
||||
|
||||
// Try with noAuthNoPriv as a last resort
|
||||
if (config.securityLevel === 'authPriv' || config.securityLevel === 'authNoPriv') {
|
||||
const retryConfig = { ...config, securityLevel: 'noAuthNoPriv' as 'noAuthNoPriv' };
|
||||
const retryConfig = { ...config, securityLevel: 'noAuthNoPriv' as const };
|
||||
try {
|
||||
if (this.debug) {
|
||||
console.log(`Retrying with noAuthNoPriv security level`);
|
||||
logger.dim(`Retrying with noAuthNoPriv security level`);
|
||||
}
|
||||
const value = await this.snmpGet(oid, retryConfig);
|
||||
if (this.debug) {
|
||||
console.log(`${description} retry value:`, value);
|
||||
logger.dim(`${description} retry value: ${value}`);
|
||||
}
|
||||
return value;
|
||||
} catch (retryError) {
|
||||
if (this.debug) {
|
||||
console.error(
|
||||
`Retry failed for ${description}:`,
|
||||
retryError instanceof Error ? retryError.message : String(retryError),
|
||||
logger.error(
|
||||
`Retry failed for ${description}: ${
|
||||
retryError instanceof Error ? retryError.message : String(retryError)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -539,36 +544,38 @@ export class NupstSnmp {
|
||||
|
||||
/**
|
||||
* Try standard OIDs as fallback
|
||||
* @param oid OID to query
|
||||
* @param _oid Original OID (unused, kept for method signature consistency)
|
||||
* @param description Description of the value for logging
|
||||
* @param config SNMP configuration
|
||||
* @returns Promise resolving to the SNMP value
|
||||
*/
|
||||
private async tryStandardOids(
|
||||
oid: string,
|
||||
_oid: string,
|
||||
description: string,
|
||||
config: ISnmpConfig,
|
||||
// deno-lint-ignore no-explicit-any
|
||||
): Promise<any> {
|
||||
try {
|
||||
// Try RFC 1628 standard UPS MIB OIDs
|
||||
const standardOIDs = UpsOidSets.getStandardOids();
|
||||
|
||||
if (this.debug) {
|
||||
console.log(
|
||||
logger.dim(
|
||||
`Trying standard RFC 1628 OID for ${description}: ${standardOIDs[description]}`,
|
||||
);
|
||||
}
|
||||
|
||||
const standardValue = await this.snmpGet(standardOIDs[description], config);
|
||||
if (this.debug) {
|
||||
console.log(`${description} standard OID value:`, standardValue);
|
||||
logger.dim(`${description} standard OID value: ${standardValue}`);
|
||||
}
|
||||
return standardValue;
|
||||
} catch (stdError) {
|
||||
if (this.debug) {
|
||||
console.error(
|
||||
`Standard OID retry failed for ${description}:`,
|
||||
stdError instanceof Error ? stdError.message : String(stdError),
|
||||
logger.error(
|
||||
`Standard OID retry failed for ${description}: ${
|
||||
stdError instanceof Error ? stdError.message : String(stdError)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -623,14 +630,14 @@ export class NupstSnmp {
|
||||
batteryRuntime: number,
|
||||
): number {
|
||||
if (this.debug) {
|
||||
console.log('Raw runtime value:', batteryRuntime);
|
||||
logger.dim(`Raw runtime value: ${batteryRuntime}`);
|
||||
}
|
||||
|
||||
if (upsModel === 'cyberpower' && batteryRuntime > 0) {
|
||||
// CyberPower: TimeTicks is in 1/100 seconds, convert to minutes
|
||||
const minutes = Math.floor(batteryRuntime / 6000); // 6000 ticks = 1 minute
|
||||
if (this.debug) {
|
||||
console.log(
|
||||
logger.dim(
|
||||
`Converting CyberPower runtime from ${batteryRuntime} ticks to ${minutes} minutes`,
|
||||
);
|
||||
}
|
||||
@@ -639,7 +646,7 @@ export class NupstSnmp {
|
||||
// Eaton: Runtime is in seconds, convert to minutes
|
||||
const minutes = Math.floor(batteryRuntime / 60);
|
||||
if (this.debug) {
|
||||
console.log(
|
||||
logger.dim(
|
||||
`Converting Eaton runtime from ${batteryRuntime} seconds to ${minutes} minutes`,
|
||||
);
|
||||
}
|
||||
@@ -648,7 +655,7 @@ export class NupstSnmp {
|
||||
// Generic conversion for large tick values (likely TimeTicks)
|
||||
const minutes = Math.floor(batteryRuntime / 6000);
|
||||
if (this.debug) {
|
||||
console.log(`Converting ${batteryRuntime} ticks to ${minutes} minutes`);
|
||||
logger.dim(`Converting ${batteryRuntime} ticks to ${minutes} minutes`);
|
||||
}
|
||||
return minutes;
|
||||
}
|
||||
@@ -667,14 +674,14 @@ export class NupstSnmp {
|
||||
outputVoltage: number,
|
||||
): number {
|
||||
if (this.debug) {
|
||||
console.log('Raw voltage value:', outputVoltage);
|
||||
logger.dim(`Raw voltage value: ${outputVoltage}`);
|
||||
}
|
||||
|
||||
if (upsModel === 'cyberpower' && outputVoltage > 0) {
|
||||
// CyberPower: Voltage is in 0.1V, convert to volts
|
||||
const volts = outputVoltage / 10;
|
||||
if (this.debug) {
|
||||
console.log(
|
||||
logger.dim(
|
||||
`Converting CyberPower voltage from ${outputVoltage} (0.1V) to ${volts} volts`,
|
||||
);
|
||||
}
|
||||
@@ -695,14 +702,14 @@ export class NupstSnmp {
|
||||
outputCurrent: number,
|
||||
): number {
|
||||
if (this.debug) {
|
||||
console.log('Raw current value:', outputCurrent);
|
||||
logger.dim(`Raw current value: ${outputCurrent}`);
|
||||
}
|
||||
|
||||
if (upsModel === 'cyberpower' && outputCurrent > 0) {
|
||||
// CyberPower: Current is in 0.1A, convert to amps
|
||||
const amps = outputCurrent / 10;
|
||||
if (this.debug) {
|
||||
console.log(
|
||||
logger.dim(
|
||||
`Converting CyberPower current from ${outputCurrent} (0.1A) to ${amps} amps`,
|
||||
);
|
||||
}
|
||||
@@ -711,7 +718,7 @@ export class NupstSnmp {
|
||||
// RFC 1628 standard: Current is in 0.1A, convert to amps
|
||||
const amps = outputCurrent / 10;
|
||||
if (this.debug) {
|
||||
console.log(
|
||||
logger.dim(
|
||||
`Converting RFC 1628 current from ${outputCurrent} (0.1A) to ${amps} amps`,
|
||||
);
|
||||
}
|
||||
@@ -720,7 +727,7 @@ export class NupstSnmp {
|
||||
|
||||
// Eaton XUPS-MIB and APC PowerNet report current directly in RMS Amps (no scaling needed)
|
||||
if ((upsModel === 'eaton' || upsModel === 'apc') && this.debug && outputCurrent > 0) {
|
||||
console.log(`${upsModel.toUpperCase()} current already in RMS Amps: ${outputCurrent}A`);
|
||||
logger.dim(`${upsModel.toUpperCase()} current already in RMS Amps: ${outputCurrent}A`);
|
||||
}
|
||||
|
||||
return outputCurrent;
|
||||
|
||||
@@ -23,7 +23,7 @@ export interface IUpsStatus {
|
||||
/** Output current in amps */
|
||||
outputCurrent: number;
|
||||
/** Raw values from SNMP responses */
|
||||
raw: Record<string, any>;
|
||||
raw: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
116
ts/systemd.ts
116
ts/systemd.ts
@@ -1,10 +1,10 @@
|
||||
import process from 'node:process';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { NupstDaemon, type IUpsConfig } from './daemon.ts';
|
||||
import { type IUpsConfig, NupstDaemon } from './daemon.ts';
|
||||
import { NupstSnmp } from './snmp/manager.ts';
|
||||
import { logger } from './logger.ts';
|
||||
import { theme, symbols, getBatteryColor, getRuntimeColor, formatPowerStatus } from './colors.ts';
|
||||
import { formatPowerStatus, getBatteryColor, getRuntimeColor, symbols, theme } from './colors.ts';
|
||||
|
||||
/**
|
||||
* Class for managing systemd service
|
||||
@@ -54,7 +54,11 @@ WantedBy=multi-user.target
|
||||
logger.log('');
|
||||
logger.error('No configuration found');
|
||||
logger.log(` ${theme.dim('Config file:')} ${configPath}`);
|
||||
logger.log(` ${theme.dim('Run')} ${theme.command('nupst ups add')} ${theme.dim('to create a configuration')}`);
|
||||
logger.log(
|
||||
` ${theme.dim('Run')} ${theme.command('nupst ups add')} ${
|
||||
theme.dim('to create a configuration')
|
||||
}`,
|
||||
);
|
||||
logger.log('');
|
||||
throw new Error('Configuration not found');
|
||||
}
|
||||
@@ -142,32 +146,43 @@ WantedBy=multi-user.target
|
||||
private async displayVersionInfo(): Promise<void> {
|
||||
try {
|
||||
const nupst = this.daemon.getNupstSnmp().getNupst();
|
||||
if (!nupst) {
|
||||
return;
|
||||
}
|
||||
const version = nupst.getVersion();
|
||||
|
||||
|
||||
// Check for updates
|
||||
const updateAvailable = await nupst.checkForUpdates();
|
||||
|
||||
|
||||
// Display version info
|
||||
if (updateAvailable) {
|
||||
const updateStatus = nupst.getUpdateStatus();
|
||||
logger.log('');
|
||||
logger.log(
|
||||
`${theme.dim('NUPST')} ${theme.dim('v' + version)} ${symbols.warning} ${theme.statusWarning(`Update available: v${updateStatus.latestVersion}`)}`,
|
||||
`${theme.dim('NUPST')} ${theme.dim('v' + version)} ${symbols.warning} ${
|
||||
theme.statusWarning(`Update available: v${updateStatus.latestVersion}`)
|
||||
}`,
|
||||
);
|
||||
logger.log(
|
||||
` ${theme.dim('Run')} ${theme.command('sudo nupst update')} ${theme.dim('to upgrade')}`,
|
||||
);
|
||||
logger.log(` ${theme.dim('Run')} ${theme.command('sudo nupst update')} ${theme.dim('to upgrade')}`);
|
||||
} else {
|
||||
logger.log('');
|
||||
logger.log(
|
||||
`${theme.dim('NUPST')} ${theme.dim('v' + version)} ${symbols.success} ${theme.success('Up to date')}`,
|
||||
`${theme.dim('NUPST')} ${theme.dim('v' + version)} ${symbols.success} ${
|
||||
theme.success('Up to date')
|
||||
}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
// If version check fails, show at least the current version
|
||||
try {
|
||||
const nupst = this.daemon.getNupstSnmp().getNupst();
|
||||
const version = nupst.getVersion();
|
||||
logger.log('');
|
||||
logger.log(`${theme.dim('NUPST')} ${theme.dim('v' + version)}`);
|
||||
if (nupst) {
|
||||
const version = nupst.getVersion();
|
||||
logger.log('');
|
||||
logger.log(`${theme.dim('NUPST')} ${theme.dim('v' + version)}`);
|
||||
}
|
||||
} catch (_innerError) {
|
||||
// Silently fail if we can't even get the version
|
||||
}
|
||||
@@ -237,9 +252,15 @@ WantedBy=multi-user.target
|
||||
// Display beautiful status
|
||||
logger.log('');
|
||||
if (isActive) {
|
||||
logger.log(`${symbols.running} ${theme.success('Service:')} ${theme.statusActive('active (running)')}`);
|
||||
logger.log(
|
||||
`${symbols.running} ${theme.success('Service:')} ${
|
||||
theme.statusActive('active (running)')
|
||||
}`,
|
||||
);
|
||||
} else {
|
||||
logger.log(`${symbols.stopped} ${theme.dim('Service:')} ${theme.statusInactive('inactive')}`);
|
||||
logger.log(
|
||||
`${symbols.stopped} ${theme.dim('Service:')} ${theme.statusInactive('inactive')}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (pid || memory || cpu) {
|
||||
@@ -250,10 +271,11 @@ WantedBy=multi-user.target
|
||||
logger.log(` ${details.join(' ')}`);
|
||||
}
|
||||
logger.log('');
|
||||
|
||||
} catch (error) {
|
||||
logger.log('');
|
||||
logger.log(`${symbols.stopped} ${theme.dim('Service:')} ${theme.statusInactive('not installed')}`);
|
||||
logger.log(
|
||||
`${symbols.stopped} ${theme.dim('Service:')} ${theme.statusInactive('not installed')}`,
|
||||
);
|
||||
logger.log('');
|
||||
}
|
||||
}
|
||||
@@ -290,13 +312,13 @@ WantedBy=multi-user.target
|
||||
groups: [],
|
||||
actions: config.thresholds
|
||||
? [
|
||||
{
|
||||
type: 'shutdown',
|
||||
thresholds: config.thresholds,
|
||||
triggerMode: 'onlyThresholds',
|
||||
shutdownDelay: 5,
|
||||
},
|
||||
]
|
||||
{
|
||||
type: 'shutdown',
|
||||
thresholds: config.thresholds,
|
||||
triggerMode: 'onlyThresholds',
|
||||
shutdownDelay: 5,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
};
|
||||
|
||||
@@ -304,7 +326,9 @@ WantedBy=multi-user.target
|
||||
} else {
|
||||
logger.log('');
|
||||
logger.warn('No UPS devices configured');
|
||||
logger.log(` ${theme.dim('Run')} ${theme.command('nupst ups add')} ${theme.dim('to add a device')}`);
|
||||
logger.log(
|
||||
` ${theme.dim('Run')} ${theme.command('nupst ups add')} ${theme.dim('to add a device')}`,
|
||||
);
|
||||
logger.log('');
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -339,7 +363,9 @@ WantedBy=multi-user.target
|
||||
}
|
||||
|
||||
// Display UPS name and power status
|
||||
logger.log(` ${statusSymbol} ${theme.highlight(ups.name)} - ${formatPowerStatus(status.powerStatus)}`);
|
||||
logger.log(
|
||||
` ${statusSymbol} ${theme.highlight(ups.name)} - ${formatPowerStatus(status.powerStatus)}`,
|
||||
);
|
||||
|
||||
// Display battery with color coding
|
||||
const batteryColor = getBatteryColor(status.batteryCapacity);
|
||||
@@ -347,16 +373,27 @@ WantedBy=multi-user.target
|
||||
// Get threshold from actions (if any action has thresholds defined)
|
||||
const actionWithThresholds = ups.actions?.find((action) => action.thresholds);
|
||||
const batteryThreshold = actionWithThresholds?.thresholds?.battery;
|
||||
const batterySymbol = batteryThreshold !== undefined && status.batteryCapacity >= batteryThreshold
|
||||
? symbols.success
|
||||
: batteryThreshold !== undefined
|
||||
? symbols.warning
|
||||
: '';
|
||||
const batterySymbol =
|
||||
batteryThreshold !== undefined && status.batteryCapacity >= batteryThreshold
|
||||
? symbols.success
|
||||
: batteryThreshold !== undefined
|
||||
? symbols.warning
|
||||
: '';
|
||||
|
||||
logger.log(` Battery: ${batteryColor(status.batteryCapacity + '%')} ${batterySymbol} Runtime: ${getRuntimeColor(status.batteryRuntime)(status.batteryRuntime + ' min')}`);
|
||||
logger.log(
|
||||
` Battery: ${batteryColor(status.batteryCapacity + '%')} ${batterySymbol} Runtime: ${
|
||||
getRuntimeColor(status.batteryRuntime)(status.batteryRuntime + ' min')
|
||||
}`,
|
||||
);
|
||||
|
||||
// Display power metrics
|
||||
logger.log(` Load: ${theme.highlight(status.outputLoad + '%')} Power: ${theme.highlight(status.outputPower + 'W')} Voltage: ${theme.highlight(status.outputVoltage + 'V')} Current: ${theme.highlight(status.outputCurrent + 'A')}`);
|
||||
logger.log(
|
||||
` Load: ${theme.highlight(status.outputLoad + '%')} Power: ${
|
||||
theme.highlight(status.outputPower + 'W')
|
||||
} Voltage: ${theme.highlight(status.outputVoltage + 'V')} Current: ${
|
||||
theme.highlight(status.outputCurrent + 'A')
|
||||
}`,
|
||||
);
|
||||
|
||||
// Display host info
|
||||
logger.log(` ${theme.dim(`Host: ${ups.snmp.host}:${ups.snmp.port}`)}`);
|
||||
@@ -376,7 +413,9 @@ WantedBy=multi-user.target
|
||||
for (const action of ups.actions) {
|
||||
let actionDesc = `${action.type}`;
|
||||
if (action.thresholds) {
|
||||
actionDesc += ` (${action.triggerMode || 'onlyThresholds'}: battery<${action.thresholds.battery}%, runtime<${action.thresholds.runtime}min`;
|
||||
actionDesc += ` (${
|
||||
action.triggerMode || 'onlyThresholds'
|
||||
}: battery<${action.thresholds.battery}%, runtime<${action.thresholds.runtime}min`;
|
||||
if (action.shutdownDelay) {
|
||||
actionDesc += `, delay=${action.shutdownDelay}s`;
|
||||
}
|
||||
@@ -393,10 +432,11 @@ WantedBy=multi-user.target
|
||||
}
|
||||
|
||||
logger.log('');
|
||||
|
||||
} catch (error) {
|
||||
// Display error for this UPS
|
||||
logger.log(` ${symbols.error} ${theme.highlight(ups.name)} - ${theme.error('Connection failed')}`);
|
||||
logger.log(
|
||||
` ${symbols.error} ${theme.highlight(ups.name)} - ${theme.error('Connection failed')}`,
|
||||
);
|
||||
logger.log(` ${theme.dim(error instanceof Error ? error.message : String(error))}`);
|
||||
logger.log(` ${theme.dim(`Host: ${ups.snmp.host}:${ups.snmp.port}`)}`);
|
||||
logger.log('');
|
||||
@@ -421,7 +461,9 @@ WantedBy=multi-user.target
|
||||
// Display group name and mode
|
||||
const modeColor = group.mode === 'redundant' ? theme.success : theme.warning;
|
||||
logger.log(
|
||||
` ${symbols.info} ${theme.highlight(group.name)} ${theme.dim(`(${modeColor(group.mode)})`)}`,
|
||||
` ${symbols.info} ${theme.highlight(group.name)} ${
|
||||
theme.dim(`(${modeColor(group.mode)})`)
|
||||
}`,
|
||||
);
|
||||
|
||||
// Display description if present
|
||||
@@ -446,7 +488,9 @@ WantedBy=multi-user.target
|
||||
for (const action of group.actions) {
|
||||
let actionDesc = `${action.type}`;
|
||||
if (action.thresholds) {
|
||||
actionDesc += ` (${action.triggerMode || 'onlyThresholds'}: battery<${action.thresholds.battery}%, runtime<${action.thresholds.runtime}min`;
|
||||
actionDesc += ` (${
|
||||
action.triggerMode || 'onlyThresholds'
|
||||
}: battery<${action.thresholds.battery}%, runtime<${action.thresholds.runtime}min`;
|
||||
if (action.shutdownDelay) {
|
||||
actionDesc += `, delay=${action.shutdownDelay}s`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user