Compare commits

..

17 Commits
v3.4.3 ... dev

Author SHA1 Message Date
633542a2be feat: Web服务区域新增添加Web服务功能
- 添加新增Web服务按钮到筛选栏
- 实现完整的Web服务添加表单(ID、名称、端口、目录、启动命令等)
- 新增的Web服务具有所有现有功能(启停、日志、状态监控)
- 移除之前的文本块功能
2026-06-25 12:29:29 +08:00
ffe3dd1fc3 feat: Web服务区域新增文本块功能
- 添加新增文本块按钮到筛选栏
- 实现文本块增删改查功能
- 支持多种颜色主题(蓝/绿/黄/红/紫/灰)
- 文本块显示在Web服务网格中
- 数据存储在localStorage
2026-06-25 12:19:26 +08:00
701889d22f Merge remote-tracking branch 'origin/master' into dev 2026-06-25 12:11:52 +08:00
a155e8fcd9 feat: 更新项目服务、工具函数和模板 2026-06-25 12:11:16 +08:00
1c97f3bd1e chore: 创建dev分支,端口改为16023 2026-06-25 12:05:55 +08:00
0c54af8d26 feat: Web服务和Docker监控区支持折叠/展开
- 所有分组标题(Web服务/自定义服务/归档服务等)可点击折叠
- Docker容器列表、镜像列表标题可点击折叠
- 点击标题切换 chevron 旋转动画(→↓)
- 折叠状态持久化到 localStorage,刷新保持
2026-06-01 22:35:57 +08:00
f7722be539 fix: 系统资源面板未开实时监控时按页面刷新间隔自动更新
- 新增 systemRefreshTimer,切换至系统Tab时自动启动
- 未开实时监控 → 按页面「刷新间隔」秒数定时刷新系统资源
- 开启实时监控 → 按实时间隔秒数刷新(原有逻辑不变)
- 关闭实时监控 → 切回页面刷新间隔继续更新
- 修改页面刷新间隔 → 同步更新系统资源刷新频率
- 新增 watchdog.sh 守护脚本,端口掉线自动重启
2026-06-01 20:35:07 +08:00
779f42c98c fix: 服务发现支持无权限查看的端口(Docker host网络等)
- ss -tlnp 输出中没有 pid= 的端口不再跳过
- pid 为 None 时显示「受权限限制」, 进程名标记「未知进程」
- 当前发现: 8088, 9119, 16031, 16067, 18789
2026-06-01 18:32:10 +08:00
525620b0f8 fix: 自定义服务保存时添加port字段,修复发现扫描排除失效
之前 quickAddService 保存自定义服务时遗漏了 port 字段,
导致 scanServices 的 exclude_ports 取不到有效端口值
2026-06-01 18:28:44 +08:00
ebc8d687d0 fix: 服务发现排除前端已注册的自定义服务
- API 新增 exclude_ports 查询参数,接受逗号分隔端口列表
- 前端 scanServices() 读取 localStorage 自定义服务端口传给后端
- 已添加的服务不再出现在扫描结果中
2026-06-01 18:23:23 +08:00
294526b403 feat: 服务发现 - 自动扫描本机未注册的新服务
方案C: 端口扫描 + 进程信息增强

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

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

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

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",

4
app.py
View File

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

View File

