From f917d15278b4a84ee6cac6256ae624ac5edcf4c9 Mon Sep 17 00:00:00 2001 From: hubian <908234780@qq.com> Date: Mon, 20 Apr 2026 23:20:31 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=90=8E=E5=8F=B0=E9=93=BE=E6=8E=A5?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=20externalIp=20=E6=9B=BF=E4=BB=A3=20localhos?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后台链接现在会自动将 localhost 替换为页面配置的对外IP - 用户可通过页面顶部的对外IP输入框调整访问地址 --- app.py | 2165 +++++++++++++++++++++++++++++++++----------------------- 1 file changed, 1295 insertions(+), 870 deletions(-) diff --git a/app.py b/app.py index 256f36c..a35d5b2 100644 --- a/app.py +++ b/app.py @@ -1,7 +1,8 @@ #!/usr/bin/env python3 """ -项目服务管理面板 +项目服务管理面板 v2.1.0 端口: 19013 +修复: 后台链接使用 externalIp 替代 localhost """ import os @@ -10,8 +11,11 @@ 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 +from flask import Flask, render_template_string, jsonify, request, g import urllib.request import urllib.error @@ -22,16 +26,86 @@ 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) @@ -42,35 +116,23 @@ def log_message(msg): def signal_handler(signum, frame): - """处理信号,记录kill来源""" + """处理信号""" signal_names = { signal.SIGTERM: 'SIGTERM', signal.SIGINT: 'SIGINT', signal.SIGHUP: 'SIGHUP', } sig_name = signal_names.get(signum, f'SIGNAL_{signum}') - - # 尝试获取kill来源 - try: - import psutil - proc = psutil.Process() - parent = proc.parent() - parent_info = f"父进程: {parent.pid} ({parent.name()})" - except: - parent_info = "无法获取父进程信息" - - log_message(f"⚠️ 进程收到 {sig_name} 信号,即将退出! {parent_info}") - log_message(f"信号来源可能是: 手动kill命令、systemd、OOM killer、或其他进程") + 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 # Windows不支持SIGHUP + pass def load_projects(): @@ -117,15 +179,11 @@ def get_process_on_port(port): ) 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() - } + return {'pid': int(pid), 'info': cmd_result.stdout.strip()} except: pass return None @@ -144,14 +202,10 @@ def get_project_status(project): 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 - } + 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']) @@ -163,24 +217,14 @@ def get_project_status(project): } elif project_type == 'cron': - # 检查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 - ) + 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 - } + return {'status': 'configured' if cron_configured else 'not_configured', 'cron': cron_expr, 'cron_configured': cron_configured} elif project_type == 'extension': return {'status': 'completed', 'message': '浏览器插件,手动安装'} @@ -199,22 +243,10 @@ def run_command(cmd, cwd, project_id, action): os.makedirs(os.path.dirname(log_file), exist_ok=True) with open(log_file, 'a') as f: - f.write(f"\n{'='*50}\n") - f.write(f"[{datetime.now().isoformat()}] {action}\n") - f.write(f"Command: {cmd}\n") - f.write(f"Directory: {cwd}\n") - - process = subprocess.Popen( - cmd, - shell=True, - cwd=cwd, - stdout=open(log_file, 'a'), - stderr=subprocess.STDOUT, - start_new_session=True - ) + 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") @@ -227,31 +259,682 @@ def run_command(cmd, cwd, project_id, action): 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 = result.stdout.strip().split('\n') - pids = [p for p in pids if p] - + 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 -# HTML模板 +# ==================== 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 + + 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 + + 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() + + 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() + + synced_count = 0 + for cron in user_crons: + # 检查是否已存在 + c.execute("SELECT id FROM cron_tasks WHERE expression = ? AND command = ?", (cron['expression'], cron['command'])) + existing = c.fetchone() + + if not existing: + # 尝试从命令推断分类和名称 + 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} 个任务'}) + + +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 '无法计算' + + +@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 = ''' - 项目服务管理面板 + 项目服务管理面板 v2.1 @@ -270,43 +953,47 @@ HTML_TEMPLATE = ''' .btn:hover { transform: translateY(-1px); } .btn:active { transform: translateY(0); } .type-badge { font-size: 0.7rem; padding: 2px 6px; border-radius: 4px; } - /* 导航按钮样式 */ .nav-buttons { position: fixed; right: 20px; bottom: 20px; z-index: 100; display: flex; flex-direction: column; gap: 8px; } .nav-btn { width: 40px; height: 40px; border-radius: 50%; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: all 0.2s; background: #334155; border: 1px solid #475569; } .nav-btn:hover { background: #475569; transform: scale(1.1); } .nav-btn i { font-size: 20px; color: #94a3b8; } .nav-btn:hover i { color: #f1f5f9; } + .tab-btn { border-bottom: 2px solid transparent; } + .tab-btn.active { border-bottom-color: #3b82f6; } + .cron-card { transition: all 0.2s; } + .cron-card:hover { border-color: #475569; } + .history-item { border-left: 3px solid #334155; padding-left: 12px; } + .history-item.rollback { border-left-color: #f59e0b; } + .history-item.update { border-left-color: #3b82f6; } + .history-item.create { border-left-color: #22c55e; } + .history-item.delete { border-left-color: #ef4444; } + .history-item.toggle { border-left-color: #8b5cf6; } + .modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 50; display: flex; align-items: center; justify-content: center; } + .modal-content { max-width: 600px; width: 90%; max-height: 80vh; overflow: auto; }
-
+

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

