System Status Report
{esc(host['hostname'])} · {esc(os_info['name'])}
#!/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 uptime_info():
try:
seconds = int(float(pathlib.Path("/proc/uptime").read_text(encoding="ascii").split()[0]))
except (OSError, ValueError, IndexError):
return {"seconds": None, "formatted": "Unavailable"}
days, remainder = divmod(seconds, 86400)
hours, remainder = divmod(remainder, 3600)
minutes = remainder // 60
return {"seconds": seconds, "formatted": f"{days}d {hours}h {minutes}m"}
def parse_json_command(command):
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"),
"uptime": uptime_info(),
"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'])}",
f" Uptime: {value_or_unavailable(report['uptime']['formatted'])}",
"",
"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'])}