From fd452e56d8d6479346c1ae7397889355438a6db0 Mon Sep 17 00:00:00 2001 From: yumusb <43062104+yumusb@users.noreply.github.com> Date: Wed, 8 Apr 2026 20:24:03 +0800 Subject: [PATCH 1/9] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E9=95=9C=E5=83=8F=E3=80=81=E5=A2=9E=E5=8A=A0=E5=88=A0=E9=99=A4?= =?UTF-8?q?=E6=AE=8B=E7=95=99=E6=95=B0=E6=8D=AE=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build-test-ghcr.yml | 117 +++++++++++++++++++++++ internal/app.go | 1 + internal/handler/agent_admin_handler.go | 26 ++++- internal/service/agent_service.go | 10 +- internal/service/metric_service.go | 93 ++++++++++++++++++ web/src/admin/pages/Agents/AgentList.tsx | 58 ++++++++++- web/src/api/agent.ts | 9 ++ 7 files changed, 306 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/build-test-ghcr.yml diff --git a/.github/workflows/build-test-ghcr.yml b/.github/workflows/build-test-ghcr.yml new file mode 100644 index 0000000..6290561 --- /dev/null +++ b/.github/workflows/build-test-ghcr.yml @@ -0,0 +1,117 @@ +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-release + + # 验证 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 \ No newline at end of file diff --git a/internal/app.go b/internal/app.go index ea42054..4fce73b 100644 --- a/internal/app.go +++ b/internal/app.go @@ -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) // 流量管理(管理员访问) diff --git a/internal/handler/agent_admin_handler.go b/internal/handler/agent_admin_handler.go index 855fa02..25df1ab 100644 --- a/internal/handler/agent_admin_handler.go +++ b/internal/handler/agent_admin_handler.go @@ -229,13 +229,13 @@ 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 数据失败", + // 删除探针及其所有相关数据 + if err := h.agentService.DeleteAgent(ctx, agentID); err != nil { + h.logger.Error("删除探针失败", zap.String("agentID", agentID), zap.String("name", agent.Name), zap.Error(err)) - return orz.NewError(500, "删除探针指标数据失败") + return err } // 删除探针及其所有相关数据 @@ -254,6 +254,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 { diff --git a/internal/service/agent_service.go b/internal/service/agent_service.go index 98d8e4a..f866724 100644 --- a/internal/service/agent_service.go +++ b/internal/service/agent_service.go @@ -376,7 +376,15 @@ func (s *AgentService) DeleteAgent(ctx context.Context, agentID string) error { return err } - // 4. 最后删除探针本身 + // 4. 清理 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)) + // 记录错误但不停止删除流程 + } + } + + // 5. 最后删除探针本身 if err := s.AgentRepo.DeleteById(ctx, agentID); err != nil { s.logger.Error("删除探针失败", zap.String("agentId", agentID), zap.Error(err)) return err diff --git a/internal/service/metric_service.go b/internal/service/metric_service.go index a403cc6..14850df 100644 --- a/internal/service/metric_service.go +++ b/internal/service/metric_service.go @@ -48,6 +48,99 @@ 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 { + 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 { diff --git a/web/src/admin/pages/Agents/AgentList.tsx b/web/src/admin/pages/Agents/AgentList.tsx index 696503a..2ebdae8 100644 --- a/web/src/admin/pages/Agents/AgentList.tsx +++ b/web/src/admin/pages/Agents/AgentList.tsx @@ -1,12 +1,12 @@ 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 {Edit, Eye, EyeOff, FileWarning, Lock, MoreVertical, Plus, RefreshCw, Shield, Tags, Trash2, Wrench} from 'lucide-react'; import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'; import dayjs from 'dayjs'; -import {deleteAgent, getTags, listAgentsByAdmin} from '@/api/agent.ts'; +import {cleanupOrphanedAgentMetrics, deleteAgent, getTags, listAgentsByAdmin} from '@/api/agent.ts'; import type {Agent} from '@/types'; import {getErrorMessage} from '@/lib/utils'; import {PageHeader} from '@admin/components'; @@ -30,6 +30,47 @@ const AgentList = () => { const [batchVisibilityModalVisible, setBatchVisibilityModalVisible] = useState(false); const [editingAgentId, setEditingAgentId] = useState(undefined); + // 清理遗留指标数据的 mutation + const cleanupMutation = useMutation({ + mutationFn: cleanupOrphanedAgentMetrics, + onSuccess: (response) => { + messageApi.success(response.data.message || '成功清理残留探针指标数据'); + // 清理完成后,延时刷新页面显示最新状态(清理操作不影响探针列表本身) + setTimeout(() => { + queryClient.invalidateQueries({queryKey: ['admin', 'agents']}); + }, 2000); // 延时 2 秒后刷新,让用户能看到清理结果 + }, + onError: (error: unknown) => { + messageApi.error(getErrorMessage(error, '清理残留探针指标数据失败')); + }, + }); + + // 清理遗留指标数据 + const handleCleanupMetrics = () => { + modal.confirm({ + title: '清理残留探针指标数据', + content: ( +
+

