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)
This commit is contained in:
2026-06-10 20:41:24 +08:00
parent 7e762b68fe
commit 8dbf628dd6
11 changed files with 1012 additions and 35 deletions
+1 -1
View File
@@ -92,7 +92,7 @@ commands:
permissions: permissions:
- framework.command.test - framework.command.test
source: internal source: internal
last_updated: 248209.227564533 last_updated: 252439.598783596
plugin_commands: plugin_commands:
example_plugin: example_plugin:
echo: *id001 echo: *id001
+1 -1
View File
@@ -1,5 +1,5 @@
http_port: 4200 http_port: 4200
last_updated: 248209.231599689 last_updated: 252439.682639846
plugin_routes: plugin_routes:
example_plugin: example_plugin:
- methods: - methods:
+28
View File
@@ -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
+30 -23
View File
@@ -5,7 +5,9 @@ import logging
import asyncio import asyncio
import sys import sys
import signal 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 import os
from pathlib import Path from pathlib import Path
@@ -31,15 +33,13 @@ from service_manager import ServiceManager
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class SenSuFramework: class CatFramework:
"""框架主类""" """框架主类"""
def __init__(self, headless=False): def __init__(self):
self.service_manager = ServiceManager() self.service_manager = ServiceManager()
self.headless = headless
self.is_running = False self.is_running = False
self.headless = headless logger.debug("🐱 DreamSu 框架初始化开始")
logger.debug("🐱 SenSu 框架初始化开始")
async def initialize(self): async def initialize(self):
"""初始化框架""" """初始化框架"""
@@ -83,18 +83,16 @@ class SenSuFramework:
self.service_manager.register_service("shutdown", shutdown_service) self.service_manager.register_service("shutdown", shutdown_service)
# 8. TUI服务 # 8. TUI服务
if self.headless:
logger.info("Headless: skip TUI")
self.service_manager.register_service("tui", self._create_fallback_tui())
else:
try: try:
tui_service = TuiService(base_config, log_service, command_service) tui_service = TuiService(base_config, log_service, command_service)
await tui_service.start() await tui_service.start()
self.service_manager.register_service("tui", tui_service) self.service_manager.register_service("tui", tui_service)
logger.info("TUI服务启动成功") logger.info("TUI服务启动成功")
except Exception as e: except Exception as e:
logger.warning(f"TUI服务启动失败: {e}") logger.warning(f"TUI服务启动失败,使用命令行模式: {str(e)}")
self.service_manager.register_service("tui", self._create_fallback_tui()) self.service_manager.register_service("tui", self._create_fallback_tui())
# 9. 权限服务
logger.info("> 初始化 权限服务 中...") logger.info("> 初始化 权限服务 中...")
permission_service = PermissionService(base_config, self.service_manager.get_service("tui"), core_bridge) permission_service = PermissionService(base_config, self.service_manager.get_service("tui"), core_bridge)
permission_started = await permission_service.start() permission_started = await permission_service.start()
@@ -158,8 +156,7 @@ class SenSuFramework:
logger.info("✅ 互联网服务启动成功") logger.info("✅ 互联网服务启动成功")
# 显示服务信息 # 显示服务信息
logger.info("🌐 服务地址:") logger.info(f"🌐 服务地址: http://{internet_service.http_host}:{internet_service.http_port}")
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}/health")
else: else:
logger.warning("⚠️ 网络服务已启动但端口未响应") logger.warning("⚠️ 网络服务已启动但端口未响应")
@@ -184,6 +181,19 @@ class SenSuFramework:
# 注册框架关闭处理器 # 注册框架关闭处理器
shutdown_service.register_shutdown_handler(self._framework_shutdown_handler) 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 初始化完成!") logger.info("🎉 SenSu 初始化完成!")
self.is_running = True self.is_running = True
@@ -200,14 +210,14 @@ class SenSuFramework:
# 显示欢迎消息 # 显示欢迎消息
tui_service = self.service_manager.get_service("tui") tui_service = self.service_manager.get_service("tui")
if hasattr(tui_service, 'show_message'): 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("====================================\n", "info")
tui_service.show_message("🐱 SenSu 已就绪!", "info") tui_service.show_message("🐱 SenSu 已就绪!", "info")
tui_service.show_message(f"当前 SenSu 版本号 {version}", "info") tui_service.show_message(f"当前 SenSu 版本号 {version}", "info")
tui_service.show_message(f"当前日志级别 {logLevel}\n", "info") tui_service.show_message(f"当前日志级别 {logLevel}\n", "info")
tui_service.show_message("====================================\n", "info") tui_service.show_message("====================================\n", "info")
tui_service.show_message(f"当前API配置地址", "debug") 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("====================================\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")
@@ -223,7 +233,7 @@ class SenSuFramework:
def _create_fallback_tui(self): def _create_fallback_tui(self):
"""创建回退的TUI服务(命令行模式)""" """创建回退的TUI服务(命令行模式)"""
class FallbackTuiService: class FallbackTuiService:
def __init__(self, headless=False): def __init__(self):
self.is_running = True self.is_running = True
def show_message(self, message: str, msg_type: str = "info", persistent: bool = False): 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) logger.error(f"关闭框架时出错: {str(e)}", exc_info=True)
await self._safe_shutdown() await self._safe_shutdown()
async def main(headless=False): async def main():
"""主函数""" """主函数"""
framework = SenSuFramework(headless=headless) framework = CatFramework()
try: try:
# 初始化框架 # 初始化框架
@@ -405,7 +415,6 @@ if __name__ == "__main__":
# 设置更详细的异常处理 # 设置更详细的异常处理
import signal import signal
def signal_handler(signum, frame): def signal_handler(signum, frame):
"""信号处理""" """信号处理"""
print(f"\n🐱 接收到信号 {signum},正在关闭...") print(f"\n🐱 接收到信号 {signum},正在关闭...")
@@ -416,10 +425,8 @@ if __name__ == "__main__":
signal.signal(signal.SIGTERM, signal_handler) signal.signal(signal.SIGTERM, signal_handler)
print("\n🐱 主程序启动...") print("\n🐱 主程序启动...")
parser = argparse.ArgumentParser(description="SenSu") # 运行主程序
parser.add_argument("--headless", action="store_true") asyncio.run(main())
args = parser.parse_args()
asyncio.run(main(headless=args.headless))
except KeyboardInterrupt: except KeyboardInterrupt:
print("\n🐱 接收到键盘中断,关闭...") print("\n🐱 接收到键盘中断,关闭...")
+433
View File
@@ -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("🐱 框架进程结束")
+153
View File
@@ -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 已关闭")
+130
View File
@@ -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
+2 -1
View File
@@ -5,7 +5,7 @@ import os
import logging import logging
from pathlib import Path from pathlib import Path
from aiohttp import web 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__) logger = logging.getLogger(__name__)
@@ -62,6 +62,7 @@ class WebPanelManager:
plugins.setup_routes(app, self.base_path) plugins.setup_routes(app, self.base_path)
commands.setup_routes(app, self.base_path) commands.setup_routes(app, self.base_path)
logs.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") ls = self.sm.get_service("log")
+60
View File
@@ -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("📦 项目管理路由已注册")
+119
View File
@@ -0,0 +1,119 @@
<!DOCTYPE html>
<html lang="zh">
<head><meta charset="UTF-8"><title>项目管理 - SenSu</title>
<style>
body{font-family:monospace;background:#0e1416;color:#e0e3e4;margin:0;padding:16px}
h2{color:#00bcd4;border-bottom:1px solid #333;padding-bottom:8px}
.card{background:#1a1f21;border-radius:8px;padding:12px;margin:8px 0}
input,button,select{padding:6px 10px;margin:4px;border-radius:4px;border:1px solid #444;background:#222;color:#e0e3e4}
button{background:#00bcd4;color:#000;cursor:pointer;font-weight:bold}
button:hover{opacity:0.8}
button.danger{background:#ff5252}
.status-running{color:#4caf50}.status-stopped{color:#ff5252}.status-error{color:#ff9800}
.logs{background:#000;color:#0f0;padding:8px;border-radius:4px;max-height:300px;overflow-y:auto;font-size:12px}
.tabs{display:flex;gap:4px;margin-bottom:12px}
.tab{padding:6px 12px;background:#333;border-radius:4px 4px 0 0;cursor:pointer}
.tab.active{background:#00bcd4;color:#000}
</style></head>
<body>
<h2>📦 项目管理</h2>
<div class="tabs">
<div class="tab active" onclick="showTab('running')">运行中</div>
<div class="tab" onclick="showTab('add')">+ 添加项目</div>
</div>
<div id="tab-running">
<div id="project-list">加载中...</div>
</div>
<div id="tab-add" style="display:none">
<div class="card">
<h3>添加新项目</h3>
<input id="proj-name" placeholder="项目名称" style="width:200px"><br>
<input id="proj-cmd" placeholder="启动命令 (如: python3 main.py)" style="width:400px"><br>
<input id="proj-cwd" placeholder="工作目录 (默认: .)" value="." style="width:200px"><br>
<input id="proj-port" placeholder="端口 (可选)" type="number" style="width:100px"><br>
<input id="proj-proxy" placeholder="代理路径 (可选, 如: /myapp)" style="width:200px"><br>
<button onclick="addProject()">启动项目</button>
</div>
<div class="card">
<h3>从 Git 部署</h3>
<input id="git-url" placeholder="Git URL" style="width:400px"><br>
<input id="git-branch" placeholder="分支 (默认: main)" value="main" style="width:200px"><br>
<button onclick="deployGit()">克隆并部署</button>
</div>
</div>
<script>
let projects=[];
async function refresh(){
try{
const r=await fetch("/SenSu/api/projects");
const d=await r.json();
projects=d.projects;
let h="";
for(const p of projects){
const cls=p.status==="running"?"status-running":p.status==="error"?"status-error":"status-stopped";
h+=`<div class="card">
<b>${p.name}</b> <span class="${cls}">● ${p.status}</span>
${p.pid?` PID:${p.pid}`:""} ${p.port?` :${p.port}`:""} ${p.uptime?` ${p.uptime}s`:""}
${p.proxy?`${p.proxy}`:""}
<button class="danger" onclick="stopProject('${p.name}')">停止</button>
<button onclick="viewLogs('${p.name}')">日志</button>
<button onclick="sendCmd('${p.name}')">发送命令</button>
<div id="logs-${p.name}" class="logs" style="display:none"></div>
</div>`;
}
document.getElementById("project-list").innerHTML=h||"暂无项目";
}catch(e){console.error(e)}
}
async function addProject(){
const name=document.getElementById("proj-name").value;
const cmdStr=document.getElementById("proj-cmd").value;
if(!name||!cmdStr){alert("请填写名称和命令");return}
await fetch("/SenSu/api/projects/run",{
method:"POST",headers:{"Content-Type":"application/json"},
body:JSON.stringify({
name,cwd:document.getElementById("proj-cwd").value,
cmd:cmdStr.split(" "),
port:parseInt(document.getElementById("proj-port").value)||0,
proxy_path:document.getElementById("proj-proxy").value
})
});
refresh();
}
async function deployGit(){
const url=document.getElementById("git-url").value;
const branch=document.getElementById("git-branch").value;
if(!url){alert("请填写 Git URL");return}
const name=url.split("/").pop().replace(".git","");
const resp=await fetch("/SenSu/api/projects/run",{
method:"POST",headers:{"Content-Type":"application/json"},
body:JSON.stringify({name,cmd:["git","clone","-b",branch,url,name],cwd:"data/projects"})
});
refresh();
}
async function stopProject(name){
await fetch(`/SenSu/api/projects/${name}/stop`,{method:"POST"});
refresh();
}
async function viewLogs(name){
const el=document.getElementById("logs-"+name);
el.style.display=el.style.display==="none"?"block":"none";
if(el.style.display==="block"){
const r=await fetch(`/SenSu/api/projects/${name}/logs?tail=30`);
const d=await r.json();
el.innerHTML=d.logs.join("\n")||"(无日志)";
}
}
async function sendCmd(name){
const text=prompt("发送命令到 "+name+":");
if(text) await fetch(`/SenSu/api/projects/${name}/stdin`,{
method:"POST",headers:{"Content-Type":"application/json"},
body:JSON.stringify({text})
});
}
function showTab(t){document.querySelectorAll("#tab-running,#tab-add").forEach(e=>e.style.display="none");document.getElementById("tab-"+t).style.display="block"}
setInterval(refresh,3000);refresh();
</script>
</body></html>
+46
View File
@@ -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)