504 lines
18 KiB
TypeScript
504 lines
18 KiB
TypeScript
import process from 'node:process';
|
|
import { Nupst } from '../nupst.ts';
|
|
import { type ITableColumn, logger } from '../logger.ts';
|
|
import { symbols, theme } from '../colors.ts';
|
|
import type { IActionConfig } from '../actions/base-action.ts';
|
|
import { ProxmoxAction } from '../actions/proxmox-action.ts';
|
|
import type { IGroupConfig, IUpsConfig } from '../daemon.ts';
|
|
import * as helpers from '../helpers/index.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 or group
|
|
*/
|
|
public async add(targetId?: string): Promise<void> {
|
|
try {
|
|
if (!targetId) {
|
|
logger.error('Target ID is required');
|
|
logger.log(
|
|
` ${theme.dim('Usage:')} ${theme.command('nupst action add <ups-id|group-id>')}`,
|
|
);
|
|
logger.log('');
|
|
logger.log(` ${theme.dim('List UPS devices:')} ${theme.command('nupst ups list')}`);
|
|
logger.log(` ${theme.dim('List groups:')} ${theme.command('nupst group list')}`);
|
|
logger.log('');
|
|
process.exit(1);
|
|
}
|
|
|
|
const config = await this.nupst.getDaemon().loadConfig();
|
|
|
|
// Check if it's a UPS
|
|
const ups = config.upsDevices.find((u) => u.id === targetId);
|
|
// Check if it's a group
|
|
const group = config.groups?.find((g) => g.id === targetId);
|
|
|
|
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('');
|
|
process.exit(1);
|
|
}
|
|
|
|
const target = ups || group;
|
|
const targetType = ups ? 'UPS' : 'Group';
|
|
const targetName = ups ? ups.name : group!.name;
|
|
|
|
await helpers.withPrompt(async (prompt) => {
|
|
logger.log('');
|
|
logger.info(`Add Action to ${targetType} ${theme.highlight(targetName)}`);
|
|
logger.log('');
|
|
|
|
// Action type selection
|
|
logger.log(` ${theme.dim('Action Type:')}`);
|
|
logger.log(` ${theme.dim('1)')} Shutdown (system shutdown)`);
|
|
logger.log(` ${theme.dim('2)')} Webhook (HTTP notification)`);
|
|
logger.log(` ${theme.dim('3)')} Custom Script (run .sh file from /etc/nupst)`);
|
|
logger.log(` ${theme.dim('4)')} Proxmox (gracefully shut down VMs/LXCs before host shutdown)`);
|
|
|
|
const typeInput = await prompt(` ${theme.dim('Select action type')} ${theme.dim('[1]:')} `);
|
|
const typeValue = parseInt(typeInput, 10) || 1;
|
|
|
|
const newAction: Partial<IActionConfig> = {};
|
|
|
|
if (typeValue === 1) {
|
|
// Shutdown action
|
|
newAction.type = 'shutdown';
|
|
|
|
const delayStr = await prompt(
|
|
` ${theme.dim('Shutdown delay')} ${theme.dim('(minutes) [5]:')} `,
|
|
);
|
|
const shutdownDelay = delayStr ? parseInt(delayStr, 10) : 5;
|
|
if (isNaN(shutdownDelay) || shutdownDelay < 0) {
|
|
logger.error('Invalid shutdown delay. Must be >= 0.');
|
|
process.exit(1);
|
|
}
|
|
newAction.shutdownDelay = shutdownDelay;
|
|
} else if (typeValue === 2) {
|
|
// Webhook action
|
|
newAction.type = 'webhook';
|
|
|
|
const url = await prompt(` ${theme.dim('Webhook URL:')} `);
|
|
if (!url.trim()) {
|
|
logger.error('Webhook URL is required.');
|
|
process.exit(1);
|
|
}
|
|
newAction.webhookUrl = url.trim();
|
|
|
|
logger.log('');
|
|
logger.log(` ${theme.dim('HTTP Method:')}`);
|
|
logger.log(` ${theme.dim('1)')} POST (JSON body)`);
|
|
logger.log(` ${theme.dim('2)')} GET (query parameters)`);
|
|
const methodInput = await prompt(` ${theme.dim('Select method')} ${theme.dim('[1]:')} `);
|
|
newAction.webhookMethod = methodInput === '2' ? 'GET' : 'POST';
|
|
|
|
const timeoutInput = await prompt(` ${theme.dim('Timeout in seconds')} ${theme.dim('[10]:')} `);
|
|
const timeout = parseInt(timeoutInput, 10);
|
|
if (timeoutInput.trim() && !isNaN(timeout)) {
|
|
newAction.webhookTimeout = timeout * 1000;
|
|
}
|
|
} else if (typeValue === 3) {
|
|
// Script action
|
|
newAction.type = 'script';
|
|
|
|
const scriptPath = await prompt(` ${theme.dim('Script filename (in /etc/nupst/, must end with .sh):')} `);
|
|
if (!scriptPath.trim() || !scriptPath.trim().endsWith('.sh')) {
|
|
logger.error('Script path must end with .sh.');
|
|
process.exit(1);
|
|
}
|
|
newAction.scriptPath = scriptPath.trim();
|
|
|
|
const timeoutInput = await prompt(` ${theme.dim('Script timeout in seconds')} ${theme.dim('[60]:')} `);
|
|
const timeout = parseInt(timeoutInput, 10);
|
|
if (timeoutInput.trim() && !isNaN(timeout)) {
|
|
newAction.scriptTimeout = timeout * 1000;
|
|
}
|
|
} else if (typeValue === 4) {
|
|
// Proxmox action
|
|
newAction.type = 'proxmox';
|
|
|
|
// Auto-detect CLI availability
|
|
const detection = ProxmoxAction.detectCliAvailability();
|
|
|
|
if (detection.available) {
|
|
logger.log('');
|
|
logger.success('Proxmox CLI tools detected (qm/pct). No API token needed.');
|
|
logger.dim(` qm: ${detection.qmPath}`);
|
|
logger.dim(` pct: ${detection.pctPath}`);
|
|
newAction.proxmoxMode = 'cli';
|
|
} else {
|
|
logger.log('');
|
|
if (!detection.isRoot) {
|
|
logger.warn('Not running as root - CLI mode unavailable, using API mode.');
|
|
} else {
|
|
logger.warn('Proxmox CLI tools (qm/pct) not found - using API mode.');
|
|
}
|
|
logger.log('');
|
|
logger.info('Proxmox API Settings:');
|
|
logger.dim('Create a token with: pveum user token add root@pam nupst --privsep=0');
|
|
|
|
const pxHost = await prompt(` ${theme.dim('Proxmox Host')} ${theme.dim('[localhost]:')} `);
|
|
newAction.proxmoxHost = pxHost.trim() || 'localhost';
|
|
|
|
const pxPortInput = await prompt(` ${theme.dim('Proxmox API Port')} ${theme.dim('[8006]:')} `);
|
|
const pxPort = parseInt(pxPortInput, 10);
|
|
newAction.proxmoxPort = pxPortInput.trim() && !isNaN(pxPort) ? pxPort : 8006;
|
|
|
|
const pxNode = await prompt(` ${theme.dim('Proxmox Node Name (empty = auto-detect):')} `);
|
|
if (pxNode.trim()) {
|
|
newAction.proxmoxNode = pxNode.trim();
|
|
}
|
|
|
|
const tokenId = await prompt(` ${theme.dim('API Token ID (e.g., root@pam!nupst):')} `);
|
|
if (!tokenId.trim()) {
|
|
logger.error('Token ID is required for API mode.');
|
|
process.exit(1);
|
|
}
|
|
newAction.proxmoxTokenId = tokenId.trim();
|
|
|
|
const tokenSecret = await prompt(` ${theme.dim('API Token Secret:')} `);
|
|
if (!tokenSecret.trim()) {
|
|
logger.error('Token Secret is required for API mode.');
|
|
process.exit(1);
|
|
}
|
|
newAction.proxmoxTokenSecret = tokenSecret.trim();
|
|
|
|
const insecureInput = await prompt(` ${theme.dim('Skip TLS verification (self-signed cert)?')} ${theme.dim('(Y/n):')} `);
|
|
newAction.proxmoxInsecure = insecureInput.toLowerCase() !== 'n';
|
|
newAction.proxmoxMode = 'api';
|
|
}
|
|
|
|
// Common Proxmox settings (both modes)
|
|
const excludeInput = await prompt(` ${theme.dim('VM/CT IDs to exclude (comma-separated, or empty):')} `);
|
|
if (excludeInput.trim()) {
|
|
newAction.proxmoxExcludeIds = excludeInput.split(',').map((s) => parseInt(s.trim(), 10)).filter((n) => !isNaN(n));
|
|
}
|
|
|
|
const timeoutInput = await prompt(` ${theme.dim('VM shutdown timeout in seconds')} ${theme.dim('[120]:')} `);
|
|
const stopTimeout = parseInt(timeoutInput, 10);
|
|
if (timeoutInput.trim() && !isNaN(stopTimeout)) {
|
|
newAction.proxmoxStopTimeout = stopTimeout;
|
|
}
|
|
|
|
const forceInput = await prompt(` ${theme.dim('Force-stop VMs that don\'t shut down in time?')} ${theme.dim('(Y/n):')} `);
|
|
newAction.proxmoxForceStop = forceInput.toLowerCase() !== 'n';
|
|
} else {
|
|
logger.error('Invalid action type.');
|
|
process.exit(1);
|
|
}
|
|
|
|
// Battery threshold (all action types)
|
|
logger.log('');
|
|
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);
|
|
}
|
|
|
|
newAction.thresholds = { battery, runtime };
|
|
|
|
// 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';
|
|
newAction.triggerMode = triggerMode as IActionConfig['triggerMode'];
|
|
|
|
// Add to target (UPS or group)
|
|
if (!target!.actions) {
|
|
target!.actions = [];
|
|
}
|
|
target!.actions.push(newAction as IActionConfig);
|
|
|
|
await this.nupst.getDaemon().saveConfig(config);
|
|
|
|
logger.log('');
|
|
logger.success(`Action added to ${targetType} ${targetName}`);
|
|
logger.log(` ${theme.dim('Changes saved and will be applied automatically')}`);
|
|
logger.log('');
|
|
});
|
|
} catch (error) {
|
|
logger.error(
|
|
`Failed to add action: ${error instanceof Error ? error.message : String(error)}`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove an action from a UPS or group
|
|
*/
|
|
public async remove(targetId?: string, actionIndexStr?: string): Promise<void> {
|
|
try {
|
|
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>')
|
|
}`,
|
|
);
|
|
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();
|
|
|
|
// Check if it's a UPS
|
|
const ups = config.upsDevices.find((u) => u.id === targetId);
|
|
// Check if it's a group
|
|
const group = config.groups?.find((g) => g.id === targetId);
|
|
|
|
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('');
|
|
process.exit(1);
|
|
}
|
|
|
|
const target = ups || group;
|
|
const targetType = ups ? 'UPS' : 'Group';
|
|
const targetName = ups ? ups.name : group!.name;
|
|
|
|
if (!target!.actions || target!.actions.length === 0) {
|
|
logger.error(`No actions configured for ${targetType} '${targetName}'`);
|
|
logger.log('');
|
|
process.exit(1);
|
|
}
|
|
|
|
if (actionIndex >= target!.actions.length) {
|
|
logger.error(
|
|
`Invalid action index. ${targetType} '${targetName}' has ${
|
|
target!.actions.length
|
|
} action(s) (index 0-${target!.actions.length - 1})`,
|
|
);
|
|
logger.log('');
|
|
logger.log(
|
|
` ${theme.dim('List actions:')} ${theme.command(`nupst action list ${targetId}`)}`,
|
|
);
|
|
logger.log('');
|
|
process.exit(1);
|
|
}
|
|
|
|
const removedAction = target!.actions[actionIndex];
|
|
target!.actions.splice(actionIndex, 1);
|
|
|
|
await this.nupst.getDaemon().saveConfig(config);
|
|
|
|
logger.log('');
|
|
logger.success(`Action removed from ${targetType} ${targetName}`);
|
|
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(` ${theme.dim('Changes saved and will be applied automatically')}`);
|
|
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/group or all devices
|
|
*/
|
|
public async list(targetId?: string): Promise<void> {
|
|
try {
|
|
const config = await this.nupst.getDaemon().loadConfig();
|
|
|
|
if (targetId) {
|
|
// List actions for specific UPS or group
|
|
const ups = config.upsDevices.find((u) => u.id === targetId);
|
|
const group = config.groups?.find((g) => g.id === targetId);
|
|
|
|
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('');
|
|
process.exit(1);
|
|
}
|
|
|
|
if (ups) {
|
|
this.displayTargetActions(ups, 'UPS');
|
|
} else {
|
|
this.displayTargetActions(group!, 'Group');
|
|
}
|
|
} else {
|
|
// List actions for all UPS devices and groups
|
|
logger.log('');
|
|
logger.info('Actions for All UPS Devices and Groups');
|
|
logger.log('');
|
|
|
|
let hasAnyActions = false;
|
|
|
|
// Display UPS actions
|
|
for (const ups of config.upsDevices) {
|
|
if (ups.actions && ups.actions.length > 0) {
|
|
hasAnyActions = true;
|
|
this.displayTargetActions(ups, 'UPS');
|
|
}
|
|
}
|
|
|
|
// Display Group actions
|
|
for (const group of config.groups || []) {
|
|
if (group.actions && group.actions.length > 0) {
|
|
hasAnyActions = true;
|
|
this.displayTargetActions(group, 'Group');
|
|
}
|
|
}
|
|
|
|
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|group-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 or Group
|
|
*/
|
|
private displayTargetActions(
|
|
target: IUpsConfig | IGroupConfig,
|
|
targetType: 'UPS' | 'Group',
|
|
): void {
|
|
logger.log(
|
|
`${symbols.info} ${targetType} ${theme.highlight(target.name)} ${
|
|
theme.dim(`(${target.id})`)
|
|
}`,
|
|
);
|
|
logger.log('');
|
|
|
|
if (!target.actions || target.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: 'Details', key: 'details', align: 'left' },
|
|
];
|
|
|
|
const rows = target.actions.map((action, index) => {
|
|
let details = `${action.shutdownDelay || 5}min delay`;
|
|
if (action.type === 'proxmox') {
|
|
const mode = action.proxmoxMode || 'auto';
|
|
if (mode === 'cli' || (mode === 'auto' && !action.proxmoxTokenId)) {
|
|
details = 'CLI mode';
|
|
} else {
|
|
const host = action.proxmoxHost || 'localhost';
|
|
const port = action.proxmoxPort || 8006;
|
|
details = `API ${host}:${port}`;
|
|
}
|
|
if (action.proxmoxExcludeIds?.length) {
|
|
details += `, excl: ${action.proxmoxExcludeIds.join(',')}`;
|
|
}
|
|
} else if (action.type === 'webhook') {
|
|
details = action.webhookUrl || theme.dim('N/A');
|
|
} else if (action.type === 'script') {
|
|
details = action.scriptPath || theme.dim('N/A');
|
|
}
|
|
|
|
return {
|
|
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'),
|
|
details,
|
|
};
|
|
});
|
|
|
|
logger.logTable(columns, rows);
|
|
logger.log('');
|
|
}
|
|
}
|