#!/usr/bin/env python3 # Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) # SPDX-License-Identifier: MIT """System diagnostics — CPU, memory, disk, network, GPU, services, security, performance. No external dependencies — leverages /proc, /sys, and system tools with graceful fallbacks when any data source is unavailable. Usage: system-diag.py # full report system-diag.py --section cpu,memory # only selected sections system-diag.py --external-ip # also detect external IPs (3rd-party) system-diag.py --json # machine-readable output system-diag.py --version Linux only. Progress is written to stderr, the report to stdout. """ from __future__ import annotations import argparse import json import os import re import shutil import socket import subprocess import sys import time from collections.abc import Callable from concurrent.futures import Future, ThreadPoolExecutor, as_completed from typing import Any BOLD = "\033[1m" RED = "\033[31m" GREEN = "\033[32m" YELLOW = "\033[33m" CYAN = "\033[36m" MAGENTA = "\033[35m" DIM = "\033[2m" RESET = "\033[0m" def _supports_colour() -> bool: """Honour NO_COLOR (https://no-color.org) and non-TTY output.""" if os.environ.get("NO_COLOR") is not None: return False return sys.stdout.isatty() if not _supports_colour(): BOLD = RED = GREEN = YELLOW = CYAN = MAGENTA = DIM = RESET = "" # Letter grade → colour, applied at render time only (never stored in data). GRADE_COLOURS = {"A": GREEN, "B": GREEN, "C": YELLOW, "D": YELLOW, "F": RED} VERSION = "1.0.0" WIDTH = 60 ALL_SECTIONS = [ "overview", "cpu", "memory", "disk", "network", "gpu", "services", "security", "performance", "issues", ] def _status(msg: str) -> None: """Print progress message to stderr — stays on same line until ``_status_done``.""" print(f" {msg}...", end="", flush=True, file=sys.stderr) def _status_done(msg: str = "done") -> None: """Complete the current progress line on stderr.""" print(f" {msg}", file=sys.stderr) def run(cmd: list[str], timeout: int = 15) -> tuple[str, str]: """Execute a shell command and return (stdout, stderr) separately. Sets LC_ALL=C (highest locale precedence, overrides user settings) for reliable English-language output parsing. Returns empty strings on timeout or if the command cannot be run. """ try: env = {**os.environ, "LANG": "C", "LC_ALL": "C"} result = subprocess.run( cmd, capture_output=True, text=True, timeout=timeout, env=env, errors="replace", ) return result.stdout, result.stderr except (subprocess.TimeoutExpired, OSError): return "", "" def _read_file(path: str) -> str: """Read a file, returning '' if it doesn't exist or can't be read.""" try: with open(path, encoding="utf-8", errors="replace") as f: return f.read().strip() except OSError: return "" def _read_lines(path: str) -> list[str]: """Read lines from a file, returning [] if unavailable.""" try: with open(path, encoding="utf-8", errors="replace") as f: return [line.strip() for line in f] except OSError: return [] def _bar(value: float, maximum: float = 100.0, width: int = 20) -> str: """Build a textual usage bar with colour. Returns a string like '████████░░░░░░░░░░░░ 42%' with ANSI colour. """ ratio = max(0.0, min(1.0, value / maximum)) if maximum > 0 else 0.0 filled = int(ratio * width) if ratio < 0.80: colour = GREEN elif ratio < 0.90: colour = YELLOW else: colour = RED bar_str = colour + "█" * filled + DIM + "░" * (width - filled) + RESET return f"{bar_str} {ratio * 100:.0f}%" # ────────────────────────────────────────────────────────────────────────────── # Section: System Overview # ────────────────────────────────────────────────────────────────────────────── def _parse_os_release() -> dict[str, str]: """Parse /etc/os-release into a dict.""" info: dict[str, str] = {} for line in _read_lines("/etc/os-release"): if "=" in line: key, _, val = line.partition("=") info[key.strip()] = val.strip().strip('"') return info def _uptime_human(seconds: float) -> str: """Return human-readable uptime string, e.g. '3 days, 7 hours'.""" days, rem = divmod(int(seconds), 86400) hours, rem = divmod(rem, 3600) minutes = rem // 60 parts = [] if days: parts.append(f"{days} day{'s' if days != 1 else ''}") if hours: parts.append(f"{hours} hour{'s' if hours != 1 else ''}") if minutes or not parts: parts.append(f"{minutes} minute{'s' if minutes != 1 else ''}") return ", ".join(parts) def _boot_time() -> str: """Return boot time from /proc/stat (btime).""" for line in _read_lines("/proc/stat"): if line.startswith("btime "): try: ts = int(line.split()[1]) return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(ts)) except (ValueError, IndexError): pass return "unknown" def _load_average() -> tuple[float, float, float]: """Return (1min, 5min, 15min) load averages.""" content = _read_file("/proc/loadavg") try: parts = content.split() return float(parts[0]), float(parts[1]), float(parts[2]) except (IndexError, ValueError): return (0.0, 0.0, 0.0) def _logged_users() -> int: """Return the number of unique users with a login session (via ``who``).""" out, _ = run(["who"]) users = {line.split()[0] for line in out.split("\n") if line.strip()} return len(users) def collect_overview() -> dict[str, Any]: """Collect system overview data.""" os_info = _parse_os_release() os_name = os_info.get("PRETTY_NAME", os_info.get("NAME", "unknown")) load1, load5, load15 = _load_average() uptime_seconds = 0.0 uptime_raw = _read_file("/proc/uptime") if uptime_raw: try: uptime_seconds = float(uptime_raw.split()[0]) except (ValueError, IndexError): uptime_seconds = 0.0 return { "hostname": socket.gethostname() or os.uname().nodename, "os": os_name, "kernel": os.uname().release, "uptime_seconds": uptime_seconds, "uptime_human": _uptime_human(uptime_seconds), "load_1min": load1, "load_5min": load5, "load_15min": load15, "cpu_cores": os.cpu_count() or 1, "users_logged": _logged_users(), "boot_time": _boot_time(), } # ────────────────────────────────────────────────────────────────────────────── # Section: CPU # ────────────────────────────────────────────────────────────────────────────── def _cpu_model() -> str: """Get CPU model name from /proc/cpuinfo.""" for line in _read_lines("/proc/cpuinfo"): if line.startswith("model name"): return line.split(":", 1)[1].strip() return "unknown" def _cpu_arch() -> str: """Get CPU architecture.""" return os.uname().machine def _cpu_cores_threads() -> tuple[int, int]: """Return (physical cores, logical threads). "core id" is unique only within one physical package ("physical id"), so the (package, core) pair is needed on multi-socket systems. """ cpuinfo = _read_file("/proc/cpuinfo") cores: set[tuple[str, str]] = set() threads = 0 physical_id = "0" for line in cpuinfo.split("\n"): if line.startswith("physical id"): physical_id = line.split(":", 1)[1].strip() elif line.startswith("core id"): cores.add((physical_id, line.split(":", 1)[1].strip())) elif line.startswith("processor"): threads += 1 phys_count = len(cores) if cores else (os.cpu_count() or 1) return phys_count, threads if threads else (os.cpu_count() or 1) def _cpu_frequency() -> str: """Get current CPU frequency in MHz.""" # Try /proc/cpuinfo first for line in _read_lines("/proc/cpuinfo"): if "cpu mhz" in line.lower(): return line.split(":", 1)[1].strip() + " MHz" # Try /sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq freq = _read_file("/sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq") if freq: try: return f"{int(freq) / 1000:.0f} MHz" except ValueError: pass return "unknown" def _cpu_temperature() -> str: """Get CPU temperature from hwmon sensors.""" base = "/sys/class/hwmon" if not os.path.isdir(base): return "unknown" for entry in sorted(os.listdir(base)): name = _read_file(os.path.join(base, entry, "name")) if name.lower() in ("coretemp", "k10temp", "cpu_thermal", "acpitz"): for i in range(1, 5): temp_raw = _read_file(os.path.join(base, entry, f"temp{i}_input")) if temp_raw: try: temp_c = int(temp_raw) / 1000.0 return f"{temp_c:.1f}°C" except ValueError: pass # Fallback: try temp1_input without label temp_raw = _read_file(os.path.join(base, entry, "temp1_input")) if temp_raw: try: return f"{int(temp_raw) / 1000.0:.1f}°C" except ValueError: pass return "unknown" def _parse_cpu_fields(line: str) -> list[int]: """Parse a /proc/stat cpu line into jiffies, dropping the guest columns. guest and guest_nice are already included in user and nice, so keeping them in the total would double-count. """ try: return [int(x) for x in line.split()[1:9]] except ValueError: return [] def _read_cpu_times() -> tuple[list[int], list[list[int]]]: """Read aggregate and per-CPU jiffy counters from /proc/stat.""" aggregate: list[int] = [] per_core: list[list[int]] = [] for line in _read_lines("/proc/stat"): if line.startswith("cpu "): aggregate = _parse_cpu_fields(line) elif line[:3] == "cpu" and line[3:4].isdigit(): per_core.append(_parse_cpu_fields(line)) return aggregate, per_core def _usage_percent(times1: list[int], times2: list[int]) -> float: """Compute usage percentage between two jiffy snapshots. iowait counts as idle time — a process waiting on disk is not using CPU. """ if len(times1) < 5 or len(times2) < 5: return 0.0 idle_delta = (times2[3] + times2[4]) - (times1[3] + times1[4]) total_delta = sum(times2) - sum(times1) if total_delta <= 0: return 0.0 return round((1 - idle_delta / total_delta) * 100, 1) def _cpu_usage(sample_interval: float = 0.2) -> tuple[float, list[dict[str, Any]]]: """Sample aggregate and per-core CPU usage over a short interval. Both are computed from the same sampling window, so the per-core view matches the aggregate instead of showing since-boot averages. """ agg1, cores1 = _read_cpu_times() time.sleep(sample_interval) agg2, cores2 = _read_cpu_times() per_core = [ {"cpu": str(index), "usage_pct": _usage_percent(c1, c2)} for index, (c1, c2) in enumerate(zip(cores1, cores2)) ] return _usage_percent(agg1, agg2), per_core def collect_cpu() -> dict[str, Any]: """Collect CPU data.""" phys_cores, threads = _cpu_cores_threads() usage, per_core = _cpu_usage() return { "model": _cpu_model(), "architecture": _cpu_arch(), "physical_cores": phys_cores, "threads": threads, "frequency": _cpu_frequency(), "temperature": _cpu_temperature(), "usage_pct": usage, "per_core": per_core, } # ────────────────────────────────────────────────────────────────────────────── # Section: Memory # ────────────────────────────────────────────────────────────────────────────── def _parse_meminfo() -> dict[str, int]: """Parse /proc/meminfo into {key: value_in_kb}.""" mem: dict[str, int] = {} for line in _read_lines("/proc/meminfo"): parts = line.split() if len(parts) >= 2: key = parts[0].rstrip(":") try: mem[key] = int(parts[1]) except ValueError: pass return mem def _top_mem_processes(count: int = 5) -> list[dict[str, Any]]: """Return top N memory-consuming processes using ps.""" out, _ = run( ["ps", "-eo", "pid,user,rss,comm", "--sort=-rss", "--no-headers"], timeout=5, ) procs: list[dict[str, Any]] = [] for line in out.strip().split("\n")[:count]: parts = line.split(None, 3) if len(parts) >= 4: try: rss_kb = int(parts[2]) procs.append( { "pid": int(parts[0]), "user": parts[1], "rss_mb": round(rss_kb / 1024, 1), "command": parts[3], } ) except ValueError: pass return procs def collect_memory() -> dict[str, Any]: """Collect memory data.""" mem = _parse_meminfo() total = mem.get("MemTotal", 0) // 1024 free = mem.get("MemFree", 0) // 1024 available = mem.get("MemAvailable", 0) // 1024 used = total - available if available else total - free swap_total = mem.get("SwapTotal", 0) // 1024 swap_free = mem.get("SwapFree", 0) // 1024 swap_used = swap_total - swap_free usage_pct = round((used / total) * 100, 1) if total > 0 else 0.0 swap_pct = round((swap_used / swap_total) * 100, 1) if swap_total > 0 else 0.0 return { "total_mb": total, "used_mb": used, "free_mb": free, "available_mb": available, "usage_pct": usage_pct, "swap_total_mb": swap_total, "swap_used_mb": swap_used, "swap_usage_pct": swap_pct, "top_processes": _top_mem_processes(), "buffers_mb": mem.get("Buffers", 0) // 1024, "cached_mb": mem.get("Cached", 0) // 1024, } # ────────────────────────────────────────────────────────────────────────────── # Section: Disk # ────────────────────────────────────────────────────────────────────────────── def _skip_filesystem(fs_type: str, device: str) -> bool: """Return True if this filesystem should be excluded.""" excluded = { "tmpfs", "devtmpfs", "squashfs", "overlay", "proc", "sysfs", "devpts", "cgroup", "cgroup2", "pstore", "bpf", "debugfs", "tracefs", "securityfs", "hugetlbfs", "fuse.gvfsd-fuse", "fuse.portal", "ramfs", "efivarfs", "configfs", "selinuxfs", "systemd-1", "mqueue", "fusectl", "sunrpc", "binfmt_misc", "autofs", "rpc_pipefs", "nsfs", } if fs_type in excluded: return True if device.startswith("tmpfs") or device.startswith("devtmpfs"): return True return False def _mounts() -> list[dict[str, Any]]: """Parse /proc/mounts for real filesystems. ``used`` is derived from ``f_bavail`` (space available to unprivileged users), so root-reserved blocks count as used — slightly more conservative than ``df``, which is deliberate for a health report. Bind mounts and btrfs subvolumes share the underlying device, so entries are deduplicated by device id to keep the totals honest. """ fs_list: list[dict[str, Any]] = [] seen_devices: set[int] = set() for line in _read_lines("/proc/mounts"): parts = line.split() if len(parts) < 6: continue device, mountpoint, fs_type = parts[0], parts[1], parts[2] if _skip_filesystem(fs_type, device): continue mountpoint = mountpoint.replace("\\040", " ") # spaces are escaped in /proc/mounts try: st_dev = os.stat(mountpoint).st_dev if st_dev in seen_devices: continue seen_devices.add(st_dev) stat = os.statvfs(mountpoint) total = stat.f_blocks * stat.f_frsize available = stat.f_bavail * stat.f_frsize used = total - available size_gb = total / (1024**3) used_gb = used / (1024**3) avail_gb = available / (1024**3) use_pct = round((used / total) * 100, 1) if total > 0 else 0.0 except OSError: continue fs_list.append( { "device": device, "mountpoint": mountpoint, "fstype": fs_type, "size_gb": round(size_gb, 1), "used_gb": round(used_gb, 1), "available_gb": round(avail_gb, 1), "use_pct": use_pct, } ) return fs_list def _disk_io_stats() -> dict[str, Any] | None: """Collect disk I/O stats using iostat, if available. Uses ``iostat -d -x 1 2``: the first report block covers averages since boot, the second is a live 1-second sample — only the last block is parsed. Columns are located by header name so that layout differences between sysstat versions cannot silently misalign the values. """ out, _ = run(["iostat", "-d", "-x", "1", "2"], timeout=10) if not out: return None # Split the output into report blocks, each starting at a Device header. blocks: list[list[str]] = [] current: list[str] = [] for line in out.split("\n"): if "Device" in line and "r/s" in line: if current: blocks.append(current) current = [line] elif current: current.append(line) if current: blocks.append(current) if not blocks: return None lines = blocks[-1] columns = {name: index for index, name in enumerate(lines[0].split())} def _value(parts: list[str], *names: str) -> float: for name in names: index = columns.get(name) if index is not None and index < len(parts): try: return float(parts[index]) except ValueError: return 0.0 return 0.0 devices: list[dict[str, Any]] = [] for line in lines[1:]: parts = line.split() if not parts or parts[0] == "Device": continue reads = _value(parts, "r/s") writes = _value(parts, "w/s") devices.append( { "device": parts[0], "tps": round(reads + writes, 2), "read_mb_s": round(_value(parts, "rkB/s", "rMB/s") / 1024, 2), "write_mb_s": round(_value(parts, "wkB/s", "wMB/s") / 1024, 2), } ) return {"devices": devices} if devices else None def _in_container() -> bool: """Best-effort container detection (Podman/Docker markers, overlay rootfs).""" if os.path.exists("/run/.containerenv") or os.path.exists("/.dockerenv"): return True for line in _read_lines("/proc/mounts"): parts = line.split() if len(parts) >= 3 and parts[1] == "/" and parts[2] == "overlay": return True return False def collect_disk() -> dict[str, Any]: """Collect disk data.""" mounts = _mounts() total_gb = sum(m["size_gb"] for m in mounts) used_gb = sum(m["used_gb"] for m in mounts) critical = [m for m in mounts if m["use_pct"] > 90] return { "filesystems": mounts, "total_gb": round(total_gb, 1), "used_gb": round(used_gb, 1), "usage_pct": round((used_gb / total_gb) * 100, 1) if total_gb > 0 else 0.0, "critical_fs": critical, "io_stats": _disk_io_stats(), "container": _in_container(), } # ────────────────────────────────────────────────────────────────────────────── # Section: Network # ────────────────────────────────────────────────────────────────────────────── def _net_interfaces() -> list[dict[str, Any]]: """Enumerate non-loopback network interfaces.""" ifaces: list[dict[str, Any]] = [] net_path = "/sys/class/net" if not os.path.isdir(net_path): return ifaces for iface in sorted(os.listdir(net_path)): if iface == "lo": continue operstate = _read_file(os.path.join(net_path, iface, "operstate")) speed_raw = _read_file(os.path.join(net_path, iface, "speed")) speed = f"{speed_raw} Mbps" if speed_raw and speed_raw != "-1" else "unknown" # Get IPs via ip command out, _ = run(["ip", "-br", "addr", "show", iface], timeout=5) ipv4: list[str] = [] ipv6: list[str] = [] if out: parts = out.split() skip_next = False for part in parts[2:]: if part == "peer": skip_next = True # next token is the remote end's address continue if skip_next: skip_next = False continue if "." in part and "/" in part: ipv4.append(part.split("/")[0]) elif ":" in part and "/" in part: ipv6.append(part.split("/")[0]) ifaces.append( { "name": iface, "state": operstate.upper() if operstate else "UNKNOWN", "speed": speed, "ipv4": ipv4, "ipv6": ipv6, } ) return ifaces def _connection_summary() -> dict[str, int]: """Get connection summary via ss -s.""" out, _ = run(["ss", "-s"], timeout=5) result: dict[str, int] = {} for line in out.split("\n"): if "TCP:" in line: match = re.search(r"estab\s+(\d+)", line) if match: result["tcp_established"] = int(match.group(1)) if "Total:" in line: match = re.search(r"(\d+)", line) if match: result["total"] = int(match.group(1)) return result def _default_routes() -> dict[str, str]: """Get default IPv6 and IPv4 routes (IPv6 first).""" routes: dict[str, str] = {"v6": "", "v4": ""} out, _ = run(["ip", "-6", "route", "show", "default"], timeout=5) if out.strip(): routes["v6"] = out.strip() out, _ = run(["ip", "-4", "route", "show", "default"], timeout=5) if out.strip(): routes["v4"] = out.strip() return routes def _external_ip() -> dict[str, str]: """Detect external IP addresses via icanhazip.com (IPv6 first). Only called when the user passes --external-ip: it sends a request to a third-party service, which also reveals this host's address to it. """ result: dict[str, str] = {"v6": "unknown", "v4": "unknown"} for family, flag in (("v6", "-6"), ("v4", "-4")): out, _ = run( ["curl", flag, "--max-time", "5", "-s", "https://icanhazip.com"], timeout=8, ) if out.strip(): result[family] = out.strip() return result def collect_network(include_external: bool = False) -> dict[str, Any]: """Collect network data. External-IP lookup is opt-in.""" data: dict[str, Any] = { "interfaces": _net_interfaces(), "connections": _connection_summary(), "routes": _default_routes(), } if include_external: data["external_ip"] = _external_ip() return data # ────────────────────────────────────────────────────────────────────────────── # Section: GPU # ────────────────────────────────────────────────────────────────────────────── # Matches rocm-smi data lines across ROCm versions, with or without the # bracketed card index: "GPU[0]\t\t: Card series:\t…" and "GPU\t\t: …". _ROCM_LINE = re.compile(r"^GPU(?:\[(\d+)\])?\s*:\s*([^:]+?)\s*:\s*(.+)$") def _rocm_smi_bin() -> str | None: """Locate rocm-smi: PATH first (distro packages), ROCm default as fallback.""" found = shutil.which("rocm-smi") if found: return found default = "/opt/rocm/bin/rocm-smi" return default if os.path.isfile(default) else None def _amd_gpu() -> dict[str, Any] | None: """Collect AMD GPU info via rocm-smi.""" binary = _rocm_smi_bin() if binary is None: return None out, _ = run([binary, "--showproductname", "--showuse"], timeout=10) if not out: return None # Group key/value pairs by card id — every rocm-smi data line is # prefixed with "GPU[N] :", so looping per line would create one # "card" per line instead of one card with many properties. cards: dict[str, dict[str, str]] = {} for line in out.split("\n"): match = _ROCM_LINE.match(line.strip()) if not match: continue card_id = match.group(1) or "0" key = match.group(2).strip() value = match.group(3).strip() cards.setdefault(card_id, {"id": card_id})[key] = value if not cards: return None info: dict[str, Any] = {"vendor": "AMD", "cards": list(cards.values())} vram = _rocm_vram(binary) if vram: info["vram"] = vram return info def _rocm_vram(binary: str) -> dict[str, Any] | None: """Parse ``rocm-smi --showmeminfo vram`` into MiB totals. Values are reported in bytes ("VRAM Total Memory (B)") and aggregated across all cards. Keys are matched exactly: "VRAM Total Used Memory (B)" contains both the words "Total" and "Used", so substring matching would double-count. """ out, _ = run([binary, "--showmeminfo", "vram"], timeout=10) if not out: return None total_bytes = 0 used_bytes = 0 for line in out.split("\n"): match = _ROCM_LINE.match(line.strip()) if not match: continue key = match.group(2).strip() try: value = int(match.group(3).strip().split()[0]) except (ValueError, IndexError): continue if key == "VRAM Total Used Memory (B)": used_bytes += value elif key == "VRAM Total Memory (B)": total_bytes += value if total_bytes <= 0: return None mib = 1024 * 1024 return { "total_mb": total_bytes // mib, "used_mb": used_bytes // mib, "free_mb": (total_bytes - used_bytes) // mib, } def _nvidia_gpu() -> dict[str, Any] | None: """Collect NVIDIA GPU info via nvidia-smi.""" out, _ = run( [ "nvidia-smi", "--query-gpu=name,temperature.gpu,utilization.gpu,memory.used,memory.total", "--format=csv,noheader,nounits", ], timeout=10, ) if not out: return None cards: list[dict[str, Any]] = [] for line in out.strip().split("\n"): parts = [p.strip() for p in line.split(",")] if len(parts) >= 3: try: cards.append( { "name": parts[0], "temperature": parts[1] if len(parts) > 1 else "N/A", "utilization_pct": parts[2] if len(parts) > 2 else "N/A", "memory": ( f"{parts[3]} MiB / {parts[4]} MiB" if len(parts) >= 5 else "N/A" ), } ) except (ValueError, IndexError): pass return {"vendor": "NVIDIA", "cards": cards} if cards else None def collect_gpu() -> dict[str, Any] | None: """Collect GPU data. Falls back to lspci when ROCm/nvidia-smi unavailable.""" nvidia = _nvidia_gpu() if nvidia: return nvidia amd = _amd_gpu() if amd: return amd # Fallback: detect GPU hardware via lspci (no driver needed) return _lspci_gpu() def _lspci_gpu() -> dict[str, Any] | None: """Detect GPU hardware via lspci — works even without vendor drivers. Returns {"unavailable": True} when lspci itself is not installed, so the report can say 'detection unavailable' instead of a false 'no GPU'. """ if shutil.which("lspci") is None: return {"unavailable": True} out, _ = run(["lspci"], timeout=10) if not out: return {"unavailable": True} cards: list[dict[str, str]] = [] for line in out.split("\n"): lower = line.lower() if "vga" in lower or "display" in lower or "3d" in lower: # Extract model: "01:00.0 VGA compatible controller: AMD ..." parts = line.split(": ", 2) model = parts[-1].strip() if len(parts) >= 3 else line.strip() vendor = "AMD" if "amd" in lower or "radeon" in lower or "ati" in lower else "" vendor = vendor or ("NVIDIA" if "nvidia" in lower else "") vendor = vendor or ("Intel" if "intel" in lower else "") cards.append({"model": model, "vendor": vendor, "driver": "(not loaded)"}) if not cards: # Probe for AMD compute devices without a display class (e.g. Instinct) out2, _ = run(["lspci", "-d", "1002:"], timeout=10) if out2: for line in out2.strip().split("\n"): if line.strip(): cards.append( { "model": line.strip(), "vendor": "AMD", "driver": "(not loaded)", } ) if not cards: return None return { "vendor": cards[0]["vendor"] or "unknown", "cards": cards, "fallback": True, } # ────────────────────────────────────────────────────────────────────────────── # Section: Services # ────────────────────────────────────────────────────────────────────────────── def _failed_units() -> list[dict[str, str]] | None: """List failed systemd units, or None when systemctl is unavailable. --plain is required: without it, systemd prefixes failed units with a "●" bullet even when output is piped, which breaks field parsing. """ if shutil.which("systemctl") is None: return None out, _ = run(["systemctl", "--failed", "--no-legend", "--no-pager", "--plain"], timeout=10) units: list[dict[str, str]] = [] for line in out.strip().split("\n"): parts = line.split(None, 4) if len(parts) >= 2: units.append({"unit": parts[0], "state": parts[1]}) return units def _check_service(name: str) -> dict[str, str]: """Check status of a named systemd service.""" out, _ = run(["systemctl", "is-active", name, "--no-pager"], timeout=5) status = out.strip() if out.strip() else "not-found" return {"name": name, "status": status} def collect_services() -> dict[str, Any]: """Collect service data.""" key_services = ["sshd", "firewalld", "nginx", "postgresql"] svc_status = {} for svc in key_services: svc_status[svc] = _check_service(svc) return { "failed_units": _failed_units(), "key_services": svc_status, } # ────────────────────────────────────────────────────────────────────────────── # Section: Security # ────────────────────────────────────────────────────────────────────────────── def _selinux_status() -> str: """Get SELinux mode: Enforcing, Permissive, Disabled, or 'unknown'. 'unknown' means getenforce is not installed at all — callers can then distinguish 'no SELinux tooling' from 'SELinux disabled'. """ out, _ = run(["getenforce"], timeout=5) return out.strip() if out.strip() else "unknown" def _firewalld_info() -> dict[str, Any]: """Get firewalld status. ``available`` is False when firewall-cmd is not installed at all, so callers can distinguish 'tool absent' from 'firewall disabled'. """ result: dict[str, Any] = { "available": False, "active": False, "default_zone": "", "zones": [], } out, _ = run(["firewall-cmd", "--state"], timeout=5) state = out.strip() if not state: return result result["available"] = True result["active"] = state == "running" out, _ = run(["firewall-cmd", "--get-default-zone"], timeout=5) if out.strip(): result["default_zone"] = out.strip() out, _ = run(["firewall-cmd", "--get-active-zones"], timeout=5) if out.strip(): for line in out.split("\n"): # Zone names sit at column 0; member lines (" interfaces: …") # are indented — indentation must be checked before stripping. if not line.strip() or line.startswith((" ", "\t")): continue result["zones"].append(line.strip()) return result def _failed_ssh_attempts() -> int: """Count failed SSH password attempts today.""" out, _ = run( ["journalctl", "-u", "sshd", "--since", "today", "--no-pager"], timeout=10, ) if not out: # Fall back to the "ssh" unit name used by some distros out, _ = run( ["journalctl", "-u", "ssh", "--since", "today", "--no-pager"], timeout=10, ) return out.lower().count("failed password") def _last_logins(count: int = 3) -> list[str]: """Get last N successful logins.""" out, _ = run(["last", "-n", str(count), "--no-hostname"], timeout=5) logins: list[str] = [] for line in out.strip().split("\n"): if line and "reboot" not in line.lower() and "wtmp" not in line.lower(): logins.append(line.strip()) if len(logins) >= count: break return logins def _open_ports_count() -> int: """Count listening TCP ports.""" out, _ = run(["ss", "-tlnp"], timeout=5) # Count lines minus header lines = [entry for entry in out.strip().split("\n") if entry.strip() and "LISTEN" in entry] return len(lines) def collect_security() -> dict[str, Any]: """Collect security data.""" return { "selinux": _selinux_status(), "firewalld": _firewalld_info(), "failed_ssh_attempts": _failed_ssh_attempts(), "last_logins": _last_logins(), "open_ports_count": _open_ports_count(), } # ────────────────────────────────────────────────────────────────────────────── # Section: Performance # ────────────────────────────────────────────────────────────────────────────── def _process_counts() -> dict[str, int]: """Count total and zombie processes.""" total = 0 zombies = 0 for entry in os.listdir("/proc"): try: _pid = int(entry) total += 1 status = _read_file(f"/proc/{entry}/status") for line in status.split("\n"): if line.startswith("State:"): if "Z" in line: zombies += 1 break except (ValueError, OSError): continue return {"total": total, "zombies": zombies} def _top_cpu_processes(count: int = 5) -> list[dict[str, Any]]: """Return top N CPU-consuming processes.""" out, _ = run( ["ps", "-eo", "pid,user,%cpu,comm", "--sort=-%cpu", "--no-headers"], timeout=5, ) procs: list[dict[str, Any]] = [] for line in out.strip().split("\n")[:count]: parts = line.split(None, 3) if len(parts) >= 4: try: procs.append( { "pid": int(parts[0]), "user": parts[1], "cpu_pct": float(parts[2]), "command": parts[3], } ) except ValueError: pass return procs def _context_switches() -> dict[str, int]: """Get context switches and interrupts from /proc/stat.""" result: dict[str, int] = {"ctxt": 0, "intr": 0} for line in _read_lines("/proc/stat"): if line.startswith("ctxt "): try: result["ctxt"] = int(line.split()[1]) except (IndexError, ValueError): pass if line.startswith("intr "): try: result["intr"] = int(line.split()[1]) except (IndexError, ValueError): pass return result def collect_performance() -> dict[str, Any]: """Collect performance data.""" counts = _process_counts() load1, _, _ = _load_average() cores = os.cpu_count() or 1 return { "processes_total": counts["total"], "zombies": counts["zombies"], "top_cpu_processes": _top_cpu_processes(), "context_switches": _context_switches(), "load_vs_cores_ratio": round(load1 / cores, 2), } # ────────────────────────────────────────────────────────────────────────────── # Section: Recent Issues # ────────────────────────────────────────────────────────────────────────────── def collect_issues() -> dict[str, Any]: """Collect recent system issues from journals.""" out, _ = run( ["journalctl", "-p", "err", "-n", "10", "--no-pager"], timeout=10, ) recent_errors = [ line.strip() for line in out.strip().split("\n") if line.strip() and "-- No entries --" not in line ][:10] out, _ = run( ["journalctl", "-k", "--no-pager", "--grep", "Oops|BUG", "--output", "short"], timeout=10, ) kernel_oops = [ line.strip() for line in out.strip().split("\n") if line.strip() and "No entries" not in line ][:5] # Also check kernel panic if not kernel_oops: out2, _ = run( ["journalctl", "-k", "--no-pager", "--grep", "panic", "--output", "short"], timeout=10, ) kernel_oops = [ line.strip() for line in out2.strip().split("\n") if line.strip() and "No entries" not in line ][:5] out, _ = run( ["journalctl", "-k", "--no-pager", "--grep", "Out of memory", "--output", "short"], timeout=10, ) oom_events = [ line.strip() for line in out.strip().split("\n") if line.strip() and "No entries" not in line ][-5:] return { "recent_errors": recent_errors, "kernel_oops": kernel_oops, "oom_events": oom_events, } # ────────────────────────────────────────────────────────────────────────────── # Health Score # ────────────────────────────────────────────────────────────────────────────── def compute_health(data: dict[str, Any]) -> dict[str, Any]: """Compute a 0-100 health score with letter grade. Each factor is worth a fixed maximum: CPU 10, memory 10, disk 15, SELinux 10, failed services 15, load 10, zombies 10, swap 10, firewall 10 (total 100). Factors whose data source is unavailable — section not collected, or the required tool not installed — are excluded and the score is renormalised against the remaining maximum, so missing tooling never looks like a security failure. An explicitly disabled control (e.g. SELinux "Disabled") still scores 0. Letter mapping: A ≥ 90, B ≥ 80, C ≥ 70, D ≥ 60, F < 60. """ earned = 0 possible = 0 breakdown: dict[str, int] = {} maxima: dict[str, int] = {} skipped: list[str] = [] def _factor(name: str, points: int | None, maximum: int) -> None: """Record one factor; ``points=None`` means 'not assessable'.""" nonlocal earned, possible if points is None: skipped.append(name) return breakdown[name] = points maxima[name] = maximum earned += points possible += maximum cpu_pct = data.get("cpu", {}).get("usage_pct") cpu_points = None if cpu_pct is not None: cpu_points = 10 if cpu_pct < 80 else 8 if cpu_pct < 90 else 4 if cpu_pct < 95 else 0 _factor("cpu_usage", cpu_points, 10) mem_pct = data.get("memory", {}).get("usage_pct") mem_points = None if mem_pct is not None: mem_points = 10 if mem_pct < 85 else 8 if mem_pct < 90 else 4 if mem_pct < 95 else 0 _factor("memory_usage", mem_points, 10) critical = data.get("disk", {}).get("critical_fs") disk_points = None if critical is not None: disk_points = 15 if not critical else 10 if len(critical) == 1 else 0 _factor("disk_usage", disk_points, 15) # "unknown" means getenforce is not installed — not assessable. selinux = data.get("security", {}).get("selinux", "unknown") selinux_points = None if selinux != "unknown": selinux_points = 10 if selinux == "Enforcing" else 5 if selinux == "Permissive" else 0 _factor("selinux", selinux_points, 10) # None means systemctl is not installed — not assessable. failed = data.get("services", {}).get("failed_units") svc_points = None if failed is not None: svc_points = 15 if not failed else 10 if len(failed) <= 2 else 5 if len(failed) <= 5 else 0 _factor("services", svc_points, 15) load1 = data.get("overview", {}).get("load_1min") cores = data.get("overview", {}).get("cpu_cores", 1) or 1 load_points = None if load1 is not None: load_points = 10 if load1 < cores else 5 if load1 < cores * 2 else 0 _factor("load", load_points, 10) zombies = data.get("performance", {}).get("zombies") zombie_points = None if zombies is not None: zombie_points = 10 if zombies == 0 else 5 if zombies <= 3 else 0 _factor("zombies", zombie_points, 10) swap_pct = data.get("memory", {}).get("swap_usage_pct") swap_points = None if swap_pct is not None: swap_points = 10 if swap_pct < 50 else 5 if swap_pct < 75 else 0 _factor("swap", swap_points, 10) # available=False means firewall-cmd is not installed — not assessable. fw = data.get("security", {}).get("firewalld", {}) fw_points = None if fw.get("available"): fw_points = 10 if fw.get("active") else 0 _factor("firewall", fw_points, 10) score = round(100 * earned / possible) if possible > 0 else 0 if score >= 90: letter = "A" elif score >= 80: letter = "B" elif score >= 70: letter = "C" elif score >= 60: letter = "D" else: letter = "F" return { "score": score, "max_score": 100, "grade": letter, "breakdown": breakdown, "maxima": maxima, "factors_skipped": skipped, } # ────────────────────────────────────────────────────────────────────────────── # Human-readable report # ────────────────────────────────────────────────────────────────────────────── def _section_header(title: str) -> None: """Print a section divider.""" print(f"\n{BOLD}── {title} ──{RESET}") def _kv(label: str, value: str, colour: str = "") -> None: """Print a key-value line, optionally wrapped in a colour.""" if colour: print(f" {label:<22s} {colour}{value}{RESET}") else: print(f" {label:<22s} {value}") def _kv_status(label: str, ok: bool, ok_text: str = "yes", fail_text: str = "no") -> None: """Print a key-value line with colour-coded boolean status.""" if ok: print(f" {label:<22s} {GREEN}{ok_text}{RESET}") else: print(f" {label:<22s} {RED}{fail_text}{RESET}") def print_report(data: dict[str, Any]) -> None: """Render the human-readable diagnostic report to stdout.""" health = data.get("health", {}) grade_letter = str(health.get("grade", "?")) grade_colour = GRADE_COLOURS.get(grade_letter, RESET) # Present sections in canonical order; numbers adapt to --section subsets. order = [name for name in ALL_SECTIONS if name in data] # --- Header --- print() print(f"{BOLD}{'═' * WIDTH}{RESET}") overview = data.get("overview", {}) print(f" {BOLD}System Diagnostics v{VERSION}{RESET}") print(f"{BOLD}{'═' * WIDTH}{RESET}") print(f" Hostname: {overview.get('hostname', 'unknown')}") print(f" Date: {time.strftime('%Y-%m-%d %H:%M:%S')}") print( f" Health: {grade_colour}{BOLD}{grade_letter}{RESET} " f"({health.get('score', '?')}/{health.get('max_score', 100)})" ) # --- System Overview --- if "overview" in data: _section_header(f"{order.index('overview') + 1}. System Overview") o = data["overview"] _kv("OS", str(o.get("os", "unknown"))) _kv("Kernel", str(o.get("kernel", "unknown"))) _kv("Uptime", str(o.get("uptime_human", "unknown"))) _kv("Boot time", str(o.get("boot_time", "unknown"))) load1 = o.get("load_1min", 0) load5 = o.get("load_5min", 0) load15 = o.get("load_15min", 0) cores = o.get("cpu_cores", 1) load_col = GREEN if load1 < cores else YELLOW if load1 < cores * 2 else RED _kv("Load avg (1/5/15m)", f"{load_col}{load1:.2f} / {load5:.2f} / {load15:.2f}{RESET}") _kv("Users logged in", str(o.get("users_logged", 0))) # --- CPU --- if "cpu" in data: _section_header(f"{order.index('cpu') + 1}. CPU") c = data["cpu"] phys = c.get("physical_cores", 0) threads = c.get("threads", 0) _kv("Model", str(c.get("model", "unknown"))) _kv("Architecture", str(c.get("architecture", "unknown"))) _kv("Cores / Threads", f"{phys} physical / {threads} logical") _kv("Frequency", str(c.get("frequency", "unknown"))) _kv("Temperature", str(c.get("temperature", "unknown"))) usage = c.get("usage_pct", 0.0) usage_col = GREEN if usage < 80 else YELLOW if usage < 90 else RED _kv("CPU usage", f"{usage_col}{usage}%{RESET} {_bar(usage)}") per_core = c.get("per_core", []) if per_core: print(f" {BOLD}Per-core usage:{RESET}") for core in per_core: core_usage = core["usage_pct"] core_col = GREEN if core_usage < 80 else YELLOW if core_usage < 90 else RED print( f" cpu{core['cpu']:<4s} {core_col}{core_usage:>5.1f}%{RESET} " f"{_bar(core_usage)}" ) # --- Memory --- if "memory" in data: _section_header(f"{order.index('memory') + 1}. Memory") m = data["memory"] _kv("Total", f"{m.get('total_mb', 0)} MiB") _kv("Used", f"{m.get('used_mb', 0)} MiB {_bar(m.get('usage_pct', 0))}") _kv("Available", f"{m.get('available_mb', 0)} MiB") _kv("Buffers", f"{m.get('buffers_mb', 0)} MiB") _kv("Cached", f"{m.get('cached_mb', 0)} MiB") swap_total = m.get("swap_total_mb", 0) swap_used = m.get("swap_used_mb", 0) swap_pct = m.get("swap_usage_pct", 0) swap_col = GREEN if swap_pct < 50 else YELLOW if swap_pct < 75 else RED _kv("Swap", f"{swap_col}{swap_used} / {swap_total} MiB{RESET} {_bar(swap_pct)}") top_mem = m.get("top_processes", []) if top_mem: print(f"\n {BOLD}Top 5 memory consumers:{RESET}") print(f" {'PID':<8s} {'USER':<10s} {'RSS':>8s} COMMAND") for proc in top_mem: print( f" {proc['pid']:<8d} {proc['user']:<10s} " f"{proc['rss_mb']:>6.1f}M {proc['command']}" ) # --- Disk --- if "disk" in data: _section_header(f"{order.index('disk') + 1}. Disk") d = data["disk"] _kv("Total space", f"{d.get('total_gb', 0):.1f} GiB") _kv("Used", f"{d.get('used_gb', 0):.1f} GiB {_bar(d.get('usage_pct', 0))}") fss = d.get("filesystems", []) if fss: print(f"\n {BOLD}Filesystems:{RESET}") header = f" {'Device':<20s} {'Mount':<18s} {'Size':>7s} {'Used':>7s} {'Use%'}" print(header) print(f" {'-' * (len(header) - 2)}") for fs in fss: use_pct = fs["use_pct"] pct_col = GREEN if use_pct < 80 else YELLOW if use_pct < 90 else RED dev = fs["device"][:19] if len(fs["device"]) > 19 else fs["device"] mnt = fs["mountpoint"][:17] if len(fs["mountpoint"]) > 17 else fs["mountpoint"] print( f" {dev:<20s} {mnt:<18s} {fs['size_gb']:>6.0f}G " f"{fs['used_gb']:>6.0f}G {pct_col}{use_pct:>4.1f}%{RESET}" ) if not fss and d.get("container"): print(f" {DIM}(container detected — overlay root filesystem not shown){RESET}") critical = d.get("critical_fs", []) if critical: print(f"\n {RED}⚠ Warning: {len(critical)} filesystem(s) above 90% usage!{RESET}") io_stats = d.get("io_stats") if io_stats: print(f"\n {BOLD}I/O Stats (iostat):{RESET}") for dev in io_stats.get("devices", []): print( f" {dev['device']:<10s} tps: {dev['tps']:>8.1f} " f"read: {dev['read_mb_s']:>8.2f} MB/s " f"write: {dev['write_mb_s']:>8.2f} MB/s" ) # --- Network --- if "network" in data: _section_header(f"{order.index('network') + 1}. Network") n = data["network"] ifaces = n.get("interfaces", []) for iface in ifaces: state_col = GREEN if iface["state"] == "UP" else RED v6 = ", ".join(iface["ipv6"]) if iface["ipv6"] else f"{DIM}(none){RESET}" v4 = ", ".join(iface["ipv4"]) if iface["ipv4"] else f"{DIM}(none){RESET}" print( f" {iface['name']:<8s} {state_col}{iface['state']:<5s}{RESET} " f"speed: {iface.get('speed', 'unknown')}" ) print(f" IPv6: {v6}") print(f" IPv4: {v4}") conns = n.get("connections", {}) if conns: _kv("TCP established", str(conns.get("tcp_established", "?"))) _kv("Total connections", str(conns.get("total", "?"))) routes = n.get("routes", {}) if routes.get("v6"): _kv("Default IPv6 route", str(routes["v6"])) if routes.get("v4"): _kv("Default IPv4 route", str(routes["v4"])) ext_ip = n.get("external_ip", {}) if ext_ip.get("v6", "unknown") != "unknown": _kv("External IPv6", str(ext_ip["v6"])) if ext_ip.get("v4", "unknown") != "unknown": _kv("External IPv4", str(ext_ip["v4"])) # --- GPU --- if "gpu" in data: _section_header(f"{order.index('gpu') + 1}. GPU") g = data["gpu"] if g and g.get("unavailable"): print(f" {DIM}GPU detection unavailable (no nvidia-smi/rocm-smi/lspci){RESET}") elif g: if g.get("fallback"): print(f" {YELLOW}(hardware detected, GPU drivers not loaded){RESET}") print(f" Vendor: {g.get('vendor', 'unknown')}") for card in g.get("cards", []): for k, v in card.items(): _kv(k.capitalize(), str(v)) vram = g.get("vram") if vram: total = vram.get("total_mb", 0) used = vram.get("used_mb", 0) free = vram.get("free_mb", 0) pct = (used / total * 100) if total > 0 else 0 colour = GREEN if pct < 80 else YELLOW if pct < 90 else RED _kv("VRAM Used/Total", f"{colour}{used}/{total} MiB{RESET} ({pct:.0f}%)") _kv("VRAM Free", f"{free} MiB") else: print(f" {DIM}No GPU detected{RESET}") # --- Services --- if "services" in data: _section_header(f"{order.index('services') + 1}. Services") s = data["services"] failed = s.get("failed_units") if failed is None: print(f" {DIM}Failed units: unavailable (systemctl not installed){RESET}") elif failed: print(f" {RED}Failed units: {len(failed)}{RESET}") for unit in failed: print(f" {RED}✗{RESET} {unit['unit']} ({unit['state']})") else: print(f" {GREEN}No failed units{RESET}") print() ks = s.get("key_services", {}) for name, info in sorted(ks.items()): status = info.get("status", "unknown") if status == "active": print(f" {GREEN}●{RESET} {name:<15s} {GREEN}{status}{RESET}") elif status == "inactive": print(f" {YELLOW}○{RESET} {name:<15s} {YELLOW}{status}{RESET}") elif status == "failed": print(f" {RED}✗{RESET} {name:<15s} {RED}{status}{RESET}") else: print(f" {DIM}─{RESET} {name:<15s} {DIM}{status}{RESET}") # --- Security --- if "security" in data: _section_header(f"{order.index('security') + 1}. Security") sc = data["security"] selinux = sc.get("selinux", "unknown") if selinux == "Enforcing": sel_col = GREEN elif selinux == "Permissive": sel_col = YELLOW elif selinux == "Disabled": sel_col = RED else: # "unknown" — SELinux tooling not installed sel_col = DIM _kv("SELinux", f"{sel_col}{selinux}{RESET}") fw = sc.get("firewalld", {}) if fw.get("available"): _kv_status("Firewalld active", bool(fw.get("active")), "active", "inactive") if fw.get("default_zone"): _kv("Default zone", str(fw["default_zone"])) if fw.get("zones"): _kv("Active zones", ", ".join(fw["zones"])) else: _kv("Firewalld", f"{DIM}not installed{RESET}") _kv("Failed SSH (today)", str(sc.get("failed_ssh_attempts", 0))) _kv("Open TCP ports", str(sc.get("open_ports_count", 0))) logins = sc.get("last_logins", []) if logins: print(f"\n {BOLD}Last logins:{RESET}") for login in logins: print(f" {login}") # --- Performance --- if "performance" in data: _section_header(f"{order.index('performance') + 1}. Performance") p = data["performance"] _kv("Processes total", str(p.get("processes_total", 0))) zombies = p.get("zombies", 0) zombie_col = GREEN if zombies == 0 else RED _kv("Zombie processes", f"{zombie_col}{zombies}{RESET}") ctxt = p.get("context_switches", {}) _kv("Ctxt switches (boot)", str(ctxt.get("ctxt", "?"))) _kv("Interrupts (boot)", str(ctxt.get("intr", "?"))) load_ratio = p.get("load_vs_cores_ratio", 0) ratio_col = GREEN if load_ratio < 1 else YELLOW if load_ratio < 2 else RED _kv("Load / cores ratio", f"{ratio_col}{load_ratio:.2f}{RESET}") top_cpu = p.get("top_cpu_processes", []) if top_cpu: print(f"\n {BOLD}Top 5 CPU consumers (lifetime average):{RESET}") print(f" {'PID':<8s} {'USER':<10s} {'CPU%':>6s} COMMAND") for proc in top_cpu: print( f" {proc['pid']:<8d} {proc['user']:<10s} " f"{proc['cpu_pct']:>5.1f}% {proc['command']}" ) # --- Recent Issues --- if "issues" in data: _section_header(f"{order.index('issues') + 1}. Recent Issues") i = data["issues"] errors = i.get("recent_errors", []) if errors: print(f" {BOLD}Last {len(errors)} errors from journal:{RESET}") for line in errors: # Truncate long lines display = line[:120] + "…" if len(line) > 120 else line print(f" {DIM}{display}{RESET}") else: print(f" {GREEN}No recent errors in journal{RESET}") oops = i.get("kernel_oops", []) if oops: print(f"\n {RED}⚠ Kernel OOPS / panic messages:{RESET}") for line in oops: display = line[:120] + "…" if len(line) > 120 else line print(f" {RED}{display}{RESET}") else: print(f"\n {GREEN}No kernel OOPS detected{RESET}") oom = i.get("oom_events", []) if oom: print(f"\n {RED}⚠ OOM events:{RESET}") for line in oom: display = line[:120] + "…" if len(line) > 120 else line print(f" {RED}{display}{RESET}") else: print(f"\n {GREEN}No OOM events in kernel log{RESET}") # --- Health Score --- _section_header(f"{len(order) + 1}. Health Score") bd = health.get("breakdown", {}) labels = { "cpu_usage": "CPU usage < 80%", "memory_usage": "Memory usage < 85%", "disk_usage": "No disk > 90%", "selinux": "SELinux enforcing", "services": "No failed services", "load": "Load < cores", "zombies": "No zombie processes", "swap": "Swap usage < 50%", "firewall": "Firewall active", } maxima = health.get("maxima", {}) for key, label in labels.items(): if key not in bd: continue # factor could not be assessed in this run pts = bd[key] max_pts = maxima.get(key, 10) bar = "█" * max(0, pts) + "░" * (max_pts - max(0, pts)) print(f" {label:<24s} {bar} {pts}/{max_pts}") skipped = health.get("factors_skipped", []) if skipped: names = ", ".join(labels.get(key, key) for key in skipped) print(f" {DIM}Not assessed (data unavailable — score renormalised):{RESET}") print(f" {DIM}{names}{RESET}") # --- Footer --- print() print(f"{BOLD}{'═' * WIDTH}{RESET}") print( f" {BOLD}Health Score: {grade_colour}{grade_letter}{RESET} " f"({health.get('score', '?')}/{health.get('max_score', 100)})" ) print(f"{BOLD}{'═' * WIDTH}{RESET}") print() # ────────────────────────────────────────────────────────────────────────────── # Data collection orchestrator # ────────────────────────────────────────────────────────────────────────────── def collect_all(sections: list[str], external_ip: bool = False) -> dict[str, Any]: """Collect all diagnostics data, optionally filtered by section.""" data: dict[str, Any] = { "version": VERSION, "timestamp": time.strftime("%Y-%m-%dT%H:%M:%S%z"), } collectors: dict[str, tuple[Callable[[], Any], str]] = { "overview": (collect_overview, "system overview"), "cpu": (collect_cpu, "CPU"), "memory": (collect_memory, "memory"), "disk": (collect_disk, "disk"), "network": (lambda: collect_network(include_external=external_ip), "network"), "gpu": (collect_gpu, "GPU"), "services": (collect_services, "services"), "security": (collect_security, "security"), "performance": (collect_performance, "performance"), "issues": (collect_issues, "recent issues"), } with ThreadPoolExecutor(max_workers=6) as executor: future_map: dict[Future[Any], str] = { executor.submit(collectors[section][0]): section for section in dict.fromkeys(sections) if section in collectors } for future in as_completed(future_map): section = future_map[future] label = collectors[section][1] _status(f"Collecting {label}") try: result = future.result() data[section] = result if result is not None else {} _status_done() except Exception as exc: data[section] = {"error": str(exc)} _status_done("error") print(f" [{label}] {exc}", file=sys.stderr) return data def main() -> None: """Parse arguments, collect diagnostics, and print the report.""" parser = argparse.ArgumentParser( description=( "System diagnostics — CPU, memory, disk, network, GPU, services, security, performance" ), formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument( "--section", default="", help=( f"Comma-separated sections to run. Available: {', '.join(ALL_SECTIONS)} [default: all]" ), ) parser.add_argument( "--external-ip", action="store_true", help=( "Also detect external IP addresses via icanhazip.com " "(sends a request to a third-party service)" ), ) parser.add_argument( "--json", action="store_true", help="Output machine-readable JSON to stdout", ) parser.add_argument( "--version", action="version", version=f"%(prog)s {VERSION}", ) args = parser.parse_args() if sys.platform != "linux": print( f"{RED}Error: system-diag.py currently supports Linux only " f"(detected platform: {sys.platform}).{RESET}", file=sys.stderr, ) sys.exit(1) # Determine which sections to run if args.section: sections = [s.strip().lower() for s in args.section.split(",")] invalid = [s for s in sections if s not in ALL_SECTIONS] if invalid: print( f"{RED}Error: unknown section(s): {', '.join(invalid)}{RESET}", file=sys.stderr, ) print( f"Available: {', '.join(ALL_SECTIONS)}", file=sys.stderr, ) sys.exit(1) else: sections = list(ALL_SECTIONS) # Collect data data = collect_all(sections, external_ip=args.external_ip) # Compute health score health = compute_health(data) data["health"] = health # Output if args.json: print(json.dumps(data, indent=2, default=str)) else: print_report(data) if __name__ == "__main__": main()