From 1f542ca27152a29478dc46818f16750c0a4791bd Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Tue, 14 Apr 2026 14:27:29 +0000 Subject: [PATCH] fix(cli,daemon,snmp): normalize CLI argument parsing and extract daemon monitoring helpers with stronger SNMP typing --- changelog.md | 9 + mod.ts | 7 +- readme.hints.md | 67 +++- test/test.showcase.ts | 6 +- test/test.ts | 430 ++++++++++++++++++++ ts/00_commitinfo_data.ts | 2 +- ts/action-orchestration.ts | 70 ++++ ts/cli.ts | 8 +- ts/cli/group-handler.ts | 8 +- ts/cli/service-handler.ts | 37 +- ts/cli/ups-handler.ts | 41 +- ts/config-watch.ts | 58 +++ ts/daemon.ts | 646 ++++++++---------------------- ts/http-server.ts | 3 +- ts/index.ts | 2 +- ts/migrations/migration-runner.ts | 2 +- ts/pause-state.ts | 68 ++++ ts/shutdown-executor.ts | 145 +++++++ ts/shutdown-monitoring.ts | 72 ++++ ts/snmp/manager.ts | 416 +++++++++++-------- ts/ups-monitoring.ts | 138 +++++++ ts/ups-status.ts | 38 ++ 22 files changed, 1571 insertions(+), 702 deletions(-) create mode 100644 ts/action-orchestration.ts create mode 100644 ts/config-watch.ts create mode 100644 ts/pause-state.ts create mode 100644 ts/shutdown-executor.ts create mode 100644 ts/shutdown-monitoring.ts create mode 100644 ts/ups-monitoring.ts create mode 100644 ts/ups-status.ts diff --git a/changelog.md b/changelog.md index 11a464f..db3c4d7 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,14 @@ # Changelog +## 2026-04-14 - 5.5.1 - fix(cli,daemon,snmp) +normalize CLI argument parsing and extract daemon monitoring helpers with stronger SNMP typing + +- Pass runtime arguments directly to the CLI in both Deno and Node entrypoints so commands and debug flags are parsed consistently +- Refactor daemon logic into dedicated pause state, config watch, UPS status, monitoring, action orchestration, shutdown execution, and shutdown monitoring modules +- Add explicit local typings and value coercion around net-snmp interactions to reduce untyped response handling +- Update user-facing CLI guidance to use current subcommands such as "nupst ups add", "nupst ups edit", and "nupst service start" +- Expand test coverage for extracted monitoring and pause-state helpers + ## 2026-04-02 - 5.5.0 - feat(proxmox) add Proxmox CLI auto-detection and interactive action setup improvements diff --git a/mod.ts b/mod.ts index 8259be1..31bccad 100644 --- a/mod.ts +++ b/mod.ts @@ -25,12 +25,7 @@ import { NupstCli } from './ts/cli.ts'; */ async function main(): Promise { const cli = new NupstCli(); - - // Deno.args is already 0-indexed (unlike Node's process.argv which starts at index 2) - // We need to prepend placeholder args to match the existing CLI parser expectations - const args = ['deno', 'mod.ts', ...Deno.args]; - - await cli.parseAndExecute(args); + await cli.parseAndExecute(Deno.args); } // Execute main and handle errors diff --git a/readme.hints.md b/readme.hints.md index b9747cd..2e822f5 100644 --- a/readme.hints.md +++ b/readme.hints.md @@ -36,9 +36,14 @@ - Uses: `IUpsConfig`, `INupstConfig`, `ISnmpConfig`, `IActionConfig`, `IThresholds`, `ISnmpUpsStatus` +7. **SNMP Manager Boundary Types (`ts/snmp/manager.ts`)** + - Added local wrapper interfaces for the untyped `net-snmp` package surface used by NUPST + - SNMP metric reads now coerce values explicitly instead of relying on `any`-typed responses + ## Features Added (February 2026) ### Network Loss Handling + - `TPowerStatus` extended with `'unreachable'` state - `IUpsStatus` has `consecutiveFailures` and `unreachableSince` tracking - After `NETWORK.CONSECUTIVE_FAILURE_THRESHOLD` (3) failures, UPS transitions to `unreachable` @@ -46,22 +51,61 @@ - Recovery is logged when UPS comes back from unreachable ### UPSD/NIS Protocol Support + - New `ts/upsd/` directory with TCP client for NUT (Network UPS Tools) servers - `ts/protocol/` directory with `ProtocolResolver` for protocol-agnostic status queries - `IUpsConfig.protocol` field: `'snmp'` (default) or `'upsd'` - `IUpsConfig.snmp` is now optional (not needed for UPSD devices) - CLI supports protocol selection during `nupst ups add` -- Config version bumped to `4.2` with migration from `4.1` +- Config version is now `4.3`, including the `4.2` -> `4.3` runtime unit migration ### Pause/Resume Command + - File-based signaling via `/etc/nupst/pause` JSON file - `nupst pause [--duration 30m|2h|1d]` creates pause file - `nupst resume` deletes pause file +- `ts/pause-state.ts` owns pause snapshot parsing and transition detection for daemon polling - Daemon polls continue but actions are suppressed while paused - Auto-resume after duration expires - HTTP API includes pause state in response +### Shutdown Orchestration + +- `ts/shutdown-executor.ts` owns command discovery and fallback execution for delayed and emergency + shutdowns +- `ts/daemon.ts` now delegates OS shutdown execution instead of embedding command lookup logic + inline + +### Config Watch Handling + +- `ts/config-watch.ts` owns file-watch event matching and config-reload transition analysis +- `ts/daemon.ts` now delegates config/pause watch event classification and reload messaging + decisions + +### UPS Status Tracking + +- `ts/ups-status.ts` owns the daemon UPS status shape and default status factory +- `ts/daemon.ts` now reuses a shared initializer instead of duplicating the default UPS status + object + +### UPS Monitoring Transitions + +- `ts/ups-monitoring.ts` owns pure UPS poll success/failure transition logic and threshold detection +- `ts/daemon.ts` now orchestrates protocol calls and logging while delegating state transitions + +### Action Orchestration + +- `ts/action-orchestration.ts` owns action context construction and action execution decisions +- `ts/daemon.ts` now delegates pause suppression, legacy shutdown fallback, and action context + building + +### Shutdown Monitoring + +- `ts/shutdown-monitoring.ts` owns shutdown-loop row building and emergency candidate selection +- `ts/daemon.ts` now keeps the shutdown loop orchestration while delegating row/emergency decisions + ### Proxmox VM Shutdown Action + - New action type `'proxmox'` in `ts/actions/proxmox-action.ts` - Uses Proxmox REST API with PVEAPIToken authentication - Shuts down QEMU VMs and LXC containers before host shutdown @@ -76,13 +120,30 @@ - **CLI Handlers**: All use the `helpers.withPrompt()` utility for interactive input - **Constants**: All timing values should be referenced from `ts/constants.ts` - **Actions**: Use `IActionConfig` from `ts/actions/base-action.ts` for action configuration -- **Config version**: Currently `4.2`, migrations run automatically +- **Action orchestration**: Use helpers from `ts/action-orchestration.ts` for action context and + execution decisions +- **Config watch logic**: Use helpers from `ts/config-watch.ts` for file event filtering and reload + transitions +- **Pause state**: Use `loadPauseSnapshot()` and `IPauseState` from `ts/pause-state.ts` +- **Shutdown execution**: Use `ShutdownExecutor` for OS-level shutdown command lookup and fallbacks +- **Shutdown monitoring**: Use helpers from `ts/shutdown-monitoring.ts` for emergency loop rows and + candidate selection +- **UPS status state**: Use `IUpsStatus` and `createInitialUpsStatus()` from `ts/ups-status.ts` +- **UPS poll transitions**: Use helpers from `ts/ups-monitoring.ts` for success/failure updates +- **Config version**: Currently `4.3`, migrations run automatically ## File Organization ``` ts/ ├── constants.ts # All timing/threshold constants +├── action-orchestration.ts # Action context and execution decisions +├── config-watch.ts # File watch filters and config reload transitions +├── shutdown-monitoring.ts # Shutdown loop rows and emergency selection +├── ups-monitoring.ts # Pure UPS poll transition and threshold helpers +├── pause-state.ts # Shared pause state types and transition detection +├── shutdown-executor.ts # Delayed/emergency shutdown command execution +├── ups-status.ts # Daemon UPS status shape and initializer ├── interfaces/ │ └── nupst-accessor.ts # Interface to break circular deps ├── helpers/ @@ -103,7 +164,7 @@ ts/ │ └── index.ts ├── migrations/ │ ├── migration-runner.ts -│ └── migration-v4.1-to-v4.2.ts # Adds protocol field +│ └── migration-v4.2-to-v4.3.ts # Adds SNMP runtimeUnit defaults └── cli/ └── ... # All handlers use helpers.withPrompt() ``` diff --git a/test/test.showcase.ts b/test/test.showcase.ts index 73c6def..ffcb631 100644 --- a/test/test.showcase.ts +++ b/test/test.showcase.ts @@ -229,10 +229,10 @@ console.log(''); // === 10. Update Available Example === logger.logBoxTitle('Update Available', 70, 'warning'); logger.logBoxLine(''); -logger.logBoxLine(`Current Version: ${theme.dim('4.0.1')}`); -logger.logBoxLine(`Latest Version: ${theme.highlight('4.0.2')}`); +logger.logBoxLine(`Current Version: ${theme.dim('5.5.0')}`); +logger.logBoxLine(`Latest Version: ${theme.highlight('5.5.1')}`); logger.logBoxLine(''); -logger.logBoxLine(`Run ${theme.command('sudo nupst update')} to update`); +logger.logBoxLine(`Run ${theme.command('sudo nupst upgrade')} to update`); logger.logBoxLine(''); logger.logBoxEnd(); diff --git a/test/test.ts b/test/test.ts index e556e49..1a88017 100644 --- a/test/test.ts +++ b/test/test.ts @@ -2,9 +2,27 @@ import { assert, assertEquals, assertExists } from 'jsr:@std/assert@^1.0.0'; import { NupstSnmp } from '../ts/snmp/manager.ts'; import { UpsOidSets } from '../ts/snmp/oid-sets.ts'; import type { IOidSet, ISnmpConfig, TUpsModel } from '../ts/snmp/types.ts'; +import { + analyzeConfigReload, + shouldRefreshPauseState, + shouldReloadConfig, +} from '../ts/config-watch.ts'; +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 { + buildShutdownErrorRow, + buildShutdownStatusRow, + selectEmergencyCandidate, +} from '../ts/shutdown-monitoring.ts'; +import { + buildFailedUpsPollSnapshot, + buildSuccessfulUpsPollSnapshot, + hasThresholdViolation, +} from '../ts/ups-monitoring.ts'; +import { createInitialUpsStatus } from '../ts/ups-status.ts'; import * as qenv from 'npm:@push.rocks/qenv@^6.0.0'; const testQenv = new qenv.Qenv('./', '.nogit/'); @@ -82,6 +100,418 @@ Deno.test('UI constants: box widths are ascending', () => { assert(UI.WIDE_BOX_WIDTH < UI.EXTRA_WIDE_BOX_WIDTH); }); +// ----------------------------------------------------------------------------- +// Pause State Tests +// ----------------------------------------------------------------------------- + +Deno.test('loadPauseSnapshot: reports paused state for valid pause file', async () => { + const tempDir = await Deno.makeTempDir(); + const pauseFilePath = `${tempDir}/pause.json`; + const pauseState: IPauseState = { + pausedAt: 1000, + pausedBy: 'cli', + reason: 'maintenance', + resumeAt: 5000, + }; + + try { + await Deno.writeTextFile(pauseFilePath, JSON.stringify(pauseState)); + + const snapshot = loadPauseSnapshot(pauseFilePath, false, 2000); + + assertEquals(snapshot.isPaused, true); + assertEquals(snapshot.pauseState, pauseState); + assertEquals(snapshot.transition, 'paused'); + } finally { + await Deno.remove(tempDir, { recursive: true }); + } +}); + +Deno.test('loadPauseSnapshot: auto-resumes expired pause file', async () => { + const tempDir = await Deno.makeTempDir(); + const pauseFilePath = `${tempDir}/pause.json`; + const pauseState: IPauseState = { + pausedAt: 1000, + pausedBy: 'cli', + resumeAt: 1500, + }; + + try { + await Deno.writeTextFile(pauseFilePath, JSON.stringify(pauseState)); + + const snapshot = loadPauseSnapshot(pauseFilePath, true, 2000); + + assertEquals(snapshot.isPaused, false); + assertEquals(snapshot.pauseState, null); + assertEquals(snapshot.transition, 'autoResumed'); + + let fileExists = true; + try { + await Deno.stat(pauseFilePath); + } catch { + fileExists = false; + } + assertEquals(fileExists, false); + } finally { + await Deno.remove(tempDir, { recursive: true }); + } +}); + +Deno.test('loadPauseSnapshot: reports resumed when pause file disappears', async () => { + const tempDir = await Deno.makeTempDir(); + + try { + const snapshot = loadPauseSnapshot(`${tempDir}/pause.json`, true, 2000); + + assertEquals(snapshot.isPaused, false); + assertEquals(snapshot.pauseState, null); + assertEquals(snapshot.transition, 'resumed'); + } finally { + await Deno.remove(tempDir, { recursive: true }); + } +}); + +// ----------------------------------------------------------------------------- +// Config Watch Tests +// ----------------------------------------------------------------------------- + +Deno.test('shouldReloadConfig: matches modify events for config.json', () => { + assertEquals( + shouldReloadConfig({ kind: 'modify', paths: ['/etc/nupst/config.json'] }), + true, + ); + assertEquals( + shouldReloadConfig({ kind: 'create', paths: ['/etc/nupst/config.json'] }), + false, + ); + assertEquals( + shouldReloadConfig({ kind: 'modify', paths: ['/etc/nupst/other.json'] }), + false, + ); +}); + +Deno.test('shouldRefreshPauseState: matches create/modify/remove pause events', () => { + assertEquals( + shouldRefreshPauseState({ kind: 'create', paths: ['/etc/nupst/pause'] }), + true, + ); + assertEquals( + shouldRefreshPauseState({ kind: 'remove', paths: ['/etc/nupst/pause'] }), + true, + ); + assertEquals( + shouldRefreshPauseState({ kind: 'modify', paths: ['/etc/nupst/config.json'] }), + false, + ); +}); + +Deno.test('analyzeConfigReload: detects monitoring start and device count changes', () => { + assertEquals(analyzeConfigReload(0, 2), { + transition: 'monitoringWillStart', + message: 'Configuration reloaded! Found 2 UPS device(s)', + shouldInitializeUpsStatus: false, + shouldLogMonitoringStart: true, + }); + + assertEquals(analyzeConfigReload(2, 3), { + transition: 'deviceCountChanged', + message: 'Configuration reloaded! UPS devices: 2 -> 3', + shouldInitializeUpsStatus: true, + shouldLogMonitoringStart: false, + }); + + assertEquals(analyzeConfigReload(2, 2), { + transition: 'reloaded', + message: 'Configuration reloaded successfully', + shouldInitializeUpsStatus: false, + shouldLogMonitoringStart: false, + }); +}); + +// ----------------------------------------------------------------------------- +// UPS Status Tests +// ----------------------------------------------------------------------------- + +Deno.test('createInitialUpsStatus: creates default daemon UPS status shape', () => { + assertEquals(createInitialUpsStatus({ id: 'ups-1', name: 'Main UPS' }, 1234), { + id: 'ups-1', + name: 'Main UPS', + powerStatus: 'unknown', + batteryCapacity: 100, + batteryRuntime: 999, + outputLoad: 0, + outputPower: 0, + outputVoltage: 0, + outputCurrent: 0, + lastStatusChange: 1234, + lastCheckTime: 0, + consecutiveFailures: 0, + unreachableSince: 0, + }); +}); + +// ----------------------------------------------------------------------------- +// Action Orchestration Tests +// ----------------------------------------------------------------------------- + +Deno.test('buildUpsActionContext: includes previous power status and timestamp', () => { + const status = { + ...createInitialUpsStatus({ id: 'ups-1', name: 'Main UPS' }, 1000), + powerStatus: 'onBattery' as const, + batteryCapacity: 42, + batteryRuntime: 15, + }; + const previousStatus = { + ...createInitialUpsStatus({ id: 'ups-1', name: 'Main UPS' }, 500), + powerStatus: 'online' as const, + }; + + assertEquals( + buildUpsActionContext( + { id: 'ups-1', name: 'Main UPS' }, + status, + previousStatus, + 'thresholdViolation', + 9999, + ), + { + upsId: 'ups-1', + upsName: 'Main UPS', + powerStatus: 'onBattery', + batteryCapacity: 42, + batteryRuntime: 15, + previousPowerStatus: 'online', + timestamp: 9999, + triggerReason: 'thresholdViolation', + }, + ); +}); + +Deno.test('decideUpsActionExecution: suppresses actions while paused', () => { + const decision = decideUpsActionExecution( + true, + { id: 'ups-1', name: 'Main UPS', actions: [{ type: 'shutdown' }] }, + createInitialUpsStatus({ id: 'ups-1', name: 'Main UPS' }, 1000), + undefined, + 'powerStatusChange', + 9999, + ); + + assertEquals(decision, { + type: 'suppressed', + message: '[PAUSED] Actions suppressed for UPS Main UPS (trigger: powerStatusChange)', + }); +}); + +Deno.test('decideUpsActionExecution: falls back to legacy shutdown without actions', () => { + const decision = decideUpsActionExecution( + false, + { id: 'ups-1', name: 'Main UPS' }, + createInitialUpsStatus({ id: 'ups-1', name: 'Main UPS' }, 1000), + undefined, + 'thresholdViolation', + 9999, + ); + + assertEquals(decision, { + type: 'legacyShutdown', + reason: 'UPS "Main UPS" battery or runtime below threshold', + }); +}); + +Deno.test('decideUpsActionExecution: returns executable action plan when actions exist', () => { + const decision = decideUpsActionExecution( + false, + { id: 'ups-1', name: 'Main UPS', actions: [{ type: 'shutdown' }] }, + { + ...createInitialUpsStatus({ id: 'ups-1', name: 'Main UPS' }, 1000), + powerStatus: 'onBattery', + batteryCapacity: 55, + batteryRuntime: 18, + }, + { + ...createInitialUpsStatus({ id: 'ups-1', name: 'Main UPS' }, 500), + powerStatus: 'online', + }, + 'powerStatusChange', + 9999, + ); + + assertEquals(decision, { + type: 'execute', + actions: [{ type: 'shutdown' }], + context: { + upsId: 'ups-1', + upsName: 'Main UPS', + powerStatus: 'onBattery', + batteryCapacity: 55, + batteryRuntime: 18, + previousPowerStatus: 'online', + timestamp: 9999, + triggerReason: 'powerStatusChange', + }, + }); +}); + +// ----------------------------------------------------------------------------- +// Shutdown Monitoring Tests +// ----------------------------------------------------------------------------- + +Deno.test('buildShutdownStatusRow: marks critical rows below emergency runtime threshold', () => { + const snapshot = buildShutdownStatusRow( + 'Main UPS', + { + powerStatus: 'onBattery', + batteryCapacity: 25, + batteryRuntime: 4, + outputLoad: 15, + outputPower: 100, + outputVoltage: 230, + outputCurrent: 0.4, + raw: {}, + }, + 5, + { + battery: (value) => `B:${value}`, + runtime: (value) => `R:${value}`, + ok: (text) => `ok:${text}`, + critical: (text) => `critical:${text}`, + error: (text) => `error:${text}`, + }, + ); + + assertEquals(snapshot.isCritical, true); + assertEquals(snapshot.row, { + name: 'Main UPS', + battery: 'B:25', + runtime: 'R:4', + status: 'critical:CRITICAL!', + }); +}); + +Deno.test('buildShutdownErrorRow: builds shutdown error table row', () => { + assertEquals(buildShutdownErrorRow('Main UPS', (text) => `error:${text}`), { + name: 'Main UPS', + battery: 'error:N/A', + runtime: 'error:N/A', + status: 'error:ERROR', + }); +}); + +Deno.test('selectEmergencyCandidate: keeps first critical UPS candidate', () => { + const firstCandidate = selectEmergencyCandidate( + null, + { id: 'ups-1', name: 'UPS 1' }, + { + powerStatus: 'onBattery', + batteryCapacity: 40, + batteryRuntime: 4, + outputLoad: 10, + outputPower: 60, + outputVoltage: 230, + outputCurrent: 0.3, + raw: {}, + }, + 5, + ); + + const secondCandidate = selectEmergencyCandidate( + firstCandidate, + { id: 'ups-2', name: 'UPS 2' }, + { + powerStatus: 'onBattery', + batteryCapacity: 30, + batteryRuntime: 3, + outputLoad: 15, + outputPower: 70, + outputVoltage: 230, + outputCurrent: 0.4, + raw: {}, + }, + 5, + ); + + assertEquals(secondCandidate, firstCandidate); +}); + +// ----------------------------------------------------------------------------- +// UPS Monitoring Tests +// ----------------------------------------------------------------------------- + +Deno.test('buildSuccessfulUpsPollSnapshot: marks recovery from unreachable', () => { + const currentStatus = { + ...createInitialUpsStatus({ id: 'ups-1', name: 'Main UPS' }, 1000), + powerStatus: 'unreachable' as const, + unreachableSince: 2000, + consecutiveFailures: 3, + }; + + const snapshot = buildSuccessfulUpsPollSnapshot( + { id: 'ups-1', name: 'Main UPS' }, + { + powerStatus: 'online', + batteryCapacity: 95, + batteryRuntime: 40, + outputLoad: 10, + outputPower: 50, + outputVoltage: 230, + outputCurrent: 0.5, + raw: {}, + }, + currentStatus, + 8000, + ); + + assertEquals(snapshot.transition, 'recovered'); + assertEquals(snapshot.downtimeSeconds, 6); + assertEquals(snapshot.updatedStatus.powerStatus, 'online'); + assertEquals(snapshot.updatedStatus.consecutiveFailures, 0); + assertEquals(snapshot.updatedStatus.lastStatusChange, 8000); +}); + +Deno.test('buildFailedUpsPollSnapshot: marks UPS unreachable at failure threshold', () => { + const currentStatus = { + ...createInitialUpsStatus({ id: 'ups-1', name: 'Main UPS' }, 1000), + powerStatus: 'onBattery' as const, + consecutiveFailures: 2, + }; + + const snapshot = buildFailedUpsPollSnapshot( + { id: 'ups-1', name: 'Main UPS' }, + currentStatus, + 9000, + ); + + assertEquals(snapshot.transition, 'unreachable'); + assertEquals(snapshot.failures, 3); + assertEquals(snapshot.updatedStatus.powerStatus, 'unreachable'); + assertEquals(snapshot.updatedStatus.unreachableSince, 9000); + assertEquals(snapshot.updatedStatus.lastStatusChange, 9000); +}); + +Deno.test('hasThresholdViolation: only fires on battery when any action threshold is exceeded', () => { + assertEquals( + hasThresholdViolation('online', 40, 10, [ + { type: 'shutdown', thresholds: { battery: 50, runtime: 20 } }, + ]), + false, + ); + + assertEquals( + hasThresholdViolation('onBattery', 40, 10, [ + { type: 'shutdown', thresholds: { battery: 50, runtime: 20 } }, + ]), + true, + ); + + assertEquals( + hasThresholdViolation('onBattery', 90, 60, [ + { type: 'shutdown', thresholds: { battery: 50, runtime: 20 } }, + ]), + false, + ); +}); + // ----------------------------------------------------------------------------- // UpsOidSets Tests // ----------------------------------------------------------------------------- diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index babb620..c8873fd 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.0', + version: '5.5.1', 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 new file mode 100644 index 0000000..c98706b --- /dev/null +++ b/ts/action-orchestration.ts @@ -0,0 +1,70 @@ +import type { IActionConfig, IActionContext, TPowerStatus } from './actions/base-action.ts'; +import type { IUpsStatus } from './ups-status.ts'; + +export interface IUpsActionSource { + id: string; + name: string; + actions?: IActionConfig[]; +} + +export type TUpsTriggerReason = IActionContext['triggerReason']; + +export type TActionExecutionDecision = + | { type: 'suppressed'; message: string } + | { type: 'legacyShutdown'; reason: string } + | { type: 'skip' } + | { type: 'execute'; actions: IActionConfig[]; context: IActionContext }; + +export function buildUpsActionContext( + ups: IUpsActionSource, + status: IUpsStatus, + previousStatus: IUpsStatus | undefined, + triggerReason: TUpsTriggerReason, + timestamp: number = Date.now(), +): IActionContext { + return { + upsId: ups.id, + upsName: ups.name, + powerStatus: status.powerStatus as TPowerStatus, + batteryCapacity: status.batteryCapacity, + batteryRuntime: status.batteryRuntime, + previousPowerStatus: (previousStatus?.powerStatus || 'unknown') as TPowerStatus, + timestamp, + triggerReason, + }; +} + +export function decideUpsActionExecution( + isPaused: boolean, + ups: IUpsActionSource, + status: IUpsStatus, + previousStatus: IUpsStatus | undefined, + triggerReason: TUpsTriggerReason, + timestamp: number = Date.now(), +): TActionExecutionDecision { + if (isPaused) { + return { + type: 'suppressed', + message: `[PAUSED] Actions suppressed for UPS ${ups.name} (trigger: ${triggerReason})`, + }; + } + + const actions = ups.actions || []; + + if (actions.length === 0 && triggerReason === 'thresholdViolation') { + return { + type: 'legacyShutdown', + reason: `UPS "${ups.name}" battery or runtime below threshold`, + }; + } + + if (actions.length === 0) { + return { type: 'skip' }; + } + + return { + type: 'execute', + actions, + context: buildUpsActionContext(ups, status, previousStatus, triggerReason, timestamp), + }; +} diff --git a/ts/cli.ts b/ts/cli.ts index a01a8f1..daef931 100644 --- a/ts/cli.ts +++ b/ts/cli.ts @@ -19,7 +19,7 @@ export class NupstCli { /** * Parse command line arguments and execute the appropriate command - * @param args Command line arguments (process.argv) + * @param args Command line arguments excluding runtime and script path */ public async parseAndExecute(args: string[]): Promise { // Extract debug and version flags from any position @@ -38,8 +38,8 @@ export class NupstCli { } // Get the command (default to help if none provided) - const command = debugOptions.cleanedArgs[2] || 'help'; - const commandArgs = debugOptions.cleanedArgs.slice(3); + const command = debugOptions.cleanedArgs[0] || 'help'; + const commandArgs = debugOptions.cleanedArgs.slice(1); // Route to the appropriate command handler await this.executeCommand(command, commandArgs, debugOptions.debugMode); @@ -98,7 +98,7 @@ export class NupstCli { await serviceHandler.start(); break; case 'status': - await serviceHandler.status(); + await serviceHandler.status(debugMode); break; case 'logs': await serviceHandler.logs(); diff --git a/ts/cli/group-handler.ts b/ts/cli/group-handler.ts index ebb8de2..26ed1ac 100644 --- a/ts/cli/group-handler.ts +++ b/ts/cli/group-handler.ts @@ -124,7 +124,7 @@ export class GroupHandler { await this.nupst.getDaemon().loadConfig(); } catch (error) { logger.error( - 'No configuration found. Please run "nupst setup" first to create a configuration.', + 'No configuration found. Please run "nupst ups add" first to create a configuration.', ); return; } @@ -219,7 +219,7 @@ export class GroupHandler { await this.nupst.getDaemon().loadConfig(); } catch (error) { logger.error( - 'No configuration found. Please run "nupst setup" first to create a configuration.', + 'No configuration found. Please run "nupst ups add" first to create a configuration.', ); return; } @@ -316,7 +316,7 @@ export class GroupHandler { await this.nupst.getDaemon().loadConfig(); } catch (error) { logger.error( - 'No configuration found. Please run "nupst setup" first to create a configuration.', + 'No configuration found. Please run "nupst ups add" first to create a configuration.', ); return; } @@ -484,7 +484,7 @@ export class GroupHandler { prompt: (question: string) => Promise, ): Promise { if (!config.upsDevices || config.upsDevices.length === 0) { - logger.log('No UPS devices available. Use "nupst add" to add UPS devices.'); + logger.log('No UPS devices available. Use "nupst ups add" to add UPS devices.'); return; } diff --git a/ts/cli/service-handler.ts b/ts/cli/service-handler.ts index 669d7d6..dfc0f94 100644 --- a/ts/cli/service-handler.ts +++ b/ts/cli/service-handler.ts @@ -6,7 +6,7 @@ import { Nupst } from '../nupst.ts'; import { logger } from '../logger.ts'; import { theme } from '../colors.ts'; import { PAUSE } from '../constants.ts'; -import type { IPauseState } from '../daemon.ts'; +import type { IPauseState } from '../pause-state.ts'; import * as helpers from '../helpers/index.ts'; /** @@ -30,7 +30,9 @@ export class ServiceHandler { public async enable(): Promise { this.checkRootAccess('This command must be run as root.'); await this.nupst.getSystemd().install(); - logger.log('NUPST service has been installed. Use "nupst start" to start the service.'); + logger.log( + 'NUPST service has been installed. Use "nupst service start" to start the service.', + ); } /** @@ -103,10 +105,8 @@ export class ServiceHandler { /** * Show status of the systemd service and UPS */ - public async status(): Promise { - // Extract debug options from args array - const debugOptions = this.extractDebugOptions(process.argv); - await this.nupst.getSystemd().getStatus(debugOptions.debugMode); + public async status(debugMode: boolean = false): Promise { + await this.nupst.getSystemd().getStatus(debugMode); } /** @@ -221,10 +221,14 @@ export class ServiceHandler { const unit = match[2].toLowerCase(); switch (unit) { - case 'm': return value * 60 * 1000; - case 'h': return value * 60 * 60 * 1000; - case 'd': return value * 24 * 60 * 60 * 1000; - default: return null; + case 'm': + return value * 60 * 1000; + case 'h': + return value * 60 * 60 * 1000; + case 'd': + return value * 24 * 60 * 60 * 1000; + default: + return null; } } @@ -398,17 +402,4 @@ export class ServiceHandler { process.exit(1); } } - - /** - * Extract and remove debug options from args array - * @param args Command line arguments - * @returns Object with debug flags and cleaned args - */ - private extractDebugOptions(args: string[]): { debugMode: boolean; cleanedArgs: string[] } { - const debugMode = args.includes('--debug') || args.includes('-d'); - // Remove debug flags from args - const cleanedArgs = args.filter((arg) => arg !== '--debug' && arg !== '-d'); - - return { debugMode, cleanedArgs }; - } } diff --git a/ts/cli/ups-handler.ts b/ts/cli/ups-handler.ts index 8d504bb..9fd67a2 100644 --- a/ts/cli/ups-handler.ts +++ b/ts/cli/ups-handler.ts @@ -103,7 +103,15 @@ export class UpsHandler { const protocol: TProtocol = protocolChoice === 2 ? 'upsd' : 'snmp'; // Create a new UPS configuration object with defaults - const newUps: Record & { id: string; name: string; groups: string[]; actions: IActionConfig[]; protocol: TProtocol; snmp?: ISnmpConfig; upsd?: IUpsdConfig } = { + const newUps: Record & { + id: string; + name: string; + groups: string[]; + actions: IActionConfig[]; + protocol: TProtocol; + snmp?: ISnmpConfig; + upsd?: IUpsdConfig; + } = { id: upsId, name: name || `UPS-${upsId}`, protocol, @@ -203,7 +211,7 @@ export class UpsHandler { return; } else { // For specific UPS ID, error if config doesn't exist - logger.error('No configuration found. Please run "nupst setup" first.'); + logger.error('No configuration found. Please run "nupst ups add" first.'); return; } } @@ -242,7 +250,7 @@ export class UpsHandler { } else { // For backward compatibility, edit the first UPS if no ID specified if (config.upsDevices.length === 0) { - logger.error('No UPS devices configured. Please run "nupst add" to add a UPS.'); + logger.error('No UPS devices configured. Please run "nupst ups add" to add a UPS.'); return; } upsToEdit = config.upsDevices[0]; @@ -261,7 +269,9 @@ export class UpsHandler { logger.info(`Current Protocol: ${currentProtocol.toUpperCase()}`); logger.dim(' 1) SNMP (network UPS with SNMP agent)'); logger.dim(' 2) UPSD/NIS (local NUT server, e.g. USB-connected UPS)'); - const protocolInput = await prompt(`Select protocol [${currentProtocol === 'upsd' ? '2' : '1'}]: `); + const protocolInput = await prompt( + `Select protocol [${currentProtocol === 'upsd' ? '2' : '1'}]: `, + ); const protocolChoice = parseInt(protocolInput, 10); if (protocolChoice === 2) { upsToEdit.protocol = 'upsd'; @@ -348,7 +358,7 @@ export class UpsHandler { const errorBoxWidth = 45; logger.logBoxTitle('Configuration Error', errorBoxWidth); logger.logBoxLine('No configuration found.'); - logger.logBoxLine("Please run 'nupst setup' first to create a configuration."); + logger.logBoxLine("Please run 'nupst ups add' first to create a configuration."); logger.logBoxEnd(); return; } @@ -359,7 +369,7 @@ export class UpsHandler { // Check if multi-UPS config if (!config.upsDevices || !Array.isArray(config.upsDevices)) { logger.error('Legacy single-UPS configuration detected. Cannot delete UPS.'); - logger.log('Use "nupst add" to migrate to multi-UPS configuration format first.'); + logger.log('Use "nupst ups add" to migrate to multi-UPS configuration format first.'); return; } @@ -527,7 +537,7 @@ export class UpsHandler { const errorBoxWidth = 45; logger.logBoxTitle('Configuration Error', errorBoxWidth); logger.logBoxLine('No configuration found.'); - logger.logBoxLine("Please run 'nupst setup' first to create a configuration."); + logger.logBoxLine("Please run 'nupst ups add' first to create a configuration."); logger.logBoxEnd(); return; } @@ -624,7 +634,9 @@ export class UpsHandler { logger.logBoxLine( ` Battery Capacity: ${snmpConfig.customOIDs.BATTERY_CAPACITY || 'Not set'}`, ); - logger.logBoxLine(` Battery Runtime: ${snmpConfig.customOIDs.BATTERY_RUNTIME || 'Not set'}`); + logger.logBoxLine( + ` Battery Runtime: ${snmpConfig.customOIDs.BATTERY_RUNTIME || 'Not set'}`, + ); } } @@ -650,7 +662,9 @@ export class UpsHandler { const upsId = isUpsConfig ? (config as IUpsConfig).id : 'default'; const upsName = isUpsConfig ? (config as IUpsConfig).name : 'Default UPS'; const protocol = isUpsConfig ? ((config as IUpsConfig).protocol || 'snmp') : 'snmp'; - logger.log(`\nTesting connection to UPS: ${upsName} (${upsId}) via ${protocol.toUpperCase()}...`); + logger.log( + `\nTesting connection to UPS: ${upsName} (${upsId}) via ${protocol.toUpperCase()}...`, + ); try { let status: ISnmpUpsStatus; @@ -691,7 +705,9 @@ export class UpsHandler { logger.logBoxTitle(`Connection Failed: ${upsName}`, errorBoxWidth); logger.logBoxLine(`Error: ${error instanceof Error ? error.message : String(error)}`); logger.logBoxEnd(); - logger.log("\nPlease check your settings and run 'nupst edit' to reconfigure this UPS."); + logger.log( + `\nPlease check your settings and run 'nupst ups edit ${upsId}' to reconfigure this UPS.`, + ); } } @@ -1239,7 +1255,8 @@ export class UpsHandler { // Common Proxmox settings (both modes) const excludeInput = await prompt('VM/CT IDs to exclude (comma-separated, or empty): '); if (excludeInput.trim()) { - action.proxmoxExcludeIds = excludeInput.split(',').map((s) => parseInt(s.trim(), 10)).filter((n) => !isNaN(n)); + action.proxmoxExcludeIds = excludeInput.split(',').map((s) => parseInt(s.trim(), 10)) + .filter((n) => !isNaN(n)); } const timeoutInput = await prompt('VM shutdown timeout in seconds [120]: '); @@ -1248,7 +1265,7 @@ export class UpsHandler { action.proxmoxStopTimeout = stopTimeout; } - const forceInput = await prompt('Force-stop VMs that don\'t shut down in time? (Y/n): '); + const forceInput = await prompt("Force-stop VMs that don't shut down in time? (Y/n): "); action.proxmoxForceStop = forceInput.toLowerCase() !== 'n'; logger.log(''); diff --git a/ts/config-watch.ts b/ts/config-watch.ts new file mode 100644 index 0000000..2614419 --- /dev/null +++ b/ts/config-watch.ts @@ -0,0 +1,58 @@ +export interface IWatchEventLike { + kind: string; + paths: string[]; +} + +export type TConfigReloadTransition = 'monitoringWillStart' | 'deviceCountChanged' | 'reloaded'; + +export interface IConfigReloadSnapshot { + transition: TConfigReloadTransition; + message: string; + shouldInitializeUpsStatus: boolean; + shouldLogMonitoringStart: boolean; +} + +export function shouldReloadConfig( + event: IWatchEventLike, + configFileName: string = 'config.json', +): boolean { + return event.kind === 'modify' && event.paths.some((path) => path.includes(configFileName)); +} + +export function shouldRefreshPauseState( + event: IWatchEventLike, + pauseFileName: string = 'pause', +): boolean { + return ['create', 'modify', 'remove'].includes(event.kind) && + event.paths.some((path) => path.includes(pauseFileName)); +} + +export function analyzeConfigReload( + oldDeviceCount: number, + newDeviceCount: number, +): IConfigReloadSnapshot { + if (newDeviceCount > 0 && oldDeviceCount === 0) { + return { + transition: 'monitoringWillStart', + message: `Configuration reloaded! Found ${newDeviceCount} UPS device(s)`, + shouldInitializeUpsStatus: false, + shouldLogMonitoringStart: true, + }; + } + + if (newDeviceCount !== oldDeviceCount) { + return { + transition: 'deviceCountChanged', + message: `Configuration reloaded! UPS devices: ${oldDeviceCount} -> ${newDeviceCount}`, + shouldInitializeUpsStatus: true, + shouldLogMonitoringStart: false, + }; + } + + return { + transition: 'reloaded', + message: 'Configuration reloaded successfully', + shouldInitializeUpsStatus: false, + shouldLogMonitoringStart: false, + }; +} diff --git a/ts/daemon.ts b/ts/daemon.ts index 01fcf7a..0ebca90 100644 --- a/ts/daemon.ts +++ b/ts/daemon.ts @@ -1,8 +1,6 @@ import process from 'node:process'; import * as fs from 'node:fs'; import * as path from 'node:path'; -import { exec, execFile } from 'node:child_process'; -import { promisify } from 'node:util'; import { NupstSnmp } from './snmp/manager.ts'; import type { ISnmpConfig, IUpsStatus as ISnmpUpsStatus } from './snmp/types.ts'; import { NupstUpsd } from './upsd/client.ts'; @@ -13,12 +11,29 @@ import { logger } from './logger.ts'; import { MigrationRunner } from './migrations/index.ts'; import { formatPowerStatus, getBatteryColor, getRuntimeColor, theme } from './colors.ts'; import type { IActionConfig } from './actions/base-action.ts'; -import { ActionManager, type IActionContext, type TPowerStatus } from './actions/index.ts'; +import { ActionManager } from './actions/index.ts'; +import { decideUpsActionExecution, type TUpsTriggerReason } from './action-orchestration.ts'; import { NupstHttpServer } from './http-server.ts'; import { NETWORK, PAUSE, THRESHOLDS, TIMING, UI } from './constants.ts'; - -const execAsync = promisify(exec); -const execFileAsync = promisify(execFile); +import { + analyzeConfigReload, + shouldRefreshPauseState, + shouldReloadConfig, +} from './config-watch.ts'; +import { type IPauseState, loadPauseSnapshot } from './pause-state.ts'; +import { ShutdownExecutor } from './shutdown-executor.ts'; +import { + buildFailedUpsPollSnapshot, + buildSuccessfulUpsPollSnapshot, + ensureUpsStatus, + hasThresholdViolation, +} from './ups-monitoring.ts'; +import { + buildShutdownErrorRow, + buildShutdownStatusRow, + selectEmergencyCandidate, +} from './shutdown-monitoring.ts'; +import { createInitialUpsStatus, type IUpsStatus } from './ups-status.ts'; /** * UPS configuration interface @@ -70,20 +85,6 @@ export interface IHttpServerConfig { authToken: string; } -/** - * Pause state interface - */ -export interface IPauseState { - /** Timestamp when pause was activated */ - pausedAt: number; - /** Who initiated the pause (e.g., 'cli', 'api') */ - pausedBy: string; - /** Optional reason for pausing */ - reason?: string; - /** When to auto-resume (null = indefinite, timestamp in ms) */ - resumeAt?: number | null; -} - /** * Configuration interface for the daemon */ @@ -113,25 +114,6 @@ export interface INupstConfig { }; } -/** - * UPS status tracking interface - */ -export interface IUpsStatus { - id: string; - name: string; - powerStatus: 'online' | 'onBattery' | 'unknown' | 'unreachable'; - batteryCapacity: number; - batteryRuntime: number; - outputLoad: number; // Load percentage (0-100%) - outputPower: number; // Power in watts - outputVoltage: number; // Voltage in volts - outputCurrent: number; // Current in amps - lastStatusChange: number; - lastCheckTime: number; - consecutiveFailures: number; - unreachableSince: number; -} - /** * Daemon class for monitoring UPS and handling shutdown * Responsible for loading/saving config and monitoring the UPS status @@ -191,6 +173,7 @@ export class NupstDaemon { private pauseState: IPauseState | null = null; private upsStatus: Map = new Map(); private httpServer?: NupstHttpServer; + private readonly shutdownExecutor: ShutdownExecutor; /** * Create a new daemon instance with the given protocol managers @@ -199,6 +182,7 @@ export class NupstDaemon { this.snmp = snmp; this.upsd = upsd; this.protocolResolver = new ProtocolResolver(snmp, upsd); + this.shutdownExecutor = new ShutdownExecutor(); this.config = this.DEFAULT_CONFIG; } @@ -283,7 +267,7 @@ export class NupstDaemon { private logConfigError(message: string): void { logger.logBox( 'Configuration Error', - [message, "Please run 'nupst setup' first to create a configuration."], + [message, "Please run 'nupst ups add' first to create a configuration."], 45, 'error', ); @@ -388,21 +372,7 @@ export class NupstDaemon { if (this.config.upsDevices && this.config.upsDevices.length > 0) { for (const ups of this.config.upsDevices) { - this.upsStatus.set(ups.id, { - id: ups.id, - name: ups.name, - powerStatus: 'unknown', - batteryCapacity: 100, - batteryRuntime: 999, // High value as default - outputLoad: 0, - outputPower: 0, - outputVoltage: 0, - outputCurrent: 0, - lastStatusChange: Date.now(), - lastCheckTime: 0, - consecutiveFailures: 0, - unreachableSince: 0, - }); + this.upsStatus.set(ups.id, createInitialUpsStatus(ups)); } logger.log(`Initialized status tracking for ${this.config.upsDevices.length} UPS devices`); @@ -507,66 +477,39 @@ export class NupstDaemon { * Check and update pause state from the pause file */ private checkPauseState(): void { - try { - if (fs.existsSync(PAUSE.FILE_PATH)) { - const data = fs.readFileSync(PAUSE.FILE_PATH, 'utf8'); - const state = JSON.parse(data) as IPauseState; + const snapshot = loadPauseSnapshot(PAUSE.FILE_PATH, this.isPaused); - // Check if auto-resume time has passed - if (state.resumeAt && Date.now() >= state.resumeAt) { - // Auto-resume: delete the pause file - try { - fs.unlinkSync(PAUSE.FILE_PATH); - } catch (_e) { - // Ignore deletion errors - } - if (this.isPaused) { - logger.log(''); - logger.logBoxTitle('Auto-Resume', 45, 'success'); - logger.logBoxLine('Pause duration expired, resuming action monitoring'); - logger.logBoxEnd(); - logger.log(''); - } - this.isPaused = false; - this.pauseState = null; - return; - } - - if (!this.isPaused) { - logger.log(''); - logger.logBoxTitle('Actions Paused', 45, 'warning'); - logger.logBoxLine(`Paused by: ${state.pausedBy}`); - if (state.reason) { - logger.logBoxLine(`Reason: ${state.reason}`); - } - if (state.resumeAt) { - const remaining = Math.round((state.resumeAt - Date.now()) / 1000); - logger.logBoxLine(`Auto-resume in: ${remaining} seconds`); - } else { - logger.logBoxLine('Duration: Indefinite (run "nupst resume" to resume)'); - } - logger.logBoxEnd(); - logger.log(''); - } - - this.isPaused = true; - this.pauseState = state; - } else { - if (this.isPaused) { - logger.log(''); - logger.logBoxTitle('Actions Resumed', 45, 'success'); - logger.logBoxLine('Action monitoring has been resumed'); - logger.logBoxEnd(); - logger.log(''); - } - this.isPaused = false; - this.pauseState = null; + if (snapshot.transition === 'autoResumed') { + logger.log(''); + logger.logBoxTitle('Auto-Resume', 45, 'success'); + logger.logBoxLine('Pause duration expired, resuming action monitoring'); + logger.logBoxEnd(); + logger.log(''); + } else if (snapshot.transition === 'paused' && snapshot.pauseState) { + logger.log(''); + logger.logBoxTitle('Actions Paused', 45, 'warning'); + logger.logBoxLine(`Paused by: ${snapshot.pauseState.pausedBy}`); + if (snapshot.pauseState.reason) { + logger.logBoxLine(`Reason: ${snapshot.pauseState.reason}`); } - } catch (_error) { - // If we can't read the pause file, assume not paused - this.isPaused = false; - this.pauseState = null; + if (snapshot.pauseState.resumeAt) { + const remaining = Math.round((snapshot.pauseState.resumeAt - Date.now()) / 1000); + logger.logBoxLine(`Auto-resume in: ${remaining} seconds`); + } else { + logger.logBoxLine('Duration: Indefinite (run "nupst resume" to resume)'); + } + logger.logBoxEnd(); + logger.log(''); + } else if (snapshot.transition === 'resumed') { + logger.log(''); + logger.logBoxTitle('Actions Resumed', 45, 'success'); + logger.logBoxLine('Action monitoring has been resumed'); + logger.logBoxEnd(); + logger.log(''); } + + this.isPaused = snapshot.isPaused; + this.pauseState = snapshot.pauseState; } /** @@ -619,25 +562,8 @@ export class NupstDaemon { private async checkAllUpsDevices(): Promise { for (const ups of this.config.upsDevices) { try { - const upsStatus = this.upsStatus.get(ups.id); - if (!upsStatus) { - // Initialize status for this UPS if not exists - this.upsStatus.set(ups.id, { - id: ups.id, - name: ups.name, - powerStatus: 'unknown', - batteryCapacity: 100, - batteryRuntime: 999, - outputLoad: 0, - outputPower: 0, - outputVoltage: 0, - outputCurrent: 0, - lastStatusChange: Date.now(), - lastCheckTime: 0, - consecutiveFailures: 0, - unreachableSince: 0, - }); - } + const initialStatus = ensureUpsStatus(this.upsStatus.get(ups.id), ups); + this.upsStatus.set(ups.id, initialStatus); // Check UPS status via configured protocol const protocol = ups.protocol || 'snmp'; @@ -646,129 +572,100 @@ export class NupstDaemon { : await this.protocolResolver.getUpsStatus('snmp', ups.snmp); const currentTime = Date.now(); - // Get the current status from the map const currentStatus = this.upsStatus.get(ups.id); + const pollSnapshot = buildSuccessfulUpsPollSnapshot( + ups, + status, + currentStatus, + currentTime, + ); - // Successful query: reset consecutive failures - const wasUnreachable = currentStatus?.powerStatus === 'unreachable'; - - // Update status with new values - const updatedStatus: IUpsStatus = { - id: ups.id, - name: ups.name, - powerStatus: status.powerStatus, - batteryCapacity: status.batteryCapacity, - batteryRuntime: status.batteryRuntime, - outputLoad: status.outputLoad, - outputPower: status.outputPower, - outputVoltage: status.outputVoltage, - outputCurrent: status.outputCurrent, - lastCheckTime: currentTime, - lastStatusChange: currentStatus?.lastStatusChange || currentTime, - consecutiveFailures: 0, - unreachableSince: 0, - }; - - // If UPS was unreachable and is now reachable, log recovery - if (wasUnreachable && currentStatus) { - const downtime = Math.round((currentTime - currentStatus.unreachableSince) / 1000); + if (pollSnapshot.transition === 'recovered' && pollSnapshot.previousStatus) { logger.log(''); logger.logBoxTitle(`UPS Recovered: ${ups.name}`, 60, 'success'); - logger.logBoxLine(`UPS is reachable again after ${downtime} seconds`); + logger.logBoxLine(`UPS is reachable again after ${pollSnapshot.downtimeSeconds} seconds`); logger.logBoxLine(`Current Status: ${formatPowerStatus(status.powerStatus)}`); logger.logBoxLine(`Time: ${new Date().toISOString()}`); logger.logBoxEnd(); logger.log(''); - updatedStatus.lastStatusChange = currentTime; - // Trigger power status change action for recovery - await this.triggerUpsActions(ups, updatedStatus, currentStatus, 'powerStatusChange'); - } else if (currentStatus && currentStatus.powerStatus !== status.powerStatus) { - // Check if power status changed + await this.triggerUpsActions( + ups, + pollSnapshot.updatedStatus, + pollSnapshot.previousStatus, + 'powerStatusChange', + ); + } else if (pollSnapshot.transition === 'powerStatusChange' && pollSnapshot.previousStatus) { logger.log(''); logger.logBoxTitle(`Power Status Change: ${ups.name}`, 60, 'warning'); - logger.logBoxLine(`Previous: ${formatPowerStatus(currentStatus.powerStatus)}`); + logger.logBoxLine( + `Previous: ${formatPowerStatus(pollSnapshot.previousStatus.powerStatus)}`, + ); logger.logBoxLine(`Current: ${formatPowerStatus(status.powerStatus)}`); logger.logBoxLine(`Time: ${new Date().toISOString()}`); logger.logBoxEnd(); logger.log(''); - updatedStatus.lastStatusChange = currentTime; - // Trigger actions for power status change - await this.triggerUpsActions(ups, updatedStatus, currentStatus, 'powerStatusChange'); + await this.triggerUpsActions( + ups, + pollSnapshot.updatedStatus, + pollSnapshot.previousStatus, + 'powerStatusChange', + ); } - // Check if any action's thresholds are exceeded (for threshold violation triggers) - // Only check when on battery power - if (status.powerStatus === 'onBattery' && ups.actions && ups.actions.length > 0) { - let anyThresholdExceeded = false; - - for (const actionConfig of ups.actions) { - if (actionConfig.thresholds) { - if ( - status.batteryCapacity < actionConfig.thresholds.battery || - status.batteryRuntime < actionConfig.thresholds.runtime - ) { - anyThresholdExceeded = true; - break; - } - } - } - - // Trigger actions with threshold violation reason if any threshold is exceeded - // Actions will individually check their own thresholds in shouldExecute() - if (anyThresholdExceeded) { - await this.triggerUpsActions(ups, updatedStatus, currentStatus, 'thresholdViolation'); - } + if ( + hasThresholdViolation( + status.powerStatus, + status.batteryCapacity, + status.batteryRuntime, + ups.actions, + ) + ) { + await this.triggerUpsActions( + ups, + pollSnapshot.updatedStatus, + pollSnapshot.previousStatus, + 'thresholdViolation', + ); } // Update the status in the map - this.upsStatus.set(ups.id, updatedStatus); + this.upsStatus.set(ups.id, pollSnapshot.updatedStatus); } catch (error) { - // Network loss / query failure tracking + const currentTime = Date.now(); const currentStatus = this.upsStatus.get(ups.id); - const failures = Math.min( - (currentStatus?.consecutiveFailures || 0) + 1, - NETWORK.MAX_CONSECUTIVE_FAILURES, - ); + const failureSnapshot = buildFailedUpsPollSnapshot(ups, currentStatus, currentTime); logger.error( - `Error checking UPS ${ups.name} (${ups.id}) [failure ${failures}/${NETWORK.CONSECUTIVE_FAILURE_THRESHOLD}]: ${ + `Error checking UPS ${ups.name} (${ups.id}) [failure ${failureSnapshot.failures}/${NETWORK.CONSECUTIVE_FAILURE_THRESHOLD}]: ${ error instanceof Error ? error.message : String(error) }`, ); - // Transition to unreachable after threshold consecutive failures - if ( - failures >= NETWORK.CONSECUTIVE_FAILURE_THRESHOLD && - currentStatus && - currentStatus.powerStatus !== 'unreachable' - ) { - const currentTime = Date.now(); - const previousStatus = { ...currentStatus }; - - currentStatus.powerStatus = 'unreachable'; - currentStatus.consecutiveFailures = failures; - currentStatus.unreachableSince = currentTime; - currentStatus.lastStatusChange = currentTime; - this.upsStatus.set(ups.id, currentStatus); - + if (failureSnapshot.transition === 'unreachable' && failureSnapshot.previousStatus) { logger.log(''); logger.logBoxTitle(`UPS Unreachable: ${ups.name}`, 60, 'error'); - logger.logBoxLine(`${failures} consecutive communication failures`); - logger.logBoxLine(`Last known status: ${formatPowerStatus(previousStatus.powerStatus)}`); + logger.logBoxLine(`${failureSnapshot.failures} consecutive communication failures`); + logger.logBoxLine( + `Last known status: ${formatPowerStatus(failureSnapshot.previousStatus.powerStatus)}`, + ); logger.logBoxLine(`Time: ${new Date().toISOString()}`); logger.logBoxEnd(); logger.log(''); // Trigger power status change action for unreachable - await this.triggerUpsActions(ups, currentStatus, previousStatus, 'powerStatusChange'); - } else if (currentStatus) { - currentStatus.consecutiveFailures = failures; - this.upsStatus.set(ups.id, currentStatus); + await this.triggerUpsActions( + ups, + failureSnapshot.updatedStatus, + failureSnapshot.previousStatus, + 'powerStatusChange', + ); } + + this.upsStatus.set(ups.id, failureSnapshot.updatedStatus); } } } @@ -781,7 +678,11 @@ export class NupstDaemon { logger.log(''); const pauseLabel = this.isPaused ? ' [PAUSED]' : ''; - logger.logBoxTitle(`Periodic Status Update${pauseLabel}`, 70, this.isPaused ? 'warning' : 'info'); + logger.logBoxTitle( + `Periodic Status Update${pauseLabel}`, + 70, + this.isPaused ? 'warning' : 'info', + ); logger.logBoxLine(`Timestamp: ${timestamp}`); if (this.isPaused && this.pauseState) { logger.logBoxLine(`Actions paused by: ${this.pauseState.pausedBy}`); @@ -822,30 +723,6 @@ export class NupstDaemon { logger.log(''); } - /** - * Build action context from UPS state - * @param ups UPS configuration - * @param status Current UPS status - * @param triggerReason Why this action is being triggered - * @returns Action context - */ - private buildActionContext( - ups: IUpsConfig, - status: IUpsStatus, - triggerReason: 'powerStatusChange' | 'thresholdViolation', - ): IActionContext { - return { - upsId: ups.id, - upsName: ups.name, - powerStatus: status.powerStatus as TPowerStatus, - batteryCapacity: status.batteryCapacity, - batteryRuntime: status.batteryRuntime, - previousPowerStatus: 'unknown' as TPowerStatus, // Will be set from map in calling code - timestamp: Date.now(), - triggerReason, - }; - } - /** * Trigger actions for a UPS device * @param ups UPS configuration @@ -857,35 +734,31 @@ export class NupstDaemon { ups: IUpsConfig, status: IUpsStatus, previousStatus: IUpsStatus | undefined, - triggerReason: 'powerStatusChange' | 'thresholdViolation', + triggerReason: TUpsTriggerReason, ): Promise { - // Check if actions are paused - if (this.isPaused) { - logger.info( - `[PAUSED] Actions suppressed for UPS ${ups.name} (trigger: ${triggerReason})`, - ); + const decision = decideUpsActionExecution( + this.isPaused, + ups, + status, + previousStatus, + triggerReason, + ); + + if (decision.type === 'suppressed') { + logger.info(decision.message); return; } - const actions = ups.actions || []; - - // Backward compatibility: if no actions configured, use default shutdown behavior - if (actions.length === 0 && triggerReason === 'thresholdViolation') { - // Fall back to old shutdown logic for backward compatibility - await this.initiateShutdown(`UPS "${ups.name}" battery or runtime below threshold`); + if (decision.type === 'legacyShutdown') { + await this.initiateShutdown(decision.reason); return; } - if (actions.length === 0) { - return; // No actions to execute + if (decision.type === 'skip') { + return; } - // Build action context - const context = this.buildActionContext(ups, status, triggerReason); - context.previousPowerStatus = (previousStatus?.powerStatus || 'unknown') as TPowerStatus; - - // Execute actions - await ActionManager.executeActions(actions, context); + await ActionManager.executeActions(decision.actions, decision.context); } /** @@ -899,56 +772,8 @@ export class NupstDaemon { const shutdownDelayMinutes = 5; try { - // Find shutdown command in common system paths - const shutdownPaths = [ - '/sbin/shutdown', - '/usr/sbin/shutdown', - '/bin/shutdown', - '/usr/bin/shutdown', - ]; - - let shutdownCmd = ''; - for (const path of shutdownPaths) { - try { - if (fs.existsSync(path)) { - shutdownCmd = path; - logger.log(`Found shutdown command at: ${shutdownCmd}`); - break; - } - } catch (e) { - // Continue checking other paths - } - } - - if (shutdownCmd) { - // Execute shutdown command with delay to allow for VM graceful shutdown - logger.log( - `Executing: ${shutdownCmd} -h +${shutdownDelayMinutes} "UPS battery critical..."`, - ); - const { stdout } = await execFileAsync(shutdownCmd, [ - '-h', - `+${shutdownDelayMinutes}`, - `UPS battery critical, shutting down in ${shutdownDelayMinutes} minutes`, - ]); - logger.log(`Shutdown initiated: ${stdout}`); - logger.log(`Allowing ${shutdownDelayMinutes} minutes for VMs to shut down safely`); - } else { - // Try using the PATH to find shutdown - try { - logger.log('Shutdown command not found in common paths, trying via PATH...'); - const { stdout } = await execAsync( - `shutdown -h +${shutdownDelayMinutes} "UPS battery critical, shutting down in ${shutdownDelayMinutes} minutes"`, - { - env: process.env, // Pass the current environment - }, - ); - logger.log(`Shutdown initiated: ${stdout}`); - } catch (e) { - throw new Error( - `Shutdown command not found: ${e instanceof Error ? e.message : String(e)}`, - ); - } - } + await this.shutdownExecutor.scheduleShutdown(shutdownDelayMinutes); + logger.log(`Allowing ${shutdownDelayMinutes} minutes for VMs to shut down safely`); // Monitor UPS during shutdown and force immediate shutdown if battery gets too low logger.log('Monitoring UPS during shutdown process...'); @@ -956,51 +781,10 @@ export class NupstDaemon { } catch (error) { logger.error(`Failed to initiate shutdown: ${error}`); - // Try alternative shutdown methods - const alternatives = [ - { cmd: 'poweroff', args: ['--force'] }, - { cmd: 'halt', args: ['-p'] }, - { cmd: 'systemctl', args: ['poweroff'] }, - { cmd: 'reboot', args: ['-p'] }, // Some systems allow reboot -p for power off - ]; - - for (const alt of alternatives) { - try { - // First check if command exists in common system paths - const paths = [ - `/sbin/${alt.cmd}`, - `/usr/sbin/${alt.cmd}`, - `/bin/${alt.cmd}`, - `/usr/bin/${alt.cmd}`, - ]; - - let cmdPath = ''; - for (const path of paths) { - if (fs.existsSync(path)) { - cmdPath = path; - break; - } - } - - if (cmdPath) { - logger.log(`Trying alternative shutdown method: ${cmdPath} ${alt.args.join(' ')}`); - await execFileAsync(cmdPath, alt.args); - return; // Exit if successful - } else { - // Try using PATH environment - logger.log(`Trying alternative via PATH: ${alt.cmd} ${alt.args.join(' ')}`); - await execAsync(`${alt.cmd} ${alt.args.join(' ')}`, { - env: process.env, // Pass the current environment - }); - return; // Exit if successful - } - } catch (altError) { - logger.error(`Alternative method ${alt.cmd} failed: ${altError}`); - // Continue to next method - } + const shutdownTriggered = await this.shutdownExecutor.tryScheduledAlternatives(); + if (!shutdownTriggered) { + logger.error('All shutdown methods failed'); } - - logger.error('All shutdown methods failed'); } } @@ -1037,7 +821,6 @@ export class NupstDaemon { ]; const rows: Array> = []; - let emergencyDetected = false; let emergencyUps: { ups: IUpsConfig; status: ISnmpUpsStatus } | null = null; // Check all UPS devices @@ -1047,31 +830,30 @@ export class NupstDaemon { const status = protocol === 'upsd' && ups.upsd ? await this.protocolResolver.getUpsStatus('upsd', undefined, ups.upsd) : await this.protocolResolver.getUpsStatus('snmp', ups.snmp); + const rowSnapshot = buildShutdownStatusRow( + ups.name, + status, + THRESHOLDS.EMERGENCY_RUNTIME_MINUTES, + { + battery: (batteryCapacity) => + getBatteryColor(batteryCapacity)(`${batteryCapacity}%`), + runtime: (batteryRuntime) => + getRuntimeColor(batteryRuntime)(`${batteryRuntime} min`), + ok: theme.success, + critical: theme.error, + error: theme.error, + }, + ); - const batteryColor = getBatteryColor(status.batteryCapacity); - const runtimeColor = getRuntimeColor(status.batteryRuntime); - - const isCritical = status.batteryRuntime < THRESHOLDS.EMERGENCY_RUNTIME_MINUTES; - - rows.push({ - name: ups.name, - battery: batteryColor(status.batteryCapacity + '%'), - runtime: runtimeColor(status.batteryRuntime + ' min'), - status: isCritical ? theme.error('CRITICAL!') : theme.success('OK'), - }); - - // If any UPS battery runtime gets critically low, flag for immediate shutdown - if (isCritical && !emergencyDetected) { - emergencyDetected = true; - emergencyUps = { ups, status }; - } + rows.push(rowSnapshot.row); + emergencyUps = selectEmergencyCandidate( + emergencyUps, + ups, + status, + THRESHOLDS.EMERGENCY_RUNTIME_MINUTES, + ); } catch (upsError) { - rows.push({ - name: ups.name, - battery: theme.error('N/A'), - runtime: theme.error('N/A'), - status: theme.error('ERROR'), - }); + rows.push(buildShutdownErrorRow(ups.name, theme.error)); logger.error( `Error checking UPS ${ups.name} during shutdown: ${ @@ -1086,7 +868,7 @@ export class NupstDaemon { logger.log(''); // If emergency detected, trigger immediate shutdown - if (emergencyDetected && emergencyUps) { + if (emergencyUps) { logger.log(''); logger.logBoxTitle('EMERGENCY SHUTDOWN', 60, 'error'); logger.logBoxLine( @@ -1124,86 +906,14 @@ export class NupstDaemon { */ private async forceImmediateShutdown(): Promise { try { - // Find shutdown command in common system paths - const shutdownPaths = [ - '/sbin/shutdown', - '/usr/sbin/shutdown', - '/bin/shutdown', - '/usr/bin/shutdown', - ]; - - let shutdownCmd = ''; - for (const path of shutdownPaths) { - if (fs.existsSync(path)) { - shutdownCmd = path; - logger.log(`Found shutdown command at: ${shutdownCmd}`); - break; - } - } - - if (shutdownCmd) { - logger.log(`Executing emergency shutdown: ${shutdownCmd} -h now`); - await execFileAsync(shutdownCmd, [ - '-h', - 'now', - 'EMERGENCY: UPS battery critically low, shutting down NOW', - ]); - } else { - // Try using the PATH to find shutdown - logger.log('Shutdown command not found in common paths, trying via PATH...'); - await execAsync( - 'shutdown -h now "EMERGENCY: UPS battery critically low, shutting down NOW"', - { - env: process.env, // Pass the current environment - }, - ); - } + await this.shutdownExecutor.forceImmediateShutdown(); } catch (error) { logger.error('Emergency shutdown failed, trying alternative methods...'); - // Try alternative shutdown methods in sequence - const alternatives = [ - { cmd: 'poweroff', args: ['--force'] }, - { cmd: 'halt', args: ['-p'] }, - { cmd: 'systemctl', args: ['poweroff'] }, - ]; - - for (const alt of alternatives) { - try { - // Check common paths - const paths = [ - `/sbin/${alt.cmd}`, - `/usr/sbin/${alt.cmd}`, - `/bin/${alt.cmd}`, - `/usr/bin/${alt.cmd}`, - ]; - - let cmdPath = ''; - for (const path of paths) { - if (fs.existsSync(path)) { - cmdPath = path; - break; - } - } - - if (cmdPath) { - logger.log(`Emergency: using ${cmdPath} ${alt.args.join(' ')}`); - await execFileAsync(cmdPath, alt.args); - return; // Exit if successful - } else { - // Try using PATH - logger.log(`Emergency: trying ${alt.cmd} via PATH`); - await execAsync(`${alt.cmd} ${alt.args.join(' ')}`, { - env: process.env, - }); - return; // Exit if successful - } - } catch (altError) { - // Continue to next method - } + const shutdownTriggered = await this.shutdownExecutor.tryEmergencyAlternatives(); + if (!shutdownTriggered) { + logger.error('All emergency shutdown methods failed'); } - - logger.error('All emergency shutdown methods failed'); } } @@ -1276,19 +986,13 @@ export class NupstDaemon { for await (const event of watcher) { // Respond to modify events on config file - if ( - event.kind === 'modify' && - event.paths.some((p) => p.includes('config.json')) - ) { + if (shouldReloadConfig(event)) { logger.info('Config file changed, reloading...'); await this.reloadConfig(); } // Detect pause file changes - if ( - (event.kind === 'create' || event.kind === 'modify' || event.kind === 'remove') && - event.paths.some((p) => p.includes('pause')) - ) { + if (shouldRefreshPauseState(event)) { this.checkPauseState(); } @@ -1322,18 +1026,16 @@ export class NupstDaemon { await this.loadConfig(); const newDeviceCount = this.config.upsDevices?.length || 0; - if (newDeviceCount > 0 && oldDeviceCount === 0) { - logger.success(`Configuration reloaded! Found ${newDeviceCount} UPS device(s)`); - logger.info('Monitoring will start automatically...'); - } else if (newDeviceCount !== oldDeviceCount) { - logger.success( - `Configuration reloaded! UPS devices: ${oldDeviceCount} → ${newDeviceCount}`, - ); + const reloadSnapshot = analyzeConfigReload(oldDeviceCount, newDeviceCount); + logger.success(reloadSnapshot.message); + if (reloadSnapshot.shouldLogMonitoringStart) { + logger.info('Monitoring will start automatically...'); + } + + if (reloadSnapshot.shouldInitializeUpsStatus) { // Reinitialize UPS status tracking this.initializeUpsStatus(); - } else { - logger.success('Configuration reloaded successfully'); } } catch (error) { logger.warn( diff --git a/ts/http-server.ts b/ts/http-server.ts index f462ec2..8b04578 100644 --- a/ts/http-server.ts +++ b/ts/http-server.ts @@ -1,7 +1,8 @@ import * as http from 'node:http'; import { URL } from 'node:url'; import { logger } from './logger.ts'; -import type { IPauseState, IUpsStatus } from './daemon.ts'; +import type { IPauseState } from './pause-state.ts'; +import type { IUpsStatus } from './ups-status.ts'; /** * HTTP Server for exposing UPS status as JSON diff --git a/ts/index.ts b/ts/index.ts index 79e7cf9..983c490 100644 --- a/ts/index.ts +++ b/ts/index.ts @@ -10,7 +10,7 @@ import process from 'node:process'; */ async function main() { const cli = new NupstCli(); - await cli.parseAndExecute(process.argv); + await cli.parseAndExecute(process.argv.slice(2)); } // Run the main function and handle any errors diff --git a/ts/migrations/migration-runner.ts b/ts/migrations/migration-runner.ts index 72aec37..2347e14 100644 --- a/ts/migrations/migration-runner.ts +++ b/ts/migrations/migration-runner.ts @@ -58,7 +58,7 @@ export class MigrationRunner { if (anyMigrationsRan) { logger.success('Configuration migrations complete'); } else { - logger.success('config format ok'); + logger.success('Configuration format OK'); } return { diff --git a/ts/pause-state.ts b/ts/pause-state.ts new file mode 100644 index 0000000..f53a4d8 --- /dev/null +++ b/ts/pause-state.ts @@ -0,0 +1,68 @@ +import * as fs from 'node:fs'; + +/** + * Pause state interface + */ +export interface IPauseState { + /** Timestamp when pause was activated */ + pausedAt: number; + /** Who initiated the pause (e.g., 'cli', 'api') */ + pausedBy: string; + /** Optional reason for pausing */ + reason?: string; + /** When to auto-resume (null = indefinite, timestamp in ms) */ + resumeAt?: number | null; +} + +export type TPauseTransition = 'unchanged' | 'paused' | 'resumed' | 'autoResumed'; + +export interface IPauseSnapshot { + isPaused: boolean; + pauseState: IPauseState | null; + transition: TPauseTransition; +} + +export function loadPauseSnapshot( + filePath: string, + wasPaused: boolean, + now: number = Date.now(), +): IPauseSnapshot { + try { + if (!fs.existsSync(filePath)) { + return { + isPaused: false, + pauseState: null, + transition: wasPaused ? 'resumed' : 'unchanged', + }; + } + + const data = fs.readFileSync(filePath, 'utf8'); + const pauseState = JSON.parse(data) as IPauseState; + + if (pauseState.resumeAt && now >= pauseState.resumeAt) { + try { + fs.unlinkSync(filePath); + } catch (_error) { + // Ignore deletion errors and still treat the pause as expired. + } + + return { + isPaused: false, + pauseState: null, + transition: wasPaused ? 'autoResumed' : 'unchanged', + }; + } + + return { + isPaused: true, + pauseState, + transition: wasPaused ? 'unchanged' : 'paused', + }; + } catch (_error) { + return { + isPaused: false, + pauseState: null, + transition: 'unchanged', + }; + } +} diff --git a/ts/shutdown-executor.ts b/ts/shutdown-executor.ts new file mode 100644 index 0000000..7b6d59e --- /dev/null +++ b/ts/shutdown-executor.ts @@ -0,0 +1,145 @@ +import process from 'node:process'; +import * as fs from 'node:fs'; +import { exec, execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { logger } from './logger.ts'; + +const execAsync = promisify(exec); +const execFileAsync = promisify(execFile); + +interface IShutdownAlternative { + cmd: string; + args: string[]; +} + +interface IAlternativeLogConfig { + resolvedMessage: (commandPath: string, args: string[]) => string; + pathMessage: (command: string, args: string[]) => string; + failureMessage?: (command: string, error: unknown) => string; +} + +export class ShutdownExecutor { + private readonly commonCommandDirs = ['/sbin', '/usr/sbin', '/bin', '/usr/bin']; + + public async scheduleShutdown(delayMinutes: number): Promise { + const shutdownMessage = `UPS battery critical, shutting down in ${delayMinutes} minutes`; + const shutdownCommandPath = this.findCommandPath('shutdown'); + + if (shutdownCommandPath) { + logger.log(`Found shutdown command at: ${shutdownCommandPath}`); + logger.log(`Executing: ${shutdownCommandPath} -h +${delayMinutes} "UPS battery critical..."`); + const { stdout } = await execFileAsync(shutdownCommandPath, [ + '-h', + `+${delayMinutes}`, + shutdownMessage, + ]); + logger.log(`Shutdown initiated: ${stdout}`); + return; + } + + try { + logger.log('Shutdown command not found in common paths, trying via PATH...'); + const { stdout } = await execAsync( + `shutdown -h +${delayMinutes} "${shutdownMessage}"`, + { env: process.env }, + ); + logger.log(`Shutdown initiated: ${stdout}`); + } catch (error) { + throw new Error( + `Shutdown command not found: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + public async forceImmediateShutdown(): Promise { + const shutdownMessage = 'EMERGENCY: UPS battery critically low, shutting down NOW'; + const shutdownCommandPath = this.findCommandPath('shutdown'); + + if (shutdownCommandPath) { + logger.log(`Found shutdown command at: ${shutdownCommandPath}`); + logger.log(`Executing emergency shutdown: ${shutdownCommandPath} -h now`); + await execFileAsync(shutdownCommandPath, ['-h', 'now', shutdownMessage]); + return; + } + + logger.log('Shutdown command not found in common paths, trying via PATH...'); + await execAsync(`shutdown -h now "${shutdownMessage}"`, { + env: process.env, + }); + } + + public async tryScheduledAlternatives(): Promise { + return await this.tryAlternatives( + [ + { cmd: 'poweroff', args: ['--force'] }, + { cmd: 'halt', args: ['-p'] }, + { cmd: 'systemctl', args: ['poweroff'] }, + { cmd: 'reboot', args: ['-p'] }, + ], + { + resolvedMessage: (commandPath, args) => + `Trying alternative shutdown method: ${commandPath} ${args.join(' ')}`, + pathMessage: (command, args) => `Trying alternative via PATH: ${command} ${args.join(' ')}`, + failureMessage: (command, error) => `Alternative method ${command} failed: ${error}`, + }, + ); + } + + public async tryEmergencyAlternatives(): Promise { + return await this.tryAlternatives( + [ + { cmd: 'poweroff', args: ['--force'] }, + { cmd: 'halt', args: ['-p'] }, + { cmd: 'systemctl', args: ['poweroff'] }, + ], + { + resolvedMessage: (commandPath, args) => `Emergency: using ${commandPath} ${args.join(' ')}`, + pathMessage: (command) => `Emergency: trying ${command} via PATH`, + }, + ); + } + + private findCommandPath(command: string): string | null { + for (const directory of this.commonCommandDirs) { + const commandPath = `${directory}/${command}`; + try { + if (fs.existsSync(commandPath)) { + return commandPath; + } + } catch (_error) { + // Continue checking other paths. + } + } + + return null; + } + + private async tryAlternatives( + alternatives: IShutdownAlternative[], + logConfig: IAlternativeLogConfig, + ): Promise { + for (const alternative of alternatives) { + try { + const commandPath = this.findCommandPath(alternative.cmd); + + if (commandPath) { + logger.log(logConfig.resolvedMessage(commandPath, alternative.args)); + await execFileAsync(commandPath, alternative.args); + return true; + } + + logger.log(logConfig.pathMessage(alternative.cmd, alternative.args)); + await execAsync(`${alternative.cmd} ${alternative.args.join(' ')}`, { + env: process.env, + }); + return true; + } catch (error) { + if (logConfig.failureMessage) { + logger.error(logConfig.failureMessage(alternative.cmd, error)); + } + } + } + + return false; + } +} diff --git a/ts/shutdown-monitoring.ts b/ts/shutdown-monitoring.ts new file mode 100644 index 0000000..f2087f6 --- /dev/null +++ b/ts/shutdown-monitoring.ts @@ -0,0 +1,72 @@ +import type { IUpsStatus as IProtocolUpsStatus } from './snmp/types.ts'; + +export interface IShutdownMonitoringRow extends Record { + name: string; + battery: string; + runtime: string; + status: string; +} + +export interface IShutdownRowFormatters { + battery: (batteryCapacity: number) => string; + runtime: (batteryRuntime: number) => string; + ok: (text: string) => string; + critical: (text: string) => string; + error: (text: string) => string; +} + +export interface IShutdownEmergencyCandidate { + ups: TUps; + status: IProtocolUpsStatus; +} + +export function isEmergencyRuntime( + batteryRuntime: number, + emergencyRuntimeMinutes: number, +): boolean { + return batteryRuntime < emergencyRuntimeMinutes; +} + +export function buildShutdownStatusRow( + upsName: string, + status: IProtocolUpsStatus, + emergencyRuntimeMinutes: number, + formatters: IShutdownRowFormatters, +): { row: IShutdownMonitoringRow; isCritical: boolean } { + const isCritical = isEmergencyRuntime(status.batteryRuntime, emergencyRuntimeMinutes); + + return { + row: { + name: upsName, + battery: formatters.battery(status.batteryCapacity), + runtime: formatters.runtime(status.batteryRuntime), + status: isCritical ? formatters.critical('CRITICAL!') : formatters.ok('OK'), + }, + isCritical, + }; +} + +export function buildShutdownErrorRow( + upsName: string, + errorFormatter: (text: string) => string, +): IShutdownMonitoringRow { + return { + name: upsName, + battery: errorFormatter('N/A'), + runtime: errorFormatter('N/A'), + status: errorFormatter('ERROR'), + }; +} + +export function selectEmergencyCandidate( + currentCandidate: IShutdownEmergencyCandidate | null, + ups: TUps, + status: IProtocolUpsStatus, + emergencyRuntimeMinutes: number, +): IShutdownEmergencyCandidate | null { + if (currentCandidate || !isEmergencyRuntime(status.batteryRuntime, emergencyRuntimeMinutes)) { + return currentCandidate; + } + + return { ups, status }; +} diff --git a/ts/snmp/manager.ts b/ts/snmp/manager.ts index 485055a..ec1021f 100644 --- a/ts/snmp/manager.ts +++ b/ts/snmp/manager.ts @@ -6,6 +6,73 @@ import { SNMP } from '../constants.ts'; import { logger } from '../logger.ts'; import type { INupstAccessor } from '../interfaces/index.ts'; +type TSnmpMetricDescription = + | 'power status' + | 'battery capacity' + | 'battery runtime' + | 'output load' + | 'output power' + | 'output voltage' + | 'output current'; + +type TSnmpResponseValue = string | number | bigint | boolean | Buffer; +type TSnmpValue = string | number | boolean | Buffer; + +interface ISnmpVarbind { + oid: string; + type: number; + value: TSnmpResponseValue; +} + +interface ISnmpSessionOptions { + port: number; + retries: number; + timeout: number; + transport: 'udp4' | 'udp6'; + idBitsSize: 16 | 32; + context: string; + version: number; +} + +interface ISnmpV3User { + name: string; + level: number; + authProtocol?: string; + authKey?: string; + privProtocol?: string; + privKey?: string; +} + +interface ISnmpSession { + get(oids: string[], callback: (error: Error | null, varbinds?: ISnmpVarbind[]) => void): void; + close(): void; +} + +interface ISnmpModule { + Version1: number; + Version2c: number; + Version3: number; + SecurityLevel: { + noAuthNoPriv: number; + authNoPriv: number; + authPriv: number; + }; + AuthProtocols: { + md5: string; + sha: string; + }; + PrivProtocols: { + des: string; + aes: string; + }; + createSession(target: string, community: string, options: ISnmpSessionOptions): ISnmpSession; + createV3Session(target: string, user: ISnmpV3User, options: ISnmpSessionOptions): ISnmpSession; + isVarbindError(varbind: ISnmpVarbind): boolean; + varbindError(varbind: ISnmpVarbind): string; +} + +const snmpLib = snmp as unknown as ISnmpModule; + /** * Class for SNMP communication with UPS devices * Main entry point for SNMP functionality @@ -84,6 +151,120 @@ export class NupstSnmp { } } + private createSessionOptions(config: ISnmpConfig): ISnmpSessionOptions { + return { + port: config.port, + retries: SNMP.RETRIES, + timeout: config.timeout, + transport: 'udp4', + idBitsSize: 32, + context: config.context || '', + version: config.version === 1 + ? snmpLib.Version1 + : config.version === 2 + ? snmpLib.Version2c + : snmpLib.Version3, + }; + } + + private buildV3User( + config: ISnmpConfig, + ): { user: ISnmpV3User; levelLabel: NonNullable } { + const requestedSecurityLevel = config.securityLevel || 'noAuthNoPriv'; + const user: ISnmpV3User = { + name: config.username || '', + level: snmpLib.SecurityLevel.noAuthNoPriv, + }; + let levelLabel: NonNullable = 'noAuthNoPriv'; + + if (requestedSecurityLevel === 'authNoPriv') { + user.level = snmpLib.SecurityLevel.authNoPriv; + levelLabel = 'authNoPriv'; + + if (config.authProtocol && config.authKey) { + user.authProtocol = this.resolveAuthProtocol(config.authProtocol); + user.authKey = config.authKey; + } else { + user.level = snmpLib.SecurityLevel.noAuthNoPriv; + levelLabel = 'noAuthNoPriv'; + if (this.debug) { + logger.warn('Missing authProtocol or authKey, falling back to noAuthNoPriv'); + } + } + } else if (requestedSecurityLevel === 'authPriv') { + user.level = snmpLib.SecurityLevel.authPriv; + levelLabel = 'authPriv'; + + if (config.authProtocol && config.authKey) { + user.authProtocol = this.resolveAuthProtocol(config.authProtocol); + user.authKey = config.authKey; + + if (config.privProtocol && config.privKey) { + user.privProtocol = this.resolvePrivProtocol(config.privProtocol); + user.privKey = config.privKey; + } else { + user.level = snmpLib.SecurityLevel.authNoPriv; + levelLabel = 'authNoPriv'; + if (this.debug) { + logger.warn('Missing privProtocol or privKey, falling back to authNoPriv'); + } + } + } else { + user.level = snmpLib.SecurityLevel.noAuthNoPriv; + levelLabel = 'noAuthNoPriv'; + if (this.debug) { + logger.warn('Missing authProtocol or authKey, falling back to noAuthNoPriv'); + } + } + } + + return { user, levelLabel }; + } + + private resolveAuthProtocol(protocol: NonNullable): string { + return protocol === 'MD5' ? snmpLib.AuthProtocols.md5 : snmpLib.AuthProtocols.sha; + } + + private resolvePrivProtocol(protocol: NonNullable): string { + return protocol === 'DES' ? snmpLib.PrivProtocols.des : snmpLib.PrivProtocols.aes; + } + + private normalizeSnmpValue(value: TSnmpResponseValue): TSnmpValue { + if (Buffer.isBuffer(value)) { + const isPrintableAscii = value.every((byte: number) => byte >= 32 && byte <= 126); + return isPrintableAscii ? value.toString() : value; + } + + if (typeof value === 'bigint') { + return Number(value); + } + + return value; + } + + private coerceNumericSnmpValue( + value: TSnmpValue | 0, + description: TSnmpMetricDescription, + ): number { + if (typeof value === 'number') { + return Number.isFinite(value) ? value : 0; + } + + if (typeof value === 'string') { + const trimmedValue = value.trim(); + const parsedValue = Number(trimmedValue); + if (trimmedValue && Number.isFinite(parsedValue)) { + return parsedValue; + } + } + + if (this.debug) { + logger.warn(`Non-numeric ${description} value received from SNMP, using 0`); + } + + return 0; + } + /** * Send an SNMP GET request using the net-snmp package * @param oid OID to query @@ -95,130 +276,39 @@ export class NupstSnmp { oid: string, config = this.DEFAULT_CONFIG, _retryCount = 0, - // deno-lint-ignore no-explicit-any - ): Promise { + ): Promise { return new Promise((resolve, reject) => { if (this.debug) { logger.dim( `Sending SNMP v${config.version} GET request for OID ${oid} to ${config.host}:${config.port}`, ); - logger.dim(`Using community: ${config.community}`); - } - - // Create SNMP options based on configuration - // deno-lint-ignore no-explicit-any - const options: any = { - port: config.port, - retries: SNMP.RETRIES, // Number of retries - timeout: config.timeout, - transport: 'udp4', - idBitsSize: 32, - context: config.context || '', - }; - - // Set version based on config - if (config.version === 1) { - options.version = snmp.Version1; - } else if (config.version === 2) { - options.version = snmp.Version2c; - } else { - options.version = snmp.Version3; - } - - // Create appropriate session based on SNMP version - let session; - - if (config.version === 3) { - // For SNMPv3, we need to set up authentication and privacy - // For SNMPv3, we need a valid security level - const securityLevel = config.securityLevel || 'noAuthNoPriv'; - - // Create the user object with required structure for net-snmp - // deno-lint-ignore no-explicit-any - const user: any = { - name: config.username || '', - }; - - // Set security level - if (securityLevel === 'noAuthNoPriv') { - user.level = snmp.SecurityLevel.noAuthNoPriv; - } else if (securityLevel === 'authNoPriv') { - user.level = snmp.SecurityLevel.authNoPriv; - - // Set auth protocol - must provide both protocol and key - if (config.authProtocol && config.authKey) { - if (config.authProtocol === 'MD5') { - user.authProtocol = snmp.AuthProtocols.md5; - } else if (config.authProtocol === 'SHA') { - user.authProtocol = snmp.AuthProtocols.sha; - } - user.authKey = config.authKey; - } else { - // Fallback to noAuthNoPriv if auth details missing - user.level = snmp.SecurityLevel.noAuthNoPriv; - if (this.debug) { - logger.warn('Missing authProtocol or authKey, falling back to noAuthNoPriv'); - } - } - } else if (securityLevel === 'authPriv') { - user.level = snmp.SecurityLevel.authPriv; - - // Set auth protocol - must provide both protocol and key - if (config.authProtocol && config.authKey) { - if (config.authProtocol === 'MD5') { - user.authProtocol = snmp.AuthProtocols.md5; - } else if (config.authProtocol === 'SHA') { - user.authProtocol = snmp.AuthProtocols.sha; - } - user.authKey = config.authKey; - - // Set privacy protocol - must provide both protocol and key - if (config.privProtocol && config.privKey) { - if (config.privProtocol === 'DES') { - user.privProtocol = snmp.PrivProtocols.des; - } else if (config.privProtocol === 'AES') { - user.privProtocol = snmp.PrivProtocols.aes; - } - user.privKey = config.privKey; - } else { - // Fallback to authNoPriv if priv details missing - user.level = snmp.SecurityLevel.authNoPriv; - if (this.debug) { - logger.warn('Missing privProtocol or privKey, falling back to authNoPriv'); - } - } - } else { - // Fallback to noAuthNoPriv if auth details missing - user.level = snmp.SecurityLevel.noAuthNoPriv; - if (this.debug) { - logger.warn('Missing authProtocol or authKey, falling back to noAuthNoPriv'); - } - } + if (config.version === 1 || config.version === 2) { + logger.dim(`Using community: ${config.community}`); } - - if (this.debug) { - const levelName = Object.keys(snmp.SecurityLevel).find((key) => - snmp.SecurityLevel[key] === user.level - ); - logger.dim( - `SNMPv3 user configuration: name=${user.name}, level=${levelName}, authProtocol=${ - user.authProtocol ? 'Set' : 'Not Set' - }, privProtocol=${user.privProtocol ? 'Set' : 'Not Set'}`, - ); - } - - session = snmp.createV3Session(config.host, user, options); - } else { - // For SNMPv1/v2c, we use the community string - session = snmp.createSession(config.host, config.community || 'public', options); } + const options = this.createSessionOptions(config); + const session: ISnmpSession = config.version === 3 + ? (() => { + const { user, levelLabel } = this.buildV3User(config); + + if (this.debug) { + logger.dim( + `SNMPv3 user configuration: name=${user.name}, level=${levelLabel}, authProtocol=${ + user.authProtocol ? 'Set' : 'Not Set' + }, privProtocol=${user.privProtocol ? 'Set' : 'Not Set'}`, + ); + } + + return snmpLib.createV3Session(config.host, user, options); + })() + : snmpLib.createSession(config.host, config.community || 'public', options); + // Convert the OID string to an array of OIDs if multiple OIDs are needed const oids = [oid]; // Send the GET request - // deno-lint-ignore no-explicit-any - session.get(oids, (error: Error | null, varbinds: any[]) => { + session.get(oids, (error: Error | null, varbinds?: ISnmpVarbind[]) => { // Close the session to release resources session.close(); @@ -230,7 +320,9 @@ export class NupstSnmp { return; } - if (!varbinds || varbinds.length === 0) { + const varbind = varbinds?.[0]; + + if (!varbind) { if (this.debug) { logger.error('No varbinds returned in response'); } @@ -239,36 +331,20 @@ export class NupstSnmp { } // Check for SNMP errors in the response - if ( - varbinds[0].type === snmp.ObjectType.NoSuchObject || - varbinds[0].type === snmp.ObjectType.NoSuchInstance || - varbinds[0].type === snmp.ObjectType.EndOfMibView - ) { + if (snmpLib.isVarbindError(varbind)) { + const errorMessage = snmpLib.varbindError(varbind); if (this.debug) { - logger.error(`SNMP error: ${snmp.ObjectType[varbinds[0].type]}`); + logger.error(`SNMP error: ${errorMessage}`); } - reject(new Error(`SNMP error: ${snmp.ObjectType[varbinds[0].type]}`)); + reject(new Error(`SNMP error: ${errorMessage}`)); return; } - // Process the response value based on its type - let value = varbinds[0].value; - - // Handle specific types that might need conversion - if (Buffer.isBuffer(value)) { - // If value is a Buffer, try to convert it to a string if it's printable ASCII - const isPrintableAscii = value.every((byte: number) => byte >= 32 && byte <= 126); - if (isPrintableAscii) { - value = value.toString(); - } - } else if (typeof value === 'bigint') { - // Convert BigInt to a normal number or string if needed - value = Number(value); - } + const value = this.normalizeSnmpValue(varbind.value); if (this.debug) { logger.dim( - `SNMP response: oid=${varbinds[0].oid}, type=${varbinds[0].type}, value=${value}`, + `SNMP response: oid=${varbind.oid}, type=${varbind.type}, value=${value}`, ); } @@ -315,43 +391,44 @@ export class NupstSnmp { } // Get all values with independent retry logic - const powerStatusValue = await this.getSNMPValueWithRetry( - this.activeOIDs.POWER_STATUS, + const powerStatusValue = this.coerceNumericSnmpValue( + await this.getSNMPValueWithRetry(this.activeOIDs.POWER_STATUS, 'power status', config), 'power status', - config, ); - const batteryCapacity = await this.getSNMPValueWithRetry( - this.activeOIDs.BATTERY_CAPACITY, + const batteryCapacity = this.coerceNumericSnmpValue( + await this.getSNMPValueWithRetry( + this.activeOIDs.BATTERY_CAPACITY, + 'battery capacity', + config, + ), 'battery capacity', - config, - ) || 0; - const batteryRuntime = await this.getSNMPValueWithRetry( - this.activeOIDs.BATTERY_RUNTIME, + ); + const batteryRuntime = this.coerceNumericSnmpValue( + await this.getSNMPValueWithRetry( + this.activeOIDs.BATTERY_RUNTIME, + 'battery runtime', + config, + ), 'battery runtime', - config, - ) || 0; + ); // Get power draw metrics - const outputLoad = await this.getSNMPValueWithRetry( - this.activeOIDs.OUTPUT_LOAD, + const outputLoad = this.coerceNumericSnmpValue( + await this.getSNMPValueWithRetry(this.activeOIDs.OUTPUT_LOAD, 'output load', config), 'output load', - config, - ) || 0; - const outputPower = await this.getSNMPValueWithRetry( - this.activeOIDs.OUTPUT_POWER, + ); + const outputPower = this.coerceNumericSnmpValue( + await this.getSNMPValueWithRetry(this.activeOIDs.OUTPUT_POWER, 'output power', config), 'output power', - config, - ) || 0; - const outputVoltage = await this.getSNMPValueWithRetry( - this.activeOIDs.OUTPUT_VOLTAGE, + ); + const outputVoltage = this.coerceNumericSnmpValue( + await this.getSNMPValueWithRetry(this.activeOIDs.OUTPUT_VOLTAGE, 'output voltage', config), 'output voltage', - config, - ) || 0; - const outputCurrent = await this.getSNMPValueWithRetry( - this.activeOIDs.OUTPUT_CURRENT, + ); + const outputCurrent = this.coerceNumericSnmpValue( + await this.getSNMPValueWithRetry(this.activeOIDs.OUTPUT_CURRENT, 'output current', config), 'output current', - config, - ) || 0; + ); // Determine power status - handle different values for different UPS models const powerStatus = this.determinePowerStatus(config.upsModel, powerStatusValue); @@ -430,10 +507,9 @@ export class NupstSnmp { */ private async getSNMPValueWithRetry( oid: string, - description: string, + description: TSnmpMetricDescription, config: ISnmpConfig, - // deno-lint-ignore no-explicit-any - ): Promise { + ): Promise { if (oid === '') { if (this.debug) { logger.dim(`No OID provided for ${description}, skipping`); @@ -485,10 +561,9 @@ export class NupstSnmp { */ private async tryFallbackSecurityLevels( oid: string, - description: string, + description: TSnmpMetricDescription, config: ISnmpConfig, - // deno-lint-ignore no-explicit-any - ): Promise { + ): Promise { if (this.debug) { logger.dim(`Retrying ${description} with fallback security level...`); } @@ -551,10 +626,9 @@ export class NupstSnmp { */ private async tryStandardOids( _oid: string, - description: string, + description: TSnmpMetricDescription, config: ISnmpConfig, - // deno-lint-ignore no-explicit-any - ): Promise { + ): Promise { try { // Try RFC 1628 standard UPS MIB OIDs const standardOIDs = UpsOidSets.getStandardOids(); diff --git a/ts/ups-monitoring.ts b/ts/ups-monitoring.ts new file mode 100644 index 0000000..e9a511a --- /dev/null +++ b/ts/ups-monitoring.ts @@ -0,0 +1,138 @@ +import type { IActionConfig } from './actions/base-action.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'; + +export interface ISuccessfulUpsPollSnapshot { + updatedStatus: IUpsStatus; + transition: 'none' | 'recovered' | 'powerStatusChange'; + previousStatus?: IUpsStatus; + downtimeSeconds?: number; +} + +export interface IFailedUpsPollSnapshot { + updatedStatus: IUpsStatus; + transition: 'none' | 'unreachable'; + failures: number; + previousStatus?: IUpsStatus; +} + +export function ensureUpsStatus( + currentStatus: IUpsStatus | undefined, + ups: IUpsIdentity, + now: number = Date.now(), +): IUpsStatus { + return currentStatus || createInitialUpsStatus(ups, now); +} + +export function buildSuccessfulUpsPollSnapshot( + ups: IUpsIdentity, + polledStatus: IProtocolUpsStatus, + currentStatus: IUpsStatus | undefined, + currentTime: number, +): ISuccessfulUpsPollSnapshot { + const previousStatus = ensureUpsStatus(currentStatus, ups, currentTime); + const updatedStatus: IUpsStatus = { + id: ups.id, + name: ups.name, + powerStatus: polledStatus.powerStatus, + batteryCapacity: polledStatus.batteryCapacity, + batteryRuntime: polledStatus.batteryRuntime, + outputLoad: polledStatus.outputLoad, + outputPower: polledStatus.outputPower, + outputVoltage: polledStatus.outputVoltage, + outputCurrent: polledStatus.outputCurrent, + lastCheckTime: currentTime, + lastStatusChange: previousStatus.lastStatusChange || currentTime, + consecutiveFailures: 0, + unreachableSince: 0, + }; + + if (previousStatus.powerStatus === 'unreachable') { + updatedStatus.lastStatusChange = currentTime; + return { + updatedStatus, + transition: 'recovered', + previousStatus, + downtimeSeconds: Math.round((currentTime - previousStatus.unreachableSince) / 1000), + }; + } + + if (previousStatus.powerStatus !== polledStatus.powerStatus) { + updatedStatus.lastStatusChange = currentTime; + return { + updatedStatus, + transition: 'powerStatusChange', + previousStatus, + }; + } + + return { + updatedStatus, + transition: 'none', + previousStatus: currentStatus, + }; +} + +export function buildFailedUpsPollSnapshot( + ups: IUpsIdentity, + currentStatus: IUpsStatus | undefined, + currentTime: number, +): IFailedUpsPollSnapshot { + const previousStatus = ensureUpsStatus(currentStatus, ups, currentTime); + const failures = Math.min( + previousStatus.consecutiveFailures + 1, + NETWORK.MAX_CONSECUTIVE_FAILURES, + ); + + if ( + failures >= NETWORK.CONSECUTIVE_FAILURE_THRESHOLD && + previousStatus.powerStatus !== 'unreachable' + ) { + return { + updatedStatus: { + ...previousStatus, + consecutiveFailures: failures, + powerStatus: 'unreachable', + unreachableSince: currentTime, + lastStatusChange: currentTime, + }, + transition: 'unreachable', + failures, + previousStatus, + }; + } + + return { + updatedStatus: { + ...previousStatus, + consecutiveFailures: failures, + }, + transition: 'none', + failures, + previousStatus: currentStatus, + }; +} + +export function hasThresholdViolation( + powerStatus: IProtocolUpsStatus['powerStatus'], + batteryCapacity: number, + batteryRuntime: number, + actions: IActionConfig[] | undefined, +): boolean { + if (powerStatus !== 'onBattery' || !actions || actions.length === 0) { + return false; + } + + for (const actionConfig of actions) { + if ( + actionConfig.thresholds && + (batteryCapacity < actionConfig.thresholds.battery || + batteryRuntime < actionConfig.thresholds.runtime) + ) { + return true; + } + } + + return false; +} diff --git a/ts/ups-status.ts b/ts/ups-status.ts new file mode 100644 index 0000000..fdf12d8 --- /dev/null +++ b/ts/ups-status.ts @@ -0,0 +1,38 @@ +export interface IUpsIdentity { + id: string; + name: string; +} + +export interface IUpsStatus { + id: string; + name: string; + powerStatus: 'online' | 'onBattery' | 'unknown' | 'unreachable'; + batteryCapacity: number; + batteryRuntime: number; + outputLoad: number; + outputPower: number; + outputVoltage: number; + outputCurrent: number; + lastStatusChange: number; + lastCheckTime: number; + consecutiveFailures: number; + unreachableSince: number; +} + +export function createInitialUpsStatus(ups: IUpsIdentity, now: number = Date.now()): IUpsStatus { + return { + id: ups.id, + name: ups.name, + powerStatus: 'unknown', + batteryCapacity: 100, + batteryRuntime: 999, + outputLoad: 0, + outputPower: 0, + outputVoltage: 0, + outputCurrent: 0, + lastStatusChange: now, + lastCheckTime: 0, + consecutiveFailures: 0, + unreachableSince: 0, + }; +}