diff --git a/src/public/js/config.js b/src/public/js/config.js new file mode 100644 index 000000000..fb54890e2 --- /dev/null +++ b/src/public/js/config.js @@ -0,0 +1,6 @@ +const PulseApp = window.PulseApp || {}; + +PulseApp.config = { + AVERAGING_WINDOW_SIZE: 5, + INITIAL_PBS_TASK_LIMIT: 5 +}; \ No newline at end of file diff --git a/src/public/js/hotReload.js b/src/public/js/hotReload.js new file mode 100644 index 000000000..e9ab65e2e --- /dev/null +++ b/src/public/js/hotReload.js @@ -0,0 +1,21 @@ +(function setupHotReload() { + const socket = io(); + + socket.on('hotReload', function() { + window.location.reload(); + }); + + let wasConnected = false; + socket.on('connect', function() { + if (wasConnected) { + console.log('Reconnected - refreshing page'); + setTimeout(() => window.location.reload(), 500); + } + wasConnected = true; + }); + + socket.on('disconnect', function(reason) { + wasConnected = false; + }); + +})(); \ No newline at end of file diff --git a/src/public/js/main.js b/src/public/js/main.js new file mode 100644 index 000000000..3187e548b --- /dev/null +++ b/src/public/js/main.js @@ -0,0 +1,108 @@ +document.addEventListener('DOMContentLoaded', function() { + const PulseApp = window.PulseApp || {}; + + function initializeModules() { + PulseApp.state?.init?.(); // Although state is IIFE, init might be added later + PulseApp.config?.init?.(); // Although config is obj literal, init might be added later + PulseApp.utils?.init?.(); // Although utils is obj literal, init might be added later + PulseApp.theme?.init?.(); + PulseApp.socketHandler?.init?.(); + PulseApp.tooltips?.init?.(); + + PulseApp.ui = PulseApp.ui || {}; + PulseApp.ui.tabs?.init?.(); + PulseApp.ui.nodes?.init?.(); + PulseApp.ui.dashboard?.init?.(); + PulseApp.ui.storage?.init?.(); + PulseApp.ui.pbs?.initPbsEventListeners?.(); // Specific init for PBS listeners + PulseApp.ui.backups?.init?.(); + PulseApp.ui.thresholds?.init?.(); + PulseApp.ui.common?.init?.(); + + PulseApp.thresholds = PulseApp.thresholds || {}; + PulseApp.thresholds.logging?.init?.(); + } + + function validateCriticalElements() { + const criticalElements = [ + 'connection-status', + 'main-table', // Check for table itself + // 'custom-tooltip', // Non-critical + // 'slider-value-tooltip', // Non-critical + 'dashboard-search', // Optional but important + 'dashboard-status-text', // Status display + 'app-version' // Version display + ]; + let allFound = true; + criticalElements.forEach(id => { + if (!document.getElementById(id)) { + console.error(`Critical element #${id} not found!`); + // allFound = false; // Decide if you want to stop execution + } + }); + // Check for table body specifically needed by dashboard updates + if (!document.querySelector('#main-table tbody')) { + console.error('Critical element #main-table tbody not found!'); + // allFound = false; + } + return allFound; + } + + function fetchVersion() { + const versionSpan = document.getElementById('app-version'); + fetch('/api/version') + .then(response => response.json()) + .then(data => { + if (versionSpan && data.version) { + versionSpan.textContent = data.version; + } + }) + .catch(error => { + console.error('Error fetching version:', error); + if (versionSpan) { + versionSpan.textContent = 'error'; + } + }); + } + + function updateAllUITables() { + const nodesData = PulseApp.state.get('nodesData'); + const pbsDataArray = PulseApp.state.get('pbsDataArray'); + const storageData = PulseApp.state.get('storageData'); + + PulseApp.ui.nodes?.updateNodesTable(nodesData); + PulseApp.ui.dashboard?.updateDashboardTable(); // Refreshes data internally + PulseApp.ui.storage?.updateStorageInfo(); // Uses state internaly + PulseApp.ui.pbs?.updatePbsInfo(pbsDataArray); + PulseApp.ui.backups?.updateBackupsTab(); + + const loadingOverlay = document.getElementById('loading-overlay'); + if (loadingOverlay && loadingOverlay.style.display !== 'none') { + if (PulseApp.socketHandler.isConnected()) { + console.log('[UI Update] Hiding loading overlay.'); + loadingOverlay.style.display = 'none'; + } else { + } + } + } + + // --- Main Execution --- + if (!validateCriticalElements()) { + console.error("Stopping JS execution due to missing critical elements."); + // Optionally display a user-facing error message here + return; + } + + initializeModules(); + fetchVersion(); + PulseApp.ui.storage.fetchStorageData(); // Initial fetch + + setInterval(() => { + if (PulseApp.state.get('initialDataReceived')) { + updateAllUITables(); + PulseApp.thresholds.logging?.checkThresholdViolations(); + } + }, 2000); // UI update interval + + setInterval(PulseApp.ui.storage.fetchStorageData, 30000); // Storage fetch interval +}); \ No newline at end of file diff --git a/src/public/js/socketHandler.js b/src/public/js/socketHandler.js new file mode 100644 index 000000000..4d86ea01c --- /dev/null +++ b/src/public/js/socketHandler.js @@ -0,0 +1,136 @@ +PulseApp.socketHandler = (() => { + let socket = null; + + function init() { + socket = io(); + + socket.on('connect', handleConnect); + socket.on('disconnect', handleDisconnect); + socket.on('rawData', handleRawData); + socket.on('pbsInitialStatus', handlePbsInitialStatus); + + socket.onAny((eventName, ...args) => { + }); + } + + function handleConnect() { + const connectionStatus = document.getElementById('connection-status'); + if (connectionStatus) { + connectionStatus.textContent = 'Connected'; + connectionStatus.classList.remove('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'); + connectionStatus.classList.add('connected', 'bg-green-100', 'dark:bg-green-800/30', 'text-green-700', 'dark:text-green-300'); + } + requestFullData(); + } + + function handleDisconnect(reason) { + const connectionStatus = document.getElementById('connection-status'); + if (connectionStatus) { + connectionStatus.textContent = 'Disconnected'; + connectionStatus.classList.remove('connected', 'bg-green-100', 'dark:bg-green-800/30', 'text-green-700', 'dark:text-green-300'); + connectionStatus.classList.add('disconnected', 'bg-red-100', 'dark:bg-red-800/30', 'text-red-700', 'dark:text-red-300'); + } + PulseApp.state.set('wasConnected', false); + + const loadingOverlay = document.getElementById('loading-overlay'); + if (loadingOverlay) { + const loadingText = loadingOverlay.querySelector('p'); + if (loadingText) { + loadingText.textContent = 'Connection lost.'; + + } + loadingOverlay.style.display = 'flex'; + } + } + + function handleRawData(jsonData) { + try { + const data = typeof jsonData === 'string' ? JSON.parse(jsonData) : jsonData; + + PulseApp.state.set('nodesData', data.nodes || []); + PulseApp.state.set('vmsData', data.vms || []); + PulseApp.state.set('containersData', data.containers || []); + + if (data.hasOwnProperty('metrics')) { + PulseApp.state.set('metricsData', data.metrics || []); + } else { + } + + if (data.hasOwnProperty('pbs')) { + PulseApp.state.set('pbsDataArray', Array.isArray(data.pbs) ? data.pbs : []); + } else { + } + + if (PulseApp.ui && PulseApp.ui.tabs) { + PulseApp.ui.tabs.updateTabAvailability(); + } else { + console.warn('[socketHandler] PulseApp.ui.tabs not available for updateTabAvailability'); + } + + + if (!PulseApp.state.get('initialDataReceived')) { + PulseApp.state.set('initialDataReceived', true); + + } + + + } catch (e) { + console.error('Error processing received rawData:', e, jsonData); + } + } + + function handlePbsInitialStatus(pbsStatusArray) { + if (Array.isArray(pbsStatusArray)) { + const initialPbsData = pbsStatusArray.map(statusInfo => ({ + ...statusInfo, + backupTasks: { recentTasks: [], summary: {} }, + datastores: [], + verificationTasks: { summary: {} }, + syncTasks: { summary: {} }, + pruneTasks: { summary: {} }, + nodeName: null + })); + PulseApp.state.set('pbsDataArray', initialPbsData); + + if (PulseApp.ui && PulseApp.ui.pbs) { + PulseApp.ui.pbs.updatePbsInfo(initialPbsData); + } else { + console.warn('[socketHandler] PulseApp.ui.pbs not available for updatePbsInfo'); + } + if (PulseApp.ui && PulseApp.ui.tabs) { + PulseApp.ui.tabs.updateTabAvailability(); + } else { + console.warn('[socketHandler] PulseApp.ui.tabs not available for updateTabAvailability'); + } + } else { + console.warn('[socket] Received non-array data for pbsInitialStatus:', pbsStatusArray); + } + } + + function requestFullData() { + console.log('Requesting full data reload from server...'); + const loadingOverlay = document.getElementById('loading-overlay'); + if (loadingOverlay) { + const loadingText = loadingOverlay.querySelector('p'); + if (loadingText) { + loadingText.textContent = 'Connected. Reloading data...'; + } + loadingOverlay.style.display = 'flex'; + } + if (socket) { + socket.emit('requestData'); + } else { + console.error('Cannot request data: socket not initialized.'); + } + } + + function isConnected() { + return socket && socket.connected; + } + + return { + init, + requestFullData, + isConnected + }; +})(); \ No newline at end of file diff --git a/src/public/js/state.js b/src/public/js/state.js new file mode 100644 index 000000000..97924312c --- /dev/null +++ b/src/public/js/state.js @@ -0,0 +1,129 @@ +PulseApp.state = (() => { + const savedSortState = JSON.parse(localStorage.getItem('pulseSortState')) || {}; + const savedFilterState = JSON.parse(localStorage.getItem('pulseFilterState')) || {}; + const savedThresholdState = JSON.parse(localStorage.getItem('pulseThresholdState')) || {}; + + let internalState = { + nodesData: [], + vmsData: [], + containersData: [], + metricsData: [], + dashboardData: [], + pbsDataArray: [], + storageData: {}, + dashboardHistory: {}, + initialDataReceived: false, + isThresholdRowVisible: false, + groupByNode: savedFilterState.groupByNode ?? true, + filterGuestType: savedFilterState.filterGuestType || 'all', + filterStatus: savedFilterState.filterStatus || 'all', + backupsFilterHealth: savedFilterState.backupsFilterHealth || 'all', + backupsFilterGuestType: savedFilterState.backupsFilterGuestType || 'all', + backupsSearchTerm: '', + sortState: { + nodes: { column: null, direction: 'asc', ...(savedSortState.nodes || {}) }, + main: { column: 'id', direction: 'asc', ...(savedSortState.main || {}) }, + backups: { column: 'latestBackupTime', direction: 'desc', ...(savedSortState.backups || {}) } + }, + thresholdState: { + cpu: { value: 0 }, + memory: { value: 0 }, + disk: { value: 0 }, + diskread: { value: 0 }, + diskwrite:{ value: 0 }, + netin: { value: 0 }, + netout: { value: 0 } + }, + activeLogSessions: {}, + thresholdLogEntries: [], + activeLoggingThresholds: null + }; + + for (const type in internalState.thresholdState) { + if (savedThresholdState.hasOwnProperty(type)) { + if (internalState.thresholdState[type].hasOwnProperty('operator')) { + internalState.thresholdState[type].operator = savedThresholdState[type]?.operator || '>='; + internalState.thresholdState[type].input = savedThresholdState[type]?.input || ''; + } else { + internalState.thresholdState[type].value = savedThresholdState[type]?.value || 0; + } + } + } + + function saveFilterState() { + const stateToSave = { + groupByNode: internalState.groupByNode, + filterGuestType: internalState.filterGuestType, + filterStatus: internalState.filterStatus, + backupsFilterHealth: internalState.backupsFilterHealth, + backupsFilterGuestType: internalState.backupsFilterGuestType + }; + localStorage.setItem('pulseFilterState', JSON.stringify(stateToSave)); + localStorage.setItem('pulseThresholdState', JSON.stringify(internalState.thresholdState)); + } + + function saveSortState() { + const stateToSave = { + nodes: internalState.sortState.nodes, + main: internalState.sortState.main, + backups: internalState.sortState.backups + }; + localStorage.setItem('pulseSortState', JSON.stringify(stateToSave)); + } + + return { + get: (key) => internalState[key], + set: (key, value) => { + internalState[key] = value; + if (['groupByNode', 'filterGuestType', 'filterStatus', 'backupsFilterHealth', 'backupsFilterGuestType', 'thresholdState'].includes(key)) { + saveFilterState(); + } + }, + setSortState: (tableType, column, direction) => { + if (internalState.sortState[tableType]) { + internalState.sortState[tableType] = { column, direction }; + saveSortState(); + } else { + console.warn(`Attempted to set sort state for unknown table type: ${tableType}`); + } + }, + getSortState: (tableType) => internalState.sortState[tableType], + saveFilterState: saveFilterState, + getThresholdState: () => internalState.thresholdState, + setThresholdValue: (type, value) => { + if (internalState.thresholdState[type]) { + internalState.thresholdState[type].value = parseInt(value) || 0; + saveFilterState(); + } else { + 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])) { + internalState.dashboardHistory[guestId] = []; + } + const history = internalState.dashboardHistory[guestId]; + history.push(dataPoint); + if (history.length > PulseApp.config.AVERAGING_WINDOW_SIZE) history.shift(); + }, + clearDashboardHistoryEntry: (guestId) => { + delete internalState.dashboardHistory[guestId]; + } + }; +})(); \ No newline at end of file diff --git a/src/public/js/tabs.js b/src/public/js/tabs.js new file mode 100644 index 000000000..66450091b --- /dev/null +++ b/src/public/js/tabs.js @@ -0,0 +1,254 @@ +PulseApp.ui = PulseApp.ui || {}; + +PulseApp.ui.tabs = (() => { + let tabs = []; + let tabContents = []; + let nestedTabsContainer = null; + let nestedTabContentContainer = null; + let logSessionArea = null; + + function init() { + tabs = document.querySelectorAll('.tab'); + tabContents = document.querySelectorAll('.tab-content'); + nestedTabsContainer = document.querySelector('.nested-tabs'); + nestedTabContentContainer = document.querySelector('#log-content-area'); + logSessionArea = document.getElementById('log-session-area'); + + tabs.forEach(tab => { + tab.addEventListener('click', () => { + const tabId = tab.getAttribute('data-tab'); + activateMainTab(tab, tabId); + }); + }); + + if (nestedTabsContainer) { + nestedTabsContainer.addEventListener('click', (event) => { + const nestedTab = event.target.closest('.nested-tab'); + if (nestedTab) { + const nestedTabId = nestedTab.getAttribute('data-nested-tab'); + if (nestedTabId) { + activateNestedTab(nestedTabId); + } + } + }); + } + } + + function activateMainTab(clickedTab, tabId) { + if (clickedTab.classList.contains('pointer-events-none')) return; // Don't activate disabled tabs + + tabs.forEach(t => { + t.classList.remove('active', 'bg-white', 'dark:bg-gray-800', 'border-gray-300', 'dark:border-gray-700', 'text-gray-900', 'dark:text-white', '-mb-px'); + t.classList.add('bg-gray-100', 'dark:bg-gray-700/50', 'border-transparent', 'text-gray-600', 'dark:text-gray-400', 'hover:bg-gray-200', 'dark:hover:bg-gray-700'); + }); + tabContents.forEach(content => content.classList.add('hidden')); + + clickedTab.classList.add('active', 'bg-white', 'dark:bg-gray-800', 'border-gray-300', 'dark:border-gray-700', 'text-gray-900', 'dark:text-white', '-mb-px'); + clickedTab.classList.remove('bg-gray-100', 'dark:bg-gray-700/50', 'border-transparent', 'text-gray-600', 'dark:text-gray-400', 'hover:bg-gray-200', 'dark:hover:bg-gray-700'); + + const activeContent = document.getElementById(tabId); + if (activeContent) { + activeContent.classList.remove('hidden'); + + if (tabId === 'main') { + activateNestedTab('nested-tab-dashboard'); + if (PulseApp.ui && PulseApp.ui.dashboard) { + PulseApp.ui.dashboard.updateDashboardTable(); + } else { + console.warn('[Tabs] PulseApp.ui.dashboard not available for updateDashboardTable') + } + } + + if (tabId === 'backups') { + if (PulseApp.ui && PulseApp.ui.backups) { + PulseApp.ui.backups.updateBackupsTab(); + } else { + console.warn('[Tabs] PulseApp.ui.backups not available for updateBackupsTab') + } + } + } + } + + function activateNestedTab(targetId) { + if (nestedTabsContainer) { + nestedTabsContainer.querySelectorAll('.nested-tab').forEach(nt => { + nt.classList.remove('active', 'text-blue-600', 'border-blue-600'); + nt.classList.add('text-gray-500', 'hover:text-gray-700', 'dark:text-gray-400', 'dark:hover:text-gray-200', 'border-transparent', 'hover:border-gray-300', 'dark:hover:border-gray-600'); + }); + } + + if (nestedTabContentContainer) { + nestedTabContentContainer.querySelectorAll('.log-session-panel-container').forEach(panelContainer => { + panelContainer.classList.add('hidden'); + }); + } + + const targetTab = nestedTabsContainer?.querySelector(`.nested-tab[data-nested-tab="${targetId}"]`); + if (targetTab) { + targetTab.classList.add('active', 'text-blue-600', 'border-blue-600'); + targetTab.classList.remove('text-gray-500', 'hover:text-gray-700', 'dark:text-gray-400', 'dark:hover:text-gray-200', 'border-transparent', 'hover:border-gray-300', 'dark:hover:border-gray-600'); + } + + const targetPanelContainer = nestedTabContentContainer?.querySelector(`#${targetId}`); + if (targetPanelContainer) { + targetPanelContainer.classList.remove('hidden'); + if (logSessionArea) logSessionArea.classList.remove('hidden'); + } + } + + function updateTabAvailability() { + const pbsTab = document.querySelector('.tab[data-tab="pbs"]'); + const backupsTab = document.querySelector('.tab[data-tab="backups"]'); + + if (!pbsTab || !backupsTab) { + console.warn("PBS or Backups tab element not found for availability update."); + return; + } + + const pbsDataArray = PulseApp.state.get('pbsDataArray') || []; + const isPbsAvailable = pbsDataArray.length > 0 && pbsDataArray.some(pbs => pbs.status === 'ok'); + + const disabledClasses = ['opacity-50', 'cursor-not-allowed', 'pointer-events-none']; + const enabledClasses = ['hover:bg-gray-200', 'dark:hover:bg-gray-700', 'cursor-pointer']; + + [pbsTab, backupsTab].forEach(tab => { + if (!isPbsAvailable) { + tab.classList.add(...disabledClasses); + tab.classList.remove(...enabledClasses); + tab.setAttribute('title', 'Requires PBS integration to be configured and connected.'); + tab.classList.remove('active', 'bg-white', 'dark:bg-gray-800', 'border-gray-300', 'dark:border-gray-700'); + tab.classList.add('bg-gray-100', 'dark:bg-gray-700/50', 'border-transparent'); + + } else { + tab.classList.remove(...disabledClasses); + tab.classList.add(...enabledClasses); + tab.removeAttribute('title'); + } + }); + } + + function addLogTab(sessionId, sessionTitle, fullCriteriaDesc) { + if (!nestedTabsContainer || !nestedTabContentContainer || !logSessionArea) { + console.error("Log tab/content container or session area not found!"); + return null; // Indicate failure + } + const nestedTabId = `log-session-${sessionId}`; + const startTime = new Date(sessionId); + const shortTitle = startTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + + const newTab = document.createElement('div'); + newTab.className = 'nested-tab px-3 py-1.5 cursor-pointer text-sm font-medium text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 border-b-2 border-transparent hover:border-gray-300 dark:hover:border-gray-600 flex items-center gap-1'; + newTab.dataset.nestedTab = nestedTabId; + newTab.innerHTML = ` + + ${shortTitle} + `; + + const tabTitleSpan = newTab.querySelector('.log-tab-title'); + if (tabTitleSpan) { + tabTitleSpan.addEventListener('dblclick', () => { + tabTitleSpan.classList.add('hidden'); + const input = document.createElement('input'); + input.type = 'text'; + input.value = tabTitleSpan.textContent; + input.className = 'log-tab-rename-input flex-grow p-0 px-1 h-5 text-xs border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-200 focus:ring-1 focus:ring-blue-500 focus:border-blue-500 outline-none'; + input.style.maxWidth = '150px'; + + const finalizeRename = () => { + const newName = input.value.trim(); + if (newName) tabTitleSpan.textContent = newName; + input.remove(); + tabTitleSpan.classList.remove('hidden'); + }; + const cancelRename = () => { + input.remove(); + tabTitleSpan.classList.remove('hidden'); + }; + + input.addEventListener('blur', finalizeRename); + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') finalizeRename(); + else if (e.key === 'Escape') cancelRename(); + }); + const timerSpan = newTab.querySelector('.log-timer-display'); + if (timerSpan) newTab.insertBefore(input, timerSpan); + else newTab.appendChild(input); + input.focus(); + input.select(); + }); + } + + const tabCloseButton = document.createElement('button'); + tabCloseButton.className = 'ml-auto pl-2 text-gray-400 hover:text-red-600 dark:hover:text-red-400 opacity-50 hover:opacity-100 transition-opacity'; + tabCloseButton.innerHTML = ''; + tabCloseButton.title = 'Close & Stop Log'; + tabCloseButton.onclick = (event) => { + event.stopPropagation(); + const tabElement = event.currentTarget.closest('.nested-tab'); + if (!tabElement) return; + const tabSessionId = tabElement.dataset.nestedTab; + const tabSessionIdStr = tabSessionId.replace('log-session-', ''); + + if (PulseApp.state.getActiveLogSession(tabSessionIdStr)) { + PulseApp.thresholds.logging.stopThresholdLogging(tabSessionIdStr, 'manual'); + } + + const contentToRemove = nestedTabContentContainer.querySelector(`#${tabSessionId}`); + tabElement.remove(); + if (contentToRemove) contentToRemove.remove(); + + if (PulseApp.thresholds.logging) PulseApp.thresholds.logging.updateClearAllButtonVisibility(); + + if (tabElement.classList.contains('active')) { + activateNestedTab('nested-tab-dashboard'); + } + + if (nestedTabsContainer && logSessionArea && nestedTabsContainer.querySelectorAll('.nested-tab[data-nested-tab^="log-session-"]').length === 0) { + logSessionArea.classList.add('hidden'); + } + }; + newTab.appendChild(tabCloseButton); + + const newContent = document.createElement('div'); + newContent.id = nestedTabId; + newContent.className = 'log-session-panel-container hidden'; + + nestedTabsContainer.appendChild(newTab); + nestedTabContentContainer.appendChild(newContent); + logSessionArea.classList.remove('hidden'); + + activateNestedTab(nestedTabId); + + return newContent; // Return the content container div + } + + function removeLogTabAndContent(sessionId) { + const tabToRemove = nestedTabsContainer?.querySelector(`.nested-tab[data-nested-tab="log-session-${sessionId}"]`); + const contentToRemove = nestedTabContentContainer?.querySelector(`#log-session-${sessionId}`); + + if (tabToRemove) { + const wasActive = tabToRemove.classList.contains('active'); + tabToRemove.remove(); + if (contentToRemove) contentToRemove.remove(); + + if (PulseApp.thresholds.logging) PulseApp.thresholds.logging.updateClearAllButtonVisibility(); + + if (wasActive) { + activateNestedTab('nested-tab-dashboard'); + } + + if (nestedTabsContainer && logSessionArea && nestedTabsContainer.querySelectorAll('.nested-tab[data-nested-tab^="log-session-"]').length === 0) { + logSessionArea.classList.add('hidden'); + } + } + } + + + return { + init, + activateNestedTab, + updateTabAvailability, + addLogTab, + removeLogTabAndContent + }; +})(); \ No newline at end of file diff --git a/src/public/js/theme.js b/src/public/js/theme.js new file mode 100644 index 000000000..e6daec651 --- /dev/null +++ b/src/public/js/theme.js @@ -0,0 +1,35 @@ +PulseApp.theme = (() => { + const htmlElement = document.documentElement; + const prefersDarkScheme = window.matchMedia('(prefers-color-scheme: dark)'); + let themeToggleButton = null; + + function applyTheme(theme) { + if (theme === 'dark') { + htmlElement.classList.add('dark'); + localStorage.setItem('theme', 'dark'); + } else { + htmlElement.classList.remove('dark'); + localStorage.setItem('theme', 'light'); + } + } + + function init() { + themeToggleButton = document.getElementById('theme-toggle-button'); + const savedTheme = localStorage.getItem('theme'); + const initialTheme = savedTheme || (prefersDarkScheme.matches ? 'dark' : 'light'); + applyTheme(initialTheme); + + if (themeToggleButton) { + themeToggleButton.addEventListener('click', function() { + const currentIsDark = htmlElement.classList.contains('dark'); + applyTheme(currentIsDark ? 'light' : 'dark'); + }); + } else { + console.warn('Element #theme-toggle-button not found - theme switching disabled.'); + } + } + + return { + init + }; +})(); \ No newline at end of file diff --git a/src/public/js/thresholds/logging.js b/src/public/js/thresholds/logging.js new file mode 100644 index 000000000..ffde28431 --- /dev/null +++ b/src/public/js/thresholds/logging.js @@ -0,0 +1,398 @@ +PulseApp.thresholds = PulseApp.thresholds || {}; + +PulseApp.thresholds.logging = (() => { + let clearAllLogsButton = null; + + function init() { + clearAllLogsButton = document.getElementById('clear-all-logs-button'); + + const startLogButton = document.getElementById('start-log-button'); + if (startLogButton) { + startLogButton.addEventListener('click', startThresholdLogging); + } else { + console.warn('#start-log-button not found.'); + } + + if (clearAllLogsButton) { + clearAllLogsButton.addEventListener('click', clearAllFinishedLogs); + } else { + console.warn('#clear-all-logs-button not found.'); + } + + updateClearAllButtonVisibility(); // Initial check + } + + function startThresholdLogging() { + const thresholdState = PulseApp.state.getThresholdState(); + const filterGuestType = PulseApp.state.get('filterGuestType'); + const filterStatus = PulseApp.state.get('filterStatus'); + const searchInput = document.getElementById('dashboard-search'); + + const snapshottedThresholds = {}; + let activeThresholdCount = 0; + let criteriaDescThresholds = []; + for (const type in thresholdState) { + const value = thresholdState[type].value; + if (value > 0) { + snapshottedThresholds[type] = value; + activeThresholdCount++; + criteriaDescThresholds.push(`${PulseApp.utils.getReadableThresholdName(type)}>=${PulseApp.utils.formatThresholdValue(type, value)}`); + } + } + + const snapshottedFilterGuestType = filterGuestType; + const snapshottedFilterStatus = filterStatus; + let criteriaDescFilters = []; + if (snapshottedFilterGuestType !== 'all') criteriaDescFilters.push(`Type: ${snapshottedFilterGuestType.toUpperCase()}`); + if (snapshottedFilterStatus !== 'all') criteriaDescFilters.push(`Status: ${snapshottedFilterStatus}`); + + const snapshottedRawSearch = searchInput ? searchInput.value.toLowerCase() : ''; + const snapshottedSearchTerms = snapshottedRawSearch.split(',').map(term => term.trim()).filter(term => term); + let criteriaDescSearch = snapshottedSearchTerms.length > 0 ? `Search: "${snapshottedSearchTerms.join(', ')}"` : null; + + if (activeThresholdCount === 0 && snapshottedSearchTerms.length === 0 && criteriaDescFilters.length === 0) { + alert("Please set at least one threshold, enter search terms, or select a Type/Status filter before starting the log."); + const toggleThresholdsButton = document.getElementById('toggle-thresholds-button'); + if (toggleThresholdsButton && !PulseApp.state.get('isThresholdRowVisible') && activeThresholdCount === 0) { + toggleThresholdsButton.click(); + } + return; + } + + const sessionId = Date.now(); + const startTime = new Date(); + let fullCriteriaDesc = [criteriaDescFilters.join('; '), criteriaDescSearch, criteriaDescThresholds.length > 0 ? `Thresholds: ${criteriaDescThresholds.join(', ')}`: null].filter(Boolean).join('; '); + const sessionTitle = `Log @ ${startTime.toLocaleTimeString()}${fullCriteriaDesc ? ` (${fullCriteriaDesc})` : ''}`; + + const logContentContainer = PulseApp.ui.tabs.addLogTab(sessionId, sessionTitle, fullCriteriaDesc); + if (!logContentContainer) return; // Stop if tab/content creation failed + + const panel = document.createElement('div'); + panel.id = `log-session-panel-${sessionId}`; + panel.className = 'log-session-panel border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 shadow-md'; + + const header = document.createElement('div'); + header.className = 'log-session-header flex justify-between items-center p-2 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/50 rounded-t'; + + const titleSpan = document.createElement('span'); + titleSpan.className = 'text-sm font-medium text-gray-700 dark:text-gray-200 truncate'; + titleSpan.textContent = sessionTitle; + titleSpan.title = sessionTitle; + + const panelCloseButton = document.createElement('button'); + panelCloseButton.className = 'p-1 rounded text-gray-500 hover:bg-red-100 dark:text-gray-400 dark:hover:bg-red-800/50'; + panelCloseButton.innerHTML = ''; + panelCloseButton.title = 'Close Log Panel & Stop Log (if active)'; + + panelCloseButton.onclick = (event) => { + const panelToRemove = event.currentTarget.closest('.log-session-panel'); + if (panelToRemove) { + const panelSessionIdStr = panelToRemove.id.replace('log-session-panel-', ''); + PulseApp.ui.tabs.removeLogTabAndContent(panelSessionIdStr); + // Stop session is handled implicitly by removeLogTabAndContent calling the tab close handler + } + }; + + header.appendChild(titleSpan); + header.appendChild(panelCloseButton); + + const tableContainer = document.createElement('div'); + tableContainer.className = 'log-table-container p-2 max-h-96 overflow-y-auto'; + + const table = document.createElement('table'); + table.id = `log-table-${sessionId}`; + table.className = 'min-w-full text-xs border-collapse'; + table.innerHTML = ` + + + Time + Guest + Node + CPU + Mem + Disk% + DRead + DWrite + NetIn + NetOut + + + + + + Logging started continuously... + + + + `; + + tableContainer.appendChild(table); + panel.appendChild(header); + panel.appendChild(tableContainer); + logContentContainer.appendChild(panel); // Add the panel to the content area created by addLogTab + + PulseApp.state.addActiveLogSession(sessionId, { + thresholds: snapshottedThresholds, + startTime: startTime, + entries: [], + element: panel, + searchTerms: snapshottedSearchTerms, + filterGuestType: snapshottedFilterGuestType, + filterStatus: snapshottedFilterStatus + }); + + console.log(`[Log Session ${sessionId}] Started. Thresholds:`, snapshottedThresholds); + updateClearAllButtonVisibility(); // Might hide the button if only active logs exist + } + + function stopThresholdLogging(sessionId, reason = 'manual') { + const session = PulseApp.state.getActiveLogSession(sessionId); + if (!session) { + console.warn(`Attempted to stop non-existent log session: ${sessionId}`); + return; + } + + console.log(`[Log Session ${sessionId}] Stopping. Reason: ${reason}`); + + if (session.element) { + const tableBody = session.element.querySelector(`#log-table-${sessionId} tbody`); + if (tableBody) { + const initialMsgRow = tableBody.querySelector('.initial-log-message'); + if (initialMsgRow) initialMsgRow.remove(); + + const finalStatusRow = tableBody.insertRow(-1); + finalStatusRow.className = 'final-log-message'; + const cell = finalStatusRow.insertCell(0); + cell.colSpan = 10; // Adjusted colspan + cell.className = 'p-1 px-2 text-center text-xs text-gray-500 dark:text-gray-400 italic border-t border-gray-200 dark:border-gray-700'; + const stopTime = new Date().toLocaleTimeString(); + if (reason === 'timer') { + cell.textContent = `Logging finished (timer expired) at ${stopTime}`; + } else { + cell.textContent = `Logging stopped manually at ${stopTime}`; + } + } + } + + PulseApp.state.removeActiveLogSession(sessionId); + updateClearAllButtonVisibility(); // Show the button as this log is now finished + } + + function addLogRow(sessionId, entry) { + const session = PulseApp.state.getActiveLogSession(sessionId); + if (!session || !session.element) return; + + const tableBody = session.element.querySelector(`#log-table-${sessionId} tbody`); + if (!tableBody) return; + + const initialMsgRow = tableBody.querySelector('.initial-log-message'); + if (initialMsgRow) initialMsgRow.remove(); + + const row = tableBody.insertRow(0); + row.className = 'bg-yellow-50 dark:bg-yellow-900/20 animate-pulse-once'; + + const cpuValueHTML = entry.activeThresholdKeys.includes('cpu') ? `${entry.cpuFormatted}` : entry.cpuFormatted; + const memValueHTML = entry.activeThresholdKeys.includes('memory') ? `${entry.memFormatted}` : entry.memFormatted; + const diskValueHTML = entry.activeThresholdKeys.includes('disk') ? `${entry.diskFormatted}` : entry.diskFormatted; + const diskReadValueHTML = entry.activeThresholdKeys.includes('diskread') ? `${entry.diskReadFormatted}` : entry.diskReadFormatted; + const diskWriteValueHTML = entry.activeThresholdKeys.includes('diskwrite') ? `${entry.diskWriteFormatted}` : entry.diskWriteFormatted; + const netInValueHTML = entry.activeThresholdKeys.includes('netin') ? `${entry.netInFormatted}` : entry.netInFormatted; + const netOutValueHTML = entry.activeThresholdKeys.includes('netout') ? `${entry.netOutFormatted}` : entry.netOutFormatted; + + const guestDisplayHTML = entry.guestMatchedSearch ? `${entry.guestName} (${entry.guestId})` : `${entry.guestName} (${entry.guestId})`; + const nodeDisplayHTML = entry.nodeMatchedSearch ? `${entry.node}` : entry.node; + + row.innerHTML = ` + ${entry.timestamp.toLocaleTimeString()} + ${guestDisplayHTML} + ${nodeDisplayHTML} + ${cpuValueHTML} + ${memValueHTML} + ${diskValueHTML} + ${diskReadValueHTML} + ${diskWriteValueHTML} + ${netInValueHTML} + ${netOutValueHTML} + `; + + tableBody.prepend(row); + + setTimeout(() => { + row.classList.remove('bg-yellow-50', 'dark:bg-yellow-900/20', 'animate-pulse-once'); + }, 1500); + + PulseApp.state.addLogEntry(sessionId, entry); + } + + function checkThresholdViolations() { + const activeSessions = PulseApp.state.getAllActiveLogSessions(); + if (Object.keys(activeSessions).length === 0) { + return; + } + + const now = new Date(); + const dashboardData = PulseApp.state.get('dashboardData') || []; + + dashboardData.forEach(guest => { + if (guest.status !== 'running') return; + + Object.keys(activeSessions).forEach(sessionId => { + const session = activeSessions[sessionId]; + if (!session) return; + + const typeMatch = session.filterGuestType === 'all' || + (session.filterGuestType === 'vm' && guest.type === 'VM') || + (session.filterGuestType === 'lxc' && guest.type === 'CT'); + if (!typeMatch) return; + + const statusMatch = session.filterStatus === 'all' || guest.status === session.filterStatus; + if (!statusMatch) return; + + const snapshottedSearch = session.searchTerms || []; + let guestMatchedSearch = false; + let nodeMatchedSearch = false; + let overallSearchMatch = false; + + if (snapshottedSearch.length > 0) { + snapshottedSearch.forEach(term => { + if (!guestMatchedSearch && ( + (guest.name && guest.name.toLowerCase().includes(term)) || + (guest.vmid && guest.vmid.toString().includes(term)) || + (guest.uniqueId && guest.uniqueId.toString().includes(term)) + )) { + guestMatchedSearch = true; + } + if (!nodeMatchedSearch && ( + (guest.node && guest.node.toLowerCase().includes(term)) + )) { + nodeMatchedSearch = true; + } + }); + overallSearchMatch = guestMatchedSearch || nodeMatchedSearch; + if (!overallSearchMatch) return; + } else { + overallSearchMatch = true; + } + + let meetsAllThresholds = true; + let violationDetails = []; + + for (const type in session.thresholds) { + const thresholdValue = session.thresholds[type]; + if (thresholdValue <= 0) continue; + + let guestValue; + if (type === 'cpu') guestValue = guest.cpu * 100; + else if (type === 'memory') guestValue = guest.memory; + else if (type === 'disk') guestValue = guest.disk; + else if (type === 'diskread') guestValue = guest.diskread; + else if (type === 'diskwrite') guestValue = guest.diskwrite; + else if (type === 'netin') guestValue = guest.netin; + else if (type === 'netout') guestValue = guest.netout; + else continue; + + if (guestValue === undefined || guestValue === null || guestValue === 'N/A' || isNaN(guestValue)) { + continue; + } + + if (guestValue < thresholdValue) { + meetsAllThresholds = false; + break; + } + } + + if (meetsAllThresholds) { + const metricsSnapshot = { + cpu: guest.cpu * 100, + mem: guest.memory, + disk: guest.disk, + diskRead: guest.diskread, + diskWrite: guest.diskwrite, + netIn: guest.netin, + netOut: guest.netout + }; + + const logEntry = { + timestamp: now, + guestId: guest.vmid, + guestName: guest.name, + node: guest.node, + cpuFormatted: PulseApp.utils.formatThresholdValue('cpu', metricsSnapshot.cpu), + memFormatted: PulseApp.utils.formatThresholdValue('memory', metricsSnapshot.mem), + diskFormatted: PulseApp.utils.formatThresholdValue('disk', metricsSnapshot.disk), + diskReadFormatted: PulseApp.utils.formatThresholdValue('diskread', metricsSnapshot.diskRead), + diskWriteFormatted: PulseApp.utils.formatThresholdValue('diskwrite', metricsSnapshot.diskWrite), + netInFormatted: PulseApp.utils.formatThresholdValue('netin', metricsSnapshot.netIn), + netOutFormatted: PulseApp.utils.formatThresholdValue('netout', metricsSnapshot.netOut), + metricsRaw: metricsSnapshot, + activeThresholdKeys: Object.keys(session.thresholds), + guestMatchedSearch: guestMatchedSearch, + nodeMatchedSearch: nodeMatchedSearch + }; + + const lastEntry = session.entries.length > 0 ? session.entries[0] : null; + if (!lastEntry || + !(lastEntry.guestId === logEntry.guestId && + (now.getTime() - lastEntry.timestamp.getTime()) < 1000) + ) + { + addLogRow(sessionId, logEntry); + } + } + }); + }); + } + + function updateClearAllButtonVisibility() { + if (!clearAllLogsButton) return; + const nestedTabsContainer = document.querySelector('.nested-tabs'); + if (!nestedTabsContainer) return; + + let hasFinishedLogs = false; + const activeSessions = PulseApp.state.getAllActiveLogSessions(); + nestedTabsContainer.querySelectorAll('.nested-tab[data-nested-tab^="log-session-"]').forEach(tab => { + const sessionId = tab.dataset.nestedTab.replace('log-session-', ''); + if (!activeSessions[sessionId]) { + hasFinishedLogs = true; + } + }); + + clearAllLogsButton.classList.toggle('hidden', !hasFinishedLogs); + } + + function clearAllFinishedLogs() { + const nestedTabsContainer = document.querySelector('.nested-tabs'); + const nestedTabContentContainer = document.querySelector('#log-content-area'); + const logSessionArea = document.getElementById('log-session-area'); + if (!nestedTabsContainer || !nestedTabContentContainer || !logSessionArea) return; + + const activeSessions = PulseApp.state.getAllActiveLogSessions(); + const tabsToRemove = []; + const contentsToRemove = []; + + nestedTabsContainer.querySelectorAll('.nested-tab[data-nested-tab^="log-session-"]').forEach(tab => { + const sessionIdFull = tab.dataset.nestedTab; + if (!activeSessions[sessionIdFull.replace('log-session-', '')]) { + tabsToRemove.push(tab); + const content = nestedTabContentContainer.querySelector(`#${sessionIdFull}`); + if (content) contentsToRemove.push(content); + } + }); + + tabsToRemove.forEach(tab => tab.remove()); + contentsToRemove.forEach(content => content.remove()); + + if (nestedTabsContainer.querySelectorAll('.nested-tab[data-nested-tab^="log-session-"]').length === 0) { + logSessionArea.classList.add('hidden'); + } + + if (clearAllLogsButton) clearAllLogsButton.classList.add('hidden'); + } + + return { + init, + stopThresholdLogging, + checkThresholdViolations, + updateClearAllButtonVisibility + }; +})(); \ No newline at end of file diff --git a/src/public/js/tooltips.js b/src/public/js/tooltips.js new file mode 100644 index 000000000..4cb13a466 --- /dev/null +++ b/src/public/js/tooltips.js @@ -0,0 +1,110 @@ +PulseApp.tooltips = (() => { + let tooltipElement = null; + let sliderValueTooltip = null; + + function init() { + tooltipElement = document.getElementById('custom-tooltip'); + sliderValueTooltip = document.getElementById('slider-value-tooltip'); + + if (!tooltipElement) { + console.warn('Element #custom-tooltip not found - tooltips will not work.'); + return; // Don't attach listeners if the element is missing + } + if (!sliderValueTooltip) { + console.warn('Element #slider-value-tooltip not found - slider values will not display on drag.'); + // Continue initialization for general tooltips even if slider tooltip is missing + } + + tooltipElement.classList.remove('duration-100'); + tooltipElement.classList.add('duration-50'); + + document.body.addEventListener('mouseover', handleMouseOver); + document.body.addEventListener('mouseout', handleMouseOut); + document.body.addEventListener('mousemove', handleMouseMove); + + document.addEventListener('mouseup', hideSliderTooltip); + document.addEventListener('touchend', hideSliderTooltip); + } + + function handleMouseOver(event) { + const target = event.target.closest('.metric-tooltip-trigger, .storage-tooltip-trigger'); + if (target) { + const tooltipText = target.getAttribute('data-tooltip'); + if (tooltipText && tooltipElement) { + tooltipElement.textContent = tooltipText; + positionTooltip(event); + tooltipElement.classList.remove('hidden', 'opacity-0'); + tooltipElement.classList.add('opacity-100'); + } + } + } + + function handleMouseOut(event) { + const target = event.target.closest('.metric-tooltip-trigger, .storage-tooltip-trigger'); + if (target && tooltipElement) { + tooltipElement.classList.add('hidden', 'opacity-0'); + tooltipElement.classList.remove('opacity-100'); + } + } + + function handleMouseMove(event) { + const target = event.target.closest('.metric-tooltip-trigger, .storage-tooltip-trigger'); + if (tooltipElement && !tooltipElement.classList.contains('hidden') && target) { + positionTooltip(event); + } else if (tooltipElement && !tooltipElement.classList.contains('hidden') && !target) { + // Optional: Hide if moving away from a trigger element while tooltip is still visible + // tooltipElement.classList.add('hidden', 'opacity-0'); + // tooltipElement.classList.remove('opacity-100'); + } + } + + function positionTooltip(event) { + if (!tooltipElement) return; + const offsetX = 10; + const offsetY = 15; + tooltipElement.style.left = `${event.pageX + offsetX}px`; + tooltipElement.style.top = `${event.pageY + offsetY}px`; + } + + function updateSliderTooltip(sliderElement) { + if (!sliderValueTooltip || !sliderElement) return; + + const type = sliderElement.id.replace('threshold-slider-', ''); + if (!PulseApp.state.getThresholdState()[type]) return; // Only process actual threshold sliders + + const numericValue = parseInt(sliderElement.value); + let displayText = `${numericValue}%`; + + const rect = sliderElement.getBoundingClientRect(); + const min = parseFloat(sliderElement.min); + const max = parseFloat(sliderElement.max); + const value = parseFloat(sliderElement.value); + + const percent = (max > min) ? (value - min) / (max - min) : 0; + + const thumbWidthEstimate = 16; + let thumbX = rect.left + (percent * (rect.width - thumbWidthEstimate)) + (thumbWidthEstimate / 2); + + sliderValueTooltip.textContent = displayText; + sliderValueTooltip.classList.remove('hidden'); + const tooltipRect = sliderValueTooltip.getBoundingClientRect(); + + const posX = thumbX - (tooltipRect.width / 2); + const posY = rect.top - tooltipRect.height - 5; + + sliderValueTooltip.style.left = `${posX}px`; + sliderValueTooltip.style.top = `${posY}px`; + } + + function hideSliderTooltip() { + if (sliderValueTooltip) { + sliderValueTooltip.classList.add('hidden'); + } + } + + return { + init, + updateSliderTooltip, + hideSliderTooltip + }; +})(); \ No newline at end of file diff --git a/src/public/js/ui/backups.js b/src/public/js/ui/backups.js new file mode 100644 index 000000000..7c79f3b69 --- /dev/null +++ b/src/public/js/ui/backups.js @@ -0,0 +1,319 @@ +PulseApp.ui = PulseApp.ui || {}; + +PulseApp.ui.backups = (() => { + let backupsSearchInput = null; + let resetBackupsButton = null; + let backupsTabContent = null; + + function init() { + backupsSearchInput = document.getElementById('backups-search'); + resetBackupsButton = document.getElementById('reset-backups-filters-button'); + backupsTabContent = document.getElementById('backups'); + + if (backupsSearchInput) { + backupsSearchInput.addEventListener('input', updateBackupsTab); + } else { + console.warn('Element #backups-search not found - backups text filtering disabled.'); + } + + if (resetBackupsButton) { + resetBackupsButton.addEventListener('click', resetBackupsView); + } + + if (backupsTabContent) { + backupsTabContent.addEventListener('keydown', (event) => { + if (event.key === 'Escape' && backupsTabContent.contains(document.activeElement)) { + resetBackupsView(); + } + }); + } + } + + function updateBackupsTab() { + const tableContainer = document.getElementById('backups-table-container'); + const tableBody = document.getElementById('backups-overview-tbody'); + const loadingMsg = document.getElementById('backups-loading-message'); + const noDataMsg = document.getElementById('backups-no-data-message'); + const statusTextElement = document.getElementById('backups-status-text'); + + if (!tableContainer || !tableBody || !loadingMsg || !noDataMsg || !statusTextElement) { + console.error("UI elements for Backups tab not found!"); + return; + } + + const vmsData = PulseApp.state.get('vmsData') || []; + const containersData = PulseApp.state.get('containersData') || []; + const pbsDataArray = PulseApp.state.get('pbsDataArray') || []; + const initialDataReceived = PulseApp.state.get('initialDataReceived'); + + const allGuests = [...vmsData, ...containersData]; + + if (!initialDataReceived) { + loadingMsg.classList.remove('hidden'); + tableContainer.classList.add('hidden'); + noDataMsg.classList.add('hidden'); + return; + } + + if (allGuests.length === 0) { + loadingMsg.classList.add('hidden'); + tableContainer.classList.add('hidden'); + noDataMsg.textContent = "No Proxmox guests (VMs/Containers) found."; + noDataMsg.classList.remove('hidden'); + return; + } + + loadingMsg.classList.add('hidden'); + + const backupStatusByGuest = []; + const now = Math.floor(Date.now() / 1000); + const sevenDaysAgo = now - (7 * 24 * 60 * 60); + const threeDaysAgo = now - (3 * 24 * 60 * 60); + + const allRecentBackupTasks = pbsDataArray.flatMap(pbs => + (pbs.backupTasks?.recentTasks || []).map(task => ({ + ...task, + guestId: task.id?.split('/')[1] || null, + guestTypePbs: task.id?.split('/')[0] || null, + pbsInstanceName: pbs.pbsInstanceName + })) + ); + + const allSnapshots = pbsDataArray.flatMap(pbsInstance => + (pbsInstance.datastores || []).flatMap(ds => + (ds.snapshots || []).map(snap => ({ + ...snap, + pbsInstanceName: pbsInstance.pbsInstanceName, + datastoreName: ds.name, + backupType: snap['backup-type'], + backupVMID: snap['backup-id'] + })) + ) + ); + + allGuests.forEach(guest => { + const guestId = String(guest.vmid); + const guestTypePve = guest.type === 'qemu' ? 'vm' : 'ct'; + + 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); + 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); + + let healthStatus = 'none'; + let displayTimestamp = latestSnapshotTime; + + if (latestTask) { + displayTimestamp = latestTask.startTime; + if (latestTask.status === 'OK') { + if (latestTask.startTime >= threeDaysAgo) { + healthStatus = 'ok'; + } else if (latestTask.startTime >= sevenDaysAgo) { + healthStatus = 'stale'; + } else { + healthStatus = 'old'; + } + } else { + healthStatus = 'failed'; + } + } else if (latestSnapshotTime) { + if (latestSnapshotTime >= threeDaysAgo) { + healthStatus = 'ok'; + } else if (latestSnapshotTime >= sevenDaysAgo) { + healthStatus = 'stale'; + } else { + healthStatus = 'old'; + } + } else { + healthStatus = 'none'; + displayTimestamp = null; + } + + backupStatusByGuest.push({ + guestName: guest.name || `Guest ${guest.vmid}`, + guestId: guest.vmid, + guestType: guest.type === 'qemu' ? 'VM' : 'LXC', + node: guest.node, + guestPveStatus: guest.status, + latestBackupTime: displayTimestamp, + pbsInstanceName: latestSnapshot?.pbsInstanceName || latestTask?.pbsInstanceName || 'N/A', + datastoreName: latestSnapshot?.datastoreName || 'N/A', + totalBackups: totalBackups, + backupHealthStatus: healthStatus + }); + }); + + const currentBackupsSearchTerm = backupsSearchInput ? backupsSearchInput.value.toLowerCase() : ''; + const backupsSearchTerms = currentBackupsSearchTerm.split(',').map(term => term.trim()).filter(term => term); + const backupsFilterHealth = PulseApp.state.get('backupsFilterHealth'); + const backupsFilterGuestType = PulseApp.state.get('backupsFilterGuestType'); + + const filteredBackupStatus = backupStatusByGuest.filter(item => { + const healthMatch = (backupsFilterHealth === 'all') || + (backupsFilterHealth === 'ok' && (item.backupHealthStatus === 'ok' || item.backupHealthStatus === 'stale')) || + (backupsFilterHealth === 'warning' && (item.backupHealthStatus === 'old')) || + (backupsFilterHealth === 'error' && item.backupHealthStatus === 'failed') || + (backupsFilterHealth === 'none' && item.backupHealthStatus === 'none'); + if (!healthMatch) return false; + + const typeMatch = (backupsFilterGuestType === 'all') || + (backupsFilterGuestType === 'vm' && item.guestType === 'VM') || + (backupsFilterGuestType === 'lxc' && item.guestType === 'LXC'); + if (!typeMatch) return false; + + if (backupsSearchTerms.length > 0) { + const nameMatch = backupsSearchTerms.some(term => + (item.guestName?.toLowerCase() || '').includes(term) || + (item.node?.toLowerCase() || '').includes(term) || + (item.guestId?.toString() || '').includes(term) + ); + if (!nameMatch) return false; + } + + return true; + }); + + const sortStateBackups = PulseApp.state.getSortState('backups'); + const sortedBackupStatus = PulseApp.utils.sortData(filteredBackupStatus, sortStateBackups.column, sortStateBackups.direction, 'backups'); + + tableBody.innerHTML = ''; + let visibleCount = 0; + if (sortedBackupStatus.length > 0) { + sortedBackupStatus.forEach(guestStatus => { + const row = tableBody.insertRow(); + row.className = `transition-all duration-150 ease-out hover:bg-gray-100 dark:hover:bg-gray-700 hover:shadow-md hover:-translate-y-px ${guestStatus.guestPveStatus === 'stopped' ? 'opacity-60 grayscale' : ''}`; + + const latestBackupFormatted = guestStatus.latestBackupTime + ? PulseApp.utils.formatPbsTimestamp(guestStatus.latestBackupTime) + : 'No backups found'; + + let healthIndicator = ''; + switch (guestStatus.backupHealthStatus) { + case 'ok': + healthIndicator = ''; + break; + case 'stale': + healthIndicator = ''; + break; + case 'failed': + healthIndicator = ''; + break; + case 'old': + healthIndicator = ''; + break; + case 'none': + healthIndicator = '-'; + break; + } + + const typeIconClass = guestStatus.guestType === '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' + : 'ct-icon bg-green-100 dark:bg-green-900/50 text-green-700 dark:text-green-300 px-1.5 py-0.5 font-medium'; + const typeIcon = `${guestStatus.guestType}`; + + row.innerHTML = ` + ${healthIndicator} + ${guestStatus.guestName} + ${guestStatus.guestId} + ${typeIcon} + ${guestStatus.node} + ${latestBackupFormatted} + ${guestStatus.pbsInstanceName} + ${guestStatus.datastoreName} + ${guestStatus.totalBackups} + `; + visibleCount++; + }); + + loadingMsg.classList.add('hidden'); + noDataMsg.classList.add('hidden'); + tableContainer.classList.remove('hidden'); + } else { + loadingMsg.classList.add('hidden'); + tableContainer.classList.add('hidden'); + let emptyMessage = "No backup information found for any guests."; + if (backupStatusByGuest.length === 0) { + if (allGuests.length === 0) { + emptyMessage = "No Proxmox guests (VMs/Containers) found."; + } else { + emptyMessage = "No backup information found for any guests."; + } + } + else if (filteredBackupStatus.length === 0) { + const typeFilterText = backupsFilterGuestType === 'all' ? '' : `Type: ${backupsFilterGuestType.toUpperCase()}`; + const filtersApplied = [typeFilterText].filter(Boolean).join(', '); + + if (filtersApplied) { + emptyMessage = `No guests found matching the selected filters (${filtersApplied}).`; + } else { + emptyMessage = "No guests with backup information found."; + } + } + if (filteredBackupStatus.length === 0 && backupsSearchTerms.length > 0) { + emptyMessage = `No guests found matching search "${currentBackupsSearchTerm}".`; + if (filtersApplied) { + emptyMessage += ` and filters (${filtersApplied})`; + } + } + noDataMsg.textContent = emptyMessage; + noDataMsg.classList.remove('hidden'); + } + + const backupsSortColumn = sortStateBackups.column; + const backupsHeader = document.querySelector(`#backups-overview-table th[data-sort="${backupsSortColumn}"]`); + if (PulseApp.ui && PulseApp.ui.common) { + PulseApp.ui.common.updateSortUI('backups-overview-table', backupsHeader); + } else { + console.warn('[Backups] PulseApp.ui.common not available for updateSortUI'); + } + + if (statusTextElement) { + const statusBaseText = `Updated: ${new Date().toLocaleTimeString()}`; + let statusFilterText = currentBackupsSearchTerm ? ` | Filter: "${currentBackupsSearchTerm}"` : ''; + const typeFilterLabel = backupsFilterGuestType !== 'all' ? backupsFilterGuestType.toUpperCase() : ''; + const healthFilterLabel = backupsFilterHealth !== 'all' ? backupsFilterHealth.charAt(0).toUpperCase() + backupsFilterHealth.slice(1) : ''; + const otherFilters = [typeFilterLabel, healthFilterLabel].filter(Boolean).join('/'); + if (otherFilters) { + statusFilterText += ` | ${otherFilters}`; + } + let statusCountText = ` | Showing ${visibleCount} guests`; + statusTextElement.textContent = statusBaseText + statusFilterText + statusCountText; + } + } + + function resetBackupsView() { + console.log('Resetting backups view...'); + if (backupsSearchInput) backupsSearchInput.value = ''; + PulseApp.state.set('backupsSearchTerm', ''); + + const backupTypeAllRadio = document.getElementById('backups-filter-type-all'); + if(backupTypeAllRadio) backupTypeAllRadio.checked = true; + PulseApp.state.set('backupsFilterGuestType', 'all'); + + const backupStatusAllRadio = document.getElementById('backups-filter-status-all'); + if(backupStatusAllRadio) backupStatusAllRadio.checked = true; + PulseApp.state.set('backupsFilterHealth', 'all'); + + PulseApp.state.setSortState('backups', 'latestBackupTime', 'desc'); + + updateBackupsTab(); + PulseApp.state.saveFilterState(); // Save reset state + } + + return { + init, + updateBackupsTab, + resetBackupsView + }; +})(); \ No newline at end of file diff --git a/src/public/js/ui/common.js b/src/public/js/ui/common.js new file mode 100644 index 000000000..afc182ae3 --- /dev/null +++ b/src/public/js/ui/common.js @@ -0,0 +1,282 @@ +PulseApp.ui = PulseApp.ui || {}; + +PulseApp.ui.common = (() => { + let searchInput = null; + let backupsSearchInput = null; + + function init() { + searchInput = document.getElementById('dashboard-search'); + backupsSearchInput = document.getElementById('backups-search'); + + setupTableSorting('nodes-table'); + setupTableSorting('main-table'); + setupTableSorting('backups-overview-table'); + + setupEventListeners(); + applyInitialFilterUI(); + applyInitialSortUI(); + } + + function applyInitialFilterUI() { + const groupByNode = PulseApp.state.get('groupByNode'); + const filterGuestType = PulseApp.state.get('filterGuestType'); + const filterStatus = PulseApp.state.get('filterStatus'); + const backupsFilterHealth = PulseApp.state.get('backupsFilterHealth'); + const backupsFilterGuestType = PulseApp.state.get('backupsFilterGuestType'); + + const groupRadio = document.getElementById(groupByNode ? 'group-grouped' : 'group-list'); + if (groupRadio) groupRadio.checked = true; + const typeRadio = document.getElementById(`filter-${filterGuestType === 'ct' ? 'lxc' : filterGuestType}`); + if (typeRadio) typeRadio.checked = true; + const statusRadio = document.getElementById(`filter-status-${filterStatus}`); + if (statusRadio) statusRadio.checked = true; + const backupHealthRadio = document.getElementById(`backups-filter-status-${backupsFilterHealth}`); + if (backupHealthRadio) backupHealthRadio.checked = true; + const backupTypeRadio = document.getElementById(`backups-filter-type-${backupsFilterGuestType}`); + if (backupTypeRadio) backupTypeRadio.checked = true; + } + + 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}"]`); + if (initialMainHeader) { + 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); + } + } + + function setupEventListeners() { + document.querySelectorAll('input[name="group-filter"]').forEach(radio => { + radio.addEventListener('change', function() { + if (this.checked) { + PulseApp.state.set('groupByNode', this.value === 'grouped'); + PulseApp.ui.dashboard.updateDashboardTable(); + if (searchInput) searchInput.dispatchEvent(new Event('input')); + PulseApp.state.saveFilterState(); + } + }); + }); + + document.querySelectorAll('input[name="type-filter"]').forEach(radio => { + radio.addEventListener('change', function() { + if (this.checked) { + PulseApp.state.set('filterGuestType', this.value); + PulseApp.ui.dashboard.updateDashboardTable(); + if (searchInput) searchInput.dispatchEvent(new Event('input')); + PulseApp.state.saveFilterState(); + PulseApp.ui.thresholds.updateLogControlsVisibility(); + } + }); + }); + + document.querySelectorAll('input[name="status-filter"]').forEach(radio => { + radio.addEventListener('change', function() { + if (this.checked) { + PulseApp.state.set('filterStatus', this.value); + PulseApp.ui.dashboard.updateDashboardTable(); + if (searchInput) searchInput.dispatchEvent(new Event('input')); + PulseApp.state.saveFilterState(); + PulseApp.ui.thresholds.updateLogControlsVisibility(); + } + }); + }); + + if (searchInput) { + searchInput.addEventListener('input', function() { + PulseApp.ui.dashboard.updateDashboardTable(); + PulseApp.ui.thresholds.updateLogControlsVisibility(); + }); + } else { + console.warn('Element #dashboard-search not found - text filtering disabled.'); + } + + document.querySelectorAll('input[name="backups-type-filter"]').forEach(radio => { + radio.addEventListener('change', function() { + if (this.checked) { + PulseApp.state.set('backupsFilterGuestType', this.value); + PulseApp.ui.backups.updateBackupsTab(); + PulseApp.state.saveFilterState(); + } + }); + }); + + document.querySelectorAll('input[name="backups-status-filter"]').forEach(radio => { + radio.addEventListener('change', function() { + if (this.checked) { + PulseApp.state.set('backupsFilterHealth', this.value); + PulseApp.ui.backups.updateBackupsTab(); + PulseApp.state.saveFilterState(); + } + }); + }); + + const resetButton = document.getElementById('reset-filters-button'); + if (resetButton) { + resetButton.addEventListener('click', resetDashboardView); + } else { + console.warn('Reset button #reset-filters-button not found.'); + } + + document.addEventListener('keydown', function(event) { + const activeElement = document.activeElement; + const isSearchInputFocused = activeElement === searchInput || activeElement === backupsSearchInput; + const isGeneralInputElement = !isSearchInputFocused && (activeElement.tagName === 'INPUT' || activeElement.tagName === 'TEXTAREA' || activeElement.isContentEditable); + const isMainActive = document.getElementById('main')?.classList.contains('active'); + + if (event.key === 'Escape') { + if (document.getElementById('backups')?.contains(activeElement)) { + PulseApp.ui.backups.resetBackupsView(); + } else { + resetDashboardView(); + } + } else if (isSearchInputFocused && event.key === 'Enter') { + activeElement.blur(); + event.preventDefault(); + } else if ( + !isSearchInputFocused && + !isGeneralInputElement && + isMainActive && // Only focus dashboard search if main tab is active + !event.metaKey && + !event.ctrlKey && + !event.altKey && + event.key.length === 1 && + event.key !== ' ' + ) { + if (searchInput) { + searchInput.focus(); + } + } + }); + } + + function updateSortUI(tableId, clickedHeader, explicitKey = null) { + const tableElement = document.getElementById(tableId); + if (!tableElement) return; + + let derivedKey; + if (tableId.startsWith('pbs-')) { + const match = tableId.match(/pbs-recent-(backup|verify|sync|prunegc)-tasks-table-/); + derivedKey = match && match[1] ? `pbs${match[1].charAt(0).toUpperCase() + match[1].slice(1)}` : null; + } else if (tableId.startsWith('nodes-')) { + derivedKey = 'nodes'; + } else if (tableId.startsWith('main-')) { + derivedKey = 'main'; + } else if (tableId.startsWith('backups-')) { + derivedKey = 'backups'; + } else { + derivedKey = null; + } + + const tableKey = explicitKey || derivedKey; + if (!tableKey) { + console.error(`[updateSortUI] Could not determine sort key for tableId: ${tableId}`); + return; + } + + const currentSort = PulseApp.state.getSortState(tableKey); + if (!currentSort) { + console.error(`[updateSortUI] No sort state found for key: '${tableKey}'`); + return; + } + + const headers = tableElement.querySelectorAll('th.sortable'); + headers.forEach(header => { + header.classList.remove('bg-blue-50', 'dark:bg-blue-900/20'); + const arrow = header.querySelector('.sort-arrow'); + if (arrow) arrow.remove(); + + if (header === clickedHeader && currentSort.column) { + header.classList.add('bg-blue-50', 'dark:bg-blue-900/20'); + const arrowSpan = document.createElement('span'); + arrowSpan.className = 'sort-arrow ml-1'; + arrowSpan.textContent = currentSort.direction === 'asc' ? '▲' : '▼'; + header.appendChild(arrowSpan); + } + }); + } + + function setupTableSorting(tableId) { + const tableElement = document.getElementById(tableId); + if (!tableElement) { + console.warn(`Table #${tableId} not found for sort setup.`); + return; + } + const tableTypeMatch = tableId.match(/^([a-zA-Z]+)-/); + if (!tableTypeMatch) { + console.warn(`Could not determine table type from ID: ${tableId}`); + return; + } + const tableType = tableTypeMatch[1]; + + tableElement.querySelectorAll('th.sortable').forEach(th => { + th.addEventListener('click', () => { + const column = th.getAttribute('data-sort'); + if (!column) return; + + const currentSortState = PulseApp.state.getSortState(tableType); + let newDirection = 'asc'; + if (currentSortState && currentSortState.column === column) { + newDirection = currentSortState.direction === 'asc' ? 'desc' : 'asc'; + } + + 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; + case 'backups': + PulseApp.ui.backups.updateBackupsTab(); + break; + default: + console.error('Unknown table type for sorting update:', tableType); + } + + updateSortUI(tableId, th); + }); + }); + } + + function resetDashboardView() { + if (searchInput) searchInput.value = ''; + PulseApp.state.setSortState('main', 'id', 'asc'); + + const groupGroupedRadio = document.getElementById('group-grouped'); + if(groupGroupedRadio) groupGroupedRadio.checked = true; + PulseApp.state.set('groupByNode', true); + + const filterAllRadio = document.getElementById('filter-all'); + if(filterAllRadio) filterAllRadio.checked = true; + PulseApp.state.set('filterGuestType', 'all'); + + const statusAllRadio = document.getElementById('filter-status-all'); + if(statusAllRadio) statusAllRadio.checked = true; + PulseApp.state.set('filterStatus', 'all'); + + PulseApp.ui.thresholds.resetThresholds(); + PulseApp.ui.dashboard.updateDashboardTable(); + if (searchInput) searchInput.blur(); + PulseApp.ui.thresholds.updateLogControlsVisibility(); + PulseApp.state.saveFilterState(); // Save reset state + } + + return { + init, + updateSortUI + }; +})(); \ No newline at end of file diff --git a/src/public/js/ui/dashboard.js b/src/public/js/ui/dashboard.js new file mode 100644 index 000000000..8d9821e5b --- /dev/null +++ b/src/public/js/ui/dashboard.js @@ -0,0 +1,354 @@ +PulseApp.ui = PulseApp.ui || {}; + +PulseApp.ui.dashboard = (() => { + let searchInput = null; + + function init() { + searchInput = document.getElementById('dashboard-search'); + } + + function refreshDashboardData() { + PulseApp.state.set('dashboardData', []); + let dashboardData = []; + + let maxNameLength = 0; + let maxUptimeLength = 0; + + function calculateAverage(historyArray, key) { + if (!historyArray || historyArray.length === 0) return null; + const validEntries = historyArray.filter(entry => typeof entry[key] === 'number' && !isNaN(entry[key])); + if (validEntries.length === 0) return null; + const sum = validEntries.reduce((acc, curr) => acc + curr[key], 0); + return sum / validEntries.length; + } + + function calculateAverageRate(historyArray, key) { + if (!historyArray || historyArray.length < 2) return null; + const validHistory = historyArray.filter(entry => + typeof entry.timestamp === 'number' && !isNaN(entry.timestamp) && + typeof entry[key] === 'number' && !isNaN(entry[key]) + ); + + if (validHistory.length < 2) return null; + const oldest = validHistory[0]; + const newest = validHistory[validHistory.length - 1]; + const valueDiff = newest[key] - oldest[key]; + const timeDiff = (newest.timestamp - oldest.timestamp) / 1000; + + if (timeDiff <= 0) return 0; + return valueDiff / timeDiff; + } + + const processGuest = (guest, type) => { + let avgCpu = 0, avgMem = 0, avgDisk = 0; + let avgDiskReadRate = 0, avgDiskWriteRate = 0, avgNetInRate = 0, avgNetOutRate = 0; + let avgMemoryPercent = 'N/A', avgDiskPercent = 'N/A'; + + const metricsData = PulseApp.state.get('metricsData') || []; + const metrics = metricsData.find(m => + m.id === guest.vmid && + m.type === guest.type && + m.node === guest.node && + m.endpointId === guest.endpointId + ); + + const guestUniqueId = guest.id; + + if (guest.status === 'running' && metrics && metrics.current) { + const currentDataPoint = { + timestamp: Date.now(), + ...metrics.current + }; + PulseApp.state.updateDashboardHistory(guestUniqueId, currentDataPoint); + const history = PulseApp.state.getDashboardHistory()[guestUniqueId] || []; + + avgCpu = calculateAverage(history, 'cpu') ?? 0; + avgMem = calculateAverage(history, 'mem') ?? 0; + avgDisk = calculateAverage(history, 'disk') ?? 0; + avgDiskReadRate = calculateAverageRate(history, 'diskread') ?? 0; + avgDiskWriteRate = calculateAverageRate(history, 'diskwrite') ?? 0; + avgNetInRate = calculateAverageRate(history, 'netin') ?? 0; + avgNetOutRate = calculateAverageRate(history, 'netout') ?? 0; + avgMemoryPercent = (guest.maxmem > 0) ? Math.round(avgMem / guest.maxmem * 100) : 'N/A'; + avgDiskPercent = (guest.maxdisk > 0) ? Math.round(avgDisk / guest.maxdisk * 100) : 'N/A'; + + } else { + PulseApp.state.clearDashboardHistoryEntry(guestUniqueId); + } + + const name = guest.name || `${guest.type === 'qemu' ? 'VM' : 'CT'} ${guest.vmid}`; + const uptimeFormatted = PulseApp.utils.formatUptime(guest.uptime); + if (name.length > maxNameLength) maxNameLength = name.length; + if (uptimeFormatted.length > maxUptimeLength) maxUptimeLength = uptimeFormatted.length; + + dashboardData.push({ + id: guest.vmid, + uniqueId: guestUniqueId, + vmid: guest.vmid, + name: name, + node: guest.node, + type: guest.type === 'qemu' ? 'VM' : 'CT', + status: guest.status, + cpu: avgCpu, + cpus: guest.cpus || 1, + memory: avgMemoryPercent, + memoryCurrent: avgMem, + memoryTotal: guest.maxmem, + disk: avgDiskPercent, + diskCurrent: avgDisk, + diskTotal: guest.maxdisk, + uptime: guest.status === 'running' ? guest.uptime : 0, + diskread: avgDiskReadRate, + diskwrite: avgDiskWriteRate, + netin: avgNetInRate, + netout: avgNetOutRate + }); + }; + + const vmsData = PulseApp.state.get('vmsData') || []; + const containersData = PulseApp.state.get('containersData') || []; + + vmsData.forEach(vm => processGuest(vm, 'qemu')); + containersData.forEach(ct => processGuest(ct, 'lxc')); + + PulseApp.state.set('dashboardData', dashboardData); + + const nameColWidth = Math.min(Math.max(maxNameLength * 8 + 16, 100), 300); + const uptimeColWidth = Math.max(maxUptimeLength * 7 + 16, 80); + const htmlElement = document.documentElement; + if (htmlElement) { + htmlElement.style.setProperty('--name-col-width', `${nameColWidth}px`); + htmlElement.style.setProperty('--uptime-col-width', `${uptimeColWidth}px`); + } + } + + function updateDashboardTable() { + const tableBody = document.querySelector('#main-table tbody'); + const statusElement = document.getElementById('dashboard-status-text'); + if (!tableBody || !statusElement) { + console.error('Dashboard table body or status element not found!'); + return; + } + + refreshDashboardData(); + + const dashboardData = PulseApp.state.get('dashboardData') || []; + const filterGuestType = PulseApp.state.get('filterGuestType'); + const filterStatus = PulseApp.state.get('filterStatus'); + const thresholdState = PulseApp.state.getThresholdState(); + + const textSearchTerms = searchInput ? searchInput.value.toLowerCase().split(',').map(term => term.trim()).filter(term => term) : []; + + let filteredData = dashboardData.filter(guest => { + const typeMatch = filterGuestType === 'all' || (filterGuestType === 'vm' && guest.type === 'VM') || (filterGuestType === 'lxc' && guest.type === 'CT'); + const statusMatch = filterStatus === 'all' || guest.status === filterStatus; + + const searchMatch = textSearchTerms.length === 0 || textSearchTerms.some(term => + (guest.name && guest.name.toLowerCase().includes(term)) || + (guest.node && guest.node.toLowerCase().includes(term)) || + (guest.vmid && guest.vmid.toString().includes(term)) || + (guest.uniqueId && guest.uniqueId.toString().includes(term)) + ); + + let thresholdsMet = true; + for (const type in thresholdState) { + const state = thresholdState[type]; + let guestValue; + + if (type === 'cpu') guestValue = guest.cpu * 100; + else if (type === 'memory') guestValue = guest.memory; + else if (type === 'disk') guestValue = guest.disk; + else if (type === 'diskread') guestValue = guest.diskread; + else if (type === 'diskwrite') guestValue = guest.diskwrite; + else if (type === 'netin') guestValue = guest.netin; + else if (type === 'netout') guestValue = guest.netout; + else continue; + + if (state.value > 0) { + if (guestValue === undefined || guestValue === null || guestValue === 'N/A' || isNaN(guestValue)) { + thresholdsMet = false; + break; + } + + if (!(guestValue >= state.value)) { + thresholdsMet = false; + break; + } + } + } + + return typeMatch && statusMatch && searchMatch && thresholdsMet; + }); + + const sortStateMain = PulseApp.state.getSortState('main'); + let sortedData = PulseApp.utils.sortData(filteredData, sortStateMain.column, sortStateMain.direction, 'main'); + + tableBody.innerHTML = ''; + let visibleCount = 0; + let visibleNodes = new Set(); + const groupByNode = PulseApp.state.get('groupByNode'); + + if (groupByNode) { + const nodeGroups = {}; + sortedData.forEach(guest => { + const nodeName = guest.node || 'Unknown Node'; + if (!nodeGroups[nodeName]) nodeGroups[nodeName] = []; + nodeGroups[nodeName].push(guest); + }); + + Object.keys(nodeGroups).sort().forEach(nodeName => { + visibleNodes.add(nodeName.toLowerCase()); + const nodeHeaderRow = document.createElement('tr'); + nodeHeaderRow.className = 'node-header bg-gray-100 dark:bg-gray-700/80 font-semibold text-gray-700 dark:text-gray-300 text-xs'; + nodeHeaderRow.innerHTML = ` + + ${nodeName} + `; + tableBody.appendChild(nodeHeaderRow); + + nodeGroups[nodeName].forEach(guest => { + const guestRow = createGuestRow(guest); + if (guestRow) { + tableBody.appendChild(guestRow); + visibleCount++; + } + }); + }); + } else { + sortedData.forEach(guest => { + const guestRow = createGuestRow(guest); + if (guestRow) { + tableBody.appendChild(guestRow); + visibleCount++; + visibleNodes.add((guest.node || 'Unknown Node').toLowerCase()); + } + }); + } + + if (visibleCount === 0) { + let filterDescription = []; + if (filterGuestType !== 'all') filterDescription.push(`Type: ${filterGuestType.toUpperCase()}`); + if (filterStatus !== 'all') filterDescription.push(`Status: ${filterStatus}`); + if (textSearchTerms.length > 0) filterDescription.push(`Search: "${textSearchTerms.join(', ')}"`); + const activeThresholds = Object.entries(thresholdState).filter(([_, state]) => state.value > 0); + if (activeThresholds.length > 0) { + const thresholdTexts = activeThresholds.map(([key, state]) => { + return `${PulseApp.utils.getReadableThresholdName(key)}>=${PulseApp.utils.formatThresholdValue(key, state.value)}`; + }); + filterDescription.push(`Thresholds: ${thresholdTexts.join(', ')}`); + } + + let message = "No guests match the current filters"; + if (filterDescription.length > 0) { + message += ` (${filterDescription.join('; ')})`; + } + message += "."; + + tableBody.innerHTML = `${message}`; + } + + const statusBaseText = `Updated: ${new Date().toLocaleTimeString()}`; + let statusFilterText = textSearchTerms.length > 0 ? ` | Search: "${textSearchTerms.join(', ')}"` : ''; + const typeLabel = filterGuestType !== 'all' ? filterGuestType.toUpperCase() : ''; + const statusLabel = filterStatus !== 'all' ? filterStatus : ''; + const otherFilters = [typeLabel, statusLabel].filter(Boolean).join('/'); + if (otherFilters) { + statusFilterText += ` | ${otherFilters}`; + } + let statusCountText = ` | Showing ${visibleCount} guests`; + if (groupByNode && visibleNodes.size > 0) statusCountText += ` across ${visibleNodes.size} nodes`; + statusElement.textContent = statusBaseText + statusFilterText + statusCountText; + + const mainSortColumn = sortStateMain.column; + const mainHeader = document.querySelector(`#main-table th[data-sort="${mainSortColumn}"]`); + if (PulseApp.ui && PulseApp.ui.common) { + if (mainHeader) { + PulseApp.ui.common.updateSortUI('main-table', mainHeader); + } else { + console.warn(`Sort header for column '${mainSortColumn}' not found in main table.`); + } + } else { + console.warn('[Dashboard] PulseApp.ui.common not available for updateSortUI'); + } + } + + function createGuestRow(guest) { + const row = document.createElement('tr'); + row.className = `border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50 ${guest.status === 'stopped' ? 'opacity-60 grayscale' : ''}`; + row.setAttribute('data-name', guest.name.toLowerCase()); + row.setAttribute('data-type', guest.type.toLowerCase()); + row.setAttribute('data-node', guest.node.toLowerCase()); + row.setAttribute('data-id', guest.id); + + let cpuBarHTML = '-'; + let memoryBarHTML = '-'; + let diskBarHTML = '-'; + let diskReadFormatted = '-'; + let diskWriteFormatted = '-'; + let netInFormatted = '-'; + let netOutFormatted = '-'; + + if (guest.status === 'running') { + const cpuPercent = Math.round(guest.cpu * 100); + const memoryPercent = guest.memory; + + const cpuTooltipText = `${cpuPercent}% ${guest.cpus ? `(${(guest.cpu * guest.cpus).toFixed(1)}/${guest.cpus} cores)` : ''}`; + const memoryTooltipText = guest.memoryTotal ? `${PulseApp.utils.formatBytes(guest.memoryCurrent)} / ${PulseApp.utils.formatBytes(guest.memoryTotal)} (${memoryPercent}%)` : `${memoryPercent}%`; + + const cpuColorClass = PulseApp.utils.getUsageColor(cpuPercent); + const memColorClass = PulseApp.utils.getUsageColor(memoryPercent); + + cpuBarHTML = PulseApp.utils.createProgressTextBarHTML(cpuPercent, cpuTooltipText, cpuColorClass); + memoryBarHTML = PulseApp.utils.createProgressTextBarHTML(memoryPercent, memoryTooltipText, memColorClass); + + if (guest.type === 'CT') { + 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); + diskBarHTML = PulseApp.utils.createProgressTextBarHTML(diskPercent, diskTooltipText, diskColorClass); + } else if (guest.type === 'VM') { + if (guest.diskTotal) { + const totalDiskFormatted = PulseApp.utils.formatBytes(guest.diskTotal); + diskBarHTML = `${totalDiskFormatted}`; + } else { + diskBarHTML = '-'; + } + } else { + diskBarHTML = '-'; + } + + diskReadFormatted = PulseApp.utils.formatSpeed(guest.diskread, 0); + diskWriteFormatted = PulseApp.utils.formatSpeed(guest.diskwrite, 0); + netInFormatted = PulseApp.utils.formatSpeed(guest.netin, 0); + netOutFormatted = PulseApp.utils.formatSpeed(guest.netout, 0); + } + + const typeIconClass = 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' + : 'ct-icon bg-green-100 dark:bg-green-900/50 text-green-700 dark:text-green-300 px-1.5 py-0.5 font-medium'; + const typeIcon = `${guest.type === 'VM' ? 'VM' : 'LXC'}`; + + row.innerHTML = ` + ${guest.name} + ${typeIcon} + ${guest.id} + ${guest.status === 'stopped' ? '-' : PulseApp.utils.formatUptime(guest.uptime)} + ${cpuBarHTML} + ${memoryBarHTML} + ${diskBarHTML} + ${diskReadFormatted} + ${diskWriteFormatted} + ${netInFormatted} + ${netOutFormatted} + `; + + return row; + } + + return { + init, + refreshDashboardData, + updateDashboardTable, + createGuestRow + }; +})(); \ No newline at end of file diff --git a/src/public/js/ui/nodes.js b/src/public/js/ui/nodes.js new file mode 100644 index 000000000..e3eb4b84c --- /dev/null +++ b/src/public/js/ui/nodes.js @@ -0,0 +1,87 @@ +PulseApp.ui = PulseApp.ui || {}; + +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!'); + return; + } + tbody.innerHTML = ''; + + const sortStateNodes = PulseApp.state.getSortState('nodes'); + const dataToDisplay = PulseApp.utils.sortData(nodes, sortStateNodes.column, sortStateNodes.direction, 'nodes'); + + if (dataToDisplay.length === 0) { + tbody.innerHTML = 'No nodes found or data unavailable'; + return; + } + + dataToDisplay.forEach(node => { + const row = document.createElement('tr'); + row.className = 'transition-all duration-150 ease-out hover:bg-gray-100 dark:hover:bg-gray-700 hover:shadow-md hover:-translate-y-px'; + + const isOnline = node && node.uptime > 0; + const statusText = isOnline ? 'online' : (node.status || 'unknown'); + const statusColor = isOnline + ? 'bg-green-500 dark:bg-green-400' + : 'bg-red-500 dark:bg-red-400'; + + const cpuPercent = node.cpu ? (node.cpu * 100) : 0; + const memUsed = node.mem || 0; + const memTotal = node.maxmem || 0; + const memPercent = (memUsed && memTotal > 0) ? (memUsed / memTotal * 100) : 0; + const diskUsed = node.disk || 0; + const diskTotal = node.maxdisk || 0; + const diskPercent = (diskUsed && diskTotal > 0) ? (diskUsed / diskTotal * 100) : 0; + + const cpuColorClass = PulseApp.utils.getUsageColor(cpuPercent); + const memColorClass = PulseApp.utils.getUsageColor(memPercent); + const diskColorClass = PulseApp.utils.getUsageColor(diskPercent); + + const cpuTooltipText = `${cpuPercent.toFixed(1)}%${node.maxcpu && node.maxcpu > 0 ? ` (${(node.cpu * node.maxcpu).toFixed(1)}/${node.maxcpu} cores)` : ''}`; + const memTooltipText = `${PulseApp.utils.formatBytes(memUsed)} / ${PulseApp.utils.formatBytes(memTotal)} (${memPercent.toFixed(1)}%)`; + const diskTooltipText = `${PulseApp.utils.formatBytes(diskUsed)} / ${PulseApp.utils.formatBytes(diskTotal)} (${diskPercent.toFixed(1)}%)`; + + const cpuBarHTML = PulseApp.utils.createProgressTextBarHTML(cpuPercent, cpuTooltipText, cpuColorClass); + const memoryBarHTML = PulseApp.utils.createProgressTextBarHTML(memPercent, memTooltipText, memColorClass); + const diskBarHTML = PulseApp.utils.createProgressTextBarHTML(diskPercent, diskTooltipText, diskColorClass); + + const uptimeFormatted = PulseApp.utils.formatUptime(node.uptime || 0); + let normalizedLoadFormatted = 'N/A'; + if (node.loadavg && node.loadavg.length > 0 && node.maxcpu && node.maxcpu > 0) { + const load1m = parseFloat(node.loadavg[0]); + if (!isNaN(load1m)) { + const normalizedLoad = load1m / node.maxcpu; + normalizedLoadFormatted = normalizedLoad.toFixed(2); + } else { + console.warn(`[updateNodesTable] Node '${node.node}' has non-numeric loadavg[0]:`, node.loadavg[0]); + } + } else if (node.loadavg && node.maxcpu <= 0) { + console.warn(`[updateNodesTable] Node '${node.node}' has invalid maxcpu (${node.maxcpu}) for load normalization.`); + } + + row.innerHTML = ` + + + + ${statusText} + + + ${node.node || 'N/A'} + ${cpuBarHTML} + ${memoryBarHTML} + ${diskBarHTML} + ${uptimeFormatted} + ${normalizedLoadFormatted} + `; + + tbody.appendChild(row); + }); + } + + return { + updateNodesTable + }; +})(); \ No newline at end of file diff --git a/src/public/js/ui/pbs.js b/src/public/js/ui/pbs.js new file mode 100644 index 000000000..d1b479f6a --- /dev/null +++ b/src/public/js/ui/pbs.js @@ -0,0 +1,567 @@ +PulseApp.ui = PulseApp.ui || {}; + +PulseApp.ui.pbs = (() => { + + const getPbsStatusIcon = (status) => { + if (status === 'OK') { + return ''; + } else if (status === 'running') { + return ''; + } else if (status) { + return ``; + } else { + return '?'; + } + }; + + const getPbsGcStatusText = (gcStatus) => { + if (!gcStatus || gcStatus === 'unknown' || gcStatus === 'N/A') { + return '-'; + } + let colorClass = 'text-gray-600 dark:text-gray-400'; + if (gcStatus.includes('error') || gcStatus.includes('failed')) { + colorClass = 'text-red-500 dark:text-red-400'; + } else if (gcStatus === 'OK') { + colorClass = 'text-green-500 dark:text-green-400'; + } + return `${gcStatus}`; + }; + + function updatePbsTaskSummaryCard(prefix, summaryData) { + const okEl = document.getElementById(`pbs-${prefix}-ok`); + const failedEl = document.getElementById(`pbs-${prefix}-failed`); + const totalEl = document.getElementById(`pbs-${prefix}-total`); + const lastOkEl = document.getElementById(`pbs-${prefix}-last-ok`); + const lastFailedEl = document.getElementById(`pbs-${prefix}-last-failed`); + + if (!okEl || !failedEl || !totalEl || !lastOkEl || !lastFailedEl) { + console.warn(`UI elements for PBS task summary '${prefix}' not found.`); + return; + } + + if (summaryData && summaryData.summary) { + const summary = summaryData.summary; + okEl.textContent = summary.ok ?? '-'; + failedEl.textContent = summary.failed ?? '-'; + totalEl.textContent = summary.total ?? '-'; + lastOkEl.textContent = PulseApp.utils.formatPbsTimestamp(summary.lastOk); + lastFailedEl.textContent = PulseApp.utils.formatPbsTimestamp(summary.lastFailed); + + failedEl.classList.toggle('font-bold', (summary.failed ?? 0) > 0); + + } else { + okEl.textContent = '-'; + failedEl.textContent = '-'; + totalEl.textContent = '-'; + lastOkEl.textContent = '-'; + lastFailedEl.textContent = '-'; + failedEl.classList.remove('font-bold'); + } + } + + const parsePbsTaskTarget = (task) => { + const workerId = task.worker_id || task.id || ''; + const taskType = task.worker_type || task.type || ''; + + let displayTarget = workerId; + + if (taskType === 'backup' || taskType === 'verify') { + const parts = workerId.split(':'); + if (parts.length >= 2) { + const targetPart = parts[1]; + const targetSubParts = targetPart.split('/'); + if (targetSubParts.length >= 2) { + const guestType = targetSubParts[0]; + const guestId = targetSubParts[1]; + displayTarget = `${guestType}/${guestId}`; + } + } + } else if (taskType === 'prune' || taskType === 'garbage_collection') { + const parts = workerId.split('::'); + if (parts.length === 2) { + displayTarget = `Prune ${parts[0]} (${parts[1]})`; + } else { + const singleColonParts = workerId.split(':'); + if (singleColonParts.length === 1 && workerId !== '') { + displayTarget = `GC ${workerId}`; + } else if (singleColonParts.length >= 2) { + displayTarget = `Prune ${singleColonParts[0]} (${singleColonParts[1]})` + } + } + } else if (taskType === 'sync') { + displayTarget = `Sync Job: ${workerId}`; + } + + return displayTarget; + }; + + function populatePbsTaskTable(parentSectionElement, fullTasksArray) { + if (!parentSectionElement) { + console.warn('[PBS UI] Parent element not found for task table'); + return; + } + const tableBody = parentSectionElement.querySelector('tbody'); + const showMoreButton = parentSectionElement.querySelector('.pbs-show-more'); + const noTasksMessage = parentSectionElement.querySelector('.pbs-no-tasks'); + const initialLimit = PulseApp.config.INITIAL_PBS_TASK_LIMIT; + + if (!tableBody) { + console.warn('[PBS UI] Table body not found within', parentSectionElement); + return; + } + + tableBody.innerHTML = ''; + + const tasks = fullTasksArray || []; + let displayedTasks = tasks.slice(0, initialLimit); + + if (tasks.length === 0) { + if (noTasksMessage) noTasksMessage.classList.remove('hidden'); + if (showMoreButton) showMoreButton.classList.add('hidden'); + } else { + if (noTasksMessage) noTasksMessage.classList.add('hidden'); + + displayedTasks.forEach(task => { + const target = parsePbsTaskTarget(task); + const statusIcon = getPbsStatusIcon(task.status); + const startTime = task.startTime ? PulseApp.utils.formatPbsTimestamp(task.startTime) : 'N/A'; + const duration = task.duration !== null ? PulseApp.utils.formatDuration(task.duration) : 'N/A'; + const upid = task.upid || 'N/A'; + const shortUpid = upid.length > 30 ? `${upid.substring(0, 15)}...${upid.substring(upid.length - 15)}` : upid; + + const row = document.createElement('tr'); + row.className = 'border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors duration-150 ease-in-out'; + row.innerHTML = ` + ${target} + ${statusIcon} + ${startTime} + ${duration} + ${shortUpid} + `; + tableBody.appendChild(row); + }); + + if (showMoreButton) { + if (tasks.length > initialLimit) { + showMoreButton.classList.remove('hidden'); + const remainingCount = tasks.length - initialLimit; + showMoreButton.textContent = `Show More (${remainingCount} older)`; + if (!showMoreButton.dataset.handlerAttached) { + showMoreButton.addEventListener('click', () => { + tasks.slice(initialLimit).forEach(task => { + const target = parsePbsTaskTarget(task); + const statusIcon = getPbsStatusIcon(task.status); + const startTime = task.startTime ? PulseApp.utils.formatPbsTimestamp(task.startTime) : 'N/A'; + const duration = task.duration !== null ? PulseApp.utils.formatDuration(task.duration) : 'N/A'; + const upid = task.upid || 'N/A'; + const shortUpid = upid.length > 30 ? `${upid.substring(0, 15)}...${upid.substring(upid.length - 15)}` : upid; + + const row = document.createElement('tr'); + row.className = 'border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors duration-150 ease-in-out'; + row.innerHTML = ` + ${target} + ${statusIcon} + ${startTime} + ${duration} + ${shortUpid} + `; + tableBody.appendChild(row); + }); + showMoreButton.classList.add('hidden'); + }); + showMoreButton.dataset.handlerAttached = 'true'; + } + } else { + showMoreButton.classList.add('hidden'); + } + } + } + } + + function updatePbsInfo(pbsArray) { + const container = document.getElementById('pbs-instances-container'); + if (!container) { + console.error("PBS container element #pbs-instances-container not found!"); + return; + } + + if (!pbsArray || pbsArray.length === 0) { + container.innerHTML = ''; + const placeholder = document.createElement('p'); + placeholder.className = 'text-gray-500 dark:text-gray-400 p-4 text-center text-sm'; + placeholder.textContent = 'Proxmox Backup Server integration is not configured.'; + container.appendChild(placeholder); + return; + } + + const loadingMessage = document.getElementById('pbs-loading-message'); + if (loadingMessage) { + loadingMessage.remove(); + } + + const notConfiguredBanner = container.querySelector('.pbs-not-configured-banner'); + if (notConfiguredBanner) { + notConfiguredBanner.remove(); + } + + const currentInstanceIds = new Set(); + + const createSummaryCard = (type, title, summaryData) => { + const card = document.createElement('div'); + const summary = summaryData?.summary || {}; + const ok = summary.ok ?? '-'; + const failed = summary.failed ?? '-'; + const total = summary.total ?? '-'; + const lastOk = PulseApp.utils.formatPbsTimestamp(summary.lastOk); + const lastFailed = PulseApp.utils.formatPbsTimestamp(summary.lastFailed); + const failedStyle = (failed > 0) ? 'font-bold text-red-600 dark:text-red-400' : 'text-red-600 dark:text-red-400 font-semibold'; + + const highlightClass = (failed > 0) ? 'border-l-4 border-red-500 dark:border-red-400' : 'border-l-4 border-transparent'; + card.className = `border border-gray-200 dark:border-gray-700 rounded p-3 bg-gray-100/50 dark:bg-gray-700/50 ${highlightClass}`; + + card.innerHTML = ` +

${title} (7d)

+
+
OK: ${ok}
+
Failed: ${failed}
+
Total: ${total}
+
Last OK: ${lastOk}
+
Last Fail: ${lastFailed}
+
`; + return card; + }; + + const createTaskTableHTML = (tableId, title, idColumnHeader) => { + const tbodyId = tableId.replace('-table-', '-tbody-'); + const toggleButtonContainerId = tableId.replace('-table', '-toggle-container'); + return ` +

Recent ${title} Tasks

+
+ + + + + + + + + + + + + +
${idColumnHeader}StatusStart TimeDurationUPID
+
+
+ + +
+ `; + }; + + pbsArray.forEach((pbsInstance, index) => { + const rawInstanceId = pbsInstance.pbsEndpointId || `instance-${index}`; + const instanceId = PulseApp.utils.sanitizeForId(rawInstanceId); + const instanceName = pbsInstance.pbsInstanceName || `PBS Instance ${index + 1}`; + const instanceElementId = `pbs-instance-${instanceId}`; + currentInstanceIds.add(instanceElementId); + + let instanceWrapper = document.getElementById(instanceElementId); + let detailsContainer, dsTableBody, instanceTitleElement; + + let statusText = 'Loading...'; + let showDetails = false; + let statusColorClass = 'text-gray-600 dark:text-gray-400'; + switch (pbsInstance.status) { + case 'configured': + statusText = `Configured, attempting connection...`; + statusColorClass = 'text-gray-600 dark:text-gray-400'; + break; + case 'ok': + statusText = `Status: OK`; + statusColorClass = 'text-green-600 dark:text-green-400'; + showDetails = true; + break; + case 'error': + statusText = `Error: ${pbsInstance.errorMessage || 'Connection failed'}`; + statusColorClass = 'text-red-600 dark:text-red-400'; + break; + default: + statusText = `Status: ${pbsInstance.status || 'Unknown'}`; + break; + } + + let overallHealth = 'ok'; + let healthTitle = 'OK'; + if (pbsInstance.status === 'error') { + overallHealth = 'error'; + healthTitle = `Error: ${pbsInstance.errorMessage || 'Connection failed'}`; + } else if (pbsInstance.status !== 'ok') { + overallHealth = 'warning'; + healthTitle = 'Connecting or unknown status'; + } else { + const highUsageDatastore = (pbsInstance.datastores || []).find(ds => { + const totalBytes = ds.total || 0; + const usedBytes = ds.used || 0; + const usagePercent = totalBytes > 0 ? Math.round((usedBytes / totalBytes) * 100) : 0; + return usagePercent > 85; + }); + if (highUsageDatastore) { + overallHealth = 'warning'; + healthTitle = `Warning: Datastore ${highUsageDatastore.name} usage high (${Math.round((highUsageDatastore.used / highUsageDatastore.total) * 100)}%)`; + } + + if (overallHealth !== 'error') { + const hasFailures = [ + pbsInstance.backupTasks, + pbsInstance.verificationTasks, + pbsInstance.syncTasks, + pbsInstance.pruneTasks + ].some(taskGroup => (taskGroup?.summary?.failed ?? 0) > 0); + + if (hasFailures) { + overallHealth = 'error'; + healthTitle = 'Error: One or more recent tasks failed'; + } + } + } + + const createHealthBadgeHTML = (health, title) => { + let colorClass = 'bg-gray-400 dark:bg-gray-500'; + if (health === 'ok') colorClass = 'bg-green-500'; + else if (health === 'warning') colorClass = 'bg-yellow-500'; + else if (health === 'error') colorClass = 'bg-red-500'; + return ``; + }; + + if (instanceWrapper) { + detailsContainer = instanceWrapper.querySelector(`#pbs-details-${instanceId}`); + instanceTitleElement = instanceWrapper.querySelector('h3'); + + if (instanceTitleElement) { + instanceTitleElement.innerHTML = `${createHealthBadgeHTML(overallHealth, healthTitle)}${instanceName}`; + } + + if (detailsContainer) { + dsTableBody = detailsContainer.querySelector(`#pbs-ds-tbody-${instanceId}`); + if (dsTableBody) { + dsTableBody.innerHTML = ''; + if (showDetails && pbsInstance.datastores) { + if (pbsInstance.datastores.length === 0) { + dsTableBody.innerHTML = `No PBS datastores found or accessible.`; + } else { + pbsInstance.datastores.forEach(ds => { + const totalBytes = ds.total || 0; + const usedBytes = ds.used || 0; + const availableBytes = (ds.available !== null && ds.available !== undefined) ? ds.available : (totalBytes > 0 ? totalBytes - usedBytes : 0); + const usagePercent = totalBytes > 0 ? Math.round((usedBytes / totalBytes) * 100) : 0; + const usageColor = PulseApp.utils.getUsageColor(usagePercent); + const usageText = totalBytes > 0 ? `${usagePercent}% (${PulseApp.utils.formatBytes(usedBytes)} of ${PulseApp.utils.formatBytes(totalBytes)})` : 'N/A'; + const gcStatusHtml = getPbsGcStatusText(ds.gcStatus); + const row = document.createElement('tr'); + row.className = 'hover:bg-gray-50 dark:hover:bg-gray-700/50'; + row.innerHTML = `${ds.name || 'N/A'} ${ds.path || 'N/A'} ${PulseApp.utils.formatBytes(usedBytes)} ${PulseApp.utils.formatBytes(availableBytes)} ${totalBytes > 0 ? PulseApp.utils.formatBytes(totalBytes) : 'N/A'} ${totalBytes > 0 ? PulseApp.utils.createProgressTextBarHTML(usagePercent, usageText, usageColor) : '-'} ${gcStatusHtml}`; + dsTableBody.appendChild(row); + }); + } + } else { + dsTableBody.innerHTML = `${statusText}`; + } + } + + const summariesSection = detailsContainer.querySelector(`#pbs-summaries-section-${instanceId}`); + if (summariesSection) { + summariesSection.innerHTML = ''; + summariesSection.appendChild(createSummaryCard('backup', 'Backups', pbsInstance.backupTasks)); + summariesSection.appendChild(createSummaryCard('verify', 'Verification', pbsInstance.verificationTasks)); + summariesSection.appendChild(createSummaryCard('sync', 'Sync', pbsInstance.syncTasks)); + summariesSection.appendChild(createSummaryCard('prune', 'Prune/GC', pbsInstance.pruneTasks)); + } + + if (showDetails) { + const backupSection = detailsContainer.querySelector('.pbs-task-section[data-task-type="backup"]'); + if (backupSection) populatePbsTaskTable(backupSection, pbsInstance.backupTasks?.recentTasks); + + const verifySection = detailsContainer.querySelector('.pbs-task-section[data-task-type="verify"]'); + if (verifySection) populatePbsTaskTable(verifySection, pbsInstance.verificationTasks?.recentTasks); + + const syncSection = detailsContainer.querySelector('.pbs-task-section[data-task-type="sync"]'); + if (syncSection) populatePbsTaskTable(syncSection, pbsInstance.syncTasks?.recentTasks); + + const pruneGcSection = detailsContainer.querySelector('.pbs-task-section[data-task-type="prunegc"]'); + if (pruneGcSection) populatePbsTaskTable(pruneGcSection, pbsInstance.pruneTasks?.recentTasks); + + } else { + const backupTbody = document.getElementById(`pbs-recent-backup-tasks-tbody-${instanceId}`); + if (backupTbody) backupTbody.innerHTML = `${statusText}`; + const verifyTbody = document.getElementById(`pbs-recent-verify-tasks-tbody-${instanceId}`); + if (verifyTbody) verifyTbody.innerHTML = `${statusText}`; + const syncTbody = document.getElementById(`pbs-recent-sync-tasks-tbody-${instanceId}`); + if (syncTbody) syncTbody.innerHTML = `${statusText}`; + const pruneTbody = document.getElementById(`pbs-recent-prunegc-tasks-tbody-${instanceId}`); + if (pruneTbody) pruneTbody.innerHTML = `${statusText}`; + } + + detailsContainer.classList.toggle('hidden', !showDetails); + } + + } else { + instanceWrapper = document.createElement('div'); + instanceWrapper.className = 'pbs-instance-section border border-gray-200 dark:border-gray-700 rounded p-4 mb-4 bg-gray-50/30 dark:bg-gray-800/30'; + instanceWrapper.id = instanceElementId; + + const headerDiv = document.createElement('div'); + headerDiv.className = 'flex justify-between items-center mb-3'; + instanceTitleElement = document.createElement('h3'); + instanceTitleElement.className = 'text-lg font-semibold text-gray-800 dark:text-gray-200 flex items-center'; + instanceTitleElement.innerHTML = `${createHealthBadgeHTML(overallHealth, healthTitle)}${instanceName}`; + headerDiv.appendChild(instanceTitleElement); + instanceWrapper.appendChild(headerDiv); + + detailsContainer = document.createElement('div'); + detailsContainer.className = `pbs-instance-details space-y-4 ${showDetails ? '' : 'hidden'}`; + detailsContainer.id = `pbs-details-${instanceId}`; + + const dsSection = document.createElement('div'); + dsSection.id = `pbs-ds-section-${instanceId}`; + dsSection.innerHTML = ` +

Datastores

+
+ + + + + + + + + + + + + +
NamePathUsedAvailableTotalUsageGC Status
+
`; + detailsContainer.appendChild(dsSection); + + const summariesSection = document.createElement('div'); + summariesSection.id = `pbs-summaries-section-${instanceId}`; + summariesSection.className = 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4'; + summariesSection.appendChild(createSummaryCard('backup', 'Backups', pbsInstance.backupTasks)); + summariesSection.appendChild(createSummaryCard('verify', 'Verification', pbsInstance.verificationTasks)); + summariesSection.appendChild(createSummaryCard('sync', 'Sync', pbsInstance.syncTasks)); + summariesSection.appendChild(createSummaryCard('prune', 'Prune/GC', pbsInstance.pruneTasks)); + detailsContainer.appendChild(summariesSection); + + const recentBackupTasksSection = document.createElement('div'); + recentBackupTasksSection.className = 'pbs-task-section'; + recentBackupTasksSection.dataset.taskType = 'backup'; + recentBackupTasksSection.innerHTML = createTaskTableHTML(`pbs-recent-backup-tasks-table-${instanceId}`, 'Backup', 'Guest'); + detailsContainer.appendChild(recentBackupTasksSection); + + const recentVerifyTasksSection = document.createElement('div'); + recentVerifyTasksSection.className = 'pbs-task-section'; + recentVerifyTasksSection.dataset.taskType = 'verify'; + recentVerifyTasksSection.innerHTML = createTaskTableHTML(`pbs-recent-verify-tasks-table-${instanceId}`, 'Verification', 'Guest/Group'); + detailsContainer.appendChild(recentVerifyTasksSection); + + const recentSyncTasksSection = document.createElement('div'); + recentSyncTasksSection.className = 'pbs-task-section'; + recentSyncTasksSection.dataset.taskType = 'sync'; + recentSyncTasksSection.innerHTML = createTaskTableHTML(`pbs-recent-sync-tasks-table-${instanceId}`, 'Sync', 'Job ID'); + detailsContainer.appendChild(recentSyncTasksSection); + + const recentPruneGcTasksSection = document.createElement('div'); + recentPruneGcTasksSection.className = 'pbs-task-section'; + recentPruneGcTasksSection.dataset.taskType = 'prunegc'; + recentPruneGcTasksSection.innerHTML = createTaskTableHTML(`pbs-recent-prunegc-tasks-table-${instanceId}`, 'Prune/GC', 'Datastore/Group'); + detailsContainer.appendChild(recentPruneGcTasksSection); + + instanceWrapper.appendChild(detailsContainer); + container.appendChild(instanceWrapper); + + dsTableBody = instanceWrapper.querySelector(`#pbs-ds-tbody-${instanceId}`); + if (dsTableBody) { + if (showDetails && pbsInstance.datastores) { + if (pbsInstance.datastores.length === 0) { dsTableBody.innerHTML = `No PBS datastores found or accessible.`; } + else { + pbsInstance.datastores.forEach(ds => { + const totalBytes = ds.total || 0; + const usedBytes = ds.used || 0; + const availableBytes = (ds.available !== null && ds.available !== undefined) ? ds.available : (totalBytes > 0 ? totalBytes - usedBytes : 0); + const usagePercent = totalBytes > 0 ? Math.round((usedBytes / totalBytes) * 100) : 0; + const usageColor = PulseApp.utils.getUsageColor(usagePercent); + const usageText = totalBytes > 0 ? `${usagePercent}% (${PulseApp.utils.formatBytes(usedBytes)} of ${PulseApp.utils.formatBytes(totalBytes)})` : 'N/A'; + const gcStatusHtml = getPbsGcStatusText(ds.gcStatus); + const row = document.createElement('tr'); + row.className = 'hover:bg-gray-50 dark:hover:bg-gray-700/50'; + row.innerHTML = `${ds.name || 'N/A'} ${ds.path || 'N/A'} ${PulseApp.utils.formatBytes(usedBytes)} ${PulseApp.utils.formatBytes(availableBytes)} ${totalBytes > 0 ? PulseApp.utils.formatBytes(totalBytes) : 'N/A'} ${totalBytes > 0 ? PulseApp.utils.createProgressTextBarHTML(usagePercent, usageText, usageColor) : '-'} ${gcStatusHtml}`; + dsTableBody.appendChild(row); + }); + } + } else { dsTableBody.innerHTML = `${statusText}`; } + } + + if (showDetails) { + const backupSection = detailsContainer.querySelector('.pbs-task-section[data-task-type="backup"]'); + if (backupSection) populatePbsTaskTable(backupSection, pbsInstance.backupTasks?.recentTasks); + + const verifySection = detailsContainer.querySelector('.pbs-task-section[data-task-type="verify"]'); + if (verifySection) populatePbsTaskTable(verifySection, pbsInstance.verificationTasks?.recentTasks); + + const syncSection = detailsContainer.querySelector('.pbs-task-section[data-task-type="sync"]'); + if (syncSection) populatePbsTaskTable(syncSection, pbsInstance.syncTasks?.recentTasks); + + const pruneGcSection = detailsContainer.querySelector('.pbs-task-section[data-task-type="prunegc"]'); + if (pruneGcSection) populatePbsTaskTable(pruneGcSection, pbsInstance.pruneTasks?.recentTasks); + + } else { + const backupTbody = document.getElementById(`pbs-recent-backup-tasks-tbody-${instanceId}`); + if (backupTbody) backupTbody.innerHTML = `${statusText}`; + const verifyTbody = document.getElementById(`pbs-recent-verify-tasks-tbody-${instanceId}`); + if (verifyTbody) verifyTbody.innerHTML = `${statusText}`; + const syncTbody = document.getElementById(`pbs-recent-sync-tasks-tbody-${instanceId}`); + if (syncTbody) syncTbody.innerHTML = `${statusText}`; + const pruneTbody = document.getElementById(`pbs-recent-prunegc-tasks-tbody-${instanceId}`); + if (pruneTbody) pruneTbody.innerHTML = `${statusText}`; + } + + } + + }); + + container.querySelectorAll('.pbs-instance-section').forEach(el => { + if (!currentInstanceIds.has(el.id)) { + el.remove(); + } + }); + + } + + function initPbsEventListeners() { + const pbsInstancesContainer = document.getElementById('pbs-instances-container'); + if (pbsInstancesContainer) { + pbsInstancesContainer.addEventListener('click', (event) => { + const button = event.target.closest('.pbs-show-more'); // Target only show more buttons + if (!button) return; + + const parentSection = button.closest('.pbs-task-section'); + if (!parentSection) { + console.error("Parent task section (.pbs-task-section) not found for show more button"); + return; + } + // We don't need the full tasks from dataset anymore, assume populate handles it + // const fullTasks = JSON.parse(parentSection.dataset.fullTasks || '[]'); + // populatePbsTaskTable(parentSection, fullTasks); + // The click handler inside populatePbsTaskTable should handle showing more. + }); + } else { + console.warn("PBS instances container not found, toggle functionality will not work."); + } + } + + return { + updatePbsInfo, + initPbsEventListeners + }; +})(); \ No newline at end of file diff --git a/src/public/js/ui/storage.js b/src/public/js/ui/storage.js new file mode 100644 index 000000000..99d1fc200 --- /dev/null +++ b/src/public/js/ui/storage.js @@ -0,0 +1,227 @@ +PulseApp.ui = PulseApp.ui || {}; + +PulseApp.ui.storage = (() => { + + async function fetchStorageData() { + try { + const response = await fetch('/api/storage'); + if (!response.ok) { + let serverErrorMsg = `Server responded with status: ${response.status} ${response.statusText}`; + try { + const errorJson = await response.json(); + if (errorJson && errorJson.globalError) { + serverErrorMsg = errorJson.globalError; + } else if (errorJson) { + serverErrorMsg += ` | Body: ${JSON.stringify(errorJson)}`; + } + } catch (parseError) { + } + throw new Error(serverErrorMsg); + } + + const fetchedData = await response.json(); + PulseApp.state.set('storageData', fetchedData); + + } catch (error) { + let finalErrorMessage = 'Failed to load storage data due to an unknown error.'; + if (error instanceof TypeError) { + finalErrorMessage = `Failed to load storage data due to a network error: ${error.message}`; + console.error('Network error during storage fetch:', error); + } else { + finalErrorMessage = error.message; + console.error('Error processing storage response:', error); + } + console.error(`Storage fetch failed, preserving previous data. Error: ${finalErrorMessage}`); + } + } + + function getStorageTypeIcon(type) { + switch(type) { + case 'dir': + return ''; + case 'lvm': + case 'lvmthin': + return ''; + case 'zfs': + case 'zfspool': + return ''; + case 'nfs': + case 'cifs': + return ''; + case 'cephfs': + case 'rbd': + return ''; + default: + return ''; + } + } + + function getContentBadgeDetails(contentType) { + let details = { + badgeClass: 'bg-gray-200 dark:bg-gray-600 text-gray-700 dark:text-gray-300', + tooltip: `Content type: ${contentType}` + }; + + switch(contentType) { + case 'iso': + details.badgeClass = 'bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300'; + details.tooltip = 'ISO images (e.g., for OS installation)'; + break; + case 'vztmpl': + details.badgeClass = 'bg-purple-100 dark:bg-purple-900/50 text-purple-700 dark:text-purple-300'; + details.tooltip = 'Container templates'; + break; + case 'backup': + details.badgeClass = 'bg-orange-100 dark:bg-orange-900/50 text-orange-700 dark:text-orange-300'; + details.tooltip = 'VM/Container backup files (vzdump)'; + break; + case 'images': + details.badgeClass = 'bg-teal-100 dark:bg-teal-900/50 text-teal-700 dark:text-teal-300'; + details.tooltip = 'VM disk images (qcow2, raw, etc.)'; + break; + case 'rootdir': + details.badgeClass = 'bg-red-100 dark:bg-red-900/50 text-red-700 dark:text-red-300'; + details.tooltip = 'Storage for container root filesystems'; + break; + case 'snippets': + details.badgeClass = 'bg-pink-100 dark:bg-pink-900/50 text-pink-700 dark:text-pink-300'; + details.tooltip = 'Snippet files (e.g., cloud-init configs)'; + break; + } + return details; + } + + function sortNodeStorageData(storageArray) { + if (!storageArray || !Array.isArray(storageArray)) return []; + const sortedArray = [...storageArray]; + sortedArray.sort((a, b) => { + const nameA = String(a.storage || '').toLowerCase(); + const nameB = String(b.storage || '').toLowerCase(); + return nameA.localeCompare(nameB); + }); + return sortedArray; + } + + function updateStorageInfo() { + const contentDiv = document.getElementById('storage-info-content'); + if (!contentDiv) return; + contentDiv.innerHTML = ''; + contentDiv.className = ''; + + const storage = PulseApp.state.get('storageData'); + + if (storage && storage.globalError) { + contentDiv.innerHTML = `

Error: ${storage.globalError}

`; + return; + } + + const nodeKeys = storage ? Object.keys(storage) : []; + const hasValidNodeData = nodeKeys.length > 0 && nodeKeys.some(key => Array.isArray(storage[key])); + const allNodesAreErrors = nodeKeys.length > 0 && nodeKeys.every(key => storage[key] && storage[key].error); + + if (nodeKeys.length === 0) { + contentDiv.innerHTML = '

No storage data received from server.

'; + return; + } else if (allNodesAreErrors) { + contentDiv.innerHTML = '

Failed to load storage data for all nodes. Check server logs.

'; + return; + } else if (!hasValidNodeData) { + contentDiv.innerHTML = '

Received unexpected storage data format from server.

'; + return; + } + + const table = document.createElement('table'); + table.className = 'w-full text-sm border-collapse table-auto min-w-full'; + + const thead = document.createElement('thead'); + thead.innerHTML = ` + + Storage + Content + Type + Shared + Usage + Avail + Total + + `; + table.appendChild(thead); + + const tbody = document.createElement('tbody'); + tbody.className = 'divide-y divide-gray-200 dark:divide-gray-600'; + + const sortedNodeNames = Object.keys(storage).sort((a, b) => a.localeCompare(b)); + + sortedNodeNames.forEach(nodeName => { + const nodeStorageData = storage[nodeName]; + + 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'; + nodeHeaderRow.innerHTML = ` + + + Node: ${nodeName} + `; + tbody.appendChild(nodeHeaderRow); + + if (nodeStorageData.error) { + const errorRow = document.createElement('tr'); + errorRow.innerHTML = `Error loading storage: ${nodeStorageData.error}`; + tbody.appendChild(errorRow); + return; + } + + if (!Array.isArray(nodeStorageData) || nodeStorageData.length === 0) { + const noDataRow = document.createElement('tr'); + noDataRow.innerHTML = `No storage configured or found for this node.`; + tbody.appendChild(noDataRow); + return; + } + + const sortedNodeStorageData = sortNodeStorageData(nodeStorageData); + + sortedNodeStorageData.forEach(store => { + const row = document.createElement('tr'); + const isDisabled = store.enabled === 0 || store.active === 0; + row.className = `transition-all duration-150 ease-out hover:bg-gray-100 dark:hover:bg-gray-700/60 hover:shadow-md hover:-translate-y-px ${isDisabled ? 'opacity-50 grayscale-[50%]' : ''}`; + + const usagePercent = store.total > 0 ? (store.used / store.total) * 100 : 0; + const usageTooltipText = `${PulseApp.utils.formatBytes(store.used)} / ${PulseApp.utils.formatBytes(store.total)} (${usagePercent.toFixed(1)}%)`; + + const usageColorClass = PulseApp.utils.getUsageColor(usagePercent); + const sharedIconTooltip = store.shared === 1 ? 'Shared across cluster' : 'Local to node'; + 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(''); + + const usageBarHTML = PulseApp.utils.createProgressTextBarHTML(usagePercent, usageTooltipText, usageColorClass); + + row.innerHTML = ` + ${store.storage || 'N/A'} + ${contentBadges || '-'} + ${store.type || 'N/A'} + ${sharedIcon} + ${usageBarHTML} + ${PulseApp.utils.formatBytes(store.avail)} + ${PulseApp.utils.formatBytes(store.total)} + `; + tbody.appendChild(row); + }); + }); + + table.appendChild(thead); + table.appendChild(tbody); + contentDiv.appendChild(table); + } + + return { + fetchStorageData, + updateStorageInfo + }; +})(); \ No newline at end of file diff --git a/src/public/js/ui/thresholds.js b/src/public/js/ui/thresholds.js new file mode 100644 index 000000000..e5f21a210 --- /dev/null +++ b/src/public/js/ui/thresholds.js @@ -0,0 +1,205 @@ +PulseApp.ui = PulseApp.ui || {}; + +PulseApp.ui.thresholds = (() => { + let thresholdRow = null; + let toggleThresholdsButton = null; + let thresholdBadge = null; + let sliders = {}; + let thresholdSelects = {}; + let startLogButton = null; + + 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'), + memory: document.getElementById('threshold-slider-memory'), + disk: document.getElementById('threshold-slider-disk'), + }; + thresholdSelects = { + diskread: document.getElementById('threshold-select-diskread'), + diskwrite: document.getElementById('threshold-select-diskwrite'), + netin: document.getElementById('threshold-select-netin'), + netout: document.getElementById('threshold-select-netout'), + }; + + applyInitialThresholdUI(); + updateThresholdIndicator(); + updateThresholdRowVisibility(); + updateLogControlsVisibility(); + + if (toggleThresholdsButton) { + toggleThresholdsButton.addEventListener('click', () => { + PulseApp.state.set('isThresholdRowVisible', !PulseApp.state.get('isThresholdRowVisible')); + updateThresholdRowVisibility(); + }); + } else { + console.warn('#toggle-thresholds-button not found.'); + } + + setupThresholdListeners(); + } + + function applyInitialThresholdUI() { + const thresholdState = PulseApp.state.getThresholdState(); + for (const type in thresholdState) { + if (sliders[type]) { + const sliderElement = sliders[type]; + if (sliderElement) sliderElement.value = thresholdState[type].value; + } else if (thresholdSelects[type]) { + const selectElement = thresholdSelects[type]; + if (selectElement) selectElement.value = thresholdState[type].value; + } + } + } + + function setupThresholdListeners() { + for (const type in sliders) { + if (sliders[type]) { + const sliderElement = sliders[type]; + sliderElement.addEventListener('input', (event) => { + const value = event.target.value; + updateThreshold(type, value); + PulseApp.tooltips.updateSliderTooltip(event.target); + }); + + const showTooltip = (event) => PulseApp.tooltips.updateSliderTooltip(event.target); + sliderElement.addEventListener('mousedown', showTooltip); + sliderElement.addEventListener('touchstart', showTooltip, { passive: true }); + } else { + console.warn(`Slider element not found for type: ${type}`); + } + } + + for (const type in thresholdSelects) { + const selectElement = thresholdSelects[type]; + if (selectElement) { + selectElement.addEventListener('change', (event) => { + const value = event.target.value; + updateThreshold(type, value); + }); + } + } + } + + function updateThreshold(type, value) { + PulseApp.state.setThresholdValue(type, value); + + if (PulseApp.ui && PulseApp.ui.dashboard) { + PulseApp.ui.dashboard.updateDashboardTable(); + } else { + console.warn('[Thresholds] PulseApp.ui.dashboard not available for updateDashboardTable'); + } + updateThresholdIndicator(); + updateLogControlsVisibility(); + } + + function updateThresholdRowVisibility() { + const isVisible = PulseApp.state.get('isThresholdRowVisible'); + if (thresholdRow) { + thresholdRow.classList.toggle('hidden', !isVisible); + if (toggleThresholdsButton) { + toggleThresholdsButton.classList.toggle('bg-blue-100', isVisible); + toggleThresholdsButton.classList.toggle('dark:bg-blue-800/50', isVisible); + toggleThresholdsButton.classList.toggle('text-blue-700', isVisible); + toggleThresholdsButton.classList.toggle('dark:text-blue-300', isVisible); + } + } + } + + function updateThresholdIndicator() { + if (!thresholdBadge) return; + + const mainTableHeader = document.querySelector('#main-table thead'); + if (!mainTableHeader) return; + + const thresholdState = PulseApp.state.getThresholdState(); + let activeCount = 0; + for (const type in thresholdState) { + const defaultColorClasses = ['text-gray-600', 'dark:text-gray-300']; + const activeColorClasses = ['text-blue-600', 'dark:text-blue-400']; + const headerCell = mainTableHeader.querySelector(`th[data-sort="${type}"]`); + + if (thresholdState[type].value > 0) { + activeCount++; + if (headerCell) { + headerCell.classList.add('threshold-active-header'); + headerCell.classList.remove(...defaultColorClasses); + headerCell.classList.add(...activeColorClasses); + } + } else { + if (headerCell) { + headerCell.classList.remove('threshold-active-header'); + headerCell.classList.remove(...activeColorClasses); + headerCell.classList.add(...defaultColorClasses); + } + } + } + + if (activeCount > 0) { + thresholdBadge.textContent = activeCount; + thresholdBadge.classList.remove('hidden'); + } else { + thresholdBadge.classList.add('hidden'); + } + } + + function resetThresholds() { + const thresholdState = PulseApp.state.getThresholdState(); + for (const type in thresholdState) { + PulseApp.state.setThresholdValue(type, 0); + if (sliders[type]) { + const sliderElement = sliders[type]; + if (sliderElement) sliderElement.value = 0; + } else if (thresholdSelects[type]) { + const selectElement = thresholdSelects[type]; + if (selectElement) selectElement.value = 0; + } + } + PulseApp.tooltips.hideSliderTooltip(); + 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); + } + + return { + init, + resetThresholds, + updateLogControlsVisibility + }; +})(); \ No newline at end of file diff --git a/src/public/js/utils.js b/src/public/js/utils.js new file mode 100644 index 000000000..daa2f58ba --- /dev/null +++ b/src/public/js/utils.js @@ -0,0 +1,183 @@ +PulseApp.utils = (() => { + function getUsageColor(percentage) { + if (percentage >= 90) return 'red'; + if (percentage >= 75) return 'yellow'; + return 'green'; + } + + function createProgressTextBarHTML(percentage, text, color) { + // Always use a neutral background regardless of the progress color + const bgColorClass = 'bg-gray-200 dark:bg-gray-700'; + + const progressColorClass = { + red: 'bg-red-500/50 dark:bg-red-500/50', + yellow: 'bg-yellow-500/50 dark:bg-yellow-500/50', + green: 'bg-green-500/50 dark:bg-green-500/50' + }[color] || 'bg-gray-500/50'; // Fallback progress color with opacity + + return ` +
+
+ ${text} +
+ `; + } + + function formatBytes(bytes, decimals = 1, k = 1024) { + if (bytes === 0 || bytes === null || bytes === undefined) return '0 B'; + const dm = decimals < 0 ? 0 : decimals; + const sizes = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; + } + + function formatSpeed(bytesPerSecond, decimals = 1) { + if (bytesPerSecond === null || bytesPerSecond === undefined) return 'N/A'; + if (bytesPerSecond < 1) return '0 B/s'; + return formatBytes(bytesPerSecond, decimals) + '/s'; + } + + function formatUptime(seconds) { + if (seconds === null || seconds === undefined || seconds < 0) return 'N/A'; + if (seconds < 60) return `${Math.floor(seconds)}s`; + if (seconds < 3600) return `${Math.floor(seconds / 60)}m`; + if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`; + const days = Math.floor(seconds / 86400); + return `${days}d`; + } + + function formatDuration(seconds) { + if (seconds === null || seconds === undefined || seconds < 0) return 'N/A'; + if (seconds < 1) return `< 1s`; + if (seconds < 60) return `${Math.round(seconds)}s`; + const minutes = Math.floor(seconds / 60); + const remainingSeconds = Math.round(seconds % 60); + if (minutes < 60) { + return `${minutes}m ${remainingSeconds}s`; + } + const hours = Math.floor(minutes / 60); + const remainingMinutes = minutes % 60; + return `${hours}h ${remainingMinutes}m`; + } + + function formatPbsTimestamp(timestamp) { + if (!timestamp) return 'N/A'; + try { + const date = new Date(timestamp * 1000); + const now = new Date(); + const isToday = date.toDateString() === now.toDateString(); + const timeOptions = { hour: '2-digit', minute: '2-digit' }; + const dateOptions = { month: 'short', day: 'numeric' }; + + if (isToday) { + return `Today ${date.toLocaleTimeString([], timeOptions)}`; + } else { + return `${date.toLocaleDateString([], dateOptions)} ${date.toLocaleTimeString([], timeOptions)}`; + } + } catch (e) { + console.error("Error formatting PBS timestamp:", timestamp, e); + return 'Invalid Date'; + } + } + + function getReadableThresholdName(type) { + const names = { + cpu: 'CPU', + memory: 'Memory', + disk: 'Disk Usage', + diskread: 'Disk Read', + diskwrite: 'Disk Write', + netin: 'Net In', + netout: 'Net Out' + }; + return names[type] || type; + } + + function formatThresholdValue(type, value) { + const numericValue = Number(value); + if (isNaN(numericValue)) return 'N/A'; + + if (['cpu', 'memory', 'disk'].includes(type)) { + return `${Math.round(numericValue)}%`; + } + if (['diskread', 'diskwrite', 'netin', 'netout'].includes(type)) { + return formatSpeed(numericValue, 0); + } + return String(value); // Fallback + } + + function getReadableThresholdCriteria(type, value) { + const operatorMap = { + diskread: '>=', + diskwrite: '>=', + netin: '>=', + netout: '>=' + }; + const operator = operatorMap[type] || '>='; + const displayValue = formatThresholdValue(type, value); + return `${type}${operator}${displayValue}`; + } + + function sortData(data, column, direction, tableType = 'main') { + if (!column) return data; + + const sortStates = PulseApp.state.getSortState(tableType); + const effectiveDirection = direction || sortStates.direction; + + return [...data].sort((a, b) => { + let valA = a[column]; + let valB = b[column]; + + if (column === 'id' || column === 'vmid' || column === 'guestId') { + valA = parseInt(valA, 10); + valB = parseInt(valB, 10); + } + else if (column === 'name' || column === 'node' || column === 'guestName' || column === 'guestType' || column === 'pbsInstanceName' || column === 'datastoreName') { + valA = String(valA || '').toLowerCase(); + valB = String(valB || '').toLowerCase(); + } + else if (['cpu', 'memory', 'disk', 'maxcpu', 'maxmem', 'maxdisk', 'uptime', 'loadavg', 'loadnorm', 'totalBackups'].includes(column)) { + valA = parseFloat(valA || 0); + valB = parseFloat(valB || 0); + } + else if (['diskread', 'diskwrite', 'netin', 'netout'].includes(column)) { + valA = parseFloat(valA || 0); + valB = parseFloat(valB || 0); + } + else if (column === 'latestBackupTime') { + valA = parseInt(valA || 0, 10); + valB = parseInt(valB || 0, 10); + } + else if (column === 'backupHealthStatus') { + const healthOrder = { 'failed': 0, 'old': 1, 'stale': 2, 'ok': 3, 'none': 4 }; + valA = healthOrder[valA] ?? 99; + valB = healthOrder[valB] ?? 99; + } + + let comparison = 0; + if (valA < valB) { + comparison = -1; + } else if (valA > valB) { + comparison = 1; + } + + return effectiveDirection === 'desc' ? (comparison * -1) : comparison; + }); + } + + // Return the public API for this module + return { + sanitizeForId: (str) => str.replace(/[^a-zA-Z0-9-]/g, '-'), + getUsageColor, + createProgressTextBarHTML, + formatBytes, + formatSpeed, + formatUptime, + formatDuration, + formatPbsTimestamp, + getReadableThresholdName, + formatThresholdValue, + getReadableThresholdCriteria, + sortData + }; +})(); \ No newline at end of file