463 lines
14 KiB
Python
463 lines
14 KiB
Python
"""Сбор метрик хоста для админ-дашборда (опционально через 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,
|
||
}
|