commit 328fd4c16c61194e081e6fbd25608ac3ac69e670 Author: hubian <908234780@qq.com> Date: Sat Apr 11 13:06:15 2026 +0800 feat: 项目服务管理面板 v1.0.0 功能: - 项目列表展示(Web服务、Cron任务、CLI工具、插件) - 实时状态检测(端口检测、健康检查) - 服务控制(启动、停止、重启) - 日志查看 - 类型筛选 - 支持动态添加新项目(API) 项目配置存储在 projects.json,便于扩展新项目 diff --git a/app.py b/app.py new file mode 100644 index 0000000..476c981 --- /dev/null +++ b/app.py @@ -0,0 +1,828 @@ +#!/usr/bin/env python3 +""" +项目服务管理面板 +端口: 19013 +""" + +import os +import json +import subprocess +import signal +import threading +from datetime import datetime +from flask import Flask, render_template_string, jsonify, request +import urllib.request +import urllib.error + +app = Flask(__name__) + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +WORKSPACE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +PROJECTS_FILE = os.path.join(BASE_DIR, 'projects.json') + +# 进程状态缓存 +process_cache = {} + + +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任务是否配置 + 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") + 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 + ) + + 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 = result.stdout.strip().split('\n') + pids = [p for p in pids if p] + + for pid in pids: + try: + os.kill(int(pid), signal.SIGTERM) + except: + pass + + return len(pids) + except: + return 0 + + +# HTML模板 +HTML_TEMPLATE = ''' + + + + + 项目服务管理面板 + + + + + +
+ +
+
+

+ + 项目服务管理面板 +

+

统一管理所有项目和服务

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

Web服务

+

-

+
+ +
+
+
+
+
+

运行中

+

-

+
+ +
+
+
+
+
+

Cron任务

+

-

+
+ +
+
+
+
+
+

CLI工具

+

-

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

加载中...

+
+
+
+ + + + + + + +''' + + +@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(WORKSPACE_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(WORKSPACE_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': '删除成功'}) + + +if __name__ == '__main__': + os.makedirs(os.path.join(BASE_DIR, 'logs'), exist_ok=True) + print("=" * 50) + print("项目服务管理面板") + print("访问地址: http://localhost:19013") + print("=" * 50) + app.run(host='0.0.0.0', port=19013, debug=False) \ No newline at end of file diff --git a/projects.json b/projects.json new file mode 100644 index 0000000..4abb8ce --- /dev/null +++ b/projects.json @@ -0,0 +1,139 @@ +{ + "projects": [ + { + "id": "pdf-translate-v2", + "name": "PDF翻译助手 V2", + "type": "web", + "ports": [19000], + "directory": "works/pdf-translate-web-v2", + "start_cmd": "python3 app.py", + "health_url": "http://localhost:19000/api/health", + "description": "英文PDF翻译中文网站,支持用户系统、会员体系", + "admin_url": "http://localhost:19000/admin", + "git_repo": "http://192.168.2.8:12007/coder/pdf-translate-web", + "version": "v2.1.0" + }, + { + "id": "llm-index-rag", + "name": "LLM Index RAG", + "type": "web", + "ports": [19001], + "directory": "works/llm-index-rag", + "start_cmd": "python3 app.py", + "health_url": "http://localhost:19001/api/stats", + "description": "基于索引和搜索的知识检索系统", + "admin_url": "http://localhost:19001/settings", + "git_repo": "http://192.168.2.8:12007/coder/llm-index-rag", + "version": "v1.1.2" + }, + { + "id": "snippet-notes", + "name": "碎片信息记录", + "type": "web", + "ports": [19009], + "directory": "works/snippet-notes", + "start_cmd": "python3 app.py", + "health_url": "http://localhost:19009/api/notes", + "description": "碎片信息记录工具,AI自动生成标题", + "git_repo": null, + "version": "v1.0.0" + }, + { + "id": "param-hub", + "name": "ParamHub Python", + "type": "web", + "ports": [19010], + "directory": "works/param-hub-python", + "start_cmd": "python3 app.py", + "health_url": "http://localhost:19010/api/stats", + "description": "AI大模型与硬件参数速查平台", + "admin_url": "http://localhost:19010/admin", + "git_repo": "http://192.168.2.8:12007/coder/param-hub-python", + "version": "v1.2.0" + }, + { + "id": "product-crawler", + "name": "产品参数爬取系统", + "type": "web", + "ports": [19011, 19012], + "directory": "/home/xian/.openclaw/common/projects/product-crawler", + "start_cmd": "python3 app.py", + "start_cmds": { + "api": {"port": 19011, "cmd": "python3 app.py"}, + "admin": {"port": 19012, "cmd": "cd admin && python3 app.py"} + }, + "health_url": "http://localhost:19011/api/products", + "description": "自动从官网爬取产品参数信息", + "git_repo": "http://192.168.2.8:12007/coder/product-crawler", + "version": "v1.0.0" + }, + { + "id": "llm-proxy", + "name": "LLM Proxy", + "type": "web", + "ports": [19007, 19008], + "directory": "/home/xian/.openclaw/common/projects/llm-proxy", + "start_cmd": "python3 app.py", + "start_cmds": { + "api": {"port": 19007, "cmd": "python3 app.py"}, + "admin": {"port": 19008, "cmd": "cd admin && python3 app.py"} + }, + "health_url": "http://localhost:19007/health", + "description": "大模型API中转系统,多提供商调度", + "admin_url": "http://localhost:19008", + "git_repo": "http://192.168.2.8:12007/coder/llm-proxy", + "version": "v0.5.1" + }, + { + "id": "web-context-extension", + "name": "网页助手插件", + "type": "extension", + "directory": "works/web-context-extension", + "description": "浏览器扩展,右键菜单支持收藏网页、AI总结", + "git_repo": "http://192.168.2.8:12007/coder/web-context-extension", + "version": "v0.1.0" + }, + { + "id": "service-monitor", + "name": "Web服务监控", + "type": "cron", + "directory": "works/service-monitor", + "cron": "*/20 * * * *", + "cron_cmd": "/usr/bin/python3 /home/xian/.openclaw/workspace-coder/works/service-monitor/monitor.py", + "description": "每20分钟检查服务状态,邮件通知", + "git_repo": "http://192.168.2.8:12007/coder/service-monitor", + "version": "v1.0.0" + }, + { + "id": "board-monitor", + "name": "A股板块监控", + "type": "cron", + "directory": "works/board-monitor", + "cron": "0 17 * * 1-5", + "cron_cmd": "python3 board_monitor.py report", + "description": "每个交易日17:00发送板块分析报告", + "git_repo": "http://192.168.2.8:12007/coder/board-monitor", + "version": "v1.3.0" + }, + { + "id": "stock-system", + "name": "A股历史数据系统", + "type": "cli", + "directory": "/home/xian/.openclaw/common/stock_system", + "run_cmd": "python3 fetch_history_v2.py", + "description": "获取A股历史行情数据,支持断点续传", + "git_repo": "http://192.168.2.8:12007/coder/stock_system", + "version": "v0.2.0" + }, + { + "id": "pdf-translator", + "name": "PDF翻译脚本", + "type": "cli", + "directory": "works/pdf-translator", + "run_cmd": "python3 translate_pdf.py", + "description": "命令行PDF翻译工具", + "git_repo": null, + "version": "v1.0.0" + } + ] +} \ No newline at end of file