fix(tooling): better oids and more power metrics. Also new json httpServer feature support.

This commit is contained in:
2025-10-23 12:57:58 +00:00
parent b7a0bbcf6d
commit 777d48d82e
8 changed files with 697 additions and 78 deletions

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/nupst',
version: '5.1.0',
version: '5.1.1',
description: 'Network UPS Shutdown Tool - Monitor SNMP-enabled UPS devices and orchestrate graceful system shutdowns during power emergencies'
}

View File

@@ -223,6 +223,24 @@ export class NupstCli {
return;
}
// Handle feature subcommands
if (command === 'feature') {
const subcommand = commandArgs[0];
const featureHandler = this.nupst.getFeatureHandler();
switch (subcommand) {
case 'httpServer':
case 'http-server':
case 'http':
await featureHandler.configureHttpServer();
break;
default:
this.showFeatureHelp();
break;
}
return;
}
// Handle config subcommand
if (command === 'config') {
const subcommand = commandArgs[0] || 'show';
@@ -294,6 +312,26 @@ export class NupstCli {
` ${theme.path('/etc/nupst/config.json')}`,
], 60, 'info');
// HTTP Server Status (if configured)
if (config.httpServer) {
const serverStatus = config.httpServer.enabled
? theme.success('Enabled')
: 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');
}
// UPS Devices Table
if (config.upsDevices.length > 0) {
const upsRows = config.upsDevices.map((ups) => ({
@@ -466,6 +504,7 @@ export class NupstCli {
this.printCommand('ups <subcommand>', 'Manage UPS devices');
this.printCommand('group <subcommand>', 'Manage UPS groups');
this.printCommand('action <subcommand>', 'Manage UPS actions');
this.printCommand('feature <subcommand>', 'Manage optional features');
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)'));
@@ -509,6 +548,11 @@ export class NupstCli {
this.printCommand('nupst action list [target-id]', 'List all actions (optionally for specific target)');
console.log('');
// Feature subcommands
logger.log(theme.info('Feature Subcommands:'));
this.printCommand('nupst feature httpServer', 'Configure HTTP server for JSON status export');
console.log('');
// Options
logger.log(theme.info('Options:'));
this.printCommand('--debug, -d', 'Enable debug mode for detailed SNMP logging');
@@ -632,6 +676,21 @@ Examples:
nupst action add default - Add a new action to UPS or group 'default'
nupst action remove default 0 - Remove action at index 0 from UPS or group 'default'
nupst action add dc-rack-1 - Add a new action to group 'dc-rack-1'
`);
}
private showFeatureHelp(): void {
logger.log(`
NUPST - Feature Management Commands
Usage:
nupst feature <subcommand>
Subcommands:
httpServer - Configure HTTP server for JSON status export
Examples:
nupst feature httpServer - Enable/disable HTTP server with interactive setup
`);
}
}

213
ts/cli/feature-handler.ts Normal file
View File

@@ -0,0 +1,213 @@
import process from 'node:process';
import { execSync } from 'node:child_process';
import { Nupst } from '../nupst.ts';
import { logger } from '../logger.ts';
import { theme } from '../colors.ts';
import * as helpers from '../helpers/index.ts';
/**
* Class for handling feature-related CLI commands
* Provides interface for managing optional features like HTTP server
*/
export class FeatureHandler {
private readonly nupst: Nupst;
/**
* Create a new feature handler
* @param nupst Reference to the main Nupst instance
*/
constructor(nupst: Nupst) {
this.nupst = nupst;
}
/**
* Configure HTTP server feature
*/
public async configureHttpServer(): Promise<void> {
try {
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 {
await this.runHttpServerConfig(prompt);
} finally {
rl.close();
process.stdin.destroy();
}
} catch (error) {
logger.error(`HTTP Server config error: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Run the interactive HTTP server configuration process
* @param prompt Function to prompt for user input
*/
private async runHttpServerConfig(prompt: (question: string) => Promise<string>): Promise<void> {
logger.log('');
logger.logBoxTitle('HTTP Server Feature Configuration', 60);
logger.logBoxLine('Configure the HTTP server to expose UPS status as JSON');
logger.logBoxEnd();
logger.log('');
// Load config
let config;
try {
await this.nupst.getDaemon().loadConfig();
config = this.nupst.getDaemon().getConfig();
} catch (error) {
logger.error('No configuration found. Please run "nupst ups add" first.');
return;
}
// Show current status
if (config.httpServer?.enabled) {
logger.info('HTTP Server is currently: ' + theme.success('ENABLED'));
logger.log(` Port: ${theme.highlight(String(config.httpServer.port))}`);
logger.log(` Path: ${theme.highlight(config.httpServer.path)}`);
logger.log(` Auth Token: ${theme.dim('***' + config.httpServer.authToken.slice(-4))}`);
logger.log('');
} else {
logger.info('HTTP Server is currently: ' + theme.dim('DISABLED'));
logger.log('');
}
// Ask enable/disable
const action = await prompt('Enable or disable HTTP server? (enable/disable/cancel): ');
if (action.toLowerCase() === 'cancel' || action.toLowerCase() === 'c') {
logger.log('Cancelled.');
return;
}
if (action.toLowerCase() === 'disable' || action.toLowerCase() === 'd') {
// Disable HTTP server
config.httpServer = {
enabled: false,
port: config.httpServer?.port || 8080,
path: config.httpServer?.path || '/ups-status',
authToken: config.httpServer?.authToken || '',
};
this.nupst.getDaemon().saveConfig(config);
logger.log('');
logger.success('HTTP Server disabled');
logger.log('');
await this.restartServiceIfRunning();
return;
}
if (action.toLowerCase() !== 'enable' && action.toLowerCase() !== 'e') {
logger.error('Invalid option. Please enter "enable", "disable", or "cancel".');
return;
}
// Enable - gather configuration
logger.log('');
const portInput = await prompt(`HTTP Server Port [${config.httpServer?.port || 8080}]: `);
const port = portInput ? parseInt(portInput, 10) : (config.httpServer?.port || 8080);
if (isNaN(port) || port < 1 || port > 65535) {
logger.error('Invalid port number. Must be between 1 and 65535.');
return;
}
const pathInput = await prompt(`URL Path [${config.httpServer?.path || '/ups-status'}]: `);
const path = pathInput || config.httpServer?.path || '/ups-status';
// Ensure path starts with /
const finalPath = path.startsWith('/') ? path : `/${path}`;
// Generate or reuse auth token
let authToken = config.httpServer?.authToken;
if (!authToken) {
// Generate new random token
authToken = helpers.shortId() + helpers.shortId() + helpers.shortId();
logger.log('');
logger.info('Generated new authentication token');
} else {
const regenerate = await prompt('Regenerate authentication token? (y/N): ');
if (regenerate.toLowerCase() === 'y' || regenerate.toLowerCase() === 'yes') {
authToken = helpers.shortId() + helpers.shortId() + helpers.shortId();
logger.info('Generated new authentication token');
}
}
// Save configuration
config.httpServer = {
enabled: true,
port,
path: finalPath,
authToken,
};
this.nupst.getDaemon().saveConfig(config);
// Display summary
logger.log('');
logger.logBoxTitle('HTTP Server Configuration', 70, 'success');
logger.logBoxLine(`Status: ${theme.success('ENABLED')}`);
logger.logBoxLine(`Port: ${theme.highlight(String(port))}`);
logger.logBoxLine(`Path: ${theme.highlight(finalPath)}`);
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 "http://localhost:${port}${finalPath}?token=${authToken}"`);
logger.logBoxEnd();
logger.log('');
logger.warn('IMPORTANT: Save the authentication token securely!');
logger.log('');
await this.restartServiceIfRunning();
}
/**
* Restart the service if it's currently running
*/
private async restartServiceIfRunning(): Promise<void> {
try {
const isActive = execSync('systemctl is-active nupst.service || true').toString().trim() === 'active';
if (isActive) {
logger.log('');
const readline = await import('node:readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const answer = await new Promise<string>((resolve) => {
rl.question('Service is running. Restart to apply changes? (Y/n): ', resolve);
});
rl.close();
if (!answer || answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes') {
logger.info('Restarting service...');
execSync('sudo systemctl restart nupst.service');
logger.success('Service restarted successfully');
} else {
logger.warn('Changes will take effect on next service restart');
}
}
} catch (error) {
// Ignore errors - service might not be installed
}
}
}

View File

@@ -10,6 +10,7 @@ import { MigrationRunner } from './migrations/index.ts';
import { theme, symbols, getBatteryColor, getRuntimeColor, formatPowerStatus } 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';
const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);
@@ -46,6 +47,20 @@ export interface IGroupConfig {
actions?: IActionConfig[];
}
/**
* HTTP Server configuration interface
*/
export interface IHttpServerConfig {
/** Whether HTTP server is enabled */
enabled: boolean;
/** Port to listen on */
port: number;
/** URL path for the endpoint */
path: string;
/** Authentication token */
authToken: string;
}
/**
* Configuration interface for the daemon
*/
@@ -58,6 +73,8 @@ export interface INupstConfig {
groups: IGroupConfig[];
/** Check interval in milliseconds */
checkInterval: number;
/** HTTP Server configuration */
httpServer?: IHttpServerConfig;
// Legacy fields for backward compatibility (will be migrated away)
/** UPS list (v3 format - legacy) */
@@ -82,6 +99,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
lastStatusChange: number;
lastCheckTime: number;
}
@@ -139,6 +160,7 @@ export class NupstDaemon {
private snmp: NupstSnmp;
private isRunning: boolean = false;
private upsStatus: Map<string, IUpsStatus> = new Map();
private httpServer?: NupstHttpServer;
/**
* Create a new daemon instance with the given SNMP manager
@@ -278,6 +300,21 @@ export class NupstDaemon {
// Initialize UPS status tracking
this.initializeUpsStatus();
// Start HTTP server if configured
if (this.config.httpServer?.enabled && this.config.httpServer.authToken) {
try {
this.httpServer = new NupstHttpServer(
this.config.httpServer.port,
this.config.httpServer.path,
this.config.httpServer.authToken,
() => this.upsStatus
);
this.httpServer.start();
} catch (error) {
logger.error(`Failed to start HTTP server: ${error instanceof Error ? error.message : String(error)}`);
}
}
// Start UPS monitoring
this.isRunning = true;
await this.monitor();
@@ -304,6 +341,10 @@ export class NupstDaemon {
powerStatus: 'unknown',
batteryCapacity: 100,
batteryRuntime: 999, // High value as default
outputLoad: 0,
outputPower: 0,
outputVoltage: 0,
outputCurrent: 0,
lastStatusChange: Date.now(),
lastCheckTime: 0,
});
@@ -377,6 +418,12 @@ export class NupstDaemon {
*/
public stop(): void {
logger.log('Stopping NUPST daemon...');
// Stop HTTP server if running
if (this.httpServer) {
this.httpServer.stop();
}
this.isRunning = false;
}
@@ -437,6 +484,10 @@ export class NupstDaemon {
powerStatus: 'unknown',
batteryCapacity: 100,
batteryRuntime: 999,
outputLoad: 0,
outputPower: 0,
outputVoltage: 0,
outputCurrent: 0,
lastStatusChange: Date.now(),
lastCheckTime: 0,
});
@@ -456,6 +507,10 @@ export class NupstDaemon {
powerStatus: status.powerStatus,
batteryCapacity: status.batteryCapacity,
batteryRuntime: status.batteryRuntime,
outputLoad: status.outputLoad,
outputPower: status.outputPower,
outputVoltage: status.outputVoltage,
outputCurrent: status.outputCurrent,
lastCheckTime: currentTime,
lastStatusChange: currentStatus?.lastStatusChange || currentTime,
};

113
ts/http-server.ts Normal file
View File

@@ -0,0 +1,113 @@
import * as http from 'node:http';
import { URL } from 'node:url';
import { logger } from './logger.ts';
import type { IUpsStatus } from './daemon.ts';
/**
* HTTP Server for exposing UPS status as JSON
* Serves cached data from the daemon's monitoring loop
*/
export class NupstHttpServer {
private server?: http.Server;
private port: number;
private path: string;
private authToken: string;
private getUpsStatus: () => Map<string, IUpsStatus>;
/**
* Create a new HTTP server instance
* @param port Port to listen on
* @param path URL path for the endpoint
* @param authToken Authentication token required for access
* @param getUpsStatus Function to retrieve cached UPS status
*/
constructor(
port: number,
path: string,
authToken: string,
getUpsStatus: () => Map<string, IUpsStatus>
) {
this.port = port;
this.path = path;
this.authToken = authToken;
this.getUpsStatus = getUpsStatus;
}
/**
* Verify authentication token from request
* Supports both Bearer token in Authorization header and token query parameter
* @param req HTTP request
* @returns True if authenticated, false otherwise
*/
private isAuthenticated(req: http.IncomingMessage): boolean {
// Check Authorization header (Bearer token)
const authHeader = req.headers.authorization;
if (authHeader?.startsWith('Bearer ')) {
const token = authHeader.substring(7);
return token === this.authToken;
}
// Check token query parameter
if (req.url) {
const url = new URL(req.url, `http://localhost:${this.port}`);
const tokenParam = url.searchParams.get('token');
return tokenParam === this.authToken;
}
return false;
}
/**
* Start the HTTP server
*/
public start(): void {
this.server = http.createServer((req, res) => {
// Parse URL
const reqUrl = new URL(req.url || '/', `http://localhost:${this.port}`);
if (reqUrl.pathname === this.path && req.method === 'GET') {
// Check authentication
if (!this.isAuthenticated(req)) {
res.writeHead(401, {
'Content-Type': 'application/json',
'WWW-Authenticate': 'Bearer'
});
res.end(JSON.stringify({ error: 'Unauthorized' }));
return;
}
// Get cached status (no refresh)
const statusMap = this.getUpsStatus();
const statusArray = Array.from(statusMap.values());
res.writeHead(200, {
'Content-Type': 'application/json',
'Cache-Control': 'no-cache'
});
res.end(JSON.stringify(statusArray, null, 2));
} else {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Not Found' }));
}
});
this.server.listen(this.port, () => {
logger.success(`HTTP server started on port ${this.port} at ${this.path}`);
});
this.server.on('error', (error: any) => {
logger.error(`HTTP server error: ${error.message}`);
});
}
/**
* Stop the HTTP server
*/
public stop(): void {
if (this.server) {
this.server.close(() => {
logger.log('HTTP server stopped');
});
}
}
}

View File

@@ -7,6 +7,7 @@ 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 { FeatureHandler } from './cli/feature-handler.ts';
import * as https from 'node:https';
/**
@@ -21,6 +22,7 @@ export class Nupst {
private readonly groupHandler: GroupHandler;
private readonly serviceHandler: ServiceHandler;
private readonly actionHandler: ActionHandler;
private readonly featureHandler: FeatureHandler;
private updateAvailable: boolean = false;
private latestVersion: string = '';
@@ -39,6 +41,7 @@ export class Nupst {
this.groupHandler = new GroupHandler(this);
this.serviceHandler = new ServiceHandler(this);
this.actionHandler = new ActionHandler(this);
this.featureHandler = new FeatureHandler(this);
}
/**
@@ -90,6 +93,13 @@ export class Nupst {
return this.actionHandler;
}
/**
* Get the Feature handler for feature management
*/
public getFeatureHandler(): FeatureHandler {
return this.featureHandler;
}
/**
* Get the current version of NUPST
* @returns The current version string