feat(alert-center): 添加飞书Webhook通知渠道 + 告警引擎实时通知
- 后端: NotifyChannel 模型新增 feishu 类型, FeishuWebhookConfig - 后端: TestNotifyChannel 真实发送飞书测试消息(文本格式) - 后端: 告警引擎触发/恢复时自动发送飞书通知(sendFeishuAlert/sendFeishuRecovery) - 后端: AlertEngine 新增 tcp_time_wait/tcp_close_wait/service_process 指标检测 - 后端: NetworkStatModel 新增 TCPCloseWait 字段 - 后端: QuickSetupRules 一键创建6条告警规则(CPU/内存/磁盘/TIME_WAIT/CLOSE_WAIT/进程) - 后端: 注册 /alerts/quick-setup 路由 - 前端: AlertChannelDialog 新增飞书类型选项 - 前端: helpers 新增 getChannelIcon/getChannelTypeLabel 支持 feishu/dingtalk/wechat
This commit is contained in:
1 parent
7bc5169e8f
commit
5333f6f04a
30 files changed
+2406
-1001
No files matched your search
@@ -0,0 +1,157 @@
|
||||
// Package collector — 进程 TOP3 采集器
|
||||
// 扫描 /proc/*/stat + /proc/*/status 获取 CPU/内存/句柄 TOP3 进程
|
||||
// 1000 服务器规模下的优化:只取 TOP3,不遍历全部进程的 cmdline
|
||||
//
|
||||
// 署名 Logan.Li
|
||||
package collector
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"ezops/agent/model"
|
||||
)
|
||||
|
||||
// ProcessTopCollector 进程 TOP 采集器
|
||||
type ProcessTopCollector struct{}
|
||||
|
||||
// NewProcessTopCollector 创建进程 TOP 采集器
|
||||
func NewProcessTopCollector() *ProcessTopCollector {
|
||||
return &ProcessTopCollector{}
|
||||
}
|
||||
|
||||
// procSample holds key metrics for a single process
|
||||
type procSample struct {
|
||||
PID int
|
||||
Name string
|
||||
CPU float64 // CPU 使用率(需两次采样 + 系统总 jiffies 才能算,这里用 utime+stime ticks 近似)
|
||||
RSSKB uint64 // 物理内存 (KB)
|
||||
FDCount int // 打开的文件句柄数
|
||||
Threads int // 线程数
|
||||
}
|
||||
|
||||
// Collect gathers process metrics and returns top N sorted by RSS.
|
||||
// Sorting by RSS is the most stable and reliable metric without multi-sampling CPU.
|
||||
func (p *ProcessTopCollector) Collect(topN int) ([]model.ProcessStat, error) {
|
||||
entries, err := os.ReadDir("/proc")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var samples []procSample
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
pid, err := strconv.Atoi(entry.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
name := readProcName(entry.Name())
|
||||
// Skip kernel threads (empty cmdline + running in kernel)
|
||||
if name == "" || strings.HasPrefix(name, "[") {
|
||||
continue
|
||||
}
|
||||
|
||||
rssKB := readProcRSS(entry.Name())
|
||||
fdCount := countFD(entry.Name())
|
||||
threads := readProcThreads(entry.Name())
|
||||
|
||||
samples = append(samples, procSample{
|
||||
PID: pid,
|
||||
Name: name,
|
||||
RSSKB: rssKB,
|
||||
FDCount: fdCount,
|
||||
Threads: threads,
|
||||
})
|
||||
}
|
||||
|
||||
// Sort by RSS descending
|
||||
sort.Slice(samples, func(i, j int) bool {
|
||||
return samples[i].RSSKB > samples[j].RSSKB
|
||||
})
|
||||
|
||||
// Take top N
|
||||
if topN > len(samples) {
|
||||
topN = len(samples)
|
||||
}
|
||||
result := make([]model.ProcessStat, topN)
|
||||
for i := 0; i < topN; i++ {
|
||||
s := samples[i]
|
||||
result[i] = model.ProcessStat{
|
||||
PID: s.PID,
|
||||
Name: s.Name,
|
||||
MemoryMB: float64(s.RSSKB) / 1024.0,
|
||||
FDCount: s.FDCount,
|
||||
Threads: s.Threads,
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// readProcName reads /proc/<pid>/comm
|
||||
func readProcName(pid string) string {
|
||||
data, err := os.ReadFile(filepath.Join("/proc", pid, "comm"))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(data))
|
||||
}
|
||||
|
||||
// readProcRSS reads VmRSS from /proc/<pid>/status
|
||||
func readProcRSS(pid string) uint64 {
|
||||
f, err := os.Open(filepath.Join("/proc", pid, "status"))
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if strings.HasPrefix(line, "VmRSS:") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 2 {
|
||||
val, _ := strconv.ParseUint(fields[1], 10, 64)
|
||||
return val
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// readProcThreads reads Threads from /proc/<pid>/status
|
||||
func readProcThreads(pid string) int {
|
||||
f, err := os.Open(filepath.Join("/proc", pid, "status"))
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if strings.HasPrefix(line, "Threads:") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 2 {
|
||||
val, _ := strconv.Atoi(fields[1])
|
||||
return val
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// countFD counts open file descriptors for a process
|
||||
func countFD(pid string) int {
|
||||
entries, err := os.ReadDir(filepath.Join("/proc", pid, "fd"))
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return len(entries)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// Package collector — Swap 采集器
|
||||
// 从 /proc/meminfo 读取 SwapTotal / SwapFree
|
||||
// 仅当系统有 swap 分区时返回有效数据
|
||||
//
|
||||
// 署名 Logan.Li
|
||||
package collector
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"ezops/agent/model"
|
||||
)
|
||||
|
||||
// SwapCollector 交换分区采集器
|
||||
type SwapCollector struct{}
|
||||
|
||||
// NewSwapCollector 创建 Swap 采集器
|
||||
func NewSwapCollector() *SwapCollector {
|
||||
return &SwapCollector{}
|
||||
}
|
||||
|
||||
// Collect 读取 swap 指标
|
||||
func (s *SwapCollector) Collect() (*model.SwapStat, error) {
|
||||
f, err := os.Open("/proc/meminfo")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var swapTotal, swapFree uint64
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
// Line format: "SwapTotal: 0 kB"
|
||||
key := strings.TrimSuffix(fields[0], ":")
|
||||
switch key {
|
||||
case "SwapTotal":
|
||||
swapTotal, _ = parseUint64(fields[1])
|
||||
case "SwapFree":
|
||||
swapFree, _ = parseUint64(fields[1])
|
||||
}
|
||||
}
|
||||
|
||||
// No swap configured — return nil (caller should omit from report)
|
||||
if swapTotal == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
used := swapTotal - swapFree
|
||||
stat := &model.SwapStat{
|
||||
TotalGB: float64(swapTotal) / (1024.0 * 1024.0),
|
||||
UsedGB: float64(used) / (1024.0 * 1024.0),
|
||||
}
|
||||
if swapTotal > 0 {
|
||||
stat.UsagePercent = float64(used) / float64(swapTotal) * 100.0
|
||||
}
|
||||
return stat, nil
|
||||
}
|
||||
|
||||
// parseUint64 parses a uint64 or returns 0
|
||||
func parseUint64(s string) (uint64, error) {
|
||||
var v uint64
|
||||
for _, c := range s {
|
||||
if c < '0' || c > '9' {
|
||||
break
|
||||
}
|
||||
v = v*10 + uint64(c-'0')
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
+193
-49
@@ -1,7 +1,13 @@
|
||||
// Package main 是 AIOps Agent 的入口。
|
||||
// Agent 定时采集 CPU、内存、磁盘及监听端口指标,并通过 HTTP 上报至 AIOps 后端。
|
||||
// Agent 定时采集 CPU、内存、磁盘、端口、进程 TOP、Swap、FD、上下文切换等指标,
|
||||
// 并通过 HTTP 上报至 AIOps 后端。
|
||||
//
|
||||
// 以下变量可在编译时通过 -ldflags "-X main.version=... -X main.buildTime=..." 注入。
|
||||
// 优化要点(1000 台服务器规模):
|
||||
// - 启动随机 Jitter 避免惊群效应
|
||||
// - 连接池复用 HTTP Keep-Alive
|
||||
// - 进程 TOP 限制为 3(可配置)
|
||||
// - Agent 自检心跳(序列号丢包检测)
|
||||
// - 端口变化率采集(增量检测,减少 payload)
|
||||
//
|
||||
// 署名 Logan.Li
|
||||
package main
|
||||
@@ -9,10 +15,13 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/rand"
|
||||
"net"
|
||||
"os"
|
||||
"os/signal"
|
||||
"runtime"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -22,23 +31,21 @@ import (
|
||||
"ezops/agent/task"
|
||||
)
|
||||
|
||||
// 编译时可注入的版本信息
|
||||
var (
|
||||
version = "dev"
|
||||
buildTime = "unknown"
|
||||
)
|
||||
|
||||
func main() {
|
||||
server := flag.String("server", "http://localhost:8080", "AIOps 后端上报地址(含 scheme 与端口)")
|
||||
server := flag.String("server", "http://localhost:8080", "AIOps 后端上报地址")
|
||||
interval := flag.Int("interval", 10, "采集与上报间隔,单位:秒")
|
||||
token := flag.String("token", "", "Bearer 鉴权令牌")
|
||||
hostnameFlag := flag.String("hostname", "", "上报主机名,默认使用系统主机名")
|
||||
topN := flag.Int("top-n", 3, "进程 TOP N 数量")
|
||||
flag.Parse()
|
||||
|
||||
// 统一日志格式:带微秒时间戳,便于排查问题
|
||||
logger := log.New(os.Stdout, "[aiops-agent] ", log.LstdFlags|log.Lmicroseconds)
|
||||
|
||||
// 解析主机名:优先使用 -hostname,否则回退到 os.Hostname()
|
||||
hostname := *hostnameFlag
|
||||
if hostname == "" {
|
||||
if h, err := os.Hostname(); err == nil {
|
||||
@@ -48,50 +55,52 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// 参数校验
|
||||
if *server == "" {
|
||||
logger.Fatal("参数 -server 不能为空")
|
||||
}
|
||||
if *interval < 1 {
|
||||
logger.Fatalf("参数 -interval 必须 >= 1,当前为 %d", *interval)
|
||||
if *interval < 5 {
|
||||
logger.Fatalf("参数 -interval 必须 >= 5,当前为 %d(1000 台规模推荐 30~60s)", *interval)
|
||||
}
|
||||
|
||||
logger.Printf("AIOps Agent 启动: version=%s buildTime=%s", version, buildTime)
|
||||
logger.Printf("配置: server=%s interval=%ds hostname=%q token=%s",
|
||||
*server, *interval, hostname, maskToken(*token))
|
||||
logger.Printf("AIOps Agent v%s (built %s) go=%s", version, buildTime, runtime.Version())
|
||||
logger.Printf("配置: server=%s interval=%ds top-n=%d hostname=%q", *server, *interval, *topN, hostname)
|
||||
|
||||
// 初始化各采集器与上报器
|
||||
// ═══ 初始化采集器 ═══
|
||||
cpuCollector := collector.NewCPUCollector()
|
||||
memCollector := collector.NewMemCollector()
|
||||
diskCollector := collector.NewDiskCollector()
|
||||
portCollector := collector.NewPortCollector()
|
||||
netCollector := collector.NewNetworkCollector()
|
||||
diskIOCollector := collector.NewDiskIOCollector()
|
||||
procTopCollector := collector.NewProcessTopCollector()
|
||||
swapCollector := collector.NewSwapCollector()
|
||||
rep := reporter.New(*server, *token, logger)
|
||||
|
||||
// 采集系统静态信息(OS/内核),仅在启动时执行一次
|
||||
// 系统静态信息(仅启动时一次)
|
||||
sysInfo := collector.CollectSystem()
|
||||
logger.Printf("系统信息: OS=%q Kernel=%q", sysInfo.OSVersion, sysInfo.Kernel)
|
||||
|
||||
// 探测本机主 IP(用于后端匹配 servers 表)
|
||||
// 探测本机 IP
|
||||
localIP := detectLocalIP()
|
||||
logger.Printf("探测本机 IP: %s", localIP)
|
||||
|
||||
// 系统信息上报计数器:前 3 次上报携带系统信息,确保后端至少收到一次
|
||||
sysReportCount := 0
|
||||
sysReportCount := 0 // 前 3 次上报携带系统信息
|
||||
|
||||
// 初始化任务执行器与任务客户端(与 reporter 复用相同的 server/token/hostname)
|
||||
// 任务执行器
|
||||
taskExecutor := task.New(logger)
|
||||
taskClient := task.NewClient(*server, *token, hostname, logger)
|
||||
|
||||
// 监听 SIGINT / SIGTERM,支持优雅退出
|
||||
// ═══ 启动随机 Jitter:避免 1000 台同时上报 ═══
|
||||
startupJitter := time.Duration(rand.Intn(*interval)) * time.Second
|
||||
logger.Printf("启动 Jitter: %v(防止惊群效应)", startupJitter)
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
ticker := time.NewTicker(time.Duration(*interval) * time.Second)
|
||||
defer ticker.Stop()
|
||||
// Agent 自检序列号
|
||||
var reportSeq uint64
|
||||
|
||||
// collectAndReport 执行单次采集并上报;各项采集独立进行,单项失败不影响整体上报
|
||||
// collectAndReport 执行单次采集并上报
|
||||
collectAndReport := func() {
|
||||
report := &model.Report{
|
||||
Hostname: hostname,
|
||||
@@ -100,69 +109,140 @@ func main() {
|
||||
AgentVersion: version,
|
||||
}
|
||||
|
||||
// 前 3 次上报携带系统信息
|
||||
// 系统信息(前 3 次)
|
||||
if sysReportCount < 3 && (sysInfo.OSVersion != "" || sysInfo.Kernel != "") {
|
||||
report.System = &model.SystemStat{
|
||||
OSVersion: sysInfo.OSVersion,
|
||||
Kernel: sysInfo.Kernel,
|
||||
Uptime: sysInfo.UptimeSeconds,
|
||||
OSVersion: sysInfo.OSVersion,
|
||||
Kernel: sysInfo.Kernel,
|
||||
Uptime: sysInfo.UptimeSeconds,
|
||||
}
|
||||
sysReportCount++
|
||||
}
|
||||
|
||||
// CPU
|
||||
if stat, err := cpuCollector.Collect(); err != nil {
|
||||
logger.Printf("CPU 采集异常: %v", err)
|
||||
} else {
|
||||
report.CPU = stat
|
||||
}
|
||||
|
||||
// 内存
|
||||
if stat, err := memCollector.Collect(); err != nil {
|
||||
logger.Printf("内存采集异常: %v", err)
|
||||
} else {
|
||||
report.Memory = stat
|
||||
}
|
||||
|
||||
// Swap(仅当系统配置了 swap 才上报)
|
||||
if swap, err := swapCollector.Collect(); err != nil {
|
||||
logger.Printf("Swap 采集异常: %v", err)
|
||||
} else if swap != nil {
|
||||
report.Swap = swap
|
||||
}
|
||||
|
||||
// 磁盘
|
||||
if disks, err := diskCollector.Collect(); err != nil {
|
||||
logger.Printf("磁盘采集异常: %v", err)
|
||||
} else {
|
||||
report.Disks = disks
|
||||
}
|
||||
|
||||
// 监听端口
|
||||
if ports, err := portCollector.Collect(); err != nil {
|
||||
logger.Printf("端口采集异常: %v", err)
|
||||
} else {
|
||||
report.Ports = ports
|
||||
}
|
||||
|
||||
// 网络
|
||||
if netStat, err := netCollector.Collect(); err != nil {
|
||||
logger.Printf("网络采集异常: %v", err)
|
||||
} else {
|
||||
report.Network = netStat
|
||||
}
|
||||
|
||||
// 磁盘 IO
|
||||
if dioStat, err := diskIOCollector.Collect(); err != nil {
|
||||
logger.Printf("磁盘IO采集异常: %v", err)
|
||||
} else {
|
||||
report.DiskIO = dioStat
|
||||
}
|
||||
|
||||
// 进程 TOP N
|
||||
if procs, err := procTopCollector.Collect(*topN); err != nil {
|
||||
logger.Printf("进程TOP采集异常: %v", err)
|
||||
} else {
|
||||
report.ProcessTop = procs
|
||||
}
|
||||
|
||||
// 系统 FD 句柄
|
||||
if fdStat, err := collectFD(); err != nil {
|
||||
logger.Printf("FD采集异常: %v", err)
|
||||
} else {
|
||||
report.FD = fdStat
|
||||
}
|
||||
|
||||
// 上下文切换
|
||||
if csStat, err := collectContextSwitches(); err != nil {
|
||||
// 静默跳过(某些内核/容器环境不可用)
|
||||
} else {
|
||||
report.ContextSwitches = csStat
|
||||
}
|
||||
|
||||
// Agent 自检心跳
|
||||
reportSeq++
|
||||
var memStats runtime.MemStats
|
||||
runtime.ReadMemStats(&memStats)
|
||||
report.Heartbeat = model.AgentHeartbeat{
|
||||
Version: version,
|
||||
UptimeSec: sysInfo.UptimeSeconds,
|
||||
SelfCPU: 0, // TODO: self-CPU via /proc/self/stat
|
||||
SelfRSSMB: float64(memStats.Alloc) / 1024.0 / 1024.0,
|
||||
ReportSeq: reportSeq,
|
||||
}
|
||||
|
||||
// 上报
|
||||
if err := rep.Send(ctx, report); err != nil {
|
||||
logger.Printf("上报失败: %v", err)
|
||||
return
|
||||
}
|
||||
logger.Printf("上报成功: cpu=%.2f%% mem=%.2f%% disks=%d ports=%d net=%dconn/%dwait netIO=%.0f/%.0fB/s diskIO=%.0f/%.0fB/s",
|
||||
report.CPU.UsagePercent, report.Memory.UsagePercent, len(report.Disks), len(report.Ports),
|
||||
report.Network.TCPEstablished, report.Network.TCPTimeWait,
|
||||
report.Network.NetRxBPS, report.Network.NetTxBPS,
|
||||
report.DiskIO.ReadBPS, report.DiskIO.WriteBPS)
|
||||
logger.Printf("上报成功: cpu=%.1f%% mem=%.1f%% swap=%.1f%% disks=%d ports=%d procs=%d fd=%d/%d cs=%.0f/s",
|
||||
report.CPU.UsagePercent,
|
||||
report.Memory.UsagePercent,
|
||||
func() float64 {
|
||||
if report.Swap != nil {
|
||||
return report.Swap.UsagePercent
|
||||
}
|
||||
return 0
|
||||
}(),
|
||||
len(report.Disks),
|
||||
len(report.Ports),
|
||||
len(report.ProcessTop),
|
||||
func() int {
|
||||
if report.FD != nil {
|
||||
return report.FD.Allocated
|
||||
}
|
||||
return 0
|
||||
}(),
|
||||
func() int64 {
|
||||
if report.FD != nil {
|
||||
return report.FD.Max
|
||||
}
|
||||
return 0
|
||||
}(),
|
||||
func() float64 {
|
||||
if report.ContextSwitches != nil {
|
||||
return report.ContextSwitches.CTXPerSec
|
||||
}
|
||||
return 0
|
||||
}(),
|
||||
)
|
||||
}
|
||||
|
||||
// pollAndExecute 执行单次任务轮询:拉取待执行任务 → 逐个执行 → 回传结果。
|
||||
// 网络错误只记录日志,不 panic,下一个周期继续重试。
|
||||
// pollAndExecute 任务轮询
|
||||
pollAndExecute := func() {
|
||||
tasks, err := taskClient.Poll(ctx)
|
||||
if err != nil {
|
||||
// 上下文已取消(Agent 退出中)属于正常情况,不输出告警
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
@@ -170,12 +250,10 @@ func main() {
|
||||
return
|
||||
}
|
||||
if len(tasks) == 0 {
|
||||
return // 暂无任务,静默返回
|
||||
return
|
||||
}
|
||||
logger.Printf("拉取到 %d 个待执行任务", len(tasks))
|
||||
|
||||
for _, t := range tasks {
|
||||
// Agent 退出时跳过后续任务,避免退出延迟
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
@@ -191,11 +269,9 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// 启动任务轮询协程:每 5 秒轮询一次,与指标采集并行运行。
|
||||
// 使用独立的 5 秒固定间隔,不受 -interval 影响。
|
||||
// ═══ 任务轮询协程(5s 间隔,不受 -interval 影响)═══
|
||||
const taskPollInterval = 5 * time.Second
|
||||
go func() {
|
||||
// 启动后立即轮询一次,再按固定周期执行
|
||||
pollAndExecute()
|
||||
ticker := time.NewTicker(taskPollInterval)
|
||||
defer ticker.Stop()
|
||||
@@ -209,20 +285,23 @@ func main() {
|
||||
}
|
||||
}()
|
||||
|
||||
// 主协程:启动后立即执行一次指标采集,再按 ticker 周期执行
|
||||
// ═══ 主循环:启动先 sleep jitter,再按 interval 采集 ═══
|
||||
time.Sleep(startupJitter)
|
||||
collectAndReport()
|
||||
ticker := time.NewTicker(time.Duration(*interval) * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
collectAndReport()
|
||||
case <-ctx.Done():
|
||||
logger.Println("收到退出信号,Agent 正在关闭...")
|
||||
logger.Println("收到退出信号,Agent 正常关闭")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// maskToken 对令牌做脱敏处理,仅用于日志输出
|
||||
// maskToken 对令牌做脱敏处理
|
||||
func maskToken(token string) string {
|
||||
if token == "" {
|
||||
return "(empty)"
|
||||
@@ -233,11 +312,8 @@ func maskToken(token string) string {
|
||||
return token[:4] + "****"
|
||||
}
|
||||
|
||||
// detectLocalIP 探测本机主 IP 地址。
|
||||
// 通过 UDP 连接到公网地址(不实际发包),获取系统路由选择的本机 IP。
|
||||
// 如果失败,回退到遍历网卡查找非 loopback 的 IPv4 地址。
|
||||
// detectLocalIP 探测本机主 IP
|
||||
func detectLocalIP() string {
|
||||
// 方法1:通过 UDP "连接" 到公网 DNS 获取出口 IP
|
||||
conn, err := net.Dial("udp", "8.8.8.8:53")
|
||||
if err == nil {
|
||||
defer conn.Close()
|
||||
@@ -245,8 +321,6 @@ func detectLocalIP() string {
|
||||
return addr.IP.String()
|
||||
}
|
||||
}
|
||||
|
||||
// 方法2:遍历网卡
|
||||
ifaces, err := net.Interfaces()
|
||||
if err == nil {
|
||||
for _, iface := range ifaces {
|
||||
@@ -263,3 +337,73 @@ func detectLocalIP() string {
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// 补充采集器(内联简单实现,不单独建文件)
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
// collectFD 读取 /proc/sys/fs/file-nr 和 /proc/sys/fs/file-max
|
||||
func collectFD() (*model.SocketFDStat, error) {
|
||||
alloc, err := os.ReadFile("/proc/sys/fs/file-nr")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var allocated, _unused1, _unused2 int
|
||||
fmt.Sscanf(string(alloc), "%d %d %d", &allocated, &_unused1, &_unused2)
|
||||
|
||||
max, err := os.ReadFile("/proc/sys/fs/file-max")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var maxFD int64
|
||||
fmt.Sscanf(string(max), "%d", &maxFD)
|
||||
|
||||
stat := &model.SocketFDStat{
|
||||
Allocated: allocated,
|
||||
Max: maxFD,
|
||||
}
|
||||
if maxFD > 0 {
|
||||
stat.UsagePct = float64(allocated) / float64(maxFD) * 100.0
|
||||
}
|
||||
return stat, nil
|
||||
}
|
||||
|
||||
// collectContextSwitches 读取 /proc/stat 中的 ctxt 和 intr 行
|
||||
// 需要两次采样才能计算速率;这里使用简单的累计值(首次返回 0)
|
||||
func collectContextSwitches() (*model.ContextSwitchStat, error) {
|
||||
data, err := os.ReadFile("/proc/stat")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stat := &model.ContextSwitchStat{}
|
||||
lines := string(data)
|
||||
for _, line := range splitLines(lines) {
|
||||
if hasPrefix(line, "ctxt ") {
|
||||
fmt.Sscanf(line, "ctxt %f", &stat.CTXPerSec)
|
||||
}
|
||||
if hasPrefix(line, "intr ") {
|
||||
fmt.Sscanf(line, "intr %f", &stat.IRQPerSec)
|
||||
}
|
||||
}
|
||||
// NOTE: 这里返回的是累计值而非速率;后端可用两次上报的差值计算速率
|
||||
return stat, nil
|
||||
}
|
||||
|
||||
func splitLines(s string) []string {
|
||||
var result []string
|
||||
start := 0
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == '\n' {
|
||||
result = append(result, s[start:i])
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
if start < len(s) {
|
||||
result = append(result, s[start:])
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func hasPrefix(s, prefix string) bool {
|
||||
return len(s) >= len(prefix) && s[:len(prefix)] == prefix
|
||||
}
|
||||
+16
-11
@@ -6,17 +6,22 @@ package model
|
||||
|
||||
// Report 为单次采集周期的完整上报报文
|
||||
type Report struct {
|
||||
Hostname string `json:"hostname"` // 主机名
|
||||
IP string `json:"ip,omitempty"` // 本机 IP(Agent 启动时探测)
|
||||
Timestamp string `json:"timestamp"` // RFC3339 格式的 UTC 时间戳
|
||||
AgentVersion string `json:"agent_version"` // Agent 版本号
|
||||
CPU CPUStat `json:"cpu"` // CPU 指标
|
||||
Memory MemoryStat `json:"memory"` // 内存指标
|
||||
Disks []DiskStat `json:"disks"` // 磁盘指标列表
|
||||
Ports []PortStat `json:"ports"` // 监听端口列表
|
||||
Network NetworkStat `json:"network"` // 网络指标
|
||||
DiskIO DiskIOStat `json:"disk_io"` // 磁盘 IO
|
||||
System *SystemStat `json:"system,omitempty"` // 系统信息(首次采集后携带)
|
||||
Hostname string `json:"hostname"` // 主机名
|
||||
IP string `json:"ip,omitempty"` // 本机 IP(Agent 启动时探测)
|
||||
Timestamp string `json:"timestamp"` // RFC3339 UTC 时间戳
|
||||
AgentVersion string `json:"agent_version"` // Agent 版本号
|
||||
CPU CPUStat `json:"cpu"` // CPU 指标
|
||||
Memory MemoryStat `json:"memory"` // 内存指标
|
||||
Swap *SwapStat `json:"swap,omitempty"` // 交换分区(有 swap 才上报)
|
||||
Disks []DiskStat `json:"disks"` // 磁盘指标列表
|
||||
Ports []PortStat `json:"ports"` // 监听端口列表
|
||||
Network NetworkStat `json:"network"` // 网络指标
|
||||
DiskIO DiskIOStat `json:"disk_io"` // 磁盘 IO
|
||||
System *SystemStat `json:"system,omitempty"` // 系统信息(首次采集后携带)
|
||||
ProcessTop []ProcessStat `json:"process_top,omitempty"` // 进程 TOP N
|
||||
FD *SocketFDStat `json:"fd,omitempty"` // 系统文件句柄
|
||||
ContextSwitches *ContextSwitchStat `json:"context_switches,omitempty"` // 上下文切换
|
||||
Heartbeat AgentHeartbeat `json:"heartbeat"` // Agent 自检
|
||||
}
|
||||
|
||||
// CPUStat CPU 相关指标
|
||||
|
||||
@@ -18,11 +18,41 @@ type DiskIOStat struct {
|
||||
WriteIOPS float64 `json:"write_iops"` // 写入 IOPS (count/s)
|
||||
}
|
||||
|
||||
// SwapStat 交换分区指标(OOM 前兆判断)
|
||||
type SwapStat struct {
|
||||
TotalGB float64 `json:"total_gb"`
|
||||
UsedGB float64 `json:"used_gb"`
|
||||
UsagePercent float64 `json:"usage_percent"`
|
||||
}
|
||||
|
||||
// SocketFDStat 系统级 FD 使用情况
|
||||
type SocketFDStat struct {
|
||||
Allocated int `json:"allocated"` // 已分配的 file handles (fs.file-nr 第一列)
|
||||
Max int64 `json:"max"` // 系统上限 (fs.file-max)
|
||||
UsagePct float64 `json:"usage_pct"`
|
||||
}
|
||||
|
||||
// ContextSwitchStat 上下文切换(决定是否开中断合并/CPU 隔离)
|
||||
type ContextSwitchStat struct {
|
||||
CTXPerSec float64 `json:"ctx_per_sec"` // 每秒上下文切换次数
|
||||
IRQPerSec float64 `json:"irq_per_sec"` // 每秒中断次数
|
||||
}
|
||||
|
||||
// AgentHeartbeat Agent 自检心跳(随每次上报附带,极小 payload)
|
||||
type AgentHeartbeat struct {
|
||||
Version string `json:"version"`
|
||||
UptimeSec int64 `json:"uptime_sec"`
|
||||
LastErr string `json:"last_err,omitempty"`
|
||||
SelfCPU float64 `json:"self_cpu_pct"`
|
||||
SelfRSSMB float64 `json:"self_rss_mb"`
|
||||
ReportSeq uint64 `json:"report_seq"` // 单调递增序列号,用于丢包检测
|
||||
}
|
||||
|
||||
// SystemStat 系统信息(启动时采集一次)
|
||||
type SystemStat struct {
|
||||
OSVersion string `json:"os_version"` // OS 版本 (如 "Ubuntu 22.04")
|
||||
Kernel string `json:"kernel"` // 内核版本
|
||||
Uptime int64 `json:"uptime"` // 运行秒数
|
||||
UlimitNofile int64 `json:"ulimit_nofile"` // ulimit -n (最大文件描述符)
|
||||
UlimitNproc int64 `json:"ulimit_nproc"` // ulimit -u (最大进程数)
|
||||
OSVersion string `json:"os_version"` // OS 版本 (如 "Ubuntu 22.04")
|
||||
Kernel string `json:"kernel"` // 内核版本
|
||||
Uptime int64 `json:"uptime"` // 运行秒数
|
||||
UlimitNofile int64 `json:"ulimit_nofile"` // ulimit -n (最大文件描述符)
|
||||
UlimitNproc int64 `json:"ulimit_nproc"` // ulimit -u (最大进程数)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package model
|
||||
|
||||
// ProcessStat 单个进程统计(TOP N 采集)
|
||||
type ProcessStat struct {
|
||||
PID int `json:"pid"`
|
||||
Name string `json:"name"`
|
||||
MemoryMB float64 `json:"memory_mb"` // 物理内存 (MB)
|
||||
FDCount int `json:"fd_count"` // 打开的文件句柄数
|
||||
Threads int `json:"threads"` // 线程数
|
||||
}
|
||||
Reference in new issue
Block a user