Files
kilyabin 19b79a4e13 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>
2026-03-15 00:17:01 +04:00

329 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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)