feat(monitoring): add edge-triggered threshold handling with group action orchestration and HA-aware Proxmox shutdowns

This commit is contained in:
2026-04-16 02:54:16 +00:00
parent bf4d519428
commit a435bd6fed
13 changed files with 1052 additions and 117 deletions
+167 -11
View File
@@ -14,6 +14,7 @@ import type { IActionConfig } from './actions/base-action.ts';
import { ActionManager } from './actions/index.ts';
import {
applyDefaultShutdownDelay,
buildUpsActionContext,
decideUpsActionExecution,
type TUpsTriggerReason,
} from './action-orchestration.ts';
@@ -26,11 +27,17 @@ import {
} from './config-watch.ts';
import { type IPauseState, loadPauseSnapshot } from './pause-state.ts';
import { ShutdownExecutor } from './shutdown-executor.ts';
import {
buildGroupStatusSnapshot,
buildGroupThresholdContextStatus,
evaluateGroupActionThreshold,
} from './group-monitoring.ts';
import {
buildFailedUpsPollSnapshot,
buildSuccessfulUpsPollSnapshot,
ensureUpsStatus,
hasThresholdViolation,
getActionThresholdStates,
getEnteredThresholdIndexes,
} from './ups-monitoring.ts';
import {
buildShutdownErrorRow,
@@ -178,6 +185,8 @@ export class NupstDaemon {
private isPaused: boolean = false;
private pauseState: IPauseState | null = null;
private upsStatus: Map<string, IUpsStatus> = new Map();
private groupStatus: Map<string, IUpsStatus> = new Map();
private thresholdState: Map<string, boolean[]> = new Map();
private httpServer?: NupstHttpServer;
private readonly shutdownExecutor: ShutdownExecutor;
@@ -218,7 +227,8 @@ export class NupstDaemon {
// Cast to INupstConfig since migrations ensure the output is valid.
const validConfig = migratedConfig as unknown as INupstConfig;
const normalizedShutdownDelay = this.normalizeShutdownDelay(validConfig.defaultShutdownDelay);
const shouldPersistNormalizedConfig = validConfig.defaultShutdownDelay !== normalizedShutdownDelay;
const shouldPersistNormalizedConfig =
validConfig.defaultShutdownDelay !== normalizedShutdownDelay;
validConfig.defaultShutdownDelay = normalizedShutdownDelay;
if (migrated || shouldPersistNormalizedConfig) {
this.config = validConfig;
@@ -642,19 +652,24 @@ export class NupstDaemon {
);
}
if (
hasThresholdViolation(
status.powerStatus,
status.batteryCapacity,
status.batteryRuntime,
ups.actions,
)
) {
const thresholdStates = getActionThresholdStates(
status.powerStatus,
status.batteryCapacity,
status.batteryRuntime,
ups.actions,
);
const enteredThresholdIndexes = this.trackEnteredThresholdIndexes(
`ups:${ups.id}`,
thresholdStates,
);
if (enteredThresholdIndexes.length > 0) {
await this.triggerUpsActions(
ups,
pollSnapshot.updatedStatus,
pollSnapshot.previousStatus,
'thresholdViolation',
enteredThresholdIndexes,
);
}
@@ -694,6 +709,95 @@ export class NupstDaemon {
this.upsStatus.set(ups.id, failureSnapshot.updatedStatus);
}
}
await this.checkGroupActions();
}
private trackEnteredThresholdIndexes(sourceKey: string, currentStates: boolean[]): number[] {
const previousStates = this.thresholdState.get(sourceKey);
const enteredIndexes = getEnteredThresholdIndexes(previousStates, currentStates);
this.thresholdState.set(sourceKey, [...currentStates]);
return enteredIndexes;
}
private getGroupActionIdentity(group: IGroupConfig): { id: string; name: string } {
return {
id: group.id,
name: `Group ${group.name}`,
};
}
private async checkGroupActions(): Promise<void> {
for (const group of this.config.groups || []) {
const groupIdentity = this.getGroupActionIdentity(group);
const memberStatuses = this.config.upsDevices
.filter((ups) => ups.groups?.includes(group.id))
.map((ups) => this.upsStatus.get(ups.id))
.filter((status): status is IUpsStatus => !!status);
if (memberStatuses.length === 0) {
continue;
}
const currentTime = Date.now();
const pollSnapshot = buildGroupStatusSnapshot(
groupIdentity,
group.mode,
memberStatuses,
this.groupStatus.get(group.id),
currentTime,
);
if (pollSnapshot.transition === 'powerStatusChange' && pollSnapshot.previousStatus) {
logger.log('');
logger.logBoxTitle(`Group Power Status Change: ${group.name}`, 60, 'warning');
logger.logBoxLine(
`Previous: ${formatPowerStatus(pollSnapshot.previousStatus.powerStatus)}`,
);
logger.logBoxLine(`Current: ${formatPowerStatus(pollSnapshot.updatedStatus.powerStatus)}`);
logger.logBoxLine(`Members: ${memberStatuses.map((status) => status.name).join(', ')}`);
logger.logBoxLine(`Time: ${new Date().toISOString()}`);
logger.logBoxEnd();
logger.log('');
await this.triggerGroupActions(
group,
pollSnapshot.updatedStatus,
pollSnapshot.previousStatus,
'powerStatusChange',
);
}
const thresholdEvaluations = (group.actions || []).map((action) =>
evaluateGroupActionThreshold(action, group.mode, memberStatuses)
);
const thresholdStates = thresholdEvaluations.map((evaluation) =>
evaluation.exceedsThreshold && !evaluation.blockedByUnreachable
);
const enteredThresholdIndexes = this.trackEnteredThresholdIndexes(
`group:${group.id}`,
thresholdStates,
);
if (enteredThresholdIndexes.length > 0) {
const thresholdStatus = buildGroupThresholdContextStatus(
groupIdentity,
thresholdEvaluations,
enteredThresholdIndexes,
pollSnapshot.updatedStatus,
currentTime,
);
await this.triggerGroupActions(
group,
thresholdStatus,
pollSnapshot.previousStatus,
'thresholdViolation',
enteredThresholdIndexes,
);
}
this.groupStatus.set(group.id, pollSnapshot.updatedStatus);
}
}
/**
@@ -761,6 +865,7 @@ export class NupstDaemon {
status: IUpsStatus,
previousStatus: IUpsStatus | undefined,
triggerReason: TUpsTriggerReason,
actionIndexes?: number[],
): Promise<void> {
const decision = decideUpsActionExecution(
this.isPaused,
@@ -784,14 +889,63 @@ export class NupstDaemon {
return;
}
const selectedActions = actionIndexes
? decision.actions.filter((_action, index) => actionIndexes.includes(index))
: decision.actions;
if (selectedActions.length === 0) {
return;
}
const actions = applyDefaultShutdownDelay(
decision.actions,
selectedActions,
this.getDefaultShutdownDelayMinutes(),
);
await ActionManager.executeActions(actions, decision.context);
}
private async triggerGroupActions(
group: IGroupConfig,
status: IUpsStatus,
previousStatus: IUpsStatus | undefined,
triggerReason: TUpsTriggerReason,
actionIndexes?: number[],
): Promise<void> {
if (this.isPaused) {
logger.info(
`[PAUSED] Actions suppressed for Group ${group.name} (trigger: ${triggerReason})`,
);
return;
}
const configuredActions = group.actions || [];
if (configuredActions.length === 0) {
return;
}
const selectedActions = actionIndexes
? configuredActions.filter((_action, index) => actionIndexes.includes(index))
: configuredActions;
if (selectedActions.length === 0) {
return;
}
const actions = applyDefaultShutdownDelay(
selectedActions,
this.getDefaultShutdownDelayMinutes(),
);
const context = buildUpsActionContext(
this.getGroupActionIdentity(group),
status,
previousStatus,
triggerReason,
);
await ActionManager.executeActions(actions, context);
}
/**
* Initiate system shutdown with UPS monitoring during shutdown
* @param reason Reason for shutdown
@@ -1054,6 +1208,8 @@ export class NupstDaemon {
// Load the new configuration
await this.loadConfig();
this.thresholdState.clear();
this.groupStatus.clear();
const newDeviceCount = this.config.upsDevices?.length || 0;
const reloadSnapshot = analyzeConfigReload(oldDeviceCount, newDeviceCount);