feat(hub): show server CPU memory disk and network status on monitor page
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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"),
|
||||
}
|
||||
@@ -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="未登录中控")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -61,6 +61,34 @@
|
||||
<div class="page-head">
|
||||
<h1><span class="head-tag">MON</span> 监控区</h1>
|
||||
</div>
|
||||
<div id="host-status-bar" class="host-status-bar hidden" aria-label="服务器运行状态" aria-live="polite">
|
||||
<div class="host-status-head">
|
||||
<span class="host-status-dot ok" id="host-status-dot" aria-hidden="true"></span>
|
||||
<span class="host-status-name" id="host-status-name">服务器</span>
|
||||
<span class="host-status-uptime" id="host-status-uptime"></span>
|
||||
</div>
|
||||
<div class="host-status-metrics">
|
||||
<div class="host-metric" id="host-metric-cpu">
|
||||
<span class="host-metric-label">CPU</span>
|
||||
<div class="host-metric-bar"><span class="host-metric-fill" id="host-cpu-fill"></span></div>
|
||||
<span class="host-metric-val" id="host-cpu-val">—</span>
|
||||
</div>
|
||||
<div class="host-metric" id="host-metric-mem">
|
||||
<span class="host-metric-label">内存</span>
|
||||
<div class="host-metric-bar"><span class="host-metric-fill" id="host-mem-fill"></span></div>
|
||||
<span class="host-metric-val" id="host-mem-val">—</span>
|
||||
</div>
|
||||
<div class="host-metric" id="host-metric-disk">
|
||||
<span class="host-metric-label">硬盘</span>
|
||||
<div class="host-metric-bar"><span class="host-metric-fill" id="host-disk-fill"></span></div>
|
||||
<span class="host-metric-val" id="host-disk-val">—</span>
|
||||
</div>
|
||||
<div class="host-metric host-metric-net" id="host-metric-net">
|
||||
<span class="host-metric-label">网络</span>
|
||||
<span class="host-metric-val" id="host-net-val">—</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="monitor-alert-summary" class="monitor-alert-summary hidden" aria-live="polite"></div>
|
||||
<div class="toolbar">
|
||||
<button type="button" id="btn-monitor-refresh" class="primary">立即刷新</button>
|
||||
@@ -600,6 +628,6 @@
|
||||
<script src="/assets/dashboard.js?v=20260612-dash-monitor-count"></script>
|
||||
<script src="/assets/ai_review_render.js?v=3"></script>
|
||||
<script src="/assets/time_close_ui.js?v=2"></script>
|
||||
<script src="/assets/app.js?v=20260612-ai-chat-budget"></script>
|
||||
<script src="/assets/app.js?v=20260612-host-status"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user