Coverage for openhcs/pyqt_gui/widgets/system_monitor.py: 0.0%
268 statements
« prev ^ index » next coverage.py v7.10.7, created at 2025-10-01 18:33 +0000
« prev ^ index » next coverage.py v7.10.7, created at 2025-10-01 18:33 +0000
1"""
2System Monitor Widget for PyQt6
4Real-time system monitoring with CPU, RAM, GPU, and VRAM usage graphs.
5Migrated from Textual TUI with full feature parity.
6"""
8import logging
9import time
10from typing import Optional
11from datetime import datetime
12from collections import deque
14from PyQt6.QtWidgets import (
15 QWidget, QVBoxLayout, QHBoxLayout, QLabel, QFrame, QGridLayout
16)
17from PyQt6.QtCore import QTimer, pyqtSignal, QThread
18from PyQt6.QtGui import QFont
20# Import PyQtGraph for high-performance plotting
21try:
22 import pyqtgraph as pg
23 PYQTGRAPH_AVAILABLE = True
24except ImportError:
25 PYQTGRAPH_AVAILABLE = False
27# Import the SystemMonitor service
28from openhcs.textual_tui.services.system_monitor import SystemMonitor
29from openhcs.pyqt_gui.services.persistent_system_monitor import PersistentSystemMonitor
30from openhcs.pyqt_gui.shared.style_generator import StyleSheetGenerator
31from openhcs.pyqt_gui.shared.color_scheme import PyQt6ColorScheme
32from openhcs.pyqt_gui.config import PyQtGUIConfig, PerformanceMonitorConfig, get_default_pyqt_gui_config
34logger = logging.getLogger(__name__)
37class SystemMonitorWidget(QWidget):
38 """
39 PyQt6 System Monitor Widget.
41 Displays real-time system metrics with graphs for CPU, RAM, GPU, and VRAM usage.
42 Provides the same functionality as the Textual SystemMonitorTextual widget.
43 """
45 # Signals
46 metrics_updated = pyqtSignal(dict) # Emitted when metrics are updated
48 def __init__(self,
49 color_scheme: Optional[PyQt6ColorScheme] = None,
50 config: Optional[PyQtGUIConfig] = None,
51 parent=None):
52 """
53 Initialize the system monitor widget.
55 Args:
56 color_scheme: Color scheme for styling (optional, uses default if None)
57 config: GUI configuration (optional, uses default if None)
58 parent: Parent widget
59 """
60 super().__init__(parent)
62 # Initialize configuration
63 self.config = config or get_default_pyqt_gui_config()
64 self.monitor_config = self.config.performance_monitor
66 # Initialize color scheme and style generator
67 self.color_scheme = color_scheme or PyQt6ColorScheme()
68 self.style_generator = StyleSheetGenerator(self.color_scheme)
70 # Calculate monitoring parameters from configuration
71 update_interval = self.monitor_config.update_interval_seconds
72 history_length = self.monitor_config.calculated_max_data_points
74 # Core monitoring - use persistent thread for non-blocking metrics collection
75 self.monitor = SystemMonitor(history_length=history_length) # Match the dynamic history length
77 self.persistent_monitor = PersistentSystemMonitor(
78 update_interval=update_interval,
79 history_length=history_length
80 )
81 # No timer needed - the persistent thread handles timing
83 # Setup UI
84 self.setup_ui()
85 self.setup_connections()
87 # Delay monitoring start until widget is shown (fixes WSL2 hanging)
88 self._monitoring_started = False
90 logger.debug("System monitor widget initialized")
92 def showEvent(self, event):
93 """Handle widget show event - start monitoring when widget becomes visible."""
94 super().showEvent(event)
95 if not self._monitoring_started:
96 # Start monitoring only when widget is actually shown
97 # This prevents WSL2 hanging issues during initialization
98 self.start_monitoring()
99 self._monitoring_started = True
100 logger.debug("System monitoring started on widget show")
102 def closeEvent(self, event):
103 """Handle widget close event - cleanup resources."""
104 self.cleanup()
105 super().closeEvent(event)
107 def __del__(self):
108 """Destructor - ensure cleanup happens."""
109 try:
110 self.cleanup()
111 except:
112 pass # Ignore errors during destruction
114 def setup_ui(self):
115 """Setup the user interface."""
116 layout = QVBoxLayout(self)
117 layout.setContentsMargins(10, 10, 10, 10)
118 layout.setSpacing(10)
120 # Header section
121 header_layout = self.create_header_section()
122 layout.addLayout(header_layout)
124 # Monitoring section
125 if PYQTGRAPH_AVAILABLE:
126 monitoring_widget = self.create_pyqtgraph_section()
127 else:
128 monitoring_widget = self.create_fallback_section()
130 layout.addWidget(monitoring_widget)
132 # Apply centralized styling
133 self.setStyleSheet(self.style_generator.generate_system_monitor_style())
135 def create_header_section(self) -> QHBoxLayout:
136 """
137 Create the header section with title and system info.
139 Returns:
140 Header layout
141 """
142 header_layout = QHBoxLayout()
144 # ASCII header (left side)
145 self.header_label = QLabel(self.get_ascii_header())
146 self.header_label.setObjectName("header_label")
147 font = QFont("Courier", 10)
148 font.setBold(True)
149 self.header_label.setFont(font)
150 header_layout.addWidget(self.header_label)
152 # System info (right side)
153 self.info_label = QLabel("")
154 self.info_label.setObjectName("info_label")
155 self.info_label.setFont(QFont("Courier", 8))
156 self.info_label.setWordWrap(True)
157 header_layout.addWidget(self.info_label)
159 return header_layout
161 def create_pyqtgraph_section(self) -> QWidget:
162 """
163 Create PyQtGraph-based monitoring section with consolidated graphs.
165 Returns:
166 Widget containing consolidated PyQtGraph plots
167 """
168 widget = QWidget()
169 layout = QGridLayout(widget)
171 # Configure PyQtGraph based on config settings
172 pg.setConfigOption('background', self.color_scheme.to_hex(self.color_scheme.window_bg))
173 pg.setConfigOption('foreground', 'white')
174 pg.setConfigOption('antialias', self.monitor_config.antialiasing)
176 # Create consolidated PyQtGraph plots in 1x2 grid
177 self.cpu_gpu_plot = pg.PlotWidget(title="CPU/GPU Usage")
178 self.ram_vram_plot = pg.PlotWidget(title="RAM/VRAM Usage")
180 # Disable mouse interaction on plots
181 self.cpu_gpu_plot.setMouseEnabled(x=False, y=False)
182 self.ram_vram_plot.setMouseEnabled(x=False, y=False)
183 self.cpu_gpu_plot.setMenuEnabled(False)
184 self.ram_vram_plot.setMenuEnabled(False)
186 # Store plot data items for efficient updates using configured colors and line width
187 colors = self.monitor_config.chart_colors
188 line_width = self.monitor_config.line_width
190 # CPU/GPU plot curves
191 self.cpu_curve = self.cpu_gpu_plot.plot(pen=pg.mkPen(colors['cpu'], width=line_width), name='CPU')
192 self.gpu_curve = self.cpu_gpu_plot.plot(pen=pg.mkPen(colors['gpu'], width=line_width), name='GPU')
194 # RAM/VRAM plot curves
195 self.ram_curve = self.ram_vram_plot.plot(pen=pg.mkPen(colors['ram'], width=line_width), name='RAM')
196 self.vram_curve = self.ram_vram_plot.plot(pen=pg.mkPen(colors['vram'], width=line_width), name='VRAM')
198 # Style CPU/GPU plot
199 self.cpu_gpu_plot.setBackground(self.color_scheme.to_hex(self.color_scheme.panel_bg))
200 self.cpu_gpu_plot.setYRange(0, 100)
201 self.cpu_gpu_plot.setXRange(0, self.monitor_config.history_duration_seconds) # Show time range in seconds
202 self.cpu_gpu_plot.setLabel('left', 'Usage (%)')
203 self.cpu_gpu_plot.setLabel('bottom', 'Time (seconds)')
204 self.cpu_gpu_plot.showGrid(x=self.monitor_config.show_grid, y=self.monitor_config.show_grid, alpha=0.3)
205 self.cpu_gpu_plot.getAxis('left').setTextPen('white')
206 self.cpu_gpu_plot.getAxis('bottom').setTextPen('white')
207 self.cpu_gpu_plot.addLegend()
209 # Style RAM/VRAM plot
210 self.ram_vram_plot.setBackground(self.color_scheme.to_hex(self.color_scheme.panel_bg))
211 self.ram_vram_plot.setYRange(0, 100)
212 self.ram_vram_plot.setXRange(0, self.monitor_config.history_duration_seconds) # Show time range in seconds
213 self.ram_vram_plot.setLabel('left', 'Usage (%)')
214 self.ram_vram_plot.setLabel('bottom', 'Time (seconds)')
215 self.ram_vram_plot.showGrid(x=self.monitor_config.show_grid, y=self.monitor_config.show_grid, alpha=0.3)
216 self.ram_vram_plot.getAxis('left').setTextPen('white')
217 self.ram_vram_plot.getAxis('bottom').setTextPen('white')
218 self.ram_vram_plot.addLegend()
220 # Add plots to grid layout (1x2 instead of 2x2)
221 layout.addWidget(self.cpu_gpu_plot, 0, 0)
222 layout.addWidget(self.ram_vram_plot, 0, 1)
224 return widget
226 def create_fallback_section(self) -> QWidget:
227 """
228 Create fallback text-based monitoring section.
230 Returns:
231 Widget containing text-based display
232 """
233 widget = QFrame()
234 widget.setFrameStyle(QFrame.Shape.Box)
235 widget.setStyleSheet(f"""
236 QFrame {{
237 background-color: {self.color_scheme.to_hex(self.color_scheme.panel_bg)};
238 border: 1px solid {self.color_scheme.to_hex(self.color_scheme.border_color)};
239 border-radius: 3px;
240 padding: 10px;
241 }}
242 """)
244 layout = QVBoxLayout(widget)
246 self.fallback_label = QLabel("")
247 self.fallback_label.setFont(QFont("Courier", 10))
248 self.fallback_label.setStyleSheet(f"color: {self.color_scheme.to_hex(self.color_scheme.text_accent)};")
249 layout.addWidget(self.fallback_label)
251 return widget
253 def setup_connections(self):
254 """Setup signal/slot connections."""
255 self.metrics_updated.connect(self.update_display)
257 # Connect persistent monitor signals
258 self.persistent_monitor.connect_signals(
259 metrics_callback=self.on_metrics_updated,
260 error_callback=self.on_metrics_error
261 )
263 def start_monitoring(self):
264 """Start the persistent monitoring thread."""
265 self.persistent_monitor.start_monitoring()
266 logger.debug("System monitoring started")
268 def stop_monitoring(self):
269 """Stop the persistent monitoring thread."""
270 self.persistent_monitor.stop_monitoring()
271 logger.debug("System monitoring stopped")
273 def cleanup(self):
274 """Clean up widget resources."""
275 try:
276 logger.debug("Cleaning up SystemMonitorWidget...")
278 # Stop monitoring first
279 self.stop_monitoring()
281 # Clean up pyqtgraph plots
282 if PYQTGRAPH_AVAILABLE and hasattr(self, 'cpu_plot'):
283 try:
284 self.cpu_plot.clear()
285 self.ram_plot.clear()
286 self.gpu_plot.clear()
287 self.vram_plot.clear()
289 # Clear plot widgets
290 if hasattr(self, 'cpu_plot_widget'):
291 self.cpu_plot_widget.close()
292 if hasattr(self, 'ram_plot_widget'):
293 self.ram_plot_widget.close()
294 if hasattr(self, 'gpu_plot_widget'):
295 self.gpu_plot_widget.close()
296 if hasattr(self, 'vram_plot_widget'):
297 self.vram_plot_widget.close()
299 except Exception as e:
300 logger.warning(f"Error cleaning up pyqtgraph plots: {e}")
302 # Clear data
303 if hasattr(self, 'monitor'):
304 self.monitor.cpu_history.clear()
305 self.monitor.ram_history.clear()
306 self.monitor.gpu_history.clear()
307 self.monitor.vram_history.clear()
308 self.monitor.time_stamps.clear()
310 logger.debug("SystemMonitorWidget cleanup completed")
312 except Exception as e:
313 logger.warning(f"Error during SystemMonitorWidget cleanup: {e}")
315 def on_metrics_updated(self, metrics: dict):
316 """Handle metrics update from persistent monitor thread."""
317 try:
318 # Update the sync monitor's history for compatibility with existing plotting code
319 if metrics:
320 self.monitor.cpu_history.append(metrics.get('cpu_percent', 0))
321 self.monitor.ram_history.append(metrics.get('ram_percent', 0))
322 self.monitor.gpu_history.append(metrics.get('gpu_percent', 0))
323 self.monitor.vram_history.append(metrics.get('vram_percent', 0))
324 self.monitor.time_stamps.append(time.time())
326 # Update cached metrics
327 self.monitor._current_metrics = metrics.copy()
329 # Use QTimer.singleShot to ensure UI update happens on main thread
330 from PyQt6.QtCore import QTimer
331 QTimer.singleShot(0, lambda: self.metrics_updated.emit(metrics))
333 except Exception as e:
334 logger.warning(f"Failed to process metrics update: {e}")
336 def on_metrics_error(self, error_message: str):
337 """Handle metrics collection error."""
338 logger.warning(f"Metrics collection failed: {error_message}")
339 # Continue with cached/default metrics to keep UI responsive
341 def update_display(self, metrics: dict):
342 """
343 Update the display with new metrics.
345 Args:
346 metrics: Dictionary of system metrics
347 """
348 try:
349 # Update system info
350 self.update_system_info(metrics)
352 # Update plots or fallback display
353 if PYQTGRAPH_AVAILABLE:
354 self.update_pyqtgraph_plots()
355 else:
356 self.update_fallback_display(metrics)
358 except Exception as e:
359 logger.warning(f"Failed to update display: {e}")
361 def update_pyqtgraph_plots(self):
362 """Update consolidated PyQtGraph plots with current data - non-blocking and fast."""
363 try:
364 # Convert data point indices to time values in seconds
365 data_length = len(self.monitor.cpu_history)
366 if data_length == 0:
367 return
369 # Create time axis: each data point represents update_interval_seconds
370 update_interval = self.monitor_config.update_interval_seconds
371 x_time = [i * update_interval for i in range(data_length)]
373 # Get current data
374 cpu_data = list(self.monitor.cpu_history)
375 ram_data = list(self.monitor.ram_history)
376 gpu_data = list(self.monitor.gpu_history)
377 vram_data = list(self.monitor.vram_history)
379 # Update CPU/GPU consolidated plot
380 self.cpu_curve.setData(x_time, cpu_data)
382 # Handle GPU data (may not be available)
383 if any(gpu_data):
384 self.gpu_curve.setData(x_time, gpu_data)
385 gpu_status = f'{gpu_data[-1]:.1f}%' if gpu_data else 'N/A'
386 else:
387 self.gpu_curve.setData([], []) # Clear data
388 gpu_status = 'Not Available'
390 # Update CPU/GPU plot title with current values
391 cpu_status = f'{cpu_data[-1]:.1f}%' if cpu_data else 'N/A'
392 self.cpu_gpu_plot.setTitle(f'CPU/GPU Usage - CPU: {cpu_status}, GPU: {gpu_status}')
394 # Update RAM/VRAM consolidated plot
395 self.ram_curve.setData(x_time, ram_data)
397 # Handle VRAM data (may not be available)
398 if any(vram_data):
399 self.vram_curve.setData(x_time, vram_data)
400 vram_status = f'{vram_data[-1]:.1f}%' if vram_data else 'N/A'
401 else:
402 self.vram_curve.setData([], []) # Clear data
403 vram_status = 'Not Available'
405 # Update RAM/VRAM plot title with current values
406 ram_status = f'{ram_data[-1]:.1f}%' if ram_data else 'N/A'
407 self.ram_vram_plot.setTitle(f'RAM/VRAM Usage - RAM: {ram_status}, VRAM: {vram_status}')
409 except Exception as e:
410 logger.warning(f"Failed to update PyQtGraph plots: {e}")
412 def update_fallback_display(self, metrics: dict):
413 """
414 Update fallback text display.
416 Args:
417 metrics: Dictionary of system metrics
418 """
419 try:
420 display_text = f"""
421┌─────────────────────────────────────────────────────────────────┐
422│ CPU: {self.create_text_bar(metrics.get('cpu_percent', 0))} {metrics.get('cpu_percent', 0):5.1f}%
423│ RAM: {self.create_text_bar(metrics.get('ram_percent', 0))} {metrics.get('ram_percent', 0):5.1f}% ({metrics.get('ram_used_gb', 0):.1f}/{metrics.get('ram_total_gb', 0):.1f}GB)
424│ GPU: {self.create_text_bar(metrics.get('gpu_percent', 0))} {metrics.get('gpu_percent', 0):5.1f}%
425│ VRAM: {self.create_text_bar(metrics.get('vram_percent', 0))} {metrics.get('vram_percent', 0):5.1f}%
426└─────────────────────────────────────────────────────────────────┘
427"""
428 self.fallback_label.setText(display_text)
430 except Exception as e:
431 logger.warning(f"Failed to update fallback display: {e}")
433 def update_system_info(self, metrics: dict):
434 """
435 Update system information display.
437 Args:
438 metrics: Dictionary of system metrics
439 """
440 try:
441 info_text = f"""
442═══════════════════════════════════════════════════════════════════════
443System Information | Time: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
444═══════════════════════════════════════════════════════════════════════
445CPU Cores: {metrics.get('cpu_cores', 'N/A')} | CPU Frequency: {metrics.get('cpu_freq_mhz', 0):.0f} MHz
446Total RAM: {metrics.get('ram_total_gb', 0):.1f} GB | Used RAM: {metrics.get('ram_used_gb', 0):.1f} GB"""
448 # Add GPU info if available
449 if 'gpu_name' in metrics:
450 info_text += f"\nGPU: {metrics.get('gpu_name', 'N/A')} | Temperature: {metrics.get('gpu_temp', 'N/A')}°C"
451 info_text += f"\nVRAM: {metrics.get('vram_used_mb', 0):.0f}/{metrics.get('vram_total_mb', 0):.0f} MB"
453 info_text += "\n═══════════════════════════════════════════════════════════════════════"
455 self.info_label.setText(info_text)
457 except Exception as e:
458 logger.warning(f"Failed to update system info: {e}")
460 def create_text_bar(self, percent: float) -> str:
461 """
462 Create a text-based progress bar.
464 Args:
465 percent: Percentage value (0-100)
467 Returns:
468 Text progress bar
469 """
470 bar_length = 20
471 filled = int(bar_length * percent / 100)
472 bar = '█' * filled + '░' * (bar_length - filled)
473 return f"[{bar}]"
475 def get_ascii_header(self) -> str:
476 """
477 Get ASCII art header.
479 Returns:
480 ASCII art header string
481 """
482 return """
483 ██████╗ ██████╗ ███████╗███╗ ██╗██╗ ██╗ ██████╗███████╗
484██╔═══██╗██╔══██╗██╔════╝████╗ ██║██║ ██║██╔════╝██╔════╝
485██║ ██║██████╔╝█████╗ ██╔██╗ ██║███████║██║ ███████╗
486██║ ██║██╔═══╝ ██╔══╝ ██║╚██╗██║██╔══██║██║ ╚════██║
487╚██████╔╝██║ ███████╗██║ ╚████║██║ ██║╚██████╗███████║
488 ╚═════╝ ╚═╝ ╚══════╝╚═╝ ╚═══╝╚═╝ ╚═╝ ╚═════╝╚══════╝
489 """
491 def set_update_interval(self, interval_ms: int):
492 """
493 Set the update interval for monitoring.
495 Args:
496 interval_ms: Update interval in milliseconds
497 """
498 interval_seconds = interval_ms / 1000.0
499 self.persistent_monitor.set_update_interval(interval_seconds)
501 def update_config(self, new_config: PyQtGUIConfig):
502 """
503 Update the widget configuration and apply changes.
505 Args:
506 new_config: New configuration to apply
507 """
508 old_config = self.config
509 self.config = new_config
510 self.monitor_config = new_config.performance_monitor
512 # Check if we need to restart monitoring with new parameters
513 if (old_config.performance_monitor.update_fps != new_config.performance_monitor.update_fps or
514 old_config.performance_monitor.history_duration_seconds != new_config.performance_monitor.history_duration_seconds):
516 logger.info(f"Updating performance monitor: {new_config.performance_monitor.update_fps} FPS, "
517 f"{new_config.performance_monitor.history_duration_seconds}s history")
519 # Stop current monitoring
520 self.stop_monitoring()
522 # Recalculate parameters
523 update_interval = self.monitor_config.update_interval_seconds
524 history_length = self.monitor_config.calculated_max_data_points
526 # Create new monitors with updated config
527 self.monitor = SystemMonitor(history_length=history_length)
528 self.persistent_monitor = PersistentSystemMonitor(
529 update_interval=update_interval,
530 history_length=history_length
531 )
533 # Reconnect signals
534 self.persistent_monitor.connect_signals(
535 metrics_callback=self.on_metrics_updated,
536 error_callback=self.on_metrics_error
537 )
539 # Restart monitoring
540 self.start_monitoring()
542 # Update plot appearance if needed
543 if (old_config.performance_monitor.chart_colors != new_config.performance_monitor.chart_colors or
544 old_config.performance_monitor.line_width != new_config.performance_monitor.line_width):
545 self._update_plot_appearance()
547 logger.debug("Performance monitor configuration updated")
549 def _update_plot_appearance(self):
550 """Update plot appearance based on current configuration."""
551 colors = self.monitor_config.chart_colors
552 line_width = self.monitor_config.line_width
554 # Update curve pens
555 self.cpu_curve.setPen(pg.mkPen(colors['cpu'], width=line_width))
556 self.ram_curve.setPen(pg.mkPen(colors['ram'], width=line_width))
557 self.gpu_curve.setPen(pg.mkPen(colors['gpu'], width=line_width))
558 self.vram_curve.setPen(pg.mkPen(colors['vram'], width=line_width))
560 # Update plot grid for consolidated plots (don't change X range here - let update_pyqtgraph_plots handle it)
561 plots = [self.cpu_gpu_plot, self.ram_vram_plot]
562 for plot in plots:
563 plot.showGrid(x=self.monitor_config.show_grid, y=self.monitor_config.show_grid, alpha=0.3)
565 def closeEvent(self, event):
566 """Handle widget close event."""
567 self.stop_monitoring()
568 event.accept()