"""Сбор метрик хоста для админ-дашборда (опционально через psutil).""" from __future__ import annotations import os import re import socket import subprocess import sys import time from typing import Any, Dict, List, Optional _NET_STATE: Optional[Dict[str, Any]] = None _ARPHRD_LOOPBACK = 772 def _ip_addresses_mode() -> str: try: from flask import current_app, has_app_context if has_app_context(): raw = current_app.config.get("WESP_ADMIN_IP_ADDRESSES", "physical") return str(raw).strip().lower() except Exception: pass return os.getenv("WESP_ADMIN_IP_ADDRESSES", "physical").strip().lower() def _interface_name_likely_virtual(name: str) -> bool: """Эвристика виртуальных/туннельных интерфейсов (docker, utun, tap, …).""" n = name.lower() if n == "lo" or re.fullmatch(r"lo\d+", n): return True if re.fullmatch(r"br\d+", n) or "bridge" in n: return True prefixes = ( "docker", "br-", "veth", "virbr", "vmnet", "vboxnet", "utun", "awdl", "dummy", "tun", "tap", "wg", "ifb", "erspan", "gre", "sit", "ip6tnl", "ppp", "gretap", "mesh", "vethernet", ) return any(n == p or n.startswith(p) for p in prefixes) def network_interface_is_physical(name: str) -> bool: """ Узкий фильтр «физический носитель»: не loopback и не типичный виртуальный интерфейс. Linux: sysfs type/device/wireless; VLAN — по базовому имени (eth0.10 → eth0). macOS: в основном en*; Windows: всё кроме loopback и явных виртуальных имён. """ if _interface_name_likely_virtual(name): return False if sys.platform.startswith("linux"): type_path = f"/sys/class/net/{name}/type" try: with open(type_path, encoding="utf-8") as f: if int(f.read().strip()) == _ARPHRD_LOOPBACK: return False except (OSError, ValueError): pass dev = f"/sys/class/net/{name}/device" wl = f"/sys/class/net/{name}/wireless" if os.path.exists(dev) or os.path.isdir(wl): return True if "." in name: base = name.split(".", 1)[0] if base != name and not _interface_name_likely_virtual(base): return network_interface_is_physical(base) return bool( re.match(r"^(eth|en|wlan|wl|wwp|usb|enx|bond|team)\d?", name, re.I), ) if sys.platform == "darwin": return bool(re.match(r"^en\d", name)) nl = name.lower() if "loopback" in nl: return False return True def _human_uptime(seconds: int) -> str: seconds = max(0, int(seconds)) d, r = divmod(seconds, 86400) h, r = divmod(r, 3600) m, s = divmod(r, 60) parts: List[str] = [] if d: parts.append(f"{d}д") if h: parts.append(f"{h}ч") if m: parts.append(f"{m}м") if not parts and s: parts.append(f"{s}с") if not parts: parts.append("0м") return " ".join(parts) def _network_addresses() -> List[Dict[str, str]]: try: import psutil except ImportError: return [] mode = _ip_addresses_mode() physical_only = mode not in ("all", "any", "*", "1", "true", "yes") rows: List[Dict[str, str]] = [] try: for iface, snics in psutil.net_if_addrs().items(): if physical_only and not network_interface_is_physical(iface): continue for snic in snics: if snic.family == socket.AF_INET: fam = "IPv4" elif snic.family == socket.AF_INET6: fam = "IPv6" else: continue addr = snic.address.split("%")[0] rows.append( { "interface": iface, "family": fam, "address": addr, } ) except OSError: pass rows.sort(key=lambda x: (x["interface"], x["family"], x["address"])) return rows def _connection_counts() -> Optional[Dict[str, int]]: try: import psutil except ImportError: return None try: conns = psutil.net_connections(kind="inet") except (psutil.AccessDenied, PermissionError, OSError): return None tcp = 0 udp = 0 for c in conns: if c.type == socket.SOCK_STREAM: tcp += 1 elif c.type == socket.SOCK_DGRAM: udp += 1 return {"tcp": tcp, "udp": udp} def _run_text(args: List[str], timeout: float) -> str: try: r = subprocess.run( args, capture_output=True, text=True, timeout=timeout, ) if r.returncode != 0: return "" return (r.stdout or "").strip() except (OSError, subprocess.TimeoutExpired, FileNotFoundError): return "" def _format_os_line() -> str: import platform system = platform.system() release = platform.release() machine = platform.machine() or "" if system == "Darwin": v = _run_text(["sw_vers", "-productVersion"], 2.0) if v: release = v parts = [f"{system} {release}"] if machine: parts.append(machine) return " · ".join(parts) def _detect_cpu_model() -> str: import platform if sys.platform.startswith("linux"): try: with open("/proc/cpuinfo", encoding="utf-8", errors="replace") as f: for line in f: ln = line.strip() if ln.startswith("model name") or ln.startswith("Model name") or ln.startswith("cpu model"): if ":" in ln: return ln.split(":", 1)[1].strip() except OSError: pass proc = platform.processor() return proc if proc else "—" if sys.platform == "darwin": v = _run_text(["sysctl", "-n", "machdep.cpu.brand_string"], 2.0) if v: return v v = _run_text(["sysctl", "-n", "hw.model"], 2.0) if v: return v proc = platform.processor() if proc: return proc return platform.machine() or "—" def _detect_gpu_label() -> str: if sys.platform == "darwin": try: r = subprocess.run( ["system_profiler", "SPDisplaysDataType"], capture_output=True, text=True, timeout=6.0, ) if r.returncode == 0 and r.stdout: for line in r.stdout.splitlines(): s = line.strip() if s.startswith("Chipset Model:"): return s.split(":", 1)[1].strip() except (OSError, subprocess.TimeoutExpired, FileNotFoundError): pass return "н/д" if sys.platform.startswith("linux"): try: r = subprocess.run( ["lspci"], capture_output=True, text=True, timeout=3.0, ) if r.returncode == 0 and r.stdout: for line in r.stdout.splitlines(): low = line.lower() if ( "vga compatible controller" in low or "3d controller" in low or "display controller" in low ): parts = line.split(":", 2) if len(parts) >= 3: return parts[-1].strip()[:160] except (OSError, subprocess.TimeoutExpired, FileNotFoundError): pass return "н/д" def _linux_root_block_base() -> Optional[str]: try: with open("/proc/mounts", encoding="utf-8") as f: for line in f: parts = line.split() if len(parts) < 2 or parts[1] != "/": continue src = parts[0] if not src.startswith("/dev/"): return None name = src.rsplit("/", 1)[-1] m = re.match(r"(nvme\d+n\d+)p\d+$", name) if m: return m.group(1) m = re.match(r"(nvme\d+n\d+)$", name) if m: return m.group(1) base = re.sub(r"\d+$", "", name) return base or None except OSError: return None return None def _detect_root_disk_media() -> str: if sys.platform == "darwin": try: r = subprocess.run( ["diskutil", "info", "/"], capture_output=True, text=True, timeout=4.0, ) if r.returncode == 0 and r.stdout: for line in r.stdout.splitlines(): if "Solid State:" in line: rest = line.split(":", 1)[-1].strip().lower() if rest.startswith("y"): return "SSD" if rest.startswith("n"): return "HDD" except (OSError, subprocess.TimeoutExpired, FileNotFoundError): pass return "н/д" if sys.platform.startswith("linux"): base = _linux_root_block_base() if not base: return "н/д" rot_path = f"/sys/block/{base}/queue/rotational" try: with open(rot_path, encoding="utf-8") as f: v = f.read().strip() if v == "0": return "SSD / NVMe" if v == "1": return "HDD" except OSError: pass return "н/д" def collect_host_hardware_overview() -> Dict[str, Any]: """ ОС и железо для вкладки «Система». GPU и тип носителя — по возможности; иначе «н/д». """ overview: Dict[str, Any] = { "os": _format_os_line(), "cpu_model": _detect_cpu_model(), "cpu_logical_cores": None, "gpu": _detect_gpu_label(), "ram_total_bytes": None, "root_disk_media": _detect_root_disk_media(), "root_disk_used_bytes": None, "root_disk_total_bytes": None, "root_disk_percent": None, } try: import psutil except ImportError: return overview n = int(psutil.cpu_count(logical=True) or 0) overview["cpu_logical_cores"] = n if n > 0 else None overview["ram_total_bytes"] = int(psutil.virtual_memory().total) try: du = psutil.disk_usage("/") overview["root_disk_used_bytes"] = int(du.used) overview["root_disk_total_bytes"] = int(du.total) overview["root_disk_percent"] = round(float(du.percent), 1) except OSError: pass return overview def collect_system_metrics() -> Dict[str, Any]: try: import psutil except ImportError: return { "available": False, "message": "Установите пакет psutil (pip install psutil) для метрик CPU/RAM/диска.", } now = time.time() boot_t = float(psutil.boot_time()) host_uptime_sec = int(max(0.0, now - boot_t)) proc_uptime_sec = None try: proc = psutil.Process() proc_uptime_sec = int(max(0.0, now - float(proc.create_time()))) except (psutil.Error, OSError, TypeError): pass cpu_percent = float(psutil.cpu_percent(interval=None)) vm = psutil.virtual_memory() sw = psutil.swap_memory() disk_block: Dict[str, Any] | None try: du = psutil.disk_usage("/") disk_block = { "path": "/", "used": int(du.used), "total": int(du.total), "percent": round(du.percent, 2), } except OSError: disk_block = None load_avg = None try: load_avg = [round(x, 2) for x in os.getloadavg()] except (OSError, AttributeError): pass global _NET_STATE net = psutil.net_io_counters() upload_bps = 0.0 download_bps = 0.0 if _NET_STATE is not None and now > float(_NET_STATE["t"]): dt = now - float(_NET_STATE["t"]) if dt > 0: upload_bps = max(0.0, (int(net.bytes_sent) - int(_NET_STATE["sent"])) / dt) download_bps = max(0.0, (int(net.bytes_recv) - int(_NET_STATE["recv"])) / dt) _NET_STATE = {"t": now, "sent": int(net.bytes_sent), "recv": int(net.bytes_recv)} conn_counts = _connection_counts() addrs = _network_addresses() return { "available": True, "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(int(now))), "host": { "boot_time_unix": int(boot_t), "uptime_seconds": host_uptime_sec, "uptime_human": _human_uptime(host_uptime_sec), }, "process": { "pid": os.getpid(), "uptime_seconds": proc_uptime_sec, "uptime_human": _human_uptime(proc_uptime_sec) if proc_uptime_sec is not None else None, }, "cpu": { "percent": round(cpu_percent, 2), "cores": int(psutil.cpu_count(logical=True) or 1), }, "memory": { "used": int(vm.used), "total": int(vm.total), "percent": round(vm.percent, 2), }, "swap": { "used": int(sw.used), "total": int(sw.total), "percent": round(sw.percent, 2) if sw.total else 0.0, }, "disk": disk_block, "network": { "upload_bps": round(upload_bps, 2), "download_bps": round(download_bps, 2), "bytes_sent_total": int(net.bytes_sent), "bytes_recv_total": int(net.bytes_recv), }, "load_avg": load_avg, "connections": conn_counts, "addresses": addrs, }