mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 05:30:46 +08:00
Lazy-load module routes, restyle remaining light cards/modals/sidebars, and let canvas background be tuned from the display menu. Co-authored-by: Cursor <cursoragent@cursor.com>
420 lines
13 KiB
Python
420 lines
13 KiB
Python
"""Host CPU / memory metrics (Windows + Linux), used by workbench gauges."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import platform
|
||
import threading
|
||
import time
|
||
from typing import Any
|
||
|
||
_CPU_SAMPLE: tuple[float, float, float] | None = None # (idle, total, mono)
|
||
_PSUTIL_PRIMED = False
|
||
_CACHE_LOCK = threading.Lock()
|
||
_CACHED_HOST: dict[str, Any] | None = None
|
||
_CACHED_AT = 0.0
|
||
_CACHE_TTL_SEC = 1.5
|
||
|
||
# Windows Task Manager uses "% Processor Utility" (freq-aware), not "% Processor Time".
|
||
# On modern machines Utility is often ~5–10× Time; matching TM requires PDH Utility.
|
||
_PDH_LOCK = threading.Lock()
|
||
_PDH_QUERY: Any = None
|
||
_PDH_COUNTER: Any = None
|
||
_PDH_PRIMED = False
|
||
_PDH_LAST: float | None = None
|
||
_PDH_FMT_DOUBLE = 0x00000200
|
||
_PDH_PATH = r"\Processor Information(_Total)\% Processor Utility"
|
||
|
||
|
||
def _clamp_pct(n: float) -> float:
|
||
if n != n: # NaN
|
||
return 0.0
|
||
return max(0.0, min(100.0, float(n)))
|
||
|
||
|
||
def _windows_cpu_utility_pdh(sample_sec: float = 0.25) -> float | None:
|
||
"""Return Task Manager–aligned CPU % via PDH Processor Utility."""
|
||
global _PDH_QUERY, _PDH_COUNTER, _PDH_PRIMED, _PDH_LAST
|
||
if platform.system().lower() != "windows":
|
||
return None
|
||
try:
|
||
import ctypes
|
||
from ctypes import wintypes
|
||
except Exception:
|
||
return None
|
||
|
||
class PDH_FMT_COUNTERVALUE(ctypes.Structure):
|
||
class _U(ctypes.Union):
|
||
_fields_ = [
|
||
("longValue", wintypes.LONG),
|
||
("doubleValue", ctypes.c_double),
|
||
("largeValue", ctypes.c_longlong),
|
||
("AnsiStringValue", wintypes.LPSTR),
|
||
("WideStringValue", wintypes.LPWSTR),
|
||
]
|
||
|
||
_anonymous_ = ("u",)
|
||
_fields_ = [("CStatus", wintypes.DWORD), ("u", _U)]
|
||
|
||
try:
|
||
pdh = ctypes.WinDLL("pdh")
|
||
pdh.PdhOpenQueryW.argtypes = [wintypes.LPCWSTR, ctypes.c_ulonglong, ctypes.POINTER(ctypes.c_void_p)]
|
||
pdh.PdhOpenQueryW.restype = wintypes.DWORD
|
||
pdh.PdhAddCounterW.argtypes = [
|
||
ctypes.c_void_p,
|
||
wintypes.LPCWSTR,
|
||
ctypes.c_ulonglong,
|
||
ctypes.POINTER(ctypes.c_void_p),
|
||
]
|
||
pdh.PdhAddCounterW.restype = wintypes.DWORD
|
||
pdh.PdhAddEnglishCounterW.argtypes = pdh.PdhAddCounterW.argtypes
|
||
pdh.PdhAddEnglishCounterW.restype = wintypes.DWORD
|
||
pdh.PdhCollectQueryData.argtypes = [ctypes.c_void_p]
|
||
pdh.PdhCollectQueryData.restype = wintypes.DWORD
|
||
pdh.PdhGetFormattedCounterValue.argtypes = [
|
||
ctypes.c_void_p,
|
||
wintypes.DWORD,
|
||
ctypes.POINTER(wintypes.DWORD),
|
||
ctypes.POINTER(PDH_FMT_COUNTERVALUE),
|
||
]
|
||
pdh.PdhGetFormattedCounterValue.restype = wintypes.DWORD
|
||
except Exception:
|
||
return None
|
||
|
||
with _PDH_LOCK:
|
||
try:
|
||
if _PDH_QUERY is None:
|
||
h_query = ctypes.c_void_p()
|
||
h_counter = ctypes.c_void_p()
|
||
if pdh.PdhOpenQueryW(None, 0, ctypes.byref(h_query)) != 0:
|
||
return None
|
||
rc = pdh.PdhAddEnglishCounterW(h_query, _PDH_PATH, 0, ctypes.byref(h_counter))
|
||
if rc != 0:
|
||
rc = pdh.PdhAddCounterW(h_query, _PDH_PATH, 0, ctypes.byref(h_counter))
|
||
if rc != 0:
|
||
return None
|
||
_PDH_QUERY = h_query
|
||
_PDH_COUNTER = h_counter
|
||
_PDH_PRIMED = False
|
||
|
||
# After prime, CollectQueryData alone is enough (matches Task Manager Utility).
|
||
if pdh.PdhCollectQueryData(_PDH_QUERY) != 0:
|
||
return _PDH_LAST
|
||
if not _PDH_PRIMED:
|
||
time.sleep(max(0.05, min(0.15, float(sample_sec))))
|
||
if pdh.PdhCollectQueryData(_PDH_QUERY) != 0:
|
||
return _PDH_LAST
|
||
_PDH_PRIMED = True
|
||
|
||
val = PDH_FMT_COUNTERVALUE()
|
||
typ = wintypes.DWORD()
|
||
if pdh.PdhGetFormattedCounterValue(
|
||
_PDH_COUNTER, _PDH_FMT_DOUBLE, ctypes.byref(typ), ctypes.byref(val)
|
||
) != 0:
|
||
return _PDH_LAST
|
||
out = _clamp_pct(float(val.doubleValue))
|
||
_PDH_LAST = out
|
||
return out
|
||
except Exception:
|
||
return _PDH_LAST
|
||
|
||
|
||
def _sample_cpu_percent() -> tuple[float, str]:
|
||
"""Return (cpu_percent, cpu_source_tag)."""
|
||
if platform.system().lower() == "windows":
|
||
util = _windows_cpu_utility_pdh(0.25)
|
||
if util is not None:
|
||
return util, "pdh_utility"
|
||
|
||
global _PSUTIL_PRIMED
|
||
try:
|
||
import psutil # type: ignore
|
||
except Exception:
|
||
return 0.0, "none"
|
||
|
||
if not _PSUTIL_PRIMED:
|
||
try:
|
||
psutil.cpu_percent(interval=None)
|
||
except Exception:
|
||
pass
|
||
_PSUTIL_PRIMED = True
|
||
try:
|
||
cpu = float(psutil.cpu_percent(interval=0.2))
|
||
except Exception:
|
||
cpu = 0.0
|
||
return _clamp_pct(cpu), "psutil"
|
||
|
||
|
||
def _host_via_psutil() -> dict[str, Any] | None:
|
||
try:
|
||
import psutil # type: ignore
|
||
except Exception:
|
||
return None
|
||
|
||
cpu, cpu_src = _sample_cpu_percent()
|
||
vm = psutil.virtual_memory()
|
||
return {
|
||
"source": f"psutil+{cpu_src}" if cpu_src != "psutil" else "psutil",
|
||
"platform": platform.system().lower() or "unknown",
|
||
"cpu_percent": round(_clamp_pct(cpu), 1),
|
||
"cpu_metric": "processor_utility" if cpu_src == "pdh_utility" else "processor_time",
|
||
"cpu_count": int(psutil.cpu_count() or 0) or None,
|
||
"mem_percent": round(_clamp_pct(float(vm.percent)), 1),
|
||
"mem_used_bytes": int(vm.used),
|
||
"mem_total_bytes": int(vm.total),
|
||
"mem_available_bytes": int(vm.available),
|
||
}
|
||
|
||
|
||
def _read_linux_cpu_times() -> tuple[float, float] | None:
|
||
try:
|
||
with open("/proc/stat", "r", encoding="utf-8") as f:
|
||
line = f.readline()
|
||
except OSError:
|
||
return None
|
||
if not line.startswith("cpu "):
|
||
return None
|
||
parts = line.split()
|
||
try:
|
||
nums = [float(x) for x in parts[1:]]
|
||
except ValueError:
|
||
return None
|
||
if len(nums) < 4:
|
||
return None
|
||
idle = nums[3] + (nums[4] if len(nums) > 4 else 0.0) # idle + iowait
|
||
total = sum(nums)
|
||
return idle, total
|
||
|
||
|
||
def _read_linux_mem() -> dict[str, int] | None:
|
||
try:
|
||
with open("/proc/meminfo", "r", encoding="utf-8") as f:
|
||
lines = f.readlines()
|
||
except OSError:
|
||
return None
|
||
kv: dict[str, int] = {}
|
||
for line in lines:
|
||
if ":" not in line:
|
||
continue
|
||
key, rest = line.split(":", 1)
|
||
token = rest.strip().split()[0]
|
||
try:
|
||
kv[key] = int(token) * 1024 # kB → bytes
|
||
except ValueError:
|
||
continue
|
||
total = kv.get("MemTotal")
|
||
if not total:
|
||
return None
|
||
available = kv.get("MemAvailable")
|
||
if available is None:
|
||
free = kv.get("MemFree", 0)
|
||
buffers = kv.get("Buffers", 0)
|
||
cached = kv.get("Cached", 0)
|
||
available = free + buffers + cached
|
||
used = max(0, total - available)
|
||
return {"total": total, "available": available, "used": used}
|
||
|
||
|
||
def _linux_cpu_percent() -> float:
|
||
global _CPU_SAMPLE
|
||
now = _read_linux_cpu_times()
|
||
if now is None:
|
||
return 0.0
|
||
idle, total = now
|
||
prev = _CPU_SAMPLE
|
||
if prev is None or (time.monotonic() - prev[2]) < 0.15:
|
||
# Need a meaningful window; sleep then resample.
|
||
time.sleep(0.2)
|
||
again = _read_linux_cpu_times()
|
||
if again is None:
|
||
return 0.0
|
||
idle2, total2 = again
|
||
_CPU_SAMPLE = (idle2, total2, time.monotonic())
|
||
di, dt = idle2 - idle, total2 - total
|
||
else:
|
||
di, dt = idle - prev[0], total - prev[1]
|
||
_CPU_SAMPLE = (idle, total, time.monotonic())
|
||
if dt <= 0:
|
||
return 0.0
|
||
return _clamp_pct((1.0 - (di / dt)) * 100.0)
|
||
|
||
|
||
def _windows_mem() -> dict[str, int] | None:
|
||
try:
|
||
import ctypes
|
||
from ctypes import wintypes
|
||
except Exception:
|
||
return None
|
||
|
||
class MEMORYSTATUSEX(ctypes.Structure):
|
||
_fields_ = [
|
||
("dwLength", wintypes.DWORD),
|
||
("dwMemoryLoad", wintypes.DWORD),
|
||
("ullTotalPhys", ctypes.c_uint64),
|
||
("ullAvailPhys", ctypes.c_uint64),
|
||
("ullTotalPageFile", ctypes.c_uint64),
|
||
("ullAvailPageFile", ctypes.c_uint64),
|
||
("ullTotalVirtual", ctypes.c_uint64),
|
||
("ullAvailVirtual", ctypes.c_uint64),
|
||
("ullAvailExtendedVirtual", ctypes.c_uint64),
|
||
]
|
||
|
||
stat = MEMORYSTATUSEX()
|
||
stat.dwLength = ctypes.sizeof(MEMORYSTATUSEX)
|
||
if not ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(stat)): # type: ignore[attr-defined]
|
||
return None
|
||
total = int(stat.ullTotalPhys)
|
||
available = int(stat.ullAvailPhys)
|
||
used = max(0, total - available)
|
||
return {"total": total, "available": available, "used": used, "load": int(stat.dwMemoryLoad)}
|
||
|
||
|
||
def _windows_cpu_times() -> tuple[float, float] | None:
|
||
"""Return (idle, total) in 100-ns units via GetSystemTimes."""
|
||
try:
|
||
import ctypes
|
||
from ctypes import wintypes
|
||
except Exception:
|
||
return None
|
||
|
||
class FILETIME(ctypes.Structure):
|
||
_fields_ = [("dwLowDateTime", wintypes.DWORD), ("dwHighDateTime", wintypes.DWORD)]
|
||
|
||
def _ft_to_u64(ft: FILETIME) -> int:
|
||
return (int(ft.dwHighDateTime) << 32) | int(ft.dwLowDateTime)
|
||
|
||
idle = FILETIME()
|
||
kernel = FILETIME()
|
||
user = FILETIME()
|
||
ok = ctypes.windll.kernel32.GetSystemTimes( # type: ignore[attr-defined]
|
||
ctypes.byref(idle),
|
||
ctypes.byref(kernel),
|
||
ctypes.byref(user),
|
||
)
|
||
if not ok:
|
||
return None
|
||
idle_t = float(_ft_to_u64(idle))
|
||
# Kernel includes idle time on Windows.
|
||
kernel_t = float(_ft_to_u64(kernel))
|
||
user_t = float(_ft_to_u64(user))
|
||
total = kernel_t + user_t
|
||
return idle_t, total
|
||
|
||
|
||
def _windows_cpu_percent() -> float:
|
||
global _CPU_SAMPLE
|
||
first = _windows_cpu_times()
|
||
if first is None:
|
||
return 0.0
|
||
idle, total = first
|
||
prev = _CPU_SAMPLE
|
||
if prev is None or (time.monotonic() - prev[2]) < 0.15:
|
||
time.sleep(0.2)
|
||
again = _windows_cpu_times()
|
||
if again is None:
|
||
return 0.0
|
||
idle2, total2 = again
|
||
_CPU_SAMPLE = (idle2, total2, time.monotonic())
|
||
di, dt = idle2 - idle, total2 - total
|
||
else:
|
||
di, dt = idle - prev[0], total - prev[1]
|
||
_CPU_SAMPLE = (idle, total, time.monotonic())
|
||
if dt <= 0:
|
||
return 0.0
|
||
return _clamp_pct((1.0 - (di / dt)) * 100.0)
|
||
|
||
|
||
def _host_via_stdlib() -> dict[str, Any]:
|
||
system = platform.system().lower()
|
||
out: dict[str, Any] = {
|
||
"source": "stdlib",
|
||
"platform": system or "unknown",
|
||
"cpu_percent": 0.0,
|
||
"mem_percent": 0.0,
|
||
"mem_used_bytes": 0,
|
||
"mem_total_bytes": 0,
|
||
"mem_available_bytes": 0,
|
||
}
|
||
if system == "windows":
|
||
mem = _windows_mem()
|
||
if mem:
|
||
total = mem["total"]
|
||
used = mem["used"]
|
||
available = mem["available"]
|
||
pct = float(mem.get("load") or 0)
|
||
if pct <= 0 and total > 0:
|
||
pct = (used / total) * 100.0
|
||
out.update(
|
||
{
|
||
"mem_percent": round(_clamp_pct(pct), 1),
|
||
"mem_used_bytes": used,
|
||
"mem_total_bytes": total,
|
||
"mem_available_bytes": available,
|
||
}
|
||
)
|
||
util = _windows_cpu_utility_pdh(0.25)
|
||
if util is not None:
|
||
out["cpu_percent"] = round(util, 1)
|
||
out["cpu_metric"] = "processor_utility"
|
||
else:
|
||
out["cpu_percent"] = round(_windows_cpu_percent(), 1)
|
||
out["cpu_metric"] = "processor_time"
|
||
return out
|
||
|
||
# Linux / other Unix with /proc
|
||
if os.path.exists("/proc/meminfo"):
|
||
mem = _read_linux_mem()
|
||
if mem:
|
||
total = mem["total"]
|
||
used = mem["used"]
|
||
available = mem["available"]
|
||
pct = (used / total) * 100.0 if total else 0.0
|
||
out.update(
|
||
{
|
||
"mem_percent": round(_clamp_pct(pct), 1),
|
||
"mem_used_bytes": used,
|
||
"mem_total_bytes": total,
|
||
"mem_available_bytes": available,
|
||
}
|
||
)
|
||
out["cpu_percent"] = round(_linux_cpu_percent(), 1)
|
||
return out
|
||
|
||
out["error"] = "unsupported_platform"
|
||
return out
|
||
|
||
|
||
def _collect_host_metrics_uncached() -> dict[str, Any]:
|
||
via = _host_via_psutil()
|
||
if via is not None:
|
||
return via
|
||
try:
|
||
return _host_via_stdlib()
|
||
except Exception as exc: # noqa: BLE001
|
||
return {
|
||
"source": "error",
|
||
"platform": platform.system().lower() or "unknown",
|
||
"cpu_percent": 0.0,
|
||
"mem_percent": 0.0,
|
||
"mem_used_bytes": 0,
|
||
"mem_total_bytes": 0,
|
||
"mem_available_bytes": 0,
|
||
"error": str(exc)[:200],
|
||
}
|
||
|
||
|
||
def collect_host_metrics() -> dict[str, Any]:
|
||
"""Return host cpu/memory dict; never raises.
|
||
|
||
Cached briefly so concurrent /metrics polls do not stack 200ms CPU samples.
|
||
"""
|
||
global _CACHED_HOST, _CACHED_AT
|
||
now = time.monotonic()
|
||
with _CACHE_LOCK:
|
||
if _CACHED_HOST is not None and (now - _CACHED_AT) < _CACHE_TTL_SEC:
|
||
return dict(_CACHED_HOST)
|
||
out = _collect_host_metrics_uncached()
|
||
with _CACHE_LOCK:
|
||
_CACHED_HOST = dict(out)
|
||
_CACHED_AT = time.monotonic()
|
||
return out
|