mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 03:10:46 +08:00
Add dark workbench dashboard with host CPU/memory gauges.
Sample Windows CPU via Processor Utility so values match Task Manager, and expose host metrics on /v1/metrics/json for the workbench poll. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
8ded8eeb7d
commit
254c50a2c0
12 changed files with 1286 additions and 83 deletions
|
|
@ -26,6 +26,7 @@ _PUBLIC_EXACT = frozenset(
|
||||||
"/health/ready",
|
"/health/ready",
|
||||||
"/metrics",
|
"/metrics",
|
||||||
"/metrics/json",
|
"/metrics/json",
|
||||||
|
"/v1/metrics/json",
|
||||||
"/favicon.ico",
|
"/favicon.ico",
|
||||||
"/v1/auth/login",
|
"/v1/auth/login",
|
||||||
}
|
}
|
||||||
|
|
|
||||||
419
netx_api/host_metrics.py
Normal file
419
netx_api/host_metrics.py
Normal file
|
|
@ -0,0 +1,419 @@
|
||||||
|
"""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
|
||||||
|
|
||||||
|
if pdh.PdhCollectQueryData(_PDH_QUERY) != 0:
|
||||||
|
return _PDH_LAST
|
||||||
|
if not _PDH_PRIMED:
|
||||||
|
time.sleep(max(0.05, 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
|
||||||
|
|
@ -12,6 +12,7 @@ from .audit_async import audit_queue_status
|
||||||
from .cli_budget import cli_budget_status
|
from .cli_budget import cli_budget_status
|
||||||
from .config import settings
|
from .config import settings
|
||||||
from .db import db_pool_status
|
from .db import db_pool_status
|
||||||
|
from .host_metrics import collect_host_metrics
|
||||||
from .oclaw_alarm_forwarder import forwarder_status
|
from .oclaw_alarm_forwarder import forwarder_status
|
||||||
|
|
||||||
router = APIRouter(tags=["metrics"])
|
router = APIRouter(tags=["metrics"])
|
||||||
|
|
@ -29,6 +30,7 @@ def collect_runtime_metrics() -> dict[str, Any]:
|
||||||
"audit_queue": audit_queue_status(),
|
"audit_queue": audit_queue_status(),
|
||||||
"oclaw_forwarder": forwarder_status(),
|
"oclaw_forwarder": forwarder_status(),
|
||||||
"schedulers_inline": bool(getattr(settings, "run_inline_schedulers", True)),
|
"schedulers_inline": bool(getattr(settings, "run_inline_schedulers", True)),
|
||||||
|
"host": collect_host_metrics(),
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
import resource
|
import resource
|
||||||
|
|
@ -124,6 +126,15 @@ def _prom_lines(metrics: dict[str, Any]) -> str:
|
||||||
lines.append(f'netx_webcrt_queue_dropped_total {web["queue_dropped_total"]}')
|
lines.append(f'netx_webcrt_queue_dropped_total {web["queue_dropped_total"]}')
|
||||||
if "ru_maxrss" in metrics:
|
if "ru_maxrss" in metrics:
|
||||||
lines.append(f'netx_ru_maxrss {metrics["ru_maxrss"]}')
|
lines.append(f'netx_ru_maxrss {metrics["ru_maxrss"]}')
|
||||||
|
host = metrics.get("host") or {}
|
||||||
|
if host.get("cpu_percent") is not None:
|
||||||
|
lines.append(f'netx_host_cpu_percent {host["cpu_percent"]}')
|
||||||
|
if host.get("mem_percent") is not None:
|
||||||
|
lines.append(f'netx_host_mem_percent {host["mem_percent"]}')
|
||||||
|
if host.get("mem_used_bytes") is not None:
|
||||||
|
lines.append(f'netx_host_mem_used_bytes {host["mem_used_bytes"]}')
|
||||||
|
if host.get("mem_total_bytes") is not None:
|
||||||
|
lines.append(f'netx_host_mem_total_bytes {host["mem_total_bytes"]}')
|
||||||
lines.append("")
|
lines.append("")
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
@ -137,3 +148,9 @@ def metrics_prometheus() -> Response:
|
||||||
@router.get("/metrics/json")
|
@router.get("/metrics/json")
|
||||||
def metrics_json() -> dict[str, Any]:
|
def metrics_json() -> dict[str, Any]:
|
||||||
return collect_runtime_metrics()
|
return collect_runtime_metrics()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/v1/metrics/json")
|
||||||
|
def metrics_json_v1() -> dict[str, Any]:
|
||||||
|
"""Alias under /v1 so Vite/nginx /v1 proxies also work."""
|
||||||
|
return collect_runtime_metrics()
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ dependencies = [
|
||||||
"bcrypt>=4.1.0",
|
"bcrypt>=4.1.0",
|
||||||
"PyJWT>=2.8.0",
|
"PyJWT>=2.8.0",
|
||||||
"alembic>=1.13.0",
|
"alembic>=1.13.0",
|
||||||
|
"psutil>=5.9.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|
|
||||||
|
|
@ -20,3 +20,4 @@ textfsm>=1.1.0
|
||||||
bcrypt>=4.1.0
|
bcrypt>=4.1.0
|
||||||
PyJWT>=2.8.0
|
PyJWT>=2.8.0
|
||||||
alembic>=1.13.0
|
alembic>=1.13.0
|
||||||
|
psutil>=5.9.0
|
||||||
|
|
|
||||||
38
tests/test_host_metrics.py
Normal file
38
tests/test_host_metrics.py
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
"""Host CPU/memory metrics (Windows + Linux)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import netx_api.host_metrics as host_metrics
|
||||||
|
from netx_api.host_metrics import collect_host_metrics
|
||||||
|
|
||||||
|
|
||||||
|
class HostMetricsTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
host_metrics._CACHED_HOST = None
|
||||||
|
host_metrics._CACHED_AT = 0.0
|
||||||
|
|
||||||
|
def test_collect_returns_cpu_mem_fields(self) -> None:
|
||||||
|
out = collect_host_metrics()
|
||||||
|
src = str(out.get("source") or "")
|
||||||
|
self.assertTrue(src.startswith("psutil") or src in {"stdlib", "error"}, src)
|
||||||
|
self.assertIn("cpu_percent", out)
|
||||||
|
self.assertIn("mem_percent", out)
|
||||||
|
self.assertGreaterEqual(float(out["cpu_percent"]), 0.0)
|
||||||
|
self.assertLessEqual(float(out["cpu_percent"]), 100.0)
|
||||||
|
self.assertGreaterEqual(float(out["mem_percent"]), 0.0)
|
||||||
|
self.assertLessEqual(float(out["mem_percent"]), 100.0)
|
||||||
|
self.assertGreaterEqual(int(out.get("mem_total_bytes") or 0), 0)
|
||||||
|
|
||||||
|
def test_stdlib_fallback_when_psutil_missing(self) -> None:
|
||||||
|
with patch("netx_api.host_metrics._host_via_psutil", return_value=None):
|
||||||
|
out = collect_host_metrics()
|
||||||
|
self.assertEqual(out.get("source"), "stdlib")
|
||||||
|
self.assertIn("cpu_percent", out)
|
||||||
|
self.assertIn("mem_percent", out)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|
@ -24,6 +24,55 @@ const en = {
|
||||||
monitoring: "Monitoring",
|
monitoring: "Monitoring",
|
||||||
operations: "Operations",
|
operations: "Operations",
|
||||||
system: "System",
|
system: "System",
|
||||||
|
welcomeEyebrow: "Workbench",
|
||||||
|
welcomeTitle: "Welcome back, {{name}}",
|
||||||
|
welcomeSub: "Monitor sync, topology, and ops tasks from one place.",
|
||||||
|
today: "Today · {{date}}",
|
||||||
|
kpi: {
|
||||||
|
uptime: "API uptime",
|
||||||
|
health: "System health",
|
||||||
|
tasks: "Active tasks",
|
||||||
|
healthOk: "Schedulers healthy",
|
||||||
|
healthWarn: "Check schedulers",
|
||||||
|
healthUnknown: "Metrics unavailable",
|
||||||
|
},
|
||||||
|
statusTitle: "Runtime status",
|
||||||
|
announceTitle: "Announcements",
|
||||||
|
recentTitle: "Recent operations",
|
||||||
|
recentMore: "View all",
|
||||||
|
quickTitle: "Quick actions",
|
||||||
|
emptyRecent: "No recent audit entries.",
|
||||||
|
gauge: {
|
||||||
|
cpu: "CPU",
|
||||||
|
mem: "Memory",
|
||||||
|
cli: "CLI budget",
|
||||||
|
db: "DB pool",
|
||||||
|
webcrt: "WebCRT",
|
||||||
|
tasks: "Active tasks",
|
||||||
|
low: "Low load",
|
||||||
|
normal: "Normal",
|
||||||
|
high: "Busy",
|
||||||
|
},
|
||||||
|
quick: {
|
||||||
|
tasks: "Task overview",
|
||||||
|
logs: "Audit logs",
|
||||||
|
users: "User admin",
|
||||||
|
apiKeys: "API keys",
|
||||||
|
},
|
||||||
|
announce: {
|
||||||
|
a1: "Dark workbench shell is live — other modules follow the same palette.",
|
||||||
|
a1Date: "2026-08-04",
|
||||||
|
a2: "LLDP collect supports pause / resume / stop; interrupted jobs resume after restart.",
|
||||||
|
a2Date: "2026-08-04",
|
||||||
|
a3: "Config sync and LLDP KPI boards refresh while jobs are running.",
|
||||||
|
a3Date: "2026-08-03",
|
||||||
|
},
|
||||||
|
recentCols: {
|
||||||
|
action: "Action",
|
||||||
|
module: "Module",
|
||||||
|
time: "Time",
|
||||||
|
user: "User",
|
||||||
|
},
|
||||||
cards: {
|
cards: {
|
||||||
umeSync: "UME Sync",
|
umeSync: "UME Sync",
|
||||||
umeSyncDesc: "UME alarms, subscription & inventory",
|
umeSyncDesc: "UME alarms, subscription & inventory",
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,55 @@ const zh = {
|
||||||
monitoring: "监控",
|
monitoring: "监控",
|
||||||
operations: "运维",
|
operations: "运维",
|
||||||
system: "系统管理",
|
system: "系统管理",
|
||||||
|
welcomeEyebrow: "工作台",
|
||||||
|
welcomeTitle: "欢迎回来,{{name}}",
|
||||||
|
welcomeSub: "在此总览同步、拓扑与后台任务状态。",
|
||||||
|
today: "今天 · {{date}}",
|
||||||
|
kpi: {
|
||||||
|
uptime: "API 运行时长",
|
||||||
|
health: "系统健康",
|
||||||
|
tasks: "进行中任务",
|
||||||
|
healthOk: "调度正常",
|
||||||
|
healthWarn: "请检查调度",
|
||||||
|
healthUnknown: "指标暂不可用",
|
||||||
|
},
|
||||||
|
statusTitle: "运行状态",
|
||||||
|
announceTitle: "系统公告",
|
||||||
|
recentTitle: "最近操作",
|
||||||
|
recentMore: "查看全部",
|
||||||
|
quickTitle: "快捷操作",
|
||||||
|
emptyRecent: "暂无操作记录。",
|
||||||
|
gauge: {
|
||||||
|
cpu: "CPU",
|
||||||
|
mem: "内存",
|
||||||
|
cli: "CLI 并发",
|
||||||
|
db: "数据库连接",
|
||||||
|
webcrt: "WebCRT",
|
||||||
|
tasks: "活跃任务",
|
||||||
|
low: "低负载",
|
||||||
|
normal: "正常",
|
||||||
|
high: "繁忙",
|
||||||
|
},
|
||||||
|
quick: {
|
||||||
|
tasks: "任务概览",
|
||||||
|
logs: "操作日志",
|
||||||
|
users: "用户管理",
|
||||||
|
apiKeys: "API Key",
|
||||||
|
},
|
||||||
|
announce: {
|
||||||
|
a1: "深色工作台已上线,其它模块将沿用同一套深色体系。",
|
||||||
|
a1Date: "2026-08-04",
|
||||||
|
a2: "LLDP 采集支持暂停/继续/停止;进程重启后会自动续跑。",
|
||||||
|
a2Date: "2026-08-04",
|
||||||
|
a3: "配置同步与 LLDP 看板在任务运行中可实时刷新 KPI。",
|
||||||
|
a3Date: "2026-08-03",
|
||||||
|
},
|
||||||
|
recentCols: {
|
||||||
|
action: "操作内容",
|
||||||
|
module: "模块",
|
||||||
|
time: "时间",
|
||||||
|
user: "用户",
|
||||||
|
},
|
||||||
cards: {
|
cards: {
|
||||||
umeSync: "UME同步",
|
umeSync: "UME同步",
|
||||||
umeSyncDesc: "UME 告警同步、订阅与清单",
|
umeSyncDesc: "UME 告警同步、订阅与清单",
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,9 @@
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: Inter, "Segoe UI", Arial, sans-serif;
|
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
|
||||||
background: #e8eef6;
|
background: #070b14;
|
||||||
color: #1e293b;
|
color: #e2e8f0;
|
||||||
}
|
}
|
||||||
|
|
||||||
#root {
|
#root {
|
||||||
|
|
@ -14,24 +14,29 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
.app--shell {
|
.app--shell {
|
||||||
/* Shared NetX chrome tokens — light ops shell + login-aligned blues */
|
/* Shared NetX chrome tokens — dark ops shell (global foundation) */
|
||||||
--nm-accent: #3b82f6;
|
--nm-accent: #3b82f6;
|
||||||
--nm-accent-deep: #2563eb;
|
--nm-accent-deep: #2563eb;
|
||||||
--nm-header: #0f1b2d;
|
--nm-header: #060a12;
|
||||||
--nm-chrome: #e8eef6;
|
--nm-chrome: #070b14;
|
||||||
--nm-nav: #e4eaf2;
|
--nm-nav: #0c1220;
|
||||||
--nm-nav-border: #c0cad6;
|
--nm-nav-border: #1e293b;
|
||||||
--nm-nav-hover: #d7e0eb;
|
--nm-nav-hover: #152033;
|
||||||
--nm-nav-active: #dbeafe;
|
--nm-nav-active: #1a2740;
|
||||||
--nm-nav-ink: #0f1b2d;
|
--nm-nav-ink: #e2e8f0;
|
||||||
--nm-nav-muted: #64748b;
|
--nm-nav-muted: #94a3b8;
|
||||||
--nm-panel-border: #c8d2de;
|
--nm-panel: rgba(15, 23, 42, 0.72);
|
||||||
--nm-brand: #0f1b2d;
|
--nm-panel-solid: #0f172a;
|
||||||
--nm-text: #1e293b;
|
--nm-panel-border: #1e293b;
|
||||||
--nm-muted: #64748b;
|
--nm-brand: #f1f5f9;
|
||||||
|
--nm-text: #e2e8f0;
|
||||||
|
--nm-muted: #94a3b8;
|
||||||
|
--nm-glass: rgba(15, 23, 42, 0.55);
|
||||||
|
--nm-glass-border: rgba(148, 163, 184, 0.18);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
color: var(--nm-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-brand {
|
.app-brand {
|
||||||
|
|
@ -133,30 +138,135 @@ body {
|
||||||
.workbench {
|
.workbench {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: none;
|
max-width: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-main:has(> .workbench) {
|
.app-main:has(> .workbench) {
|
||||||
padding: 16px 20px 28px;
|
padding: 16px 20px 28px;
|
||||||
|
background:
|
||||||
|
radial-gradient(1200px 480px at 12% -10%, rgba(37, 99, 235, 0.18), transparent 55%),
|
||||||
|
radial-gradient(900px 420px at 88% 0%, rgba(14, 165, 233, 0.1), transparent 50%),
|
||||||
|
var(--nm-chrome, #070b14);
|
||||||
}
|
}
|
||||||
|
|
||||||
.wb-head {
|
.wb-hero {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 24px;
|
||||||
|
padding: 16px 22px;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid var(--nm-glass-border);
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, rgba(15, 23, 42, 0.82), rgba(15, 23, 42, 0.55)),
|
||||||
|
radial-gradient(ellipse 80% 120% at 85% 20%, rgba(56, 189, 248, 0.12), transparent 55%),
|
||||||
|
radial-gradient(ellipse 60% 80% at 10% 90%, rgba(37, 99, 235, 0.16), transparent 50%);
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wb-hero__copy {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wb-hero__status {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
flex: 1 1 auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 18px;
|
min-width: 0;
|
||||||
padding-bottom: 12px;
|
width: 100%;
|
||||||
border-bottom: 1px solid #d5deea;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.wb-head__title {
|
.wb-hero__eyebrow {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 18px;
|
font-size: 12px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #7dd3fc;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wb-hero__title {
|
||||||
|
margin: 0;
|
||||||
|
font-family: Sora, "IBM Plex Sans", sans-serif;
|
||||||
|
font-size: clamp(20px, 2.2vw, 26px);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--nm-brand, #0f1b2d);
|
letter-spacing: -0.02em;
|
||||||
letter-spacing: 0.02em;
|
color: #f8fafc;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wb-hero__sub {
|
||||||
|
margin: 0;
|
||||||
|
max-width: 42ch;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wb-hero__date {
|
||||||
|
margin: 2px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wb-hero__kpis {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wb-kpi {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||||
|
background: rgba(2, 8, 23, 0.45);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wb-kpi__label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wb-kpi__value {
|
||||||
|
font-family: Sora, "IBM Plex Sans", sans-serif;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #f1f5f9;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wb-kpi__hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wb-kpi--live .wb-kpi__value {
|
||||||
|
color: #4ade80;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wb-section {
|
.wb-section {
|
||||||
margin-bottom: 22px;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wb-section__head {
|
.wb-section__head {
|
||||||
|
|
@ -173,7 +283,7 @@ body {
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
color: #334155;
|
color: #cbd5e1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wb-section__title::before {
|
.wb-section__title::before {
|
||||||
|
|
@ -189,11 +299,11 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
.wb-section--operations .wb-section__title::before {
|
.wb-section--operations .wb-section__title::before {
|
||||||
background: #0f766e;
|
background: #14b8a6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wb-section--system .wb-section__title::before {
|
.wb-section--system .wb-section__title::before {
|
||||||
background: #b45309;
|
background: #f59e0b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wb-section__count {
|
.wb-section__count {
|
||||||
|
|
@ -204,8 +314,8 @@ body {
|
||||||
height: 18px;
|
height: 18px;
|
||||||
padding: 0 5px;
|
padding: 0 5px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: #e8eef5;
|
background: rgba(148, 163, 184, 0.14);
|
||||||
color: #64748b;
|
color: #94a3b8;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
|
@ -224,49 +334,48 @@ body {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
width: auto;
|
width: auto;
|
||||||
min-width: 132px;
|
min-width: 148px;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 12px 16px 12px 12px;
|
padding: 12px 16px 12px 12px;
|
||||||
border: 1px solid #d8e0ea;
|
border: 1px solid var(--nm-glass-border);
|
||||||
border-radius: 10px;
|
border-radius: 12px;
|
||||||
background: #fff;
|
background: rgba(15, 23, 42, 0.65);
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
transition: border-color 0.14s ease, background 0.14s ease, box-shadow 0.14s ease;
|
transition: border-color 0.14s ease, background 0.14s ease, transform 0.14s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wb-card:hover,
|
.wb-card:hover,
|
||||||
.wb-card:focus-visible {
|
.wb-card:focus-visible {
|
||||||
border-color: #8fb4df;
|
border-color: rgba(59, 130, 246, 0.55);
|
||||||
background: #f7faff;
|
background: rgba(30, 41, 59, 0.85);
|
||||||
box-shadow: 0 2px 10px rgba(15, 39, 68, 0.08);
|
transform: translateY(-1px);
|
||||||
transform: none;
|
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wb-card--blue:hover,
|
.wb-card--blue:hover,
|
||||||
.wb-card--blue:focus-visible {
|
.wb-card--blue:focus-visible {
|
||||||
border-color: #90caf9;
|
border-color: rgba(59, 130, 246, 0.65);
|
||||||
}
|
}
|
||||||
|
|
||||||
.wb-card--green:hover,
|
.wb-card--green:hover,
|
||||||
.wb-card--green:focus-visible {
|
.wb-card--green:focus-visible {
|
||||||
border-color: #81c784;
|
border-color: rgba(34, 197, 94, 0.55);
|
||||||
}
|
}
|
||||||
|
|
||||||
.wb-card--amber:hover,
|
.wb-card--amber:hover,
|
||||||
.wb-card--amber:focus-visible {
|
.wb-card--amber:focus-visible {
|
||||||
border-color: #ffd54f;
|
border-color: rgba(245, 158, 11, 0.55);
|
||||||
}
|
}
|
||||||
|
|
||||||
.wb-card--slate:hover,
|
.wb-card--slate:hover,
|
||||||
.wb-card--slate:focus-visible {
|
.wb-card--slate:focus-visible {
|
||||||
border-color: #90a4ae;
|
border-color: rgba(148, 163, 184, 0.45);
|
||||||
}
|
}
|
||||||
|
|
||||||
.wb-card__icon {
|
.wb-card__icon {
|
||||||
|
|
@ -277,7 +386,6 @@ body {
|
||||||
height: 38px;
|
height: 38px;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
/* Flat-top hexagon */
|
|
||||||
clip-path: polygon(50% 0%, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%);
|
clip-path: polygon(50% 0%, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -287,47 +395,338 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
.wb-card__icon--blue {
|
.wb-card__icon--blue {
|
||||||
background: #e3f2fd;
|
background: rgba(59, 130, 246, 0.22);
|
||||||
color: var(--nm-accent, #3b82f6);
|
color: #60a5fa;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wb-card__icon--green {
|
.wb-card__icon--green {
|
||||||
background: #e8f5e9;
|
background: rgba(34, 197, 94, 0.18);
|
||||||
color: #2e7d32;
|
color: #4ade80;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wb-card__icon--amber {
|
.wb-card__icon--amber {
|
||||||
background: #fff8e1;
|
background: rgba(245, 158, 11, 0.18);
|
||||||
color: #f57f17;
|
color: #fbbf24;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wb-card__icon--slate {
|
.wb-card__icon--slate {
|
||||||
background: #eceff1;
|
background: rgba(148, 163, 184, 0.16);
|
||||||
color: #455a64;
|
color: #cbd5e1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wb-card__label {
|
.wb-card__label {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
color: var(--nm-brand, #0f1b2d);
|
color: #f1f5f9;
|
||||||
line-height: 1.25;
|
line-height: 1.25;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
padding-right: 2px;
|
padding-right: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-menu__panel--light {
|
.wb-row {
|
||||||
background: #fff;
|
display: grid;
|
||||||
border-color: #e2e8f0;
|
grid-template-columns: minmax(0, 1.15fr) minmax(0, 0.85fr);
|
||||||
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.12);
|
gap: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-menu__label--light {
|
.wb-panel {
|
||||||
|
border: 1px solid var(--nm-glass-border);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(15, 23, 42, 0.68);
|
||||||
|
padding: 16px 18px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wb-panel__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wb-panel__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 650;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wb-panel__link {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #60a5fa;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wb-panel__link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wb-gauges {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Must follow .wb-gauges so flex wins over grid (was wrapping into 4+2). */
|
||||||
|
.wb-gauges.wb-gauges--hero {
|
||||||
|
display: flex !important;
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: nowrap !important;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: none;
|
||||||
|
grid-template-columns: unset;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wb-gauges.wb-gauges--hero .wb-gauge {
|
||||||
|
flex: 1 1 0;
|
||||||
|
width: auto;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: none;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wb-gauges.wb-gauges--hero .wb-gauge__ring {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wb-gauges.wb-gauges--hero .wb-gauge__ring::before {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wb-gauges.wb-gauges--hero .wb-gauge__value {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wb-gauges.wb-gauges--hero .wb-gauge__label {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wb-gauges.wb-gauges--hero .wb-gauge__hint {
|
||||||
|
font-size: 10px;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wb-gauge {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wb-gauge__ring {
|
||||||
|
--pct: 0;
|
||||||
|
--tone: #3b82f6;
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: conic-gradient(var(--tone) calc(var(--pct) * 1%), rgba(148, 163, 184, 0.16) 0);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wb-gauge__ring::before {
|
||||||
|
content: "";
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #0b1220;
|
||||||
|
grid-area: 1 / 1;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wb-gauge__value {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
grid-area: 1 / 1;
|
||||||
|
font-family: Sora, "IBM Plex Sans", sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #f8fafc;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wb-gauge__wrap {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wb-gauge__label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wb-gauge__hint {
|
||||||
|
font-size: 11px;
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.wb-announce {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wb-announce__item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 0;
|
||||||
|
border-bottom: 1px solid rgba(148, 163, 184, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wb-announce__item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wb-announce__text {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #cbd5e1;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wb-announce__date {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wb-recent-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wb-recent-table th {
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #64748b;
|
||||||
|
padding: 0 8px 8px 0;
|
||||||
|
border-bottom: 1px solid rgba(148, 163, 184, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wb-recent-table td {
|
||||||
|
padding: 9px 8px 9px 0;
|
||||||
|
color: #cbd5e1;
|
||||||
|
border-bottom: 1px solid rgba(148, 163, 184, 0.08);
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wb-recent-table tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wb-recent-table .mono {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wb-quick-ops {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wb-quick-ops__btn,
|
||||||
|
.app-main .wb-quick-ops__btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 52px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--nm-glass-border);
|
||||||
|
background: rgba(2, 8, 23, 0.4);
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: center;
|
||||||
|
transition: border-color 0.14s ease, background 0.14s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wb-quick-ops__btn:hover,
|
||||||
|
.wb-quick-ops__btn:focus-visible {
|
||||||
|
border-color: rgba(59, 130, 246, 0.5);
|
||||||
|
background: rgba(30, 41, 59, 0.8);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wb-empty {
|
||||||
|
margin: 0;
|
||||||
|
padding: 12px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.wb-hero {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wb-hero__kpis {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.wb-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wb-gauges {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.wb-gauges.wb-gauges--hero {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.wb-hero__kpis {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wb-gauges {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wb-quick-ops {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-menu__panel--light {
|
||||||
|
background: #0f172a;
|
||||||
|
border-color: #1e293b;
|
||||||
|
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-menu__label--light {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
.header-menu__item--light {
|
.header-menu__item--light {
|
||||||
color: #334155;
|
color: #e2e8f0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-menu__item--light:hover {
|
.header-menu__item--light:hover {
|
||||||
|
|
@ -407,9 +806,9 @@ body {
|
||||||
.app-main select,
|
.app-main select,
|
||||||
.app-main input,
|
.app-main input,
|
||||||
.app-main textarea {
|
.app-main textarea {
|
||||||
border: 1px solid #cbd5e1;
|
border: 1px solid var(--nm-panel-border, #1e293b);
|
||||||
background: #fff;
|
background: #0b1220;
|
||||||
color: #1f2937;
|
color: var(--nm-text, #e2e8f0);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
}
|
}
|
||||||
|
|
@ -418,9 +817,9 @@ button,
|
||||||
select,
|
select,
|
||||||
input,
|
input,
|
||||||
textarea {
|
textarea {
|
||||||
border: 1px solid #cbd5e1;
|
border: 1px solid var(--nm-panel-border, #1e293b);
|
||||||
background: #fff;
|
background: #0b1220;
|
||||||
color: #1f2937;
|
color: var(--nm-text, #e2e8f0);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
}
|
}
|
||||||
|
|
@ -443,20 +842,22 @@ button {
|
||||||
|
|
||||||
.app-main .card,
|
.app-main .card,
|
||||||
.app-main .panel {
|
.app-main .panel {
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid var(--nm-panel-border, #1e293b);
|
||||||
background: #fff;
|
background: var(--nm-panel-solid, #0f172a);
|
||||||
border-radius: 8px;
|
border-radius: 10px;
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
|
box-shadow: none;
|
||||||
|
color: var(--nm-text, #e2e8f0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card,
|
.card,
|
||||||
.panel {
|
.panel {
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid var(--nm-panel-border, #1e293b);
|
||||||
background: #fff;
|
background: var(--nm-panel-solid, #0f172a);
|
||||||
border-radius: 8px;
|
border-radius: 10px;
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
|
box-shadow: none;
|
||||||
|
color: var(--nm-text, #e2e8f0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card h3,
|
.card h3,
|
||||||
|
|
@ -535,16 +936,16 @@ table {
|
||||||
|
|
||||||
th,
|
th,
|
||||||
td {
|
td {
|
||||||
border-bottom: 1px solid #e2e8f0;
|
border-bottom: 1px solid var(--nm-panel-border, #1e293b);
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
color: #334155;
|
color: var(--nm-text, #e2e8f0);
|
||||||
}
|
}
|
||||||
|
|
||||||
th {
|
th {
|
||||||
color: #64748b;
|
color: var(--nm-muted, #94a3b8);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1938,7 +2339,7 @@ pre {
|
||||||
.form-fieldset {
|
.form-fieldset {
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid var(--nm-panel-border, #1e293b);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1947,7 +2348,7 @@ pre {
|
||||||
padding: 0 6px;
|
padding: 0 6px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #334155;
|
color: var(--nm-text, #e2e8f0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-fieldset__title {
|
.form-fieldset__title {
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,85 @@
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useI18n } from "../i18n";
|
import { useI18n } from "../i18n";
|
||||||
import { WorkbenchCardIcon } from "../components/WorkbenchCardIcon";
|
import { WorkbenchCardIcon } from "../components/WorkbenchCardIcon";
|
||||||
import { modulesInSection, type ModuleDefinition, type WorkbenchSection } from "../config/modules";
|
import { modulesInSection, type ModuleDefinition, type WorkbenchSection } from "../config/modules";
|
||||||
import { openOrFocusModule } from "../utils/moduleWindows";
|
import { openOrFocusModule } from "../utils/moduleWindows";
|
||||||
import { useAuth } from "../auth/AuthContext";
|
import { useAuth } from "../auth/AuthContext";
|
||||||
|
import { fetchOpsTasks, fetchRuntimeMetrics } from "../services/api";
|
||||||
|
|
||||||
const SECTIONS: WorkbenchSection[] = ["monitoring", "operations", "system"];
|
const SECTIONS: WorkbenchSection[] = ["monitoring", "operations", "system"];
|
||||||
|
|
||||||
|
function clampPct(n: number): number {
|
||||||
|
if (!Number.isFinite(n)) return 0;
|
||||||
|
return Math.max(0, Math.min(100, Math.round(n)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function ratioPct(used: number, limit: number): number {
|
||||||
|
if (limit <= 0) return 0;
|
||||||
|
return clampPct((used / limit) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadHint(pct: number, t: (k: string) => string): string {
|
||||||
|
if (pct >= 75) return t("workbench.gauge.high");
|
||||||
|
if (pct >= 40) return t("workbench.gauge.normal");
|
||||||
|
return t("workbench.gauge.low");
|
||||||
|
}
|
||||||
|
|
||||||
|
function gaugeTone(pct: number): string {
|
||||||
|
if (pct >= 75) return "#f59e0b";
|
||||||
|
if (pct >= 40) return "#3b82f6";
|
||||||
|
return "#22c55e";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(n: number, locale: string): string {
|
||||||
|
const v = Math.max(0, Number(n) || 0);
|
||||||
|
const gib = 1024 ** 3;
|
||||||
|
const mib = 1024 ** 2;
|
||||||
|
if (v >= gib) {
|
||||||
|
const g = v / gib;
|
||||||
|
return locale.startsWith("zh") ? `${g.toFixed(1)} GiB` : `${g.toFixed(1)} GiB`;
|
||||||
|
}
|
||||||
|
return `${(v / mib).toFixed(0)} MiB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Gauge({ label, hint, pct }: { label: string; hint: string; pct: number }) {
|
||||||
|
const tone = gaugeTone(pct);
|
||||||
|
const safe = clampPct(pct);
|
||||||
|
return (
|
||||||
|
<div className="wb-gauge">
|
||||||
|
<div className="wb-gauge__wrap">
|
||||||
|
<div
|
||||||
|
className="wb-gauge__ring"
|
||||||
|
style={{
|
||||||
|
background: `conic-gradient(${tone} ${safe}%, rgba(148, 163, 184, 0.16) 0)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="wb-gauge__value">{safe}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="wb-gauge__label">{label}</div>
|
||||||
|
<div className="wb-gauge__hint">{hint}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function WorkbenchPage() {
|
export function WorkbenchPage() {
|
||||||
const { t } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
const { isAdmin, hasScope } = useAuth();
|
const { user, isAdmin, hasScope } = useAuth();
|
||||||
|
|
||||||
|
const metricsQuery = useQuery({
|
||||||
|
queryKey: ["runtimeMetrics"],
|
||||||
|
queryFn: fetchRuntimeMetrics,
|
||||||
|
refetchInterval: 5000,
|
||||||
|
staleTime: 2000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const opsQuery = useQuery({
|
||||||
|
queryKey: ["opsTasks"],
|
||||||
|
queryFn: fetchOpsTasks,
|
||||||
|
refetchInterval: 4000,
|
||||||
|
staleTime: 1500,
|
||||||
|
});
|
||||||
|
|
||||||
const visibleBySection = useMemo(() => {
|
const visibleBySection = useMemo(() => {
|
||||||
const canSee = (mod: ModuleDefinition) =>
|
const canSee = (mod: ModuleDefinition) =>
|
||||||
|
|
@ -22,11 +92,93 @@ export function WorkbenchPage() {
|
||||||
) as Record<WorkbenchSection, ModuleDefinition[]>;
|
) as Record<WorkbenchSection, ModuleDefinition[]>;
|
||||||
}, [isAdmin, hasScope]);
|
}, [isAdmin, hasScope]);
|
||||||
|
|
||||||
|
const metrics = metricsQuery.data;
|
||||||
|
const host = metrics?.host;
|
||||||
|
const activeTasks = opsQuery.data?.active ?? 0;
|
||||||
|
const totalTasks = opsQuery.data?.total ?? 0;
|
||||||
|
|
||||||
|
const cpuPct = clampPct(Number(host?.cpu_percent ?? 0));
|
||||||
|
const memPct = clampPct(Number(host?.mem_percent ?? 0));
|
||||||
|
const memUsed = Number(host?.mem_used_bytes || 0);
|
||||||
|
const memTotal = Number(host?.mem_total_bytes || 0);
|
||||||
|
|
||||||
|
const cliUsed = Number(metrics?.cli_budget?.in_use || 0);
|
||||||
|
const cliLimit = Math.max(1, Number(metrics?.cli_budget?.limit || 0));
|
||||||
|
const cliPct = ratioPct(cliUsed, cliLimit);
|
||||||
|
|
||||||
|
const dbOut = Number(metrics?.db_pool?.checked_out || 0);
|
||||||
|
const dbSize = Math.max(1, Number(metrics?.db_pool?.size || 0));
|
||||||
|
const dbPct = ratioPct(dbOut, dbSize);
|
||||||
|
|
||||||
|
const webUsed = Number(metrics?.webcrt?.active_sessions || 0);
|
||||||
|
const webMax = Math.max(1, Number(metrics?.webcrt?.max_sessions || 20));
|
||||||
|
const webPct = ratioPct(webUsed, webMax);
|
||||||
|
|
||||||
|
const taskCap = Math.max(totalTasks, activeTasks, 8);
|
||||||
|
const taskPct = ratioPct(activeTasks, taskCap);
|
||||||
|
|
||||||
|
const todayLabel = useMemo(() => {
|
||||||
|
try {
|
||||||
|
return new Intl.DateTimeFormat(locale === "en" ? "en-US" : "zh-CN", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
weekday: "short",
|
||||||
|
}).format(new Date());
|
||||||
|
} catch {
|
||||||
|
return new Date().toLocaleDateString();
|
||||||
|
}
|
||||||
|
}, [locale]);
|
||||||
|
|
||||||
|
const displayName = user?.username || "admin";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="workbench">
|
<div className="workbench">
|
||||||
<header className="wb-head">
|
<section className="wb-hero" aria-label={t("workbench.title")}>
|
||||||
<h1 className="wb-head__title">{t("workbench.title")}</h1>
|
<div className="wb-hero__copy">
|
||||||
</header>
|
<p className="wb-hero__eyebrow">{t("workbench.welcomeEyebrow")}</p>
|
||||||
|
<h1 className="wb-hero__title">{t("workbench.welcomeTitle", { name: displayName })}</h1>
|
||||||
|
<p className="wb-hero__date">{t("workbench.today", { date: todayLabel })}</p>
|
||||||
|
</div>
|
||||||
|
<div className="wb-hero__status" aria-label={t("workbench.statusTitle")}>
|
||||||
|
<div className="wb-gauges wb-gauges--hero">
|
||||||
|
<Gauge
|
||||||
|
label={t("workbench.gauge.cpu")}
|
||||||
|
hint={`${cpuPct}% · ${loadHint(cpuPct, t)}`}
|
||||||
|
pct={cpuPct}
|
||||||
|
/>
|
||||||
|
<Gauge
|
||||||
|
label={t("workbench.gauge.mem")}
|
||||||
|
hint={
|
||||||
|
memTotal > 0
|
||||||
|
? `${formatBytes(memUsed, locale)} / ${formatBytes(memTotal, locale)}`
|
||||||
|
: loadHint(memPct, t)
|
||||||
|
}
|
||||||
|
pct={memPct}
|
||||||
|
/>
|
||||||
|
<Gauge
|
||||||
|
label={t("workbench.gauge.cli")}
|
||||||
|
hint={`${cliUsed}/${cliLimit || "—"} · ${loadHint(cliPct, t)}`}
|
||||||
|
pct={cliPct}
|
||||||
|
/>
|
||||||
|
<Gauge
|
||||||
|
label={t("workbench.gauge.db")}
|
||||||
|
hint={`${dbOut}/${dbSize || "—"} · ${loadHint(dbPct, t)}`}
|
||||||
|
pct={dbPct}
|
||||||
|
/>
|
||||||
|
<Gauge
|
||||||
|
label={t("workbench.gauge.webcrt")}
|
||||||
|
hint={`${webUsed}/${webMax} · ${loadHint(webPct, t)}`}
|
||||||
|
pct={webPct}
|
||||||
|
/>
|
||||||
|
<Gauge
|
||||||
|
label={t("workbench.gauge.tasks")}
|
||||||
|
hint={`${activeTasks}/${taskCap} · ${loadHint(taskPct, t)}`}
|
||||||
|
pct={taskPct}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{SECTIONS.map((section) => {
|
{SECTIONS.map((section) => {
|
||||||
const mods = visibleBySection[section];
|
const mods = visibleBySection[section];
|
||||||
|
|
|
||||||
|
|
@ -231,6 +231,77 @@ export type OpsTasksResponse = {
|
||||||
|
|
||||||
export const fetchOpsTasks = () => apiGet<OpsTasksResponse>("/v1/ops/tasks");
|
export const fetchOpsTasks = () => apiGet<OpsTasksResponse>("/v1/ops/tasks");
|
||||||
|
|
||||||
|
export type RuntimeMetrics = {
|
||||||
|
uptime_sec?: number;
|
||||||
|
pid?: number;
|
||||||
|
thread_count?: number;
|
||||||
|
host?: {
|
||||||
|
source?: string;
|
||||||
|
platform?: string;
|
||||||
|
cpu_percent?: number;
|
||||||
|
mem_percent?: number;
|
||||||
|
mem_used_bytes?: number;
|
||||||
|
mem_total_bytes?: number;
|
||||||
|
mem_available_bytes?: number;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
db_pool?: {
|
||||||
|
checked_out?: number;
|
||||||
|
checked_in?: number;
|
||||||
|
overflow?: number;
|
||||||
|
size?: number;
|
||||||
|
};
|
||||||
|
cli_budget?: {
|
||||||
|
limit?: number;
|
||||||
|
in_use?: number;
|
||||||
|
available?: number;
|
||||||
|
};
|
||||||
|
webcrt?: {
|
||||||
|
active_sessions?: number;
|
||||||
|
max_sessions?: number;
|
||||||
|
};
|
||||||
|
config_sync?: { running?: boolean };
|
||||||
|
lldp_collect?: { running?: boolean };
|
||||||
|
port_traffic?: { running?: boolean };
|
||||||
|
device_schedulers?: {
|
||||||
|
stale?: boolean;
|
||||||
|
config_sync?: { running?: boolean };
|
||||||
|
lldp_collect?: { running?: boolean };
|
||||||
|
port_traffic?: { running?: boolean };
|
||||||
|
};
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchRuntimeMetrics = () => apiGet<RuntimeMetrics>("/v1/metrics/json");
|
||||||
|
|
||||||
|
export type AuditLogItem = {
|
||||||
|
id: string;
|
||||||
|
ts: string | null;
|
||||||
|
actor_username: string;
|
||||||
|
action: string;
|
||||||
|
method: string;
|
||||||
|
path: string;
|
||||||
|
status_code: number;
|
||||||
|
client_ip: string;
|
||||||
|
detail: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchAuditLogs = (params?: {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
username?: string;
|
||||||
|
action?: string;
|
||||||
|
}) => {
|
||||||
|
const p = new URLSearchParams();
|
||||||
|
p.set("page", String(Math.max(1, Number(params?.page || 1))));
|
||||||
|
p.set("page_size", String(Math.max(1, Math.min(100, Number(params?.pageSize || 20)))));
|
||||||
|
if (params?.username?.trim()) p.set("username", params.username.trim());
|
||||||
|
if (params?.action?.trim()) p.set("action", params.action.trim());
|
||||||
|
return apiGet<{ total: number; page: number; page_size: number; items: AuditLogItem[] }>(
|
||||||
|
`/v1/audit-logs?${p.toString()}`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const fetchUmeAlarmSubscriptionStatus = () =>
|
export const fetchUmeAlarmSubscriptionStatus = () =>
|
||||||
apiGet<UmeAlarmSubscriptionStatus>("/v1/ume/alarm-subscription/status");
|
apiGet<UmeAlarmSubscriptionStatus>("/v1/ume/alarm-subscription/status");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,10 @@ export default defineConfig({
|
||||||
// Keep Origin aligned with upstream for WS handshake through proxy.
|
// Keep Origin aligned with upstream for WS handshake through proxy.
|
||||||
rewriteWsOrigin: true,
|
rewriteWsOrigin: true,
|
||||||
},
|
},
|
||||||
|
"/metrics": {
|
||||||
|
target: "http://127.0.0.1:8890",
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue