diff --git a/hub_host_status_lib.py b/hub_host_status_lib.py new file mode 100644 index 0000000..47fe1a5 --- /dev/null +++ b/hub_host_status_lib.py @@ -0,0 +1,98 @@ +"""中控:本机 CPU / 内存 / 磁盘 / 网络快照(监控区服务器状态条)。""" +from __future__ import annotations + +import os +import socket +import time +from typing import Any + +_state: dict[str, Any] = { + "primed": False, + "net_ts": 0.0, + "net_sent": 0, + "net_recv": 0, +} + + +def _disk_path() -> str: + raw = (os.getenv("HUB_HOST_DISK_PATH") or "").strip() + if raw: + return raw + if os.name == "nt": + drive = (os.environ.get("SystemDrive") or "C:").strip() + return drive if drive.endswith(("\\", "/")) else drive + "\\" + return "/" + + +def _safe_int(value: Any) -> int: + try: + return int(value) + except (TypeError, ValueError): + return 0 + + +def get_host_status() -> dict[str, Any]: + try: + import psutil + except ImportError: + return { + "ok": False, + "msg": "未安装 psutil,请在 manual-trading-hub 环境执行 pip install psutil", + } + + now = time.time() + if not _state["primed"]: + psutil.cpu_percent(interval=None) + _state["primed"] = True + + cpu_pct = float(psutil.cpu_percent(interval=None)) + cpu_count = int(psutil.cpu_count(logical=True) or 0) + + vm = psutil.virtual_memory() + disk_path = _disk_path() + du = psutil.disk_usage(disk_path) + + net = psutil.net_io_counters() + sent_rate = 0.0 + recv_rate = 0.0 + if net is not None and _state["net_ts"] > 0: + dt = max(0.001, now - float(_state["net_ts"])) + sent_rate = max(0.0, (net.bytes_sent - int(_state["net_sent"])) / dt) + recv_rate = max(0.0, (net.bytes_recv - int(_state["net_recv"])) / dt) + if net is not None: + _state["net_ts"] = now + _state["net_sent"] = int(net.bytes_sent) + _state["net_recv"] = int(net.bytes_recv) + + disk_total = _safe_int(du.total) + disk_used = _safe_int(du.used) + disk_pct = round(disk_used / disk_total * 100, 1) if disk_total > 0 else 0.0 + + boot = float(psutil.boot_time()) + return { + "ok": True, + "hostname": socket.gethostname(), + "uptime_sec": max(0, int(now - boot)), + "cpu": { + "percent": round(cpu_pct, 1), + "count": cpu_count, + }, + "memory": { + "total_bytes": _safe_int(vm.total), + "used_bytes": _safe_int(vm.used), + "percent": round(float(vm.percent), 1), + }, + "disk": { + "path": disk_path, + "total_bytes": disk_total, + "used_bytes": disk_used, + "percent": disk_pct, + }, + "network": { + "bytes_sent": _safe_int(net.bytes_sent if net else 0), + "bytes_recv": _safe_int(net.bytes_recv if net else 0), + "sent_rate_bps": round(sent_rate, 1), + "recv_rate_bps": round(recv_rate, 1), + }, + "updated_at": time.strftime("%Y-%m-%d %H:%M:%S"), + } diff --git a/manual_trading_hub/hub.py b/manual_trading_hub/hub.py index 0b7fca5..8e169bd 100644 --- a/manual_trading_hub/hub.py +++ b/manual_trading_hub/hub.py @@ -1660,6 +1660,13 @@ async def api_monitor_board_refresh(): return {"ok": True, "board_version": board_store.version} +@app.get("/api/host/status") +async def api_host_status(): + from hub_host_status_lib import get_host_status + + return await asyncio.to_thread(get_host_status) + + def _require_hub_logged_in(request: Request) -> None: if password_required() and not validate_session_token(request.cookies.get(SESSION_COOKIE)): raise HTTPException(status_code=401, detail="未登录中控") diff --git a/manual_trading_hub/requirements.txt b/manual_trading_hub/requirements.txt index d6d7d66..9698e15 100644 --- a/manual_trading_hub/requirements.txt +++ b/manual_trading_hub/requirements.txt @@ -4,3 +4,4 @@ python-multipart>=0.0.9,<1 httpx>=0.27,<1 ccxt>=4.2,<5 PySocks>=1.7,<2 +psutil>=5.9,<8 diff --git a/manual_trading_hub/static/app.css b/manual_trading_hub/static/app.css index 128d0be..cfd708d 100644 --- a/manual_trading_hub/static/app.css +++ b/manual_trading_hub/static/app.css @@ -650,6 +650,130 @@ button:disabled { color: var(--muted); } +.host-status-bar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px 16px; + margin: 0 0 10px; + padding: 10px 12px; + border-radius: var(--radius); + border: 1px solid var(--border-soft); + background: var(--panel); + font-size: 12px; +} + +.host-status-bar.hidden { + display: none !important; +} + +.host-status-head { + display: inline-flex; + align-items: center; + gap: 8px; + min-width: 0; + flex: 0 0 auto; +} + +.host-status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; + background: var(--muted); +} + +.host-status-dot.ok { + background: var(--green); + box-shadow: 0 0 8px var(--green); +} + +.host-status-dot.warn { + background: #ffb020; + box-shadow: 0 0 8px rgba(255, 176, 32, 0.45); +} + +.host-status-dot.bad { + background: var(--red); + box-shadow: 0 0 8px var(--red); +} + +.host-status-name { + font-weight: 600; + color: var(--text); + white-space: nowrap; +} + +.host-status-uptime { + color: var(--muted); + font-size: 11px; + white-space: nowrap; +} + +.host-status-metrics { + display: flex; + flex: 1 1 520px; + flex-wrap: wrap; + align-items: center; + gap: 8px 14px; + min-width: 0; +} + +.host-metric { + display: grid; + grid-template-columns: 42px minmax(72px, 1fr) auto; + align-items: center; + gap: 8px; + min-width: 0; + flex: 1 1 180px; +} + +.host-metric-net { + grid-template-columns: 42px auto; + flex: 1 1 220px; +} + +.host-metric-label { + color: var(--muted); + font-size: 11px; + white-space: nowrap; +} + +.host-metric-bar { + height: 6px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.06); + overflow: hidden; +} + +html[data-theme="light"] .host-metric-bar { + background: rgba(0, 0, 0, 0.06); +} + +.host-metric-fill { + display: block; + height: 100%; + width: 0%; + border-radius: inherit; + background: #3b82f6; + transition: width 0.35s ease; +} + +.host-metric-fill.warn { + background: #ffb020; +} + +.host-metric-fill.bad { + background: var(--red); +} + +.host-metric-val { + color: var(--text); + font-variant-numeric: tabular-nums; + white-space: nowrap; + font-size: 11px; +} + .grid-monitor.grid-monitor-tiles { grid-template-columns: repeat(2, minmax(0, 1fr)) !important; gap: 10px; @@ -2613,6 +2737,25 @@ body.login-page { margin-bottom: 8px; } + .host-status-bar { + flex-direction: column; + align-items: stretch; + gap: 8px; + padding: 8px 10px; + } + + .host-status-metrics { + flex-direction: column; + align-items: stretch; + gap: 6px; + } + + .host-metric, + .host-metric-net { + flex: 1 1 auto; + width: 100%; + } + .card-head { flex-direction: column; align-items: stretch; diff --git a/manual_trading_hub/static/app.js b/manual_trading_hub/static/app.js index 561912f..b2788e0 100644 --- a/manual_trading_hub/static/app.js +++ b/manual_trading_hub/static/app.js @@ -67,6 +67,153 @@ let monitorBoardSlowHintTimer = null; let boardEventSource = null; let sseReconnectTimer = null; + let hostStatusTimer = null; + const HOST_STATUS_POLL_MS = 5000; + + function fmtHostBytes(n) { + const v = Number(n); + if (!Number.isFinite(v)) return "—"; + const abs = Math.abs(v); + if (abs >= 1e12) return (v / 1e12).toFixed(2) + " TB"; + if (abs >= 1e9) return (v / 1e9).toFixed(2) + " GB"; + if (abs >= 1e6) return (v / 1e6).toFixed(2) + " MB"; + if (abs >= 1e3) return (v / 1e3).toFixed(1) + " KB"; + return v.toFixed(0) + " B"; + } + + function fmtHostUptime(sec) { + const s = Math.max(0, Number(sec) || 0); + const d = Math.floor(s / 86400); + const h = Math.floor((s % 86400) / 3600); + const m = Math.floor((s % 3600) / 60); + if (d > 0) return d + "天" + h + "时"; + if (h > 0) return h + "时" + m + "分"; + return m + "分"; + } + + function hostMetricLevel(percent) { + const p = Number(percent); + if (!Number.isFinite(p)) return "ok"; + if (p >= 90) return "bad"; + if (p >= 75) return "warn"; + return "ok"; + } + + function setHostMetricBar(fillEl, percent) { + if (!fillEl) return; + const p = Math.max(0, Math.min(100, Number(percent) || 0)); + const level = hostMetricLevel(p); + fillEl.style.width = p + "%"; + fillEl.classList.remove("warn", "bad"); + if (level === "warn") fillEl.classList.add("warn"); + if (level === "bad") fillEl.classList.add("bad"); + } + + function renderHostStatusBar(data) { + const bar = document.getElementById("host-status-bar"); + if (!bar) return; + if (!data || !data.ok) { + bar.classList.remove("hidden"); + const dot = document.getElementById("host-status-dot"); + const name = document.getElementById("host-status-name"); + const uptime = document.getElementById("host-status-uptime"); + if (dot) { + dot.className = "host-status-dot bad"; + } + if (name) name.textContent = "服务器"; + if (uptime) uptime.textContent = (data && data.msg) || "状态不可用"; + const cpuVal = document.getElementById("host-cpu-val"); + const memVal = document.getElementById("host-mem-val"); + const diskVal = document.getElementById("host-disk-val"); + const netVal = document.getElementById("host-net-val"); + if (cpuVal) cpuVal.textContent = "—"; + if (memVal) memVal.textContent = "—"; + if (diskVal) diskVal.textContent = "—"; + if (netVal) netVal.textContent = "—"; + return; + } + bar.classList.remove("hidden"); + const cpu = data.cpu || {}; + const mem = data.memory || {}; + const disk = data.disk || {}; + const net = data.network || {}; + const levels = [ + hostMetricLevel(cpu.percent), + hostMetricLevel(mem.percent), + hostMetricLevel(disk.percent), + ]; + let overall = "ok"; + if (levels.includes("bad")) overall = "bad"; + else if (levels.includes("warn")) overall = "warn"; + const dot = document.getElementById("host-status-dot"); + const name = document.getElementById("host-status-name"); + const uptime = document.getElementById("host-status-uptime"); + if (dot) dot.className = "host-status-dot " + overall; + if (name) name.textContent = data.hostname || "服务器"; + if (uptime) { + uptime.textContent = + "运行 " + + fmtHostUptime(data.uptime_sec) + + (data.updated_at ? " · " + data.updated_at : ""); + } + setHostMetricBar(document.getElementById("host-cpu-fill"), cpu.percent); + setHostMetricBar(document.getElementById("host-mem-fill"), mem.percent); + setHostMetricBar(document.getElementById("host-disk-fill"), disk.percent); + const cpuVal = document.getElementById("host-cpu-val"); + const memVal = document.getElementById("host-mem-val"); + const diskVal = document.getElementById("host-disk-val"); + const netVal = document.getElementById("host-net-val"); + if (cpuVal) { + cpuVal.textContent = + (cpu.percent != null ? cpu.percent + "%" : "—") + + (cpu.count ? " · " + cpu.count + "核" : ""); + } + if (memVal) { + memVal.textContent = + (mem.percent != null ? mem.percent + "%" : "—") + + " · " + + fmtHostBytes(mem.used_bytes) + + "/" + + fmtHostBytes(mem.total_bytes); + } + if (diskVal) { + diskVal.textContent = + (disk.percent != null ? disk.percent + "%" : "—") + + " · " + + fmtHostBytes(disk.used_bytes) + + "/" + + fmtHostBytes(disk.total_bytes); + } + if (netVal) { + const up = fmtHostBytes(net.sent_rate_bps) + "/s"; + const down = fmtHostBytes(net.recv_rate_bps) + "/s"; + netVal.textContent = "↑" + up + " ↓" + down; + } + } + + async function fetchHostStatus() { + if (currentPage() !== "monitor") return; + try { + const r = await apiFetch("/api/host/status", { credentials: "same-origin" }); + const data = await r.json(); + renderHostStatusBar(data); + } catch (err) { + renderHostStatusBar({ ok: false, msg: String(err && err.message ? err.message : err) }); + } + } + + function stopHostStatusPoll() { + if (hostStatusTimer) { + clearInterval(hostStatusTimer); + hostStatusTimer = null; + } + } + + function startHostStatusPoll() { + stopHostStatusPoll(); + void fetchHostStatus(); + hostStatusTimer = setInterval(fetchHostStatus, HOST_STATUS_POLL_MS); + } async function apiFetch(url, opts) { const r = await fetch(url, opts); @@ -744,6 +891,7 @@ function stopMonitorPoll() { closeMonitorBoardStream(); + stopHostStatusPoll(); if (sseReconnectTimer) { clearTimeout(sseReconnectTimer); sseReconnectTimer = null; @@ -886,6 +1034,7 @@ const hadCache = restoreMonitorBoardFromCache(); void fetchMonitorBoardSnapshot({ showLoading: !hadCache }); connectMonitorBoardStream(); + startHostStatusPoll(); } async function loadSettings() { diff --git a/manual_trading_hub/static/index.html b/manual_trading_hub/static/index.html index 10e4937..34fe8f8 100644 --- a/manual_trading_hub/static/index.html +++ b/manual_trading_hub/static/index.html @@ -61,6 +61,34 @@

MON 监控区

+
@@ -600,6 +628,6 @@ - + diff --git a/tests/test_hub_host_status_lib.py b/tests/test_hub_host_status_lib.py new file mode 100644 index 0000000..cb4c640 --- /dev/null +++ b/tests/test_hub_host_status_lib.py @@ -0,0 +1,58 @@ +"""hub_host_status_lib 单元测试。""" +from __future__ import annotations + +import sys +import unittest +from unittest.mock import MagicMock, patch + +from hub_host_status_lib import _disk_path, _state, get_host_status + + +class HubHostStatusLibTest(unittest.TestCase): + def setUp(self): + _state["primed"] = False + _state["net_ts"] = 0.0 + _state["net_sent"] = 0 + _state["net_recv"] = 0 + + def test_disk_path_env_override(self): + with patch.dict("os.environ", {"HUB_HOST_DISK_PATH": "/data"}, clear=False): + self.assertEqual(_disk_path(), "/data") + + def test_get_host_status_without_psutil(self): + import builtins + + real_import = builtins.__import__ + + def fake_import(name, globals=None, locals=None, fromlist=(), level=0): + if name == "psutil": + raise ImportError("no psutil") + return real_import(name, globals, locals, fromlist, level) + + with patch("builtins.__import__", side_effect=fake_import): + out = get_host_status() + self.assertFalse(out.get("ok")) + self.assertIn("psutil", out.get("msg", "")) + + def test_get_host_status_payload(self): + fake_vm = MagicMock(total=8_000_000_000, used=3_200_000_000, percent=40.0) + fake_du = MagicMock(total=100_000_000_000, used=50_000_000_000) + fake_net = MagicMock(bytes_sent=1_000_000, bytes_recv=2_000_000) + fake_psutil = MagicMock() + fake_psutil.cpu_percent.return_value = 12.5 + fake_psutil.cpu_count.return_value = 4 + fake_psutil.virtual_memory.return_value = fake_vm + fake_psutil.disk_usage.return_value = fake_du + fake_psutil.net_io_counters.return_value = fake_net + fake_psutil.boot_time.return_value = 1_700_000_000.0 + with patch.dict(sys.modules, {"psutil": fake_psutil}): + out = get_host_status() + self.assertTrue(out.get("ok")) + self.assertEqual(out["cpu"]["percent"], 12.5) + self.assertEqual(out["memory"]["percent"], 40.0) + self.assertEqual(out["disk"]["percent"], 50.0) + self.assertIn("network", out) + + +if __name__ == "__main__": + unittest.main()