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:
196
.github/workflows/build.yml
vendored
Normal file
196
.github/workflows/build.yml
vendored
Normal 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
145
.gitignore
vendored
Normal 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
21
LICENSE
Normal 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
185
README.md
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
# Smart Report 📊
|
||||||
|
|
||||||
|
S.M.A.R.T. disk health monitoring tool with both CLI and GUI interfaces.
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
## 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
52
pyproject.toml
Normal 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
8
requirements.txt
Normal 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
47
setup.py
Normal 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
244
smart-info-2.py
Normal 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
4
smart_report/__init__.py
Normal 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
328
smart_report/cli.py
Normal 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
544
smart_report/core.py
Normal 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
559
smart_report/gui.py
Normal 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()
|
||||||
Reference in New Issue
Block a user