Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 761edef6fe |
32
README.md
32
README.md
@@ -1,19 +1,43 @@
|
||||
# 项目服务管理面板
|
||||
# 项目服务管理面板 v3.0.0
|
||||
|
||||
统一管理所有 Web 服务项目,提供状态检测、启动/停止控制、日志查看等功能。
|
||||
统一管理所有 Web 服务项目,提供状态检测、启动/停止控制、日志查看、Docker 监控等功能。
|
||||
|
||||
## 端口
|
||||
|
||||
- **服务端口**: 19013
|
||||
- **访问地址**: http://localhost:19013
|
||||
- **服务端口**: 16022
|
||||
- **访问地址**: http://localhost:16022
|
||||
|
||||
## 功能
|
||||
|
||||
### 项目管理
|
||||
- 项目列表展示(状态、端口、版本)
|
||||
- 服务状态健康检测
|
||||
- 启动/停止/重启控制
|
||||
- 日志查看(实时滚动)
|
||||
- Git 仓库链接
|
||||
- 自定义链接入口
|
||||
- 新增外部服务
|
||||
|
||||
### Docker 监控(v3.0.0 新增)
|
||||
- **容器监控**:CPU、内存、网络 I/O、磁盘 I/O 使用情况
|
||||
- **容器控制**:启动、停止、重启容器
|
||||
- **容器日志**:实时查看容器日志
|
||||
- **镜像管理**:查看镜像列表、大小、悬空镜像
|
||||
- **系统信息**:Docker 版本、磁盘占用、运行状态统计
|
||||
- **资源清理**:一键清理未使用的容器、镜像、卷
|
||||
|
||||
### 系统资源监控
|
||||
- CPU、内存、磁盘使用率
|
||||
- 网络流量和实时网速
|
||||
- CPU 温度监控
|
||||
- 进程列表
|
||||
- 阈值告警(弹窗 + 桌面通知 + 邮件)
|
||||
|
||||
### Cron 管理
|
||||
- Cron 任务增删改查
|
||||
- 历史版本回退
|
||||
- 分类管理
|
||||
- 从系统同步
|
||||
|
||||
## 启动方式 ⭐
|
||||
|
||||
|
||||
752
app.py
752
app.py
@@ -1,7 +1,8 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
项目服务管理面板 v2.2.0
|
||||
端口: 19013
|
||||
项目服务管理面板 v3.0.0
|
||||
端口: 16022
|
||||
新增: Docker 监控功能(容器、镜像、资源使用)
|
||||
新增: Web服务卡片添加自定义链接入口(+按钮)
|
||||
新增: 自定义新增外部web服务功能(新增服务按钮)
|
||||
"""
|
||||
@@ -929,6 +930,380 @@ def get_next_run_time(expression):
|
||||
return '无法计算'
|
||||
|
||||
|
||||
# ==================== Docker 监控 API ====================
|
||||
|
||||
def check_docker_available():
|
||||
"""检查 Docker 是否可用"""
|
||||
try:
|
||||
result = subprocess.run(['docker', '--version'], capture_output=True, text=True, timeout=5)
|
||||
return result.returncode == 0
|
||||
except:
|
||||
return False
|
||||
|
||||
|
||||
@app.route('/api/docker/containers')
|
||||
def api_docker_containers():
|
||||
"""获取 Docker 容器列表"""
|
||||
if not check_docker_available():
|
||||
return jsonify({'error': 'Docker 未安装或不可用', 'available': False})
|
||||
|
||||
try:
|
||||
# 获取所有容器(包括停止的)
|
||||
result = subprocess.run(
|
||||
['docker', 'ps', '-a', '--format',
|
||||
'{{.ID}}|{{.Names}}|{{.Status}}|{{.State}}|{{.Image}}|{{.Ports}}'],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
|
||||
containers = []
|
||||
for line in result.stdout.strip().split('\n'):
|
||||
if not line:
|
||||
continue
|
||||
parts = line.split('|')
|
||||
if len(parts) >= 6:
|
||||
container_id, names, status, state, image, ports = parts[:6]
|
||||
created = status.split()[0] if status else '' # 从状态中提取时间
|
||||
|
||||
# 解析状态
|
||||
is_running = state == 'running'
|
||||
|
||||
# 解析端口映射
|
||||
port_list = []
|
||||
if ports:
|
||||
for port in ports.split(', '):
|
||||
port_list.append(port.strip())
|
||||
|
||||
containers.append({
|
||||
'id': container_id,
|
||||
'names': names,
|
||||
'status': status,
|
||||
'state': state,
|
||||
'image': image,
|
||||
'ports': port_list,
|
||||
'created': created,
|
||||
'running': is_running
|
||||
})
|
||||
|
||||
# 统计信息
|
||||
running_count = sum(1 for c in containers if c['running'])
|
||||
stopped_count = len(containers) - running_count
|
||||
|
||||
return jsonify({
|
||||
'available': True,
|
||||
'containers': containers,
|
||||
'count': len(containers),
|
||||
'running_count': running_count,
|
||||
'stopped_count': stopped_count
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e), 'available': False})
|
||||
|
||||
|
||||
@app.route('/api/docker/container/<container_id>/stats')
|
||||
def api_docker_container_stats(container_id):
|
||||
"""获取单个容器的资源使用情况"""
|
||||
if not check_docker_available():
|
||||
return jsonify({'error': 'Docker 未安装或不可用', 'available': False})
|
||||
|
||||
try:
|
||||
# 使用 docker stats 获取资源使用情况(no-stream 模式)
|
||||
result = subprocess.run(
|
||||
['docker', 'stats', container_id, '--no-stream', '--format',
|
||||
'{{.Container}}|{{.Name}}|{{.CPUPerc}}|{{.MemUsage}}|{{.MemPerc}}|{{.NetIO}}|{{.BlockIO}}|{{.PIDs}}'],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
|
||||
if not result.stdout.strip():
|
||||
# 容器可能未运行
|
||||
return jsonify({
|
||||
'available': True,
|
||||
'container_id': container_id,
|
||||
'running': False,
|
||||
'message': '容器未运行,无法获取资源信息'
|
||||
})
|
||||
|
||||
line = result.stdout.strip()
|
||||
parts = line.split('|')
|
||||
if len(parts) >= 8:
|
||||
container, name, cpu_perc, mem_usage, mem_perc, net_io, block_io, pids = parts
|
||||
|
||||
# 解析 CPU 百分比
|
||||
cpu_percent = float(cpu_perc.replace('%', '')) if cpu_perc else 0
|
||||
|
||||
# 解析内存使用
|
||||
mem_parts = mem_usage.split('/') if mem_usage else ['0', '0']
|
||||
mem_used = mem_parts[0].strip() if len(mem_parts) > 0 else '0'
|
||||
mem_total = mem_parts[1].strip() if len(mem_parts) > 1 else '0'
|
||||
mem_percent = float(mem_perc.replace('%', '')) if mem_perc else 0
|
||||
|
||||
# 解析网络 I/O
|
||||
net_parts = net_io.split('/') if net_io else ['0', '0']
|
||||
net_sent = net_parts[0].strip() if len(net_parts) > 0 else '0'
|
||||
net_recv = net_parts[1].strip() if len(net_parts) > 1 else '0'
|
||||
|
||||
# 解析磁盘 I/O
|
||||
block_parts = block_io.split('/') if block_io else ['0', '0']
|
||||
block_read = block_parts[0].strip() if len(block_parts) > 0 else '0'
|
||||
block_write = block_parts[1].strip() if len(block_parts) > 1 else '0'
|
||||
|
||||
return jsonify({
|
||||
'available': True,
|
||||
'container_id': container,
|
||||
'name': name,
|
||||
'running': True,
|
||||
'cpu_percent': cpu_percent,
|
||||
'memory': {
|
||||
'used': mem_used,
|
||||
'total': mem_total,
|
||||
'percent': mem_percent
|
||||
},
|
||||
'network': {
|
||||
'sent': net_sent,
|
||||
'recv': net_recv
|
||||
},
|
||||
'block': {
|
||||
'read': block_read,
|
||||
'write': block_write
|
||||
},
|
||||
'pids': int(pids) if pids else 0
|
||||
})
|
||||
|
||||
return jsonify({'error': '解析失败', 'available': False})
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e), 'available': False})
|
||||
|
||||
|
||||
@app.route('/api/docker/images')
|
||||
def api_docker_images():
|
||||
"""获取 Docker 镜像列表"""
|
||||
if not check_docker_available():
|
||||
return jsonify({'error': 'Docker 未安装或不可用', 'available': False})
|
||||
|
||||
try:
|
||||
# 获取所有镜像
|
||||
result = subprocess.run(
|
||||
['docker', 'images', '--format',
|
||||
'{{.ID}}|{{.Repository}}|{{.Tag}}|{{.Size}}|{{.CreatedSince}}|{{.CreatedAt}}'],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
|
||||
images = []
|
||||
for line in result.stdout.strip().split('\n'):
|
||||
if not line:
|
||||
continue
|
||||
parts = line.split('|')
|
||||
if len(parts) >= 6:
|
||||
image_id, repository, tag, size, created_since, created_at = parts
|
||||
|
||||
# <none> 表示悬空镜像
|
||||
is_dangling = repository == '<none>' or tag == '<none>'
|
||||
|
||||
images.append({
|
||||
'id': image_id,
|
||||
'repository': repository,
|
||||
'tag': tag,
|
||||
'size': size,
|
||||
'created_since': created_since,
|
||||
'created_at': created_at,
|
||||
'dangling': is_dangling,
|
||||
'full_name': f"{repository}:{tag}" if not is_dangling else f"<none>:<none> ({image_id})"
|
||||
})
|
||||
|
||||
# 统计信息
|
||||
total_size = 0
|
||||
dangling_count = sum(1 for i in images if i['dangling'])
|
||||
|
||||
# 解析总大小(估算)
|
||||
for img in images:
|
||||
size_str = img['size']
|
||||
if 'GB' in size_str:
|
||||
total_size += float(size_str.replace('GB', '').strip())
|
||||
elif 'MB' in size_str:
|
||||
total_size += float(size_str.replace('MB', '').strip()) / 1024
|
||||
|
||||
return jsonify({
|
||||
'available': True,
|
||||
'images': images,
|
||||
'count': len(images),
|
||||
'dangling_count': dangling_count,
|
||||
'total_size_gb': round(total_size, 2)
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e), 'available': False})
|
||||
|
||||
|
||||
@app.route('/api/docker/system')
|
||||
def api_docker_system():
|
||||
"""获取 Docker 系统信息"""
|
||||
if not check_docker_available():
|
||||
return jsonify({'error': 'Docker 未安装或不可用', 'available': False})
|
||||
|
||||
try:
|
||||
# 获取系统信息
|
||||
result = subprocess.run(['docker', 'system', 'info', '--format', 'json'],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
|
||||
info = {}
|
||||
if result.stdout.strip():
|
||||
try:
|
||||
info = json.loads(result.stdout)
|
||||
except:
|
||||
# 如果 JSON 解析失败,尝试解析文本格式
|
||||
info = {'raw': result.stdout}
|
||||
|
||||
# 获取磁盘使用情况
|
||||
df_result = subprocess.run(['docker', 'system', 'df'],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
|
||||
df_info = {
|
||||
'images': {'count': 0, 'size': '0'},
|
||||
'containers': {'count': 0, 'size': '0'},
|
||||
'volumes': {'count': 0, 'size': '0'},
|
||||
'build_cache': {'count': 0, 'size': '0'}
|
||||
}
|
||||
|
||||
# 解析 docker system df 输出
|
||||
lines = df_result.stdout.strip().split('\n')
|
||||
for line in lines[1:]: # 跳过标题行
|
||||
parts = line.split()
|
||||
if len(parts) >= 3:
|
||||
type_name = parts[0].lower()
|
||||
count = parts[1]
|
||||
size = parts[2] if len(parts) > 2 else '0'
|
||||
|
||||
if 'image' in type_name:
|
||||
df_info['images'] = {'count': count, 'size': size}
|
||||
elif 'container' in type_name:
|
||||
df_info['containers'] = {'count': count, 'size': size}
|
||||
elif 'volume' in type_name or 'local' in type_name:
|
||||
df_info['volumes'] = {'count': count, 'size': size}
|
||||
elif 'build' in type_name or 'cache' in type_name:
|
||||
df_info['build_cache'] = {'count': count, 'size': size}
|
||||
|
||||
# 获取版本信息
|
||||
version_result = subprocess.run(['docker', '--version'],
|
||||
capture_output=True, text=True, timeout=5)
|
||||
docker_version = version_result.stdout.strip()
|
||||
|
||||
return jsonify({
|
||||
'available': True,
|
||||
'version': docker_version,
|
||||
'info': info,
|
||||
'disk_usage': df_info,
|
||||
'containers_running': info.get('ContainersRunning', 0),
|
||||
'containers_total': info.get('Containers', 0),
|
||||
'images_total': info.get('Images', 0)
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e), 'available': False})
|
||||
|
||||
|
||||
@app.route('/api/docker/container/<container_id>/start', methods=['POST'])
|
||||
def api_docker_container_start(container_id):
|
||||
"""启动 Docker 容器"""
|
||||
if not check_docker_available():
|
||||
return jsonify({'error': 'Docker 未安装或不可用'}), 400
|
||||
|
||||
try:
|
||||
result = subprocess.run(['docker', 'start', container_id],
|
||||
capture_output=True, text=True, timeout=30)
|
||||
|
||||
if result.returncode == 0:
|
||||
log_message(f"启动 Docker 容器: {container_id}")
|
||||
return jsonify({'message': '容器已启动', 'container_id': container_id})
|
||||
else:
|
||||
return jsonify({'error': result.stderr or '启动失败'}), 400
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/docker/container/<container_id>/stop', methods=['POST'])
|
||||
def api_docker_container_stop(container_id):
|
||||
"""停止 Docker 容器"""
|
||||
if not check_docker_available():
|
||||
return jsonify({'error': 'Docker 未安装或不可用'}), 400
|
||||
|
||||
try:
|
||||
result = subprocess.run(['docker', 'stop', container_id],
|
||||
capture_output=True, text=True, timeout=30)
|
||||
|
||||
if result.returncode == 0:
|
||||
log_message(f"停止 Docker 容器: {container_id}")
|
||||
return jsonify({'message': '容器已停止', 'container_id': container_id})
|
||||
else:
|
||||
return jsonify({'error': result.stderr or '停止失败'}), 400
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/docker/container/<container_id>/restart', methods=['POST'])
|
||||
def api_docker_container_restart(container_id):
|
||||
"""重启 Docker 容器"""
|
||||
if not check_docker_available():
|
||||
return jsonify({'error': 'Docker 未安装或不可用'}), 400
|
||||
|
||||
try:
|
||||
result = subprocess.run(['docker', 'restart', container_id],
|
||||
capture_output=True, text=True, timeout=30)
|
||||
|
||||
if result.returncode == 0:
|
||||
log_message(f"重启 Docker 容器: {container_id}")
|
||||
return jsonify({'message': '容器已重启', 'container_id': container_id})
|
||||
else:
|
||||
return jsonify({'error': result.stderr or '重启失败'}), 400
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/docker/container/<container_id>/logs')
|
||||
def api_docker_container_logs(container_id):
|
||||
"""获取容器日志"""
|
||||
if not check_docker_available():
|
||||
return jsonify({'error': 'Docker 未安装或不可用'}), 400
|
||||
|
||||
try:
|
||||
# 获取最近 500 行日志
|
||||
result = subprocess.run(['docker', 'logs', container_id, '--tail', '500'],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
|
||||
logs = result.stdout + result.stderr
|
||||
return jsonify({'logs': logs, 'container_id': container_id})
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/docker/prune', methods=['POST'])
|
||||
def api_docker_prune():
|
||||
"""清理 Docker 未使用资源"""
|
||||
if not check_docker_available():
|
||||
return jsonify({'error': 'Docker 未安装或不可用'}), 400
|
||||
|
||||
try:
|
||||
what = request.json.get('what', 'all') if request.json else 'all'
|
||||
|
||||
if what == 'images':
|
||||
# 清理悬空镜像
|
||||
result = subprocess.run(['docker', 'image', 'prune', '-f'],
|
||||
capture_output=True, text=True, timeout=60)
|
||||
elif what == 'containers':
|
||||
# 清理停止的容器
|
||||
result = subprocess.run(['docker', 'container', 'prune', '-f'],
|
||||
capture_output=True, text=True, timeout=60)
|
||||
elif what == 'volumes':
|
||||
# 清理未使用的卷
|
||||
result = subprocess.run(['docker', 'volume', 'prune', '-f'],
|
||||
capture_output=True, text=True, timeout=60)
|
||||
else:
|
||||
# 清理所有
|
||||
result = subprocess.run(['docker', 'system', 'prune', '-f'],
|
||||
capture_output=True, text=True, timeout=60)
|
||||
|
||||
log_message(f"Docker 清理: {what}")
|
||||
return jsonify({'message': '清理完成', 'output': result.stdout})
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
# ==================== 邮件通知 API ====================
|
||||
|
||||
@app.route('/api/email/send', methods=['POST'])
|
||||
@@ -1181,7 +1556,7 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>项目服务管理面板 v3.3.1</title>
|
||||
<title>项目服务管理面板 v3.0.0</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📊</text></svg>">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css" rel="stylesheet">
|
||||
@@ -1287,6 +1662,9 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
|
||||
<button onclick="switchTab('system')" class="tab-btn px-4 py-2 text-gray-300 hover:text-white" data-tab="system">
|
||||
<i class="ri-dashboard-2-line"></i> 系统资源
|
||||
</button>
|
||||
<button onclick="switchTab('docker')" class="tab-btn px-4 py-2 text-gray-300 hover:text-white" data-tab="docker">
|
||||
<i class="ri-ship-line"></i> Docker 监控
|
||||
</button>
|
||||
<button onclick="switchTab('cron')" class="tab-btn px-4 py-2 text-gray-300 hover:text-white" data-tab="cron">
|
||||
<i class="ri-timer-line"></i> Cron 管理
|
||||
</button>
|
||||
@@ -1577,6 +1955,98 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Docker 监控 Tab -->
|
||||
<div id="dockerTab" class="hidden">
|
||||
<!-- Docker 统计卡片 -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
|
||||
<div class="card rounded-xl p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-gray-400 text-sm">容器总数</p>
|
||||
<p id="dockerContainerTotal" class="text-2xl font-bold text-blue-400">-</p>
|
||||
</div>
|
||||
<i class="ri-box-3-line text-3xl text-blue-400 opacity-50"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card rounded-xl p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-gray-400 text-sm">运行中</p>
|
||||
<p id="dockerContainerRunning" class="text-2xl font-bold text-green-400">-</p>
|
||||
</div>
|
||||
<i class="ri-play-circle-line text-3xl text-green-400 opacity-50"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card rounded-xl p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-gray-400 text-sm">已停止</p>
|
||||
<p id="dockerContainerStopped" class="text-2xl font-bold text-red-400">-</p>
|
||||
</div>
|
||||
<i class="ri-stop-circle-line text-3xl text-red-400 opacity-50"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card rounded-xl p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-gray-400 text-sm">镜像数</p>
|
||||
<p id="dockerImageCount" class="text-2xl font-bold text-purple-400">-</p>
|
||||
</div>
|
||||
<i class="ri-archive-line text-3xl text-purple-400 opacity-50"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card rounded-xl p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-gray-400 text-sm">磁盘占用</p>
|
||||
<p id="dockerDiskSize" class="text-2xl font-bold text-orange-400">-</p>
|
||||
</div>
|
||||
<i class="ri-hard-drive-2-line text-3xl text-orange-400 opacity-50"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Docker 操作按钮 -->
|
||||
<div class="flex gap-2 mb-4 items-center">
|
||||
<button onclick="loadDockerContainers()" class="btn bg-blue-600 hover:bg-blue-700 px-3 py-2 rounded-lg flex items-center gap-1">
|
||||
<i class="ri-refresh-line"></i> 刷新容器
|
||||
</button>
|
||||
<button onclick="loadDockerImages()" class="btn bg-purple-600 hover:bg-purple-700 px-3 py-2 rounded-lg flex items-center gap-1">
|
||||
<i class="ri-archive-line"></i> 刷新镜像
|
||||
</button>
|
||||
<button onclick="loadDockerSystem()" class="btn bg-orange-600 hover:bg-orange-700 px-3 py-2 rounded-lg flex items-center gap-1">
|
||||
<i class="ri-information-line"></i> 系统信息
|
||||
</button>
|
||||
<button onclick="showDockerPruneModal()" class="btn bg-red-600 hover:bg-red-700 px-3 py-2 rounded-lg flex items-center gap-1">
|
||||
<i class="ri-delete-bin-line"></i> 清理资源
|
||||
</button>
|
||||
<div class="flex items-center gap-2 ml-4">
|
||||
<span class="text-gray-400 text-xs">Docker版本:</span>
|
||||
<span id="dockerVersion" class="text-cyan-400 text-xs font-medium">-</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 容器列表 -->
|
||||
<div class="mb-6">
|
||||
<h3 class="font-bold text-gray-200 mb-3 flex items-center gap-2">
|
||||
<i class="ri-box-3-line text-blue-400"></i> 容器列表
|
||||
</h3>
|
||||
<div id="dockerContainerList" class="space-y-3">
|
||||
<div class="text-center py-12 text-gray-400"><i class="ri-loader-4-line text-4xl animate-spin"></i><p class="mt-2">加载中...</p></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 镜像列表 -->
|
||||
<div>
|
||||
<h3 class="font-bold text-gray-200 mb-3 flex items-center gap-2">
|
||||
<i class="ri-archive-line text-purple-400"></i> 镜像列表
|
||||
</h3>
|
||||
<div id="dockerImageList" class="space-y-2">
|
||||
<div class="text-gray-500 text-center py-4">加载中...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cron 管理 Tab -->
|
||||
<div id="cronTab" class="hidden">
|
||||
<!-- Cron 统计 -->
|
||||
@@ -1937,6 +2407,7 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
|
||||
});
|
||||
document.getElementById('projectsTab').classList.toggle('hidden', tab !== 'projects');
|
||||
document.getElementById('systemTab').classList.toggle('hidden', tab !== 'system');
|
||||
document.getElementById('dockerTab').classList.toggle('hidden', tab !== 'docker');
|
||||
document.getElementById('cronTab').classList.toggle('hidden', tab !== 'cron');
|
||||
|
||||
// 切换Tab时关闭实时监控
|
||||
@@ -1959,6 +2430,11 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
|
||||
renderEmailRules();
|
||||
loadSystemStats();
|
||||
}
|
||||
if (tab === 'docker') {
|
||||
loadDockerContainers();
|
||||
loadDockerImages();
|
||||
loadDockerSystem();
|
||||
}
|
||||
}
|
||||
|
||||
// 加载阈值设置
|
||||
@@ -3332,6 +3808,276 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
|
||||
localStorage.setItem('enableLogs', enableLogs);
|
||||
}
|
||||
|
||||
// ==================== Docker 监控功能 ====================
|
||||
|
||||
// Docker 实时监控定时器
|
||||
let dockerRealtimeTimer = null;
|
||||
|
||||
async function loadDockerContainers() {
|
||||
try {
|
||||
const res = await fetch('/api/docker/containers');
|
||||
const data = await res.json();
|
||||
|
||||
if (!data.available) {
|
||||
document.getElementById('dockerContainerList').innerHTML =
|
||||
`<div class="text-red-400 text-center py-8"><i class="ri-error-warning-line text-3xl"></i><p class="mt-2">${data.error || 'Docker 不可用'}</p></div>`;
|
||||
document.getElementById('dockerContainerTotal').textContent = '-';
|
||||
document.getElementById('dockerContainerRunning').textContent = '-';
|
||||
document.getElementById('dockerContainerStopped').textContent = '-';
|
||||
return;
|
||||
}
|
||||
|
||||
// 更新统计
|
||||
document.getElementById('dockerContainerTotal').textContent = data.count;
|
||||
document.getElementById('dockerContainerRunning').textContent = data.running_count;
|
||||
document.getElementById('dockerContainerStopped').textContent = data.stopped_count;
|
||||
|
||||
// 渲染容器列表
|
||||
const list = document.getElementById('dockerContainerList');
|
||||
if (data.containers.length === 0) {
|
||||
list.innerHTML = '<div class="text-gray-500 text-center py-8"><i class="ri-box-3-line text-3xl opacity-50"></i><p class="mt-2">暂无容器</p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = data.containers.map(c => `
|
||||
<div class="card rounded-lg p-3 hover:border-gray-500 transition-colors" id="docker-container-${c.id}">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="status-dot ${c.running ? 'status-running' : 'status-stopped'}"></div>
|
||||
<div>
|
||||
<div class="text-gray-200 font-medium">${c.names}</div>
|
||||
<div class="text-gray-500 text-xs flex items-center gap-2">
|
||||
<span class="font-mono">${c.id}</span>
|
||||
<span class="text-gray-600">|</span>
|
||||
<span>${c.image}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="text-right">
|
||||
<span class="text-xs ${c.running ? 'text-green-400' : 'text-red-400'}">${c.state}</span>
|
||||
<div class="text-gray-500 text-xs mt-1">${c.created}</div>
|
||||
</div>
|
||||
<div class="flex gap-1">
|
||||
${c.running ?
|
||||
`<button onclick="stopDockerContainer('${c.id}')" class="btn bg-red-600 hover:bg-red-700 px-2 py-1 rounded text-xs" title="停止"><i class="ri-stop-line"></i></button>
|
||||
<button onclick="restartDockerContainer('${c.id}')" class="btn bg-yellow-600 hover:bg-yellow-700 px-2 py-1 rounded text-xs" title="重启"><i class="ri-refresh-line"></i></button>` :
|
||||
`<button onclick="startDockerContainer('${c.id}')" class="btn bg-green-600 hover:bg-green-700 px-2 py-1 rounded text-xs" title="启动"><i class="ri-play-line"></i></button>`
|
||||
}
|
||||
<button onclick="showDockerContainerLogs('${c.id}', '${c.names}')" class="btn bg-blue-600 hover:bg-blue-700 px-2 py-1 rounded text-xs" title="日志"><i class="ri-file-text-line"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
${c.ports.length > 0 ?
|
||||
`<div class="mt-2 pt-2 border-t border-gray-700 text-gray-400 text-xs">
|
||||
<i class="ri-link-line"></i> ${c.ports.join(' · ')}
|
||||
</div>` : ''
|
||||
}
|
||||
<div class="mt-2 docker-stats-${c.id}" id="docker-stats-${c.id}"></div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
// 为运行中的容器加载资源统计
|
||||
data.containers.filter(c => c.running).forEach(c => loadContainerStats(c.id));
|
||||
|
||||
} catch (e) {
|
||||
document.getElementById('dockerContainerList').innerHTML =
|
||||
`<div class="text-red-400 text-center py-8"><i class="ri-error-warning-line text-3xl"></i><p class="mt-2">加载失败: ${e.message}</p></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadContainerStats(containerId) {
|
||||
try {
|
||||
const res = await fetch(`/api/docker/container/${containerId}/stats`);
|
||||
const data = await res.json();
|
||||
|
||||
const statsEl = document.getElementById(`docker-stats-${containerId}`);
|
||||
if (!statsEl) return;
|
||||
|
||||
if (!data.running) {
|
||||
statsEl.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
statsEl.innerHTML = `
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 pt-2 border-t border-gray-700">
|
||||
<div>
|
||||
<div class="text-gray-400 text-xs">CPU</div>
|
||||
<div class="text-blue-400 font-medium">${data.cpu_percent.toFixed(1)}%</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-gray-400 text-xs">内存</div>
|
||||
<div class="text-green-400 font-medium">${data.memory.used} / ${data.memory.total}</div>
|
||||
<div class="text-gray-500 text-xs">${data.memory.percent.toFixed(1)}%</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-gray-400 text-xs">网络 I/O</div>
|
||||
<div class="text-cyan-400 text-xs">↑ ${data.network.sent}</div>
|
||||
<div class="text-purple-400 text-xs">↓ ${data.network.recv}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-gray-400 text-xs">磁盘 I/O</div>
|
||||
<div class="text-orange-400 text-xs">读 ${data.block.read}</div>
|
||||
<div class="text-yellow-400 text-xs">写 ${data.block.write}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} catch (e) {
|
||||
console.error('加载容器资源失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDockerImages() {
|
||||
try {
|
||||
const res = await fetch('/api/docker/images');
|
||||
const data = await res.json();
|
||||
|
||||
if (!data.available) {
|
||||
document.getElementById('dockerImageList').innerHTML =
|
||||
`<div class="text-red-400 text-center py-4">${data.error || '无法获取镜像'}</div>`;
|
||||
document.getElementById('dockerImageCount').textContent = '-';
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('dockerImageCount').textContent = data.count;
|
||||
document.getElementById('dockerDiskSize').textContent = data.total_size_gb + ' GB';
|
||||
|
||||
const list = document.getElementById('dockerImageList');
|
||||
if (data.images.length === 0) {
|
||||
list.innerHTML = '<div class="text-gray-500 text-center py-4">暂无镜像</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = data.images.map(img => `
|
||||
<div class="card rounded-lg p-2 hover:border-gray-500 transition-colors ${img.dangling ? 'opacity-60 border-red-500/30' : ''}">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<i class="ri-archive-line ${img.dangling ? 'text-red-400' : 'text-purple-400'}"></i>
|
||||
<div class="min-w-0">
|
||||
<div class="text-gray-200 text-sm truncate">${img.full_name}</div>
|
||||
<div class="text-gray-500 text-xs font-mono">${img.id}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-xs">
|
||||
<span class="text-orange-400">${img.size}</span>
|
||||
<span class="text-gray-500">${img.created_since}</span>
|
||||
${img.dangling ? '<span class="text-red-400" title="悬空镜像">悬空</span>' : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
} catch (e) {
|
||||
document.getElementById('dockerImageList').innerHTML =
|
||||
`<div class="text-red-400 text-center py-4">加载失败: ${e.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDockerSystem() {
|
||||
try {
|
||||
const res = await fetch('/api/docker/system');
|
||||
const data = await res.json();
|
||||
|
||||
if (!data.available) {
|
||||
document.getElementById('dockerVersion').textContent = '不可用';
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('dockerVersion').textContent = data.version;
|
||||
|
||||
} catch (e) {
|
||||
document.getElementById('dockerVersion').textContent = '获取失败';
|
||||
}
|
||||
}
|
||||
|
||||
async function startDockerContainer(containerId) {
|
||||
try {
|
||||
const res = await fetch(`/api/docker/container/${containerId}/start`, { method: 'POST' });
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
loadDockerContainers();
|
||||
} else {
|
||||
alert('启动失败: ' + data.error);
|
||||
}
|
||||
} catch (e) {
|
||||
alert('启动失败: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function stopDockerContainer(containerId) {
|
||||
try {
|
||||
const res = await fetch(`/api/docker/container/${containerId}/stop`, { method: 'POST' });
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
loadDockerContainers();
|
||||
} else {
|
||||
alert('停止失败: ' + data.error);
|
||||
}
|
||||
} catch (e) {
|
||||
alert('停止失败: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function restartDockerContainer(containerId) {
|
||||
try {
|
||||
const res = await fetch(`/api/docker/container/${containerId}/restart`, { method: 'POST' });
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
loadDockerContainers();
|
||||
} else {
|
||||
alert('重启失败: ' + data.error);
|
||||
}
|
||||
} catch (e) {
|
||||
alert('重启失败: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function showDockerContainerLogs(containerId, containerName) {
|
||||
try {
|
||||
const res = await fetch(`/api/docker/container/${containerId}/logs`);
|
||||
const data = await res.json();
|
||||
|
||||
document.getElementById('logTitle').textContent = `容器日志 - ${containerName} (${containerId})`;
|
||||
document.getElementById('logContent').textContent = data.logs || '暂无日志';
|
||||
document.getElementById('logModal').classList.remove('hidden');
|
||||
document.getElementById('logModal').classList.add('flex');
|
||||
} catch (e) {
|
||||
alert('获取日志失败: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function showDockerPruneModal() {
|
||||
const confirmed = confirm('确定清理 Docker 未使用资源?\n\n将清理:\n- 停止的容器\n- 悬空镜像\n- 未使用的网络\n- 未使用的构建缓存\n\n注意:不会删除正在运行的容器和有标签的镜像。');
|
||||
|
||||
if (confirmed) {
|
||||
doDockerPrune('all');
|
||||
}
|
||||
}
|
||||
|
||||
async function doDockerPrune(what) {
|
||||
try {
|
||||
const res = await fetch('/api/docker/prune', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ what })
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
alert('清理完成!\n' + data.output);
|
||||
loadDockerContainers();
|
||||
loadDockerImages();
|
||||
} else {
|
||||
alert('清理失败: ' + data.error);
|
||||
}
|
||||
} catch (e) {
|
||||
alert('清理失败: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 解析 cron 表达式显示友好时间
|
||||
document.getElementById('cronExpression').addEventListener('input', function() {
|
||||
const expr = this.value;
|
||||
|
||||
30283
logs/app.log
30283
logs/app.log
File diff suppressed because it is too large
Load Diff
@@ -29,10 +29,10 @@
|
||||
"start_cmd": "mkdir -p logs && nohup /home/hz1/miniconda3/envs/openclaw/bin/python3.12 app.py > logs/app.log 2>&1 & disown",
|
||||
"stop_cmd": "pkill -f 'python.*project-panel.*app.py' || true",
|
||||
"health_url": "http://localhost:16022/",
|
||||
"description": "统一管理所有Web服务项目",
|
||||
"description": "统一管理所有 Web 服务项目,支持 Docker 监控、系统资源监控、Cron 管理",
|
||||
"admin_url": "http://localhost:16022",
|
||||
"git_repo": "http://121.40.164.32:12007/hz4th_coder/project-panel",
|
||||
"version": "v2.0.0"
|
||||
"version": "v3.0.0"
|
||||
}
|
||||
],
|
||||
"external_services": [
|
||||
|
||||
Reference in New Issue
Block a user