Coverage for openhcs/pyqt_gui/widgets/system_monitor.py: 0.0%
438 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-04 02:09 +0000
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-04 02:09 +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
13from PyQt6.QtWidgets import (
14 QWidget, QVBoxLayout, QHBoxLayout, QLabel, QFrame, QGridLayout, QSizePolicy, QPushButton
15)
16from PyQt6.QtCore import QTimer, pyqtSignal, QMetaObject, Qt
17from PyQt6.QtGui import QFont, QResizeEvent
19# Lazy import of PyQtGraph to avoid blocking startup
20# PyQtGraph imports cupy at module level, which takes 8+ seconds
21# We'll import it on-demand when creating graphs
22PYQTGRAPH_AVAILABLE = None # None = not checked, True = available, False = not available
23pg = None # Will be set when pyqtgraph is imported
25# Import the SystemMonitorCore service (framework-agnostic)
26from openhcs.ui.shared.system_monitor_core import SystemMonitorCore
27from openhcs.pyqt_gui.services.persistent_system_monitor import PersistentSystemMonitor
28from openhcs.pyqt_gui.shared.style_generator import StyleSheetGenerator
29from openhcs.pyqt_gui.shared.color_scheme import PyQt6ColorScheme
30from openhcs.pyqt_gui.config import PyQtGUIConfig, get_default_pyqt_gui_config
32logger = logging.getLogger(__name__)
35class SystemMonitorWidget(QWidget):
36 """
37 PyQt6 System Monitor Widget.
39 Displays real-time system metrics with graphs for CPU, RAM, GPU, and VRAM usage.
40 Provides the same functionality as the Textual SystemMonitorTextual widget.
41 """
43 # Signals
44 metrics_updated = pyqtSignal(dict) # Emitted when metrics are updated
45 _pyqtgraph_loaded = pyqtSignal() # Internal signal for async pyqtgraph loading
46 _pyqtgraph_failed = pyqtSignal() # Internal signal for async pyqtgraph loading failure
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 = SystemMonitorCore(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 # Track graph layout mode (True = side-by-side, False = stacked)
84 # MUST be set before setup_ui() since create_pyqtgraph_section() uses it
85 self._graphs_side_by_side = True
87 # Delay monitoring start until widget is shown (fixes WSL2 hanging)
88 self._monitoring_started = False
90 # Setup UI
91 self.setup_ui()
92 self.setup_connections()
94 logger.debug("System monitor widget initialized")
96 def create_loading_placeholder(self) -> QWidget:
97 """
98 Create a simple loading placeholder shown while PyQtGraph loads.
100 Returns:
101 Simple loading label widget
102 """
103 from PyQt6.QtWidgets import QLabel, QVBoxLayout, QWidget
104 from PyQt6.QtCore import Qt
106 placeholder = QWidget()
107 layout = QVBoxLayout(placeholder)
109 label = QLabel("Loading system monitor...")
110 label.setAlignment(Qt.AlignmentFlag.AlignCenter)
111 layout.addWidget(label)
113 return placeholder
115 def _load_pyqtgraph_async(self):
116 """
117 Load PyQtGraph asynchronously using QTimer to avoid blocking.
119 We use QTimer instead of threading because Python's GIL causes background
120 thread imports to block the main thread anyway. By using QTimer with a delay,
121 we give the user time to interact with the UI before the import happens.
122 """
123 # Load immediately - no artificial delay
124 QTimer.singleShot(0, self._import_pyqtgraph_main_thread)
125 logger.info("PyQtGraph loading...")
127 def _import_pyqtgraph_main_thread(self):
128 """Import PyQtGraph in main thread after delay."""
129 global PYQTGRAPH_AVAILABLE, pg
131 try:
132 logger.info("⏳ Loading PyQtGraph (UI will freeze for ~8 seconds)...")
133 logger.info("📦 Importing pyqtgraph module...")
134 import pyqtgraph as pg_module
135 logger.info("📦 PyQtGraph module imported")
137 logger.info("🔧 Initializing PyQtGraph (loading GPU libraries: cupy, numpy, etc.)...")
138 pg = pg_module
139 PYQTGRAPH_AVAILABLE = True
140 logger.info("✅ PyQtGraph loaded successfully (GPU libraries ready)")
142 # Flush logs so startup screen can read them
143 import logging as _logging
144 for _h in _logging.getLogger().handlers:
145 try:
146 _h.flush()
147 except Exception:
148 pass
150 # Schedule UI switch on next event loop tick so startup screen can update
151 from PyQt6.QtCore import QTimer as _QTimer
152 _QTimer.singleShot(0, self._switch_to_pyqtgraph_ui)
153 except ImportError as e:
154 logger.warning(f"❌ PyQtGraph not available: {e}")
155 PYQTGRAPH_AVAILABLE = False
157 # Schedule fallback switch similarly
158 from PyQt6.QtCore import QTimer as _QTimer
159 _QTimer.singleShot(0, self._switch_to_fallback_ui)
161 def _switch_to_pyqtgraph_ui(self):
162 """Switch from loading placeholder to PyQtGraph UI (called in main thread)."""
163 # Remove loading placeholder
164 old_widget = self.monitoring_widget
165 layout = self.layout()
166 layout.removeWidget(old_widget)
167 old_widget.deleteLater()
169 # Create PyQtGraph section
170 self.monitoring_widget = self.create_pyqtgraph_section()
171 layout.addWidget(self.monitoring_widget, 1)
173 logger.info("Switched to PyQtGraph UI")
175 def _switch_to_fallback_ui(self):
176 """Switch from loading placeholder to fallback UI (called in main thread)."""
177 # Remove loading placeholder
178 old_widget = self.monitoring_widget
179 layout = self.layout()
180 layout.removeWidget(old_widget)
181 old_widget.deleteLater()
183 # Create fallback section
184 self.monitoring_widget = self.create_fallback_section()
185 layout.addWidget(self.monitoring_widget, 1)
187 logger.info("Switched to fallback UI (PyQtGraph not available)")
189 def showEvent(self, event):
190 """Handle widget show event - start monitoring when widget becomes visible."""
191 super().showEvent(event)
192 if not self._monitoring_started:
193 # Start monitoring only when widget is actually shown
194 # This prevents WSL2 hanging issues during initialization
195 self.start_monitoring()
196 self._monitoring_started = True
197 logger.debug("System monitoring started on widget show")
199 def resizeEvent(self, event: QResizeEvent):
200 """Handle widget resize - adjust font sizes dynamically."""
201 super().resizeEvent(event)
202 # Defer font size update until after layout is complete
203 if hasattr(self, 'info_widget'):
204 # Use a timer to update after the layout has settled
205 from PyQt6.QtCore import QTimer
206 QTimer.singleShot(0, self._update_font_sizes_from_panel)
208 def closeEvent(self, event):
209 """Handle widget close event - cleanup resources."""
210 self.cleanup()
211 super().closeEvent(event)
213 def __del__(self):
214 """Destructor - ensure cleanup happens."""
215 try:
216 self.cleanup()
217 except:
218 pass # Ignore errors during destruction
220 def _update_font_sizes_from_panel(self):
221 """Update font sizes based on the actual info panel width."""
222 if not hasattr(self, 'info_widget'):
223 return
225 # Use the actual info panel width, not the whole widget width
226 panel_width = self.info_widget.width()
228 # Conservative font sizes to prevent clipping
229 # Title font: 9-12pt based on panel width
230 title_size = max(9, min(12, panel_width // 50))
232 # Label font: 7-10pt based on panel width
233 # Conservative sizing to ensure no clipping
234 label_size = max(7, min(10, panel_width // 60))
236 # Update title font
237 if hasattr(self, 'info_title'):
238 title_font = QFont("Arial", title_size)
239 title_font.setBold(True)
240 self.info_title.setFont(title_font)
242 # Update all label fonts
243 if hasattr(self, 'cpu_cores_label'):
244 for label_pair in [
245 self.cpu_cores_label, self.cpu_freq_label,
246 self.ram_total_label, self.ram_used_label,
247 self.gpu_name_label, self.gpu_temp_label, self.vram_label
248 ]:
249 # Update key label
250 key_font = QFont("Arial", label_size)
251 label_pair[0].setFont(key_font)
253 # Update value label (bold)
254 value_font = QFont("Arial", label_size)
255 value_font.setBold(True)
256 label_pair[1].setFont(value_font)
258 def setup_ui(self):
259 """Setup the user interface."""
260 layout = QVBoxLayout(self)
261 layout.setContentsMargins(10, 10, 10, 10)
262 layout.setSpacing(10)
264 # Header section
265 header_layout = self.create_header_section()
266 layout.addLayout(header_layout)
268 # Monitoring section - start with loading placeholder
269 # PyQtGraph will be loaded asynchronously to avoid blocking startup
270 self.monitoring_widget = self.create_loading_placeholder()
271 layout.addWidget(self.monitoring_widget, 1) # Stretch factor = 1 to expand
273 # Apply centralized styling
274 self.setStyleSheet(self.style_generator.generate_system_monitor_style())
276 # Load PyQtGraph asynchronously
277 self._load_pyqtgraph_async()
279 def create_header_section(self) -> QHBoxLayout:
280 """
281 Create the header section with title and system info.
283 Returns:
284 Header layout
285 """
286 header_layout = QHBoxLayout()
288 # ASCII header (left side) - only takes space it needs
289 self.header_label = QLabel(self.get_ascii_header())
290 self.header_label.setObjectName("header_label")
291 font = QFont("Courier", 10)
292 font.setBold(True)
293 self.header_label.setFont(font)
294 self.header_label.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Preferred)
295 header_layout.addWidget(self.header_label)
297 # System info panel (right side) - styled widget instead of plain text
298 self.info_widget = self.create_info_panel()
299 header_layout.addWidget(self.info_widget, 1) # Stretch factor = 1 to fill space
301 return header_layout
303 def create_info_panel(self) -> QWidget:
304 """Create a styled system information panel with two-column layout."""
305 panel = QFrame()
306 panel.setObjectName("info_panel")
307 panel.setFrameStyle(QFrame.Shape.StyledPanel | QFrame.Shadow.Raised)
309 layout = QVBoxLayout(panel)
310 layout.setContentsMargins(20, 15, 20, 15)
311 layout.setSpacing(12)
313 # Title with timestamp (font size set dynamically in resizeEvent)
314 self.info_title = QLabel("System Information")
315 self.info_title.setObjectName("info_title")
316 layout.addWidget(self.info_title)
318 # Separator line
319 separator = QFrame()
320 separator.setFrameShape(QFrame.Shape.HLine)
321 separator.setFrameShadow(QFrame.Shadow.Sunken)
322 layout.addWidget(separator)
324 # Two-column grid layout with compact labels
325 # Grid has 5 columns: [Label1, Value1, Spacer, Label2, Value2]
326 info_grid = QGridLayout()
327 info_grid.setHorizontalSpacing(12)
328 info_grid.setVerticalSpacing(8)
329 info_grid.setColumnStretch(1, 2) # Left value column stretches more
330 info_grid.setColumnMinimumWidth(2, 25) # Spacer between columns
331 info_grid.setColumnStretch(4, 2) # Right value column stretches more
333 # Left column - CPU and RAM info (shorter labels)
334 self.cpu_cores_label = self.create_info_row("Cores:", "—")
335 self.cpu_freq_label = self.create_info_row("Freq:", "—")
336 self.ram_total_label = self.create_info_row("RAM:", "—")
337 self.ram_used_label = self.create_info_row("Used:", "—")
339 info_grid.addWidget(self.cpu_cores_label[0], 0, 0)
340 info_grid.addWidget(self.cpu_cores_label[1], 0, 1)
341 info_grid.addWidget(self.cpu_freq_label[0], 1, 0)
342 info_grid.addWidget(self.cpu_freq_label[1], 1, 1)
343 info_grid.addWidget(self.ram_total_label[0], 2, 0)
344 info_grid.addWidget(self.ram_total_label[1], 2, 1)
345 info_grid.addWidget(self.ram_used_label[0], 3, 0)
346 info_grid.addWidget(self.ram_used_label[1], 3, 1)
348 # Right column - GPU info (will be hidden if no GPU)
349 self.gpu_name_label = self.create_info_row("GPU:", "—")
350 self.gpu_temp_label = self.create_info_row("Temp:", "—")
351 self.vram_label = self.create_info_row("VRAM:", "—")
353 info_grid.addWidget(self.gpu_name_label[0], 0, 3)
354 info_grid.addWidget(self.gpu_name_label[1], 0, 4)
355 info_grid.addWidget(self.gpu_temp_label[0], 1, 3)
356 info_grid.addWidget(self.gpu_temp_label[1], 1, 4)
357 info_grid.addWidget(self.vram_label[0], 2, 3)
358 info_grid.addWidget(self.vram_label[1], 2, 4)
360 layout.addLayout(info_grid)
361 layout.addStretch()
363 # Schedule initial font size update after panel is shown
364 QTimer.singleShot(100, self._update_font_sizes_from_panel)
366 return panel
368 def create_info_row(self, label_text: str, value_text: str) -> tuple:
369 """Create a label-value pair for the info panel (font size set dynamically in resizeEvent)."""
370 label = QLabel(label_text)
371 label.setObjectName("info_label_key")
373 value = QLabel(value_text)
374 value.setObjectName("info_label_value")
376 return (label, value)
378 def create_pyqtgraph_section(self) -> QWidget:
379 """
380 Create PyQtGraph-based monitoring section with consolidated graphs.
382 Returns:
383 Widget containing consolidated PyQtGraph plots
384 """
385 widget = QWidget()
386 main_layout = QVBoxLayout(widget)
387 main_layout.setContentsMargins(0, 0, 0, 0)
388 main_layout.setSpacing(5)
390 # Container for graphs that we can re-layout
391 self.graph_container = QWidget()
392 self.graph_layout = QGridLayout(self.graph_container)
393 self.graph_layout.setSpacing(10)
395 # Configure PyQtGraph based on config settings
396 pg.setConfigOption('background', self.color_scheme.to_hex(self.color_scheme.window_bg))
397 pg.setConfigOption('foreground', 'white')
398 pg.setConfigOption('antialias', self.monitor_config.antialiasing)
400 # Create consolidated PyQtGraph plots
401 self.cpu_gpu_plot = pg.PlotWidget(title="CPU/GPU Usage")
402 self.ram_vram_plot = pg.PlotWidget(title="RAM/VRAM Usage")
404 # Disable mouse interaction on plots
405 self.cpu_gpu_plot.setMouseEnabled(x=False, y=False)
406 self.ram_vram_plot.setMouseEnabled(x=False, y=False)
407 self.cpu_gpu_plot.setMenuEnabled(False)
408 self.ram_vram_plot.setMenuEnabled(False)
410 # Store plot data items for efficient updates using configured colors and line width
411 colors = self.monitor_config.chart_colors
412 line_width = self.monitor_config.line_width
414 # CPU/GPU plot curves
415 self.cpu_curve = self.cpu_gpu_plot.plot(pen=pg.mkPen(colors['cpu'], width=line_width), name='CPU')
416 self.gpu_curve = self.cpu_gpu_plot.plot(pen=pg.mkPen(colors['gpu'], width=line_width), name='GPU')
418 # RAM/VRAM plot curves
419 self.ram_curve = self.ram_vram_plot.plot(pen=pg.mkPen(colors['ram'], width=line_width), name='RAM')
420 self.vram_curve = self.ram_vram_plot.plot(pen=pg.mkPen(colors['vram'], width=line_width), name='VRAM')
422 # Style CPU/GPU plot
423 self.cpu_gpu_plot.setBackground(self.color_scheme.to_hex(self.color_scheme.panel_bg))
424 self.cpu_gpu_plot.setYRange(0, 100)
425 self.cpu_gpu_plot.setXRange(0, self.monitor_config.history_duration_seconds)
426 self.cpu_gpu_plot.setLabel('left', 'Usage (%)')
427 self.cpu_gpu_plot.setLabel('bottom', 'Time (seconds)')
428 self.cpu_gpu_plot.showGrid(x=self.monitor_config.show_grid, y=self.monitor_config.show_grid, alpha=0.3)
429 self.cpu_gpu_plot.getAxis('left').setTextPen('white')
430 self.cpu_gpu_plot.getAxis('bottom').setTextPen('white')
431 self.cpu_gpu_plot.addLegend()
433 # Style RAM/VRAM plot
434 self.ram_vram_plot.setBackground(self.color_scheme.to_hex(self.color_scheme.panel_bg))
435 self.ram_vram_plot.setYRange(0, 100)
436 self.ram_vram_plot.setXRange(0, self.monitor_config.history_duration_seconds)
437 self.ram_vram_plot.setLabel('left', 'Usage (%)')
438 self.ram_vram_plot.setLabel('bottom', 'Time (seconds)')
439 self.ram_vram_plot.showGrid(x=self.monitor_config.show_grid, y=self.monitor_config.show_grid, alpha=0.3)
440 self.ram_vram_plot.getAxis('left').setTextPen('white')
441 self.ram_vram_plot.getAxis('bottom').setTextPen('white')
442 self.ram_vram_plot.addLegend()
444 # Add plots to grid layout (side-by-side by default)
445 self._update_graph_layout()
447 main_layout.addWidget(self.graph_container, 1) # Stretch factor = 1
449 return widget
451 def create_layout_toggle_button(self) -> QPushButton:
452 """
453 Create a toggle button for switching graph layouts.
454 This button is meant to be added to the main window's status bar.
456 Returns:
457 QPushButton configured for layout toggling
458 """
459 self.layout_toggle_button = QPushButton("⬍ Stack")
460 self.layout_toggle_button.setMaximumWidth(80)
461 self.layout_toggle_button.setMaximumHeight(24)
462 self.layout_toggle_button.setToolTip("Toggle between side-by-side and stacked layout")
463 self.layout_toggle_button.clicked.connect(self.toggle_graph_layout)
465 # Style the button to match parameter form manager style
466 button_styles = self.style_generator.generate_config_button_styles()
467 self.layout_toggle_button.setStyleSheet(button_styles["reset"])
469 return self.layout_toggle_button
471 def toggle_graph_layout(self):
472 """Toggle between side-by-side and stacked graph layouts."""
473 self._graphs_side_by_side = not self._graphs_side_by_side
474 self._update_graph_layout()
476 # Update button text
477 if hasattr(self, 'layout_toggle_button'):
478 if self._graphs_side_by_side:
479 self.layout_toggle_button.setText("⬍ Stack")
480 else:
481 self.layout_toggle_button.setText("⬌ Side")
483 def _update_graph_layout(self):
484 """Update the graph layout based on current mode."""
485 # Remove all widgets from layout
486 while self.graph_layout.count():
487 item = self.graph_layout.takeAt(0)
488 if item.widget():
489 item.widget().setParent(None)
491 if self._graphs_side_by_side:
492 # Side-by-side: 1 row, 2 columns
493 self.graph_layout.addWidget(self.cpu_gpu_plot, 0, 0)
494 self.graph_layout.addWidget(self.ram_vram_plot, 0, 1)
495 else:
496 # Stacked: 2 rows, 1 column
497 self.graph_layout.addWidget(self.cpu_gpu_plot, 0, 0)
498 self.graph_layout.addWidget(self.ram_vram_plot, 1, 0)
500 def create_fallback_section(self) -> QWidget:
501 """
502 Create fallback text-based monitoring section.
504 Returns:
505 Widget containing text-based display
506 """
507 widget = QFrame()
508 widget.setFrameStyle(QFrame.Shape.Box)
509 widget.setStyleSheet(f"""
510 QFrame {{
511 background-color: {self.color_scheme.to_hex(self.color_scheme.panel_bg)};
512 border: 1px solid {self.color_scheme.to_hex(self.color_scheme.border_color)};
513 border-radius: 3px;
514 padding: 10px;
515 }}
516 """)
518 layout = QVBoxLayout(widget)
520 self.fallback_label = QLabel("")
521 self.fallback_label.setFont(QFont("Courier", 10))
522 self.fallback_label.setStyleSheet(f"color: {self.color_scheme.to_hex(self.color_scheme.text_accent)};")
523 layout.addWidget(self.fallback_label)
525 return widget
527 def setup_connections(self):
528 """Setup signal/slot connections."""
529 self.metrics_updated.connect(self.update_display)
531 # Connect persistent monitor signals
532 self.persistent_monitor.connect_signals(
533 metrics_callback=self.on_metrics_updated,
534 error_callback=self.on_metrics_error
535 )
537 def start_monitoring(self):
538 """Start the persistent monitoring thread."""
539 self.persistent_monitor.start_monitoring()
540 logger.debug("System monitoring started")
542 def stop_monitoring(self):
543 """Stop the persistent monitoring thread."""
544 self.persistent_monitor.stop_monitoring()
545 logger.debug("System monitoring stopped")
547 def cleanup(self):
548 """Clean up widget resources."""
549 try:
550 logger.debug("Cleaning up SystemMonitorWidget...")
552 # Stop monitoring first
553 self.stop_monitoring()
555 # Clean up pyqtgraph plots
556 if PYQTGRAPH_AVAILABLE and hasattr(self, 'cpu_plot'):
557 try:
558 self.cpu_plot.clear()
559 self.ram_plot.clear()
560 self.gpu_plot.clear()
561 self.vram_plot.clear()
563 # Clear plot widgets
564 if hasattr(self, 'cpu_plot_widget'):
565 self.cpu_plot_widget.close()
566 if hasattr(self, 'ram_plot_widget'):
567 self.ram_plot_widget.close()
568 if hasattr(self, 'gpu_plot_widget'):
569 self.gpu_plot_widget.close()
570 if hasattr(self, 'vram_plot_widget'):
571 self.vram_plot_widget.close()
573 except Exception as e:
574 logger.warning(f"Error cleaning up pyqtgraph plots: {e}")
576 # Clear data
577 if hasattr(self, 'monitor'):
578 self.monitor.cpu_history.clear()
579 self.monitor.ram_history.clear()
580 self.monitor.gpu_history.clear()
581 self.monitor.vram_history.clear()
582 self.monitor.time_stamps.clear()
584 logger.debug("SystemMonitorWidget cleanup completed")
586 except Exception as e:
587 logger.warning(f"Error during SystemMonitorWidget cleanup: {e}")
589 def on_metrics_updated(self, metrics: dict):
590 """Handle metrics update from persistent monitor thread."""
591 try:
592 # Update the sync monitor's history for compatibility with existing plotting code
593 if metrics:
594 self.monitor.cpu_history.append(metrics.get('cpu_percent', 0))
595 self.monitor.ram_history.append(metrics.get('ram_percent', 0))
596 self.monitor.gpu_history.append(metrics.get('gpu_percent', 0))
597 self.monitor.vram_history.append(metrics.get('vram_percent', 0))
598 self.monitor.time_stamps.append(time.time())
600 # Update cached metrics
601 self.monitor._current_metrics = metrics.copy()
603 # Use QTimer.singleShot to ensure UI update happens on main thread
604 from PyQt6.QtCore import QTimer
605 QTimer.singleShot(0, lambda: self.metrics_updated.emit(metrics))
607 except Exception as e:
608 logger.warning(f"Failed to process metrics update: {e}")
610 def on_metrics_error(self, error_message: str):
611 """Handle metrics collection error."""
612 logger.warning(f"Metrics collection failed: {error_message}")
613 # Continue with cached/default metrics to keep UI responsive
615 def update_display(self, metrics: dict):
616 """
617 Update the display with new metrics.
619 Args:
620 metrics: Dictionary of system metrics
621 """
622 try:
623 # Update system info
624 self.update_system_info(metrics)
626 # Update plots or fallback display
627 if PYQTGRAPH_AVAILABLE is True:
628 # PyQtGraph loaded successfully - update graphs
629 self.update_pyqtgraph_plots()
630 elif PYQTGRAPH_AVAILABLE is False:
631 # PyQtGraph failed to load - update fallback display
632 self.update_fallback_display(metrics)
633 # else: PYQTGRAPH_AVAILABLE is None - still loading, skip update
635 except Exception as e:
636 logger.warning(f"Failed to update display: {e}")
638 def update_pyqtgraph_plots(self):
639 """Update consolidated PyQtGraph plots with current data - non-blocking and fast."""
640 try:
641 # Convert data point indices to time values in seconds
642 data_length = len(self.monitor.cpu_history)
643 if data_length == 0:
644 return
646 # Create time axis: each data point represents update_interval_seconds
647 update_interval = self.monitor_config.update_interval_seconds
648 x_time = [i * update_interval for i in range(data_length)]
650 # Get current data
651 cpu_data = list(self.monitor.cpu_history)
652 ram_data = list(self.monitor.ram_history)
653 gpu_data = list(self.monitor.gpu_history)
654 vram_data = list(self.monitor.vram_history)
656 # Update CPU/GPU consolidated plot
657 self.cpu_curve.setData(x_time, cpu_data)
659 # Handle GPU data (may not be available)
660 if any(gpu_data):
661 self.gpu_curve.setData(x_time, gpu_data)
662 gpu_status = f'{gpu_data[-1]:.1f}%' if gpu_data else 'N/A'
663 else:
664 self.gpu_curve.setData([], []) # Clear data
665 gpu_status = 'Not Available'
667 # Update CPU/GPU plot title with current values
668 cpu_status = f'{cpu_data[-1]:.1f}%' if cpu_data else 'N/A'
669 self.cpu_gpu_plot.setTitle(f'CPU/GPU Usage - CPU: {cpu_status}, GPU: {gpu_status}')
671 # Update RAM/VRAM consolidated plot
672 self.ram_curve.setData(x_time, ram_data)
674 # Handle VRAM data (may not be available)
675 if any(vram_data):
676 self.vram_curve.setData(x_time, vram_data)
677 vram_status = f'{vram_data[-1]:.1f}%' if vram_data else 'N/A'
678 else:
679 self.vram_curve.setData([], []) # Clear data
680 vram_status = 'Not Available'
682 # Update RAM/VRAM plot title with current values
683 ram_status = f'{ram_data[-1]:.1f}%' if ram_data else 'N/A'
684 self.ram_vram_plot.setTitle(f'RAM/VRAM Usage - RAM: {ram_status}, VRAM: {vram_status}')
686 except Exception as e:
687 logger.warning(f"Failed to update PyQtGraph plots: {e}")
689 def update_fallback_display(self, metrics: dict):
690 """
691 Update fallback text display.
693 Args:
694 metrics: Dictionary of system metrics
695 """
696 try:
697 display_text = f"""
698┌─────────────────────────────────────────────────────────────────┐
699│ CPU: {self.create_text_bar(metrics.get('cpu_percent', 0))} {metrics.get('cpu_percent', 0):5.1f}%
700│ 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)
701│ GPU: {self.create_text_bar(metrics.get('gpu_percent', 0))} {metrics.get('gpu_percent', 0):5.1f}%
702│ VRAM: {self.create_text_bar(metrics.get('vram_percent', 0))} {metrics.get('vram_percent', 0):5.1f}%
703└─────────────────────────────────────────────────────────────────┘
704"""
705 self.fallback_label.setText(display_text)
707 except Exception as e:
708 logger.warning(f"Failed to update fallback display: {e}")
710 def update_system_info(self, metrics: dict):
711 """
712 Update system information display.
714 Args:
715 metrics: Dictionary of system metrics
716 """
717 try:
718 # Update title with timestamp
719 self.info_title.setText(f"System Information — {datetime.now().strftime('%H:%M:%S')}")
721 # Update CPU info
722 self.cpu_cores_label[1].setText(str(metrics.get('cpu_cores', 'N/A')))
723 self.cpu_freq_label[1].setText(f"{metrics.get('cpu_freq_mhz', 0):.0f} MHz")
725 # Update RAM info
726 self.ram_total_label[1].setText(f"{metrics.get('ram_total_gb', 0):.1f} GB")
727 self.ram_used_label[1].setText(f"{metrics.get('ram_used_gb', 0):.1f} GB")
729 # Update GPU info if available
730 if 'gpu_name' in metrics:
731 gpu_name = metrics.get('gpu_name', 'N/A')
732 if len(gpu_name) > 35:
733 gpu_name = gpu_name[:32] + '...'
735 self.gpu_name_label[1].setText(gpu_name)
736 self.gpu_temp_label[1].setText(f"{metrics.get('gpu_temp', 'N/A')}°C")
737 self.vram_label[1].setText(
738 f"{metrics.get('vram_used_mb', 0):.0f} / {metrics.get('vram_total_mb', 0):.0f} MB"
739 )
741 # Show GPU labels
742 self.gpu_name_label[0].show()
743 self.gpu_name_label[1].show()
744 self.gpu_temp_label[0].show()
745 self.gpu_temp_label[1].show()
746 self.vram_label[0].show()
747 self.vram_label[1].show()
748 else:
749 # Hide GPU labels if no GPU
750 self.gpu_name_label[0].hide()
751 self.gpu_name_label[1].hide()
752 self.gpu_temp_label[0].hide()
753 self.gpu_temp_label[1].hide()
754 self.vram_label[0].hide()
755 self.vram_label[1].hide()
757 except Exception as e:
758 logger.warning(f"Failed to update system info: {e}")
760 def create_text_bar(self, percent: float) -> str:
761 """
762 Create a text-based progress bar.
764 Args:
765 percent: Percentage value (0-100)
767 Returns:
768 Text progress bar
769 """
770 bar_length = 20
771 filled = int(bar_length * percent / 100)
772 bar = '█' * filled + '░' * (bar_length - filled)
773 return f"[{bar}]"
775 def get_ascii_header(self) -> str:
776 """
777 Get ASCII art header.
779 Returns:
780 ASCII art header string
781 """
782 return """
783 ██████╗ ██████╗ ███████╗███╗ ██╗██╗ ██╗ ██████╗███████╗
784██╔═══██╗██╔══██╗██╔════╝████╗ ██║██║ ██║██╔════╝██╔════╝
785██║ ██║██████╔╝█████╗ ██╔██╗ ██║███████║██║ ███████╗
786██║ ██║██╔═══╝ ██╔══╝ ██║╚██╗██║██╔══██║██║ ╚════██║
787╚██████╔╝██║ ███████╗██║ ╚████║██║ ██║╚██████╗███████║
788 ╚═════╝ ╚═╝ ╚══════╝╚═╝ ╚═══╝╚═╝ ╚═╝ ╚═════╝╚══════╝
789 """
791 def set_update_interval(self, interval_ms: int):
792 """
793 Set the update interval for monitoring.
795 Args:
796 interval_ms: Update interval in milliseconds
797 """
798 interval_seconds = interval_ms / 1000.0
799 self.persistent_monitor.set_update_interval(interval_seconds)
801 def update_config(self, new_config: PyQtGUIConfig):
802 """
803 Update the widget configuration and apply changes.
805 Args:
806 new_config: New configuration to apply
807 """
808 old_config = self.config
809 self.config = new_config
810 self.monitor_config = new_config.performance_monitor
812 # Check if we need to restart monitoring with new parameters
813 if (old_config.performance_monitor.update_fps != new_config.performance_monitor.update_fps or
814 old_config.performance_monitor.history_duration_seconds != new_config.performance_monitor.history_duration_seconds):
816 logger.info(f"Updating performance monitor: {new_config.performance_monitor.update_fps} FPS, "
817 f"{new_config.performance_monitor.history_duration_seconds}s history")
819 # Stop current monitoring
820 self.stop_monitoring()
822 # Recalculate parameters
823 update_interval = self.monitor_config.update_interval_seconds
824 history_length = self.monitor_config.calculated_max_data_points
826 # Create new monitors with updated config
827 self.monitor = SystemMonitor(history_length=history_length)
828 self.persistent_monitor = PersistentSystemMonitor(
829 update_interval=update_interval,
830 history_length=history_length
831 )
833 # Reconnect signals
834 self.persistent_monitor.connect_signals(
835 metrics_callback=self.on_metrics_updated,
836 error_callback=self.on_metrics_error
837 )
839 # Restart monitoring
840 self.start_monitoring()
842 # Update plot appearance if needed
843 if (old_config.performance_monitor.chart_colors != new_config.performance_monitor.chart_colors or
844 old_config.performance_monitor.line_width != new_config.performance_monitor.line_width):
845 self._update_plot_appearance()
847 logger.debug("Performance monitor configuration updated")
849 def _update_plot_appearance(self):
850 """Update plot appearance based on current configuration."""
851 colors = self.monitor_config.chart_colors
852 line_width = self.monitor_config.line_width
854 # Update curve pens
855 self.cpu_curve.setPen(pg.mkPen(colors['cpu'], width=line_width))
856 self.ram_curve.setPen(pg.mkPen(colors['ram'], width=line_width))
857 self.gpu_curve.setPen(pg.mkPen(colors['gpu'], width=line_width))
858 self.vram_curve.setPen(pg.mkPen(colors['vram'], width=line_width))
860 # Update plot grid for consolidated plots (don't change X range here - let update_pyqtgraph_plots handle it)
861 plots = [self.cpu_gpu_plot, self.ram_vram_plot]
862 for plot in plots:
863 plot.showGrid(x=self.monitor_config.show_grid, y=self.monitor_config.show_grid, alpha=0.3)
865 def closeEvent(self, event):
866 """Handle widget close event."""
867 self.stop_monitoring()
868 event.accept()