mirror of
https://github.com/hansjone/netx.git
synced 2026-10-08 23:33:21 +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",
|
||||
"/metrics",
|
||||
"/metrics/json",
|
||||
"/v1/metrics/json",
|
||||
"/favicon.ico",
|
||||
"/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 .config import settings
|
||||
from .db import db_pool_status
|
||||
from .host_metrics import collect_host_metrics
|
||||
from .oclaw_alarm_forwarder import forwarder_status
|
||||
|
||||
router = APIRouter(tags=["metrics"])
|
||||
|
|
@ -29,6 +30,7 @@ def collect_runtime_metrics() -> dict[str, Any]:
|
|||
"audit_queue": audit_queue_status(),
|
||||
"oclaw_forwarder": forwarder_status(),
|
||||
"schedulers_inline": bool(getattr(settings, "run_inline_schedulers", True)),
|
||||
"host": collect_host_metrics(),
|
||||
}
|
||||
try:
|
||||
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"]}')
|
||||
if "ru_maxrss" in metrics:
|
||||
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("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
|
@ -137,3 +148,9 @@ def metrics_prometheus() -> Response:
|
|||
@router.get("/metrics/json")
|
||||
def metrics_json() -> dict[str, Any]:
|
||||
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",
|
||||
"PyJWT>=2.8.0",
|
||||
"alembic>=1.13.0",
|
||||
"psutil>=5.9.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
|
|
|||
|
|
@ -20,3 +20,4 @@ textfsm>=1.1.0
|
|||
bcrypt>=4.1.0
|
||||
PyJWT>=2.8.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",
|
||||
operations: "Operations",
|
||||
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: {
|
||||
umeSync: "UME Sync",
|
||||
umeSyncDesc: "UME alarms, subscription & inventory",
|
||||
|
|
|
|||
|
|
@ -24,6 +24,55 @@ const zh = {
|
|||
monitoring: "监控",
|
||||
operations: "运维",
|
||||
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: {
|
||||
umeSync: "UME同步",
|
||||
umeSyncDesc: "UME 告警同步、订阅与清单",
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@
|
|||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: Inter, "Segoe UI", Arial, sans-serif;
|
||||
background: #e8eef6;
|
||||
color: #1e293b;
|
||||
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
|
||||
background: #070b14;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
#root {
|
||||
|
|
@ -14,24 +14,29 @@ body {
|
|||
}
|
||||
|
||||
.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-deep: #2563eb;
|
||||
--nm-header: #0f1b2d;
|
||||
--nm-chrome: #e8eef6;
|
||||
--nm-nav: #e4eaf2;
|
||||
--nm-nav-border: #c0cad6;
|
||||
--nm-nav-hover: #d7e0eb;
|
||||
--nm-nav-active: #dbeafe;
|
||||
--nm-nav-ink: #0f1b2d;
|
||||
--nm-nav-muted: #64748b;
|
||||
--nm-panel-border: #c8d2de;
|
||||
--nm-brand: #0f1b2d;
|
||||
--nm-text: #1e293b;
|
||||
--nm-muted: #64748b;
|
||||
--nm-header: #060a12;
|
||||
--nm-chrome: #070b14;
|
||||
--nm-nav: #0c1220;
|
||||
--nm-nav-border: #1e293b;
|
||||
--nm-nav-hover: #152033;
|
||||
--nm-nav-active: #1a2740;
|
||||
--nm-nav-ink: #e2e8f0;
|
||||
--nm-nav-muted: #94a3b8;
|
||||
--nm-panel: rgba(15, 23, 42, 0.72);
|
||||
--nm-panel-solid: #0f172a;
|
||||
--nm-panel-border: #1e293b;
|
||||
--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;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
color: var(--nm-text);
|
||||
}
|
||||
|
||||
.app-brand {
|
||||
|
|
@ -133,30 +138,135 @@ body {
|
|||
.workbench {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.app-main:has(> .workbench) {
|
||||
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;
|
||||
align-items: center;
|
||||
margin-bottom: 18px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #d5deea;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.wb-head__title {
|
||||
.wb-hero__eyebrow {
|
||||
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;
|
||||
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 {
|
||||
margin-bottom: 22px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wb-section__head {
|
||||
|
|
@ -173,7 +283,7 @@ body {
|
|||
gap: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
color: #334155;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.wb-section__title::before {
|
||||
|
|
@ -189,11 +299,11 @@ body {
|
|||
}
|
||||
|
||||
.wb-section--operations .wb-section__title::before {
|
||||
background: #0f766e;
|
||||
background: #14b8a6;
|
||||
}
|
||||
|
||||
.wb-section--system .wb-section__title::before {
|
||||
background: #b45309;
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
.wb-section__count {
|
||||
|
|
@ -204,8 +314,8 @@ body {
|
|||
height: 18px;
|
||||
padding: 0 5px;
|
||||
border-radius: 999px;
|
||||
background: #e8eef5;
|
||||
color: #64748b;
|
||||
background: rgba(148, 163, 184, 0.14);
|
||||
color: #94a3b8;
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
line-height: 1;
|
||||
|
|
@ -224,49 +334,48 @@ body {
|
|||
align-items: center;
|
||||
gap: 10px;
|
||||
width: auto;
|
||||
min-width: 132px;
|
||||
min-width: 148px;
|
||||
max-width: 100%;
|
||||
min-height: 0;
|
||||
margin: 0;
|
||||
padding: 12px 16px 12px 12px;
|
||||
border: 1px solid #d8e0ea;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--nm-glass-border);
|
||||
border-radius: 12px;
|
||||
background: rgba(15, 23, 42, 0.65);
|
||||
box-shadow: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
text-decoration: none;
|
||||
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:focus-visible {
|
||||
border-color: #8fb4df;
|
||||
background: #f7faff;
|
||||
box-shadow: 0 2px 10px rgba(15, 39, 68, 0.08);
|
||||
transform: none;
|
||||
border-color: rgba(59, 130, 246, 0.55);
|
||||
background: rgba(30, 41, 59, 0.85);
|
||||
transform: translateY(-1px);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.wb-card--blue:hover,
|
||||
.wb-card--blue:focus-visible {
|
||||
border-color: #90caf9;
|
||||
border-color: rgba(59, 130, 246, 0.65);
|
||||
}
|
||||
|
||||
.wb-card--green:hover,
|
||||
.wb-card--green:focus-visible {
|
||||
border-color: #81c784;
|
||||
border-color: rgba(34, 197, 94, 0.55);
|
||||
}
|
||||
|
||||
.wb-card--amber:hover,
|
||||
.wb-card--amber:focus-visible {
|
||||
border-color: #ffd54f;
|
||||
border-color: rgba(245, 158, 11, 0.55);
|
||||
}
|
||||
|
||||
.wb-card--slate:hover,
|
||||
.wb-card--slate:focus-visible {
|
||||
border-color: #90a4ae;
|
||||
border-color: rgba(148, 163, 184, 0.45);
|
||||
}
|
||||
|
||||
.wb-card__icon {
|
||||
|
|
@ -277,7 +386,6 @@ body {
|
|||
height: 38px;
|
||||
border-radius: 0;
|
||||
flex-shrink: 0;
|
||||
/* Flat-top hexagon */
|
||||
clip-path: polygon(50% 0%, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%);
|
||||
}
|
||||
|
||||
|
|
@ -287,47 +395,338 @@ body {
|
|||
}
|
||||
|
||||
.wb-card__icon--blue {
|
||||
background: #e3f2fd;
|
||||
color: var(--nm-accent, #3b82f6);
|
||||
background: rgba(59, 130, 246, 0.22);
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
.wb-card__icon--green {
|
||||
background: #e8f5e9;
|
||||
color: #2e7d32;
|
||||
background: rgba(34, 197, 94, 0.18);
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
.wb-card__icon--amber {
|
||||
background: #fff8e1;
|
||||
color: #f57f17;
|
||||
background: rgba(245, 158, 11, 0.18);
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.wb-card__icon--slate {
|
||||
background: #eceff1;
|
||||
color: #455a64;
|
||||
background: rgba(148, 163, 184, 0.16);
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.wb-card__label {
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
color: var(--nm-brand, #0f1b2d);
|
||||
color: #f1f5f9;
|
||||
line-height: 1.25;
|
||||
white-space: nowrap;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.header-menu__panel--light {
|
||||
background: #fff;
|
||||
border-color: #e2e8f0;
|
||||
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.12);
|
||||
.wb-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.15fr) minmax(0, 0.85fr);
|
||||
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;
|
||||
}
|
||||
|
||||
.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 {
|
||||
color: #334155;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.header-menu__item--light:hover {
|
||||
|
|
@ -407,9 +806,9 @@ body {
|
|||
.app-main select,
|
||||
.app-main input,
|
||||
.app-main textarea {
|
||||
border: 1px solid #cbd5e1;
|
||||
background: #fff;
|
||||
color: #1f2937;
|
||||
border: 1px solid var(--nm-panel-border, #1e293b);
|
||||
background: #0b1220;
|
||||
color: var(--nm-text, #e2e8f0);
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
|
@ -418,9 +817,9 @@ button,
|
|||
select,
|
||||
input,
|
||||
textarea {
|
||||
border: 1px solid #cbd5e1;
|
||||
background: #fff;
|
||||
color: #1f2937;
|
||||
border: 1px solid var(--nm-panel-border, #1e293b);
|
||||
background: #0b1220;
|
||||
color: var(--nm-text, #e2e8f0);
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
|
@ -443,20 +842,22 @@ button {
|
|||
|
||||
.app-main .card,
|
||||
.app-main .panel {
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--nm-panel-border, #1e293b);
|
||||
background: var(--nm-panel-solid, #0f172a);
|
||||
border-radius: 10px;
|
||||
padding: 14px;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
|
||||
box-shadow: none;
|
||||
color: var(--nm-text, #e2e8f0);
|
||||
}
|
||||
|
||||
.card,
|
||||
.panel {
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--nm-panel-border, #1e293b);
|
||||
background: var(--nm-panel-solid, #0f172a);
|
||||
border-radius: 10px;
|
||||
padding: 14px;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
|
||||
box-shadow: none;
|
||||
color: var(--nm-text, #e2e8f0);
|
||||
}
|
||||
|
||||
.card h3,
|
||||
|
|
@ -535,16 +936,16 @@ table {
|
|||
|
||||
th,
|
||||
td {
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
border-bottom: 1px solid var(--nm-panel-border, #1e293b);
|
||||
padding: 8px;
|
||||
text-align: left;
|
||||
font-size: 13px;
|
||||
vertical-align: top;
|
||||
color: #334155;
|
||||
color: var(--nm-text, #e2e8f0);
|
||||
}
|
||||
|
||||
th {
|
||||
color: #64748b;
|
||||
color: var(--nm-muted, #94a3b8);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
|
|
@ -1938,7 +2339,7 @@ pre {
|
|||
.form-fieldset {
|
||||
margin-top: 16px;
|
||||
padding: 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border: 1px solid var(--nm-panel-border, #1e293b);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
|
|
@ -1947,7 +2348,7 @@ pre {
|
|||
padding: 0 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
color: var(--nm-text, #e2e8f0);
|
||||
}
|
||||
|
||||
.form-fieldset__title {
|
||||
|
|
|
|||
|
|
@ -1,15 +1,85 @@
|
|||
import { useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useI18n } from "../i18n";
|
||||
import { WorkbenchCardIcon } from "../components/WorkbenchCardIcon";
|
||||
import { modulesInSection, type ModuleDefinition, type WorkbenchSection } from "../config/modules";
|
||||
import { openOrFocusModule } from "../utils/moduleWindows";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { fetchOpsTasks, fetchRuntimeMetrics } from "../services/api";
|
||||
|
||||
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() {
|
||||
const { t } = useI18n();
|
||||
const { isAdmin, hasScope } = useAuth();
|
||||
const { t, locale } = useI18n();
|
||||
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 canSee = (mod: ModuleDefinition) =>
|
||||
|
|
@ -22,11 +92,93 @@ export function WorkbenchPage() {
|
|||
) as Record<WorkbenchSection, ModuleDefinition[]>;
|
||||
}, [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 (
|
||||
<div className="workbench">
|
||||
<header className="wb-head">
|
||||
<h1 className="wb-head__title">{t("workbench.title")}</h1>
|
||||
</header>
|
||||
<section className="wb-hero" aria-label={t("workbench.title")}>
|
||||
<div className="wb-hero__copy">
|
||||
<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) => {
|
||||
const mods = visibleBySection[section];
|
||||
|
|
|
|||
|
|
@ -231,6 +231,77 @@ export type OpsTasksResponse = {
|
|||
|
||||
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 = () =>
|
||||
apiGet<UmeAlarmSubscriptionStatus>("/v1/ume/alarm-subscription/status");
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,10 @@ export default defineConfig({
|
|||
// Keep Origin aligned with upstream for WS handshake through proxy.
|
||||
rewriteWsOrigin: true,
|
||||
},
|
||||
"/metrics": {
|
||||
target: "http://127.0.0.1:8890",
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue