tcp-serial-relay
Version:
Modular TCP to Serial relay service with comprehensive logging, monitoring and web dashboard
585 lines (520 loc) • 17 kB
JavaScript
// src/services/dashboard-server.js
const http = require('http');
const fs = require('fs');
const path = require('path');
const EventEmitter = require('events');
const { logger } = require('../utils/logger');
const { getStatus, statusManager } = require('../utils/status-manager');
const { loadConfig, updateConfig } = require('../config');
/**
* Dashboard Server for TCP-Serial Relay monitoring and control
*/
class DashboardServer extends EventEmitter {
constructor(options = {}) {
super();
this.port = options.port || 3000;
this.host = options.host || '0.0.0.0';
this.server = null;
this.wsClients = new Set();
this.logBuffer = [];
this.maxLogEntries = 1000;
// Dashboard static files
this.staticDir = path.join(__dirname, '../../dashboard');
this.ensureStaticDir();
}
/**
* Ensure dashboard static directory exists and create index.html
*/
ensureStaticDir() {
if (!fs.existsSync(this.staticDir)) {
fs.mkdirSync(this.staticDir, { recursive: true });
}
// Copy the dashboard HTML to static directory
const dashboardHtml = this.getDashboardHtml();
const indexPath = path.join(this.staticDir, 'index.html');
fs.writeFileSync(indexPath, dashboardHtml, 'utf8');
}
/**
* Get the dashboard HTML content
*/
getDashboardHtml() {
// This would contain the full HTML from the artifact
// For brevity, returning a reference - in real implementation,
// you'd include the full HTML content here
return `<!DOCTYPE html>
<html>
<head>
<title>TCP-Serial Relay Dashboard</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- Include the full dashboard HTML content here -->
</head>
<body>
<div id="app">Loading dashboard...</div>
<script>
// Dashboard JavaScript will be injected here
// Including WebSocket connection for real-time updates
const ws = new WebSocket('ws://localhost:3000/ws');
ws.onmessage = function(event) {
const data = JSON.parse(event.data);
updateDashboard(data);
};
function updateDashboard(data) {
// Update dashboard with real-time data
if (data.type === 'status') {
currentStatus = data.payload;
updateStatus();
} else if (data.type === 'log') {
addLog(data.payload.level, data.payload.message);
} else if (data.type === 'config') {
config = data.payload;
loadConfig();
}
}
// API functions for dashboard
async function apiCall(endpoint, method = 'GET', data = null) {
const options = {
method,
headers: { 'Content-Type': 'application/json' }
};
if (data) {
options.body = JSON.stringify(data);
}
const response = await fetch('/api' + endpoint + ', options);
return response.json();
}
// Override the mock functions with real API calls
async function loadConfig() {
try {
const response = await apiCall('/config');
config = response.config;
// Update form fields
document.getElementById('tcp-ip').value = config.tcpIp;
document.getElementById('tcp-port').value = config.tcpPort;
document.getElementById('serial-path').value = config.serialPath;
document.getElementById('serial-baud').value = config.serialBaud;
document.getElementById('serial-parity').value = config.serialParity;
document.getElementById('log-level').value = config.logLevel;
} catch (error) {
console.error('Failed to load config:', error);
}
}
async function saveConfig() {
try {
const newConfig = {
tcpIp: document.getElementById('tcp-ip').value,
tcpPort: parseInt(document.getElementById('tcp-port').value),
serialPath: document.getElementById('serial-path').value,
serialBaud: parseInt(document.getElementById('serial-baud').value),
serialParity: document.getElementById('serial-parity').value,
logLevel: document.getElementById('log-level').value
};
await apiCall('/config', 'POST', newConfig);
addLog('info', 'Configuration saved successfully');
} catch (error) {
addLog('error', 'Failed to save configuration');
}
}
async function startService() {
try {
await apiCall('/control/start', 'POST');
addLog('info', 'Start command sent');
} catch (error) {
addLog('error', 'Failed to start service');
}
}
async function stopService() {
try {
await apiCall('/control/stop', 'POST');
addLog('info', 'Stop command sent');
} catch (error) {
addLog('error', 'Failed to stop service');
}
}
async function restartService() {
try {
await apiCall('/control/restart', 'POST');
addLog('info', 'Restart command sent');
} catch (error) {
addLog('error', 'Failed to restart service');
}
}
async function checkHealth() {
try {
const health = await apiCall('/health');
const controlOutput = document.getElementById('control-output');
controlOutput.innerHTML = \`
<div class="log-entry"><span class="log-message">Health Check Results:</span></div>
<div class="log-entry"><span class="log-level-info">[INFO]</span> <span class="log-message">Overall Status: \${health.status}</span></div>
<div class="log-entry"><span class="log-level-info">[INFO]</span> <span class="log-message">Uptime: \${health.uptime}ms</span></div>
<div class="log-entry"><span class="log-level-info">[INFO]</span> <span class="log-message">Health Score: \${health.healthScore || 'N/A'}</span></div>
\`;
} catch (error) {
addLog('error', 'Health check failed');
}
}
</script>
</body>
</html>`;
}
/**
* Start the dashboard server
*/
async start() {
return new Promise((resolve, reject) => {
this.server = http.createServer((req, res) => {
this.handleRequest(req, res);
});
// Add WebSocket support for real-time updates
this.setupWebSocket();
this.server.listen(this.port, this.host, (error) => {
if (error) {
logger.error('Failed to start dashboard server', { error: error.message });
reject(error);
} else {
logger.info('Dashboard server started', {
host: this.host,
port: this.port,
url: `http://${this.host === '0.0.0.0' ? 'localhost' : this.host}:${this.port}`
});
resolve();
}
});
// Start broadcasting status updates
this.startStatusBroadcast();
});
}
/**
* Setup WebSocket for real-time communication
*/
setupWebSocket() {
this.server.on('upgrade', (request, socket, head) => {
if (request.url === '/ws') {
this.handleWebSocketConnection(socket, head);
}
});
}
/**
* Handle WebSocket connections
*/
handleWebSocketConnection(socket, head) {
const ws = this.createWebSocketConnection(socket, head);
this.wsClients.add(ws);
ws.on('close', () => {
this.wsClients.delete(ws);
});
// Send initial data
this.sendToClient(ws, 'status', this.getCurrentStatus());
this.sendToClient(ws, 'config', { config: this.getCurrentConfig() });
}
/**
* Create WebSocket connection (simplified implementation)
*/
createWebSocketConnection(socket, head) {
// In a real implementation, you'd use a proper WebSocket library like 'ws'
// This is a simplified version for demonstration
const ws = {
send: (data) => {
try {
socket.write(`data: ${data}\n\n`);
} catch (error) {
// Connection closed
}
},
close: () => socket.end(),
on: (event, callback) => {
if (event === 'close') {
socket.on('close', callback);
}
}
};
return ws;
}
/**
* Handle HTTP requests
*/
async handleRequest(req, res) {
const url = new URL(req.url, `http://${req.headers.host}`);
const pathname = url.pathname;
try {
// Enable CORS
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') {
res.writeHead(200);
res.end();
return;
}
// API routes
if (pathname.startsWith('/api/')) {
await this.handleApiRequest(req, res, pathname.substring(4));
return;
}
// Static file serving
if (pathname === '/' || pathname === '/index.html') {
this.serveFile(res, path.join(this.staticDir, 'index.html'), 'text/html');
} else {
this.serveFile(res, path.join(this.staticDir, pathname), this.getContentType(pathname));
}
} catch (error) {
logger.error('Request handling error', { error: error.message, url: req.url });
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Internal server error' }));
}
}
/**
* Handle API requests
*/
async handleApiRequest(req, res, path) {
const method = req.method;
const body = await this.getRequestBody(req);
logger.debug('API request', { method, path, body });
switch (path) {
case 'status':
if (method === 'GET') {
this.sendJson(res, { status: this.getCurrentStatus() });
}
break;
case 'config':
if (method === 'GET') {
this.sendJson(res, { config: this.getCurrentConfig() });
} else if (method === 'POST') {
await this.updateConfiguration(body);
this.sendJson(res, { success: true, message: 'Configuration updated' });
}
break;
case 'logs':
if (method === 'GET') {
this.sendJson(res, { logs: this.getRecentLogs() });
}
break;
case 'health':
if (method === 'GET') {
this.sendJson(res, this.getHealthStatus());
}
break;
case 'control/start':
if (method === 'POST') {
this.emit('start-service');
this.sendJson(res, { success: true, message: 'Start command issued' });
}
break;
case 'control/stop':
if (method === 'POST') {
this.emit('stop-service');
this.sendJson(res, { success: true, message: 'Stop command issued' });
}
break;
case 'control/restart':
if (method === 'POST') {
this.emit('restart-service');
this.sendJson(res, { success: true, message: 'Restart command issued' });
}
break;
case 'ports':
if (method === 'GET') {
const SerialClient = require('./serial-client');
const ports = await SerialClient.listPorts();
this.sendJson(res, { ports });
}
break;
default:
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'API endpoint not found' }));
}
}
/**
* Get request body
*/
async getRequestBody(req) {
return new Promise((resolve) => {
let body = '';
req.on('data', chunk => {
body += chunk.toString();
});
req.on('end', () => {
try {
resolve(body ? JSON.parse(body) : {});
} catch (error) {
resolve({});
}
});
});
}
/**
* Send JSON response
*/
sendJson(res, data) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(data));
}
/**
* Serve static files
*/
serveFile(res, filePath, contentType) {
if (fs.existsSync(filePath)) {
const content = fs.readFileSync(filePath);
res.writeHead(200, { 'Content-Type': contentType });
res.end(content);
} else {
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('File not found');
}
}
/**
* Get content type for file extension
*/
getContentType(pathname) {
const ext = path.extname(pathname).toLowerCase();
const types = {
'.html': 'text/html',
'.js': 'application/javascript',
'.css': 'text/css',
'.json': 'application/json',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.gif': 'image/gif',
'.ico': 'image/x-icon'
};
return types[ext] || 'application/octet-stream';
}
/**
* Get current status from status manager
*/
getCurrentStatus() {
return getStatus();
}
/**
* Get current configuration
*/
getCurrentConfig() {
try {
const configPath = path.join(process.cwd(), 'config', 'relay-config.json');
if (fs.existsSync(configPath)) {
return JSON.parse(fs.readFileSync(configPath, 'utf8'));
}
} catch (error) {
logger.warn('Failed to read current config', { error: error.message });
}
return {
tcpIp: '192.168.1.90',
tcpPort: 10002,
serialPath: '/dev/ttyUSB0',
serialBaud: 9600,
serialParity: 'odd',
logLevel: 'info'
};
}
/**
* Update configuration
*/
async updateConfiguration(newConfig) {
try {
await updateConfig(newConfig);
this.broadcastToClients('config', { config: newConfig });
logger.info('Configuration updated via dashboard', newConfig);
} catch (error) {
logger.error('Failed to update configuration', { error: error.message });
throw error;
}
}
/**
* Get health status
*/
getHealthStatus() {
const status = this.getCurrentStatus();
return {
status: status.success ? 'healthy' : 'error',
uptime: status.duration,
connections: status.connections,
metrics: status.metrics
};
}
/**
* Get recent logs
*/
getRecentLogs() {
return this.logBuffer.slice(-100); // Return last 100 log entries
}
/**
* Add log entry to buffer
*/
addLogEntry(level, message, metadata = {}) {
const logEntry = {
timestamp: new Date().toISOString(),
level,
message,
metadata
};
this.logBuffer.push(logEntry);
// Keep buffer size manageable
if (this.logBuffer.length > this.maxLogEntries) {
this.logBuffer = this.logBuffer.slice(-this.maxLogEntries + 100);
}
// Broadcast to connected clients
this.broadcastToClients('log', logEntry);
}
/**
* Start broadcasting status updates
*/
startStatusBroadcast() {
setInterval(() => {
if (this.wsClients.size > 0) {
const status = this.getCurrentStatus();
this.broadcastToClients('status', status);
}
}, 2000); // Update every 2 seconds
}
/**
* Broadcast data to all connected WebSocket clients
*/
broadcastToClients(type, payload) {
const message = JSON.stringify({ type, payload, timestamp: Date.now() });
for (const client of this.wsClients) {
try {
client.send(message);
} catch (error) {
// Remove failed client
this.wsClients.delete(client);
}
}
}
/**
* Send data to specific client
*/
sendToClient(client, type, payload) {
const message = JSON.stringify({ type, payload, timestamp: Date.now() });
try {
client.send(message);
} catch (error) {
// Client connection failed
this.wsClients.delete(client);
}
}
/**
* Stop the dashboard server
*/
async stop() {
return new Promise((resolve) => {
if (this.server) {
// Close all WebSocket connections
for (const client of this.wsClients) {
try {
client.close();
} catch (error) {
// Ignore errors when closing
}
}
this.wsClients.clear();
this.server.close(() => {
logger.info('Dashboard server stopped');
resolve();
});
} else {
resolve();
}
});
}
}
module.exports = DashboardServer;