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

1""" 

2System Monitor Widget for PyQt6 

3 

4Real-time system monitoring with CPU, RAM, GPU, and VRAM usage graphs. 

5Migrated from Textual TUI with full feature parity. 

6""" 

7 

8import logging 

9import time 

10from typing import Optional 

11from datetime import datetime 

12from collections import deque 

13 

14from PyQt6.QtWidgets import ( 

15 QWidget, QVBoxLayout, QHBoxLayout, QLabel, QFrame, QGridLayout 

16) 

17from PyQt6.QtCore import QTimer, pyqtSignal, QThread 

18from PyQt6.QtGui import QFont 

19 

20# Import PyQtGraph for high-performance plotting 

21try: 

22 import pyqtgraph as pg 

23 PYQTGRAPH_AVAILABLE = True 

24except ImportError: 

25 PYQTGRAPH_AVAILABLE = False 

26 

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 

33 

34logger = logging.getLogger(__name__) 

35 

36 

37class SystemMonitorWidget(QWidget): 

38 """ 

39 PyQt6 System Monitor Widget. 

40  

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 """ 

44 

45 # Signals 

46 metrics_updated = pyqtSignal(dict) # Emitted when metrics are updated 

47 

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. 

54 

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) 

61 

62 # Initialize configuration 

63 self.config = config or get_default_pyqt_gui_config() 

64 self.monitor_config = self.config.performance_monitor 

65 

66 # Initialize color scheme and style generator 

67 self.color_scheme = color_scheme or PyQt6ColorScheme() 

68 self.style_generator = StyleSheetGenerator(self.color_scheme) 

69 

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 

73 

74 # Core monitoring - use persistent thread for non-blocking metrics collection 

75 self.monitor = SystemMonitor(history_length=history_length) # Match the dynamic history length 

76 

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 

82 

83 # Setup UI 

84 self.setup_ui() 

85 self.setup_connections() 

86 

87 # Delay monitoring start until widget is shown (fixes WSL2 hanging) 

88 self._monitoring_started = False 

89 

90 logger.debug("System monitor widget initialized") 

91 

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") 

101 

102 def closeEvent(self, event): 

103 """Handle widget close event - cleanup resources.""" 

104 self.cleanup() 

105 super().closeEvent(event) 

106 

107 def __del__(self): 

108 """Destructor - ensure cleanup happens.""" 

109 try: 

110 self.cleanup() 

111 except: 

112 pass # Ignore errors during destruction 

113 

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) 

119 

120 # Header section 

121 header_layout = self.create_header_section() 

122 layout.addLayout(header_layout) 

123 

124 # Monitoring section 

125 if PYQTGRAPH_AVAILABLE: 

126 monitoring_widget = self.create_pyqtgraph_section() 

127 else: 

128 monitoring_widget = self.create_fallback_section() 

129 

130 layout.addWidget(monitoring_widget) 

131 

132 # Apply centralized styling 

133 self.setStyleSheet(self.style_generator.generate_system_monitor_style()) 

134 

135 def create_header_section(self) -> QHBoxLayout: 

136 """ 

137 Create the header section with title and system info. 

138  

139 Returns: 

140 Header layout 

141 """ 

142 header_layout = QHBoxLayout() 

143 

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) 

151 

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) 

158 

159 return header_layout 

160 

161 def create_pyqtgraph_section(self) -> QWidget: 

162 """ 

163 Create PyQtGraph-based monitoring section with consolidated graphs. 

164 

165 Returns: 

166 Widget containing consolidated PyQtGraph plots 

167 """ 

168 widget = QWidget() 

169 layout = QGridLayout(widget) 

170 

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) 

175 

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") 

179 

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) 

185 

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 

189 

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') 

193 

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') 

197 

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() 

208 

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() 

219 

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) 

223 

224 return widget 

225 

226 def create_fallback_section(self) -> QWidget: 

227 """ 

228 Create fallback text-based monitoring section. 

229  

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 """) 

243 

244 layout = QVBoxLayout(widget) 

245 

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) 

250 

251 return widget 

252 

253 def setup_connections(self): 

254 """Setup signal/slot connections.""" 

255 self.metrics_updated.connect(self.update_display) 

256 

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 ) 

262 

263 def start_monitoring(self): 

264 """Start the persistent monitoring thread.""" 

265 self.persistent_monitor.start_monitoring() 

266 logger.debug("System monitoring started") 

267 

268 def stop_monitoring(self): 

269 """Stop the persistent monitoring thread.""" 

270 self.persistent_monitor.stop_monitoring() 

271 logger.debug("System monitoring stopped") 

272 

273 def cleanup(self): 

274 """Clean up widget resources.""" 

275 try: 

