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 = '''
+
+
+
+
+ 项目服务管理面板
+
+
+
+
+
+
+
+
+
+
+
+ 项目服务管理面板
+
+
统一管理所有项目和服务
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+'''
+
+
+@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