From ff433b2256cbfd1150e89c10f3c3bd3f6a8821fe Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Mon, 20 Oct 2025 12:32:14 +0000 Subject: [PATCH] feat(cli): add action management commands Added comprehensive action management: Commands: - nupst action add - Add a new action to a UPS interactively - nupst action remove - 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. --- ts/cli.ts | 64 ++++++++ ts/cli/action-handler.ts | 311 +++++++++++++++++++++++++++++++++++++++ ts/nupst.ts | 10 ++ 3 files changed, 385 insertions(+) create mode 100644 ts/cli/action-handler.ts diff --git a/ts/cli.ts b/ts/cli.ts index a1e8634..a5be164 100644 --- a/ts/cli.ts +++ b/ts/cli.ts @@ -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 ', 'Manage systemd service'); this.printCommand('ups ', 'Manage UPS devices'); this.printCommand('group ', 'Manage UPS groups'); + this.printCommand('action ', '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 ', 'Add a new action to a UPS'); + this.printCommand('nupst action remove ', '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 [arguments] + +Subcommands: + add - Add a new action to a UPS interactively + remove - 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' `); } } diff --git a/ts/cli/action-handler.ts b/ts/cli/action-handler.ts new file mode 100644 index 0000000..b130063 --- /dev/null +++ b/ts/cli/action-handler.ts @@ -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 { + try { + if (!upsId) { + logger.error('UPS ID is required'); + logger.log(` ${theme.dim('Usage:')} ${theme.command('nupst action add ')}`); + 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 => { + 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 = { + '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 { + try { + if (!upsId || !actionIndexStr) { + logger.error('UPS ID and action index are required'); + logger.log( + ` ${theme.dim('Usage:')} ${theme.command('nupst action remove ')}`, + ); + 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 { + 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 ')}`); + 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(''); + } +} diff --git a/ts/nupst.ts b/ts/nupst.ts index e1bf3b1..2299582 100644 --- a/ts/nupst.ts +++ b/ts/nupst.ts @@ -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