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

196
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,196 @@
name: Build Binaries
on:
push:
branches: [main, master]
tags:
- 'v*'
pull_request:
branches: [main, master]
workflow_dispatch:
jobs:
build-linux:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y smartmontools libegl1 libopengl0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-xfixes0 libxkbcommon0 libdbus-1-3
- name: Install Python dependencies
run: |
pip install --upgrade pip
pip install -r requirements.txt
- name: Verify imports
run: |
python -c "from smart_report.cli import main; print('CLI OK')"
python -c "from smart_report.gui import main; print('GUI OK')"
- name: Build CLI binary
run: |
pyinstaller --name smart-report --onefile smart_report/cli.py
- name: Build GUI binary
run: |
pyinstaller --name smart-report-gui --onefile --windowed smart_report/gui.py
- name: List built binaries
run: |
ls -lh dist/
- name: Test CLI binary
run: |
dist/smart-report --help
- name: Upload CLI artifact
uses: actions/upload-artifact@v4
with:
name: smart-report-linux
path: dist/smart-report
- name: Upload GUI artifact
uses: actions/upload-artifact@v4
with:
name: smart-report-gui-linux
path: dist/smart-report-gui
build-windows:
runs-on: windows-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install Python dependencies
run: |
pip install --upgrade pip
pip install -r requirements.txt
- name: Verify imports
run: |
python -c "from smart_report.cli import main; print('CLI OK')"
python -c "from smart_report.gui import main; print('GUI OK')"
- name: Build CLI binary
run: |
pyinstaller --name smart-report --onefile smart_report/cli.py
- name: Build GUI binary
run: |
pyinstaller --name smart-report-gui --onefile --windowed smart_report/gui.py
- name: List built binaries
run: |
ls -lh dist/
- name: Test CLI binary
run: |
dist/smart-report.exe --help
- name: Upload CLI artifact
uses: actions/upload-artifact@v4
with:
name: smart-report-windows
path: dist/smart-report.exe
- name: Upload GUI artifact
uses: actions/upload-artifact@v4
with:
name: smart-report-gui-windows
path: dist/smart-report-gui.exe
build-macos:
runs-on: macos-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install Python dependencies
run: |
pip install --upgrade pip
pip install -r requirements.txt
- name: Verify imports
run: |
python -c "from smart_report.cli import main; print('CLI OK')"
python -c "from smart_report.gui import main; print('GUI OK')"
- name: Build CLI binary
run: |
pyinstaller --name smart-report --onefile smart_report/cli.py
- name: Build GUI binary
run: |
pyinstaller --name smart-report-gui --onefile --windowed smart_report/gui.py
- name: List built binaries
run: |
ls -lh dist/
- name: Test CLI binary
run: |
dist/smart-report --help
- name: Upload CLI artifact
uses: actions/upload-artifact@v4
with:
name: smart-report-macos
path: dist/smart-report
- name: Upload GUI artifact
uses: actions/upload-artifact@v4
with:
name: smart-report-gui-macos
path: dist/smart-report-gui
release:
needs: [build-linux, build-windows, build-macos]
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/')
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Prepare release files
run: |
cd artifacts
ls -R
# Create checksums
for dir in */; do
cd "$dir"
for file in *; do
sha256sum "$file" > "${file}.sha256"
done
cd ..
done
- name: Create Release
uses: softprops/action-gh-release@v1
with:
files: artifacts/**/*
draft: false
prerelease: false
generate_release_notes: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

145
.gitignore vendored Normal file
View File

@@ -0,0 +1,145 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
pip-wheel-metadata/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
.python-version
# pipenv
Pipfile.lock
# PEP 582
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Project specific
*.log
smart-report
smart-report-gui

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 kilyabin
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

185
README.md Normal file
View File

