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:
oliver 2026-08-04 23:20:29 +08:00
parent 8ded8eeb7d
commit 254c50a2c0
12 changed files with 1286 additions and 83 deletions

View file

@ -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
View 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

View file

@ -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()

View file

@ -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]

View file

@ -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

View 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()

View file

@ -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",

View file

@ -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 告警同步、订阅与清单",

View file

@ -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 {

View file

@ -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];

View file

@ -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");

View file

@ -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,
},
}, },
}, },
}) })