feat(daemon): Add UPSD (NUT) protocol support, Proxmox VM shutdown action, pause/resume monitoring, and network-loss/unreachable handling; bump config version to 4.2

This commit is contained in:
2026-02-20 11:51:59 +00:00
parent 782c8c9555
commit 42b8eaf6d2
30 changed files with 2183 additions and 697 deletions

View File

@@ -5,13 +5,17 @@ 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';
import type { IUpsdConfig } from './upsd/types.ts';
import type { TProtocol } from './protocol/types.ts';
import { ProtocolResolver } from './protocol/resolver.ts';
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 { NupstHttpServer } from './http-server.ts';
import { THRESHOLDS, TIMING, UI } from './constants.ts';
import { NETWORK, PAUSE, THRESHOLDS, TIMING, UI } from './constants.ts';
const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);
@@ -24,8 +28,12 @@ export interface IUpsConfig {
id: string;
/** Friendly name for the UPS */
name: string;
/** SNMP configuration settings */
snmp: ISnmpConfig;
/** Communication protocol (defaults to 'snmp') */
protocol?: TProtocol;
/** SNMP configuration settings (required for 'snmp' protocol) */
snmp?: ISnmpConfig;
/** UPSD/NIS configuration settings (required for 'upsd' protocol) */
upsd?: IUpsdConfig;
/** Group IDs this UPS belongs to */
groups: string[];
/** Actions to trigger on power status changes and threshold violations */
@@ -62,6 +70,20 @@ 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
*/
@@ -97,7 +119,7 @@ export interface INupstConfig {
export interface IUpsStatus {
id: string;
name: string;
powerStatus: 'online' | 'onBattery' | 'unknown';
powerStatus: 'online' | 'onBattery' | 'unknown' | 'unreachable';
batteryCapacity: number;
batteryRuntime: number;
outputLoad: number; // Load percentage (0-100%)
@@ -106,6 +128,8 @@ export interface IUpsStatus {
outputCurrent: number; // Current in amps
lastStatusChange: number;
lastCheckTime: number;
consecutiveFailures: number;
unreachableSince: number;
}
/**
@@ -159,15 +183,21 @@ export class NupstDaemon {
private config: INupstConfig;
private snmp: NupstSnmp;
private upsd: NupstUpsd;
private protocolResolver: ProtocolResolver;
private isRunning: boolean = false;
private isPaused: boolean = false;
private pauseState: IPauseState | null = null;
private upsStatus: Map<string, IUpsStatus> = new Map();
private httpServer?: NupstHttpServer;
/**
* Create a new daemon instance with the given SNMP manager
* Create a new daemon instance with the given protocol managers
*/
constructor(snmp: NupstSnmp) {
constructor(snmp: NupstSnmp, upsd: NupstUpsd) {
this.snmp = snmp;
this.upsd = upsd;
this.protocolResolver = new ProtocolResolver(snmp, upsd);
this.config = this.DEFAULT_CONFIG;
}
@@ -230,10 +260,11 @@ export class NupstDaemon {
// Ensure version is always set and remove legacy fields before saving
const configToSave: INupstConfig = {
version: '4.1',
version: '4.2',
upsDevices: config.upsDevices,
groups: config.groups,
checkInterval: config.checkInterval,
...(config.httpServer ? { httpServer: config.httpServer } : {}),
};
fs.writeFileSync(this.CONFIG_PATH, JSON.stringify(configToSave, null, 2));
@@ -271,6 +302,13 @@ export class NupstDaemon {
return this.snmp;
}
/**
* Get the UPSD instance
*/
public getNupstUpsd(): NupstUpsd {
return this.upsd;
}
/**
* Start the monitoring daemon
*/
@@ -317,6 +355,7 @@ export class NupstDaemon {
this.config.httpServer.path,
this.config.httpServer.authToken,
() => this.upsStatus,
() => this.pauseState,
);
this.httpServer.start();
} catch (error) {
@@ -360,6 +399,8 @@ export class NupstDaemon {
outputCurrent: 0,
lastStatusChange: Date.now(),
lastCheckTime: 0,
consecutiveFailures: 0,
unreachableSince: 0,
});
}
@@ -388,16 +429,27 @@ export class NupstDaemon {
> = [
{ header: 'Name', key: 'name', align: 'left', color: theme.highlight },
{ header: 'ID', key: 'id', align: 'left', color: theme.dim },
{ header: 'Protocol', key: 'protocol', align: 'left' },
{ header: 'Host:Port', key: 'host', align: 'left', color: theme.info },
{ header: 'Actions', key: 'actions', align: 'left' },
];
const upsRows: Array<Record<string, string>> = this.config.upsDevices.map((ups) => ({
name: ups.name,
id: ups.id,
host: `${ups.snmp.host}:${ups.snmp.port}`,
actions: `${(ups.actions || []).length} configured`,
}));
const upsRows: Array<Record<string, string>> = this.config.upsDevices.map((ups) => {
const protocol = ups.protocol || 'snmp';
let host = 'N/A';
if (protocol === 'upsd' && ups.upsd) {
host = `${ups.upsd.host}:${ups.upsd.port}`;
} else if (ups.snmp) {
host = `${ups.snmp.host}:${ups.snmp.port}`;
}
return {
name: ups.name,
id: ups.id,
protocol: protocol.toUpperCase(),
host,
actions: `${(ups.actions || []).length} configured`,
};
});
logger.logTable(upsColumns, upsRows);
logger.log('');
@@ -443,6 +495,79 @@ export class NupstDaemon {
this.isRunning = false;
}
/**
* Get the current pause state
*/
public getPauseState(): IPauseState | null {
return this.pauseState;
}
/**
* 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;
// 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;
}
} catch (_error) {
// If we can't read the pause file, assume not paused
this.isPaused = false;
this.pauseState = null;
}
}
/**
* Monitor the UPS status and trigger shutdown when necessary
*/
@@ -461,7 +586,10 @@ export class NupstDaemon {
// Monitor continuously
while (this.isRunning) {
try {
// Check all UPS devices
// Check pause state before each cycle
this.checkPauseState();
// Check all UPS devices (polling continues even when paused for visibility)
await this.checkAllUpsDevices();
// Log periodic status update
@@ -505,16 +633,24 @@ export class NupstDaemon {
outputCurrent: 0,
lastStatusChange: Date.now(),
lastCheckTime: 0,
consecutiveFailures: 0,
unreachableSince: 0,
});
}
// Check UPS status
const status = await this.snmp.getUpsStatus(ups.snmp);
// Check UPS status via configured protocol
const protocol = ups.protocol || 'snmp';
const status = protocol === 'upsd' && ups.upsd
? await this.protocolResolver.getUpsStatus('upsd', undefined, ups.upsd)
: 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);
// Successful query: reset consecutive failures
const wasUnreachable = currentStatus?.powerStatus === 'unreachable';
// Update status with new values
const updatedStatus: IUpsStatus = {
id: ups.id,
@@ -528,10 +664,27 @@ export class NupstDaemon {
outputCurrent: status.outputCurrent,
lastCheckTime: currentTime,
lastStatusChange: currentStatus?.lastStatusChange || currentTime,
consecutiveFailures: 0,
unreachableSince: 0,
};
// Check if power status changed
if (currentStatus && currentStatus.powerStatus !== status.powerStatus) {
// If UPS was unreachable and is now reachable, log recovery
if (wasUnreachable && currentStatus) {
const downtime = Math.round((currentTime - currentStatus.unreachableSince) / 1000);
logger.log('');
logger.logBoxTitle(`UPS Recovered: ${ups.name}`, 60, 'success');
logger.logBoxLine(`UPS is reachable again after ${downtime} 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
logger.log('');
logger.logBoxTitle(`Power Status Change: ${ups.name}`, 60, 'warning');
logger.logBoxLine(`Previous: ${formatPowerStatus(currentStatus.powerStatus)}`);
@@ -573,11 +726,48 @@ export class NupstDaemon {
// Update the status in the map
this.upsStatus.set(ups.id, updatedStatus);
} catch (error) {
// Network loss / query failure tracking
const currentStatus = this.upsStatus.get(ups.id);
const failures = Math.min(
(currentStatus?.consecutiveFailures || 0) + 1,
NETWORK.MAX_CONSECUTIVE_FAILURES,
);
logger.error(
`Error checking UPS ${ups.name} (${ups.id}): ${
`Error checking UPS ${ups.name} (${ups.id}) [failure ${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);
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(`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);
}
}
}
}
@@ -589,8 +779,16 @@ export class NupstDaemon {
const timestamp = new Date().toISOString();
logger.log('');
logger.logBoxTitle('Periodic Status Update', 70, 'info');
const pauseLabel = this.isPaused ? ' [PAUSED]' : '';
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}`);
if (this.pauseState.resumeAt) {
const remaining = Math.round((this.pauseState.resumeAt - Date.now()) / 1000);
logger.logBoxLine(`Auto-resume in: ${remaining > 0 ? remaining : 0} seconds`);
}
}
logger.logBoxEnd();
logger.log('');
@@ -660,6 +858,14 @@ export class NupstDaemon {
previousStatus: IUpsStatus | undefined,
triggerReason: 'powerStatusChange' | 'thresholdViolation',
): Promise<void> {
// Check if actions are paused
if (this.isPaused) {
logger.info(
`[PAUSED] Actions suppressed for UPS ${ups.name} (trigger: ${triggerReason})`,
);
return;
}
const actions = ups.actions || [];
// Backward compatibility: if no actions configured, use default shutdown behavior
@@ -836,7 +1042,10 @@ export class NupstDaemon {
// Check all UPS devices
for (const ups of this.config.upsDevices) {
try {
const status = await this.snmp.getUpsStatus(ups.snmp);
const protocol = ups.protocol || 'snmp';
const status = protocol === 'upsd' && ups.upsd
? await this.protocolResolver.getUpsStatus('upsd', undefined, ups.upsd)
: await this.protocolResolver.getUpsStatus('snmp', ups.snmp);
const batteryColor = getBatteryColor(status.batteryCapacity);
const runtimeColor = getRuntimeColor(status.batteryRuntime);
@@ -1065,7 +1274,7 @@ export class NupstDaemon {
logger.log('Config file watcher started');
for await (const event of watcher) {
// Only respond to modify events on the config file
// Respond to modify events on config file
if (
event.kind === 'modify' &&
event.paths.some((p) => p.includes('config.json'))
@@ -1074,6 +1283,14 @@ export class NupstDaemon {
await this.reloadConfig();
}
// Detect pause file changes
if (
(event.kind === 'create' || event.kind === 'modify' || event.kind === 'remove') &&
event.paths.some((p) => p.includes('pause'))
) {
this.checkPauseState();
}
// Stop watching if daemon stopped
if (!this.isRunning) {
break;