Compare commits

...

7 Commits

Author SHA1 Message Date
0c54af8d26 feat: Web服务和Docker监控区支持折叠/展开
- 所有分组标题(Web服务/自定义服务/归档服务等)可点击折叠
- Docker容器列表、镜像列表标题可点击折叠
- 点击标题切换 chevron 旋转动画(→↓)
- 折叠状态持久化到 localStorage,刷新保持
2026-06-01 22:35:57 +08:00
f7722be539 fix: 系统资源面板未开实时监控时按页面刷新间隔自动更新
- 新增 systemRefreshTimer,切换至系统Tab时自动启动
- 未开实时监控 → 按页面「刷新间隔」秒数定时刷新系统资源
- 开启实时监控 → 按实时间隔秒数刷新(原有逻辑不变)
- 关闭实时监控 → 切回页面刷新间隔继续更新
- 修改页面刷新间隔 → 同步更新系统资源刷新频率
- 新增 watchdog.sh 守护脚本,端口掉线自动重启
2026-06-01 20:35:07 +08:00
779f42c98c fix: 服务发现支持无权限查看的端口(Docker host网络等)
- ss -tlnp 输出中没有 pid= 的端口不再跳过
- pid 为 None 时显示「受权限限制」, 进程名标记「未知进程」
- 当前发现: 8088, 9119, 16031, 16067, 18789
2026-06-01 18:32:10 +08:00
525620b0f8 fix: 自定义服务保存时添加port字段,修复发现扫描排除失效
之前 quickAddService 保存自定义服务时遗漏了 port 字段,
导致 scanServices 的 exclude_ports 取不到有效端口值
2026-06-01 18:28:44 +08:00
ebc8d687d0 fix: 服务发现排除前端已注册的自定义服务
- API 新增 exclude_ports 查询参数,接受逗号分隔端口列表
- 前端 scanServices() 读取 localStorage 自定义服务端口传给后端
- 已添加的服务不再出现在扫描结果中
2026-06-01 18:23:23 +08:00
294526b403 feat: 服务发现 - 自动扫描本机未注册的新服务
方案C: 端口扫描 + 进程信息增强

后端 /api/discovery/services:
- ss -tlnp 扫描所有TCP监听端口
- 自动排除已注册服务(projects.json) + Docker容器端口 + 系统端口(SSH等)
- 读取 /proc/<pid>/cmdline 获取进程命令行
- 读取 /proc/<pid>/cwd 获取工作目录
- 智能推测服务名(匹配python/node/java等框架特征)
- 智能推测服务类型(web/external)

前端 Web服务区底部:
- 发现未注册服务区块,点击「扫描」探测
- 每个未注册服务显示: 端口/PID/进程名/cmdline
- 一键添加按钮,自动预填弹框(名称/端口/URL/描述)
2026-06-01 18:18:26 +08:00
78a4a3f1aa feat: Docker 监控面板
后端 API (routes_docker.py):
- /api/docker/containers - 容器列表(状态+资源stats: CPU/内存/网络I/O/磁盘I/O/进程数)
- /api/docker/container/<id>/stats - 单容器实时资源
- /api/docker/images - 镜像列表(大小/时间/悬空标记)
- /api/docker/system - 系统信息(版本/磁盘占用)
- /api/docker/container/<id>/start|stop|restart - 容器控制
- /api/docker/container/<id>/logs - 容器日志
- /api/docker/prune - 资源清理

前端 Docker 监控 Tab:
- 统计卡片: 总容器/运行中/已停止/镜像数/磁盘占用/可回收
- 容器列表: 状态灯+CPU/内存/NET IO/磁盘IO/端口/运行时间
- 操作按钮: 启动/停止/重启/查看日志
- 镜像列表: 有效镜像+悬空镜像分区显示
- 清理: 容器/镜像/全部一键清理
2026-06-01 18:01:17 +08:00
5 changed files with 877 additions and 7 deletions

4
app.py
View File

@@ -13,6 +13,8 @@ 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 routes_discovery import register_discovery_routes
from templates import get_html_template
@@ -31,6 +33,8 @@ def init_app():
register_config_routes(app)
register_email_routes(app)
register_project_routes(app)
register_docker_routes(app)
register_discovery_routes(app)
# 主页路由
@app.route('/')

