Add Linux system status report
This commit is contained in:
1 parent
cf53a6bb50
commit
a23fc775e3
3 files changed
+974
No files matched your search
@@ -20,6 +20,8 @@ strixscripts/
|
||||
├── HDD/
|
||||
│ ├── repair-hdd.sh презапис на диск и SMART extended self-test
|
||||
│ └── erase-storage.sh презапис на дял или Secure Erase/Sanitize на диск
|
||||
├── System/
|
||||
│ └── system-report.sh моментен системен отчет с TXT/JSON/HTML export
|
||||
└── <категория>/ функционална група скриптове (предстои)
|
||||
```
|
||||
|
||||
@@ -48,3 +50,9 @@ strixscripts/
|
||||
изпълнява SMART extended self-test.
|
||||
- [`erase-storage.sh`](./HDD/erase-storage.md) — презаписва дял с един проход
|
||||
или използва поддържан Secure Erase/Sanitize за цял диск.
|
||||
|
||||
### System/
|
||||
|
||||
- [`system-report.sh`](./System/system-report.md) — събира моментно състояние
|
||||
на системата, като групира Ethernet, Wi-Fi и виртуалните интерфейси, и
|
||||
предлага отчет във формат TXT, JSON или HTML.
|
||||
@@ -0,0 +1,86 @@
|
||||
# system-report.sh
|
||||
|
||||
Събира моментна информация за Linux системата: идентификация на хоста,
|
||||
операционна система, CPU и RAM, дискове и mount points, мрежови интерфейси,
|
||||
скорост/сигнал и монтирани мрежови файлови системи. Показва отчета в
|
||||
терминала и по избор го записва като TXT, JSON или HTML.
|
||||
|
||||
## Какво прави
|
||||
|
||||
Отчетът включва hostname и домейн, архитектура и версия на дистрибуцията,
|
||||
версия на kernel-а, моментно CPU натоварване, load average и обща/използвана/
|
||||
свободна RAM. За дисковете показва модел, тип носител и интерфейс, размер,
|
||||
дъщерни файлови системи, mount points и използвано място. Диск без mount point
|
||||
и без установен swap се маркира като „Not mounted and not in use“.
|
||||
|
||||
За мрежовите интерфейси се показват състояние и IP адреси, Ethernet скорост
|
||||
в Mbps, когато kernel-ът я предоставя, и за Wi-Fi SSID, сигнал, честота и
|
||||
канал. За свързан Wi-Fi интерфейс се използват `iw`, NetworkManager (`nmcli`)
|
||||
и `/proc/net/wireless`; стойността се попълва от наличния източник, така че
|
||||
липсата на една команда не премахва останалите метрики. Силата може да бъде
|
||||
показана в dBm, проценти, ленти или driver-provided quality. RX/TX bitrate се
|
||||
показва, когато `iw` го предоставя; ако `iw` липсва, отчетът го отбелязва,
|
||||
докато SSID, сигнал, честота и канал продължават да се събират.
|
||||
|
||||
Мрежовите mount points (например SMB/CIFS, NFS, WebDAV, SSHFS и други познати
|
||||
мрежови файлови системи) се показват отделно със source и mount target.
|
||||
Интерфейсите се групират по ред: физически Ethernet, физически Wi-Fi,
|
||||
Docker/container виртуални интерфейси, останали виртуални интерфейси. Docker
|
||||
групата се разпознава по имена като `docker0`, `veth*` и Docker bridge
|
||||
идентификатори. HTML отчетът използва отделни секции, таблици и адаптивни
|
||||
интерфейсни панели; TXT запазва същия ред с четливи заглавия.
|
||||
|
||||
След показването на отчета интерактивно се предлага запис. По подразбиране
|
||||
се използват `~/report.txt` и TXT формат. Потребителят може да избере друг
|
||||
формат и път. С `--output` може да се зададе път директно; форматът се
|
||||
определя от разширението или може изрично да се зададе с `--format`.
|
||||
|
||||
## Изисквания
|
||||
|
||||
- Linux, Bash и Python 3
|
||||
- `lsblk`, `ip` и `findmnt` се използват за наличната информация; ако някоя
|
||||
команда или kernel interface липсва, съответното поле може да бъде
|
||||
отбелязано като недостъпно
|
||||
- `iw` е незадължителен и дава RX/TX bitrate; `nmcli` и `/proc/net/wireless`
|
||||
дават fallback данни за останалите Wi-Fi метрики, когато са налични
|
||||
- Root права не са нужни
|
||||
|
||||
## Употреба
|
||||
|
||||
```bash
|
||||
./System/system-report.sh
|
||||
./System/system-report.sh --help
|
||||
```
|
||||
|
||||
| Опция | Описание |
|
||||
|-------|----------|
|
||||
| `-o`, `--output PATH` | Записва директно във файл; без тази опция предлага интерактивен запис |
|
||||
| `-f`, `--format FORMAT` | Формат `txt`, `json` или `html` |
|
||||
| `--force` | Позволява презапис на съществуващ файл без потвърждение |
|
||||
| `-h`, `--help` | Показва помощта и приключва |
|
||||
|
||||
## Примери
|
||||
|
||||
```bash
|
||||
# Предлага формат и път, като началните стойности са txt и ~/report.txt
|
||||
./System/system-report.sh
|
||||
|
||||
# Записва JSON; форматът се разпознава и от разширението
|
||||
./System/system-report.sh --output "$HOME/report.json"
|
||||
|
||||
# Извежда JSON към stdout, удобно за pipe в неинтерактивен режим
|
||||
./System/system-report.sh --format json
|
||||
|
||||
# Записва HTML в зададено място и разрешава презапис
|
||||
./System/system-report.sh --output /tmp/system-report.html --format html --force
|
||||
```
|
||||
|
||||
## Ограничения
|
||||
|
||||
CPU utilization се измерва като средно натоварване за едносекундна проба,
|
||||
затова събирането на отчет отнема поне една секунда. Ethernet скоростта и
|
||||
Wi-Fi метриките зависят от драйвера и наличните kernel utilities. Използваното
|
||||
пространство се изчислява за монтираните файлови системи; неприкачени дялове
|
||||
нямат filesystem usage стойност. Състоянието „не се използва“ е извод от
|
||||
липсата на mount points и директно разпознат swap върху диска, а не гаранция,
|
||||
че устройството няма друга специализирана употреба.
|
||||
Executable
+880
@@ -0,0 +1,880 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# system-report.sh — collect a Linux system status report and optionally save it.
|
||||
# Full documentation: see system-report.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/system-report.sh [OPTIONS]
|
||||
|
||||
Collects host, OS, CPU, memory, disk, network-interface, and network-mount
|
||||
status. Displays a text report and can save it as TXT, JSON, or HTML.
|
||||
Wi-Fi details use iw, NetworkManager, and kernel wireless statistics when
|
||||
available. Interfaces are grouped as Ethernet, Wi-Fi, Docker/container, then
|
||||
other virtual devices.
|
||||
|
||||
Options:
|
||||
-o, --output PATH Save directly to PATH; otherwise ask interactively
|
||||
-f, --format FORMAT Report format: txt, json, or html
|
||||
--force Overwrite an existing output file without prompting
|
||||
-h, --help Show this help message and exit
|
||||
|
||||
Examples:
|
||||
./System/system-report.sh
|
||||
./System/system-report.sh --output "$HOME/report.json" --format json
|
||||
./System/system-report.sh --output /tmp/system-status.html --force
|
||||
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, json, or html"
|
||||
OUTPUT_FORMAT="${2,,}"
|
||||
case "$OUTPUT_FORMAT" in
|
||||
txt|json|html) ;;
|
||||
*) common::die "unsupported report format: $2 (use txt, json, or html)" ;;
|
||||
esac
|
||||
shift 2
|
||||
;;
|
||||
--force)
|
||||
FORCE=1
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
common::die "unknown argument: $1"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
common::require_cmd python3
|
||||
|
||||
python3 -c "$(cat <<'PYCODE'
|
||||
import datetime
|
||||
import html
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
|
||||
def run(command, timeout=10):
|
||||
if shutil.which(command[0]) is None:
|
||||
return None
|
||||
try:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return None
|
||||
return result.stdout.strip() if result.returncode == 0 else None
|
||||
|
||||
|
||||
def read_text(path):
|
||||
try:
|
||||
return pathlib.Path(path).read_text(encoding="utf-8").strip()
|
||||
except (OSError, UnicodeError):
|
||||
return None
|
||||
|
||||
|
||||
def human_bytes(value):
|
||||
if value is None:
|
||||
return "Unavailable"
|
||||
amount = float(value)
|
||||
for unit in ("B", "KiB", "MiB", "GiB", "TiB", "PiB"):
|
||||
if amount < 1024 or unit == "PiB":
|
||||
return f"{amount:.1f} {unit}" if unit != "B" else f"{int(amount)} B"
|
||||
amount /= 1024
|
||||
return f"{amount:.1f} PiB"
|
||||
|
||||
|
||||
def parse_os_release():
|
||||
try:
|
||||
release = platform.freedesktop_os_release()
|
||||
return release.get("PRETTY_NAME") or release.get("NAME"), release.get("VERSION")
|
||||
except (AttributeError, OSError):
|
||||
values = {}
|
||||
try:
|
||||
for line in pathlib.Path("/etc/os-release").read_text(encoding="utf-8").splitlines():
|
||||
if "=" in line:
|
||||
key, value = line.split("=", 1)
|
||||
values[key] = value.strip().strip('"')
|
||||
except OSError:
|
||||
return None, None
|
||||
return values.get("PRETTY_NAME") or values.get("NAME"), values.get("VERSION")
|
||||
|
||||
|
||||
def cpu_counters():
|
||||
try:
|
||||
fields = pathlib.Path("/proc/stat").read_text(encoding="ascii").splitlines()[0].split()
|
||||
values = [int(value) for value in fields[1:]]
|
||||
idle = values[3] + (values[4] if len(values) > 4 else 0)
|
||||
return sum(values), idle
|
||||
except (OSError, IndexError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def cpu_usage():
|
||||
before = cpu_counters()
|
||||
if before is None:
|
||||
return None
|
||||
time.sleep(1)
|
||||
after = cpu_counters()
|
||||
if after is None:
|
||||
return None
|
||||
total_delta = after[0] - before[0]
|
||||
idle_delta = after[1] - before[1]
|
||||
if total_delta <= 0:
|
||||
return None
|
||||
return round(max(0.0, min(100.0, 100.0 * (total_delta - idle_delta) / total_delta)), 1)
|
||||
|
||||
|
||||
def memory_info():
|
||||
values = {}
|
||||
try:
|
||||
for line in pathlib.Path("/proc/meminfo").read_text(encoding="ascii").splitlines():
|
||||
key, value = line.split(":", 1)
|
||||
values[key] = int(value.strip().split()[0]) * 1024
|
||||
except (OSError, ValueError, IndexError):
|
||||
return {"total_bytes": None, "used_bytes": None, "available_bytes": None}
|
||||
total = values.get("MemTotal")
|
||||
available = values.get("MemAvailable")
|
||||
used = max(0, total - available) if total is not None and available is not None else None
|
||||
return {
|
||||
"total_bytes": total,
|
||||
"used_bytes": used,
|
||||
"available_bytes": available,
|
||||
"total": human_bytes(total),
|
||||
"used": human_bytes(used),
|
||||
"available": human_bytes(available),
|
||||
}
|
||||
|
||||
|
||||
def load_average():
|
||||
try:
|
||||
return [round(value, 2) for value in os.getloadavg()]
|
||||
except (AttributeError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
def parse_json_command(command):
|
||||
output = run(command)
|
||||
if output is None:
|
||||
return None
|
||||
try:
|
||||
return json.loads(output)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
|
||||
def mount_points(node):
|
||||
points = node.get("mountpoints") or []
|
||||
if isinstance(points, str):
|
||||
points = [points]
|
||||
return [point for point in points if isinstance(point, str) and point]
|
||||
|
||||
|
||||
def device_path(node):
|
||||
return node.get("path") or ("/dev/" + node["name"] if node.get("name") else "Unknown")
|
||||
|
||||
|
||||
def disk_kind(node):
|
||||
name = (node.get("kname") or node.get("name") or "").lower()
|
||||
transport = (node.get("tran") or "").upper()
|
||||
if name.startswith("nvme") or transport == "NVME":
|
||||
return "NVMe SSD"
|
||||
if node.get("rota") == 0:
|
||||
kind = "SSD"
|
||||
elif node.get("rota") == 1:
|
||||
kind = "HDD"
|
||||
else:
|
||||
kind = "Unknown media"
|
||||
if transport:
|
||||
kind = f"{kind} ({transport})"
|
||||
return kind
|
||||
|
||||
|
||||
def swap_devices():
|
||||
devices = set()
|
||||
try:
|
||||
lines = pathlib.Path("/proc/swaps").read_text(encoding="utf-8").splitlines()[1:]
|
||||
except (OSError, UnicodeError):
|
||||
return devices
|
||||
for line in lines:
|
||||
fields = line.split()
|
||||
if fields:
|
||||
devices.add(os.path.realpath(fields[0]))
|
||||
return devices
|
||||
|
||||
|
||||
def collect_disks():
|
||||
data = parse_json_command([
|
||||
"lsblk", "--json", "--bytes", "--paths", "--output",
|
||||
"NAME,KNAME,PATH,TYPE,SIZE,ROTA,TRAN,MODEL,FSTYPE,MOUNTPOINTS",
|
||||
])
|
||||
if not data:
|
||||
return [], "Disk inventory unavailable (lsblk missing or unsupported)."
|
||||
|
||||
swaps = swap_devices()
|
||||
disks = []
|
||||
|
||||
def add_mounts(node, results):
|
||||
path = device_path(node)
|
||||
filesystem = node.get("fstype")
|
||||
for target in mount_points(node):
|
||||
try:
|
||||
usage = shutil.disk_usage(target)
|
||||
total, used = usage.total, usage.used
|
||||
percent = round(100 * used / total, 1) if total else None
|
||||
except OSError:
|
||||
total = used = percent = None
|
||||
results.append({
|
||||
"device": path,
|
||||
"target": target,
|
||||
"filesystem": filesystem,
|
||||
"total_bytes": total,
|
||||
"used_bytes": used,
|
||||
"total": human_bytes(total),
|
||||
"used": human_bytes(used),
|
||||
"used_percent": percent,
|
||||
})
|
||||
for child in node.get("children") or []:
|
||||
add_mounts(child, results)
|
||||
|
||||
for node in data.get("blockdevices", []):
|
||||
if node.get("type") != "disk":
|
||||
continue
|
||||
name = (node.get("kname") or node.get("name") or "").lower()
|
||||
if name.startswith(("loop", "ram", "zram")):
|
||||
continue
|
||||
mounts = []
|
||||
add_mounts(node, mounts)
|
||||
descendants = []
|
||||
|
||||
def collect_paths(current):
|
||||
descendants.append(os.path.realpath(device_path(current)))
|
||||
for child in current.get("children") or []:
|
||||
collect_paths(child)
|
||||
|
||||
collect_paths(node)
|
||||
active_swap = any(path in swaps for path in descendants)
|
||||
if mounts:
|
||||
status = "Mounted"
|
||||
elif active_swap:
|
||||
status = "In use as swap (not mounted)"
|
||||
else:
|
||||
status = "Not mounted and not in use"
|
||||
size = node.get("size")
|
||||
disks.append({
|
||||
"device": device_path(node),
|
||||
"model": (node.get("model") or "").strip() or None,
|
||||
"kind": disk_kind(node),
|
||||
"size_bytes": size,
|
||||
"size": human_bytes(size),
|
||||
"status": status,
|
||||
"mounts": mounts,
|
||||
})
|
||||
return disks, None
|
||||
|
||||
|
||||
def read_addresses(interface):
|
||||
data = parse_json_command(["ip", "-j", "address", "show", "dev", interface])
|
||||
addresses = []
|
||||
if data:
|
||||
for entry in data[0].get("addr_info", []):
|
||||
local = entry.get("local")
|
||||
if local:
|
||||
addresses.append(f"{local}/{entry.get('prefixlen', '')}".rstrip("/"))
|
||||
return addresses
|
||||
|
||||
|
||||
def split_nmcli_fields(line):
|
||||
fields = []
|
||||
value = []
|
||||
escaped = False
|
||||
for character in line:
|
||||
if escaped:
|
||||
value.append(character)
|
||||
escaped = False
|
||||
elif character == "\\":
|
||||
escaped = True
|
||||
elif character == ":":
|
||||
fields.append("".join(value))
|
||||
value = []
|
||||
else:
|
||||
value.append(character)
|
||||
fields.append("".join(value))
|
||||
return fields
|
||||
|
||||
|
||||
def nmcli_wifi_info(interface):
|
||||
output = run([
|
||||
"nmcli", "--terse", "--escape", "yes", "--fields",
|
||||
"IN-USE,SSID,FREQ,SIGNAL,BARS,CHAN", "device", "wifi", "list",
|
||||
"--rescan", "no", "ifname", interface,
|
||||
])
|
||||
if not output:
|
||||
return {}
|
||||
for line in output.splitlines():
|
||||
fields = split_nmcli_fields(line)
|
||||
if len(fields) < 6 or fields[0] != "*":
|
||||
continue
|
||||
frequency = re.search(r"\d+", fields[2])
|
||||
channel = re.search(r"\d+", fields[5])
|
||||
try:
|
||||
signal = int(fields[3])
|
||||
except ValueError:
|
||||
signal = None
|
||||
return {
|
||||
"ssid": fields[1] or None,
|
||||
"frequency_mhz": int(frequency.group()) if frequency else None,
|
||||
"signal_percent": signal,
|
||||
"signal_bars": fields[4] or None,
|
||||
"channel": int(channel.group()) if channel else None,
|
||||
}
|
||||
return {}
|
||||
|
||||
|
||||
def proc_wireless_info(interface):
|
||||
try:
|
||||
lines = pathlib.Path("/proc/net/wireless").read_text(encoding="ascii").splitlines()[2:]
|
||||
except (OSError, UnicodeError):
|
||||
return {}
|
||||
for line in lines:
|
||||
name, separator, data = line.partition(":")
|
||||
if not separator or name.strip() != interface:
|
||||
continue
|
||||
fields = data.split()
|
||||
result = {}
|
||||
if len(fields) > 2:
|
||||
try:
|
||||
result["signal_dbm"] = round(float(fields[2].rstrip(".")))
|
||||
except ValueError:
|
||||
pass
|
||||
if len(fields) > 1:
|
||||
try:
|
||||
result["signal_quality"] = round(float(fields[1].rstrip(".")))
|
||||
except ValueError:
|
||||
pass
|
||||
return result
|
||||
return {}
|
||||
|
||||
|
||||
def wifi_info(interface):
|
||||
wireless_dir = pathlib.Path("/sys/class/net") / interface / "wireless"
|
||||
iw_output = run(["iw", "dev", interface, "link"])
|
||||
if not wireless_dir.exists() and iw_output is None:
|
||||
return None
|
||||
values = {
|
||||
"ssid": None,
|
||||
"signal_dbm": None,
|
||||
"signal_quality": None,
|
||||
"signal_percent": None,
|
||||
"signal_bars": None,
|
||||
"frequency_mhz": None,
|
||||
"channel": None,
|
||||
"rx_bitrate": None,
|
||||
"tx_bitrate": None,
|
||||
"bitrate_note": "iw is not installed" if shutil.which("iw") is None else None,
|
||||
}
|
||||
if iw_output:
|
||||
patterns = {
|
||||
"ssid": r"^\s*SSID:\s*(.+)$",
|
||||
"signal_dbm": r"^\s*signal:\s*(-?\d+)\s*dBm",
|
||||
"frequency_mhz": r"^\s*freq:\s*(\d+)",
|
||||
"rx_bitrate": r"^\s*rx bitrate:\s*(.+)$",
|
||||
"tx_bitrate": r"^\s*tx bitrate:\s*(.+)$",
|
||||
}
|
||||
for key, pattern in patterns.items():
|
||||
match = re.search(pattern, iw_output, re.MULTILINE)
|
||||
if match:
|
||||
value = match.group(1).strip()
|
||||
values[key] = int(value) if key in ("signal_dbm", "frequency_mhz") else value
|
||||
|
||||
for key, value in nmcli_wifi_info(interface).items():
|
||||
if values.get(key) is None:
|
||||
values[key] = value
|
||||
for key, value in proc_wireless_info(interface).items():
|
||||
if values.get(key) is None:
|
||||
values[key] = value
|
||||
return values
|
||||
|
||||
|
||||
def is_docker_interface(name):
|
||||
return name == "docker0" or name.startswith("veth") or bool(re.fullmatch(r"br-[0-9a-f]{12}", name))
|
||||
|
||||
|
||||
def collect_interfaces():
|
||||
root = pathlib.Path("/sys/class/net")
|
||||
try:
|
||||
names = sorted(path.name for path in root.iterdir())
|
||||
except OSError:
|
||||
return [], "Network interface inventory unavailable (/sys/class/net unreadable)."
|
||||
interfaces = []
|
||||
for name in names:
|
||||
base = root / name
|
||||
state = read_text(base / "operstate") or "unknown"
|
||||
carrier = read_text(base / "carrier")
|
||||
if carrier == "0":
|
||||
state = "disconnected"
|
||||
elif carrier == "1" and state == "unknown":
|
||||
state = "up"
|
||||
wireless = wifi_info(name)
|
||||
has_device = (base / "device").exists()
|
||||
has_wireless = (base / "wireless").exists()
|
||||
speed = None
|
||||
if wireless is None:
|
||||
raw_speed = read_text(base / "speed")
|
||||
try:
|
||||
parsed_speed = int(raw_speed) if raw_speed is not None else None
|
||||
speed = parsed_speed if parsed_speed is not None and parsed_speed > 0 else None
|
||||
except ValueError:
|
||||
pass
|
||||
if wireless is not None or has_wireless:
|
||||
group = "physical_wifi" if has_device or has_wireless else "other_virtual"
|
||||
interface_type = "Wi-Fi"
|
||||
elif has_device:
|
||||
group = "physical_ethernet"
|
||||
interface_type = "Ethernet"
|
||||
elif is_docker_interface(name):
|
||||
group = "docker_virtual"
|
||||
interface_type = "Docker/container virtual"
|
||||
else:
|
||||
group = "other_virtual"
|
||||
interface_type = "Virtual/other"
|
||||
interfaces.append({
|
||||
"name": name,
|
||||
"state": state,
|
||||
"type": interface_type,
|
||||
"group": group,
|
||||
"addresses": read_addresses(name),
|
||||
"speed_mbps": speed,
|
||||
"wifi": wireless,
|
||||
})
|
||||
group_order = {"physical_ethernet": 0, "physical_wifi": 1, "docker_virtual": 2, "other_virtual": 3}
|
||||
interfaces.sort(key=lambda item: (group_order[item["group"]], item["name"]))
|
||||
return interfaces, None
|
||||
|
||||
|
||||
def collect_network_mounts():
|
||||
data = parse_json_command(["findmnt", "--json", "--output", "TARGET,SOURCE,FSTYPE,OPTIONS"])
|
||||
if not data:
|
||||
return [], "Network mount inventory unavailable (findmnt missing or unsupported)."
|
||||
remote_types = {
|
||||
"9p", "afs", "beegfs", "ceph", "cifs", "davfs", "glusterfs",
|
||||
"lustre", "ncpfs", "nfs", "nfs4", "smb3", "sshfs", "fuse.sshfs",
|
||||
}
|
||||
mounts = []
|
||||
|
||||
def visit(entries):
|
||||
for item in entries:
|
||||
filesystem = (item.get("fstype") or "").lower()
|
||||
source = item.get("source") or ""
|
||||
if filesystem in remote_types or filesystem.startswith(("fuse.rclone", "fuse.sshfs")) or source.startswith(("//", "\\\\")):
|
||||
mounts.append({
|
||||
"target": item.get("target"),
|
||||
"source": source,
|
||||
"filesystem": item.get("fstype"),
|
||||
"options": item.get("options"),
|
||||
})
|
||||
visit(item.get("children") or [])
|
||||
|
||||
visit(data.get("filesystems", []))
|
||||
return mounts, None
|
||||
|
||||
|
||||
def collect_report():
|
||||
host = socket.gethostname()
|
||||
fqdn = socket.getfqdn()
|
||||
domain = run(["hostname", "-d"])
|
||||
if not domain or domain in ("(none)", "localdomain"):
|
||||
domain = fqdn[len(host) + 1:] if fqdn.startswith(host + ".") and fqdn != host else None
|
||||
os_name, os_version = parse_os_release()
|
||||
product = read_text("/sys/devices/virtual/dmi/id/product_name")
|
||||
vendor = read_text("/sys/devices/virtual/dmi/id/sys_vendor")
|
||||
cpu = cpu_usage()
|
||||
memory = memory_info()
|
||||
disks, disk_note = collect_disks()
|
||||
interfaces, interface_note = collect_interfaces()
|
||||
network_mounts, mount_note = collect_network_mounts()
|
||||
return {
|
||||
"generated_at": datetime.datetime.now().astimezone().isoformat(timespec="seconds"),
|
||||
"host": {"hostname": host, "fqdn": fqdn, "domain": domain},
|
||||
"operating_system": {
|
||||
"name": os_name,
|
||||
"version": os_version,
|
||||
"architecture": platform.machine(),
|
||||
"kernel": platform.release(),
|
||||
"system_type": " ".join(value for value in (vendor, product) if value) or platform.system(),
|
||||
},
|
||||
"cpu": {
|
||||
"usage_percent": cpu,
|
||||
"sample_seconds": 1,
|
||||
"logical_cores": os.cpu_count(),
|
||||
"load_average_1_5_15": load_average(),
|
||||
},
|
||||
"memory": memory,
|
||||
"disks": disks,
|
||||
"network_interfaces": interfaces,
|
||||
"network_mounts": network_mounts,
|
||||
"collection_notes": [note for note in (disk_note, interface_note, mount_note) if note],
|
||||
}
|
||||
|
||||
|
||||
def value_or_unavailable(value):
|
||||
return "Unavailable" if value is None or value == "" else str(value)
|
||||
|
||||
|
||||
def render_text(report):
|
||||
host = report["host"]
|
||||
os_info = report["operating_system"]
|
||||
cpu = report["cpu"]
|
||||
memory = report["memory"]
|
||||
lines = [
|
||||
"SYSTEM STATUS REPORT",
|
||||
"====================",
|
||||
f"Generated: {report['generated_at']}",
|
||||
"",
|
||||
"HOST",
|
||||
"----",
|
||||
f" Hostname: {value_or_unavailable(host['hostname'])}",
|
||||
f" Domain: {value_or_unavailable(host['domain'])}",
|
||||
f" FQDN: {value_or_unavailable(host['fqdn'])}",
|
||||
"",
|
||||
"OPERATING SYSTEM",
|
||||
"-----------------",
|
||||
f" OS: {value_or_unavailable(os_info['name'])}",
|
||||
f" Version: {value_or_unavailable(os_info['version'])}",
|
||||
f" Architecture: {value_or_unavailable(os_info['architecture'])}",
|
||||
f" Kernel: {value_or_unavailable(os_info['kernel'])}",
|
||||
f" System type: {value_or_unavailable(os_info['system_type'])}",
|
||||
"",
|
||||
"CPU",
|
||||
"---",
|
||||
f" Usage at sample: {value_or_unavailable(cpu['usage_percent'])}% (sample: {cpu['sample_seconds']} second)",
|
||||
f" Logical cores: {value_or_unavailable(cpu['logical_cores'])}",
|
||||
f" Load average (1, 5, 15 min): {value_or_unavailable(cpu['load_average_1_5_15'])}",
|
||||
"",
|
||||
"MEMORY",
|
||||
"------",
|
||||
f" Total: {value_or_unavailable(memory.get('total'))}",
|
||||
f" Used: {value_or_unavailable(memory.get('used'))}",
|
||||
f" Available: {value_or_unavailable(memory.get('available'))}",
|
||||
"",
|
||||
"DISKS",
|
||||
"-----",
|
||||
]
|
||||
if report["disks"]:
|
||||
for disk in report["disks"]:
|
||||
lines.append(f" {disk['device']} | {disk['kind']} | {disk['size']} | {disk['status']}")
|
||||
if disk["model"]:
|
||||
lines.append(f" Model: {disk['model']}")
|
||||
if disk["mounts"]:
|
||||
for mount in disk["mounts"]:
|
||||
percent = f" ({mount['used_percent']}%)" if mount["used_percent"] is not None else ""
|
||||
lines.append(f" {mount['target']} [{value_or_unavailable(mount['filesystem'])}]: {mount['used']} used of {mount['total']}{percent}")
|
||||
else:
|
||||
lines.append(" No mounted filesystems")
|
||||
else:
|
||||
lines.append(" No physical disks found")
|
||||
lines.extend(["", "NETWORK INTERFACES", "------------------", "Physical interfaces"])
|
||||
interface_groups = [
|
||||
("physical_ethernet", " Ethernet"),
|
||||
("physical_wifi", " Wi-Fi"),
|
||||
("docker_virtual", " Docker / container interfaces"),
|
||||
("other_virtual", " Other virtual interfaces"),
|
||||
]
|
||||
virtual_header_shown = False
|
||||
for group, title in interface_groups:
|
||||
entries = [item for item in report["network_interfaces"] if item["group"] == group]
|
||||
if not entries:
|
||||
continue
|
||||
if group in ("docker_virtual", "other_virtual"):
|
||||
if not virtual_header_shown:
|
||||
lines.extend(["", "Virtual interfaces (Docker/container first, then other)"])
|
||||
virtual_header_shown = True
|
||||
lines.append(title)
|
||||
for interface in entries:
|
||||
lines.append(f" {interface['name']} [{interface['state']}]")
|
||||
if interface["addresses"]:
|
||||
lines.append(f" IP: {', '.join(interface['addresses'])}")
|
||||
if interface["speed_mbps"] is not None:
|
||||
lines.append(f" Link speed: {interface['speed_mbps']} Mbps")
|
||||
wifi = interface["wifi"]
|
||||
if wifi is not None:
|
||||
lines.append(f" SSID: {value_or_unavailable(wifi['ssid'])}")
|
||||
signal_parts = []
|
||||
if wifi["signal_dbm"] is not None:
|
||||
signal_parts.append(f"{wifi['signal_dbm']} dBm")
|
||||
if wifi["signal_quality"] is not None:
|
||||
signal_parts.append(f"quality {wifi['signal_quality']}")
|
||||
if wifi["signal_percent"] is not None:
|
||||
signal_parts.append(f"{wifi['signal_percent']}%")
|
||||
if wifi["signal_bars"]:
|
||||
signal_parts.append(wifi["signal_bars"])
|
||||
lines.append(f" Signal: {', '.join(signal_parts) if signal_parts else 'Unavailable'}")
|
||||
lines.append(f" Frequency: {value_or_unavailable(wifi['frequency_mhz'])} MHz")
|
||||
lines.append(f" Channel: {value_or_unavailable(wifi['channel'])}")
|
||||
bitrate_note = f" ({wifi['bitrate_note']})" if wifi["bitrate_note"] else ""
|
||||
lines.append(f" RX/TX bitrate: {value_or_unavailable(wifi['rx_bitrate'])} / {value_or_unavailable(wifi['tx_bitrate'])}{bitrate_note}")
|
||||
if not report["network_interfaces"]:
|
||||
lines.append(" No network interfaces found")
|
||||
lines.extend(["", "NETWORK MOUNTS", "--------------"])
|
||||
if report["network_mounts"]:
|
||||
for mount in report["network_mounts"]:
|
||||
lines.append(f" {mount['source']} on {mount['target']} ({mount['filesystem']})")
|
||||
else:
|
||||
lines.append(" No network mounts detected")
|
||||
if report["collection_notes"]:
|
||||
lines.extend(["", "COLLECTION NOTES"])
|
||||
lines.extend(f" {note}" for note in report["collection_notes"])
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def render_html(report):
|
||||
def esc(value):
|
||||
return html.escape(value_or_unavailable(value))
|
||||
|
||||
def section(title, body):
|
||||
return f"<section><h2>{html.escape(title)}</h2>{body}</section>"
|
||||
|
||||
host = report["host"]
|
||||
os_info = report["operating_system"]
|
||||
cpu = report["cpu"]
|
||||
memory = report["memory"]
|
||||
cards = "".join([
|
||||
f"<article class=\"metric cpu\"><span>CPU load</span><strong>{esc(cpu['usage_percent'])}%</strong><small>{cpu['sample_seconds']} second sample</small></article>",
|
||||
f"<article class=\"metric memory\"><span>Memory used</span><strong>{esc(memory.get('used'))}</strong><small>of {esc(memory.get('total'))}</small></article>",
|
||||
f"<article class=\"metric available\"><span>Memory available</span><strong>{esc(memory.get('available'))}</strong><small>RAM</small></article>",
|
||||
f"<article class=\"metric load\"><span>Load average</span><strong>{esc(cpu['load_average_1_5_15'])}</strong><small>1 / 5 / 15 min</small></article>",
|
||||
])
|
||||
identity = "<dl>" + "".join([
|
||||
f"<dt>Hostname</dt><dd>{esc(host['hostname'])}</dd>",
|
||||
f"<dt>Domain</dt><dd>{esc(host['domain'])}</dd>",
|
||||
f"<dt>FQDN</dt><dd>{esc(host['fqdn'])}</dd>",
|
||||
f"<dt>Operating system</dt><dd>{esc(os_info['name'])}</dd>",
|
||||
f"<dt>Version</dt><dd>{esc(os_info['version'])}</dd>",
|
||||
f"<dt>Architecture</dt><dd>{esc(os_info['architecture'])}</dd>",
|
||||
f"<dt>Kernel</dt><dd>{esc(os_info['kernel'])}</dd>",
|
||||
f"<dt>System type</dt><dd>{esc(os_info['system_type'])}</dd>",
|
||||
f"<dt>Logical CPU cores</dt><dd>{esc(cpu['logical_cores'])}</dd>",
|
||||
]) + "</dl>"
|
||||
|
||||
disk_rows = []
|
||||
for disk in report["disks"]:
|
||||
mount_details = []
|
||||
for mount in disk["mounts"]:
|
||||
percent = f" ({mount['used_percent']}%)" if mount["used_percent"] is not None else ""
|
||||
mount_details.append(
|
||||
f"<li><strong>{esc(mount['target'])}</strong> · {esc(mount['filesystem'])}"
|
||||
f"<br>{esc(mount['used'])} used of {esc(mount['total'])}{percent}</li>"
|
||||
)
|
||||
details = "<ul class=\"mount-list\">" + "".join(mount_details) + "</ul>" if mount_details else "<span class=\"muted\">No mounted filesystems</span>"
|
||||
model = f"<small>{esc(disk['model'])}</small>" if disk["model"] else ""
|
||||
disk_rows.append(
|
||||
f"<tr><td><strong>{esc(disk['device'])}</strong>{model}</td>"
|
||||
f"<td>{esc(disk['kind'])}</td><td>{esc(disk['size'])}</td>"
|
||||
f"<td><span class=\"status\">{esc(disk['status'])}</span></td><td>{details}</td></tr>"
|
||||
)
|
||||
if disk_rows:
|
||||
disk_body = "<div class=\"table-wrap\"><table><thead><tr><th>Device</th><th>Media</th><th>Size</th><th>Status</th><th>Mounts and usage</th></tr></thead><tbody>" + "".join(disk_rows) + "</tbody></table></div>"
|
||||
else:
|
||||
disk_body = "<p class=\"muted\">No physical disks found.</p>"
|
||||
|
||||
groups = [
|
||||
("physical_ethernet", "Physical interfaces · Ethernet"),
|
||||
("physical_wifi", "Physical interfaces · Wi-Fi"),
|
||||
("docker_virtual", "Virtual interfaces · Docker / containers"),
|
||||
("other_virtual", "Virtual interfaces · Other"),
|
||||
]
|
||||
interface_sections = []
|
||||
for group, title in groups:
|
||||
interfaces = [item for item in report["network_interfaces"] if item["group"] == group]
|
||||
if not interfaces:
|
||||
continue
|
||||
items = []
|
||||
for interface in interfaces:
|
||||
properties = [f"<span class=\"state {html.escape(interface['state'])}\">{esc(interface['state'])}</span>"]
|
||||
if interface["addresses"]:
|
||||
properties.append(f"<span>IP {esc(', '.join(interface['addresses']))}</span>")
|
||||
if interface["speed_mbps"] is not None:
|
||||
properties.append(f"<span>{interface['speed_mbps']} Mbps</span>")
|
||||
wifi = interface["wifi"]
|
||||
if wifi is not None:
|
||||
properties.extend([
|
||||
f"<span>SSID {esc(wifi['ssid'])}</span>",
|
||||
f"<span>Signal {esc(wifi['signal_dbm'])} dBm · {esc(wifi['signal_percent'])}% · quality {esc(wifi['signal_quality'])}</span>",
|
||||
f"<span>{esc(wifi['frequency_mhz'])} MHz</span>",
|
||||
f"<span>Channel {esc(wifi['channel'])}</span>",
|
||||
f"<span>RX/TX {esc(wifi['rx_bitrate'])} / {esc(wifi['tx_bitrate'])}{' · ' + esc(wifi['bitrate_note']) if wifi['bitrate_note'] else ''}</span>",
|
||||
])
|
||||
items.append(
|
||||
f"<article class=\"interface\"><header><strong>{esc(interface['name'])}</strong>"
|
||||
f"<span>{esc(interface['type'])}</span></header><div class=\"properties\">"
|
||||
+ "".join(properties) + "</div></article>"
|
||||
)
|
||||
interface_sections.append(f"<div class=\"interface-group\"><h3>{html.escape(title)}</h3><div class=\"interface-grid\">{''.join(items)}</div></div>")
|
||||
interfaces_body = "".join(interface_sections) or "<p class=\"muted\">No network interfaces found.</p>"
|
||||
|
||||
mount_rows = "".join(
|
||||
f"<tr><td>{esc(mount['source'])}</td><td>{esc(mount['target'])}</td><td>{esc(mount['filesystem'])}</td></tr>"
|
||||
for mount in report["network_mounts"]
|
||||
)
|
||||
mount_body = (
|
||||
"<div class=\"table-wrap\"><table><thead><tr><th>Source</th><th>Mount point</th><th>Type</th></tr></thead><tbody>"
|
||||
+ mount_rows + "</tbody></table></div>"
|
||||
if mount_rows else "<p class=\"muted\">No network mounts detected.</p>"
|
||||
)
|
||||
notes = ""
|
||||
if report["collection_notes"]:
|
||||
notes = section("Collection notes", "<ul>" + "".join(f"<li>{esc(note)}</li>" for note in report["collection_notes"]) + "</ul>")
|
||||
|
||||
return (
|
||||
"<!doctype html>\n<html lang=\"en\">\n<head>\n"
|
||||
"<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n"
|
||||
"<title>System Status Report</title>\n<style>\n"
|
||||
':root{color-scheme:light;--ink:#17242b;--muted:#5e7078;--line:#d7e1e3;--paper:#f2f6f5;--panel:#fff;--teal:#087e80;--blue:#3569a8;--green:#397653}\n'
|
||||
'*{box-sizing:border-box}body{margin:0;background:var(--paper);color:var(--ink);font:15px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif}\n'
|
||||
'main{max-width:1200px;margin:0 auto;padding:36px 24px 56px}h1{margin:0;font-size:30px;line-height:1.15}h2{margin:0 0 16px;font-size:19px}h3{margin:22px 0 10px;font-size:15px;color:var(--muted)}\n'
|
||||
'.masthead{display:flex;justify-content:space-between;gap:20px;align-items:end;border-bottom:2px solid var(--teal);padding-bottom:20px}.masthead p{margin:8px 0 0;color:var(--muted)}.stamp{font:13px ui-monospace,monospace;color:var(--muted);text-align:right}\n'
|
||||
'.metrics{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;margin:20px 0}.metric{background:var(--panel);border:1px solid var(--line);border-top:3px solid var(--teal);padding:14px 16px;min-width:0}.metric.memory{border-top-color:var(--blue)}.metric.available{border-top-color:var(--green)}.metric.load{border-top-color:#bd7a2b}.metric span,.metric small{display:block;color:var(--muted)}.metric strong{display:block;margin:5px 0;font-size:21px;overflow-wrap:anywhere}\n'
|
||||
'section{background:var(--panel);border:1px solid var(--line);padding:20px;margin-top:14px}dl{display:grid;grid-template-columns:minmax(140px,220px) 1fr;gap:8px 16px;margin:0}dt{color:var(--muted)}dd{margin:0;overflow-wrap:anywhere;font-weight:550}\n'
|
||||
'.table-wrap{overflow-x:auto}table{width:100%;border-collapse:collapse;text-align:left}th,td{padding:11px 12px;border-bottom:1px solid var(--line);vertical-align:top}th{font-size:12px;text-transform:uppercase;color:var(--muted);white-space:nowrap}td small{display:block;color:var(--muted)}.status{font-size:12px;font-weight:650}.mount-list{margin:0;padding-left:18px}.mount-list li+li{margin-top:8px}.muted{color:var(--muted)}\n'
|
||||
'.interface-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:9px}.interface{border:1px solid var(--line);padding:12px;min-width:0}.interface header{display:flex;justify-content:space-between;gap:12px}.interface header span{font-size:12px;color:var(--muted);text-align:right}.properties{display:flex;flex-wrap:wrap;gap:6px 12px;margin-top:8px;color:var(--muted);font-size:13px;overflow-wrap:anywhere}.state{font-weight:650;color:var(--green)}.state.disconnected,.state.down{color:#a34137}\n'
|
||||
'@media(max-width:760px){main{padding:22px 14px 36px}.masthead{display:block}.stamp{text-align:left;margin-top:12px}.metrics{grid-template-columns:repeat(2,minmax(0,1fr))}.interface-grid{grid-template-columns:1fr}section{padding:15px}dl{grid-template-columns:1fr;gap:2px}dd{margin-bottom:9px}}\n'
|
||||
"</style>\n</head>\n<body><main>\n"
|
||||
f"<header class=\"masthead\"><div><h1>System Status Report</h1><p>{esc(host['hostname'])} · {esc(os_info['name'])}</p></div><div class=\"stamp\">Generated {esc(report['generated_at'])}</div></header>\n"
|
||||
+ f"<div class=\"metrics\">{cards}</div>\n"
|
||||
+ section("System identity", identity) + "\n"
|
||||
+ section("Storage", disk_body) + "\n"
|
||||
+ section("Network interfaces", interfaces_body) + "\n"
|
||||
+ section("Network mounts", mount_body) + "\n"
|
||||
+ notes + "\n</main></body>\n</html>\n"
|
||||
)
|
||||
|
||||
|
||||
def ask(prompt, default):
|
||||
suffix = " [" + default + "]"
|
||||
try:
|
||||
answer = input(prompt + suffix + ": ").strip()
|
||||
except EOFError:
|
||||
return default
|
||||
return answer or default
|
||||
|
||||
|
||||
def ask_yes_no(prompt, default):
|
||||
marker = "Y/n" if default else "y/N"
|
||||
try:
|
||||
answer = input(f"{prompt} [{marker}]: ").strip().lower()
|
||||
except EOFError:
|
||||
return default
|
||||
if not answer:
|
||||
return default
|
||||
return answer in ("y", "yes")
|
||||
|
||||
|
||||
def infer_format(path, requested):
|
||||
if requested:
|
||||
return requested
|
||||
suffix = pathlib.Path(path).suffix.lower().lstrip(".")
|
||||
return suffix if suffix in ("txt", "json", "html") else "txt"
|
||||
|
||||
|
||||
def save_report(path_text, format_name, force, interactive, report, text_report):
|
||||
path = pathlib.Path(path_text).expanduser()
|
||||
if not path.parent.exists() or not path.parent.is_dir():
|
||||
raise OSError(f"output directory does not exist: {path.parent}")
|
||||
if path.exists() and not force:
|
||||
if not interactive or not ask_yes_no(f"{path} already exists. Overwrite it?", False):
|
||||
print(f"Report not written: {path} already exists.", file=sys.stderr)
|
||||
return False
|
||||
rendered = text_report if format_name == "txt" else json.dumps(report, ensure_ascii=False, indent=2) + "\n" if format_name == "json" else render_html(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(rendered)
|
||||
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 = sys.argv[1] or None
|
||||
requested_format = sys.argv[2] or None
|
||||
force = sys.argv[3] == "1"
|
||||
interactive = sys.stdin.isatty() and sys.stdout.isatty()
|
||||
|
||||
report = collect_report()
|
||||
text_report = render_text(report)
|
||||
|
||||
if output_path:
|
||||
print(text_report, end="")
|
||||
format_name = infer_format(output_path, requested_format)
|
||||
saved = save_report(output_path, format_name, force, interactive, report, text_report)
|
||||
return 1 if not saved and not interactive else 0
|
||||
if not interactive:
|
||||
if requested_format == "json":
|
||||
print(json.dumps(report, ensure_ascii=False, indent=2))
|
||||
elif requested_format == "html":
|
||||
print(render_html(report), end="")
|
||||
else:
|
||||
print(text_report, end="")
|
||||
return 0
|
||||
print(text_report, end="")
|
||||
if not ask_yes_no("Save this report to a file?", True):
|
||||
return 0
|
||||
|
||||
format_name = requested_format
|
||||
if format_name is None:
|
||||
format_name = ask("Report format (txt/json/html)", "txt").lower()
|
||||
if format_name not in ("txt", "json", "html"):
|
||||
print("Unsupported format; using txt.", file=sys.stderr)
|
||||
format_name = "txt"
|
||||
default_path = str(pathlib.Path.home() / f"report.{format_name}")
|
||||
output_path = ask("Output path", default_path)
|
||||
try:
|
||||
save_report(output_path, format_name, force, interactive, report, text_report)
|
||||
except OSError as error:
|
||||
print(f"Error: could not save report: {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"
|
||||
Reference in new issue
Block a user