确定要清理已删除探针的残留指标数据吗?

+

+ 此操作将扫描数据库和VictoriaMetrics中的探针数据,清理已经删除探针的残留指标数据 +

+
+ ), + okText: '确认清理', + cancelText: '取消', + okButtonProps: {style: {background: '#16a34a'}}, // 绿色按钮提示非危险操作 + centered: true, + onOk: async () => { + try { + await cleanupMutation.mutateAsync(); + } catch (e) { + // 错误已经在 mutation 中处理 + } + }, + }); + }; + // 过滤条件 const [keyword, setKeyword] = useState(''); const [status, setStatus] = useState(undefined); @@ -495,6 +536,17 @@ const AgentList = () => { ]} /> + +
diff --git a/web/src/api/agent.ts b/web/src/api/agent.ts index 3f4f394..3ded377 100644 --- a/web/src/api/agent.ts +++ b/web/src/api/agent.ts @@ -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('/admin/agents/cleanup-metrics', {}); +}; From 3919340d85a70b0c57decd856a266e1e0fd8092e Mon Sep 17 00:00:00 2001 From: yumusb <43062104+yumusb@users.noreply.github.com> Date: Wed, 8 Apr 2026 21:15:31 +0800 Subject: [PATCH 2/9] Update notifier.go --- internal/service/notifier.go | 214 +++++++++++++++++++++++++---------- 1 file changed, 155 insertions(+), 59 deletions(-) diff --git a/internal/service/notifier.go b/internal/service/notifier.go index 91ce2cb..675cb02 100644 --- a/internal/service/notifier.go +++ b/internal/service/notifier.go @@ -13,6 +13,7 @@ import ( "net/http" "strconv" "strings" + "sync" "time" "github.com/dushixiang/pika/internal/models" @@ -23,6 +24,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 +94,8 @@ var alertTypeMetadataMap = map[string]AlertTypeMetadata{ "service": { Name: "服务告警", ThresholdUnit: "秒", - ValueUnit: "秒", - ShowThreshold: true, + ValueUnit: "秒离线", + ShowThreshold: false, ShowActual: true, }, "agent_offline": { @@ -266,7 +281,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 @@ -282,12 +296,12 @@ func (n *Notifier) buildResolvedMessage( fmt.Sprintf("告警类型: %s", record.AlertType), } - // 显示告警值和恢复值的对比 + lines = append(lines, fmt.Sprintf("告警详情: %s", 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 { @@ -408,7 +422,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 +432,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 +443,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 发送企业应用微信通知 @@ -532,28 +544,46 @@ 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) - // 发送邮件 - if err := d.DialAndSend(m); err != nil { - return fmt.Errorf("发送邮件失败: %w", err) + 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 { + lastErr = err + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return err + } + continue + } + + n.logger.Info("邮件发送成功", + zap.String("from", fromEmail), + zap.String("to", toEmail), + zap.String("subject", subject), + ) + return nil } - n.logger.Info("邮件发送成功", - zap.String("from", fromEmail), - zap.String("to", toEmail), - zap.String("subject", subject), - ) - - return nil + return fmt.Errorf("邮件重试%d次后仍失败: %w", maxRetries, lastErr) } // webhookConfig Webhook 配置 @@ -664,34 +694,66 @@ func (n *Notifier) buildCustomBody(agent *models.Agent, record *models.AlertReco return strings.NewReader(bodyStr), nil } +// 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): + } + } + + 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 { - // 创建请求 - req, err := http.NewRequestWithContext(ctx, method, webhookURL, body) + bodyData, err := io.ReadAll(body) if err != nil { - return fmt.Errorf("创建请求失败: %w", err) + return fmt.Errorf("读取请求体失败: %w", err) } - // 设置 Content-Type - req.Header.Set("Content-Type", contentType) - - // 设置自定义请求头 - for k, v := range headers { - req.Header.Set(k, v) + 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) + } + return req, nil } - // 发送请求 - client := &http.Client{ - Timeout: 10 * time.Second, - } - - 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 +791,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) } - req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(data)) - 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, err + } + req.Header.Set("Content-Type", "application/json") + return req, nil } - req.Header.Set("Content-Type", "application/json") - - client := &http.Client{ - Timeout: 10 * time.Second, - } - - 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 +998,63 @@ 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 { - n.logger.Error("发送通知失败", - zap.String("channelType", channelConfig.Type), - zap.Error(err), - ) - errs = append(errs, err) + 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", 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)) + + switch channelConfig.Type { + case "dingtalk": + return n.sendDingTalkByConfig(ctx, channelConfig.Config, message) + case "wecom": + return n.sendWeComByConfig(ctx, channelConfig.Config, message) + case "wecomApp": + return n.sendWeComAppByConfig(ctx, channelConfig.Config, message) + case "feishu": + return n.sendFeishuByConfig(ctx, channelConfig.Config, message) + case "telegram": + return n.sendTelegramByConfig(ctx, channelConfig.Config, message) + case "email": + return n.sendEmailByConfig(ctx, channelConfig.Config, message) + case "webhook": + return n.sendWebhookByConfig(ctx, 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) From ce7478b00499b7116a36bded6abd2fe79198b192 Mon Sep 17 00:00:00 2001 From: yumusb <43062104+yumusb@users.noreply.github.com> Date: Wed, 8 Apr 2026 21:23:24 +0800 Subject: [PATCH 3/9] =?UTF-8?q?=E6=89=93=E5=8C=85=E4=BD=93=E7=A7=AF?= =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Makefile | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/Makefile b/Makefile index faa86c1..bd58cbc 100644 --- a/Makefile +++ b/Makefile @@ -21,15 +21,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 --best 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 --best bin/pika-linux-amd64 + upx --best bin/pika-linux-arm64 # 构建所有平台的 Agent build-agents: @@ -39,24 +39,24 @@ 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 --best bin/agents/pika-agent-linux-amd64 + @upx --best bin/agents/pika-agent-linux-arm64 + @upx --best bin/agents/pika-agent-linux-armv7 @echo "All agents compressed successfully!" @ls -lh bin/agents/ From d915ce1c15525710cfe14c87bb0e2b77cb486e88 Mon Sep 17 00:00:00 2001 From: yumusb <43062104+yumusb@users.noreply.github.com> Date: Fri, 10 Apr 2026 12:52:40 +0800 Subject: [PATCH 4/9] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E6=89=93?= =?UTF-8?q?=E5=8C=85=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 优化 GitHub Actions 构建流程 - 优化 Makefile 构建配置 Co-Authored-By: Claude Opus 4.6 --- .github/workflows/build-test-ghcr.yml | 4 +++- .github/workflows/docker-agent-publish.yml | 4 ++-- Makefile | 22 +++++++++++++--------- 3 files changed, 18 insertions(+), 12 deletions(-) diff --git a/.github/workflows/build-test-ghcr.yml b/.github/workflows/build-test-ghcr.yml index 6290561..636a097 100644 --- a/.github/workflows/build-test-ghcr.yml +++ b/.github/workflows/build-test-ghcr.yml @@ -44,7 +44,9 @@ jobs: # 构建所有组件(前端、agents、服务端) - name: 构建所有组件 run: | - make build-release + make build-web + make build-agents + make build-servers # 验证 index.html 模板代码 - name: 验证 index.html 模板代码 diff --git a/.github/workflows/docker-agent-publish.yml b/.github/workflows/docker-agent-publish.yml index 8a537ca..83d6eab 100644 --- a/.github/workflows/docker-agent-publish.yml +++ b/.github/workflows/docker-agent-publish.yml @@ -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: 列出编译产物 diff --git a/Makefile b/Makefile index bd58cbc..97afa10 100644 --- a/Makefile +++ b/Makefile @@ -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)' @@ -22,14 +26,14 @@ build-web: # 构建服务端(开发) build-server: $(GOFLAGS) GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="$(LDFLAGS)" -o bin/pika-linux-amd64 cmd/serv/main.go - upx --best bin/pika-linux-amd64 + upx $(UPX_FLAGS) bin/pika-linux-amd64 build-servers: $(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 --best bin/pika-linux-amd64 - upx --best bin/pika-linux-arm64 + upx $(UPX_FLAGS) bin/pika-linux-amd64 + upx $(UPX_FLAGS) bin/pika-linux-arm64 # 构建所有平台的 Agent build-agents: @@ -54,17 +58,17 @@ build-agents: @echo "All agents built successfully!" @echo "Compressing agents with UPX..." - @upx --best bin/agents/pika-agent-linux-amd64 - @upx --best bin/agents/pika-agent-linux-arm64 - @upx --best 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: From ffd1461b801709ecb165ab8fe25ac9722ebae255 Mon Sep 17 00:00:00 2001 From: yumusb <43062104+yumusb@users.noreply.github.com> Date: Fri, 10 Apr 2026 12:52:50 +0800 Subject: [PATCH 5/9] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E9=80=9A?= =?UTF-8?q?=E7=9F=A5=E5=92=8C=E5=91=8A=E8=AD=A6=E6=9C=8D=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 重构告警通知逻辑 - 新增通知队列服务 - 优化告警信息处理 Co-Authored-By: Claude Opus 4.6 --- internal/models/alert.go | 31 +++-- internal/service/alert_service.go | 140 +++++++++++++------ internal/service/notification_queue.go | 179 +++++++++++++++++++++++++ internal/service/notifier.go | 105 +++++++++------ 4 files changed, 359 insertions(+), 96 deletions(-) create mode 100644 internal/service/notification_queue.go diff --git a/internal/models/alert.go b/internal/models/alert.go index 2480c96..a306c58 100644 --- a/internal/models/alert.go +++ b/internal/models/alert.go @@ -2,20 +2,23 @@ package models // AlertRecord 告警记录 type AlertRecord struct { - ID int64 `gorm:"primaryKey;autoIncrement" json:"id"` // 记录ID - AgentID string `gorm:"index" json:"agentId"` // 探针ID - AgentName string `json:"agentName"` // 探针名称 - AlertType string `json:"alertType"` // 告警类型: cpu, memory, disk, network - Message string `json:"message"` // 告警消息 - Threshold float64 `json:"threshold"` // 告警阈值 - ActualValue float64 `json:"actualValue"` // 告警触发时的实际值 - ResolvedValue float64 `json:"resolvedValue,omitempty"` // 恢复时的实际值 - Level string `json:"level"` // 告警级别: info, warning, critical - Status string `json:"status"` // 状态: firing(告警中), resolved(已恢复) - FiredAt int64 `gorm:"index" json:"firedAt"` // 触发时间(时间戳毫秒) - ResolvedAt int64 `json:"resolvedAt,omitempty"` // 恢复时间(时间戳毫秒) - CreatedAt int64 `json:"createdAt"` // 创建时间(时间戳毫秒) - UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli"` // 更新时间(时间戳毫秒) + ID int64 `gorm:"primaryKey;autoIncrement" json:"id"` // 记录ID + AgentID string `gorm:"index" json:"agentId"` // 探针ID + AgentName string `json:"agentName"` // 探针名称 + AlertType string `json:"alertType"` // 告警类型: cpu, memory, disk, network + Message string `json:"message"` // 告警消息 + Threshold float64 `json:"threshold"` // 告警阈值 + ActualValue float64 `json:"actualValue"` // 告警触发时的实际值 + ResolvedValue float64 `json:"resolvedValue,omitempty"` // 恢复时的实际值 + Level string `json:"level"` // 告警级别: info, warning, critical + 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"` // 更新时间(时间戳毫秒) } func (AlertRecord) TableName() string { diff --git a/internal/service/alert_service.go b/internal/service/alert_service.go index 7fa69c4..00b5b5c 100644 --- a/internal/service/alert_service.go +++ b/internal/service/alert_service.go @@ -15,18 +15,19 @@ import ( // AlertService 告警服务 type AlertService struct { - Service *orz.Service - AlertRecordRepo *repo.AlertRecordRepo - AlertStateRepo *repo.AlertStateRepo - agentRepo *repo.AgentRepo - monitorService *MonitorService - propertyService *PropertyService - notifier *Notifier - logger *zap.Logger + Service *orz.Service + AlertRecordRepo *repo.AlertRecordRepo + AlertStateRepo *repo.AlertStateRepo + agentRepo *repo.AgentRepo + 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 } // 发送通知 diff --git a/internal/service/notification_queue.go b/internal/service/notification_queue.go new file mode 100644 index 0000000..d47bf60 --- /dev/null +++ b/internal/service/notification_queue.go @@ -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) +} diff --git a/internal/service/notifier.go b/internal/service/notifier.go index 675cb02..03785e3 100644 --- a/internal/service/notifier.go +++ b/internal/service/notifier.go @@ -15,6 +15,7 @@ import ( "strings" "sync" "time" + "unicode/utf8" "github.com/dushixiang/pika/internal/models" "github.com/dushixiang/pika/internal/utils" @@ -251,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") } @@ -290,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, } - lines = append(lines, fmt.Sprintf("告警详情: %s", record.Message)) - if metadata.ShowActual { if (record.AlertType == "service" || record.AlertType == "agent_offline") && record.ResolvedValue == 0 { lines = append(lines, - fmt.Sprintf("离线时长: %.0f秒", record.ActualValue), - "恢复状态: 已在线", + 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") } @@ -327,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") } @@ -524,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) @@ -1035,21 +1051,24 @@ func (n *Notifier) SendNotificationByConfigs(ctx context.Context, channelConfigs 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(ctx, channelConfig.Config, message) + return n.sendDingTalkByConfig(channelCtx, channelConfig.Config, message) case "wecom": - return n.sendWeComByConfig(ctx, channelConfig.Config, message) + return n.sendWeComByConfig(channelCtx, channelConfig.Config, message) case "wecomApp": - return n.sendWeComAppByConfig(ctx, channelConfig.Config, message) + return n.sendWeComAppByConfig(channelCtx, channelConfig.Config, message) case "feishu": - return n.sendFeishuByConfig(ctx, channelConfig.Config, message) + return n.sendFeishuByConfig(channelCtx, channelConfig.Config, message) case "telegram": - return n.sendTelegramByConfig(ctx, channelConfig.Config, message) + return n.sendTelegramByConfig(channelCtx, channelConfig.Config, message) case "email": - return n.sendEmailByConfig(ctx, channelConfig.Config, message) + return n.sendEmailByConfig(channelCtx, channelConfig.Config, message) case "webhook": - return n.sendWebhookByConfig(ctx, channelConfig.Config, agent, record, maskIP) + return n.sendWebhookByConfig(channelCtx, channelConfig.Config, agent, record, maskIP) default: return fmt.Errorf("不支持的通知渠道类型: %s", channelConfig.Type) } From 85a55af6a5a9c0dcb26e30cb6bb0985b0f714338 Mon Sep 17 00:00:00 2001 From: yumusb <43062104+yumusb@users.noreply.github.com> Date: Fri, 10 Apr 2026 12:53:04 +0800 Subject: [PATCH 6/9] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E7=9B=91?= =?UTF-8?q?=E6=8E=A7=E5=9B=BE=E8=A1=A8=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 增强 MonitorChart 组件功能和样式 - 优化 ResponseTimeChart 图表显示 - 添加可选择图例功能 - 统一图表插值和 connectNulls 配置 - 优化 MiniChart 和 MonitorCard 展示 Co-Authored-By: Claude Opus 4.6 --- .../portal/components/monitor/MiniChart.tsx | 1 + .../portal/components/monitor/MonitorCard.tsx | 70 ++- .../components/monitor/ResponseTimeChart.tsx | 387 +++++++++++---- web/src/portal/components/server/CpuChart.tsx | 1 + .../portal/components/server/DiskIOChart.tsx | 2 + web/src/portal/components/server/GpuChart.tsx | 2 + .../portal/components/server/MemoryChart.tsx | 1 + .../portal/components/server/MonitorChart.tsx | 440 +++++++++++++++--- .../portal/components/server/NetworkChart.tsx | 2 + .../server/NetworkConnectionChart.tsx | 4 + web/src/portal/pages/ServerList.tsx | 55 ++- 11 files changed, 779 insertions(+), 186 deletions(-) diff --git a/web/src/portal/components/monitor/MiniChart.tsx b/web/src/portal/components/monitor/MiniChart.tsx index cc0f9cf..dee1cc3 100644 --- a/web/src/portal/components/monitor/MiniChart.tsx +++ b/web/src/portal/components/monitor/MiniChart.tsx @@ -61,6 +61,7 @@ export const MiniChart = ({data, lastValue, id}: MiniChartProps) => { strokeWidth={2} filter={`url(#glow-${id})`} isAnimationActive={false} + connectNulls dot={false} /> diff --git a/web/src/portal/components/monitor/MonitorCard.tsx b/web/src/portal/components/monitor/MonitorCard.tsx index 4181505..2af79e0 100644 --- a/web/src/portal/components/monitor/MonitorCard.tsx +++ b/web/src/portal/components/monitor/MonitorCard.tsx @@ -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(); + 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, []); - } - timeMap.get(point.timestamp)!.push(point.value); - }); + // 确定全局时间范围 + 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); }); - // 根据显示模式计算聚合值 - return Array.from(timeMap.entries()).map(([timestamp, values]) => ({ - timestamp, - value: displayMode === 'avg' - ? Math.round(values.reduce((a, b) => a + b, 0) / values.length) - : Math.max(...values), - })); + 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); + } + + // 线性插值函数 + 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); + }); + + 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; diff --git a/web/src/portal/components/monitor/ResponseTimeChart.tsx b/web/src/portal/components/monitor/ResponseTimeChart.tsx index df8f6e1..971d7b1 100644 --- a/web/src/portal/components/monitor/ResponseTimeChart.tsx +++ b/web/src/portal/components/monitor/ResponseTimeChart.tsx @@ -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 ( +
+ {allAgents.map((agent: { id: string; name: string }, index: number) => { + const isSelected = selectedAgents.has(agent.id); + const color = colors[index]; + + return ( +
onClick(agent.id)} + className="flex items-center gap-2 cursor-pointer transition-opacity" + style={{ + opacity: isSelected ? 1 : 0.4, + }} + > + + + + + {agent.name} + +
+ ); + })} +
+ ); +}; + /** * 响应时间趋势图表组件 * 显示监控各探针的响应时间变化 */ export const ResponseTimeChart = ({monitorId, monitorStats}: ResponseTimeChartProps) => { - const [selectedAgent, setSelectedAgent] = useState('all'); - const [timeRange, setTimeRange] = useState('12h'); // 对应后端 60 秒步长 + const [selectedAgents, setSelectedAgents] = useState>(new Set()); + const [timeRange, setTimeRange] = useState('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); - - if (filteredSeries.length === 0) return []; - - // 按时间戳分组数据 - const grouped: Record = {}; - - filteredSeries.forEach(series => { - // 使用 agent_id 作为标识符 - const agentId = series.labels?.agent_id || 'unknown'; - const agentKey = `agent_${agentId}`; - - series.data.forEach(point => { - if (!grouped[point.timestamp]) { - grouped[point.timestamp] = { - timestamp: point.timestamp, - }; - } - grouped[point.timestamp][agentKey] = point.value; + // 收集所有选中探针的数据 + const selectedSeriesData: Array<{ key: string; data: Array<{ timestamp: number; value: number }> }> = []; + + seriesList.forEach((s) => { + const agentId = s.labels?.agent_id || 'unknown'; + if (!selectedAgents.has(agentId)) return; + if (!s.data || s.data.length === 0) return; + + selectedSeriesData.push({ + key: `agent_${agentId}`, + data: [...s.data].sort((a, b) => a.timestamp - b.timestamp) }); }); - // 按时间戳排序 - return Object.values(grouped).sort((a, b) => a.timestamp - b.timestamp); - }, [historyData, selectedAgent, timeRange, customStart, customEnd]); + if (selectedSeriesData.length === 0) return []; - const visibleMonitorStats = useMemo(() => { - return monitorStats.filter(stat => selectedAgent === 'all' || stat.agentId === selectedAgent); - }, [monitorStats, selectedAgent]); - - // 准备移动端图例数据 - 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 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); }); - }, [visibleMonitorStats, monitorStats]); + + 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; + + // 超出范围不插值(防止产生虚假连线) + if (targetTime < data[0].timestamp || targetTime > data[data.length - 1].timestamp) { + // 如果距离最近的点足够近(比如小于两个采样间隔),可以考虑保留,否则返回null + 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 dataPoint: any = { timestamp }; + selectedSeriesData.forEach(s => { + const value = interpolate(s.data, timestamp); + if (value !== null) { + dataPoint[s.key] = Number(value.toFixed(2)); + } + }); + return dataPoint; + }); + }, [historyData, selectedAgents, timeRange, customStart, customEnd]); return ( @@ -135,37 +317,44 @@ export const ResponseTimeChart = ({monitorId, monitorStats}: ResponseTimeChartPr setCustomRange(range); }} /> - {availableAgents.length > 0 && ( - - )} + {/* 使用提示和恢复按钮 */} + {availableAgents.length > 0 && ( +
+
+ 💡 点击图表线条或图例切换显示 +
+ {hasUnselected && ( + + )} +
+ )} + {chartData.length > 0 ? (
- {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 ( - - + + ); })} @@ -199,33 +388,59 @@ export const ResponseTimeChart = ({monitorId, monitorStats}: ResponseTimeChartPr content={} wrapperStyle={{zIndex: 9999}} /> - {!isMobile && ( - - )} - {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 ( ); })} + + {/* 桌面端:直接显示图例 */} + {!isMobile && availableAgents.length > 0 && ( + + )} + + {/* 移动端:可折叠图例 */} + {isMobile && availableAgents.length > 0 && ( +
+ + +
+ )}
) : ( { strokeWidth={2} fill="url(#cpuAreaGradient)" activeDot={{r: 3}} + connectNulls /> diff --git a/web/src/portal/components/server/DiskIOChart.tsx b/web/src/portal/components/server/DiskIOChart.tsx index 48bec12..df05d2f 100644 --- a/web/src/portal/components/server/DiskIOChart.tsx +++ b/web/src/portal/components/server/DiskIOChart.tsx @@ -109,6 +109,7 @@ export const DiskIOChart = ({agentId, timeRange, start, end}: DiskIOChartProps) strokeWidth={2} fill="url(#colorDiskRead)" activeDot={{r: 3}} + connectNulls /> diff --git a/web/src/portal/components/server/GpuChart.tsx b/web/src/portal/components/server/GpuChart.tsx index c87f964..0a7481d 100644 --- a/web/src/portal/components/server/GpuChart.tsx +++ b/web/src/portal/components/server/GpuChart.tsx @@ -113,6 +113,7 @@ export const GpuChart = ({agentId, timeRange, start, end}: GpuChartProps) => { strokeWidth={2} dot={false} activeDot={{r: 3}} + connectNulls /> { strokeWidth={2} dot={false} activeDot={{r: 3}} + connectNulls /> diff --git a/web/src/portal/components/server/MemoryChart.tsx b/web/src/portal/components/server/MemoryChart.tsx index 4ed100f..6cab0a8 100644 --- a/web/src/portal/components/server/MemoryChart.tsx +++ b/web/src/portal/components/server/MemoryChart.tsx @@ -86,6 +86,7 @@ export const MemoryChart = ({agentId, timeRange, start, end}: MemoryChartProps) strokeWidth={2} fill="url(#memoryAreaGradient)" activeDot={{r: 3}} + connectNulls /> diff --git a/web/src/portal/components/server/MonitorChart.tsx b/web/src/portal/components/server/MonitorChart.tsx index b48717b..6a8d0e6 100644 --- a/web/src/portal/components/server/MonitorChart.tsx +++ b/web/src/portal/components/server/MonitorChart.tsx @@ -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 ( +
+ {allMonitorKeys.map((monitorKey: string, index: number) => { + const isSelected = selectedMonitors.has(monitorKey); + const color = colors[index]; + + return ( +
onClick({ value: monitorKey })} + className="flex items-center gap-2 cursor-pointer transition-opacity" + style={{ + opacity: isSelected ? 1 : 0.4, + }} + > + + + + + {monitorKey} + +
+ ); + })} +
+ ); +}; + /** * 监控响应时间图表组件 */ export const MonitorChart = ({agentId, timeRange, start, end}: MonitorChartProps) => { const isMobile = useIsMobile(); const rangeMs = start !== undefined && end !== undefined ? end - start : undefined; + const [selectedMonitors, setSelectedMonitors] = useState>(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(); - - 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 {chartData.length > 0 ? ( <> + {/* 使用提示和恢复按钮 */} + {allMonitorKeys.length > 1 && ( +
+
+ 💡 点击图表线条或图例切换显示 +
+ {hasUnselected && ( + + )} +
+ )} + - {monitorKeys.map((key, index) => ( - - - - - ))} + {monitorKeys.map((key, index) => { + const originalIndex = allMonitorKeys.indexOf(key); + return ( + + + + + ); + })} @@ -119,26 +413,56 @@ export const MonitorChart = ({agentId, timeRange, start, end}: MonitorChartProps content={} wrapperStyle={{zIndex: 9999,}} /> - {!isMobile && monitorKeys.length > 1 && ( - - )} - {monitorKeys.map((key, index) => ( - - ))} + {monitorKeys.map((key, index) => { + const originalIndex = allMonitorKeys.indexOf(key); + return ( + + ); + })} + + {/* 桌面端:直接显示图例 */} + {!isMobile && allMonitorKeys.length > 0 && ( + + )} + + {/* 移动端:可折叠图例 */} + {isMobile && allMonitorKeys.length > 0 && ( +
+ + +
+ )} ) : ( diff --git a/web/src/portal/components/server/NetworkChart.tsx b/web/src/portal/components/server/NetworkChart.tsx index 6ddf512..6099d79 100644 --- a/web/src/portal/components/server/NetworkChart.tsx +++ b/web/src/portal/components/server/NetworkChart.tsx @@ -141,6 +141,7 @@ export const NetworkChart = ({agentId, timeRange, start, end}: NetworkChartProps strokeWidth={2} fill="url(#color-upload)" activeDot={{r: 3}} + connectNulls /> diff --git a/web/src/portal/components/server/NetworkConnectionChart.tsx b/web/src/portal/components/server/NetworkConnectionChart.tsx index c4fe154..c1e4cbc 100644 --- a/web/src/portal/components/server/NetworkConnectionChart.tsx +++ b/web/src/portal/components/server/NetworkConnectionChart.tsx @@ -98,6 +98,7 @@ export const NetworkConnectionChart = ({agentId, timeRange, start, end}: Network strokeWidth={2} dot={false} activeDot={{r: 3}} + connectNulls /> diff --git a/web/src/portal/pages/ServerList.tsx b/web/src/portal/pages/ServerList.tsx index 05eb530..52299fc 100644 --- a/web/src/portal/pages/ServerList.tsx +++ b/web/src/portal/pages/ServerList.tsx @@ -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 ; } - // 计算所有标签(包括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(); From b9ec2105425baa359093e9bcc203d566b945c34d Mon Sep 17 00:00:00 2001 From: yumusb <43062104+yumusb@users.noreply.github.com> Date: Fri, 10 Apr 2026 12:53:13 +0800 Subject: [PATCH 7/9] =?UTF-8?q?refactor:=20=E4=BC=98=E5=8C=96=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=E6=9C=8D=E5=8A=A1=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 统一使用 orz 响应格式 - 优化 handler 和 service 层代码 - 改进上下文传递 Co-Authored-By: Claude Opus 4.6 --- internal/handler/agent_admin_handler.go | 9 ----- internal/handler/alert_handler.go | 8 ++--- internal/handler/monitor_handler.go | 2 +- internal/handler/property_handler.go | 46 +++++++------------------ internal/handler/ssh_login_handler.go | 43 +++++++---------------- internal/handler/tamper_handler.go | 23 ++++--------- internal/service/agent_service.go | 30 +++++++++------- internal/service/metric_service.go | 6 ++-- internal/service/monitor_service.go | 8 ++--- internal/service/ssh_login_service.go | 8 ++--- internal/service/tamper_service.go | 6 ++-- 11 files changed, 67 insertions(+), 122 deletions(-) diff --git a/internal/handler/agent_admin_handler.go b/internal/handler/agent_admin_handler.go index 25df1ab..1b6654a 100644 --- a/internal/handler/agent_admin_handler.go +++ b/internal/handler/agent_admin_handler.go @@ -238,15 +238,6 @@ func (h *AgentHandler) Delete(c echo.Context) error { return err } - // 删除探针及其所有相关数据 - if err := h.agentService.DeleteAgent(ctx, agentID); err != nil { - h.logger.Error("删除探针失败", - zap.String("agentID", agentID), - zap.String("name", agent.Name), - zap.Error(err)) - return err - } - h.logger.Info("探针已删除", zap.String("agentID", agentID), zap.String("name", agent.Name)) diff --git a/internal/handler/alert_handler.go b/internal/handler/alert_handler.go index 3ae4508..4d5f2fe 100644 --- a/internal/handler/alert_handler.go +++ b/internal/handler/alert_handler.go @@ -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{}) } diff --git a/internal/handler/monitor_handler.go b/internal/handler/monitor_handler.go index 9a53362..1cc9fb9 100644 --- a/internal/handler/monitor_handler.go +++ b/internal/handler/monitor_handler.go @@ -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 = "" // 隐藏目标地址 } diff --git a/internal/handler/property_handler.go b/internal/handler/property_handler.go index 7611162..dc32478 100644 --- a/internal/handler/property_handler.go +++ b/internal/handler/property_handler.go @@ -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{}) } diff --git a/internal/handler/ssh_login_handler.go b/internal/handler/ssh_login_handler.go index a894b28..7015e2d 100644 --- a/internal/handler/ssh_login_handler.go +++ b/internal/handler/ssh_login_handler.go @@ -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{}) diff --git a/internal/handler/tamper_handler.go b/internal/handler/tamper_handler.go index ab9d882..e637f31 100644 --- a/internal/handler/tamper_handler.go +++ b/internal/handler/tamper_handler.go @@ -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{}) } diff --git a/internal/service/agent_service.go b/internal/service/agent_service.go index f866724..0ac132e 100644 --- a/internal/service/agent_service.go +++ b/internal/service/agent_service.go @@ -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)) @@ -376,23 +376,29 @@ func (s *AgentService) DeleteAgent(ctx context.Context, agentID string) error { return err } - // 4. 清理 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)) - // 记录错误但不停止删除流程 - } - } - - // 5. 最后删除探针本身 + // 4. 最后删除探针本身 if err := s.AgentRepo.DeleteById(ctx, agentID); err != nil { s.logger.Error("删除探针失败", zap.String("agentId", agentID), zap.Error(err)) return err } - s.logger.Info("探针删除成功", zap.String("agentId", agentID)) return nil }) + + if err != nil { + return err + } + + // 5. 清理 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 根据认证状态列出探针(已登录返回全部,未登录返回公开可见) diff --git a/internal/service/metric_service.go b/internal/service/metric_service.go index 14850df..49c10fd 100644 --- a/internal/service/metric_service.go +++ b/internal/service/metric_service.go @@ -803,7 +803,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 { @@ -812,7 +812,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)) @@ -872,7 +871,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 { @@ -883,7 +882,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)) diff --git a/internal/service/monitor_service.go b/internal/service/monitor_service.go index 497e224..d65795b 100644 --- a/internal/service/monitor_service.go +++ b/internal/service/monitor_service.go @@ -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 diff --git a/internal/service/ssh_login_service.go b/internal/service/ssh_login_service.go index 8860ddf..453301f 100644 --- a/internal/service/ssh_login_service.go +++ b/internal/service/ssh_login_service.go @@ -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) } } diff --git a/internal/service/tamper_service.go b/internal/service/tamper_service.go index 5978556..5279c7b 100644 --- a/internal/service/tamper_service.go +++ b/internal/service/tamper_service.go @@ -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 From d4a42c6f22101f141cf53e39d588070f78e04045 Mon Sep 17 00:00:00 2001 From: yumusb <43062104+yumusb@users.noreply.github.com> Date: Fri, 10 Apr 2026 12:53:21 +0800 Subject: [PATCH 8/9] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=20UI=20=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加操作加载状态 - 表格支持水平滚动 - 优化多个页面组件展示 Co-Authored-By: Claude Opus 4.6 --- web/src/admin/components/PageHeader.tsx | 2 + web/src/admin/pages/Agents/AgentList.tsx | 158 +++++++++--------- web/src/admin/pages/Agents/SSHLoginEvents.tsx | 1 + .../pages/Agents/TamperProtectionEvents.tsx | 1 + web/src/admin/pages/AlertRecords/index.tsx | 77 +++++---- web/src/admin/pages/ApiKeys/ApiKeyList.tsx | 58 +++---- web/src/admin/pages/DDNS/index.tsx | 64 +++---- web/src/admin/pages/Monitors/MonitorList.tsx | 64 +++---- 8 files changed, 217 insertions(+), 208 deletions(-) diff --git a/web/src/admin/components/PageHeader.tsx b/web/src/admin/components/PageHeader.tsx index b0a91d1..845a1bc 100644 --- a/web/src/admin/components/PageHeader.tsx +++ b/web/src/admin/components/PageHeader.tsx @@ -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 = ({title, description, actio onClick={action.onClick} danger={action.danger} disabled={action.disabled} + loading={action.loading} > {action.label} diff --git a/web/src/admin/pages/Agents/AgentList.tsx b/web/src/admin/pages/Agents/AgentList.tsx index 2ebdae8..43b4848 100644 --- a/web/src/admin/pages/Agents/AgentList.tsx +++ b/web/src/admin/pages/Agents/AgentList.tsx @@ -533,92 +533,90 @@ const AgentList = () => { icon: , onClick: () => refetch(), }, + { + key: 'cleanup-metrics', + label: cleanupMutation.isPending ? '清理中...' : '清理残留指标数据', + icon: , + onClick: handleCleanupMetrics, + loading: cleanupMutation.isPending, + }, ]} /> - +
+
+ + + + + + - - - + (option?.label?.toString() ?? '') + .toLowerCase() + .includes(inputValue.toLowerCase()), + }} + style={{width: 200}} + value={selectedAgentId || undefined} + onChange={handleAgentChange} + options={agentOptions} + /> + +
-
- -