fix(cli,daemon,snmp): normalize CLI argument parsing and extract daemon monitoring helpers with stronger SNMP typing
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
7
mod.ts
7
mod.ts
@@ -25,12 +25,7 @@ import { NupstCli } from './ts/cli.ts';
|
||||
*/
|
||||
async function main(): Promise<void> {
|
||||
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
|
||||
|
||||
@@ -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()
|
||||
```
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
430
test/test.ts
430
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
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
70
ts/action-orchestration.ts
Normal file
70
ts/action-orchestration.ts
Normal file
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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<void> {
|
||||
// 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();
|
||||
|
||||
@@ -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<string>,
|
||||
): Promise<void> {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
// 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<void> {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, unknown> & { id: string; name: string; groups: string[]; actions: IActionConfig[]; protocol: TProtocol; snmp?: ISnmpConfig; upsd?: IUpsdConfig } = {
|
||||
const newUps: Record<string, unknown> & {
|
||||
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('');
|
||||
|
||||
58
ts/config-watch.ts
Normal file
58
ts/config-watch.ts
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
646
ts/daemon.ts
646
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<string, IUpsStatus> = 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<void> {
|
||||
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<void> {
|
||||
// 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<Record<string, string>> = [];
|
||||
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<void> {
|
||||
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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
68
ts/pause-state.ts
Normal file
68
ts/pause-state.ts
Normal file
@@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
145
ts/shutdown-executor.ts
Normal file
145
ts/shutdown-executor.ts
Normal file
@@ -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<void> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
72
ts/shutdown-monitoring.ts
Normal file
72
ts/shutdown-monitoring.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import type { IUpsStatus as IProtocolUpsStatus } from './snmp/types.ts';
|
||||
|
||||
export interface IShutdownMonitoringRow extends Record<string, string> {
|
||||
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<TUps> {
|
||||
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<TUps>(
|
||||
currentCandidate: IShutdownEmergencyCandidate<TUps> | null,
|
||||
ups: TUps,
|
||||
status: IProtocolUpsStatus,
|
||||
emergencyRuntimeMinutes: number,
|
||||
): IShutdownEmergencyCandidate<TUps> | null {
|
||||
if (currentCandidate || !isEmergencyRuntime(status.batteryRuntime, emergencyRuntimeMinutes)) {
|
||||
return currentCandidate;
|
||||
}
|
||||
|
||||
return { ups, status };
|
||||
}
|
||||
@@ -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<ISnmpConfig['securityLevel']> } {
|
||||
const requestedSecurityLevel = config.securityLevel || 'noAuthNoPriv';
|
||||
const user: ISnmpV3User = {
|
||||
name: config.username || '',
|
||||
level: snmpLib.SecurityLevel.noAuthNoPriv,
|
||||
};
|
||||
let levelLabel: NonNullable<ISnmpConfig['securityLevel']> = '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<ISnmpConfig['authProtocol']>): string {
|
||||
return protocol === 'MD5' ? snmpLib.AuthProtocols.md5 : snmpLib.AuthProtocols.sha;
|
||||
}
|
||||
|
||||
private resolvePrivProtocol(protocol: NonNullable<ISnmpConfig['privProtocol']>): 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<any> {
|
||||
): Promise<TSnmpValue> {
|
||||
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<any> {
|
||||
): Promise<TSnmpValue | 0> {
|
||||
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<any> {
|
||||
): Promise<TSnmpValue | 0> {
|
||||
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<any> {
|
||||
): Promise<TSnmpValue | 0> {
|
||||
try {
|
||||
// Try RFC 1628 standard UPS MIB OIDs
|
||||
const standardOIDs = UpsOidSets.getStandardOids();
|
||||
|
||||
138
ts/ups-monitoring.ts
Normal file
138
ts/ups-monitoring.ts
Normal file
@@ -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;
|
||||
}
|
||||
38
ts/ups-status.ts
Normal file
38
ts/ups-status.ts
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user