Merge pull request #79 from yumusb/main

优化监控展示、告警服务和前端交互
This commit is contained in:
dushixiang authored and GitHub committed 2026-04-10 16:18:25 +08:00
commit ed828a83d3
39 files changed
+1636 -432

No files matched your search

+119
View File
@@ -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
+2 -2
View File
@@ -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: 列出编译产物
+24 -20
View File
@@ -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:
+1
View File
@@ -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)
// 流量管理(管理员访问)
+18 -9
View File
@@ -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 {
+2 -6
View File
@@ -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{})
}
+1 -1
View File
@@ -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 = "" // 隐藏目标地址
}
+13 -33
View File
@@ -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{})
}
+13 -30
View File
@@ -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{})
+7 -16
View File
@@ -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{})
}
+3
View File
@@ -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"` // 更新时间(时间戳毫秒)
}
+26 -3
View File
@@ -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 根据认证状态列出探针(已登录返回全部,未登录返回公开可见)
+93 -31
View File
@@ -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
}
// 发送通知
+128 -4
View File
@@ -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))
+4 -4
View File
@@ -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
+179
View File
@@ -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
View File
@@ -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)
+4 -4
View File
@@ -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)
}
}
+3 -3
View File
@@ -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
+2
View File
@@ -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>
+13 -11
View File
@@ -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,
+5 -6
View File
@@ -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>
);
};
+6 -4
View File
@@ -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}
+6 -4
View File
@@ -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}
+7 -3
View File
@@ -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}
+9
View File
@@ -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>
+370 -46
View File
@@ -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>
+30 -25
View File
@@ -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();