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>
|
||||
Reference in New Issue
Block a user