Files
SenSu/plugins/sentinel/settings.html
T
qinglong 5e74b0aad7 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>
2026-06-13 15:05:38 +08:00

132 lines
7.1 KiB
HTML

<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>