@@ -21,7 +21,7 @@ ALERT_CONFIG_FILE = 'alert_config.json'
# 应用配置
APP_NAME = "项目服务管理面板"
APP_VERSION = "v3.4.1"
APP_PORT = 16022
APP_PORT = 16023
# 监控配置
DEFAULT_THRESHOLDS = {

476562
logs/app.log

File diff suppressed because it is too large Load Diff

1
logs/watchdog.log Normal file
View File

@@ -0,0 +1 @@
[2026-06-01 20:29:22] 守护进程启动,监控端口 16022

View File

@@ -10,29 +10,35 @@
"id": "pdf-translate-web",
"name": "PDF翻译助手",
"type": "web",
"ports": [16021],
"directory": "works/pdf-translate-web",
"ports": [
16021
],
"directory": "pdf-translate-web",
"start_cmd": "mkdir -p logs && nohup /home/hz1/miniconda3/envs/openclaw/bin/python3.12 app.py > logs/app.log 2>&1 & disown",
"stop_cmd": "pkill -f 'python.*pdf-translate-web.*app.py' || true",
"health_url": "http://localhost:16021/",
"description": "英文PDF翻译中文网站支持用户系统、会员体系",
"admin_url": "http://localhost:16021/admin",
"git_repo": "http://121.40.164.32:12007/coder/pdf-translate-web",
"version": "v2.0.0"
"version": "v2.0.0",
"log_file": ""
},
{
"id": "project-panel",
"name": "项目服务管理面板",
"type": "web",
"ports": [16022],
"directory": "works/project-panel",
"ports": [
16022
],
"directory": "project-panel",
"start_cmd": "mkdir -p logs && nohup /home/hz1/miniconda3/envs/openclaw/bin/python3.12 app.py > logs/app.log 2>&1 & disown",
"stop_cmd": "pkill -f 'python.*project-panel.*app.py' || true",
"stop_cmd": "pkill -f python.*project-panel.*app.py || true",
"health_url": "http://localhost:16022/",
"description": "统一管理所有 Web 服务项目,支持 Docker 监控、系统资源监控、Cron 管理",
"admin_url": "http://localhost:16022",
"git_repo": "http://121.40.164.32:12007/hz4th_coder/project-panel",
"version": "v3.0.0"
"version": "v3.0.0",
"log_file": "logs/app.log"
}
],
"external_services": [
@@ -40,7 +46,9 @@
"id": "meilisearch",
"name": "Meilisearch 搜索引擎",
"type": "external",
"ports": [16001],
"ports": [
16001
],
"health_url": "http://localhost:16001/health",
"description": "高性能搜索引擎,只读监控"
}

247
routes_discovery.py Normal file
View File

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

243
routes_docker.py Normal file
View File

