39 files changed
+1636
-432
No files matched your search
@@ -0,0 +1,119 @@
|
||||
name: 构建 amd64 测试镜像到 GHCR
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
env:
|
||||
# 使用 GitHub Container Registry
|
||||
GHCR_REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }} # 会自动变成 ghcr.io/用户名/仓库名
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
# 检出代码
|
||||
- name: 检出代码
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# 安装 make 和 upx
|
||||
- name: 安装 make 和 upx
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y make upx-ucl
|
||||
|
||||
# 设置 Golang
|
||||
- name: 设置 Golang
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.25.0'
|
||||
|
||||
# 设置 Node.js
|
||||
- name: 设置 Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
# 构建所有组件(前端、agents、服务端)
|
||||
- name: 构建所有组件
|
||||
run: |
|
||||
make build-web
|
||||
make build-agents
|
||||
make build-servers
|
||||
|
||||
# 验证 index.html 模板代码
|
||||
- name: 验证 index.html 模板代码
|
||||
run: |
|
||||
if grep -q "{{.CustomJS}}" web/dist/index.html && grep -q "{{.CustomCSS}}" web/dist/index.html; then
|
||||
echo "✓ 自定义模板代码已成功添加到 index.html"
|
||||
else
|
||||
echo "✗ 自定义模板代码未找到,重新执行 patch-html"
|
||||
go run cmd/patch-html/main.go
|
||||
fi
|
||||
|
||||
# 列出编译产物
|
||||
- name: 列出编译产物
|
||||
run: |
|
||||
ls -lh ./bin/
|
||||
echo "---"
|
||||
ls -lh ./bin/agents/
|
||||
|
||||
# 设置 Docker Buildx
|
||||
- name: 设置 Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
# 登录 GitHub Container Registry
|
||||
- name: 登录 GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.GHCR_REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# 提取 Docker 元数据
|
||||
- name: 提取 Docker 元数据
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=raw,value=test-amd64
|
||||
type=raw,value=latest
|
||||
|
||||
# 构建并推送 amd64 Docker 镜像到 GHCR
|
||||
- name: 构建并推送 amd64 测试镜像
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64 # 只构建 amd64 平台
|
||||
file: Dockerfile
|
||||
push: true # 总是推送,无论是什么分支
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: |
|
||||
type=registry,ref=${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}:cache
|
||||
cache-to: |
|
||||
type=registry,ref=${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}:cache,mode=max
|
||||
|
||||
# 生成构建总结
|
||||
- name: 生成构建总结
|
||||
run: |
|
||||
echo "## amd64 测试镜像构建成功 🎉" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**分支:** main" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**镜像标签:**" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
echo "${{ steps.meta.outputs.tags }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**平台:** linux/amd64" >> $GITHUB_STEP_SUMMARY
|
||||
@@ -59,10 +59,10 @@ jobs:
|
||||
with:
|
||||
go-version: '1.25.0'
|
||||
|
||||
# 构建 agents
|
||||
# 构建 agents (release版本使用最佳压缩)
|
||||
- name: 构建 agents
|
||||
run: |
|
||||
make build-agents
|
||||
make build-agents RELEASE=true
|
||||
|
||||
# 列出编译产物
|
||||
- name: 列出编译产物
|
||||
|
||||
@@ -9,6 +9,10 @@ GIT_REVISION=$(shell git rev-parse HEAD)
|
||||
GO_VERSION=$(shell go version)
|
||||
BUILD_TIME=$(shell date +%Y-%m-%d_%H:%M:%S)
|
||||
|
||||
# 是否使用最佳压缩(release 时设置为 true)
|
||||
RELEASE ?= false
|
||||
UPX_FLAGS=$(if $(filter true,$(RELEASE)),--best,)
|
||||
|
||||
# Go 构建参数
|
||||
LDFLAGS=-s -w -X 'github.com/dushixiang/pika/pkg/version.Version=$(VERSION)' -X 'github.com/dushixiang/pika/pkg/version.AgentVersion=$(AGENT_VERSION)'
|
||||
AGENT_LDFLAGS=-s -w -X 'github.com/dushixiang/pika/pkg/version.Version=$(VERSION)' -X 'github.com/dushixiang/pika/pkg/version.AgentVersion=$(AGENT_VERSION)'
|
||||
@@ -21,15 +25,15 @@ build-web:
|
||||
|
||||
# 构建服务端(开发)
|
||||
build-server:
|
||||
$(GOFLAGS) GOOS=linux GOARCH=amd64 go build -ldflags="$(LDFLAGS)" -o bin/pika-linux-amd64 cmd/serv/main.go
|
||||
upx bin/pika-linux-amd64
|
||||
$(GOFLAGS) GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="$(LDFLAGS)" -o bin/pika-linux-amd64 cmd/serv/main.go
|
||||
upx $(UPX_FLAGS) bin/pika-linux-amd64
|
||||
|
||||
build-servers:
|
||||
$(GOFLAGS) GOOS=linux GOARCH=amd64 go build -ldflags="$(LDFLAGS)" -o bin/pika-linux-amd64 cmd/serv/main.go
|
||||
$(GOFLAGS) GOOS=linux GOARCH=arm64 go build -ldflags="$(LDFLAGS)" -o bin/pika-linux-arm64 cmd/serv/main.go
|
||||
$(GOFLAGS) GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="$(LDFLAGS)" -o bin/pika-linux-amd64 cmd/serv/main.go
|
||||
$(GOFLAGS) GOOS=linux GOARCH=arm64 go build -trimpath -ldflags="$(LDFLAGS)" -o bin/pika-linux-arm64 cmd/serv/main.go
|
||||
|
||||
upx bin/pika-linux-amd64
|
||||
upx bin/pika-linux-arm64
|
||||
upx $(UPX_FLAGS) bin/pika-linux-amd64
|
||||
upx $(UPX_FLAGS) bin/pika-linux-arm64
|
||||
|
||||
# 构建所有平台的 Agent
|
||||
build-agents:
|
||||
@@ -39,32 +43,32 @@ build-agents:
|
||||
@mkdir -p bin/agents
|
||||
|
||||
# Linux
|
||||
$(GOFLAGS) GOOS=linux GOARCH=amd64 go build -ldflags="$(AGENT_LDFLAGS)" -o bin/agents/pika-agent-linux-amd64 ./cmd/agent
|
||||
$(GOFLAGS) GOOS=linux GOARCH=arm64 go build -ldflags="$(AGENT_LDFLAGS)" -o bin/agents/pika-agent-linux-arm64 ./cmd/agent
|
||||
$(GOFLAGS) GOOS=linux GOARCH=arm GOARM=7 go build -ldflags="$(AGENT_LDFLAGS)" -o bin/agents/pika-agent-linux-armv7 ./cmd/agent
|
||||
$(GOFLAGS) GOOS=linux GOARCH=loong64 go build -ldflags="$(AGENT_LDFLAGS)" -o bin/agents/pika-agent-linux-loong64 ./cmd/agent
|
||||
$(GOFLAGS) GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="$(AGENT_LDFLAGS)" -o bin/agents/pika-agent-linux-amd64 ./cmd/agent
|
||||
$(GOFLAGS) GOOS=linux GOARCH=arm64 go build -trimpath -ldflags="$(AGENT_LDFLAGS)" -o bin/agents/pika-agent-linux-arm64 ./cmd/agent
|
||||
$(GOFLAGS) GOOS=linux GOARCH=arm GOARM=7 go build -trimpath -ldflags="$(AGENT_LDFLAGS)" -o bin/agents/pika-agent-linux-armv7 ./cmd/agent
|
||||
$(GOFLAGS) GOOS=linux GOARCH=loong64 go build -trimpath -ldflags="$(AGENT_LDFLAGS)" -o bin/agents/pika-agent-linux-loong64 ./cmd/agent
|
||||
|
||||
# macOS
|
||||
$(GOFLAGS) GOOS=darwin GOARCH=amd64 go build -ldflags="$(AGENT_LDFLAGS)" -o bin/agents/pika-agent-darwin-amd64 ./cmd/agent
|
||||
$(GOFLAGS) GOOS=darwin GOARCH=arm64 go build -ldflags="$(AGENT_LDFLAGS)" -o bin/agents/pika-agent-darwin-arm64 ./cmd/agent
|
||||
$(GOFLAGS) GOOS=darwin GOARCH=amd64 go build -trimpath -ldflags="$(AGENT_LDFLAGS)" -o bin/agents/pika-agent-darwin-amd64 ./cmd/agent
|
||||
$(GOFLAGS) GOOS=darwin GOARCH=arm64 go build -trimpath -ldflags="$(AGENT_LDFLAGS)" -o bin/agents/pika-agent-darwin-arm64 ./cmd/agent
|
||||
|
||||
# Windows
|
||||
$(GOFLAGS) GOOS=windows GOARCH=amd64 go build -ldflags="$(AGENT_LDFLAGS)" -o bin/agents/pika-agent-windows-amd64.exe ./cmd/agent
|
||||
$(GOFLAGS) GOOS=windows GOARCH=arm64 go build -ldflags="$(AGENT_LDFLAGS)" -o bin/agents/pika-agent-windows-arm64.exe ./cmd/agent
|
||||
$(GOFLAGS) GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="$(AGENT_LDFLAGS)" -o bin/agents/pika-agent-windows-amd64.exe ./cmd/agent
|
||||
$(GOFLAGS) GOOS=windows GOARCH=arm64 go build -trimpath -ldflags="$(AGENT_LDFLAGS)" -o bin/agents/pika-agent-windows-arm64.exe ./cmd/agent
|
||||
|
||||
@echo "All agents built successfully!"
|
||||
@echo "Compressing agents with UPX..."
|
||||
@upx bin/agents/pika-agent-linux-amd64
|
||||
@upx bin/agents/pika-agent-linux-arm64
|
||||
@upx bin/agents/pika-agent-linux-armv7
|
||||
@upx $(UPX_FLAGS) bin/agents/pika-agent-linux-amd64
|
||||
@upx $(UPX_FLAGS) bin/agents/pika-agent-linux-arm64
|
||||
@upx $(UPX_FLAGS) bin/agents/pika-agent-linux-armv7
|
||||
@echo "All agents compressed successfully!"
|
||||
@ls -lh bin/agents/
|
||||
|
||||
# 构建所有
|
||||
# 构建所有(release版本,使用最佳压缩)
|
||||
build-release:
|
||||
make build-web
|
||||
make build-agents
|
||||
make build-servers
|
||||
make build-agents RELEASE=true
|
||||
make build-servers RELEASE=true
|
||||
|
||||
# 清理编译产物
|
||||
clean:
|
||||
|
||||
@@ -250,6 +250,7 @@ func setupApi(app *orz.App, components *AppComponents) {
|
||||
adminApi.POST("/agents/batch/tags", components.AgentHandler.BatchUpdateTags)
|
||||
adminApi.POST("/agents/batch/visibility", components.AgentHandler.BatchUpdateVisibility)
|
||||
adminApi.DELETE("/agents/:id", components.AgentHandler.Delete)
|
||||
adminApi.POST("/agents/cleanup-metrics", components.AgentHandler.CleanupOrphanedAgentMetrics) // 清理残留指标数据
|
||||
adminApi.POST("/agents/:id/command", components.AgentHandler.SendCommand)
|
||||
|
||||
// 流量管理(管理员访问)
|
||||
|
||||
@@ -229,15 +229,6 @@ func (h *AgentHandler) Delete(c echo.Context) error {
|
||||
client.Conn.Close()
|
||||
}
|
||||
|
||||
// 删除探针在 VictoriaMetrics 中的历史指标数据
|
||||
if err := h.metricService.DeleteAgentMetrics(ctx, agentID); err != nil {
|
||||
h.logger.Error("删除探针 VictoriaMetrics 数据失败",
|
||||
zap.String("agentID", agentID),
|
||||
zap.String("name", agent.Name),
|
||||
zap.Error(err))
|
||||
return orz.NewError(500, "删除探针指标数据失败")
|
||||
}
|
||||
|
||||
// 删除探针及其所有相关数据
|
||||
if err := h.agentService.DeleteAgent(ctx, agentID); err != nil {
|
||||
h.logger.Error("删除探针失败",
|
||||
@@ -254,6 +245,24 @@ func (h *AgentHandler) Delete(c echo.Context) error {
|
||||
return orz.Ok(c, orz.Map{})
|
||||
}
|
||||
|
||||
// CleanupOrphanedAgentMetrics 手动清除所有已删除探针残留的指标数据
|
||||
func (h *AgentHandler) CleanupOrphanedAgentMetrics(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
|
||||
h.logger.Info("开始手动清理残留探针指标数据")
|
||||
|
||||
if err := h.metricService.CleanOrphanedAgentMetrics(ctx); err != nil {
|
||||
h.logger.Error("清除残留探针指标数据失败", zap.Error(err))
|
||||
return orz.NewError(500, "清除残留探针指标数据失败: "+err.Error())
|
||||
}
|
||||
|
||||
h.logger.Info("完成残留探针指标数据清理")
|
||||
|
||||
return orz.Ok(c, orz.Map{
|
||||
"message": "已成功清理残留探针指标数据",
|
||||
})
|
||||
}
|
||||
|
||||
// BatchUpdateTags 批量更新探针标签
|
||||
func (h *AgentHandler) BatchUpdateTags(c echo.Context) error {
|
||||
var req struct {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/dushixiang/pika/internal/service"
|
||||
"github.com/go-orz/orz"
|
||||
"github.com/labstack/echo/v4"
|
||||
@@ -48,10 +46,8 @@ func (h *AlertHandler) ListAlertRecords(c echo.Context) error {
|
||||
func (h *AlertHandler) ClearAlertRecords(c echo.Context) error {
|
||||
if err := h.alertService.Clear(c.Request().Context()); err != nil {
|
||||
h.logger.Error("清空告警记录失败", zap.Error(err))
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||
"message": "清空告警记录失败",
|
||||
})
|
||||
return orz.NewError(500, "清空告警记录失败")
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, echo.Map{})
|
||||
return orz.Ok(c, orz.Map{})
|
||||
}
|
||||
@@ -178,7 +178,7 @@ func (h *MonitorHandler) GetAgentStatsByID(c echo.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
stats := h.metricService.GetMonitorAgentStats(id)
|
||||
stats := h.metricService.GetMonitorAgentStats(ctx, id)
|
||||
for i := range stats {
|
||||
stats[i].Target = "" // 隐藏目标地址
|
||||
}
|
||||
|
||||
@@ -33,9 +33,7 @@ func (h *PropertyHandler) GetProperty(c echo.Context) error {
|
||||
property, err := h.service.Get(c.Request().Context(), id)
|
||||
if err != nil {
|
||||
h.logger.Error("获取属性失败", zap.String("id", id), zap.Error(err))
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||
"message": "获取属性失败",
|
||||
})
|
||||
return orz.NewError(500, "获取属性失败")
|
||||
}
|
||||
|
||||
// 解析 JSON 值
|
||||
@@ -43,13 +41,11 @@ func (h *PropertyHandler) GetProperty(c echo.Context) error {
|
||||
if property.Value != "" {
|
||||
if err := json.Unmarshal([]byte(property.Value), &value); err != nil {
|
||||
h.logger.Error("解析属性值失败", zap.String("id", id), zap.Error(err))
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||
"message": "解析属性值失败",
|
||||
})
|
||||
return orz.NewError(500, "解析属性值失败")
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
return orz.Ok(c, map[string]interface{}{
|
||||
"id": property.ID,
|
||||
"name": property.Name,
|
||||
"value": value,
|
||||
@@ -66,9 +62,7 @@ func (h *PropertyHandler) SetProperty(c echo.Context) error {
|
||||
}
|
||||
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"message": "无效的请求参数",
|
||||
})
|
||||
return orz.NewError(400, "无效的请求参数")
|
||||
}
|
||||
|
||||
// 特殊校验:系统配置
|
||||
@@ -85,21 +79,17 @@ func (h *PropertyHandler) SetProperty(c echo.Context) error {
|
||||
}
|
||||
|
||||
if nameZh == "" && nameEn == "" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"message": "系统名称(中文)和系统名称(英文)不能同时为空",
|
||||
})
|
||||
return orz.NewError(400, "系统名称(中文)和系统名称(英文)不能同时为空")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.service.Set(c.Request().Context(), id, req.Name, req.Value); err != nil {
|
||||
h.logger.Error("设置属性失败", zap.String("id", id), zap.Error(err))
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||
"message": "设置属性失败",
|
||||
})
|
||||
return orz.NewError(500, "设置属性失败")
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, orz.Map{})
|
||||
return orz.Ok(c, orz.Map{})
|
||||
}
|
||||
|
||||
// GetLogo 获取系统 Logo(公开访问,返回图片文件流)
|
||||
@@ -174,9 +164,7 @@ func (h *PropertyHandler) GetLogo(c echo.Context) error {
|
||||
func (h *PropertyHandler) TestNotificationChannel(c echo.Context) error {
|
||||
channelType := c.Param("type")
|
||||
if channelType == "" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"message": "缺少渠道类型参数",
|
||||
})
|
||||
return orz.NewError(400, "缺少渠道类型参数")
|
||||
}
|
||||
|
||||
ctx := c.Request().Context()
|
||||
@@ -184,9 +172,7 @@ func (h *PropertyHandler) TestNotificationChannel(c echo.Context) error {
|
||||
channels, err := h.service.GetNotificationChannelConfigs(c.Request().Context())
|
||||
if err != nil {
|
||||
h.logger.Error("获取通知渠道配置失败", zap.Error(err))
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||
"message": "获取通知渠道配置失败",
|
||||
})
|
||||
return orz.NewError(500, "获取通知渠道配置失败")
|
||||
}
|
||||
|
||||
// 查找指定类型的渠道
|
||||
@@ -199,15 +185,11 @@ func (h *PropertyHandler) TestNotificationChannel(c echo.Context) error {
|
||||
}
|
||||
|
||||
if targetChannel == nil {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{
|
||||
"message": "通知渠道不存在,请先配置",
|
||||
})
|
||||
return orz.NewError(404, "通知渠道不存在,请先配置")
|
||||
}
|
||||
|
||||
if !targetChannel.Enabled {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"message": "通知渠道未启用",
|
||||
})
|
||||
return orz.NewError(400, "通知渠道未启用")
|
||||
}
|
||||
|
||||
// 发送测试消息(动态匹配通知渠道类型)
|
||||
@@ -216,10 +198,8 @@ func (h *PropertyHandler) TestNotificationChannel(c echo.Context) error {
|
||||
|
||||
if sendErr != nil {
|
||||
h.logger.Error("发送测试通知失败", zap.String("type", channelType), zap.Error(sendErr))
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||
"message": "发送测试通知失败: " + sendErr.Error(),
|
||||
})
|
||||
return orz.NewError(500, "发送测试通知失败: "+sendErr.Error())
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, echo.Map{})
|
||||
return orz.Ok(c, orz.Map{})
|
||||
}
|
||||
@@ -1,9 +1,6 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/dushixiang/pika/internal/models"
|
||||
"github.com/dushixiang/pika/internal/service"
|
||||
"github.com/go-orz/orz"
|
||||
@@ -30,20 +27,16 @@ func NewSSHLoginHandler(logger *zap.Logger, service *service.SSHLoginService) *S
|
||||
func (h *SSHLoginHandler) GetConfig(c echo.Context) error {
|
||||
agentID := c.Param("id")
|
||||
if agentID == "" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"message": "探针ID不能为空",
|
||||
})
|
||||
return orz.NewError(400, "探针ID不能为空")
|
||||
}
|
||||
|
||||
config, err := h.service.GetConfig(c.Request().Context(), agentID)
|
||||
if err != nil {
|
||||
h.logger.Error("获取SSH登录监控配置失败", zap.Error(err))
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||
"message": "获取配置失败",
|
||||
})
|
||||
return orz.NewError(500, "获取配置失败")
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, config)
|
||||
return orz.Ok(c, config)
|
||||
}
|
||||
|
||||
// UpdateConfig 更新SSH登录监控配置
|
||||
@@ -59,12 +52,10 @@ func (h *SSHLoginHandler) UpdateConfig(c echo.Context) error {
|
||||
err := h.service.UpdateConfig(c.Request().Context(), agentID, &req)
|
||||
if err != nil {
|
||||
h.logger.Error("更新SSH登录监控配置失败", zap.Error(err))
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||
"message": "更新配置失败",
|
||||
})
|
||||
return orz.NewError(500, "更新配置失败")
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, orz.Map{})
|
||||
return orz.Ok(c, orz.Map{})
|
||||
}
|
||||
|
||||
// ListEvents 查询SSH登录事件
|
||||
@@ -81,7 +72,7 @@ func (h *SSHLoginHandler) ListEvents(c echo.Context) error {
|
||||
Equal("ip", c.QueryParam("ip")).
|
||||
Equal("status", c.QueryParam("status"))
|
||||
|
||||
ctx := context.Background()
|
||||
ctx := c.Request().Context()
|
||||
page, err := builder.Execute(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -95,27 +86,21 @@ func (h *SSHLoginHandler) ListEvents(c echo.Context) error {
|
||||
func (h *SSHLoginHandler) GetEvent(c echo.Context) error {
|
||||
eventID := c.Param("id")
|
||||
if eventID == "" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"message": "事件ID不能为空",
|
||||
})
|
||||
return orz.NewError(400, "事件ID不能为空")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
ctx := c.Request().Context()
|
||||
event, exists, err := h.service.SSHLoginEventRepo.FindByIdExists(ctx, eventID)
|
||||
if err != nil {
|
||||
h.logger.Error("获取SSH登录事件失败", zap.Error(err))
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||
"message": "获取事件失败",
|
||||
})
|
||||
return orz.NewError(500, "获取事件失败")
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{
|
||||
"message": "事件不存在",
|
||||
})
|
||||
return orz.NewError(404, "事件不存在")
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, event)
|
||||
return orz.Ok(c, event)
|
||||
}
|
||||
|
||||
// DeleteEvents 删除探针的所有SSH登录事件
|
||||
@@ -123,12 +108,10 @@ func (h *SSHLoginHandler) GetEvent(c echo.Context) error {
|
||||
func (h *SSHLoginHandler) DeleteEvents(c echo.Context) error {
|
||||
agentID := c.Param("id")
|
||||
|
||||
ctx := context.Background()
|
||||
ctx := c.Request().Context()
|
||||
if err := h.service.DeleteEventsByAgentID(ctx, agentID); err != nil {
|
||||
h.logger.Error("删除SSH登录事件失败", zap.Error(err))
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||
"message": "删除失败",
|
||||
})
|
||||
return orz.NewError(500, "删除失败")
|
||||
}
|
||||
|
||||
return orz.Ok(c, orz.Map{})
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/dushixiang/pika/internal/models"
|
||||
"github.com/dushixiang/pika/internal/service"
|
||||
"github.com/go-orz/orz"
|
||||
@@ -37,12 +34,10 @@ func (h *TamperHandler) UpdateConfig(c echo.Context) error {
|
||||
err := h.tamperService.UpdateConfig(c.Request().Context(), agentID, &req)
|
||||
if err != nil {
|
||||
h.logger.Error("更新防篡改配置失败", zap.Error(err), zap.String("agentId", agentID))
|
||||
return c.JSON(http.StatusInternalServerError, map[string]interface{}{
|
||||
"message": "更新配置失败",
|
||||
})
|
||||
return orz.NewError(500, "更新配置失败")
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, orz.Map{})
|
||||
return orz.Ok(c, orz.Map{})
|
||||
}
|
||||
|
||||
// GetConfig 获取探针的防篡改配置
|
||||
@@ -53,12 +48,10 @@ func (h *TamperHandler) GetConfig(c echo.Context) error {
|
||||
config, err := h.tamperService.GetConfigByAgentID(c.Request().Context(), agentID)
|
||||
if err != nil {
|
||||
h.logger.Error("获取防篡改配置失败", zap.Error(err), zap.String("agentId", agentID))
|
||||
return c.JSON(http.StatusInternalServerError, map[string]interface{}{
|
||||
"message": "获取配置失败",
|
||||
})
|
||||
return orz.NewError(500, "获取配置失败")
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, config)
|
||||
return orz.Ok(c, config)
|
||||
}
|
||||
|
||||
// ListEvents 获取探针的防篡改事件
|
||||
@@ -75,7 +68,7 @@ func (h *TamperHandler) ListEvents(c echo.Context) error {
|
||||
Equal("operation", c.QueryParam("operation")).
|
||||
Contains("details", c.QueryParam("details"))
|
||||
|
||||
ctx := context.Background()
|
||||
ctx := c.Request().Context()
|
||||
page, err := builder.Execute(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -89,9 +82,7 @@ func (h *TamperHandler) DeleteEvents(c echo.Context) error {
|
||||
err := h.tamperService.DeleteEventsByAgentID(c.Request().Context(), agentID)
|
||||
if err != nil {
|
||||
h.logger.Error("删除防篡改事件失败", zap.Error(err), zap.String("agentId", agentID))
|
||||
return c.JSON(http.StatusInternalServerError, map[string]interface{}{
|
||||
"message": "删除事件失败",
|
||||
})
|
||||
return orz.NewError(500, "删除事件失败")
|
||||
}
|
||||
return c.JSON(http.StatusOK, orz.Map{})
|
||||
return orz.Ok(c, orz.Map{})
|
||||
}
|
||||
@@ -14,6 +14,9 @@ type AlertRecord struct {
|
||||
Status string `json:"status"` // 状态: firing(告警中), resolved(已恢复)
|
||||
FiredAt int64 `gorm:"index" json:"firedAt"` // 触发时间(时间戳毫秒)
|
||||
ResolvedAt int64 `json:"resolvedAt,omitempty"` // 恢复时间(时间戳毫秒)
|
||||
NotificationStatus string `json:"notificationStatus"` // 通知状态: pending, sent, failed
|
||||
NotificationSentAt int64 `json:"notificationSentAt,omitempty"` // 通知发送时间(时间戳毫秒)
|
||||
NotificationError string `gorm:"type:text" json:"notificationError,omitempty"` // 通知发送失败原因
|
||||
CreatedAt int64 `json:"createdAt"` // 创建时间(时间戳毫秒)
|
||||
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli"` // 更新时间(时间戳毫秒)
|
||||
}
|
||||
|
||||
@@ -356,8 +356,8 @@ func (s *AgentService) GetStatistics(ctx context.Context) (map[string]interface{
|
||||
|
||||
// DeleteAgent 删除探针及其所有相关数据
|
||||
func (s *AgentService) DeleteAgent(ctx context.Context, agentID string) error {
|
||||
// 在事务中执行所有删除操作
|
||||
return s.Transaction(ctx, func(ctx context.Context) error {
|
||||
// 在事务中执行所有数据库删除操作
|
||||
err := s.Transaction(ctx, func(ctx context.Context) error {
|
||||
// 1. 删除探针的审计结果
|
||||
if err := s.AgentRepo.DeleteAuditResults(ctx, agentID); err != nil {
|
||||
s.logger.Error("删除探针审计结果失败", zap.String("agentId", agentID), zap.Error(err))
|
||||
@@ -382,9 +382,32 @@ func (s *AgentService) DeleteAgent(ctx context.Context, agentID string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
s.logger.Info("探针删除成功", zap.String("agentId", agentID))
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 5. 清理内存缓存中的探针数据(事务外执行)
|
||||
if s.metricService != nil {
|
||||
// 清理探针最新指标缓存
|
||||
s.metricService.DeleteAgentLatestMetricsCache(agentID)
|
||||
|
||||
// 清理监控缓存中该探针的数据
|
||||
s.metricService.CleanAgentFromMonitorCache(agentID)
|
||||
}
|
||||
|
||||
// 6. 清理 VictoriaMetrics 中的指标数据(事务外执行,失败不影响数据库删除结果)
|
||||
if s.metricService != nil {
|
||||
if err := s.metricService.CleanAgentMetrics(ctx, agentID); err != nil {
|
||||
s.logger.Error("清理VictoriaMetrics中的探针指标数据失败", zap.String("agentId", agentID), zap.Error(err))
|
||||
// 记录错误但不返回,因为数据库删除已成功
|
||||
}
|
||||
}
|
||||
|
||||
s.logger.Info("探针删除成功", zap.String("agentId", agentID))
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListByAuth 根据认证状态列出探针(已登录返回全部,未登录返回公开可见)
|
||||
|
||||
@@ -22,11 +22,12 @@ type AlertService struct {
|
||||
monitorService *MonitorService
|
||||
propertyService *PropertyService
|
||||
notifier *Notifier
|
||||
notificationQueue *NotificationQueue
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
func NewAlertService(logger *zap.Logger, db *gorm.DB, propertyService *PropertyService, monitorService *MonitorService, notifier *Notifier) *AlertService {
|
||||
return &AlertService{
|
||||
service := &AlertService{
|
||||
Service: orz.NewService(db),
|
||||
AlertRecordRepo: repo.NewAlertRecordRepo(db),
|
||||
AlertStateRepo: repo.NewAlertStateRepo(db),
|
||||
@@ -36,6 +37,11 @@ func NewAlertService(logger *zap.Logger, db *gorm.DB, propertyService *PropertyS
|
||||
notifier: notifier,
|
||||
logger: logger,
|
||||
}
|
||||
|
||||
service.notificationQueue = NewNotificationQueue(db, service, service.AlertRecordRepo, logger)
|
||||
service.notificationQueue.Start()
|
||||
|
||||
return service
|
||||
}
|
||||
|
||||
// Clear 清空告警记录
|
||||
@@ -57,6 +63,12 @@ func (s *AlertService) Clear(ctx context.Context) error {
|
||||
})
|
||||
}
|
||||
|
||||
// Shutdown 关闭告警服务
|
||||
func (s *AlertService) Shutdown() {
|
||||
s.logger.Info("关闭告警服务")
|
||||
s.notificationQueue.Shutdown()
|
||||
}
|
||||
|
||||
// CheckMetrics 检查指标并触发告警
|
||||
func (s *AlertService) CheckMetrics(ctx context.Context, agentID string, cpu, memory, disk, networkSpeed float64) error {
|
||||
// 获取全局告警配置
|
||||
@@ -148,6 +160,7 @@ func (s *AlertService) checkAlert(ctx context.Context, config *models.AlertConfi
|
||||
// 保存状态到数据库
|
||||
if err := s.AlertStateRepo.SaveAlertState(ctx, state); err != nil {
|
||||
s.logger.Error("保存告警状态失败", zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
if shouldFire {
|
||||
@@ -197,6 +210,7 @@ func (s *AlertService) fireAlert(ctx context.Context, config *models.AlertConfig
|
||||
state.LastRecordID = record.ID
|
||||
if err := s.AlertStateRepo.SaveAlertState(ctx, state); err != nil {
|
||||
s.logger.Error("保存告警状态失败", zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
// 发送通知 - 使用新的 context 避免父 context 取消影响通知发送
|
||||
@@ -294,31 +308,23 @@ func (s *AlertService) calculateLevel(value, threshold float64) string {
|
||||
}
|
||||
}
|
||||
|
||||
// sendAlertNotification 发送告警通知(带panic恢复)
|
||||
// sendAlertNotification 发送告警通知(通过队列异步发送)
|
||||
func (s *AlertService) sendAlertNotification(record *models.AlertRecord, agent *models.Agent) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
s.logger.Error("发送告警通知时发生panic",
|
||||
zap.Any("panic", r),
|
||||
zap.Int64("recordId", record.ID),
|
||||
)
|
||||
}
|
||||
}()
|
||||
s.notificationQueue.Enqueue(record.ID, agent)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// 获取告警配置(包含 MaskIP 设置)
|
||||
// sendAlertNotificationSync 同步发送告警通知(供队列worker调用)
|
||||
func (s *AlertService) sendAlertNotificationSync(ctx context.Context, record *models.AlertRecord, agent *models.Agent) error {
|
||||
alertConfig, err := s.propertyService.GetAlertConfig(ctx)
|
||||
if err != nil {
|
||||
s.logger.Error("获取告警配置失败", zap.Error(err))
|
||||
return
|
||||
return fmt.Errorf("获取告警配置失败: %w", err)
|
||||
}
|
||||
|
||||
channelConfigs, err := s.propertyService.GetNotificationChannelConfigs(ctx)
|
||||
if err != nil {
|
||||
s.logger.Error("获取通知渠道配置失败", zap.Error(err))
|
||||
return
|
||||
return fmt.Errorf("获取通知渠道配置失败: %w", err)
|
||||
}
|
||||
|
||||
var enabledChannels []models.NotificationChannelConfig
|
||||
@@ -329,12 +335,10 @@ func (s *AlertService) sendAlertNotification(record *models.AlertRecord, agent *
|
||||
}
|
||||
|
||||
if len(enabledChannels) == 0 {
|
||||
return
|
||||
return fmt.Errorf("没有启用的通知渠道")
|
||||
}
|
||||
|
||||
if err := s.notifier.SendNotificationByConfigs(ctx, enabledChannels, record, agent, alertConfig.MaskIP); err != nil {
|
||||
s.logger.Error("发送告警通知失败", zap.Error(err))
|
||||
}
|
||||
return s.notifier.SendNotificationByConfigs(ctx, enabledChannels, record, agent, alertConfig.MaskIP)
|
||||
}
|
||||
|
||||
// CheckMonitorAlerts 检查监控相关告警(证书和服务下线)
|
||||
@@ -386,6 +390,31 @@ func (s *AlertService) checkCertificateAlerts(ctx context.Context, config *model
|
||||
return err
|
||||
}
|
||||
|
||||
// 收集所有 agentIds,批量查询探针信息
|
||||
agentIdSet := make(map[string]bool)
|
||||
for _, monitor := range monitors {
|
||||
if monitor.AgentId != "" {
|
||||
agentIdSet[monitor.AgentId] = true
|
||||
}
|
||||
}
|
||||
|
||||
agentIds := make([]string, 0, len(agentIdSet))
|
||||
for id := range agentIdSet {
|
||||
agentIds = append(agentIds, id)
|
||||
}
|
||||
|
||||
agentMap := make(map[string]*models.Agent)
|
||||
if len(agentIds) > 0 {
|
||||
agents, err := s.agentRepo.ListByIDs(ctx, agentIds)
|
||||
if err != nil {
|
||||
s.logger.Error("批量获取探针信息失败", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
for i := range agents {
|
||||
agentMap[agents[i].ID] = &agents[i]
|
||||
}
|
||||
}
|
||||
|
||||
for _, monitor := range monitors {
|
||||
// 如果证书不存在或已过期,跳过
|
||||
if monitor.CertExpiryTime == 0 {
|
||||
@@ -394,20 +423,20 @@ func (s *AlertService) checkCertificateAlerts(ctx context.Context, config *model
|
||||
|
||||
certDaysLeft := float64(monitor.CertDaysLeft)
|
||||
|
||||
// 获取探针信息
|
||||
agent, err := s.agentRepo.FindById(ctx, monitor.AgentId)
|
||||
if err != nil {
|
||||
s.logger.Error("获取探针信息失败", zap.String("agentId", monitor.AgentId), zap.Error(err))
|
||||
// 从 map 中获取探针信息
|
||||
agent, exists := agentMap[monitor.AgentId]
|
||||
if !exists {
|
||||
s.logger.Error("探针信息不存在", zap.String("agentId", monitor.AgentId))
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查证书剩余天数是否低于阈值
|
||||
if certDaysLeft <= config.Rules.CertThreshold && certDaysLeft >= 0 {
|
||||
// 触发告警(证书告警不需要持续时间,直接触发)
|
||||
s.checkCertAlert(ctx, config, &agent, &monitor, certDaysLeft, now)
|
||||
s.checkCertAlert(ctx, config, agent, &monitor, certDaysLeft, now)
|
||||
} else {
|
||||
// 恢复告警(如果之前触发过)
|
||||
s.resolveCertAlert(ctx, config, &agent, &monitor, certDaysLeft)
|
||||
s.resolveCertAlert(ctx, config, agent, &monitor, certDaysLeft)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -444,6 +473,7 @@ func (s *AlertService) checkCertAlert(ctx context.Context, config *models.AlertC
|
||||
// 保存状态到数据库
|
||||
if err := s.AlertStateRepo.SaveAlertState(ctx, state); err != nil {
|
||||
s.logger.Error("保存告警状态失败", zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
if !shouldFire {
|
||||
@@ -489,6 +519,7 @@ func (s *AlertService) checkCertAlert(ctx context.Context, config *models.AlertC
|
||||
state.LastRecordID = record.ID
|
||||
if err := s.AlertStateRepo.SaveAlertState(ctx, state); err != nil {
|
||||
s.logger.Error("保存告警状态失败", zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
// 发送通知
|
||||
@@ -536,6 +567,7 @@ func (s *AlertService) resolveCertAlert(ctx context.Context, config *models.Aler
|
||||
state.LastRecordID = 0
|
||||
if err := s.AlertStateRepo.SaveAlertState(ctx, state); err != nil {
|
||||
s.logger.Error("保存告警状态失败", zap.Error(err))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -558,11 +590,36 @@ func (s *AlertService) checkServiceDownAlerts(ctx context.Context, config *model
|
||||
return err
|
||||
}
|
||||
|
||||
// 收集所有 agentIds,批量查询探针信息
|
||||
agentIdSet := make(map[string]bool)
|
||||
for _, monitor := range monitors {
|
||||
// 获取探针信息
|
||||
agent, err := s.agentRepo.FindById(ctx, monitor.AgentId)
|
||||
if monitor.AgentId != "" {
|
||||
agentIdSet[monitor.AgentId] = true
|
||||
}
|
||||
}
|
||||
|
||||
agentIds := make([]string, 0, len(agentIdSet))
|
||||
for id := range agentIdSet {
|
||||
agentIds = append(agentIds, id)
|
||||
}
|
||||
|
||||
agentMap := make(map[string]*models.Agent)
|
||||
if len(agentIds) > 0 {
|
||||
agents, err := s.agentRepo.ListByIDs(ctx, agentIds)
|
||||
if err != nil {
|
||||
s.logger.Error("获取探针信息失败", zap.String("agentId", monitor.AgentId), zap.Error(err))
|
||||
s.logger.Error("批量获取探针信息失败", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
for i := range agents {
|
||||
agentMap[agents[i].ID] = &agents[i]
|
||||
}
|
||||
}
|
||||
|
||||
for _, monitor := range monitors {
|
||||
// 从 map 中获取探针信息
|
||||
agent, exists := agentMap[monitor.AgentId]
|
||||
if !exists {
|
||||
s.logger.Error("探针信息不存在", zap.String("agentId", monitor.AgentId))
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -605,14 +662,15 @@ func (s *AlertService) checkServiceDownAlerts(ctx context.Context, config *model
|
||||
// 保存状态到数据库
|
||||
if err := s.AlertStateRepo.SaveAlertState(ctx, state); err != nil {
|
||||
s.logger.Error("保存告警状态失败", zap.Error(err))
|
||||
continue
|
||||
}
|
||||
|
||||
if shouldFire {
|
||||
s.fireServiceDownAlert(ctx, config, &agent, &monitor, state, now)
|
||||
s.fireServiceDownAlert(ctx, config, agent, &monitor, state, now)
|
||||
}
|
||||
|
||||
if shouldResolve {
|
||||
s.resolveServiceDownAlert(ctx, config, &agent, &monitor, state)
|
||||
s.resolveServiceDownAlert(ctx, config, agent, &monitor, state)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -660,6 +718,7 @@ func (s *AlertService) fireServiceDownAlert(ctx context.Context, config *models.
|
||||
state.LastRecordID = record.ID
|
||||
if err := s.AlertStateRepo.SaveAlertState(ctx, state); err != nil {
|
||||
s.logger.Error("保存告警状态失败", zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
// 发送通知
|
||||
@@ -699,6 +758,7 @@ func (s *AlertService) resolveServiceDownAlert(ctx context.Context, config *mode
|
||||
state.LastRecordID = 0
|
||||
if err := s.AlertStateRepo.SaveAlertState(ctx, state); err != nil {
|
||||
s.logger.Error("保存告警状态失败", zap.Error(err))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -753,6 +813,7 @@ func (s *AlertService) checkAgentOfflineAlerts(ctx context.Context, config *mode
|
||||
// 保存状态到数据库
|
||||
if err := s.AlertStateRepo.SaveAlertState(ctx, state); err != nil {
|
||||
s.logger.Error("保存告警状态失败", zap.Error(err))
|
||||
continue
|
||||
}
|
||||
|
||||
if shouldFire {
|
||||
@@ -799,6 +860,7 @@ func (s *AlertService) fireAgentOfflineAlert(ctx context.Context, config *models
|
||||
state.LastRecordID = record.ID
|
||||
if err := s.AlertStateRepo.SaveAlertState(ctx, state); err != nil {
|
||||
s.logger.Error("保存告警状态失败", zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
// 发送通知
|
||||
|
||||
@@ -48,6 +48,104 @@ func NewMetricService(logger *zap.Logger, db *gorm.DB, propertyService *Property
|
||||
}
|
||||
}
|
||||
|
||||
// CleanAgentMetrics 清理指定探针的所有指标数据
|
||||
func (s *MetricService) CleanAgentMetrics(ctx context.Context, agentID string) error {
|
||||
if s.vmClient == nil {
|
||||
return fmt.Errorf("vmClient not initialized")
|
||||
}
|
||||
|
||||
s.logger.Info("开始清理探针指标数据", zap.String("agentID", agentID))
|
||||
|
||||
// 在 VictoriaMetrics 中删除与该 agent 相关的所有时间序列数据
|
||||
matchers := []string{
|
||||
fmt.Sprintf(`{agent_id="%s"}`, agentID), // 删除具有该 agent_id 标签的所有时间序列
|
||||
}
|
||||
|
||||
if err := s.vmClient.DeleteSeries(ctx, matchers); err != nil {
|
||||
s.logger.Error("删除VictoriaMetrics中的探针指标数据失败",
|
||||
zap.String("agentID", agentID),
|
||||
zap.Error(err))
|
||||
return fmt.Errorf("删除VictoriaMetrics中的探针指标数据失败: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("成功清理探针指标数据", zap.String("agentID", agentID))
|
||||
return nil
|
||||
}
|
||||
|
||||
// CleanOrphanedAgentMetrics 批量清理已删除探针的指标数据
|
||||
func (s *MetricService) CleanOrphanedAgentMetrics(ctx context.Context) error {
|
||||
if s.vmClient == nil {
|
||||
return fmt.Errorf("vmClient not initialized")
|
||||
}
|
||||
|
||||
s.logger.Info("开始清理已删除探针的指标数据")
|
||||
|
||||
// 获取 VictoriaMetrics 中的所有 agent_id 值
|
||||
agentIDs, err := s.vmClient.GetLabelValues(ctx, "agent_id", []string{})
|
||||
if err != nil {
|
||||
s.logger.Error("获取VictoriaMetrics中的agent_id列表失败", zap.Error(err))
|
||||
return fmt.Errorf("获取VictoriaMetrics中的agent_id列表失败: %w", err)
|
||||
}
|
||||
|
||||
if len(agentIDs) == 0 {
|
||||
s.logger.Info("没有在VictoriaMetrics中找到任何agent_id,跳过清理")
|
||||
return nil
|
||||
}
|
||||
|
||||
// 获取当前数据库中所有存在的 agent id
|
||||
dbAgents, err := s.agentRepo.FindAll(ctx)
|
||||
if err != nil {
|
||||
s.logger.Error("查询数据库中的探针列表失败", zap.Error(err))
|
||||
return fmt.Errorf("查询数据库中的探针列表失败: %w", err)
|
||||
}
|
||||
|
||||
// 创建现有 agent id 的映射表
|
||||
existingAgentIDs := make(map[string]bool)
|
||||
for _, agent := range dbAgents {
|
||||
existingAgentIDs[agent.ID] = true
|
||||
}
|
||||
|
||||
// 找出已不在数据库中存在的 agent id
|
||||
var orphanedAgentIDs []string
|
||||
for _, agentID := range agentIDs {
|
||||
if !existingAgentIDs[agentID] {
|
||||
orphanedAgentIDs = append(orphanedAgentIDs, agentID)
|
||||
}
|
||||
}
|
||||
|
||||
if len(orphanedAgentIDs) == 0 {
|
||||
s.logger.Info("没有找到残留的探针指标数据需要清理")
|
||||
return nil
|
||||
}
|
||||
|
||||
s.logger.Info("发现需清理的残留探针", zap.Strings("agentIDs", orphanedAgentIDs))
|
||||
|
||||
// 逐个清理残留探针的指标数据
|
||||
for _, agentID := range orphanedAgentIDs {
|
||||
// 1. 清理内存缓存
|
||||
s.DeleteAgentLatestMetricsCache(agentID)
|
||||
s.CleanAgentFromMonitorCache(agentID)
|
||||
|
||||
// 2. 清理 VictoriaMetrics 数据
|
||||
matchers := []string{
|
||||
fmt.Sprintf(`{agent_id="%s"}`, agentID),
|
||||
}
|
||||
|
||||
if err := s.vmClient.DeleteSeries(ctx, matchers); err != nil {
|
||||
s.logger.Error("删除VictoriaMetrics中的残留探针指标数据失败",
|
||||
zap.String("agentID", agentID),
|
||||
zap.Error(err))
|
||||
// 继续处理下一个,不要因为一个失败而终止
|
||||
continue
|
||||
}
|
||||
|
||||
s.logger.Info("成功清理残留探针指标数据", zap.String("agentID", agentID))
|
||||
}
|
||||
|
||||
s.logger.Info("完成残留探针指标数据清理", zap.Int("clearedCount", len(orphanedAgentIDs)))
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandleMetricData 处理指标数据
|
||||
func (s *MetricService) HandleMetricData(ctx context.Context, agentID string, metricType string, data json.RawMessage, timestamp int64) error {
|
||||
if timestamp == 0 {
|
||||
@@ -317,6 +415,34 @@ func (s *MetricService) CleanMonitorCache(ctx context.Context, monitorID string)
|
||||
return nil
|
||||
}
|
||||
|
||||
// CleanAgentFromMonitorCache 从所有监控缓存中移除指定探针的数据
|
||||
func (s *MetricService) CleanAgentFromMonitorCache(agentID string) {
|
||||
// 获取所有监控缓存键
|
||||
monitorIDs := s.monitorLatestCache.Keys()
|
||||
|
||||
for _, monitorID := range monitorIDs {
|
||||
// 获取监控缓存
|
||||
latestMetrics, ok := s.monitorLatestCache.Get(monitorID)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// 从该监控缓存中删除指定探针的数据
|
||||
latestMetrics.Agents.Delete(agentID)
|
||||
s.logger.Debug("从监控缓存中移除探针数据",
|
||||
zap.String("monitorID", monitorID),
|
||||
zap.String("agentID", agentID))
|
||||
}
|
||||
|
||||
s.logger.Info("已从所有监控缓存中移除探针数据", zap.String("agentID", agentID))
|
||||
}
|
||||
|
||||
// DeleteAgentLatestMetricsCache 删除探针在内存中的最新指标缓存
|
||||
func (s *MetricService) DeleteAgentLatestMetricsCache(agentID string) {
|
||||
s.latestCache.Delete(agentID)
|
||||
s.logger.Debug("已删除探针最新指标缓存", zap.String("agentID", agentID))
|
||||
}
|
||||
|
||||
// updateMonitorCache 更新监控数据缓存
|
||||
func (s *MetricService) updateMonitorCache(agentID string, monitorData *protocol.MonitorData, timestamp int64) {
|
||||
monitorID := monitorData.MonitorId
|
||||
@@ -710,7 +836,7 @@ func (s *MetricService) GetMonitorHistory(ctx context.Context, monitorID string,
|
||||
}
|
||||
|
||||
// GetMonitorAgentStats 获取监控任务各探针的统计数据(只从缓存读取)
|
||||
func (s *MetricService) GetMonitorAgentStats(monitorID string) []protocol.MonitorData {
|
||||
func (s *MetricService) GetMonitorAgentStats(ctx context.Context, monitorID string) []protocol.MonitorData {
|
||||
// 从缓存读取监控数据
|
||||
latestMetrics, ok := s.monitorLatestCache.Get(monitorID)
|
||||
if !ok {
|
||||
@@ -719,7 +845,6 @@ func (s *MetricService) GetMonitorAgentStats(monitorID string) []protocol.Monito
|
||||
}
|
||||
|
||||
// 查询监控任务配置
|
||||
ctx := context.Background()
|
||||
monitorTask, err := s.monitorRepo.FindById(ctx, monitorID)
|
||||
if err != nil {
|
||||
s.logger.Error("查询监控任务失败", zap.String("monitorID", monitorID), zap.Error(err))
|
||||
@@ -779,7 +904,7 @@ func (s *MetricService) GetMonitorAgentStats(monitorID string) []protocol.Monito
|
||||
}
|
||||
|
||||
// GetMonitorStats 获取监控任务的聚合统计数据(只从缓存读取)
|
||||
func (s *MetricService) GetMonitorStats(monitorID string) *metric.MonitorStatsResult {
|
||||
func (s *MetricService) GetMonitorStats(ctx context.Context, monitorID string) *metric.MonitorStatsResult {
|
||||
// 从缓存读取监控数据
|
||||
latestMetrics, ok := s.monitorLatestCache.Get(monitorID)
|
||||
if !ok {
|
||||
@@ -790,7 +915,6 @@ func (s *MetricService) GetMonitorStats(monitorID string) *metric.MonitorStatsRe
|
||||
}
|
||||
|
||||
// 查询监控任务配置
|
||||
ctx := context.Background()
|
||||
monitorTask, err := s.monitorRepo.FindById(ctx, monitorID)
|
||||
if err != nil {
|
||||
s.logger.Error("查询监控任务失败", zap.String("monitorID", monitorID), zap.Error(err))
|
||||
|
||||
@@ -219,7 +219,7 @@ func (s *MonitorService) ListByAuth(ctx context.Context, isAuthenticated bool) (
|
||||
items := make([]metric.PublicMonitorOverview, 0, len(monitors))
|
||||
for _, monitor := range monitors {
|
||||
// 查询统计数据
|
||||
stats := s.metricService.GetMonitorStats(monitor.ID)
|
||||
stats := s.metricService.GetMonitorStats(ctx, monitor.ID)
|
||||
// 构建监控概览对象
|
||||
item := s.buildMonitorOverview(monitor, stats)
|
||||
items = append(items, item)
|
||||
@@ -341,7 +341,7 @@ func (s *MonitorService) GetMonitorStatsByID(ctx context.Context, monitorID stri
|
||||
}
|
||||
|
||||
// 查询统计数据
|
||||
stats := s.metricService.GetMonitorStats(monitorID)
|
||||
stats := s.metricService.GetMonitorStats(ctx, monitorID)
|
||||
// 构建监控概览对象
|
||||
overview := s.buildMonitorOverview(monitor, stats)
|
||||
|
||||
@@ -381,7 +381,7 @@ func (s *MonitorService) GetLatestMonitorMetricsByType(ctx context.Context, moni
|
||||
// 在缓存中查询最新的监控数据
|
||||
var result []protocol.MonitorData
|
||||
for _, task := range monitorTasks {
|
||||
monitorData := s.metricService.GetMonitorAgentStats(task.ID)
|
||||
monitorData := s.metricService.GetMonitorAgentStats(ctx, task.ID)
|
||||
// 填充监控任务名称
|
||||
for i := range monitorData {
|
||||
monitorData[i].MonitorName = task.Name
|
||||
@@ -403,7 +403,7 @@ func (s *MonitorService) GetAllLatestMonitorMetrics(ctx context.Context) ([]prot
|
||||
// 在缓存中查询最新的监控数据
|
||||
var result []protocol.MonitorData
|
||||
for _, task := range monitorTasks {
|
||||
monitorData := s.metricService.GetMonitorAgentStats(task.ID)
|
||||
monitorData := s.metricService.GetMonitorAgentStats(ctx, task.ID)
|
||||
// 填充监控任务名称
|
||||
for i := range monitorData {
|
||||
monitorData[i].MonitorName = task.Name
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/dushixiang/pika/internal/models"
|
||||
"github.com/dushixiang/pika/internal/repo"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
maxConcurrentNotifications = 10
|
||||
notificationTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
type NotificationTask struct {
|
||||
RecordID int64
|
||||
Agent *models.Agent
|
||||
}
|
||||
|
||||
type NotificationQueue struct {
|
||||
db *gorm.DB
|
||||
alertService *AlertService
|
||||
alertRecordRepo *repo.AlertRecordRepo
|
||||
logger *zap.Logger
|
||||
taskChan chan NotificationTask
|
||||
semaphore chan struct{}
|
||||
shutdownChan chan struct{}
|
||||
}
|
||||
|
||||
func NewNotificationQueue(db *gorm.DB, alertService *AlertService, alertRecordRepo *repo.AlertRecordRepo, logger *zap.Logger) *NotificationQueue {
|
||||
return &NotificationQueue{
|
||||
db: db,
|
||||
alertService: alertService,
|
||||
alertRecordRepo: alertRecordRepo,
|
||||
logger: logger,
|
||||
taskChan: make(chan NotificationTask, 100),
|
||||
semaphore: make(chan struct{}, maxConcurrentNotifications),
|
||||
shutdownChan: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (q *NotificationQueue) Start() {
|
||||
q.logger.Info("启动通知发送队列", zap.Int("maxConcurrent", maxConcurrentNotifications))
|
||||
|
||||
for i := 0; i < maxConcurrentNotifications; i++ {
|
||||
go q.worker()
|
||||
}
|
||||
}
|
||||
|
||||
func (q *NotificationQueue) worker() {
|
||||
for {
|
||||
select {
|
||||
case <-q.shutdownChan:
|
||||
q.logger.Info("通知worker停止")
|
||||
return
|
||||
case task := <-q.taskChan:
|
||||
q.processTask(task)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (q *NotificationQueue) processTask(task NotificationTask) {
|
||||
q.semaphore <- struct{}{}
|
||||
defer func() { <-q.semaphore }()
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
q.logger.Error("处理通知任务时发生panic",
|
||||
zap.Any("panic", r),
|
||||
zap.Int64("recordId", task.RecordID),
|
||||
)
|
||||
}
|
||||
}()
|
||||
|
||||
record, err := q.alertRecordRepo.GetAlertRecordByID(context.Background(), task.RecordID)
|
||||
if err != nil {
|
||||
q.logger.Error("获取告警记录失败",
|
||||
zap.Int64("recordId", task.RecordID),
|
||||
zap.Error(err),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), notificationTimeout)
|
||||
defer cancel()
|
||||
|
||||
sendErr := q.alertService.sendAlertNotificationSync(ctx, record, task.Agent)
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if sendErr != nil {
|
||||
q.updateNotificationStatus(record.ID, "failed", 0, sendErr.Error())
|
||||
q.logger.Error("通知发送失败",
|
||||
zap.Int64("recordId", record.ID),
|
||||
zap.Error(sendErr),
|
||||
)
|
||||
} else {
|
||||
q.updateNotificationStatus(record.ID, "sent", now, "")
|
||||
q.logger.Info("通知发送成功",
|
||||
zap.Int64("recordId", record.ID),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func (q *NotificationQueue) updateNotificationStatus(recordID int64, status string, sentAt int64, errMsg string) {
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
updates := map[string]interface{}{
|
||||
"notification_status": status,
|
||||
"updated_at": now,
|
||||
}
|
||||
|
||||
if sentAt > 0 {
|
||||
updates["notification_sent_at"] = sentAt
|
||||
}
|
||||
|
||||
if errMsg != "" {
|
||||
updates["notification_error"] = errMsg
|
||||
} else {
|
||||
updates["notification_error"] = ""
|
||||
}
|
||||
|
||||
if err := q.db.Model(&models.AlertRecord{}).
|
||||
Where("id = ?", recordID).
|
||||
Updates(updates).Error; err != nil {
|
||||
q.logger.Error("更新通知状态失败",
|
||||
zap.Int64("recordId", recordID),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func (q *NotificationQueue) Enqueue(recordID int64, agent *models.Agent) error {
|
||||
q.updateNotificationStatus(recordID, "pending", 0, "")
|
||||
|
||||
task := NotificationTask{
|
||||
RecordID: recordID,
|
||||
Agent: agent,
|
||||
}
|
||||
|
||||
select {
|
||||
case q.taskChan <- task:
|
||||
q.logger.Info("通知任务已加入队列",
|
||||
zap.Int64("recordId", recordID),
|
||||
zap.Int("queueLen", len(q.taskChan)),
|
||||
)
|
||||
return nil
|
||||
default:
|
||||
errMsg := "通知队列已满,任务被丢弃"
|
||||
q.updateNotificationStatus(recordID, "failed", 0, errMsg)
|
||||
q.logger.Warn(errMsg,
|
||||
zap.Int64("recordId", recordID),
|
||||
zap.Int("queueLen", len(q.taskChan)),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (q *NotificationQueue) Shutdown() {
|
||||
q.logger.Info("关闭通知发送队列")
|
||||
close(q.shutdownChan)
|
||||
|
||||
for i := 0; i < maxConcurrentNotifications; i++ {
|
||||
q.semaphore <- struct{}{}
|
||||
}
|
||||
|
||||
q.logger.Info("通知发送队列已关闭")
|
||||
}
|
||||
|
||||
func (q *NotificationQueue) GetQueueLength() int {
|
||||
return len(q.taskChan)
|
||||
}
|
||||
|
||||
func (q *NotificationQueue) GetActiveWorkers() int {
|
||||
return maxConcurrentNotifications - len(q.semaphore)
|
||||
}
|
||||
+191
-76
@@ -13,7 +13,9 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/dushixiang/pika/internal/models"
|
||||
"github.com/dushixiang/pika/internal/utils"
|
||||
@@ -23,6 +25,20 @@ import (
|
||||
"gopkg.in/gomail.v2"
|
||||
)
|
||||
|
||||
const (
|
||||
maxRetries = 3
|
||||
retryBaseDelay = 1 * time.Second
|
||||
)
|
||||
|
||||
var sharedHTTPClient = &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
MaxIdleConns: 100,
|
||||
MaxIdleConnsPerHost: 10,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
},
|
||||
}
|
||||
|
||||
// AlertTypeMetadata 告警类型元数据
|
||||
type AlertTypeMetadata struct {
|
||||
Name string // 中文名称
|
||||
@@ -79,8 +95,8 @@ var alertTypeMetadataMap = map[string]AlertTypeMetadata{
|
||||
"service": {
|
||||
Name: "服务告警",
|
||||
ThresholdUnit: "秒",
|
||||
ValueUnit: "秒",
|
||||
ShowThreshold: true,
|
||||
ValueUnit: "秒离线",
|
||||
ShowThreshold: false,
|
||||
ShowActual: true,
|
||||
},
|
||||
"agent_offline": {
|
||||
@@ -236,25 +252,30 @@ func (n *Notifier) buildFiringMessage(
|
||||
levelIcon string,
|
||||
metadata AlertTypeMetadata,
|
||||
) string {
|
||||
title := fmt.Sprintf("%s %s", levelIcon, metadata.Name)
|
||||
if record.Level != "" && record.Level != "info" {
|
||||
title = fmt.Sprintf("%s %s【%s】", levelIcon, metadata.Name, strings.ToUpper(record.Level))
|
||||
}
|
||||
|
||||
lines := []string{
|
||||
fmt.Sprintf("%s %s", levelIcon, metadata.Name),
|
||||
title,
|
||||
"",
|
||||
fmt.Sprintf("探针: %s", agent.Name),
|
||||
fmt.Sprintf("主机: %s", agent.Hostname),
|
||||
fmt.Sprintf("IP: %s", displayIP),
|
||||
fmt.Sprintf("告警类型: %s", record.AlertType),
|
||||
fmt.Sprintf("告警消息: %s", record.Message),
|
||||
fmt.Sprintf("📍 探针: %s", agent.Name),
|
||||
fmt.Sprintf("🖥️ 主机: %s", agent.Hostname),
|
||||
fmt.Sprintf("🌐 IP: %s", displayIP),
|
||||
"",
|
||||
"⚠️ " + record.Message,
|
||||
}
|
||||
|
||||
if metadata.ShowThreshold {
|
||||
lines = append(lines, fmt.Sprintf("阈值: %.2f%s", record.Threshold, metadata.ThresholdUnit))
|
||||
lines = append(lines, fmt.Sprintf("📊 阈值: %.2f%s", record.Threshold, metadata.ThresholdUnit))
|
||||
}
|
||||
|
||||
if metadata.ShowActual {
|
||||
lines = append(lines, fmt.Sprintf("当前值: %.2f%s", record.ActualValue, metadata.ValueUnit))
|
||||
lines = append(lines, fmt.Sprintf("📈 当前值: %.2f%s", record.ActualValue, metadata.ValueUnit))
|
||||
}
|
||||
|
||||
lines = append(lines, fmt.Sprintf("触发时间: %s", utils.FormatTimestamp(record.FiredAt)))
|
||||
lines = append(lines, "", fmt.Sprintf("🕐 时间: %s", utils.FormatTimestamp(record.FiredAt)))
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
@@ -266,7 +287,6 @@ func (n *Notifier) buildResolvedMessage(
|
||||
displayIP string,
|
||||
metadata AlertTypeMetadata,
|
||||
) string {
|
||||
// 计算持续时间
|
||||
var durationStr string
|
||||
if record.FiredAt > 0 && record.ResolvedAt > record.FiredAt {
|
||||
durationMs := record.ResolvedAt - record.FiredAt
|
||||
@@ -276,32 +296,32 @@ func (n *Notifier) buildResolvedMessage(
|
||||
lines := []string{
|
||||
fmt.Sprintf("✅ %s已恢复", metadata.Name),
|
||||
"",
|
||||
fmt.Sprintf("探针: %s (%s)", agent.Name, agent.ID),
|
||||
fmt.Sprintf("主机: %s", agent.Hostname),
|
||||
fmt.Sprintf("IP: %s", displayIP),
|
||||
fmt.Sprintf("告警类型: %s", record.AlertType),
|
||||
fmt.Sprintf("📍 探针: %s", agent.Name),
|
||||
fmt.Sprintf("🖥️ 主机: %s", agent.Hostname),
|
||||
fmt.Sprintf("🌐 IP: %s", displayIP),
|
||||
"",
|
||||
"✓ " + record.Message,
|
||||
}
|
||||
|
||||
// 显示告警值和恢复值的对比
|
||||
if metadata.ShowActual {
|
||||
// 对于服务下线和探针离线,恢复值为0时显示"已在线"
|
||||
if (record.AlertType == "service" || record.AlertType == "agent_offline") && record.ResolvedValue == 0 {
|
||||
lines = append(lines,
|
||||
fmt.Sprintf("告警值: %.2f%s", record.ActualValue, metadata.ValueUnit),
|
||||
"恢复状态: 已在线",
|
||||
fmt.Sprintf("⏱️ 离线时长: %.0f秒", record.ActualValue),
|
||||
"✅ 恢复状态: 已在线",
|
||||
)
|
||||
} else {
|
||||
lines = append(lines,
|
||||
fmt.Sprintf("告警值: %.2f%s", record.ActualValue, metadata.ValueUnit),
|
||||
fmt.Sprintf("恢复值: %.2f%s", record.ResolvedValue, metadata.ValueUnit),
|
||||
fmt.Sprintf("📈 告警值: %.2f%s", record.ActualValue, metadata.ValueUnit),
|
||||
fmt.Sprintf("📉 恢复值: %.2f%s", record.ResolvedValue, metadata.ValueUnit),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
lines = append(lines,
|
||||
fmt.Sprintf("持续时间: %s", durationStr),
|
||||
fmt.Sprintf("恢复时间: %s", utils.FormatTimestamp(record.ResolvedAt)),
|
||||
)
|
||||
if durationStr != "" {
|
||||
lines = append(lines, fmt.Sprintf("⏱️ 持续时间: %s", durationStr))
|
||||
}
|
||||
|
||||
lines = append(lines, fmt.Sprintf("🕐 恢复时间: %s", utils.FormatTimestamp(record.ResolvedAt)))
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
@@ -313,25 +333,30 @@ func (n *Notifier) buildNoticeMessage(
|
||||
levelIcon string,
|
||||
metadata AlertTypeMetadata,
|
||||
) string {
|
||||
title := fmt.Sprintf("%s %s", levelIcon, metadata.Name)
|
||||
if record.Level != "" && record.Level != "info" {
|
||||
title = fmt.Sprintf("%s %s【%s】", levelIcon, metadata.Name, strings.ToUpper(record.Level))
|
||||
}
|
||||
|
||||
lines := []string{
|
||||
fmt.Sprintf("%s %s通知", levelIcon, metadata.Name),
|
||||
title,
|
||||
"",
|
||||
fmt.Sprintf("探针: %s", agent.Name),
|
||||
fmt.Sprintf("主机: %s", agent.Hostname),
|
||||
fmt.Sprintf("IP: %s", displayIP),
|
||||
fmt.Sprintf("告警类型: %s", record.AlertType),
|
||||
fmt.Sprintf("告警消息: %s", record.Message),
|
||||
fmt.Sprintf("📍 探针: %s", agent.Name),
|
||||
fmt.Sprintf("🖥️ 主机: %s", agent.Hostname),
|
||||
fmt.Sprintf("🌐 IP: %s", displayIP),
|
||||
"",
|
||||
"ℹ️ " + record.Message,
|
||||
}
|
||||
|
||||
if metadata.ShowThreshold {
|
||||
lines = append(lines, fmt.Sprintf("阈值: %.2f%s", record.Threshold, metadata.ThresholdUnit))
|
||||
lines = append(lines, fmt.Sprintf("📊 阈值: %.2f%s", record.Threshold, metadata.ThresholdUnit))
|
||||
}
|
||||
|
||||
if metadata.ShowActual {
|
||||
lines = append(lines, fmt.Sprintf("当前值: %.2f%s", record.ActualValue, metadata.ValueUnit))
|
||||
lines = append(lines, fmt.Sprintf("📈 当前值: %.2f%s", record.ActualValue, metadata.ValueUnit))
|
||||
}
|
||||
|
||||
lines = append(lines, fmt.Sprintf("触发时间: %s", utils.FormatTimestamp(record.FiredAt)))
|
||||
lines = append(lines, "", fmt.Sprintf("🕐 时间: %s", utils.FormatTimestamp(record.FiredAt)))
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
@@ -408,7 +433,7 @@ func (n *Notifier) getWecomAppToken(ctx context.Context, origin, corpId, corpSec
|
||||
accessTokenURL := fmt.Sprintf("%s/cgi-bin/gettoken?corpid=%s&corpsecret=%s", origin, corpId, corpSecret)
|
||||
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, accessTokenURL, nil)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
resp, err := sharedHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -418,7 +443,7 @@ func (n *Notifier) getWecomAppToken(ctx context.Context, origin, corpId, corpSec
|
||||
AccessToken string `json:"access_token"`
|
||||
ErrCode int `json:"errcode"`
|
||||
ErrMsg string `json:"errmsg"`
|
||||
ExpiresIn int64 `json:"expires_in"` //Second
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
|
||||
@@ -429,12 +454,10 @@ func (n *Notifier) getWecomAppToken(ctx context.Context, origin, corpId, corpSec
|
||||
return "", errors.New(tokenResp.ErrMsg)
|
||||
}
|
||||
|
||||
// 提前两分钟过期
|
||||
token := tokenResp.AccessToken
|
||||
expires := time.Duration(tokenResp.ExpiresIn)*time.Second - 2*time.Minute
|
||||
wecomAppAccessTokenCache.Set(key, token, expires)
|
||||
return token, nil
|
||||
|
||||
}
|
||||
|
||||
// sendWeComApp 发送企业应用微信通知
|
||||
@@ -512,15 +535,20 @@ func (n *Notifier) sendFeishu(ctx context.Context, webhook, signSecret, message
|
||||
|
||||
// sendTelegram 发送 Telegram 通知
|
||||
func (n *Notifier) sendTelegram(ctx context.Context, botToken, chatID, message string) error {
|
||||
// 构造 Telegram Bot API URL
|
||||
if utf8.RuneCountInString(message) > 4096 {
|
||||
n.logger.Warn("Telegram消息过长,进行截断",
|
||||
zap.Int("originalLen", utf8.RuneCountInString(message)),
|
||||
zap.Int("truncatedLen", 4096),
|
||||
)
|
||||
runes := []rune(message)
|
||||
message = string(runes[:4093]) + "..."
|
||||
}
|
||||
|
||||
webhookURL := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", botToken)
|
||||
|
||||
// 构造消息体
|
||||
body := map[string]interface{}{
|
||||
"chat_id": chatID,
|
||||
"text": message,
|
||||
// 可选:使用 Markdown 格式
|
||||
// "parse_mode": "Markdown",
|
||||
}
|
||||
|
||||
_, err := n.sendJSONRequest(ctx, webhookURL, body)
|
||||
@@ -532,19 +560,35 @@ func (n *Notifier) sendTelegram(ctx context.Context, botToken, chatID, message s
|
||||
|
||||
// sendEmail 发送邮件通知
|
||||
func (n *Notifier) sendEmail(ctx context.Context, smtpHost string, smtpPort int, fromEmail, password, toEmail, subject, message string) error {
|
||||
// 创建邮件消息
|
||||
m := gomail.NewMessage()
|
||||
m.SetHeader("From", fromEmail)
|
||||
m.SetHeader("To", toEmail)
|
||||
m.SetHeader("Subject", subject)
|
||||
m.SetBody("text/plain", message)
|
||||
|
||||
// 创建 SMTP 拨号器
|
||||
d := gomail.NewDialer(smtpHost, smtpPort, fromEmail, password)
|
||||
|
||||
// 发送邮件
|
||||
var lastErr error
|
||||
for i := 0; i < maxRetries; i++ {
|
||||
if i > 0 {
|
||||
delay := retryBaseDelay * time.Duration(i)
|
||||
n.logger.Info("邮件发送重试",
|
||||
zap.Int("attempt", i+1),
|
||||
zap.Duration("delay", delay),
|
||||
)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(delay):
|
||||
}
|
||||
}
|
||||
|
||||
if err := d.DialAndSend(m); err != nil {
|
||||
return fmt.Errorf("发送邮件失败: %w", err)
|
||||
lastErr = err
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
n.logger.Info("邮件发送成功",
|
||||
@@ -552,8 +596,10 @@ func (n *Notifier) sendEmail(ctx context.Context, smtpHost string, smtpPort int,
|
||||
zap.String("to", toEmail),
|
||||
zap.String("subject", subject),
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("邮件重试%d次后仍失败: %w", maxRetries, lastErr)
|
||||
}
|
||||
|
||||
// webhookConfig Webhook 配置
|
||||
@@ -664,34 +710,66 @@ func (n *Notifier) buildCustomBody(agent *models.Agent, record *models.AlertReco
|
||||
return strings.NewReader(bodyStr), nil
|
||||
}
|
||||
|
||||
// sendHTTPRequest 发送 HTTP 请求
|
||||
func (n *Notifier) sendHTTPRequest(ctx context.Context, method, webhookURL string, body io.Reader, headers map[string]string, contentType string) error {
|
||||
// 创建请求
|
||||
req, err := http.NewRequestWithContext(ctx, method, webhookURL, body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建请求失败: %w", err)
|
||||
// retryDoWithBackoff 带重试和退避的 HTTP 请求执行
|
||||
func (n *Notifier) retryDoWithBackoff(ctx context.Context, createReq func() (*http.Request, error)) (*http.Response, error) {
|
||||
var lastErr error
|
||||
for i := 0; i < maxRetries; i++ {
|
||||
if i > 0 {
|
||||
delay := retryBaseDelay * time.Duration(i)
|
||||
n.logger.Info("通知发送重试",
|
||||
zap.Int("attempt", i+1),
|
||||
zap.Duration("delay", delay),
|
||||
)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(delay):
|
||||
}
|
||||
}
|
||||
|
||||
// 设置 Content-Type
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
req, err := createReq()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建请求失败: %w", err)
|
||||
}
|
||||
|
||||
// 设置自定义请求头
|
||||
resp, err := sharedHTTPClient.Do(req)
|
||||
if err == nil {
|
||||
return resp, nil
|
||||
}
|
||||
lastErr = err
|
||||
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("重试%d次后仍失败: %w", maxRetries, lastErr)
|
||||
}
|
||||
|
||||
// sendHTTPRequest 发送 HTTP 请求
|
||||
func (n *Notifier) sendHTTPRequest(ctx context.Context, method, webhookURL string, body io.Reader, headers map[string]string, contentType string) error {
|
||||
bodyData, err := io.ReadAll(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取请求体失败: %w", err)
|
||||
}
|
||||
|
||||
createReq := func() (*http.Request, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, method, webhookURL, bytes.NewReader(bodyData))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
|
||||
// 发送请求
|
||||
client := &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
return req, nil
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
resp, err := n.retryDoWithBackoff(ctx, createReq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("发送请求失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 读取响应
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
@@ -729,31 +807,27 @@ func (n *Notifier) sendCustomWebhook(ctx context.Context, config map[string]inte
|
||||
return n.sendHTTPRequest(ctx, cfg.Method, cfg.URL, reqBody, cfg.Headers, contentType)
|
||||
}
|
||||
|
||||
// sendJSONRequest 发送JSON请求
|
||||
func (n *Notifier) sendJSONRequest(ctx context.Context, url string, body interface{}) ([]byte, error) {
|
||||
data, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("序列化请求体失败: %w", err)
|
||||
}
|
||||
|
||||
createReq := func() (*http.Request, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建请求失败: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
return req, nil
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
resp, err := n.retryDoWithBackoff(ctx, createReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("发送请求失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 读取响应
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
@@ -940,25 +1014,66 @@ func (n *Notifier) SendNotificationByConfig(ctx context.Context, channelConfig *
|
||||
|
||||
// SendNotificationByConfigs 根据新的配置结构向多个渠道发送通知
|
||||
func (n *Notifier) SendNotificationByConfigs(ctx context.Context, channelConfigs []models.NotificationChannelConfig, record *models.AlertRecord, agent *models.Agent, maskIP bool) error {
|
||||
var errs []error
|
||||
message := n.buildMessage(agent, record, maskIP)
|
||||
|
||||
for _, channelConfig := range channelConfigs {
|
||||
if err := n.SendNotificationByConfig(ctx, &channelConfig, record, agent, maskIP); err != nil {
|
||||
var wg sync.WaitGroup
|
||||
var errs []error
|
||||
var mu sync.Mutex
|
||||
|
||||
for _, cfg := range channelConfigs {
|
||||
if !cfg.Enabled {
|
||||
continue
|
||||
}
|
||||
wg.Add(1)
|
||||
go func(cfg models.NotificationChannelConfig) {
|
||||
defer wg.Done()
|
||||
err := n.sendToChannel(ctx, &cfg, message, agent, record, maskIP)
|
||||
if err != nil {
|
||||
n.logger.Error("发送通知失败",
|
||||
zap.String("channelType", channelConfig.Type),
|
||||
zap.String("channelType", cfg.Type),
|
||||
zap.Error(err),
|
||||
)
|
||||
mu.Lock()
|
||||
errs = append(errs, err)
|
||||
mu.Unlock()
|
||||
}
|
||||
}(cfg)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
if len(errs) > 0 {
|
||||
return fmt.Errorf("部分通知发送失败: %v", errs)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (n *Notifier) sendToChannel(ctx context.Context, channelConfig *models.NotificationChannelConfig, message string, agent *models.Agent, record *models.AlertRecord, maskIP bool) error {
|
||||
n.logger.Info("发送通知", zap.String("channelType", channelConfig.Type))
|
||||
|
||||
channelCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
switch channelConfig.Type {
|
||||
case "dingtalk":
|
||||
return n.sendDingTalkByConfig(channelCtx, channelConfig.Config, message)
|
||||
case "wecom":
|
||||
return n.sendWeComByConfig(channelCtx, channelConfig.Config, message)
|
||||
case "wecomApp":
|
||||
return n.sendWeComAppByConfig(channelCtx, channelConfig.Config, message)
|
||||
case "feishu":
|
||||
return n.sendFeishuByConfig(channelCtx, channelConfig.Config, message)
|
||||
case "telegram":
|
||||
return n.sendTelegramByConfig(channelCtx, channelConfig.Config, message)
|
||||
case "email":
|
||||
return n.sendEmailByConfig(channelCtx, channelConfig.Config, message)
|
||||
case "webhook":
|
||||
return n.sendWebhookByConfig(channelCtx, channelConfig.Config, agent, record, maskIP)
|
||||
default:
|
||||
return fmt.Errorf("不支持的通知渠道类型: %s", channelConfig.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// SendDingTalkByConfig 导出方法供外部调用
|
||||
func (n *Notifier) SendDingTalkByConfig(ctx context.Context, config map[string]interface{}, message string) error {
|
||||
return n.sendDingTalkByConfig(ctx, config, message)
|
||||
|
||||
@@ -172,17 +172,17 @@ func (s *SSHLoginService) HandleEvent(ctx context.Context, agentID string, event
|
||||
s.logger.Info("IP在白名单中,忽略事件", zap.String("agentId", agentID), zap.String("ip", eventData.IP))
|
||||
return nil
|
||||
}
|
||||
s.sendLoginSuccessNotification(agentID, eventData, ipLocation)
|
||||
s.sendLoginSuccessNotification(ctx, agentID, eventData, ipLocation)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SSHLoginService) sendLoginSuccessNotification(agentID string, eventData protocol.SSHLoginEvent, ipLocation string) {
|
||||
func (s *SSHLoginService) sendLoginSuccessNotification(ctx context.Context, agentID string, eventData protocol.SSHLoginEvent, ipLocation string) {
|
||||
if s.notificationSvc == nil {
|
||||
return
|
||||
}
|
||||
|
||||
agent, err := s.agentRepo.FindById(context.Background(), agentID)
|
||||
agent, err := s.agentRepo.FindById(ctx, agentID)
|
||||
if err != nil {
|
||||
s.logger.Error("获取探针信息失败", zap.String("agentId", agentID), zap.Error(err))
|
||||
return
|
||||
@@ -195,7 +195,7 @@ func (s *SSHLoginService) sendLoginSuccessNotification(agentID string, eventData
|
||||
|
||||
sourceIP := eventData.IP
|
||||
if s.notificationSvc != nil {
|
||||
if maskIP, err := s.notificationSvc.IsMaskIPEnabled(context.Background()); err == nil && maskIP {
|
||||
if maskIP, err := s.notificationSvc.IsMaskIPEnabled(ctx); err == nil && maskIP {
|
||||
sourceIP = maskIPAddress(sourceIP)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,16 +208,16 @@ func (s *TamperService) CreateEvent(ctx context.Context, agentID string, eventDa
|
||||
return err
|
||||
}
|
||||
|
||||
s.sendTamperEventNotification(agentID, eventData)
|
||||
s.sendTamperEventNotification(ctx, agentID, eventData)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *TamperService) sendTamperEventNotification(agentID string, eventData *protocol.TamperEventData) {
|
||||
func (s *TamperService) sendTamperEventNotification(ctx context.Context, agentID string, eventData *protocol.TamperEventData) {
|
||||
if s.notificationSvc == nil {
|
||||
return
|
||||
}
|
||||
|
||||
agent, err := s.agentRepo.FindById(context.Background(), agentID)
|
||||
agent, err := s.agentRepo.FindById(ctx, agentID)
|
||||
if err != nil {
|
||||
s.logger.Error("获取探针信息失败", zap.String("agentId", agentID), zap.Error(err))
|
||||
return
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface Action {
|
||||
onClick: () => void;
|
||||
danger?: boolean;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
interface PageHeaderProps {
|
||||
@@ -38,6 +39,7 @@ export const PageHeader: React.FC<PageHeaderProps> = ({title, description, actio
|
||||
onClick={action.onClick}
|
||||
danger={action.danger}
|
||||
disabled={action.disabled}
|
||||
loading={action.loading}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {useMemo, useState} from 'react';
|
||||
import {Link, useNavigate} from 'react-router-dom';
|
||||
import type {MenuProps} from 'antd';
|
||||
import {App, Button, Divider, Dropdown, Form, Input, Select, Space, Table, Tag} from 'antd';
|
||||
import {App, Button, Divider, Dropdown, Form, Input, Select, Space, Table, Tag, message} from 'antd';
|
||||
import type {ColumnsType} from 'antd/es/table';
|
||||
import {Edit, Eye, EyeOff, FileWarning, Lock, MoreVertical, Plus, RefreshCw, Shield, Tags, Trash2} from 'lucide-react';
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
|
||||
@@ -495,17 +495,17 @@ const AgentList = () => {
|
||||
]}
|
||||
/>
|
||||
|
||||
<Divider/>
|
||||
|
||||
<Form form={searchForm} layout="inline" onFinish={handleSearch}>
|
||||
<div className="bg-white dark:bg-[#1c1c21] rounded-2xl border border-gray-100 dark:border-white/5 shadow-sm p-4 sm:p-6 space-y-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
<Form form={searchForm} layout="inline" onFinish={handleSearch} className="!flex-wrap gap-y-4">
|
||||
<Form.Item label="关键字" name="keyword">
|
||||
<Input placeholder="名称/主机名/通信地址/IPv4/IPv6" style={{width: 225}}/>
|
||||
<Input placeholder="名称/主机名/通信地址/IPv4/IPv6" style={{width: 260}}/>
|
||||
</Form.Item>
|
||||
<Form.Item label="状态" name="status">
|
||||
<Select
|
||||
placeholder="请选择状态"
|
||||
allowClear
|
||||
style={{width: 160}}
|
||||
style={{width: 140}}
|
||||
options={[
|
||||
{label: '在线', value: 'online'},
|
||||
{label: '离线', value: 'offline'},
|
||||
@@ -525,11 +525,12 @@ const AgentList = () => {
|
||||
</Form>
|
||||
|
||||
{tags.length > 0 && (
|
||||
<div className="flex items-center gap-2 flex-wrap pt-4">
|
||||
<span className="">标签筛选:</span>
|
||||
<div className="flex items-center gap-2 flex-wrap pt-2 border-t border-gray-50 dark:border-white/5 mt-2">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">标签筛选:</span>
|
||||
<Tag.CheckableTag
|
||||
checked={selectedTags.length === 0}
|
||||
onChange={() => setSelectedTags([])}
|
||||
className="!rounded-full"
|
||||
>
|
||||
全部
|
||||
</Tag.CheckableTag>
|
||||
@@ -544,12 +545,14 @@ const AgentList = () => {
|
||||
setSelectedTags(selectedTags.filter(t => t !== tag));
|
||||
}
|
||||
}}
|
||||
className="!rounded-full"
|
||||
>
|
||||
{tag}
|
||||
</Tag.CheckableTag>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Table<Agent>
|
||||
columns={columns}
|
||||
@@ -557,16 +560,15 @@ const AgentList = () => {
|
||||
loading={isLoading || isFetching}
|
||||
rowKey="id"
|
||||
scroll={{x: 'max-content'}}
|
||||
tableLayout="fixed"
|
||||
rowSelection={{
|
||||
selectedRowKeys,
|
||||
onChange: (keys) => setSelectedRowKeys(keys),
|
||||
preserveSelectedRowKeys: true,
|
||||
}}
|
||||
pagination={false}
|
||||
style={{
|
||||
marginTop: 16,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AgentEditModal
|
||||
open={editModalVisible}
|
||||
|
||||
@@ -174,6 +174,7 @@ const SSHLoginEvents: React.FC<SSHLoginEventsProps> = ({agentId}) => {
|
||||
dataSource={eventsPaging?.items || []}
|
||||
loading={isLoading || isFetching}
|
||||
rowKey="id"
|
||||
scroll={{x: 'max-content'}}
|
||||
pagination={{
|
||||
current: pageIndex,
|
||||
pageSize,
|
||||
|
||||
@@ -146,6 +146,7 @@ const TamperProtectionEvents: React.FC<TamperProtectionEventsProps> = ({agentId}
|
||||
dataSource={eventsPaging?.data.items || []}
|
||||
loading={isLoading || isFetching}
|
||||
rowKey="id"
|
||||
scroll={{x: 'max-content'}}
|
||||
pagination={{
|
||||
current: pageIndex,
|
||||
pageSize,
|
||||
|
||||
@@ -271,9 +271,8 @@ const AlertRecordList = () => {
|
||||
]}
|
||||
/>
|
||||
|
||||
<Divider/>
|
||||
|
||||
<div style={{marginBottom: 16}}>
|
||||
<div className="bg-white dark:bg-[#1c1c21] rounded-2xl border border-gray-100 dark:border-white/5 shadow-sm p-4 sm:p-6 space-y-4">
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
||||
<Space>
|
||||
<Select
|
||||
placeholder="选择探针"
|
||||
@@ -298,6 +297,8 @@ const AlertRecordList = () => {
|
||||
loading={isLoading || isFetching}
|
||||
rowKey="id"
|
||||
size={'small'}
|
||||
scroll={{x: 'max-content'}}
|
||||
tableLayout="fixed"
|
||||
pagination={{
|
||||
current: pageIndex,
|
||||
pageSize,
|
||||
@@ -307,12 +308,10 @@ const AlertRecordList = () => {
|
||||
pageSizeOptions: ['10', '20', '50', '100'],
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
}}
|
||||
scroll={{
|
||||
x: 'max-content'
|
||||
}}
|
||||
onChange={handleTableChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -264,9 +264,8 @@ const ApiKeyList = () => {
|
||||
]}
|
||||
/>
|
||||
|
||||
<Divider/>
|
||||
|
||||
<div style={{marginBottom: 16}}>
|
||||
<div className="bg-white dark:bg-[#1c1c21] rounded-2xl border border-gray-100 dark:border-white/5 shadow-sm p-4 sm:p-6 space-y-4">
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
||||
<Input.Search
|
||||
placeholder="按名称搜索"
|
||||
allowClear
|
||||
@@ -279,7 +278,7 @@ const ApiKeyList = () => {
|
||||
}
|
||||
}}
|
||||
onSearch={handleSearch}
|
||||
style={{width: 260}}
|
||||
className="w-full max-w-md"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -288,6 +287,8 @@ const ApiKeyList = () => {
|
||||
dataSource={apiKeyPaging?.items || []}
|
||||
loading={isLoading || isFetching}
|
||||
rowKey="id"
|
||||
scroll={{x: 'max-content'}}
|
||||
tableLayout="fixed"
|
||||
pagination={{
|
||||
current: pageIndex,
|
||||
pageSize,
|
||||
@@ -296,6 +297,7 @@ const ApiKeyList = () => {
|
||||
}}
|
||||
onChange={handleTableChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ApiKeyModal
|
||||
open={isModalVisible}
|
||||
|
||||
@@ -280,9 +280,8 @@ const DDNSPage = () => {
|
||||
]}
|
||||
/>
|
||||
|
||||
<Divider/>
|
||||
|
||||
<div style={{marginBottom: 16, display: 'flex', justifyContent: 'space-between'}}>
|
||||
<div className="bg-white dark:bg-[#1c1c21] rounded-2xl border border-gray-100 dark:border-white/5 shadow-sm p-4 sm:p-6 space-y-4">
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
||||
<Input.Search
|
||||
placeholder="按配置名称搜索"
|
||||
allowClear
|
||||
@@ -295,7 +294,7 @@ const DDNSPage = () => {
|
||||
}
|
||||
}}
|
||||
onSearch={handleSearch}
|
||||
style={{width: 260}}
|
||||
className="w-full max-w-md"
|
||||
/>
|
||||
<Button type="primary" icon={<Plus size={16}/>} onClick={handleCreate}>
|
||||
新建配置
|
||||
@@ -307,6 +306,8 @@ const DDNSPage = () => {
|
||||
dataSource={ddnsPaging?.items || []}
|
||||
loading={isLoading || isFetching}
|
||||
rowKey="id"
|
||||
scroll={{x: 'max-content'}}
|
||||
tableLayout="fixed"
|
||||
pagination={{
|
||||
current: pageIndex,
|
||||
pageSize,
|
||||
@@ -315,6 +316,7 @@ const DDNSPage = () => {
|
||||
}}
|
||||
onChange={handleTableChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DDNSModal
|
||||
open={modalOpen}
|
||||
|
||||
@@ -247,8 +247,8 @@ const MonitorList = () => {
|
||||
]}
|
||||
/>
|
||||
|
||||
<Divider/>
|
||||
|
||||
<div className="bg-white dark:bg-[#1c1c21] rounded-2xl border border-gray-100 dark:border-white/5 shadow-sm p-4 sm:p-6 space-y-4">
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
||||
<Input.Search
|
||||
placeholder="按名称或目标搜索"
|
||||
allowClear
|
||||
@@ -261,14 +261,17 @@ const MonitorList = () => {
|
||||
}
|
||||
}}
|
||||
onSearch={(value) => handleKeywordSearch(value)}
|
||||
style={{width: 260}}
|
||||
className="w-full max-w-md"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Table<MonitorTask>
|
||||
columns={columns}
|
||||
dataSource={dataSource}
|
||||
loading={isLoading || isFetching}
|
||||
rowKey="id"
|
||||
scroll={{x: 'max-content'}}
|
||||
tableLayout="fixed"
|
||||
pagination={{
|
||||
current: pageIndex,
|
||||
pageSize,
|
||||
@@ -278,6 +281,7 @@ const MonitorList = () => {
|
||||
}}
|
||||
onChange={handleTableChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<MonitorModal
|
||||
open={modalVisible}
|
||||
|
||||
@@ -559,3 +559,12 @@ export const getSSHLoginEvents = async (agentId: string, params?: any) => {
|
||||
export const deleteSSHLoginEvents = async (agentId: string) => {
|
||||
await del(`/admin/agents/${agentId}/ssh-login/events`);
|
||||
};
|
||||
|
||||
// 清理残留的探针指标数据
|
||||
export interface CleanupMetricsResponse {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export const cleanupOrphanedAgentMetrics = async () => {
|
||||
return post<CleanupMetricsResponse>('/admin/agents/cleanup-metrics', {});
|
||||
};
|
||||
@@ -61,6 +61,7 @@ export const MiniChart = ({data, lastValue, id}: MiniChartProps) => {
|
||||
strokeWidth={2}
|
||||
filter={`url(#glow-${id})`}
|
||||
isAnimationActive={false}
|
||||
connectNulls
|
||||
dot={false}
|
||||
/>
|
||||
</AreaChart>
|
||||
|
||||
@@ -27,31 +27,67 @@ const MonitorCard = ({monitor, displayMode}: {
|
||||
staleTime: 30000,
|
||||
});
|
||||
|
||||
// 转换时序数据为图表数据 - 保持原始 timestamp
|
||||
// 转换时序数据为图表数据 - 使用统一格点对该对齐多探针数据
|
||||
const chartData = useMemo(() => {
|
||||
if (!historyData?.series || historyData.series.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 按时间戳聚合多个探针的数据
|
||||
const timeMap = new Map<number, number[]>();
|
||||
const validSeries = historyData.series.filter(s => s.data && s.data.length > 0);
|
||||
if (validSeries.length === 0) return [];
|
||||
|
||||
historyData.series.forEach(series => {
|
||||
series.data?.forEach(point => {
|
||||
if (!timeMap.has(point.timestamp)) {
|
||||
timeMap.set(point.timestamp, []);
|
||||
// 确定全局时间范围
|
||||
let minTime = Infinity, maxTime = -Infinity;
|
||||
validSeries.forEach(s => {
|
||||
minTime = Math.min(minTime, s.data![0].timestamp);
|
||||
maxTime = Math.max(maxTime, s.data![s.data!.length - 1].timestamp);
|
||||
});
|
||||
|
||||
if (minTime >= maxTime) return [];
|
||||
|
||||
// 定义目标采集点 (1小时数据,建议 60 个采集点)
|
||||
const maxPoints = 60;
|
||||
const timeStep = (maxTime - minTime) / (maxPoints - 1);
|
||||
const targetTimestamps: number[] = [];
|
||||
for (let i = 0; i < maxPoints; i++) {
|
||||
targetTimestamps.push(minTime + i * timeStep);
|
||||
}
|
||||
timeMap.get(point.timestamp)!.push(point.value);
|
||||
});
|
||||
|
||||
// 线性插值函数
|
||||
const interpolate = (data: Array<{ timestamp: number; value: number }>, targetTime: number): number | null => {
|
||||
if (data.length === 0) return null;
|
||||
if (data.length === 1) return data[0].timestamp === targetTime ? data[0].value : null;
|
||||
if (targetTime < data[0].timestamp || targetTime > data[data.length - 1].timestamp) return null;
|
||||
|
||||
let left = 0, right = data.length - 1;
|
||||
while (right - left > 1) {
|
||||
const mid = Math.floor((left + right) / 2);
|
||||
if (data[mid].timestamp <= targetTime) left = mid;
|
||||
else right = mid;
|
||||
}
|
||||
const leftPoint = data[left];
|
||||
const rightPoint = data[right];
|
||||
const ratio = (targetTime - leftPoint.timestamp) / (rightPoint.timestamp - leftPoint.timestamp);
|
||||
return leftPoint.value + ratio * (rightPoint.value - leftPoint.value);
|
||||
};
|
||||
|
||||
// 对每个目标时间点,计算所有探针的聚合值
|
||||
return targetTimestamps.map(timestamp => {
|
||||
const values: number[] = [];
|
||||
validSeries.forEach(s => {
|
||||
const val = interpolate(s.data!, timestamp);
|
||||
if (val !== null) values.push(val);
|
||||
});
|
||||
|
||||
// 根据显示模式计算聚合值
|
||||
return Array.from(timeMap.entries()).map(([timestamp, values]) => ({
|
||||
if (values.length === 0) return { timestamp, value: 0 };
|
||||
|
||||
return {
|
||||
timestamp,
|
||||
value: displayMode === 'avg'
|
||||
? Math.round(values.reduce((a, b) => a + b, 0) / values.length)
|
||||
: Math.max(...values),
|
||||
}));
|
||||
};
|
||||
});
|
||||
}, [historyData, displayMode]);
|
||||
|
||||
const displayValue = displayMode === 'avg' ? monitor.responseTime : monitor.responseTimeMax;
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import {useEffect, useMemo, useState} from 'react';
|
||||
import {useQuery} from '@tanstack/react-query';
|
||||
import {Area, AreaChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis} from 'recharts';
|
||||
import {Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis} from 'recharts';
|
||||
import {type GetMetricsResponse, getMonitorHistory} from '@/api/monitor';
|
||||
import {AGENT_COLORS} from '@portal/constants/colors';
|
||||
import {MONITOR_TIME_RANGE_OPTIONS} from '@portal/constants/time';
|
||||
import {useIsMobile} from '@portal/hooks/use-mobile';
|
||||
import type {AgentMonitorStat} from '@/types';
|
||||
@@ -11,20 +10,112 @@ import {ChartPlaceholder} from "@portal/components/ChartPlaceholder";
|
||||
import {CustomTooltip} from "@portal/components/CustomTooltip";
|
||||
import {TimeRangeSelector} from "@portal/components/TimeRangeSelector";
|
||||
import {formatChartTime} from '@/lib/format.ts';
|
||||
import {RotateCcw, ChevronDown, ChevronUp} from 'lucide-react';
|
||||
|
||||
interface ResponseTimeChartProps {
|
||||
monitorId: string;
|
||||
monitorStats: AgentMonitorStat[];
|
||||
}
|
||||
|
||||
// 移除 LTTB 降采样,改用统一格点对齐插值以获得更好的多序列对齐效果
|
||||
|
||||
|
||||
/**
|
||||
* 根据时间范围确定最大数据点数
|
||||
*/
|
||||
const getMaxDataPoints = (timeRange: string): number => {
|
||||
switch (timeRange) {
|
||||
case '15m':
|
||||
case '1h':
|
||||
return 200; // 短时间:详细数据
|
||||
case '12h':
|
||||
return 300;
|
||||
case '24h':
|
||||
return 400;
|
||||
case '7d':
|
||||
return 500;
|
||||
case '30d':
|
||||
return 600;
|
||||
default:
|
||||
return 400;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 生成不重复的颜色
|
||||
* 使用 HSL 色轮均匀分布,支持无限数量的探针
|
||||
*/
|
||||
const generateColors = (count: number): string[] => {
|
||||
const colors: string[] = [];
|
||||
const hueStep = 360 / count;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const hue = (i * hueStep) % 360;
|
||||
const saturation = 65 + (i % 3) * 10;
|
||||
const lightness = 45 + (i % 2) * 10;
|
||||
colors.push(`hsl(${hue}, ${saturation}%, ${lightness}%)`);
|
||||
}
|
||||
|
||||
return colors;
|
||||
};
|
||||
|
||||
/**
|
||||
* 自定义图例组件
|
||||
*/
|
||||
const CustomLegend = ({ onClick, selectedAgents, allAgents, colors, collapsed }: any) => {
|
||||
if (!allAgents || allAgents.length === 0) return null;
|
||||
|
||||
if (collapsed) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap justify-center gap-4 pt-4">
|
||||
{allAgents.map((agent: { id: string; name: string }, index: number) => {
|
||||
const isSelected = selectedAgents.has(agent.id);
|
||||
const color = colors[index];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={agent.id}
|
||||
onClick={() => onClick(agent.id)}
|
||||
className="flex items-center gap-2 cursor-pointer transition-opacity"
|
||||
style={{
|
||||
opacity: isSelected ? 1 : 0.4,
|
||||
}}
|
||||
>
|
||||
<svg width="32" height="12" className="overflow-visible">
|
||||
<line
|
||||
x1="0"
|
||||
y1="6"
|
||||
x2="32"
|
||||
y2="6"
|
||||
stroke={isSelected ? color : '#9ca3af'}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
className="text-xs font-medium"
|
||||
style={{
|
||||
color: isSelected ? color : '#9ca3af',
|
||||
}}
|
||||
>
|
||||
{agent.name}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 响应时间趋势图表组件
|
||||
* 显示监控各探针的响应时间变化
|
||||
*/
|
||||
export const ResponseTimeChart = ({monitorId, monitorStats}: ResponseTimeChartProps) => {
|
||||
const [selectedAgent, setSelectedAgent] = useState<string>('all');
|
||||
const [timeRange, setTimeRange] = useState<string>('12h'); // 对应后端 60 秒步长
|
||||
const [selectedAgents, setSelectedAgents] = useState<Set<string>>(new Set());
|
||||
const [timeRange, setTimeRange] = useState<string>('12h');
|
||||
const [customRange, setCustomRange] = useState<{ start: number; end: number } | null>(null);
|
||||
const [legendCollapsed, setLegendCollapsed] = useState(true); // 移动端默认收起
|
||||
const isMobile = useIsMobile();
|
||||
const customStart = timeRange === 'custom' ? customRange?.start : undefined;
|
||||
const customEnd = timeRange === 'custom' ? customRange?.end : undefined;
|
||||
@@ -55,67 +146,158 @@ export const ResponseTimeChart = ({monitorId, monitorStats}: ResponseTimeChartPr
|
||||
}));
|
||||
}, [monitorStats]);
|
||||
|
||||
// 当可用探针列表变化时,检查当前选择的探针是否还存在
|
||||
// 初始化选中所有探针
|
||||
useEffect(() => {
|
||||
if (selectedAgent === 'all') return;
|
||||
if (!availableAgents.find(agent => agent.id === selectedAgent)) {
|
||||
setSelectedAgent('all');
|
||||
if (availableAgents.length > 0 && selectedAgents.size === 0) {
|
||||
setSelectedAgents(new Set(availableAgents.map(a => a.id)));
|
||||
}
|
||||
}, [availableAgents, selectedAgent]);
|
||||
}, [availableAgents, selectedAgents.size]);
|
||||
|
||||
// 生成图表数据
|
||||
// 动态生成颜色
|
||||
const colors = useMemo(() => {
|
||||
return generateColors(availableAgents.length);
|
||||
}, [availableAgents.length]);
|
||||
|
||||
// 点击图例切换选中状态
|
||||
const handleLegendClick = (agentId: string) => {
|
||||
const newSelected = new Set(selectedAgents);
|
||||
|
||||
// 判断是否是全选状态
|
||||
const isAllSelected = selectedAgents.size === availableAgents.length;
|
||||
|
||||
if (isAllSelected) {
|
||||
// 全选状态下,点击某个 → 只选这一个
|
||||
setSelectedAgents(new Set([agentId]));
|
||||
} else {
|
||||
// 非全选状态下,点击切换
|
||||
if (newSelected.has(agentId)) {
|
||||
newSelected.delete(agentId);
|
||||
} else {
|
||||
newSelected.add(agentId);
|
||||
}
|
||||
setSelectedAgents(newSelected);
|
||||
}
|
||||
};
|
||||
|
||||
// 点击图表线条切换选中状态
|
||||
const handleAreaClick = (data: any) => {
|
||||
if (!data || !data.dataKey) return;
|
||||
|
||||
// 从 dataKey 中提取 agentId (格式: agent_${agentId})
|
||||
const agentId = data.dataKey.replace('agent_', '');
|
||||
|
||||
const newSelected = new Set(selectedAgents);
|
||||
|
||||
// 判断是否是全选状态
|
||||
const isAllSelected = selectedAgents.size === availableAgents.length;
|
||||
|
||||
if (isAllSelected) {
|
||||
// 全选状态下,点击某个 → 只选这一个
|
||||
setSelectedAgents(new Set([agentId]));
|
||||
} else {
|
||||
// 非全选状态下,点击切换
|
||||
if (newSelected.has(agentId)) {
|
||||
newSelected.delete(agentId);
|
||||
} else {
|
||||
newSelected.add(agentId);
|
||||
}
|
||||
setSelectedAgents(newSelected);
|
||||
}
|
||||
};
|
||||
|
||||
// 恢复全选
|
||||
const handleSelectAll = () => {
|
||||
setSelectedAgents(new Set(availableAgents.map(a => a.id)));
|
||||
};
|
||||
|
||||
// 是否有探针未选中
|
||||
const hasUnselected = selectedAgents.size < availableAgents.length;
|
||||
|
||||
// 切换图例显示/隐藏(仅移动端)
|
||||
const toggleLegend = () => {
|
||||
setLegendCollapsed(!legendCollapsed);
|
||||
};
|
||||
|
||||
// 生成图表数据 - 使用统一时间网格对齐
|
||||
const chartData = useMemo(() => {
|
||||
if (!historyData?.series) return [];
|
||||
|
||||
// 过滤出响应时间指标的 series
|
||||
const responseTimeSeries = historyData.series?.filter(s => s.name === 'response_time');
|
||||
const seriesList = historyData.series.filter(s => s.name === 'response_time');
|
||||
if (seriesList.length === 0) return [];
|
||||
|
||||
// 根据选择的探针过滤(使用 agent_id)
|
||||
const filteredSeries = selectedAgent === 'all'
|
||||
? responseTimeSeries
|
||||
: responseTimeSeries.filter(s => s.labels?.agent_id === selectedAgent);
|
||||
// 收集所有选中探针的数据
|
||||
const selectedSeriesData: Array<{ key: string; data: Array<{ timestamp: number; value: number }> }> = [];
|
||||
|
||||
if (filteredSeries.length === 0) return [];
|
||||
seriesList.forEach((s) => {
|
||||
const agentId = s.labels?.agent_id || 'unknown';
|
||||
if (!selectedAgents.has(agentId)) return;
|
||||
if (!s.data || s.data.length === 0) return;
|
||||
|
||||
// 按时间戳分组数据
|
||||
const grouped: Record<number, any> = {};
|
||||
selectedSeriesData.push({
|
||||
key: `agent_${agentId}`,
|
||||
data: [...s.data].sort((a, b) => a.timestamp - b.timestamp)
|
||||
});
|
||||
});
|
||||
|
||||
filteredSeries.forEach(series => {
|
||||
// 使用 agent_id 作为标识符
|
||||
const agentId = series.labels?.agent_id || 'unknown';
|
||||
const agentKey = `agent_${agentId}`;
|
||||
if (selectedSeriesData.length === 0) return [];
|
||||
|
||||
series.data.forEach(point => {
|
||||
if (!grouped[point.timestamp]) {
|
||||
grouped[point.timestamp] = {
|
||||
timestamp: point.timestamp,
|
||||
};
|
||||
// 确定全局时间范围
|
||||
let minTime = Infinity, maxTime = -Infinity;
|
||||
selectedSeriesData.forEach(s => {
|
||||
minTime = Math.min(minTime, s.data[0].timestamp);
|
||||
maxTime = Math.max(maxTime, s.data[s.data.length - 1].timestamp);
|
||||
});
|
||||
|
||||
if (minTime >= maxTime) return [];
|
||||
|
||||
// 生成均匀的目标时间点
|
||||
const maxPoints = getMaxDataPoints(timeRange);
|
||||
const timeStep = (maxTime - minTime) / (maxPoints - 1);
|
||||
const targetTimestamps: number[] = [];
|
||||
for (let i = 0; i < maxPoints; i++) {
|
||||
targetTimestamps.push(minTime + i * timeStep);
|
||||
}
|
||||
grouped[point.timestamp][agentKey] = point.value;
|
||||
});
|
||||
});
|
||||
|
||||
// 按时间戳排序
|
||||
return Object.values(grouped).sort((a, b) => a.timestamp - b.timestamp);
|
||||
}, [historyData, selectedAgent, timeRange, customStart, customEnd]);
|
||||
// 线性插值函数
|
||||
const interpolate = (data: Array<{ timestamp: number; value: number }>, targetTime: number): number | null => {
|
||||
if (data.length === 0) return null;
|
||||
if (data.length === 1) return data[0].timestamp === targetTime ? data[0].value : null;
|
||||
|
||||
const visibleMonitorStats = useMemo(() => {
|
||||
return monitorStats.filter(stat => selectedAgent === 'all' || stat.agentId === selectedAgent);
|
||||
}, [monitorStats, selectedAgent]);
|
||||
// 超出范围不插值(防止产生虚假连线)
|
||||
if (targetTime < data[0].timestamp || targetTime > data[data.length - 1].timestamp) {
|
||||
// 如果距离最近的点足够近(比如小于两个采样间隔),可以考虑保留,否则返回null
|
||||
return null;
|
||||
}
|
||||
|
||||
// 准备移动端图例数据
|
||||
const legendItems = useMemo(() => {
|
||||
return visibleMonitorStats.map((stat) => {
|
||||
const originalIndex = monitorStats.findIndex(s => s.agentId === stat.agentId);
|
||||
const color = AGENT_COLORS[originalIndex % AGENT_COLORS.length];
|
||||
const label = stat.agentName || stat.agentId.substring(0, 8);
|
||||
return {
|
||||
key: stat.agentId,
|
||||
label,
|
||||
color,
|
||||
// 二分查找
|
||||
let left = 0, right = data.length - 1;
|
||||
while (right - left > 1) {
|
||||
const mid = Math.floor((left + right) / 2);
|
||||
if (data[mid].timestamp <= targetTime) {
|
||||
left = mid;
|
||||
} else {
|
||||
right = mid;
|
||||
}
|
||||
}
|
||||
|
||||
const leftPoint = data[left];
|
||||
const rightPoint = data[right];
|
||||
const ratio = (targetTime - leftPoint.timestamp) / (rightPoint.timestamp - leftPoint.timestamp);
|
||||
return leftPoint.value + ratio * (rightPoint.value - leftPoint.value);
|
||||
};
|
||||
|
||||
// 对齐所有探针数据
|
||||
return targetTimestamps.map(timestamp => {
|
||||
const dataPoint: any = { timestamp };
|
||||
selectedSeriesData.forEach(s => {
|
||||
const value = interpolate(s.data, timestamp);
|
||||
if (value !== null) {
|
||||
dataPoint[s.key] = Number(value.toFixed(2));
|
||||
}
|
||||
});
|
||||
}, [visibleMonitorStats, monitorStats]);
|
||||
return dataPoint;
|
||||
});
|
||||
}, [historyData, selectedAgents, timeRange, customStart, customEnd]);
|
||||
|
||||
return (
|
||||
<CyberCard className={'p-6'}>
|
||||
@@ -135,37 +317,44 @@ export const ResponseTimeChart = ({monitorId, monitorStats}: ResponseTimeChartPr
|
||||
setCustomRange(range);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 使用提示和恢复按钮 */}
|
||||
{availableAgents.length > 0 && (
|
||||
<select
|
||||
value={selectedAgent}
|
||||
onChange={(e) => setSelectedAgent(e.target.value)}
|
||||
className="rounded-lg border border-slate-200 dark:border-cyan-900/50 bg-white dark:bg-black/40 px-3 py-2 text-xs font-medium text-gray-700 dark:text-cyan-300 hover:border-slate-300 dark:hover:border-cyan-500/50 focus:border-slate-400 dark:focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-slate-200 dark:focus:ring-cyan-500/20 transition-colors font-mono"
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="text-xs text-gray-500 dark:text-cyan-600">
|
||||
💡 点击图表线条或图例切换显示
|
||||
</div>
|
||||
{hasUnselected && (
|
||||
<button
|
||||
onClick={handleSelectAll}
|
||||
className="p-1.5 rounded
|
||||
text-gray-500 dark:text-cyan-500
|
||||
hover:text-gray-700 dark:hover:text-cyan-400
|
||||
hover:bg-gray-100 dark:hover:bg-cyan-900/30
|
||||
transition-colors"
|
||||
title="恢复全选"
|
||||
>
|
||||
<option value="all">所有探针</option>
|
||||
{availableAgents.map((agent) => (
|
||||
<option key={agent.id} value={agent.id}>
|
||||
{agent.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<RotateCcw size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{chartData.length > 0 ? (
|
||||
<div>
|
||||
<ResponsiveContainer width="100%" height={360}>
|
||||
<AreaChart data={chartData}>
|
||||
<defs>
|
||||
{visibleMonitorStats.map((stat) => {
|
||||
const originalIndex = monitorStats.findIndex(s => s.agentId === stat.agentId);
|
||||
const agentKey = `agent_${stat.agentId}`;
|
||||
const color = AGENT_COLORS[originalIndex % AGENT_COLORS.length];
|
||||
{Array.from(selectedAgents).map((agentId, index) => {
|
||||
const originalIndex = availableAgents.findIndex(a => a.id === agentId);
|
||||
const agentKey = `agent_${agentId}`;
|
||||
return (
|
||||
<linearGradient key={agentKey} id={`gradient_${agentKey}`} x1="0" y1="0"
|
||||
x2="0" y2="1">
|
||||
<stop offset="5%" stopColor={color} stopOpacity={0.3}/>
|
||||
<stop offset="95%" stopColor={color} stopOpacity={0}/>
|
||||
<stop offset="5%" stopColor={colors[originalIndex]} stopOpacity={0.3}/>
|
||||
<stop offset="95%" stopColor={colors[originalIndex]} stopOpacity={0}/>
|
||||
</linearGradient>
|
||||
);
|
||||
})}
|
||||
@@ -199,33 +388,59 @@ export const ResponseTimeChart = ({monitorId, monitorStats}: ResponseTimeChartPr
|
||||
content={<CustomTooltip unit={'ms'}/>}
|
||||
wrapperStyle={{zIndex: 9999}}
|
||||
/>
|
||||
{!isMobile && (
|
||||
<Legend
|
||||
wrapperStyle={{paddingTop: '20px', zIndex: 1}}
|
||||
iconType="circle"
|
||||
/>
|
||||
)}
|
||||
{visibleMonitorStats.map((stat) => {
|
||||
const originalIndex = monitorStats.findIndex(s => s.agentId === stat.agentId);
|
||||
const agentKey = `agent_${stat.agentId}`;
|
||||
const color = AGENT_COLORS[originalIndex % AGENT_COLORS.length];
|
||||
const agentLabel = stat.agentName || stat.agentId.substring(0, 8);
|
||||
{Array.from(selectedAgents).map((agentId) => {
|
||||
const originalIndex = availableAgents.findIndex(a => a.id === agentId);
|
||||
const agentKey = `agent_${agentId}`;
|
||||
const agent = availableAgents.find(a => a.id === agentId);
|
||||
return (
|
||||
<Area
|
||||
key={agentKey}
|
||||
type="monotone"
|
||||
dataKey={agentKey}
|
||||
name={agentLabel}
|
||||
stroke={color}
|
||||
name={agent?.name || agentId.substring(0, 8)}
|
||||
stroke={colors[originalIndex]}
|
||||
strokeWidth={2}
|
||||
fill={`url(#gradient_${agentKey})`}
|
||||
activeDot={{r: 5, strokeWidth: 0}}
|
||||
dot={false}
|
||||
connectNulls
|
||||
onClick={handleAreaClick}
|
||||
style={{cursor: 'pointer'}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
|
||||
{/* 桌面端:直接显示图例 */}
|
||||
{!isMobile && availableAgents.length > 0 && (
|
||||
<CustomLegend
|
||||
onClick={handleLegendClick}
|
||||
selectedAgents={selectedAgents}
|
||||
allAgents={availableAgents}
|
||||
colors={colors}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 移动端:可折叠图例 */}
|
||||
{isMobile && availableAgents.length > 0 && (
|
||||
<div className="pt-4">
|
||||
<button
|
||||
onClick={toggleLegend}
|
||||
className="w-full flex items-center justify-center gap-2 py-2 text-xs text-gray-600 dark:text-cyan-400 hover:text-gray-900 dark:hover:text-cyan-300"
|
||||
>
|
||||
<span>{legendCollapsed ? '显示图例' : '收起图例'}</span>
|
||||
{legendCollapsed ? <ChevronDown size={16} /> : <ChevronUp size={16} />}
|
||||
</button>
|
||||
<CustomLegend
|
||||
onClick={handleLegendClick}
|
||||
selectedAgents={selectedAgents}
|
||||
allAgents={availableAgents}
|
||||
colors={colors}
|
||||
collapsed={legendCollapsed}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<ChartPlaceholder
|
||||
|
||||
@@ -86,6 +86,7 @@ export const CpuChart = ({agentId, timeRange, start, end}: CpuChartProps) => {
|
||||
strokeWidth={2}
|
||||
fill="url(#cpuAreaGradient)"
|
||||
activeDot={{r: 3}}
|
||||
connectNulls
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
|
||||
@@ -109,6 +109,7 @@ export const DiskIOChart = ({agentId, timeRange, start, end}: DiskIOChartProps)
|
||||
strokeWidth={2}
|
||||
fill="url(#colorDiskRead)"
|
||||
activeDot={{r: 3}}
|
||||
connectNulls
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
@@ -118,6 +119,7 @@ export const DiskIOChart = ({agentId, timeRange, start, end}: DiskIOChartProps)
|
||||
strokeWidth={2}
|
||||
fill="url(#colorDiskWrite)"
|
||||
activeDot={{r: 3}}
|
||||
connectNulls
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
|
||||
@@ -113,6 +113,7 @@ export const GpuChart = ({agentId, timeRange, start, end}: GpuChartProps) => {
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{r: 3}}
|
||||
connectNulls
|
||||
/>
|
||||
<Line
|
||||
yAxisId="right"
|
||||
@@ -123,6 +124,7 @@ export const GpuChart = ({agentId, timeRange, start, end}: GpuChartProps) => {
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{r: 3}}
|
||||
connectNulls
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
|
||||
@@ -86,6 +86,7 @@ export const MemoryChart = ({agentId, timeRange, start, end}: MemoryChartProps)
|
||||
strokeWidth={2}
|
||||
fill="url(#memoryAreaGradient)"
|
||||
activeDot={{r: 3}}
|
||||
connectNulls
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {useMemo} from 'react';
|
||||
import {Activity} from 'lucide-react';
|
||||
import {Area, AreaChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis} from 'recharts';
|
||||
import {useEffect, useMemo, useState} from 'react';
|
||||
import {Activity, RotateCcw, ChevronDown, ChevronUp} from 'lucide-react';
|
||||
import {Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis} from 'recharts';
|
||||
import {ChartPlaceholder} from '@portal/components/ChartPlaceholder';
|
||||
import {CustomTooltip} from '@portal/components/CustomTooltip';
|
||||
import {useMetricsQuery} from '@portal/hooks/server';
|
||||
@@ -15,12 +15,150 @@ interface MonitorChartProps {
|
||||
end?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 降采样算法 - 使用LTTB (Largest Triangle Three Buckets)
|
||||
* 确保输出精确的maxPoints个点,保留关键特征
|
||||
*/
|
||||
const downsampleData = (data: any[], maxPoints: number): any[] => {
|
||||
// 边界检查
|
||||
if (!data || data.length === 0) return [];
|
||||
if (maxPoints < 2) maxPoints = 2;
|
||||
if (data.length <= maxPoints) return [...data];
|
||||
|
||||
const result: any[] = [data[0]]; // 保留第一个点
|
||||
|
||||
// 桶大小
|
||||
const bucketSize = (data.length - 2) / (maxPoints - 2);
|
||||
|
||||
for (let i = 0; i < maxPoints - 2; i++) {
|
||||
// 计算当前桶的范围
|
||||
const start = Math.floor((i + 0) * bucketSize) + 1;
|
||||
const end = Math.floor((i + 1) * bucketSize) + 1;
|
||||
|
||||
// 计算前一个点和后一个点
|
||||
const previousPoint = result[result.length - 1];
|
||||
const nextPoint = data[Math.min(end, data.length - 1)];
|
||||
|
||||
// 在桶中选择与前后点形成的三角形面积最大的点
|
||||
let maxArea = -1;
|
||||
let selectedPoint = data[start];
|
||||
|
||||
for (let j = start; j < end && j < data.length - 1; j++) {
|
||||
// 计算三角形面积
|
||||
const area = Math.abs(
|
||||
(previousPoint.timestamp - nextPoint.timestamp) * (data[j].value - previousPoint.value) -
|
||||
(previousPoint.timestamp - data[j].timestamp) * (nextPoint.value - previousPoint.value)
|
||||
);
|
||||
|
||||
if (area > maxArea) {
|
||||
maxArea = area;
|
||||
selectedPoint = data[j];
|
||||
}
|
||||
}
|
||||
|
||||
result.push(selectedPoint);
|
||||
}
|
||||
|
||||
result.push(data[data.length - 1]); // 保留最后一个点
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据时间范围确定最大数据点数
|
||||
*/
|
||||
const getMaxDataPoints = (timeRange: string): number => {
|
||||
switch (timeRange) {
|
||||
case '15m':
|
||||
case '1h':
|
||||
return 200; // 短时间:详细数据
|
||||
case '12h':
|
||||
return 300;
|
||||
case '24h':
|
||||
return 400;
|
||||
case '7d':
|
||||
return 500;
|
||||
case '30d':
|
||||
return 600;
|
||||
default:
|
||||
return 400;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 生成不重复的颜色
|
||||
* 使用 HSL 色轮均匀分布,支持无限数量的监控项
|
||||
*/
|
||||
const generateColors = (count: number): string[] => {
|
||||
const colors: string[] = [];
|
||||
const hueStep = 360 / count; // 色相间隔
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const hue = (i * hueStep) % 360;
|
||||
const saturation = 65 + (i % 3) * 10; // 65%, 75%, 85% 循环
|
||||
const lightness = 45 + (i % 2) * 10; // 45%, 55% 循环
|
||||
colors.push(`hsl(${hue}, ${saturation}%, ${lightness}%)`);
|
||||
}
|
||||
|
||||
return colors;
|
||||
};
|
||||
|
||||
/**
|
||||
* 自定义图例组件
|
||||
*/
|
||||
const CustomLegend = ({ onClick, selectedMonitors, allMonitorKeys, colors, collapsed }: any) => {
|
||||
if (!allMonitorKeys || allMonitorKeys.length === 0) return null;
|
||||
|
||||
if (collapsed) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap justify-center gap-4 pt-4">
|
||||
{allMonitorKeys.map((monitorKey: string, index: number) => {
|
||||
const isSelected = selectedMonitors.has(monitorKey);
|
||||
const color = colors[index];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={monitorKey}
|
||||
onClick={() => onClick({ value: monitorKey })}
|
||||
className="flex items-center gap-2 cursor-pointer transition-opacity"
|
||||
style={{
|
||||
opacity: isSelected ? 1 : 0.4,
|
||||
}}
|
||||
>
|
||||
<svg width="32" height="12" className="overflow-visible">
|
||||
<line
|
||||
x1="0"
|
||||
y1="6"
|
||||
x2="32"
|
||||
y2="6"
|
||||
stroke={isSelected ? color : '#9ca3af'}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
className="text-xs font-medium"
|
||||
style={{
|
||||
color: isSelected ? color : '#9ca3af',
|
||||
}}
|
||||
>
|
||||
{monitorKey}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 监控响应时间图表组件
|
||||
*/
|
||||
export const MonitorChart = ({agentId, timeRange, start, end}: MonitorChartProps) => {
|
||||
const isMobile = useIsMobile();
|
||||
const rangeMs = start !== undefined && end !== undefined ? end - start : undefined;
|
||||
const [selectedMonitors, setSelectedMonitors] = useState<Set<string>>(new Set());
|
||||
const [legendCollapsed, setLegendCollapsed] = useState(true); // 移动端默认收起
|
||||
|
||||
// 数据查询
|
||||
const {data: metricsResponse, isLoading} = useMetricsQuery({
|
||||
@@ -31,41 +169,172 @@ export const MonitorChart = ({agentId, timeRange, start, end}: MonitorChartProps
|
||||
end,
|
||||
});
|
||||
|
||||
// 数据转换 - 支持多个监控任务
|
||||
const chartData = useMemo(() => {
|
||||
const series = metricsResponse?.data.series || [];
|
||||
if (series.length === 0) return [];
|
||||
|
||||
// 收集所有时间戳
|
||||
const timestampMap = new Map<number, any>();
|
||||
|
||||
series.forEach((s) => {
|
||||
s.data.forEach((point) => {
|
||||
if (!timestampMap.has(point.timestamp)) {
|
||||
timestampMap.set(point.timestamp, {
|
||||
timestamp: point.timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
const dataPoint = timestampMap.get(point.timestamp);
|
||||
// 使用 monitor_name 作为 key,如果没有则使用 monitor_id
|
||||
const monitorKey = s.labels?.monitor_name || s.labels?.monitor_id || s.name;
|
||||
dataPoint[monitorKey] = Number(point.value.toFixed(2));
|
||||
});
|
||||
});
|
||||
|
||||
// 转换为数组并排序
|
||||
return Array.from(timestampMap.values()).sort((a, b) => a.timestamp - b.timestamp);
|
||||
}, [metricsResponse, timeRange, start, end]);
|
||||
|
||||
// 获取所有监控任务的列表(使用名称)
|
||||
const monitorKeys = useMemo(() => {
|
||||
const allMonitorKeys = useMemo(() => {
|
||||
const series = metricsResponse?.data.series || [];
|
||||
return series.map(s => s.labels?.monitor_name || s.labels?.monitor_id || s.name);
|
||||
}, [metricsResponse]);
|
||||
|
||||
// 颜色配置
|
||||
const colors = ['#2563eb', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899'];
|
||||
// 初始化选中所有监控任务
|
||||
useEffect(() => {
|
||||
if (allMonitorKeys.length > 0 && selectedMonitors.size === 0) {
|
||||
setSelectedMonitors(new Set(allMonitorKeys));
|
||||
}
|
||||
}, [allMonitorKeys, selectedMonitors.size]);
|
||||
|
||||
// 过滤后的监控任务列表
|
||||
const monitorKeys = useMemo(() => {
|
||||
return allMonitorKeys.filter(key => selectedMonitors.has(key));
|
||||
}, [allMonitorKeys, selectedMonitors]);
|
||||
|
||||
// 数据转换 - 支持多个监控任务(统一时间轴 + 线性插值)
|
||||
const chartData = useMemo(() => {
|
||||
const series = metricsResponse?.data.series || [];
|
||||
if (series.length === 0) return [];
|
||||
|
||||
// 收集所有监控任务的数据
|
||||
const seriesDataArray: Array<{ key: string; data: Array<{ timestamp: number; value: number }> }> = [];
|
||||
|
||||
series.forEach((s) => {
|
||||
const monitorKey = s.labels?.monitor_name || s.labels?.monitor_id || s.name;
|
||||
if (!selectedMonitors.has(monitorKey)) return;
|
||||
if (!s.data || s.data.length === 0) return;
|
||||
|
||||
seriesDataArray.push({
|
||||
key: monitorKey,
|
||||
data: [...s.data].sort((a, b) => a.timestamp - b.timestamp)
|
||||
});
|
||||
});
|
||||
|
||||
if (seriesDataArray.length === 0) return [];
|
||||
|
||||
// 取所有监控任务时间范围的交集,确保每个时间点所有任务都有数据
|
||||
let minTime = -Infinity, maxTime = Infinity;
|
||||
seriesDataArray.forEach(s => {
|
||||
if (s.data.length > 0) {
|
||||
minTime = Math.max(minTime, s.data[0].timestamp);
|
||||
maxTime = Math.min(maxTime, s.data[s.data.length - 1].timestamp);
|
||||
}
|
||||
});
|
||||
|
||||
// 如果没有交集,返回空数组
|
||||
if (minTime >= maxTime) return [];
|
||||
|
||||
// 均匀生成目标时间点
|
||||
const maxPoints = getMaxDataPoints(timeRange);
|
||||
const timeStep = (maxTime - minTime) / (maxPoints - 1);
|
||||
const targetTimestamps: number[] = [];
|
||||
for (let i = 0; i < maxPoints; i++) {
|
||||
targetTimestamps.push(minTime + i * timeStep);
|
||||
}
|
||||
|
||||
// 线性插值函数
|
||||
const interpolate = (data: Array<{ timestamp: number; value: number }>, targetTime: number): number | null => {
|
||||
if (data.length === 0) return null;
|
||||
if (data.length === 1) {
|
||||
// 单点数据,只有精确匹配才返回
|
||||
return data[0].timestamp === targetTime ? data[0].value : null;
|
||||
}
|
||||
|
||||
// 如果目标时间在数据范围外,返回 null(断开折线)
|
||||
if (targetTime < data[0].timestamp || targetTime > data[data.length - 1].timestamp) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 二分查找找到 targetTime 前后两个点
|
||||
let left = 0, right = data.length - 1;
|
||||
while (right - left > 1) {
|
||||
const mid = Math.floor((left + right) / 2);
|
||||
if (data[mid].timestamp <= targetTime) {
|
||||
left = mid;
|
||||
} else {
|
||||
right = mid;
|
||||
}
|
||||
}
|
||||
|
||||
// 线性插值
|
||||
const leftPoint = data[left];
|
||||
const rightPoint = data[right];
|
||||
const ratio = (targetTime - leftPoint.timestamp) / (rightPoint.timestamp - leftPoint.timestamp);
|
||||
return leftPoint.value + ratio * (rightPoint.value - leftPoint.value);
|
||||
};
|
||||
|
||||
// 对每个时间点,从每个监控任务中插值获取值
|
||||
return targetTimestamps.map(timestamp => {
|
||||
const dataPoint: any = { timestamp };
|
||||
seriesDataArray.forEach(s => {
|
||||
const value = interpolate(s.data, timestamp);
|
||||
if (value !== null) {
|
||||
dataPoint[s.key] = Number(value.toFixed(2));
|
||||
}
|
||||
});
|
||||
return dataPoint;
|
||||
});
|
||||
}, [metricsResponse, selectedMonitors, timeRange, start, end]);
|
||||
|
||||
// 动态生成颜色(根据监控项数量)
|
||||
const colors = useMemo(() => {
|
||||
return generateColors(allMonitorKeys.length);
|
||||
}, [allMonitorKeys.length]);
|
||||
|
||||
// 点击图表区域切换选中状态
|
||||
const handleAreaClick = (data: any) => {
|
||||
if (!data || !data.dataKey) return;
|
||||
|
||||
const monitorKey = data.dataKey;
|
||||
const newSelected = new Set(selectedMonitors);
|
||||
|
||||
// 判断是否是全选状态
|
||||
const isAllSelected = selectedMonitors.size === allMonitorKeys.length;
|
||||
|
||||
if (isAllSelected) {
|
||||
// 全选状态下,点击某个 → 只选这一个
|
||||
setSelectedMonitors(new Set([monitorKey]));
|
||||
} else {
|
||||
// 非全选状态下,点击切换
|
||||
if (newSelected.has(monitorKey)) {
|
||||
newSelected.delete(monitorKey);
|
||||
} else {
|
||||
newSelected.add(monitorKey);
|
||||
}
|
||||
setSelectedMonitors(newSelected);
|
||||
}
|
||||
};
|
||||
|
||||
// 点击图例切换选中状态
|
||||
const handleLegendClick = (data: any) => {
|
||||
const monitorKey = data.value;
|
||||
const newSelected = new Set(selectedMonitors);
|
||||
|
||||
// 判断是否是全选状态
|
||||
const isAllSelected = selectedMonitors.size === allMonitorKeys.length;
|
||||
|
||||
if (isAllSelected) {
|
||||
// 全选状态下,点击某个 → 只选这一个
|
||||
setSelectedMonitors(new Set([monitorKey]));
|
||||
} else {
|
||||
// 非全选状态下,点击切换
|
||||
if (newSelected.has(monitorKey)) {
|
||||
newSelected.delete(monitorKey);
|
||||
} else {
|
||||
newSelected.add(monitorKey);
|
||||
}
|
||||
setSelectedMonitors(newSelected);
|
||||
}
|
||||
};
|
||||
|
||||
// 恢复全选
|
||||
const handleSelectAll = () => {
|
||||
setSelectedMonitors(new Set(allMonitorKeys));
|
||||
};
|
||||
|
||||
// 是否有监控项未选中(用于显示恢复按钮)
|
||||
const hasUnselected = selectedMonitors.size < allMonitorKeys.length;
|
||||
|
||||
// 切换图例显示/隐藏(仅移动端)
|
||||
const toggleLegend = () => {
|
||||
setLegendCollapsed(!legendCollapsed);
|
||||
};
|
||||
|
||||
// 如果没有数据且不是加载中,不渲染组件
|
||||
if (!isLoading && chartData.length === 0) {
|
||||
@@ -85,16 +354,41 @@ export const MonitorChart = ({agentId, timeRange, start, end}: MonitorChartProps
|
||||
<ChartContainer title="监控响应时间" icon={Activity}>
|
||||
{chartData.length > 0 ? (
|
||||
<>
|
||||
{/* 使用提示和恢复按钮 */}
|
||||
{allMonitorKeys.length > 1 && (
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="text-xs text-gray-500 dark:text-cyan-600">
|
||||
💡 点击图表线条或图例切换显示
|
||||
</div>
|
||||
{hasUnselected && (
|
||||
<button
|
||||
onClick={handleSelectAll}
|
||||
className="p-1.5 rounded
|
||||
text-gray-500 dark:text-cyan-500
|
||||
hover:text-gray-700 dark:hover:text-cyan-400
|
||||
hover:bg-gray-100 dark:hover:bg-cyan-900/30
|
||||
transition-colors"
|
||||
title="恢复全选"
|
||||
>
|
||||
<RotateCcw size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<AreaChart data={chartData}>
|
||||
<defs>
|
||||
{monitorKeys.map((key, index) => (
|
||||
{monitorKeys.map((key, index) => {
|
||||
const originalIndex = allMonitorKeys.indexOf(key);
|
||||
return (
|
||||
<linearGradient key={key} id={`monitorAreaGradient-${index}`} x1="0" y1="0" x2="0"
|
||||
y2="1">
|
||||
<stop offset="5%" stopColor={colors[index % colors.length]} stopOpacity={0.4}/>
|
||||
<stop offset="95%" stopColor={colors[index % colors.length]} stopOpacity={0}/>
|
||||
<stop offset="5%" stopColor={colors[originalIndex]} stopOpacity={0.4}/>
|
||||
<stop offset="95%" stopColor={colors[originalIndex]} stopOpacity={0}/>
|
||||
</linearGradient>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</defs>
|
||||
<CartesianGrid stroke="currentColor" strokeDasharray="4 4"
|
||||
className="stroke-slate-200 dark:stroke-cyan-900/30"/>
|
||||
@@ -119,26 +413,56 @@ export const MonitorChart = ({agentId, timeRange, start, end}: MonitorChartProps
|
||||
content={<CustomTooltip unit="ms"/>}
|
||||
wrapperStyle={{zIndex: 9999,}}
|
||||
/>
|
||||
{!isMobile && monitorKeys.length > 1 && (
|
||||
<Legend
|
||||
wrapperStyle={{fontSize: '12px'}}
|
||||
iconType="line"
|
||||
/>
|
||||
)}
|
||||
{monitorKeys.map((key, index) => (
|
||||
{monitorKeys.map((key, index) => {
|
||||
const originalIndex = allMonitorKeys.indexOf(key);
|
||||
return (
|
||||
<Area
|
||||
key={key}
|
||||
type="monotone"
|
||||
dataKey={key}
|
||||
name={key}
|
||||
stroke={colors[index % colors.length]}
|
||||
stroke={colors[originalIndex]}
|
||||
strokeWidth={2}
|
||||
fill={`url(#monitorAreaGradient-${index})`}
|
||||
activeDot={{r: 3}}
|
||||
connectNulls
|
||||
onClick={handleAreaClick}
|
||||
style={{cursor: 'pointer'}}
|
||||
/>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
|
||||
{/* 桌面端:直接显示图例 */}
|
||||
{!isMobile && allMonitorKeys.length > 0 && (
|
||||
<CustomLegend
|
||||
onClick={handleLegendClick}
|
||||
selectedMonitors={selectedMonitors}
|
||||
allMonitorKeys={allMonitorKeys}
|
||||
colors={colors}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 移动端:可折叠图例 */}
|
||||
{isMobile && allMonitorKeys.length > 0 && (
|
||||
<div className="pt-4">
|
||||
<button
|
||||
onClick={toggleLegend}
|
||||
className="w-full flex items-center justify-center gap-2 py-2 text-xs text-gray-600 dark:text-cyan-400 hover:text-gray-900 dark:hover:text-cyan-300"
|
||||
>
|
||||
<span>{legendCollapsed ? '显示图例' : '收起图例'}</span>
|
||||
{legendCollapsed ? <ChevronDown size={16} /> : <ChevronUp size={16} />}
|
||||
</button>
|
||||
<CustomLegend
|
||||
onClick={handleLegendClick}
|
||||
selectedMonitors={selectedMonitors}
|
||||
allMonitorKeys={allMonitorKeys}
|
||||
colors={colors}
|
||||
collapsed={legendCollapsed}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<ChartPlaceholder/>
|
||||
|
||||
@@ -141,6 +141,7 @@ export const NetworkChart = ({agentId, timeRange, start, end}: NetworkChartProps
|
||||
strokeWidth={2}
|
||||
fill="url(#color-upload)"
|
||||
activeDot={{r: 3}}
|
||||
connectNulls
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
@@ -150,6 +151,7 @@ export const NetworkChart = ({agentId, timeRange, start, end}: NetworkChartProps
|
||||
strokeWidth={2}
|
||||
fill="url(#color-download)"
|
||||
activeDot={{r: 3}}
|
||||
connectNulls
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
|
||||
@@ -98,6 +98,7 @@ export const NetworkConnectionChart = ({agentId, timeRange, start, end}: Network
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{r: 3}}
|
||||
connectNulls
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
@@ -107,6 +108,7 @@ export const NetworkConnectionChart = ({agentId, timeRange, start, end}: Network
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{r: 3}}
|
||||
connectNulls
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
@@ -116,6 +118,7 @@ export const NetworkConnectionChart = ({agentId, timeRange, start, end}: Network
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{r: 3}}
|
||||
connectNulls
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
@@ -125,6 +128,7 @@ export const NetworkConnectionChart = ({agentId, timeRange, start, end}: Network
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{r: 3}}
|
||||
connectNulls
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
|
||||
@@ -105,10 +105,35 @@ const ServerList = () => {
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
// 计算统计数据
|
||||
// 计算所有标签(包括ALL和ONLINE/OFFLINE)
|
||||
const allTags = useMemo(() => {
|
||||
const tags = ['ALL', 'ONLINE', 'OFFLINE'];
|
||||
if (tagsData && tagsData.length > 0) {
|
||||
tagsData.forEach((tag: string) => {
|
||||
if (!tags.includes(tag.toUpperCase())) {
|
||||
tags.push(tag.toUpperCase());
|
||||
}
|
||||
});
|
||||
}
|
||||
return tags;
|
||||
}, [tagsData]);
|
||||
|
||||
// 过滤逻辑
|
||||
const displayAgents = useMemo(() => {
|
||||
if (selectedTag === 'ONLINE') {
|
||||
return agents.filter(a => a.status === 1);
|
||||
} else if (selectedTag === 'OFFLINE') {
|
||||
return agents.filter(a => a.status !== 1);
|
||||
} else if (selectedTag && selectedTag !== 'ALL') {
|
||||
return agents.filter(a => a.tags?.map(t => t.toUpperCase()).includes(selectedTag));
|
||||
}
|
||||
return agents;
|
||||
}, [agents, selectedTag]);
|
||||
|
||||
// 计算统计数据(基于过滤后的 displayAgents)
|
||||
const stats = useMemo(() => {
|
||||
const total = agents.length;
|
||||
const online = agents?.filter(a => a.status === 1).length;
|
||||
const total = displayAgents.length;
|
||||
const online = displayAgents.filter(a => a.status === 1).length;
|
||||
const offline = total - online;
|
||||
|
||||
// 计算网络统计
|
||||
@@ -117,7 +142,7 @@ const ServerList = () => {
|
||||
let totalUploadTotal = 0;
|
||||
let totalDownloadTotal = 0;
|
||||
|
||||
agents.forEach(agent => {
|
||||
displayAgents.forEach(agent => {
|
||||
if (agent.status === 1 && agent.metrics?.network) {
|
||||
totalUploadRate += agent.metrics.network.totalBytesSentRate || 0;
|
||||
totalDownloadRate += agent.metrics.network.totalBytesRecvRate || 0;
|
||||
@@ -135,7 +160,7 @@ const ServerList = () => {
|
||||
uploadTotal: totalUploadTotal,
|
||||
downloadTotal: totalDownloadTotal
|
||||
};
|
||||
}, [agents]);
|
||||
}, [displayAgents]);
|
||||
|
||||
const handleNavigate = (agentId: string) => {
|
||||
navigate(`/servers/${agentId}`);
|
||||
@@ -145,26 +170,6 @@ const ServerList = () => {
|
||||
return <LoadingSpinner/>;
|
||||
}
|
||||
|
||||
// 计算所有标签(包括ALL和ONLINE/OFFLINE)
|
||||
const allTags = ['ALL', 'ONLINE', 'OFFLINE'];
|
||||
if (tagsData && tagsData.length > 0) {
|
||||
tagsData.forEach((tag: string) => {
|
||||
if (!allTags.includes(tag.toUpperCase())) {
|
||||
allTags.push(tag.toUpperCase());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 过滤逻辑
|
||||
let displayAgents = agents;
|
||||
if (selectedTag === 'ONLINE') {
|
||||
displayAgents = agents?.filter(a => a.status === 1);
|
||||
} else if (selectedTag === 'OFFLINE') {
|
||||
displayAgents = agents?.filter(a => a.status !== 1);
|
||||
} else if (selectedTag && selectedTag !== 'ALL') {
|
||||
displayAgents = agents?.filter(a => a.tags?.map(t => t.toUpperCase()).includes(selectedTag));
|
||||
}
|
||||
|
||||
// debug
|
||||
// displayAgents = Array.from({length:10}, ()=>displayAgents).flat();
|
||||
|
||||
|
||||
Reference in new issue
Block a user