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

This commit is contained in:
aaaamoooo
2026-10-21 03:16:30 +08:00
parent c6f6b9dbea
commit d287bb9be9
23 changed files with 3370 additions and 146 deletions
+382
View File
@@ -0,0 +1,382 @@
package main
// Host/system sampling for the system-status page — mirrors
// lib/sysstat.js. Memory-only ring history (240 samples), ~30s minimum
// sampling interval, "today" traffic baseline kept in memory (a panel
// restart restarts the day counter from traffic seen since boot).
import (
"net"
"os"
"runtime"
"strconv"
"strings"
"sync"
"syscall"
"time"
)
const historyCap = 240
const sampleMinIntervalMs = 25000
type sysSample struct {
At string `json:"at"`
CPU *float64 `json:"cpu"`
Mem *float64 `json:"mem"`
UpBps float64 `json:"upBps"`
DownBps float64 `json:"downBps"`
Connections int `json:"connections"`
}
type sysCurrent struct {
CPUPct *float64
MemTotal int64
MemUsed int64
DiskTotal int64
DiskUsed int64
UpBps float64
DownBps float64
Conns int
TotalUp int64
TotalDown int64
}
type SystemMonitor struct {
mu sync.Mutex
dataDir string
startedAt time.Time
prevIdle int64
prevTotal int64
hasPrev bool
cpuPct *float64
history []sysSample
lastSample time.Time
dayKey string
dayBaseUp int64
dayBaseDown int64
todayUp int64
todayDown int64
current *sysCurrent
}
func NewSystemMonitor(dataDir string) *SystemMonitor {
return &SystemMonitor{dataDir: dataDir, startedAt: time.Now()}
}
func readCPUTimes() (idle, total int64, ok bool) {
raw, err := os.ReadFile("/proc/stat")
if err != nil {
return 0, 0, false
}
line := strings.SplitN(string(raw), "\n", 2)[0]
fields := strings.Fields(line)
if len(fields) < 6 {
return 0, 0, false
}
var values []int64
for _, f := range fields[1:] {
n, err := strconv.ParseInt(f, 10, 64)
if err != nil {
return 0, 0, false
}
values = append(values, n)
}
idle = values[3]
if len(values) > 4 {
idle += values[4]
}
for _, v := range values {
total += v
}
return idle, total, true
}
func readMemInfo() (total, used int64) {
raw, err := os.ReadFile("/proc/meminfo")
if err != nil {
return 0, 0
}
grab := func(key string) int64 {
for _, line := range strings.Split(string(raw), "\n") {
if strings.HasPrefix(line, key+":") {
fields := strings.Fields(line)
if len(fields) >= 2 {
if n, err := strconv.ParseInt(fields[1], 10, 64); err == nil {
return n * 1024
}
}
}
}
return 0
}
total = grab("MemTotal")
available := grab("MemAvailable")
if total > 0 {
used = total - available
}
return total, used
}
func isGlobalIPv4(ip net.IP) bool {
v4 := ip.To4()
if v4 == nil {
return false
}
a, b := v4[0], v4[1]
switch {
case a == 0 || a == 10 || a == 127:
return false
case a == 172 && b >= 16 && b <= 31:
return false
case a == 192 && b == 168:
return false
case a == 100 && b >= 64 && b <= 127:
return false
case a == 169 && b == 254:
return false
case a >= 224:
return false
}
return true
}
func isGlobalIPv6(ip net.IP) bool {
if ip.To4() != nil {
return false
}
if ip.IsLoopback() || ip.IsUnspecified() || ip.IsMulticast() {
return false
}
b := ip.To16()
if b == nil {
return false
}
if b[0] == 0xfc || b[0] == 0xfd { // ULA fc00::/7
return false
}
if b[0] == 0xfe && b[1]&0xc0 == 0x80 { // link-local fe80::/10
return false
}
return true
}
// HostIPs picks the host's global interface addresses (no external
// lookup of any kind), mirroring hostIPs in lib/sysstat.js.
func HostIPs() (ipv4, ipv6 *string) {
ifaces, err := net.Interfaces()
if err != nil {
return nil, nil
}
for _, iface := range ifaces {
if iface.Flags&net.FlagLoopback != 0 || iface.Flags&net.FlagUp == 0 {
continue
}
addrs, err := iface.Addrs()
if err != nil {
continue
}
for _, addr := range addrs {
var ip net.IP
switch a := addr.(type) {
case *net.IPNet:
ip = a.IP
case *net.IPAddr:
ip = a.IP
}
if ip == nil {
continue
}
if ipv4 == nil && isGlobalIPv4(ip) {
s := ip.String()
ipv4 = &s
}
if ipv6 == nil && isGlobalIPv6(ip) {
s := ip.String()
ipv6 = &s
}
}
}
return ipv4, ipv6
}
func round1(v float64) float64 {
return float64(int64(v*10+0.5)) / 10
}
func (m *SystemMonitor) Sample(d *Data, force bool) *sysCurrent {
m.mu.Lock()
defer m.mu.Unlock()
now := time.Now()
if !force && m.current != nil && now.Sub(m.lastSample).Milliseconds() < sampleMinIntervalMs {
return m.current
}
m.lastSample = now
if idle, total, ok := readCPUTimes(); ok {
if m.hasPrev {
totalDelta := total - m.prevTotal
idleDelta := idle - m.prevIdle
if totalDelta > 0 {
pct := (1 - float64(idleDelta)/float64(totalDelta)) * 100
if pct < 0 {
pct = 0
}
if pct > 100 {
pct = 100
}
m.cpuPct = &pct
}
}
m.prevIdle, m.prevTotal, m.hasPrev = idle, total, true
}
memTotal, memUsed := readMemInfo()
var diskTotal, diskUsed int64
var statfs syscall.Statfs_t
if err := syscall.Statfs(m.dataDir, &statfs); err == nil {
diskTotal = int64(statfs.Blocks) * int64(statfs.Bsize)
diskUsed = (int64(statfs.Blocks) - int64(statfs.Bfree)) * int64(statfs.Bsize)
}
var totalUp, totalDown int64
var upBps, downBps float64
nowMs := now.UnixMilli()
for _, node := range d.Nodes {
if node.MeteredTraffic != nil {
totalUp += node.MeteredTraffic.Uplink
totalDown += node.MeteredTraffic.Downlink
}
up, down := NodeSpeedBps(node, nowMs)
upBps += up
downBps += down
}
dayKey := now.Format("2006-01-02")
if m.dayKey != dayKey {
m.dayKey = dayKey
m.dayBaseUp = totalUp
m.dayBaseDown = totalDown
}
m.todayUp = totalUp - m.dayBaseUp
if m.todayUp < 0 {
m.todayUp = 0
}
m.todayDown = totalDown - m.dayBaseDown
if m.todayDown < 0 {
m.todayDown = 0
}
conns := CountOnlineCustomers(d, now)
var cpuVal, memVal *float64
if m.cpuPct != nil {
v := round1(*m.cpuPct)
cpuVal = &v
}
if memTotal > 0 {
v := round1(float64(memUsed) / float64(memTotal) * 100)
memVal = &v
}
m.history = append(m.history, sysSample{
At: ISOFromTime(now), CPU: cpuVal, Mem: memVal,
UpBps: upBps, DownBps: downBps, Connections: conns,
})
if len(m.history) > historyCap {
m.history = m.history[len(m.history)-historyCap:]
}
m.current = &sysCurrent{
CPUPct: cpuVal, MemTotal: memTotal, MemUsed: memUsed,
DiskTotal: diskTotal, DiskUsed: diskUsed,
UpBps: upBps, DownBps: downBps, Conns: conns,
TotalUp: totalUp, TotalDown: totalDown,
}
return m.current
}
func panelMemBytes() int64 {
raw, err := os.ReadFile("/proc/self/status")
if err != nil {
return 0
}
for _, line := range strings.Split(string(raw), "\n") {
if strings.HasPrefix(line, "VmRSS:") {
fields := strings.Fields(line)
if len(fields) >= 2 {
if n, err := strconv.ParseInt(fields[1], 10, 64); err == nil {
return n * 1024
}
}
}
}
return 0
}
func hostUptimeSec() int64 {
raw, err := os.ReadFile("/proc/uptime")
if err != nil {
return 0
}
fields := strings.Fields(string(raw))
if len(fields) == 0 {
return 0
}
if v, err := strconv.ParseFloat(fields[0], 64); err == nil {
return int64(v)
}
return 0
}
func (m *SystemMonitor) Status(d *Data, version string) map[string]any {
current := m.Sample(d, false)
m.mu.Lock()
history := make([]sysSample, len(m.history))
copy(history, m.history)
todayUp, todayDown := m.todayUp, m.todayDown
startedAt := m.startedAt
m.mu.Unlock()
if current == nil {
current = &sysCurrent{}
}
memTotal, memUsed := current.MemTotal, current.MemUsed
var memPct, diskPct *float64
if memTotal > 0 {
v := round1(float64(memUsed) / float64(memTotal) * 100)
memPct = &v
}
if current.DiskTotal > 0 {
v := round1(float64(current.DiskUsed) / float64(current.DiskTotal) * 100)
diskPct = &v
}
onlineNodes := 0
for _, node := range d.Nodes {
if node.Status == "active" {
onlineNodes++
}
}
ipv4, ipv6 := HostIPs()
load1 := float64(0)
// load average from /proc/loadavg
if raw, err := os.ReadFile("/proc/loadavg"); err == nil {
fields := strings.Fields(string(raw))
if len(fields) > 0 {
load1, _ = strconv.ParseFloat(fields[0], 64)
}
}
return map[string]any{
"version": version,
"startedAt": ISOFromTime(startedAt),
"panelUptimeSec": int64(time.Since(startedAt).Seconds()),
"hostUptimeSec": hostUptimeSec(),
"panelMemBytes": panelMemBytes(),
"host": map[string]any{
"cpuPercent": current.CPUPct,
"cpuCount": runtime.NumCPU(),
"load1": load1,
"memTotal": memTotal, "memUsed": memUsed, "memPercent": memPct,
"diskTotal": current.DiskTotal, "diskUsed": current.DiskUsed, "diskPercent": diskPct,
},
"network": map[string]any{
"upBps": current.UpBps, "downBps": current.DownBps,
"connections": current.Conns, "onlineCustomers": current.Conns,
"onlineNodes": onlineNodes, "nodesTotal": len(d.Nodes), "customersTotal": len(d.Customers),
},
"today": map[string]any{"upBytes": todayUp, "downBytes": todayDown},
"totals": map[string]any{"upBytes": current.TotalUp, "downBytes": current.TotalDown},
"ips": map[string]any{"ipv4": ipv4, "ipv6": ipv6},
"history": history,
}
}