diff --git a/example-action.sh b/example-action.sh new file mode 100644 index 0000000..674dc80 --- /dev/null +++ b/example-action.sh @@ -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 diff --git a/ts/actions/base-action.ts b/ts/actions/base-action.ts new file mode 100644 index 0000000..6336cc3 --- /dev/null +++ b/ts/actions/base-action.ts @@ -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; + + /** + * 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 + ); + } +} diff --git a/ts/actions/index.ts b/ts/actions/index.ts new file mode 100644 index 0000000..049a5f3 --- /dev/null +++ b/ts/actions/index.ts @@ -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 { + 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(''); + } +} diff --git a/ts/actions/script-action.ts b/ts/actions/script-action.ts new file mode 100644 index 0000000..f57e17d --- /dev/null +++ b/ts/actions/script-action.ts @@ -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 { + // 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 { + // 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; + } + } +} diff --git a/ts/actions/shutdown-action.ts b/ts/actions/shutdown-action.ts new file mode 100644 index 0000000..ae0d40c --- /dev/null +++ b/ts/actions/shutdown-action.ts @@ -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 { + // 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 { + // 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 { + 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'); + } +} diff --git a/ts/actions/webhook-action.ts b/ts/actions/webhook-action.ts new file mode 100644 index 0000000..dae9059 --- /dev/null +++ b/ts/actions/webhook-action.ts @@ -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 { + // 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 { + 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(); + }); + } +} diff --git a/ts/cli/ups-handler.ts b/ts/cli/ups-handler.ts index 602e7a0..7de1e47 100644 --- a/ts/cli/ups-handler.ts +++ b/ts/cli/ups-handler.ts @@ -77,10 +77,10 @@ export class UpsHandler { checkInterval: config.checkInterval, upsDevices: [{ id: 'default', - name: 'Default UPS', - snmp: config.snmp, - thresholds: config.thresholds, - groups: [], + name: 'Default UPS', + snmp: config.snmp, + groups: [], + actions: [], }], groups: [], }; @@ -117,6 +117,7 @@ export class UpsHandler { runtime: 20, }, groups: [], + actions: [], }; // Gather SNMP settings @@ -136,6 +137,9 @@ export class UpsHandler { await groupHandler.assignUpsToGroups(newUps, config.groups, prompt); } +// Gather action settings + await this.gatherActionSettings(newUps.actions, prompt); + // Add the new UPS to the config config.upsDevices.push(newUps); @@ -221,16 +225,16 @@ export class UpsHandler { // Convert old format to new format if needed if (!config.upsDevices) { // Initialize with the current config as the first UPS - if (!config.snmp || !config.thresholds) { - logger.error('Legacy configuration is missing required SNMP or threshold settings'); + if (!config.snmp) { + logger.error('Legacy configuration is missing required SNMP settings'); return; } config.upsDevices = [{ id: 'default', name: 'Default UPS', snmp: config.snmp, - thresholds: config.thresholds, groups: [], + actions: [], }]; config.groups = []; logger.log('Converting existing configuration to multi-UPS format.'); @@ -265,9 +269,6 @@ export class UpsHandler { // Edit SNMP settings await this.gatherSnmpSettings(upsToEdit.snmp, prompt); - // Edit threshold settings - await this.gatherThresholdSettings(upsToEdit.thresholds, prompt); - // Edit UPS model settings await this.gatherUpsModelSettings(upsToEdit.snmp, prompt); @@ -279,6 +280,14 @@ export class UpsHandler { 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 await this.nupst.getDaemon().saveConfig(config); @@ -396,13 +405,12 @@ export class UpsHandler { logger.logBox('UPS Devices', [ 'Legacy single-UPS configuration detected.', '', - ...((!config.snmp || !config.thresholds) - ? ['Error: Configuration missing SNMP or threshold settings'] + ...(!config.snmp + ? ['Error: Configuration missing SNMP settings'] : [ 'Default UPS:', ` Host: ${config.snmp.host}:${config.snmp.port}`, ` Model: ${config.snmp.upsModel || 'cyberpower'}`, - ` Thresholds: ${config.thresholds.battery}% battery, ${config.thresholds.runtime} min runtime`, '', 'Use "nupst ups add" to add more UPS devices and migrate', 'to the multi-UPS configuration format.', @@ -506,9 +514,8 @@ export class UpsHandler { */ private displayTestConfig(config: any): void { // 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 thresholds = isUpsConfig ? config.thresholds : config.thresholds || {}; const checkInterval = config.checkInterval || 30000; // Get UPS name and ID if available @@ -919,6 +926,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, + ): Promise { + 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 * @param ups UPS configuration diff --git a/ts/daemon.ts b/ts/daemon.ts index 95873e2..0e689ff 100644 --- a/ts/daemon.ts +++ b/ts/daemon.ts @@ -8,6 +8,8 @@ import type { ISnmpConfig } from './snmp/types.ts'; import { logger, type ITableColumn } from './logger.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 execFileAsync = promisify(execFile); @@ -22,15 +24,10 @@ export interface IUpsConfig { name: string; /** SNMP configuration settings */ 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 */ groups: string[]; + /** Actions to trigger on power status changes and threshold violations */ + actions?: IActionConfig[]; } /** @@ -45,6 +42,8 @@ export interface IGroupConfig { mode: 'redundant' | 'nonRedundant'; /** Optional description */ description?: string; + /** Actions to trigger on power status changes and threshold violations */ + actions?: IActionConfig[]; } /** @@ -97,7 +96,7 @@ export class NupstDaemon { /** Default configuration */ private readonly DEFAULT_CONFIG: INupstConfig = { - version: '4.0', + version: '4.1', upsDevices: [ { id: 'default', @@ -118,16 +117,23 @@ export class NupstDaemon { // UPS model for OID selection upsModel: 'cyberpower', }, - thresholds: { - battery: 60, // Shutdown when battery below 60% - runtime: 20, // Shutdown when runtime below 20 minutes - }, groups: [], + actions: [ + { + type: 'shutdown', + triggerMode: 'onlyThresholds', + thresholds: { + battery: 60, // Shutdown when battery below 60% + runtime: 20, // Shutdown when runtime below 20 minutes + }, + shutdownDelay: 5, + }, + ], }, ], groups: [], checkInterval: 30000, // Check every 30 seconds - }; + } private config: INupstConfig; private snmp: NupstSnmp; @@ -199,7 +205,7 @@ export class NupstDaemon { // Ensure version is always set and remove legacy fields before saving const configToSave: INupstConfig = { - version: '4.0', + version: '4.1', upsDevices: config.upsDevices, groups: config.groups, checkInterval: config.checkInterval, @@ -298,6 +304,8 @@ export class NupstDaemon { batteryRuntime: 999, // High value as default lastStatusChange: Date.now(), lastCheckTime: 0, + thresholdsExceeded: false, + lastThresholdCrossing: 0, }); } @@ -326,14 +334,14 @@ export class NupstDaemon { { 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: 'Battery/Runtime', key: 'thresholds', align: 'left' }, + { header: 'Actions', key: 'actions', align: 'left' }, ]; const upsRows: Array> = this.config.upsDevices.map((ups) => ({ name: ups.name, id: ups.id, host: `${ups.snmp.host}:${ups.snmp.port}`, - thresholds: `${ups.thresholds.battery}% / ${ups.thresholds.runtime} min`, + actions: `${(ups.actions || []).length} configured`, })); logger.logTable(upsColumns, upsRows); @@ -401,9 +409,6 @@ export class NupstDaemon { lastLogTime = currentTime; } - // Check if shutdown is required based on group configurations - await this.evaluateGroupShutdownConditions(); - // Wait before next check await this.sleep(this.config.checkInterval); } catch (error) { @@ -466,6 +471,33 @@ export class NupstDaemon { logger.log(''); 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 @@ -519,137 +551,7 @@ export class NupstDaemon { logger.log(''); } - /** - * Evaluate if shutdown is required based on group configurations - */ - private async evaluateGroupShutdownConditions(): Promise { - if (!this.config.groups || this.config.groups.length === 0) { - // No groups defined, check individual UPS conditions - for (const [id, status] of this.upsStatus.entries()) { - if (status.powerStatus === 'onBattery') { - // 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 { - // Count UPS devices on battery and in critical condition - 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 { - 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 { - // Only evaluate UPS devices not in any group - if (ups.groups && ups.groups.length > 0) { - return; - } + // Check threshold conditions if ( @@ -669,6 +571,64 @@ export class NupstDaemon { } } + /** + * 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, + }; + } + + /** + * 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 triggerUpsActions( + ups: IUpsConfig, + status: IUpsStatus, + previousStatus: IUpsStatus | undefined, + triggerReason: 'powerStatusChange' | 'thresholdViolation', + ): Promise { + const actions = ups.actions || []; + + // 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`); + 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); + } + /** * Initiate system shutdown with UPS monitoring during shutdown * @param reason Reason for shutdown diff --git a/ts/migrations/index.ts b/ts/migrations/index.ts index 9bb2ccb..74e65fd 100644 --- a/ts/migrations/index.ts +++ b/ts/migrations/index.ts @@ -8,3 +8,4 @@ export { BaseMigration } from './base-migration.ts'; export { MigrationRunner } from './migration-runner.ts'; export { MigrationV1ToV2 } from './migration-v1-to-v2.ts'; export { MigrationV3ToV4 } from './migration-v3-to-v4.ts'; +export { MigrationV4_0ToV4_1 } from './migration-v4.0-to-v4.1.ts'; diff --git a/ts/migrations/migration-runner.ts b/ts/migrations/migration-runner.ts index 0578397..d01c703 100644 --- a/ts/migrations/migration-runner.ts +++ b/ts/migrations/migration-runner.ts @@ -1,6 +1,7 @@ import { BaseMigration } from './base-migration.ts'; import { MigrationV1ToV2 } from './migration-v1-to-v2.ts'; import { MigrationV3ToV4 } from './migration-v3-to-v4.ts'; +import { MigrationV4_0ToV4_1 } from './migration-v4.0-to-v4.1.ts'; import { logger } from '../logger.ts'; /** @@ -17,7 +18,8 @@ export class MigrationRunner { this.migrations = [ new MigrationV1ToV2(), new MigrationV3ToV4(), - // Add future migrations here (v4→v5, v5→v6, etc.) + new MigrationV4_0ToV4_1(), + // Add future migrations here (v4.2, v4.3, etc.) ]; // Sort by order to ensure they run in sequence diff --git a/ts/migrations/migration-v4.0-to-v4.1.ts b/ts/migrations/migration-v4.0-to-v4.1.ts new file mode 100644 index 0000000..5664e28 --- /dev/null +++ b/ts/migrations/migration-v4.0-to-v4.1.ts @@ -0,0 +1,125 @@ +import { BaseMigration } from './base-migration.ts'; +import { logger } from '../logger.ts'; + +/** + * Migration from v4.0 to v4.1 + * + * 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.0 format: + * { + * version: "4.0", + * upsDevices: [ + * { + * id: "ups-1", + * name: "UPS 1", + * snmp: {...}, + * thresholds: { battery: 60, runtime: 20 }, // UPS-level + * groups: [] + * } + * ] + * } + * + * To v4.1 format: + * { + * version: "4.1", + * upsDevices: [ + * { + * id: "ups-1", + * name: "UPS 1", + * snmp: {...}, + * groups: [], + * actions: [ // Thresholds moved here + * { + * type: "shutdown", + * thresholds: { battery: 60, runtime: 20 }, + * onlyOnThresholdViolation: true, + * shutdownDelay: 5 + * } + * ] + * } + * ] + * } + */ +export class MigrationV4_0ToV4_1 extends BaseMigration { + readonly order = 5; + readonly fromVersion = '4.0'; + readonly toVersion = '4.1'; + + async shouldRun(config: any): Promise { + // Run if config is version 4.0 or missing version with v4 structure + if (config.version === '4.0') { + return true; + } + + // Also run if config has upsDevices with thresholds at UPS level (v4.0 format) + if (config.upsDevices && config.upsDevices.length > 0) { + const firstDevice = config.upsDevices[0]; + // v4.0 has thresholds at UPS level, v4.1 has them in actions + return firstDevice.thresholds !== undefined && firstDevice.actions === undefined; + } + + return false; + } + + async migrate(config: any): Promise { + logger.info(`${this.getName()}: Migrating v4.0 config to v4.1 format...`); + logger.dim(` - Moving thresholds from UPS level to action level`); + logger.dim(` - Creating default shutdown actions from existing thresholds`); + + // 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; + } +}