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 ` +
${progressBar}
+
${chartHtml}
+ `; } function _createMemoryBarHtml(guest) { if (guest.status !== STATUS_RUNNING) return '-'; - const memoryPercent = guest.memory; // This is already a percentage + const memoryPercent = guest.memory; let memoryTooltipText = `${PulseApp.utils.formatBytes(guest.memoryCurrent)} / ${PulseApp.utils.formatBytes(guest.memoryTotal)} (${memoryPercent}%)`; if (guest.type === GUEST_TYPE_VM && guest.memorySource === 'guest' && guest.rawHostMemory !== null && guest.rawHostMemory !== undefined) { memoryTooltipText += ` (Host: ${PulseApp.utils.formatBytes(guest.rawHostMemory)})`; } - const memColorClass = PulseApp.utils.getUsageColor(memoryPercent); - return PulseApp.utils.createProgressTextBarHTML(memoryPercent, memoryTooltipText, memColorClass); + const memColorClass = PulseApp.utils.getUsageColor(memoryPercent, 'memory'); + const progressBar = PulseApp.utils.createProgressTextBarHTML(memoryPercent, memoryTooltipText, memColorClass); + + // Create both text and chart versions + const guestId = guest.uniqueId; + const chartHtml = PulseApp.charts ? PulseApp.charts.createUsageChartHTML(guestId, 'memory') : ''; + + return ` +
${progressBar}
+
${chartHtml}
+ `; } function _createDiskBarHtml(guest) { if (guest.status !== STATUS_RUNNING) return '-'; if (guest.type === GUEST_TYPE_CT) { - const diskPercent = guest.disk; // This is already a percentage + const diskPercent = guest.disk; const diskTooltipText = guest.diskTotal ? `${PulseApp.utils.formatBytes(guest.diskCurrent)} / ${PulseApp.utils.formatBytes(guest.diskTotal)} (${diskPercent}%)` : `${diskPercent}%`; - const diskColorClass = PulseApp.utils.getUsageColor(diskPercent); - return PulseApp.utils.createProgressTextBarHTML(diskPercent, diskTooltipText, diskColorClass); - } else { // For VMs, show total disk size, not a progress bar + const diskColorClass = PulseApp.utils.getUsageColor(diskPercent, 'disk'); + const progressBar = PulseApp.utils.createProgressTextBarHTML(diskPercent, diskTooltipText, diskColorClass); + + // Create both text and chart versions + const guestId = guest.uniqueId; + const chartHtml = PulseApp.charts ? PulseApp.charts.createUsageChartHTML(guestId, 'disk') : ''; + + return ` +
${progressBar}
+
${chartHtml}
+ `; + } else { if (guest.diskTotal) { return `${PulseApp.utils.formatBytes(guest.diskTotal)}`; } @@ -474,29 +528,40 @@ PulseApp.ui.dashboard = (() => { const memoryBarHTML = _createMemoryBarHtml(guest); const diskBarHTML = _createDiskBarHtml(guest); - const diskReadFormatted = guest.status === STATUS_RUNNING ? PulseApp.utils.formatSpeed(guest.diskread, 0) : '-'; - const diskWriteFormatted = guest.status === STATUS_RUNNING ? PulseApp.utils.formatSpeed(guest.diskwrite, 0) : '-'; + const diskReadFormatted = guest.status === STATUS_RUNNING ? PulseApp.utils.formatSpeedWithStyling(guest.diskread, 0) : '-'; + const diskWriteFormatted = guest.status === STATUS_RUNNING ? PulseApp.utils.formatSpeedWithStyling(guest.diskwrite, 0) : '-'; + const netInFormatted = guest.status === STATUS_RUNNING ? PulseApp.utils.formatSpeedWithStyling(guest.netin, 0) : '-'; + const netOutFormatted = guest.status === STATUS_RUNNING ? PulseApp.utils.formatSpeedWithStyling(guest.netout, 0) : '-'; - // Icons and colors - const upArrow = '↑'; - const downArrow = '↓'; - - let netInIcon = ''; - let netOutIcon = ''; - - if (guest.status === STATUS_RUNNING) { - const netInActive = guest.netin > 0; - const netOutActive = guest.netout > 0; - - netInIcon = `${downArrow}`; - netOutIcon = `${upArrow}`; - } else { - netInIcon = `${downArrow}`; - netOutIcon = `${upArrow}`; - } + // Create I/O cells with both text and chart versions + const guestId = guest.uniqueId; - const netInFormatted = guest.status === STATUS_RUNNING ? PulseApp.utils.formatSpeed(guest.netin, 0) : '-'; - const netOutFormatted = guest.status === STATUS_RUNNING ? PulseApp.utils.formatSpeed(guest.netout, 0) : '-'; + let diskReadCell, diskWriteCell, netInCell, netOutCell; + + if (guest.status === STATUS_RUNNING && PulseApp.charts) { + // Text versions - clean, no arrows + const diskReadText = diskReadFormatted; + const diskWriteText = diskWriteFormatted; + const netInText = netInFormatted; + const netOutText = netOutFormatted; + + // Chart versions - clean, no arrows + const diskReadChart = PulseApp.charts.createSparklineHTML(guestId, 'diskread'); + const diskWriteChart = PulseApp.charts.createSparklineHTML(guestId, 'diskwrite'); + const netInChart = PulseApp.charts.createSparklineHTML(guestId, 'netin'); + const netOutChart = PulseApp.charts.createSparklineHTML(guestId, 'netout'); + + diskReadCell = `
${diskReadText}
${diskReadChart}
`; + diskWriteCell = `
${diskWriteText}
${diskWriteChart}
`; + netInCell = `
${netInText}
${netInChart}
`; + netOutCell = `
${netOutText}
${netOutChart}
`; + } else { + // Fallback to text only for stopped guests - no arrows + diskReadCell = diskReadFormatted; + diskWriteCell = diskWriteFormatted; + netInCell = netInFormatted; + netOutCell = netOutFormatted; + } const typeIconClass = guest.type === GUEST_TYPE_VM ? 'vm-icon bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 px-1.5 py-0.5 font-medium' @@ -519,10 +584,10 @@ PulseApp.ui.dashboard = (() => { ${cpuBarHTML} ${memoryBarHTML} ${diskBarHTML} - ${diskReadFormatted} - ${diskWriteFormatted} - ${netInIcon}${netInFormatted} - ${netOutIcon}${netOutFormatted} + ${diskReadCell} + ${diskWriteCell} + ${netInCell} + ${netOutCell} `; return row; } @@ -547,12 +612,35 @@ PulseApp.ui.dashboard = (() => { guestMetricDragSnapshot = {}; } + function toggleChartsMode() { + const mainContainer = document.getElementById('main'); + const button = document.getElementById('toggle-charts-button'); + + if (mainContainer.classList.contains('charts-mode')) { + // Switch to metrics mode + mainContainer.classList.remove('charts-mode'); + button.title = 'Toggle Charts View'; + } else { + // Switch to charts mode + mainContainer.classList.add('charts-mode'); + button.title = 'Toggle Metrics View'; + + // Immediately render charts when switching to charts mode + if (PulseApp.charts) { + requestAnimationFrame(() => { + PulseApp.charts.updateAllCharts(); + }); + } + } + } + return { init, refreshDashboardData, updateDashboardTable, createGuestRow, snapshotGuestMetricsForDrag, // Export snapshot function - clearGuestMetricSnapshots // Export clear function + clearGuestMetricSnapshots, // Export clear function + toggleChartsMode }; })(); diff --git a/src/public/js/ui/nodes.js b/src/public/js/ui/nodes.js index f0d9385a7..c260c6a4f 100644 --- a/src/public/js/ui/nodes.js +++ b/src/public/js/ui/nodes.js @@ -4,7 +4,7 @@ PulseApp.ui.nodes = (() => { function _createNodeCpuBarHtml(node) { const cpuPercent = node.cpu ? (node.cpu * 100) : 0; - const cpuColorClass = PulseApp.utils.getUsageColor(cpuPercent); + const cpuColorClass = PulseApp.utils.getUsageColor(cpuPercent, 'cpu'); const cpuTooltipText = `${cpuPercent.toFixed(1)}%${node.maxcpu && node.maxcpu > 0 ? ` (${(node.cpu * node.maxcpu).toFixed(1)}/${node.maxcpu} cores)` : ''}`; return PulseApp.utils.createProgressTextBarHTML(cpuPercent, cpuTooltipText, cpuColorClass); } @@ -13,7 +13,7 @@ PulseApp.ui.nodes = (() => { const memUsed = node.mem || 0; const memTotal = node.maxmem || 0; const memPercent = (memUsed && memTotal > 0) ? (memUsed / memTotal * 100) : 0; - const memColorClass = PulseApp.utils.getUsageColor(memPercent); + const memColorClass = PulseApp.utils.getUsageColor(memPercent, 'memory'); const memTooltipText = `${PulseApp.utils.formatBytes(memUsed)} / ${PulseApp.utils.formatBytes(memTotal)} (${memPercent.toFixed(1)}%)`; return PulseApp.utils.createProgressTextBarHTML(memPercent, memTooltipText, memColorClass); } @@ -22,7 +22,7 @@ PulseApp.ui.nodes = (() => { const diskUsed = node.disk || 0; const diskTotal = node.maxdisk || 0; const diskPercent = (diskUsed && diskTotal > 0) ? (diskUsed / diskTotal * 100) : 0; - const diskColorClass = PulseApp.utils.getUsageColor(diskPercent); + const diskColorClass = PulseApp.utils.getUsageColor(diskPercent, 'disk'); const diskTooltipText = `${PulseApp.utils.formatBytes(diskUsed)} / ${PulseApp.utils.formatBytes(diskTotal)} (${diskPercent.toFixed(1)}%)`; return PulseApp.utils.createProgressTextBarHTML(diskPercent, diskTooltipText, diskColorClass); } @@ -181,10 +181,11 @@ PulseApp.ui.nodes = (() => { function updateNodesTable(nodes) { const tbody = document.getElementById('nodes-table-body'); if (!tbody) { - console.error('Critical element #nodes-table-body not found for nodes table update!'); + // Node table doesn't exist in current UI - nodes are displayed as summary cards instead + console.log('[Nodes] Node table not found - using summary cards display instead'); return; } - tbody.innerHTML = ''; // Clear previous content + tbody.innerHTML = ''; if (!nodes || nodes.length === 0) { tbody.innerHTML = 'No nodes found or data unavailable'; @@ -193,7 +194,7 @@ PulseApp.ui.nodes = (() => { // Group nodes by clusterIdentifier const clusters = nodes.reduce((acc, node) => { - const key = node.clusterIdentifier || 'Unknown Cluster'; // Fallback for safety + const key = node.clusterIdentifier || 'Unknown Cluster'; if (!acc[key]) { acc[key] = []; } @@ -207,8 +208,6 @@ PulseApp.ui.nodes = (() => { for (const clusterIdentifier in clusters) { if (clusters.hasOwnProperty(clusterIdentifier)) { const nodesInCluster = clusters[clusterIdentifier]; - // All nodes in this group should have the same endpointType, so pick from the first. - // Default to 'standalone' if nodesInCluster is empty or endpointType is missing for some reason. const endpointType = (nodesInCluster && nodesInCluster.length > 0 && nodesInCluster[0].endpointType) ? nodesInCluster[0].endpointType : 'standalone'; @@ -218,7 +217,6 @@ PulseApp.ui.nodes = (() => { : PulseApp.ui.common.NODE_GROUP_STANDALONE_ICON_SVG; const clusterHeaderRow = document.createElement('tr'); - // Applying base background, then overlaying with stripe pattern classes clusterHeaderRow.innerHTML = PulseApp.ui.common.generateNodeGroupHeaderCellHTML(clusterIdentifier, 7, 'th'); tbody.appendChild(clusterHeaderRow); diff --git a/src/public/js/ui/storage.js b/src/public/js/ui/storage.js index eef30fddf..4162c70c8 100644 --- a/src/public/js/ui/storage.js +++ b/src/public/js/ui/storage.js @@ -1,29 +1,50 @@ PulseApp.ui = PulseApp.ui || {}; PulseApp.ui.storage = (() => { + // Cache for computed values to avoid recalculation + const contentBadgeCache = new Map(); + const iconCache = new Map(); + const contentBadgeHTMLCache = new Map(); // Cache for complete content badge HTML function getStorageTypeIcon(type) { + if (iconCache.has(type)) { + return iconCache.get(type); + } + + let icon; switch(type) { case 'dir': - return ''; + icon = ''; + break; case 'lvm': case 'lvmthin': - return ''; + icon = ''; + break; case 'zfs': case 'zfspool': - return ''; + icon = ''; + break; case 'nfs': case 'cifs': - return ''; + icon = ''; + break; case 'cephfs': case 'rbd': - return ''; + icon = ''; + break; default: - return ''; + icon = ''; } + + iconCache.set(type, icon); + return icon; } function getContentBadgeDetails(contentType) { + if (contentBadgeCache.has(contentType)) { + return contentBadgeCache.get(contentType); + } + let details = { badgeClass: 'bg-gray-200 dark:bg-gray-600 text-gray-700 dark:text-gray-300', tooltip: `Content type: ${contentType}` @@ -55,9 +76,31 @@ PulseApp.ui.storage = (() => { details.tooltip = 'Snippet files (e.g., cloud-init configs)'; break; } + + contentBadgeCache.set(contentType, details); return details; } + function getContentBadgesHTML(contentString) { + if (!contentString) return '-'; + + if (contentBadgeHTMLCache.has(contentString)) { + return contentBadgeHTMLCache.get(contentString); + } + + const contentTypes = contentString.split(',').map(ct => ct.trim()).filter(ct => ct); + contentTypes.sort(); + + const contentBadges = contentTypes.map(ct => { + const details = getContentBadgeDetails(ct); + return `${ct}`; + }).join(''); + + const result = contentBadges || '-'; + contentBadgeHTMLCache.set(contentString, result); + return result; + } + function sortNodeStorageData(storageArray) { if (!storageArray || !Array.isArray(storageArray)) return []; const sortedArray = [...storageArray]; @@ -82,9 +125,11 @@ PulseApp.ui.storage = (() => { return; } + // Pre-sort storage data for each node to avoid repeated sorting const storageByNode = nodes.reduce((acc, node) => { if (node && node.node) { - acc[node.node] = Array.isArray(node.storage) ? node.storage : []; + const storageData = Array.isArray(node.storage) ? node.storage : []; + acc[node.node] = sortNodeStorageData(storageData); } return acc; }, {}); @@ -119,7 +164,7 @@ PulseApp.ui.storage = (() => { const sortedNodeNames = Object.keys(storageByNode).sort((a, b) => a.localeCompare(b)); sortedNodeNames.forEach(nodeName => { - const nodeStorageData = storageByNode[nodeName]; + const nodeStorageData = storageByNode[nodeName]; // Already sorted const nodeHeaderRow = document.createElement('tr'); nodeHeaderRow.className = 'bg-gray-100 dark:bg-gray-700/80 font-semibold text-gray-700 dark:text-gray-300 text-xs node-storage-header'; @@ -133,8 +178,8 @@ PulseApp.ui.storage = (() => { return; } - const sortedNodeStorageData = sortNodeStorageData(nodeStorageData); - sortedNodeStorageData.forEach(store => { + // Use pre-sorted data instead of sorting again + nodeStorageData.forEach(store => { const row = _createStorageRow(store); tbody.appendChild(row); }); @@ -156,19 +201,17 @@ PulseApp.ui.storage = (() => { const usageBarHTML = PulseApp.utils.createProgressTextBarHTML(usagePercent, usageTooltipText, usageColorClass); const sharedIconTooltip = store.shared === 1 ? 'Shared across cluster' : 'Local to node'; + const isDarkMode = document.documentElement.classList.contains('dark'); + const localIconGrayClass = isDarkMode ? 'text-gray-400' : 'text-gray-300'; const sharedIcon = store.shared === 1 ? `` - : ``; + : ``; - const contentTypes = (store.content || '').split(',').map(ct => ct.trim()).filter(ct => ct); - contentTypes.sort(); - const contentBadges = contentTypes.map(ct => { - const details = getContentBadgeDetails(ct); - return `${ct}`; - }).join(''); + // Use cached content badge HTML instead of processing inline + const contentBadges = getContentBadgesHTML(store.content); row.innerHTML = ` ${store.storage || 'N/A'} - ${contentBadges || '-'} + ${contentBadges} ${store.type || 'N/A'} ${sharedIcon} ${usageBarHTML} diff --git a/src/public/js/ui/thresholds.js b/src/public/js/ui/thresholds.js index ad94b435a..a722dcdaa 100644 --- a/src/public/js/ui/thresholds.js +++ b/src/public/js/ui/thresholds.js @@ -6,14 +6,12 @@ PulseApp.ui.thresholds = (() => { let thresholdBadge = null; let sliders = {}; let thresholdSelects = {}; - let startLogButton = null; let isDraggingSlider = false; function init() { thresholdRow = document.getElementById('threshold-slider-row'); toggleThresholdsButton = document.getElementById('toggle-thresholds-button'); thresholdBadge = document.getElementById('threshold-count-badge'); - startLogButton = document.getElementById('start-log-button'); sliders = { cpu: document.getElementById('threshold-slider-cpu'), @@ -30,7 +28,6 @@ PulseApp.ui.thresholds = (() => { applyInitialThresholdUI(); updateThresholdIndicator(); updateThresholdRowVisibility(); - updateLogControlsVisibility(); if (toggleThresholdsButton) { toggleThresholdsButton.addEventListener('click', () => { @@ -113,8 +110,6 @@ PulseApp.ui.thresholds = (() => { document.addEventListener('touchend', _handleThresholdDragEnd); } - // Old setupThresholdListeners function removed. - function updateThreshold(type, value) { PulseApp.state.setThresholdValue(type, value); @@ -124,7 +119,6 @@ PulseApp.ui.thresholds = (() => { console.warn('[Thresholds] PulseApp.ui.dashboard not available for updateDashboardTable'); } updateThresholdIndicator(); - updateLogControlsVisibility(); } function updateThresholdRowVisibility() { @@ -196,38 +190,6 @@ PulseApp.ui.thresholds = (() => { PulseApp.state.set('isThresholdRowVisible', false); updateThresholdRowVisibility(); updateThresholdIndicator(); - updateLogControlsVisibility(); // Ensure log button visibility updates - } - - function updateLogControlsVisibility() { - if (!startLogButton) return; - - let isAnyFilterActive = false; - const thresholdState = PulseApp.state.getThresholdState(); - const searchInput = document.getElementById('dashboard-search'); - const filterGuestType = PulseApp.state.get('filterGuestType'); - const filterStatus = PulseApp.state.get('filterStatus'); - - for (const type in thresholdState) { - if (thresholdState[type].value > 0) { - isAnyFilterActive = true; - break; - } - } - - if (!isAnyFilterActive && searchInput && searchInput.value.trim() !== '') { - isAnyFilterActive = true; - } - - if (!isAnyFilterActive && filterGuestType !== 'all') { - isAnyFilterActive = true; - } - - if (!isAnyFilterActive && filterStatus !== 'all') { - isAnyFilterActive = true; - } - - startLogButton.classList.toggle('hidden', !isAnyFilterActive); } // Getter for dashboard.js to check drag state @@ -238,7 +200,6 @@ PulseApp.ui.thresholds = (() => { return { init, resetThresholds, - updateLogControlsVisibility, isThresholdDragInProgress }; })(); diff --git a/src/public/js/utils.js b/src/public/js/utils.js index 3b3212069..07ea39798 100644 --- a/src/public/js/utils.js +++ b/src/public/js/utils.js @@ -1,8 +1,27 @@ PulseApp.utils = (() => { - function getUsageColor(percentage) { - if (percentage >= 90) return 'red'; - if (percentage >= 75) return 'yellow'; - return 'green'; + function getUsageColor(percentage, metric = 'generic') { + // Progress bars use traditional green/yellow/red with metric-specific thresholds + if (metric === 'cpu') { + // CPU: show color for significant usage + if (percentage >= 90) return 'red'; + if (percentage >= 80) return 'yellow'; + return 'green'; // Healthy green for normal CPU usage + } else if (metric === 'memory') { + // Memory: be more conservative due to critical nature + if (percentage >= 85) return 'red'; + if (percentage >= 75) return 'yellow'; + return 'green'; // Healthy green for normal memory usage + } else if (metric === 'disk') { + // Disk: can run higher before concerning + if (percentage >= 90) return 'red'; + if (percentage >= 80) return 'yellow'; + return 'green'; // Healthy green for normal disk usage + } else { + // Generic/legacy fallback (for other uses like storage, etc.) + if (percentage >= 90) return 'red'; + if (percentage >= 75) return 'yellow'; + return 'green'; // Keep green for non-dashboard usage + } } function createProgressTextBarHTML(percentage, text, color) { @@ -37,6 +56,32 @@ PulseApp.utils = (() => { return formatBytes(bytesPerSecond, decimals) + '/s'; } + function formatSpeedWithStyling(bytesPerSecond, decimals = 1) { + if (bytesPerSecond === null || bytesPerSecond === undefined) return 'N/A'; + + let formattedSpeed; + if (bytesPerSecond < 1) { + formattedSpeed = '0 B/s'; + } else { + formattedSpeed = formatBytes(bytesPerSecond, decimals) + '/s'; + } + + // Use same absolute thresholds as chart logic + const mbps = bytesPerSecond / (1024 * 1024); + const isDarkMode = document.documentElement.classList.contains('dark'); + + let textClass = ''; + if (mbps < 1) { + // Not noteworthy - use theme-adaptive dim gray + textClass = isDarkMode ? 'text-gray-400' : 'text-gray-300'; + } else { + // Noteworthy - use normal text color + textClass = 'text-gray-800 dark:text-gray-200'; + } + + return `${formattedSpeed}`; + } + function formatUptime(seconds) { if (seconds === null || seconds === undefined || seconds < 0) return 'N/A'; if (seconds < 60) return `${Math.floor(seconds)}s`; @@ -197,6 +242,7 @@ PulseApp.utils = (() => { createProgressTextBarHTML, formatBytes, formatSpeed, + formatSpeedWithStyling, formatUptime, formatDuration, formatPbsTimestamp,