Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 782c8c9555 | |||
| 463c32ebba | |||
| 51aa68ff8d | |||
| cb34ae5041 | |||
| 165c7d29bb | |||
| ff2dc00f31 | |||
| fda072d15e | |||
| c7786e9626 | |||
| 91fe5f7ae6 | |||
| 07648b4880 | |||
| d0e3a4ae74 | |||
| 89ffd61717 | |||
| 60eadaf6a1 | |||
| bd52ba4cb2 | |||
| a3d6a8b75d | |||
| fbd71b1f3b | |||
| 6481572981 | |||
| 0dc14a6ea1 | |||
| dea344e6ba | |||
| f81f5957ab | |||
| 281d3fbbeb | |||
| c1cb136a7d | |||
| b80275a594 | |||
| b64a515c94 | |||
| 68c4eb6480 | |||
| 6c8f6ac33f | |||
| ffa491c7a1 | |||
| 777d48d82e | |||
| b7a0bbcf6d | |||
| fbe1cd64cb | |||
| 9ba50da73c | |||
| 684319983d | |||
| 18bd9f6cda | |||
| f03c683d02 |
37
.gitea/release-template.md
Normal file
37
.gitea/release-template.md
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
## NUPST {{VERSION}}
|
||||||
|
|
||||||
|
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)
|
||||||
|
- macOS ARM64 (Apple Silicon)
|
||||||
|
- 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.
|
||||||
@@ -21,7 +21,7 @@ jobs:
|
|||||||
- name: Set up Deno
|
- name: Set up Deno
|
||||||
uses: denoland/setup-deno@v1
|
uses: denoland/setup-deno@v1
|
||||||
with:
|
with:
|
||||||
deno-version: v1.x
|
deno-version: v2.x
|
||||||
|
|
||||||
- name: Check TypeScript types
|
- name: Check TypeScript types
|
||||||
run: deno check mod.ts
|
run: deno check mod.ts
|
||||||
@@ -45,7 +45,7 @@ jobs:
|
|||||||
- name: Set up Deno
|
- name: Set up Deno
|
||||||
uses: denoland/setup-deno@v1
|
uses: denoland/setup-deno@v1
|
||||||
with:
|
with:
|
||||||
deno-version: v1.x
|
deno-version: v2.x
|
||||||
|
|
||||||
- name: Compile for current platform
|
- name: Compile for current platform
|
||||||
run: |
|
run: |
|
||||||
@@ -71,7 +71,7 @@ jobs:
|
|||||||
- name: Set up Deno
|
- name: Set up Deno
|
||||||
uses: denoland/setup-deno@v1
|
uses: denoland/setup-deno@v1
|
||||||
with:
|
with:
|
||||||
deno-version: v1.x
|
deno-version: v2.x
|
||||||
|
|
||||||
- name: Compile all platform binaries
|
- name: Compile all platform binaries
|
||||||
run: bash scripts/compile-all.sh
|
run: bash scripts/compile-all.sh
|
||||||
|
|||||||
129
.gitea/workflows/npm-publish.yml
Normal file
129
.gitea/workflows/npm-publish.yml
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
name: Publish to npm
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
npm-publish:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Deno
|
||||||
|
uses: denoland/setup-deno@v1
|
||||||
|
with:
|
||||||
|
deno-version: v2.x
|
||||||
|
|
||||||
|
- name: Setup Node.js for npm publishing
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '18.x'
|
||||||
|
registry-url: 'https://registry.npmjs.org/'
|
||||||
|
|
||||||
|
- name: Get version from tag
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
VERSION=${GITHUB_REF#refs/tags/}
|
||||||
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
echo "version_number=${VERSION#v}" >> $GITHUB_OUTPUT
|
||||||
|
echo "Publishing version: $VERSION"
|
||||||
|
|
||||||
|
- name: Verify deno.json version matches tag
|
||||||
|
run: |
|
||||||
|
DENO_VERSION=$(grep -o '"version": "[^"]*"' deno.json | cut -d'"' -f4)
|
||||||
|
TAG_VERSION="${{ steps.version.outputs.version_number }}"
|
||||||
|
echo "deno.json version: $DENO_VERSION"
|
||||||
|
echo "Tag version: $TAG_VERSION"
|
||||||
|
if [ "$DENO_VERSION" != "$TAG_VERSION" ]; then
|
||||||
|
echo "ERROR: Version mismatch!"
|
||||||
|
echo "deno.json has version $DENO_VERSION but tag is $TAG_VERSION"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Compile binaries for npm package
|
||||||
|
run: |
|
||||||
|
echo "Compiling binaries for npm package..."
|
||||||
|
deno task compile
|
||||||
|
echo ""
|
||||||
|
echo "Binary sizes:"
|
||||||
|
ls -lh dist/binaries/
|
||||||
|
|
||||||
|
- name: Generate SHA256 checksums
|
||||||
|
run: |
|
||||||
|
cd dist/binaries
|
||||||
|
sha256sum * > SHA256SUMS
|
||||||
|
cat SHA256SUMS
|
||||||
|
cd ../..
|
||||||
|
|
||||||
|
- name: Sync package.json version
|
||||||
|
run: |
|
||||||
|
VERSION="${{ steps.version.outputs.version_number }}"
|
||||||
|
echo "Syncing package.json to version ${VERSION}..."
|
||||||
|
npm version ${VERSION} --no-git-tag-version --allow-same-version
|
||||||
|
echo "package.json version: $(grep '"version"' package.json | head -1)"
|
||||||
|
|
||||||
|
- name: Create npm package
|
||||||
|
run: |
|
||||||
|
echo "Creating npm package..."
|
||||||
|
npm pack
|
||||||
|
echo ""
|
||||||
|
echo "Package created:"
|
||||||
|
ls -lh *.tgz
|
||||||
|
|
||||||
|
- name: Test local installation
|
||||||
|
run: |
|
||||||
|
echo "Testing local package installation..."
|
||||||
|
PACKAGE_FILE=$(ls *.tgz)
|
||||||
|
npm install -g ${PACKAGE_FILE}
|
||||||
|
echo ""
|
||||||
|
echo "Testing nupst command:"
|
||||||
|
nupst --version || echo "Note: Binary execution may fail in CI environment"
|
||||||
|
echo ""
|
||||||
|
echo "Checking installed files:"
|
||||||
|
npm ls -g @serve.zone/nupst || true
|
||||||
|
|
||||||
|
- name: Publish to npm
|
||||||
|
env:
|
||||||
|
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
|
run: |
|
||||||
|
echo "Publishing to npm registry..."
|
||||||
|
npm publish --access public
|
||||||
|
echo ""
|
||||||
|
echo "✅ Successfully published @serve.zone/nupst to npm!"
|
||||||
|
echo ""
|
||||||
|
echo "Package info:"
|
||||||
|
npm view @serve.zone/nupst
|
||||||
|
|
||||||
|
- name: Verify npm package
|
||||||
|
run: |
|
||||||
|
echo "Waiting for npm propagation..."
|
||||||
|
sleep 30
|
||||||
|
echo ""
|
||||||
|
echo "Verifying published package..."
|
||||||
|
npm view @serve.zone/nupst
|
||||||
|
echo ""
|
||||||
|
echo "Testing installation from npm:"
|
||||||
|
npm install -g @serve.zone/nupst
|
||||||
|
echo ""
|
||||||
|
echo "Package installed successfully!"
|
||||||
|
which nupst || echo "Binary location check skipped"
|
||||||
|
|
||||||
|
- name: Publish Summary
|
||||||
|
run: |
|
||||||
|
echo "================================================"
|
||||||
|
echo " npm Publish Complete!"
|
||||||
|
echo "================================================"
|
||||||
|
echo ""
|
||||||
|
echo "✅ Package: @serve.zone/nupst"
|
||||||
|
echo "✅ Version: ${{ steps.version.outputs.version }}"
|
||||||
|
echo ""
|
||||||
|
echo "Installation:"
|
||||||
|
echo " npm install -g @serve.zone/nupst"
|
||||||
|
echo ""
|
||||||
|
echo "Registry:"
|
||||||
|
echo " https://www.npmjs.com/package/@serve.zone/nupst"
|
||||||
|
echo ""
|
||||||
@@ -18,7 +18,7 @@ jobs:
|
|||||||
- name: Set up Deno
|
- name: Set up Deno
|
||||||
uses: denoland/setup-deno@v1
|
uses: denoland/setup-deno@v1
|
||||||
with:
|
with:
|
||||||
deno-version: v1.x
|
deno-version: v2.x
|
||||||
|
|
||||||
- name: Get version from tag
|
- name: Get version from tag
|
||||||
id: version
|
id: version
|
||||||
|
|||||||
54
.npmignore
Normal file
54
.npmignore
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# Source code (not needed for binary distribution)
|
||||||
|
/ts/
|
||||||
|
/test/
|
||||||
|
mod.ts
|
||||||
|
*.ts
|
||||||
|
|
||||||
|
# Development files
|
||||||
|
.git/
|
||||||
|
.gitea/
|
||||||
|
.claude/
|
||||||
|
.serena/
|
||||||
|
.nogit/
|
||||||
|
.github/
|
||||||
|
deno.json
|
||||||
|
deno.lock
|
||||||
|
tsconfig.json
|
||||||
|
|
||||||
|
# Scripts not needed for npm
|
||||||
|
/scripts/compile-all.sh
|
||||||
|
install.sh
|
||||||
|
uninstall.sh
|
||||||
|
example-action.sh
|
||||||
|
|
||||||
|
# Documentation files not needed for npm package
|
||||||
|
readme.plan.md
|
||||||
|
readme.hints.md
|
||||||
|
npm-publish-instructions.md
|
||||||
|
docs/
|
||||||
|
|
||||||
|
# IDE and editor files
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Keep only the install-binary.js in scripts/
|
||||||
|
/scripts/*
|
||||||
|
!/scripts/install-binary.js
|
||||||
|
|
||||||
|
# Exclude all dist directory (binaries will be downloaded during install)
|
||||||
|
/dist/
|
||||||
|
|
||||||
|
# Logs and temporary files
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Other
|
||||||
|
node_modules/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
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'
|
|
||||||
109
bin/nupst-wrapper.js
Normal file
109
bin/nupst-wrapper.js
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NUPST npm wrapper
|
||||||
|
* This script executes the appropriate pre-compiled binary based on the current platform
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawn } from 'child_process';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { dirname, join } from 'path';
|
||||||
|
import { existsSync } from 'fs';
|
||||||
|
import { arch, platform } from 'os';
|
||||||
|
import process from "node:process";
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the binary name for the current platform
|
||||||
|
*/
|
||||||
|
function getBinaryName() {
|
||||||
|
const plat = platform();
|
||||||
|
const architecture = arch();
|
||||||
|
|
||||||
|
// Map Node's platform/arch to our binary naming
|
||||||
|
const platformMap = {
|
||||||
|
'darwin': 'macos',
|
||||||
|
'linux': 'linux',
|
||||||
|
'win32': 'windows',
|
||||||
|
};
|
||||||
|
|
||||||
|
const archMap = {
|
||||||
|
'x64': 'x64',
|
||||||
|
'arm64': 'arm64',
|
||||||
|
};
|
||||||
|
|
||||||
|
const mappedPlatform = platformMap[plat];
|
||||||
|
const mappedArch = archMap[architecture];
|
||||||
|
|
||||||
|
if (!mappedPlatform || !mappedArch) {
|
||||||
|
console.error(`Error: Unsupported platform/architecture: ${plat}/${architecture}`);
|
||||||
|
console.error('Supported platforms: Linux, macOS, Windows');
|
||||||
|
console.error('Supported architectures: x64, arm64');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct binary name
|
||||||
|
let binaryName = `nupst-${mappedPlatform}-${mappedArch}`;
|
||||||
|
if (plat === 'win32') {
|
||||||
|
binaryName += '.exe';
|
||||||
|
}
|
||||||
|
|
||||||
|
return binaryName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the binary
|
||||||
|
*/
|
||||||
|
function executeBinary() {
|
||||||
|
const binaryName = getBinaryName();
|
||||||
|
const binaryPath = join(__dirname, '..', 'dist', 'binaries', binaryName);
|
||||||
|
|
||||||
|
// Check if binary exists
|
||||||
|
if (!existsSync(binaryPath)) {
|
||||||
|
console.error(`Error: Binary not found at ${binaryPath}`);
|
||||||
|
console.error('This might happen if:');
|
||||||
|
console.error('1. The postinstall script failed to run');
|
||||||
|
console.error('2. The platform is not supported');
|
||||||
|
console.error('3. The package was not installed correctly');
|
||||||
|
console.error('');
|
||||||
|
console.error('Try reinstalling the package:');
|
||||||
|
console.error(' npm uninstall -g @serve.zone/nupst');
|
||||||
|
console.error(' npm install -g @serve.zone/nupst');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spawn the binary with all arguments passed through
|
||||||
|
const child = spawn(binaryPath, process.argv.slice(2), {
|
||||||
|
stdio: 'inherit',
|
||||||
|
shell: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle child process events
|
||||||
|
child.on('error', (err) => {
|
||||||
|
console.error(`Error executing nupst: ${err.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('exit', (code, signal) => {
|
||||||
|
if (signal) {
|
||||||
|
process.kill(process.pid, signal);
|
||||||
|
} else {
|
||||||
|
process.exit(code || 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Forward signals to child process
|
||||||
|
const signals = ['SIGINT', 'SIGTERM', 'SIGHUP'];
|
||||||
|
signals.forEach((signal) => {
|
||||||
|
process.on(signal, () => {
|
||||||
|
if (!child.killed) {
|
||||||
|
child.kill(signal);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute
|
||||||
|
executeBinary();
|
||||||
128
changelog.md
128
changelog.md
@@ -1,5 +1,133 @@
|
|||||||
# Changelog
|
# 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)
|
||||||
|
|
||||||
|
## 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)
|
||||||
|
- 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 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 generated npm package artifact (serve.zone-nupst-5.0.5.tgz) and npmextra.json
|
||||||
|
|
||||||
## 2025-10-18 - 4.0.0 - BREAKING CHANGE(core): Complete migration to Deno runtime
|
## 2025-10-18 - 4.0.0 - BREAKING CHANGE(core): Complete migration to Deno runtime
|
||||||
|
|
||||||
**MAJOR RELEASE: NUPST v4.0 is a complete rewrite powered by Deno**
|
**MAJOR RELEASE: NUPST v4.0 is a complete rewrite powered by Deno**
|
||||||
|
|||||||
11
deno.json
11
deno.json
@@ -1,7 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "@serve.zone/nupst",
|
"name": "@serve.zone/nupst",
|
||||||
"version": "5.0.3",
|
"version": "5.2.4",
|
||||||
"exports": "./mod.ts",
|
"exports": "./mod.ts",
|
||||||
|
"nodeModulesDir": "auto",
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"dev": "deno run --allow-all mod.ts",
|
"dev": "deno run --allow-all mod.ts",
|
||||||
"compile": "deno task compile:all",
|
"compile": "deno task compile:all",
|
||||||
@@ -14,7 +15,9 @@
|
|||||||
},
|
},
|
||||||
"lint": {
|
"lint": {
|
||||||
"rules": {
|
"rules": {
|
||||||
"tags": ["recommended"]
|
"tags": [
|
||||||
|
"recommended"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"fmt": {
|
"fmt": {
|
||||||
@@ -25,7 +28,9 @@
|
|||||||
"singleQuote": true
|
"singleQuote": true
|
||||||
},
|
},
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"lib": ["deno.window"],
|
"lib": [
|
||||||
|
"deno.window"
|
||||||
|
],
|
||||||
"strict": true
|
"strict": true
|
||||||
},
|
},
|
||||||
"imports": {
|
"imports": {
|
||||||
|
|||||||
13
install.sh
13
install.sh
@@ -216,9 +216,20 @@ fi
|
|||||||
BINARY_PATH="$INSTALL_DIR/nupst"
|
BINARY_PATH="$INSTALL_DIR/nupst"
|
||||||
mv "$TEMP_FILE" "$BINARY_PATH"
|
mv "$TEMP_FILE" "$BINARY_PATH"
|
||||||
|
|
||||||
|
if [ $? -ne 0 ] || [ ! -f "$BINARY_PATH" ]; then
|
||||||
|
echo "Error: Failed to move binary to $BINARY_PATH"
|
||||||
|
rm -f "$TEMP_FILE" 2>/dev/null
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
# Make executable
|
# Make executable
|
||||||
chmod +x "$BINARY_PATH"
|
chmod +x "$BINARY_PATH"
|
||||||
|
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "Error: Failed to make binary executable"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
echo "Binary installed successfully to: $BINARY_PATH"
|
echo "Binary installed successfully to: $BINARY_PATH"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
@@ -238,7 +249,7 @@ echo ""
|
|||||||
# Restart service if it was running before update
|
# Restart service if it was running before update
|
||||||
if [ $SERVICE_WAS_RUNNING -eq 1 ]; then
|
if [ $SERVICE_WAS_RUNNING -eq 1 ]; then
|
||||||
echo "Restarting NUPST service..."
|
echo "Restarting NUPST service..."
|
||||||
systemctl start nupst
|
systemctl restart nupst
|
||||||
echo "Service restarted successfully."
|
echo "Service restarted successfully."
|
||||||
echo ""
|
echo ""
|
||||||
fi
|
fi
|
||||||
|
|||||||
20
npmextra.json
Normal file
20
npmextra.json
Normal file
@@ -0,0 +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": {}
|
||||||
|
}
|
||||||
64
package.json
Normal file
64
package.json
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
{
|
||||||
|
"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",
|
||||||
|
"keywords": [
|
||||||
|
"ups",
|
||||||
|
"snmp",
|
||||||
|
"power",
|
||||||
|
"shutdown",
|
||||||
|
"monitoring",
|
||||||
|
"cyberpower",
|
||||||
|
"apc",
|
||||||
|
"eaton",
|
||||||
|
"tripplite",
|
||||||
|
"liebert",
|
||||||
|
"vertiv",
|
||||||
|
"battery",
|
||||||
|
"backup"
|
||||||
|
],
|
||||||
|
"homepage": "https://code.foss.global/serve.zone/nupst",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://code.foss.global/serve.zone/nupst/issues"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://code.foss.global/serve.zone/nupst.git"
|
||||||
|
},
|
||||||
|
"author": "Serve Zone",
|
||||||
|
"license": "MIT",
|
||||||
|
"type": "module",
|
||||||
|
"bin": {
|
||||||
|
"nupst": "./bin/nupst-wrapper.js"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"postinstall": "node scripts/install-binary.js",
|
||||||
|
"prepublishOnly": "echo 'Publishing NUPST binaries to npm...'",
|
||||||
|
"test": "echo 'Tests are run with Deno: deno task test'",
|
||||||
|
"build": "echo 'no build needed'"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"bin/",
|
||||||
|
"scripts/install-binary.js",
|
||||||
|
"readme.md",
|
||||||
|
"license",
|
||||||
|
"changelog.md"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
},
|
||||||
|
"os": [
|
||||||
|
"darwin",
|
||||||
|
"linux",
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"cpu": [
|
||||||
|
"x64",
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public",
|
||||||
|
"registry": "https://registry.npmjs.org/"
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@10.18.1+sha512.77a884a165cbba2d8d1c19e3b4880eee6d2fcabd0d879121e282196b80042351d5eb3ca0935fa599da1dc51265cc68816ad2bddd2a2de5ea9fdf92adbec7cd34"
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
```
|
||||||
|
|||||||
432
readme.md
432
readme.md
@@ -1,8 +1,20 @@
|
|||||||
# ⚡ NUPST - Network UPS Shutdown Tool
|
# ⚡ NUPST - Network UPS Shutdown Tool
|
||||||
|
|
||||||
**Keep your systems safe when the power goes out.** NUPST is a lightweight, battle-tested command-line tool that monitors SNMP-enabled UPS devices and orchestrates graceful system shutdowns during power emergencies. Distributed as self-contained binaries with zero runtime dependencies for maximum reliability.
|
**Keep your systems safe when the power goes out.** NUPST is a lightweight, battle-tested
|
||||||
|
command-line tool that monitors SNMP-enabled UPS devices and orchestrates graceful system shutdowns
|
||||||
|
during power emergencies. Distributed as self-contained binaries with zero runtime dependencies for
|
||||||
|
maximum reliability.
|
||||||
|
|
||||||
**Version 5.0+** is powered by Deno and distributed as single pre-compiled binaries—no installation, no setup, just run.
|
**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
|
## ✨ Features
|
||||||
|
|
||||||
@@ -14,13 +26,21 @@
|
|||||||
- Battery threshold triggers
|
- Battery threshold triggers
|
||||||
- Runtime threshold triggers
|
- Runtime threshold triggers
|
||||||
- Power status change triggers
|
- Power status change triggers
|
||||||
|
- Webhook notifications
|
||||||
|
- Custom shell scripts
|
||||||
- Configurable shutdown delays
|
- Configurable shutdown delays
|
||||||
- **🌐 Universal SNMP Support**: Full support for SNMP v1, v2c, and v3 with authentication and encryption
|
- **🌐 Universal SNMP Support**: Full support for SNMP v1, v2c, and v3 with authentication and
|
||||||
- **🏭 Multiple UPS Brands**: Works with CyberPower, APC, Eaton, TrippLite, Liebert/Vertiv, and custom OID configurations
|
encryption
|
||||||
|
- **🏭 Multiple UPS Brands**: Works with CyberPower, APC, Eaton, TrippLite, Liebert/Vertiv, and
|
||||||
|
custom OID configurations
|
||||||
- **🚀 Systemd Integration**: Simple service installation and management
|
- **🚀 Systemd Integration**: Simple service installation and management
|
||||||
- **📊 Real-time Monitoring**: Live status updates with detailed action and group information
|
- **📊 Real-time Monitoring**: Live status updates with detailed action and group information
|
||||||
- **📦 Self-Contained Binary**: Single executable with zero runtime dependencies—just download and run
|
- **🌐 HTTP API**: Optional HTTP server for JSON status export with authentication
|
||||||
- **🖥️ Cross-Platform**: Binaries available for Linux (x64, ARM64), macOS (Intel, Apple Silicon), and Windows
|
- **⚡ Power Metrics**: Monitor output load, power (watts), voltage, and current for all UPS devices
|
||||||
|
- **📦 Self-Contained Binary**: Single executable with zero runtime dependencies—just download and
|
||||||
|
run
|
||||||
|
- **🖥️ Cross-Platform**: Binaries available for Linux (x64, ARM64), macOS (Intel, Apple Silicon),
|
||||||
|
and Windows
|
||||||
|
|
||||||
## 🚀 Quick Start
|
## 🚀 Quick Start
|
||||||
|
|
||||||
@@ -52,7 +72,7 @@ nupst service status
|
|||||||
|
|
||||||
## 📥 Installation
|
## 📥 Installation
|
||||||
|
|
||||||
### Automated Installer (Recommended)
|
### Automated Installer Script (Recommended)
|
||||||
|
|
||||||
The installer script handles everything automatically:
|
The installer script handles everything automatically:
|
||||||
|
|
||||||
@@ -61,6 +81,7 @@ curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh |
|
|||||||
```
|
```
|
||||||
|
|
||||||
**What it does:**
|
**What it does:**
|
||||||
|
|
||||||
1. Detects your platform (OS and architecture)
|
1. Detects your platform (OS and architecture)
|
||||||
2. Downloads the latest pre-compiled binary
|
2. Downloads the latest pre-compiled binary
|
||||||
3. Installs to `/opt/nupst/nupst`
|
3. Installs to `/opt/nupst/nupst`
|
||||||
@@ -84,15 +105,16 @@ curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh |
|
|||||||
|
|
||||||
### Manual Installation
|
### Manual Installation
|
||||||
|
|
||||||
Download the appropriate binary for your platform from [releases](https://code.foss.global/serve.zone/nupst/releases):
|
Download the appropriate binary for your platform from
|
||||||
|
[releases](https://code.foss.global/serve.zone/nupst/releases):
|
||||||
|
|
||||||
| Platform | Binary |
|
| Platform | Binary |
|
||||||
|----------|--------|
|
| ------------------- | ----------------------- |
|
||||||
| Linux x64 | `nupst-linux-x64` |
|
| Linux x64 | `nupst-linux-x64` |
|
||||||
| Linux ARM64 | `nupst-linux-arm64` |
|
| Linux ARM64 | `nupst-linux-arm64` |
|
||||||
| macOS Intel | `nupst-macos-x64` |
|
| macOS Intel | `nupst-macos-x64` |
|
||||||
| macOS Apple Silicon | `nupst-macos-arm64` |
|
| macOS Apple Silicon | `nupst-macos-arm64` |
|
||||||
| Windows x64 | `nupst-windows-x64.exe` |
|
| Windows x64 | `nupst-windows-x64.exe` |
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Download binary (replace with your platform)
|
# Download binary (replace with your platform)
|
||||||
@@ -105,6 +127,17 @@ chmod +x nupst
|
|||||||
sudo mv nupst /usr/local/bin/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
|
### Verify Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -156,9 +189,10 @@ nupst group remove <id> # Remove a group
|
|||||||
nupst group list # List all groups
|
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.
|
Actions define what happens when UPS conditions are met. Actions can be attached to individual UPS
|
||||||
|
devices or to groups.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Add an action to a UPS device or group
|
# Add an action to a UPS device or group
|
||||||
@@ -174,6 +208,14 @@ nupst action list
|
|||||||
nupst action list <ups-id|group-id>
|
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**
|
**Example: Adding an action**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -198,6 +240,78 @@ Add Action to UPS Main Server UPS
|
|||||||
Changes saved and will be applied automatically
|
Changes saved and will be applied automatically
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Feature Management
|
||||||
|
|
||||||
|
Optional features like the HTTP server for JSON status export:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Configure HTTP server feature (interactive)
|
||||||
|
nupst feature httpServer
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example: Enabling HTTP Server**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ sudo nupst feature httpServer
|
||||||
|
|
||||||
|
HTTP Server Feature Configuration
|
||||||
|
Configure the HTTP server to expose UPS status as JSON
|
||||||
|
|
||||||
|
HTTP Server is currently: DISABLED
|
||||||
|
|
||||||
|
Enable or disable HTTP server? (enable/disable/cancel): enable
|
||||||
|
|
||||||
|
HTTP Server Port [8080]: 8080
|
||||||
|
URL Path [/ups-status]: /ups-status
|
||||||
|
Generated new authentication token
|
||||||
|
|
||||||
|
✓ HTTP Server Configuration
|
||||||
|
|
||||||
|
Status: ENABLED
|
||||||
|
Port: 8080
|
||||||
|
Path: /ups-status
|
||||||
|
Auth Token: abc123xyz789def456
|
||||||
|
|
||||||
|
Usage examples:
|
||||||
|
curl -H "Authorization: Bearer abc123xyz789def456" http://localhost:8080/ups-status
|
||||||
|
curl "http://localhost:8080/ups-status?token=abc123xyz789def456"
|
||||||
|
|
||||||
|
⚠ IMPORTANT: Save the authentication token securely!
|
||||||
|
|
||||||
|
Service is running. Restart to apply changes? (Y/n): Y
|
||||||
|
```
|
||||||
|
|
||||||
|
**Query UPS Status via HTTP:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Using Bearer token in header
|
||||||
|
curl -H "Authorization: Bearer abc123xyz789def456" \
|
||||||
|
http://localhost:8080/ups-status
|
||||||
|
|
||||||
|
# Using token as query parameter
|
||||||
|
curl "http://localhost:8080/ups-status?token=abc123xyz789def456"
|
||||||
|
```
|
||||||
|
|
||||||
|
**JSON Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "ups-main",
|
||||||
|
"name": "Main Server UPS",
|
||||||
|
"powerStatus": "online",
|
||||||
|
"batteryCapacity": 100,
|
||||||
|
"batteryRuntime": 45,
|
||||||
|
"outputLoad": 23,
|
||||||
|
"outputPower": 115,
|
||||||
|
"outputVoltage": 230.5,
|
||||||
|
"outputCurrent": 0.5,
|
||||||
|
"lastStatusChange": 1729685123456,
|
||||||
|
"lastCheckTime": 1729685153456
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -214,14 +328,21 @@ nupst config show # Display current configuration
|
|||||||
|
|
||||||
## ⚙️ Configuration
|
## ⚙️ 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.
|
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
|
```json
|
||||||
{
|
{
|
||||||
"version": "4.1",
|
"version": "4.2",
|
||||||
"checkInterval": 30000,
|
"checkInterval": 30000,
|
||||||
|
"httpServer": {
|
||||||
|
"enabled": true,
|
||||||
|
"port": 8080,
|
||||||
|
"path": "/ups-status",
|
||||||
|
"authToken": "abc123xyz789def456"
|
||||||
|
},
|
||||||
"upsDevices": [
|
"upsDevices": [
|
||||||
{
|
{
|
||||||
"id": "ups-main",
|
"id": "ups-main",
|
||||||
@@ -298,8 +419,9 @@ NUPST stores configuration at `/etc/nupst/config.json`. The easiest way to confi
|
|||||||
|
|
||||||
#### Global Settings
|
#### Global Settings
|
||||||
|
|
||||||
- **`version`**: Config format version (current: "4.1")
|
- **`version`**: Config format version (current: "4.2")
|
||||||
- **`checkInterval`**: Polling interval in milliseconds (default: 30000)
|
- **`checkInterval`**: Polling interval in milliseconds (default: 30000)
|
||||||
|
- **`httpServer`**: Optional HTTP server configuration (see HTTP Server Configuration below)
|
||||||
|
|
||||||
#### UPS Device Settings
|
#### UPS Device Settings
|
||||||
|
|
||||||
@@ -310,25 +432,25 @@ NUPST stores configuration at `/etc/nupst/config.json`. The easiest way to confi
|
|||||||
|
|
||||||
**SNMP Configuration:**
|
**SNMP Configuration:**
|
||||||
|
|
||||||
| Field | Description | Values |
|
| Field | Description | Values |
|
||||||
|-------|-------------|--------|
|
| ----------- | ----------------------- | -------------------------------------------------------------- |
|
||||||
| `host` | IP address or hostname | e.g., "192.168.1.100" |
|
| `host` | IP address or hostname | e.g., "192.168.1.100" |
|
||||||
| `port` | SNMP port | Default: 161 |
|
| `port` | SNMP port | Default: 161 |
|
||||||
| `version` | SNMP version | 1, 2, or 3 |
|
| `version` | SNMP version | 1, 2, or 3 |
|
||||||
| `timeout` | Timeout in milliseconds | Default: 5000 |
|
| `timeout` | Timeout in milliseconds | Default: 5000 |
|
||||||
| `upsModel` | UPS brand/model | 'cyberpower', 'apc', 'eaton', 'tripplite', 'liebert', 'custom' |
|
| `upsModel` | UPS brand/model | 'cyberpower', 'apc', 'eaton', 'tripplite', 'liebert', 'custom' |
|
||||||
| `community` | SNMP community (v1/v2c) | Default: "public" |
|
| `community` | SNMP community (v1/v2c) | Default: "public" |
|
||||||
|
|
||||||
**SNMPv3 Security:**
|
**SNMPv3 Security:**
|
||||||
|
|
||||||
| Field | Description |
|
| Field | Description |
|
||||||
|-------|-------------|
|
| --------------- | ------------------------------------------- |
|
||||||
| `securityLevel` | 'noAuthNoPriv', 'authNoPriv', or 'authPriv' |
|
| `securityLevel` | 'noAuthNoPriv', 'authNoPriv', or 'authPriv' |
|
||||||
| `username` | SNMPv3 username |
|
| `username` | SNMPv3 username |
|
||||||
| `authProtocol` | 'MD5' or 'SHA' |
|
| `authProtocol` | 'MD5' or 'SHA' |
|
||||||
| `authKey` | Authentication password |
|
| `authKey` | Authentication password |
|
||||||
| `privProtocol` | 'DES' or 'AES' (for authPriv) |
|
| `privProtocol` | 'DES' or 'AES' (for authPriv) |
|
||||||
| `privKey` | Privacy/encryption password |
|
| `privKey` | Privacy/encryption password |
|
||||||
|
|
||||||
#### Action Configuration
|
#### Action Configuration
|
||||||
|
|
||||||
@@ -348,21 +470,36 @@ Actions define automated responses to UPS conditions:
|
|||||||
|
|
||||||
**Action Fields:**
|
**Action Fields:**
|
||||||
|
|
||||||
| Field | Description | Values |
|
| 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 }` |
|
| `thresholds` | Battery and runtime limits | `{ battery: 0-100, runtime: minutes }` |
|
||||||
| `triggerMode` | When to trigger action | See Trigger Modes below |
|
| `triggerMode` | When to trigger action | See Trigger Modes below |
|
||||||
| `shutdownDelay` | Delay before executing (seconds) | Default: 5 |
|
| `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:**
|
**Trigger Modes:**
|
||||||
|
|
||||||
| Mode | Description |
|
| Mode | Description |
|
||||||
|------|-------------|
|
| --------------------------- | -------------------------------------------------------------------------- |
|
||||||
| `onlyPowerChanges` | Trigger only when power status changes (on battery → online or vice versa) |
|
| `onlyPowerChanges` | Trigger only when power status changes (on battery → online or vice versa) |
|
||||||
| `onlyThresholds` | Trigger only when battery or runtime thresholds are violated |
|
| `onlyThresholds` | Trigger only when battery or runtime thresholds are violated |
|
||||||
| `powerChangesAndThresholds` | Trigger only when power changes AND thresholds are violated |
|
| `powerChangesAndThresholds` | Trigger only when power changes AND thresholds are violated |
|
||||||
| `anyChange` | Trigger on any status change |
|
| `anyChange` | Trigger on any status change |
|
||||||
|
|
||||||
#### Group Settings
|
#### Group Settings
|
||||||
|
|
||||||
@@ -380,8 +517,53 @@ Groups allow coordinated management of multiple UPS devices:
|
|||||||
|
|
||||||
**Group Modes:**
|
**Group Modes:**
|
||||||
|
|
||||||
- **`redundant`**: System shuts down only when ALL UPS devices in the group are critical. Perfect for setups with backup UPS units.
|
- **`redundant`**: System shuts down only when ALL UPS devices in the group are critical. Perfect
|
||||||
- **`nonRedundant`**: System shuts down when ANY UPS device in the group is critical. Used when all UPS devices must be operational.
|
for setups with backup UPS units.
|
||||||
|
- **`nonRedundant`**: System shuts down when ANY UPS device in the group is critical. Used when all
|
||||||
|
UPS devices must be operational.
|
||||||
|
|
||||||
|
#### HTTP Server Configuration
|
||||||
|
|
||||||
|
Enable optional HTTP server for JSON status export with authentication:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"enabled": true,
|
||||||
|
"port": 8080,
|
||||||
|
"path": "/ups-status",
|
||||||
|
"authToken": "abc123xyz789def456"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**HTTP Server Fields:**
|
||||||
|
|
||||||
|
| Field | Description | Default |
|
||||||
|
| ----------- | ------------------------------------------------ | -------------- |
|
||||||
|
| `enabled` | Whether HTTP server is enabled | `false` |
|
||||||
|
| `port` | TCP port for HTTP server | `8080` |
|
||||||
|
| `path` | URL path for status endpoint | `/ups-status` |
|
||||||
|
| `authToken` | Authentication token (required for all requests) | Auto-generated |
|
||||||
|
|
||||||
|
**Authentication Methods:**
|
||||||
|
|
||||||
|
The HTTP server supports two authentication methods:
|
||||||
|
|
||||||
|
1. **Bearer Token** (Header): `Authorization: Bearer <token>`
|
||||||
|
2. **Query Parameter**: `?token=<token>`
|
||||||
|
|
||||||
|
**Security Features:**
|
||||||
|
|
||||||
|
- Token-based authentication required for all requests
|
||||||
|
- Returns 401 Unauthorized for invalid/missing tokens
|
||||||
|
- Serves cached data from monitoring loop (no extra SNMP queries)
|
||||||
|
- No CORS headers (local network only)
|
||||||
|
|
||||||
|
**Use Cases:**
|
||||||
|
|
||||||
|
- Integration with monitoring systems (Prometheus, Grafana, etc.)
|
||||||
|
- Custom dashboards and visualizations
|
||||||
|
- Mobile apps and web interfaces
|
||||||
|
- Home automation systems
|
||||||
|
|
||||||
### Supported UPS Models
|
### Supported UPS Models
|
||||||
|
|
||||||
@@ -408,7 +590,8 @@ For custom UPS models, specify `customOIDs`:
|
|||||||
|
|
||||||
### Status Display
|
### Status Display
|
||||||
|
|
||||||
The status command shows comprehensive information about your UPS devices, groups, and configured actions:
|
The status command shows comprehensive information about your UPS devices, groups, and configured
|
||||||
|
actions:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
$ nupst service status
|
$ nupst service status
|
||||||
@@ -440,6 +623,7 @@ nupst service logs
|
|||||||
```
|
```
|
||||||
|
|
||||||
Example output:
|
Example output:
|
||||||
|
|
||||||
```
|
```
|
||||||
[2025-01-15 10:30:15] ℹ NUPST daemon started
|
[2025-01-15 10:30:15] ℹ NUPST daemon started
|
||||||
[2025-01-15 10:30:15] ✓ Connected to Main Server UPS (192.168.1.100)
|
[2025-01-15 10:30:15] ✓ Connected to Main Server UPS (192.168.1.100)
|
||||||
@@ -464,11 +648,11 @@ NUPST is designed with security as a priority:
|
|||||||
|
|
||||||
Full SNMPv3 support with authentication and encryption:
|
Full SNMPv3 support with authentication and encryption:
|
||||||
|
|
||||||
| Security Level | Description |
|
| Security Level | Description |
|
||||||
|----------------|-------------|
|
| -------------- | ------------------------------------------------------ |
|
||||||
| `noAuthNoPriv` | No authentication, no encryption (not recommended) |
|
| `noAuthNoPriv` | No authentication, no encryption (not recommended) |
|
||||||
| `authNoPriv` | MD5/SHA authentication without encryption |
|
| `authNoPriv` | MD5/SHA authentication without encryption |
|
||||||
| `authPriv` | Full authentication + DES/AES encryption (recommended) |
|
| `authPriv` | Full authentication + DES/AES encryption (recommended) |
|
||||||
|
|
||||||
**Example SNMPv3 Configuration:**
|
**Example SNMPv3 Configuration:**
|
||||||
|
|
||||||
@@ -489,6 +673,12 @@ Full SNMPv3 support with authentication and encryption:
|
|||||||
- **Local-Only Communication**: Only connects to UPS devices on local network
|
- **Local-Only Communication**: Only connects to UPS devices on local network
|
||||||
- **No Telemetry**: No data sent to external servers
|
- **No Telemetry**: No data sent to external servers
|
||||||
- **No Auto-Updates**: Manual update process only
|
- **No Auto-Updates**: Manual update process only
|
||||||
|
- **HTTP Server** (optional):
|
||||||
|
- Disabled by default
|
||||||
|
- Token-based authentication required
|
||||||
|
- Local network access only (no CORS)
|
||||||
|
- Serves cached data (no additional SNMP queries)
|
||||||
|
- Configurable port and path
|
||||||
|
|
||||||
### Verifying Downloads
|
### Verifying Downloads
|
||||||
|
|
||||||
@@ -514,6 +704,7 @@ curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh |
|
|||||||
```
|
```
|
||||||
|
|
||||||
The installer will:
|
The installer will:
|
||||||
|
|
||||||
- Download the latest binary
|
- Download the latest binary
|
||||||
- Replace the existing installation
|
- Replace the existing installation
|
||||||
- Preserve your configuration
|
- Preserve your configuration
|
||||||
@@ -540,7 +731,8 @@ sudo nupst service start
|
|||||||
nupst --version
|
nupst --version
|
||||||
```
|
```
|
||||||
|
|
||||||
Visit the [releases page](https://code.foss.global/serve.zone/nupst/releases) for the latest version.
|
Visit the [releases page](https://code.foss.global/serve.zone/nupst/releases) for the latest
|
||||||
|
version.
|
||||||
|
|
||||||
## 🗑️ Uninstallation
|
## 🗑️ Uninstallation
|
||||||
|
|
||||||
@@ -638,11 +830,11 @@ When installed, NUPST makes the following changes:
|
|||||||
|
|
||||||
### File System
|
### File System
|
||||||
|
|
||||||
| Path | Description |
|
| Path | Description |
|
||||||
|------|-------------|
|
| ----------------------------------- | -------------------- |
|
||||||
| `/opt/nupst/nupst` | Pre-compiled binary |
|
| `/opt/nupst/nupst` | Pre-compiled binary |
|
||||||
| `/usr/local/bin/nupst` | Symlink to binary |
|
| `/usr/local/bin/nupst` | Symlink to binary |
|
||||||
| `/etc/nupst/config.json` | Configuration file |
|
| `/etc/nupst/config.json` | Configuration file |
|
||||||
| `/etc/systemd/system/nupst.service` | Systemd service unit |
|
| `/etc/systemd/system/nupst.service` | Systemd service unit |
|
||||||
|
|
||||||
### Services
|
### Services
|
||||||
@@ -653,12 +845,12 @@ When installed, NUPST makes the following changes:
|
|||||||
### Network
|
### Network
|
||||||
|
|
||||||
- Outbound SNMP to UPS devices (default port 161)
|
- Outbound SNMP to UPS devices (default port 161)
|
||||||
- No inbound connections required
|
- Optional inbound HTTP server (disabled by default, configurable port)
|
||||||
- No external internet connections
|
- No external internet connections
|
||||||
|
|
||||||
## 🚀 Migration from v3.x
|
## 🚀 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
|
```bash
|
||||||
# One command to migrate everything
|
# One command to migrate everything
|
||||||
@@ -666,56 +858,29 @@ curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh |
|
|||||||
```
|
```
|
||||||
|
|
||||||
**The installer automatically:**
|
**The installer automatically:**
|
||||||
|
|
||||||
- Detects v3.x installation
|
- Detects v3.x installation
|
||||||
- Stops the service
|
- Stops the service
|
||||||
- Replaces Node.js version with Deno binary
|
- 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
|
- 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 |
|
| **Runtime** | Node.js + npm | Deno |
|
||||||
| **Distribution** | Git repo + npm install | Pre-compiled binaries |
|
| **Distribution** | Git repo + npm install | Pre-compiled binaries |
|
||||||
| **Runtime Dependencies** | node_modules required | Zero (self-contained) |
|
| **Runtime Dependencies** | node_modules required | Zero (self-contained) |
|
||||||
| **Size** | ~150MB (with node_modules) | ~80MB (single binary) |
|
| **Size** | ~150MB (with node_modules) | ~80MB (single binary) |
|
||||||
| **Startup** | Seconds | Milliseconds |
|
| **Startup** | Seconds | Milliseconds |
|
||||||
| **Commands** | Flat (`nupst add`) | Subcommands (`nupst ups add`) |
|
| **Commands** | Flat (`nupst add`) | Subcommands (`nupst ups add`) |
|
||||||
| **Configuration** | UPS-level thresholds | Action-based thresholds |
|
| **Configuration** | UPS-level thresholds | Action-based thresholds |
|
||||||
|
|
||||||
### Configuration Compatibility
|
### Configuration Compatibility
|
||||||
|
|
||||||
Your v3.x configuration is **fully compatible**. The migration system automatically converts:
|
Your v3.x configuration is **fully compatible**. The migration system automatically converts older
|
||||||
|
formats to the current version.
|
||||||
**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.
|
|
||||||
|
|
||||||
## 💻 Development
|
## 💻 Development
|
||||||
|
|
||||||
@@ -750,40 +915,49 @@ deno task compile
|
|||||||
nupst/
|
nupst/
|
||||||
├── mod.ts # Entry point
|
├── mod.ts # Entry point
|
||||||
├── ts/
|
├── ts/
|
||||||
│ ├── cli.ts # CLI command routing
|
│ ├── cli.ts # CLI command routing
|
||||||
│ ├── nupst.ts # Main coordinator class
|
│ ├── nupst.ts # Main coordinator class
|
||||||
│ ├── daemon.ts # Background monitoring daemon
|
│ ├── daemon.ts # Background monitoring daemon
|
||||||
│ ├── systemd.ts # Systemd service management
|
│ ├── systemd.ts # Systemd service management
|
||||||
│ ├── snmp/ # SNMP implementation
|
│ ├── constants.ts # Centralized configuration constants
|
||||||
│ ├── actions/ # Action system
|
│ ├── interfaces/ # TypeScript interfaces
|
||||||
│ ├── migrations/ # Config migration system
|
│ ├── snmp/ # SNMP implementation
|
||||||
│ └── cli/ # CLI handlers
|
│ ├── actions/ # Action system (shutdown, webhook, script)
|
||||||
├── test/ # Test files
|
│ ├── helpers/ # Utility functions
|
||||||
├── scripts/ # Build scripts
|
│ ├── migrations/ # Config migration system
|
||||||
└── deno.json # Deno configuration
|
│ └── 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
|
## 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 in describing the origin of the work and reproducing the content of the NOTICE 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
|
||||||
|
in describing the origin of the work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
### Trademarks
|
### Trademarks
|
||||||
|
|
||||||
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.
|
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 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
|
### Company Information
|
||||||
|
|
||||||
Task Venture Capital GmbH
|
Task Venture Capital GmbH Registered at District Court Bremen HRB 35230 HB, Germany
|
||||||
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 Capital GmbH of any derivative works.
|
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
|
||||||
|
Capital GmbH of any derivative works.
|
||||||
|
|||||||
237
scripts/install-binary.js
Normal file
237
scripts/install-binary.js
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
#!/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 { 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);
|
||||||
|
const streamPipeline = promisify(pipeline);
|
||||||
|
|
||||||
|
// Configuration
|
||||||
|
const REPO_BASE = 'https://code.foss.global/serve.zone/nupst';
|
||||||
|
const VERSION = process.env.npm_package_version || '5.0.5';
|
||||||
|
|
||||||
|
function getBinaryInfo() {
|
||||||
|
const plat = platform();
|
||||||
|
const architecture = arch();
|
||||||
|
|
||||||
|
const platformMap = {
|
||||||
|
'darwin': 'macos',
|
||||||
|
'linux': 'linux',
|
||||||
|
'win32': 'windows',
|
||||||
|
};
|
||||||
|
|
||||||
|
const archMap = {
|
||||||
|
'x64': 'x64',
|
||||||
|
'arm64': 'arm64',
|
||||||
|
};
|
||||||
|
|
||||||
|
const mappedPlatform = platformMap[plat];
|
||||||
|
const mappedArch = archMap[architecture];
|
||||||
|
|
||||||
|
if (!mappedPlatform || !mappedArch) {
|
||||||
|
return { supported: false, platform: plat, arch: architecture };
|
||||||
|
}
|
||||||
|
|
||||||
|
let binaryName = `nupst-${mappedPlatform}-${mappedArch}`;
|
||||||
|
if (plat === 'win32') {
|
||||||
|
binaryName += '.exe';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
supported: true,
|
||||||
|
platform: mappedPlatform,
|
||||||
|
arch: mappedArch,
|
||||||
|
binaryName,
|
||||||
|
originalPlatform: plat,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadFile(url, destination) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
console.log(`Downloading from: ${url}`);
|
||||||
|
|
||||||
|
// Follow redirects
|
||||||
|
const download = (url, redirectCount = 0) => {
|
||||||
|
if (redirectCount > 5) {
|
||||||
|
reject(new Error('Too many redirects'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
https.get(url, (response) => {
|
||||||
|
if (response.statusCode === 301 || response.statusCode === 302) {
|
||||||
|
console.log(`Following redirect to: ${response.headers.location}`);
|
||||||
|
download(response.headers.location, redirectCount + 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.statusCode !== 200) {
|
||||||
|
reject(new Error(`Failed to download: ${response.statusCode} ${response.statusMessage}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalSize = parseInt(response.headers['content-length'], 10);
|
||||||
|
let downloadedSize = 0;
|
||||||
|
let lastProgress = 0;
|
||||||
|
|
||||||
|
response.on('data', (chunk) => {
|
||||||
|
downloadedSize += chunk.length;
|
||||||
|
const progress = Math.round((downloadedSize / totalSize) * 100);
|
||||||
|
|
||||||
|
// Only log every 10% to reduce noise
|
||||||
|
if (progress >= lastProgress + 10) {
|
||||||
|
console.log(`Download progress: ${progress}%`);
|
||||||
|
lastProgress = progress;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const file = createWriteStream(destination);
|
||||||
|
|
||||||
|
pipeline(response, file, (err) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
} else {
|
||||||
|
console.log('Download complete!');
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}).on('error', reject);
|
||||||
|
};
|
||||||
|
|
||||||
|
download(url);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('===========================================');
|
||||||
|
console.log(' NUPST - Binary Installation');
|
||||||
|
console.log('===========================================');
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
const binaryInfo = getBinaryInfo();
|
||||||
|
|
||||||
|
if (!binaryInfo.supported) {
|
||||||
|
console.error(
|
||||||
|
`❌ Error: Unsupported platform/architecture: ${binaryInfo.platform}/${binaryInfo.arch}`,
|
||||||
|
);
|
||||||
|
console.error('');
|
||||||
|
console.error('Supported platforms:');
|
||||||
|
console.error(' • Linux (x64, arm64)');
|
||||||
|
console.error(' • macOS (x64, arm64)');
|
||||||
|
console.error(' • Windows (x64)');
|
||||||
|
console.error('');
|
||||||
|
console.error('If you believe your platform should be supported, please file an issue:');
|
||||||
|
console.error(' https://code.foss.global/serve.zone/nupst/issues');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Platform: ${binaryInfo.platform} (${binaryInfo.originalPlatform})`);
|
||||||
|
console.log(`Architecture: ${binaryInfo.arch}`);
|
||||||
|
console.log(`Binary: ${binaryInfo.binaryName}`);
|
||||||
|
console.log(`Version: ${VERSION}`);
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// Create dist/binaries directory if it doesn't exist
|
||||||
|
const binariesDir = join(__dirname, '..', 'dist', 'binaries');
|
||||||
|
if (!existsSync(binariesDir)) {
|
||||||
|
console.log('Creating binaries directory...');
|
||||||
|
mkdirSync(binariesDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const binaryPath = join(binariesDir, binaryInfo.binaryName);
|
||||||
|
|
||||||
|
// Check if binary already exists and skip download
|
||||||
|
if (existsSync(binaryPath)) {
|
||||||
|
console.log('✓ Binary already exists, skipping download');
|
||||||
|
} else {
|
||||||
|
// Construct download URL
|
||||||
|
// Try release URL first, fall back to raw branch if needed
|
||||||
|
const releaseUrl = `${REPO_BASE}/releases/download/v${VERSION}/${binaryInfo.binaryName}`;
|
||||||
|
const fallbackUrl = `${REPO_BASE}/raw/branch/main/dist/binaries/${binaryInfo.binaryName}`;
|
||||||
|
|
||||||
|
console.log('Downloading platform-specific binary...');
|
||||||
|
console.log('This may take a moment depending on your connection speed.');
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Try downloading from release
|
||||||
|
await downloadFile(releaseUrl, binaryPath);
|
||||||
|
} catch (err) {
|
||||||
|
console.log(`Release download failed: ${err.message}`);
|
||||||
|
console.log('Trying fallback URL...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Try fallback URL
|
||||||
|
await downloadFile(fallbackUrl, binaryPath);
|
||||||
|
} catch (fallbackErr) {
|
||||||
|
console.error(`❌ Error: Failed to download binary`);
|
||||||
|
console.error(` Primary URL: ${releaseUrl}`);
|
||||||
|
console.error(` Fallback URL: ${fallbackUrl}`);
|
||||||
|
console.error('');
|
||||||
|
console.error('This might be because:');
|
||||||
|
console.error('1. The release has not been created yet');
|
||||||
|
console.error('2. Network connectivity issues');
|
||||||
|
console.error('3. The version specified does not exist');
|
||||||
|
console.error('');
|
||||||
|
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',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clean up partial download
|
||||||
|
if (existsSync(binaryPath)) {
|
||||||
|
unlinkSync(binaryPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✓ Binary downloaded successfully`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// On Unix-like systems, ensure the binary is executable
|
||||||
|
if (binaryInfo.originalPlatform !== 'win32') {
|
||||||
|
try {
|
||||||
|
console.log('Setting executable permissions...');
|
||||||
|
chmodSync(binaryPath, 0o755);
|
||||||
|
console.log('✓ Binary permissions updated');
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`⚠️ Warning: Could not set executable permissions: ${err.message}`);
|
||||||
|
console.error(' You may need to manually run:');
|
||||||
|
console.error(` chmod +x ${binaryPath}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
console.log('✅ NUPST installation completed successfully!');
|
||||||
|
console.log('');
|
||||||
|
console.log('You can now use NUPST by running:');
|
||||||
|
console.log(' nupst --help');
|
||||||
|
console.log('');
|
||||||
|
console.log('For initial setup, run:');
|
||||||
|
console.log(' sudo nupst ups add');
|
||||||
|
console.log('');
|
||||||
|
console.log('===========================================');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run the installation
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(`❌ Installation failed: ${err.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
BIN
serve.zone-nupst-5.0.5.tgz
Normal file
BIN
serve.zone-nupst-5.0.5.tgz
Normal file
Binary file not shown.
@@ -1,6 +1,7 @@
|
|||||||
# Manual Docker Testing Scripts
|
# Manual Docker Testing Scripts
|
||||||
|
|
||||||
This directory contains scripts for manually testing NUPST installation and migration in Docker containers with systemd support.
|
This directory contains scripts for manually testing NUPST installation and migration in Docker
|
||||||
|
containers with systemd support.
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
@@ -15,12 +16,14 @@ This directory contains scripts for manually testing NUPST installation and migr
|
|||||||
Creates a Docker container with systemd and installs NUPST v3.
|
Creates a Docker container with systemd and installs NUPST v3.
|
||||||
|
|
||||||
**What it does:**
|
**What it does:**
|
||||||
|
|
||||||
- Creates Ubuntu 22.04 container with systemd enabled
|
- Creates Ubuntu 22.04 container with systemd enabled
|
||||||
- Installs NUPST v3 from commit `806f81c6` (last v3 version)
|
- Installs NUPST v3 from commit `806f81c6` (last v3 version)
|
||||||
- Enables and starts the systemd service
|
- Enables and starts the systemd service
|
||||||
- Leaves container running for testing
|
- Leaves container running for testing
|
||||||
|
|
||||||
**Usage:**
|
**Usage:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
chmod +x 01-setup-v3-container.sh
|
chmod +x 01-setup-v3-container.sh
|
||||||
./01-setup-v3-container.sh
|
./01-setup-v3-container.sh
|
||||||
@@ -33,6 +36,7 @@ chmod +x 01-setup-v3-container.sh
|
|||||||
Tests the migration from v3 to v4.
|
Tests the migration from v3 to v4.
|
||||||
|
|
||||||
**What it does:**
|
**What it does:**
|
||||||
|
|
||||||
- Checks current v3 installation
|
- Checks current v3 installation
|
||||||
- Pulls v4 code from `migration/deno-v4` branch
|
- Pulls v4 code from `migration/deno-v4` branch
|
||||||
- Runs install.sh (should auto-detect and migrate)
|
- Runs install.sh (should auto-detect and migrate)
|
||||||
@@ -40,6 +44,7 @@ Tests the migration from v3 to v4.
|
|||||||
- Tests basic commands
|
- Tests basic commands
|
||||||
|
|
||||||
**Usage:**
|
**Usage:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
chmod +x 02-test-v3-to-v4-migration.sh
|
chmod +x 02-test-v3-to-v4-migration.sh
|
||||||
./02-test-v3-to-v4-migration.sh
|
./02-test-v3-to-v4-migration.sh
|
||||||
@@ -52,6 +57,7 @@ chmod +x 02-test-v3-to-v4-migration.sh
|
|||||||
Removes the test container.
|
Removes the test container.
|
||||||
|
|
||||||
**Usage:**
|
**Usage:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
chmod +x 03-cleanup.sh
|
chmod +x 03-cleanup.sh
|
||||||
./03-cleanup.sh
|
./03-cleanup.sh
|
||||||
@@ -134,16 +140,19 @@ docker rm -f nupst-test-v3
|
|||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### Container won't start
|
### Container won't start
|
||||||
|
|
||||||
- Ensure Docker daemon is running
|
- Ensure Docker daemon is running
|
||||||
- Check you have privileged access
|
- Check you have privileged access
|
||||||
- Try: `docker logs nupst-test-v3`
|
- Try: `docker logs nupst-test-v3`
|
||||||
|
|
||||||
### Systemd not working in container
|
### Systemd not working in container
|
||||||
|
|
||||||
- Requires Linux host (not macOS/Windows)
|
- Requires Linux host (not macOS/Windows)
|
||||||
- Needs `--privileged` and cgroup volume mounts
|
- Needs `--privileged` and cgroup volume mounts
|
||||||
- Check: `docker exec nupst-test-v3 systemctl --version`
|
- Check: `docker exec nupst-test-v3 systemctl --version`
|
||||||
|
|
||||||
### Migration fails
|
### Migration fails
|
||||||
|
|
||||||
- Check logs: `docker exec nupst-test-v3 journalctl -xe`
|
- Check logs: `docker exec nupst-test-v3 journalctl -xe`
|
||||||
- Verify install.sh ran: `docker exec nupst-test-v3 ls -la /opt/nupst/`
|
- Verify install.sh ran: `docker exec nupst-test-v3 ls -la /opt/nupst/`
|
||||||
- Check service: `docker exec nupst-test-v3 systemctl status nupst`
|
- Check service: `docker exec nupst-test-v3 systemctl status nupst`
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
* Run with: deno run --allow-all test/showcase.ts
|
* Run with: deno run --allow-all test/showcase.ts
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { logger, type ITableColumn } from '../ts/logger.ts';
|
import { type ITableColumn, logger } from '../ts/logger.ts';
|
||||||
import { theme, symbols, getBatteryColor, formatPowerStatus } from '../ts/colors.ts';
|
import { formatPowerStatus, getBatteryColor, symbols, theme } from '../ts/colors.ts';
|
||||||
|
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log('═'.repeat(80));
|
console.log('═'.repeat(80));
|
||||||
@@ -38,31 +38,51 @@ logger.logBoxEnd();
|
|||||||
|
|
||||||
console.log('');
|
console.log('');
|
||||||
|
|
||||||
logger.logBox('Success Box (Green)', [
|
logger.logBox(
|
||||||
'Used for successful operations',
|
'Success Box (Green)',
|
||||||
'Installation complete, service started, etc.',
|
[
|
||||||
], 60, 'success');
|
'Used for successful operations',
|
||||||
|
'Installation complete, service started, etc.',
|
||||||
|
],
|
||||||
|
60,
|
||||||
|
'success',
|
||||||
|
);
|
||||||
|
|
||||||
console.log('');
|
console.log('');
|
||||||
|
|
||||||
logger.logBox('Error Box (Red)', [
|
logger.logBox(
|
||||||
'Used for critical errors and failures',
|
'Error Box (Red)',
|
||||||
'Configuration errors, connection failures, etc.',
|
[
|
||||||
], 60, 'error');
|
'Used for critical errors and failures',
|
||||||
|
'Configuration errors, connection failures, etc.',
|
||||||
|
],
|
||||||
|
60,
|
||||||
|
'error',
|
||||||
|
);
|
||||||
|
|
||||||
console.log('');
|
console.log('');
|
||||||
|
|
||||||
logger.logBox('Warning Box (Yellow)', [
|
logger.logBox(
|
||||||
'Used for warnings and deprecations',
|
'Warning Box (Yellow)',
|
||||||
'Old command format, missing config, etc.',
|
[
|
||||||
], 60, 'warning');
|
'Used for warnings and deprecations',
|
||||||
|
'Old command format, missing config, etc.',
|
||||||
|
],
|
||||||
|
60,
|
||||||
|
'warning',
|
||||||
|
);
|
||||||
|
|
||||||
console.log('');
|
console.log('');
|
||||||
|
|
||||||
logger.logBox('Info Box (Cyan)', [
|
logger.logBox(
|
||||||
'Used for informational messages',
|
'Info Box (Cyan)',
|
||||||
'Version info, update available, etc.',
|
[
|
||||||
], 60, 'info');
|
'Used for informational messages',
|
||||||
|
'Version info, update available, etc.',
|
||||||
|
],
|
||||||
|
60,
|
||||||
|
'info',
|
||||||
|
);
|
||||||
|
|
||||||
console.log('');
|
console.log('');
|
||||||
|
|
||||||
@@ -112,15 +132,24 @@ const upsColumns: ITableColumn[] = [
|
|||||||
{ header: 'ID', key: 'id' },
|
{ header: 'ID', key: 'id' },
|
||||||
{ header: 'Name', key: 'name' },
|
{ header: 'Name', key: 'name' },
|
||||||
{ header: 'Host', key: 'host' },
|
{ header: 'Host', key: 'host' },
|
||||||
{ header: 'Status', key: 'status', color: (v) => {
|
{
|
||||||
if (v.includes('Online')) return theme.success(v);
|
header: 'Status',
|
||||||
if (v.includes('Battery')) return theme.warning(v);
|
key: 'status',
|
||||||
return theme.dim(v);
|
color: (v) => {
|
||||||
}},
|
if (v.includes('Online')) return theme.success(v);
|
||||||
{ header: 'Battery', key: 'battery', align: 'right', color: (v) => {
|
if (v.includes('Battery')) return theme.warning(v);
|
||||||
const pct = parseInt(v);
|
return theme.dim(v);
|
||||||
return getBatteryColor(pct)(v);
|
},
|
||||||
}},
|
},
|
||||||
|
{
|
||||||
|
header: 'Battery',
|
||||||
|
key: 'battery',
|
||||||
|
align: 'right',
|
||||||
|
color: (v) => {
|
||||||
|
const pct = parseInt(v);
|
||||||
|
return getBatteryColor(pct)(v);
|
||||||
|
},
|
||||||
|
},
|
||||||
{ header: 'Runtime', key: 'runtime', align: 'right' },
|
{ header: 'Runtime', key: 'runtime', align: 'right' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
442
test/test.ts
442
test/test.ts
@@ -1,93 +1,377 @@
|
|||||||
import { assert, assertEquals, assertExists } from 'jsr:@std/assert@^1.0.0';
|
import { assert, assertEquals, assertExists } from 'jsr:@std/assert@^1.0.0';
|
||||||
import { NupstSnmp } from '../ts/snmp/manager.ts';
|
import { NupstSnmp } from '../ts/snmp/manager.ts';
|
||||||
import 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';
|
import * as qenv from 'npm:@push.rocks/qenv@^6.0.0';
|
||||||
const testQenv = new qenv.Qenv('./', '.nogit/');
|
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);
|
const snmp = new NupstSnmp(true);
|
||||||
|
|
||||||
// Load the test configuration from .nogit/env.json
|
// Load test configurations
|
||||||
const testConfigV1 = await testQenv.getEnvVarOnDemandAsObject('testConfigV1');
|
const testConfigV1 = await testQenv.getEnvVarOnDemandAsObject('testConfigV1');
|
||||||
const testConfigV3 = await testQenv.getEnvVarOnDemandAsObject('testConfigV3');
|
const testConfigV3 = await testQenv.getEnvVarOnDemandAsObject('testConfigV3');
|
||||||
|
|
||||||
Deno.test('should log config', () => {
|
Deno.test('Integration: Real UPS test v1', async () => {
|
||||||
console.log(testConfigV1);
|
await testUpsConnection(snmp, testConfigV1, 'SNMPv1 connection');
|
||||||
assert(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Test with real UPS using the configuration from .nogit/env.json
|
Deno.test('Integration: Real UPS test v3', async () => {
|
||||||
Deno.test('Real UPS test v1', async () => {
|
await testUpsConnection(snmp, testConfigV3, 'SNMPv3 connection');
|
||||||
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');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* commitinfo - reads version from deno.json
|
* autocreated commitinfo by @push.rocks/commitinfo
|
||||||
*/
|
*/
|
||||||
import denoConfig from '../deno.json' with { type: 'json' };
|
|
||||||
|
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: denoConfig.name,
|
name: '@serve.zone/nupst',
|
||||||
version: denoConfig.version,
|
version: '5.2.4',
|
||||||
description: 'Network UPS Shutdown Tool (https://nupst.serve.zone)',
|
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
|
// Re-export types for convenience
|
||||||
export type { IActionConfig, IActionContext, TPowerStatus } from './base-action.ts';
|
export type { IActionConfig, IActionContext, TPowerStatus } from './base-action.ts';
|
||||||
|
export type { IWebhookPayload } from './webhook-action.ts';
|
||||||
export { Action } from './base-action.ts';
|
export { Action } from './base-action.ts';
|
||||||
export { ShutdownAction } from './shutdown-action.ts';
|
export { ShutdownAction } from './shutdown-action.ts';
|
||||||
export { WebhookAction } from './webhook-action.ts';
|
export { WebhookAction } from './webhook-action.ts';
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import * as fs from 'node:fs';
|
|||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
import { exec } from 'node:child_process';
|
import { exec } from 'node:child_process';
|
||||||
import { promisify } from 'node:util';
|
import { promisify } from 'node:util';
|
||||||
import { Action, type IActionConfig, type IActionContext } from './base-action.ts';
|
import { Action, type IActionContext } from './base-action.ts';
|
||||||
import { logger } from '../logger.ts';
|
import { logger } from '../logger.ts';
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
@@ -26,7 +26,11 @@ export class ScriptAction extends Action {
|
|||||||
async execute(context: IActionContext): Promise<void> {
|
async execute(context: IActionContext): Promise<void> {
|
||||||
// Check if we should execute based on trigger mode
|
// Check if we should execute based on trigger mode
|
||||||
if (!this.shouldExecute(context)) {
|
if (!this.shouldExecute(context)) {
|
||||||
logger.info(`Script action skipped (trigger mode: ${this.config.triggerMode || 'powerChangesAndThresholds'})`);
|
logger.info(
|
||||||
|
`Script action skipped (trigger mode: ${
|
||||||
|
this.config.triggerMode || 'powerChangesAndThresholds'
|
||||||
|
})`,
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import * as fs from 'node:fs';
|
import * as fs from 'node:fs';
|
||||||
import { execFile } from 'node:child_process';
|
import { execFile } from 'node:child_process';
|
||||||
import { promisify } from 'node:util';
|
import { promisify } from 'node:util';
|
||||||
import { Action, type IActionConfig, type IActionContext } from './base-action.ts';
|
import { Action, type IActionContext } from './base-action.ts';
|
||||||
import { logger } from '../logger.ts';
|
import { logger } from '../logger.ts';
|
||||||
|
import { SHUTDOWN, UI } from '../constants.ts';
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
@@ -15,6 +16,92 @@ const execFileAsync = promisify(execFile);
|
|||||||
export class ShutdownAction extends Action {
|
export class ShutdownAction extends Action {
|
||||||
readonly type = 'shutdown';
|
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
|
* Execute the shutdown action
|
||||||
* @param context Action context with UPS state
|
* @param context Action context with UPS state
|
||||||
@@ -22,14 +109,18 @@ export class ShutdownAction extends Action {
|
|||||||
async execute(context: IActionContext): Promise<void> {
|
async execute(context: IActionContext): Promise<void> {
|
||||||
// Check if we should execute based on trigger mode and thresholds
|
// Check if we should execute based on trigger mode and thresholds
|
||||||
if (!this.shouldExecute(context)) {
|
if (!this.shouldExecute(context)) {
|
||||||
logger.info(`Shutdown action skipped (trigger mode: ${this.config.triggerMode || 'powerChangesAndThresholds'})`);
|
logger.info(
|
||||||
|
`Shutdown action skipped (trigger mode: ${
|
||||||
|
this.config.triggerMode || 'powerChangesAndThresholds'
|
||||||
|
})`,
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const shutdownDelay = this.config.shutdownDelay || 5; // Default 5 minutes
|
const shutdownDelay = this.config.shutdownDelay || SHUTDOWN.DEFAULT_DELAY_MINUTES;
|
||||||
|
|
||||||
logger.log('');
|
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(`UPS: ${context.upsName} (${context.upsId})`);
|
||||||
logger.logBoxLine(`Power Status: ${context.powerStatus}`);
|
logger.logBoxLine(`Power Status: ${context.powerStatus}`);
|
||||||
logger.logBoxLine(`Battery: ${context.batteryCapacity}%`);
|
logger.logBoxLine(`Battery: ${context.batteryCapacity}%`);
|
||||||
|
|||||||
@@ -1,8 +1,34 @@
|
|||||||
import * as http from 'node:http';
|
import * as http from 'node:http';
|
||||||
import * as https from 'node:https';
|
import * as https from 'node:https';
|
||||||
import { URL } from 'node:url';
|
import { URL } from 'node:url';
|
||||||
import { Action, type IActionConfig, type IActionContext } from './base-action.ts';
|
import { Action, type IActionContext } from './base-action.ts';
|
||||||
import { logger } from '../logger.ts';
|
import { logger } from '../logger.ts';
|
||||||
|
import { WEBHOOK } from '../constants.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
* WebhookAction - Calls an HTTP webhook with UPS state information
|
||||||
@@ -20,7 +46,11 @@ export class WebhookAction extends Action {
|
|||||||
async execute(context: IActionContext): Promise<void> {
|
async execute(context: IActionContext): Promise<void> {
|
||||||
// Check if we should execute based on trigger mode
|
// Check if we should execute based on trigger mode
|
||||||
if (!this.shouldExecute(context)) {
|
if (!this.shouldExecute(context)) {
|
||||||
logger.info(`Webhook action skipped (trigger mode: ${this.config.triggerMode || 'powerChangesAndThresholds'})`);
|
logger.info(
|
||||||
|
`Webhook action skipped (trigger mode: ${
|
||||||
|
this.config.triggerMode || 'powerChangesAndThresholds'
|
||||||
|
})`,
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,7 +60,7 @@ export class WebhookAction extends Action {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const method = this.config.webhookMethod || 'POST';
|
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}`);
|
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 method HTTP method (GET or POST)
|
||||||
* @param timeout Request timeout in milliseconds
|
* @param timeout Request timeout in milliseconds
|
||||||
*/
|
*/
|
||||||
private async callWebhook(
|
private callWebhook(
|
||||||
context: IActionContext,
|
context: IActionContext,
|
||||||
method: 'GET' | 'POST',
|
method: 'GET' | 'POST',
|
||||||
timeout: number,
|
timeout: number,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const payload: any = {
|
const payload: IWebhookPayload = {
|
||||||
upsId: context.upsId,
|
upsId: context.upsId,
|
||||||
upsName: context.upsName,
|
upsName: context.upsName,
|
||||||
powerStatus: context.powerStatus,
|
powerStatus: context.powerStatus,
|
||||||
|
|||||||
214
ts/cli.ts
214
ts/cli.ts
@@ -1,7 +1,7 @@
|
|||||||
import { execSync } from 'node:child_process';
|
import { execSync } from 'node:child_process';
|
||||||
import { Nupst } from './nupst.ts';
|
import { Nupst } from './nupst.ts';
|
||||||
import { logger, type ITableColumn } from './logger.ts';
|
import { type ITableColumn, logger } from './logger.ts';
|
||||||
import { theme, symbols } from './colors.ts';
|
import { theme } from './colors.ts';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Class for handling CLI commands
|
* Class for handling CLI commands
|
||||||
@@ -223,6 +223,24 @@ export class NupstCli {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle feature subcommands
|
||||||
|
if (command === 'feature') {
|
||||||
|
const subcommand = commandArgs[0];
|
||||||
|
const featureHandler = this.nupst.getFeatureHandler();
|
||||||
|
|
||||||
|
switch (subcommand) {
|
||||||
|
case 'httpServer':
|
||||||
|
case 'http-server':
|
||||||
|
case 'http':
|
||||||
|
await featureHandler.configureHttpServer();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
this.showFeatureHelp();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Handle config subcommand
|
// Handle config subcommand
|
||||||
if (command === 'config') {
|
if (command === 'config') {
|
||||||
const subcommand = commandArgs[0] || 'show';
|
const subcommand = commandArgs[0] || 'show';
|
||||||
@@ -269,10 +287,15 @@ export class NupstCli {
|
|||||||
try {
|
try {
|
||||||
await this.nupst.getDaemon().loadConfig();
|
await this.nupst.getDaemon().loadConfig();
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
logger.logBox('Configuration Error', [
|
logger.logBox(
|
||||||
'No configuration found.',
|
'Configuration Error',
|
||||||
"Please run 'nupst ups add' first to create a configuration.",
|
[
|
||||||
], 50, 'error');
|
'No configuration found.',
|
||||||
|
"Please run 'nupst ups add' first to create a configuration.",
|
||||||
|
],
|
||||||
|
50,
|
||||||
|
'error',
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,14 +308,46 @@ export class NupstCli {
|
|||||||
|
|
||||||
// Overview Box
|
// Overview Box
|
||||||
logger.log('');
|
logger.log('');
|
||||||
logger.logBox('NUPST Configuration', [
|
logger.logBox(
|
||||||
`UPS Devices: ${theme.highlight(String(config.upsDevices.length))}`,
|
'NUPST Configuration',
|
||||||
`Groups: ${theme.highlight(String(config.groups ? config.groups.length : 0))}`,
|
[
|
||||||
`Check Interval: ${theme.info(String(config.checkInterval / 1000))} seconds`,
|
`UPS Devices: ${theme.highlight(String(config.upsDevices.length))}`,
|
||||||
'',
|
`Groups: ${theme.highlight(String(config.groups ? config.groups.length : 0))}`,
|
||||||
theme.dim('Configuration File:'),
|
`Check Interval: ${theme.info(String(config.checkInterval / 1000))} seconds`,
|
||||||
` ${theme.path('/etc/nupst/config.json')}`,
|
'',
|
||||||
], 60, 'info');
|
theme.dim('Configuration File:'),
|
||||||
|
` ${theme.path('/etc/nupst/config.json')}`,
|
||||||
|
],
|
||||||
|
60,
|
||||||
|
'info',
|
||||||
|
);
|
||||||
|
|
||||||
|
// HTTP Server Status (if configured)
|
||||||
|
if (config.httpServer) {
|
||||||
|
const serverStatus = config.httpServer.enabled
|
||||||
|
? theme.success('Enabled')
|
||||||
|
: 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',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// UPS Devices Table
|
// UPS Devices Table
|
||||||
if (config.upsDevices.length > 0) {
|
if (config.upsDevices.length > 0) {
|
||||||
@@ -356,60 +411,66 @@ export class NupstCli {
|
|||||||
// === Legacy Single UPS Configuration ===
|
// === Legacy Single UPS Configuration ===
|
||||||
|
|
||||||
if (!config.snmp) {
|
if (!config.snmp) {
|
||||||
logger.logBox('Configuration Error', [
|
logger.logBox(
|
||||||
'Error: Legacy configuration missing SNMP settings',
|
'Configuration Error',
|
||||||
], 60, 'error');
|
[
|
||||||
|
'Error: Legacy configuration missing SNMP settings',
|
||||||
|
],
|
||||||
|
60,
|
||||||
|
'error',
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.log('');
|
logger.log('');
|
||||||
logger.logBox('NUPST Configuration (Legacy)', [
|
logger.logBox(
|
||||||
theme.warning('Legacy single-UPS configuration format'),
|
'NUPST Configuration (Legacy)',
|
||||||
'',
|
[
|
||||||
theme.dim('SNMP Settings:'),
|
theme.warning('Legacy single-UPS configuration format'),
|
||||||
` Host: ${theme.info(config.snmp.host)}`,
|
'',
|
||||||
` Port: ${theme.info(String(config.snmp.port))}`,
|
theme.dim('SNMP Settings:'),
|
||||||
` Version: ${config.snmp.version}`,
|
` Host: ${theme.info(config.snmp.host)}`,
|
||||||
` UPS Model: ${config.snmp.upsModel || 'cyberpower'}`,
|
` Port: ${theme.info(String(config.snmp.port))}`,
|
||||||
...(config.snmp.version === 1 || config.snmp.version === 2
|
` Version: ${config.snmp.version}`,
|
||||||
? [` Community: ${config.snmp.community}`]
|
` UPS Model: ${config.snmp.upsModel || 'cyberpower'}`,
|
||||||
: []
|
...(config.snmp.version === 1 || config.snmp.version === 2
|
||||||
),
|
? [` Community: ${config.snmp.community}`]
|
||||||
...(config.snmp.version === 3
|
: []),
|
||||||
? [
|
...(config.snmp.version === 3
|
||||||
|
? [
|
||||||
` Security Level: ${config.snmp.securityLevel}`,
|
` Security Level: ${config.snmp.securityLevel}`,
|
||||||
` Username: ${config.snmp.username}`,
|
` Username: ${config.snmp.username}`,
|
||||||
...(config.snmp.securityLevel === 'authNoPriv' || config.snmp.securityLevel === 'authPriv'
|
...(config.snmp.securityLevel === 'authNoPriv' ||
|
||||||
|
config.snmp.securityLevel === 'authPriv'
|
||||||
? [` Auth Protocol: ${config.snmp.authProtocol || 'None'}`]
|
? [` Auth Protocol: ${config.snmp.authProtocol || 'None'}`]
|
||||||
: []
|
: []),
|
||||||
),
|
|
||||||
...(config.snmp.securityLevel === 'authPriv'
|
...(config.snmp.securityLevel === 'authPriv'
|
||||||
? [` Privacy Protocol: ${config.snmp.privProtocol || 'None'}`]
|
? [` Privacy Protocol: ${config.snmp.privProtocol || 'None'}`]
|
||||||
: []
|
: []),
|
||||||
),
|
|
||||||
` Timeout: ${config.snmp.timeout / 1000} seconds`,
|
` Timeout: ${config.snmp.timeout / 1000} seconds`,
|
||||||
]
|
]
|
||||||
: []
|
: []),
|
||||||
),
|
...(config.snmp.upsModel === 'custom' && config.snmp.customOIDs
|
||||||
...(config.snmp.upsModel === 'custom' && config.snmp.customOIDs
|
? [
|
||||||
? [
|
|
||||||
theme.dim('Custom OIDs:'),
|
theme.dim('Custom OIDs:'),
|
||||||
` Power Status: ${config.snmp.customOIDs.POWER_STATUS || 'Not set'}`,
|
` Power Status: ${config.snmp.customOIDs.POWER_STATUS || 'Not set'}`,
|
||||||
` Battery Capacity: ${config.snmp.customOIDs.BATTERY_CAPACITY || 'Not set'}`,
|
` Battery Capacity: ${config.snmp.customOIDs.BATTERY_CAPACITY || 'Not set'}`,
|
||||||
` Battery Runtime: ${config.snmp.customOIDs.BATTERY_RUNTIME || 'Not set'}`,
|
` Battery Runtime: ${config.snmp.customOIDs.BATTERY_RUNTIME || 'Not set'}`,
|
||||||
]
|
]
|
||||||
: []
|
: []),
|
||||||
),
|
'',
|
||||||
'',
|
|
||||||
|
|
||||||
` Check Interval: ${config.checkInterval / 1000} seconds`,
|
` Check Interval: ${config.checkInterval / 1000} seconds`,
|
||||||
'',
|
'',
|
||||||
theme.dim('Configuration File:'),
|
theme.dim('Configuration File:'),
|
||||||
` ${theme.path('/etc/nupst/config.json')}`,
|
` ${theme.path('/etc/nupst/config.json')}`,
|
||||||
'',
|
'',
|
||||||
theme.warning('Note: Using legacy single-UPS configuration format.'),
|
theme.warning('Note: Using legacy single-UPS configuration format.'),
|
||||||
`Consider using ${theme.command('nupst ups add')} to migrate to multi-UPS format.`,
|
`Consider using ${theme.command('nupst ups add')} to migrate to multi-UPS format.`,
|
||||||
], 70, 'warning');
|
],
|
||||||
|
70,
|
||||||
|
'warning',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Service Status
|
// Service Status
|
||||||
@@ -420,10 +481,15 @@ export class NupstCli {
|
|||||||
execSync('systemctl is-enabled nupst.service || true').toString().trim() === 'enabled';
|
execSync('systemctl is-enabled nupst.service || true').toString().trim() === 'enabled';
|
||||||
|
|
||||||
logger.log('');
|
logger.log('');
|
||||||
logger.logBox('Service Status', [
|
logger.logBox(
|
||||||
`Active: ${isActive ? theme.success('Yes') : theme.dim('No')}`,
|
'Service Status',
|
||||||
`Enabled: ${isEnabled ? theme.success('Yes') : theme.dim('No')}`,
|
[
|
||||||
], 50, isActive ? 'success' : 'default');
|
`Active: ${isActive ? theme.success('Yes') : theme.dim('No')}`,
|
||||||
|
`Enabled: ${isEnabled ? theme.success('Yes') : theme.dim('No')}`,
|
||||||
|
],
|
||||||
|
50,
|
||||||
|
isActive ? 'success' : 'default',
|
||||||
|
);
|
||||||
logger.log('');
|
logger.log('');
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
// Ignore errors checking service status
|
// Ignore errors checking service status
|
||||||
@@ -466,6 +532,7 @@ export class NupstCli {
|
|||||||
this.printCommand('ups <subcommand>', 'Manage UPS devices');
|
this.printCommand('ups <subcommand>', 'Manage UPS devices');
|
||||||
this.printCommand('group <subcommand>', 'Manage UPS groups');
|
this.printCommand('group <subcommand>', 'Manage UPS groups');
|
||||||
this.printCommand('action <subcommand>', 'Manage UPS actions');
|
this.printCommand('action <subcommand>', 'Manage UPS actions');
|
||||||
|
this.printCommand('feature <subcommand>', 'Manage optional features');
|
||||||
this.printCommand('config [show]', 'Display current configuration');
|
this.printCommand('config [show]', 'Display current configuration');
|
||||||
this.printCommand('update', 'Update NUPST from repository', theme.dim('(requires root)'));
|
this.printCommand('update', 'Update NUPST from repository', theme.dim('(requires root)'));
|
||||||
this.printCommand('uninstall', 'Completely remove NUPST', theme.dim('(requires root)'));
|
this.printCommand('uninstall', 'Completely remove NUPST', theme.dim('(requires root)'));
|
||||||
@@ -475,8 +542,16 @@ export class NupstCli {
|
|||||||
|
|
||||||
// Service subcommands
|
// Service subcommands
|
||||||
logger.log(theme.info('Service Subcommands:'));
|
logger.log(theme.info('Service Subcommands:'));
|
||||||
this.printCommand('nupst service enable', 'Install and enable systemd service', theme.dim('(requires root)'));
|
this.printCommand(
|
||||||
this.printCommand('nupst service disable', 'Stop and disable systemd service', theme.dim('(requires root)'));
|
'nupst service enable',
|
||||||
|
'Install and enable systemd service',
|
||||||
|
theme.dim('(requires root)'),
|
||||||
|
);
|
||||||
|
this.printCommand(
|
||||||
|
'nupst service disable',
|
||||||
|
'Stop and disable systemd service',
|
||||||
|
theme.dim('(requires root)'),
|
||||||
|
);
|
||||||
this.printCommand('nupst service start', 'Start the systemd service');
|
this.printCommand('nupst service start', 'Start the systemd service');
|
||||||
this.printCommand('nupst service stop', 'Stop the systemd service');
|
this.printCommand('nupst service stop', 'Stop the systemd service');
|
||||||
this.printCommand('nupst service restart', 'Restart the systemd service');
|
this.printCommand('nupst service restart', 'Restart the systemd service');
|
||||||
@@ -506,7 +581,15 @@ export class NupstCli {
|
|||||||
logger.log(theme.info('Action Subcommands:'));
|
logger.log(theme.info('Action Subcommands:'));
|
||||||
this.printCommand('nupst action add <target-id>', 'Add a new action to a UPS or group');
|
this.printCommand('nupst action add <target-id>', 'Add a new action to a UPS or group');
|
||||||
this.printCommand('nupst action remove <target-id> <index>', 'Remove an action by index');
|
this.printCommand('nupst action remove <target-id> <index>', 'Remove an action by index');
|
||||||
this.printCommand('nupst action list [target-id]', 'List all actions (optionally for specific target)');
|
this.printCommand(
|
||||||
|
'nupst action list [target-id]',
|
||||||
|
'List all actions (optionally for specific target)',
|
||||||
|
);
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// Feature subcommands
|
||||||
|
logger.log(theme.info('Feature Subcommands:'));
|
||||||
|
this.printCommand('nupst feature httpServer', 'Configure HTTP server for JSON status export');
|
||||||
console.log('');
|
console.log('');
|
||||||
|
|
||||||
// Options
|
// Options
|
||||||
@@ -632,6 +715,21 @@ Examples:
|
|||||||
nupst action add default - Add a new action to UPS or group 'default'
|
nupst action add default - Add a new action to UPS or group 'default'
|
||||||
nupst action remove default 0 - Remove action at index 0 from UPS or group 'default'
|
nupst action remove default 0 - Remove action at index 0 from UPS or group 'default'
|
||||||
nupst action add dc-rack-1 - Add a new action to group 'dc-rack-1'
|
nupst action add dc-rack-1 - Add a new action to group 'dc-rack-1'
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private showFeatureHelp(): void {
|
||||||
|
logger.log(`
|
||||||
|
NUPST - Feature Management Commands
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
nupst feature <subcommand>
|
||||||
|
|
||||||
|
Subcommands:
|
||||||
|
httpServer - Configure HTTP server for JSON status export
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
nupst feature httpServer - Enable/disable HTTP server with interactive setup
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
import { Nupst } from '../nupst.ts';
|
import { Nupst } from '../nupst.ts';
|
||||||
import { logger, type ITableColumn } from '../logger.ts';
|
import { type ITableColumn, logger } from '../logger.ts';
|
||||||
import { theme, symbols } from '../colors.ts';
|
import { symbols, theme } from '../colors.ts';
|
||||||
import type { IActionConfig } from '../actions/base-action.ts';
|
import type { IActionConfig } from '../actions/base-action.ts';
|
||||||
import type { IUpsConfig, IGroupConfig } from '../daemon.ts';
|
import type { IGroupConfig, IUpsConfig } from '../daemon.ts';
|
||||||
|
import * as helpers from '../helpers/index.ts';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Class for handling action-related CLI commands
|
* Class for handling action-related CLI commands
|
||||||
@@ -47,7 +48,9 @@ export class ActionHandler {
|
|||||||
if (!ups && !group) {
|
if (!ups && !group) {
|
||||||
logger.error(`UPS or Group with ID '${targetId}' not found`);
|
logger.error(`UPS or Group with ID '${targetId}' not found`);
|
||||||
logger.log('');
|
logger.log('');
|
||||||
logger.log(` ${theme.dim('List available UPS devices:')} ${theme.command('nupst ups list')}`);
|
logger.log(
|
||||||
|
` ${theme.dim('List available UPS devices:')} ${theme.command('nupst ups list')}`,
|
||||||
|
);
|
||||||
logger.log(` ${theme.dim('List available groups:')} ${theme.command('nupst group list')}`);
|
logger.log(` ${theme.dim('List available groups:')} ${theme.command('nupst group list')}`);
|
||||||
logger.log('');
|
logger.log('');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
@@ -57,21 +60,7 @@ export class ActionHandler {
|
|||||||
const targetType = ups ? 'UPS' : 'Group';
|
const targetType = ups ? 'UPS' : 'Group';
|
||||||
const targetName = ups ? ups.name : group!.name;
|
const targetName = ups ? ups.name : group!.name;
|
||||||
|
|
||||||
const readline = await import('node:readline');
|
await helpers.withPrompt(async (prompt) => {
|
||||||
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 {
|
|
||||||
logger.log('');
|
logger.log('');
|
||||||
logger.info(`Add Action to ${targetType} ${theme.highlight(targetName)}`);
|
logger.info(`Add Action to ${targetType} ${theme.highlight(targetName)}`);
|
||||||
logger.log('');
|
logger.log('');
|
||||||
@@ -103,12 +92,16 @@ export class ActionHandler {
|
|||||||
// Trigger mode
|
// Trigger mode
|
||||||
logger.log('');
|
logger.log('');
|
||||||
logger.log(` ${theme.dim('Trigger mode:')}`);
|
logger.log(` ${theme.dim('Trigger mode:')}`);
|
||||||
logger.log(` ${theme.dim('1)')} onlyPowerChanges - Trigger only when power status changes`);
|
logger.log(
|
||||||
|
` ${theme.dim('1)')} onlyPowerChanges - Trigger only when power status changes`,
|
||||||
|
);
|
||||||
logger.log(
|
logger.log(
|
||||||
` ${theme.dim('2)')} onlyThresholds - Trigger only when thresholds are violated`,
|
` ${theme.dim('2)')} onlyThresholds - Trigger only when thresholds are violated`,
|
||||||
);
|
);
|
||||||
logger.log(
|
logger.log(
|
||||||
` ${theme.dim('3)')} powerChangesAndThresholds - Trigger on power change AND thresholds`,
|
` ${
|
||||||
|
theme.dim('3)')
|
||||||
|
} powerChangesAndThresholds - Trigger on power change AND thresholds`,
|
||||||
);
|
);
|
||||||
logger.log(` ${theme.dim('4)')} anyChange - Trigger on any status change`);
|
logger.log(` ${theme.dim('4)')} anyChange - Trigger on any status change`);
|
||||||
const triggerChoice = await prompt(` ${theme.dim('Choice')} ${theme.dim('[2]:')} `);
|
const triggerChoice = await prompt(` ${theme.dim('Choice')} ${theme.dim('[2]:')} `);
|
||||||
@@ -154,9 +147,7 @@ export class ActionHandler {
|
|||||||
logger.success(`Action added to ${targetType} ${targetName}`);
|
logger.success(`Action added to ${targetType} ${targetName}`);
|
||||||
logger.log(` ${theme.dim('Changes saved and will be applied automatically')}`);
|
logger.log(` ${theme.dim('Changes saved and will be applied automatically')}`);
|
||||||
logger.log('');
|
logger.log('');
|
||||||
} finally {
|
});
|
||||||
rl.close();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`Failed to add action: ${error instanceof Error ? error.message : String(error)}`,
|
`Failed to add action: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
@@ -173,7 +164,9 @@ export class ActionHandler {
|
|||||||
if (!targetId || !actionIndexStr) {
|
if (!targetId || !actionIndexStr) {
|
||||||
logger.error('Target ID and action index are required');
|
logger.error('Target ID and action index are required');
|
||||||
logger.log(
|
logger.log(
|
||||||
` ${theme.dim('Usage:')} ${theme.command('nupst action remove <ups-id|group-id> <action-index>')}`,
|
` ${theme.dim('Usage:')} ${
|
||||||
|
theme.command('nupst action remove <ups-id|group-id> <action-index>')
|
||||||
|
}`,
|
||||||
);
|
);
|
||||||
logger.log('');
|
logger.log('');
|
||||||
logger.log(` ${theme.dim('List actions:')} ${theme.command('nupst action list')}`);
|
logger.log(` ${theme.dim('List actions:')} ${theme.command('nupst action list')}`);
|
||||||
@@ -197,7 +190,9 @@ export class ActionHandler {
|
|||||||
if (!ups && !group) {
|
if (!ups && !group) {
|
||||||
logger.error(`UPS or Group with ID '${targetId}' not found`);
|
logger.error(`UPS or Group with ID '${targetId}' not found`);
|
||||||
logger.log('');
|
logger.log('');
|
||||||
logger.log(` ${theme.dim('List available UPS devices:')} ${theme.command('nupst ups list')}`);
|
logger.log(
|
||||||
|
` ${theme.dim('List available UPS devices:')} ${theme.command('nupst ups list')}`,
|
||||||
|
);
|
||||||
logger.log(` ${theme.dim('List available groups:')} ${theme.command('nupst group list')}`);
|
logger.log(` ${theme.dim('List available groups:')} ${theme.command('nupst group list')}`);
|
||||||
logger.log('');
|
logger.log('');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
@@ -215,7 +210,9 @@ export class ActionHandler {
|
|||||||
|
|
||||||
if (actionIndex >= target!.actions.length) {
|
if (actionIndex >= target!.actions.length) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`Invalid action index. ${targetType} '${targetName}' has ${target!.actions.length} action(s) (index 0-${target!.actions.length - 1})`,
|
`Invalid action index. ${targetType} '${targetName}' has ${
|
||||||
|
target!.actions.length
|
||||||
|
} action(s) (index 0-${target!.actions.length - 1})`,
|
||||||
);
|
);
|
||||||
logger.log('');
|
logger.log('');
|
||||||
logger.log(
|
logger.log(
|
||||||
@@ -235,7 +232,9 @@ export class ActionHandler {
|
|||||||
logger.log(` ${theme.dim('Type:')} ${removedAction.type}`);
|
logger.log(` ${theme.dim('Type:')} ${removedAction.type}`);
|
||||||
if (removedAction.thresholds) {
|
if (removedAction.thresholds) {
|
||||||
logger.log(
|
logger.log(
|
||||||
` ${theme.dim('Thresholds:')} Battery: ${removedAction.thresholds.battery}%, Runtime: ${removedAction.thresholds.runtime}min`,
|
` ${
|
||||||
|
theme.dim('Thresholds:')
|
||||||
|
} Battery: ${removedAction.thresholds.battery}%, Runtime: ${removedAction.thresholds.runtime}min`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
logger.log(` ${theme.dim('Changes saved and will be applied automatically')}`);
|
logger.log(` ${theme.dim('Changes saved and will be applied automatically')}`);
|
||||||
@@ -263,8 +262,12 @@ export class ActionHandler {
|
|||||||
if (!ups && !group) {
|
if (!ups && !group) {
|
||||||
logger.error(`UPS or Group with ID '${targetId}' not found`);
|
logger.error(`UPS or Group with ID '${targetId}' not found`);
|
||||||
logger.log('');
|
logger.log('');
|
||||||
logger.log(` ${theme.dim('List available UPS devices:')} ${theme.command('nupst ups list')}`);
|
logger.log(
|
||||||
logger.log(` ${theme.dim('List available groups:')} ${theme.command('nupst group list')}`);
|
` ${theme.dim('List available UPS devices:')} ${theme.command('nupst ups list')}`,
|
||||||
|
);
|
||||||
|
logger.log(
|
||||||
|
` ${theme.dim('List available groups:')} ${theme.command('nupst group list')}`,
|
||||||
|
);
|
||||||
logger.log('');
|
logger.log('');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
@@ -302,7 +305,9 @@ export class ActionHandler {
|
|||||||
logger.log(` ${theme.dim('No actions configured')}`);
|
logger.log(` ${theme.dim('No actions configured')}`);
|
||||||
logger.log('');
|
logger.log('');
|
||||||
logger.log(
|
logger.log(
|
||||||
` ${theme.dim('Add an action:')} ${theme.command('nupst action add <ups-id|group-id>')}`,
|
` ${theme.dim('Add an action:')} ${
|
||||||
|
theme.command('nupst action add <ups-id|group-id>')
|
||||||
|
}`,
|
||||||
);
|
);
|
||||||
logger.log('');
|
logger.log('');
|
||||||
}
|
}
|
||||||
@@ -323,7 +328,9 @@ export class ActionHandler {
|
|||||||
targetType: 'UPS' | 'Group',
|
targetType: 'UPS' | 'Group',
|
||||||
): void {
|
): void {
|
||||||
logger.log(
|
logger.log(
|
||||||
`${symbols.info} ${targetType} ${theme.highlight(target.name)} ${theme.dim(`(${target.id})`)}`,
|
`${symbols.info} ${targetType} ${theme.highlight(target.name)} ${
|
||||||
|
theme.dim(`(${target.id})`)
|
||||||
|
}`,
|
||||||
);
|
);
|
||||||
logger.log('');
|
logger.log('');
|
||||||
|
|
||||||
|
|||||||
192
ts/cli/feature-handler.ts
Normal file
192
ts/cli/feature-handler.ts
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import { execSync } from 'node:child_process';
|
||||||
|
import { Nupst } from '../nupst.ts';
|
||||||
|
import { logger } from '../logger.ts';
|
||||||
|
import { theme } from '../colors.ts';
|
||||||
|
import * as helpers from '../helpers/index.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class for handling feature-related CLI commands
|
||||||
|
* Provides interface for managing optional features like HTTP server
|
||||||
|
*/
|
||||||
|
export class FeatureHandler {
|
||||||
|
private readonly nupst: Nupst;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new feature handler
|
||||||
|
* @param nupst Reference to the main Nupst instance
|
||||||
|
*/
|
||||||
|
constructor(nupst: Nupst) {
|
||||||
|
this.nupst = nupst;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure HTTP server feature
|
||||||
|
*/
|
||||||
|
public async configureHttpServer(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await helpers.withPrompt(async (prompt) => {
|
||||||
|
await this.runHttpServerConfig(prompt);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`HTTP Server config error: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the interactive HTTP server configuration process
|
||||||
|
* @param prompt Function to prompt for user input
|
||||||
|
*/
|
||||||
|
private async runHttpServerConfig(prompt: (question: string) => Promise<string>): Promise<void> {
|
||||||
|
logger.log('');
|
||||||
|
logger.logBoxTitle('HTTP Server Feature Configuration', 60);
|
||||||
|
logger.logBoxLine('Configure the HTTP server to expose UPS status as JSON');
|
||||||
|
logger.logBoxEnd();
|
||||||
|
logger.log('');
|
||||||
|
|
||||||
|
// Load config
|
||||||
|
let config;
|
||||||
|
try {
|
||||||
|
await this.nupst.getDaemon().loadConfig();
|
||||||
|
config = this.nupst.getDaemon().getConfig();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('No configuration found. Please run "nupst ups add" first.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show current status
|
||||||
|
if (config.httpServer?.enabled) {
|
||||||
|
logger.info('HTTP Server is currently: ' + theme.success('ENABLED'));
|
||||||
|
logger.log(` Port: ${theme.highlight(String(config.httpServer.port))}`);
|
||||||
|
logger.log(` Path: ${theme.highlight(config.httpServer.path)}`);
|
||||||
|
logger.log(` Auth Token: ${theme.dim('***' + config.httpServer.authToken.slice(-4))}`);
|
||||||
|
logger.log('');
|
||||||
|
} else {
|
||||||
|
logger.info('HTTP Server is currently: ' + theme.dim('DISABLED'));
|
||||||
|
logger.log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ask enable/disable
|
||||||
|
const action = await prompt('Enable or disable HTTP server? (enable/disable/cancel): ');
|
||||||
|
|
||||||
|
if (action.toLowerCase() === 'cancel' || action.toLowerCase() === 'c') {
|
||||||
|
logger.log('Cancelled.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action.toLowerCase() === 'disable' || action.toLowerCase() === 'd') {
|
||||||
|
// Disable HTTP server
|
||||||
|
config.httpServer = {
|
||||||
|
enabled: false,
|
||||||
|
port: config.httpServer?.port || 8080,
|
||||||
|
path: config.httpServer?.path || '/ups-status',
|
||||||
|
authToken: config.httpServer?.authToken || '',
|
||||||
|
};
|
||||||
|
|
||||||
|
this.nupst.getDaemon().saveConfig(config);
|
||||||
|
|
||||||
|
logger.log('');
|
||||||
|
logger.success('HTTP Server disabled');
|
||||||
|
logger.log('');
|
||||||
|
|
||||||
|
await this.restartServiceIfRunning();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action.toLowerCase() !== 'enable' && action.toLowerCase() !== 'e') {
|
||||||
|
logger.error('Invalid option. Please enter "enable", "disable", or "cancel".');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable - gather configuration
|
||||||
|
logger.log('');
|
||||||
|
|
||||||
|
const portInput = await prompt(`HTTP Server Port [${config.httpServer?.port || 8080}]: `);
|
||||||
|
const port = portInput ? parseInt(portInput, 10) : (config.httpServer?.port || 8080);
|
||||||
|
|
||||||
|
if (isNaN(port) || port < 1 || port > 65535) {
|
||||||
|
logger.error('Invalid port number. Must be between 1 and 65535.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathInput = await prompt(`URL Path [${config.httpServer?.path || '/ups-status'}]: `);
|
||||||
|
const path = pathInput || config.httpServer?.path || '/ups-status';
|
||||||
|
|
||||||
|
// Ensure path starts with /
|
||||||
|
const finalPath = path.startsWith('/') ? path : `/${path}`;
|
||||||
|
|
||||||
|
// Generate or reuse auth token
|
||||||
|
let authToken = config.httpServer?.authToken;
|
||||||
|
if (!authToken) {
|
||||||
|
// Generate new random token
|
||||||
|
authToken = helpers.shortId() + helpers.shortId() + helpers.shortId();
|
||||||
|
logger.log('');
|
||||||
|
logger.info('Generated new authentication token');
|
||||||
|
} else {
|
||||||
|
const regenerate = await prompt('Regenerate authentication token? (y/N): ');
|
||||||
|
if (regenerate.toLowerCase() === 'y' || regenerate.toLowerCase() === 'yes') {
|
||||||
|
authToken = helpers.shortId() + helpers.shortId() + helpers.shortId();
|
||||||
|
logger.info('Generated new authentication token');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save configuration
|
||||||
|
config.httpServer = {
|
||||||
|
enabled: true,
|
||||||
|
port,
|
||||||
|
path: finalPath,
|
||||||
|
authToken,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.nupst.getDaemon().saveConfig(config);
|
||||||
|
|
||||||
|
// Display summary
|
||||||
|
logger.log('');
|
||||||
|
logger.logBoxTitle('HTTP Server Configuration', 70, 'success');
|
||||||
|
logger.logBoxLine(`Status: ${theme.success('ENABLED')}`);
|
||||||
|
logger.logBoxLine(`Port: ${theme.highlight(String(port))}`);
|
||||||
|
logger.logBoxLine(`Path: ${theme.highlight(finalPath)}`);
|
||||||
|
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 "http://localhost:${port}${finalPath}?token=${authToken}"`);
|
||||||
|
logger.logBoxEnd();
|
||||||
|
logger.log('');
|
||||||
|
|
||||||
|
logger.warn('IMPORTANT: Save the authentication token securely!');
|
||||||
|
logger.log('');
|
||||||
|
|
||||||
|
await this.restartServiceIfRunning();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restart the service if it's currently running
|
||||||
|
*/
|
||||||
|
private async restartServiceIfRunning(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const isActive =
|
||||||
|
execSync('systemctl is-active nupst.service || true').toString().trim() === 'active';
|
||||||
|
|
||||||
|
if (isActive) {
|
||||||
|
logger.log('');
|
||||||
|
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...');
|
||||||
|
execSync('sudo systemctl restart nupst.service');
|
||||||
|
logger.success('Service restarted successfully');
|
||||||
|
} else {
|
||||||
|
logger.warn('Changes will take effect on next service restart');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore errors - service might not be installed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
import process from 'node:process';
|
|
||||||
import { Nupst } from '../nupst.ts';
|
import { Nupst } from '../nupst.ts';
|
||||||
import { logger, type ITableColumn } from '../logger.ts';
|
import { type ITableColumn, logger } from '../logger.ts';
|
||||||
import { theme } from '../colors.ts';
|
import { theme } from '../colors.ts';
|
||||||
import * as helpers from '../helpers/index.ts';
|
import * as helpers from '../helpers/index.ts';
|
||||||
import { type IGroupConfig } from '../daemon.ts';
|
import type { IGroupConfig, INupstConfig, IUpsConfig } from '../daemon.ts';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Class for handling group-related CLI commands
|
* Class for handling group-related CLI commands
|
||||||
@@ -29,10 +28,15 @@ export class GroupHandler {
|
|||||||
try {
|
try {
|
||||||
await this.nupst.getDaemon().loadConfig();
|
await this.nupst.getDaemon().loadConfig();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.logBox('Configuration Error', [
|
logger.logBox(
|
||||||
'No configuration found.',
|
'Configuration Error',
|
||||||
"Please run 'nupst ups add' first to create a configuration.",
|
[
|
||||||
], 50, 'error');
|
'No configuration found.',
|
||||||
|
"Please run 'nupst ups add' first to create a configuration.",
|
||||||
|
],
|
||||||
|
50,
|
||||||
|
'error',
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,21 +45,35 @@ export class GroupHandler {
|
|||||||
|
|
||||||
// Check if multi-UPS config
|
// Check if multi-UPS config
|
||||||
if (!config.groups || !Array.isArray(config.groups)) {
|
if (!config.groups || !Array.isArray(config.groups)) {
|
||||||
logger.logBox('UPS Groups', [
|
logger.logBox(
|
||||||
'No groups configured.',
|
'UPS Groups',
|
||||||
'',
|
[
|
||||||
`${theme.dim('Run')} ${theme.command('nupst group add')} ${theme.dim('to add a group')}`,
|
'No groups configured.',
|
||||||
], 50, 'info');
|
'',
|
||||||
|
`${theme.dim('Run')} ${theme.command('nupst group add')} ${
|
||||||
|
theme.dim('to add a group')
|
||||||
|
}`,
|
||||||
|
],
|
||||||
|
50,
|
||||||
|
'info',
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Display group list with modern table
|
// Display group list with modern table
|
||||||
if (config.groups.length === 0) {
|
if (config.groups.length === 0) {
|
||||||
logger.logBox('UPS Groups', [
|
logger.logBox(
|
||||||
'No UPS groups configured.',
|
'UPS Groups',
|
||||||
'',
|
[
|
||||||
`${theme.dim('Run')} ${theme.command('nupst group add')} ${theme.dim('to add a group')}`,
|
'No UPS groups configured.',
|
||||||
], 60, 'info');
|
'',
|
||||||
|
`${theme.dim('Run')} ${theme.command('nupst group add')} ${
|
||||||
|
theme.dim('to add a group')
|
||||||
|
}`,
|
||||||
|
],
|
||||||
|
60,
|
||||||
|
'info',
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,24 +118,7 @@ export class GroupHandler {
|
|||||||
*/
|
*/
|
||||||
public async add(): Promise<void> {
|
public async add(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Import readline module for user input
|
await helpers.withPrompt(async (prompt) => {
|
||||||
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 {
|
|
||||||
// Try to load configuration
|
// Try to load configuration
|
||||||
try {
|
try {
|
||||||
await this.nupst.getDaemon().loadConfig();
|
await this.nupst.getDaemon().loadConfig();
|
||||||
@@ -200,10 +201,7 @@ export class GroupHandler {
|
|||||||
this.nupst.getUpsHandler().restartServiceIfRunning();
|
this.nupst.getUpsHandler().restartServiceIfRunning();
|
||||||
|
|
||||||
logger.log('\nGroup setup complete!');
|
logger.log('\nGroup setup complete!');
|
||||||
} finally {
|
});
|
||||||
rl.close();
|
|
||||||
process.stdin.destroy();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Add group error: ${error instanceof Error ? error.message : String(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> {
|
public async edit(groupId: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Import readline module for user input
|
await helpers.withPrompt(async (prompt) => {
|
||||||
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 {
|
|
||||||
// Try to load configuration
|
// Try to load configuration
|
||||||
try {
|
try {
|
||||||
await this.nupst.getDaemon().loadConfig();
|
await this.nupst.getDaemon().loadConfig();
|
||||||
@@ -318,10 +299,7 @@ export class GroupHandler {
|
|||||||
this.nupst.getUpsHandler().restartServiceIfRunning();
|
this.nupst.getUpsHandler().restartServiceIfRunning();
|
||||||
|
|
||||||
logger.log('\nGroup edit complete!');
|
logger.log('\nGroup edit complete!');
|
||||||
} finally {
|
});
|
||||||
rl.close();
|
|
||||||
process.stdin.destroy();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Edit group error: ${error instanceof Error ? error.message : String(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];
|
const groupToDelete = config.groups[groupIndex];
|
||||||
|
|
||||||
// Get confirmation before deleting
|
// Get confirmation before deleting
|
||||||
const readline = await import('node:readline');
|
const { prompt, close } = await helpers.createPrompt();
|
||||||
const rl = readline.createInterface({
|
const confirm = (await prompt(
|
||||||
input: process.stdin,
|
`Are you sure you want to delete group "${groupToDelete.name}" (${groupId})? [y/N]: `,
|
||||||
output: process.stdout,
|
)).toLowerCase();
|
||||||
});
|
close();
|
||||||
|
|
||||||
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();
|
|
||||||
|
|
||||||
if (confirm !== 'y' && confirm !== 'yes') {
|
if (confirm !== 'y' && confirm !== 'yes') {
|
||||||
logger.log('Deletion cancelled.');
|
logger.log('Deletion cancelled.');
|
||||||
@@ -419,8 +385,8 @@ export class GroupHandler {
|
|||||||
* @param prompt Function to prompt for user input
|
* @param prompt Function to prompt for user input
|
||||||
*/
|
*/
|
||||||
public async assignUpsToGroups(
|
public async assignUpsToGroups(
|
||||||
ups: any,
|
ups: IUpsConfig,
|
||||||
groups: any[],
|
groups: IGroupConfig[],
|
||||||
prompt: (question: string) => Promise<string>,
|
prompt: (question: string) => Promise<string>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Initialize groups array if it doesn't exist
|
// Initialize groups array if it doesn't exist
|
||||||
@@ -514,7 +480,7 @@ export class GroupHandler {
|
|||||||
*/
|
*/
|
||||||
public async assignUpsToGroup(
|
public async assignUpsToGroup(
|
||||||
groupId: string,
|
groupId: string,
|
||||||
config: any,
|
config: INupstConfig,
|
||||||
prompt: (question: string) => Promise<string>,
|
prompt: (question: string) => Promise<string>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!config.upsDevices || config.upsDevices.length === 0) {
|
if (!config.upsDevices || config.upsDevices.length === 0) {
|
||||||
@@ -522,7 +488,7 @@ export class GroupHandler {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const group = config.groups.find((g: { id: string }) => g.id === groupId);
|
const group = config.groups.find((g) => g.id === groupId);
|
||||||
if (!group) {
|
if (!group) {
|
||||||
logger.error(`Group with ID "${groupId}" not found.`);
|
logger.error(`Group with ID "${groupId}" not found.`);
|
||||||
return;
|
return;
|
||||||
@@ -530,7 +496,7 @@ export class GroupHandler {
|
|||||||
|
|
||||||
// Show current assignments
|
// Show current assignments
|
||||||
logger.log(`\nUPS devices in group "${group.name}" (${group.id}):`);
|
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)
|
ups.groups && ups.groups.includes(groupId)
|
||||||
);
|
);
|
||||||
if (upsInGroup.length === 0) {
|
if (upsInGroup.length === 0) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import process from 'node:process';
|
|||||||
import { execSync } from 'node:child_process';
|
import { execSync } from 'node:child_process';
|
||||||
import { Nupst } from '../nupst.ts';
|
import { Nupst } from '../nupst.ts';
|
||||||
import { logger } from '../logger.ts';
|
import { logger } from '../logger.ts';
|
||||||
|
import * as helpers from '../helpers/index.ts';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Class for handling service-related CLI commands
|
* Class for handling service-related CLI commands
|
||||||
@@ -125,7 +126,7 @@ export class ServiceHandler {
|
|||||||
/**
|
/**
|
||||||
* Update NUPST from repository and refresh systemd service
|
* Update NUPST from repository and refresh systemd service
|
||||||
*/
|
*/
|
||||||
public async update(): Promise<void> {
|
public update(): void {
|
||||||
try {
|
try {
|
||||||
// Check if running as root
|
// Check if running as root
|
||||||
this.checkRootAccess(
|
this.checkRootAccess(
|
||||||
@@ -146,8 +147,12 @@ export class ServiceHandler {
|
|||||||
const latestVersion = release.tag_name; // e.g., "v4.0.7"
|
const latestVersion = release.tag_name; // e.g., "v4.0.7"
|
||||||
|
|
||||||
// Normalize versions for comparison (ensure both have "v" prefix)
|
// Normalize versions for comparison (ensure both have "v" prefix)
|
||||||
const normalizedCurrent = currentVersion.startsWith('v') ? currentVersion : `v${currentVersion}`;
|
const normalizedCurrent = currentVersion.startsWith('v')
|
||||||
const normalizedLatest = latestVersion.startsWith('v') ? latestVersion : `v${latestVersion}`;
|
? currentVersion
|
||||||
|
: `v${currentVersion}`;
|
||||||
|
const normalizedLatest = latestVersion.startsWith('v')
|
||||||
|
? latestVersion
|
||||||
|
: `v${latestVersion}`;
|
||||||
|
|
||||||
logger.dim(`Current version: ${normalizedCurrent}`);
|
logger.dim(`Current version: ${normalizedCurrent}`);
|
||||||
logger.dim(`Latest version: ${normalizedLatest}`);
|
logger.dim(`Latest version: ${normalizedLatest}`);
|
||||||
@@ -196,22 +201,7 @@ export class ServiceHandler {
|
|||||||
this.checkRootAccess('This command must be run as root.');
|
this.checkRootAccess('This command must be run as root.');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Import readline module for user input
|
const { prompt, close } = await helpers.createPrompt();
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
logger.log('');
|
logger.log('');
|
||||||
logger.highlight('NUPST Uninstaller');
|
logger.highlight('NUPST Uninstaller');
|
||||||
@@ -254,15 +244,13 @@ export class ServiceHandler {
|
|||||||
|
|
||||||
if (!uninstallScriptPath) {
|
if (!uninstallScriptPath) {
|
||||||
logger.error('Could not locate uninstall.sh script. Aborting uninstall.');
|
logger.error('Could not locate uninstall.sh script. Aborting uninstall.');
|
||||||
rl.close();
|
close();
|
||||||
process.stdin.destroy();
|
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close readline before executing script
|
// Close prompt before executing script
|
||||||
rl.close();
|
close();
|
||||||
process.stdin.destroy();
|
|
||||||
|
|
||||||
// Execute uninstall.sh with the appropriate option
|
// Execute uninstall.sh with the appropriate option
|
||||||
logger.log('');
|
logger.log('');
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
import { execSync } from 'node:child_process';
|
import { execSync } from 'node:child_process';
|
||||||
import { Nupst } from '../nupst.ts';
|
import { Nupst } from '../nupst.ts';
|
||||||
import { logger, type ITableColumn } from '../logger.ts';
|
import { type ITableColumn, logger } from '../logger.ts';
|
||||||
import { theme } from '../colors.ts';
|
import { theme } from '../colors.ts';
|
||||||
import * as helpers from '../helpers/index.ts';
|
import * as helpers from '../helpers/index.ts';
|
||||||
import type { TUpsModel } from '../snmp/types.ts';
|
import type { ISnmpConfig, IUpsStatus as ISnmpUpsStatus, TUpsModel } from '../snmp/types.ts';
|
||||||
import type { INupstConfig } from '../daemon.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
|
* Class for handling UPS-related CLI commands
|
||||||
@@ -27,29 +36,9 @@ export class UpsHandler {
|
|||||||
*/
|
*/
|
||||||
public async add(): Promise<void> {
|
public async add(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Import readline module for user input
|
await helpers.withPrompt(async (prompt) => {
|
||||||
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 this.runAddProcess(prompt);
|
await this.runAddProcess(prompt);
|
||||||
} finally {
|
});
|
||||||
rl.close();
|
|
||||||
process.stdin.destroy();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Add UPS error: ${error instanceof Error ? error.message : String(error)}`);
|
logger.error(`Add UPS error: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
}
|
}
|
||||||
@@ -77,10 +66,10 @@ export class UpsHandler {
|
|||||||
checkInterval: config.checkInterval,
|
checkInterval: config.checkInterval,
|
||||||
upsDevices: [{
|
upsDevices: [{
|
||||||
id: 'default',
|
id: 'default',
|
||||||
name: 'Default UPS',
|
name: 'Default UPS',
|
||||||
snmp: config.snmp,
|
snmp: config.snmp,
|
||||||
groups: [],
|
groups: [],
|
||||||
actions: [],
|
actions: [],
|
||||||
}],
|
}],
|
||||||
groups: [],
|
groups: [],
|
||||||
};
|
};
|
||||||
@@ -134,7 +123,7 @@ export class UpsHandler {
|
|||||||
await groupHandler.assignUpsToGroups(newUps, config.groups, prompt);
|
await groupHandler.assignUpsToGroups(newUps, config.groups, prompt);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gather action settings
|
// Gather action settings
|
||||||
await this.gatherActionSettings(newUps.actions, prompt);
|
await this.gatherActionSettings(newUps.actions, prompt);
|
||||||
|
|
||||||
// Add the new UPS to the config
|
// Add the new UPS to the config
|
||||||
@@ -160,29 +149,9 @@ export class UpsHandler {
|
|||||||
*/
|
*/
|
||||||
public async edit(upsId?: string): Promise<void> {
|
public async edit(upsId?: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Import readline module for user input
|
await helpers.withPrompt(async (prompt) => {
|
||||||
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 this.runEditProcess(upsId, prompt);
|
await this.runEditProcess(upsId, prompt);
|
||||||
} finally {
|
});
|
||||||
rl.close();
|
|
||||||
process.stdin.destroy();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Edit UPS error: ${error instanceof Error ? error.message : String(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];
|
const upsToDelete = config.upsDevices[upsIndex];
|
||||||
|
|
||||||
// Get confirmation before deleting
|
// Get confirmation before deleting
|
||||||
const readline = await import('node:readline');
|
const { prompt, close } = await helpers.createPrompt();
|
||||||
const rl = readline.createInterface({
|
const confirm = (await prompt(
|
||||||
input: process.stdin,
|
`Are you sure you want to delete UPS "${upsToDelete.name}" (${upsId})? [y/N]: `,
|
||||||
output: process.stdout,
|
)).toLowerCase();
|
||||||
});
|
close();
|
||||||
|
|
||||||
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();
|
|
||||||
|
|
||||||
if (confirm !== 'y' && confirm !== 'yes') {
|
if (confirm !== 'y' && confirm !== 'yes') {
|
||||||
logger.log('Deletion cancelled.');
|
logger.log('Deletion cancelled.');
|
||||||
@@ -386,10 +343,15 @@ export class UpsHandler {
|
|||||||
try {
|
try {
|
||||||
await this.nupst.getDaemon().loadConfig();
|
await this.nupst.getDaemon().loadConfig();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.logBox('Configuration Error', [
|
logger.logBox(
|
||||||
'No configuration found.',
|
'Configuration Error',
|
||||||
"Please run 'nupst ups add' first to create a configuration.",
|
[
|
||||||
], 50, 'error');
|
'No configuration found.',
|
||||||
|
"Please run 'nupst ups add' first to create a configuration.",
|
||||||
|
],
|
||||||
|
50,
|
||||||
|
'error',
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -399,31 +361,38 @@ export class UpsHandler {
|
|||||||
// Check if multi-UPS config
|
// Check if multi-UPS config
|
||||||
if (!config.upsDevices || !Array.isArray(config.upsDevices)) {
|
if (!config.upsDevices || !Array.isArray(config.upsDevices)) {
|
||||||
// Legacy single UPS configuration
|
// Legacy single UPS configuration
|
||||||
logger.logBox('UPS Devices', [
|
logger.logBox(
|
||||||
'Legacy single-UPS configuration detected.',
|
'UPS Devices',
|
||||||
'',
|
[
|
||||||
...(!config.snmp
|
'Legacy single-UPS configuration detected.',
|
||||||
? ['Error: Configuration missing SNMP settings']
|
'',
|
||||||
: [
|
...(!config.snmp ? ['Error: Configuration missing SNMP settings'] : [
|
||||||
'Default UPS:',
|
'Default UPS:',
|
||||||
` Host: ${config.snmp.host}:${config.snmp.port}`,
|
` Host: ${config.snmp.host}:${config.snmp.port}`,
|
||||||
` Model: ${config.snmp.upsModel || 'cyberpower'}`,
|
` Model: ${config.snmp.upsModel || 'cyberpower'}`,
|
||||||
'',
|
'',
|
||||||
'Use "nupst ups add" to add more UPS devices and migrate',
|
'Use "nupst ups add" to add more UPS devices and migrate',
|
||||||
'to the multi-UPS configuration format.',
|
'to the multi-UPS configuration format.',
|
||||||
]
|
]),
|
||||||
),
|
],
|
||||||
], 60, 'warning');
|
60,
|
||||||
|
'warning',
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Display UPS list with modern table
|
// Display UPS list with modern table
|
||||||
if (config.upsDevices.length === 0) {
|
if (config.upsDevices.length === 0) {
|
||||||
logger.logBox('UPS Devices', [
|
logger.logBox(
|
||||||
'No UPS devices configured.',
|
'UPS Devices',
|
||||||
'',
|
[
|
||||||
`${theme.dim('Run')} ${theme.command('nupst ups add')} ${theme.dim('to add a device')}`,
|
'No UPS devices configured.',
|
||||||
], 60, 'info');
|
'',
|
||||||
|
`${theme.dim('Run')} ${theme.command('nupst ups add')} ${theme.dim('to add a device')}`,
|
||||||
|
],
|
||||||
|
60,
|
||||||
|
'info',
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -509,19 +478,28 @@ export class UpsHandler {
|
|||||||
* Display the configuration for testing
|
* Display the configuration for testing
|
||||||
* @param config Current configuration or individual UPS configuration
|
* @param config Current configuration or individual UPS configuration
|
||||||
*/
|
*/
|
||||||
private displayTestConfig(config: any): void {
|
private displayTestConfig(config: IUpsConfig | INupstConfig): void {
|
||||||
// Check if this is a UPS device or full configuration
|
// Type guard: IUpsConfig has 'id' and 'name' at root level, INupstConfig doesn't
|
||||||
const isUpsConfig = config.snmp;
|
const isUpsConfig = 'id' in config && 'name' in config;
|
||||||
const snmpConfig = isUpsConfig ? config.snmp : config.snmp || {};
|
|
||||||
const checkInterval = config.checkInterval || 30000;
|
|
||||||
|
|
||||||
// Get UPS name and ID if available
|
// Get SNMP config and other values based on config type
|
||||||
const upsName = config.name ? config.name : 'Default UPS';
|
const snmpConfig: ISnmpConfig | undefined = isUpsConfig
|
||||||
const upsId = config.id ? config.id : 'default';
|
? (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;
|
const boxWidth = 45;
|
||||||
logger.logBoxTitle(`Testing Configuration: ${upsName}`, boxWidth);
|
logger.logBoxTitle(`Testing Configuration: ${upsName}`, boxWidth);
|
||||||
logger.logBoxLine(`UPS ID: ${upsId}`);
|
logger.logBoxLine(`UPS ID: ${upsId}`);
|
||||||
|
|
||||||
|
if (!snmpConfig) {
|
||||||
|
logger.logBoxLine('SNMP Settings: Not configured');
|
||||||
|
logger.logBoxEnd();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
logger.logBoxLine('SNMP Settings:');
|
logger.logBoxLine('SNMP Settings:');
|
||||||
logger.logBoxLine(` Host: ${snmpConfig.host}`);
|
logger.logBoxLine(` Host: ${snmpConfig.host}`);
|
||||||
logger.logBoxLine(` Port: ${snmpConfig.port}`);
|
logger.logBoxLine(` Port: ${snmpConfig.port}`);
|
||||||
@@ -557,9 +535,10 @@ export class UpsHandler {
|
|||||||
logger.logBoxLine(` Battery Runtime: ${snmpConfig.customOIDs.BATTERY_RUNTIME || 'Not set'}`);
|
logger.logBoxLine(` Battery Runtime: ${snmpConfig.customOIDs.BATTERY_RUNTIME || 'Not set'}`);
|
||||||
}
|
}
|
||||||
// Show group assignments if this is a UPS config
|
// Show group assignments if this is a UPS config
|
||||||
if (config.groups && Array.isArray(config.groups)) {
|
if (isUpsConfig) {
|
||||||
|
const groups = (config as IUpsConfig).groups;
|
||||||
logger.logBoxLine(
|
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
|
* Test connection to the UPS
|
||||||
* @param config Current UPS configuration or legacy config
|
* @param config Current UPS configuration or legacy config
|
||||||
*/
|
*/
|
||||||
private async testConnection(config: any): Promise<void> {
|
private async testConnection(config: IUpsConfig | INupstConfig): Promise<void> {
|
||||||
const upsId = config.id || 'default';
|
// Type guard: IUpsConfig has 'id' and 'name' at root level
|
||||||
const upsName = config.name || 'Default UPS';
|
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})...`);
|
logger.log(`\nTesting connection to UPS: ${upsName} (${upsId})...`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Create a test config with a short timeout
|
// Get SNMP config based on config type
|
||||||
const snmpConfig = config.snmp ? config.snmp : config.snmp;
|
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,
|
...snmpConfig,
|
||||||
timeout: Math.min(snmpConfig.timeout, 10000), // Use at most 10 seconds for testing
|
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(` Battery Capacity: ${status.batteryCapacity}%`);
|
||||||
logger.logBoxLine(` Runtime Remaining: ${status.batteryRuntime} minutes`);
|
logger.logBoxLine(` Runtime Remaining: ${status.batteryRuntime} minutes`);
|
||||||
logger.logBoxEnd();
|
logger.logBoxEnd();
|
||||||
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorBoxWidth = 45;
|
const errorBoxWidth = 45;
|
||||||
logger.logBoxTitle(`Connection Failed: ${upsName}`, errorBoxWidth);
|
logger.logBoxTitle(`Connection Failed: ${upsName}`, errorBoxWidth);
|
||||||
@@ -610,7 +595,7 @@ export class UpsHandler {
|
|||||||
* @param status UPS status
|
* @param status UPS status
|
||||||
* @param thresholds Threshold configuration
|
* @param thresholds Threshold configuration
|
||||||
*/
|
*/
|
||||||
private analyzeThresholds(status: any, thresholds: any): void {
|
private analyzeThresholds(status: ISnmpUpsStatus, thresholds: IThresholds): void {
|
||||||
const boxWidth = 45;
|
const boxWidth = 45;
|
||||||
logger.logBoxTitle('Threshold Analysis', boxWidth);
|
logger.logBoxTitle('Threshold Analysis', boxWidth);
|
||||||
|
|
||||||
@@ -649,7 +634,7 @@ export class UpsHandler {
|
|||||||
* @param prompt Function to prompt for user input
|
* @param prompt Function to prompt for user input
|
||||||
*/
|
*/
|
||||||
private async gatherSnmpSettings(
|
private async gatherSnmpSettings(
|
||||||
snmpConfig: any,
|
snmpConfig: Partial<ISnmpConfig>,
|
||||||
prompt: (question: string) => Promise<string>,
|
prompt: (question: string) => Promise<string>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// SNMP IP Address
|
// SNMP IP Address
|
||||||
@@ -693,7 +678,7 @@ export class UpsHandler {
|
|||||||
* @param prompt Function to prompt for user input
|
* @param prompt Function to prompt for user input
|
||||||
*/
|
*/
|
||||||
private async gatherSnmpV3Settings(
|
private async gatherSnmpV3Settings(
|
||||||
snmpConfig: any,
|
snmpConfig: Partial<ISnmpConfig>,
|
||||||
prompt: (question: string) => Promise<string>,
|
prompt: (question: string) => Promise<string>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
logger.log('');
|
logger.log('');
|
||||||
@@ -718,17 +703,17 @@ export class UpsHandler {
|
|||||||
if (secLevel === 1) {
|
if (secLevel === 1) {
|
||||||
snmpConfig.securityLevel = 'noAuthNoPriv';
|
snmpConfig.securityLevel = 'noAuthNoPriv';
|
||||||
// No auth, no priv - clear out authentication and privacy settings
|
// No auth, no priv - clear out authentication and privacy settings
|
||||||
snmpConfig.authProtocol = '';
|
snmpConfig.authProtocol = undefined;
|
||||||
snmpConfig.authKey = '';
|
snmpConfig.authKey = undefined;
|
||||||
snmpConfig.privProtocol = '';
|
snmpConfig.privProtocol = undefined;
|
||||||
snmpConfig.privKey = '';
|
snmpConfig.privKey = undefined;
|
||||||
// Set appropriate timeout for security level
|
// Set appropriate timeout for security level
|
||||||
snmpConfig.timeout = 5000; // 5 seconds for basic security
|
snmpConfig.timeout = 5000; // 5 seconds for basic security
|
||||||
} else if (secLevel === 2) {
|
} else if (secLevel === 2) {
|
||||||
snmpConfig.securityLevel = 'authNoPriv';
|
snmpConfig.securityLevel = 'authNoPriv';
|
||||||
// Auth, no priv - clear out privacy settings
|
// Auth, no priv - clear out privacy settings
|
||||||
snmpConfig.privProtocol = '';
|
snmpConfig.privProtocol = undefined;
|
||||||
snmpConfig.privKey = '';
|
snmpConfig.privKey = undefined;
|
||||||
// Set appropriate timeout for security level
|
// Set appropriate timeout for security level
|
||||||
snmpConfig.timeout = 10000; // 10 seconds for authentication
|
snmpConfig.timeout = 10000; // 10 seconds for authentication
|
||||||
} else {
|
} else {
|
||||||
@@ -771,7 +756,7 @@ export class UpsHandler {
|
|||||||
* @param prompt Function to prompt for user input
|
* @param prompt Function to prompt for user input
|
||||||
*/
|
*/
|
||||||
private async gatherAuthenticationSettings(
|
private async gatherAuthenticationSettings(
|
||||||
snmpConfig: any,
|
snmpConfig: Partial<ISnmpConfig>,
|
||||||
prompt: (question: string) => Promise<string>,
|
prompt: (question: string) => Promise<string>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Authentication protocol
|
// Authentication protocol
|
||||||
@@ -798,7 +783,7 @@ export class UpsHandler {
|
|||||||
* @param prompt Function to prompt for user input
|
* @param prompt Function to prompt for user input
|
||||||
*/
|
*/
|
||||||
private async gatherPrivacySettings(
|
private async gatherPrivacySettings(
|
||||||
snmpConfig: any,
|
snmpConfig: Partial<ISnmpConfig>,
|
||||||
prompt: (question: string) => Promise<string>,
|
prompt: (question: string) => Promise<string>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Privacy protocol
|
// Privacy protocol
|
||||||
@@ -823,7 +808,7 @@ export class UpsHandler {
|
|||||||
* @param prompt Function to prompt for user input
|
* @param prompt Function to prompt for user input
|
||||||
*/
|
*/
|
||||||
private async gatherUpsModelSettings(
|
private async gatherUpsModelSettings(
|
||||||
snmpConfig: any,
|
snmpConfig: Partial<ISnmpConfig>,
|
||||||
prompt: (question: string) => Promise<string>,
|
prompt: (question: string) => Promise<string>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
logger.log('');
|
logger.log('');
|
||||||
@@ -868,16 +853,21 @@ export class UpsHandler {
|
|||||||
logger.info('Enter custom OIDs for your UPS:');
|
logger.info('Enter custom OIDs for your UPS:');
|
||||||
logger.dim('(Leave blank to use standard RFC 1628 OIDs as fallback)');
|
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 powerStatusOID = await prompt('Power Status OID: ');
|
||||||
const batteryCapacityOID = await prompt('Battery Capacity OID: ');
|
const batteryCapacityOID = await prompt('Battery Capacity OID: ');
|
||||||
const batteryRuntimeOID = await prompt('Battery Runtime 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 = {
|
snmpConfig.customOIDs = {
|
||||||
POWER_STATUS: powerStatusOID.trim(),
|
POWER_STATUS: powerStatusOID.trim(),
|
||||||
BATTERY_CAPACITY: batteryCapacityOID.trim(),
|
BATTERY_CAPACITY: batteryCapacityOID.trim(),
|
||||||
BATTERY_RUNTIME: batteryRuntimeOID.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
|
* @param prompt Function to prompt for user input
|
||||||
*/
|
*/
|
||||||
private async gatherActionSettings(
|
private async gatherActionSettings(
|
||||||
actions: any[],
|
actions: IActionConfig[],
|
||||||
prompt: (question: string) => Promise<string>,
|
prompt: (question: string) => Promise<string>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
logger.log('');
|
logger.log('');
|
||||||
@@ -915,7 +905,7 @@ export class UpsHandler {
|
|||||||
const typeInput = await prompt('Select action type [1]: ');
|
const typeInput = await prompt('Select action type [1]: ');
|
||||||
const typeValue = parseInt(typeInput, 10) || 1;
|
const typeValue = parseInt(typeInput, 10) || 1;
|
||||||
|
|
||||||
const action: any = {};
|
const action: Partial<IActionConfig> = {};
|
||||||
|
|
||||||
if (typeValue === 1) {
|
if (typeValue === 1) {
|
||||||
// Shutdown action
|
// Shutdown action
|
||||||
@@ -995,10 +985,15 @@ export class UpsHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Configure thresholds if needed for onlyThresholds or powerChangesAndThresholds modes
|
// Configure thresholds if needed for onlyThresholds or powerChangesAndThresholds modes
|
||||||
if (action.triggerMode === 'onlyThresholds' || action.triggerMode === 'powerChangesAndThresholds') {
|
if (
|
||||||
|
action.triggerMode === 'onlyThresholds' ||
|
||||||
|
action.triggerMode === 'powerChangesAndThresholds'
|
||||||
|
) {
|
||||||
logger.log('');
|
logger.log('');
|
||||||
logger.info('Action Thresholds:');
|
logger.info('Action Thresholds:');
|
||||||
logger.dim('Action will trigger when battery or runtime falls below these values (while on battery)');
|
logger.dim(
|
||||||
|
'Action will trigger when battery or runtime falls below these values (while on battery)',
|
||||||
|
);
|
||||||
|
|
||||||
const batteryInput = await prompt('Battery threshold percentage [60]: ');
|
const batteryInput = await prompt('Battery threshold percentage [60]: ');
|
||||||
const battery = parseInt(batteryInput, 10);
|
const battery = parseInt(batteryInput, 10);
|
||||||
@@ -1014,8 +1009,12 @@ export class UpsHandler {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
actions.push(action);
|
actions.push(action as IActionConfig);
|
||||||
logger.success(`${action.type.charAt(0).toUpperCase() + action.type.slice(1)} action added (mode: ${action.triggerMode || 'powerChangesAndThresholds'})`);
|
logger.success(
|
||||||
|
`${action.type!.charAt(0).toUpperCase() + action.type!.slice(1)} action added (mode: ${
|
||||||
|
action.triggerMode || 'powerChangesAndThresholds'
|
||||||
|
})`,
|
||||||
|
);
|
||||||
|
|
||||||
const more = await prompt('Add another action? (y/N): ');
|
const more = await prompt('Add another action? (y/N): ');
|
||||||
addMore = more.toLowerCase() === 'y';
|
addMore = more.toLowerCase() === 'y';
|
||||||
@@ -1031,7 +1030,7 @@ export class UpsHandler {
|
|||||||
* Display UPS configuration summary
|
* Display UPS configuration summary
|
||||||
* @param ups UPS configuration
|
* @param ups UPS configuration
|
||||||
*/
|
*/
|
||||||
private displayUpsConfigSummary(ups: any): void {
|
private displayUpsConfigSummary(ups: IUpsConfig): void {
|
||||||
const boxWidth = 45;
|
const boxWidth = 45;
|
||||||
logger.log('');
|
logger.log('');
|
||||||
logger.logBoxTitle(`UPS Configuration: ${ups.name}`, boxWidth);
|
logger.logBoxTitle(`UPS Configuration: ${ups.name}`, boxWidth);
|
||||||
@@ -1055,7 +1054,7 @@ export class UpsHandler {
|
|||||||
* @param prompt Function to prompt for user input
|
* @param prompt Function to prompt for user input
|
||||||
*/
|
*/
|
||||||
private async optionallyTestConnection(
|
private async optionallyTestConnection(
|
||||||
snmpConfig: any,
|
snmpConfig: ISnmpConfig,
|
||||||
prompt: (question: string) => Promise<string>,
|
prompt: (question: string) => Promise<string>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const testConnection = await prompt(
|
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;
|
||||||
167
ts/daemon.ts
167
ts/daemon.ts
@@ -5,11 +5,13 @@ import { exec, execFile } from 'node:child_process';
|
|||||||
import { promisify } from 'node:util';
|
import { promisify } from 'node:util';
|
||||||
import { NupstSnmp } from './snmp/manager.ts';
|
import { NupstSnmp } from './snmp/manager.ts';
|
||||||
import type { ISnmpConfig, IUpsStatus as ISnmpUpsStatus } from './snmp/types.ts';
|
import type { ISnmpConfig, IUpsStatus as ISnmpUpsStatus } from './snmp/types.ts';
|
||||||
import { logger, type ITableColumn } from './logger.ts';
|
import { logger } from './logger.ts';
|
||||||
import { MigrationRunner } from './migrations/index.ts';
|
import { MigrationRunner } from './migrations/index.ts';
|
||||||
import { theme, symbols, getBatteryColor, getRuntimeColor, formatPowerStatus } from './colors.ts';
|
import { formatPowerStatus, getBatteryColor, getRuntimeColor, theme } from './colors.ts';
|
||||||
import type { IActionConfig } from './actions/base-action.ts';
|
import type { IActionConfig } from './actions/base-action.ts';
|
||||||
import { ActionManager, type IActionContext, type TPowerStatus } from './actions/index.ts';
|
import { ActionManager, type IActionContext, type TPowerStatus } from './actions/index.ts';
|
||||||
|
import { NupstHttpServer } from './http-server.ts';
|
||||||
|
import { THRESHOLDS, TIMING, UI } from './constants.ts';
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
@@ -46,6 +48,20 @@ export interface IGroupConfig {
|
|||||||
actions?: IActionConfig[];
|
actions?: IActionConfig[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP Server configuration interface
|
||||||
|
*/
|
||||||
|
export interface IHttpServerConfig {
|
||||||
|
/** Whether HTTP server is enabled */
|
||||||
|
enabled: boolean;
|
||||||
|
/** Port to listen on */
|
||||||
|
port: number;
|
||||||
|
/** URL path for the endpoint */
|
||||||
|
path: string;
|
||||||
|
/** Authentication token */
|
||||||
|
authToken: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configuration interface for the daemon
|
* Configuration interface for the daemon
|
||||||
*/
|
*/
|
||||||
@@ -58,6 +74,8 @@ export interface INupstConfig {
|
|||||||
groups: IGroupConfig[];
|
groups: IGroupConfig[];
|
||||||
/** Check interval in milliseconds */
|
/** Check interval in milliseconds */
|
||||||
checkInterval: number;
|
checkInterval: number;
|
||||||
|
/** HTTP Server configuration */
|
||||||
|
httpServer?: IHttpServerConfig;
|
||||||
|
|
||||||
// Legacy fields for backward compatibility (will be migrated away)
|
// Legacy fields for backward compatibility (will be migrated away)
|
||||||
/** UPS list (v3 format - legacy) */
|
/** UPS list (v3 format - legacy) */
|
||||||
@@ -82,6 +100,10 @@ export interface IUpsStatus {
|
|||||||
powerStatus: 'online' | 'onBattery' | 'unknown';
|
powerStatus: 'online' | 'onBattery' | 'unknown';
|
||||||
batteryCapacity: number;
|
batteryCapacity: number;
|
||||||
batteryRuntime: number;
|
batteryRuntime: number;
|
||||||
|
outputLoad: number; // Load percentage (0-100%)
|
||||||
|
outputPower: number; // Power in watts
|
||||||
|
outputVoltage: number; // Voltage in volts
|
||||||
|
outputCurrent: number; // Current in amps
|
||||||
lastStatusChange: number;
|
lastStatusChange: number;
|
||||||
lastCheckTime: number;
|
lastCheckTime: number;
|
||||||
}
|
}
|
||||||
@@ -123,8 +145,8 @@ export class NupstDaemon {
|
|||||||
type: 'shutdown',
|
type: 'shutdown',
|
||||||
triggerMode: 'onlyThresholds',
|
triggerMode: 'onlyThresholds',
|
||||||
thresholds: {
|
thresholds: {
|
||||||
battery: 60, // Shutdown when battery below 60%
|
battery: THRESHOLDS.DEFAULT_BATTERY_PERCENT, // Shutdown when battery below 60%
|
||||||
runtime: 20, // Shutdown when runtime below 20 minutes
|
runtime: THRESHOLDS.DEFAULT_RUNTIME_MINUTES, // Shutdown when runtime below 20 minutes
|
||||||
},
|
},
|
||||||
shutdownDelay: 5,
|
shutdownDelay: 5,
|
||||||
},
|
},
|
||||||
@@ -132,13 +154,14 @@ export class NupstDaemon {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
groups: [],
|
groups: [],
|
||||||
checkInterval: 30000, // Check every 30 seconds
|
checkInterval: TIMING.CHECK_INTERVAL_MS, // Check every 30 seconds
|
||||||
}
|
};
|
||||||
|
|
||||||
private config: INupstConfig;
|
private config: INupstConfig;
|
||||||
private snmp: NupstSnmp;
|
private snmp: NupstSnmp;
|
||||||
private isRunning: boolean = false;
|
private isRunning: boolean = false;
|
||||||
private upsStatus: Map<string, IUpsStatus> = new Map();
|
private upsStatus: Map<string, IUpsStatus> = new Map();
|
||||||
|
private httpServer?: NupstHttpServer;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new daemon instance with the given SNMP manager
|
* Create a new daemon instance with the given SNMP manager
|
||||||
@@ -226,7 +249,12 @@ export class NupstDaemon {
|
|||||||
* Helper method to log configuration errors consistently
|
* Helper method to log configuration errors consistently
|
||||||
*/
|
*/
|
||||||
private logConfigError(message: string): void {
|
private logConfigError(message: string): void {
|
||||||
logger.logBox('Configuration Error', [message, "Please run 'nupst setup' first to create a configuration."], 45, 'error');
|
logger.logBox(
|
||||||
|
'Configuration Error',
|
||||||
|
[message, "Please run 'nupst setup' first to create a configuration."],
|
||||||
|
45,
|
||||||
|
'error',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -260,24 +288,46 @@ export class NupstDaemon {
|
|||||||
this.logConfigLoaded();
|
this.logConfigLoaded();
|
||||||
|
|
||||||
// Log version information
|
// 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
|
// Check for updates in the background
|
||||||
this.snmp.getNupst().checkForUpdates().then((updateAvailable: boolean) => {
|
nupst.checkForUpdates().then((updateAvailable: boolean) => {
|
||||||
if (updateAvailable) {
|
if (updateAvailable) {
|
||||||
const updateStatus = this.snmp.getNupst().getUpdateStatus();
|
const updateStatus = nupst.getUpdateStatus();
|
||||||
const boxWidth = 45;
|
const boxWidth = 45;
|
||||||
logger.logBoxTitle('Update Available', boxWidth);
|
logger.logBoxTitle('Update Available', boxWidth);
|
||||||
logger.logBoxLine(`Current Version: ${updateStatus.currentVersion}`);
|
logger.logBoxLine(`Current Version: ${updateStatus.currentVersion}`);
|
||||||
logger.logBoxLine(`Latest Version: ${updateStatus.latestVersion}`);
|
logger.logBoxLine(`Latest Version: ${updateStatus.latestVersion}`);
|
||||||
logger.logBoxLine('Run "sudo nupst update" to update');
|
logger.logBoxLine('Run "sudo nupst update" to update');
|
||||||
logger.logBoxEnd();
|
logger.logBoxEnd();
|
||||||
}
|
}
|
||||||
}).catch(() => {}); // Ignore errors checking for updates
|
}).catch(() => {}); // Ignore errors checking for updates
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize UPS status tracking
|
// Initialize UPS status tracking
|
||||||
this.initializeUpsStatus();
|
this.initializeUpsStatus();
|
||||||
|
|
||||||
|
// Start HTTP server if configured
|
||||||
|
if (this.config.httpServer?.enabled && this.config.httpServer.authToken) {
|
||||||
|
try {
|
||||||
|
this.httpServer = new NupstHttpServer(
|
||||||
|
this.config.httpServer.port,
|
||||||
|
this.config.httpServer.path,
|
||||||
|
this.config.httpServer.authToken,
|
||||||
|
() => this.upsStatus,
|
||||||
|
);
|
||||||
|
this.httpServer.start();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`Failed to start HTTP server: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Start UPS monitoring
|
// Start UPS monitoring
|
||||||
this.isRunning = true;
|
this.isRunning = true;
|
||||||
await this.monitor();
|
await this.monitor();
|
||||||
@@ -304,6 +354,10 @@ export class NupstDaemon {
|
|||||||
powerStatus: 'unknown',
|
powerStatus: 'unknown',
|
||||||
batteryCapacity: 100,
|
batteryCapacity: 100,
|
||||||
batteryRuntime: 999, // High value as default
|
batteryRuntime: 999, // High value as default
|
||||||
|
outputLoad: 0,
|
||||||
|
outputPower: 0,
|
||||||
|
outputVoltage: 0,
|
||||||
|
outputCurrent: 0,
|
||||||
lastStatusChange: Date.now(),
|
lastStatusChange: Date.now(),
|
||||||
lastCheckTime: 0,
|
lastCheckTime: 0,
|
||||||
});
|
});
|
||||||
@@ -319,7 +373,6 @@ export class NupstDaemon {
|
|||||||
* Log the loaded configuration settings
|
* Log the loaded configuration settings
|
||||||
*/
|
*/
|
||||||
private logConfigLoaded(): void {
|
private logConfigLoaded(): void {
|
||||||
|
|
||||||
logger.log('');
|
logger.log('');
|
||||||
logger.logBoxTitle('Configuration Loaded', 70, 'success');
|
logger.logBoxTitle('Configuration Loaded', 70, 'success');
|
||||||
logger.logBoxLine(`Check Interval: ${this.config.checkInterval / 1000} seconds`);
|
logger.logBoxLine(`Check Interval: ${this.config.checkInterval / 1000} seconds`);
|
||||||
@@ -330,7 +383,9 @@ export class NupstDaemon {
|
|||||||
if (this.config.upsDevices && this.config.upsDevices.length > 0) {
|
if (this.config.upsDevices && this.config.upsDevices.length > 0) {
|
||||||
logger.info(`UPS Devices (${this.config.upsDevices.length}):`);
|
logger.info(`UPS Devices (${this.config.upsDevices.length}):`);
|
||||||
|
|
||||||
const upsColumns: Array<{ header: string; key: string; align?: 'left' | 'right'; color?: (val: string) => string }> = [
|
const upsColumns: Array<
|
||||||
|
{ header: string; key: string; align?: 'left' | 'right'; color?: (val: string) => string }
|
||||||
|
> = [
|
||||||
{ header: 'Name', key: 'name', align: 'left', color: theme.highlight },
|
{ header: 'Name', key: 'name', align: 'left', color: theme.highlight },
|
||||||
{ header: 'ID', key: 'id', align: 'left', color: theme.dim },
|
{ header: 'ID', key: 'id', align: 'left', color: theme.dim },
|
||||||
{ header: 'Host:Port', key: 'host', align: 'left', color: theme.info },
|
{ header: 'Host:Port', key: 'host', align: 'left', color: theme.info },
|
||||||
@@ -355,7 +410,9 @@ export class NupstDaemon {
|
|||||||
if (this.config.groups && this.config.groups.length > 0) {
|
if (this.config.groups && this.config.groups.length > 0) {
|
||||||
logger.info(`Groups (${this.config.groups.length}):`);
|
logger.info(`Groups (${this.config.groups.length}):`);
|
||||||
|
|
||||||
const groupColumns: Array<{ header: string; key: string; align?: 'left' | 'right'; color?: (val: string) => string }> = [
|
const groupColumns: Array<
|
||||||
|
{ header: string; key: string; align?: 'left' | 'right'; color?: (val: string) => string }
|
||||||
|
> = [
|
||||||
{ header: 'Name', key: 'name', align: 'left', color: theme.highlight },
|
{ header: 'Name', key: 'name', align: 'left', color: theme.highlight },
|
||||||
{ header: 'ID', key: 'id', align: 'left', color: theme.dim },
|
{ header: 'ID', key: 'id', align: 'left', color: theme.dim },
|
||||||
{ header: 'Mode', key: 'mode', align: 'left', color: theme.info },
|
{ header: 'Mode', key: 'mode', align: 'left', color: theme.info },
|
||||||
@@ -377,6 +434,12 @@ export class NupstDaemon {
|
|||||||
*/
|
*/
|
||||||
public stop(): void {
|
public stop(): void {
|
||||||
logger.log('Stopping NUPST daemon...');
|
logger.log('Stopping NUPST daemon...');
|
||||||
|
|
||||||
|
// Stop HTTP server if running
|
||||||
|
if (this.httpServer) {
|
||||||
|
this.httpServer.stop();
|
||||||
|
}
|
||||||
|
|
||||||
this.isRunning = false;
|
this.isRunning = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -394,7 +457,6 @@ export class NupstDaemon {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let lastLogTime = 0; // Track when we last logged status
|
let lastLogTime = 0; // Track when we last logged status
|
||||||
const LOG_INTERVAL = 5 * 60 * 1000; // Log at least every 5 minutes (300000ms)
|
|
||||||
|
|
||||||
// Monitor continuously
|
// Monitor continuously
|
||||||
while (this.isRunning) {
|
while (this.isRunning) {
|
||||||
@@ -404,7 +466,7 @@ export class NupstDaemon {
|
|||||||
|
|
||||||
// Log periodic status update
|
// Log periodic status update
|
||||||
const currentTime = Date.now();
|
const currentTime = Date.now();
|
||||||
if (currentTime - lastLogTime >= LOG_INTERVAL) {
|
if (currentTime - lastLogTime >= TIMING.LOG_INTERVAL_MS) {
|
||||||
this.logAllUpsStatus();
|
this.logAllUpsStatus();
|
||||||
lastLogTime = currentTime;
|
lastLogTime = currentTime;
|
||||||
}
|
}
|
||||||
@@ -437,6 +499,10 @@ export class NupstDaemon {
|
|||||||
powerStatus: 'unknown',
|
powerStatus: 'unknown',
|
||||||
batteryCapacity: 100,
|
batteryCapacity: 100,
|
||||||
batteryRuntime: 999,
|
batteryRuntime: 999,
|
||||||
|
outputLoad: 0,
|
||||||
|
outputPower: 0,
|
||||||
|
outputVoltage: 0,
|
||||||
|
outputCurrent: 0,
|
||||||
lastStatusChange: Date.now(),
|
lastStatusChange: Date.now(),
|
||||||
lastCheckTime: 0,
|
lastCheckTime: 0,
|
||||||
});
|
});
|
||||||
@@ -456,6 +522,10 @@ export class NupstDaemon {
|
|||||||
powerStatus: status.powerStatus,
|
powerStatus: status.powerStatus,
|
||||||
batteryCapacity: status.batteryCapacity,
|
batteryCapacity: status.batteryCapacity,
|
||||||
batteryRuntime: status.batteryRuntime,
|
batteryRuntime: status.batteryRuntime,
|
||||||
|
outputLoad: status.outputLoad,
|
||||||
|
outputPower: status.outputPower,
|
||||||
|
outputVoltage: status.outputVoltage,
|
||||||
|
outputCurrent: status.outputCurrent,
|
||||||
lastCheckTime: currentTime,
|
lastCheckTime: currentTime,
|
||||||
lastStatusChange: currentStatus?.lastStatusChange || currentTime,
|
lastStatusChange: currentStatus?.lastStatusChange || currentTime,
|
||||||
};
|
};
|
||||||
@@ -525,7 +595,9 @@ export class NupstDaemon {
|
|||||||
logger.log('');
|
logger.log('');
|
||||||
|
|
||||||
// Build table data
|
// Build table data
|
||||||
const columns: Array<{ header: string; key: string; align?: 'left' | 'right'; color?: (val: string) => string }> = [
|
const columns: Array<
|
||||||
|
{ header: string; key: string; align?: 'left' | 'right'; color?: (val: string) => string }
|
||||||
|
> = [
|
||||||
{ header: 'UPS Name', key: 'name', align: 'left', color: theme.highlight },
|
{ header: 'UPS Name', key: 'name', align: 'left', color: theme.highlight },
|
||||||
{ header: 'ID', key: 'id', align: 'left', color: theme.dim },
|
{ header: 'ID', key: 'id', align: 'left', color: theme.dim },
|
||||||
{ header: 'Power Status', key: 'powerStatus', align: 'left' },
|
{ header: 'Power Status', key: 'powerStatus', align: 'left' },
|
||||||
@@ -551,10 +623,6 @@ export class NupstDaemon {
|
|||||||
logger.log('');
|
logger.log('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build action context from UPS state
|
* Build action context from UPS state
|
||||||
* @param ups UPS configuration
|
* @param ups UPS configuration
|
||||||
@@ -734,26 +802,27 @@ export class NupstDaemon {
|
|||||||
* Force immediate shutdown if any UPS gets critically low
|
* Force immediate shutdown if any UPS gets critically low
|
||||||
*/
|
*/
|
||||||
private async monitorDuringShutdown(): Promise<void> {
|
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();
|
const startTime = Date.now();
|
||||||
|
|
||||||
logger.log('');
|
logger.log('');
|
||||||
logger.logBoxTitle('Shutdown Monitoring Active', 60, 'warning');
|
logger.logBoxTitle('Shutdown Monitoring Active', UI.WIDE_BOX_WIDTH, 'warning');
|
||||||
logger.logBoxLine(`Emergency threshold: ${EMERGENCY_RUNTIME_THRESHOLD} minutes runtime`);
|
logger.logBoxLine(
|
||||||
logger.logBoxLine(`Check interval: ${CHECK_INTERVAL / 1000} seconds`);
|
`Emergency threshold: ${THRESHOLDS.EMERGENCY_RUNTIME_MINUTES} minutes runtime`,
|
||||||
logger.logBoxLine(`Max monitoring time: ${MAX_MONITORING_TIME / 1000} seconds`);
|
);
|
||||||
|
logger.logBoxLine(`Check interval: ${TIMING.SHUTDOWN_CHECK_INTERVAL_MS / 1000} seconds`);
|
||||||
|
logger.logBoxLine(`Max monitoring time: ${TIMING.MAX_SHUTDOWN_MONITORING_MS / 1000} seconds`);
|
||||||
logger.logBoxEnd();
|
logger.logBoxEnd();
|
||||||
logger.log('');
|
logger.log('');
|
||||||
|
|
||||||
// Continue monitoring until max monitoring time is reached
|
// 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 {
|
try {
|
||||||
logger.info('Checking UPS status during shutdown...');
|
logger.info('Checking UPS status during shutdown...');
|
||||||
|
|
||||||
// Build table for UPS status during shutdown
|
// Build table for UPS status during shutdown
|
||||||
const columns: Array<{ header: string; key: string; align?: 'left' | 'right'; color?: (val: string) => string }> = [
|
const columns: Array<
|
||||||
|
{ header: string; key: string; align?: 'left' | 'right'; color?: (val: string) => string }
|
||||||
|
> = [
|
||||||
{ header: 'UPS Name', key: 'name', align: 'left', color: theme.highlight },
|
{ header: 'UPS Name', key: 'name', align: 'left', color: theme.highlight },
|
||||||
{ header: 'Battery', key: 'battery', align: 'right' },
|
{ header: 'Battery', key: 'battery', align: 'right' },
|
||||||
{ header: 'Runtime', key: 'runtime', align: 'right' },
|
{ header: 'Runtime', key: 'runtime', align: 'right' },
|
||||||
@@ -772,7 +841,7 @@ export class NupstDaemon {
|
|||||||
const batteryColor = getBatteryColor(status.batteryCapacity);
|
const batteryColor = getBatteryColor(status.batteryCapacity);
|
||||||
const runtimeColor = getRuntimeColor(status.batteryRuntime);
|
const runtimeColor = getRuntimeColor(status.batteryRuntime);
|
||||||
|
|
||||||
const isCritical = status.batteryRuntime < EMERGENCY_RUNTIME_THRESHOLD;
|
const isCritical = status.batteryRuntime < THRESHOLDS.EMERGENCY_RUNTIME_MINUTES;
|
||||||
|
|
||||||
rows.push({
|
rows.push({
|
||||||
name: ups.name,
|
name: ups.name,
|
||||||
@@ -813,7 +882,7 @@ export class NupstDaemon {
|
|||||||
logger.logBoxLine(
|
logger.logBoxLine(
|
||||||
`UPS ${emergencyUps.ups.name} runtime critically low: ${emergencyUps.status.batteryRuntime} minutes`,
|
`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.logBoxLine('Forcing immediate shutdown!');
|
||||||
logger.logBoxEnd();
|
logger.logBoxEnd();
|
||||||
logger.log('');
|
logger.log('');
|
||||||
@@ -824,14 +893,14 @@ export class NupstDaemon {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Wait before checking again
|
// Wait before checking again
|
||||||
await this.sleep(CHECK_INTERVAL);
|
await this.sleep(TIMING.SHUTDOWN_CHECK_INTERVAL_MS);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`Error monitoring UPS during shutdown: ${
|
`Error monitoring UPS during shutdown: ${
|
||||||
error instanceof Error ? error.message : String(error)
|
error instanceof Error ? error.message : String(error)
|
||||||
}`,
|
}`,
|
||||||
);
|
);
|
||||||
await this.sleep(CHECK_INTERVAL);
|
await this.sleep(TIMING.SHUTDOWN_CHECK_INTERVAL_MS);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -933,12 +1002,12 @@ export class NupstDaemon {
|
|||||||
* Watches for config changes and reloads when detected
|
* Watches for config changes and reloads when detected
|
||||||
*/
|
*/
|
||||||
private async idleMonitoring(): Promise<void> {
|
private async idleMonitoring(): Promise<void> {
|
||||||
const IDLE_CHECK_INTERVAL = 60000; // Check every 60 seconds
|
|
||||||
let lastConfigCheck = Date.now();
|
let lastConfigCheck = Date.now();
|
||||||
const CONFIG_CHECK_INTERVAL = 60000; // Check config every minute
|
|
||||||
|
|
||||||
logger.log('Entering idle monitoring mode...');
|
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
|
// Start file watcher for hot-reload
|
||||||
this.watchConfigFile();
|
this.watchConfigFile();
|
||||||
@@ -948,7 +1017,7 @@ export class NupstDaemon {
|
|||||||
const currentTime = Date.now();
|
const currentTime = Date.now();
|
||||||
|
|
||||||
// Periodically check if config has been updated
|
// Periodically check if config has been updated
|
||||||
if (currentTime - lastConfigCheck >= CONFIG_CHECK_INTERVAL) {
|
if (currentTime - lastConfigCheck >= TIMING.CONFIG_CHECK_INTERVAL_MS) {
|
||||||
try {
|
try {
|
||||||
// Try to load config
|
// Try to load config
|
||||||
const newConfig = await this.loadConfig();
|
const newConfig = await this.loadConfig();
|
||||||
@@ -968,12 +1037,12 @@ export class NupstDaemon {
|
|||||||
lastConfigCheck = currentTime;
|
lastConfigCheck = currentTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.sleep(IDLE_CHECK_INTERVAL);
|
await this.sleep(TIMING.IDLE_CHECK_INTERVAL_MS);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`Error during idle monitoring: ${error instanceof Error ? error.message : String(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 './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();
|
||||||
|
}
|
||||||
|
}
|
||||||
113
ts/http-server.ts
Normal file
113
ts/http-server.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import * as http from 'node:http';
|
||||||
|
import { URL } from 'node:url';
|
||||||
|
import { logger } from './logger.ts';
|
||||||
|
import type { IUpsStatus } from './daemon.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP Server for exposing UPS status as JSON
|
||||||
|
* Serves cached data from the daemon's monitoring loop
|
||||||
|
*/
|
||||||
|
export class NupstHttpServer {
|
||||||
|
private server?: http.Server;
|
||||||
|
private port: number;
|
||||||
|
private path: string;
|
||||||
|
private authToken: string;
|
||||||
|
private getUpsStatus: () => Map<string, IUpsStatus>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new HTTP server instance
|
||||||
|
* @param port Port to listen on
|
||||||
|
* @param path URL path for the endpoint
|
||||||
|
* @param authToken Authentication token required for access
|
||||||
|
* @param getUpsStatus Function to retrieve cached UPS status
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
port: number,
|
||||||
|
path: string,
|
||||||
|
authToken: string,
|
||||||
|
getUpsStatus: () => Map<string, IUpsStatus>,
|
||||||
|
) {
|
||||||
|
this.port = port;
|
||||||
|
this.path = path;
|
||||||
|
this.authToken = authToken;
|
||||||
|
this.getUpsStatus = getUpsStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify authentication token from request
|
||||||
|
* Supports both Bearer token in Authorization header and token query parameter
|
||||||
|
* @param req HTTP request
|
||||||
|
* @returns True if authenticated, false otherwise
|
||||||
|
*/
|
||||||
|
private isAuthenticated(req: http.IncomingMessage): boolean {
|
||||||
|
// Check Authorization header (Bearer token)
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
if (authHeader?.startsWith('Bearer ')) {
|
||||||
|
const token = authHeader.substring(7);
|
||||||
|
return token === this.authToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check token query parameter
|
||||||
|
if (req.url) {
|
||||||
|
const url = new URL(req.url, `http://localhost:${this.port}`);
|
||||||
|
const tokenParam = url.searchParams.get('token');
|
||||||
|
return tokenParam === this.authToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the HTTP server
|
||||||
|
*/
|
||||||
|
public start(): void {
|
||||||
|
this.server = http.createServer((req, res) => {
|
||||||
|
// Parse URL
|
||||||
|
const reqUrl = new URL(req.url || '/', `http://localhost:${this.port}`);
|
||||||
|
|
||||||
|
if (reqUrl.pathname === this.path && req.method === 'GET') {
|
||||||
|
// Check authentication
|
||||||
|
if (!this.isAuthenticated(req)) {
|
||||||
|
res.writeHead(401, {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'WWW-Authenticate': 'Bearer',
|
||||||
|
});
|
||||||
|
res.end(JSON.stringify({ error: 'Unauthorized' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get cached status (no refresh)
|
||||||
|
const statusMap = this.getUpsStatus();
|
||||||
|
const statusArray = Array.from(statusMap.values());
|
||||||
|
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
});
|
||||||
|
res.end(JSON.stringify(statusArray, null, 2));
|
||||||
|
} else {
|
||||||
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: 'Not Found' }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.server.listen(this.port, () => {
|
||||||
|
logger.success(`HTTP server started on port ${this.port} at ${this.path}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.server.on('error', (error: Error) => {
|
||||||
|
logger.error(`HTTP server error: ${error.message}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the HTTP server
|
||||||
|
*/
|
||||||
|
public stop(): void {
|
||||||
|
if (this.server) {
|
||||||
|
this.server.close(() => {
|
||||||
|
logger.log('HTTP server stopped');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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
|
* Table column alignment options
|
||||||
@@ -230,7 +230,8 @@ export class Logger {
|
|||||||
* Strip ANSI color codes from string for accurate length calculation
|
* Strip ANSI color codes from string for accurate length calculation
|
||||||
*/
|
*/
|
||||||
private stripAnsi(text: string): string {
|
private stripAnsi(text: string): string {
|
||||||
// Remove ANSI escape codes
|
// Remove ANSI escape codes (intentional control character regex)
|
||||||
|
// deno-lint-ignore no-control-regex
|
||||||
return text.replace(/\x1b\[[0-9;]*m/g, '');
|
return text.replace(/\x1b\[[0-9;]*m/g, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,9 @@ export abstract class BaseMigration {
|
|||||||
* @param config - Raw configuration object to check (unknown schema for migrations)
|
* @param config - Raw configuration object to check (unknown schema for migrations)
|
||||||
* @returns True if migration should run, false otherwise
|
* @returns True if migration should run, false otherwise
|
||||||
*/
|
*/
|
||||||
abstract shouldRun(config: Record<string, unknown>): Promise<boolean>;
|
abstract shouldRun(
|
||||||
|
config: Record<string, unknown>,
|
||||||
|
): boolean | Promise<boolean>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Perform the migration on the given config
|
* Perform the migration on the given config
|
||||||
@@ -39,7 +41,9 @@ export abstract class BaseMigration {
|
|||||||
* @param config - Raw configuration object to migrate (unknown schema for migrations)
|
* @param config - Raw configuration object to migrate (unknown schema for migrations)
|
||||||
* @returns Migrated configuration object
|
* @returns Migrated configuration object
|
||||||
*/
|
*/
|
||||||
abstract migrate(config: Record<string, unknown>): Promise<Record<string, unknown>>;
|
abstract migrate(
|
||||||
|
config: Record<string, unknown>,
|
||||||
|
): Record<string, unknown> | Promise<Record<string, unknown>>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get human-readable name for this migration
|
* Get human-readable name for this migration
|
||||||
|
|||||||
@@ -23,12 +23,12 @@ export class MigrationV1ToV2 extends BaseMigration {
|
|||||||
readonly fromVersion = '1.x';
|
readonly fromVersion = '1.x';
|
||||||
readonly toVersion = '2.0';
|
readonly toVersion = '2.0';
|
||||||
|
|
||||||
async shouldRun(config: any): Promise<boolean> {
|
shouldRun(config: Record<string, unknown>): boolean {
|
||||||
// V1 format has snmp field directly at root, no upsDevices or upsList
|
// V1 format has snmp field directly at root, no upsDevices or upsList
|
||||||
return !!config.snmp && !config.upsDevices && !config.upsList;
|
return !!config.snmp && !config.upsDevices && !config.upsList;
|
||||||
}
|
}
|
||||||
|
|
||||||
async migrate(config: any): Promise<any> {
|
migrate(config: Record<string, unknown>): Record<string, unknown> {
|
||||||
logger.info(`${this.getName()}: Converting single SNMP config to multi-UPS format...`);
|
logger.info(`${this.getName()}: Converting single SNMP config to multi-UPS format...`);
|
||||||
|
|
||||||
const migrated = {
|
const migrated = {
|
||||||
|
|||||||
@@ -42,15 +42,16 @@ export class MigrationV3ToV4 extends BaseMigration {
|
|||||||
readonly fromVersion = '3.x';
|
readonly fromVersion = '3.x';
|
||||||
readonly toVersion = '4.0';
|
readonly toVersion = '4.0';
|
||||||
|
|
||||||
async shouldRun(config: any): Promise<boolean> {
|
shouldRun(config: Record<string, unknown>): boolean {
|
||||||
// V3 format has upsList OR has upsDevices with flat structure (host at top level)
|
// V3 format has upsList OR has upsDevices with flat structure (host at top level)
|
||||||
if (config.upsList && !config.upsDevices) {
|
if (config.upsList && !config.upsDevices) {
|
||||||
return true; // Classic v3 with upsList
|
return true; // Classic v3 with upsList
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if upsDevices exists but has flat structure (v3 format)
|
// Check if upsDevices exists but has flat structure (v3 format)
|
||||||
if (config.upsDevices && config.upsDevices.length > 0) {
|
const upsDevices = config.upsDevices as Array<Record<string, unknown>> | undefined;
|
||||||
const firstDevice = config.upsDevices[0];
|
if (upsDevices && upsDevices.length > 0) {
|
||||||
|
const firstDevice = upsDevices[0];
|
||||||
// V3 has host at top level, v4 has it nested in snmp object
|
// V3 has host at top level, v4 has it nested in snmp object
|
||||||
return !!firstDevice.host && !firstDevice.snmp;
|
return !!firstDevice.host && !firstDevice.snmp;
|
||||||
}
|
}
|
||||||
@@ -58,17 +59,17 @@ export class MigrationV3ToV4 extends BaseMigration {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async migrate(config: any): Promise<any> {
|
migrate(config: Record<string, unknown>): Record<string, unknown> {
|
||||||
logger.info(`${this.getName()}: Migrating v3 config to v4 format...`);
|
logger.info(`${this.getName()}: Migrating v3 config to v4 format...`);
|
||||||
logger.dim(` - Restructuring UPS devices (flat → nested snmp config)`);
|
logger.dim(` - Restructuring UPS devices (flat → nested snmp config)`);
|
||||||
|
|
||||||
// Get devices from either upsList or upsDevices (for partially migrated configs)
|
// Get devices from either upsList or upsDevices (for partially migrated configs)
|
||||||
const sourceDevices = config.upsList || config.upsDevices;
|
const sourceDevices = (config.upsList || config.upsDevices) as Array<Record<string, unknown>>;
|
||||||
|
|
||||||
// Transform each UPS device from v3 flat structure to v4 nested structure
|
// Transform each UPS device from v3 flat structure to v4 nested structure
|
||||||
const transformedDevices = sourceDevices.map((device: any) => {
|
const transformedDevices = sourceDevices.map((device: Record<string, unknown>) => {
|
||||||
// Build SNMP config object
|
// Build SNMP config object
|
||||||
const snmpConfig: any = {
|
const snmpConfig: Record<string, unknown> = {
|
||||||
host: device.host,
|
host: device.host,
|
||||||
port: device.port || 161,
|
port: device.port || 161,
|
||||||
version: typeof device.version === 'string' ? parseInt(device.version, 10) : device.version,
|
version: typeof device.version === 'string' ? parseInt(device.version, 10) : device.version,
|
||||||
@@ -112,7 +113,9 @@ export class MigrationV3ToV4 extends BaseMigration {
|
|||||||
checkInterval: config.checkInterval || 30000,
|
checkInterval: config.checkInterval || 30000,
|
||||||
};
|
};
|
||||||
|
|
||||||
logger.success(`${this.getName()}: Migration complete (${transformedDevices.length} devices transformed)`);
|
logger.success(
|
||||||
|
`${this.getName()}: Migration complete (${transformedDevices.length} devices transformed)`,
|
||||||
|
);
|
||||||
return migrated;
|
return migrated;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export class MigrationV4_0ToV4_1 extends BaseMigration {
|
|||||||
readonly fromVersion = '4.0';
|
readonly fromVersion = '4.0';
|
||||||
readonly toVersion = '4.1';
|
readonly toVersion = '4.1';
|
||||||
|
|
||||||
async shouldRun(config: Record<string, unknown>): Promise<boolean> {
|
shouldRun(config: Record<string, unknown>): boolean {
|
||||||
// Run if config is version 4.0
|
// Run if config is version 4.0
|
||||||
if (config.version === '4.0') {
|
if (config.version === '4.0') {
|
||||||
return true;
|
return true;
|
||||||
@@ -65,7 +65,7 @@ export class MigrationV4_0ToV4_1 extends BaseMigration {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async migrate(config: Record<string, unknown>): Promise<Record<string, unknown>> {
|
migrate(config: Record<string, unknown>): Record<string, unknown> {
|
||||||
logger.info(`${this.getName()}: Migrating v4.0 config to v4.1 format...`);
|
logger.info(`${this.getName()}: Migrating v4.0 config to v4.1 format...`);
|
||||||
logger.dim(` - Moving thresholds from UPS level to action level`);
|
logger.dim(` - Moving thresholds from UPS level to action level`);
|
||||||
logger.dim(` - Creating default shutdown actions from existing thresholds`);
|
logger.dim(` - Creating default shutdown actions from existing thresholds`);
|
||||||
@@ -81,7 +81,9 @@ export class MigrationV4_0ToV4_1 extends BaseMigration {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// If device has thresholds at UPS level, convert to shutdown action
|
// If device has thresholds at UPS level, convert to shutdown action
|
||||||
const deviceThresholds = device.thresholds as { battery: number; runtime: number } | undefined;
|
const deviceThresholds = device.thresholds as
|
||||||
|
| { battery: number; runtime: number }
|
||||||
|
| undefined;
|
||||||
if (deviceThresholds) {
|
if (deviceThresholds) {
|
||||||
migrated.actions = [
|
migrated.actions = [
|
||||||
{
|
{
|
||||||
|
|||||||
37
ts/nupst.ts
37
ts/nupst.ts
@@ -1,19 +1,21 @@
|
|||||||
import { NupstSnmp } from './snmp/manager.ts';
|
import { NupstSnmp } from './snmp/manager.ts';
|
||||||
import { NupstDaemon } from './daemon.ts';
|
import { NupstDaemon } from './daemon.ts';
|
||||||
import { NupstSystemd } from './systemd.ts';
|
import { NupstSystemd } from './systemd.ts';
|
||||||
import { commitinfo } from './00_commitinfo_data.ts';
|
import denoConfig from '../deno.json' with { type: 'json' };
|
||||||
import { logger } from './logger.ts';
|
import { logger } from './logger.ts';
|
||||||
import { UpsHandler } from './cli/ups-handler.ts';
|
import { UpsHandler } from './cli/ups-handler.ts';
|
||||||
import { GroupHandler } from './cli/group-handler.ts';
|
import { GroupHandler } from './cli/group-handler.ts';
|
||||||
import { ServiceHandler } from './cli/service-handler.ts';
|
import { ServiceHandler } from './cli/service-handler.ts';
|
||||||
import { ActionHandler } from './cli/action-handler.ts';
|
import { ActionHandler } from './cli/action-handler.ts';
|
||||||
|
import { FeatureHandler } from './cli/feature-handler.ts';
|
||||||
import * as https from 'node:https';
|
import * as https from 'node:https';
|
||||||
|
import type { INupstAccessor, IUpdateStatus } from './interfaces/index.ts';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main Nupst class that coordinates all components
|
* Main Nupst class that coordinates all components
|
||||||
* Acts as a facade to access SNMP, Daemon, and Systemd functionality
|
* 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 snmp: NupstSnmp;
|
||||||
private readonly daemon: NupstDaemon;
|
private readonly daemon: NupstDaemon;
|
||||||
private readonly systemd: NupstSystemd;
|
private readonly systemd: NupstSystemd;
|
||||||
@@ -21,6 +23,7 @@ export class Nupst {
|
|||||||
private readonly groupHandler: GroupHandler;
|
private readonly groupHandler: GroupHandler;
|
||||||
private readonly serviceHandler: ServiceHandler;
|
private readonly serviceHandler: ServiceHandler;
|
||||||
private readonly actionHandler: ActionHandler;
|
private readonly actionHandler: ActionHandler;
|
||||||
|
private readonly featureHandler: FeatureHandler;
|
||||||
private updateAvailable: boolean = false;
|
private updateAvailable: boolean = false;
|
||||||
private latestVersion: string = '';
|
private latestVersion: string = '';
|
||||||
|
|
||||||
@@ -39,6 +42,7 @@ export class Nupst {
|
|||||||
this.groupHandler = new GroupHandler(this);
|
this.groupHandler = new GroupHandler(this);
|
||||||
this.serviceHandler = new ServiceHandler(this);
|
this.serviceHandler = new ServiceHandler(this);
|
||||||
this.actionHandler = new ActionHandler(this);
|
this.actionHandler = new ActionHandler(this);
|
||||||
|
this.featureHandler = new FeatureHandler(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -90,12 +94,19 @@ export class Nupst {
|
|||||||
return this.actionHandler;
|
return this.actionHandler;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the Feature handler for feature management
|
||||||
|
*/
|
||||||
|
public getFeatureHandler(): FeatureHandler {
|
||||||
|
return this.featureHandler;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the current version of NUPST
|
* Get the current version of NUPST
|
||||||
* @returns The current version string
|
* @returns The current version string
|
||||||
*/
|
*/
|
||||||
public getVersion(): string {
|
public getVersion(): string {
|
||||||
return commitinfo.version;
|
return denoConfig.version;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -124,11 +135,7 @@ export class Nupst {
|
|||||||
* Get update status information
|
* Get update status information
|
||||||
* @returns Object with update status information
|
* @returns Object with update status information
|
||||||
*/
|
*/
|
||||||
public getUpdateStatus(): {
|
public getUpdateStatus(): IUpdateStatus {
|
||||||
currentVersion: string;
|
|
||||||
latestVersion: string;
|
|
||||||
updateAvailable: boolean;
|
|
||||||
} {
|
|
||||||
return {
|
return {
|
||||||
currentVersion: this.getVersion(),
|
currentVersion: this.getVersion(),
|
||||||
latestVersion: this.latestVersion || this.getVersion(),
|
latestVersion: this.latestVersion || this.getVersion(),
|
||||||
@@ -143,8 +150,8 @@ export class Nupst {
|
|||||||
private getLatestVersion(): Promise<string> {
|
private getLatestVersion(): Promise<string> {
|
||||||
return new Promise<string>((resolve, reject) => {
|
return new Promise<string>((resolve, reject) => {
|
||||||
const options = {
|
const options = {
|
||||||
hostname: 'registry.npmjs.org',
|
hostname: 'code.foss.global',
|
||||||
path: '/@serve.zone/nupst',
|
path: '/api/v1/repos/serve.zone/nupst/releases/latest',
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
'Accept': 'application/json',
|
'Accept': 'application/json',
|
||||||
@@ -162,10 +169,14 @@ export class Nupst {
|
|||||||
res.on('end', () => {
|
res.on('end', () => {
|
||||||
try {
|
try {
|
||||||
const response = JSON.parse(data);
|
const response = JSON.parse(data);
|
||||||
if (response['dist-tags'] && response['dist-tags'].latest) {
|
if (response.tag_name) {
|
||||||
resolve(response['dist-tags'].latest);
|
// 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)
|
||||||
|
: response.tag_name;
|
||||||
|
resolve(version);
|
||||||
} else {
|
} else {
|
||||||
reject(new Error('Failed to parse version from npm registry response'));
|
reject(new Error('Failed to parse version from Gitea API response'));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
reject(error);
|
reject(error);
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import * as snmp from 'npm:net-snmp@3.20.0';
|
import * as snmp from 'npm:net-snmp@3.26.0';
|
||||||
import { Buffer } from 'node:buffer';
|
import { Buffer } from 'node:buffer';
|
||||||
import type { IOidSet, ISnmpConfig, IUpsStatus, TUpsModel } from './types.ts';
|
import type { IOidSet, ISnmpConfig, IUpsStatus, TUpsModel } from './types.ts';
|
||||||
import { UpsOidSets } from './oid-sets.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
|
* Class for SNMP communication with UPS devices
|
||||||
@@ -10,18 +13,18 @@ import { UpsOidSets } from './oid-sets.ts';
|
|||||||
export class NupstSnmp {
|
export class NupstSnmp {
|
||||||
// Active OID set
|
// Active OID set
|
||||||
private activeOIDs: IOidSet;
|
private activeOIDs: IOidSet;
|
||||||
// Reference to the parent Nupst instance
|
// Reference to the parent Nupst instance (uses interface to avoid circular dependency)
|
||||||
private nupst: any; // Type 'any' to avoid circular dependency
|
private nupst: INupstAccessor | null = null;
|
||||||
// Debug mode flag
|
// Debug mode flag
|
||||||
private debug: boolean = false;
|
private debug: boolean = false;
|
||||||
|
|
||||||
// Default SNMP configuration
|
// Default SNMP configuration
|
||||||
private readonly DEFAULT_CONFIG: ISnmpConfig = {
|
private readonly DEFAULT_CONFIG: ISnmpConfig = {
|
||||||
host: '127.0.0.1', // Default to localhost
|
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
|
community: 'public', // Default community string for v1/v2c
|
||||||
version: 1, // SNMPv1
|
version: 1, // SNMPv1
|
||||||
timeout: 5000, // 5 seconds timeout
|
timeout: SNMP.DEFAULT_TIMEOUT_MS, // 5 seconds timeout
|
||||||
upsModel: 'cyberpower', // Default UPS model
|
upsModel: 'cyberpower', // Default UPS model
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -39,14 +42,14 @@ export class NupstSnmp {
|
|||||||
* Set reference to the main Nupst instance
|
* Set reference to the main Nupst instance
|
||||||
* @param nupst 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;
|
this.nupst = nupst;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get reference to the main Nupst instance
|
* Get reference to the main Nupst instance
|
||||||
*/
|
*/
|
||||||
public getNupst(): any {
|
public getNupst(): INupstAccessor | null {
|
||||||
return this.nupst;
|
return this.nupst;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,7 +58,7 @@ export class NupstSnmp {
|
|||||||
*/
|
*/
|
||||||
public enableDebug(): void {
|
public enableDebug(): void {
|
||||||
this.debug = true;
|
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) {
|
if (config.upsModel === 'custom' && config.customOIDs) {
|
||||||
this.activeOIDs = config.customOIDs;
|
this.activeOIDs = config.customOIDs;
|
||||||
if (this.debug) {
|
if (this.debug) {
|
||||||
console.log('Using custom OIDs:', this.activeOIDs);
|
logger.dim(`Using custom OIDs: ${JSON.stringify(this.activeOIDs)}`);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -77,7 +80,7 @@ export class NupstSnmp {
|
|||||||
this.activeOIDs = UpsOidSets.getOidSet(model);
|
this.activeOIDs = UpsOidSets.getOidSet(model);
|
||||||
|
|
||||||
if (this.debug) {
|
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(
|
public snmpGet(
|
||||||
oid: string,
|
oid: string,
|
||||||
config = this.DEFAULT_CONFIG,
|
config = this.DEFAULT_CONFIG,
|
||||||
retryCount = 0,
|
_retryCount = 0,
|
||||||
|
// deno-lint-ignore no-explicit-any
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (this.debug) {
|
if (this.debug) {
|
||||||
console.log(
|
logger.dim(
|
||||||
`Sending SNMP v${config.version} GET request for OID ${oid} to ${config.host}:${config.port}`,
|
`Sending SNMP v${config.version} GET request for OID ${oid} to ${config.host}:${config.port}`,
|
||||||
);
|
);
|
||||||
console.log('Using community:', config.community);
|
logger.dim(`Using community: ${config.community}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create SNMP options based on configuration
|
// Create SNMP options based on configuration
|
||||||
|
// deno-lint-ignore no-explicit-any
|
||||||
const options: any = {
|
const options: any = {
|
||||||
port: config.port,
|
port: config.port,
|
||||||
retries: 2, // Number of retries
|
retries: SNMP.RETRIES, // Number of retries
|
||||||
timeout: config.timeout,
|
timeout: config.timeout,
|
||||||
transport: 'udp4',
|
transport: 'udp4',
|
||||||
idBitsSize: 32,
|
idBitsSize: 32,
|
||||||
@@ -129,6 +134,7 @@ export class NupstSnmp {
|
|||||||
const securityLevel = config.securityLevel || 'noAuthNoPriv';
|
const securityLevel = config.securityLevel || 'noAuthNoPriv';
|
||||||
|
|
||||||
// Create the user object with required structure for net-snmp
|
// Create the user object with required structure for net-snmp
|
||||||
|
// deno-lint-ignore no-explicit-any
|
||||||
const user: any = {
|
const user: any = {
|
||||||
name: config.username || '',
|
name: config.username || '',
|
||||||
};
|
};
|
||||||
@@ -151,7 +157,7 @@ export class NupstSnmp {
|
|||||||
// Fallback to noAuthNoPriv if auth details missing
|
// Fallback to noAuthNoPriv if auth details missing
|
||||||
user.level = snmp.SecurityLevel.noAuthNoPriv;
|
user.level = snmp.SecurityLevel.noAuthNoPriv;
|
||||||
if (this.debug) {
|
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') {
|
} else if (securityLevel === 'authPriv') {
|
||||||
@@ -178,29 +184,27 @@ export class NupstSnmp {
|
|||||||
// Fallback to authNoPriv if priv details missing
|
// Fallback to authNoPriv if priv details missing
|
||||||
user.level = snmp.SecurityLevel.authNoPriv;
|
user.level = snmp.SecurityLevel.authNoPriv;
|
||||||
if (this.debug) {
|
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 {
|
} else {
|
||||||
// Fallback to noAuthNoPriv if auth details missing
|
// Fallback to noAuthNoPriv if auth details missing
|
||||||
user.level = snmp.SecurityLevel.noAuthNoPriv;
|
user.level = snmp.SecurityLevel.noAuthNoPriv;
|
||||||
if (this.debug) {
|
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) {
|
if (this.debug) {
|
||||||
console.log('SNMPv3 user configuration:', {
|
const levelName = Object.keys(snmp.SecurityLevel).find((key) =>
|
||||||
name: user.name,
|
snmp.SecurityLevel[key] === user.level
|
||||||
level: Object.keys(snmp.SecurityLevel).find((key) =>
|
);
|
||||||
snmp.SecurityLevel[key] === user.level
|
logger.dim(
|
||||||
),
|
`SNMPv3 user configuration: name=${user.name}, level=${levelName}, authProtocol=${
|
||||||
authProtocol: user.authProtocol ? 'Set' : 'Not Set',
|
user.authProtocol ? 'Set' : 'Not Set'
|
||||||
authKey: user.authKey ? 'Set' : 'Not Set',
|
}, privProtocol=${user.privProtocol ? 'Set' : 'Not Set'}`,
|
||||||
privProtocol: user.privProtocol ? 'Set' : 'Not Set',
|
);
|
||||||
privKey: user.privKey ? 'Set' : 'Not Set',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
session = snmp.createV3Session(config.host, user, options);
|
session = snmp.createV3Session(config.host, user, options);
|
||||||
@@ -213,13 +217,14 @@ export class NupstSnmp {
|
|||||||
const oids = [oid];
|
const oids = [oid];
|
||||||
|
|
||||||
// Send the GET request
|
// Send the GET request
|
||||||
session.get(oids, (error: any, varbinds: any[]) => {
|
// deno-lint-ignore no-explicit-any
|
||||||
|
session.get(oids, (error: Error | null, varbinds: any[]) => {
|
||||||
// Close the session to release resources
|
// Close the session to release resources
|
||||||
session.close();
|
session.close();
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
if (this.debug) {
|
if (this.debug) {
|
||||||
console.error('SNMP GET error:', error);
|
logger.error(`SNMP GET error: ${error}`);
|
||||||
}
|
}
|
||||||
reject(new Error(`SNMP GET error: ${error.message || error}`));
|
reject(new Error(`SNMP GET error: ${error.message || error}`));
|
||||||
return;
|
return;
|
||||||
@@ -227,7 +232,7 @@ export class NupstSnmp {
|
|||||||
|
|
||||||
if (!varbinds || varbinds.length === 0) {
|
if (!varbinds || varbinds.length === 0) {
|
||||||
if (this.debug) {
|
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'));
|
reject(new Error('No varbinds returned in response'));
|
||||||
return;
|
return;
|
||||||
@@ -240,7 +245,7 @@ export class NupstSnmp {
|
|||||||
varbinds[0].type === snmp.ObjectType.EndOfMibView
|
varbinds[0].type === snmp.ObjectType.EndOfMibView
|
||||||
) {
|
) {
|
||||||
if (this.debug) {
|
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]}`));
|
reject(new Error(`SNMP error: ${snmp.ObjectType[varbinds[0].type]}`));
|
||||||
return;
|
return;
|
||||||
@@ -262,11 +267,9 @@ export class NupstSnmp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (this.debug) {
|
if (this.debug) {
|
||||||
console.log('SNMP response:', {
|
logger.dim(
|
||||||
oid: varbinds[0].oid,
|
`SNMP response: oid=${varbinds[0].oid}, type=${varbinds[0].type}, value=${value}`,
|
||||||
type: varbinds[0].type,
|
);
|
||||||
value: value,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
resolve(value);
|
resolve(value);
|
||||||
@@ -285,26 +288,30 @@ export class NupstSnmp {
|
|||||||
this.setActiveOIDs(config);
|
this.setActiveOIDs(config);
|
||||||
|
|
||||||
if (this.debug) {
|
if (this.debug) {
|
||||||
console.log('---------------------------------------');
|
logger.dim('---------------------------------------');
|
||||||
console.log('Getting UPS status with config:');
|
logger.dim('Getting UPS status with config:');
|
||||||
console.log(' Host:', config.host);
|
logger.dim(` Host: ${config.host}`);
|
||||||
console.log(' Port:', config.port);
|
logger.dim(` Port: ${config.port}`);
|
||||||
console.log(' Version:', config.version);
|
logger.dim(` Version: ${config.version}`);
|
||||||
console.log(' Timeout:', config.timeout, 'ms');
|
logger.dim(` Timeout: ${config.timeout} ms`);
|
||||||
console.log(' UPS Model:', config.upsModel || 'cyberpower');
|
logger.dim(` UPS Model: ${config.upsModel || 'cyberpower'}`);
|
||||||
if (config.version === 1 || config.version === 2) {
|
if (config.version === 1 || config.version === 2) {
|
||||||
console.log(' Community:', config.community);
|
logger.dim(` Community: ${config.community}`);
|
||||||
} else if (config.version === 3) {
|
} else if (config.version === 3) {
|
||||||
console.log(' Security Level:', config.securityLevel);
|
logger.dim(` Security Level: ${config.securityLevel}`);
|
||||||
console.log(' Username:', config.username);
|
logger.dim(` Username: ${config.username}`);
|
||||||
console.log(' Auth Protocol:', config.authProtocol || 'None');
|
logger.dim(` Auth Protocol: ${config.authProtocol || 'None'}`);
|
||||||
console.log(' Privacy Protocol:', config.privProtocol || 'None');
|
logger.dim(` Privacy Protocol: ${config.privProtocol || 'None'}`);
|
||||||
}
|
}
|
||||||
console.log('Using OIDs:');
|
logger.dim('Using OIDs:');
|
||||||
console.log(' Power Status:', this.activeOIDs.POWER_STATUS);
|
logger.dim(` Power Status: ${this.activeOIDs.POWER_STATUS}`);
|
||||||
console.log(' Battery Capacity:', this.activeOIDs.BATTERY_CAPACITY);
|
logger.dim(` Battery Capacity: ${this.activeOIDs.BATTERY_CAPACITY}`);
|
||||||
console.log(' Battery Runtime:', this.activeOIDs.BATTERY_RUNTIME);
|
logger.dim(` Battery Runtime: ${this.activeOIDs.BATTERY_RUNTIME}`);
|
||||||
console.log('---------------------------------------');
|
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
|
// Get all values with independent retry logic
|
||||||
@@ -324,41 +331,89 @@ export class NupstSnmp {
|
|||||||
config,
|
config,
|
||||||
) || 0;
|
) || 0;
|
||||||
|
|
||||||
|
// Get power draw metrics
|
||||||
|
const outputLoad = await this.getSNMPValueWithRetry(
|
||||||
|
this.activeOIDs.OUTPUT_LOAD,
|
||||||
|
'output load',
|
||||||
|
config,
|
||||||
|
) || 0;
|
||||||
|
const outputPower = await this.getSNMPValueWithRetry(
|
||||||
|
this.activeOIDs.OUTPUT_POWER,
|
||||||
|
'output power',
|
||||||
|
config,
|
||||||
|
) || 0;
|
||||||
|
const outputVoltage = await this.getSNMPValueWithRetry(
|
||||||
|
this.activeOIDs.OUTPUT_VOLTAGE,
|
||||||
|
'output voltage',
|
||||||
|
config,
|
||||||
|
) || 0;
|
||||||
|
const outputCurrent = await this.getSNMPValueWithRetry(
|
||||||
|
this.activeOIDs.OUTPUT_CURRENT,
|
||||||
|
'output current',
|
||||||
|
config,
|
||||||
|
) || 0;
|
||||||
|
|
||||||
// Determine power status - handle different values for different UPS models
|
// Determine power status - handle different values for different UPS models
|
||||||
const powerStatus = this.determinePowerStatus(config.upsModel, powerStatusValue);
|
const powerStatus = this.determinePowerStatus(config.upsModel, powerStatusValue);
|
||||||
|
|
||||||
// Convert to minutes for UPS models with different time units
|
// Convert to minutes for UPS models with different time units
|
||||||
const processedRuntime = this.processRuntimeValue(config.upsModel, batteryRuntime);
|
const processedRuntime = this.processRuntimeValue(config.upsModel, batteryRuntime);
|
||||||
|
|
||||||
|
// Process power metrics with vendor-specific scaling
|
||||||
|
const processedVoltage = this.processVoltageValue(config.upsModel, outputVoltage);
|
||||||
|
const processedCurrent = this.processCurrentValue(config.upsModel, outputCurrent);
|
||||||
|
|
||||||
|
// Calculate power from voltage × current if not provided by UPS
|
||||||
|
let processedPower = outputPower;
|
||||||
|
if (outputPower === 0 && processedVoltage > 0 && processedCurrent > 0) {
|
||||||
|
processedPower = Math.round(processedVoltage * processedCurrent);
|
||||||
|
if (this.debug) {
|
||||||
|
logger.dim(
|
||||||
|
`Calculated power from V×I: ${processedVoltage}V × ${processedCurrent}A = ${processedPower}W`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const result = {
|
const result = {
|
||||||
powerStatus,
|
powerStatus,
|
||||||
batteryCapacity,
|
batteryCapacity,
|
||||||
batteryRuntime: processedRuntime,
|
batteryRuntime: processedRuntime,
|
||||||
|
outputLoad,
|
||||||
|
outputPower: processedPower,
|
||||||
|
outputVoltage: processedVoltage,
|
||||||
|
outputCurrent: processedCurrent,
|
||||||
raw: {
|
raw: {
|
||||||
powerStatus: powerStatusValue,
|
powerStatus: powerStatusValue,
|
||||||
batteryCapacity,
|
batteryCapacity,
|
||||||
batteryRuntime,
|
batteryRuntime,
|
||||||
|
outputLoad,
|
||||||
|
outputPower,
|
||||||
|
outputVoltage,
|
||||||
|
outputCurrent,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
if (this.debug) {
|
if (this.debug) {
|
||||||
console.log('---------------------------------------');
|
logger.dim('---------------------------------------');
|
||||||
console.log('UPS status result:');
|
logger.dim('UPS status result:');
|
||||||
console.log(' Power Status:', result.powerStatus);
|
logger.dim(` Power Status: ${result.powerStatus}`);
|
||||||
console.log(' Battery Capacity:', result.batteryCapacity + '%');
|
logger.dim(` Battery Capacity: ${result.batteryCapacity}%`);
|
||||||
console.log(' Battery Runtime:', result.batteryRuntime, 'minutes');
|
logger.dim(` Battery Runtime: ${result.batteryRuntime} minutes`);
|
||||||
console.log('---------------------------------------');
|
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;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (this.debug) {
|
if (this.debug) {
|
||||||
console.error('---------------------------------------');
|
logger.error('---------------------------------------');
|
||||||
console.error(
|
logger.error(
|
||||||
'Error getting UPS status:',
|
`Error getting UPS status: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
error instanceof Error ? error.message : String(error),
|
|
||||||
);
|
);
|
||||||
console.error('---------------------------------------');
|
logger.error('---------------------------------------');
|
||||||
}
|
}
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Failed to get UPS status: ${error instanceof Error ? error.message : String(error)}`,
|
`Failed to get UPS status: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
@@ -377,29 +432,29 @@ export class NupstSnmp {
|
|||||||
oid: string,
|
oid: string,
|
||||||
description: string,
|
description: string,
|
||||||
config: ISnmpConfig,
|
config: ISnmpConfig,
|
||||||
|
// deno-lint-ignore no-explicit-any
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
if (oid === '') {
|
if (oid === '') {
|
||||||
if (this.debug) {
|
if (this.debug) {
|
||||||
console.log(`No OID provided for ${description}, skipping`);
|
logger.dim(`No OID provided for ${description}, skipping`);
|
||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.debug) {
|
if (this.debug) {
|
||||||
console.log(`Getting ${description} OID: ${oid}`);
|
logger.dim(`Getting ${description} OID: ${oid}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const value = await this.snmpGet(oid, config);
|
const value = await this.snmpGet(oid, config);
|
||||||
if (this.debug) {
|
if (this.debug) {
|
||||||
console.log(`${description} value:`, value);
|
logger.dim(`${description} value: ${value}`);
|
||||||
}
|
}
|
||||||
return value;
|
return value;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (this.debug) {
|
if (this.debug) {
|
||||||
console.error(
|
logger.error(
|
||||||
`Error getting ${description}:`,
|
`Error getting ${description}: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
error instanceof Error ? error.message : String(error),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -415,7 +470,7 @@ export class NupstSnmp {
|
|||||||
|
|
||||||
// Return a default value if all attempts fail
|
// Return a default value if all attempts fail
|
||||||
if (this.debug) {
|
if (this.debug) {
|
||||||
console.log(`Using default value 0 for ${description}`);
|
logger.dim(`Using default value 0 for ${description}`);
|
||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -432,28 +487,30 @@ export class NupstSnmp {
|
|||||||
oid: string,
|
oid: string,
|
||||||
description: string,
|
description: string,
|
||||||
config: ISnmpConfig,
|
config: ISnmpConfig,
|
||||||
|
// deno-lint-ignore no-explicit-any
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
if (this.debug) {
|
if (this.debug) {
|
||||||
console.log(`Retrying ${description} with fallback security level...`);
|
logger.dim(`Retrying ${description} with fallback security level...`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try with authNoPriv if current level is authPriv
|
// Try with authNoPriv if current level is authPriv
|
||||||
if (config.securityLevel === 'authPriv') {
|
if (config.securityLevel === 'authPriv') {
|
||||||
const retryConfig = { ...config, securityLevel: 'authNoPriv' as 'authNoPriv' };
|
const retryConfig = { ...config, securityLevel: 'authNoPriv' as const };
|
||||||
try {
|
try {
|
||||||
if (this.debug) {
|
if (this.debug) {
|
||||||
console.log(`Retrying with authNoPriv security level`);
|
logger.dim(`Retrying with authNoPriv security level`);
|
||||||
}
|
}
|
||||||
const value = await this.snmpGet(oid, retryConfig);
|
const value = await this.snmpGet(oid, retryConfig);
|
||||||
if (this.debug) {
|
if (this.debug) {
|
||||||
console.log(`${description} retry value:`, value);
|
logger.dim(`${description} retry value: ${value}`);
|
||||||
}
|
}
|
||||||
return value;
|
return value;
|
||||||
} catch (retryError) {
|
} catch (retryError) {
|
||||||
if (this.debug) {
|
if (this.debug) {
|
||||||
console.error(
|
logger.error(
|
||||||
`Retry failed for ${description}:`,
|
`Retry failed for ${description}: ${
|
||||||
retryError instanceof Error ? retryError.message : String(retryError),
|
retryError instanceof Error ? retryError.message : String(retryError)
|
||||||
|
}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -461,21 +518,22 @@ export class NupstSnmp {
|
|||||||
|
|
||||||
// Try with noAuthNoPriv as a last resort
|
// Try with noAuthNoPriv as a last resort
|
||||||
if (config.securityLevel === 'authPriv' || config.securityLevel === 'authNoPriv') {
|
if (config.securityLevel === 'authPriv' || config.securityLevel === 'authNoPriv') {
|
||||||
const retryConfig = { ...config, securityLevel: 'noAuthNoPriv' as 'noAuthNoPriv' };
|
const retryConfig = { ...config, securityLevel: 'noAuthNoPriv' as const };
|
||||||
try {
|
try {
|
||||||
if (this.debug) {
|
if (this.debug) {
|
||||||
console.log(`Retrying with noAuthNoPriv security level`);
|
logger.dim(`Retrying with noAuthNoPriv security level`);
|
||||||
}
|
}
|
||||||
const value = await this.snmpGet(oid, retryConfig);
|
const value = await this.snmpGet(oid, retryConfig);
|
||||||
if (this.debug) {
|
if (this.debug) {
|
||||||
console.log(`${description} retry value:`, value);
|
logger.dim(`${description} retry value: ${value}`);
|
||||||
}
|
}
|
||||||
return value;
|
return value;
|
||||||
} catch (retryError) {
|
} catch (retryError) {
|
||||||
if (this.debug) {
|
if (this.debug) {
|
||||||
console.error(
|
logger.error(
|
||||||
`Retry failed for ${description}:`,
|
`Retry failed for ${description}: ${
|
||||||
retryError instanceof Error ? retryError.message : String(retryError),
|
retryError instanceof Error ? retryError.message : String(retryError)
|
||||||
|
}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -486,36 +544,38 @@ export class NupstSnmp {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Try standard OIDs as fallback
|
* Try standard OIDs as fallback
|
||||||
* @param oid OID to query
|
* @param _oid Original OID (unused, kept for method signature consistency)
|
||||||
* @param description Description of the value for logging
|
* @param description Description of the value for logging
|
||||||
* @param config SNMP configuration
|
* @param config SNMP configuration
|
||||||
* @returns Promise resolving to the SNMP value
|
* @returns Promise resolving to the SNMP value
|
||||||
*/
|
*/
|
||||||
private async tryStandardOids(
|
private async tryStandardOids(
|
||||||
oid: string,
|
_oid: string,
|
||||||
description: string,
|
description: string,
|
||||||
config: ISnmpConfig,
|
config: ISnmpConfig,
|
||||||
|
// deno-lint-ignore no-explicit-any
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
try {
|
try {
|
||||||
// Try RFC 1628 standard UPS MIB OIDs
|
// Try RFC 1628 standard UPS MIB OIDs
|
||||||
const standardOIDs = UpsOidSets.getStandardOids();
|
const standardOIDs = UpsOidSets.getStandardOids();
|
||||||
|
|
||||||
if (this.debug) {
|
if (this.debug) {
|
||||||
console.log(
|
logger.dim(
|
||||||
`Trying standard RFC 1628 OID for ${description}: ${standardOIDs[description]}`,
|
`Trying standard RFC 1628 OID for ${description}: ${standardOIDs[description]}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const standardValue = await this.snmpGet(standardOIDs[description], config);
|
const standardValue = await this.snmpGet(standardOIDs[description], config);
|
||||||
if (this.debug) {
|
if (this.debug) {
|
||||||
console.log(`${description} standard OID value:`, standardValue);
|
logger.dim(`${description} standard OID value: ${standardValue}`);
|
||||||
}
|
}
|
||||||
return standardValue;
|
return standardValue;
|
||||||
} catch (stdError) {
|
} catch (stdError) {
|
||||||
if (this.debug) {
|
if (this.debug) {
|
||||||
console.error(
|
logger.error(
|
||||||
`Standard OID retry failed for ${description}:`,
|
`Standard OID retry failed for ${description}: ${
|
||||||
stdError instanceof Error ? stdError.message : String(stdError),
|
stdError instanceof Error ? stdError.message : String(stdError)
|
||||||
|
}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -570,14 +630,14 @@ export class NupstSnmp {
|
|||||||
batteryRuntime: number,
|
batteryRuntime: number,
|
||||||
): number {
|
): number {
|
||||||
if (this.debug) {
|
if (this.debug) {
|
||||||
console.log('Raw runtime value:', batteryRuntime);
|
logger.dim(`Raw runtime value: ${batteryRuntime}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (upsModel === 'cyberpower' && batteryRuntime > 0) {
|
if (upsModel === 'cyberpower' && batteryRuntime > 0) {
|
||||||
// CyberPower: TimeTicks is in 1/100 seconds, convert to minutes
|
// CyberPower: TimeTicks is in 1/100 seconds, convert to minutes
|
||||||
const minutes = Math.floor(batteryRuntime / 6000); // 6000 ticks = 1 minute
|
const minutes = Math.floor(batteryRuntime / 6000); // 6000 ticks = 1 minute
|
||||||
if (this.debug) {
|
if (this.debug) {
|
||||||
console.log(
|
logger.dim(
|
||||||
`Converting CyberPower runtime from ${batteryRuntime} ticks to ${minutes} minutes`,
|
`Converting CyberPower runtime from ${batteryRuntime} ticks to ${minutes} minutes`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -586,7 +646,7 @@ export class NupstSnmp {
|
|||||||
// Eaton: Runtime is in seconds, convert to minutes
|
// Eaton: Runtime is in seconds, convert to minutes
|
||||||
const minutes = Math.floor(batteryRuntime / 60);
|
const minutes = Math.floor(batteryRuntime / 60);
|
||||||
if (this.debug) {
|
if (this.debug) {
|
||||||
console.log(
|
logger.dim(
|
||||||
`Converting Eaton runtime from ${batteryRuntime} seconds to ${minutes} minutes`,
|
`Converting Eaton runtime from ${batteryRuntime} seconds to ${minutes} minutes`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -595,11 +655,81 @@ export class NupstSnmp {
|
|||||||
// Generic conversion for large tick values (likely TimeTicks)
|
// Generic conversion for large tick values (likely TimeTicks)
|
||||||
const minutes = Math.floor(batteryRuntime / 6000);
|
const minutes = Math.floor(batteryRuntime / 6000);
|
||||||
if (this.debug) {
|
if (this.debug) {
|
||||||
console.log(`Converting ${batteryRuntime} ticks to ${minutes} minutes`);
|
logger.dim(`Converting ${batteryRuntime} ticks to ${minutes} minutes`);
|
||||||
}
|
}
|
||||||
return minutes;
|
return minutes;
|
||||||
}
|
}
|
||||||
|
|
||||||
return batteryRuntime;
|
return batteryRuntime;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process voltage value based on UPS model
|
||||||
|
* @param upsModel UPS model
|
||||||
|
* @param outputVoltage Raw output voltage value
|
||||||
|
* @returns Processed voltage in volts
|
||||||
|
*/
|
||||||
|
private processVoltageValue(
|
||||||
|
upsModel: TUpsModel | undefined,
|
||||||
|
outputVoltage: number,
|
||||||
|
): number {
|
||||||
|
if (this.debug) {
|
||||||
|
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) {
|
||||||
|
logger.dim(
|
||||||
|
`Converting CyberPower voltage from ${outputVoltage} (0.1V) to ${volts} volts`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return volts;
|
||||||
|
}
|
||||||
|
|
||||||
|
return outputVoltage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process current value based on UPS model
|
||||||
|
* @param upsModel UPS model
|
||||||
|
* @param outputCurrent Raw output current value
|
||||||
|
* @returns Processed current in amps
|
||||||
|
*/
|
||||||
|
private processCurrentValue(
|
||||||
|
upsModel: TUpsModel | undefined,
|
||||||
|
outputCurrent: number,
|
||||||
|
): number {
|
||||||
|
if (this.debug) {
|
||||||
|
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) {
|
||||||
|
logger.dim(
|
||||||
|
`Converting CyberPower current from ${outputCurrent} (0.1A) to ${amps} amps`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return amps;
|
||||||
|
} else if ((upsModel === 'tripplite' || upsModel === 'liebert') && outputCurrent > 0) {
|
||||||
|
// RFC 1628 standard: Current is in 0.1A, convert to amps
|
||||||
|
const amps = outputCurrent / 10;
|
||||||
|
if (this.debug) {
|
||||||
|
logger.dim(
|
||||||
|
`Converting RFC 1628 current from ${outputCurrent} (0.1A) to ${amps} amps`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return amps;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eaton XUPS-MIB and APC PowerNet report current directly in RMS Amps (no scaling needed)
|
||||||
|
if ((upsModel === 'eaton' || upsModel === 'apc') && this.debug && outputCurrent > 0) {
|
||||||
|
logger.dim(`${upsModel.toUpperCase()} current already in RMS Amps: ${outputCurrent}A`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return outputCurrent;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,28 +14,40 @@ export class UpsOidSets {
|
|||||||
POWER_STATUS: '1.3.6.1.4.1.3808.1.1.1.4.1.1.0', // upsBaseOutputStatus
|
POWER_STATUS: '1.3.6.1.4.1.3808.1.1.1.4.1.1.0', // upsBaseOutputStatus
|
||||||
BATTERY_CAPACITY: '1.3.6.1.4.1.3808.1.1.1.2.2.1.0', // upsAdvanceBatteryCapacity (percentage)
|
BATTERY_CAPACITY: '1.3.6.1.4.1.3808.1.1.1.2.2.1.0', // upsAdvanceBatteryCapacity (percentage)
|
||||||
BATTERY_RUNTIME: '1.3.6.1.4.1.3808.1.1.1.2.2.4.0', // upsAdvanceBatteryRunTimeRemaining (TimeTicks)
|
BATTERY_RUNTIME: '1.3.6.1.4.1.3808.1.1.1.2.2.4.0', // upsAdvanceBatteryRunTimeRemaining (TimeTicks)
|
||||||
|
OUTPUT_LOAD: '1.3.6.1.4.1.3808.1.1.1.4.2.3.0', // upsAdvanceOutputLoad (percentage)
|
||||||
|
OUTPUT_POWER: '1.3.6.1.4.1.3808.1.1.1.4.2.5.0', // upsAdvanceOutputPower (watts)
|
||||||
|
OUTPUT_VOLTAGE: '1.3.6.1.4.1.3808.1.1.1.4.2.1.0', // upsAdvanceOutputVoltage (0.1V scale)
|
||||||
|
OUTPUT_CURRENT: '1.3.6.1.4.1.3808.1.1.1.4.2.4.0', // upsAdvanceOutputCurrent (0.1A scale)
|
||||||
POWER_STATUS_VALUES: {
|
POWER_STATUS_VALUES: {
|
||||||
online: 2, // upsBaseOutputStatus: 2=onLine
|
online: 2, // upsBaseOutputStatus: 2=onLine
|
||||||
onBattery: 3, // upsBaseOutputStatus: 3=onBattery
|
onBattery: 3, // upsBaseOutputStatus: 3=onBattery
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
// APC OIDs
|
// APC OIDs (PowerNet MIB)
|
||||||
apc: {
|
apc: {
|
||||||
POWER_STATUS: '1.3.6.1.4.1.318.1.1.1.4.1.1.0', // upsBasicOutputStatus
|
POWER_STATUS: '1.3.6.1.4.1.318.1.1.1.4.1.1.0', // upsBasicOutputStatus
|
||||||
BATTERY_CAPACITY: '1.3.6.1.4.1.318.1.1.1.2.2.1.0', // Battery capacity in percentage
|
BATTERY_CAPACITY: '1.3.6.1.4.1.318.1.1.1.2.2.1.0', // Battery capacity in percentage
|
||||||
BATTERY_RUNTIME: '1.3.6.1.4.1.318.1.1.1.2.2.3.0', // Remaining runtime in minutes
|
BATTERY_RUNTIME: '1.3.6.1.4.1.318.1.1.1.2.2.3.0', // Remaining runtime in minutes
|
||||||
|
OUTPUT_LOAD: '1.3.6.1.4.1.318.1.1.1.4.2.3.0', // upsAdvOutputLoad (percentage)
|
||||||
|
OUTPUT_POWER: '1.3.6.1.4.1.318.1.1.1.4.2.8.0', // upsAdvOutputActivePower (watts)
|
||||||
|
OUTPUT_VOLTAGE: '1.3.6.1.4.1.318.1.1.1.4.2.1.0', // upsAdvOutputVoltage
|
||||||
|
OUTPUT_CURRENT: '1.3.6.1.4.1.318.1.1.1.4.2.4.0', // upsAdvOutputCurrent
|
||||||
POWER_STATUS_VALUES: {
|
POWER_STATUS_VALUES: {
|
||||||
online: 2, // upsBasicOutputStatus: 2=onLine
|
online: 2, // upsBasicOutputStatus: 2=onLine
|
||||||
onBattery: 3, // upsBasicOutputStatus: 3=onBattery
|
onBattery: 3, // upsBasicOutputStatus: 3=onBattery
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
// Eaton OIDs
|
// Eaton OIDs (XUPS-MIB)
|
||||||
eaton: {
|
eaton: {
|
||||||
POWER_STATUS: '1.3.6.1.4.1.534.1.4.4.0', // xupsOutputSource
|
POWER_STATUS: '1.3.6.1.4.1.534.1.4.4.0', // xupsOutputSource
|
||||||
BATTERY_CAPACITY: '1.3.6.1.4.1.534.1.2.4.0', // xupsBatCapacity (percentage)
|
BATTERY_CAPACITY: '1.3.6.1.4.1.534.1.2.4.0', // xupsBatCapacity (percentage)
|
||||||
BATTERY_RUNTIME: '1.3.6.1.4.1.534.1.2.1.0', // xupsBatTimeRemaining (seconds)
|
BATTERY_RUNTIME: '1.3.6.1.4.1.534.1.2.1.0', // xupsBatTimeRemaining (seconds)
|
||||||
|
OUTPUT_LOAD: '1.3.6.1.4.1.534.1.4.4.1.8.1', // xupsOutputPercentLoad (phase 1)
|
||||||
|
OUTPUT_POWER: '1.3.6.1.4.1.534.1.4.4.1.4.1', // xupsOutputWatts (phase 1)
|
||||||
|
OUTPUT_VOLTAGE: '1.3.6.1.4.1.534.1.4.4.1.2.1', // xupsOutputVoltage (phase 1)
|
||||||
|
OUTPUT_CURRENT: '1.3.6.1.4.1.534.1.4.4.1.3.1', // xupsOutputCurrent (phase 1)
|
||||||
POWER_STATUS_VALUES: {
|
POWER_STATUS_VALUES: {
|
||||||
online: 3, // xupsOutputSource: 3=normal (mains power)
|
online: 3, // xupsOutputSource: 3=normal (mains power)
|
||||||
onBattery: 5, // xupsOutputSource: 5=battery
|
onBattery: 5, // xupsOutputSource: 5=battery
|
||||||
@@ -47,6 +59,10 @@ export class UpsOidSets {
|
|||||||
POWER_STATUS: '1.3.6.1.4.1.850.1.1.3.1.1.1.0', // tlUpsOutputSource
|
POWER_STATUS: '1.3.6.1.4.1.850.1.1.3.1.1.1.0', // tlUpsOutputSource
|
||||||
BATTERY_CAPACITY: '1.3.6.1.4.1.850.1.1.3.2.4.1.0', // Battery capacity in percentage
|
BATTERY_CAPACITY: '1.3.6.1.4.1.850.1.1.3.2.4.1.0', // Battery capacity in percentage
|
||||||
BATTERY_RUNTIME: '1.3.6.1.4.1.850.1.1.3.2.2.1.0', // Remaining runtime in minutes
|
BATTERY_RUNTIME: '1.3.6.1.4.1.850.1.1.3.2.2.1.0', // Remaining runtime in minutes
|
||||||
|
OUTPUT_LOAD: '1.3.6.1.2.1.33.1.4.4.1.5.1', // RFC 1628: upsOutputPercentLoad
|
||||||
|
OUTPUT_POWER: '1.3.6.1.2.1.33.1.4.4.1.4.1', // RFC 1628: upsOutputPower (watts)
|
||||||
|
OUTPUT_VOLTAGE: '1.3.6.1.2.1.33.1.4.4.1.2.1', // RFC 1628: upsOutputVoltage
|
||||||
|
OUTPUT_CURRENT: '1.3.6.1.2.1.33.1.4.4.1.3.1', // RFC 1628: upsOutputCurrent (0.1A scale)
|
||||||
POWER_STATUS_VALUES: {
|
POWER_STATUS_VALUES: {
|
||||||
online: 2, // tlUpsOutputSource: 2=normal (mains power)
|
online: 2, // tlUpsOutputSource: 2=normal (mains power)
|
||||||
onBattery: 3, // tlUpsOutputSource: 3=onBattery
|
onBattery: 3, // tlUpsOutputSource: 3=onBattery
|
||||||
@@ -58,6 +74,10 @@ export class UpsOidSets {
|
|||||||
POWER_STATUS: '1.3.6.1.4.1.476.1.42.3.9.20.1.20.1.2.1.2.1', // lgpPwrOutputSource
|
POWER_STATUS: '1.3.6.1.4.1.476.1.42.3.9.20.1.20.1.2.1.2.1', // lgpPwrOutputSource
|
||||||
BATTERY_CAPACITY: '1.3.6.1.4.1.476.1.42.3.9.20.1.20.1.2.1.4.1', // Battery capacity in percentage
|
BATTERY_CAPACITY: '1.3.6.1.4.1.476.1.42.3.9.20.1.20.1.2.1.4.1', // Battery capacity in percentage
|
||||||
BATTERY_RUNTIME: '1.3.6.1.4.1.476.1.42.3.9.20.1.20.1.2.1.5.1', // Remaining runtime in minutes
|
BATTERY_RUNTIME: '1.3.6.1.4.1.476.1.42.3.9.20.1.20.1.2.1.5.1', // Remaining runtime in minutes
|
||||||
|
OUTPUT_LOAD: '1.3.6.1.2.1.33.1.4.4.1.5.1', // RFC 1628: upsOutputPercentLoad
|
||||||
|
OUTPUT_POWER: '1.3.6.1.2.1.33.1.4.4.1.4.1', // RFC 1628: upsOutputPower (watts)
|
||||||
|
OUTPUT_VOLTAGE: '1.3.6.1.2.1.33.1.4.4.1.2.1', // RFC 1628: upsOutputVoltage
|
||||||
|
OUTPUT_CURRENT: '1.3.6.1.2.1.33.1.4.4.1.3.1', // RFC 1628: upsOutputCurrent (0.1A scale)
|
||||||
POWER_STATUS_VALUES: {
|
POWER_STATUS_VALUES: {
|
||||||
online: 2, // lgpPwrOutputSource: 2=normal (mains power)
|
online: 2, // lgpPwrOutputSource: 2=normal (mains power)
|
||||||
onBattery: 3, // lgpPwrOutputSource: 3=onBattery
|
onBattery: 3, // lgpPwrOutputSource: 3=onBattery
|
||||||
@@ -69,6 +89,10 @@ export class UpsOidSets {
|
|||||||
POWER_STATUS: '',
|
POWER_STATUS: '',
|
||||||
BATTERY_CAPACITY: '',
|
BATTERY_CAPACITY: '',
|
||||||
BATTERY_RUNTIME: '',
|
BATTERY_RUNTIME: '',
|
||||||
|
OUTPUT_LOAD: '',
|
||||||
|
OUTPUT_POWER: '',
|
||||||
|
OUTPUT_VOLTAGE: '',
|
||||||
|
OUTPUT_CURRENT: '',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -90,6 +114,10 @@ export class UpsOidSets {
|
|||||||
'power status': '1.3.6.1.2.1.33.1.4.1.0', // upsOutputSource
|
'power status': '1.3.6.1.2.1.33.1.4.1.0', // upsOutputSource
|
||||||
'battery capacity': '1.3.6.1.2.1.33.1.2.4.0', // upsEstimatedChargeRemaining
|
'battery capacity': '1.3.6.1.2.1.33.1.2.4.0', // upsEstimatedChargeRemaining
|
||||||
'battery runtime': '1.3.6.1.2.1.33.1.2.3.0', // upsEstimatedMinutesRemaining
|
'battery runtime': '1.3.6.1.2.1.33.1.2.3.0', // upsEstimatedMinutesRemaining
|
||||||
|
'output load': '1.3.6.1.2.1.33.1.4.4.1.5.1', // upsOutputPercentLoad (indexed by line)
|
||||||
|
'output power': '1.3.6.1.2.1.33.1.4.4.1.4.1', // upsOutputPower in watts (indexed by line)
|
||||||
|
'output voltage': '1.3.6.1.2.1.33.1.4.4.1.2.1', // upsOutputVoltage (indexed by line)
|
||||||
|
'output current': '1.3.6.1.2.1.33.1.4.4.1.3.1', // upsOutputCurrent in 0.1A (indexed by line)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,8 +14,16 @@ export interface IUpsStatus {
|
|||||||
batteryCapacity: number;
|
batteryCapacity: number;
|
||||||
/** Remaining runtime in minutes */
|
/** Remaining runtime in minutes */
|
||||||
batteryRuntime: number;
|
batteryRuntime: number;
|
||||||
|
/** Output load percentage (0-100) */
|
||||||
|
outputLoad: number;
|
||||||
|
/** Output power in watts */
|
||||||
|
outputPower: number;
|
||||||
|
/** Output voltage in volts */
|
||||||
|
outputVoltage: number;
|
||||||
|
/** Output current in amps */
|
||||||
|
outputCurrent: number;
|
||||||
/** Raw values from SNMP responses */
|
/** Raw values from SNMP responses */
|
||||||
raw: Record<string, any>;
|
raw: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -28,6 +36,14 @@ export interface IOidSet {
|
|||||||
BATTERY_CAPACITY: string;
|
BATTERY_CAPACITY: string;
|
||||||
/** OID for battery runtime */
|
/** OID for battery runtime */
|
||||||
BATTERY_RUNTIME: string;
|
BATTERY_RUNTIME: string;
|
||||||
|
/** OID for output load percentage */
|
||||||
|
OUTPUT_LOAD: string;
|
||||||
|
/** OID for output power in watts */
|
||||||
|
OUTPUT_POWER: string;
|
||||||
|
/** OID for output voltage */
|
||||||
|
OUTPUT_VOLTAGE: string;
|
||||||
|
/** OID for output current */
|
||||||
|
OUTPUT_CURRENT: string;
|
||||||
/** Power status value mappings */
|
/** Power status value mappings */
|
||||||
POWER_STATUS_VALUES?: {
|
POWER_STATUS_VALUES?: {
|
||||||
/** SNMP value that indicates UPS is online (on AC power) */
|
/** SNMP value that indicates UPS is online (on AC power) */
|
||||||
|
|||||||
113
ts/systemd.ts
113
ts/systemd.ts
@@ -1,10 +1,10 @@
|
|||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
import { promises as fs } from 'node:fs';
|
import { promises as fs } from 'node:fs';
|
||||||
import { execSync } from 'node:child_process';
|
import { execSync } from 'node:child_process';
|
||||||
import { NupstDaemon, type IUpsConfig } from './daemon.ts';
|
import { type IUpsConfig, NupstDaemon } from './daemon.ts';
|
||||||
import { NupstSnmp } from './snmp/manager.ts';
|
import { NupstSnmp } from './snmp/manager.ts';
|
||||||
import { logger } from './logger.ts';
|
import { logger } from './logger.ts';
|
||||||
import { theme, symbols, getBatteryColor, getRuntimeColor, formatPowerStatus } from './colors.ts';
|
import { formatPowerStatus, getBatteryColor, getRuntimeColor, symbols, theme } from './colors.ts';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Class for managing systemd service
|
* Class for managing systemd service
|
||||||
@@ -54,7 +54,11 @@ WantedBy=multi-user.target
|
|||||||
logger.log('');
|
logger.log('');
|
||||||
logger.error('No configuration found');
|
logger.error('No configuration found');
|
||||||
logger.log(` ${theme.dim('Config file:')} ${configPath}`);
|
logger.log(` ${theme.dim('Config file:')} ${configPath}`);
|
||||||
logger.log(` ${theme.dim('Run')} ${theme.command('nupst ups add')} ${theme.dim('to create a configuration')}`);
|
logger.log(
|
||||||
|
` ${theme.dim('Run')} ${theme.command('nupst ups add')} ${
|
||||||
|
theme.dim('to create a configuration')
|
||||||
|
}`,
|
||||||
|
);
|
||||||
logger.log('');
|
logger.log('');
|
||||||
throw new Error('Configuration not found');
|
throw new Error('Configuration not found');
|
||||||
}
|
}
|
||||||
@@ -142,6 +146,9 @@ WantedBy=multi-user.target
|
|||||||
private async displayVersionInfo(): Promise<void> {
|
private async displayVersionInfo(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const nupst = this.daemon.getNupstSnmp().getNupst();
|
const nupst = this.daemon.getNupstSnmp().getNupst();
|
||||||
|
if (!nupst) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const version = nupst.getVersion();
|
const version = nupst.getVersion();
|
||||||
|
|
||||||
// Check for updates
|
// Check for updates
|
||||||
@@ -152,22 +159,30 @@ WantedBy=multi-user.target
|
|||||||
const updateStatus = nupst.getUpdateStatus();
|
const updateStatus = nupst.getUpdateStatus();
|
||||||
logger.log('');
|
logger.log('');
|
||||||
logger.log(
|
logger.log(
|
||||||
`${theme.dim('NUPST')} ${theme.dim('v' + version)} ${symbols.warning} ${theme.statusWarning(`Update available: v${updateStatus.latestVersion}`)}`,
|
`${theme.dim('NUPST')} ${theme.dim('v' + version)} ${symbols.warning} ${
|
||||||
|
theme.statusWarning(`Update available: v${updateStatus.latestVersion}`)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
logger.log(
|
||||||
|
` ${theme.dim('Run')} ${theme.command('sudo nupst update')} ${theme.dim('to upgrade')}`,
|
||||||
);
|
);
|
||||||
logger.log(` ${theme.dim('Run')} ${theme.command('sudo nupst update')} ${theme.dim('to upgrade')}`);
|
|
||||||
} else {
|
} else {
|
||||||
logger.log('');
|
logger.log('');
|
||||||
logger.log(
|
logger.log(
|
||||||
`${theme.dim('NUPST')} ${theme.dim('v' + version)} ${symbols.success} ${theme.success('Up to date')}`,
|
`${theme.dim('NUPST')} ${theme.dim('v' + version)} ${symbols.success} ${
|
||||||
|
theme.success('Up to date')
|
||||||
|
}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// If version check fails, show at least the current version
|
// If version check fails, show at least the current version
|
||||||
try {
|
try {
|
||||||
const nupst = this.daemon.getNupstSnmp().getNupst();
|
const nupst = this.daemon.getNupstSnmp().getNupst();
|
||||||
const version = nupst.getVersion();
|
if (nupst) {
|
||||||
logger.log('');
|
const version = nupst.getVersion();
|
||||||
logger.log(`${theme.dim('NUPST')} ${theme.dim('v' + version)}`);
|
logger.log('');
|
||||||
|
logger.log(`${theme.dim('NUPST')} ${theme.dim('v' + version)}`);
|
||||||
|
}
|
||||||
} catch (_innerError) {
|
} catch (_innerError) {
|
||||||
// Silently fail if we can't even get the version
|
// Silently fail if we can't even get the version
|
||||||
}
|
}
|
||||||
@@ -237,9 +252,15 @@ WantedBy=multi-user.target
|
|||||||
// Display beautiful status
|
// Display beautiful status
|
||||||
logger.log('');
|
logger.log('');
|
||||||
if (isActive) {
|
if (isActive) {
|
||||||
logger.log(`${symbols.running} ${theme.success('Service:')} ${theme.statusActive('active (running)')}`);
|
logger.log(
|
||||||
|
`${symbols.running} ${theme.success('Service:')} ${
|
||||||
|
theme.statusActive('active (running)')
|
||||||
|
}`,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
logger.log(`${symbols.stopped} ${theme.dim('Service:')} ${theme.statusInactive('inactive')}`);
|
logger.log(
|
||||||
|
`${symbols.stopped} ${theme.dim('Service:')} ${theme.statusInactive('inactive')}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pid || memory || cpu) {
|
if (pid || memory || cpu) {
|
||||||
@@ -250,10 +271,11 @@ WantedBy=multi-user.target
|
|||||||
logger.log(` ${details.join(' ')}`);
|
logger.log(` ${details.join(' ')}`);
|
||||||
}
|
}
|
||||||
logger.log('');
|
logger.log('');
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.log('');
|
logger.log('');
|
||||||
logger.log(`${symbols.stopped} ${theme.dim('Service:')} ${theme.statusInactive('not installed')}`);
|
logger.log(
|
||||||
|
`${symbols.stopped} ${theme.dim('Service:')} ${theme.statusInactive('not installed')}`,
|
||||||
|
);
|
||||||
logger.log('');
|
logger.log('');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -290,13 +312,13 @@ WantedBy=multi-user.target
|
|||||||
groups: [],
|
groups: [],
|
||||||
actions: config.thresholds
|
actions: config.thresholds
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
type: 'shutdown',
|
type: 'shutdown',
|
||||||
thresholds: config.thresholds,
|
thresholds: config.thresholds,
|
||||||
triggerMode: 'onlyThresholds',
|
triggerMode: 'onlyThresholds',
|
||||||
shutdownDelay: 5,
|
shutdownDelay: 5,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: [],
|
: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -304,7 +326,9 @@ WantedBy=multi-user.target
|
|||||||
} else {
|
} else {
|
||||||
logger.log('');
|
logger.log('');
|
||||||
logger.warn('No UPS devices configured');
|
logger.warn('No UPS devices configured');
|
||||||
logger.log(` ${theme.dim('Run')} ${theme.command('nupst ups add')} ${theme.dim('to add a device')}`);
|
logger.log(
|
||||||
|
` ${theme.dim('Run')} ${theme.command('nupst ups add')} ${theme.dim('to add a device')}`,
|
||||||
|
);
|
||||||
logger.log('');
|
logger.log('');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -339,7 +363,9 @@ WantedBy=multi-user.target
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Display UPS name and power status
|
// Display UPS name and power status
|
||||||
logger.log(` ${statusSymbol} ${theme.highlight(ups.name)} - ${formatPowerStatus(status.powerStatus)}`);
|
logger.log(
|
||||||
|
` ${statusSymbol} ${theme.highlight(ups.name)} - ${formatPowerStatus(status.powerStatus)}`,
|
||||||
|
);
|
||||||
|
|
||||||
// Display battery with color coding
|
// Display battery with color coding
|
||||||
const batteryColor = getBatteryColor(status.batteryCapacity);
|
const batteryColor = getBatteryColor(status.batteryCapacity);
|
||||||
@@ -347,13 +373,27 @@ WantedBy=multi-user.target
|
|||||||
// Get threshold from actions (if any action has thresholds defined)
|
// Get threshold from actions (if any action has thresholds defined)
|
||||||
const actionWithThresholds = ups.actions?.find((action) => action.thresholds);
|
const actionWithThresholds = ups.actions?.find((action) => action.thresholds);
|
||||||
const batteryThreshold = actionWithThresholds?.thresholds?.battery;
|
const batteryThreshold = actionWithThresholds?.thresholds?.battery;
|
||||||
const batterySymbol = batteryThreshold !== undefined && status.batteryCapacity >= batteryThreshold
|
const batterySymbol =
|
||||||
? symbols.success
|
batteryThreshold !== undefined && status.batteryCapacity >= batteryThreshold
|
||||||
: batteryThreshold !== undefined
|
? symbols.success
|
||||||
? symbols.warning
|
: batteryThreshold !== undefined
|
||||||
: '';
|
? symbols.warning
|
||||||
|
: '';
|
||||||
|
|
||||||
logger.log(` Battery: ${batteryColor(status.batteryCapacity + '%')} ${batterySymbol} Runtime: ${getRuntimeColor(status.batteryRuntime)(status.batteryRuntime + ' min')}`);
|
logger.log(
|
||||||
|
` Battery: ${batteryColor(status.batteryCapacity + '%')} ${batterySymbol} Runtime: ${
|
||||||
|
getRuntimeColor(status.batteryRuntime)(status.batteryRuntime + ' min')
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Display power metrics
|
||||||
|
logger.log(
|
||||||
|
` Load: ${theme.highlight(status.outputLoad + '%')} Power: ${
|
||||||
|
theme.highlight(status.outputPower + 'W')
|
||||||
|
} Voltage: ${theme.highlight(status.outputVoltage + 'V')} Current: ${
|
||||||
|
theme.highlight(status.outputCurrent + 'A')
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
|
||||||
// Display host info
|
// Display host info
|
||||||
logger.log(` ${theme.dim(`Host: ${ups.snmp.host}:${ups.snmp.port}`)}`);
|
logger.log(` ${theme.dim(`Host: ${ups.snmp.host}:${ups.snmp.port}`)}`);
|
||||||
@@ -373,7 +413,9 @@ WantedBy=multi-user.target
|
|||||||
for (const action of ups.actions) {
|
for (const action of ups.actions) {
|
||||||
let actionDesc = `${action.type}`;
|
let actionDesc = `${action.type}`;
|
||||||
if (action.thresholds) {
|
if (action.thresholds) {
|
||||||
actionDesc += ` (${action.triggerMode || 'onlyThresholds'}: battery<${action.thresholds.battery}%, runtime<${action.thresholds.runtime}min`;
|
actionDesc += ` (${
|
||||||
|
action.triggerMode || 'onlyThresholds'
|
||||||
|
}: battery<${action.thresholds.battery}%, runtime<${action.thresholds.runtime}min`;
|
||||||
if (action.shutdownDelay) {
|
if (action.shutdownDelay) {
|
||||||
actionDesc += `, delay=${action.shutdownDelay}s`;
|
actionDesc += `, delay=${action.shutdownDelay}s`;
|
||||||
}
|
}
|
||||||
@@ -390,10 +432,11 @@ WantedBy=multi-user.target
|
|||||||
}
|
}
|
||||||
|
|
||||||
logger.log('');
|
logger.log('');
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Display error for this UPS
|
// Display error for this UPS
|
||||||
logger.log(` ${symbols.error} ${theme.highlight(ups.name)} - ${theme.error('Connection failed')}`);
|
logger.log(
|
||||||
|
` ${symbols.error} ${theme.highlight(ups.name)} - ${theme.error('Connection failed')}`,
|
||||||
|
);
|
||||||
logger.log(` ${theme.dim(error instanceof Error ? error.message : String(error))}`);
|
logger.log(` ${theme.dim(error instanceof Error ? error.message : String(error))}`);
|
||||||
logger.log(` ${theme.dim(`Host: ${ups.snmp.host}:${ups.snmp.port}`)}`);
|
logger.log(` ${theme.dim(`Host: ${ups.snmp.host}:${ups.snmp.port}`)}`);
|
||||||
logger.log('');
|
logger.log('');
|
||||||
@@ -418,7 +461,9 @@ WantedBy=multi-user.target
|
|||||||
// Display group name and mode
|
// Display group name and mode
|
||||||
const modeColor = group.mode === 'redundant' ? theme.success : theme.warning;
|
const modeColor = group.mode === 'redundant' ? theme.success : theme.warning;
|
||||||
logger.log(
|
logger.log(
|
||||||
` ${symbols.info} ${theme.highlight(group.name)} ${theme.dim(`(${modeColor(group.mode)})`)}`,
|
` ${symbols.info} ${theme.highlight(group.name)} ${
|
||||||
|
theme.dim(`(${modeColor(group.mode)})`)
|
||||||
|
}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Display description if present
|
// Display description if present
|
||||||
@@ -443,7 +488,9 @@ WantedBy=multi-user.target
|
|||||||
for (const action of group.actions) {
|
for (const action of group.actions) {
|
||||||
let actionDesc = `${action.type}`;
|
let actionDesc = `${action.type}`;
|
||||||
if (action.thresholds) {
|
if (action.thresholds) {
|
||||||
actionDesc += ` (${action.triggerMode || 'onlyThresholds'}: battery<${action.thresholds.battery}%, runtime<${action.thresholds.runtime}min`;
|
actionDesc += ` (${
|
||||||
|
action.triggerMode || 'onlyThresholds'
|
||||||
|
}: battery<${action.thresholds.battery}%, runtime<${action.thresholds.runtime}min`;
|
||||||
if (action.shutdownDelay) {
|
if (action.shutdownDelay) {
|
||||||
actionDesc += `, delay=${action.shutdownDelay}s`;
|
actionDesc += `, delay=${action.shutdownDelay}s`;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user