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} |
+ Status |
+ Start Time |
+ Duration |
+ UPID |
+
+
+
+
+
+
+
+
+ `;
+ };
+
+ 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
+
+
+
+
+ | Name |
+ Path |
+ Used |
+ Available |
+ Total |
+ Usage |
+ GC 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 `
+
+ `;
+ }
+
+ 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