feat: v0.34 健康探针(Architecture B) + Agent v2.1 + 端口监控 + 告警联系人重构

- v0.31: AlertContact 重构(channel_type下拉+webhook_url替代三分立字段)
- v0.32: Agent ProcessTop 增强(cmdline/user/cpu_pct/mem_pct)
- v0.33: 端口监控 Tab(监听端口自动发现+down联动告警)
- v0.34: 健康探针(Agent本地执行HTTP/TCP/Exec→结果随心跳上报)
- Agent新增: ContextSwitch/FD/HealthProbe采集器
- 后端新增: health_probe handler + Agent model扩展
- HostMonitor新增Tab: 健康探针/端口监控
This commit is contained in:
attacker committed 2026-06-24 19:18:10 +08:00
1 parent e1f75f0bc4
commit d656520428
25 files changed
+1700 -619

No files matched your search

+140
View File
@@ -0,0 +1,140 @@
// Package client 提供 Agent 与 AIOps 后端通信的共享 HTTP 客户端。
// Reporter 和 TaskClient 共用此客户端,避免重复实现鉴权、重试、上下文传播。
//
// 署名 Logan.Li
package client
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strings"
"time"
)
const (
defaultTimeout = 10 * time.Second
maxRetries = 3
userAgent = "aiops-agent/2.1"
)
// Client 共享 HTTP 客户端,封装鉴权、路径拼接、重试逻辑。
type Client struct {
baseURL string
token string
httpClient *http.Client
Logger *log.Logger
}
// New 创建客户端实例。
func New(server, token string, logger *log.Logger) *Client {
return &Client{
baseURL: strings.TrimRight(server, "/"),
token: token,
httpClient: &http.Client{Timeout: defaultTimeout},
Logger: logger,
}
}
// Do 执行 HTTP 请求并返回原始响应。调用方负责关闭 resp.Body。
func (c *Client) Do(ctx context.Context, method, path string, body []byte) (*http.Response, error) {
url := c.baseURL + path
var lastErr error
for attempt := 1; attempt <= maxRetries; attempt++ {
var reqBody io.Reader
if body != nil {
reqBody = bytes.NewReader(body)
}
req, err := http.NewRequestWithContext(ctx, method, url, reqBody)
if err != nil {
return nil, fmt.Errorf("构造请求失败: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", userAgent)
if c.token != "" {
req.Header.Set("Authorization", "Bearer "+c.token)
}
if err := ctx.Err(); err != nil {
return nil, err
}
resp, err := c.httpClient.Do(req)
if err != nil {
lastErr = err
c.Logger.Printf("[http] 请求失败(第%d次) %s %s: %v", attempt, method, path, err)
if !sleepWithCtx(ctx, attempt) {
return nil, ctx.Err()
}
continue
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return resp, nil
}
// 读取错误响应体
errBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
resp.Body.Close()
lastErr = fmt.Errorf("HTTP %d %s %s: %s", resp.StatusCode, method, path, string(errBody))
// 4xx(除 429)不重试
if resp.StatusCode >= 400 && resp.StatusCode < 500 && resp.StatusCode != http.StatusTooManyRequests {
return nil, lastErr
}
c.Logger.Printf("[http] 响应异常(第%d次) %s %s: HTTP %d", attempt, method, path, resp.StatusCode)
if !sleepWithCtx(ctx, attempt) {
return nil, ctx.Err()
}
}
return nil, lastErr
}
// DoJSON 执行请求并解析 JSON 响应到 result。自动关闭响应体。
func (c *Client) DoJSON(ctx context.Context, method, path string, reqBody, result interface{}) error {
var bodyBytes []byte
if reqBody != nil {
var err error
bodyBytes, err = json.Marshal(reqBody)
if err != nil {
return fmt.Errorf("序列化请求体失败: %w", err)
}
}
resp, err := c.Do(ctx, method, path, bodyBytes)
if err != nil {
return err
}
defer resp.Body.Close()
if result != nil {
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("读取响应体失败: %w", err)
}
if err := json.Unmarshal(body, result); err != nil {
return fmt.Errorf("解析响应失败: %w", err)
}
}
return nil
}
// sleepWithCtx 带退避的休眠,可被 context 取消。
func sleepWithCtx(ctx context.Context, attempt int) bool {
timer := time.NewTimer(time.Duration(attempt) * time.Second)
defer timer.Stop()
select {
case <-timer.C:
return true
case <-ctx.Done():
return false
}
}
+39
View File
@@ -0,0 +1,39 @@
// Package collector 的上下文切换采集器。
// 读取 /proc/stat 中的 ctxt(上下文切换)和 intr(中断)累计值。
// 注意:此处返回累计值而非速率,后端可用两次上报差值计算。
//
// 署名 Logan.Li
package collector
import (
"fmt"
"os"
"ezops/agent/model"
)
// ContextSwitchCollector 上下文切换采集器
type ContextSwitchCollector struct{}
// NewContextSwitchCollector 创建采集器实例
func NewContextSwitchCollector() *ContextSwitchCollector {
return &ContextSwitchCollector{}
}
// Collect 读取 /proc/stat 中的上下文切换和中断统计
func (c *ContextSwitchCollector) Collect() (*model.ContextSwitchStat, error) {
data, err := os.ReadFile("/proc/stat")
if err != nil {
return nil, fmt.Errorf("读取 /proc/stat 失败: %w", err)
}
stat := &model.ContextSwitchStat{}
for _, line := range SplitLines(string(data)) {
if HasPrefix(line, "ctxt ") {
fmt.Sscanf(line, "ctxt %f", &stat.CTXPerSec)
}
if HasPrefix(line, "intr ") {
fmt.Sscanf(line, "intr %f", &stat.IRQPerSec)
}
}
return stat, nil
}
+49
View File
@@ -0,0 +1,49 @@
// Package collector 的 FD 句柄采集器。
// 读取 /proc/sys/fs/file-nr 和 /proc/sys/fs/file-max 获取系统级文件句柄使用情况。
//
// 署名 Logan.Li
package collector
import (
"fmt"
"os"
"ezops/agent/model"
)
// FDCollector 系统文件句柄采集器
type FDCollector struct{}
// NewFDCollector 创建采集器实例
func NewFDCollector() *FDCollector {
return &FDCollector{}
}
// Collect 读取 /proc/sys/fs/file-nr 和 file-max
func (c *FDCollector) Collect() (*model.SocketFDStat, error) {
alloc, err := os.ReadFile("/proc/sys/fs/file-nr")
if err != nil {
return nil, fmt.Errorf("读取 file-nr 失败: %w", 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, fmt.Errorf("读取 file-max 失败: %w", err)
}
var maxFD int64
fmt.Sscanf(string(max), "%d", &maxFD)
stat := &model.SocketFDStat{
Allocated: allocated,
Max: maxFD,
}
if maxFD > 100_000_000 {
stat.Max = -1
stat.UsagePct = 0
} else if maxFD > 0 {
stat.UsagePct = float64(allocated) / float64(maxFD) * 100.0
}
return stat, nil
}
+216
View File
@@ -0,0 +1,216 @@
// Package collector 的健康探针采集器。
// 在后端下发的探针配置驱动下,本地执行 HTTP GET / TCP 连接 / 命令执行,
// 并将结果返回供上报。
//
// 署名 Logan.Li
package collector
import (
"context"
"fmt"
"io"
"net"
"net/http"
"os/exec"
"strings"
"time"
"ezops/agent/model"
)
// HealthProbeCollector 健康探针执行器
type HealthProbeCollector struct {
httpClient *http.Client
}
// NewHealthProbeCollector 创建采集器实例
func NewHealthProbeCollector() *HealthProbeCollector {
return &HealthProbeCollector{
httpClient: &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
DialContext: (&net.Dialer{
Timeout: 5 * time.Second,
}).DialContext,
},
},
}
}
// Execute 执行所有探针配置,返回结果列表。
// parentCtx 用于传播取消信号——探针执行尽量尊重主循环的生命周期。
func (h *HealthProbeCollector) Execute(parentCtx context.Context, configs []model.ProbeConfig) []model.ProbeResult {
results := make([]model.ProbeResult, 0, len(configs))
for _, cfg := range configs {
// 检查父上下文是否已取消
if err := parentCtx.Err(); err != nil {
break
}
result := model.ProbeResult{
ProbeID: cfg.ID,
Name: cfg.Name,
ProbeType: cfg.ProbeType,
}
start := time.Now()
switch cfg.ProbeType {
case "http_get":
result = h.execHTTP(parentCtx, cfg, start)
case "tcp_socket":
result = h.execTCP(cfg, start)
case "exec_command":
result = h.execCmd(parentCtx, cfg, start)
default:
result.Status = "down"
result.Message = fmt.Sprintf("unknown probe type: %s", cfg.ProbeType)
}
results = append(results, result)
}
return results
}
// execHTTP 执行 HTTP 探测,接收父 context
func (h *HealthProbeCollector) execHTTP(parentCtx context.Context, cfg model.ProbeConfig, start time.Time) model.ProbeResult {
result := model.ProbeResult{
ProbeID: cfg.ID,
Name: cfg.Name,
ProbeType: cfg.ProbeType,
}
timeout := time.Duration(cfg.Timeout) * time.Second
if timeout <= 0 {
timeout = 10 * time.Second
}
ctx, cancel := context.WithTimeout(parentCtx, timeout)
defer cancel()
method := cfg.HTTPMethod
if method == "" {
method = "GET"
}
req, err := http.NewRequestWithContext(ctx, method, cfg.HTTPURL, nil)
if err != nil {
result.Status = "down"
result.Message = fmt.Sprintf("构造请求失败: %v", err)
result.LatencyMs = int(time.Since(start).Milliseconds())
return result
}
req.Header.Set("User-Agent", "aiops-agent-health-probe/2.1")
resp, err := h.httpClient.Do(req)
latencyMs := int(time.Since(start).Milliseconds())
result.LatencyMs = latencyMs
if err != nil {
result.Status = "down"
result.Message = fmt.Sprintf("HTTP请求失败: %v", err)
return result
}
defer resp.Body.Close()
expectCode := cfg.HTTPExpectCode
if expectCode == 0 {
expectCode = 200
}
result.StatusCode = resp.StatusCode
if resp.StatusCode != expectCode {
result.Status = "down"
result.Message = fmt.Sprintf("状态码不匹配: got %d, want %d", resp.StatusCode, expectCode)
return result
}
// 可选:检查响应体
if cfg.HTTPExpectBody != "" {
bodyBytes, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
if err != nil {
result.Status = "down"
result.Message = fmt.Sprintf("读取响应体失败: %v", err)
return result
}
if !strings.Contains(string(bodyBytes), cfg.HTTPExpectBody) {
result.Status = "down"
result.Message = fmt.Sprintf("响应体不包含期望字符串: %q", cfg.HTTPExpectBody)
return result
}
}
result.Status = "up"
result.Message = fmt.Sprintf("HTTP %d OK (%dms)", resp.StatusCode, latencyMs)
return result
}
// execTCP 执行 TCP 连接探测
func (h *HealthProbeCollector) execTCP(cfg model.ProbeConfig, start time.Time) model.ProbeResult {
result := model.ProbeResult{
ProbeID: cfg.ID,
Name: cfg.Name,
ProbeType: cfg.ProbeType,
}
timeout := time.Duration(cfg.Timeout) * time.Second
if timeout <= 0 {
timeout = 10 * time.Second
}
addr := fmt.Sprintf("%s:%d", cfg.TCPHost, cfg.TCPPort)
conn, err := net.DialTimeout("tcp", addr, timeout)
latencyMs := int(time.Since(start).Milliseconds())
result.LatencyMs = latencyMs
if err != nil {
result.Status = "down"
result.Message = fmt.Sprintf("TCP连接失败 %s: %v", addr, err)
return result
}
conn.Close()
result.Status = "up"
result.Message = fmt.Sprintf("TCP %s connected (%dms)", addr, latencyMs)
return result
}
// execCmd 执行命令探测(exit code 0 = up),接收父 context
func (h *HealthProbeCollector) execCmd(parentCtx context.Context, cfg model.ProbeConfig, start time.Time) model.ProbeResult {
result := model.ProbeResult{
ProbeID: cfg.ID,
Name: cfg.Name,
ProbeType: cfg.ProbeType,
}
timeout := time.Duration(cfg.Timeout) * time.Second
if timeout <= 0 {
timeout = 10 * time.Second
}
ctx, cancel := context.WithTimeout(parentCtx, timeout)
defer cancel()
cmd := exec.CommandContext(ctx, "sh", "-c", cfg.ExecCommand)
output, err := cmd.CombinedOutput()
latencyMs := int(time.Since(start).Milliseconds())
result.LatencyMs = latencyMs
result.StatusCode = cmd.ProcessState.ExitCode()
if err != nil {
result.Status = "down"
// 截断输出避免过大
outMsg := strings.TrimSpace(string(output))
if len(outMsg) > 200 {
outMsg = outMsg[:200] + "..."
}
result.Message = fmt.Sprintf("命令执行失败(exit=%d): %s", cmd.ProcessState.ExitCode(), outMsg)
return result
}
result.Status = "up"
result.Message = fmt.Sprintf("命令执行成功 (%dms)", latencyMs)
return result
}
+230 -67
View File
@@ -1,6 +1,6 @@
// Package collector — 进程 TOP3 采集器 // Package collector — 进程 TOP 采集器
// 扫描 /proc/*/stat + /proc/*/status 获取 CPU/内存/句柄 TOP3 进程 // 扫描 /proc 获取 TOP N 进程完整信息:PID/User/CMD/CPU%/MEM%/FDs
// 1000 服务器规模下的优化:只取 TOP3,不遍历全部进程的 cmdline // 阿里云 ECS 监控风格 — 直观 6 列布局
// //
// 署名 Logan.Li // 署名 Logan.Li
package collector package collector
@@ -8,6 +8,7 @@ package collector
import ( import (
"bufio" "bufio"
"os" "os"
"os/user"
"path/filepath" "path/filepath"
"sort" "sort"
"strconv" "strconv"
@@ -16,33 +17,166 @@ import (
"ezops/agent/model" "ezops/agent/model"
) )
const clktck = 100.0 // Linux CLK_TCK = 100 Hz
// ProcessTopCollector 进程 TOP 采集器 // ProcessTopCollector 进程 TOP 采集器
type ProcessTopCollector struct{} type ProcessTopCollector struct {
memTotalKB uint64 // 系统总内存(KB),初始化时读取
numCPU int // CPU 核心数
}
// NewProcessTopCollector 创建进程 TOP 采集器 // NewProcessTopCollector 创建进程 TOP 采集器
func NewProcessTopCollector() *ProcessTopCollector { func NewProcessTopCollector() *ProcessTopCollector {
return &ProcessTopCollector{} pt := &ProcessTopCollector{}
pt.memTotalKB = readMemTotalKB()
pt.numCPU = readNumCPU()
if pt.numCPU < 1 {
pt.numCPU = 1
}
return pt
} }
// procSample holds key metrics for a single process // readMemTotalKB reads MemTotal from /proc/meminfo
type procSample struct { func readMemTotalKB() uint64 {
PID int f, err := os.Open("/proc/meminfo")
Name string if err != nil {
CPU float64 // CPU 使用率(需两次采样 + 系统总 jiffies 才能算,这里用 utime+stime ticks 近似) return 1
RSSKB uint64 // 物理内存 (KB) }
FDCount int // 打开的文件句柄数 defer f.Close()
Threads int // 线程数 sc := bufio.NewScanner(f)
for sc.Scan() {
if strings.HasPrefix(sc.Text(), "MemTotal:") {
f := strings.Fields(sc.Text())
if len(f) >= 2 {
v, _ := strconv.ParseUint(f[1], 10, 64)
return v
}
}
}
return 1
} }
// Collect gathers process metrics and returns top N sorted by RSS. // readNumCPU reads CPU count from /proc/stat (cpuN lines)
// Sorting by RSS is the most stable and reliable metric without multi-sampling CPU. func readNumCPU() int {
f, err := os.Open("/proc/stat")
if err != nil {
return 1
}
defer f.Close()
n := 0
sc := bufio.NewScanner(f)
for sc.Scan() {
if strings.HasPrefix(sc.Text(), "cpu") && len(sc.Text()) > 3 && sc.Text()[3] >= '0' && sc.Text()[3] <= '9' {
n++
}
}
return n
}
// readProcCPU reads utime+stime from /proc/<pid>/stat
func readProcCPUJiffies(pid string) uint64 {
data, err := os.ReadFile(filepath.Join("/proc", pid, "stat"))
if err != nil {
return 0
}
// stat: pid (comm) state ppid ... utime stime ...
// We need fields 14 (utime) and 15 (stime) in 1-based indexing
// Parse: skip past the closing ')' of comm
s := string(data)
idx := strings.LastIndex(s, ")")
if idx < 0 {
return 0
}
fields := strings.Fields(s[idx+2:])
if len(fields) < 13 {
return 0
}
// After ')', fields are: state ppid pgrp session tty_nr tpgid flags minflt cminflt majflt cmajflt utime stime
// So utime = fields[11], stime = fields[12]
utime, _ := strconv.ParseUint(fields[11], 10, 64)
stime, _ := strconv.ParseUint(fields[12], 10, 64)
return utime + stime
}
// readProcUser reads the owner username from /proc/<pid>/status (Uid line)
func readProcUser(pid string) string {
statusFile := filepath.Join("/proc", pid, "status")
f, err := os.Open(statusFile)
if err != nil {
return "?"
}
defer f.Close()
sc := bufio.NewScanner(f)
var uidStr string
for sc.Scan() {
line := sc.Text()
if strings.HasPrefix(line, "Uid:") {
fields := strings.Fields(line)
if len(fields) >= 2 {
uidStr = fields[1]
}
break
}
}
if uidStr == "" || uidStr == "0" {
return "root"
}
// Try to look up username
u, err := user.LookupId(uidStr)
if err == nil {
return u.Username
}
return uidStr
}
// readProcCmdline reads /proc/<pid>/cmdline, replaces null bytes with spaces
func readProcCmdline(pid string) string {
data, err := os.ReadFile(filepath.Join("/proc", pid, "cmdline"))
if err != nil || len(data) == 0 {
// Fallback to comm
data2, err2 := os.ReadFile(filepath.Join("/proc", pid, "comm"))
if err2 == nil {
return strings.TrimSpace(string(data2))
}
return "?"
}
// Replace null bytes with spaces
out := strings.Builder{}
for _, b := range data {
if b == 0 {
out.WriteByte(' ')
} else {
out.WriteByte(b)
}
}
return strings.TrimSpace(out.String())
}
// Collect gathers process metrics and returns top N sorted by RSS
func (p *ProcessTopCollector) Collect(topN int) ([]model.ProcessStat, error) { func (p *ProcessTopCollector) Collect(topN int) ([]model.ProcessStat, error) {
uptimeJiffies := readUptimeJiffies()
if uptimeJiffies < 1 {
uptimeJiffies = 1
}
entries, err := os.ReadDir("/proc") entries, err := os.ReadDir("/proc")
if err != nil { if err != nil {
return nil, err return nil, err
} }
var samples []procSample type sample struct {
pid int
name string
cmdline string
user string
cpuPct float64
memPct float64
rssKB uint64
fdCount int
threads int
}
var samples []sample
for _, entry := range entries { for _, entry := range entries {
if !entry.IsDir() { if !entry.IsDir() {
continue continue
@@ -51,32 +185,56 @@ func (p *ProcessTopCollector) Collect(topN int) ([]model.ProcessStat, error) {
if err != nil { if err != nil {
continue continue
} }
pidStr := entry.Name()
name := readProcName(entry.Name()) // Read comm to filter kernel threads
// Skip kernel threads (empty cmdline + running in kernel) commData, err := os.ReadFile(filepath.Join("/proc", pidStr, "comm"))
if name == "" || strings.HasPrefix(name, "[") { if err != nil {
continue continue
} }
name := strings.TrimSpace(string(commData))
if name == "" || strings.HasPrefix(name, "[") {
continue // skip kernel threads
}
rssKB := readProcRSS(entry.Name()) // Gather all metrics
fdCount := countFD(entry.Name()) jiffies := readProcCPUJiffies(pidStr)
threads := readProcThreads(entry.Name()) rssKB := readProcRSS(pidStr)
fdCount := countFD(pidStr)
threads := readProcThreads(pidStr)
userName := readProcUser(pidStr)
cmdline := readProcCmdline(pidStr)
samples = append(samples, procSample{ // CPU% = (utime+stime) / uptime_jiffies * 100 / num_cpus
PID: pid, cpuPct := float64(jiffies) / float64(uptimeJiffies) * 100.0 / float64(p.numCPU)
Name: name, if cpuPct > 100*float64(p.numCPU) {
RSSKB: rssKB, cpuPct = 0 // invalid
FDCount: fdCount, }
Threads: threads,
// Memory%
var memPct float64
if p.memTotalKB > 0 {
memPct = float64(rssKB) / float64(p.memTotalKB) * 100.0
}
samples = append(samples, sample{
pid: pid,
name: name,
cmdline: cmdline,
user: userName,
cpuPct: cpuPct,
memPct: memPct,
rssKB: rssKB,
fdCount: fdCount,
threads: threads,
}) })
} }
// Sort by RSS descending // Sort by RSS descending
sort.Slice(samples, func(i, j int) bool { sort.Slice(samples, func(i, j int) bool {
return samples[i].RSSKB > samples[j].RSSKB return samples[i].rssKB > samples[j].rssKB
}) })
// Take top N
if topN > len(samples) { if topN > len(samples) {
topN = len(samples) topN = len(samples)
} }
@@ -84,23 +242,41 @@ func (p *ProcessTopCollector) Collect(topN int) ([]model.ProcessStat, error) {
for i := 0; i < topN; i++ { for i := 0; i < topN; i++ {
s := samples[i] s := samples[i]
result[i] = model.ProcessStat{ result[i] = model.ProcessStat{
PID: s.PID, PID: s.pid,
Name: s.Name, Name: s.name,
MemoryMB: float64(s.RSSKB) / 1024.0, Cmdline: s.cmdline,
FDCount: s.FDCount, User: s.user,
Threads: s.Threads, CPUPct: s.cpuPct,
MemPct: s.memPct,
MemoryMB: float64(s.rssKB) / 1024.0,
FDCount: s.fdCount,
Threads: s.threads,
} }
} }
return result, nil return result, nil
} }
// readProcName reads /proc/<pid>/comm // readUptimeJiffies reads /proc/uptime and converts to jiffies
func readProcName(pid string) string { func readUptimeJiffies() uint64 {
data, err := os.ReadFile(filepath.Join("/proc", pid, "comm")) data, err := os.ReadFile("/proc/uptime")
if err != nil { if err != nil {
return "" return 1
} }
return strings.TrimSpace(string(data)) f := strings.Fields(string(data))
if len(f) < 1 {
return 1
}
sec, _ := strconv.ParseFloat(f[0], 64)
return uint64(sec * clktck)
}
// 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)
} }
// readProcRSS reads VmRSS from /proc/<pid>/status // readProcRSS reads VmRSS from /proc/<pid>/status
@@ -110,15 +286,13 @@ func readProcRSS(pid string) uint64 {
return 0 return 0
} }
defer f.Close() defer f.Close()
sc := bufio.NewScanner(f)
scanner := bufio.NewScanner(f) for sc.Scan() {
for scanner.Scan() { if strings.HasPrefix(sc.Text(), "VmRSS:") {
line := scanner.Text() f := strings.Fields(sc.Text())
if strings.HasPrefix(line, "VmRSS:") { if len(f) >= 2 {
fields := strings.Fields(line) v, _ := strconv.ParseUint(f[1], 10, 64)
if len(fields) >= 2 { return v
val, _ := strconv.ParseUint(fields[1], 10, 64)
return val
} }
} }
} }
@@ -132,26 +306,15 @@ func readProcThreads(pid string) int {
return 0 return 0
} }
defer f.Close() defer f.Close()
sc := bufio.NewScanner(f)
scanner := bufio.NewScanner(f) for sc.Scan() {
for scanner.Scan() { if strings.HasPrefix(sc.Text(), "Threads:") {
line := scanner.Text() f := strings.Fields(sc.Text())
if strings.HasPrefix(line, "Threads:") { if len(f) >= 2 {
fields := strings.Fields(line) v, _ := strconv.Atoi(f[1])
if len(fields) >= 2 { return v
val, _ := strconv.Atoi(fields[1])
return val
} }
} }
} }
return 0 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)
}
+25
View File
@@ -0,0 +1,25 @@
// Package collector 的通用工具函数。
//
// 署名 Logan.Li
package collector
// SplitLines 按换行符切割字符串
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
}
// HasPrefix 检查字符串前缀
func HasPrefix(s, prefix string) bool {
return len(s) >= len(prefix) && s[:len(prefix)] == prefix
}
+93 -157
View File
@@ -1,6 +1,7 @@
// Package main 是 AIOps Agent 的入口。 // Package main 是 AIOps Agent 的入口。
// Agent 定时采集 CPU、内存、磁盘、端口、进程 TOP、Swap、FD、上下文切换等指标, // Agent 定时采集 CPU、内存、磁盘、端口、进程 TOP、Swap、FD、上下文切换等指标,
// 并通过 HTTP 上报至 AIOps 后端。 // 并通过 HTTP 上报至 AIOps 后端。
// v2.1: 共享 HTTP client + 独立探针拉取 + 配置结构体
// //
// 优化要点(1000 台服务器规模): // 优化要点(1000 台服务器规模):
// - 启动随机 Jitter 避免惊群效应 // - 启动随机 Jitter 避免惊群效应
@@ -15,7 +16,6 @@ package main
import ( import (
"context" "context"
"flag" "flag"
"fmt"
"log" "log"
"math/rand" "math/rand"
"net" "net"
@@ -25,6 +25,7 @@ import (
"syscall" "syscall"
"time" "time"
agentclient "ezops/agent/client"
"ezops/agent/collector" "ezops/agent/collector"
"ezops/agent/model" "ezops/agent/model"
"ezops/agent/reporter" "ezops/agent/reporter"
@@ -32,38 +33,51 @@ import (
) )
var ( var (
version = "v2.0.0" version = "v2.1.0"
buildTime = "unknown" buildTime = "unknown"
) )
// Config Agent 运行配置(v2.1 从 flag 集中解析)
type Config struct {
Server string
Token string
Hostname string
Interval int
TopN int
}
func main() { func main() {
server := flag.String("server", "http://localhost:8080", "AIOps 后端上报地址") cfg := Config{
interval := flag.Int("interval", 10, "采集与上报间隔,单位:秒") Server: *flag.String("server", "http://localhost:8080", "AIOps 后端上报地址"),
token := flag.String("token", "", "Bearer 鉴权令牌") Token: *flag.String("token", "", "Bearer 鉴权令牌"),
hostnameFlag := flag.String("hostname", "", "上报主机名,默认使用系统主机名") Hostname: *flag.String("hostname", "", "上报主机名,默认使用系统主机名"),
topN := flag.Int("top-n", 3, "进程 TOP N 数量") Interval: *flag.Int("interval", 10, "采集与上报间隔,单位:秒"),
TopN: *flag.Int("top-n", 3, "进程 TOP N 数量"),
}
flag.Parse() flag.Parse()
logger := log.New(os.Stdout, "[aiops-agent] ", log.LstdFlags|log.Lmicroseconds) logger := log.New(os.Stdout, "[aiops-agent] ", log.LstdFlags|log.Lmicroseconds)
hostname := *hostnameFlag if cfg.Hostname == "" {
if hostname == "" {
if h, err := os.Hostname(); err == nil { if h, err := os.Hostname(); err == nil {
hostname = h cfg.Hostname = h
} else { } else {
hostname = "unknown" cfg.Hostname = "unknown"
} }
} }
if *server == "" { if cfg.Server == "" {
logger.Fatal("参数 -server 不能为空") logger.Fatal("参数 -server 不能为空")
} }
if *interval < 5 { if cfg.Interval < 5 {
logger.Fatalf("参数 -interval 必须 >= 5,当前为 %d(1000 台规模推荐 30~60s)", *interval) logger.Fatalf("参数 -interval 必须 >= 5,当前为 %d(1000 台规模推荐 30~60s)", cfg.Interval)
} }
logger.Printf("AIOps Agent v%s (built %s) go=%s", version, buildTime, runtime.Version()) 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) logger.Printf("配置: server=%s interval=%ds top-n=%d hostname=%q", cfg.Server, cfg.Interval, cfg.TopN, cfg.Hostname)
// ═══ 共享 HTTP 客户端(Reporter + TaskClient 共用)═══
httpCli := agentclient.New(cfg.Server, cfg.Token, logger)
// ═══ 初始化采集器 ═══ // ═══ 初始化采集器 ═══
cpuCollector := collector.NewCPUCollector() cpuCollector := collector.NewCPUCollector()
@@ -74,7 +88,10 @@ func main() {
diskIOCollector := collector.NewDiskIOCollector() diskIOCollector := collector.NewDiskIOCollector()
procTopCollector := collector.NewProcessTopCollector() procTopCollector := collector.NewProcessTopCollector()
swapCollector := collector.NewSwapCollector() swapCollector := collector.NewSwapCollector()
rep := reporter.New(*server, *token, logger) healthProbeCollector := collector.NewHealthProbeCollector()
fdCollector := collector.NewFDCollector()
csCollector := collector.NewContextSwitchCollector()
rep := reporter.New(httpCli, logger)
// 系统静态信息(仅启动时一次) // 系统静态信息(仅启动时一次)
sysInfo := collector.CollectSystem() sysInfo := collector.CollectSystem()
@@ -84,111 +101,97 @@ func main() {
localIP := detectLocalIP() localIP := detectLocalIP()
logger.Printf("探测本机 IP: %s", localIP) logger.Printf("探测本机 IP: %s", localIP)
sysReportCount := 0 // 前 3 次上报携带系统信息 sysReportCount := 0
// 任务执行器 // 任务执行器 + 任务客户端
taskExecutor := task.New(logger) taskExecutor := task.New(logger)
taskClient := task.NewClient(*server, *token, hostname, logger) taskClient := task.NewClient(httpCli, cfg.Hostname, logger)
// ═══ 启动随机 Jitter:避免 1000 台同时上报 ═══ // ═══ 启动随机 Jitter ═══
startupJitter := time.Duration(rand.Intn(*interval)) * time.Second startupJitter := time.Duration(rand.Intn(cfg.Interval)) * time.Second
logger.Printf("启动 Jitter: %v(防止惊群效应)", startupJitter) logger.Printf("启动 Jitter: %v(防止惊群效应)", startupJitter)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop() defer stop()
// Agent 自检序列号
var reportSeq uint64 var reportSeq uint64
var probeConfigs []model.ProbeConfig
// collectAndReport 执行单次采集并上报 // ═══ collectAndReport 采集 + 上报 ═══
collectAndReport := func() { collectAndReport := func() {
report := &model.Report{ report := &model.Report{
Hostname: hostname, Hostname: cfg.Hostname,
IP: localIP, IP: localIP,
Timestamp: time.Now().UTC().Format(time.RFC3339), Timestamp: time.Now().UTC().Format(time.RFC3339),
AgentVersion: version, AgentVersion: version,
} }
// 系统信息(前 3 次)
if sysReportCount < 3 && (sysInfo.OSVersion != "" || sysInfo.Kernel != "") { if sysReportCount < 3 && (sysInfo.OSVersion != "" || sysInfo.Kernel != "") {
report.System = &model.SystemStat{ report.System = &model.SystemStat{
OSVersion: sysInfo.OSVersion, OSVersion: sysInfo.OSVersion,
Kernel: sysInfo.Kernel, Kernel: sysInfo.Kernel,
Uptime: sysInfo.UptimeSeconds, Uptime: sysInfo.UptimeSeconds,
} }
sysReportCount++ sysReportCount++
} }
// CPU // 系统指标采集
if stat, err := cpuCollector.Collect(); err != nil { if stat, err := cpuCollector.Collect(); err != nil {
logger.Printf("CPU 采集异常: %v", err) logger.Printf("CPU 采集异常: %v", err)
} else { } else {
report.CPU = stat report.CPU = stat
} }
// 内存
if stat, err := memCollector.Collect(); err != nil { if stat, err := memCollector.Collect(); err != nil {
logger.Printf("内存采集异常: %v", err) logger.Printf("内存采集异常: %v", err)
} else { } else {
report.Memory = stat report.Memory = stat
} }
// Swap(仅当系统配置了 swap 才上报)
if swap, err := swapCollector.Collect(); err != nil { if swap, err := swapCollector.Collect(); err != nil {
logger.Printf("Swap 采集异常: %v", err) logger.Printf("Swap 采集异常: %v", err)
} else if swap != nil { } else if swap != nil {
report.Swap = swap report.Swap = swap
} }
// 磁盘
if disks, err := diskCollector.Collect(); err != nil { if disks, err := diskCollector.Collect(); err != nil {
logger.Printf("磁盘采集异常: %v", err) logger.Printf("磁盘采集异常: %v", err)
} else { } else {
report.Disks = disks report.Disks = disks
} }
// 监听端口
if ports, err := portCollector.Collect(); err != nil { if ports, err := portCollector.Collect(); err != nil {
logger.Printf("端口采集异常: %v", err) logger.Printf("端口采集异常: %v", err)
} else { } else {
report.Ports = ports report.Ports = ports
} }
// 网络
if netStat, err := netCollector.Collect(); err != nil { if netStat, err := netCollector.Collect(); err != nil {
logger.Printf("网络采集异常: %v", err) logger.Printf("网络采集异常: %v", err)
} else { } else {
report.Network = netStat report.Network = netStat
} }
// 磁盘 IO
if dioStat, err := diskIOCollector.Collect(); err != nil { if dioStat, err := diskIOCollector.Collect(); err != nil {
logger.Printf("磁盘IO采集异常: %v", err) logger.Printf("磁盘IO采集异常: %v", err)
} else { } else {
report.DiskIO = dioStat report.DiskIO = dioStat
} }
if procs, err := procTopCollector.Collect(cfg.TopN); err != nil {
// 进程 TOP N
if procs, err := procTopCollector.Collect(*topN); err != nil {
logger.Printf("进程TOP采集异常: %v", err) logger.Printf("进程TOP采集异常: %v", err)
} else { } else {
report.ProcessTop = procs report.ProcessTop = procs
} }
if fdStat, err := fdCollector.Collect(); err != nil {
// 系统 FD 句柄
if fdStat, err := collectFD(); err != nil {
logger.Printf("FD采集异常: %v", err) logger.Printf("FD采集异常: %v", err)
} else { } else {
report.FD = fdStat report.FD = fdStat
} }
if csStat, err := csCollector.Collect(); err != nil {
// 上下文切换 // 静默跳过
if csStat, err := collectContextSwitches(); err != nil {
// 静默跳过(某些内核/容器环境不可用)
} else { } else {
report.ContextSwitches = csStat report.ContextSwitches = csStat
} }
// v2.1 健康探针(使用缓存配置)
if len(probeConfigs) > 0 {
report.ProbeResults = healthProbeCollector.Execute(ctx, probeConfigs)
}
// Agent 自检心跳 // Agent 自检心跳
reportSeq++ reportSeq++
var memStats runtime.MemStats var memStats runtime.MemStats
@@ -196,17 +199,19 @@ func main() {
report.Heartbeat = model.AgentHeartbeat{ report.Heartbeat = model.AgentHeartbeat{
Version: version, Version: version,
UptimeSec: sysInfo.UptimeSeconds, UptimeSec: sysInfo.UptimeSeconds,
SelfCPU: 0, // TODO: self-CPU via /proc/self/stat SelfCPU: 0,
SelfRSSMB: float64(memStats.Alloc) / 1024.0 / 1024.0, SelfRSSMB: float64(memStats.Alloc) / 1024.0 / 1024.0,
ReportSeq: reportSeq, ReportSeq: reportSeq,
} }
// 上报 // 上报指标(纯上报,不解析响应)
if err := rep.Send(ctx, report); err != nil { if err := rep.Send(ctx, report); err != nil {
logger.Printf("上报失败: %v", err) logger.Printf("上报失败: %v", err)
return return
} }
logger.Printf("上报成功: cpu=%.1f%% mem=%.1f%% swap=%.1f%% disks=%d ports=%d procs=%d fd=%d/%d cs=%.0f/s",
probeCount := len(report.ProbeResults)
logger.Printf("上报成功: cpu=%.1f%% mem=%.1f%% swap=%.1f%% disks=%d ports=%d procs=%d fd=%d/%d cs=%.0f/s probes=%d",
report.CPU.UsagePercent, report.CPU.UsagePercent,
report.Memory.UsagePercent, report.Memory.UsagePercent,
func() float64 { func() float64 {
@@ -215,9 +220,7 @@ func main() {
} }
return 0 return 0
}(), }(),
len(report.Disks), len(report.Disks), len(report.Ports), len(report.ProcessTop),
len(report.Ports),
len(report.ProcessTop),
func() int { func() int {
if report.FD != nil { if report.FD != nil {
return report.FD.Allocated return report.FD.Allocated
@@ -236,10 +239,11 @@ func main() {
} }
return 0 return 0
}(), }(),
probeCount,
) )
} }
// pollAndExecute 任务轮询 // ═══ pollAndExecute 任务轮询 ═══
pollAndExecute := func() { pollAndExecute := func() {
tasks, err := taskClient.Poll(ctx) tasks, err := taskClient.Poll(ctx)
if err != nil { if err != nil {
@@ -249,46 +253,59 @@ func main() {
logger.Printf("任务轮询失败: %v", err) logger.Printf("任务轮询失败: %v", err)
return return
} }
if len(tasks) == 0 { if len(tasks) > 0 {
return logger.Printf("拉取到 %d 个待执行任务", len(tasks))
} for _, t := range tasks {
logger.Printf("拉取到 %d 个待执行任务", len(tasks))
for _, t := range tasks {
if ctx.Err() != nil {
return
}
result := taskExecutor.ExecuteTask(ctx, t)
if err := taskClient.Submit(ctx, result); err != nil {
if ctx.Err() != nil { if ctx.Err() != nil {
return return
} }
logger.Printf("任务 %s 结果回传失败: %v", t.TaskID, err) result := taskExecutor.ExecuteTask(ctx, t)
} else { if err := taskClient.Submit(ctx, result); err != nil {
logger.Printf("任务 %s 结果回传成功 status=%s", t.TaskID, result.Status) logger.Printf("任务 %s 结果回传失败: %v", t.TaskID, err)
} else {
logger.Printf("任务 %s 结果回传成功 status=%s", t.TaskID, result.Status)
}
} }
} }
} }
// ═══ 任务轮询协程(5s 间隔,不受 -interval 影响)═══ // ═══ pullProbes 探针配置拉取(v2.1 独立于上报)═══
const taskPollInterval = 5 * time.Second pullProbes := func() {
configs, err := taskClient.PollProbes(ctx)
if err != nil {
if ctx.Err() != nil {
return
}
logger.Printf("探针配置拉取失败: %v", err)
return
}
if len(configs) > 0 {
probeConfigs = configs
}
}
// ═══ 任务轮询 + 探针拉取协程(5s 间隔)═══
const pollInterval = 5 * time.Second
go func() { go func() {
pollAndExecute() pollAndExecute()
ticker := time.NewTicker(taskPollInterval) pullProbes()
ticker := time.NewTicker(pollInterval)
defer ticker.Stop() defer ticker.Stop()
for { for {
select { select {
case <-ticker.C: case <-ticker.C:
pollAndExecute() pollAndExecute()
pullProbes()
case <-ctx.Done(): case <-ctx.Done():
return return
} }
} }
}() }()
// ═══ 主循环:启动先 sleep jitter,再按 interval 采集 ═══ // ═══ 主循环:采集 + 上报 ═══
time.Sleep(startupJitter) time.Sleep(startupJitter)
collectAndReport() collectAndReport()
ticker := time.NewTicker(time.Duration(*interval) * time.Second) ticker := time.NewTicker(time.Duration(cfg.Interval) * time.Second)
defer ticker.Stop() defer ticker.Stop()
for { for {
select { select {
@@ -301,17 +318,6 @@ func main() {
} }
} }
// maskToken 对令牌做脱敏处理
func maskToken(token string) string {
if token == "" {
return "(empty)"
}
if len(token) <= 4 {
return "****"
}
return token[:4] + "****"
}
// detectLocalIP 探测本机主 IP // detectLocalIP 探测本机主 IP
func detectLocalIP() string { func detectLocalIP() string {
conn, err := net.Dial("udp", "8.8.8.8:53") conn, err := net.Dial("udp", "8.8.8.8:53")
@@ -337,73 +343,3 @@ func detectLocalIP() string {
} }
return "unknown" 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
}
+1
View File
@@ -22,6 +22,7 @@ type Report struct {
FD *SocketFDStat `json:"fd,omitempty"` // 系统文件句柄 FD *SocketFDStat `json:"fd,omitempty"` // 系统文件句柄
ContextSwitches *ContextSwitchStat `json:"context_switches,omitempty"` // 上下文切换 ContextSwitches *ContextSwitchStat `json:"context_switches,omitempty"` // 上下文切换
Heartbeat AgentHeartbeat `json:"heartbeat"` // Agent 自检 Heartbeat AgentHeartbeat `json:"heartbeat"` // Agent 自检
ProbeResults []ProbeResult `json:"probe_results,omitempty"` // v2.1 健康探针结果
} }
// CPUStat CPU 相关指标 // CPUStat CPU 相关指标
+28
View File
@@ -56,3 +56,31 @@ type SystemStat struct {
UlimitNofile int64 `json:"ulimit_nofile"` // ulimit -n (最大文件描述符) UlimitNofile int64 `json:"ulimit_nofile"` // ulimit -n (最大文件描述符)
UlimitNproc int64 `json:"ulimit_nproc"` // ulimit -u (最大进程数) UlimitNproc int64 `json:"ulimit_nproc"` // ulimit -u (最大进程数)
} }
// ──── v2.1 健康探针 ────
// ProbeConfig 后端下发的探针配置
type ProbeConfig struct {
ID uint `json:"id"`
Name string `json:"name"`
ProbeType string `json:"probe_type"` // http_get / tcp_socket / exec_command
HTTPURL string `json:"http_url,omitempty"`
HTTPMethod string `json:"http_method,omitempty"`
HTTPExpectCode int `json:"http_expect_code,omitempty"`
HTTPExpectBody string `json:"http_expect_body,omitempty"`
TCPHost string `json:"tcp_host,omitempty"`
TCPPort int `json:"tcp_port,omitempty"`
ExecCommand string `json:"exec_command,omitempty"`
Timeout int `json:"timeout"`
}
// ProbeResult 探针执行结果
type ProbeResult struct {
ProbeID uint `json:"probe_id"`
Name string `json:"name"`
ProbeType string `json:"probe_type"`
Status string `json:"status"` // up / down
LatencyMs int `json:"latency_ms"`
StatusCode int `json:"status_code,omitempty"`
Message string `json:"message,omitempty"`
}
+10 -6
View File
@@ -1,10 +1,14 @@
package model package model
// ProcessStat 单个进程统计(TOP N 采集) // ProcessStat 单个进程统计(TOP N 采集)— 阿里云风格
type ProcessStat struct { type ProcessStat struct {
PID int `json:"pid"` PID int `json:"pid"`
Name string `json:"name"` Name string `json:"name"` // 进程名(comm)
MemoryMB float64 `json:"memory_mb"` // 物理内存 (MB) Cmdline string `json:"cmdline"` // 完整命令行
FDCount int `json:"fd_count"` // 打开的文件句柄数 User string `json:"user"` // 运行用户
Threads int `json:"threads"` // 线程数 CPUPct float64 `json:"cpu_pct"` // CPU 使用率 (%)
MemPct float64 `json:"mem_pct"` // 内存使用率 (%)
MemoryMB float64 `json:"memory_mb"` // 物理内存 (MB)
FDCount int `json:"fd_count"` // 打开的文件句柄数
Threads int `json:"threads"` // 线程数
} }
+14 -98
View File
@@ -1,122 +1,38 @@
// Package reporter 实现 AIOps Agent 向后端上报采集结果的能力。 // Package reporter 实现 AIOps Agent 向后端上报采集结果的能力。
// 通过 HTTP POST 将指标 JSON 发送至 {server}/api/v1/agent/report, // 通过 HTTP POST 将指标 JSON 发送至 {server}/api/v1/agent/report。
// 并在请求头携带 Bearer Token 完成鉴权。失败时进行有限次指数退避重试。 // 共享 client 处理鉴权 + 重试,Reporter 仅负责序列化和路径选择。
// //
// 署名 Logan.Li // 署名 Logan.Li
package reporter package reporter
import ( import (
"bytes"
"context" "context"
"encoding/json"
"fmt" "fmt"
"io"
"log" "log"
"net/http"
"strings"
"time"
"ezops/agent/client"
"ezops/agent/model" "ezops/agent/model"
) )
const ( const reportPath = "/api/v1/agent/report"
reportPath = "/api/v1/agent/report" // 后端上报接口路径
defaultTimeout = 10 * time.Second // 单次 HTTP 请求超时
maxRetries = 3 // 最大重试次数(含首次)
userAgent = "aiops-agent/1.0" // 标识 Agent 版本,便于后端识别
)
// Reporter 负责将采集报告上报至 AIOps 后端 // Reporter 负责将采集报告上报至 AIOps 后端
type Reporter struct { type Reporter struct {
server string client *client.Client
token string logger *log.Logger
httpClient *http.Client
logger *log.Logger
} }
// New 创建 Reporter 实例。 // New 创建 Reporter 实例。
// - server: AIOps 后端地址(如 http://localhost:8080) // - cli: 共享 HTTP 客户端
// - token: Bearer 认证令牌 // - logger: 日志输出
func New(server, token string, logger *log.Logger) *Reporter { func New(cli *client.Client, logger *log.Logger) *Reporter {
return &Reporter{ return &Reporter{client: cli, logger: logger}
server: normalizeServer(server),
token: token,
httpClient: &http.Client{Timeout: defaultTimeout},
logger: logger,
}
} }
// normalizeServer 去除尾部斜杠,保证 URL 拼接正确 // Send 将报告 POST 到后端。纯上报,不解析响应。
func normalizeServer(server string) string {
return strings.TrimRight(server, "/")
}
// Send 将报告序列化为 JSON 并 POST 到后端,失败时进行有限次重试。
// 重试过程可被传入的上下文取消打断(如 Agent 收到退出信号时)。
func (r *Reporter) Send(ctx context.Context, report *model.Report) error { func (r *Reporter) Send(ctx context.Context, report *model.Report) error {
payload, err := json.Marshal(report) if err := r.client.DoJSON(ctx, "POST", reportPath, report, nil); err != nil {
if err != nil { return fmt.Errorf("上报失败: %w", err)
return fmt.Errorf("序列化上报数据失败: %w", err)
}
url := r.server + reportPath
var lastErr error
for attempt := 1; attempt <= maxRetries; attempt++ {
// 每次重试都重建请求,避免请求体被消费后无法复用
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload))
if err != nil {
return fmt.Errorf("构造请求失败: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+r.token)
req.Header.Set("User-Agent", userAgent)
// 上下文已取消则立即返回,避免无意义的网络请求
if err := ctx.Err(); err != nil {
return err
}
resp, err := r.httpClient.Do(req)
if err != nil {
lastErr = err
r.logger.Printf("上报请求失败(第%d次): %v", attempt, err)
if !r.sleepWithCtx(ctx, attempt) {
return ctx.Err()
}
continue
}
// 读取并丢弃响应体以便复用连接
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return nil // 上报成功
}
lastErr = fmt.Errorf("上报失败,后端返回 HTTP %d", resp.StatusCode)
// 4xx(除 429 外)通常为客户端错误(如鉴权失败),重试无意义
if resp.StatusCode >= 400 && resp.StatusCode < 500 && resp.StatusCode != http.StatusTooManyRequests {
return lastErr
}
r.logger.Printf("上报响应异常(第%d次): HTTP %d", attempt, resp.StatusCode)
if !r.sleepWithCtx(ctx, attempt) {
return ctx.Err()
}
}
return lastErr
}
// sleepWithCtx 带线性退避的休眠,可被上下文取消打断。
// 返回 true 表示休眠结束可继续重试,false 表示被取消。
func (r *Reporter) sleepWithCtx(ctx context.Context, attempt int) bool {
timer := time.NewTimer(time.Duration(attempt) * time.Second)
defer timer.Stop()
select {
case <-timer.C:
return true
case <-ctx.Done():
return false
} }
return nil
} }
+36 -109
View File
@@ -1,149 +1,76 @@
// Package task 的 client.go 实现与后端任务接口的 HTTP 交互。 // Package task 的 client.go 实现与后端任务/探针接口的 HTTP 交互。
// 涵盖拉取待执行任务(poll)与回传执行结果(submit result)。 // 涵盖拉取待执行任务(poll)、回传执行结果(submit result)、拉取探针配置(poll probes)。
// 仅依赖 Go 标准库,失败时返回错误,由调用方决定重试策略。 // 通过共享 client 处理鉴权 + 重试,本文件仅负责业务路径和数据模型。
// //
// 署名 Logan.Li // 署名 Logan.Li
package task package task
import ( import (
"bytes"
"context" "context"
"encoding/json"
"fmt" "fmt"
"io"
"log" "log"
"net/http"
"net/url" "net/url"
"strings"
"time" "ezops/agent/client"
"ezops/agent/model"
) )
const ( const (
pollPath = "/api/v1/agent/tasks/poll" // 任务轮询接口 pollPath = "/api/v1/agent/tasks/poll"
resultPathFmt = "/api/v1/agent/tasks/%s/result" // 结果回传接口(含 task_id 占位) resultPathFmt = "/api/v1/agent/tasks/%s/result"
httpTimeout = 10 * time.Second // 单次 HTTP 请求超时 probePath = "/api/v1/agent/health-probes/config"
clientUserAgent = "aiops-agent/1.0" // 标识 Agent 版本
) )
// Client 负责与后端任务接口通信。 // Client 负责与后端任务/探针接口通信。
type Client struct { type Client struct {
server string client *client.Client
token string hostname string
hostname string logger *log.Logger
httpClient *http.Client
logger *log.Logger
} }
// NewClient 创建任务客户端实例。 // NewClient 创建任务客户端实例。
// - server: AIOps 后端地址(如 http://localhost:8080) func NewClient(cli *client.Client, hostname string, logger *log.Logger) *Client {
// - token: Bearer 认证令牌 return &Client{client: cli, hostname: hostname, logger: logger}
// - hostname: 本机主机名,用于后端筛选该主机待执行的任务
func NewClient(server, token, hostname string, logger *log.Logger) *Client {
return &Client{
server: strings.TrimRight(server, "/"),
token: token,
hostname: hostname,
httpClient: &http.Client{Timeout: httpTimeout},
logger: logger,
}
} }
// Poll 拉取当前主机待执行的任务列表。 // Poll 拉取当前主机待执行的任务列表。
// 返回空切片表示暂无任务(后端返回空数组或 HTTP 204)。
func (c *Client) Poll(ctx context.Context) ([]Task, error) { func (c *Client) Poll(ctx context.Context) ([]Task, error) {
// 构造带 hostname 查询参数的 URL path := pollPath + "?hostname=" + url.QueryEscape(c.hostname)
u := c.server + pollPath + "?hostname=" + url.QueryEscape(c.hostname)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return nil, fmt.Errorf("构造轮询请求失败: %w", err)
}
c.attachHeaders(req)
// 上下文已取消则直接返回,避免无意义请求
if err := ctx.Err(); err != nil {
return nil, err
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("轮询请求失败: %w", err)
}
defer resp.Body.Close()
// 204 No Content 或 200 空体均视为无任务
if resp.StatusCode == http.StatusNoContent {
return nil, nil
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return nil, fmt.Errorf("轮询响应异常: HTTP %d, body=%s", resp.StatusCode, string(body))
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取轮询响应失败: %w", err)
}
// 空响应体视为无任务
if len(bytes.TrimSpace(body)) == 0 {
return nil, nil
}
var wrapper struct { var wrapper struct {
Code int `json:"code"` Code int `json:"code"`
Message string `json:"message"` Message string `json:"message"`
Data []Task `json:"data"` Data []Task `json:"data"`
} }
if err := json.Unmarshal(body, &wrapper); err != nil { if err := c.client.DoJSON(ctx, "GET", path, nil, &wrapper); err != nil {
// 兼容裸数组格式 return nil, fmt.Errorf("任务轮询失败: %w", err)
var tasks []Task
if err2 := json.Unmarshal(body, &tasks); err2 != nil {
return nil, fmt.Errorf("解析轮询响应失败: %w", err)
}
return tasks, nil
} }
return wrapper.Data, nil return wrapper.Data, nil
} }
// Submit 将任务执行结果回传至后端。 // Submit 将任务执行结果回传至后端。
func (c *Client) Submit(ctx context.Context, res Result) error { func (c *Client) Submit(ctx context.Context, res Result) error {
payload, err := json.Marshal(res) path := fmt.Sprintf(resultPathFmt, url.PathEscape(res.TaskID))
if err != nil { if err := c.client.DoJSON(ctx, "POST", path, res, nil); err != nil {
return fmt.Errorf("序列化任务结果失败: %w", err) return fmt.Errorf("任务结果回传失败: %w", err)
}
u := c.server + fmt.Sprintf(resultPathFmt, url.PathEscape(res.TaskID))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewReader(payload))
if err != nil {
return fmt.Errorf("构造回传请求失败: %w", err)
}
c.attachHeaders(req)
if err := ctx.Err(); err != nil {
return err
}
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("回传请求失败: %w", err)
}
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("回传响应异常: HTTP %d", resp.StatusCode)
} }
return nil return nil
} }
// attachHeaders 统一设置公共请求头(鉴权、内容类型、UA)。 // PollProbes 拉取本机健康探针配置(v2.1)。
func (c *Client) attachHeaders(req *http.Request) { func (c *Client) PollProbes(ctx context.Context) ([]model.ProbeConfig, error) {
req.Header.Set("Content-Type", "application/json") path := probePath + "?hostname=" + url.QueryEscape(c.hostname)
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", clientUserAgent) var wrapper struct {
if c.token != "" { Code int `json:"code"`
req.Header.Set("Authorization", "Bearer "+c.token) Message string `json:"message"`
Data []model.ProbeConfig `json:"data"`
} }
if err := c.client.DoJSON(ctx, "GET", path, nil, &wrapper); err != nil {
return nil, fmt.Errorf("探针配置拉取失败: %w", err)
}
if wrapper.Data == nil {
return []model.ProbeConfig{}, nil
}
return wrapper.Data, nil
} }
+3 -2
View File
@@ -227,8 +227,9 @@ func (h *AgentHandler) ReceiveReport(c *gin.Context) {
h.writeHotMetric(c, "metrics:load1:"+req.Hostname, req.CPU.Load1, timestamp, ttl, reportTime) h.writeHotMetric(c, "metrics:load1:"+req.Hostname, req.CPU.Load1, timestamp, ttl, reportTime)
} }
// 保存成功 // 保存成功 — 响应中携带探针配置给 Agent
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "ok"}) resp := h.makeProbeConfigResponse(req.Hostname, 200, "ok", req.ProbeResults)
c.JSON(http.StatusOK, resp)
} }
// writeHotMetric 将单个指标写入 Redis 有序集合并维护 TTL 和过期数据清理 // writeHotMetric 将单个指标写入 Redis 有序集合并维护 TTL 和过期数据清理
@@ -33,12 +33,11 @@ func (h *AlertHandler) CreateContact(c *gin.Context) {
return return
} }
contact := models.AlertContact{ contact := models.AlertContact{
Name: req.Name, Name: req.Name,
Phone: req.Phone, Phone: req.Phone,
Email: req.Email, Email: req.Email,
Dingtalk: req.Dingtalk, ChannelType: req.ChannelType,
Feishu: req.Feishu, WebhookURL: req.WebhookURL,
Wechat: req.Wechat,
} }
if err := h.db.Create(&contact).Error; err != nil { if err := h.db.Create(&contact).Error; err != nil {
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "创建失败"}) c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "创建失败"})
@@ -64,9 +63,8 @@ func (h *AlertHandler) UpdateContact(c *gin.Context) {
if req.Name != "" { updates["name"] = req.Name } if req.Name != "" { updates["name"] = req.Name }
if req.Phone != "" { updates["phone"] = req.Phone } if req.Phone != "" { updates["phone"] = req.Phone }
if req.Email != "" { updates["email"] = req.Email } if req.Email != "" { updates["email"] = req.Email }
if req.Dingtalk != "" { updates["dingtalk"] = req.Dingtalk } if req.ChannelType != "" { updates["channel_type"] = req.ChannelType }
if req.Feishu != "" { updates["feishu"] = req.Feishu } if req.WebhookURL != "" { updates["webhook_url"] = req.WebhookURL }
if req.Wechat != "" { updates["wechat"] = req.Wechat }
h.db.Model(&contact).Updates(updates) h.db.Model(&contact).Updates(updates)
h.db.First(&contact, id) h.db.First(&contact, id)
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "更新成功", Data: contact}) c.JSON(http.StatusOK, models.Response{Code: 200, Message: "更新成功", Data: contact})
@@ -0,0 +1,308 @@
package handlers
import (
"net/http"
"strconv"
"time"
"ezops/internal/models"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// ────────────────────────────────────────
// HealthProbe CRUD(健康探针配置管理)
// ────────────────────────────────────────
// ListHealthProbes GET /api/v1/agent/health-probes
func (h *AgentHandler) ListHealthProbes(c *gin.Context) {
query := h.db.Model(&models.HealthProbe{})
if hostname := c.Query("hostname"); hostname != "" {
query = query.Where("hostname = ?", hostname)
}
if enabled := c.Query("enabled"); enabled != "" {
if enabled == "true" {
query = query.Where("enabled = ?", true)
} else if enabled == "false" {
query = query.Where("enabled = ?", false)
}
}
var probes []models.HealthProbe
query.Order("created_at DESC").Limit(500).Find(&probes)
if probes == nil {
probes = []models.HealthProbe{}
}
c.JSON(http.StatusOK, models.Response{
Code: 200,
Message: "success",
Data: probes,
})
}
// CreateHealthProbe POST /api/v1/agent/health-probes
func (h *AgentHandler) CreateHealthProbe(c *gin.Context) {
var probe models.HealthProbe
if err := c.ShouldBindJSON(&probe); err != nil {
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "请求参数错误: " + err.Error()})
return
}
if probe.Name == "" {
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "探针名称不能为空"})
return
}
if probe.Hostname == "" {
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "目标主机不能为空"})
return
}
if probe.ProbeType == "" {
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "探针类型不能为空"})
return
}
// 验证探针类型配置
switch probe.ProbeType {
case models.ProbeTypeHTTP:
if probe.HTTPURL == "" {
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "HTTP 探针必须填写 URL"})
return
}
case models.ProbeTypeTCP:
if probe.TCPHost == "" || probe.TCPPort == 0 {
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "TCP 探针必须填写 Host 和 Port"})
return
}
case models.ProbeTypeExec:
if probe.ExecCommand == "" {
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "Exec 探针必须填写执行命令"})
return
}
default:
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "不支持的探针类型: " + string(probe.ProbeType)})
return
}
probe.ID = 0
probe.Enabled = true
if probe.Timeout == 0 {
probe.Timeout = 10
}
if probe.Interval == 0 {
probe.Interval = 60
}
if err := h.db.Create(&probe).Error; err != nil {
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "创建失败: " + err.Error()})
return
}
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "创建成功", Data: probe})
}
// UpdateHealthProbe PUT /api/v1/agent/health-probes/:id
func (h *AgentHandler) UpdateHealthProbe(c *gin.Context) {
id := c.Param("id")
var probe models.HealthProbe
if err := h.db.First(&probe, id).Error; err != nil {
c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "探针不存在"})
return
}
var input models.HealthProbe
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "参数错误: " + err.Error()})
return
}
updates := map[string]interface{}{}
if input.Name != "" {
updates["name"] = input.Name
}
if input.Hostname != "" {
updates["hostname"] = input.Hostname
}
if input.ProbeType != "" {
updates["probe_type"] = input.ProbeType
}
if input.HTTPURL != "" {
updates["http_url"] = input.HTTPURL
}
if input.TCPHost != "" {
updates["tcp_host"] = input.TCPHost
}
if input.TCPPort != 0 {
updates["tcp_port"] = input.TCPPort
}
if input.ExecCommand != "" {
updates["exec_command"] = input.ExecCommand
}
if input.Timeout > 0 {
updates["timeout"] = input.Timeout
}
if input.Interval > 0 {
updates["interval"] = input.Interval
}
// enabled 布尔处理:显式传入
updates["enabled"] = input.Enabled
if input.HTTPMethod != "" {
updates["http_method"] = input.HTTPMethod
}
if input.HTTPHeaders != "" {
updates["http_headers"] = input.HTTPHeaders
}
if input.HTTPExpectCode > 0 {
updates["http_expect_code"] = input.HTTPExpectCode
}
if input.HTTPExpectBody != "" {
updates["http_expect_body"] = input.HTTPExpectBody
}
if input.Description != "" {
updates["description"] = input.Description
}
if err := h.db.Model(&probe).Updates(updates).Error; err != nil {
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "更新失败: " + err.Error()})
return
}
h.db.First(&probe, id)
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "更新成功", Data: probe})
}
// DeleteHealthProbe DELETE /api/v1/agent/health-probes/:id
func (h *AgentHandler) DeleteHealthProbe(c *gin.Context) {
id := c.Param("id")
if err := h.db.Delete(&models.HealthProbe{}, id).Error; err != nil {
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "删除失败"})
return
}
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "删除成功"})
}
// GetHealthProbesForAgent Agent 拉取本机探针配置(Agent 调用,无需 JWT)
// GET /api/v1/agent/health-probes/config?hostname=xxx
func (h *AgentHandler) GetHealthProbesForAgent(c *gin.Context) {
hostname := c.Query("hostname")
if hostname == "" {
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "hostname 不能为空"})
return
}
var probes []models.HealthProbe
h.db.Where("hostname = ? AND enabled = ?", hostname, true).
Order("id ASC").
Find(&probes)
if probes == nil {
probes = []models.HealthProbe{}
}
c.JSON(http.StatusOK, models.Response{
Code: 200,
Message: "success",
Data: probes,
})
}
// ────────────────────────────────────────
// ReceiveReport 增强:处理探针结果 + 返回配置
// ────────────────────────────────────────
// processProbeResults 处理 Agent 上报的探针结果,更新探针状态
func (h *AgentHandler) processProbeResults(hostname string, results []models.HealthProbeResult) {
now := time.Now()
for _, r := range results {
updates := map[string]interface{}{
"last_status": string(r.Status),
"last_checked_at": now,
"last_latency_ms": r.LatencyMs,
}
if r.Status == models.ProbeStatusDown {
// 失败时增加连续失败计数
h.db.Model(&models.HealthProbe{}).
Where("id = ? AND hostname = ?", r.ProbeID, hostname).
UpdateColumn("fail_count", gorm.Expr("fail_count + 1"))
} else {
// 成功时清零
updates["fail_count"] = 0
}
h.db.Model(&models.HealthProbe{}).
Where("id = ? AND hostname = ?", r.ProbeID, hostname).
Updates(updates)
// ═══ 探针告警:Agent 本地探测 down → 平台创建告警事件 ═══
if r.Status == models.ProbeStatusDown {
// 检查是否存在未恢复的同探针告警
var existingAlert models.AgentAlert
h.db.Where("rule_name = ? AND hostname = ? AND metric = ? AND resolved = ?",
r.Name, hostname, "health_probe", false).
First(&existingAlert)
if existingAlert.ID == 0 {
// 获取 fail_count 判断严重级别
var probe models.HealthProbe
if err := h.db.First(&probe, r.ProbeID).Error; err == nil {
severity := "warning"
if probe.FailCount >= 3 {
severity = "critical"
}
alert := models.AgentAlert{
RuleName: r.Name,
Hostname: hostname,
Metric: "health_probe",
Severity: severity,
Message: "健康探针 [" + r.Name + "] 检测异常: " + r.Message + " (类型=" + r.ProbeType + ", 延迟=" + strconv.Itoa(r.LatencyMs) + "ms)",
Threshold: 0,
Resolved: false,
}
h.db.Create(&alert)
}
}
} else {
// 探针恢复正常:自动恢复告警
h.db.Model(&models.AgentAlert{}).
Where("rule_name = ? AND hostname = ? AND metric = ? AND resolved = ?",
r.Name, hostname, "health_probe", false).
Updates(map[string]interface{}{
"resolved": true,
"resolved_at": now,
})
}
}
}
// probeConfigResponse ReceiveReport 的响应结构(含探针配置)
type probeConfigResponse struct {
models.Response
ProbeConfigs []models.HealthProbe `json:"probe_configs,omitempty"`
}
// makeProbeConfigResponse 构建带探针配置的响应
func (h *AgentHandler) makeProbeConfigResponse(hostname string, respCode int, respMsg string, probeResults []models.HealthProbeResult) probeConfigResponse {
// 处理上报的探针结果
if len(probeResults) > 0 {
h.processProbeResults(hostname, probeResults)
}
// 拉取该主机的新探针配置(供 Agent 下次执行)
var probes []models.HealthProbe
h.db.Where("hostname = ? AND enabled = ?", hostname, true).
Order("id ASC").
Find(&probes)
if probes == nil {
probes = []models.HealthProbe{}
}
return probeConfigResponse{
Response: models.Response{
Code: respCode,
Message: respMsg,
},
ProbeConfigs: probes,
}
}
+8
View File
@@ -674,6 +674,8 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
agentGroup.POST("/tasks/:task_id/result", taskHandler.SubmitResult) agentGroup.POST("/tasks/:task_id/result", taskHandler.SubmitResult)
agentGroup.POST("/aggregate", agentHandler.AggregateMetrics) agentGroup.POST("/aggregate", agentHandler.AggregateMetrics)
agentGroup.POST("/exec", agentInstallHandler.RemoteExec) agentGroup.POST("/exec", agentInstallHandler.RemoteExec)
// 健康探针:Agent 拉取本机配置(无需 JWT)
agentGroup.GET("/health-probes/config", agentHandler.GetHealthProbesForAgent)
} }
// Agent 数据查询 + 任务管理(需要 JWT) // Agent 数据查询 + 任务管理(需要 JWT)
agentQuery := authorized.Group("/agent") agentQuery := authorized.Group("/agent")
@@ -702,6 +704,12 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
agentQuery.POST("/uninstall", agentInstallHandler.UninstallAgent) agentQuery.POST("/uninstall", agentInstallHandler.UninstallAgent)
agentQuery.POST("/batch-uninstall", agentInstallHandler.BatchUninstallAgent) agentQuery.POST("/batch-uninstall", agentInstallHandler.BatchUninstallAgent)
agentQuery.POST("/batch-upgrade", agentInstallHandler.BatchUpgradeAgent) agentQuery.POST("/batch-upgrade", agentInstallHandler.BatchUpgradeAgent)
// 健康探针管理(CRUD)
agentQuery.GET("/health-probes", agentHandler.ListHealthProbes)
agentQuery.POST("/health-probes", agentHandler.CreateHealthProbe)
agentQuery.PUT("/health-probes/:id", agentHandler.UpdateHealthProbe)
agentQuery.DELETE("/health-probes/:id", agentHandler.DeleteHealthProbe)
} }
// Agent 二进制下载(无需鉴权,Agent 升级时使用) // Agent 二进制下载(无需鉴权,Agent 升级时使用)
+6 -1
View File
@@ -87,7 +87,8 @@ type AgentReportRequest struct {
ProcessTop []ProcessMetric `json:"process_top,omitempty"` ProcessTop []ProcessMetric `json:"process_top,omitempty"`
FD *FDMetric `json:"fd,omitempty"` FD *FDMetric `json:"fd,omitempty"`
ContextSwitches *CSMetric `json:"context_switches,omitempty"` ContextSwitches *CSMetric `json:"context_switches,omitempty"`
Heartbeat HeartbeatMetric `json:"heartbeat"` Heartbeat HeartbeatMetric `json:"heartbeat"`
ProbeResults []HealthProbeResult `json:"probe_results,omitempty"` // v2.1 健康探针结果
} }
// ──── v2.0 新增上报类型 ──── // ──── v2.0 新增上报类型 ────
@@ -101,6 +102,10 @@ type SwapMetric struct {
type ProcessMetric struct { type ProcessMetric struct {
PID int `json:"pid"` PID int `json:"pid"`
Name string `json:"name"` Name string `json:"name"`
Cmdline string `json:"cmdline"`
User string `json:"user"`
CPUPct float64 `json:"cpu_pct"`
MemPct float64 `json:"mem_pct"`
MemoryMB float64 `json:"memory_mb"` MemoryMB float64 `json:"memory_mb"`
FDCount int `json:"fd_count"` FDCount int `json:"fd_count"`
Threads int `json:"threads"` Threads int `json:"threads"`
+19 -22
View File
@@ -400,16 +400,15 @@ type AcknowledgeAlertRequest struct {
// ============================================ // ============================================
type AlertContact struct { type AlertContact struct {
ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"` ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"`
Name string `gorm:"type:varchar(128);not null;comment:联系人姓名" json:"name"` Name string `gorm:"type:varchar(128);not null;comment:联系人姓名" json:"name"`
Phone string `gorm:"type:varchar(32);comment:手机号" json:"phone,omitempty"` Phone string `gorm:"type:varchar(32);comment:手机号" json:"phone,omitempty"`
Email string `gorm:"type:varchar(128);comment:邮箱" json:"email,omitempty"` Email string `gorm:"type:varchar(128);comment:邮箱" json:"email,omitempty"`
Dingtalk string `gorm:"type:varchar(256);comment:钉钉webhook地址" json:"dingtalk,omitempty"` ChannelType string `gorm:"type:varchar(32);default:'feishu';comment:渠道类型: feishu/dingtalk/wechat/webhook" json:"channel_type,omitempty"`
Feishu string `gorm:"type:varchar(256);comment:飞书webhook地址" json:"feishu,omitempty"` WebhookURL string `gorm:"type:varchar(512);comment:Webhook地址" json:"webhook_url,omitempty"`
Wechat string `gorm:"type:varchar(256);comment:企业微信webhook地址" json:"wechat,omitempty"` CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"`
CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"` UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"`
UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"` DeletedAt gorm.DeletedAt `gorm:"index;comment:删除时间" json:"deleted_at,omitempty"`
DeletedAt gorm.DeletedAt `gorm:"index;comment:删除时间" json:"deleted_at,omitempty"`
} }
func (AlertContact) TableName() string { func (AlertContact) TableName() string {
@@ -436,22 +435,20 @@ func (AlertContactGroup) TableName() string {
// CreateContactRequest 创建联系人请求 // CreateContactRequest 创建联系人请求
type CreateContactRequest struct { type CreateContactRequest struct {
Name string `json:"name" binding:"required"` Name string `json:"name" binding:"required"`
Phone string `json:"phone,omitempty"` Phone string `json:"phone,omitempty"`
Email string `json:"email,omitempty"` Email string `json:"email,omitempty"`
Dingtalk string `json:"dingtalk,omitempty"` ChannelType string `json:"channel_type" binding:"required,oneof=feishu dingtalk wechat webhook"`
Feishu string `json:"feishu,omitempty"` WebhookURL string `json:"webhook_url" binding:"required"`
Wechat string `json:"wechat,omitempty"`
} }
// UpdateContactRequest 更新联系人请求 // UpdateContactRequest 更新联系人请求
type UpdateContactRequest struct { type UpdateContactRequest struct {
Name string `json:"name,omitempty"` Name string `json:"name,omitempty"`
Phone string `json:"phone,omitempty"` Phone string `json:"phone,omitempty"`
Email string `json:"email,omitempty"` Email string `json:"email,omitempty"`
Dingtalk string `json:"dingtalk,omitempty"` ChannelType string `json:"channel_type,omitempty" binding:"omitempty,oneof=feishu dingtalk wechat webhook"`
Feishu string `json:"feishu,omitempty"` WebhookURL string `json:"webhook_url,omitempty"`
Wechat string `json:"wechat,omitempty"`
} }
// CreateContactGroupRequest 创建联系组请求 // CreateContactGroupRequest 创建联系组请求
+70
View File
@@ -353,3 +353,73 @@ type HostGroup struct {
func (HostGroup) TableName() string { func (HostGroup) TableName() string {
return "asset_host_groups" return "asset_host_groups"
} }
// ============================================
// HealthProbe 健康探针配置(Agent 本地执行)
// ============================================
// ProbeType 探针类型
type ProbeType string
const (
ProbeTypeHTTP ProbeType = "http_get" // HTTP GET 探测
ProbeTypeTCP ProbeType = "tcp_socket" // TCP 端口连接探测
ProbeTypeExec ProbeType = "exec_command" // 命令执行探测
)
// ProbeStatus 探针结果状态
type ProbeStatus string
const (
ProbeStatusUp ProbeStatus = "up" // 健康
ProbeStatusDown ProbeStatus = "down" // 异常
)
// HealthProbe 健康探针配置表
// 存储在平台 DB,Agent 拉取配置后在目标主机本地执行,结果随心跳上报
type HealthProbe struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
Name string `gorm:"type:varchar(128);not null;index" json:"name"`
Hostname string `gorm:"type:varchar(255);index;not null" json:"hostname"`
ProbeType ProbeType `gorm:"type:varchar(32);not null" json:"probe_type"`
// HTTP 探针配置
HTTPURL string `gorm:"type:varchar(512)" json:"http_url,omitempty"`
HTTPMethod string `gorm:"type:varchar(10);default:GET" json:"http_method,omitempty"`
HTTPHeaders string `gorm:"type:text" json:"http_headers,omitempty"`
HTTPExpectCode int `gorm:"default:200" json:"http_expect_code,omitempty"`
HTTPExpectBody string `gorm:"type:varchar(256)" json:"http_expect_body,omitempty"`
// TCP 探针配置
TCPHost string `gorm:"type:varchar(255)" json:"tcp_host,omitempty"`
TCPPort int `json:"tcp_port,omitempty"`
// Exec 探针配置
ExecCommand string `gorm:"type:text" json:"exec_command,omitempty"`
// 通用配置
Timeout int `gorm:"default:10" json:"timeout"`
Interval int `gorm:"default:60" json:"interval"`
Enabled bool `gorm:"default:true;index" json:"enabled"`
LastStatus ProbeStatus `gorm:"type:varchar(8);default:''" json:"last_status,omitempty"`
LastCheckedAt *time.Time `json:"last_checked_at,omitempty"`
LastLatencyMs int `json:"last_latency_ms"`
FailCount int `json:"fail_count"`
Description string `gorm:"type:varchar(256)" json:"description,omitempty"`
CreatedAt time.Time `gorm:"not null" json:"created_at"`
UpdatedAt time.Time `gorm:"not null" json:"updated_at"`
DeletedAt *time.Time `gorm:"index" json:"deleted_at,omitempty"`
}
// TableName 指定表名
func (HealthProbe) TableName() string {
return "monitor_health_probes"
}
// HealthProbeResult Agent 上报的探针执行结果(作为心跳的一部分)
type HealthProbeResult struct {
ProbeID uint `json:"probe_id"`
Name string `json:"name"`
ProbeType string `json:"probe_type"`
Status ProbeStatus `json:"status"` // up / down
LatencyMs int `json:"latency_ms"` // 响应延迟(ms)
StatusCode int `json:"status_code,omitempty"` // HTTP 状态码
Message string `json:"message,omitempty"` // 错误/附加信息
}
+1
View File
@@ -133,6 +133,7 @@ func AutoMigrate(db *gorm.DB) error {
&models.AlertUpgrade{}, // 告警升级策略 // Logan.Li &models.AlertUpgrade{}, // 告警升级策略 // Logan.Li
&models.AlertUpgradeRecord{}, // 告警升级记录 // Logan.Li &models.AlertUpgradeRecord{}, // 告警升级记录 // Logan.Li
&models.AlertSilence{}, // 告警静默 // Logan.Li &models.AlertSilence{}, // 告警静默 // Logan.Li
&models.HealthProbe{}, // 健康探针配置 // Logan.Li
} }
var lastErr error var lastErr error
@@ -18,9 +18,9 @@ import { useConfirm } from '@/components/ui/confirm-dialog';
import { alertCenterApi, agentApi, alertApi } from '@/services/api'; import { alertCenterApi, agentApi, alertApi } from '@/services/api';
import { import {
type Alert, type AlertRule, mapAlert, getSeverityBadge, getStatusBadge, type Alert, type AlertRule, mapAlert, getSeverityBadge, getStatusBadge,
getChannelTypeLabel,
} from './alert-center-helpers'; } from './alert-center-helpers';
import { AlertDetailDialog, AlertSilenceDialog } from './AlertCenterDetail'; import { AlertDetailDialog, AlertSilenceDialog } from './AlertCenterDetail';
import { AlertChannelsTab } from './AlertChannelsTab';
import { AlertRuleDialog } from '@/components/monitor/AlertRuleDialog'; import { AlertRuleDialog } from '@/components/monitor/AlertRuleDialog';
import { ContactDialog } from './ContactDialog'; import { ContactDialog } from './ContactDialog';
import { ContactGroupDialog } from './ContactGroupDialog'; import { ContactGroupDialog } from './ContactGroupDialog';
@@ -265,7 +265,7 @@ const AlertCenter: React.FC = () => {
<TabsList className="grid w-full grid-cols-4 lg:w-[480px] h-9"> <TabsList className="grid w-full grid-cols-4 lg:w-[480px] h-9">
<TabsTrigger value="alerts" className="text-xs">告警列表</TabsTrigger> <TabsTrigger value="alerts" className="text-xs">告警列表</TabsTrigger>
<TabsTrigger value="rules" className="text-xs">告警规则</TabsTrigger> <TabsTrigger value="rules" className="text-xs">告警规则</TabsTrigger>
<TabsTrigger value="contacts" className="text-xs">通知管理</TabsTrigger> <TabsTrigger value="contacts" className="text-xs">报警联系人</TabsTrigger>
<TabsTrigger value="groups" className="text-xs">报警联系组</TabsTrigger> <TabsTrigger value="groups" className="text-xs">报警联系组</TabsTrigger>
</TabsList> </TabsList>
@@ -501,11 +501,9 @@ const AlertCenter: React.FC = () => {
)} )}
</TabsContent> </TabsContent>
{/* 通知管理 Tab — 渠道 + 联系人合并 */} {/* 报警联系人 Tab */}
<TabsContent value="contacts" className="space-y-4"> <TabsContent value="contacts" className="space-y-4">
<AlertChannelsTab refreshKey={0} /> <div className="flex justify-between items-center">
<div className="border-t border-border pt-4 mt-1">
<div className="flex justify-between items-center mb-3">
<h3 className="text-sm font-medium">报警联系人</h3> <h3 className="text-sm font-medium">报警联系人</h3>
<Button size="sm" onClick={() => { setEditingContact(null); setContactDialogOpen(true); }}> <Button size="sm" onClick={() => { setEditingContact(null); setContactDialogOpen(true); }}>
<Plus className="w-4 h-4 mr-1" /> <Plus className="w-4 h-4 mr-1" />
@@ -529,9 +527,16 @@ const AlertCenter: React.FC = () => {
{c.phone && <div>📱 {c.phone}</div>} {c.phone && <div>📱 {c.phone}</div>}
{c.email && <div>📧 {c.email}</div>} {c.email && <div>📧 {c.email}</div>}
<div className="flex gap-1 flex-wrap"> <div className="flex gap-1 flex-wrap">
{c.dingtalk && <Badge variant="outline" className="text-[10px]">钉钉</Badge>} {c.channel_type && (
{c.feishu && <Badge variant="outline" className="text-[10px]">飞书</Badge>} <Badge variant="outline" className="text-[10px]">
{c.wechat && <Badge variant="outline" className="text-[10px]">企微</Badge>} {getChannelTypeLabel(c.channel_type)}
</Badge>
)}
{c.webhook_url && (
<span className="text-[10px] text-muted-foreground truncate max-w-[200px]" title={c.webhook_url}>
{c.webhook_url}
</span>
)}
</div> </div>
<div className="flex gap-1.5 pt-1.5"> <div className="flex gap-1.5 pt-1.5">
<Button variant="outline" size="sm" className="h-7 text-xs flex-1" <Button variant="outline" size="sm" className="h-7 text-xs flex-1"
@@ -553,7 +558,6 @@ const AlertCenter: React.FC = () => {
))} ))}
</div> </div>
)} )}
</div>
</TabsContent> </TabsContent>
{/* 报警联系组 Tab */} {/* 报警联系组 Tab */}
@@ -1,4 +1,4 @@
// 报警联系人创建/编辑对话框 // Logan.Li // 报警联系人创建/编辑对话框 — 渠道类型下拉 + webhook URL // Logan.Li
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
@@ -6,10 +6,20 @@ import {
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { alertApi } from '@/services/api'; import { alertApi } from '@/services/api';
import type { Contact } from './alert-center-helpers'; import type { Contact } from './alert-center-helpers';
const CHANNEL_OPTIONS = [
{ value: 'feishu', label: '飞书', placeholder: 'https://open.feishu.cn/open-apis/bot/v2/hook/...' },
{ value: 'dingtalk', label: '钉钉', placeholder: 'https://oapi.dingtalk.com/robot/send?access_token=...' },
{ value: 'wechat', label: '企业微信', placeholder: 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=...' },
{ value: 'webhook', label: '自定义Webhook', placeholder: 'https://your-webhook-url...' },
];
export function ContactDialog({ export function ContactDialog({
open, open,
onOpenChange, onOpenChange,
@@ -21,39 +31,37 @@ export function ContactDialog({
editingContact: Contact | null; editingContact: Contact | null;
onSaved: () => void; onSaved: () => void;
}) { }) {
const [form, setForm] = useState({ const [channelType, setChannelType] = useState('feishu');
name: '', phone: '', email: '', dingtalk: '', feishu: '', wechat: '', const [form, setForm] = useState({ name: '', phone: '', email: '', webhook_url: '' });
});
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
useEffect(() => { useEffect(() => {
if (open) { if (open) {
if (editingContact) { if (editingContact) {
setChannelType(editingContact.channel_type || 'feishu');
setForm({ setForm({
name: editingContact.name || '', name: editingContact.name || '',
phone: editingContact.phone || '', phone: editingContact.phone || '',
email: editingContact.email || '', email: editingContact.email || '',
dingtalk: editingContact.dingtalk || '', webhook_url: editingContact.webhook_url || '',
feishu: editingContact.feishu || '',
wechat: editingContact.wechat || '',
}); });
} else { } else {
setForm({ name: '', phone: '', email: '', dingtalk: '', feishu: '', wechat: '' }); setChannelType('feishu');
setForm({ name: '', phone: '', email: '', webhook_url: '' });
} }
} }
}, [open, editingContact]); }, [open, editingContact]);
const handleSave = async () => { const handleSave = async () => {
if (!form.name.trim()) return; if (!form.name.trim() || !form.webhook_url.trim()) return;
setSaving(true); setSaving(true);
try { try {
const payload = { const payload = {
name: form.name.trim(), name: form.name.trim(),
phone: form.phone.trim() || undefined, phone: form.phone.trim() || undefined,
email: form.email.trim() || undefined, email: form.email.trim() || undefined,
dingtalk: form.dingtalk.trim() || undefined, channel_type: channelType,
feishu: form.feishu.trim() || undefined, webhook_url: form.webhook_url.trim(),
wechat: form.wechat.trim() || undefined,
}; };
if (editingContact) { if (editingContact) {
await alertApi.updateContact(editingContact.id, payload); await alertApi.updateContact(editingContact.id, payload);
@@ -71,12 +79,14 @@ export function ContactDialog({
} }
}; };
const selectedChannel = CHANNEL_OPTIONS.find(c => c.value === channelType);
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>{editingContact ? '编辑报警联系人' : '添加报警联系人'}</DialogTitle> <DialogTitle>{editingContact ? '编辑报警联系人' : '添加报警联系人'}</DialogTitle>
<DialogDescription>配置报警联系人信息(姓名必填,渠道选填)</DialogDescription> <DialogDescription>选择通知渠道并填入 Webhook 地址</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
@@ -87,53 +97,50 @@ export function ContactDialog({
placeholder="如:张三" placeholder="如:张三"
/> />
</div> </div>
<div className="space-y-2"> <div className="grid grid-cols-2 gap-3">
<Label>手机号</Label> <div className="space-y-2">
<Input <Label>手机号</Label>
value={form.phone} <Input
onChange={(e) => setForm({ ...form, phone: e.target.value })} value={form.phone}
placeholder="如:13800138000" onChange={(e) => setForm({ ...form, phone: e.target.value })}
/> placeholder="13800138000"
/>
</div>
<div className="space-y-2">
<Label>邮箱</Label>
<Input
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
placeholder="zhangsan@example.com"
/>
</div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>邮箱</Label> <Label>渠道类型 *</Label>
<Input <Select value={channelType} onValueChange={setChannelType}>
value={form.email} <SelectTrigger>
onChange={(e) => setForm({ ...form, email: e.target.value })} <SelectValue placeholder="选择渠道类型" />
placeholder="如:zhangsan@example.com" </SelectTrigger>
/> <SelectContent>
{CHANNEL_OPTIONS.map(opt => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>钉钉(webhook URL)</Label> <Label>Webhook URL *</Label>
<Input <Input
value={form.dingtalk} value={form.webhook_url}
onChange={(e) => setForm({ ...form, dingtalk: e.target.value })} onChange={(e) => setForm({ ...form, webhook_url: e.target.value })}
placeholder="https://oapi.dingtalk.com/robot/send?access_token=..." placeholder={selectedChannel?.placeholder || 'https://...'}
className="font-mono text-sm" className="font-mono text-xs"
/>
</div>
<div className="space-y-2">
<Label>飞书(webhook URL)</Label>
<Input
value={form.feishu}
onChange={(e) => setForm({ ...form, feishu: e.target.value })}
placeholder="https://open.feishu.cn/open-apis/bot/v2/hook/..."
className="font-mono text-sm"
/>
</div>
<div className="space-y-2">
<Label>企业微信(webhook URL)</Label>
<Input
value={form.wechat}
onChange={(e) => setForm({ ...form, wechat: e.target.value })}
placeholder="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=..."
className="font-mono text-sm"
/> />
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button> <Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
<Button onClick={handleSave} disabled={!form.name.trim() || saving}> <Button onClick={handleSave} disabled={!form.name.trim() || !form.webhook_url.trim() || saving}>
{saving ? '保存中...' : editingContact ? '保存修改' : '添加联系人'} {saving ? '保存中...' : editingContact ? '保存修改' : '添加联系人'}
</Button> </Button>
</DialogFooter> </DialogFooter>
@@ -60,9 +60,8 @@ export interface Contact {
name: string; name: string;
phone?: string; phone?: string;
email?: string; email?: string;
dingtalk?: string; channel_type?: string;
feishu?: string; webhook_url?: string;
wechat?: string;
created_at?: string; created_at?: string;
updated_at?: string; updated_at?: string;
} }
+319 -85
View File
@@ -10,7 +10,7 @@ import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
Cpu, HardDrive, Network, Activity, Server, Loader2, Cpu, HardDrive, Network, Activity, Server, Loader2,
Info, Monitor, Disc, Zap, Clock, List, AlertTriangle, Info, Monitor, Disc, Clock, List, HeartPulse, Plus, Trash2, RefreshCw, Edit, CheckCircle, XCircle,
} from 'lucide-react'; } from 'lucide-react';
import { import {
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip,
@@ -81,12 +81,36 @@ export function HostMonitor() {
const [latest, setLatest] = useState<any>(null); const [latest, setLatest] = useState<any>(null);
const [serverInfo, setServerInfo] = useState<any>(null); const [serverInfo, setServerInfo] = useState<any>(null);
// 健康探针
const [probes, setProbes] = useState<any[]>([]);
const [probeDialogOpen, setProbeDialogOpen] = useState(false);
const [editingProbe, setEditingProbe] = useState<any>(null);
const [probeForm, setProbeForm] = useState({
name: '', hostname: '', probe_type: 'http_get', http_url: '', http_method: 'GET',
http_expect_code: 200, http_expect_body: '',
tcp_host: '', tcp_port: 3306, exec_command: '', timeout: 10, interval: 60, description: '',
});
// 加载探针
const fetchProbes = useCallback(() => {
if (!selectedHost) return;
agentApi.getHealthProbes({ hostname: selectedHost }).then((data: any) => {
if (Array.isArray(data)) setProbes(data);
else if (data?.list) setProbes(data.list);
else setProbes([]);
}).catch(() => setProbes([]));
}, [selectedHost]);
useEffect(() => {
fetchProbes();
}, [fetchProbes]);
const timeConfig = TIME_RANGES.find(tr => tr.hours === rangeHours) || TIME_RANGES[0]; const timeConfig = TIME_RANGES.find(tr => tr.hours === rangeHours) || TIME_RANGES[0];
useEffect(() => { useEffect(() => {
agentApi.getLatest().then((data: any) => { agentApi.getLatest().then((data: any) => {
const list = Array.isArray(data) ? data : (data?.data || data?.list || []); const list = Array.isArray(data) ? data : (data?.data || data?.list || []);
setHosts(list);
if (list.length > 0 && !selectedHost) { if (list.length > 0 && !selectedHost) {
const m = urlHost ? list.find((h: any) => h.hostname === urlHost) : null; const m = urlHost ? list.find((h: any) => h.hostname === urlHost) : null;
setSelectedHost(m?.hostname || list[0]?.hostname || ''); setSelectedHost(m?.hostname || list[0]?.hostname || '');
@@ -241,8 +265,10 @@ export function HostMonitor() {
{ id: 'basic', label: '基础监控', icon: <Monitor className="w-3 h-3" /> }, { id: 'basic', label: '基础监控', icon: <Monitor className="w-3 h-3" /> },
{ id: 'disk', label: '磁盘监控', icon: <HardDrive className="w-3 h-3" /> }, { id: 'disk', label: '磁盘监控', icon: <HardDrive className="w-3 h-3" /> },
{ id: 'network', label: '网络监控', icon: <Network className="w-3 h-3" /> }, { id: 'network', label: '网络监控', icon: <Network className="w-3 h-3" /> },
{ id: 'ports', label: '端口监控', icon: <Server className="w-3 h-3" /> },
{ id: 'process', label: '进程监控', icon: <List className="w-3 h-3" /> }, { id: 'process', label: '进程监控', icon: <List className="w-3 h-3" /> },
{ id: 'system', label: '系统信息', icon: <Info className="w-3 h-3" /> }, { id: 'system', label: '系统信息', icon: <Info className="w-3 h-3" /> },
{ id: 'health_probes', label: '健康探针', icon: <HeartPulse className="w-3 h-3" /> },
].map(tab => ( ].map(tab => (
<TabsTrigger key={tab.id} value={tab.id} className="text-[11px] h-7 gap-1 data-[state=active]:bg-[var(--dg-bg-primary)]"> <TabsTrigger key={tab.id} value={tab.id} className="text-[11px] h-7 gap-1 data-[state=active]:bg-[var(--dg-bg-primary)]">
{tab.icon}{tab.label} {tab.icon}{tab.label}
@@ -290,103 +316,135 @@ export function HostMonitor() {
/> />
</TabsContent> </TabsContent>
{/* ═══ 进程监控 — v2.0 新增 ═══ */} {/* ═══ 端口监控 ═══ */}
<TabsContent value="process" className="mt-3 space-y-3"> <TabsContent value="ports" className="mt-3 space-y-3">
{/* 进程 TOP 表格 */}
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]"> <Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-4"> <CardContent className="p-4">
<h3 className="text-xs font-medium text-[var(--dg-text-primary)] mb-3 flex items-center gap-2"> <h3 className="text-xs font-medium text-[var(--dg-text-primary)] mb-3 flex items-center gap-2">
<List className="h-4 w-4 text-cyan-400" />进程 TOP {processTop.length} (按内存排序) <Server className="h-4 w-4 text-emerald-400" />监听端口 {portInfo.length}
</h3> </h3>
{processTop.length === 0 ? ( {portInfo.length === 0 ? (
<div className="text-center py-6 text-xs text-[var(--dg-text-muted)] border border-dashed border-[var(--dg-border)] rounded-lg"> <div className="text-center py-6 text-xs text-[var(--dg-text-muted)] border border-dashed border-[var(--dg-border)] rounded-lg">
Agent v2.0+ 支持,当前无数据 暂无端口数据
</div> </div>
) : ( ) : (
<table className="w-full text-xs"> <div className="overflow-x-auto">
<thead> <table className="w-full text-xs">
<tr className="text-[var(--dg-text-muted)] border-b border-[var(--dg-border)]"> <thead>
<th className="py-1.5 text-left font-medium">PID</th> <tr className="text-[var(--dg-text-muted)] border-b border-[var(--dg-border)]">
<th className="py-1.5 text-left font-medium">进程名</th> <th className="py-1.5 text-left font-medium">端口</th>
<th className="py-1.5 text-right font-medium">内存 (MB)</th> <th className="py-1.5 text-left font-medium">协议</th>
<th className="py-1.5 text-right font-medium">FD 数</th> <th className="py-1.5 text-left font-medium">进程</th>
<th className="py-1.5 text-right font-medium">线程数</th> <th className="py-1.5 text-right font-medium">状态</th>
</tr>
</thead>
<tbody>
{processTop.map((p: any, i: number) => (
<tr key={i} className="border-b border-[var(--dg-border)]/30 hover:bg-slate-800/30">
<td className="py-1.5 font-mono text-[var(--dg-text-muted)]">{p.pid}</td>
<td className="py-1.5 font-mono text-[var(--dg-text-primary)]">{p.name}</td>
<td className="py-1.5 text-right font-mono text-cyan-400">{(p.memory_mb ?? 0).toFixed(0)}</td>
<td className="py-1.5 text-right font-mono text-[var(--dg-text-muted)]">{p.fd_count ?? 0}</td>
<td className="py-1.5 text-right font-mono text-[var(--dg-text-muted)]">{p.threads ?? 0}</td>
</tr> </tr>
))} </thead>
</tbody> <tbody>
</table> {portInfo.map((p: any, i: number) => (
<tr key={i} className="border-b border-[var(--dg-border)]/30 hover:bg-slate-800/30 transition-colors">
<td className="py-1.5 font-mono text-[var(--dg-text-primary)]">{p.port}</td>
<td className="py-1.5">
<Badge variant="outline" className="text-[10px] font-mono">
{p.protocol || 'tcp'}
</Badge>
</td>
<td className="py-1.5 font-mono text-[var(--dg-text-muted)]">{p.process || '—'}</td>
<td className="py-1.5 text-right">
<Badge variant="default" className="text-[10px] bg-emerald-500/20 text-emerald-400 border-emerald-500/30">
LISTEN
</Badge>
</td>
</tr>
))}
</tbody>
</table>
</div>
)} )}
</CardContent> </CardContent>
</Card> </Card>
</TabsContent>
{/* Swap + FD + CS 快速卡片 */} {/* ═══ 进程监控 — 简洁 5 列 ═══ */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-3"> <TabsContent value="process" className="mt-3 space-y-3">
<MetricChartCard title="Swap 使用率(%)" value={currentVal('swap')} <Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
icon={<AlertTriangle className="h-4 w-4 text-rose-400" />} <CardContent className="p-4">
data={metrics.swap} color="#f43f5e" unit="%" domain={[0, 100]} aggType={aggType} <h3 className="text-xs font-medium text-[var(--dg-text-primary)] mb-3 flex items-center gap-2">
/> <List className="h-4 w-4 text-cyan-400" />进程 TOP {processTop.length}
<MetricChartCard title="FD 句柄使用率(%)" value={currentVal('fd')} </h3>
icon={<Activity className="h-4 w-4 text-cyan-400" />} {processTop.length === 0 ? (
data={metrics.fd} color="#22d3ee" unit="%" domain={[0, 100]} aggType={aggType} <div className="text-center py-6 text-xs text-[var(--dg-text-muted)] border border-dashed border-[var(--dg-border)] rounded-lg">
/> Agent v2.0+ 支持,等待数据上报…
{/* CS 当前值卡片 */}
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-3">
<div className="flex items-center gap-1.5 mb-2">
<Zap className="h-4 w-4 text-indigo-400" /><span className="text-xs text-[var(--dg-text-primary)]">上下文切换</span>
</div> </div>
<div className="text-xl font-bold text-[var(--dg-text-primary)] tabular-nums"> ) : (
{csInfo?.ctx_per_sec != null ? `${(csInfo.ctx_per_sec / 1000).toFixed(1)}K/s` : '--'} <div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="text-[var(--dg-text-muted)] border-b border-[var(--dg-border)]">
<th className="py-1.5 text-left font-medium w-[40%]">进程名称 / CMD</th>
<th className="py-1.5 text-right font-medium w-[8%]">PID</th>
<th className="py-1.5 text-right font-medium w-[16%]">内存使用率(%)</th>
<th className="py-1.5 text-right font-medium w-[16%]">CPU使用率(%)</th>
<th className="py-1.5 text-right font-medium w-[20%]">打开文件数</th>
</tr>
</thead>
<tbody>
{processTop.map((p: any, i: number) => {
const cmd = p.cmdline || p.name || '?';
const cpup = p.cpu_pct != null ? Number(p.cpu_pct) : null;
const memp = p.mem_pct != null ? Number(p.mem_pct) : null;
return (
<tr key={i} className="border-b border-[var(--dg-border)]/30 hover:bg-slate-800/30 transition-colors">
<td className="py-1.5 pr-2">
<div className="flex items-center gap-1 group/cmd">
<span
className="font-mono text-[var(--dg-text-primary)] truncate max-w-[320px] inline-block align-middle"
title={cmd}
>
{cmd}
</span>
<button
className="opacity-0 group-hover/cmd:opacity-100 transition-opacity p-0.5 hover:text-cyan-400 shrink-0"
title="复制命令行"
onClick={async (e) => {
e.stopPropagation();
try {
await navigator.clipboard.writeText(cmd);
} catch {
// fallback
const ta = document.createElement('textarea');
ta.value = cmd; ta.style.position = 'fixed'; ta.style.opacity = '0';
document.body.appendChild(ta); ta.select();
document.execCommand('copy'); document.body.removeChild(ta);
}
}}
>
<svg className="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
</button>
</div>
</td>
<td className="py-1.5 text-right font-mono text-[var(--dg-text-muted)]">{p.pid}</td>
<td className="py-1.5 text-right font-mono">
<span className={memp != null && memp > 50 ? 'text-rose-400' : 'text-cyan-400'}>
{memp != null ? memp.toFixed(2) : '--'}
</span>
</td>
<td className="py-1.5 text-right font-mono">
<span className={cpup != null && cpup > 50 ? 'text-rose-400' : 'text-[var(--dg-text-primary)]'}>
{cpup != null ? cpup.toFixed(2) : '--'}
</span>
</td>
<td className="py-1.5 text-right font-mono text-[var(--dg-text-muted)]">{p.fd_count ?? '--'}</td>
</tr>
);
})}
</tbody>
</table>
</div> </div>
<div className="text-[10px] text-[var(--dg-text-muted)] mt-0.5"> )}
中断: {csInfo?.irq_per_sec != null ? `${(csInfo.irq_per_sec / 1000).toFixed(1)}K/s` : '--'} </CardContent>
</div> </Card>
</CardContent>
</Card>
</div>
{/* FD / Swap 详情 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{fdInfo && fdInfo.max > 0 && (
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-3">
<span className="text-[10px] text-[var(--dg-text-muted)]">文件句柄</span>
<div className="flex items-baseline gap-1">
<span className="text-lg font-bold text-[var(--dg-text-primary)]">{fdInfo.allocated}</span>
<span className="text-xs text-[var(--dg-text-muted)]">/ {fdInfo.max}</span>
</div>
<div className="mt-1 h-1.5 bg-slate-700 rounded-full overflow-hidden">
<div className="h-full rounded-full bg-cyan-500" style={{ width: `${Math.min(fdInfo.usage_pct || 0, 100)}%` }} />
</div>
</CardContent>
</Card>
)}
{swapInfo && swapInfo.total_gb > 0 && (
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-3">
<span className="text-[10px] text-[var(--dg-text-muted)]">Swap</span>
<div className="flex items-baseline gap-1">
<span className="text-lg font-bold text-[var(--dg-text-primary)]">{(swapInfo.used_gb ?? 0).toFixed(1)}</span>
<span className="text-xs text-[var(--dg-text-muted)]">/ {(swapInfo.total_gb ?? 0).toFixed(0)} GB</span>
</div>
<div className="mt-1 h-1.5 bg-slate-700 rounded-full overflow-hidden">
<div className={`h-full rounded-full ${(swapInfo.usage_percent || 0) > 50 ? 'bg-rose-500' : 'bg-amber-500'}`}
style={{ width: `${Math.min(swapInfo.usage_percent || 0, 100)}%` }} />
</div>
</CardContent>
</Card>
)}
</div>
</TabsContent> </TabsContent>
{/* 系统信息 */} {/* 系统信息 */}
@@ -460,7 +518,183 @@ export function HostMonitor() {
); );
})()} })()}
</TabsContent> </TabsContent>
{/* 健康探针 — Agent 本地执行 + 结果上报 */}
<TabsContent value="health_probes" className="mt-3 space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-xs font-medium text-[var(--dg-text-primary)]">探针列表(Agent 在目标主机本地执行探测)</h3>
<div className="flex gap-2">
<Button size="sm" variant="outline" className="h-7 text-[11px]" onClick={fetchProbes}>
<RefreshCw className="w-3 h-3 mr-1" />刷新
</Button>
<Button size="sm" className="h-7 text-[11px] bg-blue-600 hover:bg-blue-500" onClick={() => {
setEditingProbe(null);
setProbeForm({
name: '', hostname: selectedHost, probe_type: 'http_get', http_url: '',
http_method: 'GET', http_expect_code: 200, http_expect_body: '',
tcp_host: 'localhost', tcp_port: 3306, exec_command: '', timeout: 10, interval: 60, description: '',
});
setProbeDialogOpen(true);
}}>
<Plus className="w-3 h-3 mr-1" />添加探针
</Button>
</div>
</div>
{probes.length === 0 ? (
<Card className="bg-[var(--dg-bg-secondary)] border-dashed border-[var(--dg-border)]">
<CardContent className="p-8 text-center text-xs text-[var(--dg-text-muted)]">
暂无健康探针,点击"添加探针"创建
</CardContent>
</Card>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{probes.map((p: any) => (
<Card key={p.id} className={`bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] ${p.last_status === 'down' ? 'border-red-700/50' : ''}`}>
<CardContent className="p-3">
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
{p.last_status === 'up' ? <CheckCircle className="w-4 h-4 text-emerald-400" /> :
p.last_status === 'down' ? <XCircle className="w-4 h-4 text-red-400" /> :
<Clock className="w-4 h-4 text-slate-500" />}
<span className="text-xs font-medium text-[var(--dg-text-primary)]">{p.name}</span>
<Badge variant="outline" className="text-[10px] h-5 px-1.5 capitalize">
{p.probe_type === 'http_get' ? 'HTTP' : p.probe_type === 'tcp_socket' ? 'TCP' : 'Exec'}
</Badge>
</div>
<div className="flex gap-1">
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" onClick={() => {
setEditingProbe(p);
setProbeForm({
name: p.name, hostname: p.hostname, probe_type: p.probe_type,
http_url: p.http_url || '', http_method: p.http_method || 'GET',
http_expect_code: p.http_expect_code || 200, http_expect_body: p.http_expect_body || '',
tcp_host: p.tcp_host || '', tcp_port: p.tcp_port || 3306,
exec_command: p.exec_command || '', timeout: p.timeout || 10,
interval: p.interval || 60, description: p.description || '',
});
setProbeDialogOpen(true);
}}>
<Edit className="w-3 h-3" />
</Button>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0 text-red-400 hover:text-red-300" onClick={async () => {
if (confirm(`确定删除探针"${p.name}"?`)) {
await agentApi.deleteHealthProbe(p.id);
fetchProbes();
}
}}>
<Trash2 className="w-3 h-3" />
</Button>
</div>
</div>
<div className="space-y-1 text-[11px] text-[var(--dg-text-muted)]">
{p.probe_type === 'http_get' && (
<div className="truncate">URL: {p.http_url}</div>
)}
{p.probe_type === 'tcp_socket' && (
<div>{p.tcp_host}:{p.tcp_port}</div>
)}
{p.probe_type === 'exec_command' && (
<div className="truncate font-mono">{p.exec_command}</div>
)}
<div className="flex items-center gap-3">
<span>状态: <span className={p.last_status === 'up' ? 'text-emerald-400' : p.last_status === 'down' ? 'text-red-400' : 'text-slate-400'}>
{p.last_status ? p.last_status.toUpperCase() : '未检测'}
</span></span>
{p.last_latency_ms > 0 && <span>延迟: {p.last_latency_ms}ms</span>}
{p.fail_count > 0 && <span className="text-red-400">连续失败: {p.fail_count}次</span>}
{p.last_checked_at && <span className="text-[10px]">{new Date(p.last_checked_at).toLocaleString('zh-CN')}</span>}
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
</TabsContent>
</Tabs> </Tabs>
{/* 添加/编辑探针对话框 */}
{probeDialogOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => setProbeDialogOpen(false)}>
<div className="bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg p-5 w-[480px] max-h-[90vh] overflow-y-auto shadow-xl" onClick={e => e.stopPropagation()}>
<h3 className="text-sm font-semibold text-[var(--dg-text-primary)] mb-4">
{editingProbe ? '编辑探针' : '添加探针'}
</h3>
<div className="space-y-3">
<div>
<label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block">探针名称 <span className="text-red-400">*</span></label>
<input className="w-full h-8 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 text-xs text-[var(--dg-text-primary)]" value={probeForm.name} onChange={e => setProbeForm({ ...probeForm, name: e.target.value })} placeholder="如:cex-agent 健康检查" />
</div>
<div>
<label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block">目标主机</label>
<input className="w-full h-8 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 text-xs text-[var(--dg-text-primary)]" value={probeForm.hostname} onChange={e => setProbeForm({ ...probeForm, hostname: e.target.value })} />
</div>
<div>
<label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block">探针类型</label>
<select className="w-full h-8 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 text-xs text-[var(--dg-text-primary)]" value={probeForm.probe_type} onChange={e => setProbeForm({ ...probeForm, probe_type: e.target.value })}>
<option value="http_get">HTTP GET</option>
<option value="tcp_socket">TCP Socket</option>
<option value="exec_command">Exec Command</option>
</select>
</div>
{probeForm.probe_type === 'http_get' && (
<>
<div><label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block">HTTP URL <span className="text-red-400">*</span></label>
<input className="w-full h-8 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 text-xs text-[var(--dg-text-primary)]" value={probeForm.http_url} onChange={e => setProbeForm({ ...probeForm, http_url: e.target.value })} placeholder="http://localhost:8080/health" /></div>
<div className="grid grid-cols-2 gap-2">
<div><label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block">期望状态码</label>
<input type="number" className="w-full h-8 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 text-xs text-[var(--dg-text-primary)]" value={probeForm.http_expect_code} onChange={e => setProbeForm({ ...probeForm, http_expect_code: Number(e.target.value) })} /></div>
<div><label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block">期望响应内容(可选)</label>
<input className="w-full h-8 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 text-xs text-[var(--dg-text-primary)]" value={probeForm.http_expect_body} onChange={e => setProbeForm({ ...probeForm, http_expect_body: e.target.value })} /></div>
</div>
</>
)}
{probeForm.probe_type === 'tcp_socket' && (
<div className="grid grid-cols-2 gap-2">
<div><label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block">TCP 目标地址</label>
<input className="w-full h-8 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 text-xs text-[var(--dg-text-primary)]" value={probeForm.tcp_host} onChange={e => setProbeForm({ ...probeForm, tcp_host: e.target.value })} placeholder="localhost" /></div>
<div><label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block">TCP 端口</label>
<input type="number" className="w-full h-8 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 text-xs text-[var(--dg-text-primary)]" value={probeForm.tcp_port} onChange={e => setProbeForm({ ...probeForm, tcp_port: Number(e.target.value) })} /></div>
</div>
)}
{probeForm.probe_type === 'exec_command' && (
<div><label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block">执行命令 <span className="text-red-400">*</span></label>
<input className="w-full h-8 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 text-xs font-mono text-[var(--dg-text-primary)]" value={probeForm.exec_command} onChange={e => setProbeForm({ ...probeForm, exec_command: e.target.value })} placeholder="pgrep -x mysqld" /></div>
)}
<div className="grid grid-cols-2 gap-2">
<div><label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block">超时(秒)</label>
<input type="number" className="w-full h-8 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 text-xs text-[var(--dg-text-primary)]" value={probeForm.timeout} onChange={e => setProbeForm({ ...probeForm, timeout: Number(e.target.value) })} /></div>
<div><label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block">间隔(秒)</label>
<input type="number" className="w-full h-8 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 text-xs text-[var(--dg-text-primary)]" value={probeForm.interval} onChange={e => setProbeForm({ ...probeForm, interval: Number(e.target.value) })} /></div>
</div>
<div><label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block">描述</label>
<input className="w-full h-8 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 text-xs text-[var(--dg-text-primary)]" value={probeForm.description} onChange={e => setProbeForm({ ...probeForm, description: e.target.value })} /></div>
</div>
<div className="flex justify-end gap-2 mt-5">
<Button variant="outline" size="sm" className="h-7 text-[11px]" onClick={() => setProbeDialogOpen(false)}>取消</Button>
<Button size="sm" className="h-7 text-[11px] bg-blue-600 hover:bg-blue-500" onClick={async () => {
if (!probeForm.name || !probeForm.hostname) return;
try {
if (editingProbe) {
await agentApi.updateHealthProbe(editingProbe.id, probeForm);
} else {
await agentApi.createHealthProbe(probeForm);
}
setProbeDialogOpen(false);
fetchProbes();
} catch (e) { alert('保存失败'); }
}} disabled={!probeForm.name || !probeForm.hostname}>
{editingProbe ? '保存' : '创建'}
</Button>
</div>
</div>
</div>
)}
</div> </div>
); );
} }
+5
View File
@@ -885,6 +885,11 @@ export const agentApi = {
batchUpgrade: (serverIds: number[]) => http.post('/agent/batch-upgrade', { server_ids: serverIds }), batchUpgrade: (serverIds: number[]) => http.post('/agent/batch-upgrade', { server_ids: serverIds }),
listAlerts: (params?: Record<string, string>) => http.get('/agent/alerts', params), listAlerts: (params?: Record<string, string>) => http.get('/agent/alerts', params),
resolveAlert: (id: number) => http.post(`/agent/alerts/${id}/resolve`), resolveAlert: (id: number) => http.post(`/agent/alerts/${id}/resolve`),
// 健康探针
getHealthProbes: (params?: Record<string, string>) => http.get('/agent/health-probes', params),
createHealthProbe: (data: any) => http.post('/agent/health-probes', data),
updateHealthProbe: (id: number, data: any) => http.put(`/agent/health-probes/${id}`, data),
deleteHealthProbe: (id: number) => http.delete(`/agent/health-probes/${id}`),
}; };
// ==================== 区域管理 API ==================== // ==================== 区域管理 API ====================