From 5e74b0aad7a2ac7662d93571fbbd35ca48b41746 Mon Sep 17 00:00:00 2001 From: qinglong Date: Sat, 13 Jun 2026 15:05:38 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=F0=9F=9B=A1=EF=B8=8F=20Sentinel=20?= =?UTF-8?q?=E9=9B=86=E7=BE=A4=E7=9B=91=E6=8E=A7=E6=8F=92=E4=BB=B6=20+=20AP?= =?UTF-8?q?I=20Key=20=E7=B3=BB=E7=BB=9F=20+=20=E6=A1=86=E6=9E=B6=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit == API Key 系统 == - services/web_panel/routes/apikeys.py: CRUD + 权限模板 + 过期控制 - panel_auth + _check_plugin_auth 双重验证 (Session → API Key) - 模板: readonly(只读) / monitor(监控) / full(管理) - 持久化到 SenSuDB + 过期自动清理 == WebUI 框架设置页 == - static/web_panel/pages/settings.html: API Key 管理界面 - 创建对话框 (名称/权限/TTL), 列表表格, 一键删除 == 热重载修复 == - PluginService._reload_plugin: 路由器冻结时跳过 (避免破坏已注册路由) == 🛡️ Sentinel 插件 == - 多节点 SenSu 集群性能监控 - 后台 urllib+run_in_executor 轮询 (避免事件循环死锁) - SSE 实时推送仪表盘 - 节点管理页 (增删改+连接测试+备注) - 路径: /sentinel/api/nodes Co-Authored-By: Claude --- plugins/sentinel/__init__.py | 253 ++++++++++++++++++++++++++++++ plugins/sentinel/config.yaml | 16 ++ plugins/sentinel/dashboard.html | 91 +++++++++++ plugins/sentinel/permissions.yaml | 12 ++ plugins/sentinel/settings.html | 131 ++++++++++++++++ services/plugin_service.py | 9 ++ 6 files changed, 512 insertions(+) create mode 100644 plugins/sentinel/__init__.py create mode 100644 plugins/sentinel/config.yaml create mode 100644 plugins/sentinel/dashboard.html create mode 100644 plugins/sentinel/permissions.yaml create mode 100644 plugins/sentinel/settings.html diff --git a/plugins/sentinel/__init__.py b/plugins/sentinel/__init__.py new file mode 100644 index 0000000..85e5736 --- /dev/null +++ b/plugins/sentinel/__init__.py @@ -0,0 +1,253 @@ +#!/usr/bin/env python3 +"""🛡️ Sentinel — 多节点 SenSu 集群性能监控插件""" +import logging +import asyncio +import os +import time +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 页面 + for fid, title, fname in [ + ("sentinel_dash", "🛡️ Sentinel", "dashboard.html"), + ("sentinel_cfg", "🛡️ 节点管理", "settings.html"), + ]: + p = os.path.join(os.path.dirname(__file__), fname) + if os.path.exists(p): + with open(p) as f: + self.register_web_page(fid, title, f.read(), icon="S" if "dash" in fid else "⚙") + + # 注册 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=False + ) + + # 启动后台轮询 + 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 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", ""), + "enabled": n.get("enabled", True), + "online": c.get("online", False), + "system": c.get("system", {}), + "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(), + "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: + 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() + node = {"url": data.get("url", "").strip(), "api_key": data.get("api_key", "").strip()} + if not node["url"]: + return web.json_response({"ok": False, "error": "URL 为空"}, status=400) + 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() + changed = False + for node in nodes: + if not node.get("enabled", True): + continue + prev = _NODE_CACHE.get(node.get("id", ""), {}) + online, sys_info, err = await self._fetch_system(node) + new_status = { + "online": online, + "system": sys_info or {}, + "last_seen": time.time(), + "error": err if not online else "", + } + if (prev.get("online") != online + or abs(prev.get("last_seen", 0) - new_status["last_seen"]) > 5): + changed = True + _NODE_CACHE[node.get("id", "")] = new_status + if changed: + 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", ""), + "enabled": n.get("enabled", True), + "online": c.get("online", False), + "system": c.get("system", {}), + "last_seen": c.get("last_seen", 0), + "error": c.get("error", ""), + }) + return result + + async def handle_sse(self, request): + return await PluginWebMixin.handle_sse(self, request) + + # ── TUI 命令 ── + + @plugin_command(name="sentinel", description="Sentinel 集群状态") + async def cmd_sentinel(self, *args): + nodes = self._get_nodes() + if not nodes: + return "🛡️ Sentinel: 无配置节点" + lines = ["🛡️ Sentinel 集群状态:", "=" * 40] + for n in nodes: + c = _NODE_CACHE.get(n.get("id", ""), {}) + icon = "🟢" if c.get("online") else "🔴" + cpu = c.get("system", {}).get("cpu", {}).get("percent", "?") + mem = c.get("system", {}).get("memory", {}).get("percent", "?") + lines.append(f" {icon} {n['name']} CPU:{cpu}% MEM:{mem}%") + return "\n".join(lines) + + async def shutdown(self): + self._running = False + if self._poll_task: + self._poll_task.cancel() + logger.info("🛡️ Sentinel 已关闭") diff --git a/plugins/sentinel/config.yaml b/plugins/sentinel/config.yaml new file mode 100644 index 0000000..345f35b --- /dev/null +++ b/plugins/sentinel/config.yaml @@ -0,0 +1,16 @@ +author: SenSu Team +description: 🛡️ 多节点 SenSu 集群性能监控 — 实时仪表盘 + 节点管理 +name: Sentinel +nodes: +- api_key: sk-e77716bb9be5ab0d42a3154d7bacee7e555bfba1b828eb09 + enabled: true + id: node-3733a070 + name: 本地容器 + notes: 骁龙8E Gen5 + url: http://127.0.0.1:4200 +settings: + auto_start: true + isolation: false + poll_interval: 3 + request_timeout: 5 +version: 1.0.0 diff --git a/plugins/sentinel/dashboard.html b/plugins/sentinel/dashboard.html new file mode 100644 index 0000000..58a03a4 --- /dev/null +++ b/plugins/sentinel/dashboard.html @@ -0,0 +1,91 @@ +
+
+

🛡️ Sentinel 集群监控

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

🛡️ 节点管理

+ +
+ +
+ + + + + + + +
diff --git a/services/plugin_service.py b/services/plugin_service.py index 6163967..5b830e4 100644 --- a/services/plugin_service.py +++ b/services/plugin_service.py @@ -155,6 +155,15 @@ class PluginService: 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)