Compare commits

...

5 Commits

Author SHA1 Message Date
761edef6fe feat: 增加 Docker 监控功能 v3.0.0
新增功能:
- Docker 容器监控(CPU、内存、网络I/O、磁盘I/O)
- 容器控制(启动、停止、重启)
- 容器日志查看
- 镜像列表管理
- Docker 系统信息
- 一键清理未使用资源

API 接口:
- /api/docker/containers - 容器列表
- /api/docker/container/<id>/stats - 容器资源统计
- /api/docker/images - 镜像列表
- /api/docker/system - Docker 系统信息
- /api/docker/container/<id>/start|stop|restart - 容器控制
- /api/docker/container/<id>/logs - 容器日志
- /api/docker/prune - 资源清理

前端:
- 新增 Docker 监控 Tab
- 容器卡片展示(状态、资源使用)
- 镜像列表展示
- 操作按钮(刷新、清理)
2026-06-01 11:30:15 +08:00
5f9439c8a9 chore: 更新仓库地址到hz4th_coder账号下 2026-06-01 11:15:24 +08:00
0d0354ac4e feat: CPU温度监控 + 端口统一调整为16022
- 添加双CPU Package温度显示 (CPU0/CPU1)
- 端口从19013改为16022
- projects.json精简配置,适配hz1服务器
- Git仓库地址更新为新地址
2026-06-01 11:08:08 +08:00
47a38032a6 fix: AI Chat App启动路径修正,directory改为backend目录 2026-04-29 16:22:37 +08:00
b59931f357 feat: 添加AI Chat App项目配置 2026-04-29 16:18:28 +08:00
4 changed files with 864 additions and 30485 deletions

View File

@@ -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 任务增删改查
- 历史版本回退
- 分类管理
- 从系统同步
## 启动方式 ⭐

797
app.py
View File

@@ -1,7 +1,8 @@
#!/usr/bin/env python3
"""
项目服务管理面板 v2.2.0
端口: 19013
项目服务管理面板 v3.0.0
端口: 16022
新增: Docker 监控功能(容器、镜像、资源使用)
新增: Web服务卡片添加自定义链接入口+按钮)
新增: 自定义新增外部web服务功能新增服务按钮
"""
@@ -30,6 +31,27 @@ try:
except ImportError:
HAS_PSUTIL = False
# CPU 温度获取
def get_cpu_temperatures():
"""获取 CPU Package 温度"""
temps = {'package_0': None, 'package_1': None}
try:
result = subprocess.run(['sensors'], capture_output=True, text=True, timeout=5)
output = result.stdout
# 解析 Package id 0 和 Package id 1
for line in output.split('\n'):
if 'Package id 0:' in line:
match = re.search(r'Package id 0:\s*([+\d.]+)°C', line)
if match:
temps['package_0'] = float(match.group(1).replace('+', ''))
elif 'Package id 1:' in line:
match = re.search(r'Package id 1:\s*([+\d.]+)°C', line)
if match:
temps['package_1'] = float(match.group(1).replace('+', ''))
except Exception as e:
log_message(f"获取CPU温度失败: {e}")
return temps
app = Flask(__name__)
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
@@ -718,6 +740,9 @@ def api_system_stats():
cpu_count = psutil.cpu_count(logical=False) or psutil.cpu_count()
cpu_count_logical = psutil.cpu_count(logical=True)
# CPU 温度
cpu_temps = get_cpu_temperatures()
# 内存
mem = psutil.virtual_memory()
mem_total_gb = round(mem.total / (1024**3), 2)
@@ -769,7 +794,9 @@ def api_system_stats():
'cpu': {
'percent': cpu_percent,
'count': cpu_count,
'logical': cpu_count_logical
'logical': cpu_count_logical,
'temp_package_0': cpu_temps['package_0'],
'temp_package_1': cpu_temps['package_1']
},
'memory': {
'total_gb': mem_total_gb,
@@ -903,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'])
@@ -1155,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">
@@ -1261,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>
@@ -1377,6 +1781,12 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
<div id="cpuBar" class="bg-blue-500 h-4 rounded-full transition-all duration-300" style="width: 0%"></div>
</div>
<p class="text-gray-400 text-sm">物理核心: <span id="cpuPhysical">-</span> | 逻辑核心: <span id="cpuLogical">-</span></p>
<div class="mt-3 pt-3 border-t border-gray-700">
<p class="text-gray-400 text-sm flex items-center gap-4">
<span><i class="ri-temp-hot-line text-red-400"></i> CPU0: <span id="cpuTemp0" class="text-white font-medium">-</span>°C</span>
<span><i class="ri-temp-hot-line text-red-400"></i> CPU1: <span id="cpuTemp1" class="text-white font-medium">-</span>°C</span>
</p>
</div>
</div>
<div class="card rounded-xl p-6">
<div class="flex items-center justify-between mb-4">
@@ -1545,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 统计 -->
@@ -1905,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时关闭实时监控
@@ -1927,6 +2430,11 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
renderEmailRules();
loadSystemStats();
}
if (tab === 'docker') {
loadDockerContainers();
loadDockerImages();
loadDockerSystem();
}
}
// 加载阈值设置
@@ -2114,6 +2622,13 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
document.getElementById('cpuBar').style.width = data.cpu.percent + '%';
document.getElementById('cpuPhysical').textContent = data.cpu.count;
document.getElementById('cpuLogical').textContent = data.cpu.logical;
// CPU 温度
if (data.cpu.temp_package_0 !== null) {
document.getElementById('cpuTemp0').textContent = data.cpu.temp_package_0.toFixed(1);
}
if (data.cpu.temp_package_1 !== null) {
document.getElementById('cpuTemp1').textContent = data.cpu.temp_package_1.toFixed(1);
}
// 内存
document.getElementById('memPercent').textContent = data.memory.percent.toFixed(1) + '%';
@@ -3293,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;
@@ -3372,8 +4157,8 @@ if __name__ == '__main__':
log_message("=" * 50)
log_message("项目服务管理面板 v2.0.0 启动")
log_message("访问地址: http://localhost:19013")
log_message("访问地址: http://localhost:16022")
log_message(f"进程PID: {os.getpid()}")
log_message("=" * 50)
app.run(host='0.0.0.0', port=19013, debug=False)
app.run(host='0.0.0.0', port=16022, debug=False)

