581 lines
38 KiB
HTML
581 lines
38 KiB
HTML
<div class="plugin-page-root" style="padding:16px">
|
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
|
|
<h2 style="margin:0">🛡️ Sentinel 集群监控</h2>
|
|
<div style="display:flex;gap:4px;align-items:center">
|
|
<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>
|
|
<span style="color:var(--border);margin:0 4px">|</span>
|
|
<button class="btn btn-sm btn-outlined" id="btn-layout" onclick="toggleLayout()" title="切换卡片布局">☷ 横版</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>API 路径前缀</label><input id="edit-base" placeholder="/SenSu" value="/SenSu"></div>
|
|
<div class="form-row"><label>备注</label><textarea id="edit-notes" rows="2" placeholder="设备位置、用途等..."></textarea></div>
|
|
<div style="display:flex;gap:12px;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:12px; transition:box-shadow .3s, background .3s }
|
|
.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-card.flash { animation:card-flash .6s ease-out }
|
|
@keyframes card-flash { 0%{background:var(--md-sys-color-surface-container-highest)} 100%{background:var(--md-sys-color-surface-container)} }
|
|
.node-name { font-weight:600; font-size:1rem; margin-bottom:2px }
|
|
.node-sys { font-size:.75rem; color:var(--text-dim); margin-bottom:6px }
|
|
.node-url { font-size:.75rem; color:var(--text-dim); margin-bottom:8px; word-break:break-all }
|
|
.node-notes { font-size:.75rem; color:var(--text-dim); font-style:italic; margin-bottom:8px }
|
|
/* 横版布局 — 左设备信息 | 右数据+图表同行 */
|
|
#sentinel-grid.layout-row { display:flex;flex-direction:column;gap:8px }
|
|
#sentinel-grid.layout-row .node-card { display:grid;grid-template-columns:140px 1fr 0.8fr 0.7fr 0.8fr 1fr;gap:12px 16px;align-items:start }
|
|
#sentinel-grid.layout-row .col-left { min-width:0 }
|
|
#sentinel-grid.layout-row .col-right-stack { display:flex;flex-direction:column;gap:12px;min-width:0 }
|
|
#sentinel-grid.layout-row .col-extra { display:flex;flex-direction:column;gap:4px;padding:0 2px }
|
|
#sentinel-grid.layout-row .extra-row { font-size:.8rem;padding:3px 0;border-bottom:1px solid var(--md-sys-color-surface-container-lowest) }
|
|
#sentinel-grid.layout-row .extra-row .elabel { font-size:.65rem;color:var(--text-dim);text-transform:uppercase;margin-right:4px }
|
|
#sentinel-grid.layout-row .col-disk { display:none;overflow-y:auto;max-height:200px;padding:0 2px }
|
|
#sentinel-grid.layout-row .disk-list { display:flex;flex-direction:column;gap:4px }
|
|
#sentinel-grid.layout-row .part-row { display:flex;flex-direction:column;gap:2px;padding:3px 0;border-bottom:1px solid var(--md-sys-color-surface-container-lowest) }
|
|
#sentinel-grid.layout-row .part-info { display:flex;justify-content:space-between;font-size:.7rem }
|
|
#sentinel-grid.layout-row .part-name { font-weight:500;max-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap }
|
|
#sentinel-grid.layout-row .part-mount { color:var(--text-dim);font-size:.65rem }
|
|
#sentinel-grid.layout-row .part-bar { height:3px;border-radius:1px;background:var(--md-sys-color-surface-container-lowest);margin:2px 0 }
|
|
#sentinel-grid.layout-row .part-bar-fill { height:100%;border-radius:1px;background:var(--primary) }
|
|
#sentinel-grid.layout-row .part-detail { display:flex;justify-content:space-between;font-size:.6rem;color:var(--text-dim) }
|
|
#sentinel-grid.layout-row .part-pct { text-align:right }
|
|
#sentinel-grid.layout-row .extra-row .eval { font-weight:500 }
|
|
#sentinel-grid.layout-row .disk-list { display:flex;flex-direction:column;gap:4px }
|
|
#sentinel-grid.layout-row .part-row { display:flex;flex-direction:column;gap:2px;padding:3px 0;border-bottom:1px solid var(--md-sys-color-surface-container-lowest) }
|
|
#sentinel-grid.layout-row .part-info { display:flex;justify-content:space-between;font-size:.7rem }
|
|
#sentinel-grid.layout-row .part-name { font-weight:500;max-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap }
|
|
#sentinel-grid.layout-row .part-mount { color:var(--text-dim);font-size:.65rem }
|
|
#sentinel-grid.layout-row .part-bar { height:3px;border-radius:1px;background:var(--md-sys-color-surface-container-lowest);margin:2px 0 }
|
|
#sentinel-grid.layout-row .part-bar-fill { height:100%;border-radius:1px;background:var(--primary) }
|
|
#sentinel-grid.layout-row .part-detail { display:flex;justify-content:space-between;font-size:.6rem;color:var(--text-dim) }
|
|
#sentinel-grid.layout-row .part-pct { text-align:right }
|
|
#sentinel-grid.layout-row .col-extra { display:flex;flex-direction:column;gap:4px;padding:0 2px }
|
|
#sentinel-grid.layout-row .extra-row { font-size:.8rem;padding:3px 0;border-bottom:1px solid var(--md-sys-color-surface-container-lowest) }
|
|
#sentinel-grid.layout-row .extra-row .elabel { font-size:.65rem;color:var(--text-dim);text-transform:uppercase;margin-right:4px }
|
|
#sentinel-grid.layout-row .col-disk { display:none;overflow-y:auto;max-height:200px;padding:0 2px }
|
|
#sentinel-grid.layout-row .disk-list { display:flex;flex-direction:column;gap:4px }
|
|
#sentinel-grid.layout-row .part-row { display:flex;flex-direction:column;gap:2px;padding:3px 0;border-bottom:1px solid var(--md-sys-color-surface-container-lowest) }
|
|
#sentinel-grid.layout-row .part-info { display:flex;justify-content:space-between;font-size:.7rem }
|
|
#sentinel-grid.layout-row .part-name { font-weight:500;max-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap }
|
|
#sentinel-grid.layout-row .part-mount { color:var(--text-dim);font-size:.65rem }
|
|
#sentinel-grid.layout-row .part-bar { height:3px;border-radius:1px;background:var(--md-sys-color-surface-container-lowest);margin:2px 0 }
|
|
#sentinel-grid.layout-row .part-bar-fill { height:100%;border-radius:1px;background:var(--primary) }
|
|
#sentinel-grid.layout-row .part-detail { display:flex;justify-content:space-between;font-size:.6rem;color:var(--text-dim) }
|
|
#sentinel-grid.layout-row .part-pct { text-align:right }
|
|
#sentinel-grid.layout-row .extra-row .eval { font-weight:500 }
|
|
#sentinel-grid.layout-row .disk-list { display:flex;flex-direction:column;gap:4px }
|
|
#sentinel-grid.layout-row .part-row { display:flex;flex-direction:column;gap:2px;padding:3px 0;border-bottom:1px solid var(--md-sys-color-surface-container-lowest) }
|
|
#sentinel-grid.layout-row .part-info { display:flex;justify-content:space-between;font-size:.7rem }
|
|
#sentinel-grid.layout-row .part-name { font-weight:500;max-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap }
|
|
#sentinel-grid.layout-row .part-mount { color:var(--text-dim);font-size:.65rem }
|
|
#sentinel-grid.layout-row .part-bar { height:3px;border-radius:1px;background:var(--md-sys-color-surface-container-lowest);margin:2px 0 }
|
|
#sentinel-grid.layout-row .part-bar-fill { height:100%;border-radius:1px;background:var(--primary) }
|
|
#sentinel-grid.layout-row .part-detail { display:flex;justify-content:space-between;font-size:.6rem;color:var(--text-dim) }
|
|
#sentinel-grid.layout-row .part-pct { text-align:right }
|
|
#sentinel-grid.layout-row .disk-list { display:flex;flex-direction:column;gap:4px }
|
|
#sentinel-grid.layout-row .part-row { display:flex;flex-direction:column;gap:2px;padding:3px 0;border-bottom:1px solid var(--md-sys-color-surface-container-lowest) }
|
|
#sentinel-grid.layout-row .part-info { display:flex;justify-content:space-between;font-size:.7rem }
|
|
#sentinel-grid.layout-row .part-name { font-weight:500;max-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap }
|
|
#sentinel-grid.layout-row .part-mount { color:var(--text-dim);font-size:.65rem }
|
|
#sentinel-grid.layout-row .part-bar { height:3px;border-radius:1px;background:var(--md-sys-color-surface-container-lowest);margin:2px 0 }
|
|
#sentinel-grid.layout-row .part-bar-fill { height:100%;border-radius:1px;background:var(--primary);transition:width .6s }
|
|
#sentinel-grid.layout-row .part-detail { display:flex;justify-content:space-between;font-size:.6rem;color:var(--text-dim) }
|
|
#sentinel-grid.layout-row .part-size { text-align:left }
|
|
#sentinel-grid.layout-row .mtr-data .rlabel { font-size:.7rem;color:var(--text-dim);text-transform:uppercase;margin-right:6px;text-shadow:0 0 3px var(--bg) }
|
|
#sentinel-grid.layout-row .mtr-data .rval { font-weight:600;text-shadow:0 0 3px var(--bg) }
|
|
#sentinel-grid.layout-row .mtr { position:relative;padding:0;margin-bottom:8px;height:60px }
|
|
#sentinel-grid.layout-row .mtr canvas { width:100%;height:60px;border-radius:4px;background:rgba(128,128,128,0.06) }
|
|
#sentinel-grid.layout-row .mtr-data { position:absolute;top:3px;left:8px;z-index:1;display:flex;align-items:baseline;gap:6px }
|
|
#sentinel-grid.layout-row .node-time { margin-top:4px }
|
|
#sentinel-grid.layout-row .metric-bar { display:none }
|
|
.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; transition:color .4s }
|
|
.metric-bar { height:4px; border-radius:2px; margin-top:2px; background:var(--md-sys-color-surface-container-lowest); overflow:hidden }
|
|
.metric-bar-fill { height:100%; border-radius:2px; transition:width .6s cubic-bezier(.4,0,.2,1) }
|
|
.bar-green { background:linear-gradient(90deg,#66bb6a,#4caf50) } .bar-yellow { background:linear-gradient(90deg,#ffa726,#ff9800) } .bar-red { background:linear-gradient(90deg,#ef5350,#f44336) }
|
|
.metric-value.up { color:#4caf50 } .metric-value.down { color:#f44336 } .metric-value.steady { color:var(--text) }
|
|
.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 }
|
|
.mtr canvas { image-rendering:auto;-webkit-font-smoothing:antialiased }
|
|
</style>
|
|
|
|
<script>
|
|
// 确保 chart.js 已加载 (SPA 导航时可能未加载)
|
|
|
|
var pollTimer = null;
|
|
var _chartInstances = {};
|
|
var editingId = null;
|
|
var _layoutMode = 'grid'; // 'grid' | 'row'
|
|
|
|
function toggleLayout() {
|
|
_layoutMode = _layoutMode === 'grid' ? 'row' : 'grid';
|
|
var btn = document.getElementById("btn-layout");
|
|
var grid = document.getElementById("sentinel-grid");
|
|
// 销毁旧图表
|
|
try { Object.keys(_chartInstances).forEach(function(k){ var chs = _chartInstances[k]; if(chs) { Object.values(chs).forEach(function(c){ if(c&&c.destroy)c.destroy(); }); } }); } catch(e) {}
|
|
_chartInstances = {};
|
|
_cardCache = {};
|
|
_firstRender = true;
|
|
grid.innerHTML = '<div class="card" style="text-align:center;padding:24px;color:var(--text-dim)">加载中...</div>';
|
|
if (_layoutMode === 'row') {
|
|
grid.className = 'layout-row';
|
|
grid.style.cssText = 'display:flex;flex-direction:column;gap:12px';
|
|
btn.textContent = '⊞ 网格';
|
|
btn.className = 'btn btn-sm btn-filled';
|
|
} else {
|
|
grid.className = '';
|
|
grid.style.cssText = 'display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px';
|
|
btn.textContent = '☷ 横版';
|
|
btn.className = 'btn btn-sm btn-outlined';
|
|
}
|
|
pollNodes();
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
// ═══ 仪表盘 ═══
|
|
|
|
var _cardCache = {};
|
|
|
|
function animateValue(el, oldVal, newVal, decimals, suffix) {
|
|
if (oldVal === newVal) { el.textContent = newVal.toFixed(decimals) + suffix; return; }
|
|
var start = performance.now();
|
|
var duration = 1000;
|
|
function step(ts) {
|
|
var progress = Math.min((ts - start) / duration, 1);
|
|
// ease-out
|
|
var eased = 1 - Math.pow(1 - progress, 3);
|
|
var current = oldVal + (newVal - oldVal) * eased;
|
|
el.textContent = current.toFixed(decimals) + suffix;
|
|
if (progress < 1) requestAnimationFrame(step);
|
|
}
|
|
requestAnimationFrame(step);
|
|
}
|
|
|
|
var _firstRender = true;
|
|
|
|
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>';
|
|
try { Object.keys(_chartInstances).forEach(function(k){ var chs = _chartInstances[k]; if(chs) { Object.values(chs).forEach(function(c){ if(c&&c.destroy)c.destroy(); }); } }); } catch(e) {}
|
|
_chartInstances = {};
|
|
_cardCache = {};
|
|
_firstRender = true;
|
|
return;
|
|
}
|
|
if (_firstRender) { grid.innerHTML = ''; _firstRender = false; }
|
|
var isRow = _layoutMode === 'row';
|
|
|
|
// 清理删除的节点
|
|
var activeIds = {};
|
|
nodes.forEach(function(n){ activeIds[n.id||n.name] = true; });
|
|
Object.keys(_cardCache).forEach(function(id){
|
|
if (!activeIds[id]) { var el = _cardCache[id]; if(el.parentNode) el.parentNode.removeChild(el); delete _cardCache[id]; }
|
|
});
|
|
|
|
nodes.forEach(function(n){
|
|
var sys = n.system || {};
|
|
var cpu = sys.cpu || {}; var cpuVal = cpu.percent || 0;
|
|
var mem = sys.memory || {}; var memVal = mem.percent || 0;
|
|
var net = sys.net_speed || {};
|
|
var id = n.id || n.name;
|
|
|
|
var card = _cardCache[id];
|
|
if (!card) {
|
|
card = document.createElement('div');
|
|
card.className = 'node-card ' + (n.online ? 'online' : 'offline');
|
|
card.setAttribute('data-id', id);
|
|
var plat = n.platform || {};
|
|
var sysInfo = (plat.system||'') + (plat.release ? ' ' + plat.release : '') + (plat.machine ? ' | ' + plat.machine : '');
|
|
if (isRow) {
|
|
card.innerHTML =
|
|
'<div class="col-left">'+
|
|
'<div class="node-name">'+(n.online?'🟢':'🔴')+' '+esc(n.name)+'</div>'+
|
|
(sysInfo.trim() ? '<div class="node-sys">'+esc(sysInfo)+'</div>' : '')+
|
|
'<div class="node-url">'+esc(n.url||'')+'</div>'+
|
|
(n.notes ? '<div class="node-notes">📝 '+esc(n.notes)+'</div>' : '')+
|
|
'</div>'+
|
|
'<div class="col-right-stack">'+
|
|
'<div class="mtr" id="mtr-'+id+'-cpu"><div class="mtr-data"><span class="rlabel">CPU</span> <span class="rval cpu-val">0%</span></div><canvas id="'+id+'-cpu" height="60"></canvas></div>'+
|
|
'<div class="mtr" id="mtr-'+id+'-mem"><div class="mtr-data"><span class="rlabel">MEM</span> <span class="rval mem-val">0%</span> <span class="mem-row-detail" style="font-size:.65rem;color:var(--text-dim)"></span></div><canvas id="'+id+'-mem" height="60"></canvas></div>'+
|
|
'<div class="mtr" id="mtr-'+id+'-net"><div class="mtr-data"><span class="rlabel">NET</span> <span class="rval net-down" style="font-size:.85rem">0</span> <span class="rval net-up" style="font-size:.85rem">0</span></div><canvas id="'+id+'-net" height="60"></canvas></div>'+
|
|
'<div class="col-info" style="text-align:right;font-size:.7rem;color:var(--text-dim);margin-top:4px"></div>'+
|
|
'</div>'+
|
|
'<div class="col-disk"><div class="disk-list"></div></div>'+
|
|
'<div class="col-extra">'+
|
|
'<div class="extra-row"><span class="elabel">负载</span> <span class="eval load-val">—</span></div>'+
|
|
'<div class="extra-row"><span class="elabel">SWAP</span> <span class="eval swap-val">—</span></div>'+
|
|
'<div class="extra-row"><span class="elabel">温度</span> <span class="eval temp-val">—</span></div>'+
|
|
'<div class="extra-row"><span class="elabel">流量</span> <span class="eval traf-val">—</span></div>'+
|
|
'<div class="extra-row"><span class="elabel">进程</span> <span class="eval proc-val">—</span></div>'+
|
|
'</div>';
|
|
} else {
|
|
card.innerHTML =
|
|
'<div class="node-name">'+(n.online?'🟢':'🔴')+' '+esc(n.name)+'</div>'+
|
|
(sysInfo.trim() ? '<div class="node-sys">'+esc(sysInfo)+'</div>' : '')+
|
|
'<div class="node-url">'+esc(n.url||'')+'</div>'+
|
|
(n.notes ? '<div class="node-notes">📝 '+esc(n.notes)+'</div>' : '')+
|
|
'<div class="metrics-body"></div>'+
|
|
'<div class="node-time"></div>';
|
|
}
|
|
grid.appendChild(card);
|
|
_cardCache[id] = card;
|
|
if (isRow && !_chartInstances[id]) {
|
|
try {
|
|
_chartInstances[id] = { cpu: null, mem: null, net: null };
|
|
if (typeof MiniChart !== 'undefined') {
|
|
_chartInstances[id].cpu = new MiniChart(id+'-cpu', '#7aa2f7'); _chartInstances[id].mem = new MiniChart(id+'-mem', '#9ece6a'); _hires(_chartInstances[id].mem);
|
|
}
|
|
if (typeof DualLineChart !== 'undefined') {
|
|
_chartInstances[id].net = new DualLineChart(id+'-net', '#7c3aed', '#a78bfa');
|
|
// 默认设一些初始数据让线可见
|
|
for (var _i=0; _i<10; _i++) _chartInstances[id].net.update(5000, 3000);
|
|
|
|
}
|
|
} catch(e) {}
|
|
}
|
|
// 延迟确保父容器已布局
|
|
setTimeout(function(){
|
|
if (_chartInstances[id]) {
|
|
var chs = _chartInstances[id];
|
|
if (chs.cpu) chs.cpu.resize();
|
|
if (chs.mem) chs.mem.resize();
|
|
if (chs.net) chs.net.applyResize();
|
|
}
|
|
}, 100);
|
|
}
|
|
|
|
// 更新通用信息
|
|
card.className = 'node-card ' + (n.online ? 'online' : 'offline');
|
|
var nameEl = card.querySelector('.node-name');
|
|
if (nameEl) nameEl.innerHTML = (n.online?'🟢':'🔴')+' '+esc(n.name);
|
|
var urlEl = card.querySelector('.node-url');
|
|
if (urlEl) urlEl.textContent = n.url||'';
|
|
var sysEl = card.querySelector('.node-sys');
|
|
var plat2 = n.platform || {};
|
|
var si2 = (plat2.system||'') + (plat2.release ? ' ' + plat2.release : '') + (plat2.machine ? ' | ' + plat2.machine : '');
|
|
if (si2.trim()) {
|
|
if (!sysEl) { sysEl = document.createElement('div'); sysEl.className = 'node-sys'; card.insertBefore(sysEl, card.querySelector('.metrics-body')||card.querySelector('.card-mid')); }
|
|
sysEl.textContent = si2;
|
|
} else if (sysEl) { sysEl.remove(); }
|
|
|
|
var body = isRow ? card.querySelector('.col-right-stack') : card.querySelector('.metrics-body');
|
|
if (!body) return;
|
|
|
|
var oldCpu = parseFloat(body.getAttribute('data-cpu')||(sys.cpu||{}).percent||0);
|
|
var oldMem = parseFloat(body.getAttribute('data-mem')||(sys.memory||{}).percent||0);
|
|
body.setAttribute('data-cpu', (sys.cpu||{}).percent||0);
|
|
body.setAttribute('data-mem', (sys.memory||{}).percent||0);
|
|
|
|
if (isRow) {
|
|
var cpuEl = card.querySelector('.cpu-val');
|
|
var memEl = card.querySelector('.mem-val');
|
|
var netDn = card.querySelector('.net-down');
|
|
var netUp = card.querySelector('.net-up');
|
|
var mrd = card.querySelector('.mem-row-detail');
|
|
if (cpuEl) animateValue(cpuEl, oldCpu, cpuVal, 1, '%');
|
|
if (memEl) animateValue(memEl, oldMem, memVal, 1, '%');
|
|
if (netDn) netDn.textContent = '↓' + fmtSpeed(net.rx_bytes_sec||0);
|
|
if (netUp) netUp.textContent = '↑' + fmtSpeed(net.tx_bytes_sec||0);
|
|
if (mrd) mrd.textContent = (mem.used_gb||0).toFixed(1)+'/'+(mem.total_gb||0).toFixed(1)+' GB';
|
|
var charts = _chartInstances[id];
|
|
if (charts) {
|
|
try {
|
|
if (charts.cpu) charts.cpu.update(cpuVal);
|
|
if (charts.mem) charts.mem.update(memVal);
|
|
if (charts.net) {
|
|
charts.net.update(net.rx_bytes_sec||0, net.tx_bytes_sec||0);
|
|
if (charts.net.maxVal < 102400) { charts.net.maxVal = 102400; charts.net.draw(); }
|
|
}
|
|
} catch(e) {}
|
|
}
|
|
// 更新额外指标
|
|
var loadEl = card.querySelector('.load-val');
|
|
var swapEl = card.querySelector('.swap-val');
|
|
var diskEl = card.querySelector('.disk-val');
|
|
var tempEl = card.querySelector('.temp-val');
|
|
var trafEl = card.querySelector('.traf-val');
|
|
var procEl = card.querySelector('.proc-val');
|
|
var sysData = n.system || {};
|
|
if (loadEl && sysData.cpu) loadEl.textContent = (sysData.cpu.load_avg||[0,0,0]).map(function(v){return v.toFixed(1);}).join(' / ');
|
|
if (swapEl && sysData.swap) { var st = sysData.swap.total_gb||0; swapEl.textContent = st > 0 ? (sysData.swap.percent||0).toFixed(1)+'% ('+(sysData.swap.used_gb||0).toFixed(1)+'G)' : '—'; }
|
|
// 磁盘分区列表
|
|
// 磁盘分区 (在col-extra中显示)
|
|
var colDisk = card.querySelector('.col-disk');
|
|
if (colDisk) {
|
|
if (sysData.partitions && sysData.partitions.length) {
|
|
colDisk.style.display = 'block';
|
|
card.style.gridTemplateColumns = '140px 1fr 0.7fr 0.6fr';
|
|
} else {
|
|
colDisk.style.display = 'none';
|
|
card.style.gridTemplateColumns = '140px 1fr 0.5fr';
|
|
}
|
|
}
|
|
var diskList = card.querySelector('.col-disk .disk-list');
|
|
if (diskList && sysData.partitions) {
|
|
var parts = sysData.partitions || [];
|
|
if (!diskList.querySelector('.part-row') || diskList.children.length !== parts.length) {
|
|
diskList.innerHTML = parts.map(function(p){
|
|
return '<div class="part-row"><div class="part-info"><span class="part-name">'+esc(p.device||'?')+'</span> <span class="part-mount">'+esc(p.mount||'')+'</span></div><div class="part-bar"><div class="part-bar-fill" style="width:'+(p.percent||0)+'%"></div></div><div class="part-detail"><span class="part-size">'+fmtSizeGB(p)+'</span><span class="part-pct">'+(p.percent||0).toFixed(0)+'%</span></div></div>';
|
|
}).join('');
|
|
} else {
|
|
var rows = diskList.querySelectorAll('.part-row');
|
|
parts.forEach(function(p, i){
|
|
if (rows[i]) {
|
|
var fill = rows[i].querySelector('.part-bar-fill');
|
|
var pct = rows[i].querySelector('.part-pct');
|
|
if (fill) fill.style.width = (p.percent||0)+'%';
|
|
if (pct) pct.textContent = (p.percent||0).toFixed(0)+'%';
|
|
}
|
|
});
|
|
}
|
|
}
|
|
if (tempEl && sysData.temperature) tempEl.textContent = (sysData.temperature.current||0) > 0 ? (sysData.temperature.current).toFixed(1)+'°C' : '—';
|
|
if (trafEl && sysData.network) trafEl.textContent = '↓'+(sysData.network.rx_mb||0).toFixed(0)+'M ↑'+(sysData.network.tx_mb||0).toFixed(0)+'M';
|
|
if (procEl && sysData.process) procEl.textContent = (sysData.process.memory_mb||0).toFixed(0)+'MB';
|
|
|
|
// 更新额外指标
|
|
var loadEl = card.querySelector('.load-val');
|
|
var swapEl = card.querySelector('.swap-val');
|
|
var tempEl = card.querySelector('.temp-val');
|
|
var trafEl = card.querySelector('.traf-val');
|
|
var procEl = card.querySelector('.proc-val');
|
|
var sysData = n.system || {};
|
|
if (loadEl && sysData.cpu) loadEl.textContent = (sysData.cpu.load_avg||[0,0,0]).map(function(v){return v.toFixed(1);}).join(' / ');
|
|
if (swapEl && sysData.swap) { var st = sysData.swap.total_gb||0; swapEl.textContent = st > 0 ? (sysData.swap.percent||0).toFixed(1)+'% ('+(sysData.swap.used_gb||0).toFixed(1)+'G)' : '—'; }
|
|
if (tempEl && sysData.temperature) tempEl.textContent = (sysData.temperature.current||0) > 0 ? (sysData.temperature.current).toFixed(1)+'°C' : '—';
|
|
if (trafEl && sysData.network) trafEl.textContent = '↓'+(sysData.network.rx_mb||0).toFixed(0)+'M ↑'+(sysData.network.tx_mb||0).toFixed(0)+'M';
|
|
if (procEl && sysData.process) procEl.textContent = (sysData.process.memory_mb||0).toFixed(0)+'MB';
|
|
|
|
// 磁盘分区
|
|
// 磁盘分区 (在col-extra中显示)
|
|
var colDisk = card.querySelector('.col-disk');
|
|
if (colDisk) {
|
|
if (sysData.partitions && sysData.partitions.length) {
|
|
colDisk.style.display = 'block';
|
|
card.style.gridTemplateColumns = '140px 1fr 0.7fr 0.6fr';
|
|
} else {
|
|
colDisk.style.display = 'none';
|
|
card.style.gridTemplateColumns = '140px 1fr 0.5fr';
|
|
}
|
|
}
|
|
var diskList = card.querySelector('.col-disk .disk-list');
|
|
if (diskList && sysData.partitions) {
|
|
var parts = sysData.partitions || [];
|
|
if (!diskList.querySelector('.part-row') || diskList.children.length !== parts.length) {
|
|
diskList.innerHTML = parts.map(function(p){
|
|
return '<div class="part-row"><div class="part-info"><span class="part-name">'+esc(p.device||'?')+'</span> <span class="part-mount">'+esc(p.mount||'')+'</span></div><div class="part-bar"><div class="part-bar-fill" style="width:'+(p.percent||0)+'%"></div></div><div class="part-detail"><span class="part-size">'+fmtSizeGB(p)+'</span><span class="part-pct">'+(p.percent||0).toFixed(0)+'%</span></div></div>';
|
|
}).join('');
|
|
} else {
|
|
var rows = diskList.querySelectorAll('.part-row');
|
|
parts.forEach(function(p, i){
|
|
if (rows[i]) {
|
|
var fill = rows[i].querySelector('.part-bar-fill');
|
|
var pct = rows[i].querySelector('.part-pct');
|
|
if (fill) fill.style.width = (p.percent||0)+'%';
|
|
if (pct) pct.textContent = (p.percent||0).toFixed(0)+'%';
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
var infoR = card.querySelector('.col-info');
|
|
if (infoR) infoR.textContent = (n.last_seen ? new Date(n.last_seen*1000).toLocaleTimeString() : '—');
|
|
} else {
|
|
// 网格模式 — oldCpu/oldMem 已在上面读取(在setAttribute之前)
|
|
var hasMetricBar = body.querySelector('.metric-bar');
|
|
var isNew = !body.querySelector('.cpu-val') || !hasMetricBar;
|
|
if (isNew) {
|
|
body.innerHTML =
|
|
'<div class="metric-row">'+
|
|
'<div class="metric"><div class="metric-label">CPU</div><div class="metric-value cpu-val">0%</div><div class="metric-bar"><div class="metric-bar-fill" style="width:0%"></div></div></div>'+
|
|
'<div class="metric"><div style="display:flex;justify-content:space-between;align-items:baseline"><div class="metric-label">MEM</div><div class="mem-detail-val" style="font-size:.65rem;color:var(--text-dim)"></div></div><div class="metric-value mem-val">0%</div><div class="metric-bar"><div class="metric-bar-fill" style="width:0%"></div></div></div>'+
|
|
'</div>'+
|
|
'<div class="metric-row">'+
|
|
'<div class="metric"><div class="metric-label">NET ↓</div><div class="metric-value net-down" style="font-size:.9rem"></div></div>'+
|
|
'<div class="metric"><div class="metric-label">NET ↑</div><div class="metric-value net-up" style="font-size:.9rem"></div></div>'+
|
|
'</div>';
|
|
}
|
|
body.querySelector('.net-down').textContent = fmtSpeed(net.rx_bytes_sec||0);
|
|
body.querySelector('.net-up').textContent = fmtSpeed(net.tx_bytes_sec||0);
|
|
var md = body.querySelector('.mem-detail-val');
|
|
if (md) md.textContent = (mem.used_gb||0).toFixed(1) + '/' + (mem.total_gb||0).toFixed(1) + ' GB';
|
|
// 强制更新数值(备用)
|
|
var cv = body.querySelector('.cpu-val');
|
|
var mv = body.querySelector('.mem-val');
|
|
if (cv) cv.textContent = cpuVal.toFixed(1) + '%';
|
|
if (mv) mv.textContent = memVal.toFixed(1) + '%';
|
|
var barCpu = body.querySelectorAll('.metric-bar-fill')[0];
|
|
var barMem = body.querySelectorAll('.metric-bar-fill')[1];
|
|
if (barCpu && barMem) {
|
|
var cpuBarCls = bar(cpuVal), memBarCls = bar(memVal);
|
|
barCpu.style.transition = 'none'; barMem.style.transition = 'none';
|
|
barCpu.style.width = oldCpu+'%'; barMem.style.width = oldMem+'%';
|
|
barCpu.className = 'metric-bar-fill '+cpuBarCls; barMem.className = 'metric-bar-fill '+memBarCls;
|
|
barCpu.offsetWidth; barMem.offsetWidth;
|
|
barCpu.style.transition = ''; barMem.style.transition = '';
|
|
barCpu.style.width = cpuVal+'%'; barMem.style.width = memVal+'%';
|
|
}
|
|
animateValue(body.querySelector('.cpu-val'), oldCpu, cpuVal, 1, '%');
|
|
animateValue(body.querySelector('.mem-val'), oldMem, memVal, 1, '%');
|
|
card.querySelector('.node-time').innerHTML = '<div style="font-size:.7rem;color:var(--text-dim);margin-top:8px">'+(n.last_seen ? new Date(n.last_seen*1000).toLocaleTimeString() : '—')+'</div>';
|
|
}
|
|
});
|
|
}
|
|
|
|
function bar(v) { return v < 60 ? 'bar-green' : (v < 85 ? 'bar-yellow' : 'bar-red'); }
|
|
function fmtSizeGB(p) { var u=p.used_gb||0, t=p.total_gb||0; return fmtSize(u*1073741824)+' / '+fmtSize(t*1073741824); }
|
|
function fmtSize(bytes) { if(bytes===undefined||bytes===null)return'—'; bytes=Math.abs(bytes); if(bytes>=1e12)return(bytes/1e12).toFixed(1)+' TB'; if(bytes>=1e9)return(bytes/1e9).toFixed(1)+' GB'; if(bytes>=1e6)return(bytes/1e6).toFixed(1)+' MB'; if(bytes>=1e3)return(bytes/1e3).toFixed(1)+' KB'; return bytes.toFixed(0)+' B'; }
|
|
function fmtSize(bytes) { if(bytes===undefined||bytes===null)return'—'; bytes=Math.abs(bytes); if(bytes>=1e12)return(bytes/1e12).toFixed(1)+' TB'; if(bytes>=1e9)return(bytes/1e9).toFixed(1)+' GB'; if(bytes>=1e6)return(bytes/1e6).toFixed(1)+' MB'; if(bytes>=1e3)return(bytes/1e3).toFixed(1)+' KB'; return bytes.toFixed(0)+' B'; }
|
|
function fmtSizeGB(p) { var u=p.used_gb||0, t=p.total_gb||0; return fmtSize(u*1073741824)+' / '+fmtSize(t*1073741824); }
|
|
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,'>').replace(/"/g,'"'); }
|
|
|
|
function pollNodes() {
|
|
fetch("/sentinel/api/nodes").then(function(r){return r.json()}).then(function(d){
|
|
renderNodes(d.nodes || []);
|
|
}).catch(function(e){
|
|
var grid = document.getElementById("sentinel-grid");
|
|
if (grid && _firstRender) {
|
|
grid.innerHTML = '<div class="card" style="text-align:center;padding:24px;color:var(--error)">连接失败 — 检查节点配置</div>';
|
|
}
|
|
});
|
|
}
|
|
|
|
pollNodes();
|
|
pollTimer = setInterval(pollNodes, 2000);
|
|
|
|
// ═══ 节点管理 ═══
|
|
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-base").value = "/SenSu";
|
|
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-base").value = n.base_path||"/SenSu";
|
|
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(),
|
|
base_path: document.getElementById("edit-base").value.trim() || "/SenSu",
|
|
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>
|