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:
dekun
2026-06-13 14:04:05 +08:00
parent ab862efc4e
commit 1fd0003fc8
7 changed files with 485 additions and 1 deletions
+7
View File
@@ -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="未登录中控")
+1
View File
@@ -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
+143
View File
@@ -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;
+149
View File
@@ -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() {
+29 -1
View File
@@ -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>