feat(actions): support unknown/unreachable duration thresholds

This commit is contained in:
2026-05-29 14:12:01 +00:00
parent c92d7499e2
commit 4b4609f4ba
14 changed files with 378 additions and 23 deletions
+8
View File
@@ -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
View File
@@ -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++] ?? '';
+9
View File
@@ -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,
}; };
+33 -5
View File
@@ -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
);
}
} }
+14 -1
View File
@@ -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') {
+4
View File
@@ -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
+15 -2
View File
@@ -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
+8
View File
@@ -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));
+33 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
}; };
} }
+6
View File
@@ -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
View File
@@ -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[],