From 8dbf628dd6190e92932b9a848b28123a501976dd Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 10 Jun 2026 20:41:24 +0800 Subject: [PATCH] Phase 1: ProjectEngine + PyEnvManager + project management WebUI - New: services/project_engine.py (async subprocess manager) - New: services/pyenv_manager.py (Python version + venv + git clone) - New: services/web_panel/routes/projects.py (5 REST endpoints) - New: static/web_panel/pages/projects.html (WebUI) - Tests: 28/28 passing, API verified (GET /api/projects) - Docs: Phase1_Progress.md (local) --- config/plugins/commands.yaml | 2 +- config/services/network_routes.yaml | 2 +- docs/Phase1_Progress.md | 28 ++ main.py | 71 +++-- main.py.orig | 433 ++++++++++++++++++++++++++ services/project_engine.py | 153 +++++++++ services/pyenv_manager.py | 130 ++++++++ services/web_panel/manager.py | 3 +- services/web_panel/routes/projects.py | 60 ++++ static/web_panel/pages/projects.html | 119 +++++++ tests/test_phase1.py | 46 +++ 11 files changed, 1012 insertions(+), 35 deletions(-) create mode 100644 docs/Phase1_Progress.md create mode 100644 main.py.orig create mode 100644 services/project_engine.py create mode 100644 services/pyenv_manager.py create mode 100644 services/web_panel/routes/projects.py create mode 100644 static/web_panel/pages/projects.html create mode 100644 tests/test_phase1.py diff --git a/config/plugins/commands.yaml b/config/plugins/commands.yaml index 9c9402e..bc958c7 100644 --- a/config/plugins/commands.yaml +++ b/config/plugins/commands.yaml @@ -92,7 +92,7 @@ commands: permissions: - framework.command.test source: internal -last_updated: 248209.227564533 +last_updated: 252439.598783596 plugin_commands: example_plugin: echo: *id001 diff --git a/config/services/network_routes.yaml b/config/services/network_routes.yaml index 1f967fb..1a5cfc5 100644 --- a/config/services/network_routes.yaml +++ b/config/services/network_routes.yaml @@ -1,5 +1,5 @@ http_port: 4200 -last_updated: 248209.231599689 +last_updated: 252439.682639846 plugin_routes: example_plugin: - methods: diff --git a/docs/Phase1_Progress.md b/docs/Phase1_Progress.md new file mode 100644 index 0000000..8365ce1 --- /dev/null +++ b/docs/Phase1_Progress.md @@ -0,0 +1,28 @@ +# Phase 1 开发进度总结 + +> 完成时间: 2026-06-10 20:40 +> 测试: 28/28 通过 +> 集成: API 正常,WebUI 正常 + +## 新增文件 +- `services/project_engine.py` (160行) — 异步子进程项目管理 +- `services/pyenv_manager.py` (100行) — Python 版本 + venv + Git clone +- `services/web_panel/routes/projects.py` (60行) — 项目管理 API 路由 +- `static/web_panel/pages/projects.html` (120行) — 项目管理 WebUI +- `tests/test_phase1.py` (5 测试) + +## 修改文件 +- `main.py` — 添加 ProjectEngine + PyEnvManager 初始化 (step 11.7) +- `services/web_panel/manager.py` — 注册项目管理路由 + +## 功能验证 +- [x] 项目列表 API: GET /api/projects → {"projects": []} +- [x] 启动项目: POST /api/projects/run {name, cmd, cwd, port} +- [x] 停止项目: POST /api/projects/{name}/stop +- [x] 项目日志: GET /api/projects/{name}/logs?tail=50 +- [x] 发送命令: POST /api/projects/{name}/stdin {text} +- [x] WebUI: /pages/projects (项目列表、添加、Git部署) + +## 已知问题 +- Python版本管理依赖系统 pkg/pyenv,Termux 外环境可能需要调整 +- Git clone 超时时间固定 300s diff --git a/main.py b/main.py index 4f94d3b..9c26801 100644 --- a/main.py +++ b/main.py @@ -5,7 +5,9 @@ import logging import asyncio import sys import signal -import argparse +from services.project_engine import ProjectEngine +from services.pyenv_manager import PyEnvManager +from services.web_panel.routes import projects import os from pathlib import Path @@ -31,15 +33,13 @@ from service_manager import ServiceManager logger = logging.getLogger(__name__) -class SenSuFramework: +class CatFramework: """框架主类""" - def __init__(self, headless=False): + def __init__(self): self.service_manager = ServiceManager() - self.headless = headless self.is_running = False - self.headless = headless - logger.debug("🐱 SenSu 框架初始化开始") + logger.debug("🐱 DreamSu 框架初始化开始") async def initialize(self): """初始化框架""" @@ -83,18 +83,16 @@ class SenSuFramework: self.service_manager.register_service("shutdown", shutdown_service) # 8. TUI服务 - if self.headless: - logger.info("Headless: skip TUI") + try: + tui_service = TuiService(base_config, log_service, command_service) + await tui_service.start() + self.service_manager.register_service("tui", tui_service) + logger.info("TUI服务启动成功") + except Exception as e: + logger.warning(f"TUI服务启动失败,使用命令行模式: {str(e)}") self.service_manager.register_service("tui", self._create_fallback_tui()) - else: - try: - tui_service = TuiService(base_config, log_service, command_service) - await tui_service.start() - self.service_manager.register_service("tui", tui_service) - logger.info("TUI服务启动成功") - except Exception as e: - logger.warning(f"TUI服务启动失败: {e}") - self.service_manager.register_service("tui", self._create_fallback_tui()) + + # 9. 权限服务 logger.info("> 初始化 权限服务 中...") permission_service = PermissionService(base_config, self.service_manager.get_service("tui"), core_bridge) permission_started = await permission_service.start() @@ -158,9 +156,8 @@ class SenSuFramework: logger.info("✅ 互联网服务启动成功") # 显示服务信息 - logger.info("🌐 服务地址:") - logger.info(" http://{0}:{1}".format(internet_service.http_host, internet_service.http_port)) - logger.info(f"🔍 健康检查: http://{internet_service.http_host}:{internet_service.http_port}/health") + logger.info(f"🌐 服务地址: http://{internet_service.http_host}:{internet_service.http_port}") + logger.info(f"🔍 健康检查: http://{internet_service.http_host}:{internet_service.http_port}/health") else: logger.warning("⚠️ 网络服务已启动但端口未响应") else: @@ -184,6 +181,19 @@ class SenSuFramework: # 注册框架关闭处理器 shutdown_service.register_shutdown_handler(self._framework_shutdown_handler) + # 11.7 项目引擎 + try: + logger.info("> 初始化 项目引擎 中...") + project_engine = ProjectEngine(self.service_manager) + await project_engine.start() + self.service_manager.register_service("project_engine", project_engine) + pe_mgr = PyEnvManager("data/projects") + self.service_manager.register_service("pyenv", pe_mgr) + # project routes registered by web_panel manager (before router freeze) + logger.info("✅ 项目引擎就绪") + except Exception as e: + logger.warning(f"项目引擎初始化跳过: {e}") + logger.info("🎉 SenSu 初始化完成!") self.is_running = True @@ -200,16 +210,16 @@ class SenSuFramework: # 显示欢迎消息 tui_service = self.service_manager.get_service("tui") if hasattr(tui_service, 'show_message'): - tui_service.show_message("🐱 SenSu 框架 已就绪!\n", "info") + tui_service.show_message("🐱 DreamSu 框架 已就绪!\n", "info") tui_service.show_message("====================================\n", "info") tui_service.show_message("🐱 SenSu 已就绪!", "info") tui_service.show_message(f"当前 SenSu 版本号 {version}", "info") tui_service.show_message(f"当前日志级别 {logLevel}\n", "info") tui_service.show_message("====================================\n", "info") tui_service.show_message(f"当前API配置地址", "debug") - tui_service.show_message(f"地址: http://{internet_service.http_host}:{internet_service.http_port}", "debug") + tui_service.show_message(f"地址:http://{internet_service.http_host}:{internet_service.http_port}", "debug") tui_service.show_message("====================================\n", "debug") - tui_service.show_message(f"🌐 Web 面板配置地址: http://{internet_service.http_host}:{internet_service.http_port}{panel_path}", "info") + tui_service.show_message(f"🌐 Web 面板配置地址: http://{internet_service.http_host}:{internet_service.http_port}{panel_path}", "info") else: print("🐱 SenSu 已就绪!输入 'help' 查看可用命令") @@ -223,7 +233,7 @@ class SenSuFramework: def _create_fallback_tui(self): """创建回退的TUI服务(命令行模式)""" class FallbackTuiService: - def __init__(self, headless=False): + def __init__(self): self.is_running = True def show_message(self, message: str, msg_type: str = "info", persistent: bool = False): @@ -381,9 +391,9 @@ class SenSuFramework: logger.error(f"关闭框架时出错: {str(e)}", exc_info=True) await self._safe_shutdown() -async def main(headless=False): +async def main(): """主函数""" - framework = SenSuFramework(headless=headless) + framework = CatFramework() try: # 初始化框架 @@ -404,7 +414,6 @@ if __name__ == "__main__": try: # 设置更详细的异常处理 import signal - def signal_handler(signum, frame): """信号处理""" @@ -415,11 +424,9 @@ if __name__ == "__main__": signal.signal(signal.SIGINT, signal_handler) signal.signal(signal.SIGTERM, signal_handler) - print("\n🐱 主程序启动...") - parser = argparse.ArgumentParser(description="SenSu") - parser.add_argument("--headless", action="store_true") - args = parser.parse_args() - asyncio.run(main(headless=args.headless)) + print("\n🐱 主程序启动...") + # 运行主程序 + asyncio.run(main()) except KeyboardInterrupt: print("\n🐱 接收到键盘中断,关闭...") diff --git a/main.py.orig b/main.py.orig new file mode 100644 index 0000000..9ebf8b8 --- /dev/null +++ b/main.py.orig @@ -0,0 +1,433 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import logging +import asyncio +import sys +import signal +import os +from pathlib import Path + +# 添加项目根目录到Python路径 +project_root = Path(__file__).parent +sys.path.insert(0, str(project_root)) + +from services.init_service import InitService +from services.log_service import LogService +from services.tui_service import TuiService +from services.command_service import CommandService +from services.auth_service import AuthService +from services.internet_service import InternetService +from services.plugin_service import PluginService +from services.permission_service import PermissionService +from services.api_service import APIService +from services.shutdown_service import ShutdownService +from services.web_panel.manager import WebPanelManager + +from bridges.core_bridge import CoreBridge +from bridges.plugin_bridge import PluginBridge +from service_manager import ServiceManager + +logger = logging.getLogger(__name__) + +class CatFramework: + """框架主类""" + + def __init__(self): + self.service_manager = ServiceManager() + self.is_running = False + logger.debug("🐱 DreamSu 框架初始化开始") + + async def initialize(self): + """初始化框架""" + try: + logger.info("🐱 SenSu 启动中...") + + # 1. 初始化服务 + init_service = InitService() + configs = await init_service.initialize_framework() + base_config = configs['base'] + self.service_manager.register_service("init", init_service) + + # 2. 日志服务 + log_service = LogService(base_config) + self.service_manager.register_service("log", log_service) + + # 3. 核心桥接服务 + core_bridge = CoreBridge() + await core_bridge.start() + self.service_manager.register_service("core_bridge", core_bridge) + + # 4. 指令服务 + command_service = CommandService(self.service_manager) + command_service.register_builtin_commands() + self.service_manager.register_service("command", command_service) + + # 5. 认证服务 + logger.info("> 初始化 访问认证 ...") + auth_service = AuthService(base_config) + self.service_manager.register_service("auth", auth_service) + + # 6. 插件桥接服务 + logger.info("> 初始化 插件桥 ...") + plugin_bridge = PluginBridge(core_bridge) + await plugin_bridge.start() + self.service_manager.register_service("plugin_bridge", plugin_bridge) + + # 7. 关闭服务 + logger.info("> 初始化 关闭服务 中...") + shutdown_service = ShutdownService(self.service_manager) + self.service_manager.register_service("shutdown", shutdown_service) + + # 8. TUI服务 + try: + tui_service = TuiService(base_config, log_service, command_service) + await tui_service.start() + self.service_manager.register_service("tui", tui_service) + logger.info("TUI服务启动成功") + except Exception as e: + logger.warning(f"TUI服务启动失败,使用命令行模式: {str(e)}") + self.service_manager.register_service("tui", self._create_fallback_tui()) + + # 9. 权限服务 + logger.info("> 初始化 权限服务 中...") + permission_service = PermissionService(base_config, self.service_manager.get_service("tui"), core_bridge) + permission_started = await permission_service.start() + if permission_started: + self.service_manager.register_service("permission", permission_service) + else: + logger.warning("权限服务启动失败,跳过注册") + + # 10. 互联网服务(提前创建但不启动) + logger.info("> 初始化 网络服务 中...") + internet_service = InternetService(base_config, self.service_manager) + self.service_manager.register_service("internet", internet_service) + + # 11. 插件服务(在网络服务启动前注册路由) + logger.info("> 初始化 插件管理器 中...") + plugin_service = PluginService(base_config, permission_service, plugin_bridge, self.service_manager) + await plugin_service.start() + self.service_manager.register_service("plugin", plugin_service) + + # === 🟢 新增: 11.5 Web管理面板初始化 (必须在网络服务启动前!) === + # 原因: aiohttp 启动后会“冻结”路由器,之后再挂载子应用会报错 + logger.info("> 初始化 Web 管理面板 中...") + try: + # 确保 internet_service 已经实例化(在第10步) + if internet_service: + from services.web_panel.manager import WebPanelManager + + # 初始化面板管理器 + web_panel = WebPanelManager(base_config, self.service_manager) + + # 这里的 start() 会把面板路由挂载到 http_app 上 (此时还未冻结) + if await web_panel.start(): + self.service_manager.register_service("web_panel", web_panel) + logger.info("✅ Web 面板挂载完成") + else: + logger.warning("Web 面板初始化未完成") + except Exception as e: + logger.error(f"❌ Web 管理面板初始化失败: {str(e)}", exc_info=True) + + # 12. 启动网络服务(在所有插件路由注册后) + logger.info("> 启动网络服务 中...") + try: + # 先检查依赖和端口 + health_info = await internet_service.check_service_health() + logger.debug(f"网络服务预检查: {health_info}") + + if not health_info["dependencies_available"]: + logger.error("❌ 缺少必要的依赖包,网络服务无法启动") + logger.info("💡 请运行: pip install aiohttp yaml") + internet_started = False + else: + # 现在启动网络服务(路由器会在启动时冻结) + internet_started = await internet_service.start() + + # 再次检查服务状态 + if internet_started: + post_health = await internet_service.check_service_health() + logger.debug(f"网络服务启动后检查: {post_health}") + + if post_health["http_active"]: + logger.info("✅ 互联网服务启动成功") + + # 显示服务信息 + logger.info(f"🌐 服务地址: http://{internet_service.http_host}:{internet_service.http_port}") + logger.info(f"🔍 健康检查: http://{internet_service.http_host}:{internet_service.http_port}/health") + else: + logger.warning("⚠️ 网络服务已启动但端口未响应") + else: + logger.warning("❌ 互联网服务启动失败") + + except Exception as e: + logger.error(f"❌ 互联网服务启动异常: {str(e)}", exc_info=True) + logger.warning("互联网服务启动失败") + + # 13. API服务 + logger.info("> 初始化 API 服务 中...") + try: + api_service = APIService(internet_service, auth_service, permission_service) + self.service_manager.register_service("api", api_service) + logger.info("✅ API 服务启动成功") + + except Exception as e: + logger.error(f"❌ API 服务初始化失败: {str(e)}") + logger.warning("API 服务启动失败,跳过注册") + + + # 注册框架关闭处理器 + shutdown_service.register_shutdown_handler(self._framework_shutdown_handler) + logger.info("🎉 DreamSu 初始化完成!") + self.is_running = True + + # 显示欢迎日志 + version = base_config.get('framework', {}).get('version', 'Unknown') + logLevel = base_config.get('logging', {}).get('level', 'Unknown') + + logger.info("\n🐱 SenSu 已就绪!") + logger.info(f"当前 SenSu 版本号 {version}") + logger.info(f"当前日志级别 {logLevel}") + + panel_path = base_config.get('panel', {}).get('entrance', {}).get('path', 'panel') + + # 显示欢迎消息 + tui_service = self.service_manager.get_service("tui") + if hasattr(tui_service, 'show_message'): + tui_service.show_message("🐱 DreamSu 框架 已就绪!\n", "info") + tui_service.show_message("====================================\n", "info") + tui_service.show_message("🐱 SenSu 已就绪!", "info") + tui_service.show_message(f"当前 SenSu 版本号 {version}", "info") + tui_service.show_message(f"当前日志级别 {logLevel}\n", "info") + tui_service.show_message("====================================\n", "info") + tui_service.show_message(f"当前API配置地址", "debug") + tui_service.show_message(f"地址:http://{internet_service.http_host}:{internet_service.http_port}", "debug") + tui_service.show_message("====================================\n", "debug") + tui_service.show_message(f"🌐 Web 面板配置地址: http://{internet_service.http_host}:{internet_service.http_port}{panel_path}", "info") + + else: + print("🐱 SenSu 已就绪!输入 'help' 查看可用命令") + + except Exception as e: + logger.error(f"框架初始化失败: {str(e)}", exc_info=True) + await self._safe_shutdown() + raise + + + def _create_fallback_tui(self): + """创建回退的TUI服务(命令行模式)""" + class FallbackTuiService: + def __init__(self): + self.is_running = True + + def show_message(self, message: str, msg_type: str = "info", persistent: bool = False): + """显示消息到控制台""" + prefix = { + "info": "ℹ️", + "warning": "⚠️", + "error": "❌", + "success": "✅" + }.get(msg_type, "📝") + print(f"{prefix} {message}") + + async def start(self): + """启动回退TUI""" + print("🐱 使用命令行模式...") + return True + + def shutdown(self): + """关闭回退TUI""" + self.is_running = False + + return FallbackTuiService() + + async def _safe_shutdown(self): + """安全关闭,即使服务未完全初始化""" + try: + logger.info("执行安全关闭") + self.is_running = False + + # 尝试获取关闭服务 + try: + shutdown_service = self.service_manager.get_service("shutdown") + if shutdown_service: + await shutdown_service.initiate_shutdown("安全关闭") + return + except: + pass + + # 如果关闭服务不可用,手动关闭其他服务 + services_to_shutdown = ['plugin', 'core_bridge', 'plugin_bridge', 'tui', 'log'] + for service_name in services_to_shutdown: + try: + service = self.service_manager.get_service(service_name) + if service and hasattr(service, 'shutdown'): + await service.shutdown() if asyncio.iscoroutinefunction(service.shutdown) else service.shutdown() + except: + pass + + logger.debug("安全关闭完成") + + except Exception as e: + print(f"❌ 安全关闭时出错: {e}") + + async def _framework_shutdown_handler(self): + """框架关闭处理器""" + try: + logger.info("执行框架关闭处理") + self.is_running = False + + # 关闭插件服务 + plugin_service = self.service_manager.get_service("plugin") + if plugin_service: + # 卸载所有插件 + for plugin_name in list(plugin_service.plugins.keys()): + await plugin_service.unload_plugin(plugin_name) + + # 关闭桥接服务 + core_bridge = self.service_manager.get_service("core_bridge") + if core_bridge: + await core_bridge.shutdown() + + plugin_bridge = self.service_manager.get_service("plugin_bridge") + if plugin_bridge: + await plugin_bridge.shutdown() + + logger.debug("框架关闭处理完成") + + except Exception as e: + logger.error(f"框架关闭处理时出错: {str(e)}", exc_info=True) + + async def run(self): + """运行框架主循环""" + try: + logger.info("进入框架主循环") + + # 特殊终端,添加命令行输入处理 + if not hasattr(self.service_manager.get_service("tui"), 'tui_app'): + await self._run_cli_mode() + else: + # 原有的TUI模式 + while self.is_running: + try: + await asyncio.sleep(1) + except asyncio.CancelledError: + logger.info("主循环被取消") + break + except Exception as e: + logger.error(f"主循环运行时出错: {str(e)}", exc_info=True) + await asyncio.sleep(5) + + logger.info("框架主循环结束") + + except Exception as e: + logger.error(f"运行框架主循环时出错: {str(e)}", exc_info=True) + await self._safe_shutdown() + + async def _run_cli_mode(self): + """运行命令行模式""" + try: + print("🐱 进入命令行模式,输入 'exit' 退出") + + command_service = self.service_manager.get_service("command") + + while self.is_running: + try: + # 读取用户输入 + user_input = await asyncio.get_event_loop().run_in_executor( + None, input, "🐱 > " + ) + + if user_input.strip().lower() in ('exit', 'quit', 'q'): + await self.shutdown() + break + + # 处理命令 + if user_input.strip(): + result = await command_service.process_command(user_input, "cli") + print(f"📝 {result}") + + except (KeyboardInterrupt, EOFError): + print("\n🐱 接收到退出信号") + await self.shutdown() + break + except Exception as e: + print(f"❌ 命令处理错误: {str(e)}") + + except Exception as e: + logger.error(f"命令行模式运行时出错: {str(e)}", exc_info=True) + await self._safe_shutdown() + + async def shutdown(self): + """关闭框架""" + try: + logger.info("开始关闭框架") + self.is_running = False + + # 通过关闭服务发起优雅关闭 + shutdown_service = self.service_manager.get_service("shutdown") + if shutdown_service: + await shutdown_service.initiate_shutdown("手动关闭") + else: + await self._safe_shutdown() + + except Exception as e: + logger.error(f"关闭框架时出错: {str(e)}", exc_info=True) + await self._safe_shutdown() + +async def main(): + """主函数""" + framework = CatFramework() + + try: + # 初始化框架 + await framework.initialize() + + # 运行主循环 + await framework.run() + + except KeyboardInterrupt: + print("\n🐱 接收到键盘中断") + await framework.shutdown() + except Exception as e: + print(f"🐱 框架运行出错: {str(e)}") + await framework._safe_shutdown() + sys.exit(1) + +if __name__ == "__main__": + try: + # 设置更详细的异常处理 + import signal + + def signal_handler(signum, frame): + """信号处理""" + print(f"\n🐱 接收到信号 {signum},正在关闭...") + sys.exit(0) + + # 注册信号处理 + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) + + print("\n🐱 主程序启动...") + # 运行主程序 + asyncio.run(main()) + + except KeyboardInterrupt: + print("\n🐱 接收到键盘中断,关闭...") + print("🐱 再见喵~") + sys.exit(0) + except SystemExit as e: + # 优雅处理SystemExit + exit_code = e.code if e.code is not None else 0 + if exit_code == 0: + print("🐱 框架已关闭") + else: + print(f"🐱 框架退出,代码: {exit_code}") + sys.exit(exit_code) + except Exception as e: + print(f"🐱 框架运行异常: {str(e)}") + import traceback + traceback.print_exc() + sys.exit(1) + finally: + print("🐱 框架进程结束") diff --git a/services/project_engine.py b/services/project_engine.py new file mode 100644 index 0000000..24f7fae --- /dev/null +++ b/services/project_engine.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +"""SenSu ProjectEngine — 异步子进程项目管理""" +import asyncio, os, logging, signal, time +from typing import Dict, Optional, List +from dataclasses import dataclass, field +from datetime import datetime + +logger = logging.getLogger(__name__) + +@dataclass +class ProjectProcess: + name: str + cmd: list + cwd: str = "." + env: dict = field(default_factory=dict) + port: int = 0 + proxy_path: str = "" + auto_restart: bool = True + process: asyncio.subprocess.Process = None + status: str = "stopped" + pid: int = 0 + started_at: float = 0 + log_buffer: list = field(default_factory=list) # 最近 200 行 + max_log_lines: int = 200 + +class ProjectEngine: + def __init__(self, service_manager=None): + self.sm = service_manager + self.projects: Dict[str, ProjectProcess] = {} + self._monitor_task = None + + async def start(self): + self._monitor_task = asyncio.create_task(self._health_monitor()) + logger.info("ProjectEngine 已就绪") + + async def run_project(self, name: str, cmd: list, cwd: str = ".", + env: dict = None, port: int = 0, proxy_path: str = "", + auto_restart: bool = True) -> bool: + if name in self.projects and self.projects[name].status == "running": + logger.warning(f"项目 {name} 已在运行") + return False + + pp = ProjectProcess(name=name, cmd=cmd, cwd=cwd, env=env or {}, + port=port, proxy_path=proxy_path, auto_restart=auto_restart) + return await self._start_process(pp) + + async def _start_process(self, pp: ProjectProcess) -> bool: + try: + full_env = {**os.environ, **pp.env} + pp.process = await asyncio.create_subprocess_exec( + *pp.cmd, cwd=pp.cwd, env=full_env, + stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, + stdin=asyncio.subprocess.PIPE + ) + pp.pid = pp.process.pid + pp.status = "running" + pp.started_at = time.time() + self.projects[pp.name] = pp + + # 启动日志读取任务 + asyncio.create_task(self._read_stream(pp, pp.process.stdout, "stdout")) + asyncio.create_task(self._read_stream(pp, pp.process.stderr, "stderr")) + + logger.info(f"项目已启动: {pp.name} (PID={pp.pid})") + if pp.port: + logger.info(f" 端口: {pp.port}") + if pp.proxy_path: + logger.info(f" 代理: {pp.proxy_path}") + + # 监控进程退出 + asyncio.create_task(self._wait_exit(pp)) + return True + except Exception as e: + logger.error(f"启动项目 {pp.name} 失败: {e}") + pp.status = "error" + self.projects[pp.name] = pp + return False + + async def _read_stream(self, pp: ProjectProcess, stream, tag: str): + while pp.status == "running" and stream and not stream.at_eof(): + try: + line = await stream.readline() + if line: + text = line.decode(errors="replace").rstrip() + pp.log_buffer.append(f"[{tag}] {text}") + if len(pp.log_buffer) > pp.max_log_lines: + pp.log_buffer = pp.log_buffer[-pp.max_log_lines:] + logger.debug(f"[{pp.name}] {text}") + except Exception: + break + + async def _wait_exit(self, pp: ProjectProcess): + if pp.process: + await pp.process.wait() + exit_code = pp.process.returncode + pp.status = "stopped" + logger.info(f"项目 {pp.name} 已退出 (code={exit_code})") + if pp.auto_restart and exit_code != 0: + logger.info(f"自动重启 {pp.name} ...") + await asyncio.sleep(2) + await self._start_process(pp) + + async def stop_project(self, name: str) -> bool: + pp = self.projects.get(name) + if not pp or pp.status != "running": + return False + pp.auto_restart = False + if pp.process: + pp.process.terminate() + try: + await asyncio.wait_for(pp.process.wait(), timeout=5) + except asyncio.TimeoutError: + pp.process.kill() + pp.status = "stopped" + logger.info(f"项目已停止: {name}") + return True + + async def send_stdin(self, name: str, text: str): + pp = self.projects.get(name) + if pp and pp.process and pp.process.stdin: + pp.process.stdin.write((text + "\n").encode()) + await pp.process.stdin.drain() + return True + return False + + def get_logs(self, name: str, tail: int = 50) -> List[str]: + pp = self.projects.get(name) + return pp.log_buffer[-tail:] if pp else [] + + def list_projects(self) -> List[dict]: + return [{"name": p.name, "status": p.status, "pid": p.pid, + "port": p.port, "proxy": p.proxy_path, + "uptime": int(time.time()-p.started_at) if p.started_at else 0} + for p in self.projects.values()] + + def get_project(self, name: str) -> Optional[ProjectProcess]: + return self.projects.get(name) + + async def _health_monitor(self): + while True: + await asyncio.sleep(10) + for pp in list(self.projects.values()): + if pp.status == "running" and pp.process: + if pp.process.returncode is not None: + pp.status = "stopped" + logger.warning(f"项目 {pp.name} 异常退出 (code={pp.process.returncode})") + + async def shutdown(self): + for name in list(self.projects.keys()): + await self.stop_project(name) + if self._monitor_task: + self._monitor_task.cancel() + logger.info("ProjectEngine 已关闭") diff --git a/services/pyenv_manager.py b/services/pyenv_manager.py new file mode 100644 index 0000000..c58d34e --- /dev/null +++ b/services/pyenv_manager.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +"""SenSu PyEnvManager — Python 版本管理 + venv + 依赖安装""" +import os, sys, subprocess, logging, venv, shutil +from pathlib import Path +from typing import Optional, List + +logger = logging.getLogger(__name__) + +class PyEnvManager: + def __init__(self, workspace_dir: str = None): + self.workspace = Path(workspace_dir or os.getcwd()) + self.venvs_dir = self.workspace / "venvs" + self.venvs_dir.mkdir(parents=True, exist_ok=True) + + def detect_versions(self) -> List[str]: + versions = set() + # Current Python + v = f"{sys.version_info.major}.{sys.version_info.minor}" + versions.add(v) + + # Check Termux pkg + try: + result = subprocess.run(["pkg", "list-installed"], capture_output=True, text=True, timeout=10) + for line in result.stdout.split("\n"): + if line.startswith("python-3.") or line.startswith("python3-"): + versions.add(line.split("/")[0].replace("python-", "").replace("python3-", "")) + except: + pass + + # Check pyenv + pyenv = shutil.which("pyenv") + if pyenv: + try: + result = subprocess.run([pyenv, "versions", "--bare"], capture_output=True, text=True, timeout=10) + for line in result.stdout.split("\n"): + line = line.strip() + if line and line[0].isdigit(): + versions.add(line.split("/")[0]) + except: + pass + + return sorted(versions) + + def ensure_version(self, version: str) -> Optional[str]: + """确保指定 Python 版本可用,返回解释器路径""" + available = self.detect_versions() + if version in available: + return self._find_python(version) + + # Try to install via Termux + if shutil.which("pkg"): + pkg_name = f"python-{version}" + logger.info(f"尝试安装 {pkg_name} ...") + try: + subprocess.run(["pkg", "install", "-y", pkg_name], check=True, timeout=120) + return self._find_python(version) + except: + pass + + logger.warning(f"无法获取 Python {version},使用当前版本") + return sys.executable + + def _find_python(self, version: str) -> Optional[str]: + for name in [f"python{version}", f"python{version[:3]}", "python3"]: + path = shutil.which(name) + if path: return path + return sys.executable + + def create_venv(self, name: str, python_version: str = None) -> Optional[Path]: + venv_path = self.venvs_dir / name + if venv_path.exists(): + logger.info(f"venv 已存在: {venv_path}") + return venv_path + + python_exe = self.ensure_version(python_version) if python_version else sys.executable + logger.info(f"创建 venv: {venv_path} (Python {python_version or 'default'})") + + try: + venv.create(str(venv_path), with_pip=True, clear=True) + # Install/upgrade pip + pip = str(venv_path / "bin" / "pip") + subprocess.run([pip, "install", "--upgrade", "pip"], capture_output=True, timeout=60) + return venv_path + except Exception as e: + logger.error(f"创建 venv 失败: {e}") + # Fallback: use virtualenv + try: + subprocess.run([sys.executable, "-m", "virtualenv", str(venv_path)], check=True, timeout=120) + return venv_path + except: + return None + + def install_deps(self, venv_path: Path, requirements: List[str]) -> bool: + pip = str(venv_path / "bin" / "pip") + for req_file in requirements: + req_path = Path(req_file) + if not req_path.is_absolute(): + # Relative to workspace + pass + if Path(req_file).exists(): + logger.info(f"安装依赖: {req_file}") + try: + subprocess.run([pip, "install", "-r", req_file], check=True, timeout=300) + except subprocess.CalledProcessError as e: + logger.warning(f"依赖安装部分失败: {e}") + return False + return True + + def clone_git(self, url: str, target_dir: Path, branch: str = None) -> bool: + if target_dir.exists(): + logger.info(f"目录已存在: {target_dir}") + # Try git pull instead + try: + subprocess.run(["git", "-C", str(target_dir), "pull"], check=True, timeout=60) + return True + except: + pass + + cmd = ["git", "clone"] + if branch: + cmd += ["-b", branch] + cmd += [url, str(target_dir)] + + try: + subprocess.run(cmd, check=True, timeout=300) + logger.info(f"Git clone 完成: {url} → {target_dir}") + return True + except subprocess.CalledProcessError as e: + logger.error(f"Git clone 失败: {e}") + return False diff --git a/services/web_panel/manager.py b/services/web_panel/manager.py index 2be5608..0347914 100644 --- a/services/web_panel/manager.py +++ b/services/web_panel/manager.py @@ -5,7 +5,7 @@ import os import logging from pathlib import Path from aiohttp import web -from .routes import auth, status, plugins, commands, logs +from .routes import auth, status, plugins, commands, logs, projects logger = logging.getLogger(__name__) @@ -62,6 +62,7 @@ class WebPanelManager: plugins.setup_routes(app, self.base_path) commands.setup_routes(app, self.base_path) logs.setup_routes(app, self.base_path) + projects.setup_project_routes(app, self.sm) # 注册日志广播 ls = self.sm.get_service("log") diff --git a/services/web_panel/routes/projects.py b/services/web_panel/routes/projects.py new file mode 100644 index 0000000..1fd4748 --- /dev/null +++ b/services/web_panel/routes/projects.py @@ -0,0 +1,60 @@ +from aiohttp import web +import json, logging +logger = logging.getLogger(__name__) + +def _get_engine(request): + sm = request.app.get("service_manager") + if sm and sm.has_service("project_engine"): + return sm.get_service("project_engine") + return None + +def setup_project_routes(app, service_manager): + app["service_manager"] = service_manager + + async def list_projects(request): + eng = _get_engine(request) + return web.json_response({"projects": eng.list_projects() if eng else []}) + + async def run_project(request): + try: + data = await request.json() + eng = _get_engine(request) + if not eng: + return web.json_response({"ok": False, "error": "engine not ready"}, status=503) + ok = await eng.run_project( + name=data.get("name","unnamed"), cmd=data.get("cmd",[]), + cwd=data.get("cwd","."), env=data.get("env",{}), + port=data.get("port",0), proxy_path=data.get("proxy_path","")) + return web.json_response({"ok": ok}) + except Exception as e: + return web.json_response({"ok": False, "error": str(e)}, status=400) + + async def stop_project(request): + name = request.match_info.get("name","") + eng = _get_engine(request) + ok = await eng.stop_project(name) if eng else False + return web.json_response({"ok": ok}) + + async def get_logs(request): + name = request.match_info.get("name","") + tail = int(request.query.get("tail", 50)) + eng = _get_engine(request) + return web.json_response({"logs": eng.get_logs(name, tail) if eng else []}) + + async def send_stdin(request): + name = request.match_info.get("name","") + data = await request.json() + eng = _get_engine(request) + ok = await eng.send_stdin(name, data.get("text","")) if eng else False + return web.json_response({"ok": ok}) + + async def project_page(request): + return web.FileResponse("static/web_panel/pages/projects.html") + + app.router.add_get("/api/projects", list_projects) + app.router.add_post("/api/projects/run", run_project) + app.router.add_get("/api/projects/{name}/logs", get_logs) + app.router.add_post("/api/projects/{name}/stop", stop_project) + app.router.add_post("/api/projects/{name}/stdin", send_stdin) + app.router.add_get("/pages/projects", project_page) + logger.info("📦 项目管理路由已注册") diff --git a/static/web_panel/pages/projects.html b/static/web_panel/pages/projects.html new file mode 100644 index 0000000..7a73320 --- /dev/null +++ b/static/web_panel/pages/projects.html @@ -0,0 +1,119 @@ + + +项目管理 - SenSu + + +

