diff --git a/server/configApi.js b/server/configApi.js new file mode 100644 index 000000000..4854e541f --- /dev/null +++ b/server/configApi.js @@ -0,0 +1,299 @@ +const fs = require('fs').promises; +const path = require('path'); +const { loadConfiguration } = require('./configLoader'); +const { initializeApiClients } = require('./apiClients'); + +class ConfigApi { + constructor() { + this.envPath = path.join(__dirname, '../.env'); + } + + /** + * Get current configuration (without secrets) + */ + async getConfig() { + try { + const config = await this.readEnvFile(); + + return { + proxmox: config.PROXMOX_HOST ? { + host: config.PROXMOX_HOST, + port: config.PROXMOX_PORT || '8006', + tokenId: config.PROXMOX_TOKEN_ID, + // Don't send the secret + } : null, + pbs: config.PBS_HOST ? { + host: config.PBS_HOST, + port: config.PBS_PORT || '8007', + tokenId: config.PBS_TOKEN_ID, + // Don't send the secret + } : null + }; + } catch (error) { + console.error('Error reading configuration:', error); + return { proxmox: null, pbs: null }; + } + } + + /** + * Save configuration to .env file + */ + async saveConfig(config) { + try { + // Read existing .env file to preserve other settings + const existingConfig = await this.readEnvFile(); + + // Update with new values + if (config.proxmox) { + existingConfig.PROXMOX_HOST = config.proxmox.host; + existingConfig.PROXMOX_PORT = config.proxmox.port || '8006'; + existingConfig.PROXMOX_TOKEN_ID = config.proxmox.tokenId; + existingConfig.PROXMOX_TOKEN_SECRET = config.proxmox.tokenSecret; + } + + if (config.pbs) { + existingConfig.PBS_HOST = config.pbs.host; + existingConfig.PBS_PORT = config.pbs.port || '8007'; + existingConfig.PBS_TOKEN_ID = config.pbs.tokenId; + existingConfig.PBS_TOKEN_SECRET = config.pbs.tokenSecret; + } + + // Write back to .env file + await this.writeEnvFile(existingConfig); + + // Reload configuration in the application + await this.reloadConfiguration(); + + return { success: true }; + } catch (error) { + console.error('Error saving configuration:', error); + throw error; + } + } + + /** + * Test configuration by attempting to connect + */ + async testConfig(config) { + try { + // Create temporary endpoint configuration + const testEndpoints = [{ + id: 'test-primary', + name: 'Test Primary', + host: config.proxmox.host, + port: parseInt(config.proxmox.port) || 8006, + tokenId: config.proxmox.tokenId, + tokenSecret: config.proxmox.tokenSecret, + enabled: true + }]; + + const testPbsConfigs = config.pbs ? [{ + id: 'test-pbs', + name: 'Test PBS', + host: config.pbs.host, + port: parseInt(config.pbs.port) || 8007, + tokenId: config.pbs.tokenId, + tokenSecret: config.pbs.tokenSecret + }] : []; + + // Try to initialize API clients with test config + const { apiClients, pbsApiClients } = await initializeApiClients(testEndpoints, testPbsConfigs); + + // Try a simple API call to verify connection + const testClient = apiClients.get('test-primary'); + if (testClient) { + await testClient.client.get('/nodes'); + } + + return { success: true }; + } catch (error) { + console.error('Configuration test failed:', error); + return { + success: false, + error: error.message || 'Failed to connect to Proxmox server' + }; + } + } + + /** + * Read .env file and parse it + */ + async readEnvFile() { + try { + const content = await fs.readFile(this.envPath, 'utf8'); + const config = {}; + + content.split('\n').forEach(line => { + const trimmedLine = line.trim(); + if (trimmedLine && !trimmedLine.startsWith('#')) { + const [key, ...valueParts] = trimmedLine.split('='); + if (key) { + // Handle values that might contain = signs + let value = valueParts.join('=').trim(); + // Remove quotes if present + if ((value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + config[key.trim()] = value; + } + } + }); + + return config; + } catch (error) { + if (error.code === 'ENOENT') { + // .env file doesn't exist yet + return {}; + } + throw error; + } + } + + /** + * Write configuration back to .env file + */ + async writeEnvFile(config) { + const lines = []; + + // Add header + lines.push('# Pulse Configuration'); + lines.push('# Generated by Pulse Web Configuration'); + lines.push(''); + + // Group related settings + const groups = { + 'Proxmox VE Settings': ['PROXMOX_HOST', 'PROXMOX_PORT', 'PROXMOX_TOKEN_ID', 'PROXMOX_TOKEN_SECRET'], + 'Proxmox Backup Server Settings': ['PBS_HOST', 'PBS_PORT', 'PBS_TOKEN_ID', 'PBS_TOKEN_SECRET'], + 'Other Settings': [] // Will contain all other keys + }; + + // Find other keys not in predefined groups + Object.keys(config).forEach(key => { + let found = false; + Object.values(groups).forEach(groupKeys => { + if (groupKeys.includes(key)) found = true; + }); + if (!found && key !== '') { + groups['Other Settings'].push(key); + } + }); + + // Write each group + Object.entries(groups).forEach(([groupName, keys]) => { + if (keys.length > 0 && keys.some(key => config[key])) { + lines.push(`# ${groupName}`); + keys.forEach(key => { + if (config[key] !== undefined && config[key] !== '') { + const value = config[key]; + // Quote values that contain spaces or special characters + const needsQuotes = value.includes(' ') || value.includes('#') || value.includes('='); + lines.push(`${key}=${needsQuotes ? `"${value}"` : value}`); + } + }); + lines.push(''); + } + }); + + await fs.writeFile(this.envPath, lines.join('\n'), 'utf8'); + } + + /** + * Reload configuration without restarting the server + */ + async reloadConfiguration() { + try { + // Clear the require cache for dotenv + delete require.cache[require.resolve('dotenv')]; + + // Reload environment variables + require('dotenv').config(); + + // Reload configuration + const { endpoints, pbsConfigs, isConfigPlaceholder } = loadConfiguration(); + + // Get state manager instance + const stateManager = require('./state'); + + // Update configuration status + stateManager.setConfigPlaceholderStatus(isConfigPlaceholder); + stateManager.setEndpointConfigurations(endpoints, pbsConfigs); + + // Reinitialize API clients + const { apiClients, pbsApiClients } = await initializeApiClients(endpoints, pbsConfigs); + + // Update global references + if (global.pulseApiClients) { + global.pulseApiClients.apiClients = apiClients; + global.pulseApiClients.pbsApiClients = pbsApiClients; + } + + // Update global config placeholder status + if (global.pulseConfigStatus) { + global.pulseConfigStatus.isPlaceholder = isConfigPlaceholder; + } + + console.log('Configuration reloaded successfully'); + return true; + } catch (error) { + console.error('Error reloading configuration:', error); + throw error; + } + } + + /** + * Set up API routes + */ + setupRoutes(app) { + // Get current configuration + app.get('/api/config', async (req, res) => { + try { + const config = await this.getConfig(); + res.json(config); + } catch (error) { + res.status(500).json({ error: 'Failed to read configuration' }); + } + }); + + // Save configuration + app.post('/api/config', async (req, res) => { + try { + await this.saveConfig(req.body); + res.json({ success: true }); + } catch (error) { + res.status(500).json({ + success: false, + error: error.message || 'Failed to save configuration' + }); + } + }); + + // Test configuration + app.post('/api/config/test', async (req, res) => { + try { + const result = await this.testConfig(req.body); + res.json(result); + } catch (error) { + res.status(500).json({ + success: false, + error: error.message || 'Failed to test configuration' + }); + } + }); + + // Reload configuration + app.post('/api/config/reload', async (req, res) => { + try { + await this.reloadConfiguration(); + res.json({ success: true }); + } catch (error) { + res.status(500).json({ + success: false, + error: error.message || 'Failed to reload configuration' + }); + } + }); + } +} + +module.exports = ConfigApi; \ No newline at end of file diff --git a/server/index.js b/server/index.js index 6d4a2473f..b7d588465 100644 --- a/server/index.js +++ b/server/index.js @@ -35,6 +35,9 @@ try { // 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); @@ -66,6 +69,10 @@ 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 => { ... }); @@ -118,6 +125,12 @@ app.use(express.static(publicDir, { index: false })); // Route to serve the main HTML file for the root path app.get('/', (req, res) => { + // Check if configuration is placeholder or missing + if (configIsPlaceholder) { + // Redirect to setup page + return res.redirect('/setup.html'); + } + const indexPath = path.join(publicDir, 'index.html'); res.sendFile(indexPath, (err) => { if (err) { @@ -129,6 +142,9 @@ app.get('/', (req, res) => { }); // --- API Routes --- +// Set up configuration API routes +configApi.setupRoutes(app); + // Health check endpoint app.get('/healthz', (req, res) => { res.status(200).send('OK'); @@ -837,6 +853,13 @@ function gracefulShutdown(signal) { if (discoveryTimeoutId) clearTimeout(discoveryTimeoutId); if (metricTimeoutId) clearTimeout(metricTimeoutId); + // Clean up file watchers + if (envWatcher) { + envWatcher.close(); + envWatcher = null; + } + clearTimeout(reloadDebounceTimer); + // Close WebSocket connections if (io) { io.close(); @@ -881,6 +904,57 @@ function gracefulShutdown(signal) { process.on('SIGINT', () => gracefulShutdown('SIGINT')); process.on('SIGTERM', () => gracefulShutdown('SIGTERM')); +// --- Environment File Watcher --- +let envWatcher = null; +let reloadDebounceTimer = null; + +function setupEnvFileWatcher() { + const envPath = path.join(__dirname, '../.env'); + + // 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 () => { + console.log('.env file changed, reloading configuration...'); + + 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() { try { @@ -888,6 +962,10 @@ async function startServer() { const initializedClients = await initializeApiClients(endpoints, pbsConfigs); apiClients = initializedClients.apiClients; pbsApiClients = initializedClients.pbsApiClients; + + // Store globally for config reload + global.pulseApiClients = { apiClients, pbsApiClients }; + console.log("INFO: All API clients initialized."); } catch (initError) { console.error("FATAL: Failed to initialize API clients:", initError); @@ -905,6 +983,10 @@ async function startServer() { // 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 publicPath = path.join(__dirname, '../src/public'); diff --git a/src/public/index.html b/src/public/index.html index cb8601093..7571dd9d7 100644 --- a/src/public/index.html +++ b/src/public/index.html @@ -944,6 +944,7 @@ + diff --git a/src/public/js/main.js b/src/public/js/main.js index 457174877..3172119f9 100644 --- a/src/public/js/main.js +++ b/src/public/js/main.js @@ -65,6 +65,9 @@ document.addEventListener('DOMContentLoaded', function() { const state = PulseApp.state.getFullState(); PulseApp.alerts?.updateAlertsFromState?.(state); + // Check and show configuration banner if needed + PulseApp.ui.configBanner?.checkAndShowBanner(); + // Simple and direct scroll preservation - just focus on main table const mainTableContainer = document.querySelector('.table-container'); if (mainTableContainer) { diff --git a/src/public/js/socketHandler.js b/src/public/js/socketHandler.js index f483da2a1..070f7261b 100644 --- a/src/public/js/socketHandler.js +++ b/src/public/js/socketHandler.js @@ -34,6 +34,10 @@ PulseApp.socketHandler = (() => { // Development features socket.on('hotReload', handleHotReload); + + // Configuration reload events + socket.on('configurationReloaded', handleConfigurationReloaded); + socket.on('configurationError', handleConfigurationError); // Handle connection errors socket.on('connect_error', handleConnectError); @@ -123,6 +127,33 @@ PulseApp.socketHandler = (() => { window.location.reload(); } } + + function handleConfigurationReloaded(data) { + console.log('[Socket] Configuration reloaded:', data.message); + + // Show notification to user + if (PulseApp.alerts && PulseApp.alerts.showNotification) { + PulseApp.alerts.showNotification({ + message: 'Configuration has been updated and reloaded', + severity: 'info' + }); + } + + // Request fresh data with new configuration + socket.emit('requestData'); + } + + function handleConfigurationError(data) { + console.error('[Socket] Configuration reload error:', data.error); + + // Show error notification to user + if (PulseApp.alerts && PulseApp.alerts.showNotification) { + PulseApp.alerts.showNotification({ + message: 'Failed to reload configuration: ' + data.error, + severity: 'critical' + }); + } + } function handleConnectError(error) { console.error('[Socket] Connection error:', error); diff --git a/src/public/js/ui/config-banner.js b/src/public/js/ui/config-banner.js new file mode 100644 index 000000000..5b1c2d9e7 --- /dev/null +++ b/src/public/js/ui/config-banner.js @@ -0,0 +1,89 @@ +// Configuration banner component +PulseApp.ui = PulseApp.ui || {}; + +PulseApp.ui.configBanner = (() => { + let bannerElement = null; + let isShowing = false; + + function createBanner() { + const banner = document.createElement('div'); + banner.id = 'config-banner'; + banner.className = 'fixed top-0 left-0 right-0 bg-yellow-500 dark:bg-yellow-600 text-white px-4 py-3 z-50 shadow-lg transform -translate-y-full transition-transform duration-300 ease-in-out'; + + banner.innerHTML = ` +
+
+ + + +
+

Configuration Required

+

Pulse needs to be configured with your Proxmox credentials to start monitoring.

+
+
+
+ + Configure Now + + +
+
+ `; + + document.body.appendChild(banner); + return banner; + } + + function show() { + if (isShowing) return; + + if (!bannerElement) { + bannerElement = createBanner(); + } + + // Add space to the body to prevent content overlap + document.body.style.paddingTop = '80px'; + + // Trigger the slide-down animation + setTimeout(() => { + bannerElement.classList.remove('-translate-y-full'); + }, 100); + + isShowing = true; + } + + function hide() { + if (!isShowing || !bannerElement) return; + + // Slide up animation + bannerElement.classList.add('-translate-y-full'); + + // Remove padding after animation + setTimeout(() => { + document.body.style.paddingTop = ''; + isShowing = false; + }, 300); + } + + function checkAndShowBanner() { + const isConfigPlaceholder = PulseApp.state?.get('isConfigPlaceholder'); + + if (isConfigPlaceholder) { + show(); + } else { + hide(); + } + } + + return { + show, + hide, + checkAndShowBanner + }; +})(); \ No newline at end of file diff --git a/src/public/js/ui/empty-states.js b/src/public/js/ui/empty-states.js index 7b31ccd8a..c44615eb5 100644 --- a/src/public/js/ui/empty-states.js +++ b/src/public/js/ui/empty-states.js @@ -80,6 +80,19 @@ PulseApp.ui.emptyStates = (() => { text: 'Retry', onclick: 'location.reload()' }] + }, + 'config-required': { + icon: ` + + + + `, + title: 'Configuration Required', + message: 'Pulse needs to be configured with your Proxmox credentials before it can start monitoring.', + actions: [{ + text: 'Configure Now', + onclick: 'window.location.href="/setup.html"' + }] } }; diff --git a/src/public/setup.html b/src/public/setup.html new file mode 100644 index 000000000..7fa3683db --- /dev/null +++ b/src/public/setup.html @@ -0,0 +1,367 @@ + + + + + + Pulse - Configuration Setup + + + + + +
+
+ +
+
+ +

Pulse Setup

+
+

Configure your Proxmox connection to get started

+
+ + +
+ +
+

Primary Proxmox VE Server

+ +
+
+ + +

IP address or hostname of your Proxmox VE server

+
+ +
+ + +
+ +
+ + +

Format: username@realm!token-name

+
+ +
+ +
+ + +
+
+
+
+ + +
+

Proxmox Backup Server (Optional)

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + +
+
+
+
+ + + + + + + + +
+ + +
+
+ + +
+

+ Need help? Check out the + configuration guide +

+
+
+
+ + + + + \ No newline at end of file