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:
261
ts/daemon.ts
261
ts/daemon.ts
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user