用 Python Tornado + Vue3 + ECharts 搭建 Docker 实时监控 WebSocket 仪表盘

摘要

本文介绍了如何使用 Python Tornado + WebSocket + Vue3 + Element Plus + ECharts 搭建一个 Docker 实时监控单页面应用。通过 Tornado 后端收集 docker stats 数据并通过 WebSocket 推送给前端,前端使用 Vue3 和 ECharts 渲染 CPU、内存、网络流量 等实时图表,并支持 容器选择 和 鼠标悬停 tooltip 查看详细数值。


一、项目目录

text 复制代码
docker-monitor/
├── templates/
│   └── index.html      # 前端单页面 HTML
└── websocket_server.py # 后端 Tornado WebSocket 服务

说明:

  • templates/:存放 HTML 页面
  • websocket_server.py:负责 WebSocket 服务、收集 Docker stats 数据

二、后端:websocket_server.py

python 复制代码
import json
import asyncio
import pytz
from datetime import datetime
from subprocess import Popen, PIPE, STDOUT
from tornado import websocket, web, ioloop

# ----------------------
# 工具函数:执行命令
# ----------------------
def run_cmd(cmd):
    p = Popen(cmd, shell=True, stdout=PIPE, stderr=STDOUT)
    out, _ = p.communicate()
    return out.decode()

# ----------------------
# 全局存储 WebSocket 客户端
# ----------------------
clients = set()

# ----------------------
# 收集 Docker stats
# ----------------------
def collect_stats(container=None):
    fmt = '{{.BlockIO}}#{{.CPUPerc}}#{{.Container}}#{{.ID}}#{{.MemPerc}}#{{.MemUsage}}#{{.Name}}#{{.NetIO}}#{{.PIDs}}'
    cmd = f'docker stats {container or ""} --no-stream --format "{fmt}"'
    out = run_cmd(cmd)

    now = datetime.now(pytz.timezone('Asia/Shanghai'))
    result = []

    for line in out.splitlines():
        ls = line.split("#")
        if len(ls) < 9:
            continue
        result.append({
            "name": ls[6],
            "id": ls[3],
            "time": now.strftime('%H:%M:%S'),
            "cpu": float(ls[1].replace("%", "")),
            "mem": float(ls[4].replace("%", "")),
            "net": ls[7],
            "block": ls[0],
            "pids": int(ls[8])
        })

    return result

# ----------------------
# 循环推送数据给所有客户端
# ----------------------
async def push_loop():
    while True:
        data = collect_stats()
        if clients:
            msg = json.dumps({"type": "stats", "data": data})
            for c in clients:
                c.write_message(msg)
        await asyncio.sleep(2)  # 每 2 秒推送一次

# ----------------------
# WebSocket Handler
# ----------------------
class WSHandler(websocket.WebSocketHandler):
    def check_origin(self, origin):
        return True  # 允许跨域

    def open(self):
        clients.add(self)
        print("客户端连接")

    def on_close(self):
        clients.discard(self)
        print("客户端断开")

    def on_message(self, message):
        msg = json.loads(message)
        if msg.get("type") == "single":
            data = collect_stats(msg["container"])
            self.write_message(json.dumps({"type": "single", "data": data}))

# ----------------------
# HTTP 页面 Handler
# ----------------------
class IndexHandler(web.RequestHandler):
    def get(self):
        self.render("index.html")

# ----------------------
# 启动 Tornado 应用
# ----------------------
def make_app():
    return web.Application(
        [
            (r"/", IndexHandler),
            (r"/ws", WSHandler),
        ],
        template_path="templates",
        debug=True
    )

if __name__ == "__main__":
    app = make_app()
    app.listen(9001)
    ioloop.IOLoop.current().spawn_callback(push_loop)
    print("Tornado 服务已启动,访问 http://localhost:9001")
    ioloop.IOLoop.current().start()

三、前端:templates/index.html

html 复制代码
<!DOCTYPE html>
<html lang="zh">
<head>
  <meta charset="UTF-8">
  <title>Docker 实时监控</title>

  <!-- Element Plus -->
  <link rel="stylesheet" href="https://unpkg.com/element-plus/dist/index.css">
  <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
  <script src="https://unpkg.com/element-plus"></script>

  <!-- ECharts -->
  <script src="https://cdn.jsdelivr.net/npm/echarts/dist/echarts.min.js"></script>

  <style>
    body {
      background: #f5f7fa;
      padding: 20px;
    }
    .chart {
      height: 260px;
    }
  </style>
</head>

<body>
<div id="app">
  <!-- 顶部选择 -->
  <el-card>
    <el-row :gutter="20">
      <el-col :span="6">
        <el-select v-model="current" placeholder="选择容器" @change="switchContainer" style="width:100%">
          <el-option
            v-for="c in containers"
            :key="c"
            :label="c"
            :value="c"
          />
        </el-select>
      </el-col>
    </el-row>
  </el-card>

  <!-- CPU / MEM -->
  <el-row :gutter="20" style="margin-top:20px">
    <el-col :span="12">
      <el-card>
        <h3>CPU 使用率 (%)</h3>
        <div id="cpu" class="chart"></div>
      </el-card>
    </el-col>

    <el-col :span="12">
      <el-card>
        <h3>内存 使用率 (%)</h3>
        <div id="mem" class="chart"></div>
      </el-card>
    </el-col>
  </el-row>

  <!-- 网络 -->
  <el-row :gutter="20" style="margin-top:20px">
    <el-col :span="24">
      <el-card>
        <h3>网络流量 (MB)</h3>
        <div id="net" class="chart"></div>
      </el-card>
    </el-col>
  </el-row>
