Compare commits

...

3 Commits

Author SHA1 Message Date
jkunz 7c874a3119 v5.12.0
Release / build-and-release (push) Successful in 53s
2026-05-29 14:18:08 +00:00
jkunz f77d8c8ee9 fix(config): migrate smartconfig release settings to target-based npm config 2026-05-29 14:17:40 +00:00
jkunz 4b4609f4ba feat(actions): support unknown/unreachable duration thresholds 2026-05-29 14:12:01 +00:00
18 changed files with 427 additions and 37 deletions
+37 -11
View File
@@ -1,12 +1,27 @@
{
"@git.zone/cli": {
"release": {
"registries": [
"https://verdaccio.lossless.digital"
],
"accessLevel": "public"
"targets": {
"git": {
"enabled": true,
"remote": "origin",
"pushBranch": true,
"pushTags": true
},
"npm": {
"enabled": true,
"registries": [
"https://verdaccio.lossless.digital"
],
"accessLevel": "public"
},
"docker": {
"enabled": false,
"engine": "tsdocker"
}
}
},
"projectType": "deno",
"projectType": "npm",
"module": {
"githost": "code.foss.global",
"gitscope": "serve.zone",
@@ -14,7 +29,8 @@
"description": "shut down in time when the power goes out",
"npmPackagename": "@serve.zone/nupst",
"license": "MIT"
}
},
"schemaVersion": 2
},
"@git.zone/tsdeno": {
"compileTargets": [
@@ -23,7 +39,9 @@
"entryPoint": "mod.ts",
"outDir": "dist/binaries",
"target": "x86_64-unknown-linux-gnu",
"permissions": ["--allow-all"],
"permissions": [
"--allow-all"
],
"noCheck": true
},
{
@@ -31,7 +49,9 @@
"entryPoint": "mod.ts",
"outDir": "dist/binaries",
"target": "aarch64-unknown-linux-gnu",
"permissions": ["--allow-all"],
"permissions": [
"--allow-all"
],
"noCheck": true
},
{
@@ -39,7 +59,9 @@
"entryPoint": "mod.ts",
"outDir": "dist/binaries",
"target": "x86_64-apple-darwin",
"permissions": ["--allow-all"],
"permissions": [
"--allow-all"
],
"noCheck": true
},
{
@@ -47,7 +69,9 @@
"entryPoint": "mod.ts",
"outDir": "dist/binaries",
"target": "aarch64-apple-darwin",
"permissions": ["--allow-all"],
"permissions": [
"--allow-all"
],
"noCheck": true
},
{
@@ -55,7 +79,9 @@
"entryPoint": "mod.ts",
"outDir": "dist/binaries",
"target": "x86_64-pc-windows-msvc",
"permissions": ["--allow-all"],
"permissions": [
"--allow-all"
],
"noCheck": true
}
]
+17
View File
@@ -3,6 +3,23 @@
## Pending
## 2026-05-29 - 5.12.0
### 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.
### Fixes
- migrate smartconfig release settings to target-based npm config (config)
- Adds schemaVersion 2 release targets for git and npm while keeping Docker releases disabled.
- Switches the release tooling project type from deno to npm.
## 2026-05-29 - 5.11.2
### Fixes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@serve.zone/nupst",
"version": "5.11.2",
"version": "5.12.0",
"exports": "./mod.ts",
"nodeModulesDir": "auto",
"tasks": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@serve.zone/nupst",
"version": "5.11.2",
"version": "5.12.0",
"description": "Network UPS Shutdown Tool - Monitor SNMP-enabled UPS devices and orchestrate graceful system shutdowns during power emergencies",
"keywords": [
"ups",
+144 -1
View File
@@ -19,6 +19,7 @@ import {
applyDefaultShutdownDelay,
buildUpsActionContext,
decideUpsActionExecution,
getUnknownStatusDurationMinutes,
} from '../ts/action-orchestration.ts';
import {
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 { ActionHandler } from '../ts/cli/action-handler.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 * 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,
batteryRuntime: 15,
previousPowerStatus: 'online',
unknownDurationMinutes: 0,
timestamp: 9999,
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', () => {
const decision = decideUpsActionExecution(
true,
@@ -368,6 +391,7 @@ Deno.test('decideUpsActionExecution: returns executable action plan when actions
batteryCapacity: 55,
batteryRuntime: 18,
previousPowerStatus: 'online',
unknownDurationMinutes: 0,
timestamp: 9999,
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', () => {
assertEquals(
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', () => {
assertEquals(getEnteredThresholdIndexes(undefined, [false, true, true]), [1, 2]);
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);
});
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', () => {
const status = buildGroupThresholdContextStatus(
{ id: 'group-3', name: 'Group Worst' },
@@ -721,6 +803,7 @@ Deno.test('buildGroupThresholdContextStatus: uses the worst triggering member ru
{
exceedsThreshold: true,
blockedByUnreachable: false,
exceededByUnknownDuration: false,
representativeStatus: {
...createInitialUpsStatus({ id: 'ups-1', name: 'UPS 1' }, 1000),
powerStatus: 'onBattery' as const,
@@ -731,6 +814,7 @@ Deno.test('buildGroupThresholdContextStatus: uses the worst triggering member ru
{
exceedsThreshold: true,
blockedByUnreachable: false,
exceededByUnknownDuration: false,
representativeStatus: {
...createInitialUpsStatus({ id: 'ups-2', name: 'UPS 2' }, 1000),
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 {
return {
upsId: 'test-ups',
@@ -942,6 +1032,7 @@ function createMockContext(overrides: Partial<IActionContext> = {}): IActionCont
batteryCapacity: 100,
batteryRuntime: 60,
previousPowerStatus: 'online',
unknownDurationMinutes: 0,
timestamp: Date.now(),
triggerReason: 'powerStatusChange',
...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', () => {
const powerChangeAction = new TestProxmoxAction({ type: 'proxmox' });
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];
const handler = new ActionHandler(nupstMock);
const answers = ['', '12', '25', '8', '3'];
const answers = ['', '12', '25', '8', '', '3'];
let answerIndex = 0;
const prompt = async (_question: string): Promise<string> => answers[answerIndex++] ?? '';
+1 -1
View File
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/nupst',
version: '5.11.2',
version: '5.12.0',
description: 'Network UPS Shutdown Tool - Monitor SNMP-enabled UPS devices and orchestrate graceful system shutdowns during power emergencies'
}
+9
View File
@@ -15,6 +15,14 @@ export type TActionExecutionDecision =
| { type: 'skip' }
| { 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(
ups: IUpsActionSource,
status: IUpsStatus,
@@ -29,6 +37,7 @@ export function buildUpsActionContext(
batteryCapacity: status.batteryCapacity,
batteryRuntime: status.batteryRuntime,
previousPowerStatus: (previousStatus?.powerStatus || 'unknown') as TPowerStatus,
unknownDurationMinutes: getUnknownStatusDurationMinutes(status, timestamp),
timestamp,
triggerReason,
};
+33 -5
View File
@@ -3,7 +3,7 @@
*
* Actions are triggered on:
* 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';
@@ -30,6 +30,8 @@ export interface IActionContext {
// State tracking
/** Previous power status before this trigger */
previousPowerStatus: TPowerStatus;
/** Minutes the UPS has continuously been unknown/unreachable. Zero for known states. */
unknownDurationMinutes: number;
// Metadata
/** Timestamp when this action was triggered (milliseconds since epoch) */
@@ -71,6 +73,8 @@ export interface IActionConfig {
battery: number;
/** Runtime threshold in minutes */
runtime: number;
/** Optional fail-safe threshold for unknown/unreachable status duration in minutes */
unknownMinutes?: number;
};
// Shutdown action configuration
@@ -158,13 +162,13 @@ export abstract class Action {
case 'onlyThresholds':
// Only execute when this action's thresholds are exceeded
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':
// Execute on power changes OR when thresholds exceeded
if (context.triggerReason === 'powerStatusChange') return true;
if (!this.config.thresholds) return false;
return this.areThresholdsExceeded(context.batteryCapacity, context.batteryRuntime);
return this.areThresholdsExceeded(context.batteryCapacity, context.batteryRuntime, context);
case 'anyChange':
// 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 batteryRuntime Current runtime in minutes
* @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) {
return false; // No thresholds configured
}
if (context && this.isUnknownThresholdExceeded(context)) {
return true;
}
return (
batteryCapacity < this.config.thresholds.battery ||
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';
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') {
logger.info(
'Proxmox action skipped: UPS is unreachable (communication failure, actual state unknown)',
@@ -52,7 +65,7 @@ export class ProxmoxAction extends Action {
return false;
}
return this.areThresholdsExceeded(context.batteryCapacity, context.batteryRuntime);
return this.areThresholdsExceeded(context.batteryCapacity, context.batteryRuntime, context);
}
if (context.triggerReason === 'powerStatusChange') {
+4
View File
@@ -112,11 +112,15 @@ export class ScriptAction extends Action {
NUPST_POWER_STATUS: context.powerStatus,
NUPST_BATTERY_CAPACITY: String(context.batteryCapacity),
NUPST_BATTERY_RUNTIME: String(context.batteryRuntime),
NUPST_UNKNOWN_DURATION_MINUTES: String(context.unknownDurationMinutes),
NUPST_TRIGGER_REASON: context.triggerReason,
NUPST_TIMESTAMP: String(context.timestamp),
// Include action's own thresholds if configured
NUPST_BATTERY_THRESHOLD: this.config.thresholds ? String(this.config.thresholds.battery) : '',
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
+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
// 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.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') {
logger.info(
`Shutdown action skipped: UPS is unreachable (communication failure, actual state unknown)`,
@@ -57,7 +70,7 @@ export class ShutdownAction extends Action {
return false;
}
// 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
+8
View File
@@ -19,6 +19,8 @@ export interface IWebhookPayload {
batteryCapacity: number;
/** Current battery runtime in minutes */
batteryRuntime: number;
/** Minutes the UPS has continuously been unknown/unreachable */
unknownDurationMinutes: number;
/** Reason this webhook was triggered */
triggerReason: 'powerStatusChange' | 'thresholdViolation';
/** Timestamp when webhook was triggered */
@@ -27,6 +29,7 @@ export interface IWebhookPayload {
thresholds?: {
battery: number;
runtime: number;
unknownMinutes?: number;
};
}
@@ -92,6 +95,7 @@ export class WebhookAction extends Action {
powerStatus: context.powerStatus,
batteryCapacity: context.batteryCapacity,
batteryRuntime: context.batteryRuntime,
unknownDurationMinutes: context.unknownDurationMinutes,
triggerReason: context.triggerReason,
timestamp: context.timestamp,
};
@@ -101,6 +105,9 @@ export class WebhookAction extends Action {
payload.thresholds = {
battery: this.config.thresholds.battery,
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('batteryCapacity', String(payload.batteryCapacity));
url.searchParams.append('batteryRuntime', String(payload.batteryRuntime));
url.searchParams.append('unknownDurationMinutes', String(context.unknownDurationMinutes));
url.searchParams.append('triggerReason', payload.triggerReason);
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.log(` ${theme.dim('Type:')} ${removedAction.type}`);
if (removedAction.thresholds) {
const unknownThreshold = removedAction.thresholds.unknownMinutes !== undefined
? `, Unknown: ${removedAction.thresholds.unknownMinutes}min`
: '';
logger.log(
` ${
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')}`);
@@ -703,7 +706,31 @@ export class ActionHandler {
logger.error('Invalid runtime threshold. Must be >= 0.');
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(` ${theme.dim('Trigger mode:')}`);
@@ -758,6 +785,7 @@ export class ActionHandler {
{ header: 'Type', key: 'type', align: 'left' },
{ header: 'Battery', key: 'battery', align: 'right' },
{ header: 'Runtime', key: 'runtime', align: 'right' },
{ header: 'Unknown', key: 'unknown', align: 'right' },
{ header: 'Trigger Mode', key: 'triggerMode', align: 'left' },
{ header: 'Details', key: 'details', align: 'left' },
];
@@ -792,6 +820,9 @@ export class ActionHandler {
type: theme.highlight(action.type),
battery: action.thresholds ? `${action.thresholds.battery}%` : 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'),
details,
};
+12 -1
View File
@@ -1318,7 +1318,10 @@ export class UpsHandler {
logger.log('');
logger.info('Action Thresholds:');
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]: ');
@@ -1329,10 +1332,18 @@ export class UpsHandler {
const runtime = parseInt(runtimeInput, 10);
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 = {
battery: batteryThreshold,
runtime: runtimeThreshold,
};
if (unknownInput.trim() && !isNaN(unknownMinutes) && unknownMinutes >= 0) {
action.thresholds.unknownMinutes = unknownMinutes;
}
}
actions.push(action as IActionConfig);
+28 -4
View File
@@ -38,6 +38,7 @@ import {
ensureUpsStatus,
getActionThresholdStates,
getEnteredThresholdIndexes,
getUnknownStatusDurationMinutes,
} from './ups-monitoring.ts';
import {
buildShutdownErrorRow,
@@ -653,10 +654,11 @@ export class NupstDaemon {
}
const thresholdStates = getActionThresholdStates(
status.powerStatus,
status.batteryCapacity,
status.batteryRuntime,
pollSnapshot.updatedStatus.powerStatus,
pollSnapshot.updatedStatus.batteryCapacity,
pollSnapshot.updatedStatus.batteryRuntime,
ups.actions,
getUnknownStatusDurationMinutes(pollSnapshot.updatedStatus, currentTime),
);
const enteredThresholdIndexes = this.trackEnteredThresholdIndexes(
`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);
}
}
@@ -769,7 +793,7 @@ export class NupstDaemon {
}
const thresholdEvaluations = (group.actions || []).map((action) =>
evaluateGroupActionThreshold(action, group.mode, memberStatuses)
evaluateGroupActionThreshold(action, group.mode, memberStatuses, currentTime)
);
const thresholdStates = thresholdEvaluations.map((evaluation) =>
evaluation.exceedsThreshold && !evaluation.blockedByUnreachable
+29 -4
View File
@@ -1,4 +1,5 @@
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';
export interface IGroupStatusSnapshot {
@@ -10,6 +11,7 @@ export interface IGroupStatusSnapshot {
export interface IGroupThresholdEvaluation {
exceedsThreshold: boolean;
blockedByUnreachable: boolean;
exceededByUnknownDuration: boolean;
representativeStatus?: IUpsStatus;
}
@@ -143,11 +145,13 @@ export function evaluateGroupActionThreshold(
actionConfig: IActionConfig,
mode: 'redundant' | 'nonRedundant',
memberStatuses: IUpsStatus[],
currentTime: number = Date.now(),
): IGroupThresholdEvaluation {
if (!actionConfig.thresholds || memberStatuses.length === 0) {
return {
exceedsThreshold: false,
blockedByUnreachable: false,
exceededByUnknownDuration: false,
};
}
@@ -156,16 +160,32 @@ export function evaluateGroupActionThreshold(
(status.batteryCapacity < actionConfig.thresholds!.battery ||
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 > 0;
const exceededByUnknownDuration = mode === 'redundant'
? unknownMembers.length === memberStatuses.length
: unknownMembers.length > 0;
const exceedsThreshold = exceedsBatteryThreshold || exceededByUnknownDuration;
return {
exceedsThreshold,
blockedByUnreachable: exceedsThreshold &&
blockedByUnreachable: exceedsBatteryThreshold && !exceededByUnknownDuration &&
destructiveActionTypes.has(actionConfig.type) &&
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);
const representative = selectWorstStatus(representativeStatuses) || fallbackStatus;
const powerStatus = representative.powerStatus === 'unknown' ||
representative.powerStatus === 'unreachable'
? representative.powerStatus
: 'onBattery';
return {
...fallbackStatus,
id: group.id,
name: group.name,
powerStatus: 'onBattery',
powerStatus,
batteryCapacity: representative.batteryCapacity,
batteryRuntime: representative.batteryRuntime,
outputLoad: representative.outputLoad,
outputPower: representative.outputPower,
outputVoltage: representative.outputVoltage,
outputCurrent: representative.outputCurrent,
lastStatusChange: representative.lastStatusChange,
lastCheckTime: currentTime,
};
}
+6
View File
@@ -512,6 +512,9 @@ WantedBy=multi-user.target
actionDesc += ` (${
action.triggerMode || 'onlyThresholds'
}: battery<${action.thresholds.battery}%, runtime<${action.thresholds.runtime}min`;
if (action.thresholds.unknownMinutes !== undefined) {
actionDesc += `, unknown>${action.thresholds.unknownMinutes}min`;
}
if (action.type === 'shutdown') {
const shutdownDelay = action.shutdownDelay ?? defaultShutdownDelay;
actionDesc += `, delay=${shutdownDelay}min`;
@@ -599,6 +602,9 @@ WantedBy=multi-user.target
actionDesc += ` (${
action.triggerMode || 'onlyThresholds'
}: battery<${action.thresholds.battery}%, runtime<${action.thresholds.runtime}min`;
if (action.thresholds.unknownMinutes !== undefined) {
actionDesc += `, unknown>${action.thresholds.unknownMinutes}min`;
}
if (action.type === 'shutdown') {
const shutdownDelay = action.shutdownDelay ?? defaultShutdownDelay;
actionDesc += `, delay=${shutdownDelay}min`;
+35 -3
View File
@@ -1,4 +1,5 @@
import type { IActionConfig } from './actions/base-action.ts';
import { getUnknownStatusDurationMinutes } from './action-orchestration.ts';
import { NETWORK } from './constants.ts';
import type { IUpsStatus as IProtocolUpsStatus } from './snmp/types.ts';
import { createInitialUpsStatus, type IUpsIdentity, type IUpsStatus } from './ups-status.ts';
@@ -119,8 +120,15 @@ export function hasThresholdViolation(
batteryCapacity: number,
batteryRuntime: number,
actions: IActionConfig[] | undefined,
unknownDurationMinutes: number = 0,
): boolean {
return getActionThresholdStates(powerStatus, batteryCapacity, batteryRuntime, actions).some(
return getActionThresholdStates(
powerStatus,
batteryCapacity,
batteryRuntime,
actions,
unknownDurationMinutes,
).some(
Boolean,
);
}
@@ -130,8 +138,23 @@ export function isActionThresholdExceeded(
powerStatus: IProtocolUpsStatus['powerStatus'],
batteryCapacity: number,
batteryRuntime: number,
unknownDurationMinutes: number = 0,
): 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;
}
@@ -146,16 +169,25 @@ export function getActionThresholdStates(
batteryCapacity: number,
batteryRuntime: number,
actions: IActionConfig[] | undefined,
unknownDurationMinutes: number = 0,
): boolean[] {
if (!actions || actions.length === 0) {
return [];
}
return actions.map((actionConfig) =>
isActionThresholdExceeded(actionConfig, powerStatus, batteryCapacity, batteryRuntime)
isActionThresholdExceeded(
actionConfig,
powerStatus,
batteryCapacity,
batteryRuntime,
unknownDurationMinutes,
)
);
}
export { getUnknownStatusDurationMinutes };
export function getEnteredThresholdIndexes(
previousStates: boolean[] | undefined,
currentStates: boolean[],