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', {}); +};