feat: 服务器监控面板(/server) - CPU/内存/硬盘/PM2进程/PG数据库/回补状态

This commit is contained in:
root 2026-02-27 17:30:41 +00:00
parent 871da720ab
commit 930c8d3a9c
4 changed files with 411 additions and 1 deletions

View File

@ -452,3 +452,125 @@ async def get_signal_trades(
status, limit
)
return {"count": len(rows), "data": rows}
# ─── 服务器状态监控 ───────────────────────────────────────────────
import shutil, subprocess, psutil
@app.get("/api/server/status")
async def get_server_status(user: dict = Depends(get_current_user)):
"""服务器全状态CPU/内存/硬盘/负载/PM2进程/PG数据库/回补进度"""
# CPU
cpu_percent = psutil.cpu_percent(interval=0.5)
cpu_count = psutil.cpu_count()
# 内存
mem = psutil.virtual_memory()
swap = psutil.swap_memory()
# 硬盘
disk = shutil.disk_usage("/")
# 负载
load1, load5, load15 = os.getloadavg()
# Uptime
boot_time = psutil.boot_time()
uptime_s = time.time() - boot_time
# 网络IO
net = psutil.net_io_counters()
# PM2进程状态
pm2_procs = []
try:
result = subprocess.run(
["npx", "pm2", "jlist"],
capture_output=True, text=True, timeout=10
)
import json as _json
procs = _json.loads(result.stdout)
for p in procs:
pm2_procs.append({
"name": p.get("name", ""),
"status": p.get("pm2_env", {}).get("status", "unknown"),
"cpu": p.get("monit", {}).get("cpu", 0),
"memory_mb": round(p.get("monit", {}).get("memory", 0) / 1024 / 1024, 1),
"restarts": p.get("pm2_env", {}).get("restart_time", 0),
"uptime_ms": p.get("pm2_env", {}).get("pm_uptime", 0),
"pid": p.get("pid", 0),
})
except Exception:
pm2_procs = []
# PG数据库大小 + agg_trades条数
pg_info = {}
try:
row = await async_fetchrow(
"SELECT pg_database_size(current_database()) as db_size"
)
pg_info["db_size_mb"] = round(row["db_size"] / 1024 / 1024, 1) if row else 0
row2 = await async_fetchrow("SELECT COUNT(*) as cnt FROM agg_trades")
pg_info["agg_trades_count"] = row2["cnt"] if row2 else 0
row3 = await async_fetchrow("SELECT COUNT(*) as cnt FROM rate_snapshots")
pg_info["rate_snapshots_count"] = row3["cnt"] if row3 else 0
# 各symbol最新数据时间
meta_rows = await async_fetch("SELECT symbol, last_time_ms, earliest_time_ms FROM agg_trades_meta")
pg_info["symbols"] = {}
for m in meta_rows:
sym = m["symbol"].replace("USDT", "")
pg_info["symbols"][sym] = {
"latest_ms": m["last_time_ms"],
"earliest_ms": m["earliest_time_ms"],
"span_hours": round((m["last_time_ms"] - m["earliest_time_ms"]) / 3600000, 1),
}
except Exception:
pass
# 回补进程
backfill_running = False
try:
for proc in psutil.process_iter(["pid", "cmdline"]):
cmdline = " ".join(proc.info.get("cmdline") or [])
if "backfill_agg_trades" in cmdline:
backfill_running = True
break
except Exception:
pass
return {
"timestamp": int(time.time() * 1000),
"cpu": {
"percent": cpu_percent,
"cores": cpu_count,
},
"memory": {
"total_gb": round(mem.total / 1024**3, 1),
"used_gb": round(mem.used / 1024**3, 1),
"percent": mem.percent,
"swap_percent": swap.percent,
},
"disk": {
"total_gb": round(disk.total / 1024**3, 1),
"used_gb": round(disk.used / 1024**3, 1),
"free_gb": round(disk.free / 1024**3, 1),
"percent": round(disk.used / disk.total * 100, 1),
},
"load": {
"load1": round(load1, 2),
"load5": round(load5, 2),
"load15": round(load15, 2),
},
"uptime_hours": round(uptime_s / 3600, 1),
"network": {
"bytes_sent_gb": round(net.bytes_sent / 1024**3, 2),
"bytes_recv_gb": round(net.bytes_recv / 1024**3, 2),
},
"pm2": pm2_procs,
"postgres": pg_info,
"backfill_running": backfill_running,
}

