diff --git a/changelog.md b/changelog.md index db3c4d7..66d34fd 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,12 @@ # Changelog +## 2026-04-14 - 5.6.0 - feat(config) +add configurable default shutdown delay for shutdown actions + +- introduces a top-level defaultShutdownDelay config value used by shutdown actions that do not define their own delay +- applies the configured default during action execution, daemon-initiated shutdowns, CLI prompts, and status display output +- preserves explicit shutdownDelay values including 0 minutes and normalizes invalid config values back to the built-in default + ## 2026-04-14 - 5.5.1 - fix(cli,daemon,snmp) normalize CLI argument parsing and extract daemon monitoring helpers with stronger SNMP typing diff --git a/readme.hints.md b/readme.hints.md index 2e822f5..381ae5f 100644 --- a/readme.hints.md +++ b/readme.hints.md @@ -75,6 +75,8 @@ shutdowns - `ts/daemon.ts` now delegates OS shutdown execution instead of embedding command lookup logic inline +- `defaultShutdownDelay` in config provides the inherited delay for shutdown actions without an + explicit `shutdownDelay` override ### Config Watch Handling diff --git a/readme.md b/readme.md index 04cf2ce..6c82ddf 100644 --- a/readme.md +++ b/readme.md @@ -219,12 +219,16 @@ nupst uninstall # Completely remove NUPST (requires root) NUPST stores configuration at `/etc/nupst/config.json`. The easiest way to configure is through the interactive CLI commands, but you can also edit the JSON directly. +`defaultShutdownDelay` sets the inherited delay in minutes for shutdown actions that do not define +their own `shutdownDelay`. + ### Example Configuration ```json { "version": "4.3", "checkInterval": 30000, + "defaultShutdownDelay": 5, "httpServer": { "enabled": true, "port": 8080, @@ -395,7 +399,7 @@ Actions define automated responses to UPS conditions. They run **sequentially in | Field | Description | Default | | --------------- | ---------------------------------- | ------- | -| `shutdownDelay` | Minutes to wait before shutdown | `5` | +| `shutdownDelay` | Minutes to wait before shutdown | Inherits `defaultShutdownDelay` (`5`) | #### Webhook Action @@ -610,16 +614,16 @@ UPS Devices (2): Host: 192.168.1.100:161 (SNMP) Groups: Data Center Action: proxmox (onlyThresholds: battery<30%, runtime<15min) - Action: shutdown (onlyThresholds: battery<20%, runtime<10min, delay=10s) + Action: shutdown (onlyThresholds: battery<20%, runtime<10min, delay=10min) ✓ Local USB UPS (online - 95%, 2400min) Host: 127.0.0.1:3493 (UPSD) - Action: shutdown (onlyThresholds: battery<15%, runtime<5min, delay=5s) + Action: shutdown (onlyThresholds: battery<15%, runtime<5min, delay=5min) Groups (1): ℹ Data Center (redundant) UPS Devices (1): Main Server UPS - Action: shutdown (onlyThresholds: battery<10%, runtime<5min, delay=15s) + Action: shutdown (onlyThresholds: battery<10%, runtime<5min, delay=15min) ``` ### Live Logs diff --git a/test/test.ts b/test/test.ts index 1a88017..c5ba247 100644 --- a/test/test.ts +++ b/test/test.ts @@ -11,7 +11,11 @@ import { type IPauseState, loadPauseSnapshot } from '../ts/pause-state.ts'; import { shortId } from '../ts/helpers/shortid.ts'; import { HTTP_SERVER, SNMP, THRESHOLDS, TIMING, UI } from '../ts/constants.ts'; import { Action, type IActionContext } from '../ts/actions/base-action.ts'; -import { buildUpsActionContext, decideUpsActionExecution } from '../ts/action-orchestration.ts'; +import { + applyDefaultShutdownDelay, + buildUpsActionContext, + decideUpsActionExecution, +} from '../ts/action-orchestration.ts'; import { buildShutdownErrorRow, buildShutdownStatusRow, @@ -353,6 +357,22 @@ Deno.test('decideUpsActionExecution: returns executable action plan when actions }); }); +Deno.test('applyDefaultShutdownDelay: applies only to shutdown actions without explicit delay', () => { + const actions = [ + { type: 'shutdown' as const }, + { type: 'shutdown' as const, shutdownDelay: 0 }, + { type: 'shutdown' as const, shutdownDelay: 9 }, + { type: 'webhook' as const }, + ]; + + assertEquals(applyDefaultShutdownDelay(actions, 7), [ + { type: 'shutdown', shutdownDelay: 7 }, + { type: 'shutdown', shutdownDelay: 0 }, + { type: 'shutdown', shutdownDelay: 9 }, + { type: 'webhook' }, + ]); +}); + // ----------------------------------------------------------------------------- // Shutdown Monitoring Tests // ----------------------------------------------------------------------------- diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index c8873fd..d6636af 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@serve.zone/nupst', - version: '5.5.1', + version: '5.6.0', description: 'Network UPS Shutdown Tool - Monitor SNMP-enabled UPS devices and orchestrate graceful system shutdowns during power emergencies' } diff --git a/ts/action-orchestration.ts b/ts/action-orchestration.ts index c98706b..0059b8f 100644 --- a/ts/action-orchestration.ts +++ b/ts/action-orchestration.ts @@ -34,6 +34,22 @@ export function buildUpsActionContext( }; } +export function applyDefaultShutdownDelay( + actions: IActionConfig[], + defaultDelayMinutes: number, +): IActionConfig[] { + return actions.map((action) => { + if (action.type !== 'shutdown' || action.shutdownDelay !== undefined) { + return action; + } + + return { + ...action, + shutdownDelay: defaultDelayMinutes, + }; + }); +} + export function decideUpsActionExecution( isPaused: boolean, ups: IUpsActionSource, diff --git a/ts/actions/base-action.ts b/ts/actions/base-action.ts index b11b3cd..561d15f 100644 --- a/ts/actions/base-action.ts +++ b/ts/actions/base-action.ts @@ -74,7 +74,7 @@ export interface IActionConfig { }; // Shutdown action configuration - /** Delay before shutdown in minutes (default: 5) */ + /** Delay before shutdown in minutes (defaults to the config-level shutdown delay, or 5) */ shutdownDelay?: number; /** Only execute shutdown on threshold violation, not power status changes */ onlyOnThresholdViolation?: boolean; diff --git a/ts/actions/shutdown-action.ts b/ts/actions/shutdown-action.ts index a80882a..02ed6fd 100644 --- a/ts/actions/shutdown-action.ts +++ b/ts/actions/shutdown-action.ts @@ -124,7 +124,7 @@ export class ShutdownAction extends Action { return; } - const shutdownDelay = this.config.shutdownDelay || SHUTDOWN.DEFAULT_DELAY_MINUTES; + const shutdownDelay = this.config.shutdownDelay ?? SHUTDOWN.DEFAULT_DELAY_MINUTES; logger.log(''); logger.logBoxTitle('Initiating System Shutdown', UI.WIDE_BOX_WIDTH, 'error'); diff --git a/ts/cli/action-handler.ts b/ts/cli/action-handler.ts index 45172d5..09ab03b 100644 --- a/ts/cli/action-handler.ts +++ b/ts/cli/action-handler.ts @@ -4,6 +4,7 @@ import { type ITableColumn, logger } from '../logger.ts'; import { symbols, theme } from '../colors.ts'; import type { IActionConfig } from '../actions/base-action.ts'; import { ProxmoxAction } from '../actions/proxmox-action.ts'; +import { SHUTDOWN } from '../constants.ts'; import type { IGroupConfig, IUpsConfig } from '../daemon.ts'; import * as helpers from '../helpers/index.ts'; @@ -81,16 +82,20 @@ export class ActionHandler { if (typeValue === 1) { // Shutdown action newAction.type = 'shutdown'; + const defaultShutdownDelay = + this.nupst.getDaemon().getConfig().defaultShutdownDelay ?? SHUTDOWN.DEFAULT_DELAY_MINUTES; const delayStr = await prompt( - ` ${theme.dim('Shutdown delay')} ${theme.dim('(minutes) [5]:')} `, + ` ${theme.dim('Shutdown delay')} ${theme.dim(`(minutes, leave empty for default ${defaultShutdownDelay}):`)} `, ); - const shutdownDelay = delayStr ? parseInt(delayStr, 10) : 5; - if (isNaN(shutdownDelay) || shutdownDelay < 0) { - logger.error('Invalid shutdown delay. Must be >= 0.'); - process.exit(1); + if (delayStr.trim()) { + const shutdownDelay = parseInt(delayStr, 10); + if (isNaN(shutdownDelay) || shutdownDelay < 0) { + logger.error('Invalid shutdown delay. Must be >= 0.'); + process.exit(1); + } + newAction.shutdownDelay = shutdownDelay; } - newAction.shutdownDelay = shutdownDelay; } else if (typeValue === 2) { // Webhook action newAction.type = 'webhook'; @@ -468,7 +473,9 @@ export class ActionHandler { ]; const rows = target.actions.map((action, index) => { - let details = `${action.shutdownDelay || 5}min delay`; + const defaultShutdownDelay = + this.nupst.getDaemon().getConfig().defaultShutdownDelay ?? SHUTDOWN.DEFAULT_DELAY_MINUTES; + let details = `${action.shutdownDelay ?? defaultShutdownDelay}min delay`; if (action.type === 'proxmox') { const mode = action.proxmoxMode || 'auto'; if (mode === 'cli' || (mode === 'auto' && !action.proxmoxTokenId)) { diff --git a/ts/cli/ups-handler.ts b/ts/cli/ups-handler.ts index 9fd67a2..3959673 100644 --- a/ts/cli/ups-handler.ts +++ b/ts/cli/ups-handler.ts @@ -10,7 +10,7 @@ import type { TProtocol } from '../protocol/types.ts'; import type { INupstConfig, IUpsConfig } from '../daemon.ts'; import type { IActionConfig } from '../actions/base-action.ts'; import { ProxmoxAction } from '../actions/proxmox-action.ts'; -import { UPSD } from '../constants.ts'; +import { SHUTDOWN, UPSD } from '../constants.ts'; /** * Thresholds configuration for CLI display @@ -1152,11 +1152,19 @@ export class UpsHandler { if (typeValue === 1) { // Shutdown action action.type = 'shutdown'; + const defaultShutdownDelay = + this.nupst.getDaemon().getConfig().defaultShutdownDelay ?? SHUTDOWN.DEFAULT_DELAY_MINUTES; - const delayInput = await prompt('Shutdown delay in minutes [5]: '); - const delay = parseInt(delayInput, 10); - if (delayInput.trim() && !isNaN(delay)) { - action.shutdownDelay = delay; + const delayInput = await prompt( + `Shutdown delay in minutes (leave empty for default ${defaultShutdownDelay}): `, + ); + if (delayInput.trim()) { + const delay = parseInt(delayInput, 10); + if (isNaN(delay) || delay < 0) { + logger.warn('Invalid shutdown delay, using configured default'); + } else { + action.shutdownDelay = delay; + } } } else if (typeValue === 2) { // Webhook action diff --git a/ts/daemon.ts b/ts/daemon.ts index 0ebca90..a5b82c7 100644 --- a/ts/daemon.ts +++ b/ts/daemon.ts @@ -12,9 +12,13 @@ import { MigrationRunner } from './migrations/index.ts'; import { formatPowerStatus, getBatteryColor, getRuntimeColor, theme } from './colors.ts'; import type { IActionConfig } from './actions/base-action.ts'; import { ActionManager } from './actions/index.ts'; -import { decideUpsActionExecution, type TUpsTriggerReason } from './action-orchestration.ts'; +import { + applyDefaultShutdownDelay, + decideUpsActionExecution, + type TUpsTriggerReason, +} from './action-orchestration.ts'; import { NupstHttpServer } from './http-server.ts'; -import { NETWORK, PAUSE, THRESHOLDS, TIMING, UI } from './constants.ts'; +import { NETWORK, PAUSE, SHUTDOWN, THRESHOLDS, TIMING, UI } from './constants.ts'; import { analyzeConfigReload, shouldRefreshPauseState, @@ -97,6 +101,8 @@ export interface INupstConfig { groups: IGroupConfig[]; /** Check interval in milliseconds */ checkInterval: number; + /** Default delay in minutes for shutdown actions without an override */ + defaultShutdownDelay?: number; /** HTTP Server configuration */ httpServer?: IHttpServerConfig; @@ -125,6 +131,7 @@ export class NupstDaemon { /** Default configuration */ private readonly DEFAULT_CONFIG: INupstConfig = { version: '4.3', + defaultShutdownDelay: SHUTDOWN.DEFAULT_DELAY_MINUTES, upsDevices: [ { id: 'default', @@ -155,7 +162,6 @@ export class NupstDaemon { battery: THRESHOLDS.DEFAULT_BATTERY_PERCENT, // Shutdown when battery below 60% runtime: THRESHOLDS.DEFAULT_RUNTIME_MINUTES, // Shutdown when runtime below 20 minutes }, - shutdownDelay: 5, }, ], }, @@ -208,10 +214,13 @@ export class NupstDaemon { const migrationRunner = new MigrationRunner(); const { config: migratedConfig, migrated } = await migrationRunner.run(parsedConfig); - // Save migrated config back to disk if any migrations ran - // Cast to INupstConfig since migrations ensure the output is valid + // Save migrated or normalized config back to disk when needed. + // Cast to INupstConfig since migrations ensure the output is valid. const validConfig = migratedConfig as unknown as INupstConfig; - if (migrated) { + const normalizedShutdownDelay = this.normalizeShutdownDelay(validConfig.defaultShutdownDelay); + const shouldPersistNormalizedConfig = validConfig.defaultShutdownDelay !== normalizedShutdownDelay; + validConfig.defaultShutdownDelay = normalizedShutdownDelay; + if (migrated || shouldPersistNormalizedConfig) { this.config = validConfig; await this.saveConfig(this.config); } else { @@ -249,6 +258,7 @@ export class NupstDaemon { upsDevices: config.upsDevices, groups: config.groups, checkInterval: config.checkInterval, + defaultShutdownDelay: this.normalizeShutdownDelay(config.defaultShutdownDelay), ...(config.httpServer ? { httpServer: config.httpServer } : {}), }; @@ -280,6 +290,22 @@ export class NupstDaemon { return this.config; } + private normalizeShutdownDelay(delayMinutes: number | undefined): number { + if ( + typeof delayMinutes !== 'number' || + !Number.isFinite(delayMinutes) || + delayMinutes < 0 + ) { + return SHUTDOWN.DEFAULT_DELAY_MINUTES; + } + + return delayMinutes; + } + + private getDefaultShutdownDelayMinutes(): number { + return this.normalizeShutdownDelay(this.config.defaultShutdownDelay); + } + /** * Get the SNMP instance */ @@ -758,7 +784,12 @@ export class NupstDaemon { return; } - await ActionManager.executeActions(decision.actions, decision.context); + const actions = applyDefaultShutdownDelay( + decision.actions, + this.getDefaultShutdownDelayMinutes(), + ); + + await ActionManager.executeActions(actions, decision.context); } /** @@ -768,8 +799,7 @@ export class NupstDaemon { public async initiateShutdown(reason: string): Promise { logger.log(`Initiating system shutdown due to: ${reason}`); - // Set a longer delay for shutdown to allow VMs and services to close - const shutdownDelayMinutes = 5; + const shutdownDelayMinutes = this.getDefaultShutdownDelayMinutes(); try { await this.shutdownExecutor.scheduleShutdown(shutdownDelayMinutes); diff --git a/ts/migrations/migration-v4.0-to-v4.1.ts b/ts/migrations/migration-v4.0-to-v4.1.ts index 778df2c..2bccfa3 100644 --- a/ts/migrations/migration-v4.0-to-v4.1.ts +++ b/ts/migrations/migration-v4.0-to-v4.1.ts @@ -37,8 +37,7 @@ import { logger } from '../logger.ts'; * { * type: "shutdown", * thresholds: { battery: 60, runtime: 20 }, - * triggerMode: "onlyThresholds", - * shutdownDelay: 5 + * triggerMode: "onlyThresholds" * } * ] * } @@ -93,7 +92,6 @@ export class MigrationV4_0ToV4_1 extends BaseMigration { runtime: deviceThresholds.runtime, }, triggerMode: 'onlyThresholds', // Preserve old behavior (only on threshold violation) - shutdownDelay: 5, // Default delay }, ]; logger.dim( diff --git a/ts/systemd.ts b/ts/systemd.ts index 12ab54e..f809fc8 100644 --- a/ts/systemd.ts +++ b/ts/systemd.ts @@ -5,6 +5,7 @@ import { type IUpsConfig, NupstDaemon } from './daemon.ts'; import { NupstSnmp } from './snmp/manager.ts'; import { logger } from './logger.ts'; import { formatPowerStatus, getBatteryColor, getRuntimeColor, symbols, theme } from './colors.ts'; +import { SHUTDOWN } from './constants.ts'; /** * Class for managing systemd service @@ -316,7 +317,6 @@ WantedBy=multi-user.target type: 'shutdown', thresholds: config.thresholds, triggerMode: 'onlyThresholds', - shutdownDelay: 5, }, ] : [], @@ -346,6 +346,8 @@ WantedBy=multi-user.target */ private async displaySingleUpsStatus(ups: IUpsConfig, snmp: NupstSnmp): Promise { try { + const defaultShutdownDelay = + this.daemon.getConfig().defaultShutdownDelay ?? SHUTDOWN.DEFAULT_DELAY_MINUTES; const protocol = ups.protocol || 'snmp'; let status; @@ -432,14 +434,16 @@ WantedBy=multi-user.target actionDesc += ` (${ action.triggerMode || 'onlyThresholds' }: battery<${action.thresholds.battery}%, runtime<${action.thresholds.runtime}min`; - if (action.shutdownDelay) { - actionDesc += `, delay=${action.shutdownDelay}s`; + if (action.type === 'shutdown') { + const shutdownDelay = action.shutdownDelay ?? defaultShutdownDelay; + actionDesc += `, delay=${shutdownDelay}min`; } actionDesc += ')'; } else { actionDesc += ` (${action.triggerMode || 'onlyPowerChanges'}`; - if (action.shutdownDelay) { - actionDesc += `, delay=${action.shutdownDelay}s`; + if (action.type === 'shutdown') { + const shutdownDelay = action.shutdownDelay ?? defaultShutdownDelay; + actionDesc += `, delay=${shutdownDelay}min`; } actionDesc += ')'; } @@ -506,20 +510,23 @@ WantedBy=multi-user.target // Display actions if any if (group.actions && group.actions.length > 0) { + const defaultShutdownDelay = config.defaultShutdownDelay ?? SHUTDOWN.DEFAULT_DELAY_MINUTES; for (const action of group.actions) { let actionDesc = `${action.type}`; if (action.thresholds) { actionDesc += ` (${ action.triggerMode || 'onlyThresholds' }: battery<${action.thresholds.battery}%, runtime<${action.thresholds.runtime}min`; - if (action.shutdownDelay) { - actionDesc += `, delay=${action.shutdownDelay}s`; + if (action.type === 'shutdown') { + const shutdownDelay = action.shutdownDelay ?? defaultShutdownDelay; + actionDesc += `, delay=${shutdownDelay}min`; } actionDesc += ')'; } else { actionDesc += ` (${action.triggerMode || 'onlyPowerChanges'}`; - if (action.shutdownDelay) { - actionDesc += `, delay=${action.shutdownDelay}s`; + if (action.type === 'shutdown') { + const shutdownDelay = action.shutdownDelay ?? defaultShutdownDelay; + actionDesc += `, delay=${shutdownDelay}min`; } actionDesc += ')'; }