Compare commits

..

15 Commits

Author SHA1 Message Date
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
27a84de317 feat: 邮件规则新增服务下线监控
- 监控资源新增「服务下线监控」复选框
- 勾选后出现服务选择器,列出所有Web服务
- 阈值单位为分钟(默认5分钟),范围1~1440
- 触发类型自动锁定为duration,简化配置
- checkEmailRules支持null参数,仅检查服务下线
- loadProjects同步调用,服务状态刷新即时检测
- 离线计时:服务下线时记录时间戳,恢复时自动重置
- 规则列表特殊显示:🔴 服务名 下线 ≥ X分钟
2026-06-01 17:50:02 +08:00
89b5a69325 fix: 添加邮件请求日志,便于排查发送问题 2026-06-01 17:44:39 +08:00
23810cf180 fix: 邮件发送改为 sendmail + Date头,对齐已验证脚本
- send_message() → sendmail()(与 send_email.py 一致)
- 新增 Date 头(部分SMTP服务器缺少会拦截)
- 增加发送前/后详细日志
2026-06-01 17:37:39 +08:00
16400b0359 fix: 修复邮件发送通道
- 发件账号改为 hz4th_coder@tphai.com(替换无效的 favor@tphai.com)
- 发件人名称: 黄庄4号程序员
- 连接前添加 server.ehlo() 调用(对齐 send_email.py 工作脚本)
- 无SSL/无STARTTLS 模式
2026-06-01 17:28:31 +08:00
df319b9c4a feat: 邮件通知规则三大增强
1. 新增CPU温度监控资源 - 取双CPU较高温度值判断
2. 支持多资源随机组合监控 - 复选框替代单选,任一资源超标即触发
3. 新增测试发送按钮 - 可输入收件邮箱即时测试邮件通道
- 旧版单resource字段兼容: resources数组优先,无则回退resource
- checkEmailRules重构: 逐资源独立计算触发状态,分别统计连续次数/持续时间/平均值
2026-06-01 17:22:44 +08:00
ecbba836d9 feat: 监控阈值设置新增CPU温度项
- 默认阈值: 80°C (范围 30-120°C)
- 同时监控 CPU0 和 CPU1 (双路服务器)
- 超温时弹窗警告 + 桌面通知
2026-06-01 17:13:06 +08:00
bb19141edc fix: 修复Web服务监控区域状态显示问题
后端 status 字段从字符串改为结构化对象 {status, ports: {port: {running}}}
前端期望的是 p.status.status 和 p.status.ports[port].running,之前的字符串格式导致:
- 所有服务状态始终显示「未知」
- 所有端口始终显示红色(已停止)
2026-06-01 17:08:09 +08:00
c960048042 fix: 重写routes_system.py修复API错误
完整重写系统监控API,确保:
- 正确的数据结构匹配JavaScript期待
- system.uptime/system.boot_time/system.process_count
- cpu.count/cpu.logical
- disk.free_gb
- network.sent_mb/recv_mb/sent_speed/recv_speed
- 所有异常处理和日志记录

修复500错误,现在系统监控应该正常显示
2026-06-01 16:19:59 +08:00
51f7ee9ad0 fix: 修正系统监控API返回数据结构
匹配JavaScript期待的数据结构:
- system.uptime(系统运行时间)
- system.boot_time(启动时间)
- system.process_count(进程数)
- cpu.count/cpu.logical(CPU核心数)
- disk.free_gb(磁盘剩余空间)
- network.sent_mb/recv_mb(累计流量)
- network.sent_speed/recv_speed(实时网速)

确保前端能正确显示所有系统资源信息
2026-06-01 16:17:47 +08:00
ea904fb2b2 fix: 清理templates_full.txt开头的Python代码
移除模板文件开头的Python代码片段:
- '# ==================== HTML 模板 ===================='
- 'HTML_TEMPLATE = '''

确保templates_full.txt是纯粹的HTML模板,从<!DOCTYPE html>开始

这是导致浏览器console报错的根本原因:
- Python代码片段会被浏览器当作JavaScript解析
- 导致JavaScript执行失败,函数未定义
- closeEmailRuleModal等函数无法正常工作
2026-06-01 16:12:37 +08:00
21240dc108 fix: 替换模板中的默认IP地址为121.40.164.32
修复JavaScript代码中的默认IP值:
- localStorage.getItem('externalIp') || '192.168.2.17'
- 改为:|| '121.40.164.32'