@@ -0,0 +1,185 @@
# Smart Report 📊
S.M.A.R.T. disk health monitoring tool with both CLI and GUI interfaces.
![License](https://img.shields.io/badge/license-MIT-blue.svg)
![Python](https://img.shields.io/badge/python-3.8+-blue.svg)
## Features
- 🖥️ **CLI Interface** - Quick terminal-based health report using Rich
- 🖱️ **GUI Interface** - Modern PyQt6 interface with real-time refresh
- 💾 **Multi-disk Support** - Check all disks at once (HDD, SSD, NVMe)
- 📈 **Health Metrics** - Temperature, wear percentage, reallocated sectors, power-on hours
- 🎨 **Color-coded Status** - Easy visual identification of disk health
## Screenshots
### CLI Interface
```
$ sudo smart-report-cli
```
### GUI Interface
```
$ sudo smart-report-gui
```
## Requirements
### System Dependencies
- `smartmontools` - For S.M.A.R.T. data access
- `nvme-cli` - For NVMe SSD support (optional)
Install on Ubuntu/Debian:
```bash
sudo apt-get install smartmontools nvme-cli
```
Install on Fedora/RHEL:
```bash
sudo dnf install smartmontools nvme-cli
```
Install on Arch Linux:
```bash
sudo pacman -S smartmontools nvme-cli
```
## Installation
### From Source
```bash
git clone https://github.com/kilyabin/smart-report.git
cd smart-report
pip install -r requirements.txt
pip install -e .
```
### Run Without Installation
```bash
# CLI version
sudo python3 smart_report/cli.py
# GUI version
sudo python3 smart_report/gui.py
```
## Usage
### CLI Version
```bash
# Run with sudo for full access
sudo smart-report
# Or without sudo (limited data)
smart-report
```
### GUI Version
```bash
# Run with sudo for full access
sudo smart-report-gui
# Or without sudo (limited data)
smart-report-gui
```
## Building Binaries
### Local Build
```bash
# Build CLI binary
pyinstaller --name smart-report --onefile smart_report/cli.py
# Build GUI binary
pyinstaller --name smart-report-gui --onefile --windowed smart_report/gui.py
# Binaries will be in ./dist/
```
### GitHub Actions
Binaries are automatically built on every push and release. Download from:
- **Releases page**: Pre-built binaries for Linux, Windows, macOS
- **Actions tab**: Latest CI builds
## Project Structure
```
smart-report/
├── smart_report/
│ ├── __init__.py # Package info
│ ├── core.py # Core SMART data collection
│ ├── cli.py # CLI interface (Rich)
│ └── gui.py # GUI interface (PyQt6)
├── requirements.txt # Python dependencies
├── pyproject.toml # Package configuration
├── setup.py # Setup script
├── README.md # This file
└── .github/
└── workflows/
└── build.yml # GitHub Actions workflow
```
## Development
```bash
# Install in development mode
pip install -e .
# Run CLI
python -m smart_report.cli
# Run GUI
python -m smart_report.gui
```
## Troubleshooting
### "Permission denied" errors
Run with `sudo` for full S.M.A.R.T. access:
```bash
sudo smart-report-cli
sudo smart-report-gui
```
### "smartctl not found"
Install smartmontools (see Requirements above).
### GUI doesn't start
Make sure PyQt6 is installed:
```bash
pip install PyQt6
```
### NVMe drives show limited data
Install nvme-cli:
```bash
sudo apt-get install nvme-cli # Ubuntu/Debian
```
## License
MIT License - see [LICENSE](LICENSE) file for details.
## Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
1. Fork the repository
2. Create your feature branch (`git checkout -b feature/amazing-feature`)
3. Commit your changes (`git commit -m 'Add amazing feature'`)
4. Push to the branch (`git push origin feature/amazing-feature`)
5. Open a Pull Request
## Acknowledgments
- [smartmontools](https://www.smartmontools.org/) - S.M.A.R.T. data access
- [Rich](https://github.com/Textualize/rich) - Beautiful CLI interface
- [PyQt6](https://www.riverbankcomputing.com/static/Docs/PyQt6/) - GUI framework

52
pyproject.toml Normal file
View File

@@ -0,0 +1,52 @@
[build-system]
requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "smart-report"
version = "1.0.0"
description = "S.M.A.R.T. disk health monitoring tool with CLI and GUI interfaces"
readme = "README.md"
license = {text = "MIT"}
authors = [
{name = "kilyabin"}
]
classifiers = [
"Development Status :: 4 - Beta",
"Environment :: Console",
"Environment :: X11 Applications :: Qt",
"Intended Audience :: System Administrators",
"License :: OSI Approved :: MIT License",
"Operating System :: POSIX :: Linux",
"Operating System :: Microsoft :: Windows",
"Operating System :: MacOS :: MacOS X",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: System :: Monitoring",
"Topic :: System :: Hardware",
]
requires-python = ">=3.8"
dependencies = [
"rich>=13.0.0",
]
[project.optional-dependencies]
gui = ["PyQt6>=6.4.0"]
dev = ["pyinstaller>=6.0.0", "build"]
[project.scripts]
smart-report = "smart_report.cli:main"
smart-report-gui = "smart_report.gui:main"
[project.urls]
Homepage = "https://github.com/kilyabin/smart-report"
Repository = "https://github.com/kilyabin/smart-report.git"
Issues = "https://github.com/kilyabin/smart-report/issues"
[tool.setuptools.packages.find]
where = ["."]
include = ["smart_report*"]

8
requirements.txt Normal file
View File

@@ -0,0 +1,8 @@
# Core dependencies
rich>=13.0.0
# GUI dependencies (optional)
PyQt6>=6.4.0
# Build dependencies
PyInstaller>=6.0.0

47
setup.py Normal file
View File

@@ -0,0 +1,47 @@
#!/usr/bin/env python3
"""Setup script for smart-report package."""
from setuptools import setup, find_packages
setup(
name="smart-report",
version="1.0.0",
description="S.M.A.R.T. disk health monitoring tool with CLI and GUI interfaces",
long_description=open("README.md", encoding="utf-8").read(),
long_description_content_type="text/markdown",
author="kilyabin",
license="MIT",
packages=find_packages(),
classifiers=[
"Development Status :: 4 - Beta",
"Environment :: Console",
"Environment :: X11 Applications :: Qt",
"Intended Audience :: System Administrators",
"License :: OSI Approved :: MIT License",
"Operating System :: POSIX :: Linux",
"Operating System :: Microsoft :: Windows",
"Operating System :: MacOS :: MacOS X",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: System :: Monitoring",
"Topic :: System :: Hardware",
],
python_requires=">=3.8",
install_requires=[
"rich>=13.0.0",
],
extras_require={
"gui": ["PyQt6>=6.4.0"],
"dev": ["pyinstaller>=6.0.0", "build"],
},
entry_points={
"console_scripts": [
"smart-report=smart_report.cli:main",
"smart-report-gui=smart_report.gui:main",
],
},
)

244
smart-info-2.py Normal file
View File

@@ -0,0 +1,244 @@
#!/usr/bin/env python3
"""
Мониторинг здоровья дисков с S.M.A.R.T (аналог CrystalDiskInfo)
Требует: sudo smartctl (smartmontools)
"""
import subprocess
import sys
from datetime import datetime
def check_smartctl():
"""Проверка наличия smartctl"""
try:
subprocess.run(['which', 'smartctl'], capture_output=True, check=True)
except subprocess.CalledProcessError:
print("❌ smartmontools не установлен!")
print("Установите: sudo pacman -S smartmontools")
sys.exit(1)
def get_disks():
"""Получить список дисков"""
try:
result = subprocess.run(['lsblk', '-d', '-n', '-o', 'NAME'],
capture_output=True, text=True, check=True)
return [f"/dev/{disk}" for disk in result.stdout.strip().split('\n') if disk]
except:
return []
def get_disk_info(disk):
"""Получить информацию о диске"""
try:
model = subprocess.run(['lsblk', '-d', '-n', '-o', 'MODEL', disk],
capture_output=True, text=True).stdout.strip() or "Unknown"
size = subprocess.run(['lsblk', '-d', '-n', '-o', 'SIZE', disk],
capture_output=True, text=True).stdout.strip() or "Unknown"
return model, size
except:
return "Unknown", "Unknown"
def parse_smart_data(disk):
"""Парсить S.M.A.R.T данные"""
try:
result = subprocess.run(['sudo', 'smartctl', '-a', disk],
capture_output=True, text=True)
output = result.stdout
data = {
'status': 'GOOD' if 'PASSED' in output else ('BAD' if 'FAILED' in output else 'UNKNOWN'),
'temp': 'N/A',
'power_hours': 'N/A',
'power_cycles': 'N/A',
'reallocated': 0,
'pending': 0,
'uncorrectable': 0,
'attrs': {}
}
for line in output.split('\n'):
parts = line.split()
if len(parts) < 10:
continue
if '194' in parts[0] or 'Temperature_Celsius' in line:
try:
data['temp'] = f"{parts[9]}°C"
except:
pass
if '9' in parts[0] or 'Power_On_Hours' in line:
try:
hours = int(parts[9])
data['power_hours'] = f"{hours}h ({hours//24}d)"
except:
pass
if '12' in parts[0] or 'Power_Cycle_Count' in line:
try:
data['power_cycles'] = parts[9]
except:
pass
if '5' in parts[0] or 'Reallocated_Sector_Ct' in line:
try:
data['reallocated'] = int(parts[9])
except:
pass
if '197' in parts[0] or 'Current_Pending_Sect' in line:
try:
data['pending'] = int(parts[9])
except:
pass
if '198' in parts[0] or 'Offline_Uncorrectable' in line:
try:
data['uncorrectable'] = int(parts[9])
except:
pass
if parts and parts[0].isdigit() and len(parts) >= 10:
try:
attr_id = parts[0]
attr_name = parts[1] if len(parts) > 1 else 'Unknown'
data['attrs'][attr_id] = {
'name': attr_name,
'value': parts[3],
'worst': parts[4],
'threshold': parts[5],
'raw': parts[9]
}
except:
pass
return data
except:
return None
def calculate_health(smart_data):
"""Правильный расчёт здоровья диска"""
if not smart_data:
return 50, []
if smart_data['status'] == 'BAD':
return 5, ["🔴 S.M.A.R.T статус: BAD"]
health = 100
warnings = []
reallocated = smart_data['reallocated']
pending = smart_data['pending']
uncorrectable = smart_data['uncorrectable']
# === ПЕРЕНАЗНАЧЕННЫЕ СЕКТОРА ===
if reallocated > 0:
if reallocated > 500:
penalty = min(80, reallocated * 0.5)
health -= penalty
warnings.append(f"🔴 КРИТИЧНО: {reallocated} переназначенных секторов! Диск может отказать!")
elif reallocated > 100:
penalty = min(70, reallocated * 0.3)
health -= penalty
warnings.append(f"🟠 ВНИМАНИЕ: {reallocated} переназначенных секторов. Начните резервное копирование!")
elif reallocated > 10:
penalty = reallocated * 0.2
health -= penalty
warnings.append(f"🟡 ВНИМАНИЕ: {reallocated} переназначенных секторов")
else:
health -= reallocated * 0.1
# === ОЖИДАЮЩИЕ СЕКТОРА ===
if pending > 0:
health -= min(70, pending * 2)
warnings.append(f"🔴 КРИТИЧНО: {pending} ожидающих секторов!")
# === НЕИСПРАВИМЫЕ ОШИБКИ ===
if uncorrectable > 0:
health -= min(80, uncorrectable * 5)
warnings.append(f"🔴 КРИТИЧНО: {uncorrectable} неисправимых ошибок!")
return max(5, int(health)), warnings
def get_color(health):
"""Цвет в зависимости от процента"""
if health >= 85:
return '\033[92m' # Зеленый
elif health >= 60:
return '\033[93m' # Желтый
elif health >= 30:
return '\033[91m' # Красный
else:
return '\033[41m' # Красный фон
def get_icon(health):
"""Иконка здоровья"""
if health >= 85:
return ""
elif health >= 60:
return ""
elif health >= 30:
return ""
else:
return "💥"
def print_disk(disk, model, size, health, warnings, smart_data):
"""Вывести информацию о диске"""
color = get_color(health)
icon = get_icon(health)
reset = '\033[0m'
print(f"\n{'='*75}")
print(f"Диск: {disk:15} Модель: {model:25} Размер: {size}")
bar_len = 40
filled = int(bar_len * health / 100)
bar = '' * filled + '' * (bar_len - filled)
print(f"Здоровье: {color}{icon} {bar} {health}%{reset}")
if warnings:
print("\n⚠️ ПРЕДУПРЕЖДЕНИЯ:")
for warning in warnings:
print(f" {warning}")
if not smart_data:
print("S.M.A.R.T: Не поддерживается")
return
print(f"\nСтатус: {smart_data['status']:8} | Температура: {smart_data['temp']:8} | "
f"Часов: {smart_data['power_hours']:15} | Циклов: {smart_data['power_cycles']}")
print(f"\nКритические атрибуты:")
print(f" • Переназначенные сектора: {smart_data['reallocated']}")
print(f" • Ожидающие сектора: {smart_data['pending']}")
print(f" • Неисправимые ошибки: {smart_data['uncorrectable']}")
def main():
"""Главная функция"""
check_smartctl()
print("\n" + "="*75)
print(f" МОНИТОРИНГ ЗДОРОВЬЯ ДИСКОВ (S.M.A.R.T)")
print(f" {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}")
print("="*75)
disks = get_disks()
if not disks:
print("❌ Диски не найдены")
return
for disk in disks:
model, size = get_disk_info(disk)
smart_data = parse_smart_data(disk)
health, warnings = calculate_health(smart_data)
print_disk(disk, model, size, health, warnings, smart_data)
print("\n" + "="*75 + "\n")
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
print("\n\nПрограмма прервана")
except Exception as e:
print(f"❌ Ошибка: {e}")

4
smart_report/__init__.py Normal file
View File

@@ -0,0 +1,4 @@
"""Smart Report - S.M.A.R.T. disk health monitoring tool."""
__version__ = "1.0.0"
__author__ = "kilyabin"

328
smart_report/cli.py Normal file
View File

@@ -0,0 +1,328 @@
#!/usr/bin/env python3
"""Console application for S.M.A.R.T. disk health monitoring using Rich."""
import argparse
import sys
from datetime import datetime
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
from rich import box
from rich.text import Text
from smart_report.core import (
collect_all_disks_data,
check_smartctl,
is_root,
get_locale,
get_message,
)
def get_health_icon(health: int, lang: str = None) -> str:
"""Get icon based on health percentage."""
if health >= 85:
return ""
elif health >= 60:
return ""
elif health >= 30:
return ""
else:
return "💥"
def get_health_color(health: int) -> str:
"""Get color based on health percentage."""
if health >= 85:
return "green"
elif health >= 60:
return "yellow"
elif health >= 30:
return "red"
else:
return "bold red"
def create_health_bar(health: int, width: int = 40) -> str:
"""Create a visual health bar."""
filled = int(width * health / 100)
bar = "" * filled + "" * (width - filled)
return bar
def print_disk_panel(console: Console, data, lang: str, show_all: bool = False):
"""Print disk information as a rich panel."""
color = get_health_color(data.health)
icon = get_health_icon(data.health, lang)
health_bar = create_health_bar(data.health)
# Header line
header = (
f"[bold]{get_message('disk', lang)}:[/bold] {data.disk:12} "
f"[bold]{get_message('model', lang)}:[/bold] {data.model:25} "
f"[bold]{get_message('size', lang)}:[/bold] {data.size}"
)
# Health bar
health_display = f"[{color}] {icon} {health_bar} {data.health}%[/{color}]"
# Build content
content = f"{header}\n\n"
content += f"{get_message('health', lang)}: {health_display}\n\n"
if data.smart_supported:
# Status line
status_color = (
"green" if data.status == "GOOD" else ("red" if data.status == "BAD" else "yellow")
)
status_line = (
f"[bold]{get_message('status', lang)}:[/bold] [{status_color}]{data.status:8}[/{status_color}] | "
f"[bold]{get_message('temperature', lang)}:[/bold] {data.temp:8} | "
f"[bold]{get_message('power_hours', lang)}:[/bold] {data.power_hours:15} | "
f"[bold]{get_message('power_cycles', lang)}:[/bold] {data.power_cycles}"
)
content += f"{status_line}\n\n"
# Critical attributes
content += f"[bold]{get_message('critical_attrs', lang)}:[/bold]\n"
content += f"{get_message('reallocated', lang)}: [red]{data.reallocated}[/red]\n"
content += f"{get_message('pending', lang)}: [red]{data.pending}[/red]\n"
content += f"{get_message('uncorrectable', lang)}: [red]{data.uncorrectable}[/red]\n"
# Additional attributes for detailed view
if data.remaining_lifetime < 100:
content += f" • Remaining Lifetime: [green]{data.remaining_lifetime}%[/green]\n"
if data.media_wearout_indicator < 100:
content += f" • Media Wearout Indicator: [yellow]{data.media_wearout_indicator}%[/yellow]\n"
if data.ssd_life_left < 100 and data.remaining_lifetime == 100:
content += f" • SSD Life Left: [yellow]{data.ssd_life_left}%[/yellow]\n"
if data.host_writes_gb > 0:
content += f" • Host Writes: [cyan]{data.host_writes_gb} GB[/cyan]\n"
if data.crc_errors > 0:
content += f" • CRC Errors: [yellow]{data.crc_errors}[/yellow]\n"
if data.program_fail_count > 0:
content += f" • Program Failures: [red]{data.program_fail_count}[/red]\n"
if data.erase_fail_count > 0:
content += f" • Erase Failures: [red]{data.erase_fail_count}[/red]\n"
if data.command_timeout > 0:
content += f" • Command Timeouts: [yellow]{data.command_timeout}[/yellow]\n"
if data.reallocated_event_count > 0:
content += f" • Realloc Events: [yellow]{data.reallocated_event_count}[/yellow]\n"
if data.reported_uncorrect > 0:
content += f" • Reported Uncorrect: [red]{data.reported_uncorrect}[/red]\n"
# Remove trailing newline if no additional attributes were added
content = content.rstrip('\n')
# Show all SMART attributes if requested
if show_all and data.attrs:
content += "\n\n[bold]All S.M.A.R.T. Attributes:[/bold]\n"
table = Table(box=box.SIMPLE, show_header=True, header_style="bold cyan")
table.add_column("ID", style="cyan")
table.add_column("Name", style="white")
table.add_column("Value", justify="right")
table.add_column("Worst", justify="right")
table.add_column("Threshold", justify="right")
table.add_column("Raw", style="yellow", justify="right")
for attr_id in sorted(data.attrs.keys(), key=lambda x: int(x)):
attr = data.attrs[attr_id]
table.add_row(
attr_id,
attr['name'],
attr['value'],
attr['worst'],
attr['threshold'],
attr['raw']
)
# Render table to string and add to content
from rich.console import Console as RichConsole
from io import StringIO
output = StringIO()
rich_console = RichConsole(file=output, force_terminal=True)
rich_console.print(table)
content += "\n" + output.getvalue()
else:
content += f"[yellow]{get_message('smart_not_supported', lang)}[/yellow]"
# Warnings panel
if data.warnings:
warnings_title = f"[bold red]⚠️ {get_message('warning_reallocated_10', lang).split(':')[0].replace('🟡', '').strip()}:[/bold red]"
warnings_text = "\n".join([f"{w}" for w in data.warnings])
console.print(
Panel(
f"{content}\n\n{warnings_title}\n{warnings_text}",
title=f"📀 {data.disk} - {data.model}",
border_style="red",
box=box.ROUNDED,
)
)
else:
console.print(
Panel(
content,
title=f"📀 {data.disk} - {data.model}",
border_style=color,
box=box.ROUNDED,
)
)
def print_full_smart_data(console: Console, data, lang: str):
"""Print full SMART data in detailed format."""
color = get_health_color(data.health)
icon = get_health_icon(data.health, lang)
health_bar = create_health_bar(data.health)
# Header
console.print(f"\n{'=' * 80}")
console.print(f"[bold cyan]📀 {data.disk} - {data.model} ({data.size})[/bold cyan]")
console.print(f"{'=' * 80}\n")
# Health summary
health_display = f"[{color}] {icon} {health_bar} {data.health}%[/{color}]"
console.print(f"[bold]{get_message('health', lang)}:[/bold] {health_display}")
console.print()
if not data.smart_supported:
console.print(f"[yellow]{get_message('smart_not_supported', lang)}[/yellow]\n")
return
# Status line
status_color = (
"green" if data.status == "GOOD" else ("red" if data.status == "BAD" else "yellow")
)
console.print(
f"[bold]{get_message('status', lang)}:[/bold] [{status_color}]{data.status:8}[/{status_color}] | "
f"[bold]{get_message('temperature', lang)}:[/bold] {data.temp:8} | "
f"[bold]{get_message('power_hours', lang)}:[/bold] {data.power_hours:15} | "
f"[bold]{get_message('power_cycles', lang)}:[/bold] {data.power_cycles}"
)
console.print()
# Critical attributes
console.print(f"[bold red]{get_message('critical_attrs', lang)}:[/bold red]")
console.print(f"{get_message('reallocated', lang)}: [red]{data.reallocated}[/red]")
console.print(f"{get_message('pending', lang)}: [red]{data.pending}[/red]")
console.print(f"{get_message('uncorrectable', lang)}: [red]{data.uncorrectable}[/red]")
console.print()
# Warnings
if data.warnings:
console.print(f"[bold red]⚠️ WARNINGS / ПРЕДУПРЕЖДЕНИЯ:[/bold red]")
for warning in data.warnings:
console.print(f" [red]• {warning}[/red]")
console.print()
# All SMART attributes table
if data.attrs:
console.print(f"[bold cyan]All S.M.A.R.T. Attributes / Все атрибуты S.M.A.R.T.:[/bold cyan]\n")
table = Table(box=box.ROUNDED, show_header=True, header_style="bold cyan")
table.add_column("ID", style="cyan", justify="right")
table.add_column("Name", style="white")
table.add_column("Value", justify="right", style="green")
table.add_column("Worst", justify="right", style="yellow")
table.add_column("Threshold", justify="right", style="red")
table.add_column("Raw", style="magenta", justify="right")
for attr_id in sorted(data.attrs.keys(), key=lambda x: int(x)):
attr = data.attrs[attr_id]
# Highlight critical attributes
if attr_id in ['5', '197', '198', '194', '9', '12']:
row_style = "bold"
else:
row_style = ""
table.add_row(
attr_id,
attr['name'],
attr['value'],
attr['worst'],
attr['threshold'],
attr['raw'],
style=row_style
)
console.print(table)
console.print()
def main():
"""Main entry point for console application."""
parser = argparse.ArgumentParser(
description=get_message('disk_monitor', 'en'),
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
sudo smart-report Show disk health summary
sudo smart-report --all Show all SMART attributes
sudo smart-report -a Show all SMART attributes (short form)
"""
)
parser.add_argument(
'-a', '--all',
action='store_true',
help='Show all S.M.A.R.T. attributes for each disk'
)
args = parser.parse_args()
console = Console()
lang = get_locale()
# Check smartctl
if not check_smartctl():
console.print(f"[bold red]{get_message('smart_not_installed', lang)}[/bold red]")
console.print(f"[green]{get_message('install_command', lang)}[/green]")
sys.exit(1)
# Check root status
if not is_root():
console.print(
f"[bold red]{get_message('run_with_sudo', lang)}:[/bold red] "
f"[green]sudo smart-report[/green]"
)
console.print(
f"[yellow]{get_message('run_with_sudo', lang)}.[/yellow]\n"
)
try:
disks_data = collect_all_disks_data(lang)
except Exception as e:
console.print(f"[bold red]{get_message('error', lang)}:[/bold red] {e}")
sys.exit(1)
if not disks_data:
console.print(f"[bold yellow]{get_message('no_disks_found', lang)}[/bold yellow]")
return
# Header
console.print("\n" + "=" * 75)
console.print(f" [bold]{get_message('disk_monitor', lang)}[/bold]")
console.print(f" {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}")
if args.all:
console.print(f" [cyan]Detailed Mode / Подробный режим[/cyan]")
console.print("=" * 75 + "\n")
# Print each disk
for data in disks_data:
if args.all:
print_full_smart_data(console, data, lang)
else:
print_disk_panel(console, data, lang, show_all=False)
console.print("=" * 75 + "\n")
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
print("\n\nПрограмма прервана / Program interrupted")
except Exception as e:
print(f"❌ Ошибка / Error: {e}")
sys.exit(1)

544
smart_report/core.py Normal file
View File

@@ -0,0 +1,544 @@
"""Core SMART data collection logic with bilingual support."""
import locale
import subprocess
from dataclasses import dataclass, field
from typing import Dict, List, Optional
@dataclass
class DiskSmartData:
"""S.M.A.R.T. data for a single disk."""
disk: str
model: str = "Unknown"
size: str = "Unknown"
status: str = "UNKNOWN"
temp: str = "N/A"
power_hours: str = "N/A"
power_cycles: str = "N/A"
reallocated: int = 0
pending: int = 0
uncorrectable: int = 0
attrs: Dict[str, dict] = field(default_factory=dict)
health: int = 100
warnings: List[str] = field(default_factory=list)
error: Optional[str] = None
smart_supported: bool = True
# Additional SMART attributes for health calculation
ssd_life_left: int = 100
remaining_lifetime: int = 100 # ID 169 - more reliable for some SSDs
media_wearout_indicator: int = 100 # ID 233 - Intel/Crucial
crc_errors: int = 0
program_fail_count: int = 0
erase_fail_count: int = 0
command_timeout: int = 0
spin_retry_count: int = 0
reallocated_event_count: int = 0
reported_uncorrect: int = 0
host_writes_gb: float = 0 # Calculated from attribute 241/233
def get_locale() -> str:
"""Detect system locale and return 'ru' or 'en'."""
try:
loc = locale.getdefaultlocale()[0] or ""
return "ru" if loc.startswith("ru") else "en"
except Exception:
return "en"
MESSAGES = {
"en": {
"smart_not_installed": "❌ smartmontools is not installed!",
"install_command": "Install: sudo pacman -S smartmontools",
"no_disks_found": "❌ No disks found",
"disk_monitor": "DISK HEALTH MONITORING (S.M.A.R.T.)",
"disk": "Disk",
"model": "Model",
"size": "Size",
"health": "Health",
"status": "Status",
"temperature": "Temperature",
"power_hours": "Power-On Hours",
"power_cycles": "Cycles",
"critical_attrs": "Critical Attributes",
"reallocated": "Reallocated Sectors",
"pending": "Pending Sectors",
"uncorrectable": "Uncorrectable Errors",
"smart_status_bad": "🔴 S.M.A.R.T. status: BAD",
"critical_reallocated_500": "🔴 CRITICAL: {0} reallocated sectors! Disk may fail!",
"warning_reallocated_100": "🟠 WARNING: {0} reallocated sectors. Start backup!",
"warning_reallocated_10": "🟡 WARNING: {0} reallocated sectors",
"critical_pending": "🔴 CRITICAL: {0} pending sectors!",
"critical_uncorrectable": "🔴 CRITICAL: {0} uncorrectable errors!",
"smart_not_supported": "S.M.A.R.T.: Not supported",
"running_as_root": "✓ Running as root",
"run_with_sudo": "⚠️ Run with sudo for full access",
"collecting_data": "Collecting data...",
"disks_found": "Found {0} disk(s)",
"error": "Error",
"refresh": "🔄 Refresh",
"disk_health_report": "📊 S.M.A.R.T. Disk Health Report",
# Additional health warnings
"warning_ssd_life": "🟠 SSD life remaining: {0}%",
"warning_crc_errors": "🟡 CRC errors: {0} (check SATA cable)",
"warning_program_fail": "🔴 Program failures: {0}",
"warning_erase_fail": "🔴 Erase failures: {0}",
"warning_command_timeout": "🟡 Command timeouts: {0}",
"warning_spin_retry": "🟡 Spin retry count: {0}",
"warning_reallocated_event": "🟡 Reallocation events: {0}",
"warning_reported_uncorrect": "🔴 Reported uncorrect errors: {0}",
},
"ru": {
"smart_not_installed": "❌ smartmontools не установлен!",
"install_command": "Установите: sudo pacman -S smartmontools",
"no_disks_found": "❌ Диски не найдены",
"disk_monitor": "МОНИТОРИНГ ЗДОРОВЬЯ ДИСКОВ (S.M.A.R.T.)",
"disk": "Диск",
"model": "Модель",
"size": "Размер",
"health": "Здоровье",
"status": "Статус",
"temperature": "Температура",
"power_hours": "Часов работы",
"power_cycles": "Циклов",
"critical_attrs": "Критические атрибуты",
"reallocated": "Переназначенные сектора",
"pending": "Ожидающие сектора",
"uncorrectable": "Неисправимые ошибки",
"smart_status_bad": "🔴 S.M.A.R.T. статус: BAD",
"critical_reallocated_500": "🔴 КРИТИЧНО: {0} переназначенных секторов! Диск может отказать!",
"warning_reallocated_100": "🟠 ВНИМАНИЕ: {0} переназначенных секторов. Начните резервное копирование!",
"warning_reallocated_10": "🟡 ВНИМАНИЕ: {0} переназначенных секторов",
"critical_pending": "🔴 КРИТИЧНО: {0} ожидающих секторов!",
"critical_uncorrectable": "🔴 КРИТИЧНО: {0} неисправимых ошибок!",
"smart_not_supported": "S.M.A.R.T.: Не поддерживается",
"running_as_root": "✓ Запуск от root",
"run_with_sudo": "⚠️ Запустите с sudo для полного доступа",
"collecting_data": "Сбор данных...",
"disks_found": "Найдено дисков: {0}",
"error": "Ошибка",
"refresh": "🔄 Обновить",
"disk_health_report": "📊 Отчет о здоровье дисков (S.M.A.R.T.)",
# Additional health warnings
"warning_ssd_life": "🟠 Остаток ресурса SSD: {0}%",
"warning_crc_errors": "🟡 Ошибки CRC: {0} (проверьте SATA кабель)",
"warning_program_fail": "🔴 Ошибки программирования: {0}",
"warning_erase_fail": "🔴 Ошибки стирания: {0}",
"warning_command_timeout": "🟡 Таймауты команд: {0}",
"warning_spin_retry": "🟡 Повторы раскрутки: {0}",
"warning_reallocated_event": "🟡 События переназначения: {0}",
"warning_reported_uncorrect": "🔴 Сообщённые ошибки: {0}",
},
}
def get_message(key: str, lang: str = None, *args) -> str:
"""Get localized message."""
if lang is None:
lang = get_locale()
msg = MESSAGES.get(lang, MESSAGES["en"]).get(key, MESSAGES["en"].get(key, key))
if args:
return msg.format(*args)
return msg
def check_smartctl() -> bool:
"""Check if smartctl is installed."""
try:
subprocess.run(["which", "smartctl"], capture_output=True, check=True)
return True
except subprocess.CalledProcessError:
return False
def get_disk_list() -> List[str]:
"""Get list of all physical disks (/dev/sda, /dev/nvme0n1, etc.)."""
try:
result = subprocess.run(
["lsblk", "-d", "-n", "-o", "NAME"],
capture_output=True,
text=True,
check=True,
)
return [f"/dev/{disk}" for disk in result.stdout.strip().split("\n") if disk]
except Exception:
return []
def get_disk_info(disk: str) -> tuple:
"""Get disk model and size."""
try:
model = subprocess.run(
["lsblk", "-d", "-n", "-o", "MODEL", disk],
capture_output=True,
text=True,
).stdout.strip() or "Unknown"
size = subprocess.run(
["lsblk", "-d", "-n", "-o", "SIZE", disk],
capture_output=True,
text=True,
).stdout.strip() or "Unknown"
return model, size
except Exception:
return "Unknown", "Unknown"
def parse_smart_data(disk: str) -> Optional[DiskSmartData]:
"""Parse S.M.A.R.T. data for a disk (supports both ATA and NVMe)."""
data = DiskSmartData(disk=disk)
try:
result = subprocess.run(
["sudo", "smartctl", "-a", disk],
capture_output=True,
text=True,
)
output = result.stdout
except Exception as e:
data.error = str(e)
data.smart_supported = False
return data
if not output.strip():
data.smart_supported = False
return data
# Parse status
if "PASSED" in output:
data.status = "GOOD"
elif "FAILED" in output:
data.status = "BAD"
else:
data.status = "UNKNOWN"
# Check if NVMe format
is_nvme = "NVMe" in output or "SMART overall-health" not in output
# Parse attributes (ATA format)
for line in output.split("\n"):
parts = line.split()
if len(parts) < 10:
# Try NVMe format parsing
if is_nvme:
# NVMe: "Temperature: 35 Celsius"
if "Temperature:" in line:
try:
temp_val = line.split(":")[1].strip().split()[0]
data.temp = f"{temp_val}°C"
except (IndexError, ValueError):
pass
# NVMe: "Power On Hours: 1234"
if "Power On Hours:" in line:
try:
hours = int(line.split(":")[1].strip())
data.power_hours = f"{hours}h ({hours // 24}d)"
except (IndexError, ValueError):
pass
# NVMe: "Power Cycle Count: 5678"
if "Power Cycle Count:" in line:
try:
data.power_cycles = line.split(":")[1].strip()
except (IndexError, ValueError):
pass
# NVMe: "Media and Data Integrity Errors: 0"
if "Media and Data Integrity Errors:" in line:
try:
data.uncorrectable = int(line.split(":")[1].strip())
except (IndexError, ValueError):
pass
continue
# ATA format parsing
# Temperature (ID 194)
if parts[0] == "194" or "Temperature_Celsius" in line:
try:
data.temp = f"{parts[9]}°C"
except (IndexError, ValueError):
pass
# Power-on hours (ID 9)
if parts[0] == "9" or "Power_On_Hours" in line:
try:
hours = int(parts[9])
data.power_hours = f"{hours}h ({hours // 24}d)"
except (IndexError, ValueError):
pass
# Power cycle count (ID 12)
if parts[0] == "12" or "Power_Cycle_Count" in line:
try:
data.power_cycles = parts[9]
except (IndexError, ValueError):
pass
# Reallocated sectors (ID 5)
if parts[0] == "5" or "Reallocated_Sector_Ct" in line:
try:
data.reallocated = int(parts[9])
except (IndexError, ValueError):
pass
# Current pending sectors (ID 197)
if parts[0] == "197" or "Current_Pending_Sect" in line:
try:
data.pending = int(parts[9])
except (IndexError, ValueError):
pass
# Offline uncorrectable (ID 198)
if parts[0] == "198" or "Offline_Uncorrectable" in line:
try:
data.uncorrectable = int(parts[9])
except (IndexError, ValueError):
pass
# SSD Life Left (ID 231) - crucial for SSD health
if parts[0] == "231" or "SSD_Life_Left" in line:
try:
data.ssd_life_left = int(parts[9])
except (IndexError, ValueError):
pass
# Remaining Lifetime Percent (ID 169) - more reliable for some SSDs
# NOTE: Use normalized VALUE (parts[3]), not raw!
if parts[0] == "169" and "Remaining_Lifetime" in line:
try:
data.remaining_lifetime = int(parts[3]) # Normalized value 0-100
except (IndexError, ValueError):
pass
# Media Wearout Indicator (ID 233) - Intel/Crucial/WD
# NOTE: Use normalized VALUE (parts[3]), not raw!
if parts[0] == "233" and ("Media_Wearout" in line or "Wear_Leveling" in line):
try:
data.media_wearout_indicator = int(parts[3]) # Normalized value 0-100
except (IndexError, ValueError):
pass
# Host Writes (ID 241) - for calculating actual write volume
if parts[0] == "241" or "Host_Writes" in line or "Lifetime_Writes" in line:
try:
raw_value = int(parts[9])
# Convert from 32MiB blocks to GB
data.host_writes_gb = round(raw_value * 32 / 1024, 1)
except (IndexError, ValueError):
pass
# CRC Error Count (ID 199) - indicates cable/connection issues
if parts[0] == "199" or "CRC_Error_Count" in line or "UDMA_CRC_Error" in line:
try:
data.crc_errors = int(parts[9])
except (IndexError, ValueError):
pass
# Program Fail Count (ID 181)
if parts[0] == "181" or "Program_Fail_Count" in line:
try:
data.program_fail_count = int(parts[9])
except (IndexError, ValueError):
pass
# Erase Fail Count (ID 172 or 182)
if parts[0] in ["172", "182"] or "Erase_Fail_Count" in line:
try:
data.erase_fail_count = int(parts[9])
except (IndexError, ValueError):
pass
# Command Timeout (ID 188)
if parts[0] == "188" or "Command_Timeout" in line:
try:
data.command_timeout = int(parts[9])
except (IndexError, ValueError):
pass
# Spin Retry Count (ID 10)
if parts[0] == "10" or "Spin_Retry_Count" in line:
try:
data.spin_retry_count = int(parts[9])
except (IndexError, ValueError):
pass
# Reallocated Event Count (ID 196)
if parts[0] == "196" or "Reallocated_Event_Count" in line:
try:
data.reallocated_event_count = int(parts[9])
except (IndexError, ValueError):
pass
# Reported Uncorrect Errors (ID 187)
if parts[0] == "187" or "Reported_Uncorrect" in line:
try:
data.reported_uncorrect = int(parts[9])
except (IndexError, ValueError):
pass
# Store all attributes
if parts and parts[0].isdigit() and len(parts) >= 10:
try:
attr_id = parts[0]
attr_name = parts[1] if len(parts) > 1 else "Unknown"
data.attrs[attr_id] = {
"name": attr_name,
"value": parts[3],
"worst": parts[4],
"threshold": parts[5],
"raw": parts[9],
}
except (IndexError, ValueError):
pass
return data
def calculate_health(data: DiskSmartData, lang: str = None) -> tuple:
"""Calculate disk health percentage and warnings based on multiple SMART attributes."""
if lang is None:
lang = get_locale()
if data.error or not data.smart_supported:
return 50, []
if data.status == "BAD":
return 5, [get_message("smart_status_bad", lang)]
health = 100
warnings = []
# === SSD WEAR INDICATORS - use the most reliable one ===
# Priority: remaining_lifetime (169) > media_wearout (233) > ssd_life_left (231)
# Some manufacturers (ADATA, Silicon Motion) have unreliable ID 231
ssd_wear_values = []
# ID 169 - Remaining Lifetime (more reliable for ADATA, Silicon Motion)
if data.remaining_lifetime < 100 and data.remaining_lifetime > 0:
ssd_wear_values.append(("Remaining Lifetime (169)", data.remaining_lifetime))
# ID 233 - Media Wearout Indicator (Intel, Crucial, WD)
if data.media_wearout_indicator < 100 and data.media_wearout_indicator > 0:
ssd_wear_values.append(("Media Wearout (233)", data.media_wearout_indicator))
# ID 231 - SSD Life Left (Kingston, Samsung, some others)
# Only use if no other indicators or if consistent with them
if data.ssd_life_left < 100 and data.ssd_life_left > 0:
ssd_wear_values.append(("SSD Life Left (231)", data.ssd_life_left))
# Choose the most reliable indicator
if ssd_wear_values:
# Prefer ID 169 if available (most reliable)
preferred = next((v for n, v in ssd_wear_values if "169" in n), None)
if preferred is not None:
health = min(health, preferred)
if preferred < 50:
warnings.append(get_message("warning_ssd_life", lang, preferred))
else:
# Use minimum of available values
min_wear = min(v for _, v in ssd_wear_values)
health = min(health, min_wear)
if min_wear < 50:
warnings.append(get_message("warning_ssd_life", lang, min_wear))
# === REALLOCATED SECTORS (ID 5) ===
if data.reallocated > 0:
if data.reallocated > 500:
penalty = min(80, data.reallocated * 0.5)
health -= penalty
warnings.append(get_message("critical_reallocated_500", lang, data.reallocated))
elif data.reallocated > 100:
penalty = min(70, data.reallocated * 0.3)
health -= penalty
warnings.append(get_message("warning_reallocated_100", lang, data.reallocated))
elif data.reallocated > 10:
penalty = data.reallocated * 0.2
health -= penalty
warnings.append(get_message("warning_reallocated_10", lang, data.reallocated))
else:
health -= data.reallocated * 0.1
# === REALLOCATION EVENTS (ID 196) ===
if data.reallocated_event_count > 0:
if data.reallocated_event_count > 100:
health -= min(40, data.reallocated_event_count * 0.4)
warnings.append(get_message("warning_reallocated_event", lang, data.reallocated_event_count))
elif data.reallocated_event_count > 0:
health -= min(20, data.reallocated_event_count * 0.2)
# === PENDING SECTORS (ID 197) ===
if data.pending > 0:
health -= min(70, data.pending * 2)
warnings.append(get_message("critical_pending", lang, data.pending))
# === UNCORRECTABLE ERRORS (ID 198) ===
if data.uncorrectable > 0:
health -= min(80, data.uncorrectable * 5)
warnings.append(get_message("critical_uncorrectable", lang, data.uncorrectable))
# === REPORTED UNCORRECT ERRORS (ID 187) ===
if data.reported_uncorrect > 0:
health -= min(60, data.reported_uncorrect * 5)
warnings.append(get_message("warning_reported_uncorrect", lang, data.reported_uncorrect))
# === PROGRAM FAIL COUNT (ID 181) ===
if data.program_fail_count > 0:
health -= min(50, data.program_fail_count * 10)
warnings.append(get_message("warning_program_fail", lang, data.program_fail_count))
# === ERASE FAIL COUNT (ID 172/182) ===
if data.erase_fail_count > 0:
health -= min(50, data.erase_fail_count * 10)
warnings.append(get_message("warning_erase_fail", lang, data.erase_fail_count))
# === CRC ERRORS (ID 199) - Usually cable issue ===
if data.crc_errors > 0:
if data.crc_errors > 100:
health -= min(30, data.crc_errors * 0.3)
elif data.crc_errors > 0:
health -= min(15, data.crc_errors * 0.15)
warnings.append(get_message("warning_crc_errors", lang, data.crc_errors))
# === COMMAND TIMEOUT (ID 188) ===
if data.command_timeout > 0:
health -= min(25, data.command_timeout * 2)
warnings.append(get_message("warning_command_timeout", lang, data.command_timeout))
# === SPIN RETRY COUNT (ID 10) - For HDDs ===
if data.spin_retry_count > 0:
health -= min(30, data.spin_retry_count * 5)
warnings.append(get_message("warning_spin_retry", lang, data.spin_retry_count))
data.health = max(5, int(health))
data.warnings = warnings
return data.health, warnings
def collect_all_disks_data(lang: str = None) -> List[DiskSmartData]:
"""Collect S.M.A.R.T. data for all disks."""
if lang is None:
lang = get_locale()
disks = get_disk_list()
results = []
for disk in disks:
model, size = get_disk_info(disk)
smart_data = parse_smart_data(disk)
if smart_data:
smart_data.model = model
smart_data.size = size
calculate_health(smart_data, lang)
results.append(smart_data)
return results
def is_root() -> bool:
"""Check if running as root."""
try:
result = subprocess.run(["id", "-u"], capture_output=True, text=True)
return result.stdout.strip() == "0"
except Exception:
return False

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