// Load environment variables from .env file // Check for persistent config directory (Docker) or use project root const fs = require('fs'); const path = require('path'); const configDir = path.join(__dirname, '../config'); const configEnvPath = path.join(configDir, '.env'); const projectEnvPath = path.join(__dirname, '../.env'); if (fs.existsSync(configEnvPath)) { require('dotenv').config({ path: configEnvPath }); } else { require('dotenv').config({ path: projectEnvPath }); } // Import the state manager FIRST const stateManager = require('./state'); // Import metrics history system const metricsHistory = require('./metricsHistory'); // Import diagnostic tool const DiagnosticTool = require('./diagnostics'); // --- BEGIN Configuration Loading using configLoader --- const { loadConfiguration, ConfigurationError } = require('./configLoader'); let endpoints; let pbsConfigs; let configIsPlaceholder = false; // Define placeholder flag variable here try { const { endpoints: loadedEndpoints, pbsConfigs: loadedPbsConfigs, isConfigPlaceholder: loadedPlaceholderFlag } = loadConfiguration(); endpoints = loadedEndpoints; pbsConfigs = loadedPbsConfigs; configIsPlaceholder = loadedPlaceholderFlag; // Store flag temporarily } catch (error) { if (error instanceof ConfigurationError) { console.error(error.message); process.exit(1); // Exit if configuration loading failed } else { console.error('An unexpected error occurred during configuration loading:', error); process.exit(1); // Exit on other unexpected errors during load } } // --- END Configuration Loading --- // Set the placeholder status in stateManager *after* config loading is complete stateManager.setConfigPlaceholderStatus(configIsPlaceholder); // Store globally for config reload global.pulseConfigStatus = { isPlaceholder: configIsPlaceholder }; // Set endpoint configurations for client use stateManager.setEndpointConfigurations(endpoints, pbsConfigs); const express = require('express'); const http = require('http'); const cors = require('cors'); const compression = require('compression'); const { Server } = require('socket.io'); const { URL } = require('url'); // <--- ADD: Import URL constructor const axios = require('axios'); const axiosRetry = require('axios-retry').default; // Import axios-retry // Development specific dependencies let chokidar; if (process.env.NODE_ENV === 'development') { try { chokidar = require('chokidar'); } catch (e) { console.warn('chokidar is not installed. Hot reload requires chokidar: npm install --save-dev chokidar'); } } // --- API Client Initialization --- const { initializeApiClients } = require('./apiClients'); let apiClients = {}; // Initialize as empty objects let pbsApiClients = {}; // Note: Client initialization is now async and happens in startServer() // --- END API Client Initialization --- // Configuration API const ConfigApi = require('./configApi'); const configApi = new ConfigApi(); // --- REMOVED OLD CLIENT INIT LOGIC --- // The following blocks were moved to apiClients.js // endpoints.forEach(endpoint => { ... }); // async function initializeAllPbsClients() { ... } // --- END REMOVED OLD CLIENT INIT LOGIC --- // --- Data Fetching (Imported) --- const { fetchDiscoveryData, fetchMetricsData } = require('./dataFetcher'); // --- END Data Fetching --- // Server configuration const DEBUG_METRICS = false; // Set to true to show detailed metrics logs const PORT = 7655; // Using a different port from the main server // --- Define Update Intervals (Configurable via Env Vars) --- const METRIC_UPDATE_INTERVAL = parseInt(process.env.PULSE_METRIC_INTERVAL_MS, 10) || 2000; // Default: 2 seconds const DISCOVERY_UPDATE_INTERVAL = parseInt(process.env.PULSE_DISCOVERY_INTERVAL_MS, 10) || 30000; // Default: 30 seconds console.log(`INFO: Using Metric Update Interval: ${METRIC_UPDATE_INTERVAL}ms`); console.log(`INFO: Using Discovery Update Interval: ${DISCOVERY_UPDATE_INTERVAL}ms`); // Initialize enhanced state management stateManager.init(); // Create Express app const app = express(); const server = http.createServer(app); // Create HTTP server instance // Middleware app.use(compression({ filter: (req, res) => { // Don't compress responses with this request header if (req.headers['x-no-compression']) { return false; } // Fallback to standard filter function return compression.filter(req, res); }, threshold: 1024, // Only compress if response is over 1KB level: 6 // Compression level (1-9, 6 is good balance of speed vs compression) })); app.use(cors()); app.use(express.json()); // Define the public directory path const publicDir = path.join(__dirname, '../src/public'); // Serve static files (CSS, JS, images) from the public directory app.use(express.static(publicDir, { index: false })); // Route to serve the main HTML file for the root path app.get('/', (req, res) => { // Always serve the main application with settings modal for initial configuration const indexPath = path.join(publicDir, 'index.html'); res.sendFile(indexPath, (err) => { if (err) { console.error(`Error sending index.html: ${err.message}`); // Avoid sending error details to the client for security res.status(err.status || 500).send('Internal Server Error loading page.'); } }); }); // Route to explicitly handle setup page app.get('/setup.html', (req, res) => { const setupPath = path.join(publicDir, 'setup.html'); res.sendFile(setupPath, (err) => { if (err) { console.error(`Error sending setup.html: ${err.message}`); res.status(err.status || 500).send('Internal Server Error loading setup page.'); } }); }); // --- API Routes --- // Set up configuration API routes configApi.setupRoutes(app); // Set up threshold API routes const { setupThresholdRoutes } = require('./thresholdRoutes'); setupThresholdRoutes(app); // Set up update API routes const UpdateManager = require('./updateManager'); const updateManager = new UpdateManager(); // Check for updates endpoint app.get('/api/updates/check', async (req, res) => { try { // Check if we're in test mode if (process.env.UPDATE_TEST_MODE === 'true') { const testVersion = process.env.UPDATE_TEST_VERSION || '99.99.99'; const currentVersion = updateManager.currentVersion; // Create mock update data const mockUpdateInfo = { currentVersion: currentVersion, latestVersion: testVersion, updateAvailable: true, isDocker: updateManager.isDockerEnvironment(), releaseNotes: 'Test release for update mechanism testing\n\n- Testing download functionality\n- Testing backup process\n- Testing installation process', releaseUrl: 'https://github.com/rcourtman/Pulse/releases/test', publishedAt: new Date().toISOString(), assets: [{ name: 'pulse-v' + testVersion + '.tar.gz', size: 1024000, downloadUrl: 'http://localhost:3000/api/test/mock-update.tar.gz' }] }; console.log('[UpdateManager] Test mode enabled, returning mock update info'); return res.json(mockUpdateInfo); } // Allow override of update channel via query parameter for preview const channelOverride = req.query.channel; const updateInfo = await updateManager.checkForUpdates(channelOverride); res.json(updateInfo); } catch (error) { console.error('Error checking for updates:', error); res.status(500).json({ error: error.message }); } }); // Download and apply update endpoint app.post('/api/updates/apply', async (req, res) => { try { const { downloadUrl } = req.body; if (!downloadUrl) { return res.status(400).json({ error: 'Download URL is required' }); } // Send immediate response res.json({ message: 'Update started. The application will restart automatically when complete.', status: 'in_progress' }); // Apply update in background setTimeout(async () => { try { // Download update const updateFile = await updateManager.downloadUpdate(downloadUrl, (progress) => { io.emit('updateProgress', progress); }); // Apply update (pass download URL for version extraction) await updateManager.applyUpdate(updateFile, (progress) => { io.emit('updateProgress', progress); }, downloadUrl); io.emit('updateComplete', { success: true }); } catch (error) { console.error('Error applying update:', error); io.emit('updateError', { error: error.message }); } }, 100); } catch (error) { console.error('Error initiating update:', error); res.status(500).json({ error: error.message }); } }); // Update status endpoint app.get('/api/updates/status', (req, res) => { try { const status = updateManager.getUpdateStatus(); res.json(status); } catch (error) { console.error('Error getting update status:', error); res.status(500).json({ error: error.message }); } }); // Mock update tarball endpoint for testing (not actually used in test mode) app.get('/api/test/mock-update.tar.gz', (req, res) => { if (process.env.UPDATE_TEST_MODE !== 'true') { return res.status(404).json({ error: 'Test mode not enabled' }); } // This endpoint exists just to make the URL valid // The actual download is handled differently in test mode res.status(200).json({ message: 'Test mode active - download handled internally', testMode: true }); }); // Health check endpoint app.get('/healthz', (req, res) => { res.status(200).send('OK'); }); // Enhanced health endpoint with detailed monitoring info app.get('/api/health', (req, res) => { try { const healthSummary = stateManager.getHealthSummary(); // Add system info including placeholder status const state = stateManager.getState(); healthSummary.system = { configPlaceholder: state.isConfigPlaceholder || false, hasData: stateManager.hasData(), clientsInitialized: Object.keys(global.pulseApiClients?.apiClients || {}).length > 0 }; res.json(healthSummary); } catch (error) { console.error("Error in /api/health:", error); res.status(500).json({ error: "Failed to fetch health information" }); } }); // Performance metrics endpoint app.get('/api/performance', (req, res) => { try { const limit = parseInt(req.query.limit) || 50; const performanceHistory = stateManager.getPerformanceHistory(limit); const connectionHealth = stateManager.getConnectionHealth(); res.json({ history: performanceHistory, connections: connectionHealth, timestamp: Date.now() }); } catch (error) { console.error("Error in /api/performance:", error); res.status(500).json({ error: "Failed to fetch performance data" }); } }); // Enhanced alerts endpoint with filtering app.get('/api/alerts', (req, res) => { try { const filters = { severity: req.query.severity, group: req.query.group, node: req.query.node, acknowledged: req.query.acknowledged === 'true' ? true : req.query.acknowledged === 'false' ? false : undefined }; const alertInfo = { active: stateManager.alertManager.getActiveAlerts(filters), stats: stateManager.alertManager.getEnhancedAlertStats(), rules: stateManager.alertManager.getRules() }; res.json(alertInfo); } catch (error) { console.error("Error in /api/alerts:", error); res.status(500).json({ error: "Failed to fetch alert information" }); } }); // Alert history endpoint with pagination and filtering app.get('/api/alerts/history', (req, res) => { try { const limit = parseInt(req.query.limit) || 100; const filters = { severity: req.query.severity, group: req.query.group, node: req.query.node }; const history = stateManager.alertManager.getAlertHistory(limit, filters); res.json({ history, timestamp: Date.now() }); } catch (error) { console.error("Error in /api/alerts/history:", error); res.status(500).json({ error: "Failed to fetch alert history" }); } }); // Alert acknowledgment endpoint app.post('/api/alerts/:alertId/acknowledge', (req, res) => { try { const alertId = req.params.alertId; const { userId = 'api-user', note = '' } = req.body; const success = stateManager.alertManager.acknowledgeAlert(alertId, userId, note); if (success) { res.json({ success: true, message: "Alert acknowledged successfully" }); } else { res.status(404).json({ error: "Alert not found" }); } } catch (error) { console.error("Error acknowledging alert:", error); res.status(400).json({ error: error.message }); } }); // Alert suppression endpoint app.post('/api/alerts/suppress', (req, res) => { try { const { ruleId, guestFilter = {}, duration = 3600000, reason = '' } = req.body; if (!ruleId) { return res.status(400).json({ error: "ruleId is required" }); } const success = stateManager.alertManager.suppressAlert(ruleId, guestFilter, duration, reason); if (success) { res.json({ success: true, message: "Alert rule suppressed successfully" }); } else { res.status(400).json({ error: "Failed to suppress alert rule" }); } } catch (error) { console.error("Error suppressing alert:", error); res.status(400).json({ error: error.message }); } }); // Alert groups endpoint app.get('/api/alerts/groups', (req, res) => { try { const stats = stateManager.alertManager.getEnhancedAlertStats(); res.json({ groups: stats.groups }); } catch (error) { console.error("Error in /api/alerts/groups:", error); res.status(500).json({ error: "Failed to fetch alert groups" }); } }); // Notification channels endpoint app.get('/api/alerts/channels', (req, res) => { try { const stats = stateManager.alertManager.getEnhancedAlertStats(); res.json({ channels: stats.channels }); } catch (error) { console.error("Error in /api/alerts/channels:", error); res.status(500).json({ error: "Failed to fetch notification channels" }); } }); // Enhanced alert metrics endpoint app.get('/api/alerts/metrics', (req, res) => { try { const stats = stateManager.alertManager.getEnhancedAlertStats(); res.json({ metrics: stats.metrics, summary: { active: stats.active, acknowledged: stats.acknowledged, escalated: stats.escalated, suppressed: stats.suppressedRules }, trends: { last24Hours: stats.last24Hours, lastHour: stats.lastHour }, timestamp: Date.now() }); } catch (error) { console.error("Error in /api/alerts/metrics:", error); res.status(500).json({ error: "Failed to fetch alert metrics" }); } }); // Alert rules management with filtering app.get('/api/alerts/rules', (req, res) => { try { const filters = { group: req.query.group, severity: req.query.severity }; const rules = stateManager.alertManager.getRules(filters); res.json({ rules }); } catch (error) { console.error("Error in /api/alerts/rules:", error); res.status(500).json({ error: "Failed to fetch alert rules" }); } }); // Create new alert rule app.post('/api/alerts/rules', (req, res) => { try { const rule = req.body; const newRule = stateManager.alertManager.addRule(rule); res.json({ success: true, message: "Rule added successfully", rule: newRule }); } catch (error) { console.error("Error adding alert rule:", error); res.status(400).json({ error: error.message }); } }); // Update alert rule app.put('/api/alerts/rules/:id', (req, res) => { try { const ruleId = req.params.id; const updates = req.body; const success = stateManager.alertManager.updateRule(ruleId, updates); if (success) { res.json({ success: true, message: "Rule updated successfully" }); } else { res.status(404).json({ error: "Rule not found" }); } } catch (error) { console.error("Error updating alert rule:", error); res.status(400).json({ error: error.message }); } }); // Delete alert rule app.delete('/api/alerts/rules/:id', (req, res) => { try { const ruleId = req.params.id; const success = stateManager.alertManager.removeRule(ruleId); if (success) { res.json({ success: true, message: "Rule removed successfully" }); } else { res.status(404).json({ error: "Rule not found" }); } } catch (error) { console.error("Error removing alert rule:", error); res.status(400).json({ error: error.message }); } }); // Version check functionality let latestVersionCache = null; let lastVersionCheck = 0; const VERSION_CHECK_INTERVAL = 6 * 60 * 60 * 1000; // 6 hours async function checkLatestVersion() { const now = Date.now(); // Return cached version if still fresh if (latestVersionCache && (now - lastVersionCheck) < VERSION_CHECK_INTERVAL) { return latestVersionCache; } try { const response = await axios.get('https://api.github.com/repos/rcourtman/Pulse/releases/latest', { timeout: 5000, headers: { 'Accept': 'application/vnd.github.v3+json' } }); if (response.data && response.data.tag_name) { // Remove 'v' prefix if present const version = response.data.tag_name.replace(/^v/, ''); latestVersionCache = version; lastVersionCheck = now; return version; } } catch (error) { console.error('Error checking latest version:', error.message); } return null; } // Version API endpoint app.get('/api/version', async (req, res) => { try { const packageJson = require('../package.json'); const currentVersion = packageJson.version || 'N/A'; // Check for latest version const latestVersion = await checkLatestVersion(); res.json({ version: currentVersion, latestVersion: latestVersion, updateAvailable: latestVersion && latestVersion !== currentVersion && compareVersions(latestVersion, currentVersion) > 0 }); } catch (error) { console.error("Error in version endpoint:", error); res.status(500).json({ error: "Could not retrieve version" }); } }); // Simple version comparison function function compareVersions(v1, v2) { const parts1 = v1.split('.').map(Number); const parts2 = v2.split('.').map(Number); for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) { const part1 = parts1[i] || 0; const part2 = parts2[i] || 0; if (part1 > part2) return 1; if (part1 < part2) return -1; } return 0; } app.get('/api/storage', async (req, res) => { try { // Get current nodes from state manager const { nodes: currentNodes } = stateManager.getState(); const storageInfoByNode = {}; (currentNodes || []).forEach(node => { storageInfoByNode[node.node] = node.storage || []; }); res.json(storageInfoByNode); } catch (error) { console.error("Error in /api/storage:", error); res.status(500).json({ globalError: error.message || "Failed to fetch storage details." }); } }); // Chart data API endpoint app.get('/api/charts', async (req, res) => { try { // Get current guest info for context const currentState = stateManager.getState(); const guestInfoMap = {}; // Build guest info map [...(currentState.vms || []), ...(currentState.containers || [])].forEach(guest => { const guestId = `${guest.endpointId}-${guest.node}-${guest.vmid}`; guestInfoMap[guestId] = { maxmem: guest.maxmem, maxdisk: guest.maxdisk, type: guest.type }; }); const chartData = metricsHistory.getAllGuestChartData(guestInfoMap); const stats = metricsHistory.getStats(); res.json({ data: chartData, stats: stats, timestamp: Date.now() }); } catch (error) { console.error("Error in /api/charts:", error); res.status(500).json({ error: error.message || "Failed to fetch chart data." }); } }); // Direct state inspection endpoint app.get('/api/diagnostics-state', (req, res) => { try { const state = stateManager.getState(); const summary = { timestamp: new Date().toISOString(), last_update: state.lastUpdate, update_age_seconds: state.lastUpdate ? Math.floor((Date.now() - new Date(state.lastUpdate).getTime()) / 1000) : null, guests_count: state.guests?.length || 0, nodes_count: state.nodes?.length || 0, pbs_count: state.pbs?.length || 0, sample_guests: state.guests?.slice(0, 5).map(g => ({ vmid: g.vmid, name: g.name, type: g.type, status: g.status })) || [], sample_backups: [], errors: state.errors || [] }; // Get sample backups if (state.pbs && Array.isArray(state.pbs)) { state.pbs.forEach(pbsInstance => { if (pbsInstance.datastores) { pbsInstance.datastores.forEach(ds => { if (ds.snapshots && ds.snapshots.length > 0) { ds.snapshots.slice(0, 5).forEach(snap => { summary.sample_backups.push({ store: ds.store, backup_id: snap['backup-id'], backup_type: snap['backup-type'], backup_time: new Date(snap['backup-time'] * 1000).toISOString() }); }); } }); } }); } res.json(summary); } catch (error) { console.error("State inspection error:", error); res.status(500).json({ error: error.message }); } }); // Quick diagnostic check endpoint app.get('/api/diagnostics/check', async (req, res) => { try { // Use cached result if available and recent const cacheKey = 'diagnosticCheck'; const cached = global.diagnosticCache?.[cacheKey]; if (cached && (Date.now() - cached.timestamp) < 60000) { // Cache for 1 minute return res.json(cached.result); } // Run a quick check delete require.cache[require.resolve('./diagnostics')]; const DiagnosticTool = require('./diagnostics'); const diagnosticTool = new DiagnosticTool(stateManager, metricsHistory, apiClients, pbsApiClients); const report = await diagnosticTool.runDiagnostics(); const hasIssues = report.recommendations && report.recommendations.some(r => r.severity === 'critical' || r.severity === 'warning'); const result = { hasIssues, criticalCount: report.recommendations?.filter(r => r.severity === 'critical').length || 0, warningCount: report.recommendations?.filter(r => r.severity === 'warning').length || 0 }; // Cache the result if (!global.diagnosticCache) global.diagnosticCache = {}; global.diagnosticCache[cacheKey] = { timestamp: Date.now(), result }; res.json(result); } catch (error) { console.error("Error in diagnostic check:", error); res.json({ hasIssues: false }); // Don't show icon on error } }); // Raw state endpoint - shows everything app.get('/api/raw-state', (req, res) => { const state = stateManager.getState(); const rawState = stateManager.state || {}; res.json({ lastUpdate: state.lastUpdate, statsLastUpdated: state.stats?.lastUpdated, rawStateLastUpdated: rawState.stats?.lastUpdated, guestsLength: state.guests?.length, rawGuestsLength: rawState.guests?.length, guestsType: Array.isArray(state.guests) ? 'array' : typeof state.guests, allKeys: Object.keys(state), rawKeys: Object.keys(rawState), serverUptime: process.uptime(), // Sample guest to see structure firstGuest: state.guests?.[0], rawFirstGuest: rawState.guests?.[0] }); }); // --- Diagnostic Endpoint --- app.get('/api/diagnostics', async (req, res) => { try { console.log('Running diagnostics...'); // Force reload the diagnostic module to get latest changes delete require.cache[require.resolve('./diagnostics')]; const DiagnosticTool = require('./diagnostics'); const diagnosticTool = new DiagnosticTool(stateManager, metricsHistory, apiClients, pbsApiClients); const report = await diagnosticTool.runDiagnostics(); // Format the report for easy reading const formattedReport = { ...report, summary: { hasIssues: report.recommendations && report.recommendations.some(r => r.severity === 'critical'), criticalIssues: report.recommendations ? report.recommendations.filter(r => r.severity === 'critical').length : 0, warnings: report.recommendations ? report.recommendations.filter(r => r.severity === 'warning').length : 0, info: report.recommendations ? report.recommendations.filter(r => r.severity === 'info').length : 0, isTimingIssue: report.state && report.state.dataAge === null && report.state.serverUptime < 90 } }; res.json(formattedReport); } catch (error) { console.error("Error running diagnostics:", error); console.error("Stack trace:", error.stack); res.status(500).json({ error: "Failed to run diagnostics", details: error.message, stack: error.stack }); } }); // Test email endpoint app.post('/api/test-email', async (req, res) => { try { const { host, port, user, pass, from, to, secure } = req.body; if (!host || !port || !user || !pass || !from || !to) { return res.status(400).json({ success: false, error: 'All email fields are required for testing' }); } // Create a temporary transporter for testing const nodemailer = require('nodemailer'); const testTransporter = nodemailer.createTransport({ host: host, port: parseInt(port), secure: secure === true, auth: { user: user, pass: pass } }); // Send test email const testMailOptions = { from: from, to: to, subject: '🧪 Pulse Email Test - Configuration Successful', text: ` This is a test email from your Pulse monitoring system. If you received this email, your SMTP configuration is working correctly! Configuration used: - SMTP Host: ${host} - SMTP Port: ${port} - Secure: ${secure ? 'Yes' : 'No'} - From: ${from} - To: ${to} You will now receive alert notifications when VMs/LXCs exceed their configured thresholds. Best regards, Pulse Monitoring System `, html: `
Configuration Successful!
Congratulations! If you received this email, your SMTP configuration is working correctly.
You will now receive alert notifications when VMs/LXCs exceed their configured thresholds.
This test email was sent by your Pulse monitoring system.