Files
pulse/server/index.js
T
rcourtman a139e5eeb4 feat: implement centralized version calculation system
- Create versionUtils.js for unified version logic across components
- Update UpdateManager to use centralized getCurrentVersion()
- Update /api/version endpoint to use centralized logic
- Ensures consistent version calculation between update checks and version display
- Prevents version fragmentation between different system components

This resolves the issue where Settings modal showed inconsistent RC versions
by ensuring both the current version display and update checking use the
same dynamic git-based calculation.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-13 16:24:58 +01:00

1796 lines
67 KiB
JavaScript

// 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 axios = require('axios');
const axiosRetry = require('axios-retry').default; // Import axios-retry
// Hot reload dependencies (always try to load for development convenience)
let chokidar;
try {
chokidar = require('chokidar');
} catch (e) {
console.warn('chokidar is not installed. Hot reload requires chokidar: npm install 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();
// --- Data Fetching (Imported) ---
const { fetchDiscoveryData, fetchMetricsData } = require('./dataFetcher');
// --- END Data Fetching ---
// Server configuration
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
};
// Get alert data with safe serialization
let alertInfo;
try {
const activeAlerts = stateManager.alertManager.getActiveAlerts(filters);
const stats = stateManager.alertManager.getEnhancedAlertStats();
const rules = stateManager.alertManager.getRules();
alertInfo = {
active: activeAlerts,
stats: stats,
rules: rules
};
// Test serialization of each part to identify the issue
JSON.stringify(activeAlerts);
JSON.stringify(stats);
JSON.stringify(rules);
} catch (serializationError) {
console.error('[API] Serialization error in /api/alerts:', serializationError.message);
// Return empty data if serialization fails
alertInfo = {
active: [],
stats: {
active: 0,
acknowledged: 0,
last24Hours: 0,
lastHour: 0,
totalRules: 0,
suppressedRules: 0,
metrics: { totalFired: 0, totalResolved: 0, totalAcknowledged: 0, averageResolutionTime: 0, falsePositiveRate: 0 },
groups: []
},
rules: []
};
}
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" });
}
});
// 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,
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" });
}
});
// Test email configuration
app.post('/api/alerts/test-email', async (req, res) => {
try {
console.log('[Test Email] Sending test email...');
// Get email configuration from process.env (loaded by configLoader)
const config = {
ALERT_TO_EMAIL: process.env.ALERT_TO_EMAIL,
ALERT_FROM_EMAIL: process.env.ALERT_FROM_EMAIL,
SMTP_HOST: process.env.SMTP_HOST,
SMTP_PORT: process.env.SMTP_PORT,
SMTP_USER: process.env.SMTP_USER,
SMTP_SECURE: process.env.SMTP_SECURE
};
console.log('[Test Email] Config loaded, ALERT_TO_EMAIL:', config.ALERT_TO_EMAIL);
if (!config.ALERT_TO_EMAIL) {
return res.status(400).json({ success: false, error: 'No recipient email address configured' });
}
if (!config.SMTP_HOST) {
return res.status(400).json({ success: false, error: 'SMTP server not configured' });
}
// Use the alert manager to send a test email with the config
const testResult = await stateManager.alertManager.sendTestEmailWithConfig(config);
if (testResult.success) {
console.log('[Test Email] Test email sent successfully');
res.json({ success: true, message: 'Test email sent successfully' });
} else {
console.error('[Test Email] Failed to send test email:', testResult.error);
res.status(400).json({ success: false, error: testResult.error || 'Failed to send test email' });
}
} catch (error) {
console.error('[Test Email] Error sending test email:', error);
res.status(500).json({ success: false, error: 'Internal server error while sending test email' });
}
});
// 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 });
}
});
// Enhanced alerts/rules endpoints to handle compound threshold rules
app.get('/api/alerts/compound-rules', (req, res) => {
try {
const allRules = stateManager.alertManager.getRules();
const compoundRules = allRules.filter(rule => rule.type === 'compound_threshold');
res.json({ success: true, rules: compoundRules });
} catch (error) {
console.error("Error fetching compound threshold rules:", error);
res.status(500).json({ error: "Failed to fetch compound threshold rules" });
}
});
// Debug endpoint to manually reload alert rules
app.post('/api/alerts/rules/reload', async (req, res) => {
try {
await stateManager.alertManager.loadAlertRules();
const allRules = stateManager.alertManager.getRules();
res.json({ success: true, message: "Alert rules reloaded", rulesCount: allRules.length });
} catch (error) {
console.error("Error reloading alert rules:", error);
res.status(500).json({ error: "Failed to reload alert rules" });
}
});
// Endpoint to trigger immediate alert evaluation
app.post('/api/alerts/evaluate', async (req, res) => {
try {
stateManager.alertManager.evaluateCurrentState();
res.json({ success: true, message: "Alert evaluation triggered" });
} catch (error) {
console.error("Error triggering alert evaluation:", error);
res.status(500).json({ error: "Failed to trigger alert evaluation" });
}
});
// Debug endpoint for alert threshold evaluation (without rule ID)
app.get('/api/alerts/debug', (req, res) => {
try {
const ruleId = req.query.ruleId;
const currentState = stateManager.getState();
const allGuests = [...(currentState.vms || []), ...(currentState.containers || [])];
const metrics = currentState.metrics || [];
const debugInfo = {
timestamp: new Date().toISOString(),
totalGuests: allGuests.length,
totalMetrics: metrics.length,
debugMode: process.env.ALERT_DEBUG === 'true',
guests: []
};
// If specific rule requested, filter to that rule
let rulesToCheck = stateManager.alertManager.getRules();
if (ruleId) {
rulesToCheck = rulesToCheck.filter(r => r.id === ruleId);
if (rulesToCheck.length === 0) {
return res.status(404).json({ error: `Rule '${ruleId}' not found` });
}
}
// Evaluate each guest against each rule
allGuests.forEach(guest => {
const guestMetrics = metrics.find(m =>
m.endpointId === guest.endpointId &&
m.node === guest.node &&
m.id === guest.vmid
);
const guestDebug = {
name: guest.name,
vmid: guest.vmid,
node: guest.node,
type: guest.type,
hasMetrics: !!guestMetrics,
rules: []
};
if (guestMetrics && guestMetrics.current) {
// Calculate disk percentage if available
if (guest.maxdisk && guestMetrics.current.disk) {
const diskPercentage = (guestMetrics.current.disk / guest.maxdisk) * 100;
guestDebug.diskUsage = {
raw: guestMetrics.current.disk,
max: guest.maxdisk,
percentage: Math.round(diskPercentage * 100) / 100
};
}
guestDebug.currentMetrics = {
cpu: guestMetrics.current.cpu,
memory: guestMetrics.current.memory,
disk: guestMetrics.current.disk
};
}
// Check each rule
rulesToCheck.forEach(rule => {
if (rule.type === 'compound_threshold' && rule.thresholds) {
const ruleDebug = {
ruleId: rule.id,
ruleName: rule.name,
thresholds: [],
allThresholdsMet: true
};
rule.thresholds.forEach(threshold => {
let metricValue = null;
let thresholdMet = false;
if (guestMetrics && guestMetrics.current) {
metricValue = stateManager.alertManager.evaluateThresholdCondition(threshold, guestMetrics.current, guest) ?
stateManager.alertManager.getThresholdCurrentValue(threshold, guestMetrics.current, guest) : null;
thresholdMet = stateManager.alertManager.evaluateThresholdCondition(threshold, guestMetrics.current, guest);
}
ruleDebug.thresholds.push({
metric: threshold.metric,
condition: threshold.condition,
threshold: threshold.threshold,
currentValue: metricValue,
met: thresholdMet
});
if (!thresholdMet) {
ruleDebug.allThresholdsMet = false;
}
});
guestDebug.rules.push(ruleDebug);
}
});
debugInfo.guests.push(guestDebug);
});
res.json(debugInfo);
} catch (error) {
console.error("Error in alert debug endpoint:", error);
res.status(500).json({ error: "Failed to generate debug information" });
}
});
// Simple endpoint to get just the alert enabled/disabled status
app.get('/api/alerts/status', (req, res) => {
try {
const { loadConfiguration } = require('./configLoader');
const { endpoints, pbsConfigs, isConfigPlaceholder } = loadConfiguration();
// Read the environment variables directly
const alertStatus = {
cpu: process.env.ALERT_CPU_ENABLED !== 'false',
memory: process.env.ALERT_MEMORY_ENABLED !== 'false',
disk: process.env.ALERT_DISK_ENABLED !== 'false',
down: process.env.ALERT_DOWN_ENABLED === 'true'
};
res.json({ success: true, alerts: alertStatus });
} catch (error) {
console.error("Error getting alert status:", error);
res.status(500).json({ error: "Failed to get alert status" });
}
});
// Version API endpoint
app.get('/api/version', async (req, res) => {
try {
const { getCurrentVersionInfo } = require('./versionUtils');
// Get version info using centralized logic
const versionInfo = getCurrentVersionInfo();
const currentVersion = versionInfo.version;
const gitBranch = versionInfo.gitBranch;
const isDevelopment = versionInfo.isDevelopment;
let latestVersion = currentVersion;
let updateAvailable = false;
try {
// Try to check for updates, but don't fail if it doesn't work
const updateInfo = await updateManager.checkForUpdates();
latestVersion = updateInfo.latestVersion || currentVersion;
updateAvailable = updateInfo.hasUpdate || false;
} catch (updateError) {
// Log the error but continue with current version info
console.error("[Version API] Error checking for updates:", updateError.message);
}
res.json({
version: currentVersion,
latestVersion: latestVersion,
updateAvailable: updateAvailable,
gitBranch: gitBranch,
isDevelopment: isDevelopment
});
} catch (error) {
console.error("[Version API] Error in version endpoint:", error);
// Still try to return current version if possible
try {
const packageJson = require('../package.json');
res.json({
version: packageJson.version || 'N/A',
latestVersion: packageJson.version || 'N/A',
updateAvailable: false
});
} catch (fallbackError) {
res.status(500).json({ error: "Could not retrieve version" });
}
}
});
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, // true for 465, false for other ports
requireTLS: true, // Force TLS encryption
auth: {
user: user,
pass: pass
},
tls: {
// Do not fail on invalid certs for testing
rejectUnauthorized: false
}
});
// 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: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<div style="background: #059669; color: white; padding: 20px; border-radius: 8px 8px 0 0;">
<h1 style="margin: 0; font-size: 24px;">🧪 Pulse Email Test</h1>
<p style="margin: 5px 0 0 0; opacity: 0.9;">Configuration Successful!</p>
</div>
<div style="background: #f0fdf4; padding: 20px; border-left: 4px solid #059669;">
<p style="margin: 0 0 15px 0; color: #065f46;">
<strong>Congratulations!</strong> If you received this email, your SMTP configuration is working correctly.
</p>
<h3 style="color: #065f46; margin: 15px 0 10px 0;">Configuration Details:</h3>
<ul style="color: #047857; margin: 0; padding-left: 20px;">
<li><strong>SMTP Host:</strong> ${host}</li>
<li><strong>SMTP Port:</strong> ${port}</li>
<li><strong>Secure:</strong> ${secure ? 'Yes (SSL/TLS)' : 'No (STARTTLS)'}</li>
<li><strong>From Address:</strong> ${from}</li>
<li><strong>To Address:</strong> ${to}</li>
</ul>
<p style="margin: 15px 0 0 0; color: #065f46;">
You will now receive alert notifications when VMs/LXCs exceed their configured thresholds.
</p>
</div>
<div style="background: white; padding: 20px; border-radius: 0 0 8px 8px; border-top: 1px solid #d1fae5;">
<p style="margin: 0; color: #6b7280; font-size: 12px; text-align: center;">
This test email was sent by your Pulse monitoring system.
</p>
</div>
</div>
`
};
await testTransporter.sendMail(testMailOptions);
testTransporter.close();
console.log(`[EMAIL TEST] Test email sent successfully to: ${to}`);
res.json({
success: true,
message: 'Test email sent successfully!'
});
} catch (error) {
console.error('[EMAIL TEST] Failed to send test email:', error);
res.status(400).json({
success: false,
error: error.message || 'Failed to send test email'
});
}
});
// Test webhook endpoint
app.post('/api/test-webhook', async (req, res) => {
try {
const { url, enabled } = req.body;
if (!url) {
return res.status(400).json({
success: false,
error: 'Webhook URL is required for testing'
});
}
// Create test webhook payload
const axios = require('axios');
// Detect webhook type based on URL
const isDiscord = url.includes('discord.com/api/webhooks') || url.includes('discordapp.com/api/webhooks');
const isSlack = url.includes('slack.com/') || url.includes('hooks.slack.com');
let testPayload;
if (isDiscord) {
// Discord-specific format
testPayload = {
embeds: [{
title: '🧪 Webhook Test Alert',
description: 'This is a test alert to verify webhook configuration',
color: 3447003, // Blue
fields: [
{
name: 'VM/LXC',
value: 'Test-VM (qemu 999)',
inline: true
},
{
name: 'Node',
value: 'test-node',
inline: true
},
{
name: 'Status',
value: 'running',
inline: true
},
{
name: 'Metric',
value: 'TEST',
inline: true
},
{
name: 'Current Value',
value: '75%',
inline: true
},
{
name: 'Threshold',
value: '80%',
inline: true
}
],
footer: {
text: 'Pulse Monitoring System - Test Message'
},
timestamp: new Date().toISOString()
}]
};
} else if (isSlack) {
// Slack-specific format
testPayload = {
text: '🧪 *Webhook Test Alert*',
attachments: [{
color: 'good',
fields: [
{
title: 'VM/LXC',
value: 'Test-VM (qemu 999)',
short: true
},
{
title: 'Node',
value: 'test-node',
short: true
},
{
title: 'Status',
value: 'Webhook configuration test successful!',
short: false
}
],
footer: 'Pulse Monitoring - Test',
ts: Math.floor(Date.now() / 1000)
}]
};
} else {
// Generic webhook format with all fields (backward compatibility)
testPayload = {
timestamp: new Date().toISOString(),
alert: {
id: 'test-alert-' + Date.now(),
rule: {
name: 'Webhook Test Alert',
description: 'This is a test alert to verify webhook configuration',
severity: 'info',
metric: 'test'
},
guest: {
name: 'Test-VM',
id: '999',
type: 'qemu',
node: 'test-node',
status: 'running'
},
value: 75,
threshold: 80,
emoji: '🧪'
},
// Include both formats for generic webhooks
embeds: [{
title: '🧪 Webhook Test Alert',
description: 'This is a test alert to verify webhook configuration',
color: 3447003,
fields: [
{
name: 'VM/LXC',
value: 'Test-VM (qemu 999)',
inline: true
},
{
name: 'Node',
value: 'test-node',
inline: true
},
{
name: 'Status',
value: 'running',
inline: true
}
],
footer: {
text: 'Pulse Monitoring System - Test Message'
},
timestamp: new Date().toISOString()
}],
text: '🧪 *Webhook Test Alert*',
attachments: [{
color: 'good',
fields: [
{
title: 'VM/LXC',
value: 'Test-VM (qemu 999)',
short: true
},
{
title: 'Status',
value: 'Webhook configuration test successful!',
short: false
}
],
footer: 'Pulse Monitoring - Test',
ts: Math.floor(Date.now() / 1000)
}]
};
}
// Send test webhook
const response = await axios.post(url, testPayload, {
headers: {
'Content-Type': 'application/json',
'User-Agent': 'Pulse-Monitoring/1.0'
},
timeout: 10000, // 10 second timeout
maxRedirects: 3
});
console.log(`[WEBHOOK TEST] Test webhook sent successfully to: ${url} (${response.status})`);
res.json({
success: true,
message: 'Test webhook sent successfully!',
status: response.status
});
} catch (error) {
console.error('[WEBHOOK TEST] Failed to send test webhook:', error);
let errorMessage = 'Failed to send test webhook';
if (error.response) {
errorMessage = `Webhook failed: ${error.response.status} ${error.response.statusText}`;
} else if (error.request) {
errorMessage = `Webhook failed: No response from ${url}`;
} else {
errorMessage = `Webhook failed: ${error.message}`;
}
res.status(400).json({
success: false,
error: errorMessage
});
}
});
// Global error handler for unhandled API errors
app.use((err, req, res, next) => {
console.error('Unhandled API error:', err);
// Ensure we always return JSON for API routes
if (req.url.startsWith('/api/')) {
return res.status(500).json({
success: false,
error: 'Internal server error',
message: err.message
});
}
// For non-API routes, return HTML error
res.status(500).send('Internal Server Error');
});
// 404 handler for API routes
app.use('/api/*splat', (req, res) => {
res.status(404).json({
success: false,
error: 'API endpoint not found'
});
});
// --- WebSocket Setup ---
const io = new Server(server, {
// Optional: Configure CORS for Socket.IO if needed, separate from Express CORS
cors: {
origin: "*", // Allow all origins for Socket.IO, adjust as needed for security
methods: ["GET", "POST"]
}
});
function sendCurrentStateToSocket(socket) {
try {
const fullCurrentState = stateManager.getState(); // This includes isConfigPlaceholder and alerts
const currentPlaceholderStatus = fullCurrentState.isConfigPlaceholder; // Extract for clarity if needed
if (stateManager.hasData()) {
// Test serialization first to catch circular reference errors
JSON.stringify(fullCurrentState);
socket.emit('rawData', fullCurrentState);
} else {
console.log('No data available yet, sending initial/loading state.');
socket.emit('initialState', { loading: true, isConfigPlaceholder: currentPlaceholderStatus });
}
} catch (error) {
console.error('[WebSocket] Error serializing state data:', error.message);
// Send minimal safe state if serialization fails
socket.emit('initialState', {
loading: false,
error: 'State serialization error',
isConfigPlaceholder: stateManager.getState().isConfigPlaceholder || false
});
}
}
io.on('connection', (socket) => {
console.log('Client connected');
sendCurrentStateToSocket(socket);
socket.on('requestData', async () => {
console.log('Client requested data');
try {
sendCurrentStateToSocket(socket);
// Optionally trigger an immediate discovery cycle?
// runDiscoveryCycle(); // Be careful with triggering cycles on demand
} catch (error) {
console.error('Error processing requestData event:', error);
// Notify client of error? Consider emitting an error event to the specific socket
// socket.emit('requestError', { message: 'Failed to process your request.' });
}
});
socket.on('disconnect', () => {
console.log('Client disconnected');
});
});
// Set up alert event forwarding to connected clients
stateManager.alertManager.on('alert', (alert) => {
if (io.engine.clientsCount > 0) {
try {
// Emit the actual alert data using safe serialization
const safeAlert = stateManager.alertManager.createSafeAlertForEmit(alert);
console.log(`[Socket] Emitting new alert: ${safeAlert.id}`);
io.emit('alert', safeAlert);
} catch (error) {
console.error('[Socket] Failed to emit alert:', error);
}
}
});
stateManager.alertManager.on('alertResolved', (alert) => {
if (io.engine.clientsCount > 0) {
try {
const safeAlert = stateManager.alertManager.createSafeAlertForEmit(alert);
console.log(`[Socket] Emitting alert resolved: ${safeAlert.id}`);
io.emit('alertResolved', safeAlert);
} catch (error) {
console.error('[Socket] Failed to emit alert resolved:', error);
}
}
});
stateManager.alertManager.on('alertAcknowledged', (alert) => {
if (io.engine.clientsCount > 0) {
try {
const safeAlert = stateManager.alertManager.createSafeAlertForEmit(alert);
console.log(`[Socket] Emitting alert acknowledged: ${safeAlert.id}`);
io.emit('alertAcknowledged', safeAlert);
} catch (error) {
console.error('[Socket] Failed to emit alert acknowledged:', error);
}
}
});
// --- Global State Variables ---
// These will hold the latest fetched data
// let currentNodes = [];
// let currentVms = [];
// let currentContainers = [];
// let currentMetrics = [];
// let pbsDataArray = []; // Array to hold data for each PBS instance
let isDiscoveryRunning = false; // Prevent concurrent discovery runs
let isMetricsRunning = false; // Prevent concurrent metric runs
let discoveryTimeoutId = null;
let metricTimeoutId = null;
// --- End Global State ---
// --- Update Cycle Logic ---
// Uses imported fetch functions and updates global state
async function runDiscoveryCycle() {
if (isDiscoveryRunning) return;
isDiscoveryRunning = true;
const startTime = Date.now();
let errors = [];
try {
// Use global API clients if local ones aren't set
const currentApiClients = global.pulseApiClients ? global.pulseApiClients.apiClients : apiClients;
const currentPbsApiClients = global.pulseApiClients ? global.pulseApiClients.pbsApiClients : pbsApiClients;
if (Object.keys(currentApiClients).length === 0 && Object.keys(currentPbsApiClients).length === 0) {
console.warn("[Discovery Cycle] API clients not initialized yet, skipping run.");
return;
}
// Use imported fetchDiscoveryData
const discoveryData = await fetchDiscoveryData(currentApiClients, currentPbsApiClients);
const duration = Date.now() - startTime;
// Update state using the enhanced state manager
stateManager.updateDiscoveryData(discoveryData, duration, errors);
// No need to store in global vars anymore
// ... (logging summary) ...
const updatedState = stateManager.getState(); // Get the fully updated state
console.log(`[Discovery Cycle] Updated state. Nodes: ${updatedState.nodes.length}, VMs: ${updatedState.vms.length}, CTs: ${updatedState.containers.length}, PBS: ${updatedState.pbs.length}`);
// Emit combined data using updated state manager state (which includes the flag)
if (io.engine.clientsCount > 0) {
try {
const pveBackups = updatedState.pveBackups || {};
console.log(`[Discovery Broadcast] Broadcasting state with PVE backups: ${(pveBackups.backupTasks || []).length} tasks, ${(pveBackups.storageBackups || []).length} storage, ${(pveBackups.guestSnapshots || []).length} snapshots`);
// Test serialization first to catch circular reference errors
JSON.stringify(updatedState);
io.emit('rawData', updatedState);
} catch (serializationError) {
console.error('[Discovery Broadcast] Error serializing state data:', serializationError.message);
// Don't emit anything if serialization fails
}
}
} catch (error) {
console.error(`[Discovery Cycle] Error during execution: ${error.message}`, error.stack);
errors.push({ type: 'discovery', message: error.message, endpointId: 'general' });
const duration = Date.now() - startTime;
stateManager.updateDiscoveryData({ nodes: [], vms: [], containers: [], pbs: [] }, duration, errors);
} finally {
isDiscoveryRunning = false;
scheduleNextDiscovery();
}
}
async function runMetricCycle() {
if (isMetricsRunning) return;
if (io.engine.clientsCount === 0) {
scheduleNextMetric();
return;
}
isMetricsRunning = true;
const startTime = Date.now();
let errors = [];
try {
// Use global API clients if local ones aren't set
const currentApiClients = global.pulseApiClients ? global.pulseApiClients.apiClients : apiClients;
if (Object.keys(currentApiClients).length === 0) {
console.warn("[Metrics Cycle] PVE API clients not initialized yet, skipping run.");
return;
}
// Use global state for running guests
const { vms: currentVms, containers: currentContainers } = stateManager.getState();
const runningVms = currentVms.filter(vm => vm.status === 'running');
const runningContainers = currentContainers.filter(ct => ct.status === 'running');
if (runningVms.length > 0 || runningContainers.length > 0) {
// Use imported fetchMetricsData
const fetchedMetrics = await fetchMetricsData(runningVms, runningContainers, currentApiClients);
const duration = Date.now() - startTime;
// Update metrics state with enhanced error tracking
if (fetchedMetrics && fetchedMetrics.length >= 0) { // Allow empty array to clear metrics
stateManager.updateMetricsData(fetchedMetrics, duration, errors);
// Add metrics to history for charts
fetchedMetrics.forEach(metricData => {
if (metricData && metricData.current) {
const guestId = `${metricData.endpointId}-${metricData.node}-${metricData.id}`;
metricsHistory.addMetricData(guestId, metricData.current);
}
});
// Emit only metrics updates if needed, or rely on full rawData updates?
// Consider emitting a smaller 'metricsUpdate' event if performance is key
// io.emit('metricsUpdate', stateManager.getState().metrics);
}
// Emit rawData with updated global state (which includes metrics, alerts, and placeholder flag)
try {
const currentState = stateManager.getState();
// Test serialization first to catch circular reference errors
JSON.stringify(currentState);
io.emit('rawData', currentState);
} catch (serializationError) {
console.error('[Metrics Broadcast] Error serializing state data:', serializationError.message);
// Don't emit anything if serialization fails
}
} else {
const currentMetrics = stateManager.getState().metrics;
if (currentMetrics.length > 0) {
console.log('[Metrics Cycle] No running guests found, clearing metrics.');
stateManager.clearMetricsData(); // Clear metrics
// Emit state update with cleared metrics only if clients are connected
// (Avoid unnecessary emits if no one is listening and nothing changed except clearing metrics)
if (io.engine.clientsCount > 0) {
try {
const currentState = stateManager.getState();
// Test serialization first to catch circular reference errors
JSON.stringify(currentState);
io.emit('rawData', currentState);
} catch (serializationError) {
console.error('[Metrics Clear Broadcast] Error serializing state data:', serializationError.message);
// Don't emit anything if serialization fails
}
}
}
}
} catch (error) {
console.error(`[Metrics Cycle] Error during execution: ${error.message}`, error.stack);
errors.push({ type: 'metrics', message: error.message, endpointId: 'general' });
const duration = Date.now() - startTime;
stateManager.updateMetricsData([], duration, errors);
} finally {
isMetricsRunning = false;
scheduleNextMetric();
}
}
// --- Schedulers ---
function scheduleNextDiscovery() {
if (discoveryTimeoutId) clearTimeout(discoveryTimeoutId);
// Use the constant defined earlier
discoveryTimeoutId = setTimeout(runDiscoveryCycle, DISCOVERY_UPDATE_INTERVAL);
}
function scheduleNextMetric() {
if (metricTimeoutId) clearTimeout(metricTimeoutId);
// Use the constant defined earlier
metricTimeoutId = setTimeout(runMetricCycle, METRIC_UPDATE_INTERVAL);
}
// --- End Schedulers ---
// Graceful shutdown handling
let shutdownInProgress = false;
function gracefulShutdown(signal) {
if (shutdownInProgress) {
console.log(`\nReceived ${signal} again, force exiting...`);
process.exit(1);
}
shutdownInProgress = true;
console.log(`\n${signal} signal received: closing HTTP server and cleaning up...`);
// Force exit after 5 seconds if graceful shutdown takes too long
const forceExitTimer = setTimeout(() => {
console.log('Force exiting after 5 seconds...');
process.exit(1);
}, 5000);
// Clear timers
if (discoveryTimeoutId) clearTimeout(discoveryTimeoutId);
if (metricTimeoutId) clearTimeout(metricTimeoutId);
// Clean up file watchers
if (envWatcher) {
envWatcher.close();
envWatcher = null;
}
if (devWatcher) {
devWatcher.close();
devWatcher = null;
}
clearTimeout(reloadDebounceTimer);
// Close WebSocket connections
if (io) {
io.close();
}
// Close server
server.close((err) => {
if (err) {
console.error('Error closing server:', err);
} else {
console.log('HTTP server closed.');
}
// Cleanup state manager
try {
stateManager.destroy();
} catch (cleanupError) {
console.error('Error during state manager cleanup:', cleanupError);
}
clearTimeout(forceExitTimer);
console.log('Cleanup completed. Exiting...');
process.exit(0);
});
// If server.close doesn't call the callback (no active connections),
// still proceed with cleanup after a short delay
setTimeout(() => {
if (shutdownInProgress) {
try {
stateManager.destroy();
} catch (cleanupError) {
console.error('Error during fallback state manager cleanup:', cleanupError);
}
clearTimeout(forceExitTimer);
console.log('Fallback cleanup completed. Exiting...');
process.exit(0);
}
}, 1000);
}
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
// --- Environment File Watcher ---
let envWatcher = null;
let devWatcher = null;
let reloadDebounceTimer = null;
let lastReloadTime = 0;
global.lastReloadTime = 0; // Make it globally accessible
function setupEnvFileWatcher() {
// Use same logic as ConfigApi to find .env file
const configDir = path.join(__dirname, '../config');
const configEnvPath = path.join(configDir, '.env');
const projectEnvPath = path.join(__dirname, '../.env');
const envPath = fs.existsSync(configEnvPath) ? configEnvPath : projectEnvPath;
// Check if the file exists
if (!fs.existsSync(envPath)) {
console.log('No .env file found, skipping file watcher setup');
return;
}
console.log('Setting up .env file watcher for automatic configuration reload');
envWatcher = fs.watch(envPath, (eventType, filename) => {
if (eventType === 'change') {
// Debounce the reload to avoid multiple reloads for rapid changes
clearTimeout(reloadDebounceTimer);
reloadDebounceTimer = setTimeout(async () => {
// Prevent reload if we just reloaded within the last 2 seconds (from API save)
const now = Date.now();
if (now - global.lastReloadTime < 2000) {
console.log('.env file changed but skipping reload (too recent)');
return;
}
console.log('.env file changed, reloading configuration...');
global.lastReloadTime = now;
try {
await configApi.reloadConfiguration();
// Notify connected clients about configuration change
io.emit('configurationReloaded', {
message: 'Configuration has been updated',
timestamp: Date.now()
});
console.log('Configuration reloaded successfully');
} catch (error) {
console.error('Failed to reload configuration:', error);
// Notify clients about the error
io.emit('configurationError', {
message: 'Failed to reload configuration',
error: error.message,
timestamp: Date.now()
});
}
}, 1000); // Wait 1 second after last change before reloading
}
});
envWatcher.on('error', (error) => {
console.error('Error watching .env file:', error);
});
}
// --- Start the server ---
async function startServer() {
// Only initialize API clients if we have endpoints configured
if (endpoints.length > 0 || pbsConfigs.length > 0) {
try {
// Use the correct initializer function name
const initializedClients = await initializeApiClients(endpoints, pbsConfigs);
apiClients = initializedClients.apiClients;
pbsApiClients = initializedClients.pbsApiClients;
// Store globally for config reload
global.pulseApiClients = { apiClients, pbsApiClients };
global.runDiscoveryCycle = runDiscoveryCycle;
console.log("INFO: All API clients initialized.");
} catch (initError) {
console.error("FATAL: Failed to initialize API clients:", initError);
process.exit(1); // Exit if clients can't be initialized
}
// Run initial discovery cycle in background after server starts
setImmediate(() => {
runDiscoveryCycle().catch(error => {
console.error('Error in initial discovery cycle:', error);
});
});
} else {
console.log("INFO: No endpoints configured. Starting in setup mode.");
// Initialize empty clients for consistency
apiClients = {};
pbsApiClients = {};
global.pulseApiClients = { apiClients, pbsApiClients };
global.runDiscoveryCycle = runDiscoveryCycle;
}
server.listen(PORT, '0.0.0.0', () => {
console.log(`Server listening on port ${PORT}`);
console.log(`Enhanced monitoring with alerts enabled`);
console.log(`Health endpoint: http://localhost:${PORT}/api/health`);
console.log(`Performance metrics: http://localhost:${PORT}/api/performance`);
console.log(`Alerts API: http://localhost:${PORT}/api/alerts`);
// Schedule the first metric run *after* the initial discovery completes and server is listening
scheduleNextMetric();
// Watch .env file for changes
setupEnvFileWatcher();
// Setup hot reload in development mode
if (process.env.NODE_ENV === 'development' && chokidar) {
const watchPaths = [
path.join(__dirname, '../src/public'), // Frontend files
path.join(__dirname, './'), // Server files
path.join(__dirname, '../data'), // Config files
path.join(__dirname, '../package.json'), // Package.json for auto-restart on version updates
];
devWatcher = chokidar.watch(watchPaths, {
ignored: [
/(^|[\\\/])\../, // ignore dotfiles
/node_modules/, // ignore node_modules
/\.log$/, // ignore log files
/\.tmp$/, // ignore temp files
/\.test\.js$/, // ignore test files
/coverage/, // ignore coverage directory
/temp/, // ignore temp directory
/alert-rules\.json$/, // ignore alert rules runtime data
/custom-thresholds\.json$/, // ignore custom thresholds runtime data
/active-alerts\.json$/, // ignore active alerts runtime data
/notification-history\.json$/ // ignore notification history runtime data
],
persistent: true,
ignoreInitial: true // Don't trigger on initial scan
});
devWatcher.on('change', () => {
io.emit('hotReload'); // Notify clients to reload
});
devWatcher.on('add', () => {
io.emit('hotReload'); // Notify clients to reload
});
devWatcher.on('unlink', () => {
io.emit('hotReload'); // Notify clients to reload
});
devWatcher.on('error', error => console.error(`[Hot Reload] Watcher error: ${error}`));
}
});
}
startServer();