@@ -0,0 +1,462 @@
|
||||
"""Сбор метрик хоста для админ-дашборда (опционально через 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,
|
||||
}
|
||||
Reference in New Issue
Block a user