Merge pull request 'feature/monitoring-overview' (#10) from feature/monitoring-overview into main
Reviewed-on: #10
@@ -14,6 +14,8 @@ Zero-configuration web interface for monitoring and managing SPORE embedded syst
|
|||||||

|

|
||||||
### Topology
|
### Topology
|
||||||

|

|
||||||
|
### Monitoring
|
||||||
|

|
||||||
### Firmware
|
### Firmware
|
||||||

|

|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 390 KiB After Width: | Height: | Size: 371 KiB |
|
Before Width: | Height: | Size: 316 KiB After Width: | Height: | Size: 310 KiB |
BIN
assets/monitoring.png
Normal file
|
After Width: | Height: | Size: 433 KiB |
|
Before Width: | Height: | Size: 395 KiB After Width: | Height: | Size: 399 KiB |
@@ -20,6 +20,7 @@
|
|||||||
<div class="nav-left">
|
<div class="nav-left">
|
||||||
<button class="nav-tab active" data-view="cluster">🌐 Cluster</button>
|
<button class="nav-tab active" data-view="cluster">🌐 Cluster</button>
|
||||||
<button class="nav-tab" data-view="topology">🔗 Topology</button>
|
<button class="nav-tab" data-view="topology">🔗 Topology</button>
|
||||||
|
<button class="nav-tab" data-view="monitoring">📡 Monitoring</button>
|
||||||
<button class="nav-tab" data-view="firmware">📦 Firmware</button>
|
<button class="nav-tab" data-view="firmware">📦 Firmware</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-right">
|
<div class="nav-right">
|
||||||
@@ -138,6 +139,35 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="monitoring-view" class="view-content">
|
||||||
|
<div class="monitoring-view-section">
|
||||||
|
<div class="monitoring-header">
|
||||||
|
<h2>📡 Monitoring</h2>
|
||||||
|
<button class="refresh-btn" id="refresh-monitoring-btn">
|
||||||
|
<svg class="refresh-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M1 4v6h6M23 20v-6h-6" />
|
||||||
|
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15" />
|
||||||
|
</svg>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="monitoring-content">
|
||||||
|
<div class="cluster-summary" id="cluster-summary">
|
||||||
|
<div class="loading">
|
||||||
|
<div>Loading cluster resource summary...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nodes-monitoring" id="nodes-monitoring">
|
||||||
|
<div class="loading">
|
||||||
|
<div>Loading node resource data...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="./vendor/d3.v7.min.js"></script>
|
<script src="./vendor/d3.v7.min.js"></script>
|
||||||
@@ -156,6 +186,7 @@
|
|||||||
<script src="./scripts/components/ClusterViewComponent.js"></script>
|
<script src="./scripts/components/ClusterViewComponent.js"></script>
|
||||||
<script src="./scripts/components/ClusterStatusComponent.js"></script>
|
<script src="./scripts/components/ClusterStatusComponent.js"></script>
|
||||||
<script src="./scripts/components/TopologyGraphComponent.js"></script>
|
<script src="./scripts/components/TopologyGraphComponent.js"></script>
|
||||||
|
<script src="./scripts/components/MonitoringViewComponent.js"></script>
|
||||||
<script src="./scripts/components/ComponentsLoader.js"></script>
|
<script src="./scripts/components/ComponentsLoader.js"></script>
|
||||||
<script src="./scripts/theme-manager.js"></script>
|
<script src="./scripts/theme-manager.js"></script>
|
||||||
<script src="./scripts/app.js"></script>
|
<script src="./scripts/app.js"></script>
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ document.addEventListener('DOMContentLoaded', async function() {
|
|||||||
const clusterViewModel = new ClusterViewModel();
|
const clusterViewModel = new ClusterViewModel();
|
||||||
const firmwareViewModel = new FirmwareViewModel();
|
const firmwareViewModel = new FirmwareViewModel();
|
||||||
const topologyViewModel = new TopologyViewModel();
|
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
|
// Connect firmware view model to cluster data
|
||||||
clusterViewModel.subscribe('members', (members) => {
|
clusterViewModel.subscribe('members', (members) => {
|
||||||
@@ -40,6 +41,7 @@ document.addEventListener('DOMContentLoaded', async function() {
|
|||||||
app.registerRoute('cluster', ClusterViewComponent, 'cluster-view', clusterViewModel);
|
app.registerRoute('cluster', ClusterViewComponent, 'cluster-view', clusterViewModel);
|
||||||
app.registerRoute('topology', TopologyGraphComponent, 'topology-view', topologyViewModel);
|
app.registerRoute('topology', TopologyGraphComponent, 'topology-view', topologyViewModel);
|
||||||
app.registerRoute('firmware', FirmwareViewComponent, 'firmware-view', firmwareViewModel);
|
app.registerRoute('firmware', FirmwareViewComponent, 'firmware-view', firmwareViewModel);
|
||||||
|
app.registerRoute('monitoring', MonitoringViewComponent, 'monitoring-view', monitoringViewModel);
|
||||||
logger.debug('App: Routes registered and components pre-initialized');
|
logger.debug('App: Routes registered and components pre-initialized');
|
||||||
|
|
||||||
// Initialize cluster status component for header badge
|
// Initialize cluster status component for header badge
|
||||||
|
|||||||
506
public/scripts/components/MonitoringViewComponent.js
Normal file
@@ -0,0 +1,506 @@
|
|||||||
|
// 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;
|
||||||
|
|
||||||
|
// Drawer state for desktop
|
||||||
|
this.drawer = new DrawerComponent();
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine if we should use desktop drawer behavior
|
||||||
|
isDesktop() {
|
||||||
|
return this.drawer.isDesktop();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open drawer for a specific node
|
||||||
|
openDrawerForNode(nodeData) {
|
||||||
|
const { ip, hostname } = nodeData;
|
||||||
|
|
||||||
|
// Get display name for drawer title
|
||||||
|
let displayName = ip;
|
||||||
|
if (hostname && ip) {
|
||||||
|
displayName = `${hostname} - ${ip}`;
|
||||||
|
} else if (hostname) {
|
||||||
|
displayName = hostname;
|
||||||
|
} else if (ip) {
|
||||||
|
displayName = ip;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open drawer with content callback
|
||||||
|
this.drawer.openDrawer(displayName, (contentContainer, setActiveComponent) => {
|
||||||
|
// Load and mount NodeDetails into drawer
|
||||||
|
const nodeDetailsVM = new NodeDetailsViewModel();
|
||||||
|
const nodeDetailsComponent = new NodeDetailsComponent(contentContainer, nodeDetailsVM, this.eventBus);
|
||||||
|
setActiveComponent(nodeDetailsComponent);
|
||||||
|
|
||||||
|
nodeDetailsVM.loadNodeDetails(ip).then(() => {
|
||||||
|
nodeDetailsComponent.mount();
|
||||||
|
}).catch((error) => {
|
||||||
|
logger.error('Failed to load node details for drawer:', error);
|
||||||
|
contentContainer.innerHTML = `
|
||||||
|
<div class="error">
|
||||||
|
<strong>Error loading node details:</strong><br>
|
||||||
|
${this.escapeHtml(error.message)}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
closeDrawer() {
|
||||||
|
this.drawer.closeDrawer();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get color class based on utilization percentage (same logic as gauges)
|
||||||
|
getUtilizationColorClass(percentage) {
|
||||||
|
const numPercentage = parseFloat(percentage);
|
||||||
|
|
||||||
|
if (numPercentage === 0 || isNaN(numPercentage)) return 'utilization-empty';
|
||||||
|
if (numPercentage < 50) return 'utilization-green';
|
||||||
|
if (numPercentage < 80) return 'utilization-yellow';
|
||||||
|
return 'utilization-red';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format lastSeen timestamp to human readable format
|
||||||
|
formatLastSeen(lastSeen) {
|
||||||
|
if (!lastSeen) return 'Unknown';
|
||||||
|
|
||||||
|
// lastSeen appears to be in milliseconds
|
||||||
|
const now = Date.now();
|
||||||
|
const diff = now - lastSeen;
|
||||||
|
|
||||||
|
if (diff < 60000) { // Less than 1 minute
|
||||||
|
return 'Just now';
|
||||||
|
} else if (diff < 3600000) { // Less than 1 hour
|
||||||
|
const minutes = Math.floor(diff / 60000);
|
||||||
|
return `${minutes}m ago`;
|
||||||
|
} else if (diff < 86400000) { // Less than 1 day
|
||||||
|
const hours = Math.floor(diff / 3600000);
|
||||||
|
const minutes = Math.floor((diff % 3600000) / 60000);
|
||||||
|
return `${hours}h ${minutes}m ago`;
|
||||||
|
} else { // More than 1 day
|
||||||
|
const days = Math.floor(diff / 86400000);
|
||||||
|
const hours = Math.floor((diff % 86400000) / 3600000);
|
||||||
|
return `${days}d ${hours}h ago`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format flash size in human readable format
|
||||||
|
formatFlashSize(bytes) {
|
||||||
|
if (!bytes || bytes === 0) return 'Unknown';
|
||||||
|
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
let size = bytes;
|
||||||
|
let unitIndex = 0;
|
||||||
|
|
||||||
|
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||||
|
size /= 1024;
|
||||||
|
unitIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up resources
|
||||||
|
cleanup() {
|
||||||
|
if (this.drawer) {
|
||||||
|
this.drawer.cleanup();
|
||||||
|
}
|
||||||
|
super.cleanup();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup click event listeners for node cards
|
||||||
|
setupNodeCardClickListeners(container) {
|
||||||
|
const nodeCards = container.querySelectorAll('.node-card');
|
||||||
|
nodeCards.forEach(card => {
|
||||||
|
const nodeIp = card.dataset.nodeIp;
|
||||||
|
if (nodeIp) {
|
||||||
|
// Find the node data
|
||||||
|
const nodeResources = this.viewModel.get('nodeResources');
|
||||||
|
const nodeData = nodeResources.get(nodeIp);
|
||||||
|
|
||||||
|
if (nodeData) {
|
||||||
|
this.addEventListener(card, 'click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
this.openDrawerForNode(nodeData);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add hover cursor style
|
||||||
|
card.style.cursor = 'pointer';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = `
|
||||||
|
<div class="loading">
|
||||||
|
<div>Loading cluster resource summary...</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="error">
|
||||||
|
<div>❌ Error: ${error}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
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 = `
|
||||||
|
<div class="cluster-summary-content">
|
||||||
|
<div class="summary-header">
|
||||||
|
<h3>Summary</h3>
|
||||||
|
<div class="last-updated">${lastUpdatedText}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="summary-stats">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon">🖥️</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<div class="stat-label">Total Nodes</div>
|
||||||
|
<div class="stat-value">${clusterSummary.totalNodes}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon">⚡</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<div class="stat-label">CPU</div>
|
||||||
|
<div class="stat-value">${Math.round(clusterSummary.totalCpu - clusterSummary.availableCpu)}MHz / ${Math.round(clusterSummary.totalCpu)}MHz</div>
|
||||||
|
<div class="stat-utilization">
|
||||||
|
<div class="utilization-bar">
|
||||||
|
<div class="utilization-fill ${this.getUtilizationColorClass(cpuUtilization)}" style="width: ${cpuUtilization}%"></div>
|
||||||
|
</div>
|
||||||
|
<span class="utilization-text">${cpuUtilization}% used</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon">🧠</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<div class="stat-label">Memory</div>
|
||||||
|
<div class="stat-value">${this.viewModel.formatResourceValue(clusterSummary.totalMemory - clusterSummary.availableMemory, 'memory')} / ${this.viewModel.formatResourceValue(clusterSummary.totalMemory, 'memory')}</div>
|
||||||
|
<div class="stat-utilization">
|
||||||
|
<div class="utilization-bar">
|
||||||
|
<div class="utilization-fill ${this.getUtilizationColorClass(memoryUtilization)}" style="width: ${memoryUtilization}%"></div>
|
||||||
|
</div>
|
||||||
|
<span class="utilization-text">${memoryUtilization}% used</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon">💾</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<div class="stat-label">Storage</div>
|
||||||
|
<div class="stat-value">${this.viewModel.formatResourceValue(clusterSummary.totalStorage - clusterSummary.availableStorage, 'storage')} / ${this.viewModel.formatResourceValue(clusterSummary.totalStorage, 'storage')}</div>
|
||||||
|
<div class="stat-utilization">
|
||||||
|
<div class="utilization-bar">
|
||||||
|
<div class="utilization-fill ${this.getUtilizationColorClass(storageUtilization)}" style="width: ${storageUtilization}%"></div>
|
||||||
|
</div>
|
||||||
|
<span class="utilization-text">${storageUtilization}% used</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderNodesMonitoring(isLoading, error, nodeResources) {
|
||||||
|
const container = this.findElement('#nodes-monitoring');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="loading">
|
||||||
|
<div>Loading node resource data...</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="error">
|
||||||
|
<div>❌ Error: ${error}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!nodeResources || nodeResources.size === 0) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="no-data">
|
||||||
|
<div>No node resource data available</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodesHtml = Array.from(nodeResources.values()).map(nodeData => {
|
||||||
|
return this.renderNodeCard(nodeData);
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="nodes-monitoring-content">
|
||||||
|
<h3>🖥️ Node Resource Details</h3>
|
||||||
|
<div class="nodes-grid">
|
||||||
|
${nodesHtml}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Add click event listeners to node cards
|
||||||
|
this.setupNodeCardClickListeners(container);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderNodeCard(nodeData) {
|
||||||
|
const { ip, hostname, resources, hasResources, error, resourceSource } = nodeData;
|
||||||
|
|
||||||
|
|
||||||
|
if (!hasResources) {
|
||||||
|
return `
|
||||||
|
<div class="node-card error" data-node-ip="${ip}">
|
||||||
|
<div class="node-header">
|
||||||
|
<div class="node-title">${hostname || ip}</div>
|
||||||
|
<div class="node-ip">${ip}</div>
|
||||||
|
</div>
|
||||||
|
<div class="node-status">
|
||||||
|
<span class="status-badge error">❌ No Resources</span>
|
||||||
|
</div>
|
||||||
|
<div class="node-error">
|
||||||
|
${error || 'Monitoring endpoint not available'}
|
||||||
|
</div>
|
||||||
|
${nodeData.lastSeen ? `
|
||||||
|
<div class="node-uptime">
|
||||||
|
<div class="uptime-label">⏱️ Last Seen</div>
|
||||||
|
<div class="uptime-value">${this.formatLastSeen(nodeData.lastSeen)}</div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 || {};
|
||||||
|
const system = resources?.system || {};
|
||||||
|
|
||||||
|
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 `
|
||||||
|
<div class="node-card" data-node-ip="${ip}">
|
||||||
|
<div class="node-header">
|
||||||
|
<div class="node-title">${hostname || ip}</div>
|
||||||
|
<div class="node-ip">${ip}</div>
|
||||||
|
</div>
|
||||||
|
<div class="node-status">
|
||||||
|
<span class="status-badge ${resourceSource === 'monitoring' ? 'success' : 'warning'}">${resourceSourceText}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${system.uptime_formatted ? `
|
||||||
|
<div class="node-uptime">
|
||||||
|
<div class="uptime-label">⏱️ Uptime</div>
|
||||||
|
<div class="uptime-value">${system.uptime_formatted}</div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
<div class="node-latency">
|
||||||
|
<div class="latency-label">🐢 Latency</div>
|
||||||
|
<div class="latency-value">${nodeData.latency ? `${nodeData.latency}ms` : 'N/A'}</div>
|
||||||
|
</div>
|
||||||
|
<div class="latency-divider"></div>
|
||||||
|
|
||||||
|
${(nodeData.basic?.flashChipSize || nodeData.resources?.flashChipSize) ? `
|
||||||
|
<div class="node-flash">
|
||||||
|
<div class="flash-label">💾 Flash</div>
|
||||||
|
<div class="flash-value">${this.formatFlashSize(nodeData.basic?.flashChipSize || nodeData.resources?.flashChipSize)}</div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
<div class="node-resources">
|
||||||
|
<div class="resource-item">
|
||||||
|
<div class="resource-label">⚡ CPU</div>
|
||||||
|
<div class="resource-value">${Math.round(cpuUsed)}MHz / ${Math.round(cpuTotal)}MHz</div>
|
||||||
|
<div class="resource-utilization">
|
||||||
|
<div class="utilization-bar">
|
||||||
|
<div class="utilization-fill ${this.getUtilizationColorClass(cpuUtilization)}" style="width: ${cpuUtilization}%"></div>
|
||||||
|
</div>
|
||||||
|
<span class="utilization-text">${cpuUtilization}% used</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="resource-item">
|
||||||
|
<div class="resource-label">🧠 Memory</div>
|
||||||
|
<div class="resource-value">${this.viewModel.formatResourceValue(memoryUsed, 'memory')} / ${this.viewModel.formatResourceValue(memoryTotal, 'memory')}</div>
|
||||||
|
<div class="resource-utilization">
|
||||||
|
<div class="utilization-bar">
|
||||||
|
<div class="utilization-fill ${this.getUtilizationColorClass(memoryUtilization)}" style="width: ${memoryUtilization}%"></div>
|
||||||
|
</div>
|
||||||
|
<span class="utilization-text">${memoryUtilization}% used</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="resource-item">
|
||||||
|
<div class="resource-label">💾 Storage</div>
|
||||||
|
<div class="resource-value">${this.viewModel.formatResourceValue(storageUsed, 'storage')} / ${this.viewModel.formatResourceValue(storageTotal, 'storage')}</div>
|
||||||
|
<div class="resource-utilization">
|
||||||
|
<div class="utilization-bar">
|
||||||
|
<div class="utilization-fill ${this.getUtilizationColorClass(storageUtilization)}" style="width: ${storageUtilization}%"></div>
|
||||||
|
</div>
|
||||||
|
<span class="utilization-text">${storageUtilization}% used</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,22 @@ class NodeDetailsComponent extends Component {
|
|||||||
return (r << 16) + (g << 8) + b;
|
return (r << 16) + (g << 8) + b;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Format flash size in human readable format
|
||||||
|
formatFlashSize(bytes) {
|
||||||
|
if (!bytes || bytes === 0) return 'Unknown';
|
||||||
|
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
let size = bytes;
|
||||||
|
let unitIndex = 0;
|
||||||
|
|
||||||
|
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||||
|
size /= 1024;
|
||||||
|
unitIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
||||||
|
}
|
||||||
|
|
||||||
// Parameter component renderers
|
// Parameter component renderers
|
||||||
renderSelectComponent(p, formId, pidx) {
|
renderSelectComponent(p, formId, pidx) {
|
||||||
return `<select id="${formId}-field-${pidx}" data-param-name="${p.name}" data-param-location="${p.location || 'body'}" data-param-type="${p.type || 'string'}" data-param-required="${p.required ? '1' : '0'}" class="param-input">${p.values.map(v => `<option value="${v}">${v}</option>`).join('')}</select>`;
|
return `<select id="${formId}-field-${pidx}" data-param-name="${p.name}" data-param-location="${p.location || 'body'}" data-param-type="${p.type || 'string'}" data-param-required="${p.required ? '1' : '0'}" class="param-input">${p.values.map(v => `<option value="${v}">${v}</option>`).join('')}</select>`;
|
||||||
@@ -333,7 +349,7 @@ class NodeDetailsComponent extends Component {
|
|||||||
</div>
|
</div>
|
||||||
<div class="detail-row">
|
<div class="detail-row">
|
||||||
<span class="detail-label">Flash Size:</span>
|
<span class="detail-label">Flash Size:</span>
|
||||||
<span class="detail-value">${Math.round(nodeStatus.flashChipSize / 1024)}KB</span>
|
<span class="detail-value">${this.formatFlashSize(nodeStatus.flashChipSize)}</span>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -341,7 +357,6 @@ class NodeDetailsComponent extends Component {
|
|||||||
if (monitoringResources) {
|
if (monitoringResources) {
|
||||||
html += `
|
html += `
|
||||||
<div class="monitoring-section">
|
<div class="monitoring-section">
|
||||||
<div class="monitoring-header">Resources</div>
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// CPU Usage
|
// CPU Usage
|
||||||
|
|||||||
@@ -480,7 +480,7 @@ class NavigationViewModel extends ViewModel {
|
|||||||
super();
|
super();
|
||||||
this.setMultiple({
|
this.setMultiple({
|
||||||
activeView: 'cluster',
|
activeView: 'cluster',
|
||||||
views: ['cluster', 'firmware']
|
views: ['cluster', 'topology', 'firmware', 'monitoring']
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -651,4 +651,232 @@ class TopologyViewModel extends ViewModel {
|
|||||||
clearSelection() {
|
clearSelection() {
|
||||||
this.set('selectedNode', null);
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -782,10 +782,6 @@ p {
|
|||||||
.latency-value {
|
.latency-value {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
background: var(--bg-secondary);
|
|
||||||
padding: 0.2rem 0.5rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
border: 1px solid var(--border-primary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tab Styles */
|
/* Tab Styles */
|
||||||
@@ -1357,7 +1353,7 @@ p {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Special handling for cluster and firmware views to ensure proper width */
|
/* 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 */
|
display: flex; /* Use flex display for proper layout */
|
||||||
flex-direction: column; /* Stack content vertically */
|
flex-direction: column; /* Stack content vertically */
|
||||||
overflow-y: auto; /* Allow vertical scrolling */
|
overflow-y: auto; /* Allow vertical scrolling */
|
||||||
@@ -3987,3 +3983,434 @@ html {
|
|||||||
border-color: rgba(139, 92, 246, 0.5) !important;
|
border-color: rgba(139, 92, 246, 0.5) !important;
|
||||||
background: rgba(139, 92, 246, 0.08) !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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
<script src="/scripts/components/ClusterViewComponent.js"></script>
|
<script src="/scripts/components/ClusterViewComponent.js"></script>
|
||||||
<script src="/scripts/components/ClusterStatusComponent.js"></script>
|
<script src="/scripts/components/ClusterStatusComponent.js"></script>
|
||||||
<script src="/scripts/components/TopologyGraphComponent.js"></script>
|
<script src="/scripts/components/TopologyGraphComponent.js"></script>
|
||||||
|
<script src="/scripts/components/MonitoringViewComponent.js"></script>
|
||||||
<script src="/scripts/components/ComponentsLoader.js"></script>
|
<script src="/scripts/components/ComponentsLoader.js"></script>
|
||||||
<script src="/scripts/theme-manager.js"></script>
|
<script src="/scripts/theme-manager.js"></script>
|
||||||
<script src="/scripts/app.js"></script>
|
<script src="/scripts/app.js"></script>
|
||||||
@@ -41,6 +42,7 @@
|
|||||||
<div class="nav-left">
|
<div class="nav-left">
|
||||||
<button class="nav-tab active" data-view="cluster">🌐 Cluster</button>
|
<button class="nav-tab active" data-view="cluster">🌐 Cluster</button>
|
||||||
<button class="nav-tab" data-view="topology">🔗 Topology</button>
|
<button class="nav-tab" data-view="topology">🔗 Topology</button>
|
||||||
|
<button class="nav-tab" data-view="monitoring">📡 Monitoring</button>
|
||||||
<button class="nav-tab" data-view="firmware">📦 Firmware</button>
|
<button class="nav-tab" data-view="firmware">📦 Firmware</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-right">
|
<div class="nav-right">
|
||||||
@@ -114,12 +116,102 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="firmware-content" id="firmware-container">
|
<div class="firmware-content" id="firmware-container">
|
||||||
<!-- Firmware content will be rendered here -->
|
<div class="firmware-overview">
|
||||||
|
<div class="firmware-actions">
|
||||||
|
<div class="action-group">
|
||||||
|
<h3>🚀 Firmware Update</h3>
|
||||||
|
<div class="firmware-upload-compact">
|
||||||
|
<div class="compact-upload-row">
|
||||||
|
<div class="file-upload-area">
|
||||||
|
<div class="target-options">
|
||||||
|
<label class="target-option">
|
||||||
|
<input type="radio" name="target-type" value="all" checked>
|
||||||
|
<span class="radio-custom"></span>
|
||||||
|
<span class="target-label">All Nodes</span>
|
||||||
|
</label>
|
||||||
|
<label class="target-option specific-node-option">
|
||||||
|
<input type="radio" name="target-type" value="specific">
|
||||||
|
<span class="radio-custom"></span>
|
||||||
|
<span class="target-label">Specific Node</span>
|
||||||
|
<select id="specific-node-select" class="node-select">
|
||||||
|
<option value="">Select a node...</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="target-option by-label-option">
|
||||||
|
<input type="radio" name="target-type" value="labels">
|
||||||
|
<span class="radio-custom"></span>
|
||||||
|
<span class="target-label">By Label</span>
|
||||||
|
<select id="label-select" class="label-select"
|
||||||
|
style="min-width: 220px; display: inline-block; vertical-align: middle;">
|
||||||
|
<option value="">Select a label...</option>
|
||||||
|
</select>
|
||||||
|
<div id="selected-labels-container" class="selected-labels"></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="file-input-wrapper">
|
||||||
|
<input type="file" id="global-firmware-file" accept=".bin,.hex"
|
||||||
|
style="display: none;">
|
||||||
|
<button class="upload-btn-compact"
|
||||||
|
onclick="document.getElementById('global-firmware-file').click()">
|
||||||
|
📁 Choose File
|
||||||
|
</button>
|
||||||
|
<span class="file-info" id="file-info">No file selected</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="deploy-btn" id="deploy-btn" disabled>🚀 Deploy</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="firmware-nodes-list" id="firmware-nodes-list">
|
||||||
|
<!-- Nodes will be populated here -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="monitoring-view" class="view-content">
|
||||||
|
<div class="monitoring-view-section">
|
||||||
|
<div class="monitoring-header">
|
||||||
|
<h2>📡 Monitoring</h2>
|
||||||
|
<button class="refresh-btn" id="refresh-monitoring-btn">
|
||||||
|
<svg class="refresh-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M1 4v6h6M23 20v-6h-6" />
|
||||||
|
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15" />
|
||||||
|
</svg>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="monitoring-content">
|
||||||
|
<div class="cluster-summary" id="cluster-summary">
|
||||||
|
<div class="loading">
|
||||||
|
<div>Loading cluster resource summary...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nodes-monitoring" id="nodes-monitoring">
|
||||||
|
<div class="loading">
|
||||||
|
<div>Loading node resource data...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Mock status indicator -->
|
||||||
|
<div class="mock-status" id="mock-status">
|
||||||
|
<div class="mock-status-content">
|
||||||
|
<span class="mock-status-icon">🎭</span>
|
||||||
|
<span class="mock-status-text">Mock Mode</span>
|
||||||
|
<button class="btn-sm" id="mock-info-btn">Info</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Mock server status indicator
|
// Mock server status indicator
|
||||||
@@ -145,7 +237,7 @@
|
|||||||
<style>
|
<style>
|
||||||
.mock-status {
|
.mock-status {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 20px;
|
bottom: 20px;
|
||||||
right: 20px;
|
right: 20px;
|
||||||
background: rgba(255, 193, 7, 0.9);
|
background: rgba(255, 193, 7, 0.9);
|
||||||
color: #000;
|
color: #000;
|
||||||
|
|||||||