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>
This commit is contained in:
kilyabin
2026-03-15 00:15:21 +04:00
commit 19b79a4e13
12 changed files with 2333 additions and 0 deletions

559
smart_report/gui.py Normal file
View File

@@ -0,0 +1,559 @@
#!/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()