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

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

183 

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

187 

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

191 

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

202 

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

213 

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) 

217 

218 return widget 

219 

220 def create_fallback_section(self) -> QWidget: 

221 """ 

222 Create fallback text-based monitoring section. 

223  

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

237 

238 layout = QVBoxLayout(widget) 

239 

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) 

244 

245 return widget 

246 

247 def setup_connections(self): 

248 """Setup signal/slot connections.""" 

249 self.metrics_updated.connect(self.update_display) 

250 

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 ) 

256 

257 def start_monitoring(self): 

258 """Start the persistent monitoring thread.""" 

259 self.persistent_monitor.start_monitoring() 

260 logger.debug("System monitoring started") 

261 

262 def stop_monitoring(self): 

263 """Stop the persistent monitoring thread.""" 

264 self.persistent_monitor.stop_monitoring() 

265 logger.debug("System monitoring stopped") 

266 

267 def cleanup(self): 

268 """Clean up widget resources.""" 

269 try: 

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

271 

272 # Stop monitoring first 

273 self.stop_monitoring() 

274 

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

282 

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

292 

293 except Exception as e: 

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

295 

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

303 

304 logger.debug("SystemMonitorWidget cleanup completed") 

305 

306 except Exception as e: 

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

308 

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

319 

320 # Update cached metrics 

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

322 

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

326 

327 except Exception as e: 

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

329 

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 

334 

335 def update_display(self, metrics: dict): 

336 """ 

337 Update the display with new metrics. 

338 

339 Args: 

340 metrics: Dictionary of system metrics 

341 """ 

342 try: 

343 # Update system info 

344 self.update_system_info(metrics) 

345 

346 # Update plots or fallback display 

347 if PYQTGRAPH_AVAILABLE: 

348 self.update_pyqtgraph_plots() 

349 else: 

350 self.update_fallback_display(metrics) 

351 

352 except Exception as e: 

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

354 

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 

362 

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

366 

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) 

372 

373 # Update CPU/GPU consolidated plot 

374 self.cpu_curve.setData(x_time, cpu_data) 

375 

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' 

383 

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

387 

388 # Update RAM/VRAM consolidated plot 

389 self.ram_curve.setData(x_time, ram_data) 

390 

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' 

398 

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

402 

403 except Exception as e: 

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

405 

406 def update_fallback_display(self, metrics: dict): 

407 """ 

408 Update fallback text display. 

409  

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) 

423 

424 except Exception as e: 

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

426 

427 def update_system_info(self, metrics: dict): 

428 """ 

429 Update system information display. 

430  

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

441 

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" 

446 

447 info_text += "\n═══════════════════════════════════════════════════════════════════════" 

448 

449 self.info_label.setText(info_text) 

450 

451 except Exception as e: 

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

453 

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

455 """ 

456 Create a text-based progress bar. 

457  

458 Args: 

459 percent: Percentage value (0-100) 

460  

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

468 

469 def get_ascii_header(self) -> str: 

470 """ 

471 Get ASCII art header. 

472  

473 Returns: 

474 ASCII art header string 

475 """ 

476 return """ 

477 ██████╗ ██████╗ ███████╗███╗ ██╗██╗ ██╗ ██████╗███████╗ 

478██╔═══██╗██╔══██╗██╔════╝████╗ ██║██║ ██║██╔════╝██╔════╝ 

479██║ ██║██████╔╝█████╗ ██╔██╗ ██║███████║██║ ███████╗ 

480██║ ██║██╔═══╝ ██╔══╝ ██║╚██╗██║██╔══██║██║ ╚════██║ 

481╚██████╔╝██║ ███████╗██║ ╚████║██║ ██║╚██████╗███████║ 

482 ╚═════╝ ╚═╝ ╚══════╝╚═╝ ╚═══╝╚═╝ ╚═╝ ╚═════╝╚══════╝ 

483 """ 

484 

485 def set_update_interval(self, interval_ms: int): 

486 """ 

487 Set the update interval for monitoring. 

488 

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) 

494 

495 def update_config(self, new_config: PyQtGUIConfig): 

496 """ 

497 Update the widget configuration and apply changes. 

498 

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 

505 

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

509 

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

512 

513 # Stop current monitoring 

514 self.stop_monitoring() 

515 

516 # Recalculate parameters 

517 update_interval = self.monitor_config.update_interval_seconds 

518 history_length = self.monitor_config.calculated_max_data_points 

519 

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 ) 

526 

527 # Reconnect signals 

528 self.persistent_monitor.connect_signals( 

529 metrics_callback=self.on_metrics_updated, 

530 error_callback=self.on_metrics_error 

531 ) 

532 

533 # Restart monitoring 

534 self.start_monitoring() 

535 

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

540 

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

542 

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 

547 

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

553 

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) 

558 

559 def closeEvent(self, event): 

560 """Handle widget close event.""" 

561 self.stop_monitoring() 

562 event.accept()