Merge branch 'dushixiang:main' into main

This commit is contained in:
榆木 authored and GitHub committed 2025-12-26 19:27:26 +08:00
commit d763609fdc
27 files changed
+624 -253

No files matched your search

+1 -1
View File
@@ -14,7 +14,7 @@
*~
# 构建产物
bin/agents/
# bin/agents/ 不能忽略,Agent Docker 镜像构建需要使用
web/dist/
web/node_modules/
+150
View File
@@ -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
+23
View File
@@ -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"]
+10 -18
View File
@@ -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
View File
@@ -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("✅ 服务卸载成功")
}
+28 -8
View File
@@ -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
+1 -1
View File
@@ -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
}
-1
View File
@@ -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 监控配置
+1
View File
@@ -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"
+119 -18
View File
@@ -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
+22 -80
View File
@@ -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 {
+23 -2
View File
@@ -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 通知
+2 -4
View File
@@ -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
View File
@@ -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
}
+26
View File
@@ -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()
}
}
+44
View File
@@ -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
}
+34
View File
@@ -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 "./"
}
+1 -1
View File
@@ -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"/>
+5 -5
View File
@@ -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>
)}
+13 -7
View File
@@ -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>
+4 -10
View File
@@ -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) {
+1 -1
View File
@@ -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: '最后活跃时间',
+14 -8
View File
@@ -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}
+1 -28
View File
@@ -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"
+1 -29
View File
@@ -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>
+9 -5
View File
@@ -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'}>
+26 -7
View File
@@ -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>
);