feat: fix dashboard real-time data sync, stabilize node order and eliminate UI jitter
Release / Build & Release (push) Canceled after 0s

This commit is contained in:
kory
2026-09-30 13:17:19 +08:00
parent bd44465eaa
commit af63e3a120
20 changed files with 2133 additions and 90 deletions
+285 -10
View File
@@ -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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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 => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[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);
})();