247
routes_discovery.py Normal file
View File

@@ -0,0 +1,247 @@
#!/usr/bin/env python3
"""
服务发现 API 路由 - 扫描本机未注册的服务
方案C: 端口扫描 + 进程信息增强
"""
import os
import re
import json
import subprocess
from flask import jsonify, request
from utils import log_message
from config import PROJECTS_FILE
# 系统保留/常见基础服务端口,不纳入发现
SYSTEM_PORTS = {
22, # SSH
25, # SMTP
53, # DNS
80, # HTTP (nginx/apache 前端)
111, # portmapper
135, # RPC
137, # NetBIOS
138, # NetBIOS
139, # NetBIOS
443, # HTTPS
445, # SMB
587, # SMTP submission
631, # CUPS
993, # IMAPS
995, # POP3S
3306, # MySQL
3389, # RDP
5432, # PostgreSQL
6379, # Redis
8080, # 常见代理
8443, # 常见 HTTPS 代理
9090, # Prometheus
3000, # 常见开发端口
4200, # Angular dev
5000, # Flask dev
8000, # 常见开发
9000, # 常见开发
}
def _get_listening_ports():
"""获取所有TCP监听端口及进程信息"""
services = []
try:
result = subprocess.run(
['ss', '-tlnp'],
capture_output=True, text=True, timeout=5
)
for line in result.stdout.split('\n'):
line = line.strip()
if not line or 'State' in line or 'Local' in line:
continue
# 解析: LISTEN 0 128 0.0.0.0:16022 0.0.0.0:* users:(("python3.12",pid=123,fd=3))
if '127.0.0.1' in line and '0.0.0.0' not in line.split()[3] and '*' not in line.split()[3]:
# 跳过仅监听 localhost 的
pass
# 提取端口和pid
addr_part = line.split()[3] if len(line.split()) > 3 else ''
port_match = re.search(r':(\d+)$', addr_part)
if not port_match:
continue
port = int(port_match.group(1))
pid_match = re.search(r'pid=(\d+)', line)
pid = int(pid_match.group(1)) if pid_match else None
proc_match = re.search(r'users:\(\(\"([^\"]+)\"', line)
proc_name = proc_match.group(1) if proc_match else '未知进程'
services.append({
'port': port,
'pid': pid,
'process': proc_name,
})
except Exception as e:
log_message(f"端口扫描失败: {e}")
return services
def _get_process_cmdline(pid):
"""读取进程命令行"""
try:
cmdline_path = f'/proc/{pid}/cmdline'
if os.path.exists(cmdline_path):
with open(cmdline_path, 'rb') as f:
raw = f.read()
# cmdline 以 \0 分隔
args = raw.split(b'\x00')
# 解码,跳过空字符串
cmd = ' '.join(a.decode('utf-8', errors='replace') for a in args if a)
return cmd
except:
pass
return ''
def _get_process_cwd(pid):
"""读取进程工作目录"""
try:
cwd_path = f'/proc/{pid}/cwd'
if os.path.exists(cwd_path):
return os.readlink(cwd_path)
except:
pass
return ''
def _guess_service_name(pid, proc_name, cmdline):
"""根据进程信息推测服务名"""
# 已知框架特征
patterns = [
(r'python.*?app\.py', 'Python Web 服务'),
(r'python.*?main\.py', 'Python 服务'),
(r'python.*?server\.py', 'Python Server'),
(r'node\s+.*?(server|app|index)\.js', 'Node.js 服务'),
(r'npm\s+(start|run)', 'Node.js 服务'),
(r'java\s+-jar\s+(\S+)', 'Java 服务'),
(r'gunicorn', 'Gunicorn 服务'),
(r'uvicorn', 'Uvicorn 服务'),
(r'nginx', 'Nginx'),
(r'php-fpm', 'PHP-FPM'),
(r'mysqld', 'MySQL'),
(r'redis-server', 'Redis'),
(r'docker-proxy', 'Docker 代理'),
(r'containerd', 'containerd'),
]
for pattern, name in patterns:
if re.search(pattern, cmdline, re.IGNORECASE):
return name
# 默认使用进程名
return proc_name
def _guess_service_type(cmdline):
"""推测服务类型"""
if re.search(r'(python|gunicorn|uvicorn|flask|django)', cmdline, re.IGNORECASE):
return 'web'
if re.search(r'(node|npm|next|express)', cmdline, re.IGNORECASE):
return 'web'
if re.search(r'(java|spring|tomcat)', cmdline, re.IGNORECASE):
return 'web'
if re.search(r'(mysqld|postgres|redis|mongo)', cmdline, re.IGNORECASE):
return 'external'
return 'external'
def register_discovery_routes(app):
"""注册服务发现路由"""
@app.route('/api/discovery/services')
def api_discovery_services():
"""扫描本机未注册的新服务"""
# 1. 收集已知端口
known_ports = set()
try:
with open(PROJECTS_FILE, 'r', encoding='utf-8') as f:
data = json.load(f)
for proj in data.get('projects', []):
for p in proj.get('ports', []):
known_ports.add(p)
for svc in data.get('external_services', []):
for p in svc.get('ports', []):
known_ports.add(p)
except:
pass
# 2. 前端传入的额外排除端口(自定义服务等)
exclude_str = request.args.get('exclude_ports', '')
if exclude_str:
try:
for p in exclude_str.split(','):
known_ports.add(int(p.strip()))
except:
pass
# 3. 获取 Docker 容器端口
try:
result = subprocess.run(
['docker', 'ps', '--format', '{{.Ports}}'],
capture_output=True, text=True, timeout=5
)
for line in result.stdout.split('\n'):
# 匹配 0.0.0.0:16001->7700/tcp 格式
for m in re.finditer(r':(\d+)->', line):
known_ports.add(int(m.group(1)))
except:
pass
# 3. 扫描所有监听端口
all_services = _get_listening_ports()
# 4. 筛选未注册的
undiscovered = []
seen_ports = set()
for svc in all_services:
port = svc['port']
pid = svc['pid']
# 跳过已知/系统端口
if port in known_ports or port in SYSTEM_PORTS:
continue
if port in seen_ports:
continue
seen_ports.add(port)
if pid:
cmdline = _get_process_cmdline(pid)
cwd = _get_process_cwd(pid)
name = _guess_service_name(pid, svc['process'], cmdline)
svc_type = _guess_service_type(cmdline)
else:
cmdline = ''
cwd = ''
name = svc['process']
svc_type = 'unknown'
undiscovered.append({
'port': port,
'pid': pid,
'process': svc['process'],
'cmdline': cmdline[:200] if cmdline else '',
'cwd': cwd,
'suggested_name': name,
'suggested_type': svc_type,
})
# 按端口排序
undiscovered.sort(key=lambda x: x['port'])
return jsonify({
'services': undiscovered,
'count': len(undiscovered),
'known_ports': len(known_ports),
})

