From 78a4a3f1aa8f1bd1b48b20f1fa3e2675056d8a3a Mon Sep 17 00:00:00 2001 From: hz4th_coder Date: Mon, 1 Jun 2026 18:01:17 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Docker=20=E7=9B=91=E6=8E=A7=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端 API (routes_docker.py): - /api/docker/containers - 容器列表(状态+资源stats: CPU/内存/网络I/O/磁盘I/O/进程数) - /api/docker/container//stats - 单容器实时资源 - /api/docker/images - 镜像列表(大小/时间/悬空标记) - /api/docker/system - 系统信息(版本/磁盘占用) - /api/docker/container//start|stop|restart - 容器控制 - /api/docker/container//logs - 容器日志 - /api/docker/prune - 资源清理 前端 Docker 监控 Tab: - 统计卡片: 总容器/运行中/已停止/镜像数/磁盘占用/可回收 - 容器列表: 状态灯+CPU/内存/NET IO/磁盘IO/端口/运行时间 - 操作按钮: 启动/停止/重启/查看日志 - 镜像列表: 有效镜像+悬空镜像分区显示 - 清理: 容器/镜像/全部一键清理 --- app.py | 2 + routes_docker.py | 243 +++++++++++++++++++++++++++++++++++++++++++++ templates_full.txt | 196 ++++++++++++++++++++++++++++++++++++ 3 files changed, 441 insertions(+) create mode 100644 routes_docker.py diff --git a/app.py b/app.py index d74c2a8..0b3c591 100644 --- a/app.py +++ b/app.py @@ -13,6 +13,7 @@ from routes_system import register_system_routes from routes_config import register_config_routes from routes_email import register_email_routes from routes_project import register_project_routes +from routes_docker import register_docker_routes from templates import get_html_template @@ -31,6 +32,7 @@ def init_app(): register_config_routes(app) register_email_routes(app) register_project_routes(app) + register_docker_routes(app) # 主页路由 @app.route('/') diff --git a/routes_docker.py b/routes_docker.py new file mode 100644 index 0000000..6a94507 --- /dev/null +++ b/routes_docker.py @@ -0,0 +1,243 @@ +#!/usr/bin/env python3 +""" +Docker 监控 API 路由 +""" + +import subprocess +import json +from flask import jsonify, request +from utils import log_message + + +def _run_docker(cmd, timeout=10): + """执行 docker 命令并返回解析结果""" + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + if result.returncode != 0: + return None, result.stderr.strip() + return result.stdout.strip(), None + except subprocess.TimeoutExpired: + return None, '命令超时' + except FileNotFoundError: + return None, 'Docker 未安装' + except Exception as e: + return None, str(e) + + +def register_docker_routes(app): + """注册 Docker 监控相关路由""" + + @app.route('/api/docker/containers') + def api_docker_containers(): + """获取所有容器列表(含资源使用)""" + # 获取容器列表 + fmt = '{{.ID}}|{{.Names}}|{{.Image}}|{{.State}}|{{.Status}}|{{.Ports}}|{{.CreatedAt}}|{{.RunningFor}}' + out, err = _run_docker(['docker', 'ps', '-a', '--format', fmt, '--no-trunc']) + if out is None: + return jsonify({'error': err, 'containers': []}) + + containers = [] + for line in out.split('\n'): + if not line.strip(): + continue + parts = line.split('|') + if len(parts) < 8: + continue + cid = parts[0][:12] # 缩短ID + containers.append({ + 'id': cid, + 'name': parts[1], + 'image': parts[2], + 'state': parts[3], + 'status': parts[4], + 'ports': parts[5], + 'created': parts[6], + 'runningFor': parts[7] + }) + + # 批量获取运行中容器的stats + running = [c for c in containers if c['state'] == 'running'] + if running: + try: + stats_out, _ = _run_docker([ + 'docker', 'stats', '--no-stream', '--no-trunc', + '--format', '{{.Name}}|{{.CPUPerc}}|{{.MemPerc}}|{{.MemUsage}}|{{.NetIO}}|{{.BlockIO}}|{{.PIDs}}' + ], timeout=15) + if stats_out: + stats_map = {} + for line in stats_out.split('\n'): + if not line.strip(): + continue + sp = line.split('|') + if len(sp) >= 7: + stats_map[sp[0]] = { + 'cpuPercent': sp[1], + 'memPercent': sp[2], + 'memUsage': sp[3], + 'netIO': sp[4], + 'blockIO': sp[5], + 'pids': sp[6] + } + for c in containers: + if c['name'] in stats_map: + c['stats'] = stats_map[c['name']] + except: + pass + + return jsonify({'containers': containers}) + + @app.route('/api/docker/container//stats') + def api_docker_container_stats(container_id): + """获取单个容器实时资源统计""" + out, err = _run_docker([ + 'docker', 'stats', '--no-stream', '--no-trunc', + '--format', '{{.CPUPerc}}|{{.MemPerc}}|{{.MemUsage}}|{{.NetIO}}|{{.BlockIO}}|{{.PIDs}}', + container_id + ], timeout=10) + if out is None: + return jsonify({'error': err}), 500 + if not out: + return jsonify({'error': '容器不存在或未运行'}), 404 + + parts = out.split('|') + return jsonify({ + 'cpuPercent': parts[0] if len(parts) > 0 else '0%', + 'memPercent': parts[1] if len(parts) > 1 else '0%', + 'memUsage': parts[2] if len(parts) > 2 else '', + 'netIO': parts[3] if len(parts) > 3 else '', + 'blockIO': parts[4] if len(parts) > 4 else '', + 'pids': parts[5] if len(parts) > 5 else '0' + }) + + @app.route('/api/docker/images') + def api_docker_images(): + """获取所有镜像列表""" + fmt = '{{.Repository}}|{{.Tag}}|{{.ID}}|{{.Size}}|{{.CreatedAt}}|{{.Containers}}' + out, err = _run_docker(['docker', 'images', '--format', fmt, '--no-trunc']) + if out is None: + return jsonify({'error': err, 'images': []}) + + images = [] + for line in out.split('\n'): + if not line.strip(): + continue + parts = line.split('|') + if len(parts) < 6: + continue + repo = parts[0] if parts[0] != '' else '' + tag = parts[1] if parts[1] != '' else '' + images.append({ + 'repository': repo, + 'tag': tag, + 'id': parts[2][:12], + 'size': parts[3], + 'created': parts[4], + 'containers': parts[5], + 'dangling': not repo and not tag + }) + + return jsonify({'images': images}) + + @app.route('/api/docker/system') + def api_docker_system(): + """获取 Docker 系统信息""" + # 版本信息 + ver_out, _ = _run_docker(['docker', 'version', '--format', '{{.Server.Version}}|{{.Server.Os}}|{{.Server.Arch}}'], timeout=5) + + # 磁盘使用 + df_out, _ = _run_docker(['docker', 'system', 'df', '--format', '{{.Type}}|{{.TotalCount}}|{{.Active}}|{{.Size}}|{{.Reclaimable}}'], timeout=10) + + system_info = { + 'version': '', + 'os': '', + 'arch': '', + 'diskUsage': [] + } + + if ver_out: + ver_parts = ver_out.split('|') + if len(ver_parts) >= 3: + system_info.update({ + 'version': ver_parts[0], + 'os': ver_parts[1], + 'arch': ver_parts[2] + }) + + if df_out: + for line in df_out.split('\n'): + if not line.strip(): + continue + parts = line.split('|') + if len(parts) >= 5: + system_info['diskUsage'].append({ + 'type': parts[0], + 'total': parts[1], + 'active': parts[2], + 'size': parts[3], + 'reclaimable': parts[4] + }) + + return jsonify(system_info) + + @app.route('/api/docker/container//logs') + def api_docker_container_logs(container_id): + """获取容器日志(最近200行)""" + lines = int(request.args.get('lines', 200)) + lines = max(10, min(1000, lines)) + + out, err = _run_docker(['docker', 'logs', '--tail', str(lines), container_id], timeout=10) + if out is None: + return jsonify({'error': err}), 500 + + return jsonify({'logs': out, 'lines': lines}) + + @app.route('/api/docker/container//start', methods=['POST']) + def api_docker_container_start(container_id): + """启动容器""" + out, err = _run_docker(['docker', 'start', container_id]) + if out is None and err: + return jsonify({'error': err}), 500 + log_message(f"Docker 容器启动: {container_id}") + return jsonify({'message': f'容器 {container_id} 已启动'}) + + @app.route('/api/docker/container//stop', methods=['POST']) + def api_docker_container_stop(container_id): + """停止容器""" + out, err = _run_docker(['docker', 'stop', container_id]) + if out is None and err: + return jsonify({'error': err}), 500 + log_message(f"Docker 容器停止: {container_id}") + return jsonify({'message': f'容器 {container_id} 已停止'}) + + @app.route('/api/docker/container//restart', methods=['POST']) + def api_docker_container_restart(container_id): + """重启容器""" + out, err = _run_docker(['docker', 'restart', container_id]) + if out is None and err: + return jsonify({'error': err}), 500 + log_message(f"Docker 容器重启: {container_id}") + return jsonify({'message': f'容器 {container_id} 已重启'}) + + @app.route('/api/docker/prune', methods=['POST']) + def api_docker_prune(): + """清理Docker资源""" + target = request.json.get('target', 'all') if request.json else 'all' + results = {} + + # 清理停止的容器 + if target in ('all', 'containers'): + out, _ = _run_docker(['docker', 'container', 'prune', '-f'], timeout=30) + results['containers'] = out or '已清理' + + # 清理悬空镜像 + if target in ('all', 'images'): + out, _ = _run_docker(['docker', 'image', 'prune', '-f'], timeout=60) + results['images'] = out or '已清理' + + # 清理构建缓存 + if target in ('all', 'build'): + out, _ = _run_docker(['docker', 'builder', 'prune', '-f'], timeout=60) + results['build'] = out or '已清理' + + log_message(f"Docker 资源清理完成: {target}") + return jsonify({'message': '清理完成', 'results': results}) diff --git a/templates_full.txt b/templates_full.txt index e5b4aad..bead823 100644 --- a/templates_full.txt +++ b/templates_full.txt @@ -119,6 +119,9 @@ + @@ -485,6 +488,72 @@

