System Status Report
{esc(host['hostname'])} · {esc(os_info['name'])}
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}
| Device | Media | Size | Status | Mounts and usage |
|---|
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"No network interfaces found.
" + + mount_rows = "".join( + f"| Source | Mount point | Type |
|---|
No network mounts detected.
" + ) + notes = "" + if report["collection_notes"]: + notes = section("Collection notes", "{esc(host['hostname'])} · {esc(os_info['name'])}