feat(actions): support unknown/unreachable duration thresholds
This commit is contained in:
@@ -3,6 +3,14 @@
|
|||||||
## Pending
|
## Pending
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- support unknown/unreachable duration thresholds (actions)
|
||||||
|
- Add optional unknownMinutes thresholds for actions when UPS status remains unknown or unreachable.
|
||||||
|
- Allow shutdown and Proxmox actions to run on configured unknown-state threshold violations.
|
||||||
|
- Expose unknown duration and threshold values to script and webhook actions.
|
||||||
|
- Update CLI and systemd output to configure and display unknown/unreachable thresholds.
|
||||||
|
|
||||||
## 2026-05-29 - 5.11.2
|
## 2026-05-29 - 5.11.2
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
|||||||
+144
-1
@@ -19,6 +19,7 @@ import {
|
|||||||
applyDefaultShutdownDelay,
|
applyDefaultShutdownDelay,
|
||||||
buildUpsActionContext,
|
buildUpsActionContext,
|
||||||
decideUpsActionExecution,
|
decideUpsActionExecution,
|
||||||
|
getUnknownStatusDurationMinutes,
|
||||||
} from '../ts/action-orchestration.ts';
|
} from '../ts/action-orchestration.ts';
|
||||||
import {
|
import {
|
||||||
buildShutdownErrorRow,
|
buildShutdownErrorRow,
|
||||||
@@ -43,6 +44,7 @@ import { MigrationV4_2ToV4_3 } from '../ts/migrations/migration-v4.2-to-v4.3.ts'
|
|||||||
import { MigrationV4_3ToV4_4 } from '../ts/migrations/migration-v4.3-to-v4.4.ts';
|
import { MigrationV4_3ToV4_4 } from '../ts/migrations/migration-v4.3-to-v4.4.ts';
|
||||||
import { ActionHandler } from '../ts/cli/action-handler.ts';
|
import { ActionHandler } from '../ts/cli/action-handler.ts';
|
||||||
import { ProxmoxAction } from '../ts/actions/proxmox-action.ts';
|
import { ProxmoxAction } from '../ts/actions/proxmox-action.ts';
|
||||||
|
import { ShutdownAction } from '../ts/actions/shutdown-action.ts';
|
||||||
import { renderUpgradeChangelog } from '../ts/upgrade-changelog.ts';
|
import { renderUpgradeChangelog } from '../ts/upgrade-changelog.ts';
|
||||||
|
|
||||||
import * as qenv from 'npm:@push.rocks/qenv@^6.0.0';
|
import * as qenv from 'npm:@push.rocks/qenv@^6.0.0';
|
||||||
@@ -302,12 +304,33 @@ Deno.test('buildUpsActionContext: includes previous power status and timestamp',
|
|||||||
batteryCapacity: 42,
|
batteryCapacity: 42,
|
||||||
batteryRuntime: 15,
|
batteryRuntime: 15,
|
||||||
previousPowerStatus: 'online',
|
previousPowerStatus: 'online',
|
||||||
|
unknownDurationMinutes: 0,
|
||||||
timestamp: 9999,
|
timestamp: 9999,
|
||||||
triggerReason: 'thresholdViolation',
|
triggerReason: 'thresholdViolation',
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test('getUnknownStatusDurationMinutes: tracks unknown and unreachable durations only', () => {
|
||||||
|
assertEquals(
|
||||||
|
getUnknownStatusDurationMinutes({
|
||||||
|
...createInitialUpsStatus({ id: 'ups-1', name: 'Main UPS' }, 1_000),
|
||||||
|
powerStatus: 'unknown',
|
||||||
|
lastStatusChange: 1_000,
|
||||||
|
}, 181_000),
|
||||||
|
3,
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
getUnknownStatusDurationMinutes({
|
||||||
|
...createInitialUpsStatus({ id: 'ups-1', name: 'Main UPS' }, 1_000),
|
||||||
|
powerStatus: 'online',
|
||||||
|
lastStatusChange: 1_000,
|
||||||
|
}, 181_000),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
Deno.test('decideUpsActionExecution: suppresses actions while paused', () => {
|
Deno.test('decideUpsActionExecution: suppresses actions while paused', () => {
|
||||||
const decision = decideUpsActionExecution(
|
const decision = decideUpsActionExecution(
|
||||||
true,
|
true,
|
||||||
@@ -368,6 +391,7 @@ Deno.test('decideUpsActionExecution: returns executable action plan when actions
|
|||||||
batteryCapacity: 55,
|
batteryCapacity: 55,
|
||||||
batteryRuntime: 18,
|
batteryRuntime: 18,
|
||||||
previousPowerStatus: 'online',
|
previousPowerStatus: 'online',
|
||||||
|
unknownDurationMinutes: 0,
|
||||||
timestamp: 9999,
|
timestamp: 9999,
|
||||||
triggerReason: 'powerStatusChange',
|
triggerReason: 'powerStatusChange',
|
||||||
},
|
},
|
||||||
@@ -571,6 +595,30 @@ Deno.test('isActionThresholdExceeded: evaluates a single action threshold on bat
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test('isActionThresholdExceeded: evaluates unknown duration threshold separately', () => {
|
||||||
|
assertEquals(
|
||||||
|
isActionThresholdExceeded(
|
||||||
|
{ type: 'shutdown', thresholds: { battery: 50, runtime: 20, unknownMinutes: 10 } },
|
||||||
|
'unreachable',
|
||||||
|
100,
|
||||||
|
60,
|
||||||
|
9.9,
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
isActionThresholdExceeded(
|
||||||
|
{ type: 'shutdown', thresholds: { battery: 50, runtime: 20, unknownMinutes: 10 } },
|
||||||
|
'unknown',
|
||||||
|
100,
|
||||||
|
60,
|
||||||
|
10,
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
Deno.test('getActionThresholdStates: returns per-action threshold state array', () => {
|
Deno.test('getActionThresholdStates: returns per-action threshold state array', () => {
|
||||||
assertEquals(
|
assertEquals(
|
||||||
getActionThresholdStates('onBattery', 25, 8, [
|
getActionThresholdStates('onBattery', 25, 8, [
|
||||||
@@ -582,6 +630,16 @@ Deno.test('getActionThresholdStates: returns per-action threshold state array',
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test('getActionThresholdStates: includes unknown duration thresholds', () => {
|
||||||
|
assertEquals(
|
||||||
|
getActionThresholdStates('unreachable', 100, 60, [
|
||||||
|
{ type: 'shutdown', thresholds: { battery: 30, runtime: 10, unknownMinutes: 15 } },
|
||||||
|
{ type: 'shutdown', thresholds: { battery: 30, runtime: 10, unknownMinutes: 20 } },
|
||||||
|
], 15),
|
||||||
|
[true, false],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
Deno.test('getEnteredThresholdIndexes: reports only newly-entered thresholds', () => {
|
Deno.test('getEnteredThresholdIndexes: reports only newly-entered thresholds', () => {
|
||||||
assertEquals(getEnteredThresholdIndexes(undefined, [false, true, true]), [1, 2]);
|
assertEquals(getEnteredThresholdIndexes(undefined, [false, true, true]), [1, 2]);
|
||||||
assertEquals(getEnteredThresholdIndexes([false, true, false], [true, true, false]), [0]);
|
assertEquals(getEnteredThresholdIndexes([false, true, false], [true, true, false]), [0]);
|
||||||
@@ -714,6 +772,30 @@ Deno.test('evaluateGroupActionThreshold: blocks destructive actions when a membe
|
|||||||
assertEquals(evaluation.blockedByUnreachable, true);
|
assertEquals(evaluation.blockedByUnreachable, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test('evaluateGroupActionThreshold: allows configured unknown duration threshold', () => {
|
||||||
|
const evaluation = evaluateGroupActionThreshold(
|
||||||
|
{ type: 'shutdown', thresholds: { battery: 50, runtime: 20, unknownMinutes: 10 } },
|
||||||
|
'nonRedundant',
|
||||||
|
[
|
||||||
|
{
|
||||||
|
...createInitialUpsStatus({ id: 'ups-1', name: 'UPS 1' }, 1000),
|
||||||
|
powerStatus: 'unreachable' as const,
|
||||||
|
lastStatusChange: 1000,
|
||||||
|
unreachableSince: 1000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...createInitialUpsStatus({ id: 'ups-2', name: 'UPS 2' }, 1000),
|
||||||
|
powerStatus: 'online' as const,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
601000,
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(evaluation.exceedsThreshold, true);
|
||||||
|
assertEquals(evaluation.blockedByUnreachable, false);
|
||||||
|
assertEquals(evaluation.exceededByUnknownDuration, true);
|
||||||
|
});
|
||||||
|
|
||||||
Deno.test('buildGroupThresholdContextStatus: uses the worst triggering member runtime', () => {
|
Deno.test('buildGroupThresholdContextStatus: uses the worst triggering member runtime', () => {
|
||||||
const status = buildGroupThresholdContextStatus(
|
const status = buildGroupThresholdContextStatus(
|
||||||
{ id: 'group-3', name: 'Group Worst' },
|
{ id: 'group-3', name: 'Group Worst' },
|
||||||
@@ -721,6 +803,7 @@ Deno.test('buildGroupThresholdContextStatus: uses the worst triggering member ru
|
|||||||
{
|
{
|
||||||
exceedsThreshold: true,
|
exceedsThreshold: true,
|
||||||
blockedByUnreachable: false,
|
blockedByUnreachable: false,
|
||||||
|
exceededByUnknownDuration: false,
|
||||||
representativeStatus: {
|
representativeStatus: {
|
||||||
...createInitialUpsStatus({ id: 'ups-1', name: 'UPS 1' }, 1000),
|
...createInitialUpsStatus({ id: 'ups-1', name: 'UPS 1' }, 1000),
|
||||||
powerStatus: 'onBattery' as const,
|
powerStatus: 'onBattery' as const,
|
||||||
@@ -731,6 +814,7 @@ Deno.test('buildGroupThresholdContextStatus: uses the worst triggering member ru
|
|||||||
{
|
{
|
||||||
exceedsThreshold: true,
|
exceedsThreshold: true,
|
||||||
blockedByUnreachable: false,
|
blockedByUnreachable: false,
|
||||||
|
exceededByUnknownDuration: false,
|
||||||
representativeStatus: {
|
representativeStatus: {
|
||||||
...createInitialUpsStatus({ id: 'ups-2', name: 'UPS 2' }, 1000),
|
...createInitialUpsStatus({ id: 'ups-2', name: 'UPS 2' }, 1000),
|
||||||
powerStatus: 'onBattery' as const,
|
powerStatus: 'onBattery' as const,
|
||||||
@@ -934,6 +1018,12 @@ class TestProxmoxAction extends ProxmoxAction {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class TestShutdownAction extends ShutdownAction {
|
||||||
|
public testShouldExecute(context: IActionContext): boolean {
|
||||||
|
return this.shouldExecute(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function createMockContext(overrides: Partial<IActionContext> = {}): IActionContext {
|
function createMockContext(overrides: Partial<IActionContext> = {}): IActionContext {
|
||||||
return {
|
return {
|
||||||
upsId: 'test-ups',
|
upsId: 'test-ups',
|
||||||
@@ -942,6 +1032,7 @@ function createMockContext(overrides: Partial<IActionContext> = {}): IActionCont
|
|||||||
batteryCapacity: 100,
|
batteryCapacity: 100,
|
||||||
batteryRuntime: 60,
|
batteryRuntime: 60,
|
||||||
previousPowerStatus: 'online',
|
previousPowerStatus: 'online',
|
||||||
|
unknownDurationMinutes: 0,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
triggerReason: 'powerStatusChange',
|
triggerReason: 'powerStatusChange',
|
||||||
...overrides,
|
...overrides,
|
||||||
@@ -1120,6 +1211,58 @@ Deno.test('ProxmoxAction.shouldExecute: skips thresholds unless UPS is confirmed
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test('ProxmoxAction.shouldExecute: allows configured unknown duration threshold', () => {
|
||||||
|
const action = new TestProxmoxAction({
|
||||||
|
type: 'proxmox',
|
||||||
|
triggerMode: 'onlyThresholds',
|
||||||
|
thresholds: { battery: 50, runtime: 20, unknownMinutes: 10 },
|
||||||
|
});
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
action.testShouldExecute(createMockContext({
|
||||||
|
powerStatus: 'unreachable',
|
||||||
|
triggerReason: 'thresholdViolation',
|
||||||
|
unknownDurationMinutes: 9.9,
|
||||||
|
})),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
action.testShouldExecute(createMockContext({
|
||||||
|
powerStatus: 'unreachable',
|
||||||
|
triggerReason: 'thresholdViolation',
|
||||||
|
unknownDurationMinutes: 10,
|
||||||
|
})),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('ShutdownAction.shouldExecute: allows configured unknown duration threshold', () => {
|
||||||
|
const action = new TestShutdownAction({
|
||||||
|
type: 'shutdown',
|
||||||
|
triggerMode: 'onlyThresholds',
|
||||||
|
thresholds: { battery: 50, runtime: 20, unknownMinutes: 10 },
|
||||||
|
});
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
action.testShouldExecute(createMockContext({
|
||||||
|
powerStatus: 'unknown',
|
||||||
|
triggerReason: 'thresholdViolation',
|
||||||
|
unknownDurationMinutes: 10,
|
||||||
|
})),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
action.testShouldExecute(createMockContext({
|
||||||
|
powerStatus: 'unknown',
|
||||||
|
triggerReason: 'powerStatusChange',
|
||||||
|
unknownDurationMinutes: 10,
|
||||||
|
})),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
Deno.test('ProxmoxAction.shouldExecute: allows confirmed battery events', () => {
|
Deno.test('ProxmoxAction.shouldExecute: allows confirmed battery events', () => {
|
||||||
const powerChangeAction = new TestProxmoxAction({ type: 'proxmox' });
|
const powerChangeAction = new TestProxmoxAction({ type: 'proxmox' });
|
||||||
const thresholdAction = new TestProxmoxAction({
|
const thresholdAction = new TestProxmoxAction({
|
||||||
@@ -1193,7 +1336,7 @@ Deno.test('ActionHandler.runEditProcess: updates an existing shutdown action', a
|
|||||||
} as unknown as ConstructorParameters<typeof ActionHandler>[0];
|
} as unknown as ConstructorParameters<typeof ActionHandler>[0];
|
||||||
|
|
||||||
const handler = new ActionHandler(nupstMock);
|
const handler = new ActionHandler(nupstMock);
|
||||||
const answers = ['', '12', '25', '8', '3'];
|
const answers = ['', '12', '25', '8', '', '3'];
|
||||||
let answerIndex = 0;
|
let answerIndex = 0;
|
||||||
const prompt = async (_question: string): Promise<string> => answers[answerIndex++] ?? '';
|
const prompt = async (_question: string): Promise<string> => answers[answerIndex++] ?? '';
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,14 @@ export type TActionExecutionDecision =
|
|||||||
| { type: 'skip' }
|
| { type: 'skip' }
|
||||||
| { type: 'execute'; actions: IActionConfig[]; context: IActionContext };
|
| { type: 'execute'; actions: IActionConfig[]; context: IActionContext };
|
||||||
|
|
||||||
|
export function getUnknownStatusDurationMinutes(status: IUpsStatus, timestamp: number): number {
|
||||||
|
if (status.powerStatus !== 'unknown' && status.powerStatus !== 'unreachable') {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.max(0, (timestamp - status.lastStatusChange) / 60_000);
|
||||||
|
}
|
||||||
|
|
||||||
export function buildUpsActionContext(
|
export function buildUpsActionContext(
|
||||||
ups: IUpsActionSource,
|
ups: IUpsActionSource,
|
||||||
status: IUpsStatus,
|
status: IUpsStatus,
|
||||||
@@ -29,6 +37,7 @@ export function buildUpsActionContext(
|
|||||||
batteryCapacity: status.batteryCapacity,
|
batteryCapacity: status.batteryCapacity,
|
||||||
batteryRuntime: status.batteryRuntime,
|
batteryRuntime: status.batteryRuntime,
|
||||||
previousPowerStatus: (previousStatus?.powerStatus || 'unknown') as TPowerStatus,
|
previousPowerStatus: (previousStatus?.powerStatus || 'unknown') as TPowerStatus,
|
||||||
|
unknownDurationMinutes: getUnknownStatusDurationMinutes(status, timestamp),
|
||||||
timestamp,
|
timestamp,
|
||||||
triggerReason,
|
triggerReason,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
*
|
*
|
||||||
* Actions are triggered on:
|
* Actions are triggered on:
|
||||||
* 1. Power status changes (online ↔ onBattery)
|
* 1. Power status changes (online ↔ onBattery)
|
||||||
* 2. Threshold violations (battery/runtime cross below configured thresholds)
|
* 2. Threshold violations (battery/runtime or unknown-state duration cross configured thresholds)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type TPowerStatus = 'online' | 'onBattery' | 'unknown' | 'unreachable';
|
export type TPowerStatus = 'online' | 'onBattery' | 'unknown' | 'unreachable';
|
||||||
@@ -30,6 +30,8 @@ export interface IActionContext {
|
|||||||
// State tracking
|
// State tracking
|
||||||
/** Previous power status before this trigger */
|
/** Previous power status before this trigger */
|
||||||
previousPowerStatus: TPowerStatus;
|
previousPowerStatus: TPowerStatus;
|
||||||
|
/** Minutes the UPS has continuously been unknown/unreachable. Zero for known states. */
|
||||||
|
unknownDurationMinutes: number;
|
||||||
|
|
||||||
// Metadata
|
// Metadata
|
||||||
/** Timestamp when this action was triggered (milliseconds since epoch) */
|
/** Timestamp when this action was triggered (milliseconds since epoch) */
|
||||||
@@ -71,6 +73,8 @@ export interface IActionConfig {
|
|||||||
battery: number;
|
battery: number;
|
||||||
/** Runtime threshold in minutes */
|
/** Runtime threshold in minutes */
|
||||||
runtime: number;
|
runtime: number;
|
||||||
|
/** Optional fail-safe threshold for unknown/unreachable status duration in minutes */
|
||||||
|
unknownMinutes?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Shutdown action configuration
|
// Shutdown action configuration
|
||||||
@@ -158,13 +162,13 @@ export abstract class Action {
|
|||||||
case 'onlyThresholds':
|
case 'onlyThresholds':
|
||||||
// Only execute when this action's thresholds are exceeded
|
// Only execute when this action's thresholds are exceeded
|
||||||
if (!this.config.thresholds) return false; // No thresholds = never execute
|
if (!this.config.thresholds) return false; // No thresholds = never execute
|
||||||
return this.areThresholdsExceeded(context.batteryCapacity, context.batteryRuntime);
|
return this.areThresholdsExceeded(context.batteryCapacity, context.batteryRuntime, context);
|
||||||
|
|
||||||
case 'powerChangesAndThresholds':
|
case 'powerChangesAndThresholds':
|
||||||
// Execute on power changes OR when thresholds exceeded
|
// Execute on power changes OR when thresholds exceeded
|
||||||
if (context.triggerReason === 'powerStatusChange') return true;
|
if (context.triggerReason === 'powerStatusChange') return true;
|
||||||
if (!this.config.thresholds) return false;
|
if (!this.config.thresholds) return false;
|
||||||
return this.areThresholdsExceeded(context.batteryCapacity, context.batteryRuntime);
|
return this.areThresholdsExceeded(context.batteryCapacity, context.batteryRuntime, context);
|
||||||
|
|
||||||
case 'anyChange':
|
case 'anyChange':
|
||||||
// Execute on every trigger (power change or threshold check)
|
// Execute on every trigger (power change or threshold check)
|
||||||
@@ -176,19 +180,43 @@ export abstract class Action {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if current battery/runtime exceeds this action's thresholds
|
* Check if current UPS state exceeds this action's thresholds
|
||||||
* @param batteryCapacity Current battery percentage
|
* @param batteryCapacity Current battery percentage
|
||||||
* @param batteryRuntime Current runtime in minutes
|
* @param batteryRuntime Current runtime in minutes
|
||||||
* @returns True if thresholds are exceeded
|
* @returns True if thresholds are exceeded
|
||||||
*/
|
*/
|
||||||
protected areThresholdsExceeded(batteryCapacity: number, batteryRuntime: number): boolean {
|
protected areThresholdsExceeded(
|
||||||
|
batteryCapacity: number,
|
||||||
|
batteryRuntime: number,
|
||||||
|
context?: IActionContext,
|
||||||
|
): boolean {
|
||||||
if (!this.config.thresholds) {
|
if (!this.config.thresholds) {
|
||||||
return false; // No thresholds configured
|
return false; // No thresholds configured
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (context && this.isUnknownThresholdExceeded(context)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
batteryCapacity < this.config.thresholds.battery ||
|
batteryCapacity < this.config.thresholds.battery ||
|
||||||
batteryRuntime < this.config.thresholds.runtime
|
batteryRuntime < this.config.thresholds.runtime
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected isUnknownThresholdExceeded(context: IActionContext): boolean {
|
||||||
|
const unknownMinutes = this.config.thresholds?.unknownMinutes;
|
||||||
|
if (
|
||||||
|
typeof unknownMinutes !== 'number' ||
|
||||||
|
!Number.isFinite(unknownMinutes) ||
|
||||||
|
unknownMinutes < 0
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
(context.powerStatus === 'unknown' || context.powerStatus === 'unreachable') &&
|
||||||
|
context.unknownDurationMinutes >= unknownMinutes
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,19 @@ export class ProxmoxAction extends Action {
|
|||||||
const mode = this.config.triggerMode || 'powerChangesAndThresholds';
|
const mode = this.config.triggerMode || 'powerChangesAndThresholds';
|
||||||
|
|
||||||
if (context.powerStatus !== 'onBattery') {
|
if (context.powerStatus !== 'onBattery') {
|
||||||
|
if (
|
||||||
|
context.triggerReason === 'thresholdViolation' &&
|
||||||
|
mode !== 'onlyPowerChanges' &&
|
||||||
|
this.isUnknownThresholdExceeded(context)
|
||||||
|
) {
|
||||||
|
logger.warn(
|
||||||
|
`Proxmox action triggered: UPS ${context.powerStatus} for ${
|
||||||
|
context.unknownDurationMinutes.toFixed(1)
|
||||||
|
} minutes`,
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (context.powerStatus === 'unreachable') {
|
if (context.powerStatus === 'unreachable') {
|
||||||
logger.info(
|
logger.info(
|
||||||
'Proxmox action skipped: UPS is unreachable (communication failure, actual state unknown)',
|
'Proxmox action skipped: UPS is unreachable (communication failure, actual state unknown)',
|
||||||
@@ -52,7 +65,7 @@ export class ProxmoxAction extends Action {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.areThresholdsExceeded(context.batteryCapacity, context.batteryRuntime);
|
return this.areThresholdsExceeded(context.batteryCapacity, context.batteryRuntime, context);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (context.triggerReason === 'powerStatusChange') {
|
if (context.triggerReason === 'powerStatusChange') {
|
||||||
|
|||||||
@@ -112,11 +112,15 @@ export class ScriptAction extends Action {
|
|||||||
NUPST_POWER_STATUS: context.powerStatus,
|
NUPST_POWER_STATUS: context.powerStatus,
|
||||||
NUPST_BATTERY_CAPACITY: String(context.batteryCapacity),
|
NUPST_BATTERY_CAPACITY: String(context.batteryCapacity),
|
||||||
NUPST_BATTERY_RUNTIME: String(context.batteryRuntime),
|
NUPST_BATTERY_RUNTIME: String(context.batteryRuntime),
|
||||||
|
NUPST_UNKNOWN_DURATION_MINUTES: String(context.unknownDurationMinutes),
|
||||||
NUPST_TRIGGER_REASON: context.triggerReason,
|
NUPST_TRIGGER_REASON: context.triggerReason,
|
||||||
NUPST_TIMESTAMP: String(context.timestamp),
|
NUPST_TIMESTAMP: String(context.timestamp),
|
||||||
// Include action's own thresholds if configured
|
// Include action's own thresholds if configured
|
||||||
NUPST_BATTERY_THRESHOLD: this.config.thresholds ? String(this.config.thresholds.battery) : '',
|
NUPST_BATTERY_THRESHOLD: this.config.thresholds ? String(this.config.thresholds.battery) : '',
|
||||||
NUPST_RUNTIME_THRESHOLD: this.config.thresholds ? String(this.config.thresholds.runtime) : '',
|
NUPST_RUNTIME_THRESHOLD: this.config.thresholds ? String(this.config.thresholds.runtime) : '',
|
||||||
|
NUPST_UNKNOWN_THRESHOLD_MINUTES: this.config.thresholds?.unknownMinutes !== undefined
|
||||||
|
? String(this.config.thresholds.unknownMinutes)
|
||||||
|
: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build command with arguments
|
// Build command with arguments
|
||||||
|
|||||||
@@ -35,8 +35,21 @@ export class ShutdownAction extends Action {
|
|||||||
|
|
||||||
// CRITICAL SAFETY CHECK: Shutdown should NEVER trigger unless UPS is on battery
|
// CRITICAL SAFETY CHECK: Shutdown should NEVER trigger unless UPS is on battery
|
||||||
// A low battery while on grid power is not an emergency (the battery is charging)
|
// A low battery while on grid power is not an emergency (the battery is charging)
|
||||||
// When UPS is unreachable, we don't know the actual state - don't trigger false shutdown
|
// Unknown/unreachable status can only trigger after an explicit unknownMinutes threshold.
|
||||||
if (context.powerStatus !== 'onBattery') {
|
if (context.powerStatus !== 'onBattery') {
|
||||||
|
if (
|
||||||
|
context.triggerReason === 'thresholdViolation' &&
|
||||||
|
mode !== 'onlyPowerChanges' &&
|
||||||
|
this.isUnknownThresholdExceeded(context)
|
||||||
|
) {
|
||||||
|
logger.warn(
|
||||||
|
`Shutdown action triggered: UPS ${context.powerStatus} for ${
|
||||||
|
context.unknownDurationMinutes.toFixed(1)
|
||||||
|
} minutes`,
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (context.powerStatus === 'unreachable') {
|
if (context.powerStatus === 'unreachable') {
|
||||||
logger.info(
|
logger.info(
|
||||||
`Shutdown action skipped: UPS is unreachable (communication failure, actual state unknown)`,
|
`Shutdown action skipped: UPS is unreachable (communication failure, actual state unknown)`,
|
||||||
@@ -57,7 +70,7 @@ export class ShutdownAction extends Action {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// Check if thresholds are actually exceeded
|
// Check if thresholds are actually exceeded
|
||||||
return this.areThresholdsExceeded(context.batteryCapacity, context.batteryRuntime);
|
return this.areThresholdsExceeded(context.batteryCapacity, context.batteryRuntime, context);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle power status changes
|
// Handle power status changes
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ export interface IWebhookPayload {
|
|||||||
batteryCapacity: number;
|
batteryCapacity: number;
|
||||||
/** Current battery runtime in minutes */
|
/** Current battery runtime in minutes */
|
||||||
batteryRuntime: number;
|
batteryRuntime: number;
|
||||||
|
/** Minutes the UPS has continuously been unknown/unreachable */
|
||||||
|
unknownDurationMinutes: number;
|
||||||
/** Reason this webhook was triggered */
|
/** Reason this webhook was triggered */
|
||||||
triggerReason: 'powerStatusChange' | 'thresholdViolation';
|
triggerReason: 'powerStatusChange' | 'thresholdViolation';
|
||||||
/** Timestamp when webhook was triggered */
|
/** Timestamp when webhook was triggered */
|
||||||
@@ -27,6 +29,7 @@ export interface IWebhookPayload {
|
|||||||
thresholds?: {
|
thresholds?: {
|
||||||
battery: number;
|
battery: number;
|
||||||
runtime: number;
|
runtime: number;
|
||||||
|
unknownMinutes?: number;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,6 +95,7 @@ export class WebhookAction extends Action {
|
|||||||
powerStatus: context.powerStatus,
|
powerStatus: context.powerStatus,
|
||||||
batteryCapacity: context.batteryCapacity,
|
batteryCapacity: context.batteryCapacity,
|
||||||
batteryRuntime: context.batteryRuntime,
|
batteryRuntime: context.batteryRuntime,
|
||||||
|
unknownDurationMinutes: context.unknownDurationMinutes,
|
||||||
triggerReason: context.triggerReason,
|
triggerReason: context.triggerReason,
|
||||||
timestamp: context.timestamp,
|
timestamp: context.timestamp,
|
||||||
};
|
};
|
||||||
@@ -101,6 +105,9 @@ export class WebhookAction extends Action {
|
|||||||
payload.thresholds = {
|
payload.thresholds = {
|
||||||
battery: this.config.thresholds.battery,
|
battery: this.config.thresholds.battery,
|
||||||
runtime: this.config.thresholds.runtime,
|
runtime: this.config.thresholds.runtime,
|
||||||
|
...(this.config.thresholds.unknownMinutes !== undefined
|
||||||
|
? { unknownMinutes: this.config.thresholds.unknownMinutes }
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,6 +120,7 @@ export class WebhookAction extends Action {
|
|||||||
url.searchParams.append('powerStatus', payload.powerStatus);
|
url.searchParams.append('powerStatus', payload.powerStatus);
|
||||||
url.searchParams.append('batteryCapacity', String(payload.batteryCapacity));
|
url.searchParams.append('batteryCapacity', String(payload.batteryCapacity));
|
||||||
url.searchParams.append('batteryRuntime', String(payload.batteryRuntime));
|
url.searchParams.append('batteryRuntime', String(payload.batteryRuntime));
|
||||||
|
url.searchParams.append('unknownDurationMinutes', String(context.unknownDurationMinutes));
|
||||||
|
|
||||||
url.searchParams.append('triggerReason', payload.triggerReason);
|
url.searchParams.append('triggerReason', payload.triggerReason);
|
||||||
url.searchParams.append('timestamp', String(payload.timestamp));
|
url.searchParams.append('timestamp', String(payload.timestamp));
|
||||||
|
|||||||
@@ -242,10 +242,13 @@ export class ActionHandler {
|
|||||||
logger.success(`Action removed from ${targetType} ${targetName}`);
|
logger.success(`Action removed from ${targetType} ${targetName}`);
|
||||||
logger.log(` ${theme.dim('Type:')} ${removedAction.type}`);
|
logger.log(` ${theme.dim('Type:')} ${removedAction.type}`);
|
||||||
if (removedAction.thresholds) {
|
if (removedAction.thresholds) {
|
||||||
|
const unknownThreshold = removedAction.thresholds.unknownMinutes !== undefined
|
||||||
|
? `, Unknown: ${removedAction.thresholds.unknownMinutes}min`
|
||||||
|
: '';
|
||||||
logger.log(
|
logger.log(
|
||||||
` ${
|
` ${
|
||||||
theme.dim('Thresholds:')
|
theme.dim('Thresholds:')
|
||||||
} Battery: ${removedAction.thresholds.battery}%, Runtime: ${removedAction.thresholds.runtime}min`,
|
} Battery: ${removedAction.thresholds.battery}%, Runtime: ${removedAction.thresholds.runtime}min${unknownThreshold}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
logger.log(` ${theme.dim('Changes saved and will be applied automatically')}`);
|
logger.log(` ${theme.dim('Changes saved and will be applied automatically')}`);
|
||||||
@@ -703,7 +706,31 @@ export class ActionHandler {
|
|||||||
logger.error('Invalid runtime threshold. Must be >= 0.');
|
logger.error('Invalid runtime threshold. Must be >= 0.');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
newAction.thresholds = { battery, runtime };
|
const thresholds: NonNullable<IActionConfig['thresholds']> = { battery, runtime };
|
||||||
|
|
||||||
|
const currentUnknownThreshold = existingAction?.thresholds?.unknownMinutes;
|
||||||
|
const unknownPrompt = currentUnknownThreshold !== undefined
|
||||||
|
? ` ${theme.dim('Unknown/unreachable threshold')} ${
|
||||||
|
theme.dim(`(minutes, 'clear' = disabled) [${currentUnknownThreshold}]:`)
|
||||||
|
} `
|
||||||
|
: ` ${theme.dim('Unknown/unreachable threshold')} ${
|
||||||
|
theme.dim('(minutes, empty = disabled):')
|
||||||
|
} `;
|
||||||
|
const unknownInput = await prompt(unknownPrompt);
|
||||||
|
if (this.isClearInput(unknownInput)) {
|
||||||
|
// Leave disabled.
|
||||||
|
} else if (unknownInput.trim()) {
|
||||||
|
const unknownMinutes = parseInt(unknownInput, 10);
|
||||||
|
if (isNaN(unknownMinutes) || unknownMinutes < 0) {
|
||||||
|
logger.error('Invalid unknown/unreachable threshold. Must be >= 0.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
thresholds.unknownMinutes = unknownMinutes;
|
||||||
|
} else if (currentUnknownThreshold !== undefined) {
|
||||||
|
thresholds.unknownMinutes = currentUnknownThreshold;
|
||||||
|
}
|
||||||
|
|
||||||
|
newAction.thresholds = thresholds;
|
||||||
|
|
||||||
logger.log('');
|
logger.log('');
|
||||||
logger.log(` ${theme.dim('Trigger mode:')}`);
|
logger.log(` ${theme.dim('Trigger mode:')}`);
|
||||||
@@ -758,6 +785,7 @@ export class ActionHandler {
|
|||||||
{ header: 'Type', key: 'type', align: 'left' },
|
{ header: 'Type', key: 'type', align: 'left' },
|
||||||
{ header: 'Battery', key: 'battery', align: 'right' },
|
{ header: 'Battery', key: 'battery', align: 'right' },
|
||||||
{ header: 'Runtime', key: 'runtime', align: 'right' },
|
{ header: 'Runtime', key: 'runtime', align: 'right' },
|
||||||
|
{ header: 'Unknown', key: 'unknown', align: 'right' },
|
||||||
{ header: 'Trigger Mode', key: 'triggerMode', align: 'left' },
|
{ header: 'Trigger Mode', key: 'triggerMode', align: 'left' },
|
||||||
{ header: 'Details', key: 'details', align: 'left' },
|
{ header: 'Details', key: 'details', align: 'left' },
|
||||||
];
|
];
|
||||||
@@ -792,6 +820,9 @@ export class ActionHandler {
|
|||||||
type: theme.highlight(action.type),
|
type: theme.highlight(action.type),
|
||||||
battery: action.thresholds ? `${action.thresholds.battery}%` : theme.dim('N/A'),
|
battery: action.thresholds ? `${action.thresholds.battery}%` : theme.dim('N/A'),
|
||||||
runtime: action.thresholds ? `${action.thresholds.runtime}min` : theme.dim('N/A'),
|
runtime: action.thresholds ? `${action.thresholds.runtime}min` : theme.dim('N/A'),
|
||||||
|
unknown: action.thresholds?.unknownMinutes !== undefined
|
||||||
|
? `${action.thresholds.unknownMinutes}min`
|
||||||
|
: theme.dim('off'),
|
||||||
triggerMode: theme.dim(action.triggerMode || 'onlyThresholds'),
|
triggerMode: theme.dim(action.triggerMode || 'onlyThresholds'),
|
||||||
details,
|
details,
|
||||||
};
|
};
|
||||||
|
|||||||
+12
-1
@@ -1318,7 +1318,10 @@ export class UpsHandler {
|
|||||||
logger.log('');
|
logger.log('');
|
||||||
logger.info('Action Thresholds:');
|
logger.info('Action Thresholds:');
|
||||||
logger.dim(
|
logger.dim(
|
||||||
'Action will trigger when battery or runtime falls below these values (while on battery)',
|
'Action will trigger when battery/runtime falls below these values while on battery.',
|
||||||
|
);
|
||||||
|
logger.dim(
|
||||||
|
'Optionally, it can also trigger after the UPS has been unknown/unreachable for N minutes.',
|
||||||
);
|
);
|
||||||
|
|
||||||
const batteryInput = await prompt('Battery threshold percentage [60]: ');
|
const batteryInput = await prompt('Battery threshold percentage [60]: ');
|
||||||
@@ -1329,10 +1332,18 @@ export class UpsHandler {
|
|||||||
const runtime = parseInt(runtimeInput, 10);
|
const runtime = parseInt(runtimeInput, 10);
|
||||||
const runtimeThreshold = (runtimeInput.trim() && !isNaN(runtime)) ? runtime : 20;
|
const runtimeThreshold = (runtimeInput.trim() && !isNaN(runtime)) ? runtime : 20;
|
||||||
|
|
||||||
|
const unknownInput = await prompt(
|
||||||
|
'Unknown/unreachable threshold in minutes (empty = disabled): ',
|
||||||
|
);
|
||||||
|
const unknownMinutes = parseInt(unknownInput, 10);
|
||||||
|
|
||||||
action.thresholds = {
|
action.thresholds = {
|
||||||
battery: batteryThreshold,
|
battery: batteryThreshold,
|
||||||
runtime: runtimeThreshold,
|
runtime: runtimeThreshold,
|
||||||
};
|
};
|
||||||
|
if (unknownInput.trim() && !isNaN(unknownMinutes) && unknownMinutes >= 0) {
|
||||||
|
action.thresholds.unknownMinutes = unknownMinutes;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
actions.push(action as IActionConfig);
|
actions.push(action as IActionConfig);
|
||||||
|
|||||||
+28
-4
@@ -38,6 +38,7 @@ import {
|
|||||||
ensureUpsStatus,
|
ensureUpsStatus,
|
||||||
getActionThresholdStates,
|
getActionThresholdStates,
|
||||||
getEnteredThresholdIndexes,
|
getEnteredThresholdIndexes,
|
||||||
|
getUnknownStatusDurationMinutes,
|
||||||
} from './ups-monitoring.ts';
|
} from './ups-monitoring.ts';
|
||||||
import {
|
import {
|
||||||
buildShutdownErrorRow,
|
buildShutdownErrorRow,
|
||||||
@@ -653,10 +654,11 @@ export class NupstDaemon {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const thresholdStates = getActionThresholdStates(
|
const thresholdStates = getActionThresholdStates(
|
||||||
status.powerStatus,
|
pollSnapshot.updatedStatus.powerStatus,
|
||||||
status.batteryCapacity,
|
pollSnapshot.updatedStatus.batteryCapacity,
|
||||||
status.batteryRuntime,
|
pollSnapshot.updatedStatus.batteryRuntime,
|
||||||
ups.actions,
|
ups.actions,
|
||||||
|
getUnknownStatusDurationMinutes(pollSnapshot.updatedStatus, currentTime),
|
||||||
);
|
);
|
||||||
const enteredThresholdIndexes = this.trackEnteredThresholdIndexes(
|
const enteredThresholdIndexes = this.trackEnteredThresholdIndexes(
|
||||||
`ups:${ups.id}`,
|
`ups:${ups.id}`,
|
||||||
@@ -706,6 +708,28 @@ export class NupstDaemon {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const thresholdStates = getActionThresholdStates(
|
||||||
|
failureSnapshot.updatedStatus.powerStatus,
|
||||||
|
failureSnapshot.updatedStatus.batteryCapacity,
|
||||||
|
failureSnapshot.updatedStatus.batteryRuntime,
|
||||||
|
ups.actions,
|
||||||
|
getUnknownStatusDurationMinutes(failureSnapshot.updatedStatus, currentTime),
|
||||||
|
);
|
||||||
|
const enteredThresholdIndexes = this.trackEnteredThresholdIndexes(
|
||||||
|
`ups:${ups.id}`,
|
||||||
|
thresholdStates,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (enteredThresholdIndexes.length > 0) {
|
||||||
|
await this.triggerUpsActions(
|
||||||
|
ups,
|
||||||
|
failureSnapshot.updatedStatus,
|
||||||
|
failureSnapshot.previousStatus,
|
||||||
|
'thresholdViolation',
|
||||||
|
enteredThresholdIndexes,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
this.upsStatus.set(ups.id, failureSnapshot.updatedStatus);
|
this.upsStatus.set(ups.id, failureSnapshot.updatedStatus);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -769,7 +793,7 @@ export class NupstDaemon {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const thresholdEvaluations = (group.actions || []).map((action) =>
|
const thresholdEvaluations = (group.actions || []).map((action) =>
|
||||||
evaluateGroupActionThreshold(action, group.mode, memberStatuses)
|
evaluateGroupActionThreshold(action, group.mode, memberStatuses, currentTime)
|
||||||
);
|
);
|
||||||
const thresholdStates = thresholdEvaluations.map((evaluation) =>
|
const thresholdStates = thresholdEvaluations.map((evaluation) =>
|
||||||
evaluation.exceedsThreshold && !evaluation.blockedByUnreachable
|
evaluation.exceedsThreshold && !evaluation.blockedByUnreachable
|
||||||
|
|||||||
+29
-4
@@ -1,4 +1,5 @@
|
|||||||
import type { IActionConfig, TPowerStatus } from './actions/base-action.ts';
|
import type { IActionConfig, TPowerStatus } from './actions/base-action.ts';
|
||||||
|
import { getUnknownStatusDurationMinutes, isActionThresholdExceeded } from './ups-monitoring.ts';
|
||||||
import { createInitialUpsStatus, type IUpsIdentity, type IUpsStatus } from './ups-status.ts';
|
import { createInitialUpsStatus, type IUpsIdentity, type IUpsStatus } from './ups-status.ts';
|
||||||
|
|
||||||
export interface IGroupStatusSnapshot {
|
export interface IGroupStatusSnapshot {
|
||||||
@@ -10,6 +11,7 @@ export interface IGroupStatusSnapshot {
|
|||||||
export interface IGroupThresholdEvaluation {
|
export interface IGroupThresholdEvaluation {
|
||||||
exceedsThreshold: boolean;
|
exceedsThreshold: boolean;
|
||||||
blockedByUnreachable: boolean;
|
blockedByUnreachable: boolean;
|
||||||
|
exceededByUnknownDuration: boolean;
|
||||||
representativeStatus?: IUpsStatus;
|
representativeStatus?: IUpsStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,11 +145,13 @@ export function evaluateGroupActionThreshold(
|
|||||||
actionConfig: IActionConfig,
|
actionConfig: IActionConfig,
|
||||||
mode: 'redundant' | 'nonRedundant',
|
mode: 'redundant' | 'nonRedundant',
|
||||||
memberStatuses: IUpsStatus[],
|
memberStatuses: IUpsStatus[],
|
||||||
|
currentTime: number = Date.now(),
|
||||||
): IGroupThresholdEvaluation {
|
): IGroupThresholdEvaluation {
|
||||||
if (!actionConfig.thresholds || memberStatuses.length === 0) {
|
if (!actionConfig.thresholds || memberStatuses.length === 0) {
|
||||||
return {
|
return {
|
||||||
exceedsThreshold: false,
|
exceedsThreshold: false,
|
||||||
blockedByUnreachable: false,
|
blockedByUnreachable: false,
|
||||||
|
exceededByUnknownDuration: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,16 +160,32 @@ export function evaluateGroupActionThreshold(
|
|||||||
(status.batteryCapacity < actionConfig.thresholds!.battery ||
|
(status.batteryCapacity < actionConfig.thresholds!.battery ||
|
||||||
status.batteryRuntime < actionConfig.thresholds!.runtime)
|
status.batteryRuntime < actionConfig.thresholds!.runtime)
|
||||||
);
|
);
|
||||||
const exceedsThreshold = mode === 'redundant'
|
|
||||||
|
const unknownMembers = memberStatuses.filter((status) =>
|
||||||
|
isActionThresholdExceeded(
|
||||||
|
actionConfig,
|
||||||
|
status.powerStatus,
|
||||||
|
status.batteryCapacity,
|
||||||
|
status.batteryRuntime,
|
||||||
|
getUnknownStatusDurationMinutes(status, currentTime),
|
||||||
|
) && (status.powerStatus === 'unknown' || status.powerStatus === 'unreachable')
|
||||||
|
);
|
||||||
|
|
||||||
|
const exceedsBatteryThreshold = mode === 'redundant'
|
||||||
? criticalMembers.length === memberStatuses.length
|
? criticalMembers.length === memberStatuses.length
|
||||||
: criticalMembers.length > 0;
|
: criticalMembers.length > 0;
|
||||||
|
const exceededByUnknownDuration = mode === 'redundant'
|
||||||
|
? unknownMembers.length === memberStatuses.length
|
||||||
|
: unknownMembers.length > 0;
|
||||||
|
const exceedsThreshold = exceedsBatteryThreshold || exceededByUnknownDuration;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
exceedsThreshold,
|
exceedsThreshold,
|
||||||
blockedByUnreachable: exceedsThreshold &&
|
blockedByUnreachable: exceedsBatteryThreshold && !exceededByUnknownDuration &&
|
||||||
destructiveActionTypes.has(actionConfig.type) &&
|
destructiveActionTypes.has(actionConfig.type) &&
|
||||||
memberStatuses.some((status) => status.powerStatus === 'unreachable'),
|
memberStatuses.some((status) => status.powerStatus === 'unreachable'),
|
||||||
representativeStatus: selectWorstStatus(criticalMembers),
|
exceededByUnknownDuration,
|
||||||
|
representativeStatus: selectWorstStatus([...criticalMembers, ...unknownMembers]),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,18 +201,23 @@ export function buildGroupThresholdContextStatus(
|
|||||||
.filter((status): status is IUpsStatus => !!status);
|
.filter((status): status is IUpsStatus => !!status);
|
||||||
|
|
||||||
const representative = selectWorstStatus(representativeStatuses) || fallbackStatus;
|
const representative = selectWorstStatus(representativeStatuses) || fallbackStatus;
|
||||||
|
const powerStatus = representative.powerStatus === 'unknown' ||
|
||||||
|
representative.powerStatus === 'unreachable'
|
||||||
|
? representative.powerStatus
|
||||||
|
: 'onBattery';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...fallbackStatus,
|
...fallbackStatus,
|
||||||
id: group.id,
|
id: group.id,
|
||||||
name: group.name,
|
name: group.name,
|
||||||
powerStatus: 'onBattery',
|
powerStatus,
|
||||||
batteryCapacity: representative.batteryCapacity,
|
batteryCapacity: representative.batteryCapacity,
|
||||||
batteryRuntime: representative.batteryRuntime,
|
batteryRuntime: representative.batteryRuntime,
|
||||||
outputLoad: representative.outputLoad,
|
outputLoad: representative.outputLoad,
|
||||||
outputPower: representative.outputPower,
|
outputPower: representative.outputPower,
|
||||||
outputVoltage: representative.outputVoltage,
|
outputVoltage: representative.outputVoltage,
|
||||||
outputCurrent: representative.outputCurrent,
|
outputCurrent: representative.outputCurrent,
|
||||||
|
lastStatusChange: representative.lastStatusChange,
|
||||||
lastCheckTime: currentTime,
|
lastCheckTime: currentTime,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -512,6 +512,9 @@ WantedBy=multi-user.target
|
|||||||
actionDesc += ` (${
|
actionDesc += ` (${
|
||||||
action.triggerMode || 'onlyThresholds'
|
action.triggerMode || 'onlyThresholds'
|
||||||
}: battery<${action.thresholds.battery}%, runtime<${action.thresholds.runtime}min`;
|
}: battery<${action.thresholds.battery}%, runtime<${action.thresholds.runtime}min`;
|
||||||
|
if (action.thresholds.unknownMinutes !== undefined) {
|
||||||
|
actionDesc += `, unknown>${action.thresholds.unknownMinutes}min`;
|
||||||
|
}
|
||||||
if (action.type === 'shutdown') {
|
if (action.type === 'shutdown') {
|
||||||
const shutdownDelay = action.shutdownDelay ?? defaultShutdownDelay;
|
const shutdownDelay = action.shutdownDelay ?? defaultShutdownDelay;
|
||||||
actionDesc += `, delay=${shutdownDelay}min`;
|
actionDesc += `, delay=${shutdownDelay}min`;
|
||||||
@@ -599,6 +602,9 @@ WantedBy=multi-user.target
|
|||||||
actionDesc += ` (${
|
actionDesc += ` (${
|
||||||
action.triggerMode || 'onlyThresholds'
|
action.triggerMode || 'onlyThresholds'
|
||||||
}: battery<${action.thresholds.battery}%, runtime<${action.thresholds.runtime}min`;
|
}: battery<${action.thresholds.battery}%, runtime<${action.thresholds.runtime}min`;
|
||||||
|
if (action.thresholds.unknownMinutes !== undefined) {
|
||||||
|
actionDesc += `, unknown>${action.thresholds.unknownMinutes}min`;
|
||||||
|
}
|
||||||
if (action.type === 'shutdown') {
|
if (action.type === 'shutdown') {
|
||||||
const shutdownDelay = action.shutdownDelay ?? defaultShutdownDelay;
|
const shutdownDelay = action.shutdownDelay ?? defaultShutdownDelay;
|
||||||
actionDesc += `, delay=${shutdownDelay}min`;
|
actionDesc += `, delay=${shutdownDelay}min`;
|
||||||
|
|||||||
+35
-3
@@ -1,4 +1,5 @@
|
|||||||
import type { IActionConfig } from './actions/base-action.ts';
|
import type { IActionConfig } from './actions/base-action.ts';
|
||||||
|
import { getUnknownStatusDurationMinutes } from './action-orchestration.ts';
|
||||||
import { NETWORK } from './constants.ts';
|
import { NETWORK } from './constants.ts';
|
||||||
import type { IUpsStatus as IProtocolUpsStatus } from './snmp/types.ts';
|
import type { IUpsStatus as IProtocolUpsStatus } from './snmp/types.ts';
|
||||||
import { createInitialUpsStatus, type IUpsIdentity, type IUpsStatus } from './ups-status.ts';
|
import { createInitialUpsStatus, type IUpsIdentity, type IUpsStatus } from './ups-status.ts';
|
||||||
@@ -119,8 +120,15 @@ export function hasThresholdViolation(
|
|||||||
batteryCapacity: number,
|
batteryCapacity: number,
|
||||||
batteryRuntime: number,
|
batteryRuntime: number,
|
||||||
actions: IActionConfig[] | undefined,
|
actions: IActionConfig[] | undefined,
|
||||||
|
unknownDurationMinutes: number = 0,
|
||||||
): boolean {
|
): boolean {
|
||||||
return getActionThresholdStates(powerStatus, batteryCapacity, batteryRuntime, actions).some(
|
return getActionThresholdStates(
|
||||||
|
powerStatus,
|
||||||
|
batteryCapacity,
|
||||||
|
batteryRuntime,
|
||||||
|
actions,
|
||||||
|
unknownDurationMinutes,
|
||||||
|
).some(
|
||||||
Boolean,
|
Boolean,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -130,8 +138,23 @@ export function isActionThresholdExceeded(
|
|||||||
powerStatus: IProtocolUpsStatus['powerStatus'],
|
powerStatus: IProtocolUpsStatus['powerStatus'],
|
||||||
batteryCapacity: number,
|
batteryCapacity: number,
|
||||||
batteryRuntime: number,
|
batteryRuntime: number,
|
||||||
|
unknownDurationMinutes: number = 0,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (powerStatus !== 'onBattery' || !actionConfig.thresholds) {
|
if (!actionConfig.thresholds) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const unknownMinutes = actionConfig.thresholds.unknownMinutes;
|
||||||
|
if (
|
||||||
|
(powerStatus === 'unknown' || powerStatus === 'unreachable') &&
|
||||||
|
typeof unknownMinutes === 'number' &&
|
||||||
|
Number.isFinite(unknownMinutes) &&
|
||||||
|
unknownMinutes >= 0
|
||||||
|
) {
|
||||||
|
return unknownDurationMinutes >= unknownMinutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (powerStatus !== 'onBattery') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,16 +169,25 @@ export function getActionThresholdStates(
|
|||||||
batteryCapacity: number,
|
batteryCapacity: number,
|
||||||
batteryRuntime: number,
|
batteryRuntime: number,
|
||||||
actions: IActionConfig[] | undefined,
|
actions: IActionConfig[] | undefined,
|
||||||
|
unknownDurationMinutes: number = 0,
|
||||||
): boolean[] {
|
): boolean[] {
|
||||||
if (!actions || actions.length === 0) {
|
if (!actions || actions.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return actions.map((actionConfig) =>
|
return actions.map((actionConfig) =>
|
||||||
isActionThresholdExceeded(actionConfig, powerStatus, batteryCapacity, batteryRuntime)
|
isActionThresholdExceeded(
|
||||||
|
actionConfig,
|
||||||
|
powerStatus,
|
||||||
|
batteryCapacity,
|
||||||
|
batteryRuntime,
|
||||||
|
unknownDurationMinutes,
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { getUnknownStatusDurationMinutes };
|
||||||
|
|
||||||
export function getEnteredThresholdIndexes(
|
export function getEnteredThresholdIndexes(
|
||||||
previousStates: boolean[] | undefined,
|
previousStates: boolean[] | undefined,
|
||||||
currentStates: boolean[],
|
currentStates: boolean[],
|
||||||
|
|||||||
Reference in New Issue
Block a user