diff --git a/public/index.html b/public/index.html index 32651e1..80c14e7 100644 --- a/public/index.html +++ b/public/index.html @@ -21,6 +21,7 @@ + + +
+
+
+

📊 Monitoring

+ +
+ +
+
+
+
Loading cluster resource summary...
+
+
+ +
+
+
Loading node resource data...
+
+
+
+
+
@@ -156,6 +186,7 @@ + diff --git a/public/scripts/app.js b/public/scripts/app.js index 95f045c..40b5379 100644 --- a/public/scripts/app.js +++ b/public/scripts/app.js @@ -15,7 +15,8 @@ document.addEventListener('DOMContentLoaded', async function() { const clusterViewModel = new ClusterViewModel(); const firmwareViewModel = new FirmwareViewModel(); const topologyViewModel = new TopologyViewModel(); - logger.debug('App: View models created:', { clusterViewModel, firmwareViewModel, topologyViewModel }); + const monitoringViewModel = new MonitoringViewModel(); + logger.debug('App: View models created:', { clusterViewModel, firmwareViewModel, topologyViewModel, monitoringViewModel }); // Connect firmware view model to cluster data clusterViewModel.subscribe('members', (members) => { @@ -40,6 +41,7 @@ document.addEventListener('DOMContentLoaded', async function() { app.registerRoute('cluster', ClusterViewComponent, 'cluster-view', clusterViewModel); app.registerRoute('topology', TopologyGraphComponent, 'topology-view', topologyViewModel); app.registerRoute('firmware', FirmwareViewComponent, 'firmware-view', firmwareViewModel); + app.registerRoute('monitoring', MonitoringViewComponent, 'monitoring-view', monitoringViewModel); logger.debug('App: Routes registered and components pre-initialized'); // Initialize cluster status component for header badge diff --git a/public/scripts/components/MonitoringViewComponent.js b/public/scripts/components/MonitoringViewComponent.js new file mode 100644 index 0000000..4c2e075 --- /dev/null +++ b/public/scripts/components/MonitoringViewComponent.js @@ -0,0 +1,346 @@ +// Monitoring View Component +class MonitoringViewComponent extends Component { + constructor(container, viewModel, eventBus) { + super(container, viewModel, eventBus); + + logger.debug('MonitoringViewComponent: Constructor called'); + logger.debug('MonitoringViewComponent: Container:', container); + logger.debug('MonitoringViewComponent: Container ID:', container?.id); + + // Track if we've already loaded data to prevent unnecessary reloads + this.dataLoaded = false; + } + + mount() { + logger.debug('MonitoringViewComponent: Mounting...'); + super.mount(); + + // Set up refresh button event listener + this.setupRefreshButton(); + + // Only load data if we haven't already or if the view model is empty + const clusterMembers = this.viewModel.get('clusterMembers'); + if (!this.dataLoaded || !clusterMembers || clusterMembers.length === 0) { + this.loadData(); + } + + // Subscribe to view model changes + this.setupSubscriptions(); + } + + setupRefreshButton() { + const refreshBtn = this.findElement('#refresh-monitoring-btn'); + if (refreshBtn) { + refreshBtn.addEventListener('click', () => { + this.refreshData(); + }); + } + } + + setupSubscriptions() { + // Subscribe to cluster members changes + this.viewModel.subscribe('clusterMembers', () => { + this.render(); + }); + + // Subscribe to node resources changes + this.viewModel.subscribe('nodeResources', () => { + this.render(); + }); + + // Subscribe to cluster summary changes + this.viewModel.subscribe('clusterSummary', () => { + this.render(); + }); + + // Subscribe to loading state changes + this.viewModel.subscribe('isLoading', () => { + this.render(); + }); + + // Subscribe to error changes + this.viewModel.subscribe('error', () => { + this.render(); + }); + } + + async loadData() { + logger.debug('MonitoringViewComponent: Loading data...'); + this.dataLoaded = true; + await this.viewModel.loadClusterData(); + } + + async refreshData() { + logger.debug('MonitoringViewComponent: Refreshing data...'); + await this.viewModel.refresh(); + } + + render() { + logger.debug('MonitoringViewComponent: Rendering...'); + + const isLoading = this.viewModel.get('isLoading'); + const error = this.viewModel.get('error'); + const clusterSummary = this.viewModel.get('clusterSummary'); + const nodeResources = this.viewModel.get('nodeResources'); + const lastUpdated = this.viewModel.get('lastUpdated'); + + // Render cluster summary + this.renderClusterSummary(isLoading, error, clusterSummary, lastUpdated); + + // Render nodes monitoring + this.renderNodesMonitoring(isLoading, error, nodeResources); + } + + renderClusterSummary(isLoading, error, clusterSummary, lastUpdated) { + const container = this.findElement('#cluster-summary'); + if (!container) return; + + if (isLoading) { + container.innerHTML = ` +
+
Loading cluster resource summary...
+
+ `; + return; + } + + if (error) { + container.innerHTML = ` +
+
❌ Error: ${error}
+
+ `; + return; + } + + const cpuUtilization = this.viewModel.getResourceUtilization('Cpu'); + const memoryUtilization = this.viewModel.getResourceUtilization('Memory'); + const storageUtilization = this.viewModel.getResourceUtilization('Storage'); + + const lastUpdatedText = lastUpdated ? + `Last updated: ${lastUpdated.toLocaleTimeString()}` : + 'Never updated'; + + container.innerHTML = ` +
+
+

Summary

+
${lastUpdatedText}
+
+ +
+
+
🖥️
+
+
Total Nodes
+
${clusterSummary.totalNodes}
+
+
+ +
+
+
+
CPU
+
${Math.round(clusterSummary.totalCpu - clusterSummary.availableCpu)}MHz / ${Math.round(clusterSummary.totalCpu)}MHz
+
+
+
+
+ ${cpuUtilization}% used +
+
+
+ +
+
🧠
+
+
Memory
+
${this.viewModel.formatResourceValue(clusterSummary.totalMemory - clusterSummary.availableMemory, 'memory')} / ${this.viewModel.formatResourceValue(clusterSummary.totalMemory, 'memory')}
+
+
+
+
+ ${memoryUtilization}% used +
+
+
+ +
+
💾
+
+
Storage
+
${this.viewModel.formatResourceValue(clusterSummary.totalStorage - clusterSummary.availableStorage, 'storage')} / ${this.viewModel.formatResourceValue(clusterSummary.totalStorage, 'storage')}
+
+
+
+
+ ${storageUtilization}% used +
+
+
+
+
+ `; + } + + renderNodesMonitoring(isLoading, error, nodeResources) { + const container = this.findElement('#nodes-monitoring'); + if (!container) return; + + if (isLoading) { + container.innerHTML = ` +
+
Loading node resource data...
+
+ `; + return; + } + + if (error) { + container.innerHTML = ` +
+
❌ Error: ${error}
+
+ `; + return; + } + + if (!nodeResources || nodeResources.size === 0) { + container.innerHTML = ` +
+
No node resource data available
+
+ `; + return; + } + + const nodesHtml = Array.from(nodeResources.values()).map(nodeData => { + return this.renderNodeCard(nodeData); + }).join(''); + + container.innerHTML = ` +
+

🖥️ Node Resource Details

+
+ ${nodesHtml} +
+
+ `; + } + + renderNodeCard(nodeData) { + const { ip, hostname, resources, hasResources, error, resourceSource } = nodeData; + + if (!hasResources) { + return ` +
+
+
${hostname || ip}
+
${ip}
+
+
+ ❌ No Resources +
+
+ ${error || 'Monitoring endpoint not available'} +
+
+ `; + } + + // Extract resource data (handle both monitoring API and basic data formats) + const cpu = resources?.cpu || {}; + const memory = resources?.memory || {}; + const storage = resources?.filesystem || resources?.storage || {}; + + let cpuTotal, cpuAvailable, cpuUsed, cpuUtilization; + let memoryTotal, memoryAvailable, memoryUsed, memoryUtilization; + let storageTotal, storageAvailable, storageUsed, storageUtilization; + + if (resourceSource === 'monitoring') { + // Real monitoring API format + const cpuFreqMHz = nodeData.basic?.cpuFreqMHz || 80; // Get CPU frequency from basic resources + cpuTotal = cpuFreqMHz; // Total CPU frequency in MHz + cpuAvailable = cpuFreqMHz * (100 - (cpu.average_usage || 0)) / 100; // Available frequency + cpuUsed = cpuFreqMHz * (cpu.average_usage || 0) / 100; // Used frequency + cpuUtilization = Math.round(cpu.average_usage || 0); + + memoryTotal = memory.total_heap || 0; + memoryAvailable = memory.free_heap || 0; + memoryUsed = memoryTotal - memoryAvailable; + memoryUtilization = memoryTotal > 0 ? Math.round((memoryUsed / memoryTotal) * 100) : 0; + + storageTotal = storage.total_bytes || 0; + storageAvailable = storage.free_bytes || 0; + storageUsed = storageTotal - storageAvailable; + storageUtilization = storageTotal > 0 ? Math.round((storageUsed / storageTotal) * 100) : 0; + } else { + // Basic data format - use CPU frequency from basic resources + const cpuFreqMHz = nodeData.basic?.cpuFreqMHz || 80; + cpuTotal = cpuFreqMHz; // Total CPU frequency in MHz + cpuAvailable = cpuFreqMHz * (cpu.available || 0.8); // Available frequency + cpuUsed = cpuFreqMHz * (cpu.used || 0.2); // Used frequency + cpuUtilization = cpuTotal > 0 ? Math.round((cpuUsed / cpuTotal) * 100) : 0; + + memoryTotal = memory.total || 0; + memoryAvailable = memory.available || memory.free || 0; + memoryUsed = memoryTotal - memoryAvailable; + memoryUtilization = memoryTotal > 0 ? Math.round((memoryUsed / memoryTotal) * 100) : 0; + + storageTotal = storage.total || 0; + storageAvailable = storage.available || storage.free || 0; + storageUsed = storageTotal - storageAvailable; + storageUtilization = storageTotal > 0 ? Math.round((storageUsed / storageTotal) * 100) : 0; + } + + const resourceSourceText = resourceSource === 'monitoring' ? '📊 Full Monitoring' : + resourceSource === 'basic' ? '📋 Basic Data' : '❓ Unknown'; + + return ` +
+
+
${hostname || ip}
+
${ip}
+
+
+ ${resourceSourceText} +
+ +
+
+
⚡ CPU
+
${Math.round(cpuUsed)}MHz / ${Math.round(cpuTotal)}MHz
+
+
+
+
+ ${cpuUtilization}% used +
+
+ +
+
🧠 Memory
+
${this.viewModel.formatResourceValue(memoryUsed, 'memory')} / ${this.viewModel.formatResourceValue(memoryTotal, 'memory')}
+
+
+
+
+ ${memoryUtilization}% used +
+
+ +
+
💾 Storage
+
${this.viewModel.formatResourceValue(storageUsed, 'storage')} / ${this.viewModel.formatResourceValue(storageTotal, 'storage')}
+
+
+
+
+ ${storageUtilization}% used +
+
+
+
+ `; + } +} diff --git a/public/scripts/view-models.js b/public/scripts/view-models.js index d6fe1bb..e6564fc 100644 --- a/public/scripts/view-models.js +++ b/public/scripts/view-models.js @@ -480,7 +480,7 @@ class NavigationViewModel extends ViewModel { super(); this.setMultiple({ activeView: 'cluster', - views: ['cluster', 'firmware'] + views: ['cluster', 'topology', 'firmware', 'monitoring'] }); } @@ -651,4 +651,231 @@ class TopologyViewModel extends ViewModel { clearSelection() { this.set('selectedNode', null); } +} + +// Monitoring View Model for cluster resource monitoring +class MonitoringViewModel extends ViewModel { + constructor() { + super(); + this.setMultiple({ + clusterMembers: [], + nodeResources: new Map(), // Map of node IP -> resource data + clusterSummary: { + totalCpu: 0, + totalMemory: 0, + totalStorage: 0, + totalNodes: 0, + availableCpu: 0, + availableMemory: 0, + availableStorage: 0 + }, + isLoading: false, + lastUpdated: null, + error: null + }); + } + + // Load cluster members and their resource data + async loadClusterData() { + this.set('isLoading', true); + this.set('error', null); + + try { + // Get cluster members + const response = await window.apiClient.getClusterMembers(); + // Extract members array from response object + const members = response.members || []; + this.set('clusterMembers', members); + + // Load resource data for each node + await this.loadNodeResources(members); + + // Calculate cluster summary + this.calculateClusterSummary(); + + this.set('lastUpdated', new Date()); + } catch (error) { + console.error('Failed to load cluster monitoring data:', error); + this.set('error', error.message || 'Failed to load monitoring data'); + } finally { + this.set('isLoading', false); + } + } + + // Load resource data for all nodes + async loadNodeResources(members) { + const nodeResources = new Map(); + + // Process nodes in parallel + const resourcePromises = members.map(async (member) => { + try { + const resources = await window.apiClient.getMonitoringResources(member.ip); + // Handle both real API (wrapped in data) and mock API (direct response) + const resourceData = (resources && resources.data) ? resources.data : resources; + nodeResources.set(member.ip, { + ...member, + resources: resourceData, + hasResources: true, + resourceSource: 'monitoring' + }); + } catch (error) { + console.warn(`Failed to load monitoring resources for node ${member.ip}:`, error); + // Fall back to basic resource data from cluster members API + const basicResources = member.resources ? this.convertBasicResources(member.resources) : null; + nodeResources.set(member.ip, { + ...member, + resources: basicResources, + hasResources: !!basicResources, + resourceSource: basicResources ? 'basic' : 'none', + error: basicResources ? null : error.message + }); + } + }); + + await Promise.all(resourcePromises); + this.set('nodeResources', nodeResources); + } + + // Convert basic resource data from cluster members API to monitoring format + convertBasicResources(basicResources) { + // Convert ESP32 basic resources to monitoring format + const freeHeap = basicResources.freeHeap || 0; + const flashSize = basicResources.flashChipSize || 0; + const cpuFreq = basicResources.cpuFreqMHz || 80; + + // Estimate total heap (ESP32 typically has ~300KB heap) + const estimatedTotalHeap = 300 * 1024; // 300KB in bytes + const usedHeap = estimatedTotalHeap - freeHeap; + + return { + cpu: { + total: cpuFreq, // Total CPU frequency in MHz + available: cpuFreq * 0.8, // Estimate 80% available + used: cpuFreq * 0.2 + }, + memory: { + total: estimatedTotalHeap, + available: freeHeap, + used: usedHeap + }, + storage: { + total: flashSize, + available: flashSize * 0.5, // Estimate 50% available + used: flashSize * 0.5 + }, + // Include original basic resources for reference + basic: basicResources + }; + } + + // Calculate cluster resource summary + calculateClusterSummary() { + const nodeResources = this.get('nodeResources'); + const members = this.get('clusterMembers'); + + let totalCpu = 0; + let totalMemory = 0; + let totalStorage = 0; + let availableCpu = 0; + let availableMemory = 0; + let availableStorage = 0; + let totalNodes = 0; + + for (const [ip, nodeData] of nodeResources) { + if (nodeData.hasResources && nodeData.resources) { + totalNodes++; + + // Extract resource data (handle both monitoring API and basic data formats) + const resources = nodeData.resources; + + // CPU resources + if (resources.cpu) { + const cpuFreqMHz = nodeData.basic?.cpuFreqMHz || 80; // Get CPU frequency from basic resources + if (nodeData.resourceSource === 'monitoring') { + // Real monitoring API format + totalCpu += cpuFreqMHz; // Total CPU frequency in MHz + availableCpu += cpuFreqMHz * (100 - (resources.cpu.average_usage || 0)) / 100; // Available frequency + } else { + // Basic data format + totalCpu += cpuFreqMHz; // Total CPU frequency in MHz + availableCpu += cpuFreqMHz * (resources.cpu.available || 0.8); // Available frequency + } + } + + // Memory resources + if (resources.memory) { + if (nodeData.resourceSource === 'monitoring') { + // Real monitoring API format + totalMemory += resources.memory.total_heap || 0; + availableMemory += resources.memory.free_heap || 0; + } else { + // Basic data format + totalMemory += resources.memory.total || 0; + availableMemory += resources.memory.available || 0; + } + } + + // Storage resources + if (resources.filesystem || resources.storage) { + const storage = resources.filesystem || resources.storage; + if (nodeData.resourceSource === 'monitoring') { + // Real monitoring API format + totalStorage += storage.total_bytes || 0; + availableStorage += storage.free_bytes || 0; + } else { + // Basic data format + totalStorage += storage.total || 0; + availableStorage += storage.available || 0; + } + } + } + } + + this.set('clusterSummary', { + totalCpu, + totalMemory, + totalStorage, + totalNodes, + availableCpu, + availableMemory, + availableStorage + }); + } + + // Get resource utilization percentage + getResourceUtilization(resourceType) { + const summary = this.get('clusterSummary'); + const total = summary[`total${resourceType}`]; + const available = summary[`available${resourceType}`]; + + if (total === 0) return 0; + return Math.round(((total - available) / total) * 100); + } + + // Get formatted resource value + formatResourceValue(value, type) { + if (type === 'memory' || type === 'storage') { + // Convert bytes to human readable format + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let size = value; + let unitIndex = 0; + + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024; + unitIndex++; + } + + return `${size.toFixed(1)} ${units[unitIndex]}`; + } else if (type === 'cpu') { + // CPU is typically in cores or percentage + return `${value} cores`; + } + + return value.toString(); + } + + // Refresh monitoring data + async refresh() { + await this.loadClusterData(); + } } \ No newline at end of file diff --git a/public/styles/main.css b/public/styles/main.css index 4ce911d..f2243d1 100644 --- a/public/styles/main.css +++ b/public/styles/main.css @@ -1357,7 +1357,7 @@ p { } /* Special handling for cluster and firmware views to ensure proper width */ -#cluster-view.active, #firmware-view.active { +#cluster-view.active, #firmware-view.active, #monitoring-view.active { display: flex; /* Use flex display for proper layout */ flex-direction: column; /* Stack content vertically */ overflow-y: auto; /* Allow vertical scrolling */ @@ -3987,3 +3987,314 @@ html { border-color: rgba(139, 92, 246, 0.5) !important; background: rgba(139, 92, 246, 0.08) !important; } + +/* Monitoring View Styles */ +.monitoring-section { + background: var(--bg-secondary); + border-radius: 16px; + backdrop-filter: var(--backdrop-blur); + box-shadow: var(--shadow-primary); + border: 1px solid var(--border-primary); + padding: 1rem; + margin-bottom: 1rem; + position: relative; + overflow: visible; +} + +.monitoring-section::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 1px; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent); +} + +.monitoring-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--border-primary); +} + +.monitoring-header h2 { + color: var(--text-primary); + margin: 0; + font-size: 1.5rem; + font-weight: 600; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); +} + +.monitoring-content { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +/* Cluster Summary Styles */ +.cluster-summary-content { + /*background: var(--bg-tertiary); + border-radius: 12px; + padding: 1.5rem; + border: 1px solid var(--border-primary);*/ +} + +.summary-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; +} + +.summary-header h3 { + color: var(--text-primary); + margin: 0; + font-size: 1.25rem; + font-weight: 600; +} + +.last-updated { + color: var(--text-secondary); + font-size: 0.875rem; + opacity: 0.8; +} + +.summary-stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1rem; +} + +.stat-card { + background: var(--bg-primary); + border-radius: 10px; + padding: 1.25rem; + border: 1px solid var(--border-primary); + display: flex; + align-items: center; + gap: 1rem; + transition: all 0.2s ease; +} + +.stat-card:hover { + border-color: rgba(255, 255, 255, 0.1); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); +} + +.stat-icon { + font-size: 2rem; + opacity: 0.8; +} + +.stat-content { + flex: 1; +} + +.stat-label { + color: var(--text-secondary); + font-size: 0.875rem; + margin-bottom: 0.25rem; +} + +.stat-value { + color: var(--text-primary); + font-size: 1.5rem; + font-weight: 600; + margin-bottom: 0.5rem; +} + +.stat-utilization { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.utilization-bar { + flex: 1; + height: 6px; + background: rgba(255, 255, 255, 0.1); + border-radius: 3px; + overflow: hidden; +} + +.utilization-fill { + height: 100%; + background: linear-gradient(90deg, #4ade80, #22c55e); + border-radius: 3px; + transition: width 0.3s ease; +} + +.utilization-text { + color: var(--text-secondary); + font-size: 0.75rem; + font-weight: 500; + min-width: 3rem; +} + +/* Nodes Monitoring Styles */ +.nodes-monitoring-content h3 { + color: var(--text-primary); + margin: 0 0 1rem 0; + font-size: 1.25rem; + font-weight: 600; +} + +.nodes-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 1rem; +} + +.node-card { + background: var(--bg-tertiary); + border-radius: 12px; + padding: 1.25rem; + border: 1px solid var(--border-primary); + transition: all 0.2s ease; +} + +.node-card:hover { + border-color: rgba(255, 255, 255, 0.1); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); +} + +.node-card.error { + border-color: rgba(239, 68, 68, 0.3); + background: rgba(239, 68, 68, 0.05); +} + +.node-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; + padding-bottom: 0.75rem; + border-bottom: 1px solid var(--border-primary); +} + +.node-title { + color: var(--text-primary); + font-size: 1.1rem; + font-weight: 600; +} + +.node-ip { + color: var(--text-secondary); + font-size: 0.875rem; + font-family: 'Courier New', monospace; +} + +.node-status { + margin-bottom: 0.75rem; +} + +.status-badge { + display: inline-block; + padding: 0.25rem 0.5rem; + border-radius: 6px; + font-size: 0.75rem; + font-weight: 500; +} + +.status-badge.error { + background: rgba(239, 68, 68, 0.2); + color: #fca5a5; + border: 1px solid rgba(239, 68, 68, 0.3); +} + +.status-badge.success { + background: rgba(34, 197, 94, 0.2); + color: #86efac; + border: 1px solid rgba(34, 197, 94, 0.3); +} + +.status-badge.warning { + background: rgba(245, 158, 11, 0.2); + color: #fcd34d; + border: 1px solid rgba(245, 158, 11, 0.3); +} + +.node-error { + color: var(--text-secondary); + font-size: 0.875rem; + font-style: italic; +} + +.node-resources { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.resource-item { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.resource-label { + color: var(--text-secondary); + font-size: 0.875rem; + font-weight: 500; +} + +.resource-value { + color: var(--text-primary); + font-size: 1rem; + font-weight: 600; +} + +.resource-utilization { + display: flex; + align-items: center; + gap: 0.5rem; +} + +/* Error and Loading States */ +.error { + text-align: center; + padding: 2rem; + color: #fca5a5; + background: rgba(239, 68, 68, 0.05); + border: 1px solid rgba(239, 68, 68, 0.2); + border-radius: 8px; +} + +.no-data { + text-align: center; + padding: 2rem; + color: var(--text-secondary); + background: var(--bg-tertiary); + border: 1px solid var(--border-primary); + border-radius: 8px; +} + +/* Responsive Design */ +@media (max-width: 768px) { + .summary-stats { + grid-template-columns: 1fr; + } + + .nodes-grid { + grid-template-columns: 1fr; + } + + .monitoring-header { + flex-direction: column; + gap: 1rem; + align-items: flex-start; + } + + .stat-card { + flex-direction: column; + text-align: center; + gap: 0.75rem; + } + + .stat-icon { + font-size: 1.5rem; + } +}