30283
logs/app.log

File diff suppressed because it is too large Load Diff

View File

@@ -1,221 +1,48 @@
{
"server": {
"name": "hz1-PowerEdge-R730xd",
"description": "黄庄1号服务器 - PowerEdge R730xd",
"python_path": "/home/hz1/miniconda3/envs/openclaw/bin/python3.12",
"updated": "2026-06-01"
},
"projects": [
{
"id": "pdf-translate-v2",
"name": "PDF翻译助手 V2",
"id": "pdf-translate-web",
"name": "PDF翻译助手",
"type": "web",
"ports": [19000],
"directory": "works/pdf-translate-web-v2",
"start_cmd": "mkdir -p logs && nohup python3 app.py > logs/app.log 2>&1 & disown",
"health_url": "http://localhost:19000/api/health",
"ports": [16021],
"directory": "works/pdf-translate-web",
"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.*pdf-translate-web.*app.py' || true",
"health_url": "http://localhost:16021/",
"description": "英文PDF翻译中文网站支持用户系统、会员体系",
"admin_url": "http://localhost:19000/admin",
"git_repo": "http://192.168.2.8:12007/coder/pdf-translate-web",
"version": "v2.1.0"
},
{
"id": "llm-index-rag",
"name": "LLM Index RAG",
"type": "web",
"ports": [19001],
"directory": "works/llm-index-rag",
"start_cmd": "mkdir -p logs && nohup python3 app.py > logs/app.log 2>&1 & disown",
"health_url": "http://localhost:19001/api/stats",
"description": "基于索引和搜索的知识检索系统",
"admin_url": "http://localhost:19001/settings",
"git_repo": "http://192.168.2.8:12007/coder/llm-index-rag",
"version": "v1.1.2"
},
{
"id": "snippet-notes",
"name": "碎片信息记录",
"type": "web",
"ports": [19009],
"directory": "works/snippet-notes",
"start_cmd": "mkdir -p logs && nohup python3 app.py > logs/app.log 2>&1 & disown",
"health_url": "http://localhost:19009/api/notes",
"description": "碎片信息记录工具AI自动生成标题",
"git_repo": null,
"version": "v1.0.0"
},
{
"id": "param-hub",
"name": "ParamHub Python",
"type": "web",
"ports": [19010],
"directory": "works/param-hub-python",
"start_cmd": "mkdir -p logs && nohup python3 app.py > logs/app.log 2>&1 & disown",
"health_url": "http://localhost:19010/api/stats",
"description": "AI大模型与硬件参数速查平台",
"admin_url": "http://localhost:19010/admin",
"git_repo": "http://192.168.2.8:12007/coder/param-hub-python",
"version": "v1.2.0"
},
{
"id": "ai-chat-system",
"name": "AI对话系统",
"type": "web",
"ports": [19020],
"directory": "works/ai-chat",
"start_cmd": "mkdir -p logs && nohup python3 main_v2.py > logs/app.log 2>&1 & disown",
"health_url": "http://localhost:19020/api/admin/stats",
"description": "网页端和Matrix端实时同步的AI聊天系统",
"admin_url": "http://localhost:19020/admin",
"git_repo": "http://192.168.2.8:12007/coder/ai-chat-system",
"version": "v2.5.4"
},
{
"id": "product-crawler",
"name": "产品参数爬取系统",
"type": "web",
"ports": [19011],
"directory": "/home/xian/.openclaw/common/projects/product-crawler",
"start_cmd": "mkdir -p logs && nohup python3 app.py > logs/app.log 2>&1 & disown",
"health_url": "http://localhost:19011/api/products",
"description": "自动从官网爬取产品参数信息",
"admin_url": "http://localhost:19011/admin",
"git_repo": "http://192.168.2.8:12007/coder/product-crawler",
"version": "v2.0.0"
},
{
"id": "llm-proxy",
"name": "LLM Proxy",
"type": "web",
"ports": [19007],
"directory": "/home/xian/.openclaw/common/projects/llm-proxy",
"start_cmd": "mkdir -p logs && nohup python3 app.py > logs/app.log 2>&1 & disown",
"health_url": "http://localhost:19007/health",
"description": "大模型API中转系统多提供商调度",
"admin_url": "http://localhost:19007/admin",
"git_repo": "http://192.168.2.8:12007/coder/llm-proxy",
"admin_url": "http://localhost:16021/admin",
"git_repo": "http://121.40.164.32:12007/coder/pdf-translate-web",
"version": "v2.0.0"
},
{
"id": "project-panel",
"name": "项目服务管理面板",
"type": "web",
"ports": [19013],
"ports": [16022],
"directory": "works/project-panel",
"start_cmd": "mkdir -p logs && nohup python3 app.py > logs/app.log 2>&1 & disown",
"health_url": "http://localhost:19013/",
"description": "统一管理所有Web服务项目",
"admin_url": "http://localhost:19013",
"git_repo": "http://192.168.2.8:12007/coder/project-panel",
"version": "v1.0.1"
},
"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 服务项目,支持 Docker 监控、系统资源监控、Cron 管理",
"admin_url": "http://localhost:16022",
"git_repo": "http://121.40.164.32:12007/hz4th_coder/project-panel",
"version": "v3.0.0"
}
],
"external_services": [
{
"id": "web-context-extension",
"name": "网页助手插件",
"type": "extension",
"directory": "works/web-context-extension",
"description": "浏览器扩展右键菜单支持收藏网页、AI总结",
"git_repo": "http://192.168.2.8:12007/coder/web-context-extension",
"version": "v0.1.0"
},
{
"id": "service-monitor",
"name": "Web服务监控",
"type": "cron",
"directory": "works/service-monitor",
"cron": "*/20 * * * *",
"cron_cmd": "/usr/bin/python3 /home/xian/.openclaw/workspace-coder/works/service-monitor/monitor.py",
"description": "每20分钟检查服务状态邮件通知",
"git_repo": "http://192.168.2.8:12007/coder/service-monitor",
"version": "v1.0.0"
},
{
"id": "board-monitor",
"name": "A股板块监控",
"type": "cron",
"directory": "works/board-monitor",
"cron": "0 17 * * 1-5",
"cron_cmd": "python3 board_monitor.py report",
"description": "每个交易日17:00发送板块分析报告",
"git_repo": "http://192.168.2.8:12007/coder/board-monitor",
"version": "v1.3.0"
},
{
"id": "stock-system",
"name": "A股历史数据系统",
"type": "cli",
"directory": "/home/xian/.openclaw/common/stock_system",
"run_cmd": "python3 fetch_history_v2.py",
"description": "获取A股历史行情数据支持断点续传",
"git_repo": "http://192.168.2.8:12007/coder/stock_system",
"version": "v0.2.0"
},
{
"id": "pdf-translator",
"name": "PDF翻译脚本",
"type": "cli",
"directory": "works/pdf-translator",
"run_cmd": "python3 translate_pdf.py",
"description": "命令行PDF翻译工具",
"git_repo": null,
"version": "v1.0.0"
},
{
"id": "xian-favor",
"name": "Xian Favor 收藏系统",
"type": "web",
"ports": [19014],
"directory": "works/xian-favor",
"start_cmd": "mkdir -p logs && nohup python3 -c \"from xian_favor.api import start_server; start_server(port=19014)\" > logs/app.log 2>&1 & disown",
"health_url": "http://localhost:19014/api/items?limit=1",
"description": "文本笔记、链接收藏、待办事项管理系统",
"admin_url": "http://localhost:19014",
"git_repo": "http://192.168.2.8:12007/coder/xian-favor",
"version": "v3.5.0"
},
{
"id": "multi-agent-bidding",
"name": "多智能体竞标调度系统",
"type": "web",
"ports": [19015],
"directory": "works/multi-agent-bidding",
"start_cmd": "mkdir -p logs && nohup python3 -m app.app --port 19015 > logs/app.log 2>&1 & disown",
"health_url": "http://localhost:19015/api/agents",
"description": "基于竞标机制的多智能体任务调度系统",
"admin_url": "http://localhost:19015",
"git_repo": "http://192.168.2.8:12007/coder/multi-agent-bidding",
"version": "v1.0.0"
},
{
"id": "tech-forum",
"name": "技术论坛",
"type": "web",
"ports": [19004],
"directory": "/home/xian/.openclaw/common/projects/tech-forum",
"start_cmd": "mkdir -p logs && nohup python3 backend/app.py > logs/app.log 2>&1 & disown",
"health_url": "http://localhost:19004/api/health",
"description": "技术交流、工具分享、问答讨论社区",
"admin_url": "http://localhost:19004/admin",
"git_repo": "http://192.168.2.8:12007/coder/tech-forum",
"version": "v1.2.0"
},
{
"id": "image-editor",
"name": "图片编辑器",
"type": "web",
"ports": [19018],
"directory": "works/image-editor",
"start_cmd": "mkdir -p logs && nohup python3 app.py > logs/app.log 2>&1 & disown",
"health_url": "http://localhost:19018/api/health",
"description": "前端图片处理:合并、分割、挖孔、圆形切图、文字图片",
"git_repo": "http://192.168.2.8:12007/coder/image-editor",
"version": "v1.2.1"
},
{
"id": "voice-chat-web",
"name": "语音对话网页",
"type": "web",
"ports": [19019],
"directory": "works/voice-chat-web",
"start_cmd": "mkdir -p logs && MODEL_SERVICE_URL=http://192.168.2.5:12001 nohup python3 main.py > logs/server.log 2>&1 & disown",
"health_url": "http://localhost:19019/api/status",
"description": "基于Qwen2-Audio的语音交互网页支持录音和文字对话",
"git_repo": "http://192.168.2.8:12007/coder/voice-chat-web",
"version": "v1.2.0"
"id": "meilisearch",
"name": "Meilisearch 搜索引擎",
"type": "external",
"ports": [16001],
"health_url": "http://localhost:16001/health",
"description": "高性能搜索引擎,只读监控"
}
]
}