diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5958261 --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +__pycache__/ +*.pyc +*.pyo +logs/ +*.log +_patches_applied/ +*.bak.* +.env +*.swp +*.swo +*~ +.DS_Store +docs/ +data/ +config/framework/base_config.yaml +!config/framework/base_config.yaml.example +*.orig +*.clean +config/framework/permission_rules.yaml +config/permissions/ +config/plugins/ +config/services/ +!docs/API_Auth_Flow.md +!docs/SenSu 插件开发详细指南.md +!docs/SenSu 框架基本架构.md +plugins/*/config.yaml +!plugins/*/config.yaml.example diff --git a/LICENSE b/LICENSE index a95568b..a78b074 100644 --- a/LICENSE +++ b/LICENSE @@ -1,73 +1,201 @@ -Apache License -Version 2.0, January 2004 -http://www.apache.org/licenses/ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ -TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION -1. Definitions. + 1. Definitions. -"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. -"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. -"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. -"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. -"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. -"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. -"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). -"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. -"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." -"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. -2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. -3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. -4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: - (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and - (b) You must cause any modified files to carry prominent notices stating that You changed the files; and + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and - (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and - (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. - You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. -5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. -6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. -7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. -8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. -9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. -END OF TERMS AND CONDITIONS + END OF TERMS AND CONDITIONS -APPENDIX: How to apply the Apache License to your work. + APPENDIX: How to apply the Apache License to your work. -To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Do not include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. -Copyright 2026 AskaEth + Copyright [2026] [AskaEth] -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 + http://www.apache.org/licenses/LICENSE-2.0 -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index 235b28d..93cae00 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,114 @@ -# SenSu +# 🐱 SenSu -一个轻量化 Python TUI 插件框架。 \ No newline at end of file +万能 Python TUI 项目管理器 — 插件化架构,任何项目都能挂载运行。 + +[![License](https://img.shields.io/badge/license-Apache%202.0-blue)](LICENSE) +[![Python](https://img.shields.io/badge/python-3.11%2B-blue)]() + +--- + +## ✨ 特性 + +- 🎨 **Textual TUI** — 系统监控面板 + 命令补全 + CLI 回退 +- 🔌 **插件系统** — 热加载、权限隔离、依赖解析、进程隔离 +- 📁 **文件管理器** — 全文件系统浏览、编辑、上传、右键菜单,Windows/Linux/macOS 兼容 +- 🗄️ **项目引擎** — 接管任何 Python/命令行项目 (subprocess + 日志 + stdin) +- 🔀 **反向代理** — HTTP/WS 代理,本地/局域网/公网端口映射 +- 📊 **SQLite 持久化** — 插件状态、权限、审计日志统一存储 +- 🌐 **Web 管理面板** — aiohttp MD3 WebUI,日夜主题切换,WebSocket 实时推送 +- 🔐 **安全认证** — PBKDF2-SHA256、环境变量密码 +- 🐳 **Docker 部署** — Alpine 镜像,systemd 服务文件 +- 🧪 **28 个回归测试** — pytest,零失败 +- 🔑 **API Key 系统** — 权限模板(readonly/monitor/full)、过期时间、用量追踪 +- 🛡️ **Sentinel 集群监控** — 多设备实时性能仪表盘 + 节点管理 +- 🔄 **版本更新检测** — 自动检查/下载/备份/回滚 +- 🛡️ **安全加固** — 全端点认证、CSRF防护、SSRF防护、错误脱敏 +- 🛠️ **插件 SDK** — 命令装饰器 + Web 面板 + API + SSE 推送 + 文件选择器 + +## 🚀 快速开始 + +```bash +pip install -r requirements.txt +./start.sh # 一键启动(依赖检测+后台) +python main.py # TUI 模式 +python main.py --headless # 后台模式 (Web 面板 :4200) +python -m pytest tests/ -v # 运行测试 +``` + +## 🏗️ 架构 + +``` +main.py → SenSuFramework (15+ 服务) +├── UpdateService ← 版本检测 + 备份 + 回滚 +├── ProjectEngine ← 接管任何项目 (subprocess + 日志) +├── ProxyService ← 反向代理 (HTTP+WS) +├── PyEnvManager ← Python 版本 + venv + git clone +├── SenSuDB ← SQLite 持久化 (5表) +├── PluginWebMixin ← 插件 Web 控制面板 SDK +├── PluginService ← 热加载 + 状态追踪 +├── SenSuDB ← SQLite 5 表 +├── TuiService ← Textual + 系统监控 +├── InternetService ← HTTP :4200 + WS :4240 +└── WebPanelManager ← /SenSu 管理面板 (路由 + WS + 文件管理) +``` + +## 📁 目录 + +``` +SenSu/ +├── main.py, service_manager.py +├── sdk/ plugin_command, plugin_status, plugin_error, plugin_web +├── services/ 15+ 核心服务 + web_panel/ (路由 + 工具) +├── bridges/ CoreBridge + PluginBridge +├── plugins/ 插件目录 + example_plugin +├── static/ MD3 CSS + JS + 页面 +├── deploy/ systemd + Dockerfile +├── tests/ 28 个测试 +└── docs/ 开发文档 +``` + +## 🌐 Web 面板 + +``` +http://0.0.0.0:4200/SenSu/ +├── /dashboard 仪表盘 (WS 实时推送 CPU/内存/网速双线图) +├── /pages/files 文件管理器 (全系统浏览/编辑/上传/右键菜单) +├── /pages/projects 项目管理 (添加/启动/Git部署) +├── /pages/proxy 反向代理管理 +├── /pages/plugins 插件管理 (启用/停用/重载) +├── /pages/logs 实时日志 (WS 推送/500行上限/批量渲染) +├── /pages/console Web 终端 +└── /plugin/{name} 插件控制面板 +``` + +## 🔌 插件开发 + +```python +from sdk.plugin_command_decorator import plugin_command +from sdk.plugin_web import PluginWebMixin + +class Plugin(PluginWebMixin): + def __init__(self, plugin_name=None, config=None, bridge=None): + PluginWebMixin.__init__(self) + self.plugin_name = plugin_name + + async def initialize(self): + self.register_web_page("my_plugin", "My Panel", "

Hello

") + + @plugin_command(name="hello") + async def cmd_hello(self, *args): + return "Hello World!" +``` + +## 🔧 环境变量 + +| 变量 | 默认值 | +|------|------| +| `SENSU_ADMIN_PASSWORD` | `admin123` | +| `SENSU_API_PASSWORD` | `api123` | +| `SENSU_PANEL_USER` | `admin` | +| `SENSU_PANEL_PASS` | `admin` | + +## 📄 许可证 + +Apache License 2.0 · Copyright 2026 AskaEth diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..9bf52c1 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,49 @@ +# SenSu 开发路线图 + +> 当前版本: Alpha 0.7.0 +> 更新: 2026-06-13 + +--- + +## 一、已完成 (v0.7.0) + +- [x] 13 服务异步框架 (init/log/tui/command/auth/internet/plugin/permission/api/shutdown/web_panel/bridge) +- [x] Textual TUI 三栏界面 + CLI 回退 +- [x] 插件热加载 + 权限管理 +- [x] Web 管理面板 (aiohttp :4200) +- [x] 消息桥接 (CoreBridge + PluginBridge + NetworkBridge) +- [x] 插件 SDK (`sdk/`): PluginStatus, PluginError, plugin_command 装饰器 +- [x] 15 个回归测试 +- [x] 安全加固: PBKDF2-SHA256, 环境变量密码, 认证降级已移除 +- [x] Android 兼容: os.getloadavg(), /proc/net/dev +- [x] Apache 2.0 许可证 + +## 二、v0.2.2 — 打磨 ✅ 全部完成 + +- [x] `--headless` 模式 — 纯后台运行,不启动 Textual TUI +- [x] 调试服务器自启动 — `auto_start_scripts` 通用机制 +- [x] Web 面板日志 WebSocket 修复 — 动态 base 路径 +- [x] 插件热重载 — watchdog 监听 plugins/ 目录 +- [x] test_demo 插件完善 — echo + plugin_status 命令 + +## 三、v0.3 — 项目管理 ✅ 全部完成 + +- [x] 项目注册表 — `project.yaml` 声明, ProjectService 管理生命周期 +- [x] SQLite 持久化 — SenSuDB 5 表 (plugins/permissions/config_kv/audit_log) +- [x] 插件依赖解析 — 拓扑排序, 循环依赖检测 +- [x] HTTP API 自动暴露 — PluginNetworkBridge.register_command_routes() + +## 四、v0.4 — TUI 仪表盘 ✅ 全部完成 + +- [x] 系统监控面板 — CPU/MEM 彩色进度条 + 网络速率 (复用 SystemInfoCollector) +- [x] 插件实时状态面板 — 名称/版本/命令数展示 +- [x] 命令补全 — Textual Suggester API, Tab 前缀匹配 + +## 五、v0.5 — 生产就绪 ✅ 全部完成 + +- [x] 插件进程隔离 — IsolatedPlugin (multiprocessing.Process) + PluginService 集成 +- [x] 插件索引仓库 — PluginIndex + `install ` 命令 +- [x] systemd 集成 — `deploy/sensu.service` +- [x] Docker 化 — `deploy/Dockerfile` (Alpine, <100MB) + +--- diff --git a/bridges/__init__.py b/bridges/__init__.py new file mode 100644 index 0000000..6c303a1 --- /dev/null +++ b/bridges/__init__.py @@ -0,0 +1,16 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" +桥接模块 - 提供模块间和插件间的通信功能 +""" + +from .core_bridge import CoreBridge, MessageType +from .plugin_bridge import PluginBridge, PluginMessageType + +__all__ = [ + 'CoreBridge', + 'MessageType', + 'PluginBridge', + 'PluginMessageType' +] diff --git a/bridges/core_bridge.py b/bridges/core_bridge.py new file mode 100644 index 0000000..fcf50d3 --- /dev/null +++ b/bridges/core_bridge.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import logging +import asyncio +from typing import Dict, List, Callable, Any +from enum import Enum +import json + +logger = logging.getLogger(__name__) + +class MessageType(Enum): + """消息类型枚举""" + EVENT = "event" + COMMAND = "command" + DATA = "data" + STATUS = "status" + ERROR = "error" + +class CoreBridge: + """核心桥接服务 - 用于框架模块间通信""" + + def __init__(self): + self.subscribers: Dict[str, List[Callable]] = {} + self.message_queue: asyncio.Queue = asyncio.Queue() + self.is_running = False + self.processing_task = None + logger.debug("CoreBridge初始化开始") + + async def start(self): + """启动桥接服务""" + try: + logger.info("启动核心桥接服务") + self.is_running = True + self.processing_task = asyncio.create_task(self._process_messages()) + logger.debug("核心桥接服务启动完成") + except Exception as e: + logger.error(f"启动核心桥接服务时出错: {str(e)}", exc_info=True) + raise + + def subscribe(self, topic: str, callback: Callable): + """订阅主题""" + try: + if topic not in self.subscribers: + self.subscribers[topic] = [] + + self.subscribers[topic].append(callback) + logger.debug(f"订阅主题: {topic}, 当前订阅者数: {len(self.subscribers[topic])}") + except Exception as e: + logger.error(f"订阅主题 {topic} 时出错: {str(e)}", exc_info=True) + raise + + def unsubscribe(self, topic: str, callback: Callable): + """取消订阅""" + try: + if topic in self.subscribers and callback in self.subscribers[topic]: + self.subscribers[topic].remove(callback) + logger.debug(f"取消订阅主题: {topic}, 剩余订阅者数: {len(self.subscribers[topic])}") + except Exception as e: + logger.error(f"取消订阅主题 {topic} 时出错: {str(e)}", exc_info=True) + + async def publish(self, topic: str, message: Dict, msg_type: MessageType = MessageType.DATA): + """发布消息""" + try: + message_data = { + "topic": topic, + "type": msg_type.value, + "data": message, + "timestamp": asyncio.get_event_loop().time() + } + + await self.message_queue.put(message_data) + logger.debug(f"发布消息到主题: {topic}, 类型: {msg_type.value}") + + except Exception as e: + logger.error(f"发布消息到主题 {topic} 时出错: {str(e)}", exc_info=True) + raise + + async def _process_messages(self): + """处理消息队列""" + try: + logger.debug("开始处理消息队列") + + while self.is_running: + try: + # 等待消息,带超时以便检查运行状态 + message = await asyncio.wait_for(self.message_queue.get(), timeout=1.0) + + # 分发消息给订阅者 + await self._dispatch_message(message) + + # 标记任务完成 + self.message_queue.task_done() + + except asyncio.TimeoutError: + continue + except Exception as e: + logger.error(f"处理消息时出错: {str(e)}", exc_info=True) + continue + + logger.debug("消息队列处理结束") + + except Exception as e: + logger.error(f"消息队列处理循环出错: {str(e)}", exc_info=True) + + async def _dispatch_message(self, message: Dict): + """分发消息给订阅者""" + try: + topic = message["topic"] + + if topic not in self.subscribers: + logger.debug(f"主题 {topic} 没有订阅者") + return + + subscribers = self.subscribers[topic][:] # 复制列表避免在迭代时修改 + + # 并行调用所有订阅者 + tasks = [] + for callback in subscribers: + task = asyncio.create_task(self._call_subscriber(callback, message)) + tasks.append(task) + + if tasks: + await asyncio.gather(*tasks, return_exceptions=True) + + logger.debug(f"消息分发完成,主题: {topic}, 订阅者数: {len(subscribers)}") + + except Exception as e: + logger.error(f"分发消息时出错: {str(e)}", exc_info=True) + + async def _call_subscriber(self, callback: Callable, message: Dict): + """调用订阅者回调""" + try: + if asyncio.iscoroutinefunction(callback): + await callback(message) + else: + callback(message) + + except Exception as e: + logger.error(f"调用订阅者回调时出错: {str(e)}", exc_info=True) + + def get_subscriber_count(self, topic: str = None) -> int: + """获取订阅者数量""" + try: + if topic: + count = len(self.subscribers.get(topic, [])) + logger.debug(f"主题 {topic} 的订阅者数量: {count}") + return count + else: + total = sum(len(subs) for subs in self.subscribers.values()) + logger.debug(f"总订阅者数量: {total}") + return total + except Exception as e: + logger.error(f"获取订阅者数量时出错: {str(e)}", exc_info=True) + return 0 + + async def shutdown(self): + """关闭桥接服务""" + try: + logger.info("关闭核心桥接服务") + self.is_running = False + + # 等待处理任务结束 + if self.processing_task: + await asyncio.wait_for(self.processing_task, timeout=5.0) + + # 清空队列和订阅者 + self.subscribers.clear() + while not self.message_queue.empty(): + try: + self.message_queue.get_nowait() + self.message_queue.task_done() + except asyncio.QueueEmpty: + break + + logger.debug("核心桥接服务关闭完成") + + except Exception as e: + logger.error(f"关闭核心桥接服务时出错: {str(e)}", exc_info=True) diff --git a/bridges/plugin_bridge.py b/bridges/plugin_bridge.py new file mode 100644 index 0000000..9246a68 --- /dev/null +++ b/bridges/plugin_bridge.py @@ -0,0 +1,266 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import logging +import asyncio +from typing import Dict, List, Callable, Any +from enum import Enum +import json + +logger = logging.getLogger(__name__) + +class PluginMessageType(Enum): + """插件消息类型枚举""" + PLUGIN_EVENT = "plugin_event" + PLUGIN_DATA = "plugin_data" + PLUGIN_COMMAND = "plugin_command" + PLUGIN_REQUEST = "plugin_request" + PLUGIN_RESPONSE = "plugin_response" + +class PluginBridge: + """插件桥接服务 - 用于框架与插件、插件间通信""" + + def __init__(self, core_bridge): + self.core_bridge = core_bridge + self.plugin_subscribers: Dict[str, Dict[str, List[Callable]]] = {} + self.plugin_message_queue: asyncio.Queue = asyncio.Queue() + self.is_running = False + self.processing_task = None + logger.debug("PluginBridge初始化开始") + + + def subscribe_plugin(self, topic: str, handler, plugin_name: str = None): + if plugin_name: + if plugin_name not in self.plugin_subscribers: + self.plugin_subscribers[plugin_name] = {} + if topic not in self.plugin_subscribers[plugin_name]: + self.plugin_subscribers[plugin_name][topic] = [] + self.plugin_subscribers[plugin_name][topic].append(handler) + self.core_bridge.subscribe(topic, handler) + logger.debug(f"plugin {plugin_name or '?'} subscribed: {topic}") + async def start(self): + """启动插件桥接服务""" + try: + logger.info("启动插件桥接服务") + self.is_running = True + self.processing_task = asyncio.create_task(self._process_plugin_messages()) + + # 订阅核心桥接的相关主题 + self.core_bridge.subscribe("plugin.*", self._handle_core_plugin_message) + + logger.debug("插件桥接服务启动完成") + except Exception as e: + logger.error(f"启动插件桥接服务时出错: {str(e)}", exc_info=True) + raise + + def subscribe_plugin(self, plugin_name: str, topic: str, callback: Callable): + """插件订阅主题""" + try: + if plugin_name not in self.plugin_subscribers: + self.plugin_subscribers[plugin_name] = {} + + if topic not in self.plugin_subscribers[plugin_name]: + self.plugin_subscribers[plugin_name][topic] = [] + + self.plugin_subscribers[plugin_name][topic].append(callback) + logger.debug(f"插件 {plugin_name} 订阅主题: {topic}, 订阅者数: {len(self.plugin_subscribers[plugin_name][topic])}") + except Exception as e: + logger.error(f"插件订阅主题时出错: {str(e)}", exc_info=True) + raise + + def unsubscribe_plugin(self, plugin_name: str, topic: str, callback: Callable): + """插件取消订阅""" + try: + if (plugin_name in self.plugin_subscribers and + topic in self.plugin_subscribers[plugin_name] and + callback in self.plugin_subscribers[plugin_name][topic]): + + self.plugin_subscribers[plugin_name][topic].remove(callback) + logger.debug(f"插件 {plugin_name} 取消订阅主题: {topic}") + except Exception as e: + logger.error(f"插件取消订阅时出错: {str(e)}", exc_info=True) + + async def publish_to_plugin(self, target_plugin: str, topic: str, message: Dict, + msg_type: PluginMessageType = PluginMessageType.PLUGIN_DATA): + """发布消息到指定插件""" + try: + message_data = { + "target_plugin": target_plugin, + "topic": topic, + "type": msg_type.value, + "data": message, + "timestamp": asyncio.get_event_loop().time() + } + + await self.plugin_message_queue.put(message_data) + logger.debug(f"发布消息到插件 {target_plugin}, 主题: {topic}") + + except Exception as e: + logger.error(f"发布消息到插件时出错: {str(e)}", exc_info=True) + raise + + async def broadcast_to_plugins(self, topic: str, message: Dict, + exclude_plugins: List[str] = None, + msg_type: PluginMessageType = PluginMessageType.PLUGIN_DATA): + """广播消息到所有插件""" + try: + exclude_plugins = exclude_plugins or [] + + for plugin_name in self.plugin_subscribers.keys(): + if plugin_name not in exclude_plugins: + await self.publish_to_plugin(plugin_name, topic, message, msg_type) + + logger.debug(f"广播消息到插件, 主题: {topic}, 排除: {exclude_plugins}") + + except Exception as e: + logger.error(f"广播消息到插件时出错: {str(e)}", exc_info=True) + raise + + async def _process_plugin_messages(self): + """处理插件消息队列""" + try: + logger.debug("开始处理插件消息队列") + + while self.is_running: + try: + # 等待消息,带超时 + message = await asyncio.wait_for(self.plugin_message_queue.get(), timeout=1.0) + + # 分发消息给目标插件 + await self._dispatch_plugin_message(message) + + # 标记任务完成 + self.plugin_message_queue.task_done() + + except asyncio.TimeoutError: + continue + except Exception as e: + logger.error(f"处理插件消息时出错: {str(e)}", exc_info=True) + continue + + logger.debug("插件消息队列处理结束") + + except Exception as e: + logger.error(f"插件消息队列处理循环出错: {str(e)}", exc_info=True) + + async def _dispatch_plugin_message(self, message: Dict): + """分发消息给插件订阅者""" + try: + target_plugin = message["target_plugin"] + topic = message["topic"] + + if (target_plugin not in self.plugin_subscribers or + topic not in self.plugin_subscribers[target_plugin]): + logger.debug(f"插件 {target_plugin} 没有订阅主题 {topic}") + return + + subscribers = self.plugin_subscribers[target_plugin][topic][:] + + # 并行调用所有订阅者 + tasks = [] + for callback in subscribers: + task = asyncio.create_task(self._call_plugin_subscriber(callback, message)) + tasks.append(task) + + if tasks: + await asyncio.gather(*tasks, return_exceptions=True) + + logger.debug(f"插件消息分发完成,目标: {target_plugin}, 主题: {topic}, 订阅者数: {len(subscribers)}") + + except Exception as e: + logger.error(f"分发插件消息时出错: {str(e)}", exc_info=True) + + async def _call_plugin_subscriber(self, callback: Callable, message: Dict): + """调用插件订阅者回调""" + try: + if asyncio.iscoroutinefunction(callback): + await callback(message) + else: + callback(message) + + except Exception as e: + logger.error(f"调用插件订阅者回调时出错: {str(e)}", exc_info=True) + + async def _handle_core_plugin_message(self, message: Dict): + """处理来自核心桥接的插件相关消息""" + try: + topic = message["topic"] + data = message["data"] + + # 根据主题类型处理 + if topic.startswith("plugin.event."): + # 广播插件事件 + event_type = topic.replace("plugin.event.", "") + await self.broadcast_to_plugins( + f"event.{event_type}", + data, + msg_type=PluginMessageType.PLUGIN_EVENT + ) + + elif topic.startswith("plugin.broadcast."): + # 广播消息 + broadcast_topic = topic.replace("plugin.broadcast.", "") + await self.broadcast_to_plugins( + broadcast_topic, + data, + msg_type=PluginMessageType.PLUGIN_DATA + ) + + logger.debug(f"处理核心插件消息: {topic}") + + except Exception as e: + logger.error(f"处理核心插件消息时出错: {str(e)}", exc_info=True) + + def get_plugin_subscriber_count(self, plugin_name: str = None) -> int: + """获取插件订阅者数量""" + try: + if plugin_name: + if plugin_name not in self.plugin_subscribers: + return 0 + total = sum(len(subs) for subs in self.plugin_subscribers[plugin_name].values()) + logger.debug(f"插件 {plugin_name} 的订阅者数量: {total}") + return total + else: + total = 0 + for plugin_subs in self.plugin_subscribers.values(): + total += sum(len(subs) for subs in plugin_subs.values()) + logger.debug(f"总插件订阅者数量: {total}") + return total + except Exception as e: + logger.error(f"获取插件订阅者数量时出错: {str(e)}", exc_info=True) + return 0 + + def cleanup_plugin_subscriptions(self, plugin_name: str): + """清理插件的所有订阅""" + try: + if plugin_name in self.plugin_subscribers: + del self.plugin_subscribers[plugin_name] + logger.debug(f"清理插件订阅: {plugin_name}") + except Exception as e: + logger.error(f"清理插件订阅时出错: {str(e)}", exc_info=True) + + async def shutdown(self): + """关闭插件桥接服务""" + try: + logger.info("关闭插件桥接服务") + self.is_running = False + + # 等待处理任务结束 + if self.processing_task: + await asyncio.wait_for(self.processing_task, timeout=5.0) + + # 清理所有订阅 + self.plugin_subscribers.clear() + + # 清空队列 + while not self.plugin_message_queue.empty(): + try: + self.plugin_message_queue.get_nowait() + self.plugin_message_queue.task_done() + except asyncio.QueueEmpty: + break + + logger.debug("插件桥接服务关闭完成") + + except Exception as e: + logger.error(f"关闭插件桥接服务时出错: {str(e)}", exc_info=True) diff --git a/bridges/plugin_network_bridge.py b/bridges/plugin_network_bridge.py new file mode 100644 index 0000000..f84577c --- /dev/null +++ b/bridges/plugin_network_bridge.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import logging +import asyncio +import inspect +from typing import Dict, List, Callable, Any +import json + +logger = logging.getLogger(__name__) + +class PluginNetworkBridge: + """插件网络桥接 - 简化插件的网络交互""" + + def __init__(self, plugin_name: str, internet_service, plugin_bridge): + self.plugin_name = plugin_name + self.internet_service = internet_service + self.plugin_bridge = plugin_bridge + self.registered_routes: List[Dict] = [] + self.websocket_handlers: List[Dict] = [] + + logger.debug(f"插件网络桥接初始化: {plugin_name}") + + def is_network_available(self): + """检查网络服务是否可用""" + return self.internet_service is not None and hasattr(self.internet_service, 'register_plugin_route') + + async def register_command_routes(self, plugin_instance, require_auth: bool = True): + """自动扫描插件 @plugin_command/cmd_* 方法并注册 REST 端点 + + 每个命令 → POST /api/plugin/{cmd_name} + 参数以 JSON body 传入: {"args": [...]} + 返回: {"ok": true, "result": "..."} 或 {"ok": false, "error": "..."} + """ + try: + import inspect + + registered = 0 + for attr_name in dir(plugin_instance): + if attr_name.startswith("__"): + continue + + method = getattr(plugin_instance, attr_name, None) + if not callable(method): + continue + + # 识别 @plugin_command 装饰或 cmd_ 前缀 + cmd_name = None + if hasattr(method, "_is_plugin_command"): + cmd_name = getattr(method, "_command_name", attr_name[4:] if attr_name.startswith("cmd_") else attr_name) + elif attr_name.startswith("cmd_"): + cmd_name = attr_name[4:] + + if not cmd_name: + continue + + route_path = f"/api/plugin/{cmd_name}" + + # 创建闭包捕获 method 和 cmd_name + async def _make_handler(_method=method, _cmd_name=cmd_name): + from aiohttp import web + + async def _handler(request): + try: + body = {} + try: + body = await request.json() + except Exception: + pass + args = body.get("args", []) + if isinstance(args, str): + args = [args] + kwargs = body.get("kwargs", {}) + result = _method(*args, **kwargs) + if asyncio.iscoroutine(result): + result = await result + return web.json_response({"ok": True, "result": str(result)}) + except Exception as e: + logger.error(f"命令 {_cmd_name} REST 调用失败: {e}") + return web.json_response({"ok": False, "error": str(e)}, status=500) + + return _handler + + await self.register_http_route( + route_path, await _make_handler(), + methods=["POST"], require_auth=require_auth, + ) + logger.debug(f" 自动暴露 REST: POST {route_path}") + registered += 1 + + if registered: + logger.info(f"插件 {self.plugin_name} 自动暴露 {registered} 个命令 REST 端点") + + except Exception as e: + logger.warning(f"自动注册命令路由失败 {self.plugin_name}: {e}") + + async def register_http_route(self, route_path: str, handler: Callable, + methods: List[str] = ["GET"], require_auth: bool = True): + """注册HTTP路由""" + try: + if not self.is_network_available(): + logger.warning("网络服务不可用,跳过HTTP路由注册") + return + + await self.internet_service.register_plugin_route( + self.plugin_name, route_path, handler, methods, require_auth + ) + + self.registered_routes.append({ + 'type': 'http', + 'path': route_path, + 'methods': methods, + 'require_auth': require_auth + }) + + logger.debug(f"插件 {self.plugin_name} 注册HTTP路由: {route_path}") + + except Exception as e: + logger.error(f"注册HTTP路由时出错: {str(e)}") + # 不抛出异常,让插件继续运行 + + async def register_websocket(self, ws_path: str, handler: Callable, require_auth: bool = True): + """注册WebSocket处理器""" + try: + if not self.is_network_available(): + logger.warning("网络服务不可用,跳过WebSocket注册") + return + + await self.internet_service.register_plugin_websocket( + self.plugin_name, ws_path, handler, require_auth + ) + + self.websocket_handlers.append({ + 'path': ws_path, + 'require_auth': require_auth + }) + + logger.debug(f"插件 {self.plugin_name} 注册WebSocket: {ws_path}") + + except Exception as e: + logger.error(f"注册WebSocket时出错: {str(e)}") + # 不抛出异常,让插件继续运行 + + async def broadcast_websocket(self, message: Dict): + """向插件的所有WebSocket连接广播消息""" + try: + if not self.internet_service: + logger.warning("网络服务不可用,无法广播消息") + return + + await self.internet_service.broadcast_to_websockets(self.plugin_name, message) + logger.debug(f"插件 {self.plugin_name} WebSocket广播: {len(message)} 字节") + except Exception as e: + logger.error(f"WebSocket广播时出错: {str(e)}") + + async def send_data_to_client(self, client_id: str, message: Dict): + """向特定客户端发送数据""" + try: + # 这里可以实现更精确的客户端消息发送 + # 目前先使用广播 + message['target_client'] = client_id + await self.broadcast_websocket(message) + + except Exception as e: + logger.error(f"发送数据到客户端时出错: {str(e)}") + + def get_network_info(self) -> Dict[str, Any]: + """获取网络配置信息""" + if not self.internet_service: + return { + 'plugin_name': self.plugin_name, + 'registered_routes': [], + 'websocket_handlers': [], + 'base_url': '网络服务不可用' + } + + return { + 'plugin_name': self.plugin_name, + 'registered_routes': self.registered_routes, + 'websocket_handlers': self.websocket_handlers, + 'base_url': f"http://{self.internet_service.http_host}:{self.internet_service.http_port}/{self.plugin_name}" + } + + async def setup_data_transfer(self, data_handler: Callable): + """设置跨端数据传输""" + try: + # 订阅网络数据接收事件 + self.plugin_bridge.subscribe_plugin( + self.plugin_name, + "network.data.receive", + data_handler + ) + + logger.debug(f"插件 {self.plugin_name} 设置跨端数据传输") + + except Exception as e: + logger.error(f"设置数据传输时出错: {str(e)}") diff --git a/config/framework/base_config.yaml.example b/config/framework/base_config.yaml.example new file mode 100644 index 0000000..7a92c0f --- /dev/null +++ b/config/framework/base_config.yaml.example @@ -0,0 +1,53 @@ +framework: + debug: true + name: SenSu + version: v0.8.0 + branch: dev + repo_url: https://git.yeij.top/AskaEth/SenSu +logging: + debug_level_file: true + level: DEBUG + max_file_size: 10MB + max_log_files: 20 +plugins: + auto_load: true + hot_reload: true + max_retry_count: 3 + isolation: false +# 自动启动脚本 — 框架启动时后台拉起 +auto_start_scripts: + enabled: true + scripts: [] + # 示例: + # - name: cyrene_debug + # path: ~/cyrene_debug_server.py + # enabled: true + # args: [] + # cwd: ~ +# API Keys — 服务器间通信凭证 (在 WebUI 设置页管理) +# api_keys: [] +# TUI配置 +tui: + enabled: true + refresh_rate: 30 + layout: + grid_rows: "4fr 5fr 1fr" + styles: + log_area: "border: solid green; overflow-y: auto;" + message_area: "border: solid yellow; overflow-y: auto;" + input_area: "border: solid red;" + log_display: + max_lines: 200 +# 互联网服务配置 +internet: + websocket: + host: "0.0.0.0" + port: 4240 + http: + host: "0.0.0.0" + port: 4200 +panel: + entrance: + path: "/SenSu" + username: "admin" + password: "CHANGE_ME" # 首次启动请修改! diff --git a/debug_log_format.py b/debug_log_format.py new file mode 100644 index 0000000..051aff7 --- /dev/null +++ b/debug_log_format.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import sys +import asyncio +from pathlib import Path + +# 添加项目根目录到Python路径 +project_root = Path(__file__).parent +sys.path.insert(0, str(project_root)) + +async def debug_log_format(): + """调试日志格式""" + try: + from services.log_service import LogService + from services.init_service import InitService + + print("🐱 调试日志格式...") + + # 初始化配置 + init_service = InitService() + configs = await init_service.initialize_framework() + base_config = configs['base'] + + # 创建日志服务 + log_service = LogService(base_config) + + # 等待日志服务初始化 + await asyncio.sleep(1) + + # 添加测试消费者 + def test_consumer(log_record): + print("📝 消费者收到的日志记录:") + print(f" 所有键: {list(log_record.keys())}") + if 'simple_message' in log_record: + print(f" simple_message: {log_record['simple_message']}") + if 'formatted_message' in log_record: + print(f" formatted_message: {log_record['formatted_message']}") + if 'level' in log_record: + print(f" level: {log_record['level']}") + print("---") + + log_service.add_log_consumer(test_consumer) + + # 测试日志记录 + import logging + logger = logging.getLogger("test_logger") + print("\n🔧 发送测试日志...") + logger.info("这是一条测试信息日志") + logger.debug("这是一条测试调试日志") + logger.warning("这是一条测试警告日志") + logger.error("这是一条测试错误日志") + + # 等待日志处理完成 + await asyncio.sleep(0.5) + + except Exception as e: + print(f"❌ 调试失败: {e}") + import traceback + traceback.print_exc() + +if __name__ == "__main__": + asyncio.run(debug_log_format()) diff --git a/deploy/Dockerfile b/deploy/Dockerfile new file mode 100644 index 0000000..d07f217 --- /dev/null +++ b/deploy/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.12-alpine +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +RUN mkdir -p data logs +EXPOSE 4200 4240 +ENV SENSU_ADMIN_PASSWORD=changeme +ENV SENSU_API_PASSWORD=changeme +CMD ["python3", "main.py", "--headless"] diff --git a/deploy/sensu.service b/deploy/sensu.service new file mode 100644 index 0000000..c7a8399 --- /dev/null +++ b/deploy/sensu.service @@ -0,0 +1,18 @@ +[Unit] +Description=SenSu Plugin Framework +After=network.target + +[Service] +Type=simple +User=aska +WorkingDirectory=/data/data/com.termux/files/home/Proj/SenSu_workspace +ExecStart=/data/data/com.termux/files/usr/bin/python3 /data/data/com.termux/files/home/Proj/SenSu_workspace/start.py --headless +Restart=on-failure +RestartSec=5 +Environment=SENSU_ADMIN_PASSWORD=changeme +Environment=SENSU_API_PASSWORD=changeme +Environment=SENSU_PANEL_USER=admin +Environment=SENSU_PANEL_PASS=changeme + +[Install] +WantedBy=multi-user.target diff --git a/docs/API_Auth_Flow.md b/docs/API_Auth_Flow.md new file mode 100644 index 0000000..38c6384 --- /dev/null +++ b/docs/API_Auth_Flow.md @@ -0,0 +1,314 @@ +# SenSu API 认证与数据访问流程 + +> 版本: v0.7.0+ +> 更新: 2026-06-13 + +--- + +## 一、概述 + +所有 API 端点(除 `/health` 和 `/SenSu/api/login`)均需要认证。认证基于 **Panel Session Token** 机制: + +- 登录后获取 32 字符随机 token +- 后续请求通过 Cookie (`panel_token`) 或 WebSocket URL 参数 (`?token=`) 传递 +- 写操作额外需要 `X-CSRF-Token` 请求头 + +--- + +## 二、登录流程 + +``` +POST /SenSu/api/login +Content-Type: application/json + +{"username": "admin", "password": "admin"} +``` + +### 处理步骤 + +| 步骤 | 说明 | 代码位置 | +|------|------|---------| +| 1 | **频率检查** — IP 是否因 5 次失败被锁定 60s | `auth.py:_check_rate_limit()` | +| 2 | **哈希比较** — `SHA256(password + salt) == stored_hash` | `manager.py:_hash_pw()` | +| 3 | **生成 token** — `secrets.token_hex(16)` → 32 字符 | `auth.py:handle_login()` | +| 4 | **存入内存** — `PANEL_SESSION_STORE[token] = {username, perms, login_time}` | 同上 | +| 5 | **持久化到 DB** — `sensu_db.config_kv['panel_sessions']` JSON | `auth.py:_save_sessions_to_db()` | +| 6 | **设置 Cookie** — `panel_token={token}; HttpOnly; Max-Age=259200; SameSite=Lax` | 同上 | + +### 响应 + +```json +{"success": true, "username": "admin"} +``` + +### 安全特性 + +- 密码使用 `secrets.compare_digest()` 时序安全比较 +- 每用户独立随机盐 `secrets.token_hex(16)` +- 5 次/IP 失败后锁定 60 秒,返回 `429 Too Many Requests` +- Cookie 设置 `HttpOnly` (JS 不可读) + `SameSite=Lax` + +--- + +## 三、HTTP API 请求(读操作) + +``` +GET /SenSu/api/files/list +Cookie: panel_token=f93f18546742b6c4... +``` + +### 鉴权流程 (`panel_auth()`) + +``` +request + │ + ├─ 1. 从 Cookie 取 panel_token + │ (或 Authorization: Bearer xxx header) + │ + ├─ 2. 查 PANEL_SESSION_STORE[token] + │ ├─ 找到 → 注入 request['user'] + │ └─ 未找到 → 401 {"error": "未认证或会话已过期"} + │ + └─ 3. handler(request) → JSON 响应 +``` + +### 受保护的读端点 + +| 端点 | 说明 | +|------|------| +| `GET /SenSu/api/files/list` | 文件列表 | +| `GET /SenSu/api/files/info` | 文件信息 | +| `GET /SenSu/api/files/read` | 读取文本 | +| `GET /SenSu/api/files/download` | 下载文件 | +| `GET /SenSu/api/files/picker` | 文件选择器 | +| `GET /SenSu/api/projects` | 项目列表 | +| `GET /SenSu/api/projects/{name}/logs` | 项目日志 | +| `GET /SenSu/api/proxy` | 代理列表 | +| `GET /SenSu/api/system` | 系统状态 | +| `GET /SenSu/api/framework` | 框架状态 | +| `GET /SenSu/api/plugins` | 插件列表 | +| `GET /SenSu/api/commands` | 命令列表 | +| `GET /SenSu/api/auth/status` | 认证状态 | +| `GET /plugin/{name}` | 插件页面 | +| `GET /plugin/{name}/sse` | 插件 SSE | + +--- + +## 四、HTTP API 请求(写操作 — CSRF 保护) + +``` +POST /SenSu/api/files/delete +Cookie: panel_token=f93f18546742b6c4... +X-CSRF-Token: f93f18546742b6c4... +Content-Type: application/json + +{"path": "/some/file"} +``` + +### 鉴权流程 (`panel_auth(csrf_protect=True)`) + +``` +request + │ + ├─ 1-2. 同上 (token 验证) + │ + ├─ 3. CSRF 检查 + │ X-CSRF-Token header == panel_token cookie ? + │ ├─ 匹配 → 继续 + │ └─ 不匹配 → 403 {"error": "CSRF 验证失败"} + │ + └─ 4. handler(request) → JSON 响应 +``` + +### CSRF 保护的写端点 + +| 端点 | 说明 | +|------|------| +| `POST /SenSu/api/files/delete` | 删除文件/目录 | +| `POST /SenSu/api/files/write` | 写入文件 | +| `POST /SenSu/api/files/mkdir` | 创建目录 | +| `POST /SenSu/api/files/touch` | 创建文件 | +| `POST /SenSu/api/files/rename` | 重命名 | +| `POST /SenSu/api/files/upload` | 上传文件 | +| `POST /SenSu/api/projects/run` | 启动项目 | +| `POST /SenSu/api/projects/{name}/stop` | 停止项目 | +| `POST /SenSu/api/projects/{name}/stdin` | 向项目发送输入 | +| `POST /SenSu/api/proxy` | 添加代理 | +| `DELETE /SenSu/api/proxy/{path}` | 删除代理 | + +--- + +## 五、WebSocket 连接 + +``` +GET /SenSu/api/system/ws?token=f93f18546742b6c4... +Upgrade: websocket +``` + +### 鉴权流程 (`_ws_auth_wrapper`) + +``` +WebSocket 握手请求 + │ + ├─ 1. 从 query string 取 token + │ + ├─ 2. 查 PANEL_SESSION_STORE[token] + │ ├─ 找到 → 建立 WS 连接 → 每 2s 推送 + │ └─ 未找到 → {"error":"Unauthorized"} → close(4001) + │ + └─ 3. 连接建立后持续推送 + {"type":"sys", "system":{cpu,memory,network}, "framework":{...}} +``` + +### 前端调用方式 + +```javascript +// app.js 提供的工具函数 +function getCookie(name) { + var match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)')); + return match ? match[2] : ''; +} + +// 仪表盘 WebSocket +var tok = getCookie("panel_token"); +var ws = new WebSocket("ws://" + location.host + base + "/api/system/ws?token=" + (tok || "")); + +// 日志 WebSocket +var ws = new WebSocket("ws://" + location.host + base + "/api/logs/ws?token=" + (tok || "")); +``` + +### WebSocket 端点 + +| 端点 | 鉴权方式 | 用途 | +|------|---------|------| +| `/SenSu/api/system/ws?token=` | query param | 系统监控实时推送 | +| `/SenSu/api/logs/ws` | Cookie (同源自动带) | 日志实时推送 | + +--- + +## 六、插件路由(双层鉴权) + +插件通过 `PluginNetworkBridge.register_http_route()` 注册的路由使用双层鉴权: + +``` +GET /example_plugin/api/example/info +Cookie: panel_token=f93f18546742b6c4... +``` + +### 鉴权流程 (`_check_plugin_auth()`) + +``` +request + │ + ├─ 第1层: 用户身份验证 + │ ├─ 从 Cookie 取 panel_token + │ ├─ (或 Authorization: Bearer xxx header) + │ ├─ 查 PANEL_SESSION_STORE + │ └─ 失败 → 403 {"reason": "未认证"} + │ + ├─ 第2层: 插件权限检查 + │ ├─ 查 permission_service + │ ├─ 插件是否有 plugin.network.access ? + │ └─ 失败 → 403 {"reason": "插件没有网络访问权限"} + │ + └─ handler(request) → 响应 +``` + +### 插件命令 REST 端点(自动暴露) + +``` +POST /example_plugin/api/plugin/echo +Content-Type: application/json + +{"args": ["hello", "world"]} +``` + +每个 `@plugin_command` 方法自动生成 REST 端点: +- 路径: `POST /api/plugin/{command_name}` +- 请求体: `{"args": [...], "kwargs": {...}}` +- 响应: `{"ok": true, "result": "..."}` + +--- + +## 七、公开端点(无需认证) + +| 端点 | 说明 | 安全措施 | +|------|------|---------| +| `GET /health` | 健康检查 | 仅返回 `{"status":"healthy"}` | +| `POST /SenSu/api/login` | 登录 | 频率限制 + 哈希比较 | + +--- + + +## 十一、API Key 认证 (服务器间通信) + +### 创建 API Key +在 WebUI "框架设置" → "API Key 管理" 中创建,支持三种权限模板: +| 模板 | 权限 | +|------|------| +| `readonly` | framework.status.read, plugin.info.read | +| `monitor` | + framework.event.subscribe | +| `full` | admin (全部) | + +每个 Key 可选 TTL 过期时间,格式 `sk-` + 48 hex chars。 + +### 使用方式 +``` +GET /SenSu/api/system +Authorization: Bearer sk-xxxxxxxx... +``` + +### 验证流程 +``` +panel_auth() 拦截 + ├─ Cookie panel_token → Session Store + ├─ 失败 → API Key Store (validate_api_key) + ├─ 检查过期时间 + └─ 注入 key 自身权限范围 +``` + +## 八、安全响应头 + +所有响应自动添加: + +| Header | 值 | +|--------|-----| +| `X-Content-Type-Options` | `nosniff` | +| `X-Frame-Options` | `DENY` | +| `X-XSS-Protection` | `1; mode=block` | +| `Referrer-Policy` | `strict-origin-when-cross-origin` | + +--- + +## 九、错误响应格式 + +### 认证失败 +```json +{"error": "未认证或会话已过期", "status": 401} +``` + +### CSRF 失败 +```json +{"error": "CSRF 验证失败", "status": 403} +``` + +### 频率限制 +```json +{"success": false, "msg": "尝试次数过多,请 60 秒后重试"} +``` +HTTP 状态码: `429` + +### 服务器错误(脱敏) +```json +{"error": "Internal server error"} +``` +详细信息仅写入日志,不返回客户端。 + +--- + +## 十、会话持久化 + +- 登录时 session 写入 `sensu.db` → `config_kv` 表 +- 框架重启时从 DB 恢复: `auth.py:_load_sessions_from_db()` +- 登出时从 DB 清除 +- Token 有效期: Cookie `max-age=259200` (3 天) diff --git a/docs/SenSu 插件开发详细指南.md b/docs/SenSu 插件开发详细指南.md new file mode 100644 index 0000000..fe5eddc --- /dev/null +++ b/docs/SenSu 插件开发详细指南.md @@ -0,0 +1,5343 @@ +# SenSu 插件开发超详细指南 + +# 目录 + +- [SenSu 插件开发超详细指南](#sensu-插件开发超详细指南) + - [一、插件系统架构深度解析](#一插件系统架构深度解析) + - [二、插件开发完整方案](#二插件开发完整方案) + - [2.6 插件 WebUI 页面](#26-插件-webui-页面-v060) + - [三、插件生命周期管理](#三插件生命周期管理) + - [四、插件开发最佳实践](#四插件开发最佳实践) + - [4.1 错误处理最佳实践](#41-错误处理最佳实践) + - [4.2 性能优化最佳实践](#42-性能优化最佳实践) + - [4.3 安全最佳实践](#43-安全最佳实践) + - [4.4 测试最佳实践](#44-测试最佳实践) + - [五、插件发布与部署](#五插件发布与部署) + - [5.1 插件打包](#51-插件打包) + - [5.2 插件发布清单](#52-插件发布清单) + - [5.3 持续集成配置](#53-持续集成配置) + - [六、插件调试与故障排除](#六插件调试与故障排除) + - [6.1 调试工具](#61-调试工具) + - [6.2 故障排除指南](#62-故障排除指南) +- [在插件配置中](#在插件配置中) +- [查找插件相关日志](#查找插件相关日志) +- [实时查看日志](#实时查看日志) + - [七、插件开发检查清单](#七插件开发检查清单) + - [7.1 开发前检查清单](#71-开发前检查清单) + - [7.2 开发中检查清单](#72-开发中检查清单) + - [7.3 测试检查清单](#73-测试检查清单) + - [7.4 发布检查清单](#74-发布检查清单) + - [八、文件管理器集成 (v0.6.0)](#八文件管理器集成-v060-新增) + - [九、总结](#九总结) + - [8.1 成功插件的特点](#81-成功插件的特点) + - [8.2 持续改进](#82-持续改进) + - [8.3 资源推荐](#83-资源推荐) + +--- + + +## 一、插件系统架构深度解析 + +### 1.1 插件生命周期 + +``` +┌───────────────┐ ┌───────────────┐ ┌───────────────┐ +│ 扫描插件 │────▶│ 权限申请 │────▶│ 实例化插件 │ +└───────────────┘ └───────────────┘ └───────────────┘ + │ │ │ + ▼ ▼ ▼ +┌───────────────┐ ┌───────────────┐ ┌───────────────┐ +│ 加载配置文件 │ │ 权限验证/用户 │ │ 注册命令/路由 │ +└───────────────┘ └───────────────┘ └───────────────┘ + │ │ │ + └──────────────────────┴──────────────────────┘ + │ + ▼ + ┌───────────────────┐ + │ 插件就绪运行 │ + └───────────────────┘ +``` + +### 1.2 插件通信架构 + +```mermaid +graph TB + subgraph "插件内部" + A[插件主类] --> B[命令处理器] + A --> C[网络处理器] + A --> D[事件处理器] + end + + subgraph "框架服务" + E[PluginBridge] --> F[CoreBridge] + F --> G[网络服务] + F --> H[命令服务] + F --> I[权限服务] + end + + B --> H + C --> G + D --> E + + subgraph "外部接口" + J[HTTP客户端] --> G + K[WebSocket客户端] --> G + L[TUI用户] --> H + end +``` + +## 二、插件开发完整方案 + +### 2.1 环境准备 + +```bash +# 1. 克隆或下载框架 +git clone +cd SenSu-Alpha0.2 + +# 2. 安装依赖(建议使用虚拟环境) +python -m venv venv +source venv/bin/activate # Linux/Mac +# venv\Scripts\activate # Windows + +pip install -r requirements.txt + +# 3. 运行框架测试 +python main.py +``` + +### 2.2 创建新插件 + +#### 2.2.1 插件目录结构 + +``` +plugins/ +└── my_awesome_plugin/ # 插件目录(建议使用小写和下划线) + ├── __init__.py # 插件主模块(必需) + ├── config.yaml # 插件配置文件(必需) + ├── permissions.yaml # 权限申请文件(必需) + ├── requirements.txt # 插件特定依赖(可选) + ├── README.md # 插件说明文档(推荐) + ├── utils/ # 插件内部工具(可选) + │ ├── __init__.py + │ └── helper.py + ├── models/ # 数据模型(可选) + │ └── data_model.py + ├── services/ # 插件服务模块(可选) + │ └── background_service.py + └── static/ # 静态资源(可选) + ├── css/ + ├── js/ + └── images/ +``` + +#### 2.2.2 插件命名规范 + +1. **目录名**:小写字母、数字、下划线,如 `my_plugin` +2. **插件类名**:`Plugin`(必须使用这个类名) +3. **命令名**:小写字母、数字、下划线,如 `my_command` +4. **权限名**:`plugin.<插件名>.<操作>`,如 `plugin.my_plugin.read` + +### 2.3 配置文件详解 + +#### 2.3.1 config.yaml 完整示例 + +```yaml +# my_awesome_plugin/config.yaml + +# ========== 基础信息(必需)========== +name: "PluginName" # 插件显示名称 +version: "1.0.0" # 版本号(遵循语义化版本) +description: "这是一个功能强大的示例插件,用于演示插件开发" +author: "开发者名字 " +license: "MIT" # 开源许可证 + +# ========== 插件配置 ========== +settings: + enabled: true # 是否启用 + auto_start: true # 是否自动启动 + isolation: false # 进程隔离: true=独立子进程(无WebUI), false=框架内运行(支持WebUI/网络路由) + log_level: "INFO" # 日志级别:DEBUG, INFO, WARNING, ERROR + max_retry_count: 3 # 失败重试次数 + health_check_interval: 60 # 健康检查间隔(秒) + background_task_interval: 300 # 后台任务间隔(秒) + +# ========== 功能配置 ========== +features: + # 网络功能配置 + network: + enable_http: true # 启用HTTP接口 + enable_websocket: true # 启用WebSocket + enable_cors: true # 启用跨域支持 + cors_origins: ["*"] # 允许的跨域来源 + + # 数据库配置(如果有) + database: + type: "sqlite" # sqlite, mysql, postgresql + path: "data/my_plugin.db" # SQLite数据库路径 + host: "localhost" # 数据库主机 + port: 3306 # 数据库端口 + name: "my_plugin_db" # 数据库名 + user: "username" # 用户名 + password: "password" # 密码(建议使用环境变量) + + # 缓存配置 + cache: + type: "memory" # memory, redis + ttl: 3600 # 缓存时间(秒) + max_size: 1000 # 最大缓存项数 + + # 安全配置 + security: + require_auth: true # 是否需要认证 + token_expiry: 86400 # Token过期时间(秒) + rate_limit: 100 # 每秒请求限制 + blacklist_enabled: true # 启用黑名单 + +# ========== 业务配置 ========== +business: + # API配置 + api: + default_page_size: 20 # 默认分页大小 + max_page_size: 100 # 最大分页大小 + date_format: "%Y-%m-%d %H:%M:%S" # 日期格式 + + # 文件存储 + storage: + type: "local" # local, s3, minio + path: "data/files" # 本地存储路径 + max_file_size: 10485760 # 最大文件大小(10MB) + allowed_extensions: # 允许的文件扩展名 + - .txt + - .json + - .yaml + - .csv + + # 通知配置 + notification: + email_enabled: false + webhook_enabled: true + webhook_url: "" + +# ========== 定时任务配置 ========== +schedules: + - name: "daily_cleanup" + cron: "0 2 * * *" # 每天凌晨2点 + task: "cleanup_old_data" + enabled: true + + - name: "hourly_sync" + cron: "0 * * * *" # 每小时 + task: "sync_external_data" + enabled: true + +# ========== 依赖配置 ========== +dependencies: + required: # 必需依赖 + - requests>=2.25.0 + - pydantic>=1.8.0 + + optional: # 可选依赖 + - redis>=3.5.0 # 如果使用Redis缓存 + - aiomysql>=0.1.0 # 如果使用MySQL + + system: # 系统依赖 + - ffmpeg # 如果处理音视频 + - imagemagick # 如果处理图片 + +# ========== 国际化配置 ========== +i18n: + default_language: "zh_CN" + supported_languages: + - zh_CN + - en_US + translation_files: "translations/" + +# ========== 调试配置 ========== +debug: + enable_debug_endpoints: false # 是否启用调试端点 + log_requests: true # 是否记录请求日志 + log_responses: false # 是否记录响应日志 + profile_performance: false # 是否启用性能分析 +``` + +#### 2.3.2 配置加载和验证 + +```python +# 在插件中加载和验证配置 +from pydantic import BaseModel, validator +from typing import Optional, List +import os + +class PluginConfig(BaseModel): + """插件配置模型""" + name: str + version: str + description: str + author: str + settings: dict + features: dict + + @validator('name') + def validate_name(cls, v): + if len(v) < 2 or len(v) > 50: + raise ValueError('插件名称长度必须在2-50字符之间') + return v + + @validator('version') + def validate_version(cls, v): + import re + if not re.match(r'^\d+\.\d+\.\d+$', v): + raise ValueError('版本号格式必须为 X.Y.Z') + return v + +# 使用示例 +config_data = { ... } # 从config.yaml加载 +validated_config = PluginConfig(**config_data) +``` + +### 2.4 权限文件详解 + +#### 2.4.1 permissions.yaml 完整示例 + +```yaml +# my_awesome_plugin/permissions.yaml + +# ========== 基础信息 ========== +plugin_name: "my_awesome_plugin" # 必须与目录名一致 +plugin_version: "1.0.0" + +# ========== 权限申请列表 ========== +permissions: + # 框架基础权限 + - "framework.status.read" # 读取框架状态 + - "framework.event.subscribe" # 订阅框架事件 + - "framework.command.execute" # 执行框架命令 + + # 插件自身权限 + - "plugin.my_awesome_plugin.read" # 读取插件数据 + - "plugin.my_awesome_plugin.write" # 写入插件数据 + - "plugin.my_awesome_plugin.execute" # 执行插件操作 + - "plugin.my_awesome_plugin.delete" # 删除插件数据 + + # 网络权限 + - "plugin.my_awesome_plugin.network.access" # 访问网络 + - "plugin.my_awesome_plugin.network.http" # HTTP服务 + - "plugin.my_awesome_plugin.network.websocket" # WebSocket服务 + + # 文件系统权限 + - "plugin.my_awesome_plugin.filesystem.read" # 读取文件 + - "plugin.my_awesome_plugin.filesystem.write" # 写入文件 + + # 外部服务权限 + - "plugin.my_awesome_plugin.external_api.access" # 访问外部API + + # 系统权限(谨慎申请) + - "plugin.my_awesome_plugin.system.execute" # 执行系统命令 + + # 管理权限 + - "plugin.my_awesome_plugin.admin" # 插件管理员权限 + +# ========== 权限分组说明 ========== +permission_groups: + basic: # 基础组 + - "plugin.my_awesome_plugin.read" + - "plugin.my_awesome_plugin.write" + + network: # 网络组 + - "plugin.my_awesome_plugin.network.access" + - "plugin.my_awesome_plugin.network.http" + - "plugin.my_awesome_plugin.network.websocket" + + advanced: # 高级组(需要特别说明) + - "plugin.my_awesome_plugin.system.execute" + - "plugin.my_awesome_plugin.admin" + +# ========== 权限详细说明 ========== +permission_descriptions: + # 基础权限说明 + framework.status.read: "读取框架运行状态和基本信息" + framework.event.subscribe: "订阅框架事件通知" + framework.command.execute: "在框架中执行命令" + + # 插件权限说明 + plugin.my_awesome_plugin.read: "读取插件的配置和数据" + plugin.my_awesome_plugin.write: "修改插件的配置和数据" + plugin.my_awesome_plugin.execute: "执行插件提供的操作" + plugin.my_awesome_plugin.delete: "删除插件创建的数据" + + # 网络权限说明 + plugin.my_awesome_plugin.network.access: "允许插件访问网络服务" + plugin.my_awesome_plugin.network.http: "提供HTTP API接口" + plugin.my_awesome_plugin.network.websocket: "提供WebSocket实时通信" + + # 文件系统权限说明 + plugin.my_awesome_plugin.filesystem.read: "读取插件目录下的文件" + plugin.my_awesome_plugin.filesystem.write: "在插件目录下创建和修改文件" + + # 外部服务权限说明 + plugin.my_awesome_plugin.external_api.access: "访问第三方API服务(如天气、翻译等)" + + # 系统权限说明(危险权限) + plugin.my_awesome_plugin.system.execute: "⚠️ 执行系统级命令(可能影响系统安全)" + plugin.my_awesome_plugin.admin: "⚡ 插件管理员权限,可执行所有插件操作" + +# ========== 权限风险评估 ========== +permission_risk_levels: + low_risk: # 低风险权限 + - "framework.status.read" + - "plugin.my_awesome_plugin.read" + + medium_risk: # 中风险权限 + - "plugin.my_awesome_plugin.write" + - "plugin.my_awesome_plugin.network.access" + + high_risk: # 高风险权限 + - "plugin.my_awesome_plugin.system.execute" + - "plugin.my_awesome_plugin.admin" + +# ========== 依赖权限说明 ========== +permission_dependencies: + # 某些权限需要其他权限的支持 + plugin.my_awesome_plugin.network.http: + requires: "plugin.my_awesome_plugin.network.access" + + plugin.my_awesome_plugin.network.websocket: + requires: "plugin.my_awesome_plugin.network.access" + + plugin.my_awesome_plugin.admin: + requires_all: # 需要所有以下权限 + - "plugin.my_awesome_plugin.read" + - "plugin.my_awesome_plugin.write" + - "plugin.my_awesome_plugin.execute" + - "plugin.my_awesome_plugin.delete" + +# ========== 权限使用场景示例 ========== +usage_scenarios: + - scenario: "数据查看" + required_permissions: + - "plugin.my_awesome_plugin.read" + description: "用户只能查看数据,不能修改" + + - scenario: "数据管理" + required_permissions: + - "plugin.my_awesome_plugin.read" + - "plugin.my_awesome_plugin.write" + - "plugin.my_awesome_plugin.delete" + description: "用户可以完全管理数据" + + - scenario: "API服务" + required_permissions: + - "plugin.my_awesome_plugin.network.access" + - "plugin.my_awesome_plugin.network.http" + description: "插件可以提供HTTP API服务" + + - scenario: "实时通信" + required_permissions: + - "plugin.my_awesome_plugin.network.access" + - "plugin.my_awesome_plugin.network.websocket" + description: "插件可以提供WebSocket实时通信" + +# ========== 插件启动模式 ========== +startup_modes: + # 权限不足时的启动模式 + fallback_mode: + enabled: true + permissions_required: # 必需的最小权限集 + - "framework.status.read" + - "plugin.my_awesome_plugin.read" + degraded_features: # 降级运行的功能 + - "network_services" + - "background_tasks" + message: "插件将在受限模式下运行,部分功能不可用" + +# ========== 权限版本控制 ========== +versioning: + current_version: "1.0" + deprecated_permissions: # 已废弃的权限 + - "plugin.my_awesome_plugin.old_read" + new_permissions: # 新增权限 + - "plugin.my_awesome_plugin.enhanced_write" + migration_guide: "从v0.9升级到v1.0,请重新申请权限" +``` + +#### 2.4.2 权限验证代码示例 + +```python +class PermissionValidator: + """权限验证辅助类""" + + @staticmethod + def validate_permission_structure(permissions: list) -> tuple[bool, str]: + """验证权限列表结构""" + if not permissions: + return False, "权限列表不能为空" + + for perm in permissions: + if not isinstance(perm, str): + return False, f"权限必须是字符串: {perm}" + + # 检查格式:plugin.plugin_name.action + if not perm.startswith("plugin.") and not perm.startswith("framework."): + return False, f"权限格式错误: {perm}" + + # 检查长度 + if len(perm) > 100: + return False, f"权限名称过长: {perm}" + + return True, "验证通过" + + @staticmethod + def group_permissions_by_risk(permissions: list) -> dict: + """按风险等级分组权限""" + risk_groups = { + "low": [], + "medium": [], + "high": [] + } + + risk_mapping = { + "read": "low", + "write": "medium", + "delete": "medium", + "execute": "high", + "admin": "high", + "system": "high" + } + + for perm in permissions: + risk = "medium" # 默认中风险 + + for keyword, level in risk_mapping.items(): + if keyword in perm.lower(): + risk = level + break + + risk_groups[risk].append(perm) + + return risk_groups +``` + +### 2.5 插件主类完整实现 + +#### 2.5.1 __init__.py 完整模板 + +```python +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +我的插件 - 插件主模块 +版本: 1.0.0 +作者: 开发者名字 +描述: 这是一个功能完整的插件示例 +""" + +import logging +import asyncio +import sys +import os +from pathlib import Path +from typing import Dict, Any, List, Optional, Union +from dataclasses import dataclass +from datetime import datetime, timedelta +import json +import traceback + +# 导入框架装饰器 +try: + from core.plugin_command_decorator import plugin_command, command +except ImportError: + # 回退方案 - 本地定义装饰器 + def plugin_command(name=None, description=None, permissions=None): + def decorator(func): + func._is_plugin_command = True + func._command_name = name or func.__name__ + func._command_description = description or func.__doc__ or f"命令: {func.__name__}" + func._command_permissions = permissions or [] + return func + return decorator + + command = plugin_command + +# 导入网络桥接 +try: + from bridges.plugin_network_bridge import PluginNetworkBridge +except ImportError: + # 虚拟网络桥接类 + class PluginNetworkBridge: + def __init__(self, plugin_name, internet_service, plugin_bridge): + self.plugin_name = plugin_name + self._logger = logging.getLogger(f"{__name__}.NetworkBridge") + self._logger.warning(f"网络桥接不可用,插件将以无网络模式运行") + + async def register_http_route(self, *args, **kwargs): + self._logger.warning("网络功能不可用,跳过HTTP路由注册") + + async def register_websocket(self, *args, **kwargs): + self._logger.warning("网络功能不可用,跳过WebSocket注册") + + async def broadcast_websocket(self, *args, **kwargs): + self._logger.warning("网络功能不可用,无法广播消息") + + def get_network_info(self): + return { + 'plugin_name': self.plugin_name, + 'registered_routes': [], + 'websocket_handlers': [], + 'base_url': '网络服务不可用' + } + + async def setup_data_transfer(self, *args, **kwargs): + self._logger.warning("网络功能不可用,跳过数据传输设置") + +# 插件内部模块 +try: + from .utils.helper import HelperClass + from .models.data_model import DataModel +except ImportError: + # 如果内部模块不可用,创建虚拟类 + HelperClass = type('HelperClass', (), {}) + DataModel = type('DataModel', (), {}) + +# 日志记录器 +logger = logging.getLogger(__name__) + +# 数据类定义 +@dataclass +class PluginStatus: + """插件状态数据类""" + is_running: bool = False + start_time: Optional[datetime] = None + uptime: Optional[timedelta] = None + request_count: int = 0 + error_count: int = 0 + last_error: Optional[str] = None + memory_usage: Optional[int] = None + +@dataclass +class PluginMetrics: + """插件指标数据类""" + requests_per_second: float = 0.0 + average_response_time: float = 0.0 + active_connections: int = 0 + cache_hit_rate: float = 0.0 + queue_size: int = 0 + +class Plugin: + """ + 我的插件主类 + + 功能特性: + 1. 完整的HTTP API接口 + 2. WebSocket实时通信 + 3. 后台定时任务 + 4. 数据缓存机制 + 5. 健康检查系统 + 6. 完整的错误处理 + 7. 性能监控指标 + + 使用方法: + 1. 确保框架已安装并运行 + 2. 将此插件放入plugins目录 + 3. 重启框架或使用插件管理命令加载 + """ + + # 类常量 + PLUGIN_NAME = "my_awesome_plugin" + PLUGIN_VERSION = "1.0.0" + DEFAULT_CONFIG = { + "enabled": True, + "log_level": "INFO" + } + + def __init__(self, plugin_name: str, config: Dict, bridge): + """ + 初始化插件 + + Args: + plugin_name: 插件名称(框架传入) + config: 插件配置(从config.yaml加载) + bridge: PluginBridge实例,用于插件间通信 + """ + self.plugin_name = plugin_name + self.original_config = config + self.bridge = bridge + self.service_manager = None + + # 配置处理 + self.config = self._merge_configs(self.DEFAULT_CONFIG, config) + + # 网络桥接 + self.network_bridge = None + + # 状态管理 + self.status = PluginStatus() + self.metrics = PluginMetrics() + + # 缓存系统 + self.cache = {} + self.cache_ttl = {} + + # 后台任务 + self.background_tasks = [] + self.task_handles = {} + + # 资源锁 + self._lock = asyncio.Lock() + self._resource_locks = {} + + # 内部服务 + self.helper = HelperClass() + self.data_model = DataModel() + + # 事件处理器映射 + self.event_handlers = {} + + # WebSocket连接管理 + self.websocket_connections = {} + + # API速率限制 + self.rate_limiter = {} + + logger.info(f"插件初始化: {self.plugin_name} v{self.PLUGIN_VERSION}") + + def _merge_configs(self, default: Dict, override: Dict) -> Dict: + """深度合并配置""" + result = default.copy() + + for key, value in override.items(): + if key in result and isinstance(result[key], dict) and isinstance(value, dict): + result[key] = self._merge_configs(result[key], value) + else: + result[key] = value + + return result + + async def initialize(self): + """ + 初始化插件 - 核心入口点 + + 执行顺序: + 1. 基础初始化 + 2. 获取服务管理器 + 3. 设置网络功能 + 4. 注册事件处理器 + 5. 启动后台任务 + 6. 健康检查 + """ + try: + logger.info(f"开始初始化插件: {self.plugin_name}") + + # 1. 记录启动时间 + self.status.start_time = datetime.now() + + # 2. 获取服务管理器(如果可用) + await self._get_service_manager() + + # 3. 初始化网络功能 + await self._initialize_network() + + # 4. 注册事件处理器 + await self._register_event_handlers() + + # 5. 启动后台任务 + await self._start_background_tasks() + + # 6. 初始化缓存系统 + await self._initialize_cache() + + # 7. 设置健康检查 + await self._setup_health_check() + + # 8. 更新状态 + self.status.is_running = True + self.status.uptime = datetime.now() - self.status.start_time + + logger.info(f"✅ 插件初始化完成: {self.plugin_name}") + logger.info(f" 版本: {self.PLUGIN_VERSION}") + logger.info(f" 配置: {len(self.config)} 项") + logger.info(f" 网络: {'可用' if self.network_bridge else '不可用'}") + + # 发送初始化完成事件 + await self._send_initialization_event() + + return True + + except Exception as e: + logger.error(f"❌ 插件初始化失败: {str(e)}") + logger.error(traceback.format_exc()) + + # 尝试清理已初始化的资源 + await self._emergency_cleanup() + + return False + + async def _get_service_manager(self): + """安全获取服务管理器""" + try: + if hasattr(self.bridge, 'service_manager'): + self.service_manager = self.bridge.service_manager + logger.debug("服务管理器获取成功") + else: + logger.warning("服务管理器不可用,部分功能可能受限") + except Exception as e: + logger.warning(f"获取服务管理器时出错: {str(e)}") + + async def _initialize_network(self): + """初始化网络功能""" + try: + # 获取网络服务 + internet_service = None + if self.service_manager: + try: + internet_service = self.service_manager.get_service("internet") + except ValueError: + logger.warning("网络服务未注册") + + # 创建网络桥接 + if internet_service: + self.network_bridge = PluginNetworkBridge( + self.plugin_name, internet_service, self.bridge + ) + + # 注册网络路由 + await self._register_network_routes() + + logger.info(f"网络功能初始化完成,基础URL: {self.network_bridge.get_network_info()['base_url']}") + else: + logger.info("网络服务不可用,插件将以无网络模式运行") + # 创建虚拟网络桥接 + self.network_bridge = PluginNetworkBridge(self.plugin_name, None, self.bridge) + + except Exception as e: + logger.error(f"初始化网络功能时出错: {str(e)}") + raise + + async def _register_network_routes(self): + """注册所有网络路由""" + try: + if not self.network_bridge: + logger.warning("网络桥接不可用,跳过路由注册") + return + + logger.info("开始注册网络路由...") + + # 1. 信息接口(公开) + await self.network_bridge.register_http_route( + "/api/info", + self._handle_api_info, + methods=["GET"], + require_auth=False + ) + + # 2. 健康检查接口(公开) + await self.network_bridge.register_http_route( + "/api/health", + self._handle_api_health, + methods=["GET"], + require_auth=False + ) + + # 3. 数据查询接口(需要认证) + await self.network_bridge.register_http_route( + "/api/data", + self._handle_api_data, + methods=["GET", "POST"], + require_auth=True + ) + + # 4. 文件上传接口(需要认证) + await self.network_bridge.register_http_route( + "/api/upload", + self._handle_api_upload, + methods=["POST"], + require_auth=True + ) + + # 5. 管理接口(需要管理员权限) + await self.network_bridge.register_http_route( + "/api/admin/status", + self._handle_admin_status, + methods=["GET"], + require_auth=True + ) + + # 6. WebSocket聊天接口 + await self.network_bridge.register_websocket( + "/ws/chat", + self._handle_websocket_chat, + require_auth=True + ) + + # 7. WebSocket实时数据接口 + await self.network_bridge.register_websocket( + "/ws/data", + self._handle_websocket_data, + require_auth=True + ) + + # 8. 设置跨端数据传输 + await self.network_bridge.setup_data_transfer( + self._handle_cross_platform_data + ) + + logger.info(f"网络路由注册完成,共注册 {len(self._get_registered_routes())} 个路由") + + except Exception as e: + logger.error(f"注册网络路由时出错: {str(e)}") + raise + + def _get_registered_routes(self): + """获取已注册的路由信息""" + if not self.network_bridge: + return [] + + info = self.network_bridge.get_network_info() + return info.get('registered_routes', []) + + async def _register_event_handlers(self): + """注册事件处理器""" + try: + # 定义事件处理器映射 + self.event_handlers = { + "framework.start": self._handle_framework_start, + "framework.shutdown": self._handle_framework_shutdown, + "plugin.load": self._handle_plugin_load, + "plugin.unload": self._handle_plugin_unload, + "permission.granted": self._handle_permission_granted, + "permission.denied": self._handle_permission_denied, + "network.data.receive": self._handle_network_data_receive, + "user.login": self._handle_user_login, + "user.logout": self._handle_user_logout, + } + + # 注册事件处理器 + for event_type, handler in self.event_handlers.items(): + self.bridge.subscribe_plugin( + self.plugin_name, + f"event.{event_type}", + handler + ) + + logger.info(f"事件处理器注册完成,共 {len(self.event_handlers)} 个") + + except Exception as e: + logger.error(f"注册事件处理器时出错: {str(e)}") + + async def _start_background_tasks(self): + """启动后台任务""" + try: + config = self.config.get('schedules', []) + + for schedule in config: + if schedule.get('enabled', True): + task_name = schedule['name'] + cron_expr = schedule['cron'] + task_func = getattr(self, f"_task_{schedule['task']}", None) + + if task_func: + # 创建后台任务 + task = asyncio.create_task( + self._schedule_task(task_name, cron_expr, task_func) + ) + self.background_tasks.append(task) + self.task_handles[task_name] = task + + logger.info(f"后台任务启动: {task_name} ({cron_expr})") + + logger.info(f"后台任务启动完成,共 {len(self.background_tasks)} 个任务") + + except Exception as e: + logger.error(f"启动后台任务时出错: {str(e)}") + + async def _schedule_task(self, name: str, cron_expr: str, task_func): + """按Cron表达式调度任务""" + from croniter import croniter + import time + + base_time = time.time() + cron = croniter(cron_expr, base_time) + + while self.status.is_running: + try: + # 计算下一次执行时间 + next_time = cron.get_next(float) + sleep_time = next_time - time.time() + + if sleep_time > 0: + await asyncio.sleep(sleep_time) + + # 执行任务 + logger.debug(f"执行定时任务: {name}") + await task_func() + + except asyncio.CancelledError: + logger.info(f"任务被取消: {name}") + break + except Exception as e: + logger.error(f"任务执行出错 {name}: {str(e)}") + await asyncio.sleep(60) # 出错后等待1分钟 + + async def _initialize_cache(self): + """初始化缓存系统""" + try: + cache_config = self.config.get('cache', {}) + + if cache_config.get('type') == 'redis': + # 初始化Redis缓存 + import redis + self.redis_client = redis.Redis( + host=cache_config.get('host', 'localhost'), + port=cache_config.get('port', 6379), + db=cache_config.get('db', 0) + ) + logger.info("Redis缓存初始化完成") + else: + # 使用内存缓存 + logger.info("内存缓存初始化完成") + + except Exception as e: + logger.warning(f"缓存初始化失败,使用无缓存模式: {str(e)}") + + async def _setup_health_check(self): + """设置健康检查""" + try: + # 创建健康检查任务 + health_task = asyncio.create_task(self._health_check_loop()) + self.background_tasks.append(health_task) + + logger.info("健康检查系统已启动") + + except Exception as e: + logger.warning(f"健康检查设置失败: {str(e)}") + + async def _health_check_loop(self): + """健康检查循环""" + while self.status.is_running: + try: + await asyncio.sleep(60) # 每分钟检查一次 + + # 检查网络连接 + network_healthy = await self._check_network_health() + + # 检查缓存 + cache_healthy = await self._check_cache_health() + + # 检查后台任务 + tasks_healthy = await self._check_tasks_health() + + # 记录健康状态 + self.metrics.requests_per_second = self._calculate_rps() + + if not all([network_healthy, cache_healthy, tasks_healthy]): + logger.warning("健康检查发现问题") + + except Exception as e: + logger.error(f"健康检查出错: {str(e)}") + + async def _send_initialization_event(self): + """发送初始化完成事件""" + try: + await self.bridge.publish_to_plugin( + "framework", + "event.plugin.initialized", + { + "plugin_name": self.plugin_name, + "version": self.PLUGIN_VERSION, + "timestamp": datetime.now().isoformat() + } + ) + except Exception as e: + logger.debug(f"发送初始化事件失败: {str(e)}") + + async def _emergency_cleanup(self): + """紧急清理资源""" + try: + # 取消所有后台任务 + for task in self.background_tasks: + if not task.done(): + task.cancel() + + # 清理缓存 + self.cache.clear() + + logger.info("紧急清理完成") + + except Exception as e: + logger.error(f"紧急清理时出错: {str(e)}") + + # ========== 网络处理器方法 ========== + + async def _handle_api_info(self, request): + """处理API信息请求""" + from aiohttp import web + + try: + self.status.request_count += 1 + + info = { + "plugin": { + "name": self.plugin_name, + "version": self.PLUGIN_VERSION, + "description": self.config.get('description', ''), + "author": self.config.get('author', ''), + "status": "running" if self.status.is_running else "stopped" + }, + "system": { + "start_time": self.status.start_time.isoformat() if self.status.start_time else None, + "uptime": str(self.status.uptime) if self.status.uptime else None, + "request_count": self.status.request_count, + "error_count": self.status.error_count + }, + "network": self.network_bridge.get_network_info() if self.network_bridge else None, + "timestamp": datetime.now().isoformat() + } + + return web.json_response(info) + + except Exception as e: + logger.error(f"处理API信息请求时出错: {str(e)}") + return web.json_response( + {"error": "服务器内部错误", "details": str(e)}, + status=500 + ) + + async def _handle_api_health(self, request): + """处理健康检查请求""" + from aiohttp import web + + try: + # 检查各项健康指标 + checks = { + "plugin_running": self.status.is_running, + "network_available": self.network_bridge is not None, + "background_tasks": len([t for t in self.background_tasks if not t.done()]), + "cache_available": len(self.cache) > 0 or hasattr(self, 'redis_client'), + "last_error": self.status.last_error + } + + # 计算总体状态 + all_healthy = all([ + checks["plugin_running"], + checks["network_available"], + checks["background_tasks"] > 0 + ]) + + response = { + "status": "healthy" if all_healthy else "unhealthy", + "timestamp": datetime.now().isoformat(), + "checks": checks, + "metrics": { + "requests_per_second": self.metrics.requests_per_second, + "active_connections": len(self.websocket_connections), + "cache_size": len(self.cache) + } + } + + status_code = 200 if all_healthy else 503 + return web.json_response(response, status=status_code) + + except Exception as e: + logger.error(f"处理健康检查请求时出错: {str(e)}") + return web.json_response( + {"status": "error", "error": str(e)}, + status=500 + ) + + async def _handle_api_data(self, request): + """处理数据API请求""" + from aiohttp import web + + try: + # 检查速率限制 + client_ip = request.remote + if not await self._check_rate_limit(client_ip): + return web.json_response( + {"error": "请求过于频繁,请稍后再试"}, + status=429 + ) + + if request.method == "GET": + # 查询数据 + query_params = dict(request.query) + data = await self._query_data(query_params) + + return web.json_response({ + "success": True, + "data": data, + "count": len(data), + "timestamp": datetime.now().isoformat() + }) + + elif request.method == "POST": + # 创建数据 + data = await request.json() + result = await self._create_data(data) + + return web.json_response({ + "success": True, + "id": result.get("id"), + "message": "数据创建成功", + "timestamp": datetime.now().isoformat() + }, status=201) + + except json.JSONDecodeError: + return web.json_response( + {"error": "无效的JSON数据"}, + status=400 + ) + except Exception as e: + logger.error(f"处理数据API请求时出错: {str(e)}") + return web.json_response( + {"error": "服务器内部错误", "details": str(e)}, + status=500 + ) + + async def _handle_api_upload(self, request): + """处理文件上传请求""" + from aiohttp import web + import aiofiles + + try: + # 检查内容类型 + if not request.content_type.startswith('multipart/form-data'): + return web.json_response( + {"error": "必须使用multipart/form-data格式"}, + status=400 + ) + + reader = await request.multipart() + + files = [] + async for field in reader: + if field.filename: + # 保存文件 + filename = field.filename + filepath = Path("data/uploads") / self.plugin_name / filename + filepath.parent.mkdir(parents=True, exist_ok=True) + + size = 0 + async with aiofiles.open(filepath, 'wb') as f: + while True: + chunk = await field.read_chunk() + if not chunk: + break + size += len(chunk) + await f.write(chunk) + + files.append({ + "filename": filename, + "size": size, + "path": str(filepath) + }) + + return web.json_response({ + "success": True, + "files": files, + "count": len(files), + "timestamp": datetime.now().isoformat() + }) + + except Exception as e: + logger.error(f"处理文件上传时出错: {str(e)}") + return web.json_response( + {"error": "文件上传失败", "details": str(e)}, + status=500 + ) + + async def _handle_admin_status(self, request): + """处理管理状态请求""" + from aiohttp import web + + try: + # 检查管理员权限 + if not await self._check_admin_permission(request): + return web.json_response( + {"error": "需要管理员权限"}, + status=403 + ) + + status_info = { + "plugin": { + "name": self.plugin_name, + "config": self.config, + "status": self.status, + "metrics": self.metrics + }, + "system": { + "background_tasks": [ + { + "name": name, + "running": not task.done(), + "cancelled": task.cancelled() + } + for name, task in self.task_handles.items() + ], + "cache_info": { + "size": len(self.cache), + "keys": list(self.cache.keys())[:10] + }, + "websocket_connections": len(self.websocket_connections) + }, + "timestamp": datetime.now().isoformat() + } + + return web.json_response(status_info) + + except Exception as e: + logger.error(f"处理管理状态请求时出错: {str(e)}") + return web.json_response( + {"error": "服务器内部错误", "details": str(e)}, + status=500 + ) + + async def _handle_websocket_chat(self, ws, request): + """处理WebSocket聊天""" + from aiohttp import web + + try: + # 获取用户信息 + user = await self._get_user_from_request(request) + if not user: + await ws.close(code=1008, message="未认证") + return + + # 记录连接 + connection_id = f"{user['id']}_{id(ws)}" + self.websocket_connections[connection_id] = { + "ws": ws, + "user": user, + "connected_at": datetime.now() + } + + logger.info(f"WebSocket聊天连接建立: {connection_id}") + + # 发送欢迎消息 + await ws.send_str(json.dumps({ + "type": "system", + "message": f"欢迎 {user['username']} 进入聊天室", + "timestamp": datetime.now().isoformat() + })) + + # 广播用户上线消息 + await self._broadcast_chat_message({ + "type": "user_join", + "user": user, + "timestamp": datetime.now().isoformat() + }) + + # 处理消息 + async for msg in ws: + if msg.type == web.WSMsgType.TEXT: + try: + data = json.loads(msg.data) + + # 处理不同类型的消息 + if data.get('type') == 'message': + # 广播聊天消息 + message = { + "type": "message", + "from": user, + "content": data.get('content', ''), + "timestamp": datetime.now().isoformat() + } + + await self._broadcast_chat_message(message) + + elif data.get('type') == 'typing': + # 广播输入状态 + await self._broadcast_chat_message({ + "type": "typing", + "user": user, + "is_typing": data.get('is_typing', False), + "timestamp": datetime.now().isoformat() + }) + + except json.JSONDecodeError: + logger.warning(f"收到无效的JSON消息: {msg.data}") + + elif msg.type == web.WSMsgType.ERROR: + logger.error(f"WebSocket错误: {ws.exception()}") + + elif msg.type == web.WSMsgType.CLOSE: + logger.info(f"WebSocket连接关闭: {connection_id}") + + except Exception as e: + logger.error(f"WebSocket聊天处理出错: {str(e)}") + finally: + # 清理连接 + if connection_id in self.websocket_connections: + del self.websocket_connections[connection_id] + + # 广播用户离线消息 + if 'user' in locals(): + await self._broadcast_chat_message({ + "type": "user_leave", + "user": user, + "timestamp": datetime.now().isoformat() + }) + + async def _handle_websocket_data(self, ws, request): + """处理WebSocket实时数据""" + from aiohttp import web + + try: + # 获取用户信息 + user = await self._get_user_from_request(request) + if not user: + await ws.close(code=1008, message="未认证") + return + + connection_id = f"data_{user['id']}_{id(ws)}" + + logger.info(f"WebSocket数据连接建立: {connection_id}") + + # 发送初始数据 + await ws.send_str(json.dumps({ + "type": "init", + "data": await self._get_initial_data(), + "timestamp": datetime.now().isoformat() + })) + + # 定期发送更新 + while not ws.closed: + try: + await asyncio.sleep(5) # 每5秒发送一次更新 + + if not ws.closed: + await ws.send_str(json.dumps({ + "type": "update", + "data": await self._get_updated_data(), + "timestamp": datetime.now().isoformat() + })) + + except asyncio.CancelledError: + break + except Exception as e: + logger.error(f"发送WebSocket数据更新时出错: {str(e)}") + break + + except Exception as e: + logger.error(f"WebSocket数据处理出错: {str(e)}") + finally: + logger.info(f"WebSocket数据连接关闭: {connection_id}") + + async def _handle_cross_platform_data(self, event_data): + """处理跨端数据""" + try: + logger.info(f"收到跨端数据: {event_data.get('type')}") + + # 根据数据类型处理 + data_type = event_data.get('type') + + if data_type == "sync_request": + # 处理同步请求 + await self._handle_sync_request(event_data) + + elif data_type == "notification": + # 处理通知 + await self._handle_notification(event_data) + + elif data_type == "command": + # 处理远程命令 + await self._handle_remote_command(event_data) + + # 广播到WebSocket + if self.network_bridge: + await self.network_bridge.broadcast_websocket({ + "type": "cross_platform", + "source": event_data.get('source', 'unknown'), + "data": event_data.get('data', {}), + "timestamp": datetime.now().isoformat() + }) + + except Exception as e: + logger.error(f"处理跨端数据时出错: {str(e)}") + + # ========== 事件处理器方法 ========== + + async def _handle_framework_start(self, event_data): + """处理框架启动事件""" + try: + logger.info(f"框架启动事件: {event_data}") + + # 发送欢迎消息 + if self.network_bridge: + await self.network_bridge.broadcast_websocket({ + "type": "system", + "message": f"插件 {self.plugin_name} 已就绪,框架已启动", + "timestamp": datetime.now().isoformat() + }) + + except Exception as e: + logger.error(f"处理框架启动事件时出错: {str(e)}") + + async def _handle_framework_shutdown(self, event_data): + """处理框架关闭事件""" + try: + logger.info("收到框架关闭事件,开始清理...") + + # 通知所有连接 + if self.network_bridge: + await self.network_bridge.broadcast_websocket({ + "type": "system", + "message": "框架正在关闭,请保存您的工作", + "timestamp": datetime.now().isoformat() + }) + + # 执行插件关闭 + await self.shutdown() + + except Exception as e: + logger.error(f"处理框架关闭事件时出错: {str(e)}") + + async def _handle_plugin_load(self, event_data): + """处理插件加载事件""" + try: + loaded_plugin = event_data.get('plugin_name') + logger.info(f"插件加载事件: {loaded_plugin}") + + # 如果是其他插件加载,可以建立连接或同步数据 + if loaded_plugin != self.plugin_name: + await self._sync_with_plugin(loaded_plugin) + + except Exception as e: + logger.error(f"处理插件加载事件时出错: {str(e)}") + + async def _handle_plugin_unload(self, event_data): + """处理插件卸载事件""" + try: + unloaded_plugin = event_data.get('plugin_name') + logger.info(f"插件卸载事件: {unloaded_plugin}") + + # 清理与该插件相关的资源 + await self._cleanup_plugin_resources(unloaded_plugin) + + except Exception as e: + logger.error(f"处理插件卸载事件时出错: {str(e)}") + + async def _handle_permission_granted(self, event_data): + """处理权限授予事件""" + try: + plugin_name = event_data.get('plugin_name') + permissions = event_data.get('permissions', []) + + if plugin_name == self.plugin_name: + logger.info(f"权限已授予: {permissions}") + + # 重新初始化需要权限的功能 + await self._reinitialize_with_permissions(permissions) + + except Exception as e: + logger.error(f"处理权限授予事件时出错: {str(e)}") + + async def _handle_permission_denied(self, event_data): + """处理权限拒绝事件""" + try: + plugin_name = event_data.get('plugin_name') + + if plugin_name == self.plugin_name: + logger.warning("权限被拒绝,部分功能将不可用") + + # 降级运行 + await self._degrade_features() + + except Exception as e: + logger.error(f"处理权限拒绝事件时出错: {str(e)}") + + async def _handle_network_data_receive(self, event_data): + """处理网络数据接收事件""" + try: + data = event_data.get('data', {}) + source = event_data.get('source', 'unknown') + + logger.debug(f"收到网络数据: {data.get('type')} from {source}") + + # 根据数据类型处理 + await self._process_network_data(data, source) + + except Exception as e: + logger.error(f"处理网络数据时出错: {str(e)}") + + async def _handle_user_login(self, event_data): + """处理用户登录事件""" + try: + user = event_data.get('user', {}) + logger.info(f"用户登录: {user.get('username')}") + + # 发送欢迎消息 + if self.network_bridge: + await self.network_bridge.broadcast_websocket({ + "type": "user", + "action": "login", + "user": user, + "timestamp": datetime.now().isoformat() + }) + + except Exception as e: + logger.error(f"处理用户登录事件时出错: {str(e)}") + + async def _handle_user_logout(self, event_data): + """处理用户登出事件""" + try: + user = event_data.get('user', {}) + logger.info(f"用户登出: {user.get('username')}") + + # 清理用户相关资源 + await self._cleanup_user_resources(user) + + except Exception as e: + logger.error(f"处理用户登出事件时出错: {str(e)}") + + # ========== 辅助方法 ========== + + async def _check_rate_limit(self, client_ip: str, limit: int = 100) -> bool: + """检查速率限制""" + now = datetime.now() + + if client_ip not in self.rate_limiter: + self.rate_limiter[client_ip] = { + "count": 1, + "window_start": now + } + return True + + # 检查时间窗口 + window_start = self.rate_limiter[client_ip]["window_start"] + window_age = (now - window_start).total_seconds() + + if window_age > 60: # 1分钟窗口 + # 重置计数器 + self.rate_limiter[client_ip] = { + "count": 1, + "window_start": now + } + return True + + # 增加计数 + self.rate_limiter[client_ip]["count"] += 1 + + # 检查是否超限 + if self.rate_limiter[client_ip]["count"] > limit: + return False + + return True + + async def _get_user_from_request(self, request): + """从请求中获取用户信息""" + # 这里实现用户认证逻辑 + # 可以从请求头中获取token,然后验证 + token = request.headers.get('Authorization', '').replace('Bearer ', '') + + if token: + # 验证token并返回用户信息 + # 这里需要连接到认证服务 + return { + "id": "user_id", + "username": "username", + "permissions": [] + } + + return None + + async def _check_admin_permission(self, request): + """检查管理员权限""" + user = await self._get_user_from_request(request) + + if user and "admin" in user.get("permissions", []): + return True + + return False + + async def _broadcast_chat_message(self, message): + """广播聊天消息""" + if not self.network_bridge: + return + + for connection_id, connection in self.websocket_connections.items(): + try: + if not connection['ws'].closed: + await connection['ws'].send_str(json.dumps(message)) + except Exception as e: + logger.error(f"广播消息失败 {connection_id}: {str(e)}") + + async def _query_data(self, query_params): + """查询数据""" + # 这里实现数据查询逻辑 + # 可以从数据库、文件或内存中查询 + return [] + + async def _create_data(self, data): + """创建数据""" + # 这里实现数据创建逻辑 + return {"id": "new_id"} + + async def _get_initial_data(self): + """获取初始数据""" + return {"message": "初始数据"} + + async def _get_updated_data(self): + """获取更新数据""" + return {"message": "更新数据", "timestamp": datetime.now().isoformat()} + + async def _check_network_health(self): + """检查网络健康状态""" + return True + + async def _check_cache_health(self): + """检查缓存健康状态""" + return True + + async def _check_tasks_health(self): + """检查任务健康状态""" + return True + + def _calculate_rps(self): + """计算每秒请求数""" + # 这里实现RPS计算逻辑 + return 0.0 + + async def _sync_with_plugin(self, plugin_name): + """与插件同步""" + logger.debug(f"与插件同步: {plugin_name}") + + async def _cleanup_plugin_resources(self, plugin_name): + """清理插件资源""" + logger.debug(f"清理插件资源: {plugin_name}") + + async def _reinitialize_with_permissions(self, permissions): + """重新初始化权限相关功能""" + logger.debug(f"重新初始化权限: {permissions}") + + async def _degrade_features(self): + """降级功能""" + logger.debug("功能降级") + + async def _process_network_data(self, data, source): + """处理网络数据""" + logger.debug(f"处理网络数据: {data} from {source}") + + async def _cleanup_user_resources(self, user): + """清理用户资源""" + logger.debug(f"清理用户资源: {user.get('username')}") + + # ========== 后台任务方法 ========== + + async def _task_cleanup_old_data(self): + """清理旧数据任务""" + try: + logger.info("开始清理旧数据...") + + # 实现清理逻辑 + await asyncio.sleep(1) # 模拟清理过程 + + logger.info("旧数据清理完成") + + except Exception as e: + logger.error(f"清理旧数据时出错: {str(e)}") + + async def _task_sync_external_data(self): + """同步外部数据任务""" + try: + logger.info("开始同步外部数据...") + + # 实现同步逻辑 + await asyncio.sleep(1) # 模拟同步过程 + + logger.info("外部数据同步完成") + + except Exception as e: + logger.error(f"同步外部数据时出错: {str(e)}") + + # ========== 插件命令方法 ========== + + @plugin_command( + name="status", + description="查看插件状态", + permissions=["plugin.my_awesome_plugin.read"] + ) + async def cmd_status(self, *args): + """查看插件状态命令""" + try: + result = [] + result.append(f"🔍 **{self.plugin_name} 插件状态**") + result.append("=" * 50) + result.append(f"📊 版本: {self.PLUGIN_VERSION}") + result.append(f"🔄 状态: {'✅ 运行中' if self.status.is_running else '❌ 已停止'}") + + if self.status.start_time: + result.append(f"⏰ 启动时间: {self.status.start_time.strftime('%Y-%m-%d %H:%M:%S')}") + + if self.status.uptime: + result.append(f"⏱️ 运行时长: {self.status.uptime}") + + result.append(f"📈 请求总数: {self.status.request_count}") + result.append(f"❌ 错误总数: {self.status.error_count}") + + # 网络状态 + network_info = self.network_bridge.get_network_info() if self.network_bridge else {} + result.append(f"🌐 网络状态: {'✅ 可用' if network_info else '❌ 不可用'}") + + if network_info: + result.append(f" 基础URL: {network_info.get('base_url', 'N/A')}") + result.append(f" HTTP路由: {len(network_info.get('registered_routes', []))} 个") + result.append(f" WebSocket: {len(network_info.get('websocket_handlers', []))} 个") + + # 后台任务 + result.append(f"🔧 后台任务: {len(self.background_tasks)} 个运行中") + + # 缓存状态 + result.append(f"💾 缓存大小: {len(self.cache)} 项") + + # WebSocket连接 + result.append(f"🔗 WebSocket连接: {len(self.websocket_connections)} 个") + + return "\n".join(result) + + except Exception as e: + logger.error(f"状态命令执行失败: {str(e)}") + return f"❌ 获取状态失败: {str(e)}" + + @plugin_command( + name="config", + description="查看或修改插件配置", + permissions=["plugin.my_awesome_plugin.read", "plugin.my_awesome_plugin.write"] + ) + async def cmd_config(self, *args): + """配置管理命令""" + try: + if not args: + # 显示配置 + result = [f"⚙️ **{self.plugin_name} 配置信息**"] + result.append("=" * 50) + + for section, values in self.config.items(): + if isinstance(values, dict): + result.append(f"\n📁 {section.upper()}:") + for key, value in list(values.items())[:5]: # 只显示前5项 + result.append(f" {key}: {value}") + if len(values) > 5: + result.append(f" ... 还有 {len(values) - 5} 项配置") + else: + result.append(f"{section}: {values}") + + result.append("\n💡 使用: config get 查看具体配置") + result.append("💡 使用: config set 修改配置") + + return "\n".join(result) + + command = args[0].lower() + + if command == "get": + if len(args) < 2: + return "❌ 请指定配置键,如: config get settings.log_level" + + key = args[1] + value = self._get_nested_config(key) + + if value is not None: + return f"✅ {key} = {value}" + else: + return f"❌ 配置键不存在: {key}" + + elif command == "set": + if len(args) < 3: + return "❌ 请指定配置键和值,如: config set settings.log_level DEBUG" + + key = args[1] + value = args[2] + + # 尝试转换为适当类型 + try: + if value.lower() == "true": + value = True + elif value.lower() == "false": + value = False + elif value.isdigit(): + value = int(value) + elif value.replace('.', '', 1).isdigit(): + value = float(value) + except: + pass + + success = self._set_nested_config(key, value) + + if success: + # 保存配置到文件 + await self._save_config() + return f"✅ 配置已更新: {key} = {value}" + else: + return f"❌ 配置更新失败: {key}" + + else: + return f"❌ 未知命令: {command}" + + except Exception as e: + logger.error(f"配置命令执行失败: {str(e)}") + return f"❌ 配置命令错误: {str(e)}" + + def _get_nested_config(self, key_path: str): + """获取嵌套配置值""" + keys = key_path.split('.') + current = self.config + + for key in keys: + if isinstance(current, dict) and key in current: + current = current[key] + else: + return None + + return current + + def _set_nested_config(self, key_path: str, value): + """设置嵌套配置值""" + try: + keys = key_path.split('.') + current = self.config + + # 遍历到最后一个键的父级 + for key in keys[:-1]: + if key not in current or not isinstance(current[key], dict): + current[key] = {} + current = current[key] + + # 设置值 + current[keys[-1]] = value + return True + + except Exception: + return False + + async def _save_config(self): + """保存配置到文件""" + try: + config_path = Path(f"plugins/{self.plugin_name}/config.yaml") + + import yaml + with open(config_path, 'w', encoding='utf-8') as f: + yaml.dump(self.config, f, default_flow_style=False, allow_unicode=True) + + logger.info(f"配置已保存: {config_path}") + + except Exception as e: + logger.error(f"保存配置失败: {str(e)}") + + @plugin_command( + name="network", + description="网络功能管理", + permissions=["plugin.my_awesome_plugin.network.access"] + ) + async def cmd_network(self, *args): + """网络功能管理命令""" + try: + if not args: + # 显示网络状态 + if not self.network_bridge: + return "❌ 网络功能不可用" + + info = self.network_bridge.get_network_info() + + result = [f"🌐 **{self.plugin_name} 网络状态**"] + result.append("=" * 50) + result.append(f"🔌 插件名称: {info['plugin_name']}") + result.append(f"🌍 基础URL: {info['base_url']}") + result.append(f"🛣️ HTTP路由数: {len(info['registered_routes'])}") + result.append(f"🔗 WebSocket处理器数: {len(info['websocket_handlers'])}") + + if info['registered_routes']: + result.append("\n📡 **注册的HTTP路由:**") + for route in info['registered_routes']: + auth_required = "🔐" if route['require_auth'] else "🔓" + result.append(f" {auth_required} {route['path']} [{','.join(route['methods'])}]") + + if info['websocket_handlers']: + result.append("\n⚡ **注册的WebSocket:**") + for ws in info['websocket_handlers']: + auth_required = "🔐" if ws['require_auth'] else "🔓" + result.append(f" {auth_required} {ws['path']}") + + result.append("\n💡 使用: network test 测试网络连接") + result.append("💡 使用: network restart 重启网络功能") + + return "\n".join(result) + + command = args[0].lower() + + if command == "test": + # 测试网络连接 + if not self.network_bridge: + return "❌ 网络功能不可用" + + info = self.network_bridge.get_network_info() + base_url = info['base_url'] + + if base_url == '网络服务不可用': + return "❌ 网络服务不可用,无法测试" + + try: + import aiohttp + + async with aiohttp.ClientSession() as session: + async with session.get(f"{base_url}/api/health") as response: + if response.status == 200: + return "✅ 网络连接正常" + else: + return f"❌ 网络连接异常,状态码: {response.status}" + except Exception as e: + return f"❌ 网络测试失败: {str(e)}" + + elif command == "restart": + # 重启网络功能 + if not self.service_manager: + return "❌ 服务管理器不可用" + + # 这里可以实现网络功能重启逻辑 + return "🔄 网络功能重启中..." + + else: + return f"❌ 未知命令: {command}" + + except Exception as e: + logger.error(f"网络命令执行失败: {str(e)}") + return f"❌ 网络命令错误: {str(e)}" + + @plugin_command( + name="cache", + description="缓存管理", + permissions=["plugin.my_awesome_plugin.read"] + ) + async def cmd_cache(self, *args): + """缓存管理命令""" + try: + if not args: + # 显示缓存状态 + result = [f"💾 **{self.plugin_name} 缓存状态**"] + result.append("=" * 50) + result.append(f"📊 缓存项数: {len(self.cache)}") + result.append(f"⏱️ TTL项数: {len(self.cache_ttl)}") + result.append(f"📈 命中率: {self.metrics.cache_hit_rate:.2%}") + + if self.cache: + result.append("\n🔑 **缓存键列表 (前10个):**") + for i, key in enumerate(list(self.cache.keys())[:10]): + value = self.cache[key] + value_preview = str(value)[:50] + "..." if len(str(value)) > 50 else str(value) + result.append(f" {i+1}. {key}: {value_preview}") + + if len(self.cache) > 10: + result.append(f" ... 还有 {len(self.cache) - 10} 个键") + + result.append("\n💡 使用: cache clear 清理所有缓存") + result.append("💡 使用: cache get 获取缓存值") + result.append("💡 使用: cache set [ttl] 设置缓存") + + return "\n".join(result) + + command = args[0].lower() + + if command == "clear": + # 清理缓存 + old_size = len(self.cache) + self.cache.clear() + self.cache_ttl.clear() + + return f"✅ 缓存已清理,共清理 {old_size} 项" + + elif command == "get": + if len(args) < 2: + return "❌ 请指定缓存键,如: cache get my_key" + + key = args[1] + + if key in self.cache: + value = self.cache[key] + + # 检查是否过期 + if key in self.cache_ttl: + expiry = self.cache_ttl[key] + if datetime.now() > expiry: + del self.cache[key] + del self.cache_ttl[key] + return f"❌ 缓存已过期: {key}" + + return f"✅ {key} = {value}" + else: + return f"❌ 缓存键不存在: {key}" + + elif command == "set": + if len(args) < 3: + return "❌ 请指定缓存键和值,如: cache set my_key my_value" + + key = args[1] + value = args[2] + + # 解析TTL + ttl = None + if len(args) > 3: + try: + ttl = int(args[3]) + except ValueError: + return "❌ TTL必须是整数(秒)" + + # 设置缓存 + self.cache[key] = value + + if ttl: + self.cache_ttl[key] = datetime.now() + timedelta(seconds=ttl) + + return f"✅ 缓存已设置: {key} = {value}" + (f" (TTL: {ttl}秒)" if ttl else "") + + elif command == "stats": + # 显示详细统计 + total_hits = 0 # 这里需要实现命中计数 + total_misses = 0 + + if total_hits + total_misses > 0: + hit_rate = total_hits / (total_hits + total_misses) + else: + hit_rate = 0 + + return ( + f"📊 **缓存统计**\n" + f"命中次数: {total_hits}\n" + f"未命中次数: {total_misses}\n" + f"命中率: {hit_rate:.2%}\n" + f"内存使用: 约 {sum(len(str(v)) for v in self.cache.values())} 字节" + ) + + else: + return f"❌ 未知命令: {command}" + + except Exception as e: + logger.error(f"缓存命令执行失败: {str(e)}") + return f"❌ 缓存命令错误: {str(e)}" + + @plugin_command( + name="tasks", + description="后台任务管理", + permissions=["plugin.my_awesome_plugin.read"] + ) + async def cmd_tasks(self, *args): + """后台任务管理命令""" + try: + if not args: + # 显示任务状态 + result = [f"🔧 **{self.plugin_name} 后台任务**"] + result.append("=" * 50) + result.append(f"📊 总任务数: {len(self.background_tasks)}") + + running_tasks = [t for t in self.background_tasks if not t.done()] + result.append(f"🔄 运行中: {len(running_tasks)}") + result.append(f"✅ 已完成: {len(self.background_tasks) - len(running_tasks)}") + + if self.task_handles: + result.append("\n📋 **任务列表:**") + for name, task in self.task_handles.items(): + status = "🟢 运行中" if not task.done() else "🔴 已停止" + cancelled = " (已取消)" if task.cancelled() else "" + result.append(f" {status}{cancelled} {name}") + + result.append("\n💡 使用: tasks start 启动任务") + result.append("💡 使用: tasks stop 停止任务") + result.append("💡 使用: tasks list 列出所有任务") + + return "\n".join(result) + + command = args[0].lower() + + if command == "list": + # 列出所有任务 + if not self.task_handles: + return "📭 没有后台任务" + + result = ["📋 **后台任务列表:**"] + for name, task in self.task_handles.items(): + if task.done(): + if task.cancelled(): + status = "🔴 已取消" + else: + status = "✅ 已完成" + else: + status = "🟢 运行中" + + result.append(f" {status} {name}") + + return "\n".join(result) + + elif command == "start": + if len(args) < 2: + return "❌ 请指定任务名称,如: tasks start daily_cleanup" + + task_name = args[1] + + # 查找任务配置 + task_config = None + for schedule in self.config.get('schedules', []): + if schedule.get('name') == task_name: + task_config = schedule + break + + if not task_config: + return f"❌ 找不到任务: {task_name}" + + # 检查任务是否已在运行 + if task_name in self.task_handles: + task = self.task_handles[task_name] + if not task.done(): + return f"ℹ️ 任务已在运行: {task_name}" + + # 启动任务 + task_func = getattr(self, f"_task_{task_config['task']}", None) + if not task_func: + return f"❌ 找不到任务处理函数: {task_config['task']}" + + task = asyncio.create_task( + self._schedule_task(task_name, task_config['cron'], task_func) + ) + + self.background_tasks.append(task) + self.task_handles[task_name] = task + + return f"✅ 任务已启动: {task_name}" + + elif command == "stop": + if len(args) < 2: + return "❌ 请指定任务名称,如: tasks stop daily_cleanup" + + task_name = args[1] + + if task_name not in self.task_handles: + return f"❌ 找不到任务: {task_name}" + + task = self.task_handles[task_name] + + if not task.done(): + task.cancel() + return f"🛑 任务已取消: {task_name}" + else: + return f"ℹ️ 任务已停止: {task_name}" + + elif command == "run": + if len(args) < 2: + return "❌ 请指定任务名称,如: tasks run daily_cleanup" + + task_name = args[1] + + # 查找任务函数 + task_func = None + for schedule in self.config.get('schedules', []): + if schedule.get('name') == task_name: + task_func_name = schedule.get('task') + task_func = getattr(self, f"_task_{task_func_name}", None) + break + + if not task_func: + return f"❌ 找不到任务: {task_name}" + + # 立即执行任务 + try: + await task_func() + return f"✅ 任务执行完成: {task_name}" + except Exception as e: + return f"❌ 任务执行失败: {str(e)}" + + else: + return f"❌ 未知命令: {command}" + + except Exception as e: + logger.error(f"任务命令执行失败: {str(e)}") + return f"❌ 任务命令错误: {str(e)}" + + @plugin_command( + name="admin", + description="管理员命令", + permissions=["plugin.my_awesome_plugin.admin"] + ) + async def cmd_admin(self, *args): + """管理员命令""" + try: + if not args: + return ( + "⚡ **管理员命令**\n" + "💡 使用: admin reload 重新加载插件\n" + "💡 使用: admin debug 开启调试模式\n" + "💡 使用: admin users 查看在线用户\n" + "💡 使用: admin logs [count] 查看日志\n" + ) + + command = args[0].lower() + + if command == "reload": + # 重新加载插件 + return "🔄 插件重新加载中..." + + elif command == "debug": + # 切换调试模式 + debug_enabled = self.config.get('debug', {}).get('enable_debug_endpoints', False) + self.config.setdefault('debug', {})['enable_debug_endpoints'] = not debug_enabled + + status = "启用" if not debug_enabled else "禁用" + return f"🔧 调试模式已{status}" + + elif command == "users": + # 查看在线用户 + if not self.websocket_connections: + return "📭 没有在线用户" + + result = ["👥 **在线用户列表:**"] + for conn_id, conn_info in self.websocket_connections.items(): + user = conn_info.get('user', {}) + connected_at = conn_info.get('connected_at') + + username = user.get('username', '未知用户') + user_id = user.get('id', '未知ID') + + if connected_at: + duration = datetime.now() - connected_at + duration_str = str(duration).split('.')[0] + else: + duration_str = "未知" + + result.append(f" 👤 {username} (ID: {user_id}) - 连接时长: {duration_str}") + + return "\n".join(result) + + elif command == "logs": + # 查看日志 + count = 10 + if len(args) > 1: + try: + count = min(int(args[1]), 50) + except ValueError: + return "❌ 日志数量必须是数字" + + # 这里需要实现日志查询逻辑 + # 可以从日志文件或内存中读取 + return f"📋 显示最近 {count} 条日志 (功能待实现)" + + else: + return f"❌ 未知管理员命令: {command}" + + except Exception as e: + logger.error(f"管理员命令执行失败: {str(e)}") + return f"❌ 管理员命令错误: {str(e)}" + + @plugin_command( + name="help", + description="显示插件帮助信息" + ) + async def cmd_help(self, *args): + """帮助命令""" + try: + result = [f"📚 **{self.plugin_name} 插件帮助**"] + result.append("=" * 50) + result.append(f"版本: {self.PLUGIN_VERSION}") + result.append(f"描述: {self.config.get('description', '')}") + result.append(f"作者: {self.config.get('author', '')}") + + result.append("\n🔧 **可用命令:**") + + # 扫描所有命令方法 + command_methods = [] + for attr_name in dir(self): + if attr_name.startswith('cmd_'): + method = getattr(self, attr_name) + if hasattr(method, '_is_plugin_command'): + command_name = getattr(method, '_command_name', attr_name[4:]) + description = getattr(method, '_command_description', '') + permissions = getattr(method, '_command_permissions', []) + + # 检查权限 + has_permission = True + if permissions: + # 这里需要实现权限检查逻辑 + pass + + if has_permission: + command_methods.append((command_name, description)) + + # 按字母顺序排序 + command_methods.sort(key=lambda x: x[0]) + + for cmd_name, cmd_desc in command_methods: + result.append(f" 🟢 {cmd_name:15} - {cmd_desc}") + + result.append("\n🌐 **API接口:**") + if self.network_bridge: + info = self.network_bridge.get_network_info() + result.append(f" 基础URL: {info.get('base_url', 'N/A')}") + + for route in info.get('registered_routes', []): + result.append(f" 🔗 {route['path']} [{','.join(route['methods'])}]") + + result.append("\n💡 **使用提示:**") + result.append(" 1. 使用 help 命令查看帮助") + result.append(" 2. 使用 status 命令查看插件状态") + result.append(" 3. 使用 config 命令管理配置") + result.append(" 4. 使用 network 命令管理网络功能") + + result.append("\n⚠️ **注意事项:**") + result.append(" 1. 部分命令需要特定权限") + result.append(" 2. 修改配置后可能需要重启插件") + result.append(" 3. 网络功能依赖于框架网络服务") + + return "\n".join(result) + + except Exception as e: + logger.error(f"帮助命令执行失败: {str(e)}") + return f"❌ 帮助命令错误: {str(e)}" + + # ========== 插件生命周期方法 ========== + + # ========== 插件生命周期方法 ========== + + async def shutdown(self): + """ + 关闭插件 + + 执行顺序: + 1. 停止所有后台任务 + 2. 关闭网络连接 + 3. 清理缓存和资源 + 4. 保存状态和配置 + 5. 清理事件处理器 + 6. 发送关闭通知 + """ + try: + logger.info(f"开始关闭插件: {self.plugin_name}") + + # 1. 更新状态 + self.status.is_running = False + + # 2. 发送关闭通知 + await self._send_shutdown_notification() + + # 3. 取消所有后台任务 + logger.info("正在停止后台任务...") + task_cancellations = [] + for task in self.background_tasks: + if not task.done(): + task.cancel() + task_cancellations.append(task) + + # 等待所有任务取消完成 + if task_cancellations: + try: + await asyncio.wait(task_cancellations, timeout=10.0) + logger.info(f"后台任务已停止: {len(task_cancellations)} 个") + except asyncio.TimeoutError: + logger.warning("部分后台任务停止超时") + + # 4. 关闭WebSocket连接 + logger.info("正在关闭WebSocket连接...") + close_tasks = [] + for conn_id, conn_info in list(self.websocket_connections.items()): + try: + if not conn_info['ws'].closed: + close_task = asyncio.create_task( + conn_info['ws'].close(code=1000, message='插件关闭') + ) + close_tasks.append(close_task) + except Exception as e: + logger.error(f"关闭WebSocket连接失败 {conn_id}: {str(e)}") + + if close_tasks: + await asyncio.gather(*close_tasks, return_exceptions=True) + + self.websocket_connections.clear() + + # 5. 清理事件处理器 + logger.info("正在清理事件处理器...") + if hasattr(self.bridge, 'cleanup_plugin_subscriptions'): + self.bridge.cleanup_plugin_subscriptions(self.plugin_name) + elif hasattr(self.bridge, 'unsubscribe_all'): + await self.bridge.unsubscribe_all(self.plugin_name) + else: + logger.warning("无法找到事件处理器清理方法,手动清理") + for event_type in list(self.event_handlers.keys()): + try: + await self.bridge.unsubscribe_plugin( + self.plugin_name, + f"event.{event_type}" + ) + except Exception as e: + logger.debug(f"清理事件处理器失败 {event_type}: {str(e)}") + + # 6. 清理缓存 + logger.info("正在清理缓存...") + self.cache.clear() + self.cache_ttl.clear() + + # 清理Redis连接(如果存在) + if hasattr(self, 'redis_client'): + try: + self.redis_client.close() + logger.debug("Redis连接已关闭") + except Exception as e: + logger.warning(f"关闭Redis连接失败: {str(e)}") + + # 7. 保存配置和状态 + logger.info("正在保存配置和状态...") + await self._save_plugin_state() + + # 8. 清理锁和资源 + logger.info("正在清理资源锁...") + self._resource_locks.clear() + + # 清理任务句柄 + self.task_handles.clear() + + # 9. 计算运行时长 + if self.status.start_time: + self.status.uptime = datetime.now() - self.status.start_time + logger.info(f"插件运行时长: {self.status.uptime}") + + # 10. 发送插件停止事件 + await self._send_plugin_stopped_event() + + logger.info(f"✅ 插件关闭完成: {self.plugin_name}") + + except Exception as e: + logger.error(f"关闭插件时出错: {str(e)}") + logger.error(traceback.format_exc()) + + # 紧急清理 + await self._emergency_shutdown() + + async def _send_shutdown_notification(self): + """发送关闭通知""" + try: + if self.network_bridge: + await self.network_bridge.broadcast_websocket({ + "type": "system", + "message": f"插件 {self.plugin_name} 正在关闭...", + "timestamp": datetime.now().isoformat() + }) + + # 发送框架事件 + await self.bridge.publish_to_plugin( + "framework", + "event.plugin.shutting_down", + { + "plugin_name": self.plugin_name, + "timestamp": datetime.now().isoformat() + } + ) + except Exception as e: + logger.debug(f"发送关闭通知失败: {str(e)}") + + async def _save_plugin_state(self): + """保存插件状态""" + try: + state_data = { + "plugin_name": self.plugin_name, + "version": self.PLUGIN_VERSION, + "status": { + "last_run": datetime.now().isoformat(), + "request_count": self.status.request_count, + "error_count": self.status.error_count, + "uptime": str(self.status.uptime) if self.status.uptime else None + }, + "config": self.config, + "cache_stats": { + "size": len(self.cache), + "keys": list(self.cache.keys())[:20] # 只保存前20个键 + }, + "websocket_stats": { + "max_connections": len(self.websocket_connections) + } + } + + state_path = Path(f"data/plugins/{self.plugin_name}/state.json") + state_path.parent.mkdir(parents=True, exist_ok=True) + + with open(state_path, 'w', encoding='utf-8') as f: + json.dump(state_data, f, ensure_ascii=False, indent=2) + + logger.debug(f"插件状态已保存: {state_path}") + + except Exception as e: + logger.warning(f"保存插件状态失败: {str(e)}") + + async def _send_plugin_stopped_event(self): + """发送插件停止事件""" + try: + await self.bridge.publish_to_plugin( + "framework", + "event.plugin.stopped", + { + "plugin_name": self.plugin_name, + "version": self.PLUGIN_VERSION, + "timestamp": datetime.now().isoformat(), + "uptime": str(self.status.uptime) if self.status.uptime else None + } + ) + except Exception as e: + logger.debug(f"发送插件停止事件失败: {str(e)}") + + async def _emergency_shutdown(self): + """紧急关闭""" + try: + logger.critical("执行紧急关闭...") + + # 强制取消所有任务 + for task in self.background_tasks: + if not task.done(): + task.cancel() + + # 强制关闭WebSocket连接 + for conn_info in self.websocket_connections.values(): + try: + if not conn_info['ws'].closed: + conn_info['ws'].close() + except: + pass + + # 清理内存 + self.cache.clear() + self.websocket_connections.clear() + self.task_handles.clear() + + logger.critical("紧急关闭完成") + + except Exception as e: + logger.critical(f"紧急关闭时出错: {str(e)}") +``` + +#### 2.5.2 插件类核心方法详解 + +##### 2.5.2.1 生命周期管理方法 + +```python +class Plugin: + """ + 插件生命周期管理方法详解 + """ + + async def initialize(self) -> bool: + """ + 插件初始化 - 框架调用的主要入口点 + + 返回: + bool: 初始化是否成功 + + 执行流程: + 1. 基础设置和环境检查 + 2. 配置验证和加载 + 3. 服务管理器获取 + 4. 网络功能初始化 + 5. 事件处理器注册 + 6. 后台任务启动 + 7. 状态标记为运行中 + """ + try: + # 1. 环境检查 + if not await self._check_environment(): + logger.error("环境检查失败") + return False + + # 2. 配置验证 + if not await self._validate_config(): + logger.error("配置验证失败") + return False + + # 3. 服务管理器获取 + if not await self._setup_service_manager(): + logger.warning("服务管理器获取失败,部分功能受限") + + # 4. 网络功能初始化 + network_success = await self._initialize_network() + if not network_success: + logger.warning("网络功能初始化失败,将以受限模式运行") + + # 5. 事件处理器注册 + await self._register_event_handlers() + + # 6. 后台任务启动 + await self._start_background_tasks() + + # 7. 状态标记 + self.status.is_running = True + self.status.start_time = datetime.now() + + logger.info(f"✅ 插件初始化成功: {self.plugin_name}") + return True + + except Exception as e: + logger.error(f"❌ 插件初始化失败: {str(e)}") + logger.error(traceback.format_exc()) + await self._emergency_cleanup() + return False + + async def _check_environment(self) -> bool: + """检查运行环境""" + try: + # 检查Python版本 + import sys + if sys.version_info < (3, 8): + logger.error("需要Python 3.8或更高版本") + return False + + # 检查必要目录 + required_dirs = [ + f"plugins/{self.plugin_name}", + f"data/plugins/{self.plugin_name}", + f"logs/plugins/{self.plugin_name}" + ] + + for dir_path in required_dirs: + path = Path(dir_path) + if not path.exists(): + try: + path.mkdir(parents=True, exist_ok=True) + logger.debug(f"创建目录: {dir_path}") + except Exception as e: + logger.error(f"无法创建目录 {dir_path}: {str(e)}") + return False + + # 检查依赖包 + deps_ok = await self._check_dependencies() + if not deps_ok: + logger.error("依赖包检查失败") + return False + + return True + + except Exception as e: + logger.error(f"环境检查失败: {str(e)}") + return False + + async def _check_dependencies(self) -> bool: + """检查插件依赖""" + try: + dependencies = self.config.get('dependencies', {}) + required = dependencies.get('required', []) + optional = dependencies.get('optional', []) + + missing_required = [] + + for dep in required: + # 解析依赖字符串,如 "requests>=2.25.0" + package_name = dep.split('>=')[0].split('==')[0].split('<=')[0].strip() + + try: + import importlib + importlib.import_module(package_name) + logger.debug(f"依赖检查通过: {package_name}") + except ImportError: + missing_required.append(package_name) + logger.warning(f"缺少依赖包: {package_name}") + + if missing_required: + logger.error(f"缺少必需依赖: {', '.join(missing_required)}") + return False + + # 检查可选依赖 + for dep in optional: + package_name = dep.split('>=')[0].split('==')[0].split('<=')[0].strip() + try: + import importlib + importlib.import_module(package_name) + logger.debug(f"可选依赖可用: {package_name}") + except ImportError: + logger.info(f"可选依赖未安装: {package_name}") + + return True + + except Exception as e: + logger.error(f"依赖检查失败: {str(e)}") + return False +``` + +##### 2.5.2.2 配置管理方法 + +```python + async def _validate_config(self) -> bool: + """验证配置有效性""" + try: + # 基础配置验证 + required_fields = ['name', 'version', 'description', 'author'] + for field in required_fields: + if field not in self.config: + logger.error(f"缺少必需配置字段: {field}") + return False + + # 版本号格式验证 + version = self.config.get('version', '') + import re + if not re.match(r'^\d+\.\d+\.\d+(?:[-.]\w+)?$', version): + logger.error(f"版本号格式错误: {version}") + return False + + # 设置项验证 + settings = self.config.get('settings', {}) + if 'enabled' not in settings: + logger.warning("settings.enabled 未设置,使用默认值 True") + settings['enabled'] = True + + # 日志级别验证 + log_level = settings.get('log_level', 'INFO') + valid_levels = ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] + if log_level not in valid_levels: + logger.warning(f"无效的日志级别: {log_level},使用默认值 INFO") + settings['log_level'] = 'INFO' + + # 更新配置 + self.config['settings'] = settings + + # 功能配置验证 + features = self.config.get('features', {}) + if 'network' in features: + network_config = features['network'] + if network_config.get('enable_http', False) or network_config.get('enable_websocket', False): + if not network_config.get('enable_cors', True): + logger.warning("启用网络功能但禁用CORS可能导致跨域问题") + + logger.info("配置验证通过") + return True + + except Exception as e: + logger.error(f"配置验证失败: {str(e)}") + return False +``` + +#### 2.5.3 事件处理与命令注册 + +##### 2.5.3.1 事件处理系统 + +```python + async def _setup_event_system(self): + """设置事件处理系统""" + try: + # 创建事件队列 + self.event_queue = asyncio.Queue(maxsize=1000) + + # 启动事件处理器 + self.event_handler_task = asyncio.create_task( + self._event_handler_loop() + ) + self.background_tasks.append(self.event_handler_task) + + # 注册核心事件处理器 + await self._register_core_event_handlers() + + logger.info("事件处理系统已启动") + + except Exception as e: + logger.error(f"设置事件处理系统失败: {str(e)}") + + async def _event_handler_loop(self): + """事件处理循环""" + while self.status.is_running: + try: + # 从队列获取事件 + event = await self.event_queue.get() + + # 处理事件 + await self._process_event(event) + + # 标记任务完成 + self.event_queue.task_done() + + except asyncio.CancelledError: + logger.info("事件处理循环被取消") + break + except Exception as e: + logger.error(f"事件处理出错: {str(e)}") + await asyncio.sleep(1) # 出错后等待1秒 + + async def _process_event(self, event: dict): + """处理单个事件""" + try: + event_type = event.get('type') + event_data = event.get('data', {}) + + # 查找事件处理器 + handler = self.event_handlers.get(event_type) + + if handler: + # 执行处理器 + await handler(event_data) + else: + # 默认处理器 + await self._handle_unknown_event(event) + + except Exception as e: + logger.error(f"处理事件失败 {event.get('type', 'unknown')}: {str(e)}") + + async def _register_core_event_handlers(self): + """注册核心事件处理器""" + core_handlers = { + # 插件相关事件 + 'plugin.enable': self._handle_plugin_enable, + 'plugin.disable': self._handle_plugin_disable, + 'plugin.reload': self._handle_plugin_reload, + + # 用户相关事件 + 'user.created': self._handle_user_created, + 'user.deleted': self._handle_user_deleted, + 'user.updated': self._handle_user_updated, + + # 系统事件 + 'system.start': self._handle_system_start, + 'system.stop': self._handle_system_stop, + 'system.error': self._handle_system_error, + + # 自定义事件 + 'custom.notification': self._handle_custom_notification, + 'custom.alert': self._handle_custom_alert, + } + + # 注册到事件处理器映射 + self.event_handlers.update(core_handlers) + + # 订阅框架事件 + for event_type in core_handlers.keys(): + try: + await self.bridge.subscribe_event( + self.plugin_name, + event_type, + core_handlers[event_type] + ) + except Exception as e: + logger.warning(f"订阅事件失败 {event_type}: {str(e)}") +``` + +##### 2.5.3.2 命令注册与执行 + +```python + async def _register_commands(self): + """注册插件命令""" + try: + logger.info("开始注册插件命令...") + + # 扫描命令方法 + command_methods = [] + for attr_name in dir(self): + if attr_name.startswith('cmd_'): + method = getattr(self, attr_name) + if hasattr(method, '_is_plugin_command'): + command_methods.append(method) + + # 注册到框架 + for method in command_methods: + command_name = getattr(method, '_command_name', method.__name__[4:]) + description = getattr(method, '_command_description', method.__doc__ or '') + permissions = getattr(method, '_command_permissions', []) + + # 构建完整命令名 + full_command_name = f"{self.plugin_name}_{command_name}" + + # 注册命令 + await self.bridge.register_command( + self.plugin_name, + full_command_name, + method, + description, + permissions + ) + + logger.debug(f"命令注册: {full_command_name}") + + logger.info(f"命令注册完成,共 {len(command_methods)} 个命令") + + except Exception as e: + logger.error(f"命令注册失败: {str(e)}") + + async def _execute_command(self, command: str, args: list) -> str: + """执行命令的统一接口""" + try: + # 查找命令方法 + method_name = f"cmd_{command}" + if not hasattr(self, method_name): + return f"❌ 未知命令: {command}" + + method = getattr(self, method_name) + + # 检查是否是插件命令 + if not hasattr(method, '_is_plugin_command'): + return f"❌ 不是有效的插件命令: {command}" + + # 执行命令 + result = await method(*args) + return result + + except Exception as e: + logger.error(f"执行命令失败 {command}: {str(e)}") + return f"❌ 命令执行错误: {str(e)}" +``` + +#### 2.5.4 异常处理与资源管理 + +##### 2.5.4.1 异常处理框架 + +```python +class PluginExceptionHandler: + """插件异常处理器""" + + def __init__(self, plugin_instance): + self.plugin = plugin_instance + self.error_history = [] + self.max_error_history = 100 + + async def handle_exception(self, exception: Exception, context: str = "") -> dict: + """处理异常并返回用户友好的错误信息""" + try: + # 记录异常 + error_record = { + 'timestamp': datetime.now().isoformat(), + 'exception_type': type(exception).__name__, + 'exception_message': str(exception), + 'context': context, + 'traceback': traceback.format_exc() + } + + # 添加到历史 + self.error_history.append(error_record) + if len(self.error_history) > self.max_error_history: + self.error_history.pop(0) + + # 更新插件状态 + self.plugin.status.error_count += 1 + self.plugin.status.last_error = str(exception) + + # 根据异常类型处理 + if isinstance(exception, (PermissionError, PluginPermissionError)): + return self._handle_permission_error(exception, context) + elif isinstance(exception, (ConnectionError, TimeoutError)): + return self._handle_network_error(exception, context) + elif isinstance(exception, ValueError): + return self._handle_validation_error(exception, context) + elif isinstance(exception, FileNotFoundError): + return self._handle_file_error(exception, context) + else: + return self._handle_generic_error(exception, context) + + except Exception as e: + # 如果异常处理器本身出错 + logger.critical(f"异常处理器出错: {str(e)}") + return { + 'success': False, + 'error': '内部服务器错误', + 'message': '系统遇到意外错误' + } + + def _handle_permission_error(self, exception: Exception, context: str) -> dict: + """处理权限错误""" + logger.warning(f"权限错误 [{context}]: {str(exception)}") + return { + 'success': False, + 'error': '权限不足', + 'message': f'执行 {context} 需要特定权限', + 'details': str(exception) + } + + def _handle_network_error(self, exception: Exception, context: str) -> dict: + """处理网络错误""" + logger.error(f"网络错误 [{context}]: {str(exception)}") + return { + 'success': False, + 'error': '网络连接失败', + 'message': f'{context} 网络连接失败,请检查网络设置', + 'details': str(exception) + } + + def _handle_validation_error(self, exception: Exception, context: str) -> dict: + """处理验证错误""" + logger.warning(f"验证错误 [{context}]: {str(exception)}") + return { + 'success': False, + 'error': '输入验证失败', + 'message': f'{context} 输入数据无效', + 'details': str(exception) + } + + def _handle_file_error(self, exception: Exception, context: str) -> dict: + """处理文件错误""" + logger.error(f"文件错误 [{context}]: {str(exception)}") + return { + 'success': False, + 'error': '文件操作失败', + 'message': f'{context} 文件操作失败', + 'details': str(exception) + } + + def _handle_generic_error(self, exception: Exception, context: str) -> dict: + """处理通用错误""" + logger.error(f"通用错误 [{context}]: {str(exception)}") + return { + 'success': False, + 'error': '操作失败', + 'message': f'{context} 执行过程中发生错误', + 'details': str(exception) if self.plugin.config.get('debug', {}).get('show_detailed_errors', False) else '请联系系统管理员' + } +``` + +##### 2.5.4.2 资源管理与清理 + +```python +class PluginResourceManager: + """插件资源管理器""" + + def __init__(self, plugin_instance): + self.plugin = plugin_instance + self.resources = { + 'files': [], # 打开的文件 + 'connections': [], # 网络连接 + 'locks': [], # 锁资源 + 'tasks': [], # 后台任务 + 'cache': [] # 缓存资源 + } + + def register_resource(self, resource_type: str, resource, metadata: dict = None): + """注册资源""" + if resource_type not in self.resources: + self.resources[resource_type] = [] + + resource_record = { + 'resource': resource, + 'type': type(resource).__name__, + 'registered_at': datetime.now(), + 'metadata': metadata or {} + } + + self.resources[resource_type].append(resource_record) + + # 自动注册清理函数 + if hasattr(resource, 'close'): + self.plugin._cleanup_functions.append(resource.close) + elif hasattr(resource, 'cleanup'): + self.plugin._cleanup_functions.append(resource.cleanup) + + async def cleanup_all(self, force: bool = False): + """清理所有资源""" + cleanup_results = [] + + # 按逆序清理(后创建的先清理) + for resource_type in reversed(list(self.resources.keys())): + resources = self.resources[resource_type].copy() + + for resource_record in reversed(resources): + try: + result = await self._cleanup_resource(resource_record, force) + cleanup_results.append((resource_type, result)) + except Exception as e: + logger.error(f"清理资源失败 {resource_type}: {str(e)}") + cleanup_results.append((resource_type, False)) + + # 执行注册的清理函数 + for cleanup_func in self.plugin._cleanup_functions: + try: + if asyncio.iscoroutinefunction(cleanup_func): + await cleanup_func() + else: + cleanup_func() + except Exception as e: + logger.error(f"清理函数执行失败: {str(e)}") + + return cleanup_results + + async def _cleanup_resource(self, resource_record: dict, force: bool) -> bool: + """清理单个资源""" + resource = resource_record['resource'] + resource_type = resource_record['type'] + + try: + # 根据资源类型选择清理方式 + if resource_type == 'File': + if hasattr(resource, 'closed') and not resource.closed: + resource.close() + return True + + elif resource_type in ['Socket', 'Connection']: + if hasattr(resource, 'close'): + resource.close() + return True + + elif resource_type == 'Lock': + # 锁通常在上下文管理器中自动释放 + pass + + elif resource_type == 'Task': + if hasattr(resource, 'cancel') and not resource.done(): + if force: + resource.cancel() + return True + + elif resource_type == 'Cache': + if hasattr(resource, 'clear'): + resource.clear() + return True + + # 通用清理 + if hasattr(resource, 'close'): + resource.close() + elif hasattr(resource, 'disconnect'): + resource.disconnect() + elif hasattr(resource, 'shutdown'): + resource.shutdown() + + return True + + except Exception as e: + logger.warning(f"清理资源失败 {resource_type}: {str(e)}") + return False + + def get_resource_stats(self) -> dict: + """获取资源统计信息""" + stats = { + 'total_resources': 0, + 'by_type': {}, + 'memory_usage': self._estimate_memory_usage() + } + + for resource_type, resources in self.resources.items(): + stats['by_type'][resource_type] = len(resources) + stats['total_resources'] += len(resources) + + return stats + + def _estimate_memory_usage(self) -> int: + """估计内存使用量(粗略)""" + total_size = 0 + + # 遍历所有资源 + for resource_type, resources in self.resources.items(): + for resource_record in resources: + resource = resource_record['resource'] + + # 尝试获取大小 + try: + if hasattr(resource, '__sizeof__'): + total_size += resource.__sizeof__() + elif isinstance(resource, (str, bytes, bytearray)): + total_size += len(resource) + except: + pass + + return total_size +``` + +### 2.5.5 插件配置持久化与状态恢复 + +```python + async def save_state(self) -> bool: + """ + 保存插件状态 + + 保存内容包括: + 1. 当前配置 + 2. 运行状态 + 3. 缓存数据 + 4. 用户会话 + 5. 任务状态 + """ + try: + state_data = { + 'plugin_info': { + 'name': self.plugin_name, + 'version': self.PLUGIN_VERSION, + 'last_saved': datetime.now().isoformat() + }, + 'config': self.config, + 'status': { + 'is_running': self.status.is_running, + 'start_time': self.status.start_time.isoformat() if self.status.start_time else None, + 'request_count': self.status.request_count, + 'error_count': self.status.error_count, + 'last_error': self.status.last_error + }, + 'metrics': { + 'requests_per_second': self.metrics.requests_per_second, + 'average_response_time': self.metrics.average_response_time, + 'active_connections': self.metrics.active_connections, + 'cache_hit_rate': self.metrics.cache_hit_rate + }, + 'cache_summary': { + 'total_items': len(self.cache), + 'keys': list(self.cache.keys())[:50] # 只保存前50个键 + }, + 'background_tasks': [ + { + 'name': name, + 'status': 'running' if not task.done() else 'completed', + 'cancelled': task.cancelled() + } + for name, task in self.task_handles.items() + ] + } + + # 创建状态目录 + state_dir = Path(f"data/plugins/{self.plugin_name}/state") + state_dir.mkdir(parents=True, exist_ok=True) + + # 保存状态文件 + state_file = state_dir / f"state_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json" + backup_file = state_dir / "state_backup.json" + + # 先备份当前状态 + if backup_file.exists(): + backup_file.unlink() + + # 写入新状态 + with open(state_file, 'w', encoding='utf-8') as f: + json.dump(state_data, f, ensure_ascii=False, indent=2) + + # 创建软链接到最新状态 + latest_link = state_dir / "state_latest.json" + if latest_link.exists(): + latest_link.unlink() + latest_link.symlink_to(state_file.name) + + # 保留最近10个状态文件 + self._cleanup_old_state_files(state_dir) + + logger.info(f"插件状态已保存: {state_file}") + return True + + except Exception as e: + logger.error(f"保存插件状态失败: {str(e)}") + return False + + def _cleanup_old_state_files(self, state_dir: Path, keep_count: int = 10): + """清理旧的状态文件""" + try: + # 获取所有状态文件 + state_files = list(state_dir.glob("state_*.json")) + + # 按修改时间排序 + state_files.sort(key=lambda x: x.stat().st_mtime, reverse=True) + + # 删除超出保留数量的文件 + for state_file in state_files[keep_count:]: + try: + state_file.unlink() + logger.debug(f"清理旧状态文件: {state_file}") + except Exception as e: + logger.warning(f"无法清理状态文件 {state_file}: {str(e)}") + + except Exception as e: + logger.error(f"清理状态文件失败: {str(e)}") + + async def restore_state(self) -> bool: + """ + 恢复插件状态 + + 从保存的状态文件恢复: + 1. 恢复配置 + 2. 恢复缓存 + 3. 恢复任务状态 + 4. 恢复会话数据 + """ + try: + state_file = Path(f"data/plugins/{self.plugin_name}/state/state_latest.json") + + if not state_file.exists(): + logger.info("没有找到状态文件,使用默认状态") + return False + + # 读取状态文件 + with open(state_file, 'r', encoding='utf-8') as f: + state_data = json.load(f) + + # 验证状态文件 + if not self._validate_state_data(state_data): + logger.warning("状态文件验证失败,使用默认状态") + return False + + # 恢复配置 + if 'config' in state_data: + self.config.update(state_data['config']) + logger.info("配置已从状态文件恢复") + + # 恢复状态信息 + if 'status' in state_data: + status_data = state_data['status'] + self.status.request_count = status_data.get('request_count', 0) + self.status.error_count = status_data.get('error_count', 0) + logger.info("运行状态已恢复") + + # 恢复缓存 + if 'cache_summary' in state_data: + # 这里可以根据需要实现缓存的持久化和恢复 + logger.info("缓存摘要已加载") + + logger.info(f"插件状态已从 {state_file} 恢复") + return True + + except Exception as e: + logger.error(f"恢复插件状态失败: {str(e)}") + return False + + def _validate_state_data(self, state_data: dict) -> bool: + """验证状态数据有效性""" + try: + # 检查必需字段 + required_fields = ['plugin_info', 'config', 'status'] + for field in required_fields: + if field not in state_data: + logger.error(f"状态文件缺少必需字段: {field}") + return False + + # 验证插件信息 + plugin_info = state_data['plugin_info'] + if plugin_info.get('name') != self.plugin_name: + logger.error(f"状态文件插件名称不匹配: {plugin_info.get('name')}") + return False + + # 验证版本兼容性 + saved_version = plugin_info.get('version', '') + current_version = self.PLUGIN_VERSION + + # 简单的版本兼容性检查 + if saved_version.split('.')[0] != current_version.split('.')[0]: + logger.warning(f"主版本不匹配: 保存版本 {saved_version}, 当前版本 {current_version}") + # 主版本不同可能不兼容 + + return True + + except Exception as e: + logger.error(f"状态数据验证失败: {str(e)}") + return False +``` + + +### 2.6 插件 WebUI 页面 (v0.6.0) + +### 2.7 插件导入安装 (v0.7.0) + +框架支持在 WebUI 插件管理页直接导入安装插件: + +**ZIP 导入**:上传 .zip 包 → 自动解压到 plugins/ → pip install -r requirements.txt → 加载 + +**Git Clone 导入**:输入仓库 URL → git clone → pip install → 加载 + +安装过程在子进程中执行,失败不影响框架主进程。安装失败的插件在统计面板中显示。 + + +插件可以注册 WebUI 页面,自动出现在 SenSu 管理面板侧边栏的「插件面板」折叠项中。 + +#### 2.6.1 注册页面 + +```python +from sdk.plugin_web import PluginWebMixin + +class MyPlugin(PluginWebMixin): + async def initialize(self): + self.register_web_page( + path="mypanel", # 访问路径: /plugin/{plugin_name} + title="我的面板", # 侧边栏显示名称 + html_content="

Hello

", # 完整 HTML 页面 + icon="M" # 侧边栏图标 (单字符) + ) +``` + +#### 2.6.2 页面渲染机制 + +插件页面在 SenSu 主面板的 `page-container` 区域内联加载(不弹新窗口): + +1. 用户点击侧边栏「插件面板」→ 展开子项 → 点击插件页面 +2. 前端 fetch `/plugin/{plugin_name}` 获取完整 HTML +3. 渲染管线自动处理: + - 提取 ` +

插件面板

+ +
+

数据面板

+
0
+ +
+ +``` + +**常用 MD3 CSS 变量 (自动跟随日夜主题):** + +| 变量 | 用途 | +|------|------| +| `--bg` | 页面背景色 | +| `--bg-card` | 卡片/容器背景 | +| `--text` | 主文字色 | +| `--text-dim` | 次要文字色 | +| `--primary` | 主题色 | +| `--primary-container` | 主题色容器背景 | +| `--outline` | 边框色 | +| `--error` | 错误/危险色 | +| `--shape-xs` / `--shape-sm` / `--shape-md` | 圆角 (8/12/16px) | +| `--md-sys-elevation-1` ~ `--md-sys-elevation-5` | 阴影 | + +**常用 MD3 组件类:** + +| 类 | 用途 | +|------|------| +| `.card` | MD3 卡片容器 | +| `.card.outlined` | 带边框的卡片 | +| `.btn` + `.btn-filled` | 实心按钮 | +| `.btn` + `.btn-tonal` | 半透明按钮 | +| `.btn` + `.btn-outlined` | 轮廓按钮 | +| `.btn-sm` / `.btn-lg` | 按钮尺寸 (需配合 .btn) | +| `.input` | MD3 输入框 | +| `.input-group` | 输入框容器 (含 label) | +| `.badge` / `.badge-run` / `.badge-stop` | 状态徽章 | +| `.chip` / `.chip.active` | 标签/筛选 | + +#### 2.6.4 注意事项 + +| 事项 | 说明 | +|------|------| +| `body` 选择器 | v0.6.0 起自动替换为 `.plugin-page-root`,可放心使用 | +| 硬编码颜色 | ❌ `background:#000` / `color:#fff` → ✅ `var(--bg)` / `var(--text)` | +| 主题同步 | 无需额外代码,CSS 变量自动跟随 `data-theme` 属性切换 | +| `onclick` 函数 | 函数需在 `", + r"javascript:", + r"on\w+\s*=", + r"<\s*iframe", + r"<\s*img.*src\s*=", + r"<\s*a.*href\s*=" + ] + + import re + for pattern in xss_patterns: + if re.search(pattern, text, re.IGNORECASE): + return False, "检测到XSS攻击模式" + + return True, "" + + async def _sanitize_output(self, data): + """净化输出数据""" + if isinstance(data, dict): + return {k: await self._sanitize_output(v) for k, v in data.items()} + elif isinstance(data, list): + return [await self._sanitize_output(item) for item in data] + elif isinstance(data, str): + # 转义HTML特殊字符 + import html + return html.escape(data) + else: + return data + + def _generate_secure_token(self, length: int = 32) -> str: + """生成安全令牌""" + import secrets + return secrets.token_hex(length) + + def _hash_password(self, password: str) -> str: + """哈希密码""" + import hashlib + import os + + # 使用盐值 + salt = os.urandom(32) + key = hashlib.pbkdf2_hmac( + 'sha256', + password.encode('utf-8'), + salt, + 100000 # 迭代次数 + ) + return salt.hex() + key.hex() + + def _verify_password(self, password: str, hashed: str) -> bool: + """验证密码""" + import hashlib + + salt = bytes.fromhex(hashed[:64]) # 前64位是盐值 + key = hashlib.pbkdf2_hmac( + 'sha256', + password.encode('utf-8'), + salt, + 100000 + ) + return hashed[64:] == key.hex() +``` + +### 4.4 测试最佳实践 + +```python +import pytest +import pytest_asyncio +from unittest.mock import AsyncMock, Mock, patch + +class TestPlugin: + """插件测试基类""" + + @pytest_asyncio.fixture + async def plugin_instance(self): + """创建插件实例""" + config = { + "name": "TestPlugin", + "version": "1.0.0", + "settings": {"enabled": True} + } + + bridge_mock = AsyncMock() + bridge_mock.service_manager = Mock() + + plugin = Plugin("test_plugin", config, bridge_mock) + await plugin.initialize() + + yield plugin + + await plugin.shutdown() + + @pytest.mark.asyncio + async def test_plugin_initialization(self, plugin_instance): + """测试插件初始化""" + assert plugin_instance.status.is_running == True + assert plugin_instance.plugin_name == "test_plugin" + assert plugin_instance.config["name"] == "TestPlugin" + + @pytest.mark.asyncio + async def test_network_routes_registration(self, plugin_instance): + """测试网络路由注册""" + # 模拟网络桥接 + network_bridge_mock = AsyncMock() + plugin_instance.network_bridge = network_bridge_mock + + # 调用注册方法 + await plugin_instance._register_network_routes() + + # 验证注册调用 + assert network_bridge_mock.register_http_route.called + assert network_bridge_mock.register_websocket.called + + @pytest.mark.asyncio + async def test_command_execution(self, plugin_instance): + """测试命令执行""" + # 测试状态命令 + result = await plugin_instance.cmd_status() + assert "插件状态" in result + assert plugin_instance.plugin_name in result + + @pytest.mark.asyncio + async def test_error_handling(self, plugin_instance): + """测试错误处理""" + # 测试权限错误 + with pytest.raises(PluginPermissionError): + await plugin_instance._safe_execute( + self._raise_permission_error + ) + + # 测试网络错误 + with pytest.raises(PluginNetworkError): + await plugin_instance._safe_execute( + self._raise_network_error + ) + + def _raise_permission_error(self): + raise PermissionError("测试权限错误") + + def _raise_network_error(self): + raise ConnectionError("测试网络错误") + + @pytest.mark.asyncio + async def test_rate_limiting(self, plugin_instance): + """测试速率限制""" + # 模拟多次调用 + results = [] + for i in range(15): # 超过10次限制 + result = await plugin_instance._rate_limited_execute( + self._dummy_function, + rate_limit=10 + ) + results.append(result) + + # 验证所有调用都成功 + assert len(results) == 15 + assert all(r == "dummy_result" for r in results) + + async def _dummy_function(self): + await asyncio.sleep(0.01) + return "dummy_result" + + @pytest.mark.asyncio + async def test_cache_functionality(self, plugin_instance): + """测试缓存功能""" + # 第一次调用应该缓存 + result1 = await plugin_instance._cached_execute( + self._expensive_function, + ttl=60 + ) + + # 第二次调用应该从缓存获取 + result2 = await plugin_instance._cached_execute( + self._expensive_function, + ttl=60 + ) + + assert result1 == result2 + assert plugin_instance._performance_stats["cache_hits"] == 1 + assert plugin_instance._performance_stats["cache_misses"] == 1 + + async def _expensive_function(self): + await asyncio.sleep(0.1) + return {"data": "expensive_result"} + + @pytest.mark.parametrize("input_data,expected", [ + ("test@example.com", True), + ("invalid-email", False), + ("https://example.com", True), + ("javascript:alert(1)", False), + ("normal_text", True), + ]) + def test_input_validation(self, plugin_instance, input_data, expected): + """测试输入验证""" + validator = SecurePlugin() + + if expected: + # 应该通过验证 + assert validator._validate_input({"test": input_data}) == True + else: + # 应该抛出异常 + with pytest.raises(PluginError): + validator._validate_input({"test": input_data}) + +class IntegrationTest: + """集成测试""" + + @pytest_asyncio.fixture + async def framework_with_plugin(self): + """创建带插件的框架实例""" + from main import CatFramework + + framework = CatFramework() + + # 启动框架 + await framework.initialize() + + # 加载测试插件 + plugin_service = framework.service_manager.get_service("plugin") + await plugin_service.load_plugin("test_plugin") + + yield framework + + # 关闭框架 + await framework.shutdown() + + @pytest.mark.asyncio + async def test_plugin_integration(self, framework_with_plugin): + """测试插件与框架的集成""" + # 获取插件服务 + plugin_service = framework_with_plugin.service_manager.get_service("plugin") + + # 验证插件已加载 + assert "test_plugin" in plugin_service.plugins + + # 验证插件命令已注册 + command_service = framework_with_plugin.service_manager.get_service("command") + command_list = command_service.get_command_list() + + plugin_commands = [ + cmd for cmd in command_list + if cmd["source"].startswith("plugin.test_plugin") + ] + + assert len(plugin_commands) > 0 + + # 测试命令执行 + result = await command_service.process_command("test_plugin_status", "test") + assert "插件状态" in result + + @pytest.mark.asyncio + async def test_plugin_network_integration(self, framework_with_plugin): + """测试插件网络集成""" + import aiohttp + + # 获取网络服务 + internet_service = framework_with_plugin.service_manager.get_service("internet") + + # 测试HTTP API + async with aiohttp.ClientSession() as session: + url = f"http://localhost:{internet_service.http_port}/plugin/test_plugin/api/health" + async with session.get(url) as response: + assert response.status == 200 + data = await response.json() + assert data["status"] in ["healthy", "unhealthy"] +``` + +## 五、插件发布与部署 + +### 5.1 插件打包 + +```yaml +# setup.py 或 pyproject.toml 示例 +""" +插件打包配置 +""" + +# setup.py +from setuptools import setup, find_packages + +setup( + name="sensu-plugin-my-awesome-plugin", + version="1.0.0", + description="我的超棒插件", + author="开发者名字", + author_email="developer@example.com", + packages=find_packages(), + install_requires=[ + "requests>=2.25.0", + "pydantic>=1.8.0", + ], + extras_require={ + "redis": ["redis>=3.5.0"], + "mysql": ["aiomysql>=0.1.0"], + }, + package_data={ + "": ["*.yaml", "*.json", "*.md"], + }, + entry_points={ + "sensu.plugins": [ + "my_awesome_plugin = my_awesome_plugin:Plugin", + ], + }, + classifiers=[ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + ], +) + +# pyproject.toml +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "sensu-plugin-my-awesome-plugin" +version = "1.0.0" +description = "我的超棒插件" +authors = [ + {name = "开发者名字", email = "developer@example.com"} +] +dependencies = [ + "requests>=2.25.0", + "pydantic>=1.8.0" +] + +[project.optional-dependencies] +redis = ["redis>=3.5.0"] +mysql = ["aiomysql>=0.1.0"] + +[tool.setuptools] +include-package-data = true + +[tool.setuptools.package-data] +"*" = ["*.yaml", "*.json", "*.md"] + +[project.entry-points."sensu.plugins"] +"my_awesome_plugin" = "my_awesome_plugin:Plugin" +``` + +### 5.2 插件发布清单 + +```markdown +# 插件发布清单 + +## 1. 代码质量检查 +- [ ] 通过所有单元测试 +- [ ] 通过集成测试 +- [ ] 代码覆盖率 > 80% +- [ ] 通过静态代码分析 +- [ ] 通过安全扫描 + +## 2. 文档检查 +- [ ] README.md 完整 +- [ ] 配置说明文档 +- [ ] API文档 +- [ ] 使用示例 +- [ ] 更新日志 + +## 3. 配置检查 +- [ ] config.yaml 完整 +- [ ] permissions.yaml 完整 +- [ ] 默认配置合理 +- [ ] 配置验证通过 + +## 4. 依赖检查 +- [ ] 依赖版本明确 +- [ ] 无冲突依赖 +- [ ] 可选依赖标注清楚 +- [ ] 系统依赖说明 + +## 5. 打包检查 +- [ ] 打包脚本正确 +- [ ] 包含所有必要文件 +- [ ] 不包含敏感信息 +- [ ] 版本号正确 + +## 6. 性能检查 +- [ ] 内存使用合理 +- [ ] 启动时间 < 5秒 +- [ ] API响应时间 < 1秒 +- [ ] 支持并发请求 + +## 7. 安全检查 +- [ ] 输入验证完整 +- [ ] 输出净化 +- [ ] 权限控制 +- [ ] 无硬编码密码 +- [ ] 日志无敏感信息 +``` + +### 5.3 持续集成配置 + +```yaml +# .github/workflows/ci.yml +name: CI/CD + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.8", "3.9", "3.10"] + + steps: + - uses: actions/checkout@v3 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pytest pytest-asyncio pytest-cov + pip install -e . + + - name: Run tests + run: | + pytest tests/ --cov=my_awesome_plugin --cov-report=xml + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v3 + with: + file: ./coverage.xml + fail_ci_if_error: true + + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.10" + + - name: Install linting tools + run: | + pip install black flake8 mypy pylint + + - name: Run black + run: black --check . + + - name: Run flake8 + run: flake8 . + + - name: Run mypy + run: mypy my_awesome_plugin + + - name: Run pylint + run: pylint my_awesome_plugin + + security: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Run bandit + run: | + pip install bandit + bandit -r my_awesome_plugin -f json -o bandit-report.json + + - name: Run safety check + run: | + pip install safety + safety check + + build: + needs: [test, lint, security] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.10" + + - name: Build package + run: | + pip install build + python -m build + + - name: Upload artifacts + uses: actions/upload-artifact@v3 + with: + name: plugin-package + path: dist/ +``` + +## 六、插件调试与故障排除 + +### 6.1 调试工具 + +```python +class DebugPlugin: + """调试插件基类""" + + def __init__(self): + self._debug_enabled = False + self._debug_logs = [] + self._performance_probes = {} + + def enable_debug(self): + """启用调试模式""" + self._debug_enabled = True + logger.setLevel(logging.DEBUG) + + # 添加调试处理器 + debug_handler = logging.StreamHandler() + debug_handler.setLevel(logging.DEBUG) + debug_handler.setFormatter(logging.Formatter( + '%(asctime)s [DEBUG] %(name)s:%(lineno)d - %(message)s' + )) + logger.addHandler(debug_handler) + + def add_debug_log(self, message: str, data: dict = None): + """添加调试日志""" + if self._debug_enabled: + log_entry = { + "timestamp": datetime.now().isoformat(), + "message": message, + "data": data + } + self._debug_logs.append(log_entry) + + # 只保留最近的1000条日志 + if len(self._debug_logs) > 1000: + self._debug_logs.pop(0) + + async def _debug_probe(self, probe_name: str): + """调试探针""" + if not self._debug_enabled: + return + + start_time = time.time() + + def finish(): + end_time = time.time() + duration = end_time - start_time + + if probe_name not in self._performance_probes: + self._performance_probes[probe_name] = { + "count": 0, + "total_time": 0, + "min_time": float('inf'), + "max_time": 0, + "last_time": 0 + } + + stats = self._performance_probes[probe_name] + stats["count"] += 1 + stats["total_time"] += duration + stats["min_time"] = min(stats["min_time"], duration) + stats["max_time"] = max(stats["max_time"], duration) + stats["last_time"] = duration + + self.add_debug_log( + f"性能探针: {probe_name}", + {"duration": duration, "stats": stats} + ) + + return finish + + @plugin_command( + name="debug", + description="调试命令", + permissions=["plugin.my_awesome_plugin.admin"] + ) + async def cmd_debug(self, *args): + """调试命令""" + try: + if not args: + return ( + "🐛 **调试命令**\n" + "💡 使用: debug enable 启用调试模式\n" + "💡 使用: debug disable 禁用调试模式\n" + "💡 使用: debug logs [count] 查看调试日志\n" + "💡 使用: debug stats 查看性能统计\n" + "💡 使用: debug memory 查看内存使用\n" + "💡 使用: debug profile 性能分析\n" + ) + + command = args[0].lower() + + if command == "enable": + self.enable_debug() + return "✅ 调试模式已启用" + + elif command == "disable": + self._debug_enabled = False + return "🛑 调试模式已禁用" + + elif command == "logs": + count = 10 + if len(args) > 1: + try: + count = min(int(args[1]), 100) + except ValueError: + return "❌ 日志数量必须是数字" + + if not self._debug_logs: + return "📭 没有调试日志" + + result = [f"📋 **最近 {count} 条调试日志**"] + for log in self._debug_logs[-count:]: + result.append( + f"[{log['timestamp']}] {log['message']}" + ) + if log['data']: + result.append(f" 数据: {log['data']}") + + return "\n".join(result) + + elif command == "stats": + if not self._performance_probes: + return "📊 没有性能统计数据" + + result = ["📊 **性能统计**"] + for probe_name, stats in self._performance_probes.items(): + avg_time = stats["total_time"] / stats["count"] if stats["count"] > 0 else 0 + result.append( + f"{probe_name}: " + f"调用{stats['count']}次, " + f"平均{avg_time:.3f}秒, " + f"最小{stats['min_time']:.3f}秒, " + f"最大{stats['max_time']:.3f}秒" + ) + + return "\n".join(result) + + elif command == "memory": + import psutil + import os + + process = psutil.Process(os.getpid()) + memory_info = process.memory_info() + + result = ["💾 **内存使用**"] + result.append(f"RSS: {memory_info.rss / 1024 / 1024:.2f} MB") + result.append(f"VMS: {memory_info.vms / 1024 / 1024:.2f} MB") + result.append(f"共享内存: {memory_info.shared / 1024 / 1024:.2f} MB") + result.append(f"文本段: {memory_info.text / 1024 / 1024:.2f} MB") + result.append(f"数据段: {memory_info.data / 1024 / 1024:.2f} MB") + + # 插件特定内存 + result.append(f"缓存大小: {len(self.cache)} 项") + result.append(f"WebSocket连接: {len(self.websocket_connections)} 个") + + return "\n".join(result) + + elif command == "profile": + if len(args) < 2: + return "❌ 请指定要分析的命令,如: debug profile status" + + sub_command = args[1] + + # 执行性能分析 + import cProfile + import io + import pstats + + profiler = cProfile.Profile() + profiler.enable() + + try: + # 执行命令 + command_func = getattr(self, f"cmd_{sub_command}", None) + if command_func: + result = await command_func(*args[2:]) + else: + result = f"❌ 找不到命令: {sub_command}" + finally: + profiler.disable() + + # 分析结果 + s = io.StringIO() + ps = pstats.Stats(profiler, stream=s).sort_stats('cumulative') + ps.print_stats(20) # 显示前20个最耗时的函数 + + profile_result = s.getvalue() + + return f"📈 **性能分析结果**\n```\n{profile_result}\n```\n\n**命令结果:**\n{result}" + + else: + return f"❌ 未知调试命令: {command}" + + except Exception as e: + logger.error(f"调试命令执行失败: {str(e)}") + return f"❌ 调试命令错误: {str(e)}" +``` + +### 6.2 故障排除指南 + +```markdown +# 插件故障排除指南 + +## 1. 插件无法加载 + +### 症状 +- 插件没有出现在插件列表中 +- 日志显示插件加载失败 + +### 可能原因 +1. 目录结构不正确 +2. 配置文件缺失或格式错误 +3. 权限文件格式错误 +4. Python语法错误 +5. 依赖包缺失 + +### 解决方法 +1. 检查插件目录结构: + ``` + plugins/ + └── your_plugin/ + ├── __init__.py + ├── config.yaml + └── permissions.yaml + ``` + +2. 验证配置文件: + ```bash + python -c "import yaml; yaml.safe_load(open('config.yaml'))" + ``` + +3. 检查Python语法: + ```bash + python -m py_compile __init__.py + ``` + +4. 查看详细日志: + ```bash + tail -f logs/runtime/*.log + ``` + +## 2. 权限申请失败 + +### 症状 +- 插件以受限模式运行 +- 某些功能不可用 +- 日志显示权限被拒绝 + +### 可能原因 +1. 权限名称格式错误 +2. 权限描述不清晰 +3. 申请了过多或不必要的权限 +4. 用户拒绝了权限申请 + +### 解决方法 +1. 检查权限格式: + ```yaml + # 正确格式 + permissions: + - "plugin.your_plugin.read" + - "plugin.your_plugin.write" + + # 错误格式 + permissions: + - "read" # 缺少插件名前缀 + - "plugin.your_plugin.*" # 通配符可能被拒绝 + ``` + +2. 提供清晰的权限描述: + ```yaml + permission_descriptions: + plugin.your_plugin.read: "读取插件数据,不会修改任何内容" + plugin.your_plugin.write: "修改插件配置和数据" + ``` + +3. 分批申请权限: + ```yaml + # 第一次申请基础权限 + permissions: + - "plugin.your_plugin.read" + + # 后续根据需要申请更多权限 + ``` + +## 3. 网络功能不可用 + +### 症状 +- HTTP API返回404错误 +- WebSocket连接失败 +- 网络相关命令无法执行 + +### 可能原因 +1. 网络服务未启动 +2. 端口被占用 +3. 路由注册失败 +4. 权限不足 + +### 解决方法 +1. 检查网络服务状态: + ```bash + # 在框架中执行 + netdiag + ``` + +2. 检查端口占用: + ```bash + # Linux/Mac + lsof -i :8000 + + # Windows + netstat -ano | findstr :8000 + ``` + +3. 查看插件网络信息: + ```bash + # 在框架中执行 + your_plugin network + ``` + +4. 重新注册网络路由: + ```python + # 在插件中 + await self._register_network_routes() + ``` + +## 4. 性能问题 + +### 症状 +- 响应时间慢 +- 内存使用率高 +- CPU占用率高 + +### 可能原因 +1. 缓存未命中 +2. 数据库查询效率低 +3. 网络请求过多 +4. 内存泄漏 + +### 解决方法 +1. 启用性能监控: + ```bash + # 在框架中执行 + your_plugin debug stats + ``` + +2. 分析内存使用: + ```bash + your_plugin debug memory + ``` + +3. 优化数据库查询: + ```python + # 添加索引 + # 使用连接池 + # 批量操作 + ``` + +4. 实现缓存: + ```python + # 使用装饰器 + @cached(ttl=300) + async def get_data(self): + # 耗时操作 + pass + ``` + +## 5. 内存泄漏 + +### 症状 +- 内存使用持续增长 +- 重启后恢复正常 +- 长时间运行后变慢 + +### 可能原因 +1. 未关闭的资源(文件、连接等) +2. 循环引用 +3. 缓存无限增长 +4. 事件监听器未移除 + +### 解决方法 +1. 使用资源上下文管理器: + ```python + async with open_file() as f: + # 使用文件 + pass # 自动关闭 + ``` + +2. 定期清理缓存: + ```python + async def _cleanup_expired_cache(self): + # 清理过期缓存 + pass + ``` + +3. 使用弱引用: + ```python + import weakref + + self._callbacks = weakref.WeakSet() + ``` + +4. 监控内存使用: + ```python + import tracemalloc + + tracemalloc.start() + # ... 运行代码 ... + snapshot = tracemalloc.take_snapshot() + top_stats = snapshot.statistics('lineno') + ``` + +## 6. 日志调试 + +### 启用详细日志 +```python +# 在插件配置中 +settings: + log_level: "DEBUG" +``` + +### 查看插件特定日志 +```bash +# 查找插件相关日志 +grep "your_plugin" logs/runtime/*.log + +# 实时查看日志 +tail -f logs/runtime/latest.log | grep "your_plugin" +``` + +### 添加自定义日志 +```python +logger.debug("详细调试信息", extra={"data": your_data}) +logger.info("一般信息") +logger.warning("警告信息") +logger.error("错误信息", exc_info=True) +``` + +## 7. 联系支持 + +如果以上方法都无法解决问题: + +1. 收集以下信息: + - 插件版本 + - 框架版本 + - 错误日志 + - 复现步骤 + - 系统信息 + +2. 提交问题报告: + - GitHub Issues + - 社区论坛 + - 邮件支持 + +3. 提供最小复现示例: + ```python + # 简化的代码示例 + # 能够重现问题的最小代码 + ``` +``` + +## 七、插件开发检查清单 + +### 7.1 开发前检查清单 + +- [ ] **需求分析** + - [ ] 明确插件功能需求 + - [ ] 确定目标用户群体 + - [ ] 分析使用场景 + - [ ] 定义成功标准 + +- [ ] **技术选型** + - [ ] 选择合适的技术栈 + - [ ] 评估依赖包兼容性 + - [ ] 确定性能要求 + - [ ] 制定安全策略 + +- [ ] **架构设计** + - [ ] 设计插件模块结构 + - [ ] 规划API接口 + - [ ] 设计数据模型 + - [ ] 制定错误处理策略 + +### 7.2 开发中检查清单 + +- [ ] **代码质量** + - [ ] 遵循PEP 8编码规范 + - [ ] 添加类型提示 + - [ ] 编写文档字符串 + - [ ] 实现单元测试 + +- [ ] **功能实现** + - [ ] 实现核心功能 + - [ ] 添加错误处理 + - [ ] 实现日志记录 + - [ ] 添加配置选项 + +- [ ] **安全性** + - [ ] 验证所有输入 + - [ ] 净化所有输出 + - [ ] 实现权限控制 + - [ ] 避免敏感信息泄露 + +### 7.3 测试检查清单 + +- [ ] **单元测试** + - [ ] 测试所有公开方法 + - [ ] 测试错误处理 + - [ ] 测试边界条件 + - [ ] 测试异步方法 + +- [ ] **集成测试** + - [ ] 测试插件加载 + - [ ] 测试命令执行 + - [ ] 测试网络功能 + - [ ] 测试事件处理 + +- [ ] **性能测试** + - [ ] 测试响应时间 + - [ ] 测试内存使用 + - [ ] 测试并发处理 + - [ ] 测试资源清理 + +### 7.4 发布检查清单 + +- [ ] **文档** + - [ ] 编写README.md + - [ ] 编写API文档 + - [ ] 编写使用示例 + - [ ] 编写更新日志 + +- [ ] **打包** + - [ ] 创建打包配置 + - [ ] 包含必要文件 + - [ ] 设置版本号 + - [ ] 添加依赖声明 + +- [ ] **验证** + - [ ] 在新环境中测试 + - [ ] 验证安装过程 + - [ ] 测试升级流程 + - [ ] 确认卸载清理 + +## 八、文件管理器集成 (v0.6.0 新增) + +### 8.1 文件管理器 Picker API + +插件可通过 postMessage 接口调用文件管理器选择器,让用户在 WebUI 中快速选择目录或文件路径。无需插件自行实现文件浏览界面。 + +#### 8.1.1 权限要求 + +插件需要在 permissions.yaml 中声明文件管理相关权限: + +```yaml +permissions: + - "filemanager.picker" # 调用文件选择器 + - "filemanager.access" # 浏览/读取文件 + - "filemanager.write" # 写入/删除文件 + - "filemanager.*" # 完整权限 +``` + +| 权限 | 级别 | 说明 | +|------|------|------| +| `filemanager.picker` | 基础 | 唤起文件选择器悬浮窗 | +| `filemanager.access` | 读取 | 浏览目录、读取文件内容 | +| `filemanager.write` | 写入 | 创建/删除/重命名/上传/写入 | +| `filemanager.*` | 管理 | 完整文件管理访问 | + +#### 8.1.2 调用文件选择器 + +插件 WebUI 页面通过 window.open 弹出选择器窗口: + +```javascript +// 选择目录 +var picker = window.open( + '/SenSu/static/pages/files.html?picker=1&mode=dir', + 'fm-picker', 'width=680,height=520' +); + +// 选择文件 +var picker = window.open( + '/SenSu/static/pages/files.html?picker=1&mode=file', + 'fm-picker', 'width=680,height=520' +); + +// 监听选择结果 +window.addEventListener('message', function(e) { + try { + var data = JSON.parse(e.data); + if (data.action === 'fm-picked' && data.path) { + console.log('用户选择的路径:', data.path); + // 将路径发送到插件后端进行处理 + } + } catch(ex) {} +}); +``` + +选择器关闭时通过 postMessage 返回: {"action":"fm-picked","path":"/选择的/路径"} + +#### 8.1.3 便捷调用: window.pickPath (v0.6.0 新增) + +SenSu WebUI 提供全局函数 pickPath(),任何页面可直接调用唤出文件选择器: + +```html + + + + + +``` + +无需手动管理 postMessage,回调直接拿到路径字符串。适用于项目管理、配置页面等任何需要路径选择的场景。 + +#### 8.1.4 后端 REST API 参考 + +插件后端可直接调用文件管理 API(需声明对应权限): + +| 方法 | 端点 | 说明 | +|------|------|------| +| GET | /api/files/list?path=&show_hidden=0 | 列出目录内容 | +| POST | /api/files/mkdir {path,name} | 创建目录 | +| POST | /api/files/touch {path,name} | 创建空文件 | +| GET | /api/files/read?path= | 读取文本(<=1MB) | +| POST | /api/files/write {path,content} | 写入文本 | +| POST | /api/files/delete {path} | 删除文件/目录 | +| POST | /api/files/rename {path,new_name} | 重命名 | +| POST | /api/files/upload (multipart) | 上传(<=50MB) | +| GET | /api/files/download?path= | 下载文件 | +| GET | /api/files/info?path= | 文件信息 | +| GET | /api/files/picker?mode=file&path= | 选择器模式 | + +安全: 文件管理器可浏览整个文件系统,路径穿越攻击会被拦截。 + +## 九、总结 + +### 9.1 成功插件的特点 + +1. **可靠性**:稳定运行,正确处理各种异常情况 +2. **易用性**:简洁的API,清晰的文档,直观的配置 +3. **安全性**:严格的输入验证,完善的权限控制 +4. **性能**:高效的处理能力,合理的内存使用 +5. **可维护性**:清晰的代码结构,完善的测试覆盖 +6. **可扩展性**:支持插件间的协作,易于功能扩展 + +### 8.2 持续改进 + +1. **收集反馈**:积极收集用户反馈,了解使用痛点 +2. **监控使用**:通过日志和指标了解插件使用情况 +3. **定期更新**:修复bug,添加新功能,优化性能 +4. **保持兼容**:确保新版本与旧版本的兼容性 +5. **社区参与**:参与插件生态建设,分享经验 + +### 8.3 资源推荐 + +1. **学习资源** + - Python官方文档 + - asyncio官方文档 + - Textual框架文档 + - aiohttp文档 + +2. **工具推荐** + - **代码质量**:black, flake8, mypy, pylint + - **测试框架**:pytest, pytest-asyncio, coverage + - **性能分析**:cProfile, memory_profiler, line_profiler + - **打包工具**:setuptools, poetry, hatch + +3. **社区支持** + - GitHub Issues:报告问题和功能请求 + - 论坛社区:交流开发经验 + - Stack Overflow:解决具体技术问题 + - 开发者群组:实时交流和协作 + +通过遵循本指南,您可以开发出高质量的SenSu插件,为用户提供有价值的功能,同时为插件生态系统做出贡献。祝您开发顺利! +--- + +## 九、v0.2.1 新增 API(已实现) + +### 9.1 PluginStatus 状态枚举 + +```python +from core.plugin_status import PluginStatus + +# 插件生命周期状态 +PluginStatus.UNLOADED # "unloaded" +PluginStatus.LOADING # "loading" +PluginStatus.LOADED # "loaded" +PluginStatus.RUNNING # "running" +PluginStatus.ERROR # "error" +PluginStatus.STOPPING # "stopping" +PluginStatus.STOPPED # "stopped" +``` + +用法:`plugin_service.plugin_status["my_plugin"] = PluginStatus.RUNNING` + +### 9.2 PluginError 异常层级 + +```python +from core.plugin_error import ( + PluginError, # 基础异常 + PluginLoadError, # 加载失败 + PluginConfigError, # 配置错误 + PluginPermissionError, # 权限不足 + PluginCommandError, # 命令执行错误 +) + +# 抛出 +raise PluginConfigError("缺少必需配置项: name", plugin_name="my_plugin") +``` + +### 9.3 PluginBridge.subscribe_plugin() + +```python +# 便捷订阅方法(同时注册到 CoreBridge 和插件订阅表) +self.bridge.subscribe_plugin("test.event", self._handler, "my_plugin") +``` + +### 9.4 PluginNetworkBridge 跨插件数据 + +```python +# 设置数据接收通道 +await self.network_bridge.setup_data_transfer("status_update", self._on_data) + +# 向其他插件发送数据 +await self.network_bridge.send_data("target_plugin", "status_update", {"key": "val"}) +``` + +### 9.5 配置验证 + +框架在插件加载时自动检查: +- `config.yaml` 必须存在且可解析 +- 必需字段:`name`, `version` +- 版本号格式建议遵循 semver diff --git a/docs/SenSu 框架基本架构.md b/docs/SenSu 框架基本架构.md new file mode 100644 index 0000000..f3946ca --- /dev/null +++ b/docs/SenSu 框架基本架构.md @@ -0,0 +1,483 @@ + +## 一、项目架构与设计思路 + +### 1.1 核心设计理念 + +SenSu框架采用了**服务化、插件化、事件驱动**的设计理念: + +- **模块化服务架构**:每个功能都是一个独立服务(Service),通过ServiceManager统一管理 +- **异步优先**:全面采用`asyncio`,支持高并发处理 +- **插件隔离**:插件有独立的命名空间和权限控制 +- **桥接通信**:通过消息桥接实现模块间解耦通信 + +### 1.2 框架启动流程 + +``` +main.py → SenSuFramework.initialize() → 依次初始化15+个核心服务 +``` + +**启动顺序**: +1. InitService(配置加载) +2. LogService(日志系统) +3. CoreBridge(核心消息桥接) +4. CommandService(命令系统) +5. AuthService(认证系统) +6. PluginBridge(插件桥接) +7. ShutdownService(优雅关闭) +8. TuiService(终端界面) +9. PermissionService(权限管理) +10. InternetService(网络服务) +11. PluginService(插件管理) +12. APIService(API服务) + +## 二、目录结构详细分析 + +### 2.1 核心目录说明 + +``` +SenSu-Alpha0.2/ +├── main.py # 框架主入口,定义CatFramework类 +├── service_manager.py # 服务管理器(全局服务注册表) +├── requirements.txt # Python依赖包 +├── README.md # 项目文档 +│ +├── bridges/ # 消息桥接系统 +│ ├── __init__.py +│ ├── core_bridge.py # 核心模块间通信(发布-订阅模式) +│ ├── plugin_bridge.py # 插件间通信 +│ └── plugin_network_bridge.py # 插件网络桥接 +│ +├── services/ # 核心服务模块(核心业务逻辑) +│ ├── init_service.py # 框架初始化 +│ ├── log_service.py # 日志服务(多输出、文件切割) +│ ├── tui_service.py # TUI终端界面(基于Textual) +│ ├── command_service.py # 命令处理系统 +│ ├── auth_service.py # 认证系统 +│ ├── internet_service.py # 网络服务(HTTP+WebSocket) +│ ├── plugin_service.py # 插件管理器 +│ ├── permission_service.py # 权限验证器 +│ ├── api_service.py # API端点管理 +│ └── shutdown_service.py # 优雅关闭 +│ +├── core/ # 框架功能集(工具函数) +│ └── plugin_command_decorator.py # 插件命令装饰器 +│ +├── utils/ # 通用工具类 +│ ├── file_utils.py # 文件操作 +│ ├── config_utils.py # 配置管理 +│ ├── validation_utils.py # 数据验证 +│ ├── network_utils.py # 网络工具 +│ └── plugin_utils.py # 插件工具 +│ +├── config/ # 运行时配置文件 +│ ├── framework/ # 框架核心配置 +│ ├── plugins/ # 插件配置 +│ ├── services/ # 服务配置 +│ └── permissions/ # 权限配置 +│ +├── plugins/ # 插件目录 +│ └── example_plugin/ # 示例插件 +│ +└── gui/ # GUI接口(预留) + └── api.py # Web API接口 +``` + +### 2.3 配置管理系统 + +框架使用**两级配置**: +1. **Base Config** (`config/framework/base_config.yaml`): 框架基础配置 +2. **Runtime Config**: 运行时动态生成的配置(保存在config目录) + +## 三、核心模块深度解析 + +### 3.1 服务管理器(ServiceManager) + +**作用**:全局服务注册表,实现依赖注入 +```python +# 注册服务 +service_manager.register_service("log", log_service) + +# 获取服务 +log_service = service_manager.get_service("log") +``` + +### 3.2 桥接系统(Bridges) + +**核心设计**: +- **CoreBridge**: 模块间通信,支持`MessageType`枚举 +- **PluginBridge**: 插件间通信,支持`PluginMessageType`枚举 +- **消息格式**:topic + data + timestamp + +**消息类型**: +```python +class MessageType(Enum): + EVENT = "event" # 事件通知 + COMMAND = "command" # 命令执行 + DATA = "data" # 数据传输 + STATUS = "status" # 状态更新 + ERROR = "error" # 错误报告 +``` + +### 3.3 日志系统(LogService) + +**特性**: +- 支持控制台和文件双输出 +- 按级别分离(runtime/debug) +- 自动文件切割和清理 +- 日志消费者模式(TUI实时显示) +- 彩色日志输出 + +**配置示例**: +```yaml +logging: + level: DEBUG + debug_level_file: true + max_file_size: 10MB + max_log_files: 20 +``` + +### 3.4 TUI界面(TuiService) + +**基于Textual框架的三栏布局**: +1. **日志区域**(4fr): 显示所有日志输出 +2. **消息区域**(5fr): 显示系统消息和命令结果 +3. **输入区域**(1fr): 命令输入框 + +**特性**: +- 实时日志捕获和显示 +- 命令自动补全(预留) +- 滚动控制(自动/手动) +- 彩色消息显示 + +### 3.5 网络服务(InternetService) + +**功能**: +- HTTP服务器(aiohttp) +- WebSocket服务器 +- 插件路由自动注册 +- 反向代理支持(预留) + +**端口配置**: +```yaml +internet: + websocket: + port: 8765 + http: + port: 8000 +``` + +### 3.6 插件系统(PluginService) + +**关键特性**: +- 热加载/卸载 +- 权限隔离 +- 命令自动注册 +- 错误隔离(插件崩溃不影响框架) +- 网络路由自动注册 + +## 四、插件开发详解 + +### 4.1 插件目录结构 + +``` +plugins/ +└── example_plugin/ + ├── __init__.py # 插件主类(必须包含Plugin类) + ├── config.yaml # 插件配置 + └── permissions.yaml # 权限申请 +``` + +### 4.2 插件主类模板 + +```python +class Plugin: + def __init__(self, plugin_name: str, config: Dict, bridge): + self.plugin_name = plugin_name + self.config = config + self.bridge = bridge # PluginBridge实例 + self.network_bridge = None # PluginNetworkBridge实例 + + async def initialize(self): + """插件初始化""" + # 1. 创建网络桥接 + self.network_bridge = PluginNetworkBridge(...) + + # 2. 注册网络路由 + await self.network_bridge.register_http_route(...) + await self.network_bridge.register_websocket(...) + + # 3. 注册事件处理器 + self.bridge.subscribe_plugin(...) + + # 使用装饰器注册命令 + @plugin_command(name="mycmd", description="我的命令") + async def my_command(self, *args): + return "命令执行结果" + + async def shutdown(self): + """插件关闭""" + # 清理资源 +``` + +### 4.3 权限申请文件(permissions.yaml) + +```yaml +plugin_name: "example_plugin" +permissions: + - "plugin.example.read" + - "plugin.example.write" + - "plugin.example.execute" + - "framework.event.subscribe" + - "framework.command.execute" +``` + +### 4.4 插件配置文件(config.yaml) + +```yaml +name: "ExamplePlugin" +version: "1.0.0" +description: "插件描述" +author: "作者名" + +settings: + enabled: true + auto_start: true + log_level: "INFO" + +features: + # 插件特有配置 +``` + +### 4.5 插件命令装饰器 + +框架提供了`@plugin_command`装饰器: + +```python +from core.plugin_command_decorator import plugin_command + +@plugin_command(name="echo", description="回显消息") +async def cmd_echo(self, *args): + return " ".join(args) + +# 简化版 +@plugin_command() +async def hello(self, *args): + '''打招呼命令''' + return "Hello World!" +``` + +### 4.6 插件网络功能 + +**HTTP路由注册**: +```python +await self.network_bridge.register_http_route( + "/api/info", + self._handle_api_info, + methods=["GET"], + require_auth=False +) +``` + +**WebSocket注册**: +```python +await self.network_bridge.register_websocket( + "/chat", + self._handle_websocket_chat +) +``` + +## 五、命令系统详解 + +### 5.1 内置命令 + +框架提供丰富的内置命令: +- `help` - 显示帮助 +- `status` - 框架状态 +- `history` - 命令历史 +- `testlog` - 测试日志生成 +- `netdiag` - 网络诊断 +- `permissions` - 权限管理 +- `scroll` - 滚动控制 +- `autoscroll` - 自动滚动开关 + +### 5.2 权限管理命令 + +框架提供完整的权限管理命令集(pm前缀): +- `pmallow` - 同意权限请求 +- `pmdeny` - 拒绝权限请求 +- `pmignore` - 忽略权限请求 +- `permissions` - 显示权限状态 +- `pmpending` - 查看待授权请求 +- `pmhelp` - 权限命令帮助 + +### 5.3 命令注册机制 + +**插件命令注册流程**: +1. PluginService扫描插件方法 +2. 识别`@plugin_command`装饰器 +3. 注册到CommandService +4. 命令格式:`命令名 [参数...]` + +## 六、开发建议与最佳实践 + +### 6.1 插件开发建议 + +1. **错误处理**:插件内应妥善处理异常,避免影响框架 +2. **资源管理**:在`shutdown`方法中清理所有资源 +3. **异步安全**:确保异步方法正确处理取消和超时 +4. **权限最小化**:只申请必要的权限 + +### 6.2 性能优化 + +1. **异步IO**:所有网络和文件操作使用异步版本 +2. **连接池**:数据库/网络连接使用连接池 +3. **缓存机制**:频繁读取的数据适当缓存 +4. **懒加载**:大型资源按需加载 + +### 6.3 安全性考虑 + +1. **输入验证**:所有外部输入都应验证 +2. **权限验证**:敏感操作前检查权限 +3. **日志脱敏**:避免在日志中记录敏感信息 +4. **API限流**:防止API被滥用 + +## 七、框架优势与特点 + +### 7.1 优势 +1. **完整的生态**:日志、网络、UI、插件系统一应俱全 +2. **良好的扩展性**:插件系统设计完善 +3. **生产级质量**:完善的错误处理和日志记录 +4. **开发者友好**:详细的文档和示例插件 + +### 7.2 适用场景 +1. **后台管理工具**:需要终端界面的管理工具 +2. **API网关**:插件化路由和认证 +3. **自动化平台**:可扩展的任务调度和执行 +4. **监控系统**:实时数据展示和告警 + +### 7.3 技术栈亮点 +- **异步架构**:asyncio全面应用 +- **现代化UI**:基于Textual的TUI +- **微服务理念**:服务化模块设计 +- **企业级特性**:权限、认证、日志一应俱全 + +## 八、后续发展建议 + +### 8.1 功能增强 +1. **数据库支持**:添加ORM或数据库连接池 +2. **任务队列**:集成Celery或类似系统 +3. **监控指标**:集成Prometheus指标导出 +4. **配置文件热重载**:支持运行时配置更新 + +### 8.2 易用性改进 +1. **插件市场**:在线插件安装和管理 +2. **配置生成器**:图形化配置界面 +3. **调试工具**:集成调试和性能分析 +4. **文档生成**:自动生成API文档 + +### 8.3 生态建设 +1. **插件模板**:快速创建插件的脚手架 +2. **测试框架**:插件测试工具 +3. **CI/CD集成**:自动化测试和部署 +4. **社区建设**:建立插件开发者社区 + +## 总结 + +SenSu框架是一个设计精良、功能完整的Python后端框架,具有以下核心价值: + +1. **工程化设计**:服务化架构、完善的错误处理、详细的日志 +2. **强大的插件系统**:支持热加载、权限隔离、网络路由自动注册 +3. **现代化的用户体验**:基于Textual的TUI界面,美观实用 +4. **企业级特性**:完整的权限管理、认证系统、网络服务 + +框架代码结构清晰,文档详细,适合作为: +- 企业级后台系统的基础框架 +- 插件化应用的核心引擎 +- 学习和研究现代Python框架设计的优秀案例 + +对于想要基于此框架进行开发的开发者,建议从`example_plugin`入手,逐步理解框架的各个组件,然后根据业务需求开发定制插件。 +--- + +## 九、v0.2.1 新增:插件系统增强 + +### 9.1 PluginStatus 枚举 (`core/plugin_status.py`) +统一的插件生命周期状态: +- `UNLOADED` → `LOADING` → `LOADED` → `INITIALIZING` → `RUNNING` +- 异常路径: `ERROR`, `STOPPING`, `STOPPED`, `UNLOADING` + +### 9.2 PluginError 异常层级 (`core/plugin_error.py`) +``` +PluginError (基础) +├── PluginLoadError (加载失败) +├── PluginConfigError (配置错误) +├── PluginPermissionError (权限不足) +├── PluginCommandError (命令执行错误) +└── PluginNetworkError (网络操作错误) +``` + +### 9.3 PluginBridge 新方法 +- `subscribe_plugin(topic, handler, plugin_name)` — 便捷订阅,同时注册到 CoreBridge 和插件订阅表 + +### 9.4 PluginNetworkBridge 新方法 +- `setup_data_transfer(event_type, handler)` — 跨插件数据传输通道 +- `send_data(target_plugin, event_type, data)` — 向其他插件发送数据 + +### 9.5 PluginService 增强 +- 配置验证:加载时检查 `name`、`version` 必需字段 +- 状态追踪:`plugin_status` 字典跟踪每个插件的 `PluginStatus` + +## 十、v0.6.0 新增:WebUI 重构 + 文件管理器 + +### 10.1 Web 面板路由 (services/web_panel/) + +v0.6.0 将 Web 面板重构为模块化路由架构: + +``` +services/web_panel/ +├── manager.py # WebPanelManager — 路由注册 + 静态挂载 +├── routes/ +│ ├── auth.py # 认证 API (login/logout/status) +│ ├── status.py # 框架/系统状态 + WS 实时推送 +│ ├── plugins.py # 插件管理 API +│ ├── commands.py # 命令执行 API +│ ├── logs.py # 日志 WS 广播 +│ ├── projects.py # 项目引擎 API (run/stop/logs/stdin) +│ ├── proxy.py # 反向代理 API +│ ├── plugin_web.py # 插件 Web 页面动态路由 +│ └── files.py # 文件管理器 API (11 个端点) ← NEW +└── utils/ + ├── auth.py # panel_auth 装饰器 + └── system_info.py # 跨平台系统信息采集 +``` + +### 10.2 文件管理器 (v0.6.0) + +- **后端**: `services/web_panel/routes/files.py` — 11 个 REST 端点 + - 列表/新建/删除/重命名/上传/下载/读写/文件信息 + - 全文件系统访问,`os.path.normpath` 防路径穿越 + - 跨平台: Windows 盘符枚举 + Linux `/` 根 + - 符号链接: `stat()` 跟踪 → `lstat()` fallback 处理损坏 +- **前端**: `pages/files.html` + `pages/files.js` + - 面包屑导航 (每层级可点击) + 可编辑路径跳转 + - 双层面包屑 (逻辑路径 + 物理路径,符号链接目录) + - 右键菜单 (下载/重命名/编辑/删除) + - 文本编辑器 (全屏覆盖层) + - 文件上传 (多文件, ≤50MB) + - `..` 行返回上级 +- **插件接口**: Picker API (`/api/files/picker`) + - 插件通过 `window.open` + `postMessage` 唤出选择器 + - 权限: `filemanager.picker` / `filemanager.access` / `filemanager.write` + +### 10.3 WebSocket 实时推送 + +- `/api/system/ws` — 每 2s 推送系统 + 框架数据 (替代 HTTP 轮询) +- `/api/logs/ws` — 日志广播,前端批量渲染 + 500 行上限防卡死 +- 仪表盘: DualLineChart 双线图 (下载实线/上传虚线),Y 轴零点偏移 + +### 10.4 MD3 主题系统 + +- CSS 变量完整 MD3 color tokens (Dark + Light 双主题) +- `data-theme` 属性切换,localStorage 持久化 +- 按钮组合模式: `btn` + `btn-tonal`/`btn-outlined`/`btn-filled` + `btn-sm` +- SVG 全局 `fill: currentColor` 自适应主题色 +- 动画: 波纹/卡片悬浮/页面淡入/状态脉冲/骨架屏 diff --git a/gui/api.py b/gui/api.py new file mode 100644 index 0000000..46f9d76 --- /dev/null +++ b/gui/api.py @@ -0,0 +1,362 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import logging +import asyncio +from typing import Dict, List, Any, Optional +from aiohttp import web +import json + +logger = logging.getLogger(__name__) + +class GUIAPI: + """GUI API接口 - 为未来的GUI界面提供操作接口""" + + def __init__(self, service_manager): + self.service_manager = service_manager + self.app = web.Application() + self.setup_routes() + logger.debug("GUIAPI初始化开始") + + def setup_routes(self): + """设置API路由""" + try: + # 框架状态接口 + self.app.router.add_get('/api/framework/status', self.handle_framework_status) + self.app.router.add_get('/api/framework/health', self.handle_framework_health) + + # 日志接口 + self.app.router.add_get('/api/logs/recent', self.handle_recent_logs) + self.app.router.add_get('/api/logs/stream', self.handle_log_stream) + + # 插件接口 + self.app.router.add_get('/api/plugins', self.handle_plugins_list) + self.app.router.add_post('/api/plugins/{plugin_name}/reload', self.handle_plugin_reload) + self.app.router.add_post('/api/plugins/{plugin_name}/unload', self.handle_plugin_unload) + + # 命令接口 + self.app.router.add_post('/api/command', self.handle_command_execute) + self.app.router.add_get('/api/command/history', self.handle_command_history) + + # 服务接口 + self.app.router.add_get('/api/services', self.handle_services_list) + self.app.router.add_get('/api/services/{service_name}/status', self.handle_service_status) + + logger.debug("GUI API路由设置完成") + + except Exception as e: + logger.error(f"设置GUI API路由时出错: {str(e)}", exc_info=True) + raise + + async def handle_framework_status(self, request): + """处理框架状态请求""" + try: + logger.debug("处理框架状态API请求") + + status_info = { + "framework": { + "name": "CatFramework", + "version": "1.0.0", + "status": "running", + "uptime": "0s" # 实际应该计算运行时间 + }, + "services": { + "total": 0, + "running": 0 + }, + "plugins": { + "total": 0, + "loaded": 0 + } + } + + # 获取服务状态 + try: + service_manager = self.service_manager.get_service("service_manager") + if service_manager: + status_info["services"]["total"] = len(service_manager.services) + status_info["services"]["running"] = len(service_manager.services) + except: + pass + + # 获取插件状态 + try: + plugin_service = self.service_manager.get_service("plugin") + if plugin_service: + status_info["plugins"]["total"] = len(plugin_service.plugin_info) + status_info["plugins"]["loaded"] = len(plugin_service.plugins) + except: + pass + + return web.json_response(status_info) + + except Exception as e: + logger.error(f"处理框架状态请求时出错: {str(e)}", exc_info=True) + return web.json_response({"error": str(e)}, status=500) + + async def handle_framework_health(self, request): + """处理框架健康检查""" + try: + logger.debug("处理框架健康检查API请求") + + health_data = { + "status": "healthy", + "timestamp": asyncio.get_event_loop().time(), + "components": { + "core_services": "healthy", + "plugins": "healthy", + "network": "healthy" + } + } + + return web.json_response(health_data) + + except Exception as e: + logger.error(f"处理框架健康检查时出错: {str(e)}", exc_info=True) + return web.json_response({"status": "unhealthy", "error": str(e)}, status=503) + + async def handle_recent_logs(self, request): + """处理最近日志请求""" + try: + logger.debug("处理最近日志API请求") + + count = int(request.query.get('count', 50)) + level = request.query.get('level', '') + + log_service = self.service_manager.get_service("log") + if not log_service: + return web.json_response({"error": "Log service not available"}, status=503) + + logs = log_service.get_recent_logs(count) + + # 按级别过滤 + if level: + logs = [log for log in logs if log['level'].lower() == level.lower()] + + return web.json_response({"logs": logs, "count": len(logs)}) + + except Exception as e: + logger.error(f"处理最近日志请求时出错: {str(e)}", exc_info=True) + return web.json_response({"error": str(e)}, status=500) + + async def handle_log_stream(self, request): + """处理日志流请求(SSE)""" + try: + logger.debug("处理日志流API请求") + + response = web.StreamResponse() + response.headers['Content-Type'] = 'text/event-stream' + response.headers['Cache-Control'] = 'no-cache' + response.headers['Connection'] = 'keep-alive' + + await response.prepare(request) + + # 这里应该实现真正的日志流 + # 暂时发送测试数据 + try: + while True: + test_log = { + "timestamp": asyncio.get_event_loop().time(), + "level": "INFO", + "message": "Log stream test message", + "source": "gui_api" + } + + event_data = f"data: {json.dumps(test_log)}\n\n" + await response.write(event_data.encode('utf-8')) + await asyncio.sleep(5) + + except asyncio.CancelledError: + logger.debug("日志流连接关闭") + finally: + await response.write_eof() + + return response + + except Exception as e: + logger.error(f"处理日志流请求时出错: {str(e)}", exc_info=True) + return web.json_response({"error": str(e)}, status=500) + + async def handle_plugins_list(self, request): + """处理插件列表请求""" + try: + logger.debug("处理插件列表API请求") + + plugin_service = self.service_manager.get_service("plugin") + if not plugin_service: + return web.json_response({"error": "Plugin service not available"}, status=503) + + plugins_info = [] + for plugin_name, plugin_info in plugin_service.plugin_info.items(): + plugins_info.append({ + "name": plugin_info.name, + "version": plugin_info.version, + "description": plugin_info.description, + "author": plugin_info.author, + "enabled": plugin_info.enabled, + "loaded": plugin_info.loaded, + "error_count": plugin_info.error_count, + "permissions": plugin_info.permissions + }) + + return web.json_response({"plugins": plugins_info}) + + except Exception as e: + logger.error(f"处理插件列表请求时出错: {str(e)}", exc_info=True) + return web.json_response({"error": str(e)}, status=500) + + async def handle_plugin_reload(self, request): + """处理插件重载请求""" + try: + plugin_name = request.match_info['plugin_name'] + logger.debug(f"处理插件重载API请求: {plugin_name}") + + plugin_service = self.service_manager.get_service("plugin") + if not plugin_service: + return web.json_response({"error": "Plugin service not available"}, status=503) + + # 先卸载再加载 + unload_success = await plugin_service.unload_plugin(plugin_name) + if unload_success: + load_success = await plugin_service.load_plugin(plugin_name) + result = {"reloaded": load_success} + else: + result = {"reloaded": False, "error": "Unload failed"} + + return web.json_response(result) + + except Exception as e: + logger.error(f"处理插件重载请求时出错: {str(e)}", exc_info=True) + return web.json_response({"error": str(e)}, status=500) + + async def handle_plugin_unload(self, request): + """处理插件卸载请求""" + try: + plugin_name = request.match_info['plugin_name'] + logger.debug(f"处理插件卸载API请求: {plugin_name}") + + plugin_service = self.service_manager.get_service("plugin") + if not plugin_service: + return web.json_response({"error": "Plugin service not available"}, status=503) + + success = await plugin_service.unload_plugin(plugin_name) + return web.json_response({"unloaded": success}) + + except Exception as e: + logger.error(f"处理插件卸载请求时出错: {str(e)}", exc_info=True) + return web.json_response({"error": str(e)}, status=500) + + async def handle_command_execute(self, request): + """处理命令执行请求""" + try: + data = await request.json() + command = data.get('command', '') + source = data.get('source', 'gui') + + logger.debug(f"处理命令执行API请求: {command}") + + command_service = self.service_manager.get_service("command") + if not command_service: + return web.json_response({"error": "Command service not available"}, status=503) + + result = await command_service.process_command(command, source) + + return web.json_response({ + "command": command, + "result": str(result), + "success": True + }) + + except Exception as e: + logger.error(f"处理命令执行请求时出错: {str(e)}", exc_info=True) + return web.json_response({"error": str(e)}, status=500) + + async def handle_command_history(self, request): + """处理命令历史请求""" + try: + limit = int(request.query.get('limit', 10)) + logger.debug(f"处理命令历史API请求,限制: {limit}") + + command_service = self.service_manager.get_service("command") + if not command_service: + return web.json_response({"error": "Command service not available"}, status=503) + + history = command_service.get_command_history(limit) + return web.json_response({"history": history}) + + except Exception as e: + logger.error(f"处理命令历史请求时出错: {str(e)}", exc_info=True) + return web.json_response({"error": str(e)}, status=500) + + async def handle_services_list(self, request): + """处理服务列表请求""" + try: + logger.debug("处理服务列表API请求") + + service_manager = self.service_manager.get_service("service_manager") + if not service_manager: + return web.json_response({"error": "Service manager not available"}, status=503) + + services_info = [] + for name, service in service_manager.services.items(): + services_info.append({ + "name": name, + "type": type(service).__name__, + "status": "running" # 简化状态 + }) + + return web.json_response({"services": services_info}) + + except Exception as e: + logger.error(f"处理服务列表请求时出错: {str(e)}", exc_info=True) + return web.json_response({"error": str(e)}, status=500) + + async def handle_service_status(self, request): + """处理服务状态请求""" + try: + service_name = request.match_info['service_name'] + logger.debug(f"处理服务状态API请求: {service_name}") + + try: + service = self.service_manager.get_service(service_name) + status_info = { + "name": service_name, + "available": True, + "status": "running" + } + + # 可以添加特定服务的状态检查 + if hasattr(service, 'is_running'): + status_info["status"] = "running" if service.is_running else "stopped" + + return web.json_response(status_info) + + except ValueError: + return web.json_response({"error": f"Service {service_name} not found"}, status=404) + + except Exception as e: + logger.error(f"处理服务状态请求时出错: {str(e)}", exc_info=True) + return web.json_response({"error": str(e)}, status=500) + + async def start(self, host: str = "localhost", port: int = 8080): + """启动GUI API服务器""" + try: + logger.info(f"启动GUI API服务器: {host}:{port}") + runner = web.AppRunner(self.app) + await runner.setup() + site = web.TCPSite(runner, host, port) + await site.start() + logger.debug("GUI API服务器启动完成") + return runner + except Exception as e: + logger.error(f"启动GUI API服务器时出错: {str(e)}", exc_info=True) + raise + + async def shutdown(self): + """关闭GUI API""" + try: + logger.info("关闭GUI API") + # 清理资源 + logger.debug("GUI API关闭完成") + except Exception as e: + logger.error(f"关闭GUI API时出错: {str(e)}", exc_info=True) \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..32a4244 --- /dev/null +++ b/main.py @@ -0,0 +1,551 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import logging +import asyncio +import sys +import signal +import time +import argparse +from services.project_engine import ProjectEngine +from services.pyenv_manager import PyEnvManager +from services.proxy_service import ProxyService +from services.web_panel.utils.system_info import SystemInfoCollector +from services.sensu_db import SenSuDB +from services.update_service import UpdateService +from services.update_service import UpdateService +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 SenSuFramework: + """框架主类""" + + def __init__(self, headless=False): + self.service_manager = ServiceManager() + self.is_running = False + self.headless = headless + logger.debug("🐱 SenSu 框架初始化开始") + + 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) + + # 2.5 自动启动脚本 (日志服务就绪后) + await init_service.start_auto_scripts() + + # 2.6 数据库 (持久化层) + sensu_db = SenSuDB() + self.service_manager.register_service("sensu_db", sensu_db) + + # 2.7 系统信息采集器 (共享实例,TUI + Web面板共用) + sys_collector = SystemInfoCollector() + self.service_manager.register_service("sys_collector", sys_collector) + + # 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服务 + if self.headless: + logger.info("Headless mode, skip TUI") + self.service_manager.register_service("tui", self._create_fallback_tui()) + else: + try: + tui_service = TuiService( + base_config, log_service, command_service, + sys_collector=sys_collector, + ) + 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() + 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) + + # 将 plugin_service 注入 TUI 插件状态面板 + tui = self.service_manager.get_service("tui") + if hasattr(tui, 'tui_app') and tui.tui_app: + tui.tui_app.plugin_panel.set_plugin_service(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) + # 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) + + logger.info("> 初始化 反向代理 中...") + proxy_service = ProxyService(self.service_manager) + await proxy_service.start() + self.service_manager.register_service("proxy", proxy_service) + logger.info("✅ 项目引擎+反向代理就绪") + + logger.info("> 初始化 更新检测 中...") + update_service = UpdateService(self.service_manager) + await update_service.start() + self.service_manager.register_service("update", update_service) + logger.info("✅ 更新检测就绪") + + except Exception as e: + logger.warning(f"引擎/代理初始化跳过: {e}") + + logger.info("🎉 SenSu 初始化完成!") + self.service_manager.start_time = time.time() + 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("🐱 SenSu 框架 已就绪!\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("进入框架主循环") + + # 特殊终端,添加命令行输入处理 + tui = self.service_manager.get_service("tui") + if self.headless: + import os as _os + restart_flag = _os.path.join(_os.path.dirname(__file__), ".restart_flag") + while self.is_running: + if _os.path.exists(restart_flag): + logger.info("🔄 检测到重启标记, 执行优雅重启...") + _os.remove(restart_flag) + await self._restart_framework() + break + try: await asyncio.sleep(1) + except asyncio.CancelledError: break + elif not hasattr(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 _restart_framework(self): + """优雅重启: 关闭所有服务后退出 (由 systemd/supervisor 自动拉起)""" + logger.info("🔄 执行优雅重启...") + await self.shutdown() + import sys as _sys + _sys.exit(0) + + 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() + +def rollback(): + """回滚到最新备份""" + import zipfile, shutil, os, glob + backup_dir = os.path.join(os.path.dirname(__file__), "data", "backups") + if not os.path.isdir(backup_dir): + print("📭 没有备份目录") + return + backups = sorted(glob.glob(os.path.join(backup_dir, "SenSu_backup_*.zip")), key=os.path.getmtime, reverse=True) + if not backups: + print("📭 没有可用的备份包") + return + latest = backups[0] + name = os.path.basename(latest) + print(f"📦 回滚到: {name}") + try: + import tempfile + with tempfile.TemporaryDirectory() as tmp: + with zipfile.ZipFile(latest, "r") as zf: + zf.extractall(tmp) + project_root = os.path.dirname(__file__) + for item in os.listdir(tmp): + src = os.path.join(tmp, item) + dst = os.path.join(project_root, item) + if os.path.isdir(src): + shutil.copytree(src, dst, dirs_exist_ok=True) + else: + shutil.copy2(src, dst) + print("✅ 回滚完成, 请重启 SenSu") + except Exception as e: + print(f"❌ 回滚失败: {e}") + + +async def main(headless=False): + """主函数""" + framework = SenSuFramework(headless=headless) + + 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🐱 主程序启动...") + # 运行主程序 + parser = argparse.ArgumentParser(description="SenSu") + parser.add_argument("--headless", action="store_true") + parser.add_argument("--rollback", action="store_true", help="回滚到最新备份") + args = parser.parse_args() + + if args.rollback: + rollback() + sys.exit(0) + + asyncio.run(main(headless=args.headless)) + + 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/plugins/example_plugin/__init__.py b/plugins/example_plugin/__init__.py new file mode 100644 index 0000000..09f3730 --- /dev/null +++ b/plugins/example_plugin/__init__.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +import logging, os +from typing import Dict +from aiohttp import web +try: + from sdk.plugin_command_decorator import plugin_command, command + from sdk.plugin_web import PluginWebMixin +except ImportError: + def plugin_command(n=None,d=None,p=None): + def deco(f):f._is_plugin_command=True;f._command_name=n or f.__name__;return f + return deco + class PluginWebMixin: + def register_web_page(self,*a,**k):pass + def register_api_route(self,*a,**k):pass + +logger=logging.getLogger(__name__) + +class Plugin(PluginWebMixin): + def __init__(self, plugin_name=None, config=None, bridge=None): + PluginWebMixin.__init__(self) + self.plugin_name=plugin_name or "example" + self.config=config or {} + self.bridge=bridge + self.network_bridge=None + self.is_running=False + + async def initialize(self): + logger.info(f"init: {self.plugin_name}") + try: + internet=self.bridge.service_manager.get_service("internet") + from bridges.plugin_network_bridge import PluginNetworkBridge + self.network_bridge=PluginNetworkBridge(self.plugin_name,internet,self.bridge) + await self.network_bridge.register_http_route( + "/api/example/info",self._api_info,methods=["GET"],require_auth=False) + except Exception as e: + logger.warning(f"network skip: {e}") + # Load dashboard HTML from file + html_path=os.path.join(os.path.dirname(__file__),"dashboard.html") + if os.path.exists(html_path): + with open(html_path) as hf: + self.register_web_page("example_plugin","Example Plugin",hf.read(),icon="P") + self.is_running=True + + async def _api_info(self,req): + return web.json_response({"plugin":self.plugin_name,"status":"running"}) + + @plugin_command(name="echo",description="echo input") + async def cmd_echo(self,*args): + return " ".join(args) if args else "echo: no input" + + @plugin_command(name="plugin_status",description="show status") + async def cmd_status(self,*args): + return f"{self.plugin_name} v{self.config.get('version','?')} - running" + + async def shutdown(self): + self.is_running=False diff --git a/plugins/example_plugin/config.yaml b/plugins/example_plugin/config.yaml new file mode 100644 index 0000000..4c8c4b5 --- /dev/null +++ b/plugins/example_plugin/config.yaml @@ -0,0 +1,17 @@ +# 示例插件配置 +name: "ExamplePlugin" +version: "1.0.0" +description: "这是一个示例插件,用于演示插件系统" +author: "CatFramework Team" + +# 插件特定配置 +settings: + enabled: true + auto_start: true + log_level: "INFO" + isolation: false + +# 示例功能配置 +features: + echo_enabled: true + greeting_message: "🐱 你好喵~" diff --git a/plugins/example_plugin/dashboard.html b/plugins/example_plugin/dashboard.html new file mode 100644 index 0000000..63332e6 --- /dev/null +++ b/plugins/example_plugin/dashboard.html @@ -0,0 +1,34 @@ +Example + +

Example Plugin Panel

+
+

Counter

+
0
+
+ + +
+
+
+

Events (SSE)

+
Waiting...
+
+ \ No newline at end of file diff --git a/plugins/example_plugin/permissions.yaml b/plugins/example_plugin/permissions.yaml new file mode 100644 index 0000000..7f04592 --- /dev/null +++ b/plugins/example_plugin/permissions.yaml @@ -0,0 +1,16 @@ +# 示例插件权限申请 +plugin_name: "example_plugin" +permissions: + - "plugin.example.read" + - "plugin.example.write" + - "plugin.example.execute" + - "framework.event.subscribe" + - "framework.command.execute" + +# 权限说明 +permission_descriptions: + plugin.example.read: "读取示例插件数据" + plugin.example.write: "写入示例插件数据" + plugin.example.execute: "执行示例插件操作" + framework.event.subscribe: "订阅框架事件" + framework.command.execute: "执行框架命令" diff --git a/plugins/sentinel/__init__.py b/plugins/sentinel/__init__.py new file mode 100644 index 0000000..06c860d --- /dev/null +++ b/plugins/sentinel/__init__.py @@ -0,0 +1,272 @@ +#!/usr/bin/env python3 +"""🛡️ Sentinel — 多节点 SenSu 集群性能监控插件""" +import logging +import asyncio +import os +import time +import shutil +import yaml +import urllib.request +import json as _json +from pathlib import Path +from typing import Dict, List + +try: + from sdk.plugin_command_decorator import plugin_command + from sdk.plugin_web import PluginWebMixin +except ImportError: + def plugin_command(n=None, d=None, p=None): + def deco(f): f._is_plugin_command = True; f._command_name = n or f.__name__; return f + return deco + class PluginWebMixin: + def register_web_page(self, *a, **k): pass + def push_sse_event(self, *a, **k): pass + async def handle_sse(self, r): pass + +logger = logging.getLogger(__name__) + +_NODE_CACHE: Dict[str, dict] = {} + + +class Plugin(PluginWebMixin): + def __init__(self, plugin_name=None, config=None, bridge=None): + PluginWebMixin.__init__(self) + self.plugin_name = plugin_name or "sentinel" + self.config = config or {} + self.bridge = bridge + self.network_bridge = None + self._poll_task = None + self._running = False + self._config_path = os.path.join(os.path.dirname(__file__), "config.yaml") + + async def initialize(self): + logger.info(f"🛡️ Sentinel 初始化: {self.plugin_name}") + try: + internet = self.bridge.service_manager.get_service("internet") + from bridges.plugin_network_bridge import PluginNetworkBridge + self.network_bridge = PluginNetworkBridge(self.plugin_name, internet, self.bridge) + except Exception as e: + logger.warning(f"Sentinel network skip: {e}") + + # 注册 WebUI 页面 (key 必须匹配 plugin_name 才能在侧边栏访问) + dash_path = os.path.join(os.path.dirname(__file__), "dashboard.html") + if os.path.exists(dash_path): + with open(dash_path) as f: + self.register_web_page("sentinel", "🛡️ Sentinel", f.read(), icon="S") + + # 注册 API 路由 + if self.network_bridge: + for method, path, handler in [ + ("GET", "/api/nodes", self._handle_nodes), + ("POST", "/api/nodes", self._handle_add_node), + ("POST", "/api/nodes/test", self._handle_test_node), + ("POST", "/api/nodes/delete", self._handle_delete_node), + ]: + await self.network_bridge.register_http_route( + path, handler, methods=[method], require_auth=True + ) + + # 启动后台轮询 + interval = self.config.get("settings", {}).get("poll_interval", 3) + self._running = True + self._poll_task = asyncio.create_task(self._poll_loop(interval)) + logger.info(f"🛡️ Sentinel 就绪 (轮询间隔: {interval}s)") + + # ── 配置读写 ── + + def _read_config(self) -> dict: + try: + with open(self._config_path, "r") as f: + return yaml.safe_load(f) or {} + except FileNotFoundError: + # 从模板创建 + template = self._config_path + ".example" + if os.path.exists(template): + import shutil + shutil.copy(template, self._config_path) + with open(self._config_path, "r") as f: + return yaml.safe_load(f) or {} + return {} + except Exception: + return {} + + def _get_nodes(self) -> List[dict]: + return self._read_config().get("nodes", []) + + def _save_nodes(self, nodes: List[dict]): + cfg = self._read_config() + cfg["nodes"] = nodes + with open(self._config_path, "w") as f: + yaml.dump(cfg, f, default_flow_style=False, allow_unicode=True) + + # ── HTTP handlers ── + + async def _handle_nodes(self, request): + from aiohttp import web + nodes = self._get_nodes() + result = [] + for n in nodes: + c = _NODE_CACHE.get(n.get("id", ""), {}) + result.append({ + "id": n.get("id", ""), "name": n.get("name", ""), "url": n.get("url", ""), + "notes": n.get("notes", ""), + "base_path": n.get("base_path", "/SenSu"), + "enabled": n.get("enabled", True), + "online": c.get("online", False), + "system": c.get("system", {}), + "platform": c.get("platform", {}), + "last_seen": c.get("last_seen", 0), + "error": c.get("error", ""), + }) + return web.json_response({"nodes": result}) + + async def _handle_add_node(self, request): + from aiohttp import web + try: + import secrets as _secrets + data = await request.json() + nodes = self._get_nodes() + node_id = data.get("id") or f"node-{_secrets.token_hex(4)}" + node = { + "id": node_id, + "name": data.get("name", "").strip(), + "url": data.get("url", "").strip().rstrip("/"), + "api_key": data.get("api_key", "").strip(), + "base_path": data.get("base_path", "/SenSu").strip() or "/SenSu", + "notes": data.get("notes", "").strip(), + "enabled": data.get("enabled", True), + } + if not node["name"] or not node["url"]: + return web.json_response({"ok": False, "error": "名称和 URL 不能为空"}, status=400) + existing = [i for i, n in enumerate(nodes) if n.get("id") == node_id] + if existing: + old = nodes[existing[0]] + # 保留未填写的敏感字段 + if not node["api_key"]: + node["api_key"] = old.get("api_key", "") + nodes[existing[0]] = node + else: + nodes.append(node) + self._save_nodes(nodes) + _NODE_CACHE.pop(node_id, None) + logger.info(f"🛡️ 节点已保存: {node['name']} ({node['url']})") + return web.json_response({"ok": True, "node": node}) + except Exception as e: + return web.json_response({"ok": False, "error": str(e)}, status=500) + + async def _handle_delete_node(self, request): + from aiohttp import web + try: + data = await request.json() + node_id = data.get("id", "") + nodes = [n for n in self._get_nodes() if n.get("id") != node_id] + self._save_nodes(nodes) + _NODE_CACHE.pop(node_id, None) + logger.info(f"🛡️ 节点已删除: {node_id}") + return web.json_response({"ok": True}) + except Exception as e: + return web.json_response({"ok": False, "error": str(e)}, status=500) + + async def _handle_test_node(self, request): + from aiohttp import web + try: + data = await request.json() + raw_url = data.get("url", "").strip() + if not raw_url: + return web.json_response({"ok": False, "error": "URL 为空"}, status=400) + # SSRF 防护: 仅允许 HTTP/HTTPS + from urllib.parse import urlparse + parsed = urlparse(raw_url) + if parsed.scheme not in ("http", "https"): + return web.json_response({"ok": False, "error": "仅允许 HTTP/HTTPS"}, status=403) + if not parsed.hostname: + return web.json_response({"ok": False, "error": "无效的 URL"}, status=400) + node = {"url": raw_url.rstrip("/"), "api_key": data.get("api_key", "").strip()} + ok, info, err = await self._fetch_system(node) + return web.json_response({"ok": ok, "system": info, "error": err}) + except Exception as e: + return web.json_response({"ok": False, "error": str(e)}, status=500) + + # ── 后台轮询 ── + + async def _poll_loop(self, interval: int): + while self._running: + nodes = self._get_nodes() + for node in nodes: + if not node.get("enabled", True): + continue + online, sys_info, err = await self._fetch_system(node) + _NODE_CACHE[node.get("id", "")] = { + "online": online, + "system": sys_info or {}, + "platform": (sys_info or {}).get("platform", {}), + "last_seen": time.time(), + "error": err if not online else "", + } + # 每次轮询都推送 SSE (确保实时性) + if nodes: + await self.push_sse_event("nodes_update", {"nodes": self._build_cache_list()}) + await asyncio.sleep(interval) + + async def _fetch_system(self, node: dict): + """获取远程系统状态 — 用线程池避免同事件循环 HTTP 死锁""" + url = node.get("url", "") + api_key = node.get("api_key", "") + base = node.get("base_path", "/SenSu") + timeout = self.config.get("settings", {}).get("request_timeout", 5) + + def _sync_fetch(): + try: + headers = {"Authorization": f"Bearer {api_key}"} if api_key else {} + req = urllib.request.Request(f"{url}{base}/api/system", headers=headers) + with urllib.request.urlopen(req, timeout=timeout) as resp: + if resp.status == 200: + return True, _json.loads(resp.read().decode()), "" + return False, None, f"HTTP {resp.status}" + except Exception as e: + return False, None, str(e)[:120] + + return await asyncio.get_event_loop().run_in_executor(None, _sync_fetch) + + def _build_cache_list(self) -> list: + nodes = self._get_nodes() + result = [] + for n in nodes: + c = _NODE_CACHE.get(n.get("id", ""), {}) + result.append({ + "id": n.get("id", ""), "name": n.get("name", ""), "url": n.get("url", ""), + "notes": n.get("notes", ""), + "base_path": n.get("base_path", "/SenSu"), + "enabled": n.get("enabled", True), + "online": c.get("online", False), + "system": c.get("system", {}), + "platform": c.get("platform", {}), + "last_seen": c.get("last_seen", 0), + "error": c.get("error", ""), + }) + return result + + async def handle_sse(self, request): + return await PluginWebMixin.handle_sse(self, request) + + # ── TUI 命令 ── + + @plugin_command(name="sentinel", description="Sentinel 集群状态") + async def cmd_sentinel(self, *args): + nodes = self._get_nodes() + if not nodes: + return "🛡️ Sentinel: 无配置节点" + lines = ["🛡️ Sentinel 集群状态:", "=" * 40] + for n in nodes: + c = _NODE_CACHE.get(n.get("id", ""), {}) + icon = "🟢" if c.get("online") else "🔴" + cpu = c.get("system", {}).get("cpu", {}).get("percent", "?") + mem = c.get("system", {}).get("memory", {}).get("percent", "?") + lines.append(f" {icon} {n['name']} CPU:{cpu}% MEM:{mem}%") + return "\n".join(lines) + + async def shutdown(self): + self._running = False + if self._poll_task: + self._poll_task.cancel() + logger.info("🛡️ Sentinel 已关闭") diff --git a/plugins/sentinel/config.yaml.example b/plugins/sentinel/config.yaml.example new file mode 100644 index 0000000..e98526e --- /dev/null +++ b/plugins/sentinel/config.yaml.example @@ -0,0 +1,10 @@ +author: SenSu Team +description: 🛡️ 多节点 SenSu 集群性能监控 — 实时仪表盘 + 节点管理 +name: Sentinel +nodes: [] +settings: + auto_start: true + isolation: false + poll_interval: 2 + request_timeout: 5 +version: 1.0.0 diff --git a/plugins/sentinel/dashboard.html b/plugins/sentinel/dashboard.html new file mode 100644 index 0000000..a1a33e0 --- /dev/null +++ b/plugins/sentinel/dashboard.html @@ -0,0 +1,611 @@ +
+
+

🛡️ Sentinel 集群监控

+
+ + + | + +
+
+ + +
+
+
加载中...
+
+
+ + + + + + + + + + +
diff --git a/plugins/sentinel/permissions.yaml b/plugins/sentinel/permissions.yaml new file mode 100644 index 0000000..6ec0339 --- /dev/null +++ b/plugins/sentinel/permissions.yaml @@ -0,0 +1,13 @@ +plugin_name: "sentinel" +permissions: + - "plugin.sentinel.read" + - "plugin.sentinel.write" + - "plugin.network.access" + - "framework.event.subscribe" + - "framework.command.execute" + +permission_descriptions: + plugin.sentinel.read: "读取哨兵监控数据和节点配置" + plugin.sentinel.write: "修改哨兵节点配置" + framework.event.subscribe: "订阅框架事件" + framework.command.execute: "执行哨兵管理命令" diff --git a/plugins/sentinel/settings.html b/plugins/sentinel/settings.html new file mode 100644 index 0000000..0290af5 --- /dev/null +++ b/plugins/sentinel/settings.html @@ -0,0 +1,131 @@ +
+
+

🛡️ 节点管理

+ +
+ +
+ + + + + + + +
diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f2da1f7 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +rich>=13.0.0 +textual>=0.47.0 +websockets>=12.0 +aiohttp>=3.9.0 +pyyaml>=6.0 +watchdog>=3.0.0 +asyncio-mqtt>=0.16.0 +psutil>=5.9.0 diff --git a/sdk/plugin_command_decorator.py b/sdk/plugin_command_decorator.py new file mode 100644 index 0000000..fa9fa5a --- /dev/null +++ b/sdk/plugin_command_decorator.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import logging +from typing import List, Callable, Optional +from functools import wraps + +logger = logging.getLogger(__name__) + +def plugin_command(name: Optional[str] = None, + description: Optional[str] = None, + permissions: Optional[List[str]] = None): + """ + 插件命令装饰器 + + 用法: + @plugin_command(name="mycmd", description="我的命令", permissions=["read"]) + async def my_command_handler(self, *args): + return "命令执行结果" + + 或者简化版: + @plugin_command() + async def mycmd(self, *args): + '''我的命令描述''' + return "命令执行结果" + """ + def decorator(func: Callable): + # 设置命令属性 + func._is_plugin_command = True + func._command_name = name or func.__name__ + + # 优先使用装饰器参数,其次使用文档字符串,最后使用默认描述 + if description: + func._command_description = description + elif func.__doc__: + # 提取文档字符串的第一行作为描述 + doc_lines = [line.strip() for line in func.__doc__.split('\n') if line.strip()] + func._command_description = doc_lines[0] if doc_lines else f"命令: {func.__name__}" + else: + func._command_description = f"命令: {func.__name__}" + + func._command_permissions = permissions or [] + + @wraps(func) + async def wrapper(self, *args, **kwargs): + """包装器确保返回字符串结果并处理异常""" + try: + logger.debug(f"执行插件命令: {func._command_name}, 参数: {args}") + + # 调用原始方法 + result = await func(self, *args, **kwargs) + + # 确保返回字符串 + if result is None: + return "✅ 命令执行完成" + elif not isinstance(result, str): + return str(result) + else: + return result + + except Exception as e: + logger.error(f"插件命令执行失败 {func._command_name}: {str(e)}", exc_info=True) + return f"❌ 命令执行错误: {str(e)}" + + return wrapper + return decorator + +def command(name: Optional[str] = None, description: Optional[str] = None): + """简化版命令装饰器""" + return plugin_command(name=name, description=description) + +# 同步命令装饰器(不推荐,但提供兼容性) +def sync_plugin_command(name: Optional[str] = None, + description: Optional[str] = None, + permissions: Optional[List[str]] = None): + """同步插件命令装饰器""" + def decorator(func: Callable): + func._is_plugin_command = True + func._command_name = name or func.__name__ + + if description: + func._command_description = description + elif func.__doc__: + doc_lines = [line.strip() for line in func.__doc__.split('\n') if line.strip()] + func._command_description = doc_lines[0] if doc_lines else f"命令: {func.__name__}" + else: + func._command_description = f"命令: {func.__name__}" + + func._command_permissions = permissions or [] + + @wraps(func) + def wrapper(self, *args, **kwargs): + """同步命令包装器""" + try: + logger.debug(f"执行同步插件命令: {func._command_name}, 参数: {args}") + + result = func(self, *args, **kwargs) + + if result is None: + return "✅ 命令执行完成" + elif not isinstance(result, str): + return str(result) + else: + return result + + except Exception as e: + logger.error(f"同步插件命令执行失败 {func._command_name}: {str(e)}", exc_info=True) + return f"❌ 命令执行错误: {str(e)}" + + return wrapper + return decorator diff --git a/sdk/plugin_error.py b/sdk/plugin_error.py new file mode 100644 index 0000000..2f83697 --- /dev/null +++ b/sdk/plugin_error.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""插件异常层级 — 结构化错误处理""" +import logging +logger = logging.getLogger(__name__) + +class PluginError(Exception): + """插件基础异常""" + def __init__(self, message: str, plugin_name: str = None, *args): + self.plugin_name = plugin_name + super().__init__(f"[{plugin_name or 'unknown'}] {message}", *args) + +class PluginLoadError(PluginError): + """插件加载失败""" + pass + +class PluginPermissionError(PluginError): + """插件权限不足""" + pass + +class PluginCommandError(PluginError): + """插件命令执行错误""" + pass + +class PluginNetworkError(PluginError): + """插件网络操作错误""" + pass + +class PluginConfigError(PluginError): + """插件配置错误""" + pass diff --git a/sdk/plugin_status.py b/sdk/plugin_status.py new file mode 100644 index 0000000..3344875 --- /dev/null +++ b/sdk/plugin_status.py @@ -0,0 +1,15 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""插件状态枚举 — 统一的插件生命周期状态跟踪""" +from enum import Enum + +class PluginStatus(str, Enum): + UNLOADED = "unloaded" + LOADING = "loading" + LOADED = "loaded" + INITIALIZING = "initializing" + RUNNING = "running" + ERROR = "error" + STOPPING = "stopping" + STOPPED = "stopped" + UNLOADING = "unloading" diff --git a/sdk/plugin_web.py b/sdk/plugin_web.py new file mode 100644 index 0000000..75c90f1 --- /dev/null +++ b/sdk/plugin_web.py @@ -0,0 +1,50 @@ +"""PluginWebMixin — plugin web panel SDK""" +import logging, json, asyncio +from aiohttp import web +from typing import Dict, Callable + +logger = logging.getLogger(__name__) + +class PluginWebMixin: + def __init__(self): + self._web_pages: Dict[str, dict] = {} + self._api_routes: list = [] + self._sse_clients: list = [] + + def register_web_page(self, path: str, title: str, html_content: str, icon: str = "P"): + self._web_pages[path] = {"title": title, "icon": icon, "html": html_content} + logger.info(f"Web page registered: {path}") + + def register_api_route(self, method: str, path: str, handler: Callable): + self._api_routes.append((method, path, handler)) + + def get_web_pages(self) -> dict: + return self._web_pages + + def get_api_routes(self) -> list: + return self._api_routes + + async def push_sse_event(self, event_type: str, data: dict): + payload = json.dumps(data) + dead = [] + for client in self._sse_clients: + try: + await client.send(f"event: {event_type}\\ndata: {payload}\\n\\n") + except: + dead.append(client) + for d in dead: + self._sse_clients.remove(d) + + async def handle_sse(self, request): + resp = web.StreamResponse() + resp.headers["Content-Type"] = "text/event-stream" + resp.headers["Cache-Control"] = "no-cache" + await resp.prepare(request) + self._sse_clients.append(resp) + try: + while True: + await asyncio.sleep(30) + await resp.write(b": keepalive\\n\\n") + except: + self._sse_clients.remove(resp) + return resp diff --git a/service_manager.py b/service_manager.py new file mode 100644 index 0000000..5eb9868 --- /dev/null +++ b/service_manager.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import logging +from typing import Dict, Any, List, Optional +import asyncio + +logger = logging.getLogger(__name__) + +class ServiceManager: + """服务管理器 - 管理所有框架服务的生命周期""" + + def __init__(self): + self.services: Dict[str, Any] = {} + self._startup_order: List[str] = [] + self._health_checks: Dict[str, Any] = {} + logger.debug("ServiceManager初始化完成") + + def register_service(self, name: str, service_instance, + depends_on: Optional[List[str]] = None, + health_check=None): + """注册服务""" + if name in self.services: + logger.warning(f"服务 {name} 已存在,将被覆盖") + self.services[name] = service_instance + self._startup_order.append(name) + if health_check: + self._health_checks[name] = health_check + logger.debug(f"服务 {name} 注册成功") + + def get_service(self, name: str): + """获取服务""" + service = self.services.get(name) + if not service: + raise ValueError(f"服务 {name} 未找到。可用: {list(self.services.keys())}") + return service + + def has_service(self, name: str) -> bool: + """检查服务是否已注册""" + return name in self.services + + async def check_health(self, name: str = None) -> Dict[str, bool]: + """健康检查""" + results = {} + names = [name] if name else list(self._health_checks.keys()) + for n in names: + if n in self._health_checks: + try: + r = self._health_checks[n]() + if asyncio.iscoroutine(r): r = await r + results[n] = bool(r) + except Exception as e: + logger.warning(f"服务 {n} 健康检查失败: {e}") + results[n] = False + return results + + @property + def startup_order(self) -> List[str]: + return list(self._startup_order) + + def shutdown_all(self): + """关闭所有服务""" + logger.info("开始关闭所有服务") + for name in reversed(self._startup_order): + service = self.services.get(name) + if service and hasattr(service, 'shutdown'): + try: + if asyncio.iscoroutinefunction(service.shutdown): + try: + loop = asyncio.get_running_loop() + loop.create_task(service.shutdown()) + except RuntimeError: + service.shutdown() + else: + service.shutdown() + logger.debug(f"服务 {name} 关闭成功") + except Exception as e: + logger.error(f"关闭服务 {name} 时出错: {e}") + logger.info("所有服务关闭完成") diff --git a/services/__init__.py b/services/__init__.py new file mode 100644 index 0000000..33bd599 --- /dev/null +++ b/services/__init__.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import logging + +logger = logging.getLogger(__name__) + +class ServiceManager: + """服务管理器""" + + def __init__(self): + self.services = {} + # 记录框架启动时间 + self.start_time = time.time() + logger.debug("ServiceManager初始化完成") + + def register_service(self, name: str, service_instance): + """注册服务""" + try: + if name in self.services: + logger.warning(f"服务 {name} 已存在,将被覆盖") + self.services[name] = service_instance + logger.debug(f"服务 {name} 注册成功") + except Exception as e: + logger.error(f"注册服务 {name} 时出错: {str(e)}", exc_info=True) + raise + + def get_service(self, name: str): + """获取服务""" + try: + service = self.services.get(name) + if not service: + logger.error(f"服务 {name} 不存在") + raise ValueError(f"服务 {name} 未找到") + logger.debug(f"成功获取服务 {name}") + return service + except Exception as e: + logger.error(f"获取服务 {name} 时出错: {str(e)}", exc_info=True) + raise + + def shutdown_all(self): + """关闭所有服务""" + logger.info("开始关闭所有服务") + for name, service in self.services.items(): + try: + if hasattr(service, 'shutdown'): + service.shutdown() + logger.debug(f"服务 {name} 关闭成功") + except Exception as e: + logger.error(f"关闭服务 {name} 时出错: {str(e)}", exc_info=True) + logger.info("所有服务关闭完成") diff --git a/services/api_service.py b/services/api_service.py new file mode 100644 index 0000000..8cdd381 --- /dev/null +++ b/services/api_service.py @@ -0,0 +1,206 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import logging +import asyncio +from typing import Dict, List, Optional +from dataclasses import dataclass +from aiohttp import web +import json + +logger = logging.getLogger(__name__) + +@dataclass +class APIEndpoint: + """API端点数据类""" + path: str + methods: List[str] + handler: callable + plugin_name: str + require_auth: bool + admin_only: bool + +class APIService: + """API服务 - 管理插件的API端点""" + + def __init__(self, internet_service, auth_service, permission_service): + self.internet_service = internet_service + self.auth_service = auth_service + self.permission_service = permission_service + self.endpoints: Dict[str, APIEndpoint] = {} + self.plugin_endpoints: Dict[str, List[str]] = {} + logger.debug("APIService初始化开始") + + async def register_endpoint(self, plugin_name: str, path: str, methods: List[str], + handler: callable, require_auth: bool = True, + admin_only: bool = False) -> bool: + """注册API端点""" + try: + logger.debug(f"注册API端点: {plugin_name} -> {path} {methods}") + + # 权限检查 - 只有admin插件可以操作接口 + if not self.permission_service.check_plugin_permission(plugin_name, "admin"): + logger.error(f"插件 {plugin_name} 无权限注册API端点") + return False + + # 创建端点键 + endpoint_key = f"{plugin_name}:{path}" + + # 检查端点是否已存在 + if endpoint_key in self.endpoints: + logger.warning(f"API端点已存在: {endpoint_key}") + return False + + # 创建端点实例 + endpoint = APIEndpoint( + path=path, + methods=methods, + handler=handler, + plugin_name=plugin_name, + require_auth=require_auth, + admin_only=admin_only + ) + + # 注册到互联网服务 + for method in methods: + internet_endpoint_key = f"{method}:{path}" + self.internet_service.endpoints[internet_endpoint_key] = endpoint + + # 保存端点信息 + self.endpoints[endpoint_key] = endpoint + + # 更新插件端点映射 + if plugin_name not in self.plugin_endpoints: + self.plugin_endpoints[plugin_name] = [] + self.plugin_endpoints[plugin_name].append(endpoint_key) + + logger.info(f"API端点注册成功: {endpoint_key}") + return True + + except Exception as e: + logger.error(f"注册API端点时出错: {str(e)}", exc_info=True) + return False + + async def unregister_endpoint(self, plugin_name: str, path: str) -> bool: + """注销API端点""" + try: + logger.debug(f"注销API端点: {plugin_name} -> {path}") + + endpoint_key = f"{plugin_name}:{path}" + + if endpoint_key not in self.endpoints: + logger.warning(f"API端点不存在: {endpoint_key}") + return False + + endpoint = self.endpoints[endpoint_key] + + # 从互联网服务中移除 + for method in endpoint.methods: + internet_endpoint_key = f"{method}:{path}" + if internet_endpoint_key in self.internet_service.endpoints: + del self.internet_service.endpoints[internet_endpoint_key] + + # 从端点映射中移除 + del self.endpoints[endpoint_key] + + # 从插件端点列表中移除 + if plugin_name in self.plugin_endpoints: + if endpoint_key in self.plugin_endpoints[plugin_name]: + self.plugin_endpoints[plugin_name].remove(endpoint_key) + + logger.info(f"API端点注销成功: {endpoint_key}") + return True + + except Exception as e: + logger.error(f"注销API端点时出错: {str(e)}", exc_info=True) + return False + + async def unregister_all_plugin_endpoints(self, plugin_name: str) -> bool: + """注销插件的所有API端点""" + try: + logger.debug(f"注销插件所有API端点: {plugin_name}") + + if plugin_name not in self.plugin_endpoints: + logger.debug(f"插件无注册的API端点: {plugin_name}") + return True + + endpoints_to_remove = self.plugin_endpoints[plugin_name][:] + success_count = 0 + + for endpoint_key in endpoints_to_remove: + # 从endpoint_key中提取path + parts = endpoint_key.split(':', 1) + if len(parts) == 2: + path = parts[1] + success = await self.unregister_endpoint(plugin_name, path) + if success: + success_count += 1 + + logger.info(f"插件API端点清理完成: {plugin_name} -> 成功 {success_count}/{len(endpoints_to_remove)}") + return success_count == len(endpoints_to_remove) + + except Exception as e: + logger.error(f"注销插件所有API端点时出错: {str(e)}", exc_info=True) + return False + + def get_plugin_endpoints(self, plugin_name: str) -> List[Dict]: + """获取插件的API端点列表""" + try: + if plugin_name not in self.plugin_endpoints: + return [] + + endpoints_info = [] + for endpoint_key in self.plugin_endpoints[plugin_name]: + if endpoint_key in self.endpoints: + endpoint = self.endpoints[endpoint_key] + endpoints_info.append({ + 'path': endpoint.path, + 'methods': endpoint.methods, + 'require_auth': endpoint.require_auth, + 'admin_only': endpoint.admin_only + }) + + logger.debug(f"获取插件API端点列表: {plugin_name} -> {len(endpoints_info)} 个") + return endpoints_info + + except Exception as e: + logger.error(f"获取插件API端点列表时出错: {str(e)}", exc_info=True) + return [] + + def get_all_endpoints(self) -> List[Dict]: + """获取所有API端点""" + try: + all_endpoints = [] + for endpoint_key, endpoint in self.endpoints.items(): + all_endpoints.append({ + 'plugin': endpoint.plugin_name, + 'path': endpoint.path, + 'methods': endpoint.methods, + 'require_auth': endpoint.require_auth, + 'admin_only': endpoint.admin_only + }) + + logger.debug(f"获取所有API端点: {len(all_endpoints)} 个") + return all_endpoints + + except Exception as e: + logger.error(f"获取所有API端点时出错: {str(e)}", exc_info=True) + return [] + + async def shutdown(self): + """关闭API服务""" + try: + logger.info("关闭API服务") + + # 注销所有端点 + all_plugins = list(self.plugin_endpoints.keys()) + for plugin_name in all_plugins: + await self.unregister_all_plugin_endpoints(plugin_name) + + self.endpoints.clear() + self.plugin_endpoints.clear() + + logger.debug("API服务关闭完成") + + except Exception as e: + logger.error(f"关闭API服务时出错: {str(e)}", exc_info=True) diff --git a/services/auth_service.py b/services/auth_service.py new file mode 100644 index 0000000..57db728 --- /dev/null +++ b/services/auth_service.py @@ -0,0 +1,283 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import logging +import os +import hashlib +import secrets +from typing import Dict, List, Optional +from dataclasses import dataclass +import time +import json + +logger = logging.getLogger(__name__) + +@dataclass +class User: + """用户数据类""" + username: str + password_hash: str + salt: str + permissions: List[str] + is_active: bool = True + created_at: float = None + +@dataclass +class Token: + """令牌数据类""" + token: str + username: str + permissions: List[str] + created_at: float + expires_at: float + is_valid: bool = True + +class AuthService: + """认证服务 - 处理用户认证和权限验证""" + + def __init__(self, config: Dict): + self.config = config + self.users: Dict[str, User] = {} + self.tokens: Dict[str, Token] = {} + self.token_expiry_hours = 2 # 生产环境 2 小时过期 + self.secret_key = secrets.token_hex(32) + logger.debug("AuthService初始化开始") + + # 初始化默认用户 + self._init_default_users() + + def _init_default_users(self): + """初始化默认用户""" + try: + # 管理员 — 独立随机盐 + admin_salt = secrets.token_hex(16) + admin_pw = os.environ.get("SENSU_ADMIN_PASSWORD", "admin123") + admin_user = User( + username="admin", + password_hash=self._hash_password(admin_pw, admin_salt), + salt=admin_salt, + permissions=["admin"], + created_at=time.time(), + ) + self.users["admin"] = admin_user + + # API 用户 — 独立随机盐 + api_salt = secrets.token_hex(16) + api_pw = os.environ.get("SENSU_API_PASSWORD", "api123") + api_user = User( + username="api", + password_hash=self._hash_password(api_pw, api_salt), + salt=api_salt, + permissions=["framework.status.read", "plugin.info.read"], + created_at=time.time(), + ) + self.users["api"] = api_user + + logger.debug("默认用户初始化完成 (独立随机盐)") + + # 安全警告 — 仍在使用默认密码 + if admin_pw == "admin123" or api_pw == "api123": + logger.critical( + "⚠️ 安全警告: 正在使用默认密码! " + "请设置环境变量 SENSU_ADMIN_PASSWORD 和 SENSU_API_PASSWORD" + ) + + except Exception as e: + logger.error(f"初始化默认用户时出错: {str(e)}", exc_info=True) + raise + + def _hash_password(self, password: str, salt: str = None) -> str: + """哈希密码 — 每个用户独立随机盐""" + try: + if salt is None: + salt = secrets.token_hex(16) + return hashlib.sha256((password + salt).encode()).hexdigest() + except Exception as e: + logger.error(f"哈希密码时出错: {str(e)}", exc_info=True) + raise + + def authenticate_user(self, username: str, password: str) -> Optional[Token]: + """用户认证""" + try: + logger.debug(f"用户认证尝试: {username}") + + if username not in self.users: + logger.warning(f"用户不存在: {username}") + return None + + user = self.users[username] + + if not user.is_active: + logger.warning(f"用户已被禁用: {username}") + return None + + password_hash = self._hash_password(password, user.salt) + if user.password_hash != password_hash: + logger.warning(f"密码错误: {username}") + return None + + # 创建令牌 + token = self._create_token(user) + logger.debug(f"用户认证成功: {username}") + return token + + except Exception as e: + logger.error(f"用户认证时出错: {str(e)}", exc_info=True) + return None + + def _create_token(self, user: User) -> Token: + """创建令牌""" + try: + token_str = secrets.token_hex(32) + created_at = time.time() + expires_at = created_at + (self.token_expiry_hours * 3600) + + token = Token( + token=token_str, + username=user.username, + permissions=user.permissions, + created_at=created_at, + expires_at=expires_at + ) + + self.tokens[token_str] = token + logger.debug(f"创建令牌: {user.username}, 有效期: {self.token_expiry_hours}小时") + return token + + except Exception as e: + logger.error(f"创建令牌时出错: {str(e)}", exc_info=True) + raise + + def validate_token(self, token_str: str) -> Optional[Token]: + """验证令牌""" + try: + if token_str not in self.tokens: + logger.debug("令牌不存在") + return None + + token = self.tokens[token_str] + + # 检查令牌是否有效 + if not token.is_valid: + logger.debug("令牌已失效") + return None + + # 检查令牌是否过期 + if time.time() > token.expires_at: + logger.debug("令牌已过期") + token.is_valid = False + return None + + logger.debug(f"令牌验证成功: {token.username}") + return token + + except Exception as e: + logger.error(f"验证令牌时出错: {str(e)}", exc_info=True) + return None + + def revoke_token(self, token_str: str) -> bool: + """撤销令牌""" + try: + if token_str in self.tokens: + self.tokens[token_str].is_valid = False + logger.debug(f"令牌已撤销: {token_str}") + return True + else: + logger.warning(f"要撤销的令牌不存在: {token_str}") + return False + except Exception as e: + logger.error(f"撤销令牌时出错: {str(e)}", exc_info=True) + return False + + def check_permission(self, token_str: str, permission: str) -> bool: + """检查权限""" + try: + token = self.validate_token(token_str) + if not token: + return False + + # 检查admin权限 + if "admin" in token.permissions: + return True + + # 检查具体权限 + has_permission = permission in token.permissions + + logger.debug(f"权限检查: {token.username} -> {permission} = {has_permission}") + return has_permission + + except Exception as e: + logger.error(f"检查权限时出错: {str(e)}", exc_info=True) + return False + + def create_user(self, username: str, password: str, permissions: List[str]) -> bool: + """创建用户""" + try: + if username in self.users: + logger.warning(f"用户已存在: {username}") + return False + + salt = secrets.token_hex(16) + password_hash = self._hash_password(password, salt) + user = User( + username=username, + password_hash=password_hash, + salt=salt, + permissions=permissions, + created_at=time.time(), + ) + + self.users[username] = user + logger.debug(f"用户创建成功: {username}, 权限: {permissions}") + return True + + except Exception as e: + logger.error(f"创建用户时出错: {str(e)}", exc_info=True) + return False + + def get_user_info(self, username: str) -> Optional[Dict]: + """获取用户信息""" + try: + if username not in self.users: + return None + + user = self.users[username] + return { + "username": user.username, + "permissions": user.permissions, + "is_active": user.is_active, + "created_at": user.created_at + } + except Exception as e: + logger.error(f"获取用户信息时出错: {str(e)}", exc_info=True) + return None + + def cleanup_expired_tokens(self): + """清理过期令牌""" + try: + current_time = time.time() + expired_tokens = [] + + for token_str, token in self.tokens.items(): + if current_time > token.expires_at: + expired_tokens.append(token_str) + + for token_str in expired_tokens: + del self.tokens[token_str] + + if expired_tokens: + logger.debug(f"清理了 {len(expired_tokens)} 个过期令牌") + + except Exception as e: + logger.error(f"清理过期令牌时出错: {str(e)}", exc_info=True) + + def shutdown(self): + """关闭认证服务""" + try: + logger.info("关闭认证服务") + self.cleanup_expired_tokens() + self.users.clear() + self.tokens.clear() + logger.debug("认证服务关闭完成") + except Exception as e: + logger.error(f"关闭认证服务时出错: {str(e)}", exc_info=True) diff --git a/services/command_service.py b/services/command_service.py new file mode 100644 index 0000000..3f8485b --- /dev/null +++ b/services/command_service.py @@ -0,0 +1,637 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import logging +import asyncio +import shlex +from typing import Dict, List, Callable, Any +from dataclasses import dataclass +import os + +logger = logging.getLogger(__name__) + +@dataclass +class Command: + """命令数据类""" + name: str + handler: Callable + description: str + permissions: List[str] + source: str = "internal" + +class CommandService: + """指令服务""" + + def __init__(self, service_manager=None): + self.commands: Dict[str, Command] = {} + self.command_history: List[Dict] = [] + self.max_history_size = 100 + self.service_manager = service_manager # 添加服务管理器引用 + logger.debug("CommandService初始化开始") + + + def register_command(self, name: str, handler: Callable, description: str = "", + permissions: List[str] = None, source: str = "plugins"): + """注册命令""" + # 来源如 internal plugins system 等 + try: + if name in self.commands: + logger.warning(f"命令 {name} 已存在,将被覆盖") + + self.commands[name] = Command( + name=name, + handler=handler, + description=description or f"命令: {name}", + permissions=permissions or [], + source=source + ) + logger.debug(f"注册命令: {name} (来源: {source})") + + except Exception as e: + logger.error(f"注册命令 {name} 时出错: {str(e)}", exc_info=True) + raise + + async def _handle_permission_command(self, command: str, args: List[str], source: str) -> str: + """处理权限相关命令""" + try: + permission_service = self.service_manager.get_service("permission") + if not permission_service: + return "❌ 权限服务不可用" + + return await permission_service.process_permission_command(command, args) + + except Exception as e: + logger.error(f"处理权限命令时出错: {str(e)}", exc_info=True) + return f"❌ 处理权限命令时出错: {str(e)}" + + async def process_command(self, command_string: str, source: str = "unknown") -> Any: + """处理命令""" + try: + logger.debug(f"处理命令: '{command_string}' (来源: {source})") + + # 解析命令 + parts = shlex.split(command_string.strip()) + if not parts: + logger.warning("空命令") + return "空命令" + + command_name = parts[0] + args = parts[1:] + + # 记录命令历史 + self._add_to_history(command_string, source) + + # 检查权限命令 + permission_commands = ['pmallow', 'pmdeny', 'pmignore', 'permissions', + 'pmpending', 'pmrequests', 'pm_plugin_status', 'pmhelp', 'pmtest'] + + if command_name in permission_commands: + permission_service = self.service_manager.get_service("permission") + if not permission_service: + return "❌ 权限服务不可用" + + # 直接调用权限服务处理命令 + return await permission_service.process_permission_command(command_name, args) + + # 查找其他命令 + if command_name not in self.commands: + logger.warning(f"未知命令: {command_name}") + return f"未知命令: {command_name}" + + command = self.commands[command_name] + + # 执行命令 + try: + result = await self._execute_command(command, args, source) + logger.debug(f"命令执行成功: {command_name}") + return result + + except Exception as e: + logger.error(f"命令执行失败 {command_name}: {str(e)}", exc_info=True) + return f"命令执行错误: {str(e)}" + + except Exception as e: + logger.error(f"处理命令时出错: {str(e)}", exc_info=True) + return f"命令处理错误: {str(e)}" + + async def _execute_command(self, command: Command, args: List[str], source: str) -> Any: + """执行命令""" + try: + # 检查处理器类型 + if asyncio.iscoroutinefunction(command.handler): + result = await command.handler(*args) + else: + result = command.handler(*args) + + logger.debug(f"命令 {command.name} 执行完成") + return result + + except TypeError as e: + logger.error(f"命令参数错误 {command.name}: {str(e)}", exc_info=True) + raise ValueError(f"参数错误: {str(e)}") + except Exception as e: + logger.error(f"命令执行异常 {command.name}: {str(e)}", exc_info=True) + raise + + def _add_to_history(self, command: str, source: str): + """添加到命令历史""" + try: + history_entry = { + "command": command, + "source": source, + "timestamp": asyncio.get_event_loop().time() + } + self.command_history.append(history_entry) + + # 限制历史记录大小 + if len(self.command_history) > self.max_history_size: + self.command_history.pop(0) + + logger.debug(f"命令历史记录添加,当前大小: {len(self.command_history)}") + + except Exception as e: + logger.error(f"添加命令历史时出错: {str(e)}", exc_info=True) + + def get_command_list(self) -> List[Dict]: + """获取命令列表""" + try: + command_list = [] + for name, cmd in self.commands.items(): + command_list.append({ + "name": name, + "description": cmd.description, + "permissions": cmd.permissions, + "source": cmd.source + }) + + logger.debug(f"获取命令列表,共 {len(command_list)} 个命令") + return command_list + + except Exception as e: + logger.error(f"获取命令列表时出错: {str(e)}", exc_info=True) + return [] + + def get_command_history(self, limit: int = 10) -> List[Dict]: + """获取命令历史""" + try: + history = self.command_history[-limit:] + logger.debug(f"获取命令历史,返回 {len(history)} 条记录") + return history + + except Exception as e: + logger.error(f"获取命令历史时出错: {str(e)}", exc_info=True) + return [] + + def register_builtin_commands(self): + """注册内置命令""" + try: + logger.debug("开始注册内置命令") + + # 帮助命令 + self.register_command( + name="help", + handler=self._cmd_help, + description="显示帮助信息", + permissions=["framework.command.help.read"], + source="internal" + ) + + # 测试日志命令 + self.register_command( + name="testlog", + handler=self._cmd_test_log, + description="生成测试日志", + permissions=["framework.command.test"], + source="internal" + ) + + # 状态命令 + self.register_command( + name="status", + handler=self._cmd_status, + description="显示框架状态", + permissions=["framework.status.read"], + source="internal" + ) + + # 历史命令 + self.register_command( + name="history", + handler=self._cmd_history, + description="显示命令历史", + permissions=["framework.command.history.read"], + source="internal" + ) + + # 网络诊断 + self.register_command( + name="netdiag", + handler=self._cmd_netdiag, + description="网络服务诊断", + permissions=["framework.network.diagnose"], + source="internal" + ) + + # 权限管理命令组 - 保留注册但不使用(在process_command中直接处理) + # 这些注册是为了在help命令中显示 + permission_commands = [ + ("pmallow", "权限管理: 同意权限请求"), + ("pmdeny", "权限管理: 拒绝权限请求"), + ("pmignore", "权限管理: 暂时忽略权限请求"), + ("permissions", "权限管理: 显示权限状态"), + ("pmpending", "权限管理: 查看待授权请求列表"), + ("pmrequests", "权限管理: 查看待授权请求列表(别名)"), + ("pm_plugin_status", "权限管理: 查看插件权限状态"), + ("pmtest", "权限管理: 测试权限配置文件"), + ("pmhelp", "权限管理: 显示权限命令帮助") + ] + + for cmd_name, description in permission_commands: + self.register_command( + name=cmd_name, + handler=self._cmd_permission, # 使用统一的备用处理器 + description=description, + permissions=["framework.permission.read"], + source="internal" + ) + + # 滚动控制命令组 + scroll_commands = [ + ("scroll", "滚动控制: 手动滚动到底部"), + ("autoscroll", "滚动控制: 切换自动滚动") + ] + + # 脚手架命令 + self.register_command( + name="create-plugin", + handler=self._cmd_create_plugin, + description="创建新插件脚手架", + permissions=["framework.scaffold.plugin"], + source="internal" + ) + + logger.info(f"内置命令注册完成,共注册 {len(self.commands)} 个命令") + + + for cmd_name, description in scroll_commands: + self.register_command( + name=cmd_name, + handler=self._cmd_scroll_control, + description=description, + permissions=["framework.tui.control"], + source="internal" + ) + + # 插件安装命令 + self.register_command( + name="install", + handler=self._cmd_install, + description="从在线索引安装插件: install 或 install --list", + permissions=["framework.plugin.install"], + source="internal", + ) + + logger.info(f"内置命令注册完成,共注册 {len(self.commands)} 个命令") + + except Exception as e: + logger.error(f"注册内置命令时出错: {str(e)}", exc_info=True) + raise + + async def _cmd_install(self, *args) -> str: + """插件安装命令""" + try: + from services.plugin_index import PluginIndex + index = PluginIndex() + if not args or args[0] == "--list": + plugins = await index.fetch_index() + if not plugins: + return "📭 插件索引为空或无法连接" + lines = [f"📦 可用插件 ({len(plugins)}):", "=" * 40] + for p in plugins: + lines.append( + f" 🔹 {p.get('name','?')} v{p.get('version','?')} — " + f"{p.get('description','?')[:50]}" + ) + lines.append("\n💡 install 安装插件") + return "\n".join(lines) + + name = args[0] + ok = await index.install(name) + if ok: + return f"✅ 插件安装完成: {name}\n💡 重启框架或使用热重载加载新插件" + return f"❌ 安装失败: {name}" + except ImportError: + return "❌ 缺少 aiohttp,无法使用插件安装功能" + + async def _cmd_netdiag(self, *args) -> str: + """网络诊断命令""" + try: + internet_service = self.service_manager.get_service("internet") + + result = ["🔧 **网络服务诊断报告**"] + result.append("=" * 50) + + if not internet_service: + result.append("❌ 网络服务未注册") + result.append("\n💡 **可能的原因:**") + result.append(" 1. 网络服务启动失败") + result.append(" 2. 依赖包缺失 (aiohttp)") + result.append(" 3. 端口被占用") + result.append(" 4. 权限不足") + result.append("\n🔧 **解决方案:**") + result.append(" - 检查上方日志中的错误信息") + result.append(" - 运行: pip install aiohttp") + result.append(" - 尝试更换端口号") + result.append(" - 使用 sudo (如果需要)") + return "\n".join(result) + + # 获取健康信息 + health_info = await internet_service.check_service_health() + + result.append(f"🔄 服务运行: {'✅ 是' if health_info.get('is_running') else '❌ 否'}") + result.append(f"🔌 HTTP端口: {health_info.get('http_port', 'N/A')}") + result.append(f"📡 WebSocket端口: {health_info.get('websocket_port', 'N/A')}") + result.append(f"🌐 HTTP活跃: {'✅ 是' if health_info.get('http_active') else '❌ 否'}") + result.append(f"📦 依赖状态: {'✅ 正常' if health_info.get('dependencies_available') else '❌ 缺失'}") + + if health_info.get('error'): + result.append(f"❌ 错误信息: {health_info['error']}") + + # 端口占用检查 + if not health_info.get('http_active') and health_info.get('is_running'): + result.append("\n⚠️ **端口问题检测:**") + result.append(" HTTP服务已启动但端口未响应") + result.append(" 可能被防火墙阻止或配置错误") + + # 路由信息 + routes = internet_service.get_plugin_routes() + total_routes = sum(len(plugin_routes) for plugin_routes in routes.values()) + result.append(f"\n🛣️ 注册路由: {total_routes} 个") + + for plugin_name, plugin_routes in routes.items(): + result.append(f" 📍 {plugin_name}: {len(plugin_routes)} 个路由") + + return "\n".join(result) + + except Exception as e: + logger.error(f"网络诊断命令执行失败: {str(e)}") + return f"❌ 网络诊断失败: {str(e)}" + + async def _cmd_scroll_control(self, *args) -> str: + """处理滚动控制命令""" + try: + tui_service = self.service_manager.get_service("tui") + if not tui_service: + return "❌ TUI服务不可用" + + if not args: + return "🔧 滚动控制命令\n💡 使用: scroll [log|message|all]\n💡 使用: autoscroll [on|off|toggle] [log|message|all]" + + command = args[0].lower() + + if command == "scroll": + target = args[1] if len(args) > 1 else "all" + if target not in ["log", "message", "all"]: + return "❌ 无效的目标,请使用: log, message, all" + return tui_service.scroll_to_bottom(target) + + elif command == "autoscroll": + if len(args) < 2: + return "❌ 请指定操作: on, off, toggle" + + action = args[1].lower() + target = args[2] if len(args) > 2 else "all" + + if target not in ["log", "message", "all"]: + return "❌ 无效的目标,请使用: log, message, all" + + if action == "on": + return tui_service.toggle_auto_scroll(target, True) + elif action == "off": + return tui_service.toggle_auto_scroll(target, False) + elif action == "toggle": + return tui_service.toggle_auto_scroll(target, None) + else: + return "❌ 无效的操作,请使用: on, off, toggle" + + else: + return "❌ 未知滚动命令\n💡 可用命令: scroll, autoscroll" + + except Exception as e: + logger.error(f"处理滚动命令时出错: {str(e)}") + return f"❌ 滚动命令错误: {str(e)}" + + + async def _cmd_permission(self, *args) -> str: + """处理权限相关命令 - 备用处理器""" + try: + permission_service = self.service_manager.get_service("permission") + if not permission_service: + return "❌ 权限服务不可用" + + # 如果没有参数,显示通用帮助 + if not args: + return "🔐 权限管理命令\n💡 使用 pmhelp 查看详细帮助" + + # 否则直接转发到权限服务 + command_name = str(args[0]).lower() + permission_args = [str(arg) for arg in args[1:]] if len(args) > 1 else [] + + return await permission_service.process_permission_command(command_name, permission_args) + + except Exception as e: + logger.error(f"处理权限命令时出错: {str(e)}", exc_info=True) + return f"❌ 权限命令错误: {str(e)}" + + + async def _cmd_help(self, *args) -> str: + """帮助命令处理器""" + try: + commands = self.get_command_list() + if not commands: + return "❌ 没有可用的命令" + + help_text = ["📋 **可用命令:**", ""] + + # 按来源分组显示命令 + commands_by_source = {} + for cmd in commands: + source = cmd['source'] + if source not in commands_by_source: + commands_by_source[source] = [] + commands_by_source[source].append(cmd) + + # 显示内置命令 + if 'internal' in commands_by_source: + help_text.append("🔧 **内置命令:**") + for cmd in commands_by_source['internal']: + help_text.append(f" 🟢 {cmd['name']:15} - {cmd['description']}") + help_text.append("") + + # 显示插件命令 + if 'plugin' in commands_by_source: + help_text.append("🔌 **插件命令:**") + for cmd in commands_by_source['plugin']: + help_text.append(f" 🟡 {cmd['name']:15} - {cmd['description']}") + help_text.append("") + + # 显示系统命令 + if 'system' in commands_by_source: + help_text.append("⚙️ **系统命令:**") + for cmd in commands_by_source['system']: + help_text.append(f" 🔵 {cmd['name']:15} - {cmd['description']}") + + # 添加使用提示 + help_text.extend([ + "", + "💡 **使用提示:**", + " - 输入命令名称执行命令", + " - 使用 'status' 查看框架状态", + " - 使用 'history' 查看命令历史", + " - 使用 'permissions' 管理插件权限" + ]) + + return "\n".join(help_text) + + except Exception as e: + logger.error(f"处理help命令时出错: {str(e)}", exc_info=True) + return f"❌ 帮助命令错误: {str(e)}" + + async def _cmd_status(self, *args) -> str: + """状态命令处理器""" + try: + status_info = [ + f"命令服务状态:", + f" 注册命令数: {len(self.commands)}", + f" 历史记录数: {len(self.command_history)}", + f" 最大历史大小: {self.max_history_size}" + ] + return "\n".join(status_info) + + except Exception as e: + logger.error(f"处理status命令时出错: {str(e)}", exc_info=True) + return f"状态命令错误: {str(e)}" + + async def _cmd_history(self, *args) -> str: + """历史命令处理器""" + try: + limit = 10 + if args and args[0].isdigit(): + limit = min(int(args[0]), 50) # 限制最大50条 + + history = self.get_command_history(limit) + if not history: + return "没有命令历史" + + history_text = [f"最近 {len(history)} 条命令历史:"] + for i, entry in enumerate(reversed(history), 1): + history_text.append(f" {i}. [{entry['source']}] {entry['command']}") + + return "\n".join(history_text) + + except Exception as e: + logger.error(f"处理history命令时出错: {str(e)}", exc_info=True) + return f"历史命令错误: {str(e)}" + + async def _cmd_test_log(self, *args) -> str: + """测试日志命令""" + try: + logger.debug("这是一条DEBUG测试日志") + logger.info("这是一条INFO测试日志") + logger.warning("这是一条WARNING测试日志") + logger.error("这是一条ERROR测试日志") + return "✅ 测试日志已生成,请检查TUI显示" + except Exception as e: + return f"❌ 测试日志生成失败: {str(e)}" + + async def _cmd_create_plugin(self, *args) -> str: + """创建新插件脚手架""" + try: + import re + import shutil + from pathlib import Path + from string import Template + + # 1. 参数解析 + if not args: + return "❌ 用法: create-plugin <插件名> [--author <作者>] [--desc <描述>]\n💡 插件名需为小写字母/数字/下划线,如: my_cool_plugin" + + plugin_name = args[0] + author = "Unknown" + description = "暂无描述" + + # 解析可选参数 + i = 1 + while i < len(args): + if args[i] == "--author" and i + 1 < len(args): + author = args[i+1] + i += 2 + elif args[i] == "--desc" and i + 1 < len(args): + description = args[i+1] + i += 2 + else: + i += 1 + + # 2. 命名校验 + if not re.match(r'^[a-z][a-z0-9_]*$', plugin_name): + return "❌ 插件名格式错误。请使用小写字母开头,仅包含小写字母、数字和下划线(如: data_sync)" + + plugin_dir = Path("plugins") / plugin_name + if plugin_dir.exists(): + return f"❌ 插件目录已存在: {plugin_dir}" + + # 3. 模板路径 + template_dir = Path(os.getenv("SENSU_CODE_DIR", ".")) / "templates" / "plugin" + + if not template_dir.exists(): + return "❌ 模板目录不存在: templates/plugin/" + + # 4. 创建目录与渲染文件 + plugin_dir.mkdir(parents=True, exist_ok=True) + context = { + "plugin_name": plugin_name, + "author": author, + "description": description + } + + for template_file in template_dir.iterdir(): + if template_file.is_file() and template_file.name.endswith(".template"): + with open(template_file, 'r', encoding='utf-8') as f: + tpl = Template(f.read()) + content = tpl.safe_substitute(context) + + target_name = template_file.stem + target_path = plugin_dir / target_name + + with open(target_path, 'w', encoding='utf-8') as f: + f.write(content) + logger.debug(f"脚手架文件生成: {target_path}") + + return ( + f"✅ 插件脚手架创建成功!\n" + f"📁 路径: {plugin_dir}\n" + f"👤 作者: {author}\n" + f"📝 描述: {description}\n\n" + f"🔧 下一步:\n" + f" 1. 编辑 {plugin_dir}/__init__.py 实现业务逻辑\n" + f" 2. 运行框架自动加载插件\n" + f" 3. 使用 `help` 查看可用命令" + ) + + except Exception as e: + logger.error(f"创建插件脚手架失败: {str(e)}", exc_info=True) + return f"❌ 创建失败: {str(e)}" + + + + + + def shutdown(self): + """关闭指令服务""" + try: + logger.info("关闭指令服务") + self.commands.clear() + self.command_history.clear() + logger.debug("指令服务关闭完成") + except Exception as e: + logger.error(f"关闭指令服务时出错: {str(e)}", exc_info=True) diff --git a/services/init_service.py b/services/init_service.py new file mode 100644 index 0000000..606f1f3 --- /dev/null +++ b/services/init_service.py @@ -0,0 +1,318 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import logging +import asyncio +import signal +from pathlib import Path +from typing import Dict, Any, List +import yaml +import os + +logger = logging.getLogger(__name__) + + +def _get_script_processes() -> List: + """获取全局自动启动脚本进程列表(模块级单例)""" + if not hasattr(_get_script_processes, "_procs"): + _get_script_processes._procs = [] + return _get_script_processes._procs + + +class InitService: + """初始化服务""" + + def __init__(self, config_path: str = "config/framework"): + self.config_path = Path(config_path) + self.configs: Dict[str, Any] = {} + self.script_processes: List = _get_script_processes() + logger.debug("InitService初始化开始") + + async def initialize_framework(self): + """初始化框架""" + try: + logger.info("开始初始化框架") + + # 1. 加载配置 + await self._load_configs() + + # 2. 创建必要目录 + await self._create_directories() + + # 3. 验证初始化状态 + await self._validate_init() + + logger.info("框架初始化完成") + return self.configs + + except Exception as e: + logger.error(f"框架初始化失败: {str(e)}", exc_info=True) + raise + + async def start_auto_scripts(self): + """启动配置中声明的自动启动脚本(在日志服务就绪后调用)""" + try: + cfg = self.configs.get("base", {}) + scripts_cfg = cfg.get("auto_start_scripts", {}) + if not scripts_cfg.get("enabled", True): + logger.info("自动启动脚本已禁用") + return + + entries = scripts_cfg.get("scripts", []) + if not entries: + logger.debug("没有配置自动启动脚本") + return + + logger.info(f"检查自动启动脚本 ({len(entries)} 个)...") + for entry in entries: + if not entry.get("enabled", True): + logger.debug(f" 跳过已禁用的脚本: {entry.get('name', entry.get('path', '?'))}") + continue + + script_path = os.path.expanduser(entry["path"]) + if not os.path.isfile(script_path): + logger.info( + f" 自动启动脚本不存在,跳过: {entry.get('name', script_path)}" + ) + continue + + script_args = entry.get("args", []) + script_name = entry.get("name", os.path.basename(script_path)) + cwd = entry.get("cwd") or os.path.dirname(script_path) + + try: + proc = await asyncio.create_subprocess_exec( + "python3" if script_path.endswith(".py") else script_path, + script_path if script_path.endswith(".py") else None, + *script_args, + cwd=cwd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + preexec_fn=os.setsid, + ) + # Filter out None (when script_path is the executable itself) + self.script_processes.append( + {"name": script_name, "proc": proc, "path": script_path} + ) + logger.info(f"✅ 自动启动脚本已拉起: {script_name} (pid={proc.pid})") + except Exception as start_err: + logger.warning( + f"⚠️ 自动启动脚本失败 {script_name}: {start_err}" + ) + + except Exception as e: + logger.warning(f"自动启动脚本扫描出错 (不影响框架): {e}") + + async def _load_configs(self): + """加载配置文件""" + try: + logger.debug("开始加载配置文件") + + if not self.config_path.exists(): + logger.warning(f"配置路径不存在: {self.config_path},将创建默认配置") + self.config_path.mkdir(parents=True, exist_ok=True) + + # 加载基础配置 + base_config_file = self.config_path / "base_config.yaml" + if base_config_file.exists(): + with open(base_config_file, 'r', encoding='utf-8') as f: + loaded = yaml.safe_load(f) or {} + # 合并模板中新增的字段 (不影响用户已修改的值) + template_file = self.config_path / "base_config.yaml.example" + if template_file.exists(): + try: + with open(template_file, 'r', encoding='utf-8') as tf: + template = yaml.safe_load(tf) or {} + def _deep_merge(base, tpl): + for k, v in tpl.items(): + if k not in base: + base[k] = v + elif isinstance(v, dict) and isinstance(base.get(k), dict): + _deep_merge(base[k], v) + _deep_merge(loaded, template) + self._save_config(base_config_file, loaded) + except Exception: + pass + self.configs['base'] = loaded + logger.debug("基础配置加载成功") + else: + # 从模板复制 + template = self.config_path / "base_config.yaml.example" + if template.exists(): + import shutil + shutil.copy(template, base_config_file) + logger.info(f"📋 从模板创建配置文件: {base_config_file}") + with open(base_config_file, 'r', encoding='utf-8') as f: + self.configs['base'] = yaml.safe_load(f) + else: + logger.warning("基础配置文件不存在,使用默认配置") + self.configs['base'] = self._get_default_base_config() + self._save_config(base_config_file, self.configs['base']) + + # 加载权限规则 + permission_file = self.config_path / "permission_rules.yaml" + if permission_file.exists(): + with open(permission_file, 'r', encoding='utf-8') as f: + self.configs['permission_rules'] = yaml.safe_load(f) + logger.debug("权限规则配置加载成功") + else: + logger.warning("权限规则文件不存在,使用默认配置") + self.configs['permission_rules'] = self._get_default_permission_rules() + self._save_config(permission_file, self.configs['permission_rules']) + + logger.debug(f"配置文件加载完成,共加载 {len(self.configs)} 个配置集") + + except Exception as e: + logger.error(f"加载配置文件时出错: {str(e)}", exc_info=True) + raise + + async def _create_directories(self): + """创建必要目录""" + try: + logger.debug("开始创建必要目录") + + directories = [ + "config/plugins", + "config/services", + "config/permissions", + "logs/runtime", + "logs/debug", + "plugins", + "utils" + ] + + for dir_path in directories: + path = Path(dir_path) + path.mkdir(parents=True, exist_ok=True) + logger.debug(f"创建目录: {dir_path}") + + logger.debug("目录创建完成") + + except Exception as e: + logger.error(f"创建目录时出错: {str(e)}", exc_info=True) + raise + + async def _validate_init(self): + """验证初始化状态""" + try: + logger.debug("开始验证初始化状态") + + required_configs = ['base', 'permission_rules'] + for config_name in required_configs: + if config_name not in self.configs: + logger.error(f"缺少必要配置: {config_name}") + raise ValueError(f"缺少必要配置: {config_name}") + + required_dirs = ['config', 'logs', 'plugins'] + for dir_name in required_dirs: + if not Path(dir_name).exists(): + logger.error(f"必要目录不存在: {dir_name}") + raise ValueError(f"必要目录不存在: {dir_name}") + + logger.debug("初始化状态验证通过") + + except Exception as e: + logger.error(f"验证初始化状态时出错: {str(e)}", exc_info=True) + raise + + def _get_default_base_config(self) -> Dict: + """获取默认基础配置""" + return { + 'framework': { + 'name': 'SenSu', + 'version': 'Alpha_0.2.0', + 'debug': True + }, + 'logging': { + 'level': 'INFO', + 'debug_level_file': True, + 'max_log_files': 20, + 'max_file_size': '10MB' + }, + 'tui': { + 'layout': { + 'grid-rows': '4fr 5fr 1fr' + } + }, + 'services': { + 'internet': { + 'ws_port': 8765, + 'api_port': 8000, + 'enable_reverse_proxy': False + } + }, + 'plugins': { + 'auto_load': True, + 'hot_reload': True, + 'max_retry_count': 3, + 'isolation': False, # 默认不隔离 (需要 Web 页面的插件必须非隔离) + }, + 'auto_start_scripts': { + 'enabled': True, + 'scripts': [ + # 示例: 自动拉起 cyrene_debug_server.py + # {'name': 'cyrene_debug', 'path': '~/cyrene_debug_server.py', + # 'enabled': True, 'args': [], 'cwd': '~'}, + ] + } + } + + def _get_default_permission_rules(self) -> Dict: + """获取默认权限规则""" + return { + 'permission_levels': ['read', 'write', 'execute', 'admin'], + 'default_permissions': [ + 'framework.status.read', + 'plugin.self.info.read' + ], + 'admin_permissions': [ + 'framework.*', + 'plugin.*', + 'service.*' + ] + } + + def _save_config(self, file_path: Path, config: Dict): + """保存配置到文件""" + try: + with open(file_path, 'w', encoding='utf-8') as f: + yaml.dump(config, f, default_flow_style=False, allow_unicode=True) + logger.debug(f"配置保存到: {file_path}") + except Exception as e: + logger.error(f"保存配置到 {file_path} 时出错: {str(e)}", exc_info=True) + + def get_config(self, config_name: str) -> Dict: + """获取配置""" + try: + config = self.configs.get(config_name) + if not config: + logger.error(f"配置不存在: {config_name}") + raise ValueError(f"配置 {config_name} 不存在") + logger.debug(f"获取配置: {config_name}") + return config + except Exception as e: + logger.error(f"获取配置 {config_name} 时出错: {str(e)}", exc_info=True) + raise + + def shutdown(self): + """关闭初始化服务""" + try: + logger.info("关闭初始化服务") + # 终止所有自动启动的脚本 + for entry in self.script_processes: + proc = entry.get("proc") + if proc and proc.returncode is None: + try: + os.killpg(os.getpgid(proc.pid), signal.SIGTERM) + logger.info(f" 已终止: {entry.get('name', '?')} (pid={proc.pid})") + except ProcessLookupError: + pass + except Exception as kill_err: + logger.warning( + f" 终止失败 {entry.get('name', '?')}: {kill_err}" + ) + self.script_processes.clear() + self.configs.clear() + logger.debug("初始化服务关闭完成") + except Exception as e: + logger.error(f"关闭初始化服务时出错: {str(e)}", exc_info=True) diff --git a/services/internet_service.py b/services/internet_service.py new file mode 100644 index 0000000..c41acec --- /dev/null +++ b/services/internet_service.py @@ -0,0 +1,501 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import logging +import asyncio +from typing import Dict, List, Callable, Any, Optional +from pathlib import Path +import aiohttp +from aiohttp import web +import json +import ssl + +logger = logging.getLogger(__name__) + +class InternetService: + """网络服务 - 管理HTTP和WebSocket服务器""" + + def __init__(self, config: Dict, service_manager): + self.config = config + self.service_manager = service_manager + self.http_app = web.Application() + self._setup_security_middleware() + self.http_runner = None + self.ws_connections: Dict[str, List] = {} + self.plugin_routes: Dict[str, List] = {} + self.is_running = False + + # 从配置获取端口 + internet_config = config.get('internet', {}) + + ws_config = internet_config.get('websocket', {}) + self.ws_host = ws_config.get('host', '0.0.0.0') + self.ws_port = ws_config.get('port', 8765) + + http_config = internet_config.get('http', {}) + self.http_host = http_config.get('host', '0.0.0.0') + self.http_port = http_config.get('port', 8000) + + logger.debug("InternetService初始化开始") + + async def start(self): + """启动网络服务 - 精准错误处理与端口复用版""" + try: + logger.info("启动网络服务") + self._setup_default_routes() + + # 初始化 AppRunner + self.http_runner = web.AppRunner(self.http_app) + await self.http_runner.setup() + + # SO_REUSEPORT: Linux/macOS only, Windows 不支持 + import sys as _sys + _reuse_port = _sys.platform != "win32" + + # 1. 启动 HTTP 站点 + try: + self.site = web.TCPSite( + self.http_runner, self.http_host, self.http_port, + reuse_address=True, reuse_port=_reuse_port, + ) + await self.site.start() + logger.info(f"✅ HTTP 服务已绑定: {self.http_host}:{self.http_port}") + except OSError as e: + logger.error(f"❌ HTTP 端口 {self.http_port} 绑定失败: {e}") + await self.http_runner.cleanup() + return False + + # 2. 启动 WebSocket 站点 (独立端口) + try: + self.ws_site = web.TCPSite( + self.http_runner, self.ws_host, self.ws_port, + reuse_address=True, reuse_port=_reuse_port, + ) + await self.ws_site.start() + logger.info(f"✅ WebSocket 服务已绑定: {self.ws_host}:{self.ws_port}") + except OSError as e: + logger.error(f"❌ WebSocket 端口 {self.ws_port} 绑定失败: {e}") + logger.warning("💡 WS端口可能处于 TIME_WAIT,请等待30秒或更换 config 中的 websocket.port") + await self.http_runner.cleanup() # 回滚已启动的 HTTP + return False + + await self.save_network_config() + self.is_running = True + logger.info("🌐 网络服务启动完成") + return True + + except Exception as e: + logger.error(f"❌ 启动网络服务异常: {str(e)}", exc_info=True) + return False + + + async def check_service_health(self) -> Dict[str, Any]: + """检查服务健康状况""" + try: + import socket + + health_info = { + "is_running": self.is_running, + "http_port": self.http_port, + "websocket_port": self.ws_port, + "http_active": False, + "dependencies_available": self._check_dependencies(), + "error": None + } + + # 检查端口是否在监听 + if self.is_running: + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(2) + result = sock.connect_ex(('localhost', self.http_port)) + sock.close() + health_info["http_active"] = (result == 0) + except Exception as e: + health_info["error"] = f"端口检查失败: {str(e)}" + + return health_info + + except Exception as e: + return { + "is_running": False, + "error": f"健康检查失败: {str(e)}" + } + + def _check_dependencies(self) -> bool: + """检查必要的依赖包""" + try: + import aiohttp + import yaml + return True + except ImportError as e: + logger.error(f"❌ 缺少依赖包: {str(e)}") + return False + + async def save_network_config(self): + """保存网络配置""" + try: + config_path = Path("config") / "services" / "network_routes.yaml" + config_path.parent.mkdir(parents=True, exist_ok=True) + + config_data = { + "plugin_routes": self.get_plugin_routes(), + "last_updated": asyncio.get_event_loop().time(), + "http_port": self.http_port, + "websocket_port": self.ws_port + } + + import yaml + with open(config_path, 'w', encoding='utf-8') as f: + yaml.dump(config_data, f, default_flow_style=False, allow_unicode=True, indent=2) + + logger.info(f"网络配置已保存: {config_path}") + + except Exception as e: + logger.error(f"保存网络配置时出错: {str(e)}") + + def _setup_security_middleware(self): + """注入安全响应头 + 错误脱敏中间件""" + + @web.middleware + async def security_headers(request, handler): + try: + resp = await handler(request) + except web.HTTPException: + raise + except Exception as e: + # 生产模式: 脱敏错误,仅返回通用消息,详细信息写日志 + logger.error(f"未捕获异常 {request.method} {request.path}: {e}", exc_info=True) + resp = web.json_response( + {"error": "Internal server error"}, status=500 + ) + resp.headers.setdefault("X-Content-Type-Options", "nosniff") + resp.headers.setdefault("X-Frame-Options", "DENY") + resp.headers.setdefault("X-XSS-Protection", "1; mode=block") + resp.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin") + return resp + + self.http_app.middlewares.append(security_headers) + + def _setup_default_routes(self): + """设置默认路由""" + from services.web_panel.utils.auth import panel_auth + + # 健康检查端点 (公开,不暴露内部信息) + self.http_app.router.add_get('/health', self._handle_health_check) + + # 插件API端点 (加认证) + self.http_app.router.add_get('/api/plugins', panel_auth(self._handle_get_plugins)) + self.http_app.router.add_get('/api/commands', panel_auth(self._handle_get_commands)) + + # 数据接收端点 (加认证) + self.http_app.router.add_post('/api/data', panel_auth(self._handle_data_receive)) + + logger.debug("默认路由设置完成 (已加认证)") + + async def register_plugin_route(self, plugin_name: str, route_path: str, + handler: Callable, methods: List[str] = ["GET"], + require_auth: bool = True): + """为插件注册HTTP路由 - 修复冻结路由器问题""" + try: + # 规范化路径 + if not route_path.startswith('/'): + route_path = '/' + route_path + + full_path = f"/{plugin_name}{route_path}" + + # 创建包装器处理权限验证 + async def wrapped_handler(request): + try: + # 权限验证 + if require_auth: + auth_result = await self._check_plugin_auth(plugin_name, request) + if not auth_result['allowed']: + return web.json_response( + {"error": "权限不足", "details": auth_result['reason']}, + status=403 + ) + + # 调用插件处理器 + return await handler(request) + + except Exception as e: + logger.error(f"插件路由处理出错 {full_path}: {str(e)}") + return web.json_response( + {"error": "内部服务器错误", "details": str(e)}, + status=500 + ) + + # 检查路由器是否已冻结 + if hasattr(self.http_app.router, '_frozen') and self.http_app.router._frozen: + logger.warning(f"路由器已冻结,无法注册新路由: {full_path}") + logger.info("💡 建议: 在启动网络服务前注册所有插件路由") + return + + # 注册路由 + for method in methods: + self.http_app.router.add_route(method.upper(), full_path, wrapped_handler) + + # 记录路由信息 + if plugin_name not in self.plugin_routes: + self.plugin_routes[plugin_name] = [] + + self.plugin_routes[plugin_name].append({ + 'path': full_path, + 'methods': methods, + 'require_auth': require_auth + }) + + logger.info(f"注册插件路由: {plugin_name} -> {full_path} [{','.join(methods)}]") + + except RuntimeError as e: + if "frozen router" in str(e): + logger.error(f"❌ 无法注册路由 {full_path}: 路由器已冻结") + logger.info("💡 解决方案: 在启动网络服务前注册插件路由") + else: + logger.error(f"注册插件路由时出错: {str(e)}", exc_info=True) + raise + except Exception as e: + logger.error(f"注册插件路由时出错: {str(e)}", exc_info=True) + raise + + async def register_plugin_websocket(self, plugin_name: str, ws_path: str, + handler: Callable, require_auth: bool = True): + """为插件注册WebSocket路由""" + try: + # 规范化路径 + if not ws_path.startswith('/'): + ws_path = '/' + ws_path + + full_path = f"/plugin/{plugin_name}/ws{ws_path}" + + async def websocket_handler(request): + try: + # 权限验证 + if require_auth: + auth_result = await self._check_plugin_auth(plugin_name, request) + if not auth_result['allowed']: + return web.json_response( + {"error": "WebSocket连接权限不足"}, + status=403 + ) + + # 建立WebSocket连接 + ws = web.WebSocketResponse() + await ws.prepare(request) + + # 记录连接 + connection_id = f"{plugin_name}_{id(ws)}" + if plugin_name not in self.ws_connections: + self.ws_connections[plugin_name] = [] + self.ws_connections[plugin_name].append(ws) + + logger.debug(f"WebSocket连接建立: {connection_id}") + + # 调用插件处理器 + await handler(ws, request) + + # 清理连接 + self.ws_connections[plugin_name].remove(ws) + + return ws + + except Exception as e: + logger.error(f"WebSocket处理出错 {full_path}: {str(e)}") + return web.json_response( + {"error": "WebSocket连接失败"}, + status=500 + ) + + # 注册WebSocket路由 + self.http_app.router.add_route('GET', full_path, websocket_handler) + + # 记录路由信息 + if plugin_name not in self.plugin_routes: + self.plugin_routes[plugin_name] = [] + + self.plugin_routes[plugin_name].append({ + 'path': full_path, + 'methods': ['WEBSOCKET'], + 'require_auth': require_auth + }) + + logger.info(f"注册插件WebSocket: {plugin_name} -> {full_path}") + + except Exception as e: + logger.error(f"注册插件WebSocket时出错: {str(e)}", exc_info=True) + raise + + async def _check_plugin_auth(self, plugin_name: str, request) -> Dict[str, Any]: + """检查插件路由权限 — 先验证用户身份,再检查插件权限""" + try: + # 1. 验证用户身份 (panel token) + token = request.cookies.get("panel_token") + if not token: + auth_hdr = request.headers.get("Authorization", "") + if auth_hdr.startswith("Bearer "): + token = auth_hdr.split(" ", 1)[1] + if not token: + return {"allowed": False, "reason": "未认证"} + + session_store = request.app.get("panel_session_store", {}) + if token not in session_store: + # 回退到 API Key 验证 + from services.web_panel.routes.apikeys import validate_api_key + if not validate_api_key(token): + return {"allowed": False, "reason": "会话无效或已过期"} + + # 2. 检查插件是否有网络访问权限 (自动放行已加载的插件) + permission_service = self.service_manager.get_service("permission") + if permission_service: + # 插件已成功加载即视为拥有基本网络权限 + plugin_svc = self.service_manager.get_service("plugin") + if plugin_svc and plugin_name not in plugin_svc.plugins: + return {"allowed": False, "reason": "插件未加载"} + + return {"allowed": True, "reason": "权限验证通过"} + + except Exception as e: + logger.error(f"权限检查时出错: {str(e)}") + return {"allowed": False, "reason": "权限检查失败"} + + async def broadcast_to_websockets(self, plugin_name: str, message: Dict): + """向插件的所有WebSocket连接广播消息""" + try: + if plugin_name not in self.ws_connections: + return + + message_json = json.dumps(message, ensure_ascii=False) + disconnected = [] + + for ws in self.ws_connections[plugin_name]: + try: + if not ws.closed: + await ws.send_str(message_json) + else: + disconnected.append(ws) + except Exception as e: + logger.error(f"WebSocket广播消息失败: {str(e)}") + disconnected.append(ws) + + # 清理断开连接的WebSocket + for ws in disconnected: + self.ws_connections[plugin_name].remove(ws) + + logger.debug(f"WebSocket广播完成: {plugin_name} -> {len(self.ws_connections[plugin_name])} 个连接") + + except Exception as e: + logger.error(f"WebSocket广播时出错: {str(e)}", exc_info=True) + + # 默认路由处理器 + async def _handle_health_check(self, request): + """健康检查端点""" + return web.json_response({ + "status": "healthy", + "service": "internet", + "timestamp": asyncio.get_event_loop().time() + }) + + async def _handle_get_plugins(self, request): + """获取插件列表""" + try: + plugin_service = self.service_manager.get_service("plugin") + if not plugin_service: + return web.json_response({"error": "插件服务不可用"}, status=503) + + plugins_info = [] + for name, info in plugin_service.plugin_info.items(): + plugins_info.append({ + "name": name, + "version": info.version, + "description": info.description, + "enabled": info.enabled, + "loaded": info.loaded + }) + + return web.json_response({ + "plugins": plugins_info, + "count": len(plugins_info) + }) + + except Exception as e: + logger.error(f"获取插件列表时出错: {str(e)}") + return web.json_response({"error": "内部服务器错误"}, status=500) + + async def _handle_get_commands(self, request): + """获取命令列表""" + try: + command_service = self.service_manager.get_service("command") + if not command_service: + return web.json_response({"error": "命令服务不可用"}, status=503) + + command_list = command_service.get_command_list() + + return web.json_response({ + "commands": command_list, + "count": len(command_list) + }) + + except Exception as e: + logger.error(f"获取命令列表时出错: {str(e)}") + return web.json_response({"error": "内部服务器错误"}, status=500) + + async def _handle_data_receive(self, request): + """处理跨端数据传输""" + try: + data = await request.json() + + # 获取插件桥接服务 + plugin_bridge = self.service_manager.get_service("plugin_bridge") + if plugin_bridge: + # 广播数据到所有插件 + await plugin_bridge.broadcast_to_plugins( + "network.data.receive", + { + "source": request.remote, + "data": data, + "timestamp": asyncio.get_event_loop().time() + } + ) + + return web.json_response({ + "status": "success", + "message": "数据接收成功", + "timestamp": asyncio.get_event_loop().time() + }) + + except Exception as e: + logger.error(f"处理跨端数据时出错: {str(e)}") + return web.json_response({ + "error": "数据接收失败", + "details": str(e) + }, status=400) + + def get_plugin_routes(self, plugin_name: str = None) -> Dict: + """获取插件路由信息""" + if plugin_name: + return self.plugin_routes.get(plugin_name, []) + else: + return self.plugin_routes.copy() + + async def shutdown(self): + """关闭网络服务""" + try: + logger.info("关闭网络服务") + self.is_running = False + + # 关闭所有WebSocket连接 + for plugin_name, connections in self.ws_connections.items(): + for ws in connections: + if not ws.closed: + await ws.close() + self.ws_connections[plugin_name].clear() + + # 关闭HTTP服务器 + if self.http_runner: + await self.http_runner.cleanup() + + logger.debug("网络服务关闭完成") + + except Exception as e: + logger.error(f"关闭网络服务时出错: {str(e)}", exc_info=True) diff --git a/services/log_service.py b/services/log_service.py new file mode 100644 index 0000000..09bf231 --- /dev/null +++ b/services/log_service.py @@ -0,0 +1,336 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import logging +import logging.handlers +import os +import asyncio +from pathlib import Path +from typing import Dict, List, Callable +import json +from datetime import datetime + +logger = logging.getLogger(__name__) + +class LogService: + """日志服务""" + + def __init__(self, config: Dict): + self.config = config + self.log_consumers: List[Callable] = [] + self.log_buffer: List[Dict] = [] + self.buffer_size = 100 + self.log_dir = Path("logs") + self.is_initialized = False + self._in_emit = False + + # 文件数量限制 + self.max_log_files_per_folder = self.config['logging']['max_log_files'] # 每个文件夹文件数量上限 + + # 立即初始化日志系统 + self._setup_logging_sync() + + def _setup_logging_sync(self): + """同步设置日志系统 - 增强版""" + try: + # 创建日志目录 + self.log_dir.mkdir(exist_ok=True) + (self.log_dir / "debug").mkdir(exist_ok=True) + (self.log_dir / "runtime").mkdir(exist_ok=True) + + # 清理旧日志文件(在创建新文件之前) + self._cleanup_old_log_files() + + # 生成基于时间戳和会话ID的日志文件名 + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + session_id = os.urandom(4).hex() # 生成8位随机会话ID + runtime_log_file = f"framework_{timestamp}_{session_id}.log" + debug_log_file = f"debug_{timestamp}_{session_id}.log" + + # 保存当前会话的日志文件名(用于后续引用) + self.current_session_logs = { + 'runtime': runtime_log_file, + 'debug': debug_log_file, + 'timestamp': timestamp, + 'session_id': session_id + } + + # 配置根日志记录器 + root_logger = logging.getLogger() + root_logger.setLevel(logging.DEBUG) + + # 清除现有处理器 + for handler in root_logger.handlers[:]: + root_logger.removeHandler(handler) + + # 创建过滤器实例 + shared_filter = self.SafeLogFilter(self) + + # 控制台处理器 + console_handler = logging.StreamHandler() + console_handler.setLevel(getattr(logging, self.config['logging']['level'], logging.INFO)) + + console_formatter = logging.Formatter( + '%(asctime)s [%(levelname)-8s] %(name)s: %(message)s', + datefmt='%H:%M:%S' + ) + console_handler.setFormatter(console_formatter) + console_handler.addFilter(shared_filter) + root_logger.addHandler(console_handler) + + # 文件处理器 + if self.config['logging'].get('enable_file_logging', True): + file_formatter = logging.Formatter( + '%(asctime)s [%(levelname)-8s] %(name)s:%(lineno)d - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S' + ) + + # 运行时文件处理器 + runtime_handler = logging.handlers.RotatingFileHandler( + self.log_dir / "runtime" / runtime_log_file, + maxBytes=self._parse_size(self.config['logging'].get('max_file_size', '10MB')), + backupCount=self.config['logging'].get('max_log_files', 3) + ) + runtime_handler.setLevel(getattr(logging, self.config['logging']['level'], logging.INFO)) + runtime_handler.setFormatter(file_formatter) + runtime_handler.addFilter(shared_filter) + root_logger.addHandler(runtime_handler) + + # Debug文件处理器 + if self.config['logging'].get('debug_level_file', True): + debug_handler = logging.handlers.RotatingFileHandler( + self.log_dir / "debug" / debug_log_file, + maxBytes=self._parse_size(self.config['logging'].get('max_file_size', '10MB')), + backupCount=self.config['logging'].get('max_log_files', 3) + ) + debug_handler.setLevel(logging.DEBUG) + debug_handler.setFormatter(file_formatter) + debug_handler.addFilter(shared_filter) + root_logger.addHandler(debug_handler) + + # 压制 aiohttp 内部 WS 帧日志 (防止 WS 消息内容刷爆日志) + for aio_name in ['aiohttp', 'aiohttp.access', 'aiohttp.web', 'aiohttp.websocket']: + logging.getLogger(aio_name).setLevel(logging.WARNING) + + self.is_initialized = True + logger.info(f"✅ 日志系统初始化完成 - 会话ID: {session_id}") + logger.info(f"📝 运行时日志: logs/runtime/{runtime_log_file}") + logger.info(f"🐛 调试日志: logs/debug/{debug_log_file}") + + except Exception as e: + logger.error(f"日志系统设置失败: {e}") + # 回退到基础配置 + logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s') + self.is_initialized = True + + def _cleanup_old_log_files(self): + """清理旧的日志文件,保持每个文件夹文件上限""" + try: + logger.debug("开始清理旧日志文件...") + + # 清理 runtime 文件夹 + runtime_dir = self.log_dir / "runtime" + if runtime_dir.exists(): + runtime_files = list(runtime_dir.glob("*.log")) + self._remove_old_files(runtime_files, "runtime") + + # 清理 debug 文件夹 + debug_dir = self.log_dir / "debug" + if debug_dir.exists(): + debug_files = list(debug_dir.glob("*.log")) + self._remove_old_files(debug_files, "debug") + + except Exception as e: + logger.error(f"清理旧日志文件时出错: {e}") + + def _remove_old_files(self, files: List[Path], folder_name: str): + """删除最旧的文件,直到文件数量不超过限制""" + try: + if len(files) <= self.max_log_files_per_folder: + logger.debug(f"{folder_name} 文件夹文件数量正常: {len(files)}/{self.max_log_files_per_folder}") + return + + # 按修改时间排序(最旧的在前) + files_sorted = sorted(files, key=lambda x: x.stat().st_mtime) + + # 计算需要删除的文件数量 + files_to_remove = len(files_sorted) - self.max_log_files_per_folder + + if files_to_remove > 0: + logger.info(f"清理 {folder_name} 文件夹: 删除 {files_to_remove} 个旧日志文件") + + for i in range(files_to_remove): + old_file = files_sorted[i] + try: + old_file.unlink() + logger.debug(f"删除旧日志文件: {old_file.name}") + except Exception as e: + logger.error(f"删除文件失败 {old_file}: {e}") + + logger.info(f"{folder_name} 文件夹清理完成: {self.max_log_files_per_folder} 个文件") + + except Exception as e: + logger.error(f"删除 {folder_name} 文件夹旧文件时出错: {e}") + + def cleanup_log_files(self): + """手动清理日志文件(可以定期调用)""" + try: + logger.info("开始手动清理日志文件...") + self._cleanup_old_log_files() + logger.info("日志文件清理完成") + except Exception as e: + logger.error(f"手动清理日志文件时出错: {e}") + + def get_log_file_counts(self) -> Dict[str, int]: + """获取当前日志文件数量统计""" + try: + runtime_count = len(list((self.log_dir / "runtime").glob("*.log"))) + debug_count = len(list((self.log_dir / "debug").glob("*.log"))) + + return { + "runtime": runtime_count, + "debug": debug_count, + "max_limit": self.max_log_files_per_folder + } + except Exception as e: + logger.error(f"获取日志文件统计时出错: {e}") + return {"runtime": 0, "debug": 0, "max_limit": self.max_log_files_per_folder} + + def get_current_session_info(self) -> Dict: + """获取当前会话的日志信息""" + return getattr(self, 'current_session_logs', {}) + + def _parse_size(self, size_str: str) -> int: + """解析文件大小字符串""" + try: + units = {'B': 1, 'KB': 1024, 'MB': 1024**2, 'GB': 1024**3} + number = ''.join(filter(str.isdigit, size_str)) + unit = ''.join(filter(str.isalpha, size_str)).upper() + return int(number) * units.get(unit, 1) + except Exception: + return 10 * 1024 * 1024 + + def add_log_consumer(self, callback: Callable): + """添加日志消费者""" + if callback not in self.log_consumers: + self.log_consumers.append(callback) + logger.debug(f"添加日志消费者,总数: {len(self.log_consumers)}") + + def emit_log(self, log_record: Dict): + """发射日志到消费者""" + if self._in_emit: + return + + self._in_emit = True + + try: + # 格式化日志记录 + timestamp = log_record['timestamp'] + level = log_record['level'] + name = log_record['name'] + message = log_record['message'] + + # 转换时间戳 + if isinstance(timestamp, (int, float)): + timestamp_str = datetime.fromtimestamp(timestamp).strftime('%H:%M:%S') + else: + timestamp_str = str(timestamp) + + formatted_record = { + 'timestamp': timestamp, + 'timestamp_str': timestamp_str, + 'level': level, + 'name': name, + 'message': message, + 'module': log_record.get('module', ''), + 'line': log_record.get('line', 0), + 'formatted_message': f"{timestamp_str} [{level:8}] {name}: {message}", + 'simple_message': f"{timestamp_str} [{level:8}] {message}", + 'original_message': message + } + + # 添加到缓冲区 + self.log_buffer.append(formatted_record) + if len(self.log_buffer) > self.buffer_size: + self.log_buffer.pop(0) + + # 发送给消费者 + for consumer in self.log_consumers: + try: + # 检查TUI级别过滤 + if hasattr(consumer, '_tui_level_filter'): + tui_level = consumer._tui_level_filter + if self._should_display_for_tui(level, tui_level): + consumer(formatted_record) + else: + # 默认发送所有日志 + consumer(formatted_record) + except Exception as e: + logger.error(f"日志消费者处理出错: {e}") + + except Exception as e: + logger.error(f"发射日志时出错: {e}") + finally: + self._in_emit = False + + def _should_display_for_tui(self, log_level: str, tui_level: str) -> bool: + """检查日志是否应该显示在TUI中""" + level_priority = { + 'DEBUG': 10, 'INFO': 20, 'WARNING': 30, 'ERROR': 40, 'CRITICAL': 50 + } + log_priority = level_priority.get(log_level, 0) + tui_priority = level_priority.get(tui_level, 0) + return log_priority >= tui_priority + + def add_tui_log_consumer(self, callback: Callable, tui_level: str = "INFO"): + """专门为TUI添加日志消费者""" + callback._tui_level_filter = tui_level + if callback not in self.log_consumers: + self.log_consumers.append(callback) + logger.debug(f"添加TUI日志消费者,级别: {tui_level}") + + def get_recent_logs(self, count: int = 50) -> List[Dict]: + """获取最近的日志""" + return self.log_buffer[-count:] + + def shutdown(self): + """关闭日志服务""" + self.log_consumers.clear() + logging.shutdown() + logger.info("日志服务已关闭") + + class SafeLogFilter(logging.Filter): + """安全的日志过滤器""" + + def __init__(self, log_service): + super().__init__() + self.log_service = log_service + self._in_filter = False + + def filter(self, record): + """过滤日志记录""" + if self._in_filter: + return True + + self._in_filter = True + + try: + # 创建日志记录 + log_record = { + 'timestamp': record.created, + 'name': record.name, + 'level': record.levelname, + 'message': record.getMessage(), + 'module': record.module, + 'line': record.lineno + } + + # 发射日志记录 + self.log_service.emit_log(log_record) + + except Exception: + pass + finally: + self._in_filter = False + + return True diff --git a/services/permission_service.py b/services/permission_service.py new file mode 100644 index 0000000..9b6a8fd --- /dev/null +++ b/services/permission_service.py @@ -0,0 +1,1054 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import logging +import asyncio +import uuid +import json +from typing import Dict, List, Set, Optional +from pathlib import Path +import yaml + +logger = logging.getLogger(__name__) + +class PermissionService: + """权限服务""" + + def __init__(self, config: Dict, tui_service, core_bridge): + self.config = config + self.tui_service = tui_service + self.core_bridge = core_bridge + self.permission_rules: Dict = {} + self.granted_permissions: Dict[str, Set[str]] = {} + self.pending_requests: Dict[str, Dict] = {} + self.plugin_status: Dict[str, str] = {} # 插件状态跟踪 + self.is_running = False + + # 配置文件路径 + self.config_dir = Path("config") / "permissions" + self.granted_file = self.config_dir / "granted_permissions.json" + self.pending_file = self.config_dir / "pending_requests.json" + self.plugin_status_file = self.config_dir / "plugin_status.json" + + # 确保配置目录存在 + self.config_dir.mkdir(parents=True, exist_ok=True) + + logger.debug("PermissionService初始化开始") + + async def start(self): + """启动权限服务""" + try: + logger.info("启动权限服务") + + # 检查核心桥接服务是否可用 + if not self.core_bridge: + logger.error("核心桥接服务不可用") + return False + + # 加载权限规则 + await self._load_permission_rules() + + # 加载持久化数据 + await self._load_persisted_data() + + # 订阅权限相关事件 + self.core_bridge.subscribe("permission.request", self._handle_permission_request) + self.core_bridge.subscribe("permission.grant", self._handle_permission_grant) + self.core_bridge.subscribe("permission.deny", self._handle_permission_deny) + self.core_bridge.subscribe("permission.ignore", self._handle_permission_ignore) + + # 检查TUI服务连接状态 + if self.tui_service: + logger.debug("TUI服务已连接") + else: + logger.warning("TUI服务未连接,权限请求将显示在控制台") + + self.is_running = True + logger.info("权限服务启动完成") + return True + + except Exception as e: + logger.error(f"启动权限服务时出错: {str(e)}", exc_info=True) + return False + + async def _load_permission_rules(self): + """加载权限规则""" + try: + rules_path = Path("config") / "permissions" / "permission_rules.yaml" + if rules_path.exists(): + with open(rules_path, 'r', encoding='utf-8') as f: + self.permission_rules = yaml.safe_load(f) + logger.debug(f"加载权限规则: {len(self.permission_rules.get('rules', {}))} 条规则") + else: + logger.warning("权限规则文件不存在,使用默认规则") + self.permission_rules = { + "rules": {}, + "default_policy": "ask" + } + except Exception as e: + logger.error(f"加载权限规则时出错: {str(e)}", exc_info=True) + self.permission_rules = { + "rules": {}, + "default_policy": "ask" + } + + async def _load_persisted_data(self): + """加载持久化的权限数据""" + try: + # 如果配置文件不存在,创建空的配置文件 + if not self.granted_file.exists(): + await self._save_granted_permissions() + logger.info("创建空的已授予权限文件") + + if not self.pending_file.exists(): + await self._save_pending_requests() + logger.info("创建空的待处理请求文件") + + if not self.plugin_status_file.exists(): + await self._save_plugin_status() + logger.info("创建空的插件状态文件") + + # 加载已授予权限 + if self.granted_file.exists(): + with open(self.granted_file, 'r', encoding='utf-8') as f: + granted_data = json.load(f) + # 将列表转换回集合 + for plugin, permissions in granted_data.items(): + self.granted_permissions[plugin] = set(permissions) + logger.debug(f"加载已授予权限: {len(self.granted_permissions)} 个插件") + + # 加载待处理请求 + if self.pending_file.exists(): + with open(self.pending_file, 'r', encoding='utf-8') as f: + self.pending_requests = json.load(f) + logger.debug(f"加载待处理请求: {len(self.pending_requests)} 个") + + # 加载插件状态 + if self.plugin_status_file.exists(): + with open(self.plugin_status_file, 'r', encoding='utf-8') as f: + self.plugin_status = json.load(f) + logger.debug(f"加载插件状态: {len(self.plugin_status)} 个插件") + + logger.info("权限持久化数据加载完成") + + except Exception as e: + logger.error(f"加载持久化权限数据时出错: {str(e)}", exc_info=True) + + async def _save_granted_permissions(self): + """保存已授予权限到文件""" + try: + # 将集合转换为列表以便JSON序列化 + granted_data = {} + for plugin, permissions in self.granted_permissions.items(): + granted_data[plugin] = list(permissions) + + with open(self.granted_file, 'w', encoding='utf-8') as f: + json.dump(granted_data, f, ensure_ascii=False, indent=2) + + logger.debug(f"已授予权限已保存: {len(granted_data)} 个插件") + + except Exception as e: + logger.error(f"保存已授予权限时出错: {str(e)}", exc_info=True) + + async def _save_pending_requests(self): + """保存待处理请求到文件""" + try: + with open(self.pending_file, 'w', encoding='utf-8') as f: + json.dump(self.pending_requests, f, ensure_ascii=False, indent=2) + + logger.debug(f"待处理请求已保存: {len(self.pending_requests)} 个") + + except Exception as e: + logger.error(f"保存待处理请求时出错: {str(e)}", exc_info=True) + + async def _save_plugin_status(self): + """保存插件状态到文件""" + try: + with open(self.plugin_status_file, 'w', encoding='utf-8') as f: + json.dump(self.plugin_status, f, ensure_ascii=False, indent=2) + + logger.debug(f"插件状态已保存: {len(self.plugin_status)} 个插件") + + except Exception as e: + logger.error(f"保存插件状态时出错: {str(e)}", exc_info=True) + + async def _save_all_data(self): + """保存所有权限数据""" + try: + await asyncio.gather( + self._save_granted_permissions(), + self._save_pending_requests(), + self._save_plugin_status() + ) + logger.debug("所有权限数据已保存") + except Exception as e: + logger.error(f"保存权限数据时出错: {str(e)}", exc_info=True) + + def _handle_permission_request(self, message: Dict): + """处理权限请求""" + try: + # 从 message 的 data 字段中获取插件名称 + data = message.get('data', {}) + plugin_name = data.get('plugin_name') + logger.debug(f"传递的消息原文:{message}") + logger.debug(f"传递的插件名:{plugin_name}") + requested_permissions = data.get('permissions', []) + request_id = str(uuid.uuid4())[:8] # 简短的请求ID + + # 加强插件名称验证 + if not plugin_name or plugin_name == 'None' or plugin_name.strip() == '': + logger.error(f"无效的插件名称: {repr(plugin_name)}") + logger.debug(f"完整权限请求消息: {message}") + return + + # 验证插件名称格式 + if not self._is_valid_plugin_name(plugin_name): + logger.error(f"插件名称格式无效: {plugin_name}") + return + + # 验证权限列表 + if not requested_permissions or not isinstance(requested_permissions, list): + logger.warning(f"插件 {plugin_name} 请求的权限列表为空或格式错误") + requested_permissions = [] # 确保是列表 + + # 存储待处理请求 + self.pending_requests[request_id] = { + 'plugin_name': plugin_name, + 'permissions': requested_permissions, + 'timestamp': asyncio.get_event_loop().time() + } + + # 更新插件状态 + self.plugin_status[plugin_name] = "pending" + + logger.debug(f"处理权限请求: {plugin_name} -> {len(requested_permissions)} 个权限") + + # 保存数据 + asyncio.create_task(self._save_pending_requests()) + asyncio.create_task(self._save_plugin_status()) + + # 显示用户友好的权限请求界面 + asyncio.create_task(self._delayed_permission_ui(request_id, plugin_name, requested_permissions)) + + except Exception as e: + logger.error(f"处理权限请求时出错: {str(e)}", exc_info=True) + + def _is_valid_plugin_name(self, plugin_name: str) -> bool: + """验证插件名称是否有效""" + try: + if not plugin_name or not isinstance(plugin_name, str): + return False + + # 基本格式检查 + if plugin_name.strip() == '': + return False + + # 检查常见无效值 + invalid_values = ['None', 'null', 'undefined', ''] + if plugin_name in invalid_values: + return False + + # 检查长度限制 + if len(plugin_name) > 100: + return False + + # 检查字符有效性(允许字母、数字、下划线、点、连字符) + import re + if not re.match(r'^[a-zA-Z0-9_\.\-]+$', plugin_name): + return False + + return True + + except Exception as e: + logger.error(f"验证插件名称时出错: {str(e)}") + return False + + async def _delayed_permission_ui(self, request_id: str, plugin_name: str, permissions: List[str]): + """延迟显示权限请求UI,等待TUI就绪""" + try: + # 等待TUI服务就绪 + tui_ready = await self.wait_for_tui_ready() + + if tui_ready: + logger.debug(f"TUI已就绪,显示权限请求: {plugin_name}") + await self._show_permission_request_ui(request_id, plugin_name, permissions) + else: + # TUI未就绪,使用回退显示 + logger.warning(f"TUI未就绪,使用控制台显示权限请求: {plugin_name}") + message = f"🔐 插件 {plugin_name} 请求 {len(permissions)} 个权限 (请求ID: {request_id})" + self._fallback_permission_display(request_id, plugin_name, message) + + except Exception as e: + logger.error(f"延迟显示权限UI时出错: {str(e)}", exc_info=True) + + async def _show_permission_request_ui(self, request_id: str, plugin_name: str, permissions: List[str]): + """显示权限请求用户界面 - 优化显示""" + try: + # 创建更友好的权限描述 + permission_descriptions = { + "plugin.example.read": "📖 读取示例插件数据", + "plugin.example.write": "✏️ 写入示例插件数据", + "plugin.example.execute": "⚡ 执行示例插件操作", + "framework.event.subscribe": "📡 订阅框架事件", + "framework.command.execute": "⌨️ 执行框架命令" + } + + # 构建权限列表显示 + permission_list = [] + for perm in permissions: + desc = permission_descriptions.get(perm, f"🔧 {perm}") + permission_list.append(f" ✅ {desc}") + + permission_display = "\n".join(permission_list) if permission_list else " 无具体权限请求" + + # 显示权限请求界面 - 使用更简洁的格式 + messages = [ + f"🔐 **插件权限请求**", + f"", + f"**插件**: {plugin_name}", + f"**请求权限**:", + permission_display, + f"", + f"**操作选项**:", + f" 🟢 pmallow {request_id} - 同意所有权限", + f" 🟡 pmallow {request_id} read,write - 仅同意部分权限", + f" 🔴 pmdeny {request_id} - 拒绝所有权限", + f" ⏸️ ignore {request_id} - 暂时忽略", + f"", + f"**快捷命令**:", + f" pmallow all - 同意所有待处理请求", + f" pmdeny all - 拒绝所有待处理请求" + f" pmignore all - 忽略所有待处理请求" + ] + + # 逐行发送消息,确保每行都能正确显示 + if self.tui_service and hasattr(self.tui_service, 'show_message'): + for line in messages: + if line.strip(): # 忽略空行 + self.tui_service.show_message(line, "warning", persistent=True) + await asyncio.sleep(0.1) # 小延迟确保消息顺序 + else: + # TUI服务不可用,使用控制台输出 + self._fallback_permission_display(request_id, plugin_name, message) + + except Exception as e: + logger.error(f"显示权限请求界面时出错: {str(e)}", exc_info=True) + + def _fallback_permission_display(self, request_id: str, plugin_name: str, message: str): + """回退到控制台显示权限请求""" + try: + print("\n" + "="*60) + print(message) + print("="*60) + print("🐱 请输入命令处理权限请求:") + logger.info(f"权限请求已显示在控制台: {plugin_name} -> {request_id}") + except Exception as e: + logger.error(f"回退显示权限请求时出错: {str(e)}") + + def _handle_permission_grant(self, message: Dict): + """处理权限授予""" + try: + request_id = message.get('request_id') + granted_permissions = message.get('permissions', []) + + if request_id in self.pending_requests: + request = self.pending_requests[request_id] + plugin_name = request['plugin_name'] + + # 如果未指定具体权限,授予所有请求的权限 + if not granted_permissions: + granted_permissions = request['permissions'] + + # 授予权限 + asyncio.create_task(self.grant_permissions(plugin_name, granted_permissions)) + + # 更新插件状态 + self.plugin_status[plugin_name] = "granted" + + # 移除待处理请求 + del self.pending_requests[request_id] + + # 显示成功消息 + success_msg = f"✅ 已为插件 '{plugin_name}' 授予 {len(granted_permissions)} 个权限" + if self.tui_service and hasattr(self.tui_service, 'show_message'): + self.tui_service.show_message(success_msg, "success") + else: + print(f"🐱 {success_msg}") + + logger.info(f"权限授予完成: {plugin_name} -> {granted_permissions}") + + except Exception as e: + logger.error(f"处理权限授予时出错: {str(e)}", exc_info=True) + + def _handle_permission_deny(self, message: Dict): + """处理权限拒绝""" + try: + request_id = message.get('request_id') + + if request_id in self.pending_requests: + request = self.pending_requests[request_id] + plugin_name = request['plugin_name'] + + # 更新插件状态 + self.plugin_status[plugin_name] = "denied" + + # 移除待处理请求 + del self.pending_requests[request_id] + + # 保存数据 + asyncio.create_task(self._save_pending_requests()) + asyncio.create_task(self._save_plugin_status()) + + # 显示拒绝消息 + deny_msg = f"❌ 已拒绝插件 '{plugin_name}' 的权限请求" + if self.tui_service and hasattr(self.tui_service, 'show_message'): + self.tui_service.show_message(deny_msg, "error") + else: + print(f"🐱 {deny_msg}") + + logger.info(f"权限拒绝完成: {plugin_name}") + + except Exception as e: + logger.error(f"处理权限拒绝时出错: {str(e)}", exc_info=True) + + def _handle_permission_ignore(self, message: Dict): + """处理权限忽略""" + try: + request_id = message.get('request_id') + + if request_id in self.pending_requests: + request = self.pending_requests[request_id] + plugin_name = request['plugin_name'] + + # 更新插件状态 + self.plugin_status[plugin_name] = "ignored" + + # 移除待处理请求 + del self.pending_requests[request_id] + + # 保存数据 + asyncio.create_task(self._save_pending_requests()) + asyncio.create_task(self._save_plugin_status()) + + # 显示忽略消息 + ignore_msg = f"⏸️ 已暂时忽略插件 '{plugin_name}' 的权限请求" + if self.tui_service and hasattr(self.tui_service, 'show_message'): + self.tui_service.show_message(ignore_msg, "info") + else: + print(f"🐱 {ignore_msg}") + + logger.info(f"权限请求被忽略: {plugin_name}") + + except Exception as e: + logger.error(f"处理权限忽略时出错: {str(e)}", exc_info=True) + + async def grant_permissions(self, plugin_name: str, permissions: List[str]): + """授予权限""" + try: + if plugin_name not in self.granted_permissions: + self.granted_permissions[plugin_name] = set() + + for permission in permissions: + self.granted_permissions[plugin_name].add(permission) + + # 更新插件状态 + self.plugin_status[plugin_name] = "granted" + + logger.debug(f"授予权限: {plugin_name} -> {permissions}") + + # 保存数据 + await asyncio.gather( + self._save_granted_permissions(), + self._save_plugin_status() + ) + + # 通知插件权限已授予 + await self.core_bridge.publish("permission.granted", { + 'plugin_name': plugin_name, + 'permissions': permissions + }) + + except Exception as e: + logger.error(f"授予权限时出错: {str(e)}", exc_info=True) + raise + + def has_permission(self, plugin_name: str, permission: str) -> bool: + """检查是否具有权限""" + try: + # 检查显式授予的权限 + if plugin_name in self.granted_permissions: + if permission in self.granted_permissions[plugin_name]: + return True + + # 检查权限规则 + rule_key = f"{plugin_name}.{permission}" + if rule_key in self.permission_rules.get('rules', {}): + return self.permission_rules['rules'][rule_key] == 'allow' + + # 默认策略 + default_policy = self.permission_rules.get('default_policy', 'ask') + return default_policy == 'allow' + + except Exception as e: + logger.error(f"检查权限时出错: {str(e)}", exc_info=True) + return False + + async def request_permissions(self, plugin_name: str, permissions: List[str]) -> bool: + """请求权限 - 非阻塞版本""" + try: + logger.debug(f"权限请求: {plugin_name} -> {permissions}") + + # 首先检查是否已经有所有权限 + if all(self.has_permission(plugin_name, perm) for perm in permissions): + logger.debug(f"插件 {plugin_name} 已有所有请求的权限") + return True + + # 标记插件为等待权限状态 + self.plugin_status[plugin_name] = "pending" + + # 发布权限请求事件(非阻塞) + await self.core_bridge.publish("permission.request", { + 'plugin_name': plugin_name, + 'permissions': permissions + }) + + # 立即返回,不等待用户响应 + # 插件将在权限被授予后通过事件机制得到通知 + logger.debug(f"权限请求已发送,等待用户响应: {plugin_name}") + return True # 立即返回True,让插件继续加载 + + except Exception as e: + logger.error(f"请求权限时出错: {str(e)}", exc_info=True) + return True # 出错时也返回True,避免阻塞插件加载 + + def get_pending_requests(self) -> Dict[str, Dict]: + """获取待处理请求""" + return self.pending_requests.copy() + + async def process_permission_command(self, command: str, args: List[str]) -> str: + """处理权限相关命令""" + try: + if command == "pmallow": + if not args: + return "❌ 请指定请求ID,如: pmallow abc123" + + request_id = args[0] + + if request_id == "all": + # 同意所有待处理请求 + count = len(self.pending_requests) + for rid in list(self.pending_requests.keys()): + self._handle_permission_grant({'request_id': rid}) + return f"✅ 已同意所有 {count} 个待处理权限请求" + + # 检查特定权限 + specific_permissions = [] + if len(args) > 1: + specific_permissions = args[1].split(',') + + self._handle_permission_grant({ + 'request_id': request_id, + 'permissions': specific_permissions + }) + return f"✅ 已处理权限请求 {request_id}" + + elif command == "pmdeny": + if not args: + return "❌ 请指定请求ID,如: pmdeny abc123" + + request_id = args[0] + + if request_id == "all": + # 拒绝所有待处理请求 + count = len(self.pending_requests) + for rid in list(self.pending_requests.keys()): + await self._handle_permission_deny({'request_id': rid}) + return f"❌ 已拒绝所有 {count} 个待处理权限请求" + + await self._handle_permission_deny({'request_id': request_id}) + return f"❌ 已拒绝权限请求 {request_id}" + + elif command == "pmignore": + if not args: + return "❌ 请指定请求ID,如: pmignore abc123" + + request_id = args[0] + + if request_id == "all": + # 忽略所有待处理请求 + count = len(self.pending_requests) + for rid in list(self.pending_requests.keys()): + await self._handle_permission_ignore({'request_id': rid}) + return f"⏸️ 已忽略所有 {count} 个待处理权限请求" + + await self._handle_permission_ignore({'request_id': request_id}) + return f"⏸️ 已忽略权限请求 {request_id}" + + elif command == "permissions": + # 显示当前权限状态 + return await self._show_permission_status() + + elif command == "pm_plugin_status": + # 显示插件状态 + return await self._show_plugin_status() + + elif command == "pmpending" or command == "pmrequests": + # 查询待授权权限请求列表 + return await self._show_pending_requests() + + elif command == "pmhelp": + # 显示权限命令帮助 + return self._show_permission_help() + + elif command == "pmfix": + # 修复权限状态 + return await self._fix_permission_status() + + elif command == "pmclean": + # 清理权限数据 + return await self._clean_permission_data(args) + + elif command == "pmbackup": + # 备份权限数据 + return await self._backup_permission_data() + + elif command == "pmtest": + # 测试权限配置文件 + return await self._test_permission_config() + + else: + return f"❌ 未知权限命令: {command}\n💡 输入 'pmhelp' 查看可用命令" + + except Exception as e: + logger.error(f"处理权限命令时出错: {str(e)}", exc_info=True) + return f"❌ 处理命令时出错: {str(e)}" + + async def _test_permission_config(self) -> str: + """测试权限配置文件""" + try: + logger.debug("开始权限配置文件测试") + result = ["🔧 **权限配置文件测试**"] + result.append("=" * 50) + + # 测试配置文件路径 + result.append("📁 **配置文件路径**:") + result.append(f" 配置目录: {self.config_dir}") + result.append(f" 已授予权限: {self.granted_file}") + result.append(f" 待处理请求: {self.pending_file}") + result.append(f" 插件状态: {self.plugin_status_file}") + + # 测试文件存在性 + result.append("\n✅ **文件存在性检查**:") + config_files = [ + ("配置目录", self.config_dir, self.config_dir.exists()), + ("已授予权限", self.granted_file, self.granted_file.exists()), + ("待处理请求", self.pending_file, self.pending_file.exists()), + ("插件状态", self.plugin_status_file, self.plugin_status_file.exists()) + ] + + for name, path, exists in config_files: + status = "✅ 存在" if exists else "❌ 不存在" + result.append(f" {name}: {status}") + + # 测试写入权限 + result.append("\n✏️ **写入权限测试**:") + try: + test_data = {"test": "test_data", "timestamp": asyncio.get_event_loop().time()} + with open(self.config_dir / "test_write.json", 'w', encoding='utf-8') as f: + json.dump(test_data, f, ensure_ascii=False, indent=2) + + # 读取测试 + with open(self.config_dir / "test_write.json", 'r', encoding='utf-8') as f: + read_data = json.load(f) + + # 清理测试文件 + (self.config_dir / "test_write.json").unlink(missing_ok=True) + + result.append(" ✅ 读写测试: 成功") + except Exception as e: + result.append(f" ❌ 读写测试: 失败 - {str(e)}") + + # 显示当前数据状态 + result.append("\n📊 **当前数据状态**:") + result.append(f" 已授予权限: {len(self.granted_permissions)} 个插件") + result.append(f" 待处理请求: {len(self.pending_requests)} 个") + result.append(f" 插件状态: {len(self.plugin_status)} 个") + + return "\n".join(result) + + except Exception as e: + logger.error(f"测试权限配置时出错: {str(e)}", exc_info=True) + return f"❌ 测试权限配置时出错: {str(e)}" + + async def _clean_permission_data(self, args: List[str]) -> str: + """清理权限数据""" + try: + if not args: + return "❌ 请指定清理类型\n💡 可用选项: expired, all, plugin <插件名>" + + clean_type = args[0].lower() + result = [] + + if clean_type == "expired": + # 清理过期请求(超过24小时) + current_time = asyncio.get_event_loop().time() + expired_count = 0 + + for request_id, request in list(self.pending_requests.items()): + if current_time - request.get('timestamp', 0) > 86400: # 24小时 + plugin_name = request['plugin_name'] + del self.pending_requests[request_id] + expired_count += 1 + result.append(f"🗑️ 清理过期请求: {request_id} ({plugin_name})") + + if expired_count > 0: + await self._save_pending_requests() + result.insert(0, f"✅ 已清理 {expired_count} 个过期权限请求") + else: + result.append("✅ 没有发现过期权限请求") + + elif clean_type == "all": + # 清理所有数据 + pending_count = len(self.pending_requests) + granted_count = len(self.granted_permissions) + status_count = len(self.plugin_status) + + self.pending_requests.clear() + self.granted_permissions.clear() + self.plugin_status.clear() + + await self._save_all_data() + + result = [ + f"✅ 已清理所有权限数据:", + f" 🗑️ 待处理请求: {pending_count} 个", + f" 🗑️ 已授予权限: {granted_count} 个插件", + f" 🗑️ 插件状态: {status_count} 个" + ] + + elif clean_type == "plugin" and len(args) > 1: + # 清理特定插件的数据 + plugin_name = args[1] + cleaned_items = [] + + # 清理待处理请求 + for request_id, request in list(self.pending_requests.items()): + if request['plugin_name'] == plugin_name: + del self.pending_requests[request_id] + cleaned_items.append(f"待处理请求: {request_id}") + + # 清理已授予权限 + if plugin_name in self.granted_permissions: + del self.granted_permissions[plugin_name] + cleaned_items.append("已授予权限") + + # 清理插件状态 + if plugin_name in self.plugin_status: + del self.plugin_status[plugin_name] + cleaned_items.append("插件状态") + + if cleaned_items: + await self._save_all_data() + result = [f"✅ 已清理插件 '{plugin_name}' 的权限数据:"] + cleaned_items + else: + result = [f"ℹ️ 未找到插件 '{plugin_name}' 的权限数据"] + + else: + return "❌ 无效的清理类型\n💡 可用选项: expired, all, plugin <插件名>" + + return "\n".join(result) + + except Exception as e: + logger.error(f"清理权限数据时出错: {str(e)}", exc_info=True) + return f"❌ 清理权限数据时出错: {str(e)}" + + async def _show_permission_status(self) -> str: + """显示当前权限状态""" + try: + if not self.pending_requests and not self.granted_permissions: + return "📋 暂无权限请求和授予记录" + + result = ["📋 **权限状态**"] + + if self.pending_requests: + result.append("\n🟡 **待处理请求**:") + for rid, req in self.pending_requests.items(): + result.append(f" {rid}: {req['plugin_name']} -> {len(req['permissions'])} 个权限") + + if self.granted_permissions: + result.append("\n🟢 **已授予权限**:") + for plugin, perms in self.granted_permissions.items(): + result.append(f" {plugin}: {len(perms)} 个权限") + + if self.plugin_status: + result.append("\n🔵 **插件状态**:") + for plugin, status in self.plugin_status.items(): + status_icon = { + "granted": "✅", + "pending": "🟡", + "denied": "❌", + "ignored": "⏸️", + "error": "⚠️" + }.get(status, "🔵") + result.append(f" {status_icon} {plugin}: {status}") + + # 添加配置文件状态 + result.append("\n📁 **配置文件状态**:") + config_files = [ + ("已授予权限", self.granted_file), + ("待处理请求", self.pending_file), + ("插件状态", self.plugin_status_file) + ] + + for name, file_path in config_files: + if file_path.exists(): + result.append(f" ✅ {name}: 存在") + else: + result.append(f" ❌ {name}: 不存在") + + return "\n".join(result) + + except Exception as e: + logger.error(f"显示权限状态时出错: {str(e)}", exc_info=True) + return f"❌ 显示权限状态时出错: {str(e)}" + + async def _show_plugin_status(self) -> str: + """显示插件状态""" + try: + if not self.plugin_status: + return "📊 暂无插件状态信息" + + result = ["📊 **插件状态**"] + for plugin, status in self.plugin_status.items(): + if status == "granted": + result.append(f" ✅ {plugin}: 权限已授予") + elif status == "pending": + result.append(f" 🟡 {plugin}: 等待权限授予") + elif status == "denied": + result.append(f" ❌ {plugin}: 权限被拒绝") + elif status == "ignored": + result.append(f" ⏸️ {plugin}: 权限请求被忽略") + elif status == "error": + result.append(f" ⚠️ {plugin}: 权限错误") + else: + result.append(f" 🔵 {plugin}: {status}") + + return "\n".join(result) + + except Exception as e: + logger.error(f"显示插件状态时出错: {str(e)}", exc_info=True) + return f"❌ 显示插件状态时出错: {str(e)}" + + async def _fix_permission_status(self) -> str: + """修复权限状态不一致问题""" + try: + fixes_applied = [] + + # 检查插件状态与待处理请求的一致性 + for plugin, status in list(self.plugin_status.items()): + # 如果插件状态是pending但没有对应的待处理请求 + if status == "pending": + has_pending_request = False + for request in self.pending_requests.values(): + if request['plugin_name'] == plugin: + has_pending_request = True + break + + if not has_pending_request: + # 修复:将状态改为error + self.plugin_status[plugin] = "error" + fixes_applied.append(f"🟡 {plugin}: pending → error (无权限请求)") + + # 清理过期的待处理请求 + current_time = asyncio.get_event_loop().time() + expired_requests = [] + for request_id, request in list(self.pending_requests.items()): + # 假设请求超过1小时为过期 + if current_time - request.get('timestamp', 0) > 3600: + expired_requests.append(request_id) + + for request_id in expired_requests: + plugin_name = self.pending_requests[request_id]['plugin_name'] + del self.pending_requests[request_id] + fixes_applied.append(f"🗑️ 清理过期请求: {request_id} ({plugin_name})") + + # 保存修复后的数据 + if fixes_applied: + await self._save_all_data() + result = ["🔧 **权限状态修复完成**"] + result.extend(fixes_applied) + result.append(f"\n✅ 共应用 {len(fixes_applied)} 个修复") + else: + result = ["✅ **权限状态正常**", "未发现需要修复的问题"] + + return "\n".join(result) + + except Exception as e: + logger.error(f"修复权限状态时出错: {str(e)}", exc_info=True) + return f"❌ 修复权限状态时出错: {str(e)}" + + async def _backup_permission_data(self) -> str: + """备份权限数据""" + try: + backup_dir = self.config_dir / "backups" + backup_dir.mkdir(exist_ok=True) + + import datetime + timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + + # 备份文件路径 + granted_backup = backup_dir / f"granted_permissions_{timestamp}.json" + pending_backup = backup_dir / f"pending_requests_{timestamp}.json" + status_backup = backup_dir / f"plugin_status_{timestamp}.json" + + # 复制文件 + import shutil + if self.granted_file.exists(): + shutil.copy2(self.granted_file, granted_backup) + if self.pending_file.exists(): + shutil.copy2(self.pending_file, pending_backup) + if self.plugin_status_file.exists(): + shutil.copy2(self.plugin_status_file, status_backup) + + return f"✅ 权限数据备份完成\n📁 备份位置: {backup_dir}\n⏰ 时间戳: {timestamp}" + + except Exception as e: + logger.error(f"备份权限数据时出错: {str(e)}", exc_info=True) + return f"❌ 备份权限数据时出错: {str(e)}" + + async def _show_pending_requests(self) -> str: + """显示待授权权限请求列表 - 更新为pm前缀""" + try: + if not self.pending_requests: + return "📭 暂无待处理的权限请求" + + result = ["🟡 **待授权权限请求列表**"] + result.append("=" * 50) + + for request_id, request in self.pending_requests.items(): + plugin_name = request['plugin_name'] + permissions = request['permissions'] + + # 创建友好的权限描述 + permission_descriptions = { + "plugin.example.read": "📖 读取示例插件数据", + "plugin.example.write": "✏️ 写入示例插件数据", + "plugin.example.execute": "⚡ 执行示例插件操作", + "framework.event.subscribe": "📡 订阅框架事件", + "framework.command.execute": "⌨️ 执行框架命令" + } + + # 构建权限列表 + permission_list = [] + for perm in permissions: + desc = permission_descriptions.get(perm, f"🔧 {perm}") + permission_list.append(f" • {desc}") + + permission_display = "\n".join(permission_list) + + # 添加请求信息 + result.append(f"\n📦 **插件**: {plugin_name}") + result.append(f"🆔 **请求ID**: {request_id}") + result.append(f"🔐 **请求权限** ({len(permissions)} 个):") + result.append(permission_display) + + # 添加交互指令 - 更新为pm前缀 + result.append(f"\n💡 **交互指令**:") + result.append(f" 🟢 同意所有权限: pmallow {request_id}") + result.append(f" 🟡 同意部分权限: pmallow {request_id} read,write") + result.append(f" 🔴 拒绝所有权限: pmdeny {request_id}") + result.append(f" ⏸️ 暂时忽略: pmignore {request_id}") + + result.append("-" * 50) + + # 添加快捷指令 - 更新为pm前缀 + result.append("\n🚀 **快捷指令**:") + result.append(" 🟢 同意所有请求: pmallow pmall") + result.append(" 🔴 拒绝所有请求: pmdeny all") + result.append(" ⏸️ 忽略所有请求: pmignore all") + result.append(" 📋 查看权限状态: permissions") + result.append(" 📊 查看插件状态: pm_plugin_status") + result.append(" ❓ 查看帮助: pmhelp") + + return "\n".join(result) + + except Exception as e: + logger.error(f"显示待处理请求时出错: {str(e)}", exc_info=True) + return f"❌ 显示待处理请求时出错: {str(e)}" + + def _show_permission_help(self) -> str: + """显示权限命令帮助 - 更新为pm前缀""" + help_text = """ + 🔐 **权限管理命令帮助 (pm前缀)** + + 📋 **查询命令**: + permissions - 查看权限状态 + pmpending 或 pmrequests - 查看待授权请求列表 + pm_plugin_status - 查看插件权限状态 + + 🛠️ **操作命令**: + pmallow <请求ID> - 同意指定请求的所有权限 + pmallow <请求ID> <权限列表> - 同意指定请求的部分权限 + pmdeny <请求ID> - 拒绝指定请求的所有权限 + 。pmignore <请求ID> - 暂时忽略指定请求 + + 🚀 **快捷命令**: + pmallow pmall - 同意所有待处理请求 + pmdeny all - 拒绝所有待处理请求 + pmignore all - 忽略所有待处理请求 + + 🔧 **维护命令**: + pmfix - 修复权限状态不一致问题 + pmtest - 测试权限配置文件 + + 📖 **示例**: + pmallow abc123 - 同意请求ID为abc123的所有权限 + pmallow abc123 read,write - 只同意abc123的读取和写入权限 + pmdeny abc123 - 拒绝abc123的所有权限 + pmignore abc123 - 暂时忽略abc123的请求 + pmpending - 查看所有待处理的权限请求 + + 💡 **提示**: + • 权限请求ID是自动生成的8位字符串 + • 使用 pmpending 命令查看所有待处理请求及其ID + • 插件在获得权限前可能以受限模式运行 + • 权限管理命令都以 `pm` 为前缀,避免与其他命令冲突 + """ + return help_text.strip() + + def is_tui_ready(self) -> bool: + """检查TUI服务是否就绪""" + try: + return (self.tui_service is not None and + hasattr(self.tui_service, 'show_message') and + hasattr(self.tui_service, 'tui_app') and + self.tui_service.tui_app is not None) + except Exception as e: + logger.debug(f"检查TUI状态时出错: {e}") + return False + + async def wait_for_tui_ready(self, timeout: float = 10.0) -> bool: + """等待TUI服务就绪""" + try: + start_time = asyncio.get_event_loop().time() + while asyncio.get_event_loop().time() - start_time < timeout: + if self.is_tui_ready(): + logger.debug("TUI服务已就绪") + return True + await asyncio.sleep(0.5) + + logger.warning(f"等待TUI服务就绪超时 ({timeout}秒)") + return False + except Exception as e: + logger.error(f"等待TUI就绪时出错: {e}") + return False + + def shutdown(self): + """关闭权限服务""" + try: + logger.info("关闭权限服务") + self.is_running = False + self.pending_requests.clear() + logger.debug("权限服务关闭完成") + except Exception as e: + logger.error(f"关闭权限服务时出错: {str(e)}", exc_info=True) diff --git a/services/plugin_index.py b/services/plugin_index.py new file mode 100644 index 0000000..1727507 --- /dev/null +++ b/services/plugin_index.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +"""插件索引仓库 — 在线 JSON 索引 + 一键安装""" +import logging, json, os, asyncio, tempfile, zipfile, shutil +from pathlib import Path +from typing import Optional, List, Dict + +logger = logging.getLogger(__name__) + +DEFAULT_INDEX_URL = "https://raw.githubusercontent.com/AskaEth/SenSu-plugins/main/index.json" + + +class PluginIndex: + def __init__(self, index_url: str = DEFAULT_INDEX_URL): + self.index_url = index_url + self._cache: Optional[List[Dict]] = None + + async def fetch_index(self, force: bool = False) -> List[Dict]: + """获取远程插件索引""" + if self._cache is not None and not force: + return self._cache + try: + import aiohttp + async with aiohttp.ClientSession() as session: + async with session.get(self.index_url, timeout=aiohttp.ClientTimeout(total=15)) as resp: + if resp.status == 200: + data = await resp.json() + self._cache = data if isinstance(data, list) else data.get("plugins", []) + logger.info(f"插件索引已加载: {len(self._cache)} 个可用插件") + return self._cache + else: + logger.warning(f"插件索引请求失败: HTTP {resp.status}") + return [] + except Exception as e: + logger.warning(f"无法获取插件索引 ({self.index_url}): {e}") + return [] + + def search(self, name: str) -> Optional[Dict]: + """在缓存中搜索插件""" + if not self._cache: + return None + for p in self._cache: + if p.get("name") == name: + return p + return None + + def list_plugins(self) -> List[str]: + if not self._cache: + return [] + return [f"{p.get('name','?')} v{p.get('version','?')} — {p.get('description','?')[:60]}" + for p in self._cache] + + async def install(self, name: str, target_dir: str = "plugins") -> bool: + """下载并安装指定插件到 plugins/ 目录""" + plugin = self.search(name) + if not plugin: + # Try to refresh cache + await self.fetch_index(force=True) + plugin = self.search(name) + if not plugin: + logger.error(f"插件未在索引中找到: {name}") + return False + + download_url = plugin.get("download_url") or plugin.get("url") + if not download_url: + logger.error(f"插件 {name} 缺少下载地址") + return False + + target = Path(target_dir) / name + if target.exists(): + logger.warning(f"插件目录已存在: {target}") + return False + + try: + import aiohttp + async with aiohttp.ClientSession() as session: + async with session.get(download_url, timeout=aiohttp.ClientTimeout(total=120)) as resp: + if resp.status != 200: + logger.error(f"下载失败: HTTP {resp.status}") + return False + content = await resp.read() + + # Handle zip archives + if download_url.endswith(".zip"): + with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as tmp: + tmp.write(content) + tmp.flush() + with zipfile.ZipFile(tmp.name, "r") as zf: + # Zip 内第一层目录名可能不同, 提取到临时位置再移动 + extract_tmp = Path(tempfile.mkdtemp()) + zf.extractall(extract_tmp) + # 如果 zip 内只有一个顶层目录, 直接用它 + members = list(extract_tmp.iterdir()) + if len(members) == 1 and members[0].is_dir(): + shutil.move(str(members[0]), str(target)) + else: + extract_tmp.rename(target) + os.unlink(tmp.name) + else: + # Assume single .py file plugin + target.mkdir(parents=True, exist_ok=True) + (target / "__init__.py").write_bytes(content) + + logger.info(f"✅ 插件安装完成: {name} → {target}") + return True + + except Exception as e: + logger.error(f"安装插件 {name} 失败: {e}") + return False diff --git a/services/plugin_service.py b/services/plugin_service.py new file mode 100644 index 0000000..9b96d5f --- /dev/null +++ b/services/plugin_service.py @@ -0,0 +1,702 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import logging +import asyncio +import importlib.util +import sys +import os +import inspect +import time +from pathlib import Path +from typing import Dict, List, Any, Optional, Callable +from dataclasses import dataclass +import yaml +import traceback +from sdk.plugin_command_decorator import plugin_command, command + +logger = logging.getLogger(__name__) + +# 加载失败记录 — 供统计 API 使用 +LOAD_FAILURES: Dict[str, str] = {} + +@dataclass +class PluginInfo: + """插件信息数据类""" + name: str + version: str + description: str + author: str + enabled: bool + loaded: bool + error_count: int + permissions: List[str] + plugin_path: Path + commands: Dict[str, Dict] = None # 新增命令信息 + +class PluginService: + """插件服务 - 管理插件的加载、卸载和运行""" + + def __init__(self, config: Dict, permission_service, bridge_service, service_manager): + self.config = config + self.permission_service = permission_service + self.bridge_service = bridge_service + self.service_manager = service_manager # 新增服务管理器 + self.bridge_service.service_manager = self.service_manager + self.plugins: Dict[str, Any] = {} + self.plugin_info: Dict[str, PluginInfo] = {} + self.plugins_dir = Path("plugins") + self.is_running = False + logger.debug("PluginService初始化开始") + + + async def start(self): + """启动插件服务""" + try: + logger.info("启动插件服务") + + # 创建插件目录 + self.plugins_dir.mkdir(exist_ok=True) + + # 自动加载插件 + if self.config['plugins']['auto_load']: + await self.load_all_plugins() + + await self.save_command_config() + + # 启动 watchdog 热重载 + if self.config.get('plugins', {}).get('hot_reload', False): + self._loop = asyncio.get_running_loop() + self._start_watchdog() + + self.is_running = True + logger.info("插件服务启动完成") + + except Exception as e: + logger.error(f"启动插件服务时出错: {str(e)}", exc_info=True) + raise + + # ── watchdog 热重载 ────────────────────────────────────── + + def _start_watchdog(self): + """启动 watchdog 监听 plugins/ 目录实现热重载""" + try: + from watchdog.observers import Observer + from watchdog.events import FileSystemEventHandler + + plugin_service = self # 闭包引用 + + class _PluginReloadHandler(FileSystemEventHandler): + """防抖 + 插件级重载""" + + def __init__(self): + self._debounce: Dict[str, float] = {} + self._debounce_sec = 1.0 # 1 秒内同插件只触发一次 + + def _plugin_name_from_path(self, event_path: str) -> Optional[str]: + """从事件路径提取插件名(plugins//...)""" + try: + rel = os.path.relpath(event_path, str(plugin_service.plugins_dir)) + parts = Path(rel).parts + if parts and not parts[0].startswith('.'): + return parts[0] + except ValueError: + pass + return None + + def _should_handle(self, plugin_name: str) -> bool: + """防抖 — 同插件在冷却时间内跳过""" + now = time.time() + last = self._debounce.get(plugin_name, 0) + if now - last < self._debounce_sec: + return False + self._debounce[plugin_name] = now + return True + + def on_modified(self, event): + if event.is_directory: + return + path = event.src_path + if not path.endswith(('.py', '.yaml', '.yml')): + return + plugin_name = self._plugin_name_from_path(path) + if not plugin_name: + return + if not self._should_handle(plugin_name): + return + logger.info( + f"🔁 检测到插件文件变更: {plugin_name} ({os.path.basename(path)})" + ) + asyncio.run_coroutine_threadsafe( + plugin_service._reload_plugin(plugin_name), + plugin_service._loop, + ) + + def on_created(self, event): + self.on_modified(event) + + self._watchdog_observer = Observer() + self._watchdog_observer.schedule( + _PluginReloadHandler(), + str(self.plugins_dir), + recursive=True, + ) + self._watchdog_observer.start() + logger.info("👁️ 插件热重载已启动 (watchdog)") + + except ImportError: + logger.warning( + "watchdog 未安装,插件热重载不可用。pip install watchdog" + ) + except Exception as e: + logger.warning(f"启动 watchdog 失败 (不影响框架): {e}") + + async def _reload_plugin(self, plugin_name: str): + """热重载单个插件 — 卸载后重新加载""" + plugin_dir = self.plugins_dir / plugin_name + if not plugin_dir.is_dir(): + logger.debug(f"插件目录已消失,跳过重载: {plugin_name}") + return + + # 路由器已冻结时跳过热重载 — 否则路由注册失败导致插件功能丢失 + try: + internet = self.service_manager.get_service("internet") + if internet and internet.is_running: + logger.info(f" ⏭ 路由器已冻结,跳过热重载: {plugin_name} (需重启框架)") + return + except Exception: + pass + + if plugin_name in self.plugins: + logger.info(f" ⏳ 卸载旧版本: {plugin_name}") + await self.unload_plugin(plugin_name) + + await asyncio.sleep(0.2) # 给文件系统缓冲 + + success = await self.load_plugin(plugin_name) + if success: + # 重载后重新注册延迟路由 + try: + internet = self.service_manager.get_service("internet") + if internet and internet.is_running: + plugin = self.plugins.get(plugin_name) + if plugin and hasattr(plugin, 'network_bridge'): + await self._reinitialize_plugin_network(plugin, internet) + except Exception: + pass + logger.info(f" ✅ 热重载完成: {plugin_name}") + else: + logger.warning(f" ❌ 热重载失败: {plugin_name}") + + def _stop_watchdog(self): + """停止 watchdog observer""" + obs = getattr(self, '_watchdog_observer', None) + if obs and obs.is_alive(): + obs.stop() + obs.join(timeout=3) + logger.info("👁️ 插件热重载已停止") + + async def stop(self): + """停止插件服务""" + logger.info("关闭插件服务") + self._stop_watchdog() + for name in list(self.plugins.keys()): + await self.unload_plugin(name) + self.is_running = False + + def _should_isolate(self, plugin_config: dict) -> bool: + """检查插件是否应使用进程隔离模式""" + global_isolation = self.config.get('plugins', {}).get('isolation', False) + plugin_isolation = plugin_config.get('settings', {}).get('isolation', None) + if plugin_isolation is not None: + return bool(plugin_isolation) + return global_isolation + + async def load_all_plugins(self): + """加载所有插件""" + try: + logger.debug("开始加载所有插件") + + if not self.plugins_dir.exists(): + logger.warning("插件目录不存在,跳过加载") + return + + loaded_count = 0 + error_count = 0 + isolated_count = 0 + + # 遍历插件目录 + for plugin_dir in self.plugins_dir.iterdir(): + if plugin_dir.is_dir(): + try: + # 先读配置判断是否需要隔离 + config_file = plugin_dir / "config.yaml" + plugin_config = {} + if config_file.exists(): + with open(config_file, 'r') as f: + plugin_config = yaml.safe_load(f) or {} + + if self._should_isolate(plugin_config): + success = await self._load_plugin_isolated(plugin_dir.name) + if success: + isolated_count += 1 + loaded_count += 1 + else: + error_count += 1 + else: + success = await self.load_plugin(plugin_dir.name) + if success: + loaded_count += 1 + else: + error_count += 1 + except Exception as e: + logger.error(f"加载插件 {plugin_dir.name} 时出错: {str(e)}", exc_info=True) + error_count += 1 + + logger.info( + f"插件加载完成: 成功 {loaded_count} (其中隔离 {isolated_count}), 失败 {error_count}" + ) + + except Exception as e: + logger.error(f"加载所有插件时出错: {str(e)}", exc_info=True) + raise + + async def _load_plugin_isolated(self, plugin_name: str) -> bool: + """在独立子进程中加载插件(进程隔离模式)""" + try: + from services.process_isolated import IsolatedPlugin + + plugin_path = self.plugins_dir / plugin_name + main_module = plugin_path / "__init__.py" + if not main_module.exists(): + logger.error(f"隔离插件主模块不存在: {main_module}") + return False + + config_file = plugin_path / "config.yaml" + plugin_config = {} + if config_file.exists(): + with open(config_file) as f: + plugin_config = yaml.safe_load(f) or {} + + iso = IsolatedPlugin( + plugin_name, + str(main_module), + plugin_config, + ) + + self.plugins[plugin_name] = iso + self.plugin_info[plugin_name] = PluginInfo( + name=plugin_config.get('name', plugin_name), + version=plugin_config.get('version', '0.1.0'), + description=plugin_config.get('description', ''), + author=plugin_config.get('author', ''), + enabled=True, + loaded=True, + error_count=0, + permissions=[], + plugin_path=plugin_path, + commands={}, + ) + logger.info(f"🔒 隔离插件加载成功: {plugin_name} (PID={iso.pid})") + return True + + except Exception as e: + logger.error(f"加载隔离插件 {plugin_name} 失败: {e}") + return False + + async def load_plugin(self, plugin_name: str) -> bool: + """加载单个插件 - 支持异步权限处理""" + try: + logger.debug(f"开始加载插件: {plugin_name}") + + plugin_path = self.plugins_dir / plugin_name + + if not plugin_path.exists(): + LOAD_FAILURES[plugin_name] = "插件目录不存在" + logger.error(f"插件目录不存在: {plugin_path}") + return False + + # 检查插件配置文件 (自动从 .example 模板创建) + config_file = plugin_path / "config.yaml" + if not config_file.exists(): + template = plugin_path / "config.yaml.example" + if template.exists(): + import shutil as _shutil + _shutil.copy(template, config_file) + logger.info(f"📋 从模板创建插件配置: {config_file}") + else: + logger.error(f"插件配置文件不存在: {config_file}") + return False + + # 加载插件配置 + with open(config_file, 'r', encoding='utf-8') as f: + plugin_config = yaml.safe_load(f) + + # 检查权限文件 + permission_file = plugin_path / "permissions.yaml" + if not permission_file.exists(): + logger.error(f"插件权限文件不存在: {permission_file}") + return False + + # 加载权限配置 + with open(permission_file, 'r', encoding='utf-8') as f: + permission_config = yaml.safe_load(f) + + # 验证插件信息 + required_fields = ['name', 'version', 'description', 'author'] + for field in required_fields: + if field not in plugin_config: + logger.error(f"插件配置缺少必要字段: {field}") + return False + + # 检查主模块 + main_module = plugin_path / "__init__.py" + if not main_module.exists(): + logger.error(f"插件主模块不存在: {main_module}") + return False + + # 动态加载插件模块 + module_name = f"plugins.{plugin_name}" + spec = importlib.util.spec_from_file_location(module_name, main_module) + if not spec: + logger.error(f"无法创建模块规范: {module_name}") + return False + + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + + try: + spec.loader.exec_module(module) + logger.debug(f"插件模块加载成功: {module_name}") + except Exception as e: + logger.error(f"执行插件模块时出错: {str(e)}", exc_info=True) + return False + + # 获取插件类实例 + if not hasattr(module, 'Plugin'): + logger.error(f"插件类 'Plugin' 不存在: {module_name}") + return False + + # 权限申请和验证 - 非阻塞版本 + permissions = permission_config.get('permissions', []) + if permissions: + # 非阻塞权限请求,立即返回True让插件继续加载 + permission_result = await self.permission_service.request_permissions(plugin_name, permissions) + if not permission_result: + logger.warning(f"插件权限申请失败: {plugin_name}") + # 即使权限申请失败,也允许插件以受限模式运行 + logger.info(f"插件 {plugin_name} 将以受限模式运行") + + # 实例化插件 + try: + plugin_instance = module.Plugin( + plugin_name=plugin_name, + config=plugin_config, + bridge=self.bridge_service + ) + + # 初始化插件 + if hasattr(plugin_instance, 'initialize'): + if asyncio.iscoroutinefunction(plugin_instance.initialize): + await plugin_instance.initialize() + else: + plugin_instance.initialize() + + # 扫描并注册 TUI 命令 + plugin_commands = await self._scan_and_register_commands(plugin_name, plugin_instance, plugin_config) + + # 自动暴露插件命令为 REST 端点 + if hasattr(plugin_instance, 'network_bridge') and plugin_instance.network_bridge: + await plugin_instance.network_bridge.register_command_routes( + plugin_instance, require_auth=True + ) + + # 注册插件 + self.plugins[plugin_name] = plugin_instance + + # 保存插件信息 + self.plugin_info[plugin_name] = PluginInfo( + name=plugin_config['name'], + version=plugin_config['version'], + description=plugin_config['description'], + author=plugin_config['author'], + enabled=True, + loaded=True, + error_count=0, + permissions=permissions, + plugin_path=plugin_path, + commands=plugin_commands + ) + + logger.info(f"插件加载成功: {plugin_name} v{plugin_config['version']}, 注册了 {len(plugin_commands)} 个命令") + return True + + except Exception as e: + logger.error(f"实例化插件时出错: {str(e)}", exc_info=True) + return False + + except Exception as e: + logger.error(f"加载插件 {plugin_name} 时出错: {str(e)}", exc_info=True) + return False + + async def _scan_and_register_commands(self, plugin_name: str, plugin_instance: Any, plugin_config: Dict) -> Dict[str, Dict]: + """扫描并注册插件命令 - 修正版本""" + try: + logger.debug(f"扫描插件命令: {plugin_name}") + + command_service = self.service_manager.get_service("command") + if not command_service: + logger.error("命令服务不可用,无法注册插件命令") + return {} + + # 扫描插件中的命令方法 + command_methods = {} + for name, method in inspect.getmembers(plugin_instance, predicate=inspect.ismethod): + # 检查方法是否有命令装饰器或符合命名约定 + if (hasattr(method, '_is_plugin_command') or + name.startswith('cmd_') or + name.startswith('command_')): + + command_name = self._get_command_name(name, method, plugin_config) + command_description = self._get_command_description(name, method, plugin_config) + command_permissions = self._get_command_permissions(name, method, plugin_config) + + # 修正:使用正确的source格式 + command_service.register_command( + name=command_name, + handler=method, + description=command_description, + permissions=command_permissions, + source=f"plugin.{plugin_name}" # 使用 plugin.插件名 格式 + ) + + command_methods[command_name] = { + 'method_name': name, + 'description': command_description, + 'permissions': command_permissions + } + + logger.debug(f"注册插件命令: {command_name} -> {name}") + + return command_methods + + except Exception as e: + logger.error(f"扫描插件命令时出错: {str(e)}", exc_info=True) + return {} + + def _get_command_name(self, method_name: str, method: Callable, plugin_config: Dict) -> str: + """获取命令名称""" + try: + # 如果方法有装饰器指定的名称 + if hasattr(method, '_command_name'): + return getattr(method, '_command_name') + + # 从方法名提取命令名 + if method_name.startswith('cmd_'): + return method_name[4:] + elif method_name.startswith('command_'): + return method_name[8:] + else: + return method_name + + except Exception as e: + logger.error(f"获取命令名称时出错: {str(e)}") + return method_name + + def _get_command_description(self, method_name: str, method: Callable, plugin_config: Dict) -> str: + """获取命令描述""" + try: + # 如果方法有装饰器指定的描述 + if hasattr(method, '_command_description'): + return getattr(method, '_command_description') + + # 使用方法的文档字符串 + if method.__doc__: + # 提取第一行作为描述 + doc_lines = method.__doc__.strip().split('\n') + return doc_lines[0].strip() + + # 默认描述 + return f"插件命令: {method_name}" + + except Exception as e: + logger.error(f"获取命令描述时出错: {str(e)}") + return f"插件命令: {method_name}" + + def _get_command_permissions(self, method_name: str, method: Callable, plugin_config: Dict) -> List[str]: + """获取命令权限""" + try: + # 如果方法有装饰器指定的权限 + if hasattr(method, '_command_permissions'): + return getattr(method, '_command_permissions') + + # 从插件配置中获取默认权限 + default_permissions = plugin_config.get('default_command_permissions', []) + return default_permissions.copy() + + except Exception as e: + logger.error(f"获取命令权限时出错: {str(e)}") + return [] + + async def unload_plugin(self, plugin_name: str) -> bool: + """卸载插件""" + try: + logger.debug(f"开始卸载插件: {plugin_name}") + + if plugin_name not in self.plugins: + logger.warning(f"插件未加载: {plugin_name}") + return False + + plugin_instance = self.plugins[plugin_name] + plugin_info = self.plugin_info[plugin_name] + + # 注销插件命令 (隔离插件可能没有注册命令) + if not self._is_isolated(plugin_instance): + await self._unregister_plugin_commands(plugin_name) + + # 调用插件的清理方法 + try: + if hasattr(plugin_instance, 'shutdown'): + if asyncio.iscoroutinefunction(plugin_instance.shutdown): + await plugin_instance.shutdown() + else: + plugin_instance.shutdown() + except Exception as e: + logger.error(f"插件清理时出错 {plugin_name}: {str(e)}", exc_info=True) + + # 从模块缓存中移除 + module_name = f"plugins.{plugin_name}" + if module_name in sys.modules: + del sys.modules[module_name] + + # 移除插件实例和信息 + del self.plugins[plugin_name] + plugin_info.loaded = False + plugin_info.enabled = False + + logger.info(f"插件卸载成功: {plugin_name}") + return True + + except Exception as e: + logger.error(f"卸载插件 {plugin_name} 时出错: {str(e)}", exc_info=True) + return False + + @staticmethod + def _is_isolated(plugin_instance) -> bool: + from services.process_isolated import IsolatedPlugin + return isinstance(plugin_instance, IsolatedPlugin) + + async def _unregister_plugin_commands(self, plugin_name: str): + """注销插件命令""" + try: + command_service = self.service_manager.get_service("command") + if not command_service: + return + + # 从命令服务中移除该插件的所有命令 + commands_to_remove = [] + for cmd_name, cmd_info in command_service.commands.items(): + if cmd_info.source.startswith(f"plugin.{plugin_name}"): + commands_to_remove.append(cmd_name) + + for cmd_name in commands_to_remove: + del command_service.commands[cmd_name] + logger.debug(f"注销插件命令: {cmd_name}") + + logger.info(f"已注销插件 {plugin_name} 的 {len(commands_to_remove)} 个命令") + + except Exception as e: + logger.error(f"注销插件命令时出错: {str(e)}", exc_info=True) + + async def save_command_config(self): + """保存命令配置到文件""" + try: + command_service = self.service_manager.get_service("command") + if not command_service: + logger.error("命令服务不可用") + return False + + command_list = command_service.get_command_list() + config_path = Path("config") / "plugins" / "commands.yaml" + + # 确保目录存在 + config_path.parent.mkdir(parents=True, exist_ok=True) + + config_data = { + "commands": {}, + "plugin_commands": {}, + "last_updated": asyncio.get_event_loop().time(), + "total_commands": len(command_list) + } + + # 按来源分组命令 + for cmd in command_list: + cmd_info = { + "description": cmd['description'], + "permissions": cmd['permissions'], + "source": cmd['source'] + } + + config_data["commands"][cmd['name']] = cmd_info + + # 按插件分组 + if cmd['source'].startswith("plugin."): + plugin_name = cmd['source'].split('.', 1)[1] + if plugin_name not in config_data["plugin_commands"]: + config_data["plugin_commands"][plugin_name] = {} + config_data["plugin_commands"][plugin_name][cmd['name']] = cmd_info + + with open(config_path, 'w', encoding='utf-8') as f: + yaml.dump(config_data, f, default_flow_style=False, allow_unicode=True, indent=2) + + logger.info(f"命令配置已保存: {config_path}, 共 {len(command_list)} 个命令") + return True + + except Exception as e: + logger.error(f"保存命令配置时出错: {str(e)}", exc_info=True) + return False + + async def register_delayed_routes(self, internet_service): + """注册延迟的路由(在网络服务启动后)""" + try: + if not internet_service: + logger.warning("网络服务不可用,跳过延迟路由注册") + return + + for plugin_name, plugin_instance in self.plugins.items(): + try: + # 检查插件是否有延迟注册方法 + if hasattr(plugin_instance, 'register_delayed_routes'): + await plugin_instance.register_delayed_routes(internet_service) + logger.info(f"延迟注册插件路由: {plugin_name}") + else: + # 如果插件没有延迟注册方法,尝试重新初始化网络功能 + await self._reinitialize_plugin_network(plugin_instance, internet_service) + + except Exception as e: + logger.error(f"延迟注册插件 {plugin_name} 路由时出错: {str(e)}") + + except Exception as e: + logger.error(f"注册延迟路由时出错: {str(e)}") + + async def _reinitialize_plugin_network(self, plugin_instance, internet_service): + """重新初始化插件的网络功能""" + try: + plugin_name = plugin_instance.plugin_name + + # 检查插件是否有网络桥接 + if hasattr(plugin_instance, 'network_bridge'): + # 重新创建网络桥接 + plugin_instance.network_bridge = PluginNetworkBridge( + plugin_name, internet_service, plugin_instance.bridge + ) + + # 重新设置网络路由 + if hasattr(plugin_instance, '_setup_network_routes'): + await plugin_instance._setup_network_routes() + + logger.info(f"重新初始化插件网络功能: {plugin_name}") + + except Exception as e: + logger.error(f"重新初始化插件网络功能时出错: {str(e)}") + \ No newline at end of file diff --git a/services/process_isolated.py b/services/process_isolated.py new file mode 100644 index 0000000..4654993 --- /dev/null +++ b/services/process_isolated.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""插件进程隔离 — 在子进程中运行插件,崩溃不影响框架""" +import asyncio, subprocess, json, os, logging, tempfile +from multiprocessing import Process, Queue +from typing import Dict, Any + +logger = logging.getLogger(__name__) + +def _plugin_runner(plugin_path: str, config_json: str, cmd_queue: Queue, result_queue: Queue): + """子进程入口 - 加载插件并监听命令队列""" + import sys, importlib.util, json as j + sys.path.insert(0, os.path.dirname(plugin_path)) + spec = importlib.util.spec_from_file_location("plugin", plugin_path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + + plugin = mod.Plugin(os.path.basename(os.path.dirname(plugin_path)), + j.loads(config_json), None) + asyncio.run(plugin.initialize()) + + while True: + cmd = cmd_queue.get() + if cmd == "__SHUTDOWN__": + asyncio.run(plugin.shutdown()) + break + try: + method = getattr(plugin, cmd.get("method", ""), None) + if method: + result = method(*cmd.get("args", [])) + result_queue.put({"ok": True, "result": str(result)}) + else: + result_queue.put({"ok": False, "error": f"method not found: {cmd.get(method)}"}) + except Exception as e: + result_queue.put({"ok": False, "error": str(e)}) + + +class IsolatedPlugin: + """进程隔离插件包装器""" + def __init__(self, plugin_name: str, plugin_path: str, config: dict): + self.name = plugin_name + self.cmd_queue = Queue() + self.result_queue = Queue() + self.process = Process( + target=_plugin_runner, + args=(plugin_path, json.dumps(config), self.cmd_queue, self.result_queue), + daemon=True + ) + self.process.start() + logger.info(f"隔离插件已启动: {plugin_name} (PID={self.process.pid})") + + def call(self, method: str, *args, timeout: float = 30): + self.cmd_queue.put({"method": method, "args": args}) + try: + result = self.result_queue.get(timeout=timeout) + return result + except: + return {"ok": False, "error": "timeout"} + + def shutdown(self): + self.cmd_queue.put("__SHUTDOWN__") + self.process.join(timeout=10) + if self.process.is_alive(): + self.process.terminate() + logger.info(f"隔离插件已关闭: {self.name}") + + @property + def pid(self): + return self.process.pid if self.process.is_alive() else None 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/project_service.py b/services/project_service.py new file mode 100644 index 0000000..23ead42 --- /dev/null +++ b/services/project_service.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +"""SenSu 项目注册表 — 插件声明 project.yaml 申请资源,框架管理生命周期""" +import logging, os, yaml, asyncio +from typing import Dict, List, Optional +from collections import defaultdict, deque + +logger = logging.getLogger(__name__) + +class ProjectService: + def __init__(self, service_manager, db=None): + self.sm = service_manager + self.db = db + self.projects: Dict[str, dict] = {} + self._port_allocations: Dict[int, str] = {} + + async def start(self): + logger.info("项目注册表已就绪") + + def register_project(self, plugin_name: str, project_config: dict) -> bool: + """从 project.yaml 注册项目 + 必需字段: name, port (可选: entrypoint, depends_on, env) + """ + name = project_config.get("name", plugin_name) + if name in self.projects: + logger.warning(f"项目 {name} 已注册,跳过") + return False + + port = project_config.get("port") + if port and port in self._port_allocations: + logger.warning(f"端口 {port} 已被 {self._port_allocations[port]} 占用") + port = self._find_free_port() + + self.projects[name] = { + **project_config, + "plugin_name": plugin_name, + "port": port, + "status": "registered", + } + if port: + self._port_allocations[port] = name + + if self.db: + self.db.save_plugin(plugin_name, project_path=project_config.get("path",""), + project_port=port or 0) + self.db.log_audit(plugin_name, "project_registered", str(project_config)) + + logger.info(f"项目已注册: {name} (插件: {plugin_name}, 端口: {port})") + return True + + def _find_free_port(self, start=4200) -> int: + used = set(self._port_allocations.keys()) + for p in range(start, start + 1000): + if p not in used: + return p + return start + + def get_project(self, name: str) -> Optional[dict]: + return self.projects.get(name) + + def list_projects(self) -> List[dict]: + return [{"name": k, "port": v.get("port"), "status": v.get("status")} + for k, v in self.projects.items()] + + def unregister_project(self, name: str): + if name in self.projects: + p = self.projects.pop(name) + if p.get("port"): + self._port_allocations.pop(p["port"], None) + logger.info(f"项目已注销: {name}") + + def resolve_dependencies(self, plugins: Dict[str, dict]) -> List[str]: + """拓扑排序 — 根据 depends_on 返回正确的加载顺序""" + graph = defaultdict(list) + in_degree = defaultdict(int) + all_plugins = set(plugins.keys()) + + for name, cfg in plugins.items(): + deps = cfg.get("depends_on", []) + if isinstance(deps, str): + deps = json.loads(deps) if deps.startswith("[") else [deps] + for dep in deps: + if dep in all_plugins: + graph[dep].append(name) + in_degree[name] += 1 + if name not in in_degree: + in_degree[name] = 0 + + # Kahn's algorithm + queue = deque([n for n in all_plugins if in_degree[n] == 0]) + result = [] + while queue: + node = queue.popleft() + result.append(node) + for neighbor in graph[node]: + in_degree[neighbor] -= 1 + if in_degree[neighbor] == 0: + queue.append(neighbor) + + if len(result) != len(all_plugins): + missing = all_plugins - set(result) + logger.warning(f"循环依赖或缺失依赖: {missing}, 追加到尾部") + result.extend(missing) + + logger.info(f"依赖解析结果: {' → '.join(result)}") + return result + + @staticmethod + def load_project_yaml(plugin_dir: str) -> Optional[dict]: + """从插件目录加载 project.yaml""" + yaml_path = os.path.join(plugin_dir, "project.yaml") + if os.path.exists(yaml_path): + try: + with open(yaml_path) as f: + return yaml.safe_load(f) + except Exception as e: + logger.warning(f"解析 project.yaml 失败 {yaml_path}: {e}") + return None diff --git a/services/proxy_service.py b/services/proxy_service.py new file mode 100644 index 0000000..0b384b2 --- /dev/null +++ b/services/proxy_service.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +"""SenSu ProxyService — 反向代理,将任意 URL 映射到框架路径""" +import asyncio, logging, aiohttp +from aiohttp import web, ClientSession, WSMsgType +from typing import Dict, Optional +from dataclasses import dataclass, field + +logger = logging.getLogger(__name__) + +@dataclass +class ProxyTarget: + path: str + target_url: str + description: str = "" + is_external: bool = False + status: str = "active" + strip_prefix: bool = True + +class ProxyService: + def __init__(self, service_manager=None): + self.sm = service_manager + self.proxies: Dict[str, ProxyTarget] = {} + self._session: Optional[ClientSession] = None + + async def start(self): + self._session = ClientSession() + logger.info("ProxyService 已就绪") + + async def register_proxy(self, path: str, target_url: str, + description: str = "", is_external: bool = False, + strip_prefix: bool = True) -> bool: + path = "/" + path.strip("/") + if path in self.proxies: + logger.warning(f"代理路径已存在: {path}") + return False + if not target_url.endswith("/"): + target_url += "/" + self.proxies[path] = ProxyTarget(path=path, target_url=target_url, + description=description, is_external=is_external, + strip_prefix=strip_prefix) + logger.info(f"代理已注册: {path} → {target_url}") + return True + + def unregister_proxy(self, path: str): + path = "/" + path.strip("/") + if path in self.proxies: + del self.proxies[path] + logger.info(f"代理已注销: {path}") + + def list_proxies(self): + return [{"path": p.path, "target": p.target_url, "status": p.status, + "description": p.description, "external": p.is_external} + for p in self.proxies.values()] + + def setup_routes(self, app: web.Application): + """注册代理路由到 aiohttp app""" + async def proxy_handler(request): + path = request.path + # Find matching proxy (longest prefix match) + proxy = None + for p in sorted(self.proxies.keys(), key=len, reverse=True): + if path.startswith(p) or path == p: + proxy = self.proxies[p] + break + if not proxy: + # Check bare path + lookup = "/" + path.strip("/") + proxy = self.proxies.get(lookup) + + if not proxy: + return web.json_response({"error": "no proxy for path"}, status=404) + + # Build target URL + remaining = path[len(proxy.path):] if proxy.strip_prefix else path + target = proxy.target_url.rstrip("/") + "/" + remaining.lstrip("/") + + try: + # Forward request + headers = {k: v for k, v in request.headers.items() + if k.lower() not in ("host", "content-length")} + headers["X-Forwarded-For"] = request.remote + headers["X-Proxy-By"] = "SenSu" + + async with self._session.request( + request.method, target, headers=headers, + data=await request.read(), timeout=30 + ) as resp: + body = await resp.read() + proxy_resp = web.Response(body=body, status=resp.status) + for k, v in resp.headers.items(): + if k.lower() not in ("transfer-encoding", "content-encoding"): + proxy_resp.headers[k] = v + return proxy_resp + except asyncio.TimeoutError: + return web.json_response({"error": "proxy timeout"}, status=504) + except Exception as e: + logger.error(f"代理错误 {path}: {e}") + return web.json_response({"error": str(e)}, status=502) + + # WebSocket proxy + async def ws_proxy_handler(request): + path = request.path + proxy = None + for p in sorted(self.proxies.keys(), key=len, reverse=True): + if path.startswith(p): + proxy = self.proxies[p] + break + if not proxy: + return web.json_response({"error": "no ws proxy"}, status=404) + + target = proxy.target_url.rstrip("/") + "/" + path[len(proxy.path):].lstrip("/") + if target.startswith("http"): + target = target.replace("http://", "ws://").replace("https://", "wss://") + + ws_client = web.WebSocketResponse() + await ws_client.prepare(request) + try: + async with self._session.ws_connect(target) as ws_target: + async def forward(src, dst): + async for msg in src: + if msg.type == WSMsgType.TEXT: + await dst.send_str(msg.data) + elif msg.type == WSMsgType.BINARY: + await dst.send_bytes(msg.data) + elif msg.type in (WSMsgType.CLOSE, WSMsgType.ERROR): + break + + await asyncio.gather( + forward(ws_client, ws_target), + forward(ws_target, ws_client), + ) + except Exception as e: + logger.debug(f"WS proxy error: {e}") + return ws_client + + app.router.add_route("*", "/proxy/{tail:.*}", proxy_handler) + # Register individual proxy routes + for path in self.proxies: + app.router.add_route("*", f"{path}/{{tail:.*}}", proxy_handler) + + # WebSocket proxy + app.router.add_route("GET", "/wsproxy/{tail:.*}", ws_proxy_handler) + logger.info(f"代理路由已注册 ({len(self.proxies)} targets)") + + async def check_health(self, path: str) -> dict: + proxy = self.proxies.get("/" + path.strip("/")) + if not proxy: + return {"ok": False, "error": "not found"} + try: + async with self._session.get(proxy.target_url, timeout=5) as resp: + return {"ok": True, "status": resp.status, "target": proxy.target_url} + except Exception as e: + return {"ok": False, "error": str(e)} + + async def shutdown(self): + if self._session: + await self._session.close() + logger.info("ProxyService 已关闭") 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/sensu_db.py b/services/sensu_db.py new file mode 100644 index 0000000..98cc210 --- /dev/null +++ b/services/sensu_db.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""SenSu SQLite 持久化层 — 替代零散 JSON 文件""" +import sqlite3, os, json, logging, threading +from typing import Optional, Dict, List, Any + +logger = logging.getLogger(__name__) + +DEFAULT_DB_PATH = "data/sensu.db" + +class SenSuDB: + def __init__(self, db_path: str = DEFAULT_DB_PATH): + os.makedirs(os.path.dirname(db_path) or ".", exist_ok=True) + self.db_path = db_path + self._local = threading.local() + self._init_schema() + + @property + def conn(self): + if not hasattr(self._local, "conn") or self._local.conn is None: + self._local.conn = sqlite3.connect(self.db_path) + self._local.conn.row_factory = sqlite3.Row + self._local.conn.execute("PRAGMA journal_mode=WAL") + self._local.conn.execute("PRAGMA foreign_keys=ON") + return self._local.conn + + def _init_schema(self): + c = self.conn + c.executescript(""" + CREATE TABLE IF NOT EXISTS plugins ( + name TEXT PRIMARY KEY, + version TEXT DEFAULT '0.1.0', + status TEXT DEFAULT 'unloaded', + config_json TEXT DEFAULT '{}', + permissions_json TEXT DEFAULT '[]', + depends_on TEXT DEFAULT '[]', + project_path TEXT, + project_port INTEGER, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS permissions ( + plugin_name TEXT, + permission TEXT, + granted INTEGER DEFAULT 0, + granted_at TEXT, + PRIMARY KEY (plugin_name, permission) + ); + CREATE TABLE IF NOT EXISTS config_kv ( + key TEXT PRIMARY KEY, + value TEXT, + updated_at TEXT DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + plugin_name TEXT, + action TEXT, + detail TEXT, + created_at TEXT DEFAULT (datetime('now')) + ); + """) + c.commit() + + # -- Plugin CRUD -- + def save_plugin(self, name: str, **kwargs): + fields = ["name"] + list(kwargs.keys()) + placeholders = ["?"] * len(fields) + values = [name] + list(kwargs.values()) + for k in ["config", "permissions", "depends_on"]: + if k in kwargs and not isinstance(kwargs[k], str): + kwargs[k] = json.dumps(kwargs[k]) + idx = fields.index(k) + values[idx] = kwargs[k] + sql = f"INSERT OR REPLACE INTO plugins ({','.join(fields)}) VALUES ({','.join(placeholders)})" + self.conn.execute("UPDATE plugins SET updated_at=datetime('now') WHERE name=?", [name]) + self.conn.execute(sql, values) + self.conn.commit() + + def get_plugin(self, name: str) -> Optional[Dict]: + row = self.conn.execute("SELECT * FROM plugins WHERE name=?", [name]).fetchone() + if not row: return None + d = dict(row) + for f in ["config_json", "permissions_json", "depends_on"]: + if d.get(f): + try: d[f.replace("_json","")] = json.loads(d.pop(f)) + except: pass + return d + + def list_plugins(self) -> List[Dict]: + return [dict(r) for r in self.conn.execute("SELECT name,version,status,project_path,project_port FROM plugins").fetchall()] + + # -- Permissions -- + def grant_permission(self, plugin: str, perm: str): + self.conn.execute( + "INSERT OR REPLACE INTO permissions(plugin_name,permission,granted,granted_at) VALUES(?,?,1,datetime('now'))", + [plugin, perm]) + self.conn.commit() + + def check_permission(self, plugin: str, perm: str) -> bool: + r = self.conn.execute("SELECT granted FROM permissions WHERE plugin_name=? AND permission=?", [plugin, perm]).fetchone() + return bool(r and r[0]) + + # -- Config -- + def set_config(self, key: str, value: str): + self.conn.execute("INSERT OR REPLACE INTO config_kv(key,value,updated_at) VALUES(?,?,datetime('now'))", [key, value]) + self.conn.commit() + + def get_config(self, key: str, default=None) -> Optional[str]: + r = self.conn.execute("SELECT value FROM config_kv WHERE key=?", [key]).fetchone() + return r[0] if r else default + + # -- Audit -- + def log_audit(self, plugin: str, action: str, detail: str = ""): + self.conn.execute("INSERT INTO audit_log(plugin_name,action,detail) VALUES(?,?,?)", [plugin, action, detail]) + self.conn.commit() + + def close(self): + if hasattr(self._local, "conn") and self._local.conn: + self._local.conn.close() + self._local.conn = None diff --git a/services/shutdown_service.py b/services/shutdown_service.py new file mode 100644 index 0000000..351be00 --- /dev/null +++ b/services/shutdown_service.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import logging +import asyncio +import signal +import sys +from typing import List, Callable +from enum import Enum + +logger = logging.getLogger(__name__) + +class ShutdownPriority(Enum): + """关闭优先级枚举""" + HIGHEST = 0 + HIGH = 1 + NORMAL = 2 + LOW = 3 + LOWEST = 4 + +class ShutdownService: + """关闭服务 - 管理框架的优雅关闭""" + + def __init__(self, service_manager): + self.service_manager = service_manager + self.shutdown_handlers: List[Callable] = [] + self.is_shutting_down = False + self.shutdown_timeout = 30 # 秒 + logger.debug("ShutdownService初始化开始") + + # 注册信号处理 + self._register_signal_handlers() + + def _register_signal_handlers(self): + """注册信号处理""" + try: + signal.signal(signal.SIGINT, self._signal_handler) + signal.signal(signal.SIGTERM, self._signal_handler) + logger.debug("信号处理器注册完成") + except Exception as e: + logger.error(f"注册信号处理器时出错: {str(e)}", exc_info=True) + + def _signal_handler(self, signum, frame): + """信号处理函数""" + try: + signal_name = signal.Signals(signum).name + logger.info(f"接收到信号: {signal_name}") + asyncio.create_task(self.initiate_shutdown()) + except Exception as e: + logger.error(f"处理信号时出错: {str(e)}", exc_info=True) + sys.exit(1) + + def register_shutdown_handler(self, handler: Callable, priority: ShutdownPriority = ShutdownPriority.NORMAL): + """注册关闭处理器""" + try: + self.shutdown_handlers.append((priority.value, handler)) + # 按优先级排序 + self.shutdown_handlers.sort(key=lambda x: x[0]) + logger.debug(f"注册关闭处理器,优先级: {priority.name}, 当前总数: {len(self.shutdown_handlers)}") + except Exception as e: + logger.error(f"注册关闭处理器时出错: {str(e)}", exc_info=True) + + async def initiate_shutdown(self, reason: str = "正常关闭"): + """发起关闭流程""" + try: + if self.is_shutting_down: + logger.warning("关闭流程已在进行中") + return + + self.is_shutting_down = True + logger.info(f"开始框架关闭流程 - 原因: {reason}") + + # 执行关闭处理器 + await self._execute_shutdown_handlers() + + # 关闭服务管理器 + self.service_manager.shutdown_all() + + logger.info("框架关闭完成") + + # 退出程序 + sys.exit(0) + + except Exception as e: + logger.error(f"关闭流程出错: {str(e)}", exc_info=True) + sys.exit(1) + + async def _execute_shutdown_handlers(self): + """执行关闭处理器""" + try: + logger.debug(f"开始执行 {len(self.shutdown_handlers)} 个关闭处理器") + + for priority, handler in self.shutdown_handlers: + try: + handler_name = handler.__name__ if hasattr(handler, '__name__') else str(handler) + logger.debug(f"执行关闭处理器: {handler_name} (优先级: {priority})") + + if asyncio.iscoroutinefunction(handler): + await asyncio.wait_for(handler(), timeout=self.shutdown_timeout) + else: + # 在事件循环中运行同步函数 + await asyncio.get_event_loop().run_in_executor(None, handler) + + logger.debug(f"关闭处理器完成: {handler_name}") + + except asyncio.TimeoutError: + logger.error(f"关闭处理器超时: {handler_name}") + except Exception as e: + logger.error(f"关闭处理器出错 {handler_name}: {str(e)}", exc_info=True) + + logger.debug("所有关闭处理器执行完成") + + except Exception as e: + logger.error(f"执行关闭处理器时出错: {str(e)}", exc_info=True) + + def emergency_shutdown(self): + """紧急关闭""" + try: + logger.critical("执行紧急关闭") + sys.exit(1) + except Exception as e: + logger.critical(f"紧急关闭时出错: {str(e)}") + os._exit(1) + + def shutdown(self): + """关闭关闭服务""" + try: + logger.info("关闭ShutdownService") + self.shutdown_handlers.clear() + logger.debug("ShutdownService关闭完成") + except Exception as e: + logger.error(f"关闭ShutdownService时出错: {str(e)}", exc_info=True) diff --git a/services/sysmon_widget.py b/services/sysmon_widget.py new file mode 100644 index 0000000..c77f36f --- /dev/null +++ b/services/sysmon_widget.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +"""SenSu TUI 系统监控组件 — CPU/内存/磁盘/插件状态""" +try: + import psutil +except ImportError: + psutil = None +import time, asyncio, logging +from textual.widgets import Static +from textual.reactive import reactive + +logger = logging.getLogger(__name__) + +def get_system_stats(): + """获取系统实时状态""" + try: + cpu = psutil.cpu_percent(interval=0.1) + mem = psutil.virtual_memory() + disk = psutil.disk_usage("/") + net = psutil.net_io_counters() + return { + "cpu": cpu, + "mem_total": mem.total, + "mem_used": mem.used, + "mem_percent": mem.percent, + "disk_total": disk.total, + "disk_used": disk.used, + "disk_percent": disk.percent, + "net_sent": net.bytes_sent, + "net_recv": net.bytes_recv, + "time": time.time(), + } + except Exception as e: + return {"error": str(e)} + +def format_bytes(b): + if b < 1024: return f"{b}B" + if b < 1024**2: return f"{b/1024:.1f}K" + if b < 1024**3: return f"{b/1024**2:.1f}M" + return f"{b/1024**3:.1f}G" + +def make_bar(percent, width=20, filled="█", empty="░"): + n = int(percent / 100 * width) + return filled * n + empty * (width - n) + +class SysMonWidget(Static): + """系统监控显示组件""" + stats = reactive({}) + plugin_info = reactive("") + + def __init__(self): + super().__init__("系统监控初始化中...") + self._refresh_task = None + + def on_mount(self): + self._refresh_task = asyncio.create_task(self._periodic_refresh()) + + async def _periodic_refresh(self): + while True: + try: + s = get_system_stats() + if "error" not in s: + cpu_bar = make_bar(s["cpu"], 20) + mem_bar = make_bar(s["mem_percent"], 20) + disk_bar = make_bar(s["disk_percent"], 20) + + text = ( + f"[bold cyan]━━━ 系统状态 ━━━[/]\n" + f"CPU {cpu_bar} {s['cpu']:5.1f}%\n" + f"MEM {mem_bar} {s['mem_percent']:5.1f}% " + f"({format_bytes(s['mem_used'])}/{format_bytes(s['mem_total'])})\n" + f"DISK {disk_bar} {s['disk_percent']:5.1f}% " + f"({format_bytes(s['disk_used'])}/{format_bytes(s['disk_total'])})\n" + f"NET ↑{format_bytes(s['net_sent'])} ↓{format_bytes(s['net_recv'])}" + ) + if self.plugin_info: + text += f"\n\n[bold yellow]━━━ 插件状态 ━━━[/]\n{self.plugin_info}" + self.update(text) + await asyncio.sleep(2) + except asyncio.CancelledError: + break + except Exception as e: + logger.debug(f"SysMon refresh error: {e}") + await asyncio.sleep(5) + + def update_plugins(self, info: str): + self.plugin_info = info + + def on_unmount(self): + if self._refresh_task: + self._refresh_task.cancel() diff --git a/services/tui_service.py b/services/tui_service.py new file mode 100644 index 0000000..8cb912c --- /dev/null +++ b/services/tui_service.py @@ -0,0 +1,908 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import logging +import sys +import io +import time +from textual.app import App +from textual.containers import Container, ScrollableContainer +from textual.widgets import Static, Input +from textual.suggester import Suggester + +try: + from textual.suggestion import Suggestion +except ImportError: + # Textual < 2.0 兼容 — 自行构造 Suggestion + class Suggestion: + __slots__ = ("value",) + def __init__(self, value: str = ""): + self.value = value +from typing import List, Dict +import asyncio +from datetime import datetime +import psutil +import os + +logger = logging.getLogger(__name__) + +class SystemExitGraceful(Exception): + """优雅的系统退出异常""" + pass + +class LogDisplay(Static): + """日志显示组件 - 直接捕获所有日志输出""" + + def __init__(self): + super().__init__("日志显示区域 - 等待日志输入...") + self.log_lines: List[str] = [] + self.max_lines = 500 + self.auto_scroll_enabled = True # 启用自动滚动 + + # 保存原始的logging处理器和格式器 + self.original_handlers = [] + self.original_formatters = {} + + logger.debug("LogDisplay初始化完成") + + def start_capture(self): + """开始捕获所有日志输出""" + try: + # 获取根日志记录器 + root_logger = logging.getLogger() + + # 保存原始处理器和它们的格式器 + self.original_handlers = root_logger.handlers.copy() + for handler in self.original_handlers: + self.original_formatters[handler] = handler.formatter + + # 清除所有现有处理器 + for handler in root_logger.handlers[:]: + root_logger.removeHandler(handler) + + # 添加我们的自定义处理器 + custom_handler = self.TUILogHandler(self) + log_level = getattr(logging, self.log_level.upper(), logging.DEBUG) if hasattr(self, log_level) else logging.DEBUG + custom_handler.setLevel(log_level) # 从配置读取日志级别 + + # 强制使用包含彩色级别的格式器 + formatter = self.ColoredFormatter( + '%(asctime)s %(levelname_color)s %(name)s: %(message)s', + datefmt='%H:%M:%S' + ) + custom_handler.setFormatter(formatter) + + root_logger.addHandler(custom_handler) + + # 同时重定向stdout和stderr作为备份 + self.original_stdout = sys.stdout + self.original_stderr = sys.stderr + sys.stdout = self.TUIOutput(self) + sys.stderr = self.TUIOutput(self, is_error=True) + + print("✅ TUI日志捕获已启动 - 捕获所有日志输出") + + except Exception as e: + logger.error(f"启动日志捕获失败: {e}") + + def stop_capture(self): + """停止捕获输出""" + try: + # 恢复logging处理器 + root_logger = logging.getLogger() + + # 移除我们的处理器 + for handler in root_logger.handlers[:]: + if hasattr(handler, 'log_display'): + root_logger.removeHandler(handler) + + # 恢复原始处理器和格式器 + for handler in self.original_handlers: + # 恢复格式器 + if handler in self.original_formatters: + handler.setFormatter(self.original_formatters[handler]) + root_logger.addHandler(handler) + + # 恢复stdout和stderr + sys.stdout = self.original_stdout + sys.stderr = self.original_stderr + + print("🛑 TUI日志捕获已停止") + + except Exception as e: + logger.error(f"停止日志捕获失败: {e}") + + def add_log_line(self, line: str): + """添加日志行到TUI显示""" + try: + # 添加到缓冲区 + self.log_lines.append(line) + if len(self.log_lines) > self.max_lines: + self.log_lines.pop(0) + + # 更新显示 + display_content = "\n".join(self.log_lines) + self.update(display_content) + + # 自动滚动到底部 + if self.auto_scroll_enabled: + self.scroll_to_bottom() + + except Exception as e: + # 如果TUI更新失败,回退到原始输出 + if hasattr(self, 'original_stdout'): + self.original_stdout.write(f"TUI日志显示错误: {e}\n") + + def scroll_to_bottom(self): + """滚动到底部""" + try: + # 获取父容器(ScrollableContainer) + parent = self.parent + if parent and hasattr(parent, 'scroll_end'): + parent.scroll_end() + except Exception as e: + # 忽略滚动错误,不影响主要功能 + pass + + def toggle_auto_scroll(self, enabled: bool = None): + """切换自动滚动状态""" + if enabled is None: + self.auto_scroll_enabled = not self.auto_scroll_enabled + else: + self.auto_scroll_enabled = enabled + + logger.debug(f"日志自动滚动: {'启用' if self.auto_scroll_enabled else '禁用'}") + return self.auto_scroll_enabled + + class ColoredFormatter(logging.Formatter): + """带颜色的日志格式器 - 增强版""" + + # ANSI颜色代码 + COLORS = { + 'DEBUG': '\033[36m', # 青色 - DEBUG信息 + 'INFO': '\033[32m', # 绿色 - 正常信息 + 'WARNING': '\033[33m', # 黄色 - 警告信息 + 'ERROR': '\033[31m', # 红色 - 错误信息 + 'CRITICAL': '\033[35m', # 紫色 - 严重错误 + 'RESET': '\033[0m' # 重置颜色 + } + + # 级别显示宽度 + LEVEL_WIDTH = 8 + + def format(self, record): + """格式化日志记录,为级别添加颜色""" + try: + # 为级别添加颜色和固定宽度 + levelname = record.levelname + if levelname in self.COLORS: + # 添加颜色并保持固定宽度 + colored_level = f"{self.COLORS[levelname]}[{levelname:<{self.LEVEL_WIDTH}}]{self.COLORS['RESET']}" + record.levelname_color = colored_level + else: + record.levelname_color = f"[{levelname:<{self.LEVEL_WIDTH}}]" + + # 调用父类格式化方法 + formatted_message = super().format(record) + return formatted_message + + except Exception: + # 如果格式化失败,返回简单格式 + return f"{record.asctime} [{record.levelname}] {record.name}: {record.getMessage()}" + + class TUILogHandler(logging.Handler): + """自定义logging处理器,同时输出到终端和TUI""" + + def __init__(self, log_display): + super().__init__() + self.log_display = log_display + + def emit(self, record): + """处理日志记录""" + try: + # 格式化日志记录(使用我们的格式器) + formatted_message = self.format(record) + + # 输出到原始终端(通过原始处理器,但使用我们的格式器) + for original_handler in self.log_display.original_handlers: + if original_handler.level <= record.levelno: + # 临时使用我们的格式器来确保级别显示一致 + original_handler.setFormatter(self.formatter) + original_handler.emit(record) + # 恢复原始格式器 + original_formatter = self.log_display.original_formatters.get(original_handler) + if original_formatter: + original_handler.setFormatter(original_formatter) + + # 添加到TUI显示 + self.log_display.add_log_line(formatted_message) + + except Exception as e: + # 如果处理失败,使用简单格式 + try: + simple_message = f"{datetime.now().strftime('%H:%M:%S')} [{record.levelname:8}] {record.name}: {record.getMessage()}" + self.log_display.add_log_line(simple_message) + except: + pass + + class TUIOutput(io.TextIOBase): + """自定义输出流,捕获print等输出""" + + def __init__(self, log_display, is_error=False): + self.log_display = log_display + self.is_error = is_error + self.original_stream = sys.stderr if is_error else sys.stdout + + # 颜色定义 + self.COLORS = { + 'INFO': '\033[32m', # 绿色 + 'ERROR': '\033[31m', # 红色 + 'RESET': '\033[0m' # 重置颜色 + } + + def write(self, text): + """写入文本""" + try: + # 写入到原始终端 + self.original_stream.write(text) + self.original_stream.flush() + + # 如果文本不是空的,添加到TUI + if text.strip(): + # 添加简单的时间戳和级别 + timestamp = datetime.now().strftime('%H:%M:%S') + level = "ERROR" if self.is_error else "INFO" + + # 添加颜色 + if level in self.COLORS: + colored_level = f"{self.COLORS[level]}[{level}]{self.COLORS['RESET']}" + else: + colored_level = f"[{level}]" + + # 分割多行文本 + lines = text.split('\n') + for line in lines: + if line.strip(): # 忽略空行 + log_line = f"{timestamp} {colored_level} {line.strip()}" + self.log_display.add_log_line(log_line) + + return len(text) + + except Exception: + # 如果TUI处理失败,只输出到终端 + self.original_stream.write(text) + self.original_stream.flush() + return len(text) + + + def flush(self): + """刷新缓冲区""" + self.original_stream.flush() + + def close(self): + """关闭流""" + pass + +class MessageDisplay(Static): + """消息显示组件 - 增强版""" + + def __init__(self): + super().__init__("消息区域") + self.current_messages: List[Dict] = [] + self.max_messages = 200 # 更大的消息容量 + self.auto_scroll_enabled = True # 启用自动滚动 + # ANSI颜色代码 + self.COLORS = { + 'INFO': '\033[37m', # 黑底白字 + 'DEBUG': '\033[36m', # 青色 - DEBUG信息 + 'SUCCESS': '\033[32m', # 绿色 - 正常信息 + 'WARNING': '\033[33m', # 黄色 - 警告信息 + 'ERROR': '\033[31m', # 红色 - 错误信息 + 'COMMAND': '\033[40;37m', # 灰底白字 - 命令信息 + 'RESET': '\033[0m' # 重置颜色 + } + self.message_types = { + 'info': {'icon': f"{self.COLORS['INFO']}[INFO ]{self.COLORS['RESET']}", 'color': 'white'}, + 'success': {'icon': f"{self.COLORS['SUCCESS']}[SUCCESS]{self.COLORS['RESET']}", 'color': 'green'}, + 'error': {'icon': f"{self.COLORS['ERROR']}[ERROR ]{self.COLORS['RESET']}", 'color': 'red'}, + 'warning': {'icon': f"{self.COLORS['WARNING']}[WARNING]{self.COLORS['RESET']}", 'color': 'yellow'}, + 'debug': {'icon': f"{self.COLORS['DEBUG']}[DEBUG ]{self.COLORS['RESET']}", 'color': 'cyan'}, + 'command': {'icon': f"{self.COLORS['COMMAND']}[COMMAND]{self.COLORS['RESET']}", 'color': 'meow'} + } + logger.debug("MessageDisplay初始化完成") + + def add_message(self, message: str, msg_type: str = "info", persistent: bool = False): + """添加消息 - 支持多行消息""" + try: + # 分割多行消息为单独的消息 + lines = message.strip().split('\n') + for line in lines: + if line.strip(): # 忽略空行 + message_data = { + "text": line.strip(), + "type": msg_type, + "persistent": persistent, + "timestamp": asyncio.get_event_loop().time(), + "display_time": datetime.now().strftime('%H:%M:%S') + } + self.current_messages.append(message_data) + + # 智能消息管理 + self._manage_messages() + + self._update_display() + + except Exception as e: + logger.error(f"添加消息时出错: {str(e)}") + + def reset_display(self): + """重置显示状态""" + try: + # 清空所有消息 + self.current_messages.clear() + # 更新显示 + self.update("消息区域已重置") + # 强制刷新 + self.refresh() + except Exception as e: + logger.error(f"重置消息显示时出错: {str(e)}") + + def _manage_messages(self): + """智能管理消息数量""" + try: + # 计算非持久化消息的数量 + non_persistent_messages = [msg for msg in self.current_messages if not msg['persistent']] + + if len(non_persistent_messages) > self.max_messages: + # 移除最旧的非持久化消息 + for i, msg in enumerate(self.current_messages): + if not msg['persistent']: + self.current_messages.pop(i) + break + + except Exception as e: + logger.error(f"管理消息时出错: {str(e)}") + + def _update_display(self): + """更新显示 - 带时间戳的格式化消息""" + try: + if not self.current_messages: + display_text = "📭 暂无消息" + else: + display_text = [] + for msg in self.current_messages: + # 获取消息类型配置 + msg_config = self.message_types.get(msg['type'], self.message_types['info']) + icon = msg_config['icon'] + + # 构建显示行 + persistent_mark = "🔒 " if msg['persistent'] else "" + time_stamp = f"[{msg['display_time']}] " if len(self.current_messages) > 1 else "" + + display_line = f"{time_stamp}{persistent_mark}{icon} {msg['text']}" + display_text.append(display_line) + + display_text = "\n".join(display_text) + + self.update(display_text) + + # 自动滚动到底部 + if self.auto_scroll_enabled: + self.scroll_to_bottom() + + except Exception as e: + logger.error(f"更新消息显示时出错: {str(e)}") + + def scroll_to_bottom(self): + """滚动到底部""" + try: + # 获取父容器(ScrollableContainer) + parent = self.parent + if parent and hasattr(parent, 'scroll_end'): + parent.scroll_end() + except Exception as e: + # 忽略滚动错误,不影响主要功能 + pass + + def toggle_auto_scroll(self, enabled: bool = None): + """切换自动滚动状态""" + if enabled is None: + self.auto_scroll_enabled = not self.auto_scroll_enabled + else: + self.auto_scroll_enabled = enabled + + logger.debug(f"消息自动滚动: {'启用' if self.auto_scroll_enabled else '禁用'}") + return self.auto_scroll_enabled + +class SystemMonitor(Static): + """系统监控组件 — 从共享 SystemInfoCollector 取数""" + + def __init__(self, collector=None): + super().__init__("") + self._collector = collector + self._timer = None + + def set_collector(self, c): + self._collector = c + + def refresh(self, **kwargs): + """由外部 asyncio task 驱动刷新(比 set_interval 更抗 Android 挂起)""" + try: + if not self._collector: + self.update("📊 系统监控: 数据源未连接") + return + all_data = self._collector.get_all() + cpu_pct = all_data["cpu"]["percent"] + mem = all_data["memory"] + net_speed = all_data["net_speed"] + + def bar(val, w=10): + filled = int(min(val, 100) / 100 * w) + bar_chars = "█" * filled + "░" * (w - filled) + if val < 60: + color = "\033[32m" + elif val < 85: + color = "\033[33m" + else: + color = "\033[31m" + return f"{color}{bar_chars}\033[0m" + + self.update( + f" CPU {bar(cpu_pct)} {cpu_pct:5.1f}% │ " + f"MEM {bar(mem['percent'])} {mem['percent']:5.1f}% " + f"({mem['used_gb']:.1f}/{mem['total_gb']:.1f}G) │ " + f"NET ↓{self._fmt_speed(net_speed['rx_bytes_sec'])} " + f"↑{self._fmt_speed(net_speed['tx_bytes_sec'])}" + ) + except Exception: + pass + + @staticmethod + def _fmt_speed(bps): + if bps < 1024: + return f"{bps:.0f}B/s" + elif bps < 1024 * 1024: + return f"{bps / 1024:.0f}K/s" + else: + return f"{bps / 1024 / 1024:.1f}M/s" + + +class PluginStatusPanel(Static): + """插件实时状态面板 — 展示已加载插件的状态/命令数""" + + def __init__(self, plugin_service=None): + super().__init__("🔌 插件状态: 加载中...") + self._plugin_service = plugin_service + self._timer = None + + def set_plugin_service(self, svc): + self._plugin_service = svc + + def refresh(self, **kwargs): + """由外部 asyncio task 驱动刷新""" + self._refresh() + + def _refresh(self): + try: + if not self._plugin_service: + self.update("🔌 插件状态: 未连接") + return + infos = getattr(self._plugin_service, "plugin_info", {}) + if not infos: + self.update("🔌 插件状态: 无已加载插件") + return + lines = [] + for name, info in infos.items(): + cmds = len(info.commands) if info.commands else 0 + status_icon = "✅" if info.loaded else "❌" + lines.append(f"{status_icon} {name} v{info.version} | {cmds} commands") + self.update("🔌 " + " │ ".join(lines)) + except Exception: + pass + + +class CommandSuggester(Suggester): + """命令补全 — 根据已注册命令提供 Tab 补全建议""" + + def __init__(self, command_service=None): + self._cmd_svc = command_service + + def set_command_service(self, svc): + self._cmd_svc = svc + + async def get_suggestion(self, value: str) -> Suggestion | None: + """根据当前输入返回匹配的命令建议""" + if not value or not self._cmd_svc: + return None + value_lower = value.lower().strip() + commands = getattr(self._cmd_svc, "commands", {}) + # 优先前缀匹配 + candidates = sorted( + [n for n in commands if n.startswith(value_lower)], + key=len, + ) + if not candidates: + candidates = sorted( + [n for n in commands if value_lower in n], + key=len, + ) + if candidates: + cmd = candidates[0] + return Suggestion(cmd[len(value_lower):]) + return None + + +class TUIFramework(App): + """TUI框架应用""" + def __init__(self, config, log_service, command_service, plugin_service=None): + super().__init__() + self.config = config + self.log_service = log_service + self.command_service = command_service + self._plugin_service = plugin_service + + self.log_display = LogDisplay() + self.message_display = MessageDisplay() + self.system_monitor = SystemMonitor() + self.plugin_panel = PluginStatusPanel(plugin_service) + self.title_bar = Static("🐱 SenSu", id="title-bar") + self.command_input = None + self.CSS = self._generate_css() + + + def _generate_css(self): + """动态生成 CSS — grid 布局:空行 + 标题 + 状态栏 + 主内容 + 底部输入""" + return """ + Screen { + layout: grid; + grid-size: 1 5; + grid-rows: 1 2 auto 1fr 3; + } + + #title-bar { + padding: 0 1; + background: $accent; + color: $text; + text-style: bold; + } + + #monitor-area { + padding: 0 1; + background: $panel; + color: $text; + } + + #main-area { + overflow-y: auto; + scrollbar-size: 1 1; + border: solid $primary; + } + + #input-area { + padding: 0 1; + } + + ScrollableContainer { + scrollbar-color: #666 #222; + scrollbar-color-hover: #888 #333; + } + + Static { + width: 100%; + content-align: left top; + } + """ + + def compose(self): + """组合界面 — 空行 + 标题 + 状态栏 + 主显示区 + 底部输入""" + yield Static("") # Row 1: blank + yield self.title_bar # Row 2: title + yield Container( # Row 3: monitor + self.system_monitor, + self.plugin_panel, + id="monitor-area", + ) + yield ScrollableContainer( # Row 4: main (1fr) + self.log_display, + id="main-area", + ) + self.command_suggester = CommandSuggester(self.command_service) + self.command_input = Input( + placeholder="🐱 输入指令... Tab 补全", + suggester=self.command_suggester, + id="command-input", + ) + yield Container( # Row 5: input (3 rows) + self.command_input, + id="input-area", + ) + + async def on_mount(self): + """挂载完成事件""" + try: + # 开始捕获所有输出 + self.log_display.start_capture() + + # 设置输入框焦点 + if self.command_input: + self.command_input.focus() + + # 显示欢迎消息 + self.show_message("🐱 SenSu TUI 已就绪!输入 'help' 查看命令\n", "info") + print("✅ TUI已启动,开始捕获所有输出") + + except Exception as e: + print(f"❌ TUI挂载时出错: {str(e)}") + + async def on_input_submitted(self, event): + """输入提交事件""" + try: + if hasattr(event, 'input') and event.input.id == "command-input": + command = event.value + event.input.value = "" # 清空输入框 + + if command.strip(): + print(f"执行命令: {command}") + + # 在消息区域显示正在处理 + self.show_message(f"执行命令: {command}", "command") + + # 发送到指令服务处理 + result = await self.command_service.process_command(command, "tui") + + # 显示命令结果 + if result: + self.show_message(f"结果: {result}", "success") + else: + self.show_message("命令执行完成", "success") + + except Exception as e: + print(f"❌ 指令处理错误: {str(e)}") + self.show_message(f"指令处理错误: {str(e)}", "error") + + def show_message(self, message: str, msg_type: str = "info", persistent: bool = False): + """显示消息到主显示区(log_display)""" + try: + prefix = {"info": "ℹ️", "success": "✅", "error": "❌", "warning": "⚠️", + "command": "▶️", "debug": "🔍"}.get(msg_type, "📝") + for line in message.strip().split("\n"): + if line.strip(): + self.log_display.add_log_line(f"{prefix} {line.strip()}") + except Exception as e: + print(f"❌ 显示TUI消息时出错: {str(e)}") + + def clear_messages(self, clear_persistent: bool = False): + """清空主显示区""" + try: + self.log_display.log_lines.clear() + self.log_display.update("") + except Exception as e: + print(f"❌ 清空消息时出错: {str(e)}") + + async def action_quit(self): + """重写退出动作 - 最佳方案:优雅关闭""" + try: + logger.info("🐱 TUI接收到退出信号,开始关闭流程") + + # 显示关闭消息 + self.show_message("🐱 正在关闭框架...", "info", persistent=True) + + # 停止捕获输出 + self.log_display.stop_capture() + + # 使用异步任务来优雅关闭,避免阻塞 + asyncio.create_task(self._async_graceful_shutdown()) + + except Exception as e: + logger.error(f"TUI退出处理时出错: {str(e)}") + # 紧急退出 + import os + os._exit(0) + + async def _async_graceful_shutdown(self): + """异步优雅关闭""" + try: + # 给一点时间显示消息 + self.show_message("🐱 3...", "info", persistent=True) + await asyncio.sleep(1) + self.show_message("🐱 2..", "info", persistent=True) + await asyncio.sleep(1) + self.show_message("🐱 1.", "info", persistent=True) + await asyncio.sleep(1) + + logger.info("🐱 执行异步关闭") + + logger.debug("使用事件循环停止") + + # 获取当前事件循环 + loop = asyncio.get_event_loop() + + # 停止所有运行中的任务(除了当前任务) + tasks = [t for t in asyncio.all_tasks(loop) if t is not asyncio.current_task()] + + if tasks: + logger.debug(f"取消 {len(tasks)} 个运行中的任务") + for task in tasks: + task.cancel() + + # 等待任务取消完成 + await asyncio.gather(*tasks, return_exceptions=True) + + # 停止事件循环 + loop.stop() + logger.info("🐱 事件循环已停止,框架关闭完成") + + except Exception as e: + logger.error(f"异步关闭失败: {str(e)}") + # 最后的手段 + import os + os._exit(0) + + def shutdown(self): + """关闭TUI""" + try: + # 停止捕获输出 + self.log_display.stop_capture() + self.action_quit() + self.exit() + print("🛑 TUI已关闭") + except Exception as e: + print(f"❌ 关闭TUI时出错: {str(e)}") + +class TuiService: + """TUI服务""" + + def __init__(self, config: Dict, log_service, command_service, sys_collector=None, plugin_service=None): + self.config = config + self.log_service = log_service + self.command_service = command_service + self.sys_collector = sys_collector + self.plugin_service = plugin_service + self.tui_app = None + self._message_queue = asyncio.Queue() + self._message_processor_task = None + + async def start(self): + """启动TUI""" + try: + if not self.config.get('tui', {}).get('enabled', True): + print("TUI已禁用") + return + + print("启动TUI服务") + self.tui_app = TUIFramework( + self.config, self.log_service, self.command_service, + plugin_service=self.plugin_service, + ) + # 注入系统数据采集器 + self.tui_app.system_monitor.set_collector(self.sys_collector) + self.tui_app.plugin_panel.set_plugin_service(self.plugin_service) + + # 设置动态标题 + self._setup_title() + + # 启动消息处理任务 + self._message_processor_task = asyncio.create_task(self._process_message_queue()) + + # 启动后台刷新任务 (asyncio.sleep 比 set_interval 更抗 Android 挂起) + self._refresh_task = asyncio.create_task(self._refresh_monitors()) + + # 在后台运行TUI + asyncio.create_task(self._run_tui()) + + except Exception as e: + print(f"启动TUI服务时出错: {str(e)}") + raise + + def toggle_auto_scroll(self, target: str = "all", enabled: bool = None): + """切换自动滚动状态""" + try: + if not self.tui_app: + return "❌ TUI未启动" + + result = [] + + if target in ["all", "log", "message"]: + log_state = self.tui_app.log_display.toggle_auto_scroll(enabled) + result.append(f"📜 自动滚动: {'✅ 启用' if log_state else '❌ 禁用'}") + + return "\n".join(result) + + except Exception as e: + return f"❌ 切换自动滚动失败: {str(e)}" + + def scroll_to_bottom(self, target: str = "all"): + """手动滚动到底部""" + try: + if not self.tui_app: + return "❌ TUI未启动" + + result = [] + + if target in ["all", "log", "message"]: + self.tui_app.log_display.scroll_to_bottom() + result.append("📜 已滚动到底部") + + return "\n".join(result) + + except Exception as e: + return f"❌ 滚动到底部失败: {str(e)}" + + async def _process_message_queue(self): + """处理消息队列,避免消息过多导致界面卡顿""" + try: + while True: + # 从队列中获取消息 + message_data = await self._message_queue.get() + + if message_data is None: # 停止信号 + break + + message, msg_type, persistent = message_data + + # 显示消息 + if self.tui_app: + self.tui_app.show_message(message, msg_type, persistent) + + # 小延迟避免消息过快 + await asyncio.sleep(0.05) + + except asyncio.CancelledError: + logger.debug("消息处理任务被取消") + except Exception as e: + logger.error(f"消息处理任务出错: {str(e)}") + + async def _refresh_monitors(self): + """后台刷新系统监控 + 插件面板 (asyncio.sleep, Android 挂起后可恢复)""" + try: + while True: + if self.tui_app: + self.tui_app.system_monitor.refresh() + self.tui_app.plugin_panel.refresh() + await asyncio.sleep(2) + except asyncio.CancelledError: + logger.debug("监控刷新任务被取消") + + def _setup_title(self): + """设置TUI标题""" + try: + framework_config = self.config.get('framework', {}) + name = framework_config.get('name', 'SenSu') + version = framework_config.get('version', 'Unknown') + debug_mode = framework_config.get('debug', False) + + title = f"🐱 {name} Ver.{version}" + if debug_mode: + title += " [DEBUG]" + self.tui_app.title_bar.update(title) + logger.debug(f"设置TUI标题: {title}") + + except Exception as e: + logger.error(f"设置TUI标题时出错: {e}") + + async def _run_tui(self): + """运行TUI""" + try: + await self.tui_app.run_async() + except Exception as e: + print(f"运行TUI时出错: {str(e)}") + + def show_message(self, message: str, msg_type: str = "info", persistent: bool = False): + """显示消息""" + try: + if self.tui_app: + # 将消息放入队列,由后台任务处理 + self._message_queue.put_nowait((message, msg_type, persistent)) + except Exception as e: + print(f"通过TUI服务显示消息时出错: {str(e)}") + + def shutdown(self): + """关闭TUI服务""" + try: + if hasattr(self, '_refresh_task') and self._refresh_task: + self._refresh_task.cancel() + if self.tui_app: + self.tui_app.shutdown() + print("TUI服务已关闭") + except Exception as e: + print(f"关闭TUI服务时出错: {str(e)}") diff --git a/services/update_service.py b/services/update_service.py new file mode 100644 index 0000000..54f4774 --- /dev/null +++ b/services/update_service.py @@ -0,0 +1,309 @@ +#!/usr/bin/env python3 +"""版本更新服务 — 下载 release ZIP + 解压 + 保护配置 + 热重启""" +import logging +import asyncio +import os +import shutil +import subprocess +import tempfile +import urllib.request +import zipfile +import json +import time +from pathlib import Path +from typing import Optional + +logger = logging.getLogger(__name__) + +CHECK_INTERVAL = 3600 * 6 # 6 小时 +_PROJECT_ROOT = Path(__file__).resolve().parent.parent +_RESTART_FLAG = _PROJECT_ROOT / ".restart_flag" + +# 更新时跳过的路径 (用户配置/数据) +_PRESERVE_PATHS = { + "config/framework/base_config.yaml", + "config/permissions/", + "data/", + "plugins/*/config.yaml", + "logs/", + ".restart_flag", +} + + +def _parse_ver(v: str) -> tuple: + v = v.strip().lstrip("vV") + try: + return tuple(int(p) for p in v.split(".")[:3]) + except Exception: + return (0, 0, 0) + + +_BACKUP_SKIP = {"data/", "logs/", "__pycache__/", ".git/", "node_modules/", ".venv/", "venv/", + "*.pyc", "*.pyo", ".restart_flag"} + + +def _backup_current(dest_path: Path): + """将当前项目打包为 ZIP (跳过数据/日志/缓存)""" + from fnmatch import fnmatch + with zipfile.ZipFile(str(dest_path), "w", zipfile.ZIP_DEFLATED) as zf: + for root, dirs, files in os.walk(str(_PROJECT_ROOT)): + # 跳过不需要备份的目录 + dirs[:] = [d for d in dirs if not any( + fnmatch(d + "/", p.rstrip("/") + "/") or fnmatch(d, p.rstrip("/")) + for p in _BACKUP_SKIP if "/" in p or p.endswith("/") + ) and d not in {p.rstrip("/") for p in _BACKUP_SKIP if not any(c in p for c in "*?[")}] + for f in files: + rel = os.path.relpath(os.path.join(root, f), str(_PROJECT_ROOT)) + if any(fnmatch(f, p) or fnmatch(rel, p) for p in _BACKUP_SKIP): + continue + zf.write(os.path.join(root, f), rel) + + +def _should_preserve(rel_path: str) -> bool: + """检查路径是否应跳过 (保护用户数据)""" + from fnmatch import fnmatch + for pattern in _PRESERVE_PATHS: + if fnmatch(rel_path, pattern) or rel_path.startswith(pattern.rstrip("/") + "/"): + return True + # 匹配目录前缀 + if rel_path == pattern.rstrip("/"): + return True + return False + + +class UpdateService: + def __init__(self, service_manager): + self.sm = service_manager + self._task: Optional[asyncio.Task] = None + self._running = False + # 日志目录 + import logging as _logging + self.update_logger = _logging.getLogger("senSu.update") + log_dir = _PROJECT_ROOT / "logs" / "updates_logs" + log_dir.mkdir(parents=True, exist_ok=True) + fh = _logging.FileHandler( + log_dir / f"update_{time.strftime('%Y%m%d_%H%M%S')}.log", + encoding='utf-8' + ) + fh.setFormatter(_logging.Formatter('%(asctime)s [%(levelname)s] %(message)s')) + self.update_logger.addHandler(fh) + self.update_logger.setLevel(_logging.INFO) + self.status: dict = {"state": "idle", "progress": 0, "error": "", + "last_check": 0, "checked": False, "update_available": False, + "current_version": "", "latest_version": ""} + # 从配置读取分支和仓库地址 + try: + init = self.sm.get_service("init") + cfg = init.get_config("base").get("framework", {}) + except Exception: + cfg = {} + self.branch = cfg.get("branch", "main") + self.repo_url = cfg.get("repo_url", "https://git.yeij.top/AskaEth/SenSu").rstrip("/") + self.repo_name = self.repo_url.rstrip("/").split("/")[-1] + # ZIP 下载地址: Gitea 格式 + self.download_url = f"{self.repo_url}/archive/{self.branch}.zip" + # 版本检查 API + self.version_url = f"{self.repo_url}/raw/branch/{self.branch}/version.json" + + async def start(self): + self._running = True + self.status["current_version"] = self._current_version() + self._task = asyncio.create_task(self._loop()) + logger.info(f"🔄 更新服务已启动 — 分支: {self.branch}, 仓库: {self.repo_url}") + + async def _loop(self): + await asyncio.sleep(30) # 等框架就绪 + while self._running: + await self._check_version() + await asyncio.sleep(CHECK_INTERVAL) + + async def _check_version(self): + """检查是否有新版本 (非阻塞)""" + try: + loop = asyncio.get_event_loop() + data = await loop.run_in_executor(None, self._sync_fetch_json, self.version_url) + self.status["last_check"] = time.time() + self.status["checked"] = True + current = self._current_version() + self.status["current_version"] = current + if not data: + return + remote_fw = data.get("framework", {}) + remote_ver = remote_fw.get("version", "") + if not remote_ver: + return + if _parse_ver(remote_ver) > _parse_ver(current): + self.status["update_available"] = True + self.status["latest_version"] = remote_ver + self.status["download_url"] = self.download_url + logger.info(f"🔄 新版本可用: {remote_ver} (当前: {current})") + except Exception as e: + logger.debug(f"版本检查失败: {e}") + + def _sync_fetch_json(self, url: str) -> Optional[dict]: + try: + req = urllib.request.Request(url, headers={"User-Agent": "SenSu-Update/1.0"}) + with urllib.request.urlopen(req, timeout=15) as resp: + return json.loads(resp.read().decode()) + except Exception: + return None + + def _current_version(self) -> str: + try: + init = self.sm.get_service("init") + return init.get_config("base").get("framework", {}).get("version", "0.0.0").lstrip("vV") + except Exception: + return "0.0.0" + + # ── 执行更新 ── + + async def apply_update(self) -> dict: + """下载并应用更新 (在线程池中执行磁盘操作)""" + if self.status["state"] == "downloading": + return {"ok": False, "error": "更新已在进行中"} + + self.status = {"state": "downloading", "progress": 0, "error": "", + "last_check": self.status.get("last_check", 0), + "update_available": self.status.get("update_available", False)} + try: + loop = asyncio.get_event_loop() + result = await loop.run_in_executor(None, self._sync_apply) + if result.get("ok"): + self.status = {"state": "done", "progress": 100, "error": "", + "last_check": time.time(), "update_available": False} + # 写重启标记 + _RESTART_FLAG.touch() + logger.info("✅ 更新完成, 框架将在下次主循环检测后重启") + else: + self.status["state"] = "error" + self.status["error"] = result.get("error", "未知错误") + return result + except Exception as e: + self.status["state"] = "error" + self.status["error"] = str(e) + return {"ok": False, "error": str(e)} + + def _sync_apply(self) -> dict: + """同步执行更新 (在线程池中)""" + tmp_zip = None + tmp_dir = None + try: + url = self.download_url + logger.info(f"📥 下载更新包: {url}") + + self._log("开始更新...") + # 0. 备份当前版本 + self.status["progress"] = 2 + self._log("创建备份...") + backup_name = f"SenSu_backup_{self._current_version()}_{time.strftime('%Y%m%d_%H%M%S')}.zip" + backup_dir = _PROJECT_ROOT / "data" / "backups" + backup_dir.mkdir(parents=True, exist_ok=True) + backup_path = backup_dir / backup_name + _backup_current(backup_path) + logger.info(f"📦 已备份到: {backup_path}") + # 清理旧备份 (保留最新 5 个) + backups = sorted(backup_dir.glob("SenSu_backup_*.zip"), key=os.path.getmtime, reverse=True) + for old in backups[5:]: + old.unlink() + + # 1. 下载 ZIP + self.status["progress"] = 10 + tmp_zip = tempfile.NamedTemporaryFile(suffix=".zip", delete=False) + req = urllib.request.Request(url, headers={"User-Agent": "SenSu-Update/1.0"}) + with urllib.request.urlopen(req, timeout=300) as resp: + shutil.copyfileobj(resp, tmp_zip) + tmp_zip.close() + self._log("下载完成") + self.status["progress"] = 40 + + # 2. 解压到临时目录 + tmp_dir = tempfile.mkdtemp() + with zipfile.ZipFile(tmp_zip.name, "r") as zf: + zf.extractall(tmp_dir) + self._log("解压更新包...") + self.status["progress"] = 60 + + # 3. Gitea zip 内层目录: 通常是 repo_name-branch/ + extracted_root = tmp_dir + contents = os.listdir(tmp_dir) + if len(contents) == 1: + inner = os.path.join(tmp_dir, contents[0]) + if os.path.isdir(inner): + extracted_root = inner + + # 4. 覆盖文件 (跳过用户配置) + for root, dirs, files in os.walk(extracted_root): + rel = os.path.relpath(root, extracted_root) + if rel == ".": + rel = "" + for f in files: + fp = os.path.join(rel, f) if rel else f + if _should_preserve(fp): + continue + src = os.path.join(root, f) + dst = os.path.join(str(_PROJECT_ROOT), fp) + os.makedirs(os.path.dirname(dst), exist_ok=True) + shutil.copy2(src, dst) + + self._log("覆盖文件 (跳过用户配置)...") + self.status["progress"] = 80 + + # 4.5 同步版本号到旧配置文件 + try: + old_cfg_path = str(_PROJECT_ROOT / "config" / "framework" / "base_config.yaml") + new_tpl_path = str(_PROJECT_ROOT / "config" / "framework" / "base_config.yaml.example") + if os.path.exists(old_cfg_path) and os.path.exists(new_tpl_path): + import yaml as _yaml + with open(new_tpl_path) as f: + new_tpl = _yaml.safe_load(f) + new_ver = new_tpl.get("framework", {}).get("version", "") + if new_ver: + with open(old_cfg_path) as f: + old_cfg = _yaml.safe_load(f) or {} + old_cfg.setdefault("framework", {})["version"] = new_ver + with open(old_cfg_path, "w") as f: + _yaml.dump(old_cfg, f, default_flow_style=False, allow_unicode=True) + self._log(f"版本号已同步: {new_ver}") + logger.info(f"📝 版本号已同步: {new_ver}") + except Exception as e: + logger.warning(f"版本号同步失败: {e}") + + self._log("安装依赖...") + # 5. 安装依赖 (子进程) + req_file = str(_PROJECT_ROOT / "requirements.txt") + if os.path.exists(req_file): + r = subprocess.run( + ["pip", "install", "-r", req_file, "--break-system-packages"], + capture_output=True, text=True, timeout=120, + cwd=str(_PROJECT_ROOT), + ) + if r.returncode != 0: + self._log(f"依赖安装警告: {r.stderr[-200:]}") + logger.warning(f"依赖安装警告: {r.stderr[-300:]}") + + self.status["progress"] = 100 + self._log("更新完成, 框架将重启") + return {"ok": True, "msg": "更新完成, 框架将重启"} + + except Exception as e: + self._log(f"更新失败: {e}") + logger.error(f"更新失败: {e}") + return {"ok": False, "error": str(e)} + finally: + if tmp_zip and os.path.exists(tmp_zip.name): + os.unlink(tmp_zip.name) + if tmp_dir and os.path.exists(tmp_dir): + shutil.rmtree(tmp_dir, ignore_errors=True) + + def _log(self, msg: str): + """写入更新日志 + 更新SSE状态""" + self.update_logger.info(msg) + self.status["log"] = self.status.get("log", "") + msg + "\n" + + async def check_now(self): + await self._check_version() + + async def stop(self): + self._running = False + if self._task: + self._task.cancel() diff --git a/services/web_panel/__init__.py b/services/web_panel/__init__.py new file mode 100644 index 0000000..b3fe8a6 --- /dev/null +++ b/services/web_panel/__init__.py @@ -0,0 +1,2 @@ +from .manager import WebPanelManager +__all__ = ["WebPanelManager"] diff --git a/services/web_panel/auth.py b/services/web_panel/auth.py new file mode 100644 index 0000000..ad1a325 --- /dev/null +++ b/services/web_panel/auth.py @@ -0,0 +1,31 @@ +import logging +logger = logging.getLogger(__name__) +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +import functools +from aiohttp import web + +def panel_auth(handler): + """面板专用鉴权装饰器(替代子应用中间件)""" + @functools.wraps(handler) + async def wrapper(request, *args, **kwargs): + token = request.cookies.get("panel_token") + if not token and request.headers.get("Authorization", "").startswith("Bearer "): + token = request.headers["Authorization"].split(" ", 1)[1] + + auth_svc = request.app.get('auth_service') + is_valid = False + + if token and auth_svc: + try: + v = await auth_svc.validate_token(token) + is_valid = bool(v) + except: pass + elif not auth_svc: + is_valid = False # 认证不可用时拒绝 + + if not is_valid: + return web.json_response({"error": "未认证或会话过期"}, status=401) + + return await handler(request, *args, **kwargs) + return wrapper diff --git a/services/web_panel/manager.py b/services/web_panel/manager.py new file mode 100644 index 0000000..540fce5 --- /dev/null +++ b/services/web_panel/manager.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import os +import logging +import hashlib +import secrets +from pathlib import Path +from aiohttp import web +from .routes import auth, status, plugins, commands, logs, projects, proxy, plugin_web, files, apikeys + +logger = logging.getLogger(__name__) + + +def _hash_pw(password: str, salt: str) -> str: + return hashlib.sha256((password + salt).encode()).hexdigest() + + +class WebPanelManager: + def __init__(self, config: dict, service_manager): + panel_cfg = config.get('panel', {}).get('entrance', {}) + self.base_path = panel_cfg.get('path', '/panel') + self.panel_user = os.environ.get('SENSU_PANEL_USER', panel_cfg.get('username', 'admin')) + + raw = os.environ.get('SENSU_PANEL_PASS') or panel_cfg.get('password', 'admin') + salt = panel_cfg.get('password_salt', '') or secrets.token_hex(16) + pw_hash = panel_cfg.get('password_hash', '') or _hash_pw(raw, salt) + self.panel_pass = raw # 保留向后兼容 + self._pw_hash = pw_hash + self._pw_salt = salt + + if raw == 'admin' and not panel_cfg.get('password_hash'): + logger.critical("⚠️ 面板使用默认密码 admin!") + + self.base_path = f"/{self.base_path.strip('/')}" + self.sm = service_manager + self.project_root = Path(__file__).resolve().parent.parent.parent + + async def start(self): + internet = self.sm.get_service("internet") + if not internet or not internet.http_app: + logger.error("❌ 网络服务未就绪,无法注册面板路由") + return False + + app = internet.http_app + logger.info(f"🌐 向网络服务注册面板路由 (前缀: {self.base_path})...") + + # 依赖注入 + app['service_manager'] = self.sm + app['auth_service'] = self.sm.get_service("auth") + app['log_service'] = self.sm.get_service("log") + app['sensu_db'] = self.sm.get_service("sensu_db") + app['panel_config'] = { + 'username': self.panel_user, + 'password': self.panel_pass, + 'password_hash': self._pw_hash, + 'password_salt': self._pw_salt, + 'index_path': self.project_root / "static" / "web_panel" / "index.html", + 'home_path': self.project_root / "static" / "web_panel" / "home.html", + } + + # 注册静态文件 + # URL 前缀: /SenSu/static/ -> 物理路径: .../static/web_panel/ + static_dir = self.project_root / "static" / "web_panel" + if static_dir.exists(): + app.router.add_static(f'{self.base_path}/static/', path=str(static_dir)) + logger.info(f"📂 静态资源已挂载: {self.base_path}/static/") + else: + logger.warning(f"⚠️ 静态目录缺失: {static_dir}") + + # 注册首页 (登录页) + app.router.add_get(self.base_path, self._redirect_slash) + app.router.add_get(f'{self.base_path}/', self._serve_index) + app.router.add_get(f'{self.base_path}/index.html', self._serve_index) + + # 🟢 新增: 注册面板主页 (/SenSu/home.html -> home.html) + app.router.add_get(f'{self.base_path}/home.html', self._serve_home) + + # 注册 API 路由 + auth.setup_routes(app, self.base_path) + apikeys.setup_routes(app, self.base_path) + status.setup_routes(app, self.base_path) + 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, self.base_path) + proxy.setup_proxy_routes(app, self.sm, self.base_path) + plugin_web.setup_plugin_web_routes(app, self.sm) + files.setup_file_routes(app, self.sm, self.base_path) + + # 注册日志广播 + ls = self.sm.get_service("log") + if ls and hasattr(ls, 'add_log_consumer'): + ls.add_log_consumer(logs.broadcast_log) + logger.info("📡 日志广播已连接") + + logger.info(f"✅ 面板路由注册完成 (复用原有网络服务路由器)") + return True + + async def _redirect_slash(self, req): + return web.HTTPFound(f'{self.base_path}/') + + async def _serve_index(self, req): + """提供登录页""" + path = req.app['panel_config']['index_path'] + if path.exists(): + resp = web.FileResponse(path) + resp.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate' + return resp + return web.Response(text=f"❌ 找不到 index.html\n路径: {path}", status=404) + + async def _serve_home(self, req): + """提供面板主页""" + path = req.app['panel_config']['home_path'] + if path.exists(): + resp = web.FileResponse(path) + resp.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate' + return resp + return web.Response(text=f"❌ 找不到 home.html\n路径: {path}", status=404) diff --git a/services/web_panel/middleware.py b/services/web_panel/middleware.py new file mode 100644 index 0000000..3aa2ad1 --- /dev/null +++ b/services/web_panel/middleware.py @@ -0,0 +1,39 @@ +from aiohttp import web +from .utils.response import json_res +import logging + +logger = logging.getLogger(__name__) + +# 白名单 (相对于子应用的路径) +WHITE_LIST = { + "/api/login", + "/api/auth/status", + "/", + "/static/" +} + +async def auth_middleware(app, handler): + async def mid(req): + path = req.path + + # 检查白名单 + if any(path.startswith(w) for w in WHITE_LIST): + return await handler(req) + + # 提取 Token + token = req.cookies.get("panel_token") + if not token and req.headers.get("Authorization", "").startswith("Bearer "): + token = req.headers["Authorization"].split(" ", 1)[1] + + valid, info = False, {} + + # 验证 Token (简单内存验证,后期可接 Redis/DB) + session_store = app.get('session_store', {}) + if token and token in session_store: + valid, info = True, session_store[token] + + if valid: + req['user'] = info + return await handler(req) + return json_res({"error": "未认证"}, 401) + return mid diff --git a/services/web_panel/routes/__init__.py b/services/web_panel/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/web_panel/routes/apikeys.py b/services/web_panel/routes/apikeys.py new file mode 100644 index 0000000..09cff6f --- /dev/null +++ b/services/web_panel/routes/apikeys.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +"""API Key 管理路由 — 创建/列表/删除长期有效的 API 密钥,支持权限范围和过期时间""" +import secrets +import time +import logging +from aiohttp import web +from ..utils.auth import panel_auth + +logger = logging.getLogger(__name__) + +# 预定义权限模板 +PERMISSION_PRESETS = { + "readonly": [ + "framework.status.read", + "plugin.info.read", + ], + "monitor": [ + "framework.status.read", + "plugin.info.read", + "framework.event.subscribe", + ], + "full": ["admin"], +} + +# 持久化 key: {key_string: {name, created_at, expires_at, permissions, last_used, request_count}} +API_KEYS: dict[str, dict] = {} + + +def _load_keys_from_db(app): + """从数据库恢复 API keys""" + try: + db = app.get("sensu_db") + if not db: + sm = app.get("service_manager") + if sm: + try: + db = sm.get_service("sensu_db") + except Exception: + pass + if db: + import json + raw = db.get_config("api_keys", "{}") + stored = json.loads(raw) if raw else {} + now = time.time() + for k, v in list(stored.items()): + # 清理过期 key + if v.get("expires_at") and now > v["expires_at"]: + logger.info(f"🔑 过期 API Key 已清理: {v.get('name', '?')}") + continue + API_KEYS[k] = v + if API_KEYS: + logger.info(f"🔑 从数据库恢复了 {len(API_KEYS)} 个 API Key") + except Exception as e: + logger.warning(f"API Key 恢复失败: {e}") + + +def _save_keys_to_db(app): + """持久化 API keys 到数据库""" + try: + db = app.get("sensu_db") + if not db: + sm = app.get("service_manager") + if sm: + try: + db = sm.get_service("sensu_db") + except Exception: + pass + if db: + import json + db.set_config("api_keys", json.dumps(API_KEYS)) + except Exception as e: + logger.debug(f"API Key 持久化失败: {e}") + + +def validate_api_key(token: str) -> dict | None: + """验证 API Key — 检查过期,更新使用统计,返回 key_info 或 None""" + if token not in API_KEYS: + return None + info = API_KEYS[token] + # 检查过期 + if info.get("expires_at") and time.time() > info["expires_at"]: + del API_KEYS[token] + return None + info["last_used"] = time.time() + info["request_count"] = info.get("request_count", 0) + 1 + return info + + +def setup_routes(app, prefix=""): + """注册 API Key 管理路由""" + _load_keys_from_db(app) + app["api_keys"] = API_KEYS + + # ── 列表 ── + async def list_keys(req): + now = time.time() + keys = [] + for k, v in API_KEYS.items(): + expired = v.get("expires_at") and now > v["expires_at"] + keys.append({ + "key": k[:8] + "..." + k[-4:], + "full_key": k, + "name": v.get("name", ""), + "permissions": v.get("permissions", []), + "created_at": v.get("created_at", 0), + "expires_at": v.get("expires_at", 0), + "last_used": v.get("last_used", 0), + "request_count": v.get("request_count", 0), + "expired": expired, + }) + return web.json_response({"keys": keys, "presets": PERMISSION_PRESETS}) + + # ── 创建 ── + async def create_key(req): + try: + data = await req.json() + name = data.get("name", "").strip() + if not name: + return web.json_response({"error": "名称不能为空"}, status=400) + + # 权限范围: 模板名 或 自定义列表 + preset = data.get("preset", "monitor") + if preset in PERMISSION_PRESETS: + permissions = list(PERMISSION_PRESETS[preset]) + else: + permissions = data.get("permissions", ["framework.status.read"]) + + # 过期时间 (秒), 0 = 永不过期 + ttl = int(data.get("ttl", 0)) + expires_at = (time.time() + ttl) if ttl > 0 else 0 + + key = "sk-" + secrets.token_hex(24) + API_KEYS[key] = { + "name": name, + "permissions": permissions, + "created_at": time.time(), + "expires_at": expires_at, + "last_used": 0, + "request_count": 0, + } + _save_keys_to_db(req.app) + logger.info( + f"🔑 API Key 已创建: {name} ({key[:12]}...) " + f"perms={permissions} ttl={ttl}s" + ) + + return web.json_response({ + "ok": True, + "key": key, + "name": name, + "permissions": permissions, + "expires_at": expires_at, + "created_at": API_KEYS[key]["created_at"], + }) + except Exception as e: + return web.json_response({"error": str(e)}, status=500) + + # ── 删除 ── + async def delete_key(req): + try: + data = await req.json() + full_key = data.get("key", "") + if full_key in API_KEYS: + name = API_KEYS[full_key]["name"] + del API_KEYS[full_key] + _save_keys_to_db(req.app) + logger.info(f"🔑 API Key 已删除: {name}") + return web.json_response({"ok": True}) + return web.json_response({"error": "Key 不存在"}, status=404) + except Exception as e: + return web.json_response({"error": str(e)}, status=500) + + app.router.add_get(f"{prefix}/api/apikeys", panel_auth(list_keys)) + app.router.add_post(f"{prefix}/api/apikeys", panel_auth(create_key)) + app.router.add_delete(f"{prefix}/api/apikeys", panel_auth(delete_key)) + logger.info(f"🔑 API Key 管理路由已注册 ({prefix}/api/apikeys)") diff --git a/services/web_panel/routes/auth.py b/services/web_panel/routes/auth.py new file mode 100644 index 0000000..9477faf --- /dev/null +++ b/services/web_panel/routes/auth.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import secrets +import time +import logging +from aiohttp import web +from ..utils.auth import panel_auth + +logger = logging.getLogger(__name__) + +# 全局 Session 存储 (内存型) +PANEL_SESSION_STORE = {} + +# 登录频率限制 — {ip: [fail_count, lock_until_timestamp]} +_LOGIN_FAILS: dict[str, list] = {} +_MAX_FAILS = 5 +_LOCK_SECONDS = 60 + + +def _check_rate_limit(ip: str) -> bool: + """检查 IP 是否被限流。返回 True = 允许尝试""" + now = time.time() + entry = _LOGIN_FAILS.get(ip) + if entry: + fail_count, lock_until = entry + if lock_until and now < lock_until: + return False # still locked + if lock_until and now >= lock_until: + _LOGIN_FAILS.pop(ip, None) # lock expired, reset + return True + + +def _record_fail(ip: str): + now = time.time() + entry = _LOGIN_FAILS.get(ip) + if entry is None: + entry = [0, None] + entry[0] += 1 + if entry[0] >= _MAX_FAILS: + entry[1] = now + _LOCK_SECONDS + logger.warning(f"🔒 IP {ip} 登录锁定 {_LOCK_SECONDS}s ({_MAX_FAILS} 次失败)") + else: + logger.debug(f"IP {ip} 登录失败计数: {entry[0]}/{_MAX_FAILS}") + _LOGIN_FAILS[ip] = entry + + +def _clear_fails(ip: str): + _LOGIN_FAILS.pop(ip, None) + + +def _load_sessions_from_db(app): + """从数据库恢复持久化的 session""" + try: + db = app.get("sensu_db") + if not db: + sm = app.get("service_manager") + if sm: + try: + db = sm.get_service("sensu_db") + except Exception: + pass + if db: + raw = db.get_config("panel_sessions", "{}") + stored = json.loads(raw) if raw else {} + for token, info in stored.items(): + PANEL_SESSION_STORE[token] = info + if stored: + logger.info(f"📦 从数据库恢复了 {len(stored)} 个会话") + except Exception as e: + logger.warning(f"会话恢复失败: {e}") + + +def _save_sessions_to_db(app): + """持久化当前 session 到数据库""" + try: + db = app.get("sensu_db") + if not db: + sm = app.get("service_manager") + if sm: + try: + db = sm.get_service("sensu_db") + except Exception: + pass + if db: + db.set_config("panel_sessions", json.dumps(PANEL_SESSION_STORE)) + except Exception as e: + logger.debug(f"会话持久化失败: {e}") + + +def setup_routes(app, prefix=''): + """注册面板认证路由""" + import json as _json + + # 🟢 关键:将 Session Store 挂载到 app,供拦截器读取 + app['panel_session_store'] = PANEL_SESSION_STORE + + # 从数据库恢复持久化会话 + _load_sessions_from_db(app) + + # 路由注册 + app.router.add_post(f'{prefix}/api/login', handle_login) + app.router.add_post(f'{prefix}/api/logout', panel_auth(handle_logout)) + app.router.add_get(f'{prefix}/api/auth/status', panel_auth(handle_auth_status)) + +async def handle_login(req): + """处理面板登录""" + try: + # 频率限制 + ip = req.remote + if not _check_rate_limit(ip): + return web.json_response( + {"success": False, "msg": "尝试次数过多,请 60 秒后重试"}, + status=429, + ) + + data = await req.json() + username = data.get('username') + password = data.get('password') + + cfg = req.app.get('panel_config', {}) + cfg_user = cfg.get('username', 'admin') + + # 校验 — 优先哈希比较,回退明文 (向后兼容) + pw_hash = cfg.get('password_hash', '') + pw_salt = cfg.get('password_salt', '') + if pw_hash and pw_salt: + import hashlib + ok = secrets.compare_digest( + pw_hash, + hashlib.sha256((password + pw_salt).encode()).hexdigest() + ) + else: + cfg_pass = cfg.get('password', 'admin') + ok = secrets.compare_digest(username, cfg_user) and secrets.compare_digest(password, cfg_pass) + + if username == cfg_user and ok: + _clear_fails(ip) + # 登录成功:生成 Token + token = secrets.token_hex(16) + + # 写入 Session Store + user_info = { + "username": username, + "perms": ["admin"], + "login_time": __import__('time').time() + } + PANEL_SESSION_STORE[token] = user_info + _save_sessions_to_db(req.app) # 持久化到数据库 + + logger.info(f"✅ 面板登录成功: {username} (Session: {token[:4]}...)") + + resp = web.json_response({"success": True, "username": username}) + # 设置 Cookie + resp.set_cookie("panel_token", token, max_age=259200, httponly=True, samesite="Lax") + return resp + else: + _record_fail(ip) + logger.warning(f"❌ 面板登录失败: 用户 {username} 密码错误 (IP: {ip})") + return web.json_response({"success": False, "msg": "用户名或密码错误"}, status=401) + + except Exception as e: + logger.error(f"登录异常: {e}") + return web.json_response({"error": str(e)}, status=500) + +async def handle_logout(req): + """处理退出登录""" + token = req.cookies.get("panel_token") + if token and token in PANEL_SESSION_STORE: + del PANEL_SESSION_STORE[token] + _save_sessions_to_db(req.app) # 持久化删除 + logger.info(f"👋 用户退出登录") + + resp = web.json_response({"success": True}) + resp.del_cookie("panel_token") + return resp + +async def handle_auth_status(req): + """获取当前认证状态 (被 panel_auth 拦截,能进来说明已认证)""" + user = req.get('user', {}) + return web.json_response({ + "authenticated": True, + "username": user.get("username", "Unknown"), + "perms": user.get("perms", []) + }) diff --git a/services/web_panel/routes/commands.py b/services/web_panel/routes/commands.py new file mode 100644 index 0000000..7f5a448 --- /dev/null +++ b/services/web_panel/routes/commands.py @@ -0,0 +1,28 @@ +from aiohttp import web +from ..utils.auth import panel_auth +import re + +# Shell 元字符黑名单 — 防止命令注入 +_SHELL_DANGER = re.compile(r'[;&|`$(){}!#~<>]') + + +def setup_routes(app, prefix=''): + app.router.add_post(f'{prefix}/api/command', panel_auth(exec_cmd)) + + +async def exec_cmd(req): + d = await req.json() + raw = d.get('command', '') + # 安全检查: 拒绝含 shell 元字符的命令 + if _SHELL_DANGER.search(raw): + return web.json_response( + {"success": False, "error": "命令包含不允许的字符"}, status=400 + ) + cs = req.app.get('service_manager').get_service("command") + if not cs: + return web.json_response({"error": "Missing"}, status=503) + try: + res = await cs.process_command(raw, source="web") + return web.json_response({"success": True, "output": str(res)}) + except Exception: + return web.json_response({"success": False, "error": "命令执行失败"}) diff --git a/services/web_panel/routes/files.py b/services/web_panel/routes/files.py new file mode 100644 index 0000000..fb4bf0c --- /dev/null +++ b/services/web_panel/routes/files.py @@ -0,0 +1,452 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +File Manager Backend API + +Endpoints: + GET {prefix}/api/files/list?path=&show_hidden=0 — list directory + POST {prefix}/api/files/mkdir {path, name} — create directory + POST {prefix}/api/files/touch {path, name} — create empty file + POST {prefix}/api/files/delete {path} — delete file/dir + POST {prefix}/api/files/rename {path, new_name} — rename + POST {prefix}/api/files/upload (multipart) — upload file(s) + GET {prefix}/api/files/download?path= — download file + GET {prefix}/api/files/read?path= — read text file + POST {prefix}/api/files/write {path, content} — write text file + GET {prefix}/api/files/info?path= — file/dir stat + +Root directory is restricted to configurable ROOTS (default: [project_root, data/]). +Path-traversal is blocked. +""" + +import os +import shutil +import stat +import time +import json +import logging +import mimetypes +from pathlib import Path +from aiohttp import web +from ..utils.auth import panel_auth + +logger = logging.getLogger(__name__) + +# ── Security: allowed root directories ── +_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent.parent +_ALLOWED_ROOTS = [] + +if os.name == 'nt': + # Windows: enumerate all available drive letters + import string + for letter in string.ascii_uppercase: + drive = Path(letter + ":\\") + if drive.exists(): + _ALLOWED_ROOTS.append(drive) +else: + # Linux / macOS / Android — 默认仅限项目目录 + data/,生产安全 + _ALLOWED_ROOTS = [] +_ALLOWED_ROOTS.append(_PROJECT_ROOT) +# 从环境变量读取额外允许路径 (逗号分隔) +_extra_roots = os.environ.get("SENSU_FILE_ROOTS", "") +for r in _extra_roots.split(","): + r = r.strip() + if r: + _ALLOWED_ROOTS.append(Path(r)) +# Deduplicate and keep only existing +_seen = set() +_filtered = [] +for p in _ALLOWED_ROOTS: + rp = p.resolve() if p.exists() else None + if rp and str(rp) not in _seen: + _seen.add(str(rp)) + _filtered.append(rp) +_ALLOWED_ROOTS = _filtered or [Path("/") if os.name != 'nt' else Path("C:\\")] + +MAX_READ_SIZE = 1 * 1024 * 1024 # 1 MB for text read +MAX_UPLOAD_SIZE = 50 * 1024 * 1024 # 50 MB per upload + +# 危险文件扩展名 — 禁止上传 +_DENY_EXTENSIONS = { + '.exe', '.dll', '.so', '.sh', '.bash', '.zsh', '.fish', + '.bat', '.cmd', '.ps1', '.vbs', '.vba', '.wsf', '.msi', + '.pyc', '.pyo', '.class', '.jar', '.war', + '.php', '.jsp', '.asp', '.aspx', '.cgi', '.pl', + '.deb', '.rpm', '.apk', '.ipa', +} +_DENY_NAMES = {'.htaccess', 'Makefile', 'Dockerfile', '.bashrc', '.profile'} +TEXT_EXTENSIONS = { + '.txt','.py','.js','.ts','.html','.css','.json','.yaml','.yml', + '.md','.ini','.cfg','.conf','.log','.sh','.bat','.env','.xml', + '.toml','.csv','.sql','.Makefile','.gitignore','.dockerfile', + '.c','.h','.cpp','.hpp','.java','.kt','.rs','.go','.rb','.php', + '.swift','.r','.lua','.pl','.scala','.dart', +} + +# ── Helpers ── + +def _normalize(path_str: str) -> Path: + """Normalize path (resolve .. and .) WITHOUT following symlinks. + Returns a Path that may be a symlink itself.""" + if not path_str: + return _ALLOWED_ROOTS[0] + + # Use os.path.normpath which resolves .. and . without following symlinks + normalized = os.path.normpath(path_str) + return Path(normalized) + +def _resolve(path_str: str) -> Path: + """Resolve and validate path against allowed roots. + Returns the LOGICAL path (may still be a symlink, not resolved).""" + candidate = _normalize(path_str) + # Security: ensure it's within an allowed root + resolved = candidate.resolve() + for root in _ALLOWED_ROOTS: + try: + resolved.relative_to(root) + return candidate # Return the logical path, not resolved + except ValueError: + continue + raise ValueError(f"Path not within allowed roots: {path_str}") + +def _ensure_exists(p: Path, must_exist: bool = True): + if not p.exists(): + if must_exist: + raise FileNotFoundError(str(p)) + return p + +def _stat(p: Path) -> dict: + """Stat a path: follow symlinks normally; fall back to lstat for broken ones.""" + try: + s = p.stat() # Follow symlinks — resolves dir/file correctly + return { + "name": p.name, + "path": str(p), + "size": s.st_size, + "is_dir": p.is_dir(), + "is_file": p.is_file(), + "is_symlink": p.is_symlink(), + "mtime": int(s.st_mtime), + "mtime_str": time.strftime("%Y-%m-%d %H:%M", time.localtime(s.st_mtime)), + "mode": stat.filemode(s.st_mode), + "ext": p.suffix.lower() if p.is_file() else "", + "readable": os.access(p, os.R_OK), + "writable": os.access(p, os.W_OK), + } + except FileNotFoundError: + # Broken symlink or missing target + try: + s = p.lstat() + return { + "name": p.name, + "path": str(p), + "size": s.st_size, + "is_dir": False, + "is_file": False, + "is_symlink": True, + "mtime": int(s.st_mtime), + "mtime_str": time.strftime("%Y-%m-%d %H:%M", time.localtime(s.st_mtime)), + "mode": stat.filemode(s.st_mode), + "ext": "", + "readable": False, + "writable": False, + "broken": True, + } + except Exception: + pass # Give up entirely + except (OSError, PermissionError): + pass + # Ultimate fallback + return { + "name": p.name, "path": str(p), "size": 0, + "is_dir": False, "is_file": False, "is_symlink": p.is_symlink(), + "mtime": 0, "mtime_str": "—", "mode": "?---------", "ext": "", + "readable": False, "writable": False, "broken": True, + } + +# ── Route setup ── + +def setup_file_routes(app, service_manager, prefix=''): + """Register all file-manager routes on the aiohttp app.""" + logger.info(f"📁 文件管理路由已注册 ({prefix}/api/files)") + + # List directory + async def list_dir(req): + try: + path = req.query.get("path", "") + show_hidden = req.query.get("show_hidden", "0") == "1" + p = _resolve(path) + _ensure_exists(p) + if not p.is_dir(): + return web.json_response({"error": "Not a directory"}, status=400) + + items = [] + try: + for entry in sorted(p.iterdir(), key=lambda x: (not x.is_dir(), x.name.lower())): + if not show_hidden and entry.name.startswith('.'): + continue + try: + items.append(_stat(entry)) + except Exception: + pass # Skip entries that can't be stat'd + except PermissionError: + return web.json_response({"error": "Permission denied"}, status=403) + + # Breadcrumbs: split path by OS separator, every segment clickable + def make_breadcrumbs(path_obj): + raw = str(path_obj) + if os.name == 'nt' and len(raw) >= 2 and raw[1] == ':': + # Windows: C:\Users\... → crumbs: [C:\, Users, ...] + crumbs = [{"label": raw[:2] + "\\", "path": raw[:2] + "\\"}] + tail = raw[3:] + else: + crumbs = [{"label": "/", "path": "/"}] + tail = raw.lstrip("/") + parts = [x for x in tail.replace("\\", "/").split("/") if x] + acc = crumbs[0]["path"].rstrip("\\/") + for part in parts: + acc += ("\\" if os.name == 'nt' and acc.endswith(":") else "") + "/" + part + crumbs.append({"label": part, "path": acc.replace("\\", "/")}) + return crumbs + + crumbs = make_breadcrumbs(p) + # Logical parent: None at filesystem root (Unix: /, Windows: C:\) + p_str = str(p) + is_root = p_str == "/" or (os.name == 'nt' and len(p_str) == 3 and p_str[1] == ':') + logical_parent = str(Path(p_str).parent) if not is_root else None + resolved = p.resolve() + resolved_crumbs = None + if str(resolved) != str(p): + resolved_crumbs = make_breadcrumbs(resolved) + + return web.json_response({ + "current": str(p), + "resolved": str(resolved) if str(resolved) != str(p) else None, + "breadcrumbs": crumbs, + "resolved_breadcrumbs": resolved_crumbs, + "items": items, + "parent": logical_parent, + "allowed_roots": [str(r) for r in _ALLOWED_ROOTS], + }) + except (FileNotFoundError, ValueError) as e: + return web.json_response({"error": str(e)}, status=404) + except Exception as e: + logger.error(f"list_dir: {e}") + return web.json_response({"error": str(e)}, status=500) + + # Create directory + async def mkdir(req): + try: + data = await req.json() + p = _resolve(data.get("path", "")) + name = data.get("name", "").strip() + if not name or "/" in name or "\\" in name: + return web.json_response({"error": "Invalid name"}, status=400) + target = (p / name) + target.mkdir(parents=False, exist_ok=False) + logger.info(f"📁 创建目录: {target}") + return web.json_response({"ok": True, "path": str(target)}) + except FileExistsError: + return web.json_response({"error": "Already exists"}, status=409) + except Exception as e: + return web.json_response({"error": str(e)}, status=500) + + # Create empty file + async def touch(req): + try: + data = await req.json() + p = _resolve(data.get("path", "")) + name = data.get("name", "").strip() + if not name or "/" in name or "\\" in name: + return web.json_response({"error": "Invalid name"}, status=400) + target = (p / name) + target.touch(exist_ok=False) + logger.info(f"📄 创建文件: {target}") + return web.json_response({"ok": True, "path": str(target)}) + except FileExistsError: + return web.json_response({"error": "Already exists"}, status=409) + except Exception as e: + return web.json_response({"error": str(e)}, status=500) + + # Delete file or empty directory (recursive for non-empty dirs) + async def delete(req): + try: + data = await req.json() + p = _resolve(data.get("path", "")) + _ensure_exists(p) + # Safety: refuse to delete project root + if p == _PROJECT_ROOT: + return web.json_response({"error": "Cannot delete project root"}, status=403) + if p.is_dir(): + shutil.rmtree(p) + else: + p.unlink() + logger.info(f"🗑 删除: {p}") + # 审计日志 + db = request.app.get("sensu_db") + if db: + db.log_audit(request.get("user", {}).get("username", "?"), "file_delete", str(p)) + return web.json_response({"ok": True}) + except Exception as e: + return web.json_response({"error": str(e)}, status=500) + + # Rename + async def rename(req): + try: + data = await req.json() + p = _resolve(data.get("path", "")) + new_name = data.get("new_name", "").strip() + if not new_name or "/" in new_name or "\\" in new_name: + return web.json_response({"error": "Invalid name"}, status=400) + target = p.parent / new_name + p.rename(target) + logger.info(f"✏ 重命名: {p} → {target}") + return web.json_response({"ok": True, "path": str(target)}) + except Exception as e: + return web.json_response({"error": str(e)}, status=500) + + # Upload + async def upload(req): + try: + reader = await req.multipart() + target_dir_str = req.query.get("path", "") + target_dir = _resolve(target_dir_str) + _ensure_exists(target_dir) + if not target_dir.is_dir(): + return web.json_response({"error": "Target not a directory"}, status=400) + + uploaded = [] + while True: + part = await reader.next() + if part is None: + break + if part.name == "file": + fname = part.filename + if not fname: + continue + # Sanitize filename + fname = Path(fname).name + # 安全检查: 拒绝危险文件类型 + ext = Path(fname).suffix.lower() + if ext in _DENY_EXTENSIONS or fname in _DENY_NAMES: + return web.json_response( + {"error": f"禁止上传的文件类型: {ext or fname}"}, + status=403, + ) + dest = target_dir / fname + size = 0 + with open(dest, 'wb') as f: + while True: + chunk = await part.read_chunk(65536) + if not chunk: + break + size += len(chunk) + if size > MAX_UPLOAD_SIZE: + f.close() + dest.unlink() + return web.json_response({"error": f"File too large: {fname}"}, status=413) + f.write(chunk) + uploaded.append(_stat(dest)) + logger.info(f"📤 上传 {len(uploaded)} 文件到 {target_dir}") + return web.json_response({"ok": True, "files": uploaded}) + except Exception as e: + return web.json_response({"error": str(e)}, status=500) + + # Download + async def download(req): + try: + p = _resolve(req.query.get("path", "")) + _ensure_exists(p) + if not p.is_file(): + return web.json_response({"error": "Not a file"}, status=400) + ct, _ = mimetypes.guess_type(str(p)) + return web.FileResponse(p, headers={ + "Content-Type": ct or "application/octet-stream", + "Content-Disposition": f'attachment; filename="{p.name}"', + }) + except Exception as e: + return web.json_response({"error": str(e)}, status=500) + + # Read text file + async def read_file(req): + try: + p = _resolve(req.query.get("path", "")) + _ensure_exists(p) + if not p.is_file(): + return web.json_response({"error": "Not a file"}, status=400) + if p.suffix.lower() not in TEXT_EXTENSIONS: + return web.json_response({"error": f"Not a text file: {p.suffix}"}, status=415) + if p.stat().st_size > MAX_READ_SIZE: + return web.json_response({"error": "File too large to read"}, status=413) + content = p.read_text(encoding="utf-8", errors="replace") + return web.json_response({ + "path": str(p), + "name": p.name, + "size": len(content), + "content": content, + }) + except Exception as e: + return web.json_response({"error": str(e)}, status=500) + + # Write text file + async def write_file(req): + try: + data = await req.json() + p = _resolve(data.get("path", "")) + content = data.get("content", "") + p.write_text(content, encoding="utf-8") + logger.info(f"💾 写入文件: {p} ({len(content)} bytes)") + db = request.app.get("sensu_db") + if db: + db.log_audit(request.get("user", {}).get("username", "?"), "file_write", str(p)) + return web.json_response({"ok": True, "size": len(content)}) + except Exception as e: + return web.json_response({"error": str(e)}, status=500) + + # File/dir info + async def file_info(req): + try: + p = _resolve(req.query.get("path", "")) + _ensure_exists(p) + return web.json_response(_stat(p)) + except Exception as e: + return web.json_response({"error": str(e)}, status=500) + + # Picker API (for plugins) — returns selected path as JSON + # GET {prefix}/api/files/picker?mode=file|dir&path= + async def picker_api(req): + try: + mode = req.query.get("mode", "dir") # file | dir + path = req.query.get("path", "") + p = _resolve(path) + _ensure_exists(p) + items = [] + if p.is_dir(): + for entry in sorted(p.iterdir(), key=lambda x: (not x.is_dir(), x.name.lower())): + if entry.name.startswith('.'): + continue + if mode == "file" and not entry.is_file(): + continue + if mode == "dir" and not entry.is_dir(): + continue + items.append(_stat(entry)) + return web.json_response({ + "current": str(p), + "mode": mode, + "items": items, + }) + except Exception as e: + return web.json_response({"error": str(e)}, status=500) + + # ── Register routes ── + app.router.add_get(f'{prefix}/api/files/list', panel_auth(list_dir)) + app.router.add_post(f'{prefix}/api/files/mkdir', panel_auth(mkdir, csrf_protect=True)) + app.router.add_post(f'{prefix}/api/files/touch', panel_auth(touch, csrf_protect=True)) + app.router.add_post(f'{prefix}/api/files/delete', panel_auth(delete, csrf_protect=True)) + app.router.add_post(f'{prefix}/api/files/rename', panel_auth(rename, csrf_protect=True)) + app.router.add_post(f'{prefix}/api/files/upload', panel_auth(upload, csrf_protect=True)) + app.router.add_get(f'{prefix}/api/files/download', panel_auth(download)) + app.router.add_get(f'{prefix}/api/files/read', panel_auth(read_file)) + app.router.add_post(f'{prefix}/api/files/write', panel_auth(write_file, csrf_protect=True)) + app.router.add_get(f'{prefix}/api/files/info', panel_auth(file_info)) + app.router.add_get(f'{prefix}/api/files/picker', panel_auth(picker_api)) diff --git a/services/web_panel/routes/logs.py b/services/web_panel/routes/logs.py new file mode 100644 index 0000000..b627630 --- /dev/null +++ b/services/web_panel/routes/logs.py @@ -0,0 +1,30 @@ +import json, asyncio +from aiohttp import web +from ..utils.auth import panel_auth + +active_ws = set() + +def setup_routes(app, prefix=''): + app.router.add_get(f'{prefix}/api/logs/ws', panel_auth(ws_handler)) + +async def ws_handler(req): + ws = web.WebSocketResponse(heartbeat=30.0) + await ws.prepare(req) + active_ws.add(ws) + try: + async for msg in ws: + if msg.type == web.WSMsgType.TEXT: + d = json.loads(msg.data) + if d.get('action') == 'set_level': + ls = req.app.get('log_service') + if ls: ls.set_level(d.get('level','INFO')) + finally: active_ws.discard(ws) + return ws + +def broadcast_log(log_record): + if not active_ws: return + payload = json.dumps({"type":"log", "level":log_record.get('level','INFO'), + "message":log_record.get('simple_message',''), "timestamp":log_record.get('timestamp',0)}) + for ws in list(active_ws): + if not ws.closed: asyncio.ensure_future(ws.send_str(payload)) + else: active_ws.discard(ws) diff --git a/services/web_panel/routes/plugin_web.py b/services/web_panel/routes/plugin_web.py new file mode 100644 index 0000000..119f405 --- /dev/null +++ b/services/web_panel/routes/plugin_web.py @@ -0,0 +1,42 @@ +from aiohttp import web +import json, logging +from ..utils.auth import panel_auth + +logger = logging.getLogger(__name__) + +def setup_plugin_web_routes(app, service_manager): + async def plugin_page(request): + name = request.match_info.get("name","") + ps = service_manager.get_service("plugin") + plugin = ps.plugins.get(name) + if not plugin or not hasattr(plugin, "get_web_pages"): + return web.Response(text=f"Plugin {name} not found", status=404) + pages = plugin.get_web_pages() + if name in pages: + resp = web.Response(text=pages[name]["html"], content_type="text/html") + resp.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" + return resp + return web.json_response({"error": "no web page"}) + + async def plugin_sse(request): + name = request.match_info.get("name","") + ps = service_manager.get_service("plugin") + plugin = ps.plugins.get(name) + if plugin and hasattr(plugin, "handle_sse"): + return await plugin.handle_sse(request) + return web.json_response({"error": "SSE not supported"}, status=404) + + async def plugin_event(request): + name = request.match_info.get("name","") + data = await request.json() + ps = service_manager.get_service("plugin") + plugin = ps.plugins.get(name) + if plugin and hasattr(plugin, "push_sse_event"): + await plugin.push_sse_event(data.get("type","event"), data) + return web.json_response({"ok": True}) + return web.json_response({"ok": False}, status=404) + + app.router.add_get("/plugin/{name}", panel_auth(plugin_page)) + app.router.add_get("/plugin/{name}/sse", panel_auth(plugin_sse)) + app.router.add_post("/plugin/{name}/event", panel_auth(plugin_event)) + logger.info("Plugin web routes registered (已加认证)") diff --git a/services/web_panel/routes/plugins.py b/services/web_panel/routes/plugins.py new file mode 100644 index 0000000..efdf5ba --- /dev/null +++ b/services/web_panel/routes/plugins.py @@ -0,0 +1,287 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +import logging, os, shutil, subprocess, tempfile, zipfile, asyncio, json +from pathlib import Path +from aiohttp import web +from ..utils.auth import panel_auth + +logger = logging.getLogger(__name__) + +PLUGINS_DIR = Path("plugins") +_IMPORT_FAILS: dict[str, str] = {} # plugin_name → error message + + +def _run_in_subprocess(cmd: list, cwd: str, timeout: int = 120) -> tuple[bool, str]: + """在子进程中执行命令,不阻塞主事件循环""" + try: + r = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=timeout) + return r.returncode == 0, r.stdout[-500:] + "\n" + r.stderr[-500:] + except subprocess.TimeoutExpired: + return False, "安装超时 (>120s)" + except Exception as e: + return False, str(e) + + +def _install_plugin_deps(plugin_dir: str) -> tuple[bool, str]: + """安装插件依赖 (requirements.txt)""" + req_file = os.path.join(plugin_dir, "requirements.txt") + if not os.path.exists(req_file): + return True, "" + return _run_in_subprocess( + ["pip", "install", "-r", "requirements.txt", "--break-system-packages"], + plugin_dir, + ) + + +async def _async_install_deps(plugin_dir: str) -> tuple[bool, str]: + """异步包装器 — 在线程池中运行 pip install""" + loop = asyncio.get_event_loop() + return await loop.run_in_executor(None, _install_plugin_deps, plugin_dir) + + +def setup_routes(app, prefix=""): + app.router.add_get(f"{prefix}/api/plugins", panel_auth(list_plugins)) + app.router.add_get(f"{prefix}/api/plugin-pages", panel_auth(list_plugin_web_pages)) + app.router.add_get(f"{prefix}/api/plugins/stats", panel_auth(plugin_stats)) + app.router.add_post(f"{prefix}/api/plugins/{{name}}/{{action}}", panel_auth(manage_plugin)) + app.router.add_post(f"{prefix}/api/plugins/import", panel_auth(import_plugin)) + app.router.add_get(f"{prefix}/api/plugins/{{name}}/perms", panel_auth(get_perms)) + app.router.add_post(f"{prefix}/api/plugins/{{name}}/perms", panel_auth(set_perms)) + + +# ── 统计 ── + +async def plugin_stats(req): + sm = req.app.get("service_manager") + ps = sm.get_service("plugin") if sm else None + total = len(ps.plugin_info) if ps else 0 + loaded = sum(1 for n, info in ps.plugin_info.items() if n in ps.plugins) if ps else 0 + disabled = total - loaded + # 合并: 导入失败 + 加载失败 + from services.plugin_service import LOAD_FAILURES + failed = len(_IMPORT_FAILS) + fail_details = dict(_IMPORT_FAILS) + for name, reason in LOAD_FAILURES.items(): + if name not in fail_details: + fail_details[name] = reason + failed += 1 + if ps: + for name, info in ps.plugin_info.items(): + if getattr(info, "error_count", 0) > 0 and name not in fail_details: + fail_details[name] = f"启动失败 (错误数: {info.error_count})" + failed += 1 + return web.json_response({ + "total": total, + "loaded": loaded, + "disabled": max(0, disabled), + "failed": failed, + "fail_details": fail_details, + }) + + +# ── 导入 ── + +async def import_plugin(req): + """导入插件: multipart zip 或 JSON {url: git地址}""" + sm = req.app.get("service_manager") + ps = sm.get_service("plugin") if sm else None + if not ps: + return web.json_response({"ok": False, "error": "Plugin Service 未就绪"}, status=503) + + content_type = req.content_type or "" + + # ── Git clone 方式 ── + if "application/json" in content_type: + try: + data = await req.json() + git_url = data.get("url", "").strip() + if not git_url: + return web.json_response({"ok": False, "error": "Git URL 不能为空"}, status=400) + + # 提取 repo 名作为插件名 + name = git_url.rstrip("/").split("/")[-1] + if name.endswith(".git"): + name = name[:-4] + target = PLUGINS_DIR / name + if target.exists(): + return web.json_response({"ok": False, "error": f"插件目录已存在: {name}"}, status=409) + + ok, out = _run_in_subprocess( + ["git", "clone", git_url, str(target)], str(PLUGINS_DIR) + ) + if not ok: + _IMPORT_FAILS[name] = f"git clone 失败: {out[-200:]}" + return web.json_response({"ok": False, "error": "Git clone 失败", "detail": out[-300:]}, status=500) + + # 安装依赖 + dep_ok, dep_out = await _async_install_deps(str(target)) + if not dep_ok: + logger.warning(f"依赖安装部分失败 {name}: {dep_out[-200:]}") + + # 加载插件 + loaded = await _safe_load_plugin(ps, name) + _IMPORT_FAILS.pop(name, None) + return web.json_response({ + "ok": True, "name": name, "loaded": loaded, + "dep_ok": dep_ok, "detail": dep_out[-200:] if not dep_ok else "", + }) + except Exception as e: + return web.json_response({"ok": False, "error": str(e)}, status=500) + + # ── ZIP 上传方式 ── + try: + reader = await req.multipart() + part = await reader.next() + while part: + if part.name == "file" and part.filename: + fname = part.filename + if not fname.endswith(".zip"): + return web.json_response({"ok": False, "error": "仅支持 .zip 文件"}, status=400) + + # 保存到临时文件 + tmp = tempfile.NamedTemporaryFile(suffix=".zip", delete=False) + while True: + chunk = await part.read_chunk(65536) + if not chunk: + break + tmp.write(chunk) + tmp.close() + + # 提取 → plugins/ + plugin_name = fname[:-4] # 去掉 .zip + target = PLUGINS_DIR / plugin_name + if target.exists(): + os.unlink(tmp.name) + return web.json_response({"ok": False, "error": f"插件目录已存在: {plugin_name}"}, status=409) + + try: + with zipfile.ZipFile(tmp.name, "r") as zf: + # 检查 zip 结构 — 如果顶层只有一个目录,用它 + members = zf.namelist() + top_dirs = set() + for m in members: + if "/" in m: + top_dirs.add(m.split("/")[0]) + if len(top_dirs) == 1 and all(m.startswith(list(top_dirs)[0]) for m in members if m): + # 有统一顶层目录 + zf.extractall(PLUGINS_DIR) + if list(top_dirs)[0] != plugin_name: + src = PLUGINS_DIR / list(top_dirs)[0] + src.rename(target) + else: + target.mkdir(parents=True) + zf.extractall(target) + except zipfile.BadZipFile: + os.unlink(tmp.name) + return web.json_response({"ok": False, "error": "无效的 ZIP 文件"}, status=400) + finally: + os.unlink(tmp.name) + + # 安装依赖 + dep_ok, dep_out = await _async_install_deps(str(target)) + + # 加载插件 + loaded = await _safe_load_plugin(ps, plugin_name) + if loaded: + _IMPORT_FAILS.pop(plugin_name, None) + return web.json_response({ + "ok": True, "name": plugin_name, "loaded": loaded, + "dep_ok": dep_ok, "detail": dep_out[-200:] if not dep_ok else "", + }) + part = await reader.next() + return web.json_response({"ok": False, "error": "未找到上传文件 (字段名: file)"}, status=400) + except Exception as e: + logger.error(f"导入插件失败: {e}") + return web.json_response({"ok": False, "error": str(e)}, status=500) + + +async def _safe_load_plugin(ps, name: str) -> bool: + """安全加载插件 — 先隔离模式尝试,成功后再切换到正常模式""" + try: + # 先尝试隔离加载 (子进程,崩溃不影响框架) + from services.process_isolated import IsolatedPlugin + plugin_path = PLUGINS_DIR / name / "__init__.py" + if not plugin_path.exists(): + _IMPORT_FAILS[name] = "缺少 __init__.py" + return False + + iso = IsolatedPlugin(name, str(plugin_path), {"settings": {"isolation": True}}) + ps.plugins[name] = iso + logger.info(f"🔌 插件已导入(隔离模式): {name}") + return True + except Exception as e: + _IMPORT_FAILS[name] = str(e)[:200] + logger.error(f"插件加载失败 {name}: {e}") + return False + + +# ── 列表 / 管理 / 权限 ── + +async def list_plugins(req): + sm = req.app.get("service_manager") + if not sm: + return web.json_response({"error": "Service Manager 未初始化"}, status=503) + ps = sm.get_service("plugin") + if not ps: + return web.json_response({"plugins": []}) + data = [] + for name, info in ps.plugin_info.items(): + data.append({ + "name": name, + "version": getattr(info, "version", "?"), + "running": name in ps.plugins, + "enabled": getattr(info, "enabled", True), + "error": _IMPORT_FAILS.get(name, ""), + }) + return web.json_response({"plugins": data}) + + +async def manage_plugin(req): + sm = req.app.get("service_manager") + if not sm: + return web.json_response({"error": "SM Missing"}, status=503) + name = req.match_info["name"] + action = req.match_info["action"] + ps = sm.get_service("plugin") + if not ps: + return web.json_response({"error": "Plugin Service Missing"}, status=503) + try: + if action in ("disable", "unload"): + await ps.unload_plugin(name) + elif action == "enable": + await ps.load_plugin(name) + elif action == "reload": + await ps.unload_plugin(name) + await ps.load_plugin(name) + return web.json_response({"success": True, "msg": "操作成功"}) + except Exception as e: + logger.error(f"插件操作失败: {e}") + return web.json_response({"success": False, "error": str(e)}) + + +async def list_plugin_web_pages(req): + sm = req.app.get("service_manager") + if not sm: + return web.json_response({"pages": []}) + ps = sm.get_service("plugin") + if not ps: + return web.json_response({"pages": []}) + pages = [] + for name, plugin in ps.plugins.items(): + if hasattr(plugin, "get_web_pages"): + for path, info in plugin.get_web_pages().items(): + pages.append({ + "plugin": name, + "path": "/plugin/" + name, + "title": info.get("title", name), + "icon": info.get("icon", "P"), + }) + return web.json_response({"pages": pages}) + + +async def get_perms(req): + return web.json_response({"plugin": req.match_info["name"], "permissions": ["read", "write"]}) + + +async def set_perms(req): + return web.json_response({"success": True}) diff --git a/services/web_panel/routes/projects.py b/services/web_panel/routes/projects.py new file mode 100644 index 0000000..47c3c30 --- /dev/null +++ b/services/web_panel/routes/projects.py @@ -0,0 +1,62 @@ +from aiohttp import web +import json, logging +from ..utils.auth import panel_auth + +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, prefix=''): + 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(f'{prefix}/api/projects', panel_auth(list_projects)) + app.router.add_post(f'{prefix}/api/projects/run', panel_auth(run_project, csrf_protect=True)) + app.router.add_get(f'{prefix}/api/projects/{{name}}/logs', panel_auth(get_logs)) + app.router.add_post(f'{prefix}/api/projects/{{name}}/stop', panel_auth(stop_project, csrf_protect=True)) + app.router.add_post(f'{prefix}/api/projects/{{name}}/stdin', panel_auth(send_stdin, csrf_protect=True)) + app.router.add_get(f'{prefix}/pages/projects', panel_auth(project_page)) + logger.info("📦 项目管理路由已注册 (已加认证)") diff --git a/services/web_panel/routes/proxy.py b/services/web_panel/routes/proxy.py new file mode 100644 index 0000000..a154c4a --- /dev/null +++ b/services/web_panel/routes/proxy.py @@ -0,0 +1,32 @@ +from aiohttp import web, ClientSession +import json, logging, asyncio +from ..utils.auth import panel_auth + +logger = logging.getLogger(__name__) + +def setup_proxy_routes(app, service_manager, prefix=''): + async def list_proxies(request): + ps = service_manager.get_service("proxy") + return web.json_response({"proxies": ps.list_proxies() if ps else []}) + + async def add_proxy(request): + try: + data = await request.json() + ps = service_manager.get_service("proxy") + ok = await ps.register_proxy( + path=data.get("path",""), target_url=data.get("target",""), + description=data.get("description",""), is_external=data.get("external",False)) + return web.json_response({"ok": ok}) + except Exception as e: + return web.json_response({"ok": False, "error": str(e)}, status=400) + + async def remove_proxy(request): + path = request.match_info.get("path","") + ps = service_manager.get_service("proxy") + ps.unregister_proxy(path) + return web.json_response({"ok": True}) + + app.router.add_get(f'{prefix}/api/proxy', panel_auth(list_proxies)) + app.router.add_post(f'{prefix}/api/proxy', panel_auth(add_proxy, csrf_protect=True)) + app.router.add_delete(f'{prefix}/api/proxy/{{path}}', panel_auth(remove_proxy, csrf_protect=True)) + logger.info(f'🔀 代理路由已注册 ({prefix}/api/proxy) (已加认证)') diff --git a/services/web_panel/routes/status.py b/services/web_panel/routes/status.py new file mode 100644 index 0000000..a374ea3 --- /dev/null +++ b/services/web_panel/routes/status.py @@ -0,0 +1,247 @@ +import time +import json +import asyncio +import logging +from aiohttp import web +from ..utils.system_info import SystemInfoCollector +from ..utils.auth import panel_auth + +collector = SystemInfoCollector() +logger = logging.getLogger(__name__) + +# Track active system-status WS clients +_sys_ws_clients: set = set() + +def _ws_auth_wrapper(handler): + """WebSocket 鉴权 — Cookie 优先(同源自动发送), query token 备用""" + async def wrapper(request): + # 1. Cookie (浏览器同源自动发送, 支持 HttpOnly) + token = request.cookies.get("panel_token", "") + # 2. Query string token 备用 (跨域/非浏览器客户端) + if not token: + token = request.query.get("token", "") + # 3. API Key 验证 + session_store = request.app.get("panel_session_store", {}) + is_valid = token and token in session_store + if not is_valid and token: + from services.web_panel.routes.apikeys import validate_api_key + is_valid = validate_api_key(token) is not None + + if not is_valid: + ws = web.WebSocketResponse() + await ws.prepare(request) + await ws.send_str(json.dumps({"error": "Unauthorized"})) + await ws.close(code=4001, message="Unauthorized") + return ws + return await handler(request) + return wrapper + +def setup_routes(app, prefix=''): + app.router.add_get(f'{prefix}/api/framework', panel_auth(get_framework)) + app.router.add_get(f'{prefix}/api/system', panel_auth(get_system)) + app.router.add_get(f'{prefix}/api/system/ws', _ws_auth_wrapper(sys_ws_handler)) + app.router.add_get(f'{prefix}/api/updates', panel_auth(get_updates)) + app.router.add_post(f'{prefix}/api/updates/check', panel_auth(check_now)) + app.router.add_post(f'{prefix}/api/updates/apply', panel_auth(apply_update)) + app.router.add_post(f'{prefix}/api/updates/backup', panel_auth(create_backup)) + app.router.add_get(f'{prefix}/api/updates/backups', panel_auth(list_backups)) + app.router.add_post(f'{prefix}/api/updates/rollback', panel_auth(do_rollback)) + logger.info(f"📡 系统状态WS端点已注册: {prefix}/api/system/ws (已加认证)") + +def _read_version(): + """Read version from config file (shared helper)""" + ver = "v0.6.0" + try: + import yaml, os + config_path = os.path.join(os.path.dirname(__file__), "..", "..", "..", "config", "framework", "base_config.yaml") + with open(config_path) as f: + cfg = yaml.safe_load(f) + ver = cfg.get("framework", {}).get("version", ver) + except: + pass + return ver + +def _get_framework_data(sm): + """Collect framework status (reused by HTTP and WS handlers)""" + ver = _read_version() + ps = sm.get_service("plugin") if sm else None + uptime = int(time.time() - getattr(sm, 'start_time', time.time())) + return { + "version": ver, + "uptime": uptime, + "plugins": len(ps.plugins) if ps else 0 + } + +async def get_framework(req): + sm = req.app.get('service_manager') + if not sm: + return web.json_response({"error": "Missing"}, status=500) + return web.json_response(_get_framework_data(sm)) + +def _get_collector(req): + """获取共享的 SystemInfoCollector(优先从 service_manager),fallback 到模块级实例""" + sm = req.app.get('service_manager') + if sm: + try: + shared = sm.get_service("sys_collector") + if shared: + return shared + except Exception: + pass + return collector + +async def get_system(req): + c = _get_collector(req) + return web.json_response(c.get_all()) + +async def sys_ws_handler(req): + """WebSocket push endpoint: system+framwork stats every 2s (no logging per message)""" + ws = web.WebSocketResponse(heartbeat=30.0) + await ws.prepare(req) + _sys_ws_clients.add(ws) + + async def push(): + """Send one snapshot to this client (silent on error)""" + try: + sm = req.app.get('service_manager') + c = _get_collector(req) + payload = json.dumps({ + "type": "sys", + "system": c.get_all(), + "framework": _get_framework_data(sm) + }, ensure_ascii=False) + if not ws.closed: + await ws.send_str(payload) + except Exception: + pass + + # Background push loop — runs until client disconnects + async def push_loop(): + while not ws.closed: + await push() + await asyncio.sleep(2) + + task = asyncio.ensure_future(push_loop()) + try: + async for msg in ws: + if msg.type == web.WSMsgType.ERROR: + break + finally: + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + _sys_ws_clients.discard(ws) + return ws + + +async def get_updates(req): + """获取更新状态""" + sm = req.app.get("service_manager") + us = sm.get_service("update") if sm else None + if not us: + return web.json_response({"error": "更新服务未就绪"}, status=503) + return web.json_response(us.status) + + +async def check_now(req): + """手动触发更新检查""" + sm = req.app.get("service_manager") + us = sm.get_service("update") if sm else None + if not us: + return web.json_response({"error": "更新服务未就绪"}, status=503) + await us.check_now() + return web.json_response(us.status) + + +async def apply_update(req): + """执行更新""" + sm = req.app.get("service_manager") + us = sm.get_service("update") if sm else None + if not us: + return web.json_response({"error": "更新服务未就绪"}, status=503) + result = await us.apply_update() + return web.json_response(result) + + +async def list_backups(req): + """列出可用备份""" + import glob, os, time as _time + backup_dir = os.path.join(os.path.dirname(__file__), "..", "..", "..", "data", "backups") + backups = [] + if os.path.isdir(backup_dir): + for f in sorted(glob.glob(os.path.join(backup_dir, "SenSu_backup_*.zip")), key=os.path.getmtime, reverse=True): + st = os.stat(f) + backups.append({ + "name": os.path.basename(f), + "size_mb": round(st.st_size / 1048576, 1), + "time": _time.strftime("%Y-%m-%d %H:%M", _time.localtime(st.st_mtime)), + }) + return web.json_response({"backups": backups}) + + +async def create_backup(req): + """手动创建备份""" + import time as _time, os as _os + from services.update_service import _backup_current, _PROJECT_ROOT + backup_dir = _os.path.join(_PROJECT_ROOT, "data", "backups") + _os.makedirs(backup_dir, exist_ok=True) + try: + sm = req.app.get("service_manager") + ver = "unknown" + if sm: + try: + init = sm.get_service("init") + ver = init.get_config("base").get("framework", {}).get("version", "0.0.0") + except: pass + name = f"SenSu_backup_{ver}_{_time.strftime('%Y%m%d_%H%M%S')}.zip" + dest = _os.path.join(backup_dir, name) + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, _backup_current, dest) + # 清理旧备份 + backups = sorted(glob.glob(_os.path.join(backup_dir, "SenSu_backup_*.zip")), key=_os.path.getmtime, reverse=True) + for old_b in backups[5:]: + _os.unlink(old_b) + return web.json_response({"ok": True, "name": name}) + except Exception as e: + return web.json_response({"ok": False, "error": str(e)}, status=500) + + +async def do_rollback(req): + """执行回滚""" + import glob, os, zipfile, shutil, tempfile + try: + data = await req.json() + backup_name = data.get("name", "") + except Exception: + backup_name = "" + backup_dir = os.path.join(os.path.dirname(__file__), "..", "..", "..", "data", "backups") + if backup_name: + backup_path = os.path.join(backup_dir, backup_name) + if not os.path.exists(backup_path): + return web.json_response({"ok": False, "error": "备份文件不存在"}, status=404) + else: + # 使用最新备份 + backups = sorted(glob.glob(os.path.join(backup_dir, "SenSu_backup_*.zip")), key=os.path.getmtime, reverse=True) + if not backups: + return web.json_response({"ok": False, "error": "没有可用的备份"}, status=404) + backup_path = backups[0] + try: + project_root = os.path.join(os.path.dirname(__file__), "..", "..", "..") + with tempfile.TemporaryDirectory() as tmp: + with zipfile.ZipFile(backup_path, "r") as zf: + zf.extractall(tmp) + for item in os.listdir(tmp): + src = os.path.join(tmp, item) + dst = os.path.join(project_root, item) + if os.path.isdir(src): + shutil.copytree(src, dst, dirs_exist_ok=True) + else: + shutil.copy2(src, dst) + # 写重启标记 + restart_flag = os.path.join(project_root, ".restart_flag") + Path(restart_flag).touch() + return web.json_response({"ok": True, "msg": "回滚完成, 框架将重启"}) + except Exception as e: + return web.json_response({"ok": False, "error": str(e)}, status=500) diff --git a/services/web_panel/utils/__init__.py b/services/web_panel/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/web_panel/utils/auth.py b/services/web_panel/utils/auth.py new file mode 100644 index 0000000..1361a36 --- /dev/null +++ b/services/web_panel/utils/auth.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import functools +from aiohttp import web + +def panel_auth(handler, csrf_protect: bool = False): + """面板专用鉴权装饰器 — Session Store + API Key 双重验证 + csrf_protect=True 时额外检查 X-CSRF-Token 头 (用于写操作) + """ + @functools.wraps(handler) + async def wrapper(request, *args, **kwargs): + # 1. 获取 Token (Cookie / Authorization header) + token = request.cookies.get("panel_token") + if not token and request.headers.get("Authorization", "").startswith("Bearer "): + token = request.headers["Authorization"].split(" ", 1)[1] + + is_valid = False + + # 2a. 从面板 Session Store 验证 (浏览器登录) + session_store = request.app.get('panel_session_store', {}) + if token and token in session_store: + is_valid = True + request['user'] = session_store[token] + + # 2b. 从 API Key Store 验证 (服务器间调用 — 使用 key 自身权限) + if not is_valid and token: + from services.web_panel.routes.apikeys import validate_api_key + key_info = validate_api_key(token) + if key_info: + is_valid = True + request['user'] = { + "username": f"apikey:{key_info['name']}", + "perms": key_info.get("permissions", ["framework.status.read"]), + "login_time": key_info.get("created_at", 0), + "is_api_key": True, + } + + # 3. 拦截逻辑 + if not is_valid: + return web.json_response({ + "error": "未认证或会话已过期", + "status": 401 + }, status=401) + + # 4. CSRF 检查 — 仅对 session token (API key 免 CSRF) + if csrf_protect and token in session_store: + csrf = request.headers.get("X-CSRF-Token", "") + if csrf != token: + return web.json_response({ + "error": "CSRF 验证失败", + "status": 403, + }, status=403) + + return await handler(request, *args, **kwargs) + return wrapper diff --git a/services/web_panel/utils/response.py b/services/web_panel/utils/response.py new file mode 100644 index 0000000..1ae6156 --- /dev/null +++ b/services/web_panel/utils/response.py @@ -0,0 +1,7 @@ +from aiohttp import web +def json_res(data, status=200, cookie=None): + resp = web.json_response(data, status=status) + if cookie: resp.set_cookie(cookie["n"], cookie["v"], max_age=cookie.get("m", 86400), httponly=True) + return resp +def get_user(req): + return req.get('user') diff --git a/services/web_panel/utils/system_info.py b/services/web_panel/utils/system_info.py new file mode 100644 index 0000000..f48799c --- /dev/null +++ b/services/web_panel/utils/system_info.py @@ -0,0 +1,276 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +import os +import time +import platform +import logging +from typing import Dict, Any, Tuple + +logger = logging.getLogger(__name__) + + +class SystemInfoCollector: + def __init__(self): + self.is_android = 'ANDROID_ROOT' in os.environ or os.path.exists('/system/bin/getprop') + self.psutil = None + if not self.is_android: + try: + import psutil + self.psutil = psutil + except ImportError as e: + logger.debug(f"psutil not available: {e}") + else: + logger.info("🤖 Android 平台识别,启用原生采集") + + # State for speed calculation (rx_bytes, tx_bytes, timestamp) + self._prev_net: Tuple[int, int, float] | None = None + + # ── Raw counters (used by both total and speed) ── + + def _read_net_bytes(self) -> Tuple[int, int]: + """Read raw (rx_bytes, tx_bytes) since boot from system""" + try: + if self.psutil: + io = self.psutil.net_io_counters() + return (io.bytes_recv, io.bytes_sent) + except Exception: + pass + + # Android / Linux: parse /proc/net/dev + try: + rx = tx = 0 + with open('/proc/net/dev', 'r') as f: + for line in f: + if ':' not in line or 'lo' in line: + continue + parts = line.split(':')[1].split() + rx += int(parts[0]) + if len(parts) > 8: + tx += int(parts[8]) + return (rx, tx) + except Exception as e: + logger.warning(f"Network read failed: {e}") + return (0, 0) + + # ── Public collectors ── + + def get_all(self) -> Dict[str, Any]: + return { + "platform": { + "system": platform.system(), + "machine": platform.machine(), + "release": platform.release(), + "python": platform.python_version(), + }, + "cpu": self._get_cpu(), + "memory": self._get_memory(), + "swap": self._get_swap(), + "disk": self._get_disk(), + "partitions": self._get_partitions(), + "process": self._get_process(), + "network": self._get_network(), + "net_speed": self._get_network_speed(), + "temperature": self._get_temperature(), + } + + def _get_cpu(self) -> Dict[str, Any]: + if self.psutil: + load = [0, 0, 0] + if hasattr(os, 'getloadavg'): + load = os.getloadavg() + elif hasattr(self.psutil, 'getloadavg'): + try: load = list(self.psutil.getloadavg()) + except: pass + return { + "percent": self.psutil.cpu_percent(interval=0.1), + "cores": self.psutil.cpu_count(), + "load_avg": load + } + try: + load = os.getloadavg() if hasattr(os, "getloadavg") else [0.0, 0.0, 0.0] + cores = os.cpu_count() or 1 + percent = min(100.0, (load[0] / cores) * 100) + return {"percent": round(percent, 1), "cores": cores, "load_avg": load} + except Exception as e: + logger.error(f"CPU info error: {e}", exc_info=True) + return {"percent": 0, "cores": 0, "load_avg": [0, 0, 0]} + + def _get_memory(self) -> Dict[str, Any]: + if self.psutil: + m = self.psutil.virtual_memory() + return {"total_gb": round(m.total / 1073741824, 1), + "used_gb": round(m.used / 1073741824, 1), + "percent": m.percent} + try: + mem = {} + with open('/proc/meminfo') as f: + for line in f: + parts = line.split() + if len(parts) >= 2: + mem[parts[0].rstrip(':')] = int(parts[1]) * 1024 + t = mem.get('MemTotal', 1) + a = mem.get('MemAvailable', mem.get('MemFree', 0)) + return {"total_gb": round(t / 1073741824, 1), + "used_gb": round((t - a) / 1073741824, 1), + "percent": round(((t - a) / t) * 100, 1)} + except Exception as e: + logger.warning(f"Memory info failed: {e}") + return {"total_gb": 0, "used_gb": 0, "percent": 0} + + def _get_swap(self) -> Dict[str, Any]: + if self.psutil: + try: + s = self.psutil.swap_memory() + return {"total_gb": round(s.total / 1073741824, 1), + "used_gb": round(s.used / 1073741824, 1), + "percent": s.percent} + except Exception: + pass + try: + with open('/proc/meminfo') as f: + mem = {} + for line in f: + parts = line.split() + if len(parts) >= 2: + mem[parts[0].rstrip(':')] = int(parts[1]) * 1024 + t = mem.get('SwapTotal', 0) + f_swap = mem.get('SwapFree', mem.get('SwapCached', 0)) + if t > 0: + used = t - f_swap + return {"total_gb": round(t / 1073741824, 1), + "used_gb": round(used / 1073741824, 1), + "percent": round((used / t) * 100, 1)} + except Exception: + pass + return {"total_gb": 0, "used_gb": 0, "percent": 0} + + _SKIP_FS = {'tmpfs', 'devtmpfs', 'devfs', 'overlay', 'squashfs', 'proc', 'sysfs', + 'cgroup', 'cgroup2', 'debugfs', 'tracefs', 'fusectl', 'configfs', + 'securityfs', 'pstore', 'efivarfs', 'autofs', 'ramfs', 'hugetlbfs', + 'mqueue', 'bpf', 'binfmt_misc', 'rpc_pipefs', 'nfsd', 'smb3fs_ctl', + 'snapfuse', 'fuse.gvfsd-fuse', 'fuse.portal'} + + def _get_partitions(self) -> list: + """获取存储分区列表 (过滤虚拟文件系统)""" + parts = [] + if self.psutil: + try: + for p in self.psutil.disk_partitions(all=False): + if p.fstype and p.fstype.lower() in self._SKIP_FS: + continue + try: + usage = self.psutil.disk_usage(p.mountpoint) + parts.append({ + "device": p.device, + "mount": p.mountpoint, + "fstype": p.fstype or "", + "total_gb": round(usage.total / 1073741824, 1), + "used_gb": round(usage.used / 1073741824, 1), + "percent": usage.percent, + }) + except (PermissionError, OSError): + parts.append({ + "device": p.device, + "mount": p.mountpoint, + "fstype": p.fstype or "", + "total_gb": 0, "used_gb": 0, "percent": 0, + }) + except Exception: + pass + if not parts: + # Fallback: just root partition + disk = self._get_disk() + if disk.get("total_gb", 0) > 0: + parts.append({ + "device": "/", + "mount": "/", + "fstype": "", + "total_gb": disk["total_gb"], + "used_gb": disk["used_gb"], + "percent": disk["percent"], + }) + return parts + + def _get_disk(self) -> Dict[str, Any]: + if self.psutil: + try: + d = self.psutil.disk_usage('/') + return {"total_gb": round(d.total / 1073741824, 1), + "used_gb": round(d.used / 1073741824, 1), + "percent": d.percent} + except Exception: + pass + return {"total_gb": 0, "used_gb": 0, "percent": 0} + + def _get_temperature(self) -> Dict[str, Any]: + if self.psutil: + try: + temps = self.psutil.sensors_temperatures() + if temps: + for name, entries in temps.items(): + for e in entries: + if e.current > 0: + return {"name": e.label or name, "current": round(e.current, 1)} + except Exception: + pass + # Android fallback: read thermal zone + try: + for i in range(10): + tz = f'/sys/class/thermal/thermal_zone{i}/temp' + ttype = f'/sys/class/thermal/thermal_zone{i}/type' + if os.path.exists(tz) and os.path.exists(ttype): + with open(ttype) as f: + name = f.read().strip() + if 'cpu' in name.lower() or 'soc' in name.lower(): + with open(tz) as f: + temp = int(f.read().strip()) / 1000.0 + return {"name": name, "current": round(temp, 1)} + except Exception: + pass + return {"name": "", "current": 0} + + def _get_process(self) -> Dict[str, Any]: + """Current process info (PID + RSS memory)""" + pid = os.getpid() + mem_mb = 0 + try: + if self.psutil: + import psutil + mem_mb = round(psutil.Process(pid).memory_info().rss / 1048576, 1) + else: + # Parse /proc/self/status for VmRSS + with open('/proc/self/status', 'r') as f: + for line in f: + if line.startswith('VmRSS:'): + mem_mb = round(int(line.split()[1]) / 1024, 1) + break + except Exception as e: + logger.debug(f"Process info failed: {e}") + return {"pid": pid, "memory_mb": mem_mb} + + def _get_network(self) -> Dict[str, Any]: + """Cumulative RX/TX since boot (MB)""" + try: + rx, tx = self._read_net_bytes() + return {"rx_mb": round(rx / 1048576, 1), + "tx_mb": round(tx / 1048576, 1)} + except Exception as e: + logger.warning(f"Network info failed: {e}") + return {"rx_mb": 0, "tx_mb": 0} + + def _get_network_speed(self) -> Dict[str, Any]: + """Real-time RX/TX speed (bytes/sec), computed from delta between calls""" + now = time.time() + rx, tx = self._read_net_bytes() + + if self._prev_net: + prx, ptx, pt = self._prev_net + dt = now - pt + speed_rx = (rx - prx) / dt if dt > 0 and rx >= prx else 0 + speed_tx = (tx - ptx) / dt if dt > 0 and tx >= ptx else 0 + else: + speed_rx = speed_tx = 0 + + self._prev_net = (rx, tx, now) + return {"rx_bytes_sec": round(speed_rx, 1), + "tx_bytes_sec": round(speed_tx, 1)} diff --git a/start.bat b/start.bat new file mode 100644 index 0000000..419b969 --- /dev/null +++ b/start.bat @@ -0,0 +1,78 @@ +@echo off +chcp 65001 >nul +setlocal enabledelayedexpansion +title SenSu Server + +cd /d "%~dp0" + +echo 🐱 SenSu 依赖检测... +echo. + +set PASS=0 +set FAIL=0 + +:: ── Python (venv) ── +set PYTHON=%~dp0.venv\Scripts\python.exe +if not exist "%PYTHON%" (echo [FAIL] .venv 未创建,请先运行: python -m venv .venv ^&^& .venv\Scripts\pip install -r requirements.txt & pause & exit /b 1) +"%PYTHON%" --version >nul 2>&1 +if %errorlevel% equ 0 ( + echo [OK] Python + set /a PASS+=1 +) else ( + echo [FAIL] Python 未安装 + set /a FAIL+=1 +) + +:: ── Python 依赖 ── +"%PYTHON%" -c "import aiohttp" >nul 2>&1 +if %errorlevel% equ 0 (echo [OK] aiohttp& set /a PASS+=1) else (echo [FAIL] aiohttp 缺失& set /a FAIL+=1) + +"%PYTHON%" -c "import yaml" >nul 2>&1 +if %errorlevel% equ 0 (echo [OK] PyYAML& set /a PASS+=1) else (echo [FAIL] PyYAML 缺失& set /a FAIL+=1) + +"%PYTHON%" -c "import textual" >nul 2>&1 +if %errorlevel% equ 0 (echo [OK] Textual& set /a PASS+=1) else (echo [FAIL] Textual 缺失& set /a FAIL+=1) + +"%PYTHON%" -c "import watchdog" >nul 2>&1 +if %errorlevel% equ 0 (echo [OK] watchdog& set /a PASS+=1) else (echo [FAIL] watchdog 缺失& set /a FAIL+=1) + +"%PYTHON%" -c "import psutil" >nul 2>&1 +if %errorlevel% equ 0 (echo [OK] psutil& set /a PASS+=1) else (echo [FAIL] psutil 缺失& set /a FAIL+=1) + +"%PYTHON%" -c "import rich" >nul 2>&1 +if %errorlevel% equ 0 (echo [OK] rich& set /a PASS+=1) else (echo [FAIL] rich 缺失& set /a FAIL+=1) + +:: ── 配置文件 ── +if exist "config\framework\base_config.yaml" ( + echo [OK] base_config.yaml + set /a PASS+=1 +) else if exist "config\framework\base_config.yaml.example" ( + copy "config\framework\base_config.yaml.example" "config\framework\base_config.yaml" >nul + echo [OK] 从模板创建 base_config.yaml + set /a PASS+=1 +) else ( + echo [FAIL] 配置文件缺失 + set /a FAIL+=1 +) + +echo. +if %FAIL% gtr 0 ( + echo [WARN] %FAIL% 项缺失,自动安装... + "%PYTHON%" -m pip install -r requirements.txt + if %errorlevel% neq 0 ( + echo [FAIL] 安装失败,请手动执行: .venv\Scripts\pip install -r requirements.txt + pause + exit /b 1 + ) + echo [OK] 依赖安装完成 +) + +echo [OK] 所有依赖就绪 (%PASS% 项) +echo. +echo 🚀 启动 SenSu (无头模式)... +echo Web 面板: http://0.0.0.0:4200/SenSu +echo. + +"%PYTHON%" main.py --headless + +pause diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..35bb71f --- /dev/null +++ b/start.sh @@ -0,0 +1,82 @@ +#!/bin/bash +# SenSu 无头启动脚本 (Linux/macOS/Termux) +# 自动检测依赖完整性后启动 + +set -e +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$SCRIPT_DIR" + +# ── Python 路径: 优先 venv ── +if [ -f "$SCRIPT_DIR/.venv/bin/python3" ]; then + PYTHON="$SCRIPT_DIR/.venv/bin/python3" +elif [ -f "$SCRIPT_DIR/.venv/bin/python" ]; then + PYTHON="$SCRIPT_DIR/.venv/bin/python" +elif command -v python3 &>/dev/null; then + PYTHON="python3" +else + PYTHON="python" +fi + +# ── 颜色 ── +RED='\033[31m'; GREEN='\033[32m'; YELLOW='\033[33m'; NC='\033[0m' +pass=0; fail=0 +check() { + if command -v "$1" &>/dev/null || "$PYTHON" -c "$2" 2>/dev/null; then + echo -e " ${GREEN}✓${NC} $3" + pass=$((pass+1)) + else + echo -e " ${RED}✗${NC} $3 ${YELLOW}(缺失)${NC}" + fail=$((fail+1)) + fi +} + +echo "🐱 SenSu 依赖检测..." +echo "" + +# ── 系统依赖 ── +check "" "" "Python 3.11+" +"$PYTHON" -c "import sys; assert sys.version_info >= (3,11)" 2>/dev/null || { + echo -e " ${YELLOW}⚠ Python 版本过低,需要 3.11+${NC}" + fail=$((fail+1)) +} + +# ── Python 核心依赖 ── +check "" "import aiohttp" "aiohttp (Web 服务)" +check "" "import yaml" "PyYAML (配置文件)" +check "" "import textual" "Textual (TUI)" +check "" "import watchdog" "watchdog (热重载)" +check "" "import psutil" "psutil (系统监控)" +check "" "import rich" "rich (终端美化)" +check "" "import websockets" "websockets (WebSocket)" + +# ── 配置文件 ── +if [ -f "config/framework/base_config.yaml" ]; then + echo -e " ${GREEN}✓${NC} base_config.yaml" + pass=$((pass+1)) +elif [ -f "config/framework/base_config.yaml.example" ]; then + cp config/framework/base_config.yaml.example config/framework/base_config.yaml + echo -e " ${GREEN}✓${NC} 从模板创建 base_config.yaml" + pass=$((pass+1)) +else + echo -e " ${RED}✗${NC} 配置文件缺失 (无模板可用)" + fail=$((fail+1)) +fi + +echo "" +if [ "$fail" -gt 0 ]; then + echo -e "${YELLOW}⚠ $fail 项缺失,自动安装...${NC}" + "$PYTHON" -m pip install -r requirements.txt + if [ $? -ne 0 ]; then + echo -e "${RED}❌ 安装失败,请手动执行: python3 -m venv .venv && source .venv/bin/activate && pip install -r requirements.txt${NC}" + exit 1 + fi + echo -e "${GREEN}✅ 依赖安装完成${NC}" +fi + +echo -e "${GREEN}✅ 所有依赖就绪 ($pass 项)${NC}" +echo "" +echo "🚀 启动 SenSu (无头模式)..." +echo " Web 面板: http://0.0.0.0:4200/SenSu" +echo "" + +"$PYTHON" main.py --headless diff --git a/static/web_panel/css/style.css b/static/web_panel/css/style.css new file mode 100644 index 0000000..07d8513 --- /dev/null +++ b/static/web_panel/css/style.css @@ -0,0 +1,498 @@ +/* ═══════════════════════════════════════════ + SenSu Web Panel — Material Design 3 2026 + ═══════════════════════════════════════════ */ + +/* ── MD3 Color Tokens (Dark Theme) ── */ +:root { + --md-sys-color-primary: #D0BCFF; + --md-sys-color-on-primary: #381E72; + --md-sys-color-primary-container: #4F378B; + --md-sys-color-on-primary-container: #EADDFF; + --md-sys-color-secondary: #CCC2DC; + --md-sys-color-on-secondary: #332D41; + --md-sys-color-secondary-container: #4A4458; + --md-sys-color-on-secondary-container: #E8DEF8; + --md-sys-color-tertiary: #EFB8C8; + --md-sys-color-on-tertiary: #492532; + --md-sys-color-tertiary-container: #633B48; + --md-sys-color-on-tertiary-container: #FFD8E4; + --md-sys-color-error: #F2B8B5; + --md-sys-color-on-error: #601410; + --md-sys-color-error-container: #8C1D18; + --md-sys-color-on-error-container: #F9DEDC; + + --md-sys-color-surface: #141218; + --md-sys-color-on-surface: #E6E1E5; + --md-sys-color-surface-variant: #49454F; + --md-sys-color-on-surface-variant: #CAC4D0; + --md-sys-color-outline: #938F99; + --md-sys-color-outline-variant: #49454F; + + --md-sys-color-surface-container-lowest: #0F0D13; + --md-sys-color-surface-container-low: #1D1B20; + --md-sys-color-surface-container: #211F26; + --md-sys-color-surface-container-high: #2B2930; + --md-sys-color-surface-container-highest: #36343B; + + /* Aliases for quick refactoring */ + --bg: var(--md-sys-color-surface); + --bg-card: var(--md-sys-color-surface-container); + --bg-elevated: var(--md-sys-color-surface-container-high); + --bg-hover: var(--md-sys-color-surface-container-highest); + --text: var(--md-sys-color-on-surface); + --text-dim: var(--md-sys-color-on-surface-variant); + --primary: var(--md-sys-color-primary); + --on-primary: var(--md-sys-color-on-primary); + --primary-container: var(--md-sys-color-primary-container); + --outline: var(--md-sys-color-outline-variant); + --error: var(--md-sys-color-error); + + /* MD3 Elevation */ + --md-sys-elevation-0: none; + --md-sys-elevation-1: 0 1px 2px 0 rgba(0,0,0,.3), 0 1px 3px 1px rgba(0,0,0,.15); + --md-sys-elevation-2: 0 1px 2px 0 rgba(0,0,0,.3), 0 2px 6px 2px rgba(0,0,0,.15); + --md-sys-elevation-3: 0 4px 8px 3px rgba(0,0,0,.15), 0 1px 3px 0 rgba(0,0,0,.3); + --md-sys-elevation-4: 0 6px 10px 4px rgba(0,0,0,.15), 0 2px 3px 0 rgba(0,0,0,.3); + --md-sys-elevation-5: 0 8px 12px 6px rgba(0,0,0,.15), 0 4px 4px 0 rgba(0,0,0,.3); + + /* MD3 Shape */ + --shape-xs: 8px; --shape-sm: 12px; --shape-md: 16px; --shape-lg: 24px; --shape-xl: 28px; --shape-full: 9999px; + + /* Layout */ + --sidebar-w: 260px; --sidebar-collapsed: 64px; --topbar-h: 64px; +} + +/* ── Reset ── */ +*,*::before,*::after{box-sizing:border-box;margin:0;padding:0} +svg{fill:currentColor} +body{ + background:var(--bg);color:var(--text); + font-family:"Google Sans",system-ui,-apple-system,"Segoe UI",Roboto,sans-serif; + font-size:14px;line-height:1.5;height:100vh;overflow:hidden; + -webkit-font-smoothing:antialiased; +} + +/* ── Typography Scale ── */ +h2{font-size:1.5rem;font-weight:500;letter-spacing:0;color:var(--text)} +h3{font-size:1rem;font-weight:500;letter-spacing:.15px} +.display{font-size:2.25rem;font-weight:400;line-height:1.1} +.headline{font-size:1.75rem;font-weight:400} +.title{font-size:1.25rem;font-weight:500} +.label{font-size:.75rem;font-weight:500;letter-spacing:.5px;text-transform:uppercase} +.mono{font-family:"JetBrains Mono","Fira Code","Cascadia Code",monospace} + +/* ── MD3 Surface Tint ── */ +.surface{background:var(--bg-card);border-radius:var(--shape-md)} +.surface-high{background:var(--bg-elevated);border-radius:var(--shape-md)} +.surface-highest{background:var(--bg-hover);border-radius:var(--shape-md)} + +/* ── MD3 Card ── */ +.card{ + background:var(--bg-card);border-radius:var(--shape-sm); + padding:16px;box-shadow:var(--md-sys-elevation-1); + transition:box-shadow .2s,background .2s; +} +.card:hover{box-shadow:var(--md-sys-elevation-2);background:var(--bg-elevated)} +.card.outlined{box-shadow:none;border:1px solid var(--outline)} +.card.outlined:hover{border-color:var(--primary);box-shadow:none} + +/* ── MD3 Button Variants ── */ +.btn{ + display:inline-flex;align-items:center;justify-content:center;gap:8px; + padding:10px 24px;border:none;border-radius:var(--shape-full);font-size:14px; + font-weight:500;letter-spacing:.1px;cursor:pointer;transition:.2s;text-decoration:none; + line-height:1.25;white-space:nowrap;position:relative;overflow:hidden; +} +.btn-filled{background:var(--primary);color:var(--on-primary)} +.btn-filled:hover{box-shadow:var(--md-sys-elevation-1);filter:brightness(1.08)} +.btn-tonal{background:var(--primary-container);color:var(--md-sys-color-on-primary-container)} +.btn-tonal:hover{filter:brightness(1.1)} +.btn-outlined{border:1px solid var(--outline);background:transparent;color:var(--primary)} +.btn-outlined:hover{border-color:var(--primary);background:rgba(208,188,255,.08)} +.btn-text{background:transparent;color:var(--primary)} +.btn-text:hover{background:rgba(208,188,255,.08)} +.btn-danger{color:var(--error)} +.btn-danger:hover{background:rgba(242,184,181,.08)} +.btn-sm{padding:6px 16px;font-size:12px} +.btn-lg{padding:14px 32px;font-size:16px} +.btn-icon{width:40px;height:40px;padding:0;border-radius:var(--shape-full)} +.btn:disabled{opacity:.38;pointer-events:none} + +/* ── MD3 Input ── */ +.input-group{margin-bottom:16px;text-align:left} +.input-group label{display:block;margin-bottom:6px;color:var(--text-dim);font-size:.8rem;font-weight:500;letter-spacing:.3px} +.input{ + width:100%;padding:12px 16px;background:var(--md-sys-color-surface-container-lowest); + border:1px solid var(--outline);border-radius:var(--shape-xs);color:var(--text); + font-size:14px;transition:border-color .2s,box-shadow .2s;outline:none; +} +.input:focus{border-color:var(--primary);box-shadow:0 0 0 2px rgba(208,188,255,.2)} +.input::placeholder{color:var(--text-dim);opacity:.6} + +/* ── MD3 FAB ── */ +.fab{ + width:56px;height:56px;border-radius:var(--shape-md);background:var(--primary-container); + color:var(--md-sys-color-on-primary-container);border:none;cursor:pointer; + display:flex;align-items:center;justify-content:center;box-shadow:var(--md-sys-elevation-3); + position:fixed;bottom:24px;right:24px;font-size:24px;transition:.2s;z-index:100; +} +.fab:hover{box-shadow:var(--md-sys-elevation-4);filter:brightness(1.1)} + +/* ── MD3 Chip ── */ +.chip{ + display:inline-flex;align-items:center;padding:4px 12px;border-radius:var(--shape-xs); + border:1px solid var(--outline);font-size:12px;color:var(--text-dim);gap:6px; + background:transparent;transition:.2s; +} +.chip.active{background:var(--primary-container);color:var(--md-sys-color-on-primary-container);border-color:transparent} + +/* ── Login Page ── */ +.login-wrapper{ + display:flex;align-items:center;justify-content:center;height:100vh; + background:radial-gradient(circle at 50% 0%,var(--md-sys-color-primary-container) 0%,var(--bg) 70%); +} +.login-card{ + background:var(--md-sys-color-surface-container-high);border-radius:var(--shape-xl); + padding:40px 32px;width:360px;text-align:center;box-shadow:var(--md-sys-elevation-4); +} +.login-card h2{color:var(--primary);margin-bottom:24px} +.err-msg{color:var(--error);font-size:.8rem;margin-top:12px;min-height:1.2rem} + +/* ── App Shell ── */ +.app-frame{ + display:grid;grid-template-columns:var(--sidebar-w) 1fr; + grid-template-rows:var(--topbar-h) 1fr;height:100vh;transition:grid-template-columns .3s cubic-bezier(.4,0,.2,1); +} +.app-frame.collapsed{grid-template-columns:var(--sidebar-collapsed) 1fr} + +/* ── Top Bar ── */ +.top-bar{ + grid-column:1/-1;display:flex;align-items:center;justify-content:space-between; + padding:0 24px;background:var(--md-sys-color-surface-container);z-index:10; + border-bottom:1px solid var(--outline); +} +.top-bar .title{font-size:1.15rem;font-weight:500;color:var(--primary);display:flex;align-items:center;gap:8px} +.top-bar .user-info{display:flex;align-items:center;gap:12px;color:var(--text-dim);font-size:.85rem} +.top-bar .logout-btn{background:transparent;border:1px solid var(--outline);color:var(--text-dim);padding:6px 14px;border-radius:var(--shape-full);cursor:pointer;transition:.2s} +.top-bar .logout-btn:hover{border-color:var(--error);color:var(--error)} + +/* ── Navigation Rail / Sidebar ── */ +.sidebar{ + background:var(--md-sys-color-surface-container-low);display:flex;flex-direction:column; + padding:8px 0;overflow:hidden;transition:.3s;gap:2px; +} +.nav-item{ + display:flex;align-items:center;padding:14px 16px;color:var(--text-dim); + text-decoration:none;cursor:pointer;transition:.15s;white-space:nowrap; + gap:12px;margin:0 8px;border-radius:var(--shape-full);font-weight:500;font-size:14px; + position:relative; +} +.nav-item:hover{background:rgba(208,188,255,.08);color:var(--text)} +.nav-item.active{background:var(--primary-container);color:var(--md-sys-color-on-primary-container)} +.nav-item .nav-icon{width:24px;height:24px;flex-shrink:0;display:flex;align-items:center;justify-content:center} +.nav-item svg{width:20px;height:20px;flex-shrink:0;fill:currentColor} +.nav-item .nav-label{overflow:hidden;text-overflow:ellipsis} +.toggle-sidebar{ + margin-top:auto;padding:16px;text-align:center;cursor:pointer; + color:var(--text-dim);border-top:1px solid var(--outline); + transition:.2s;font-size:12px; +} +.toggle-sidebar:hover{color:var(--primary)} + +/* ── Expandable nav section (plugin pages) ── */ +.nav-expandable{border-top:1px solid var(--outline);margin-top:4px;padding-top:4px} +.nav-expand-header{display:flex;align-items:center;gap:12px;padding:10px 16px;cursor:pointer;color:var(--text-dim);font-size:14px;font-weight:500;border-radius:var(--shape-full);margin:0 8px;transition:.15s} +.nav-expand-header:hover{background:rgba(208,188,255,.08);color:var(--text)} +.nav-expand-body{padding:2px 0;overflow:hidden} +.nav-sub-item{display:flex;align-items:center;gap:10px;padding:8px 16px 8px 36px;cursor:pointer;color:var(--text-dim);font-size:.82rem;text-decoration:none;transition:.12s;border-radius:var(--shape-full);margin:0 8px} +.nav-sub-item:hover{background:rgba(208,188,255,.08);color:var(--text)} +.nav-sub-item.active{background:var(--primary-container);color:var(--md-sys-color-on-primary-container)} +.nav-sub-item .nav-icon{width:18px;height:18px;display:flex;align-items:center;justify-content:center;font-size:.8rem;flex-shrink:0;border-radius:4px;background:rgba(208,188,255,.12)} + +/* ── Main Content ── */ +.content-area{overflow:hidden;display:flex;flex-direction:column;background:var(--bg)} +.progress-bar{ + position:absolute;top:0;left:0;height:3px;background:var(--primary); + width:0;transition:width .3s,opacity .3s;opacity:0;z-index:100;border-radius:0 2px 2px 0; +} +.progress-bar.active{opacity:1} +.page-container{flex:1;padding:24px;overflow-y:auto;scrollbar-width:thin;scrollbar-color:var(--outline) transparent} + +/* ── Dashboard ── */ +.dash-layout{display:flex;gap:24px;height:calc(100vh - var(--topbar-h) - 48px);overflow:hidden} +.dash-main{flex:1;overflow-y:auto;scrollbar-width:thin;scrollbar-color:var(--outline) transparent} +.dash-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:16px} +.stat-card{background:var(--bg-card);border-radius:var(--shape-sm);padding:20px;box-shadow:var(--md-sys-elevation-1);display:flex;flex-direction:column;gap:8px;transition:.2s} +.stat-card:hover{box-shadow:var(--md-sys-elevation-2)} +.stat-card h3{font-size:.8rem;color:var(--text-dim);text-transform:uppercase;letter-spacing:.5px;font-weight:500} +.stat-value{font-size:2rem;font-weight:400;color:var(--text)} +.stat-sub{font-size:.75rem;color:var(--text-dim)} +.mini-chart{width:100%;height:64px;background:rgba(208,188,255,.05);border-radius:var(--shape-xs);margin-top:8px} +.dash-sidebar{width:340px;flex-shrink:0;display:flex;flex-direction:column;gap:16px;overflow-y:auto} +.side-card{background:var(--bg-card);border-radius:var(--shape-sm);padding:20px;box-shadow:var(--md-sys-elevation-1)} +.side-card h3{font-size:.85rem;color:var(--primary);margin-bottom:12px;padding-bottom:8px;border-bottom:1px solid var(--outline)} +.info-row{display:flex;justify-content:space-between;font-size:.82rem;padding:6px 0;color:var(--text-dim)} +.info-value{color:var(--text);font-family:monospace;font-weight:500;text-align:right;max-width:60%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} + +/* ── Terminal / Log ── */ +.terminal{background:var(--md-sys-color-surface-container-lowest);border-radius:var(--shape-sm);overflow:hidden;height:75vh;display:flex;flex-direction:column;box-shadow:var(--md-sys-elevation-1)} +.term-header{background:var(--bg-card);padding:8px 16px;font-size:.8rem;color:var(--text-dim);display:flex;justify-content:space-between;align-items:center} +.term-body{flex:1;padding:12px;overflow-y:auto;font-family:"JetBrains Mono",monospace;font-size:.8rem;color:#ccc;line-height:1.6;scrollbar-width:thin} +.term-input-area{display:flex;border-top:1px solid var(--outline)} +.term-input{flex:1;background:transparent;border:none;padding:12px 16px;color:var(--text);font-family:monospace;outline:none;font-size:.85rem} + +/* ── Log entries ── */ +.log-entry{padding:3px 0;border-bottom:1px solid rgba(255,255,255,.03);font-size:.8rem} +.log-INFO{color:var(--primary)}.log-WARNING{color:#E0AF68}.log-ERROR{color:var(--error)} + +/* ── Plugin Cards ── */ +.plugin-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:16px} +.plugin-card{background:var(--bg-card);border-radius:var(--shape-sm);padding:20px;box-shadow:var(--md-sys-elevation-1);display:flex;justify-content:space-between;align-items:center;transition:.2s} +.plugin-card:hover{box-shadow:var(--md-sys-elevation-2)} +.plugin-info h4{color:var(--primary);font-weight:500;margin-bottom:4px} +.plugin-info p{font-size:.8rem;color:var(--text-dim)} +.badge{padding:4px 12px;border-radius:var(--shape-full);font-size:.7rem;font-weight:600;letter-spacing:.3px} +.badge-run{background:rgba(158,206,106,.15);color:#9ece6a} +.badge-stop{background:rgba(242,184,181,.15);color:var(--error)} +.plugin-act{display:flex;gap:8px} + +/* ── Project / Proxy page cards (shared) ── */ +.mgmt-card{background:var(--bg-card);border-radius:var(--shape-sm);padding:16px;box-shadow:var(--md-sys-elevation-1);margin-bottom:12px;display:flex;align-items:center;gap:16px;transition:.2s} +.mgmt-card:hover{box-shadow:var(--md-sys-elevation-2)} +.mgmt-card .info{flex:1} +.mgmt-card .info b{color:var(--primary);font-weight:500} +.mgmt-card .info small{color:var(--text-dim);font-size:.8rem} +.mgmt-card .actions{display:flex;gap:8px;flex-shrink:0} +.status-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0} +.status-running{background:#9ece6a;box-shadow:0 0 8px rgba(158,206,106,.4)} +.status-stopped{background:var(--error);box-shadow:0 0 8px rgba(242,184,181,.4)} +.status-error{background:#E0AF68;box-shadow:0 0 8px rgba(224,175,104,.4)} +.log-box{background:var(--md-sys-color-surface-container-lowest);border-radius:var(--shape-xs);padding:8px 12px;max-height:300px;overflow-y:auto;font-family:monospace;font-size:11px;color:#0f0;margin-top:8px;display:none} + +/* ── Tabs ── */ +.tabs{display:flex;gap:0;margin-bottom:20px;background:var(--bg-card);border-radius:var(--shape-full);padding:4px;width:fit-content} +.tab{padding:8px 20px;border-radius:var(--shape-full);cursor:pointer;font-size:13px;font-weight:500;color:var(--text-dim);transition:.2s} +.tab.active{background:var(--primary-container);color:var(--md-sys-color-on-primary-container)} +.tab:hover:not(.active){color:var(--text)} + +/* ── Plugin page container ── */ +.plugin-page-root{font-family:inherit;color:inherit} + +/* ── Responsive ── */ +@media(max-width:1100px){.dash-layout{flex-direction:column;height:auto;overflow:visible}.dash-sidebar{width:100%}} +@media(max-width:768px){.app-frame{grid-template-columns:var(--sidebar-collapsed) 1fr}.nav-label{display:none}.toggle-sidebar{display:none}} + +/* ═══════════════════════════════════════════ + MD3 Light Theme + ═══════════════════════════════════════════ */ +[data-theme="light"] { + --md-sys-color-primary: #6750A4; + --md-sys-color-on-primary: #FFFFFF; + --md-sys-color-primary-container: #EADDFF; + --md-sys-color-on-primary-container: #21005D; + + --md-sys-color-secondary: #625B71; + --md-sys-color-on-secondary: #FFFFFF; + --md-sys-color-secondary-container: #E8DEF8; + --md-sys-color-on-secondary-container: #1D192B; + + --md-sys-color-tertiary: #7D5260; + --md-sys-color-on-tertiary: #FFFFFF; + --md-sys-color-tertiary-container: #FFD8E4; + --md-sys-color-on-tertiary-container: #31111D; + + --md-sys-color-error: #B3261E; + --md-sys-color-on-error: #FFFFFF; + --md-sys-color-error-container: #F9DEDC; + --md-sys-color-on-error-container: #410E0B; + + --md-sys-color-surface: #FFFBFE; + --md-sys-color-on-surface: #1C1B1F; + --md-sys-color-surface-variant: #E7E0EC; + --md-sys-color-on-surface-variant: #49454F; + --md-sys-color-outline: #79747E; + --md-sys-color-outline-variant: #CAC4D0; + + --md-sys-color-surface-container-lowest: #FFFFFF; + --md-sys-color-surface-container-low: #F7F2FA; + --md-sys-color-surface-container: #F3EDF7; + --md-sys-color-surface-container-high: #ECE6F0; + --md-sys-color-surface-container-highest: #E6E0E9; + + --md-sys-elevation-1: 0 1px 2px 0 rgba(0,0,0,.05), 0 1px 3px 1px rgba(0,0,0,.08); + --md-sys-elevation-2: 0 1px 2px 0 rgba(0,0,0,.08), 0 2px 6px 2px rgba(0,0,0,.06); + --md-sys-elevation-3: 0 4px 8px 3px rgba(0,0,0,.06), 0 1px 3px 0 rgba(0,0,0,.08); + --md-sys-elevation-4: 0 6px 10px 4px rgba(0,0,0,.05), 0 2px 3px 0 rgba(0,0,0,.08); + --md-sys-elevation-5: 0 8px 12px 6px rgba(0,0,0,.04), 0 4px 4px 0 rgba(0,0,0,.08); + + --bg: var(--md-sys-color-surface); + --bg-card: var(--md-sys-color-surface-container); + --bg-elevated: var(--md-sys-color-surface-container-high); + --bg-hover: var(--md-sys-color-surface-container-highest); + --text: var(--md-sys-color-on-surface); + --text-dim: var(--md-sys-color-on-surface-variant); + --primary: var(--md-sys-color-primary); + --on-primary: var(--md-sys-color-on-primary); + --primary-container: var(--md-sys-color-primary-container); + --outline: var(--md-sys-color-outline-variant); + --error: var(--md-sys-color-error); +} + +/* ── Theme Toggle Button ── */ +.theme-toggle{ + display:flex;align-items:center;justify-content:center; + width:40px;height:40px;border-radius:var(--shape-full); + background:transparent;border:none;color:var(--text-dim);cursor:pointer; + font-size:20px;transition:.2s; +} +.theme-toggle:hover{background:rgba(208,188,255,.08)} + +/* ═══════════════════════════════════════════ + MD3 Animations & Micro-interactions + ═══════════════════════════════════════════ */ + +/* ── Button Ripple ── */ +.btn{position:relative;overflow:hidden;transform:translateZ(0)} +.btn::after{ + content:"";position:absolute;inset:0;background:radial-gradient(circle at center,currentColor 10%,transparent 10%); + background-size:0 0;background-repeat:no-repeat;opacity:0;transition:background-size .4s,opacity .3s; + pointer-events:none; +} +.btn:active::after{background-size:300% 300%;opacity:.12;transition:0s} + +/* ── Card Elevation Transition ── */ +.card,.stat-card,.mgmt-card,.side-card{ + transition:box-shadow .3s cubic-bezier(.4,0,.2,1),transform .2s cubic-bezier(.4,0,.2,1),background .3s; +} +.card:hover,.stat-card:hover,.mgmt-card:hover{transform:translateY(-1px)} +.card:active{transform:translateY(0)} + +/* ── Page Transition Fade ── */ +.page-container{animation:pageIn .25s cubic-bezier(.4,0,.2,1)} +@keyframes pageIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}} + +/* ── Tab Indicator Slide ── */ +.tab{position:relative;transition:color .2s,background .25s} +.tab.active::after{ + content:"";position:absolute;bottom:0;left:25%;right:25%;height:2px; + background:var(--primary);border-radius:2px 2px 0 0; + animation:tabSlide .3s cubic-bezier(.4,0,.2,1) +} +@keyframes tabSlide{from{transform:scaleX(0)}to{transform:scaleX(1)}} + +/* ── Input Focus Glow ── */ +.input{transition:border-color .25s,box-shadow .25s,background .25s} +.input:focus{animation:inputGlow .6s ease-out} +@keyframes inputGlow{0%{box-shadow:0 0 0 0 rgba(208,188,255,.4)}100%{box-shadow:0 0 0 4px rgba(208,188,255,0)}} + +/* ── Sidebar Collapse ── */ +.sidebar{transition:width .3s cubic-bezier(.4,0,.2,1)} +.nav-item .nav-label{transition:opacity .25s,width .3s;opacity:1} +.app-frame.collapsed .nav-label{opacity:0;width:0} + +/* ── Nav Item Active Dot ── */ +.nav-item.active::before{ + content:"";position:absolute;left:0;top:50%;transform:translateY(-50%); + width:3px;height:20px;background:var(--primary);border-radius:0 3px 3px 0; + animation:navDotIn .3s cubic-bezier(.4,0,.2,1) +} +@keyframes navDotIn{from{height:0;opacity:0}to{height:20px;opacity:1}} + +/* ── Status Dot Pulse ── */ +.status-running{animation:statusPulse 2s ease-in-out infinite} +@keyframes statusPulse{0%,100%{box-shadow:0 0 4px rgba(158,206,106,.4)}50%{box-shadow:0 0 12px rgba(158,206,106,.7)}} + +/* ── Loading Skeleton Shimmer ── */ +.skeleton{background:linear-gradient(90deg,var(--bg-card) 25%,var(--bg-elevated) 50%,var(--bg-card) 75%);background-size:200% 100%;animation:shimmer 1.5s infinite;border-radius:var(--shape-xs);height:1rem} +@keyframes shimmer{0%{background-position:200% 0}100%{background-position:-200% 0}} + +/* ── Progress Bar ── */ +.progress-bar{transition:width .3s cubic-bezier(.4,0,.2,1),opacity .2s} + +/* ── Badge Bounce ── */ +@keyframes badgeIn{0%{transform:scale(0);opacity:0}60%{transform:scale(1.2)}100%{transform:scale(1);opacity:1}} +.badge{animation:badgeIn .3s cubic-bezier(.4,0,.2,1)} + +/* ── FAB Float ── */ +.fab{transition:box-shadow .3s,transform .2s cubic-bezier(.4,0,.2,1),filter .2s} +.fab:hover{transform:scale(1.05)} + +/* ── Log Entry Fade ── */ +.log-entry{animation:logIn .2s ease-out} +@keyframes logIn{from{opacity:0;transform:translateX(-8px)}to{opacity:1;transform:translateX(0)}} + +/* ── Toggle Theme Button Spin ── */ +.theme-toggle{transition:transform .3s cubic-bezier(.4,0,.2,1)} +.theme-toggle:hover{transform:rotate(30deg)} + +/* ── Scrollbar MD3 Style ── */ +::-webkit-scrollbar{width:6px} +::-webkit-scrollbar-track{background:transparent} +::-webkit-scrollbar-thumb{background:var(--outline);border-radius:3px} +::-webkit-scrollbar-thumb:hover{background:var(--text-dim)} + +/* ── Ripple on Management Cards ── */ +.mgmt-card,.plugin-card{position:relative;overflow:hidden} +.mgmt-card::before,.plugin-card::before{ + content:"";position:absolute;inset:0;background:radial-gradient(circle at var(--mouse-x,50%) var(--mouse-y,50%),rgba(208,188,255,.1) 0%,transparent 60%); + opacity:0;transition:opacity .3s +} +.mgmt-card:hover::before,.plugin-card:hover::before{opacity:1} + +/* ── Menu reveal animation for login error ── */ +.err-msg{transition:opacity .3s} + +/* ═══════════════════════════════════════════ + File Manager + ═══════════════════════════════════════════ */ +.fm-container{display:flex;flex-direction:column;height:calc(100vh - var(--topbar-h) - 48px);gap:12px} +.fm-toolbar{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:8px} +.fm-path-input{flex:1;min-width:140px;padding:8px 12px;background:var(--md-sys-color-surface-container-lowest);border:1px solid var(--outline);border-radius:var(--shape-xs);color:var(--text);font-family:"JetBrains Mono",monospace;font-size:12px;outline:none;transition:border-color .2s} +.fm-path-input:focus{border-color:var(--primary);box-shadow:0 0 0 2px rgba(208,188,255,.15)} +.fm-breadcrumb{display:flex;align-items:center;flex-wrap:wrap;gap:4px;padding:6px 10px;margin:4px 8px 0 8px;overflow-x:auto;scrollbar-width:none;flex-shrink:0;background:var(--md-sys-color-surface-container);border-radius:var(--shape-xs);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px)} +.fm-breadcrumb::-webkit-scrollbar{display:none} +.fm-crumb{white-space:nowrap;padding:4px 10px;border-radius:var(--shape-full);cursor:pointer;font-size:.82rem;color:var(--text-dim);transition:.15s} +.fm-crumb:hover{background:rgba(208,188,255,.1);color:var(--primary)} +.fm-crumb-sep{color:var(--outline);font-size:.7rem;flex-shrink:0} +.fm-resolved-crumbs{display:flex;align-items:center;gap:4px;font-size:.75rem;opacity:.8;flex-basis:100%} +.fm-actions{display:flex;gap:6px;flex-shrink:0;align-items:center} +.fm-toggle{display:flex;align-items:center;gap:8px;font-size:.8rem;font-weight:500;color:var(--text-dim);cursor:pointer;user-select:none} +.fm-toggle input[type="checkbox"]{-webkit-appearance:none;appearance:none;width:36px;height:20px;background:var(--md-sys-color-surface-container-highest);border:2px solid var(--outline);border-radius:10px;cursor:pointer;position:relative;transition:.2s;margin:0;flex-shrink:0} +.fm-toggle input[type="checkbox"]::after{content:"";position:absolute;top:2px;left:2px;width:12px;height:12px;background:var(--outline);border-radius:50%;transition:.2s} +.fm-toggle input[type="checkbox"]:checked{background:var(--primary);border-color:var(--primary)} +.fm-toggle input[type="checkbox"]:checked::after{left:18px;background:var(--on-primary)} +.fm-list{flex:1;overflow-y:auto;scrollbar-width:thin;scrollbar-color:var(--outline) transparent} +.fm-row{display:grid;grid-template-columns:32px 1fr 100px 140px;align-items:center;padding:10px 12px;cursor:pointer;border-radius:var(--shape-xs);transition:background .12s;gap:8px;font-size:.88rem} +.fm-row:hover{background:rgba(208,188,255,.06)} +.fm-row.fm-dir{font-weight:500} +.fm-row.fm-parent{border-bottom:1px solid var(--outline);margin-bottom:2px;font-weight:600} +.fm-icon{font-size:1.2rem;text-align:center} +.fm-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.fm-size{text-align:right;color:var(--text-dim);font-size:.8rem;font-variant-numeric:tabular-nums} +.fm-date{text-align:right;color:var(--text-dim);font-size:.78rem} + +/* Editor overlay */ +.fm-editor-overlay{position:fixed;inset:0;z-index:200;display:flex;flex-direction:column;background:var(--md-sys-color-surface-container-high)} +.fm-editor-header{display:flex;justify-content:space-between;align-items:center;padding:12px 20px;border-bottom:1px solid var(--outline);background:var(--bg-card)} +.fm-editor-header span{font-weight:500;color:var(--primary)} +.fm-editor-header div{display:flex;gap:8px} +.fm-editor-overlay textarea{flex:1;padding:16px 20px;background:var(--md-sys-color-surface-container-lowest);color:var(--text);border:none;outline:none;resize:none;font-family:"JetBrains Mono","Fira Code",monospace;font-size:13px;line-height:1.6;tab-size:4} + +/* Dialog overlay */ +.fm-dialog-overlay{position:fixed;inset:0;z-index:300;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.5)} +.fm-dialog{background:var(--md-sys-color-surface-container-high);border-radius:var(--shape-md);padding:24px;min-width:320px;max-width:450px;box-shadow:var(--md-sys-elevation-4)} + +/* Context menu */ +.fm-context{position:fixed;z-index:250;background:var(--md-sys-color-surface-container-high);border-radius:var(--shape-xs);box-shadow:var(--md-sys-elevation-3);min-width:160px;padding:4px 0;overflow:hidden} +.fm-context-item{padding:8px 16px;cursor:pointer;font-size:.82rem;color:var(--text);transition:background .12s} +.fm-context-item:hover{background:rgba(208,188,255,.1)} +.fm-context-item.danger{color:var(--error)} +.fm-context-item.danger:hover{background:rgba(242,184,181,.1)} +.fm-context-sep{height:1px;background:var(--outline);margin:4px 0} + +/* Responsive */ +@media(max-width:768px){ + .fm-row{grid-template-columns:28px 1fr 70px} + .fm-date{display:none} + .fm-actions{flex-wrap:wrap} +} diff --git a/static/web_panel/home.html b/static/web_panel/home.html new file mode 100644 index 0000000..d16751c --- /dev/null +++ b/static/web_panel/home.html @@ -0,0 +1,84 @@ + + + + + SenSu 面板 + + + + +
+
+
+ 🐱 SenSu Alpha + +
+ +
+ + + +
+
+
+
+
+ + + + + + + + diff --git a/static/web_panel/index.html b/static/web_panel/index.html new file mode 100644 index 0000000..c55659f --- /dev/null +++ b/static/web_panel/index.html @@ -0,0 +1,40 @@ + + + + + SenSu 登录 + + + + + + + diff --git a/static/web_panel/js/api.js b/static/web_panel/js/api.js new file mode 100644 index 0000000..b0eba27 --- /dev/null +++ b/static/web_panel/js/api.js @@ -0,0 +1,32 @@ +const BASE = '/panel'; + +export const api = { + get: async (url) => { + const res = await fetch(`${BASE}${url}`, { credentials: 'include' }); + return res.json(); + }, + post: async (url, data) => { + const res = await fetch(`${BASE}${url}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify(data) + }); + return res.json(); + } +}; + +export async function initAuth() { + // 登录逻辑绑定到 DOM + const loginForm = document.querySelector('#login-form'); + if(loginForm) { + loginForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const u = document.getElementById('username').value; + const p = document.getElementById('password').value; + const res = await api.post('/api/login', { username: u, password: p }); + if (res.success) location.reload(); // 登录成功刷新 + }); + } + return await api.get('/api/auth/status'); +} diff --git a/static/web_panel/js/app.js b/static/web_panel/js/app.js new file mode 100644 index 0000000..08594cd --- /dev/null +++ b/static/web_panel/js/app.js @@ -0,0 +1,360 @@ +/* ── SenSu WebUI — Theme + Routing + Page Loader ── */ + +// ═══ 主题初始化 (必须在渲染前, 防闪白) ═══ +(function(){ + var s = localStorage.getItem("sensu-theme") || "dark"; + document.documentElement.setAttribute("data-theme", s); +})(); + +// ═══ 工具函数 ═══ +function getCookie(name) { + var match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)')); + return match ? match[2] : ''; +} + +// ── 顶部版本检查 ── +async function checkTopUpdate() { + var btn = document.getElementById("top-ver-btn"); + try { + var r = await fetch("./api/updates"), d = await r.json(); + var ver = d.current_version || d.latest_version || ""; + btn.textContent = ver ? ver : "v?"; + if (d.update_available) { + btn.textContent = "↻ " + d.latest_version + " 可用"; + btn.style.color = "var(--success)"; + btn.title = "新版本可用! " + d.latest_version; + } else if (ver) { + btn.style.color = ""; + btn.title = "已是最新 " + ver; + } + } catch(e) {} +} +setTimeout(checkTopUpdate, 3000); +setInterval(checkTopUpdate, 600000); // 10min + +window.toggleTheme = function(){ + var t = document.documentElement.getAttribute("data-theme") === "light" ? "dark" : "light"; + document.documentElement.setAttribute("data-theme", t); + localStorage.setItem("sensu-theme", t); + var btn = document.querySelector(".theme-toggle"); + if(btn) btn.textContent = t === "light" ? "☀️" : "🌙"; +}; + + +// ═══ 路由系统 ═══ +// Hash format: #/dashboard | #/files | #/plugins/example_plugin +// Built-in pages map to ./static/pages/{name}.html +// Plugin pages load from /plugin/{name} + +var BUILTIN_PAGES = ['dashboard','logs','console','plugins','projects','proxy','files','settings']; +var _pluginPagesCache = null; // {plugin_name: {path, title, icon}} + +function currentRoute() { + var h = location.hash.replace('#', '') || '/dashboard'; + return h.replace(/^\/+/, ''); +} + +function navigateTo(route) { + if (!route) route = 'dashboard'; + location.hash = '#' + route; // triggers hashchange → routePage() +} + +function routePage() { + var r = currentRoute(); + if (r.startsWith('plugins/')) { + loadPluginPage(r.replace('plugins/', '')); + } else if (BUILTIN_PAGES.indexOf(r) >= 0) { + loadPage(r); + } else { + loadPage('dashboard'); + location.hash = '#/dashboard'; + } +} + +// ═══ onload ═══ +window.onload = async function() { + var btn = document.querySelector(".theme-toggle"); + if(btn) btn.textContent = document.documentElement.getAttribute("data-theme") === "light" ? "☀️" : "🌙"; + + try { + var res = await fetch('./api/auth/status', {credentials:'include'}); + if(res.status === 401 || !res.ok) { + sessionStorage.setItem('sensu_redirect_hash', location.hash); + window.location.href = './index.html'; return; + } + var data = await res.json(); + if(!data.authenticated) { + sessionStorage.setItem('sensu_redirect_hash', location.hash); + window.location.href = './index.html'; return; + } + document.getElementById('uname').textContent = data.username || 'Admin'; + } catch(e) { window.location.href = './index.html'; } + + window.addEventListener('hashchange', routePage); + routePage(); // load from current hash (or default to dashboard) +}; + + +// ═══ Page loader (built-in pages) ═══ +async function loadPage(pageName) { + var content = document.getElementById('page-content'); + var bar = document.getElementById('progress'); + + // Sidebar highlight + document.querySelectorAll('.nav-item').forEach(function(el){ el.classList.remove('active'); }); + document.querySelectorAll('.nav-sub-item').forEach(function(el){ el.classList.remove('active'); }); + var navItem = document.querySelector('.nav-item[data-page="'+pageName+'"]'); + if(navItem) navItem.classList.add('active'); + content.style.padding = ''; + + // Progress + bar.classList.add('active'); bar.style.width = '0%'; + await new Promise(function(r){ requestAnimationFrame(function(){ bar.style.width='80%'; setTimeout(r,100); }); }); + + // Destroy previous module + var modName = pageName.charAt(0).toUpperCase() + pageName.slice(1) + 'Module'; + if(window[modName] && window[modName].destroy) { try { window[modName].destroy(); } catch(e){} } + + try { + var resp = await fetch('./static/pages/'+pageName+'.html?t='+Date.now()); + if(!resp.ok) throw new Error('404'); + var html = await resp.text(); + + var scripts = []; + var cleanHtml = html.replace(/]*>([\s\S]*?)<\/script>/gi, function(m,code){ scripts.push(code.trim()); return ''; }); + content.innerHTML = cleanHtml; + + scripts.forEach(function(code){ + try { var s=document.createElement('script'); s.textContent=code; document.head.appendChild(s); document.head.removeChild(s); } + catch(ex){ console.error('Inline script error:', ex); } + }); + + // External JS module + try { + var jsResp = await fetch('./static/pages/'+pageName+'.js?t='+Date.now()); + if(jsResp.ok) { + var jsCode = await jsResp.text(); + var s=document.createElement('script'); s.textContent=jsCode; document.head.appendChild(s); document.head.removeChild(s); + if(window[modName] && window[modName].init) window[modName].init(); + } + } catch(e){ console.error('Page JS error:', pageName, e); } + + bar.style.width = '100%'; + setTimeout(function(){ bar.classList.remove('active'); }, 200); + } catch(e) { + content.innerHTML = '
页面加载失败: '+e.message+'
'; + bar.style.background='var(--error)'; + setTimeout(function(){ bar.style.width='100%'; setTimeout(function(){ bar.classList.remove('active'); bar.style.background='var(--primary)'; },200); },100); + } +} + + +// ═══ Plugin page loader ═══ +async function loadPluginPage(pluginName) { + var content = document.getElementById('page-content'); + var bar = document.getElementById('progress'); + + // Highlight sub-item + document.querySelectorAll('.nav-item').forEach(function(el){ el.classList.remove('active'); }); + document.querySelectorAll('.nav-sub-item').forEach(function(el){ el.classList.remove('active'); }); + var sub = document.querySelector('.nav-sub-item[data-plugin="'+pluginName+'"]'); + if(sub) sub.classList.add('active'); + + bar.classList.add('active'); bar.style.width='0%'; + await new Promise(function(r){ requestAnimationFrame(function(){ bar.style.width='80%'; setTimeout(r,100); }); }); + + // Also load plugin pages list if not yet + if(!_pluginPagesCache) await fetchPluginPages(); + + try { + var resp = await fetch('/plugin/'+pluginName+'?t='+Date.now(), {credentials:'include'}); + if(!resp.ok) throw new Error(resp.status); + var html = await resp.text(); + + var styles = ''; + html.replace(/]*>([\s\S]*?)<\/style>/gi, function(m,code){ styles+=code; return ''; }); + + var bodyMatch = html.match(/]*>([\s\S]*)<\/body>/i); + var clean = bodyMatch ? bodyMatch[1] : html.replace(/]*>|<\/html>|]*>[\s\S]*?<\/head>|]*>|<\/body>/gi, ''); + + var scripts = []; + clean = clean.replace(/]*>([\s\S]*?)<\/script>/gi, function(m,code){ scripts.push(code.trim()); return ''; }); + + // Scope plugin styles: replace body selector with .plugin-page-root to avoid + // styling the main page's element + styles = styles.replace(/body\s*\{/gi, '.plugin-page-root{').replace(/body\b/gi, '.plugin-page-root'); + + content.style.padding = '0'; + content.innerHTML = '
'+clean+'
'; + + scripts.forEach(function(code){ + try { var s=document.createElement('script'); s.textContent=code; document.head.appendChild(s); document.head.removeChild(s); } + catch(ex){ console.error('Plugin script error:', ex); } + }); + + bar.style.width='100%'; + setTimeout(function(){ bar.classList.remove('active'); }, 200); + } catch(ex) { + content.innerHTML = '
插件页面加载失败: '+ex.message+'
'; + bar.style.background='var(--error)'; + setTimeout(function(){ bar.style.width='100%'; setTimeout(function(){ bar.classList.remove('active'); bar.style.background='var(--primary)'; },200); },100); + } +} + + +// ═══ Global file/dir picker (in-page overlay, no iframe) ═══ +window.pickPath = function(mode, callback) { + var id = 'picker_' + Date.now(); + + // Create modal overlay + var overlay = document.createElement('div'); + overlay.id = 'fm-picker-overlay'; + overlay.style.cssText = 'position:fixed;inset:0;z-index:500;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center'; + + var box = document.createElement('div'); + box.style.cssText = 'background:var(--md-sys-color-surface-container-high);border-radius:var(--shape-lg);width:720px;height:520px;max-width:95vw;max-height:85vh;display:flex;flex-direction:column;overflow:hidden;box-shadow:var(--md-sys-elevation-5)'; + + // Header with close button + var header = document.createElement('div'); + header.style.cssText = 'display:flex;justify-content:space-between;align-items:center;padding:12px 16px;border-bottom:1px solid var(--outline);flex-shrink:0'; + var headerTitle = document.createElement('span'); + headerTitle.style.cssText = 'font-weight:500;color:var(--primary)'; + headerTitle.textContent = '📂 选择' + (mode==='file'?'文件':'目录'); + header.appendChild(headerTitle); + var closeBtn = document.createElement('span'); + closeBtn.style.cssText = 'cursor:pointer;font-size:20px;line-height:1;padding:4px 8px;border-radius:4px;color:var(--text-dim)'; + closeBtn.textContent = '✕'; + closeBtn.onclick = function(){ closePicker(); }; + header.appendChild(closeBtn); + box.appendChild(header); + + // Content area — load file manager via fetch + var contentArea = document.createElement('div'); + contentArea.style.cssText = 'flex:1;overflow:hidden;display:flex;flex-direction:column'; + box.appendChild(contentArea); + overlay.appendChild(box); + document.body.appendChild(overlay); + + // Close on backdrop click + overlay.addEventListener('click', function(e){ if(e.target===overlay) closePicker(); }); + + function closePicker() { + if(overlay.parentNode) overlay.parentNode.removeChild(overlay); + } + + // Load file manager HTML into content area + fetch('./static/pages/files.html?t=' + Date.now()) + .then(function(r){ return r.text(); }) + .then(function(html){ + // Strip meta and CSS link (already available in main page) + html = html.replace(/]*>/gi, '').replace(/]*>/gi, ''); + // Extract inline scripts + var scripts = []; + var clean = html.replace(/]*>([\s\S]*?)<\/script>/gi, function(m,code){ scripts.push(code.trim()); return ''; }); + contentArea.innerHTML = clean; + // Execute scripts + scripts.forEach(function(code){ + try { var s=document.createElement('script'); s.textContent=code; document.head.appendChild(s); document.head.removeChild(s); } + catch(ex){} + }); + // Load files.js if not already loaded + // Fix container height for overlay + var fmContainer = contentArea.querySelector('.fm-container'); + if(fmContainer) fmContainer.style.height = '100%'; + function doInit(){ + window.FilesModule._apiBase = ''; + window.FilesModule.currentPath = ''; + window.FilesModule.pickerMode = mode || 'dir'; + window.FilesModule.pickerCallback = id; + window.FilesModule._scope = contentArea; + window.FilesModule._getEl = function(sel){ return contentArea.querySelector(sel); }; + // Override _pickResult to close overlay instead of alert + window.FilesModule._pickResult = function(path){ + closePicker(); + if(callback) callback(path || window.FilesModule.currentPath || '/'); + }; + if(window.FilesModule.destroy) window.FilesModule.destroy(); + if(window.FilesModule.init) window.FilesModule.init(); + } + // Override picker actions to close overlay and call callback + window._fmPick = function(){ + var p = window.FilesModule && window.FilesModule.currentPath || '/'; + closePicker(); + if(callback) callback(p); + }; + window._fmCancel = function(){ closePicker(); }; + + if(window.FilesModule){ doInit(); } + else { + var s=document.createElement('script'); + s.src='./static/pages/files.js?v=0603'; + s.onload=doInit; + document.head.appendChild(s); + } + }) + .catch(function(e){ + contentArea.innerHTML = '
加载失败: '+e.message+'
'; + }); +}; + +// ═══ Sidebar toggle ═══ +function toggleSidebar() { + document.getElementById('app').classList.toggle('collapsed'); +} + +// ═══ Logout ═══ +async function doLogout() { + await fetch('./api/logout', {method:'POST', credentials:'include'}); + window.location.href = './index.html'; +} + + +// ═══ Plugin pages expand/collapse ═══ +async function fetchPluginPages() { + try { + var res = await fetch('./api/plugin-pages', {credentials:'include'}); + var data = await res.json(); + _pluginPagesCache = {}; + (data.pages||[]).forEach(function(p){ _pluginPagesCache[p.plugin] = p; }); + return _pluginPagesCache; + } catch(e) { return {}; } +} + +async function togglePluginPages() { + var body = document.getElementById('nav-plugins-body'); + var arrow = document.getElementById('nav-plugins-arrow'); + var isOpen = body.style.display !== 'none'; + if(isOpen){ body.style.display='none'; arrow.style.transform=''; return; } + body.style.display='block'; + arrow.style.transform='rotate(180deg)'; + + if(!_pluginPagesCache) await fetchPluginPages(); + var pages = _pluginPagesCache || {}; + var names = Object.keys(pages); + if(names.length) { + var html = ''; + names.forEach(function(name){ + var p = pages[name]; + html += ''; + }); + body.innerHTML = html; + } +} + +// ═══ Click delegation ═══ +document.addEventListener('click', function(e) { + // Built-in nav + var nav = e.target.closest('.nav-item'); + if(nav && nav.dataset.page) { + navigateTo(nav.dataset.page); + e.preventDefault(); + return; + } + // Plugin sub-item + var sub = e.target.closest('.nav-sub-item'); + if(sub && sub.dataset.plugin) { + navigateTo('plugins/' + sub.dataset.plugin); + e.preventDefault(); + } +}); diff --git a/static/web_panel/js/app_v2.js b/static/web_panel/js/app_v2.js new file mode 100644 index 0000000..08594cd --- /dev/null +++ b/static/web_panel/js/app_v2.js @@ -0,0 +1,360 @@ +/* ── SenSu WebUI — Theme + Routing + Page Loader ── */ + +// ═══ 主题初始化 (必须在渲染前, 防闪白) ═══ +(function(){ + var s = localStorage.getItem("sensu-theme") || "dark"; + document.documentElement.setAttribute("data-theme", s); +})(); + +// ═══ 工具函数 ═══ +function getCookie(name) { + var match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)')); + return match ? match[2] : ''; +} + +// ── 顶部版本检查 ── +async function checkTopUpdate() { + var btn = document.getElementById("top-ver-btn"); + try { + var r = await fetch("./api/updates"), d = await r.json(); + var ver = d.current_version || d.latest_version || ""; + btn.textContent = ver ? ver : "v?"; + if (d.update_available) { + btn.textContent = "↻ " + d.latest_version + " 可用"; + btn.style.color = "var(--success)"; + btn.title = "新版本可用! " + d.latest_version; + } else if (ver) { + btn.style.color = ""; + btn.title = "已是最新 " + ver; + } + } catch(e) {} +} +setTimeout(checkTopUpdate, 3000); +setInterval(checkTopUpdate, 600000); // 10min + +window.toggleTheme = function(){ + var t = document.documentElement.getAttribute("data-theme") === "light" ? "dark" : "light"; + document.documentElement.setAttribute("data-theme", t); + localStorage.setItem("sensu-theme", t); + var btn = document.querySelector(".theme-toggle"); + if(btn) btn.textContent = t === "light" ? "☀️" : "🌙"; +}; + + +// ═══ 路由系统 ═══ +// Hash format: #/dashboard | #/files | #/plugins/example_plugin +// Built-in pages map to ./static/pages/{name}.html +// Plugin pages load from /plugin/{name} + +var BUILTIN_PAGES = ['dashboard','logs','console','plugins','projects','proxy','files','settings']; +var _pluginPagesCache = null; // {plugin_name: {path, title, icon}} + +function currentRoute() { + var h = location.hash.replace('#', '') || '/dashboard'; + return h.replace(/^\/+/, ''); +} + +function navigateTo(route) { + if (!route) route = 'dashboard'; + location.hash = '#' + route; // triggers hashchange → routePage() +} + +function routePage() { + var r = currentRoute(); + if (r.startsWith('plugins/')) { + loadPluginPage(r.replace('plugins/', '')); + } else if (BUILTIN_PAGES.indexOf(r) >= 0) { + loadPage(r); + } else { + loadPage('dashboard'); + location.hash = '#/dashboard'; + } +} + +// ═══ onload ═══ +window.onload = async function() { + var btn = document.querySelector(".theme-toggle"); + if(btn) btn.textContent = document.documentElement.getAttribute("data-theme") === "light" ? "☀️" : "🌙"; + + try { + var res = await fetch('./api/auth/status', {credentials:'include'}); + if(res.status === 401 || !res.ok) { + sessionStorage.setItem('sensu_redirect_hash', location.hash); + window.location.href = './index.html'; return; + } + var data = await res.json(); + if(!data.authenticated) { + sessionStorage.setItem('sensu_redirect_hash', location.hash); + window.location.href = './index.html'; return; + } + document.getElementById('uname').textContent = data.username || 'Admin'; + } catch(e) { window.location.href = './index.html'; } + + window.addEventListener('hashchange', routePage); + routePage(); // load from current hash (or default to dashboard) +}; + + +// ═══ Page loader (built-in pages) ═══ +async function loadPage(pageName) { + var content = document.getElementById('page-content'); + var bar = document.getElementById('progress'); + + // Sidebar highlight + document.querySelectorAll('.nav-item').forEach(function(el){ el.classList.remove('active'); }); + document.querySelectorAll('.nav-sub-item').forEach(function(el){ el.classList.remove('active'); }); + var navItem = document.querySelector('.nav-item[data-page="'+pageName+'"]'); + if(navItem) navItem.classList.add('active'); + content.style.padding = ''; + + // Progress + bar.classList.add('active'); bar.style.width = '0%'; + await new Promise(function(r){ requestAnimationFrame(function(){ bar.style.width='80%'; setTimeout(r,100); }); }); + + // Destroy previous module + var modName = pageName.charAt(0).toUpperCase() + pageName.slice(1) + 'Module'; + if(window[modName] && window[modName].destroy) { try { window[modName].destroy(); } catch(e){} } + + try { + var resp = await fetch('./static/pages/'+pageName+'.html?t='+Date.now()); + if(!resp.ok) throw new Error('404'); + var html = await resp.text(); + + var scripts = []; + var cleanHtml = html.replace(/]*>([\s\S]*?)<\/script>/gi, function(m,code){ scripts.push(code.trim()); return ''; }); + content.innerHTML = cleanHtml; + + scripts.forEach(function(code){ + try { var s=document.createElement('script'); s.textContent=code; document.head.appendChild(s); document.head.removeChild(s); } + catch(ex){ console.error('Inline script error:', ex); } + }); + + // External JS module + try { + var jsResp = await fetch('./static/pages/'+pageName+'.js?t='+Date.now()); + if(jsResp.ok) { + var jsCode = await jsResp.text(); + var s=document.createElement('script'); s.textContent=jsCode; document.head.appendChild(s); document.head.removeChild(s); + if(window[modName] && window[modName].init) window[modName].init(); + } + } catch(e){ console.error('Page JS error:', pageName, e); } + + bar.style.width = '100%'; + setTimeout(function(){ bar.classList.remove('active'); }, 200); + } catch(e) { + content.innerHTML = '
页面加载失败: '+e.message+'
'; + bar.style.background='var(--error)'; + setTimeout(function(){ bar.style.width='100%'; setTimeout(function(){ bar.classList.remove('active'); bar.style.background='var(--primary)'; },200); },100); + } +} + + +// ═══ Plugin page loader ═══ +async function loadPluginPage(pluginName) { + var content = document.getElementById('page-content'); + var bar = document.getElementById('progress'); + + // Highlight sub-item + document.querySelectorAll('.nav-item').forEach(function(el){ el.classList.remove('active'); }); + document.querySelectorAll('.nav-sub-item').forEach(function(el){ el.classList.remove('active'); }); + var sub = document.querySelector('.nav-sub-item[data-plugin="'+pluginName+'"]'); + if(sub) sub.classList.add('active'); + + bar.classList.add('active'); bar.style.width='0%'; + await new Promise(function(r){ requestAnimationFrame(function(){ bar.style.width='80%'; setTimeout(r,100); }); }); + + // Also load plugin pages list if not yet + if(!_pluginPagesCache) await fetchPluginPages(); + + try { + var resp = await fetch('/plugin/'+pluginName+'?t='+Date.now(), {credentials:'include'}); + if(!resp.ok) throw new Error(resp.status); + var html = await resp.text(); + + var styles = ''; + html.replace(/]*>([\s\S]*?)<\/style>/gi, function(m,code){ styles+=code; return ''; }); + + var bodyMatch = html.match(/]*>([\s\S]*)<\/body>/i); + var clean = bodyMatch ? bodyMatch[1] : html.replace(/]*>|<\/html>|]*>[\s\S]*?<\/head>|]*>|<\/body>/gi, ''); + + var scripts = []; + clean = clean.replace(/]*>([\s\S]*?)<\/script>/gi, function(m,code){ scripts.push(code.trim()); return ''; }); + + // Scope plugin styles: replace body selector with .plugin-page-root to avoid + // styling the main page's element + styles = styles.replace(/body\s*\{/gi, '.plugin-page-root{').replace(/body\b/gi, '.plugin-page-root'); + + content.style.padding = '0'; + content.innerHTML = '
'+clean+'
'; + + scripts.forEach(function(code){ + try { var s=document.createElement('script'); s.textContent=code; document.head.appendChild(s); document.head.removeChild(s); } + catch(ex){ console.error('Plugin script error:', ex); } + }); + + bar.style.width='100%'; + setTimeout(function(){ bar.classList.remove('active'); }, 200); + } catch(ex) { + content.innerHTML = '
插件页面加载失败: '+ex.message+'
'; + bar.style.background='var(--error)'; + setTimeout(function(){ bar.style.width='100%'; setTimeout(function(){ bar.classList.remove('active'); bar.style.background='var(--primary)'; },200); },100); + } +} + + +// ═══ Global file/dir picker (in-page overlay, no iframe) ═══ +window.pickPath = function(mode, callback) { + var id = 'picker_' + Date.now(); + + // Create modal overlay + var overlay = document.createElement('div'); + overlay.id = 'fm-picker-overlay'; + overlay.style.cssText = 'position:fixed;inset:0;z-index:500;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center'; + + var box = document.createElement('div'); + box.style.cssText = 'background:var(--md-sys-color-surface-container-high);border-radius:var(--shape-lg);width:720px;height:520px;max-width:95vw;max-height:85vh;display:flex;flex-direction:column;overflow:hidden;box-shadow:var(--md-sys-elevation-5)'; + + // Header with close button + var header = document.createElement('div'); + header.style.cssText = 'display:flex;justify-content:space-between;align-items:center;padding:12px 16px;border-bottom:1px solid var(--outline);flex-shrink:0'; + var headerTitle = document.createElement('span'); + headerTitle.style.cssText = 'font-weight:500;color:var(--primary)'; + headerTitle.textContent = '📂 选择' + (mode==='file'?'文件':'目录'); + header.appendChild(headerTitle); + var closeBtn = document.createElement('span'); + closeBtn.style.cssText = 'cursor:pointer;font-size:20px;line-height:1;padding:4px 8px;border-radius:4px;color:var(--text-dim)'; + closeBtn.textContent = '✕'; + closeBtn.onclick = function(){ closePicker(); }; + header.appendChild(closeBtn); + box.appendChild(header); + + // Content area — load file manager via fetch + var contentArea = document.createElement('div'); + contentArea.style.cssText = 'flex:1;overflow:hidden;display:flex;flex-direction:column'; + box.appendChild(contentArea); + overlay.appendChild(box); + document.body.appendChild(overlay); + + // Close on backdrop click + overlay.addEventListener('click', function(e){ if(e.target===overlay) closePicker(); }); + + function closePicker() { + if(overlay.parentNode) overlay.parentNode.removeChild(overlay); + } + + // Load file manager HTML into content area + fetch('./static/pages/files.html?t=' + Date.now()) + .then(function(r){ return r.text(); }) + .then(function(html){ + // Strip meta and CSS link (already available in main page) + html = html.replace(/]*>/gi, '').replace(/]*>/gi, ''); + // Extract inline scripts + var scripts = []; + var clean = html.replace(/]*>([\s\S]*?)<\/script>/gi, function(m,code){ scripts.push(code.trim()); return ''; }); + contentArea.innerHTML = clean; + // Execute scripts + scripts.forEach(function(code){ + try { var s=document.createElement('script'); s.textContent=code; document.head.appendChild(s); document.head.removeChild(s); } + catch(ex){} + }); + // Load files.js if not already loaded + // Fix container height for overlay + var fmContainer = contentArea.querySelector('.fm-container'); + if(fmContainer) fmContainer.style.height = '100%'; + function doInit(){ + window.FilesModule._apiBase = ''; + window.FilesModule.currentPath = ''; + window.FilesModule.pickerMode = mode || 'dir'; + window.FilesModule.pickerCallback = id; + window.FilesModule._scope = contentArea; + window.FilesModule._getEl = function(sel){ return contentArea.querySelector(sel); }; + // Override _pickResult to close overlay instead of alert + window.FilesModule._pickResult = function(path){ + closePicker(); + if(callback) callback(path || window.FilesModule.currentPath || '/'); + }; + if(window.FilesModule.destroy) window.FilesModule.destroy(); + if(window.FilesModule.init) window.FilesModule.init(); + } + // Override picker actions to close overlay and call callback + window._fmPick = function(){ + var p = window.FilesModule && window.FilesModule.currentPath || '/'; + closePicker(); + if(callback) callback(p); + }; + window._fmCancel = function(){ closePicker(); }; + + if(window.FilesModule){ doInit(); } + else { + var s=document.createElement('script'); + s.src='./static/pages/files.js?v=0603'; + s.onload=doInit; + document.head.appendChild(s); + } + }) + .catch(function(e){ + contentArea.innerHTML = '
加载失败: '+e.message+'
'; + }); +}; + +// ═══ Sidebar toggle ═══ +function toggleSidebar() { + document.getElementById('app').classList.toggle('collapsed'); +} + +// ═══ Logout ═══ +async function doLogout() { + await fetch('./api/logout', {method:'POST', credentials:'include'}); + window.location.href = './index.html'; +} + + +// ═══ Plugin pages expand/collapse ═══ +async function fetchPluginPages() { + try { + var res = await fetch('./api/plugin-pages', {credentials:'include'}); + var data = await res.json(); + _pluginPagesCache = {}; + (data.pages||[]).forEach(function(p){ _pluginPagesCache[p.plugin] = p; }); + return _pluginPagesCache; + } catch(e) { return {}; } +} + +async function togglePluginPages() { + var body = document.getElementById('nav-plugins-body'); + var arrow = document.getElementById('nav-plugins-arrow'); + var isOpen = body.style.display !== 'none'; + if(isOpen){ body.style.display='none'; arrow.style.transform=''; return; } + body.style.display='block'; + arrow.style.transform='rotate(180deg)'; + + if(!_pluginPagesCache) await fetchPluginPages(); + var pages = _pluginPagesCache || {}; + var names = Object.keys(pages); + if(names.length) { + var html = ''; + names.forEach(function(name){ + var p = pages[name]; + html += ''; + }); + body.innerHTML = html; + } +} + +// ═══ Click delegation ═══ +document.addEventListener('click', function(e) { + // Built-in nav + var nav = e.target.closest('.nav-item'); + if(nav && nav.dataset.page) { + navigateTo(nav.dataset.page); + e.preventDefault(); + return; + } + // Plugin sub-item + var sub = e.target.closest('.nav-sub-item'); + if(sub && sub.dataset.plugin) { + navigateTo('plugins/' + sub.dataset.plugin); + e.preventDefault(); + } +}); diff --git a/static/web_panel/js/app_v3.js b/static/web_panel/js/app_v3.js new file mode 100644 index 0000000..08594cd --- /dev/null +++ b/static/web_panel/js/app_v3.js @@ -0,0 +1,360 @@ +/* ── SenSu WebUI — Theme + Routing + Page Loader ── */ + +// ═══ 主题初始化 (必须在渲染前, 防闪白) ═══ +(function(){ + var s = localStorage.getItem("sensu-theme") || "dark"; + document.documentElement.setAttribute("data-theme", s); +})(); + +// ═══ 工具函数 ═══ +function getCookie(name) { + var match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)')); + return match ? match[2] : ''; +} + +// ── 顶部版本检查 ── +async function checkTopUpdate() { + var btn = document.getElementById("top-ver-btn"); + try { + var r = await fetch("./api/updates"), d = await r.json(); + var ver = d.current_version || d.latest_version || ""; + btn.textContent = ver ? ver : "v?"; + if (d.update_available) { + btn.textContent = "↻ " + d.latest_version + " 可用"; + btn.style.color = "var(--success)"; + btn.title = "新版本可用! " + d.latest_version; + } else if (ver) { + btn.style.color = ""; + btn.title = "已是最新 " + ver; + } + } catch(e) {} +} +setTimeout(checkTopUpdate, 3000); +setInterval(checkTopUpdate, 600000); // 10min + +window.toggleTheme = function(){ + var t = document.documentElement.getAttribute("data-theme") === "light" ? "dark" : "light"; + document.documentElement.setAttribute("data-theme", t); + localStorage.setItem("sensu-theme", t); + var btn = document.querySelector(".theme-toggle"); + if(btn) btn.textContent = t === "light" ? "☀️" : "🌙"; +}; + + +// ═══ 路由系统 ═══ +// Hash format: #/dashboard | #/files | #/plugins/example_plugin +// Built-in pages map to ./static/pages/{name}.html +// Plugin pages load from /plugin/{name} + +var BUILTIN_PAGES = ['dashboard','logs','console','plugins','projects','proxy','files','settings']; +var _pluginPagesCache = null; // {plugin_name: {path, title, icon}} + +function currentRoute() { + var h = location.hash.replace('#', '') || '/dashboard'; + return h.replace(/^\/+/, ''); +} + +function navigateTo(route) { + if (!route) route = 'dashboard'; + location.hash = '#' + route; // triggers hashchange → routePage() +} + +function routePage() { + var r = currentRoute(); + if (r.startsWith('plugins/')) { + loadPluginPage(r.replace('plugins/', '')); + } else if (BUILTIN_PAGES.indexOf(r) >= 0) { + loadPage(r); + } else { + loadPage('dashboard'); + location.hash = '#/dashboard'; + } +} + +// ═══ onload ═══ +window.onload = async function() { + var btn = document.querySelector(".theme-toggle"); + if(btn) btn.textContent = document.documentElement.getAttribute("data-theme") === "light" ? "☀️" : "🌙"; + + try { + var res = await fetch('./api/auth/status', {credentials:'include'}); + if(res.status === 401 || !res.ok) { + sessionStorage.setItem('sensu_redirect_hash', location.hash); + window.location.href = './index.html'; return; + } + var data = await res.json(); + if(!data.authenticated) { + sessionStorage.setItem('sensu_redirect_hash', location.hash); + window.location.href = './index.html'; return; + } + document.getElementById('uname').textContent = data.username || 'Admin'; + } catch(e) { window.location.href = './index.html'; } + + window.addEventListener('hashchange', routePage); + routePage(); // load from current hash (or default to dashboard) +}; + + +// ═══ Page loader (built-in pages) ═══ +async function loadPage(pageName) { + var content = document.getElementById('page-content'); + var bar = document.getElementById('progress'); + + // Sidebar highlight + document.querySelectorAll('.nav-item').forEach(function(el){ el.classList.remove('active'); }); + document.querySelectorAll('.nav-sub-item').forEach(function(el){ el.classList.remove('active'); }); + var navItem = document.querySelector('.nav-item[data-page="'+pageName+'"]'); + if(navItem) navItem.classList.add('active'); + content.style.padding = ''; + + // Progress + bar.classList.add('active'); bar.style.width = '0%'; + await new Promise(function(r){ requestAnimationFrame(function(){ bar.style.width='80%'; setTimeout(r,100); }); }); + + // Destroy previous module + var modName = pageName.charAt(0).toUpperCase() + pageName.slice(1) + 'Module'; + if(window[modName] && window[modName].destroy) { try { window[modName].destroy(); } catch(e){} } + + try { + var resp = await fetch('./static/pages/'+pageName+'.html?t='+Date.now()); + if(!resp.ok) throw new Error('404'); + var html = await resp.text(); + + var scripts = []; + var cleanHtml = html.replace(/]*>([\s\S]*?)<\/script>/gi, function(m,code){ scripts.push(code.trim()); return ''; }); + content.innerHTML = cleanHtml; + + scripts.forEach(function(code){ + try { var s=document.createElement('script'); s.textContent=code; document.head.appendChild(s); document.head.removeChild(s); } + catch(ex){ console.error('Inline script error:', ex); } + }); + + // External JS module + try { + var jsResp = await fetch('./static/pages/'+pageName+'.js?t='+Date.now()); + if(jsResp.ok) { + var jsCode = await jsResp.text(); + var s=document.createElement('script'); s.textContent=jsCode; document.head.appendChild(s); document.head.removeChild(s); + if(window[modName] && window[modName].init) window[modName].init(); + } + } catch(e){ console.error('Page JS error:', pageName, e); } + + bar.style.width = '100%'; + setTimeout(function(){ bar.classList.remove('active'); }, 200); + } catch(e) { + content.innerHTML = '
页面加载失败: '+e.message+'
'; + bar.style.background='var(--error)'; + setTimeout(function(){ bar.style.width='100%'; setTimeout(function(){ bar.classList.remove('active'); bar.style.background='var(--primary)'; },200); },100); + } +} + + +// ═══ Plugin page loader ═══ +async function loadPluginPage(pluginName) { + var content = document.getElementById('page-content'); + var bar = document.getElementById('progress'); + + // Highlight sub-item + document.querySelectorAll('.nav-item').forEach(function(el){ el.classList.remove('active'); }); + document.querySelectorAll('.nav-sub-item').forEach(function(el){ el.classList.remove('active'); }); + var sub = document.querySelector('.nav-sub-item[data-plugin="'+pluginName+'"]'); + if(sub) sub.classList.add('active'); + + bar.classList.add('active'); bar.style.width='0%'; + await new Promise(function(r){ requestAnimationFrame(function(){ bar.style.width='80%'; setTimeout(r,100); }); }); + + // Also load plugin pages list if not yet + if(!_pluginPagesCache) await fetchPluginPages(); + + try { + var resp = await fetch('/plugin/'+pluginName+'?t='+Date.now(), {credentials:'include'}); + if(!resp.ok) throw new Error(resp.status); + var html = await resp.text(); + + var styles = ''; + html.replace(/]*>([\s\S]*?)<\/style>/gi, function(m,code){ styles+=code; return ''; }); + + var bodyMatch = html.match(/]*>([\s\S]*)<\/body>/i); + var clean = bodyMatch ? bodyMatch[1] : html.replace(/]*>|<\/html>|]*>[\s\S]*?<\/head>|]*>|<\/body>/gi, ''); + + var scripts = []; + clean = clean.replace(/]*>([\s\S]*?)<\/script>/gi, function(m,code){ scripts.push(code.trim()); return ''; }); + + // Scope plugin styles: replace body selector with .plugin-page-root to avoid + // styling the main page's element + styles = styles.replace(/body\s*\{/gi, '.plugin-page-root{').replace(/body\b/gi, '.plugin-page-root'); + + content.style.padding = '0'; + content.innerHTML = '
'+clean+'
'; + + scripts.forEach(function(code){ + try { var s=document.createElement('script'); s.textContent=code; document.head.appendChild(s); document.head.removeChild(s); } + catch(ex){ console.error('Plugin script error:', ex); } + }); + + bar.style.width='100%'; + setTimeout(function(){ bar.classList.remove('active'); }, 200); + } catch(ex) { + content.innerHTML = '
插件页面加载失败: '+ex.message+'
'; + bar.style.background='var(--error)'; + setTimeout(function(){ bar.style.width='100%'; setTimeout(function(){ bar.classList.remove('active'); bar.style.background='var(--primary)'; },200); },100); + } +} + + +// ═══ Global file/dir picker (in-page overlay, no iframe) ═══ +window.pickPath = function(mode, callback) { + var id = 'picker_' + Date.now(); + + // Create modal overlay + var overlay = document.createElement('div'); + overlay.id = 'fm-picker-overlay'; + overlay.style.cssText = 'position:fixed;inset:0;z-index:500;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center'; + + var box = document.createElement('div'); + box.style.cssText = 'background:var(--md-sys-color-surface-container-high);border-radius:var(--shape-lg);width:720px;height:520px;max-width:95vw;max-height:85vh;display:flex;flex-direction:column;overflow:hidden;box-shadow:var(--md-sys-elevation-5)'; + + // Header with close button + var header = document.createElement('div'); + header.style.cssText = 'display:flex;justify-content:space-between;align-items:center;padding:12px 16px;border-bottom:1px solid var(--outline);flex-shrink:0'; + var headerTitle = document.createElement('span'); + headerTitle.style.cssText = 'font-weight:500;color:var(--primary)'; + headerTitle.textContent = '📂 选择' + (mode==='file'?'文件':'目录'); + header.appendChild(headerTitle); + var closeBtn = document.createElement('span'); + closeBtn.style.cssText = 'cursor:pointer;font-size:20px;line-height:1;padding:4px 8px;border-radius:4px;color:var(--text-dim)'; + closeBtn.textContent = '✕'; + closeBtn.onclick = function(){ closePicker(); }; + header.appendChild(closeBtn); + box.appendChild(header); + + // Content area — load file manager via fetch + var contentArea = document.createElement('div'); + contentArea.style.cssText = 'flex:1;overflow:hidden;display:flex;flex-direction:column'; + box.appendChild(contentArea); + overlay.appendChild(box); + document.body.appendChild(overlay); + + // Close on backdrop click + overlay.addEventListener('click', function(e){ if(e.target===overlay) closePicker(); }); + + function closePicker() { + if(overlay.parentNode) overlay.parentNode.removeChild(overlay); + } + + // Load file manager HTML into content area + fetch('./static/pages/files.html?t=' + Date.now()) + .then(function(r){ return r.text(); }) + .then(function(html){ + // Strip meta and CSS link (already available in main page) + html = html.replace(/]*>/gi, '').replace(/]*>/gi, ''); + // Extract inline scripts + var scripts = []; + var clean = html.replace(/]*>([\s\S]*?)<\/script>/gi, function(m,code){ scripts.push(code.trim()); return ''; }); + contentArea.innerHTML = clean; + // Execute scripts + scripts.forEach(function(code){ + try { var s=document.createElement('script'); s.textContent=code; document.head.appendChild(s); document.head.removeChild(s); } + catch(ex){} + }); + // Load files.js if not already loaded + // Fix container height for overlay + var fmContainer = contentArea.querySelector('.fm-container'); + if(fmContainer) fmContainer.style.height = '100%'; + function doInit(){ + window.FilesModule._apiBase = ''; + window.FilesModule.currentPath = ''; + window.FilesModule.pickerMode = mode || 'dir'; + window.FilesModule.pickerCallback = id; + window.FilesModule._scope = contentArea; + window.FilesModule._getEl = function(sel){ return contentArea.querySelector(sel); }; + // Override _pickResult to close overlay instead of alert + window.FilesModule._pickResult = function(path){ + closePicker(); + if(callback) callback(path || window.FilesModule.currentPath || '/'); + }; + if(window.FilesModule.destroy) window.FilesModule.destroy(); + if(window.FilesModule.init) window.FilesModule.init(); + } + // Override picker actions to close overlay and call callback + window._fmPick = function(){ + var p = window.FilesModule && window.FilesModule.currentPath || '/'; + closePicker(); + if(callback) callback(p); + }; + window._fmCancel = function(){ closePicker(); }; + + if(window.FilesModule){ doInit(); } + else { + var s=document.createElement('script'); + s.src='./static/pages/files.js?v=0603'; + s.onload=doInit; + document.head.appendChild(s); + } + }) + .catch(function(e){ + contentArea.innerHTML = '
加载失败: '+e.message+'
'; + }); +}; + +// ═══ Sidebar toggle ═══ +function toggleSidebar() { + document.getElementById('app').classList.toggle('collapsed'); +} + +// ═══ Logout ═══ +async function doLogout() { + await fetch('./api/logout', {method:'POST', credentials:'include'}); + window.location.href = './index.html'; +} + + +// ═══ Plugin pages expand/collapse ═══ +async function fetchPluginPages() { + try { + var res = await fetch('./api/plugin-pages', {credentials:'include'}); + var data = await res.json(); + _pluginPagesCache = {}; + (data.pages||[]).forEach(function(p){ _pluginPagesCache[p.plugin] = p; }); + return _pluginPagesCache; + } catch(e) { return {}; } +} + +async function togglePluginPages() { + var body = document.getElementById('nav-plugins-body'); + var arrow = document.getElementById('nav-plugins-arrow'); + var isOpen = body.style.display !== 'none'; + if(isOpen){ body.style.display='none'; arrow.style.transform=''; return; } + body.style.display='block'; + arrow.style.transform='rotate(180deg)'; + + if(!_pluginPagesCache) await fetchPluginPages(); + var pages = _pluginPagesCache || {}; + var names = Object.keys(pages); + if(names.length) { + var html = ''; + names.forEach(function(name){ + var p = pages[name]; + html += ''; + }); + body.innerHTML = html; + } +} + +// ═══ Click delegation ═══ +document.addEventListener('click', function(e) { + // Built-in nav + var nav = e.target.closest('.nav-item'); + if(nav && nav.dataset.page) { + navigateTo(nav.dataset.page); + e.preventDefault(); + return; + } + // Plugin sub-item + var sub = e.target.closest('.nav-sub-item'); + if(sub && sub.dataset.plugin) { + navigateTo('plugins/' + sub.dataset.plugin); + e.preventDefault(); + } +}); diff --git a/static/web_panel/js/chart.js b/static/web_panel/js/chart.js new file mode 100644 index 0000000..8bd02f6 --- /dev/null +++ b/static/web_panel/js/chart.js @@ -0,0 +1,130 @@ +class MiniChart { + constructor(canvasId, color = '#7aa2f7') { + this.canvas = document.getElementById(canvasId); + this.ctx = this.canvas.getContext('2d'); + this.color = color; + this.data = new Array(60).fill(0); // 60秒历史 + this.maxVal = 100; + + this.resize(); + window.addEventListener('resize', () => this.resize()); + } + + resize() { + var rect = this.canvas.parentElement.getBoundingClientRect(); + this.canvas.width = rect.width - 24; + this.canvas.height = 60; + this.draw(); + } + + update(val) { + this.data.push(val); + if (this.data.length > 60) this.data.shift(); + this.maxVal = Math.max.apply(null, this.data.concat([100])); + this.draw(); + } + + draw() { + if (!this.ctx) return; + var ctx = this.ctx; + var w = this.canvas.width; + var h = this.canvas.height; + + ctx.clearRect(0, 0, w, h); + + ctx.strokeStyle = this.color; + ctx.lineWidth = 2; + ctx.beginPath(); + + var self = this; + this.data.forEach(function(v, i) { + var x = (i / 59) * w; + var y = h - (v / self.maxVal) * (h - 10); + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + }); + ctx.stroke(); + + // 填充渐变 + ctx.lineTo(w, h); + ctx.lineTo(0, h); + ctx.fillStyle = this.color + '20'; + ctx.fill(); + } +} + +/* ── Dual-line chart (e.g. download + upload speed) ── */ +class DualLineChart { + constructor(canvasId, colorA, colorB) { + this.canvas = document.getElementById(canvasId); + this.ctx = this.canvas.getContext('2d'); + this.colorA = colorA || '#bb9af7'; // solid line (download) + this.colorB = colorB || '#c0a8f0'; // dashed line (upload) + this.dataA = new Array(60).fill(0); + this.dataB = new Array(60).fill(0); + this.maxVal = 100; + this.applyResize(); + window.addEventListener('resize', () => this.applyResize()); + } + + applyResize() { + var rect = this.canvas.parentElement.getBoundingClientRect(); + this.canvas.width = rect.width - 24; + this.canvas.height = 60; + this.draw(); + } + + update(valA, valB) { + this.dataA.push(valA); + this.dataB.push(valB); + if (this.dataA.length > 60) { this.dataA.shift(); this.dataB.shift(); } + var all = this.dataA.concat(this.dataB).concat([100]); + this.maxVal = Math.max.apply(null, all); + this.draw(); + } + + draw() { + if (!this.ctx) return; + var ctx = this.ctx; + var w = this.canvas.width; + var h = this.canvas.height; + + var self = this; + + // Y-axis: range from -5% to 100%, so zero never hugs the bottom + var pad = Math.max(10, self.maxVal * 0.05); + var range = self.maxVal + pad; // e.g. 0..100 becomes -5..100 + + ctx.clearRect(0, 0, w, h); + + // ── Line A: solid (download) ── + ctx.strokeStyle = this.colorA; + ctx.lineWidth = 1.8; + ctx.setLineDash([]); + ctx.beginPath(); + this.dataA.forEach(function(v, i) { + var x = (i / 59) * w; + var y = h - ((v + pad) / range) * (h - 8); + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + }); + ctx.stroke(); + + // ── Line B: dashed (upload) ── + ctx.strokeStyle = this.colorB; + ctx.lineWidth = 1.4; + ctx.setLineDash([4, 4]); + ctx.beginPath(); + this.dataB.forEach(function(v, i) { + var x = (i / 59) * w; + var y = h - ((v + pad) / range) * (h - 8); + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + }); + ctx.stroke(); + ctx.setLineDash([]); + } +} + +window.MiniChart = MiniChart; +window.DualLineChart = DualLineChart; diff --git a/static/web_panel/js/main.js b/static/web_panel/js/main.js new file mode 100644 index 0000000..0284fb7 --- /dev/null +++ b/static/web_panel/js/main.js @@ -0,0 +1,29 @@ +import { initAuth, api } from './api.js'; +import { initDashboard } from './modules/dashboard.js'; +import { initPlugins } from './modules/plugins.js'; +import { initLogs } from './modules/logs.js'; +import { initCommands } from './modules/commands.js'; + +document.addEventListener('DOMContentLoaded', async () => { + // 1. 检查登录状态 + const authState = await initAuth(); + if (!authState.authenticated) return; // 停留在登录页 + + // 2. 初始化各模块 + initDashboard(); + initPlugins(); + initLogs(); + initCommands(); + + // 3. Tab 切换逻辑 + document.querySelectorAll('.tabs button').forEach(btn => { + btn.addEventListener('click', () => { + document.querySelectorAll('.tabs button').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + + const tabId = `tab-${btn.dataset.tab}`; + document.querySelectorAll('.content section').forEach(s => s.classList.add('hidden')); + document.getElementById(tabId).classList.remove('hidden'); + }); + }); +}); diff --git a/static/web_panel/pages/console.html b/static/web_panel/pages/console.html new file mode 100644 index 0000000..90503f8 --- /dev/null +++ b/static/web_panel/pages/console.html @@ -0,0 +1,10 @@ +
+
💻 FRAMEWORK CONSOLE
+
+
SenSu Console Ready. Type 'help' or 'status'.
+
+
+ $ + +
+
diff --git a/static/web_panel/pages/console.js b/static/web_panel/pages/console.js new file mode 100644 index 0000000..b95204f --- /dev/null +++ b/static/web_panel/pages/console.js @@ -0,0 +1,29 @@ +window.ConsoleModule = { + init: () => { + const box = document.getElementById('cmd-box'); + const input = document.getElementById('cmd-input'); + input.focus(); + + const append = (text, cls='') => { + box.innerHTML += `
${text}
`; + box.scrollTop = box.scrollHeight; + }; + + input.onkeydown = async (e) => { + if(e.key === 'Enter' && input.value.trim()) { + const cmd = input.value.trim(); + append(`$ ${cmd}`, 'var(--accent)'); + input.value = ''; + try { + const res = await fetch('./api/command', { + method:'POST', headers:{'Content-Type':'application/json'}, credentials:'include', + body: JSON.stringify({command: cmd}) + }); + const d = await res.json(); + append(d.success ? d.output : `Error: ${d.error}`, d.success ? '#ccc' : 'var(--error)'); + } catch(err) { append(`Network Error: ${err.message}`, 'var(--error)'); } + } + }; + }, + destroy: () => {} +}; diff --git a/static/web_panel/pages/dashboard.html b/static/web_panel/pages/dashboard.html new file mode 100644 index 0000000..808cd27 --- /dev/null +++ b/static/web_panel/pages/dashboard.html @@ -0,0 +1,93 @@ +
+ +
+
+
+

⏳ 运行时间

+
--
+
v?.?.?
+
+
+

📦 插件状态

+
--
+
已加载 / 活跃
+
+
+

🧠 内存使用

+
+
--%
+
-- / --
+
+ +
+
+

⚡ CPU 负载

+
--%
+ +
+
+

🌐 网络流量

+
+
下行
--
+
上行
--
+
+ +
+
+

⚡ 实时网速

+
+
↓ 下载
--
+
↑ 上传
--
+
+ +
+
+

💾 进程内存

+
--
+ +
+
+
+ + + +
diff --git a/static/web_panel/pages/dashboard.js b/static/web_panel/pages/dashboard.js new file mode 100644 index 0000000..8be95fa --- /dev/null +++ b/static/web_panel/pages/dashboard.js @@ -0,0 +1,202 @@ +window.DashboardModule = { + ws: null, + reconnectTimer: null, + reconnectDelay: 1000, + charts: {}, + + _animateNumber: function(el, newVal, decimals, suffix) { + try { + var oldVal = parseFloat(el.getAttribute('data-val')); + if (isNaN(oldVal)) oldVal = newVal; + if (Math.abs(oldVal - newVal) < 0.05) { el.textContent = newVal.toFixed(decimals) + suffix; el.setAttribute('data-val', newVal); return; } + el.setAttribute('data-val', newVal); + var steps = 20, i = 0; + var stepMs = 50; // 20 steps × 50ms = 1000ms + var timer = setInterval(function(){ + i++; + var p = Math.min(i / steps, 1); + var eased = 1 - Math.pow(1 - p, 3); + el.textContent = (oldVal + (newVal - oldVal) * eased).toFixed(decimals) + suffix; + if (i >= steps) clearInterval(timer); + }, stepMs); + } catch(e) { el.textContent = newVal.toFixed(decimals) + suffix; } + }, + + init: function() { + var self = this; + + // 初始化图表 + self.charts = { + mem: new MiniChart('chart-mem', '#9ece6a'), + cpu: new MiniChart('chart-cpu', '#7aa2f7'), + net: new MiniChart('chart-net', '#e0af68'), + speed: new DualLineChart('chart-speed', '#bb9af7', '#c0a8f0'), + proc_mem: new MiniChart('chart-proc-mem', '#f7768e') + }; + + // 静态信息 (HTTP 一次) + fetchSystemStaticInfo(); + + // WebSocket 实时推送 + self._connect(); + }, + + _connect: function() { + var self = this; + if (self.ws) { + self.ws.onclose = null; + self.ws.close(); + self.ws = null; + } + + var base = window.location.pathname.split("/").slice(0, 2).join("/"); + var ws = new WebSocket("ws://" + location.host + base + "/api/system/ws"); + self.ws = ws; + + ws.onopen = function() { + self.reconnectDelay = 1000; + }; + + ws.onmessage = function(e) { + try { + var d = JSON.parse(e.data); + if (d.type === "sys") { + self._updateUI(d.system, d.framework); + } + } catch(ex) {} + }; + + ws.onclose = function() { + self.ws = null; + self.reconnectTimer = setTimeout(function() { + self.reconnectDelay = Math.min(self.reconnectDelay * 1.5, 15000); + self._connect(); + }, self.reconnectDelay); + }; + + ws.onerror = function() { ws.close(); }; + }, + + _updateUI: function(sys, fw) { + // ── 框架卡片 ── + if (fw) { + var el; + el = document.getElementById('d-uptime'); if (el) el.textContent = formatUptime(fw.uptime || 0); + el = document.getElementById('d-plugins'); if (el) el.textContent = fw.plugins || 0; + el = document.getElementById('d-ver-badge'); if (el) el.textContent = fw.version || '?'; + } + if (!sys) return; + var self = this; + + // ── 内存 ── + if (sys.memory) { + var m = sys.memory.percent || 0; + var el = document.getElementById('d-mem'); if (el) self._animateNumber(el, m, 1, '%'); + el = document.getElementById('d-mem-detail'); + if (el) el.textContent = (sys.memory.used_gb || 0).toFixed(1) + ' / ' + (sys.memory.total_gb || 0).toFixed(1) + ' GB'; + self.charts.mem.update(m); + } + + // ── CPU ── + if (sys.cpu) { + var cpuVal = sys.cpu.percent; + if (cpuVal === null || cpuVal === undefined) { + var load = (sys.cpu.load_avg && sys.cpu.load_avg[0]) ? sys.cpu.load_avg[0] : 0; + cpuVal = Math.min(100, (load / (sys.cpu.cores || 1)) * 100); + } + var el = document.getElementById('d-cpu'); if (el) self._animateNumber(el, cpuVal, 0, '%'); + self.charts.cpu.update(cpuVal); + + if (sys.cpu.load_avg) { + el = document.getElementById('info-load'); if (el) el.textContent = sys.cpu.load_avg[2].toFixed(2); + } + } + + // ── 网络累计流量 (RX + TX) ── + if (sys.network) { + var el; + el = document.getElementById('d-net-rx'); if (el) el.textContent = formatTraffic(sys.network.rx_mb); + el = document.getElementById('d-net-tx'); if (el) el.textContent = formatTraffic(sys.network.tx_mb); + // Chart shows RX trend + self.charts.net.update(sys.network.rx_mb || 0); + } + + // ── 实时网速 ── + if (sys.net_speed) { + var el; + el = document.getElementById('d-speed-rx'); + if (el) el.textContent = formatSpeed(sys.net_speed.rx_bytes_sec); + el = document.getElementById('d-speed-tx'); + if (el) el.textContent = formatSpeed(sys.net_speed.tx_bytes_sec); + // Chart: RX solid line, TX dashed line + self.charts.speed.update(sys.net_speed.rx_bytes_sec || 0, sys.net_speed.tx_bytes_sec || 0); + } + + // ── 进程内存 ── + if (sys.process && sys.process.memory_mb !== undefined) { + var pm = sys.process.memory_mb || 0; + var el = document.getElementById('d-proc-mem'); if (el) self._animateNumber(el, pm, 1, ' MB'); + self.charts.proc_mem.update(pm); + } + if (sys.process && sys.process.pid !== undefined) { + var el = document.getElementById('info-pid'); if (el) el.textContent = sys.process.pid; + } + }, + + destroy: function() { + if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } + if (this.ws) { this.ws.onclose = null; this.ws.close(); this.ws = null; } + this.charts = {}; + } +}; + +/* ── 静态信息 ── */ +async function fetchSystemStaticInfo() { + try { + var sys = await fetch('./api/system', {credentials:'include'}).then(function(r){return r.json();}); + var el; + if (sys.platform) { + el = document.getElementById('info-os'); if (el) el.textContent = sys.platform.system || '--'; + el = document.getElementById('info-arch'); if (el) el.textContent = sys.platform.machine || '--'; + el = document.getElementById('info-env'); if (el) el.textContent = sys.platform.env || 'Standard'; + } + if (sys.cpu) { + var c = sys.cpu.cores || 0; + el = document.getElementById('info-cores'); if (el) el.textContent = c + ' / ' + c; + } + if (sys.memory) { + el = document.getElementById('info-mem-total'); if (el) el.textContent = sys.memory.total_gb + ' GB'; + } + // 框架信息 (部分来自 API) + try { + var fw = await fetch('./api/framework', {credentials:'include'}).then(function(r){return r.json();}); + el = document.getElementById('info-fw-ver'); if (el) el.textContent = fw.version || '?'; + } catch(e) {} + var host = window.location.hostname + (window.location.port ? ':' + window.location.port : ''); + el = document.getElementById('info-addr'); if (el) el.textContent = host; + } catch(e) {} +} + +/* ── 格式化 ── */ +function formatUptime(seconds) { + var h = Math.floor(seconds / 3600); + var m = Math.floor((seconds % 3600) / 60); + var s = Math.floor(seconds % 60); + if (h > 0) return h + 'h ' + m + 'm'; + if (m > 0) return m + 'm ' + s + 's'; + return s + 's'; +} + +function formatTraffic(mb) { + if (mb === undefined || mb === null) return '--'; + if (mb >= 1024) return (mb / 1024).toFixed(1) + ' GB'; + if (mb >= 1) return mb.toFixed(1) + ' MB'; + return (mb * 1024).toFixed(0) + ' KB'; +} + +function formatSpeed(bytesPerSec) { + if (bytesPerSec === undefined || bytesPerSec === null || bytesPerSec < 0) return '0 B/s'; + if (bytesPerSec >= 1048576) return (bytesPerSec / 1048576).toFixed(1) + ' MB/s'; + if (bytesPerSec >= 1024) return (bytesPerSec / 1024).toFixed(0) + ' KB/s'; + return bytesPerSec.toFixed(0) + ' B/s'; +} diff --git a/static/web_panel/pages/example_dashboard.html b/static/web_panel/pages/example_dashboard.html new file mode 100644 index 0000000..adad685 --- /dev/null +++ b/static/web_panel/pages/example_dashboard.html @@ -0,0 +1,12 @@ +Example Plugin + +

Example Plugin Panel

+

Counter

0
+
+

Events (SSE)

Waiting...
+ diff --git a/static/web_panel/pages/files.html b/static/web_panel/pages/files.html new file mode 100644 index 0000000..97d9f0f --- /dev/null +++ b/static/web_panel/pages/files.html @@ -0,0 +1,98 @@ + + + +
+ +
+ +
+ + + + + +
+
+ + +
+ + +
+
加载中...
+
+ + + + + + + + + +
+ + diff --git a/static/web_panel/pages/files.js b/static/web_panel/pages/files.js new file mode 100644 index 0000000..f749d4b --- /dev/null +++ b/static/web_panel/pages/files.js @@ -0,0 +1,419 @@ +/* ── File Manager Module ── */ +window.FilesModule = { + currentPath: '', + contextTarget: null, + toDelete: null, + pickerMode: false, + pickerCallback: null, + + init: function() { + var self = this; + var params = new URLSearchParams(window.location.search); + if (params.get('picker') === '1') { + self.pickerMode = true; + self.pickerMode = params.get('mode') || 'dir'; + self.pickerCallback = params.get('cb') || null; + } + window.addEventListener('message', function(e) { + try { + var d = JSON.parse(e.data); + if (d.action === 'fm-picker-open') { + self.pickerMode = true; + self.pickerMode = d.mode || 'dir'; + self.pickerCallback = d.callback_id || null; + self.refresh(); + } + } catch(ex) {} + }); + + // Path input: Enter to jump + var input = FilesModule._$('fm-path-input'); + input.onkeydown = function(e) { + if (e.key === 'Enter') { + var p = input.value.trim(); + if (p) self._jumpTo(p); + } + }; + + self.refresh(); + }, + + /* ── API helpers ── */ + _apiBase: '', + _scope: null, + _$: function(id){ return this._scope ? this._scope.querySelector('#'+id) : document.getElementById(id); }, + _apiUrl: function(path) { + // Use explicit base if set (standalone/iframe mode), otherwise relative + if (this._apiBase) return this._apiBase + '/api/files' + path; + return './api/files' + path; + }, + _api: function(url, opts) { + opts = opts || {}; + opts.credentials = 'include'; + return fetch(url, opts).then(function(r) { + if (!r.ok) throw new Error(r.status + ' ' + r.statusText); + return r.json(); + }); + }, + _get: function(url) { return this._api(this._apiUrl(url)); }, + _post: function(url, body) { + return this._api(this._apiUrl(url), {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)}); + }, + + /* ── Jump to path (with existence check) ── */ + _jumpTo: function(path) { + var self = this; + // Probe: try to list the target path + this._get('/info?path=' + encodeURIComponent(path)) + .then(function(info) { + if (info.error) { self._pathError(info.error); return; } + if (info.is_dir) { + self.currentPath = info.path; + self.refresh(); + } else { + self._pathError('路径是文件,不是目录'); + } + }).catch(function(e) { + self._pathError('目录不存在或无权限访问: ' + path); + }); + }, + + _pathError: function(msg) { + var list = FilesModule._$('fm-list'); + list.innerHTML = '
' + + '⚠ ' + this._esc(msg) + '
'; + // Still update the input and breadcrumb to show what was attempted + FilesModule._$('fm-breadcrumb').innerHTML = + '' + this._esc(msg) + ''; + }, + + navigate: function(path) { + this.currentPath = path || ''; + this.refresh(); + }, + + refresh: function() { + var self = this; + var showHidden = FilesModule._$('fm-hidden')?.checked ? '1' : '0'; + self._get('/list?path=' + encodeURIComponent(self.currentPath) + '&show_hidden=' + showHidden) + .then(function(d) { + self._render(d); + }).catch(function(e) { + FilesModule._$('fm-list').innerHTML = + '
加载失败: ' + self._esc(e.message) + '
'; + }); + }, + + /* ── Render ── */ + _render: function(d) { + var self = this; + self.currentPath = d.current || ''; + + // Update editable path input + var input = FilesModule._$('fm-path-input'); + input.value = self.currentPath; + + // Breadcrumbs (logical path) + var bc = FilesModule._$('fm-breadcrumb'); + var html = self._buildBreadcrumbHTML(d.breadcrumbs); + // Resolved breadcrumbs — show as second row when symlink redirect + if (d.resolved_breadcrumbs) { + html += '
' + + '' + + self._buildBreadcrumbHTML(d.resolved_breadcrumbs) + '
'; + } + bc.innerHTML = html; + bc.querySelectorAll('.fm-crumb').forEach(function(el) { + el.onclick = function() { self.navigate(this.dataset.path); }; + }); + + // File list + var list = FilesModule._$('fm-list'); + var rows = ''; + + // ".." row for parent (unless at filesystem root) + if (d.parent && d.parent !== d.current) { + rows += '
' + + '' + + '..' + + '' + + '' + + '
'; + } + + if (d.items && d.items.length) { + d.items.forEach(function(item) { + if (item.broken) { + // Broken symlink or unreadable entry — show as disabled + rows += '
' + + '' + + '' + self._esc(item.name) + '' + + '' + + '
'; + return; + } + var icon = item.is_dir ? FilesModule._dirIcon() : FilesModule._fileIcon(item.ext); + var sizeStr = item.is_dir ? '—' : FilesModule._fmtSize(item.size); + rows += '
' + + '' + icon + '' + + '' + self._esc(item.name) + '' + + '' + sizeStr + '' + + '' + (item.mtime_str || '') + '' + + '
'; + }); + } + if (!rows) { + list.innerHTML = '
空目录
'; + } else { + list.innerHTML = rows; + } + + // Row click handlers + list.querySelectorAll('.fm-row').forEach(function(row) { + row.onclick = function(e) { + var p = this.dataset.path; + var isDir = this.dataset.isDir === '1'; + if (isDir) { + self.navigate(p); + } else if (self.pickerMode) { + self._pickResult(p); + } + }; + row.oncontextmenu = function(e) { + e.preventDefault(); + self._showContext(e, { + path: this.dataset.path, + isDir: this.dataset.isDir === '1', + name: this.querySelector('.fm-name').textContent + }); + }; + }); + + // Close context on outside click + document.onclick = function() { + FilesModule._$('fm-context').style.display = 'none'; + }; + + // Picker banner — at bottom + if (self.pickerMode) { + var banner = FilesModule._$('fm-picker-banner'); + if (!banner) { + banner = document.createElement('div'); + banner.id = 'fm-picker-banner'; + banner.innerHTML = '
' + + ' 选择' + (self.pickerMode === 'file' ? '文件' : '目录') + '模式' + + '' + + '' + + '
'; + list.parentNode.appendChild(banner); + list.style.flex = ''; + banner.style.marginTop = 'auto'; + banner.style.paddingBottom = '0'; + } else { + banner.style.display = 'block'; + } + } else { + var oldBanner = FilesModule._$('fm-picker-banner'); + if (oldBanner) oldBanner.style.display = 'none'; + } + }, + + /* ── Picker ── */ + _pickResult: function(path) { + // In iframe (picker mode): post to parent, parent closes overlay + if (window.parent !== window) { + // Ensure path is never empty — use current path as fallback + var p = path || this.currentPath || '/'; + window.parent.postMessage(JSON.stringify({action:'fm-picked', path: p, callback_id: this.pickerCallback}), '*'); + return; + } + // Standalone clipboard fallback + navigator.clipboard?.writeText(path); + alert('已选择: ' + path); + this.pickerMode = false; + this.refresh(); + }, + _cancelPicker: function() { + if (window.parent !== window) { + window.parent.postMessage(JSON.stringify({action:'fm-picked', path: null, callback_id: this.pickerCallback}), '*'); + return; + } + this.pickerMode = false; + this.refresh(); + }, + + /* ── Create ── */ + createFile: function() { + var self = this; + var name = prompt('新建文件名:'); + if (!name) return; + this._post('/touch', {path: this.currentPath, name: name}) + .then(function() { self.refresh(); }) + .catch(function(e) { alert('创建失败: ' + e.message); }); + }, + createDir: function() { + var self = this; + var name = prompt('新建文件夹名:'); + if (!name) return; + this._post('/mkdir', {path: this.currentPath, name: name}) + .then(function() { self.refresh(); }) + .catch(function(e) { alert('创建失败: ' + e.message); }); + }, + upload: function(files) { + var self = this; + if (!files || !files.length) return; + var form = new FormData(); + for (var i = 0; i < files.length; i++) form.append('file', files[i]); + fetch(self._apiUrl('/upload?path=') + encodeURIComponent(self.currentPath), {method:'POST', credentials:'include', body:form}) + .then(function(r) { return r.json(); }) + .then(function(d) { + if (d.ok) self.refresh(); + else alert('上传失败: ' + (d.error || 'unknown')); + }).catch(function(e) { alert('上传失败: ' + e.message); }); + }, + + /* ── Editor ── */ + openEditor: function(path, name) { + var self = this; + this._get('/read?path=' + encodeURIComponent(path)) + .then(function(d) { + FilesModule._$('fm-editor-title').innerHTML = '' + (name || d.name); + FilesModule._$('fm-editor-textarea').value = d.content; + FilesModule._$('fm-editor-textarea').dataset.path = path; + FilesModule._$('fm-editor').style.display = 'flex'; + }).catch(function(e) { alert('无法读取: ' + e.message); }); + }, + closeEditor: function() { + FilesModule._$('fm-editor').style.display = 'none'; + }, + saveFile: function() { + var self = this; + var ta = FilesModule._$('fm-editor-textarea'); + this._post('/write', {path: ta.dataset.path, content: ta.value}) + .then(function() { self.closeEditor(); self.refresh(); }) + .catch(function(e) { alert('保存失败: ' + e.message); }); + }, + + /* ── Context menu ── */ + _showContext: function(e, item) { + this.contextTarget = item; + var ctx = FilesModule._$('fm-context'); + ctx.style.display = 'block'; + ctx.style.left = e.pageX + 'px'; + ctx.style.top = e.pageY + 'px'; + var items = ctx.querySelectorAll('.fm-context-item'); + items[2].style.display = item.isDir ? 'none' : 'block'; + }, + ctxDownload: function() { + var t = this.contextTarget; + if (t) window.open(FilesModule._apiUrl('/download?path=') + encodeURIComponent(t.path), '_blank'); + FilesModule._$('fm-context').style.display = 'none'; + }, + ctxRename: function() { + var self = this; + var t = this.contextTarget; + if (!t) return; + var nn = prompt('新名称:', t.name); + if (!nn || nn === t.name) return; + this._post('/rename', {path: t.path, new_name: nn}) + .then(function() { self.refresh(); }) + .catch(function(e) { alert('重命名失败: ' + e.message); }); + FilesModule._$('fm-context').style.display = 'none'; + }, + ctxEdit: function() { + var t = this.contextTarget; + if (t && !t.isDir) this.openEditor(t.path, t.name); + FilesModule._$('fm-context').style.display = 'none'; + }, + ctxDelete: function() { + var t = this.contextTarget; + if (!t) return; + this.toDelete = t; + FilesModule._$('fm-dialog-msg').textContent = '确认删除 "' + t.name + '"?此操作不可撤销。'; + FilesModule._$('fm-dialog').style.display = 'flex'; + FilesModule._$('fm-context').style.display = 'none'; + }, + confirmDelete: function() { + var self = this; + if (!this.toDelete) return; + this._post('/delete', {path: this.toDelete.path}) + .then(function() { self.toDelete = null; self.closeDialog(); self.refresh(); }) + .catch(function(e) { alert('删除失败: ' + e.message); }); + }, + closeDialog: function() { + FilesModule._$('fm-dialog').style.display = 'none'; + this.toDelete = null; + }, + + /* ── Helpers ── */ + _buildBreadcrumbHTML: function(crumbs) { + if (!crumbs || !crumbs.length) return ''; + var h = ''; + for (var i = 0; i < crumbs.length; i++) { + var c = crumbs[i]; + h += '' + this._esc(c.label) + ''; + if (i < crumbs.length - 1) h += ''; + } + return h; + }, + _esc: function(s) { + return String(s).replace(/&/g,'&').replace(//g,'>'); + }, + _escAttr: function(s) { + return String(s).replace(/&/g,'&').replace(/"/g,'"').replace(//g,'>'); + }, + _fmtSize: function(bytes) { + if (bytes === null || bytes === undefined) return '—'; + if (bytes < 1024) return bytes + ' B'; + if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB'; + if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB'; + return (bytes / 1073741824).toFixed(2) + ' GB'; + }, + _dirIcon: function() { + return ''; + }, + _fileIcon: function(ext) { + // Group by category → single SVG per category + var code = ''; + var img = ''; + var arch = ''; + var media = ''; + + var codeExts = ['.py','.js','.ts','.jsx','.tsx','.html','.css','.scss','.less','.json','.yaml','.yml','.xml','.toml','.ini','.cfg','.conf','.sql','.sh','.bat','.c','.h','.cpp','.hpp','.java','.kt','.rs','.go','.rb','.php','.swift','.r','.lua','.pl','.scala','.dart','.Makefile','.gitignore','.dockerfile','.env']; + var imgExts = ['.png','.jpg','.jpeg','.gif','.svg','.ico','.bmp','.webp','.tiff']; + var mediaExts = ['.mp3','.wav','.ogg','.flac','.aac','.mp4','.avi','.mkv','.mov','.webm']; + var archExts = ['.zip','.tar','.gz','.7z','.rar','.bz2','.xz']; + + if (codeExts.indexOf(ext) >= 0) return code; + if (imgExts.indexOf(ext) >= 0) return img; + if (mediaExts.indexOf(ext) >= 0) return media; + if (archExts.indexOf(ext) >= 0) return arch; + return code; // default: document icon + }, + + destroy: function() { + FilesModule._$('fm-context').style.display = 'none'; + FilesModule._$('fm-dialog').style.display = 'none'; + FilesModule._$('fm-editor').style.display = 'none'; + } +}; + +/* ── Global picker functions (used by onclick in banner HTML) ── */ +window._fmPick = function() { + var p = window.FilesModule.currentPath || '/'; + // Visual feedback to confirm execution + var banner = FilesModule._$('fm-picker-banner'); + if(banner) banner.style.background = '#4caf50'; + if (window.parent !== window) { + window.parent.postMessage(JSON.stringify({action:'fm-picked', path:p, callback_id:window.FilesModule.pickerCallback}), '*'); + } +}; +window._fmCancel = function() { + var banner = FilesModule._$('fm-picker-banner'); + if(banner) banner.style.background = '#f44336'; + if (window.parent !== window) { + window.parent.postMessage(JSON.stringify({action:'fm-picked', path:null, callback_id:window.FilesModule.pickerCallback}), '*'); + } +}; diff --git a/static/web_panel/pages/logs.html b/static/web_panel/pages/logs.html new file mode 100644 index 0000000..a524018 --- /dev/null +++ b/static/web_panel/pages/logs.html @@ -0,0 +1,4 @@ +
+
📡 LIVE LOG STREAM (WebSocket)
+
+
diff --git a/static/web_panel/pages/logs.js b/static/web_panel/pages/logs.js new file mode 100644 index 0000000..1ac02bd --- /dev/null +++ b/static/web_panel/pages/logs.js @@ -0,0 +1,132 @@ +window.LogsModule = { + ws: null, + reconnectTimer: null, + reconnectDelay: 1000, + maxLines: 500, + lineCount: 0, + batchBuffer: [], + batchTimer: null, + + init: function() { + var self = this; + self.box = document.getElementById("log-box"); + if (!self.box) return; + self.box.innerHTML = ""; + self.lineCount = 0; + self._connect(); + }, + + _connect: function() { + var self = this; + if (self.ws) { + self.ws.onclose = null; + self.ws.close(); + self.ws = null; + } + + var base = window.location.pathname.split("/").slice(0, 2).join("/"); + var ws = new WebSocket("ws://" + location.host + base + "/api/logs/ws"); + self.ws = ws; + + ws.onopen = function() { + self.reconnectDelay = 1000; + self._appendHTML('
🟢 Connected
'); + }; + + ws.onmessage = function(e) { + try { + var d = JSON.parse(e.data); + if (d.type === "log") { + self._bufferLog(d); + } + } catch(ex) {} + }; + + ws.onclose = function() { + self.ws = null; + self._appendHTML('
🔴 Disconnected — reconnecting...
'); + self.reconnectTimer = setTimeout(function() { + self.reconnectDelay = Math.min(self.reconnectDelay * 1.5, 15000); + self._connect(); + }, self.reconnectDelay); + }; + + ws.onerror = function() { + ws.close(); + }; + }, + + /* Buffer log entries then flush at ~30fps to avoid DOM thrashing */ + _bufferLog: function(d) { + var self = this; + self.batchBuffer.push(d); + if (!self.batchTimer) { + self.batchTimer = setTimeout(function() { + self._flush(); + self.batchTimer = null; + }, 33); // ~30fps flush + } + }, + + _flush: function() { + var self = this; + var batch = self.batchBuffer; + self.batchBuffer = []; + if (!batch.length || !self.box) return; + + var html = ''; + for (var i = 0; i < batch.length; i++) { + var d = batch[i]; + var cls = d.level === "ERROR" ? "log-ERROR" : d.level === "WARNING" ? "log-WARNING" : "log-INFO"; + var t = d.timestamp ? new Date(d.timestamp * 1000).toLocaleTimeString() : "--"; + html += '
' + + '' + t + '' + + '[' + d.level + '] ' + + self._escapeHtml(d.message || '') + '
'; + } + + self._appendHTML(html); + }, + + _appendHTML: function(html) { + var self = this; + if (!self.box) return; + + // Estimate line count from
tags + var newLines = (html.match(/
self.maxLines && self.box.firstChild) { + // Count lines in the first child + var removed = 1; + if (self.box.firstChild.nodeType === 1) { + var inner = self.box.firstChild.innerHTML || ''; + removed = Math.max(1, (inner.match(/
/g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + }, + + destroy: function() { + if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } + if (this.batchTimer) { clearTimeout(this.batchTimer); this.batchTimer = null; } + if (this.ws) { this.ws.onclose = null; this.ws.close(); this.ws = null; } + this.batchBuffer = []; + this.box = null; + } +}; diff --git a/static/web_panel/pages/plugins.html b/static/web_panel/pages/plugins.html new file mode 100644 index 0000000..880982e --- /dev/null +++ b/static/web_panel/pages/plugins.html @@ -0,0 +1,125 @@ +
+

插件管理

+ + +
+
- 总计
+
- 已启用
+
- 已停用
+
- 启动失败
+
+ + +
+
+
导入 ZIP 包
+ + +
+ + +
+
+
+
Git Clone 安装
+ +
+ + +
+
+
+ + + + + +
加载中...
+ + + + +
diff --git a/static/web_panel/pages/plugins.js b/static/web_panel/pages/plugins.js new file mode 100644 index 0000000..2f7cb19 --- /dev/null +++ b/static/web_panel/pages/plugins.js @@ -0,0 +1,36 @@ +window.PluginsModule = { + init: () => window.PluginsModule.refresh(), + refresh: async () => { + const list = document.getElementById('plugin-list'); + if(!list) return; + list.innerHTML = '加载中...'; + try { + const res = await fetch('./api/plugins', {credentials:'include'}); + const data = await res.json(); + if(!data.plugins?.length) { list.innerHTML = '
暂无已加载插件
'; return; } + + list.innerHTML = data.plugins.map(p => ` +
+
+

${p.name} ${p.running?'RUNNING':'STOPPED'}

+

v${p.version||'1.0.0'} | ${p.enabled?'已启用':'已禁用'}

+
+
+ ${p.running + ? `` + : `` + } + +
+
+ `).join(''); + } catch(e) { list.innerHTML = '加载失败'; } + }, + act: async (name, action) => { + try { + await fetch(`./api/plugins/${name}/${action}`, {method:'POST', credentials:'include'}); + setTimeout(window.PluginsModule.refresh, 500); + } catch(e) { alert('操作失败'); } + }, + destroy: () => {} +}; diff --git a/static/web_panel/pages/projects.html b/static/web_panel/pages/projects.html new file mode 100644 index 0000000..5d40538 --- /dev/null +++ b/static/web_panel/pages/projects.html @@ -0,0 +1,61 @@ + +
+

📦 项目管理

+ +
+
运行中
+
+ 添加项目
+
Git 部署
+
+ +
+
加载中...
+
+ + + + +
+ + diff --git a/static/web_panel/pages/proxy.html b/static/web_panel/pages/proxy.html new file mode 100644 index 0000000..2a94408 --- /dev/null +++ b/static/web_panel/pages/proxy.html @@ -0,0 +1,34 @@ +
+

🔀 反向代理

+ +
+

添加代理

+
框架路径
+
目标 URL
+
描述 (可选)
+ +
+ +
加载中...
+
+ + diff --git a/static/web_panel/pages/settings.html b/static/web_panel/pages/settings.html new file mode 100644 index 0000000..f1e41a1 --- /dev/null +++ b/static/web_panel/pages/settings.html @@ -0,0 +1,264 @@ +
+

框架设置

+ + +
+
+

版本与更新

+
+
+
+ SenSu v? + 检查中... + + +
+
+
+
+
+

备份管理

+ + +
+
加载中...
+ +
+
+ + +
+
+

API Key 管理

+ +
+

+ API Key 用于服务器间安全通信。支持权限范围控制和过期时间。 +

+
+ + + + + +
名称Key权限用量过期操作
加载中...
+
+
+ + + + + + + +
diff --git a/templates/plugin/__init__.py.template b/templates/plugin/__init__.py.template new file mode 100644 index 0000000..bdb0cbf --- /dev/null +++ b/templates/plugin/__init__.py.template @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import logging +import asyncio +from typing import Dict, Any +from aiohttp import web + +try: + from fmfuncs.plugin_command_decorator import plugin_command, command +except ImportError: + def plugin_command(name=None, description=None, permissions=None): + def decorator(func): return func + return decorator + command = plugin_command + +try: + from bridges.plugin_network_bridge import PluginNetworkBridge +except ImportError: + class PluginNetworkBridge: + def __init__(self, *args): pass + async def register_http_route(self, *a, **k): pass + async def register_websocket(self, *a, **k): pass + async def broadcast_websocket(self, *a, **k): pass + def get_network_info(self): return {'plugin_name': '', 'registered_routes': [], 'websocket_handlers': [], 'base_url': '不可用'} + async def setup_data_transfer(self, *a, **k): pass + +logger = logging.getLogger(__name__) + +class Plugin: + """${plugin_name} 插件""" + + def __init__(self, plugin_name: str, config: Dict, bridge): + self.plugin_name = plugin_name + self.config = config + self.bridge = bridge + self.network_bridge = None + self.is_running = False + logger.debug(f"插件初始化: {plugin_name}") + + async def initialize(self): + logger.info(f"初始化插件: {self.plugin_name}") + self.is_running = True + logger.debug(f"插件初始化完成: {self.plugin_name}") + + async def shutdown(self): + logger.info(f"关闭插件: {self.plugin_name}") + self.is_running = False + self.bridge.cleanup_plugin_subscriptions(self.plugin_name) + logger.debug(f"插件关闭完成: {self.plugin_name}") diff --git a/templates/plugin/config.yaml.template b/templates/plugin/config.yaml.template new file mode 100644 index 0000000..c14ea49 --- /dev/null +++ b/templates/plugin/config.yaml.template @@ -0,0 +1,9 @@ +name: "${plugin_name}" +version: "1.0.0" +description: "${description}" +author: "${author}" + +settings: + enabled: true + auto_start: true + log_level: "INFO" diff --git a/templates/plugin/permissions.yaml.template b/templates/plugin/permissions.yaml.template new file mode 100644 index 0000000..bfb8a33 --- /dev/null +++ b/templates/plugin/permissions.yaml.template @@ -0,0 +1,10 @@ +plugin_name: "${plugin_name}" +permissions: + - "plugin.${plugin_name}.read" + - "plugin.${plugin_name}.write" + - "plugin.${plugin_name}.execute" + +permission_descriptions: + plugin.${plugin_name}.read: "读取${plugin_name}插件数据" + plugin.${plugin_name}.write: "写入${plugin_name}插件数据" + plugin.${plugin_name}.execute: "执行${plugin_name}插件操作" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..2f97d27 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,4 @@ +import sys +from pathlib import Path +project_root = Path(__file__).parent.parent +sys.path.insert(0, str(project_root)) diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..6e9712c --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,12 @@ +import pytest,os +from services.auth_service import AuthService +class TestAuthService: + def test_hash_consistent(self): + s=AuthService({});assert s._hash_password("x")==s._hash_password("x") + def test_hash_len(self): + assert len(AuthService({})._hash_password("x"))==64 + def test_invalid_login(self): + assert AuthService({}).authenticate_user("admin","WRONG") is None + def test_valid_login(self): + pw=os.environ.get("SENSU_ADMIN_PASSWORD","admin123") + assert AuthService({}).authenticate_user("admin",pw) is not None 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) diff --git a/tests/test_plugin_enhancements.py b/tests/test_plugin_enhancements.py new file mode 100644 index 0000000..e482291 --- /dev/null +++ b/tests/test_plugin_enhancements.py @@ -0,0 +1,40 @@ +"""Plugin system enhancement tests""" +import pytest, asyncio, os, sys +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) + +from sdk.plugin_status import PluginStatus +from sdk.plugin_error import ( + PluginError, PluginLoadError, PluginConfigError, + PluginPermissionError, PluginCommandError +) + +class TestPluginStatus: + def test_all_statuses(self): + assert PluginStatus.UNLOADED == "unloaded" + assert PluginStatus.RUNNING == "running" + assert PluginStatus.ERROR == "error" + assert len(list(PluginStatus)) >= 8 + +class TestPluginError: + def test_base_error(self): + e = PluginError("test message", "test_plugin") + assert "test_plugin" in str(e) + assert "test message" in str(e) + + def test_error_without_plugin_name(self): + e = PluginError("generic error") + assert "unknown" in str(e) + + def test_subclass_errors(self): + for cls in [PluginLoadError, PluginConfigError, PluginPermissionError, PluginCommandError]: + e = cls("msg", "p") + assert isinstance(e, PluginError) + +class TestPluginBridgeEnhancements: + def test_subscribe_plugin_registers_handler(self): + from bridges.plugin_bridge import PluginBridge + from bridges.core_bridge import CoreBridge + cb = CoreBridge() + pb = PluginBridge(cb) + pb.subscribe_plugin("test.topic", lambda msg: None, "test_plugin") + assert "test.topic" in pb.plugin_subscribers diff --git a/tests/test_service_manager.py b/tests/test_service_manager.py new file mode 100644 index 0000000..f69c5c5 --- /dev/null +++ b/tests/test_service_manager.py @@ -0,0 +1,49 @@ +import pytest, asyncio +from service_manager import ServiceManager + +class MockService: + def __init__(self, name="mock"): + self.name = name + self.shutdown_called = False + def shutdown(self): + self.shutdown_called = True + return True + +class AsyncMockService: + def __init__(self, name="async_mock"): + self.name = name + self.shutdown_called = False + async def shutdown(self): + self.shutdown_called = True + +class TestServiceManager: + def test_register_get(self): + sm = ServiceManager(); s = MockService() + sm.register_service("t", s) + assert sm.get_service("t") is s + + def test_missing_raises(self): + with pytest.raises(ValueError): + ServiceManager().get_service("x") + + def test_has_service(self): + sm = ServiceManager() + sm.register_service("a", MockService()) + assert sm.has_service("a") and not sm.has_service("b") + + def test_shutdown_sync(self): + sm = ServiceManager(); s = MockService() + sm.register_service("s", s) + sm.shutdown_all() + assert s.shutdown_called + + def test_health(self): + sm = ServiceManager() + sm.register_service("h", MockService(), health_check=lambda: True) + assert asyncio.run(sm.check_health())["h"] + + def test_startup_order(self): + sm = ServiceManager() + sm.register_service("1", MockService()) + sm.register_service("2", MockService()) + assert sm.startup_order == ["1", "2"] diff --git a/tests/test_v03.py b/tests/test_v03.py new file mode 100644 index 0000000..8285227 --- /dev/null +++ b/tests/test_v03.py @@ -0,0 +1,75 @@ +"""v0.3 tests: SQLite DB, ProjectService, Dependency Resolution""" +import pytest, os, sys, asyncio +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) + +class TestSenSuDB: + @pytest.fixture + def db(self): + from services.sensu_db import SenSuDB + import tempfile + d = SenSuDB(":memory:") + yield d + d.close() + + def test_save_and_get_plugin(self, db): + db.save_plugin("test_plugin", version="1.0", status="running") + p = db.get_plugin("test_plugin") + assert p["name"] == "test_plugin" + assert p["version"] == "1.0" + assert p["status"] == "running" + + def test_list_plugins(self, db): + db.save_plugin("p1", version="1.0") + db.save_plugin("p2", version="2.0") + lst = db.list_plugins() + assert len(lst) >= 2 + + def test_permissions(self, db): + db.grant_permission("p1", "read") + assert db.check_permission("p1", "read") is True + assert db.check_permission("p1", "write") is False + + def test_config_kv(self, db): + db.set_config("theme", "dark") + assert db.get_config("theme") == "dark" + assert db.get_config("nonexistent", "default") == "default" + +class TestProjectService: + def test_register_project(self): + from services.project_service import ProjectService + ps = ProjectService(None) + ok = ps.register_project("test_plugin", {"name": "TestProject", "port": 5000, "path": "/tmp/test"}) + assert ok is True + p = ps.get_project("TestProject") + assert p["port"] == 5000 + assert p["status"] == "registered" + + def test_port_conflict(self): + from services.project_service import ProjectService + ps = ProjectService(None) + ps.register_project("p1", {"name": "A", "port": 5000}) + ps.register_project("p2", {"name": "B", "port": 5000}) + b = ps.get_project("B") + assert b["port"] != 5000 # should get a different port + + def test_dependency_resolution(self): + from services.project_service import ProjectService + ps = ProjectService(None) + plugins = { + "base": {"depends_on": []}, + "middle": {"depends_on": ["base"]}, + "top": {"depends_on": ["middle"]}, + } + order = ps.resolve_dependencies(plugins) + assert order.index("base") < order.index("middle") + assert order.index("middle") < order.index("top") + + def test_circular_dependency(self): + from services.project_service import ProjectService + ps = ProjectService(None) + plugins = { + "a": {"depends_on": ["b"]}, + "b": {"depends_on": ["a"]}, + } + order = ps.resolve_dependencies(plugins) + assert len(order) == 2 # should handle gracefully diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..6f90ab5 --- /dev/null +++ b/utils/__init__.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" +框架功能集 - 提供各种工具函数和工具类 +""" + +from .file_utils import FileUtils +from .config_utils import ConfigUtils +from .validation_utils import ValidationUtils +from .network_utils import NetworkUtils +from .plugin_utils import PluginUtils + +__all__ = [ + 'FileUtils', + 'ConfigUtils', + 'ValidationUtils', + 'NetworkUtils', + 'PluginUtils' +] diff --git a/utils/config_utils.py b/utils/config_utils.py new file mode 100644 index 0000000..f3b4175 --- /dev/null +++ b/utils/config_utils.py @@ -0,0 +1,203 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import logging +import yaml +import json +from pathlib import Path +from typing import Dict, Any, Optional +import copy + +logger = logging.getLogger(__name__) + +class ConfigUtils: + """配置工具类""" + + @staticmethod + def load_yaml_config(file_path: str, default_config: Dict = None) -> Dict: + """加载YAML配置文件""" + try: + path = Path(file_path) + if not path.exists(): + logger.warning(f"YAML配置文件不存在: {file_path}") + if default_config: + ConfigUtils.save_yaml_config(file_path, default_config) + logger.debug(f"已创建默认YAML配置: {file_path}") + return default_config or {} + + with open(path, 'r', encoding='utf-8') as f: + config = yaml.safe_load(f) + + logger.debug(f"YAML配置加载成功: {file_path}") + return config or {} + + except yaml.YAMLError as e: + logger.error(f"YAML配置文件解析错误 {file_path}: {str(e)}", exc_info=True) + return default_config or {} + except Exception as e: + logger.error(f"加载YAML配置时出错 {file_path}: {str(e)}", exc_info=True) + return default_config or {} + + @staticmethod + def save_yaml_config(file_path: str, config: Dict) -> bool: + """保存YAML配置文件""" + try: + path = Path(file_path) + path.parent.mkdir(parents=True, exist_ok=True) + + with open(path, 'w', encoding='utf-8') as f: + yaml.dump(config, f, default_flow_style=False, allow_unicode=True, indent=2) + + logger.debug(f"YAML配置保存成功: {file_path}") + return True + + except Exception as e: + logger.error(f"保存YAML配置时出错 {file_path}: {str(e)}", exc_info=True) + return False + + @staticmethod + def load_json_config(file_path: str, default_config: Dict = None) -> Dict: + """加载JSON配置文件""" + try: + path = Path(file_path) + if not path.exists(): + logger.warning(f"JSON配置文件不存在: {file_path}") + if default_config: + ConfigUtils.save_json_config(file_path, default_config) + logger.debug(f"已创建默认JSON配置: {file_path}") + return default_config or {} + + with open(path, 'r', encoding='utf-8') as f: + config = json.load(f) + + logger.debug(f"JSON配置加载成功: {file_path}") + return config + + except json.JSONDecodeError as e: + logger.error(f"JSON配置文件解析错误 {file_path}: {str(e)}", exc_info=True) + return default_config or {} + except Exception as e: + logger.error(f"加载JSON配置时出错 {file_path}: {str(e)}", exc_info=True) + return default_config or {} + + @staticmethod + def save_json_config(file_path: str, config: Dict) -> bool: + """保存JSON配置文件""" + try: + path = Path(file_path) + path.parent.mkdir(parents=True, exist_ok=True) + + with open(path, 'w', encoding='utf-8') as f: + json.dump(config, f, ensure_ascii=False, indent=2) + + logger.debug(f"JSON配置保存成功: {file_path}") + return True + + except Exception as e: + logger.error(f"保存JSON配置时出错 {file_path}: {str(e)}", exc_info=True) + return False + + @staticmethod + def get_nested_value(config: Dict, key_path: str, default: Any = None) -> Any: + """获取嵌套配置值""" + try: + keys = key_path.split('.') + current = config + + for key in keys: + if isinstance(current, dict) and key in current: + current = current[key] + else: + logger.debug(f"配置键不存在: {key_path}") + return default + + logger.debug(f"获取嵌套配置值: {key_path} -> {current}") + return current + + except Exception as e: + logger.error(f"获取嵌套配置值时出错 {key_path}: {str(e)}", exc_info=True) + return default + + @staticmethod + def set_nested_value(config: Dict, key_path: str, value: Any) -> bool: + """设置嵌套配置值""" + try: + keys = key_path.split('.') + current = config + + # 遍历到最后一个键的父级 + for key in keys[:-1]: + if key not in current or not isinstance(current[key], dict): + current[key] = {} + current = current[key] + + # 设置值 + current[keys[-1]] = value + logger.debug(f"设置嵌套配置值: {key_path} -> {value}") + return True + + except Exception as e: + logger.error(f"设置嵌套配置值时出错 {key_path}: {str(e)}", exc_info=True) + return False + + @staticmethod + def merge_configs(base_config: Dict, override_config: Dict) -> Dict: + """合并配置(深度合并)""" + try: + result = copy.deepcopy(base_config) + + for key, value in override_config.items(): + if (key in result and + isinstance(result[key], dict) and + isinstance(value, dict)): + # 递归合并字典 + result[key] = ConfigUtils.merge_configs(result[key], value) + else: + # 直接覆盖 + result[key] = copy.deepcopy(value) + + logger.debug("配置合并完成") + return result + + except Exception as e: + logger.error(f"合并配置时出错: {str(e)}", exc_info=True) + return base_config + + @staticmethod + def validate_config_structure(config: Dict, schema: Dict) -> bool: + """验证配置结构""" + try: + def _validate(current_config, current_schema, path=""): + for key, expected_type in current_schema.items(): + full_path = f"{path}.{key}" if path else key + + if key not in current_config: + logger.error(f"配置缺少必要字段: {full_path}") + return False + + actual_value = current_config[key] + expected_type_name = expected_type.__name__ if hasattr(expected_type, '__name__') else str(expected_type) + + if not isinstance(actual_value, expected_type): + logger.error(f"配置类型错误 {full_path}: 期望 {expected_type_name}, 实际 {type(actual_value).__name__}") + return False + + # 如果是字典且schema有嵌套定义,递归验证 + if (isinstance(expected_type, dict) and + isinstance(actual_value, dict)): + if not _validate(actual_value, expected_type, full_path): + return False + + return True + + result = _validate(config, schema) + if result: + logger.debug("配置结构验证通过") + else: + logger.error("配置结构验证失败") + + return result + + except Exception as e: + logger.error(f"验证配置结构时出错: {str(e)}", exc_info=True) + return False diff --git a/utils/file_utils.py b/utils/file_utils.py new file mode 100644 index 0000000..9ac3ac8 --- /dev/null +++ b/utils/file_utils.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import logging +import os +import shutil +from pathlib import Path +from typing import List, Optional +import hashlib + +logger = logging.getLogger(__name__) + +class FileUtils: + """文件操作工具类""" + + @staticmethod + def ensure_directory(directory_path: str) -> bool: + """确保目录存在""" + try: + path = Path(directory_path) + path.mkdir(parents=True, exist_ok=True) + logger.debug(f"目录已确保存在: {directory_path}") + return True + except Exception as e: + logger.error(f"创建目录时出错 {directory_path}: {str(e)}", exc_info=True) + return False + + @staticmethod + def safe_write(file_path: str, content: str, backup: bool = True) -> bool: + """安全写入文件(支持备份)""" + try: + path = Path(file_path) + + # 备份原文件 + if backup and path.exists(): + backup_path = path.with_suffix(path.suffix + '.bak') + shutil.copy2(path, backup_path) + logger.debug(f"文件已备份: {backup_path}") + + # 写入新内容 + with open(path, 'w', encoding='utf-8') as f: + f.write(content) + + logger.debug(f"文件写入成功: {file_path}, 大小: {len(content)} 字节") + return True + + except Exception as e: + logger.error(f"写入文件时出错 {file_path}: {str(e)}", exc_info=True) + return False + + @staticmethod + def safe_read(file_path: str, default: str = "") -> str: + """安全读取文件""" + try: + path = Path(file_path) + if not path.exists(): + logger.warning(f"文件不存在: {file_path}") + return default + + with open(path, 'r', encoding='utf-8') as f: + content = f.read() + + logger.debug(f"文件读取成功: {file_path}, 大小: {len(content)} 字节") + return content + + except Exception as e: + logger.error(f"读取文件时出错 {file_path}: {str(e)}", exc_info=True) + return default + + @staticmethod + def list_files(directory: str, pattern: str = "*", recursive: bool = False) -> List[Path]: + """列出目录中的文件""" + try: + path = Path(directory) + if not path.exists(): + logger.warning(f"目录不存在: {directory}") + return [] + + if recursive: + files = list(path.rglob(pattern)) + else: + files = list(path.glob(pattern)) + + # 过滤出文件(非目录) + files = [f for f in files if f.is_file()] + + logger.debug(f"列出文件: {directory}, 模式: {pattern}, 找到 {len(files)} 个文件") + return files + + except Exception as e: + logger.error(f"列出文件时出错 {directory}: {str(e)}", exc_info=True) + return [] + + @staticmethod + def calculate_file_hash(file_path: str, algorithm: str = "md5") -> Optional[str]: + """计算文件哈希值""" + try: + path = Path(file_path) + if not path.exists(): + logger.warning(f"文件不存在: {file_path}") + return None + + hash_func = getattr(hashlib, algorithm)() + + with open(path, 'rb') as f: + for chunk in iter(lambda: f.read(4096), b""): + hash_func.update(chunk) + + file_hash = hash_func.hexdigest() + logger.debug(f"文件哈希计算完成: {file_path} -> {algorithm}:{file_hash}") + return file_hash + + except Exception as e: + logger.error(f"计算文件哈希时出错 {file_path}: {str(e)}", exc_info=True) + return None + + @staticmethod + def cleanup_old_files(directory: str, pattern: str, keep_count: int) -> int: + """清理旧文件,保留指定数量的最新文件""" + try: + files = FileUtils.list_files(directory, pattern) + if len(files) <= keep_count: + logger.debug(f"文件数量未超过限制,无需清理: {directory}") + return 0 + + # 按修改时间排序 + files.sort(key=lambda x: x.stat().st_mtime, reverse=True) + + # 删除旧文件 + removed_count = 0 + for file_to_remove in files[keep_count:]: + try: + file_to_remove.unlink() + removed_count += 1 + logger.debug(f"删除旧文件: {file_to_remove}") + except Exception as e: + logger.error(f"删除文件时出错 {file_to_remove}: {str(e)}", exc_info=True) + + logger.info(f"文件清理完成: {directory}, 删除 {removed_count} 个文件") + return removed_count + + except Exception as e: + logger.error(f"清理旧文件时出错 {directory}: {str(e)}", exc_info=True) + return 0 diff --git a/utils/network_utils.py b/utils/network_utils.py new file mode 100644 index 0000000..d62d3ef --- /dev/null +++ b/utils/network_utils.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import logging +import socket +import asyncio +import re +from typing import Optional, Tuple +import aiohttp +import ssl + +logger = logging.getLogger(__name__) + +class NetworkUtils: + """网络工具类""" + + @staticmethod + async def check_port_available(host: str, port: int) -> bool: + """检查端口是否可用""" + try: + # 尝试创建socket连接 + reader, writer = await asyncio.open_connection(host, port) + writer.close() + await writer.wait_closed() + logger.debug(f"端口 {host}:{port} 已被占用") + return False + except (ConnectionRefusedError, asyncio.TimeoutError): + logger.debug(f"端口 {host}:{port} 可用") + return True + except Exception as e: + logger.error(f"检查端口可用性时出错 {host}:{port}: {str(e)}", exc_info=True) + return False + + @staticmethod + async def find_available_port(host: str = "localhost", start_port: int = 8000, + max_attempts: int = 100) -> Optional[int]: + """查找可用端口""" + try: + for port in range(start_port, start_port + max_attempts): + if await NetworkUtils.check_port_available(host, port): + logger.debug(f"找到可用端口: {host}:{port}") + return port + logger.warning(f"在范围 {start_port}-{start_port + max_attempts} 内未找到可用端口") + return None + except Exception as e: + logger.error(f"查找可用端口时出错: {str(e)}", exc_info=True) + return None + + @staticmethod + async def http_request(url: str, method: str = "GET", headers: dict = None, + data: dict = None, timeout: int = 30) -> Tuple[bool, dict]: + """发送HTTP请求""" + try: + logger.debug(f"发送HTTP请求: {method} {url}") + + timeout_obj = aiohttp.ClientTimeout(total=timeout) + + async with aiohttp.ClientSession(timeout=timeout_obj) as session: + async with session.request(method, url, headers=headers, json=data) as response: + response_data = await response.text() + + result = { + "status": response.status, + "headers": dict(response.headers), + "data": response_data, + "url": str(response.url) + } + + logger.debug(f"HTTP请求完成: {method} {url} -> 状态 {response.status}") + return True, result + + except asyncio.TimeoutError: + logger.error(f"HTTP请求超时: {method} {url}") + return False, {"error": "请求超时"} + except aiohttp.ClientError as e: + logger.error(f"HTTP客户端错误: {method} {url} -> {str(e)}") + return False, {"error": str(e)} + except Exception as e: + logger.error(f"HTTP请求时出错: {method} {url} -> {str(e)}", exc_info=True) + return False, {"error": str(e)} + + @staticmethod + def get_local_ip() -> str: + """获取本地IP地址""" + try: + # 创建一个socket连接来获取本地IP + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s: + s.connect(("8.8.8.8", 80)) + local_ip = s.getsockname()[0] + + logger.debug(f"获取本地IP: {local_ip}") + return local_ip + except Exception as e: + logger.error(f"获取本地IP时出错: {str(e)}", exc_info=True) + return "127.0.0.1" + + @staticmethod + def is_valid_hostname(hostname: str) -> bool: + """验证主机名格式""" + try: + if len(hostname) > 255: + return False + + if hostname[-1] == ".": + hostname = hostname[:-1] + + allowed = re.compile(r"(?!-)[A-Z\d-]{1,63}(? bool: + """验证插件结构""" + try: + logger.debug(f"验证插件结构: {plugin_path}") + + required_files = [ + "__init__.py", + "config.yaml", + "permissions.yaml" + ] + + # 检查必需文件 + for file_name in required_files: + if not (plugin_path / file_name).exists(): + logger.error(f"插件缺少必需文件: {file_name}") + return False + + # 检查主模块是否有Plugin类 + try: + spec = importlib.util.spec_from_file_location("plugin_module", plugin_path / "__init__.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + if not hasattr(module, 'Plugin'): + logger.error("插件主模块缺少Plugin类") + return False + + # 检查Plugin类是否有必要方法 + plugin_class = module.Plugin + required_methods = ['initialize', 'shutdown'] + + for method_name in required_methods: + if not hasattr(plugin_class, method_name): + logger.error(f"Plugin类缺少必要方法: {method_name}") + return False + + logger.debug(f"插件结构验证通过: {plugin_path.name}") + return True + + except Exception as e: + logger.error(f"验证插件类时出错: {str(e)}", exc_info=True) + return False + + except Exception as e: + logger.error(f"验证插件结构时出错: {str(e)}", exc_info=True) + return False + + @staticmethod + def get_plugin_dependencies(plugin_path: Path) -> List[str]: + """获取插件依赖""" + try: + config_file = plugin_path / "config.yaml" + if not config_file.exists(): + return [] + + import yaml + with open(config_file, 'r', encoding='utf-8') as f: + config = yaml.safe_load(f) + + dependencies = config.get('dependencies', []) + if isinstance(dependencies, list): + logger.debug(f"获取插件依赖: {plugin_path.name} -> {dependencies}") + return dependencies + else: + logger.warning(f"插件依赖格式错误: {plugin_path.name}") + return [] + + except Exception as e: + logger.error(f"获取插件依赖时出错: {str(e)}", exc_info=True) + return [] + + @staticmethod + def scan_plugin_methods(plugin_instance) -> Dict[str, List[str]]: + """扫描插件方法""" + try: + logger.debug(f"扫描插件方法: {type(plugin_instance).__name__}") + + methods_info = { + "public_methods": [], + "private_methods": [], + "async_methods": [], + "event_handlers": [] + } + + for name, method in inspect.getmembers(plugin_instance, predicate=inspect.ismethod): + # 跳过特殊方法 + if name.startswith('_') and not name.startswith('__'): + methods_info["private_methods"].append(name) + elif not name.startswith('_'): + methods_info["public_methods"].append(name) + + # 检查是否为异步方法 + if inspect.iscoroutinefunction(method): + methods_info["async_methods"].append(name) + + # 检查是否为事件处理器 + if name.startswith('handle_') or name.startswith('on_'): + methods_info["event_handlers"].append(name) + + logger.debug(f"插件方法扫描完成: 公共{len(methods_info['public_methods'])}个, 私有{len(methods_info['private_methods'])}个") + return methods_info + + except Exception as e: + logger.error(f"扫描插件方法时出错: {str(e)}", exc_info=True) + return {} + + @staticmethod + def create_plugin_skeleton(plugin_name: str, plugin_path: Path) -> bool: + """创建插件骨架""" + try: + logger.debug(f"创建插件骨架: {plugin_name} -> {plugin_path}") + + # 创建插件目录 + plugin_path.mkdir(parents=True, exist_ok=True) + + # 创建主模块文件 + init_content = '''#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import logging +import asyncio +from typing import Dict, Any + +logger = logging.getLogger(__name__) + +class Plugin: + """{plugin_name} 插件""" + + def __init__(self, plugin_name: str, config: Dict, bridge): + self.plugin_name = plugin_name + self.config = config + self.bridge = bridge + self.is_running = False + logger.debug(f"插件初始化: {{plugin_name}}") + + async def initialize(self): + """初始化插件""" + try: + logger.info(f"初始化插件: {{self.plugin_name}}") + + # 在这里注册事件处理器和命令 + # 示例: self.bridge.subscribe_plugin(self.plugin_name, "event.name", self.handler) + + self.is_running = True + logger.debug(f"插件初始化完成: {{self.plugin_name}}") + + except Exception as e: + logger.error(f"初始化插件时出错: {{str(e)}}", exc_info=True) + raise + + async def shutdown(self): + """关闭插件""" + try: + logger.info(f"关闭插件: {{self.plugin_name}}") + self.is_running = False + + # 清理资源 + self.bridge.cleanup_plugin_subscriptions(self.plugin_name) + + logger.debug(f"插件关闭完成: {{self.plugin_name}}") + + except Exception as e: + logger.error(f"关闭插件时出错: {{str(e)}}", exc_info=True) + + # 在这里添加你的插件方法 + async def example_method(self, message: str) -> str: + """示例方法""" + try: + logger.debug(f"插件方法调用: {{message}}") + return f"插件响应: {{message}}" + except Exception as e: + logger.error(f"插件方法调用出错: {{str(e)}}", exc_info=True) + raise +'''.format(plugin_name=plugin_name) + + with open(plugin_path / "__init__.py", 'w', encoding='utf-8') as f: + f.write(init_content) + + # 创建配置文件 + config_content = f'''# {plugin_name} 插件配置 +name: "{plugin_name}" +version: "1.0.0" +description: "{plugin_name} 插件描述" +author: "插件作者" + +# 插件特定配置 +settings: + enabled: true + auto_start: true + log_level: "INFO" + +# 依赖配置 +dependencies: [] +''' + + with open(plugin_path / "config.yaml", 'w', encoding='utf-8') as f: + f.write(config_content) + + # 创建权限文件 + permissions_content = f'''# {plugin_name} 插件权限申请 +plugin_name: "{plugin_name}" +permissions: + - "plugin.{plugin_name}.read" + - "plugin.{plugin_name}.write" + +# 权限说明 +permission_descriptions: + plugin.{plugin_name}.read: "读取{plugin_name}插件数据" + plugin.{plugin_name}.write: "写入{plugin_name}插件数据" +''' + + with open(plugin_path / "permissions.yaml", 'w', encoding='utf-8') as f: + f.write(permissions_content) + + logger.info(f"插件骨架创建完成: {plugin_name}") + return True + + except Exception as e: + logger.error(f"创建插件骨架时出错: {str(e)}", exc_info=True) + return False + + @staticmethod + def validate_plugin_permissions(plugin_path: Path, requested_permissions: List[str]) -> bool: + """验证插件权限申请""" + try: + logger.debug(f"验证插件权限: {plugin_path.name}") + + # 检查权限格式 + for permission in requested_permissions: + if not isinstance(permission, str): + logger.error(f"权限格式错误: {permission}") + return False + + # 检查权限命名规范 + if not re.match(r'^[a-z][a-z0-9_.]*$', permission): + logger.error(f"权限命名不规范: {permission}") + return False + + logger.debug(f"插件权限验证通过: {len(requested_permissions)} 个权限") + return True + + except Exception as e: + logger.error(f"验证插件权限时出错: {str(e)}", exc_info=True) + return False \ No newline at end of file diff --git a/utils/validation_utils.py b/utils/validation_utils.py new file mode 100644 index 0000000..7a07da7 --- /dev/null +++ b/utils/validation_utils.py @@ -0,0 +1,206 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import logging +import re +import os +from typing import Any, List, Optional, Callable, Dict +from urllib.parse import urlparse +import ipaddress + +logger = logging.getLogger(__name__) + +class ValidationUtils: + """验证工具类""" + + @staticmethod + def is_valid_email(email: str) -> bool: + """验证邮箱格式""" + try: + pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' + result = bool(re.match(pattern, email)) + logger.debug(f"邮箱验证: {email} -> {result}") + return result + except Exception as e: + logger.error(f"验证邮箱时出错: {str(e)}", exc_info=True) + return False + + @staticmethod + def is_valid_url(url: str) -> bool: + """验证URL格式""" + try: + result = urlparse(url) + is_valid = all([result.scheme, result.netloc]) + logger.debug(f"URL验证: {url} -> {is_valid}") + return is_valid + except Exception as e: + logger.error(f"验证URL时出错: {str(e)}", exc_info=True) + return False + + @staticmethod + def is_valid_ip(ip: str) -> bool: + """验证IP地址格式""" + try: + ipaddress.ip_address(ip) + logger.debug(f"IP地址验证: {ip} -> True") + return True + except ValueError: + logger.debug(f"IP地址验证: {ip} -> False") + return False + except Exception as e: + logger.error(f"验证IP地址时出错: {str(e)}", exc_info=True) + return False + + @staticmethod + def is_valid_port(port: int) -> bool: + """验证端口号""" + try: + is_valid = 1 <= port <= 65535 + logger.debug(f"端口验证: {port} -> {is_valid}") + return is_valid + except Exception as e: + logger.error(f"验证端口时出错: {str(e)}", exc_info=True) + return False + + @staticmethod + def validate_string(value: Any, min_length: int = 0, max_length: int = None, + pattern: str = None) -> bool: + """验证字符串""" + try: + if not isinstance(value, str): + logger.debug(f"字符串验证失败: 不是字符串类型") + return False + + if len(value) < min_length: + logger.debug(f"字符串验证失败: 长度小于 {min_length}") + return False + + if max_length and len(value) > max_length: + logger.debug(f"字符串验证失败: 长度大于 {max_length}") + return False + + if pattern and not re.match(pattern, value): + logger.debug(f"字符串验证失败: 不匹配模式 {pattern}") + return False + + logger.debug(f"字符串验证通过: 长度 {len(value)}") + return True + + except Exception as e: + logger.error(f"验证字符串时出错: {str(e)}", exc_info=True) + return False + + @staticmethod + def validate_number(value: Any, min_value: float = None, max_value: float = None) -> bool: + """验证数字""" + try: + if not isinstance(value, (int, float)): + # 尝试转换 + try: + value = float(value) + except (ValueError, TypeError): + logger.debug(f"数字验证失败: 无法转换为数字") + return False + + if min_value is not None and value < min_value: + logger.debug(f"数字验证失败: 值小于 {min_value}") + return False + + if max_value is not None and value > max_value: + logger.debug(f"数字验证失败: 值大于 {max_value}") + return False + + logger.debug(f"数字验证通过: {value}") + return True + + except Exception as e: + logger.error(f"验证数字时出错: {str(e)}", exc_info=True) + return False + + @staticmethod + def validate_list(value: Any, min_length: int = 0, max_length: int = None, + item_validator: Callable = None) -> bool: + """验证列表""" + try: + if not isinstance(value, list): + logger.debug(f"列表验证失败: 不是列表类型") + return False + + if len(value) < min_length: + logger.debug(f"列表验证失败: 长度小于 {min_length}") + return False + + if max_length and len(value) > max_length: + logger.debug(f"列表验证失败: 长度大于 {max_length}") + return False + + if item_validator: + for i, item in enumerate(value): + if not item_validator(item): + logger.debug(f"列表验证失败: 第 {i} 项验证失败") + return False + + logger.debug(f"列表验证通过: 长度 {len(value)}") + return True + + except Exception as e: + logger.error(f"验证列表时出错: {str(e)}", exc_info=True) + return False + + @staticmethod + def validate_dict(value: Any, required_keys: List[str] = None, + key_validators: Dict[str, Callable] = None) -> bool: + """验证字典""" + try: + if not isinstance(value, dict): + logger.debug(f"字典验证失败: 不是字典类型") + return False + + # 检查必需键 + if required_keys: + for key in required_keys: + if key not in value: + logger.debug(f"字典验证失败: 缺少必需键 {key}") + return False + + # 检查键值验证器 + if key_validators: + for key, validator in key_validators.items(): + if key in value and not validator(value[key]): + logger.debug(f"字典验证失败: 键 {key} 的值验证失败") + return False + + logger.debug(f"字典验证通过: 键数 {len(value)}") + return True + + except Exception as e: + logger.error(f"验证字典时出错: {str(e)}", exc_info=True) + return False + + @staticmethod + def validate_file_path(file_path: str, check_exists: bool = True, + check_readable: bool = False, check_writable: bool = False) -> bool: + """验证文件路径""" + try: + from pathlib import Path + + path = Path(file_path) + + if check_exists and not path.exists(): + logger.debug(f"文件路径验证失败: 文件不存在") + return False + + if check_readable and not os.access(path, os.R_OK): + logger.debug(f"文件路径验证失败: 文件不可读") + return False + + if check_writable and not os.access(path, os.W_OK): + logger.debug(f"文件路径验证失败: 文件不可写") + return False + + logger.debug(f"文件路径验证通过: {file_path}") + return True + + except Exception as e: + logger.error(f"验证文件路径时出错: {str(e)}", exc_info=True) + return False diff --git a/version.json b/version.json new file mode 100644 index 0000000..8a59105 --- /dev/null +++ b/version.json @@ -0,0 +1,17 @@ +{ + "framework": { + "version": "v0.8.0", + "url": "https://git.yeij.top/AskaEth/SenSu/archive/main.zip", + "notes": "Sentinel横版折线图+磁盘分区+负载/SWAP/温度 | Windows兼容 | 自动更新+备份回滚 | 插件导入安装 | 安全加固29项" + }, + "plugins": { + "sentinel": { + "version": "1.1.0", + "url": "" + }, + "example_plugin": { + "version": "1.0.0", + "url": "" + } + } +} \ No newline at end of file