@@ -0,0 +1,243 @@
#!/usr/bin/env python3
"""
Docker 监控 API 路由
"""
import subprocess
import json
from flask import jsonify, request
from utils import log_message
def _run_docker(cmd, timeout=10):
"""执行 docker 命令并返回解析结果"""
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
if result.returncode != 0:
return None, result.stderr.strip()
return result.stdout.strip(), None
except subprocess.TimeoutExpired:
return None, '命令超时'
except FileNotFoundError:
return None, 'Docker 未安装'
except Exception as e:
return None, str(e)
def register_docker_routes(app):
"""注册 Docker 监控相关路由"""
@app.route('/api/docker/containers')
def api_docker_containers():
"""获取所有容器列表(含资源使用)"""
# 获取容器列表
fmt = '{{.ID}}|{{.Names}}|{{.Image}}|{{.State}}|{{.Status}}|{{.Ports}}|{{.CreatedAt}}|{{.RunningFor}}'
out, err = _run_docker(['docker', 'ps', '-a', '--format', fmt, '--no-trunc'])
if out is None:
return jsonify({'error': err, 'containers': []})
containers = []
for line in out.split('\n'):
if not line.strip():
continue
parts = line.split('|')
if len(parts) < 8:
continue
cid = parts[0][:12] # 缩短ID
containers.append({
'id': cid,
'name': parts[1],
'image': parts[2],
'state': parts[3],
'status': parts[4],
'ports': parts[5],
'created': parts[6],
'runningFor': parts[7]
})
# 批量获取运行中容器的stats
running = [c for c in containers if c['state'] == 'running']
if running:
try:
stats_out, _ = _run_docker([
'docker', 'stats', '--no-stream', '--no-trunc',
'--format', '{{.Name}}|{{.CPUPerc}}|{{.MemPerc}}|{{.MemUsage}}|{{.NetIO}}|{{.BlockIO}}|{{.PIDs}}'
], timeout=15)
if stats_out:
stats_map = {}
for line in stats_out.split('\n'):
if not line.strip():
continue
sp = line.split('|')
if len(sp) >= 7:
stats_map[sp[0]] = {
'cpuPercent': sp[1],
'memPercent': sp[2],
'memUsage': sp[3],
'netIO': sp[4],
'blockIO': sp[5],
'pids': sp[6]
}
for c in containers:
if c['name'] in stats_map:
c['stats'] = stats_map[c['name']]
except:
pass
return jsonify({'containers': containers})
@app.route('/api/docker/container/<container_id>/stats')
def api_docker_container_stats(container_id):
"""获取单个容器实时资源统计"""
out, err = _run_docker([
'docker', 'stats', '--no-stream', '--no-trunc',
'--format', '{{.CPUPerc}}|{{.MemPerc}}|{{.MemUsage}}|{{.NetIO}}|{{.BlockIO}}|{{.PIDs}}',
container_id
], timeout=10)
if out is None:
return jsonify({'error': err}), 500
if not out:
return jsonify({'error': '容器不存在或未运行'}), 404
parts = out.split('|')
return jsonify({
'cpuPercent': parts[0] if len(parts) > 0 else '0%',
'memPercent': parts[1] if len(parts) > 1 else '0%',
'memUsage': parts[2] if len(parts) > 2 else '',
'netIO': parts[3] if len(parts) > 3 else '',
'blockIO': parts[4] if len(parts) > 4 else '',
'pids': parts[5] if len(parts) > 5 else '0'
})
@app.route('/api/docker/images')
def api_docker_images():
"""获取所有镜像列表"""
fmt = '{{.Repository}}|{{.Tag}}|{{.ID}}|{{.Size}}|{{.CreatedAt}}|{{.Containers}}'
out, err = _run_docker(['docker', 'images', '--format', fmt, '--no-trunc'])
if out is None:
return jsonify({'error': err, 'images': []})
images = []
for line in out.split('\n'):
if not line.strip():
continue
parts = line.split('|')
if len(parts) < 6:
continue
repo = parts[0] if parts[0] != '<none>' else ''
tag = parts[1] if parts[1] != '<none>' else ''
images.append({
'repository': repo,
'tag': tag,
'id': parts[2][:12],
'size': parts[3],
'created': parts[4],
'containers': parts[5],
'dangling': not repo and not tag
})
return jsonify({'images': images})
@app.route('/api/docker/system')
def api_docker_system():
"""获取 Docker 系统信息"""
# 版本信息
ver_out, _ = _run_docker(['docker', 'version', '--format', '{{.Server.Version}}|{{.Server.Os}}|{{.Server.Arch}}'], timeout=5)
# 磁盘使用
df_out, _ = _run_docker(['docker', 'system', 'df', '--format', '{{.Type}}|{{.TotalCount}}|{{.Active}}|{{.Size}}|{{.Reclaimable}}'], timeout=10)
system_info = {
'version': '',
'os': '',
'arch': '',
'diskUsage': []
}
if ver_out:
ver_parts = ver_out.split('|')
if len(ver_parts) >= 3:
system_info.update({
'version': ver_parts[0],
'os': ver_parts[1],
'arch': ver_parts[2]
})
if df_out:
for line in df_out.split('\n'):
if not line.strip():
continue
parts = line.split('|')
if len(parts) >= 5:
system_info['diskUsage'].append({
'type': parts[0],
'total': parts[1],
'active': parts[2],
'size': parts[3],
'reclaimable': parts[4]
})
return jsonify(system_info)
@app.route('/api/docker/container/<container_id>/logs')
def api_docker_container_logs(container_id):
"""获取容器日志最近200行"""
lines = int(request.args.get('lines', 200))
lines = max(10, min(1000, lines))
out, err = _run_docker(['docker', 'logs', '--tail', str(lines), container_id], timeout=10)
if out is None:
return jsonify({'error': err}), 500
return jsonify({'logs': out, 'lines': lines})
@app.route('/api/docker/container/<container_id>/start', methods=['POST'])
def api_docker_container_start(container_id):
"""启动容器"""
out, err = _run_docker(['docker', 'start', container_id])
if out is None and err:
return jsonify({'error': err}), 500
log_message(f"Docker 容器启动: {container_id}")
return jsonify({'message': f'容器 {container_id} 已启动'})
@app.route('/api/docker/container/<container_id>/stop', methods=['POST'])
def api_docker_container_stop(container_id):
"""停止容器"""
out, err = _run_docker(['docker', 'stop', container_id])
if out is None and err:
return jsonify({'error': err}), 500
log_message(f"Docker 容器停止: {container_id}")
return jsonify({'message': f'容器 {container_id} 已停止'})
@app.route('/api/docker/container/<container_id>/restart', methods=['POST'])
def api_docker_container_restart(container_id):
"""重启容器"""
out, err = _run_docker(['docker', 'restart', container_id])
if out is None and err:
return jsonify({'error': err}), 500
log_message(f"Docker 容器重启: {container_id}")
return jsonify({'message': f'容器 {container_id} 已重启'})
@app.route('/api/docker/prune', methods=['POST'])
def api_docker_prune():
"""清理Docker资源"""
target = request.json.get('target', 'all') if request.json else 'all'
results = {}
# 清理停止的容器
if target in ('all', 'containers'):
out, _ = _run_docker(['docker', 'container', 'prune', '-f'], timeout=30)
results['containers'] = out or '已清理'
# 清理悬空镜像
if target in ('all', 'images'):
out, _ = _run_docker(['docker', 'image', 'prune', '-f'], timeout=60)
results['images'] = out or '已清理'
# 清理构建缓存
if target in ('all', 'build'):
out, _ = _run_docker(['docker', 'builder', 'prune', '-f'], timeout=60)
results['build'] = out or '已清理'
log_message(f"Docker 资源清理完成: {target}")
return jsonify({'message': '清理完成', 'results': results})

