diff --git a/README.md b/README.md index 1279a21..61553ec 100644 --- a/README.md +++ b/README.md @@ -1,155 +1,182 @@ -# 项目服务管理面板 v3.0.0 +# 项目服务管理面板 -统一管理所有 Web 服务项目,提供状态检测、启动/停止控制、日志查看、Docker 监控等功能。 +统一的 Web 服务项目管理平台,支持项目监控、系统资源监控、Cron 任务管理。 -## 端口 +## 版本信息 -- **服务端口**: 16022 -- **访问地址**: http://localhost:16022 +- **当前版本**: v3.4.0 +- **端口**: 16022 +- **更新日期**: 2026-06-01 -## 功能 +## 功能特性 -### 项目管理 -- 项目列表展示(状态、端口、版本) -- 服务状态健康检测 -- 启动/停止/重启控制 -- 日志查看(实时滚动) -- Git 仓库链接 -- 自定义链接入口 -- 新增外部服务 +### 1. 项目服务管理 +- 📊 项目状态实时监控 +- 🚀 一键启动/停止/重启服务 +- 🔗 自定义链接入口 +- 📝 项目配置管理 +- 🌐 外部服务监控 -### Docker 监控(v3.0.0 新增) -- **容器监控**:CPU、内存、网络 I/O、磁盘 I/O 使用情况 -- **容器控制**:启动、停止、重启容器 -- **容器日志**:实时查看容器日志 -- **镜像管理**:查看镜像列表、大小、悬空镜像 -- **系统信息**:Docker 版本、磁盘占用、运行状态统计 -- **资源清理**:一键清理未使用的容器、镜像、卷 +### 2. 系统资源监控 +- 💻 CPU 使用率 + 温度监控 +- 🎯 内存使用率 +- 💾 磁盘使用率 +- 📡 网络上传/下载速度 +- ⏱️ 实时监控模式 +- ⚠️ 预警阈值设置 -### 系统资源监控 -- CPU、内存、磁盘使用率 -- 网络流量和实时网速 -- CPU 温度监控 -- 进程列表 -- 阈值告警(弹窗 + 桌面通知 + 邮件) +### 3. Cron 任务管理 +- 📋 任务列表查看 +- ➕ 添加/编辑/删除任务 +- 🔄 启用/禁用切换 +- 📚 版本历史记录 +- 🔙 回退到历史版本 +- 🔗 从系统 crontab 同步 -### Cron 管理 -- Cron 任务增删改查 -- 历史版本回退 -- 分类管理 -- 从系统同步 +### 4. 配置管理 +- 📤 导出完整配置(JSON) +- 📥 导入配置文件 +- ⚙️ 统一配置文件管理 +- 💾 自动备份旧配置 -## 启动方式 ⭐ +## 项目结构(模块化) -**重要:必须使用 `nohup + disown` 方式启动,脱离进程树!** - -```bash -cd ~/.openclaw/workspace-coder/works/project-panel -nohup python3 app.py > logs/app.log 2>&1 & disown +``` +project-panel/ +├── app_main.py # 主入口文件 +├── config.py # 配置常量 +├── database.py # 数据库操作 +├── utils.py # 工具函数 +├── routes_cron.py # Cron 管理 API +├── routes_system.py # 系统监控 API +├── routes_config.py # 配置管理 API +├── routes_email.py # 邮件通知 API +├── routes_project.py # 项目管理 API +├── templates.py # HTML 模板 +├── projects.json # 项目配置文件 +├── alert_config.json # 预警和网页设置 +├── cron_manager.db # Cron 任务数据库 +└── logs/ # 日志目录 ``` -### 为什么需要 disown? - -如果通过 OpenClaw 的 exec 启动服务,当 OpenClaw 清理进程时(如超时、重启),会发送 SIGTERM 杀掉所有子进程。 - -使用 `nohup + disown` 可以: -1. `nohup` - 让进程忽略 SIGHUP(终端关闭信号) -2. `disown` - 从 shell 进程树中移除,避免被连带杀掉 -3. `> logs/app.log 2>&1` - 重定向输出到日志文件 - -### ❌ 错误方式 - -```bash -# 不要用这种方式(会被 OpenClaw 杀掉) -python3 app.py -``` - -### ✅ 正确方式 - -```bash -nohup python3 app.py > logs/app.log 2>&1 & disown -``` - -## 项目配置 - -项目配置在 `projects.json` 中,每个项目包含: +## 配置文件说明 +### projects.json ```json { - "id": "project-id", - "name": "项目名称", - "ports": [19000], - "directory": "works/project-dir", - "start_cmd": "nohup python3 app.py > logs/app.log 2>&1 & disown", - "health_url": "http://localhost:19000/api/health", - "admin_url": "http://localhost:19000/admin", - "git_repo": "http://192.168.2.8:12007/coder/project", - "version": "v1.0.0" + "server": { + "name": "服务器名称", + "description": "服务器描述" + }, + "projects": [ + { + "id": "项目ID", + "name": "项目名称", + "type": "web", + "ports": [端口列表], + "directory": "工作目录", + "start_cmd": "启动命令", + "stop_cmd": "停止命令", + "health_url": "健康检查URL", + "description": "项目描述" + } + ], + "external_services": [...] } ``` -## 端口规范 - -所有 Web 服务必须使用 **19000-19100** 端口范围: - -| 端口 | 项目 | -|------|------| -| 19000 | PDF翻译助手 V2 | -| 19001 | LLM Index RAG | -| 19009 | 碎片信息记录 | -| 19010 | ParamHub Python | -| 19013 | 项目服务管理面板(本服务) | -| 19014 | Xian Favor 收藏系统 | -| 19020 | AI对话系统 | -| 19007 | LLM Proxy | -| 19011 | 产品参数爬取系统 | -| 19004 | 技术论坛 | -| 19015 | 多智能体竞标调度系统 | +### alert_config.json +```json +{ + "thresholds": { + "cpu": 80, + "memory": 85, + "disk": 90, + "interval": 60 + }, + "web_settings": { + "external_ip": "121.40.164.32", + "refresh_interval": 30 + } +} +``` ## API 接口 -- `GET /api/projects` - 获取所有项目列表 -- `GET /api/project/:id` - 获取单个项目详情 -- `POST /api/project/:id/start` - 启动项目 -- `POST /api/project/:id/stop` - 停止项目 -- `POST /api/project/:id/restart` - 重启项目 -- `GET /api/project/:id/logs` - 获取项目日志 +### 项目管理 +- `GET /api/projects` - 获取项目列表 +- `POST /api/projects//start` - 启动项目 +- `POST /api/projects//stop` - 停止项目 +- `POST /api/projects//restart` - 重启项目 +- `POST /api/projects/add` - 添加项目 -## 监控 +### 系统监控 +- `GET /api/system/stats` - 获取系统资源信息 +- `GET /api/system/processes` - 获取进程列表 +- `GET /api/system/docker` - 获取 Docker 容器状态 -服务监控由 `service-monitor` 负责,每 20 分钟检查一次,发送邮件通知。 +### Cron 管理 +- `GET /api/cron/tasks` - 获取任务列表 +- `POST /api/cron/tasks` - 添加任务 +- `PUT /api/cron/tasks/` - 更新任务 +- `DELETE /api/cron/tasks/` - 删除任务 +- `POST /api/cron/tasks//toggle` - 启用/禁用 +- `POST /api/cron/sync` - 从系统同步 -## 故障排查 +### 配置管理 +- `GET /api/config/export` - 导出配置 +- `POST /api/config/import` - 导入配置 +- `GET /api/web-settings` - 获取网页设置 +- `POST /api/web-settings` - 保存网页设置 -### 服务频繁停止? +### 预警配置 +- `GET /api/alerts` - 获取预警配置 +- `POST /api/alerts` - 保存预警配置 -检查是否使用了正确的启动方式: -1. 确认使用了 `nohup + disown` -2. 确认日志目录存在:`mkdir -p logs/` -3. 查看日志:`cat logs/app.log` +## 使用方法 -### OpenClaw 杀进程? +### 启动服务 +```bash +python3 app_main.py +``` -如果日志突然停止(约凌晨 01:10),可能是被 OpenClaw 清理进程杀掉了。 +### 访问面板 +打开浏览器访问:`http://localhost:16022/` -**证据:** -- OpenClaw 日志:`[tools] exec failed: Command aborted by signal SIGTERM` -- 服务日志最后记录时间:01:09 左右 +### 导出配置 +点击页面头部"导出配置"按钮,下载 JSON 配置文件 -**解决:** 使用 `disown` 脱离进程树。 +### 导入配置 +点击"导入配置"按钮,上传配置文件即可应用 -## Git 仓库 +## 依赖项 -http://192.168.2.8:12007/coder/project-panel +- Python 3.12+ +- Flask +- psutil (系统监控) +- croniter (Cron 表达式解析) ## 更新日志 -### v1.0.1 -- 添加 README.md 文档 -- 记录正确的启动方式(nohup + disown) -- 记录端口规范 +### v3.4.0 (2026-06-01) +- 🎯 **模块化重构**: 将 3700+ 行单文件拆分为多个模块 +- 📁 **清晰结构**: config、database、utils、routes、templates 分离 +- 🔧 **易于维护**: 每个模块职责明确,便于扩展和维护 -### v1.0.0 -- 项目服务管理面板 -- 状态检测、启动/停止控制 -- 日志查看 \ No newline at end of file +### v3.3.5 (2026-06-01) +- ✨ 统一配置文件管理 +- 📤 配置导入/导出功能 +- 🔄 Cron 任务同步优化(清空旧任务) +- ⚙️ 网页设置持久化 + +### v3.3.4 +- 🌐 对外 IP 默认值调整 +- 🐛 Bug 修复 + +## 开发者 + +黄庄4号程序员 - hz4th_coder@tphai.com + +## 许可证 + +内部使用项目 \ No newline at end of file diff --git a/__pycache__/app.cpython-310.pyc b/__pycache__/app.cpython-310.pyc deleted file mode 100644 index 74fff97..0000000 Binary files a/__pycache__/app.cpython-310.pyc and /dev/null differ diff --git a/app.py b/app.py index c867f28..d74c2a8 100644 --- a/app.py +++ b/app.py @@ -1,3722 +1,49 @@ #!/usr/bin/env python3 """ -项目服务管理面板 v2.2.0 -端口: 19013 -新增: Web服务卡片添加自定义链接入口(+按钮) -新增: 自定义新增外部web服务功能(新增服务按钮) +项目服务管理面板 - 主入口 +模块化重构版本 v3.4.0 """ -import os -import json -import subprocess -import signal -import threading -import sys -import sqlite3 -import shutil -import re -from datetime import datetime -from flask import Flask, render_template_string, jsonify, request, g -import urllib.request -import urllib.error +from flask import Flask +from config import APP_NAME, APP_VERSION, APP_PORT +from database import init_db, close_db +from utils import log_message +from routes_cron import register_cron_routes +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 templates import get_html_template -# 系统资源监控 -try: - import psutil - import time - HAS_PSUTIL = True - # 网速计算用的上次数据 - _last_net_stats = {'bytes_sent': 0, 'bytes_recv': 0, 'time': 0} -except ImportError: - HAS_PSUTIL = False - -# CPU 温度获取 -def get_cpu_temperatures(): - """获取 CPU Package 温度""" - temps = {'package_0': None, 'package_1': None} - try: - result = subprocess.run(['sensors'], capture_output=True, text=True, timeout=5) - output = result.stdout - # 解析 Package id 0 和 Package id 1 - for line in output.split('\n'): - if 'Package id 0:' in line: - match = re.search(r'Package id 0:\s*([+\d.]+)°C', line) - if match: - temps['package_0'] = float(match.group(1).replace('+', '')) - elif 'Package id 1:' in line: - match = re.search(r'Package id 1:\s*([+\d.]+)°C', line) - if match: - temps['package_1'] = float(match.group(1).replace('+', '')) - except Exception as e: - log_message(f"获取CPU温度失败: {e}") - return temps +# 创建 Flask 应用 app = Flask(__name__) -BASE_DIR = os.path.dirname(os.path.abspath(__file__)) -WORKSPACE_DIR = os.path.dirname(BASE_DIR) # works目录 -ROOT_DIR = os.path.dirname(WORKSPACE_DIR) # workspace-coder目录 -PROJECTS_FILE = os.path.join(BASE_DIR, 'projects.json') -LOG_FILE = os.path.join(BASE_DIR, 'logs', 'app.log') -DB_FILE = os.path.join(BASE_DIR, 'cron_manager.db') -CRON_BACKUP_DIR = os.path.join(BASE_DIR, 'cron_backups') -# 进程状态缓存 -process_cache = {} - - -def get_db(): - """获取数据库连接""" - if 'db' not in g: - g.db = sqlite3.connect(DB_FILE) - g.db.row_factory = sqlite3.Row - return g.db - - -@app.teardown_appcontext -def close_db(error): - """关闭数据库连接""" - if hasattr(g, 'db'): - g.db.close() - - -def init_db(): - """初始化数据库""" - conn = sqlite3.connect(DB_FILE) - conn.row_factory = sqlite3.Row - c = conn.cursor() - - # Cron 任务表 - c.execute('''CREATE TABLE IF NOT EXISTS cron_tasks ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - expression TEXT NOT NULL, - command TEXT NOT NULL, - description TEXT, - category TEXT DEFAULT 'other', - enabled INTEGER DEFAULT 1, - created_at TEXT DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT DEFAULT CURRENT_TIMESTAMP - )''') - - # Cron 历史版本表 - c.execute('''CREATE TABLE IF NOT EXISTS cron_history ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - task_id INTEGER NOT NULL, - version INTEGER NOT NULL, - name TEXT, - expression TEXT, - command TEXT, - description TEXT, - category TEXT, - enabled INTEGER, - operation TEXT, - operator TEXT DEFAULT 'system', - created_at TEXT DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (task_id) REFERENCES cron_tasks(id) - )''') - - # Cron 执行日志表 - c.execute('''CREATE TABLE IF NOT EXISTS cron_logs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - task_id INTEGER, - execution_time TEXT DEFAULT CURRENT_TIMESTAMP, - status TEXT, - output TEXT, - FOREIGN KEY (task_id) REFERENCES cron_tasks(id) - )''') - - # 创建索引 - c.execute('CREATE INDEX IF NOT EXISTS idx_cron_history_task ON cron_history(task_id)') - c.execute('CREATE INDEX IF NOT EXISTS idx_cron_logs_task ON cron_logs(task_id)') - - conn.commit() - conn.close() - - -def log_message(msg): - """写入日志""" - timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') - log_line = f"[{timestamp}] {msg}\n" - print(log_line.strip()) - try: - os.makedirs(os.path.dirname(LOG_FILE), exist_ok=True) - with open(LOG_FILE, 'a') as f: - f.write(log_line) - except: - pass - - -def signal_handler(signum, frame): - """处理信号""" - signal_names = { - signal.SIGTERM: 'SIGTERM', - signal.SIGINT: 'SIGINT', - signal.SIGHUP: 'SIGHUP', - } - sig_name = signal_names.get(signum, f'SIGNAL_{signum}') - log_message(f"⚠️ 进程收到 {sig_name} 信号,即将退出!") - sys.exit(0) - - -signal.signal(signal.SIGTERM, signal_handler) -signal.signal(signal.SIGINT, signal_handler) -try: - signal.signal(signal.SIGHUP, signal_handler) -except: - pass - - -def load_projects(): - """加载项目配置""" - with open(PROJECTS_FILE, 'r', encoding='utf-8') as f: - return json.load(f)['projects'] - - -def save_projects(projects): - """保存项目配置""" - with open(PROJECTS_FILE, 'w', encoding='utf-8') as f: - json.dump({'projects': projects}, f, ensure_ascii=False, indent=2) - - -def check_port(port): - """检查端口是否有进程监听""" - try: - import socket - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.settimeout(1) - result = sock.connect_ex(('localhost', port)) - sock.close() - return result == 0 - except: - return False - - -def check_health(url): - """检查健康检查URL""" - try: - req = urllib.request.Request(url, method='GET') - with urllib.request.urlopen(req, timeout=3) as response: - return response.status == 200 - except: - return False - - -def get_process_on_port(port): - """获取占用端口的进程信息""" - try: - result = subprocess.run( - f"lsof -i :{port} -t 2>/dev/null | head -1", - shell=True, capture_output=True, text=True, timeout=5 - ) - pid = result.stdout.strip() - if pid: - cmd_result = subprocess.run( - f"ps -p {pid} -o pid,ppid,user,cmd --no-headers 2>/dev/null", - shell=True, capture_output=True, text=True - ) - return {'pid': int(pid), 'info': cmd_result.stdout.strip()} - except: - pass - return None - - -def get_project_status(project): - """获取项目状态""" - project_type = project.get('type', 'web') - - if project_type == 'web': - ports = project.get('ports', []) - if not ports: - return {'status': 'unknown', 'message': '未配置端口'} - - port_status = {} - all_running = True - for port in ports: - running = check_port(port) - port_status[port] = {'running': running, 'process': get_process_on_port(port) if running else None} - if not running: - all_running = False - - health_ok = None - if all_running and project.get('health_url'): - health_ok = check_health(project['health_url']) - - return { - 'status': 'running' if all_running else ('partial' if any(p['running'] for p in port_status.values()) else 'stopped'), - 'ports': port_status, - 'health': health_ok - } - - elif project_type == 'cron': - cron_expr = project.get('cron', '') - cron_cmd = project.get('cron_cmd', '') - try: - result = subprocess.run(f"crontab -l 2>/dev/null | grep -F '{cron_cmd}'", shell=True, capture_output=True, text=True) - cron_configured = bool(result.stdout.strip()) - except: - cron_configured = False - return {'status': 'configured' if cron_configured else 'not_configured', 'cron': cron_expr, 'cron_configured': cron_configured} - - elif project_type == 'extension': - return {'status': 'completed', 'message': '浏览器插件,手动安装'} - - elif project_type == 'cli': - return {'status': 'ready', 'message': '命令行工具,按需运行'} - - return {'status': 'unknown'} - - -def run_command(cmd, cwd, project_id, action): - """异步执行命令""" - def _run(): - try: - log_file = os.path.join(BASE_DIR, 'logs', f'{project_id}.log') - os.makedirs(os.path.dirname(log_file), exist_ok=True) - - with open(log_file, 'a') as f: - f.write(f"\n{'='*50}\n[{datetime.now().isoformat()}] {action}\nCommand: {cmd}\nDirectory: {cwd}\n") - - process = subprocess.Popen(cmd, shell=True, cwd=cwd, stdout=open(log_file, 'a'), stderr=subprocess.STDOUT, start_new_session=True) - process_cache[project_id] = process.pid - except Exception as e: - with open(os.path.join(BASE_DIR, 'logs', 'error.log'), 'a') as f: - f.write(f"[{datetime.now().isoformat()}] Error: {e}\n") - - thread = threading.Thread(target=_run) - thread.daemon = True - thread.start() - - -def stop_process(port): - """停止占用端口的进程""" - try: - result = subprocess.run(f"lsof -i :{port} -t 2>/dev/null", shell=True, capture_output=True, text=True, timeout=5) - pids = [p for p in result.stdout.strip().split('\n') if p] - for pid in pids: - try: - os.kill(int(pid), signal.SIGTERM) - except: - pass - return len(pids) - except: - return 0 - - -# ==================== Cron 管理 API ==================== - -def backup_crontab(): - """备份当前 crontab""" - os.makedirs(CRON_BACKUP_DIR, exist_ok=True) - timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') - backup_file = os.path.join(CRON_BACKUP_DIR, f'crontab_{timestamp}.txt') - try: - result = subprocess.run("crontab -l 2>/dev/null", shell=True, capture_output=True, text=True) - with open(backup_file, 'w') as f: - f.write(result.stdout) - return backup_file - except: - return None - - -def sync_crontab_from_db(): - """从数据库同步到系统 crontab""" - db = get_db() - c = db.cursor() - c.execute("SELECT expression, command, enabled FROM cron_tasks WHERE enabled = 1") - tasks = c.fetchall() - - lines = [] - for task in tasks: - lines.append(f"{task['expression']} {task['command']}") - - # 写入临时文件 - tmp_file = '/tmp/crontab_sync.tmp' - with open(tmp_file, 'w') as f: - f.write('\n'.join(lines) + '\n') - - # 安装 crontab - subprocess.run(f"crontab {tmp_file}", shell=True) - os.remove(tmp_file) - - # 备份 - backup_crontab() - - -def get_system_crons(): - """获取系统所有 cron 任务""" - crons = [] - - try: - result = subprocess.run("crontab -l 2>/dev/null", shell=True, capture_output=True, text=True, timeout=5) - for line in result.stdout.strip().split('\n'): - line = line.strip() - if line and not line.startswith('#'): - parts = line.split() - if len(parts) >= 6: - crons.append({'source': 'user', 'expression': ' '.join(parts[:5]), 'command': ' '.join(parts[5:]), 'line': line}) - except: - pass - - try: - if os.path.exists('/etc/crontab'): - with open('/etc/crontab', 'r') as f: - for line in f: - line = line.strip() - if line and not line.startswith('#'): - parts = line.split() - if len(parts) >= 7: - crons.append({'source': 'system', 'expression': ' '.join(parts[:5]), 'user': parts[5], 'command': ' '.join(parts[6:]), 'line': line}) - except: - pass - - try: - if os.path.exists('/etc/cron.d'): - for filename in os.listdir('/etc/cron.d'): - filepath = os.path.join('/etc/cron.d', filename) - if os.path.isfile(filepath): - with open(filepath, 'r') as f: - for line in f: - line = line.strip() - if line and not line.startswith('#'): - parts = line.split() - if len(parts) >= 7: - crons.append({'source': 'cron.d', 'file': filename, 'expression': ' '.join(parts[:5]), 'user': parts[5], 'command': ' '.join(parts[6:]), 'line': line}) - except: - pass - - return crons - - -def parse_cron_expression(expr): - """解析 cron 表达式为人类可读格式""" - parts = expr.split() - if len(parts) != 5: - return expr +def init_app(): + """初始化应用""" + # 注册数据库钩子 + app.teardown_appcontext(close_db) - minute, hour, day, month, weekday = parts - desc = [] - - if minute == '*': - desc.append('每分钟') - elif minute.startswith('*/'): - desc.append(f'每{minute[2:]}分钟') - else: - desc.append(f'{minute}分') - - if hour != '*': - if minute == '*': - desc = [f'{hour}点每分钟'] - else: - desc = [f'{hour}点{minute}分'] - - if day != '*': - desc.append(f'{day}号') - - if month != '*': - desc.append(f'{month}月') - - if weekday != '*': - weekdays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'] - try: - desc.append(weekdays[int(weekday)]) - except: - desc.append(f'周{weekday}') - - return ' '.join(desc) if desc else expr - - -def cron_expression_to_human(expr): - """更友好的人类可读格式""" - parts = expr.split() - if len(parts) != 5: - return expr - - minute, hour, day, month, weekday = parts - - # 特殊模式 - patterns = [ - ('0 4 * * *', '每天凌晨4点'), - ('0 0 * * *', '每天午夜0点'), - ('*/5 * * * *', '每5分钟'), - ('*/10 * * * *', '每10分钟'), - ('*/20 * * * *', '每20分钟'), - ('*/30 * * * *', '每30分钟'), - ('0 */1 * * *', '每小时'), - ('0 */2 * * *', '每2小时'), - ('0 9 * * 1', '每周一9点'), - ('0 9 * * 1-5', '每周一到五9点'), - ] - - for pattern, desc in patterns: - if expr == pattern: - return desc - - # 通用解析 - return parse_cron_expression(expr) - - -@app.route('/api/cron/tasks') -def api_cron_tasks(): - """获取数据库中的 Cron 任务列表""" - db = get_db() - c = db.cursor() - c.execute("SELECT * FROM cron_tasks ORDER BY created_at DESC") - tasks = [dict(row) for row in c.fetchall()] - - # 添加人类可读描述和下次执行时间 - for task in tasks: - task['human_time'] = cron_expression_to_human(task['expression']) - task['next_run'] = get_next_run_time(task['expression']) - - return jsonify({'tasks': tasks}) - - -@app.route('/api/cron/tasks', methods=['POST']) -def api_cron_add(): - """添加新的 Cron 任务""" - data = request.json + # 注册路由 + register_cron_routes(app) + register_system_routes(app) + register_config_routes(app) + register_email_routes(app) + register_project_routes(app) - if not data.get('name') or not data.get('expression') or not data.get('command'): - return jsonify({'error': '缺少必要字段'}), 400 + # 主页路由 + @app.route('/') + def index(): + return get_html_template() - db = get_db() - c = db.cursor() + # 初始化数据库 + init_db() - # 插入任务 - c.execute('''INSERT INTO cron_tasks (name, expression, command, description, category, enabled) - VALUES (?, ?, ?, ?, ?, ?)''', - (data['name'], data['expression'], data['command'], data.get('description', ''), data.get('category', 'other'), 1)) - - task_id = c.lastrowid - db.commit() - - # 记录历史(版本1) - c.execute('''INSERT INTO cron_history (task_id, version, name, expression, command, description, category, enabled, operation) - VALUES (?, 1, ?, ?, ?, ?, ?, ?, 'create')''', - (task_id, data['name'], data['expression'], data['command'], data.get('description', ''), data.get('category', 'other'), 1)) - db.commit() - - # 同步到系统 crontab - sync_crontab_from_db() - - log_message(f"添加 Cron 任务: {data['name']} ({data['expression']})") - - return jsonify({'message': '任务添加成功', 'task_id': task_id}) - - -@app.route('/api/cron/tasks/', methods=['PUT']) -def api_cron_update(task_id): - """更新 Cron 任务""" - data = request.json - db = get_db() - c = db.cursor() - - # 获取当前任务 - c.execute("SELECT * FROM cron_tasks WHERE id = ?", (task_id,)) - old_task = c.fetchone() - if not old_task: - return jsonify({'error': '任务不存在'}), 404 - - # 获取当前版本号 - c.execute("SELECT MAX(version) FROM cron_history WHERE task_id = ?", (task_id,)) - max_version = c.fetchone()[0] or 0 - new_version = max_version + 1 - - # 更新任务 - update_fields = [] - update_values = [] - for field in ['name', 'expression', 'command', 'description', 'category', 'enabled']: - if field in data: - update_fields.append(f"{field} = ?") - update_values.append(data[field]) - - if update_fields: - update_fields.append("updated_at = CURRENT_TIMESTAMP") - c.execute(f"UPDATE cron_tasks SET {','.join(update_fields)} WHERE id = ?", update_values + [task_id]) - db.commit() - - # 记录历史 - c.execute('''INSERT INTO cron_history (task_id, version, name, expression, command, description, category, enabled, operation) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'update')''', - (task_id, new_version, data.get('name', old_task['name']), data.get('expression', old_task['expression']), - data.get('command', old_task['command']), data.get('description', old_task['description']), - data.get('category', old_task['category']), data.get('enabled', old_task['enabled']))) - db.commit() - - # 同步到系统 crontab - sync_crontab_from_db() - - log_message(f"更新 Cron 任务 ID={task_id}: 版本 {new_version}") - - return jsonify({'message': '任务更新成功', 'version': new_version}) - - -@app.route('/api/cron/tasks/', methods=['DELETE']) -def api_cron_delete(task_id): - """删除 Cron 任务""" - db = get_db() - c = db.cursor() - - c.execute("SELECT * FROM cron_tasks WHERE id = ?", (task_id,)) - task = c.fetchone() - if not task: - return jsonify({'error': '任务不存在'}), 404 - - # 记录删除历史 - c.execute("SELECT MAX(version) FROM cron_history WHERE task_id = ?", (task_id,)) - max_version = c.fetchone()[0] or 0 - - c.execute('''INSERT INTO cron_history (task_id, version, name, expression, command, description, category, enabled, operation) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'delete')''', - (task_id, max_version + 1, task['name'], task['expression'], task['command'], task['description'], task['category'], task['enabled'])) - db.commit() - - # 删除任务 - c.execute("DELETE FROM cron_tasks WHERE id = ?", (task_id,)) - db.commit() - - # 同步到系统 crontab - sync_crontab_from_db() - - log_message(f"删除 Cron 任务 ID={task_id}: {task['name']}") - - return jsonify({'message': '任务删除成功'}) - - -@app.route('/api/cron/tasks//toggle', methods=['POST']) -def api_cron_toggle(task_id): - """启用/禁用 Cron 任务""" - db = get_db() - c = db.cursor() - - c.execute("SELECT enabled FROM cron_tasks WHERE id = ?", (task_id,)) - task = c.fetchone() - if not task: - return jsonify({'error': '任务不存在'}), 404 - - new_enabled = 0 if task['enabled'] == 1 else 1 - c.execute("UPDATE cron_tasks SET enabled = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", (new_enabled, task_id)) - db.commit() - - # 记录历史 - c.execute("SELECT MAX(version) FROM cron_history WHERE task_id = ?", (task_id,)) - max_version = c.fetchone()[0] or 0 - c.execute('''INSERT INTO cron_history (task_id, version, operation, enabled) - VALUES (?, ?, ?, ?)''', (task_id, max_version + 1, 'toggle', new_enabled)) - db.commit() - - # 同步到系统 crontab - sync_crontab_from_db() - - log_message(f"切换 Cron 任务 ID={task_id} 状态: {new_enabled}") - - return jsonify({'message': '状态已切换', 'enabled': new_enabled}) - - -@app.route('/api/cron/tasks//history') -def api_cron_history(task_id): - """获取任务历史版本""" - db = get_db() - c = db.cursor() - c.execute("SELECT * FROM cron_history WHERE task_id = ? ORDER BY version DESC", (task_id,)) - history = [dict(row) for row in c.fetchall()] - - for h in history: - if h['expression']: - h['human_time'] = cron_expression_to_human(h['expression']) - - return jsonify({'history': history}) - - -@app.route('/api/cron/tasks//rollback/', methods=['POST']) -def api_cron_rollback(task_id, version): - """回退到指定版本""" - db = get_db() - c = db.cursor() - - c.execute("SELECT * FROM cron_history WHERE task_id = ? AND version = ?", (task_id, version)) - history = c.fetchone() - if not history: - return jsonify({'error': '版本不存在'}), 404 - - # 更新任务到历史版本 - c.execute('''UPDATE cron_tasks SET name = ?, expression = ?, command = ?, description = ?, category = ?, enabled = ?, updated_at = CURRENT_TIMESTAMP - WHERE id = ?''', - (history['name'], history['expression'], history['command'], history['description'], history['category'], history['enabled'], task_id)) - db.commit() - - # 记录回退操作 - c.execute("SELECT MAX(version) FROM cron_history WHERE task_id = ?", (task_id,)) - max_version = c.fetchone()[0] or 0 - c.execute('''INSERT INTO cron_history (task_id, version, name, expression, command, description, category, enabled, operation) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)''', - (task_id, max_version + 1, history['name'], history['expression'], history['command'], history['description'], history['category'], history['enabled'], f'rollback to v{version}')) - db.commit() - - # 同步到系统 crontab - sync_crontab_from_db() - - log_message(f"回退 Cron 任务 ID={task_id} 到版本 {version}") - - return jsonify({'message': f'已回退到版本 {version}'}) - - -@app.route('/api/cron/tasks//logs') -def api_cron_logs(task_id): - """获取任务执行日志""" - enable_logs = request.args.get('enable', 'true') == 'true' - - if not enable_logs: - return jsonify({'logs': [], 'message': '日志功能已关闭'}) - - db = get_db() - c = db.cursor() - c.execute("SELECT * FROM cron_logs WHERE task_id = ? ORDER BY execution_time DESC LIMIT 100", (task_id,)) - logs = [dict(row) for row in c.fetchall()] - - return jsonify({'logs': logs}) - - -@app.route('/api/cron/categories') -def api_cron_categories(): - """获取任务分类列表""" - categories = [ - {'id': 'monitor', 'name': '系统监控', 'icon': 'ri-heart-pulse-line', 'color': 'blue'}, - {'id': 'backup', 'name': '数据备份', 'icon': 'ri-archive-line', 'color': 'green'}, - {'id': 'cleanup', 'name': '清理任务', 'icon': 'ri-delete-bin-line', 'color': 'yellow'}, - {'id': 'report', 'name': '报表生成', 'icon': 'ri-file-chart-line', 'color': 'purple'}, - {'id': 'sync', 'name': '数据同步', 'icon': 'ri-refresh-line', 'color': 'cyan'}, - {'id': 'notification', 'name': '通知提醒', 'icon': 'ri-notification-line', 'color': 'orange'}, - {'id': 'other', 'name': '其他任务', 'icon': 'ri-more-line', 'color': 'gray'}, - ] - return jsonify({'categories': categories}) - - -@app.route('/api/cron/sync', methods=['POST']) -def api_cron_sync(): - """从系统 crontab 同步到数据库(清空旧的,只保留当前系统实际存在的)""" - crons = get_system_crons() - user_crons = [c for c in crons if c['source'] == 'user'] - - db = get_db() - c = db.cursor() - - # 清空数据库中的旧任务 - c.execute("DELETE FROM cron_history WHERE task_id IN (SELECT id FROM cron_tasks)") - c.execute("DELETE FROM cron_logs WHERE task_id IN (SELECT id FROM cron_tasks)") - c.execute("DELETE FROM cron_tasks") - - synced_count = 0 - for cron in user_crons: - # 尝试从命令推断分类和名称 - name = guess_cron_name(cron['command']) - category = guess_cron_category(cron['command']) - - c.execute('''INSERT INTO cron_tasks (name, expression, command, description, category, enabled) - VALUES (?, ?, ?, ?, ?, 1)''', - (name, cron['expression'], cron['command'], '', category)) - synced_count += 1 - - db.commit() - - log_message(f"从系统 crontab 同步了 {synced_count} 个任务(已清空旧任务)") - - return jsonify({'message': f'同步完成,共 {synced_count} 个任务', 'count': synced_count}) - - -# ==================== 系统资源监控 API ==================== - -@app.route('/api/system/stats') -def api_system_stats(): - """获取系统资源信息""" - if not HAS_PSUTIL: - return jsonify({'error': 'psutil未安装', 'available': False}) - - try: - # CPU - cpu_percent = psutil.cpu_percent(interval=0.5) - cpu_count = psutil.cpu_count(logical=False) or psutil.cpu_count() - cpu_count_logical = psutil.cpu_count(logical=True) - - # CPU 温度 - cpu_temps = get_cpu_temperatures() - - # 内存 - mem = psutil.virtual_memory() - mem_total_gb = round(mem.total / (1024**3), 2) - mem_used_gb = round(mem.used / (1024**3), 2) - mem_available_gb = round(mem.available / (1024**3), 2) - mem_percent = mem.percent - - # 磁盘 - 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 - - # 网络流量和网速计算 - net = psutil.net_io_counters() - current_time = time.time() - - # 计算网速 (bytes/s) - time_diff = current_time - _last_net_stats['time'] if _last_net_stats['time'] > 0 else 1 - sent_speed = (net.bytes_sent - _last_net_stats['bytes_sent']) / time_diff if time_diff > 0 else 0 - recv_speed = (net.bytes_recv - _last_net_stats['bytes_recv']) / time_diff if time_diff > 0 else 0 - - # 保存当前数据用于下次计算 - _last_net_stats['bytes_sent'] = net.bytes_sent - _last_net_stats['bytes_recv'] = net.bytes_recv - _last_net_stats['time'] = current_time - - # 转换为 KB/s 或 MB/s - sent_speed_kb = sent_speed / 1024 - recv_speed_kb = recv_speed / 1024 - sent_speed_display = f"{sent_speed_kb:.1f} KB/s" if sent_speed_kb < 1024 else f"{sent_speed_kb/1024:.2f} MB/s" - recv_speed_display = f"{recv_speed_kb:.1f} KB/s" if recv_speed_kb < 1024 else f"{recv_speed_kb/1024:.2f} MB/s" - - net_sent_mb = round(net.bytes_sent / (1024**2), 2) - net_recv_mb = round(net.bytes_recv / (1024**2), 2) - - # 系统启动时间 - boot_time = datetime.fromtimestamp(psutil.boot_time()).strftime('%Y-%m-%d %H:%M:%S') - uptime_seconds = int(datetime.now().timestamp() - psutil.boot_time()) - uptime_hours = uptime_seconds // 3600 - uptime_minutes = (uptime_seconds % 3600) // 60 - - # 进程数 - process_count = len(psutil.pids()) - - return jsonify({ - 'available': True, - 'cpu': { - 'percent': cpu_percent, - 'count': cpu_count, - 'logical': cpu_count_logical, - 'temp_package_0': cpu_temps['package_0'], - 'temp_package_1': cpu_temps['package_1'] - }, - 'memory': { - 'total_gb': mem_total_gb, - 'used_gb': mem_used_gb, - 'available_gb': mem_available_gb, - 'percent': mem_percent - }, - 'disk': { - 'total_gb': disk_total_gb, - 'used_gb': disk_used_gb, - 'free_gb': disk_free_gb, - 'percent': disk_percent - }, - 'network': { - 'sent_mb': net_sent_mb, - 'recv_mb': net_recv_mb, - 'sent_speed': sent_speed_display, - 'recv_speed': recv_speed_display - }, - 'system': { - 'boot_time': boot_time, - 'uptime': f'{uptime_hours}小时{uptime_minutes}分钟', - 'process_count': process_count - } - }) - except Exception as e: - return jsonify({'error': str(e), 'available': False}) - - -@app.route('/api/system/processes') -def api_system_processes(): - """获取CPU占用最高的进程列表""" - if not HAS_PSUTIL: - return jsonify({'error': 'psutil未安装', 'available': False}) - - try: - limit = int(request.args.get('limit', 10)) - limit = max(1, min(50, limit)) # 限制1-50 - - processes = [] - for proc in psutil.process_iter(['pid', 'name', 'memory_percent', 'status', 'create_time', 'exe', 'cmdline']): - try: - pinfo = proc.info - # 获取实时CPU占用(需要单独调用) - cpu_percent = proc.cpu_percent(interval=0.1) - - # 格式化命令行 - cmdline = pinfo.get('cmdline', []) - cmdline_str = ' '.join(cmdline) if cmdline else pinfo['name'] - if len(cmdline_str) > 60: - cmdline_str = cmdline_str[:60] + '...' - - # 启动时间 - create_time = datetime.fromtimestamp(pinfo['create_time']).strftime('%m-%d %H:%M') - - processes.append({ - 'pid': pinfo['pid'], - 'name': pinfo['name'], - 'cpu': cpu_percent, - 'memory': pinfo['memory_percent'] or 0, - 'status': pinfo['status'], - 'create_time': create_time, - 'exe': pinfo.get('exe', '') or '', - 'cmdline': cmdline_str - }) - except (psutil.NoSuchProcess, psutil.AccessDenied): - continue - - # 按CPU占用排序,取前N个 - processes.sort(key=lambda x: x['cpu'], reverse=True) - top_processes = processes[:limit] - - return jsonify({ - 'available': True, - 'processes': top_processes, - 'count': len(top_processes) - }) - except Exception as e: - return jsonify({'error': str(e), 'available': False}) - - -def guess_cron_name(command): - """从命令推断任务名称""" - keywords = { - 'service-monitor': '服务监控', - 'board_monitor': 'A股板块监控', - 'daily-summary': '每日总结', - 'backup': '数据备份', - 'cleanup': '清理任务', - 'xian-favor': 'Xian Favor 备份', - } - - for keyword, name in keywords.items(): - if keyword in command: - return name - - # 从文件名推断 - match = re.search(r'/([^/]+\.py)', command) - if match: - return match.group(1).replace('.py', '').replace('_', ' ') - - return '未命名任务' - - -def guess_cron_category(command): - """从命令推断任务分类""" - if 'monitor' in command or 'check' in command: - return 'monitor' - if 'backup' in command: - return 'backup' - if 'cleanup' in command or 'clean' in command: - return 'cleanup' - if 'report' in command or 'summary' in command: - return 'report' - if 'sync' in command: - return 'sync' - if 'notify' in command or 'mail' in command: - return 'notification' - return 'other' - - -def get_next_run_time(expression): - """获取下次执行时间""" - try: - from croniter import croniter - base = datetime.now() - cron = croniter(expression, base) - next_time = cron.get_next(datetime) - return next_time.strftime('%Y-%m-%d %H:%M:%S') - except: - return '无法计算' - - -# ==================== 配置管理 API ==================== - -CONFIG_FILE = 'config.json' - -def load_full_config(): - """加载完整配置(合并项目和预警配置)""" - # 加载 projects.json 完整数据 - try: - with open(PROJECTS_FILE, 'r', encoding='utf-8') as f: - projects_data = json.load(f) - except: - projects_data = {'server': {}, 'projects': [], 'external_services': []} - - # 加载预警配置 - alert_config = {} - if os.path.exists('alert_config.json'): - try: - with open('alert_config.json', 'r', encoding='utf-8') as f: - alert_config = json.load(f) - except: - pass - - # 合并配置 - config = { - "version": "1.0", - "exported_at": datetime.now().strftime('%Y-%m-%d %H:%M:%S'), - "server": projects_data.get('server', {}), - "projects": projects_data.get('projects', []), - "external_services": projects_data.get('external_services', []), - "web_settings": { - "external_ip": alert_config.get('web_settings', {}).get('external_ip', '121.40.164.32'), - "refresh_interval": alert_config.get('web_settings', {}).get('refresh_interval', 30) - }, - "alerts": alert_config.get('alerts', alert_config) # 支持两种结构 - } - return config - -def save_full_config(config): - """保存完整配置""" - # 保存 projects 部分 - projects_data = { - "server": config.get('server', {}), - "projects": config.get('projects', []), - "external_services": config.get('external_services', []) - } - with open(PROJECTS_FILE, 'w', encoding='utf-8') as f: - json.dump(projects_data, f, ensure_ascii=False, indent=2) - - # 保存预警配置和网页设置 - alert_config = { - "web_settings": config.get('web_settings', {}), - "thresholds": config.get('alerts', {}).get('thresholds', config.get('alerts', {})) - } - with open('alert_config.json', 'w', encoding='utf-8') as f: - json.dump(alert_config, f, ensure_ascii=False, indent=2) - - return True - -@app.route('/api/config/export', methods=['GET']) -def api_config_export(): - """导出配置文件""" - try: - config = load_full_config() - return jsonify(config) - except Exception as e: - return jsonify({'error': str(e)}), 500 - -@app.route('/api/config/import', methods=['POST']) -def api_config_import(): - """导入配置文件""" - try: - config = request.get_json() - if not config: - return jsonify({'error': '无效的配置数据'}), 400 - - # 验证配置结构 - required_keys = ['server', 'projects'] - for key in required_keys: - if key not in config: - return jsonify({'error': f'缺少必要字段: {key}'}), 400 - - # 备份当前配置 - backup_time = datetime.now().strftime('%Y%m%d_%H%M%S') - if os.path.exists(PROJECTS_FILE): - shutil.copy(PROJECTS_FILE, f'{PROJECTS_FILE}.bak.{backup_time}') - if os.path.exists('alert_config.json'): - shutil.copy('alert_config.json', f'alert_config.json.bak.{backup_time}') - - # 保存新配置 - save_full_config(config) - - return jsonify({ - 'message': '配置导入成功', - 'projects_count': len(config.get('projects', [])), - 'services_count': len(config.get('external_services', [])) - }) - except Exception as e: - return jsonify({'error': str(e)}), 500 - -@app.route('/api/alerts', methods=['GET']) -def api_get_alerts(): - """获取预警配置""" - try: - if os.path.exists('alert_config.json'): - with open('alert_config.json', 'r', encoding='utf-8') as f: - return jsonify(json.load(f)) - return jsonify({}) - except Exception as e: - return jsonify({'error': str(e)}), 500 - -@app.route('/api/alerts', methods=['POST']) -def api_save_alerts(): - """保存预警配置""" - try: - config = request.get_json() - - # 加载现有配置 - existing_config = {} - if os.path.exists('alert_config.json'): - try: - with open('alert_config.json', 'r', encoding='utf-8') as f: - existing_config = json.load(f) - except: - pass - - # 合并配置(保留web_settings) - if 'thresholds' in config: - existing_config['thresholds'] = config['thresholds'] - if 'web_settings' in config: - existing_config['web_settings'] = config['web_settings'] - - with open('alert_config.json', 'w', encoding='utf-8') as f: - json.dump(existing_config, f, ensure_ascii=False, indent=2) - return jsonify({'message': '保存成功'}) - except Exception as e: - return jsonify({'error': str(e)}), 500 - -@app.route('/api/web-settings', methods=['GET']) -def api_get_web_settings(): - """获取网页设置""" - try: - if os.path.exists('alert_config.json'): - with open('alert_config.json', 'r', encoding='utf-8') as f: - config = json.load(f) - web_settings = config.get('web_settings', {}) - return jsonify(web_settings) - return jsonify({'external_ip': '121.40.164.32', 'refresh_interval': 30}) - except Exception as e: - return jsonify({'error': str(e)}), 500 - -@app.route('/api/web-settings', methods=['POST']) -def api_save_web_settings(): - """保存网页设置""" - try: - settings = request.get_json() - - # 加载现有配置 - existing_config = {} - if os.path.exists('alert_config.json'): - try: - with open('alert_config.json', 'r', encoding='utf-8') as f: - existing_config = json.load(f) - except: - pass - - # 更新网页设置 - existing_config['web_settings'] = settings - - with open('alert_config.json', 'w', encoding='utf-8') as f: - json.dump(existing_config, f, ensure_ascii=False, indent=2) - return jsonify({'message': '保存成功'}) - except Exception as e: - return jsonify({'error': str(e)}), 500 - - -# ==================== 邮件通知 API ==================== - -@app.route('/api/email/send', methods=['POST']) -def api_email_send(): - """发送邮件通知""" - data = request.get_json() - if not data: - return jsonify({'error': '无效数据'}), 400 - - to_email = data.get('to') - subject = data.get('subject') - body = data.get('body') - - if not to_email or not subject or not body: - return jsonify({'error': '缺少必要参数'}), 400 - - try: - import smtplib - from email.mime.text import MIMEText - from email.mime.multipart import MIMEMultipart - from datetime import datetime - - # 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@!') - - server = smtplib.SMTP(smtp_host, smtp_port) - server.ehlo() - server.login(smtp_user, smtp_pass) - - msg = MIMEMultipart() - msg['From'] = smtp_user - msg['To'] = to_email - msg['Subject'] = subject - msg['Date'] = datetime.now().strftime('%a, %d %b %Y %H:%M:%S +0800') - msg['Reply-To'] = to_email - msg.attach(MIMEText(body, 'plain', 'utf-8')) - - server.sendmail(smtp_user, to_email, msg.as_string()) - server.quit() - - return jsonify({'success': True, 'message': '邮件已发送'}) - except Exception as e: - return jsonify({'error': str(e)}), 500 - - -@app.route('/api/email/config', methods=['GET', 'POST']) -def api_email_config(): - """获取/设置邮件配置""" - if request.method == 'GET': - return jsonify({ - 'host': os.environ.get('SMTP_HOST', 'mail.tphai.com'), - 'port': int(os.environ.get('SMTP_PORT', 587)), - 'user': os.environ.get('SMTP_USER', 'favor@tphai.com') - }) - else: - data = request.get_json() - # 只保存到环境变量说明,实际使用需要配置环境变量 - return jsonify({'message': '邮件配置需要通过环境变量设置'}) - - -@app.route('/api/crons') -def api_crons(): - """获取系统 cron 列表(兼容旧接口)""" - crons = get_system_crons() - for cron in crons: - cron['description'] = parse_cron_expression(cron['expression']) - return jsonify({'crons': crons}) - - -# ==================== 项目管理 API ==================== - -@app.route('/') -def index(): - return render_template_string(HTML_TEMPLATE) - - -@app.route('/api/projects') -def api_projects(): - projects = load_projects() - for project in projects: - project['status'] = get_project_status(project) - return jsonify({'projects': projects}) - - -@app.route('/api/projects/') -def api_project(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 - project['status'] = get_project_status(project) - return jsonify(project) - - -@app.route('/api/projects//start', methods=['POST']) -def api_start(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 - - if project['type'] != 'web': - return jsonify({'error': '只有Web服务支持启动'}), 400 - - directory = project.get('directory', '') - if directory.startswith('/'): - cwd = directory - else: - cwd = os.path.join(ROOT_DIR, directory) - - if not os.path.exists(cwd): - return jsonify({'error': f'目录不存在: {cwd}'}), 400 - - if project.get('start_cmds'): - for name, info in project['start_cmds'].items(): - run_command(info['cmd'], cwd, project_id, f'start-{name}') - else: - cmd = project.get('start_cmd', 'python3 app.py') - run_command(cmd, cwd, project_id, 'start') - - return jsonify({'message': '服务启动中...'}) - - -@app.route('/api/projects//stop', methods=['POST']) -def api_stop(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 - - if project['type'] != 'web': - return jsonify({'error': '只有Web服务支持停止'}), 400 - - ports = project.get('ports', []) - stopped = 0 - for port in ports: - stopped += stop_process(port) - - return jsonify({'message': f'已停止 {stopped} 个进程'}) - - -@app.route('/api/projects//run', methods=['POST']) -def api_run(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 - - if project['type'] != 'cli': - return jsonify({'error': '只有CLI工具支持运行'}), 400 - - directory = project.get('directory', '') - if directory.startswith('/'): - cwd = directory - else: - cwd = os.path.join(ROOT_DIR, directory) - - cmd = project.get('run_cmd', '') - if not cmd: - return jsonify({'error': '未配置运行命令'}), 400 - - run_command(cmd, cwd, project_id, 'run') - return jsonify({'message': 'CLI工具已开始运行...'}) - - -@app.route('/api/projects//log') -def api_log(project_id): - log_file = os.path.join(BASE_DIR, 'logs', f'{project_id}.log') - if os.path.exists(log_file): - with open(log_file, 'r', encoding='utf-8', errors='ignore') as f: - lines = f.readlines()[-1000:] - return jsonify({'log': ''.join(lines)}) - return jsonify({'log': '暂无日志'}) - - -@app.route('/api/projects/add', methods=['POST']) -def api_add_project(): - data = request.json - if not data.get('name') or not data.get('type'): - return jsonify({'error': '缺少必要字段'}), 400 - - projects = load_projects() - project_id = re.sub(r'[^a-z0-9-]', '-', data['name'].lower()) - - if any(p['id'] == project_id for p in projects): - return jsonify({'error': '项目ID已存在'}), 400 - - new_project = { - 'id': project_id, - 'name': data['name'], - 'type': data['type'], - 'description': data.get('description', ''), - 'directory': data.get('directory', ''), - 'version': data.get('version', 'v1.0.0'), - 'git_repo': data.get('git_repo'), - } - - if data['type'] == 'web': - new_project['ports'] = data.get('ports', []) - new_project['start_cmd'] = data.get('start_cmd', 'python3 app.py') - new_project['health_url'] = data.get('health_url') - new_project['admin_url'] = data.get('admin_url') - elif data['type'] == 'cron': - new_project['cron'] = data.get('cron') - new_project['cron_cmd'] = data.get('cron_cmd') - elif data['type'] == 'cli': - new_project['run_cmd'] = data.get('run_cmd') - - projects.append(new_project) - save_projects(projects) - - return jsonify({'message': '项目添加成功', 'project': new_project}) - - -@app.route('/api/projects/', methods=['PUT']) -def api_update_project(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 - - data = request.json - for key, value in data.items(): - if key != 'id': - project[key] = value - - save_projects(projects) - return jsonify({'message': '更新成功', 'project': project}) - - -@app.route('/api/projects/', methods=['DELETE']) -def api_delete_project(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 - - projects = [p for p in projects if p['id'] != project_id] - save_projects(projects) - return jsonify({'message': '删除成功'}) - - -# ==================== HTML 模板 ==================== - -HTML_TEMPLATE = ''' - - - - - 项目服务管理面板 v3.3.1 - - - - - - -
- -
-
-

