feat(cli): add action management commands

Added comprehensive action management:

Commands:
- nupst action add <ups-id> - Add a new action to a UPS interactively
- nupst action remove <ups-id> <index> - Remove an action by index
- nupst action list [ups-id] - List all actions (optionally for specific UPS)

Features:
- Interactive prompts for action configuration
- Battery and runtime threshold configuration
- Trigger mode selection (onlyPowerChanges, onlyThresholds, powerChangesAndThresholds, anyChange)
- Shutdown delay configuration
- Table-based display of actions with indices
- Support for managing actions across multiple UPS devices

Implementation:
- Created ActionHandler class in ts/cli/action-handler.ts
- Integrated with existing CLI infrastructure
- Added to nupst.ts, cli.ts, and help system
- Proper TypeScript typing throughout

Closes the gap where users had to manually edit config.json to manage actions.
This commit is contained in:
2025-10-20 12:32:14 +00:00
parent 263d69aef1
commit ff433b2256
3 changed files with 385 additions and 0 deletions

View File

@@ -72,6 +72,7 @@ export class NupstCli {
const upsHandler = this.nupst.getUpsHandler();
const groupHandler = this.nupst.getGroupHandler();
const serviceHandler = this.nupst.getServiceHandler();
const actionHandler = this.nupst.getActionHandler();
// Handle service subcommands
if (command === 'service') {
@@ -193,6 +194,38 @@ export class NupstCli {
return;
}
// Handle action subcommands
if (command === 'action') {
const subcommand = commandArgs[0] || 'list';
const subcommandArgs = commandArgs.slice(1);
switch (subcommand) {
case 'add': {
const upsId = subcommandArgs[0];
await actionHandler.add(upsId);
break;
}
case 'remove':
case 'rm': // Alias
case 'delete': { // Backward compatibility
const upsId = subcommandArgs[0];
const actionIndex = subcommandArgs[1];
await actionHandler.remove(upsId, actionIndex);
break;
}
case 'list':
case 'ls': { // Alias
const upsId = subcommandArgs[0];
await actionHandler.list(upsId);
break;
}
default:
this.showActionHelp();
break;
}
return;
}
// Handle config subcommand
if (command === 'config') {
const subcommand = commandArgs[0] || 'show';
@@ -499,6 +532,7 @@ export class NupstCli {
this.printCommand('service <subcommand>', 'Manage systemd service');
this.printCommand('ups <subcommand>', 'Manage UPS devices');
this.printCommand('group <subcommand>', 'Manage UPS groups');
this.printCommand('action <subcommand>', 'Manage UPS actions');
this.printCommand('config [show]', 'Display current configuration');
this.printCommand('update', 'Update NUPST from repository', theme.dim('(requires root)'));
this.printCommand('uninstall', 'Completely remove NUPST', theme.dim('(requires root)'));
@@ -535,6 +569,13 @@ export class NupstCli {
this.printCommand('nupst group list (or ls)', 'List all UPS groups');
console.log('');
// Action subcommands
logger.log(theme.info('Action Subcommands:'));
this.printCommand('nupst action add <ups-id>', 'Add a new action to a UPS');
this.printCommand('nupst action remove <ups-id> <index>', 'Remove an action by index');
this.printCommand('nupst action list [ups-id]', 'List all actions (optionally for specific UPS)');
console.log('');
// Options
logger.log(theme.info('Options:'));
this.printCommand('--debug, -d', 'Enable debug mode for detailed SNMP logging');
@@ -639,6 +680,29 @@ Examples:
nupst group add - Create a new group
nupst group edit dc-1 - Edit group with ID 'dc-1'
nupst group remove dc-1 - Remove group with ID 'dc-1'
`);
}
private showActionHelp(): void {
logger.log(`
NUPST - Action Management Commands
Usage:
nupst action <subcommand> [arguments]
Subcommands:
add <ups-id> - Add a new action to a UPS interactively
remove <ups-id> <index> - Remove an action by index (alias: rm, delete)
list [ups-id] - List all actions (optionally for specific UPS) (alias: ls)
Options:
--debug, -d - Enable debug mode for detailed logging
Examples:
nupst action list - List actions for all UPS devices
nupst action list default - List actions for UPS with ID 'default'
nupst action add default - Add a new action to UPS 'default'
nupst action remove default 0 - Remove action at index 0 from UPS 'default'
`);
}
}

311
ts/cli/action-handler.ts Normal file
View File

@@ -0,0 +1,311 @@
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 { IActionConfig } from '../actions/base-action.ts';
import type { IUpsConfig } from '../daemon.ts';
/**
* Class for handling action-related CLI commands
* Provides interface for managing UPS actions
*/
export class ActionHandler {
private readonly nupst: Nupst;
/**
* Create a new action handler
* @param nupst Reference to the main Nupst instance
*/
constructor(nupst: Nupst) {
this.nupst = nupst;
}
/**
* Add a new action to a UPS
*/
public async add(upsId?: string): Promise<void> {
try {
if (!upsId) {
logger.error('UPS ID is required');
logger.log(` ${theme.dim('Usage:')} ${theme.command('nupst action add <ups-id>')}`);
logger.log('');
logger.log(` ${theme.dim('List UPS devices:')} ${theme.command('nupst ups list')}`);
logger.log('');
process.exit(1);
}
const config = await this.nupst.getDaemon().loadConfig();
const ups = config.upsDevices.find((u) => u.id === upsId);
if (!ups) {
logger.error(`UPS with ID '${upsId}' not found`);
logger.log('');
logger.log(` ${theme.dim('List available UPS devices:')} ${theme.command('nupst ups list')}`);
logger.log('');
process.exit(1);
}
const readline = await import('node:readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const prompt = (question: string): Promise<string> => {
return new Promise((resolve) => {
rl.question(question, (answer: string) => {
resolve(answer);
});
});
};
try {
logger.log('');
logger.info(`Add Action to ${theme.highlight(ups.name)}`);
logger.log('');
// Action type (currently only shutdown is supported)
const type = 'shutdown';
logger.log(` ${theme.dim('Action type:')} ${theme.highlight('shutdown')}`);
// Battery threshold
const batteryStr = await prompt(
` ${theme.dim('Battery threshold')} ${theme.dim('(%):')} `,
);
const battery = parseInt(batteryStr, 10);
if (isNaN(battery) || battery < 0 || battery > 100) {
logger.error('Invalid battery threshold. Must be 0-100.');
process.exit(1);
}
// Runtime threshold
const runtimeStr = await prompt(
` ${theme.dim('Runtime threshold')} ${theme.dim('(minutes):')} `,
);
const runtime = parseInt(runtimeStr, 10);
if (isNaN(runtime) || runtime < 0) {
logger.error('Invalid runtime threshold. Must be >= 0.');
process.exit(1);
}
// 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('2)')} onlyThresholds - Trigger only when thresholds are violated`,
);
logger.log(
` ${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]:')} `);
const triggerModeMap: Record<string, string> = {
'1': 'onlyPowerChanges',
'2': 'onlyThresholds',
'3': 'powerChangesAndThresholds',
'4': 'anyChange',
'': 'onlyThresholds', // Default
};
const triggerMode = triggerModeMap[triggerChoice] || 'onlyThresholds';
// Shutdown delay
const delayStr = await prompt(
` ${theme.dim('Shutdown delay')} ${theme.dim('(seconds) [5]:')} `,
);
const shutdownDelay = delayStr ? parseInt(delayStr, 10) : 5;
if (isNaN(shutdownDelay) || shutdownDelay < 0) {
logger.error('Invalid shutdown delay. Must be >= 0.');
process.exit(1);
}
// Create the action
const newAction: IActionConfig = {
type,
thresholds: {
battery,
runtime,
},
triggerMode: triggerMode as IActionConfig['triggerMode'],
shutdownDelay,
};
// Add to UPS
if (!ups.actions) {
ups.actions = [];
}
ups.actions.push(newAction);
await this.nupst.getDaemon().saveConfig(config);
logger.log('');
logger.success(`Action added to ${ups.name}`);
logger.log('');
logger.log(` ${theme.dim('Restart service to apply changes:')} ${theme.command('nupst service restart')}`);
logger.log('');
} finally {
rl.close();
}
} catch (error) {
logger.error(`Failed to add action: ${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
}
}
/**
* Remove an action from a UPS
*/
public async remove(upsId?: string, actionIndexStr?: string): Promise<void> {
try {
if (!upsId || !actionIndexStr) {
logger.error('UPS ID and action index are required');
logger.log(
` ${theme.dim('Usage:')} ${theme.command('nupst action remove <ups-id> <action-index>')}`,
);
logger.log('');
logger.log(` ${theme.dim('List actions:')} ${theme.command('nupst action list')}`);
logger.log('');
process.exit(1);
}
const actionIndex = parseInt(actionIndexStr, 10);
if (isNaN(actionIndex) || actionIndex < 0) {
logger.error('Invalid action index. Must be >= 0.');
process.exit(1);
}
const config = await this.nupst.getDaemon().loadConfig();
const ups = config.upsDevices.find((u) => u.id === upsId);
if (!ups) {
logger.error(`UPS with ID '${upsId}' not found`);
logger.log('');
logger.log(` ${theme.dim('List available UPS devices:')} ${theme.command('nupst ups list')}`);
logger.log('');
process.exit(1);
}
if (!ups.actions || ups.actions.length === 0) {
logger.error(`No actions configured for UPS '${ups.name}'`);
logger.log('');
process.exit(1);
}
if (actionIndex >= ups.actions.length) {
logger.error(
`Invalid action index. UPS '${ups.name}' has ${ups.actions.length} action(s) (index 0-${ups.actions.length - 1})`,
);
logger.log('');
logger.log(` ${theme.dim('List actions:')} ${theme.command(`nupst action list ${upsId}`)}`);
logger.log('');
process.exit(1);
}
const removedAction = ups.actions[actionIndex];
ups.actions.splice(actionIndex, 1);
await this.nupst.getDaemon().saveConfig(config);
logger.log('');
logger.success(`Action removed from ${ups.name}`);
logger.log(` ${theme.dim('Type:')} ${removedAction.type}`);
if (removedAction.thresholds) {
logger.log(
` ${theme.dim('Thresholds:')} Battery: ${removedAction.thresholds.battery}%, Runtime: ${removedAction.thresholds.runtime}min`,
);
}
logger.log('');
logger.log(` ${theme.dim('Restart service to apply changes:')} ${theme.command('nupst service restart')}`);
logger.log('');
} catch (error) {
logger.error(
`Failed to remove action: ${error instanceof Error ? error.message : String(error)}`,
);
process.exit(1);
}
}
/**
* List all actions for a specific UPS or all UPS devices
*/
public async list(upsId?: string): Promise<void> {
try {
const config = await this.nupst.getDaemon().loadConfig();
if (upsId) {
// List actions for specific UPS
const ups = config.upsDevices.find((u) => u.id === upsId);
if (!ups) {
logger.error(`UPS with ID '${upsId}' not found`);
logger.log('');
logger.log(` ${theme.dim('List available UPS devices:')} ${theme.command('nupst ups list')}`);
logger.log('');
process.exit(1);
}
this.displayUpsActions(ups);
} else {
// List actions for all UPS devices
logger.log('');
logger.info('Actions for All UPS Devices');
logger.log('');
let hasAnyActions = false;
for (const ups of config.upsDevices) {
if (ups.actions && ups.actions.length > 0) {
hasAnyActions = true;
this.displayUpsActions(ups);
}
}
if (!hasAnyActions) {
logger.log(` ${theme.dim('No actions configured')}`);
logger.log('');
logger.log(` ${theme.dim('Add an action:')} ${theme.command('nupst action add <ups-id>')}`);
logger.log('');
}
}
} catch (error) {
logger.error(
`Failed to list actions: ${error instanceof Error ? error.message : String(error)}`,
);
process.exit(1);
}
}
/**
* Display actions for a single UPS
*/
private displayUpsActions(ups: IUpsConfig): void {
logger.log(`${symbols.info} ${theme.highlight(ups.name)} ${theme.dim(`(${ups.id})`)}`);
logger.log('');
if (!ups.actions || ups.actions.length === 0) {
logger.log(` ${theme.dim('No actions configured')}`);
logger.log('');
return;
}
const columns: ITableColumn[] = [
{ header: 'Index', key: 'index', align: 'right' },
{ header: 'Type', key: 'type', align: 'left' },
{ header: 'Battery', key: 'battery', align: 'right' },
{ header: 'Runtime', key: 'runtime', align: 'right' },
{ header: 'Trigger Mode', key: 'triggerMode', align: 'left' },
{ header: 'Delay', key: 'delay', align: 'right' },
];
const rows = ups.actions.map((action, index) => ({
index: theme.dim(index.toString()),
type: theme.highlight(action.type),
battery: action.thresholds ? `${action.thresholds.battery}%` : theme.dim('N/A'),
runtime: action.thresholds ? `${action.thresholds.runtime}min` : theme.dim('N/A'),
triggerMode: theme.dim(action.triggerMode || 'onlyThresholds'),
delay: `${action.shutdownDelay || 5}s`,
}));
logger.logTable(columns, rows);
logger.log('');
}
}

View File

@@ -6,6 +6,7 @@ import { logger } from './logger.ts';
import { UpsHandler } from './cli/ups-handler.ts';
import { GroupHandler } from './cli/group-handler.ts';
import { ServiceHandler } from './cli/service-handler.ts';
import { ActionHandler } from './cli/action-handler.ts';
import * as https from 'node:https';
/**
@@ -19,6 +20,7 @@ export class Nupst {
private readonly upsHandler: UpsHandler;
private readonly groupHandler: GroupHandler;
private readonly serviceHandler: ServiceHandler;
private readonly actionHandler: ActionHandler;
private updateAvailable: boolean = false;
private latestVersion: string = '';
@@ -36,6 +38,7 @@ export class Nupst {
this.upsHandler = new UpsHandler(this);
this.groupHandler = new GroupHandler(this);
this.serviceHandler = new ServiceHandler(this);
this.actionHandler = new ActionHandler(this);
}
/**
@@ -80,6 +83,13 @@ export class Nupst {
return this.serviceHandler;
}
/**
* Get the Action handler for action management
*/
public getActionHandler(): ActionHandler {
return this.actionHandler;
}
/**
* Get the current version of NUPST
* @returns The current version string