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

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 

12 

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 

18 

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 

24 

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 

31 

32logger = logging.getLogger(__name__) 

33 

34 

35class SystemMonitorWidget(QWidget): 

36 """ 

37 PyQt6 System Monitor Widget. 

38  

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

42 

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 

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 = SystemMonitorCore(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 # 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 

86 

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

88 self._monitoring_started = False 

89 

90 # Setup UI 

91 self.setup_ui() 

92 self.setup_connections() 

93 

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

95 

96 def create_loading_placeholder(self) -> QWidget: 

97 """ 

98 Create a simple loading placeholder shown while PyQtGraph loads. 

99 

100 Returns: 

101 Simple loading label widget 

102 """ 

103 from PyQt6.QtWidgets import QLabel, QVBoxLayout, QWidget 

104 from PyQt6.QtCore import Qt 

105 

106 placeholder = QWidget() 

107 layout = QVBoxLayout(placeholder) 

108 

109 label = QLabel("Loading system monitor...") 

110 label.setAlignment(Qt.AlignmentFlag.AlignCenter) 

111 layout.addWidget(label) 

112 

113 return placeholder 

114 

115 def _load_pyqtgraph_async(self): 

116 """ 

117 Load PyQtGraph asynchronously using QTimer to avoid blocking. 

118 

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

126 

127 def _import_pyqtgraph_main_thread(self): 

128 """Import PyQtGraph in main thread after delay.""" 

129 global PYQTGRAPH_AVAILABLE, pg 

130 

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

136 

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

141 

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 

149 

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 

156 

157 # Schedule fallback switch similarly 

158 from PyQt6.QtCore import QTimer as _QTimer 

159 _QTimer.singleShot(0, self._switch_to_fallback_ui) 

160 

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

168 

169 # Create PyQtGraph section 

170 self.monitoring_widget = self.create_pyqtgraph_section() 

171 layout.addWidget(self.monitoring_widget, 1) 

172 

173 logger.info("Switched to PyQtGraph UI") 

174 

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

182 

183 # Create fallback section 

184 self.monitoring_widget = self.create_fallback_section() 

185 layout.addWidget(self.monitoring_widget, 1) 

186 

187 logger.info("Switched to fallback UI (PyQtGraph not available)") 

188 

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

198 

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) 

207 

208 def closeEvent(self, event): 

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

210 self.cleanup() 

211 super().closeEvent(event) 

212 

213 def __del__(self): 

214 """Destructor - ensure cleanup happens.""" 

215 try: 

216 self.cleanup() 

217 except: 

218 pass # Ignore errors during destruction 

219 

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 

224 

225 # Use the actual info panel width, not the whole widget width 

226 panel_width = self.info_widget.width() 

227 

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

231 

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

235 

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) 

241 

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) 

252 

253 # Update value label (bold) 

254 value_font = QFont("Arial", label_size) 

255 value_font.setBold(True) 

256 label_pair[1].setFont(value_font) 

257 

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) 

263 

264 # Header section 

265 header_layout = self.create_header_section() 

266 layout.addLayout(header_layout) 

267 

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 

272 

273 # Apply centralized styling 

274 self.setStyleSheet(self.style_generator.generate_system_monitor_style()) 

275 

276 # Load PyQtGraph asynchronously 

277 self._load_pyqtgraph_async() 

278 

279 def create_header_section(self) -> QHBoxLayout: 

280 """ 

281 Create the header section with title and system info. 

282 

283 Returns: 

284 Header layout 

285 """ 

286 header_layout = QHBoxLayout() 

287 

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) 

296 

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 

300 

301 return header_layout 

302 

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) 

308 

309 layout = QVBoxLayout(panel) 

310 layout.setContentsMargins(20, 15, 20, 15) 

311 layout.setSpacing(12) 

312 

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) 

317 

318 # Separator line 

319 separator = QFrame() 

320 separator.setFrameShape(QFrame.Shape.HLine) 

321 separator.setFrameShadow(QFrame.Shadow.Sunken) 

322 layout.addWidget(separator) 

323 

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 

332 

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:", "—") 

338 

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) 

347 

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:", "—") 

352 

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) 

359 

360 layout.addLayout(info_grid) 

361 layout.addStretch() 

362 

363 # Schedule initial font size update after panel is shown 

364 QTimer.singleShot(100, self._update_font_sizes_from_panel) 

365 

366 return panel 

367 

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

372 

373 value = QLabel(value_text) 

374 value.setObjectName("info_label_value") 

375 

376 return (label, value) 

377 

378 def create_pyqtgraph_section(self) -> QWidget: 

