feat(monitoring): add edge-triggered threshold handling with group action orchestration and HA-aware Proxmox shutdowns
This commit is contained in:
+167
-11
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user