feat(cli,snmp): fix APC runtime unit defaults and add interactive action editing
This commit is contained in:
+505
-249
@@ -41,267 +41,29 @@ export class ActionHandler {
|
||||
}
|
||||
|
||||
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;
|
||||
const targetSnapshot = this.resolveActionTarget(config, targetId);
|
||||
|
||||
await helpers.withPrompt(async (prompt) => {
|
||||
logger.log('');
|
||||
logger.info(`Add Action to ${targetType} ${theme.highlight(targetName)}`);
|
||||
logger.info(
|
||||
`Add Action to ${targetSnapshot.targetType} ${
|
||||
theme.highlight(targetSnapshot.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 defaultShutdownDelay = this.nupst.getDaemon().getConfig().defaultShutdownDelay ??
|
||||
SHUTDOWN.DEFAULT_DELAY_MINUTES;
|
||||
|
||||
const delayStr = await prompt(
|
||||
` ${theme.dim('Shutdown delay')} ${
|
||||
theme.dim(`(minutes, leave empty for default ${defaultShutdownDelay}):`)
|
||||
} `,
|
||||
);
|
||||
if (delayStr.trim()) {
|
||||
const shutdownDelay = parseInt(delayStr, 10);
|
||||
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';
|
||||
|
||||
const haPolicyInput = await prompt(
|
||||
` ${theme.dim('HA-managed guest handling')} ${theme.dim('([1] none, 2 haStop):')} `,
|
||||
);
|
||||
newAction.proxmoxHaPolicy = haPolicyInput.trim() === '2' ? 'haStop' : 'none';
|
||||
} 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'];
|
||||
const newAction = await this.promptForActionConfig(prompt);
|
||||
|
||||
// Add to target (UPS or group)
|
||||
if (!target!.actions) {
|
||||
target!.actions = [];
|
||||
if (!targetSnapshot.target.actions) {
|
||||
targetSnapshot.target.actions = [];
|
||||
}
|
||||
target!.actions.push(newAction as IActionConfig);
|
||||
targetSnapshot.target.actions.push(newAction);
|
||||
|
||||
await this.nupst.getDaemon().saveConfig(config);
|
||||
|
||||
logger.log('');
|
||||
logger.success(`Action added to ${targetType} ${targetName}`);
|
||||
logger.success(`Action added to ${targetSnapshot.targetType} ${targetSnapshot.targetName}`);
|
||||
logger.log(` ${theme.dim('Changes saved and will be applied automatically')}`);
|
||||
logger.log('');
|
||||
});
|
||||
@@ -313,6 +75,98 @@ export class ActionHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit an existing action on a UPS or group
|
||||
*/
|
||||
public async edit(targetId?: string, actionIndexStr?: string): Promise<void> {
|
||||
try {
|
||||
await helpers.withPrompt(async (prompt) => {
|
||||
await this.runEditProcess(targetId, actionIndexStr, prompt);
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to edit action: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the interactive process to edit an action
|
||||
*/
|
||||
public async runEditProcess(
|
||||
targetId: string | undefined,
|
||||
actionIndexStr: string | undefined,
|
||||
prompt: (question: string) => Promise<string>,
|
||||
): Promise<void> {
|
||||
if (!targetId || !actionIndexStr) {
|
||||
logger.error('Target ID and action index are required');
|
||||
logger.log(
|
||||
` ${theme.dim('Usage:')} ${
|
||||
theme.command('nupst action edit <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();
|
||||
const targetSnapshot = this.resolveActionTarget(config, targetId);
|
||||
|
||||
if (!targetSnapshot.target.actions || targetSnapshot.target.actions.length === 0) {
|
||||
logger.error(
|
||||
`No actions configured for ${targetSnapshot.targetType} '${targetSnapshot.targetName}'`,
|
||||
);
|
||||
logger.log('');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (actionIndex >= targetSnapshot.target.actions.length) {
|
||||
logger.error(
|
||||
`Invalid action index. ${targetSnapshot.targetType} '${targetSnapshot.targetName}' has ${targetSnapshot.target.actions.length} action(s) (index 0-${
|
||||
targetSnapshot.target.actions.length - 1
|
||||
})`,
|
||||
);
|
||||
logger.log('');
|
||||
logger.log(
|
||||
` ${theme.dim('List actions:')} ${theme.command(`nupst action list ${targetId}`)}`,
|
||||
);
|
||||
logger.log('');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const currentAction = targetSnapshot.target.actions[actionIndex];
|
||||
|
||||
logger.log('');
|
||||
logger.info(
|
||||
`Edit Action ${theme.highlight(String(actionIndex))} on ${targetSnapshot.targetType} ${
|
||||
theme.highlight(targetSnapshot.targetName)
|
||||
}`,
|
||||
);
|
||||
logger.log(` ${theme.dim('Current type:')} ${theme.highlight(currentAction.type)}`);
|
||||
logger.log('');
|
||||
|
||||
const updatedAction = await this.promptForActionConfig(prompt, currentAction);
|
||||
targetSnapshot.target.actions[actionIndex] = updatedAction;
|
||||
|
||||
await this.nupst.getDaemon().saveConfig(config);
|
||||
|
||||
logger.log('');
|
||||
logger.success(`Action updated on ${targetSnapshot.targetType} ${targetSnapshot.targetName}`);
|
||||
logger.log(` ${theme.dim('Index:')} ${actionIndex}`);
|
||||
logger.log(` ${theme.dim('Type:')} ${updatedAction.type}`);
|
||||
logger.log(` ${theme.dim('Changes saved and will be applied automatically')}`);
|
||||
logger.log('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an action from a UPS or group
|
||||
*/
|
||||
@@ -477,6 +331,408 @@ export class ActionHandler {
|
||||
}
|
||||
}
|
||||
|
||||
private resolveActionTarget(
|
||||
config: { upsDevices: IUpsConfig[]; groups?: IGroupConfig[] },
|
||||
targetId: string,
|
||||
): { target: IUpsConfig | IGroupConfig; targetType: 'UPS' | 'Group'; targetName: string } {
|
||||
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);
|
||||
}
|
||||
|
||||
return {
|
||||
target: (ups || group)!,
|
||||
targetType: ups ? 'UPS' : 'Group',
|
||||
targetName: ups ? ups.name : group!.name,
|
||||
};
|
||||
}
|
||||
|
||||
private isClearInput(input: string): boolean {
|
||||
return input.trim().toLowerCase() === 'clear';
|
||||
}
|
||||
|
||||
private getActionTypeValue(action?: IActionConfig): number {
|
||||
switch (action?.type) {
|
||||
case 'webhook':
|
||||
return 2;
|
||||
case 'script':
|
||||
return 3;
|
||||
case 'proxmox':
|
||||
return 4;
|
||||
case 'shutdown':
|
||||
default:
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
private getTriggerModeValue(action?: IActionConfig): number {
|
||||
switch (action?.triggerMode) {
|
||||
case 'onlyPowerChanges':
|
||||
return 1;
|
||||
case 'powerChangesAndThresholds':
|
||||
return 3;
|
||||
case 'anyChange':
|
||||
return 4;
|
||||
case 'onlyThresholds':
|
||||
default:
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
private async promptForActionConfig(
|
||||
prompt: (question: string) => Promise<string>,
|
||||
existingAction?: IActionConfig,
|
||||
): Promise<IActionConfig> {
|
||||
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 defaultTypeValue = this.getActionTypeValue(existingAction);
|
||||
const typeInput = await prompt(
|
||||
` ${theme.dim('Select action type')} ${theme.dim(`[${defaultTypeValue}]:`)} `,
|
||||
);
|
||||
const typeValue = parseInt(typeInput, 10) || defaultTypeValue;
|
||||
const newAction: Partial<IActionConfig> = {};
|
||||
|
||||
if (typeValue === 1) {
|
||||
const shutdownAction = existingAction?.type === 'shutdown' ? existingAction : undefined;
|
||||
const defaultShutdownDelay = this.nupst.getDaemon().getConfig().defaultShutdownDelay ??
|
||||
SHUTDOWN.DEFAULT_DELAY_MINUTES;
|
||||
|
||||
newAction.type = 'shutdown';
|
||||
|
||||
const delayPrompt = shutdownAction?.shutdownDelay !== undefined
|
||||
? ` ${theme.dim('Shutdown delay')} ${
|
||||
theme.dim(
|
||||
`(minutes, 'clear' = default ${defaultShutdownDelay}) [${shutdownAction.shutdownDelay}]:`,
|
||||
)
|
||||
} `
|
||||
: ` ${theme.dim('Shutdown delay')} ${
|
||||
theme.dim(`(minutes, leave empty for default ${defaultShutdownDelay}):`)
|
||||
} `;
|
||||
const delayInput = await prompt(delayPrompt);
|
||||
if (this.isClearInput(delayInput)) {
|
||||
// Leave unset so the config-level default is used.
|
||||
} else if (delayInput.trim()) {
|
||||
const shutdownDelay = parseInt(delayInput, 10);
|
||||
if (isNaN(shutdownDelay) || shutdownDelay < 0) {
|
||||
logger.error('Invalid shutdown delay. Must be >= 0.');
|
||||
process.exit(1);
|
||||
}
|
||||
newAction.shutdownDelay = shutdownDelay;
|
||||
} else if (shutdownAction?.shutdownDelay !== undefined) {
|
||||
newAction.shutdownDelay = shutdownAction.shutdownDelay;
|
||||
}
|
||||
} else if (typeValue === 2) {
|
||||
const webhookAction = existingAction?.type === 'webhook' ? existingAction : undefined;
|
||||
newAction.type = 'webhook';
|
||||
|
||||
const webhookUrlInput = await prompt(
|
||||
` ${theme.dim('Webhook URL')} ${
|
||||
theme.dim(webhookAction?.webhookUrl ? `[${webhookAction.webhookUrl}]:` : ':')
|
||||
} `,
|
||||
);
|
||||
const webhookUrl = webhookUrlInput.trim() || webhookAction?.webhookUrl || '';
|
||||
if (!webhookUrl) {
|
||||
logger.error('Webhook URL is required.');
|
||||
process.exit(1);
|
||||
}
|
||||
newAction.webhookUrl = webhookUrl;
|
||||
|
||||
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 defaultMethodValue = webhookAction?.webhookMethod === 'GET' ? 2 : 1;
|
||||
const methodInput = await prompt(
|
||||
` ${theme.dim('Select method')} ${theme.dim(`[${defaultMethodValue}]:`)} `,
|
||||
);
|
||||
const methodValue = parseInt(methodInput, 10) || defaultMethodValue;
|
||||
newAction.webhookMethod = methodValue === 2 ? 'GET' : 'POST';
|
||||
|
||||
const currentWebhookTimeout = webhookAction?.webhookTimeout;
|
||||
const timeoutPrompt = currentWebhookTimeout !== undefined
|
||||
? ` ${theme.dim('Timeout in seconds')} ${
|
||||
theme.dim(`('clear' to unset) [${Math.floor(currentWebhookTimeout / 1000)}]:`)
|
||||
} `
|
||||
: ` ${theme.dim('Timeout in seconds')} ${theme.dim('[10]:')} `;
|
||||
const timeoutInput = await prompt(timeoutPrompt);
|
||||
if (this.isClearInput(timeoutInput)) {
|
||||
// Leave unset.
|
||||
} else if (timeoutInput.trim()) {
|
||||
const timeout = parseInt(timeoutInput, 10);
|
||||
if (isNaN(timeout) || timeout < 0) {
|
||||
logger.error('Invalid webhook timeout. Must be >= 0.');
|
||||
process.exit(1);
|
||||
}
|
||||
newAction.webhookTimeout = timeout * 1000;
|
||||
} else if (currentWebhookTimeout !== undefined) {
|
||||
newAction.webhookTimeout = currentWebhookTimeout;
|
||||
}
|
||||
} else if (typeValue === 3) {
|
||||
const scriptAction = existingAction?.type === 'script' ? existingAction : undefined;
|
||||
newAction.type = 'script';
|
||||
|
||||
const scriptPathInput = await prompt(
|
||||
` ${theme.dim('Script filename (in /etc/nupst/, must end with .sh)')} ${
|
||||
theme.dim(scriptAction?.scriptPath ? `[${scriptAction.scriptPath}]:` : ':')
|
||||
} `,
|
||||
);
|
||||
const scriptPath = scriptPathInput.trim() || scriptAction?.scriptPath || '';
|
||||
if (!scriptPath || !scriptPath.endsWith('.sh')) {
|
||||
logger.error('Script path must end with .sh.');
|
||||
process.exit(1);
|
||||
}
|
||||
newAction.scriptPath = scriptPath;
|
||||
|
||||
const currentScriptTimeout = scriptAction?.scriptTimeout;
|
||||
const timeoutPrompt = currentScriptTimeout !== undefined
|
||||
? ` ${theme.dim('Script timeout in seconds')} ${
|
||||
theme.dim(`('clear' to unset) [${Math.floor(currentScriptTimeout / 1000)}]:`)
|
||||
} `
|
||||
: ` ${theme.dim('Script timeout in seconds')} ${theme.dim('[60]:')} `;
|
||||
const timeoutInput = await prompt(timeoutPrompt);
|
||||
if (this.isClearInput(timeoutInput)) {
|
||||
// Leave unset.
|
||||
} else if (timeoutInput.trim()) {
|
||||
const timeout = parseInt(timeoutInput, 10);
|
||||
if (isNaN(timeout) || timeout < 0) {
|
||||
logger.error('Invalid script timeout. Must be >= 0.');
|
||||
process.exit(1);
|
||||
}
|
||||
newAction.scriptTimeout = timeout * 1000;
|
||||
} else if (currentScriptTimeout !== undefined) {
|
||||
newAction.scriptTimeout = currentScriptTimeout;
|
||||
}
|
||||
} else if (typeValue === 4) {
|
||||
const proxmoxAction = existingAction?.type === 'proxmox' ? existingAction : undefined;
|
||||
const detection = ProxmoxAction.detectCliAvailability();
|
||||
let useApiMode = false;
|
||||
|
||||
newAction.type = 'proxmox';
|
||||
|
||||
if (detection.available) {
|
||||
logger.log('');
|
||||
logger.success('Proxmox CLI tools detected (qm/pct).');
|
||||
logger.dim(` qm: ${detection.qmPath}`);
|
||||
logger.dim(` pct: ${detection.pctPath}`);
|
||||
|
||||
if (proxmoxAction) {
|
||||
logger.log('');
|
||||
logger.log(` ${theme.dim('Proxmox mode:')}`);
|
||||
logger.log(` ${theme.dim('1)')} CLI (local qm/pct tools)`);
|
||||
logger.log(` ${theme.dim('2)')} API (REST token authentication)`);
|
||||
const defaultModeValue = proxmoxAction.proxmoxMode === 'api' ? 2 : 1;
|
||||
const modeInput = await prompt(
|
||||
` ${theme.dim('Select Proxmox mode')} ${theme.dim(`[${defaultModeValue}]:`)} `,
|
||||
);
|
||||
const modeValue = parseInt(modeInput, 10) || defaultModeValue;
|
||||
useApiMode = modeValue === 2;
|
||||
}
|
||||
} 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.');
|
||||
}
|
||||
useApiMode = true;
|
||||
}
|
||||
|
||||
if (useApiMode) {
|
||||
logger.log('');
|
||||
logger.info('Proxmox API Settings:');
|
||||
logger.dim('Create a token with: pveum user token add root@pam nupst --privsep=0');
|
||||
|
||||
const currentHost = proxmoxAction?.proxmoxHost || 'localhost';
|
||||
const pxHost = await prompt(
|
||||
` ${theme.dim('Proxmox Host')} ${theme.dim(`[${currentHost}]:`)} `,
|
||||
);
|
||||
newAction.proxmoxHost = pxHost.trim() || currentHost;
|
||||
|
||||
const currentPort = proxmoxAction?.proxmoxPort || 8006;
|
||||
const pxPortInput = await prompt(
|
||||
` ${theme.dim('Proxmox API Port')} ${theme.dim(`[${currentPort}]:`)} `,
|
||||
);
|
||||
const pxPort = parseInt(pxPortInput, 10);
|
||||
newAction.proxmoxPort = pxPortInput.trim() && !isNaN(pxPort) ? pxPort : currentPort;
|
||||
|
||||
const pxNodePrompt = proxmoxAction?.proxmoxNode
|
||||
? ` ${theme.dim('Proxmox Node Name')} ${
|
||||
theme.dim(`('clear' = auto-detect) [${proxmoxAction.proxmoxNode}]:`)
|
||||
} `
|
||||
: ` ${theme.dim('Proxmox Node Name')} ${theme.dim('(empty = auto-detect):')} `;
|
||||
const pxNode = await prompt(pxNodePrompt);
|
||||
if (this.isClearInput(pxNode)) {
|
||||
// Leave unset so hostname auto-detection is used.
|
||||
} else if (pxNode.trim()) {
|
||||
newAction.proxmoxNode = pxNode.trim();
|
||||
} else if (proxmoxAction?.proxmoxNode) {
|
||||
newAction.proxmoxNode = proxmoxAction.proxmoxNode;
|
||||
}
|
||||
|
||||
const currentTokenId = proxmoxAction?.proxmoxTokenId || '';
|
||||
const tokenIdInput = await prompt(
|
||||
` ${theme.dim('API Token ID (e.g., root@pam!nupst)')} ${
|
||||
theme.dim(currentTokenId ? `[${currentTokenId}]:` : ':')
|
||||
} `,
|
||||
);
|
||||
const tokenId = tokenIdInput.trim() || currentTokenId;
|
||||
if (!tokenId) {
|
||||
logger.error('Token ID is required for API mode.');
|
||||
process.exit(1);
|
||||
}
|
||||
newAction.proxmoxTokenId = tokenId;
|
||||
|
||||
const currentTokenSecret = proxmoxAction?.proxmoxTokenSecret || '';
|
||||
const tokenSecretInput = await prompt(
|
||||
` ${theme.dim('API Token Secret')} ${theme.dim(currentTokenSecret ? '[*****]:' : ':')} `,
|
||||
);
|
||||
const tokenSecret = tokenSecretInput.trim() || currentTokenSecret;
|
||||
if (!tokenSecret) {
|
||||
logger.error('Token Secret is required for API mode.');
|
||||
process.exit(1);
|
||||
}
|
||||
newAction.proxmoxTokenSecret = tokenSecret;
|
||||
|
||||
const defaultInsecure = proxmoxAction?.proxmoxInsecure !== false;
|
||||
const insecureInput = await prompt(
|
||||
` ${theme.dim('Skip TLS verification (self-signed cert)?')} ${
|
||||
theme.dim(defaultInsecure ? '(Y/n):' : '(y/N):')
|
||||
} `,
|
||||
);
|
||||
newAction.proxmoxInsecure = insecureInput.trim()
|
||||
? insecureInput.toLowerCase() !== 'n'
|
||||
: defaultInsecure;
|
||||
newAction.proxmoxMode = 'api';
|
||||
} else {
|
||||
newAction.proxmoxMode = 'cli';
|
||||
}
|
||||
|
||||
const currentExcludeIds = proxmoxAction?.proxmoxExcludeIds || [];
|
||||
const excludePrompt = currentExcludeIds.length > 0
|
||||
? ` ${theme.dim('VM/CT IDs to exclude')} ${
|
||||
theme.dim(`(comma-separated, 'clear' = none) [${currentExcludeIds.join(',')}]:`)
|
||||
} `
|
||||
: ` ${theme.dim('VM/CT IDs to exclude (comma-separated, or empty):')} `;
|
||||
const excludeInput = await prompt(excludePrompt);
|
||||
if (this.isClearInput(excludeInput)) {
|
||||
newAction.proxmoxExcludeIds = [];
|
||||
} else if (excludeInput.trim()) {
|
||||
newAction.proxmoxExcludeIds = excludeInput.split(',').map((s) => parseInt(s.trim(), 10))
|
||||
.filter((n) => !isNaN(n));
|
||||
} else if (currentExcludeIds.length > 0) {
|
||||
newAction.proxmoxExcludeIds = [...currentExcludeIds];
|
||||
}
|
||||
|
||||
const currentStopTimeout = proxmoxAction?.proxmoxStopTimeout;
|
||||
const stopTimeoutPrompt = currentStopTimeout !== undefined
|
||||
? ` ${theme.dim('VM shutdown timeout in seconds')} ${
|
||||
theme.dim(`('clear' to unset) [${currentStopTimeout}]:`)
|
||||
} `
|
||||
: ` ${theme.dim('VM shutdown timeout in seconds')} ${theme.dim('[120]:')} `;
|
||||
const timeoutInput = await prompt(stopTimeoutPrompt);
|
||||
if (this.isClearInput(timeoutInput)) {
|
||||
// Leave unset.
|
||||
} else if (timeoutInput.trim()) {
|
||||
const stopTimeout = parseInt(timeoutInput, 10);
|
||||
if (isNaN(stopTimeout) || stopTimeout < 0) {
|
||||
logger.error('Invalid VM shutdown timeout. Must be >= 0.');
|
||||
process.exit(1);
|
||||
}
|
||||
newAction.proxmoxStopTimeout = stopTimeout;
|
||||
} else if (currentStopTimeout !== undefined) {
|
||||
newAction.proxmoxStopTimeout = currentStopTimeout;
|
||||
}
|
||||
|
||||
const defaultForceStop = proxmoxAction?.proxmoxForceStop !== false;
|
||||
const forceInput = await prompt(
|
||||
` ${theme.dim("Force-stop VMs that don't shut down in time?")} ${
|
||||
theme.dim(defaultForceStop ? '(Y/n):' : '(y/N):')
|
||||
} `,
|
||||
);
|
||||
newAction.proxmoxForceStop = forceInput.trim()
|
||||
? forceInput.toLowerCase() !== 'n'
|
||||
: defaultForceStop;
|
||||
|
||||
const defaultHaPolicyValue = proxmoxAction?.proxmoxHaPolicy === 'haStop' ? 2 : 1;
|
||||
const haPolicyInput = await prompt(
|
||||
` ${theme.dim('HA-managed guest handling')} ${
|
||||
theme.dim(`([1] none, 2 haStop) [${defaultHaPolicyValue}]:`)
|
||||
} `,
|
||||
);
|
||||
const haPolicyValue = parseInt(haPolicyInput, 10) || defaultHaPolicyValue;
|
||||
newAction.proxmoxHaPolicy = haPolicyValue === 2 ? 'haStop' : 'none';
|
||||
} else {
|
||||
logger.error('Invalid action type.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
logger.log('');
|
||||
const defaultBatteryThreshold = existingAction?.thresholds?.battery ?? 60;
|
||||
const batteryInput = await prompt(
|
||||
` ${theme.dim('Battery threshold')} ${theme.dim(`(%) [${defaultBatteryThreshold}]:`)} `,
|
||||
);
|
||||
const battery = batteryInput.trim() ? parseInt(batteryInput, 10) : defaultBatteryThreshold;
|
||||
if (isNaN(battery) || battery < 0 || battery > 100) {
|
||||
logger.error('Invalid battery threshold. Must be 0-100.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const defaultRuntimeThreshold = existingAction?.thresholds?.runtime ?? 20;
|
||||
const runtimeInput = await prompt(
|
||||
` ${theme.dim('Runtime threshold')} ${
|
||||
theme.dim(`(minutes) [${defaultRuntimeThreshold}]:`)
|
||||
} `,
|
||||
);
|
||||
const runtime = runtimeInput.trim() ? parseInt(runtimeInput, 10) : defaultRuntimeThreshold;
|
||||
if (isNaN(runtime) || runtime < 0) {
|
||||
logger.error('Invalid runtime threshold. Must be >= 0.');
|
||||
process.exit(1);
|
||||
}
|
||||
newAction.thresholds = { battery, runtime };
|
||||
|
||||
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 defaultTriggerValue = this.getTriggerModeValue(existingAction);
|
||||
const triggerChoice = await prompt(
|
||||
` ${theme.dim('Choice')} ${theme.dim(`[${defaultTriggerValue}]:`)} `,
|
||||
);
|
||||
const triggerValue = parseInt(triggerChoice, 10) || defaultTriggerValue;
|
||||
const triggerModeMap: Record<number, NonNullable<IActionConfig['triggerMode']>> = {
|
||||
1: 'onlyPowerChanges',
|
||||
2: 'onlyThresholds',
|
||||
3: 'powerChangesAndThresholds',
|
||||
4: 'anyChange',
|
||||
};
|
||||
newAction.triggerMode = triggerModeMap[triggerValue] || 'onlyThresholds';
|
||||
|
||||
return newAction as IActionConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display actions for a single UPS or Group
|
||||
*/
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { TProtocol } from '../protocol/types.ts';
|
||||
import type { INupstConfig, IUpsConfig } from '../daemon.ts';
|
||||
import type { IActionConfig } from '../actions/base-action.ts';
|
||||
import { ProxmoxAction } from '../actions/proxmox-action.ts';
|
||||
import { getDefaultRuntimeUnitForUpsModel } from '../snmp/runtime-units.ts';
|
||||
import { SHUTDOWN, UPSD } from '../constants.ts';
|
||||
|
||||
/**
|
||||
@@ -996,18 +997,16 @@ export class UpsHandler {
|
||||
logger.log('');
|
||||
logger.info('Battery Runtime Unit:');
|
||||
logger.dim(' Controls how NUPST interprets the runtime value from your UPS.');
|
||||
logger.dim(' 1) Minutes (APC, TrippLite, Liebert - most common)');
|
||||
logger.dim(' 1) Minutes (TrippLite, Liebert, many RFC 1628 devices)');
|
||||
logger.dim(' 2) Seconds (Eaton, HPE, many RFC 1628 devices)');
|
||||
logger.dim(' 3) Ticks (CyberPower - 1/100 second increments)');
|
||||
logger.dim(' 3) Ticks (CyberPower, APC PowerNet - 1/100 second increments)');
|
||||
|
||||
const defaultUnitValue = snmpConfig.runtimeUnit === 'seconds'
|
||||
const defaultRuntimeUnit = snmpConfig.runtimeUnit ||
|
||||
getDefaultRuntimeUnitForUpsModel(snmpConfig.upsModel);
|
||||
const defaultUnitValue = defaultRuntimeUnit === 'seconds'
|
||||
? 2
|
||||
: snmpConfig.runtimeUnit === 'ticks'
|
||||
: defaultRuntimeUnit === 'ticks'
|
||||
? 3
|
||||
: snmpConfig.upsModel === 'cyberpower'
|
||||
? 3
|
||||
: snmpConfig.upsModel === 'eaton'
|
||||
? 2
|
||||
: 1;
|
||||
|
||||
const unitInput = await prompt(`Select runtime unit [${defaultUnitValue}]: `);
|
||||
|
||||
Reference in New Issue
Block a user