Add hardware health and uptime reports
This commit is contained in:
1 parent
25162b8a45
commit
4b9a65df75
5 files changed
+547
-3
No files matched your search
@@ -22,7 +22,8 @@ strixscripts/
|
|||||||
│ └── erase-storage.sh презапис на дял или Secure Erase/Sanitize на диск
|
│ └── erase-storage.sh презапис на дял или Secure Erase/Sanitize на диск
|
||||||
├── System/
|
├── System/
|
||||||
│ ├── network-diagnostic.sh подробна мрежова диагностика, топ 10 Wi-Fi мрежи и export
|
│ ├── network-diagnostic.sh подробна мрежова диагностика, топ 10 Wi-Fi мрежи и export
|
||||||
│ └── system-report.sh моментен системен отчет с TXT/JSON/HTML export
|
│ ├── system-report.sh моментен системен отчет с TXT/HTML export
|
||||||
|
│ └── check-hardware-health.sh SMART здраве на дискове и температури на компонентите
|
||||||
├── Virtualisation/
|
├── Virtualisation/
|
||||||
│ └── virtualisation-check.sh проверка на инсталирани виртуализационни решения и инстанции
|
│ └── virtualisation-check.sh проверка на инсталирани виртуализационни решения и инстанции
|
||||||
├── Web/
|
├── Web/
|
||||||
@@ -64,6 +65,8 @@ strixscripts/
|
|||||||
- [`system-report.sh`](./System/system-report.md) — събира моментно състояние
|
- [`system-report.sh`](./System/system-report.md) — събира моментно състояние
|
||||||
на системата, като групира Ethernet, Wi-Fi и виртуалните интерфейси, и
|
на системата, като групира Ethernet, Wi-Fi и виртуалните интерфейси, и
|
||||||
предлага отчет във формат TXT, JSON или HTML.
|
предлага отчет във формат TXT, JSON или HTML.
|
||||||
|
- [`check-hardware-health.sh`](./System/check-hardware-health.md) — проверява
|
||||||
|
SMART състоянието и типа на дисковете и предупреждава за високи температури.
|
||||||
|
|
||||||
### Virtualisation/
|
### Virtualisation/
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# check-hardware-health.sh
|
||||||
|
|
||||||
|
Проверява SMART състоянието и типа на физическите дискове, както и температурите на наличните хардуерни датчици. Проблемите се маркират с червен текст в терминала и с червен статус в HTML отчета.
|
||||||
|
|
||||||
|
## Какво прави
|
||||||
|
|
||||||
|
Скриптът показва времето от последното стартиране на системата, открива дисковете чрез `lsblk`, определя типа им по интерфейса и rotational флага (например PATA, SATA HDD/SSD и NVMe SSD), след което чете SMART данните чрез `smartctl`. При неуспешен SMART self-assessment или отчетен NVMe critical warning дискът се маркира като проблемен. Температурите се събират чрез `sensors -j`; за всеки датчик се използват предоставените от него прагове, а ако липсват — предпазни прагове по тип компонент. SMART температурата също се включва, когато е налична. При системи с батерия се показват зарядът, състоянието, циклите и здравето, изчислено от пълния спрямо проектния капацитет, когато тези стойности са налични.
|
||||||
|
|
||||||
|
Отчетът се показва в терминала и може да се запише като TXT, HTML или JSON. За терминалния изход се използва ANSI червено, когато stdout е терминал и `NO_COLOR` не е зададен; TXT файлът съдържа ясни статуси, HTML отчетът оцветява проблемните редове в червено, а JSON файлът съдържа структурираните данни от проверката.
|
||||||
|
|
||||||
|
При стартиране чрез `sudo` интерактивният път по подразбиране сочи към home директорията на извикалия потребител. Записаният файл получава неговите UID/GID, така че да остане достъпен за него след приключване на скрипта.
|
||||||
|
|
||||||
|
## Изисквания
|
||||||
|
|
||||||
|
- Linux, Bash, Python 3 и `lsblk` (задължителни)
|
||||||
|
- `smartmontools` (`smartctl`) — незадължителен; без него SMART е „непроверено“
|
||||||
|
- `lm-sensors` (`sensors`) — незадължителен; без него температурите от тези датчици не са налични
|
||||||
|
- SMART достъпът до някои устройства може да изисква root права или подходящи права за `/dev`.
|
||||||
|
- При стартиране без root права скриптът предупреждава, че SMART данните може да са непълни, и иска потвърждение преди проверките.
|
||||||
|
|
||||||
|
## Употреба
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./System/check-hardware-health.sh [OPTIONS]
|
||||||
|
```
|
||||||
|
|
||||||
|
| Опция | Описание |
|
||||||
|
|-------|----------|
|
||||||
|
| `-o`, `--output PATH` | Записва директно във файл; без тази опция предлага интерактивен запис |
|
||||||
|
| `-f`, `--format FORMAT` | Формат `txt`, `html` или `json` |
|
||||||
|
| `--force` | Позволява презапис на съществуващ файл без потвърждение |
|
||||||
|
| `-h`, `--help` | Показва помощта и приключва |
|
||||||
|
|
||||||
|
## Примери
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Проверява устройствата и предлага интерактивно записване
|
||||||
|
./System/check-hardware-health.sh
|
||||||
|
|
||||||
|
# Записва HTML отчет
|
||||||
|
./System/check-hardware-health.sh --output "$HOME/hardware-health.html"
|
||||||
|
|
||||||
|
# Записва HTML отчет и разрешава презапис
|
||||||
|
./System/check-hardware-health.sh --output /tmp/hardware-health.html --format html --force
|
||||||
|
|
||||||
|
# Записва структуриран JSON отчет; форматът се разпознава и по разширението
|
||||||
|
./System/check-hardware-health.sh --output "$HOME/hardware-health.json"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Ограничения
|
||||||
|
|
||||||
|
Показват се само датчици, SMART атрибути и батерийни полета, предоставени от kernel, драйвера и устройството. Липсващ резултат не доказва, че компонентът е изправен. Батерийното здраве се оценява спрямо капацитетите full/design: под 80% е предупреждение, под 60% е критично; някои драйвери не предоставят цикли или капацитети. Прагът за температурно предупреждение обичайно идва от `sensors`; при липса на праг се използват 60 C за дискове, 85 C за CPU/GPU и 80 C за други компоненти. Това са общи предпазни стойности, не заместител на спецификацията на производителя. „PATA“ се разпознава само когато устройството е представено с традиционно `/dev/hd*` име без transport метаданни; при двусмислени данни отчетът използва обобщения тип от `lsblk`.
|
||||||
Executable
+474
@@ -0,0 +1,474 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# check-hardware-health.sh — report disk SMART health and hardware temperatures.
|
||||||
|
# Full documentation: see check-hardware-health.md in this same folder.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
# shellcheck source=lib/common.sh
|
||||||
|
source "${SCRIPT_DIR}/../lib/common.sh"
|
||||||
|
|
||||||
|
SCRIPT_HELP=$(cat <<'HELP'
|
||||||
|
Usage: ./System/check-hardware-health.sh [OPTIONS]
|
||||||
|
|
||||||
|
Checks physical disks and their SMART health, reports device/media type,
|
||||||
|
and reads component temperatures from lm-sensors when available. High
|
||||||
|
temperatures and detected disk problems are highlighted in red in the report.
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-o, --output PATH Save the report to PATH; otherwise ask interactively
|
||||||
|
-f, --format FORMAT Report format: txt, html, or json
|
||||||
|
--force Overwrite an existing output file without prompting
|
||||||
|
-h, --help Show this help message and exit
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
./System/check-hardware-health.sh
|
||||||
|
./System/check-hardware-health.sh --output "$HOME/hardware-health.html"
|
||||||
|
./System/check-hardware-health.sh --output "$HOME/hardware-health.json"
|
||||||
|
HELP
|
||||||
|
)
|
||||||
|
|
||||||
|
common::show_help_if_requested "$@"
|
||||||
|
|
||||||
|
OUTPUT_PATH=""
|
||||||
|
OUTPUT_FORMAT=""
|
||||||
|
FORCE=0
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
-o|--output)
|
||||||
|
[[ $# -ge 2 ]] || common::die "$1 requires a path"
|
||||||
|
OUTPUT_PATH="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-f|--format)
|
||||||
|
[[ $# -ge 2 ]] || common::die "$1 requires txt, html, or json"
|
||||||
|
OUTPUT_FORMAT="${2,,}"
|
||||||
|
case "$OUTPUT_FORMAT" in
|
||||||
|
txt|html|json) ;;
|
||||||
|
*) common::die "unsupported report format: $2 (use txt, html, or json)" ;;
|
||||||
|
esac
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--force)
|
||||||
|
FORCE=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
*) common::die "unknown argument: $1" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
common::require_cmd python3
|
||||||
|
common::require_cmd lsblk
|
||||||
|
|
||||||
|
if [[ "$EUID" -ne 0 ]]; then
|
||||||
|
common::log "Warning: without root privileges, SMART data may be unavailable and not all checks will run."
|
||||||
|
common::confirm "Continue without root privileges?" || common::die "Hardware health check cancelled." 130
|
||||||
|
fi
|
||||||
|
|
||||||
|
python3 -c "$(cat <<'PYCODE'
|
||||||
|
import datetime
|
||||||
|
import html
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
import pwd
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
|
||||||
|
def run(command):
|
||||||
|
if shutil.which(command[0]) is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
result = subprocess.run(command, capture_output=True, text=True, timeout=15)
|
||||||
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
|
return None
|
||||||
|
return result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def command_json(command):
|
||||||
|
output = run(command)
|
||||||
|
try:
|
||||||
|
return json.loads(output) if output else None
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def uptime_info():
|
||||||
|
try:
|
||||||
|
seconds = int(float(pathlib.Path("/proc/uptime").read_text(encoding="ascii").split()[0]))
|
||||||
|
except (OSError, ValueError, IndexError):
|
||||||
|
return {"seconds": None, "formatted": "Unavailable"}
|
||||||
|
days, remainder = divmod(seconds, 86400)
|
||||||
|
hours, remainder = divmod(remainder, 3600)
|
||||||
|
minutes = remainder // 60
|
||||||
|
return {"seconds": seconds, "formatted": f"{days}d {hours}h {minutes}m"}
|
||||||
|
|
||||||
|
|
||||||
|
def disk_kind(device):
|
||||||
|
name = (device.get("kname") or device.get("name") or "").lower()
|
||||||
|
transport = (device.get("tran") or "").lower()
|
||||||
|
media = "HDD" if device.get("rota") is True else "SSD" if device.get("rota") is False else "storage"
|
||||||
|
if name.startswith("nvme") or transport == "nvme":
|
||||||
|
return f"NVMe {media}"
|
||||||
|
if name.startswith("/dev/hd") and not transport:
|
||||||
|
return f"PATA {media}"
|
||||||
|
if transport == "sata":
|
||||||
|
return f"SATA {media}"
|
||||||
|
return f"{transport.upper()} {media}" if transport else media
|
||||||
|
|
||||||
|
|
||||||
|
def smart_check(path):
|
||||||
|
if shutil.which("smartctl") is None:
|
||||||
|
return {"status": "unavailable", "detail": "smartctl is not installed", "temperature": None}
|
||||||
|
output = run(["smartctl", "--json", "--all", path])
|
||||||
|
try:
|
||||||
|
data = json.loads(output) if output else None
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
data = None
|
||||||
|
if data is None:
|
||||||
|
return {"status": "unknown", "detail": "SMART data unavailable; device may require elevated privileges", "temperature": None}
|
||||||
|
|
||||||
|
failures = []
|
||||||
|
passed = data.get("smart_status", {}).get("passed")
|
||||||
|
if passed is False:
|
||||||
|
failures.append("SMART overall-health self-assessment failed")
|
||||||
|
nvme = data.get("nvme_smart_health_information_log", {})
|
||||||
|
warning = nvme.get("critical_warning")
|
||||||
|
if isinstance(warning, int) and warning:
|
||||||
|
failures.append(f"NVMe critical warning: {warning}")
|
||||||
|
for attribute in data.get("ata_smart_attributes", {}).get("table", []):
|
||||||
|
flags = attribute.get("flags", {})
|
||||||
|
if flags.get("prefailure") and attribute.get("when_failed") not in (None, "", "-", "Never"):
|
||||||
|
failures.append(f"SMART attribute {attribute.get('name', attribute.get('id', 'unknown'))} failed")
|
||||||
|
temperature = data.get("temperature", {}).get("current", nvme.get("temperature"))
|
||||||
|
try:
|
||||||
|
temperature = float(temperature) if temperature is not None else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
temperature = None
|
||||||
|
detail = "; ".join(failures) if failures else "SMART health passed" if passed is True else "SMART data has no overall health result"
|
||||||
|
return {"status": "failed" if failures else "passed" if passed is True else "unknown", "detail": detail, "temperature": temperature}
|
||||||
|
|
||||||
|
|
||||||
|
def collect_disks():
|
||||||
|
data = command_json(["lsblk", "--json", "--bytes", "--paths", "--output", "NAME,KNAME,PATH,TYPE,ROTA,TRAN,MODEL"])
|
||||||
|
if not data:
|
||||||
|
return [], "Disk inventory unavailable."
|
||||||
|
disks = []
|
||||||
|
for device in data.get("blockdevices", []):
|
||||||
|
if device.get("type") != "disk":
|
||||||
|
continue
|
||||||
|
name = (device.get("kname") or device.get("name") or "").lower()
|
||||||
|
if name.startswith(("loop", "ram", "zram")):
|
||||||
|
continue
|
||||||
|
path = device.get("path") or device.get("name")
|
||||||
|
if path:
|
||||||
|
disks.append({"device": path, "model": (device.get("model") or "Unknown model").strip(), "kind": disk_kind(device), "smart": smart_check(path)})
|
||||||
|
return disks, None
|
||||||
|
|
||||||
|
|
||||||
|
def sysfs_text(path):
|
||||||
|
try:
|
||||||
|
return path.read_text(encoding="utf-8").strip()
|
||||||
|
except (OSError, UnicodeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def numeric_value(value):
|
||||||
|
try:
|
||||||
|
return float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def collect_batteries():
|
||||||
|
batteries = []
|
||||||
|
for device in sorted(pathlib.Path("/sys/class/power_supply").glob("BAT*")):
|
||||||
|
if sysfs_text(device / "present") == "0":
|
||||||
|
continue
|
||||||
|
energy_now = numeric_value(sysfs_text(device / "energy_now"))
|
||||||
|
energy_full = numeric_value(sysfs_text(device / "energy_full"))
|
||||||
|
energy_design = numeric_value(sysfs_text(device / "energy_full_design"))
|
||||||
|
charge_now = numeric_value(sysfs_text(device / "charge_now"))
|
||||||
|
charge_full = numeric_value(sysfs_text(device / "charge_full"))
|
||||||
|
charge_design = numeric_value(sysfs_text(device / "charge_full_design"))
|
||||||
|
|
||||||
|
if energy_full is not None or energy_design is not None or energy_now is not None:
|
||||||
|
full_capacity, design_capacity, current_capacity, unit = energy_full, energy_design, energy_now, "uWh"
|
||||||
|
else:
|
||||||
|
full_capacity, design_capacity, current_capacity, unit = charge_full, charge_design, charge_now, "uAh"
|
||||||
|
|
||||||
|
charge_percent = numeric_value(sysfs_text(device / "capacity"))
|
||||||
|
if charge_percent is None and current_capacity is not None and full_capacity:
|
||||||
|
charge_percent = current_capacity * 100 / full_capacity
|
||||||
|
health_percent = full_capacity * 100 / design_capacity if full_capacity is not None and design_capacity else None
|
||||||
|
if health_percent is None:
|
||||||
|
reported_health = sysfs_text(device / "health")
|
||||||
|
lowered_health = (reported_health or "").lower()
|
||||||
|
health_status = "critical" if any(word in lowered_health for word in ("poor", "replace", "critical")) else "warning" if any(word in lowered_health for word in ("fair", "low")) else "unknown"
|
||||||
|
else:
|
||||||
|
health_status = "critical" if health_percent < 60 else "warning" if health_percent < 80 else "good"
|
||||||
|
|
||||||
|
batteries.append({
|
||||||
|
"name": device.name,
|
||||||
|
"manufacturer": sysfs_text(device / "manufacturer"),
|
||||||
|
"model": sysfs_text(device / "model_name"),
|
||||||
|
"status": sysfs_text(device / "status") or "Unknown",
|
||||||
|
"charge_percent": round(charge_percent, 1) if charge_percent is not None else None,
|
||||||
|
"cycle_count": numeric_value(sysfs_text(device / "cycle_count")),
|
||||||
|
"full_capacity": full_capacity,
|
||||||
|
"design_capacity": design_capacity,
|
||||||
|
"current_capacity": current_capacity,
|
||||||
|
"capacity_unit": unit if full_capacity is not None or design_capacity is not None or current_capacity is not None else None,
|
||||||
|
"health_percent": round(health_percent, 1) if health_percent is not None else None,
|
||||||
|
"reported_health": sysfs_text(device / "health"),
|
||||||
|
"health_status": health_status,
|
||||||
|
})
|
||||||
|
return batteries
|
||||||
|
|
||||||
|
|
||||||
|
def fallback_limits(identity):
|
||||||
|
identity = identity.lower()
|
||||||
|
if any(word in identity for word in ("nvme", "drive", "disk", "composite")):
|
||||||
|
return 60.0, 70.0
|
||||||
|
if any(word in identity for word in ("gpu", "amdgpu", "nvidia", "junction", "hotspot")):
|
||||||
|
return 85.0, 95.0
|
||||||
|
if any(word in identity for word in ("cpu", "k10temp", "core", "package", "tctl", "tdie")):
|
||||||
|
return 85.0, 95.0
|
||||||
|
return 80.0, 90.0
|
||||||
|
|
||||||
|
|
||||||
|
def valid_limit(value):
|
||||||
|
try:
|
||||||
|
value = float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
return value if 0 < value < 150 else None
|
||||||
|
|
||||||
|
|
||||||
|
def collect_temperatures():
|
||||||
|
data = command_json(["sensors", "-j"])
|
||||||
|
if not data:
|
||||||
|
return [], "Temperature sensors unavailable (install/configure lm-sensors)."
|
||||||
|
readings = []
|
||||||
|
for chip, entries in data.items():
|
||||||
|
if not isinstance(entries, dict):
|
||||||
|
continue
|
||||||
|
for label, values in entries.items():
|
||||||
|
if not isinstance(values, dict):
|
||||||
|
continue
|
||||||
|
for key, raw in values.items():
|
||||||
|
if not key.startswith("temp") or not key.endswith("_input"):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
current = float(raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if not -20 <= current <= 150:
|
||||||
|
continue
|
||||||
|
prefix = key[:-6]
|
||||||
|
high = valid_limit(values.get(prefix + "_max")) or valid_limit(values.get(prefix + "_high"))
|
||||||
|
critical = valid_limit(values.get(prefix + "_crit")) or valid_limit(values.get(prefix + "_emergency"))
|
||||||
|
fallback_warning, fallback_critical = fallback_limits(chip + " " + label)
|
||||||
|
warning = high or (critical - 5 if critical is not None else fallback_warning)
|
||||||
|
critical = critical or fallback_critical
|
||||||
|
if critical <= warning:
|
||||||
|
critical = warning + 5
|
||||||
|
readings.append({"component": f"{chip} / {label}", "celsius": round(current, 1), "warning_limit": round(warning, 1), "critical_limit": round(critical, 1), "status": "critical" if current >= critical else "warning" if current >= warning else "ok"})
|
||||||
|
return readings, None
|
||||||
|
|
||||||
|
|
||||||
|
def collect_report():
|
||||||
|
disks, disk_note = collect_disks()
|
||||||
|
temperatures, sensor_note = collect_temperatures()
|
||||||
|
batteries = collect_batteries()
|
||||||
|
for disk in disks:
|
||||||
|
value = disk["smart"]["temperature"]
|
||||||
|
if value is not None:
|
||||||
|
warning, critical = fallback_limits(disk["kind"])
|
||||||
|
temperatures.append({"component": disk["device"] + " SMART", "celsius": round(value, 1), "warning_limit": warning, "critical_limit": critical, "status": "critical" if value >= critical else "warning" if value >= warning else "ok"})
|
||||||
|
return {"generated_at": datetime.datetime.now().astimezone().isoformat(timespec="seconds"), "uptime": uptime_info(), "disks": disks, "temperatures": temperatures, "batteries": batteries, "collection_notes": [note for note in (disk_note, sensor_note) if note]}
|
||||||
|
|
||||||
|
|
||||||
|
def has_problems(report):
|
||||||
|
return (
|
||||||
|
any(disk["smart"]["status"] == "failed" for disk in report["disks"])
|
||||||
|
or any(item["status"] in ("warning", "critical") for item in report["temperatures"])
|
||||||
|
or any(battery["health_status"] in ("warning", "critical") for battery in report["batteries"])
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def render_text(report, color=False):
|
||||||
|
red, reset = ("\033[31m", "\033[0m") if color else ("", "")
|
||||||
|
lines = ["HARDWARE HEALTH REPORT", "======================", f"Generated: {report['generated_at']}", f"System uptime: {report['uptime']['formatted']}", "", "DISKS / SMART", "-------------"]
|
||||||
|
if report["disks"]:
|
||||||
|
for disk in report["disks"]:
|
||||||
|
smart = disk["smart"]
|
||||||
|
style = (red, reset) if smart["status"] == "failed" else ("", "")
|
||||||
|
lines.append(f" {style[0]}{disk['device']} | {disk['kind']} | {disk['model']} | {smart['status'].upper()}: {smart['detail']}{style[1]}")
|
||||||
|
else:
|
||||||
|
lines.append(" No physical disks found")
|
||||||
|
lines.extend(["", "COMPONENT TEMPERATURES", "----------------------"])
|
||||||
|
for item in report["temperatures"]:
|
||||||
|
style = (red, reset) if item["status"] in ("warning", "critical") else ("", "")
|
||||||
|
lines.append(f" {style[0]}{item['component']}: {item['celsius']:.1f} C [{item['status'].upper()}; warning {item['warning_limit']:.1f} C, critical {item['critical_limit']:.1f} C]{style[1]}")
|
||||||
|
if not report["temperatures"]:
|
||||||
|
lines.append(" No temperature readings available")
|
||||||
|
lines.extend(["", "BATTERIES", "---------"])
|
||||||
|
if report["batteries"]:
|
||||||
|
for battery in report["batteries"]:
|
||||||
|
health = f"{battery['health_percent']:.1f}%" if battery["health_percent"] is not None else battery["reported_health"] or "Unavailable"
|
||||||
|
charge = f"{battery['charge_percent']:.1f}%" if battery["charge_percent"] is not None else "Unavailable"
|
||||||
|
cycles = str(int(battery["cycle_count"])) if battery["cycle_count"] is not None else "Unavailable"
|
||||||
|
model = f" | {battery['manufacturer'] or ''} {battery['model'] or ''}".rstrip()
|
||||||
|
style = (red, reset) if battery["health_status"] in ("warning", "critical") else ("", "")
|
||||||
|
lines.append(f" {battery['name']}{model} | Charge: {charge} | State: {battery['status']} | Cycles: {cycles}")
|
||||||
|
lines.append(f" {style[0]}Health: {health} ({battery['health_status'].upper()}){style[1]}")
|
||||||
|
if battery["full_capacity"] is not None and battery["design_capacity"] is not None:
|
||||||
|
lines.append(f" Full/design capacity: {battery['full_capacity']:.0f}/{battery['design_capacity']:.0f} {battery['capacity_unit']}")
|
||||||
|
else:
|
||||||
|
lines.append(" No batteries detected")
|
||||||
|
if report["collection_notes"]:
|
||||||
|
lines.extend(["", "COLLECTION NOTES", *[" " + note for note in report["collection_notes"]]])
|
||||||
|
lines.extend(["", "OVERALL: " + ("PROBLEMS DETECTED" if has_problems(report) else "No reported SMART or temperature problems")])
|
||||||
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def render_html(report):
|
||||||
|
def esc(value):
|
||||||
|
return html.escape(str(value))
|
||||||
|
disk_rows = []
|
||||||
|
for disk in report["disks"]:
|
||||||
|
smart = disk["smart"]
|
||||||
|
status = "problem" if smart["status"] == "failed" else "unknown" if smart["status"] != "passed" else "ok"
|
||||||
|
disk_rows.append(f"<tr><td>{esc(disk['device'])}<small>{esc(disk['model'])}</small></td><td>{esc(disk['kind'])}</td><td class=\"{status}\">{esc(smart['status'].upper())}</td><td class=\"{status}\">{esc(smart['detail'])}</td></tr>")
|
||||||
|
temp_rows = []
|
||||||
|
for item in report["temperatures"]:
|
||||||
|
status = "problem" if item["status"] in ("warning", "critical") else "ok"
|
||||||
|
temp_rows.append(f"<tr><td>{esc(item['component'])}</td><td class=\"{status}\">{item['celsius']:.1f} C</td><td>{item['warning_limit']:.1f} C</td><td>{item['critical_limit']:.1f} C</td><td class=\"{status}\">{esc(item['status'].upper())}</td></tr>")
|
||||||
|
battery_rows = []
|
||||||
|
for battery in report["batteries"]:
|
||||||
|
health_class = "problem" if battery["health_status"] in ("warning", "critical") else "unknown" if battery["health_status"] == "unknown" else "ok"
|
||||||
|
charge = f"{battery['charge_percent']:.1f}%" if battery["charge_percent"] is not None else "Unavailable"
|
||||||
|
health = f"{battery['health_percent']:.1f}%" if battery["health_percent"] is not None else battery["reported_health"] or "Unavailable"
|
||||||
|
cycles = str(int(battery["cycle_count"])) if battery["cycle_count"] is not None else "Unavailable"
|
||||||
|
model = " ".join(value for value in (battery["manufacturer"], battery["model"]) if value)
|
||||||
|
battery_rows.append(f"<tr><td>{esc(battery['name'])}<small>{esc(model)}</small></td><td>{esc(charge)}</td><td>{esc(battery['status'])}</td><td>{esc(cycles)}</td><td class=\"{health_class}\">{esc(health)} ({esc(battery['health_status'].upper())})</td></tr>")
|
||||||
|
notes = "".join(f"<li>{esc(note)}</li>" for note in report["collection_notes"])
|
||||||
|
summary_class = "problem" if has_problems(report) else "ok"
|
||||||
|
summary = "Problems detected" if has_problems(report) else "No reported SMART or temperature problems"
|
||||||
|
return ("<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>Hardware Health Report</title><style>"
|
||||||
|
":root{color-scheme:light;--ink:#17242b;--muted:#5e7078;--line:#d7e1e3;--paper:#f2f6f5;--panel:#fff;--accent:#087e80;--red:#b42318;--green:#247044;--amber:#8a5a00}*{box-sizing:border-box}body{margin:0;background:var(--paper);color:var(--ink);font:15px/1.5 system-ui,sans-serif}main{max-width:1100px;margin:auto;padding:30px 20px 48px}header{border-bottom:2px solid var(--accent);padding-bottom:16px}h1{margin:0;font-size:28px}header p{color:var(--muted);margin:6px 0 0}section{background:var(--panel);border:1px solid var(--line);padding:18px;margin-top:14px}h2{margin:0 0 14px;font-size:18px}.table-wrap{overflow-x:auto}table{width:100%;border-collapse:collapse;text-align:left}th,td{padding:10px;border-bottom:1px solid var(--line);vertical-align:top}th{color:var(--muted);font-size:12px;text-transform:uppercase}small{display:block;color:var(--muted)}.problem{color:var(--red);font-weight:700}.unknown{color:var(--amber);font-weight:650}.ok{color:var(--green);font-weight:650}.summary{font-size:18px}@media(max-width:640px){main{padding:20px 12px 32px}section{padding:13px}}</style></head><body><main>"
|
||||||
|
f"<header><h1>Hardware Health Report</h1><p>Generated {esc(report['generated_at'])} · Uptime {esc(report['uptime']['formatted'])}</p></header><section><p class=\"summary {summary_class}\">{summary}</p></section>"
|
||||||
|
"<section><h2>Disks / SMART</h2><div class=\"table-wrap\"><table><thead><tr><th>Device / model</th><th>Type</th><th>SMART</th><th>Details</th></tr></thead><tbody>"
|
||||||
|
+ ("".join(disk_rows) or "<tr><td colspan=\"4\">No physical disks found</td></tr>")
|
||||||
|
+ "</tbody></table></div></section><section><h2>Component temperatures</h2><div class=\"table-wrap\"><table><thead><tr><th>Component</th><th>Current</th><th>Warning at</th><th>Critical at</th><th>Status</th></tr></thead><tbody>"
|
||||||
|
+ ("".join(temp_rows) or "<tr><td colspan=\"5\">No temperature readings available</td></tr>")
|
||||||
|
+ "</tbody></table></div></section>"
|
||||||
|
+ "<section><h2>Batteries</h2><div class=\"table-wrap\"><table><thead><tr><th>Battery</th><th>Charge</th><th>State</th><th>Cycles</th><th>Health</th></tr></thead><tbody>"
|
||||||
|
+ ("".join(battery_rows) or "<tr><td colspan=\"5\">No batteries detected</td></tr>")
|
||||||
|
+ "</tbody></table></div></section>"
|
||||||
|
+ (f"<section><h2>Collection notes</h2><ul>{notes}</ul></section>" if notes else "")
|
||||||
|
+ "</main></body></html>\n")
|
||||||
|
|
||||||
|
|
||||||
|
def ask(prompt, default):
|
||||||
|
try:
|
||||||
|
answer = input(f"{prompt} [{default}]: ").strip()
|
||||||
|
except EOFError:
|
||||||
|
return default
|
||||||
|
return answer or default
|
||||||
|
|
||||||
|
|
||||||
|
def invoking_user_account():
|
||||||
|
sudo_user = os.environ.get("SUDO_USER")
|
||||||
|
if sudo_user:
|
||||||
|
try:
|
||||||
|
return pwd.getpwnam(sudo_user)
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
sudo_uid = os.environ.get("SUDO_UID")
|
||||||
|
if sudo_uid and sudo_uid.isdigit():
|
||||||
|
try:
|
||||||
|
return pwd.getpwuid(int(sudo_uid))
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
return pwd.getpwuid(os.getuid())
|
||||||
|
|
||||||
|
|
||||||
|
def invoking_user_home():
|
||||||
|
return pathlib.Path(invoking_user_account().pw_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def save_report(path_text, format_name, force, interactive, report):
|
||||||
|
path = pathlib.Path(path_text).expanduser()
|
||||||
|
if not path.parent.is_dir():
|
||||||
|
raise OSError(f"output directory does not exist: {path.parent}")
|
||||||
|
if path.exists() and not force:
|
||||||
|
try:
|
||||||
|
overwrite = interactive and input(f"{path} exists. Overwrite? [y/N]: ").strip().lower() in ("y", "yes")
|
||||||
|
except EOFError:
|
||||||
|
overwrite = False
|
||||||
|
if not overwrite:
|
||||||
|
print(f"Report not written: {path} already exists.", file=sys.stderr)
|
||||||
|
return False
|
||||||
|
if format_name == "html":
|
||||||
|
content = render_html(report)
|
||||||
|
elif format_name == "json":
|
||||||
|
content = json.dumps(report, ensure_ascii=False, indent=2) + "\n"
|
||||||
|
else:
|
||||||
|
content = render_text(report)
|
||||||
|
temporary = None
|
||||||
|
try:
|
||||||
|
with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=path.parent, delete=False) as output:
|
||||||
|
temporary = pathlib.Path(output.name)
|
||||||
|
output.write(content)
|
||||||
|
sudo_uid = os.environ.get("SUDO_UID")
|
||||||
|
if os.geteuid() == 0 and sudo_uid and sudo_uid.isdigit():
|
||||||
|
account = invoking_user_account()
|
||||||
|
os.chown(temporary, account.pw_uid, account.pw_gid)
|
||||||
|
os.replace(temporary, path)
|
||||||
|
finally:
|
||||||
|
if temporary is not None and temporary.exists():
|
||||||
|
temporary.unlink()
|
||||||
|
print(f"Report saved: {path}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
output_path, requested_format, force_value = sys.argv[1:]
|
||||||
|
interactive = sys.stdin.isatty() and sys.stdout.isatty()
|
||||||
|
report = collect_report()
|
||||||
|
print(render_text(report, color=sys.stdout.isatty() and not os.environ.get("NO_COLOR")), end="")
|
||||||
|
if output_path:
|
||||||
|
suffix = pathlib.Path(output_path).suffix.lower().lstrip(".")
|
||||||
|
format_name = requested_format or (suffix if suffix in ("txt", "html", "json") else "txt")
|
||||||
|
saved = save_report(output_path, format_name, force_value == "1", interactive, report)
|
||||||
|
return 1 if not saved and not interactive else 0
|
||||||
|
if not interactive:
|
||||||
|
return 0
|
||||||
|
if input("Save this report to a file? [Y/n]: ").strip().lower() in ("n", "no"):
|
||||||
|
return 0
|
||||||
|
format_name = requested_format or ask("Report format (txt/html/json)", "txt").lower()
|
||||||
|
if format_name not in ("txt", "html", "json"):
|
||||||
|
print("Unsupported format; using txt.", file=sys.stderr)
|
||||||
|
format_name = "txt"
|
||||||
|
path = ask("Output path", str(invoking_user_home() / f"hardware-health.{format_name}"))
|
||||||
|
try:
|
||||||
|
save_report(path, format_name, force_value == "1", interactive, report)
|
||||||
|
except OSError as error:
|
||||||
|
print(f"Error: {error}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
sys.exit(main())
|
||||||
|
except OSError as error:
|
||||||
|
print(f"Error: {error}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
PYCODE
|
||||||
|
)" "$OUTPUT_PATH" "$OUTPUT_FORMAT" "$FORCE"
|
||||||
@@ -7,9 +7,10 @@
|
|||||||
|
|
||||||
## Какво прави
|
## Какво прави
|
||||||
|
|
||||||
Отчетът включва hostname и домейн, архитектура и версия на дистрибуцията,
|
Отчетът включва hostname и домейн, uptime, архитектура и версия на дистрибуцията,
|
||||||
версия на kernel-а, моментно CPU натоварване, load average и обща/използвана/
|
версия на kernel-а, моментно CPU натоварване, load average и обща/използвана/
|
||||||
свободна RAM. За дисковете показва модел, тип носител и интерфейс, размер,
|
свободна RAM. Uptime се чете от `/proc/uptime` и се показва в секунди в JSON,
|
||||||
|
както и в четим формат в TXT и HTML. За дисковете показва модел, тип носител и интерфейс, размер,
|
||||||
дъщерни файлови системи, mount points и използвано място. Диск без mount point
|
дъщерни файлови системи, mount points и използвано място. Диск без mount point
|
||||||
и без установен swap се маркира като „Not mounted and not in use“.
|
и без установен swap се маркира като „Not mounted and not in use“.
|
||||||
|
|
||||||
|
|||||||
@@ -184,6 +184,17 @@ def load_average():
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def uptime_info():
|
||||||
|
try:
|
||||||
|
seconds = int(float(pathlib.Path("/proc/uptime").read_text(encoding="ascii").split()[0]))
|
||||||
|
except (OSError, ValueError, IndexError):
|
||||||
|
return {"seconds": None, "formatted": "Unavailable"}
|
||||||
|
days, remainder = divmod(seconds, 86400)
|
||||||
|
hours, remainder = divmod(remainder, 3600)
|
||||||
|
minutes = remainder // 60
|
||||||
|
return {"seconds": seconds, "formatted": f"{days}d {hours}h {minutes}m"}
|
||||||
|
|
||||||
|
|
||||||
def parse_json_command(command):
|
def parse_json_command(command):
|
||||||
output = run(command)
|
output = run(command)
|
||||||
if output is None:
|
if output is None:
|
||||||
@@ -526,6 +537,7 @@ def collect_report():
|
|||||||
network_mounts, mount_note = collect_network_mounts()
|
network_mounts, mount_note = collect_network_mounts()
|
||||||
return {
|
return {
|
||||||
"generated_at": datetime.datetime.now().astimezone().isoformat(timespec="seconds"),
|
"generated_at": datetime.datetime.now().astimezone().isoformat(timespec="seconds"),
|
||||||
|
"uptime": uptime_info(),
|
||||||
"host": {"hostname": host, "fqdn": fqdn, "domain": domain},
|
"host": {"hostname": host, "fqdn": fqdn, "domain": domain},
|
||||||
"operating_system": {
|
"operating_system": {
|
||||||
"name": os_name,
|
"name": os_name,
|
||||||
@@ -567,6 +579,7 @@ def render_text(report):
|
|||||||
f" Hostname: {value_or_unavailable(host['hostname'])}",
|
f" Hostname: {value_or_unavailable(host['hostname'])}",
|
||||||
f" Domain: {value_or_unavailable(host['domain'])}",
|
f" Domain: {value_or_unavailable(host['domain'])}",
|
||||||
f" FQDN: {value_or_unavailable(host['fqdn'])}",
|
f" FQDN: {value_or_unavailable(host['fqdn'])}",
|
||||||
|
f" Uptime: {value_or_unavailable(report['uptime']['formatted'])}",
|
||||||
"",
|
"",
|
||||||
"OPERATING SYSTEM",
|
"OPERATING SYSTEM",
|
||||||
"-----------------",
|
"-----------------",
|
||||||
@@ -679,6 +692,7 @@ def render_html(report):
|
|||||||
f"<dt>Hostname</dt><dd>{esc(host['hostname'])}</dd>",
|
f"<dt>Hostname</dt><dd>{esc(host['hostname'])}</dd>",
|
||||||
f"<dt>Domain</dt><dd>{esc(host['domain'])}</dd>",
|
f"<dt>Domain</dt><dd>{esc(host['domain'])}</dd>",
|
||||||
f"<dt>FQDN</dt><dd>{esc(host['fqdn'])}</dd>",
|
f"<dt>FQDN</dt><dd>{esc(host['fqdn'])}</dd>",
|
||||||
|
f"<dt>Uptime</dt><dd>{esc(report['uptime']['formatted'])}</dd>",
|
||||||
f"<dt>Operating system</dt><dd>{esc(os_info['name'])}</dd>",
|
f"<dt>Operating system</dt><dd>{esc(os_info['name'])}</dd>",
|
||||||
f"<dt>Version</dt><dd>{esc(os_info['version'])}</dd>",
|
f"<dt>Version</dt><dd>{esc(os_info['version'])}</dd>",
|
||||||
f"<dt>Architecture</dt><dd>{esc(os_info['architecture'])}</dd>",
|
f"<dt>Architecture</dt><dd>{esc(os_info['architecture'])}</dd>",
|
||||||
|
|||||||
Reference in new issue
Block a user