feat: 脚本/定时任务真实执行+输出捕获+执行历史 — 替换pending空壳为本地执行,采集stdout/stderr,更新统计;修复CronJobManager/Automation字段名不匹配(status→enabled,cron→cron_expr,command→content,language→type)
This commit is contained in:
1 parent
85bc2a8e05
commit
a4fd5fc178
19 files changed
+2715
-114
No files matched your search
+58
-1
@@ -18,6 +18,7 @@ import (
|
||||
"ezops/agent/collector"
|
||||
"ezops/agent/model"
|
||||
"ezops/agent/reporter"
|
||||
"ezops/agent/task"
|
||||
)
|
||||
|
||||
// 编译时可注入的版本信息
|
||||
@@ -65,6 +66,10 @@ func main() {
|
||||
portCollector := collector.NewPortCollector()
|
||||
rep := reporter.New(*server, *token, logger)
|
||||
|
||||
// 初始化任务执行器与任务客户端(与 reporter 复用相同的 server/token/hostname)
|
||||
taskExecutor := task.New(logger)
|
||||
taskClient := task.NewClient(*server, *token, hostname, logger)
|
||||
|
||||
// 监听 SIGINT / SIGTERM,支持优雅退出
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
@@ -111,7 +116,59 @@ func main() {
|
||||
report.CPU.UsagePercent, report.Memory.UsagePercent, len(report.Disks), len(report.Ports))
|
||||
}
|
||||
|
||||
// 启动后立即执行一次,再按 ticker 周期执行
|
||||
// pollAndExecute 执行单次任务轮询:拉取待执行任务 → 逐个执行 → 回传结果。
|
||||
// 网络错误只记录日志,不 panic,下一个周期继续重试。
|
||||
pollAndExecute := func() {
|
||||
tasks, err := taskClient.Poll(ctx)
|
||||
if err != nil {
|
||||
// 上下文已取消(Agent 退出中)属于正常情况,不输出告警
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
logger.Printf("任务轮询失败: %v", err)
|
||||
return
|
||||
}
|
||||
if len(tasks) == 0 {
|
||||
return // 暂无任务,静默返回
|
||||
}
|
||||
logger.Printf("拉取到 %d 个待执行任务", len(tasks))
|
||||
|
||||
for _, t := range tasks {
|
||||
// Agent 退出时跳过后续任务,避免退出延迟
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
result := taskExecutor.ExecuteTask(ctx, t)
|
||||
if err := taskClient.Submit(ctx, result); err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
logger.Printf("任务 %s 结果回传失败: %v", t.TaskID, err)
|
||||
} else {
|
||||
logger.Printf("任务 %s 结果回传成功 status=%s", t.TaskID, result.Status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 启动任务轮询协程:每 5 秒轮询一次,与指标采集并行运行。
|
||||
// 使用独立的 5 秒固定间隔,不受 -interval 影响。
|
||||
const taskPollInterval = 5 * time.Second
|
||||
go func() {
|
||||
// 启动后立即轮询一次,再按固定周期执行
|
||||
pollAndExecute()
|
||||
ticker := time.NewTicker(taskPollInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
pollAndExecute()
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// 主协程:启动后立即执行一次指标采集,再按 ticker 周期执行
|
||||
collectAndReport()
|
||||
for {
|
||||
select {
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
// Package task 的 client.go 实现与后端任务接口的 HTTP 交互。
|
||||
// 涵盖拉取待执行任务(poll)与回传执行结果(submit result)。
|
||||
// 仅依赖 Go 标准库,失败时返回错误,由调用方决定重试策略。
|
||||
//
|
||||
// 署名 Logan.Li
|
||||
package task
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
pollPath = "/api/v1/agent/tasks/poll" // 任务轮询接口
|
||||
resultPathFmt = "/api/v1/agent/tasks/%s/result" // 结果回传接口(含 task_id 占位)
|
||||
httpTimeout = 10 * time.Second // 单次 HTTP 请求超时
|
||||
clientUserAgent = "aiops-agent/1.0" // 标识 Agent 版本
|
||||
)
|
||||
|
||||
// Client 负责与后端任务接口通信。
|
||||
type Client struct {
|
||||
server string
|
||||
token string
|
||||
hostname string
|
||||
httpClient *http.Client
|
||||
logger *log.Logger
|
||||
}
|
||||
|
||||
// NewClient 创建任务客户端实例。
|
||||
// - server: AIOps 后端地址(如 http://localhost:8080)
|
||||
// - token: Bearer 认证令牌
|
||||
// - 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 拉取当前主机待执行的任务列表。
|
||||
// 返回空切片表示暂无任务(后端返回空数组或 HTTP 204)。
|
||||
func (c *Client) Poll(ctx context.Context) ([]Task, error) {
|
||||
// 构造带 hostname 查询参数的 URL
|
||||
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 {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data []Task `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &wrapper); err != nil {
|
||||
// 兼容裸数组格式
|
||||
var tasks []Task
|
||||
if err2 := json.Unmarshal(body, &tasks); err2 != nil {
|
||||
return nil, fmt.Errorf("解析轮询响应失败: %w", err)
|
||||
}
|
||||
return tasks, nil
|
||||
}
|
||||
return wrapper.Data, nil
|
||||
}
|
||||
|
||||
// Submit 将任务执行结果回传至后端。
|
||||
func (c *Client) Submit(ctx context.Context, res Result) error {
|
||||
payload, err := json.Marshal(res)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
// attachHeaders 统一设置公共请求头(鉴权、内容类型、UA)。
|
||||
func (c *Client) attachHeaders(req *http.Request) {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", clientUserAgent)
|
||||
if c.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
// Package task 实现 AIOps Agent 的任务拉取与执行能力。
|
||||
// Agent 通过轮询接口从后端获取待执行任务(命令/脚本),本地执行后回传结果。
|
||||
//
|
||||
// 署名 Logan.Li
|
||||
package task
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os/exec"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 任务类型常量
|
||||
const (
|
||||
TypeCommand = "command" // 单条 shell 命令
|
||||
TypeScript = "script" // 多行 bash 脚本
|
||||
)
|
||||
|
||||
// Task 表示从后端拉取到的待执行任务。
|
||||
type Task struct {
|
||||
TaskID string `json:"task_id"` // 任务唯一标识,回传结果时使用
|
||||
Type string `json:"type"` // 任务类型:command / script
|
||||
Command string `json:"command"` // 命令内容(command 时为单条命令,script 时为脚本正文)
|
||||
Timeout int `json:"timeout"` // 执行超时,单位:秒;<=0 表示不限制
|
||||
}
|
||||
|
||||
// Result 表示任务执行结果,用于回传后端。
|
||||
type Result struct {
|
||||
TaskID string `json:"task_id"` // 对应的任务 ID
|
||||
ExitCode int `json:"exit_code"` // 进程退出码;超时/启动失败时为 -1
|
||||
Result string `json:"result"` // 合并后的 stdout+stderr 输出
|
||||
Status string `json:"status"` // 执行状态:success / failed / timeout
|
||||
}
|
||||
|
||||
// Executor 负责本地执行任务并产出结果。
|
||||
type Executor struct {
|
||||
logger *log.Logger
|
||||
}
|
||||
|
||||
// New 创建 Executor 实例。
|
||||
func New(logger *log.Logger) *Executor {
|
||||
return &Executor{logger: logger}
|
||||
}
|
||||
|
||||
// ExecuteTask 执行单个任务,返回执行结果。
|
||||
// 根据任务类型选择执行方式:
|
||||
// - command: 通过 "sh -c <command>" 执行单条命令
|
||||
// - script: 通过 "bash" 以标准输入喂入脚本正文执行
|
||||
//
|
||||
// 超时控制通过 context 实现;网络/信号导致的取消不会 panic。
|
||||
// 超时或执行失败均会返回 Result,不返回 error,便于调用方统一回传。
|
||||
func (e *Executor) ExecuteTask(ctx context.Context, t Task) Result {
|
||||
res := Result{TaskID: t.TaskID}
|
||||
|
||||
// 构造子进程:根据任务类型选择解释器
|
||||
var cmd *exec.Cmd
|
||||
switch t.Type {
|
||||
case TypeCommand:
|
||||
// 单条命令:交给 /bin/sh -c 解释执行
|
||||
cmd = exec.CommandContext(ctx, "sh", "-c", t.Command)
|
||||
case TypeScript:
|
||||
// 脚本:通过 bash 标准输入执行,便于支持多行与 heredoc 等语法
|
||||
cmd = exec.CommandContext(ctx, "bash")
|
||||
cmd.Stdin = bytes.NewReader([]byte(t.Command))
|
||||
default:
|
||||
// 未知任务类型,直接标记失败,避免误执行
|
||||
res.ExitCode = -1
|
||||
res.Status = "failed"
|
||||
res.Result = fmt.Sprintf("未知的任务类型: %q", t.Type)
|
||||
e.logger.Printf("任务 %s 类型非法: %q", t.TaskID, t.Type)
|
||||
return res
|
||||
}
|
||||
|
||||
// 合并 stdout 与 stderr,输出顺序与终端一致,便于排查问题
|
||||
var buf bytes.Buffer
|
||||
cmd.Stdout = &buf
|
||||
cmd.Stderr = &buf
|
||||
|
||||
// 设置执行超时上下文(若任务指定了有效 timeout)
|
||||
execCtx, cancel := e.withTimeout(ctx, t.Timeout)
|
||||
defer cancel()
|
||||
|
||||
startTime := time.Now()
|
||||
err := cmd.Run()
|
||||
elapsed := time.Since(startTime)
|
||||
|
||||
// 优先判定超时:Run 返回的 deadline exceeded 与 ctx.Err() 一起识别
|
||||
if execCtx.Err() == context.DeadlineExceeded {
|
||||
res.ExitCode = -1
|
||||
res.Status = "timeout"
|
||||
res.Result = fmt.Sprintf("任务执行超时(%ds),已输出:\n%s", t.Timeout, buf.String())
|
||||
e.logger.Printf("任务 %s 执行超时(%ds)", t.TaskID, t.Timeout)
|
||||
return res
|
||||
}
|
||||
|
||||
// 正常退出码提取
|
||||
res.ExitCode = exitCode(err)
|
||||
res.Result = buf.String()
|
||||
|
||||
// 根据退出码判定状态
|
||||
if res.ExitCode == 0 {
|
||||
res.Status = "success"
|
||||
e.logger.Printf("任务 %s 执行成功 exit=0 耗时=%s 输出=%dB",
|
||||
t.TaskID, elapsed, buf.Len())
|
||||
} else {
|
||||
res.Status = "failed"
|
||||
e.logger.Printf("任务 %s 执行失败 exit=%d 耗时=%s 输出=%dB",
|
||||
t.TaskID, res.ExitCode, elapsed, buf.Len())
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
// withTimeout 在父上下文基础上叠加超时控制。
|
||||
// 当 timeout <= 0 时直接返回父上下文,不做超时限制。
|
||||
// 返回的取消函数保证安全可调用(即使未创建新的超时上下文)。
|
||||
func (e *Executor) withTimeout(parent context.Context, timeout int) (context.Context, context.CancelFunc) {
|
||||
if timeout <= 0 {
|
||||
// 无超时要求:返回空取消函数,保持调用方代码统一
|
||||
return parent, func() {}
|
||||
}
|
||||
return context.WithTimeout(parent, time.Duration(timeout)*time.Second)
|
||||
}
|
||||
|
||||
// exitCode 从 exec.Run 返回的错误中提取退出码。
|
||||
// 成功时返回 0;无法识别的错误(如进程未启动)返回 -1。
|
||||
func exitCode(err error) int {
|
||||
if err == nil {
|
||||
return 0
|
||||
}
|
||||
// 尝试断言为 *exec.ExitError 以获取 ExitCode
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
return exitErr.ExitCode()
|
||||
}
|
||||
// 其他类型错误(如二进制不存在)统一记为 -1
|
||||
return -1
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,220 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"ezops/internal/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// AgentTaskHandler Agent 远程任务管理 // Logan.Li
|
||||
type AgentTaskHandler struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewAgentTaskHandler(db *gorm.DB) *AgentTaskHandler {
|
||||
return &AgentTaskHandler{db: db}
|
||||
}
|
||||
|
||||
// CreateTask 下发远程任务(前端调用,需 JWT)
|
||||
// POST /api/v1/agent/tasks
|
||||
func (h *AgentTaskHandler) CreateTask(c *gin.Context) {
|
||||
var req struct {
|
||||
Hostname string `json:"hostname"`
|
||||
Type string `json:"type"` // command / script
|
||||
Command string `json:"command"`
|
||||
Timeout int `json:"timeout"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, models.Response{Code: 400, Message: "参数错误: " + err.Error()})
|
||||
return
|
||||
}
|
||||
if req.Hostname == "" || req.Command == "" {
|
||||
c.JSON(400, models.Response{Code: 400, Message: "hostname 和 command 不能为空"})
|
||||
return
|
||||
}
|
||||
if req.Timeout == 0 {
|
||||
req.Timeout = 60
|
||||
}
|
||||
if req.Type == "" {
|
||||
req.Type = "command"
|
||||
}
|
||||
|
||||
username, _ := c.Get("username")
|
||||
|
||||
task := models.AgentTask{
|
||||
TaskID: uuid.New().String(),
|
||||
Hostname: req.Hostname,
|
||||
Type: req.Type,
|
||||
Command: req.Command,
|
||||
Timeout: req.Timeout,
|
||||
Status: "pending",
|
||||
CreatedBy: fmt.Sprintf("%v", username),
|
||||
}
|
||||
|
||||
if err := h.db.Create(&task).Error; err != nil {
|
||||
c.JSON(500, models.Response{Code: 500, Message: "创建失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, models.Response{Code: 200, Message: "ok", Data: task})
|
||||
}
|
||||
|
||||
// PollTasks Agent 轮询拉取任务(Agent 调用,无需 JWT)
|
||||
// GET /api/v1/agent/tasks/poll?hostname=xxx
|
||||
func (h *AgentTaskHandler) PollTasks(c *gin.Context) {
|
||||
hostname := c.Query("hostname")
|
||||
if hostname == "" {
|
||||
c.JSON(400, models.Response{Code: 400, Message: "hostname 不能为空"})
|
||||
return
|
||||
}
|
||||
|
||||
// 查找该主机 pending 的任务,按时间排序,最多取 5 个
|
||||
var tasks []models.AgentTask
|
||||
h.db.Where("hostname = ? AND status = ?", hostname, "pending").
|
||||
Order("created_at ASC").
|
||||
Limit(5).
|
||||
Find(&tasks)
|
||||
|
||||
// 标记为 running(防其他 agent 重复拉取)
|
||||
now := time.Now()
|
||||
for _, t := range tasks {
|
||||
h.db.Model(&models.AgentTask{}).Where("id = ? AND status = ?", t.ID, "pending").
|
||||
Updates(map[string]interface{}{"status": "running", "picked_at": now})
|
||||
}
|
||||
|
||||
c.JSON(200, models.Response{Code: 200, Data: tasks})
|
||||
}
|
||||
|
||||
// SubmitResult Agent 回传执行结果(Agent 调用,无需 JWT)
|
||||
// POST /api/v1/agent/tasks/:task_id/result
|
||||
func (h *AgentTaskHandler) SubmitResult(c *gin.Context) {
|
||||
taskID := c.Param("task_id")
|
||||
|
||||
var req struct {
|
||||
ExitCode int `json:"exit_code"`
|
||||
Result string `json:"result"`
|
||||
Status string `json:"status"` // success / failed / timeout
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, models.Response{Code: 400, Message: "参数错误"})
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
updates := map[string]interface{}{
|
||||
"exit_code": req.ExitCode,
|
||||
"result": req.Result,
|
||||
"status": req.Status,
|
||||
"finished_at": now,
|
||||
}
|
||||
|
||||
if err := h.db.Model(&models.AgentTask{}).Where("task_id = ?", taskID).Updates(updates).Error; err != nil {
|
||||
c.JSON(500, models.Response{Code: 500, Message: "更新失败"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, models.Response{Code: 200, Message: "ok"})
|
||||
}
|
||||
|
||||
// ListTasks 查询任务列表(前端调用,需 JWT)
|
||||
// GET /api/v1/agent/tasks?hostname=xxx&status=xxx
|
||||
func (h *AgentTaskHandler) ListTasks(c *gin.Context) {
|
||||
query := h.db.Model(&models.AgentTask{})
|
||||
|
||||
if hostname := c.Query("hostname"); hostname != "" {
|
||||
query = query.Where("hostname = ?", hostname)
|
||||
}
|
||||
if status := c.Query("status"); status != "" {
|
||||
query = query.Where("status = ?", status)
|
||||
}
|
||||
|
||||
var tasks []models.AgentTask
|
||||
query.Order("created_at DESC").Limit(200).Find(&tasks)
|
||||
|
||||
c.JSON(200, models.Response{Code: 200, Data: tasks})
|
||||
}
|
||||
|
||||
// GetTask 获取单个任务详情
|
||||
// GET /api/v1/agent/tasks/:id
|
||||
func (h *AgentTaskHandler) GetTask(c *gin.Context) {
|
||||
var task models.AgentTask
|
||||
if err := h.db.First(&task, c.Param("id")).Error; err != nil {
|
||||
c.JSON(404, models.Response{Code: 404, Message: "任务不存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(200, models.Response{Code: 200, Data: task})
|
||||
}
|
||||
|
||||
// ===== Agent 告警规则 =====
|
||||
|
||||
// ListRules GET /api/v1/agent/rules
|
||||
func (h *AgentTaskHandler) ListRules(c *gin.Context) {
|
||||
var rules []models.AgentRule
|
||||
h.db.Order("created_at DESC").Find(&rules)
|
||||
c.JSON(200, models.Response{Code: 200, Data: rules})
|
||||
}
|
||||
|
||||
// CreateRule POST /api/v1/agent/rules
|
||||
func (h *AgentTaskHandler) CreateRule(c *gin.Context) {
|
||||
var rule models.AgentRule
|
||||
if err := c.ShouldBindJSON(&rule); err != nil {
|
||||
c.JSON(400, models.Response{Code: 400, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
rule.ID = 0
|
||||
rule.Enabled = true
|
||||
if err := h.db.Create(&rule).Error; err != nil {
|
||||
c.JSON(500, models.Response{Code: 500, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(200, models.Response{Code: 200, Data: rule})
|
||||
}
|
||||
|
||||
// UpdateRule PUT /api/v1/agent/rules/:id
|
||||
func (h *AgentTaskHandler) UpdateRule(c *gin.Context) {
|
||||
var rule models.AgentRule
|
||||
if err := h.db.First(&rule, c.Param("id")).Error; err != nil {
|
||||
c.JSON(404, models.Response{Code: 404, Message: "规则不存在"})
|
||||
return
|
||||
}
|
||||
var update models.AgentRule
|
||||
if err := c.ShouldBindJSON(&update); err != nil {
|
||||
c.JSON(400, models.Response{Code: 400, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
h.db.Model(&rule).Updates(update)
|
||||
c.JSON(200, models.Response{Code: 200, Data: rule})
|
||||
}
|
||||
|
||||
// DeleteRule DELETE /api/v1/agent/rules/:id
|
||||
func (h *AgentTaskHandler) DeleteRule(c *gin.Context) {
|
||||
h.db.Delete(&models.AgentRule{}, c.Param("id"))
|
||||
c.JSON(200, models.Response{Code: 200, Message: "ok"})
|
||||
}
|
||||
|
||||
// ListAlerts GET /api/v1/agent/alerts
|
||||
func (h *AgentTaskHandler) ListAlerts(c *gin.Context) {
|
||||
query := h.db.Model(&models.AgentAlert{})
|
||||
if c.Query("resolved") == "false" {
|
||||
query = query.Where("resolved = ?", false)
|
||||
}
|
||||
if hostname := c.Query("hostname"); hostname != "" {
|
||||
query = query.Where("hostname = ?", hostname)
|
||||
}
|
||||
var alerts []models.AgentAlert
|
||||
query.Order("created_at DESC").Limit(200).Find(&alerts)
|
||||
c.JSON(200, models.Response{Code: 200, Data: alerts})
|
||||
}
|
||||
|
||||
// ResolveAlert POST /api/v1/agent/alerts/:id/resolve
|
||||
func (h *AgentTaskHandler) ResolveAlert(c *gin.Context) {
|
||||
now := time.Now()
|
||||
h.db.Model(&models.AgentAlert{}).Where("id = ?", c.Param("id")).
|
||||
Updates(map[string]interface{}{"resolved": true, "resolved_at": now})
|
||||
c.JSON(200, models.Response{Code: 200, Message: "ok"})
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"ezops/internal/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// AlertEngine 告警引擎 — 定时扫描 Agent 上报数据并触发告警 // Logan.Li
|
||||
type AlertEngine struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewAlertEngine(db *gorm.DB) *AlertEngine {
|
||||
return &AlertEngine{db: db}
|
||||
}
|
||||
|
||||
// Check 每 30 秒调用一次
|
||||
func (e *AlertEngine) Check() {
|
||||
e.checkResourceAlerts()
|
||||
e.checkOfflineAlerts()
|
||||
e.checkPortAlerts()
|
||||
e.autoResolveAlerts()
|
||||
}
|
||||
|
||||
// 检查 CPU/内存/磁盘 阈值
|
||||
func (e *AlertEngine) checkResourceAlerts() {
|
||||
var rules []models.AgentRule
|
||||
e.db.Where("enabled = ? AND metric IN ?", true, []string{"cpu", "mem", "disk"}).Find(&rules)
|
||||
|
||||
for _, rule := range rules {
|
||||
// 获取目标主机最新上报
|
||||
var report models.AgentReport
|
||||
query := e.db.Model(&models.AgentReport{})
|
||||
if rule.Hostname != "" {
|
||||
query = query.Where("hostname = ?", rule.Hostname)
|
||||
}
|
||||
query.Order("id DESC").First(&report)
|
||||
if report.ID == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
var value float64
|
||||
switch rule.Metric {
|
||||
case "cpu":
|
||||
value = report.CPUUsage
|
||||
case "mem":
|
||||
value = report.MemUsage
|
||||
case "disk":
|
||||
var disks []models.DiskMetric
|
||||
json.Unmarshal([]byte(report.DiskInfo), &disks)
|
||||
for _, d := range disks {
|
||||
if d.UsagePercent > value {
|
||||
value = d.UsagePercent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否超阈值
|
||||
if !e.compareOperator(rule.Operator, value, rule.Threshold) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 避免重复告警:如果最近 10 分钟已有同规则未恢复告警,不重复
|
||||
var existing models.AgentAlert
|
||||
tenMinAgo := time.Now().Add(-10 * time.Minute)
|
||||
e.db.Where("rule_name = ? AND hostname = ? AND resolved = ? AND created_at > ?",
|
||||
rule.Name, report.Hostname, false, tenMinAgo).First(&existing)
|
||||
if existing.ID > 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// 创建告警
|
||||
alert := models.AgentAlert{
|
||||
RuleName: rule.Name,
|
||||
Hostname: report.Hostname,
|
||||
Metric: rule.Metric,
|
||||
Severity: "warning",
|
||||
Message: fmt.Sprintf("%s: %s %.1f%% %s %.1f%%", report.Hostname, rule.Metric, value, rule.Operator, rule.Threshold),
|
||||
Value: value,
|
||||
Threshold: rule.Threshold,
|
||||
}
|
||||
if value > rule.Threshold*1.5 {
|
||||
alert.Severity = "critical"
|
||||
}
|
||||
e.db.Create(&alert)
|
||||
|
||||
// 更新规则最后触发时间
|
||||
now := time.Now()
|
||||
e.db.Model(&rule).Update("last_triggered", now)
|
||||
}
|
||||
}
|
||||
|
||||
// 检查主机离线(超过 3 分钟无上报)— 内置规则,无需手动创建
|
||||
func (e *AlertEngine) checkOfflineAlerts() {
|
||||
offlineThreshold := 3 * time.Minute
|
||||
|
||||
// 查所有曾上报的主机
|
||||
var hostnames []string
|
||||
e.db.Model(&models.AgentReport{}).Distinct("hostname").Pluck("hostname", &hostnames)
|
||||
|
||||
for _, hostname := range hostnames {
|
||||
var report models.AgentReport
|
||||
e.db.Where("hostname = ?", hostname).Order("id DESC").First(&report)
|
||||
if report.ID == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
isOffline := time.Since(report.ReportTime) > offlineThreshold
|
||||
|
||||
// 检查是否有未恢复的离线告警
|
||||
var existing models.AgentAlert
|
||||
e.db.Where("hostname = ? AND metric = ? AND resolved = ?",
|
||||
hostname, "offline", false).First(&existing)
|
||||
|
||||
if isOffline && existing.ID == 0 {
|
||||
alert := models.AgentAlert{
|
||||
RuleName: "主机离线",
|
||||
Hostname: hostname,
|
||||
Metric: "offline",
|
||||
Severity: "critical",
|
||||
Message: fmt.Sprintf("主机 %s 已超过 3 分钟无数据上报,可能已离线", hostname),
|
||||
Value: time.Since(report.ReportTime).Minutes(),
|
||||
Threshold: 3,
|
||||
}
|
||||
e.db.Create(&alert)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查端口消失(之前有的监听端口现在没了)
|
||||
func (e *AlertEngine) checkPortAlerts() {
|
||||
var rules []models.AgentRule
|
||||
e.db.Where("enabled = ? AND metric = ?", true, "port_down").Find(&rules)
|
||||
|
||||
for _, rule := range rules {
|
||||
if rule.Port == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// 获取目标主机最新上报
|
||||
var report models.AgentReport
|
||||
query := e.db.Model(&models.AgentReport{})
|
||||
if rule.Hostname != "" {
|
||||
query = query.Where("hostname = ?", rule.Hostname)
|
||||
}
|
||||
query.Order("id DESC").First(&report)
|
||||
if report.ID == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查端口是否在最新上报中
|
||||
var ports []models.PortMetric
|
||||
json.Unmarshal([]byte(report.PortInfo), &ports)
|
||||
|
||||
portUp := false
|
||||
for _, p := range ports {
|
||||
if p.Port == rule.Port {
|
||||
portUp = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if portUp {
|
||||
continue // 端口正常
|
||||
}
|
||||
|
||||
// 端口消失了,检查是否已有未恢复告警
|
||||
var existing models.AgentAlert
|
||||
e.db.Where("rule_name = ? AND hostname = ? AND resolved = ?",
|
||||
rule.Name, report.Hostname, false).First(&existing)
|
||||
if existing.ID > 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
alert := models.AgentAlert{
|
||||
RuleName: rule.Name,
|
||||
Hostname: report.Hostname,
|
||||
Metric: "port_down",
|
||||
Severity: "critical",
|
||||
Message: fmt.Sprintf("主机 %s 端口 %d 服务疑似下线", report.Hostname, rule.Port),
|
||||
Value: float64(rule.Port),
|
||||
Threshold: float64(rule.Port),
|
||||
}
|
||||
e.db.Create(&alert)
|
||||
|
||||
now := time.Now()
|
||||
e.db.Model(&rule).Update("last_triggered", now)
|
||||
}
|
||||
}
|
||||
|
||||
// 自动恢复告警(指标恢复正常)
|
||||
func (e *AlertEngine) autoResolveAlerts() {
|
||||
var alerts []models.AgentAlert
|
||||
e.db.Where("resolved = ?", false).Find(&alerts)
|
||||
|
||||
for _, alert := range alerts {
|
||||
resolved := false
|
||||
|
||||
switch alert.Metric {
|
||||
case "offline":
|
||||
// 主机重新上报了
|
||||
var report models.AgentReport
|
||||
e.db.Where("hostname = ?", alert.Hostname).Order("id DESC").First(&report)
|
||||
if report.ID > 0 && time.Since(report.ReportTime) < 3*time.Minute {
|
||||
resolved = true
|
||||
}
|
||||
case "cpu", "mem", "disk":
|
||||
// 获取对应规则,检查是否恢复正常
|
||||
var rule models.AgentRule
|
||||
e.db.Where("name = ?", alert.RuleName).First(&rule)
|
||||
if rule.ID == 0 {
|
||||
continue
|
||||
}
|
||||
var report models.AgentReport
|
||||
if rule.Hostname != "" {
|
||||
e.db.Where("hostname = ?", alert.Hostname).Order("id DESC").First(&report)
|
||||
} else {
|
||||
e.db.Where("hostname = ?", alert.Hostname).Order("id DESC").First(&report)
|
||||
}
|
||||
if report.ID == 0 {
|
||||
continue
|
||||
}
|
||||
var value float64
|
||||
switch alert.Metric {
|
||||
case "cpu":
|
||||
value = report.CPUUsage
|
||||
case "mem":
|
||||
value = report.MemUsage
|
||||
case "disk":
|
||||
var disks []models.DiskMetric
|
||||
json.Unmarshal([]byte(report.DiskInfo), &disks)
|
||||
for _, d := range disks {
|
||||
if d.UsagePercent > value {
|
||||
value = d.UsagePercent
|
||||
}
|
||||
}
|
||||
}
|
||||
if !e.compareOperator(rule.Operator, value, rule.Threshold) {
|
||||
resolved = true
|
||||
}
|
||||
case "port_down":
|
||||
var rule models.AgentRule
|
||||
e.db.Where("name = ?", alert.RuleName).First(&rule)
|
||||
if rule.ID > 0 {
|
||||
var report models.AgentReport
|
||||
e.db.Where("hostname = ?", alert.Hostname).Order("id DESC").First(&report)
|
||||
var ports []models.PortMetric
|
||||
json.Unmarshal([]byte(report.PortInfo), &ports)
|
||||
for _, p := range ports {
|
||||
if p.Port == rule.Port {
|
||||
resolved = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if resolved {
|
||||
now := time.Now()
|
||||
e.db.Model(&alert).Updates(map[string]interface{}{
|
||||
"resolved": true,
|
||||
"resolved_at": now,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (e *AlertEngine) compareOperator(op string, val, threshold float64) bool {
|
||||
switch op {
|
||||
case ">":
|
||||
return val > threshold
|
||||
case ">=":
|
||||
return val >= threshold
|
||||
case "<":
|
||||
return val < threshold
|
||||
case "<=":
|
||||
return val <= threshold
|
||||
case "==":
|
||||
return val == threshold
|
||||
default:
|
||||
return val > threshold
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,13 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"ezops/internal/models"
|
||||
|
||||
@@ -10,6 +15,50 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// runLocal executes a script or shell command locally on the AIOps host and
|
||||
// returns the combined stdout+stderr output, the process exit code (0 = success)
|
||||
// and the wall-clock duration in milliseconds. A timeout prevents runaway jobs.
|
||||
func runLocal(scriptType, content string, timeoutSec int) (output string, exitCode int, durationMs int) {
|
||||
start := time.Now()
|
||||
timeout := time.Duration(timeoutSec) * time.Second
|
||||
if timeout <= 0 {
|
||||
timeout = 300 * time.Second
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
var cmd *exec.Cmd
|
||||
switch scriptType {
|
||||
case "python":
|
||||
cmd = exec.CommandContext(ctx, "python3", "-c", content)
|
||||
default: // shell / command / "" → run via bash
|
||||
cmd = exec.CommandContext(ctx, "bash", "-c", content)
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
cmd.Stderr = &out
|
||||
|
||||
err := cmd.Run()
|
||||
durationMs = int(time.Since(start).Milliseconds())
|
||||
output = out.String()
|
||||
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
output += fmt.Sprintf("\n[超时] 执行超过 %d 秒被终止", timeoutSec)
|
||||
return output, -1, durationMs
|
||||
}
|
||||
if err != nil {
|
||||
if exitError, ok := err.(*exec.ExitError); ok {
|
||||
exitCode = exitError.ExitCode()
|
||||
} else {
|
||||
exitCode = -1
|
||||
output += "\n" + err.Error()
|
||||
}
|
||||
return output, exitCode, durationMs
|
||||
}
|
||||
return output, 0, durationMs
|
||||
}
|
||||
|
||||
// AutomationHandler 自动化运维处理器
|
||||
type AutomationHandler struct {
|
||||
db *gorm.DB
|
||||
@@ -278,15 +327,28 @@ func (h *AutomationHandler) ExecuteCronJob(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve the content to execute. For type=script, load the linked Script.
|
||||
content := job.Content
|
||||
scriptType := "shell"
|
||||
if job.Type == "script" && job.ScriptID != nil {
|
||||
var script models.Script
|
||||
if err := h.db.First(&script, *job.ScriptID).Error; err == nil {
|
||||
content = script.Content
|
||||
scriptType = script.Type
|
||||
h.db.Model(&script).UpdateColumn("run_count", script.RunCount+1)
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
execution := models.TaskExecution{
|
||||
TaskType: "cron",
|
||||
TaskID: uint(id),
|
||||
TaskName: job.Name,
|
||||
Status: "pending",
|
||||
Status: "running",
|
||||
TargetType: job.TargetType,
|
||||
Targets: job.Targets,
|
||||
StartedAt: &now,
|
||||
}
|
||||
|
||||
if err := h.db.Create(&execution).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.Response{
|
||||
Code: 500,
|
||||
@@ -295,9 +357,46 @@ func (h *AutomationHandler) ExecuteCronJob(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Execute locally on the AIOps host.
|
||||
output, exitCode, duration := runLocal(scriptType, content, job.Timeout)
|
||||
finishedAt := time.Now()
|
||||
status := "success"
|
||||
if exitCode != 0 {
|
||||
status = "failed"
|
||||
}
|
||||
|
||||
h.db.Model(&execution).Updates(map[string]interface{}{
|
||||
"status": status,
|
||||
"output": output,
|
||||
"duration": duration,
|
||||
"finished_at": finishedAt,
|
||||
})
|
||||
|
||||
// Update cron job statistics.
|
||||
statsUpdates := map[string]interface{}{
|
||||
"last_run_at": now,
|
||||
"last_status": status,
|
||||
"run_count": job.RunCount + 1,
|
||||
}
|
||||
if status == "success" {
|
||||
statsUpdates["success_count"] = job.SuccessCount + 1
|
||||
} else {
|
||||
statsUpdates["fail_count"] = job.FailCount + 1
|
||||
}
|
||||
h.db.Model(&job).Updates(statsUpdates)
|
||||
|
||||
execution.Output = output
|
||||
execution.Status = status
|
||||
execution.Duration = duration
|
||||
execution.FinishedAt = &finishedAt
|
||||
|
||||
msg := "执行成功"
|
||||
if status == "failed" {
|
||||
msg = "执行失败"
|
||||
}
|
||||
c.JSON(http.StatusOK, models.Response{
|
||||
Code: 200,
|
||||
Message: "执行中",
|
||||
Message: msg,
|
||||
Data: execution,
|
||||
})
|
||||
}
|
||||
@@ -520,23 +619,19 @@ func (h *AutomationHandler) ExecuteScript(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// The request body is optional — when omitted we execute locally.
|
||||
var req models.ExecuteScriptRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.Response{
|
||||
Code: 400,
|
||||
Message: "请求参数错误: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
|
||||
now := time.Now()
|
||||
execution := models.TaskExecution{
|
||||
TaskType: "script",
|
||||
TaskID: uint(id),
|
||||
TaskName: script.Name,
|
||||
Status: "pending",
|
||||
Status: "running",
|
||||
TargetType: req.TargetType,
|
||||
StartedAt: &now,
|
||||
}
|
||||
|
||||
if err := h.db.Create(&execution).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.Response{
|
||||
Code: 500,
|
||||
@@ -545,9 +640,74 @@ func (h *AutomationHandler) ExecuteScript(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Execute locally on the AIOps host.
|
||||
output, exitCode, duration := runLocal(script.Type, script.Content, script.Timeout)
|
||||
finishedAt := time.Now()
|
||||
status := "success"
|
||||
if exitCode != 0 {
|
||||
status = "failed"
|
||||
}
|
||||
|
||||
h.db.Model(&execution).Updates(map[string]interface{}{
|
||||
"status": status,
|
||||
"output": output,
|
||||
"duration": duration,
|
||||
"finished_at": finishedAt,
|
||||
})
|
||||
h.db.Model(&script).UpdateColumn("run_count", script.RunCount+1)
|
||||
|
||||
execution.Output = output
|
||||
execution.Status = status
|
||||
execution.Duration = duration
|
||||
execution.FinishedAt = &finishedAt
|
||||
|
||||
msg := "执行成功"
|
||||
if status == "failed" {
|
||||
msg = "执行失败"
|
||||
}
|
||||
c.JSON(http.StatusOK, models.Response{
|
||||
Code: 200,
|
||||
Message: "执行中",
|
||||
Message: msg,
|
||||
Data: execution,
|
||||
})
|
||||
}
|
||||
|
||||
// ListExecutions 获取任务执行记录列表
|
||||
// @Summary 获取任务执行记录
|
||||
// @Description 获取脚本/定时任务的执行历史记录(支持 task_type / task_id 筛选)
|
||||
// @Tags 自动化运维
|
||||
// @Produce json
|
||||
// @Security Bearer
|
||||
// @Param page query int false "页码"
|
||||
// @Param page_size query int false "每页数量"
|
||||
// @Param task_type query string false "任务类型 (cron/script)"
|
||||
// @Param task_id query string false "任务ID"
|
||||
// @Success 200 {object} models.Response{data=models.ListResponse}
|
||||
// @Router /task-executions [get]
|
||||
func (h *AutomationHandler) ListExecutions(c *gin.Context) {
|
||||
var query models.PageQuery
|
||||
c.ShouldBindQuery(&query)
|
||||
|
||||
db := h.db.Model(&models.TaskExecution{})
|
||||
if taskType := c.Query("task_type"); taskType != "" {
|
||||
db = db.Where("task_type = ?", taskType)
|
||||
}
|
||||
if taskID := c.Query("task_id"); taskID != "" {
|
||||
db = db.Where("task_id = ?", taskID)
|
||||
}
|
||||
|
||||
var total int64
|
||||
db.Count(&total)
|
||||
|
||||
var execs []models.TaskExecution
|
||||
db.Order("created_at DESC").Offset(query.GetOffset()).Limit(query.GetLimit()).Find(&execs)
|
||||
|
||||
c.JSON(http.StatusOK, models.Response{
|
||||
Code: 200,
|
||||
Message: "success",
|
||||
Data: models.ListResponse{
|
||||
List: execs,
|
||||
Total: total,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Register 注册所有路由
|
||||
@@ -260,6 +261,9 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config) {
|
||||
scripts.POST("/:id/execute", automationHandler.ExecuteScript)
|
||||
}
|
||||
|
||||
// 任务执行记录(脚本/定时任务执行历史)
|
||||
authorized.GET("/task-executions", automationHandler.ListExecutions)
|
||||
|
||||
// 审批流程路由
|
||||
approvalHandler := handlers.NewApprovalHandler(db)
|
||||
approvals := authorized.Group("/approvals")
|
||||
@@ -306,16 +310,44 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config) {
|
||||
audit.POST("/logs/clear", auditHandler.ClearAuditLogs)
|
||||
}
|
||||
|
||||
// Agent 上报路由(无需 JWT,使用 Agent Token 鉴权)
|
||||
// Agent 上报路由(无需 JWT)
|
||||
agentHandler := handlers.NewAgentHandler(db)
|
||||
taskHandler := handlers.NewAgentTaskHandler(db)
|
||||
agentGroup := api.Group("/agent")
|
||||
{
|
||||
agentGroup.POST("/report", agentHandler.ReceiveReport)
|
||||
agentGroup.GET("/tasks/poll", taskHandler.PollTasks)
|
||||
agentGroup.POST("/tasks/:task_id/result", taskHandler.SubmitResult)
|
||||
}
|
||||
// Agent 数据查询(需要 JWT)
|
||||
// Agent 数据查询 + 任务管理(需要 JWT)
|
||||
agentQuery := authorized.Group("/agent")
|
||||
{
|
||||
agentQuery.GET("/latest", agentHandler.GetLatestReports)
|
||||
agentQuery.GET("/history/:hostname", agentHandler.GetHostHistory)
|
||||
|
||||
// 远程任务
|
||||
agentQuery.POST("/tasks", taskHandler.CreateTask)
|
||||
agentQuery.GET("/tasks", taskHandler.ListTasks)
|
||||
agentQuery.GET("/tasks/:id", taskHandler.GetTask)
|
||||
|
||||
// 告警规则
|
||||
agentQuery.GET("/rules", taskHandler.ListRules)
|
||||
agentQuery.POST("/rules", taskHandler.CreateRule)
|
||||
agentQuery.PUT("/rules/:id", taskHandler.UpdateRule)
|
||||
agentQuery.DELETE("/rules/:id", taskHandler.DeleteRule)
|
||||
|
||||
// 告警事件
|
||||
agentQuery.GET("/alerts", taskHandler.ListAlerts)
|
||||
agentQuery.POST("/alerts/:id/resolve", taskHandler.ResolveAlert)
|
||||
}
|
||||
|
||||
// 启动告警引擎(每 30 秒检查一次)
|
||||
alertEngine := handlers.NewAlertEngine(db)
|
||||
go func() {
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
alertEngine.Check()
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// AgentTask Agent 远程任务 // Logan.Li
|
||||
type AgentTask struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
TaskID string `gorm:"size:36;uniqueIndex;not null" json:"task_id"` // UUID
|
||||
Hostname string `gorm:"size:255;index;not null" json:"hostname"` // 目标主机
|
||||
Type string `gorm:"size:32;not null" json:"type"` // command / script / file
|
||||
Command string `gorm:"type:text" json:"command"` // 命令或脚本内容
|
||||
Timeout int `gorm:"default:60" json:"timeout"` // 超时秒数
|
||||
Status string `gorm:"size:20;default:pending;index" json:"status"` // pending/running/success/failed/timeout
|
||||
Result string `gorm:"type:text" json:"result"` // 执行输出
|
||||
ExitCode int `gorm:"default:-1" json:"exit_code"` // 退出码
|
||||
CreatedBy string `gorm:"size:255" json:"created_by"` // 创建人
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
PickedAt *time.Time `json:"picked_at"` // Agent 拉取时间
|
||||
FinishedAt *time.Time `json:"finished_at"` // 完成时间
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
}
|
||||
|
||||
func (AgentTask) TableName() string {
|
||||
return "agent_tasks"
|
||||
}
|
||||
|
||||
// AgentRule 告警规则(基于 Agent 数据) // Logan.Li
|
||||
type AgentRule struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
Name string `gorm:"size:255;not null" json:"name"`
|
||||
Hostname string `gorm:"size:255" json:"hostname"` // 空表示全部主机
|
||||
Metric string `gorm:"size:32;not null" json:"metric"` // cpu/mem/disk/offline/port_down
|
||||
Operator string `gorm:"size:10;default:>" json:"operator"` // > >= < <= ==
|
||||
Threshold float64 `gorm:"type:decimal(10,2)" json:"threshold"`
|
||||
Port int `json:"port"` // port_down 专用
|
||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||
LastTriggered *time.Time `json:"last_triggered"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
}
|
||||
|
||||
func (AgentRule) TableName() string {
|
||||
return "agent_rules"
|
||||
}
|
||||
|
||||
// AgentAlert Agent 告警事件 // Logan.Li
|
||||
type AgentAlert struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
RuleName string `gorm:"size:255" json:"rule_name"`
|
||||
Hostname string `gorm:"size:255;index" json:"hostname"`
|
||||
Metric string `gorm:"size:32" json:"metric"`
|
||||
Severity string `gorm:"size:20;default:warning" json:"severity"` // critical/warning/info
|
||||
Message string `gorm:"type:text" json:"message"`
|
||||
Value float64 `gorm:"type:decimal(10,2)" json:"value"`
|
||||
Threshold float64 `gorm:"type:decimal(10,2)" json:"threshold"`
|
||||
Resolved bool `gorm:"default:false;index" json:"resolved"`
|
||||
ResolvedAt *time.Time `json:"resolved_at"`
|
||||
CreatedAt time.Time `gorm:"index" json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
}
|
||||
|
||||
func (AgentAlert) TableName() string {
|
||||
return "agent_alerts"
|
||||
}
|
||||
@@ -92,5 +92,8 @@ func AutoMigrate(db *gorm.DB) error {
|
||||
&models.CloudBilling{},
|
||||
&models.BillingAlertRule{},
|
||||
&models.AgentReport{},
|
||||
&models.AgentTask{},
|
||||
&models.AgentRule{},
|
||||
&models.AgentAlert{},
|
||||
)
|
||||
}
|
||||
@@ -35,6 +35,8 @@ import LogManager from '@/components/monitor/logs/LogManager';
|
||||
import AlertCenter from '@/components/monitor/alert-center/AlertCenter';
|
||||
import TraceManager from '@/components/monitor/trace/TraceManager';
|
||||
import GrafanaDashboard from '@/components/monitor/dashboard/GrafanaDashboard';
|
||||
import { AgentMonitor } from '@/components/agent/AgentMonitor';
|
||||
import { AgentAlerts } from '@/components/agent/AgentAlerts';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@@ -61,6 +63,7 @@ function App() {
|
||||
<Route path="regions" element={<RegionManager />} />
|
||||
<Route path="platforms" element={<PlatformManager />} />
|
||||
<Route path="cloud-billing" element={<CloudBilling />} />
|
||||
<Route path="agent-monitor" element={<AgentMonitor />} />
|
||||
<Route path="clusters" element={<K8sClusters />} />
|
||||
<Route path="nodes" element={<K8sClusters />} />
|
||||
<Route path="workloads" element={<K8sClusters />} />
|
||||
@@ -75,6 +78,7 @@ function App() {
|
||||
<Route path="logs-monitor" element={<LogManager />} />
|
||||
<Route path="trace" element={<TraceManager />} />
|
||||
<Route path="grafana" element={<GrafanaDashboard />} />
|
||||
<Route path="agent-alerts" element={<AgentAlerts />} />
|
||||
<Route path="alerts" element={<AlertManager />} />
|
||||
<Route path="logs" element={<AlertManager />} />
|
||||
<Route path="approval" element={<ApprovalFlowManager />} />
|
||||
|
||||
@@ -0,0 +1,542 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import { agentApi } from '@/services/api';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Bell,
|
||||
Cpu,
|
||||
MemoryStick,
|
||||
HardDrive,
|
||||
WifiOff,
|
||||
Network,
|
||||
Clock,
|
||||
} from 'lucide-react';
|
||||
|
||||
// ============ 常量 ============
|
||||
|
||||
const METRIC_OPTIONS = [
|
||||
{ value: 'cpu', label: 'CPU', icon: Cpu },
|
||||
{ value: 'mem', label: '内存', icon: MemoryStick },
|
||||
{ value: 'disk', label: '磁盘', icon: HardDrive },
|
||||
{ value: 'offline', label: '主机离线', icon: WifiOff },
|
||||
{ value: 'port_down', label: '端口不通', icon: Network },
|
||||
];
|
||||
|
||||
const OPERATOR_OPTIONS = [
|
||||
{ value: '>', label: '> 大于' },
|
||||
{ value: '<', label: '< 小于' },
|
||||
{ value: '>=', label: '≥ 大于等于' },
|
||||
{ value: '<=', label: '≤ 小于等于' },
|
||||
{ value: '==', label: '= 等于' },
|
||||
];
|
||||
|
||||
const SEVERITY_OPTIONS = [
|
||||
{ value: 'critical', label: '严重', color: 'bg-red-500/15 text-red-400 border-red-500/30' },
|
||||
{ value: 'warning', label: '警告', color: 'bg-amber-500/15 text-amber-400 border-amber-500/30' },
|
||||
{ value: 'info', label: '提示', color: 'bg-blue-500/15 text-blue-400 border-blue-500/30' },
|
||||
];
|
||||
|
||||
function getMetricLabel(metric: string) {
|
||||
return METRIC_OPTIONS.find((m) => m.value === metric)?.label || metric;
|
||||
}
|
||||
|
||||
function asList(data: any): any[] {
|
||||
if (!data) return [];
|
||||
if (Array.isArray(data)) return data;
|
||||
if (Array.isArray(data.list)) return data.list;
|
||||
if (Array.isArray(data.items)) return data.items;
|
||||
if (Array.isArray(data.data)) return data.data;
|
||||
return [];
|
||||
}
|
||||
|
||||
// ============ 主组件 ============
|
||||
|
||||
export function AgentAlerts() {
|
||||
const confirm = useConfirm();
|
||||
const [activeTab, setActiveTab] = useState('events');
|
||||
const [alerts, setAlerts] = useState<any[]>([]);
|
||||
const [rules, setRules] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [resolvedView, setResolvedView] = useState(false);
|
||||
|
||||
// 规则编辑
|
||||
const [ruleOpen, setRuleOpen] = useState(false);
|
||||
const [editingRule, setEditingRule] = useState<any>(null);
|
||||
const [ruleSaving, setRuleSaving] = useState(false);
|
||||
const [ruleForm, setRuleForm] = useState({
|
||||
name: '',
|
||||
metric: 'cpu',
|
||||
operator: '>',
|
||||
threshold: '90',
|
||||
port: '',
|
||||
severity: 'warning',
|
||||
enabled: true,
|
||||
duration: '60',
|
||||
});
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [alertsRes, rulesRes] = await Promise.all([
|
||||
agentApi.listAlerts({ resolved: String(resolvedView) }),
|
||||
agentApi.listRules(),
|
||||
]);
|
||||
setAlerts(asList(alertsRes));
|
||||
setRules(asList(rulesRes));
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast.error('加载告警数据失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [resolvedView]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
// ============ 告警事件 ============
|
||||
const handleResolve = async (id: number) => {
|
||||
const ok = await confirm({ title: '恢复告警', description: '确定将此告警标记为已恢复?', confirmText: '恢复' });
|
||||
if (!ok) return;
|
||||
try {
|
||||
await agentApi.resolveAlert(id);
|
||||
toast.success('告警已恢复');
|
||||
loadData();
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message || '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
// ============ 规则 CRUD ============
|
||||
const resetRuleForm = () => {
|
||||
setRuleForm({ name: '', metric: 'cpu', operator: '>', threshold: '90', port: '', severity: 'warning', enabled: true, duration: '60' });
|
||||
setEditingRule(null);
|
||||
};
|
||||
|
||||
const openCreateRule = () => {
|
||||
resetRuleForm();
|
||||
setRuleOpen(true);
|
||||
};
|
||||
|
||||
const openEditRule = (rule: any) => {
|
||||
setEditingRule(rule);
|
||||
setRuleForm({
|
||||
name: rule.name || '',
|
||||
metric: rule.metric || 'cpu',
|
||||
operator: rule.operator || '>',
|
||||
threshold: String(rule.threshold ?? '90'),
|
||||
port: rule.port ? String(rule.port) : '',
|
||||
severity: rule.severity || 'warning',
|
||||
enabled: rule.enabled !== false,
|
||||
duration: rule.duration ? String(rule.duration) : '60',
|
||||
});
|
||||
setRuleOpen(true);
|
||||
};
|
||||
|
||||
const handleSaveRule = async () => {
|
||||
if (!ruleForm.name.trim()) {
|
||||
toast.error('请输入规则名称');
|
||||
return;
|
||||
}
|
||||
setRuleSaving(true);
|
||||
try {
|
||||
const payload: any = {
|
||||
name: ruleForm.name.trim(),
|
||||
metric: ruleForm.metric,
|
||||
operator: ruleForm.operator,
|
||||
threshold: Number(ruleForm.threshold) || 0,
|
||||
severity: ruleForm.severity,
|
||||
enabled: ruleForm.enabled,
|
||||
duration: Number(ruleForm.duration) || 0,
|
||||
};
|
||||
if (ruleForm.metric === 'port_down' && ruleForm.port) {
|
||||
payload.port = Number(ruleForm.port);
|
||||
}
|
||||
if (editingRule) {
|
||||
await agentApi.updateRule(editingRule.id, payload);
|
||||
toast.success('规则已更新');
|
||||
} else {
|
||||
await agentApi.createRule(payload);
|
||||
toast.success('规则已创建');
|
||||
}
|
||||
setRuleOpen(false);
|
||||
resetRuleForm();
|
||||
loadData();
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message || '保存失败');
|
||||
} finally {
|
||||
setRuleSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteRule = async (id: number) => {
|
||||
const ok = await confirm({ title: '删除规则', description: '确定删除此告警规则?', confirmText: '删除', variant: 'destructive' });
|
||||
if (!ok) return;
|
||||
try {
|
||||
await agentApi.deleteRule(id);
|
||||
toast.success('规则已删除');
|
||||
loadData();
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message || '删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleRule = async (rule: any) => {
|
||||
try {
|
||||
await agentApi.updateRule(rule.id, { ...rule, enabled: !rule.enabled });
|
||||
loadData();
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message || '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const severityBadge = (sev: string) => {
|
||||
const opt = SEVERITY_OPTIONS.find((s) => s.value === sev) || SEVERITY_OPTIONS[1];
|
||||
return (
|
||||
<Badge className={opt.color}>
|
||||
<AlertTriangle className="h-3 w-3 mr-1" />
|
||||
{opt.label}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 页头 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-100">Agent 告警</h2>
|
||||
<p className="text-sm text-slate-400 mt-0.5">基于 Agent 上报指标的告警规则与事件</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={loadData} disabled={loading} className="bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700">
|
||||
<RefreshCw className={`h-4 w-4 mr-1.5 ${loading ? 'animate-spin' : ''}`} />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList className="bg-slate-800 border border-slate-700">
|
||||
<TabsTrigger value="events" className="data-[state=active]:bg-slate-700">告警事件</TabsTrigger>
|
||||
<TabsTrigger value="rules" className="data-[state=active]:bg-slate-700">告警规则</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* ========== 告警事件 ========== */}
|
||||
<TabsContent value="events" className="mt-4 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={resolvedView ? 'outline' : 'default'}
|
||||
onClick={() => setResolvedView(false)}
|
||||
className={!resolvedView ? 'bg-blue-600 hover:bg-blue-700 text-white' : 'bg-slate-800 border-slate-700 text-slate-300'}
|
||||
>
|
||||
未恢复
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={resolvedView ? 'default' : 'outline'}
|
||||
onClick={() => setResolvedView(true)}
|
||||
className={resolvedView ? 'bg-blue-600 hover:bg-blue-700 text-white' : 'bg-slate-800 border-slate-700 text-slate-300'}
|
||||
>
|
||||
已恢复
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="flex items-center justify-center py-16">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-slate-500" />
|
||||
<span className="ml-2 text-slate-400">加载中...</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : alerts.length === 0 ? (
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="flex flex-col items-center justify-center py-16">
|
||||
<CheckCircle className="h-10 w-10 text-emerald-500 mb-3" />
|
||||
<p className="text-slate-400">{resolvedView ? '暂无已恢复的告警' : '当前无活动告警,一切正常'}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{alerts.map((alert) => (
|
||||
<Card key={alert.id} className="bg-slate-900 border-slate-800 py-4">
|
||||
<CardContent>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-start gap-3 min-w-0 flex-1">
|
||||
<div className="w-9 h-9 rounded-lg bg-slate-800 flex items-center justify-center shrink-0">
|
||||
<AlertTriangle className={`h-4 w-4 ${(alert.severity || 'warning') === 'critical' ? 'text-red-400' : (alert.severity || 'warning') === 'warning' ? 'text-amber-400' : 'text-blue-400'}`} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{severityBadge(alert.severity)}
|
||||
<span className="font-medium text-slate-100">{alert.rule_name || alert.name || alert.title || '告警'}</span>
|
||||
</div>
|
||||
<p className="text-sm text-slate-400 mt-1">{alert.message || alert.description || '触发告警阈值'}</p>
|
||||
<div className="flex items-center gap-4 mt-2 text-xs text-slate-500 flex-wrap">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Cpu className="h-3 w-3" />
|
||||
{alert.hostname || '--'}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="text-slate-400">指标:</span> {getMetricLabel(alert.metric)}
|
||||
</span>
|
||||
{alert.value != null && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="text-slate-400">当前值:</span> {alert.value}
|
||||
</span>
|
||||
)}
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{alert.created_at || alert.triggered_at ? new Date(alert.created_at || alert.triggered_at).toLocaleString() : '--'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!resolvedView && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleResolve(alert.id)}
|
||||
className="shrink-0 h-8 bg-slate-800 border-slate-700 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-400"
|
||||
>
|
||||
<CheckCircle className="h-3.5 w-3.5 mr-1" />
|
||||
恢复
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* ========== 告警规则 ========== */}
|
||||
<TabsContent value="rules" className="mt-4 space-y-3">
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" onClick={openCreateRule} className="bg-blue-600 hover:bg-blue-700 text-white">
|
||||
<Plus className="h-4 w-4 mr-1.5" />
|
||||
新建规则
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="flex items-center justify-center py-16">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-slate-500" />
|
||||
<span className="ml-2 text-slate-400">加载中...</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : rules.length === 0 ? (
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="flex flex-col items-center justify-center py-16">
|
||||
<Bell className="h-10 w-10 text-slate-600 mb-3" />
|
||||
<p className="text-slate-400">暂无告警规则</p>
|
||||
<Button size="sm" onClick={openCreateRule} className="mt-4 bg-blue-600 hover:bg-blue-700 text-white">
|
||||
<Plus className="h-4 w-4 mr-1.5" />
|
||||
创建第一条规则
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{rules.map((rule) => {
|
||||
const MetricIcon = METRIC_OPTIONS.find((m) => m.value === rule.metric)?.icon || Cpu;
|
||||
return (
|
||||
<Card key={rule.id} className={`bg-slate-900 ${rule.enabled === false ? 'border-slate-800 opacity-60' : 'border-slate-800'} py-4`}>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div className="w-9 h-9 rounded-lg bg-slate-800 flex items-center justify-center shrink-0">
|
||||
<MetricIcon className="h-4 w-4 text-blue-400" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium text-slate-100">{rule.name}</span>
|
||||
{severityBadge(rule.severity)}
|
||||
{rule.enabled === false && (
|
||||
<Badge className="bg-slate-700 text-slate-400 border border-slate-600">已禁用</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-1.5 text-xs text-slate-500 flex-wrap">
|
||||
<span>{getMetricLabel(rule.metric)}</span>
|
||||
{rule.metric !== 'offline' && (
|
||||
<span className="text-slate-400">
|
||||
{rule.operator} {rule.threshold}{rule.metric === 'port_down' ? '' : '%'}
|
||||
</span>
|
||||
)}
|
||||
{rule.metric === 'port_down' && rule.port ? (
|
||||
<span className="text-slate-400">端口 {rule.port}</span>
|
||||
) : null}
|
||||
{rule.duration ? <span className="text-slate-400">持续 {rule.duration}s</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Switch checked={rule.enabled !== false} onCheckedChange={() => handleToggleRule(rule)} />
|
||||
<Button size="icon" variant="ghost" className="h-8 w-8 text-slate-400 hover:text-slate-100 hover:bg-slate-800" onClick={() => openEditRule(rule)}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" className="h-8 w-8 text-slate-400 hover:text-red-400 hover:bg-slate-800" onClick={() => handleDeleteRule(rule.id)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* ========== 规则编辑弹窗 ========== */}
|
||||
<Dialog open={ruleOpen} onOpenChange={(v) => { if (!v) setRuleOpen(false); }}>
|
||||
<DialogContent className="max-w-md bg-slate-900 border-slate-800 text-slate-100">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-slate-100">{editingRule ? '编辑规则' : '新建规则'}</DialogTitle>
|
||||
<DialogDescription className="text-slate-400">配置 Agent 指标告警规则</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-slate-300 text-xs">规则名称</Label>
|
||||
<Input
|
||||
value={ruleForm.name}
|
||||
onChange={(e) => setRuleForm({ ...ruleForm, name: e.target.value })}
|
||||
placeholder="例如:CPU 使用率告警"
|
||||
className="h-9 bg-slate-800 border-slate-700 text-slate-200"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-slate-300 text-xs">监控指标</Label>
|
||||
<Select value={ruleForm.metric} onValueChange={(v) => setRuleForm({ ...ruleForm, metric: v })}>
|
||||
<SelectTrigger className="h-9 bg-slate-800 border-slate-700 text-slate-200">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-slate-800 border-slate-700 text-slate-200">
|
||||
{METRIC_OPTIONS.map((m) => (
|
||||
<SelectItem key={m.value} value={m.value}>{m.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-slate-300 text-xs">严重程度</Label>
|
||||
<Select value={ruleForm.severity} onValueChange={(v) => setRuleForm({ ...ruleForm, severity: v })}>
|
||||
<SelectTrigger className="h-9 bg-slate-800 border-slate-700 text-slate-200">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-slate-800 border-slate-700 text-slate-200">
|
||||
{SEVERITY_OPTIONS.map((s) => (
|
||||
<SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ruleForm.metric !== 'offline' && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-slate-300 text-xs">比较运算符</Label>
|
||||
<Select value={ruleForm.operator} onValueChange={(v) => setRuleForm({ ...ruleForm, operator: v })}>
|
||||
<SelectTrigger className="h-9 bg-slate-800 border-slate-700 text-slate-200">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-slate-800 border-slate-700 text-slate-200">
|
||||
{OPERATOR_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-slate-300 text-xs">阈值 {ruleForm.metric !== 'port_down' ? '(%)' : ''}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={ruleForm.threshold}
|
||||
onChange={(e) => setRuleForm({ ...ruleForm, threshold: e.target.value })}
|
||||
className="h-9 bg-slate-800 border-slate-700 text-slate-200"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ruleForm.metric === 'port_down' && (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-slate-300 text-xs">监控端口</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={ruleForm.port}
|
||||
onChange={(e) => setRuleForm({ ...ruleForm, port: e.target.value })}
|
||||
placeholder="例如:8080"
|
||||
className="h-9 bg-slate-800 border-slate-700 text-slate-200"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-slate-300 text-xs">持续时间(秒)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={ruleForm.duration}
|
||||
onChange={(e) => setRuleForm({ ...ruleForm, duration: e.target.value })}
|
||||
placeholder="指标持续超限多久后告警"
|
||||
className="h-9 bg-slate-800 border-slate-700 text-slate-200"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-slate-300 text-xs">启用规则</Label>
|
||||
<Switch checked={ruleForm.enabled} onCheckedChange={(v) => setRuleForm({ ...ruleForm, enabled: v })} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="outline" onClick={() => setRuleOpen(false)} className="border-slate-700 text-slate-300">取消</Button>
|
||||
<Button onClick={handleSaveRule} disabled={ruleSaving} className="bg-blue-600 hover:bg-blue-700 text-white">
|
||||
{ruleSaving ? <Loader2 className="h-4 w-4 mr-1.5 animate-spin" /> : null}
|
||||
{editingRule ? '保存' : '创建'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AgentAlerts;
|
||||
@@ -0,0 +1,671 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { agentApi } from '@/services/api';
|
||||
import { AgentWizard } from './AgentWizard';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Cpu,
|
||||
MemoryStick,
|
||||
HardDrive,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
RefreshCw,
|
||||
Terminal,
|
||||
Rocket,
|
||||
Activity,
|
||||
Network,
|
||||
Loader2,
|
||||
Clock,
|
||||
Server,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
|
||||
// ============ 工具函数 ============
|
||||
|
||||
/** 在线判定:3 分钟内有上报视为在线 */
|
||||
function isOnline(lastReport: any): boolean {
|
||||
if (!lastReport) return false;
|
||||
const t = new Date(lastReport).getTime();
|
||||
if (Number.isNaN(t)) return false;
|
||||
return Date.now() - t < 3 * 60 * 1000;
|
||||
}
|
||||
|
||||
function timeAgo(ts: any): string {
|
||||
if (!ts) return '--';
|
||||
const t = new Date(ts).getTime();
|
||||
if (Number.isNaN(t)) return '--';
|
||||
const diff = Math.floor((Date.now() - t) / 1000);
|
||||
if (diff < 60) return `${diff}秒前`;
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}分钟前`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}小时前`;
|
||||
return new Date(ts).toLocaleString();
|
||||
}
|
||||
|
||||
function asList(data: any): any[] {
|
||||
if (!data) return [];
|
||||
if (Array.isArray(data)) return data;
|
||||
if (Array.isArray(data.list)) return data.list;
|
||||
if (Array.isArray(data.items)) return data.items;
|
||||
if (Array.isArray(data.data)) return data.data;
|
||||
return [];
|
||||
}
|
||||
|
||||
// ============ 组件 ============
|
||||
|
||||
export function AgentMonitor() {
|
||||
const [hosts, setHosts] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [selectedHost, setSelectedHost] = useState<any>(null);
|
||||
const [wizardOpen, setWizardOpen] = useState(false);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
// 远程命令
|
||||
const [cmdOpen, setCmdOpen] = useState(false);
|
||||
const [cmdHost, setCmdHost] = useState('');
|
||||
const [cmdType, setCmdType] = useState('command');
|
||||
const [cmdCommand, setCmdCommand] = useState('');
|
||||
const [cmdTimeout, setCmdTimeout] = useState('30');
|
||||
const [cmdSubmitting, setCmdSubmitting] = useState(false);
|
||||
const [cmdResult, setCmdResult] = useState<any>(null);
|
||||
const [cmdPolling, setCmdPolling] = useState(false);
|
||||
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const loadLatest = useCallback(async (silent = false) => {
|
||||
if (!silent) setRefreshing(true);
|
||||
try {
|
||||
const data = await agentApi.getLatest();
|
||||
const list = asList(data);
|
||||
setHosts(list);
|
||||
// 同步选中主机的最新数据
|
||||
if (selectedHost) {
|
||||
const fresh = list.find((h) => h.hostname === selectedHost.hostname);
|
||||
if (fresh) setSelectedHost(fresh);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!silent) toast.error('加载主机列表失败');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [selectedHost]);
|
||||
|
||||
useEffect(() => {
|
||||
loadLatest();
|
||||
timerRef.current = setInterval(() => loadLatest(true), 30000);
|
||||
return () => {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// 打开远程命令弹窗
|
||||
const openCommandDialog = (hostname: string) => {
|
||||
setCmdHost(hostname);
|
||||
setCmdType('command');
|
||||
setCmdCommand('');
|
||||
setCmdTimeout('30');
|
||||
setCmdResult(null);
|
||||
setCmdOpen(true);
|
||||
};
|
||||
|
||||
// 提交远程命令并轮询结果
|
||||
const submitCommand = async () => {
|
||||
if (!cmdCommand.trim()) {
|
||||
toast.error('请输入要执行的命令');
|
||||
return;
|
||||
}
|
||||
setCmdSubmitting(true);
|
||||
setCmdResult(null);
|
||||
try {
|
||||
const res: any = await agentApi.createTask({
|
||||
hostname: cmdHost,
|
||||
type: cmdType,
|
||||
command: cmdCommand.trim(),
|
||||
timeout: Number(cmdTimeout) || 30,
|
||||
});
|
||||
const taskId = res?.id ?? res?.task_id;
|
||||
toast.success('任务已下发');
|
||||
setCmdSubmitting(false);
|
||||
|
||||
// 轮询任务结果
|
||||
if (taskId != null) {
|
||||
setCmdPolling(true);
|
||||
let attempts = 0;
|
||||
pollTimerRef.current = setInterval(async () => {
|
||||
attempts++;
|
||||
try {
|
||||
const tasks: any = await agentApi.listTasks({ hostname: cmdHost });
|
||||
const list = asList(tasks);
|
||||
const t = list.find((x: any) => String(x.id) === String(taskId));
|
||||
if (t && t.status && t.status !== 'pending' && t.status !== 'running') {
|
||||
setCmdResult(t);
|
||||
setCmdPolling(false);
|
||||
if (pollTimerRef.current) {
|
||||
clearInterval(pollTimerRef.current);
|
||||
pollTimerRef.current = null;
|
||||
}
|
||||
loadLatest(true);
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
if (attempts > 15) {
|
||||
// 30s 超时
|
||||
setCmdPolling(false);
|
||||
setCmdResult({ status: 'timeout', result: '等待结果超时,请稍后在任务列表查看' });
|
||||
if (pollTimerRef.current) {
|
||||
clearInterval(pollTimerRef.current);
|
||||
pollTimerRef.current = null;
|
||||
}
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message || '任务下发失败');
|
||||
setCmdSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const closeCommandDialog = () => {
|
||||
setCmdOpen(false);
|
||||
if (pollTimerRef.current) {
|
||||
clearInterval(pollTimerRef.current);
|
||||
pollTimerRef.current = null;
|
||||
}
|
||||
setCmdPolling(false);
|
||||
};
|
||||
|
||||
const onlineCount = hosts.filter((h) => isOnline(h.last_report || h.last_reported_at || h.updated_at)).length;
|
||||
|
||||
const stats = [
|
||||
{ label: '主机总数', value: hosts.length, icon: Server, color: 'text-blue-400' },
|
||||
{ label: '在线', value: onlineCount, icon: Wifi, color: 'text-emerald-400' },
|
||||
{ label: '离线', value: hosts.length - onlineCount, icon: WifiOff, color: 'text-red-400' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 页头 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-100">Agent 监控</h2>
|
||||
<p className="text-sm text-slate-400 mt-0.5">实时监控 Agent 上报的主机指标</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => loadLatest()}
|
||||
disabled={refreshing}
|
||||
className="bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700 hover:text-slate-100"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 mr-1.5 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
刷新
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setWizardOpen(true)}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white"
|
||||
>
|
||||
<Rocket className="h-4 w-4 mr-1.5" />
|
||||
部署Agent
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{stats.map((s) => (
|
||||
<Card key={s.label} className="bg-slate-900 border-slate-800 py-4">
|
||||
<CardContent className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-slate-800 flex items-center justify-center">
|
||||
<s.icon className={`h-5 w-5 ${s.color}`} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-slate-100">{s.value}</div>
|
||||
<div className="text-xs text-slate-400">{s.label}</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 主机列表 */}
|
||||
{loading ? (
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="flex items-center justify-center py-16">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-slate-500" />
|
||||
<span className="ml-2 text-slate-400">加载中...</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : hosts.length === 0 ? (
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="flex flex-col items-center justify-center py-16">
|
||||
<Server className="h-10 w-10 text-slate-600 mb-3" />
|
||||
<p className="text-slate-400">暂无 Agent 上报数据</p>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setWizardOpen(true)}
|
||||
className="mt-4 bg-blue-600 hover:bg-blue-700 text-white"
|
||||
>
|
||||
<Rocket className="h-4 w-4 mr-1.5" />
|
||||
立即部署
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
|
||||
{hosts.map((host) => {
|
||||
const online = isOnline(host.last_report || host.last_reported_at || host.updated_at);
|
||||
const cpu = Number(host.cpu_percent ?? host.cpu ?? 0);
|
||||
const mem = Number(host.mem_percent ?? host.mem ?? 0);
|
||||
const disk = Number(host.disk_percent ?? host.disk ?? 0);
|
||||
const ports: any[] = Array.isArray(host.ports) ? host.ports : [];
|
||||
return (
|
||||
<Card
|
||||
key={host.hostname}
|
||||
className="bg-slate-900 border-slate-800 hover:border-slate-700 transition-colors cursor-pointer py-4"
|
||||
onClick={() => setSelectedHost(host)}
|
||||
>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Server className="h-4 w-4 text-slate-500 shrink-0" />
|
||||
<span className="font-medium text-slate-100 truncate">{host.hostname}</span>
|
||||
</div>
|
||||
{online ? (
|
||||
<Badge className="bg-emerald-500/15 text-emerald-400 border border-emerald-500/30">
|
||||
<Wifi className="h-3 w-3 mr-1" />
|
||||
在线
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="bg-red-500/15 text-red-400 border border-red-500/30">
|
||||
<WifiOff className="h-3 w-3 mr-1" />
|
||||
离线
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 指标条 */}
|
||||
<div className="space-y-2">
|
||||
<MetricBar icon={Cpu} label="CPU" value={cpu} />
|
||||
<MetricBar icon={MemoryStick} label="内存" value={mem} />
|
||||
<MetricBar icon={HardDrive} label="磁盘" value={disk} />
|
||||
</div>
|
||||
|
||||
{/* 端口 & 时间 */}
|
||||
<div className="flex items-center justify-between text-xs text-slate-400">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Network className="h-3.5 w-3.5" />
|
||||
{ports.length} 个端口
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
{timeAgo(host.last_report || host.last_reported_at || host.updated_at)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 操作 */}
|
||||
<div className="flex gap-2 pt-1" onClick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="flex-1 h-8 bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700"
|
||||
onClick={() => openCommandDialog(host.hostname)}
|
||||
>
|
||||
<Terminal className="h-3.5 w-3.5 mr-1" />
|
||||
远程命令
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="flex-1 h-8 bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700"
|
||||
onClick={() => setSelectedHost(host)}
|
||||
>
|
||||
<Activity className="h-3.5 w-3.5 mr-1" />
|
||||
详情
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 详情抽屉 */}
|
||||
<HostDetailDialog
|
||||
host={selectedHost}
|
||||
onClose={() => setSelectedHost(null)}
|
||||
onCommand={(hn) => openCommandDialog(hn)}
|
||||
/>
|
||||
|
||||
{/* 远程命令弹窗 */}
|
||||
<Dialog open={cmdOpen} onOpenChange={(v) => { if (!v) closeCommandDialog(); }}>
|
||||
<DialogContent className="max-w-2xl bg-slate-900 border-slate-800 text-slate-100">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-slate-100 flex items-center gap-2">
|
||||
<Terminal className="h-5 w-5 text-blue-400" />
|
||||
远程命令
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-slate-400">
|
||||
在 {cmdHost} 上执行命令
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-slate-300 text-xs">目标主机</Label>
|
||||
<Input
|
||||
value={cmdHost}
|
||||
onChange={(e) => setCmdHost(e.target.value)}
|
||||
readOnly
|
||||
className="h-9 bg-slate-800 border-slate-700 text-slate-200"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-slate-300 text-xs">类型</Label>
|
||||
<Select value={cmdType} onValueChange={setCmdType}>
|
||||
<SelectTrigger className="h-9 bg-slate-800 border-slate-700 text-slate-200">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-slate-800 border-slate-700 text-slate-200">
|
||||
<SelectItem value="command">Shell 命令</SelectItem>
|
||||
<SelectItem value="script">多行脚本</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-slate-300 text-xs">命令</Label>
|
||||
<Textarea
|
||||
value={cmdCommand}
|
||||
onChange={(e) => setCmdCommand(e.target.value)}
|
||||
placeholder="例如:free -m && df -h"
|
||||
rows={3}
|
||||
className="bg-slate-950 border-slate-700 text-slate-200 font-mono text-sm resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-40 space-y-1.5">
|
||||
<Label className="text-slate-300 text-xs">超时(秒)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={cmdTimeout}
|
||||
onChange={(e) => setCmdTimeout(e.target.value)}
|
||||
className="h-9 bg-slate-800 border-slate-700 text-slate-200"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 结果展示 */}
|
||||
{cmdResult && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-slate-300 text-xs">执行结果</Label>
|
||||
<Badge
|
||||
className={
|
||||
cmdResult.status === 'success' || cmdResult.status === 'done' || cmdResult.status === 'completed'
|
||||
? 'bg-emerald-500/15 text-emerald-400 border border-emerald-500/30'
|
||||
: cmdResult.status === 'timeout'
|
||||
? 'bg-amber-500/15 text-amber-400 border border-amber-500/30'
|
||||
: 'bg-red-500/15 text-red-400 border border-red-500/30'
|
||||
}
|
||||
>
|
||||
{cmdResult.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<pre className="text-xs text-slate-300 font-mono whitespace-pre-wrap break-all bg-slate-950 border border-slate-800 rounded-lg p-3 max-h-48 overflow-auto">
|
||||
{cmdResult.result || cmdResult.output || cmdResult.error || '无输出'}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="outline" onClick={closeCommandDialog} className="border-slate-700 text-slate-300">
|
||||
关闭
|
||||
</Button>
|
||||
<Button
|
||||
onClick={submitCommand}
|
||||
disabled={cmdSubmitting || cmdPolling || !cmdCommand.trim()}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white"
|
||||
>
|
||||
{cmdSubmitting ? (
|
||||
<><Loader2 className="h-4 w-4 mr-1.5 animate-spin" />下发中</>
|
||||
) : cmdPolling ? (
|
||||
<><Loader2 className="h-4 w-4 mr-1.5 animate-spin" />等待结果</>
|
||||
) : (
|
||||
<><Terminal className="h-4 w-4 mr-1.5" />执行</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 部署向导 */}
|
||||
<AgentWizard open={wizardOpen} onOpenChange={setWizardOpen} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============ 指标条子组件 ============
|
||||
function MetricBar({ icon: Icon, label, value }: { icon: any; label: string; value: number }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="h-3.5 w-3.5 text-slate-500 shrink-0" />
|
||||
<span className="text-xs text-slate-400 w-8 shrink-0">{label}</span>
|
||||
<div className="flex-1">
|
||||
<Progress value={value} className={`h-1.5 ${value >= 90 ? '[&>[data-slot=progress-indicator]]:bg-red-500' : value >= 75 ? '[&>[data-slot=progress-indicator]]:bg-amber-500' : '[&>[data-slot=progress-indicator]]:bg-emerald-500'}`} />
|
||||
</div>
|
||||
<span className={`text-xs font-medium w-10 text-right ${value >= 90 ? 'text-red-400' : value >= 75 ? 'text-amber-400' : 'text-slate-300'}`}>
|
||||
{value.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============ 主机详情弹窗 ============
|
||||
function HostDetailDialog({
|
||||
host,
|
||||
onClose,
|
||||
onCommand,
|
||||
}: {
|
||||
host: any;
|
||||
onClose: () => void;
|
||||
onCommand: (hostname: string) => void;
|
||||
}) {
|
||||
const [history, setHistory] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hours, setHours] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
if (!host) return;
|
||||
let active = true;
|
||||
setLoading(true);
|
||||
agentApi
|
||||
.getHistory(host.hostname, hours)
|
||||
.then((data) => {
|
||||
if (active) setHistory(asList(data));
|
||||
})
|
||||
.catch(() => { if (active) setHistory([]); })
|
||||
.finally(() => { if (active) setLoading(false); });
|
||||
return () => { active = false; };
|
||||
}, [host, hours]);
|
||||
|
||||
if (!host) return null;
|
||||
|
||||
const online = isOnline(host.last_report || host.last_reported_at || host.updated_at);
|
||||
const cpu = Number(host.cpu_percent ?? host.cpu ?? 0);
|
||||
const mem = Number(host.mem_percent ?? host.mem ?? 0);
|
||||
const disk = Number(host.disk_percent ?? host.disk ?? 0);
|
||||
const ports: any[] = Array.isArray(host.ports) ? host.ports : [];
|
||||
|
||||
const chartData = history.map((h) => ({
|
||||
time: new Date(h.timestamp || h.time || h.created_at || Date.now()).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }),
|
||||
cpu: Number(h.cpu_percent ?? h.cpu ?? 0),
|
||||
mem: Number(h.mem_percent ?? h.mem ?? 0),
|
||||
disk: Number(h.disk_percent ?? h.disk ?? 0),
|
||||
}));
|
||||
|
||||
return (
|
||||
<Dialog open={!!host} onOpenChange={(v) => { if (!v) onClose(); }}>
|
||||
<DialogContent className="max-w-3xl bg-slate-900 border-slate-800 text-slate-100">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-slate-100 flex items-center gap-2">
|
||||
<Server className="h-5 w-5 text-blue-400" />
|
||||
{host.hostname}
|
||||
{online ? (
|
||||
<Badge className="bg-emerald-500/15 text-emerald-400 border border-emerald-500/30">在线</Badge>
|
||||
) : (
|
||||
<Badge className="bg-red-500/15 text-red-400 border border-red-500/30">离线</Badge>
|
||||
)}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 基本信息 */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-sm">
|
||||
<InfoCell label="IP 地址" value={host.ip || '--'} />
|
||||
<InfoCell label="操作系统" value={host.os || host.platform || '--'} />
|
||||
<InfoCell label="最后上报" value={timeAgo(host.last_report || host.last_reported_at || host.updated_at)} />
|
||||
<InfoCell label="端口数" value={String(ports.length)} />
|
||||
</div>
|
||||
|
||||
{/* 趋势图 */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-slate-200 flex items-center gap-1.5">
|
||||
<Activity className="h-4 w-4 text-blue-400" />
|
||||
指标趋势
|
||||
</span>
|
||||
<Select value={String(hours)} onValueChange={(v) => setHours(Number(v))}>
|
||||
<SelectTrigger size="sm" className="h-7 w-28 bg-slate-800 border-slate-700 text-slate-300 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-slate-800 border-slate-700 text-slate-200">
|
||||
<SelectItem value="1">最近 1 小时</SelectItem>
|
||||
<SelectItem value="6">最近 6 小时</SelectItem>
|
||||
<SelectItem value="24">最近 24 小时</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="h-56 rounded-lg bg-slate-950 border border-slate-800 p-3">
|
||||
{loading ? (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-slate-500" />
|
||||
</div>
|
||||
) : chartData.length === 0 ? (
|
||||
<div className="h-full flex items-center justify-center text-sm text-slate-500">暂无历史数据</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={chartData} margin={{ top: 5, right: 5, bottom: 0, left: -20 }}>
|
||||
<defs>
|
||||
<linearGradient id="gCpu" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.4} />
|
||||
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
<linearGradient id="gMem" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#10b981" stopOpacity={0.4} />
|
||||
<stop offset="95%" stopColor="#10b981" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
<linearGradient id="gDisk" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#f59e0b" stopOpacity={0.4} />
|
||||
<stop offset="95%" stopColor="#f59e0b" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
|
||||
<XAxis dataKey="time" stroke="#475569" fontSize={11} />
|
||||
<YAxis stroke="#475569" fontSize={11} domain={[0, 100]} />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: '#0f172a', border: '1px solid #1e293b', borderRadius: '6px', color: '#f8fafc', fontSize: '12px' }}
|
||||
/>
|
||||
<Area type="monotone" dataKey="cpu" stroke="#3b82f6" fill="url(#gCpu)" name="CPU" />
|
||||
<Area type="monotone" dataKey="mem" stroke="#10b981" fill="url(#gMem)" name="内存" />
|
||||
<Area type="monotone" dataKey="disk" stroke="#f59e0b" fill="url(#gDisk)" name="磁盘" />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 端口列表 */}
|
||||
<div>
|
||||
<span className="text-sm font-medium text-slate-200 flex items-center gap-1.5 mb-2">
|
||||
<Network className="h-4 w-4 text-blue-400" />
|
||||
端口发现 ({ports.length})
|
||||
</span>
|
||||
{ports.length === 0 ? (
|
||||
<p className="text-xs text-slate-500">暂无端口数据</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1.5 max-h-32 overflow-y-auto">
|
||||
{ports.map((p, idx) => (
|
||||
<Badge key={idx} className="bg-slate-800 text-slate-300 border border-slate-700">
|
||||
<Network className="h-3 w-3 mr-1" />
|
||||
{p.port ?? p}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button
|
||||
onClick={() => onCommand(host.hostname)}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white"
|
||||
>
|
||||
<Terminal className="h-4 w-4 mr-1.5" />
|
||||
远程命令
|
||||
</Button>
|
||||
<Button variant="outline" onClick={onClose} className="border-slate-700 text-slate-300">
|
||||
关闭
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoCell({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="bg-slate-950 border border-slate-800 rounded-lg px-3 py-2">
|
||||
<div className="text-xs text-slate-500">{label}</div>
|
||||
<div className="text-sm text-slate-200 font-medium truncate">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AgentMonitor;
|
||||
@@ -0,0 +1,173 @@
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { useUserStore } from '@/store';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Copy, Check, Terminal, Download, Settings, Play, Cpu } from 'lucide-react';
|
||||
|
||||
interface AgentWizardProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
/** 自动检测服务器地址:使用当前浏览器访问的主机名,端口固定 8080 */
|
||||
function detectServerAddress(): string {
|
||||
if (typeof window !== 'undefined' && window.location) {
|
||||
const host = window.location.hostname;
|
||||
return host ? `http://${host}:8080` : 'http://127.0.0.1:8080';
|
||||
}
|
||||
return 'http://127.0.0.1:8080';
|
||||
}
|
||||
|
||||
export function AgentWizard({ open, onOpenChange }: AgentWizardProps) {
|
||||
const token = useUserStore((s) => s.token) || '<YOUR_TOKEN>';
|
||||
const serverUrl = detectServerAddress();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const installCommand = `curl -fsSL ${serverUrl.replace(/^http:/, 'http:')}/agent/install.sh | bash -s -- --server ${serverUrl} --token ${token}`;
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(installCommand);
|
||||
setCopied(true);
|
||||
toast.success('安装命令已复制');
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
toast.error('复制失败,请手动复制');
|
||||
}
|
||||
};
|
||||
|
||||
const binaryUrl = `${serverUrl}/agent/aiops-agent-linux-amd64`;
|
||||
|
||||
const manualSteps = [
|
||||
{
|
||||
icon: Download,
|
||||
title: '1. 下载二进制',
|
||||
commands: [
|
||||
`curl -fsSL ${binaryUrl} -o /usr/local/bin/aiops-agent`,
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: Settings,
|
||||
title: '2. 赋予执行权限',
|
||||
commands: ['chmod +x /usr/local/bin/aiops-agent'],
|
||||
},
|
||||
{
|
||||
icon: Cpu,
|
||||
title: '3. 创建 systemd 服务',
|
||||
commands: [
|
||||
`cat > /etc/systemd/system/aiops-agent.service << 'EOF'`,
|
||||
`[Unit]`,
|
||||
`Description=AIOps Agent`,
|
||||
`After=network.target`,
|
||||
``,
|
||||
`[Service]`,
|
||||
`Type=simple`,
|
||||
`ExecStart=/usr/local/bin/aiops-agent --server ${serverUrl} --token ${token}`,
|
||||
`Restart=always`,
|
||||
`RestartSec=5`,
|
||||
``,
|
||||
`[Install]`,
|
||||
`WantedBy=multi-user.target`,
|
||||
`EOF`,
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: Play,
|
||||
title: '4. 启动并设置开机自启',
|
||||
commands: [
|
||||
'systemctl daemon-reload',
|
||||
'systemctl enable --now aiops-agent',
|
||||
'systemctl status aiops-agent',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl bg-slate-900 border-slate-800 text-slate-100">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-slate-100">部署 Agent</DialogTitle>
|
||||
<DialogDescription className="text-slate-400">
|
||||
在目标主机上执行以下命令即可完成 Agent 安装与注册
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Tabs defaultValue="auto" className="w-full">
|
||||
<TabsList className="bg-slate-800 border border-slate-700">
|
||||
<TabsTrigger value="auto" className="data-[state=active]:bg-slate-700">
|
||||
<Terminal className="h-4 w-4 mr-1.5" />
|
||||
安装Agent
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="manual" className="data-[state=active]:bg-slate-700">
|
||||
手动安装
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 自动安装 */}
|
||||
<TabsContent value="auto" className="mt-4 space-y-3">
|
||||
<p className="text-sm text-slate-400">
|
||||
在目标主机执行以下命令,Agent 将自动下载、安装并启动:
|
||||
</p>
|
||||
<div className="relative rounded-lg bg-slate-950 border border-slate-800 p-4">
|
||||
<pre className="text-xs text-emerald-400 font-mono whitespace-pre-wrap break-all pr-10">
|
||||
{installCommand}
|
||||
</pre>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={handleCopy}
|
||||
className="absolute top-2 right-2 h-8 w-8 text-slate-400 hover:text-slate-100 hover:bg-slate-800"
|
||||
title="复制"
|
||||
>
|
||||
{copied ? <Check className="h-4 w-4 text-emerald-400" /> : <Copy className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-start gap-2 text-xs text-slate-500">
|
||||
<Terminal className="h-3.5 w-3.5 mt-0.5 shrink-0" />
|
||||
<span>
|
||||
服务器地址已自动检测为当前访问地址({serverUrl})。Agent 安装成功后将自动连接并开始上报指标。
|
||||
</span>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* 手动安装 */}
|
||||
<TabsContent value="manual" className="mt-4 space-y-4">
|
||||
<div className="max-h-[420px] overflow-y-auto space-y-3 pr-1">
|
||||
{manualSteps.map((step) => (
|
||||
<div key={step.title} className="rounded-lg bg-slate-950 border border-slate-800 overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-4 py-2.5 bg-slate-800/50">
|
||||
<step.icon className="h-4 w-4 text-blue-400" />
|
||||
<span className="text-sm font-medium text-slate-200">{step.title}</span>
|
||||
</div>
|
||||
<div className="p-3 space-y-1">
|
||||
{step.commands.map((cmd, idx) => (
|
||||
<div key={idx} className="flex items-start gap-2">
|
||||
<span className="text-slate-600 font-mono text-xs select-none mt-0.5">$</span>
|
||||
<code className="text-xs text-slate-300 font-mono whitespace-pre-wrap break-all flex-1">
|
||||
{cmd}
|
||||
</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-slate-500">
|
||||
完成上述步骤后,Agent 将作为 systemd 服务运行并自动上报指标。
|
||||
</p>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default AgentWizard;
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { cronJobApi, scriptApi } from '@/services/api';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
function parseParams(params: any) {
|
||||
if (Array.isArray(params)) return params;
|
||||
@@ -99,11 +100,11 @@ export function AutomationManager() {
|
||||
(script.description || '').toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
const getJobStatusBadge = (status: any) => {
|
||||
return status === 1 ? (
|
||||
const getJobStatusBadge = (enabled: any) => {
|
||||
return enabled ? (
|
||||
<Badge variant="outline" className="border-emerald-500/30 text-emerald-400 bg-emerald-500/10">
|
||||
<Play className="h-3 w-3 mr-1" />
|
||||
运行中
|
||||
已启用
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="border-slate-500/30 text-slate-400 bg-slate-500/10">
|
||||
@@ -115,23 +116,32 @@ export function AutomationManager() {
|
||||
|
||||
const getLanguageBadge = (language: any) => {
|
||||
const colors: Record<string, string> = {
|
||||
shell: 'border-green-500/30 text-green-400 bg-green-500/10',
|
||||
bash: 'border-green-500/30 text-green-400 bg-green-500/10',
|
||||
python: 'border-blue-500/30 text-blue-400 bg-blue-500/10',
|
||||
powershell: 'border-cyan-500/30 text-cyan-400 bg-cyan-500/10',
|
||||
yaml: 'border-purple-500/30 text-purple-400 bg-purple-500/10',
|
||||
};
|
||||
return (
|
||||
<Badge variant="outline" className={colors[language] || colors.bash}>
|
||||
{(language || '').toUpperCase()}
|
||||
<Badge variant="outline" className={colors[language] || colors.shell}>
|
||||
{(language || 'shell').toUpperCase()}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
const handleExecuteScript = async (id: any) => {
|
||||
try {
|
||||
await scriptApi.executeScript(id);
|
||||
const result = await scriptApi.executeScript(id);
|
||||
const status = result?.status || 'success';
|
||||
if (status === 'success') {
|
||||
toast.success('执行成功', { description: `耗时 ${result?.duration || 0}ms` });
|
||||
} else {
|
||||
toast.error('执行失败', { description: (result?.output || '').slice(0, 100) });
|
||||
}
|
||||
setIsExecuteDialogOpen(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to execute script:', error);
|
||||
toast.error('执行失败');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -176,7 +186,7 @@ export function AutomationManager() {
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">运行中</p>
|
||||
<p className="text-2xl font-bold text-emerald-400">
|
||||
{cronJobs.filter((j) => j.status === 1).length}
|
||||
{cronJobs.filter((j) => j.enabled).length}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-emerald-500/10 text-emerald-400">
|
||||
@@ -279,21 +289,19 @@ export function AutomationManager() {
|
||||
<p className="text-xs text-slate-500 mt-0.5">{job.description}</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{getJobStatusBadge(job.status)}</TableCell>
|
||||
<TableCell>{getJobStatusBadge(job.enabled)}</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-xs text-slate-300 bg-slate-800 px-2 py-0.5 rounded">
|
||||
{job.cron}
|
||||
{job.cron_expr}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-400">
|
||||
{job.last_run ? new Date(job.last_run).toLocaleString() : '--'}
|
||||
{job.last_run_at ? new Date(job.last_run_at).toLocaleString() : '--'}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-400">
|
||||
{job.next_run ? new Date(job.next_run).toLocaleString() : '--'}
|
||||
</span>
|
||||
<span className="text-sm text-slate-400">--</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-sm">
|
||||
@@ -308,7 +316,19 @@ export function AutomationManager() {
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-slate-400 hover:text-blue-400"
|
||||
onClick={() => cronJobApi.executeJob(job.id)}
|
||||
onClick={async () => {
|
||||
try {
|
||||
const result = await cronJobApi.executeJob(job.id);
|
||||
if (result?.status === 'success') {
|
||||
toast.success('执行成功', { description: `耗时 ${result?.duration || 0}ms` });
|
||||
} else {
|
||||
toast.error('执行失败');
|
||||
}
|
||||
loadData();
|
||||
} catch {
|
||||
toast.error('执行失败');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -342,7 +362,7 @@ export function AutomationManager() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{getLanguageBadge(script.language)}
|
||||
{getLanguageBadge(script.type)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
@@ -423,7 +443,7 @@ export function AutomationManager() {
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedScript && getLanguageBadge(selectedScript.language)}
|
||||
{selectedScript && getLanguageBadge(selectedScript.type)}
|
||||
<span className="text-sm text-slate-500">
|
||||
更新于 {selectedScript && selectedScript.updated_at ? new Date(selectedScript.updated_at).toLocaleDateString() : '--'}
|
||||
</span>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -35,21 +34,16 @@ import {
|
||||
Search,
|
||||
Clock,
|
||||
Pause,
|
||||
RotateCw,
|
||||
Edit,
|
||||
Trash2,
|
||||
History,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Loader2,
|
||||
Server,
|
||||
Calendar,
|
||||
Terminal,
|
||||
MoreHorizontal,
|
||||
Eye,
|
||||
Copy,
|
||||
} from 'lucide-react';
|
||||
import { cronJobApi } from '@/services/api';
|
||||
import { toast } from 'sonner';
|
||||
import { cronJobApi, taskExecutionApi } from '@/services/api';
|
||||
|
||||
export function CronJobManager() {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
@@ -57,13 +51,16 @@ export function CronJobManager() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||
const [isLogDialogOpen, setIsLogDialogOpen] = useState(false);
|
||||
const [selectedJob, setSelectedJob] = useState<any | null>(null);
|
||||
const [executions, setExecutions] = useState<any[]>([]);
|
||||
const [executionsLoading, setExecutionsLoading] = useState(false);
|
||||
const [expandedExec, setExpandedExec] = useState<number | null>(null);
|
||||
const [executingId, setExecutingId] = useState<number | null>(null);
|
||||
const [newJob, setNewJob] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
cron: '',
|
||||
command: '',
|
||||
targetServers: '',
|
||||
cron_expr: '',
|
||||
type: 'command',
|
||||
content: '',
|
||||
});
|
||||
|
||||
const loadData = async () => {
|
||||
@@ -82,6 +79,23 @@ export function CronJobManager() {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const loadExecutions = async () => {
|
||||
try {
|
||||
setExecutionsLoading(true);
|
||||
const data = await taskExecutionApi.getExecutions({ task_type: 'cron', page_size: 50 });
|
||||
setExecutions(data.list || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to load executions:', error);
|
||||
} finally {
|
||||
setExecutionsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openLogDialog = () => {
|
||||
setIsLogDialogOpen(true);
|
||||
loadExecutions();
|
||||
};
|
||||
|
||||
const filteredJobs = jobs.filter(
|
||||
(job) =>
|
||||
(job.name || '').toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
@@ -132,18 +146,25 @@ export function CronJobManager() {
|
||||
};
|
||||
|
||||
const handleCreateJob = async () => {
|
||||
if (!newJob.name || !newJob.cron_expr) {
|
||||
toast.error('请填写任务名称和 Cron 表达式');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await cronJobApi.createJob({
|
||||
name: newJob.name,
|
||||
description: newJob.description,
|
||||
cron: newJob.cron,
|
||||
command: newJob.command,
|
||||
cron_expr: newJob.cron_expr,
|
||||
type: newJob.type,
|
||||
content: newJob.content,
|
||||
});
|
||||
toast.success('任务创建成功');
|
||||
setIsCreateDialogOpen(false);
|
||||
setNewJob({ name: '', description: '', cron: '', command: '', targetServers: '' });
|
||||
setNewJob({ name: '', description: '', cron_expr: '', type: 'command', content: '' });
|
||||
loadData();
|
||||
} catch (error) {
|
||||
console.error('Failed to create cron job:', error);
|
||||
toast.error('创建失败');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -153,24 +174,37 @@ export function CronJobManager() {
|
||||
loadData();
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle job:', error);
|
||||
toast.error('操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleExecuteJob = async (id: any) => {
|
||||
setExecutingId(id);
|
||||
try {
|
||||
await cronJobApi.executeJob(id);
|
||||
const result = await cronJobApi.executeJob(id);
|
||||
const status = result?.status || 'success';
|
||||
if (status === 'success') {
|
||||
toast.success('执行成功', { description: `耗时 ${result?.duration || 0}ms` });
|
||||
} else {
|
||||
toast.error('执行失败', { description: (result?.output || '').slice(0, 100) });
|
||||
}
|
||||
loadData();
|
||||
} catch (error) {
|
||||
console.error('Failed to execute job:', error);
|
||||
toast.error('执行失败');
|
||||
} finally {
|
||||
setExecutingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteJob = async (id: any) => {
|
||||
try {
|
||||
await cronJobApi.deleteJob(id);
|
||||
toast.success('已删除');
|
||||
loadData();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete job:', error);
|
||||
toast.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -183,7 +217,7 @@ export function CronJobManager() {
|
||||
<p className="text-sm text-slate-500 mt-1">Cron 任务管理与执行</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" className="border-slate-700 text-slate-300" onClick={() => setIsLogDialogOpen(true)}>
|
||||
<Button variant="outline" className="border-slate-700 text-slate-300" onClick={openLogDialog}>
|
||||
<History className="h-4 w-4 mr-2" />
|
||||
执行历史
|
||||
</Button>
|
||||
@@ -206,14 +240,16 @@ export function CronJobManager() {
|
||||
<CardContent className="p-4">
|
||||
<p className="text-slate-400 text-sm">已启用</p>
|
||||
<p className="text-2xl font-bold text-emerald-400">
|
||||
{jobs.filter((j) => j.status === 1).length}
|
||||
{jobs.filter((j) => j.enabled).length}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-slate-800 border-slate-700">
|
||||
<CardContent className="p-4">
|
||||
<p className="text-slate-400 text-sm">执行中</p>
|
||||
<p className="text-2xl font-bold text-blue-400">--</p>
|
||||
<p className="text-slate-400 text-sm">累计成功</p>
|
||||
<p className="text-2xl font-bold text-emerald-400">
|
||||
{jobs.reduce((sum, j) => sum + (j.success_count || 0), 0)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-slate-800 border-slate-700">
|
||||
@@ -251,47 +287,59 @@ export function CronJobManager() {
|
||||
<Card key={job.id} className="bg-slate-800 border-slate-700">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<h3 className="font-medium text-slate-100">{job.name}</h3>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
job.status === 1
|
||||
job.enabled
|
||||
? 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10'
|
||||
: 'border-slate-500/30 text-slate-400 bg-slate-500/10'
|
||||
}
|
||||
>
|
||||
{job.status === 1 ? '已启用' : '已禁用'}
|
||||
{job.enabled ? '已启用' : '已禁用'}
|
||||
</Badge>
|
||||
{getStatusBadge(job.last_status)}
|
||||
<Badge variant="outline" className="border-slate-600 text-slate-400">
|
||||
{job.type === 'script' ? '脚本' : job.type === 'playbook' ? 'Playbook' : '命令'}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-slate-400 mt-1">{job.description}</p>
|
||||
<div className="flex items-center gap-4 mt-2 text-sm">
|
||||
<span className="flex items-center gap-1 text-slate-400">
|
||||
<Clock className="h-3 w-3" />
|
||||
{parseCron(job.cron)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-slate-400">
|
||||
<Terminal className="h-3 w-3" />
|
||||
<code className="text-xs bg-slate-900 px-2 py-0.5 rounded">{job.command}</code>
|
||||
{parseCron(job.cron_expr)}
|
||||
</span>
|
||||
{(job.content || job.script) && (
|
||||
<span className="flex items-center gap-1 text-slate-400">
|
||||
<Terminal className="h-3 w-3" />
|
||||
<code className="text-xs bg-slate-900 px-2 py-0.5 rounded truncate max-w-xs">
|
||||
{job.script ? job.script.name : job.content}
|
||||
</code>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 mt-2 text-xs text-slate-500">
|
||||
<span>上次执行: {job.last_run ? new Date(job.last_run).toLocaleString() : '--'}</span>
|
||||
<span>下次执行: {job.next_run ? new Date(job.next_run).toLocaleString() : '--'}</span>
|
||||
<span>上次执行: {job.last_run_at ? new Date(job.last_run_at).toLocaleString() : '--'}</span>
|
||||
<span>成功: {job.success_count || 0} / 失败: {job.fail_count || 0}</span>
|
||||
<span>累计: {job.run_count || 0} 次</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-emerald-600 text-emerald-400 hover:bg-emerald-600/20"
|
||||
disabled={executingId === job.id}
|
||||
onClick={() => handleExecuteJob(job.id)}
|
||||
>
|
||||
<Play className="h-4 w-4 mr-1" />
|
||||
{executingId === job.id ? (
|
||||
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Play className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
立即执行
|
||||
</Button>
|
||||
<Button
|
||||
@@ -300,13 +348,9 @@ export function CronJobManager() {
|
||||
className="h-8 w-8 text-slate-400 hover:text-blue-400"
|
||||
onClick={() => handleToggleJob(job.id)}
|
||||
>
|
||||
{job.status === 1 ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||
{job.enabled ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-slate-400 hover:text-blue-400"
|
||||
>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-blue-400">
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
@@ -354,12 +398,12 @@ export function CronJobManager() {
|
||||
<Label className="text-slate-300">Cron 表达式</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={newJob.cron}
|
||||
onChange={(e) => setNewJob({ ...newJob, cron: e.target.value })}
|
||||
value={newJob.cron_expr}
|
||||
onChange={(e) => setNewJob({ ...newJob, cron_expr: e.target.value })}
|
||||
placeholder="0 2 * * *"
|
||||
className="bg-slate-800 border-slate-700 text-slate-100 mt-1"
|
||||
/>
|
||||
<Select onValueChange={(v) => setNewJob({ ...newJob, cron: v })}>
|
||||
<Select onValueChange={(v) => setNewJob({ ...newJob, cron_expr: v })}>
|
||||
<SelectTrigger className="w-48 bg-slate-800 border-slate-700 text-slate-100 mt-1">
|
||||
<SelectValue placeholder="快速选择" />
|
||||
</SelectTrigger>
|
||||
@@ -377,21 +421,12 @@ export function CronJobManager() {
|
||||
<div>
|
||||
<Label className="text-slate-300">执行命令</Label>
|
||||
<Textarea
|
||||
value={newJob.command}
|
||||
onChange={(e) => setNewJob({ ...newJob, command: e.target.value })}
|
||||
value={newJob.content}
|
||||
onChange={(e) => setNewJob({ ...newJob, content: e.target.value })}
|
||||
placeholder="/scripts/backup.sh"
|
||||
className="bg-slate-800 border-slate-700 text-slate-100 mt-1 font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-slate-300">目标服务器</Label>
|
||||
<Input
|
||||
value={newJob.targetServers}
|
||||
onChange={(e) => setNewJob({ ...newJob, targetServers: e.target.value })}
|
||||
placeholder="server1,server2 或 all"
|
||||
className="bg-slate-800 border-slate-700 text-slate-100 mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsCreateDialogOpen(false)} className="border-slate-700 text-slate-300">
|
||||
@@ -404,49 +439,62 @@ export function CronJobManager() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Execution Log Dialog */}
|
||||
{/* Execution History Dialog */}
|
||||
<Dialog open={isLogDialogOpen} onOpenChange={setIsLogDialogOpen}>
|
||||
<DialogContent className="max-w-4xl bg-slate-900 border-slate-700 text-slate-100">
|
||||
<DialogContent className="max-w-4xl bg-slate-900 border-slate-700 text-slate-100 max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>执行历史</DialogTitle>
|
||||
<DialogDescription className="text-slate-500">定时任务执行记录</DialogDescription>
|
||||
<DialogDescription className="text-slate-500">定时任务执行记录与输出日志</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="mt-4">
|
||||
{jobs.length === 0 ? (
|
||||
{executionsLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 text-slate-500 animate-spin" />
|
||||
</div>
|
||||
) : executions.length === 0 ? (
|
||||
<div className="text-center text-slate-500 py-8">暂无执行记录</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-slate-700">
|
||||
<TableHead className="text-slate-400">任务</TableHead>
|
||||
<TableHead className="text-slate-400">上次执行</TableHead>
|
||||
<TableHead className="text-slate-400">成功次数</TableHead>
|
||||
<TableHead className="text-slate-400">失败次数</TableHead>
|
||||
<TableHead className="text-slate-400">状态</TableHead>
|
||||
<TableHead className="text-slate-400">耗时</TableHead>
|
||||
<TableHead className="text-slate-400">执行时间</TableHead>
|
||||
<TableHead className="text-slate-400">输出</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{jobs.map((job) => (
|
||||
<TableRow key={job.id} className="border-slate-700">
|
||||
<TableCell className="text-slate-200">{job.name}</TableCell>
|
||||
<TableCell className="text-slate-400">
|
||||
{job.last_run ? new Date(job.last_run).toLocaleString() : '--'}
|
||||
</TableCell>
|
||||
<TableCell className="text-emerald-400">{job.success_count || 0}</TableCell>
|
||||
<TableCell className="text-red-400">{job.fail_count || 0}</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
(job.fail_count || 0) > 0
|
||||
? 'border-red-500/30 text-red-400 bg-red-500/10'
|
||||
: 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10'
|
||||
}
|
||||
>
|
||||
{(job.fail_count || 0) > 0 ? '有失败' : '正常'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{executions.map((exec) => (
|
||||
<>
|
||||
<TableRow key={exec.id} className="border-slate-700 cursor-pointer hover:bg-slate-800/50" onClick={() => setExpandedExec(expandedExec === exec.id ? null : exec.id)}>
|
||||
<TableCell className="text-slate-200">{exec.task_name}</TableCell>
|
||||
<TableCell>{getStatusBadge(exec.status)}</TableCell>
|
||||
<TableCell className="text-slate-400">{exec.duration ? `${exec.duration}ms` : '--'}</TableCell>
|
||||
<TableCell className="text-slate-400">
|
||||
{exec.started_at ? new Date(exec.started_at).toLocaleString() : '--'}
|
||||
</TableCell>
|
||||
<TableCell className="text-slate-400">
|
||||
{exec.output ? (
|
||||
<span className="text-blue-400 text-xs flex items-center gap-1">
|
||||
<Terminal className="h-3 w-3" />
|
||||
{expandedExec === exec.id ? '收起' : '查看'}
|
||||
</span>
|
||||
) : (
|
||||
'--'
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{expandedExec === exec.id && exec.output && (
|
||||
<TableRow key={`${exec.id}-out`} className="border-slate-700">
|
||||
<TableCell colSpan={5} className="bg-slate-950 p-0">
|
||||
<pre className="p-4 text-xs text-slate-300 font-mono whitespace-pre-wrap max-h-64 overflow-y-auto">
|
||||
{exec.output}
|
||||
</pre>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
@@ -61,6 +61,7 @@ const menuItems: MenuItem[] = [
|
||||
{ id: 'regions', name: '区域管理', icon: MapPin, path: '/regions' },
|
||||
{ id: 'platforms', name: '云平台', icon: CloudCog, path: '/platforms' },
|
||||
{ id: 'cloud-billing', name: '云账单', icon: BarChart3, path: '/cloud-billing' },
|
||||
{ id: 'agent-monitor', name: 'Agent监控', icon: Activity, path: '/agent-monitor' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -93,6 +94,7 @@ const menuItems: MenuItem[] = [
|
||||
{ id: 'logs-monitor', name: '日志能力', icon: FileCode, path: '/logs-monitor' },
|
||||
{ id: 'trace', name: '链路追踪', icon: GitBranch, path: '/trace' },
|
||||
{ id: 'grafana', name: '监控大屏', icon: Monitor, path: '/grafana' },
|
||||
{ id: 'agent-alerts', name: 'Agent告警', icon: AlertTriangle, path: '/agent-alerts' },
|
||||
],
|
||||
},
|
||||
{ id: 'approval', name: '审批流程', icon: CheckSquare, path: '/approval' },
|
||||
|
||||
@@ -300,6 +300,13 @@ export const scriptApi = {
|
||||
},
|
||||
};
|
||||
|
||||
// ==================== 任务执行记录 API ====================
|
||||
export const taskExecutionApi = {
|
||||
getExecutions: async (params?: { task_type?: string; task_id?: string; page?: number; page_size?: number }) => {
|
||||
return http.get<ListResponse>('/task-executions', params);
|
||||
},
|
||||
};
|
||||
|
||||
// ==================== 审批流程 API ====================
|
||||
export const approvalApi = {
|
||||
getTemplates: async () => {
|
||||
@@ -444,6 +451,20 @@ export const billingApi = {
|
||||
},
|
||||
};
|
||||
|
||||
// ==================== Agent 监控 API ====================
|
||||
export const agentApi = {
|
||||
getLatest: () => http.get('/agent/latest'),
|
||||
getHistory: (hostname: string, hours = 1) => http.get(`/agent/history/${hostname}?hours=${hours}`),
|
||||
createTask: (data: { hostname: string; type: string; command: string; timeout?: number }) => http.post('/agent/tasks', data),
|
||||
listTasks: (params?: Record<string, string>) => http.get('/agent/tasks', params),
|
||||
listRules: () => http.get('/agent/rules'),
|
||||
createRule: (data: any) => http.post('/agent/rules', data),
|
||||
updateRule: (id: number, data: any) => http.put(`/agent/rules/${id}`, data),
|
||||
deleteRule: (id: number) => http.delete(`/agent/rules/${id}`),
|
||||
listAlerts: (params?: Record<string, string>) => http.get('/agent/alerts', params),
|
||||
resolveAlert: (id: number) => http.post(`/agent/alerts/${id}/resolve`),
|
||||
};
|
||||
|
||||
// ==================== 区域管理 API ====================
|
||||
export const regionApi = {
|
||||
list: async () => {
|
||||
|
||||
Reference in new issue
Block a user