commit 19b79a4e132547ad46d044e557be08401ec993ec Author: kilyabin <65072190+kilyabin@users.noreply.github.com> Date: Sun Mar 15 00:15:21 2026 +0400 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 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..68b8314 --- /dev/null +++ b/.github/workflows/build.yml @@ -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 }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a97f727 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7e2b918 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..8a39990 --- /dev/null +++ b/README.md @@ -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 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..90084e8 --- /dev/null +++ b/pyproject.toml @@ -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*"] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d11d49c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +# Core dependencies +rich>=13.0.0 + +# GUI dependencies (optional) +PyQt6>=6.4.0 + +# Build dependencies +PyInstaller>=6.0.0 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..147eded --- /dev/null +++ b/setup.py @@ -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", + ], + }, +) diff --git a/smart-info-2.py b/smart-info-2.py new file mode 100644 index 0000000..06bc76d --- /dev/null +++ b/smart-info-2.py @@ -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}") diff --git a/smart_report/__init__.py b/smart_report/__init__.py new file mode 100644 index 0000000..35d1317 --- /dev/null +++ b/smart_report/__init__.py @@ -0,0 +1,4 @@ +"""Smart Report - S.M.A.R.T. disk health monitoring tool.""" + +__version__ = "1.0.0" +__author__ = "kilyabin" diff --git a/smart_report/cli.py b/smart_report/cli.py new file mode 100644 index 0000000..32d1d1b --- /dev/null +++ b/smart_report/cli.py @@ -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) diff --git a/smart_report/core.py b/smart_report/core.py new file mode 100644 index 0000000..f7661e2 --- /dev/null +++ b/smart_report/core.py @@ -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 diff --git a/smart_report/gui.py b/smart_report/gui.py new file mode 100644 index 0000000..6b74bc4 --- /dev/null +++ b/smart_report/gui.py @@ -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"📀 {data.disk} - {data.model}")) + header_layout.addStretch() + + # Health bar + health_bar = HealthBarWidget(data.health) + header_layout.addWidget(health_bar) + + layout.addLayout(header_layout) + + # Info grid + info_layout = QHBoxLayout() + + if data.smart_supported: + # Status + status_color = ( + "#27ae60" if data.status == "GOOD" else ("#e74c3c" if data.status == "BAD" else "#f39c12") + ) + status_label = QLabel(f"{get_message('status', self.lang)}: {data.status}") + status_label.setStyleSheet(f"color: {status_color}; font-weight: bold;") + info_layout.addWidget(status_label) + + # Temperature + info_layout.addWidget(QLabel(f"{get_message('temperature', self.lang)}: {data.temp}")) + + # Power hours + info_layout.addWidget(QLabel(f"{get_message('power_hours', self.lang)}: {data.power_hours}")) + + # Power cycles + info_layout.addWidget(QLabel(f"{get_message('power_cycles', self.lang)}: {data.power_cycles}")) + + info_layout.addStretch() + + layout.addLayout(info_layout) + + # Critical attributes + attrs_layout = QHBoxLayout() + attrs_layout.addWidget( + QLabel(f"• {get_message('reallocated', self.lang)}: {data.reallocated}") + ) + attrs_layout.addWidget( + QLabel(f"• {get_message('pending', self.lang)}: {data.pending}") + ) + attrs_layout.addWidget( + QLabel(f"• {get_message('uncorrectable', self.lang)}: {data.uncorrectable}") + ) + attrs_layout.addStretch() + layout.addLayout(attrs_layout) + + # Warnings + if data.warnings: + warnings_text = "
".join([f"⚠️ {w}" for w in data.warnings]) + warnings_label = QLabel(f"{warnings_text}") + warnings_label.setWordWrap(True) + warnings_label.setStyleSheet("background-color: #ffeaea; padding: 10px; border-radius: 4px;") + layout.addWidget(warnings_label) + else: + not_supported = QLabel(f"{get_message('smart_not_supported', self.lang)}") + not_supported.setStyleSheet("color: #999;") + layout.addWidget(not_supported) + + return panel + + def create_detailed_panel(self, data: DiskSmartData) -> QFrame: + """Create a detailed panel widget for a single disk.""" + panel = QFrame() + panel.setFrameStyle(QFrame.Shape.StyledPanel) + panel.setStyleSheet( + "QFrame { background-color: white; border: 1px solid #ddd; " + "border-radius: 8px; padding: 15px; }" + ) + + layout = QVBoxLayout(panel) + layout.setSpacing(15) + + # Header + header_layout = QHBoxLayout() + header_layout.addWidget(QLabel(f"📀 {data.disk}")) + header_layout.addWidget(QLabel(f"Model: {data.model}")) + header_layout.addWidget(QLabel(f"Size: {data.size}")) + header_layout.addStretch() + + # Health indicator + health_label = QLabel(f"{get_message('health', self.lang)}: {data.health}%") + health_label.setFont(QFont("Arial", 14, QFont.Weight.Bold)) + if data.health >= 85: + health_label.setStyleSheet("color: #27ae60;") + elif data.health >= 60: + health_label.setStyleSheet("color: #f39c12;") + elif data.health >= 30: + health_label.setStyleSheet("color: #e74c3c;") + else: + health_label.setStyleSheet("color: #c0392b;") + header_layout.addWidget(health_label) + + layout.addLayout(header_layout) + + if not data.smart_supported: + not_supported = QLabel(f"{get_message('smart_not_supported', self.lang)}") + not_supported.setStyleSheet("color: #999; font-size: 14px;") + layout.addWidget(not_supported) + return panel + + # Info section + info_frame = QFrame() + info_frame.setStyleSheet("background-color: #f9f9f9; border-radius: 4px; padding: 10px;") + info_layout = QHBoxLayout(info_frame) + + status_color = ( + "#27ae60" if data.status == "GOOD" else ("#e74c3c" if data.status == "BAD" else "#f39c12") + ) + info_layout.addWidget(QLabel(f"Status: {data.status}")) + info_layout.addWidget(QLabel(f"Temperature: {data.temp}")) + info_layout.addWidget(QLabel(f"Power-On Hours: {data.power_hours}")) + info_layout.addWidget(QLabel(f"Power Cycles: {data.power_cycles}")) + info_layout.addStretch() + + layout.addWidget(info_frame) + + # Critical attributes section + critical_frame = QFrame() + critical_frame.setStyleSheet("background-color: #fff5f5; border: 1px solid #ffcccc; border-radius: 4px; padding: 10px;") + critical_layout = QHBoxLayout(critical_frame) + critical_layout.addWidget(QLabel(f"⚠️ {get_message('critical_attrs', self.lang)}:")) + critical_layout.addWidget(QLabel(f"{get_message('reallocated', self.lang)}: {data.reallocated}")) + critical_layout.addWidget(QLabel(f"{get_message('pending', self.lang)}: {data.pending}")) + critical_layout.addWidget(QLabel(f"{get_message('uncorrectable', self.lang)}: {data.uncorrectable}")) + + # Additional attributes + if data.remaining_lifetime < 100: + critical_layout.addWidget(QLabel(f"Rem. Life: {data.remaining_lifetime}%")) + if data.media_wearout_indicator < 100: + critical_layout.addWidget(QLabel(f"Wearout: {data.media_wearout_indicator}%")) + if data.ssd_life_left < 100 and data.remaining_lifetime == 100: + critical_layout.addWidget(QLabel(f"SSD Life: {data.ssd_life_left}%")) + if data.host_writes_gb > 0: + critical_layout.addWidget(QLabel(f"Writes: {data.host_writes_gb}GB")) + if data.crc_errors > 0: + critical_layout.addWidget(QLabel(f"CRC: {data.crc_errors}")) + if data.program_fail_count > 0: + critical_layout.addWidget(QLabel(f"Prog Fail: {data.program_fail_count}")) + if data.erase_fail_count > 0: + critical_layout.addWidget(QLabel(f"Erase Fail: {data.erase_fail_count}")) + if data.command_timeout > 0: + critical_layout.addWidget(QLabel(f"Timeouts: {data.command_timeout}")) + if data.reallocated_event_count > 0: + critical_layout.addWidget(QLabel(f"Realloc Ev: {data.reallocated_event_count}")) + if data.reported_uncorrect > 0: + critical_layout.addWidget(QLabel(f"Uncorrect: {data.reported_uncorrect}")) + + critical_layout.addStretch() + layout.addWidget(critical_frame) + + # Warnings + if data.warnings: + warnings_frame = QFrame() + warnings_frame.setStyleSheet("background-color: #ffeaea; border: 1px solid #e74c3c; border-radius: 4px; padding: 10px;") + warnings_layout = QVBoxLayout(warnings_frame) + warnings_layout.addWidget(QLabel("⚠️ WARNINGS / ПРЕДУПРЕЖДЕНИЯ:")) + for warning in data.warnings: + warnings_layout.addWidget(QLabel(f"• {warning}")) + layout.addWidget(warnings_frame) + + # SMART attributes tree + layout.addWidget(QLabel("All S.M.A.R.T. Attributes / Все атрибуты S.M.A.R.T.:")) + + attrs_tree = SmartAttributesTree(data, self.lang) + attrs_tree.setMinimumHeight(300) + attrs_tree.setStyleSheet(""" + QTreeWidget { + border: 1px solid #ddd; + border-radius: 4px; + background-color: white; + gridline-color: #eee; + } + QTreeWidget::item { + padding: 4px; + } + QTreeWidget::item:selected { + background-color: #3498db; + color: white; + } + QHeaderView::section { + background-color: #f5f5f5; + padding: 8px; + border: none; + border-bottom: 1px solid #ddd; + font-weight: bold; + } + """) + layout.addWidget(attrs_tree) + + return panel + + def on_error(self, error_msg: str): + """Handle error.""" + QMessageBox.critical( + self, get_message("error", self.lang), f"{get_message('error', self.lang)}:\n{error_msg}" + ) + self.status_label.setText("") + self.refresh_btn.setEnabled(True) + + def closeEvent(self, event): + """Handle window close.""" + if self.worker and self.worker.isRunning(): + self.worker.terminate() + self.worker.wait() + event.accept() + + +def main(): + """Main entry point.""" + app = QApplication(sys.argv) + app.setStyle("Fusion") + + # Set application palette + palette = app.palette() + palette.setColor(palette.ColorRole.Window, QColor(245, 245, 245)) + app.setPalette(palette) + + window = DiskHealthWindow() + window.show() + + sys.exit(app.exec()) + + +if __name__ == "__main__": + main()