"""Linux system metric collection using /proc, /sys and statvfs. The module intentionally depends only on the Python standard library so the dashboard can run on a bare server. Every parser is a pure function and can be tested with synthetic /proc data (see tests/test_metrics.py). """ from __future__ import annotations import datetime as dt import glob import json import os import platform import re import shutil import socket import subprocess import time from pathlib import Path from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple try: # package import from . import __version__ except ImportError: # pragma: no cover - direct module execution __version__ = "1.0.0" # Filesystems worth showing on a storage dashboard. Pseudo filesystems are # deliberately excluded so the panel does not fill with tmpfs/proc entries. REAL_FS_TYPES = { "ext2", "ext3", "ext4", "xfs", "btrfs", "zfs", "f2fs", "jfs", "reiserfs", "bcachefs", "vfat", "exfat", "ntfs", "ntfs3", "fuseblk", "overlay", "aufs", "nfs", "nfs4", "cifs", "smb3", "ceph", "glusterfs", "lustre", "9p", } VIRTUAL_IFACE_PREFIXES = ( "lo", "veth", "docker", "br-", "virbr", "cni", "flannel", "kube", "cali", "tunl", "vxlan", "dummy", "ifb", "lxc", "podman", ) # Linux /proc/stat fields, in order after the "cpu" label. _CPU_FIELDS = ("user", "nice", "system", "idle", "iowait", "irq", "softirq", "steal") _MEMINFO_KEYS = { "MemTotal": "total", "MemFree": "free", "MemAvailable": "available", "Buffers": "buffers", "Cached": "cached", "SReclaimable": "sreclaimable", "Shmem": "shmem", "SwapTotal": "swap_total", "SwapFree": "swap_free", "Dirty": "dirty", "Writeback": "writeback", "Slab": "slab", } _STAT_RE = re.compile(r"^(\d+) \((.+)\) ([A-Za-z]) (.*)$") def _read_text(path: Path) -> str: try: return path.read_text(encoding="utf-8", errors="replace") except (OSError, ValueError): return "" def _as_int(value: str, default: int = 0) -> int: try: return int(value) except (TypeError, ValueError): return default def _pct(used: float, total: float) -> float: if total <= 0: return 0.0 return max(0.0, min(100.0, used / total * 100.0)) def _round(value: Optional[float], digits: int = 1) -> Optional[float]: if value is None: return None return round(float(value), digits) def parse_cpu_stat(text: str) -> Tuple[Optional[Dict[str, int]], List[Dict[str, int]]]: """Parse /proc/stat CPU times into aggregate and per-core dictionaries.""" aggregate: Optional[Dict[str, int]] = None cores: List[Dict[str, int]] = [] for line in text.splitlines(): if not line.startswith("cpu"): continue parts = line.split() if len(parts) < 5: continue label = parts[0] try: values = [int(v) for v in parts[1:9]] except ValueError: continue values += [0] * (8 - len(values)) item = dict(zip(_CPU_FIELDS, values)) item["total"] = sum(values) item["idle_all"] = item["idle"] + item["iowait"] if label == "cpu": aggregate = item elif label[3:].isdigit(): item["index"] = int(label[3:]) cores.append(item) cores.sort(key=lambda c: c["index"]) return aggregate, cores def parse_cpuinfo(text: str) -> Dict[str, Any]: """Extract model name, logical cores and physical core count from cpuinfo.""" model = "" processors = 0 physical = set() physical_id: Optional[str] = None core_id: Optional[str] = None for line in text.splitlines() + [""]: if not line.strip(): if physical_id is not None and core_id is not None: physical.add((physical_id, core_id)) physical_id = core_id = None continue if ":" not in line: continue key, value = line.split(":", 1) key = key.strip().lower() value = value.strip() if key == "processor": processors += 1 elif key in ("model name", "hardware", "processor model") and not model: model = value elif key == "physical id": physical_id = value elif key == "core id": core_id = value return { "model": model, "logical_cores": processors, "physical_cores": len(physical) if physical else processors, } def parse_meminfo(text: str) -> Dict[str, int]: """Parse /proc/meminfo into a canonical byte-valued dictionary.""" data: Dict[str, int] = {} for line in text.splitlines(): if ":" not in line: continue key, raw = line.split(":", 1) key = key.strip() target = _MEMINFO_KEYS.get(key) if not target: continue match = re.search(r"(\d+)", raw) if not match: continue # All tracked fields are reported in kB by the kernel. data[target] = int(match.group(1)) * 1024 return data def parse_loadavg(text: str) -> Dict[str, Any]: parts = text.split() if len(parts) < 5: return {"load1": 0.0, "load5": 0.0, "load15": 0.0, "running": 0, "threads": 0} running = threads = 0 if "/" in parts[3]: left, right = parts[3].split("/", 1) running, threads = _as_int(left), _as_int(right) return { "load1": float(parts[0]), "load5": float(parts[1]), "load15": float(parts[2]), "running": running, "threads": threads, } def parse_uptime(text: str) -> float: parts = text.split() if not parts: return 0.0 try: return max(0.0, float(parts[0])) except ValueError: return 0.0 def parse_net_dev(text: str) -> Dict[str, Dict[str, int]]: """Parse /proc/net/dev, returning cumulative counters by interface.""" result: Dict[str, Dict[str, int]] = {} for line in text.splitlines(): if ":" not in line: continue name, raw = line.split(":", 1) name = name.strip() fields = raw.split() if not name or len(fields) < 16: continue try: values = [int(v) for v in fields[:16]] except ValueError: continue result[name] = { "rx_bytes": values[0], "rx_packets": values[1], "rx_errors": values[2], "rx_drops": values[3], "tx_bytes": values[8], "tx_packets": values[9], "tx_errors": values[10], "tx_drops": values[11], } return result def parse_diskstats(text: str) -> Dict[str, Dict[str, int]]: """Parse /proc/diskstats into cumulative counters by block device.""" result: Dict[str, Dict[str, int]] = {} for line in text.splitlines(): parts = line.split() if len(parts) < 14: continue name = parts[2] try: values = [int(v) for v in parts[3:14]] except ValueError: continue result[name] = { "reads": values[0], "reads_merged": values[1], "sectors_read": values[2], "read_ms": values[3], "writes": values[4], "writes_merged": values[5], "sectors_written": values[6], "write_ms": values[7], "io_in_progress": values[8], "io_ms": values[9], "weighted_io_ms": values[10], } return result def _unescape_mount(value: str) -> str: def replace(match: re.Match[str]) -> str: return chr(int(match.group(1), 8)) return re.sub(r"\\([0-7]{3})", replace, value) def parse_mounts(text: str) -> List[Dict[str, str]]: mounts = [] for line in text.splitlines(): parts = line.split() if len(parts) < 4: continue mounts.append({ "device": _unescape_mount(parts[0]), "mount": _unescape_mount(parts[1]), "fstype": parts[2], "options": parts[3], }) return mounts def build_alerts( cpu_percent: float, memory_percent: float, swap_percent: float, load1: float, cores: int, disks: Sequence[Dict[str, Any]], temp_c: Optional[float], ) -> List[Dict[str, str]]: """Build compact, user-facing alert messages for the status ticker.""" alerts: List[Dict[str, str]] = [] def add(severity: str, message: str) -> None: alerts.append({"severity": severity, "message": message}) if cpu_percent >= 95: add("crit", f"CPU 使用率 {cpu_percent:.0f}%") elif cpu_percent >= 85: add("warn", f"CPU 使用率 {cpu_percent:.0f}%") if memory_percent >= 95: add("crit", f"内存使用率 {memory_percent:.0f}%") elif memory_percent >= 85: add("warn", f"内存使用率 {memory_percent:.0f}%") if swap_percent >= 80: add("crit", f"Swap 已用 {swap_percent:.0f}%") elif swap_percent >= 50: add("warn", f"Swap 已用 {swap_percent:.0f}%") if cores > 0 and load1 >= cores * 3: add("crit", f"负载 {load1:.2f} / {cores} 核") elif cores > 0 and load1 >= cores * 1.5: add("warn", f"负载 {load1:.2f} / {cores} 核") for disk in sorted(disks, key=lambda d: d.get("percent", 0), reverse=True): pct = float(disk.get("percent", 0)) if pct >= 95: add("crit", f"磁盘 {disk.get('mount', '?')} 已用 {pct:.0f}%") elif pct >= 85: add("warn", f"磁盘 {disk.get('mount', '?')} 已用 {pct:.0f}%") if len([a for a in alerts if a["message"].startswith("磁盘")]) >= 2: break if temp_c is not None: if temp_c >= 90: add("crit", f"CPU 温度 {temp_c:.0f}°C") elif temp_c >= 80: add("warn", f"CPU 温度 {temp_c:.0f}°C") order = {"crit": 0, "warn": 1, "info": 2} alerts.sort(key=lambda a: order.get(a["severity"], 3)) return alerts class SystemCollector: """Collect one coherent snapshot from a Linux-like /proc and /sys tree.""" def __init__( self, proc_root: str = "/proc", sys_root: str = "/sys", clock: Callable[[], float] = time.monotonic, wall_clock: Callable[[], float] = time.time, ) -> None: self.proc_root = Path(proc_root) self.sys_root = Path(sys_root) self.clock = clock self.wall_clock = wall_clock self._ncpu = os.cpu_count() or 1 self._page_size = self._get_page_size() self._static = self._read_static() self._prev_cpu: Optional[Dict[str, int]] = None self._prev_cpu_cores: List[Dict[str, int]] = [] self._cpu_total_now = 0 self._prev_net: Dict[str, Dict[str, int]] = {} self._prev_net_ts: Optional[float] = None self._prev_disk: Dict[str, Dict[str, int]] = {} self._prev_disk_ts: Optional[float] = None self._proc_ticks: Dict[int, int] = {} self._prev_proc_total = 0 self._proc_cache: List[Dict[str, Any]] = [] self._proc_cache_ts = 0.0 self._cpu_meta: Dict[str, Any] = {"freq_mhz": None, "temp_c": None, "temp_label": None} self._cpu_meta_ts = 0.0 self._disk_cache: List[Dict[str, Any]] = [] self._disk_cache_ts = 0.0 self._addr_cache: Dict[str, Any] = {"interfaces": {}, "primary": None, "all": []} self._addr_cache_ts = 0.0 def _get_page_size(self) -> int: try: return int(os.sysconf("SC_PAGE_SIZE")) except (AttributeError, ValueError, OSError): return 4096 def _read_static(self) -> Dict[str, Any]: cpuinfo = parse_cpuinfo(_read_text(self.proc_root / "cpuinfo")) osr: Dict[str, str] = {} for line in _read_text(Path("/etc/os-release")).splitlines(): if "=" in line: key, value = line.split("=", 1) osr[key] = value.strip().strip('"') if not osr: for candidate in (self.proc_root.parent / "etc" / "os-release",): for line in _read_text(candidate).splitlines(): if "=" in line: key, value = line.split("=", 1) osr[key] = value.strip().strip('"') os_name = osr.get("PRETTY_NAME") or osr.get("NAME") or platform.platform() if osr.get("VERSION") and osr.get("VERSION") not in os_name: os_name = f"{os_name} {osr['VERSION']}" cores = len(self._per_core_names()) or self._ncpu physical = cpuinfo.get("physical_cores") or cores now = dt.datetime.now().astimezone() offset = now.utcoffset() tz_offset = int(offset.total_seconds()) if offset else -time.timezone tz_name = now.tzname() or time.tzname[0] or "LOCAL" return { "hostname": socket.gethostname() or "unknown", "os": os_name, "kernel": platform.release(), "arch": platform.machine() or "unknown", "cpu_model": cpuinfo.get("model") or platform.processor() or "Unknown CPU", "cores": cores, "physical_cores": physical, "tz_offset": tz_offset, "tz_name": tz_name, "version": __version__, } def _per_core_names(self) -> List[str]: return sorted(p.name for p in self.sys_root.glob("devices/system/cpu/cpu[0-9]*") if p.is_dir()) # ------------------------------------------------------------------ # Host / address helpers # ------------------------------------------------------------------ def _default_interface(self) -> Optional[str]: text = _read_text(self.proc_root / "net" / "route") best: Optional[Tuple[int, str]] = None for line in text.splitlines()[1:]: fields = line.split() if len(fields) < 8: continue iface, dest, _gw, flags, _ref, _use, metric, _mask = fields[:8] try: flag_value = int(flags, 16) except ValueError: continue if dest == "00000000" and flag_value & 0x2: try: value = int(metric) except ValueError: value = 0 if best is None or value < best[0]: best = (value, iface) return best[1] if best else None def _read_addresses(self) -> Dict[str, Any]: mapping: Dict[str, List[str]] = {} ip_bin = shutil.which("ip") if ip_bin: try: proc = subprocess.run( [ip_bin, "-j", "addr", "show"], capture_output=True, text=True, timeout=1.5, check=False, ) if proc.returncode == 0 and proc.stdout.strip(): for item in json.loads(proc.stdout): ifname = item.get("ifname") if not ifname: continue addresses = [] for addr in item.get("addr_info", []): if addr.get("family") == "inet" and addr.get("local"): addresses.append(addr["local"]) if addresses: mapping[ifname] = addresses except (OSError, ValueError, subprocess.SubprocessError): mapping = {} primary: Optional[str] = None try: with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: sock.settimeout(0.2) sock.connect(("1.1.1.1", 80)) primary = sock.getsockname()[0] except OSError: primary = None default_iface = self._default_interface() if primary and default_iface and primary not in mapping.get(default_iface, []): mapping.setdefault(default_iface, []).insert(0, primary) all_ips = sorted({ip for values in mapping.values() for ip in values}) if not primary and all_ips: primary = all_ips[0] return {"interfaces": mapping, "primary": primary, "all": all_ips} def _addresses(self, now: float) -> Dict[str, Any]: if now - self._addr_cache_ts >= 30.0 or not self._addr_cache_ts: self._addr_cache = self._read_addresses() self._addr_cache_ts = now return self._addr_cache # ------------------------------------------------------------------ # CPU / memory / load # ------------------------------------------------------------------ def _read_cpu(self, now: float) -> Dict[str, Any]: aggregate, cores = parse_cpu_stat(_read_text(self.proc_root / "stat")) if aggregate is None: return { "percent": 0.0, "per_core": [], "breakdown": {}, "freq_mhz": None, "temp_c": None, "temp_label": None, "model": self._static["cpu_model"], "cores": self._static["cores"], "physical_cores": self._static["physical_cores"], } self._cpu_total_now = aggregate["total"] first = self._prev_cpu is None if first: used = aggregate["total"] - aggregate["idle_all"] total = aggregate["total"] percent = _pct(used, total) breakdown = { key: _pct(aggregate.get(key, 0), total) for key in ("user", "nice", "system", "iowait", "irq", "softirq", "steal") } per_core = [ _pct(core["total"] - core["idle_all"], core["total"]) for core in cores ] else: prev = self._prev_cpu total_delta = max(0, aggregate["total"] - prev["total"]) idle_delta = max(0, aggregate["idle_all"] - prev["idle_all"]) percent = _pct(total_delta - idle_delta, total_delta) breakdown = {} for key in ("user", "nice", "system", "iowait", "irq", "softirq", "steal"): breakdown[key] = _pct( max(0, aggregate.get(key, 0) - prev.get(key, 0)), total_delta ) per_core = [] prev_cores = {c["index"]: c for c in self._prev_cpu_cores} for core in cores: old = prev_cores.get(core["index"]) if not old: per_core.append(0.0) continue dt = max(0, core["total"] - old["total"]) di = max(0, core["idle_all"] - old["idle_all"]) per_core.append(_pct(dt - di, dt)) self._prev_cpu = aggregate self._prev_cpu_cores = cores meta = self._read_cpu_meta(now) return { "percent": _round(percent, 1), "per_core": [_round(v, 1) for v in per_core], "breakdown": {k: _round(v, 1) for k, v in breakdown.items()}, "freq_mhz": meta["freq_mhz"], "temp_c": meta["temp_c"], "temp_label": meta["temp_label"], "model": self._static["cpu_model"], "cores": len(cores) or self._static["cores"], "physical_cores": self._static["physical_cores"], } def _read_cpu_meta(self, now: float) -> Dict[str, Any]: if now - self._cpu_meta_ts < 3.0 and self._cpu_meta_ts: return self._cpu_meta freqs: List[float] = [] for path in glob.glob(str(self.sys_root / "devices/system/cpu/cpu*/cpufreq/scaling_cur_freq")): try: freqs.append(int(Path(path).read_text().strip()) / 1000.0) except (OSError, ValueError): continue if not freqs: for line in _read_text(self.proc_root / "cpuinfo").splitlines(): if line.lower().startswith("cpu mhz"): try: freqs.append(float(line.split(":", 1)[1].strip())) except (IndexError, ValueError): pass freq = round(sum(freqs) / len(freqs)) if freqs else None zones: List[Tuple[float, str]] = [] for path in glob.glob(str(self.sys_root / "class/thermal/thermal_zone*")): zone = Path(path) raw_text = _read_text(zone / "temp").strip() try: raw = float(raw_text) except ValueError: continue celsius = raw / 1000.0 if raw > 1000 else raw if not -20.0 <= celsius <= 150.0: continue label = _read_text(zone / "type").strip() or zone.name zones.append((celsius, label)) if not zones: for path in glob.glob(str(self.sys_root / "class/hwmon/hwmon*/temp*_input")): pw = Path(path) name = _read_text(pw.parent / "name").strip() label = _read_text(pw.with_name(pw.stem.replace("_input", "_label"))).strip() or name if not any(token in f"{name} {label}".lower() for token in ("cpu", "package", "tctl", "tdie", "core")): continue try: raw = float(_read_text(pw).strip()) except ValueError: continue celsius = raw / 1000.0 if raw > 1000 else raw if -20.0 <= celsius <= 150.0: zones.append((celsius, label)) if zones: preferred = [z for z in zones if any(token in z[1].lower() for token in ("cpu", "package", "tctl", "tdie", "core"))] temp_c, temp_label = max(preferred or zones, key=lambda z: z[0]) temp_c = round(temp_c, 1) else: temp_c, temp_label = None, None self._cpu_meta = {"freq_mhz": freq, "temp_c": temp_c, "temp_label": temp_label} self._cpu_meta_ts = now return self._cpu_meta def _read_memory(self) -> Dict[str, Any]: info = parse_meminfo(_read_text(self.proc_root / "meminfo")) total = info.get("total", 0) free = info.get("free", 0) available = info.get("available") or free buffers = info.get("buffers", 0) cached = info.get("cached", 0) + info.get("sreclaimable", 0) swap_total = info.get("swap_total", 0) swap_free = info.get("swap_free", 0) used = max(0, total - available) if total else 0 percent = _pct(used, total) swap_used = max(0, swap_total - swap_free) swap_percent = _pct(swap_used, swap_total) apps = max(0, total - free - buffers - cached) cache_value = min(cached, max(0, total - free - buffers - apps)) composition = { "apps": apps, "cache": max(0, cache_value), "buffers": buffers, "free": free, } comp_total = sum(composition.values()) if comp_total > total and comp_total > 0: scale = total / comp_total composition = {k: int(v * scale) for k, v in composition.items()} return { "total": total, "used": used, "free": free, "available": available, "buffers": buffers, "cached": cached, "apps": apps, "percent": _round(percent, 1), "composition": composition, "swap_total": swap_total, "swap_used": swap_used, "swap_free": swap_free, "swap_percent": _round(swap_percent, 1), "dirty": info.get("dirty", 0), "writeback": info.get("writeback", 0), } def _read_load(self, cores: int) -> Dict[str, Any]: load = parse_loadavg(_read_text(self.proc_root / "loadavg")) load["percent"] = _round(_pct(load["load1"], max(1, cores)), 1) return load # ------------------------------------------------------------------ # Disks # ------------------------------------------------------------------ def _stat_disk(self, mount: Dict[str, str]) -> Optional[Dict[str, Any]]: mountpoint = mount["mount"] try: stat = os.statvfs(mountpoint) except OSError: return None frsize = stat.f_frsize or stat.f_bsize total = frsize * stat.f_blocks free = frsize * stat.f_bavail used = total - frsize * stat.f_bfree if total <= 0: return None if total < 1024 * 1024: return None percent = _pct(used, used + free) inodes_total = stat.f_files inodes_free = stat.f_ffree return { "device": mount["device"], "mount": mountpoint, "fstype": mount["fstype"], "total": int(total), "used": int(max(0, used)), "free": int(max(0, free)), "percent": _round(percent, 1), "readonly": "ro" in mount["options"].split(","), "inodes_total": int(inodes_total), "inodes_free": int(inodes_free), } def _read_disks(self, now: float) -> List[Dict[str, Any]]: if now - self._disk_cache_ts < 5.0 and self._disk_cache_ts: return self._disk_cache mounts = parse_mounts(_read_text(self.proc_root / "mounts")) disks: List[Dict[str, Any]] = [] seen: set = set() for mount in mounts: fstype = mount["fstype"].lower() device = mount["device"] is_real = ( fstype in REAL_FS_TYPES or device.startswith("/dev/") or (fstype.startswith("fuse.") and fstype not in ("fuse.gvfsd-fuse", "fuse.lxcfs")) ) if not is_real: continue if mount["mount"] in ("/dev", "/proc", "/sys", "/run"): continue disk = self._stat_disk(mount) if not disk: continue key = (disk["device"], disk["total"], disk["used"]) if key in seen: continue seen.add(key) disks.append(disk) disks.sort(key=lambda d: (0 if d["mount"] == "/" else 1, -d["percent"])) self._disk_cache = disks self._disk_cache_ts = now return disks def _is_partition(self, name: str) -> bool: try: if (self.sys_root / "class" / "block" / name / "partition").exists(): return True except OSError: pass patterns = ( r"^(sd|hd|vd|xvd)[a-z]+\d+$", r"^nvme\d+n\d+p\d+$", r"^mmcblk\d+p\d+$", r"^md\d+p\d+$", ) return any(re.match(pattern, name) for pattern in patterns) def _read_disk_io(self, now: float) -> Dict[str, Any]: stats = parse_diskstats(_read_text(self.proc_root / "diskstats")) dt = (now - self._prev_disk_ts) if self._prev_disk_ts is not None else 0.0 devices: List[Dict[str, Any]] = [] new_prev: Dict[str, Dict[str, int]] = {} for name, current in stats.items(): if name.startswith(("loop", "ram", "fd", "sr", "zram")): continue if self._is_partition(name): continue new_prev[name] = current old = self._prev_disk.get(name) if old and dt > 0: read_sectors = max(0, current["sectors_read"] - old["sectors_read"]) write_sectors = max(0, current["sectors_written"] - old["sectors_written"]) reads = max(0, current["reads"] - old["reads"]) writes = max(0, current["writes"] - old["writes"]) read_ms = max(0, current["read_ms"] - old["read_ms"]) write_ms = max(0, current["write_ms"] - old["write_ms"]) io_ms = max(0, current["io_ms"] - old["io_ms"]) devices.append({ "device": name, "read_bps": read_sectors * 512.0 / dt, "write_bps": write_sectors * 512.0 / dt, "read_iops": reads / dt, "write_iops": writes / dt, "util_percent": min(100.0, io_ms / (dt * 1000.0) * 100.0), "read_await_ms": read_ms / reads if reads else 0.0, "write_await_ms": write_ms / writes if writes else 0.0, }) elif current["sectors_read"] or current["sectors_written"]: devices.append({ "device": name, "read_bps": 0.0, "write_bps": 0.0, "read_iops": 0.0, "write_iops": 0.0, "util_percent": 0.0, "read_await_ms": 0.0, "write_await_ms": 0.0, }) self._prev_disk = new_prev self._prev_disk_ts = now devices.sort(key=lambda d: d["read_bps"] + d["write_bps"], reverse=True) devices = devices[:8] for item in devices: item["read_bps"] = round(item["read_bps"]) item["write_bps"] = round(item["write_bps"]) item["read_iops"] = round(item["read_iops"], 1) item["write_iops"] = round(item["write_iops"], 1) item["util_percent"] = round(item["util_percent"], 1) item["read_await_ms"] = round(item["read_await_ms"], 2) item["write_await_ms"] = round(item["write_await_ms"], 2) return { "devices": devices, "read_bps": sum(d["read_bps"] for d in devices), "write_bps": sum(d["write_bps"] for d in devices), } # ------------------------------------------------------------------ # Network # ------------------------------------------------------------------ def _iface_speed(self, name: str) -> Optional[int]: try: raw = _read_text(self.sys_root / "class" / "net" / name / "speed").strip() value = int(raw) return value if value > 0 else None except (ValueError, OSError): return None def _read_network(self, now: float) -> Dict[str, Any]: stats = parse_net_dev(_read_text(self.proc_root / "net" / "dev")) addresses = self._addresses(now) dt = (now - self._prev_net_ts) if self._prev_net_ts is not None else 0.0 interfaces: List[Dict[str, Any]] = [] default_iface = self._default_interface() for name, current in stats.items(): if name == "lo": continue old = self._prev_net.get(name) rx_bps = (current["rx_bytes"] - old["rx_bytes"]) / dt if old and dt > 0 else 0.0 tx_bps = (current["tx_bytes"] - old["tx_bytes"]) / dt if old and dt > 0 else 0.0 rx_bps = max(0.0, rx_bps) tx_bps = max(0.0, tx_bps) is_virtual = name.startswith(VIRTUAL_IFACE_PREFIXES) ips = addresses["interfaces"].get(name, []) if not ips and name == default_iface and addresses.get("primary"): ips = [addresses["primary"]] interfaces.append({ "name": name, "ip": ips[0] if ips else "", "virtual": is_virtual, "primary": name == default_iface, "rx_bps": rx_bps, "tx_bps": tx_bps, "rx_total": current["rx_bytes"], "tx_total": current["tx_bytes"], "errors": current["rx_errors"] + current["tx_errors"], "drops": current["rx_drops"] + current["tx_drops"], "speed_mbps": self._iface_speed(name), "link": bool(ips) or rx_bps > 0 or tx_bps > 0, }) physical = [i for i in interfaces if not i["virtual"]] aggregate_source = physical or interfaces total_rx = sum(i["rx_bps"] for i in aggregate_source) total_tx = sum(i["tx_bps"] for i in aggregate_source) for item in interfaces: item["rx_bps"] = round(item["rx_bps"]) item["tx_bps"] = round(item["tx_bps"]) interfaces.sort(key=lambda i: (not i["primary"], i["virtual"], -(i["rx_bps"] + i["tx_bps"]))) self._prev_net = stats self._prev_net_ts = now return { "interfaces": interfaces[:8], "interface_count": len(interfaces), "total_rx_bps": round(total_rx), "total_tx_bps": round(total_tx), "primary_iface": default_iface, "primary_ip": addresses.get("primary"), "all_ips": addresses.get("all", []), "errors": sum(i["errors"] for i in interfaces), "drops": sum(i["drops"] for i in interfaces), } # ------------------------------------------------------------------ # Processes # ------------------------------------------------------------------ def _read_processes(self, now: float, mem_total: int) -> List[Dict[str, Any]]: if self._proc_cache_ts and now - self._proc_cache_ts < 2.0: return self._proc_cache pids: List[int] = [] try: with os.scandir(self.proc_root) as entries: for entry in entries: if entry.is_dir(follow_symlinks=False) and entry.name.isdigit(): pids.append(int(entry.name)) except OSError: return self._proc_cache total_jiffies = self._cpu_total_now delta_total = max(0, total_jiffies - self._prev_proc_total) previous = self._proc_ticks new_ticks: Dict[int, int] = {} candidates: List[Dict[str, Any]] = [] for pid in pids: text = _read_text(self.proc_root / str(pid) / "stat") match = _STAT_RE.match(text) if not match: continue try: rest = match.group(4).split() utime = int(rest[10]) stime = int(rest[11]) rss_pages = int(rest[20]) threads = int(rest[16]) except (IndexError, ValueError): continue ticks = utime + stime new_ticks[pid] = ticks old_ticks = previous.get(pid) cpu_percent = 0.0 if old_ticks is not None and delta_total > 0: cpu_percent = max(0.0, (ticks - old_ticks) / delta_total * self._ncpu * 100.0) rss = max(0, rss_pages) * self._page_size candidates.append({ "pid": pid, "name": match.group(2).strip(), "cmd": "", "cpu_percent": cpu_percent, "mem_percent": _pct(rss, mem_total), "rss_bytes": rss, "threads": threads, }) self._proc_ticks = new_ticks self._prev_proc_total = total_jiffies by_cpu = sorted(candidates, key=lambda p: p["cpu_percent"], reverse=True)[:8] by_mem = sorted(candidates, key=lambda p: p["rss_bytes"], reverse=True)[:8] selected: List[Dict[str, Any]] = [] seen = set() for item in by_cpu + by_mem: if item["pid"] in seen: continue seen.add(item["pid"]) selected.append(item) if len(selected) >= 12: break for item in selected: raw = _read_text(self.proc_root / str(item["pid"]) / "cmdline") cmd = " ".join(part for part in raw.split("\x00") if part).strip() if not cmd: cmd = item["name"] item["cmd"] = cmd[:90] item["cpu_percent"] = round(item["cpu_percent"], 1) item["mem_percent"] = round(item["mem_percent"], 2) selected.sort(key=lambda p: p["cpu_percent"], reverse=True) self._proc_cache = selected self._proc_cache_ts = now return selected # ------------------------------------------------------------------ # Snapshot # ------------------------------------------------------------------ def snapshot(self) -> Dict[str, Any]: now = self.clock() wall = self.wall_clock() cpu = self._read_cpu(now) memory = self._read_memory() load = self._read_load(cpu["cores"] or self._static["cores"]) uptime = parse_uptime(_read_text(self.proc_root / "uptime")) disks = self._read_disks(now) disk_io = self._read_disk_io(now) network = self._read_network(now) addresses = self._addresses(now) processes = self._read_processes(now, memory["total"]) alerts = build_alerts( cpu_percent=float(cpu["percent"]), memory_percent=float(memory["percent"]), swap_percent=float(memory["swap_percent"]), load1=float(load["load1"]), cores=int(cpu["cores"] or self._static["cores"]), disks=disks, temp_c=cpu["temp_c"], ) host = dict(self._static) local_now = dt.datetime.now().astimezone() local_offset = local_now.utcoffset() host.update({ "tz_offset": int(local_offset.total_seconds()) if local_offset else host["tz_offset"], "tz_name": local_now.tzname() or host["tz_name"], "boot_epoch": wall - uptime, "uptime_seconds": round(uptime, 1), "primary_ip": addresses.get("primary"), "ips": addresses.get("all", []), "process_count": len(self._proc_ticks), "thread_count": load.get("threads", 0), }) return { "ts": wall, "version": __version__, "host": host, "cpu": cpu, "memory": memory, "load": load, "disks": disks, "disk_io": disk_io, "network": network, "processes": processes, "alerts": alerts, }