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