fix: Sentinel WebUI — 合并为单页面(仪表盘+节点管理Tab)
插件 Web 页面 key 必须匹配 plugin_name (/plugin/sentinel) 改为单页面 Tab 切换: 仪表盘 + 节点管理 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -47,15 +47,11 @@ class Plugin(PluginWebMixin):
|
||||
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 "⚙")
|
||||
# 注册 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:
|
||||
|
||||
+139
-16
@@ -1,11 +1,44 @@
|
||||
<div class="plugin-page-root" style="padding:16px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
|
||||
<h2 style="margin:0">🛡️ Sentinel 集群监控</h2>
|
||||
<span id="sentinel-status" style="font-size:.8rem;color:var(--text-dim)">连接中...</span>
|
||||
<div style="display:flex;gap:4px">
|
||||
<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>
|
||||
</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>备注</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>
|
||||
.node-card { background:var(--md-sys-color-surface-container); border-radius:var(--shape-sm); padding:16px; transition:box-shadow .2s }
|
||||
@@ -21,23 +54,36 @@
|
||||
.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 }
|
||||
.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 sseConn = null;
|
||||
var currentNodes = [];
|
||||
var editingId = null;
|
||||
|
||||
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;
|
||||
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();
|
||||
}
|
||||
|
||||
// ═══ 仪表盘 ═══
|
||||
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>';
|
||||
return;
|
||||
}
|
||||
grid.innerHTML = nodes.map(function(n){
|
||||
var sys = n.system || {};
|
||||
var cpu = sys.cpu || {};
|
||||
@@ -46,7 +92,6 @@
|
||||
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>'+
|
||||
@@ -69,9 +114,8 @@
|
||||
|
||||
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,'>'); }
|
||||
function esc(s) { return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||
|
||||
// ── SSE ──
|
||||
function connectSSE() {
|
||||
if (sseConn) { sseConn.close(); sseConn = null; }
|
||||
sseConn = new EventSource("/SenSu/plugin/sentinel/sse");
|
||||
@@ -82,10 +126,89 @@
|
||||
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();
|
||||
|
||||
// ═══ 节点管理 ═══
|
||||
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;
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user