merge: dev → main (v0.8.0 稳定版)

This commit is contained in:
qinglong
2026-06-14 15:57:08 +08:00
113 changed files with 22946 additions and 43 deletions
+27
View File
@@ -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
+169 -41
View File
@@ -1,73 +1,201 @@
Apache License Apache License
Version 2.0, January 2004 Version 2.0, January 2004
http://www.apache.org/licenses/ 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"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
You may obtain a copy of the License at 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 Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS, distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
+113 -2
View File
@@ -1,3 +1,114 @@
# SenSu # 🐱 SenSu
一个轻量化 Python TUI 插件框架。 万能 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", "<h1>Hello</h1>")
@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
+49
View File
@@ -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 <name>` 命令
- [x] systemd 集成 — `deploy/sensu.service`
- [x] Docker 化 — `deploy/Dockerfile` (Alpine, <100MB)
---
+16
View File
@@ -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'
]
+179
View File
@@ -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)
+266
View File
@@ -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)
+197
View File
@@ -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)}")
+53
View File
@@ -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" # 首次启动请修改!
+63
View File
@@ -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())
+10
View File
@@ -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"]
+18
View File
@@ -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
+314
View File
@@ -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 天)
File diff suppressed because it is too large Load Diff
+483
View File
@@ -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. APIServiceAPI服务)
## 二、目录结构详细分析
### 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` 自适应主题色
- 动画: 波纹/卡片悬浮/页面淡入/状态脉冲/骨架屏
+362
View File
@@ -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)
+551
View File
@@ -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("🐱 框架进程结束")
+56
View File
@@ -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
+17
View File
@@ -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: "🐱 你好喵~"
+34
View File
@@ -0,0 +1,34 @@
<!DOCTYPE html><html lang="zh"><head><meta charset="UTF-8"><title>Example</title>
<style>
.plugin-page-root{font-family:system-ui,sans-serif;padding:20px;min-height:100%}
h2{color:var(--primary);font-weight:500;margin-bottom:16px}
.counter{font-size:48px;color:var(--primary);text-align:center;padding:20px}
.log{background:var(--md-sys-color-surface-container-lowest);color:#4caf50;padding:8px 12px;border-radius:var(--shape-xs);max-height:200px;overflow-y:auto;font-size:12px;font-family:monospace;line-height:1.6}
</style></head><body>
<h2>Example Plugin Panel</h2>
<div class="card" style="margin-bottom:16px">
<h3 style="margin-bottom:8px">Counter</h3>
<div class="counter" id="count">0</div>
<div style="display:flex;gap:8px;justify-content:center">
<button class="btn btn-sm btn-filled" onclick="inc()">+1</button>
<button class="btn btn-sm btn-outlined" onclick="reset()">Reset</button>
</div>
</div>
<div class="card">
<h3 style="margin-bottom:8px">Events (SSE)</h3>
<div class="log" id="log">Waiting...</div>
</div>
<script>
var n=0;
function inc(){
n++;
document.getElementById("count").textContent=n;
fetch("/SenSu/plugin/example_plugin/event",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({type:"counter",value:n})});
}
function reset(){n=0;document.getElementById("count").textContent=0;}
var es=new EventSource("/SenSu/plugin/example_plugin/sse");
es.addEventListener("counter",function(e){
var d=JSON.parse(e.data);
document.getElementById("log").innerHTML+=new Date().toLocaleTimeString()+" counter="+d.value+"<br>";
});
</script></body></html>
+16
View File
@@ -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: "执行框架命令"
+272
View File
@@ -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 已关闭")
+10
View File
@@ -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
+611
View File
@@ -0,0 +1,611 @@
<div class="plugin-page-root" style="padding:16px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
<h2 style="margin:0">🛡️ Sentinel 集群监控</h2>
<div style="display:flex;gap:4px;align-items:center">
<button class="btn btn-sm btn-filled" id="tab-mon" onclick="switchTab('mon')">仪表盘</button>
<button class="btn btn-sm btn-outlined" id="tab-cfg" onclick="switchTab('cfg')">节点管理</button>
<span style="color:var(--border);margin:0 4px">|</span>
<button class="btn btn-sm btn-outlined" id="btn-layout" onclick="toggleLayout()" title="切换卡片布局">☷ 横版</button>
</div>
</div>
<!-- ── 仪表盘 ── -->
<div id="view-mon">
<div id="sentinel-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px">
<div class="card" style="text-align:center;padding:24px;color:var(--text-dim)">加载中...</div>
</div>
</div>
<!-- ── 节点管理 ── -->
<div id="view-cfg" style="display:none">
<div style="margin-bottom:12px">
<button class="btn btn-filled" onclick="showAdd()">+ 添加节点</button>
</div>
<div id="node-list"></div>
</div>
<!-- ── 编辑对话框 ── -->
<div id="edit-dialog" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.5);z-index:1000;align-items:center;justify-content:center">
<div class="card" style="min-width:440px;max-width:520px;max-height:90vh;overflow-y:auto">
<h3 id="edit-title" style="margin:0 0 16px 0">添加节点</h3>
<input type="hidden" id="edit-id">
<div class="form-row"><label>名称 *</label><input id="edit-name" placeholder="例如: OnePlus 15"></div>
<div class="form-row"><label>URL *</label><input id="edit-url" placeholder="http://192.168.1.100:4200"></div>
<div class="form-row"><label>API Key</label><input id="edit-key" placeholder="sk-..."></div>
<div class="form-row"><label>API 路径前缀</label><input id="edit-base" placeholder="/SenSu" value="/SenSu"></div>
<div class="form-row"><label>备注</label><textarea id="edit-notes" rows="2" placeholder="设备位置、用途等..."></textarea></div>
<div style="display:flex;gap:12px;justify-content:flex-end;margin-top:16px">
<button class="btn btn-outlined" onclick="testConn()">测试连接</button>
<button class="btn btn-outlined" onclick="hideEdit()">取消</button>
<button class="btn btn-filled" onclick="saveNode()">保存</button>
</div>
<div id="test-result" style="margin-top:8px;font-size:.85rem"></div>
</div>
</div>
<style>
.node-card { background:var(--md-sys-color-surface-container); border-radius:var(--shape-sm); padding:12px; transition:box-shadow .3s, background .3s }
.node-card:hover { box-shadow:0 2px 8px rgba(0,0,0,.2) }
.node-card.online { border-left:3px solid var(--success, #4caf50) }
.node-card.offline { border-left:3px solid var(--error, #f44336); opacity:.7 }
.node-card.flash { animation:card-flash .6s ease-out }
@keyframes card-flash { 0%{background:var(--md-sys-color-surface-container-highest)} 100%{background:var(--md-sys-color-surface-container)} }
.node-name { font-weight:600; font-size:1rem; margin-bottom:2px }
.node-sys { font-size:.75rem; color:var(--text-dim); margin-bottom:6px }
.node-url { font-size:.75rem; color:var(--text-dim); margin-bottom:8px; word-break:break-all }
.node-notes { font-size:.75rem; color:var(--text-dim); font-style:italic; margin-bottom:8px }
/* 横版布局 — 左设备信息 | 右数据+图表同行 */
#sentinel-grid.layout-row { display:flex;flex-direction:column;gap:8px }
#sentinel-grid.layout-row .node-card { display:grid;grid-template-columns:140px 1fr 0.8fr 0.7fr 0.8fr 1fr;gap:12px 16px;align-items:start }
#sentinel-grid.layout-row .col-left { min-width:0 }
#sentinel-grid.layout-row .col-right-stack { display:flex;flex-direction:column;gap:12px;min-width:0 }
#sentinel-grid.layout-row .col-extra { display:flex;flex-direction:column;gap:4px;padding:0 2px }
#sentinel-grid.layout-row .extra-row { font-size:.8rem;padding:3px 0;border-bottom:1px solid var(--md-sys-color-surface-container-lowest) }
#sentinel-grid.layout-row .extra-row .elabel { font-size:.65rem;color:var(--text-dim);text-transform:uppercase;margin-right:4px }
#sentinel-grid.layout-row .col-disk { display:none;overflow-y:auto;max-height:200px;padding:0 2px }
#sentinel-grid.layout-row .disk-list { display:flex;flex-direction:column;gap:4px }
#sentinel-grid.layout-row .part-row { display:flex;flex-direction:column;gap:2px;padding:3px 0;border-bottom:1px solid var(--md-sys-color-surface-container-lowest) }
#sentinel-grid.layout-row .part-info { display:flex;justify-content:space-between;font-size:.7rem }
#sentinel-grid.layout-row .part-name { font-weight:500;max-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap }
#sentinel-grid.layout-row .part-mount { color:var(--text-dim);font-size:.65rem }
#sentinel-grid.layout-row .part-bar { height:3px;border-radius:1px;background:var(--md-sys-color-surface-container-lowest);margin:2px 0 }
#sentinel-grid.layout-row .part-bar-fill { height:100%;border-radius:1px;background:var(--primary) }
#sentinel-grid.layout-row .part-detail { display:flex;justify-content:space-between;font-size:.6rem;color:var(--text-dim) }
#sentinel-grid.layout-row .part-pct { text-align:right }
#sentinel-grid.layout-row .extra-row .eval { font-weight:500 }
#sentinel-grid.layout-row .disk-list { display:flex;flex-direction:column;gap:4px }
#sentinel-grid.layout-row .part-row { display:flex;flex-direction:column;gap:2px;padding:3px 0;border-bottom:1px solid var(--md-sys-color-surface-container-lowest) }
#sentinel-grid.layout-row .part-info { display:flex;justify-content:space-between;font-size:.7rem }
#sentinel-grid.layout-row .part-name { font-weight:500;max-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap }
#sentinel-grid.layout-row .part-mount { color:var(--text-dim);font-size:.65rem }
#sentinel-grid.layout-row .part-bar { height:3px;border-radius:1px;background:var(--md-sys-color-surface-container-lowest);margin:2px 0 }
#sentinel-grid.layout-row .part-bar-fill { height:100%;border-radius:1px;background:var(--primary) }
#sentinel-grid.layout-row .part-detail { display:flex;justify-content:space-between;font-size:.6rem;color:var(--text-dim) }
#sentinel-grid.layout-row .part-pct { text-align:right }
#sentinel-grid.layout-row .col-extra { display:flex;flex-direction:column;gap:4px;padding:0 2px }
#sentinel-grid.layout-row .extra-row { font-size:.8rem;padding:3px 0;border-bottom:1px solid var(--md-sys-color-surface-container-lowest) }
#sentinel-grid.layout-row .extra-row .elabel { font-size:.65rem;color:var(--text-dim);text-transform:uppercase;margin-right:4px }
#sentinel-grid.layout-row .col-disk { display:none;overflow-y:auto;max-height:200px;padding:0 2px }
#sentinel-grid.layout-row .disk-list { display:flex;flex-direction:column;gap:4px }
#sentinel-grid.layout-row .part-row { display:flex;flex-direction:column;gap:2px;padding:3px 0;border-bottom:1px solid var(--md-sys-color-surface-container-lowest) }
#sentinel-grid.layout-row .part-info { display:flex;justify-content:space-between;font-size:.7rem }
#sentinel-grid.layout-row .part-name { font-weight:500;max-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap }
#sentinel-grid.layout-row .part-mount { color:var(--text-dim);font-size:.65rem }
#sentinel-grid.layout-row .part-bar { height:3px;border-radius:1px;background:var(--md-sys-color-surface-container-lowest);margin:2px 0 }
#sentinel-grid.layout-row .part-bar-fill { height:100%;border-radius:1px;background:var(--primary) }
#sentinel-grid.layout-row .part-detail { display:flex;justify-content:space-between;font-size:.6rem;color:var(--text-dim) }
#sentinel-grid.layout-row .part-pct { text-align:right }
#sentinel-grid.layout-row .extra-row .eval { font-weight:500 }
#sentinel-grid.layout-row .disk-list { display:flex;flex-direction:column;gap:4px }
#sentinel-grid.layout-row .part-row { display:flex;flex-direction:column;gap:2px;padding:3px 0;border-bottom:1px solid var(--md-sys-color-surface-container-lowest) }
#sentinel-grid.layout-row .part-info { display:flex;justify-content:space-between;font-size:.7rem }
#sentinel-grid.layout-row .part-name { font-weight:500;max-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap }
#sentinel-grid.layout-row .part-mount { color:var(--text-dim);font-size:.65rem }
#sentinel-grid.layout-row .part-bar { height:3px;border-radius:1px;background:var(--md-sys-color-surface-container-lowest);margin:2px 0 }
#sentinel-grid.layout-row .part-bar-fill { height:100%;border-radius:1px;background:var(--primary) }
#sentinel-grid.layout-row .part-detail { display:flex;justify-content:space-between;font-size:.6rem;color:var(--text-dim) }
#sentinel-grid.layout-row .part-pct { text-align:right }
#sentinel-grid.layout-row .disk-list { display:flex;flex-direction:column;gap:4px }
#sentinel-grid.layout-row .part-row { display:flex;flex-direction:column;gap:2px;padding:3px 0;border-bottom:1px solid var(--md-sys-color-surface-container-lowest) }
#sentinel-grid.layout-row .part-info { display:flex;justify-content:space-between;font-size:.7rem }
#sentinel-grid.layout-row .part-name { font-weight:500;max-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap }
#sentinel-grid.layout-row .part-mount { color:var(--text-dim);font-size:.65rem }
#sentinel-grid.layout-row .part-bar { height:3px;border-radius:1px;background:var(--md-sys-color-surface-container-lowest);margin:2px 0 }
#sentinel-grid.layout-row .part-bar-fill { height:100%;border-radius:1px;background:var(--primary);transition:width .6s }
#sentinel-grid.layout-row .part-detail { display:flex;justify-content:space-between;font-size:.6rem;color:var(--text-dim) }
#sentinel-grid.layout-row .part-size { text-align:left }
#sentinel-grid.layout-row .mtr-data .rlabel { font-size:.7rem;color:var(--text-dim);text-transform:uppercase;margin-right:6px;text-shadow:0 0 3px var(--bg) }
#sentinel-grid.layout-row .mtr-data .rval { font-weight:600;text-shadow:0 0 3px var(--bg) }
#sentinel-grid.layout-row .mtr { position:relative;padding:0;margin-bottom:8px;height:60px }
#sentinel-grid.layout-row .mtr canvas { display:block;width:100%;height:60px;border-radius:4px;background:rgba(128,128,128,0.06) }
#sentinel-grid.layout-row .mtr-data { position:absolute;top:3px;left:8px;z-index:1;display:flex;align-items:baseline;gap:6px }
#sentinel-grid.layout-row .node-time { margin-top:4px }
#sentinel-grid.layout-row .metric-bar { display:none }
.metric-row { display:flex; gap:12px; margin-bottom:8px }
.metric { flex:1 }
.metric-label { font-size:.7rem; color:var(--text-dim); text-transform:uppercase }
.metric-value { font-size:1.1rem; font-weight:600; transition:color .4s }
.metric-bar { height:4px; border-radius:2px; margin-top:2px; background:var(--md-sys-color-surface-container-lowest); overflow:hidden }
.metric-bar-fill { height:100%; border-radius:2px; transition:width .6s cubic-bezier(.4,0,.2,1) }
.bar-green { background:linear-gradient(90deg,#66bb6a,#4caf50) } .bar-yellow { background:linear-gradient(90deg,#ffa726,#ff9800) } .bar-red { background:linear-gradient(90deg,#ef5350,#f44336) }
.metric-value.up { color:#4caf50 } .metric-value.down { color:#f44336 } .metric-value.steady { color:var(--text) }
.form-row { margin-bottom:10px }
.form-row label { display:block;font-size:.8rem;margin-bottom:3px;color:var(--text-dim) }
.form-row input, .form-row textarea { width:100%;padding:8px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:var(--shape-xs);font-size:.9rem }
.form-row input:focus, .form-row textarea:focus { outline:1px solid var(--primary) }
.node-item { display:flex;align-items:center;padding:10px 12px;margin-bottom:6px;background:var(--md-sys-color-surface-container);border-radius:var(--shape-sm);gap:12px }
.node-item .info { flex:1;min-width:0 }
.node-item .name { font-weight:600 }
.node-item .url { font-size:.75rem;color:var(--text-dim);word-break:break-all }
.node-item .notes { font-size:.75rem;color:var(--text-dim);font-style:italic }
.mtr canvas { image-rendering:auto;-webkit-font-smoothing:antialiased }
</style>
<script>
// ── 内联高分辨率图表 (不依赖 chart.js) ──
function HiChart(canvasId, color, color2) {
this.canvas = document.getElementById(canvasId);
this.ctx = this.canvas.getContext('2d');
this.color = color;
this.color2 = color2 || null; // null = single line
this.data = new Array(60).fill(0);
this.data2 = color2 ? new Array(60).fill(0) : null;
this.maxVal = 100;
this._dpr = Math.min(window.devicePixelRatio || 1, 4);
this._resize();
var self = this;
window.addEventListener('resize', function(){ self._resize(); });
}
HiChart.prototype._resize = function() {
var rect = this.canvas.parentElement.getBoundingClientRect();
var w = Math.max(rect.width - 8, 100);
var h = 60;
this.canvas.width = w * this._dpr;
this.canvas.height = h * this._dpr;
// CSS controls display size via width:100%;height:60px
this.ctx.setTransform(this._dpr, 0, 0, this._dpr, 0, 0);
this._draw();
};
HiChart.prototype.update = function(val, val2) {
this.data.push(val);
if (this.data.length > 60) this.data.shift();
if (this.data2) { this.data2.push(val2||0); if (this.data2.length > 60) this.data2.shift(); }
var all = this.data2 ? this.data.concat(this.data2) : this.data;
this.maxVal = Math.max.apply(null, all.concat([100]));
this._draw();
};
HiChart.prototype._draw = function() {
var ctx = this.ctx, w = this.canvas.width / this._dpr, h = this.canvas.height / this._dpr;
ctx.clearRect(0, 0, w, h);
var drawLine = function(data, color, dash) {
ctx.strokeStyle = color; ctx.lineWidth = 2; ctx.beginPath();
if (dash) ctx.setLineDash([4, 3]); else ctx.setLineDash([]);
var step = w / (data.length - 1);
var scale = (h - 4) / Math.max(this.maxVal, 1);
for (var i = 0; i < data.length; i++) {
var x = i * step, y = h - 4 - data[i] * scale;
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.stroke();
}.bind(this);
drawLine(this.data, this.color, false);
if (this.data2) drawLine(this.data2, this.color2, true);
};
// 确保 chart.js 已加载 (SPA 导航时可能未加载)
var pollTimer = null;
var _chartInstances = {};
var editingId = null;
var _layoutMode = 'grid'; // 'grid' | 'row'
function toggleLayout() {
_layoutMode = _layoutMode === 'grid' ? 'row' : 'grid';
var btn = document.getElementById("btn-layout");
var grid = document.getElementById("sentinel-grid");
// 销毁旧图表
try { Object.keys(_chartInstances).forEach(function(k){ _chartInstances[k] = null; }); } catch(e) {}
_chartInstances = {};
_cardCache = {};
_firstRender = true;
grid.innerHTML = '<div class="card" style="text-align:center;padding:24px;color:var(--text-dim)">加载中...</div>';
if (_layoutMode === 'row') {
grid.className = 'layout-row';
grid.style.cssText = 'display:flex;flex-direction:column;gap:12px';
btn.textContent = '⊞ 网格';
btn.className = 'btn btn-sm btn-filled';
} else {
grid.className = '';
grid.style.cssText = 'display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px';
btn.textContent = '☷ 横版';
btn.className = 'btn btn-sm btn-outlined';
}
pollNodes();
}
function switchTab(t) {
document.getElementById("view-mon").style.display = t === "mon" ? "block" : "none";
document.getElementById("view-cfg").style.display = t === "cfg" ? "block" : "none";
document.getElementById("tab-mon").className = "btn btn-sm " + (t === "mon" ? "btn-filled" : "btn-outlined");
document.getElementById("tab-cfg").className = "btn btn-sm " + (t === "cfg" ? "btn-filled" : "btn-outlined");
if (t === "cfg") loadNodes();
}
// ═══ 仪表盘 ═══
var _cardCache = {};
function animateValue(el, oldVal, newVal, decimals, suffix) {
if (oldVal === newVal) { el.textContent = newVal.toFixed(decimals) + suffix; return; }
var start = performance.now();
var duration = 1000;
function step(ts) {
var progress = Math.min((ts - start) / duration, 1);
// ease-out
var eased = 1 - Math.pow(1 - progress, 3);
var current = oldVal + (newVal - oldVal) * eased;
el.textContent = current.toFixed(decimals) + suffix;
if (progress < 1) requestAnimationFrame(step);
}
requestAnimationFrame(step);
}
var _firstRender = true;
function renderNodes(nodes) {
var grid = document.getElementById("sentinel-grid");
if (!nodes.length) {
grid.innerHTML = '<div class="card" style="text-align:center;padding:24px;color:var(--text-dim)">暂无节点 — 切换到节点管理添加</div>';
try { Object.keys(_chartInstances).forEach(function(k){ _chartInstances[k] = null; }); } catch(e) {}
_chartInstances = {};
_cardCache = {};
_firstRender = true;
return;
}
if (_firstRender) { grid.innerHTML = ''; _firstRender = false; }
var isRow = _layoutMode === 'row';
// 清理删除的节点
var activeIds = {};
nodes.forEach(function(n){ activeIds[n.id||n.name] = true; });
Object.keys(_cardCache).forEach(function(id){
if (!activeIds[id]) { var el = _cardCache[id]; if(el.parentNode) el.parentNode.removeChild(el); delete _cardCache[id]; }
});
nodes.forEach(function(n){
var sys = n.system || {};
var cpu = sys.cpu || {}; var cpuVal = cpu.percent || 0;
var mem = sys.memory || {}; var memVal = mem.percent || 0;
var net = sys.net_speed || {};
var id = n.id || n.name;
var card = _cardCache[id];
if (!card) {
card = document.createElement('div');
card.className = 'node-card ' + (n.online ? 'online' : 'offline');
card.setAttribute('data-id', id);
var plat = n.platform || {};
var sysInfo = (plat.system||'') + (plat.release ? ' ' + plat.release : '') + (plat.machine ? ' | ' + plat.machine : '');
if (isRow) {
card.innerHTML =
'<div class="col-left">'+
'<div class="node-name">'+(n.online?'🟢':'🔴')+' '+esc(n.name)+'</div>'+
(sysInfo.trim() ? '<div class="node-sys">'+esc(sysInfo)+'</div>' : '')+
'<div class="node-url">'+esc(n.url||'')+'</div>'+
(n.notes ? '<div class="node-notes">📝 '+esc(n.notes)+'</div>' : '')+
'</div>'+
'<div class="col-right-stack">'+
'<div class="mtr" id="mtr-'+id+'-cpu"><div class="mtr-data"><span class="rlabel">CPU</span> <span class="rval cpu-val">0%</span></div><canvas id="'+id+'-cpu" height="60"></canvas></div>'+
'<div class="mtr" id="mtr-'+id+'-mem"><div class="mtr-data"><span class="rlabel">MEM</span> <span class="rval mem-val">0%</span> <span class="mem-row-detail" style="font-size:.65rem;color:var(--text-dim)"></span></div><canvas id="'+id+'-mem" height="60"></canvas></div>'+
'<div class="mtr" id="mtr-'+id+'-net"><div class="mtr-data"><span class="rlabel">NET</span> <span class="rval net-down" style="font-size:.85rem">0</span> <span class="rval net-up" style="font-size:.85rem">0</span></div><canvas id="'+id+'-net" height="60"></canvas></div>'+
'<div class="col-info" style="text-align:right;font-size:.7rem;color:var(--text-dim);margin-top:4px"></div>'+
'</div>'+
'<div class="col-disk"><div class="disk-list"></div></div>'+
'<div class="col-extra">'+
'<div class="extra-row"><span class="elabel">负载</span> <span class="eval load-val">—</span></div>'+
'<div class="extra-row"><span class="elabel">SWAP</span> <span class="eval swap-val">—</span></div>'+
'<div class="extra-row"><span class="elabel">温度</span> <span class="eval temp-val">—</span></div>'+
'<div class="extra-row"><span class="elabel">流量</span> <span class="eval traf-val">—</span></div>'+
'<div class="extra-row"><span class="elabel">进程</span> <span class="eval proc-val">—</span></div>'+
'</div>';
} else {
card.innerHTML =
'<div class="node-name">'+(n.online?'🟢':'🔴')+' '+esc(n.name)+'</div>'+
(sysInfo.trim() ? '<div class="node-sys">'+esc(sysInfo)+'</div>' : '')+
'<div class="node-url">'+esc(n.url||'')+'</div>'+
(n.notes ? '<div class="node-notes">📝 '+esc(n.notes)+'</div>' : '')+
'<div class="metrics-body"></div>'+
'<div class="node-time"></div>';
}
grid.appendChild(card);
_cardCache[id] = card;
if (isRow && !_chartInstances[id]) {
_chartInstances[id] = { cpu: null, mem: null, net: null };
_chartInstances[id].cpu = new HiChart(id+'-cpu', '#7aa2f7');
_chartInstances[id].mem = new HiChart(id+'-mem', '#9ece6a');
_chartInstances[id].net = new HiChart(id+'-net', '#bb9af7', '#c0a8f0');
}
}
// 更新通用信息
card.className = 'node-card ' + (n.online ? 'online' : 'offline');
var nameEl = card.querySelector('.node-name');
if (nameEl) nameEl.innerHTML = (n.online?'🟢':'🔴')+' '+esc(n.name);
var urlEl = card.querySelector('.node-url');
if (urlEl) urlEl.textContent = n.url||'';
var sysEl = card.querySelector('.node-sys');
var plat2 = n.platform || {};
var si2 = (plat2.system||'') + (plat2.release ? ' ' + plat2.release : '') + (plat2.machine ? ' | ' + plat2.machine : '');
if (si2.trim()) {
if (!sysEl) { sysEl = document.createElement('div'); sysEl.className = 'node-sys'; card.insertBefore(sysEl, card.querySelector('.metrics-body')||card.querySelector('.card-mid')); }
sysEl.textContent = si2;
} else if (sysEl) { sysEl.remove(); }
var body = isRow ? card.querySelector('.col-right-stack') : card.querySelector('.metrics-body');
if (!body) return;
var oldCpu = parseFloat(body.getAttribute('data-cpu')||(sys.cpu||{}).percent||0);
var oldMem = parseFloat(body.getAttribute('data-mem')||(sys.memory||{}).percent||0);
body.setAttribute('data-cpu', (sys.cpu||{}).percent||0);
body.setAttribute('data-mem', (sys.memory||{}).percent||0);
if (isRow) {
var cpuEl = card.querySelector('.cpu-val');
var memEl = card.querySelector('.mem-val');
var netDn = card.querySelector('.net-down');
var netUp = card.querySelector('.net-up');
var mrd = card.querySelector('.mem-row-detail');
if (cpuEl) animateValue(cpuEl, oldCpu, cpuVal, 1, '%');
if (memEl) animateValue(memEl, oldMem, memVal, 1, '%');
if (netDn) netDn.textContent = '↓' + fmtSpeed(net.rx_bytes_sec||0);
if (netUp) netUp.textContent = '↑' + fmtSpeed(net.tx_bytes_sec||0);
if (mrd) mrd.textContent = (mem.used_gb||0).toFixed(1)+'/'+(mem.total_gb||0).toFixed(1)+' GB';
var charts = _chartInstances[id];
if (charts) {
try {
if (charts.cpu) charts.cpu.update(cpuVal);
if (charts.mem) charts.mem.update(memVal);
if (charts.net) { charts.net.update(net.rx_bytes_sec||0, net.tx_bytes_sec||0); if (charts.net.maxVal < 102400) { charts.net.maxVal = 102400; charts.net._draw(); } }
} catch(e) {}
}
// 更新额外指标
var loadEl = card.querySelector('.load-val');
var swapEl = card.querySelector('.swap-val');
var diskEl = card.querySelector('.disk-val');
var tempEl = card.querySelector('.temp-val');
var trafEl = card.querySelector('.traf-val');
var procEl = card.querySelector('.proc-val');
var sysData = n.system || {};
if (loadEl && sysData.cpu) loadEl.textContent = (sysData.cpu.load_avg||[0,0,0]).map(function(v){return v.toFixed(1);}).join(' / ');
if (swapEl && sysData.swap) { var st = sysData.swap.total_gb||0; swapEl.textContent = st > 0 ? (sysData.swap.percent||0).toFixed(1)+'% ('+(sysData.swap.used_gb||0).toFixed(1)+'G)' : '—'; }
// 磁盘分区列表
// 磁盘分区 (在col-extra中显示)
var colDisk = card.querySelector('.col-disk');
if (colDisk) {
if (sysData.partitions && sysData.partitions.length) {
colDisk.style.display = 'block';
card.style.gridTemplateColumns = '140px 1fr 0.7fr 0.6fr';
} else {
colDisk.style.display = 'none';
card.style.gridTemplateColumns = '140px 1fr 0.5fr';
}
}
var diskList = card.querySelector('.col-disk .disk-list');
if (diskList && sysData.partitions) {
var parts = sysData.partitions || [];
if (!diskList.querySelector('.part-row') || diskList.children.length !== parts.length) {
diskList.innerHTML = parts.map(function(p){
return '<div class="part-row"><div class="part-info"><span class="part-name">'+esc(p.device||'?')+'</span> <span class="part-mount">'+esc(p.mount||'')+'</span></div><div class="part-bar"><div class="part-bar-fill" style="width:'+(p.percent||0)+'%"></div></div><div class="part-detail"><span class="part-size">'+fmtSizeGB(p)+'</span><span class="part-pct">'+(p.percent||0).toFixed(0)+'%</span></div></div>';
}).join('');
} else {
var rows = diskList.querySelectorAll('.part-row');
parts.forEach(function(p, i){
if (rows[i]) {
var fill = rows[i].querySelector('.part-bar-fill');
var pct = rows[i].querySelector('.part-pct');
if (fill) fill.style.width = (p.percent||0)+'%';
if (pct) pct.textContent = (p.percent||0).toFixed(0)+'%';
}
});
}
}
if (tempEl && sysData.temperature) tempEl.textContent = (sysData.temperature.current||0) > 0 ? (sysData.temperature.current).toFixed(1)+'°C' : '—';
if (trafEl && sysData.network) trafEl.textContent = '↓'+(sysData.network.rx_mb||0).toFixed(0)+'M ↑'+(sysData.network.tx_mb||0).toFixed(0)+'M';
if (procEl && sysData.process) procEl.textContent = (sysData.process.memory_mb||0).toFixed(0)+'MB';
// 更新额外指标
var loadEl = card.querySelector('.load-val');
var swapEl = card.querySelector('.swap-val');
var tempEl = card.querySelector('.temp-val');
var trafEl = card.querySelector('.traf-val');
var procEl = card.querySelector('.proc-val');
var sysData = n.system || {};
if (loadEl && sysData.cpu) loadEl.textContent = (sysData.cpu.load_avg||[0,0,0]).map(function(v){return v.toFixed(1);}).join(' / ');
if (swapEl && sysData.swap) { var st = sysData.swap.total_gb||0; swapEl.textContent = st > 0 ? (sysData.swap.percent||0).toFixed(1)+'% ('+(sysData.swap.used_gb||0).toFixed(1)+'G)' : '—'; }
if (tempEl && sysData.temperature) tempEl.textContent = (sysData.temperature.current||0) > 0 ? (sysData.temperature.current).toFixed(1)+'°C' : '—';
if (trafEl && sysData.network) trafEl.textContent = '↓'+(sysData.network.rx_mb||0).toFixed(0)+'M ↑'+(sysData.network.tx_mb||0).toFixed(0)+'M';
if (procEl && sysData.process) procEl.textContent = (sysData.process.memory_mb||0).toFixed(0)+'MB';
// 磁盘分区
// 磁盘分区 (在col-extra中显示)
var colDisk = card.querySelector('.col-disk');
if (colDisk) {
if (sysData.partitions && sysData.partitions.length) {
colDisk.style.display = 'block';
card.style.gridTemplateColumns = '140px 1fr 0.7fr 0.6fr';
} else {
colDisk.style.display = 'none';
card.style.gridTemplateColumns = '140px 1fr 0.5fr';
}
}
var diskList = card.querySelector('.col-disk .disk-list');
if (diskList && sysData.partitions) {
var parts = sysData.partitions || [];
if (!diskList.querySelector('.part-row') || diskList.children.length !== parts.length) {
diskList.innerHTML = parts.map(function(p){
return '<div class="part-row"><div class="part-info"><span class="part-name">'+esc(p.device||'?')+'</span> <span class="part-mount">'+esc(p.mount||'')+'</span></div><div class="part-bar"><div class="part-bar-fill" style="width:'+(p.percent||0)+'%"></div></div><div class="part-detail"><span class="part-size">'+fmtSizeGB(p)+'</span><span class="part-pct">'+(p.percent||0).toFixed(0)+'%</span></div></div>';
}).join('');
} else {
var rows = diskList.querySelectorAll('.part-row');
parts.forEach(function(p, i){
if (rows[i]) {
var fill = rows[i].querySelector('.part-bar-fill');
var pct = rows[i].querySelector('.part-pct');
if (fill) fill.style.width = (p.percent||0)+'%';
if (pct) pct.textContent = (p.percent||0).toFixed(0)+'%';
}
});
}
}
var infoR = card.querySelector('.col-info');
if (infoR) infoR.textContent = (n.last_seen ? new Date(n.last_seen*1000).toLocaleTimeString() : '—');
} else {
// 网格模式 — oldCpu/oldMem 已在上面读取(在setAttribute之前)
var hasMetricBar = body.querySelector('.metric-bar');
var isNew = !body.querySelector('.cpu-val') || !hasMetricBar;
if (isNew) {
body.innerHTML =
'<div class="metric-row">'+
'<div class="metric"><div class="metric-label">CPU</div><div class="metric-value cpu-val">0%</div><div class="metric-bar"><div class="metric-bar-fill" style="width:0%"></div></div></div>'+
'<div class="metric"><div style="display:flex;justify-content:space-between;align-items:baseline"><div class="metric-label">MEM</div><div class="mem-detail-val" style="font-size:.65rem;color:var(--text-dim)"></div></div><div class="metric-value mem-val">0%</div><div class="metric-bar"><div class="metric-bar-fill" style="width:0%"></div></div></div>'+
'</div>'+
'<div class="metric-row">'+
'<div class="metric"><div class="metric-label">NET ↓</div><div class="metric-value net-down" style="font-size:.9rem"></div></div>'+
'<div class="metric"><div class="metric-label">NET ↑</div><div class="metric-value net-up" style="font-size:.9rem"></div></div>'+
'</div>';
}
body.querySelector('.net-down').textContent = fmtSpeed(net.rx_bytes_sec||0);
body.querySelector('.net-up').textContent = fmtSpeed(net.tx_bytes_sec||0);
var md = body.querySelector('.mem-detail-val');
if (md) md.textContent = (mem.used_gb||0).toFixed(1) + '/' + (mem.total_gb||0).toFixed(1) + ' GB';
// 强制更新数值(备用)
var cv = body.querySelector('.cpu-val');
var mv = body.querySelector('.mem-val');
if (cv) cv.textContent = cpuVal.toFixed(1) + '%';
if (mv) mv.textContent = memVal.toFixed(1) + '%';
var barCpu = body.querySelectorAll('.metric-bar-fill')[0];
var barMem = body.querySelectorAll('.metric-bar-fill')[1];
if (barCpu && barMem) {
var cpuBarCls = bar(cpuVal), memBarCls = bar(memVal);
barCpu.style.transition = 'none'; barMem.style.transition = 'none';
barCpu.style.width = oldCpu+'%'; barMem.style.width = oldMem+'%';
barCpu.className = 'metric-bar-fill '+cpuBarCls; barMem.className = 'metric-bar-fill '+memBarCls;
barCpu.offsetWidth; barMem.offsetWidth;
barCpu.style.transition = ''; barMem.style.transition = '';
barCpu.style.width = cpuVal+'%'; barMem.style.width = memVal+'%';
}
animateValue(body.querySelector('.cpu-val'), oldCpu, cpuVal, 1, '%');
animateValue(body.querySelector('.mem-val'), oldMem, memVal, 1, '%');
card.querySelector('.node-time').innerHTML = '<div style="font-size:.7rem;color:var(--text-dim);margin-top:8px">'+(n.last_seen ? new Date(n.last_seen*1000).toLocaleTimeString() : '—')+'</div>';
}
});
}
function bar(v) { return v < 60 ? 'bar-green' : (v < 85 ? 'bar-yellow' : 'bar-red'); }
function fmtSizeGB(p) { var u=p.used_gb||0, t=p.total_gb||0; return fmtSize(u*1073741824)+' / '+fmtSize(t*1073741824); }
function fmtSize(bytes) { if(bytes===undefined||bytes===null)return'—'; bytes=Math.abs(bytes); if(bytes>=1e12)return(bytes/1e12).toFixed(1)+' TB'; if(bytes>=1e9)return(bytes/1e9).toFixed(1)+' GB'; if(bytes>=1e6)return(bytes/1e6).toFixed(1)+' MB'; if(bytes>=1e3)return(bytes/1e3).toFixed(1)+' KB'; return bytes.toFixed(0)+' B'; }
function fmtSize(bytes) { if(bytes===undefined||bytes===null)return'—'; bytes=Math.abs(bytes); if(bytes>=1e12)return(bytes/1e12).toFixed(1)+' TB'; if(bytes>=1e9)return(bytes/1e9).toFixed(1)+' GB'; if(bytes>=1e6)return(bytes/1e6).toFixed(1)+' MB'; if(bytes>=1e3)return(bytes/1e3).toFixed(1)+' KB'; return bytes.toFixed(0)+' B'; }
function fmtSizeGB(p) { var u=p.used_gb||0, t=p.total_gb||0; return fmtSize(u*1073741824)+' / '+fmtSize(t*1073741824); }
function fmtSpeed(bps) { return bps<1024?bps.toFixed(0)+'B/s':(bps<1048576?(bps/1024).toFixed(0)+'K/s':(bps/1048576).toFixed(1)+'M/s'); }
function esc(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
function pollNodes() {
fetch("/sentinel/api/nodes").then(function(r){return r.json()}).then(function(d){
renderNodes(d.nodes || []);
}).catch(function(e){
var grid = document.getElementById("sentinel-grid");
if (grid && _firstRender) {
grid.innerHTML = '<div class="card" style="text-align:center;padding:24px;color:var(--error)">连接失败 — 检查节点配置</div>';
}
});
}
pollNodes();
pollTimer = setInterval(pollNodes, 2000);
// ═══ 节点管理 ═══
function showAdd(){
editingId = null;
document.getElementById("edit-title").textContent = "添加节点";
document.getElementById("edit-id").value = "";
document.getElementById("edit-name").value = "";
document.getElementById("edit-url").value = "http://";
document.getElementById("edit-key").value = "";
document.getElementById("edit-base").value = "/SenSu";
document.getElementById("edit-notes").value = "";
document.getElementById("test-result").innerHTML = "";
document.getElementById("edit-dialog").style.display = "flex";
}
function showEdit(n){
editingId = n.id;
document.getElementById("edit-title").textContent = "编辑: " + esc(n.name);
document.getElementById("edit-id").value = n.id||"";
document.getElementById("edit-name").value = n.name||"";
document.getElementById("edit-url").value = n.url||"";
document.getElementById("edit-key").value = n.api_key||"";
document.getElementById("edit-base").value = n.base_path||"/SenSu";
document.getElementById("edit-notes").value = n.notes||"";
document.getElementById("test-result").innerHTML = "";
document.getElementById("edit-dialog").style.display = "flex";
}
function hideEdit(){ document.getElementById("edit-dialog").style.display = "none"; }
async function saveNode(){
var data = {
id: document.getElementById("edit-id").value,
name: document.getElementById("edit-name").value.trim(),
url: document.getElementById("edit-url").value.trim(),
api_key: document.getElementById("edit-key").value.trim(),
base_path: document.getElementById("edit-base").value.trim() || "/SenSu",
notes: document.getElementById("edit-notes").value.trim()
};
if(!data.name||!data.url){ alert("名称和 URL 不能为空"); return; }
var resp = await fetch("/sentinel/api/nodes",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(data)});
var r = await resp.json();
if(r.ok){ hideEdit(); loadNodes(); }
else alert("保存失败: "+(r.error||""));
}
async function deleteNode(id,name){
if(!confirm("确认删除节点 '"+name+"'?")) return;
await fetch("/sentinel/api/nodes/delete",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:id})});
loadNodes();
}
async function testConn(){
var url = document.getElementById("edit-url").value.trim();
var key = document.getElementById("edit-key").value.trim();
var el = document.getElementById("test-result");
el.innerHTML = '<span style="color:var(--text-dim)">⏳ 测试中...</span>';
try {
var resp = await fetch("/sentinel/api/nodes/test",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({url:url,api_key:key})});
var r = await resp.json();
if(r.ok) el.innerHTML = '<span style="color:var(--success)">✅ 连接成功 — CPU:'+(r.system.cpu.percent||0)+'% MEM:'+(r.system.memory.percent||0)+'%</span>';
else el.innerHTML = '<span style="color:var(--error)">❌ '+esc(r.error||'失败')+'</span>';
}catch(e){ el.innerHTML = '<span style="color:var(--error)">❌ '+esc(String(e))+'</span>'; }
}
async function loadNodes(){
var resp = await fetch("/sentinel/api/nodes");
var data = await resp.json();
var nodes = data.nodes||[];
var list = document.getElementById("node-list");
if(!nodes.length){ list.innerHTML = '<div style="text-align:center;padding:24px;color:var(--text-dim)">暂无节点 — 点击上方添加</div>'; return; }
list.innerHTML = nodes.map(function(n){
var status = n.online ? '<span style="color:var(--success)">🟢</span>' : '<span style="color:var(--error)">🔴</span>';
return '<div class="node-item">'+
'<div>'+status+'</div>'+
'<div class="info"><div class="name">'+esc(n.name)+'</div><div class="url">'+esc(n.url)+'</div>'+
(n.notes?'<div class="notes">📝 '+esc(n.notes)+'</div>':'')+
'</div>'+
'<button class="btn btn-sm btn-outlined" onclick=\'showEdit('+JSON.stringify(n)+')\'>✏</button>'+
'<button class="btn btn-sm btn-outlined" style="color:var(--error)" onclick="deleteNode(\''+esc(n.id)+'\',\''+esc(n.name)+'\')">✕</button>'+
'</div>';
}).join("");
}
</script>
</div>
+13
View File
@@ -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: "执行哨兵管理命令"
+131
View File
@@ -0,0 +1,131 @@
<div class="plugin-page-root" style="padding:16px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<h2 style="margin:0">🛡️ 节点管理</h2>
<button class="btn btn-filled" onclick="showAdd()">+ 添加节点</button>
</div>
<div id="node-list"></div>
<!-- 编辑对话框 -->
<div id="edit-dialog" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.5);z-index:1000;align-items:center;justify-content:center">
<div class="card" style="min-width:440px;max-width:520px;max-height:90vh;overflow-y:auto">
<h3 id="edit-title" style="margin:0 0 16px 0">添加节点</h3>
<input type="hidden" id="edit-id">
<div class="form-row"><label>名称 *</label><input id="edit-name" placeholder="例如: OnePlus 15"></div>
<div class="form-row"><label>URL *</label><input id="edit-url" placeholder="http://192.168.1.100:4200"></div>
<div class="form-row"><label>API Key</label><input id="edit-key" placeholder="sk-..."></div>
<div class="form-row"><label>备注</label><textarea id="edit-notes" rows="2" placeholder="设备位置、用途等..."></textarea></div>
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:16px">
<button class="btn btn-outlined" onclick="testConn()">测试连接</button>
<button class="btn btn-outlined" onclick="hideEdit()">取消</button>
<button class="btn btn-filled" onclick="saveNode()">保存</button>
</div>
<div id="test-result" style="margin-top:8px;font-size:.85rem"></div>
</div>
</div>
<style>
.form-row { margin-bottom:10px }
.form-row label { display:block;font-size:.8rem;margin-bottom:3px;color:var(--text-dim) }
.form-row input, .form-row textarea { width:100%;padding:8px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:var(--shape-xs);font-size:.9rem }
.form-row input:focus, .form-row textarea:focus { outline:1px solid var(--primary) }
.node-item { display:flex;align-items:center;padding:10px 12px;margin-bottom:6px;background:var(--md-sys-color-surface-container);border-radius:var(--shape-sm);gap:12px }
.node-item .info { flex:1;min-width:0 }
.node-item .name { font-weight:600 }
.node-item .url { font-size:.75rem;color:var(--text-dim);word-break:break-all }
.node-item .notes { font-size:.75rem;color:var(--text-dim);font-style:italic }
</style>
<script>
var editingId = null;
function showAdd(){
editingId = null;
document.getElementById("edit-title").textContent = "添加节点";
document.getElementById("edit-id").value = "";
document.getElementById("edit-name").value = "";
document.getElementById("edit-url").value = "http://";
document.getElementById("edit-key").value = "";
document.getElementById("edit-notes").value = "";
document.getElementById("test-result").innerHTML = "";
document.getElementById("edit-dialog").style.display = "flex";
}
function showEdit(n){
editingId = n.id;
document.getElementById("edit-title").textContent = "编辑: " + esc(n.name);
document.getElementById("edit-id").value = n.id||"";
document.getElementById("edit-name").value = n.name||"";
document.getElementById("edit-url").value = n.url||"";
document.getElementById("edit-key").value = n.api_key||"";
document.getElementById("edit-notes").value = n.notes||"";
document.getElementById("test-result").innerHTML = "";
document.getElementById("edit-dialog").style.display = "flex";
}
function hideEdit(){ document.getElementById("edit-dialog").style.display = "none"; }
async function saveNode(){
var data = {
id: document.getElementById("edit-id").value,
name: document.getElementById("edit-name").value.trim(),
url: document.getElementById("edit-url").value.trim(),
api_key: document.getElementById("edit-key").value.trim(),
notes: document.getElementById("edit-notes").value.trim()
};
if(!data.name||!data.url){ alert("名称和 URL 不能为空"); return; }
var resp = await fetch("/sentinel/api/nodes",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(data)});
var r = await resp.json();
if(r.ok){ hideEdit(); loadNodes(); }
else alert("保存失败: "+(r.error||""));
}
async function deleteNode(id,name){
if(!confirm("确认删除节点 '"+name+"'?")) return;
// 删除通过保存空列表实现 — 或调用 API
var resp = await fetch("/sentinel/api/nodes",{method:"GET"});
var data = await resp.json();
var nodes = (data.nodes||[]).filter(function(n){return n.id!==id});
// 通过逐个更新实现 — 简单方式: POST 一个标记删除的请求
// 实际: fetch DELETE 端点
await fetch("/sentinel/api/nodes",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:id,_delete:true})});
loadNodes();
}
async function testConn(){
var url = document.getElementById("edit-url").value.trim();
var key = document.getElementById("edit-key").value.trim();
var el = document.getElementById("test-result");
el.innerHTML = '<span style="color:var(--text-dim)">⏳ 测试中...</span>';
try {
var resp = await fetch("/sentinel/api/nodes/test",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({url:url,api_key:key})});
var r = await resp.json();
if(r.ok) el.innerHTML = '<span style="color:var(--success)">✅ 连接成功 — CPU:'+(r.system.cpu.percent||0)+'% MEM:'+(r.system.memory.percent||0)+'%</span>';
else el.innerHTML = '<span style="color:var(--error)">❌ '+esc(r.error||'失败')+'</span>';
}catch(e){ el.innerHTML = '<span style="color:var(--error)">❌ '+esc(String(e))+'</span>'; }
}
async function loadNodes(){
var resp = await fetch("/sentinel/api/nodes");
var data = await resp.json();
var nodes = data.nodes||[];
var list = document.getElementById("node-list");
if(!nodes.length){ list.innerHTML = '<div style="text-align:center;padding:24px;color:var(--text-dim)">暂无节点 — 点击上方添加</div>'; return; }
list.innerHTML = nodes.map(function(n){
var status = n.online ? '<span style="color:var(--success)">🟢</span>' : '<span style="color:var(--error)">🔴</span>';
return '<div class="node-item">'+
'<div>'+status+'</div>'+
'<div class="info"><div class="name">'+esc(n.name)+'</div><div class="url">'+esc(n.url)+'</div>'+
(n.notes?'<div class="notes">📝 '+esc(n.notes)+'</div>':'')+
'</div>'+
'<button class="btn btn-sm btn-outlined" onclick=\'showEdit('+JSON.stringify(n)+')\'>✏</button>'+
'<button class="btn btn-sm btn-outlined" style="color:var(--error)" onclick="deleteNode(\''+esc(n.id)+'\',\''+esc(n.name)+'\')">✕</button>'+
'</div>';
}).join("");
}
function esc(s){ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
loadNodes();
</script>
</div>
+8
View File
@@ -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
+111
View File
@@ -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
+31
View File
@@ -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
+15
View File
@@ -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"
+50
View File
@@ -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
+79
View File
@@ -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("所有服务关闭完成")
+51
View File
@@ -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("所有服务关闭完成")
+206
View File
@@ -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)
+283
View File
@@ -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)
+637
View File
@@ -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 <name> 或 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 <name> 安装插件")
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)
+318
View File
@@ -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)
+501
View File
@@ -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)
+336
View File
@@ -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
File diff suppressed because it is too large Load Diff
+108
View File
@@ -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
+702
View File
@@ -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/<name>/..."""
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)}")
+68
View File
@@ -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
+153
View File
@@ -0,0 +1,153 @@
#!/usr/bin/env python3
"""SenSu ProjectEngine — 异步子进程项目管理"""
import asyncio, os, logging, signal, time
from typing import Dict, Optional, List
from dataclasses import dataclass, field
from datetime import datetime
logger = logging.getLogger(__name__)
@dataclass
class ProjectProcess:
name: str
cmd: list
cwd: str = "."
env: dict = field(default_factory=dict)
port: int = 0
proxy_path: str = ""
auto_restart: bool = True
process: asyncio.subprocess.Process = None
status: str = "stopped"
pid: int = 0
started_at: float = 0
log_buffer: list = field(default_factory=list) # 最近 200 行
max_log_lines: int = 200
class ProjectEngine:
def __init__(self, service_manager=None):
self.sm = service_manager
self.projects: Dict[str, ProjectProcess] = {}
self._monitor_task = None
async def start(self):
self._monitor_task = asyncio.create_task(self._health_monitor())
logger.info("ProjectEngine 已就绪")
async def run_project(self, name: str, cmd: list, cwd: str = ".",
env: dict = None, port: int = 0, proxy_path: str = "",
auto_restart: bool = True) -> bool:
if name in self.projects and self.projects[name].status == "running":
logger.warning(f"项目 {name} 已在运行")
return False
pp = ProjectProcess(name=name, cmd=cmd, cwd=cwd, env=env or {},
port=port, proxy_path=proxy_path, auto_restart=auto_restart)
return await self._start_process(pp)
async def _start_process(self, pp: ProjectProcess) -> bool:
try:
full_env = {**os.environ, **pp.env}
pp.process = await asyncio.create_subprocess_exec(
*pp.cmd, cwd=pp.cwd, env=full_env,
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
stdin=asyncio.subprocess.PIPE
)
pp.pid = pp.process.pid
pp.status = "running"
pp.started_at = time.time()
self.projects[pp.name] = pp
# 启动日志读取任务
asyncio.create_task(self._read_stream(pp, pp.process.stdout, "stdout"))
asyncio.create_task(self._read_stream(pp, pp.process.stderr, "stderr"))
logger.info(f"项目已启动: {pp.name} (PID={pp.pid})")
if pp.port:
logger.info(f" 端口: {pp.port}")
if pp.proxy_path:
logger.info(f" 代理: {pp.proxy_path}")
# 监控进程退出
asyncio.create_task(self._wait_exit(pp))
return True
except Exception as e:
logger.error(f"启动项目 {pp.name} 失败: {e}")
pp.status = "error"
self.projects[pp.name] = pp
return False
async def _read_stream(self, pp: ProjectProcess, stream, tag: str):
while pp.status == "running" and stream and not stream.at_eof():
try:
line = await stream.readline()
if line:
text = line.decode(errors="replace").rstrip()
pp.log_buffer.append(f"[{tag}] {text}")
if len(pp.log_buffer) > pp.max_log_lines:
pp.log_buffer = pp.log_buffer[-pp.max_log_lines:]
logger.debug(f"[{pp.name}] {text}")
except Exception:
break
async def _wait_exit(self, pp: ProjectProcess):
if pp.process:
await pp.process.wait()
exit_code = pp.process.returncode
pp.status = "stopped"
logger.info(f"项目 {pp.name} 已退出 (code={exit_code})")
if pp.auto_restart and exit_code != 0:
logger.info(f"自动重启 {pp.name} ...")
await asyncio.sleep(2)
await self._start_process(pp)
async def stop_project(self, name: str) -> bool:
pp = self.projects.get(name)
if not pp or pp.status != "running":
return False
pp.auto_restart = False
if pp.process:
pp.process.terminate()
try:
await asyncio.wait_for(pp.process.wait(), timeout=5)
except asyncio.TimeoutError:
pp.process.kill()
pp.status = "stopped"
logger.info(f"项目已停止: {name}")
return True
async def send_stdin(self, name: str, text: str):
pp = self.projects.get(name)
if pp and pp.process and pp.process.stdin:
pp.process.stdin.write((text + "\n").encode())
await pp.process.stdin.drain()
return True
return False
def get_logs(self, name: str, tail: int = 50) -> List[str]:
pp = self.projects.get(name)
return pp.log_buffer[-tail:] if pp else []
def list_projects(self) -> List[dict]:
return [{"name": p.name, "status": p.status, "pid": p.pid,
"port": p.port, "proxy": p.proxy_path,
"uptime": int(time.time()-p.started_at) if p.started_at else 0}
for p in self.projects.values()]
def get_project(self, name: str) -> Optional[ProjectProcess]:
return self.projects.get(name)
async def _health_monitor(self):
while True:
await asyncio.sleep(10)
for pp in list(self.projects.values()):
if pp.status == "running" and pp.process:
if pp.process.returncode is not None:
pp.status = "stopped"
logger.warning(f"项目 {pp.name} 异常退出 (code={pp.process.returncode})")
async def shutdown(self):
for name in list(self.projects.keys()):
await self.stop_project(name)
if self._monitor_task:
self._monitor_task.cancel()
logger.info("ProjectEngine 已关闭")
+117
View File
@@ -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
+158
View File
@@ -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 已关闭")
+130
View File
@@ -0,0 +1,130 @@
#!/usr/bin/env python3
"""SenSu PyEnvManager — Python 版本管理 + venv + 依赖安装"""
import os, sys, subprocess, logging, venv, shutil
from pathlib import Path
from typing import Optional, List
logger = logging.getLogger(__name__)
class PyEnvManager:
def __init__(self, workspace_dir: str = None):
self.workspace = Path(workspace_dir or os.getcwd())
self.venvs_dir = self.workspace / "venvs"
self.venvs_dir.mkdir(parents=True, exist_ok=True)
def detect_versions(self) -> List[str]:
versions = set()
# Current Python
v = f"{sys.version_info.major}.{sys.version_info.minor}"
versions.add(v)
# Check Termux pkg
try:
result = subprocess.run(["pkg", "list-installed"], capture_output=True, text=True, timeout=10)
for line in result.stdout.split("\n"):
if line.startswith("python-3.") or line.startswith("python3-"):
versions.add(line.split("/")[0].replace("python-", "").replace("python3-", ""))
except:
pass
# Check pyenv
pyenv = shutil.which("pyenv")
if pyenv:
try:
result = subprocess.run([pyenv, "versions", "--bare"], capture_output=True, text=True, timeout=10)
for line in result.stdout.split("\n"):
line = line.strip()
if line and line[0].isdigit():
versions.add(line.split("/")[0])
except:
pass
return sorted(versions)
def ensure_version(self, version: str) -> Optional[str]:
"""确保指定 Python 版本可用,返回解释器路径"""
available = self.detect_versions()
if version in available:
return self._find_python(version)
# Try to install via Termux
if shutil.which("pkg"):
pkg_name = f"python-{version}"
logger.info(f"尝试安装 {pkg_name} ...")
try:
subprocess.run(["pkg", "install", "-y", pkg_name], check=True, timeout=120)
return self._find_python(version)
except:
pass
logger.warning(f"无法获取 Python {version},使用当前版本")
return sys.executable
def _find_python(self, version: str) -> Optional[str]:
for name in [f"python{version}", f"python{version[:3]}", "python3"]:
path = shutil.which(name)
if path: return path
return sys.executable
def create_venv(self, name: str, python_version: str = None) -> Optional[Path]:
venv_path = self.venvs_dir / name
if venv_path.exists():
logger.info(f"venv 已存在: {venv_path}")
return venv_path
python_exe = self.ensure_version(python_version) if python_version else sys.executable
logger.info(f"创建 venv: {venv_path} (Python {python_version or 'default'})")
try:
venv.create(str(venv_path), with_pip=True, clear=True)
# Install/upgrade pip
pip = str(venv_path / "bin" / "pip")
subprocess.run([pip, "install", "--upgrade", "pip"], capture_output=True, timeout=60)
return venv_path
except Exception as e:
logger.error(f"创建 venv 失败: {e}")
# Fallback: use virtualenv
try:
subprocess.run([sys.executable, "-m", "virtualenv", str(venv_path)], check=True, timeout=120)
return venv_path
except:
return None
def install_deps(self, venv_path: Path, requirements: List[str]) -> bool:
pip = str(venv_path / "bin" / "pip")
for req_file in requirements:
req_path = Path(req_file)
if not req_path.is_absolute():
# Relative to workspace
pass
if Path(req_file).exists():
logger.info(f"安装依赖: {req_file}")
try:
subprocess.run([pip, "install", "-r", req_file], check=True, timeout=300)
except subprocess.CalledProcessError as e:
logger.warning(f"依赖安装部分失败: {e}")
return False
return True
def clone_git(self, url: str, target_dir: Path, branch: str = None) -> bool:
if target_dir.exists():
logger.info(f"目录已存在: {target_dir}")
# Try git pull instead
try:
subprocess.run(["git", "-C", str(target_dir), "pull"], check=True, timeout=60)
return True
except:
pass
cmd = ["git", "clone"]
if branch:
cmd += ["-b", branch]
cmd += [url, str(target_dir)]
try:
subprocess.run(cmd, check=True, timeout=300)
logger.info(f"Git clone 完成: {url}{target_dir}")
return True
except subprocess.CalledProcessError as e:
logger.error(f"Git clone 失败: {e}")
return False
+119
View File
@@ -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
+132
View File
@@ -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)
+90
View File
@@ -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()
+908
View File
@@ -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)}")
+309
View File
@@ -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()
+2
View File
@@ -0,0 +1,2 @@
from .manager import WebPanelManager
__all__ = ["WebPanelManager"]
+31
View File
@@ -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
+119
View File
@@ -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)
+39
View File
@@ -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
+176
View File
@@ -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)")
+185
View File
@@ -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", [])
})
+28
View File
@@ -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": "命令执行失败"})
+452
View File
@@ -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))
+30
View File
@@ -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)
+42
View File
@@ -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 (已加认证)")
+287
View File
@@ -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})
+62
View File
@@ -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("📦 项目管理路由已注册 (已加认证)")
+32
View File
@@ -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) (已加认证)')
+247
View File
@@ -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)
+56
View File
@@ -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
+7
View File
@@ -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')
+276
View File
@@ -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)}
+78
View File
@@ -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
Executable
+82
View File
@@ -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
+498
View File
@@ -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}
}
+84
View File
@@ -0,0 +1,84 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SenSu 面板</title>
<!-- 🟢 加上 static 前缀 -->
<link rel="stylesheet" href="./static/css/style.css">
</head>
<body>
<div id="app" class="app-frame">
<header class="top-bar">
<div class="title" style="display:flex;align-items:center;gap:8px">
🐱 SenSu Alpha
<button id="top-ver-btn" class="btn btn-sm btn-text" style="font-size:.7em;padding:1px 6px" onclick="checkTopUpdate()" title="检查更新">v?</button>
</div>
<div class="user-info">
<span id="uname">Loading...</span>
<button class="theme-toggle" onclick="toggleTheme()" title="切换日夜间模式">🌓</button>
<button class="logout-btn" onclick="doLogout()">退出</button>
</div>
</header>
<aside class="sidebar">
<a class="nav-item active" data-page="dashboard">
<svg width="20" height="20" viewBox="0 0 24 24"><path d="M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z"/></svg>
<span>仪表盘</span>
</a>
<a class="nav-item" data-page="logs">
<svg width="20" height="20" viewBox="0 0 24 24"><path d="M14 2H6c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z"/></svg>
<span>实时日志</span>
</a>
<a class="nav-item" data-page="console">
<svg width="20" height="20" viewBox="0 0 24 24"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm-8 14H4v-4h8v4zm0-6H4V8h8v4zm8 6h-8v-4h8v4zm0-6h-8V8h8v4z"/></svg>
<span>控制台</span>
</a>
<a class="nav-item" data-page="plugins">
<svg width="20" height="20" viewBox="0 0 24 24"><path d="M20.5 11H19V7c0-1.1-.9-2-2-2h-4V3.5C13 2.12 11.88 1 10.5 1S8 2.12 8 3.5V5H4c-1.1 0-1.99.9-1.99 2v3.8H3.5c1.49 0 2.7 1.21 2.7 2.7s-1.21 2.7-2.7 2.7H2V20c0 1.1.9 2 2 2h3.8v-1.5c0-1.49 1.21-2.7 2.7-2.7 1.49 0 2.7 1.21 2.7 2.7V22H17c1.1 0 2-.9 2-2v-4h1.5c1.38 0 2.5-1.12 2.5-2.5S21.88 11 20.5 11z"/></svg>
<span>插件管理</span>
</a>
<a class="nav-item" data-page="projects">
<svg width="20" height="20" viewBox="0 0 24 24"><path d="M20 6h-8l-2-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2zm0 12H4V6h5.17l2 2H20v10z"/></svg>
<span>项目管理</span>
</a>
<a class="nav-item" data-page="proxy">
<svg width="20" height="20" viewBox="0 0 24 24"><path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></svg>
<span>反向代理</span>
</a>
<a class="nav-item" data-page="files">
<svg width="20" height="20" viewBox="0 0 24 24"><path d="M20 6h-8l-2-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2zm-1 4H5V8h14v2zm0 2v6H5v-6h14z"/></svg>
<span>文件管理</span>
</a>
<a class="nav-item" data-page="settings">
<svg width="20" height="20" viewBox="0 0 24 24"><path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 00.12-.61l-1.92-3.32a.49.49 0 00-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94L14.4 2.81a.484.484 0 00-.41-.3h-3.98c-.2 0-.38.13-.45.32l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96a.49.49 0 00-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58a.49.49 0 00-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.19.24.31.44.31h3.98c.2 0 .38-.13.45-.32l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg>
<span>框架设置</span>
</a>
<!-- Plugin WebUI pages (collapsible) -->
<div class="nav-expandable" id="nav-plugins">
<div class="nav-expand-header" onclick="togglePluginPages()">
<svg width="20" height="20" viewBox="0 0 24 24" style="flex-shrink:0"><path d="M20.5 11H19V7c0-1.1-.9-2-2-2h-4V3.5C13 2.12 11.88 1 10.5 1S8 2.12 8 3.5V5H4c-1.1 0-1.99.9-1.99 2v3.8H3.5c1.49 0 2.7 1.21 2.7 2.7s-1.21 2.7-2.7 2.7H2V20c0 1.1.9 2 2 2h3.8v-1.5c0-1.49 1.21-2.7 2.7-2.7 1.49 0 2.7 1.21 2.7 2.7V22H17c1.1 0 2-.9 2-2v-4h1.5c1.38 0 2.5-1.12 2.5-2.5S21.88 11 20.5 11z"/></svg>
<span style="flex:1">插件面板</span>
<svg width="14" height="14" viewBox="0 0 24 24" id="nav-plugins-arrow" style="transition:transform .2s"><path d="M7 10l5 5 5-5z"/></svg>
</div>
<div class="nav-expand-body" id="nav-plugins-body" style="display:none">
<div class="nav-expand-empty" style="padding:6px 16px;font-size:.75rem;color:var(--text-dim)">暂无插件面板</div>
</div>
</div>
<div class="toggle-sidebar" onclick="toggleSidebar()"></div>
</aside>
<main class="content-area">
<div id="progress" class="progress-bar"></div>
<div id="page-content" class="page-container"></div>
</main>
</div>
<!-- 🟢 1. 先加载图表库 -->
<script src="./static/js/chart.js?v=0603"></script>
<!-- 🟢 2. 再加载主逻辑 -->
<script src="./static/js/app_v3.js"></script>
</body>
</html>
+40
View File
@@ -0,0 +1,40 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SenSu 登录</title>
<link rel="stylesheet" href="./static/css/style.css">
</head>
<body>
<div class="login-wrapper">
<div class="login-card">
<h2>🐱 SenSu Login</h2>
<div class="input-group"><label>用户名</label><input class="input" type="text" id="u" value="admin"></div>
<div class="input-group"><label>密码</label><input class="input" type="password" id="p" value="admin"></div>
<button class="btn btn-filled" style="width:100%;margin-top:8px" onclick="doLogin()">进入系统</button>
<div id="err" class="err-msg"></div>
</div>
</div>
<script>
async function doLogin() {
const u = document.getElementById('u').value;
const p = document.getElementById('p').value;
const err = document.getElementById('err');
err.textContent = "验证中...";
try {
const res = await fetch('./api/login', {
method: 'POST', headers: {'Content-Type': 'application/json'},
credentials: 'include', body: JSON.stringify({username: u, password: p})
});
const data = await res.json();
if(res.ok && data.success) {
var hash = sessionStorage.getItem('sensu_redirect_hash') || '';
sessionStorage.removeItem('sensu_redirect_hash');
window.location.href = './home.html' + hash;
}
else err.textContent = data.msg || "凭证错误";
} catch(e) { err.textContent = "网络异常"; }
}
</script>
</body>
</html>
+32
View File
@@ -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');
}
+360
View File
@@ -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(/<script\b[^>]*>([\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 = '<div style="color:var(--error);text-align:center;margin-top:20vh">页面加载失败: '+e.message+'</div>';
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(/<style\b[^>]*>([\s\S]*?)<\/style>/gi, function(m,code){ styles+=code; return ''; });
var bodyMatch = html.match(/<body\b[^>]*>([\s\S]*)<\/body>/i);
var clean = bodyMatch ? bodyMatch[1] : html.replace(/<html[^>]*>|<\/html>|<head[^>]*>[\s\S]*?<\/head>|<body[^>]*>|<\/body>/gi, '');
var scripts = [];
clean = clean.replace(/<script\b[^>]*>([\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 <body> element
styles = styles.replace(/body\s*\{/gi, '.plugin-page-root{').replace(/body\b/gi, '.plugin-page-root');
content.style.padding = '0';
content.innerHTML = '<div class="plugin-page-root"><style>'+styles+'</style>'+clean+'</div>';
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 = '<div style="color:var(--error);text-align:center;margin-top:20vh">插件页面加载失败: '+ex.message+'</div>';
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(/<meta[^>]*>/gi, '').replace(/<link[^>]*>/gi, '');
// Extract inline scripts
var scripts = [];
var clean = html.replace(/<script\b[^>]*>([\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 = '<div style="color:var(--error);padding:40px;text-align:center">加载失败: '+e.message+'</div>';
});
};
// ═══ 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 += '<div class="nav-sub-item" data-plugin="'+p.plugin+'" data-path="'+p.path+'">'+
'<span class="nav-icon">'+(p.icon||'P')+'</span><span>'+p.title+'</span></div>';
});
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();
}
});
+360
View File
@@ -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(/<script\b[^>]*>([\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 = '<div style="color:var(--error);text-align:center;margin-top:20vh">页面加载失败: '+e.message+'</div>';
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(/<style\b[^>]*>([\s\S]*?)<\/style>/gi, function(m,code){ styles+=code; return ''; });
var bodyMatch = html.match(/<body\b[^>]*>([\s\S]*)<\/body>/i);
var clean = bodyMatch ? bodyMatch[1] : html.replace(/<html[^>]*>|<\/html>|<head[^>]*>[\s\S]*?<\/head>|<body[^>]*>|<\/body>/gi, '');
var scripts = [];
clean = clean.replace(/<script\b[^>]*>([\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 <body> element
styles = styles.replace(/body\s*\{/gi, '.plugin-page-root{').replace(/body\b/gi, '.plugin-page-root');
content.style.padding = '0';
content.innerHTML = '<div class="plugin-page-root"><style>'+styles+'</style>'+clean+'</div>';
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 = '<div style="color:var(--error);text-align:center;margin-top:20vh">插件页面加载失败: '+ex.message+'</div>';
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(/<meta[^>]*>/gi, '').replace(/<link[^>]*>/gi, '');
// Extract inline scripts
var scripts = [];
var clean = html.replace(/<script\b[^>]*>([\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 = '<div style="color:var(--error);padding:40px;text-align:center">加载失败: '+e.message+'</div>';
});
};
// ═══ 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 += '<div class="nav-sub-item" data-plugin="'+p.plugin+'" data-path="'+p.path+'">'+
'<span class="nav-icon">'+(p.icon||'P')+'</span><span>'+p.title+'</span></div>';
});
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();
}
});
+360
View File
@@ -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(/<script\b[^>]*>([\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 = '<div style="color:var(--error);text-align:center;margin-top:20vh">页面加载失败: '+e.message+'</div>';
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(/<style\b[^>]*>([\s\S]*?)<\/style>/gi, function(m,code){ styles+=code; return ''; });
var bodyMatch = html.match(/<body\b[^>]*>([\s\S]*)<\/body>/i);
var clean = bodyMatch ? bodyMatch[1] : html.replace(/<html[^>]*>|<\/html>|<head[^>]*>[\s\S]*?<\/head>|<body[^>]*>|<\/body>/gi, '');
var scripts = [];
clean = clean.replace(/<script\b[^>]*>([\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 <body> element
styles = styles.replace(/body\s*\{/gi, '.plugin-page-root{').replace(/body\b/gi, '.plugin-page-root');
content.style.padding = '0';
content.innerHTML = '<div class="plugin-page-root"><style>'+styles+'</style>'+clean+'</div>';
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 = '<div style="color:var(--error);text-align:center;margin-top:20vh">插件页面加载失败: '+ex.message+'</div>';
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(/<meta[^>]*>/gi, '').replace(/<link[^>]*>/gi, '');
// Extract inline scripts
var scripts = [];
var clean = html.replace(/<script\b[^>]*>([\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 = '<div style="color:var(--error);padding:40px;text-align:center">加载失败: '+e.message+'</div>';
});
};
// ═══ 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 += '<div class="nav-sub-item" data-plugin="'+p.plugin+'" data-path="'+p.path+'">'+
'<span class="nav-icon">'+(p.icon||'P')+'</span><span>'+p.title+'</span></div>';
});
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();
}
});
+130
View File
@@ -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;
+29
View File
@@ -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');
});
});
});
+10
View File
@@ -0,0 +1,10 @@
<div class="terminal">
<div class="term-header">💻 FRAMEWORK CONSOLE</div>
<div class="term-body" id="cmd-box" style="font-size:13px; color:#ccc;">
<div style="color:var(--success)">SenSu Console Ready. Type 'help' or 'status'.</div>
</div>
<div class="term-input-area">
<span style="padding:10px; color:var(--accent)">$</span>
<input type="text" id="cmd-input" class="term-input" placeholder="输入命令并回车..." autocomplete="off">
</div>
</div>
+29
View File
@@ -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 += `<div style="margin-top:2px;${cls?'color:'+cls:''}">${text}</div>`;
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: () => {}
};
+93
View File
@@ -0,0 +1,93 @@
<div class="dash-layout">
<!-- 🟢 左侧:动态监控区 -->
<div class="dash-main">
<div class="dash-grid">
<div class="stat-card">
<h3>⏳ 运行时间</h3>
<div class="stat-value" id="d-uptime">--</div>
<div class="stat-sub" id="d-ver-badge">v?.?.?</div>
</div>
<div class="stat-card">
<h3>📦 插件状态</h3>
<div class="stat-value" id="d-plugins">--</div>
<div class="stat-sub">已加载 / 活跃</div>
</div>
<div class="stat-card">
<h3>🧠 内存使用</h3>
<div style="display:flex;align-items:baseline;gap:8px">
<div class="stat-value" id="d-mem">--%</div>
<div class="stat-sub" id="d-mem-detail">-- / --</div>
</div>
<canvas id="chart-mem" class="mini-chart"></canvas>
</div>
<div class="stat-card">
<h3>⚡ CPU 负载</h3>
<div class="stat-value" id="d-cpu">--%</div>
<canvas id="chart-cpu" class="mini-chart"></canvas>
</div>
<div class="stat-card">
<h3>🌐 网络流量</h3>
<div style="display:flex; gap:16px">
<div><div class="stat-sub" style="font-size:.65rem">下行</div><div class="stat-value" id="d-net-rx" style="font-size:1.4rem">--</div></div>
<div><div class="stat-sub" style="font-size:.65rem">上行</div><div class="stat-value" id="d-net-tx" style="font-size:1.4rem">--</div></div>
</div>
<canvas id="chart-net" class="mini-chart"></canvas>
</div>
<div class="stat-card">
<h3>⚡ 实时网速</h3>
<div style="display:flex; gap:16px">
<div><div class="stat-sub" style="font-size:.65rem">↓ 下载</div><div class="stat-value" id="d-speed-rx" style="font-size:1.4rem">--</div></div>
<div><div class="stat-sub" style="font-size:.65rem">↑ 上传</div><div class="stat-value" id="d-speed-tx" style="font-size:1.4rem">--</div></div>
</div>
<canvas id="chart-speed" class="mini-chart"></canvas>
</div>
<div class="stat-card">
<h3>💾 进程内存</h3>
<div class="stat-value" id="d-proc-mem">--</div>
<canvas id="chart-proc-mem" class="mini-chart"></canvas>
</div>
</div>
</div>
<!-- 🟢 右侧:高优先级信息栏 -->
<aside class="dash-sidebar">
<!-- 1. 硬件平台信息 -->
<div class="side-card">
<h3>💻 硬件平台</h3>
<div class="info-row"><span>操作系统</span><span class="info-value" id="info-os">--</span></div>
<div class="info-row"><span>硬件架构</span><span class="info-value" id="info-arch">--</span></div>
<div class="info-row"><span>核心数 (L/P)</span><span class="info-value" id="info-cores">--</span></div>
<div class="info-row"><span>物理内存</span><span class="info-value" id="info-mem-total">--</span></div>
<div class="info-row"><span>运行环境</span><span class="info-value" id="info-env">--</span></div>
</div>
<!-- 2. 框架简易信息 -->
<div class="side-card">
<h3>🐱 框架状态</h3>
<div class="info-row"><span>版本号</span><span class="info-value" id="info-fw-ver">--</span></div>
<div class="info-row"><span>访问地址</span><span class="info-value" id="info-addr">--</span></div>
<div class="info-row"><span>进程 PID</span><span class="info-value" id="info-pid">--</span></div>
<div class="info-row"><span>系统负载 (15m)</span><span class="info-value" id="info-load">--</span></div>
<div class="info-row" style="border:none; padding-top:8px;">
<span>运行状态</span>
<span class="info-value" style="color:var(--success)">🟢 Online</span>
</div>
</div>
<!-- 3. 快捷操作 -->
<div class="side-card">
<h3>
<svg width="18" height="18" viewBox="0 0 24 24" style="vertical-align:-3px;margin-right:6px"><path fill="currentColor" d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94L14.4 2.81c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41L9.25 5.35c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg>
快捷操作
</h3>
<button class="btn btn-sm btn-tonal" style="width:100%;margin-bottom:6px" onclick="window.PluginsModule?.refresh()">
<svg width="16" height="16" viewBox="0 0 24 24"><path fill="currentColor" d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>
刷新插件列表
</button>
<button class="btn btn-sm btn-outlined" style="width:100%;color:var(--error);border-color:var(--error)" onclick="doLogout()">
<svg width="16" height="16" viewBox="0 0 24 24"><path fill="currentColor" d="M17 7l-1.41 1.41L18.17 11H8v2h10.17l-2.58 2.58L17 17l5-5zM4 5h8V3H4c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h8v-2H4V5z"/></svg>
退出登录
</button>
</div>
</aside>
</div>
+202
View File
@@ -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';
}
@@ -0,0 +1,12 @@
<!DOCTYPE html><html lang="zh"><head><meta charset="UTF-8"><title>Example Plugin</title>
<style>body{font-family:monospace;background:#0e1416;color:#e0e3e4;padding:16px}
h2{color:#00bcd4}.card{background:#1a1f21;border-radius:8px;padding:12px;margin:8px 0}
button{background:#00bcd4;color:#000;padding:8px 16px;border:none;border-radius:4px;cursor:pointer}
.counter{font-size:48px;color:#4caf50;text-align:center;padding:20px}
.log{background:#000;color:#0f0;padding:8px;border-radius:4px;max-height:200px;overflow-y:auto;font-size:12px}
</style></head><body>
<h2>Example Plugin Panel</h2>
<div class="card"><h3>Counter</h3><div class="counter" id="count">0</div>
<button onclick="inc()">+1</button> <button onclick="reset()">Reset</button></div>
<div class="card"><h3>Events (SSE)</h3><div class="log" id="log">Waiting...</div></div>
<script>let n=0;function inc(){n++;document.getElementById("count").textContent=n;fetch("/SenSu/plugin/example_plugin/event",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({type:"counter",value:n})})}function reset(){n=0;document.getElementById("count").textContent=0}const es=new EventSource("/SenSu/plugin/example_plugin/sse");es.addEventListener("counter",e=>{const d=JSON.parse(e.data);document.getElementById("log").innerHTML+=new Date().toLocaleTimeString()+" counter="+d.value+"<br>"})</script></body></html>
+98
View File
@@ -0,0 +1,98 @@
<!-- File Manager Page -->
<meta charset="UTF-8">
<link rel="stylesheet" href="../css/style.css?v=0601">
<div class="fm-container">
<!-- Top bar: editable path + actions -->
<div class="fm-toolbar">
<input class="fm-path-input" id="fm-path-input" type="text" spellcheck="false" title="输入路径后回车跳转">
<div class="fm-actions">
<label class="fm-toggle">
<input type="checkbox" id="fm-hidden" onchange="FilesModule.refresh()">
<span>显示隐藏</span>
</label>
<button class="btn btn-sm btn-outlined" onclick="FilesModule.createFile()">
<svg width="14" height="14" viewBox="0 0 24 24"><path fill="currentColor" d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm-1 2l5 5h-5V4zM8 14h8v2H8v-2zm0-4h8v2H8v-2z"/></svg>
新建文件
</button>
<button class="btn btn-sm btn-outlined" onclick="FilesModule.createDir()">
<svg width="14" height="14" viewBox="0 0 24 24"><path fill="currentColor" d="M20 6h-8l-2-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2zm-2 4h-3v3h-2v-3h-3V8h3V5h2v3h3v2z"/></svg>
新建文件夹
</button>
<button class="btn btn-sm btn-tonal" onclick="document.getElementById('fm-upload-input').click()">
<svg width="14" height="14" viewBox="0 0 24 24"><path fill="currentColor" d="M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z"/></svg>
上传
</button>
<input type="file" id="fm-upload-input" multiple style="display:none" onchange="FilesModule.upload(this.files)">
</div>
</div>
<!-- Breadcrumb row (own line) -->
<div class="fm-breadcrumb" id="fm-breadcrumb"></div>
<!-- File list -->
<div class="fm-list" id="fm-list">
<div style="text-align:center;color:var(--text-dim);padding:40px">加载中...</div>
</div>
<!-- Editor overlay -->
<div class="fm-editor-overlay" id="fm-editor" style="display:none">
<div class="fm-editor-header">
<span id="fm-editor-title">编辑文件</span>
<div>
<button class="btn btn-sm btn-tonal" onclick="FilesModule.saveFile()">💾 保存</button>
<button class="btn btn-sm btn-outlined" onclick="FilesModule.closeEditor()">✕ 关闭</button>
</div>
</div>
<textarea id="fm-editor-textarea" spellcheck="false"></textarea>
</div>
<!-- Delete confirm dialog -->
<div class="fm-dialog-overlay" id="fm-dialog" style="display:none">
<div class="fm-dialog">
<p id="fm-dialog-msg">确认删除?</p>
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:16px">
<button class="btn btn-sm btn-outlined" onclick="FilesModule.closeDialog()">取消</button>
<button class="btn btn-sm btn-danger" id="fm-dialog-confirm" onclick="FilesModule.confirmDelete()">删除</button>
</div>
</div>
</div>
<!-- Context menu -->
<div class="fm-context" id="fm-context" style="display:none">
<div class="fm-context-item" onclick="FilesModule.ctxDownload()"><svg width="14" height="14" viewBox="0 0 24 24"><path fill="currentColor" d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/></svg> 下载</div>
<div class="fm-context-item" onclick="FilesModule.ctxRename()"><svg width="14" height="14" viewBox="0 0 24 24"><path fill="currentColor" d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg> 重命名</div>
<div class="fm-context-item" onclick="FilesModule.ctxEdit()"><svg width="14" height="14" viewBox="0 0 24 24"><path fill="currentColor" d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm-1 2l5 5h-5V4zM8 14h8v2H8v-2zm0-4h8v2H8v-2z"/></svg> 编辑</div>
<div class="fm-context-sep"></div>
<div class="fm-context-item danger" onclick="FilesModule.ctxDelete()"><svg width="14" height="14" viewBox="0 0 24 24"><path fill="currentColor" d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg> 删除</div>
</div>
</div>
<script>
// Load files.js for standalone mode (iframe picker)
if(window.parent !== window && !window.FilesModule){
// Sync theme from parent window
try {
var parentTheme = window.parent.document.documentElement.getAttribute('data-theme');
if(parentTheme) document.documentElement.setAttribute('data-theme', parentTheme);
} catch(e){}
// Reset body/container for iframe — no topbar, no sidebar
var fmStyle=document.createElement('style');
fmStyle.textContent='html,body{height:100%;margin:0;overflow:hidden}.fm-container{height:100%;gap:8px}.fm-list{padding-bottom:0}';
document.head.appendChild(fmStyle);
// Derive paths from our own URL: /SenSu/static/pages/files.html → /SenSu
var apiBase = location.pathname.replace(/\/static\/pages\/files\.html.*$/, '');
var script = document.createElement('script');
script.src = apiBase + '/static/pages/files.js?v=0603';
script.onload = function(){
if(!window.FilesModule) return;
window.FilesModule._apiBase = apiBase;
window.FilesModule.init();
};
script.onerror = function(){
document.body.innerHTML = '<div style=\"color:red;padding:20px\">Failed to load files.js</div>';
};
document.head.appendChild(script);
}
</script>
+419
View File
@@ -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 = '<div style="text-align:center;color:var(--error);padding:40px">' +
'⚠ ' + this._esc(msg) + '</div>';
// Still update the input and breadcrumb to show what was attempted
FilesModule._$('fm-breadcrumb').innerHTML =
'<span class="fm-crumb" style="color:var(--error)">' + this._esc(msg) + '</span>';
},
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 =
'<div style="text-align:center;color:var(--error);padding:40px">加载失败: ' + self._esc(e.message) + '</div>';
});
},
/* ── 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 += '<div class="fm-resolved-crumbs">' +
'<span class="fm-crumb-sep" style="color:var(--primary)">↳</span>' +
self._buildBreadcrumbHTML(d.resolved_breadcrumbs) + '</div>';
}
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 += '<div class="fm-row fm-dir fm-parent" data-path="' + self._escAttr(d.parent) + '" data-is-dir="1">' +
'<span class="fm-icon"><svg width="18" height="18" viewBox="0 0 24 24"><path fill="var(--text-dim)" d="M20 6h-8l-2-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2zm-1 4H5V8h14v2z"/></svg></span>' +
'<span class="fm-name" style="font-weight:600">..</span>' +
'<span class="fm-size"></span>' +
'<span class="fm-date"></span>' +
'</div>';
}
if (d.items && d.items.length) {
d.items.forEach(function(item) {
if (item.broken) {
// Broken symlink or unreadable entry — show as disabled
rows += '<div class="fm-row fm-broken" data-path="">' +
'<span class="fm-icon"><svg width="18" height="18" viewBox="0 0 24 24"><path fill="var(--error)" d="M11 18h2v-2h-2v2zm1-16C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm0-14c-2.21 0-4 1.79-4 4h2c0-1.1.9-2 2-2s2 .9 2 2c0 2-3 1.75-3 5h2c0-2.25 3-2.5 3-5 0-2.21-1.79-4-4-4z"/></svg></span>' +
'<span class="fm-name" style="color:var(--error)" title="损坏的链接或无法访问">' + self._esc(item.name) + '</span>' +
'<span class="fm-size">—</span>' +
'<span class="fm-date">—</span></div>';
return;
}
var icon = item.is_dir ? FilesModule._dirIcon() : FilesModule._fileIcon(item.ext);
var sizeStr = item.is_dir ? '—' : FilesModule._fmtSize(item.size);
rows += '<div class="fm-row' + (item.is_dir ? ' fm-dir' : '') +
'" data-path="' + self._escAttr(item.path) +
'" data-is-dir="' + (item.is_dir?'1':'0') + '">' +
'<span class="fm-icon">' + icon + '</span>' +
'<span class="fm-name">' + self._esc(item.name) + '</span>' +
'<span class="fm-size">' + sizeStr + '</span>' +
'<span class="fm-date">' + (item.mtime_str || '') + '</span>' +
'</div>';
});
}
if (!rows) {
list.innerHTML = '<div style="text-align:center;color:var(--text-dim);padding:40px">空目录</div>';
} 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 = '<div style="display:flex;align-items:center;gap:8px;padding:8px 12px;background:var(--primary-container);color:var(--md-sys-color-on-primary-container);border-radius:var(--shape-xs);font-size:.82rem">' +
'<span><svg width="16" height="16" viewBox="0 0 24 24" style="vertical-align:-3px"><path fill="currentColor" d="M20 6h-8l-2-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2z"/></svg> 选择' + (self.pickerMode === 'file' ? '文件' : '目录') + '模式</span>' +
'<button type="button" style="margin-left:auto;padding:6px 18px;border:none;border-radius:20px;background:var(--primary);color:var(--on-primary);font-weight:500;cursor:pointer;font-size:.82rem" onclick="_fmPick()">选择当前目录</button>' +
'<button type="button" style="padding:6px 18px;border:1px solid;border-radius:20px;background:transparent;color:var(--md-sys-color-on-primary-container);font-weight:500;cursor:pointer;font-size:.82rem" onclick="_fmCancel()">取消</button>' +
'</div>';
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 = '<svg width="16" height="16" viewBox="0 0 24 24" style="vertical-align:-3px;margin-right:4px"><path fill="var(--primary)" d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm-1 2l5 5h-5V4zM8 14h8v2H8v-2zm0-4h8v2H8v-2z"/></svg>' + (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 += '<span class="fm-crumb" data-path="' + this._escAttr(c.path || '') + '">' + this._esc(c.label) + '</span>';
if (i < crumbs.length - 1) h += '<span class="fm-crumb-sep">▸</span>';
}
return h;
},
_esc: function(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
},
_escAttr: function(s) {
return String(s).replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
},
_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 '<svg width="18" height="18" viewBox="0 0 24 24"><path fill="var(--primary)" d="M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/></svg>';
},
_fileIcon: function(ext) {
// Group by category → single SVG per category
var code = '<svg width="18" height="18" viewBox="0 0 24 24"><path fill="var(--text-dim)" d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm-1 2l5 5h-5V4zM8 14h4v-2H8v2zm0 3h2v-2H8v2z"/></svg>';
var img = '<svg width="18" height="18" viewBox="0 0 24 24"><path fill="var(--text-dim)" d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/></svg>';
var arch = '<svg width="18" height="18" viewBox="0 0 24 24"><path fill="var(--text-dim)" d="M20 6h-8l-2-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2zm-2 10H6v-2h12v2zm0-4H6V8h12v4z"/></svg>';
var media = '<svg width="18" height="18" viewBox="0 0 24 24"><path fill="var(--text-dim)" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg>';
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}), '*');
}
};
+4
View File
@@ -0,0 +1,4 @@
<div class="terminal">
<div class="term-header">📡 LIVE LOG STREAM (WebSocket)</div>
<div class="term-body" id="log-box" style="font-size:13px;"></div>
</div>
+132
View File
@@ -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('<div style="color:var(--md-sys-color-primary)">🟢 Connected</div>');
};
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('<div style="color:var(--error)">🔴 Disconnected — reconnecting...</div>');
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 += '<div class="log-entry">' +
'<span style="color:#555;margin-right:5px">' + t + '</span>' +
'<span class="' + cls + '">[' + d.level + ']</span> ' +
self._escapeHtml(d.message || '') + '</div>';
}
self._appendHTML(html);
},
_appendHTML: function(html) {
var self = this;
if (!self.box) return;
// Estimate line count from <div> tags
var newLines = (html.match(/<div/g) || []).length;
self.lineCount += newLines;
// Trim old lines if over cap
while (self.lineCount > 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(/<div/g) || []).length);
}
self.box.removeChild(self.box.firstChild);
self.lineCount = Math.max(0, self.lineCount - removed);
}
// Efficient append: insertAdjacentHTML instead of innerHTML +=
self.box.insertAdjacentHTML("beforeend", html);
self.box.scrollTop = self.box.scrollHeight;
},
_escapeHtml: function(text) {
return String(text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
},
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;
}
};
+125
View File
@@ -0,0 +1,125 @@
<div class="plugin-page-root" style="padding:16px">
<h2 style="margin:0 0 12px 0"><svg width="22" height="22" viewBox="0 0 24 24" style="vertical-align:-4px;margin-right:6px"><path fill="currentColor" d="M20.5 11H19V7c0-1.1-.9-2-2-2h-4V3.5C13 2.12 11.88 1 10.5 1S8 2.12 8 3.5V5H4c-1.1 0-1.99.9-1.99 2v3.8H3.5c1.49 0 2.7 1.21 2.7 2.7s-1.21 2.7-2.7 2.7H2V20c0 1.1.9 2 2 2h3.8v-1.5c0-1.49 1.21-2.7 2.7-2.7 1.49 0 2.7 1.21 2.7 2.7V22H17c1.1 0 2-.9 2-2v-4h1.5c1.38 0 2.5-1.12 2.5-2.5S21.88 11 20.5 11z"/></svg>插件管理</h2>
<!-- ── 统计卡片 ── -->
<div id="stats-card" style="display:flex;gap:12px;margin-bottom:16px;flex-wrap:wrap">
<div class="stat-chip"><span class="stat-num" id="st-total">-</span> 总计</div>
<div class="stat-chip loaded"><span class="stat-num" id="st-loaded">-</span> 已启用</div>
<div class="stat-chip disabled"><span class="stat-num" id="st-disabled">-</span> 已停用</div>
<div class="stat-chip failed"><span class="stat-num" id="st-failed">-</span> 启动失败</div>
</div>
<!-- ── 导入区域 ── -->
<div style="display:flex;gap:16px;margin-bottom:16px;flex-wrap:wrap;align-items:stretch">
<div class="import-card">
<div class="card-title"><svg width="18" height="18" viewBox="0 0 24 24" style="vertical-align:-3px;margin-right:4px"><path fill="currentColor" d="M20 6h-8l-2-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2zm0 12H4V6h5.17l2 2H20v10z"/></svg>导入 ZIP 包</div>
<label class="file-label" for="zip-file">选择 .zip 文件</label>
<input type="file" id="zip-file" accept=".zip" class="file-input">
<div class="card-actions">
<button class="btn btn-sm btn-filled" onclick="importZip()">上传安装</button>
<span id="zip-status" class="status-text"></span>
</div>
</div>
<div class="import-card">
<div class="card-title"><svg width="18" height="18" viewBox="0 0 24 24" style="vertical-align:-3px;margin-right:4px"><path fill="currentColor" d="M9.4 16.6L4.8 12l4.6-4.6L8 6l-6 6 6 6 1.4-1.4zm5.2 0l4.6-4.6-4.6-4.6L16 6l6 6-6 6-1.4-1.4z"/></svg>Git Clone 安装</div>
<input id="git-url" class="text-input" placeholder="https://github.com/user/repo.git">
<div class="card-actions">
<button class="btn btn-sm btn-filled" onclick="importGit()">Clone 安装</button>
<span id="git-status" class="status-text"></span>
</div>
</div>
</div>
<!-- ── 失败详情 ── -->
<div id="fail-detail" style="display:none;margin-bottom:12px;padding:10px;background:var(--md-sys-color-surface-container-lowest);border-radius:var(--shape-xs);font-size:.8rem;color:var(--error)"></div>
<!-- ── 插件列表 ── -->
<div id="plugin-list">加载中...</div>
<style>
.stat-chip { display:flex;align-items:center;gap:6px;padding:8px 14px;border-radius:var(--shape-sm);background:var(--md-sys-color-surface-container);min-width:100px }
.stat-chip.loaded { border-left:3px solid var(--success,#4caf50) }
.stat-chip.disabled { border-left:3px solid var(--text-dim) }
.stat-chip.failed { border-left:3px solid var(--error,#f44336) }
.stat-num { font-size:1.4rem;font-weight:700 }
.plugin-row { display:flex;align-items:center;justify-content:space-between;padding:10px 12px;margin-bottom:4px;background:var(--md-sys-color-surface-container);border-radius:var(--shape-sm);gap:8px }
.plugin-row .info { flex:1;min-width:0 }
.plugin-row .name { font-weight:600 } .plugin-row .ver { font-size:.75rem;color:var(--text-dim) }
.plugin-row .error-tag { font-size:.75rem;color:var(--error) }
.import-card { flex:1;min-width:280px;padding:14px;background:var(--md-sys-color-surface-container);border-radius:var(--shape-sm);display:flex;flex-direction:column;gap:8px;margin-bottom:4px }
.import-card .card-title { font-weight:600 }
.import-card .card-actions { display:flex;align-items:center;gap:8px;margin-top:auto }
.import-card .status-text { font-size:.8rem;white-space:nowrap }
.import-card .text-input { width:100%;padding:6px 8px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:var(--shape-xs);box-sizing:border-box }
.import-card .text-input:focus { outline:1px solid var(--primary) }
.file-input { color:var(--text);font-size:.85rem }
.file-input::file-selector-button { padding:5px 12px;background:var(--md-sys-color-surface-container-lowest);color:var(--text);border:1px solid var(--border);border-radius:var(--shape-xs);cursor:pointer;margin-right:8px }
.file-input::file-selector-button:hover { background:var(--md-sys-color-surface-container-highest) }
.file-label { font-size:.8rem;color:var(--text-dim) }
</style>
<script>
async function load() {
var list = document.getElementById("plugin-list");
try {
var r1 = await fetch("./api/plugins/stats"), stats = await r1.json();
document.getElementById("st-total").textContent = stats.total||0;
document.getElementById("st-loaded").textContent = stats.loaded||0;
document.getElementById("st-disabled").textContent = stats.disabled||0;
document.getElementById("st-failed").textContent = stats.failed||0;
var fd = document.getElementById("fail-detail");
if (stats.fail_details && Object.keys(stats.fail_details).length) {
fd.style.display = "block";
fd.innerHTML = "<strong>启动失败:</strong> " + Object.entries(stats.fail_details).map(function(e){return e[0]+": "+esc(e[1])}).join(" | ");
} else fd.style.display = "none";
var r2 = await fetch("./api/plugins"), data = await r2.json();
var plugins = data.plugins||[];
if (!plugins.length) { list.innerHTML = '<div style="text-align:center;padding:24px;color:var(--text-dim)">暂无插件</div>'; return; }
list.innerHTML = plugins.map(function(p){
return '<div class="plugin-row"><div class="info"><div class="name">'+esc(p.name)+'</div><div class="ver">v'+esc(p.version||'?')+(p.running?' ✓':' ⏸')+'</div>'+(p.error?'<div class="error-tag">'+esc(p.error)+'</div>':'')+'</div>'+
'<button class="btn btn-sm btn-outlined" onclick="togglePlugin(\''+esc(p.name)+'\',\''+(p.running?'unload':'enable')+'\')">'+(p.running?'停用':'启用')+'</button>'+
'<button class="btn btn-sm btn-outlined" onclick="togglePlugin(\''+esc(p.name)+'\',\'reload\')">重载</button></div>';
}).join("");
} catch(e) { list.innerHTML = '<div style="color:var(--error)">加载失败: '+esc(String(e))+'</div>'; }
}
async function togglePlugin(name, action) {
await fetch("./api/plugins/"+name+"/"+action, {method:"POST"});
load();
}
async function importZip() {
var f = document.getElementById("zip-file").files[0];
if (!f) { alert("请选择 .zip 文件"); return; }
var s = document.getElementById("zip-status");
s.textContent = "⏳ 上传中..."; s.style.color = "var(--text-dim)";
var fd = new FormData(); fd.append("file", f);
try {
var r = await fetch("./api/plugins/import", {method:"POST", body:fd});
var d = await r.json();
if (d.ok) { s.textContent = "✓ " + d.name + (d.loaded?" 已加载":" 待手动加载"); s.style.color = "var(--success)"; }
else { s.textContent = "✗ " + (d.error||"失败"); s.style.color = "var(--error)"; }
} catch(e) { s.textContent = "✗ " + e; s.style.color = "var(--error)"; }
load();
}
async function importGit() {
var url = document.getElementById("git-url").value.trim();
if (!url) { alert("请输入 Git 仓库地址"); return; }
var s = document.getElementById("git-status");
s.textContent = "⏳ Clone 中..."; s.style.color = "var(--text-dim)";
try {
var r = await fetch("./api/plugins/import", {method:"POST", headers:{"Content-Type":"application/json"}, body:JSON.stringify({url:url})});
var d = await r.json();
if (d.ok) { s.textContent = "✓ " + d.name + (d.loaded?" 已加载":" 待手动加载"); s.style.color = "var(--success)"; }
else { s.textContent = "✗ " + (d.error||"失败"); s.style.color = "var(--error)"; }
} catch(e) { s.textContent = "✗ " + e; s.style.color = "var(--error)"; }
load();
}
function esc(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
load();
</script>
</div>
+36
View File
@@ -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 = '<div style="color:var(--text-dim)">暂无已加载插件</div>'; return; }
list.innerHTML = data.plugins.map(p => `
<div class="plugin-card">
<div class="plugin-info">
<h4>${p.name} <span class="badge ${p.running?'badge-run':'badge-stop'}">${p.running?'RUNNING':'STOPPED'}</span></h4>
<p>v${p.version||'1.0.0'} | ${p.enabled?'已启用':'已禁用'}</p>
</div>
<div class="plugin-act">
${p.running
? `<button class="btn btn-sm btn-tonal" onclick="window.PluginsModule.act('${p.name}','disable')">停用</button>`
: `<button class="btn btn-sm btn-tonal" onclick="window.PluginsModule.act('${p.name}','enable')">启用</button>`
}
<button class="btn btn-sm btn-outlined" onclick="window.PluginsModule.act('${p.name}','reload')">重载</button>
</div>
</div>
`).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: () => {}
};
+61
View File
@@ -0,0 +1,61 @@
<!DOCTYPE html>
<div>
<h2 style="margin-bottom:16px">📦 项目管理</h2>
<div class="tabs">
<div class="tab active" onclick="showTab(event,'running')">运行中</div>
<div class="tab" onclick="showTab(event,'add')">+ 添加项目</div>
<div class="tab" onclick="showTab(event,'git')">Git 部署</div>
</div>
<div id="tab-running">
<div id="project-list" style="display:flex;flex-direction:column;gap:12px">加载中...</div>
</div>
<div id="tab-add" style="display:none">
<div class="card outlined" style="max-width:500px">
<h3 style="margin-bottom:12px">添加新项目</h3>
<div class="input-group"><span class="label">项目名称</span><input class="input" id="proj-name" placeholder="如: my-app"></div>
<div class="input-group"><span class="label">启动命令</span><input class="input" id="proj-cmd" placeholder="如: python3 main.py"></div>
<div class="input-group"><span class="label">工作目录</span><div style="display:flex;gap:6px"><input class="input" id="proj-cwd" value="." style="flex:1"><button class="btn btn-sm btn-outlined" onclick="pickPath('dir',function(p){document.getElementById('proj-cwd').value=p})" title="选择目录" style="flex-shrink:0"><svg width="14" height="14" viewBox="0 0 24 24"><path fill="currentColor" d="M20 6h-8l-2-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2zm0 12H4V6h5.17l2 2H20v10z"/></svg></button></div></div>
<div class="input-group"><span class="label">端口 (可选)</span><input class="input" id="proj-port" type="number" placeholder="5000" style="width:120px"></div>
<div class="input-group"><span class="label">代理路径 (可选)</span><input class="input" id="proj-proxy" placeholder="如: /myapp" style="width:200px"></div>
<button class="btn btn-filled" onclick="addProject()" style="margin-top:8px">启动项目</button>
</div>
</div>
<div id="tab-git" style="display:none">
<div class="card outlined" style="max-width:500px">
<h3 style="margin-bottom:12px">从 Git 部署</h3>
<div class="input-group"><span class="label">Git URL</span><input class="input" id="git-url" placeholder="https://git.yeij.top/user/repo.git"></div>
<div class="input-group"><span class="label">分支</span><input class="input" id="git-branch" value="main" style="width:160px"></div>
<div class="input-group"><span class="label">目标目录</span><div style="display:flex;gap:6px"><input class="input" id="git-cwd" value="data/projects" style="flex:1"><button class="btn btn-sm btn-outlined" onclick="pickPath("dir",function(p){document.getElementById("git-cwd").value=p})" title="选择目录" style="flex-shrink:0"><svg width="14" height="14" viewBox="0 0 24 24"><path fill="currentColor" d="M20 6h-8l-2-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2zm0 12H4V6h5.17l2 2H20v10z"/></svg></button></div></div>
<button class="btn btn-filled" onclick="deployGit()" style="margin-top:8px">克隆并部署</button>
</div>
</div>
</div>
<script>
var projects=[];
function showTab(e,t){document.querySelectorAll("#tab-running,#tab-add,#tab-git").forEach(el=>el.style.display="none");document.getElementById("tab-"+t).style.display="block";document.querySelectorAll(".tab").forEach(el=>el.classList.remove("active"));e.target.classList.add("active")}
async function refresh(){
try{var r=await fetch("./api/projects");var d=await r.json();projects=d.projects;var h="";for(var p of projects){var cls=p.status==="running"?"status-running":p.status==="error"?"status-error":"status-stopped";h+='<div class="mgmt-card"><div class="status-dot '+cls+'"></div><div class="info"><b>'+p.name+'</b><br><small>'+p.status+" PID:"+(p.pid||"-")+" :"+(p.port||"-")+" "+(p.uptime?p.uptime+"s":"")+(p.proxy?" → "+p.proxy:"")+'</small></div><div class="actions"><button class="btn btn-sm btn-danger" onclick="stopP(this,'+JSON.stringify(p.name)+')">停止</button><button class="btn btn-sm btn-outlined" onclick="logsP(this,'+JSON.stringify(p.name)+')">日志</button><button class="btn btn-sm btn-text" onclick="cmdP(this,'+JSON.stringify(p.name)+')">命令</button></div></div><div class="log-box" id="logs-'+p.name+'"></div>'}document.getElementById("project-list").innerHTML=h||"<div style=\"color:var(--text-dim);text-align:center;padding:40px\">暂无项目</div>"}catch(e){document.getElementById("project-list").innerHTML="<div style=\"color:var(--error);text-align:center;padding:40px\">引擎未就绪 (API 错误)</div>"}
}
async function addProject(){
var n=document.getElementById("proj-name").value,cmd=document.getElementById("proj-cmd").value;
if(!n||!cmd){alert("请填写名称和命令");return}
await fetch("./api/projects/run",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:n,cmd:cmd.split(" "),cwd:document.getElementById("proj-cwd").value,port:parseInt(document.getElementById("proj-port").value)||0,proxy_path:document.getElementById("proj-proxy").value})});
refresh()
}
async function deployGit(){
var u=document.getElementById("git-url").value,b=document.getElementById("git-branch").value;
if(!u){alert("请填写 Git URL");return}
var n=u.split("/").pop().replace(".git","");
await fetch("./api/projects/run",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:n,cmd:["git","clone","-b",b,u,n],cwd:document.getElementById("git-cwd").value})});
refresh()
}
async function stopP(el,n){await fetch("./api/projects/"+n+"/stop",{method:"POST"});refresh()}
async function logsP(el,n){var b=document.getElementById("logs-"+n);b.style.display=b.style.display==="none"?"block":"none";if(b.style.display==="block"){var r=await fetch("./api/projects/"+n+"/logs?tail=30");var d=await r.json();b.innerHTML=d.logs.join("<br>")||"(无日志)"}}
async function cmdP(el,n){var t=prompt("命令: "+n);if(t)await fetch("./api/projects/"+n+"/stdin",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({text:t})})}
setInterval(refresh,4000);refresh();
</script>
+34
View File
@@ -0,0 +1,34 @@
<div>
<h2 style="margin-bottom:16px">🔀 反向代理</h2>
<div class="card outlined" style="max-width:520px;margin-bottom:16px">
<h3 style="margin-bottom:12px">添加代理</h3>
<div class="input-group"><span class="label">框架路径</span><input class="input" id="px-path" placeholder="/app"></div>
<div class="input-group"><span class="label">目标 URL</span><input class="input" id="px-target" placeholder="http://192.168.1.5:8080"></div>
<div class="input-group"><span class="label">描述 (可选)</span><input class="input" id="px-desc" placeholder="我的应用"></div>
<button class="btn btn-filled" onclick="addP()">添加代理</button>
</div>
<div id="proxy-list" style="display:flex;flex-direction:column;gap:12px">加载中...</div>
</div>
<script>
function refresh(){
fetch("/SenSu/api/proxy").then(r=>{if(!r.ok)throw new Error(r.status);return r.json()}).then(d=>{
var h="";
for(var p of d.proxies||[]){
h+='<div class="mgmt-card"><div class="status-dot status-running"></div><div class="info"><b>'+p.path+'</b><br><small>→ '+p.target+(p.description?" ("+p.description+")":"")+(p.external?" 🌐":"")+'</small></div><div class="actions"><button class="btn btn-sm btn-danger" onclick="delP(\"'+p.path+'\")">删除</button></div></div>';
}
document.getElementById("proxy-list").innerHTML=h||"<div style=\"color:var(--text-dim);text-align:center;padding:40px\">暂无代理配置</div>"
}).catch(function(e){
document.getElementById("proxy-list").innerHTML="<div style=\"color:var(--error);text-align:center;padding:40px\">代理服务未就绪 (API 返回 "+e.message+")</div>"
})
}
function addP(){
var p=document.getElementById("px-path").value,t=document.getElementById("px-target").value;
if(!p||!t){alert("请填写");return}
fetch("/SenSu/api/proxy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({path:p,target:t,description:document.getElementById("px-desc").value})}).then(r=>r.json()).then(d=>{if(d.ok)refresh()})
}
function delP(path){fetch("/SenSu/api/proxy/"+encodeURIComponent(path),{method:"DELETE"}).then(()=>refresh())}
refresh();
</script>
+264
View File
@@ -0,0 +1,264 @@
<div class="plugin-page-root">
<h2><svg width="22" height="22" viewBox="0 0 24 24" style="vertical-align:-4px;margin-right:6px"><path fill="currentColor" d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 00.12-.61l-1.92-3.32a.49.49 0 00-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94L14.4 2.81a.484.484 0 00-.41-.3h-3.98c-.2 0-.38.13-.45.32l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96a.49.49 0 00-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58a.49.49 0 00-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.19.24.31.44.31h3.98c.2 0 .38-.13.45-.32l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg>框架设置</h2>
<!-- ── 版本与更新 ── -->
<div class="card" style="margin-bottom:24px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
<h3 style="margin:0"><svg width="20" height="20" viewBox="0 0 24 24" style="vertical-align:-4px;margin-right:6px"><path fill="currentColor" d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>版本与更新</h3>
</div>
<div id="update-area">
<div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap">
<span style="font-size:1.1rem;font-weight:600">SenSu <span id="ver-current">v?</span></span>
<span id="ver-badge" style="font-size:.85rem;padding:2px 8px;border-radius:12px;background:var(--md-sys-color-surface-container-lowest)">检查中...</span>
<button class="btn btn-sm btn-filled" onclick="checkUpdate()">检查更新</button>
<button id="btn-apply" class="btn btn-sm btn-outlined" onclick="applyUpdate()">手动更新</button>
</div>
<div id="update-notes" style="margin-top:8px;font-size:.85rem;color:var(--text-dim)"></div>
</div>
<div style="margin-top:16px;border-top:1px solid var(--border);padding-top:12px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
<h4 style="margin:0"><svg width="18" height="18" viewBox="0 0 24 24" style="vertical-align:-3px;margin-right:4px"><path fill="currentColor" d="M20 6h-8l-2-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2zm0 12H4V6h5.17l2 2H20v10z"/></svg>备份管理</h4>
<button class="btn btn-sm btn-outlined" onclick="loadBackups()">刷新列表</button>
<button class="btn btn-sm btn-filled" onclick="createBackup()" style="margin-left:8px">+ 创建备份</button>
</div>
<div id="backup-list"><span style="color:var(--text-dim);font-size:.85rem">加载中...</span></div>
<div id="update-log-area" style="display:none;margin-top:12px">
<div style="font-weight:600;margin-bottom:4px">更新日志</div>
<pre id="update-log" style="background:var(--md-sys-color-surface-container-lowest);padding:8px;border-radius:var(--shape-xs);max-height:200px;overflow-y:auto;font-size:.8rem;margin:0;white-space:pre-wrap;word-break:break-all"></pre>
</div>
</div>
</div>
<!-- ── API Key 管理 ── -->
<div class="card" style="margin-bottom:24px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<h3 style="margin:0"><svg width="20" height="20" viewBox="0 0 24 24" style="vertical-align:-4px;margin-right:6px"><path fill="currentColor" d="M12.65 10C11.83 7.67 9.61 6 7 6c-3.31 0-6 2.69-6 6s2.69 6 6 6c2.61 0 4.83-1.67 5.65-4H17v4h4v-4h2v-4H12.65zM7 14c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2z"/></svg>API Key 管理</h3>
<button class="btn btn-filled" onclick="showCreateKey()">+ 创建 Key</button>
</div>
<p style="color:var(--text-dim);font-size:.85rem;margin-bottom:12px">
API Key 用于服务器间安全通信。支持权限范围控制和过期时间。
</p>
<div id="apikey-list" style="overflow-x:auto">
<table style="width:100%;border-collapse:collapse">
<thead><tr style="text-align:left;border-bottom:1px solid var(--border)">
<th style="padding:8px">名称</th><th style="padding:8px">Key</th><th style="padding:8px">权限</th><th style="padding:8px">用量</th><th style="padding:8px">过期</th><th style="padding:8px">操作</th>
</tr></thead>
<tbody id="apikey-tbody"><tr><td colspan="6" style="padding:16px;text-align:center;color:var(--text-dim)">加载中...</td></tr></tbody>
</table>
</div>
</div>
<!-- ── 创建对话框 (hidden) ── -->
<div id="create-dialog" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.5);z-index:1000;display:none;align-items:center;justify-content:center">
<div class="card" style="min-width:420px;max-width:500px">
<h3 style="margin:0 0 16px 0">创建 API Key</h3>
<div class="form-group" style="margin-bottom:12px">
<label style="display:block;margin-bottom:4px;font-size:.85rem">名称</label>
<input id="new-key-name" class="input" placeholder="例如: 监控插件" style="width:100%;padding:8px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:var(--shape-xs)">
</div>
<div class="form-group" style="margin-bottom:12px">
<label style="display:block;margin-bottom:4px;font-size:.85rem">权限范围</label>
<select id="new-key-preset" class="input" style="width:100%;padding:8px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:var(--shape-xs)">
<option value="readonly">只读 — 仅查询系统状态</option>
<option value="monitor" selected>监控 — 查询 + 事件订阅</option>
<option value="full">管理 — 全部权限</option>
</select>
</div>
<div class="form-group" style="margin-bottom:16px">
<label style="display:block;margin-bottom:4px;font-size:.85rem">过期时间 (秒, 0=永不过期)</label>
<input id="new-key-ttl" class="input" type="number" value="0" min="0" style="width:100%;padding:8px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:var(--shape-xs)">
<span style="font-size:.75rem;color:var(--text-dim)">常用: 86400(1天) 604800(7天) 2592000(30天)</span>
</div>
<div style="display:flex;gap:8px;justify-content:flex-end">
<button class="btn btn-outlined" onclick="hideCreateKey()">取消</button>
<button class="btn btn-filled" onclick="doCreateKey()">创建</button>
</div>
<div id="create-result" style="margin-top:12px;display:none">
<div style="background:var(--md-sys-color-surface-container-lowest);padding:12px;border-radius:var(--shape-xs);word-break:break-all">
<strong style="color:var(--success)">✓ Key 已创建 (仅显示一次):</strong>
<code id="new-key-display" style="display:block;margin-top:4px;font-size:.9rem;color:var(--primary)"></code>
</div>
</div>
</div>
</div>
<style>
.input:focus { outline:1px solid var(--primary) }
#apikey-tbody td { padding:8px; border-bottom:1px solid var(--md-sys-color-surface-container-lowest) }
#apikey-tbody tr:hover { background:var(--md-sys-color-surface-container-lowest) }
.backup-row { display:flex;align-items:center;justify-content:space-between;padding:6px 0;border-bottom:1px solid var(--md-sys-color-surface-container-lowest);gap:8px }
.backup-name { font-size:.85rem;word-break:break-all;flex:1;min-width:0 }
</style>
<script>
// ── 版本与更新 ──
async function loadVersion() {
try {
var r = await fetch("./api/updates"), d = await r.json();
document.getElementById("ver-current").textContent = d.current_version || d.latest_version || "?";
var badge = document.getElementById("ver-badge");
var notesHtml = "";
if (d.update_available) {
badge.textContent = "↻ " + d.latest_version + " 可用";
badge.style.background = "var(--success,#4caf50)";
badge.style.color = "#fff";
document.getElementById("btn-apply").textContent = "立即更新";
document.getElementById("btn-apply").className = "btn btn-sm btn-filled";
document.getElementById("btn-apply").style.background = "var(--success,#4caf50)";
notesHtml = d.latest_version ? "最新版本: <b>"+esc(d.latest_version)+"</b>" : "";
} else if (d.checked) {
badge.textContent = "✓ 已是最新";
badge.style.background = "var(--md-sys-color-surface-container-lowest)";
badge.style.color = "var(--text)";
document.getElementById("btn-apply").textContent = "手动更新";
document.getElementById("btn-apply").className = "btn btn-sm btn-outlined";
document.getElementById("btn-apply").style.background = "";
} else {
badge.textContent = "⏳ 等待检查...";
}
if (d.last_check) {
notesHtml += (notesHtml ? " | " : "") + "上次检查: "+new Date(d.last_check*1000).toLocaleString();
}
document.getElementById("update-notes").innerHTML = notesHtml;
} catch(e) {}
}
async function createBackup() {
try {
var r = await fetch("./api/updates/backup", {method:"POST"}), d = await r.json();
if (d.ok) alert("✓ 备份已创建: " + d.name);
else alert("✗ " + (d.error||"失败"));
loadBackups();
} catch(e) { alert("✗ " + e); }
}
async function checkUpdate() {
document.getElementById("ver-badge").textContent = "⏳ 检查中...";
await fetch("./api/updates/check", {method:"POST"});
setTimeout(loadVersion, 1500);
}
var _updatePollTimer=null;
async function applyUpdate() {
if (!confirm("确认更新 SenSu?更新前会自动备份当前版本。")) return;
document.getElementById("btn-apply").disabled = true;
document.getElementById("btn-apply").textContent = "⏳ 更新中...";
try {
var r = await fetch("./api/updates/apply", {method:"POST"}), d = await r.json();
if (d.ok) alert("✓ " + d.msg);
else alert("✗ " + (d.error||"失败"));
} catch(e) { alert("✗ " + e); }
loadVersion().then(function(){ checkUpdate(); });
}
// ── 备份管理 ──
async function loadBackups() {
var el = document.getElementById("backup-list");
try {
var r = await fetch("./api/updates/backups"), d = await r.json();
if (!d.backups.length) { el.innerHTML = '<span style="color:var(--text-dim);font-size:.85rem">暂无备份</span>'; return; }
el.innerHTML = d.backups.map(function(b,i){
return '<div class="backup-row"><span class="backup-name">'+esc(b.name)+'</span><span style="font-size:.8rem;color:var(--text-dim)">'+b.size_mb+'MB | '+b.time+'</span>'+
'<button class="btn btn-sm btn-outlined" onclick="doRollback(\''+esc(b.name)+'\')">回滚</button></div>';
}).join("");
} catch(e) { el.innerHTML = '<span style="color:var(--error)">加载失败</span>'; }
}
async function doRollback(name) {
if (!confirm("确认回滚到 "+name+" 当前版本将被覆盖。")) return;
try {
var r = await fetch("./api/updates/rollback", {method:"POST", headers:{"Content-Type":"application/json"}, body:JSON.stringify({name:name})}), d = await r.json();
if (d.ok) alert("✓ " + d.msg);
else alert("✗ " + (d.error||"失败"));
} catch(e) { alert("✗ " + e); }
}
// ── 加载 API Keys ──
async function loadKeys() {
var tbody = document.getElementById("apikey-tbody");
try {
var resp = await fetch("./api/apikeys");
var data = await resp.json();
if (!data.keys || !data.keys.length) {
tbody.innerHTML = '<tr><td colspan="6" style="padding:16px;text-align:center;color:var(--text-dim)">暂无 API Key</td></tr>';
return;
}
var rows = [];
data.keys.forEach(function(k) {
var expStr = k.expires_at > 0 ? new Date(k.expires_at*1000).toLocaleDateString() : "永不过期";
if (k.expired) expStr = '<span style="color:var(--error)">已过期</span>';
var permLabel = k.permissions && k.permissions[0] === "admin" ? "管理" : (k.permissions && k.permissions.length <= 2 ? "只读" : "监控");
rows.push(
'<tr>' +
'<td>' + esc(k.name) + '</td>' +
'<td><code>' + esc(k.key) + '</code></td>' +
'<td><span class="badge">' + permLabel + '</span></td>' +
'<td>' + (k.request_count || 0) + '</td>' +
'<td>' + expStr + '</td>' +
'<td><button class="btn btn-sm btn-outlined" style="color:var(--error);border-color:var(--error)" onclick="deleteKey(\'' + esc(k.full_key) + '\',\'' + esc(k.name) + '\')">删除</button></td>' +
'</tr>'
);
});
tbody.innerHTML = rows.join("");
} catch(e) {
tbody.innerHTML = '<tr><td colspan="6" style="padding:16px;text-align:center;color:var(--error)">加载失败: ' + esc(String(e)) + '</td></tr>';
}
}
function esc(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
// ── 创建对话框 ──
function showCreateKey() {
document.getElementById("create-dialog").style.display = "flex";
document.getElementById("create-result").style.display = "none";
document.getElementById("new-key-name").value = "";
document.getElementById("new-key-ttl").value = "0";
}
function hideCreateKey() {
document.getElementById("create-dialog").style.display = "none";
}
async function doCreateKey() {
var name = document.getElementById("new-key-name").value.trim();
if (!name) { alert("请输入名称"); return; }
var preset = document.getElementById("new-key-preset").value;
var ttl = parseInt(document.getElementById("new-key-ttl").value) || 0;
try {
var resp = await fetch("./api/apikeys", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({name: name, preset: preset, ttl: ttl})
});
var data = await resp.json();
if (data.ok) {
document.getElementById("create-result").style.display = "block";
document.getElementById("new-key-display").textContent = data.key;
loadKeys();
} else {
alert("创建失败: " + (data.error || "未知错误"));
}
} catch(e) { alert("请求失败: " + e); }
}
// ── 删除 ──
async function deleteKey(fullKey, name) {
if (!confirm("确认删除 Key '" + name + "' 此操作不可撤销。")) return;
try {
var resp = await fetch("./api/apikeys", {
method: "DELETE",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({key: fullKey})
});
var data = await resp.json();
if (data.ok) loadKeys();
else alert("删除失败: " + (data.error || "未知错误"));
} catch(e) { alert("请求失败: " + e); }
}
// ── 初始加载 ──
loadKeys();
loadVersion().then(function(){ checkUpdate(); });
loadBackups();
</script>
</div>
+50
View File
@@ -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}")
+9
View File
@@ -0,0 +1,9 @@
name: "${plugin_name}"
version: "1.0.0"
description: "${description}"
author: "${author}"
settings:
enabled: true
auto_start: true
log_level: "INFO"
@@ -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}插件操作"
View File

Some files were not shown because too many files have changed in this diff Show More