feat: monitoring view

This commit is contained in:
2025-09-20 12:49:44 +02:00
parent e0e86f88a9
commit c13d544e54
5 changed files with 920 additions and 3 deletions

View File

@@ -21,6 +21,7 @@
<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="firmware">📦 Firmware</button>
<button class="nav-tab" data-view="monitoring">📊 Monitoring</button>
</div>
<div class="nav-right">
<div class="theme-switcher">
@@ -138,6 +139,35 @@
</div>
</div>
</div>
<div id="monitoring-view" class="view-content">
<div class="monitoring-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>
<script src="./vendor/d3.v7.min.js"></script>
@@ -156,6 +186,7 @@
<script src="./scripts/components/ClusterViewComponent.js"></script>
<script src="./scripts/components/ClusterStatusComponent.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/theme-manager.js"></script>
<script src="./scripts/app.js"></script>

View File

@@ -15,7 +15,8 @@ document.addEventListener('DOMContentLoaded', async function() {
const clusterViewModel = new ClusterViewModel();
const firmwareViewModel = new FirmwareViewModel();
const topologyViewModel = new TopologyViewModel();
logger.debug('App: View models created:', { clusterViewModel, firmwareViewModel, topologyViewModel });
const monitoringViewModel = new MonitoringViewModel();
logger.debug('App: View models created:', { clusterViewModel, firmwareViewModel, topologyViewModel, monitoringViewModel });
// Connect firmware view model to cluster data
clusterViewModel.subscribe('members', (members) => {
@@ -40,6 +41,7 @@ document.addEventListener('DOMContentLoaded', async function() {
app.registerRoute('cluster', ClusterViewComponent, 'cluster-view', clusterViewModel);
app.registerRoute('topology', TopologyGraphComponent, 'topology-view', topologyViewModel);
app.registerRoute('firmware', FirmwareViewComponent, 'firmware-view', firmwareViewModel);
app.registerRoute('monitoring', MonitoringViewComponent, 'monitoring-view', monitoringViewModel);
logger.debug('App: Routes registered and components pre-initialized');
// Initialize cluster status component for header badge

View File

@@ -0,0 +1,346 @@
// Monitoring View Component
class MonitoringViewComponent extends Component {
constructor(container, viewModel, eventBus) {
super(container, viewModel, eventBus);
logger.debug('MonitoringViewComponent: Constructor called');
logger.debug('MonitoringViewComponent: Container:', container);
logger.debug('MonitoringViewComponent: Container ID:', container?.id);
// Track if we've already loaded data to prevent unnecessary reloads
this.dataLoaded = false;
}
mount() {
logger.debug('MonitoringViewComponent: Mounting...');
super.mount();
// Set up refresh button event listener
this.setupRefreshButton();
// Only load data if we haven't already or if the view model is empty
const clusterMembers = this.viewModel.get('clusterMembers');
if (!this.dataLoaded || !clusterMembers || clusterMembers.length === 0) {
this.loadData();
}
// Subscribe to view model changes
this.setupSubscriptions();
}
setupRefreshButton() {
const refreshBtn = this.findElement('#refresh-monitoring-btn');
if (refreshBtn) {
refreshBtn.addEventListener('click', () => {
this.refreshData();
});
}
}
setupSubscriptions() {
// Subscribe to cluster members changes
this.viewModel.subscribe('clusterMembers', () => {
this.render();
});
// Subscribe to node resources changes
this.viewModel.subscribe('nodeResources', () => {
this.render();
});
// Subscribe to cluster summary changes
this.viewModel.subscribe('clusterSummary', () => {
this.render();
});
// Subscribe to loading state changes
this.viewModel.subscribe('isLoading', () => {
this.render();
});
// Subscribe to error changes
this.viewModel.subscribe('error', () => {
this.render();
});
}
async loadData() {
logger.debug('MonitoringViewComponent: Loading data...');
this.dataLoaded = true;
await this.viewModel.loadClusterData();
}
async refreshData() {
logger.debug('MonitoringViewComponent: Refreshing data...');
await this.viewModel.refresh();
}
render() {
logger.debug('MonitoringViewComponent: Rendering...');
const isLoading = this.viewModel.get('isLoading');
const error = this.viewModel.get('error');
const clusterSummary = this.viewModel.get('clusterSummary');
const nodeResources = this.viewModel.get('nodeResources');
const lastUpdated = this.viewModel.get('lastUpdated');
// Render cluster summary
this.renderClusterSummary(isLoading, error, clusterSummary, lastUpdated);
// Render nodes monitoring
this.renderNodesMonitoring(isLoading, error, nodeResources);
}
renderClusterSummary(isLoading, error, clusterSummary, lastUpdated) {
const container = this.findElement('#cluster-summary');
if (!container) return;
if (isLoading) {
container.innerHTML = `
<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" 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" 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" 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>
`;
}
renderNodeCard(nodeData) {
const { ip, hostname, resources, hasResources, error, resourceSource } = nodeData;
if (!hasResources) {
return `
<div class="node-card error">
<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>
</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 || {};
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">
<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>
<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" 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" 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" style="width: ${storageUtilization}%"></div>
</div>
<span class="utilization-text">${storageUtilization}% used</span>
</div>
</div>
</div>
</div>
`;
}
}

View File

@@ -480,7 +480,7 @@ class NavigationViewModel extends ViewModel {
super();
this.setMultiple({
activeView: 'cluster',
views: ['cluster', 'firmware']
views: ['cluster', 'topology', 'firmware', 'monitoring']
});
}
@@ -652,3 +652,230 @@ class TopologyViewModel extends ViewModel {
this.set('selectedNode', null);
}
}
// Monitoring View Model for cluster resource monitoring
class MonitoringViewModel extends ViewModel {
constructor() {
super();
this.setMultiple({
clusterMembers: [],
nodeResources: new Map(), // Map of node IP -> resource data
clusterSummary: {
totalCpu: 0,
totalMemory: 0,
totalStorage: 0,
totalNodes: 0,
availableCpu: 0,
availableMemory: 0,
availableStorage: 0
},
isLoading: false,
lastUpdated: null,
error: null
});
}
// Load cluster members and their resource data
async loadClusterData() {
this.set('isLoading', true);
this.set('error', null);
try {
// Get cluster members
const response = await window.apiClient.getClusterMembers();
// Extract members array from response object
const members = response.members || [];
this.set('clusterMembers', members);
// Load resource data for each node
await this.loadNodeResources(members);
// Calculate cluster summary
this.calculateClusterSummary();
this.set('lastUpdated', new Date());
} catch (error) {
console.error('Failed to load cluster monitoring data:', error);
this.set('error', error.message || 'Failed to load monitoring data');
} finally {
this.set('isLoading', false);
}
}
// Load resource data for all nodes
async loadNodeResources(members) {
const nodeResources = new Map();
// Process nodes in parallel
const resourcePromises = members.map(async (member) => {
try {
const resources = await window.apiClient.getMonitoringResources(member.ip);
// Handle both real API (wrapped in data) and mock API (direct response)
const resourceData = (resources && resources.data) ? resources.data : resources;
nodeResources.set(member.ip, {
...member,
resources: resourceData,
hasResources: true,
resourceSource: 'monitoring'
});
} catch (error) {
console.warn(`Failed to load monitoring resources for node ${member.ip}:`, error);
// Fall back to basic resource data from cluster members API
const basicResources = member.resources ? this.convertBasicResources(member.resources) : null;
nodeResources.set(member.ip, {
...member,
resources: basicResources,
hasResources: !!basicResources,
resourceSource: basicResources ? 'basic' : 'none',
error: basicResources ? null : error.message
});
}
});
await Promise.all(resourcePromises);
this.set('nodeResources', nodeResources);
}
// Convert basic resource data from cluster members API to monitoring format
convertBasicResources(basicResources) {
// Convert ESP32 basic resources to monitoring format
const freeHeap = basicResources.freeHeap || 0;
const flashSize = basicResources.flashChipSize || 0;
const cpuFreq = basicResources.cpuFreqMHz || 80;
// Estimate total heap (ESP32 typically has ~300KB heap)
const estimatedTotalHeap = 300 * 1024; // 300KB in bytes
const usedHeap = estimatedTotalHeap - freeHeap;
return {
cpu: {
total: cpuFreq, // Total CPU frequency in MHz
available: cpuFreq * 0.8, // Estimate 80% available
used: cpuFreq * 0.2
},
memory: {
total: estimatedTotalHeap,
available: freeHeap,
used: usedHeap
},
storage: {
total: flashSize,
available: flashSize * 0.5, // Estimate 50% available
used: flashSize * 0.5
},
// Include original basic resources for reference
basic: basicResources
};
}
// Calculate cluster resource summary
calculateClusterSummary() {
const nodeResources = this.get('nodeResources');
const members = this.get('clusterMembers');
let totalCpu = 0;
let totalMemory = 0;
let totalStorage = 0;
let availableCpu = 0;
let availableMemory = 0;
let availableStorage = 0;
let totalNodes = 0;
for (const [ip, nodeData] of nodeResources) {
if (nodeData.hasResources && nodeData.resources) {
totalNodes++;
// Extract resource data (handle both monitoring API and basic data formats)
const resources = nodeData.resources;
// CPU resources
if (resources.cpu) {
const cpuFreqMHz = nodeData.basic?.cpuFreqMHz || 80; // Get CPU frequency from basic resources
if (nodeData.resourceSource === 'monitoring') {
// Real monitoring API format
totalCpu += cpuFreqMHz; // Total CPU frequency in MHz
availableCpu += cpuFreqMHz * (100 - (resources.cpu.average_usage || 0)) / 100; // Available frequency
} else {
// Basic data format
totalCpu += cpuFreqMHz; // Total CPU frequency in MHz
availableCpu += cpuFreqMHz * (resources.cpu.available || 0.8); // Available frequency
}
}
// Memory resources
if (resources.memory) {
if (nodeData.resourceSource === 'monitoring') {
// Real monitoring API format
totalMemory += resources.memory.total_heap || 0;
availableMemory += resources.memory.free_heap || 0;
} else {
// Basic data format
totalMemory += resources.memory.total || 0;
availableMemory += resources.memory.available || 0;
}
}
// Storage resources
if (resources.filesystem || resources.storage) {
const storage = resources.filesystem || resources.storage;
if (nodeData.resourceSource === 'monitoring') {
// Real monitoring API format
totalStorage += storage.total_bytes || 0;
availableStorage += storage.free_bytes || 0;
} else {
// Basic data format
totalStorage += storage.total || 0;
availableStorage += storage.available || 0;
}
}
}
}
this.set('clusterSummary', {
totalCpu,
totalMemory,
totalStorage,
totalNodes,
availableCpu,
availableMemory,
availableStorage
});
}
// Get resource utilization percentage
getResourceUtilization(resourceType) {
const summary = this.get('clusterSummary');
const total = summary[`total${resourceType}`];
const available = summary[`available${resourceType}`];
if (total === 0) return 0;
return Math.round(((total - available) / total) * 100);
}
// Get formatted resource value
formatResourceValue(value, type) {
if (type === 'memory' || type === 'storage') {
// Convert bytes to human readable format
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let size = value;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(1)} ${units[unitIndex]}`;
} else if (type === 'cpu') {
// CPU is typically in cores or percentage
return `${value} cores`;
}
return value.toString();
}
// Refresh monitoring data
async refresh() {
await this.loadClusterData();
}
}

View File

@@ -1357,7 +1357,7 @@ p {
}
/* Special handling for cluster and firmware views to ensure proper width */
#cluster-view.active, #firmware-view.active {
#cluster-view.active, #firmware-view.active, #monitoring-view.active {
display: flex; /* Use flex display for proper layout */
flex-direction: column; /* Stack content vertically */
overflow-y: auto; /* Allow vertical scrolling */
@@ -3987,3 +3987,314 @@ html {
border-color: rgba(139, 92, 246, 0.5) !important;
background: rgba(139, 92, 246, 0.08) !important;
}
/* Monitoring View Styles */
.monitoring-section {
background: var(--bg-secondary);
border-radius: 16px;
backdrop-filter: var(--backdrop-blur);
box-shadow: var(--shadow-primary);
border: 1px solid var(--border-primary);
padding: 1rem;
margin-bottom: 1rem;
position: relative;
overflow: visible;
}
.monitoring-section::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
}
.monitoring-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border-primary);
}
.monitoring-header h2 {
color: var(--text-primary);
margin: 0;
font-size: 1.5rem;
font-weight: 600;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
.monitoring-content {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
/* Cluster Summary Styles */
.cluster-summary-content {
/*background: var(--bg-tertiary);
border-radius: 12px;
padding: 1.5rem;
border: 1px solid var(--border-primary);*/
}
.summary-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.summary-header h3 {
color: var(--text-primary);
margin: 0;
font-size: 1.25rem;
font-weight: 600;
}
.last-updated {
color: var(--text-secondary);
font-size: 0.875rem;
opacity: 0.8;
}
.summary-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
}
.stat-card {
background: var(--bg-primary);
border-radius: 10px;
padding: 1.25rem;
border: 1px solid var(--border-primary);
display: flex;
align-items: center;
gap: 1rem;
transition: all 0.2s ease;
}
.stat-card:hover {
border-color: rgba(255, 255, 255, 0.1);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.stat-icon {
font-size: 2rem;
opacity: 0.8;
}
.stat-content {
flex: 1;
}
.stat-label {
color: var(--text-secondary);
font-size: 0.875rem;
margin-bottom: 0.25rem;
}
.stat-value {
color: var(--text-primary);
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
.stat-utilization {
display: flex;
align-items: center;
gap: 0.5rem;
}
.utilization-bar {
flex: 1;
height: 6px;
background: rgba(255, 255, 255, 0.1);
border-radius: 3px;
overflow: hidden;
}
.utilization-fill {
height: 100%;
background: linear-gradient(90deg, #4ade80, #22c55e);
border-radius: 3px;
transition: width 0.3s ease;
}
.utilization-text {
color: var(--text-secondary);
font-size: 0.75rem;
font-weight: 500;
min-width: 3rem;
}
/* Nodes Monitoring Styles */
.nodes-monitoring-content h3 {
color: var(--text-primary);
margin: 0 0 1rem 0;
font-size: 1.25rem;
font-weight: 600;
}
.nodes-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1rem;
}
.node-card {
background: var(--bg-tertiary);
border-radius: 12px;
padding: 1.25rem;
border: 1px solid var(--border-primary);
transition: all 0.2s ease;
}
.node-card:hover {
border-color: rgba(255, 255, 255, 0.1);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.node-card.error {
border-color: rgba(239, 68, 68, 0.3);
background: rgba(239, 68, 68, 0.05);
}
.node-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--border-primary);
}
.node-title {
color: var(--text-primary);
font-size: 1.1rem;
font-weight: 600;
}
.node-ip {
color: var(--text-secondary);
font-size: 0.875rem;
font-family: 'Courier New', monospace;
}
.node-status {
margin-bottom: 0.75rem;
}
.status-badge {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 6px;
font-size: 0.75rem;
font-weight: 500;
}
.status-badge.error {
background: rgba(239, 68, 68, 0.2);
color: #fca5a5;
border: 1px solid rgba(239, 68, 68, 0.3);
}
.status-badge.success {
background: rgba(34, 197, 94, 0.2);
color: #86efac;
border: 1px solid rgba(34, 197, 94, 0.3);
}
.status-badge.warning {
background: rgba(245, 158, 11, 0.2);
color: #fcd34d;
border: 1px solid rgba(245, 158, 11, 0.3);
}
.node-error {
color: var(--text-secondary);
font-size: 0.875rem;
font-style: italic;
}
.node-resources {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.resource-item {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.resource-label {
color: var(--text-secondary);
font-size: 0.875rem;
font-weight: 500;
}
.resource-value {
color: var(--text-primary);
font-size: 1rem;
font-weight: 600;
}
.resource-utilization {
display: flex;
align-items: center;
gap: 0.5rem;
}
/* Error and Loading States */
.error {
text-align: center;
padding: 2rem;
color: #fca5a5;
background: rgba(239, 68, 68, 0.05);
border: 1px solid rgba(239, 68, 68, 0.2);
border-radius: 8px;
}
.no-data {
text-align: center;
padding: 2rem;
color: var(--text-secondary);
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
border-radius: 8px;
}
/* Responsive Design */
@media (max-width: 768px) {
.summary-stats {
grid-template-columns: 1fr;
}
.nodes-grid {
grid-template-columns: 1fr;
}
.monitoring-header {
flex-direction: column;
gap: 1rem;
align-items: flex-start;
}
.stat-card {
flex-direction: column;
text-align: center;
gap: 0.75rem;
}
.stat-icon {
font-size: 1.5rem;
}
}