Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 633542a2be | |||
| ffe3dd1fc3 | |||
| 701889d22f | |||
| a155e8fcd9 | |||
| 1c97f3bd1e | |||
| 0c54af8d26 | |||
| f7722be539 | |||
| 779f42c98c | |||
| 525620b0f8 | |||
| ebc8d687d0 | |||
| 294526b403 | |||
| 78a4a3f1aa | |||
| 27a84de317 | |||
| 89b5a69325 | |||
| 23810cf180 | |||
| 16400b0359 | |||
| df319b9c4a |
BIN
__pycache__/app.cpython-312.pyc
Normal file
BIN
__pycache__/app.cpython-312.pyc
Normal file
Binary file not shown.
BIN
__pycache__/config.cpython-310.pyc
Normal file
BIN
__pycache__/config.cpython-310.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
__pycache__/routes_discovery.cpython-312.pyc
Normal file
BIN
__pycache__/routes_discovery.cpython-312.pyc
Normal file
Binary file not shown.
BIN
__pycache__/routes_docker.cpython-312.pyc
Normal file
BIN
__pycache__/routes_docker.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
__pycache__/templates.cpython-310.pyc
Normal file
BIN
__pycache__/templates.cpython-310.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
4
app.py
@@ -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('/')
|
||||
|
||||
@@ -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
476562
logs/app.log
File diff suppressed because it is too large
Load Diff
1
logs/watchdog.log
Normal file
1
logs/watchdog.log
Normal file
@@ -0,0 +1 @@
|
||||
[2026-06-01 20:29:22] 守护进程启动,监控端口 16022
|
||||
@@ -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
247
routes_discovery.py
Normal 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
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})
|
||||
@@ -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
|
||||
@@ -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):
|
||||
"""删除项目"""
|
||||
|
||||
@@ -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:
|
||||
# 如果文件不存在,返回基本模板
|
||||
|
||||
1247
templates_full.txt
1247
templates_full.txt
File diff suppressed because it is too large
Load Diff
6
utils.py
6
utils.py
@@ -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
24
watchdog.sh
Executable 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
|
||||
Reference in New Issue
Block a user