diff --git a/.dockerignore b/.dockerignore index 437478c..23f139d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -14,7 +14,7 @@ *~ # 构建产物 -bin/agents/ +# bin/agents/ 不能忽略,Agent Docker 镜像构建需要使用 web/dist/ web/node_modules/ diff --git a/.github/workflows/docker-agent-publish.yml b/.github/workflows/docker-agent-publish.yml new file mode 100644 index 0000000..8a537ca --- /dev/null +++ b/.github/workflows/docker-agent-publish.yml @@ -0,0 +1,150 @@ +name: 构建并发布 Agent Docker 镜像 + +on: + push: + tags: + - 'v*' # 当推送以 v 开头的 tag 时触发 + workflow_dispatch: # 允许手动触发 + +env: + # 使用 docker.io 作为 Docker Hub 的注册表 + DOCKER_REGISTRY: docker.io + # 使用 GitHub Container Registry + GHCR_REGISTRY: ghcr.io + # Agent 镜像名称 + IMAGE_NAME: ${{ github.repository }}-agent + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + id-token: write + + steps: + # 获取版本号 + - name: 获取版本号 + id: get_version + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "VERSION=dev" >> $GITHUB_ENV + else + echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV + fi + + # 检出代码 + - name: 检出代码 + uses: actions/checkout@v4 + with: + fetch-depth: 0 # 获取完整历史以便计算 AGENT_VERSION + + # 计算 Agent 版本号 + - name: 计算 Agent 版本号 + id: agent_version + run: | + AGENT_VERSION=$(git log -1 --format=%h -- pkg/agent 2>/dev/null || echo "dev") + echo "AGENT_VERSION=$AGENT_VERSION" >> $GITHUB_ENV + echo "Agent version: $AGENT_VERSION" + + # 安装 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' + + # 构建 agents + - name: 构建 agents + run: | + make build-agents + + # 列出编译产物 + - name: 列出编译产物 + run: | + echo "Agent binaries:" + ls -lh ./bin/agents/ + + # 设置 QEMU 支持多平台构建 + - name: 设置 QEMU + uses: docker/setup-qemu-action@v3 + + # 设置 Docker Buildx + - name: 设置 Docker Buildx + uses: docker/setup-buildx-action@v3 + + # 登录 Docker Hub + - name: 登录 Docker Hub + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ${{ env.DOCKER_REGISTRY }} + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_PASSWORD }} + + # 登录 GitHub Container Registry + - name: 登录 GitHub Container Registry + if: github.event_name != 'pull_request' + 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.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }} + ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=raw,value=latest + type=raw,value=${{ env.AGENT_VERSION }} + + # 构建并推送 Agent Docker 镜像 + - name: 构建并推送 Agent Docker 镜像 + id: build-and-push + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + file: Dockerfile.agent + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: | + VERSION=${{ env.VERSION }} + AGENT_VERSION=${{ env.AGENT_VERSION }} + cache-from: | + type=registry,ref=${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:cache + type=local,src=/tmp/.buildx-cache + cache-to: | + type=registry,ref=${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:cache,mode=max + type=local,dest=/tmp/.buildx-cache + + # 生成构建总结 + - name: 生成构建总结 + run: | + echo "## Agent Docker 镜像构建成功 🎉" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Server 版本:** ${{ env.VERSION }}" >> $GITHUB_STEP_SUMMARY + echo "**Agent 版本:** ${{ env.AGENT_VERSION }}" >> $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 "**支持平台:**" >> $GITHUB_STEP_SUMMARY + echo "- linux/amd64" >> $GITHUB_STEP_SUMMARY + echo "- linux/arm64" >> $GITHUB_STEP_SUMMARY diff --git a/Dockerfile.agent b/Dockerfile.agent new file mode 100644 index 0000000..ca18101 --- /dev/null +++ b/Dockerfile.agent @@ -0,0 +1,23 @@ +# 用于 GitHub Action 的 Agent Dockerfile +# 这个 Dockerfile 假设编译产物已经在外部构建完成 + +FROM alpine:latest + +# 安装运行时依赖 +RUN apk add --no-cache ca-certificates tzdata + +# 设置时区为上海 +ENV TZ=Asia/Shanghai + +WORKDIR /app + +ARG TARGETARCH + +# 从外部编译的产物复制 agent 文件 +COPY ./bin/agents/pika-agent-linux-${TARGETARCH} ./pika-agent + +# 赋予执行权限 +RUN chmod +x ./pika-agent + +# 启动 agent +ENTRYPOINT ["./pika-agent"] diff --git a/Makefile b/Makefile index 48f7c40..160a9b3 100644 --- a/Makefile +++ b/Makefile @@ -2,16 +2,8 @@ VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") # Agent 版本号基于 pkg/agent/ 目录的最后修改提交生成 -# 格式:agent-{最近修改 pkg/agent 的提交短hash}-{提交数量} -AGENT_VERSION ?= $(shell \ - LAST_COMMIT=$$(git log -1 --format=%h -- pkg/agent 2>/dev/null || echo "dev"); \ - COMMIT_COUNT=$$(git rev-list --count HEAD -- pkg/agent 2>/dev/null || echo "0"); \ - if [ "$$LAST_COMMIT" = "dev" ]; then \ - echo "dev"; \ - else \ - echo "$$LAST_COMMIT-$$COMMIT_COUNT"; \ - fi \ -) +# 格式:{最近修改 pkg/agent 的提交短hash} +AGENT_VERSION ?= $(shell git log -1 --format=%h -- pkg/agent 2>/dev/null || echo "dev") GIT_REVISION=$(shell git rev-parse HEAD) GO_VERSION=$(shell go version) @@ -46,18 +38,18 @@ 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/*.go - $(GOFLAGS) GOOS=linux GOARCH=arm64 go build -ldflags="$(AGENT_LDFLAGS)" -o bin/agents/pika-agent-linux-arm64 cmd/agent/*.go - $(GOFLAGS) GOOS=linux GOARCH=arm GOARM=7 go build -ldflags="$(AGENT_LDFLAGS)" -o bin/agents/pika-agent-linux-armv7 cmd/agent/*.go - $(GOFLAGS) GOOS=linux GOARCH=loong64 go build -ldflags="$(AGENT_LDFLAGS)" -o bin/agents/pika-agent-linux-loong64 cmd/agent/*.go + $(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 # macOS - $(GOFLAGS) GOOS=darwin GOARCH=amd64 go build -ldflags="$(AGENT_LDFLAGS)" -o bin/agents/pika-agent-darwin-amd64 cmd/agent/*.go - $(GOFLAGS) GOOS=darwin GOARCH=arm64 go build -ldflags="$(AGENT_LDFLAGS)" -o bin/agents/pika-agent-darwin-arm64 cmd/agent/*.go + $(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 # Windows - $(GOFLAGS) GOOS=windows GOARCH=amd64 go build -ldflags="$(AGENT_LDFLAGS)" -o bin/agents/pika-agent-windows-amd64.exe cmd/agent/*.go - $(GOFLAGS) GOOS=windows GOARCH=arm64 go build -ldflags="$(AGENT_LDFLAGS)" -o bin/agents/pika-agent-windows-arm64.exe cmd/agent/*.go + $(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 @echo "All agents built successfully!" @echo "Compressing agents with UPX..." diff --git a/cmd/agent/main.go b/cmd/agent/main.go index 9e1421d..6a56e3b 100644 --- a/cmd/agent/main.go +++ b/cmd/agent/main.go @@ -237,23 +237,9 @@ func installService(cmd *cobra.Command, args []string) { // uninstallService 卸载服务 func uninstallService(cmd *cobra.Command, args []string) { - // 加载配置 - cfg, err := config.Load(configPath) - if err != nil { - log.Fatalf("❌ 加载配置失败: %v", err) + if err := service.UninstallAgent(configPath); err != nil { + log.Fatalf("❌ 卸载失败: %v", err) } - - // 创建服务管理器 - mgr, err := service.NewServiceManager(cfg) - if err != nil { - log.Fatalf("❌ 创建服务管理器失败: %v", err) - } - - // 卸载服务 - if err := mgr.Uninstall(); err != nil { - log.Fatalf("❌ 卸载服务失败: %v", err) - } - log.Println("✅ 服务卸载成功") } diff --git a/internal/handler/agent_handler.go b/internal/handler/agent_handler.go index 0f99c97..75ad790 100644 --- a/internal/handler/agent_handler.go +++ b/internal/handler/agent_handler.go @@ -288,16 +288,15 @@ func (h *AgentHandler) sendTamperConfig(conn *websocket.Conn, agentID string) er // Paging 探针分页查询 func (h *AgentHandler) Paging(c echo.Context) error { - hostname := c.QueryParam("hostname") - ip := c.QueryParam("ip") status := c.QueryParam("status") pr := orz.GetPageRequest(c, "name") builder := orz.NewPageBuilder(h.agentService.AgentRepo.Repository). PageRequest(pr). - Contains("hostname", hostname). - Contains("ip", ip) + Contains("name", c.QueryParam("name")). + Contains("hostname", c.QueryParam("hostname")). + Contains("ip", c.QueryParam("ip")) // 处理状态筛选 if status == "online" { @@ -713,8 +712,29 @@ func (h *AgentHandler) Delete(c echo.Context) error { return err } - // 如果探针在线,先断开连接 + // 如果探针在线,先发送卸载指令,然后断开连接 if client, exists := h.wsManager.GetClient(agentID); exists { + // 构建卸载消息 + uninstallMsg, err := json.Marshal(protocol.OutboundMessage{ + Type: protocol.MessageTypeUninstall, + Data: struct{}{}, // 空数据,卸载指令不需要额外参数 + }) + if err == nil { + // 发送卸载指令(忽略发送错误,继续删除流程) + if err := h.wsManager.SendToClient(agentID, uninstallMsg); err != nil { + h.logger.Warn("发送卸载指令失败", + zap.String("agentID", agentID), + zap.Error(err)) + } else { + h.logger.Info("已向探针发送卸载指令", + zap.String("agentID", agentID), + zap.String("name", agent.Name)) + // 等待一小段时间让探针处理卸载消息 + time.Sleep(500 * time.Millisecond) + } + } + + // 断开连接 client.Conn.Close() } @@ -854,10 +874,10 @@ download_agent() { echo_info "正在下载探针..." - if command -v wget &> /dev/null; then - wget -q --show-progress "$download_url" -O "$temp_file" - elif command -v curl &> /dev/null; then + if command -v curl &> /dev/null; then curl -# -L "$download_url" -o "$temp_file" + elif command -v wget &> /dev/null; then + wget -q "$download_url" -O "$temp_file" else echo_error "未找到 wget 或 curl 命令,请先安装其中之一" exit 1 diff --git a/internal/handler/monitor_handler.go b/internal/handler/monitor_handler.go index f18c28d..29ca6fb 100644 --- a/internal/handler/monitor_handler.go +++ b/internal/handler/monitor_handler.go @@ -202,7 +202,7 @@ func (h *MonitorHandler) GetHistoryByID(c echo.Context) error { return orz.NewError(400, err.Error()) } - history, err := h.monitorService.GetMonitorHistory(ctx, id, start, end, aggregation) + history, err := h.metricService.GetMonitorHistory(ctx, id, start, end, aggregation) if err != nil { return err } diff --git a/internal/models/monitor_task.go b/internal/models/monitor_task.go index 2d9f18f..be38bba 100644 --- a/internal/models/monitor_task.go +++ b/internal/models/monitor_task.go @@ -18,7 +18,6 @@ type MonitorTask struct { Interval int `json:"interval"` // 检测频率(秒),默认 60 AgentIds datatypes.JSONSlice[string] `json:"agentIds"` // 指定的探针 ID 列表(JSON 数组) AgentNames []string `gorm:"-" json:"agentNames"` // 指定的探针名称列表 - Tags datatypes.JSONSlice[string] `json:"tags"` // 指定的标签列表(JSON 数组),拥有这些标签的探针都会执行此监控 HTTPConfig datatypes.JSONType[protocol.HTTPMonitorConfig] `json:"httpConfig"` // HTTP 监控配置 TCPConfig datatypes.JSONType[protocol.TCPMonitorConfig] `json:"tcpConfig"` // TCP 监控配置 ICMPConfig datatypes.JSONType[protocol.ICMPMonitorConfig] `json:"icmpConfig"` // ICMP 监控配置 diff --git a/internal/protocol/message.go b/internal/protocol/message.go index dadf299..150e15a 100644 --- a/internal/protocol/message.go +++ b/internal/protocol/message.go @@ -52,6 +52,7 @@ const ( MessageTypeHeartbeat MessageType = "heartbeat" MessageTypeCommand MessageType = "command" MessageTypeCommandResp MessageType = "command_response" + MessageTypeUninstall MessageType = "uninstall" // 指标消息 MessageTypeMetrics MessageType = "metrics" MessageTypeMonitorConfig MessageType = "monitor_config" diff --git a/internal/service/metric_service.go b/internal/service/metric_service.go index bede616..2afa171 100644 --- a/internal/service/metric_service.go +++ b/internal/service/metric_service.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "slices" "strconv" "time" @@ -296,6 +297,38 @@ func (s *MetricService) GetMetrics(ctx context.Context, agentID, metricType stri }, nil } +// CleanMonitorCache 清理监控任务缓存中不再关联的探针数据 +func (s *MetricService) CleanMonitorCache(ctx context.Context, monitorID string) error { + // 从缓存读取监控数据 + latestMetrics, ok := s.monitorLatestCache.Get(monitorID) + if !ok { + // 缓存不存在,无需清理 + return nil + } + + // 查询监控任务配置 + monitorTask, err := s.monitorRepo.FindById(ctx, monitorID) + if err != nil { + return err + } + + // 只在有过滤条件(指定了 AgentIds)时清理缓存 + if len(monitorTask.AgentIds) > 0 { + // 遍历缓存中的探针,移除不再关联的探针数据 + for agentId := range latestMetrics.Agents.Keys() { + if !slices.Contains(monitorTask.AgentIds, agentId) { + // 该探针已不再关联到此监控任务,从缓存中移除 + latestMetrics.Agents.Delete(agentId) + s.logger.Debug("从监控缓存中移除探针", + zap.String("monitorID", monitorID), + zap.String("agentID", agentId)) + } + } + } + + return nil +} + // updateMonitorCache 更新监控数据缓存 func (s *MetricService) updateMonitorCache(agentID string, monitorData *protocol.MonitorData, timestamp int64) { monitorID := monitorData.MonitorId @@ -572,6 +605,13 @@ func (s *MetricService) buildMonitorPromQLQueries(monitorID string, aggregation // GetMonitorHistory 获取监控任务的历史趋势数据 func (s *MetricService) GetMonitorHistory(ctx context.Context, monitorID string, start, end int64, aggregation string) (*metric.GetMetricsResponse, error) { + // 查询监控任务配置 + monitorTask, err := s.monitorRepo.FindById(ctx, monitorID) + if err != nil { + s.logger.Error("查询监控任务失败", zap.String("monitorID", monitorID), zap.Error(err)) + return nil, err + } + step := vmclient.AutoStep(time.UnixMilli(start), time.UnixMilli(end)) queries := s.buildMonitorPromQLQueries(monitorID, aggregation, step) @@ -592,11 +632,26 @@ func (s *MetricService) GetMonitorHistory(ctx context.Context, monitorID string, series = append(series, convertedSeries...) } - // 收集所有 agent_id + // 过滤掉已取消关联的 agent 数据(仅在有过滤条件时) agentIdSet := make(map[string]struct{}) - for _, s := range series { - if agentId, ok := s.Labels["agent_id"]; ok { - agentIdSet[agentId] = struct{}{} + if len(monitorTask.AgentIds) > 0 { + // 有过滤条件,只保留当前关联的 agent 数据 + filteredSeries := make([]metric.Series, 0) + for _, s := range series { + if agentId, ok := s.Labels["agent_id"]; ok { + if slices.Contains(monitorTask.AgentIds, agentId) { + filteredSeries = append(filteredSeries, s) + agentIdSet[agentId] = struct{}{} + } + } + } + series = filteredSeries + } else { + // 无过滤条件,收集所有 agent_id + for _, s := range series { + if agentId, ok := s.Labels["agent_id"]; ok { + agentIdSet[agentId] = struct{}{} + } } } @@ -645,14 +700,31 @@ func (s *MetricService) GetMonitorAgentStats(monitorID string) []protocol.Monito return []protocol.MonitorData{} } - // 收集所有 agentId - agentIds := make([]string, 0, latestMetrics.Agents.Len()) - for agentId := range latestMetrics.Agents.Keys() { - agentIds = append(agentIds, agentId) + // 查询监控任务配置 + ctx := context.Background() + monitorTask, err := s.monitorRepo.FindById(ctx, monitorID) + if err != nil { + s.logger.Error("查询监控任务失败", zap.String("monitorID", monitorID), zap.Error(err)) + return []protocol.MonitorData{} + } + + // 收集所有当前关联的 agentId(从缓存中过滤) + agentIds := make([]string, 0) + if len(monitorTask.AgentIds) > 0 { + // 有过滤条件,只保留匹配的 agent + for agentId := range latestMetrics.Agents.Keys() { + if slices.Contains(monitorTask.AgentIds, agentId) { + agentIds = append(agentIds, agentId) + } + } + } else { + // 无过滤条件,返回所有缓存中的 agent + for agentId := range latestMetrics.Agents.Keys() { + agentIds = append(agentIds, agentId) + } } // 查询 agent 信息 - ctx := context.Background() agents, err := s.agentRepo.FindByIdIn(ctx, agentIds) if err != nil { s.logger.Error("查询 agent 信息失败", zap.Error(err)) @@ -665,10 +737,20 @@ func (s *MetricService) GetMonitorAgentStats(monitorID string) []protocol.Monito } // 转换为数组并填充 agent 名称 - result := make([]protocol.MonitorData, 0, latestMetrics.Agents.Len()) + result := make([]protocol.MonitorData, 0, len(agentIds)) for stat := range latestMetrics.Agents.Values() { - stat.AgentName = agentNameMap[stat.AgentId] // 填充 agent 名称 - result = append(result, *stat) + // 根据过滤条件决定是否包含该 agent + if len(monitorTask.AgentIds) > 0 { + // 有过滤条件,只返回当前关联的 agent 数据 + if slices.Contains(monitorTask.AgentIds, stat.AgentId) { + stat.AgentName = agentNameMap[stat.AgentId] // 填充 agent 名称 + result = append(result, *stat) + } + } else { + // 无过滤条件,返回所有 agent 数据 + stat.AgentName = agentNameMap[stat.AgentId] // 填充 agent 名称 + result = append(result, *stat) + } } return result @@ -685,12 +767,22 @@ 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)) + return &metric.MonitorStatsResult{ + Status: "unknown", + } + } + // 聚合各探针数据 - return s.aggregateMonitorStats(latestMetrics) + return s.aggregateMonitorStats(latestMetrics, monitorTask.AgentIds) } // aggregateMonitorStats 聚合各探针的监控数据 -func (s *MetricService) aggregateMonitorStats(latestMetrics *metric.LatestMonitorMetrics) *metric.MonitorStatsResult { +func (s *MetricService) aggregateMonitorStats(latestMetrics *metric.LatestMonitorMetrics, agentIds []string) *metric.MonitorStatsResult { result := &metric.MonitorStatsResult{ Status: "unknown", } @@ -704,11 +796,21 @@ func (s *MetricService) aggregateMonitorStats(latestMetrics *metric.LatestMonito var maxResponseTime int64 var lastCheckTime int64 var upCount, downCount, unknownCount int + var validCount int // 实际聚合的探针数量 hasCert := false var minCertExpiryTime int64 var minCertDaysLeft int for stat := range latestMetrics.Agents.Values() { + // 根据过滤条件决定是否聚合该探针 + if len(agentIds) > 0 { + // 有过滤条件,只聚合当前关联的探针数据 + if !slices.Contains(agentIds, stat.AgentId) { + continue + } + } + + validCount++ totalResponseTime += stat.ResponseTime // 计算响应时间的最小值和最大值 @@ -742,10 +844,9 @@ func (s *MetricService) aggregateMonitorStats(latestMetrics *metric.LatestMonito } } - count := latestMetrics.Agents.Len() - result.AgentCount = count - if count > 0 { - result.ResponseTime = totalResponseTime / int64(count) + result.AgentCount = validCount + if validCount > 0 { + result.ResponseTime = totalResponseTime / int64(validCount) } result.ResponseTimeMin = minResponseTime result.ResponseTimeMax = maxResponseTime diff --git a/internal/service/monitor_service.go b/internal/service/monitor_service.go index b84eaac..9f45a83 100644 --- a/internal/service/monitor_service.go +++ b/internal/service/monitor_service.go @@ -68,7 +68,6 @@ type MonitorTaskRequest struct { TCPConfig protocol.TCPMonitorConfig `json:"tcpConfig,omitempty"` ICMPConfig protocol.ICMPMonitorConfig `json:"icmpConfig,omitempty"` AgentIds []string `json:"agentIds,omitempty"` - Tags []string `json:"tags"` } func (s *MonitorService) CreateMonitor(ctx context.Context, req *MonitorTaskRequest) (*models.MonitorTask, error) { @@ -95,7 +94,6 @@ func (s *MonitorService) CreateMonitor(ctx context.Context, req *MonitorTaskRequ Visibility: visibility, Interval: interval, AgentIds: datatypes.JSONSlice[string](req.AgentIds), - Tags: datatypes.JSONSlice[string](req.Tags), HTTPConfig: datatypes.NewJSONType(req.HTTPConfig), TCPConfig: datatypes.NewJSONType(req.TCPConfig), ICMPConfig: datatypes.NewJSONType(req.ICMPConfig), @@ -136,7 +134,6 @@ func (s *MonitorService) UpdateMonitor(ctx context.Context, id string, req *Moni task.Description = req.Description task.ShowTargetPublic = req.ShowTargetPublic task.Visibility = req.Visibility - task.Tags = req.Tags // 更新检测频率 interval := req.Interval @@ -154,6 +151,16 @@ func (s *MonitorService) UpdateMonitor(ctx context.Context, id string, req *Moni return nil, err } + // 清理监控缓存中不再关联的探针数据 + if s.metricService != nil { + if err := s.metricService.CleanMonitorCache(ctx, id); err != nil { + s.logger.Warn("清理监控缓存失败", + zap.String("monitorID", id), + zap.Error(err)) + // 不返回错误,继续执行 + } + } + // 更新调度器 if s.scheduler != nil { // 如果从禁用变为启用,或者间隔时间改变 @@ -255,57 +262,6 @@ func (s *MonitorService) buildMonitorOverview(monitor models.MonitorTask, stats return overview } -// resolveTargetAgents 计算监控任务对应的目标探针范围 -// 规则: -// 1. 如果既没有指定 AgentIds 也没有指定 Tags,返回所有传入的探针(全部节点) -// 2. 如果指定了 AgentIds 或 Tags(或两者都指定),则返回匹配的探针(自动去重) -// - AgentIds: 直接匹配探针 ID -// - Tags: 匹配探针标签中包含任意一个指定标签的探针 -// - 两者结果取并集 -func (s *MonitorService) resolveTargetAgents(monitor models.MonitorTask, availableAgents []models.Agent) []models.Agent { - // 如果既没有指定 AgentIds 也没有指定 Tags,使用所有可用探针 - if len(monitor.AgentIds) == 0 && len(monitor.Tags) == 0 { - return availableAgents - } - - // 使用 map 来去重 - targetAgentIDSet := make(map[string]struct{}) - - // 1. 处理通过 AgentIds 指定的探针 - if len(monitor.AgentIds) > 0 { - for _, agentID := range monitor.AgentIds { - targetAgentIDSet[agentID] = struct{}{} - } - } - - // 2. 处理通过 Tags 指定的探针 - if len(monitor.Tags) > 0 { - for _, agent := range availableAgents { - if agent.Tags != nil && len(agent.Tags) > 0 { - // 检查探针的标签中是否包含任何一个指定的标签 - for _, agentTag := range agent.Tags { - for _, monitorTag := range monitor.Tags { - if agentTag == monitorTag { - targetAgentIDSet[agent.ID] = struct{}{} - break - } - } - } - } - } - } - - // 3. 根据去重后的 ID 集合筛选探针 - targetAgents := make([]models.Agent, 0, len(targetAgentIDSet)) - for _, agent := range availableAgents { - if _, ok := targetAgentIDSet[agent.ID]; ok { - targetAgents = append(targetAgents, agent) - } - } - - return targetAgents -} - // sendMonitorConfigToAgent 向指定探针发送监控配置(内部方法) func (s *MonitorService) sendMonitorConfigToAgent(agentID string, payload protocol.MonitorConfigPayload) error { msgData, err := json.Marshal(protocol.OutboundMessage{ @@ -321,25 +277,17 @@ func (s *MonitorService) sendMonitorConfigToAgent(agentID string, payload protoc // SendMonitorTaskToAgents 向指定探针发送单个监控任务(公开方法) func (s *MonitorService) SendMonitorTaskToAgents(ctx context.Context, monitor models.MonitorTask) error { - // 实时获取所有在线探针,避免依赖数据库状态 - onlineIDs := s.wsManager.GetAllClients() - if len(onlineIDs) == 0 { - return nil + // 确定目标探针 ID 列表 + var targetAgentIDs []string + if len(monitor.AgentIds) == 0 { + // 没有指定探针,向所有在线探针发送 + targetAgentIDs = s.wsManager.GetAllClients() + } else { + // 指定了探针 + targetAgentIDs = monitor.AgentIds } - // 查询在线探针的详细信息 - onlineAgents, err := s.agentRepo.ListByIDs(ctx, onlineIDs) - if err != nil { - s.logger.Error("获取在线探针信息失败", zap.Error(err)) - return err - } - if len(onlineAgents) == 0 { - return nil - } - - // 使用统一的方法计算目标探针 - targetAgents := s.resolveTargetAgents(monitor, onlineAgents) - if len(targetAgents) == 0 { + if len(targetAgentIDs) == 0 { return nil } @@ -368,12 +316,12 @@ func (s *MonitorService) SendMonitorTaskToAgents(ctx context.Context, monitor mo } // 向每个目标探针发送 - for _, agent := range targetAgents { - if err := s.sendMonitorConfigToAgent(agent.ID, payload); err != nil { + for _, agentID := range targetAgentIDs { + if err := s.sendMonitorConfigToAgent(agentID, payload); err != nil { s.logger.Error("发送监控配置失败", zap.String("taskID", monitor.ID), zap.String("taskName", monitor.Name), - zap.String("agentID", agent.ID), + zap.String("agentID", agentID), zap.Error(err)) } } @@ -397,12 +345,6 @@ func (s *MonitorService) GetMonitorStatsByID(ctx context.Context, monitorID stri return &overview, nil } -// GetMonitorHistory 获取监控任务的历史时序数据 -// 直接返回 VictoriaMetrics 的原始时序数据,包含所有探针的独立序列 -func (s *MonitorService) GetMonitorHistory(ctx context.Context, monitorID string, start, end int64, aggregation string) (*metric.GetMetricsResponse, error) { - return s.metricService.GetMonitorHistory(ctx, monitorID, start, end, aggregation) -} - // GetMonitorByAuth 根据认证状态获取监控任务(已登录返回全部,未登录返回公开可见) func (s *MonitorService) GetMonitorByAuth(ctx context.Context, id string, isAuthenticated bool) (*models.MonitorTask, error) { if isAuthenticated { diff --git a/internal/service/notifier.go b/internal/service/notifier.go index 7702d38..91c71bf 100644 --- a/internal/service/notifier.go +++ b/internal/service/notifier.go @@ -281,6 +281,7 @@ func (n *Notifier) sendWeCom(ctx context.Context, webhook, message string) error } var wecomAppAccessTokenCache = cache.New[string, string](time.Minute) + func (n *Notifier) getWecomAppToken(ctx context.Context, origin, corpId, corpSecret string) (string, error) { key := fmt.Sprintf("%s#%s", corpId, corpSecret) if token, found := wecomAppAccessTokenCache.Get(key); found { @@ -360,7 +361,7 @@ func (n *Notifier) sendWeComApp(ctx context.Context, origin, corpId, corpSecret } // sendFeishu 发送飞书通知 -func (n *Notifier) sendFeishu(ctx context.Context, webhook, message string) error { +func (n *Notifier) sendFeishu(ctx context.Context, webhook, signSecret, message string) error { body := map[string]interface{}{ "msg_type": "text", "content": map[string]string{ @@ -368,6 +369,23 @@ func (n *Notifier) sendFeishu(ctx context.Context, webhook, message string) erro }, } + // 如果有加签密钥,计算签名 + if signSecret != "" { + timestamp := time.Now().Unix() + stringToSign := fmt.Sprintf("%v", timestamp) + "\n" + signSecret + var data []byte + h := hmac.New(sha256.New, []byte(stringToSign)) + _, err := h.Write(data) + if err != nil { + return err + } + signature := base64.StdEncoding.EncodeToString(h.Sum(nil)) + + // 将签名和时间戳加入请求头 + body["timestamp"] = fmt.Sprintf("%v", timestamp) + body["sign"] = signature + } + _, err := n.sendJSONRequest(ctx, webhook, body) if err != nil { return err @@ -772,7 +790,10 @@ func (n *Notifier) sendFeishuByConfig(ctx context.Context, config map[string]int // 构造 Webhook URL webhook := fmt.Sprintf("https://open.feishu.cn/open-apis/bot/v2/hook/%s", secretKey) - return n.sendFeishu(ctx, webhook, message) + // 检查是否有加签密钥 + signSecret, _ := config["signSecret"].(string) + + return n.sendFeishu(ctx, webhook, signSecret, message) } // sendTelegramByConfig 根据配置发送 Telegram 通知 diff --git a/pkg/agent/config/config.go b/pkg/agent/config/config.go index b919356..d911b95 100644 --- a/pkg/agent/config/config.go +++ b/pkg/agent/config/config.go @@ -9,6 +9,7 @@ import ( "runtime" "time" + "github.com/dushixiang/pika/pkg/agent/utils" "gopkg.in/yaml.v3" ) @@ -107,10 +108,7 @@ func DefaultConfig() *Config { // GetDefaultConfigPath 获取默认配置文件路径 func GetDefaultConfigPath() string { - homeDir, err := os.UserHomeDir() - if err != nil { - homeDir = "." - } + var homeDir = utils.GetSafeHomeDir() return filepath.Join(homeDir, ".pika", "agent.yaml") } diff --git a/pkg/agent/id/id.go b/pkg/agent/id/id.go index 81ac6fc..48b833b 100644 --- a/pkg/agent/id/id.go +++ b/pkg/agent/id/id.go @@ -6,6 +6,7 @@ import ( "path/filepath" "strings" + "github.com/dushixiang/pika/pkg/agent/utils" "github.com/google/uuid" ) @@ -17,12 +18,11 @@ type Manager struct { // NewManager 创建 ID 管理器 func NewManager() *Manager { return &Manager{ - idFilePath: getIDFilePath(), + idFilePath: GetIDFilePath(), } } -// getIDFilePath 获取 ID 文件路径 -func getIDFilePath() string { +func oldGetIDFilePath() string { // 获取用户主目录 homeDir, err := os.UserHomeDir() if err != nil { @@ -34,9 +34,23 @@ func getIDFilePath() string { return filepath.Join(homeDir, ".pika", "agent.id") } +// GetIDFilePath 获取 ID 文件路径 +func GetIDFilePath() string { + // 获取用户主目录 + var homeDir = utils.GetSafeHomeDir() + // 统一使用 ~/.pika/agent.id + return filepath.Join(homeDir, ".pika", "agent.id") +} + // Load 加载或生成探针 ID // 如果 ID 文件存在,则读取;否则生成新的 UUID 并保存 func (m *Manager) Load() (string, error) { + // 尝试从旧路径迁移 + if err := m.migrateFromOldPath(); err != nil { + // 迁移失败不影响后续流程,仅记录错误 + fmt.Printf("警告: 迁移旧 ID 文件失败: %v\n", err) + } + // 尝试读取现有 ID if id, err := m.read(); err == nil && id != "" { return id, nil @@ -99,3 +113,49 @@ func (m *Manager) Exists() bool { func (m *Manager) Delete() error { return os.Remove(m.idFilePath) } + +// migrateFromOldPath 从旧路径迁移 ID 文件到新路径 +func (m *Manager) migrateFromOldPath() error { + oldPath := oldGetIDFilePath() + newPath := m.idFilePath + + // 如果旧路径和新路径相同,无需迁移 + if oldPath == newPath { + return nil + } + + // 检查旧文件是否存在 + oldInfo, err := os.Stat(oldPath) + if err != nil { + // 旧文件不存在,无需迁移 + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("检查旧 ID 文件失败: %w", err) + } + + // 确保新路径的目录存在 + newDir := filepath.Dir(newPath) + if err := os.MkdirAll(newDir, 0755); err != nil { + return fmt.Errorf("创建新目录失败: %w", err) + } + + // 强制迁移:读取旧文件内容 + data, err := os.ReadFile(oldPath) + if err != nil { + return fmt.Errorf("读取旧 ID 文件失败: %w", err) + } + + // 写入新路径(覆盖已存在的文件) + if err := os.WriteFile(newPath, data, oldInfo.Mode()); err != nil { + return fmt.Errorf("写入新 ID 文件失败: %w", err) + } + + // 删除旧文件 + if err := os.Remove(oldPath); err != nil { + fmt.Printf("警告: 删除旧 ID 文件失败: %v\n", err) + } + + fmt.Printf("已迁移 ID 文件: %s -> %s\n", oldPath, newPath) + return nil +} diff --git a/pkg/agent/service/agent.go b/pkg/agent/service/agent.go index 6ca3500..0e369c5 100644 --- a/pkg/agent/service/agent.go +++ b/pkg/agent/service/agent.go @@ -296,6 +296,8 @@ func (a *Agent) readLoop(conn *websocket.Conn, done chan struct{}) error { go a.handleTamperProtect(msg.Data) case protocol.MessageTypeDDNSConfig: go a.handleDDNSConfig(msg.Data) + case protocol.MessageTypeUninstall: + go a.handleUninstall() default: // 忽略其他类型 } @@ -829,3 +831,27 @@ func (a *Agent) getIPAddress(manager *collector.Manager, method, value string, i return "", fmt.Errorf("不支持的 IP 获取方式: %s", method) } } + +// handleUninstall 处理服务端发送的卸载指令 +func (a *Agent) handleUninstall() { + log.Println("📥 收到服务端卸载指令,开始执行卸载...") + + // 获取配置文件路径 + cfgPath := a.cfg.Path + if cfgPath == "" { + cfgPath = config.GetDefaultConfigPath() + } + + // 执行卸载操作 + if err := UninstallAgent(cfgPath); err != nil { + log.Printf("❌ 卸载失败: %v", err) + return + } + + log.Println("✅ 探针卸载成功,即将退出...") + + // 卸载成功后,触发停止信号 + if a.cancel != nil { + a.cancel() + } +} diff --git a/pkg/agent/service/service.go b/pkg/agent/service/service.go index 8638ca8..cbe4255 100644 --- a/pkg/agent/service/service.go +++ b/pkg/agent/service/service.go @@ -9,6 +9,7 @@ import ( "syscall" "github.com/dushixiang/pika/pkg/agent/config" + "github.com/dushixiang/pika/pkg/agent/id" "github.com/dushixiang/pika/pkg/agent/sysutil" "github.com/dushixiang/pika/pkg/agent/updater" "github.com/kardianos/service" @@ -235,3 +236,46 @@ func (m *ServiceManager) Run() error { return nil } + +// UninstallAgent 执行探针卸载操作(可被复用) +func UninstallAgent(cfgPath string) error { + // 加载配置 + cfg, err := config.Load(cfgPath) + if err != nil { + return fmt.Errorf("加载配置失败: %w", err) + } + + // 创建服务管理器 + mgr, err := NewServiceManager(cfg) + if err != nil { + return fmt.Errorf("创建服务管理器失败: %w", err) + } + + // 检查服务状态,如果在运行则停止 + status, err := mgr.Status() + if err != nil { + log.Printf("⚠️ 获取服务状态失败: %v", err) + } else if status != "已停止 (Stopped)" { + if err := mgr.Stop(); err != nil { + return fmt.Errorf("停止服务失败: %w", err) + } + } + + // 卸载服务 + if err := mgr.Uninstall(); err != nil { + return fmt.Errorf("卸载服务失败: %w", err) + } + + // 删除配置文件 + if err := os.Remove(cfgPath); err != nil { + log.Printf("⚠️ 删除配置文件失败: %v", err) + } + + // 删除探针 ID 文件 + idPath := id.GetIDFilePath() + if err := os.Remove(idPath); err != nil { + log.Printf("⚠️ 删除探针 ID 文件失败: %v", err) + } + + return nil +} diff --git a/pkg/agent/utils/homedir.go b/pkg/agent/utils/homedir.go new file mode 100644 index 0000000..03ad987 --- /dev/null +++ b/pkg/agent/utils/homedir.go @@ -0,0 +1,34 @@ +package utils + +import ( + "os" + "os/user" +) + +func GetSafeHomeDir() string { + // 1. 尝试从环境变量拿(最快,符合大多数 Unix/Windows 惯例) + if h, err := os.UserHomeDir(); err == nil && h != "" { + return h + } + + // 2. 尝试从系统底层数据库拿(绕过环境变量,直接看 /etc/passwd) + if u, err := user.Current(); err == nil && u.HomeDir != "" { + return u.HomeDir + } + + // 3. 特殊环境兜底(针对 OpenWrt/嵌入式等 root 为主的系统) + // 如果是 root 权限运行但没设家目录,通常就是 /root + if os.Getuid() == 0 { + if _, err := os.Stat("/root"); err == nil { + return "/root" + } + } + + // 4. 极端情况兜底:使用当前程序运行目录或临时目录 + // 保证程序至少能跑起来,而不是直接 panic 或报错退出 + if pwd, err := os.Getwd(); err == nil { + return pwd + } + + return "./" +} diff --git a/web/src/components/NetworkStatCard.tsx b/web/src/components/NetworkStatCard.tsx index dfcab91..ba7178f 100644 --- a/web/src/components/NetworkStatCard.tsx +++ b/web/src/components/NetworkStatCard.tsx @@ -27,7 +27,7 @@ const NetworkStatCard: FC = ({
-
网络统计
+
网络统计
diff --git a/web/src/components/ServerCard.tsx b/web/src/components/ServerCard.tsx index 548df35..b8b89a2 100644 --- a/web/src/components/ServerCard.tsx +++ b/web/src/components/ServerCard.tsx @@ -187,17 +187,17 @@ const ServerCard: FC = ({server}) => { {netConn.established || 0} - 已建立 + ESTABLISHED {netConn.listen || 0} - 监听 + LISTEN - - {netConn.closeWait || 0} - 等待关闭 + + {netConn.closeWait || 0} + CLOSE_WAIT
)} diff --git a/web/src/components/monitor/MonitorHero.tsx b/web/src/components/monitor/MonitorHero.tsx index 1853ea9..88efc48 100644 --- a/web/src/components/monitor/MonitorHero.tsx +++ b/web/src/components/monitor/MonitorHero.tsx @@ -5,6 +5,7 @@ import {CertBadge} from './CertBadge'; import LittleStatCard from '@/components/common/LittleStatCard'; import type {PublicMonitor} from '@/types'; import CyberCard from "@/components/CyberCard.tsx"; +import {formatDateTime} from "@/utils/util.ts"; interface MonitorHeroProps { monitor: PublicMonitor; @@ -66,14 +67,19 @@ export const MonitorHero = ({monitor, onBack}: MonitorHeroProps) => {
- {/* 证书信息(如果是 HTTPS)*/} - {monitor.type === 'https' && monitor.certExpiryTime && ( -
+ {/* 证书信息(如果存在证书数据)*/} + {monitor.certExpiryTime > 0 && ( +
SSL 证书: - +
+ + + 到期时间: {formatDateTime(monitor.certExpiryTime)} + +
)} diff --git a/web/src/pages/Agents/AgentInstall.tsx b/web/src/pages/Agents/AgentInstall.tsx index f4aaa66..78b86b8 100644 --- a/web/src/pages/Agents/AgentInstall.tsx +++ b/web/src/pages/Agents/AgentInstall.tsx @@ -122,16 +122,10 @@ const AgentInstall = () => { }), []); // 复制到剪贴板 - const copyToClipboard = useCallback(async (text: string) => { - try { - copy(text); - message.success('已复制到剪贴板'); - } catch (error) { - console.error('Failed to copy:', error); - message.error('复制失败,请手动复制'); - } - }, [message]); - + const copyToClipboard = (text: string) => { + copy(text); + message.success('已复制到剪贴板'); + } // 获取一键安装命令(使用后端检测的地址) const installCommand = useMemo(() => { if (!backendServerUrl || !selectedApiKey) { diff --git a/web/src/pages/Agents/AgentList.tsx b/web/src/pages/Agents/AgentList.tsx index aca5af9..a4d340e 100644 --- a/web/src/pages/Agents/AgentList.tsx +++ b/web/src/pages/Agents/AgentList.tsx @@ -160,7 +160,6 @@ const AgentList = () => { title: '名称', dataIndex: 'name', key: 'name', - hideInSearch: true, fixed: 'left', render: (_, record) => (
@@ -266,6 +265,7 @@ const AgentList = () => { title: '版本', dataIndex: 'version', key: 'version', + hideInSearch: true, }, { title: '最后活跃时间', diff --git a/web/src/pages/ApiKeys/ApiKeyList.tsx b/web/src/pages/ApiKeys/ApiKeyList.tsx index b482b26..e10f890 100644 --- a/web/src/pages/ApiKeys/ApiKeyList.tsx +++ b/web/src/pages/ApiKeys/ApiKeyList.tsx @@ -1,7 +1,7 @@ import {useRef, useState} from 'react'; import type {ActionType, ProColumns} from '@ant-design/pro-components'; import {ProTable} from '@ant-design/pro-components'; -import {App, Button, Divider, Form, Input, message as antMessage, Modal, Popconfirm, Tag,} from 'antd'; +import {App, Button, Divider, Form, Input, Modal, Popconfirm, Tag,} from 'antd'; import {Copy, Edit, Eye, EyeOff, Plus, Power, PowerOff, RefreshCw, Trash2} from 'lucide-react'; import { deleteApiKey, @@ -15,6 +15,7 @@ import type {ApiKey, GenerateApiKeyRequest} from '@/types'; import dayjs from 'dayjs'; import {getErrorMessage} from '@/lib/utils'; import {PageHeader} from '@/components'; +import copy from "copy-to-clipboard"; const ApiKeyList = () => { const {message: messageApi} = App.useApp(); @@ -110,8 +111,8 @@ const ApiKeyList = () => { }; const handleCopyApiKey = (key: string) => { - navigator.clipboard.writeText(key); - antMessage.success('已复制到剪贴板'); + copy(key) + messageApi.success('复制成功'); }; const toggleKeyVisibility = (id: string) => { @@ -139,7 +140,8 @@ const ApiKeyList = () => { const displayText = isVisible ? fullKey : (fullKey.length > 8 ? `${fullKey.substring(0, 8)}...` : fullKey); return (
- + {displayText}