diff --git a/netx_api/auth_middleware.py b/netx_api/auth_middleware.py index 778e988..981694f 100644 --- a/netx_api/auth_middleware.py +++ b/netx_api/auth_middleware.py @@ -26,6 +26,7 @@ _PUBLIC_EXACT = frozenset( "/health/ready", "/metrics", "/metrics/json", + "/v1/metrics/json", "/favicon.ico", "/v1/auth/login", } diff --git a/netx_api/host_metrics.py b/netx_api/host_metrics.py new file mode 100644 index 0000000..72d264b --- /dev/null +++ b/netx_api/host_metrics.py @@ -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 diff --git a/netx_api/metrics_router.py b/netx_api/metrics_router.py index 51c712f..5d18a8f 100644 --- a/netx_api/metrics_router.py +++ b/netx_api/metrics_router.py @@ -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() diff --git a/pyproject.toml b/pyproject.toml index 2cb74cc..a2056ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ dependencies = [ "bcrypt>=4.1.0", "PyJWT>=2.8.0", "alembic>=1.13.0", + "psutil>=5.9.0", ] [project.optional-dependencies] diff --git a/requirements.txt b/requirements.txt index 6ad2d4f..522e563 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,3 +20,4 @@ textfsm>=1.1.0 bcrypt>=4.1.0 PyJWT>=2.8.0 alembic>=1.13.0 +psutil>=5.9.0 diff --git a/tests/test_host_metrics.py b/tests/test_host_metrics.py new file mode 100644 index 0000000..9b1f97b --- /dev/null +++ b/tests/test_host_metrics.py @@ -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() diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 632f515..2d25b15 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -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", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 7c2078b..c2d37ed 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -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 告警同步、订阅与清单", diff --git a/web/src/index.css b/web/src/index.css index 082a152..0bd85fc 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -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 { diff --git a/web/src/pages/WorkbenchPage.tsx b/web/src/pages/WorkbenchPage.tsx index 7f76221..bebb830 100644 --- a/web/src/pages/WorkbenchPage.tsx +++ b/web/src/pages/WorkbenchPage.tsx @@ -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 ( +
{t("workbench.welcomeEyebrow")}
+{t("workbench.today", { date: todayLabel })}
+