feat(actions): implement action system for UPS state management with shutdown, webhook, and script actions
This commit is contained in:
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();
|
||||
});
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user