diff --git a/README.md b/README.md
index 0149c4f..91ae9c6 100644
--- a/README.md
+++ b/README.md
@@ -14,6 +14,8 @@ Zero-configuration web interface for monitoring and managing SPORE embedded syst

### Topology

+### Monitoring
+
### Firmware

diff --git a/assets/cluster.png b/assets/cluster.png
index 8f0e534..d52258e 100644
Binary files a/assets/cluster.png and b/assets/cluster.png differ
diff --git a/assets/firmware.png b/assets/firmware.png
index 343f2a2..cc0f639 100644
Binary files a/assets/firmware.png and b/assets/firmware.png differ
diff --git a/assets/monitoring.png b/assets/monitoring.png
new file mode 100644
index 0000000..44399db
Binary files /dev/null and b/assets/monitoring.png differ
diff --git a/assets/topology.png b/assets/topology.png
index db6624a..3377a99 100644
Binary files a/assets/topology.png and b/assets/topology.png differ
diff --git a/public/index.html b/public/index.html
index 32651e1..5009255 100644
--- a/public/index.html
+++ b/public/index.html
@@ -20,6 +20,7 @@
-
`;
// CPU Usage
diff --git a/public/scripts/view-models.js b/public/scripts/view-models.js
index d6fe1bb..7657baa 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,232 @@ 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,
+ basic: member.resources, // Preserve original basic data
+ 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..fe23e35 100644
--- a/public/styles/main.css
+++ b/public/styles/main.css
@@ -782,10 +782,6 @@ p {
.latency-value {
color: var(--text-primary);
font-weight: 600;
- background: var(--bg-secondary);
- padding: 0.2rem 0.5rem;
- border-radius: 4px;
- border: 1px solid var(--border-primary);
}
/* Tab Styles */
@@ -1357,7 +1353,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 +3983,434 @@ html {
border-color: rgba(139, 92, 246, 0.5) !important;
background: rgba(139, 92, 246, 0.08) !important;
}
+
+/* Monitoring View Styles */
+.monitoring-view-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-view-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 bar colors based on percentage (same as gauges) */
+.utilization-empty {
+ background: rgba(255, 255, 255, 0.1) !important;
+}
+
+.utilization-green {
+ background: linear-gradient(90deg, var(--accent-success), var(--accent-success)) !important;
+}
+
+.utilization-yellow {
+ background: linear-gradient(90deg, var(--accent-warning), var(--accent-warning)) !important;
+}
+
+.utilization-red {
+ background: linear-gradient(90deg, var(--accent-error), var(--accent-error)) !important;
+}
+
+.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);
+ transform: translateY(-2px);
+ transition: all 0.2s ease;
+}
+
+.node-card[data-node-ip] {
+ cursor: pointer;
+ transition: all 0.2s ease;
+}
+
+.node-card[data-node-ip]:active {
+ transform: translateY(0);
+}
+
+.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;
+}
+
+.node-uptime {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 0.5rem;
+ padding: 0.25rem 0;
+}
+
+.uptime-label {
+ color: var(--text-secondary);
+ font-size: 0.8rem;
+ font-weight: bold;
+ opacity: 0.8;
+}
+
+.uptime-value {
+ color: var(--text-primary);
+ font-size: 0.8rem;
+ font-weight: 600;
+ font-family: 'Courier New', monospace;
+ opacity: 0.9;
+}
+
+.node-latency {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 0.5rem;
+ padding: 0.25rem 0;
+}
+
+.latency-label {
+ color: var(--text-secondary);
+ font-size: 0.8rem;
+ font-weight: bold;
+ opacity: 0.8;
+}
+
+.latency-value {
+ color: var(--text-primary);
+ font-size: 0.8rem;
+ font-weight: 600;
+ font-family: 'Courier New', monospace;
+ opacity: 0.9;
+}
+
+.latency-divider {
+ height: 1px;
+ background: var(--border-primary);
+ margin: 0.5rem 0;
+ opacity: 0.3;
+}
+
+.node-flash {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 0.75rem;
+ padding: 0.5rem;
+ background: var(--bg-primary);
+ border-radius: 6px;
+ border: 1px solid var(--border-primary);
+}
+
+.flash-label {
+ color: var(--text-secondary);
+ font-size: 0.875rem;
+ font-weight: 500;
+}
+
+.flash-value {
+ color: var(--text-primary);
+ font-size: 0.875rem;
+ font-weight: 600;
+ font-family: 'Courier New', monospace;
+}
+
+.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;
+}
+
+/* Monitoring Section in Node Details Drawer */
+.details-drawer .monitoring-section {
+
+}
+
+.details-drawer .monitoring-section .monitoring-header {
+ color: var(--text-primary);
+ font-size: 1rem;
+ font-weight: 600;
+ margin-bottom: 0.75rem;
+ padding-bottom: 0.5rem;
+ border-bottom: 1px solid var(--border-primary);
+}
+
+/* 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;
+ }
+}
diff --git a/test/mock-ui.html b/test/mock-ui.html
index ab96904..c40dba9 100644
--- a/test/mock-ui.html
+++ b/test/mock-ui.html
@@ -26,6 +26,7 @@
+
@@ -41,6 +42,7 @@
+
@@ -114,12 +116,102 @@
-
+
+
+
+
🚀 Firmware Update
+
+
+
+
+
+
+
+
+ No file selected
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+