243
routes_docker.py Normal file
View File

@@ -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/<container_id>/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] != '<none>' else ''
tag = parts[1] if parts[1] != '<none>' 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/<container_id>/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/<container_id>/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/<container_id>/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/<container_id>/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})

View File

@@ -67,6 +67,9 @@
.history-item.toggle { border-left-color: #8b5cf6; }
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 50; display: flex; align-items: center; justify-content: center; }
.modal-content { max-width: 600px; width: 90%; max-height: 80vh; overflow: auto; }
.section-header { cursor: pointer; user-select: none; }
.section-header:hover { opacity: 0.85; }
.chevron { display: inline-block; transition: transform 0.2s ease; font-size: 14px; }
</style>
</head>
<body class="min-h-screen text-gray-100">
@@ -119,6 +122,9 @@
<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>
<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>
</div>
<!-- 项目服务 Tab -->
@@ -177,6 +183,24 @@
<div id="projectsList" class="space-y-4">
<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 id="discoverySection" class="hidden mt-4 pt-4 border-t border-gray-600">
<div class="flex items-center justify-between mb-3">
<h3 class="text-gray-200 text-sm font-semibold flex items-center gap-2">
<i class="ri-radar-line text-cyan-400"></i> 发现未注册服务
</h3>
<div class="flex items-center gap-2">
<span id="discoveryCount" class="text-xs text-gray-500"></span>
<button onclick="scanServices()" class="btn bg-cyan-600 hover:bg-cyan-700 px-2 py-1 rounded text-xs flex items-center gap-1">
<i class="ri-scan-line"></i> 扫描
</button>
</div>
</div>
<div id="discoveryList" class="space-y-2">
<div class="text-gray-500 text-xs text-center py-2">点击「扫描」发现本机未注册的新服务</div>
</div>
</div>
</div>
<!-- 系统资源 Tab -->
@@ -485,6 +509,72 @@
<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>
<!-- Docker 监控 Tab -->
<div id="dockerTab" class="hidden">
<!-- Docker 统计卡片 -->
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3 mb-6">
<div class="card rounded-xl p-3">
<p class="text-gray-400 text-xs">总容器数</p>
<p id="dockerContainersTotal" class="text-xl font-bold text-blue-400">-</p>
</div>
<div class="card rounded-xl p-3">
<p class="text-gray-400 text-xs">运行中</p>
<p id="dockerContainersRunning" class="text-xl font-bold text-green-400">-</p>
</div>
<div class="card rounded-xl p-3">
<p class="text-gray-400 text-xs">已停止</p>
<p id="dockerContainersStopped" class="text-xl font-bold text-red-400">-</p>
</div>
<div class="card rounded-xl p-3">
<p class="text-gray-400 text-xs">总镜像数</p>
<p id="dockerImagesTotal" class="text-xl font-bold text-purple-400">-</p>
</div>
<div class="card rounded-xl p-3">
<p class="text-gray-400 text-xs">磁盘占用</p>
<p id="dockerDiskSize" class="text-xl font-bold text-yellow-400">-</p>
</div>
<div class="card rounded-xl p-3">
<p class="text-gray-400 text-xs">可回收</p>
<p id="dockerReclaimable" class="text-xl font-bold text-orange-400">-</p>
</div>
</div>
<!-- 操作按钮 -->
<div class="flex gap-2 mb-4 flex-wrap">
<button onclick="loadDockerData()" class="btn bg-blue-600 hover:bg-blue-700 px-3 py-2 rounded-lg text-sm flex items-center gap-1">
<i class="ri-refresh-line"></i> 刷新
</button>
<button onclick="dockerPrune('containers')" class="btn bg-yellow-600 hover:bg-yellow-700 px-3 py-2 rounded-lg text-sm flex items-center gap-1">
<i class="ri-delete-bin-line"></i> 清理容器
</button>
<button onclick="dockerPrune('images')" class="btn bg-orange-600 hover:bg-orange-700 px-3 py-2 rounded-lg text-sm flex items-center gap-1">
<i class="ri-image-line"></i> 清理镜像
</button>
<button onclick="dockerPrune('all')" class="btn bg-red-600 hover:bg-red-700 px-3 py-2 rounded-lg text-sm flex items-center gap-1">
<i class="ri-broom-line"></i> 全部清理
</button>
<span class="text-gray-500 text-xs flex items-center ml-2">版本: <span id="dockerVersion" class="text-gray-300 ml-1">-</span></span>
</div>
<!-- 容器列表 -->
<h3 class="text-sm font-semibold text-gray-300 mb-2 flex items-center gap-2 section-header" onclick="toggleSection('docker-containers-grid', this.querySelector('.chevron'))">
<i class="ri-arrow-down-s-line chevron"></i><i class="ri-computer-line text-blue-400"></i> 容器列表
<span id="dockerContainerCount" class="text-xs text-gray-500"></span>
</h3>
<div id="docker-containers-grid" class="space-y-2 mb-6">
<div class="text-gray-500 text-sm py-4 text-center">加载中...</div>
</div>
<!-- 镜像列表 -->
<h3 class="text-sm font-semibold text-gray-300 mb-2 flex items-center gap-2 section-header" onclick="toggleSection('docker-images-grid', this.querySelector('.chevron'))">
<i class="ri-arrow-down-s-line chevron"></i><i class="ri-stack-line text-purple-400"></i> 镜像列表
<span id="dockerImageCount" class="text-xs text-gray-500"></span>
</h3>
<div id="docker-images-grid" class="space-y-1">
<div class="text-gray-500 text-sm py-4 text-center">加载中...</div>
</div>
</div>
</div>
<!-- 日志模态框 -->
@@ -842,11 +932,46 @@
// Tab 切换
// 系统资源监控变量(必须先声明)
let realtimeTimer = null;
let systemRefreshTimer = null;
let realtimeInterval = 2; // 秒
let lastAlertTime = 0; // 上次警告时间
let thresholds = { cpu: 80, cpu_temp: 80, memory: 85, disk: 90, interval: 60 };
let desktopNotifyEnabled = false;
// ==================== 折叠/展开 ====================
function getCollapsedState() {
const stored = localStorage.getItem('collapsedSections');
return stored ? JSON.parse(stored) : {};
}
function saveCollapsedState(state) {
localStorage.setItem('collapsedSections', JSON.stringify(state));
}
function toggleSection(sectionId, chevronEl) {
const grid = document.getElementById(sectionId);
if (!grid) return;
const state = getCollapsedState();
if (grid.classList.contains('hidden')) {
grid.classList.remove('hidden');
if (chevronEl) chevronEl.style.transform = 'rotate(0deg)';
state[sectionId] = false;
} else {
grid.classList.add('hidden');
if (chevronEl) chevronEl.style.transform = 'rotate(-90deg)';
state[sectionId] = true;
}
saveCollapsedState(state);
}
function applyCollapsedState(sectionId, gridEl, chevronEl) {
const state = getCollapsedState();
if (state[sectionId]) {
gridEl.classList.add('hidden');
if (chevronEl) chevronEl.style.transform = 'rotate(-90deg)';
} else {
gridEl.classList.remove('hidden');
if (chevronEl) chevronEl.style.transform = 'rotate(0deg)';
}
}
function switchTab(tab) {
currentTab = tab;
document.querySelectorAll('.tab-btn').forEach(btn => {
@@ -855,6 +980,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) {
@@ -867,6 +995,12 @@
document.getElementById('processListSection').classList.add('hidden');
}
// 离开系统Tab时关闭非实时刷新定时器
if (tab !== 'system' && systemRefreshTimer) {
clearInterval(systemRefreshTimer);
systemRefreshTimer = null;
}
if (tab === 'cron') {
loadCronTasks();
}
@@ -875,6 +1009,8 @@
loadDesktopNotifySetting();
renderEmailRules();
loadSystemStats();
// 启动系统资源定时刷新(按页面刷新间隔)
startSystemRefresh();
}
}
@@ -1191,14 +1327,17 @@
const toggle = document.getElementById('realtimeToggle');
const indicator = document.getElementById('realtimeIndicator');
// 先停止系统定时刷新
if (systemRefreshTimer) {
clearInterval(systemRefreshTimer);
systemRefreshTimer = null;
}
if (checked) {
toggle.classList.add('active');
indicator.classList.remove('hidden');
// 立即加载一次
loadSystemStats();
// 启动定时器
realtimeTimer = setInterval(loadSystemStats, realtimeInterval * 1000);
// 如果进程列表开关也开启,则显示并加载
if (document.getElementById('showProcessListCheck').checked) {
document.getElementById('processListSection').classList.remove('hidden');
loadTopProcesses();
@@ -1207,14 +1346,28 @@
toggle.classList.remove('active');
indicator.classList.add('hidden');
document.getElementById('processListSection').classList.add('hidden');
// 停止定时器
if (realtimeTimer) {
clearInterval(realtimeTimer);
realtimeTimer = null;
}
// 关闭实时后,按页面刷新间隔继续刷新系统资源
startSystemRefresh();
}
}
function startSystemRefresh() {
// 先停掉旧定时器
if (systemRefreshTimer) {
clearInterval(systemRefreshTimer);
systemRefreshTimer = null;
}
// 如果在实时监控模式则不启动由realtimeTimer负责
if (document.getElementById('realtimeCheck').checked) return;
// 按页面刷新间隔定时刷新
const intervalSec = parseInt(document.getElementById('refreshInterval').value) || 30;
systemRefreshTimer = setInterval(loadSystemStats, intervalSec * 1000);
}
function toggleProcessList() {
const checked = document.getElementById('showProcessListCheck').checked;
const realtimeChecked = document.getElementById('realtimeCheck').checked;
@@ -1329,23 +1482,38 @@
const archivedProjs = projs.filter(p => activeStatus[p.id] === false);
if (activeProjs.length > 0) {
html += `<div class="mb-6"><h2 class="text-lg font-semibold text-gray-300 mb-3 flex items-center gap-2"><i class="${typeInfo.icon} text-${typeInfo.color}-400"></i>${typeInfo.name}<span class="text-sm text-gray-500">(${activeProjs.length})</span></h2><div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">`;
const secId = 'section-web-active';
html += `<div class="mb-6"><h2 class="text-lg font-semibold text-gray-300 mb-3 flex items-center gap-2 section-header" onclick="toggleSection('${secId}', this.querySelector('.chevron'))"><i class="ri-arrow-down-s-line chevron"></i><i class="${typeInfo.icon} text-${typeInfo.color}-400"></i>${typeInfo.name}<span class="text-sm text-gray-500">(${activeProjs.length})</span></h2><div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3" id="${secId}">`;
activeProjs.forEach(p => { html += renderProjectCard(p, true); });
html += '</div></div>';
}
if (archivedProjs.length > 0) {
html += `<div class="mb-6 opacity-60"><h2 class="text-lg font-semibold text-gray-400 mb-3 flex items-center gap-2"><i class="ri-archive-line text-gray-400"></i>归档服务<span class="text-sm text-gray-500">(${archivedProjs.length})</span></h2><div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">`;
const secId = 'section-web-archived';
html += `<div class="mb-6 opacity-60"><h2 class="text-lg font-semibold text-gray-400 mb-3 flex items-center gap-2 section-header" onclick="toggleSection('${secId}', this.querySelector('.chevron'))"><i class="ri-arrow-down-s-line chevron"></i><i class="ri-archive-line text-gray-400"></i>归档服务<span class="text-sm text-gray-500">(${archivedProjs.length})</span></h2><div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3" id="${secId}">`;
archivedProjs.forEach(p => { html += renderProjectCard(p, false); });
html += '</div></div>';
}
} else {
html += `<div class="mb-6"><h2 class="text-lg font-semibold text-gray-300 mb-3 flex items-center gap-2"><i class="${typeInfo.icon} text-${typeInfo.color}-400"></i>${typeInfo.name}<span class="text-sm text-gray-500">(${projs.length})</span></h2><div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">`;
const secId = 'section-' + type;
html += `<div class="mb-6"><h2 class="text-lg font-semibold text-gray-300 mb-3 flex items-center gap-2 section-header" onclick="toggleSection('${secId}', this.querySelector('.chevron'))"><i class="ri-arrow-down-s-line chevron"></i><i class="${typeInfo.icon} text-${typeInfo.color}-400"></i>${typeInfo.name}<span class="text-sm text-gray-500">(${projs.length})</span></h2><div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3" id="${secId}">`;
projs.forEach(p => { html += renderProjectCard(p, true); });
html += '</div></div>';
}
}
list.innerHTML = html;
// 应用已保存的折叠状态
setTimeout(() => {
const state = getCollapsedState();
for (const [secId, collapsed] of Object.entries(state)) {
const grid = document.getElementById(secId);
const chevron = grid?.parentElement?.querySelector('.chevron');
if (grid && collapsed) {
grid.classList.add('hidden');
if (chevron) chevron.style.transform = 'rotate(-90deg)';
}
}
}, 10);
}
function getActiveStatus() {
@@ -2148,6 +2316,7 @@
const newService = {
id: 'custom_' + Date.now(),
name: name,
port: port || '',
url: mainUrl,
admin_url: adminUrl || null,
description: desc || '自定义外部服务',
@@ -2660,11 +2829,194 @@
clearInterval(refreshTimer);
refreshTimer = setInterval(() => { if (currentTab === 'projects') loadProjects(); }, seconds * 1000);
// 如果当前在系统Tab且非实时模式更新系统刷新间隔
if (currentTab === 'system' && !document.getElementById('realtimeCheck').checked) {
startSystemRefresh();
}
}
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('docker-containers-grid');
if (containers.length === 0) {
list.innerHTML = '<div class="text-gray-500 text-sm py-4 text-center">暂无容器</div>';
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 `<div class="card rounded-lg p-3 ${isRunning ? 'border-green-500/30' : 'border-red-500/30'}">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<div class="w-2 h-2 rounded-full ${isRunning ? 'bg-green-400' : 'bg-red-400'}" style="${isRunning ? 'box-shadow: 0 0 8px #4ade80;' : ''}"></div>
<span class="font-semibold text-sm text-gray-200">${c.name}</span>
<span class="text-xs ${isRunning ? 'text-green-400' : 'text-red-400'}">${isRunning ? '运行中' : '已停止'}</span>
</div>
<div class="flex items-center gap-1">
${isRunning ? `<button onclick="dockerControl('${c.id}','stop')" class="btn bg-red-600/50 hover:bg-red-600 px-2 py-0.5 rounded text-xs">停止</button><button onclick="dockerControl('${c.id}','restart')" class="btn bg-yellow-600/50 hover:bg-yellow-600 px-2 py-0.5 rounded text-xs">重启</button>` : `<button onclick="dockerControl('${c.id}','start')" class="btn bg-green-600/50 hover:bg-green-600 px-2 py-0.5 rounded text-xs">启动</button>`}
<button onclick="showDockerLogs('${c.id}','${c.name}')" class="btn bg-gray-600/50 hover:bg-gray-600 px-2 py-0.5 rounded text-xs">日志</button>
</div>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-2 text-xs">
<div><span class="text-gray-500">镜像:</span> <span class="text-gray-300">${c.image}</span></div>
${isRunning ? `<div><span class="text-gray-500">CPU:</span> <span class="text-blue-400">${s.cpuPercent || '-'}</span></div><div><span class="text-gray-500">内存:</span> <span class="text-green-400">${s.memUsage || '-'} (${s.memPercent || '-'})</span></div><div><span class="text-gray-500">进程:</span> <span class="text-gray-300">${s.pids || '-'}</span></div><div><span class="text-gray-500">网络I/O:</span> <span class="text-cyan-400">${s.netIO || '-'}</span></div><div><span class="text-gray-500">磁盘I/O:</span> <span class="text-purple-400">${s.blockIO || '-'}</span></div>` : ''}
${portsDisplay ? `<div class="col-span-2"><span class="text-gray-500">端口:</span> <span class="text-yellow-400">${portsDisplay}</span></div>` : ''}
<div class="col-span-2"><span class="text-gray-500">运行时间:</span> <span class="text-gray-400">${c.runningFor || '-'}</span></div>
</div>
</div>`;
}).join('');
}
function renderDockerImages(images) {
const list = document.getElementById('docker-images-grid');
if (images.length === 0) {
list.innerHTML = '<div class="text-gray-500 text-sm py-4 text-center">暂无镜像</div>';
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 => `<div class="flex items-center justify-between bg-gray-700/30 px-3 py-1.5 rounded text-xs"><div class="flex items-center gap-2 flex-1 min-w-0"><span class="text-gray-200 truncate">${i.repository}:${i.tag || 'latest'}</span><span class="text-gray-500">${i.size}</span></div><span class="text-gray-500 ml-2 shrink-0">${(i.created || '').substring(0, 19)}</span></div>`).join('');
}
if (dangling.length > 0) {
html += `<div class="text-yellow-400/70 text-xs mt-2 mb-1"><i class="ri-error-warning-line"></i> 悬空镜像 (${dangling.length}个)</div>`;
html += dangling.map(i => `<div class="flex items-center justify-between bg-yellow-500/10 px-3 py-1.5 rounded text-xs opacity-70"><div class="flex items-center gap-2 flex-1 min-w-0"><span class="text-gray-400 truncate">&lt;none&gt;:&lt;none&gt;</span><span class="text-gray-500">${i.size}</span><span class="text-gray-500">${i.id}</span></div><span class="text-gray-500 ml-2 shrink-0">${(i.created || '').substring(0, 19)}</span></div>`).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); }
}
// ==================== 服务发现 ====================
async function scanServices() {
const list = document.getElementById('discoveryList');
list.innerHTML = '<div class="text-gray-400 text-xs text-center py-2"><i class="ri-loader-4-line animate-spin"></i> 扫描中...</div>';
try {
// 收集前端已注册的自定义服务端口,传给后端排除
const customServices = getCustomServices();
const customPorts = customServices.map(s => parseInt(s.port)).filter(p => !isNaN(p));
const excludeParam = customPorts.length > 0 ? `?exclude_ports=${customPorts.join(',')}` : '';
const res = await fetch(`/api/discovery/services${excludeParam}`);
const data = await res.json();
const services = data.services || [];
document.getElementById('discoveryCount').textContent = `发现 ${services.length} 个`;
if (services.length === 0) {
list.innerHTML = '<div class="text-green-400 text-xs text-center py-2"><i class="ri-checkbox-circle-line"></i> 所有服务已注册,没有遗漏</div>';
} else {
list.innerHTML = services.map(s => `
<div class="flex items-center justify-between bg-gray-700/30 px-3 py-2 rounded-lg">
<div class="flex items-center gap-3 flex-1 min-w-0">
<span class="text-xs text-cyan-400 font-mono">:${s.port}</span>
<span class="text-gray-300 text-sm truncate">${s.suggested_name}</span>
<span class="text-gray-500 text-xs">PID ${s.pid != null ? s.pid : '受权限限制'}</span>
<span class="text-gray-500 text-xs truncate hidden md:inline">${(s.cmdline || '').substring(0, 60)}</span>
</div>
<button onclick="quickAddService(${s.port}, '${s.suggested_name.replace(/'/g, "\\'")}', '${s.suggested_type}', '${(s.cwd || '').replace(/'/g, "\\'")}')" class="btn bg-green-600/50 hover:bg-green-600 px-2 py-1 rounded text-xs shrink-0 ml-2">
<i class="ri-add-line"></i> 添加
</button>
</div>
`).join('');
}
} catch (e) {
list.innerHTML = `<div class="text-red-400 text-xs text-center py-2">扫描失败: ${e.message}</div>`;
}
}
function quickAddService(port, name, type, cwd) {
showAddServiceModal();
document.getElementById('serviceName').value = name;
document.getElementById('servicePort').value = port;
document.getElementById('serviceFullUrl').value = `http://${externalIp}:${port}`;
document.getElementById('serviceDesc').value = `自动发现 | ${type} | PID路径: ${cwd || '未知'}`;
updateServicePreview();
}
// 页面加载后显示发现区块
function showDiscoverySection() {
document.getElementById('discoverySection').classList.remove('hidden');
}
setTimeout(showDiscoverySection, 500);
</script>
</body>
</html>