379 """ 

380 Create PyQtGraph-based monitoring section with consolidated graphs. 

381 

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) 

389 

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) 

394 

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) 

399 

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

403 

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) 

409 

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 

413 

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

417 

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

421 

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

432 

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

443 

444 # Add plots to grid layout (side-by-side by default) 

445 self._update_graph_layout() 

446 

447 main_layout.addWidget(self.graph_container, 1) # Stretch factor = 1 

448 

449 return widget 

450 

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. 

455 

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) 

464 

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

468 

469 return self.layout_toggle_button 

470 

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

475 

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

482 

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) 

490 

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) 

499 

500 def create_fallback_section(self) -> QWidget: 

501 """ 

502 Create fallback text-based monitoring section. 

503  

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

517 

518 layout = QVBoxLayout(widget) 

519 

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) 

524 

525 return widget 

526 

527 def setup_connections(self): 

528 """Setup signal/slot connections.""" 

529 self.metrics_updated.connect(self.update_display) 

530 

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 ) 

536 

537 def start_monitoring(self): 

538 """Start the persistent monitoring thread.""" 

539 self.persistent_monitor.start_monitoring() 

540 logger.debug("System monitoring started") 

541 

542 def stop_monitoring(self): 

543 """Stop the persistent monitoring thread.""" 

544 self.persistent_monitor.stop_monitoring() 

545 logger.debug("System monitoring stopped") 

546 

547 def cleanup(self): 

548 """Clean up widget resources.""" 

549 try: 

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

551 

552 # Stop monitoring first 

553 self.stop_monitoring() 

554 

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

562 

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

572 

573 except Exception as e: 

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

575 

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

583 

584 logger.debug("SystemMonitorWidget cleanup completed") 

585 

586 except Exception as e: 

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

588 

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

599 

600 # Update cached metrics 

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

602 

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

606 

607 except Exception as e: 

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

609 

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 

614 

615 def update_display(self, metrics: dict): 

616 """ 

617 Update the display with new metrics. 

618 

619 Args: 

620 metrics: Dictionary of system metrics 

621 """ 

622 try: 

623 # Update system info 

624 self.update_system_info(metrics) 

625 

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 

634 

635 except Exception as e: 

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

637 

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 

645 

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

649 

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) 

655 

656 # Update CPU/GPU consolidated plot 

657 self.cpu_curve.setData(x_time, cpu_data) 

658 

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' 

666 

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

670 

671 # Update RAM/VRAM consolidated plot 

672 self.ram_curve.setData(x_time, ram_data) 

673 

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' 

681 

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

685 

686 except Exception as e: 

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

688 

689 def update_fallback_display(self, metrics: dict): 

690 """ 

691 Update fallback text display. 

692  

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) 

706 

707 except Exception as e: 

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

709 

710 def update_system_info(self, metrics: dict): 

711 """ 

712 Update system information display. 

713 

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

720 

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

724 

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

728 

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] + '...' 

734 

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 ) 

740 

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

756 

757 except Exception as e: 

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

759 

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

761 """ 

762 Create a text-based progress bar. 

763  

764 Args: 

765 percent: Percentage value (0-100) 

766  

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

774 

775 def get_ascii_header(self) -> str: 

776 """ 

777 Get ASCII art header. 

778  

779 Returns: 

780 ASCII art header string 

781 """ 

782 return """ 

783 ██████╗ ██████╗ ███████╗███╗ ██╗██╗ ██╗ ██████╗███████╗ 

784██╔═══██╗██╔══██╗██╔════╝████╗ ██║██║ ██║██╔════╝██╔════╝ 

785██║ ██║██████╔╝█████╗ ██╔██╗ ██║███████║██║ ███████╗ 

786██║ ██║██╔═══╝ ██╔══╝ ██║╚██╗██║██╔══██║██║ ╚════██║ 

787╚██████╔╝██║ ███████╗██║ ╚████║██║ ██║╚██████╗███████║ 

788 ╚═════╝ ╚═╝ ╚══════╝╚═╝ ╚═══╝╚═╝ ╚═╝ ╚═════╝╚══════╝ 

789 """ 

790 

791 def set_update_interval(self, interval_ms: int): 

792 """ 

793 Set the update interval for monitoring. 

794 

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) 

800 

801 def update_config(self, new_config: PyQtGUIConfig): 

802 """ 

803 Update the widget configuration and apply changes. 

804 

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 

811 

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

815 

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

818 

819 # Stop current monitoring 

820 self.stop_monitoring() 

821 

822 # Recalculate parameters 

823 update_interval = self.monitor_config.update_interval_seconds 

824 history_length = self.monitor_config.calculated_max_data_points 

825 

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 ) 

832 

833 # Reconnect signals 

834 self.persistent_monitor.connect_signals( 

835 metrics_callback=self.on_metrics_updated, 

836 error_callback=self.on_metrics_error 

837 ) 

838 

839 # Restart monitoring 

840 self.start_monitoring() 

841 

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

846 

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

848 

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 

853 

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

859 

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) 

864 

865 def closeEvent(self, event): 

866 """Handle widget close event.""" 

867 self.stop_monitoring() 

868 event.accept()