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:
attacker committed 2026-06-24 08:34:43 +00:00
1 parent 7bc5169e8f
commit 5333f6f04a
30 files changed
+2406 -1001

No files matched your search

+157
View File
@@ -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)
}
+76
View File
@@ -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
}