View File

@ -2,3 +2,4 @@ fastapi
uvicorn
httpx
python-dotenv
psutil

View File

@ -0,0 +1,286 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { authFetch } from "@/lib/auth";
import { useAuth } from "@/lib/auth";
import Link from "next/link";
interface PM2Proc {
name: string;
status: string;
cpu: number;
memory_mb: number;
restarts: number;
uptime_ms: number;
pid: number;
}
interface ServerStatus {
timestamp: number;
cpu: { percent: number; cores: number };
memory: { total_gb: number; used_gb: number; percent: number; swap_percent: number };
disk: { total_gb: number; used_gb: number; free_gb: number; percent: number };
load: { load1: number; load5: number; load15: number };
uptime_hours: number;
network: { bytes_sent_gb: number; bytes_recv_gb: number };
pm2: PM2Proc[];
postgres: {
db_size_mb: number;
agg_trades_count: number;
rate_snapshots_count: number;
symbols: Record<string, { latest_ms: number; earliest_ms: number; span_hours: number }>;
};
backfill_running: boolean;
}
function bjtStr(ms: number) {
const d = new Date(ms + 8 * 3600 * 1000);
return `${d.getUTCFullYear()}-${String(d.getUTCMonth()+1).padStart(2,"0")}-${String(d.getUTCDate()).padStart(2,"0")} ${String(d.getUTCHours()).padStart(2,"0")}:${String(d.getUTCMinutes()).padStart(2,"0")}:${String(d.getUTCSeconds()).padStart(2,"0")}`;
}
function uptimeStr(ms: number) {
if (!ms) return "-";
const now = Date.now();
const diff = now - ms;
const h = Math.floor(diff / 3600000);
const m = Math.floor((diff % 3600000) / 60000);
if (h > 24) return `${Math.floor(h/24)}d ${h%24}h`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
function ProgressBar({ percent, color = "cyan" }: { percent: number; color?: string }) {
const colorClass = percent > 90 ? "bg-red-500" : percent > 70 ? "bg-amber-500" : color === "cyan" ? "bg-cyan-500" : "bg-emerald-500";
return (
<div className="w-full bg-slate-700 rounded-full h-2.5 overflow-hidden">
<div className={`h-full rounded-full transition-all duration-500 ${colorClass}`} style={{ width: `${Math.min(percent, 100)}%` }} />
</div>
);
}
function StatusBadge({ status }: { status: string }) {
const isOnline = status === "online";
return (
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${isOnline ? "bg-emerald-500/20 text-emerald-400" : "bg-red-500/20 text-red-400"}`}>
<span className={`w-1.5 h-1.5 rounded-full ${isOnline ? "bg-emerald-400" : "bg-red-400"}`} />
{status}
</span>
);
}
function numberFmt(n: number) {
return n.toLocaleString("en-US");
}
export default function ServerPage() {
const { token } = useAuth();
const [data, setData] = useState<ServerStatus | null>(null);
const [loading, setLoading] = useState(true);
const [lastUpdate, setLastUpdate] = useState(0);
const fetchData = useCallback(async () => {
try {
const res = await authFetch("/api/server/status");
if (!res.ok) return;
const json = await res.json();
setData(json);
setLastUpdate(Date.now());
} catch (e) {
// ignore
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (!token) return;
fetchData();
const iv = setInterval(fetchData, 10000); // 10秒刷新
return () => clearInterval(iv);
}, [token, fetchData]);
if (!token) {
return (
<div className="min-h-screen bg-slate-950 flex items-center justify-center">
<div className="text-center">
<p className="text-slate-400 mb-4"></p>
<Link href="/login" className="text-cyan-400 hover:text-cyan-300 underline"></Link>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-slate-950 text-slate-200">
{/* 顶栏 */}
<header className="border-b border-slate-800 bg-slate-900/80 backdrop-blur-sm sticky top-0 z-50">
<div className="max-w-7xl mx-auto px-4 h-14 flex items-center justify-between">
<div className="flex items-center gap-3">
<Link href="/" className="text-slate-400 hover:text-slate-200 text-sm"> </Link>
<h1 className="text-base font-semibold text-white">🖥 </h1>
<span className="text-xs text-slate-500">GCP asia-northeast1-b</span>
</div>
<div className="flex items-center gap-2 text-xs text-slate-500">
{data?.backfill_running && (
<span className="flex items-center gap-1 text-cyan-400">
<span className="w-1.5 h-1.5 rounded-full bg-cyan-400 animate-pulse" />
</span>
)}
<span>10</span>
{lastUpdate > 0 && <span>· {bjtStr(lastUpdate)}</span>}
</div>
</div>
</header>
<main className="max-w-7xl mx-auto px-4 py-6 space-y-6">
{loading ? (
<div className="flex items-center justify-center h-64 text-slate-400">...</div>
) : !data ? (
<div className="flex items-center justify-center h-64 text-red-400"></div>
) : (
<>
{/* 系统概览 4卡片 */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{/* CPU */}
<div className="rounded-xl border border-slate-800 bg-slate-900 p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-slate-400">CPU</span>
<span className="text-xs text-slate-500">{data.cpu.cores} </span>
</div>
<div className="text-2xl font-bold text-white mb-2">{data.cpu.percent}%</div>
<ProgressBar percent={data.cpu.percent} />
<div className="mt-2 text-xs text-slate-500">
: {data.load.load1} / {data.load.load5} / {data.load.load15}
</div>
</div>
{/* 内存 */}
<div className="rounded-xl border border-slate-800 bg-slate-900 p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-slate-400"></span>
<span className="text-xs text-slate-500">{data.memory.used_gb}G / {data.memory.total_gb}G</span>
</div>
<div className="text-2xl font-bold text-white mb-2">{data.memory.percent}%</div>
<ProgressBar percent={data.memory.percent} />
{data.memory.swap_percent > 0 && (
<div className="mt-2 text-xs text-amber-400">Swap: {data.memory.swap_percent}%</div>
)}
</div>
{/* 硬盘 */}
<div className="rounded-xl border border-slate-800 bg-slate-900 p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-slate-400"></span>
<span className="text-xs text-slate-500">{data.disk.free_gb}G </span>
</div>
<div className="text-2xl font-bold text-white mb-2">{data.disk.percent}%</div>
<ProgressBar percent={data.disk.percent} color="emerald" />
<div className="mt-2 text-xs text-slate-500">
{data.disk.used_gb}G / {data.disk.total_gb}G
</div>
</div>
{/* 运行时间 & 网络 */}
<div className="rounded-xl border border-slate-800 bg-slate-900 p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-slate-400"></span>
<span className="text-xs text-slate-500">Uptime</span>
</div>
<div className="text-2xl font-bold text-white mb-2">
{data.uptime_hours > 24 ? `${Math.floor(data.uptime_hours/24)}` : `${data.uptime_hours}h`}
</div>
<div className="text-xs text-slate-500 space-y-0.5">
<div> : {data.network.bytes_sent_gb} GB</div>
<div> : {data.network.bytes_recv_gb} GB</div>
</div>
</div>
</div>
{/* PM2 进程列表 */}
<div className="rounded-xl border border-slate-800 bg-slate-900 overflow-hidden">
<div className="px-4 py-3 border-b border-slate-800">
<h3 className="font-semibold text-sm text-white">📦 PM2 </h3>
</div>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="text-slate-400 border-b border-slate-800">
<th className="text-left px-4 py-2 font-medium"></th>
<th className="text-center px-4 py-2 font-medium"></th>
<th className="text-right px-4 py-2 font-medium">CPU</th>
<th className="text-right px-4 py-2 font-medium"></th>
<th className="text-right px-4 py-2 font-medium"></th>
<th className="text-right px-4 py-2 font-medium"></th>
<th className="text-right px-4 py-2 font-medium">PID</th>
</tr>
</thead>
<tbody>
{data.pm2.map((p, i) => (
<tr key={i} className="border-b border-slate-800/50 hover:bg-slate-800/30">
<td className="px-4 py-2.5 font-mono text-cyan-400">{p.name}</td>
<td className="px-4 py-2.5 text-center"><StatusBadge status={p.status} /></td>
<td className="px-4 py-2.5 text-right font-mono">{p.cpu}%</td>
<td className="px-4 py-2.5 text-right font-mono">{p.memory_mb} MB</td>
<td className="px-4 py-2.5 text-right font-mono">{p.restarts}</td>
<td className="px-4 py-2.5 text-right text-slate-400">{uptimeStr(p.uptime_ms)}</td>
<td className="px-4 py-2.5 text-right text-slate-500 font-mono">{p.pid || "-"}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* 数据库信息 */}
<div className="rounded-xl border border-slate-800 bg-slate-900 overflow-hidden">
<div className="px-4 py-3 border-b border-slate-800">
<h3 className="font-semibold text-sm text-white">🗄 PostgreSQL</h3>
</div>
<div className="p-4 grid grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<div className="text-xs text-slate-400 mb-1"></div>
<div className="text-lg font-bold text-white">
{data.postgres.db_size_mb > 1024
? `${(data.postgres.db_size_mb / 1024).toFixed(1)} GB`
: `${data.postgres.db_size_mb} MB`}
</div>
</div>
<div>
<div className="text-xs text-slate-400 mb-1">aggTrades </div>
<div className="text-lg font-bold text-cyan-400">{numberFmt(data.postgres.agg_trades_count)}</div>
</div>
<div>
<div className="text-xs text-slate-400 mb-1"></div>
<div className="text-lg font-bold text-white">{numberFmt(data.postgres.rate_snapshots_count)}</div>
</div>
<div>
<div className="text-xs text-slate-400 mb-1"></div>
<div className={`text-lg font-bold ${data.backfill_running ? "text-cyan-400" : "text-slate-500"}`}>
{data.backfill_running ? "🔄 运行中" : "⏸ 停止"}
</div>
</div>
</div>
{data.postgres.symbols && Object.keys(data.postgres.symbols).length > 0 && (
<div className="px-4 pb-4">
<div className="text-xs text-slate-400 mb-2"></div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-2">
{Object.entries(data.postgres.symbols).map(([sym, info]) => (
<div key={sym} className="flex items-center justify-between bg-slate-800/50 rounded-lg px-3 py-2">
<span className="font-mono text-sm text-white">{sym}</span>
<div className="text-xs text-slate-400 text-right">
<div>{bjtStr(info.earliest_ms)} {bjtStr(info.latest_ms)}</div>
<div className="text-cyan-400">{info.span_hours > 24 ? `${(info.span_hours/24).toFixed(1)}` : `${info.span_hours.toFixed(1)} 小时`}</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
</>
)}
</main>
</div>
);
}

View File

@ -7,13 +7,14 @@ import { useAuth } from "@/lib/auth";
import {
LayoutDashboard, Info,
Menu, X, Zap, LogIn, UserPlus,
ChevronLeft, ChevronRight, Activity, LogOut, Crosshair
ChevronLeft, ChevronRight, Activity, LogOut, Crosshair, Monitor
} from "lucide-react";
const navItems = [
{ href: "/", label: "仪表盘", icon: LayoutDashboard },
{ href: "/trades", label: "成交流", icon: Activity },
{ href: "/signals", label: "信号引擎", icon: Crosshair },
{ href: "/server", label: "服务器", icon: Monitor },
{ href: "/about", label: "说明", icon: Info },
];