#!/usr/bin/env python3 """GUI application for S.M.A.R.T. disk health monitoring using PyQt6.""" import sys from typing import Optional, Dict, List from PyQt6.QtWidgets import ( QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QTableWidget, QTableWidgetItem, QPushButton, QLabel, QHeaderView, QMessageBox, QProgressBar, QFrame, QScrollArea, QTabWidget, QTreeWidget, QTreeWidgetItem, ) from PyQt6.QtCore import Qt, QThread, pyqtSignal from PyQt6.QtGui import QFont, QColor, QPalette from smart_report.core import ( collect_all_disks_data, DiskSmartData, is_root, get_locale, get_message, ) class SmartDataWorker(QThread): """Worker thread for collecting SMART data.""" data_ready = pyqtSignal(list) error = pyqtSignal(str) def __init__(self, lang: str): super().__init__() self.lang = lang def run(self): try: data = collect_all_disks_data(self.lang) self.data_ready.emit(data) except Exception as e: self.error.emit(str(e)) class HealthBarWidget(QFrame): """Custom health bar widget with percentage.""" def __init__(self, health: int, parent=None): super().__init__(parent) self.health = health self.setFixedHeight(30) self.setMinimumWidth(200) self.setup_ui() def setup_ui(self): layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) self.progress = QProgressBar() self.progress.setRange(0, 100) self.progress.setValue(self.health) self.progress.setFormat(f" {self.health}%") self.progress.setAlignment(Qt.AlignmentFlag.AlignCenter) self.progress.setStyleSheet(self.get_stylesheet()) layout.addWidget(self.progress) def get_stylesheet(self) -> str: if self.health >= 85: color = "#27ae60" # Green elif self.health >= 60: color = "#f39c12" # Yellow elif self.health >= 30: color = "#e74c3c" # Red else: color = "#c0392b" # Dark red return f""" QProgressBar {{ border: 1px solid #ccc; border-radius: 3px; text-align: center; font-weight: bold; }} QProgressBar::chunk {{ background-color: {color}; border-radius: 2px; }} """ class SmartAttributesTree(QTreeWidget): """Tree widget for displaying SMART attributes.""" def __init__(self, data: DiskSmartData, lang: str, parent=None): super().__init__(parent) self.data = data self.lang = lang self.setup_ui() def setup_ui(self): self.setHeaderLabels([ get_message("disk", self.lang), "Value", "Worst", "Threshold", "Raw" ]) self.setAlternatingRowColors(True) self.setRootIsDecorated(False) # Populate with attributes for attr_id in sorted(self.data.attrs.keys(), key=lambda x: int(x)): attr = self.data.attrs[attr_id] item = QTreeWidgetItem([ f"{attr_id} - {attr['name']}", attr['value'], attr['worst'], attr['threshold'], attr['raw'] ]) # Highlight critical attributes if attr_id in ['5', '197', '198']: item.setForeground(0, QColor("#e74c3c")) font = item.font(0) font.setBold(True) item.setFont(0, font) elif attr_id in ['194', '9', '12']: item.setForeground(0, QColor("#3498db")) font = item.font(0) font.setBold(True) item.setFont(0, font) self.addTopLevelItem(item) self.resizeColumnToContents(0) class DiskHealthWindow(QMainWindow): """Main window for disk health monitoring.""" def __init__(self): super().__init__() self.lang = get_locale() self.worker: Optional[SmartDataWorker] = None self.current_data: List[DiskSmartData] = [] self.init_ui() self.refresh_data() def init_ui(self): """Initialize the user interface.""" self.setWindowTitle(get_message("disk_health_report", self.lang)) self.setMinimumSize(1100, 650) # Central widget central_widget = QWidget() self.setCentralWidget(central_widget) main_layout = QVBoxLayout(central_widget) main_layout.setSpacing(10) main_layout.setContentsMargins(15, 15, 15, 15) # Header header_layout = QHBoxLayout() self.title_label = QLabel(get_message("disk_health_report", self.lang)) self.title_label.setFont(QFont("Arial", 18, QFont.Weight.Bold)) header_layout.addWidget(self.title_label) header_layout.addStretch() self.status_label = QLabel("") self.status_label.setStyleSheet("color: #666;") header_layout.addWidget(self.status_label) main_layout.addLayout(header_layout) # Tab widget self.tabs = QTabWidget() self.tabs.setStyleSheet(""" QTabWidget::pane { border: 1px solid #ddd; border-radius: 4px; background-color: white; } QTabBar::tab { background-color: #f5f5f5; border: 1px solid #ddd; border-bottom: none; border-top-left-radius: 4px; border-top-right-radius: 4px; padding: 8px 16px; margin-right: 2px; } QTabBar::tab:selected { background-color: white; border-bottom: 1px solid white; } QTabBar::tab:hover:!selected { background-color: #e8e8e8; } """) # Summary tab self.summary_tab = QWidget() self.summary_layout = QVBoxLayout(self.summary_tab) self.summary_layout.setContentsMargins(10, 10, 10, 10) self.summary_scroll = QScrollArea() self.summary_scroll.setWidgetResizable(True) self.summary_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) self.summary_container = QWidget() self.summary_container_layout = QVBoxLayout(self.summary_container) self.summary_container_layout.setSpacing(15) self.summary_scroll.setWidget(self.summary_container) self.summary_layout.addWidget(self.summary_scroll) self.tabs.addTab(self.summary_tab, "📊 Summary / Обзор") # Detailed tab self.detailed_tab = QWidget() self.detailed_layout = QVBoxLayout(self.detailed_tab) self.detailed_layout.setContentsMargins(10, 10, 10, 10) self.detailed_scroll = QScrollArea() self.detailed_scroll.setWidgetResizable(True) self.detailed_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) self.detailed_container = QWidget() self.detailed_container_layout = QVBoxLayout(self.detailed_container) self.detailed_container_layout.setSpacing(20) self.detailed_scroll.setWidget(self.detailed_container) self.detailed_layout.addWidget(self.detailed_scroll) self.tabs.addTab(self.detailed_tab, "📋 Detailed / Подробно") main_layout.addWidget(self.tabs) # Footer with buttons footer_layout = QHBoxLayout() self.refresh_btn = QPushButton(get_message("refresh", self.lang)) self.refresh_btn.clicked.connect(self.refresh_data) self.refresh_btn.setMinimumSize(120, 35) self.refresh_btn.setStyleSheet( "QPushButton { background-color: #3498db; color: white; " "border: none; padding: 8px 16px; border-radius: 4px; font-weight: bold; }" "QPushButton:hover { background-color: #2980b9; }" "QPushButton:disabled { background-color: #95a5a6; }" ) footer_layout.addWidget(self.refresh_btn) footer_layout.addStretch() self.root_label = QLabel("") self.root_label.setStyleSheet("color: #e74c3c; font-weight: bold;") footer_layout.addWidget(self.root_label) main_layout.addLayout(footer_layout) # Check root status self.update_root_status() def update_root_status(self): """Update root status label.""" if not is_root(): self.root_label.setText(get_message("run_with_sudo", self.lang)) else: self.root_label.setText(get_message("running_as_root", self.lang)) def refresh_data(self): """Start data collection in background thread.""" self.refresh_btn.setEnabled(False) self.status_label.setText(get_message("collecting_data", self.lang)) # Clear existing widgets while self.summary_container_layout.count(): item = self.summary_container_layout.takeAt(0) if item.widget(): item.widget().deleteLater() while self.detailed_container_layout.count(): item = self.detailed_container_layout.takeAt(0) if item.widget(): item.widget().deleteLater() self.worker = SmartDataWorker(self.lang) self.worker.data_ready.connect(self.on_data_ready) self.worker.error.connect(self.on_error) self.worker.start() def on_data_ready(self, data: list): """Handle collected data.""" self.current_data = data # Populate summary tab for disk_data in data: self.summary_container_layout.addWidget(self.create_summary_panel(disk_data)) # Populate detailed tab for disk_data in data: self.detailed_container_layout.addWidget(self.create_detailed_panel(disk_data)) self.status_label.setText(get_message("disks_found", self.lang, len(data))) self.refresh_btn.setEnabled(True) def create_summary_panel(self, data: DiskSmartData) -> QFrame: """Create a summary panel widget for a single disk.""" panel = QFrame() panel.setFrameStyle(QFrame.Shape.StyledPanel) panel.setStyleSheet( "QFrame { background-color: white; border: 1px solid #ddd; " "border-radius: 8px; padding: 15px; }" ) layout = QVBoxLayout(panel) layout.setSpacing(10) # Header row header_layout = QHBoxLayout() header_layout.addWidget(QLabel(f"📀 {data.disk} - {data.model}")) header_layout.addStretch() # Health bar health_bar = HealthBarWidget(data.health) header_layout.addWidget(health_bar) layout.addLayout(header_layout) # Info grid info_layout = QHBoxLayout() if data.smart_supported: # Status status_color = ( "#27ae60" if data.status == "GOOD" else ("#e74c3c" if data.status == "BAD" else "#f39c12") ) status_label = QLabel(f"{get_message('status', self.lang)}: {data.status}") status_label.setStyleSheet(f"color: {status_color}; font-weight: bold;") info_layout.addWidget(status_label) # Temperature info_layout.addWidget(QLabel(f"{get_message('temperature', self.lang)}: {data.temp}")) # Power hours info_layout.addWidget(QLabel(f"{get_message('power_hours', self.lang)}: {data.power_hours}")) # Power cycles info_layout.addWidget(QLabel(f"{get_message('power_cycles', self.lang)}: {data.power_cycles}")) info_layout.addStretch() layout.addLayout(info_layout) # Critical attributes attrs_layout = QHBoxLayout() attrs_layout.addWidget( QLabel(f"• {get_message('reallocated', self.lang)}: {data.reallocated}") ) attrs_layout.addWidget( QLabel(f"• {get_message('pending', self.lang)}: {data.pending}") ) attrs_layout.addWidget( QLabel(f"• {get_message('uncorrectable', self.lang)}: {data.uncorrectable}") ) attrs_layout.addStretch() layout.addLayout(attrs_layout) # Warnings if data.warnings: warnings_text = "
".join([f"⚠️ {w}" for w in data.warnings]) warnings_label = QLabel(f"{warnings_text}") warnings_label.setWordWrap(True) warnings_label.setStyleSheet("background-color: #ffeaea; padding: 10px; border-radius: 4px;") layout.addWidget(warnings_label) else: not_supported = QLabel(f"{get_message('smart_not_supported', self.lang)}") not_supported.setStyleSheet("color: #999;") layout.addWidget(not_supported) return panel def create_detailed_panel(self, data: DiskSmartData) -> QFrame: """Create a detailed panel widget for a single disk.""" panel = QFrame() panel.setFrameStyle(QFrame.Shape.StyledPanel) panel.setStyleSheet( "QFrame { background-color: white; border: 1px solid #ddd; " "border-radius: 8px; padding: 15px; }" ) layout = QVBoxLayout(panel) layout.setSpacing(15) # Header header_layout = QHBoxLayout() header_layout.addWidget(QLabel(f"📀 {data.disk}")) header_layout.addWidget(QLabel(f"Model: {data.model}")) header_layout.addWidget(QLabel(f"Size: {data.size}")) header_layout.addStretch() # Health indicator health_label = QLabel(f"{get_message('health', self.lang)}: {data.health}%") health_label.setFont(QFont("Arial", 14, QFont.Weight.Bold)) if data.health >= 85: health_label.setStyleSheet("color: #27ae60;") elif data.health >= 60: health_label.setStyleSheet("color: #f39c12;") elif data.health >= 30: health_label.setStyleSheet("color: #e74c3c;") else: health_label.setStyleSheet("color: #c0392b;") header_layout.addWidget(health_label) layout.addLayout(header_layout) if not data.smart_supported: not_supported = QLabel(f"{get_message('smart_not_supported', self.lang)}") not_supported.setStyleSheet("color: #999; font-size: 14px;") layout.addWidget(not_supported) return panel # Info section info_frame = QFrame() info_frame.setStyleSheet("background-color: #f9f9f9; border-radius: 4px; padding: 10px;") info_layout = QHBoxLayout(info_frame) status_color = ( "#27ae60" if data.status == "GOOD" else ("#e74c3c" if data.status == "BAD" else "#f39c12") ) info_layout.addWidget(QLabel(f"Status: {data.status}")) info_layout.addWidget(QLabel(f"Temperature: {data.temp}")) info_layout.addWidget(QLabel(f"Power-On Hours: {data.power_hours}")) info_layout.addWidget(QLabel(f"Power Cycles: {data.power_cycles}")) info_layout.addStretch() layout.addWidget(info_frame) # Critical attributes section critical_frame = QFrame() critical_frame.setStyleSheet("background-color: #fff5f5; border: 1px solid #ffcccc; border-radius: 4px; padding: 10px;") critical_layout = QHBoxLayout(critical_frame) critical_layout.addWidget(QLabel(f"⚠️ {get_message('critical_attrs', self.lang)}:")) critical_layout.addWidget(QLabel(f"{get_message('reallocated', self.lang)}: {data.reallocated}")) critical_layout.addWidget(QLabel(f"{get_message('pending', self.lang)}: {data.pending}")) critical_layout.addWidget(QLabel(f"{get_message('uncorrectable', self.lang)}: {data.uncorrectable}")) # Additional attributes if data.remaining_lifetime < 100: critical_layout.addWidget(QLabel(f"Rem. Life: {data.remaining_lifetime}%")) if data.media_wearout_indicator < 100: critical_layout.addWidget(QLabel(f"Wearout: {data.media_wearout_indicator}%")) if data.ssd_life_left < 100 and data.remaining_lifetime == 100: critical_layout.addWidget(QLabel(f"SSD Life: {data.ssd_life_left}%")) if data.host_writes_gb > 0: critical_layout.addWidget(QLabel(f"Writes: {data.host_writes_gb}GB")) if data.crc_errors > 0: critical_layout.addWidget(QLabel(f"CRC: {data.crc_errors}")) if data.program_fail_count > 0: critical_layout.addWidget(QLabel(f"Prog Fail: {data.program_fail_count}")) if data.erase_fail_count > 0: critical_layout.addWidget(QLabel(f"Erase Fail: {data.erase_fail_count}")) if data.command_timeout > 0: critical_layout.addWidget(QLabel(f"Timeouts: {data.command_timeout}")) if data.reallocated_event_count > 0: critical_layout.addWidget(QLabel(f"Realloc Ev: {data.reallocated_event_count}")) if data.reported_uncorrect > 0: critical_layout.addWidget(QLabel(f"Uncorrect: {data.reported_uncorrect}")) critical_layout.addStretch() layout.addWidget(critical_frame) # Warnings if data.warnings: warnings_frame = QFrame() warnings_frame.setStyleSheet("background-color: #ffeaea; border: 1px solid #e74c3c; border-radius: 4px; padding: 10px;") warnings_layout = QVBoxLayout(warnings_frame) warnings_layout.addWidget(QLabel("⚠️ WARNINGS / ПРЕДУПРЕЖДЕНИЯ:")) for warning in data.warnings: warnings_layout.addWidget(QLabel(f"• {warning}")) layout.addWidget(warnings_frame) # SMART attributes tree layout.addWidget(QLabel("All S.M.A.R.T. Attributes / Все атрибуты S.M.A.R.T.:")) attrs_tree = SmartAttributesTree(data, self.lang) attrs_tree.setMinimumHeight(300) attrs_tree.setStyleSheet(""" QTreeWidget { border: 1px solid #ddd; border-radius: 4px; background-color: white; gridline-color: #eee; } QTreeWidget::item { padding: 4px; } QTreeWidget::item:selected { background-color: #3498db; color: white; } QHeaderView::section { background-color: #f5f5f5; padding: 8px; border: none; border-bottom: 1px solid #ddd; font-weight: bold; } """) layout.addWidget(attrs_tree) return panel def on_error(self, error_msg: str): """Handle error.""" QMessageBox.critical( self, get_message("error", self.lang), f"{get_message('error', self.lang)}:\n{error_msg}" ) self.status_label.setText("") self.refresh_btn.setEnabled(True) def closeEvent(self, event): """Handle window close.""" if self.worker and self.worker.isRunning(): self.worker.terminate() self.worker.wait() event.accept() def main(): """Main entry point.""" app = QApplication(sys.argv) app.setStyle("Fusion") # Set application palette palette = app.palette() palette.setColor(palette.ColorRole.Window, QColor(245, 245, 245)) app.setPalette(palette) window = DiskHealthWindow() window.show() sys.exit(app.exec()) if __name__ == "__main__": main()