View File

@@ -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

@@ -68,9 +68,11 @@ def register_project_routes(app):
if project.get('start_cmds'):
for name, info in project['start_cmds'].items():
log_message(f"[API-START] 项目={project_id} 命令={info['cmd']} 目录={cwd}")
run_command(info['cmd'], cwd, project_id, f'start-{name}')
else:
cmd = project.get('start_cmd', 'python3 app.py')
log_message(f"[API-START] 项目={project_id} 命令={cmd} 目录={cwd}")
run_command(cmd, cwd, project_id, 'start')
return jsonify({'message': '服务启动中...'})
@@ -128,7 +130,9 @@ def register_project_routes(app):
'directory': data.get('directory', ''),
'start_cmd': data.get('start_cmd', ''),
'stop_cmd': data.get('stop_cmd', ''),
'log_file': data.get('log_file', ''),
'health_url': data.get('health_url', ''),
'admin_url': data.get('admin_url', ''),
'description': data.get('description', ''),
'git_repo': data.get('git_repo', ''),
'version': data.get('version', 'v1.0.0')
@@ -152,7 +156,7 @@ def register_project_routes(app):
return jsonify({'error': '项目不存在'}), 404
# 更新字段
for key in ['name', 'ports', 'directory', 'start_cmd', 'stop_cmd', 'health_url', 'description', 'git_repo', 'version']:
for key in ['name', 'ports', 'directory', 'log_file', 'start_cmd', 'stop_cmd', 'health_url', 'admin_url', 'description', 'git_repo', 'version']:
if key in data:
project[key] = data[key]
@@ -162,6 +166,55 @@ def register_project_routes(app):
return jsonify({'message': '项目更新成功'})
@app.route('/api/projects/<project_id>/log')
def api_project_log(project_id):
"""获取项目日志"""
projects = load_projects()
project = next((p for p in projects if p['id'] == project_id), None)
if not project:
return jsonify({'error': '项目不存在'}), 404
lines = request.args.get('lines', 200, type=int)
lines = max(1, min(lines, 5000)) # 限制 1-5000 行
log_file = project.get('log_file', '')
if not log_file:
# 默认:项目目录下的 logs/app.log
cwd = project.get('directory', '')
if cwd:
from config import WORKSPACE_DIR
import os
abs_dir = cwd if cwd.startswith('/') else os.path.join(WORKSPACE_DIR, cwd)
log_file = os.path.join(abs_dir, 'logs', 'app.log')
# 支持相对路径和绝对路径
import os
if not log_file.startswith('/'):
cwd = project.get('directory', '')
if cwd:
from config import WORKSPACE_DIR
abs_dir = cwd if cwd.startswith('/') else os.path.join(WORKSPACE_DIR, cwd)
log_file = os.path.join(abs_dir, log_file)
if not os.path.exists(log_file):
return jsonify({'log': f'日志文件不存在: {log_file}'})
try:
# 读取最后 N 行(高效方式)
with open(log_file, 'r', encoding='utf-8', errors='replace') as f:
all_lines = f.readlines()
tail_lines = all_lines[-lines:] if len(all_lines) > lines else all_lines
content = ''.join(tail_lines)
return jsonify({
'log': content if content else '(日志为空)',
'file': log_file,
'total_lines': len(all_lines),
'shown_lines': len(tail_lines)
})
except Exception as e:
return jsonify({'error': f'读取日志失败: {str(e)}'}), 500
@app.route('/api/projects/<project_id>', methods=['DELETE'])
def api_delete_project(project_id):
"""删除项目"""

View File

@@ -23,6 +23,10 @@ def get_html_template():
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:
# 如果文件不存在,返回基本模板

File diff suppressed because it is too large Load Diff

View File

@@ -50,9 +50,11 @@ def load_projects_full():
def save_projects(projects):
"""保存项目配置"""
"""保存项目配置(保留 server 和 external_services"""
full_data = load_projects_full()
full_data['projects'] = projects
with open(PROJECTS_FILE, 'w', encoding='utf-8') as f:
json.dump({'projects': projects}, f, ensure_ascii=False, indent=2)
json.dump(full_data, f, ensure_ascii=False, indent=2)
def check_port(port):

24
watchdog.sh Executable file
View File

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