fix(core): tidy formatting and minor fixes across CLI, SNMP, HTTP server, migrations and packaging
This commit is contained in:
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/nupst',
|
||||
version: '5.2.1',
|
||||
version: '5.2.2',
|
||||
description: 'Network UPS Shutdown Tool - Monitor SNMP-enabled UPS devices and orchestrate graceful system shutdowns during power emergencies'
|
||||
}
|
||||
|
||||
@@ -26,7 +26,11 @@ export class ScriptAction extends Action {
|
||||
async execute(context: IActionContext): Promise<void> {
|
||||
// Check if we should execute based on trigger mode
|
||||
if (!this.shouldExecute(context)) {
|
||||
logger.info(`Script action skipped (trigger mode: ${this.config.triggerMode || 'powerChangesAndThresholds'})`);
|
||||
logger.info(
|
||||
`Script action skipped (trigger mode: ${
|
||||
this.config.triggerMode || 'powerChangesAndThresholds'
|
||||
})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,9 @@ export class ShutdownAction extends Action {
|
||||
// CRITICAL SAFETY CHECK: Shutdown should NEVER trigger unless UPS is on battery
|
||||
// A low battery while on grid power is not an emergency (the battery is charging)
|
||||
if (context.powerStatus !== 'onBattery') {
|
||||
logger.info(`Shutdown action skipped: UPS is not on battery (status: ${context.powerStatus})`);
|
||||
logger.info(
|
||||
`Shutdown action skipped: UPS is not on battery (status: ${context.powerStatus})`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -54,7 +56,9 @@ export class ShutdownAction extends Action {
|
||||
if (context.triggerReason === 'powerStatusChange') {
|
||||
// 'onlyThresholds' mode ignores power status changes
|
||||
if (mode === 'onlyThresholds') {
|
||||
logger.info('Shutdown action skipped: triggerMode is onlyThresholds, ignoring power change');
|
||||
logger.info(
|
||||
'Shutdown action skipped: triggerMode is onlyThresholds, ignoring power change',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -71,15 +75,22 @@ export class ShutdownAction extends Action {
|
||||
// the daemon for testing, or the UPS may have been on battery for a while.
|
||||
// Only trigger if mode explicitly includes power changes.
|
||||
if (prev === 'unknown') {
|
||||
if (mode === 'onlyPowerChanges' || mode === 'powerChangesAndThresholds' || mode === 'anyChange') {
|
||||
logger.info('Shutdown action triggered: UPS on battery at daemon startup (unknown → onBattery)');
|
||||
if (
|
||||
mode === 'onlyPowerChanges' || mode === 'powerChangesAndThresholds' ||
|
||||
mode === 'anyChange'
|
||||
) {
|
||||
logger.info(
|
||||
'Shutdown action triggered: UPS on battery at daemon startup (unknown → onBattery)',
|
||||
);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Other transitions (e.g., onBattery → onBattery) should not trigger
|
||||
logger.info(`Shutdown action skipped: non-emergency transition (${prev} → ${context.powerStatus})`);
|
||||
logger.info(
|
||||
`Shutdown action skipped: non-emergency transition (${prev} → ${context.powerStatus})`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -98,7 +109,11 @@ export class ShutdownAction extends Action {
|
||||
async execute(context: IActionContext): Promise<void> {
|
||||
// Check if we should execute based on trigger mode and thresholds
|
||||
if (!this.shouldExecute(context)) {
|
||||
logger.info(`Shutdown action skipped (trigger mode: ${this.config.triggerMode || 'powerChangesAndThresholds'})`);
|
||||
logger.info(
|
||||
`Shutdown action skipped (trigger mode: ${
|
||||
this.config.triggerMode || 'powerChangesAndThresholds'
|
||||
})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -46,7 +46,11 @@ export class WebhookAction extends Action {
|
||||
async execute(context: IActionContext): Promise<void> {
|
||||
// Check if we should execute based on trigger mode
|
||||
if (!this.shouldExecute(context)) {
|
||||
logger.info(`Webhook action skipped (trigger mode: ${this.config.triggerMode || 'powerChangesAndThresholds'})`);
|
||||
logger.info(
|
||||
`Webhook action skipped (trigger mode: ${
|
||||
this.config.triggerMode || 'powerChangesAndThresholds'
|
||||
})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -109,7 +113,7 @@ export class WebhookAction extends Action {
|
||||
url.searchParams.append('powerStatus', payload.powerStatus);
|
||||
url.searchParams.append('batteryCapacity', String(payload.batteryCapacity));
|
||||
url.searchParams.append('batteryRuntime', String(payload.batteryRuntime));
|
||||
|
||||
|
||||
url.searchParams.append('triggerReason', payload.triggerReason);
|
||||
url.searchParams.append('timestamp', String(payload.timestamp));
|
||||
}
|
||||
|
||||
187
ts/cli.ts
187
ts/cli.ts
@@ -1,7 +1,7 @@
|
||||
import { execSync } from 'node:child_process';
|
||||
import { Nupst } from './nupst.ts';
|
||||
import { logger, type ITableColumn } from './logger.ts';
|
||||
import { theme, symbols } from './colors.ts';
|
||||
import { type ITableColumn, logger } from './logger.ts';
|
||||
import { symbols, theme } from './colors.ts';
|
||||
|
||||
/**
|
||||
* Class for handling CLI commands
|
||||
@@ -287,10 +287,15 @@ export class NupstCli {
|
||||
try {
|
||||
await this.nupst.getDaemon().loadConfig();
|
||||
} catch (_error) {
|
||||
logger.logBox('Configuration Error', [
|
||||
'No configuration found.',
|
||||
"Please run 'nupst ups add' first to create a configuration.",
|
||||
], 50, 'error');
|
||||
logger.logBox(
|
||||
'Configuration Error',
|
||||
[
|
||||
'No configuration found.',
|
||||
"Please run 'nupst ups add' first to create a configuration.",
|
||||
],
|
||||
50,
|
||||
'error',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -300,17 +305,22 @@ export class NupstCli {
|
||||
// Check if multi-UPS config
|
||||
if (config.upsDevices && Array.isArray(config.upsDevices)) {
|
||||
// === Multi-UPS Configuration ===
|
||||
|
||||
|
||||
// Overview Box
|
||||
logger.log('');
|
||||
logger.logBox('NUPST Configuration', [
|
||||
`UPS Devices: ${theme.highlight(String(config.upsDevices.length))}`,
|
||||
`Groups: ${theme.highlight(String(config.groups ? config.groups.length : 0))}`,
|
||||
`Check Interval: ${theme.info(String(config.checkInterval / 1000))} seconds`,
|
||||
'',
|
||||
theme.dim('Configuration File:'),
|
||||
` ${theme.path('/etc/nupst/config.json')}`,
|
||||
], 60, 'info');
|
||||
logger.logBox(
|
||||
'NUPST Configuration',
|
||||
[
|
||||
`UPS Devices: ${theme.highlight(String(config.upsDevices.length))}`,
|
||||
`Groups: ${theme.highlight(String(config.groups ? config.groups.length : 0))}`,
|
||||
`Check Interval: ${theme.info(String(config.checkInterval / 1000))} seconds`,
|
||||
'',
|
||||
theme.dim('Configuration File:'),
|
||||
` ${theme.path('/etc/nupst/config.json')}`,
|
||||
],
|
||||
60,
|
||||
'info',
|
||||
);
|
||||
|
||||
// HTTP Server Status (if configured)
|
||||
if (config.httpServer) {
|
||||
@@ -319,17 +329,24 @@ export class NupstCli {
|
||||
: theme.dim('Disabled');
|
||||
|
||||
logger.log('');
|
||||
logger.logBox('HTTP Server', [
|
||||
`Status: ${serverStatus}`,
|
||||
...(config.httpServer.enabled ? [
|
||||
`Port: ${theme.highlight(String(config.httpServer.port))}`,
|
||||
`Path: ${theme.highlight(config.httpServer.path)}`,
|
||||
`Auth Token: ${theme.dim('***' + config.httpServer.authToken.slice(-4))}`,
|
||||
'',
|
||||
theme.dim('Usage:'),
|
||||
` curl -H "Authorization: Bearer TOKEN" http://localhost:${config.httpServer.port}${config.httpServer.path}`,
|
||||
] : []),
|
||||
], 70, config.httpServer.enabled ? 'success' : 'default');
|
||||
logger.logBox(
|
||||
'HTTP Server',
|
||||
[
|
||||
`Status: ${serverStatus}`,
|
||||
...(config.httpServer.enabled
|
||||
? [
|
||||
`Port: ${theme.highlight(String(config.httpServer.port))}`,
|
||||
`Path: ${theme.highlight(config.httpServer.path)}`,
|
||||
`Auth Token: ${theme.dim('***' + config.httpServer.authToken.slice(-4))}`,
|
||||
'',
|
||||
theme.dim('Usage:'),
|
||||
` curl -H "Authorization: Bearer TOKEN" http://localhost:${config.httpServer.port}${config.httpServer.path}`,
|
||||
]
|
||||
: []),
|
||||
],
|
||||
70,
|
||||
config.httpServer.enabled ? 'success' : 'default',
|
||||
);
|
||||
}
|
||||
|
||||
// UPS Devices Table
|
||||
@@ -369,8 +386,8 @@ export class NupstCli {
|
||||
id: theme.dim(group.id),
|
||||
mode: group.mode,
|
||||
upsCount: String(upsInGroup.length),
|
||||
ups: upsInGroup.length > 0
|
||||
? upsInGroup.map((ups) => ups.name).join(', ')
|
||||
ups: upsInGroup.length > 0
|
||||
? upsInGroup.map((ups) => ups.name).join(', ')
|
||||
: theme.dim('None'),
|
||||
description: group.description || theme.dim('—'),
|
||||
};
|
||||
@@ -392,62 +409,68 @@ export class NupstCli {
|
||||
}
|
||||
} else {
|
||||
// === Legacy Single UPS Configuration ===
|
||||
|
||||
|
||||
if (!config.snmp) {
|
||||
logger.logBox('Configuration Error', [
|
||||
'Error: Legacy configuration missing SNMP settings',
|
||||
], 60, 'error');
|
||||
logger.logBox(
|
||||
'Configuration Error',
|
||||
[
|
||||
'Error: Legacy configuration missing SNMP settings',
|
||||
],
|
||||
60,
|
||||
'error',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.log('');
|
||||
logger.logBox('NUPST Configuration (Legacy)', [
|
||||
theme.warning('Legacy single-UPS configuration format'),
|
||||
'',
|
||||
theme.dim('SNMP Settings:'),
|
||||
` Host: ${theme.info(config.snmp.host)}`,
|
||||
` Port: ${theme.info(String(config.snmp.port))}`,
|
||||
` Version: ${config.snmp.version}`,
|
||||
` UPS Model: ${config.snmp.upsModel || 'cyberpower'}`,
|
||||
...(config.snmp.version === 1 || config.snmp.version === 2
|
||||
? [` Community: ${config.snmp.community}`]
|
||||
: []
|
||||
),
|
||||
...(config.snmp.version === 3
|
||||
? [
|
||||
logger.logBox(
|
||||
'NUPST Configuration (Legacy)',
|
||||
[
|
||||
theme.warning('Legacy single-UPS configuration format'),
|
||||
'',
|
||||
theme.dim('SNMP Settings:'),
|
||||
` Host: ${theme.info(config.snmp.host)}`,
|
||||
` Port: ${theme.info(String(config.snmp.port))}`,
|
||||
` Version: ${config.snmp.version}`,
|
||||
` UPS Model: ${config.snmp.upsModel || 'cyberpower'}`,
|
||||
...(config.snmp.version === 1 || config.snmp.version === 2
|
||||
? [` Community: ${config.snmp.community}`]
|
||||
: []),
|
||||
...(config.snmp.version === 3
|
||||
? [
|
||||
` Security Level: ${config.snmp.securityLevel}`,
|
||||
` Username: ${config.snmp.username}`,
|
||||
...(config.snmp.securityLevel === 'authNoPriv' || config.snmp.securityLevel === 'authPriv'
|
||||
...(config.snmp.securityLevel === 'authNoPriv' ||
|
||||
config.snmp.securityLevel === 'authPriv'
|
||||
? [` Auth Protocol: ${config.snmp.authProtocol || 'None'}`]
|
||||
: []
|
||||
),
|
||||
: []),
|
||||
...(config.snmp.securityLevel === 'authPriv'
|
||||
? [` Privacy Protocol: ${config.snmp.privProtocol || 'None'}`]
|
||||
: []
|
||||
),
|
||||
: []),
|
||||
` Timeout: ${config.snmp.timeout / 1000} seconds`,
|
||||
]
|
||||
: []
|
||||
),
|
||||
...(config.snmp.upsModel === 'custom' && config.snmp.customOIDs
|
||||
? [
|
||||
: []),
|
||||
...(config.snmp.upsModel === 'custom' && config.snmp.customOIDs
|
||||
? [
|
||||
theme.dim('Custom OIDs:'),
|
||||
` Power Status: ${config.snmp.customOIDs.POWER_STATUS || 'Not set'}`,
|
||||
` Battery Capacity: ${config.snmp.customOIDs.BATTERY_CAPACITY || 'Not set'}`,
|
||||
` Battery Runtime: ${config.snmp.customOIDs.BATTERY_RUNTIME || 'Not set'}`,
|
||||
]
|
||||
: []
|
||||
),
|
||||
'',
|
||||
|
||||
` Check Interval: ${config.checkInterval / 1000} seconds`,
|
||||
'',
|
||||
theme.dim('Configuration File:'),
|
||||
` ${theme.path('/etc/nupst/config.json')}`,
|
||||
'',
|
||||
theme.warning('Note: Using legacy single-UPS configuration format.'),
|
||||
`Consider using ${theme.command('nupst ups add')} to migrate to multi-UPS format.`,
|
||||
], 70, 'warning');
|
||||
: []),
|
||||
'',
|
||||
|
||||
` Check Interval: ${config.checkInterval / 1000} seconds`,
|
||||
'',
|
||||
theme.dim('Configuration File:'),
|
||||
` ${theme.path('/etc/nupst/config.json')}`,
|
||||
'',
|
||||
theme.warning('Note: Using legacy single-UPS configuration format.'),
|
||||
`Consider using ${theme.command('nupst ups add')} to migrate to multi-UPS format.`,
|
||||
],
|
||||
70,
|
||||
'warning',
|
||||
);
|
||||
}
|
||||
|
||||
// Service Status
|
||||
@@ -458,10 +481,15 @@ export class NupstCli {
|
||||
execSync('systemctl is-enabled nupst.service || true').toString().trim() === 'enabled';
|
||||
|
||||
logger.log('');
|
||||
logger.logBox('Service Status', [
|
||||
`Active: ${isActive ? theme.success('Yes') : theme.dim('No')}`,
|
||||
`Enabled: ${isEnabled ? theme.success('Yes') : theme.dim('No')}`,
|
||||
], 50, isActive ? 'success' : 'default');
|
||||
logger.logBox(
|
||||
'Service Status',
|
||||
[
|
||||
`Active: ${isActive ? theme.success('Yes') : theme.dim('No')}`,
|
||||
`Enabled: ${isEnabled ? theme.success('Yes') : theme.dim('No')}`,
|
||||
],
|
||||
50,
|
||||
isActive ? 'success' : 'default',
|
||||
);
|
||||
logger.log('');
|
||||
} catch (_error) {
|
||||
// Ignore errors checking service status
|
||||
@@ -514,8 +542,16 @@ export class NupstCli {
|
||||
|
||||
// Service subcommands
|
||||
logger.log(theme.info('Service Subcommands:'));
|
||||
this.printCommand('nupst service enable', 'Install and enable systemd service', theme.dim('(requires root)'));
|
||||
this.printCommand('nupst service disable', 'Stop and disable systemd service', theme.dim('(requires root)'));
|
||||
this.printCommand(
|
||||
'nupst service enable',
|
||||
'Install and enable systemd service',
|
||||
theme.dim('(requires root)'),
|
||||
);
|
||||
this.printCommand(
|
||||
'nupst service disable',
|
||||
'Stop and disable systemd service',
|
||||
theme.dim('(requires root)'),
|
||||
);
|
||||
this.printCommand('nupst service start', 'Start the systemd service');
|
||||
this.printCommand('nupst service stop', 'Stop the systemd service');
|
||||
this.printCommand('nupst service restart', 'Restart the systemd service');
|
||||
@@ -545,7 +581,10 @@ export class NupstCli {
|
||||
logger.log(theme.info('Action Subcommands:'));
|
||||
this.printCommand('nupst action add <target-id>', 'Add a new action to a UPS or group');
|
||||
this.printCommand('nupst action remove <target-id> <index>', 'Remove an action by index');
|
||||
this.printCommand('nupst action list [target-id]', 'List all actions (optionally for specific target)');
|
||||
this.printCommand(
|
||||
'nupst action list [target-id]',
|
||||
'List all actions (optionally for specific target)',
|
||||
);
|
||||
console.log('');
|
||||
|
||||
// Feature subcommands
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import process from 'node:process';
|
||||
import { Nupst } from '../nupst.ts';
|
||||
import { logger, type ITableColumn } from '../logger.ts';
|
||||
import { theme, symbols } from '../colors.ts';
|
||||
import { type ITableColumn, logger } from '../logger.ts';
|
||||
import { symbols, theme } from '../colors.ts';
|
||||
import type { IActionConfig } from '../actions/base-action.ts';
|
||||
import type { IUpsConfig, IGroupConfig } from '../daemon.ts';
|
||||
import type { IGroupConfig, IUpsConfig } from '../daemon.ts';
|
||||
import * as helpers from '../helpers/index.ts';
|
||||
|
||||
/**
|
||||
@@ -48,7 +48,9 @@ export class ActionHandler {
|
||||
if (!ups && !group) {
|
||||
logger.error(`UPS or Group with ID '${targetId}' not found`);
|
||||
logger.log('');
|
||||
logger.log(` ${theme.dim('List available UPS devices:')} ${theme.command('nupst ups list')}`);
|
||||
logger.log(
|
||||
` ${theme.dim('List available UPS devices:')} ${theme.command('nupst ups list')}`,
|
||||
);
|
||||
logger.log(` ${theme.dim('List available groups:')} ${theme.command('nupst group list')}`);
|
||||
logger.log('');
|
||||
process.exit(1);
|
||||
@@ -90,12 +92,16 @@ export class ActionHandler {
|
||||
// Trigger mode
|
||||
logger.log('');
|
||||
logger.log(` ${theme.dim('Trigger mode:')}`);
|
||||
logger.log(` ${theme.dim('1)')} onlyPowerChanges - Trigger only when power status changes`);
|
||||
logger.log(
|
||||
` ${theme.dim('1)')} onlyPowerChanges - Trigger only when power status changes`,
|
||||
);
|
||||
logger.log(
|
||||
` ${theme.dim('2)')} onlyThresholds - Trigger only when thresholds are violated`,
|
||||
);
|
||||
logger.log(
|
||||
` ${theme.dim('3)')} powerChangesAndThresholds - Trigger on power change AND thresholds`,
|
||||
` ${
|
||||
theme.dim('3)')
|
||||
} powerChangesAndThresholds - Trigger on power change AND thresholds`,
|
||||
);
|
||||
logger.log(` ${theme.dim('4)')} anyChange - Trigger on any status change`);
|
||||
const triggerChoice = await prompt(` ${theme.dim('Choice')} ${theme.dim('[2]:')} `);
|
||||
@@ -158,7 +164,9 @@ export class ActionHandler {
|
||||
if (!targetId || !actionIndexStr) {
|
||||
logger.error('Target ID and action index are required');
|
||||
logger.log(
|
||||
` ${theme.dim('Usage:')} ${theme.command('nupst action remove <ups-id|group-id> <action-index>')}`,
|
||||
` ${theme.dim('Usage:')} ${
|
||||
theme.command('nupst action remove <ups-id|group-id> <action-index>')
|
||||
}`,
|
||||
);
|
||||
logger.log('');
|
||||
logger.log(` ${theme.dim('List actions:')} ${theme.command('nupst action list')}`);
|
||||
@@ -182,7 +190,9 @@ export class ActionHandler {
|
||||
if (!ups && !group) {
|
||||
logger.error(`UPS or Group with ID '${targetId}' not found`);
|
||||
logger.log('');
|
||||
logger.log(` ${theme.dim('List available UPS devices:')} ${theme.command('nupst ups list')}`);
|
||||
logger.log(
|
||||
` ${theme.dim('List available UPS devices:')} ${theme.command('nupst ups list')}`,
|
||||
);
|
||||
logger.log(` ${theme.dim('List available groups:')} ${theme.command('nupst group list')}`);
|
||||
logger.log('');
|
||||
process.exit(1);
|
||||
@@ -200,7 +210,9 @@ export class ActionHandler {
|
||||
|
||||
if (actionIndex >= target!.actions.length) {
|
||||
logger.error(
|
||||
`Invalid action index. ${targetType} '${targetName}' has ${target!.actions.length} action(s) (index 0-${target!.actions.length - 1})`,
|
||||
`Invalid action index. ${targetType} '${targetName}' has ${
|
||||
target!.actions.length
|
||||
} action(s) (index 0-${target!.actions.length - 1})`,
|
||||
);
|
||||
logger.log('');
|
||||
logger.log(
|
||||
@@ -220,7 +232,9 @@ export class ActionHandler {
|
||||
logger.log(` ${theme.dim('Type:')} ${removedAction.type}`);
|
||||
if (removedAction.thresholds) {
|
||||
logger.log(
|
||||
` ${theme.dim('Thresholds:')} Battery: ${removedAction.thresholds.battery}%, Runtime: ${removedAction.thresholds.runtime}min`,
|
||||
` ${
|
||||
theme.dim('Thresholds:')
|
||||
} Battery: ${removedAction.thresholds.battery}%, Runtime: ${removedAction.thresholds.runtime}min`,
|
||||
);
|
||||
}
|
||||
logger.log(` ${theme.dim('Changes saved and will be applied automatically')}`);
|
||||
@@ -248,8 +262,12 @@ export class ActionHandler {
|
||||
if (!ups && !group) {
|
||||
logger.error(`UPS or Group with ID '${targetId}' not found`);
|
||||
logger.log('');
|
||||
logger.log(` ${theme.dim('List available UPS devices:')} ${theme.command('nupst ups list')}`);
|
||||
logger.log(` ${theme.dim('List available groups:')} ${theme.command('nupst group list')}`);
|
||||
logger.log(
|
||||
` ${theme.dim('List available UPS devices:')} ${theme.command('nupst ups list')}`,
|
||||
);
|
||||
logger.log(
|
||||
` ${theme.dim('List available groups:')} ${theme.command('nupst group list')}`,
|
||||
);
|
||||
logger.log('');
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -287,7 +305,9 @@ export class ActionHandler {
|
||||
logger.log(` ${theme.dim('No actions configured')}`);
|
||||
logger.log('');
|
||||
logger.log(
|
||||
` ${theme.dim('Add an action:')} ${theme.command('nupst action add <ups-id|group-id>')}`,
|
||||
` ${theme.dim('Add an action:')} ${
|
||||
theme.command('nupst action add <ups-id|group-id>')
|
||||
}`,
|
||||
);
|
||||
logger.log('');
|
||||
}
|
||||
@@ -308,7 +328,9 @@ export class ActionHandler {
|
||||
targetType: 'UPS' | 'Group',
|
||||
): void {
|
||||
logger.log(
|
||||
`${symbols.info} ${targetType} ${theme.highlight(target.name)} ${theme.dim(`(${target.id})`)}`,
|
||||
`${symbols.info} ${targetType} ${theme.highlight(target.name)} ${
|
||||
theme.dim(`(${target.id})`)
|
||||
}`,
|
||||
);
|
||||
logger.log('');
|
||||
|
||||
|
||||
@@ -29,7 +29,9 @@ export class FeatureHandler {
|
||||
await this.runHttpServerConfig(prompt);
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`HTTP Server config error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
logger.error(
|
||||
`HTTP Server config error: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,7 +151,9 @@ export class FeatureHandler {
|
||||
logger.logBoxLine(`Auth Token: ${theme.warning(authToken)}`);
|
||||
logger.logBoxLine('');
|
||||
logger.logBoxLine(theme.dim('Usage examples:'));
|
||||
logger.logBoxLine(` curl -H "Authorization: Bearer ${authToken}" http://localhost:${port}${finalPath}`);
|
||||
logger.logBoxLine(
|
||||
` curl -H "Authorization: Bearer ${authToken}" http://localhost:${port}${finalPath}`,
|
||||
);
|
||||
logger.logBoxLine(` curl "http://localhost:${port}${finalPath}?token=${authToken}"`);
|
||||
logger.logBoxEnd();
|
||||
logger.log('');
|
||||
@@ -165,7 +169,8 @@ export class FeatureHandler {
|
||||
*/
|
||||
private async restartServiceIfRunning(): Promise<void> {
|
||||
try {
|
||||
const isActive = execSync('systemctl is-active nupst.service || true').toString().trim() === 'active';
|
||||
const isActive =
|
||||
execSync('systemctl is-active nupst.service || true').toString().trim() === 'active';
|
||||
|
||||
if (isActive) {
|
||||
logger.log('');
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import process from 'node:process';
|
||||
import { Nupst } from '../nupst.ts';
|
||||
import { logger, type ITableColumn } from '../logger.ts';
|
||||
import { type ITableColumn, logger } from '../logger.ts';
|
||||
import { theme } from '../colors.ts';
|
||||
import * as helpers from '../helpers/index.ts';
|
||||
import type { IGroupConfig, IUpsConfig, INupstConfig } from '../daemon.ts';
|
||||
import type { IGroupConfig, INupstConfig, IUpsConfig } from '../daemon.ts';
|
||||
|
||||
/**
|
||||
* Class for handling group-related CLI commands
|
||||
@@ -29,10 +29,15 @@ export class GroupHandler {
|
||||
try {
|
||||
await this.nupst.getDaemon().loadConfig();
|
||||
} catch (error) {
|
||||
logger.logBox('Configuration Error', [
|
||||
'No configuration found.',
|
||||
"Please run 'nupst ups add' first to create a configuration.",
|
||||
], 50, 'error');
|
||||
logger.logBox(
|
||||
'Configuration Error',
|
||||
[
|
||||
'No configuration found.',
|
||||
"Please run 'nupst ups add' first to create a configuration.",
|
||||
],
|
||||
50,
|
||||
'error',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -41,21 +46,35 @@ export class GroupHandler {
|
||||
|
||||
// Check if multi-UPS config
|
||||
if (!config.groups || !Array.isArray(config.groups)) {
|
||||
logger.logBox('UPS Groups', [
|
||||
'No groups configured.',
|
||||
'',
|
||||
`${theme.dim('Run')} ${theme.command('nupst group add')} ${theme.dim('to add a group')}`,
|
||||
], 50, 'info');
|
||||
logger.logBox(
|
||||
'UPS Groups',
|
||||
[
|
||||
'No groups configured.',
|
||||
'',
|
||||
`${theme.dim('Run')} ${theme.command('nupst group add')} ${
|
||||
theme.dim('to add a group')
|
||||
}`,
|
||||
],
|
||||
50,
|
||||
'info',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Display group list with modern table
|
||||
if (config.groups.length === 0) {
|
||||
logger.logBox('UPS Groups', [
|
||||
'No UPS groups configured.',
|
||||
'',
|
||||
`${theme.dim('Run')} ${theme.command('nupst group add')} ${theme.dim('to add a group')}`,
|
||||
], 60, 'info');
|
||||
logger.logBox(
|
||||
'UPS Groups',
|
||||
[
|
||||
'No UPS groups configured.',
|
||||
'',
|
||||
`${theme.dim('Run')} ${theme.command('nupst group add')} ${
|
||||
theme.dim('to add a group')
|
||||
}`,
|
||||
],
|
||||
60,
|
||||
'info',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -147,8 +147,12 @@ export class ServiceHandler {
|
||||
const latestVersion = release.tag_name; // e.g., "v4.0.7"
|
||||
|
||||
// Normalize versions for comparison (ensure both have "v" prefix)
|
||||
const normalizedCurrent = currentVersion.startsWith('v') ? currentVersion : `v${currentVersion}`;
|
||||
const normalizedLatest = latestVersion.startsWith('v') ? latestVersion : `v${latestVersion}`;
|
||||
const normalizedCurrent = currentVersion.startsWith('v')
|
||||
? currentVersion
|
||||
: `v${currentVersion}`;
|
||||
const normalizedLatest = latestVersion.startsWith('v')
|
||||
? latestVersion
|
||||
: `v${latestVersion}`;
|
||||
|
||||
logger.dim(`Current version: ${normalizedCurrent}`);
|
||||
logger.dim(`Latest version: ${normalizedLatest}`);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import process from 'node:process';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { Nupst } from '../nupst.ts';
|
||||
import { logger, type ITableColumn } from '../logger.ts';
|
||||
import { type ITableColumn, logger } from '../logger.ts';
|
||||
import { theme } from '../colors.ts';
|
||||
import * as helpers from '../helpers/index.ts';
|
||||
import type { ISnmpConfig, TUpsModel, IUpsStatus as ISnmpUpsStatus } from '../snmp/types.ts';
|
||||
import type { ISnmpConfig, IUpsStatus as ISnmpUpsStatus, TUpsModel } from '../snmp/types.ts';
|
||||
import type { INupstConfig, IUpsConfig, IUpsStatus } from '../daemon.ts';
|
||||
import type { IActionConfig } from '../actions/base-action.ts';
|
||||
|
||||
@@ -66,10 +66,10 @@ export class UpsHandler {
|
||||
checkInterval: config.checkInterval,
|
||||
upsDevices: [{
|
||||
id: 'default',
|
||||
name: 'Default UPS',
|
||||
snmp: config.snmp,
|
||||
groups: [],
|
||||
actions: [],
|
||||
name: 'Default UPS',
|
||||
snmp: config.snmp,
|
||||
groups: [],
|
||||
actions: [],
|
||||
}],
|
||||
groups: [],
|
||||
};
|
||||
@@ -123,7 +123,7 @@ export class UpsHandler {
|
||||
await groupHandler.assignUpsToGroups(newUps, config.groups, prompt);
|
||||
}
|
||||
|
||||
// Gather action settings
|
||||
// Gather action settings
|
||||
await this.gatherActionSettings(newUps.actions, prompt);
|
||||
|
||||
// Add the new UPS to the config
|
||||
@@ -343,10 +343,15 @@ export class UpsHandler {
|
||||
try {
|
||||
await this.nupst.getDaemon().loadConfig();
|
||||
} catch (error) {
|
||||
logger.logBox('Configuration Error', [
|
||||
'No configuration found.',
|
||||
"Please run 'nupst ups add' first to create a configuration.",
|
||||
], 50, 'error');
|
||||
logger.logBox(
|
||||
'Configuration Error',
|
||||
[
|
||||
'No configuration found.',
|
||||
"Please run 'nupst ups add' first to create a configuration.",
|
||||
],
|
||||
50,
|
||||
'error',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -356,31 +361,38 @@ export class UpsHandler {
|
||||
// Check if multi-UPS config
|
||||
if (!config.upsDevices || !Array.isArray(config.upsDevices)) {
|
||||
// Legacy single UPS configuration
|
||||
logger.logBox('UPS Devices', [
|
||||
'Legacy single-UPS configuration detected.',
|
||||
'',
|
||||
...(!config.snmp
|
||||
? ['Error: Configuration missing SNMP settings']
|
||||
: [
|
||||
'Default UPS:',
|
||||
` Host: ${config.snmp.host}:${config.snmp.port}`,
|
||||
` Model: ${config.snmp.upsModel || 'cyberpower'}`,
|
||||
'',
|
||||
'Use "nupst ups add" to add more UPS devices and migrate',
|
||||
'to the multi-UPS configuration format.',
|
||||
]
|
||||
),
|
||||
], 60, 'warning');
|
||||
logger.logBox(
|
||||
'UPS Devices',
|
||||
[
|
||||
'Legacy single-UPS configuration detected.',
|
||||
'',
|
||||
...(!config.snmp ? ['Error: Configuration missing SNMP settings'] : [
|
||||
'Default UPS:',
|
||||
` Host: ${config.snmp.host}:${config.snmp.port}`,
|
||||
` Model: ${config.snmp.upsModel || 'cyberpower'}`,
|
||||
'',
|
||||
'Use "nupst ups add" to add more UPS devices and migrate',
|
||||
'to the multi-UPS configuration format.',
|
||||
]),
|
||||
],
|
||||
60,
|
||||
'warning',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Display UPS list with modern table
|
||||
if (config.upsDevices.length === 0) {
|
||||
logger.logBox('UPS Devices', [
|
||||
'No UPS devices configured.',
|
||||
'',
|
||||
`${theme.dim('Run')} ${theme.command('nupst ups add')} ${theme.dim('to add a device')}`,
|
||||
], 60, 'info');
|
||||
logger.logBox(
|
||||
'UPS Devices',
|
||||
[
|
||||
'No UPS devices configured.',
|
||||
'',
|
||||
`${theme.dim('Run')} ${theme.command('nupst ups add')} ${theme.dim('to add a device')}`,
|
||||
],
|
||||
60,
|
||||
'info',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -569,8 +581,6 @@ export class UpsHandler {
|
||||
logger.logBoxLine(` Battery Capacity: ${status.batteryCapacity}%`);
|
||||
logger.logBoxLine(` Runtime Remaining: ${status.batteryRuntime} minutes`);
|
||||
logger.logBoxEnd();
|
||||
|
||||
|
||||
} catch (error) {
|
||||
const errorBoxWidth = 45;
|
||||
logger.logBoxTitle(`Connection Failed: ${upsName}`, errorBoxWidth);
|
||||
@@ -959,7 +969,7 @@ export class UpsHandler {
|
||||
logger.dim(' 4) Any change (every ~30s check)');
|
||||
const triggerInput = await prompt('Select trigger mode [1]: ');
|
||||
const triggerValue = parseInt(triggerInput, 10) || 1;
|
||||
|
||||
|
||||
switch (triggerValue) {
|
||||
case 2:
|
||||
action.triggerMode = 'onlyPowerChanges';
|
||||
@@ -975,11 +985,16 @@ export class UpsHandler {
|
||||
}
|
||||
|
||||
// Configure thresholds if needed for onlyThresholds or powerChangesAndThresholds modes
|
||||
if (action.triggerMode === 'onlyThresholds' || action.triggerMode === 'powerChangesAndThresholds') {
|
||||
if (
|
||||
action.triggerMode === 'onlyThresholds' ||
|
||||
action.triggerMode === 'powerChangesAndThresholds'
|
||||
) {
|
||||
logger.log('');
|
||||
logger.info('Action Thresholds:');
|
||||
logger.dim('Action will trigger when battery or runtime falls below these values (while on battery)');
|
||||
|
||||
logger.dim(
|
||||
'Action will trigger when battery or runtime falls below these values (while on battery)',
|
||||
);
|
||||
|
||||
const batteryInput = await prompt('Battery threshold percentage [60]: ');
|
||||
const battery = parseInt(batteryInput, 10);
|
||||
const batteryThreshold = (batteryInput.trim() && !isNaN(battery)) ? battery : 60;
|
||||
@@ -995,7 +1010,11 @@ export class UpsHandler {
|
||||
}
|
||||
|
||||
actions.push(action as IActionConfig);
|
||||
logger.success(`${action.type!.charAt(0).toUpperCase() + action.type!.slice(1)} action added (mode: ${action.triggerMode || 'powerChangesAndThresholds'})`);
|
||||
logger.success(
|
||||
`${action.type!.charAt(0).toUpperCase() + action.type!.slice(1)} action added (mode: ${
|
||||
action.triggerMode || 'powerChangesAndThresholds'
|
||||
})`,
|
||||
);
|
||||
|
||||
const more = await prompt('Add another action? (y/N): ');
|
||||
addMore = more.toLowerCase() === 'y';
|
||||
@@ -1019,7 +1038,7 @@ export class UpsHandler {
|
||||
logger.logBoxLine(`SNMP Host: ${ups.snmp.host}:${ups.snmp.port}`);
|
||||
logger.logBoxLine(`SNMP Version: ${ups.snmp.version}`);
|
||||
logger.logBoxLine(`UPS Model: ${ups.snmp.upsModel}`);
|
||||
|
||||
|
||||
if (ups.groups && ups.groups.length > 0) {
|
||||
logger.logBoxLine(`Groups: ${ups.groups.join(', ')}`);
|
||||
} else {
|
||||
|
||||
74
ts/daemon.ts
74
ts/daemon.ts
@@ -5,13 +5,13 @@ 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 { logger, type ITableColumn } from './logger.ts';
|
||||
import { type ITableColumn, logger } from './logger.ts';
|
||||
import { MigrationRunner } from './migrations/index.ts';
|
||||
import { theme, symbols, getBatteryColor, getRuntimeColor, formatPowerStatus } from './colors.ts';
|
||||
import { formatPowerStatus, getBatteryColor, getRuntimeColor, symbols, 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 { TIMING, THRESHOLDS, UI } from './constants.ts';
|
||||
import { THRESHOLDS, TIMING, UI } from './constants.ts';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const execFileAsync = promisify(execFile);
|
||||
@@ -100,10 +100,10 @@ export interface IUpsStatus {
|
||||
powerStatus: 'online' | 'onBattery' | 'unknown';
|
||||
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
|
||||
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;
|
||||
}
|
||||
@@ -155,7 +155,7 @@ export class NupstDaemon {
|
||||
],
|
||||
groups: [],
|
||||
checkInterval: TIMING.CHECK_INTERVAL_MS, // Check every 30 seconds
|
||||
}
|
||||
};
|
||||
|
||||
private config: INupstConfig;
|
||||
private snmp: NupstSnmp;
|
||||
@@ -249,7 +249,12 @@ export class NupstDaemon {
|
||||
* Helper method to log configuration errors consistently
|
||||
*/
|
||||
private logConfigError(message: string): void {
|
||||
logger.logBox('Configuration Error', [message, "Please run 'nupst setup' first to create a configuration."], 45, 'error');
|
||||
logger.logBox(
|
||||
'Configuration Error',
|
||||
[message, "Please run 'nupst setup' first to create a configuration."],
|
||||
45,
|
||||
'error',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -311,11 +316,15 @@ export class NupstDaemon {
|
||||
this.config.httpServer.port,
|
||||
this.config.httpServer.path,
|
||||
this.config.httpServer.authToken,
|
||||
() => this.upsStatus
|
||||
() => this.upsStatus,
|
||||
);
|
||||
this.httpServer.start();
|
||||
} catch (error) {
|
||||
logger.error(`Failed to start HTTP server: ${error instanceof Error ? error.message : String(error)}`);
|
||||
logger.error(
|
||||
`Failed to start HTTP server: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,7 +373,6 @@ export class NupstDaemon {
|
||||
* Log the loaded configuration settings
|
||||
*/
|
||||
private logConfigLoaded(): void {
|
||||
|
||||
logger.log('');
|
||||
logger.logBoxTitle('Configuration Loaded', 70, 'success');
|
||||
logger.logBoxLine(`Check Interval: ${this.config.checkInterval / 1000} seconds`);
|
||||
@@ -374,8 +382,10 @@ export class NupstDaemon {
|
||||
// Display UPS devices in a table
|
||||
if (this.config.upsDevices && this.config.upsDevices.length > 0) {
|
||||
logger.info(`UPS Devices (${this.config.upsDevices.length}):`);
|
||||
|
||||
const upsColumns: Array<{ header: string; key: string; align?: 'left' | 'right'; color?: (val: string) => string }> = [
|
||||
|
||||
const upsColumns: Array<
|
||||
{ header: string; key: string; align?: 'left' | 'right'; color?: (val: string) => string }
|
||||
> = [
|
||||
{ header: 'Name', key: 'name', align: 'left', color: theme.highlight },
|
||||
{ header: 'ID', key: 'id', align: 'left', color: theme.dim },
|
||||
{ header: 'Host:Port', key: 'host', align: 'left', color: theme.info },
|
||||
@@ -399,8 +409,10 @@ export class NupstDaemon {
|
||||
// Display groups in a table
|
||||
if (this.config.groups && this.config.groups.length > 0) {
|
||||
logger.info(`Groups (${this.config.groups.length}):`);
|
||||
|
||||
const groupColumns: Array<{ header: string; key: string; align?: 'left' | 'right'; color?: (val: string) => string }> = [
|
||||
|
||||
const groupColumns: Array<
|
||||
{ header: string; key: string; align?: 'left' | 'right'; color?: (val: string) => string }
|
||||
> = [
|
||||
{ header: 'Name', key: 'name', align: 'left', color: theme.highlight },
|
||||
{ header: 'ID', key: 'id', align: 'left', color: theme.dim },
|
||||
{ header: 'Mode', key: 'mode', align: 'left', color: theme.info },
|
||||
@@ -538,7 +550,7 @@ export class NupstDaemon {
|
||||
// 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 (
|
||||
@@ -575,7 +587,7 @@ export class NupstDaemon {
|
||||
*/
|
||||
private logAllUpsStatus(): void {
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
|
||||
logger.log('');
|
||||
logger.logBoxTitle('Periodic Status Update', 70, 'info');
|
||||
logger.logBoxLine(`Timestamp: ${timestamp}`);
|
||||
@@ -583,7 +595,9 @@ export class NupstDaemon {
|
||||
logger.log('');
|
||||
|
||||
// Build table data
|
||||
const columns: Array<{ header: string; key: string; align?: 'left' | 'right'; color?: (val: string) => string }> = [
|
||||
const columns: Array<
|
||||
{ header: string; key: string; align?: 'left' | 'right'; color?: (val: string) => string }
|
||||
> = [
|
||||
{ header: 'UPS Name', key: 'name', align: 'left', color: theme.highlight },
|
||||
{ header: 'ID', key: 'id', align: 'left', color: theme.dim },
|
||||
{ header: 'Power Status', key: 'powerStatus', align: 'left' },
|
||||
@@ -595,7 +609,7 @@ export class NupstDaemon {
|
||||
for (const [id, status] of this.upsStatus.entries()) {
|
||||
const batteryColor = getBatteryColor(status.batteryCapacity);
|
||||
const runtimeColor = getRuntimeColor(status.batteryRuntime);
|
||||
|
||||
|
||||
rows.push({
|
||||
name: status.name,
|
||||
id: id,
|
||||
@@ -609,10 +623,6 @@ export class NupstDaemon {
|
||||
logger.log('');
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Build action context from UPS state
|
||||
* @param ups UPS configuration
|
||||
@@ -796,7 +806,9 @@ export class NupstDaemon {
|
||||
|
||||
logger.log('');
|
||||
logger.logBoxTitle('Shutdown Monitoring Active', UI.WIDE_BOX_WIDTH, 'warning');
|
||||
logger.logBoxLine(`Emergency threshold: ${THRESHOLDS.EMERGENCY_RUNTIME_MINUTES} minutes runtime`);
|
||||
logger.logBoxLine(
|
||||
`Emergency threshold: ${THRESHOLDS.EMERGENCY_RUNTIME_MINUTES} minutes runtime`,
|
||||
);
|
||||
logger.logBoxLine(`Check interval: ${TIMING.SHUTDOWN_CHECK_INTERVAL_MS / 1000} seconds`);
|
||||
logger.logBoxLine(`Max monitoring time: ${TIMING.MAX_SHUTDOWN_MONITORING_MS / 1000} seconds`);
|
||||
logger.logBoxEnd();
|
||||
@@ -808,7 +820,9 @@ export class NupstDaemon {
|
||||
logger.info('Checking UPS status during shutdown...');
|
||||
|
||||
// Build table for UPS status during shutdown
|
||||
const columns: Array<{ header: string; key: string; align?: 'left' | 'right'; color?: (val: string) => string }> = [
|
||||
const columns: Array<
|
||||
{ header: string; key: string; align?: 'left' | 'right'; color?: (val: string) => string }
|
||||
> = [
|
||||
{ header: 'UPS Name', key: 'name', align: 'left', color: theme.highlight },
|
||||
{ header: 'Battery', key: 'battery', align: 'right' },
|
||||
{ header: 'Runtime', key: 'runtime', align: 'right' },
|
||||
@@ -828,7 +842,7 @@ export class NupstDaemon {
|
||||
const runtimeColor = getRuntimeColor(status.batteryRuntime);
|
||||
|
||||
const isCritical = status.batteryRuntime < THRESHOLDS.EMERGENCY_RUNTIME_MINUTES;
|
||||
|
||||
|
||||
rows.push({
|
||||
name: ups.name,
|
||||
battery: batteryColor(status.batteryCapacity + '%'),
|
||||
@@ -848,7 +862,7 @@ export class NupstDaemon {
|
||||
runtime: theme.error('N/A'),
|
||||
status: theme.error('ERROR'),
|
||||
});
|
||||
|
||||
|
||||
logger.error(
|
||||
`Error checking UPS ${ups.name} during shutdown: ${
|
||||
upsError instanceof Error ? upsError.message : String(upsError)
|
||||
@@ -991,7 +1005,9 @@ export class NupstDaemon {
|
||||
let lastConfigCheck = Date.now();
|
||||
|
||||
logger.log('Entering idle monitoring mode...');
|
||||
logger.log(`Daemon will check for config changes every ${TIMING.IDLE_CHECK_INTERVAL_MS / 1000} seconds`);
|
||||
logger.log(
|
||||
`Daemon will check for config changes every ${TIMING.IDLE_CHECK_INTERVAL_MS / 1000} seconds`,
|
||||
);
|
||||
|
||||
// Start file watcher for hot-reload
|
||||
this.watchConfigFile();
|
||||
|
||||
@@ -25,7 +25,7 @@ export class NupstHttpServer {
|
||||
port: number,
|
||||
path: string,
|
||||
authToken: string,
|
||||
getUpsStatus: () => Map<string, IUpsStatus>
|
||||
getUpsStatus: () => Map<string, IUpsStatus>,
|
||||
) {
|
||||
this.port = port;
|
||||
this.path = path;
|
||||
@@ -70,7 +70,7 @@ export class NupstHttpServer {
|
||||
if (!this.isAuthenticated(req)) {
|
||||
res.writeHead(401, {
|
||||
'Content-Type': 'application/json',
|
||||
'WWW-Authenticate': 'Bearer'
|
||||
'WWW-Authenticate': 'Bearer',
|
||||
});
|
||||
res.end(JSON.stringify({ error: 'Unauthorized' }));
|
||||
return;
|
||||
@@ -82,7 +82,7 @@ export class NupstHttpServer {
|
||||
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-cache'
|
||||
'Cache-Control': 'no-cache',
|
||||
});
|
||||
res.end(JSON.stringify(statusArray, null, 2));
|
||||
} else {
|
||||
@@ -95,7 +95,7 @@ export class NupstHttpServer {
|
||||
logger.success(`HTTP server started on port ${this.port} at ${this.path}`);
|
||||
});
|
||||
|
||||
this.server.on('error', (error: any) => {
|
||||
this.server.on('error', (error: Error) => {
|
||||
logger.error(`HTTP server error: ${error.message}`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { theme, symbols } from './colors.ts';
|
||||
import { symbols, theme } from './colors.ts';
|
||||
|
||||
/**
|
||||
* Table column alignment options
|
||||
|
||||
@@ -31,7 +31,9 @@ export abstract class BaseMigration {
|
||||
* @param config - Raw configuration object to check (unknown schema for migrations)
|
||||
* @returns True if migration should run, false otherwise
|
||||
*/
|
||||
abstract shouldRun(config: Record<string, unknown>): Promise<boolean>;
|
||||
abstract shouldRun(
|
||||
config: Record<string, unknown>,
|
||||
): boolean | Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Perform the migration on the given config
|
||||
@@ -39,7 +41,9 @@ export abstract class BaseMigration {
|
||||
* @param config - Raw configuration object to migrate (unknown schema for migrations)
|
||||
* @returns Migrated configuration object
|
||||
*/
|
||||
abstract migrate(config: Record<string, unknown>): Promise<Record<string, unknown>>;
|
||||
abstract migrate(
|
||||
config: Record<string, unknown>,
|
||||
): Record<string, unknown> | Promise<Record<string, unknown>>;
|
||||
|
||||
/**
|
||||
* Get human-readable name for this migration
|
||||
|
||||
@@ -23,12 +23,12 @@ export class MigrationV1ToV2 extends BaseMigration {
|
||||
readonly fromVersion = '1.x';
|
||||
readonly toVersion = '2.0';
|
||||
|
||||
async shouldRun(config: any): Promise<boolean> {
|
||||
shouldRun(config: Record<string, unknown>): boolean {
|
||||
// V1 format has snmp field directly at root, no upsDevices or upsList
|
||||
return !!config.snmp && !config.upsDevices && !config.upsList;
|
||||
}
|
||||
|
||||
async migrate(config: any): Promise<any> {
|
||||
migrate(config: Record<string, unknown>): Record<string, unknown> {
|
||||
logger.info(`${this.getName()}: Converting single SNMP config to multi-UPS format...`);
|
||||
|
||||
const migrated = {
|
||||
|
||||
@@ -42,15 +42,16 @@ export class MigrationV3ToV4 extends BaseMigration {
|
||||
readonly fromVersion = '3.x';
|
||||
readonly toVersion = '4.0';
|
||||
|
||||
async shouldRun(config: any): Promise<boolean> {
|
||||
shouldRun(config: Record<string, unknown>): boolean {
|
||||
// V3 format has upsList OR has upsDevices with flat structure (host at top level)
|
||||
if (config.upsList && !config.upsDevices) {
|
||||
return true; // Classic v3 with upsList
|
||||
}
|
||||
|
||||
// Check if upsDevices exists but has flat structure (v3 format)
|
||||
if (config.upsDevices && config.upsDevices.length > 0) {
|
||||
const firstDevice = config.upsDevices[0];
|
||||
const upsDevices = config.upsDevices as Array<Record<string, unknown>> | undefined;
|
||||
if (upsDevices && upsDevices.length > 0) {
|
||||
const firstDevice = upsDevices[0];
|
||||
// V3 has host at top level, v4 has it nested in snmp object
|
||||
return !!firstDevice.host && !firstDevice.snmp;
|
||||
}
|
||||
@@ -58,17 +59,17 @@ export class MigrationV3ToV4 extends BaseMigration {
|
||||
return false;
|
||||
}
|
||||
|
||||
async migrate(config: any): Promise<any> {
|
||||
migrate(config: Record<string, unknown>): Record<string, unknown> {
|
||||
logger.info(`${this.getName()}: Migrating v3 config to v4 format...`);
|
||||
logger.dim(` - Restructuring UPS devices (flat → nested snmp config)`);
|
||||
|
||||
// Get devices from either upsList or upsDevices (for partially migrated configs)
|
||||
const sourceDevices = config.upsList || config.upsDevices;
|
||||
const sourceDevices = (config.upsList || config.upsDevices) as Array<Record<string, unknown>>;
|
||||
|
||||
// Transform each UPS device from v3 flat structure to v4 nested structure
|
||||
const transformedDevices = sourceDevices.map((device: any) => {
|
||||
const transformedDevices = sourceDevices.map((device: Record<string, unknown>) => {
|
||||
// Build SNMP config object
|
||||
const snmpConfig: any = {
|
||||
const snmpConfig: Record<string, unknown> = {
|
||||
host: device.host,
|
||||
port: device.port || 161,
|
||||
version: typeof device.version === 'string' ? parseInt(device.version, 10) : device.version,
|
||||
@@ -112,7 +113,9 @@ export class MigrationV3ToV4 extends BaseMigration {
|
||||
checkInterval: config.checkInterval || 30000,
|
||||
};
|
||||
|
||||
logger.success(`${this.getName()}: Migration complete (${transformedDevices.length} devices transformed)`);
|
||||
logger.success(
|
||||
`${this.getName()}: Migration complete (${transformedDevices.length} devices transformed)`,
|
||||
);
|
||||
return migrated;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ export class MigrationV4_0ToV4_1 extends BaseMigration {
|
||||
readonly fromVersion = '4.0';
|
||||
readonly toVersion = '4.1';
|
||||
|
||||
async shouldRun(config: Record<string, unknown>): Promise<boolean> {
|
||||
shouldRun(config: Record<string, unknown>): boolean {
|
||||
// Run if config is version 4.0
|
||||
if (config.version === '4.0') {
|
||||
return true;
|
||||
@@ -65,7 +65,7 @@ export class MigrationV4_0ToV4_1 extends BaseMigration {
|
||||
return false;
|
||||
}
|
||||
|
||||
async migrate(config: Record<string, unknown>): Promise<Record<string, unknown>> {
|
||||
migrate(config: Record<string, unknown>): Record<string, unknown> {
|
||||
logger.info(`${this.getName()}: Migrating v4.0 config to v4.1 format...`);
|
||||
logger.dim(` - Moving thresholds from UPS level to action level`);
|
||||
logger.dim(` - Creating default shutdown actions from existing thresholds`);
|
||||
@@ -81,7 +81,9 @@ export class MigrationV4_0ToV4_1 extends BaseMigration {
|
||||
};
|
||||
|
||||
// If device has thresholds at UPS level, convert to shutdown action
|
||||
const deviceThresholds = device.thresholds as { battery: number; runtime: number } | undefined;
|
||||
const deviceThresholds = device.thresholds as
|
||||
| { battery: number; runtime: number }
|
||||
| undefined;
|
||||
if (deviceThresholds) {
|
||||
migrated.actions = [
|
||||
{
|
||||
|
||||
@@ -171,8 +171,8 @@ export class Nupst implements INupstAccessor {
|
||||
const response = JSON.parse(data);
|
||||
if (response.tag_name) {
|
||||
// Strip 'v' prefix from tag name (e.g., "v5.1.7" -> "5.1.7")
|
||||
const version = response.tag_name.startsWith('v')
|
||||
? response.tag_name.substring(1)
|
||||
const version = response.tag_name.startsWith('v')
|
||||
? response.tag_name.substring(1)
|
||||
: response.tag_name;
|
||||
resolve(version);
|
||||
} else {
|
||||
|
||||
@@ -197,7 +197,11 @@ export class NupstSnmp {
|
||||
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'}`);
|
||||
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);
|
||||
@@ -259,7 +263,9 @@ export class NupstSnmp {
|
||||
}
|
||||
|
||||
if (this.debug) {
|
||||
logger.dim(`SNMP response: oid=${varbinds[0].oid}, type=${varbinds[0].type}, value=${value}`);
|
||||
logger.dim(
|
||||
`SNMP response: oid=${varbinds[0].oid}, type=${varbinds[0].type}, value=${value}`,
|
||||
);
|
||||
}
|
||||
|
||||
resolve(value);
|
||||
@@ -496,7 +502,9 @@ export class NupstSnmp {
|
||||
} catch (retryError) {
|
||||
if (this.debug) {
|
||||
logger.error(
|
||||
`Retry failed for ${description}: ${retryError instanceof Error ? retryError.message : String(retryError)}`,
|
||||
`Retry failed for ${description}: ${
|
||||
retryError instanceof Error ? retryError.message : String(retryError)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -517,7 +525,9 @@ export class NupstSnmp {
|
||||
} catch (retryError) {
|
||||
if (this.debug) {
|
||||
logger.error(
|
||||
`Retry failed for ${description}: ${retryError instanceof Error ? retryError.message : String(retryError)}`,
|
||||
`Retry failed for ${description}: ${
|
||||
retryError instanceof Error ? retryError.message : String(retryError)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -556,7 +566,9 @@ export class NupstSnmp {
|
||||
} catch (stdError) {
|
||||
if (this.debug) {
|
||||
logger.error(
|
||||
`Standard OID retry failed for ${description}: ${stdError instanceof Error ? stdError.message : String(stdError)}`,
|
||||
`Standard OID retry failed for ${description}: ${
|
||||
stdError instanceof Error ? stdError.message : String(stdError)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ export interface IUpsStatus {
|
||||
/** Output current in amps */
|
||||
outputCurrent: number;
|
||||
/** Raw values from SNMP responses */
|
||||
raw: Record<string, any>;
|
||||
raw: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
101
ts/systemd.ts
101
ts/systemd.ts
@@ -1,10 +1,10 @@
|
||||
import process from 'node:process';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { NupstDaemon, type IUpsConfig } from './daemon.ts';
|
||||
import { type IUpsConfig, NupstDaemon } from './daemon.ts';
|
||||
import { NupstSnmp } from './snmp/manager.ts';
|
||||
import { logger } from './logger.ts';
|
||||
import { theme, symbols, getBatteryColor, getRuntimeColor, formatPowerStatus } from './colors.ts';
|
||||
import { formatPowerStatus, getBatteryColor, getRuntimeColor, symbols, theme } from './colors.ts';
|
||||
|
||||
/**
|
||||
* Class for managing systemd service
|
||||
@@ -54,7 +54,11 @@ WantedBy=multi-user.target
|
||||
logger.log('');
|
||||
logger.error('No configuration found');
|
||||
logger.log(` ${theme.dim('Config file:')} ${configPath}`);
|
||||
logger.log(` ${theme.dim('Run')} ${theme.command('nupst ups add')} ${theme.dim('to create a configuration')}`);
|
||||
logger.log(
|
||||
` ${theme.dim('Run')} ${theme.command('nupst ups add')} ${
|
||||
theme.dim('to create a configuration')
|
||||
}`,
|
||||
);
|
||||
logger.log('');
|
||||
throw new Error('Configuration not found');
|
||||
}
|
||||
@@ -155,13 +159,19 @@ WantedBy=multi-user.target
|
||||
const updateStatus = nupst.getUpdateStatus();
|
||||
logger.log('');
|
||||
logger.log(
|
||||
`${theme.dim('NUPST')} ${theme.dim('v' + version)} ${symbols.warning} ${theme.statusWarning(`Update available: v${updateStatus.latestVersion}`)}`,
|
||||
`${theme.dim('NUPST')} ${theme.dim('v' + version)} ${symbols.warning} ${
|
||||
theme.statusWarning(`Update available: v${updateStatus.latestVersion}`)
|
||||
}`,
|
||||
);
|
||||
logger.log(
|
||||
` ${theme.dim('Run')} ${theme.command('sudo nupst update')} ${theme.dim('to upgrade')}`,
|
||||
);
|
||||
logger.log(` ${theme.dim('Run')} ${theme.command('sudo nupst update')} ${theme.dim('to upgrade')}`);
|
||||
} else {
|
||||
logger.log('');
|
||||
logger.log(
|
||||
`${theme.dim('NUPST')} ${theme.dim('v' + version)} ${symbols.success} ${theme.success('Up to date')}`,
|
||||
`${theme.dim('NUPST')} ${theme.dim('v' + version)} ${symbols.success} ${
|
||||
theme.success('Up to date')
|
||||
}`,
|
||||
);
|
||||
}
|
||||
} catch (_error) {
|
||||
@@ -242,9 +252,15 @@ WantedBy=multi-user.target
|
||||
// Display beautiful status
|
||||
logger.log('');
|
||||
if (isActive) {
|
||||
logger.log(`${symbols.running} ${theme.success('Service:')} ${theme.statusActive('active (running)')}`);
|
||||
logger.log(
|
||||
`${symbols.running} ${theme.success('Service:')} ${
|
||||
theme.statusActive('active (running)')
|
||||
}`,
|
||||
);
|
||||
} else {
|
||||
logger.log(`${symbols.stopped} ${theme.dim('Service:')} ${theme.statusInactive('inactive')}`);
|
||||
logger.log(
|
||||
`${symbols.stopped} ${theme.dim('Service:')} ${theme.statusInactive('inactive')}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (pid || memory || cpu) {
|
||||
@@ -255,10 +271,11 @@ WantedBy=multi-user.target
|
||||
logger.log(` ${details.join(' ')}`);
|
||||
}
|
||||
logger.log('');
|
||||
|
||||
} catch (error) {
|
||||
logger.log('');
|
||||
logger.log(`${symbols.stopped} ${theme.dim('Service:')} ${theme.statusInactive('not installed')}`);
|
||||
logger.log(
|
||||
`${symbols.stopped} ${theme.dim('Service:')} ${theme.statusInactive('not installed')}`,
|
||||
);
|
||||
logger.log('');
|
||||
}
|
||||
}
|
||||
@@ -295,13 +312,13 @@ WantedBy=multi-user.target
|
||||
groups: [],
|
||||
actions: config.thresholds
|
||||
? [
|
||||
{
|
||||
type: 'shutdown',
|
||||
thresholds: config.thresholds,
|
||||
triggerMode: 'onlyThresholds',
|
||||
shutdownDelay: 5,
|
||||
},
|
||||
]
|
||||
{
|
||||
type: 'shutdown',
|
||||
thresholds: config.thresholds,
|
||||
triggerMode: 'onlyThresholds',
|
||||
shutdownDelay: 5,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
};
|
||||
|
||||
@@ -309,7 +326,9 @@ WantedBy=multi-user.target
|
||||
} else {
|
||||
logger.log('');
|
||||
logger.warn('No UPS devices configured');
|
||||
logger.log(` ${theme.dim('Run')} ${theme.command('nupst ups add')} ${theme.dim('to add a device')}`);
|
||||
logger.log(
|
||||
` ${theme.dim('Run')} ${theme.command('nupst ups add')} ${theme.dim('to add a device')}`,
|
||||
);
|
||||
logger.log('');
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -344,7 +363,9 @@ WantedBy=multi-user.target
|
||||
}
|
||||
|
||||
// Display UPS name and power status
|
||||
logger.log(` ${statusSymbol} ${theme.highlight(ups.name)} - ${formatPowerStatus(status.powerStatus)}`);
|
||||
logger.log(
|
||||
` ${statusSymbol} ${theme.highlight(ups.name)} - ${formatPowerStatus(status.powerStatus)}`,
|
||||
);
|
||||
|
||||
// Display battery with color coding
|
||||
const batteryColor = getBatteryColor(status.batteryCapacity);
|
||||
@@ -352,16 +373,27 @@ WantedBy=multi-user.target
|
||||
// Get threshold from actions (if any action has thresholds defined)
|
||||
const actionWithThresholds = ups.actions?.find((action) => action.thresholds);
|
||||
const batteryThreshold = actionWithThresholds?.thresholds?.battery;
|
||||
const batterySymbol = batteryThreshold !== undefined && status.batteryCapacity >= batteryThreshold
|
||||
? symbols.success
|
||||
: batteryThreshold !== undefined
|
||||
? symbols.warning
|
||||
: '';
|
||||
const batterySymbol =
|
||||
batteryThreshold !== undefined && status.batteryCapacity >= batteryThreshold
|
||||
? symbols.success
|
||||
: batteryThreshold !== undefined
|
||||
? symbols.warning
|
||||
: '';
|
||||
|
||||
logger.log(` Battery: ${batteryColor(status.batteryCapacity + '%')} ${batterySymbol} Runtime: ${getRuntimeColor(status.batteryRuntime)(status.batteryRuntime + ' min')}`);
|
||||
logger.log(
|
||||
` Battery: ${batteryColor(status.batteryCapacity + '%')} ${batterySymbol} Runtime: ${
|
||||
getRuntimeColor(status.batteryRuntime)(status.batteryRuntime + ' min')
|
||||
}`,
|
||||
);
|
||||
|
||||
// Display power metrics
|
||||
logger.log(` Load: ${theme.highlight(status.outputLoad + '%')} Power: ${theme.highlight(status.outputPower + 'W')} Voltage: ${theme.highlight(status.outputVoltage + 'V')} Current: ${theme.highlight(status.outputCurrent + 'A')}`);
|
||||
logger.log(
|
||||
` Load: ${theme.highlight(status.outputLoad + '%')} Power: ${
|
||||
theme.highlight(status.outputPower + 'W')
|
||||
} Voltage: ${theme.highlight(status.outputVoltage + 'V')} Current: ${
|
||||
theme.highlight(status.outputCurrent + 'A')
|
||||
}`,
|
||||
);
|
||||
|
||||
// Display host info
|
||||
logger.log(` ${theme.dim(`Host: ${ups.snmp.host}:${ups.snmp.port}`)}`);
|
||||
@@ -381,7 +413,9 @@ WantedBy=multi-user.target
|
||||
for (const action of ups.actions) {
|
||||
let actionDesc = `${action.type}`;
|
||||
if (action.thresholds) {
|
||||
actionDesc += ` (${action.triggerMode || 'onlyThresholds'}: battery<${action.thresholds.battery}%, runtime<${action.thresholds.runtime}min`;
|
||||
actionDesc += ` (${
|
||||
action.triggerMode || 'onlyThresholds'
|
||||
}: battery<${action.thresholds.battery}%, runtime<${action.thresholds.runtime}min`;
|
||||
if (action.shutdownDelay) {
|
||||
actionDesc += `, delay=${action.shutdownDelay}s`;
|
||||
}
|
||||
@@ -398,10 +432,11 @@ WantedBy=multi-user.target
|
||||
}
|
||||
|
||||
logger.log('');
|
||||
|
||||
} catch (error) {
|
||||
// Display error for this UPS
|
||||
logger.log(` ${symbols.error} ${theme.highlight(ups.name)} - ${theme.error('Connection failed')}`);
|
||||
logger.log(
|
||||
` ${symbols.error} ${theme.highlight(ups.name)} - ${theme.error('Connection failed')}`,
|
||||
);
|
||||
logger.log(` ${theme.dim(error instanceof Error ? error.message : String(error))}`);
|
||||
logger.log(` ${theme.dim(`Host: ${ups.snmp.host}:${ups.snmp.port}`)}`);
|
||||
logger.log('');
|
||||
@@ -426,7 +461,9 @@ WantedBy=multi-user.target
|
||||
// Display group name and mode
|
||||
const modeColor = group.mode === 'redundant' ? theme.success : theme.warning;
|
||||
logger.log(
|
||||
` ${symbols.info} ${theme.highlight(group.name)} ${theme.dim(`(${modeColor(group.mode)})`)}`,
|
||||
` ${symbols.info} ${theme.highlight(group.name)} ${
|
||||
theme.dim(`(${modeColor(group.mode)})`)
|
||||
}`,
|
||||
);
|
||||
|
||||
// Display description if present
|
||||
@@ -451,7 +488,9 @@ WantedBy=multi-user.target
|
||||
for (const action of group.actions) {
|
||||
let actionDesc = `${action.type}`;
|
||||
if (action.thresholds) {
|
||||
actionDesc += ` (${action.triggerMode || 'onlyThresholds'}: battery<${action.thresholds.battery}%, runtime<${action.thresholds.runtime}min`;
|
||||
actionDesc += ` (${
|
||||
action.triggerMode || 'onlyThresholds'
|
||||
}: battery<${action.thresholds.battery}%, runtime<${action.thresholds.runtime}min`;
|
||||
if (action.shutdownDelay) {
|
||||
actionDesc += `, delay=${action.shutdownDelay}s`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user