ServerStatus/web/js/app.js

503 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

// 简洁现代前端 - 仅使用原生 JS
const S = { updated:0, servers:[], ssl:[], error:false, hist:{}, metricHist:{}, loadHist:{} };// hist latency; metricHist: {key:{cpu:[],mem:[],hdd:[]}}; loadHist: {key:[]}
const els = {
notice: ()=>document.getElementById('notice'),
last: ()=>document.getElementById('lastUpdate'),
serversBody: ()=>document.getElementById('serversBody'),
monitorsBody: ()=>document.getElementById('monitorsBody'),
sslBody: ()=>document.getElementById('sslBody')
};
function bytes(v){ if(v===0) return '0B'; if(!v) return '-'; const k=1000; const u=['B','KB','MB','GB','TB','PB']; const i=Math.floor(Math.log(v)/Math.log(k)); return (v/Math.pow(k,i)).toFixed(i?1:0)+u[i]; }
function pct(v){ return (v||0).toFixed(0)+'%'; }
function clsBy(v){ return v>=90?'danger':v>=80?'warn':'ok'; }
function humanAgo(ts){ if(!ts) return '-'; const s=Math.floor((Date.now()/1000 - ts)); const m=Math.floor(s/60); return m>0? m+' 分钟前':'几秒前'; }
function num(v){ return (typeof v==='number' && !isNaN(v)) ? v : '-'; }
async function fetchData(){
try {
const r = await fetch('json/stats.json?_='+Date.now());
if(!r.ok) throw new Error(r.status);
const j = await r.json();
if(j.reload) location.reload();
S.updated = j.updated; S.servers = j.servers||[]; S.ssl = j.sslcerts||[]; S.error=false;
// 更新延迟历史 (按节点名聚合)
S.servers.forEach(s=>{
const key = s.name || s.location || 'node';
if(!S.hist[key]) S.hist[key] = {cu:[],ct:[],cm:[]};
const H = S.hist[key];
// 使用 time_ 字段 (ms) 若不存在则跳过
if(typeof s.time_10010 === 'number') H.cu.push(s.time_10010);
if(typeof s.time_189 === 'number') H.ct.push(s.time_189);
if(typeof s.time_10086 === 'number') H.cm.push(s.time_10086);
const MAX=120; // 保留约 120*4s ≈ 8 分钟
['cu','ct','cm'].forEach(k=>{ if(H[k].length>MAX) H[k].splice(0,H[k].length-MAX); });
// 指标历史 (仅在线时记录)
if(!S.metricHist[key]) S.metricHist[key] = {cpu:[],mem:[],hdd:[]};
const MH = S.metricHist[key];
if(s.online4||s.online6){
const memPct = s.memory_total? (s.memory_used/s.memory_total*100):0;
const hddPct = s.hdd_total? (s.hdd_used/s.hdd_total*100):0;
MH.cpu.push(s.cpu||0);
MH.mem.push(memPct||0);
MH.hdd.push(hddPct||0);
const MAXM=120; ['cpu','mem','hdd'].forEach(k=>{ if(MH[k].length>MAXM) MH[k].splice(0,MH[k].length-MAXM); });
}
// 负载历史 (记录 load_1 / load_5 / load_15)
if(!S.loadHist[key]) S.loadHist[key] = {l1:[],l5:[],l15:[]};
const LH = S.loadHist[key];
const pushLoad = (arr,val)=>{ if(typeof val === 'number' && val >= 0){ arr.push(val); if(arr.length>120) arr.splice(0,arr.length-120); } };
pushLoad(LH.l1, s.load_1);
pushLoad(LH.l5, s.load_5);
pushLoad(LH.l15, s.load_15);
});
render();
}catch(e){ S.error=true; els.notice().textContent = '数据获取失败'; console.error(e); }
}
function render(){
els.notice().style.display='none';
renderServers();
renderServersCards();
renderMonitors();
renderMonitorsCards();
renderSSL();
renderSSLCards();
updateTime();
}
function renderServers(){
const tbody = els.serversBody();
let html='';
S.servers.forEach((s,idx)=>{
const online = s.online4||s.online6;
const proto = online ? (s.online4 && s.online6? '双栈': s.online4? 'IPv4':'IPv6') : '离线';
const statusPill = online ? `<span class="pill on">${proto}</span>` : `<span class="pill off">${proto}</span>`;
const cpuCls = clsBy(s.cpu);
const memPct = s.memory_total? (s.memory_used/s.memory_total*100):0; const memCls = clsBy(memPct);
const hddPct = s.hdd_total? (s.hdd_used/s.hdd_total*100):0; const hddCls = clsBy(hddPct);
const monthInBytes = (s.network_in - s.last_network_in) || 0;
const monthOutBytes = (s.network_out - s.last_network_out) || 0;
const monthIn = bytes(monthInBytes);
const monthOut = bytes(monthOutBytes);
const HEAVY_THRESHOLD = 500 * 1000 * 1000 * 1000; // 500GB
const heavy = monthInBytes >= HEAVY_THRESHOLD || monthOutBytes >= HEAVY_THRESHOLD;
const trafficCls = heavy ? 'caps-traffic duo heavy' : 'caps-traffic duo normal';
const netNow = bytes(s.network_rx) + ' | ' + bytes(s.network_tx);
const netTotal = bytes(s.network_in)+' | '+bytes(s.network_out);
const p1 = (s.ping_10010||0); const p2 = (s.ping_189||0); const p3 = (s.ping_10086||0);
function bucket(p){ const v = Math.max(0, Math.min(100, p)); const level = v>=20?'bad':(v>=10?'warn':'ok'); return `<div class=\"bucket\" data-lv=\"${level}\"><span style=\"--h:${v}%\"></span><label>${v.toFixed(0)}%</label></div>`; }
const pingBuckets = `<div class=\"buckets\" title=\"CU/CT/CM\">${bucket(p1)}${bucket(p2)}${bucket(p3)}</div>`;
const key = s.name || s.location || 'node';
const rowCursor = online? 'pointer':'default';
const highLoad = online && ( (s.cpu||0)>=90 || (memPct)>=90 || (hddPct)>=90 );
html += `<tr data-idx="${idx}" data-online="${online?1:0}" class="row-server${highLoad?' high-load':''}" style="cursor:${rowCursor};${online?'':'opacity:.65;'}">
<td>${statusPill}</td>
<td><span class="${trafficCls}" title="本月下行 | 上行 (≥500GB 触发红黄)"><span class="half in">${monthIn}</span><span class="half out">${monthOut}</span></span></td>
<td>${s.name||'-'}</td>
<td>${s.type||'-'}</td>
<td>${s.location||'-'}</td>
<td>${s.uptime||'-'}</td>
<td>${s.load_1==-1?'':Math.max(0,(s.load_1||0)).toFixed(2)}</td>
<td>${netNow}</td>
<td>${netTotal}</td>
<td>${online?gaugeHTML('cpu', s.cpu||0):'-'}</td>
<td>${online?gaugeHTML('mem', memPct):'-'}</td>
<td>${online?gaugeHTML('hdd', hddPct):'-'}</td>
<td>${pingBuckets}</td>
</tr>`;
});
tbody.innerHTML = html || `<tr><td colspan="13" class="muted" style="text-align:center;padding:1rem;">无数据</td></tr>`;
// 绑定行点击
tbody.querySelectorAll('tr.row-server').forEach(tr=>{
tr.addEventListener('click',()=>{
if(tr.getAttribute('data-online')!=='1') return; // 离线不弹出
const i = parseInt(tr.getAttribute('data-idx'));
openDetail(i);
});
});
// 仪表盘无需 drawSparks
}
// 生成仪表盘 (圆形 conic-gradient)
function gaugeHTML(type,val){
const pct = Math.max(0,Math.min(100,val));
const p = (pct/100).toFixed(3);
const warnAttr = pct>=90? 'data-bad' : (pct>=50? 'data-warn' : '');
return `<div class="gauge-half" data-type="${type}" ${warnAttr} style="--p:${p}" title="${labelOf(type)} ${pct.toFixed(0)}%">
<svg viewBox="0 0 100 50" preserveAspectRatio="xMidYMid meet" aria-hidden="true">
<path class="track" d="M10 50 A40 40 0 0 1 90 50" />
<path class="arc" d="M10 50 A40 40 0 0 1 90 50" />
</svg>
<span>${pct.toFixed(0)}%</span>
</div>`;
}
function labelOf(t){ return t==='cpu'?'CPU': t==='mem'?'内存':'硬盘'; }
function renderServersCards(){
const wrap = document.getElementById('serversCards');
if(!wrap) return;
// 仅在窄屏时显示 (和 CSS 一致判断, 可稍放宽避免闪烁)
if(window.innerWidth>700){ wrap.innerHTML=''; return; }
let html='';
S.servers.forEach((s,idx)=>{
const online = s.online4||s.online6;
const proto = online ? (s.online4 && s.online6? '双栈': s.online4? 'IPv4':'IPv6') : '离线';
const pill = `<span class="status-pill ${online?'on':'off'}">${proto}</span>`;
const memPct = s.memory_total? (s.memory_used/s.memory_total*100):0;
const hddPct = s.hdd_total? (s.hdd_used/s.hdd_total*100):0;
// 月流量(移动端)并应用 500GB 阈值配色逻辑
const monthInBytes = (s.network_in - s.last_network_in) || 0;
const monthOutBytes = (s.network_out - s.last_network_out) || 0;
const monthIn = bytes(monthInBytes);
const monthOut = bytes(monthOutBytes);
const HEAVY_THRESHOLD = 500 * 1000 * 1000 * 1000; // 500GB
const heavy = monthInBytes >= HEAVY_THRESHOLD || monthOutBytes >= HEAVY_THRESHOLD;
const trafficCls = heavy ? 'caps-traffic duo heavy sm' : 'caps-traffic duo normal sm';
const netNow = bytes(s.network_rx)+' | '+bytes(s.network_tx);
const netTotal = bytes(s.network_in)+' | '+bytes(s.network_out);
const p1 = (s.ping_10010||0); const p2=(s.ping_189||0); const p3=(s.ping_10086||0);
function bucket(p){ const v=Math.max(0,Math.min(100,p)); const level = v>=20?'bad':(v>=10?'warn':'ok'); return `<div class=\"bucket\" data-lv=\"${level}\"><span style=\"--h:${v}%\"></span><label>${v.toFixed(0)}%</label></div>`; }
const buckets = `<div class=\"buckets\">${bucket(p1)}${bucket(p2)}${bucket(p3)}</div>`;
const key = s.name || s.location || 'node';
const highLoad = online && ( (s.cpu||0)>=90 || (memPct)>=90 || (hddPct)>=90 );
html += `<div class=\"card${online?'':' offline'}${highLoad?' high-load':''}\" data-idx=\"${idx}\" data-online=\"${online?1:0}\">\n <button class=\"expand-btn\" aria-label=\"展开\">▼</button>\n <div class=\"card-header\">\n <div class=\"card-title\">${s.name||'-'} <span class=\"tag\">${s.location||'-'}</span></div>\n ${pill}\n </div>\n <div class=\"kvlist\">\n <div><span class=\"key\">负载</span><span>${s.load_1==-1?'':s.load_1?.toFixed(2)}</span></div>\n <div><span class=\"key\">在线</span><span>${s.uptime||'-'}</span></div>\n <div><span class=\"key\">月流量</span><span><span class=\"${trafficCls}\" title=\"本月下行 | 上行 (≥500GB 触发红黄)\"><span class=\"half in\">${monthIn}</span><span class=\"half out\">${monthOut}</span></span></span></div>\n <div><span class=\"key\">网络</span><span>${netNow}</span></div>\n <div><span class=\"key\">总流量</span><span>${netTotal}</span></div>\n <div><span class=\"key\">CPU</span><span>${s.cpu||0}%</span></div>\n <div><span class=\"key\">内存</span><span>${memPct.toFixed(0)}%</span></div>\n <div><span class=\"key\">硬盘</span><span>${hddPct.toFixed(0)}%</span></div>\n </div>\n ${buckets}\n <div class=\"expand-area\">\n <div style=\"font-size:.65rem;opacity:.7;margin-top:.3rem\">${online?'点击卡片可查看详情':'离线,不可查看详情'}</div>\n </div>\n </div>`;
});
wrap.innerHTML = html || '<div class="muted" style="font-size:.75rem;text-align:center;padding:1rem;">无数据</div>';
wrap.querySelectorAll('.card').forEach(card=>{
const idx = parseInt(card.getAttribute('data-idx'));
card.addEventListener('click', e=>{
if(e.target.classList.contains('expand-btn')){ card.classList.toggle('expanded'); e.stopPropagation(); return;}
if(card.getAttribute('data-online')!=='1') return; // 离线不弹
openDetail(idx);
});
});
}
function renderMonitors(){
const tbody = els.monitorsBody();
let html='';
S.servers.forEach(s=>{
html += `<tr>
<td>${(s.online4||s.online6)?'在线':'离线'}</td>
<td>${s.name||'-'}</td>
<td>${s.location||'-'}</td>
<td>${s.custom||'-'}</td>
</tr>`;
});
tbody.innerHTML = html || `<tr><td colspan="4" class="muted" style="text-align:center;padding:1rem;">无数据</td></tr>`;
}
// 服务卡片 (移动端)
function renderMonitorsCards(){
const wrap = document.getElementById('monitorsCards');
if(!wrap) return; if(window.innerWidth>700){ wrap.innerHTML=''; return; }
let html='';
S.servers.forEach(s=>{
const online = (s.online4||s.online6)?'在线':'离线';
const pill = `<span class="status-pill ${online==='在线'?'on':'off'}">${online}</span>`;
html += `<div class="card">
<div class="card-header"><div class="card-title">${s.name||'-'} <span class="tag">${s.location||'-'}</span></div>${pill}</div>
<div class="kvlist" style="grid-template-columns:repeat(2,minmax(0,1fr));">
<div><span class="key">监测内容</span><span>${s.custom||'-'}</span></div>
<div><span class="key">协议</span><span>${online}</span></div>
</div>
</div>`;
});
wrap.innerHTML = html || '<div class="muted" style="font-size:.75rem;text-align:center;padding:1rem;">无数据</div>';
}
function renderSSL(){
const tbody = els.sslBody();
let html='';
S.ssl.forEach(c=>{
const cls = c.expire_days<=0? 'err': c.expire_days<=7? 'warn':'ok';
const status = c.expire_days<=0? '已过期': c.expire_days<=7? '将到期':'正常';
const dt = c.expire_ts? new Date(c.expire_ts*1000).toISOString().replace('T',' ').replace(/\.\d+Z/,''):'-';
html += `<tr>
<td>${c.name||'-'}</td>
<td>${(c.domain||'').replace(/^https?:\/\//,'')}</td>
<td>${c.port||443}</td>
<td><span class="badge ${cls}">${c.expire_days??'-'}</span></td>
<td>${dt}</td>
<td><span class="badge ${cls}">${status}</span></td>
</tr>`;
});
tbody.innerHTML = html || `<tr><td colspan="6" class="muted" style="text-align:center;padding:1rem;">无证书数据</td></tr>`;
}
// 证书卡片 (移动端)
function renderSSLCards(){
const wrap = document.getElementById('sslCards');
if(!wrap) return; if(window.innerWidth>700){ wrap.innerHTML=''; return; }
let html='';
S.ssl.forEach(c=>{
const cls = c.expire_days<=0? 'err': c.expire_days<=7? 'warn':'ok';
const status = c.expire_days<=0? '已过期': c.expire_days<=7? '将到期':'正常';
const dt = c.expire_ts? new Date(c.expire_ts*1000).toISOString().replace('T',' ').replace(/\.\d+Z/,''):'-';
html += `<div class="card">
<div class="card-header"><div class="card-title">${c.name||'-'}</div><span class="status-pill ${cls==='err'?'off':'on'}">${status}</span></div>
<div class="kvlist" style="grid-template-columns:repeat(2,minmax(0,1fr));">
<div><span class="key">域名</span><span>${(c.domain||'').replace(/^https?:\/\//,'')}</span></div>
<div><span class="key">端口</span><span>${c.port||443}</span></div>
<div><span class="key">剩余(天)</span><span>${c.expire_days??'-'}</span></div>
<div><span class="key">到期</span><span>${dt.split(' ')[0]||dt}</span></div>
</div>
</div>`;
});
wrap.innerHTML = html || '<div class="muted" style="font-size:.75rem;text-align:center;padding:1rem;">无证书数据</div>';
}
function updateTime(){
const el = els.last();
if(S.updated){ el.textContent = '最后更新: '+ humanAgo(S.updated); }
}
function bindTabs(){
document.getElementById('navTabs').addEventListener('click',e=>{
if(e.target.tagName!=='BUTTON') return; const tab=e.target.dataset.tab;
document.querySelectorAll('.nav button').forEach(b=>b.classList.toggle('active',b===e.target));
document.querySelectorAll('.panel').forEach(p=>p.classList.toggle('active', p.id==='panel-'+tab));
});
}
function bindTheme(){
const btn = document.getElementById('themeToggle');
const mql = window.matchMedia('(prefers-color-scheme: light)');
const saved = localStorage.getItem('theme'); // 'light' | 'dark' | null (auto)
const apply = (isLight)=>{ document.body.classList.toggle('light', isLight); document.documentElement.classList.toggle('light', isLight); };
if(!saved){
// 自动跟随系统
apply(mql.matches);
// 监听系统偏好变化(仅在未手动选择时)
mql.addEventListener('change', e=>{ if(!localStorage.getItem('theme')) apply(e.matches); });
} else {
apply(saved==='light');
}
btn.addEventListener('click',()=>{
// 用户手动切换后即固定,不再自动
const toLight = !document.body.classList.contains('light');
apply(toLight);
localStorage.setItem('theme', toLight?'light':'dark');
});
}
bindTabs();
bindTheme();
fetchData();
setInterval(fetchData, 1000);
setInterval(updateTime, 60000);
// 详情弹窗逻辑
function openDetail(i){
const s = S.servers[i]; if(!s) return;
const box = document.getElementById('detailContent');
const modal = document.getElementById('detailModal');
document.getElementById('detailTitle').textContent = s.name + ' 详情';
const offline = !(s.online4||s.online6);
const memPct = s.memory_total? (s.memory_used/s.memory_total*100):0;
const swapPct = s.swap_total? (s.swap_used/s.swap_total*100):0;
const hddPct = s.hdd_total? (s.hdd_used/s.hdd_total*100):0;
const ioRead = (typeof s.io_read==='number')? s.io_read:0;
const ioWrite = (typeof s.io_write==='number')? s.io_write:0;
const procLine = `${num(s.tcp_count)} / ${num(s.udp_count)} / ${num(s.process_count)} / ${num(s.thread_count)}`;
// 保留延迟数据用于图表,但不再展示当前延迟文字行
const latText = offline ? '离线' : `CU/CT/CM: ${num(s.time_10010)}ms (${(s.ping_10010||0).toFixed(0)}%) / ${num(s.time_189)}ms (${(s.ping_189||0).toFixed(0)}%) / ${num(s.time_10086)}ms (${(s.ping_10086||0).toFixed(0)}%)`;
const key = s.name || s.location || 'node';
let latencyBlock = '';
if(!offline){
latencyBlock = `
<div style="display:flex;flex-direction:column;gap:.4rem;">
<canvas id="latChart" height="150" style="width:100%;border:1px solid var(--border);border-radius:10px;background:linear-gradient(145deg,var(--bg),var(--bg-alt));"></canvas>
<div class="mono" style="font-size:11px;display:flex;gap:1rem;flex-wrap:wrap;">
<span style="color:#3b82f6">● 联通 (<span id="lat-cu">${num(s.time_10010)}ms</span>)</span>
<span style="color:#10b981">● 电信 (<span id="lat-ct">${num(s.time_189)}ms</span>)</span>
<span style="color:#f59e0b">● 移动 (<span id="lat-cm">${num(s.time_10086)}ms</span>)</span>
<span style="opacity:.6"> (~${S.hist[key]?S.hist[key].cu.length:0} 条)</span>
</div>
</div>`;
} else {
latencyBlock = `
<div style="display:flex;flex-direction:column;gap:.4rem;">
<canvas id="latChart" height="150" style="width:100%;border:1px solid var(--border);border-radius:10px;background:linear-gradient(145deg,var(--bg),var(--bg-alt));"></canvas>
<div class="mono" style="font-size:11px;opacity:.6;">离线,无联通/电信/移动延迟数据</div>
</div>`;
}
// 旧进度条函数 barHTML/ioBar 已弃用
// 资源行(移除百分比显示,仅显示 已用 / 总量)
const memLine = s.memory_total? bytes(s.memory_used)+' / '+bytes(s.memory_total):'-';
const swapLine = s.swap_total? bytes(s.swap_used)+' / '+bytes(s.swap_total):'-';
const diskLine = s.hdd_total? bytes(s.hdd_used)+' / '+bytes(s.hdd_total):'-';
const ioReadLine = ioRead? bytes(ioRead)+'/s':'-';
const ioWriteLine = ioWrite? bytes(ioWrite)+'/s':'-';
// 阈值着色 ( >80% / >100MB/s )
const memColor = memPct>80? ' style="color:var(--danger)"':'';
const swapColor = swapPct>80? ' style="color:var(--danger)"':'';
const hddColor = hddPct>80? ' style="color:var(--danger)"':'';
const readColor = ioRead>100*1000*1000? ' style="color:var(--danger)"':'';
const writeColor = ioWrite>100*1000*1000? ' style="color:var(--danger)"':'';
box.innerHTML = `
<div class="kv"><span>TCP/UDP/进/线</span><span class="mono" id="detail-proc">${procLine}</span></div>
<div class="kv"><span>内存 / 虚存</span><span class="mono"><span${memColor}>${memLine}</span> | <span${swapColor}>${swapLine}</span></span></div>
<div class="kv"><span>硬盘 / 读写</span><span class="mono"><span${hddColor}>${diskLine}</span> | 读 <span${readColor}>${ioReadLine}</span> / 写 <span${writeColor}>${ioWriteLine}</span></span></div>
<div style="display:flex;flex-direction:column;gap:.35rem;">
<canvas id="loadChart" height="120" style="width:100%;border:1px solid var(--border);border-radius:10px;background:linear-gradient(145deg,var(--bg),var(--bg-alt));"></canvas>
<div class="mono" style="font-size:11px;display:flex;gap:.9rem;flex-wrap:wrap;align-items:center;opacity:.8;">
<span style="color:#8b5cf6">● load1</span>
<span style="color:#10b981">● load5</span>
<span style="color:#f59e0b">● load15</span>
<span style="opacity:.6">(~${(S.loadHist[key]?S.loadHist[key].l1.length:0)} 条)</span>
</div>
</div>
<!-- 进度条移除:读/写/虚存以文本形式显示于上方合并行 -->
${latencyBlock}
`;
modal.style.display='flex';
document.addEventListener('keydown', escCloseOnce);
if(!offline){
drawLatencyChart(key);
drawLoadChart(key);
S._openDetailKey = key; // 记录当前弹窗对应节点
startDetailAutoUpdate();
} else {
S._openDetailKey = null;
stopDetailAutoUpdate();
}
}
function escCloseOnce(e){ if(e.key==='Escape'){ closeDetail(); } }
function closeDetail(){ const m=document.getElementById('detailModal'); m.style.display='none'; document.removeEventListener('keydown', escCloseOnce); stopDetailAutoUpdate(); }
document.getElementById('detailClose').addEventListener('click', closeDetail);
document.getElementById('detailModal').addEventListener('click', e=>{ if(e.target.id==='detailModal') closeDetail(); });
// 绘制三网延迟折线图 (简易实现)
function drawLatencyChart(key){
const data = S.hist[key];
const canvas = document.getElementById('latChart');
if(!canvas || !data) return;
const ctx = canvas.getContext('2d');
const W = canvas.clientWidth; const H = canvas.height; canvas.width = W; // 适配宽度
ctx.clearRect(0,0,W,H);
const padL=40, padR=10, padT=10, padB=18;
const isLight = document.body.classList.contains('light');
const axisColor = isLight? 'rgba(0,0,0,0.22)' : 'rgba(255,255,255,0.18)';
const gridColor = isLight? 'rgba(0,0,0,0.08)' : 'rgba(255,255,255,0.10)';
const textColor = isLight? 'var(--text-dim)' : 'rgba(226,232,240,0.85)';
const series = [ {arr:data.cu,color:'#3b82f6'}, {arr:data.ct,color:'#10b981'}, {arr:data.cm,color:'#f59e0b'} ];
const allVals = series.flatMap(s=>s.arr);
if(!allVals.length){ ctx.fillStyle='var(--text-dim)'; ctx.font='12px system-ui'; ctx.fillText('暂无数据', W/2-30, H/2); return; }
const max = Math.max(...allVals);
const min = Math.min(...allVals);
const range = Math.max(1, max-min);
const n = Math.max(...series.map(s=>s.arr.length));
const xStep = (W - padL - padR) / Math.max(1,n-1);
// 网格与轴 (增强暗色对比)
ctx.strokeStyle=axisColor; ctx.lineWidth=1.1;
ctx.beginPath(); ctx.moveTo(padL,padT); ctx.lineTo(padL,H-padB); ctx.lineTo(W-padR,H-padB); ctx.stroke();
ctx.fillStyle=textColor; ctx.font='10px system-ui';
const yMarks=4; for(let i=0;i<=yMarks;i++){ const y = padT + (H-padT-padB)*i/yMarks; const val = (max - range*i/yMarks).toFixed(0)+'ms'; ctx.fillText(val,4,y+3); ctx.strokeStyle=gridColor; ctx.beginPath(); ctx.moveTo(padL,y); ctx.lineTo(W-padR,y); ctx.stroke(); }
// 绘制线
series.forEach(s=>{
if(s.arr.length<2) return;
ctx.strokeStyle = s.color; ctx.lineWidth=1.6; ctx.beginPath();
s.arr.forEach((v,i)=>{ const x = padL + xStep*i; const y = padT + (H-padT-padB)*(1-(v-min)/range); if(i===0) ctx.moveTo(x,y); else ctx.lineTo(x,y); });
ctx.stroke();
});
}
// 在每次 render 后若弹窗打开则重绘最新图
const _oldRender = render;
render = function(){ _oldRender(); if(S._openDetailKey){ drawLatencyChart(S._openDetailKey); drawLoadChart(S._openDetailKey); } };
window.addEventListener('resize', ()=>{
if(S._openDetailKey){ drawLatencyChart(S._openDetailKey); drawLoadChart(S._openDetailKey); }
renderServersCards();
renderMonitorsCards();
renderSSLCards();
});
// 绘制小型折线 (sparklines)
function drawSparks(){
const els = document.querySelectorAll('.spark');
els.forEach(div=>{
// 若已有canvas跳过重建
let canvas = div.querySelector('canvas');
if(!canvas){ canvas = document.createElement('canvas'); div.appendChild(canvas); }
const key = div.getAttribute('data-key');
const metric = div.getAttribute('data-metric');
const hist = (S.metricHist[key] && S.metricHist[key][metric])? S.metricHist[key][metric]:[];
const W = 80, H = 26; canvas.width=W; canvas.height=H; const ctx=canvas.getContext('2d');
ctx.clearRect(0,0,W,H);
div.classList.add('spark-ready');
if(hist.length<2){ ctx.fillStyle='var(--text-dim)'; ctx.font='10px system-ui'; ctx.fillText('-', W/2-3, H/2+3); return; }
// 硬盘与 CPU/内存保持一致的折线显示(去掉低波动迷你条特殊样式)
const max = Math.max(...hist); const min = Math.min(...hist); const range = Math.max(1,max-min);
const step = W/(hist.length-1);
// 线颜色
let color = '#3b82f6'; if(metric==='mem') color='#10b981'; else if(metric==='hdd') color='#f59e0b';
ctx.strokeStyle=color; ctx.lineWidth=1.3; ctx.beginPath();
hist.forEach((v,i)=>{ const x=i*step; const y=H - ( (v-min)/range )* (H-4) -2; if(i===0) ctx.moveTo(x,y); else ctx.lineTo(x,y); });
ctx.stroke();
// 当前值点
const last = hist[hist.length-1]; const lx = W-1; const ly = H - ((last-min)/range)*(H-4)-2; ctx.fillStyle=color; ctx.beginPath(); ctx.arc(lx,ly,2,0,Math.PI*2); ctx.fill();
});
}
// 负载折线图 (load1 历史)
function drawLoadChart(key){
const L = S.loadHist[key];
const canvas = document.getElementById('loadChart');
if(!canvas) return; const ctx = canvas.getContext('2d');
if(!L){ ctx.clearRect(0,0,canvas.width,canvas.height); return; }
const l1=L.l1||[], l5=L.l5||[], l15=L.l15||[];
const canvasW = canvas.clientWidth; const H = canvas.height; canvas.width = canvasW; const W=canvasW;
ctx.clearRect(0,0,W,H);
if(l1.length<2){ ctx.fillStyle='var(--text-dim)'; ctx.font='12px system-ui'; ctx.fillText('暂无负载数据', W/2-42, H/2); return; }
const all = [...l1,...l5,...l15];
const padL=38,padR=8,padT=8,padB=16;
const max=Math.max(...all); const min=Math.min(...all); const range=Math.max(0.5,max-min);
const n = Math.max(l1.length,l5.length,l15.length); const xStep=(W-padL-padR)/Math.max(1,n-1);
const isLight = document.body.classList.contains('light');
const axisColor = isLight? 'rgba(0,0,0,0.22)' : 'rgba(255,255,255,0.18)';
const gridColor = isLight? 'rgba(0,0,0,0.08)' : 'rgba(255,255,255,0.10)';
const textColor = isLight? 'var(--text-dim)' : 'rgba(226,232,240,0.85)';
// 轴 & 网格 (增强暗色对比)
ctx.strokeStyle=axisColor; ctx.lineWidth=1.1; ctx.beginPath(); ctx.moveTo(padL,padT); ctx.lineTo(padL,H-padB); ctx.lineTo(W-padR,H-padB); ctx.stroke();
ctx.fillStyle=textColor; ctx.font='10px system-ui';
const yMarks=4; for(let i=0;i<=yMarks;i++){ const y=padT+(H-padT-padB)*i/yMarks; const val=(max - range*i/yMarks).toFixed(2); ctx.fillText(val,4,y+3); ctx.strokeStyle=gridColor; ctx.beginPath(); ctx.moveTo(padL,y); ctx.lineTo(W-padR,y); ctx.stroke(); }
const series=[{arr:l1,color:'#8b5cf6',fill:true},{arr:l5,color:'#10b981'},{arr:l15,color:'#f59e0b'}];
// 面积先画 load1
series.forEach(s=>{
if(s.arr.length<2) return;
ctx.beginPath(); ctx.lineWidth=1.5; ctx.strokeStyle=s.color;
s.arr.forEach((v,i)=>{ const x=padL+xStep*i; const y=padT+(H-padT-padB)*(1-(v-min)/range); if(i===0) ctx.moveTo(x,y); else ctx.lineTo(x,y); });
ctx.stroke();
if(s.fill){
const lastX = padL + xStep*(s.arr.length-1);
ctx.lineTo(lastX,H-padB); ctx.lineTo(padL,H-padB); ctx.closePath();
const grd = ctx.createLinearGradient(0,padT,0,H-padB); grd.addColorStop(0,'rgba(139,92,246,0.25)'); grd.addColorStop(1,'rgba(139,92,246,0)');
ctx.fillStyle=grd; ctx.fill();
}
});
}
//# sourceMappingURL=app.js.map
// ====== 详情动态刷新 ======
function findServerByKey(key){ return S.servers.find(x=> (x.name||x.location||'node')===key); }
function updateDetailMetrics(key){
const s = findServerByKey(key); if(!s) return; if(!(s.online4||s.online6)) return; // 离线不更新
const procLine = `${num(s.tcp_count)} / ${num(s.udp_count)} / ${num(s.process_count)} / ${num(s.thread_count)}`;
const procEl = document.getElementById('detail-proc'); if(procEl) procEl.textContent = procLine;
const cuEl=document.getElementById('lat-cu'); if(cuEl) cuEl.textContent = num(s.time_10010)+'ms';
const ctEl=document.getElementById('lat-ct'); if(ctEl) ctEl.textContent = num(s.time_189)+'ms';
const cmEl=document.getElementById('lat-cm'); if(cmEl) cmEl.textContent = num(s.time_10086)+'ms';
// 延迟动态刷新 (若存在)
const cuE1=document.getElementById('lat-cu'); if(cuE1) cuE1.textContent = num(s.time_10010)+'ms';
const ctE1=document.getElementById('lat-ct'); if(ctE1) ctE1.textContent = num(s.time_189)+'ms';
const cmE1=document.getElementById('lat-cm'); if(cmE1) cmE1.textContent = num(s.time_10086)+'ms';
}
function startDetailAutoUpdate(){ stopDetailAutoUpdate(); S._detailTimer = setInterval(()=>{ if(S._openDetailKey) updateDetailMetrics(S._openDetailKey); }, 1000); }
function stopDetailAutoUpdate(){ if(S._detailTimer){ clearInterval(S._detailTimer); S._detailTimer=null; } }