276 logger.debug("Cleaning up SystemMonitorWidget...") 

277 

278 # Stop monitoring first 

279 self.stop_monitoring() 

280 

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() 

288 

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() 

298 

299 except Exception as e: 

300 logger.warning(f"Error cleaning up pyqtgraph plots: {e}") 

301 

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() 

309 

310 logger.debug("SystemMonitorWidget cleanup completed") 

311 

312 except Exception as e: 

313 logger.warning(f"Error during SystemMonitorWidget cleanup: {e}") 

314 

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()) 

325 

326 # Update cached metrics 

327 self.monitor._current_metrics = metrics.copy() 

328 

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)) 

332 

333 except Exception as e: 

334 logger.warning(f"Failed to process metrics update: {e}") 

335 

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 

340 

341 def update_display(self, metrics: dict): 

342 """ 

343 Update the display with new metrics. 

344 

345 Args: 

346 metrics: Dictionary of system metrics 

347 """ 

348 try: 

349 # Update system info 

350 self.update_system_info(metrics) 

351 

352 # Update plots or fallback display 

353 if PYQTGRAPH_AVAILABLE: 

354 self.update_pyqtgraph_plots() 

355 else: 

356 self.update_fallback_display(metrics) 

357 

358 except Exception as e: 

359 logger.warning(f"Failed to update display: {e}") 

360 

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 

368 

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)] 

372 

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) 

378 

379 # Update CPU/GPU consolidated plot 

380 self.cpu_curve.setData(x_time, cpu_data) 

381 

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' 

389 

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}') 

393 

394 # Update RAM/VRAM consolidated plot 

395 self.ram_curve.setData(x_time, ram_data) 

396 

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' 

404 

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}') 

408 

409 except Exception as e: 

410 logger.warning(f"Failed to update PyQtGraph plots: {e}") 

411 

412 def update_fallback_display(self, metrics: dict): 

413 """ 

414 Update fallback text display. 

415  

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) 

429 

430 except Exception as e: 

431 logger.warning(f"Failed to update fallback display: {e}") 

432 

433 def update_system_info(self, metrics: dict): 

434 """ 

435 Update system information display. 

436  

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""" 

447 

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" 

452 

453 info_text += "\n═══════════════════════════════════════════════════════════════════════" 

454 

455 self.info_label.setText(info_text) 

456 

457 except Exception as e: 

458 logger.warning(f"Failed to update system info: {e}") 

459 

460 def create_text_bar(self, percent: float) -> str: 

461 """ 

462 Create a text-based progress bar. 

463  

464 Args: 

465 percent: Percentage value (0-100) 

466  

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}]" 

474 

475 def get_ascii_header(self) -> str: 

476 """ 

477 Get ASCII art header. 

478  

479 Returns: 

480 ASCII art header string 

481 """ 

482 return """ 

483 ██████╗ ██████╗ ███████╗███╗ ██╗██╗ ██╗ ██████╗███████╗ 

484██╔═══██╗██╔══██╗██╔════╝████╗ ██║██║ ██║██╔════╝██╔════╝ 

485██║ ██║██████╔╝█████╗ ██╔██╗ ██║███████║██║ ███████╗ 

486██║ ██║██╔═══╝ ██╔══╝ ██║╚██╗██║██╔══██║██║ ╚════██║ 

487╚██████╔╝██║ ███████╗██║ ╚████║██║ ██║╚██████╗███████║ 

488 ╚═════╝ ╚═╝ ╚══════╝╚═╝ ╚═══╝╚═╝ ╚═╝ ╚═════╝╚══════╝ 

489 """ 

490 

491 def set_update_interval(self, interval_ms: int): 

492 """ 

493 Set the update interval for monitoring. 

494 

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) 

500 

501 def update_config(self, new_config: PyQtGUIConfig): 

502 """ 

503 Update the widget configuration and apply changes. 

504 

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 

511 

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): 

515 

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") 

518 

519 # Stop current monitoring 

520 self.stop_monitoring() 

521 

522 # Recalculate parameters 

523 update_interval = self.monitor_config.update_interval_seconds 

524 history_length = self.monitor_config.calculated_max_data_points 

525 

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 ) 

532 

533 # Reconnect signals 

534 self.persistent_monitor.connect_signals( 

535 metrics_callback=self.on_metrics_updated, 

536 error_callback=self.on_metrics_error 

537 ) 

538 

539 # Restart monitoring 

540 self.start_monitoring() 

541 

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() 

546 

547 logger.debug("Performance monitor configuration updated") 

548 

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 

553 

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)) 

559 

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) 

564 

565 def closeEvent(self, event): 

566 """Handle widget close event.""" 

567 self.stop_monitoring() 

568 event.accept()