- - 项目服务管理面板 - v2.1 -

-
-
-
- 对外IP: - -
- - - -
- 间隔: - - s -
- -
-
- 已连接 -
-
-
- - -
- - - -
- - -
- -
-
-
-
-

Web服务

-

-

-
- -
-
-
-
-
-

运行中

-

-

-
- -
-
-
-
-
-

Cron任务

-

-

-
- -
-
-
-
-
-

CLI工具

-

-

-
- -
-
-
- - -
- - - - - - -
- - -
-

加载中...

-
-
- - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - -''' + log_message(f"{APP_NAME} {APP_VERSION} 初始化完成") 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 启动") - log_message("访问地址: http://localhost:16022") - log_message(f"进程PID: {os.getpid()}") - log_message("=" * 50) - - app.run(host='0.0.0.0', port=16022, debug=False) \ No newline at end of file + init_app() + log_message(f"启动 {APP_NAME},端口: {APP_PORT}") + app.run(host='0.0.0.0', port=APP_PORT, debug=False) \ No newline at end of file diff --git a/config.py b/config.py new file mode 100644 index 0000000..05da740 --- /dev/null +++ b/config.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +""" +配置文件 - 定义常量和路径 +""" + +import os + +# 基础目录 +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +WORKSPACE_DIR = os.path.dirname(BASE_DIR) # works目录 +ROOT_DIR = os.path.dirname(WORKSPACE_DIR) # workspace-coder目录 + +# 文件路径 +PROJECTS_FILE = os.path.join(BASE_DIR, 'projects.json') +LOG_FILE = os.path.join(BASE_DIR, 'logs', 'app.log') +DB_FILE = os.path.join(BASE_DIR, 'cron_manager.db') +CRON_BACKUP_DIR = os.path.join(BASE_DIR, 'cron_backups') +CONFIG_FILE = 'config.json' +ALERT_CONFIG_FILE = 'alert_config.json' + +# 应用配置 +APP_NAME = "项目服务管理面板" +APP_VERSION = "v3.4.0" +APP_PORT = 16022 + +# 监控配置 +DEFAULT_THRESHOLDS = { + 'cpu': 80, + 'memory': 85, + 'disk': 90, + 'interval': 60 +} + +DEFAULT_WEB_SETTINGS = { + 'external_ip': '121.40.164.32', + 'refresh_interval': 30 +} \ No newline at end of file diff --git a/database.py b/database.py new file mode 100644 index 0000000..b36a2bd --- /dev/null +++ b/database.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +""" +数据库操作模块 +""" + +import sqlite3 +from flask import g +from config import DB_FILE + + +def get_db(): + """获取数据库连接""" + if 'db' not in g: + g.db = sqlite3.connect(DB_FILE) + g.db.row_factory = sqlite3.Row + return g.db + + +def close_db(error): + """关闭数据库连接""" + if hasattr(g, 'db'): + g.db.close() + + +def init_db(): + """初始化数据库""" + conn = sqlite3.connect(DB_FILE) + conn.row_factory = sqlite3.Row + c = conn.cursor() + + # Cron 任务表 + c.execute('''CREATE TABLE IF NOT EXISTS cron_tasks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + expression TEXT NOT NULL, + command TEXT NOT NULL, + description TEXT, + category TEXT DEFAULT 'other', + enabled INTEGER DEFAULT 1, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT DEFAULT CURRENT_TIMESTAMP + )''') + + # Cron 历史版本表 + c.execute('''CREATE TABLE IF NOT EXISTS cron_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id INTEGER NOT NULL, + version INTEGER NOT NULL, + name TEXT, + expression TEXT, + command TEXT, + description TEXT, + category TEXT, + enabled INTEGER, + operation TEXT, + operator TEXT DEFAULT 'system', + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (task_id) REFERENCES cron_tasks(id) + )''') + + # Cron 执行日志表 + c.execute('''CREATE TABLE IF NOT EXISTS cron_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id INTEGER, + execution_time TEXT DEFAULT CURRENT_TIMESTAMP, + status TEXT, + output TEXT, + FOREIGN KEY (task_id) REFERENCES cron_tasks(id) + )''') + + # 创建索引 + c.execute('CREATE INDEX IF NOT EXISTS idx_cron_history_task ON cron_history(task_id)') + c.execute('CREATE INDEX IF NOT EXISTS idx_cron_logs_task ON cron_logs(task_id)') + + conn.commit() + conn.close() \ No newline at end of file diff --git a/logs/app.log b/logs/app.log index b0eb8fd..fb87f5e 100644 --- a/logs/app.log +++ b/logs/app.log @@ -30302,3 +30302,15 @@ Press CTRL+C to quit [2026-06-01 12:11:34] 进程PID: 780597 [2026-06-01 12:11:34] ================================================== [2026-06-01 12:12:13] 从系统 crontab 同步了 0 个任务(已清空旧任务) +[2026-06-01 12:13:33] ⚠️ 进程收到 SIGTERM 信号,即将退出! +[2026-06-01 12:13:35] ================================================== +[2026-06-01 12:13:35] 项目服务管理面板 v2.0.0 启动 +[2026-06-01 12:13:35] 访问地址: http://localhost:16022 +[2026-06-01 12:13:35] 进程PID: 781142 +[2026-06-01 12:13:35] ================================================== +[2026-06-01 12:29:39] ⚠️ 进程收到 SIGTERM 信号,即将退出! +[2026-06-01 12:29:39] ================================================== +[2026-06-01 12:29:39] 项目服务管理面板 v2.0.0 启动 +[2026-06-01 12:29:39] 访问地址: http://localhost:16022 +[2026-06-01 12:29:39] 进程PID: 784244 +[2026-06-01 12:29:39] ================================================== diff --git a/routes_config.py b/routes_config.py new file mode 100644 index 0000000..2840eed --- /dev/null +++ b/routes_config.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +""" +配置管理 API 路由 +""" + +import os +import json +import shutil +from datetime import datetime +from flask import jsonify, request +from config import PROJECTS_FILE, ALERT_CONFIG_FILE +from utils import log_message + + +def load_full_config(): + """加载完整配置(合并项目和预警配置)""" + # 加载 projects.json 完整数据 + try: + with open(PROJECTS_FILE, 'r', encoding='utf-8') as f: + projects_data = json.load(f) + except: + projects_data = {'server': {}, 'projects': [], 'external_services': []} + + # 加载预警配置 + alert_config = {} + if os.path.exists(ALERT_CONFIG_FILE): + try: + with open(ALERT_CONFIG_FILE, 'r', encoding='utf-8') as f: + alert_config = json.load(f) + except: + pass + + # 合并配置 + config = { + "version": "1.0", + "exported_at": datetime.now().strftime('%Y-%m-%d %H:%M:%S'), + "server": projects_data.get('server', {}), + "projects": projects_data.get('projects', []), + "external_services": projects_data.get('external_services', []), + "web_settings": alert_config.get('web_settings', {}), + "alerts": alert_config.get('thresholds', {}) + } + return config + + +def save_full_config(config): + """保存完整配置""" + # 保存 projects 部分 + projects_data = { + "server": config.get('server', {}), + "projects": config.get('projects', []), + "external_services": config.get('external_services', []) + } + with open(PROJECTS_FILE, 'w', encoding='utf-8') as f: + json.dump(projects_data, f, ensure_ascii=False, indent=2) + + # 保存预警配置和网页设置 + alert_config = { + "web_settings": config.get('web_settings', {}), + "thresholds": config.get('alerts', {}) + } + with open(ALERT_CONFIG_FILE, 'w', encoding='utf-8') as f: + json.dump(alert_config, f, ensure_ascii=False, indent=2) + + return True + + +def register_config_routes(app): + """注册配置管理相关路由""" + + @app.route('/api/config/export', methods=['GET']) + def api_config_export(): + """导出配置文件""" + try: + config = load_full_config() + return jsonify(config) + except Exception as e: + return jsonify({'error': str(e)}), 500 + + @app.route('/api/config/import', methods=['POST']) + def api_config_import(): + """导入配置文件""" + try: + config = request.get_json() + if not config: + return jsonify({'error': '无效的配置数据'}), 400 + + # 验证配置结构 + required_keys = ['server', 'projects'] + for key in required_keys: + if key not in config: + return jsonify({'error': f'缺少必要字段: {key}'}), 400 + + # 备份当前配置 + backup_time = datetime.now().strftime('%Y%m%d_%H%M%S') + if os.path.exists(PROJECTS_FILE): + shutil.copy(PROJECTS_FILE, f'{PROJECTS_FILE}.bak.{backup_time}') + if os.path.exists(ALERT_CONFIG_FILE): + shutil.copy(ALERT_CONFIG_FILE, f'{ALERT_CONFIG_FILE}.bak.{backup_time}') + + # 保存新配置 + save_full_config(config) + + log_message(f"配置导入成功: {len(config.get('projects', []))} 个项目") + + return jsonify({ + 'message': '配置导入成功', + 'projects_count': len(config.get('projects', [])), + 'services_count': len(config.get('external_services', [])) + }) + except Exception as e: + return jsonify({'error': str(e)}), 500 + + @app.route('/api/web-settings', methods=['GET']) + def api_get_web_settings(): + """获取网页设置""" + try: + if os.path.exists(ALERT_CONFIG_FILE): + with open(ALERT_CONFIG_FILE, 'r', encoding='utf-8') as f: + config = json.load(f) + web_settings = config.get('web_settings', {}) + return jsonify(web_settings) + return jsonify({'external_ip': '121.40.164.32', 'refresh_interval': 30}) + except Exception as e: + return jsonify({'error': str(e)}), 500 + + @app.route('/api/web-settings', methods=['POST']) + def api_save_web_settings(): + """保存网页设置""" + try: + settings = request.get_json() + + # 加载现有配置 + existing_config = {} + if os.path.exists(ALERT_CONFIG_FILE): + try: + with open(ALERT_CONFIG_FILE, 'r', encoding='utf-8') as f: + existing_config = json.load(f) + except: + pass + + # 更新网页设置 + existing_config['web_settings'] = settings + + with open(ALERT_CONFIG_FILE, 'w', encoding='utf-8') as f: + json.dump(existing_config, f, ensure_ascii=False, indent=2) + + return jsonify({'message': '保存成功'}) + except Exception as e: + return jsonify({'error': str(e)}), 500 \ No newline at end of file diff --git a/routes_cron.py b/routes_cron.py new file mode 100644 index 0000000..89bc7fb --- /dev/null +++ b/routes_cron.py @@ -0,0 +1,254 @@ +#!/usr/bin/env python3 +""" +Cron 管理 API 路由 +""" + +from flask import jsonify, request +from database import get_db +from utils import ( + log_message, sync_crontab_from_db, get_system_crons, + cron_expression_to_human, get_next_run_time, guess_cron_name, guess_cron_category +) + + +def register_cron_routes(app): + """注册 Cron 相关路由""" + + @app.route('/api/cron/tasks') + def api_cron_tasks(): + """获取数据库中的 Cron 任务列表""" + db = get_db() + c = db.cursor() + c.execute("SELECT * FROM cron_tasks ORDER BY created_at DESC") + tasks = [dict(row) for row in c.fetchall()] + + # 添加人类可读描述和下次执行时间 + for task in tasks: + task['human_time'] = cron_expression_to_human(task['expression']) + task['next_run'] = get_next_run_time(task['expression']) + + return jsonify({'tasks': tasks}) + + @app.route('/api/cron/tasks', methods=['POST']) + def api_cron_add(): + """添加新的 Cron 任务""" + data = request.json + + if not data.get('name') or not data.get('expression') or not data.get('command'): + return jsonify({'error': '缺少必要字段'}), 400 + + db = get_db() + c = db.cursor() + + # 插入任务 + c.execute('''INSERT INTO cron_tasks (name, expression, command, description, category, enabled) + VALUES (?, ?, ?, ?, ?, ?)''', + (data['name'], data['expression'], data['command'], data.get('description', ''), data.get('category', 'other'), 1)) + + task_id = c.lastrowid + db.commit() + + # 记录历史(版本1) + c.execute('''INSERT INTO cron_history (task_id, version, name, expression, command, description, category, enabled, operation) + VALUES (?, 1, ?, ?, ?, ?, ?, ?, 'create')''', + (task_id, data['name'], data['expression'], data['command'], data.get('description', ''), data.get('category', 'other'), 1)) + db.commit() + + # 同步到系统 crontab + sync_crontab_from_db(db) + + log_message(f"添加 Cron 任务: {data['name']} ({data['expression']})") + + return jsonify({'message': '任务添加成功', 'task_id': task_id}) + + @app.route('/api/cron/tasks/', methods=['PUT']) + def api_cron_update(task_id): + """更新 Cron 任务""" + data = request.json + db = get_db() + c = db.cursor() + + # 获取当前任务 + c.execute("SELECT * FROM cron_tasks WHERE id = ?", (task_id,)) + old_task = c.fetchone() + if not old_task: + return jsonify({'error': '任务不存在'}), 404 + + # 获取当前版本号 + c.execute("SELECT MAX(version) FROM cron_history WHERE task_id = ?", (task_id,)) + max_version = c.fetchone()[0] or 0 + new_version = max_version + 1 + + # 更新任务 + update_fields = [] + update_values = [] + for field in ['name', 'expression', 'command', 'description', 'category', 'enabled']: + if field in data: + update_fields.append(f"{field} = ?") + update_values.append(data[field]) + + if update_fields: + update_fields.append("updated_at = CURRENT_TIMESTAMP") + c.execute(f"UPDATE cron_tasks SET {','.join(update_fields)} WHERE id = ?", update_values + [task_id]) + db.commit() + + # 记录历史 + c.execute('''INSERT INTO cron_history (task_id, version, name, expression, command, description, category, enabled, operation) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'update')''', + (task_id, new_version, data.get('name', old_task['name']), data.get('expression', old_task['expression']), + data.get('command', old_task['command']), data.get('description', old_task['description']), + data.get('category', old_task['category']), data.get('enabled', old_task['enabled']))) + db.commit() + + # 同步到系统 crontab + sync_crontab_from_db(db) + + log_message(f"更新 Cron 任务 ID={task_id}: 版本 {new_version}") + + return jsonify({'message': '任务更新成功', 'version': new_version}) + + @app.route('/api/cron/tasks/', methods=['DELETE']) + def api_cron_delete(task_id): + """删除 Cron 任务""" + db = get_db() + c = db.cursor() + + c.execute("SELECT * FROM cron_tasks WHERE id = ?", (task_id,)) + task = c.fetchone() + if not task: + return jsonify({'error': '任务不存在'}), 404 + + # 记录删除历史 + c.execute("SELECT MAX(version) FROM cron_history WHERE task_id = ?", (task_id,)) + max_version = c.fetchone()[0] or 0 + + c.execute('''INSERT INTO cron_history (task_id, version, name, expression, command, description, category, enabled, operation) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'delete')''', + (task_id, max_version + 1, task['name'], task['expression'], task['command'], task['description'], task['category'], task['enabled'])) + db.commit() + + # 删除任务 + c.execute("DELETE FROM cron_tasks WHERE id = ?", (task_id,)) + db.commit() + + # 同步到系统 crontab + sync_crontab_from_db(db) + + log_message(f"删除 Cron 任务 ID={task_id}: {task['name']}") + + return jsonify({'message': '任务删除成功'}) + + @app.route('/api/cron/tasks//toggle', methods=['POST']) + def api_cron_toggle(task_id): + """启用/禁用 Cron 任务""" + db = get_db() + c = db.cursor() + + c.execute("SELECT enabled FROM cron_tasks WHERE id = ?", (task_id,)) + task = c.fetchone() + if not task: + return jsonify({'error': '任务不存在'}), 404 + + new_enabled = 0 if task['enabled'] == 1 else 1 + c.execute("UPDATE cron_tasks SET enabled = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", (new_enabled, task_id)) + db.commit() + + # 同步到系统 crontab + sync_crontab_from_db(db) + + log_message(f"切换 Cron 任务 ID={task_id} 状态: {new_enabled}") + + return jsonify({'message': '状态已切换', 'enabled': new_enabled}) + + @app.route('/api/cron/tasks//history') + def api_cron_history(task_id): + """获取任务历史版本""" + db = get_db() + c = db.cursor() + c.execute("SELECT * FROM cron_history WHERE task_id = ? ORDER BY version DESC", (task_id,)) + history = [dict(row) for row in c.fetchall()] + + for h in history: + if h['expression']: + h['human_time'] = cron_expression_to_human(h['expression']) + + return jsonify({'history': history}) + + @app.route('/api/cron/tasks//rollback/', methods=['POST']) + def api_cron_rollback(task_id, version): + """回退到指定版本""" + db = get_db() + c = db.cursor() + + c.execute("SELECT * FROM cron_history WHERE task_id = ? AND version = ?", (task_id, version)) + history = c.fetchone() + if not history: + return jsonify({'error': '版本不存在'}), 404 + + # 更新任务到历史版本 + c.execute('''UPDATE cron_tasks SET name = ?, expression = ?, command = ?, description = ?, category = ?, enabled = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ?''', + (history['name'], history['expression'], history['command'], history['description'], history['category'], history['enabled'], task_id)) + db.commit() + + # 同步到系统 crontab + sync_crontab_from_db(db) + + log_message(f"回退 Cron 任务 ID={task_id} 到版本 {version}") + + return jsonify({'message': f'已回退到版本 {version}'}) + + @app.route('/api/cron/tasks//logs') + def api_cron_logs(task_id): + """获取任务执行日志""" + db = get_db() + c = db.cursor() + c.execute("SELECT * FROM cron_logs WHERE task_id = ? ORDER BY execution_time DESC LIMIT 100", (task_id,)) + logs = [dict(row) for row in c.fetchall()] + + return jsonify({'logs': logs}) + + @app.route('/api/cron/categories') + def api_cron_categories(): + """获取任务分类列表""" + categories = [ + {'id': 'monitor', 'name': '系统监控', 'icon': 'ri-heart-pulse-line', 'color': 'blue'}, + {'id': 'backup', 'name': '数据备份', 'icon': 'ri-archive-line', 'color': 'green'}, + {'id': 'cleanup', 'name': '清理任务', 'icon': 'ri-delete-bin-line', 'color': 'yellow'}, + {'id': 'report', 'name': '报表生成', 'icon': 'ri-file-chart-line', 'color': 'purple'}, + {'id': 'sync', 'name': '数据同步', 'icon': 'ri-refresh-line', 'color': 'cyan'}, + {'id': 'notification', 'name': '通知提醒', 'icon': 'ri-notification-line', 'color': 'orange'}, + {'id': 'other', 'name': '其他任务', 'icon': 'ri-more-line', 'color': 'gray'}, + ] + return jsonify({'categories': categories}) + + @app.route('/api/cron/sync', methods=['POST']) + def api_cron_sync(): + """从系统 crontab 同步到数据库(清空旧的,只保留当前系统实际存在的)""" + crons = get_system_crons() + user_crons = [c for c in crons if c['source'] == 'user'] + + db = get_db() + c = db.cursor() + + # 清空数据库中的旧任务 + c.execute("DELETE FROM cron_history WHERE task_id IN (SELECT id FROM cron_tasks)") + c.execute("DELETE FROM cron_logs WHERE task_id IN (SELECT id FROM cron_tasks)") + c.execute("DELETE FROM cron_tasks") + + synced_count = 0 + for cron in user_crons: + # 尝试从命令推断分类和名称 + name = guess_cron_name(cron['command']) + category = guess_cron_category(cron['command']) + + c.execute('''INSERT INTO cron_tasks (name, expression, command, description, category, enabled) + VALUES (?, ?, ?, ?, ?, 1)''', + (name, cron['expression'], cron['command'], '', category)) + synced_count += 1 + + db.commit() + + log_message(f"从系统 crontab 同步了 {synced_count} 个任务(已清空旧任务)") + + return jsonify({'message': f'同步完成,共 {synced_count} 个任务', 'count': synced_count}) \ No newline at end of file diff --git a/routes_email.py b/routes_email.py new file mode 100644 index 0000000..20de52d --- /dev/null +++ b/routes_email.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +""" +邮件通知 API 路由 +""" + +import os +import smtplib +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +from flask import jsonify, request +from utils import log_message + + +def register_email_routes(app): + """注册邮件通知相关路由""" + + @app.route('/api/email/send', methods=['POST']) + def api_email_send(): + """发送邮件通知""" + data = request.get_json() + if not data: + return jsonify({'error': '无效数据'}), 400 + + to_email = data.get('to') + subject = data.get('subject') + body = data.get('body') + + if not to_email or not subject or not body: + 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@!') + + # 创建邮件 + msg = MIMEMultipart() + msg['From'] = smtp_user + msg['To'] = to_email + msg['Subject'] = subject + + # 添加正文 + msg.attach(MIMEText(body, 'plain', 'utf-8')) + + # 发送邮件 + server = smtplib.SMTP(smtp_host, smtp_port) + server.login(smtp_user, smtp_pass) + server.send_message(msg) + server.quit() + + log_message(f"邮件发送成功: {subject} -> {to_email}") + + return jsonify({'message': '邮件发送成功'}) + except Exception as e: + log_message(f"邮件发送失败: {e}") + return jsonify({'error': str(e)}), 500 \ No newline at end of file diff --git a/routes_project.py b/routes_project.py new file mode 100644 index 0000000..d55420b --- /dev/null +++ b/routes_project.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +""" +项目管理 API 路由 +""" + +import json +from flask import jsonify, request +from config import PROJECTS_FILE +from utils import ( + log_message, load_projects, load_projects_full, save_projects, + check_port, stop_process, run_command +) + + +def register_project_routes(app): + """注册项目管理相关路由""" + + @app.route('/api/projects') + def api_projects(): + """获取项目列表""" + projects = load_projects() + + # 添加状态信息 + for project in projects: + ports = project.get('ports', []) + project['status'] = 'running' if all(check_port(p) for p in ports) else 'stopped' + + return jsonify({ + 'projects': projects, + 'server': load_projects_full().get('server', {}) + }) + + @app.route('/api/projects//start', methods=['POST']) + def api_start(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 + + if project['type'] != 'web': + return jsonify({'error': '只有Web服务支持启动'}), 400 + + cwd = project.get('directory', '') + if not cwd: + return jsonify({'error': '项目目录未配置'}), 400 + + from config import WORKSPACE_DIR + import os + cwd = os.path.join(WORKSPACE_DIR, cwd) if not cwd.startswith('/') else cwd + + if not os.path.exists(cwd): + return jsonify({'error': f'目录不存在: {cwd}'}), 400 + + if project.get('start_cmds'): + for name, info in project['start_cmds'].items(): + run_command(info['cmd'], cwd, project_id, f'start-{name}') + else: + cmd = project.get('start_cmd', 'python3 app.py') + run_command(cmd, cwd, project_id, 'start') + + return jsonify({'message': '服务启动中...'}) + + @app.route('/api/projects//stop', methods=['POST']) + def api_stop(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 + + if project['type'] != 'web': + return jsonify({'error': '只有Web服务支持停止'}), 400 + + ports = project.get('ports', []) + stopped = 0 + for port in ports: + stopped += stop_process(port) + + return jsonify({'message': f'已停止 {stopped} 个进程'}) + + @app.route('/api/projects//restart', methods=['POST']) + def api_restart(project_id): + """重启服务""" + # 先停止 + api_stop(project_id) + + # 等待1秒 + import time + time.sleep(1) + + # 再启动 + return api_start(project_id) + + @app.route('/api/projects/add', methods=['POST']) + def api_add_project(): + """添加新项目""" + data = request.get_json() + + if not data.get('id') or not data.get('name') or not data.get('ports'): + return jsonify({'error': '缺少必要字段'}), 400 + + projects = load_projects() + + # 检查ID是否已存在 + if any(p['id'] == data['id'] for p in projects): + return jsonify({'error': '项目ID已存在'}), 400 + + # 添加项目 + project = { + 'id': data['id'], + 'name': data['name'], + 'type': data.get('type', 'web'), + 'ports': data['ports'], + 'directory': data.get('directory', ''), + 'start_cmd': data.get('start_cmd', ''), + 'stop_cmd': data.get('stop_cmd', ''), + 'health_url': data.get('health_url', ''), + 'description': data.get('description', ''), + 'git_repo': data.get('git_repo', ''), + 'version': data.get('version', 'v1.0.0') + } + + projects.append(project) + save_projects(projects) + + log_message(f"添加项目: {project['name']}") + + return jsonify({'message': '项目添加成功', 'project': project}) + + @app.route('/api/projects/', methods=['PUT']) + def api_update_project(project_id): + """更新项目配置""" + data = request.get_json() + projects = load_projects() + + project = next((p for p in projects if p['id'] == project_id), None) + if not project: + return jsonify({'error': '项目不存在'}), 404 + + # 更新字段 + for key in ['name', 'ports', 'directory', 'start_cmd', 'stop_cmd', 'health_url', 'description', 'git_repo', 'version']: + if key in data: + project[key] = data[key] + + save_projects(projects) + + log_message(f"更新项目: {project['name']}") + + return jsonify({'message': '项目更新成功'}) + + @app.route('/api/projects/', methods=['DELETE']) + def api_delete_project(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 + + projects = [p for p in projects if p['id'] != project_id] + save_projects(projects) + + log_message(f"删除项目: {project['name']}") + + return jsonify({'message': '项目删除成功'}) + + @app.route('/api/external-services') + def api_external_services(): + """获取外部服务列表""" + data = load_projects_full() + services = data.get('external_services', []) + + # 添加状态 + for service in services: + ports = service.get('ports', []) + service['status'] = 'running' if all(check_port(p) for p in ports) else 'stopped' + + return jsonify({'services': services}) + + @app.route('/api/external-services/add', methods=['POST']) + def api_add_external_service(): + """添加外部服务""" + data = request.get_json() + + if not data.get('id') or not data.get('name') or not data.get('ports'): + return jsonify({'error': '缺少必要字段'}), 400 + + full_data = load_projects_full() + services = full_data.get('external_services', []) + + # 检查ID是否已存在 + if any(s['id'] == data['id'] for s in services): + return jsonify({'error': '服务ID已存在'}), 400 + + # 添加服务 + service = { + 'id': data['id'], + 'name': data['name'], + 'type': 'external', + 'ports': data['ports'], + 'health_url': data.get('health_url', ''), + 'description': data.get('description', '') + } + + services.append(service) + full_data['external_services'] = services + + with open(PROJECTS_FILE, 'w', encoding='utf-8') as f: + json.dump(full_data, f, ensure_ascii=False, indent=2) + + log_message(f"添加外部服务: {service['name']}") + + return jsonify({'message': '服务添加成功', 'service': service}) \ No newline at end of file diff --git a/routes_system.py b/routes_system.py new file mode 100644 index 0000000..5d67b97 --- /dev/null +++ b/routes_system.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +""" +系统资源监控 API 路由 +""" + +import os +import subprocess +import time +from flask import jsonify +from utils import log_message, get_cpu_temperatures, load_alert_config + +try: + import psutil + HAS_PSUTIL = True + _last_net_stats = {'bytes_sent': 0, 'bytes_recv': 0, 'time': 0} +except ImportError: + HAS_PSUTIL = False + + +def register_system_routes(app): + """注册系统监控相关路由""" + + @app.route('/api/system/stats') + def api_system_stats(): + """获取系统资源信息""" + if not HAS_PSUTIL: + return jsonify({'error': 'psutil未安装', 'available': False}) + + try: + # CPU + cpu_percent = psutil.cpu_percent(interval=0.5) + cpu_count = psutil.cpu_count(logical=False) or psutil.cpu_count() + cpu_count_logical = psutil.cpu_count(logical=True) + + # CPU 温度 + cpu_temps = get_cpu_temperatures() + + # 内存 + mem = psutil.virtual_memory() + mem_total_gb = round(mem.total / (1024**3), 2) + mem_used_gb = round(mem.used / (1024**3), 2) + mem_available_gb = round(mem.available / (1024**3), 2) + mem_percent = mem.percent + + # 磁盘 + disk = psutil.disk_usage('/') + disk_total_gb = round(disk.total / (1024**3), 2) + disk_used_gb = round(disk.used / (1024**3), 2) + disk_percent = disk.percent + + # 网速(需要计算差值) + global _last_net_stats + current_time = time.time() + net = psutil.net_io_counters() + + if _last_net_stats['time'] > 0: + time_diff = current_time - _last_net_stats['time'] + bytes_sent_diff = net.bytes_sent - _last_net_stats['bytes_sent'] + bytes_recv_diff = net.bytes_recv - _last_net_stats['bytes_recv'] + + upload_speed = (bytes_sent_diff / time_diff) / 1024 # KB/s + download_speed = (bytes_recv_diff / time_diff) / 1024 # KB/s + else: + upload_speed = 0 + download_speed = 0 + + _last_net_stats = { + 'bytes_sent': net.bytes_sent, + 'bytes_recv': net.bytes_recv, + 'time': current_time + } + + # 进程数量 + process_count = len(psutil.pids()) + + # 系统启动时间 + boot_time = psutil.boot_time() + uptime_seconds = time.time() - boot_time + uptime_hours = int(uptime_seconds // 3600) + uptime_days = int(uptime_hours // 24) + uptime_str = f"{uptime_days}天 {uptime_hours % 24}小时" + + return jsonify({ + 'available': True, + 'cpu': { + 'percent': cpu_percent, + 'count_physical': cpu_count, + 'count_logical': cpu_count_logical, + 'temp_package_0': cpu_temps.get('package_0'), + 'temp_package_1': cpu_temps.get('package_1') + }, + 'memory': { + 'total_gb': mem_total_gb, + 'used_gb': mem_used_gb, + 'available_gb': mem_available_gb, + 'percent': mem_percent + }, + 'disk': { + 'total_gb': disk_total_gb, + 'used_gb': disk_used_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 + }) + except Exception as e: + return jsonify({'error': str(e)}), 500 + + @app.route('/api/system/processes') + def api_system_processes(): + """获取进程列表""" + if not HAS_PSUTIL: + return jsonify({'error': 'psutil未安装'}), 500 + + try: + processes = [] + for proc in psutil.process_iter(['pid', 'name', 'cpu_percent', 'memory_percent', 'status']): + try: + processes.append({ + 'pid': proc.info['pid'], + 'name': proc.info['name'], + 'cpu': proc.info['cpu_percent'] or 0, + 'memory': proc.info['memory_percent'] or 0, + 'status': proc.info['status'] + }) + except: + pass + + # 按CPU排序 + processes.sort(key=lambda x: x['cpu'], reverse=True) + processes = processes[:50] # 只返回前50个 + + return jsonify({'processes': processes}) + except Exception as e: + return jsonify({'error': str(e)}), 500 + + @app.route('/api/system/docker') + def api_system_docker(): + """获取 Docker 容器状态""" + try: + result = subprocess.run(['docker', 'ps', '--format', '{{.Names}}|{{.Status}}|{{.Ports}}'], + capture_output=True, text=True, timeout=5) + containers = [] + for line in result.stdout.strip().split('\n'): + if line: + parts = line.split('|') + if len(parts) >= 2: + containers.append({ + 'name': parts[0], + 'status': parts[1], + 'ports': parts[2] if len(parts) > 2 else '' + }) + return jsonify({'containers': containers}) + except Exception as e: + return jsonify({'error': str(e), 'containers': []}) + + @app.route('/api/alerts', methods=['GET']) + def api_get_alerts(): + """获取预警配置""" + config = load_alert_config() + return jsonify(config) + + @app.route('/api/alerts', methods=['POST']) + def api_save_alerts(): + """保存预警配置""" + from flask import request + import json + from config import ALERT_CONFIG_FILE + + try: + data = request.get_json() + config = load_alert_config() + + if 'thresholds' in data: + config['thresholds'] = data['thresholds'] + if 'web_settings' in data: + config['web_settings'] = data['web_settings'] + + with open(ALERT_CONFIG_FILE, 'w', encoding='utf-8') as f: + json.dump(config, f, ensure_ascii=False, indent=2) + + return jsonify({'message': '保存成功'}) + except Exception as e: + return jsonify({'error': str(e)}), 500 \ No newline at end of file diff --git a/templates.py b/templates.py new file mode 100644 index 0000000..18b0145 --- /dev/null +++ b/templates.py @@ -0,0 +1,590 @@ +#!/usr/bin/env python3 +""" +HTML 模板 +""" + +from config import APP_NAME, APP_VERSION + + +def get_html_template(): + """返回完整的HTML模板""" + return ''' + + + + + ''' + APP_NAME + ''' + + + + + +
+ +
+
+

