Merge branch 'dushixiang:main' into main
This commit is contained in:
27 files changed
+624
-253
No files matched your search
+1
-1
@@ -14,7 +14,7 @@
|
||||
*~
|
||||
|
||||
# 构建产物
|
||||
bin/agents/
|
||||
# bin/agents/ 不能忽略,Agent Docker 镜像构建需要使用
|
||||
web/dist/
|
||||
web/node_modules/
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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..."
|
||||
|
||||
+2
-16
@@ -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("✅ 服务卸载成功")
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 监控配置
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 通知
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
+63
-3
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 "./"
|
||||
}
|
||||
@@ -27,7 +27,7 @@ const NetworkStatCard: FC<NetworkStatCardProps> = ({
|
||||
</div>
|
||||
<div className="relative z-10 flex justify-between items-start">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs font-bold font-mono uppercase tracking-widest opacity-70 mb-2">网络统计</div>
|
||||
<div className="text-xs font-bold font-mono uppercase tracking-widest opacity-70 mb-3">网络统计</div>
|
||||
<div className="space-y-0.5 text-xs sm:text-xs font-mono">
|
||||
<div className="flex items-center gap-1.5 sm:gap-2">
|
||||
<ArrowUp className="w-3 h-3 dark:text-blue-400 text-blue-600 flex-shrink-0"/>
|
||||
|
||||
@@ -187,17 +187,17 @@ const ServerCard: FC<ServerCardProps> = ({server}) => {
|
||||
<Network className="w-3 h-3 text-emerald-600 dark:text-emerald-400"/>
|
||||
<span
|
||||
className="text-emerald-600 dark:text-emerald-400">{netConn.established || 0}</span>
|
||||
<span className="text-gray-600 dark:text-cyan-500">已建立</span>
|
||||
<span className="text-gray-600 dark:text-cyan-500">ESTABLISHED</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Network className="w-3 h-3 text-blue-600 dark:text-blue-400"/>
|
||||
<span className="text-blue-600 dark:text-blue-400">{netConn.listen || 0}</span>
|
||||
<span className="text-gray-600 dark:text-cyan-500">监听</span>
|
||||
<span className="text-gray-600 dark:text-cyan-500">LISTEN</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Network className="w-3 h-3 text-amber-600 dark:text-amber-400"/>
|
||||
<span className="text-amber-600 dark:text-amber-400">{netConn.closeWait || 0}</span>
|
||||
<span className="text-gray-600 dark:text-cyan-500">等待关闭</span>
|
||||
<Network className="w-3 h-3 text-rose-600 dark:text-rose-400"/>
|
||||
<span className="text-rose-600 dark:text-rose-400">{netConn.closeWait || 0}</span>
|
||||
<span className="text-gray-600 dark:text-cyan-500">CLOSE_WAIT</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 证书信息(如果是 HTTPS)*/}
|
||||
{monitor.type === 'https' && monitor.certExpiryTime && (
|
||||
<div className="flex items-center gap-2 pt-4 border-t border-slate-200 dark:border-cyan-900/50">
|
||||
{/* 证书信息(如果存在证书数据)*/}
|
||||
{monitor.certExpiryTime > 0 && (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 pt-4 border-t border-slate-200 dark:border-cyan-900/50">
|
||||
<span className="text-xs text-gray-600 dark:text-cyan-500 font-mono">SSL 证书:</span>
|
||||
<CertBadge
|
||||
expiryTime={monitor.certExpiryTime}
|
||||
daysLeft={monitor.certDaysLeft}
|
||||
/>
|
||||
<div className="flex items-center gap-3">
|
||||
<CertBadge
|
||||
expiryTime={monitor.certExpiryTime}
|
||||
daysLeft={monitor.certDaysLeft}
|
||||
/>
|
||||
<span className="text-xs text-gray-500 dark:text-cyan-600 font-mono">
|
||||
到期时间: {formatDateTime(monitor.certExpiryTime)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CyberCard>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -160,7 +160,6 @@ const AgentList = () => {
|
||||
title: '名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
hideInSearch: true,
|
||||
fixed: 'left',
|
||||
render: (_, record) => (
|
||||
<div className="space-y-1">
|
||||
@@ -266,6 +265,7 @@ const AgentList = () => {
|
||||
title: '版本',
|
||||
dataIndex: 'version',
|
||||
key: 'version',
|
||||
hideInSearch: true,
|
||||
},
|
||||
{
|
||||
title: '最后活跃时间',
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-xs bg-gray-100 dark:bg-gray-800 dark:text-gray-200 px-2 py-1 rounded font-mono">
|
||||
<code
|
||||
className="text-xs bg-gray-100 dark:bg-gray-800 dark:text-gray-200 px-2 py-1 rounded font-mono">
|
||||
{displayText}
|
||||
</code>
|
||||
<Button
|
||||
@@ -357,17 +359,21 @@ const ApiKeyList = () => {
|
||||
]}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
|
||||
<div
|
||||
className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
|
||||
<p className="text-sm text-yellow-800 dark:text-yellow-200 font-medium">
|
||||
⚠️ 重要提示:请妥善保管此密钥,关闭后将无法再次查看完整密钥!
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">密钥名称</label>
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white">{newApiKeyData?.name}</div>
|
||||
<label
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">密钥名称</label>
|
||||
<div
|
||||
className="text-base font-semibold text-gray-900 dark:text-white">{newApiKeyData?.name}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">API密钥</label>
|
||||
<label
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">API密钥</label>
|
||||
<code
|
||||
className="block w-full bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 dark:text-gray-200 rounded px-3 py-2 text-sm font-mono break-all">
|
||||
{newApiKeyData?.key}
|
||||
|
||||
@@ -5,7 +5,7 @@ import {App, Button, Divider, Form, Input, InputNumber, Modal, Select, Space, Sw
|
||||
import {PageHeader} from '@/components';
|
||||
import {Edit, MinusCircle, Plus, PlusCircle, RefreshCw, Trash2} from 'lucide-react';
|
||||
import dayjs from 'dayjs';
|
||||
import {getAgentPaging, getTags} from '@/api/agent.ts';
|
||||
import {getAgentPaging} from '@/api/agent.ts';
|
||||
import type {Agent, MonitorTask, MonitorTaskRequest} from '@/types';
|
||||
import {createMonitor, deleteMonitor, listMonitors, updateMonitor} from '@/api/monitor.ts';
|
||||
import {getErrorMessage} from '@/lib/utils';
|
||||
@@ -23,7 +23,6 @@ const MonitorList = () => {
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
const [loadingAgents, setLoadingAgents] = useState(false);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [existingTags, setExistingTags] = useState<string[]>([]);
|
||||
|
||||
const loadAgents = useCallback(async () => {
|
||||
try {
|
||||
@@ -41,19 +40,6 @@ const MonitorList = () => {
|
||||
void loadAgents();
|
||||
}, [loadAgents]);
|
||||
|
||||
// 加载已有的标签
|
||||
useEffect(() => {
|
||||
const loadTags = async () => {
|
||||
try {
|
||||
const response = await getTags();
|
||||
setExistingTags(response.data.tags || []);
|
||||
} catch (error) {
|
||||
console.error('加载标签失败:', error);
|
||||
}
|
||||
};
|
||||
void loadTags();
|
||||
}, []);
|
||||
|
||||
const agentOptions = useMemo(
|
||||
() =>
|
||||
agents.map((agent) => ({
|
||||
@@ -455,19 +441,6 @@ const MonitorList = () => {
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="探针标签"
|
||||
name="tags"
|
||||
extra="选择标签后,拥有这些标签的探针都会执行此监控。若同时选择探针和标签,则两者取并集(自动去重)。若都不选择,则所有探针都会执行"
|
||||
>
|
||||
<Select
|
||||
mode="multiple"
|
||||
placeholder="选择标签(可多选)"
|
||||
options={existingTags.map(tag => ({label: tag, value: tag}))}
|
||||
allowClear
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="检测频率 (秒)"
|
||||
name="interval"
|
||||
|
||||
@@ -35,7 +35,6 @@ interface Stats {
|
||||
}
|
||||
|
||||
const MonitorList = () => {
|
||||
const [filter, setFilter] = useState<FilterStatus>('all');
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
const [displayMode, setDisplayMode] = useState<DisplayMode>('max');
|
||||
|
||||
@@ -54,11 +53,6 @@ const MonitorList = () => {
|
||||
const filteredMonitors = useMemo(() => {
|
||||
let result = monitors;
|
||||
|
||||
// 状态过滤
|
||||
if (filter !== 'all') {
|
||||
result = result.filter(m => m.status === filter);
|
||||
}
|
||||
|
||||
// 搜索过滤
|
||||
if (searchKeyword.trim()) {
|
||||
const keyword = searchKeyword.toLowerCase();
|
||||
@@ -69,7 +63,7 @@ const MonitorList = () => {
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [monitors, filter, searchKeyword]);
|
||||
}, [monitors, searchKeyword]);
|
||||
|
||||
// 统计信息
|
||||
const calculateStats = (monitors: PublicMonitor[]) => {
|
||||
@@ -131,28 +125,6 @@ const MonitorList = () => {
|
||||
{/* 过滤和搜索 */}
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-6">
|
||||
<div className="flex flex-wrap gap-4 items-center w-full md:w-auto">
|
||||
{/* 状态过滤 */}
|
||||
<div
|
||||
className="flex gap-2 bg-slate-100 dark:bg-black/40 p-1 rounded-lg border border-slate-200 dark:border-cyan-900/50">
|
||||
{(['all', 'up', 'down', 'unknown'] as const).map(f => {
|
||||
const labels = {all: '全部', up: '正常', down: '异常', unknown: '未知'};
|
||||
return (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={cn(
|
||||
"px-4 py-1.5 rounded-md text-xs font-medium transition-all font-mono cursor-pointer",
|
||||
filter === f
|
||||
? 'bg-gray-200 dark:bg-cyan-500/20 text-gray-800 dark:text-cyan-300 border border-gray-300 dark:border-cyan-500/30'
|
||||
: 'text-gray-600 dark:text-cyan-500 hover:text-gray-800 dark:hover:text-cyan-400'
|
||||
)}
|
||||
>
|
||||
{labels[f]}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 显示模式切换 */}
|
||||
<div className="flex gap-1 bg-slate-100 dark:bg-black/40 p-1 rounded-lg border border-slate-200 dark:border-cyan-900/50 items-center">
|
||||
<span className="text-xs text-gray-600 dark:text-cyan-500 px-2 font-mono">卡片指标:</span>
|
||||
|
||||
@@ -253,7 +253,7 @@ const ServerList = () => {
|
||||
<th className="p-5 font-bold w-[250px]">Identity</th>
|
||||
<th className="p-5 font-bold">Telemetry</th>
|
||||
<th className="p-5 font-bold w-[220px]">I/O Rate</th>
|
||||
<th className="p-5 font-bold w-[150px]">Status</th>
|
||||
<th className="p-5 font-bold w-[150px]">Network</th>
|
||||
<th className="p-5 font-bold w-[200px]">Meta / Tags</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -399,17 +399,17 @@ const ServerList = () => {
|
||||
<Network className="w-3 h-3 text-emerald-600 dark:text-emerald-400"/>
|
||||
<span
|
||||
className="text-emerald-600 dark:text-emerald-400">{netConn.established || 0}</span>
|
||||
<span className="text-gray-600 dark:text-cyan-500">已建立</span>
|
||||
<span className="text-gray-600 dark:text-cyan-500">ESTABLISHED</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Network className="w-3 h-3 text-blue-600 dark:text-blue-400"/>
|
||||
<span className="text-blue-600 dark:text-blue-400">{netConn.listen || 0}</span>
|
||||
<span className="text-gray-600 dark:text-cyan-500">监听中</span>
|
||||
<span className="text-gray-600 dark:text-cyan-500">LISTEN</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Network className="w-3 h-3 text-rose-600 dark:text-rose-400"/>
|
||||
<span className="text-rose-600 dark:text-rose-400">{netConn.closeWait || 0}</span>
|
||||
<span className="text-gray-600 dark:text-cyan-500">等待关闭</span>
|
||||
<span className="text-gray-600 dark:text-cyan-500">CLOSE_WAIT</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -429,7 +429,11 @@ const ServerList = () => {
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className={`text-xs font-mono flex items-center gap-1 ${server.expireTime && server.expireTime > 0 ? 'text-cyan-600 dark:text-cyan-500' : 'text-emerald-600 dark:text-emerald-400/80'}`}>
|
||||
className={cn(
|
||||
`text-xs font-mono flex items-center gap-1 text-gray-600 dark:text-cyan-500`,
|
||||
// 剩余时间小于 30 天时显示为红色
|
||||
server.expireTime && server.expireTime > 0 && server.expireTime - Date.now() < 30 * 24 * 60 * 60 * 1000 ? 'text-red-600 dark:text-red-400' : ''
|
||||
)}>
|
||||
|
||||
{server.expireTime > 0 &&
|
||||
<div className={'flex items-center gap-1'}>
|
||||
|
||||
@@ -266,23 +266,42 @@ const SystemConfigComponent = () => {
|
||||
{({ getFieldValue }) => {
|
||||
const systemNameEn = getFieldValue('systemNameEn') || '';
|
||||
const systemNameZh = getFieldValue('systemNameZh') || '';
|
||||
|
||||
// 与 PublicHeader 相同的分割逻辑
|
||||
let leftName = '';
|
||||
let rightName = '';
|
||||
|
||||
if (systemNameEn) {
|
||||
// 优先在空格处分割
|
||||
const spaceIndex = systemNameEn.indexOf(' ');
|
||||
if (spaceIndex > 0) {
|
||||
leftName = systemNameEn.substring(0, spaceIndex);
|
||||
rightName = systemNameEn.substring(spaceIndex); // 保留空格
|
||||
} else {
|
||||
// 如果没有空格,从中间分割
|
||||
const mid = Math.floor(systemNameEn.length / 2);
|
||||
leftName = systemNameEn.substring(0, mid);
|
||||
rightName = systemNameEn.substring(mid);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 p-4 bg-slate-50 rounded-lg">
|
||||
<div className="flex items-center gap-3 p-4 bg-slate-50 dark:bg-slate-800 rounded-lg">
|
||||
<img
|
||||
src={getLogoUrl()}
|
||||
alt="Logo 预览"
|
||||
className="h-10 w-10 object-contain rounded-md"
|
||||
className="h-8 w-8 sm:h-9 sm:w-9 object-contain rounded-md"
|
||||
onError={(e) => {
|
||||
e.currentTarget.src = '/logo.png';
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-blue-600">
|
||||
{systemNameEn}
|
||||
</p>
|
||||
<h1 className="text-sm font-bold text-slate-900">
|
||||
{systemNameZh}
|
||||
<h1 className="text-2xl font-black tracking-widest text-transparent bg-clip-text bg-gradient-to-r from-cyan-500 via-blue-500 to-purple-500 dark:from-cyan-400 dark:via-blue-400 dark:to-purple-400 uppercase italic">
|
||||
{leftName}<span className="text-slate-800 dark:text-white">{rightName}</span>
|
||||
</h1>
|
||||
<p className="text-xs text-slate-500 dark:text-cyan-500 font-mono tracking-[0.3em] uppercase">
|
||||
{systemNameZh}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user