Feat: Implement frontend charting, alert handling, and UI optimizations

This commit is contained in:
courtmanr@gmail.com
2025-05-24 22:02:09 +01:00
parent b374d7261d
commit f4ce22bb73
12 changed files with 860 additions and 332 deletions
+8 -1
View File
@@ -16,9 +16,16 @@ document.addEventListener('DOMContentLoaded', function() {
PulseApp.ui.pbs?.updatePbsInfo(pbsDataArray);
PulseApp.ui.backups?.updateBackupsTab();
// Update tab availability based on PBS data
PulseApp.ui.tabs?.updateTabAvailability();
updateLoadingOverlayVisibility(); // Call the helper function
PulseApp.thresholds?.logging?.checkThresholdViolations();
// Update alerts when state changes
const state = PulseApp.state.getFullState();
PulseApp.alerts?.updateAlertsFromState?.(state);
}
function updateLoadingOverlayVisibility() {
@@ -52,6 +59,7 @@ document.addEventListener('DOMContentLoaded', function() {
// If socketHandler.init only expects one, this might need adjustment in socketHandler.js
PulseApp.socketHandler?.init?.(updateAllUITables, updateLoadingOverlayVisibility);
PulseApp.tooltips?.init?.();
PulseApp.alerts?.init?.();
PulseApp.ui = PulseApp.ui || {};
PulseApp.ui.tabs?.init?.();
@@ -64,7 +72,6 @@ document.addEventListener('DOMContentLoaded', function() {
PulseApp.ui.common?.init?.();
PulseApp.thresholds = PulseApp.thresholds || {};
PulseApp.thresholds.logging?.init?.();
}
function validateCriticalElements() {
+353 -117
View File
@@ -1,156 +1,392 @@
PulseApp.socketHandler = (() => {
let socket = null;
let uiUpdateCallback = () => { console.warn('[socketHandler] uiUpdateCallback not assigned.'); };
let loadingOverlayCallback = () => { console.warn('[socketHandler] loadingOverlayCallback not assigned.'); };
let isConnected = false;
let reconnectAttempts = 0;
const maxReconnectAttempts = 10;
const reconnectDelay = 2000; // 2 seconds
function init(updateFunctionRef, overlayUpdateRef) {
socket = io();
uiUpdateCallback = updateFunctionRef;
loadingOverlayCallback = overlayUpdateRef;
socket.on('connect', handleConnect);
socket.on('disconnect', handleDisconnect);
socket.on('initialState', handleInitialState);
socket.on('rawData', handleRawData);
socket.on('pbsInitialStatus', handlePbsInitialStatus);
socket.on('hotReload', () => {
console.log('[socketHandler] Hot reload requested. Reloading page...');
window.location.reload();
});
// Optional: for debugging all events
// socket.onAny((eventName, ...args) => {
// console.log(`[Socket Event Debug] Event: ${eventName}`, args);
// });
function init() {
console.log('[Socket] Initializing socket connection...');
createSocket();
}
function updateConnectionStatusUI(isConnected) {
const connectionStatus = document.getElementById('connection-status');
if (!connectionStatus) return;
function createSocket() {
if (socket) {
socket.removeAllListeners();
socket.disconnect();
}
const statusText = isConnected ? 'Connected' : 'Disconnected';
const addClasses = isConnected
? ['connected', 'bg-green-100', 'dark:bg-green-800/30', 'text-green-700', 'dark:text-green-300']
: ['disconnected', 'bg-red-100', 'dark:bg-red-800/30', 'text-red-700', 'dark:text-red-300'];
const removeClasses = isConnected
? ['disconnected', 'bg-gray-200', 'dark:bg-gray-700', 'text-gray-600', 'dark:text-gray-400', 'bg-red-100', 'dark:bg-red-800/30', 'text-red-700', 'dark:text-red-300']
: ['connected', 'bg-green-100', 'dark:bg-green-800/30', 'text-green-700', 'dark:text-green-300', 'bg-gray-200', 'dark:bg-gray-700', 'text-gray-600', 'dark:text-gray-400'];
socket = io();
window.socket = socket; // Make socket available globally for alerts
connectionStatus.textContent = statusText;
connectionStatus.classList.remove(...removeClasses);
connectionStatus.classList.add(...addClasses);
setupEventListeners();
}
function setupEventListeners() {
socket.on('connect', handleConnect);
socket.on('disconnect', handleDisconnect);
socket.on('rawData', handleRawData);
socket.on('initialState', handleInitialState);
socket.on('requestError', handleRequestError);
// Enhanced monitoring events
socket.on('alert', handleAlert);
socket.on('alertResolved', handleAlertResolved);
// Development features
socket.on('hotReload', handleHotReload);
// Handle connection errors
socket.on('connect_error', handleConnectError);
socket.on('reconnect', handleReconnect);
socket.on('reconnect_error', handleReconnectError);
socket.on('reconnect_failed', handleReconnectFailed);
}
function handleConnect() {
updateConnectionStatusUI(true);
PulseApp.state.set('wasConnected', true);
requestFullData(); // Request data on new connection or reconnection
if (typeof loadingOverlayCallback === 'function') {
loadingOverlayCallback(); // Update overlay based on current state
}
console.log('[Socket] Connected to server');
isConnected = true;
reconnectAttempts = 0;
updateConnectionStatus('connected');
// Request initial data
socket.emit('requestData');
}
function handleDisconnect(reason) {
updateConnectionStatusUI(false);
PulseApp.state.set('wasConnected', false);
if (typeof loadingOverlayCallback === 'function') {
loadingOverlayCallback(); // This should show the overlay with "Connection lost"
}
}
function handleInitialState(state) {
console.log('[socketHandler] Received initial state:', state);
const isPlaceholder = state.isConfigPlaceholder || false;
PulseApp.state.set('isConfigPlaceholder', isPlaceholder);
PulseApp.state.set('initialDataReceived', false); // Mark that full data hasn't arrived yet
const statusText = document.getElementById('dashboard-status-text');
if (statusText) {
statusText.textContent = isPlaceholder ? 'Configuration Required' : 'Loading initial data...';
}
console.log('[Socket] Disconnected from server:', reason);
isConnected = false;
updateConnectionStatus('disconnected');
if (typeof loadingOverlayCallback === 'function') {
loadingOverlayCallback(); // Update overlay based on placeholder status and lack of data
// If it's not a planned disconnect, try to reconnect
if (reason !== 'io client disconnect') {
attemptReconnect();
}
}
function handleRawData(jsonData) {
function handleRawData(data) {
try {
const data = typeof jsonData === 'string' ? JSON.parse(jsonData) : jsonData;
// Update main application state
if (PulseApp.state) {
PulseApp.state.updateState(data);
}
PulseApp.state.set('isConfigPlaceholder', data.isConfigPlaceholder || false);
PulseApp.state.set('nodesData', data.nodes || []);
PulseApp.state.set('vmsData', data.vms || []);
PulseApp.state.set('containersData', data.containers || []);
PulseApp.state.set('metricsData', data.metrics || []); // Ensure metrics are always set
PulseApp.state.set('pbsDataArray', Array.isArray(data.pbs) ? data.pbs : []);
if (PulseApp.ui?.tabs) {
PulseApp.ui.tabs.updateTabAvailability();
// Update alerts system with new state
if (PulseApp.alerts && data.alerts) {
PulseApp.alerts.updateAlertsFromState(data);
}
// Set initialDataReceived to true only after successfully processing raw data
PulseApp.state.set('initialDataReceived', true);
if (typeof loadingOverlayCallback === 'function') {
loadingOverlayCallback(); // Hide overlay if not placeholder and data received
}
if (typeof uiUpdateCallback === 'function') {
uiUpdateCallback();
} else {
console.error('[socketHandler] uiUpdateCallback is not a function!');
}
} catch (e) {
console.error('Error processing received rawData:', e, jsonData);
// Process UI updates based on tab
updateUIFromData(data);
} catch (error) {
console.error('[Socket] Error processing raw data:', error);
}
}
function handlePbsInitialStatus(pbsStatusArray) {
if (Array.isArray(pbsStatusArray)) {
const initialPbsData = pbsStatusArray.map(statusInfo => ({
...statusInfo, // Spread existing status info
// Initialize other fields if they might be missing from statusInfo
backupTasks: statusInfo.backupTasks || { recentTasks: [], summary: {} },
datastores: statusInfo.datastores || [],
verificationTasks: statusInfo.verificationTasks || { summary: {} },
syncTasks: statusInfo.syncTasks || { summary: {} },
pruneTasks: statusInfo.pruneTasks || { summary: {} },
nodeName: statusInfo.nodeName || null
}));
PulseApp.state.set('pbsDataArray', initialPbsData);
// Don't set initialDataReceived here; wait for full rawData
function handleInitialState(data) {
console.log('[Socket] Received initial state:', data);
try {
if (PulseApp.state) {
PulseApp.state.updateState(data);
}
updateUIFromData(data);
} catch (error) {
console.error('[Socket] Error processing initial state:', error);
}
}
if (PulseApp.ui?.pbs) {
PulseApp.ui.pbs.updatePbsInfo(initialPbsData);
}
if (PulseApp.ui?.tabs) {
PulseApp.ui.tabs.updateTabAvailability();
}
if (typeof loadingOverlayCallback === 'function') {
loadingOverlayCallback(); // Update overlay, might still show "Loading..."
}
function handleRequestError(error) {
console.error('[Socket] Request error:', error);
updateConnectionStatus('error');
}
function handleAlert(alert) {
console.log('[Socket] Received alert:', alert);
// Forward to alerts handler
if (PulseApp.alerts) {
// The alerts handler will be called directly from its socket listeners
// This is just for any additional processing
}
}
function handleAlertResolved(alert) {
console.log('[Socket] Alert resolved:', alert);
// Forward to alerts handler
if (PulseApp.alerts) {
// The alerts handler will be called directly from its socket listeners
// This is just for any additional processing
}
}
function handleHotReload() {
console.log('[Socket] Hot reload triggered');
if (process.env.NODE_ENV === 'development') {
window.location.reload();
}
}
function handleConnectError(error) {
console.error('[Socket] Connection error:', error);
updateConnectionStatus('error');
}
function handleReconnect() {
console.log('[Socket] Reconnected successfully');
reconnectAttempts = 0;
updateConnectionStatus('connected');
}
function handleReconnectError(error) {
console.error('[Socket] Reconnection error:', error);
reconnectAttempts++;
updateConnectionStatus('reconnecting');
}
function handleReconnectFailed() {
console.error('[Socket] Reconnection failed - max attempts reached');
updateConnectionStatus('failed');
}
function attemptReconnect() {
if (reconnectAttempts < maxReconnectAttempts) {
reconnectAttempts++;
updateConnectionStatus('reconnecting');
setTimeout(() => {
console.log(`[Socket] Attempting to reconnect... (${reconnectAttempts}/${maxReconnectAttempts})`);
socket.connect();
}, reconnectDelay * reconnectAttempts); // Exponential backoff
} else {
console.warn('[socket] Received non-array data for pbsInitialStatus:', pbsStatusArray);
updateConnectionStatus('failed');
}
}
function requestFullData() {
console.log('Requesting full data reload from server...');
if (socket && socket.connected) { // Check if socket is connected before emitting
function updateConnectionStatus(status) {
const statusElement = document.getElementById('connection-status');
if (!statusElement) return;
// Clear previous classes
statusElement.className = statusElement.className
.replace(/\b(connected|disconnected|reconnecting|error|failed)\b/g, '')
.trim();
let statusText, statusClass;
switch (status) {
case 'connected':
statusText = 'Connected';
statusClass = 'connected text-xs px-2 py-1 rounded-full bg-green-100 dark:bg-green-900 text-green-600 dark:text-green-400';
break;
case 'disconnected':
statusText = 'Disconnected';
statusClass = 'disconnected text-xs px-2 py-1 rounded-full bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400';
break;
case 'reconnecting':
statusText = `Reconnecting... (${reconnectAttempts}/${maxReconnectAttempts})`;
statusClass = 'reconnecting text-xs px-2 py-1 rounded-full bg-yellow-100 dark:bg-yellow-900 text-yellow-600 dark:text-yellow-400 animate-pulse';
break;
case 'error':
statusText = 'Connection Error';
statusClass = 'error text-xs px-2 py-1 rounded-full bg-red-100 dark:bg-red-900 text-red-600 dark:text-red-400';
break;
case 'failed':
statusText = 'Connection Failed';
statusClass = 'failed text-xs px-2 py-1 rounded-full bg-red-100 dark:bg-red-900 text-red-600 dark:text-red-400';
break;
default:
statusText = 'Unknown';
statusClass = 'text-xs px-2 py-1 rounded-full bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400';
}
statusElement.textContent = statusText;
statusElement.className = statusClass;
}
function updateUIFromData(data) {
try {
// Hide loading overlay when we receive data
const loadingOverlay = document.getElementById('loading-overlay');
if (loadingOverlay && (data.nodes || data.vms || data.containers || data.pbs)) {
loadingOverlay.style.display = 'none';
}
// Update different UI sections based on current tab
const activeTab = document.querySelector('.tab.active');
if (!activeTab) return;
const tabName = activeTab.getAttribute('data-tab');
switch (tabName) {
case 'main':
updateMainTab(data);
break;
case 'storage':
updateStorageTab(data);
break;
case 'pbs':
updatePbsTab(data);
break;
case 'backups':
updateBackupsTab(data);
break;
}
// Update performance indicators if available
updatePerformanceIndicators(data);
} catch (error) {
console.error('[Socket] Error updating UI from data:', error);
}
}
function updateMainTab(data) {
try {
// Update node summary cards
if (PulseApp.ui && PulseApp.ui.nodes && data.nodes) {
PulseApp.ui.nodes.updateNodeSummaryCards(data.nodes);
}
// Update main dashboard
if (PulseApp.ui && PulseApp.ui.dashboard) {
PulseApp.ui.dashboard.updateDashboardTable();
}
} catch (error) {
console.error('[Socket] Error updating main tab:', error);
}
}
function updateStorageTab(data) {
try {
if (PulseApp.ui && PulseApp.ui.storage && data.nodes) {
PulseApp.ui.storage.updateStorageInfo();
}
} catch (error) {
console.error('[Socket] Error updating storage tab:', error);
}
}
function updatePbsTab(data) {
try {
if (PulseApp.ui && PulseApp.ui.pbs && data.pbs) {
PulseApp.ui.pbs.updatePbsInfo(data.pbs);
}
} catch (error) {
console.error('[Socket] Error updating PBS tab:', error);
}
}
function updateBackupsTab(data) {
try {
if (PulseApp.ui && PulseApp.ui.backups && data.pbs) {
PulseApp.ui.backups.updateBackupsTab();
}
} catch (error) {
console.error('[Socket] Error updating backups tab:', error);
}
}
function updatePerformanceIndicators(data) {
try {
// Update performance stats if available
if (data.stats) {
updateStatsDisplay(data.stats);
}
// Update any health indicators
if (data.performance) {
updateHealthDisplay(data.performance);
}
} catch (error) {
console.error('[Socket] Error updating performance indicators:', error);
}
}
function updateStatsDisplay(stats) {
// Update various stats in the UI
try {
const statusText = document.getElementById('dashboard-status-text');
if (statusText && stats.totalGuests !== undefined) {
const runningText = stats.runningGuests ? `${stats.runningGuests} running` : '0 running';
const stoppedText = stats.stoppedGuests ? `${stats.stoppedGuests} stopped` : '0 stopped';
statusText.textContent = `${stats.totalGuests} total guests (${runningText}, ${stoppedText})`;
}
} catch (error) {
console.error('[Socket] Error updating stats display:', error);
}
}
function updateHealthDisplay(performance) {
// Update health indicators in the UI
try {
// Could add health indicators to the header or other parts of the UI
// For now, this is just a placeholder for future enhancements
} catch (error) {
console.error('[Socket] Error updating health display:', error);
}
}
// Manual data request
function requestData() {
if (socket && isConnected) {
socket.emit('requestData');
} else {
console.error('Cannot request data: socket not initialized or not connected.');
console.warn('[Socket] Cannot request data - not connected');
}
}
function isConnected() {
return socket && socket.connected;
// Get connection status
function getConnectionStatus() {
return {
connected: isConnected,
reconnectAttempts,
socket: socket ? socket.id : null
};
}
// Manual reconnect
function reconnect() {
if (socket) {
reconnectAttempts = 0;
socket.disconnect();
socket.connect();
}
}
// Cleanup
function destroy() {
if (socket) {
socket.removeAllListeners();
socket.disconnect();
socket = null;
}
isConnected = false;
window.socket = null;
}
// Public API
return {
init,
requestFullData,
isConnected
requestData,
getConnectionStatus,
reconnect,
destroy
};
})();
// Auto-initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', PulseApp.socketHandler.init);
} else {
PulseApp.socketHandler.init();
}
+134 -28
View File
@@ -19,8 +19,39 @@ PulseApp.state = (() => {
backupsFilterHealth: savedFilterState.backupsFilterHealth || 'all',
backupsFilterGuestType: savedFilterState.backupsFilterGuestType || 'all',
backupsSearchTerm: '',
// Enhanced monitoring data
alerts: {
active: [],
stats: {},
rules: []
},
performance: {
lastDiscoveryTime: null,
lastMetricsTime: null,
discoveryDuration: 0,
metricsDuration: 0,
errorCount: 0,
successCount: 0,
avgResponseTime: 0,
peakMemoryUsage: 0
},
stats: {
totalGuests: 0,
runningGuests: 0,
stoppedGuests: 0,
totalNodes: 0,
healthyNodes: 0,
warningNodes: 0,
errorNodes: 0,
avgCpuUsage: 0,
avgMemoryUsage: 0,
avgDiskUsage: 0,
lastUpdated: null
},
isConfigPlaceholder: false,
sortState: {
nodes: { column: null, direction: 'asc', ...(savedSortState.nodes || {}) },
main: { column: 'id', direction: 'asc', ...(savedSortState.main || {}) },
backups: { column: 'latestBackupTime', direction: 'desc', ...(savedSortState.backups || {}) }
},
@@ -32,34 +63,28 @@ PulseApp.state = (() => {
diskwrite:{ value: 0 },
netin: { value: 0 },
netout: { value: 0 }
},
activeLogSessions: {},
thresholdLogEntries: [],
activeLoggingThresholds: null
}
};
// Initialize thresholdState by merging saved state with defaults
Object.keys(internalState.thresholdState).forEach(type => {
const savedTypeState = savedThresholdState[type] || {};
if (internalState.thresholdState[type].hasOwnProperty('operator')) { // Assuming this structure means it's the advanced threshold type
if (internalState.thresholdState[type].hasOwnProperty('operator')) {
internalState.thresholdState[type] = {
operator: savedTypeState.operator || '>=',
input: savedTypeState.input || '',
// Preserve any other default properties if they exist
...internalState.thresholdState[type],
...savedTypeState // This ensures saved values overwrite defaults but keeps other default props
...savedTypeState
};
} else { // Simple value threshold
} else {
internalState.thresholdState[type] = {
value: savedTypeState.value || 0,
// Preserve any other default properties
...internalState.thresholdState[type],
...savedTypeState
};
}
});
function saveFilterState() {
const stateToSave = {
groupByNode: internalState.groupByNode,
@@ -74,13 +99,94 @@ PulseApp.state = (() => {
function saveSortState() {
const stateToSave = {
nodes: internalState.sortState.nodes,
main: internalState.sortState.main,
backups: internalState.sortState.backups
};
localStorage.setItem('pulseSortState', JSON.stringify(stateToSave));
}
function updateState(newData) {
try {
console.log('[State] Updating state with new data:', Object.keys(newData));
// Update core data arrays
if (newData.nodes) internalState.nodesData = newData.nodes;
if (newData.vms) internalState.vmsData = newData.vms;
if (newData.containers) internalState.containersData = newData.containers;
if (newData.metrics) internalState.metricsData = newData.metrics;
if (newData.pbs) internalState.pbsDataArray = newData.pbs;
// Update enhanced monitoring data
if (newData.alerts) {
internalState.alerts = {
active: newData.alerts.active || [],
stats: newData.alerts.stats || {},
rules: newData.alerts.rules || []
};
}
if (newData.performance) {
internalState.performance = { ...internalState.performance, ...newData.performance };
}
if (newData.stats) {
internalState.stats = { ...internalState.stats, ...newData.stats };
}
// Update configuration status
if (newData.hasOwnProperty('isConfigPlaceholder')) {
internalState.isConfigPlaceholder = newData.isConfigPlaceholder;
}
// Combine VMs and containers for dashboard
internalState.dashboardData = [...internalState.vmsData, ...internalState.containersData];
// Mark that we've received initial data
if (!internalState.initialDataReceived && internalState.dashboardData.length > 0) {
internalState.initialDataReceived = true;
console.log('[State] Initial data received and processed');
}
// Update dashboard history for charts
updateDashboardHistoryFromMetrics();
} catch (error) {
console.error('[State] Error updating state:', error);
}
}
function updateDashboardHistoryFromMetrics() {
try {
internalState.metricsData.forEach(metric => {
if (metric && metric.current) {
const guestId = `${metric.endpointId}-${metric.node}-${metric.id}`;
const dataPoint = {
timestamp: Date.now(),
cpu: metric.current.cpu * 100 || 0,
memory: metric.current.mem || 0,
disk: metric.current.disk || 0,
diskread: metric.current.diskread || 0,
diskwrite: metric.current.diskwrite || 0,
netin: metric.current.netin || 0,
netout: metric.current.netout || 0
};
if (!internalState.dashboardHistory[guestId] || !Array.isArray(internalState.dashboardHistory[guestId])) {
internalState.dashboardHistory[guestId] = [];
}
const history = internalState.dashboardHistory[guestId];
history.push(dataPoint);
if (history.length > PulseApp.config.AVERAGING_WINDOW_SIZE) {
history.shift();
}
}
});
} catch (error) {
console.error('[State] Error updating dashboard history:', error);
}
}
return {
get: (key) => internalState[key],
set: (key, value) => {
@@ -89,6 +195,11 @@ PulseApp.state = (() => {
saveFilterState();
}
},
// Enhanced state management
updateState,
getFullState: () => ({ ...internalState }),
setSortState: (tableType, column, direction) => {
if (internalState.sortState[tableType]) {
internalState.sortState[tableType] = { column, direction };
@@ -108,21 +219,6 @@ PulseApp.state = (() => {
console.warn(`Attempted to set threshold for unknown type: ${type}`);
}
},
getActiveLogSession: (sessionId) => internalState.activeLogSessions[sessionId],
getAllActiveLogSessions: () => internalState.activeLogSessions,
addActiveLogSession: (sessionId, sessionData) => {
internalState.activeLogSessions[sessionId] = sessionData;
},
removeActiveLogSession: (sessionId) => {
delete internalState.activeLogSessions[sessionId];
},
addLogEntry: (sessionId, entry) => {
if (internalState.activeLogSessions[sessionId]) {
internalState.activeLogSessions[sessionId].entries.push(entry);
} else {
console.warn(`Attempted to add log entry to non-existent session: ${sessionId}`);
}
},
getDashboardHistory: () => internalState.dashboardHistory,
updateDashboardHistory: (guestId, dataPoint) => {
if (!internalState.dashboardHistory[guestId] || !Array.isArray(internalState.dashboardHistory[guestId])) {
@@ -134,6 +230,16 @@ PulseApp.state = (() => {
},
clearDashboardHistoryEntry: (guestId) => {
delete internalState.dashboardHistory[guestId];
}
},
forceRefreshDashboard: () => {
if (PulseApp.socketHandler && typeof PulseApp.socketHandler.requestData === 'function') {
PulseApp.socketHandler.requestData();
}
},
// Alert and performance data getters
getAlerts: () => internalState.alerts,
getPerformance: () => internalState.performance,
getStats: () => internalState.stats
};
})();
+5 -5
View File
@@ -5,13 +5,15 @@ PulseApp.ui.tabs = (() => {
let tabContents = [];
let nestedTabsContainer = null;
let nestedTabContentContainer = null;
let mainTabsContainer = null;
let logSessionArea = null;
function init() {
tabs = document.querySelectorAll('.tab');
tabs = Array.from(document.querySelectorAll('.tab'));
tabContents = document.querySelectorAll('.tab-content');
nestedTabsContainer = document.querySelector('.nested-tabs');
nestedTabContentContainer = document.querySelector('#log-content-area');
nestedTabContentContainer = document.getElementById('nested-tab-content-container');
mainTabsContainer = document.getElementById('main-tabs-container');
logSessionArea = document.getElementById('log-session-area');
// Initial styling pass for all tabs to ensure consistent look from the start
@@ -350,8 +352,6 @@ PulseApp.ui.tabs = (() => {
return {
init,
activateNestedTab,
updateTabAvailability,
addLogTab,
removeLogTabAndContent
updateTabAvailability
};
})();
+19 -1
View File
@@ -102,9 +102,27 @@ PulseApp.tooltips = (() => {
}
}
function showTooltip(event, content) {
if (!tooltipElement) return;
tooltipElement.innerHTML = content;
positionTooltip(event);
tooltipElement.classList.remove('hidden', 'opacity-0');
tooltipElement.classList.add('opacity-100');
}
function hideTooltip() {
if (tooltipElement) {
tooltipElement.classList.add('hidden', 'opacity-0');
tooltipElement.classList.remove('opacity-100');
}
}
return {
init,
updateSliderTooltip,
hideSliderTooltip
hideSliderTooltip,
showTooltip,
hideTooltip
};
})();
+85 -56
View File
@@ -57,33 +57,68 @@ PulseApp.ui.backups = (() => {
)
);
return { allGuests, initialDataReceived, allRecentBackupTasks, allSnapshots };
}
// Pre-index data by guest ID and type for performance
const tasksByGuest = new Map();
const snapshotsByGuest = new Map();
function _determineGuestBackupStatus(guest, allSnapshots, allRecentBackupTasks) {
const guestId = String(guest.vmid);
const guestTypePve = guest.type === 'qemu' ? 'vm' : 'ct';
const now = new Date(); // Use Date object for easier day calculations
now.setHours(0, 0, 0, 0); // Normalize to start of today
allRecentBackupTasks.forEach(task => {
const key = `${task.guestId}-${task.guestTypePbs}`;
if (!tasksByGuest.has(key)) tasksByGuest.set(key, []);
tasksByGuest.get(key).push(task);
});
allSnapshots.forEach(snap => {
const key = `${snap.backupVMID}-${snap.backupType}`;
if (!snapshotsByGuest.has(key)) snapshotsByGuest.set(key, []);
snapshotsByGuest.get(key).push(snap);
});
// Pre-calculate day boundaries for 7-day analysis
const now = new Date();
now.setHours(0, 0, 0, 0);
const dayBoundaries = [];
for (let i = 6; i >= 0; i--) {
const dayStart = new Date(now);
dayStart.setDate(now.getDate() - i);
const dayEnd = new Date(dayStart);
dayEnd.setDate(dayStart.getDate() + 1);
dayBoundaries.push({
start: Math.floor(dayStart.getTime() / 1000),
end: Math.floor(dayEnd.getTime() / 1000)
});
}
const threeDaysAgo = Math.floor(new Date(now).setDate(now.getDate() - 3) / 1000);
const sevenDaysAgoTimestamp = Math.floor(new Date(now).setDate(now.getDate() - 7) / 1000);
const sevenDaysAgo = Math.floor(new Date(now).setDate(now.getDate() - 7) / 1000);
const guestSnapshots = allSnapshots.filter(snap =>
String(snap.backupVMID) === guestId && snap.backupType === guestTypePve
);
const totalBackups = guestSnapshots.length;
const latestSnapshot = guestSnapshots.reduce((latest, snap) => {
return (!latest || (snap['backup-time'] && snap['backup-time'] > latest['backup-time'])) ? snap : latest;
}, null);
return {
allGuests,
initialDataReceived,
tasksByGuest,
snapshotsByGuest,
dayBoundaries,
threeDaysAgo,
sevenDaysAgo
};
}
function _determineGuestBackupStatus(guest, guestSnapshots, guestTasks, dayBoundaries, threeDaysAgo, sevenDaysAgo) {
const guestId = String(guest.vmid);
// Use pre-filtered data instead of filtering large arrays
const totalBackups = guestSnapshots ? guestSnapshots.length : 0;
const latestSnapshot = guestSnapshots && guestSnapshots.length > 0
? guestSnapshots.reduce((latest, snap) => {
return (!latest || (snap['backup-time'] && snap['backup-time'] > latest['backup-time'])) ? snap : latest;
}, null)
: null;
const latestSnapshotTime = latestSnapshot ? latestSnapshot['backup-time'] : null;
const guestTasks = allRecentBackupTasks.filter(task =>
task.guestId === guestId && task.guestTypePbs === guestTypePve
);
const latestTask = guestTasks.reduce((latest, task) => {
return (!latest || (task.startTime && task.startTime > latest.startTime)) ? task : latest;
}, null);
const latestTask = guestTasks && guestTasks.length > 0
? guestTasks.reduce((latest, task) => {
return (!latest || (task.startTime && task.startTime > latest.startTime)) ? task : latest;
}, null)
: null;
let healthStatus = 'none';
let displayTimestamp = latestSnapshotTime;
@@ -92,56 +127,50 @@ PulseApp.ui.backups = (() => {
displayTimestamp = latestTask.startTime;
if (latestTask.status === 'OK') {
if (latestTask.startTime >= threeDaysAgo) healthStatus = 'ok';
else if (latestTask.startTime >= sevenDaysAgoTimestamp) healthStatus = 'stale';
else if (latestTask.startTime >= sevenDaysAgo) healthStatus = 'stale';
else healthStatus = 'old';
} else {
healthStatus = 'failed';
}
} else if (latestSnapshotTime) {
if (latestSnapshotTime >= threeDaysAgo) healthStatus = 'ok';
else if (latestSnapshotTime >= sevenDaysAgoTimestamp) healthStatus = 'stale';
else if (latestSnapshotTime >= sevenDaysAgo) healthStatus = 'stale';
else healthStatus = 'old';
} else {
healthStatus = 'none';
displayTimestamp = null;
}
// Calculate 7-day backup status (dot matrix)
const last7DaysBackupStatus = [];
for (let i = 6; i >= 0; i--) { // Iterate from 6 days ago to today
const dayTarget = new Date(now);
dayTarget.setDate(now.getDate() - i);
const dayStartTimestamp = Math.floor(dayTarget.getTime() / 1000);
const dayEndTarget = new Date(dayTarget);
dayEndTarget.setDate(dayTarget.getDate() + 1);
const dayEndTimestamp = Math.floor(dayEndTarget.getTime() / 1000);
// Optimized 7-day backup status calculation using pre-calculated boundaries
const last7DaysBackupStatus = dayBoundaries.map(day => {
let dailyStatus = 'none';
let dailyStatus = 'none'; // Default to 'none'
// Check tasks for this day
const tasksOnThisDay = guestTasks.filter(task =>
task.startTime >= dayStartTimestamp && task.startTime < dayEndTimestamp
);
const failedTaskOnThisDay = tasksOnThisDay.find(task => task.status !== 'OK');
const successfulTaskOnThisDay = tasksOnThisDay.find(task => task.status === 'OK');
if (failedTaskOnThisDay) {
dailyStatus = 'failed';
} else if (successfulTaskOnThisDay) {
dailyStatus = 'ok';
} else {
// If no tasks, check for snapshots as a fallback for successful backup indication
const snapshotOnThisDay = guestSnapshots.some(
snap => snap['backup-time'] >= dayStartTimestamp && snap['backup-time'] < dayEndTimestamp
// Check tasks for this day - using pre-filtered guest tasks
if (guestTasks) {
const failedTaskOnThisDay = guestTasks.find(task =>
task.startTime >= day.start && task.startTime < day.end && task.status !== 'OK'
);
if (snapshotOnThisDay) {
const successfulTaskOnThisDay = guestTasks.find(task =>
task.startTime >= day.start && task.startTime < day.end && task.status === 'OK'
);
if (failedTaskOnThisDay) {
dailyStatus = 'failed';
} else if (successfulTaskOnThisDay) {
dailyStatus = 'ok';
} else if (guestSnapshots) {
// Check snapshots as fallback - using pre-filtered guest snapshots
const snapshotOnThisDay = guestSnapshots.some(
snap => snap['backup-time'] >= day.start && snap['backup-time'] < day.end
);
if (snapshotOnThisDay) {
dailyStatus = 'ok';
}
}
}
last7DaysBackupStatus.push(dailyStatus);
}
return dailyStatus;
});
return {
guestName: guest.name || `Guest ${guest.vmid}`,
@@ -269,7 +298,7 @@ PulseApp.ui.backups = (() => {
return;
}
const { allGuests, initialDataReceived, allRecentBackupTasks, allSnapshots } = _getInitialBackupData();
const { allGuests, initialDataReceived, tasksByGuest, snapshotsByGuest, dayBoundaries, threeDaysAgo, sevenDaysAgo } = _getInitialBackupData();
if (!initialDataReceived) {
loadingMsg.classList.remove('hidden');
@@ -288,7 +317,7 @@ PulseApp.ui.backups = (() => {
}
loadingMsg.classList.add('hidden');
const backupStatusByGuest = allGuests.map(guest => _determineGuestBackupStatus(guest, allSnapshots, allRecentBackupTasks));
const backupStatusByGuest = allGuests.map(guest => _determineGuestBackupStatus(guest, snapshotsByGuest.get(`${guest.vmid}-${guest.type === 'qemu' ? 'vm' : 'ct'}`) || [], tasksByGuest.get(`${guest.vmid}-${guest.type === 'qemu' ? 'vm' : 'ct'}`) || [], dayBoundaries, threeDaysAgo, sevenDaysAgo));
const filteredBackupStatus = _filterBackupData(backupStatusByGuest, backupsSearchInput);
const sortStateBackups = PulseApp.state.getSortState('backups');
+9 -13
View File
@@ -8,7 +8,6 @@ PulseApp.ui.common = (() => {
searchInput = document.getElementById('dashboard-search');
backupsSearchInput = document.getElementById('backups-search');
setupTableSorting('nodes-table');
setupTableSorting('main-table');
setupTableSorting('backups-overview-table');
@@ -41,7 +40,6 @@ PulseApp.ui.common = (() => {
function applyInitialSortUI() {
const mainSortState = PulseApp.state.getSortState('main');
const nodesSortState = PulseApp.state.getSortState('nodes');
const backupsSortState = PulseApp.state.getSortState('backups');
const initialMainHeader = document.querySelector(`#main-table th[data-sort="${mainSortState.column}"]`);
@@ -49,11 +47,6 @@ PulseApp.ui.common = (() => {
updateSortUI('main-table', initialMainHeader);
}
const initialNodesHeader = document.querySelector(`#nodes-table th[data-sort="${nodesSortState.column}"]`);
if (initialNodesHeader) {
updateSortUI('nodes-table', initialNodesHeader);
}
const initialBackupsHeader = document.querySelector(`#backups-overview-table th[data-sort="${backupsSortState.column}"]`);
if (initialBackupsHeader) {
updateSortUI('backups-overview-table', initialBackupsHeader);
@@ -79,7 +72,9 @@ PulseApp.ui.common = (() => {
PulseApp.ui.dashboard.updateDashboardTable();
if (searchInput) searchInput.dispatchEvent(new Event('input'));
PulseApp.state.saveFilterState();
PulseApp.ui.thresholds.updateLogControlsVisibility();
if (PulseApp.ui.thresholds && typeof PulseApp.ui.thresholds.updateLogControlsVisibility === 'function') {
PulseApp.ui.thresholds.updateLogControlsVisibility();
}
}
});
});
@@ -91,7 +86,9 @@ PulseApp.ui.common = (() => {
PulseApp.ui.dashboard.updateDashboardTable();
if (searchInput) searchInput.dispatchEvent(new Event('input'));
PulseApp.state.saveFilterState();
PulseApp.ui.thresholds.updateLogControlsVisibility();
if (PulseApp.ui.thresholds && typeof PulseApp.ui.thresholds.updateLogControlsVisibility === 'function') {
PulseApp.ui.thresholds.updateLogControlsVisibility();
}
}
});
});
@@ -99,7 +96,9 @@ PulseApp.ui.common = (() => {
if (searchInput) {
searchInput.addEventListener('input', function() {
PulseApp.ui.dashboard.updateDashboardTable();
PulseApp.ui.thresholds.updateLogControlsVisibility();
if (PulseApp.ui.thresholds && typeof PulseApp.ui.thresholds.updateLogControlsVisibility === 'function') {
PulseApp.ui.thresholds.updateLogControlsVisibility();
}
});
} else {
console.warn('Element #dashboard-search not found - text filtering disabled.');
@@ -259,9 +258,6 @@ PulseApp.ui.common = (() => {
PulseApp.state.setSortState(tableType, column, newDirection);
switch(tableType) {
case 'nodes':
PulseApp.ui.nodes.updateNodesTable(PulseApp.state.get('nodesData'));
break;
case 'main':
PulseApp.ui.dashboard.updateDashboardTable();
break;
+129 -41
View File
@@ -29,6 +29,17 @@ PulseApp.ui.dashboard = (() => {
tableBodyEl = document.querySelector('#main-table tbody');
statusElementEl = document.getElementById('dashboard-status-text');
// Initialize chart system
if (PulseApp.charts) {
PulseApp.charts.startChartUpdates();
}
// Initialize charts toggle
const chartsToggleButton = document.getElementById('toggle-charts-button');
if (chartsToggleButton) {
chartsToggleButton.addEventListener('click', toggleChartsMode);
}
document.addEventListener('keydown', (event) => {
// Handle Escape for resetting filters
if (event.key === 'Escape') {
@@ -132,7 +143,12 @@ PulseApp.ui.dashboard = (() => {
avgNetOutRate = snapshot.netout;
if (guest.status === STATUS_RUNNING && metrics && metrics.current) {
const currentDataPoint = { timestamp: Date.now(), ...metrics.current };
const currentDataPoint = {
timestamp: Date.now(),
...metrics.current,
// Convert CPU to percentage for consistency
cpu: (metrics.current.cpu || 0) * 100
};
PulseApp.state.updateDashboardHistory(guestUniqueId, currentDataPoint);
const history = PulseApp.state.getDashboardHistory()[guestUniqueId] || [];
avgCpu = _calculateAverage(history, 'cpu') ?? 0;
@@ -159,6 +175,7 @@ PulseApp.ui.dashboard = (() => {
const currentDataPoint = {
timestamp: Date.now(),
...metrics.current,
cpu: (metrics.current.cpu || 0) * 100,
effective_mem: currentMemForAvg,
effective_mem_total: currentMemTotalForDisplay,
effective_mem_source: effectiveMemorySource
@@ -236,8 +253,9 @@ PulseApp.ui.dashboard = (() => {
if (uptimeFormatted.length > maxUptimeLength) maxUptimeLength = uptimeFormatted.length;
});
const nameColWidth = Math.min(Math.max(maxNameLength * 8 + 16, 100), 300);
const uptimeColWidth = Math.max(maxUptimeLength * 7 + 16, 80);
// More aggressive space optimization
const nameColWidth = Math.min(Math.max(maxNameLength * 7 + 12, 80), 250);
const uptimeColWidth = Math.max(maxUptimeLength * 6.5 + 8, 40);
const htmlElement = document.documentElement;
if (htmlElement) {
htmlElement.style.setProperty('--name-col-width', `${nameColWidth}px`);
@@ -280,7 +298,7 @@ PulseApp.ui.dashboard = (() => {
const state = thresholdState[type];
let guestValue;
if (type === METRIC_CPU) guestValue = guest.cpu * 100;
if (type === METRIC_CPU) guestValue = guest.cpu;
else if (type === METRIC_MEMORY) guestValue = guest.memory;
else if (type === METRIC_DISK) guestValue = guest.disk;
else if (type === METRIC_DISK_READ) guestValue = guest.diskread;
@@ -426,35 +444,71 @@ PulseApp.ui.dashboard = (() => {
} else {
console.warn('[Dashboard] PulseApp.ui.common not available for updateSortUI');
}
// Update charts immediately after table is rendered, but only if in charts mode
const mainContainer = document.getElementById('main');
if (PulseApp.charts && visibleCount > 0 && mainContainer && mainContainer.classList.contains('charts-mode')) {
// Use requestAnimationFrame to ensure DOM is fully updated
requestAnimationFrame(() => {
PulseApp.charts.updateAllCharts();
});
}
}
function _createCpuBarHtml(guest) {
if (guest.status !== STATUS_RUNNING) return '-';
const cpuPercent = Math.round(guest.cpu * 100);
const cpuTooltipText = `${cpuPercent}% ${guest.cpus ? `(${(guest.cpu * guest.cpus).toFixed(1)}/${guest.cpus} cores)` : ''}`;
const cpuColorClass = PulseApp.utils.getUsageColor(cpuPercent);
return PulseApp.utils.createProgressTextBarHTML(cpuPercent, cpuTooltipText, cpuColorClass);
const cpuPercent = Math.round(guest.cpu);
const cpuTooltipText = `${cpuPercent}% ${guest.cpus ? `(${(guest.cpu * guest.cpus / 100).toFixed(1)}/${guest.cpus} cores)` : ''}`;
const cpuColorClass = PulseApp.utils.getUsageColor(cpuPercent, 'cpu');
const progressBar = PulseApp.utils.createProgressTextBarHTML(cpuPercent, cpuTooltipText, cpuColorClass);
// Create both text and chart versions
const guestId = guest.uniqueId;
const chartHtml = PulseApp.charts ? PulseApp.charts.createUsageChartHTML(guestId, 'cpu') : '';
return `
<div class="metric-text">${progressBar}</div>
<div class="metric-chart">${chartHtml}</div>
`;
}
function _createMemoryBarHtml(guest) {
if (guest.status !== STATUS_RUNNING) return '-';
const memoryPercent = guest.memory; // This is already a percentage
const memoryPercent = guest.memory;
let memoryTooltipText = `${PulseApp.utils.formatBytes(guest.memoryCurrent)} / ${PulseApp.utils.formatBytes(guest.memoryTotal)} (${memoryPercent}%)`;
if (guest.type === GUEST_TYPE_VM && guest.memorySource === 'guest' && guest.rawHostMemory !== null && guest.rawHostMemory !== undefined) {
memoryTooltipText += ` (Host: ${PulseApp.utils.formatBytes(guest.rawHostMemory)})`;
}
const memColorClass = PulseApp.utils.getUsageColor(memoryPercent);
return PulseApp.utils.createProgressTextBarHTML(memoryPercent, memoryTooltipText, memColorClass);
const memColorClass = PulseApp.utils.getUsageColor(memoryPercent, 'memory');
const progressBar = PulseApp.utils.createProgressTextBarHTML(memoryPercent, memoryTooltipText, memColorClass);
// Create both text and chart versions
const guestId = guest.uniqueId;
const chartHtml = PulseApp.charts ? PulseApp.charts.createUsageChartHTML(guestId, 'memory') : '';
return `
<div class="metric-text">${progressBar}</div>
<div class="metric-chart">${chartHtml}</div>
`;
}
function _createDiskBarHtml(guest) {
if (guest.status !== STATUS_RUNNING) return '-';
if (guest.type === GUEST_TYPE_CT) {
const diskPercent = guest.disk; // This is already a percentage
const diskPercent = guest.disk;
const diskTooltipText = guest.diskTotal ? `${PulseApp.utils.formatBytes(guest.diskCurrent)} / ${PulseApp.utils.formatBytes(guest.diskTotal)} (${diskPercent}%)` : `${diskPercent}%`;
const diskColorClass = PulseApp.utils.getUsageColor(diskPercent);
return PulseApp.utils.createProgressTextBarHTML(diskPercent, diskTooltipText, diskColorClass);
} else { // For VMs, show total disk size, not a progress bar
const diskColorClass = PulseApp.utils.getUsageColor(diskPercent, 'disk');
const progressBar = PulseApp.utils.createProgressTextBarHTML(diskPercent, diskTooltipText, diskColorClass);
// Create both text and chart versions
const guestId = guest.uniqueId;
const chartHtml = PulseApp.charts ? PulseApp.charts.createUsageChartHTML(guestId, 'disk') : '';
return `
<div class="metric-text">${progressBar}</div>
<div class="metric-chart">${chartHtml}</div>
`;
} else {
if (guest.diskTotal) {
return `<span class="text-xs text-gray-700 dark:text-gray-200 truncate">${PulseApp.utils.formatBytes(guest.diskTotal)}</span>`;
}
@@ -474,29 +528,40 @@ PulseApp.ui.dashboard = (() => {
const memoryBarHTML = _createMemoryBarHtml(guest);
const diskBarHTML = _createDiskBarHtml(guest);
const diskReadFormatted = guest.status === STATUS_RUNNING ? PulseApp.utils.formatSpeed(guest.diskread, 0) : '-';
const diskWriteFormatted = guest.status === STATUS_RUNNING ? PulseApp.utils.formatSpeed(guest.diskwrite, 0) : '-';
const diskReadFormatted = guest.status === STATUS_RUNNING ? PulseApp.utils.formatSpeedWithStyling(guest.diskread, 0) : '-';
const diskWriteFormatted = guest.status === STATUS_RUNNING ? PulseApp.utils.formatSpeedWithStyling(guest.diskwrite, 0) : '-';
const netInFormatted = guest.status === STATUS_RUNNING ? PulseApp.utils.formatSpeedWithStyling(guest.netin, 0) : '-';
const netOutFormatted = guest.status === STATUS_RUNNING ? PulseApp.utils.formatSpeedWithStyling(guest.netout, 0) : '-';
// Icons and colors
const upArrow = '↑';
const downArrow = '↓';
let netInIcon = '';
let netOutIcon = '';
if (guest.status === STATUS_RUNNING) {
const netInActive = guest.netin > 0;
const netOutActive = guest.netout > 0;
netInIcon = `<span class="text-xs mr-1 ${netInActive ? 'text-green-500' : 'text-gray-400 dark:text-gray-500'}">${downArrow}</span>`;
netOutIcon = `<span class="text-xs mr-1 ${netOutActive ? 'text-red-500' : 'text-gray-400 dark:text-gray-500'}">${upArrow}</span>`;
} else {
netInIcon = `<span class="text-xs mr-1 text-gray-400 dark:text-gray-500">${downArrow}</span>`;
netOutIcon = `<span class="text-xs mr-1 text-gray-400 dark:text-gray-500">${upArrow}</span>`;
}
// Create I/O cells with both text and chart versions
const guestId = guest.uniqueId;
const netInFormatted = guest.status === STATUS_RUNNING ? PulseApp.utils.formatSpeed(guest.netin, 0) : '-';
const netOutFormatted = guest.status === STATUS_RUNNING ? PulseApp.utils.formatSpeed(guest.netout, 0) : '-';
let diskReadCell, diskWriteCell, netInCell, netOutCell;
if (guest.status === STATUS_RUNNING && PulseApp.charts) {
// Text versions - clean, no arrows
const diskReadText = diskReadFormatted;
const diskWriteText = diskWriteFormatted;
const netInText = netInFormatted;
const netOutText = netOutFormatted;
// Chart versions - clean, no arrows
const diskReadChart = PulseApp.charts.createSparklineHTML(guestId, 'diskread');
const diskWriteChart = PulseApp.charts.createSparklineHTML(guestId, 'diskwrite');
const netInChart = PulseApp.charts.createSparklineHTML(guestId, 'netin');
const netOutChart = PulseApp.charts.createSparklineHTML(guestId, 'netout');
diskReadCell = `<div class="metric-text">${diskReadText}</div><div class="metric-chart">${diskReadChart}</div>`;
diskWriteCell = `<div class="metric-text">${diskWriteText}</div><div class="metric-chart">${diskWriteChart}</div>`;
netInCell = `<div class="metric-text">${netInText}</div><div class="metric-chart">${netInChart}</div>`;
netOutCell = `<div class="metric-text">${netOutText}</div><div class="metric-chart">${netOutChart}</div>`;
} else {
// Fallback to text only for stopped guests - no arrows
diskReadCell = diskReadFormatted;
diskWriteCell = diskWriteFormatted;
netInCell = netInFormatted;
netOutCell = netOutFormatted;
}
const typeIconClass = guest.type === GUEST_TYPE_VM
? 'vm-icon bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 px-1.5 py-0.5 font-medium'
@@ -519,10 +584,10 @@ PulseApp.ui.dashboard = (() => {
<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 whitespace-nowrap">${diskReadFormatted}</td>
<td class="p-1 px-2 whitespace-nowrap">${diskWriteFormatted}</td>
<td class="p-1 px-2 whitespace-nowrap">${netInIcon}${netInFormatted}</td>
<td class="p-1 px-2 whitespace-nowrap">${netOutIcon}${netOutFormatted}</td>
<td class="p-1 px-2">${diskReadCell}</td>
<td class="p-1 px-2">${diskWriteCell}</td>
<td class="p-1 px-2">${netInCell}</td>
<td class="p-1 px-2">${netOutCell}</td>
`;
return row;
}
@@ -547,12 +612,35 @@ PulseApp.ui.dashboard = (() => {
guestMetricDragSnapshot = {};
}
function toggleChartsMode() {
const mainContainer = document.getElementById('main');
const button = document.getElementById('toggle-charts-button');
if (mainContainer.classList.contains('charts-mode')) {
// Switch to metrics mode
mainContainer.classList.remove('charts-mode');
button.title = 'Toggle Charts View';
} else {
// Switch to charts mode
mainContainer.classList.add('charts-mode');
button.title = 'Toggle Metrics View';
// Immediately render charts when switching to charts mode
if (PulseApp.charts) {
requestAnimationFrame(() => {
PulseApp.charts.updateAllCharts();
});
}
}
}
return {
init,
refreshDashboardData,
updateDashboardTable,
createGuestRow,
snapshotGuestMetricsForDrag, // Export snapshot function
clearGuestMetricSnapshots // Export clear function
clearGuestMetricSnapshots, // Export clear function
toggleChartsMode
};
})();
+7 -9
View File
@@ -4,7 +4,7 @@ PulseApp.ui.nodes = (() => {
function _createNodeCpuBarHtml(node) {
const cpuPercent = node.cpu ? (node.cpu * 100) : 0;
const cpuColorClass = PulseApp.utils.getUsageColor(cpuPercent);
const cpuColorClass = PulseApp.utils.getUsageColor(cpuPercent, 'cpu');
const cpuTooltipText = `${cpuPercent.toFixed(1)}%${node.maxcpu && node.maxcpu > 0 ? ` (${(node.cpu * node.maxcpu).toFixed(1)}/${node.maxcpu} cores)` : ''}`;
return PulseApp.utils.createProgressTextBarHTML(cpuPercent, cpuTooltipText, cpuColorClass);
}
@@ -13,7 +13,7 @@ PulseApp.ui.nodes = (() => {
const memUsed = node.mem || 0;
const memTotal = node.maxmem || 0;
const memPercent = (memUsed && memTotal > 0) ? (memUsed / memTotal * 100) : 0;
const memColorClass = PulseApp.utils.getUsageColor(memPercent);
const memColorClass = PulseApp.utils.getUsageColor(memPercent, 'memory');
const memTooltipText = `${PulseApp.utils.formatBytes(memUsed)} / ${PulseApp.utils.formatBytes(memTotal)} (${memPercent.toFixed(1)}%)`;
return PulseApp.utils.createProgressTextBarHTML(memPercent, memTooltipText, memColorClass);
}
@@ -22,7 +22,7 @@ PulseApp.ui.nodes = (() => {
const diskUsed = node.disk || 0;
const diskTotal = node.maxdisk || 0;
const diskPercent = (diskUsed && diskTotal > 0) ? (diskUsed / diskTotal * 100) : 0;
const diskColorClass = PulseApp.utils.getUsageColor(diskPercent);
const diskColorClass = PulseApp.utils.getUsageColor(diskPercent, 'disk');
const diskTooltipText = `${PulseApp.utils.formatBytes(diskUsed)} / ${PulseApp.utils.formatBytes(diskTotal)} (${diskPercent.toFixed(1)}%)`;
return PulseApp.utils.createProgressTextBarHTML(diskPercent, diskTooltipText, diskColorClass);
}
@@ -181,10 +181,11 @@ PulseApp.ui.nodes = (() => {
function updateNodesTable(nodes) {
const tbody = document.getElementById('nodes-table-body');
if (!tbody) {
console.error('Critical element #nodes-table-body not found for nodes table update!');
// Node table doesn't exist in current UI - nodes are displayed as summary cards instead
console.log('[Nodes] Node table not found - using summary cards display instead');
return;
}
tbody.innerHTML = ''; // Clear previous content
tbody.innerHTML = '';
if (!nodes || nodes.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="p-4 text-center text-gray-500 dark:text-gray-400">No nodes found or data unavailable</td></tr>';
@@ -193,7 +194,7 @@ PulseApp.ui.nodes = (() => {
// Group nodes by clusterIdentifier
const clusters = nodes.reduce((acc, node) => {
const key = node.clusterIdentifier || 'Unknown Cluster'; // Fallback for safety
const key = node.clusterIdentifier || 'Unknown Cluster';
if (!acc[key]) {
acc[key] = [];
}
@@ -207,8 +208,6 @@ PulseApp.ui.nodes = (() => {
for (const clusterIdentifier in clusters) {
if (clusters.hasOwnProperty(clusterIdentifier)) {
const nodesInCluster = clusters[clusterIdentifier];
// All nodes in this group should have the same endpointType, so pick from the first.
// Default to 'standalone' if nodesInCluster is empty or endpointType is missing for some reason.
const endpointType = (nodesInCluster && nodesInCluster.length > 0 && nodesInCluster[0].endpointType)
? nodesInCluster[0].endpointType
: 'standalone';
@@ -218,7 +217,6 @@ PulseApp.ui.nodes = (() => {
: PulseApp.ui.common.NODE_GROUP_STANDALONE_ICON_SVG;
const clusterHeaderRow = document.createElement('tr');
// Applying base background, then overlaying with stripe pattern classes
clusterHeaderRow.innerHTML = PulseApp.ui.common.generateNodeGroupHeaderCellHTML(clusterIdentifier, 7, 'th');
tbody.appendChild(clusterHeaderRow);
+61 -18
View File
@@ -1,29 +1,50 @@
PulseApp.ui = PulseApp.ui || {};
PulseApp.ui.storage = (() => {
// Cache for computed values to avoid recalculation
const contentBadgeCache = new Map();
const iconCache = new Map();
const contentBadgeHTMLCache = new Map(); // Cache for complete content badge HTML
function getStorageTypeIcon(type) {
if (iconCache.has(type)) {
return iconCache.get(type);
}
let icon;
switch(type) {
case 'dir':
return '<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>';
icon = '<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>';
break;
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>';
icon = '<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>';
break;
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>';
icon = '<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>';
break;
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>';
icon = '<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>';
break;
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>';
icon = '<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>';
break;
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>';
icon = '<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>';
}
iconCache.set(type, icon);
return icon;
}
function getContentBadgeDetails(contentType) {
if (contentBadgeCache.has(contentType)) {
return contentBadgeCache.get(contentType);
}
let details = {
badgeClass: 'bg-gray-200 dark:bg-gray-600 text-gray-700 dark:text-gray-300',
tooltip: `Content type: ${contentType}`
@@ -55,9 +76,31 @@ PulseApp.ui.storage = (() => {
details.tooltip = 'Snippet files (e.g., cloud-init configs)';
break;
}
contentBadgeCache.set(contentType, details);
return details;
}
function getContentBadgesHTML(contentString) {
if (!contentString) return '-';
if (contentBadgeHTMLCache.has(contentString)) {
return contentBadgeHTMLCache.get(contentString);
}
const contentTypes = contentString.split(',').map(ct => ct.trim()).filter(ct => ct);
contentTypes.sort();
const contentBadges = contentTypes.map(ct => {
const details = getContentBadgeDetails(ct);
return `<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 result = contentBadges || '-';
contentBadgeHTMLCache.set(contentString, result);
return result;
}
function sortNodeStorageData(storageArray) {
if (!storageArray || !Array.isArray(storageArray)) return [];
const sortedArray = [...storageArray];
@@ -82,9 +125,11 @@ PulseApp.ui.storage = (() => {
return;
}
// Pre-sort storage data for each node to avoid repeated sorting
const storageByNode = nodes.reduce((acc, node) => {
if (node && node.node) {
acc[node.node] = Array.isArray(node.storage) ? node.storage : [];
const storageData = Array.isArray(node.storage) ? node.storage : [];
acc[node.node] = sortNodeStorageData(storageData);
}
return acc;
}, {});
@@ -119,7 +164,7 @@ PulseApp.ui.storage = (() => {
const sortedNodeNames = Object.keys(storageByNode).sort((a, b) => a.localeCompare(b));
sortedNodeNames.forEach(nodeName => {
const nodeStorageData = storageByNode[nodeName];
const nodeStorageData = storageByNode[nodeName]; // Already sorted
const nodeHeaderRow = document.createElement('tr');
nodeHeaderRow.className = 'bg-gray-100 dark:bg-gray-700/80 font-semibold text-gray-700 dark:text-gray-300 text-xs node-storage-header';
@@ -133,8 +178,8 @@ PulseApp.ui.storage = (() => {
return;
}
const sortedNodeStorageData = sortNodeStorageData(nodeStorageData);
sortedNodeStorageData.forEach(store => {
// Use pre-sorted data instead of sorting again
nodeStorageData.forEach(store => {
const row = _createStorageRow(store);
tbody.appendChild(row);
});
@@ -156,19 +201,17 @@ PulseApp.ui.storage = (() => {
const usageBarHTML = PulseApp.utils.createProgressTextBarHTML(usagePercent, usageTooltipText, usageColorClass);
const sharedIconTooltip = store.shared === 1 ? 'Shared across cluster' : 'Local to node';
const isDarkMode = document.documentElement.classList.contains('dark');
const localIconGrayClass = isDarkMode ? 'text-gray-400' : 'text-gray-300';
const sharedIcon = store.shared === 1 ? `<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>`;
: `<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 ${localIconGrayClass} 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('');
// Use cached content badge HTML instead of processing inline
const contentBadges = getContentBadgesHTML(store.content);
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 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 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>
-39
View File
@@ -6,14 +6,12 @@ PulseApp.ui.thresholds = (() => {
let thresholdBadge = null;
let sliders = {};
let thresholdSelects = {};
let startLogButton = null;
let isDraggingSlider = false;
function init() {
thresholdRow = document.getElementById('threshold-slider-row');
toggleThresholdsButton = document.getElementById('toggle-thresholds-button');
thresholdBadge = document.getElementById('threshold-count-badge');
startLogButton = document.getElementById('start-log-button');
sliders = {
cpu: document.getElementById('threshold-slider-cpu'),
@@ -30,7 +28,6 @@ PulseApp.ui.thresholds = (() => {
applyInitialThresholdUI();
updateThresholdIndicator();
updateThresholdRowVisibility();
updateLogControlsVisibility();
if (toggleThresholdsButton) {
toggleThresholdsButton.addEventListener('click', () => {
@@ -113,8 +110,6 @@ PulseApp.ui.thresholds = (() => {
document.addEventListener('touchend', _handleThresholdDragEnd);
}
// Old setupThresholdListeners function removed.
function updateThreshold(type, value) {
PulseApp.state.setThresholdValue(type, value);
@@ -124,7 +119,6 @@ PulseApp.ui.thresholds = (() => {
console.warn('[Thresholds] PulseApp.ui.dashboard not available for updateDashboardTable');
}
updateThresholdIndicator();
updateLogControlsVisibility();
}
function updateThresholdRowVisibility() {
@@ -196,38 +190,6 @@ PulseApp.ui.thresholds = (() => {
PulseApp.state.set('isThresholdRowVisible', false);
updateThresholdRowVisibility();
updateThresholdIndicator();
updateLogControlsVisibility(); // Ensure log button visibility updates
}
function updateLogControlsVisibility() {
if (!startLogButton) return;
let isAnyFilterActive = false;
const thresholdState = PulseApp.state.getThresholdState();
const searchInput = document.getElementById('dashboard-search');
const filterGuestType = PulseApp.state.get('filterGuestType');
const filterStatus = PulseApp.state.get('filterStatus');
for (const type in thresholdState) {
if (thresholdState[type].value > 0) {
isAnyFilterActive = true;
break;
}
}
if (!isAnyFilterActive && searchInput && searchInput.value.trim() !== '') {
isAnyFilterActive = true;
}
if (!isAnyFilterActive && filterGuestType !== 'all') {
isAnyFilterActive = true;
}
if (!isAnyFilterActive && filterStatus !== 'all') {
isAnyFilterActive = true;
}
startLogButton.classList.toggle('hidden', !isAnyFilterActive);
}
// Getter for dashboard.js to check drag state
@@ -238,7 +200,6 @@ PulseApp.ui.thresholds = (() => {
return {
init,
resetThresholds,
updateLogControlsVisibility,
isThresholdDragInProgress
};
})();
+50 -4
View File
@@ -1,8 +1,27 @@
PulseApp.utils = (() => {
function getUsageColor(percentage) {
if (percentage >= 90) return 'red';
if (percentage >= 75) return 'yellow';
return 'green';
function getUsageColor(percentage, metric = 'generic') {
// Progress bars use traditional green/yellow/red with metric-specific thresholds
if (metric === 'cpu') {
// CPU: show color for significant usage
if (percentage >= 90) return 'red';
if (percentage >= 80) return 'yellow';
return 'green'; // Healthy green for normal CPU usage
} else if (metric === 'memory') {
// Memory: be more conservative due to critical nature
if (percentage >= 85) return 'red';
if (percentage >= 75) return 'yellow';
return 'green'; // Healthy green for normal memory usage
} else if (metric === 'disk') {
// Disk: can run higher before concerning
if (percentage >= 90) return 'red';
if (percentage >= 80) return 'yellow';
return 'green'; // Healthy green for normal disk usage
} else {
// Generic/legacy fallback (for other uses like storage, etc.)
if (percentage >= 90) return 'red';
if (percentage >= 75) return 'yellow';
return 'green'; // Keep green for non-dashboard usage
}
}
function createProgressTextBarHTML(percentage, text, color) {
@@ -37,6 +56,32 @@ PulseApp.utils = (() => {
return formatBytes(bytesPerSecond, decimals) + '/s';
}
function formatSpeedWithStyling(bytesPerSecond, decimals = 1) {
if (bytesPerSecond === null || bytesPerSecond === undefined) return 'N/A';
let formattedSpeed;
if (bytesPerSecond < 1) {
formattedSpeed = '0 B/s';
} else {
formattedSpeed = formatBytes(bytesPerSecond, decimals) + '/s';
}
// Use same absolute thresholds as chart logic
const mbps = bytesPerSecond / (1024 * 1024);
const isDarkMode = document.documentElement.classList.contains('dark');
let textClass = '';
if (mbps < 1) {
// Not noteworthy - use theme-adaptive dim gray
textClass = isDarkMode ? 'text-gray-400' : 'text-gray-300';
} else {
// Noteworthy - use normal text color
textClass = 'text-gray-800 dark:text-gray-200';
}
return `<span class="${textClass}">${formattedSpeed}</span>`;
}
function formatUptime(seconds) {
if (seconds === null || seconds === undefined || seconds < 0) return 'N/A';
if (seconds < 60) return `${Math.floor(seconds)}s`;
@@ -197,6 +242,7 @@ PulseApp.utils = (() => {
createProgressTextBarHTML,
formatBytes,
formatSpeed,
formatSpeedWithStyling,
formatUptime,
formatDuration,
formatPbsTimestamp,