加载中...

+ + + @@ -855,6 +924,9 @@ document.getElementById('projectsTab').classList.toggle('hidden', tab !== 'projects'); document.getElementById('systemTab').classList.toggle('hidden', tab !== 'system'); document.getElementById('cronTab').classList.toggle('hidden', tab !== 'cron'); + document.getElementById('dockerTab').classList.toggle('hidden', tab !== 'docker'); + + if (tab === 'docker') loadDockerData(); // 切换Tab时关闭实时监控 if (tab !== 'system' && realtimeTimer) { @@ -2665,6 +2737,130 @@ setInterval(() => { fetch('/api/projects').then(() => updateConnectionStatus(true)).catch(() => updateConnectionStatus(false)); }, 10000); + + // ==================== Docker 监控 ==================== + + async function loadDockerData() { + try { + const [containersRes, imagesRes, systemRes] = await Promise.all([ + fetch('/api/docker/containers'), + fetch('/api/docker/images'), + fetch('/api/docker/system') + ]); + const containers = (await containersRes.json()).containers || []; + const images = (await imagesRes.json()).images || []; + const system = await systemRes.json(); + renderDockerStats(containers, images, system); + renderDockerContainers(containers); + renderDockerImages(images); + document.getElementById('dockerVersion').textContent = system.version || '-'; + } catch (e) { + console.error('Docker数据加载失败:', e); + } + } + + function renderDockerStats(containers, images, system) { + const total = containers.length; + const running = containers.filter(c => c.state === 'running').length; + const stopped = total - running; + const imgTotal = images.length; + document.getElementById('dockerContainersTotal').textContent = total; + document.getElementById('dockerContainersRunning').textContent = running; + document.getElementById('dockerContainersStopped').textContent = stopped; + document.getElementById('dockerImagesTotal').textContent = imgTotal; + document.getElementById('dockerContainerCount').textContent = `(${running}运行 / ${total}总计)`; + document.getElementById('dockerImageCount').textContent = `(${imgTotal}个)`; + const imagesDisk = system.diskUsage?.find(d => d.type === 'Images'); + if (imagesDisk) { + document.getElementById('dockerDiskSize').textContent = imagesDisk.size; + document.getElementById('dockerReclaimable').textContent = imagesDisk.reclaimable; + } + } + + function renderDockerContainers(containers) { + const list = document.getElementById('dockerContainersList'); + if (containers.length === 0) { + list.innerHTML = '
暂无容器
'; + return; + } + list.innerHTML = containers.map(c => { + const isRunning = c.state === 'running'; + const s = c.stats || {}; + const portsDisplay = c.ports ? c.ports.split(',').slice(0, 3).join(', ') : ''; + return `
+
+
+
+ ${c.name} + ${isRunning ? '运行中' : '已停止'} +
+
+ ${isRunning ? `` : ``} + +
+
+
+
镜像: ${c.image}
+ ${isRunning ? `
CPU: ${s.cpuPercent || '-'}
内存: ${s.memUsage || '-'} (${s.memPercent || '-'})
进程: ${s.pids || '-'}
网络I/O: ${s.netIO || '-'}
磁盘I/O: ${s.blockIO || '-'}
` : ''} + ${portsDisplay ? `
端口: ${portsDisplay}
` : ''} +
运行时间: ${c.runningFor || '-'}
+
+
`; + }).join(''); + } + + function renderDockerImages(images) { + const list = document.getElementById('dockerImagesList'); + if (images.length === 0) { + list.innerHTML = '
暂无镜像
'; + return; + } + const valid = images.filter(i => !i.dangling); + const dangling = images.filter(i => i.dangling); + let html = ''; + if (valid.length > 0) { + html += valid.map(i => `
${i.repository}:${i.tag || 'latest'}${i.size}
${(i.created || '').substring(0, 19)}
`).join(''); + } + if (dangling.length > 0) { + html += `
悬空镜像 (${dangling.length}个)
`; + html += dangling.map(i => `
<none>:<none>${i.size}${i.id}
${(i.created || '').substring(0, 19)}
`).join(''); + } + list.innerHTML = html; + } + + async function dockerControl(containerId, action) { + const names = { start: '启动', stop: '停止', restart: '重启' }; + if (!confirm(`确定要${names[action]}容器吗?`)) return; + try { + const res = await fetch(`/api/docker/container/${containerId}/${action}`, { method: 'POST' }); + const data = await res.json(); + if (data.error) { alert(`操作失败: ${data.error}`); return; } + setTimeout(loadDockerData, 1000); + } catch (e) { alert('操作失败: ' + e.message); } + } + + async function showDockerLogs(containerId, name) { + const m = document.getElementById('logModal'); + document.getElementById('logTitle').textContent = `${name} - 日志`; + document.getElementById('logContent').textContent = '加载中...'; + m.classList.add('flex'); m.classList.remove('hidden'); + try { + const res = await fetch(`/api/docker/container/${containerId}/logs?lines=200`); + const data = await res.json(); + document.getElementById('logContent').textContent = data.logs || data.error || '无日志'; + } catch (e) { document.getElementById('logContent').textContent = '加载失败: ' + e.message; } + } + + async function dockerPrune(target) { + const names = { all: '所有资源(停止的容器+悬空镜像+构建缓存)', containers: '停止的容器', images: '悬空镜像', build: '构建缓存' }; + if (!confirm(`确定清理${names[target]}?不可撤销。`)) return; + try { + const res = await fetch('/api/docker/prune', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({target}) }); + alert((await res.json()).message || '完成'); + setTimeout(loadDockerData, 1000); + } catch (e) { alert('失败: ' + e.message); } + } + \ No newline at end of file