|
|
@@ -48,7 +48,8 @@
|
|
|
|
.node-card.flash { animation:card-flash .6s ease-out }
|
|
|
|
.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)} }
|
|
|
|
@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:4px }
|
|
|
|
.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 }
|
|
|
|
.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 }
|
|
|
|
.metric-row { display:flex; gap:12px; margin-bottom:8px }
|
|
|
|
.metric-row { display:flex; gap:12px; margin-bottom:8px }
|
|
|
|
.metric { flex:1 }
|
|
|
|
.metric { flex:1 }
|
|
|
|
.metric-label { font-size:.7rem; color:var(--text-dim); text-transform:uppercase }
|
|
|
|
.metric-label { font-size:.7rem; color:var(--text-dim); text-transform:uppercase }
|
|
|
@@ -81,51 +82,109 @@
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ═══ 仪表盘 ═══
|
|
|
|
// ═══ 仪表盘 ═══
|
|
|
|
var _prevValues = {};
|
|
|
|
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 = 350;
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function renderNodes(nodes) {
|
|
|
|
function renderNodes(nodes) {
|
|
|
|
var now = Date.now();
|
|
|
|
|
|
|
|
var grid = document.getElementById("sentinel-grid");
|
|
|
|
var grid = document.getElementById("sentinel-grid");
|
|
|
|
if (!nodes.length) {
|
|
|
|
if (!nodes.length) {
|
|
|
|
grid.innerHTML = '<div class="card" style="text-align:center;padding:24px;color:var(--text-dim)">暂无节点 — 切换到节点管理添加</div>';
|
|
|
|
grid.innerHTML = '<div class="card" style="text-align:center;padding:24px;color:var(--text-dim)">暂无节点 — 切换到节点管理添加</div>';
|
|
|
|
|
|
|
|
_cardCache = {};
|
|
|
|
return;
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
grid.innerHTML = nodes.map(function(n){
|
|
|
|
// 清理已删除的节点
|
|
|
|
|
|
|
|
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 sys = n.system || {};
|
|
|
|
var cpu = sys.cpu || {};
|
|
|
|
var cpu = sys.cpu || {};
|
|
|
|
var mem = sys.memory || {};
|
|
|
|
var mem = sys.memory || {};
|
|
|
|
var net = sys.net_speed || {};
|
|
|
|
var net = sys.net_speed || {};
|
|
|
|
var cls = n.online ? 'online' : 'offline';
|
|
|
|
|
|
|
|
var cpuBar = bar(cpu.percent||0);
|
|
|
|
|
|
|
|
var memBar = bar(mem.percent||0);
|
|
|
|
|
|
|
|
var id = n.id || n.name;
|
|
|
|
var id = n.id || n.name;
|
|
|
|
var prev = _prevValues[id] || {};
|
|
|
|
var cpuVal = cpu.percent || 0;
|
|
|
|
var cpuTrend = prev.cpu !== undefined ? (cpu.percent > prev.cpu ? 'up' : (cpu.percent < prev.cpu ? 'down' : 'steady')) : 'steady';
|
|
|
|
var memVal = mem.percent || 0;
|
|
|
|
var memTrend = prev.mem !== undefined ? (mem.percent > prev.mem ? 'up' : (mem.percent < prev.mem ? 'down' : 'steady')) : 'steady';
|
|
|
|
var cpuBarCls = bar(cpuVal);
|
|
|
|
_prevValues[id] = {cpu: cpu.percent||0, mem: mem.percent||0, ts: now};
|
|
|
|
var memBarCls = bar(memVal);
|
|
|
|
var flash = prev.ts && (now - prev.ts > 1500) ? ' flash' : '';
|
|
|
|
|
|
|
|
return '<div class="node-card '+cls+flash+'" data-id="'+esc(id)+'">'+
|
|
|
|
var card = _cardCache[id];
|
|
|
|
'<div class="node-name">'+(n.online?'🟢':'🔴')+' '+esc(n.name)+'</div>'+
|
|
|
|
if (!card) {
|
|
|
|
'<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>' : '')+
|
|
|
|
card = document.createElement('div');
|
|
|
|
(n.online ?
|
|
|
|
card.className = 'node-card ' + (n.online ? 'online' : 'offline');
|
|
|
|
|
|
|
|
card.setAttribute('data-id', id);
|
|
|
|
|
|
|
|
card.innerHTML =
|
|
|
|
|
|
|
|
'<div class="node-name">'+(n.online?'🟢':'🔴')+' '+esc(n.name)+'</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;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 更新状态样式
|
|
|
|
|
|
|
|
card.className = 'node-card ' + (n.online ? 'online' : 'offline');
|
|
|
|
|
|
|
|
card.querySelector('.node-name').innerHTML = (n.online?'🟢':'🔴')+' '+esc(n.name);
|
|
|
|
|
|
|
|
card.querySelector('.node-url').textContent = n.url||'';
|
|
|
|
|
|
|
|
var notesEl = card.querySelector('.node-notes');
|
|
|
|
|
|
|
|
if (n.notes) {
|
|
|
|
|
|
|
|
if (!notesEl) {
|
|
|
|
|
|
|
|
notesEl = document.createElement('div'); notesEl.className = 'node-notes';
|
|
|
|
|
|
|
|
card.insertBefore(notesEl, card.querySelector('.metrics-body'));
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
notesEl.innerHTML = '📝 '+esc(n.notes);
|
|
|
|
|
|
|
|
} else if (notesEl) { notesEl.remove(); }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
var body = card.querySelector('.metrics-body');
|
|
|
|
|
|
|
|
if (n.online) {
|
|
|
|
|
|
|
|
var oldCpu = parseFloat((body.getAttribute('data-cpu')||cpuVal));
|
|
|
|
|
|
|
|
var oldMem = parseFloat((body.getAttribute('data-mem')||memVal));
|
|
|
|
|
|
|
|
body.setAttribute('data-cpu', cpuVal);
|
|
|
|
|
|
|
|
body.setAttribute('data-mem', memVal);
|
|
|
|
|
|
|
|
body.innerHTML =
|
|
|
|
'<div class="metric-row">'+
|
|
|
|
'<div class="metric-row">'+
|
|
|
|
'<div class="metric"><div class="metric-label">CPU</div><div class="metric-value '+cpuTrend+'">'+(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">CPU</div><div class="metric-value cpu-val">'+oldCpu.toFixed(1)+'%</div><div class="metric-bar"><div class="metric-bar-fill '+cpuBarCls+'" style="width:'+oldCpu+'%"></div></div></div>'+
|
|
|
|
'<div class="metric"><div class="metric-label">MEM</div><div class="metric-value '+memTrend+'">'+(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 class="metric"><div class="metric-label">MEM</div><div class="metric-value mem-val">'+oldMem.toFixed(1)+'%</div><div class="metric-bar"><div class="metric-bar-fill '+memBarCls+'" style="width:'+oldMem+'%"></div></div></div>'+
|
|
|
|
'</div>'+
|
|
|
|
'</div>'+
|
|
|
|
'<div class="metric-row">'+
|
|
|
|
'<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.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 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>'+
|
|
|
|
'<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="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>'+
|
|
|
|
var cpuEl = body.querySelector('.cpu-val');
|
|
|
|
'</div>';
|
|
|
|
var memEl = body.querySelector('.mem-val');
|
|
|
|
}).join("");
|
|
|
|
animateValue(cpuEl, oldCpu, cpuVal, 1, '%');
|
|
|
|
// 清除 flash 动画
|
|
|
|
animateValue(memEl, oldMem, memVal, 1, '%');
|
|
|
|
setTimeout(function(){
|
|
|
|
// 进度条平滑过渡 (CSS transition 自动处理)
|
|
|
|
document.querySelectorAll('.node-card.flash').forEach(function(el){ el.classList.remove('flash'); });
|
|
|
|
requestAnimationFrame(function(){
|
|
|
|
}, 700);
|
|
|
|
var fills = body.querySelectorAll('.metric-bar-fill');
|
|
|
|
|
|
|
|
if (fills[0]) { fills[0].style.width = cpuVal+'%'; fills[0].className = 'metric-bar-fill '+cpuBarCls; }
|
|
|
|
|
|
|
|
if (fills[1]) { fills[1].style.width = memVal+'%'; fills[1].className = 'metric-bar-fill '+memBarCls; }
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
} else {
|
|
|
|
|
|
|
|
body.innerHTML = '<div style="color:var(--error);font-size:.85rem">'+(n.error||'离线')+'</div>';
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
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 bar(v) { return v < 60 ? 'bar-green' : (v < 85 ? 'bar-yellow' : 'bar-red'); }
|
|
|
|