feat(panel): reskin backend — node sortIndex, presence/speed, /api/system, credentials, subscription aggregation (Node+Go parity)
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user