diff --git a/README.md b/README.md index 952bdb1..b783274 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/System/system-report.md b/System/system-report.md new file mode 100644 index 0000000..98e6103 --- /dev/null +++ b/System/system-report.md @@ -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 върху диска, а не гаранция, +че устройството няма друга специализирана употреба. \ No newline at end of file diff --git a/System/system-report.sh b/System/system-report.sh new file mode 100755 index 0000000..1cfec69 --- /dev/null +++ b/System/system-report.sh @@ -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"

{html.escape(title)}

{body}
" + + host = report["host"] + os_info = report["operating_system"] + cpu = report["cpu"] + memory = report["memory"] + cards = "".join([ + f"
CPU load{esc(cpu['usage_percent'])}%{cpu['sample_seconds']} second sample
", + f"
Memory used{esc(memory.get('used'))}of {esc(memory.get('total'))}
", + f"
Memory available{esc(memory.get('available'))}RAM
", + f"
Load average{esc(cpu['load_average_1_5_15'])}1 / 5 / 15 min
", + ]) + identity = "
" + "".join([ + f"
Hostname
{esc(host['hostname'])}
", + f"
Domain
{esc(host['domain'])}
", + f"
FQDN
{esc(host['fqdn'])}
", + f"
Operating system
{esc(os_info['name'])}
", + f"
Version
{esc(os_info['version'])}
", + f"
Architecture
{esc(os_info['architecture'])}
", + f"
Kernel
{esc(os_info['kernel'])}
", + f"
System type
{esc(os_info['system_type'])}
", + f"
Logical CPU cores
{esc(cpu['logical_cores'])}
", + ]) + "
" + + 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"
  • {esc(mount['target'])} · {esc(mount['filesystem'])}" + f"
    {esc(mount['used'])} used of {esc(mount['total'])}{percent}
  • " + ) + details = "" if mount_details else "No mounted filesystems" + model = f"{esc(disk['model'])}" if disk["model"] else "" + disk_rows.append( + f"{esc(disk['device'])}{model}" + f"{esc(disk['kind'])}{esc(disk['size'])}" + f"{esc(disk['status'])}{details}" + ) + if disk_rows: + disk_body = "
    " + "".join(disk_rows) + "
    DeviceMediaSizeStatusMounts and usage
    " + else: + disk_body = "

    No physical disks found.

    " + + 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"{esc(interface['state'])}"] + if interface["addresses"]: + properties.append(f"IP {esc(', '.join(interface['addresses']))}") + if interface["speed_mbps"] is not None: + properties.append(f"{interface['speed_mbps']} Mbps") + wifi = interface["wifi"] + if wifi is not None: + properties.extend([ + f"SSID {esc(wifi['ssid'])}", + f"Signal {esc(wifi['signal_dbm'])} dBm · {esc(wifi['signal_percent'])}% · quality {esc(wifi['signal_quality'])}", + f"{esc(wifi['frequency_mhz'])} MHz", + f"Channel {esc(wifi['channel'])}", + f"RX/TX {esc(wifi['rx_bitrate'])} / {esc(wifi['tx_bitrate'])}{' · ' + esc(wifi['bitrate_note']) if wifi['bitrate_note'] else ''}", + ]) + items.append( + f"
    {esc(interface['name'])}" + f"{esc(interface['type'])}
    " + + "".join(properties) + "
    " + ) + interface_sections.append(f"

    {html.escape(title)}

    {''.join(items)}
    ") + interfaces_body = "".join(interface_sections) or "

    No network interfaces found.

    " + + mount_rows = "".join( + f"{esc(mount['source'])}{esc(mount['target'])}{esc(mount['filesystem'])}" + for mount in report["network_mounts"] + ) + mount_body = ( + "
    " + + mount_rows + "
    SourceMount pointType
    " + if mount_rows else "

    No network mounts detected.

    " + ) + notes = "" + if report["collection_notes"]: + notes = section("Collection notes", "") + + return ( + "\n\n\n" + "\n\n" + "System Status Report\n\n\n
    \n" + f"

    System Status Report

    {esc(host['hostname'])} · {esc(os_info['name'])}

    Generated {esc(report['generated_at'])}
    \n" + + f"
    {cards}
    \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
    \n\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" \ No newline at end of file