Refactor: Add modular JS files from app.js refactor

This commit is contained in:
courtmanr@gmail.com
2025-05-04 12:24:37 +01:00
parent 5b36e0c95d
commit 5375bb7fc2
17 changed files with 3421 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
const PulseApp = window.PulseApp || {};
PulseApp.config = {
AVERAGING_WINDOW_SIZE: 5,
INITIAL_PBS_TASK_LIMIT: 5
};
+21
View File
@@ -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;
});
})();
+108
View File
@@ -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
});
+136
View File
@@ -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
};
})();
+129
View File
@@ -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];
}
};
})();
+254
View File
@@ -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 = `
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="inline-block align-middle"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/><line x1="12" y1="18" x2="12" y2="12"/><line x1="9" y1="15" x2="15" y2="15"/></svg>
<span class="log-tab-title" title="${fullCriteriaDesc || 'Log started ' + shortTitle}">${shortTitle}</span>
`;
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 = '<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>';
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
};
})();
+35
View File
@@ -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
};
})();
+398
View File
@@ -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 = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>';
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 = `
<thead class="sticky top-0 bg-gray-100 dark:bg-gray-700 z-10">
<tr class="border-b border-gray-300 dark:border-gray-600">
<th class="p-1 px-2 text-left font-semibold text-gray-600 dark:text-gray-300">Time</th>
<th class="p-1 px-2 text-left font-semibold text-gray-600 dark:text-gray-300">Guest</th>
<th class="p-1 px-2 text-left font-semibold text-gray-600 dark:text-gray-300">Node</th>
<th class="p-1 px-2 text-right font-semibold text-gray-600 dark:text-gray-300">CPU</th>
<th class="p-1 px-2 text-right font-semibold text-gray-600 dark:text-gray-300">Mem</th>
<th class="p-1 px-2 text-right font-semibold text-gray-600 dark:text-gray-300">Disk%</th>
<th class="p-1 px-2 text-right font-semibold text-gray-600 dark:text-gray-300">DRead</th>
<th class="p-1 px-2 text-right font-semibold text-gray-600 dark:text-gray-300">DWrite</th>
<th class="p-1 px-2 text-right font-semibold text-gray-600 dark:text-gray-300">NetIn</th>
<th class="p-1 px-2 text-right font-semibold text-gray-600 dark:text-gray-300">NetOut</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<tr class="initial-log-message">
<td colspan="10" class="p-2 text-center text-gray-500 dark:text-gray-400 italic">
Logging started continuously<span class="dot-animate">.</span><span class="dot-animate">.</span><span class="dot-animate">.</span>
</td>
</tr>
</tbody>
`;
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') ? `<strong>${entry.cpuFormatted}</strong>` : entry.cpuFormatted;
const memValueHTML = entry.activeThresholdKeys.includes('memory') ? `<strong>${entry.memFormatted}</strong>` : entry.memFormatted;
const diskValueHTML = entry.activeThresholdKeys.includes('disk') ? `<strong>${entry.diskFormatted}</strong>` : entry.diskFormatted;
const diskReadValueHTML = entry.activeThresholdKeys.includes('diskread') ? `<strong>${entry.diskReadFormatted}</strong>` : entry.diskReadFormatted;
const diskWriteValueHTML = entry.activeThresholdKeys.includes('diskwrite') ? `<strong>${entry.diskWriteFormatted}</strong>` : entry.diskWriteFormatted;
const netInValueHTML = entry.activeThresholdKeys.includes('netin') ? `<strong>${entry.netInFormatted}</strong>` : entry.netInFormatted;
const netOutValueHTML = entry.activeThresholdKeys.includes('netout') ? `<strong>${entry.netOutFormatted}</strong>` : entry.netOutFormatted;
const guestDisplayHTML = entry.guestMatchedSearch ? `<strong>${entry.guestName} (${entry.guestId})</strong>` : `${entry.guestName} (${entry.guestId})`;
const nodeDisplayHTML = entry.nodeMatchedSearch ? `<strong>${entry.node}</strong>` : entry.node;
row.innerHTML = `
<td class="p-1 px-2 whitespace-nowrap">${entry.timestamp.toLocaleTimeString()}</td>
<td class="p-1 px-2 whitespace-nowrap" title="${entry.guestName}">${guestDisplayHTML}</td>
<td class="p-1 px-2 whitespace-nowrap">${nodeDisplayHTML}</td>
<td class="p-1 px-2 whitespace-nowrap text-right">${cpuValueHTML}</td>
<td class="p-1 px-2 whitespace-nowrap text-right">${memValueHTML}</td>
<td class="p-1 px-2 whitespace-nowrap text-right">${diskValueHTML}</td>
<td class="p-1 px-2 whitespace-nowrap text-right">${diskReadValueHTML}</td>
<td class="p-1 px-2 whitespace-nowrap text-right">${diskWriteValueHTML}</td>
<td class="p-1 px-2 whitespace-nowrap text-right">${netInValueHTML}</td>
<td class="p-1 px-2 whitespace-nowrap text-right">${netOutValueHTML}</td>
`;
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
};
})();
+110
View File
@@ -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
};
})();
+319
View File
@@ -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)
: '<span class="text-gray-400">No backups found</span>';
let healthIndicator = '';
switch (guestStatus.backupHealthStatus) {
case 'ok':
healthIndicator = '<span class="text-green-600 dark:text-green-400" title="OK">●</span>';
break;
case 'stale':
healthIndicator = '<span class="text-yellow-600 dark:text-yellow-400" title="Stale">●</span>';
break;
case 'failed':
healthIndicator = '<span class="text-red-600 dark:text-red-400 font-bold" title="Failed">✖</span>';
break;
case 'old':
healthIndicator = '<span class="text-orange-600 dark:text-orange-400" title="Old">●</span>';
break;
case 'none':
healthIndicator = '<span class="text-gray-400 dark:text-gray-500" title="None">-</span>';
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 = `<span class="type-icon inline-block rounded text-xs align-middle ${typeIconClass}">${guestStatus.guestType}</span>`;
row.innerHTML = `
<td class="p-1 px-2 whitespace-nowrap text-center">${healthIndicator}</td>
<td class="p-1 px-2 whitespace-nowrap font-medium text-gray-900 dark:text-gray-100" title="${guestStatus.guestName}">${guestStatus.guestName}</td>
<td class="p-1 px-2 text-center text-gray-500 dark:text-gray-400">${guestStatus.guestId}</td>
<td class="p-1 px-2 text-center">${typeIcon}</td>
<td class="p-1 px-2 whitespace-nowrap text-gray-500 dark:text-gray-400">${guestStatus.node}</td>
<td class="p-1 px-2 whitespace-nowrap text-gray-500 dark:text-gray-400">${latestBackupFormatted}</td>
<td class="p-1 px-2 whitespace-nowrap text-gray-500 dark:text-gray-400">${guestStatus.pbsInstanceName}</td>
<td class="p-1 px-2 whitespace-nowrap text-gray-500 dark:text-gray-400">${guestStatus.datastoreName}</td>
<td class="p-1 px-2 text-center text-gray-500 dark:text-gray-400">${guestStatus.totalBackups}</td>
`;
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
};
})();
+282
View File
@@ -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
};
})();
+354
View File
@@ -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 = `<td colspan="11" class="px-2 py-1">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="inline-block mr-1 align-middle"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"></rect><rect x="2" y="14" width="20" height="8" rx="2" ry="2"></rect><line x1="6" y1="6" x2="6.01" y2="6"></line><line x1="6" y1="18" x2="6.01" y2="18"></line></svg>
${nodeName}
</td>`;
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 = `<tr><td colspan="11" class="p-4 text-center text-gray-500 dark:text-gray-400">${message}</td></tr>`;
}
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 = `<span class="text-xs text-gray-700 dark:text-gray-200 truncate">${totalDiskFormatted}</span>`;
} 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 = `<span class="type-icon inline-block rounded text-xs align-middle ${typeIconClass}">${guest.type === 'VM' ? 'VM' : 'LXC'}</span>`;
row.innerHTML = `
<td class="p-1 px-2 whitespace-nowrap truncate" title="${guest.name}">${guest.name}</td>
<td class="p-1 px-1 text-center">${typeIcon}</td>
<td class="p-1 px-2 text-center">${guest.id}</td>
<td class="p-1 px-2 whitespace-nowrap">${guest.status === 'stopped' ? '-' : PulseApp.utils.formatUptime(guest.uptime)}</td>
<td class="p-1 px-2">${cpuBarHTML}</td>
<td class="p-1 px-2">${memoryBarHTML}</td>
<td class="p-1 px-2">${diskBarHTML}</td>
<td class="p-1 px-2 text-right whitespace-nowrap">${diskReadFormatted}</td>
<td class="p-1 px-2 text-right whitespace-nowrap">${diskWriteFormatted}</td>
<td class="p-1 px-2 text-right whitespace-nowrap">${netInFormatted}</td>
<td class="p-1 px-2 text-right whitespace-nowrap">${netOutFormatted}</td>
`;
return row;
}
return {
init,
refreshDashboardData,
updateDashboardTable,
createGuestRow
};
})();
+87
View File
@@ -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 = '<tr><td colspan="7" class="p-4 text-center text-gray-500 dark:text-gray-400">No nodes found or data unavailable</td></tr>';
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 = `
<td class="p-1 px-2 whitespace-nowrap">
<span class="flex items-center">
<span class="h-2.5 w-2.5 rounded-full ${statusColor} mr-2 flex-shrink-0"></span>
<span class="capitalize">${statusText}</span>
</span>
</td>
<td class="p-1 px-2 whitespace-nowrap font-medium text-gray-900 dark:text-gray-100" title="${node.node || 'N/A'}">${node.node || 'N/A'}</td>
<td class="p-1 px-2 text-right min-w-[200px]">${cpuBarHTML}</td>
<td class="p-1 px-2 text-right min-w-[200px]">${memoryBarHTML}</td>
<td class="p-1 px-2 text-right min-w-[200px]">${diskBarHTML}</td>
<td class="p-1 px-2 text-right whitespace-nowrap">${uptimeFormatted}</td>
<td class="p-1 px-2 text-right whitespace-nowrap">${normalizedLoadFormatted}</td>
`;
tbody.appendChild(row);
});
}
return {
updateNodesTable
};
})();
+567
View File
@@ -0,0 +1,567 @@
PulseApp.ui = PulseApp.ui || {};
PulseApp.ui.pbs = (() => {
const getPbsStatusIcon = (status) => {
if (status === 'OK') {
return '<span class="text-green-500 dark:text-green-400" title="OK">✓</span>';
} else if (status === 'running') {
return '<span class="inline-block animate-spin rounded-full h-3 w-3 border-t-2 border-b-2 border-blue-500" title="Running"></span>';
} else if (status) {
return `<span class="text-red-500 dark:text-red-400 font-bold" title="${status}">✗</span>`;
} else {
return '<span class="text-gray-400" title="Unknown">?</span>';
}
};
const getPbsGcStatusText = (gcStatus) => {
if (!gcStatus || gcStatus === 'unknown' || gcStatus === 'N/A') {
return '<span class="text-xs text-gray-400">-</span>';
}
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 `<span class="text-xs ${colorClass}">${gcStatus}</span>`;
};
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 = `
<td class="px-4 py-2 text-sm text-gray-700 dark:text-gray-300">${target}</td>
<td class="px-4 py-2 text-sm text-center">${statusIcon}</td>
<td class="px-4 py-2 text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">${startTime}</td>
<td class="px-4 py-2 text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">${duration}</td>
<td class="px-4 py-2 text-xs font-mono text-gray-400 dark:text-gray-500 truncate" title="${upid}">${shortUpid}</td>
`;
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 = `
<td class="px-4 py-2 text-sm text-gray-700 dark:text-gray-300">${target}</td>
<td class="px-4 py-2 text-sm text-center">${statusIcon}</td>
<td class="px-4 py-2 text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">${startTime}</td>
<td class="px-4 py-2 text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">${duration}</td>
<td class="px-4 py-2 text-xs font-mono text-gray-400 dark:text-gray-500 truncate" title="${upid}">${shortUpid}</td>
`;
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 = `
<h4 class="text-md font-semibold mb-2 text-gray-700 dark:text-gray-300">${title} (7d)</h4>
<div class="space-y-1 text-sm">
<div><span class="font-medium text-gray-800 dark:text-gray-200">OK:</span> <span class="ml-1 text-green-600 dark:text-green-400 font-semibold">${ok}</span></div>
<div><span class="font-medium text-gray-800 dark:text-gray-200">Failed:</span> <span class="ml-1 ${failedStyle}">${failed}</span></div>
<div><span class="font-medium text-gray-800 dark:text-gray-200">Total:</span> <span class="ml-1 text-gray-700 dark:text-gray-300 font-semibold">${total}</span></div>
<div><span class="font-medium text-gray-800 dark:text-gray-200">Last OK:</span> <span class="ml-1 text-gray-600 dark:text-gray-400 text-xs">${lastOk}</span></div>
<div><span class="font-medium text-gray-800 dark:text-gray-200">Last Fail:</span> <span class="ml-1 text-gray-600 dark:text-gray-400 text-xs">${lastFailed}</span></div>
</div>`;
return card;
};
const createTaskTableHTML = (tableId, title, idColumnHeader) => {
const tbodyId = tableId.replace('-table-', '-tbody-');
const toggleButtonContainerId = tableId.replace('-table', '-toggle-container');
return `
<h4 class="text-md font-semibold mb-2 text-gray-700 dark:text-gray-300">Recent ${title} Tasks</h4>
<div class="overflow-x-auto border border-gray-200 dark:border-gray-700 rounded">
<table id="${tableId}" class="min-w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm">
<thead class="text-xs text-gray-700 dark:text-gray-300 uppercase bg-gray-100 dark:bg-gray-700/50 sticky top-0">
<tr>
<th scope="col" class="px-4 py-2 text-left font-semibold">${idColumnHeader}</th>
<th scope="col" class="px-4 py-2 text-left font-semibold">Status</th>
<th scope="col" class="px-4 py-2 text-left font-semibold">Start Time</th>
<th scope="col" class="px-4 py-2 text-left font-semibold">Duration</th>
<th scope="col" class="px-4 py-2 text-left font-semibold">UPID</th>
</tr>
</thead>
<tbody id="${tbodyId}" class="pbs-task-tbody divide-y divide-gray-200 dark:divide-gray-700">
<!-- Populated by JS -->
</tbody>
</table>
</div>
<div id="${toggleButtonContainerId}" class="pbs-toggle-button-container pt-1 text-right">
<button class="pbs-show-more text-xs text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 hidden">Show More</button>
<p class="pbs-no-tasks text-xs text-gray-400 dark:text-gray-500 hidden italic">No recent tasks found.</p>
</div>
`;
};
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 `<span title="${title}" class="inline-block w-3 h-3 ${colorClass} rounded-full mr-2 flex-shrink-0"></span>`;
};
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 = `<tr><td colspan="7" class="px-4 py-4 text-sm text-gray-400 text-center">No PBS datastores found or accessible.</td></tr>`;
} 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 = `<td class="px-4 py-2 whitespace-nowrap">${ds.name || 'N/A'}</td> <td class="px-4 py-2 whitespace-nowrap text-gray-500 dark:text-gray-400">${ds.path || 'N/A'}</td> <td class="px-4 py-2 text-right whitespace-nowrap">${PulseApp.utils.formatBytes(usedBytes)}</td> <td class="px-4 py-2 text-right whitespace-nowrap">${PulseApp.utils.formatBytes(availableBytes)}</td> <td class="px-4 py-2 text-right whitespace-nowrap">${totalBytes > 0 ? PulseApp.utils.formatBytes(totalBytes) : 'N/A'}</td> <td class="px-4 py-2 text-center min-w-[150px]">${totalBytes > 0 ? PulseApp.utils.createProgressTextBarHTML(usagePercent, usageText, usageColor) : '-'}</td> <td class="px-4 py-2 text-center whitespace-nowrap">${gcStatusHtml}</td>`;
dsTableBody.appendChild(row);
});
}
} else {
dsTableBody.innerHTML = `<tr><td colspan="7" class="px-4 py-4 text-sm text-gray-400 text-center">${statusText}</td></tr>`;
}
}
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 = `<tr><td colspan="5" class="px-4 py-4 text-sm text-gray-400 text-center">${statusText}</td></tr>`;
const verifyTbody = document.getElementById(`pbs-recent-verify-tasks-tbody-${instanceId}`);
if (verifyTbody) verifyTbody.innerHTML = `<tr><td colspan="5" class="px-4 py-4 text-sm text-gray-400 text-center">${statusText}</td></tr>`;
const syncTbody = document.getElementById(`pbs-recent-sync-tasks-tbody-${instanceId}`);
if (syncTbody) syncTbody.innerHTML = `<tr><td colspan="5" class="px-4 py-4 text-sm text-gray-400 text-center">${statusText}</td></tr>`;
const pruneTbody = document.getElementById(`pbs-recent-prunegc-tasks-tbody-${instanceId}`);
if (pruneTbody) pruneTbody.innerHTML = `<tr><td colspan="5" class="px-4 py-4 text-sm text-gray-400 text-center">${statusText}</td></tr>`;
}
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 = `
<h4 class="text-md font-semibold mb-2 text-gray-700 dark:text-gray-300">Datastores</h4>
<div class="overflow-x-auto border border-gray-200 dark:border-gray-700 rounded">
<table id="pbs-ds-table-${instanceId}" class="min-w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm">
<thead class="text-xs text-gray-700 dark:text-gray-300 uppercase bg-gray-100 dark:bg-gray-700/50">
<tr>
<th scope="col" class="px-4 py-2 text-left font-semibold dark:text-gray-300">Name</th>
<th scope="col" class="px-4 py-2 text-left font-semibold dark:text-gray-300">Path</th>
<th scope="col" class="px-4 py-2 text-right font-semibold dark:text-gray-300">Used</th>
<th scope="col" class="px-4 py-2 text-right font-semibold dark:text-gray-300">Available</th>
<th scope="col" class="px-4 py-2 text-right font-semibold dark:text-gray-300">Total</th>
<th scope="col" class="px-4 py-2 text-center font-semibold dark:text-gray-300">Usage</th>
<th scope="col" class="px-4 py-2 text-center font-semibold dark:text-gray-300">GC Status</th>
</tr>
</thead>
<tbody id="pbs-ds-tbody-${instanceId}" class="divide-y divide-gray-200 dark:divide-gray-700"></tbody>
</table>
</div>`;
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 = `<tr><td colspan="7" class="px-4 py-4 text-sm text-gray-400 text-center">No PBS datastores found or accessible.</td></tr>`; }
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 = `<td class="px-4 py-2 whitespace-nowrap">${ds.name || 'N/A'}</td> <td class="px-4 py-2 whitespace-nowrap text-gray-500 dark:text-gray-400">${ds.path || 'N/A'}</td> <td class="px-4 py-2 text-right whitespace-nowrap">${PulseApp.utils.formatBytes(usedBytes)}</td> <td class="px-4 py-2 text-right whitespace-nowrap">${PulseApp.utils.formatBytes(availableBytes)}</td> <td class="px-4 py-2 text-right whitespace-nowrap">${totalBytes > 0 ? PulseApp.utils.formatBytes(totalBytes) : 'N/A'}</td> <td class="px-4 py-2 text-center min-w-[150px]">${totalBytes > 0 ? PulseApp.utils.createProgressTextBarHTML(usagePercent, usageText, usageColor) : '-'}</td> <td class="px-4 py-2 text-center whitespace-nowrap">${gcStatusHtml}</td>`;
dsTableBody.appendChild(row);
});
}
} else { dsTableBody.innerHTML = `<tr><td colspan="7" class="px-4 py-4 text-sm text-gray-400 text-center">${statusText}</td></tr>`; }
}
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 = `<tr><td colspan="5" class="px-4 py-4 text-sm text-gray-400 text-center">${statusText}</td></tr>`;
const verifyTbody = document.getElementById(`pbs-recent-verify-tasks-tbody-${instanceId}`);
if (verifyTbody) verifyTbody.innerHTML = `<tr><td colspan="5" class="px-4 py-4 text-sm text-gray-400 text-center">${statusText}</td></tr>`;
const syncTbody = document.getElementById(`pbs-recent-sync-tasks-tbody-${instanceId}`);
if (syncTbody) syncTbody.innerHTML = `<tr><td colspan="5" class="px-4 py-4 text-sm text-gray-400 text-center">${statusText}</td></tr>`;
const pruneTbody = document.getElementById(`pbs-recent-prunegc-tasks-tbody-${instanceId}`);
if (pruneTbody) pruneTbody.innerHTML = `<tr><td colspan="5" class="px-4 py-4 text-sm text-gray-400 text-center">${statusText}</td></tr>`;
}
}
});
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
};
})();
+227
View File
@@ -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 '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="inline-block mr-1 align-middle text-yellow-600 dark:text-yellow-400"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg>';
case 'lvm':
case 'lvmthin':
return '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="inline-block mr-1 align-middle text-purple-600 dark:text-purple-400"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>';
case 'zfs':
case 'zfspool':
return '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="inline-block mr-1 align-middle text-red-600 dark:text-red-400"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"></polyline></svg>';
case 'nfs':
case 'cifs':
return '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="inline-block mr-1 align-middle text-blue-600 dark:text-blue-400"><path d="M16 17l5-5-5-5"></path><path d="M8 17l-5-5 5-5"></path></svg>';
case 'cephfs':
case 'rbd':
return '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="inline-block mr-1 align-middle text-indigo-600 dark:text-indigo-400"><path d="M18 8h1a4 4 0 0 1 0 8h-1"></path><path d="M2 8h16v9a4 4 0 0 1-4 4H6a4 4 0 0 1-4-4V8z"></path><line x1="6" y1="1" x2="6" y2="4"></line><line x1="10" y1="1" x2="10" y2="4"></line><line x1="14" y1="1" x2="14" y2="4"></line></svg>';
default:
return '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="inline-block mr-1 align-middle text-gray-500"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg>';
}
}
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 = `<p class="p-4 text-red-700 dark:text-red-300">Error: ${storage.globalError}</p>`;
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 = '<p class="text-gray-500 dark:text-gray-400 p-4 text-center">No storage data received from server.</p>';
return;
} else if (allNodesAreErrors) {
contentDiv.innerHTML = '<p class="text-red-600 dark:text-red-400 p-4 text-center">Failed to load storage data for all nodes. Check server logs.</p>';
return;
} else if (!hasValidNodeData) {
contentDiv.innerHTML = '<p class="text-yellow-600 dark:text-yellow-400 p-4 text-center">Received unexpected storage data format from server.</p>';
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 = `
<tr class="border-b border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-700 sticky top-0 z-10">
<th class="text-left p-2 px-3 font-semibold text-gray-700 dark:text-gray-300">Storage</th>
<th class="text-left p-2 px-3 font-semibold text-gray-700 dark:text-gray-300">Content</th>
<th class="text-left p-2 px-3 font-semibold text-gray-700 dark:text-gray-300">Type</th>
<th class="text-left p-2 px-3 font-semibold text-gray-700 dark:text-gray-300">Shared</th>
<th class="text-left p-2 px-3 font-semibold text-gray-700 dark:text-gray-300">Usage</th>
<th class="text-left p-2 px-3 font-semibold text-gray-700 dark:text-gray-300">Avail</th>
<th class="text-left p-2 px-3 font-semibold text-gray-700 dark:text-gray-300">Total</th>
</tr>
`;
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 = `
<td colspan="7" class="p-1.5 px-3">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="inline-block mr-1 align-middle"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"></rect><rect x="2" y="14" width="20" height="8" rx="2" ry="2"></rect><line x1="6" y1="6" x2="6.01" y2="6"></line><line x1="6" y1="18" x2="6.01" y2="18"></line></svg>
Node: ${nodeName}
</td>`;
tbody.appendChild(nodeHeaderRow);
if (nodeStorageData.error) {
const errorRow = document.createElement('tr');
errorRow.innerHTML = `<td colspan="7" class="p-2 px-3 text-sm text-red-600 dark:text-red-400 italic">Error loading storage: ${nodeStorageData.error}</td>`;
tbody.appendChild(errorRow);
return;
}
if (!Array.isArray(nodeStorageData) || nodeStorageData.length === 0) {
const noDataRow = document.createElement('tr');
noDataRow.innerHTML = `<td colspan="7" class="p-2 px-3 text-sm text-gray-500 dark:text-gray-400 italic">No storage configured or found for this node.</td>`;
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 ? `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="inline-block text-green-600 dark:text-green-400"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path><polyline points="15 3 21 3 21 9"></polyline><line x1="10" y1="14" x2="21" y2="3"></line></svg>`
: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="inline-block text-gray-400 dark:text-gray-500 opacity-50"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"></rect><rect x="2" y="14" width="20" height="8" rx="2" ry="2"></rect><line x1="6" y1="6" x2="6.01" y2="6"></line><line x1="6" y1="18" x2="6.01" y2="18"></line></svg>`;
const contentTypes = (store.content || '').split(',').map(ct => ct.trim()).filter(ct => ct);
contentTypes.sort();
const contentBadges = contentTypes.map(ct => {
const details = getContentBadgeDetails(ct);
return `<span data-tooltip="${details.tooltip}" class="storage-tooltip-trigger inline-block ${details.badgeClass} rounded px-1.5 py-0.5 text-xs font-medium mr-1 cursor-default">${ct}</span>`;
}).join('');
const usageBarHTML = PulseApp.utils.createProgressTextBarHTML(usagePercent, usageTooltipText, usageColorClass);
row.innerHTML = `
<td class="p-1 px-2 whitespace-nowrap text-gray-900 dark:text-gray-100 font-medium">${store.storage || 'N/A'}</td>
<td class="p-1 px-2 whitespace-nowrap text-gray-600 dark:text-gray-300 text-xs flex items-center">${contentBadges || '-'}</td>
<td class="p-1 px-2 whitespace-nowrap text-gray-600 dark:text-gray-300">${store.type || 'N/A'}</td>
<td class="p-1 px-2 whitespace-nowrap text-center storage-tooltip-trigger cursor-default" data-tooltip="${sharedIconTooltip}">${sharedIcon}</td>
<td class="p-1 px-2 text-gray-600 dark:text-gray-300 min-w-[250px]">${usageBarHTML}</td>
<td class="p-1 px-2 whitespace-nowrap text-gray-600 dark:text-gray-300 text-right">${PulseApp.utils.formatBytes(store.avail)}</td>
<td class="p-1 px-2 whitespace-nowrap text-gray-600 dark:text-gray-300 text-right">${PulseApp.utils.formatBytes(store.total)}</td>
`;
tbody.appendChild(row);
});
});
table.appendChild(thead);
table.appendChild(tbody);
contentDiv.appendChild(table);
}
return {
fetchStorageData,
updateStorageInfo
};
})();
+205
View File
@@ -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
};
})();
+183
View File
@@ -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 `
<div class="relative w-full h-4 rounded overflow-hidden ${bgColorClass}">
<div class="absolute top-0 left-0 h-full ${progressColorClass}" style="width: ${percentage}%;"></div>
<span class="absolute inset-0 flex items-center justify-center text-[10px] font-medium text-gray-800 dark:text-gray-100 leading-none">${text}</span>
</div>
`;
}
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
};
})();