feat(actions): implement action system for UPS state management with shutdown, webhook, and script actions

This commit is contained in:
2025-10-20 11:47:51 +00:00
parent a7113d0387
commit 32bd27b849
11 changed files with 1238 additions and 166 deletions

View File

@@ -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<Record<string, string>> = 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<void> {
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<void> {
// 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<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 (
@@ -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<void> {
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