feat: fix dashboard real-time data sync, stabilize node order and eliminate UI jitter
This commit is contained in:
+285
-10
@@ -1,14 +1,289 @@
|
||||
(() => {
|
||||
const root = document.querySelector('#content');
|
||||
const fmtBytes = n => { if (!Number.isFinite(n)) return '-'; const u=['B','KB','MB','GB','TB']; let i=0,v=n; while(v>=1024&&i<u.length-1){v/=1024;i++;} return `${v.toFixed(i?1:0)} ${u[i]}`; };
|
||||
const summaryEl = document.querySelector('#summaryText');
|
||||
const lastUpdatedEl = document.querySelector('#lastUpdated');
|
||||
|
||||
const fmtBytes = n => {
|
||||
if (!Number.isFinite(n) || n <= 0) return '0 B';
|
||||
const u = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let i = 0, v = n;
|
||||
while (v >= 1024 && i < u.length - 1) {
|
||||
v /= 1024;
|
||||
i++;
|
||||
}
|
||||
return `${v.toFixed(i === 0 ? 0 : 1)} ${u[i]}`;
|
||||
};
|
||||
|
||||
const fmtRate = n => `${fmtBytes(n)}/s`;
|
||||
const fmtUp = n => { let s=Math.max(0,Math.floor(n||0)); const d=Math.floor(s/86400); s%=86400; const h=Math.floor(s/3600); s%=3600; const m=Math.floor(s/60); return d?`${d}天${h}小时`:h?`${h}小时${m}分`:`${m}分`; };
|
||||
const esc = s => String(s??'-').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
let mode=localStorage.getItem('vpc_probe_view')||'card';
|
||||
const setMode=m=>{mode=m;localStorage.setItem('vpc_probe_view',m);render(window.__data||[]);};
|
||||
document.querySelector('#cardBtn').onclick=()=>setMode('card'); document.querySelector('#compactBtn').onclick=()=>setMode('compact');
|
||||
const row=(d,card)=>{const p=d.profile||{}; return `<div class="${card?'card':'tr'}"><div class="name"><b>${esc(d.name)}</b><span class="${d.status==='online'?'on':'off'}">${d.status==='online'?'在线':'离线'}</span></div><span>Uptime ${fmtUp(p.uptime_seconds)}</span><span>Load ${[p.load_1m,p.load_5m,p.load_15m].map(x=>Number(x||0).toFixed(2)).join(' / ')}</span><span>CPU ${p.cpu_usage_percent??'-'}%</span><span>MEM ${p.mem_percent??'-'}%</span><span>↓ ${fmtRate(p.net_rx_rate)} ↑ ${fmtRate(p.net_tx_rate)}</span><span>流量 ↓ ${fmtBytes(p.net_rx_total)} ↑ ${fmtBytes(p.net_tx_total)}</span><span>Disk ${p.disk_free||'-'} ${p.disk_percent??'-'}%</span></div>`;};
|
||||
function render(data){document.querySelector('#cardBtn').classList.toggle('active',mode==='card');document.querySelector('#compactBtn').classList.toggle('active',mode==='compact');root.className=mode;root.innerHTML=mode==='card'?data.map(d=>row(d,true)).join(''):`<div class="table">${data.map(d=>row(d,false)).join('')}</div>`;}
|
||||
async function load(){try{const r=await fetch('/probe/summary',{cache:'no-store'});if(!r.ok)throw Error();window.__data=await r.json();render(window.__data);}catch(e){root.innerHTML='<p class="error">探针数据暂时不可用</p>';}}
|
||||
load();setInterval(load,5000);
|
||||
|
||||
const fmtUp = n => {
|
||||
let s = Math.max(0, Math.floor(n || 0));
|
||||
if (s <= 0) return '--';
|
||||
const d = Math.floor(s / 86400);
|
||||
s %= 86400;
|
||||
const h = Math.floor(s / 3600);
|
||||
s %= 3600;
|
||||
const m = Math.floor(s / 60);
|
||||
if (d > 0) return `${d}天 ${h}时`;
|
||||
if (h > 0) return `${h}小时 ${m}分`;
|
||||
return `${m} 分钟`;
|
||||
};
|
||||
|
||||
const esc = s => String(s ?? '-').replace(/[&<>"']/g, c => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||||
}[c]));
|
||||
|
||||
const getFlag = (cc, country) => {
|
||||
if (!cc || typeof cc !== 'string' || cc.length !== 2) return '';
|
||||
const code = cc.toUpperCase();
|
||||
const flag = String.fromCodePoint(...[...code].map(c => 0x1F1E6 - 65 + c.charCodeAt(0)));
|
||||
const title = country ? `${country} (${code})` : code;
|
||||
return `<span class="flag-icon" title="${esc(title)}">${flag}</span>`;
|
||||
};
|
||||
|
||||
const getStatusLevel = pct => {
|
||||
const val = Number(pct || 0);
|
||||
if (val >= 85) return 'danger';
|
||||
if (val >= 65) return 'warn';
|
||||
return 'normal';
|
||||
};
|
||||
|
||||
let mode = localStorage.getItem('vpc_probe_view') || 'card';
|
||||
|
||||
const setMode = m => {
|
||||
mode = m;
|
||||
localStorage.setItem('vpc_probe_view', m);
|
||||
render(window.__data || []);
|
||||
};
|
||||
|
||||
document.querySelector('#cardBtn').onclick = () => setMode('card');
|
||||
document.querySelector('#compactBtn').onclick = () => setMode('compact');
|
||||
|
||||
const renderCard = d => {
|
||||
const p = d.profile || {};
|
||||
const isOnline = d.status === 'online';
|
||||
const cpuPct = p.cpu_usage_percent != null ? Number(p.cpu_usage_percent) : 0;
|
||||
const memPct = p.mem_percent != null ? Number(p.mem_percent) : 0;
|
||||
const diskPct = p.disk_percent != null ? Number(p.disk_percent) : 0;
|
||||
const loadStr = [p.load_1m, p.load_5m, p.load_15m].map(x => Number(x || 0).toFixed(2)).join(', ');
|
||||
|
||||
return `
|
||||
<div class="node-card">
|
||||
<div class="card-top">
|
||||
<div class="node-title">
|
||||
<div class="node-name-wrap">
|
||||
${getFlag(d.country_code, d.country)}
|
||||
<span class="node-name">${esc(d.name)}</span>
|
||||
${d.is_local ? '<span class="local-badge">本机</span>' : ''}
|
||||
</div>
|
||||
<div class="node-meta">
|
||||
<span class="meta-pill">${p.cpu_cores || 1} 核</span>
|
||||
<span class="meta-pill">${esc(d.os || 'Linux')}</span>
|
||||
<span>${fmtUp(p.uptime_seconds)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="status-badge ${isOnline ? 'online' : 'offline'}">
|
||||
<span class="dot"></span>
|
||||
${isOnline ? '在线' : '离线'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="metrics-bars">
|
||||
<div class="metric-item">
|
||||
<div class="metric-header">
|
||||
<span class="metric-label">CPU 负载</span>
|
||||
<span class="metric-value">${p.cpu_usage_percent != null ? `${cpuPct}%` : '--'}</span>
|
||||
</div>
|
||||
<div class="progress-track">
|
||||
<div class="progress-fill ${getStatusLevel(cpuPct)}" style="width: ${Math.min(100, Math.max(0, cpuPct))}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="metric-item">
|
||||
<div class="metric-header">
|
||||
<span class="metric-label">内存 (空闲 ${esc(p.mem_available || '--')})</span>
|
||||
<span class="metric-value">${p.mem_percent != null ? `${memPct}%` : '--'}</span>
|
||||
</div>
|
||||
<div class="progress-track">
|
||||
<div class="progress-fill ${getStatusLevel(memPct)}" style="width: ${Math.min(100, Math.max(0, memPct))}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="metric-item">
|
||||
<div class="metric-header">
|
||||
<span class="metric-label">磁盘 (空闲 ${esc(p.disk_free || '--')})</span>
|
||||
<span class="metric-value">${p.disk_percent != null ? `${diskPct}%` : '--'}</span>
|
||||
</div>
|
||||
<div class="progress-track">
|
||||
<div class="progress-fill ${getStatusLevel(diskPct)}" style="width: ${Math.min(100, Math.max(0, diskPct))}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-stats-grid">
|
||||
<div class="stat-box">
|
||||
<span class="stat-title">实时网络 (↓ / ↑)</span>
|
||||
<span class="stat-desc">
|
||||
<b class="traffic-in">↓ ${fmtRate(p.net_rx_rate)}</b> /
|
||||
<b class="traffic-out">↑ ${fmtRate(p.net_tx_rate)}</b>
|
||||
</span>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<span class="stat-title">总流量 (↓ / ↑)</span>
|
||||
<span class="stat-desc">
|
||||
↓ ${fmtBytes(p.net_rx_total)} / ↑ ${fmtBytes(p.net_tx_total)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<span class="stat-title">系统平均负载 (1/5/15m)</span>
|
||||
<span class="stat-desc">${loadStr}</span>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<span class="stat-title">IO PSI / Docker</span>
|
||||
<span class="stat-desc">${p.io_psi != null ? Number(p.io_psi).toFixed(2) : '0.00'} / ${p.docker ? '有' : '无'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
const renderTable = list => {
|
||||
const rows = list.map(d => {
|
||||
const p = d.profile || {};
|
||||
const isOnline = d.status === 'online';
|
||||
const cpuPct = p.cpu_usage_percent != null ? Number(p.cpu_usage_percent) : 0;
|
||||
const memPct = p.mem_percent != null ? Number(p.mem_percent) : 0;
|
||||
const diskPct = p.disk_percent != null ? Number(p.disk_percent) : 0;
|
||||
const loadStr = [p.load_1m, p.load_5m, p.load_15m].map(x => Number(x || 0).toFixed(2)).join(' / ');
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td>
|
||||
<div class="table-node">
|
||||
<span class="name">
|
||||
${getFlag(d.country_code, d.country)}
|
||||
${esc(d.name)}
|
||||
${d.is_local ? '<span class="local-badge">本机</span>' : ''}
|
||||
</span>
|
||||
<span class="sub">${p.cpu_cores || 1}核 | ${esc(d.os || 'Linux')}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="status-badge ${isOnline ? 'online' : 'offline'}">
|
||||
<span class="dot"></span>
|
||||
${isOnline ? '在线' : '离线'}
|
||||
</span>
|
||||
</td>
|
||||
<td class="col-mono">${fmtUp(p.uptime_seconds)}</td>
|
||||
<td class="col-mono">${loadStr}</td>
|
||||
<td>
|
||||
<div class="mini-bar-wrap">
|
||||
<div class="mini-bar">
|
||||
<div class="mini-bar-fill progress-fill ${getStatusLevel(cpuPct)}" style="width: ${Math.min(100, Math.max(0, cpuPct))}%"></div>
|
||||
</div>
|
||||
<span class="col-mono">${p.cpu_usage_percent != null ? `${cpuPct}%` : '--'}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="mini-bar-wrap">
|
||||
<div class="mini-bar">
|
||||
<div class="mini-bar-fill progress-fill ${getStatusLevel(memPct)}" style="width: ${Math.min(100, Math.max(0, memPct))}%"></div>
|
||||
</div>
|
||||
<span class="col-mono">${p.mem_percent != null ? `${memPct}%` : '--'}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="col-mono">
|
||||
<span class="traffic-in">↓ ${fmtRate(p.net_rx_rate)}</span><br>
|
||||
<span class="traffic-out">↑ ${fmtRate(p.net_tx_rate)}</span>
|
||||
</td>
|
||||
<td class="col-mono">
|
||||
↓ ${fmtBytes(p.net_rx_total)}<br>
|
||||
↑ ${fmtBytes(p.net_tx_total)}
|
||||
</td>
|
||||
<td>
|
||||
<div class="mini-bar-wrap">
|
||||
<div class="mini-bar">
|
||||
<div class="mini-bar-fill progress-fill ${getStatusLevel(diskPct)}" style="width: ${Math.min(100, Math.max(0, diskPct))}%"></div>
|
||||
</div>
|
||||
<span class="col-mono">${p.disk_percent != null ? `${diskPct}%` : '--'}</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
return `
|
||||
<div class="table-wrapper">
|
||||
<table class="probe-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>节点名称</th>
|
||||
<th>状态</th>
|
||||
<th>运行时间</th>
|
||||
<th>负载 (1/5/15m)</th>
|
||||
<th>CPU</th>
|
||||
<th>内存</th>
|
||||
<th>实时网速</th>
|
||||
<th>累计流量</th>
|
||||
<th>磁盘</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${rows}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
function render(data) {
|
||||
document.querySelector('#cardBtn').classList.toggle('active', mode === 'card');
|
||||
document.querySelector('#compactBtn').classList.toggle('active', mode === 'compact');
|
||||
|
||||
const total = data.length;
|
||||
const onlineCount = data.filter(x => x.status === 'online').length;
|
||||
if (summaryEl) {
|
||||
summaryEl.textContent = `${onlineCount} / ${total} 节点在线`;
|
||||
}
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
root.innerHTML = '<div class="error-msg">暂无节点数据</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === 'card') {
|
||||
root.className = 'card-view';
|
||||
root.innerHTML = `<div class="card-grid">${data.map(renderCard).join('')}</div>`;
|
||||
} else {
|
||||
root.className = 'table-view';
|
||||
root.innerHTML = renderTable(data);
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const r = await fetch('/probe/summary', { cache: 'no-store' });
|
||||
if (!r.ok) throw Error();
|
||||
let rawData = await r.json();
|
||||
if (Array.isArray(rawData)) {
|
||||
rawData.sort((a, b) => {
|
||||
if (a.is_local && !b.is_local) return -1;
|
||||
if (!a.is_local && b.is_local) return 1;
|
||||
return String(a.name || "").localeCompare(String(b.name || ""));
|
||||
});
|
||||
}
|
||||
window.__data = rawData;
|
||||
render(window.__data);
|
||||
if (lastUpdatedEl) {
|
||||
const now = new Date();
|
||||
const timeStr = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}`;
|
||||
lastUpdatedEl.textContent = `更新于 ${timeStr}`;
|
||||
}
|
||||
} catch (e) {
|
||||
root.innerHTML = '<div class="error-msg">探针数据暂时不可用,正在重试...</div>';
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
setInterval(load, 5000);
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user