24
watchdog.sh Executable file
View File

@@ -0,0 +1,24 @@
#!/bin/bash
# project-panel 守护脚本 - 进程挂了自动重启
SERVICE_DIR="/home/openclaw/.openclaw/workspace-hz4th_coder/works/project-panel"
PYTHON="/home/hz1/miniconda3/envs/openclaw/bin/python3.12"
PORT=16022
LOG="$SERVICE_DIR/logs/watchdog.log"
echo "[$(date '+%Y-%m-%d %H:%M:%S')] 守护进程启动,监控端口 $PORT" >> "$LOG"
while true; do
if ! ss -tlnp 2>/dev/null | grep -q ":$PORT "; then
echo "[$(date '+%Y-%m-%d %H:%M:%S')] 检测到 $PORT 端口离线,重启服务..." >> "$LOG"
cd "$SERVICE_DIR"
mkdir -p logs
nohup "$PYTHON" app.py > logs/app.log 2>&1 &
sleep 3
if ss -tlnp 2>/dev/null | grep -q ":$PORT "; then
echo "[$(date '+%Y-%m-%d %H:%M:%S')] 服务重启成功" >> "$LOG"
else
echo "[$(date '+%Y-%m-%d %H:%M:%S')] 服务重启失败!" >> "$LOG"
fi
fi
sleep 10
done