From fd1c8e5a8c1a4b0ce04ae8cfbd228edfc0da53c2 Mon Sep 17 00:00:00 2001 From: Patrick Balsiger Date: Tue, 16 Sep 2025 20:46:17 +0200 Subject: [PATCH] feat: add node resource infos --- public/scripts/api-client.js | 12 ++ .../components/NodeDetailsComponent.js | 195 +++++++++++++++--- public/scripts/view-models.js | 20 +- public/styles/main.css | 176 +++++++++++++++- 4 files changed, 375 insertions(+), 28 deletions(-) diff --git a/public/scripts/api-client.js b/public/scripts/api-client.js index 65d3a13..d75b252 100644 --- a/public/scripts/api-client.js +++ b/public/scripts/api-client.js @@ -108,6 +108,18 @@ class ApiClient { } return data; } + + async getMonitoringResources(ip) { + return this.request('/api/proxy-call', { + method: 'POST', + body: { + ip: ip, + method: 'GET', + uri: '/api/monitoring/resources', + params: [] + } + }); + } } // Global API client instance diff --git a/public/scripts/components/NodeDetailsComponent.js b/public/scripts/components/NodeDetailsComponent.js index 64d9010..34b613b 100644 --- a/public/scripts/components/NodeDetailsComponent.js +++ b/public/scripts/components/NodeDetailsComponent.js @@ -12,12 +12,13 @@ class NodeDetailsComponent extends Component { this.subscribeToProperty('error', this.handleErrorUpdate.bind(this)); this.subscribeToProperty('activeTab', this.handleActiveTabUpdate.bind(this)); this.subscribeToProperty('endpoints', this.handleEndpointsUpdate.bind(this)); + this.subscribeToProperty('monitoringResources', this.handleMonitoringResourcesUpdate.bind(this)); } // Handle node status update with state preservation handleNodeStatusUpdate(newStatus, previousStatus) { if (newStatus && !this.viewModel.get('isLoading')) { - this.renderNodeDetails(newStatus, this.viewModel.get('tasks'), this.viewModel.get('endpoints')); + this.renderNodeDetails(newStatus, this.viewModel.get('tasks'), this.viewModel.get('endpoints'), this.viewModel.get('monitoringResources')); } } @@ -25,7 +26,7 @@ class NodeDetailsComponent extends Component { handleTasksUpdate(newTasks, previousTasks) { const nodeStatus = this.viewModel.get('nodeStatus'); if (nodeStatus && !this.viewModel.get('isLoading')) { - this.renderNodeDetails(nodeStatus, newTasks, this.viewModel.get('endpoints')); + this.renderNodeDetails(nodeStatus, newTasks, this.viewModel.get('endpoints'), this.viewModel.get('monitoringResources')); } } @@ -55,7 +56,17 @@ class NodeDetailsComponent extends Component { const nodeStatus = this.viewModel.get('nodeStatus'); const tasks = this.viewModel.get('tasks'); if (nodeStatus && !this.viewModel.get('isLoading')) { - this.renderNodeDetails(nodeStatus, tasks, newEndpoints); + this.renderNodeDetails(nodeStatus, tasks, newEndpoints, this.viewModel.get('monitoringResources')); + } + } + + // Handle monitoring resources update with state preservation + handleMonitoringResourcesUpdate(newResources, previousResources) { + const nodeStatus = this.viewModel.get('nodeStatus'); + const tasks = this.viewModel.get('tasks'); + const endpoints = this.viewModel.get('endpoints'); + if (nodeStatus && !this.viewModel.get('isLoading')) { + this.renderNodeDetails(nodeStatus, tasks, endpoints, newResources); } } @@ -65,6 +76,7 @@ class NodeDetailsComponent extends Component { const isLoading = this.viewModel.get('isLoading'); const error = this.viewModel.get('error'); const endpoints = this.viewModel.get('endpoints'); + const monitoringResources = this.viewModel.get('monitoringResources'); if (isLoading) { this.renderLoading('
Loading detailed information...
'); @@ -81,10 +93,10 @@ class NodeDetailsComponent extends Component { return; } - this.renderNodeDetails(nodeStatus, tasks, endpoints); + this.renderNodeDetails(nodeStatus, tasks, endpoints, monitoringResources); } - renderNodeDetails(nodeStatus, tasks, endpoints) { + renderNodeDetails(nodeStatus, tasks, endpoints, monitoringResources) { // Use persisted active tab from the view model, default to 'status' const activeTab = (this.viewModel && typeof this.viewModel.get === 'function' && this.viewModel.get('activeTab')) || 'status'; logger.debug('NodeDetailsComponent: Rendering with activeTab:', activeTab); @@ -115,26 +127,7 @@ class NodeDetailsComponent extends Component {
-
- Free Heap: - ${Math.round(nodeStatus.freeHeap / 1024)}KB -
-
- Chip ID: - ${nodeStatus.chipId} -
-
- SDK Version: - ${nodeStatus.sdkVersion} -
-
- CPU Frequency: - ${nodeStatus.cpuFreqMHz}MHz -
-
- Flash Size: - ${Math.round(nodeStatus.flashChipSize / 1024)}KB -
+ ${this.renderStatusTab(nodeStatus, monitoringResources)}
@@ -187,8 +180,11 @@ class NodeDetailsComponent extends Component { await this.viewModel.loadEndpointsData(); } else if (activeTab === 'tasks' && typeof this.viewModel.loadTasksData === 'function') { await this.viewModel.loadTasksData(); + } else if (activeTab === 'status' && typeof this.viewModel.loadMonitoringResources === 'function') { + // status tab: load monitoring resources + await this.viewModel.loadMonitoringResources(); } else { - // status or firmware: refresh core node details + // firmware: refresh core node details if (nodeIp && typeof this.viewModel.loadNodeDetails === 'function') { await this.viewModel.loadNodeDetails(nodeIp); } @@ -203,6 +199,153 @@ class NodeDetailsComponent extends Component { }); } + renderStatusTab(nodeStatus, monitoringResources) { + let html = ''; + + // Add gauges section if monitoring resources are available + if (monitoringResources) { + html += this.renderResourceGauges(monitoringResources); + } + + html += ` +
+ Chip ID: + ${nodeStatus.chipId} +
+
+ SDK Version: + ${nodeStatus.sdkVersion} +
+
+ CPU Frequency: + ${nodeStatus.cpuFreqMHz}MHz +
+
+ Flash Size: + ${Math.round(nodeStatus.flashChipSize / 1024)}KB +
+ `; + + // Add monitoring resources if available + if (monitoringResources) { + html += ` +
+
Resources
+ `; + + // CPU Usage + if (monitoringResources.cpu) { + html += ` +
+ CPU Usage (Avg): + ${monitoringResources.cpu.average_usage ? monitoringResources.cpu.average_usage.toFixed(1) + '%' : 'N/A'} +
+ `; + } + + // Memory Usage + if (monitoringResources.memory) { + const heapUsagePercent = monitoringResources.memory.heap_usage_percent || 0; + const totalHeap = monitoringResources.memory.total_heap || 0; + const usedHeap = totalHeap - (monitoringResources.memory.free_heap || 0); + const usedHeapKB = Math.round(usedHeap / 1024); + const totalHeapKB = Math.round(totalHeap / 1024); + + html += ` +
+ Heap Usage: + ${heapUsagePercent.toFixed(1)}% (${usedHeapKB}KB / ${totalHeapKB}KB) +
+ `; + } + + // Filesystem Usage + if (monitoringResources.filesystem) { + const usedKB = Math.round(monitoringResources.filesystem.used_bytes / 1024); + const totalKB = Math.round(monitoringResources.filesystem.total_bytes / 1024); + html += ` +
+ Filesystem: + ${monitoringResources.filesystem.usage_percent ? monitoringResources.filesystem.usage_percent.toFixed(1) + '%' : 'N/A'} (${usedKB}KB / ${totalKB}KB) +
+ `; + } + + // System Uptime + if (monitoringResources.system) { + html += ` +
+ Uptime: + ${monitoringResources.system.uptime_formatted || 'N/A'} +
+ `; + } + + html += `
`; + } + + return html; + } + + renderResourceGauges(monitoringResources) { + // Get values with fallbacks and ensure they are numbers + const cpuUsage = parseFloat(monitoringResources.cpu?.average_usage) || 0; + const heapUsage = parseFloat(monitoringResources.memory?.heap_usage_percent) || 0; + const filesystemUsage = parseFloat(monitoringResources.filesystem?.usage_percent) || 0; + const filesystemUsed = parseFloat(monitoringResources.filesystem?.used_bytes) || 0; + const filesystemTotal = parseFloat(monitoringResources.filesystem?.total_bytes) || 0; + + // Convert filesystem bytes to KB + const filesystemUsedKB = Math.round(filesystemUsed / 1024); + const filesystemTotalKB = Math.round(filesystemTotal / 1024); + + // Helper function to get color class based on percentage + const getColorClass = (percentage) => { + const numPercentage = parseFloat(percentage); + + if (numPercentage === 0 || isNaN(numPercentage)) return 'gauge-empty'; + if (numPercentage < 50) return 'gauge-green'; + if (numPercentage < 80) return 'gauge-yellow'; + return 'gauge-red'; + }; + + return ` +
+
+
+
+
+
${cpuUsage.toFixed(1)}%
+
CPU
+
+
+
+
+
+
+
+
+
${heapUsage.toFixed(1)}%
+
Heap
+
+
+
+
+
+
+
+
+
${filesystemUsage.toFixed(1)}%
+
Storage
+ +
+
+
+
+
+ `; + } + renderEndpointsTab(endpoints) { if (!endpoints || !Array.isArray(endpoints.endpoints) || endpoints.endpoints.length === 0) { return ` diff --git a/public/scripts/view-models.js b/public/scripts/view-models.js index f662dae..fe5bcae 100644 --- a/public/scripts/view-models.js +++ b/public/scripts/view-models.js @@ -220,7 +220,8 @@ class NodeDetailsViewModel extends ViewModel { activeTab: 'status', nodeIp: null, endpoints: null, - tasksSummary: null + tasksSummary: null, + monitoringResources: null }); } @@ -250,6 +251,9 @@ class NodeDetailsViewModel extends ViewModel { // Load endpoints data await this.loadEndpointsData(); + // Load monitoring resources data + await this.loadMonitoringResources(); + } catch (error) { console.error('Failed to load node details:', error); this.set('error', error.message); @@ -284,6 +288,20 @@ class NodeDetailsViewModel extends ViewModel { } } + // Load monitoring resources data with state preservation + async loadMonitoringResources() { + try { + const ip = this.get('nodeIp'); + const response = await window.apiClient.getMonitoringResources(ip); + // The proxy call returns { data: {...} }, so we need to extract the data + const monitoringData = (response && response.data) ? response.data : null; + this.set('monitoringResources', monitoringData); + } catch (error) { + console.error('Failed to load monitoring resources:', error); + this.set('monitoringResources', null); + } + } + // Invoke an endpoint against this node async callEndpoint(method, uri, params) { const ip = this.get('nodeIp'); diff --git a/public/styles/main.css b/public/styles/main.css index b393d54..8e1d7b8 100644 --- a/public/styles/main.css +++ b/public/styles/main.css @@ -391,6 +391,173 @@ p { opacity: 1; } +/* Monitoring Section Styles */ +.monitoring-section { + margin-top: 1.5rem; + padding-top: 1rem; + border-top: 1px solid rgba(255, 255, 255, 0.1); +} + +.monitoring-header { + font-size: 1rem; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 1rem; + display: flex; + align-items: center; + gap: 0.5rem; +} + +/* Resource Gauges Styles */ +.resource-gauges { + display: flex; + justify-content: space-around; + align-items: center; + margin-bottom: 2rem; + padding: 1rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); +} + +.gauge-container { + display: flex; + flex-direction: column; + align-items: center; + flex: 1; + max-width: 110px; +} + +.gauge { + position: relative; + width: 120px; + height: 120px; + margin-bottom: 0.3rem; +} + +.gauge-circle { + position: relative; + width: 100%; + height: 100%; + border-radius: 50%; + background: rgba(255, 255, 255, 0.1); + display: flex; + align-items: center; + justify-content: center; + position: relative; + transition: all 0.3s ease; + padding: 6px; +} + +.gauge-circle::before { + content: ''; + position: absolute; + top: 6px; + left: 6px; + width: calc(100% - 12px); + height: calc(100% - 12px); + border-radius: 50%; + background: var(--bg-primary); + z-index: 1; +} + +.gauge-circle::after { + content: ''; + position: absolute; + top: 6px; + left: 6px; + width: calc(100% - 12px); + height: calc(100% - 12px); + border-radius: 50%; + background: conic-gradient( + from -90deg, + transparent 0deg, + transparent calc(var(--percentage) * 3.6deg), + var(--bg-primary) calc(var(--percentage) * 3.6deg), + var(--bg-primary) 360deg + ); + z-index: 2; +} + +.gauge-text { + position: relative; + z-index: 3; + text-align: center; + color: var(--text-primary); +} + +.gauge-value { + font-size: 1.2rem; + font-weight: 600; + line-height: 1; + margin-bottom: 0.1rem; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; +} + +.gauge-label { + font-size: 0.75rem; + font-weight: 500; + opacity: 0.7; + text-transform: uppercase; + letter-spacing: 0.3px; + margin-bottom: 0.1rem; +} + +.gauge-detail { + font-size: 0.65rem; + opacity: 0.5; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-weight: 400; +} + +/* Dynamic gauge colors based on percentage */ +.gauge-empty .gauge-circle { + background: rgba(255, 255, 255, 0.1); +} + +.gauge-green .gauge-circle { + background: conic-gradient( + from -90deg, + var(--accent-success) 0deg, + var(--accent-success) calc(var(--percentage) * 3.6deg), + rgba(255, 255, 255, 0.1) calc(var(--percentage) * 3.6deg), + rgba(255, 255, 255, 0.1) 360deg + ); +} + +.gauge-yellow .gauge-circle { + background: conic-gradient( + from -90deg, + var(--accent-warning) 0deg, + var(--accent-warning) calc(var(--percentage) * 3.6deg), + rgba(255, 255, 255, 0.1) calc(var(--percentage) * 3.6deg), + rgba(255, 255, 255, 0.1) 360deg + ); +} + +.gauge-red .gauge-circle { + background: conic-gradient( + from -90deg, + var(--accent-error) 0deg, + var(--accent-error) calc(var(--percentage) * 3.6deg), + rgba(255, 255, 255, 0.1) calc(var(--percentage) * 3.6deg), + rgba(255, 255, 255, 0.1) 360deg + ); +} + +/* Gauge value color based on usage level */ +.gauge[data-percentage] .gauge-value { + color: var(--text-primary); +} + +/* Hover effects */ +.gauge:hover .gauge-circle { + transform: scale(1.05); + box-shadow: 0 8px 25px rgba(0, 0, 0, 0.2); +} + +.gauge:hover .gauge-value { + color: var(--accent-primary); +} + .api-endpoints { margin-top: 1rem; } @@ -2844,6 +3011,13 @@ p { display: none; } +/* Hide expand icon on desktop screens */ +@media (min-width: 1025px) { + .expand-icon { + display: none; + } +} + /* Ensure expanded state is visually clear */ .member-overlay-body .member-card.expanded .member-details { display: block; @@ -2899,7 +3073,7 @@ p { top: 0; right: 0; height: 100vh; - width: clamp(33.333vw, 520px, 90vw); + width: clamp(33.333vw, 650px, 90vw); background: var(--bg-primary); color: var(--text-primary); border-left: 1px solid var(--border-primary);