Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 525620b0f8 | |||
| ebc8d687d0 | |||
| 294526b403 | |||
| 78a4a3f1aa | |||
| 27a84de317 |
4
app.py
4
app.py
@@ -13,6 +13,8 @@ from routes_system import register_system_routes
|
|||||||
from routes_config import register_config_routes
|
from routes_config import register_config_routes
|
||||||
from routes_email import register_email_routes
|
from routes_email import register_email_routes
|
||||||
from routes_project import register_project_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
|
from templates import get_html_template
|
||||||
|
|
||||||
|
|
||||||
@@ -31,6 +33,8 @@ def init_app():
|
|||||||
register_config_routes(app)
|
register_config_routes(app)
|
||||||
register_email_routes(app)
|
register_email_routes(app)
|
||||||
register_project_routes(app)
|
register_project_routes(app)
|
||||||
|
register_docker_routes(app)
|
||||||
|
register_discovery_routes(app)
|
||||||
|
|
||||||
# 主页路由
|
# 主页路由
|
||||||
@app.route('/')
|
@app.route('/')
|
||||||
|
|||||||
244
routes_discovery.py
Normal file
244
routes_discovery.py
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
#!/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)
|
||||||
|
if not pid_match:
|
||||||
|
continue
|
||||||
|
pid = int(pid_match.group(1))
|
||||||
|
|
||||||
|
# 提取进程名
|
||||||
|
proc_match = re.search(r'users:\(\(\"([^\"]+)\"', line)
|
||||||
|
proc_name = proc_match.group(1) if proc_match else 'unknown'
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
cmdline = _get_process_cmdline(pid)
|
||||||
|
cwd = _get_process_cwd(pid)
|
||||||
|
name = _guess_service_name(pid, svc['process'], cmdline)
|
||||||
|
svc_type = _guess_service_type(cmdline)
|
||||||
|
|
||||||
|
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
243
routes_docker.py
Normal 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})
|
||||||
@@ -119,6 +119,9 @@
|
|||||||
<button onclick="switchTab('cron')" class="tab-btn px-4 py-2 text-gray-300 hover:text-white" data-tab="cron">
|
<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 管理
|
<i class="ri-timer-line"></i> Cron 管理
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- 项目服务 Tab -->
|
<!-- 项目服务 Tab -->
|
||||||
@@ -177,6 +180,24 @@
|
|||||||
<div id="projectsList" class="space-y-4">
|
<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 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 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>
|
</div>
|
||||||
|
|
||||||
<!-- 系统资源 Tab -->
|
<!-- 系统资源 Tab -->
|
||||||
@@ -485,6 +506,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 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>
|
</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">
|
||||||
|
<i class="ri-computer-line text-blue-400"></i> 容器列表
|
||||||
|
<span id="dockerContainerCount" class="text-xs text-gray-500"></span>
|
||||||
|
</h3>
|
||||||
|
<div id="dockerContainersList" 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">
|
||||||
|
<i class="ri-stack-line text-purple-400"></i> 镜像列表
|
||||||
|
<span id="dockerImageCount" class="text-xs text-gray-500"></span>
|
||||||
|
</h3>
|
||||||
|
<div id="dockerImagesList" class="space-y-1">
|
||||||
|
<div class="text-gray-500 text-sm py-4 text-center">加载中...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 日志模态框 -->
|
<!-- 日志模态框 -->
|
||||||
@@ -698,17 +785,32 @@
|
|||||||
<i class="ri-hard-drive-2-line text-orange-400"></i>
|
<i class="ri-hard-drive-2-line text-orange-400"></i>
|
||||||
<span class="text-gray-200 text-sm">磁盘使用率</span>
|
<span class="text-gray-200 text-sm">磁盘使用率</span>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="flex items-center gap-2 bg-gray-700/50 px-3 py-2 rounded-lg cursor-pointer hover:bg-gray-700 col-span-2">
|
||||||
|
<input type="checkbox" value="service_down" class="email-rule-resource w-4 h-4" onchange="onResourceChange()">
|
||||||
|
<i class="ri-shut-down-line text-yellow-400"></i>
|
||||||
|
<span class="text-gray-200 text-sm">服务下线监控</span>
|
||||||
|
<span class="text-gray-500 text-xs">检测指定服务长期不在线</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-gray-500 text-xs mt-1">勾选多项可随机组合监控,任一资源超标即触发通知</p>
|
<p class="text-gray-500 text-xs mt-1">勾选多项可随机组合监控,任一资源超标即触发通知</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 服务选择器(仅当勾选「服务下线监控」时显示) -->
|
||||||
|
<div class="mb-3" id="serviceDownSelector" style="display:none;">
|
||||||
|
<label class="block text-gray-400 text-sm mb-1">监控服务 *</label>
|
||||||
|
<select id="emailRuleServiceId" class="w-full bg-gray-700 text-gray-200 px-3 py-2 rounded-lg" onchange="onServiceDownChange()">
|
||||||
|
<option value="">-- 请选择要监控的服务 --</option>
|
||||||
|
</select>
|
||||||
|
<p class="text-gray-500 text-xs mt-1">选择需要监控下线状态的服务</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="block text-gray-400 text-sm mb-1">触发阈值 *</label>
|
<label class="block text-gray-400 text-sm mb-1">触发阈值 *</label>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<input type="number" id="emailRuleThreshold" required min="1" class="w-20 bg-gray-700 text-gray-200 px-3 py-2 rounded-lg" value="80">
|
<input type="number" id="emailRuleThreshold" required min="1" class="w-20 bg-gray-700 text-gray-200 px-3 py-2 rounded-lg" value="80">
|
||||||
<span class="text-gray-400" id="emailRuleThresholdUnit">% / °C</span>
|
<span class="text-gray-400" id="emailRuleThresholdUnit">% / °C</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-gray-500 text-xs mt-1">对不同资源类型使用统一阈值(如CPU用%则填百分比,温度用°C则填温度值)</p>
|
<p class="text-gray-500 text-xs mt-1" id="emailRuleThresholdHint">对不同资源类型使用统一阈值(如CPU用%则填百分比,温度用°C则填温度值)</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
@@ -840,6 +942,9 @@
|
|||||||
document.getElementById('projectsTab').classList.toggle('hidden', tab !== 'projects');
|
document.getElementById('projectsTab').classList.toggle('hidden', tab !== 'projects');
|
||||||
document.getElementById('systemTab').classList.toggle('hidden', tab !== 'system');
|
document.getElementById('systemTab').classList.toggle('hidden', tab !== 'system');
|
||||||
document.getElementById('cronTab').classList.toggle('hidden', tab !== 'cron');
|
document.getElementById('cronTab').classList.toggle('hidden', tab !== 'cron');
|
||||||
|
document.getElementById('dockerTab').classList.toggle('hidden', tab !== 'docker');
|
||||||
|
|
||||||
|
if (tab === 'docker') loadDockerData();
|
||||||
|
|
||||||
// 切换Tab时关闭实时监控
|
// 切换Tab时关闭实时监控
|
||||||
if (tab !== 'system' && realtimeTimer) {
|
if (tab !== 'system' && realtimeTimer) {
|
||||||
@@ -1245,6 +1350,8 @@
|
|||||||
fetch('/api/system/stats').then(r => r.json()).then(sysData => {
|
fetch('/api/system/stats').then(r => r.json()).then(sysData => {
|
||||||
if (sysData.available) checkThresholds(sysData);
|
if (sysData.available) checkThresholds(sysData);
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
|
// 同时检查服务下线邮件规则
|
||||||
|
checkEmailRules(null);
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('加载失败:', e);
|
console.error('加载失败:', e);
|
||||||
@@ -1561,7 +1668,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const resourceNames = { 'cpu': 'CPU', 'cpu_temp': 'CPU温度', 'memory': '内存', 'disk': '磁盘' };
|
const resourceNames = { 'cpu': 'CPU', 'cpu_temp': 'CPU温度', 'memory': '内存', 'disk': '磁盘', 'service_down': '服务下线' };
|
||||||
const resourceUnits = { 'cpu': '%', 'cpu_temp': '°C', 'memory': '%', 'disk': '%' };
|
const resourceUnits = { 'cpu': '%', 'cpu_temp': '°C', 'memory': '%', 'disk': '%' };
|
||||||
|
|
||||||
list.innerHTML = rules.map(rule => {
|
list.innerHTML = rules.map(rule => {
|
||||||
@@ -1575,14 +1682,19 @@
|
|||||||
|
|
||||||
// 显示资源列表(支持多资源和旧版单资源兼容)
|
// 显示资源列表(支持多资源和旧版单资源兼容)
|
||||||
const resources = rule.resources || (rule.resource ? [rule.resource] : []);
|
const resources = rule.resources || (rule.resource ? [rule.resource] : []);
|
||||||
const resourceDisplay = resources.map(r => resourceNames[r] || r).join('+');
|
let resourceDisplay;
|
||||||
|
if (resources.includes('service_down') && rule.serviceName) {
|
||||||
|
resourceDisplay = `🔴 ${rule.serviceName} 下线 ≥ ${rule.threshold}分钟`;
|
||||||
|
} else {
|
||||||
|
resourceDisplay = resources.map(r => resourceNames[r] || r).join('+') + ' ≥ ' + rule.threshold;
|
||||||
|
}
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="flex items-center justify-between bg-gray-700/50 px-3 py-2 rounded-lg">
|
<div class="flex items-center justify-between bg-gray-700/50 px-3 py-2 rounded-lg">
|
||||||
<div class="flex items-center gap-3 flex-wrap">
|
<div class="flex items-center gap-3 flex-wrap">
|
||||||
<span class="px-2 py-0.5 rounded text-xs ${rule.enabled ? 'bg-green-500/20 text-green-400' : 'bg-gray-500/20 text-gray-400'}">${rule.enabled ? '启用' : '禁用'}</span>
|
<span class="px-2 py-0.5 rounded text-xs ${rule.enabled ? 'bg-green-500/20 text-green-400' : 'bg-gray-500/20 text-gray-400'}">${rule.enabled ? '启用' : '禁用'}</span>
|
||||||
<span class="text-gray-200">${rule.name}</span>
|
<span class="text-gray-200">${rule.name}</span>
|
||||||
<span class="text-gray-400 text-xs">${resourceDisplay} ≥ ${rule.threshold}</span>
|
<span class="text-gray-400 text-xs">${resourceDisplay}</span>
|
||||||
<span class="text-blue-400/70 text-xs"><i class="ri-flashlight-line"></i> ${triggerDisplay}</span>
|
<span class="text-blue-400/70 text-xs"><i class="ri-flashlight-line"></i> ${triggerDisplay}</span>
|
||||||
<span class="text-gray-400 text-xs">→ ${rule.email}</span>
|
<span class="text-gray-400 text-xs">→ ${rule.email}</span>
|
||||||
${silentInfo ? `<span class="text-yellow-400/70 text-xs"><i class="ri-moon-line"></i> ${silentInfo}</span>` : ''}
|
${silentInfo ? `<span class="text-yellow-400/70 text-xs"><i class="ri-moon-line"></i> ${silentInfo}</span>` : ''}
|
||||||
@@ -1602,9 +1714,13 @@
|
|||||||
document.getElementById('emailRuleName').value = '';
|
document.getElementById('emailRuleName').value = '';
|
||||||
// 重置资源复选框
|
// 重置资源复选框
|
||||||
document.querySelectorAll('.email-rule-resource').forEach(cb => cb.checked = false);
|
document.querySelectorAll('.email-rule-resource').forEach(cb => cb.checked = false);
|
||||||
|
document.getElementById('serviceDownSelector').style.display = 'none';
|
||||||
|
document.getElementById('emailRuleServiceId').value = '';
|
||||||
document.getElementById('emailRuleThreshold').value = 80;
|
document.getElementById('emailRuleThreshold').value = 80;
|
||||||
document.getElementById('emailRuleThresholdUnit').textContent = '% / °C';
|
document.getElementById('emailRuleThresholdUnit').textContent = '% / °C';
|
||||||
|
document.getElementById('emailRuleThresholdHint').textContent = '对不同资源类型使用统一阈值';
|
||||||
document.getElementById('emailRuleTriggerType').value = 'single';
|
document.getElementById('emailRuleTriggerType').value = 'single';
|
||||||
|
document.getElementById('emailRuleTriggerType').disabled = false;
|
||||||
document.getElementById('emailRuleTriggerParam').value = 3;
|
document.getElementById('emailRuleTriggerParam').value = 3;
|
||||||
document.getElementById('triggerParamsDiv').style.display = 'none';
|
document.getElementById('triggerParamsDiv').style.display = 'none';
|
||||||
document.getElementById('emailRuleAddress').value = '';
|
document.getElementById('emailRuleAddress').value = '';
|
||||||
@@ -1629,24 +1745,73 @@
|
|||||||
const selected = getSelectedResources();
|
const selected = getSelectedResources();
|
||||||
const unitEl = document.getElementById('emailRuleThresholdUnit');
|
const unitEl = document.getElementById('emailRuleThresholdUnit');
|
||||||
const thresholdEl = document.getElementById('emailRuleThreshold');
|
const thresholdEl = document.getElementById('emailRuleThreshold');
|
||||||
|
const hintEl = document.getElementById('emailRuleThresholdHint');
|
||||||
|
const serviceSelector = document.getElementById('serviceDownSelector');
|
||||||
|
const triggerTypeEl = document.getElementById('emailRuleTriggerType');
|
||||||
|
const triggerParamsDiv = document.getElementById('triggerParamsDiv');
|
||||||
|
|
||||||
if (selected.length === 0) {
|
const hasServiceDown = selected.includes('service_down');
|
||||||
unitEl.textContent = '% / °C';
|
|
||||||
} else if (selected.length === 1 && selected[0] === 'cpu_temp') {
|
// 显示/隐藏服务选择器
|
||||||
unitEl.textContent = '°C';
|
if (hasServiceDown) {
|
||||||
if (parseInt(thresholdEl.value) > 120) thresholdEl.value = 80;
|
serviceSelector.style.display = 'block';
|
||||||
thresholdEl.max = 120;
|
populateServiceSelector();
|
||||||
thresholdEl.min = 30;
|
// 服务下线使用分钟作为阈值单位
|
||||||
} else {
|
unitEl.textContent = '分钟';
|
||||||
unitEl.textContent = '%';
|
thresholdEl.value = 5;
|
||||||
thresholdEl.max = 100;
|
thresholdEl.max = 1440;
|
||||||
thresholdEl.min = 1;
|
thresholdEl.min = 1;
|
||||||
if (selected.includes('cpu_temp')) {
|
hintEl.textContent = '服务连续离线超过此分钟数即触发通知';
|
||||||
|
// 服务下线固定为duration类型
|
||||||
|
if (triggerTypeEl) {
|
||||||
|
triggerTypeEl.value = 'duration';
|
||||||
|
triggerTypeEl.disabled = true;
|
||||||
|
}
|
||||||
|
if (triggerParamsDiv) triggerParamsDiv.style.display = 'none';
|
||||||
|
} else {
|
||||||
|
serviceSelector.style.display = 'none';
|
||||||
|
if (triggerTypeEl) triggerTypeEl.disabled = false;
|
||||||
|
updateTriggerParams();
|
||||||
|
|
||||||
|
if (selected.length === 0) {
|
||||||
unitEl.textContent = '% / °C';
|
unitEl.textContent = '% / °C';
|
||||||
|
hintEl.textContent = '对不同资源类型使用统一阈值';
|
||||||
|
} else if (selected.length === 1 && selected[0] === 'cpu_temp') {
|
||||||
|
unitEl.textContent = '°C';
|
||||||
|
if (parseInt(thresholdEl.value) > 120) thresholdEl.value = 80;
|
||||||
|
thresholdEl.max = 120;
|
||||||
|
thresholdEl.min = 30;
|
||||||
|
hintEl.textContent = 'CPU温度超过此值即触发通知';
|
||||||
|
} else {
|
||||||
|
unitEl.textContent = '%';
|
||||||
|
thresholdEl.max = 100;
|
||||||
|
thresholdEl.min = 1;
|
||||||
|
hintEl.textContent = '对不同资源类型使用统一阈值';
|
||||||
|
if (selected.includes('cpu_temp')) {
|
||||||
|
unitEl.textContent = '% / °C';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function populateServiceSelector() {
|
||||||
|
const sel = document.getElementById('emailRuleServiceId');
|
||||||
|
const currentVal = sel.value;
|
||||||
|
// 收集所有web类型服务
|
||||||
|
const webServices = projects.filter(p => p.type === 'web');
|
||||||
|
let options = '<option value="">-- 请选择要监控的服务 --</option>';
|
||||||
|
webServices.forEach(p => {
|
||||||
|
const portsStr = p.ports ? p.ports.join(',') : '';
|
||||||
|
const selected = p.id === currentVal ? ' selected' : '';
|
||||||
|
options += `<option value="${p.id}"${selected}>${p.name} (端口: ${portsStr})</option>`;
|
||||||
|
});
|
||||||
|
sel.innerHTML = options;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onServiceDownChange() {
|
||||||
|
// 将来可以在这里做联动
|
||||||
|
}
|
||||||
|
|
||||||
function updateTriggerParams() {
|
function updateTriggerParams() {
|
||||||
const triggerType = document.getElementById('emailRuleTriggerType').value;
|
const triggerType = document.getElementById('emailRuleTriggerType').value;
|
||||||
const paramsDiv = document.getElementById('triggerParamsDiv');
|
const paramsDiv = document.getElementById('triggerParamsDiv');
|
||||||
@@ -1704,6 +1869,10 @@
|
|||||||
if (cb) cb.checked = true;
|
if (cb) cb.checked = true;
|
||||||
});
|
});
|
||||||
onResourceChange();
|
onResourceChange();
|
||||||
|
// 服务下线相关
|
||||||
|
if (rule.serviceId) {
|
||||||
|
document.getElementById('emailRuleServiceId').value = rule.serviceId;
|
||||||
|
}
|
||||||
document.getElementById('emailRuleThreshold').value = rule.threshold;
|
document.getElementById('emailRuleThreshold').value = rule.threshold;
|
||||||
document.getElementById('emailRuleTriggerType').value = rule.triggerType || 'single';
|
document.getElementById('emailRuleTriggerType').value = rule.triggerType || 'single';
|
||||||
document.getElementById('emailRuleTriggerParam').value = rule.triggerParam || 3;
|
document.getElementById('emailRuleTriggerParam').value = rule.triggerParam || 3;
|
||||||
@@ -1732,6 +1901,7 @@
|
|||||||
const silentEnd = document.getElementById('emailRuleSilentEnd').value;
|
const silentEnd = document.getElementById('emailRuleSilentEnd').value;
|
||||||
const silentEnabled = document.getElementById('emailRuleSilentEnabled').checked;
|
const silentEnabled = document.getElementById('emailRuleSilentEnabled').checked;
|
||||||
const enabled = document.getElementById('emailRuleEnabled').checked;
|
const enabled = document.getElementById('emailRuleEnabled').checked;
|
||||||
|
const serviceId = document.getElementById('emailRuleServiceId').value;
|
||||||
|
|
||||||
if (!name || !email) {
|
if (!name || !email) {
|
||||||
alert('请填写完整信息');
|
alert('请填写完整信息');
|
||||||
@@ -1743,23 +1913,32 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 服务下线必须选择具体服务
|
||||||
|
if (resources.includes('service_down') && !serviceId) {
|
||||||
|
alert('请选择要监控的服务');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const rules = getEmailRules();
|
const rules = getEmailRules();
|
||||||
|
|
||||||
if (ruleId) {
|
if (ruleId) {
|
||||||
// 编辑
|
// 编辑
|
||||||
const idx = rules.findIndex(r => r.id === ruleId);
|
const idx = rules.findIndex(r => r.id === ruleId);
|
||||||
if (idx >= 0) {
|
if (idx >= 0) {
|
||||||
rules[idx] = { ...rules[idx], name, resources, threshold, triggerType, triggerParam, email, interval, silentStart, silentEnd, silentEnabled, enabled };
|
rules[idx] = { ...rules[idx], name, resources, threshold, triggerType, triggerParam, email, interval, silentStart, silentEnd, silentEnabled, enabled, serviceId };
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 新增
|
// 新增
|
||||||
|
const svc = projects.find(p => p.id === serviceId);
|
||||||
rules.push({
|
rules.push({
|
||||||
id: 'rule_' + Date.now(),
|
id: 'rule_' + Date.now(),
|
||||||
name, resources, threshold, triggerType, triggerParam, email, interval, silentStart, silentEnd, silentEnabled, enabled,
|
name, resources, threshold, triggerType, triggerParam, email, interval, silentStart, silentEnd, silentEnabled, enabled,
|
||||||
|
serviceId, serviceName: svc ? svc.name : '',
|
||||||
|
offlineSince: null, // 服务下线开始时间
|
||||||
lastSent: 0,
|
lastSent: 0,
|
||||||
exceedCount: 0, // 连续超过次数
|
exceedCount: 0,
|
||||||
exceedDuration: 0, // 累计超过时间
|
exceedDuration: 0,
|
||||||
recentValues: [] // 最近采样值
|
recentValues: []
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1818,6 +1997,33 @@
|
|||||||
|
|
||||||
for (const resource of resources) {
|
for (const resource of resources) {
|
||||||
let value;
|
let value;
|
||||||
|
|
||||||
|
// 服务下线监控 - 特殊处理(不需要data参数)
|
||||||
|
if (resource === 'service_down') {
|
||||||
|
const svcId = rule.serviceId;
|
||||||
|
if (!svcId) continue;
|
||||||
|
const svc = projects.find(p => p.id === svcId);
|
||||||
|
if (!svc) continue;
|
||||||
|
const isOnline = svc.status?.status === 'running';
|
||||||
|
|
||||||
|
if (isOnline) {
|
||||||
|
rule.offlineSince = null;
|
||||||
|
} else {
|
||||||
|
if (!rule.offlineSince) {
|
||||||
|
rule.offlineSince = now;
|
||||||
|
}
|
||||||
|
const offlineMinutes = (now - rule.offlineSince) / 60000;
|
||||||
|
if (offlineMinutes >= rule.threshold) {
|
||||||
|
const svcName = svc.name || rule.serviceName || svcId;
|
||||||
|
exceededResources.push(`🔴 ${svcName} 已离线 ${Math.round(offlineMinutes)} 分钟 (阈值: ${rule.threshold} 分钟)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 系统资源监控需要 data 参数
|
||||||
|
if (!data) continue;
|
||||||
|
|
||||||
if (resource === 'cpu_temp') {
|
if (resource === 'cpu_temp') {
|
||||||
// CPU温度取两个Package中较高的那个
|
// CPU温度取两个Package中较高的那个
|
||||||
const t0 = data.cpu?.temp_package_0;
|
const t0 = data.cpu?.temp_package_0;
|
||||||
@@ -2032,6 +2238,7 @@
|
|||||||
const newService = {
|
const newService = {
|
||||||
id: 'custom_' + Date.now(),
|
id: 'custom_' + Date.now(),
|
||||||
name: name,
|
name: name,
|
||||||
|
port: port || '',
|
||||||
url: mainUrl,
|
url: mainUrl,
|
||||||
admin_url: adminUrl || null,
|
admin_url: adminUrl || null,
|
||||||
description: desc || '自定义外部服务',
|
description: desc || '自定义外部服务',
|
||||||
@@ -2549,6 +2756,184 @@
|
|||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
fetch('/api/projects').then(() => updateConnectionStatus(true)).catch(() => updateConnectionStatus(false));
|
fetch('/api/projects').then(() => updateConnectionStatus(true)).catch(() => updateConnectionStatus(false));
|
||||||
}, 10000);
|
}, 10000);
|
||||||
|
|
||||||
|
// ==================== Docker 监控 ====================
|
||||||
|
|
||||||
|
async function loadDockerData() {
|
||||||
|
try {
|
||||||
|
const [containersRes, imagesRes, systemRes] = await Promise.all([
|
||||||
|
fetch('/api/docker/containers'),
|
||||||
|
fetch('/api/docker/images'),
|
||||||
|
fetch('/api/docker/system')
|
||||||
|
]);
|
||||||
|
const containers = (await containersRes.json()).containers || [];
|
||||||
|
const images = (await imagesRes.json()).images || [];
|
||||||
|
const system = await systemRes.json();
|
||||||
|
renderDockerStats(containers, images, system);
|
||||||
|
renderDockerContainers(containers);
|
||||||
|
renderDockerImages(images);
|
||||||
|
document.getElementById('dockerVersion').textContent = system.version || '-';
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Docker数据加载失败:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDockerStats(containers, images, system) {
|
||||||
|
const total = containers.length;
|
||||||
|
const running = containers.filter(c => c.state === 'running').length;
|
||||||
|
const stopped = total - running;
|
||||||
|
const imgTotal = images.length;
|
||||||
|
document.getElementById('dockerContainersTotal').textContent = total;
|
||||||
|
document.getElementById('dockerContainersRunning').textContent = running;
|
||||||
|
document.getElementById('dockerContainersStopped').textContent = stopped;
|
||||||
|
document.getElementById('dockerImagesTotal').textContent = imgTotal;
|
||||||
|
document.getElementById('dockerContainerCount').textContent = `(${running}运行 / ${total}总计)`;
|
||||||
|
document.getElementById('dockerImageCount').textContent = `(${imgTotal}个)`;
|
||||||
|
const imagesDisk = system.diskUsage?.find(d => d.type === 'Images');
|
||||||
|
if (imagesDisk) {
|
||||||
|
document.getElementById('dockerDiskSize').textContent = imagesDisk.size;
|
||||||
|
document.getElementById('dockerReclaimable').textContent = imagesDisk.reclaimable;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDockerContainers(containers) {
|
||||||
|
const list = document.getElementById('dockerContainersList');
|
||||||
|
if (containers.length === 0) {
|
||||||
|
list.innerHTML = '<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('dockerImagesList');
|
||||||
|
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"><none>:<none></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}</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>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
Reference in New Issue
Block a user