- 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>
329 lines
12 KiB
Python
329 lines
12 KiB
Python
#!/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)
|