Files
site/WESP_REL/app/services/admin_system_metrics.py
2026-07-17 12:57:18 +03:00

463 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Сбор метрик хоста для админ-дашборда (опционально через 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,
}