feat(panel): reskin backend — node sortIndex, presence/speed, /api/system, credentials, subscription aggregation (Node+Go parity)

This commit is contained in:
aaaamoooo
2026-10-20 18:09:07 +00:00
parent 7c21742810
commit 011eecc096
23 changed files with 3370 additions and 146 deletions
+189
View File
@@ -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 };