Files
smart-report/smart_report/gui.py
kilyabin 19b79a4e13 feat: S.M.A.R.T. disk health monitoring with CLI and GUI
- Add core module with SMART data parsing and health calculation
- Add CLI with Rich-based terminal UI and health bar visualization
- Add GUI with PyQt6 tabs for summary and detailed views
- Support multiple health indicators (ID 231, 169, 233) for different SSD manufacturers
- Add bilingual support (Russian/English) with auto-detection
- Add GitHub Actions workflow for building binaries on Linux, Windows, macOS
- Calculate health based on reallocated sectors, pending sectors, SSD life, and more

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-15 00:17:01 +04:00

560 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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"📀 <b>{data.disk}</b> - {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"<b>{get_message('status', self.lang)}:</b> {data.status}")
status_label.setStyleSheet(f"color: {status_color}; font-weight: bold;")
info_layout.addWidget(status_label)
# Temperature
info_layout.addWidget(QLabel(f"<b>{get_message('temperature', self.lang)}:</b> {data.temp}"))
# Power hours
info_layout.addWidget(QLabel(f"<b>{get_message('power_hours', self.lang)}:</b> {data.power_hours}"))
# Power cycles
info_layout.addWidget(QLabel(f"<b>{get_message('power_cycles', self.lang)}:</b> {data.power_cycles}"))
info_layout.addStretch()
layout.addLayout(info_layout)
# Critical attributes
attrs_layout = QHBoxLayout()
attrs_layout.addWidget(
QLabel(f"• <b>{get_message('reallocated', self.lang)}:</b> {data.reallocated}")
)
attrs_layout.addWidget(
QLabel(f"• <b>{get_message('pending', self.lang)}:</b> {data.pending}")
)
attrs_layout.addWidget(
QLabel(f"• <b>{get_message('uncorrectable', self.lang)}:</b> {data.uncorrectable}")
)
attrs_layout.addStretch()
layout.addLayout(attrs_layout)
# Warnings
if data.warnings:
warnings_text = "<br>".join([f"⚠️ {w}" for w in data.warnings])
warnings_label = QLabel(f"<span style='color: #e74c3c; font-weight: bold;'>{warnings_text}</span>")
warnings_label.setWordWrap(True)
warnings_label.setStyleSheet("background-color: #ffeaea; padding: 10px; border-radius: 4px;")
layout.addWidget(warnings_label)
else:
not_supported = QLabel(f"<i>{get_message('smart_not_supported', self.lang)}</i>")
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"📀 <b style='font-size: 16px;'>{data.disk}</b>"))
header_layout.addWidget(QLabel(f"<b>Model:</b> {data.model}"))
header_layout.addWidget(QLabel(f"<b>Size:</b> {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"<i>{get_message('smart_not_supported', self.lang)}</i>")
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"<b>Status:</b> <span style='color: {status_color};'>{data.status}</span>"))
info_layout.addWidget(QLabel(f"<b>Temperature:</b> {data.temp}"))
info_layout.addWidget(QLabel(f"<b>Power-On Hours:</b> {data.power_hours}"))
info_layout.addWidget(QLabel(f"<b>Power Cycles:</b> {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"<b style='color: #e74c3c;'>⚠️ {get_message('critical_attrs', self.lang)}:</b>"))
critical_layout.addWidget(QLabel(f"<b style='color: #e74c3c;'>{get_message('reallocated', self.lang)}:</b> {data.reallocated}"))
critical_layout.addWidget(QLabel(f"<b style='color: #e74c3c;'>{get_message('pending', self.lang)}:</b> {data.pending}"))
critical_layout.addWidget(QLabel(f"<b style='color: #e74c3c;'>{get_message('uncorrectable', self.lang)}:</b> {data.uncorrectable}"))
# Additional attributes
if data.remaining_lifetime < 100:
critical_layout.addWidget(QLabel(f"<b style='color: #27ae60;'>Rem. Life:</b> {data.remaining_lifetime}%"))
if data.media_wearout_indicator < 100:
critical_layout.addWidget(QLabel(f"<b style='color: #f39c12;'>Wearout:</b> {data.media_wearout_indicator}%"))
if data.ssd_life_left < 100 and data.remaining_lifetime == 100:
critical_layout.addWidget(QLabel(f"<b style='color: #f39c12;'>SSD Life:</b> {data.ssd_life_left}%"))
if data.host_writes_gb > 0:
critical_layout.addWidget(QLabel(f"<b style='color: #3498db;'>Writes:</b> {data.host_writes_gb}GB"))
if data.crc_errors > 0:
critical_layout.addWidget(QLabel(f"<b style='color: #f39c12;'>CRC:</b> {data.crc_errors}"))
if data.program_fail_count > 0:
critical_layout.addWidget(QLabel(f"<b style='color: #e74c3c;'>Prog Fail:</b> {data.program_fail_count}"))
if data.erase_fail_count > 0:
critical_layout.addWidget(QLabel(f"<b style='color: #e74c3c;'>Erase Fail:</b> {data.erase_fail_count}"))
if data.command_timeout > 0:
critical_layout.addWidget(QLabel(f"<b style='color: #f39c12;'>Timeouts:</b> {data.command_timeout}"))
if data.reallocated_event_count > 0:
critical_layout.addWidget(QLabel(f"<b style='color: #f39c12;'>Realloc Ev:</b> {data.reallocated_event_count}"))
if data.reported_uncorrect > 0:
critical_layout.addWidget(QLabel(f"<b style='color: #e74c3c;'>Uncorrect:</b> {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("<b style='color: #e74c3c;'>⚠️ WARNINGS / ПРЕДУПРЕЖДЕНИЯ:</b>"))
for warning in data.warnings:
warnings_layout.addWidget(QLabel(f"<span style='color: #e74c3c;'>• {warning}</span>"))
layout.addWidget(warnings_frame)
# SMART attributes tree
layout.addWidget(QLabel("<b style='font-size: 14px;'>All S.M.A.R.T. Attributes / Все атрибуты S.M.A.R.T.:</b>"))
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()