Files
nupst/ts/systemd.ts
Juergen Kunz d6e0a1a274
All checks were successful
CI / Type Check & Lint (push) Successful in 6s
CI / Build Test (Current Platform) (push) Successful in 5s
Release / build-and-release (push) Successful in 44s
CI / Build All Platforms (push) Successful in 49s
feat(cli): remove ALL ugly boxes from status output - now fully beautiful
Removed the last remaining ugly ASCII boxes:
- Version info box (┌─┐│└┘) that appeared at top
- Async version check box that ended randomly in middle
- Configuration error box

Now status output is 100% clean and beautiful with just colored text:

● Service: active (running)
  PID: 9120  Memory: 45.7M  CPU: 190ms

UPS Devices (2):
  ⚠ Test UPS (SNMP v1) - On Battery
    Battery: 100% ✓  Runtime: 48 min
    Host: 192.168.187.140:161

  ◯ Test UPS (SNMP v3) - Unknown
    Battery: 0% ⚠  Runtime: 0 min
    Host: 192.168.187.140:161

No boxes, just beautiful colored output with symbols!
Bumped to v4.1.0 to mark completion of beautiful CLI feature.
2025-10-19 23:01:25 +00:00

379 lines
12 KiB
TypeScript

import process from 'node:process';
import { promises as fs } from 'node:fs';
import { execSync } from 'node:child_process';
import { NupstDaemon } from './daemon.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<void> {
const configPath = '/etc/nupst/config.json';
try {
await fs.access(configPath);
} catch (error) {
console.log('');
console.log(`${symbols.error} ${theme.error('No configuration found')}`);
console.log(` ${theme.dim('Config file:')} ${configPath}`);
console.log(` ${theme.dim('Run')} ${theme.command('nupst ups add')} ${theme.dim('to create a configuration')}`);
console.log('');
throw new Error('Configuration not found');
}
}
/**
* Install the systemd service file
* @throws Error if installation fails
*/
public async install(): Promise<void> {
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<void> {
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
*/
public async getStatus(debugMode: boolean = false): Promise<void> {
try {
// Enable debug mode if requested
if (debugMode) {
console.log('');
logger.info('Debug Mode: SNMP debugging enabled');
console.log('');
this.daemon.getNupstSnmp().enableDebug();
}
// Check if config exists first
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
console.log('');
if (isActive) {
console.log(`${symbols.running} ${theme.success('Service:')} ${theme.statusActive('active (running)')}`);
} else {
console.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)}`);
console.log(` ${details.join(' ')}`);
}
console.log('');
} catch (error) {
console.log('');
console.log(`${symbols.stopped} ${theme.dim('Service:')} ${theme.statusInactive('not installed')}`);
console.log('');
}
}
/**
* Display all UPS statuses
* @private
*/
private async displayAllUpsStatus(): Promise<void> {
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) {
console.log(theme.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
console.log(theme.info('UPS Devices (1):'));
const legacyUps = {
id: 'default',
name: 'Default UPS',
snmp: config.snmp,
thresholds: config.thresholds,
groups: [],
};
await this.displaySingleUpsStatus(legacyUps, snmp);
} else {
console.log('');
console.log(`${symbols.warning} ${theme.warning('No UPS devices configured')}`);
console.log(` ${theme.dim('Run')} ${theme.command('nupst ups add')} ${theme.dim('to add a device')}`);
console.log('');
}
} catch (error) {
console.log('');
console.log(`${symbols.error} ${theme.error('Failed to retrieve UPS status')}`);
console.log(` ${theme.dim(error instanceof Error ? error.message : String(error))}`);
console.log('');
}
}
/**
* Display status of a single UPS
* @param ups UPS configuration
* @param snmp SNMP manager
*/
private async displaySingleUpsStatus(ups: any, snmp: any): Promise<void> {
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
console.log(` ${statusSymbol} ${theme.highlight(ups.name)} - ${formatPowerStatus(status.powerStatus)}`);
// Display battery with color coding
const batteryColor = getBatteryColor(status.batteryCapacity);
const batterySymbol = status.batteryCapacity >= ups.thresholds.battery ? symbols.success : symbols.warning;
console.log(` Battery: ${batteryColor(status.batteryCapacity + '%')} ${batterySymbol} Runtime: ${getRuntimeColor(status.batteryRuntime)(status.batteryRuntime + ' min')}`);
// Display host info
console.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;
});
console.log(` ${theme.dim(`Groups: ${groupNames.join(', ')}`)}`);
}
console.log('');
} catch (error) {
// Display error for this UPS
console.log(` ${symbols.error} ${theme.highlight(ups.name)} - ${theme.error('Connection failed')}`);
console.log(` ${theme.dim(error instanceof Error ? error.message : String(error))}`);
console.log(` ${theme.dim(`Host: ${ups.snmp.host}:${ups.snmp.port}`)}`);
console.log('');
}
}
/**
* Disable and uninstall the systemd service
* @throws Error if disabling fails
*/
public async disable(): Promise<void> {
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<void> {
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');
}
}
}