-

统一管理所有项目和服务

对外IP: - +
-
间隔: - + s
@@ -317,101 +1004,135 @@ HTML_TEMPLATE = '''
- -
-
-
-
-

Web服务

-

-

-
- -
-
-
-
-
-

运行中

-

-

-
- -
-
-
-
-
-

Cron任务

-

-

-
- -
-
-
-
-
-

CLI工具

-

-

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

加载中...

+ +
+ +
+
+
+
+

Web服务

+

-

+
+ +
+
+
+
+
+

运行中

+

-

+
+ +
+
+
+
+
+

Cron任务

+

-

+
+ +
+
+
+
+
+

CLI工具

+

-

+
+ +
+
+
+ + +
+ + + + +
+ + +
+

加载中...

- -
-
-

- - 主机 Cron 列表 - - -

- -
- - -
-
加载中...
-
- - -
-
- -

加载中...

+ + +
+ 执行日志: +
+ + +
+

加载中...

+
@@ -420,9 +1141,7 @@ HTML_TEMPLATE = '''

日志

- +

@@ -430,30 +1149,114 @@ HTML_TEMPLATE = '''
         
+ + + + + + ''' -@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(): - cmd = info['cmd'] - run_command(cmd, cwd, project_id, f'start-{name}') - return jsonify({'message': '服务启动中...'}) - 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: - # 最后1000行 - 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() - - # 生成ID - import re - project_id = re.sub(r'[^a-z0-9-]', '-', data['name'].lower()) - - # 检查ID是否重复 - 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': # 不允许修改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': '删除成功'}) - - -def get_system_crons(): - """获取系统所有 cron 任务""" - crons = [] - - # 获取当前用户的 crontab - try: - result = subprocess.run( - "crontab -l 2>/dev/null", - shell=True, capture_output=True, text=True, timeout=5 - ) - user_crontab = result.stdout.strip() - if user_crontab: - for line in user_crontab.split('\n'): - line = line.strip() - if line and not line.startswith('#'): - # 解析 cron 行 - parts = line.split() - if len(parts) >= 6: - cron_expr = ' '.join(parts[:5]) - command = ' '.join(parts[5:]) - crons.append({ - 'source': 'user', - 'expression': cron_expr, - 'command': command, - 'line': line - }) - except: - pass - - # 获取系统级 cron (/etc/crontab) - 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: - # 系统 crontab 有额外的 user 字段 - cron_expr = ' '.join(parts[:5]) - user = parts[5] - command = ' '.join(parts[6:]) - crons.append({ - 'source': 'system', - 'expression': cron_expr, - 'user': user, - 'command': command, - 'line': line - }) - except: - pass - - # 获取 /etc/cron.d/ 目录下的任务 - try: - cron_d_dir = '/etc/cron.d' - if os.path.exists(cron_d_dir): - for filename in os.listdir(cron_d_dir): - filepath = os.path.join(cron_d_dir, 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: - cron_expr = ' '.join(parts[:5]) - user = parts[5] - command = ' '.join(parts[6:]) - crons.append({ - 'source': 'cron.d', - 'file': filename, - 'expression': cron_expr, - 'user': user, - 'command': command, - '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 minute == '*': - desc = [f'{hour}点每分钟'] - else: - desc = [f'{hour}点{minute}分'] - elif minute.startswith('*/'): - # 保持 "每X分钟" - pass - - # 日期 - if day != '*': - desc.append(f'{day}号') - - # 月份 - if month != '*': - months = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12'] - try: - desc.append(f'{months[int(month)-1]}月') - except: - desc.append(f'{month}月') - - # 星期 - if weekday != '*': - weekdays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'] - try: - # 0=周日, 1=周一... - desc.append(weekdays[int(weekday)]) - except: - desc.append(f'周{weekday}') - - return ' '.join(desc) if desc else expr - - -@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}) - - 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("项目服务管理面板启动") + log_message("项目服务管理面板 v2.0.0 启动") log_message("访问地址: http://localhost:19013") log_message(f"进程PID: {os.getpid()}") log_message("=" * 50) + app.run(host='0.0.0.0', port=19013, debug=False) \ No newline at end of file