diff --git a/src/public/js/main.js b/src/public/js/main.js index 75ddc794d..b5da24ed2 100644 --- a/src/public/js/main.js +++ b/src/public/js/main.js @@ -16,9 +16,16 @@ document.addEventListener('DOMContentLoaded', function() { PulseApp.ui.pbs?.updatePbsInfo(pbsDataArray); PulseApp.ui.backups?.updateBackupsTab(); + // Update tab availability based on PBS data + PulseApp.ui.tabs?.updateTabAvailability(); + updateLoadingOverlayVisibility(); // Call the helper function PulseApp.thresholds?.logging?.checkThresholdViolations(); + + // Update alerts when state changes + const state = PulseApp.state.getFullState(); + PulseApp.alerts?.updateAlertsFromState?.(state); } function updateLoadingOverlayVisibility() { @@ -52,6 +59,7 @@ document.addEventListener('DOMContentLoaded', function() { // If socketHandler.init only expects one, this might need adjustment in socketHandler.js PulseApp.socketHandler?.init?.(updateAllUITables, updateLoadingOverlayVisibility); PulseApp.tooltips?.init?.(); + PulseApp.alerts?.init?.(); PulseApp.ui = PulseApp.ui || {}; PulseApp.ui.tabs?.init?.(); @@ -64,7 +72,6 @@ document.addEventListener('DOMContentLoaded', function() { PulseApp.ui.common?.init?.(); PulseApp.thresholds = PulseApp.thresholds || {}; - PulseApp.thresholds.logging?.init?.(); } function validateCriticalElements() { diff --git a/src/public/js/socketHandler.js b/src/public/js/socketHandler.js index 9f5d78a92..485ed3774 100644 --- a/src/public/js/socketHandler.js +++ b/src/public/js/socketHandler.js @@ -1,156 +1,392 @@ PulseApp.socketHandler = (() => { let socket = null; - let uiUpdateCallback = () => { console.warn('[socketHandler] uiUpdateCallback not assigned.'); }; - let loadingOverlayCallback = () => { console.warn('[socketHandler] loadingOverlayCallback not assigned.'); }; + let isConnected = false; + let reconnectAttempts = 0; + const maxReconnectAttempts = 10; + const reconnectDelay = 2000; // 2 seconds - function init(updateFunctionRef, overlayUpdateRef) { - socket = io(); - uiUpdateCallback = updateFunctionRef; - loadingOverlayCallback = overlayUpdateRef; - - socket.on('connect', handleConnect); - socket.on('disconnect', handleDisconnect); - socket.on('initialState', handleInitialState); - socket.on('rawData', handleRawData); - socket.on('pbsInitialStatus', handlePbsInitialStatus); - socket.on('hotReload', () => { - console.log('[socketHandler] Hot reload requested. Reloading page...'); - window.location.reload(); - }); - - // Optional: for debugging all events - // socket.onAny((eventName, ...args) => { - // console.log(`[Socket Event Debug] Event: ${eventName}`, args); - // }); + function init() { + console.log('[Socket] Initializing socket connection...'); + createSocket(); } - function updateConnectionStatusUI(isConnected) { - const connectionStatus = document.getElementById('connection-status'); - if (!connectionStatus) return; + function createSocket() { + if (socket) { + socket.removeAllListeners(); + socket.disconnect(); + } - const statusText = isConnected ? 'Connected' : 'Disconnected'; - const addClasses = isConnected - ? ['connected', 'bg-green-100', 'dark:bg-green-800/30', 'text-green-700', 'dark:text-green-300'] - : ['disconnected', 'bg-red-100', 'dark:bg-red-800/30', 'text-red-700', 'dark:text-red-300']; - const removeClasses = isConnected - ? ['disconnected', 'bg-gray-200', 'dark:bg-gray-700', 'text-gray-600', 'dark:text-gray-400', 'bg-red-100', 'dark:bg-red-800/30', 'text-red-700', 'dark:text-red-300'] - : ['connected', 'bg-green-100', 'dark:bg-green-800/30', 'text-green-700', 'dark:text-green-300', 'bg-gray-200', 'dark:bg-gray-700', 'text-gray-600', 'dark:text-gray-400']; + socket = io(); + window.socket = socket; // Make socket available globally for alerts - connectionStatus.textContent = statusText; - connectionStatus.classList.remove(...removeClasses); - connectionStatus.classList.add(...addClasses); + setupEventListeners(); + } + + function setupEventListeners() { + socket.on('connect', handleConnect); + socket.on('disconnect', handleDisconnect); + socket.on('rawData', handleRawData); + socket.on('initialState', handleInitialState); + socket.on('requestError', handleRequestError); + + // Enhanced monitoring events + socket.on('alert', handleAlert); + socket.on('alertResolved', handleAlertResolved); + + // Development features + socket.on('hotReload', handleHotReload); + + // Handle connection errors + socket.on('connect_error', handleConnectError); + socket.on('reconnect', handleReconnect); + socket.on('reconnect_error', handleReconnectError); + socket.on('reconnect_failed', handleReconnectFailed); } function handleConnect() { - updateConnectionStatusUI(true); - PulseApp.state.set('wasConnected', true); - requestFullData(); // Request data on new connection or reconnection - if (typeof loadingOverlayCallback === 'function') { - loadingOverlayCallback(); // Update overlay based on current state - } + console.log('[Socket] Connected to server'); + isConnected = true; + reconnectAttempts = 0; + updateConnectionStatus('connected'); + + // Request initial data + socket.emit('requestData'); } function handleDisconnect(reason) { - updateConnectionStatusUI(false); - PulseApp.state.set('wasConnected', false); - if (typeof loadingOverlayCallback === 'function') { - loadingOverlayCallback(); // This should show the overlay with "Connection lost" - } - } - - function handleInitialState(state) { - console.log('[socketHandler] Received initial state:', state); - const isPlaceholder = state.isConfigPlaceholder || false; - PulseApp.state.set('isConfigPlaceholder', isPlaceholder); - PulseApp.state.set('initialDataReceived', false); // Mark that full data hasn't arrived yet - - const statusText = document.getElementById('dashboard-status-text'); - if (statusText) { - statusText.textContent = isPlaceholder ? 'Configuration Required' : 'Loading initial data...'; - } + console.log('[Socket] Disconnected from server:', reason); + isConnected = false; + updateConnectionStatus('disconnected'); - if (typeof loadingOverlayCallback === 'function') { - loadingOverlayCallback(); // Update overlay based on placeholder status and lack of data + // If it's not a planned disconnect, try to reconnect + if (reason !== 'io client disconnect') { + attemptReconnect(); } } - function handleRawData(jsonData) { + function handleRawData(data) { try { - const data = typeof jsonData === 'string' ? JSON.parse(jsonData) : jsonData; + // Update main application state + if (PulseApp.state) { + PulseApp.state.updateState(data); + } - PulseApp.state.set('isConfigPlaceholder', data.isConfigPlaceholder || false); - PulseApp.state.set('nodesData', data.nodes || []); - PulseApp.state.set('vmsData', data.vms || []); - PulseApp.state.set('containersData', data.containers || []); - PulseApp.state.set('metricsData', data.metrics || []); // Ensure metrics are always set - PulseApp.state.set('pbsDataArray', Array.isArray(data.pbs) ? data.pbs : []); - - if (PulseApp.ui?.tabs) { - PulseApp.ui.tabs.updateTabAvailability(); + // Update alerts system with new state + if (PulseApp.alerts && data.alerts) { + PulseApp.alerts.updateAlertsFromState(data); } - - // Set initialDataReceived to true only after successfully processing raw data - PulseApp.state.set('initialDataReceived', true); - - if (typeof loadingOverlayCallback === 'function') { - loadingOverlayCallback(); // Hide overlay if not placeholder and data received - } - - if (typeof uiUpdateCallback === 'function') { - uiUpdateCallback(); - } else { - console.error('[socketHandler] uiUpdateCallback is not a function!'); - } - } catch (e) { - console.error('Error processing received rawData:', e, jsonData); + + // Process UI updates based on tab + updateUIFromData(data); + + } catch (error) { + console.error('[Socket] Error processing raw data:', error); } } - function handlePbsInitialStatus(pbsStatusArray) { - if (Array.isArray(pbsStatusArray)) { - const initialPbsData = pbsStatusArray.map(statusInfo => ({ - ...statusInfo, // Spread existing status info - // Initialize other fields if they might be missing from statusInfo - backupTasks: statusInfo.backupTasks || { recentTasks: [], summary: {} }, - datastores: statusInfo.datastores || [], - verificationTasks: statusInfo.verificationTasks || { summary: {} }, - syncTasks: statusInfo.syncTasks || { summary: {} }, - pruneTasks: statusInfo.pruneTasks || { summary: {} }, - nodeName: statusInfo.nodeName || null - })); - PulseApp.state.set('pbsDataArray', initialPbsData); - // Don't set initialDataReceived here; wait for full rawData + function handleInitialState(data) { + console.log('[Socket] Received initial state:', data); + + try { + if (PulseApp.state) { + PulseApp.state.updateState(data); + } + + updateUIFromData(data); + + } catch (error) { + console.error('[Socket] Error processing initial state:', error); + } + } - if (PulseApp.ui?.pbs) { - PulseApp.ui.pbs.updatePbsInfo(initialPbsData); - } - if (PulseApp.ui?.tabs) { - PulseApp.ui.tabs.updateTabAvailability(); - } - if (typeof loadingOverlayCallback === 'function') { - loadingOverlayCallback(); // Update overlay, might still show "Loading..." - } + function handleRequestError(error) { + console.error('[Socket] Request error:', error); + updateConnectionStatus('error'); + } + + function handleAlert(alert) { + console.log('[Socket] Received alert:', alert); + + // Forward to alerts handler + if (PulseApp.alerts) { + // The alerts handler will be called directly from its socket listeners + // This is just for any additional processing + } + } + + function handleAlertResolved(alert) { + console.log('[Socket] Alert resolved:', alert); + + // Forward to alerts handler + if (PulseApp.alerts) { + // The alerts handler will be called directly from its socket listeners + // This is just for any additional processing + } + } + + function handleHotReload() { + console.log('[Socket] Hot reload triggered'); + if (process.env.NODE_ENV === 'development') { + window.location.reload(); + } + } + + function handleConnectError(error) { + console.error('[Socket] Connection error:', error); + updateConnectionStatus('error'); + } + + function handleReconnect() { + console.log('[Socket] Reconnected successfully'); + reconnectAttempts = 0; + updateConnectionStatus('connected'); + } + + function handleReconnectError(error) { + console.error('[Socket] Reconnection error:', error); + reconnectAttempts++; + updateConnectionStatus('reconnecting'); + } + + function handleReconnectFailed() { + console.error('[Socket] Reconnection failed - max attempts reached'); + updateConnectionStatus('failed'); + } + + function attemptReconnect() { + if (reconnectAttempts < maxReconnectAttempts) { + reconnectAttempts++; + updateConnectionStatus('reconnecting'); + + setTimeout(() => { + console.log(`[Socket] Attempting to reconnect... (${reconnectAttempts}/${maxReconnectAttempts})`); + socket.connect(); + }, reconnectDelay * reconnectAttempts); // Exponential backoff } else { - console.warn('[socket] Received non-array data for pbsInitialStatus:', pbsStatusArray); + updateConnectionStatus('failed'); } } - function requestFullData() { - console.log('Requesting full data reload from server...'); - if (socket && socket.connected) { // Check if socket is connected before emitting + function updateConnectionStatus(status) { + const statusElement = document.getElementById('connection-status'); + if (!statusElement) return; + + // Clear previous classes + statusElement.className = statusElement.className + .replace(/\b(connected|disconnected|reconnecting|error|failed)\b/g, '') + .trim(); + + let statusText, statusClass; + + switch (status) { + case 'connected': + statusText = 'Connected'; + statusClass = 'connected text-xs px-2 py-1 rounded-full bg-green-100 dark:bg-green-900 text-green-600 dark:text-green-400'; + break; + case 'disconnected': + statusText = 'Disconnected'; + statusClass = 'disconnected text-xs px-2 py-1 rounded-full bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400'; + break; + case 'reconnecting': + statusText = `Reconnecting... (${reconnectAttempts}/${maxReconnectAttempts})`; + statusClass = 'reconnecting text-xs px-2 py-1 rounded-full bg-yellow-100 dark:bg-yellow-900 text-yellow-600 dark:text-yellow-400 animate-pulse'; + break; + case 'error': + statusText = 'Connection Error'; + statusClass = 'error text-xs px-2 py-1 rounded-full bg-red-100 dark:bg-red-900 text-red-600 dark:text-red-400'; + break; + case 'failed': + statusText = 'Connection Failed'; + statusClass = 'failed text-xs px-2 py-1 rounded-full bg-red-100 dark:bg-red-900 text-red-600 dark:text-red-400'; + break; + default: + statusText = 'Unknown'; + statusClass = 'text-xs px-2 py-1 rounded-full bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400'; + } + + statusElement.textContent = statusText; + statusElement.className = statusClass; + } + + function updateUIFromData(data) { + try { + // Hide loading overlay when we receive data + const loadingOverlay = document.getElementById('loading-overlay'); + if (loadingOverlay && (data.nodes || data.vms || data.containers || data.pbs)) { + loadingOverlay.style.display = 'none'; + } + + // Update different UI sections based on current tab + const activeTab = document.querySelector('.tab.active'); + if (!activeTab) return; + + const tabName = activeTab.getAttribute('data-tab'); + + switch (tabName) { + case 'main': + updateMainTab(data); + break; + case 'storage': + updateStorageTab(data); + break; + case 'pbs': + updatePbsTab(data); + break; + case 'backups': + updateBackupsTab(data); + break; + } + + // Update performance indicators if available + updatePerformanceIndicators(data); + + } catch (error) { + console.error('[Socket] Error updating UI from data:', error); + } + } + + function updateMainTab(data) { + try { + // Update node summary cards + if (PulseApp.ui && PulseApp.ui.nodes && data.nodes) { + PulseApp.ui.nodes.updateNodeSummaryCards(data.nodes); + } + + // Update main dashboard + if (PulseApp.ui && PulseApp.ui.dashboard) { + PulseApp.ui.dashboard.updateDashboardTable(); + } + + } catch (error) { + console.error('[Socket] Error updating main tab:', error); + } + } + + function updateStorageTab(data) { + try { + if (PulseApp.ui && PulseApp.ui.storage && data.nodes) { + PulseApp.ui.storage.updateStorageInfo(); + } + } catch (error) { + console.error('[Socket] Error updating storage tab:', error); + } + } + + function updatePbsTab(data) { + try { + if (PulseApp.ui && PulseApp.ui.pbs && data.pbs) { + PulseApp.ui.pbs.updatePbsInfo(data.pbs); + } + } catch (error) { + console.error('[Socket] Error updating PBS tab:', error); + } + } + + function updateBackupsTab(data) { + try { + if (PulseApp.ui && PulseApp.ui.backups && data.pbs) { + PulseApp.ui.backups.updateBackupsTab(); + } + } catch (error) { + console.error('[Socket] Error updating backups tab:', error); + } + } + + function updatePerformanceIndicators(data) { + try { + // Update performance stats if available + if (data.stats) { + updateStatsDisplay(data.stats); + } + + // Update any health indicators + if (data.performance) { + updateHealthDisplay(data.performance); + } + + } catch (error) { + console.error('[Socket] Error updating performance indicators:', error); + } + } + + function updateStatsDisplay(stats) { + // Update various stats in the UI + try { + const statusText = document.getElementById('dashboard-status-text'); + if (statusText && stats.totalGuests !== undefined) { + const runningText = stats.runningGuests ? `${stats.runningGuests} running` : '0 running'; + const stoppedText = stats.stoppedGuests ? `${stats.stoppedGuests} stopped` : '0 stopped'; + statusText.textContent = `${stats.totalGuests} total guests (${runningText}, ${stoppedText})`; + } + + } catch (error) { + console.error('[Socket] Error updating stats display:', error); + } + } + + function updateHealthDisplay(performance) { + // Update health indicators in the UI + try { + // Could add health indicators to the header or other parts of the UI + // For now, this is just a placeholder for future enhancements + + } catch (error) { + console.error('[Socket] Error updating health display:', error); + } + } + + // Manual data request + function requestData() { + if (socket && isConnected) { socket.emit('requestData'); } else { - console.error('Cannot request data: socket not initialized or not connected.'); + console.warn('[Socket] Cannot request data - not connected'); } } - function isConnected() { - return socket && socket.connected; + // Get connection status + function getConnectionStatus() { + return { + connected: isConnected, + reconnectAttempts, + socket: socket ? socket.id : null + }; } + // Manual reconnect + function reconnect() { + if (socket) { + reconnectAttempts = 0; + socket.disconnect(); + socket.connect(); + } + } + + // Cleanup + function destroy() { + if (socket) { + socket.removeAllListeners(); + socket.disconnect(); + socket = null; + } + isConnected = false; + window.socket = null; + } + + // Public API return { init, - requestFullData, - isConnected + requestData, + getConnectionStatus, + reconnect, + destroy }; })(); + +// Auto-initialize when DOM is ready +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', PulseApp.socketHandler.init); +} else { + PulseApp.socketHandler.init(); +} + diff --git a/src/public/js/state.js b/src/public/js/state.js index 267b5dda7..db8e7af8b 100644 --- a/src/public/js/state.js +++ b/src/public/js/state.js @@ -19,8 +19,39 @@ PulseApp.state = (() => { backupsFilterHealth: savedFilterState.backupsFilterHealth || 'all', backupsFilterGuestType: savedFilterState.backupsFilterGuestType || 'all', backupsSearchTerm: '', + + // Enhanced monitoring data + alerts: { + active: [], + stats: {}, + rules: [] + }, + performance: { + lastDiscoveryTime: null, + lastMetricsTime: null, + discoveryDuration: 0, + metricsDuration: 0, + errorCount: 0, + successCount: 0, + avgResponseTime: 0, + peakMemoryUsage: 0 + }, + stats: { + totalGuests: 0, + runningGuests: 0, + stoppedGuests: 0, + totalNodes: 0, + healthyNodes: 0, + warningNodes: 0, + errorNodes: 0, + avgCpuUsage: 0, + avgMemoryUsage: 0, + avgDiskUsage: 0, + lastUpdated: null + }, + isConfigPlaceholder: false, + sortState: { - nodes: { column: null, direction: 'asc', ...(savedSortState.nodes || {}) }, main: { column: 'id', direction: 'asc', ...(savedSortState.main || {}) }, backups: { column: 'latestBackupTime', direction: 'desc', ...(savedSortState.backups || {}) } }, @@ -32,34 +63,28 @@ PulseApp.state = (() => { diskwrite:{ value: 0 }, netin: { value: 0 }, netout: { value: 0 } - }, - activeLogSessions: {}, - thresholdLogEntries: [], - activeLoggingThresholds: null + } }; // Initialize thresholdState by merging saved state with defaults Object.keys(internalState.thresholdState).forEach(type => { const savedTypeState = savedThresholdState[type] || {}; - if (internalState.thresholdState[type].hasOwnProperty('operator')) { // Assuming this structure means it's the advanced threshold type + if (internalState.thresholdState[type].hasOwnProperty('operator')) { internalState.thresholdState[type] = { operator: savedTypeState.operator || '>=', input: savedTypeState.input || '', - // Preserve any other default properties if they exist ...internalState.thresholdState[type], - ...savedTypeState // This ensures saved values overwrite defaults but keeps other default props + ...savedTypeState }; - } else { // Simple value threshold + } else { internalState.thresholdState[type] = { value: savedTypeState.value || 0, - // Preserve any other default properties ...internalState.thresholdState[type], ...savedTypeState }; } }); - function saveFilterState() { const stateToSave = { groupByNode: internalState.groupByNode, @@ -74,13 +99,94 @@ PulseApp.state = (() => { function saveSortState() { const stateToSave = { - nodes: internalState.sortState.nodes, main: internalState.sortState.main, backups: internalState.sortState.backups }; localStorage.setItem('pulseSortState', JSON.stringify(stateToSave)); } + function updateState(newData) { + try { + console.log('[State] Updating state with new data:', Object.keys(newData)); + + // Update core data arrays + if (newData.nodes) internalState.nodesData = newData.nodes; + if (newData.vms) internalState.vmsData = newData.vms; + if (newData.containers) internalState.containersData = newData.containers; + if (newData.metrics) internalState.metricsData = newData.metrics; + if (newData.pbs) internalState.pbsDataArray = newData.pbs; + + // Update enhanced monitoring data + if (newData.alerts) { + internalState.alerts = { + active: newData.alerts.active || [], + stats: newData.alerts.stats || {}, + rules: newData.alerts.rules || [] + }; + } + + if (newData.performance) { + internalState.performance = { ...internalState.performance, ...newData.performance }; + } + + if (newData.stats) { + internalState.stats = { ...internalState.stats, ...newData.stats }; + } + + // Update configuration status + if (newData.hasOwnProperty('isConfigPlaceholder')) { + internalState.isConfigPlaceholder = newData.isConfigPlaceholder; + } + + // Combine VMs and containers for dashboard + internalState.dashboardData = [...internalState.vmsData, ...internalState.containersData]; + + // Mark that we've received initial data + if (!internalState.initialDataReceived && internalState.dashboardData.length > 0) { + internalState.initialDataReceived = true; + console.log('[State] Initial data received and processed'); + } + + // Update dashboard history for charts + updateDashboardHistoryFromMetrics(); + + } catch (error) { + console.error('[State] Error updating state:', error); + } + } + + function updateDashboardHistoryFromMetrics() { + try { + internalState.metricsData.forEach(metric => { + if (metric && metric.current) { + const guestId = `${metric.endpointId}-${metric.node}-${metric.id}`; + const dataPoint = { + timestamp: Date.now(), + cpu: metric.current.cpu * 100 || 0, + memory: metric.current.mem || 0, + disk: metric.current.disk || 0, + diskread: metric.current.diskread || 0, + diskwrite: metric.current.diskwrite || 0, + netin: metric.current.netin || 0, + netout: metric.current.netout || 0 + }; + + if (!internalState.dashboardHistory[guestId] || !Array.isArray(internalState.dashboardHistory[guestId])) { + internalState.dashboardHistory[guestId] = []; + } + + const history = internalState.dashboardHistory[guestId]; + history.push(dataPoint); + if (history.length > PulseApp.config.AVERAGING_WINDOW_SIZE) { + history.shift(); + } + } + }); + } catch (error) { + console.error('[State] Error updating dashboard history:', error); + } + } + return { get: (key) => internalState[key], set: (key, value) => { @@ -89,6 +195,11 @@ PulseApp.state = (() => { saveFilterState(); } }, + + // Enhanced state management + updateState, + getFullState: () => ({ ...internalState }), + setSortState: (tableType, column, direction) => { if (internalState.sortState[tableType]) { internalState.sortState[tableType] = { column, direction }; @@ -108,21 +219,6 @@ PulseApp.state = (() => { console.warn(`Attempted to set threshold for unknown type: ${type}`); } }, - getActiveLogSession: (sessionId) => internalState.activeLogSessions[sessionId], - getAllActiveLogSessions: () => internalState.activeLogSessions, - addActiveLogSession: (sessionId, sessionData) => { - internalState.activeLogSessions[sessionId] = sessionData; - }, - removeActiveLogSession: (sessionId) => { - delete internalState.activeLogSessions[sessionId]; - }, - addLogEntry: (sessionId, entry) => { - if (internalState.activeLogSessions[sessionId]) { - internalState.activeLogSessions[sessionId].entries.push(entry); - } else { - console.warn(`Attempted to add log entry to non-existent session: ${sessionId}`); - } - }, getDashboardHistory: () => internalState.dashboardHistory, updateDashboardHistory: (guestId, dataPoint) => { if (!internalState.dashboardHistory[guestId] || !Array.isArray(internalState.dashboardHistory[guestId])) { @@ -134,6 +230,16 @@ PulseApp.state = (() => { }, clearDashboardHistoryEntry: (guestId) => { delete internalState.dashboardHistory[guestId]; - } + }, + forceRefreshDashboard: () => { + if (PulseApp.socketHandler && typeof PulseApp.socketHandler.requestData === 'function') { + PulseApp.socketHandler.requestData(); + } + }, + + // Alert and performance data getters + getAlerts: () => internalState.alerts, + getPerformance: () => internalState.performance, + getStats: () => internalState.stats }; })(); diff --git a/src/public/js/tabs.js b/src/public/js/tabs.js index fb325ab87..0beb18fd1 100644 --- a/src/public/js/tabs.js +++ b/src/public/js/tabs.js @@ -5,13 +5,15 @@ PulseApp.ui.tabs = (() => { let tabContents = []; let nestedTabsContainer = null; let nestedTabContentContainer = null; + let mainTabsContainer = null; let logSessionArea = null; function init() { - tabs = document.querySelectorAll('.tab'); + tabs = Array.from(document.querySelectorAll('.tab')); tabContents = document.querySelectorAll('.tab-content'); nestedTabsContainer = document.querySelector('.nested-tabs'); - nestedTabContentContainer = document.querySelector('#log-content-area'); + nestedTabContentContainer = document.getElementById('nested-tab-content-container'); + mainTabsContainer = document.getElementById('main-tabs-container'); logSessionArea = document.getElementById('log-session-area'); // Initial styling pass for all tabs to ensure consistent look from the start @@ -350,8 +352,6 @@ PulseApp.ui.tabs = (() => { return { init, activateNestedTab, - updateTabAvailability, - addLogTab, - removeLogTabAndContent + updateTabAvailability }; })(); diff --git a/src/public/js/tooltips.js b/src/public/js/tooltips.js index 4cb13a466..11c6014ee 100644 --- a/src/public/js/tooltips.js +++ b/src/public/js/tooltips.js @@ -102,9 +102,27 @@ PulseApp.tooltips = (() => { } } + function showTooltip(event, content) { + if (!tooltipElement) return; + + tooltipElement.innerHTML = content; + positionTooltip(event); + tooltipElement.classList.remove('hidden', 'opacity-0'); + tooltipElement.classList.add('opacity-100'); + } + + function hideTooltip() { + if (tooltipElement) { + tooltipElement.classList.add('hidden', 'opacity-0'); + tooltipElement.classList.remove('opacity-100'); + } + } + return { init, updateSliderTooltip, - hideSliderTooltip + hideSliderTooltip, + showTooltip, + hideTooltip }; })(); \ No newline at end of file diff --git a/src/public/js/ui/backups.js b/src/public/js/ui/backups.js index bd8d5fa39..9a231fe78 100644 --- a/src/public/js/ui/backups.js +++ b/src/public/js/ui/backups.js @@ -57,33 +57,68 @@ PulseApp.ui.backups = (() => { ) ); - return { allGuests, initialDataReceived, allRecentBackupTasks, allSnapshots }; - } + // Pre-index data by guest ID and type for performance + const tasksByGuest = new Map(); + const snapshotsByGuest = new Map(); - function _determineGuestBackupStatus(guest, allSnapshots, allRecentBackupTasks) { - const guestId = String(guest.vmid); - const guestTypePve = guest.type === 'qemu' ? 'vm' : 'ct'; - const now = new Date(); // Use Date object for easier day calculations - now.setHours(0, 0, 0, 0); // Normalize to start of today + allRecentBackupTasks.forEach(task => { + const key = `${task.guestId}-${task.guestTypePbs}`; + if (!tasksByGuest.has(key)) tasksByGuest.set(key, []); + tasksByGuest.get(key).push(task); + }); + + allSnapshots.forEach(snap => { + const key = `${snap.backupVMID}-${snap.backupType}`; + if (!snapshotsByGuest.has(key)) snapshotsByGuest.set(key, []); + snapshotsByGuest.get(key).push(snap); + }); + + // Pre-calculate day boundaries for 7-day analysis + const now = new Date(); + now.setHours(0, 0, 0, 0); + const dayBoundaries = []; + for (let i = 6; i >= 0; i--) { + const dayStart = new Date(now); + dayStart.setDate(now.getDate() - i); + const dayEnd = new Date(dayStart); + dayEnd.setDate(dayStart.getDate() + 1); + dayBoundaries.push({ + start: Math.floor(dayStart.getTime() / 1000), + end: Math.floor(dayEnd.getTime() / 1000) + }); + } const threeDaysAgo = Math.floor(new Date(now).setDate(now.getDate() - 3) / 1000); - const sevenDaysAgoTimestamp = Math.floor(new Date(now).setDate(now.getDate() - 7) / 1000); + const sevenDaysAgo = Math.floor(new Date(now).setDate(now.getDate() - 7) / 1000); - const guestSnapshots = allSnapshots.filter(snap => - String(snap.backupVMID) === guestId && snap.backupType === guestTypePve - ); - const totalBackups = guestSnapshots.length; - const latestSnapshot = guestSnapshots.reduce((latest, snap) => { - return (!latest || (snap['backup-time'] && snap['backup-time'] > latest['backup-time'])) ? snap : latest; - }, null); + return { + allGuests, + initialDataReceived, + tasksByGuest, + snapshotsByGuest, + dayBoundaries, + threeDaysAgo, + sevenDaysAgo + }; + } + + function _determineGuestBackupStatus(guest, guestSnapshots, guestTasks, dayBoundaries, threeDaysAgo, sevenDaysAgo) { + const guestId = String(guest.vmid); + + // Use pre-filtered data instead of filtering large arrays + const totalBackups = guestSnapshots ? guestSnapshots.length : 0; + const latestSnapshot = guestSnapshots && guestSnapshots.length > 0 + ? guestSnapshots.reduce((latest, snap) => { + return (!latest || (snap['backup-time'] && snap['backup-time'] > latest['backup-time'])) ? snap : latest; + }, null) + : null; const latestSnapshotTime = latestSnapshot ? latestSnapshot['backup-time'] : null; - const guestTasks = allRecentBackupTasks.filter(task => - task.guestId === guestId && task.guestTypePbs === guestTypePve - ); - const latestTask = guestTasks.reduce((latest, task) => { - return (!latest || (task.startTime && task.startTime > latest.startTime)) ? task : latest; - }, null); + const latestTask = guestTasks && guestTasks.length > 0 + ? guestTasks.reduce((latest, task) => { + return (!latest || (task.startTime && task.startTime > latest.startTime)) ? task : latest; + }, null) + : null; let healthStatus = 'none'; let displayTimestamp = latestSnapshotTime; @@ -92,56 +127,50 @@ PulseApp.ui.backups = (() => { displayTimestamp = latestTask.startTime; if (latestTask.status === 'OK') { if (latestTask.startTime >= threeDaysAgo) healthStatus = 'ok'; - else if (latestTask.startTime >= sevenDaysAgoTimestamp) healthStatus = 'stale'; + else if (latestTask.startTime >= sevenDaysAgo) healthStatus = 'stale'; else healthStatus = 'old'; } else { healthStatus = 'failed'; } } else if (latestSnapshotTime) { if (latestSnapshotTime >= threeDaysAgo) healthStatus = 'ok'; - else if (latestSnapshotTime >= sevenDaysAgoTimestamp) healthStatus = 'stale'; + else if (latestSnapshotTime >= sevenDaysAgo) healthStatus = 'stale'; else healthStatus = 'old'; } else { healthStatus = 'none'; displayTimestamp = null; } - // Calculate 7-day backup status (dot matrix) - const last7DaysBackupStatus = []; - for (let i = 6; i >= 0; i--) { // Iterate from 6 days ago to today - const dayTarget = new Date(now); - dayTarget.setDate(now.getDate() - i); - const dayStartTimestamp = Math.floor(dayTarget.getTime() / 1000); - - const dayEndTarget = new Date(dayTarget); - dayEndTarget.setDate(dayTarget.getDate() + 1); - const dayEndTimestamp = Math.floor(dayEndTarget.getTime() / 1000); + // Optimized 7-day backup status calculation using pre-calculated boundaries + const last7DaysBackupStatus = dayBoundaries.map(day => { + let dailyStatus = 'none'; - let dailyStatus = 'none'; // Default to 'none' - - // Check tasks for this day - const tasksOnThisDay = guestTasks.filter(task => - task.startTime >= dayStartTimestamp && task.startTime < dayEndTimestamp - ); - - const failedTaskOnThisDay = tasksOnThisDay.find(task => task.status !== 'OK'); - const successfulTaskOnThisDay = tasksOnThisDay.find(task => task.status === 'OK'); - - if (failedTaskOnThisDay) { - dailyStatus = 'failed'; - } else if (successfulTaskOnThisDay) { - dailyStatus = 'ok'; - } else { - // If no tasks, check for snapshots as a fallback for successful backup indication - const snapshotOnThisDay = guestSnapshots.some( - snap => snap['backup-time'] >= dayStartTimestamp && snap['backup-time'] < dayEndTimestamp + // Check tasks for this day - using pre-filtered guest tasks + if (guestTasks) { + const failedTaskOnThisDay = guestTasks.find(task => + task.startTime >= day.start && task.startTime < day.end && task.status !== 'OK' ); - if (snapshotOnThisDay) { + const successfulTaskOnThisDay = guestTasks.find(task => + task.startTime >= day.start && task.startTime < day.end && task.status === 'OK' + ); + + if (failedTaskOnThisDay) { + dailyStatus = 'failed'; + } else if (successfulTaskOnThisDay) { dailyStatus = 'ok'; + } else if (guestSnapshots) { + // Check snapshots as fallback - using pre-filtered guest snapshots + const snapshotOnThisDay = guestSnapshots.some( + snap => snap['backup-time'] >= day.start && snap['backup-time'] < day.end + ); + if (snapshotOnThisDay) { + dailyStatus = 'ok'; + } } } - last7DaysBackupStatus.push(dailyStatus); - } + + return dailyStatus; + }); return { guestName: guest.name || `Guest ${guest.vmid}`, @@ -269,7 +298,7 @@ PulseApp.ui.backups = (() => { return; } - const { allGuests, initialDataReceived, allRecentBackupTasks, allSnapshots } = _getInitialBackupData(); + const { allGuests, initialDataReceived, tasksByGuest, snapshotsByGuest, dayBoundaries, threeDaysAgo, sevenDaysAgo } = _getInitialBackupData(); if (!initialDataReceived) { loadingMsg.classList.remove('hidden'); @@ -288,7 +317,7 @@ PulseApp.ui.backups = (() => { } loadingMsg.classList.add('hidden'); - const backupStatusByGuest = allGuests.map(guest => _determineGuestBackupStatus(guest, allSnapshots, allRecentBackupTasks)); + const backupStatusByGuest = allGuests.map(guest => _determineGuestBackupStatus(guest, snapshotsByGuest.get(`${guest.vmid}-${guest.type === 'qemu' ? 'vm' : 'ct'}`) || [], tasksByGuest.get(`${guest.vmid}-${guest.type === 'qemu' ? 'vm' : 'ct'}`) || [], dayBoundaries, threeDaysAgo, sevenDaysAgo)); const filteredBackupStatus = _filterBackupData(backupStatusByGuest, backupsSearchInput); const sortStateBackups = PulseApp.state.getSortState('backups'); diff --git a/src/public/js/ui/common.js b/src/public/js/ui/common.js index 944f12ea2..dc287295a 100644 --- a/src/public/js/ui/common.js +++ b/src/public/js/ui/common.js @@ -8,7 +8,6 @@ PulseApp.ui.common = (() => { searchInput = document.getElementById('dashboard-search'); backupsSearchInput = document.getElementById('backups-search'); - setupTableSorting('nodes-table'); setupTableSorting('main-table'); setupTableSorting('backups-overview-table'); @@ -41,7 +40,6 @@ PulseApp.ui.common = (() => { function applyInitialSortUI() { const mainSortState = PulseApp.state.getSortState('main'); - const nodesSortState = PulseApp.state.getSortState('nodes'); const backupsSortState = PulseApp.state.getSortState('backups'); const initialMainHeader = document.querySelector(`#main-table th[data-sort="${mainSortState.column}"]`); @@ -49,11 +47,6 @@ PulseApp.ui.common = (() => { updateSortUI('main-table', initialMainHeader); } - const initialNodesHeader = document.querySelector(`#nodes-table th[data-sort="${nodesSortState.column}"]`); - if (initialNodesHeader) { - updateSortUI('nodes-table', initialNodesHeader); - } - const initialBackupsHeader = document.querySelector(`#backups-overview-table th[data-sort="${backupsSortState.column}"]`); if (initialBackupsHeader) { updateSortUI('backups-overview-table', initialBackupsHeader); @@ -79,7 +72,9 @@ PulseApp.ui.common = (() => { PulseApp.ui.dashboard.updateDashboardTable(); if (searchInput) searchInput.dispatchEvent(new Event('input')); PulseApp.state.saveFilterState(); - PulseApp.ui.thresholds.updateLogControlsVisibility(); + if (PulseApp.ui.thresholds && typeof PulseApp.ui.thresholds.updateLogControlsVisibility === 'function') { + PulseApp.ui.thresholds.updateLogControlsVisibility(); + } } }); }); @@ -91,7 +86,9 @@ PulseApp.ui.common = (() => { PulseApp.ui.dashboard.updateDashboardTable(); if (searchInput) searchInput.dispatchEvent(new Event('input')); PulseApp.state.saveFilterState(); - PulseApp.ui.thresholds.updateLogControlsVisibility(); + if (PulseApp.ui.thresholds && typeof PulseApp.ui.thresholds.updateLogControlsVisibility === 'function') { + PulseApp.ui.thresholds.updateLogControlsVisibility(); + } } }); }); @@ -99,7 +96,9 @@ PulseApp.ui.common = (() => { if (searchInput) { searchInput.addEventListener('input', function() { PulseApp.ui.dashboard.updateDashboardTable(); - PulseApp.ui.thresholds.updateLogControlsVisibility(); + if (PulseApp.ui.thresholds && typeof PulseApp.ui.thresholds.updateLogControlsVisibility === 'function') { + PulseApp.ui.thresholds.updateLogControlsVisibility(); + } }); } else { console.warn('Element #dashboard-search not found - text filtering disabled.'); @@ -259,9 +258,6 @@ PulseApp.ui.common = (() => { PulseApp.state.setSortState(tableType, column, newDirection); switch(tableType) { - case 'nodes': - PulseApp.ui.nodes.updateNodesTable(PulseApp.state.get('nodesData')); - break; case 'main': PulseApp.ui.dashboard.updateDashboardTable(); break; diff --git a/src/public/js/ui/dashboard.js b/src/public/js/ui/dashboard.js index 66c0dd921..00caa87d1 100644 --- a/src/public/js/ui/dashboard.js +++ b/src/public/js/ui/dashboard.js @@ -29,6 +29,17 @@ PulseApp.ui.dashboard = (() => { tableBodyEl = document.querySelector('#main-table tbody'); statusElementEl = document.getElementById('dashboard-status-text'); + // Initialize chart system + if (PulseApp.charts) { + PulseApp.charts.startChartUpdates(); + } + + // Initialize charts toggle + const chartsToggleButton = document.getElementById('toggle-charts-button'); + if (chartsToggleButton) { + chartsToggleButton.addEventListener('click', toggleChartsMode); + } + document.addEventListener('keydown', (event) => { // Handle Escape for resetting filters if (event.key === 'Escape') { @@ -132,7 +143,12 @@ PulseApp.ui.dashboard = (() => { avgNetOutRate = snapshot.netout; if (guest.status === STATUS_RUNNING && metrics && metrics.current) { - const currentDataPoint = { timestamp: Date.now(), ...metrics.current }; + const currentDataPoint = { + timestamp: Date.now(), + ...metrics.current, + // Convert CPU to percentage for consistency + cpu: (metrics.current.cpu || 0) * 100 + }; PulseApp.state.updateDashboardHistory(guestUniqueId, currentDataPoint); const history = PulseApp.state.getDashboardHistory()[guestUniqueId] || []; avgCpu = _calculateAverage(history, 'cpu') ?? 0; @@ -159,6 +175,7 @@ PulseApp.ui.dashboard = (() => { const currentDataPoint = { timestamp: Date.now(), ...metrics.current, + cpu: (metrics.current.cpu || 0) * 100, effective_mem: currentMemForAvg, effective_mem_total: currentMemTotalForDisplay, effective_mem_source: effectiveMemorySource @@ -236,8 +253,9 @@ PulseApp.ui.dashboard = (() => { if (uptimeFormatted.length > maxUptimeLength) maxUptimeLength = uptimeFormatted.length; }); - const nameColWidth = Math.min(Math.max(maxNameLength * 8 + 16, 100), 300); - const uptimeColWidth = Math.max(maxUptimeLength * 7 + 16, 80); + // More aggressive space optimization + const nameColWidth = Math.min(Math.max(maxNameLength * 7 + 12, 80), 250); + const uptimeColWidth = Math.max(maxUptimeLength * 6.5 + 8, 40); const htmlElement = document.documentElement; if (htmlElement) { htmlElement.style.setProperty('--name-col-width', `${nameColWidth}px`); @@ -280,7 +298,7 @@ PulseApp.ui.dashboard = (() => { const state = thresholdState[type]; let guestValue; - if (type === METRIC_CPU) guestValue = guest.cpu * 100; + if (type === METRIC_CPU) guestValue = guest.cpu; else if (type === METRIC_MEMORY) guestValue = guest.memory; else if (type === METRIC_DISK) guestValue = guest.disk; else if (type === METRIC_DISK_READ) guestValue = guest.diskread; @@ -426,35 +444,71 @@ PulseApp.ui.dashboard = (() => { } else { console.warn('[Dashboard] PulseApp.ui.common not available for updateSortUI'); } + + // Update charts immediately after table is rendered, but only if in charts mode + const mainContainer = document.getElementById('main'); + if (PulseApp.charts && visibleCount > 0 && mainContainer && mainContainer.classList.contains('charts-mode')) { + // Use requestAnimationFrame to ensure DOM is fully updated + requestAnimationFrame(() => { + PulseApp.charts.updateAllCharts(); + }); + } } function _createCpuBarHtml(guest) { if (guest.status !== STATUS_RUNNING) return '-'; - const cpuPercent = Math.round(guest.cpu * 100); - const cpuTooltipText = `${cpuPercent}% ${guest.cpus ? `(${(guest.cpu * guest.cpus).toFixed(1)}/${guest.cpus} cores)` : ''}`; - const cpuColorClass = PulseApp.utils.getUsageColor(cpuPercent); - return PulseApp.utils.createProgressTextBarHTML(cpuPercent, cpuTooltipText, cpuColorClass); + const cpuPercent = Math.round(guest.cpu); + const cpuTooltipText = `${cpuPercent}% ${guest.cpus ? `(${(guest.cpu * guest.cpus / 100).toFixed(1)}/${guest.cpus} cores)` : ''}`; + const cpuColorClass = PulseApp.utils.getUsageColor(cpuPercent, 'cpu'); + const progressBar = PulseApp.utils.createProgressTextBarHTML(cpuPercent, cpuTooltipText, cpuColorClass); + + // Create both text and chart versions + const guestId = guest.uniqueId; + const chartHtml = PulseApp.charts ? PulseApp.charts.createUsageChartHTML(guestId, 'cpu') : ''; + + return ` +