+ + ''' + APP_NAME + ''' + ''' + APP_VERSION + ''' +

+
+
+
+ 对外IP: + +
+ + + +
+ 间隔: + + s +
+ +
+
+ 已连接 +
+
+
+ + +
+ + + +
+ + +
+
+ +
+
+ + + + + + +
+ + + + + + + +''' \ No newline at end of file diff --git a/utils.py b/utils.py new file mode 100644 index 0000000..0744b71 --- /dev/null +++ b/utils.py @@ -0,0 +1,303 @@ +#!/usr/bin/env python3 +""" +工具函数模块 +""" + +import os +import json +import subprocess +import re +import signal +import shutil +from datetime import datetime +from config import LOG_FILE, PROJECTS_FILE, CRON_BACKUP_DIR, ALERT_CONFIG_FILE + + +# 进程状态缓存 +process_cache = {} + + +def log_message(msg): + """写入日志""" + timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + log_line = f"[{timestamp}] {msg}\n" + print(log_line.strip()) + try: + os.makedirs(os.path.dirname(LOG_FILE), exist_ok=True) + with open(LOG_FILE, 'a') as f: + f.write(log_line) + except: + pass + + +def load_projects(): + """加载项目配置""" + try: + with open(PROJECTS_FILE, 'r', encoding='utf-8') as f: + data = json.load(f) + return data.get('projects', []) + except: + return [] + + +def load_projects_full(): + """加载完整项目配置(包含server和external_services)""" + try: + with open(PROJECTS_FILE, 'r', encoding='utf-8') as f: + return json.load(f) + except: + return {'server': {}, 'projects': [], 'external_services': []} + + +def save_projects(projects): + """保存项目配置""" + with open(PROJECTS_FILE, 'w', encoding='utf-8') as f: + json.dump({'projects': projects}, f, ensure_ascii=False, indent=2) + + +def check_port(port): + """检查端口是否有进程监听""" + try: + import socket + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(1) + result = sock.connect_ex(('localhost', port)) + sock.close() + return result == 0 + except: + return False + + +def stop_process(port): + """停止占用指定端口的进程""" + try: + # 使用 ss 找到占用端口的进程 + result = subprocess.run(f"ss -tlnp | grep :{port}", shell=True, capture_output=True, text=True) + if result.stdout: + # 解析 pid + import re + match = re.search(r'pid=(\d+)', result.stdout) + if match: + pid = int(match.group(1)) + os.kill(pid, signal.SIGTERM) + log_message(f"已停止进程 {pid} (端口 {port})") + return 1 + except Exception as e: + log_message(f"停止进程失败: {e}") + return 0 + + +def run_command(cmd, cwd, project_id, action): + """运行命令""" + log_message(f"[{project_id}] 执行 {action}: {cmd}") + try: + subprocess.Popen(cmd, shell=True, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + return True + except Exception as e: + log_message(f"命令执行失败: {e}") + return False + + +def backup_crontab(): + """备份当前 crontab""" + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + backup_file = os.path.join(CRON_BACKUP_DIR, f'crontab_{timestamp}.txt') + + try: + os.makedirs(CRON_BACKUP_DIR, exist_ok=True) + result = subprocess.run("crontab -l 2>/dev/null", shell=True, capture_output=True, text=True) + with open(backup_file, 'w') as f: + f.write(result.stdout) + log_message(f"crontab 已备份到 {backup_file}") + except Exception as e: + log_message(f"备份 crontab 失败: {e}") + + +def get_system_crons(): + """获取系统所有 cron 任务""" + crons = [] + + try: + result = subprocess.run("crontab -l 2>/dev/null", shell=True, capture_output=True, text=True, timeout=5) + for line in result.stdout.strip().split('\n'): + line = line.strip() + if line and not line.startswith('#'): + parts = line.split() + if len(parts) >= 6: + crons.append({'source': 'user', 'expression': ' '.join(parts[:5]), 'command': ' '.join(parts[5:]), 'line': line}) + except: + pass + + try: + if os.path.exists('/etc/crontab'): + with open('/etc/crontab', 'r') as f: + for line in f: + line = line.strip() + if line and not line.startswith('#'): + parts = line.split() + if len(parts) >= 7: + crons.append({'source': 'system', 'expression': ' '.join(parts[:5]), 'user': parts[5], 'command': ' '.join(parts[6:]), 'line': line}) + except: + pass + + try: + if os.path.exists('/etc/cron.d'): + for filename in os.listdir('/etc/cron.d'): + filepath = os.path.join('/etc/cron.d', filename) + if os.path.isfile(filepath): + with open(filepath, 'r') as f: + for line in f: + line = line.strip() + if line and not line.startswith('#'): + parts = line.split() + if len(parts) >= 7: + crons.append({'source': 'cron.d', 'file': filename, 'expression': ' '.join(parts[:5]), 'user': parts[5], 'command': ' '.join(parts[6:]), 'line': line}) + except: + pass + + return crons + + +def parse_cron_expression(expr): + """解析 cron 表达式为人类可读格式""" + parts = expr.split() + if len(parts) != 5: + return expr + + minute, hour, day, month, weekday = parts + desc = [] + + if minute == '*': + desc.append('每分钟') + elif minute.startswith('*/'): + desc.append(f'每{minute[2:]}分钟') + else: + desc.append(f'{minute}分') + + if hour != '*': + if hour.startswith('*/'): + desc.append(f'每{hour[2:]}小时') + else: + desc.append(f'{hour}时') + + if day != '*' and not day.startswith('*'): + desc.append(f'{day}日') + + if month != '*' and not month.startswith('*'): + desc.append(f'{month}月') + + if weekday != '*' and not weekday.startswith('*'): + weekdays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'] + try: + desc.append(weekdays[int(weekday)]) + except: + pass + + return ' '.join(desc) if desc else expr + + +def guess_cron_name(command): + """从命令推断任务名称""" + if 'openclaw' in command or 'agent' in command: + return 'OpenClaw Agent' + elif 'backup' in command: + return '备份任务' + elif 'sync' in command: + return '同步任务' + elif 'clean' in command or 'cleanup' in command: + return '清理任务' + elif 'report' in command: + return '报告任务' + elif 'monitor' in command or 'check' in command: + return '监控任务' + elif 'git' in command: + return 'Git 任务' + return '自定义任务' + + +def guess_cron_category(command): + """从命令推断分类""" + if 'openclaw' in command: + return 'agent' + elif 'backup' in command: + return 'backup' + elif 'sync' in command: + return 'sync' + elif 'clean' in command or 'cleanup' in command: + return 'cleanup' + elif 'report' in command: + return 'report' + elif 'git' in command: + return 'git' + return 'other' + + +def sync_crontab_from_db(db): + """从数据库同步到系统 crontab""" + c = db.cursor() + c.execute("SELECT expression, command, enabled FROM cron_tasks WHERE enabled = 1") + tasks = c.fetchall() + + lines = [] + for task in tasks: + lines.append(f"{task['expression']} {task['command']}") + + # 写入临时文件 + tmp_file = '/tmp/crontab_sync.tmp' + with open(tmp_file, 'w') as f: + f.write('\n'.join(lines) + '\n') + + # 安装 crontab + subprocess.run(f"crontab {tmp_file}", shell=True) + os.remove(tmp_file) + + # 备份 + backup_crontab() + + +def get_next_run_time(expression): + """获取下次执行时间""" + try: + from croniter import croniter + base = datetime.now() + cron = croniter(expression, base) + next_time = cron.get_next(datetime) + return next_time.strftime('%Y-%m-%d %H:%M:%S') + except: + return '无法计算' + + +def get_cpu_temperatures(): + """获取 CPU Package 温度""" + temps = {'package_0': None, 'package_1': None} + try: + result = subprocess.run(['sensors'], capture_output=True, text=True, timeout=5) + output = result.stdout + # 解析 Package id 0 和 Package id 1 + for line in output.split('\n'): + if 'Package id 0:' in line: + match = re.search(r'Package id 0:\s*([+\d.]+)°C', line) + if match: + temps['package_0'] = float(match.group(1).replace('+', '')) + elif 'Package id 1:' in line: + match = re.search(r'Package id 1:\s*([+\d.]+)°C', line) + if match: + temps['package_1'] = float(match.group(1).replace('+', '')) + except Exception as e: + log_message(f"获取CPU温度失败: {e}") + return temps + + +def load_alert_config(): + """加载预警配置""" + try: + with open(ALERT_CONFIG_FILE, 'r', encoding='utf-8') as f: + return json.load(f) + except: + return {} + + +def save_alert_config(config): + """保存预警配置""" + with open(ALERT_CONFIG_FILE, 'w', encoding='utf-8') as f: + json.dump(config, f, ensure_ascii=False, indent=2) \ No newline at end of file