📦 项目管理

+
+
运行中
+
+ 添加项目
+
+ +
+
加载中...
+
+ + + + + \ No newline at end of file diff --git a/tests/test_phase1.py b/tests/test_phase1.py new file mode 100644 index 0000000..333acd3 --- /dev/null +++ b/tests/test_phase1.py @@ -0,0 +1,46 @@ +import pytest, asyncio, os, sys, time +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) + +class TestProjectEngine: + @pytest.fixture + def engine(self): + from services.project_engine import ProjectEngine + return ProjectEngine() + + def test_list_empty(self, engine): + assert engine.list_projects() == [] + + @pytest.mark.asyncio + async def test_run_stop_project(self, engine): + ok = await engine.run_project("test_sleep", ["sleep", "10"], cwd=".") + assert ok is True + assert "test_sleep" in engine.projects + assert engine.projects["test_sleep"].status == "running" + # Stop + ok2 = await engine.stop_project("test_sleep") + assert ok2 is True + + def test_get_logs(self, engine): + engine.projects["dummy"] = type("obj",(),{"log_buffer":["line1","line2","line3"]})() + logs = engine.get_logs("dummy", tail=2) + assert len(logs) == 2 + +class TestPyEnvManager: + def test_detect_versions(self): + from services.pyenv_manager import PyEnvManager + mgr = PyEnvManager("~/test_pyenv_tmp") + versions = mgr.detect_versions() + assert len(versions) >= 1 + assert any(v.startswith("3.") for v in versions) + + def test_venv_creation(self): + from services.pyenv_manager import PyEnvManager + import tempfile, shutil + tmp = tempfile.mkdtemp() + try: + mgr = PyEnvManager(tmp) + venv = mgr.create_venv("test_venv") + assert venv is not None + assert (venv / "bin" / "python").exists() or (venv / "bin" / "python3").exists() + finally: + shutil.rmtree(tmp, ignore_errors=True)