mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-23 03:33:53 +00:00
Refactor: Add modular JS files from app.js refactor
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
const PulseApp = window.PulseApp || {};
|
||||
|
||||
PulseApp.config = {
|
||||
AVERAGING_WINDOW_SIZE: 5,
|
||||
INITIAL_PBS_TASK_LIMIT: 5
|
||||
};
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
})();
|
||||
@@ -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
|
||||
});
|
||||
@@ -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
|
||||
};
|
||||
})();
|
||||
@@ -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];
|
||||
}
|
||||
};
|
||||
})();
|
||||
@@ -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
|
||||
};
|
||||
})();
|
||||
@@ -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
|
||||
};
|
||||
})();
|
||||
@@ -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
|
||||
};
|
||||
})();
|
||||
@@ -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
|
||||
};
|
||||
})();
|
||||
@@ -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
|
||||
};
|
||||
})();
|
||||
@@ -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
|
||||
};
|
||||
})();
|
||||
@@ -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
|
||||
};
|
||||
})();
|
||||
@@ -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
|
||||
};
|
||||
})();
|
||||
@@ -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
|
||||
};
|
||||
})();
|
||||
@@ -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
|
||||
};
|
||||
})();
|
||||
@@ -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
|
||||
};
|
||||
})();
|
||||
@@ -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
|
||||
};
|
||||
})();
|
||||
Reference in New Issue
Block a user