feat: add monitoring agent (Go) + agent report API + cloud billing UI

- Go agent: CPU/mem/disk/port collectors, HTTP report, static binary
- Cross-compile: linux/amd64 + linux/arm64 (statically linked)
- One-line install script for all Linux distros
- Backend: POST /api/v1/agent/report (no JWT) + GET /agent/latest,history (JWT)
- CloudBilling.tsx: fixed syntax errors, stats/trends/records/alerts
- K8s menu renamed to Kubernetes管理

Signed-off-by: Logan.Li
This commit is contained in:
attacker committed 2026-06-14 21:27:14 +08:00
1 parent 77d13b07d5
commit ac0b8e75c0
17 files changed
+1267

No files matched your search

+122
View File
@@ -0,0 +1,122 @@
// Package collector 提供服务器各项系统指标的采集能力。
// 本文件实现 CPU 使用率与负载采集,主要通过解析 Linux /proc/stat 与 /proc/loadavg 实现。
//
// 署名 Logan.Li
package collector
import (
"bufio"
"fmt"
"os"
"runtime"
"strconv"
"strings"
"sync"
"ezops/agent/model"
)
// cpuSample 保存某一时刻 CPU 的累计 jiffies,用于两次采样计算差值
type cpuSample struct {
total uint64 // 自系统启动以来 CPU 所有状态的累计 jiffies
idle uint64 // idle + iowait 的累计 jiffies
}
// CPUCollector CPU 指标采集器。
// 通过在两次采集之间保存上一次的采样数据来计算真实使用率。
type CPUCollector struct {
mu sync.Mutex
prev *cpuSample
}
// NewCPUCollector 创建 CPU 采集器实例
func NewCPUCollector() *CPUCollector {
return &CPUCollector{}
}
// Collect 采集当前 CPU 指标。
// 注意:首次调用因缺少上次采样数据,返回的使用率为 0;后续调用返回两次采集之间的平均使用率。
func (c *CPUCollector) Collect() (model.CPUStat, error) {
stat := model.CPUStat{CoreCount: runtime.NumCPU()}
c.mu.Lock()
defer c.mu.Unlock()
cur, err := readCPUSample()
if err != nil {
return stat, fmt.Errorf("读取 CPU 采样失败: %w", err)
}
if c.prev != nil {
totalDelta := cur.total - c.prev.total
idleDelta := cur.idle - c.prev.idle
if totalDelta > 0 {
// 使用率 = (总增量 - 空闲增量) / 总增量 * 100
stat.UsagePercent = float64(totalDelta-idleDelta) / float64(totalDelta) * 100.0
if stat.UsagePercent < 0 {
stat.UsagePercent = 0
}
}
}
c.prev = &cur
// 负载平均值采集失败不影响 CPU 使用率上报
if l1, l5, l15, err := readLoadAvg(); err == nil {
stat.Load1, stat.Load5, stat.Load15 = l1, l5, l15
}
return stat, nil
}
// readCPUSample 读取 /proc/stat 第一行(聚合所有 CPU)并计算 total 与 idle
func readCPUSample() (cpuSample, error) {
var sample cpuSample
f, err := os.Open("/proc/stat")
if err != nil {
return sample, err
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
if !strings.HasPrefix(line, "cpu ") {
continue
}
// 形如: cpu user nice system idle iowait irq softirq steal guest guest_nice
fields := strings.Fields(line)
if len(fields) < 5 {
return sample, fmt.Errorf("/proc/stat 数据格式异常")
}
// fields[0] == "cpu",之后依次为各类 jiffies
for i, v := range fields[1:] {
x, perr := strconv.ParseUint(v, 10, 64)
if perr != nil {
continue
}
sample.total += x
// idle(索引3) 与 iowait(索引4) 计入空闲
if i == 3 || i == 4 {
sample.idle += x
}
}
return sample, nil
}
if err := scanner.Err(); err != nil {
return sample, err
}
return sample, fmt.Errorf("/proc/stat 中未找到聚合 CPU 行")
}
// readLoadAvg 读取 /proc/loadavg,返回 1/5/15 分钟平均负载
func readLoadAvg() (load1, load5, load15 float64, err error) {
f, err := os.Open("/proc/loadavg")
if err != nil {
return 0, 0, 0, err
}
defer f.Close()
_, err = fmt.Fscanf(f, "%f %f %f", &load1, &load5, &load15)
return load1, load5, load15, err
}
+133
View File
@@ -0,0 +1,133 @@
// Package collector 提供服务器各项系统指标的采集能力。
// 本文件实现对所有已挂载的真实文件系统执行 statfs,采集磁盘使用情况。
// 使用 golang.org/x/sys/unix 的 Statfs,兼容 Linux 与 macOS。
//
// 署名 Logan.Li
package collector
import (
"bufio"
"fmt"
"os"
"runtime"
"strings"
"golang.org/x/sys/unix"
"ezops/agent/model"
)
// DiskCollector 磁盘使用率采集器
type DiskCollector struct{}
// NewDiskCollector 创建磁盘采集器实例
func NewDiskCollector() *DiskCollector {
return &DiskCollector{}
}
// mount 描述一个挂载点信息
type mount struct {
device string
mountpoint string
fstype string
}
// realFilesystems 允许采集的"真实"文件系统类型白名单,
// 用于排除 tmpfs/proc/sysfs 等伪文件系统,避免产生噪音数据。
var realFilesystems = map[string]bool{
"ext2": true, "ext3": true, "ext4": true,
"xfs": true, "btrfs": true, "zfs": true,
"apfs": true, "hfs": true, "exfat": true,
"ntfs": true, "vfat": true, "fat32": true,
"f2fs": true, "jfs": true, "reiserfs": true,
"ufs": true,
}
// Collect 采集所有真实文件系统挂载点的磁盘使用情况
func (d *DiskCollector) Collect() ([]model.DiskStat, error) {
mounts, err := d.getMounts()
if err != nil {
return nil, fmt.Errorf("获取挂载点失败: %w", err)
}
disks := make([]model.DiskStat, 0, len(mounts))
seen := make(map[string]bool) // 按挂载点去重
for _, m := range mounts {
if !realFilesystems[m.fstype] {
continue
}
if seen[m.mountpoint] {
continue
}
seen[m.mountpoint] = true
var fs unix.Statfs_t
if err := unix.Statfs(m.mountpoint, &fs); err != nil {
// 单个挂载点采集失败不影响其余挂载点
continue
}
bsize := uint64(fs.Bsize)
total := fs.Blocks * bsize
free := fs.Bfree * bsize
if total == 0 {
continue
}
used := total - free
disks = append(disks, model.DiskStat{
Mount: m.mountpoint,
Device: m.device,
TotalGB: bytesToGB(total),
UsedGB: bytesToGB(used),
UsagePercent: float64(used) / float64(total) * 100.0,
})
}
return disks, nil
}
// getMounts 获取挂载点列表。
// - Linux: 解析 /proc/mounts
// - macOS(开发环境): 返回根分区
func (d *DiskCollector) getMounts() ([]mount, error) {
if runtime.GOOS != "linux" {
// 开发环境(macOS)下提供根分区以便本地调试
return []mount{{device: "/dev/disk1", mountpoint: "/", fstype: "apfs"}}, nil
}
f, err := os.Open("/proc/mounts")
if err != nil {
return nil, err
}
defer f.Close()
var mounts []mount
scanner := bufio.NewScanner(f)
// 挂载点路径可能较长,扩大缓冲区
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for scanner.Scan() {
// 形如: /dev/sda1 / ext4 rw,relatime 0 0
fields := strings.Fields(scanner.Text())
if len(fields) < 3 {
continue
}
mounts = append(mounts, mount{
device: fields[0],
mountpoint: unescapeMount(fields[1]),
fstype: fields[2],
})
}
return mounts, scanner.Err()
}
// unescapeMount 还原 /proc/mounts 中被转义的挂载点路径(如 \040 -> 空格)
func unescapeMount(s string) string {
return strings.ReplaceAll(s, "\\040", " ")
}
// bytesToGB 字节数转 GB
func bytesToGB(b uint64) float64 {
const gb = 1024.0 * 1024.0 * 1024.0
return float64(b) / gb
}
+95
View File
@@ -0,0 +1,95 @@
// Package collector 提供服务器各项系统指标的采集能力。
// 本文件实现内存使用率采集,通过解析 Linux /proc/meminfo 获取。
//
// 署名 Logan.Li
package collector
import (
"bufio"
"fmt"
"os"
"strconv"
"strings"
"ezops/agent/model"
)
// kbToGB 将 KB 转换为 GB 的换算系数
const kbToGB = 1.0 / (1024.0 * 1024.0)
// MemCollector 内存指标采集器,通过解析 /proc/meminfo 获取
type MemCollector struct{}
// NewMemCollector 创建内存采集器实例
func NewMemCollector() *MemCollector {
return &MemCollector{}
}
// Collect 采集当前内存指标
func (m *MemCollector) Collect() (model.MemoryStat, error) {
values, err := parseMemInfo()
if err != nil {
return model.MemoryStat{}, fmt.Errorf("读取内存信息失败: %w", err)
}
totalKB := values["MemTotal"]
buffersKB := values["Buffers"]
cachedKB := values["Cached"]
availableKB := values["MemAvailable"]
freeKB := values["MemFree"]
// 若内核未提供 MemAvailable(较老版本),则按传统方式估算可用内存
if availableKB == 0 {
availableKB = freeKB + buffersKB + cachedKB
}
usedKB := uint64(0)
if totalKB > availableKB {
usedKB = totalKB - availableKB
}
stat := model.MemoryStat{
TotalGB: float64(totalKB) * kbToGB,
UsedGB: float64(usedKB) * kbToGB,
CachedGB: float64(buffersKB+cachedKB) * kbToGB,
}
if totalKB > 0 {
stat.UsagePercent = float64(usedKB) / float64(totalKB) * 100.0
}
return stat, nil
}
// parseMemInfo 解析 /proc/meminfo,返回 字段名 -> 数值(KB) 的映射
func parseMemInfo() (map[string]uint64, error) {
result := make(map[string]uint64)
f, err := os.Open("/proc/meminfo")
if err != nil {
return nil, err
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
// 形如: MemTotal: 16384000 kB
parts := strings.SplitN(line, ":", 2)
if len(parts) != 2 {
continue
}
key := strings.TrimSpace(parts[0])
valFields := strings.Fields(parts[1])
if len(valFields) == 0 {
continue
}
val, err := strconv.ParseUint(valFields[0], 10, 64)
if err != nil {
continue
}
result[key] = val
}
if err := scanner.Err(); err != nil {
return nil, err
}
return result, nil
}
+202
View File
@@ -0,0 +1,202 @@
// Package collector 提供服务器各项系统指标的采集能力。
// 本文件实现监听 TCP 端口的自动发现:
// - 扫描 /proc/net/tcp 与 /proc/net/tcp6 中状态为 LISTEN(0A) 的套接字;
// - 通过遍历 /proc/*/fd 反查套接字 inode 归属的进程。
//
// 署名 Logan.Li
package collector
import (
"os"
"path/filepath"
"strconv"
"strings"
"ezops/agent/model"
)
// PortCollector 监听端口自动发现采集器
type PortCollector struct{}
// NewPortCollector 创建端口采集器实例
func NewPortCollector() *PortCollector {
return &PortCollector{}
}
// listenSocket 描述一个处于 LISTEN 状态的套接字
type listenSocket struct {
port int
protocol string
inode uint64
}
// procInfo 描述占用某套接字 inode 的进程信息
type procInfo struct {
pid int
name string
}
// Collect 采集所有处于监听状态的 TCP 端口及其归属进程
func (p *PortCollector) Collect() ([]model.PortStat, error) {
// 1. 扫描 /proc/net/tcp(/tcp6),收集 LISTEN 套接字
sockets := p.scanListenSockets()
if len(sockets) == 0 {
return []model.PortStat{}, nil
}
// 2. 构建 inode -> 进程信息 映射(仅 Linux 上可用)
inodeMap := p.buildInodeMap()
// 3. 组装结果并按 (端口, 协议, PID) 去重
seen := make(map[string]bool)
ports := make([]model.PortStat, 0, len(sockets))
for _, s := range sockets {
pid, name := 0, ""
if info, ok := inodeMap[s.inode]; ok {
pid, name = info.pid, info.name
}
key := strconv.Itoa(s.port) + "|" + s.protocol + "|" + strconv.Itoa(pid)
if seen[key] {
continue
}
seen[key] = true
ports = append(ports, model.PortStat{
Port: s.port,
Protocol: s.protocol,
Process: name,
PID: pid,
})
}
return ports, nil
}
// scanListenSockets 解析 /proc/net/tcp 与 /proc/net/tcp6,返回 LISTEN 套接字。
// 单个文件读取失败(如 macOS 开发环境下不存在 /proc)会被忽略。
func (p *PortCollector) scanListenSockets() []listenSocket {
files := []struct {
path string
protocol string
}{
{"/proc/net/tcp", "tcp"},
{"/proc/net/tcp6", "tcp6"},
}
var sockets []listenSocket
for _, sf := range files {
result, err := parseNetTCP(sf.path, sf.protocol)
if err != nil {
continue
}
sockets = append(sockets, result...)
}
return sockets
}
// parseNetTCP 解析单个 /proc/net/tcp(*) 文件
func parseNetTCP(path, protocol string) ([]listenSocket, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var sockets []listenSocket
lines := strings.Split(string(data), "\n")
for i, line := range lines {
if i == 0 {
continue // 跳过表头
}
fields := strings.Fields(line)
if len(fields) < 10 {
continue
}
// fields[3] 为套接字状态,"0A" 表示 TCP_LISTEN
if fields[3] != "0A" {
continue
}
// fields[1] 为本地地址,格式 IP:Port(均为十六进制)
localAddr := fields[1]
colon := strings.LastIndex(localAddr, ":")
if colon < 0 {
continue
}
port, err := strconv.ParseUint(localAddr[colon+1:], 16, 32)
if err != nil {
continue
}
// fields[9] 为套接字 inode
inode, err := strconv.ParseUint(fields[9], 10, 64)
if err != nil {
continue
}
sockets = append(sockets, listenSocket{
port: int(port),
protocol: protocol,
inode: inode,
})
}
return sockets, nil
}
// buildInodeMap 扫描 /proc/*/fd,构建 套接字 inode -> 进程信息 的映射。
// 对无权限读取的进程目录(如其它用户进程)会自动跳过。
func (p *PortCollector) buildInodeMap() map[uint64]procInfo {
inodeMap := make(map[uint64]procInfo)
entries, err := os.ReadDir("/proc")
if err != nil {
return inodeMap
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
pid, err := strconv.Atoi(entry.Name())
if err != nil {
continue // 非 PID 目录
}
fdDir := filepath.Join("/proc", entry.Name(), "fd")
fds, err := os.ReadDir(fdDir)
if err != nil {
continue // 无权限读取,跳过
}
name := readProcessName(entry.Name())
for _, fd := range fds {
link, err := os.Readlink(filepath.Join(fdDir, fd.Name()))
if err != nil {
continue
}
// 套接字链接形如: socket:[12345]
if !strings.HasPrefix(link, "socket:[") {
continue
}
inodeStr := link[len("socket:[") : len(link)-1]
inode, err := strconv.ParseUint(inodeStr, 10, 64)
if err != nil {
continue
}
// 同一 inode 仅记录首个发现的进程即可
if _, exists := inodeMap[inode]; !exists {
inodeMap[inode] = procInfo{pid: pid, name: name}
}
}
}
return inodeMap
}
// readProcessName 读取进程名称:优先 /proc/<pid>/comm,失败则尝试取 cmdline 的可执行文件名
func readProcessName(pid string) string {
if data, err := os.ReadFile("/proc/" + pid + "/comm"); err == nil {
if s := strings.TrimSpace(string(data)); s != "" {
return s
}
}
if data, err := os.ReadFile("/proc/" + pid + "/cmdline"); err == nil {
// cmdline 以 \0 分隔参数,取首个(即可执行文件路径)的基名
parts := strings.SplitN(string(data), "\x00", 2)
if len(parts) > 0 && parts[0] != "" {
return filepath.Base(parts[0])
}
}
return ""
}