确保所有默认值统一为正确的对外IP地址
2026-06-01 16:11:19 +08:00
1d7f4c2bed fix: 清理模板文件末尾的多余代码
移除 templates_full.txt 末尾不应该存在的Python代码片段:
- 移除 ''' 字符串结束标记
- 移除 if __name__ == '__main__' 部分
- 确保HTML模板在 </html> 处正确结束

这个问题可能导致页面加载异常
2026-06-01 16:02:59 +08:00
88c11277cf fix: 修正版本号为 v3.4.1 2026-06-01 15:56:47 +08:00
e5ae822ed2 docs: 更新版本号为 v3.4.1 2026-06-01 15:55:19 +08:00
17 changed files with 992 additions and 30440 deletions

View File

@@ -4,7 +4,7 @@
## 版本信息
- **当前版本**: v3.4.0
- **当前版本**: v3.4.1
- **端口**: 16022
- **更新日期**: 2026-06-01
@@ -158,7 +158,13 @@ python3 app_main.py
## 更新日志
### v3.4.0 (2026-06-01)
### v3.4.1 (2026-06-01)
- 🎯 **模块化重构**: 将 3700+ 行单文件拆分为多个模块
- 📁 **清晰结构**: config、database、utils、routes、templates 分离
- 🔧 **易于维护**: 每个模块职责明确,便于扩展和维护
-**功能完整**: 保留所有原有网页功能
### v3.4.0 (2026-06-01) [已废弃]
- 🎯 **模块化重构**: 将 3700+ 行单文件拆分为多个模块
- 📁 **清晰结构**: config、database、utils、routes、templates 分离
- 🔧 **易于维护**: 每个模块职责明确,便于扩展和维护

Binary file not shown.

Binary file not shown.

View File

@@ -1,9 +1,10 @@
{
"thresholds": {
"cpu": 75,
"memory": 80,
"disk": 85,
"interval": 60
"interval": 60,
"memory": 80,
"cpu_temp": 70
},
"web_settings": {
"external_ip": "121.40.164.32",

2
app.py
View File

@@ -13,6 +13,7 @@ from routes_system import register_system_routes
from routes_config import register_config_routes
from routes_email import register_email_routes
from routes_project import register_project_routes
from routes_docker import register_docker_routes
from templates import get_html_template
@@ -31,6 +32,7 @@ def init_app():
register_config_routes(app)
register_email_routes(app)
register_project_routes(app)
register_docker_routes(app)
# 主页路由
@app.route('/')

View File

@@ -20,12 +20,13 @@ ALERT_CONFIG_FILE = 'alert_config.json'
# 应用配置
APP_NAME = "项目服务管理面板"
APP_VERSION = "v3.4.0"
APP_VERSION = "v3.4.1"
APP_PORT = 16022
# 监控配置
DEFAULT_THRESHOLDS = {
'cpu': 80,
'cpu_temp': 80,
'memory': 85,
'disk': 90,
'interval': 60

30374
logs/app.log

File diff suppressed because it is too large Load Diff

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

@@ -18,7 +18,9 @@ def register_email_routes(app):
def api_email_send():
"""发送邮件通知"""
data = request.get_json()
log_message(f"📨 收到邮件请求: {data}")
if not data:
log_message(f"❌ 无效数据: content-type={request.content_type}")
return jsonify({'error': '无效数据'}), 400
to_email = data.get('to')
@@ -26,33 +28,40 @@ def register_email_routes(app):
body = data.get('body')
if not to_email or not subject or not body:
log_message(f"❌ 缺少参数: to={to_email}, subject={subject}, body_len={len(body) if body else 0}")
return jsonify({'error': '缺少必要参数'}), 400
try:
# SMTP配置 (从环境变量或默认值)
smtp_host = os.environ.get('SMTP_HOST', 'mail.tphai.com')
smtp_port = int(os.environ.get('SMTP_PORT', 587))
smtp_user = os.environ.get('SMTP_USER', 'favor@tphai.com')
smtp_pass = os.environ.get('SMTP_PASS', 'favor@!')
smtp_user = os.environ.get('SMTP_USER', 'hz4th_coder@tphai.com')
smtp_pass = os.environ.get('SMTP_PASS', 'hz4th_coder@!')
# 创建邮件
log_message(f"📧 准备发送邮件: to={to_email}, subject={subject}, user={smtp_user}")
# 创建邮件(完全对齐 send_email.py 的方式)
from email.utils import formataddr, formatdate
msg = MIMEMultipart()
msg['From'] = smtp_user
msg['From'] = formataddr(('黄庄4号程序员', smtp_user))
msg['To'] = to_email
msg['Subject'] = subject
msg['Date'] = formatdate(localtime=True)
# 添加正文
msg.attach(MIMEText(body, 'plain', 'utf-8'))
# 发送邮件
# 发送邮件无SSL模式完全对齐 send_email.py
server = smtplib.SMTP(smtp_host, smtp_port)
server.ehlo()
server.login(smtp_user, smtp_pass)
server.send_message(msg)
# 使用 sendmail 而非 send_message与已验证成功的脚本一致
server.sendmail(smtp_user, [to_email], msg.as_string())
server.quit()
log_message(f"邮件发送成功: {subject} -> {to_email}")
log_message(f"邮件发送成功: {subject} -> {to_email}")
return jsonify({'message': '邮件发送成功'})
except Exception as e:
log_message(f"邮件发送失败: {e}")
log_message(f"邮件发送失败: {e}")
return jsonify({'error': str(e)}), 500

View File

@@ -20,10 +20,25 @@ def register_project_routes(app):
"""获取项目列表"""
projects = load_projects()
# 添加状态信息
# 添加状态信息(结构化对象,前端期望 status.status 和 status.ports
for project in projects:
ports = project.get('ports', [])
project['status'] = 'running' if all(check_port(p) for p in ports) else 'stopped'
port_statuses = {}
for p in ports:
port_statuses[str(p)] = {'running': check_port(p)}
running_count = sum(1 for ps in port_statuses.values() if ps['running'])
if running_count == len(ports) and len(ports) > 0:
overall = 'running'
elif running_count > 0:
overall = 'partial'
else:
overall = 'stopped'
project['status'] = {
'status': overall,
'ports': port_statuses
}
return jsonify({
'projects': projects,
@@ -169,10 +184,25 @@ def register_project_routes(app):
data = load_projects_full()
services = data.get('external_services', [])
# 添加状态
# 添加状态(结构化对象,前端期望 status.status 和 status.ports
for service in services:
ports = service.get('ports', [])
service['status'] = 'running' if all(check_port(p) for p in ports) else 'stopped'
port_statuses = {}
for p in ports:
port_statuses[str(p)] = {'running': check_port(p)}
running_count = sum(1 for ps in port_statuses.values() if ps['running'])
if running_count == len(ports) and len(ports) > 0:
overall = 'running'
elif running_count > 0:
overall = 'partial'
else:
overall = 'stopped'
service['status'] = {
'status': overall,
'ports': port_statuses
}
return jsonify({'services': services})

View File

@@ -6,7 +6,8 @@
import os
import subprocess
import time
from flask import jsonify
from datetime import datetime
from flask import jsonify, request
from utils import log_message, get_cpu_temperatures, load_alert_config
try:
@@ -46,6 +47,7 @@ def register_system_routes(app):
disk = psutil.disk_usage('/')
disk_total_gb = round(disk.total / (1024**3), 2)
disk_used_gb = round(disk.used / (1024**3), 2)
disk_free_gb = round(disk.free / (1024**3), 2)
disk_percent = disk.percent
# 网速(需要计算差值)
@@ -82,10 +84,15 @@ def register_system_routes(app):
return jsonify({
'available': True,
'system': {
'uptime': uptime_str,
'boot_time': datetime.fromtimestamp(boot_time).strftime('%Y-%m-%d %H:%M:%S'),
'process_count': process_count
},
'cpu': {
'percent': cpu_percent,
'count_physical': cpu_count,
'count_logical': cpu_count_logical,
'count': cpu_count,
'logical': cpu_count_logical,
'temp_package_0': cpu_temps.get('package_0'),
'temp_package_1': cpu_temps.get('package_1')
},
@@ -98,17 +105,19 @@ def register_system_routes(app):
'disk': {
'total_gb': disk_total_gb,
'used_gb': disk_used_gb,
'free_gb': disk_free_gb,
'percent': disk_percent
},
'network': {
'upload_speed_kb': round(upload_speed, 2),
'download_speed_kb': round(download_speed, 2)
},
'process_count': process_count,
'uptime': uptime_str
'sent_mb': round(net.bytes_sent / (1024**2), 2),
'recv_mb': round(net.bytes_recv / (1024**2), 2),
'sent_speed': round(upload_speed, 2),
'recv_speed': round(download_speed, 2)
}
})
except Exception as e:
return jsonify({'error': str(e)}), 500
log_message(f"系统监控API错误: {e}")
return jsonify({'error': str(e), 'available': False}), 500
@app.route('/api/system/processes')
def api_system_processes():
@@ -117,6 +126,9 @@ def register_system_routes(app):
return jsonify({'error': 'psutil未安装'}), 500
try:
limit = int(request.args.get('limit', 10))
limit = max(1, min(50, limit))
processes = []
for proc in psutil.process_iter(['pid', 'name', 'cpu_percent', 'memory_percent', 'status']):
try:
@@ -132,7 +144,7 @@ def register_system_routes(app):
# 按CPU排序
processes.sort(key=lambda x: x['cpu'], reverse=True)
processes = processes[:50] # 只返回前50个
processes = processes[:limit]
return jsonify({'processes': processes})
except Exception as e:
@@ -167,7 +179,6 @@ def register_system_routes(app):
@app.route('/api/alerts', methods=['POST'])
def api_save_alerts():
"""保存预警配置"""
from flask import request
import json
from config import ALERT_CONFIG_FILE

View File

@@ -16,9 +16,16 @@ def get_html_template():
with open(template_file, 'r', encoding='utf-8') as f:
template_content = f.read()
# 替换版本号
# 替换版本号(支持多种格式)
template_content = template_content.replace('v2.1', APP_VERSION)
template_content = template_content.replace('v3.3.1', APP_VERSION)
template_content = template_content.replace('v3.3.5', APP_VERSION)
template_content = template_content.replace('v3.4.0', APP_VERSION)
template_content = template_content.replace('v3.4.1', APP_VERSION) # 预留未来版本
# 替换默认IP地址
template_content = template_content.replace("'192.168.2.17'", "'121.40.164.32'")
template_content = template_content.replace('"192.168.2.17"', '"121.40.164.32"')
return template_content
except:

View File

@@ -1,6 +1,4 @@
# ==================== HTML 模板 ====================
HTML_TEMPLATE = '''<!DOCTYPE html>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
@@ -121,6 +119,9 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
<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 -->
@@ -307,6 +308,16 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
<span class="text-gray-500 text-xs">%</span>
</div>
</div>
<div class="threshold-item">
<div class="threshold-label">
<i class="ri-temp-hot-line threshold-icon text-red-400"></i>
<span class="text-gray-300 text-sm">CPU 温度</span>
</div>
<div class="flex items-center gap-2">
<input type="number" id="thresholdCpuTemp" class="threshold-input" min="30" max="120" value="80" onchange="saveThresholds()">
<span class="text-gray-500 text-xs">°C</span>
</div>
</div>
<div class="threshold-item">
<div class="threshold-label">
<i class="ri-database-2-line threshold-icon text-green-400"></i>
@@ -352,9 +363,14 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
<h4 class="text-gray-200 text-sm font-medium flex items-center gap-2">
<i class="ri-mail-line text-green-400"></i> 邮件通知规则
</h4>
<button onclick="showAddEmailRuleModal()" class="btn bg-green-600 hover:bg-green-700 px-2 py-1 rounded text-xs flex items-center gap-1">
<i class="ri-add-line"></i> 添加规则
</button>
<div class="flex items-center gap-2">
<button onclick="showTestEmailModal()" class="btn bg-blue-600 hover:bg-blue-700 px-2 py-1 rounded text-xs flex items-center gap-1">
<i class="ri-send-plane-line"></i> 测试发送
</button>
<button onclick="showAddEmailRuleModal()" class="btn bg-green-600 hover:bg-green-700 px-2 py-1 rounded text-xs flex items-center gap-1">
<i class="ri-add-line"></i> 添加规则
</button>
</div>
</div>
<div id="emailRulesList" class="space-y-2 text-sm">
<div class="text-gray-500 text-center py-4">暂无邮件通知规则</div>
@@ -472,6 +488,72 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
<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">
<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>
<!-- 日志模态框 -->
@@ -663,20 +745,54 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
</div>
<div class="mb-3">
<label class="block text-gray-400 text-sm mb-1">监控资源 *</label>
<select id="emailRuleResource" class="w-full bg-gray-700 text-gray-200 px-3 py-2 rounded-lg">
<option value="cpu">CPU 使用率</option>
<option value="memory">内存使用率</option>
<option value="disk">磁盘使用率</option>
<label class="block text-gray-400 text-sm mb-1">监控资源 *(可多选组合)</label>
<div class="grid grid-cols-2 gap-2">
<label class="flex items-center gap-2 bg-gray-700/50 px-3 py-2 rounded-lg cursor-pointer hover:bg-gray-700">
<input type="checkbox" value="cpu" class="email-rule-resource w-4 h-4" onchange="onResourceChange()">
<i class="ri-cpu-line text-blue-400"></i>
<span class="text-gray-200 text-sm">CPU 使用率</span>
</label>
<label class="flex items-center gap-2 bg-gray-700/50 px-3 py-2 rounded-lg cursor-pointer hover:bg-gray-700">
<input type="checkbox" value="cpu_temp" class="email-rule-resource w-4 h-4" onchange="onResourceChange()">
<i class="ri-temp-hot-line text-red-400"></i>
<span class="text-gray-200 text-sm">CPU 温度</span>
</label>
<label class="flex items-center gap-2 bg-gray-700/50 px-3 py-2 rounded-lg cursor-pointer hover:bg-gray-700">
<input type="checkbox" value="memory" class="email-rule-resource w-4 h-4" onchange="onResourceChange()">
<i class="ri-database-2-line text-green-400"></i>
<span class="text-gray-200 text-sm">内存使用率</span>
</label>
<label class="flex items-center gap-2 bg-gray-700/50 px-3 py-2 rounded-lg cursor-pointer hover:bg-gray-700">
<input type="checkbox" value="disk" class="email-rule-resource w-4 h-4" onchange="onResourceChange()">
<i class="ri-hard-drive-2-line text-orange-400"></i>
<span class="text-gray-200 text-sm">磁盘使用率</span>
</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>
<p class="text-gray-500 text-xs mt-1">勾选多项可随机组合监控,任一资源超标即触发通知</p>
</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">
<label class="block text-gray-400 text-sm mb-1">触发阈值 *</label>
<div class="flex items-center gap-2">
<input type="number" id="emailRuleThreshold" required min="1" max="100" value="80" class="w-20 bg-gray-700 text-gray-200 px-3 py-2 rounded-lg">
<span class="text-gray-400">%</span>
<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>
</div>
<p class="text-gray-500 text-xs mt-1" id="emailRuleThresholdHint">对不同资源类型使用统一阈值如CPU用%则填百分比温度用°C则填温度值</p>
</div>
<div class="mb-3">
@@ -736,6 +852,45 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
</div>
</div>
<!-- 测试邮件发送模态框 -->
<div id="testEmailModal" class="modal-overlay hidden">
<div class="card rounded-xl modal-content p-6" style="max-width: 420px;">
<div class="flex items-center justify-between mb-4">
<h3 class="font-bold text-lg"><i class="ri-send-plane-line text-blue-400"></i> 测试邮件发送</h3>
<button onclick="closeTestEmailModal()" class="text-gray-400 hover:text-white"><i class="ri-close-line text-xl"></i></button>
</div>
<form id="testEmailForm" onsubmit="sendTestEmail(event)">
<div class="mb-3">
<label class="block text-gray-400 text-sm mb-1">收件邮箱 *</label>
<input type="email" id="testEmailAddress" required class="w-full bg-gray-700 text-gray-200 px-3 py-2 rounded-lg" placeholder="your@email.com">
</div>
<div class="mb-3">
<label class="block text-gray-400 text-sm mb-1">邮件主题</label>
<input type="text" id="testEmailSubject" class="w-full bg-gray-700 text-gray-200 px-3 py-2 rounded-lg" value="[测试邮件] 项目服务管理面板">
</div>
<div class="mb-4">
<label class="block text-gray-400 text-sm mb-1">邮件内容</label>
<textarea id="testEmailBody" rows="3" class="w-full bg-gray-700 text-gray-200 px-3 py-2 rounded-lg">这是一封测试邮件,用于验证邮件通知功能是否正常工作。
发送自: 项目服务管理面板
时间: ${new Date().toLocaleString()}</textarea>
</div>
<div id="testEmailResult" class="mb-3 hidden"></div>
<div class="flex justify-end gap-2">
<button type="button" onclick="closeTestEmailModal()" class="btn bg-gray-600 hover:bg-gray-700 px-4 py-2 rounded-lg">取消</button>
<button type="submit" class="btn bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded-lg flex items-center gap-1">
<i class="ri-send-plane-line"></i> 发送测试
</button>
</div>
</form>
</div>
</div>
<!-- 导航按钮 -->
<div class="nav-buttons">
<button onclick="scrollToTop()" class="nav-btn" title="回到顶部"><i class="ri-arrow-up-line"></i></button>
@@ -758,7 +913,7 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
let realtimeTimer = null;
let realtimeInterval = 2; // 秒
let lastAlertTime = 0; // 上次警告时间
let thresholds = { cpu: 80, memory: 85, disk: 90, interval: 60 };
let thresholds = { cpu: 80, cpu_temp: 80, memory: 85, disk: 90, interval: 60 };
let desktopNotifyEnabled = false;
function switchTab(tab) {
@@ -769,6 +924,9 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
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) {
@@ -801,6 +959,7 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
if (config.thresholds) {
thresholds = config.thresholds;
document.getElementById('thresholdCpu').value = thresholds.cpu || 80;
document.getElementById('thresholdCpuTemp').value = thresholds.cpu_temp || 80;
document.getElementById('thresholdMemory').value = thresholds.memory || 85;
document.getElementById('thresholdDisk').value = thresholds.disk || 90;
document.getElementById('thresholdInterval').value = thresholds.interval || 60;
@@ -813,6 +972,7 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
// 保存阈值设置
async function saveThresholds() {
thresholds.cpu = parseInt(document.getElementById('thresholdCpu').value) || 80;
thresholds.cpu_temp = parseInt(document.getElementById('thresholdCpuTemp').value) || 80;
thresholds.memory = parseInt(document.getElementById('thresholdMemory').value) || 85;
thresholds.disk = parseInt(document.getElementById('thresholdDisk').value) || 90;
thresholds.interval = parseInt(document.getElementById('thresholdInterval').value) || 60;
@@ -937,6 +1097,12 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
if (data.disk.percent >= thresholds.disk) {
warnings.push(`磁盘使用率 ${data.disk.percent.toFixed(1)}% 超过阈值 ${thresholds.disk}%`);
}
if (data.cpu.temp_package_0 !== null && data.cpu.temp_package_0 >= thresholds.cpu_temp) {
warnings.push(`CPU0 温度 ${data.cpu.temp_package_0.toFixed(1)}°C 超过阈值 ${thresholds.cpu_temp}°C`);
}
if (data.cpu.temp_package_1 !== null && data.cpu.temp_package_1 >= thresholds.cpu_temp) {
warnings.push(`CPU1 温度 ${data.cpu.temp_package_1.toFixed(1)}°C 超过阈值 ${thresholds.cpu_temp}°C`);
}
if (warnings.length > 0) {
lastAlertTime = now;
@@ -1166,6 +1332,8 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
fetch('/api/system/stats').then(r => r.json()).then(sysData => {
if (sysData.available) checkThresholds(sysData);
}).catch(() => {});
// 同时检查服务下线邮件规则
checkEmailRules(null);
} catch (e) {
console.error('加载失败:', e);
@@ -1482,7 +1650,8 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
return;
}
const resourceNames = { 'cpu': 'CPU', 'memory': '内存', 'disk': '磁盘' };
const resourceNames = { 'cpu': 'CPU', 'cpu_temp': 'CPU温度', 'memory': '内存', 'disk': '磁盘', 'service_down': '服务下线' };
const resourceUnits = { 'cpu': '%', 'cpu_temp': '°C', 'memory': '%', 'disk': '%' };
list.innerHTML = rules.map(rule => {
const silentInfo = rule.silentEnabled ? `静默:${rule.silentStart}-${rule.silentEnd}` : '';
@@ -1493,12 +1662,21 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
else if (triggerType === 'duration') triggerDisplay = `持续${triggerParam}秒`;
else if (triggerType === 'average') triggerDisplay = `平均${triggerParam}次`;
// 显示资源列表(支持多资源和旧版单资源兼容)
const resources = rule.resources || (rule.resource ? [rule.resource] : []);
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 `
<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">
<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-400 text-xs">${resourceNames[rule.resource]} ≥ ${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-gray-400 text-xs">→ ${rule.email}</span>
${silentInfo ? `<span class="text-yellow-400/70 text-xs"><i class="ri-moon-line"></i> ${silentInfo}</span>` : ''}
@@ -1516,9 +1694,15 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
document.getElementById('emailRuleModalTitle').textContent = '添加邮件通知规则';
document.getElementById('emailRuleId').value = '';
document.getElementById('emailRuleName').value = '';
document.getElementById('emailRuleResource').value = 'cpu';
// 重置资源复选框
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('emailRuleThresholdUnit').textContent = '% / °C';
document.getElementById('emailRuleThresholdHint').textContent = '对不同资源类型使用统一阈值';
document.getElementById('emailRuleTriggerType').value = 'single';
document.getElementById('emailRuleTriggerType').disabled = false;
document.getElementById('emailRuleTriggerParam').value = 3;
document.getElementById('triggerParamsDiv').style.display = 'none';
document.getElementById('emailRuleAddress').value = '';
@@ -1534,6 +1718,82 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
document.getElementById('emailRuleModal').classList.add('hidden');
}
function getSelectedResources() {
const checkboxes = document.querySelectorAll('.email-rule-resource:checked');
return Array.from(checkboxes).map(cb => cb.value);
}
function onResourceChange() {
const selected = getSelectedResources();
const unitEl = document.getElementById('emailRuleThresholdUnit');
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');
const hasServiceDown = selected.includes('service_down');
// 显示/隐藏服务选择器
if (hasServiceDown) {
serviceSelector.style.display = 'block';
populateServiceSelector();
// 服务下线使用分钟作为阈值单位
unitEl.textContent = '分钟';
thresholdEl.value = 5;
thresholdEl.max = 1440;
thresholdEl.min = 1;
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';
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() {
const triggerType = document.getElementById('emailRuleTriggerType').value;
const paramsDiv = document.getElementById('triggerParamsDiv');
@@ -1582,7 +1842,19 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
document.getElementById('emailRuleModalTitle').textContent = '编辑邮件通知规则';
document.getElementById('emailRuleId').value = rule.id;
document.getElementById('emailRuleName').value = rule.name;
document.getElementById('emailRuleResource').value = rule.resource;
// 重置所有复选框
document.querySelectorAll('.email-rule-resource').forEach(cb => cb.checked = false);
// 勾选已保存的资源支持新版resources数组和旧版resource字符串兼容
const resources = rule.resources || (rule.resource ? [rule.resource] : []);
resources.forEach(r => {
const cb = document.querySelector(`.email-rule-resource[value="${r}"]`);
if (cb) cb.checked = true;
});
onResourceChange();
// 服务下线相关
if (rule.serviceId) {
document.getElementById('emailRuleServiceId').value = rule.serviceId;
}
document.getElementById('emailRuleThreshold').value = rule.threshold;
document.getElementById('emailRuleTriggerType').value = rule.triggerType || 'single';
document.getElementById('emailRuleTriggerParam').value = rule.triggerParam || 3;
@@ -1601,7 +1873,7 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
const ruleId = document.getElementById('emailRuleId').value;
const name = document.getElementById('emailRuleName').value.trim();
const resource = document.getElementById('emailRuleResource').value;
const resources = getSelectedResources();
const threshold = parseInt(document.getElementById('emailRuleThreshold').value);
const triggerType = document.getElementById('emailRuleTriggerType').value;
const triggerParam = parseInt(document.getElementById('emailRuleTriggerParam').value) || 3;
@@ -1611,29 +1883,44 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
const silentEnd = document.getElementById('emailRuleSilentEnd').value;
const silentEnabled = document.getElementById('emailRuleSilentEnabled').checked;
const enabled = document.getElementById('emailRuleEnabled').checked;
const serviceId = document.getElementById('emailRuleServiceId').value;
if (!name || !email) {
alert('请填写完整信息');
return;
}
if (resources.length === 0) {
alert('请至少选择一个监控资源');
return;
}
// 服务下线必须选择具体服务
if (resources.includes('service_down') && !serviceId) {
alert('请选择要监控的服务');
return;
}
const rules = getEmailRules();
if (ruleId) {
// 编辑
const idx = rules.findIndex(r => r.id === ruleId);
if (idx >= 0) {
rules[idx] = { ...rules[idx], name, resource, 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 {
// 新增
const svc = projects.find(p => p.id === serviceId);
rules.push({
id: 'rule_' + Date.now(),
name, resource, 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,
exceedCount: 0, // 连续超过次数
exceedDuration: 0, // 累计超过时间
recentValues: [] // 最近采样值
exceedCount: 0,
exceedDuration: 0,
recentValues: []
});
}
@@ -1683,77 +1970,117 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
}
}
// 检查阈值和触发机制
const value = data[rule.resource]?.percent;
if (value === undefined) continue;
// 检查阈值和触发机制(支持多资源)
const resources = rule.resources || (rule.resource ? [rule.resource] : []);
const resourceNames = { 'cpu': 'CPU使用率', 'cpu_temp': 'CPU温度', 'memory': '内存使用率', 'disk': '磁盘使用率' };
// 初始化触发相关数据
if (!rule.exceedCount) rule.exceedCount = 0;
if (!rule.exceedDuration) rule.exceedDuration = 0;
if (!rule.recentValues) rule.recentValues = [];
if (!rule.triggerType) rule.triggerType = 'single';
if (!rule.triggerParam) rule.triggerParam = 3;
// 收集所有超标的资源
const exceededResources = [];
const triggerType = rule.triggerType;
const triggerParam = rule.triggerParam;
const isExceedThreshold = value >= rule.threshold;
let shouldTrigger = false;
// 根据触发类型判断
switch (triggerType) {
case 'single':
// 一次性触发:超过阈值立即触发
if (isExceedThreshold) shouldTrigger = true;
break;
for (const resource of resources) {
let value;
case 'continuous':
// 连续触发连续超过阈值N次
if (isExceedThreshold) {
rule.exceedCount++;
if (rule.exceedCount >= triggerParam) {
shouldTrigger = true;
rule.exceedCount = 0; // 触发后重置计数
}
// 服务下线监控 - 特殊处理不需要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 {
rule.exceedCount = 0; // 未超过阈值时重置计数
}
break;
case 'duration':
// 持续时间触发累计超过阈值N秒
if (isExceedThreshold) {
// 增加累计时间假设实时监控间隔为2秒
const intervalSeconds = realtimeInterval || 2;
rule.exceedDuration += intervalSeconds;
if (rule.exceedDuration >= triggerParam) {
shouldTrigger = true;
rule.exceedDuration = 0; // 触发后重置
if (!rule.offlineSince) {
rule.offlineSince = now;
}
} else {
rule.exceedDuration = 0; // 未超过阈值时重置累计时间
}
break;
case 'average':
// 平均值触发最近N次平均值超过阈值
rule.recentValues.push(value);
if (rule.recentValues.length > triggerParam) {
rule.recentValues.shift(); // 保持最近N个值
}
if (rule.recentValues.length >= triggerParam) {
const avg = rule.recentValues.reduce((a, b) => a + b, 0) / rule.recentValues.length;
if (avg >= rule.threshold) {
shouldTrigger = true;
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} 分钟)`);
}
}
break;
continue;
}
// 系统资源监控需要 data 参数
if (!data) continue;
if (resource === 'cpu_temp') {
// CPU温度取两个Package中较高的那个
const t0 = data.cpu?.temp_package_0;
const t1 = data.cpu?.temp_package_1;
if (t0 === null && t1 === null) continue;
if (t0 === null) value = t1;
else if (t1 === null) value = t0;
else value = Math.max(t0, t1);
} else {
value = data[resource]?.percent;
}
if (value === undefined || value === null) continue;
const isExceedThreshold = value >= rule.threshold;
// 初始化触发相关数据key后缀避免不同资源冲突
const ck = '_' + resource;
if (!rule['exceedCount' + ck]) rule['exceedCount' + ck] = 0;
if (!rule['exceedDuration' + ck]) rule['exceedDuration' + ck] = 0;
if (!rule['recentValues' + ck]) rule['recentValues' + ck] = [];
const triggerType = rule.triggerType || 'single';
const triggerParam = rule.triggerParam || 3;
let resourceTriggered = false;
switch (triggerType) {
case 'single':
if (isExceedThreshold) resourceTriggered = true;
break;
case 'continuous':
if (isExceedThreshold) {
rule['exceedCount' + ck]++;
if (rule['exceedCount' + ck] >= triggerParam) {
resourceTriggered = true;
rule['exceedCount' + ck] = 0;
}
} else {
rule['exceedCount' + ck] = 0;
}
break;
case 'duration':
if (isExceedThreshold) {
const intervalSec = realtimeInterval || 2;
rule['exceedDuration' + ck] += intervalSec;
if (rule['exceedDuration' + ck] >= triggerParam) {
resourceTriggered = true;
rule['exceedDuration' + ck] = 0;
}
} else {
rule['exceedDuration' + ck] = 0;
}
break;
case 'average':
rule['recentValues' + ck].push(value);
if (rule['recentValues' + ck].length > triggerParam) {
rule['recentValues' + ck].shift();
}
if (rule['recentValues' + ck].length >= triggerParam) {
const avg = rule['recentValues' + ck].reduce((a, b) => a + b, 0) / rule['recentValues' + ck].length;
if (avg >= rule.threshold) resourceTriggered = true;
}
break;
}
if (resourceTriggered) {
const unit = resource === 'cpu_temp' ? '°C' : '%';
exceededResources.push(`${resourceNames[resource]}: ${value.toFixed(1)}${unit} (阈值: ${rule.threshold}${unit})`);
}
}
// 保存更新的规则数据
saveEmailRules(getEmailRules().map(r => r.id === rule.id ? rule : r));
if (!shouldTrigger) continue;
if (exceededResources.length === 0) continue;
// 发送邮件
try {
@@ -1763,7 +2090,7 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
body: JSON.stringify({
to: rule.email,
subject: `[系统告警] ${rule.name}`,
body: `${rule.name} 触发告警\n\n${rule.resource.toUpperCase()} 使用率: ${value.toFixed(1)}%\n阈值: ${rule.threshold}%\n时间: ${new Date().toLocaleString()}\n\n请及时处理。`
body: `${rule.name} 触发告警\n\n${exceededResources.join('\n')}\n\n时间: ${new Date().toLocaleString()}\n\n请及时处理。`
})
});
@@ -1778,6 +2105,65 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
}
}
// ==================== 测试邮件发送 ====================
function showTestEmailModal() {
document.getElementById('testEmailAddress').value = '';
document.getElementById('testEmailSubject').value = '[测试邮件] 项目服务管理面板';
document.getElementById('testEmailBody').value = '这是一封测试邮件,用于验证邮件通知功能是否正常工作。\n\n发送自: 项目服务管理面板\n时间: ' + new Date().toLocaleString();
document.getElementById('testEmailResult').classList.add('hidden');
document.getElementById('testEmailModal').classList.remove('hidden');
}
function closeTestEmailModal() {
document.getElementById('testEmailModal').classList.add('hidden');
}
async function sendTestEmail(event) {
event.preventDefault();
const email = document.getElementById('testEmailAddress').value.trim();
const subject = document.getElementById('testEmailSubject').value.trim();
const body = document.getElementById('testEmailBody').value.trim();
const resultEl = document.getElementById('testEmailResult');
const submitBtn = event.target.querySelector('button[type="submit"]');
if (!email) {
alert('请输入收件邮箱');
return;
}
submitBtn.disabled = true;
submitBtn.innerHTML = '<i class="ri-loader-4-line animate-spin"></i> 发送中...';
resultEl.classList.add('hidden');
try {
const res = await fetch('/api/email/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ to: email, subject, body })
});
const data = await res.json();
if (res.ok) {
resultEl.className = 'mb-3 p-3 rounded-lg bg-green-500/20 text-green-400 text-sm';
resultEl.innerHTML = `<i class="ri-checkbox-circle-line"></i> 测试邮件发送成功!<br>收件人: ${email}`;
} else {
resultEl.className = 'mb-3 p-3 rounded-lg bg-red-500/20 text-red-400 text-sm';
resultEl.innerHTML = `<i class="ri-error-warning-line"></i> 发送失败: ${data.error || '未知错误'}`;
}
resultEl.classList.remove('hidden');
} catch (e) {
resultEl.className = 'mb-3 p-3 rounded-lg bg-red-500/20 text-red-400 text-sm';
resultEl.innerHTML = `<i class="ri-error-warning-line"></i> 网络错误: ${e.message}`;
resultEl.classList.remove('hidden');
} finally {
submitBtn.disabled = false;
submitBtn.innerHTML = '<i class="ri-send-plane-line"></i> 发送测试';
}
}
function showAddServiceModal() {
document.getElementById('serviceName').value = '';
document.getElementById('servicePort').value = '';
@@ -2351,16 +2737,130 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
setInterval(() => {
fetch('/api/projects').then(() => updateConnectionStatus(true)).catch(() => updateConnectionStatus(false));
}, 10000);
// ==================== Docker 监控 ====================
async function loadDockerData() {
try {
const [containersRes, imagesRes, systemRes] = await Promise.all([
fetch('/api/docker/containers'),
fetch('/api/docker/images'),
fetch('/api/docker/system')
]);
const containers = (await containersRes.json()).containers || [];
const images = (await imagesRes.json()).images || [];
const system = await systemRes.json();
renderDockerStats(containers, images, system);
renderDockerContainers(containers);
renderDockerImages(images);
document.getElementById('dockerVersion').textContent = system.version || '-';
} catch (e) {
console.error('Docker数据加载失败:', e);
}
}
function renderDockerStats(containers, images, system) {
const total = containers.length;
const running = containers.filter(c => c.state === 'running').length;
const stopped = total - running;
const imgTotal = images.length;
document.getElementById('dockerContainersTotal').textContent = total;
document.getElementById('dockerContainersRunning').textContent = running;
document.getElementById('dockerContainersStopped').textContent = stopped;
document.getElementById('dockerImagesTotal').textContent = imgTotal;
document.getElementById('dockerContainerCount').textContent = `(${running}运行 / ${total}总计)`;
document.getElementById('dockerImageCount').textContent = `(${imgTotal}个)`;
const imagesDisk = system.diskUsage?.find(d => d.type === 'Images');
if (imagesDisk) {
document.getElementById('dockerDiskSize').textContent = imagesDisk.size;
document.getElementById('dockerReclaimable').textContent = imagesDisk.reclaimable;
}
}
function renderDockerContainers(containers) {
const list = document.getElementById('dockerContainersList');
if (containers.length === 0) {
list.innerHTML = '<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">&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); }
}
</script>
</body>
</html>
'''
if __name__ == '__main__':
os.makedirs(os.path.join(BASE_DIR, 'logs'), exist_ok=True)
os.makedirs(CRON_BACKUP_DIR, exist_ok=True)
init_db()
log_message("=" * 50)
log_message("项目服务管理面板 v2.0.0 启动")
</html>