feat(panel): reskin backend — node sortIndex, presence/speed, /api/system, credentials, subscription aggregation (Node+Go parity)
This commit is contained in:
+189
@@ -0,0 +1,189 @@
|
||||
'use strict';
|
||||
|
||||
// Host/system sampling for the system-status page (panel-go/sysstat.go
|
||||
// mirrors this file). Samples are taken at most every ~30s (housekeeping
|
||||
// cadence and on-demand from /api/system when stale) and kept in a
|
||||
// memory-only ring buffer (240 entries ≈ 4h at 60s cadence, tens of KB).
|
||||
// Nothing here writes to the data file: the "today" traffic baseline is
|
||||
// also memory-only, so a panel restart restarts the day counter from
|
||||
// the traffic seen since boot (named trade-off, see UI-RESKIN-REPORT).
|
||||
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const { customerOnline } = require('./presence');
|
||||
|
||||
const HISTORY_CAP = 240;
|
||||
const SAMPLE_MIN_INTERVAL_MS = 25000;
|
||||
|
||||
function readCpuTimes() {
|
||||
try {
|
||||
const line = fs.readFileSync('/proc/stat', 'utf8').split('\n')[0];
|
||||
const parts = line.trim().split(/\s+/).slice(1).map(Number);
|
||||
if (parts.length < 5 || parts.some((n) => !Number.isFinite(n))) return null;
|
||||
const idle = parts[3] + (parts[4] || 0);
|
||||
const total = parts.reduce((a, b) => a + b, 0);
|
||||
return { idle, total };
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
function readMem() {
|
||||
try {
|
||||
const text = fs.readFileSync('/proc/meminfo', 'utf8');
|
||||
const grab = (key) => {
|
||||
const match = new RegExp(`^${key}:\\s+(\\d+)`, 'm').exec(text);
|
||||
return match ? Number(match[1]) * 1024 : 0;
|
||||
};
|
||||
const total = grab('MemTotal');
|
||||
const available = grab('MemAvailable');
|
||||
return { total, used: total > 0 ? total - available : 0 };
|
||||
} catch { return { total: 0, used: 0 }; }
|
||||
}
|
||||
|
||||
function isGlobalIPv4(ip) {
|
||||
const parts = String(ip).split('.').map(Number);
|
||||
if (parts.length !== 4 || parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return false;
|
||||
const [a, b] = parts;
|
||||
if (a === 0 || a === 10 || a === 127) return false;
|
||||
if (a === 172 && b >= 16 && b <= 31) return false;
|
||||
if (a === 192 && b === 168) return false;
|
||||
if (a === 100 && b >= 64 && b <= 127) return false; // CGNAT
|
||||
if (a === 169 && b === 254) return false; // link-local
|
||||
if (a >= 224) return false; // multicast / reserved
|
||||
return true;
|
||||
}
|
||||
|
||||
function isGlobalIPv6(ip) {
|
||||
const lower = String(ip).toLowerCase();
|
||||
if (lower === '::1' || lower === '::') return false;
|
||||
if (/^f[cd]/.test(lower)) return false; // ULA fc00::/7
|
||||
if (/^fe[89ab]/.test(lower)) return false; // link-local fe80::/10
|
||||
if (lower.startsWith('ff')) return false; // multicast
|
||||
return lower.includes(':');
|
||||
}
|
||||
|
||||
// Pick the host's global addresses from the interface list (3x-ui
|
||||
// 口径: local interface addresses, no external lookup of any kind).
|
||||
function hostIPs() {
|
||||
let ipv4 = null;
|
||||
let ipv6 = null;
|
||||
const interfaces = os.networkInterfaces();
|
||||
for (const list of Object.values(interfaces)) {
|
||||
for (const info of list || []) {
|
||||
if (info.internal) continue;
|
||||
if (info.family === 'IPv4' && !ipv4 && isGlobalIPv4(info.address)) ipv4 = info.address;
|
||||
if (info.family === 'IPv6' && !ipv6 && isGlobalIPv6(info.address)) ipv6 = info.address;
|
||||
}
|
||||
}
|
||||
return { ipv4, ipv6 };
|
||||
}
|
||||
|
||||
class SystemMonitor {
|
||||
constructor(dataDir) {
|
||||
this.dataDir = dataDir;
|
||||
this.startedAt = Date.now();
|
||||
this.prevCpu = null;
|
||||
this.cpuPercent = null;
|
||||
this.history = [];
|
||||
this.lastSampleAt = 0;
|
||||
this.dayKey = null;
|
||||
this.dayBaseUp = 0;
|
||||
this.dayBaseDown = 0;
|
||||
this.todayUp = 0;
|
||||
this.todayDown = 0;
|
||||
this.current = null;
|
||||
}
|
||||
|
||||
sample(data, force = false) {
|
||||
const now = Date.now();
|
||||
if (!force && now - this.lastSampleAt < SAMPLE_MIN_INTERVAL_MS) return this.current;
|
||||
this.lastSampleAt = now;
|
||||
const cpu = readCpuTimes();
|
||||
if (cpu && this.prevCpu) {
|
||||
const totalDelta = cpu.total - this.prevCpu.total;
|
||||
const idleDelta = cpu.idle - this.prevCpu.idle;
|
||||
if (totalDelta > 0) this.cpuPercent = Math.max(0, Math.min(100, (1 - idleDelta / totalDelta) * 100));
|
||||
}
|
||||
if (cpu) this.prevCpu = cpu;
|
||||
const mem = readMem();
|
||||
let disk = { total: 0, used: 0 };
|
||||
try {
|
||||
const stat = fs.statfsSync(this.dataDir);
|
||||
disk = { total: stat.blocks * stat.bsize, used: (stat.blocks - stat.bfree) * stat.bsize };
|
||||
} catch { /* keep zeros */ }
|
||||
let totalUp = 0;
|
||||
let totalDown = 0;
|
||||
let upBps = 0;
|
||||
let downBps = 0;
|
||||
for (const node of data.nodes || []) {
|
||||
if (node.meteredTraffic) {
|
||||
totalUp += Number(node.meteredTraffic.uplink || 0);
|
||||
totalDown += Number(node.meteredTraffic.downlink || 0);
|
||||
}
|
||||
if (node.speedAt && now - Date.parse(node.speedAt) <= 150000) {
|
||||
upBps += Number(node.speedUpBps || 0);
|
||||
downBps += Number(node.speedDownBps || 0);
|
||||
}
|
||||
}
|
||||
const dayKey = new Date(now).toLocaleDateString('en-CA');
|
||||
if (this.dayKey !== dayKey) {
|
||||
this.dayKey = dayKey;
|
||||
this.dayBaseUp = totalUp;
|
||||
this.dayBaseDown = totalDown;
|
||||
}
|
||||
this.todayUp = Math.max(0, totalUp - this.dayBaseUp);
|
||||
this.todayDown = Math.max(0, totalDown - this.dayBaseDown);
|
||||
const connections = (data.customers || []).filter((c) => customerOnline(c, now)).length;
|
||||
const entry = {
|
||||
at: new Date(now).toISOString(),
|
||||
cpu: this.cpuPercent == null ? null : Math.round(this.cpuPercent * 10) / 10,
|
||||
mem: mem.total > 0 ? Math.round((mem.used / mem.total) * 1000) / 10 : null,
|
||||
upBps, downBps, connections,
|
||||
};
|
||||
this.history.push(entry);
|
||||
if (this.history.length > HISTORY_CAP) this.history.splice(0, this.history.length - HISTORY_CAP);
|
||||
this.current = {
|
||||
at: entry.at, cpuPercent: entry.cpu, mem, disk,
|
||||
upBps, downBps, connections, totalUp, totalDown,
|
||||
};
|
||||
return this.current;
|
||||
}
|
||||
|
||||
status(data, version) {
|
||||
const current = this.sample(data) || {
|
||||
cpuPercent: null, mem: readMem(), disk: { total: 0, used: 0 },
|
||||
upBps: 0, downBps: 0, connections: 0, totalUp: 0, totalDown: 0,
|
||||
};
|
||||
const mem = current.mem;
|
||||
const disk = current.disk;
|
||||
const onlineNodes = (data.nodes || []).filter((n) => n.status === 'active').length;
|
||||
return {
|
||||
version,
|
||||
startedAt: new Date(this.startedAt).toISOString(),
|
||||
panelUptimeSec: Math.floor((Date.now() - this.startedAt) / 1000),
|
||||
hostUptimeSec: Math.floor(os.uptime()),
|
||||
panelMemBytes: process.memoryUsage().rss,
|
||||
host: {
|
||||
cpuPercent: current.cpuPercent,
|
||||
cpuCount: os.cpus().length,
|
||||
load1: os.loadavg()[0],
|
||||
memTotal: mem.total, memUsed: mem.used,
|
||||
memPercent: mem.total > 0 ? Math.round((mem.used / mem.total) * 1000) / 10 : null,
|
||||
diskTotal: disk.total, diskUsed: disk.used,
|
||||
diskPercent: disk.total > 0 ? Math.round((disk.used / disk.total) * 1000) / 10 : null,
|
||||
},
|
||||
network: {
|
||||
upBps: current.upBps, downBps: current.downBps,
|
||||
connections: current.connections,
|
||||
onlineCustomers: current.connections,
|
||||
onlineNodes, nodesTotal: (data.nodes || []).length,
|
||||
customersTotal: (data.customers || []).length,
|
||||
},
|
||||
today: { upBytes: this.todayUp, downBytes: this.todayDown },
|
||||
totals: { upBytes: current.totalUp, downBytes: current.totalDown },
|
||||
ips: hostIPs(),
|
||||
history: this.history,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { SystemMonitor, hostIPs, isGlobalIPv4, isGlobalIPv6, readCpuTimes, readMem };
|
||||
Reference in New Issue
Block a user