Coverage for openhcs/pyqt_gui/widgets/system_monitor.py: 0.0%
264 statements
« prev ^ index » next coverage.py v7.10.3, created at 2025-08-14 05:57 +0000
« prev ^ index » next coverage.py v7.10.3, created at 2025-08-14 05:57 +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 # Store plot data items for efficient updates using configured colors and line width
181 colors = self.monitor_config.chart_colors
182 line_width = self.monitor_config.line_width
184 # CPU/GPU plot curves
185 self.cpu_curve = self.cpu_gpu_plot.plot(pen=pg.mkPen(colors['cpu'], width=line_width), name='CPU')
186 self.gpu_curve = self.cpu_gpu_plot.plot(pen=pg.mkPen(colors['gpu'], width=line_width), name='GPU')
188 # RAM/VRAM plot curves
189 self.ram_curve = self.ram_vram_plot.plot(pen=pg.mkPen(colors['ram'], width=line_width), name='RAM')
190 self.vram_curve = self.ram_vram_plot.plot(pen=pg.mkPen(colors['vram'], width=line_width), name='VRAM')
192 # Style CPU/GPU plot
193 self.cpu_gpu_plot.setBackground(self.color_scheme.to_hex(self.color_scheme.panel_bg))
194 self.cpu_gpu_plot.setYRange(0, 100)
195 self.cpu_gpu_plot.setXRange(0, self.monitor_config.history_duration_seconds) # Show time range in seconds
196 self.cpu_gpu_plot.setLabel('left', 'Usage (%)')
197 self.cpu_gpu_plot.setLabel('bottom', 'Time (seconds)')
198 self.cpu_gpu_plot.showGrid(x=self.monitor_config.show_grid, y=self.monitor_config.show_grid, alpha=0.3)
199 self.cpu_gpu_plot.getAxis('left').setTextPen('white')
200 self.cpu_gpu_plot.getAxis('bottom').setTextPen('white')
201 self.cpu_gpu_plot.addLegend()
203 # Style RAM/VRAM plot
204 self.ram_vram_plot.setBackground(self.color_scheme.to_hex(self.color_scheme.panel_bg))
205 self.ram_vram_plot.setYRange(0, 100)
206 self.ram_vram_plot.setXRange(0, self.monitor_config.history_duration_seconds) # Show time range in seconds
207 self.ram_vram_plot.setLabel('left', 'Usage (%)')
208 self.ram_vram_plot.setLabel('bottom', 'Time (seconds)')
209 self.ram_vram_plot.showGrid(x=self.monitor_config.show_grid, y=self.monitor_config.show_grid, alpha=0.3)
210 self.ram_vram_plot.getAxis('left').setTextPen('white')
211 self.ram_vram_plot.getAxis('bottom').setTextPen('white')
212 self.ram_vram_plot.addLegend()
214 # Add plots to grid layout (1x2 instead of 2x2)
215 layout.addWidget(self.cpu_gpu_plot, 0, 0)
216 layout.addWidget(self.ram_vram_plot, 0, 1)
218 return widget
220 def create_fallback_section(self) -> QWidget:
221 """
222 Create fallback text-based monitoring section.
224 Returns:
225 Widget containing text-based display
226 """
227 widget = QFrame()
228 widget.setFrameStyle(QFrame.Shape.Box)
229 widget.setStyleSheet(f"""
230 QFrame {{
231 background-color: {self.color_scheme.to_hex(self.color_scheme.panel_bg)};
232 border: 1px solid {self.color_scheme.to_hex(self.color_scheme.border_color)};
233 border-radius: 3px;
234 padding: 10px;
235 }}
236 """)
238 layout = QVBoxLayout(widget)
240 self.fallback_label = QLabel("")
241 self.fallback_label.setFont(QFont("Courier", 10))
242 self.fallback_label.setStyleSheet(f"color: {self.color_scheme.to_hex(self.color_scheme.text_accent)};")
243 layout.addWidget(self.fallback_label)
245 return widget
247 def setup_connections(self):
248 """Setup signal/slot connections."""
249 self.metrics_updated.connect(self.update_display)
251 # Connect persistent monitor signals
252 self.persistent_monitor.connect_signals(
253 metrics_callback=self.on_metrics_updated,
254 error_callback=self.on_metrics_error
255 )
257 def start_monitoring(self):
258 """Start the persistent monitoring thread."""
259 self.persistent_monitor.start_monitoring()
260 logger.debug("System monitoring started")
262 def stop_monitoring(self):
263 """Stop the persistent monitoring thread."""
264 self.persistent_monitor.stop_monitoring()
265 logger.debug("System monitoring stopped")
267 def cleanup(self):
268 """Clean up widget resources."""
269 try:
270 logger.debug("Cleaning up SystemMonitorWidget...")
272 # Stop monitoring first
273 self.stop_monitoring()
275 # Clean up pyqtgraph plots
276 if PYQTGRAPH_AVAILABLE and hasattr(self, 'cpu_plot'):
277 try:
278 self.cpu_plot.clear()
279 self.ram_plot.clear()
280 self.gpu_plot.clear()
281 self.vram_plot.clear()
283 # Clear plot widgets
284 if hasattr(self, 'cpu_plot_widget'):
285 self.cpu_plot_widget.close()
286 if hasattr(self, 'ram_plot_widget'):
287 self.ram_plot_widget.close()
288 if hasattr(self, 'gpu_plot_widget'):
289 self.gpu_plot_widget.close()
290 if hasattr(self, 'vram_plot_widget'):
291 self.vram_plot_widget.close()
293 except Exception as e:
294 logger.warning(f"Error cleaning up pyqtgraph plots: {e}")
296 # Clear data
297 if hasattr(self, 'monitor'):
298 self.monitor.cpu_history.clear()
299 self.monitor.ram_history.clear()
300 self.monitor.gpu_history.clear()
301 self.monitor.vram_history.clear()
302 self.monitor.time_stamps.clear()
304 logger.debug("SystemMonitorWidget cleanup completed")
306 except Exception as e:
307 logger.warning(f"Error during SystemMonitorWidget cleanup: {e}")
309 def on_metrics_updated(self, metrics: dict):
310 """Handle metrics update from persistent monitor thread."""
311 try:
312 # Update the sync monitor's history for compatibility with existing plotting code
313 if metrics:
314 self.monitor.cpu_history.append(metrics.get('cpu_percent', 0))
315 self.monitor.ram_history.append(metrics.get('ram_percent', 0))
316 self.monitor.gpu_history.append(metrics.get('gpu_percent', 0))
317 self.monitor.vram_history.append(metrics.get('vram_percent', 0))
318 self.monitor.time_stamps.append(time.time())
320 # Update cached metrics
321 self.monitor._current_metrics = metrics.copy()
323 # Use QTimer.singleShot to ensure UI update happens on main thread
324 from PyQt6.QtCore import QTimer
325 QTimer.singleShot(0, lambda: self.metrics_updated.emit(metrics))
327 except Exception as e:
328 logger.warning(f"Failed to process metrics update: {e}")
330 def on_metrics_error(self, error_message: str):
331 """Handle metrics collection error."""
332 logger.warning(f"Metrics collection failed: {error_message}")
333 # Continue with cached/default metrics to keep UI responsive
335 def update_display(self, metrics: dict):
336 """
337 Update the display with new metrics.
339 Args:
340 metrics: Dictionary of system metrics
341 """
342 try:
343 # Update system info
344 self.update_system_info(metrics)
346 # Update plots or fallback display
347 if PYQTGRAPH_AVAILABLE:
348 self.update_pyqtgraph_plots()
349 else:
350 self.update_fallback_display(metrics)
352 except Exception as e:
353 logger.warning(f"Failed to update display: {e}")
355 def update_pyqtgraph_plots(self):
356 """Update consolidated PyQtGraph plots with current data - non-blocking and fast."""
357 try:
358 # Convert data point indices to time values in seconds
359 data_length = len(self.monitor.cpu_history)
360 if data_length == 0:
361 return
363 # Create time axis: each data point represents update_interval_seconds
364 update_interval = self.monitor_config.update_interval_seconds
365 x_time = [i * update_interval for i in range(data_length)]
367 # Get current data
368 cpu_data = list(self.monitor.cpu_history)
369 ram_data = list(self.monitor.ram_history)
370 gpu_data = list(self.monitor.gpu_history)
371 vram_data = list(self.monitor.vram_history)
373 # Update CPU/GPU consolidated plot
374 self.cpu_curve.setData(x_time, cpu_data)
376 # Handle GPU data (may not be available)
377 if any(gpu_data):
378 self.gpu_curve.setData(x_time, gpu_data)
379 gpu_status = f'{gpu_data[-1]:.1f}%' if gpu_data else 'N/A'
380 else:
381 self.gpu_curve.setData([], []) # Clear data
382 gpu_status = 'Not Available'
384 # Update CPU/GPU plot title with current values
385 cpu_status = f'{cpu_data[-1]:.1f}%' if cpu_data else 'N/A'
386 self.cpu_gpu_plot.setTitle(f'CPU/GPU Usage - CPU: {cpu_status}, GPU: {gpu_status}')
388 # Update RAM/VRAM consolidated plot
389 self.ram_curve.setData(x_time, ram_data)
391 # Handle VRAM data (may not be available)
392 if any(vram_data):
393 self.vram_curve.setData(x_time, vram_data)
394 vram_status = f'{vram_data[-1]:.1f}%' if vram_data else 'N/A'
395 else:
396 self.vram_curve.setData([], []) # Clear data
397 vram_status = 'Not Available'
399 # Update RAM/VRAM plot title with current values
400 ram_status = f'{ram_data[-1]:.1f}%' if ram_data else 'N/A'
401 self.ram_vram_plot.setTitle(f'RAM/VRAM Usage - RAM: {ram_status}, VRAM: {vram_status}')
403 except Exception as e:
404 logger.warning(f"Failed to update PyQtGraph plots: {e}")
406 def update_fallback_display(self, metrics: dict):
407 """
408 Update fallback text display.
410 Args:
411 metrics: Dictionary of system metrics
412 """
413 try:
414 display_text = f"""
415┌─────────────────────────────────────────────────────────────────┐
416│ CPU: {self.create_text_bar(metrics.get('cpu_percent', 0))} {metrics.get('cpu_percent', 0):5.1f}%
417│ 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)
418│ GPU: {self.create_text_bar(metrics.get('gpu_percent', 0))} {metrics.get('gpu_percent', 0):5.1f}%
419│ VRAM: {self.create_text_bar(metrics.get('vram_percent', 0))} {metrics.get('vram_percent', 0):5.1f}%
420└─────────────────────────────────────────────────────────────────┘
421"""
422 self.fallback_label.setText(display_text)
424 except Exception as e:
425 logger.warning(f"Failed to update fallback display: {e}")
427 def update_system_info(self, metrics: dict):
428 """
429 Update system information display.
431 Args:
432 metrics: Dictionary of system metrics
433 """
434 try:
435 info_text = f"""
436═══════════════════════════════════════════════════════════════════════
437System Information | Time: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
438═══════════════════════════════════════════════════════════════════════
439CPU Cores: {metrics.get('cpu_cores', 'N/A')} | CPU Frequency: {metrics.get('cpu_freq_mhz', 0):.0f} MHz
440Total RAM: {metrics.get('ram_total_gb', 0):.1f} GB | Used RAM: {metrics.get('ram_used_gb', 0):.1f} GB"""
442 # Add GPU info if available
443 if 'gpu_name' in metrics:
444 info_text += f"\nGPU: {metrics.get('gpu_name', 'N/A')} | Temperature: {metrics.get('gpu_temp', 'N/A')}°C"
445 info_text += f"\nVRAM: {metrics.get('vram_used_mb', 0):.0f}/{metrics.get('vram_total_mb', 0):.0f} MB"
447 info_text += "\n═══════════════════════════════════════════════════════════════════════"
449 self.info_label.setText(info_text)
451 except Exception as e:
452 logger.warning(f"Failed to update system info: {e}")
454 def create_text_bar(self, percent: float) -> str:
455 """
456 Create a text-based progress bar.
458 Args:
459 percent: Percentage value (0-100)
461 Returns:
462 Text progress bar
463 """
464 bar_length = 20
465 filled = int(bar_length * percent / 100)
466 bar = '█' * filled + '░' * (bar_length - filled)
467 return f"[{bar}]"
469 def get_ascii_header(self) -> str:
470 """
471 Get ASCII art header.
473 Returns:
474 ASCII art header string
475 """
476 return """
477 ██████╗ ██████╗ ███████╗███╗ ██╗██╗ ██╗ ██████╗███████╗
478██╔═══██╗██╔══██╗██╔════╝████╗ ██║██║ ██║██╔════╝██╔════╝
479██║ ██║██████╔╝█████╗ ██╔██╗ ██║███████║██║ ███████╗
480██║ ██║██╔═══╝ ██╔══╝ ██║╚██╗██║██╔══██║██║ ╚════██║
481╚██████╔╝██║ ███████╗██║ ╚████║██║ ██║╚██████╗███████║
482 ╚═════╝ ╚═╝ ╚══════╝╚═╝ ╚═══╝╚═╝ ╚═╝ ╚═════╝╚══════╝
483 """
485 def set_update_interval(self, interval_ms: int):
486 """
487 Set the update interval for monitoring.
489 Args:
490 interval_ms: Update interval in milliseconds
491 """
492 interval_seconds = interval_ms / 1000.0
493 self.persistent_monitor.set_update_interval(interval_seconds)
495 def update_config(self, new_config: PyQtGUIConfig):
496 """
497 Update the widget configuration and apply changes.
499 Args:
500 new_config: New configuration to apply
501 """
502 old_config = self.config
503 self.config = new_config
504 self.monitor_config = new_config.performance_monitor
506 # Check if we need to restart monitoring with new parameters
507 if (old_config.performance_monitor.update_fps != new_config.performance_monitor.update_fps or
508 old_config.performance_monitor.history_duration_seconds != new_config.performance_monitor.history_duration_seconds):
510 logger.info(f"Updating performance monitor: {new_config.performance_monitor.update_fps} FPS, "
511 f"{new_config.performance_monitor.history_duration_seconds}s history")
513 # Stop current monitoring
514 self.stop_monitoring()
516 # Recalculate parameters
517 update_interval = self.monitor_config.update_interval_seconds
518 history_length = self.monitor_config.calculated_max_data_points
520 # Create new monitors with updated config
521 self.monitor = SystemMonitor(history_length=history_length)
522 self.persistent_monitor = PersistentSystemMonitor(
523 update_interval=update_interval,
524 history_length=history_length
525 )
527 # Reconnect signals
528 self.persistent_monitor.connect_signals(
529 metrics_callback=self.on_metrics_updated,
530 error_callback=self.on_metrics_error
531 )
533 # Restart monitoring
534 self.start_monitoring()
536 # Update plot appearance if needed
537 if (old_config.performance_monitor.chart_colors != new_config.performance_monitor.chart_colors or
538 old_config.performance_monitor.line_width != new_config.performance_monitor.line_width):
539 self._update_plot_appearance()
541 logger.debug("Performance monitor configuration updated")
543 def _update_plot_appearance(self):
544 """Update plot appearance based on current configuration."""
545 colors = self.monitor_config.chart_colors
546 line_width = self.monitor_config.line_width
548 # Update curve pens
549 self.cpu_curve.setPen(pg.mkPen(colors['cpu'], width=line_width))
550 self.ram_curve.setPen(pg.mkPen(colors['ram'], width=line_width))
551 self.gpu_curve.setPen(pg.mkPen(colors['gpu'], width=line_width))
552 self.vram_curve.setPen(pg.mkPen(colors['vram'], width=line_width))
554 # Update plot grid for consolidated plots (don't change X range here - let update_pyqtgraph_plots handle it)
555 plots = [self.cpu_gpu_plot, self.ram_vram_plot]
556 for plot in plots:
557 plot.showGrid(x=self.monitor_config.show_grid, y=self.monitor_config.show_grid, alpha=0.3)
559 def closeEvent(self, event):
560 """Handle widget close event."""
561 self.stop_monitoring()
562 event.accept()