const S = {
updated: 0,
servers: [],
ssl: [],
hist: {},
loadHist: {},
openDetailKey: null,
activeTab: 'servers',
osOptionsSignature: '',
suppressStatsReloadUntil: 0,
filters: { query: '', status: 'all', os: 'all', sort: 'name', dir: 'desc' },
admin: {
token: localStorage.getItem('serverstatusAdminToken') || '',
enabled: false,
connected: false,
config: null,
selectedType: 'servers',
selectedIndex: -1,
saving: false
}
};
const $ = (id) => document.getElementById(id);
const esc = (v) => String(v ?? '').replace(/[&<>"']/g, ch => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[ch]));
const clamp = (v, min, max) => Math.max(min, Math.min(max, v));
const num = (v) => typeof v === 'number' && Number.isFinite(v) ? v : 0;
function humanMinMBFromKB(kb){ return humanBytes(num(kb) * 1000, 1000 * 1000); }
function humanMinMBFromMB(mb){ return humanBytes(num(mb) * 1000 * 1000, 1000 * 1000); }
function humanMinMBFromB(bytes){ return humanBytes(num(bytes), 1000 * 1000); }
function humanMinKBFromB(bytes){ return humanBytes(num(bytes), 1000); }
function humanBytes(bytes, minUnit){
if(!Number.isFinite(bytes)) return '-';
const units = ['B','KB','MB','GB','TB','PB'];
let index = minUnit >= 1000 * 1000 ? 2 : minUnit >= 1000 ? 1 : 0;
let divisor = Math.pow(1000, index);
let value = Math.max(0, bytes) / divisor;
while(value >= 1000 && index < units.length - 1){ value /= 1000; index++; }
const out = value >= 100 ? value.toFixed(0) : value.toFixed(1);
return out + units[index];
}
function humanAgo(ts){
if(!ts) return '-';
const sec = Math.max(0, Math.floor(Date.now() / 1000 - Number(ts)));
if(sec < 60) return '几秒前';
const min = Math.floor(sec / 60);
if(min < 60) return `${min} 分钟前`;
return `${Math.floor(min / 60)} 小时前`;
}
function osClass(os){
if(!os) return '';
const v = String(os).toLowerCase();
const pick = (k)=>' os-'+k;
if(v.includes('ubuntu')) return pick('ubuntu');
if(v.includes('debian')) return pick('debian');
if(v.includes('centos')) return pick('centos');
if(v.includes('rocky')) return pick('rocky');
if(v.includes('alma')) return pick('almalinux');
if(v.includes('arch')) return pick('arch');
if(v.includes('alpine')) return pick('alpine');
if(v.includes('fedora')) return pick('fedora');
if(v.includes('rhel') || v.includes('redhat')) return pick('rhel');
if(v.includes('suse')) return pick('suse');
if(v.includes('amazon')) return pick('amazon');
if(v.includes('freebsd')) return pick('freebsd');
if(v.includes('openbsd')) return pick('openbsd');
if(v.includes('netbsd') || v.includes('bsd')) return pick('bsd');
if(v.includes('darwin') || v.includes('macos') || v.includes('os x') || v.includes('osx') || v.includes('apple') || v.includes('mac')) return pick('darwin');
if(v.includes('win')) return pick('windows');
if(v.includes('linux')) return pick('linux');
return pick(v.replace(/[^a-z0-9_-]+/g,'-').slice(0,20));
}
function osLabel(os){
if(!os) return '';
const v = String(os).toLowerCase();
if(v.includes('ubuntu')) return 'Ubuntu';
if(v.includes('debian')) return 'Debian';
if(v.includes('centos')) return 'CentOS';
if(v.includes('rocky')) return 'Rocky Linux';
if(v.includes('alma')) return 'AlmaLinux';
if(v.includes('rhel') || v.includes('redhat')) return 'Red Hat Enterprise Linux';
if(v.includes('arch')) return 'Arch Linux';
if(v.includes('alpine')) return 'Alpine Linux';
if(v.includes('fedora')) return 'Fedora';
if(v.includes('amazon')) return 'Amazon Linux';
if(v.includes('suse')) return 'SUSE Linux';
if(v.includes('freebsd')) return 'FreeBSD';
if(v.includes('openbsd')) return 'OpenBSD';
if(v.includes('netbsd') || v.includes('bsd')) return 'BSD';
if(v.includes('darwin') || v.includes('macos') || v.includes('os x') || v.includes('osx') || v.includes('apple') || v.includes('mac')) return 'macOS';
if(v.includes('win')) return 'Windows';
if(v.includes('linux')) return 'Linux';
return String(os).charAt(0).toUpperCase() + String(os).slice(1);
}
function lossValues(s){
return [s.ping_10010, s.ping_189, s.ping_10086].map(p => clamp(num(p), 0, 100));
}
function isBlocked(s){
return lossValues(s).every(p => p >= 100);
}
function metrics(s){
const online = !!(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;
const monthIn = Math.max(0, num(s.network_in) - num(s.last_network_in));
const monthOut = Math.max(0, num(s.network_out) - num(s.last_network_out));
const traffic = monthIn + monthOut;
const losses = lossValues(s);
const loss = Math.max(...losses);
const lossBadCount = losses.filter(p => p >= 40).length;
const lossWarnCount = losses.filter(p => p >= 30).length;
const blocked = online && losses.every(p => p >= 100);
const resourceCritical = online && (num(s.cpu) >= 90 || memPct >= 90 || hddPct >= 90);
const resourceWarning = online && (num(s.cpu) >= 75 || memPct >= 80 || hddPct >= 85);
const trafficWarning = online && traffic >= 1000 * 1000 * 1000 * 1000;
const lossCritical = online && loss >= 40;
const lossWarning = online && !lossCritical && loss >= 30;
const critical = online && (resourceCritical || blocked || lossCritical);
const warning = online && (resourceWarning || lossWarning);
const rowLevel = !online || critical ? 'critical' : (resourceWarning || lossWarning ? 'warning' : '');
return { online, memPct, hddPct, monthIn, monthOut, traffic, loss, lossBadCount, lossWarnCount, blocked, resourceCritical, resourceWarning, trafficWarning, lossCritical, lossWarning, critical, warning, alert: critical || warning, highlight: !!rowLevel, rowLevel };
}
async function fetchData(){
try{
const res = await fetch('json/stats.json?_=' + Date.now(), { cache: 'no-store' });
if(!res.ok) throw new Error(String(res.status));
const data = await res.json();
if(data.reload && !shouldSuppressStatsReload()) {
location.reload();
return;
}
S.updated = Number(data.updated || 0);
S.servers = (data.servers || []).map((server, index) => {
const base = [server.name || '-', server.location || '-', server.type || '-'].join('|');
const key = `${base}#${index + 1}`;
server._key = key;
if(!S.hist[key]) S.hist[key] = { cu: [], ct: [], cm: [] };
if(!S.loadHist[key]) S.loadHist[key] = { l1: [], l5: [], l15: [] };
pushHistory(S.hist[key].cu, server.time_10010);
pushHistory(S.hist[key].ct, server.time_189);
pushHistory(S.hist[key].cm, server.time_10086);
pushHistory(S.loadHist[key].l1, server.load_1);
pushHistory(S.loadHist[key].l5, server.load_5);
pushHistory(S.loadHist[key].l15, server.load_15);
return server;
});
S.ssl = data.sslcerts || [];
render();
}catch(err){
const notice = $('notice');
notice.style.display = 'flex';
notice.textContent = '数据获取失败: ' + err.message;
}
}
function shouldSuppressStatsReload(){
return S.admin.saving || S.activeTab === 'config' || Date.now() < S.suppressStatsReloadUntil;
}
function pushHistory(arr, value){
if(typeof value === 'number' && Number.isFinite(value) && value >= 0) arr.push(value);
if(arr.length > 120) arr.splice(0, arr.length - 120);
}
function visibleServers(){
const q = S.filters.query.trim().toLowerCase();
let rows = S.servers.filter(server => {
const m = metrics(server);
if(S.filters.status === 'online' && !m.online) return false;
if(S.filters.status === 'offline' && m.online) return false;
if(S.filters.status === 'alert' && !m.alert) return false;
const os = osLabel(server.os);
if(S.filters.os !== 'all' && os !== S.filters.os) return false;
if(!q) return true;
return [server.name, server.type, server.host, server.location, os].some(v => String(v || '').toLowerCase().includes(q));
});
rows.sort((a, b) => {
const dir = S.filters.dir === 'asc' ? 1 : -1;
const ma = metrics(a), mb = metrics(b);
const value = (server, m) => ({
name: String(server.name || ''),
type: String(server.type || ''),
location: String(server.location || ''),
status: m.online ? 1 : 0,
load: num(server.load_1),
cpu: num(server.cpu),
memory: m.memPct,
hdd: m.hddPct,
traffic: m.traffic,
loss: m.loss
})[S.filters.sort];
const va = value(a, ma), vb = value(b, mb);
if(typeof va === 'string') return va.localeCompare(vb, 'zh-CN') * dir;
return (va - vb) * dir;
});
return rows;
}
function render(){
$('notice').style.display = 'none';
normalizeServersToolbarState();
renderOverview();
renderOsOptions();
renderServers();
renderServersCards();
renderMonitors();
renderMonitorsCards();
renderSSL();
renderSSLCards();
updateTime();
if(S.openDetailKey) refreshDetail();
}
function renderOverview(){
const total = S.servers.length;
const online = S.servers.filter(s => metrics(s).online).length;
const alerts = alertStats();
const sslWarn = S.ssl.filter(c => c.mismatch || c.expire_days <= 7).length;
const monthDown = S.servers.reduce((sum, s) => sum + metrics(s).monthIn, 0);
const monthUp = S.servers.reduce((sum, s) => sum + metrics(s).monthOut, 0);
$('overviewCards').innerHTML = [
card('在线主机', `${online}/${total}`, '当前在线节点', online === total ? 'ok' : 'warn'),
card('证书风险', sslWarn, sslWarn ? '过期或域名不匹配' : '证书正常', sslWarn ? 'warn' : 'ok'),
card('本月下行', humanMinMBFromB(monthDown), '下载累计', 'traffic-down'),
card('本月上行', humanMinMBFromB(monthUp), '上传累计', 'traffic-up'),
card('活跃告警', alerts.total, `离线 ${alerts.offline} / 异常 ${alerts.abnormal} / 被墙 ${alerts.blocked}`, alerts.total ? (alerts.offline || alerts.blocked ? 'err' : 'warn') : 'ok')
].join('');
}
function card(label, value, hint, cls){ return `
${esc(label)}${esc(value)}${esc(hint)}
`; }
function alertStats(){
const stats = { offline: 0, abnormal: 0, blocked: 0, total: 0 };
S.servers.forEach(s => {
const m = metrics(s);
if(!m.online) stats.offline++;
else if(m.blocked) stats.blocked++;
else if(m.alert) stats.abnormal++;
});
stats.total = stats.offline + stats.abnormal + stats.blocked;
return stats;
}
function normalizeServersToolbarState(){
const show = S.activeTab === 'servers' && S.servers.length > 10;
$('serversToolbar').style.display = show ? 'flex' : 'none';
}
function renderOsOptions(){
const select = $('osFilter');
const labels = [...new Set(S.servers.map(s => osLabel(s.os)).filter(Boolean))].sort((a,b)=>a.localeCompare(b,'zh-CN'));
const signature = labels.join('\u001f');
const current = labels.includes(S.filters.os) ? S.filters.os : 'all';
if(document.activeElement === select) return;
if(S.osOptionsSignature !== signature){
select.innerHTML = '' + labels.map(label => ``).join('');
S.osOptionsSignature = signature;
}
if(select.value !== current) select.value = current;
S.filters.os = select.value;
}
function protoPill(s){
const m = metrics(s);
const proto = m.online ? (s.online4 && s.online6 ? '双栈' : (s.online4 ? 'IPv4' : 'IPv6')) : '离线';
return `${proto}`;
}
function virtPill(type){
const text = String(type || '-');
const key = text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'unknown';
return `${esc(text)}`;
}
function trafficCaps(s, small){
const m = metrics(s);
const heavy = m.traffic >= 1000 * 1000 * 1000 * 1000;
return `${humanMinMBFromB(m.monthIn)}${humanMinMBFromB(m.monthOut)}`;
}
function gaugeHTML(type, value){
const pct = clamp(num(value), 0, 100);
const thresholds = {
cpu: { warn: 75, bad: 90 },
mem: { warn: 80, bad: 90 },
hdd: { warn: 85, bad: 90 }
}[type] || { warn: 75, bad: 90 };
const warnAttr = pct >= thresholds.bad ? 'data-bad' : (pct >= thresholds.warn ? 'data-warn' : '');
const label = type === 'cpu' ? 'CPU' : type === 'mem' ? '内存' : '硬盘';
return ``;
}
function resourceMeter(label, value, pct, kind){
const safePct = clamp(num(pct), 0, 100);
const thresholds = {
cpu: { warn: 75, bad: 90 },
mem: { warn: 80, bad: 90 },
swap: { warn: 80, bad: 90 },
hdd: { warn: 85, bad: 90 }
}[kind] || { warn: 75, bad: 90 };
const level = safePct >= thresholds.bad ? 'bad' : (safePct >= thresholds.warn ? 'warn' : 'ok');
return `
${esc(label)}${esc(value)}
`;
}
function detailResourceHTML(s, m){
const swapPct = s.swap_total ? num(s.swap_used) / num(s.swap_total) * 100 : 0;
return `
${resourceMeter('CPU', `${num(s.cpu).toFixed(0)}%`, num(s.cpu), 'cpu')}
${resourceMeter('内存', `${humanMinMBFromKB(s.memory_used)} / ${humanMinMBFromKB(s.memory_total)}`, m.memPct, 'mem')}
${resourceMeter('虚存', `${humanMinMBFromKB(s.swap_used)} / ${humanMinMBFromKB(s.swap_total)}`, swapPct, 'swap')}
${resourceMeter('硬盘', `${humanMinMBFromMB(s.hdd_used)} / ${humanMinMBFromMB(s.hdd_total)}`, m.hddPct, 'hdd')}
IO读 ${humanMinMBFromB(s.io_read)} / 写 ${humanMinMBFromB(s.io_write)}
`;
}
function packetLossLine(s){
return [s.ping_10010, s.ping_189, s.ping_10086].map(p => `${clamp(num(p), 0, 100).toFixed(0)}%`).join('|');
}
function chartLegend(series){
return `${series.map(item => `${esc(item.label)}`).join('')}
`;
}
function buckets(s){
return `${[s.ping_10010, s.ping_189, s.ping_10086].map(p => {
const v = clamp(num(p), 0, 100);
const level = v >= 40 ? 'bad' : (v >= 30 ? 'warn' : 'ok');
return `
`;
}).join('')}
`;
}
function renderServers(){
const rows = visibleServers();
document.querySelectorAll('#serversTable th[data-sort]').forEach(th => {
th.classList.toggle('sorted-asc', th.dataset.sort === S.filters.sort && S.filters.dir === 'asc');
th.classList.toggle('sorted-desc', th.dataset.sort === S.filters.sort && S.filters.dir === 'desc');
});
$('serversBody').innerHTML = rows.map(s => {
const m = metrics(s);
const netNow = `${humanMinKBFromB(s.network_rx)} | ${humanMinKBFromB(s.network_tx)}`;
const netTotal = `${humanMinMBFromB(s.network_in)} | ${humanMinMBFromB(s.network_out)}`;
const alertClass = m.rowLevel ? ` alert-${m.rowLevel}` : '';
return `
| ${protoPill(s)} |
${trafficCaps(s)} |
${esc(s.name || '-')} |
${virtPill(s.type)} |
${esc(s.location || '-')} |
${esc(s.uptime || '-')} |
${s.load_1 === -1 ? '–' : num(s.load_1).toFixed(2)} |
${netNow} |
${netTotal} |
${m.online ? gaugeHTML('cpu', s.cpu) : '-'} |
${m.online ? gaugeHTML('mem', m.memPct) : '-'} |
${m.online ? gaugeHTML('hdd', m.hddPct) : '-'} |
${buckets(s)} |
`;
}).join('') || `| 无数据 |
`;
document.querySelectorAll('#serversBody .row-server').forEach(row => row.addEventListener('click', () => {
if(row.dataset.online !== '1') return;
openDetail(row.dataset.key);
}));
}
function renderServersCards(){
const wrap = $('serversCards');
if(window.innerWidth > 700){ wrap.innerHTML = ''; return; }
wrap.innerHTML = visibleServers().map(s => {
const m = metrics(s);
const alertClass = m.rowLevel ? ` alert-${m.rowLevel}` : '';
return `
负载${s.load_1 === -1 ? '–' : num(s.load_1).toFixed(2)}
在线${esc(s.uptime || '-')}
月流量${trafficCaps(s, true)}
网络${humanMinKBFromB(s.network_rx)} | ${humanMinKBFromB(s.network_tx)}
CPU${num(s.cpu).toFixed(0)}%
内存${m.memPct.toFixed(0)}%
${buckets(s)}
`;
}).join('') || '无数据
';
wrap.querySelectorAll('.card').forEach(card => card.addEventListener('click', () => {
if(card.dataset.online !== '1') return;
openDetail(card.dataset.key);
}));
}
function parseCustom(str){
if(typeof str !== 'string' || !str.trim()) return [];
return str.split(';').map(seg => {
const [name, val] = seg.split('=');
const ms = parseInt((val || '').trim(), 10);
return name && Number.isFinite(ms) ? { name: name.trim(), ms: Math.max(0, ms) } : null;
}).filter(Boolean);
}
function signalBars(ms){
const levels = [20, 50, 100, 160];
let on = typeof ms === 'number' ? (ms <= levels[0] ? 5 : ms <= levels[1] ? 4 : ms <= levels[2] ? 3 : ms <= levels[3] ? 2 : 1) : 0;
return `${[0,1,2,3,4].map(i => ``).join('')}`;
}
function monitorItems(s){
const items = parseCustom(s.custom);
return items.map(item => `${esc(item.name)}${signalBars(item.ms)}${item.ms}ms`).join('') || '-';
}
function renderMonitors(){
$('monitorsBody').innerHTML = S.servers.map(s => `| ${protoPill(s)} | ${esc(s.name || '-')} | ${esc(s.location || '-')} | ${monitorItems(s)} |
`).join('') || `| 无数据 |
`;
}
function renderMonitorsCards(){
const wrap = $('monitorsCards');
if(window.innerWidth > 700){ wrap.innerHTML = ''; return; }
wrap.innerHTML = S.servers.map(s => ``).join('') || '无数据
';
}
function renderSSL(){
$('sslBody').innerHTML = S.ssl.map(c => {
const cls = c.mismatch || c.expire_days <= 0 ? 'err' : c.expire_days <= 7 ? 'warn' : 'ok';
const status = c.mismatch ? '域名不匹配' : 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/,'') : '-';
const alertClass = cls === 'err' ? 'alert-critical' : cls === 'warn' ? 'alert-warning' : '';
return `| ${esc(c.name || '-')} | ${esc(String(c.domain || '').replace(/^https?:\/\//,''))} | ${esc(c.port || 443)} | ${esc(c.expire_days ?? '-')} | ${dt} | ${status} |
`;
}).join('') || `| 无证书数据 |
`;
}
function renderSSLCards(){
const wrap = $('sslCards');
if(window.innerWidth > 700){ wrap.innerHTML = ''; return; }
wrap.innerHTML = S.ssl.map(c => {
const cls = c.mismatch || c.expire_days <= 0 ? 'err' : c.expire_days <= 7 ? 'warn' : 'ok';
const status = c.mismatch ? '域名不匹配' : c.expire_days <= 0 ? '已过期' : c.expire_days <= 7 ? '将到期' : '正常';
const dt = c.expire_ts ? new Date(c.expire_ts * 1000).toISOString().slice(0,10) : '-';
const alertClass = cls === 'err' ? ' alert-critical' : cls === 'warn' ? ' alert-warning' : '';
return `域名${esc(String(c.domain || '').replace(/^https?:\/\//,''))}
端口${esc(c.port || 443)}
剩余${esc(c.expire_days ?? '-')} 天
到期${dt}
`;
}).join('') || '无证书数据
';
}
function openDetail(key){
S.openDetailKey = key;
$('detailModal').style.display = 'flex';
refreshDetail();
document.addEventListener('keydown', escCloseOnce);
}
function findServer(key){ return S.servers.find(s => s._key === key); }
function refreshDetail(){
const s = findServer(S.openDetailKey);
if(!s){ closeDetail(); return; }
const m = metrics(s);
const title = $('detailTitle');
title.innerHTML = `${esc(s.name || '-')} 详情${s.os ? `${esc(osLabel(s.os))}` : ''}`;
const modalBox = document.querySelector('#detailModal .modal-box');
if(modalBox){
modalBox.classList.remove('high-load');
modalBox.classList.toggle('alert-critical', m.rowLevel === 'critical');
modalBox.classList.toggle('alert-warning', m.rowLevel === 'warning');
}
const loadSeries = [
{ data: S.loadHist[s._key]?.l1 || [], color:'#8b5cf6', label:'load1' },
{ data: S.loadHist[s._key]?.l5 || [], color:'#10b981', label:'load5' },
{ data: S.loadHist[s._key]?.l15 || [], color:'#f59e0b', label:'load15' }
];
const latencySeries = [
{ data: S.hist[s._key]?.cu || [], color:'#3b82f6', label:'联通' },
{ data: S.hist[s._key]?.ct || [], color:'#10b981', label:'电信' },
{ data: S.hist[s._key]?.cm || [], color:'#f59e0b', label:'移动' }
];
$('detailContent').innerHTML = `
身份
节点 / 位置${esc(s.name || '-')} / ${esc(s.location || '-')}
虚拟化 / 主机${esc(s.type || '-')} / ${esc(s.host || '-')}
协议 / 在线${protoPill(s)}${esc(s.uptime || '-')}
资源
${detailResourceHTML(s, m)}
网络
当前 ↓|↑${humanMinKBFromB(s.network_rx)} | ${humanMinKBFromB(s.network_tx)}
总流量 ↓|↑${humanMinMBFromB(s.network_in)} | ${humanMinMBFromB(s.network_out)}
本月 ↓|↑${trafficCaps(s, true)}
连接
TCP / UDP${num(s.tcp_count)} / ${num(s.udp_count)}
进程 / 线程${num(s.process_count)} / ${num(s.thread_count)}
联通|电信|移动${packetLossLine(s)}
负载趋势
${chartLegend(loadSeries)}
三网延迟
${chartLegend(latencySeries)}`;
drawLineChart('loadChart', loadSeries, '暂无负载数据');
drawLineChart('latChart', latencySeries, '暂无延迟数据', 'ms');
}
function closeDetail(){
$('detailModal').style.display = 'none';
S.openDetailKey = null;
document.removeEventListener('keydown', escCloseOnce);
}
function escCloseOnce(e){ if(e.key === 'Escape') closeDetail(); }
function drawLineChart(id, series, emptyText, unit = ''){
const canvas = $(id);
if(!canvas) return;
const ctx = canvas.getContext('2d');
const W = canvas.clientWidth || canvas.width;
const H = canvas.height;
canvas.width = W;
ctx.clearRect(0,0,W,H);
const all = series.flatMap(s => s.data).filter(v => Number.isFinite(v));
if(all.length < 2){
ctx.fillStyle = getComputedStyle(document.body).getPropertyValue('--text-dim') || '#7a899d';
ctx.font = '12px system-ui';
ctx.fillText(emptyText, Math.max(12, W / 2 - 42), H / 2);
return;
}
const padL = unit ? 50 : 42, padR = 10, padT = 12, padB = 20;
let min = Math.min(0, ...all), max = Math.max(...all);
if(max - min < 1) max = min + 1;
const range = max - min;
const n = Math.max(...series.map(s => s.data.length));
const xStep = (W - padL - padR) / Math.max(1, n - 1);
const light = document.body.classList.contains('light');
const axis = light ? 'rgba(0,0,0,.22)' : 'rgba(255,255,255,.18)';
const grid = light ? 'rgba(0,0,0,.08)' : 'rgba(255,255,255,.10)';
const text = light ? 'rgba(30,41,59,.7)' : 'rgba(226,232,240,.82)';
ctx.strokeStyle = axis; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(padL,padT); ctx.lineTo(padL,H-padB); ctx.lineTo(W-padR,H-padB); ctx.stroke();
ctx.fillStyle = text; ctx.font = '10px system-ui';
for(let i=0;i<=4;i++){
const y = padT + (H-padT-padB) * i / 4;
const val = max - range * i / 4;
ctx.fillText(val.toFixed(max < 10 ? 1 : 0) + unit, 4, y + 3);
ctx.strokeStyle = grid; ctx.beginPath(); ctx.moveTo(padL,y); ctx.lineTo(W-padR,y); ctx.stroke();
}
series.forEach(item => {
if(item.data.length < 2) return;
ctx.strokeStyle = item.color; ctx.lineWidth = 1.7; ctx.beginPath();
item.data.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();
});
}
function updateTime(){
const last = $('lastUpdate');
if(last) last.textContent = '最后更新: ' + humanAgo(S.updated);
}
function bindTabs(){
$('navTabs').addEventListener('click', e => {
if(e.target.tagName !== 'BUTTON') return;
const tab = e.target.dataset.tab;
S.activeTab = tab;
document.querySelectorAll('.nav button').forEach(btn => btn.classList.toggle('active', btn === e.target));
document.querySelectorAll('.panel').forEach(panel => panel.classList.toggle('active', panel.id === 'panel-' + tab));
normalizeServersToolbarState();
if(tab === 'config') ensureAdminChecked();
});
}
function bindTheme(){
const btn = $('themeToggle');
const mql = window.matchMedia('(prefers-color-scheme: light)');
const apply = (light) => { document.body.classList.toggle('light', light); document.documentElement.classList.toggle('light', light); };
const saved = localStorage.getItem('theme');
apply(saved ? saved === 'light' : mql.matches);
mql.addEventListener('change', e => { if(!localStorage.getItem('theme')) apply(e.matches); });
btn.addEventListener('click', () => {
const toLight = !document.body.classList.contains('light');
apply(toLight);
localStorage.setItem('theme', toLight ? 'light' : 'dark');
if(S.openDetailKey) refreshDetail();
});
}
function bindFilters(){
$('serverSearch').addEventListener('input', e => { S.filters.query = e.target.value; renderServers(); renderServersCards(); });
$('statusFilter').addEventListener('click', e => {
if(e.target.tagName !== 'BUTTON') return;
S.filters.status = e.target.dataset.filter;
document.querySelectorAll('#statusFilter button').forEach(btn => btn.classList.toggle('active', btn === e.target));
renderServers(); renderServersCards();
});
$('osFilter').addEventListener('change', e => { S.filters.os = e.target.value; renderServers(); renderServersCards(); });
$('osFilter').addEventListener('blur', renderOsOptions);
$('sortSelect').addEventListener('change', e => { S.filters.sort = e.target.value; renderServers(); renderServersCards(); });
$('sortDirection').addEventListener('click', () => {
S.filters.dir = S.filters.dir === 'desc' ? 'asc' : 'desc';
$('sortDirection').textContent = S.filters.dir === 'desc' ? '降序' : '升序';
renderServers(); renderServersCards();
});
document.querySelectorAll('#serversTable th[data-sort]').forEach(th => th.addEventListener('click', () => {
if(S.filters.sort === th.dataset.sort) S.filters.dir = S.filters.dir === 'desc' ? 'asc' : 'desc';
else S.filters.sort = th.dataset.sort;
$('sortSelect').value = S.filters.sort;
$('sortDirection').textContent = S.filters.dir === 'desc' ? '降序' : '升序';
renderServers(); renderServersCards();
}));
}
function adminHeaders(){
return { 'Content-Type': 'application/json', 'Authorization': `Bearer ${S.admin.token}` };
}
async function api(path, options = {}){
const res = await fetch(path, { ...options, headers: { ...(options.headers || {}), ...(options.auth === false ? {} : adminHeaders()) } });
const data = await res.json().catch(() => ({}));
if(!res.ok || data.ok === false) throw new Error(data.error || res.statusText);
return data;
}
async function ensureAdminChecked(){
if(S.admin._checking) return;
S.admin._checking = true;
try{
const health = await api('/api/health', { auth:false });
S.admin.enabled = !!health.enabled;
setAdminStatus(health.enabled ? '管理 API 已启用,输入 token 后可编辑配置。' : '管理 API 未启用:请在容器环境变量设置 ADMIN_TOKEN。', health.enabled ? '' : 'err');
if(S.admin.enabled && S.admin.token) await loadConfig();
}catch(err){
setAdminStatus('无法连接管理 API:' + err.message, 'err');
}finally{
S.admin._checking = false;
}
}
function setAdminStatus(text, cls){
const el = $('adminStatus');
el.className = 'admin-status' + (cls ? ' ' + cls : '');
el.textContent = text;
}
async function loadConfig(){
const data = await api('/api/config');
S.admin.config = normalizeAdminConfig(data.config);
S.admin.connected = true;
setAdminStatus('已连接管理 API,配置可编辑。', 'ok');
renderConfigList();
renderConfigEditor();
}
const CONFIG_TYPES = {
servers: {
label: '节点',
addLabel: '新增节点',
empty: '暂无节点配置',
hint: '客户端登录使用 username/password,保存后自动重载 sergate。',
fields: [
{ name:'username', label:'用户名', required:true, max:120 },
{ name:'name', label:'节点名', required:true, max:120 },
{ name:'type', label:'虚拟化', required:true, max:120, placeholder:'kvm / xen / vmware' },
{ name:'host', label:'主机名', required:true, max:120 },
{ name:'location', label:'位置', required:true, max:120, placeholder:'🇨🇳 / 上海 / hk-01' },
{ name:'password', label:'密码', required:true, max:120, keepRaw:true },
{ name:'monthstart', label:'月初日', type:'number', min:1, max:28, default:1 },
{ name:'disabled', label:'禁用节点', type:'checkbox' }
],
title: item => item?.name || item?.username || '未命名节点',
meta: item => `${item?.location || '-'} / ${item?.type || '-'} / ${item?.host || '-'}`,
badge: item => ({ text: item?.disabled ? '禁用' : '启用', cls: item?.disabled ? 'warn' : 'ok' })
},
monitors: {
label: '监测',
addLabel: '新增监测',
empty: '暂无服务监测',
hint: '服务监测会写入 config.json 的 monitors 数组。',
fields: [
{ name:'name', label:'名称', required:true, max:120 },
{ name:'host', label:'地址', required:true, max:300, placeholder:'https://example.com' },
{ name:'type', label:'类型', required:true, max:40, placeholder:'http / https / tcp' },
{ name:'interval', label:'间隔秒', type:'number', min:1, default:600 }
],
title: item => item?.name || item?.host || '未命名监测',
meta: item => `${item?.type || '-'} / ${item?.host || '-'} / ${item?.interval || '-'}s`,
badge: item => ({ text: item?.type || '监测', cls: 'ok' })
},
sslcerts: {
label: '证书',
addLabel: '新增证书',
empty: '暂无证书配置',
hint: '证书配置会写入 config.json 的 sslcerts 数组。',
fields: [
{ name:'name', label:'名称', required:true, max:120 },
{ name:'domain', label:'域名', required:true, max:300, placeholder:'https://example.com' },
{ name:'port', label:'端口', type:'number', min:1, max:65535, default:443 },
{ name:'interval', label:'间隔秒', type:'number', min:1, default:7200 },
{ name:'callback', label:'回调地址', max:500, placeholder:'https://yourSMSurl' }
],
title: item => item?.name || item?.domain || '未命名证书',
meta: item => `${item?.domain || '-'} / ${item?.port || 443} / ${item?.interval || '-'}s`,
badge: () => ({ text: 'SSL', cls: 'ok' })
},
watchdog: {
label: '告警',
addLabel: '新增告警',
empty: '暂无告警规则',
hint: '告警规则会写入 config.json 的 watchdog 数组。',
fields: [
{ name:'name', label:'名称', required:true, max:160 },
{ name:'rule', label:'规则表达式', type:'textarea', required:true, placeholder:"online4=0&online6=0" },
{ name:'interval', label:'间隔秒', type:'number', min:1, default:600 },
{ name:'callback', label:'回调地址', max:500, placeholder:'https://yourSMSurl' }
],
title: item => item?.name || '未命名告警',
meta: item => item?.rule || '-',
badge: item => ({ text: `${item?.interval || '-'}s`, cls: 'warn' })
}
};
function normalizeAdminConfig(config){
const normalized = config && typeof config === 'object' ? config : {};
['servers','monitors','sslcerts','watchdog'].forEach(key => {
if(!Array.isArray(normalized[key])) normalized[key] = [];
});
return normalized;
}
function activeConfigDef(){
return CONFIG_TYPES[S.admin.selectedType] || CONFIG_TYPES.servers;
}
function configItems(){
if(!S.admin.config) S.admin.config = normalizeAdminConfig({});
const key = S.admin.selectedType;
if(!Array.isArray(S.admin.config[key])) S.admin.config[key] = [];
return S.admin.config[key];
}
function renderConfigList(){
const list = $('configItemList');
const def = activeConfigDef();
const items = configItems();
if(S.admin.selectedIndex >= items.length) S.admin.selectedIndex = -1;
$('addConfigItemBtn').textContent = def.addLabel;
document.querySelectorAll('#configTypeTabs button').forEach(btn => btn.classList.toggle('active', btn.dataset.type === S.admin.selectedType));
list.innerHTML = items.map((item, index) => {
const badge = def.badge(item, index);
return `${esc(def.title(item, index))}
${esc(def.meta(item, index))}
${esc(badge.text)} `;
}).join('') || `${def.empty}
`;
list.querySelectorAll('.config-row').forEach(row => row.addEventListener('click', () => selectConfigItem(Number(row.dataset.index))));
}
function selectConfigItem(index){
const item = configItems()[index];
if(!item) return;
S.admin.selectedIndex = index;
renderConfigList();
renderConfigEditor(item);
}
function renderConfigEditor(item){
const def = activeConfigDef();
const editing = S.admin.selectedIndex >= 0 && item;
const current = item || {};
$('configEditorTitle').textContent = `${editing ? '编辑' : '新增'}${def.label}`;
$('configEditorHint').textContent = def.hint;
$('configFields').innerHTML = def.fields.map(field => fieldHTML(field, current)).join('');
const resetTrafficBtn = $('resetTrafficBtn');
const canResetTraffic = editing && S.admin.selectedType === 'servers';
resetTrafficBtn.style.display = canResetTraffic ? '' : 'none';
resetTrafficBtn.disabled = !canResetTraffic || S.admin.saving;
$('deleteConfigItemBtn').disabled = !editing;
}
function fieldHTML(field, item){
const value = item[field.name] ?? field.default ?? '';
if(field.type === 'checkbox'){
return ``;
}
const required = field.required ? ' required' : '';
const min = field.min != null ? ` min="${field.min}"` : '';
const max = field.max != null ? (field.type === 'number' ? ` max="${field.max}"` : ` maxlength="${field.max}"`) : '';
const placeholder = field.placeholder ? ` placeholder="${esc(field.placeholder)}"` : '';
if(field.type === 'textarea'){
return ``;
}
return ``;
}
function clearConfigForm(){
S.admin.selectedIndex = -1;
renderConfigList();
renderConfigEditor();
}
function formConfigItem(){
const elements = $('configForm').elements;
const item = {};
activeConfigDef().fields.forEach(field => {
const el = elements[field.name];
if(!el) return;
if(field.type === 'checkbox'){
item[field.name] = el.checked;
return;
}
if(field.type === 'number'){
let value = el.value === '' ? (field.default ?? 0) : Number(el.value);
if(!Number.isFinite(value)) value = field.default ?? 0;
if(field.min != null) value = Math.max(field.min, value);
if(field.max != null) value = Math.min(field.max, value);
item[field.name] = value;
return;
}
item[field.name] = field.keepRaw ? el.value : el.value.trim();
});
return item;
}
function configItemPath(key, index){
if(index < 0) return `/api/${key}`;
const current = configItems()[index] || {};
const id = key === 'servers' ? current.username : String(index);
return `/api/${key}/${encodeURIComponent(id || String(index))}`;
}
async function saveConfigItem(key, index, item){
S.admin.saving = true;
S.suppressStatsReloadUntil = Date.now() + 8000;
try{
const data = await api(configItemPath(key, index), { method: index >= 0 ? 'PUT' : 'POST', body: JSON.stringify(item) });
if(data.config) S.admin.config = normalizeAdminConfig(data.config);
S.suppressStatsReloadUntil = Date.now() + 8000;
return data;
}finally{
S.admin.saving = false;
}
}
async function deleteConfigItem(key, index){
S.admin.saving = true;
S.suppressStatsReloadUntil = Date.now() + 8000;
try{
const data = await api(configItemPath(key, index), { method:'DELETE' });
if(data.config) S.admin.config = normalizeAdminConfig(data.config);
S.suppressStatsReloadUntil = Date.now() + 8000;
return data;
}finally{
S.admin.saving = false;
}
}
async function resetServerTraffic(index){
const server = configItems()[index];
if(!server) return setAdminStatus('请先选择要重置月流量的节点。', 'err');
const title = server.name || server.username || '未命名节点';
if(!confirm(`将节点 ${title} 的月流量重置为 0?该操作会重启采集服务以写入当前流量基准。`)) return;
S.admin.saving = true;
S.suppressStatsReloadUntil = Date.now() + 10000;
renderConfigEditor(server);
try{
const data = await api(`/api/servers/${encodeURIComponent(server.username)}/reset-traffic`, { method:'POST' });
const beforeIn = humanMinMBFromB(data.stats?.month_in_before || 0);
const beforeOut = humanMinMBFromB(data.stats?.month_out_before || 0);
setAdminStatus(`${title} 月流量已重置为 0(重置前 ↓${beforeIn} / ↑${beforeOut})。`, 'ok');
setTimeout(fetchData, 1500);
}catch(err){
setAdminStatus('重置月流量失败:' + err.message, 'err');
}finally{
S.admin.saving = false;
S.suppressStatsReloadUntil = Date.now() + 8000;
renderConfigEditor(configItems()[S.admin.selectedIndex]);
}
}
function bindAdmin(){
$('adminToken').value = S.admin.token;
$('adminTokenForm').addEventListener('submit', async e => {
e.preventDefault();
S.admin.token = $('adminToken').value.trim();
localStorage.setItem('serverstatusAdminToken', S.admin.token);
try{ await loadConfig(); }catch(err){ setAdminStatus('认证失败:' + err.message, 'err'); }
});
$('refreshConfigBtn').addEventListener('click', async () => { try{ await loadConfig(); }catch(err){ setAdminStatus('刷新失败:' + err.message, 'err'); } });
$('configTypeTabs').addEventListener('click', e => {
if(e.target.tagName !== 'BUTTON') return;
S.admin.selectedType = e.target.dataset.type;
S.admin.selectedIndex = -1;
renderConfigList();
renderConfigEditor();
});
$('addConfigItemBtn').addEventListener('click', clearConfigForm);
$('resetConfigFormBtn').addEventListener('click', clearConfigForm);
$('resetTrafficBtn').addEventListener('click', () => resetServerTraffic(S.admin.selectedIndex));
$('adminReload').addEventListener('click', async () => {
try{ await api('/api/reload', { method:'POST' }); setAdminStatus('配置重载已触发。', 'ok'); }
catch(err){ setAdminStatus('重载失败:' + err.message, 'err'); }
});
$('adminRestart').addEventListener('click', async () => {
if(!confirm('重启 sergate 采集服务?客户端会短暂断开后自动重连。')) return;
try{ await api('/api/restart', { method:'POST' }); setAdminStatus('服务重启已触发,等待容器入口脚本拉起 sergate。', 'ok'); }
catch(err){ setAdminStatus('重启失败:' + err.message, 'err'); }
});
$('configForm').addEventListener('submit', async e => {
e.preventDefault();
const def = activeConfigDef();
const key = S.admin.selectedType;
const item = formConfigItem();
const index = S.admin.selectedIndex;
try{
await saveConfigItem(key, index, item);
S.admin.selectedIndex = index >= 0 ? index : configItems().length - 1;
renderConfigList();
renderConfigEditor(configItems()[S.admin.selectedIndex]);
setAdminStatus(`${def.label}配置已保存并重载。`, 'ok');
}catch(err){ setAdminStatus('保存失败:' + err.message, 'err'); }
});
$('deleteConfigItemBtn').addEventListener('click', async () => {
const def = activeConfigDef();
const key = S.admin.selectedType;
const index = S.admin.selectedIndex;
if(index < 0) return setAdminStatus(`请先选择要删除的${def.label}。`, 'err');
const item = configItems()[index];
if(!confirm(`删除${def.label} ${def.title(item, index)}?`)) return;
try{
await deleteConfigItem(key, index);
S.admin.selectedIndex = -1;
renderConfigList();
renderConfigEditor();
setAdminStatus(`${def.label}配置已删除并重载。`, 'ok');
}catch(err){ setAdminStatus('删除失败:' + err.message, 'err'); }
});
}
$('detailClose').addEventListener('click', closeDetail);
$('detailModal').addEventListener('click', e => { if(e.target.id === 'detailModal') closeDetail(); });
window.addEventListener('resize', () => { renderServersCards(); renderMonitorsCards(); renderSSLCards(); if(S.openDetailKey) refreshDetail(); });
bindTabs();
bindTheme();
bindFilters();
bindAdmin();
fetchData();
setInterval(fetchData, 1000);
setInterval(updateTime, 60000);