tcp-serial-relay
Version:
Modular TCP to Serial relay service with comprehensive logging, monitoring and web dashboard
682 lines (568 loc) • 21.8 kB
JavaScript
// scripts/health-check.js
// Health monitoring script for TCP-Serial Relay
const fs = require('fs');
const path = require('path');
const os = require('os');
const { execSync } = require('child_process');
// Configuration
const CONFIG = {
APP_DIR: process.env.APP_DIR || '/opt/tcp-serial-relay',
LOG_DIR: process.env.LOG_DIR || '/var/log/tcp-serial-relay',
CONFIG_DIR: process.env.CONFIG_DIR || '/etc/tcp-serial-relay',
STATUS_DIR: process.env.STATUS_DIR || '/opt/tcp-serial-relay/status',
MAX_LOG_AGE_HOURS: 24,
MAX_STATUS_AGE_HOURS: 2,
ALERT_THRESHOLDS: {
diskUsagePercent: 90,
memoryUsagePercent: 90,
errorRatePercent: 50,
noActivityHours: 3
}
};
// Colors for console output
const COLORS = {
RED: '\x1b[31m',
GREEN: '\x1b[32m',
YELLOW: '\x1b[33m',
BLUE: '\x1b[34m',
RESET: '\x1b[0m'
};
class HealthChecker {
constructor() {
this.results = {
timestamp: new Date().toISOString(),
hostname: os.hostname(),
overall: 'UNKNOWN',
checks: {},
alerts: [],
metrics: {}
};
}
// Logging helpers
log(level, message, data = {}) {
const color = {
'INFO': COLORS.BLUE,
'WARN': COLORS.YELLOW,
'ERROR': COLORS.RED,
'SUCCESS': COLORS.GREEN
}[level] || COLORS.RESET;
console.log(`${color}[${level}] ${message}${COLORS.RESET}`);
if (Object.keys(data).length > 0) {
console.log(JSON.stringify(data, null, 2));
}
}
// Check if files/directories exist
checkFileSystem() {
this.log('INFO', 'Checking file system...');
const checks = {
appDirectory: fs.existsSync(CONFIG.APP_DIR),
logDirectory: fs.existsSync(CONFIG.LOG_DIR),
configDirectory: fs.existsSync(CONFIG.CONFIG_DIR),
statusDirectory: fs.existsSync(CONFIG.STATUS_DIR),
configFile: fs.existsSync(path.join(CONFIG.CONFIG_DIR, 'relay-config.json')),
envFile: fs.existsSync(path.join(CONFIG.CONFIG_DIR, 'relay.env')),
cronScript: fs.existsSync(path.join(CONFIG.APP_DIR, 'scripts', 'tcp-serial-relay-cron.sh'))
};
this.results.checks.filesystem = checks;
const allExist = Object.values(checks).every(Boolean);
if (allExist) {
this.log('SUCCESS', 'All required files and directories exist');
} else {
this.log('ERROR', 'Missing required files or directories', checks);
this.results.alerts.push({
severity: 'HIGH',
message: 'Missing required filesystem components',
details: checks
});
}
return allExist;
}
// Check cron configuration
checkCronJob() {
this.log('INFO', 'Checking cron job configuration...');
try {
const crontab = execSync('sudo -u relay crontab -l 2>/dev/null || echo ""', { encoding: 'utf8' });
const hasCronJob = crontab.includes('tcp-serial-relay-cron.sh');
this.results.checks.cronjob = {
configured: hasCronJob,
schedule: hasCronJob ? crontab.split('\n').find(line => line.includes('tcp-serial-relay-cron.sh')) : null
};
if (hasCronJob) {
this.log('SUCCESS', 'Cron job is configured');
} else {
this.log('ERROR', 'Cron job is not configured');
this.results.alerts.push({
severity: 'HIGH',
message: 'Cron job not configured',
details: 'The hourly cron job is not set up for the relay user'
});
}
return hasCronJob;
} catch (error) {
this.log('ERROR', 'Failed to check cron job', { error: error.message });
this.results.checks.cronjob = { error: error.message };
return false;
}
}
// Check recent activity from logs
checkRecentActivity() {
this.log('INFO', 'Checking recent activity...');
const cronLogPath = path.join(CONFIG.LOG_DIR, 'cron.log');
if (!fs.existsSync(cronLogPath)) {
this.log('WARN', 'Cron log file not found');
this.results.checks.activity = { error: 'Cron log file not found' };
this.results.alerts.push({
severity: 'MEDIUM',
message: 'No cron log file found',
details: 'The system may not have run any cron jobs yet'
});
return false;
}
try {
const logStats = fs.statSync(cronLogPath);
const hoursAgo = (Date.now() - logStats.mtime.getTime()) / (1000 * 60 * 60);
// Read last few lines of log
const logContent = fs.readFileSync(cronLogPath, 'utf8');
const lines = logContent.split('\n').filter(line => line.trim());
const recentLines = lines.slice(-10);
// Count success/failure rates
const successCount = recentLines.filter(line => line.includes('completed successfully')).length;
const errorCount = recentLines.filter(line => line.includes('failed') || line.includes('ERROR')).length;
const totalRuns = recentLines.filter(line => line.includes('Starting relay service')).length;
this.results.checks.activity = {
lastLogUpdate: logStats.mtime.toISOString(),
hoursAgo: Math.round(hoursAgo * 100) / 100,
recentRuns: totalRuns,
successCount,
errorCount,
successRate: totalRuns > 0 ? Math.round((successCount / totalRuns) * 100) : 0
};
this.results.metrics.activityMetrics = this.results.checks.activity;
// Check for alerts
if (hoursAgo > CONFIG.ALERT_THRESHOLDS.noActivityHours) {
this.results.alerts.push({
severity: 'HIGH',
message: 'No recent activity detected',
details: `Last log update was ${hoursAgo.toFixed(1)} hours ago`
});
}
if (errorCount > 0 && totalRuns > 0) {
const errorRate = (errorCount / totalRuns) * 100;
if (errorRate > CONFIG.ALERT_THRESHOLDS.errorRatePercent) {
this.results.alerts.push({
severity: 'HIGH',
message: 'High error rate detected',
details: `${errorRate.toFixed(1)}% of recent runs failed`
});
}
}
this.log('SUCCESS', `Activity check completed: ${successCount}/${totalRuns} successful runs`);
return true;
} catch (error) {
this.log('ERROR', 'Failed to check activity', { error: error.message });
this.results.checks.activity = { error: error.message };
return false;
}
}
// Check system resources
checkSystemResources() {
this.log('INFO', 'Checking system resources...');
try {
// Memory usage
const totalMem = os.totalmem();
const freeMem = os.freemem();
const usedMem = totalMem - freeMem;
const memUsagePercent = (usedMem / totalMem) * 100;
// Disk usage
const diskUsage = execSync("df -h / | awk 'NR==2 {print $5}' | sed 's/%//'", { encoding: 'utf8' }).trim();
const diskUsagePercent = parseInt(diskUsage);
// Load average
const loadAvg = os.loadavg();
this.results.checks.system = {
memory: {
total: Math.round(totalMem / (1024 * 1024 * 1024) * 100) / 100,
free: Math.round(freeMem / (1024 * 1024 * 1024) * 100) / 100,
used: Math.round(usedMem / (1024 * 1024 * 1024) * 100) / 100,
usagePercent: Math.round(memUsagePercent)
},
disk: {
usagePercent: diskUsagePercent
},
load: {
'1min': loadAvg[0],
'5min': loadAvg[1],
'15min': loadAvg[2]
},
uptime: Math.round(os.uptime() / 3600 * 100) / 100
};
this.results.metrics.systemMetrics = this.results.checks.system;
// Check thresholds
if (memUsagePercent > CONFIG.ALERT_THRESHOLDS.memoryUsagePercent) {
this.results.alerts.push({
severity: 'MEDIUM',
message: 'High memory usage',
details: `Memory usage is ${memUsagePercent.toFixed(1)}%`
});
}
if (diskUsagePercent > CONFIG.ALERT_THRESHOLDS.diskUsagePercent) {
this.results.alerts.push({
severity: 'HIGH',
message: 'High disk usage',
details: `Disk usage is ${diskUsagePercent}%`
});
}
this.log('SUCCESS', 'System resources check completed');
return true;
} catch (error) {
this.log('ERROR', 'Failed to check system resources', { error: error.message });
this.results.checks.system = { error: error.message };
return false;
}
}
// Check serial ports
checkSerialPorts() {
this.log('INFO', 'Checking serial ports...');
try {
// Change to app directory and check serial ports
process.chdir(CONFIG.APP_DIR);
const { SerialPort } = require('serialport');
return SerialPort.list().then(ports => {
this.results.checks.serialPorts = {
available: ports.map(port => ({
path: port.path,
manufacturer: port.manufacturer,
vendorId: port.vendorId,
productId: port.productId
})),
count: ports.length
};
if (ports.length === 0) {
this.log('WARN', 'No serial ports detected');
this.results.alerts.push({
severity: 'MEDIUM',
message: 'No serial ports detected',
details: 'Check if serial devices are connected'
});
} else {
this.log('SUCCESS', `Found ${ports.length} serial port(s)`);
ports.forEach(port => {
this.log('INFO', ` ${port.path} - ${port.manufacturer || 'Unknown'}`);
});
}
return ports.length > 0;
}).catch(error => {
this.log('ERROR', 'Failed to list serial ports', { error: error.message });
this.results.checks.serialPorts = { error: error.message };
return false;
});
} catch (error) {
this.log('ERROR', 'Failed to check serial ports', { error: error.message });
this.results.checks.serialPorts = { error: error.message };
return Promise.resolve(false);
}
}
// Check configuration
checkConfiguration() {
this.log('INFO', 'Checking configuration...');
try {
const configPath = path.join(CONFIG.CONFIG_DIR, 'relay-config.json');
if (!fs.existsSync(configPath)) {
this.log('ERROR', 'Configuration file not found');
this.results.checks.configuration = { error: 'Configuration file not found' };
return false;
}
const configContent = fs.readFileSync(configPath, 'utf8');
const config = JSON.parse(configContent);
// Validate required fields
const requiredFields = ['tcpIp', 'tcpPort', 'serialPath', 'serialBaud'];
const missingFields = requiredFields.filter(field => !config[field]);
this.results.checks.configuration = {
valid: missingFields.length === 0,
missingFields,
tcpEndpoint: `${config.tcpIp}:${config.tcpPort}`,
serialConfig: `${config.serialPath} @ ${config.serialBaud} baud`
};
if (missingFields.length > 0) {
this.log('ERROR', 'Configuration validation failed', { missingFields });
this.results.alerts.push({
severity: 'HIGH',
message: 'Invalid configuration',
details: `Missing required fields: ${missingFields.join(', ')}`
});
return false;
}
this.log('SUCCESS', 'Configuration is valid');
return true;
} catch (error) {
this.log('ERROR', 'Failed to check configuration', { error: error.message });
this.results.checks.configuration = { error: error.message };
return false;
}
}
// Check status files
checkStatusFiles() {
this.log('INFO', 'Checking recent status files...');
try {
if (!fs.existsSync(CONFIG.STATUS_DIR)) {
this.log('WARN', 'Status directory not found');
this.results.checks.statusFiles = { error: 'Status directory not found' };
return false;
}
const statusFiles = fs.readdirSync(CONFIG.STATUS_DIR)
.filter(file => file.startsWith('status-') && file.endsWith('.json'))
.map(file => {
const filePath = path.join(CONFIG.STATUS_DIR, file);
const stats = fs.statSync(filePath);
return {
file,
path: filePath,
mtime: stats.mtime,
size: stats.size
};
})
.sort((a, b) => b.mtime - a.mtime);
this.results.checks.statusFiles = {
count: statusFiles.length,
latest: statusFiles.length > 0 ? statusFiles[0] : null,
recentFiles: statusFiles.slice(0, 5).map(f => ({
file: f.file,
age: Math.round((Date.now() - f.mtime.getTime()) / (1000 * 60)),
size: f.size
}))
};
if (statusFiles.length === 0) {
this.log('WARN', 'No status files found');
this.results.alerts.push({
severity: 'MEDIUM',
message: 'No status files found',
details: 'The system may not have run successfully yet'
});
return false;
}
const latestFile = statusFiles[0];
const hoursAgo = (Date.now() - latestFile.mtime.getTime()) / (1000 * 60 * 60);
if (hoursAgo > CONFIG.MAX_STATUS_AGE_HOURS) {
this.results.alerts.push({
severity: 'MEDIUM',
message: 'Stale status files',
details: `Latest status file is ${hoursAgo.toFixed(1)} hours old`
});
}
this.log('SUCCESS', `Found ${statusFiles.length} status files`);
return true;
} catch (error) {
this.log('ERROR', 'Failed to check status files', { error: error.message });
this.results.checks.statusFiles = { error: error.message };
return false;
}
}
// Determine overall health status
determineOverallHealth() {
const checks = this.results.checks;
const alerts = this.results.alerts;
let healthScore = 0;
let totalChecks = 0;
// Score each check
Object.values(checks).forEach(check => {
totalChecks++;
if (check && !check.error) {
if (typeof check === 'boolean' && check) {
healthScore++;
} else if (typeof check === 'object' && Object.keys(check).length > 0) {
healthScore++;
}
}
});
const healthPercent = totalChecks > 0 ? (healthScore / totalChecks) * 100 : 0;
// Determine status based on score and alerts
let status = 'HEALTHY';
if (healthPercent < 50) {
status = 'CRITICAL';
} else if (healthPercent < 80) {
status = 'DEGRADED';
} else if (alerts.some(alert => alert.severity === 'HIGH')) {
status = 'WARNING';
} else if (alerts.length > 0) {
status = 'CAUTION';
}
this.results.overall = status;
this.results.metrics.healthScore = Math.round(healthPercent);
this.results.metrics.totalAlerts = alerts.length;
this.results.metrics.highSeverityAlerts = alerts.filter(a => a.severity === 'HIGH').length;
return status;
}
// Generate recommendations
generateRecommendations() {
const recommendations = [];
// Check specific issues and provide recommendations
if (this.results.checks.cronjob && !this.results.checks.cronjob.configured) {
recommendations.push({
priority: 'HIGH',
action: 'Configure cron job',
command: 'echo "0 * * * * /opt/tcp-serial-relay/scripts/tcp-serial-relay-cron.sh" | sudo -u relay crontab -'
});
}
if (this.results.checks.filesystem && !this.results.checks.filesystem.configFile) {
recommendations.push({
priority: 'HIGH',
action: 'Create configuration file',
command: 'sudo cp /opt/tcp-serial-relay/src/config/default-config.js /etc/tcp-serial-relay/relay-config.json'
});
}
if (this.results.checks.serialPorts && this.results.checks.serialPorts.count === 0) {
recommendations.push({
priority: 'MEDIUM',
action: 'Check serial device connections',
command: 'lsusb && dmesg | grep tty'
});
}
if (this.results.checks.system && this.results.checks.system.disk &&
this.results.checks.system.disk.usagePercent > 80) {
recommendations.push({
priority: 'MEDIUM',
action: 'Clean up old log files',
command: 'sudo find /var/log/tcp-serial-relay -name "*.log*" -mtime +7 -delete'
});
}
this.results.recommendations = recommendations;
return recommendations;
}
// Run all health checks
async runAllChecks() {
this.log('INFO', '=== Starting Health Check ===');
const checkResults = {
filesystem: this.checkFileSystem(),
cronjob: this.checkCronJob(),
activity: this.checkRecentActivity(),
system: this.checkSystemResources(),
configuration: this.checkConfiguration(),
statusFiles: this.checkStatusFiles()
};
// Serial ports check is async
checkResults.serialPorts = await this.checkSerialPorts();
// Wait for all checks to complete
const allPassed = Object.values(checkResults).every(Boolean);
// Determine overall health
const overallStatus = this.determineOverallHealth();
// Generate recommendations
this.generateRecommendations();
this.log('INFO', '=== Health Check Complete ===');
this.log('INFO', `Overall Status: ${overallStatus}`);
this.log('INFO', `Health Score: ${this.results.metrics.healthScore}%`);
if (this.results.alerts.length > 0) {
this.log('WARN', `${this.results.alerts.length} alert(s) found`);
}
return this.results;
}
// Output results in different formats
outputResults(format = 'console') {
switch (format) {
case 'json':
console.log(JSON.stringify(this.results, null, 2));
break;
case 'summary':
this.outputSummary();
break;
case 'prometheus':
this.outputPrometheus();
break;
default:
this.outputConsole();
}
}
outputSummary() {
console.log('\n=== HEALTH CHECK SUMMARY ===');
console.log(`Status: ${this.results.overall}`);
console.log(`Health Score: ${this.results.metrics.healthScore}%`);
console.log(`Alerts: ${this.results.metrics.totalAlerts} (${this.results.metrics.highSeverityAlerts} high severity)`);
if (this.results.alerts.length > 0) {
console.log('\nALERTS:');
this.results.alerts.forEach(alert => {
const color = alert.severity === 'HIGH' ? COLORS.RED : COLORS.YELLOW;
console.log(`${color}[${alert.severity}] ${alert.message}${COLORS.RESET}`);
console.log(` ${alert.details}`);
});
}
if (this.results.recommendations && this.results.recommendations.length > 0) {
console.log('\nRECOMMENDATIONS:');
this.results.recommendations.forEach(rec => {
console.log(`[${rec.priority}] ${rec.action}`);
console.log(` Command: ${rec.command}`);
});
}
}
outputConsole() {
console.log('\n=== DETAILED HEALTH CHECK RESULTS ===');
console.log(JSON.stringify(this.results, null, 2));
}
outputPrometheus() {
console.log('# HELP tcp_serial_relay_health Health status of TCP-Serial Relay');
console.log('# TYPE tcp_serial_relay_health gauge');
console.log(`tcp_serial_relay_health{status="${this.results.overall}"} ${this.results.metrics.healthScore}`);
console.log('# HELP tcp_serial_relay_alerts_total Total number of alerts');
console.log('# TYPE tcp_serial_relay_alerts_total counter');
console.log(`tcp_serial_relay_alerts_total ${this.results.metrics.totalAlerts}`);
if (this.results.metrics.systemMetrics) {
const sys = this.results.metrics.systemMetrics;
console.log('# HELP tcp_serial_relay_memory_usage_percent Memory usage percentage');
console.log('# TYPE tcp_serial_relay_memory_usage_percent gauge');
console.log(`tcp_serial_relay_memory_usage_percent ${sys.memory.usagePercent}`);
console.log('# HELP tcp_serial_relay_disk_usage_percent Disk usage percentage');
console.log('# TYPE tcp_serial_relay_disk_usage_percent gauge');
console.log(`tcp_serial_relay_disk_usage_percent ${sys.disk.usagePercent}`);
}
}
}
// Main execution
async function main() {
const args = process.argv.slice(2);
const format = args.find(arg => ['json', 'summary', 'prometheus', 'console'].includes(arg)) || 'summary';
const verbose = args.includes('--verbose') || args.includes('-v');
if (verbose) {
console.log('TCP-Serial Relay Health Check');
console.log('=============================');
}
const checker = new HealthChecker();
try {
const results = await checker.runAllChecks();
if (verbose || format === 'console') {
checker.outputResults('console');
} else {
checker.outputResults(format);
}
// Exit with appropriate code
const exitCode = results.overall === 'CRITICAL' ? 2 :
(results.overall === 'DEGRADED' ? 1 : 0);
process.exit(exitCode);
} catch (error) {
console.error('Health check failed:', error.message);
process.exit(3);
}
}
// Help text
if (process.argv.includes('--help') || process.argv.includes('-h')) {
console.log(`
TCP-Serial Relay Health Check
Usage: node health-check.js [options] [format]
Formats:
console Detailed console output (default)
summary Brief summary with alerts and recommendations
json JSON output for programmatic use
prometheus Prometheus metrics format
Options:
--verbose, -v Verbose output
--help, -h Show this help
Exit codes:
0 Healthy
1 Degraded (warnings)
2 Critical (errors)
3 Health check failed
`);
process.exit(0);
}
// Run if called directly
if (require.main === module) {
main();
}
module.exports = { HealthChecker };