</div>

<script>
const { createApp, ref, onMounted } = Vue

createApp({
  setup() {
    const containers = ref([])
    const current = ref(null)
    let ws = null

    const times = []
    const cpuData = []
    const memData = []
    const netRx = []
    const netTx = []

    let cpuChart, memChart, netChart

    function parseNet(v) {
      if (!v) return 0
      let n = parseFloat(v)
      if (v.includes('KB')) return n / 1024
      if (v.includes('GB')) return n * 1024
      return n
    }

    /* ========== 初始化图表(重点:tooltip 就在这里) ========== */
    function initCharts() {
      cpuChart = echarts.init(document.getElementById('cpu'))
      memChart = echarts.init(document.getElementById('mem'))
      netChart = echarts.init(document.getElementById('net'))

      cpuChart.setOption({
        tooltip: {
          trigger: 'axis',
          axisPointer: { type: 'cross' },
          formatter: p => `时间:${p[0].axisValue}<br/>CPU:${p[0].data.toFixed(2)} %`
        },
        xAxis: { type: 'category' },
        yAxis: { max: 100 },
        series: [{ type: 'line', smooth: true, symbol: 'none' }]
      })

      memChart.setOption({
        tooltip: {
          trigger: 'axis',
          axisPointer: { type: 'cross' },
          formatter: p => `时间:${p[0].axisValue}<br/>内存:${p[0].data.toFixed(2)} %`
        },
        xAxis: { type: 'category' },
        yAxis: { max: 100 },
        series: [{ type: 'line', smooth: true, symbol: 'none' }]
      })

      netChart.setOption({
        tooltip: {
          trigger: 'axis',
          axisPointer: { type: 'cross' },
          formatter: params => {
            let html = `时间:${params[0].axisValue}<br/>`
            params.forEach(p => {
              html += `${p.seriesName}:${p.data.toFixed(2)} MB<br/>`
            })
            return html
          }
        },
        xAxis: { type: 'category' },
        yAxis: { name: 'MB' },
        series: [
          { name: 'RX', type: 'line', smooth: true, symbol: 'none' },
          { name: 'TX', type: 'line', smooth: true, symbol: 'none' }
        ]
      })
    }

    function resetData() {
      times.length = cpuData.length = memData.length = netRx.length = netTx.length = 0
    }

    function updateCharts(item) {
      times.push(item.time)
      cpuData.push(item.cpu)
      memData.push(item.mem)

      const [rx, tx] = item.net.split('/')
      netRx.push(parseNet(rx))
      netTx.push(parseNet(tx))

      if (times.length > 20) {
        times.shift()
        cpuData.shift()
        memData.shift()
        netRx.shift()
        netTx.shift()
      }

      cpuChart.setOption({ xAxis:{data:times}, series:[{data:cpuData}] })
      memChart.setOption({ xAxis:{data:times}, series:[{data:memData}] })
      netChart.setOption({
        xAxis:{data:times},
        series:[
          { data: netRx },
          { data: netTx }
        ]
      })
    }

    function switchContainer() {
      resetData()
      ws.send(JSON.stringify({ type:'single', container: current.value }))
    }

    function connectWS() {
      ws = new WebSocket(
        (location.protocol === 'https:' ? 'wss' : 'ws')
        + '://' + location.host + '/ws'
      )

      ws.onmessage = e => {
        const msg = JSON.parse(e.data)
        if (!msg.data) return

        msg.data.forEach(item => {
          if (!containers.value.includes(item.name)) {
            containers.value.push(item.name)
            if (!current.value) {
              current.value = item.name
              ws.send(JSON.stringify({ type:'single', container: current.value }))
            }
          }

          if (item.name === current.value) {
            updateCharts(item)
          }
        })
      }
    }

    onMounted(() => {
      initCharts()
      connectWS()
    })

    return { containers, current, switchContainer }
  }
}).use(ElementPlus).mount('#app')
</script>
</body>
</html>

四、运行步骤

  1. 安装依赖
bash 复制代码
pip install tornado pytz
  1. 确保 Docker 可执行
bash 复制代码
docker ps
  1. 启动 Tornado 服务
bash 复制代码
python websocket_server.py
  1. 浏览器访问

    http://localhost:9001

  2. 查看实时监控图表、容器选择下拉框

相关推荐
ValhallaCoder2 小时前
Day49-图论
数据结构·python·算法·图论
weixin_462446232 小时前
使用 Python + FFmpeg 将 MP4 视频与 SRT 字幕无损合并(支持中文)
python·ffmpeg·音视频
iCan_qi2 小时前
【游戏开发】一键式图集合并图集分割工具
python·游戏·工具·贴图
小二·2 小时前
Python Web 开发进阶实战:生物启发计算 —— 在 Flask + Vue 中实现蚁群优化与人工免疫系统
前端·python·flask
名为沙丁鱼的猫7292 小时前
【万文超详A2A 协议】从个体赋能到群体智能,智能体间的“TCP/IP协议“
人工智能·python·深度学习·机器学习·自然语言处理·nlp
w***76552 小时前
PHP vs Python:如何选择?
开发语言·python·php
UR的出不克2 小时前
基于机器学习的足球比赛预测系统 - 完整开发教程
人工智能·爬虫·python·深度学习·机器学习
veminhe2 小时前
python与阿里云百炼(一)openai第一个例子
python
Remember_9932 小时前
Java 入门指南:从零开始掌握核心语法与编程思想
java·c语言·开发语言·ide·python·leetcode·eclipse