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