有一个自己的服务器监控面板是每个开发者的浪漫。不需要 Grafana + Prometheus 那一套重型方案,用 FastAPI + WebSocket + 纯前端就能在半天内搭出一个风格酷炫的实时仪表盘。这篇文章带你从零到部署,完整走一遍。

整体架构

后端使用 FastAPI 的 WebSocket 端点,通过 psutil 库采集 CPU、内存、磁盘和网络数据,按照固定频率推送给所有连接的客户端。前端使用纯 HTML/CSS/JavaScript,用 SVG 绘制仪表盘组件(CPU 环形表盘、内存柱状条、网络流量折线图),无需任何第三方前端框架。

WebSocket 连接的生命周期管理

WebSocket 连接有四个关键阶段:连接建立 → 数据推送 → 心跳维持 → 连接断开。管理好这些阶段是稳定性的基础。

from fastapi import WebSocket, WebSocketDisconnect

class ConnectionManager:
    def __init__(self):
        self.active: list[WebSocket] = []

    async def connect(self, ws: WebSocket):
        await ws.accept()
        self.active.append(ws)

    def disconnect(self, ws: WebSocket):
        self.active.remove(ws)

    async def broadcast(self, data: dict):
        dead = []
        for ws in self.active:
            try:
                await ws.send_json(data)
            except Exception:
                dead.append(ws)
        for ws in dead:  # 清理断开的连接
            self.active.remove(ws)

manager = ConnectionManager()

这里有个细节:broadcast 中不要直接 remove 正在迭代的列表,而是先收集「死亡连接」再统一清理。

psutil 数据采集与采样频率

psutil 是采集系统指标的瑞士军刀,但有些 API 调用方式需要特别注意。比如 psutil.cpu_percent() 如果带 interval 参数会阻塞当前线程

# ❌ 阻塞事件循环
cpu = psutil.cpu_percent(interval=1)  # 阻塞 1 秒!

# ✅ 使用非阻塞版本
cpu = psutil.cpu_percent(interval=None)  # 返回上次调用以来的值

正确做法是在后台用 asyncio.create_task 运行一个采集循环,定期(比如每 2 秒)采集一次,将结果缓存起来;WebSocket 广播时直接读缓存,不再触发新的采集。采样频率的选择取决于你需要的实时性——监控面板一般 1-2 秒已经足够,太频繁反而增加 CPU 开销。

import asyncio
import psutil

latest_stats = {}

async def collect_stats():
    global latest_stats
    # 第一次调用需要 interval 来校准
    psutil.cpu_percent(interval=None)
    while True:
        await asyncio.sleep(2)
        latest_stats = {
            "cpu": psutil.cpu_percent(interval=None),
            "memory": psutil.virtual_memory().percent,
            "disk": psutil.disk_usage("/").percent,
            "net_sent": psutil.net_io_counters().bytes_sent,
            "net_recv": psutil.net_io_counters().bytes_recv,
        }

@app.on_event("startup")
async def startup():
    asyncio.create_task(collect_stats())

前端 Gauge 组件的 SVG 实现

环形仪表盘是监控面板的灵魂。用 SVG 实现一个 CPU 用量环形图只需要一个 <circle> 配合 stroke-dasharraystroke-dashoffset

function updateGauge(svgId, percent) {
    const circle = document.querySelector(`#${svgId} .gauge-fill`);
    const radius = 54;
    const circumference = 2 * Math.PI * radius;
    const offset = circumference - (percent / 100) * circumference;
    circle.style.strokeDasharray = `${circumference}`;
    circle.style.strokeDashoffset = offset;
}

SVG 的 stroke-dashoffset 从完整圆周长递减到 0,配合 transition: stroke-dashoffset 0.6s ease 就能得到丝滑的动画效果。颜色可以按百分比阈值动态切换——低于 60% 用绿色,60-85% 用黄色,85% 以上用红色。

自动重连机制

WebSocket 连接断开是常态,不是异常。前端需要实现指数退避的自动重连:

let retryDelay = 1000;

function connectWS() {
    const ws = new WebSocket("ws://localhost:8000/ws");
    ws.onclose = () => {
        setTimeout(connectWS, retryDelay);
        retryDelay = Math.min(retryDelay * 2, 30000);  // 最大 30 秒
    };
    ws.onopen = () => { retryDelay = 1000; };  // 连上后重置
}

生产环境:Uvicorn + Gunicorn

开发时用 uvicorn main:app --reload 没问题,但生产环境需要 Gunicorn 管理多个 worker 进程。不过 WebSocket 场景下有特殊注意事项——不能使用多个 worker,因为 WebSocket 连接是粘性会话,必须确保同一个连接始终路由到同一个 worker。

# 使用单个 worker + Uvicorn worker class
gunicorn main:app -w 1 -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:8000

如果需要横向扩展,应该用 Nginx 做反向代理,配合 ip_hash 或基于 cookie 的会话保持来确保 WebSocket 路由的一致性。

总结

从零搭一个监控仪表盘,核心代码不到 300 行。FastAPI 的异步能力 + WebSocket 的实时推送 + 纯 SVG 的前端渲染,构成了一个轻量但完整的全栈方案。部署时注意单 worker 的 WebSocket 限制,以及 psutil API 的非阻塞调用方式——这两个坑踩一次就记住了。