Files
StrixScripts/System/system-report.sh
T
2026-09-29 10:37:50 +03:00

880 lines
35 KiB
Bash
Executable File

#!/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"