feat: 🛡️ Sentinel 集群监控插件 + API Key 系统 + 框架设置页
== 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 已关闭")
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<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">🛡️ Sentinel 集群监控</h2>
|
||||||
|
<span id="sentinel-status" style="font-size:.8rem;color:var(--text-dim)">连接中...</span>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.node-card { background:var(--md-sys-color-surface-container); border-radius:var(--shape-sm); padding:16px; transition:box-shadow .2s }
|
||||||
|
.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-name { font-weight:600; font-size:1rem; margin-bottom:4px }
|
||||||
|
.node-url { font-size:.75rem; color:var(--text-dim); margin-bottom:12px; word-break:break-all }
|
||||||
|
.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 }
|
||||||
|
.metric-bar { height:4px; border-radius:2px; margin-top:2px; background:var(--md-sys-color-surface-container-lowest) }
|
||||||
|
.metric-bar-fill { height:100%; border-radius:2px; transition:width .5s }
|
||||||
|
.bar-green { background:#4caf50 } .bar-yellow { background:#ff9800 } .bar-red { background:#f44336 }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
var sseConn = null;
|
||||||
|
var currentNodes = [];
|
||||||
|
|
||||||
|
function renderNodes(nodes) {
|
||||||
|
currentNodes = nodes || [];
|
||||||
|
var grid = document.getElementById("sentinel-grid");
|
||||||
|
document.getElementById("sentinel-status").textContent =
|
||||||
|
"在线 " + nodes.filter(function(n){return n.online}).length + "/" + nodes.length;
|
||||||
|
|
||||||
|
if (!nodes.length) {
|
||||||
|
grid.innerHTML = '<div class="card" style="text-align:center;padding:24px;color:var(--text-dim)">暂无节点 — 在节点管理中添加</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
grid.innerHTML = nodes.map(function(n){
|
||||||
|
var sys = n.system || {};
|
||||||
|
var cpu = sys.cpu || {};
|
||||||
|
var mem = sys.memory || {};
|
||||||
|
var net = sys.net_speed || {};
|
||||||
|
var cls = n.online ? 'online' : 'offline';
|
||||||
|
var cpuBar = bar(cpu.percent||0);
|
||||||
|
var memBar = bar(mem.percent||0);
|
||||||
|
|
||||||
|
return '<div class="node-card '+cls+'">'+
|
||||||
|
'<div class="node-name">'+(n.online?'🟢':'🔴')+' '+esc(n.name)+'</div>'+
|
||||||
|
'<div class="node-url">'+esc(n.url)+'</div>'+
|
||||||
|
(n.notes ? '<div style="font-size:.75rem;color:var(--text-dim);margin-bottom:8px;font-style:italic">📝 '+esc(n.notes)+'</div>' : '')+
|
||||||
|
(n.online ?
|
||||||
|
'<div class="metric-row">'+
|
||||||
|
'<div class="metric"><div class="metric-label">CPU</div><div class="metric-value">'+(cpu.percent||0).toFixed(1)+'%</div><div class="metric-bar"><div class="metric-bar-fill '+cpuBar+'" style="width:'+(cpu.percent||0)+'%"></div></div></div>'+
|
||||||
|
'<div class="metric"><div class="metric-label">MEM</div><div class="metric-value">'+(mem.percent||0).toFixed(1)+'%</div><div class="metric-bar"><div class="metric-bar-fill '+memBar+'" style="width:'+(mem.percent||0)+'%"></div></div></div>'+
|
||||||
|
'</div>'+
|
||||||
|
'<div class="metric-row">'+
|
||||||
|
'<div class="metric"><div class="metric-label">NET ↓</div><div class="metric-value" style="font-size:.9rem">'+fmtSpeed(net.rx_bytes_sec||0)+'</div></div>'+
|
||||||
|
'<div class="metric"><div class="metric-label">NET ↑</div><div class="metric-value" style="font-size:.9rem">'+fmtSpeed(net.tx_bytes_sec||0)+'</div></div>'+
|
||||||
|
'</div>'+
|
||||||
|
'<div style="font-size:.7rem;color:var(--text-dim);margin-top:8px">内存 '+(mem.used_gb||0).toFixed(1)+'/'+(mem.total_gb||0).toFixed(1)+' GB</div>'
|
||||||
|
: '<div style="color:var(--error);font-size:.85rem">'+(n.error||'离线')+'</div>')+
|
||||||
|
'<div style="font-size:.7rem;color:var(--text-dim);margin-top:8px">'+(n.last_seen ? new Date(n.last_seen*1000).toLocaleTimeString() : '—')+'</div>'+
|
||||||
|
'</div>';
|
||||||
|
}).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function bar(v) { return v < 60 ? 'bar-green' : (v < 85 ? 'bar-yellow' : 'bar-red'); }
|
||||||
|
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,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||||
|
|
||||||
|
// ── SSE ──
|
||||||
|
function connectSSE() {
|
||||||
|
if (sseConn) { sseConn.close(); sseConn = null; }
|
||||||
|
sseConn = new EventSource("/SenSu/plugin/sentinel/sse");
|
||||||
|
sseConn.addEventListener("nodes_update", function(e){
|
||||||
|
var d = JSON.parse(e.data);
|
||||||
|
if (d.nodes) renderNodes(d.nodes);
|
||||||
|
});
|
||||||
|
sseConn.onerror = function(){ setTimeout(connectSSE, 5000); };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 初始加载 ──
|
||||||
|
fetch("/sentinel/api/nodes").then(function(r){return r.json()}).then(function(d){
|
||||||
|
if (d.nodes) renderNodes(d.nodes);
|
||||||
|
}).catch(function(){});
|
||||||
|
connectSSE();
|
||||||
|
</script>
|
||||||
|
</div>
|
||||||
@@ -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: "执行哨兵管理命令"
|
||||||
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||||
|
|
||||||
|
loadNodes();
|
||||||
|
</script>
|
||||||
|
</div>
|
||||||
@@ -155,6 +155,15 @@ class PluginService:
|
|||||||
logger.debug(f"插件目录已消失,跳过重载: {plugin_name}")
|
logger.debug(f"插件目录已消失,跳过重载: {plugin_name}")
|
||||||
return
|
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:
|
if plugin_name in self.plugins:
|
||||||
logger.info(f" ⏳ 卸载旧版本: {plugin_name}")
|
logger.info(f" ⏳ 卸载旧版本: {plugin_name}")
|
||||||
await self.unload_plugin(plugin_name)
|
await self.unload_plugin(plugin_name)
|
||||||
|
|||||||
Reference in New Issue
Block a user