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 += `
+
+
+ `;
+
+ // 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);