Compare commits
12 Commits
Author | SHA1 | Date | |
---|---|---|---|
f2ce0180d3 | |||
8c1be6555f | |||
1a5558e91f | |||
611a9ddd19 | |||
afd026d08c | |||
2c8ea44d40 | |||
32bd27b849 | |||
a7113d0387 | |||
61d4e9037a | |||
caced2718f | |||
8516056f84 | |||
07ec9d7595 |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@serve.zone/nupst",
|
"name": "@serve.zone/nupst",
|
||||||
"version": "4.1.4",
|
"version": "4.2.2",
|
||||||
"exports": "./mod.ts",
|
"exports": "./mod.ts",
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"dev": "deno run --allow-all mod.ts",
|
"dev": "deno run --allow-all mod.ts",
|
||||||
|
122
example-action.sh
Normal file
122
example-action.sh
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# NUPST Action Script Example
|
||||||
|
# Copy this to /etc/nupst/ and customize for your needs
|
||||||
|
#
|
||||||
|
# This script is called by NUPST when power events or threshold violations occur.
|
||||||
|
# It receives UPS state information via environment variables and command-line arguments.
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# ARGUMENTS (positional parameters)
|
||||||
|
# ==============================================================================
|
||||||
|
# $1 = Power Status (online|onBattery|unknown)
|
||||||
|
# $2 = Battery Capacity (percentage, 0-100)
|
||||||
|
# $3 = Battery Runtime (estimated minutes remaining)
|
||||||
|
|
||||||
|
POWER_STATUS=$1
|
||||||
|
BATTERY_CAPACITY=$2
|
||||||
|
BATTERY_RUNTIME=$3
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# ENVIRONMENT VARIABLES
|
||||||
|
# ==============================================================================
|
||||||
|
# NUPST_UPS_ID - Unique UPS identifier
|
||||||
|
# NUPST_UPS_NAME - Human-readable UPS name
|
||||||
|
# NUPST_POWER_STATUS - Current power status
|
||||||
|
# NUPST_BATTERY_CAPACITY - Battery percentage (0-100)
|
||||||
|
# NUPST_BATTERY_RUNTIME - Estimated runtime in minutes
|
||||||
|
# NUPST_THRESHOLDS_EXCEEDED - "true" if below configured thresholds
|
||||||
|
# NUPST_TRIGGER_REASON - "powerStatusChange" or "thresholdViolation"
|
||||||
|
# NUPST_BATTERY_THRESHOLD - Configured battery threshold percentage
|
||||||
|
# NUPST_RUNTIME_THRESHOLD - Configured runtime threshold in minutes
|
||||||
|
# NUPST_TIMESTAMP - Unix timestamp (milliseconds since epoch)
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# EXAMPLE: Log the event
|
||||||
|
# ==============================================================================
|
||||||
|
LOG_FILE="/var/log/nupst-actions.log"
|
||||||
|
|
||||||
|
echo "========================================" >> "$LOG_FILE"
|
||||||
|
echo "NUPST Action Triggered: $(date)" >> "$LOG_FILE"
|
||||||
|
echo "----------------------------------------" >> "$LOG_FILE"
|
||||||
|
echo "UPS: $NUPST_UPS_NAME ($NUPST_UPS_ID)" >> "$LOG_FILE"
|
||||||
|
echo "Power Status: $POWER_STATUS" >> "$LOG_FILE"
|
||||||
|
echo "Battery: $BATTERY_CAPACITY%" >> "$LOG_FILE"
|
||||||
|
echo "Runtime: $BATTERY_RUNTIME minutes" >> "$LOG_FILE"
|
||||||
|
echo "Trigger Reason: $NUPST_TRIGGER_REASON" >> "$LOG_FILE"
|
||||||
|
echo "Thresholds Exceeded: $NUPST_THRESHOLDS_EXCEEDED" >> "$LOG_FILE"
|
||||||
|
echo "========================================" >> "$LOG_FILE"
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# EXAMPLE: Send email notification
|
||||||
|
# ==============================================================================
|
||||||
|
# if [ "$NUPST_TRIGGER_REASON" = "thresholdViolation" ]; then
|
||||||
|
# echo "ALERT: UPS $NUPST_UPS_NAME battery critical!" | \
|
||||||
|
# mail -s "UPS Battery Critical" admin@example.com
|
||||||
|
# fi
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# EXAMPLE: Gracefully shutdown virtual machines
|
||||||
|
# ==============================================================================
|
||||||
|
# if [ "$NUPST_POWER_STATUS" = "onBattery" ] && [ "$NUPST_THRESHOLDS_EXCEEDED" = "true" ]; then
|
||||||
|
# echo "Shutting down VMs..." >> "$LOG_FILE"
|
||||||
|
# # virsh shutdown vm1
|
||||||
|
# # virsh shutdown vm2
|
||||||
|
# # Wait for VMs to shutdown
|
||||||
|
# # sleep 120
|
||||||
|
# fi
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# EXAMPLE: Call external API/service
|
||||||
|
# ==============================================================================
|
||||||
|
# curl -X POST https://monitoring.example.com/ups-alert \
|
||||||
|
# -H "Content-Type: application/json" \
|
||||||
|
# -d "{
|
||||||
|
# \"upsId\": \"$NUPST_UPS_ID\",
|
||||||
|
# \"upsName\": \"$NUPST_UPS_NAME\",
|
||||||
|
# \"powerStatus\": \"$POWER_STATUS\",
|
||||||
|
# \"batteryCapacity\": $BATTERY_CAPACITY,
|
||||||
|
# \"batteryRuntime\": $BATTERY_RUNTIME,
|
||||||
|
# \"triggerReason\": \"$NUPST_TRIGGER_REASON\"
|
||||||
|
# }"
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# EXAMPLE: Remote shutdown via SSH with password
|
||||||
|
# ==============================================================================
|
||||||
|
# You can implement custom shutdown logic for remote systems
|
||||||
|
# that require password authentication or webhooks
|
||||||
|
#
|
||||||
|
# if [ "$NUPST_THRESHOLDS_EXCEEDED" = "true" ]; then
|
||||||
|
# # Call a webhook with a secret password/token
|
||||||
|
# curl -X POST "https://remote-server.local/shutdown?token=YOUR_SECRET_TOKEN"
|
||||||
|
#
|
||||||
|
# # Or use SSH with password (requires sshpass)
|
||||||
|
# # sshpass -p 'your-password' ssh user@remote-server 'sudo shutdown -h +5'
|
||||||
|
# fi
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# EXAMPLE: Conditional logic based on battery level
|
||||||
|
# ==============================================================================
|
||||||
|
# if [ "$BATTERY_CAPACITY" -lt 20 ]; then
|
||||||
|
# echo "Battery critically low! Immediate action needed." >> "$LOG_FILE"
|
||||||
|
# elif [ "$BATTERY_CAPACITY" -lt 50 ]; then
|
||||||
|
# echo "Battery low. Preparing for shutdown." >> "$LOG_FILE"
|
||||||
|
# else
|
||||||
|
# echo "Battery acceptable. Monitoring." >> "$LOG_FILE"
|
||||||
|
# fi
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# EXAMPLE: Different actions for different trigger reasons
|
||||||
|
# ==============================================================================
|
||||||
|
# case "$NUPST_TRIGGER_REASON" in
|
||||||
|
# powerStatusChange)
|
||||||
|
# echo "Power status changed to: $POWER_STATUS" >> "$LOG_FILE"
|
||||||
|
# # Send notification but don't take drastic action yet
|
||||||
|
# ;;
|
||||||
|
# thresholdViolation)
|
||||||
|
# echo "Thresholds violated! Taking emergency action." >> "$LOG_FILE"
|
||||||
|
# # Initiate graceful shutdowns, save data, etc.
|
||||||
|
# ;;
|
||||||
|
# esac
|
||||||
|
|
||||||
|
# Exit with success
|
||||||
|
exit 0
|
170
ts/actions/base-action.ts
Normal file
170
ts/actions/base-action.ts
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
/**
|
||||||
|
* Base classes and interfaces for the NUPST action system
|
||||||
|
*
|
||||||
|
* Actions are triggered on:
|
||||||
|
* 1. Power status changes (online ↔ onBattery)
|
||||||
|
* 2. Threshold violations (battery/runtime cross below configured thresholds)
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type TPowerStatus = 'online' | 'onBattery' | 'unknown';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Context provided to actions when they execute
|
||||||
|
* Contains all relevant UPS state and trigger information
|
||||||
|
*/
|
||||||
|
export interface IActionContext {
|
||||||
|
// UPS identification
|
||||||
|
/** Unique ID of the UPS */
|
||||||
|
upsId: string;
|
||||||
|
/** Human-readable name of the UPS */
|
||||||
|
upsName: string;
|
||||||
|
|
||||||
|
// Current state
|
||||||
|
/** Current power status */
|
||||||
|
powerStatus: TPowerStatus;
|
||||||
|
/** Current battery capacity percentage (0-100) */
|
||||||
|
batteryCapacity: number;
|
||||||
|
/** Estimated battery runtime in minutes */
|
||||||
|
batteryRuntime: number;
|
||||||
|
|
||||||
|
// State tracking
|
||||||
|
/** Previous power status before this trigger */
|
||||||
|
previousPowerStatus: TPowerStatus;
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
/** Timestamp when this action was triggered (milliseconds since epoch) */
|
||||||
|
timestamp: number;
|
||||||
|
/** Reason this action was triggered */
|
||||||
|
triggerReason: 'powerStatusChange' | 'thresholdViolation';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Action trigger mode - determines when an action executes
|
||||||
|
*/
|
||||||
|
export type TActionTriggerMode =
|
||||||
|
| 'onlyPowerChanges' // Only on power status changes (online ↔ onBattery)
|
||||||
|
| 'onlyThresholds' // Only when action's thresholds are exceeded
|
||||||
|
| 'powerChangesAndThresholds' // On power changes OR threshold violations
|
||||||
|
| 'anyChange'; // On every UPS poll/check (every ~30s)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for an action
|
||||||
|
*/
|
||||||
|
export interface IActionConfig {
|
||||||
|
/** Type of action to execute */
|
||||||
|
type: 'shutdown' | 'webhook' | 'script';
|
||||||
|
|
||||||
|
// Trigger configuration
|
||||||
|
/**
|
||||||
|
* When should this action be triggered?
|
||||||
|
* - onlyPowerChanges: Only on power status changes
|
||||||
|
* - onlyThresholds: Only when thresholds exceeded
|
||||||
|
* - powerChangesAndThresholds: On both (default)
|
||||||
|
* - anyChange: On every check
|
||||||
|
*/
|
||||||
|
triggerMode?: TActionTriggerMode;
|
||||||
|
|
||||||
|
// Threshold configuration (applies to all action types)
|
||||||
|
/** Threshold settings for this action */
|
||||||
|
thresholds?: {
|
||||||
|
/** Battery percentage threshold (0-100) */
|
||||||
|
battery: number;
|
||||||
|
/** Runtime threshold in minutes */
|
||||||
|
runtime: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Shutdown action configuration
|
||||||
|
/** Delay before shutdown in minutes (default: 5) */
|
||||||
|
shutdownDelay?: number;
|
||||||
|
/** Only execute shutdown on threshold violation, not power status changes */
|
||||||
|
onlyOnThresholdViolation?: boolean;
|
||||||
|
|
||||||
|
// Webhook action configuration
|
||||||
|
/** URL to call for webhook */
|
||||||
|
webhookUrl?: string;
|
||||||
|
/** HTTP method to use (default: POST) */
|
||||||
|
webhookMethod?: 'GET' | 'POST';
|
||||||
|
/** Timeout for webhook request in milliseconds (default: 10000) */
|
||||||
|
webhookTimeout?: number;
|
||||||
|
/** Only execute webhook on threshold violation */
|
||||||
|
webhookOnlyOnThresholdViolation?: boolean;
|
||||||
|
|
||||||
|
// Script action configuration
|
||||||
|
/** Path to script relative to /etc/nupst (e.g., "myaction.sh") */
|
||||||
|
scriptPath?: string;
|
||||||
|
/** Timeout for script execution in milliseconds (default: 60000) */
|
||||||
|
scriptTimeout?: number;
|
||||||
|
/** Only execute script on threshold violation */
|
||||||
|
scriptOnlyOnThresholdViolation?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abstract base class for all actions
|
||||||
|
* Each action type must extend this class and implement execute()
|
||||||
|
*/
|
||||||
|
export abstract class Action {
|
||||||
|
/** Type identifier for this action */
|
||||||
|
abstract readonly type: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new action with the given configuration
|
||||||
|
* @param config Action configuration
|
||||||
|
*/
|
||||||
|
constructor(protected config: IActionConfig) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute this action with the given context
|
||||||
|
* @param context Current UPS state and trigger information
|
||||||
|
*/
|
||||||
|
abstract execute(context: IActionContext): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to check if this action should execute based on trigger mode
|
||||||
|
* @param context Action context with current UPS state
|
||||||
|
* @returns True if action should execute
|
||||||
|
*/
|
||||||
|
protected shouldExecute(context: IActionContext): boolean {
|
||||||
|
const mode = this.config.triggerMode || 'powerChangesAndThresholds'; // Default
|
||||||
|
|
||||||
|
switch (mode) {
|
||||||
|
case 'onlyPowerChanges':
|
||||||
|
// Only execute on power status changes
|
||||||
|
return context.triggerReason === 'powerStatusChange';
|
||||||
|
|
||||||
|
case 'onlyThresholds':
|
||||||
|
// Only execute when this action's thresholds are exceeded
|
||||||
|
if (!this.config.thresholds) return false; // No thresholds = never execute
|
||||||
|
return this.areThresholdsExceeded(context.batteryCapacity, context.batteryRuntime);
|
||||||
|
|
||||||
|
case 'powerChangesAndThresholds':
|
||||||
|
// Execute on power changes OR when thresholds exceeded
|
||||||
|
if (context.triggerReason === 'powerStatusChange') return true;
|
||||||
|
if (!this.config.thresholds) return false;
|
||||||
|
return this.areThresholdsExceeded(context.batteryCapacity, context.batteryRuntime);
|
||||||
|
|
||||||
|
case 'anyChange':
|
||||||
|
// Execute on every trigger (power change or threshold check)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if current battery/runtime exceeds this action's thresholds
|
||||||
|
* @param batteryCapacity Current battery percentage
|
||||||
|
* @param batteryRuntime Current runtime in minutes
|
||||||
|
* @returns True if thresholds are exceeded
|
||||||
|
*/
|
||||||
|
protected areThresholdsExceeded(batteryCapacity: number, batteryRuntime: number): boolean {
|
||||||
|
if (!this.config.thresholds) {
|
||||||
|
return false; // No thresholds configured
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
batteryCapacity < this.config.thresholds.battery ||
|
||||||
|
batteryRuntime < this.config.thresholds.runtime
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
91
ts/actions/index.ts
Normal file
91
ts/actions/index.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
/**
|
||||||
|
* Action system exports and ActionManager
|
||||||
|
*
|
||||||
|
* This module provides the central coordination for the action system.
|
||||||
|
* The ActionManager is responsible for creating and executing actions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { logger } from '../logger.ts';
|
||||||
|
import type { Action, IActionConfig, IActionContext } from './base-action.ts';
|
||||||
|
import { ShutdownAction } from './shutdown-action.ts';
|
||||||
|
import { WebhookAction } from './webhook-action.ts';
|
||||||
|
import { ScriptAction } from './script-action.ts';
|
||||||
|
|
||||||
|
// Re-export types for convenience
|
||||||
|
export type { IActionConfig, IActionContext, TPowerStatus } from './base-action.ts';
|
||||||
|
export { Action } from './base-action.ts';
|
||||||
|
export { ShutdownAction } from './shutdown-action.ts';
|
||||||
|
export { WebhookAction } from './webhook-action.ts';
|
||||||
|
export { ScriptAction } from './script-action.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ActionManager - Coordinates action creation and execution
|
||||||
|
*
|
||||||
|
* Provides factory methods for creating actions from configuration
|
||||||
|
* and orchestrates action execution with error handling.
|
||||||
|
*/
|
||||||
|
export class ActionManager {
|
||||||
|
/**
|
||||||
|
* Create an action instance from configuration
|
||||||
|
* @param config Action configuration
|
||||||
|
* @returns Instantiated action
|
||||||
|
* @throws Error if action type is unknown
|
||||||
|
*/
|
||||||
|
static createAction(config: IActionConfig): Action {
|
||||||
|
switch (config.type) {
|
||||||
|
case 'shutdown':
|
||||||
|
return new ShutdownAction(config);
|
||||||
|
case 'webhook':
|
||||||
|
return new WebhookAction(config);
|
||||||
|
case 'script':
|
||||||
|
return new ScriptAction(config);
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown action type: ${(config as IActionConfig).type}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a sequence of actions with the given context
|
||||||
|
* Each action runs sequentially, and failures are logged but don't stop the chain
|
||||||
|
* @param actions Array of action configurations to execute
|
||||||
|
* @param context Action context with UPS state
|
||||||
|
*/
|
||||||
|
static async executeActions(
|
||||||
|
actions: IActionConfig[],
|
||||||
|
context: IActionContext,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!actions || actions.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.log('');
|
||||||
|
logger.logBoxTitle(`Executing ${actions.length} Action(s)`, 60, 'info');
|
||||||
|
logger.logBoxLine(`Trigger: ${context.triggerReason}`);
|
||||||
|
logger.logBoxLine(`UPS: ${context.upsName} (${context.upsId})`);
|
||||||
|
logger.logBoxLine(`Power: ${context.powerStatus}`);
|
||||||
|
logger.logBoxLine(`Battery: ${context.batteryCapacity}% / ${context.batteryRuntime} min`);
|
||||||
|
logger.logBoxEnd();
|
||||||
|
logger.log('');
|
||||||
|
|
||||||
|
for (let i = 0; i < actions.length; i++) {
|
||||||
|
const actionConfig = actions[i];
|
||||||
|
try {
|
||||||
|
logger.info(`[${i + 1}/${actions.length}] ${actionConfig.type} action...`);
|
||||||
|
|
||||||
|
const action = this.createAction(actionConfig);
|
||||||
|
await action.execute(context);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`Action ${actionConfig.type} failed: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
// Continue with next action despite failure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.log('');
|
||||||
|
logger.success('Action execution completed');
|
||||||
|
logger.log('');
|
||||||
|
}
|
||||||
|
}
|
166
ts/actions/script-action.ts
Normal file
166
ts/actions/script-action.ts
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import * as path from 'node:path';
|
||||||
|
import * as fs from 'node:fs';
|
||||||
|
import { exec } from 'node:child_process';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
|
import { Action, type IActionConfig, type IActionContext } from './base-action.ts';
|
||||||
|
import { logger } from '../logger.ts';
|
||||||
|
|
||||||
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ScriptAction - Executes a custom shell script from /etc/nupst/
|
||||||
|
*
|
||||||
|
* Runs user-provided scripts with UPS state passed as environment variables and arguments.
|
||||||
|
* Scripts must be .sh files located in /etc/nupst/ for security.
|
||||||
|
*/
|
||||||
|
export class ScriptAction extends Action {
|
||||||
|
readonly type = 'script';
|
||||||
|
|
||||||
|
private static readonly SCRIPT_DIR = '/etc/nupst';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the script action
|
||||||
|
* @param context Action context with UPS state
|
||||||
|
*/
|
||||||
|
async execute(context: IActionContext): Promise<void> {
|
||||||
|
// Check if we should execute based on trigger mode
|
||||||
|
if (!this.shouldExecute(context)) {
|
||||||
|
logger.info(`Script action skipped (trigger mode: ${this.config.triggerMode || 'powerChangesAndThresholds'})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.config.scriptPath) {
|
||||||
|
logger.error('Script path not configured');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate and build script path
|
||||||
|
const scriptPath = this.validateAndBuildScriptPath(this.config.scriptPath);
|
||||||
|
if (!scriptPath) {
|
||||||
|
logger.error(`Invalid script path: ${this.config.scriptPath}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if script exists and is executable
|
||||||
|
if (!fs.existsSync(scriptPath)) {
|
||||||
|
logger.error(`Script not found: ${scriptPath}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeout = this.config.scriptTimeout || 60000; // Default 60 seconds
|
||||||
|
|
||||||
|
logger.info(`Executing script: ${scriptPath}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.executeScript(scriptPath, context, timeout);
|
||||||
|
logger.success('Script executed successfully');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`Script execution failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
);
|
||||||
|
// Don't throw - script failures shouldn't stop other actions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate script path and build full path
|
||||||
|
* Ensures security by preventing path traversal and limiting to /etc/nupst
|
||||||
|
* @param scriptPath Relative script path from config
|
||||||
|
* @returns Full validated path or null if invalid
|
||||||
|
*/
|
||||||
|
private validateAndBuildScriptPath(scriptPath: string): string | null {
|
||||||
|
// Remove any leading/trailing whitespace
|
||||||
|
scriptPath = scriptPath.trim();
|
||||||
|
|
||||||
|
// Reject paths with path traversal attempts
|
||||||
|
if (scriptPath.includes('..') || scriptPath.includes('/') || scriptPath.includes('\\')) {
|
||||||
|
logger.error('Script path must not contain directory separators or parent references');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Require .sh extension
|
||||||
|
if (!scriptPath.endsWith('.sh')) {
|
||||||
|
logger.error('Script must have .sh extension');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build full path
|
||||||
|
return path.join(ScriptAction.SCRIPT_DIR, scriptPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the script with UPS state as environment variables and arguments
|
||||||
|
* @param scriptPath Full path to the script
|
||||||
|
* @param context Action context
|
||||||
|
* @param timeout Execution timeout in milliseconds
|
||||||
|
*/
|
||||||
|
private async executeScript(
|
||||||
|
scriptPath: string,
|
||||||
|
context: IActionContext,
|
||||||
|
timeout: number,
|
||||||
|
): Promise<void> {
|
||||||
|
// Prepare environment variables
|
||||||
|
const env = {
|
||||||
|
...process.env,
|
||||||
|
NUPST_UPS_ID: context.upsId,
|
||||||
|
NUPST_UPS_NAME: context.upsName,
|
||||||
|
NUPST_POWER_STATUS: context.powerStatus,
|
||||||
|
NUPST_BATTERY_CAPACITY: String(context.batteryCapacity),
|
||||||
|
NUPST_BATTERY_RUNTIME: String(context.batteryRuntime),
|
||||||
|
NUPST_TRIGGER_REASON: context.triggerReason,
|
||||||
|
NUPST_TIMESTAMP: String(context.timestamp),
|
||||||
|
// Include action's own thresholds if configured
|
||||||
|
NUPST_BATTERY_THRESHOLD: this.config.thresholds ? String(this.config.thresholds.battery) : '',
|
||||||
|
NUPST_RUNTIME_THRESHOLD: this.config.thresholds ? String(this.config.thresholds.runtime) : '',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build command with arguments
|
||||||
|
// Arguments: powerStatus batteryCapacity batteryRuntime
|
||||||
|
const args = [
|
||||||
|
context.powerStatus,
|
||||||
|
String(context.batteryCapacity),
|
||||||
|
String(context.batteryRuntime),
|
||||||
|
].join(' ');
|
||||||
|
|
||||||
|
const command = `bash "${scriptPath}" ${args}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { stdout, stderr } = await execAsync(command, {
|
||||||
|
env,
|
||||||
|
cwd: ScriptAction.SCRIPT_DIR,
|
||||||
|
timeout,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log output
|
||||||
|
if (stdout) {
|
||||||
|
logger.log('Script stdout:');
|
||||||
|
logger.dim(stdout.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stderr) {
|
||||||
|
logger.warn('Script stderr:');
|
||||||
|
logger.dim(stderr.trim());
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Check if it was a timeout
|
||||||
|
if (error instanceof Error && 'killed' in error && error.killed) {
|
||||||
|
throw new Error(`Script timed out after ${timeout}ms`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include stdout/stderr in error if available
|
||||||
|
if (error && typeof error === 'object' && 'stdout' in error && 'stderr' in error) {
|
||||||
|
const execError = error as { stdout: string; stderr: string };
|
||||||
|
if (execError.stdout) {
|
||||||
|
logger.log('Script stdout:');
|
||||||
|
logger.dim(execError.stdout.trim());
|
||||||
|
}
|
||||||
|
if (execError.stderr) {
|
||||||
|
logger.warn('Script stderr:');
|
||||||
|
logger.dim(execError.stderr.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
142
ts/actions/shutdown-action.ts
Normal file
142
ts/actions/shutdown-action.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import * as fs from 'node:fs';
|
||||||
|
import { execFile } from 'node:child_process';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
|
import { Action, type IActionConfig, type IActionContext } from './base-action.ts';
|
||||||
|
import { logger } from '../logger.ts';
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ShutdownAction - Initiates system shutdown
|
||||||
|
*
|
||||||
|
* This action triggers a system shutdown using the standard shutdown command.
|
||||||
|
* It includes a configurable delay to allow VMs and services to gracefully terminate.
|
||||||
|
*/
|
||||||
|
export class ShutdownAction extends Action {
|
||||||
|
readonly type = 'shutdown';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the shutdown action
|
||||||
|
* @param context Action context with UPS state
|
||||||
|
*/
|
||||||
|
async execute(context: IActionContext): Promise<void> {
|
||||||
|
// Check if we should execute based on trigger mode and thresholds
|
||||||
|
if (!this.shouldExecute(context)) {
|
||||||
|
logger.info(`Shutdown action skipped (trigger mode: ${this.config.triggerMode || 'powerChangesAndThresholds'})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shutdownDelay = this.config.shutdownDelay || 5; // Default 5 minutes
|
||||||
|
|
||||||
|
logger.log('');
|
||||||
|
logger.logBoxTitle('Initiating System Shutdown', 60, 'error');
|
||||||
|
logger.logBoxLine(`UPS: ${context.upsName} (${context.upsId})`);
|
||||||
|
logger.logBoxLine(`Power Status: ${context.powerStatus}`);
|
||||||
|
logger.logBoxLine(`Battery: ${context.batteryCapacity}%`);
|
||||||
|
logger.logBoxLine(`Runtime: ${context.batteryRuntime} minutes`);
|
||||||
|
logger.logBoxLine(`Trigger: ${context.triggerReason}`);
|
||||||
|
logger.logBoxLine(`Shutdown delay: ${shutdownDelay} minutes`);
|
||||||
|
logger.logBoxEnd();
|
||||||
|
logger.log('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.executeShutdownCommand(shutdownDelay);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`Shutdown command failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
);
|
||||||
|
// Try alternative methods
|
||||||
|
await this.tryAlternativeShutdownMethods();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the primary shutdown command
|
||||||
|
* @param delayMinutes Minutes to delay before shutdown
|
||||||
|
*/
|
||||||
|
private async executeShutdownCommand(delayMinutes: number): Promise<void> {
|
||||||
|
// Find shutdown command in common system paths
|
||||||
|
const shutdownPaths = [
|
||||||
|
'/sbin/shutdown',
|
||||||
|
'/usr/sbin/shutdown',
|
||||||
|
'/bin/shutdown',
|
||||||
|
'/usr/bin/shutdown',
|
||||||
|
];
|
||||||
|
|
||||||
|
let shutdownCmd = '';
|
||||||
|
for (const path of shutdownPaths) {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(path)) {
|
||||||
|
shutdownCmd = path;
|
||||||
|
logger.log(`Found shutdown command at: ${shutdownCmd}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (_e) {
|
||||||
|
// Continue checking other paths
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shutdownCmd) {
|
||||||
|
// Execute shutdown command with delay to allow for VM graceful shutdown
|
||||||
|
const message = `UPS battery critical, shutting down in ${delayMinutes} minutes`;
|
||||||
|
logger.log(`Executing: ${shutdownCmd} -h +${delayMinutes} "${message}"`);
|
||||||
|
|
||||||
|
const { stdout } = await execFileAsync(shutdownCmd, [
|
||||||
|
'-h',
|
||||||
|
`+${delayMinutes}`,
|
||||||
|
message,
|
||||||
|
]);
|
||||||
|
|
||||||
|
logger.log(`Shutdown initiated: ${stdout}`);
|
||||||
|
logger.log(`Allowing ${delayMinutes} minutes for VMs to shut down safely`);
|
||||||
|
} else {
|
||||||
|
throw new Error('Shutdown command not found in common paths');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try alternative shutdown methods if primary command fails
|
||||||
|
*/
|
||||||
|
private async tryAlternativeShutdownMethods(): Promise<void> {
|
||||||
|
logger.error('Trying alternative shutdown methods...');
|
||||||
|
|
||||||
|
const alternatives = [
|
||||||
|
{ cmd: 'poweroff', args: ['--force'] },
|
||||||
|
{ cmd: 'halt', args: ['-p'] },
|
||||||
|
{ cmd: 'systemctl', args: ['poweroff'] },
|
||||||
|
{ cmd: 'reboot', args: ['-p'] }, // Some systems allow reboot -p for power off
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const alt of alternatives) {
|
||||||
|
try {
|
||||||
|
// First check if command exists in common system paths
|
||||||
|
const paths = [
|
||||||
|
`/sbin/${alt.cmd}`,
|
||||||
|
`/usr/sbin/${alt.cmd}`,
|
||||||
|
`/bin/${alt.cmd}`,
|
||||||
|
`/usr/bin/${alt.cmd}`,
|
||||||
|
];
|
||||||
|
|
||||||
|
let cmdPath = '';
|
||||||
|
for (const path of paths) {
|
||||||
|
if (fs.existsSync(path)) {
|
||||||
|
cmdPath = path;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cmdPath) {
|
||||||
|
logger.log(`Trying alternative shutdown method: ${cmdPath} ${alt.args.join(' ')}`);
|
||||||
|
await execFileAsync(cmdPath, alt.args);
|
||||||
|
logger.log(`Alternative method ${alt.cmd} succeeded`);
|
||||||
|
return; // Exit if successful
|
||||||
|
}
|
||||||
|
} catch (_altError) {
|
||||||
|
logger.error(`Alternative method ${alt.cmd} failed`);
|
||||||
|
// Continue to next method
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.error('All shutdown methods failed');
|
||||||
|
}
|
||||||
|
}
|
141
ts/actions/webhook-action.ts
Normal file
141
ts/actions/webhook-action.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import * as http from 'node:http';
|
||||||
|
import * as https from 'node:https';
|
||||||
|
import { URL } from 'node:url';
|
||||||
|
import { Action, type IActionConfig, type IActionContext } from './base-action.ts';
|
||||||
|
import { logger } from '../logger.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebhookAction - Calls an HTTP webhook with UPS state information
|
||||||
|
*
|
||||||
|
* Sends UPS status to a configured webhook URL via GET or POST.
|
||||||
|
* This is useful for remote notifications and integrations with external systems.
|
||||||
|
*/
|
||||||
|
export class WebhookAction extends Action {
|
||||||
|
readonly type = 'webhook';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the webhook action
|
||||||
|
* @param context Action context with UPS state
|
||||||
|
*/
|
||||||
|
async execute(context: IActionContext): Promise<void> {
|
||||||
|
// Check if we should execute based on trigger mode
|
||||||
|
if (!this.shouldExecute(context)) {
|
||||||
|
logger.info(`Webhook action skipped (trigger mode: ${this.config.triggerMode || 'powerChangesAndThresholds'})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.config.webhookUrl) {
|
||||||
|
logger.error('Webhook URL not configured');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const method = this.config.webhookMethod || 'POST';
|
||||||
|
const timeout = this.config.webhookTimeout || 10000;
|
||||||
|
|
||||||
|
logger.info(`Calling webhook: ${method} ${this.config.webhookUrl}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.callWebhook(context, method, timeout);
|
||||||
|
logger.success('Webhook call successful');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`Webhook call failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
);
|
||||||
|
// Don't throw - webhook failures shouldn't stop other actions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call the webhook with UPS state data
|
||||||
|
* @param context Action context
|
||||||
|
* @param method HTTP method (GET or POST)
|
||||||
|
* @param timeout Request timeout in milliseconds
|
||||||
|
*/
|
||||||
|
private async callWebhook(
|
||||||
|
context: IActionContext,
|
||||||
|
method: 'GET' | 'POST',
|
||||||
|
timeout: number,
|
||||||
|
): Promise<void> {
|
||||||
|
const payload: any = {
|
||||||
|
upsId: context.upsId,
|
||||||
|
upsName: context.upsName,
|
||||||
|
powerStatus: context.powerStatus,
|
||||||
|
batteryCapacity: context.batteryCapacity,
|
||||||
|
batteryRuntime: context.batteryRuntime,
|
||||||
|
triggerReason: context.triggerReason,
|
||||||
|
timestamp: context.timestamp,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Include action's own thresholds if configured
|
||||||
|
if (this.config.thresholds) {
|
||||||
|
payload.thresholds = {
|
||||||
|
battery: this.config.thresholds.battery,
|
||||||
|
runtime: this.config.thresholds.runtime,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(this.config.webhookUrl!);
|
||||||
|
|
||||||
|
if (method === 'GET') {
|
||||||
|
// Append payload as query parameters for GET
|
||||||
|
url.searchParams.append('upsId', payload.upsId);
|
||||||
|
url.searchParams.append('upsName', payload.upsName);
|
||||||
|
url.searchParams.append('powerStatus', payload.powerStatus);
|
||||||
|
url.searchParams.append('batteryCapacity', String(payload.batteryCapacity));
|
||||||
|
url.searchParams.append('batteryRuntime', String(payload.batteryRuntime));
|
||||||
|
|
||||||
|
url.searchParams.append('triggerReason', payload.triggerReason);
|
||||||
|
url.searchParams.append('timestamp', String(payload.timestamp));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const protocol = url.protocol === 'https:' ? https : http;
|
||||||
|
|
||||||
|
const options: http.RequestOptions = {
|
||||||
|
method,
|
||||||
|
headers: method === 'POST'
|
||||||
|
? {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'User-Agent': 'nupst',
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
'User-Agent': 'nupst',
|
||||||
|
},
|
||||||
|
timeout,
|
||||||
|
};
|
||||||
|
|
||||||
|
const req = protocol.request(url, options, (res) => {
|
||||||
|
let data = '';
|
||||||
|
|
||||||
|
res.on('data', (chunk) => {
|
||||||
|
data += chunk;
|
||||||
|
});
|
||||||
|
|
||||||
|
res.on('end', () => {
|
||||||
|
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
|
||||||
|
logger.dim(`Webhook response (${res.statusCode}): ${data.substring(0, 100)}`);
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
reject(new Error(`Webhook returned status ${res.statusCode}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('error', (error) => {
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('timeout', () => {
|
||||||
|
req.destroy();
|
||||||
|
reject(new Error(`Webhook request timed out after ${timeout}ms`));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send POST data if applicable
|
||||||
|
if (method === 'POST') {
|
||||||
|
req.write(JSON.stringify(payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
242
ts/cli.ts
242
ts/cli.ts
@@ -1,6 +1,6 @@
|
|||||||
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, type ITableColumn } from './logger.ts';
|
||||||
import { theme, symbols } from './colors.ts';
|
import { theme, symbols } from './colors.ts';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -303,154 +303,162 @@ export class NupstCli {
|
|||||||
try {
|
try {
|
||||||
await this.nupst.getDaemon().loadConfig();
|
await this.nupst.getDaemon().loadConfig();
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
const errorBoxWidth = 45;
|
logger.logBox('Configuration Error', [
|
||||||
logger.logBoxTitle('Configuration Error', errorBoxWidth);
|
'No configuration found.',
|
||||||
logger.logBoxLine('No configuration found.');
|
"Please run 'nupst ups add' first to create a configuration.",
|
||||||
logger.logBoxLine("Please run 'nupst setup' first to create a configuration.");
|
], 50, 'error');
|
||||||
logger.logBoxEnd();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get current configuration
|
// Get current configuration
|
||||||
const config = this.nupst.getDaemon().getConfig();
|
const config = this.nupst.getDaemon().getConfig();
|
||||||
|
|
||||||
const boxWidth = 50;
|
|
||||||
logger.logBoxTitle('NUPST Configuration', boxWidth);
|
|
||||||
|
|
||||||
// Check if multi-UPS config
|
// Check if multi-UPS config
|
||||||
if (config.upsDevices && Array.isArray(config.upsDevices)) {
|
if (config.upsDevices && Array.isArray(config.upsDevices)) {
|
||||||
// Multi-UPS configuration
|
// === Multi-UPS Configuration ===
|
||||||
logger.logBoxLine(`UPS Devices: ${config.upsDevices.length}`);
|
|
||||||
logger.logBoxLine(`Groups: ${config.groups ? config.groups.length : 0}`);
|
|
||||||
logger.logBoxLine(`Check Interval: ${config.checkInterval / 1000} seconds`);
|
|
||||||
logger.logBoxLine('');
|
|
||||||
logger.logBoxLine('Configuration File Location:');
|
|
||||||
logger.logBoxLine(' /etc/nupst/config.json');
|
|
||||||
logger.logBoxEnd();
|
|
||||||
|
|
||||||
// Show UPS devices
|
// Overview Box
|
||||||
|
logger.log('');
|
||||||
|
logger.logBox('NUPST Configuration', [
|
||||||
|
`UPS Devices: ${theme.highlight(String(config.upsDevices.length))}`,
|
||||||
|
`Groups: ${theme.highlight(String(config.groups ? config.groups.length : 0))}`,
|
||||||
|
`Check Interval: ${theme.info(String(config.checkInterval / 1000))} seconds`,
|
||||||
|
'',
|
||||||
|
theme.dim('Configuration File:'),
|
||||||
|
` ${theme.path('/etc/nupst/config.json')}`,
|
||||||
|
], 60, 'info');
|
||||||
|
|
||||||
|
// UPS Devices Table
|
||||||
if (config.upsDevices.length > 0) {
|
if (config.upsDevices.length > 0) {
|
||||||
logger.logBoxTitle('UPS Devices', boxWidth);
|
const upsRows = config.upsDevices.map((ups) => ({
|
||||||
for (const ups of config.upsDevices) {
|
name: ups.name,
|
||||||
logger.logBoxLine(`${ups.name} (${ups.id}):`);
|
id: theme.dim(ups.id),
|
||||||
logger.logBoxLine(` Host: ${ups.snmp.host}:${ups.snmp.port}`);
|
host: `${ups.snmp.host}:${ups.snmp.port}`,
|
||||||
logger.logBoxLine(` Model: ${ups.snmp.upsModel}`);
|
model: ups.snmp.upsModel || 'cyberpower',
|
||||||
logger.logBoxLine(
|
actions: `${(ups.actions || []).length} configured`,
|
||||||
` Thresholds: ${ups.thresholds.battery}% battery, ${ups.thresholds.runtime} min runtime`,
|
groups: ups.groups.length > 0 ? ups.groups.join(', ') : theme.dim('None'),
|
||||||
);
|
}));
|
||||||
logger.logBoxLine(
|
|
||||||
` Groups: ${ups.groups.length > 0 ? ups.groups.join(', ') : 'None'}`,
|
const upsColumns: ITableColumn[] = [
|
||||||
);
|
{ header: 'Name', key: 'name', align: 'left', color: theme.highlight },
|
||||||
logger.logBoxLine('');
|
{ header: 'ID', key: 'id', align: 'left' },
|
||||||
}
|
{ header: 'Host:Port', key: 'host', align: 'left', color: theme.info },
|
||||||
logger.logBoxEnd();
|
{ header: 'Model', key: 'model', align: 'left' },
|
||||||
|
{ header: 'Actions', key: 'actions', align: 'left' },
|
||||||
|
{ header: 'Groups', key: 'groups', align: 'left' },
|
||||||
|
];
|
||||||
|
|
||||||
|
logger.log('');
|
||||||
|
logger.info(`UPS Devices (${config.upsDevices.length}):`);
|
||||||
|
logger.log('');
|
||||||
|
logger.logTable(upsColumns, upsRows);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show groups
|
// Groups Table
|
||||||
if (config.groups && config.groups.length > 0) {
|
if (config.groups && config.groups.length > 0) {
|
||||||
logger.logBoxTitle('UPS Groups', boxWidth);
|
const groupRows = config.groups.map((group) => {
|
||||||
for (const group of config.groups) {
|
|
||||||
logger.logBoxLine(`${group.name} (${group.id}):`);
|
|
||||||
logger.logBoxLine(` Mode: ${group.mode}`);
|
|
||||||
if (group.description) {
|
|
||||||
logger.logBoxLine(` Description: ${group.description}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// List UPS devices in this group
|
|
||||||
const upsInGroup = config.upsDevices.filter((ups) =>
|
const upsInGroup = config.upsDevices.filter((ups) =>
|
||||||
ups.groups && ups.groups.includes(group.id)
|
ups.groups && ups.groups.includes(group.id)
|
||||||
);
|
);
|
||||||
logger.logBoxLine(
|
return {
|
||||||
` UPS Devices: ${
|
name: group.name,
|
||||||
upsInGroup.length > 0 ? upsInGroup.map((ups) => ups.name).join(', ') : 'None'
|
id: theme.dim(group.id),
|
||||||
}`,
|
mode: group.mode,
|
||||||
);
|
upsCount: String(upsInGroup.length),
|
||||||
logger.logBoxLine('');
|
ups: upsInGroup.length > 0
|
||||||
}
|
? upsInGroup.map((ups) => ups.name).join(', ')
|
||||||
logger.logBoxEnd();
|
: theme.dim('None'),
|
||||||
|
description: group.description || theme.dim('—'),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const groupColumns: ITableColumn[] = [
|
||||||
|
{ header: 'Name', key: 'name', align: 'left', color: theme.highlight },
|
||||||
|
{ header: 'ID', key: 'id', align: 'left' },
|
||||||
|
{ header: 'Mode', key: 'mode', align: 'left', color: theme.info },
|
||||||
|
{ header: 'UPS', key: 'upsCount', align: 'right' },
|
||||||
|
{ header: 'UPS Devices', key: 'ups', align: 'left' },
|
||||||
|
{ header: 'Description', key: 'description', align: 'left' },
|
||||||
|
];
|
||||||
|
|
||||||
|
logger.log('');
|
||||||
|
logger.info(`UPS Groups (${config.groups.length}):`);
|
||||||
|
logger.log('');
|
||||||
|
logger.logTable(groupColumns, groupRows);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Legacy single UPS configuration
|
// === Legacy Single UPS Configuration ===
|
||||||
|
|
||||||
if (!config.snmp) {
|
if (!config.snmp) {
|
||||||
logger.logBoxLine('Error: Legacy configuration missing SNMP settings');
|
logger.logBox('Configuration Error', [
|
||||||
} else {
|
'Error: Legacy configuration missing SNMP settings',
|
||||||
// SNMP Settings
|
], 60, 'error');
|
||||||
logger.logBoxLine('SNMP Settings:');
|
return;
|
||||||
logger.logBoxLine(` Host: ${config.snmp.host}`);
|
|
||||||
logger.logBoxLine(` Port: ${config.snmp.port}`);
|
|
||||||
logger.logBoxLine(` Version: ${config.snmp.version}`);
|
|
||||||
logger.logBoxLine(` UPS Model: ${config.snmp.upsModel || 'cyberpower'}`);
|
|
||||||
|
|
||||||
if (config.snmp.version === 1 || config.snmp.version === 2) {
|
|
||||||
logger.logBoxLine(` Community: ${config.snmp.community}`);
|
|
||||||
} else if (config.snmp.version === 3) {
|
|
||||||
logger.logBoxLine(` Security Level: ${config.snmp.securityLevel}`);
|
|
||||||
logger.logBoxLine(` Username: ${config.snmp.username}`);
|
|
||||||
|
|
||||||
// Show auth and privacy details based on security level
|
|
||||||
if (
|
|
||||||
config.snmp.securityLevel === 'authNoPriv' ||
|
|
||||||
config.snmp.securityLevel === 'authPriv'
|
|
||||||
) {
|
|
||||||
logger.logBoxLine(` Auth Protocol: ${config.snmp.authProtocol || 'None'}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.snmp.securityLevel === 'authPriv') {
|
logger.log('');
|
||||||
logger.logBoxLine(` Privacy Protocol: ${config.snmp.privProtocol || 'None'}`);
|
logger.logBox('NUPST Configuration (Legacy)', [
|
||||||
}
|
theme.warning('Legacy single-UPS configuration format'),
|
||||||
|
'',
|
||||||
// Show timeout value
|
theme.dim('SNMP Settings:'),
|
||||||
logger.logBoxLine(` Timeout: ${config.snmp.timeout / 1000} seconds`);
|
` Host: ${theme.info(config.snmp.host)}`,
|
||||||
}
|
` Port: ${theme.info(String(config.snmp.port))}`,
|
||||||
|
` Version: ${config.snmp.version}`,
|
||||||
// Show OIDs if custom model is selected
|
` UPS Model: ${config.snmp.upsModel || 'cyberpower'}`,
|
||||||
if (config.snmp.upsModel === 'custom' && config.snmp.customOIDs) {
|
...(config.snmp.version === 1 || config.snmp.version === 2
|
||||||
logger.logBoxLine('Custom OIDs:');
|
? [` Community: ${config.snmp.community}`]
|
||||||
logger.logBoxLine(
|
: []
|
||||||
|
),
|
||||||
|
...(config.snmp.version === 3
|
||||||
|
? [
|
||||||
|
` Security Level: ${config.snmp.securityLevel}`,
|
||||||
|
` Username: ${config.snmp.username}`,
|
||||||
|
...(config.snmp.securityLevel === 'authNoPriv' || config.snmp.securityLevel === 'authPriv'
|
||||||
|
? [` Auth Protocol: ${config.snmp.authProtocol || 'None'}`]
|
||||||
|
: []
|
||||||
|
),
|
||||||
|
...(config.snmp.securityLevel === 'authPriv'
|
||||||
|
? [` Privacy Protocol: ${config.snmp.privProtocol || 'None'}`]
|
||||||
|
: []
|
||||||
|
),
|
||||||
|
` Timeout: ${config.snmp.timeout / 1000} seconds`,
|
||||||
|
]
|
||||||
|
: []
|
||||||
|
),
|
||||||
|
...(config.snmp.upsModel === 'custom' && config.snmp.customOIDs
|
||||||
|
? [
|
||||||
|
theme.dim('Custom OIDs:'),
|
||||||
` Power Status: ${config.snmp.customOIDs.POWER_STATUS || 'Not set'}`,
|
` Power Status: ${config.snmp.customOIDs.POWER_STATUS || 'Not set'}`,
|
||||||
);
|
|
||||||
logger.logBoxLine(
|
|
||||||
` Battery Capacity: ${config.snmp.customOIDs.BATTERY_CAPACITY || 'Not set'}`,
|
` Battery Capacity: ${config.snmp.customOIDs.BATTERY_CAPACITY || 'Not set'}`,
|
||||||
);
|
|
||||||
logger.logBoxLine(
|
|
||||||
` Battery Runtime: ${config.snmp.customOIDs.BATTERY_RUNTIME || 'Not set'}`,
|
` Battery Runtime: ${config.snmp.customOIDs.BATTERY_RUNTIME || 'Not set'}`,
|
||||||
);
|
]
|
||||||
}
|
: []
|
||||||
|
),
|
||||||
|
'',
|
||||||
|
|
||||||
|
` Check Interval: ${config.checkInterval / 1000} seconds`,
|
||||||
|
'',
|
||||||
|
theme.dim('Configuration File:'),
|
||||||
|
` ${theme.path('/etc/nupst/config.json')}`,
|
||||||
|
'',
|
||||||
|
theme.warning('Note: Using legacy single-UPS configuration format.'),
|
||||||
|
`Consider using ${theme.command('nupst ups add')} to migrate to multi-UPS format.`,
|
||||||
|
], 70, 'warning');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Thresholds
|
// Service Status
|
||||||
if (!config.thresholds) {
|
|
||||||
logger.logBoxLine('Error: Legacy configuration missing threshold settings');
|
|
||||||
} else {
|
|
||||||
logger.logBoxLine('Thresholds:');
|
|
||||||
logger.logBoxLine(` Battery: ${config.thresholds.battery}%`);
|
|
||||||
logger.logBoxLine(` Runtime: ${config.thresholds.runtime} minutes`);
|
|
||||||
}
|
|
||||||
logger.logBoxLine(`Check Interval: ${config.checkInterval / 1000} seconds`);
|
|
||||||
|
|
||||||
// Configuration file location
|
|
||||||
logger.logBoxLine('');
|
|
||||||
logger.logBoxLine('Configuration File Location:');
|
|
||||||
logger.logBoxLine(' /etc/nupst/config.json');
|
|
||||||
logger.logBoxLine('');
|
|
||||||
logger.logBoxLine('Note: Using legacy single-UPS configuration format.');
|
|
||||||
logger.logBoxLine('Consider using "nupst add" to migrate to multi-UPS format.');
|
|
||||||
|
|
||||||
logger.logBoxEnd();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show service status
|
|
||||||
try {
|
try {
|
||||||
const isActive =
|
const isActive =
|
||||||
execSync('systemctl is-active nupst.service || true').toString().trim() === 'active';
|
execSync('systemctl is-active nupst.service || true').toString().trim() === 'active';
|
||||||
const isEnabled =
|
const isEnabled =
|
||||||
execSync('systemctl is-enabled nupst.service || true').toString().trim() === 'enabled';
|
execSync('systemctl is-enabled nupst.service || true').toString().trim() === 'enabled';
|
||||||
|
|
||||||
const statusBoxWidth = 45;
|
logger.log('');
|
||||||
logger.logBoxTitle('Service Status', statusBoxWidth);
|
logger.logBox('Service Status', [
|
||||||
logger.logBoxLine(`Service Active: ${isActive ? 'Yes' : 'No'}`);
|
`Active: ${isActive ? theme.success('Yes') : theme.dim('No')}`,
|
||||||
logger.logBoxLine(`Service Enabled: ${isEnabled ? 'Yes' : 'No'}`);
|
`Enabled: ${isEnabled ? theme.success('Yes') : theme.dim('No')}`,
|
||||||
logger.logBoxEnd();
|
], 50, isActive ? 'success' : 'default');
|
||||||
|
logger.log('');
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
// Ignore errors checking service status
|
// Ignore errors checking service status
|
||||||
}
|
}
|
||||||
|
@@ -1,6 +1,7 @@
|
|||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
import { Nupst } from '../nupst.ts';
|
import { Nupst } from '../nupst.ts';
|
||||||
import { logger } from '../logger.ts';
|
import { logger, type ITableColumn } from '../logger.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 } from '../daemon.ts';
|
||||||
|
|
||||||
@@ -28,11 +29,10 @@ export class GroupHandler {
|
|||||||
try {
|
try {
|
||||||
await this.nupst.getDaemon().loadConfig();
|
await this.nupst.getDaemon().loadConfig();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorBoxWidth = 45;
|
logger.logBox('Configuration Error', [
|
||||||
logger.logBoxTitle('Configuration Error', errorBoxWidth);
|
'No configuration found.',
|
||||||
logger.logBoxLine('No configuration found.');
|
"Please run 'nupst ups add' first to create a configuration.",
|
||||||
logger.logBoxLine("Please run 'nupst setup' first to create a configuration.");
|
], 50, 'error');
|
||||||
logger.logBoxEnd();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,43 +41,53 @@ 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)) {
|
||||||
// Legacy or missing groups configuration
|
logger.logBox('UPS Groups', [
|
||||||
const boxWidth = 45;
|
'No groups configured.',
|
||||||
logger.logBoxTitle('UPS Groups', boxWidth);
|
'',
|
||||||
logger.logBoxLine('No groups configured.');
|
`${theme.dim('Run')} ${theme.command('nupst group add')} ${theme.dim('to add a group')}`,
|
||||||
logger.logBoxLine('Use "nupst group add" to add a UPS group.');
|
], 50, 'info');
|
||||||
logger.logBoxEnd();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Display group list
|
// Display group list with modern table
|
||||||
const boxWidth = 60;
|
|
||||||
logger.logBoxTitle('UPS Groups', boxWidth);
|
|
||||||
|
|
||||||
if (config.groups.length === 0) {
|
if (config.groups.length === 0) {
|
||||||
logger.logBoxLine('No UPS groups configured.');
|
logger.logBox('UPS Groups', [
|
||||||
logger.logBoxLine('Use "nupst group add" to add a UPS group.');
|
'No UPS groups configured.',
|
||||||
} else {
|
'',
|
||||||
logger.logBoxLine(`Found ${config.groups.length} group(s)`);
|
`${theme.dim('Run')} ${theme.command('nupst group add')} ${theme.dim('to add a group')}`,
|
||||||
logger.logBoxLine('');
|
], 60, 'info');
|
||||||
logger.logBoxLine('ID | Name | Mode | UPS Devices');
|
return;
|
||||||
logger.logBoxLine('-----------+----------------------+--------------+----------------');
|
}
|
||||||
|
|
||||||
for (const group of config.groups) {
|
|
||||||
const id = group.id.padEnd(10, ' ').substring(0, 10);
|
|
||||||
const name = (group.name || '').padEnd(20, ' ').substring(0, 20);
|
|
||||||
const mode = (group.mode || 'unknown').padEnd(12, ' ').substring(0, 12);
|
|
||||||
|
|
||||||
|
// Prepare table data
|
||||||
|
const rows = config.groups.map((group) => {
|
||||||
// Count UPS devices in this group
|
// Count UPS devices in this group
|
||||||
const upsInGroup = config.upsDevices.filter((ups) => ups.groups.includes(group.id));
|
const upsInGroup = config.upsDevices.filter((ups) => ups.groups.includes(group.id));
|
||||||
const upsCount = upsInGroup.length;
|
const upsCount = upsInGroup.length;
|
||||||
const upsNames = upsInGroup.map((ups) => ups.name).join(', ');
|
const upsNames = upsInGroup.map((ups) => ups.name).join(', ');
|
||||||
|
|
||||||
logger.logBoxLine(`${id} | ${name} | ${mode} | ${upsCount > 0 ? upsNames : 'None'}`);
|
return {
|
||||||
}
|
id: group.id,
|
||||||
}
|
name: group.name || '',
|
||||||
|
mode: group.mode || 'unknown',
|
||||||
|
count: String(upsCount),
|
||||||
|
devices: upsCount > 0 ? upsNames : theme.dim('None'),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
logger.logBoxEnd();
|
const columns: ITableColumn[] = [
|
||||||
|
{ header: 'ID', key: 'id', align: 'left', color: theme.highlight },
|
||||||
|
{ header: 'Name', key: 'name', align: 'left' },
|
||||||
|
{ header: 'Mode', key: 'mode', align: 'left', color: theme.info },
|
||||||
|
{ header: 'UPS Count', key: 'count', align: 'right' },
|
||||||
|
{ header: 'UPS Devices', key: 'devices', align: 'left' },
|
||||||
|
];
|
||||||
|
|
||||||
|
logger.log('');
|
||||||
|
logger.info(`UPS Groups (${config.groups.length}):`);
|
||||||
|
logger.log('');
|
||||||
|
logger.logTable(columns, rows);
|
||||||
|
logger.log('');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`Failed to list UPS groups: ${error instanceof Error ? error.message : String(error)}`,
|
`Failed to list UPS groups: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
@@ -1,7 +1,8 @@
|
|||||||
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 } from '../logger.ts';
|
import { logger, type ITableColumn } from '../logger.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 { TUpsModel } from '../snmp/types.ts';
|
||||||
import type { INupstConfig } from '../daemon.ts';
|
import type { INupstConfig } from '../daemon.ts';
|
||||||
@@ -78,8 +79,8 @@ export class UpsHandler {
|
|||||||
id: 'default',
|
id: 'default',
|
||||||
name: 'Default UPS',
|
name: 'Default UPS',
|
||||||
snmp: config.snmp,
|
snmp: config.snmp,
|
||||||
thresholds: config.thresholds,
|
|
||||||
groups: [],
|
groups: [],
|
||||||
|
actions: [],
|
||||||
}],
|
}],
|
||||||
groups: [],
|
groups: [],
|
||||||
};
|
};
|
||||||
@@ -116,14 +117,12 @@ export class UpsHandler {
|
|||||||
runtime: 20,
|
runtime: 20,
|
||||||
},
|
},
|
||||||
groups: [],
|
groups: [],
|
||||||
|
actions: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
// Gather SNMP settings
|
// Gather SNMP settings
|
||||||
await this.gatherSnmpSettings(newUps.snmp, prompt);
|
await this.gatherSnmpSettings(newUps.snmp, prompt);
|
||||||
|
|
||||||
// Gather threshold settings
|
|
||||||
await this.gatherThresholdSettings(newUps.thresholds, prompt);
|
|
||||||
|
|
||||||
// Gather UPS model settings
|
// Gather UPS model settings
|
||||||
await this.gatherUpsModelSettings(newUps.snmp, prompt);
|
await this.gatherUpsModelSettings(newUps.snmp, prompt);
|
||||||
|
|
||||||
@@ -135,6 +134,9 @@ export class UpsHandler {
|
|||||||
await groupHandler.assignUpsToGroups(newUps, config.groups, prompt);
|
await groupHandler.assignUpsToGroups(newUps, config.groups, prompt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Gather action settings
|
||||||
|
await this.gatherActionSettings(newUps.actions, prompt);
|
||||||
|
|
||||||
// Add the new UPS to the config
|
// Add the new UPS to the config
|
||||||
config.upsDevices.push(newUps);
|
config.upsDevices.push(newUps);
|
||||||
|
|
||||||
@@ -220,16 +222,16 @@ export class UpsHandler {
|
|||||||
// Convert old format to new format if needed
|
// Convert old format to new format if needed
|
||||||
if (!config.upsDevices) {
|
if (!config.upsDevices) {
|
||||||
// Initialize with the current config as the first UPS
|
// Initialize with the current config as the first UPS
|
||||||
if (!config.snmp || !config.thresholds) {
|
if (!config.snmp) {
|
||||||
logger.error('Legacy configuration is missing required SNMP or threshold settings');
|
logger.error('Legacy configuration is missing required SNMP settings');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
config.upsDevices = [{
|
config.upsDevices = [{
|
||||||
id: 'default',
|
id: 'default',
|
||||||
name: 'Default UPS',
|
name: 'Default UPS',
|
||||||
snmp: config.snmp,
|
snmp: config.snmp,
|
||||||
thresholds: config.thresholds,
|
|
||||||
groups: [],
|
groups: [],
|
||||||
|
actions: [],
|
||||||
}];
|
}];
|
||||||
config.groups = [];
|
config.groups = [];
|
||||||
logger.log('Converting existing configuration to multi-UPS format.');
|
logger.log('Converting existing configuration to multi-UPS format.');
|
||||||
@@ -264,9 +266,6 @@ export class UpsHandler {
|
|||||||
// Edit SNMP settings
|
// Edit SNMP settings
|
||||||
await this.gatherSnmpSettings(upsToEdit.snmp, prompt);
|
await this.gatherSnmpSettings(upsToEdit.snmp, prompt);
|
||||||
|
|
||||||
// Edit threshold settings
|
|
||||||
await this.gatherThresholdSettings(upsToEdit.thresholds, prompt);
|
|
||||||
|
|
||||||
// Edit UPS model settings
|
// Edit UPS model settings
|
||||||
await this.gatherUpsModelSettings(upsToEdit.snmp, prompt);
|
await this.gatherUpsModelSettings(upsToEdit.snmp, prompt);
|
||||||
|
|
||||||
@@ -278,6 +277,14 @@ export class UpsHandler {
|
|||||||
await groupHandler.assignUpsToGroups(upsToEdit, config.groups, prompt);
|
await groupHandler.assignUpsToGroups(upsToEdit, config.groups, prompt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize actions array if not exists
|
||||||
|
if (!upsToEdit.actions) {
|
||||||
|
upsToEdit.actions = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edit action settings
|
||||||
|
await this.gatherActionSettings(upsToEdit.actions, prompt);
|
||||||
|
|
||||||
// Save the configuration
|
// Save the configuration
|
||||||
await this.nupst.getDaemon().saveConfig(config);
|
await this.nupst.getDaemon().saveConfig(config);
|
||||||
|
|
||||||
@@ -379,11 +386,10 @@ export class UpsHandler {
|
|||||||
try {
|
try {
|
||||||
await this.nupst.getDaemon().loadConfig();
|
await this.nupst.getDaemon().loadConfig();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorBoxWidth = 45;
|
logger.logBox('Configuration Error', [
|
||||||
logger.logBoxTitle('Configuration Error', errorBoxWidth);
|
'No configuration found.',
|
||||||
logger.logBoxLine('No configuration found.');
|
"Please run 'nupst ups add' first to create a configuration.",
|
||||||
logger.logBoxLine("Please run 'nupst setup' first to create a configuration.");
|
], 50, 'error');
|
||||||
logger.logBoxEnd();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -393,58 +399,56 @@ 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
|
||||||
const boxWidth = 45;
|
logger.logBox('UPS Devices', [
|
||||||
logger.logBoxTitle('UPS Devices', boxWidth);
|
'Legacy single-UPS configuration detected.',
|
||||||
logger.logBoxLine('Legacy single-UPS configuration detected.');
|
'',
|
||||||
if (!config.snmp || !config.thresholds) {
|
...(!config.snmp
|
||||||
logger.logBoxLine('');
|
? ['Error: Configuration missing SNMP settings']
|
||||||
logger.logBoxLine('Error: Configuration missing SNMP or threshold settings');
|
: [
|
||||||
logger.logBoxEnd();
|
'Default UPS:',
|
||||||
return;
|
` Host: ${config.snmp.host}:${config.snmp.port}`,
|
||||||
}
|
` Model: ${config.snmp.upsModel || 'cyberpower'}`,
|
||||||
logger.logBoxLine('');
|
'',
|
||||||
logger.logBoxLine('Default UPS:');
|
'Use "nupst ups add" to add more UPS devices and migrate',
|
||||||
logger.logBoxLine(` Host: ${config.snmp.host}:${config.snmp.port}`);
|
'to the multi-UPS configuration format.',
|
||||||
logger.logBoxLine(` Model: ${config.snmp.upsModel || 'cyberpower'}`);
|
]
|
||||||
logger.logBoxLine(
|
),
|
||||||
` Thresholds: ${config.thresholds.battery}% battery, ${config.thresholds.runtime} min runtime`,
|
], 60, 'warning');
|
||||||
);
|
|
||||||
logger.logBoxLine('');
|
|
||||||
logger.logBoxLine('Use "nupst add" to add more UPS devices and migrate');
|
|
||||||
logger.logBoxLine('to the multi-UPS configuration format.');
|
|
||||||
logger.logBoxEnd();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Display UPS list
|
// Display UPS list with modern table
|
||||||
const boxWidth = 60;
|
|
||||||
logger.logBoxTitle('UPS Devices', boxWidth);
|
|
||||||
|
|
||||||
if (config.upsDevices.length === 0) {
|
if (config.upsDevices.length === 0) {
|
||||||
logger.logBoxLine('No UPS devices configured.');
|
logger.logBox('UPS Devices', [
|
||||||
logger.logBoxLine('Use "nupst add" to add a UPS device.');
|
'No UPS devices configured.',
|
||||||
} else {
|
'',
|
||||||
logger.logBoxLine(`Found ${config.upsDevices.length} UPS device(s)`);
|
`${theme.dim('Run')} ${theme.command('nupst ups add')} ${theme.dim('to add a device')}`,
|
||||||
logger.logBoxLine('');
|
], 60, 'info');
|
||||||
logger.logBoxLine(
|
return;
|
||||||
'ID | Name | Host | Mode | Groups',
|
|
||||||
);
|
|
||||||
logger.logBoxLine(
|
|
||||||
'-----------+----------------------+-----------------+--------------+----------------',
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const ups of config.upsDevices) {
|
|
||||||
const id = ups.id.padEnd(10, ' ').substring(0, 10);
|
|
||||||
const name = (ups.name || '').padEnd(20, ' ').substring(0, 20);
|
|
||||||
const host = `${ups.snmp.host}:${ups.snmp.port}`.padEnd(15, ' ').substring(0, 15);
|
|
||||||
const model = (ups.snmp.upsModel || 'cyberpower').padEnd(12, ' ').substring(0, 12);
|
|
||||||
const groups = ups.groups.length > 0 ? ups.groups.join(', ') : 'None';
|
|
||||||
|
|
||||||
logger.logBoxLine(`${id} | ${name} | ${host} | ${model} | ${groups}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.logBoxEnd();
|
// Prepare table data
|
||||||
|
const rows = config.upsDevices.map((ups) => ({
|
||||||
|
id: ups.id,
|
||||||
|
name: ups.name || '',
|
||||||
|
host: `${ups.snmp.host}:${ups.snmp.port}`,
|
||||||
|
model: ups.snmp.upsModel || 'cyberpower',
|
||||||
|
groups: ups.groups.length > 0 ? ups.groups.join(', ') : theme.dim('None'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const columns: ITableColumn[] = [
|
||||||
|
{ header: 'ID', key: 'id', align: 'left', color: theme.highlight },
|
||||||
|
{ header: 'Name', key: 'name', align: 'left' },
|
||||||
|
{ header: 'Host:Port', key: 'host', align: 'left', color: theme.info },
|
||||||
|
{ header: 'Model', key: 'model', align: 'left' },
|
||||||
|
{ header: 'Groups', key: 'groups', align: 'left' },
|
||||||
|
];
|
||||||
|
|
||||||
|
logger.log('');
|
||||||
|
logger.info(`UPS Devices (${config.upsDevices.length}):`);
|
||||||
|
logger.log('');
|
||||||
|
logger.logTable(columns, rows);
|
||||||
|
logger.log('');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`Failed to list UPS devices: ${error instanceof Error ? error.message : String(error)}`,
|
`Failed to list UPS devices: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
@@ -507,9 +511,8 @@ export class UpsHandler {
|
|||||||
*/
|
*/
|
||||||
private displayTestConfig(config: any): void {
|
private displayTestConfig(config: any): void {
|
||||||
// Check if this is a UPS device or full configuration
|
// Check if this is a UPS device or full configuration
|
||||||
const isUpsConfig = config.snmp && config.thresholds;
|
const isUpsConfig = config.snmp;
|
||||||
const snmpConfig = isUpsConfig ? config.snmp : config.snmp || {};
|
const snmpConfig = isUpsConfig ? config.snmp : config.snmp || {};
|
||||||
const thresholds = isUpsConfig ? config.thresholds : config.thresholds || {};
|
|
||||||
const checkInterval = config.checkInterval || 30000;
|
const checkInterval = config.checkInterval || 30000;
|
||||||
|
|
||||||
// Get UPS name and ID if available
|
// Get UPS name and ID if available
|
||||||
@@ -553,10 +556,6 @@ export class UpsHandler {
|
|||||||
);
|
);
|
||||||
logger.logBoxLine(` Battery Runtime: ${snmpConfig.customOIDs.BATTERY_RUNTIME || 'Not set'}`);
|
logger.logBoxLine(` Battery Runtime: ${snmpConfig.customOIDs.BATTERY_RUNTIME || 'Not set'}`);
|
||||||
}
|
}
|
||||||
logger.logBoxLine('Thresholds:');
|
|
||||||
logger.logBoxLine(` Battery: ${thresholds.battery}%`);
|
|
||||||
logger.logBoxLine(` Runtime: ${thresholds.runtime} minutes`);
|
|
||||||
|
|
||||||
// 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 (config.groups && Array.isArray(config.groups)) {
|
||||||
logger.logBoxLine(
|
logger.logBoxLine(
|
||||||
@@ -580,7 +579,6 @@ export class UpsHandler {
|
|||||||
try {
|
try {
|
||||||
// Create a test config with a short timeout
|
// Create a test config with a short timeout
|
||||||
const snmpConfig = config.snmp ? config.snmp : config.snmp;
|
const snmpConfig = config.snmp ? config.snmp : config.snmp;
|
||||||
const thresholds = config.thresholds ? config.thresholds : config.thresholds;
|
|
||||||
|
|
||||||
const testConfig = {
|
const testConfig = {
|
||||||
...snmpConfig,
|
...snmpConfig,
|
||||||
@@ -597,10 +595,7 @@ export class UpsHandler {
|
|||||||
logger.logBoxLine(` Runtime Remaining: ${status.batteryRuntime} minutes`);
|
logger.logBoxLine(` Runtime Remaining: ${status.batteryRuntime} minutes`);
|
||||||
logger.logBoxEnd();
|
logger.logBoxEnd();
|
||||||
|
|
||||||
// Check status against thresholds if on battery
|
|
||||||
if (status.powerStatus === 'onBattery') {
|
|
||||||
this.analyzeThresholds(status, thresholds);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorBoxWidth = 45;
|
const errorBoxWidth = 45;
|
||||||
logger.logBoxTitle(`Connection Failed: ${upsName}`, errorBoxWidth);
|
logger.logBoxTitle(`Connection Failed: ${upsName}`, errorBoxWidth);
|
||||||
@@ -822,39 +817,6 @@ export class UpsHandler {
|
|||||||
snmpConfig.privKey = privKey.trim() || defaultPrivKey;
|
snmpConfig.privKey = privKey.trim() || defaultPrivKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Gather threshold settings
|
|
||||||
* @param thresholds Thresholds configuration object to update
|
|
||||||
* @param prompt Function to prompt for user input
|
|
||||||
*/
|
|
||||||
private async gatherThresholdSettings(
|
|
||||||
thresholds: any,
|
|
||||||
prompt: (question: string) => Promise<string>,
|
|
||||||
): Promise<void> {
|
|
||||||
logger.log('');
|
|
||||||
logger.info('Shutdown Thresholds:');
|
|
||||||
|
|
||||||
// Battery threshold
|
|
||||||
const defaultBatteryThreshold = thresholds.battery || 60;
|
|
||||||
const batteryThresholdInput = await prompt(
|
|
||||||
`Battery percentage threshold [${defaultBatteryThreshold}%]: `,
|
|
||||||
);
|
|
||||||
const batteryThreshold = parseInt(batteryThresholdInput, 10);
|
|
||||||
thresholds.battery = batteryThresholdInput.trim() && !isNaN(batteryThreshold)
|
|
||||||
? batteryThreshold
|
|
||||||
: defaultBatteryThreshold;
|
|
||||||
|
|
||||||
// Runtime threshold
|
|
||||||
const defaultRuntimeThreshold = thresholds.runtime || 20;
|
|
||||||
const runtimeThresholdInput = await prompt(
|
|
||||||
`Runtime minutes threshold [${defaultRuntimeThreshold} minutes]: `,
|
|
||||||
);
|
|
||||||
const runtimeThreshold = parseInt(runtimeThresholdInput, 10);
|
|
||||||
thresholds.runtime = runtimeThresholdInput.trim() && !isNaN(runtimeThreshold)
|
|
||||||
? runtimeThreshold
|
|
||||||
: defaultRuntimeThreshold;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gather UPS model settings
|
* Gather UPS model settings
|
||||||
* @param snmpConfig SNMP configuration object to update
|
* @param snmpConfig SNMP configuration object to update
|
||||||
@@ -920,6 +882,151 @@ export class UpsHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gather action configuration settings
|
||||||
|
* @param actions Actions array to configure
|
||||||
|
* @param prompt Function to prompt for user input
|
||||||
|
*/
|
||||||
|
private async gatherActionSettings(
|
||||||
|
actions: any[],
|
||||||
|
prompt: (question: string) => Promise<string>,
|
||||||
|
): Promise<void> {
|
||||||
|
logger.log('');
|
||||||
|
logger.info('Action Configuration (Optional):');
|
||||||
|
logger.dim('Actions are triggered on power status changes and threshold violations.');
|
||||||
|
logger.dim('Leave empty to use default shutdown behavior on threshold violations.');
|
||||||
|
|
||||||
|
const configureActions = await prompt('Configure custom actions? (y/N): ');
|
||||||
|
if (configureActions.toLowerCase() !== 'y') {
|
||||||
|
return; // Keep existing actions or use default
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear existing actions
|
||||||
|
actions.length = 0;
|
||||||
|
|
||||||
|
let addMore = true;
|
||||||
|
while (addMore) {
|
||||||
|
logger.log('');
|
||||||
|
logger.info('Action Type:');
|
||||||
|
logger.dim(' 1) Shutdown (system shutdown)');
|
||||||
|
logger.dim(' 2) Webhook (HTTP notification)');
|
||||||
|
logger.dim(' 3) Custom Script (run .sh file from /etc/nupst)');
|
||||||
|
|
||||||
|
const typeInput = await prompt('Select action type [1]: ');
|
||||||
|
const typeValue = parseInt(typeInput, 10) || 1;
|
||||||
|
|
||||||
|
const action: any = {};
|
||||||
|
|
||||||
|
if (typeValue === 1) {
|
||||||
|
// Shutdown action
|
||||||
|
action.type = 'shutdown';
|
||||||
|
|
||||||
|
const delayInput = await prompt('Shutdown delay in minutes [5]: ');
|
||||||
|
const delay = parseInt(delayInput, 10);
|
||||||
|
if (delayInput.trim() && !isNaN(delay)) {
|
||||||
|
action.shutdownDelay = delay;
|
||||||
|
}
|
||||||
|
} else if (typeValue === 2) {
|
||||||
|
// Webhook action
|
||||||
|
action.type = 'webhook';
|
||||||
|
|
||||||
|
const url = await prompt('Webhook URL: ');
|
||||||
|
if (!url.trim()) {
|
||||||
|
logger.warn('Webhook URL required, skipping action');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
action.webhookUrl = url.trim();
|
||||||
|
|
||||||
|
logger.log('');
|
||||||
|
logger.info('HTTP Method:');
|
||||||
|
logger.dim(' 1) POST (JSON body)');
|
||||||
|
logger.dim(' 2) GET (query parameters)');
|
||||||
|
const methodInput = await prompt('Select method [1]: ');
|
||||||
|
action.webhookMethod = methodInput === '2' ? 'GET' : 'POST';
|
||||||
|
|
||||||
|
const timeoutInput = await prompt('Timeout in seconds [10]: ');
|
||||||
|
const timeout = parseInt(timeoutInput, 10);
|
||||||
|
if (timeoutInput.trim() && !isNaN(timeout)) {
|
||||||
|
action.webhookTimeout = timeout * 1000; // Convert to ms
|
||||||
|
}
|
||||||
|
} else if (typeValue === 3) {
|
||||||
|
// Script action
|
||||||
|
action.type = 'script';
|
||||||
|
|
||||||
|
const scriptPath = await prompt('Script filename (in /etc/nupst/, must end with .sh): ');
|
||||||
|
if (!scriptPath.trim() || !scriptPath.trim().endsWith('.sh')) {
|
||||||
|
logger.warn('Script path must end with .sh, skipping action');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
action.scriptPath = scriptPath.trim();
|
||||||
|
|
||||||
|
const timeoutInput = await prompt('Script timeout in seconds [60]: ');
|
||||||
|
const timeout = parseInt(timeoutInput, 10);
|
||||||
|
if (timeoutInput.trim() && !isNaN(timeout)) {
|
||||||
|
action.scriptTimeout = timeout * 1000; // Convert to ms
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.warn('Invalid action type, skipping');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure trigger mode (applies to all action types)
|
||||||
|
logger.log('');
|
||||||
|
logger.info('Trigger Mode:');
|
||||||
|
logger.dim(' 1) Power changes + thresholds (default)');
|
||||||
|
logger.dim(' 2) Only power status changes');
|
||||||
|
logger.dim(' 3) Only threshold violations');
|
||||||
|
logger.dim(' 4) Any change (every ~30s check)');
|
||||||
|
const triggerInput = await prompt('Select trigger mode [1]: ');
|
||||||
|
const triggerValue = parseInt(triggerInput, 10) || 1;
|
||||||
|
|
||||||
|
switch (triggerValue) {
|
||||||
|
case 2:
|
||||||
|
action.triggerMode = 'onlyPowerChanges';
|
||||||
|
break;
|
||||||
|
case 3:
|
||||||
|
action.triggerMode = 'onlyThresholds';
|
||||||
|
break;
|
||||||
|
case 4:
|
||||||
|
action.triggerMode = 'anyChange';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
action.triggerMode = 'powerChangesAndThresholds';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure thresholds if needed for onlyThresholds or powerChangesAndThresholds modes
|
||||||
|
if (action.triggerMode === 'onlyThresholds' || action.triggerMode === 'powerChangesAndThresholds') {
|
||||||
|
logger.log('');
|
||||||
|
logger.info('Action Thresholds:');
|
||||||
|
logger.dim('Action will trigger when battery or runtime falls below these values (while on battery)');
|
||||||
|
|
||||||
|
const batteryInput = await prompt('Battery threshold percentage [60]: ');
|
||||||
|
const battery = parseInt(batteryInput, 10);
|
||||||
|
const batteryThreshold = (batteryInput.trim() && !isNaN(battery)) ? battery : 60;
|
||||||
|
|
||||||
|
const runtimeInput = await prompt('Runtime threshold in minutes [20]: ');
|
||||||
|
const runtime = parseInt(runtimeInput, 10);
|
||||||
|
const runtimeThreshold = (runtimeInput.trim() && !isNaN(runtime)) ? runtime : 20;
|
||||||
|
|
||||||
|
action.thresholds = {
|
||||||
|
battery: batteryThreshold,
|
||||||
|
runtime: runtimeThreshold,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
actions.push(action);
|
||||||
|
logger.success(`${action.type.charAt(0).toUpperCase() + action.type.slice(1)} action added (mode: ${action.triggerMode || 'powerChangesAndThresholds'})`);
|
||||||
|
|
||||||
|
const more = await prompt('Add another action? (y/N): ');
|
||||||
|
addMore = more.toLowerCase() === 'y';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actions.length > 0) {
|
||||||
|
logger.log('');
|
||||||
|
logger.success(`${actions.length} action(s) configured`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Display UPS configuration summary
|
* Display UPS configuration summary
|
||||||
* @param ups UPS configuration
|
* @param ups UPS configuration
|
||||||
@@ -932,9 +1039,7 @@ export class UpsHandler {
|
|||||||
logger.logBoxLine(`SNMP Host: ${ups.snmp.host}:${ups.snmp.port}`);
|
logger.logBoxLine(`SNMP Host: ${ups.snmp.host}:${ups.snmp.port}`);
|
||||||
logger.logBoxLine(`SNMP Version: ${ups.snmp.version}`);
|
logger.logBoxLine(`SNMP Version: ${ups.snmp.version}`);
|
||||||
logger.logBoxLine(`UPS Model: ${ups.snmp.upsModel}`);
|
logger.logBoxLine(`UPS Model: ${ups.snmp.upsModel}`);
|
||||||
logger.logBoxLine(
|
|
||||||
`Thresholds: ${ups.thresholds.battery}% battery, ${ups.thresholds.runtime} min runtime`,
|
|
||||||
);
|
|
||||||
if (ups.groups && ups.groups.length > 0) {
|
if (ups.groups && ups.groups.length > 0) {
|
||||||
logger.logBoxLine(`Groups: ${ups.groups.join(', ')}`);
|
logger.logBoxLine(`Groups: ${ups.groups.join(', ')}`);
|
||||||
} else {
|
} else {
|
||||||
|
454
ts/daemon.ts
454
ts/daemon.ts
@@ -5,8 +5,11 @@ 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 } from './snmp/types.ts';
|
import type { ISnmpConfig } from './snmp/types.ts';
|
||||||
import { logger } from './logger.ts';
|
import { logger, type ITableColumn } 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 type { IActionConfig } from './actions/base-action.ts';
|
||||||
|
import { ActionManager, type IActionContext, type TPowerStatus } from './actions/index.ts';
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
@@ -21,15 +24,10 @@ export interface IUpsConfig {
|
|||||||
name: string;
|
name: string;
|
||||||
/** SNMP configuration settings */
|
/** SNMP configuration settings */
|
||||||
snmp: ISnmpConfig;
|
snmp: ISnmpConfig;
|
||||||
/** Threshold settings for initiating shutdown */
|
|
||||||
thresholds: {
|
|
||||||
/** Shutdown when battery below this percentage */
|
|
||||||
battery: number;
|
|
||||||
/** Shutdown when runtime below this minutes */
|
|
||||||
runtime: number;
|
|
||||||
};
|
|
||||||
/** Group IDs this UPS belongs to */
|
/** Group IDs this UPS belongs to */
|
||||||
groups: string[];
|
groups: string[];
|
||||||
|
/** Actions to trigger on power status changes and threshold violations */
|
||||||
|
actions?: IActionConfig[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -44,6 +42,8 @@ export interface IGroupConfig {
|
|||||||
mode: 'redundant' | 'nonRedundant';
|
mode: 'redundant' | 'nonRedundant';
|
||||||
/** Optional description */
|
/** Optional description */
|
||||||
description?: string;
|
description?: string;
|
||||||
|
/** Actions to trigger on power status changes and threshold violations */
|
||||||
|
actions?: IActionConfig[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -96,7 +96,7 @@ export class NupstDaemon {
|
|||||||
|
|
||||||
/** Default configuration */
|
/** Default configuration */
|
||||||
private readonly DEFAULT_CONFIG: INupstConfig = {
|
private readonly DEFAULT_CONFIG: INupstConfig = {
|
||||||
version: '4.0',
|
version: '4.1',
|
||||||
upsDevices: [
|
upsDevices: [
|
||||||
{
|
{
|
||||||
id: 'default',
|
id: 'default',
|
||||||
@@ -117,16 +117,23 @@ export class NupstDaemon {
|
|||||||
// UPS model for OID selection
|
// UPS model for OID selection
|
||||||
upsModel: 'cyberpower',
|
upsModel: 'cyberpower',
|
||||||
},
|
},
|
||||||
|
groups: [],
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
type: 'shutdown',
|
||||||
|
triggerMode: 'onlyThresholds',
|
||||||
thresholds: {
|
thresholds: {
|
||||||
battery: 60, // Shutdown when battery below 60%
|
battery: 60, // Shutdown when battery below 60%
|
||||||
runtime: 20, // Shutdown when runtime below 20 minutes
|
runtime: 20, // Shutdown when runtime below 20 minutes
|
||||||
},
|
},
|
||||||
groups: [],
|
shutdownDelay: 5,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
groups: [],
|
groups: [],
|
||||||
checkInterval: 30000, // Check every 30 seconds
|
checkInterval: 30000, // Check every 30 seconds
|
||||||
};
|
}
|
||||||
|
|
||||||
private config: INupstConfig;
|
private config: INupstConfig;
|
||||||
private snmp: NupstSnmp;
|
private snmp: NupstSnmp;
|
||||||
@@ -198,7 +205,7 @@ export class NupstDaemon {
|
|||||||
|
|
||||||
// Ensure version is always set and remove legacy fields before saving
|
// Ensure version is always set and remove legacy fields before saving
|
||||||
const configToSave: INupstConfig = {
|
const configToSave: INupstConfig = {
|
||||||
version: '4.0',
|
version: '4.1',
|
||||||
upsDevices: config.upsDevices,
|
upsDevices: config.upsDevices,
|
||||||
groups: config.groups,
|
groups: config.groups,
|
||||||
checkInterval: config.checkInterval,
|
checkInterval: config.checkInterval,
|
||||||
@@ -310,29 +317,57 @@ export class NupstDaemon {
|
|||||||
* Log the loaded configuration settings
|
* Log the loaded configuration settings
|
||||||
*/
|
*/
|
||||||
private logConfigLoaded(): void {
|
private logConfigLoaded(): void {
|
||||||
const boxWidth = 50;
|
|
||||||
logger.logBoxTitle('Configuration Loaded', boxWidth);
|
|
||||||
|
|
||||||
if (this.config.upsDevices && this.config.upsDevices.length > 0) {
|
|
||||||
logger.logBoxLine(`UPS Devices: ${this.config.upsDevices.length}`);
|
|
||||||
for (const ups of this.config.upsDevices) {
|
|
||||||
logger.logBoxLine(` - ${ups.name} (${ups.id}): ${ups.snmp.host}:${ups.snmp.port}`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
logger.logBoxLine('No UPS devices configured');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.config.groups && this.config.groups.length > 0) {
|
|
||||||
logger.logBoxLine(`Groups: ${this.config.groups.length}`);
|
|
||||||
for (const group of this.config.groups) {
|
|
||||||
logger.logBoxLine(` - ${group.name} (${group.id}): ${group.mode} mode`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
logger.logBoxLine('No Groups configured');
|
|
||||||
}
|
|
||||||
|
|
||||||
|
logger.log('');
|
||||||
|
logger.logBoxTitle('Configuration Loaded', 70, 'success');
|
||||||
logger.logBoxLine(`Check Interval: ${this.config.checkInterval / 1000} seconds`);
|
logger.logBoxLine(`Check Interval: ${this.config.checkInterval / 1000} seconds`);
|
||||||
logger.logBoxEnd();
|
logger.logBoxEnd();
|
||||||
|
logger.log('');
|
||||||
|
|
||||||
|
// Display UPS devices in a table
|
||||||
|
if (this.config.upsDevices && this.config.upsDevices.length > 0) {
|
||||||
|
logger.info(`UPS Devices (${this.config.upsDevices.length}):`);
|
||||||
|
|
||||||
|
const upsColumns: Array<{ header: string; key: string; align?: 'left' | 'right'; color?: (val: string) => string }> = [
|
||||||
|
{ header: 'Name', key: 'name', align: 'left', color: theme.highlight },
|
||||||
|
{ header: 'ID', key: 'id', align: 'left', color: theme.dim },
|
||||||
|
{ header: 'Host:Port', key: 'host', align: 'left', color: theme.info },
|
||||||
|
{ header: 'Actions', key: 'actions', align: 'left' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const upsRows: Array<Record<string, string>> = this.config.upsDevices.map((ups) => ({
|
||||||
|
name: ups.name,
|
||||||
|
id: ups.id,
|
||||||
|
host: `${ups.snmp.host}:${ups.snmp.port}`,
|
||||||
|
actions: `${(ups.actions || []).length} configured`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
logger.logTable(upsColumns, upsRows);
|
||||||
|
logger.log('');
|
||||||
|
} else {
|
||||||
|
logger.warn('No UPS devices configured');
|
||||||
|
logger.log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display groups in a table
|
||||||
|
if (this.config.groups && this.config.groups.length > 0) {
|
||||||
|
logger.info(`Groups (${this.config.groups.length}):`);
|
||||||
|
|
||||||
|
const groupColumns: Array<{ header: string; key: string; align?: 'left' | 'right'; color?: (val: string) => string }> = [
|
||||||
|
{ header: 'Name', key: 'name', align: 'left', color: theme.highlight },
|
||||||
|
{ header: 'ID', key: 'id', align: 'left', color: theme.dim },
|
||||||
|
{ header: 'Mode', key: 'mode', align: 'left', color: theme.info },
|
||||||
|
];
|
||||||
|
|
||||||
|
const groupRows: Array<Record<string, string>> = this.config.groups.map((group) => ({
|
||||||
|
name: group.name,
|
||||||
|
id: group.id,
|
||||||
|
mode: group.mode,
|
||||||
|
}));
|
||||||
|
|
||||||
|
logger.logTable(groupColumns, groupRows);
|
||||||
|
logger.log('');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -372,9 +407,6 @@ export class NupstDaemon {
|
|||||||
lastLogTime = currentTime;
|
lastLogTime = currentTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if shutdown is required based on group configurations
|
|
||||||
await this.evaluateGroupShutdownConditions();
|
|
||||||
|
|
||||||
// Wait before next check
|
// Wait before next check
|
||||||
await this.sleep(this.config.checkInterval);
|
await this.sleep(this.config.checkInterval);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -428,11 +460,42 @@ export class NupstDaemon {
|
|||||||
|
|
||||||
// Check if power status changed
|
// Check if power status changed
|
||||||
if (currentStatus && currentStatus.powerStatus !== status.powerStatus) {
|
if (currentStatus && currentStatus.powerStatus !== status.powerStatus) {
|
||||||
logger.logBoxTitle(`Power Status Change: ${ups.name}`, 50);
|
logger.log('');
|
||||||
logger.logBoxLine(`Status changed: ${currentStatus.powerStatus} → ${status.powerStatus}`);
|
logger.logBoxTitle(`Power Status Change: ${ups.name}`, 60, 'warning');
|
||||||
|
logger.logBoxLine(`Previous: ${formatPowerStatus(currentStatus.powerStatus)}`);
|
||||||
|
logger.logBoxLine(`Current: ${formatPowerStatus(status.powerStatus)}`);
|
||||||
|
logger.logBoxLine(`Time: ${new Date().toISOString()}`);
|
||||||
logger.logBoxEnd();
|
logger.logBoxEnd();
|
||||||
|
logger.log('');
|
||||||
|
|
||||||
updatedStatus.lastStatusChange = currentTime;
|
updatedStatus.lastStatusChange = currentTime;
|
||||||
|
|
||||||
|
// Trigger actions for power status change
|
||||||
|
await this.triggerUpsActions(ups, updatedStatus, currentStatus, 'powerStatusChange');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if any action's thresholds are exceeded (for threshold violation triggers)
|
||||||
|
// Only check when on battery power
|
||||||
|
if (status.powerStatus === 'onBattery' && ups.actions && ups.actions.length > 0) {
|
||||||
|
let anyThresholdExceeded = false;
|
||||||
|
|
||||||
|
for (const actionConfig of ups.actions) {
|
||||||
|
if (actionConfig.thresholds) {
|
||||||
|
if (
|
||||||
|
status.batteryCapacity < actionConfig.thresholds.battery ||
|
||||||
|
status.batteryRuntime < actionConfig.thresholds.runtime
|
||||||
|
) {
|
||||||
|
anyThresholdExceeded = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger actions with threshold violation reason if any threshold is exceeded
|
||||||
|
// Actions will individually check their own thresholds in shouldExecute()
|
||||||
|
if (anyThresholdExceeded) {
|
||||||
|
await this.triggerUpsActions(ups, updatedStatus, currentStatus, 'thresholdViolation');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the status in the map
|
// Update the status in the map
|
||||||
@@ -452,171 +515,100 @@ export class NupstDaemon {
|
|||||||
*/
|
*/
|
||||||
private logAllUpsStatus(): void {
|
private logAllUpsStatus(): void {
|
||||||
const timestamp = new Date().toISOString();
|
const timestamp = new Date().toISOString();
|
||||||
const boxWidth = 60;
|
|
||||||
logger.logBoxTitle('Periodic Status Update', boxWidth);
|
logger.log('');
|
||||||
|
logger.logBoxTitle('Periodic Status Update', 70, 'info');
|
||||||
logger.logBoxLine(`Timestamp: ${timestamp}`);
|
logger.logBoxLine(`Timestamp: ${timestamp}`);
|
||||||
logger.logBoxLine('');
|
logger.logBoxEnd();
|
||||||
|
logger.log('');
|
||||||
|
|
||||||
|
// Build table data
|
||||||
|
const columns: Array<{ header: string; key: string; align?: 'left' | 'right'; color?: (val: string) => string }> = [
|
||||||
|
{ header: 'UPS Name', key: 'name', align: 'left', color: theme.highlight },
|
||||||
|
{ header: 'ID', key: 'id', align: 'left', color: theme.dim },
|
||||||
|
{ header: 'Power Status', key: 'powerStatus', align: 'left' },
|
||||||
|
{ header: 'Battery', key: 'battery', align: 'right' },
|
||||||
|
{ header: 'Runtime', key: 'runtime', align: 'right' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const rows: Array<Record<string, string>> = [];
|
||||||
for (const [id, status] of this.upsStatus.entries()) {
|
for (const [id, status] of this.upsStatus.entries()) {
|
||||||
logger.logBoxLine(`UPS: ${status.name} (${id})`);
|
const batteryColor = getBatteryColor(status.batteryCapacity);
|
||||||
logger.logBoxLine(` Power Status: ${status.powerStatus}`);
|
const runtimeColor = getRuntimeColor(status.batteryRuntime);
|
||||||
logger.logBoxLine(
|
|
||||||
` Battery: ${status.batteryCapacity}% | Runtime: ${status.batteryRuntime} min`,
|
rows.push({
|
||||||
);
|
name: status.name,
|
||||||
logger.logBoxLine('');
|
id: id,
|
||||||
|
powerStatus: formatPowerStatus(status.powerStatus),
|
||||||
|
battery: batteryColor(status.batteryCapacity + '%'),
|
||||||
|
runtime: runtimeColor(status.batteryRuntime + ' min'),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.logBoxEnd();
|
logger.logTable(columns, rows);
|
||||||
|
logger.log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build action context from UPS state
|
||||||
|
* @param ups UPS configuration
|
||||||
|
* @param status Current UPS status
|
||||||
|
* @param triggerReason Why this action is being triggered
|
||||||
|
* @returns Action context
|
||||||
|
*/
|
||||||
|
private buildActionContext(
|
||||||
|
ups: IUpsConfig,
|
||||||
|
status: IUpsStatus,
|
||||||
|
triggerReason: 'powerStatusChange' | 'thresholdViolation',
|
||||||
|
): IActionContext {
|
||||||
|
return {
|
||||||
|
upsId: ups.id,
|
||||||
|
upsName: ups.name,
|
||||||
|
powerStatus: status.powerStatus as TPowerStatus,
|
||||||
|
batteryCapacity: status.batteryCapacity,
|
||||||
|
batteryRuntime: status.batteryRuntime,
|
||||||
|
previousPowerStatus: 'unknown' as TPowerStatus, // Will be set from map in calling code
|
||||||
|
timestamp: Date.now(),
|
||||||
|
triggerReason,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Evaluate if shutdown is required based on group configurations
|
* Trigger actions for a UPS device
|
||||||
|
* @param ups UPS configuration
|
||||||
|
* @param status Current UPS status
|
||||||
|
* @param previousStatus Previous UPS status (for determining previousPowerStatus)
|
||||||
|
* @param triggerReason Why actions are being triggered
|
||||||
*/
|
*/
|
||||||
private async evaluateGroupShutdownConditions(): Promise<void> {
|
private async triggerUpsActions(
|
||||||
if (!this.config.groups || this.config.groups.length === 0) {
|
ups: IUpsConfig,
|
||||||
// No groups defined, check individual UPS conditions
|
status: IUpsStatus,
|
||||||
for (const [id, status] of this.upsStatus.entries()) {
|
previousStatus: IUpsStatus | undefined,
|
||||||
if (status.powerStatus === 'onBattery') {
|
triggerReason: 'powerStatusChange' | 'thresholdViolation',
|
||||||
// Find the UPS config
|
|
||||||
const ups = this.config.upsDevices.find((u) => u.id === id);
|
|
||||||
if (ups) {
|
|
||||||
await this.evaluateUpsShutdownCondition(ups, status);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Evaluate each group
|
|
||||||
for (const group of this.config.groups) {
|
|
||||||
// Find all UPS devices in this group
|
|
||||||
const upsDevicesInGroup = this.config.upsDevices.filter((ups) =>
|
|
||||||
ups.groups && ups.groups.includes(group.id)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (upsDevicesInGroup.length === 0) {
|
|
||||||
// No UPS devices in this group
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (group.mode === 'redundant') {
|
|
||||||
// Redundant mode: only shutdown if ALL UPS devices in the group are in critical condition
|
|
||||||
await this.evaluateRedundantGroup(group, upsDevicesInGroup);
|
|
||||||
} else {
|
|
||||||
// Non-redundant mode: shutdown if ANY UPS device in the group is in critical condition
|
|
||||||
await this.evaluateNonRedundantGroup(group, upsDevicesInGroup);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Evaluate a redundant group for shutdown conditions
|
|
||||||
* In redundant mode, we only shut down if ALL UPS devices are in critical condition
|
|
||||||
*/
|
|
||||||
private async evaluateRedundantGroup(
|
|
||||||
group: IGroupConfig,
|
|
||||||
upsDevices: IUpsConfig[],
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Count UPS devices on battery and in critical condition
|
const actions = ups.actions || [];
|
||||||
let upsOnBattery = 0;
|
|
||||||
let upsInCriticalCondition = 0;
|
|
||||||
|
|
||||||
for (const ups of upsDevices) {
|
|
||||||
const status = this.upsStatus.get(ups.id);
|
|
||||||
if (!status) continue;
|
|
||||||
|
|
||||||
if (status.powerStatus === 'onBattery') {
|
|
||||||
upsOnBattery++;
|
|
||||||
|
|
||||||
// Check if this UPS is in critical condition
|
|
||||||
if (
|
|
||||||
status.batteryCapacity < ups.thresholds.battery ||
|
|
||||||
status.batteryRuntime < ups.thresholds.runtime
|
|
||||||
) {
|
|
||||||
upsInCriticalCondition++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// All UPS devices must be online for a redundant group to be considered healthy
|
|
||||||
const allUpsCount = upsDevices.length;
|
|
||||||
|
|
||||||
// If all UPS are on battery and in critical condition, shutdown
|
|
||||||
if (upsOnBattery === allUpsCount && upsInCriticalCondition === allUpsCount) {
|
|
||||||
logger.logBoxTitle(`Group Shutdown Required: ${group.name}`, 50);
|
|
||||||
logger.logBoxLine(`Mode: Redundant`);
|
|
||||||
logger.logBoxLine(`All ${allUpsCount} UPS devices in critical condition`);
|
|
||||||
logger.logBoxEnd();
|
|
||||||
|
|
||||||
await this.initiateShutdown(
|
|
||||||
`All UPS devices in redundant group "${group.name}" in critical condition`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Evaluate a non-redundant group for shutdown conditions
|
|
||||||
* In non-redundant mode, we shut down if ANY UPS device is in critical condition
|
|
||||||
*/
|
|
||||||
private async evaluateNonRedundantGroup(
|
|
||||||
group: IGroupConfig,
|
|
||||||
upsDevices: IUpsConfig[],
|
|
||||||
): Promise<void> {
|
|
||||||
for (const ups of upsDevices) {
|
|
||||||
const status = this.upsStatus.get(ups.id);
|
|
||||||
if (!status) continue;
|
|
||||||
|
|
||||||
if (status.powerStatus === 'onBattery') {
|
|
||||||
// Check if this UPS is in critical condition
|
|
||||||
if (
|
|
||||||
status.batteryCapacity < ups.thresholds.battery ||
|
|
||||||
status.batteryRuntime < ups.thresholds.runtime
|
|
||||||
) {
|
|
||||||
logger.logBoxTitle(`Group Shutdown Required: ${group.name}`, 50);
|
|
||||||
logger.logBoxLine(`Mode: Non-Redundant`);
|
|
||||||
logger.logBoxLine(`UPS ${ups.name} in critical condition`);
|
|
||||||
logger.logBoxLine(
|
|
||||||
`Battery: ${status.batteryCapacity}% (threshold: ${ups.thresholds.battery}%)`,
|
|
||||||
);
|
|
||||||
logger.logBoxLine(
|
|
||||||
`Runtime: ${status.batteryRuntime} min (threshold: ${ups.thresholds.runtime} min)`,
|
|
||||||
);
|
|
||||||
logger.logBoxEnd();
|
|
||||||
|
|
||||||
await this.initiateShutdown(
|
|
||||||
`UPS "${ups.name}" in non-redundant group "${group.name}" in critical condition`,
|
|
||||||
);
|
|
||||||
return; // Exit after initiating shutdown
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Evaluate an individual UPS for shutdown conditions
|
|
||||||
*/
|
|
||||||
private async evaluateUpsShutdownCondition(ups: IUpsConfig, status: IUpsStatus): Promise<void> {
|
|
||||||
// Only evaluate UPS devices not in any group
|
|
||||||
if (ups.groups && ups.groups.length > 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check threshold conditions
|
|
||||||
if (
|
|
||||||
status.batteryCapacity < ups.thresholds.battery ||
|
|
||||||
status.batteryRuntime < ups.thresholds.runtime
|
|
||||||
) {
|
|
||||||
logger.logBoxTitle(`UPS Shutdown Required: ${ups.name}`, 50);
|
|
||||||
logger.logBoxLine(
|
|
||||||
`Battery: ${status.batteryCapacity}% (threshold: ${ups.thresholds.battery}%)`,
|
|
||||||
);
|
|
||||||
logger.logBoxLine(
|
|
||||||
`Runtime: ${status.batteryRuntime} min (threshold: ${ups.thresholds.runtime} min)`,
|
|
||||||
);
|
|
||||||
logger.logBoxEnd();
|
|
||||||
|
|
||||||
|
// Backward compatibility: if no actions configured, use default shutdown behavior
|
||||||
|
if (actions.length === 0 && triggerReason === 'thresholdViolation') {
|
||||||
|
// Fall back to old shutdown logic for backward compatibility
|
||||||
await this.initiateShutdown(`UPS "${ups.name}" battery or runtime below threshold`);
|
await this.initiateShutdown(`UPS "${ups.name}" battery or runtime below threshold`);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (actions.length === 0) {
|
||||||
|
return; // No actions to execute
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build action context
|
||||||
|
const context = this.buildActionContext(ups, status, triggerReason);
|
||||||
|
context.previousPowerStatus = (previousStatus?.powerStatus || 'unknown') as TPowerStatus;
|
||||||
|
|
||||||
|
// Execute actions
|
||||||
|
await ActionManager.executeActions(actions, context);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -745,38 +737,61 @@ export class NupstDaemon {
|
|||||||
const MAX_MONITORING_TIME = 5 * 60 * 1000; // Max 5 minutes of monitoring
|
const MAX_MONITORING_TIME = 5 * 60 * 1000; // Max 5 minutes of monitoring
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
|
||||||
logger.log(
|
logger.log('');
|
||||||
`Emergency shutdown threshold: ${EMERGENCY_RUNTIME_THRESHOLD} minutes remaining battery runtime`,
|
logger.logBoxTitle('Shutdown Monitoring Active', 60, 'warning');
|
||||||
);
|
logger.logBoxLine(`Emergency threshold: ${EMERGENCY_RUNTIME_THRESHOLD} minutes runtime`);
|
||||||
|
logger.logBoxLine(`Check interval: ${CHECK_INTERVAL / 1000} seconds`);
|
||||||
|
logger.logBoxLine(`Max monitoring time: ${MAX_MONITORING_TIME / 1000} seconds`);
|
||||||
|
logger.logBoxEnd();
|
||||||
|
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 < MAX_MONITORING_TIME) {
|
||||||
try {
|
try {
|
||||||
logger.log('Checking UPS status during shutdown...');
|
logger.info('Checking UPS status during shutdown...');
|
||||||
|
|
||||||
|
// Build table for UPS status during shutdown
|
||||||
|
const columns: Array<{ header: string; key: string; align?: 'left' | 'right'; color?: (val: string) => string }> = [
|
||||||
|
{ header: 'UPS Name', key: 'name', align: 'left', color: theme.highlight },
|
||||||
|
{ header: 'Battery', key: 'battery', align: 'right' },
|
||||||
|
{ header: 'Runtime', key: 'runtime', align: 'right' },
|
||||||
|
{ header: 'Status', key: 'status', align: 'left' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const rows: Array<Record<string, string>> = [];
|
||||||
|
let emergencyDetected = false;
|
||||||
|
let emergencyUps: any = null;
|
||||||
|
|
||||||
// Check all UPS devices
|
// Check all UPS devices
|
||||||
for (const ups of this.config.upsDevices) {
|
for (const ups of this.config.upsDevices) {
|
||||||
try {
|
try {
|
||||||
const status = await this.snmp.getUpsStatus(ups.snmp);
|
const status = await this.snmp.getUpsStatus(ups.snmp);
|
||||||
|
|
||||||
logger.log(
|
const batteryColor = getBatteryColor(status.batteryCapacity);
|
||||||
`UPS ${ups.name}: Battery ${status.batteryCapacity}%, Runtime: ${status.batteryRuntime} minutes`,
|
const runtimeColor = getRuntimeColor(status.batteryRuntime);
|
||||||
);
|
|
||||||
|
|
||||||
// If any UPS battery runtime gets critically low, force immediate shutdown
|
const isCritical = status.batteryRuntime < EMERGENCY_RUNTIME_THRESHOLD;
|
||||||
if (status.batteryRuntime < EMERGENCY_RUNTIME_THRESHOLD) {
|
|
||||||
logger.logBoxTitle('EMERGENCY SHUTDOWN', 50);
|
|
||||||
logger.logBoxLine(
|
|
||||||
`UPS ${ups.name} runtime critically low: ${status.batteryRuntime} minutes`,
|
|
||||||
);
|
|
||||||
logger.logBoxLine('Forcing immediate shutdown!');
|
|
||||||
logger.logBoxEnd();
|
|
||||||
|
|
||||||
// Force immediate shutdown
|
rows.push({
|
||||||
await this.forceImmediateShutdown();
|
name: ups.name,
|
||||||
return;
|
battery: batteryColor(status.batteryCapacity + '%'),
|
||||||
|
runtime: runtimeColor(status.batteryRuntime + ' min'),
|
||||||
|
status: isCritical ? theme.error('CRITICAL!') : theme.success('OK'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// If any UPS battery runtime gets critically low, flag for immediate shutdown
|
||||||
|
if (isCritical && !emergencyDetected) {
|
||||||
|
emergencyDetected = true;
|
||||||
|
emergencyUps = { ups, status };
|
||||||
}
|
}
|
||||||
} catch (upsError) {
|
} catch (upsError) {
|
||||||
|
rows.push({
|
||||||
|
name: ups.name,
|
||||||
|
battery: theme.error('N/A'),
|
||||||
|
runtime: theme.error('N/A'),
|
||||||
|
status: theme.error('ERROR'),
|
||||||
|
});
|
||||||
|
|
||||||
logger.error(
|
logger.error(
|
||||||
`Error checking UPS ${ups.name} during shutdown: ${
|
`Error checking UPS ${ups.name} during shutdown: ${
|
||||||
upsError instanceof Error ? upsError.message : String(upsError)
|
upsError instanceof Error ? upsError.message : String(upsError)
|
||||||
@@ -785,6 +800,27 @@ export class NupstDaemon {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Display the table
|
||||||
|
logger.logTable(columns, rows);
|
||||||
|
logger.log('');
|
||||||
|
|
||||||
|
// If emergency detected, trigger immediate shutdown
|
||||||
|
if (emergencyDetected && emergencyUps) {
|
||||||
|
logger.log('');
|
||||||
|
logger.logBoxTitle('EMERGENCY SHUTDOWN', 60, 'error');
|
||||||
|
logger.logBoxLine(
|
||||||
|
`UPS ${emergencyUps.ups.name} runtime critically low: ${emergencyUps.status.batteryRuntime} minutes`,
|
||||||
|
);
|
||||||
|
logger.logBoxLine(`Emergency threshold: ${EMERGENCY_RUNTIME_THRESHOLD} minutes`);
|
||||||
|
logger.logBoxLine('Forcing immediate shutdown!');
|
||||||
|
logger.logBoxEnd();
|
||||||
|
logger.log('');
|
||||||
|
|
||||||
|
// Force immediate shutdown
|
||||||
|
await this.forceImmediateShutdown();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Wait before checking again
|
// Wait before checking again
|
||||||
await this.sleep(CHECK_INTERVAL);
|
await this.sleep(CHECK_INTERVAL);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -797,7 +833,9 @@ export class NupstDaemon {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.log('UPS monitoring during shutdown completed');
|
logger.log('');
|
||||||
|
logger.success('UPS monitoring during shutdown completed');
|
||||||
|
logger.log('');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@@ -5,16 +5,14 @@
|
|||||||
* Migrations run in order based on the `order` field, allowing users to jump
|
* Migrations run in order based on the `order` field, allowing users to jump
|
||||||
* multiple versions (e.g., v1 → v4 runs migrations 2, 3, and 4).
|
* multiple versions (e.g., v1 → v4 runs migrations 2, 3, and 4).
|
||||||
*/
|
*/
|
||||||
export abstract class BaseMigration {
|
/**
|
||||||
/**
|
* Abstract base class for configuration migrations
|
||||||
* Migration order number
|
*
|
||||||
* - Order 2: v1 → v2
|
* Each migration represents an upgrade from one config version to another.
|
||||||
* - Order 3: v2 → v3
|
* Migrations run in order based on the `toVersion` field, allowing users to jump
|
||||||
* - Order 4: v3 → v4
|
* multiple versions (e.g., v1 → v4 runs migrations 2, 3, and 4).
|
||||||
* etc.
|
|
||||||
*/
|
*/
|
||||||
abstract readonly order: number;
|
export abstract class BaseMigration {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Source version this migration upgrades from
|
* Source version this migration upgrades from
|
||||||
* e.g., "1.x", "3.x"
|
* e.g., "1.x", "3.x"
|
||||||
@@ -23,7 +21,7 @@ export abstract class BaseMigration {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Target version this migration upgrades to
|
* Target version this migration upgrades to
|
||||||
* e.g., "2.0", "4.0"
|
* e.g., "2.0", "4.0", "4.1"
|
||||||
*/
|
*/
|
||||||
abstract readonly toVersion: string;
|
abstract readonly toVersion: string;
|
||||||
|
|
||||||
@@ -51,4 +49,19 @@ export abstract class BaseMigration {
|
|||||||
getName(): string {
|
getName(): string {
|
||||||
return `Migration ${this.fromVersion} → ${this.toVersion}`;
|
return `Migration ${this.fromVersion} → ${this.toVersion}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse version string into a comparable number
|
||||||
|
* Supports formats like "2.0", "4.1", etc.
|
||||||
|
* Returns a number like 2.0, 4.1 for sorting
|
||||||
|
*
|
||||||
|
* @returns Parsed version number for ordering
|
||||||
|
*/
|
||||||
|
getVersionOrder(): number {
|
||||||
|
const parsed = parseFloat(this.toVersion);
|
||||||
|
if (isNaN(parsed)) {
|
||||||
|
throw new Error(`Invalid version format: ${this.toVersion}`);
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -8,3 +8,4 @@ export { BaseMigration } from './base-migration.ts';
|
|||||||
export { MigrationRunner } from './migration-runner.ts';
|
export { MigrationRunner } from './migration-runner.ts';
|
||||||
export { MigrationV1ToV2 } from './migration-v1-to-v2.ts';
|
export { MigrationV1ToV2 } from './migration-v1-to-v2.ts';
|
||||||
export { MigrationV3ToV4 } from './migration-v3-to-v4.ts';
|
export { MigrationV3ToV4 } from './migration-v3-to-v4.ts';
|
||||||
|
export { MigrationV4_1ToV4_2 } from './migration-v4.1-to-v4.2.ts';
|
||||||
|
@@ -1,6 +1,7 @@
|
|||||||
import { BaseMigration } from './base-migration.ts';
|
import { BaseMigration } from './base-migration.ts';
|
||||||
import { MigrationV1ToV2 } from './migration-v1-to-v2.ts';
|
import { MigrationV1ToV2 } from './migration-v1-to-v2.ts';
|
||||||
import { MigrationV3ToV4 } from './migration-v3-to-v4.ts';
|
import { MigrationV3ToV4 } from './migration-v3-to-v4.ts';
|
||||||
|
import { MigrationV4_1ToV4_2 } from './migration-v4.1-to-v4.2.ts';
|
||||||
import { logger } from '../logger.ts';
|
import { logger } from '../logger.ts';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -17,11 +18,12 @@ export class MigrationRunner {
|
|||||||
this.migrations = [
|
this.migrations = [
|
||||||
new MigrationV1ToV2(),
|
new MigrationV1ToV2(),
|
||||||
new MigrationV3ToV4(),
|
new MigrationV3ToV4(),
|
||||||
// Add future migrations here (v4→v5, v5→v6, etc.)
|
new MigrationV4_1ToV4_2(),
|
||||||
|
// Add future migrations here (v4.3, v4.4, etc.)
|
||||||
];
|
];
|
||||||
|
|
||||||
// Sort by order to ensure they run in sequence
|
// Sort by version order to ensure they run in sequence
|
||||||
this.migrations.sort((a, b) => a.order - b.order);
|
this.migrations.sort((a, b) => a.getVersionOrder() - b.getVersionOrder());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@@ -20,7 +20,6 @@ import { logger } from '../logger.ts';
|
|||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
export class MigrationV1ToV2 extends BaseMigration {
|
export class MigrationV1ToV2 extends BaseMigration {
|
||||||
readonly order = 2;
|
|
||||||
readonly fromVersion = '1.x';
|
readonly fromVersion = '1.x';
|
||||||
readonly toVersion = '2.0';
|
readonly toVersion = '2.0';
|
||||||
|
|
||||||
|
@@ -39,7 +39,6 @@ import { logger } from '../logger.ts';
|
|||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
export class MigrationV3ToV4 extends BaseMigration {
|
export class MigrationV3ToV4 extends BaseMigration {
|
||||||
readonly order = 4;
|
|
||||||
readonly fromVersion = '3.x';
|
readonly fromVersion = '3.x';
|
||||||
readonly toVersion = '4.0';
|
readonly toVersion = '4.0';
|
||||||
|
|
||||||
|
125
ts/migrations/migration-v4.1-to-v4.2.ts
Normal file
125
ts/migrations/migration-v4.1-to-v4.2.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import { BaseMigration } from './base-migration.ts';
|
||||||
|
import { logger } from '../logger.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migration from v4.1 to v4.2
|
||||||
|
*
|
||||||
|
* Major changes:
|
||||||
|
* 1. Moves thresholds from UPS level to action level
|
||||||
|
* 2. Creates default shutdown action for UPS devices that had thresholds
|
||||||
|
* 3. Adds empty actions array to UPS devices without actions
|
||||||
|
* 4. Adds empty actions array to groups
|
||||||
|
*
|
||||||
|
* Transforms v4.1 format (with UPS-level thresholds):
|
||||||
|
* {
|
||||||
|
* version: "4.1",
|
||||||
|
* upsDevices: [
|
||||||
|
* {
|
||||||
|
* id: "ups-1",
|
||||||
|
* name: "UPS 1",
|
||||||
|
* snmp: {...},
|
||||||
|
* thresholds: { battery: 60, runtime: 20 }, // UPS-level
|
||||||
|
* groups: [],
|
||||||
|
* actions: []
|
||||||
|
* }
|
||||||
|
* ]
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* To v4.2 format (with action-level thresholds):
|
||||||
|
* {
|
||||||
|
* version: "4.2",
|
||||||
|
* upsDevices: [
|
||||||
|
* {
|
||||||
|
* id: "ups-1",
|
||||||
|
* name: "UPS 1",
|
||||||
|
* snmp: {...},
|
||||||
|
* groups: [],
|
||||||
|
* actions: [ // Thresholds moved here
|
||||||
|
* {
|
||||||
|
* type: "shutdown",
|
||||||
|
* thresholds: { battery: 60, runtime: 20 },
|
||||||
|
* triggerMode: "onlyThresholds",
|
||||||
|
* shutdownDelay: 5
|
||||||
|
* }
|
||||||
|
* ]
|
||||||
|
* }
|
||||||
|
* ]
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
export class MigrationV4_1ToV4_2 extends BaseMigration {
|
||||||
|
readonly fromVersion = '4.1';
|
||||||
|
readonly toVersion = '4.2';
|
||||||
|
|
||||||
|
async shouldRun(config: any): Promise<boolean> {
|
||||||
|
// Run if config is version 4.1
|
||||||
|
if (config.version === '4.1') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also run if config has upsDevices with thresholds at UPS level (v4.1 format)
|
||||||
|
if (config.upsDevices && config.upsDevices.length > 0) {
|
||||||
|
const firstDevice = config.upsDevices[0];
|
||||||
|
// v4.1 has thresholds at UPS level, v4.2 has them in actions
|
||||||
|
return firstDevice.thresholds !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async migrate(config: any): Promise<any> {
|
||||||
|
logger.info(`${this.getName()}: Migrating v4.1 config to v4.2 format...`);
|
||||||
|
logger.dim(` - Moving thresholds from UPS level to action level`);
|
||||||
|
logger.dim(` - Creating default shutdown actions from existing thresholds`);
|
||||||
|
|
||||||
|
// Migrate UPS devices
|
||||||
|
const migratedDevices = (config.upsDevices || []).map((device: any) => {
|
||||||
|
const migrated: any = {
|
||||||
|
id: device.id,
|
||||||
|
name: device.name,
|
||||||
|
snmp: device.snmp,
|
||||||
|
groups: device.groups || [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// If device has thresholds at UPS level, convert to shutdown action
|
||||||
|
if (device.thresholds) {
|
||||||
|
migrated.actions = [
|
||||||
|
{
|
||||||
|
type: 'shutdown',
|
||||||
|
thresholds: {
|
||||||
|
battery: device.thresholds.battery,
|
||||||
|
runtime: device.thresholds.runtime,
|
||||||
|
},
|
||||||
|
triggerMode: 'onlyThresholds', // Preserve old behavior (only on threshold violation)
|
||||||
|
shutdownDelay: 5, // Default delay
|
||||||
|
},
|
||||||
|
];
|
||||||
|
logger.dim(
|
||||||
|
` → ${device.name}: Created shutdown action (battery: ${device.thresholds.battery}%, runtime: ${device.thresholds.runtime}min)`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// No thresholds, just add empty actions array
|
||||||
|
migrated.actions = device.actions || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return migrated;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add actions to groups
|
||||||
|
const migratedGroups = (config.groups || []).map((group: any) => ({
|
||||||
|
...group,
|
||||||
|
actions: group.actions || [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
version: this.toVersion,
|
||||||
|
upsDevices: migratedDevices,
|
||||||
|
groups: migratedGroups,
|
||||||
|
checkInterval: config.checkInterval || 30000,
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.success(
|
||||||
|
`${this.getName()}: Migration complete (${migratedDevices.length} devices, ${migratedGroups.length} groups updated)`,
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
Reference in New Issue
Block a user