import process from 'node:process'; import { promises as fs } from 'node:fs'; import { execSync } from 'node:child_process'; import { NupstDaemon, type IUpsConfig } from './daemon.ts'; import { NupstSnmp } from './snmp/manager.ts'; import { logger } from './logger.ts'; import { theme, symbols, getBatteryColor, getRuntimeColor, formatPowerStatus } from './colors.ts'; /** * Class for managing systemd service * Handles installation, removal, and control of the NUPST systemd service */ export class NupstSystemd { /** Path to the systemd service file */ private readonly serviceFilePath = '/etc/systemd/system/nupst.service'; private readonly daemon: NupstDaemon; /** Template for the systemd service file */ private readonly serviceTemplate = `[Unit] Description=NUPST - Deno-powered UPS Monitoring Tool After=network.target [Service] ExecStart=/usr/local/bin/nupst service start-daemon Restart=always RestartSec=10 User=root Group=root Environment=PATH=/usr/bin:/usr/local/bin WorkingDirectory=/opt/nupst [Install] WantedBy=multi-user.target `; /** * Create a new systemd manager * @param daemon The daemon instance to manage */ constructor(daemon: NupstDaemon) { this.daemon = daemon; } /** * Check if a configuration file exists * @private * @throws Error if configuration not found */ private async checkConfigExists(): Promise { const configPath = '/etc/nupst/config.json'; try { await fs.access(configPath); } catch (error) { logger.log(''); logger.error('No configuration found'); logger.log(` ${theme.dim('Config file:')} ${configPath}`); logger.log(` ${theme.dim('Run')} ${theme.command('nupst ups add')} ${theme.dim('to create a configuration')}`); logger.log(''); throw new Error('Configuration not found'); } } /** * Install the systemd service file * @throws Error if installation fails */ public async install(): Promise { try { // Check if configuration exists before installing await this.checkConfigExists(); // Write the service file await fs.writeFile(this.serviceFilePath, this.serviceTemplate); const boxWidth = 50; logger.logBoxTitle('Service Installation', boxWidth); logger.logBoxLine(`Service file created at ${this.serviceFilePath}`); // Reload systemd daemon execSync('systemctl daemon-reload'); logger.logBoxLine('Systemd daemon reloaded'); // Enable the service execSync('systemctl enable nupst.service'); logger.logBoxLine('Service enabled to start on boot'); logger.logBoxEnd(); } catch (error) { if (error instanceof Error && error.message === 'Configuration not found') { // Just rethrow the error as the message has already been displayed throw error; } logger.error(`Failed to install systemd service: ${error}`); throw error; } } /** * Start the systemd service * @throws Error if start fails */ public async start(): Promise { try { // Check if configuration exists before starting await this.checkConfigExists(); execSync('systemctl start nupst.service'); const boxWidth = 45; logger.logBoxTitle('Service Status', boxWidth); logger.logBoxLine('NUPST service started successfully'); logger.logBoxEnd(); } catch (error) { if (error instanceof Error && error.message === 'Configuration not found') { // Exit with error code since configuration is required process.exit(1); } logger.error(`Failed to start service: ${error}`); throw error; } } /** * Stop the systemd service * @throws Error if stop fails */ public stop(): void { try { execSync('systemctl stop nupst.service'); logger.success('NUPST service stopped'); } catch (error) { logger.error(`Failed to stop service: ${error}`); throw error; } } /** * Get status of the systemd service and UPS * @param debugMode Whether to enable debug mode for SNMP */ /** * Display version information and update status * @private */ private async displayVersionInfo(): Promise { try { const nupst = this.daemon.getNupstSnmp().getNupst(); const version = nupst.getVersion(); // Check for updates const updateAvailable = await nupst.checkForUpdates(); // Display version info if (updateAvailable) { const updateStatus = nupst.getUpdateStatus(); logger.log(''); logger.log( `${theme.dim('NUPST')} ${theme.dim('v' + version)} ${symbols.warning} ${theme.statusWarning(`Update available: v${updateStatus.latestVersion}`)}`, ); logger.log(` ${theme.dim('Run')} ${theme.command('sudo nupst update')} ${theme.dim('to upgrade')}`); } else { logger.log(''); logger.log( `${theme.dim('NUPST')} ${theme.dim('v' + version)} ${symbols.success} ${theme.success('Up to date')}`, ); } } catch (error) { // If version check fails, show at least the current version try { const nupst = this.daemon.getNupstSnmp().getNupst(); const version = nupst.getVersion(); logger.log(''); logger.log(`${theme.dim('NUPST')} ${theme.dim('v' + version)}`); } catch (_innerError) { // Silently fail if we can't even get the version } } } public async getStatus(debugMode: boolean = false): Promise { try { // Enable debug mode if requested if (debugMode) { console.log(''); logger.info('Debug Mode: SNMP debugging enabled'); console.log(''); this.daemon.getNupstSnmp().enableDebug(); } // Display version and update status first await this.displayVersionInfo(); // Check if config exists try { await this.checkConfigExists(); } catch (error) { // Error message already displayed by checkConfigExists return; } await this.displayServiceStatus(); await this.displayAllUpsStatus(); } catch (error) { logger.error( `Failed to get status: ${error instanceof Error ? error.message : String(error)}`, ); } } /** * Display the systemd service status * @private */ private displayServiceStatus(): void { try { const serviceStatus = execSync('systemctl status nupst.service').toString(); const lines = serviceStatus.split('\n'); // Parse key information from systemctl output let isActive = false; let pid = ''; let memory = ''; let cpu = ''; for (const line of lines) { if (line.includes('Active:')) { isActive = line.includes('active (running)'); } else if (line.includes('Main PID:')) { const match = line.match(/Main PID:\s+(\d+)/); if (match) pid = match[1]; } else if (line.includes('Memory:')) { const match = line.match(/Memory:\s+([\d.]+[A-Z])/); if (match) memory = match[1]; } else if (line.includes('CPU:')) { const match = line.match(/CPU:\s+([\d.]+(?:ms|s))/); if (match) cpu = match[1]; } } // Display beautiful status logger.log(''); if (isActive) { logger.log(`${symbols.running} ${theme.success('Service:')} ${theme.statusActive('active (running)')}`); } else { logger.log(`${symbols.stopped} ${theme.dim('Service:')} ${theme.statusInactive('inactive')}`); } if (pid || memory || cpu) { const details = []; if (pid) details.push(`PID: ${theme.dim(pid)}`); if (memory) details.push(`Memory: ${theme.dim(memory)}`); if (cpu) details.push(`CPU: ${theme.dim(cpu)}`); logger.log(` ${details.join(' ')}`); } logger.log(''); } catch (error) { logger.log(''); logger.log(`${symbols.stopped} ${theme.dim('Service:')} ${theme.statusInactive('not installed')}`); logger.log(''); } } /** * Display all UPS statuses * @private */ private async displayAllUpsStatus(): Promise { try { // Explicitly load the configuration first to ensure it's up-to-date await this.daemon.loadConfig(); const config = this.daemon.getConfig(); const snmp = this.daemon.getNupstSnmp(); // Check if we have the new multi-UPS config format if (config.upsDevices && Array.isArray(config.upsDevices) && config.upsDevices.length > 0) { logger.info(`UPS Devices (${config.upsDevices.length}):`); // Show status for each UPS for (const ups of config.upsDevices) { await this.displaySingleUpsStatus(ups, snmp); } } else if (config.snmp) { // Legacy single UPS configuration (v1/v2 format) logger.info('UPS Devices (1):'); const legacyUps: IUpsConfig = { id: 'default', name: 'Default UPS', snmp: config.snmp, groups: [], actions: config.thresholds ? [ { type: 'shutdown', thresholds: config.thresholds, triggerMode: 'onlyThresholds', shutdownDelay: 5, }, ] : [], }; await this.displaySingleUpsStatus(legacyUps, snmp); } else { logger.log(''); logger.warn('No UPS devices configured'); logger.log(` ${theme.dim('Run')} ${theme.command('nupst ups add')} ${theme.dim('to add a device')}`); logger.log(''); } } catch (error) { logger.log(''); logger.error('Failed to retrieve UPS status'); logger.log(` ${theme.dim(error instanceof Error ? error.message : String(error))}`); logger.log(''); } } /** * Display status of a single UPS * @param ups UPS configuration * @param snmp SNMP manager */ private async displaySingleUpsStatus(ups: IUpsConfig, snmp: NupstSnmp): Promise { try { // Create a test config with a short timeout const testConfig = { ...ups.snmp, timeout: Math.min(ups.snmp.timeout, 10000), // Use at most 10 seconds for status check }; const status = await snmp.getUpsStatus(testConfig); // Determine status symbol based on power status let statusSymbol = symbols.unknown; if (status.powerStatus === 'online') { statusSymbol = symbols.running; } else if (status.powerStatus === 'onBattery') { statusSymbol = symbols.warning; } // Display UPS name and power status logger.log(` ${statusSymbol} ${theme.highlight(ups.name)} - ${formatPowerStatus(status.powerStatus)}`); // Display battery with color coding const batteryColor = getBatteryColor(status.batteryCapacity); // Get threshold from actions (if any action has thresholds defined) const actionWithThresholds = ups.actions?.find((action) => action.thresholds); const batteryThreshold = actionWithThresholds?.thresholds?.battery; const batterySymbol = batteryThreshold !== undefined && status.batteryCapacity >= batteryThreshold ? symbols.success : batteryThreshold !== undefined ? symbols.warning : ''; logger.log(` Battery: ${batteryColor(status.batteryCapacity + '%')} ${batterySymbol} Runtime: ${getRuntimeColor(status.batteryRuntime)(status.batteryRuntime + ' min')}`); // Display host info logger.log(` ${theme.dim(`Host: ${ups.snmp.host}:${ups.snmp.port}`)}`); // Display groups if any if (ups.groups && ups.groups.length > 0) { const config = this.daemon.getConfig(); const groupNames = ups.groups.map((groupId: string) => { const group = config.groups?.find((g: { id: string }) => g.id === groupId); return group ? group.name : groupId; }); logger.log(` ${theme.dim(`Groups: ${groupNames.join(', ')}`)}`); } logger.log(''); } catch (error) { // Display error for this UPS logger.log(` ${symbols.error} ${theme.highlight(ups.name)} - ${theme.error('Connection failed')}`); logger.log(` ${theme.dim(error instanceof Error ? error.message : String(error))}`); logger.log(` ${theme.dim(`Host: ${ups.snmp.host}:${ups.snmp.port}`)}`); logger.log(''); } } /** * Disable and uninstall the systemd service * @throws Error if disabling fails */ public async disable(): Promise { try { await this.stopService(); await this.disableService(); await this.removeServiceFile(); // Reload systemd daemon execSync('systemctl daemon-reload'); logger.log('Systemd daemon reloaded'); logger.success('NUPST service has been successfully uninstalled'); } catch (error) { logger.error(`Failed to disable and uninstall service: ${error}`); throw error; } } /** * Stop the service if it's running * @private */ private stopService(): void { try { logger.log('Stopping NUPST service...'); execSync('systemctl stop nupst.service'); } catch (error) { // Service might not be running, that's okay logger.log('Service was not running or could not be stopped'); } } /** * Disable the service * @private */ private disableService(): void { try { logger.log('Disabling NUPST service...'); execSync('systemctl disable nupst.service'); } catch (error) { logger.log('Service was not enabled or could not be disabled'); } } /** * Remove the service file if it exists * @private */ private async removeServiceFile(): Promise { if (await fs.stat(this.serviceFilePath).catch(() => null)) { logger.log(`Removing service file ${this.serviceFilePath}...`); await fs.unlink(this.serviceFilePath); logger.log('Service file removed'); } else { logger.log('Service file did not exist'); } } }