Merge pull request #8 from dushixiang/victoriametrics

Victoriametrics
This commit is contained in:
dushixiang authored and GitHub committed 2025-12-14 22:50:17 +08:00
commit 833b99cea0
99 files changed
+7107 -6599

No files matched your search

+1
View File
@@ -39,3 +39,4 @@ go.work.sum
config.yaml
agent.yaml
playground
+174 -2
View File
@@ -1,7 +1,179 @@
package main
import "github.com/dushixiang/pika/internal"
import (
"fmt"
"log"
"os"
"time"
"github.com/dushixiang/pika/internal"
"github.com/dushixiang/pika/internal/config"
v0_0_13 "github.com/dushixiang/pika/internal/migrate/v0_0_13"
"github.com/dushixiang/pika/internal/vmclient"
"github.com/go-orz/orz"
"github.com/spf13/cobra"
"go.uber.org/zap"
"gorm.io/gorm"
)
var (
configFile string
rootCmd = &cobra.Command{
Use: "pika-server",
Short: "Pika 服务器监控系统",
Long: `Pika 是一个轻量级的服务器监控系统,支持多探针部署和实时监控。`,
Run: func(cmd *cobra.Command, args []string) {
internal.Run(configFile)
},
}
serveCmd = &cobra.Command{
Use: "serve",
Short: "启动 Pika 服务器",
Long: `启动 Pika HTTP 服务器,提供 Web 界面和 API 服务。`,
Run: func(cmd *cobra.Command, args []string) {
internal.Run(configFile)
},
}
migrateCmd = &cobra.Command{
Use: "migrate_v0_0_13",
Short: "执行数据库迁移",
Long: `将历史 PostgreSQL 指标数据迁移到 VictoriaMetrics(仅需执行一次)。`,
Run: func(cmd *cobra.Command, args []string) {
runMigration(configFile)
},
}
)
func init() {
// 添加全局配置文件参数
rootCmd.PersistentFlags().StringVarP(&configFile, "config", "c", "./config.yaml", "配置文件路径")
// 添加子命令
rootCmd.AddCommand(serveCmd)
rootCmd.AddCommand(migrateCmd)
}
func main() {
internal.Run("./config.yaml")
if err := rootCmd.Execute(); err != nil {
fmt.Fprintf(os.Stderr, "错误: %v\n", err)
os.Exit(1)
}
}
// runMigration 执行数据迁移
func runMigration(configPath string) {
fmt.Println("=== Pika 数据迁移工具 ===")
fmt.Println("配置文件:", configPath)
fmt.Println()
// 初始化 orz 以获取数据库连接和配置
var db *gorm.DB
var vmClient *vmclient.VMClient
var logger *zap.Logger
err := orz.Quick(configPath, func(app *orz.App) error {
logger = app.Logger()
db = app.GetDatabase()
// 读取 VictoriaMetrics 配置
var appConfig config.AppConfig
_config := app.GetConfig()
if _config != nil {
if err := _config.App.Unmarshal(&appConfig); err != nil {
return fmt.Errorf("读取配置失败: %w", err)
}
}
// 初始化 VictoriaMetrics 客户端
vmClient = provideVMClient(&appConfig, logger)
return nil
})
if err != nil {
log.Fatalf("初始化失败: %v", err)
}
// 执行迁移
fmt.Println("开始迁移历史指标数据到 VictoriaMetrics...")
fmt.Println("说明: 数据将从 PostgreSQL 迁移到 VictoriaMetrics。")
fmt.Println("警告: 建议在迁移前备份 PostgreSQL 数据!")
fmt.Println()
fmt.Print("是否在迁移完成后自动删除旧的数据表?(yes/no): ")
var deleteOldTables bool
var answer string
fmt.Scanln(&answer)
if answer == "yes" || answer == "y" || answer == "Y" {
deleteOldTables = true
fmt.Println("✓ 将在迁移完成后自动删除旧表")
} else {
deleteOldTables = false
fmt.Println("✓ 将保留旧表,您可以稍后手动删除")
}
fmt.Println()
startTime := time.Now()
// 执行数据迁移
if err := v0_0_13.Migrate(db, vmClient); err != nil {
logger.Error("迁移失败", zap.Error(err))
log.Fatalf("迁移失败: %v", err)
}
elapsed := time.Since(startTime)
fmt.Println()
fmt.Printf("✓ 数据迁移成功完成!耗时: %s\n", elapsed)
// 根据用户选择决定是否删除旧表
if deleteOldTables {
fmt.Println()
fmt.Println("正在删除旧的指标数据表...")
if err := v0_0_13.DropOldTables(db); err != nil {
logger.Error("删除旧表失败", zap.Error(err))
log.Printf("警告: 删除旧表失败: %v\n", err)
fmt.Println("注意: 旧表删除失败,但数据已成功迁移,您可以手动删除这些表。")
} else {
fmt.Println("✓ 旧表删除完成")
fmt.Println()
fmt.Println("✓ 迁移任务全部完成!")
}
} else {
fmt.Println()
fmt.Println("提示: 旧数据表已保留,您可以验证数据后手动删除。")
fmt.Println("提示: 如需删除旧表,可以重新运行此命令并选择删除。")
}
fmt.Println()
fmt.Println("提示: 现在可以启动服务器: ./pika-server serve")
}
// provideVMClient 提供 VictoriaMetrics 客户端
func provideVMClient(cfg *config.AppConfig, logger *zap.Logger) *vmclient.VMClient {
// 检查配置
if cfg.VictoriaMetrics == nil || !cfg.VictoriaMetrics.Enabled {
logger.Info("VictoriaMetrics is not enabled, using default configuration")
// 返回一个默认配置的客户端(用于本地开发)
return vmclient.NewVMClient("http://localhost:8428", 30*time.Second, 60*time.Second)
}
// 使用配置创建客户端
writeTimeout := time.Duration(cfg.VictoriaMetrics.WriteTimeout) * time.Second
if writeTimeout == 0 {
writeTimeout = 30 * time.Second
}
queryTimeout := time.Duration(cfg.VictoriaMetrics.QueryTimeout) * time.Second
if queryTimeout == 0 {
queryTimeout = 60 * time.Second
}
logger.Info("VictoriaMetrics client initialized",
zap.String("url", cfg.VictoriaMetrics.URL),
zap.Duration("writeTimeout", writeTimeout),
zap.Duration("queryTimeout", queryTimeout))
return vmclient.NewVMClient(cfg.VictoriaMetrics.URL, writeTimeout, queryTimeout)
}
+16 -1
View File
@@ -20,7 +20,20 @@ services:
restart: unless-stopped
networks:
- pika-network
victoriametrics:
image: victoriametrics/victoria-metrics:v1.131.0
container_name: victoriametrics
restart: always
command:
- "-retentionPeriod=7d" # 保留 7 天数据
- "-storageDataPath=/victoria-metrics-data"
- "-httpListenAddr=:8428"
ports:
- "127.0.0.1:8428:8428" # API, PromQL, insert, select
volumes:
- ./data/vmdata:/victoria-metrics-data
networks:
- pika-network
pika:
image: dushixiang/pika:latest
container_name: pika-server
@@ -33,6 +46,8 @@ services:
depends_on:
postgresql:
condition: service_healthy
victoriametrics:
condition: service_started
restart: unless-stopped
networks:
- pika-network
+14 -13
View File
@@ -1,6 +1,6 @@
module github.com/dushixiang/pika
go 1.25.0
go 1.25.5
require (
github.com/coreos/go-oidc/v3 v3.17.0
@@ -11,15 +11,15 @@ require (
github.com/go-orz/toolkit v0.1.0
github.com/go-playground/locales v0.14.1
github.com/go-playground/universal-translator v0.18.1
github.com/go-playground/validator/v10 v10.28.0
github.com/go-playground/validator/v10 v10.29.0
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/google/uuid v1.6.0
github.com/google/wire v0.7.0
github.com/gorilla/websocket v1.5.3
github.com/jpillora/backoff v1.0.0
github.com/kardianos/service v1.2.4
github.com/labstack/echo/v4 v4.13.4
github.com/libdns/alidns v1.0.6-beta.2
github.com/labstack/echo/v4 v4.14.0
github.com/libdns/alidns v1.0.6-beta.3
github.com/libdns/cloudflare v0.2.2
github.com/libdns/huaweicloud v1.0.0
github.com/libdns/libdns v1.1.1
@@ -33,8 +33,8 @@ require (
github.com/spf13/cobra v1.10.2
github.com/valyala/fasttemplate v1.2.2
go.uber.org/zap v1.27.1
golang.org/x/crypto v0.45.0
golang.org/x/oauth2 v0.33.0
golang.org/x/crypto v0.46.0
golang.org/x/oauth2 v0.34.0
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
gopkg.in/yaml.v3 v3.0.1
gorm.io/datatypes v1.2.7
@@ -46,7 +46,7 @@ require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ebitengine/purego v0.9.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.11 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/glebarez/go-sqlite v1.22.0 // indirect
github.com/glebarez/sqlite v1.11.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
@@ -72,6 +72,7 @@ require (
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rogpeppe/go-internal v1.13.1 // indirect
github.com/sagikazarmark/locafero v0.12.0 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/spf13/viper v1.21.0 // indirect
@@ -82,12 +83,12 @@ require (
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.31.0 // indirect
golang.org/x/time v0.11.0 // indirect
golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
golang.org/x/time v0.14.0 // indirect
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
gorm.io/driver/mysql v1.6.0 // indirect
+28
View File
@@ -19,6 +19,8 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
@@ -43,6 +45,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
github.com/go-playground/validator/v10 v10.29.0 h1:lQlF5VNJWNlRbRZNeOIkWElR+1LL/OuHcc0Kp14w1xk=
github.com/go-playground/validator/v10 v10.29.0/go.mod h1:D6QxqeMlgIPuT02L66f2ccrZ7AGgHkzKmmTMZhk/Kc4=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
@@ -90,12 +94,16 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA=
github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ=
github.com/labstack/echo/v4 v4.14.0 h1:+tiMrDLxwv6u0oKtD03mv+V1vXXB3wCqPHJqPuIe+7M=
github.com/labstack/echo/v4 v4.14.0/go.mod h1:xmw1clThob0BSVRX1CRQkGQ/vjwcpOMjQZSZa9fKA/c=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/libdns/alidns v1.0.6-beta.2 h1:cRhpaFGoQfGgsVWj+rpUdmbP74BCsH96tv3ZOwBPf38=
github.com/libdns/alidns v1.0.6-beta.2/go.mod h1:RECwyQ88e9VqQVtSrvX76o1ux3gQUKGzMgxICi+u7Ec=
github.com/libdns/alidns v1.0.6-beta.3 h1:KAmb7FQ1tRzKsaAUGa7ZpGKAMRANwg7+1c7tUbSELq8=
github.com/libdns/alidns v1.0.6-beta.3/go.mod h1:RECwyQ88e9VqQVtSrvX76o1ux3gQUKGzMgxICi+u7Ec=
github.com/libdns/cloudflare v0.2.2 h1:XWHv+C1dDcApqazlh08Q6pjytYLgR2a+Y3xrXFu0vsI=
github.com/libdns/cloudflare v0.2.2/go.mod h1:w9uTmRCDlAoafAsTPnn2nJ0XHK/eaUMh86DUk8BWi60=
github.com/libdns/huaweicloud v1.0.0 h1:BQUIkOAjF++ouiANRIE3jdMlCfeiAAr6tGqb+8hM1jo=
@@ -141,6 +149,8 @@ github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88ee
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
github.com/shirou/gopsutil/v4 v4.25.11 h1:X53gB7muL9Gnwwo2evPSE+SfOrltMoR6V3xJAXZILTY=
github.com/shirou/gopsutil/v4 v4.25.11/go.mod h1:EivAfP5x2EhLp2ovdpKSozecVXn1TmuG7SMzs/Wh4PU=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
@@ -182,18 +192,29 @@ golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWP
golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39 h1:DHNhtq3sNNzrvduZZIiFyXWOL9IWaDPHqTnLJp+rCBY=
golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 h1:MDfG8Cvcqlt9XXrmEiD4epKn7VJHZO84hejP9Jmp0MM=
golang.org/x/exp v0.0.0-20251209150349-8475f28825e9/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo=
golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -205,17 +226,24 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
+17 -75
View File
@@ -81,12 +81,6 @@ func setup(app *orz.App) error {
// 启动WebSocket管理器
go components.WSManager.Run(ctx)
// 启动数据清理任务
go components.MetricService.StartCleanupTask(ctx)
// 启动聚合下采样任务
go components.MetricService.StartAggregationTask(ctx)
// 启动指标监控任务(用于告警检测)
go startMetricsMonitoring(ctx, components, app.Logger())
@@ -96,9 +90,6 @@ func setup(app *orz.App) error {
components.MonitorService.SetScheduler(monitorScheduler)
monitorScheduler.Start(ctx)
// 启动监控统计计算任务
go startMonitorStatsCalculation(ctx, components, app.Logger())
// 启动流量重置检查任务(每小时检查一次)
go startTrafficResetCheck(ctx, components, app.Logger())
@@ -204,6 +195,7 @@ func setupApi(app *orz.App, components *AppComponents) {
publicApiWithOptionalAuth.GET("/monitors/:id/stats", components.MonitorHandler.GetStatsByID)
publicApiWithOptionalAuth.GET("/monitors/:id/agents", components.MonitorHandler.GetAgentStatsByID)
publicApiWithOptionalAuth.GET("/monitors/:id/history", components.MonitorHandler.GetHistoryByID)
publicApiWithOptionalAuth.GET("/monitors/:id", components.MonitorHandler.GetDetail) // 整合版接口
// Logo(公开访问)- 用于公共页面只获取 Logo
publicApiWithOptionalAuth.GET("/logo", components.PropertyHandler.GetLogo)
@@ -303,40 +295,19 @@ func setupApi(app *orz.App, components *AppComponents) {
func autoMigrate(database *gorm.DB) error {
// 自动迁移数据库表
return database.AutoMigrate(
&models.Agent{},
&models.ApiKey{},
&models.CPUMetric{},
&models.MemoryMetric{},
&models.DiskMetric{},
&models.NetworkMetric{},
&models.NetworkConnectionMetric{},
&models.DiskIOMetric{},
&models.GPUMetric{},
&models.TemperatureMetric{},
&models.HostMetric{},
&models.AuditResult{},
&models.Property{},
&models.AlertRecord{},
&models.AlertState{},
&models.MonitorMetric{},
&models.MonitorTask{},
&models.MonitorStats{},
&models.TamperProtectConfig{},
&models.TamperEvent{},
&models.TamperAlert{},
&models.DDNSConfig{},
&models.DDNSRecord{},
// 聚合表
&models.AggregatedCPUMetricModel{},
&models.AggregatedMemoryMetricModel{},
&models.AggregatedDiskMetricModel{},
&models.AggregatedNetworkMetricModel{},
&models.AggregatedNetworkConnectionMetricModel{},
&models.AggregatedDiskIOMetricModel{},
&models.AggregatedGPUMetricModel{},
&models.AggregatedTemperatureMetricModel{},
&models.AggregatedMonitorMetricModel{},
&models.AggregationProgress{},
&models.Agent{}, // 探针
&models.ApiKey{}, // ApiKey
&models.HostMetric{}, // 保留主机静态信息表
&models.AuditResult{}, // 审计历史
&models.Property{}, // 系统属性
&models.AlertRecord{}, // 告警记录
&models.AlertState{}, // 告警状态
&models.MonitorTask{}, // 服务监控
&models.TamperProtectConfig{}, // 防篡改配置
&models.TamperEvent{}, // 防篡改事件
&models.TamperAlert{}, // 防篡改告警
&models.DDNSConfig{}, // DDNS 配置
&models.DDNSRecord{}, // DDNS 记录
)
}
@@ -401,9 +372,9 @@ func startMetricsMonitoring(ctx context.Context, components *AppComponents, logg
for _, agent := range agents {
// 获取最新指标
latest, err := components.MetricService.GetLatestMetrics(ctx, agent.ID)
if err != nil {
logger.Debug("获取探针最新指标失败", zap.String("agentId", agent.ID), zap.Error(err))
latest, ok := components.MetricService.GetLatestMetrics(agent.ID)
if !ok {
logger.Debug("获取探针最新指标失败", zap.String("agentId", agent.ID))
continue
}
if latest == nil {
@@ -445,35 +416,6 @@ func startMetricsMonitoring(ctx context.Context, components *AppComponents, logg
}
}
// startMonitorStatsCalculation 启动监控统计计算任务
func startMonitorStatsCalculation(ctx context.Context, components *AppComponents, logger *zap.Logger) {
logger.Info("启动监控统计计算任务")
ticker := time.NewTicker(5 * time.Minute) // 每5分钟计算一次统计数据
defer ticker.Stop()
// 首次启动时立即计算一次
if err := components.MonitorService.CalculateMonitorStats(ctx); err != nil {
logger.Error("计算监控统计数据失败", zap.Error(err))
} else {
logger.Info("监控统计数据计算完成")
}
for {
select {
case <-ctx.Done():
logger.Info("监控统计计算任务已停止")
return
case <-ticker.C:
if err := components.MonitorService.CalculateMonitorStats(ctx); err != nil {
logger.Error("计算监控统计数据失败", zap.Error(err))
} else {
logger.Debug("监控统计数据计算完成")
}
}
}
}
// startTrafficResetCheck 启动流量重置检查定时任务
func startTrafficResetCheck(ctx context.Context, components *AppComponents, logger *zap.Logger) {
logger.Info("启动流量重置检查任务")
+15 -5
View File
@@ -2,11 +2,12 @@ package config
// AppConfig 应用配置
type AppConfig struct {
JWT JWTConfig `json:"JWT"`
Users map[string]string `json:"Users"` // 用户名 -> bcrypt加密的密码
OIDC *OIDCConfig `json:"OIDC"` // OIDC配置(可选)
GitHub *GitHubOAuthConfig `json:"GitHub"` // GitHub OAuth配置(可选)
GeoIP *GeoIPConfig `json:"GeoIP"` // GeoIP配置(可选)
JWT JWTConfig `json:"JWT"`
Users map[string]string `json:"Users"` // 用户名 -> bcrypt加密的密码
OIDC *OIDCConfig `json:"OIDC"` // OIDC配置(可选)
GitHub *GitHubOAuthConfig `json:"GitHub"` // GitHub OAuth配置(可选)
GeoIP *GeoIPConfig `json:"GeoIP"` // GeoIP配置(可选)
VictoriaMetrics *VMConfig `json:"VictoriaMetrics"` // VictoriaMetrics配置(可选)
}
// JWTConfig JWT配置
@@ -39,3 +40,12 @@ type GeoIPConfig struct {
DBPath string `json:"DBPath"` // GeoIP数据库文件路径(如:GeoLite2-City.mmdb)
DBLanguage string `json:"DBLanguage"` // 数据库语言(如:zh-CN、en)
}
// VMConfig VictoriaMetrics配置
type VMConfig struct {
Enabled bool `json:"Enabled"` // 是否启用VictoriaMetrics
URL string `json:"URL"` // VictoriaMetrics地址
RetentionDays int `json:"RetentionDays"` // 数据保留天数(用于文档说明)
WriteTimeout int `json:"WriteTimeout"` // 写入超时(秒)
QueryTimeout int `json:"QueryTimeout"` // 查询超时(秒)
}
+28 -90
View File
@@ -80,7 +80,7 @@ func (h *AgentHandler) HandleWebSocket(c echo.Context) error {
conn.SetReadDeadline(time.Time{})
// 解析注册消息
var msg protocol.Message
var msg protocol.InputMessage
if err := json.Unmarshal(message, &msg); err != nil {
h.logger.Error("failed to parse register message", zap.Error(err))
conn.Close()
@@ -104,8 +104,6 @@ func (h *AgentHandler) HandleWebSocket(c echo.Context) error {
// 注册探针 - 使用独立的context,不依赖HTTP请求的context
agent, err := h.agentService.RegisterAgent(context.Background(), c.RealIP(), &registerReq.AgentInfo, registerReq.ApiKey)
if err != nil {
h.logger.Error("failed to register agent", zap.Error(err))
// 发送注册失败响应
h.sendRegisterError(conn, err.Error())
conn.Close()
@@ -156,11 +154,15 @@ func (h *AgentHandler) handleWebSocketMessage(ctx context.Context, agentID strin
case protocol.MessageTypeMetrics:
// 指标数据
var metricsWrapper protocol.MetricsWrapper
var metricsWrapper protocol.MetricsPayload
if err := json.Unmarshal(data, &metricsWrapper); err != nil {
return err
}
return h.metricService.HandleMetricData(ctx, agentID, string(metricsWrapper.Type), metricsWrapper.Data)
metricsData, err := json.Marshal(metricsWrapper.Data)
if err != nil {
return err
}
return h.metricService.HandleMetricData(ctx, agentID, string(metricsWrapper.Type), json.RawMessage(metricsData))
case protocol.MessageTypeCommandResp:
// 指令响应
@@ -232,21 +234,10 @@ func (h *AgentHandler) sendRegisterSuccess(conn *websocket.Conn, agentID string)
AgentID: agentID,
Status: "success",
}
respData, err := json.Marshal(resp)
if err != nil {
return err
}
msg := protocol.Message{
return conn.WriteJSON(protocol.OutboundMessage{
Type: protocol.MessageTypeRegisterAck,
Data: respData,
}
msgData, err := json.Marshal(msg)
if err != nil {
return err
}
return conn.WriteMessage(websocket.TextMessage, msgData)
Data: resp,
})
}
// sendRegisterError 发送注册失败响应
@@ -255,15 +246,11 @@ func (h *AgentHandler) sendRegisterError(conn *websocket.Conn, errMsg string) er
Status: "error",
Message: errMsg,
}
respData, _ := json.Marshal(resp)
msg := protocol.Message{
return conn.WriteJSON(protocol.OutboundMessage{
Type: protocol.MessageTypeRegisterErr,
Data: respData,
}
msgData, _ := json.Marshal(msg)
return conn.WriteMessage(websocket.TextMessage, msgData)
Data: resp,
})
}
// sendTamperConfig 发送防篡改配置(探针初始化时发送完整配置作为新增)
@@ -288,17 +275,10 @@ func (h *AgentHandler) sendTamperConfig(conn *websocket.Conn, agentID string) er
Removed: []string{}, // 初始化时没有需要移除的
}
data, err := json.Marshal(configData)
if err != nil {
return err
}
msg := protocol.Message{
msgData, err := json.Marshal(protocol.OutboundMessage{
Type: protocol.MessageTypeTamperProtect,
Data: data,
}
msgData, err := json.Marshal(msg)
Data: configData,
})
if err != nil {
return err
}
@@ -428,7 +408,7 @@ func (h *AgentHandler) GetMetrics(c echo.Context) error {
// 验证指标类型
validTypes := map[string]bool{
"cpu": true, "memory": true, "disk": true, "network": true, "network_connection": true,
"disk_io": true, "gpu": true, "temperature": true,
"disk_io": true, "gpu": true, "temperature": true, "monitor": true,
}
if metricType == "" {
return orz.NewError(400, "指标类型不能为空")
@@ -444,20 +424,13 @@ func (h *AgentHandler) GetMetrics(c echo.Context) error {
}
// GetMetrics 内部会自动计算最优聚合间隔
metrics, err := h.metricService.GetMetrics(ctx, agentID, metricType, start, end, 0, interfaceName)
metrics, err := h.metricService.GetMetrics(ctx, agentID, metricType, start, end, interfaceName)
if err != nil {
return err
}
return orz.Ok(c, orz.Map{
"agentId": agentID,
"type": metricType,
"range": rangeParam,
"start": start,
"end": end,
"interface": interfaceName,
"metrics": metrics,
})
// 直接返回 GetMetricsResponse,避免额外嵌套
return orz.Ok(c, metrics)
}
// GetLatestMetrics 获取探针最新指标(公开接口,已登录返回全部,未登录返回公开可见)
@@ -470,9 +443,9 @@ func (h *AgentHandler) GetLatestMetrics(c echo.Context) error {
return err
}
metrics, err := h.metricService.GetLatestMetrics(ctx, id)
if err != nil {
return err
metrics, ok := h.metricService.GetLatestMetrics(id)
if !ok {
return orz.NewError(404, "探针最新指标不存在")
}
return orz.Ok(c, metrics)
@@ -541,8 +514,8 @@ func (h *AgentHandler) GetAgents(c echo.Context) error {
}
// 获取最新指标数据
metrics, err := h.metricService.GetLatestMetrics(ctx, agent.ID)
if err == nil && metrics != nil {
metrics, ok := h.metricService.GetLatestMetrics(agent.ID)
if ok {
item["metrics"] = metrics
}
@@ -604,17 +577,10 @@ func (h *AgentHandler) SendCommand(c echo.Context) error {
Type: cmdType,
}
reqData, err := json.Marshal(cmdReq)
if err != nil {
return err
}
msg := protocol.Message{
msgData, err := json.Marshal(protocol.OutboundMessage{
Type: protocol.MessageTypeCommand,
Data: reqData,
}
msgData, err := json.Marshal(msg)
Data: cmdReq,
})
if err != nil {
return err
}
@@ -706,34 +672,6 @@ func (h *AgentHandler) GetStatistics(c echo.Context) error {
return orz.Ok(c, stats)
}
// GetMonitorMetrics 获取监控指标数据
func (h *AgentHandler) GetMonitorMetrics(c echo.Context) error {
agentID := c.Param("id")
monitorName := c.QueryParam("name")
rangeParam := c.QueryParam("range")
ctx := c.Request().Context()
// 解析时间范围
start, end, err := parseTimeRange(rangeParam)
if err != nil {
return orz.NewError(400, err.Error())
}
metrics, err := h.agentService.GetMonitorMetrics(ctx, agentID, monitorName, start, end)
if err != nil {
return err
}
return orz.Ok(c, orz.Map{
"agentId": agentID,
"name": monitorName,
"range": rangeParam,
"start": start,
"end": end,
"metrics": metrics,
})
}
// Delete 删除探针
func (h *AgentHandler) Delete(c echo.Context) error {
agentID := c.Param("id")
+22 -6
View File
@@ -11,13 +11,15 @@ import (
type MonitorHandler struct {
logger *zap.Logger
monitorService *service.MonitorService
metricService *service.MetricService
agentService *service.AgentService
}
func NewMonitorHandler(logger *zap.Logger, monitorService *service.MonitorService, agentService *service.AgentService) *MonitorHandler {
func NewMonitorHandler(logger *zap.Logger, monitorService *service.MonitorService, metricService *service.MetricService, agentService *service.AgentService) *MonitorHandler {
return &MonitorHandler{
logger: logger,
monitorService: monitorService,
metricService: metricService,
agentService: agentService,
}
}
@@ -158,6 +160,24 @@ func (h *MonitorHandler) GetStatsByID(c echo.Context) error {
return orz.Ok(c, stats)
}
// GetDetail 获取监控详情(整合版,公开接口,已登录返回全部,未登录返回公开可见)
func (h *MonitorHandler) GetDetail(c echo.Context) error {
id := c.Param("id")
ctx := c.Request().Context()
// 验证监控任务访问权限
if _, err := h.monitorService.GetMonitorByAuth(ctx, id, utils.IsAuthenticated(c)); err != nil {
return err
}
detail, err := h.monitorService.GetMonitorDetail(ctx, id)
if err != nil {
return err
}
return orz.Ok(c, detail)
}
// GetAgentStatsByID 获取指定监控任务各探针的统计数据(公开接口,已登录返回全部,未登录返回公开可见)
func (h *MonitorHandler) GetAgentStatsByID(c echo.Context) error {
id := c.Param("id")
@@ -168,11 +188,7 @@ func (h *MonitorHandler) GetAgentStatsByID(c echo.Context) error {
return err
}
stats, err := h.monitorService.GetMonitorAgentStats(ctx, id)
if err != nil {
return err
}
stats := h.metricService.GetMonitorAgentStats(id)
return orz.Ok(c, stats)
}
+37
View File
@@ -0,0 +1,37 @@
package metric
import (
"github.com/dushixiang/pika/internal/models"
"github.com/dushixiang/pika/internal/protocol"
)
// DiskSummary 磁盘汇总数据
type DiskSummary struct {
UsagePercent float64 `json:"usagePercent"` // 平均使用率
TotalDisks int `json:"totalDisks"` // 磁盘数量
Total uint64 `json:"total"` // 总容量(字节)
Used uint64 `json:"used"` // 已使用(字节)
Free uint64 `json:"free"` // 空闲(字节)
}
// NetworkSummary 网络汇总数据
type NetworkSummary struct {
TotalBytesSentRate uint64 `json:"totalBytesSentRate"` // 总发送速率(字节/秒)
TotalBytesRecvRate uint64 `json:"totalBytesRecvRate"` // 总接收速率(字节/秒)
TotalBytesSentTotal uint64 `json:"totalBytesSentTotal"` // 累计总发送流量
TotalBytesRecvTotal uint64 `json:"totalBytesRecvTotal"` // 累计总接收流量
TotalInterfaces int `json:"totalInterfaces"` // 网卡数量
}
// LatestMetrics 最新指标数据(用于API响应)
type LatestMetrics struct {
CPU *protocol.CPUData `json:"cpu,omitempty"`
Memory *protocol.MemoryData `json:"memory,omitempty"`
Disk *DiskSummary `json:"disk,omitempty"`
Network *NetworkSummary `json:"network,omitempty"`
NetworkConnection *protocol.NetworkConnectionData `json:"networkConnection,omitempty"`
Host *models.HostMetric `json:"host,omitempty"`
GPU []protocol.GPUData `json:"gpu,omitempty"`
Temp []protocol.TemperatureData `json:"temperature,omitempty"`
Monitors []protocol.MonitorData `json:"monitors,omitempty"`
}
+29
View File
@@ -0,0 +1,29 @@
package metric
// DataPoint 统一的指标数据点结构
type DataPoint struct {
Timestamp int64 `json:"timestamp"` // 毫秒时间戳
Value float64 `json:"value"`
}
// Series 指标系列(支持多系列,如多网卡、多传感器)
type Series struct {
Name string `json:"name"` // 系列名称
Labels map[string]string `json:"labels,omitempty"` // 额外标签
Data []DataPoint `json:"data"` // 数据点列表
}
// GetMetricsResponse 统一的查询响应格式
type GetMetricsResponse struct {
AgentID string `json:"agentId"`
Type string `json:"type"`
Range string `json:"range"`
Series []Series `json:"series"`
}
// QueryDefinition 查询定义(用于构建多个查询)
type QueryDefinition struct {
Name string // 系列名称
Query string // PromQL 查询语句
Labels map[string]string // 额外标签
}
+66
View File
@@ -0,0 +1,66 @@
package metric
import "github.com/dushixiang/pika/internal/protocol"
// LatestMonitorMetrics 监控任务的最新指标(按 agent 分组)
type LatestMonitorMetrics struct {
MonitorID string `json:"monitorId"`
Agents map[string]*protocol.MonitorData `json:"agents"` // key: agentID
UpdatedAt int64 `json:"updatedAt"` // 最后更新时间
}
// MonitorStatsResult 监控统计结果(所有探针的聚合数据)
type MonitorStatsResult struct {
Status string `json:"status"` // 聚合状态(up/down/unknown)
ResponseTime int64 `json:"responseTime"` // 当前平均响应时间(ms)
ResponseTimeMin int64 `json:"responseTimeMin"` // 最快响应时间(ms)
ResponseTimeMax int64 `json:"responseTimeMax"` // 最慢响应时间(ms)
CertExpiryTime int64 `json:"certExpiryTime,omitempty"` // 证书过期时间(毫秒时间戳)
CertDaysLeft int `json:"certDaysLeft,omitempty"` // 证书剩余天数
AgentCount int `json:"agentCount"` // 探针数量
AgentStats struct {
Up int `json:"up"` // 正常探针数量
Down int `json:"down"` // 异常探针数量
Unknown int `json:"unknown"` // 未知状态探针数量
} `json:"agentStats"` // 探针状态分布
LastCheckTime int64 `json:"lastCheckTime"` // 最后检测时间(毫秒时间戳)
}
// PublicMonitorOverview 用于公开展示的监控配置及汇总数据
type PublicMonitorOverview struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Target string `json:"target"`
ShowTargetPublic bool `json:"showTargetPublic"` // 在公开页面是否显示目标地址
Description string `json:"description"`
Enabled bool `json:"enabled"`
Interval int `json:"interval"`
AgentCount int `json:"agentCount"`
Status string `json:"status"` // up/down/unknown
ResponseTime int64 `json:"responseTime"` // 当前平均响应时间(ms)
ResponseTimeMin int64 `json:"responseTimeMin"` // 最快响应时间(ms)
ResponseTimeMax int64 `json:"responseTimeMax"` // 最慢响应时间(ms)
CertExpiryTime int64 `json:"certExpiryTime,omitempty"` // 证书过期时间(毫秒时间戳)
CertDaysLeft int `json:"certDaysLeft,omitempty"` // 证书剩余天数
AgentStats struct {
Up int `json:"up"` // 正常探针数量
Down int `json:"down"` // 异常探针数量
Unknown int `json:"unknown"` // 未知状态探针数量
} `json:"agentStats"` // 探针状态分布
LastCheckTime int64 `json:"lastCheckTime"` // 最后检测时间
}
// MonitorDetailResponse 监控详情响应(整合版)
type MonitorDetailResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Target string `json:"target"`
ShowTargetPublic bool `json:"showTargetPublic"`
Description string `json:"description"`
Enabled bool `json:"enabled"`
Interval int `json:"interval"`
Stats *MonitorStatsResult `json:"stats"`
Agents []protocol.MonitorData `json:"agents"`
}
+355
View File
@@ -0,0 +1,355 @@
package models
// CPUMetric CPU指标
type CPUMetric struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
AgentID string `gorm:"index:idx_cpu_agent_ts,priority:1" json:"agentId"` // 探针ID
UsagePercent float64 `json:"usagePercent"` // CPU使用率
LogicalCores int `json:"logicalCores"` // 逻辑核心数
PhysicalCores int `json:"physicalCores"` // 物理核心数
ModelName string `json:"modelName"` // CPU型号
Timestamp int64 `gorm:"index:idx_cpu_agent_ts,priority:2;index:idx_cpu_ts" json:"timestamp"` // 时间戳(毫秒)
}
func (CPUMetric) TableName() string {
return "cpu_metrics"
}
// MemoryMetric 内存指标
type MemoryMetric struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
AgentID string `gorm:"index:idx_mem_agent_ts,priority:1" json:"agentId"` // 探针ID
Total uint64 `json:"total"` // 总内存(字节)
Used uint64 `json:"used"` // 已使用(字节)
Free uint64 `json:"free"` // 空闲(字节)
Available uint64 `json:"available"` // 可用内存(字节)
UsagePercent float64 `json:"usagePercent"` // 使用率
SwapTotal uint64 `json:"swapTotal"` // 总交换空间(字节)
SwapUsed uint64 `json:"swapUsed"` // 已使用交换空间(字节)
SwapFree uint64 `json:"swapFree"` // 空闲交换空间(字节)
Timestamp int64 `gorm:"index:idx_mem_agent_ts,priority:2;index:idx_mem_ts" json:"timestamp"` // 时间戳(毫秒)
}
func (MemoryMetric) TableName() string {
return "memory_metrics"
}
// DiskMetric 磁盘指标
type DiskMetric struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
AgentID string `gorm:"index:idx_disk_agent_ts,priority:1" json:"agentId"` // 探针ID
MountPoint string `json:"mountPoint"` // 挂载点
Total uint64 `json:"total"` // 总容量(字节)
Used uint64 `json:"used"` // 已使用(字节)
Free uint64 `json:"free"` // 空闲(字节)
UsagePercent float64 `json:"usagePercent"` // 使用率
Timestamp int64 `gorm:"index:idx_disk_agent_ts,priority:2;index:idx_disk_ts" json:"timestamp"` // 时间戳(毫秒)
}
func (DiskMetric) TableName() string {
return "disk_metrics"
}
// NetworkMetric 网络指标
type NetworkMetric struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
AgentID string `gorm:"index:idx_net_agent_ts,priority:1" json:"agentId"` // 探针ID
Interface string `json:"interface"` // 网卡名称
BytesSentRate uint64 `json:"bytesSentRate"` // 发送速率(字节/秒)
BytesRecvRate uint64 `json:"bytesRecvRate"` // 接收速率(字节/秒)
BytesSentTotal uint64 `json:"bytesSentTotal"` // 累计发送字节数
BytesRecvTotal uint64 `json:"bytesRecvTotal"` // 累计接收字节数
Timestamp int64 `gorm:"index:idx_net_agent_ts,priority:2;index:idx_net_ts" json:"timestamp"` // 时间戳(毫秒)
}
func (NetworkMetric) TableName() string {
return "network_metrics"
}
// NetworkConnectionMetric 网络连接统计指标
type NetworkConnectionMetric struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
AgentID string `gorm:"index:idx_netconn_agent_ts,priority:1" json:"agentId"` // 探针ID
Established uint32 `json:"established"` // ESTABLISHED 状态连接数
SynSent uint32 `json:"synSent"` // SYN_SENT 状态连接数
SynRecv uint32 `json:"synRecv"` // SYN_RECV 状态连接数
FinWait1 uint32 `json:"finWait1"` // FIN_WAIT1 状态连接数
FinWait2 uint32 `json:"finWait2"` // FIN_WAIT2 状态连接数
TimeWait uint32 `json:"timeWait"` // TIME_WAIT 状态连接数
Close uint32 `json:"close"` // CLOSE 状态连接数
CloseWait uint32 `json:"closeWait"` // CLOSE_WAIT 状态连接数
LastAck uint32 `json:"lastAck"` // LAST_ACK 状态连接数
Listen uint32 `json:"listen"` // LISTEN 状态连接数
Closing uint32 `json:"closing"` // CLOSING 状态连接数
Total uint32 `json:"total"` // 总连接数
Timestamp int64 `gorm:"index:idx_netconn_agent_ts,priority:2;index:idx_netconn_ts" json:"timestamp"` // 时间戳(毫秒)
}
func (NetworkConnectionMetric) TableName() string {
return "network_connection_metrics"
}
// DiskIOMetric 磁盘IO指标
type DiskIOMetric struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
AgentID string `gorm:"index:idx_diskio_agent_ts,priority:1" json:"agentId"` // 探针ID
Device string `json:"device"` // 设备名称
ReadCount uint64 `json:"readCount"` // 读取次数
WriteCount uint64 `json:"writeCount"` // 写入次数
ReadBytes uint64 `json:"readBytes"` // 读取字节数
WriteBytes uint64 `json:"writeBytes"` // 写入字节数
ReadBytesRate uint64 `json:"readBytesRate"` // 读取速率(字节/秒)
WriteBytesRate uint64 `json:"writeBytesRate"` // 写入速率(字节/秒)
ReadTime uint64 `json:"readTime"` // 读取时间(毫秒)
WriteTime uint64 `json:"writeTime"` // 写入时间(毫秒)
IoTime uint64 `json:"ioTime"` // IO时间(毫秒)
IopsInProgress uint64 `json:"iopsInProgress"` // 正在进行的IO操作数
Timestamp int64 `gorm:"index:idx_diskio_agent_ts,priority:2;index:idx_diskio_ts" json:"timestamp"` // 时间戳(毫秒)
}
func (DiskIOMetric) TableName() string {
return "disk_io_metrics"
}
// GPUMetric GPU指标
type GPUMetric struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
AgentID string `gorm:"index:idx_gpu_agent_idx_ts,priority:1" json:"agentId"` // 探针ID
Index int `gorm:"index:idx_gpu_agent_idx_ts,priority:2" json:"index"` // GPU索引
Name string `json:"name"` // GPU名称
Utilization float64 `json:"utilization"` // GPU使用率(%)
MemoryTotal uint64 `json:"memoryTotal"` // 总显存(字节)
MemoryUsed uint64 `json:"memoryUsed"` // 已使用显存(字节)
MemoryFree uint64 `json:"memoryFree"` // 空闲显存(字节)
Temperature float64 `json:"temperature"` // 温度(℃)
PowerDraw float64 `json:"powerDraw"` // 功耗(瓦)
FanSpeed float64 `json:"fanSpeed"` // 风扇转速(%)
PerformanceState string `json:"performanceState"` // 性能状态
Timestamp int64 `gorm:"index:idx_gpu_agent_idx_ts,priority:3;index:idx_gpu_ts" json:"timestamp"` // 时间戳(毫秒)
}
func (GPUMetric) TableName() string {
return "gpu_metrics"
}
// TemperatureMetric 温度指标
type TemperatureMetric struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
AgentID string `gorm:"index:idx_temp_agent_sensor_ts,priority:1" json:"agentId"` // 探针ID
SensorKey string `gorm:"index:idx_temp_agent_sensor_ts,priority:2" json:"sensorKey"` // 传感器标识
SensorLabel string `json:"sensorLabel"` // 传感器标签
Temperature float64 `json:"temperature"` // 温度(℃)
Timestamp int64 `gorm:"index:idx_temp_agent_sensor_ts,priority:3;index:idx_temp_ts" json:"timestamp"` // 时间戳(毫秒)
}
func (TemperatureMetric) TableName() string {
return "temperature_metrics"
}
// HostMetric 主机信息指标
type HostMetric struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
AgentID string `gorm:"uniqueIndex:ux_host_agent" json:"agentId"` // 探针ID(唯一约束用于 upsert)
OS string `json:"os"` // 操作系统
Platform string `json:"platform"` // 平台
PlatformVersion string `json:"platformVersion"` // 平台版本
KernelVersion string `json:"kernelVersion"` // 内核版本
KernelArch string `json:"kernelArch"` // 内核架构
Uptime uint64 `json:"uptime"` // 运行时间(秒)
BootTime uint64 `json:"bootTime"` // 启动时间(Unix时间戳-秒)
Procs uint64 `json:"procs"` // 进程数
Timestamp int64 `gorm:"index:idx_host_ts" json:"timestamp"` // 时间戳(毫秒)
}
func (HostMetric) TableName() string {
return "host_metrics"
}
// MonitorMetric 监控指标
type MonitorMetric struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
AgentId string `gorm:"index:idx_mon_agent_monitor_ts,priority:1" json:"agentId"` // 探针ID
MonitorId string `gorm:"index:idx_mon_agent_monitor_ts,priority:2;index:idx_mon_type_monitor_ts,priority:2" json:"monitorId"` // 监控项ID
Type string `gorm:"index:idx_mon_type_monitor_ts,priority:1" json:"type"` // 监控类型: http, tcp
Target string `json:"target"` // 监控目标
Status string `json:"status"` // 状态: up, down
StatusCode int `json:"statusCode"` // HTTP状态码
ResponseTime int64 `json:"responseTime"` // 响应时间(毫秒)
Error string `json:"error"` // 错误信息
Message string `json:"message"` // 附加信息
ContentMatch bool `json:"contentMatch"` // 内容匹配结果
CertExpiryTime int64 `json:"certExpiryTime"` // 证书过期时间(毫秒时间戳), 0表示无证书
CertDaysLeft int `json:"certDaysLeft"` // 证书剩余天数
Timestamp int64 `gorm:"index:idx_mon_agent_monitor_ts,priority:3;index:idx_mon_type_monitor_ts,priority:3;index:idx_mon_ts" json:"timestamp"` // 时间戳(毫秒)
}
func (MonitorMetric) TableName() string {
return "monitor_metrics"
}
// ----------- 聚合表 -----------
// AggregatedCPUMetricModel CPU聚合表
type AggregatedCPUMetricModel struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
AgentID string `gorm:"index:idx_cpuagg_agent_bucket,priority:1;uniqueIndex:ux_cpuagg_bucket,priority:1" json:"agentId"`
BucketSeconds int `gorm:"index:idx_cpuagg_agent_bucket,priority:2;uniqueIndex:ux_cpuagg_bucket,priority:2" json:"bucketSeconds"`
BucketStart int64 `gorm:"index:idx_cpuagg_agent_bucket,priority:3;uniqueIndex:ux_cpuagg_bucket,priority:3" json:"bucketStart"` // 毫秒
MaxUsage float64 `json:"maxUsage"`
LogicalCores int `json:"logicalCores"`
}
func (AggregatedCPUMetricModel) TableName() string {
return "cpu_metrics_aggs"
}
// AggregatedMemoryMetricModel 内存聚合表
type AggregatedMemoryMetricModel struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
AgentID string `gorm:"index:idx_memagg_agent_bucket,priority:1;uniqueIndex:ux_memagg_bucket,priority:1" json:"agentId"`
BucketSeconds int `gorm:"index:idx_memagg_agent_bucket,priority:2;uniqueIndex:ux_memagg_bucket,priority:2" json:"bucketSeconds"`
BucketStart int64 `gorm:"index:idx_memagg_agent_bucket,priority:3;uniqueIndex:ux_memagg_bucket,priority:3" json:"bucketStart"` // 毫秒
MaxUsage float64 `json:"maxUsage"`
Total uint64 `json:"total"`
}
func (AggregatedMemoryMetricModel) TableName() string {
return "memory_metrics_aggs"
}
// AggregatedDiskMetricModel 磁盘聚合表
type AggregatedDiskMetricModel struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
AgentID string `gorm:"index:idx_diskagg_agent_bucket_mp,priority:1;uniqueIndex:ux_diskagg_bucket,priority:1" json:"agentId"`
BucketSeconds int `gorm:"index:idx_diskagg_agent_bucket_mp,priority:2;uniqueIndex:ux_diskagg_bucket,priority:2" json:"bucketSeconds"`
BucketStart int64 `gorm:"index:idx_diskagg_agent_bucket_mp,priority:3;uniqueIndex:ux_diskagg_bucket,priority:3" json:"bucketStart"` // 毫秒
MountPoint string `gorm:"index:idx_diskagg_agent_bucket_mp,priority:4;uniqueIndex:ux_diskagg_bucket,priority:4" json:"mountPoint"`
MaxUsage float64 `json:"maxUsage"`
Total uint64 `json:"total"`
}
func (AggregatedDiskMetricModel) TableName() string {
return "disk_metrics_aggs"
}
// AggregatedNetworkMetricModel 网络聚合表(按网卡分组)
type AggregatedNetworkMetricModel struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
AgentID string `gorm:"index:idx_netagg_agent_bucket_iface,priority:1;uniqueIndex:ux_netagg_bucket,priority:1" json:"agentId"`
BucketSeconds int `gorm:"index:idx_netagg_agent_bucket_iface,priority:2;uniqueIndex:ux_netagg_bucket,priority:2" json:"bucketSeconds"`
BucketStart int64 `gorm:"index:idx_netagg_agent_bucket_iface,priority:3;uniqueIndex:ux_netagg_bucket,priority:3" json:"bucketStart"` // 毫秒
Interface string `gorm:"index:idx_netagg_agent_bucket_iface,priority:4;uniqueIndex:ux_netagg_bucket,priority:4" json:"interface"`
MaxSentRate float64 `json:"maxSentRate"`
MaxRecvRate float64 `json:"maxRecvRate"`
}
func (AggregatedNetworkMetricModel) TableName() string {
return "network_metrics_aggs"
}
// AggregatedNetworkConnectionMetricModel 网络连接聚合表
type AggregatedNetworkConnectionMetricModel struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
AgentID string `gorm:"index:idx_netconnagg_agent_bucket,priority:1;uniqueIndex:ux_netconnagg_bucket,priority:1" json:"agentId"`
BucketSeconds int `gorm:"index:idx_netconnagg_agent_bucket,priority:2;uniqueIndex:ux_netconnagg_bucket,priority:2" json:"bucketSeconds"`
BucketStart int64 `gorm:"index:idx_netconnagg_agent_bucket,priority:3;uniqueIndex:ux_netconnagg_bucket,priority:3" json:"bucketStart"` // 毫秒
MaxEstablished uint32 `json:"maxEstablished"`
MaxSynSent uint32 `json:"maxSynSent"`
MaxSynRecv uint32 `json:"maxSynRecv"`
MaxFinWait1 uint32 `json:"maxFinWait1"`
MaxFinWait2 uint32 `json:"maxFinWait2"`
MaxTimeWait uint32 `json:"maxTimeWait"`
MaxClose uint32 `json:"maxClose"`
MaxCloseWait uint32 `json:"maxCloseWait"`
MaxLastAck uint32 `json:"maxLastAck"`
MaxListen uint32 `json:"maxListen"`
MaxClosing uint32 `json:"maxClosing"`
MaxTotal uint32 `json:"maxTotal"`
}
func (AggregatedNetworkConnectionMetricModel) TableName() string {
return "network_connection_metrics_aggs"
}
// AggregatedDiskIOMetricModel 磁盘IO聚合表(汇总所有磁盘)
type AggregatedDiskIOMetricModel struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
AgentID string `gorm:"index:idx_diskioagg_agent_bucket,priority:1;uniqueIndex:ux_diskioagg_bucket,priority:1" json:"agentId"`
BucketSeconds int `gorm:"index:idx_diskioagg_agent_bucket,priority:2;uniqueIndex:ux_diskioagg_bucket,priority:2" json:"bucketSeconds"`
BucketStart int64 `gorm:"index:idx_diskioagg_agent_bucket,priority:3;uniqueIndex:ux_diskioagg_bucket,priority:3" json:"bucketStart"` // 毫秒
MaxReadBytesRate uint64 `json:"maxReadBytesRate"`
MaxWriteBytesRate uint64 `json:"maxWriteBytesRate"`
MaxIopsInProgress uint64 `json:"maxIopsInProgress"`
}
func (AggregatedDiskIOMetricModel) TableName() string {
return "disk_io_metrics_aggs"
}
// AggregatedGPUMetricModel GPU聚合表
type AggregatedGPUMetricModel struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
AgentID string `gorm:"index:idx_gpuagg_agent_bucket_idx,priority:1;uniqueIndex:ux_gpuagg_bucket,priority:1" json:"agentId"`
BucketSeconds int `gorm:"index:idx_gpuagg_agent_bucket_idx,priority:2;uniqueIndex:ux_gpuagg_bucket,priority:2" json:"bucketSeconds"`
BucketStart int64 `gorm:"index:idx_gpuagg_agent_bucket_idx,priority:3;uniqueIndex:ux_gpuagg_bucket,priority:3" json:"bucketStart"` // 毫秒
Index int `gorm:"index:idx_gpuagg_agent_bucket_idx,priority:4;uniqueIndex:ux_gpuagg_bucket,priority:4" json:"index"`
Name string `json:"name"`
MaxUtilization float64 `json:"maxUtilization"`
MaxMemoryUsed uint64 `json:"maxMemoryUsed"`
MaxTemperature float64 `json:"maxTemperature"`
MaxPowerDraw float64 `json:"maxPowerDraw"`
MemoryTotal uint64 `json:"memoryTotal"`
}
func (AggregatedGPUMetricModel) TableName() string {
return "gpu_metrics_aggs"
}
// AggregatedTemperatureMetricModel 温度聚合表
type AggregatedTemperatureMetricModel struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
AgentID string `gorm:"index:idx_tempagg_agent_bucket_sensor,priority:1;uniqueIndex:ux_tempagg_bucket,priority:1" json:"agentId"`
BucketSeconds int `gorm:"index:idx_tempagg_agent_bucket_sensor,priority:2;uniqueIndex:ux_tempagg_bucket,priority:2" json:"bucketSeconds"`
BucketStart int64 `gorm:"index:idx_tempagg_agent_bucket_sensor,priority:3;uniqueIndex:ux_tempagg_bucket,priority:3" json:"bucketStart"` // 毫秒
SensorKey string `gorm:"index:idx_tempagg_agent_bucket_sensor,priority:4;uniqueIndex:ux_tempagg_bucket,priority:4" json:"sensorKey"`
SensorLabel string `json:"sensorLabel"`
MaxTemperature float64 `json:"maxTemperature"`
}
func (AggregatedTemperatureMetricModel) TableName() string {
return "temperature_metrics_aggs"
}
// AggregationProgress 聚合进度记录
type AggregationProgress struct {
MetricType string `gorm:"primaryKey"` // cpu/memory/disk/network
BucketSeconds int `gorm:"primaryKey"`
LastBucket int64 `json:"lastBucket"` // 已完成的最后 bucket 起始时间(毫秒)
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli"`
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"`
}
func (AggregationProgress) TableName() string {
return "aggregation_progress"
}
// AggregatedMonitorMetricModel 监控指标聚合表
type AggregatedMonitorMetricModel struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
MonitorID string `gorm:"index:idx_monitoragg_monitor_agent_bucket,priority:1;uniqueIndex:ux_monitoragg_bucket,priority:1" json:"monitorId"`
AgentID string `gorm:"index:idx_monitoragg_monitor_agent_bucket,priority:2;uniqueIndex:ux_monitoragg_bucket,priority:2" json:"agentId"`
BucketSeconds int `gorm:"index:idx_monitoragg_monitor_agent_bucket,priority:3;uniqueIndex:ux_monitoragg_bucket,priority:3" json:"bucketSeconds"`
BucketStart int64 `gorm:"index:idx_monitoragg_monitor_agent_bucket,priority:4;uniqueIndex:ux_monitoragg_bucket,priority:4" json:"bucketStart"` // 毫秒
AvgResponse float64 `json:"avgResponse"` // 平均响应时间(ms)
MaxResponse int64 `json:"maxResponse"` // 最大响应时间(ms)
MinResponse int64 `json:"minResponse"` // 最小响应时间(ms)
SuccessCount int64 `json:"successCount"` // 成功次数
TotalCount int64 `json:"totalCount"` // 总次数
SuccessRate float64 `json:"successRate"` // 成功率(百分比)
LastStatus string `json:"lastStatus"` // 最后状态
LastError string `json:"lastError"` // 最后错误信息
}
func (AggregatedMonitorMetricModel) TableName() string {
return "monitor_metrics_aggs"
}
+802
View File
@@ -0,0 +1,802 @@
package models
import (
"context"
"fmt"
"log"
"github.com/dushixiang/pika/internal/protocol"
"github.com/dushixiang/pika/internal/vmclient"
"gorm.io/gorm"
)
const (
// 每批处理的数据量
batchSize = 1000
)
// Migrate 执行数据迁移:将 PostgreSQL 中的历史指标数据迁移到 VictoriaMetrics
func Migrate(db *gorm.DB, client *vmclient.VMClient) error {
ctx := context.Background()
log.Println("开始迁移历史指标数据到 VictoriaMetrics...")
// 迁移各类型的指标数据
migrationTasks := []struct {
name string
tableName string
migrateFunc func(context.Context, *gorm.DB, *vmclient.VMClient) error
}{
{"CPU指标", "cpu_metrics", migrateCPUMetrics},
{"内存指标", "memory_metrics", migrateMemoryMetrics},
{"磁盘指标", "disk_metrics", migrateDiskMetrics},
{"网络指标", "network_metrics", migrateNetworkMetrics},
{"网络连接指标", "network_connection_metrics", migrateNetworkConnectionMetrics},
{"磁盘IO指标", "disk_io_metrics", migrateDiskIOMetrics},
{"GPU指标", "gpu_metrics", migrateGPUMetrics},
{"温度指标", "temperature_metrics", migrateTemperatureMetrics},
{"监控指标", "monitor_metrics", migrateMonitorMetrics},
}
var errors []string
for _, task := range migrationTasks {
log.Printf("开始迁移 %s...", task.name)
if err := task.migrateFunc(ctx, db, client); err != nil {
errMsg := fmt.Sprintf("迁移 %s 失败: %v", task.name, err)
log.Println(errMsg)
errors = append(errors, errMsg)
continue // 继续迁移其他类型的数据
}
log.Printf("✓ %s 迁移完成", task.name)
}
if len(errors) > 0 {
log.Printf("迁移过程中遇到 %d 个错误,但已尽可能完成迁移", len(errors))
return fmt.Errorf("迁移过程中遇到 %d 个错误", len(errors))
}
log.Println("✓ 数据迁移完成!")
return nil
}
// migrateCPUMetrics 迁移CPU指标
func migrateCPUMetrics(ctx context.Context, db *gorm.DB, client *vmclient.VMClient) error {
var totalCount int64
if err := db.Model(&CPUMetric{}).Count(&totalCount).Error; err != nil {
return err
}
if totalCount == 0 {
log.Println(" 没有CPU数据需要迁移")
return nil
}
log.Printf(" 找到 %d 条CPU记录", totalCount)
offset := 0
migratedCount := 0
for {
var metrics []CPUMetric
if err := db.Offset(offset).Limit(batchSize).Order("timestamp ASC").Find(&metrics).Error; err != nil {
return err
}
if len(metrics) == 0 {
break
}
var vmMetrics []vmclient.Metric
for _, m := range metrics {
cpuData := &protocol.CPUData{
UsagePercent: m.UsagePercent,
LogicalCores: m.LogicalCores,
PhysicalCores: m.PhysicalCores,
ModelName: m.ModelName,
}
converted := convertToMetrics(m.AgentID, string(protocol.MetricTypeCPU), cpuData, m.Timestamp)
vmMetrics = append(vmMetrics, converted...)
}
// 批量写入 VictoriaMetrics
if err := client.Write(ctx, vmMetrics); err != nil {
log.Printf(" 警告: 写入CPU数据失败 (offset=%d): %v", offset, err)
}
migratedCount += len(metrics)
offset += batchSize
// 打印进度
if migratedCount%10000 == 0 || migratedCount == int(totalCount) {
log.Printf(" 进度: %d/%d (%.1f%%)", migratedCount, totalCount, float64(migratedCount)/float64(totalCount)*100)
}
}
return nil
}
// migrateMemoryMetrics 迁移内存指标
func migrateMemoryMetrics(ctx context.Context, db *gorm.DB, client *vmclient.VMClient) error {
var totalCount int64
if err := db.Model(&MemoryMetric{}).Count(&totalCount).Error; err != nil {
return err
}
if totalCount == 0 {
log.Println(" 没有内存数据需要迁移")
return nil
}
log.Printf(" 找到 %d 条内存记录", totalCount)
offset := 0
migratedCount := 0
for {
var metrics []MemoryMetric
if err := db.Offset(offset).Limit(batchSize).Order("timestamp ASC").Find(&metrics).Error; err != nil {
return err
}
if len(metrics) == 0 {
break
}
var vmMetrics []vmclient.Metric
for _, m := range metrics {
memData := &protocol.MemoryData{
Total: m.Total,
Used: m.Used,
Free: m.Free,
Available: m.Available,
UsagePercent: m.UsagePercent,
SwapTotal: m.SwapTotal,
SwapUsed: m.SwapUsed,
SwapFree: m.SwapFree,
}
converted := convertToMetrics(m.AgentID, string(protocol.MetricTypeMemory), memData, m.Timestamp)
vmMetrics = append(vmMetrics, converted...)
}
if err := client.Write(ctx, vmMetrics); err != nil {
log.Printf(" 警告: 写入内存数据失败 (offset=%d): %v", offset, err)
}
migratedCount += len(metrics)
offset += batchSize
if migratedCount%10000 == 0 || migratedCount == int(totalCount) {
log.Printf(" 进度: %d/%d (%.1f%%)", migratedCount, totalCount, float64(migratedCount)/float64(totalCount)*100)
}
}
return nil
}
// migrateDiskMetrics 迁移磁盘指标
func migrateDiskMetrics(ctx context.Context, db *gorm.DB, client *vmclient.VMClient) error {
var totalCount int64
if err := db.Model(&DiskMetric{}).Count(&totalCount).Error; err != nil {
return err
}
if totalCount == 0 {
log.Println(" 没有磁盘数据需要迁移")
return nil
}
log.Printf(" 找到 %d 条磁盘记录", totalCount)
offset := 0
migratedCount := 0
for {
var metrics []DiskMetric
if err := db.Offset(offset).Limit(batchSize).Order("timestamp ASC").Find(&metrics).Error; err != nil {
return err
}
if len(metrics) == 0 {
break
}
// 按 agent_id + timestamp 分组
groupedMetrics := make(map[string][]protocol.DiskData)
for _, m := range metrics {
key := fmt.Sprintf("%s_%d", m.AgentID, m.Timestamp)
groupedMetrics[key] = append(groupedMetrics[key], protocol.DiskData{
MountPoint: m.MountPoint,
Total: m.Total,
Used: m.Used,
Free: m.Free,
UsagePercent: m.UsagePercent,
})
}
var vmMetrics []vmclient.Metric
for key, diskDataList := range groupedMetrics {
// 从 key 中提取 agentID 和 timestamp
var agentID string
var timestamp int64
fmt.Sscanf(key, "%s_%d", &agentID, &timestamp)
converted := convertToMetrics(agentID, string(protocol.MetricTypeDisk), diskDataList, timestamp)
vmMetrics = append(vmMetrics, converted...)
}
if err := client.Write(ctx, vmMetrics); err != nil {
log.Printf(" 警告: 写入磁盘数据失败 (offset=%d): %v", offset, err)
}
migratedCount += len(metrics)
offset += batchSize
if migratedCount%10000 == 0 || migratedCount == int(totalCount) {
log.Printf(" 进度: %d/%d (%.1f%%)", migratedCount, totalCount, float64(migratedCount)/float64(totalCount)*100)
}
}
return nil
}
// migrateNetworkMetrics 迁移网络指标
func migrateNetworkMetrics(ctx context.Context, db *gorm.DB, client *vmclient.VMClient) error {
var totalCount int64
if err := db.Model(&NetworkMetric{}).Count(&totalCount).Error; err != nil {
return err
}
if totalCount == 0 {
log.Println(" 没有网络数据需要迁移")
return nil
}
log.Printf(" 找到 %d 条网络记录", totalCount)
offset := 0
migratedCount := 0
for {
var metrics []NetworkMetric
if err := db.Offset(offset).Limit(batchSize).Order("timestamp ASC").Find(&metrics).Error; err != nil {
return err
}
if len(metrics) == 0 {
break
}
// 按 agent_id + timestamp 分组
groupedMetrics := make(map[string][]protocol.NetworkData)
for _, m := range metrics {
key := fmt.Sprintf("%s_%d", m.AgentID, m.Timestamp)
groupedMetrics[key] = append(groupedMetrics[key], protocol.NetworkData{
Interface: m.Interface,
BytesSentRate: m.BytesSentRate,
BytesRecvRate: m.BytesRecvRate,
BytesSentTotal: m.BytesSentTotal,
BytesRecvTotal: m.BytesRecvTotal,
})
}
var vmMetrics []vmclient.Metric
for key, netDataList := range groupedMetrics {
var agentID string
var timestamp int64
fmt.Sscanf(key, "%s_%d", &agentID, &timestamp)
converted := convertToMetrics(agentID, string(protocol.MetricTypeNetwork), netDataList, timestamp)
vmMetrics = append(vmMetrics, converted...)
}
if err := client.Write(ctx, vmMetrics); err != nil {
log.Printf(" 警告: 写入网络数据失败 (offset=%d): %v", offset, err)
}
migratedCount += len(metrics)
offset += batchSize
if migratedCount%10000 == 0 || migratedCount == int(totalCount) {
log.Printf(" 进度: %d/%d (%.1f%%)", migratedCount, totalCount, float64(migratedCount)/float64(totalCount)*100)
}
}
return nil
}
// migrateNetworkConnectionMetrics 迁移网络连接指标
func migrateNetworkConnectionMetrics(ctx context.Context, db *gorm.DB, client *vmclient.VMClient) error {
var totalCount int64
if err := db.Model(&NetworkConnectionMetric{}).Count(&totalCount).Error; err != nil {
return err
}
if totalCount == 0 {
log.Println(" 没有网络连接数据需要迁移")
return nil
}
log.Printf(" 找到 %d 条网络连接记录", totalCount)
offset := 0
migratedCount := 0
for {
var metrics []NetworkConnectionMetric
if err := db.Offset(offset).Limit(batchSize).Order("timestamp ASC").Find(&metrics).Error; err != nil {
return err
}
if len(metrics) == 0 {
break
}
var vmMetrics []vmclient.Metric
for _, m := range metrics {
connData := &protocol.NetworkConnectionData{
Established: m.Established,
SynSent: m.SynSent,
SynRecv: m.SynRecv,
TimeWait: m.TimeWait,
CloseWait: m.CloseWait,
Total: m.Total,
}
converted := convertToMetrics(m.AgentID, string(protocol.MetricTypeNetworkConnection), connData, m.Timestamp)
vmMetrics = append(vmMetrics, converted...)
}
if err := client.Write(ctx, vmMetrics); err != nil {
log.Printf(" 警告: 写入网络连接数据失败 (offset=%d): %v", offset, err)
}
migratedCount += len(metrics)
offset += batchSize
if migratedCount%10000 == 0 || migratedCount == int(totalCount) {
log.Printf(" 进度: %d/%d (%.1f%%)", migratedCount, totalCount, float64(migratedCount)/float64(totalCount)*100)
}
}
return nil
}
// migrateDiskIOMetrics 迁移磁盘IO指标
func migrateDiskIOMetrics(ctx context.Context, db *gorm.DB, client *vmclient.VMClient) error {
var totalCount int64
if err := db.Model(&DiskIOMetric{}).Count(&totalCount).Error; err != nil {
return err
}
if totalCount == 0 {
log.Println(" 没有磁盘IO数据需要迁移")
return nil
}
log.Printf(" 找到 %d 条磁盘IO记录", totalCount)
offset := 0
migratedCount := 0
for {
var metrics []DiskIOMetric
if err := db.Offset(offset).Limit(batchSize).Order("timestamp ASC").Find(&metrics).Error; err != nil {
return err
}
if len(metrics) == 0 {
break
}
// 按 agent_id + timestamp 分组
groupedMetrics := make(map[string][]*protocol.DiskIOData)
for _, m := range metrics {
key := fmt.Sprintf("%s_%d", m.AgentID, m.Timestamp)
groupedMetrics[key] = append(groupedMetrics[key], &protocol.DiskIOData{
Device: m.Device,
ReadCount: m.ReadCount,
WriteCount: m.WriteCount,
ReadBytes: m.ReadBytes,
WriteBytes: m.WriteBytes,
ReadBytesRate: m.ReadBytesRate,
WriteBytesRate: m.WriteBytesRate,
ReadTime: m.ReadTime,
WriteTime: m.WriteTime,
IoTime: m.IoTime,
IopsInProgress: m.IopsInProgress,
})
}
var vmMetrics []vmclient.Metric
for key, diskIODataList := range groupedMetrics {
var agentID string
var timestamp int64
fmt.Sscanf(key, "%s_%d", &agentID, &timestamp)
converted := convertToMetrics(agentID, string(protocol.MetricTypeDiskIO), diskIODataList, timestamp)
vmMetrics = append(vmMetrics, converted...)
}
if err := client.Write(ctx, vmMetrics); err != nil {
log.Printf(" 警告: 写入磁盘IO数据失败 (offset=%d): %v", offset, err)
}
migratedCount += len(metrics)
offset += batchSize
if migratedCount%10000 == 0 || migratedCount == int(totalCount) {
log.Printf(" 进度: %d/%d (%.1f%%)", migratedCount, totalCount, float64(migratedCount)/float64(totalCount)*100)
}
}
return nil
}
// migrateGPUMetrics 迁移GPU指标
func migrateGPUMetrics(ctx context.Context, db *gorm.DB, client *vmclient.VMClient) error {
var totalCount int64
if err := db.Model(&GPUMetric{}).Count(&totalCount).Error; err != nil {
return err
}
if totalCount == 0 {
log.Println(" 没有GPU数据需要迁移")
return nil
}
log.Printf(" 找到 %d 条GPU记录", totalCount)
offset := 0
migratedCount := 0
for {
var metrics []GPUMetric
if err := db.Offset(offset).Limit(batchSize).Order("timestamp ASC").Find(&metrics).Error; err != nil {
return err
}
if len(metrics) == 0 {
break
}
// 按 agent_id + timestamp 分组
groupedMetrics := make(map[string][]protocol.GPUData)
for _, m := range metrics {
key := fmt.Sprintf("%s_%d", m.AgentID, m.Timestamp)
groupedMetrics[key] = append(groupedMetrics[key], protocol.GPUData{
Index: m.Index,
Name: m.Name,
Utilization: m.Utilization,
MemoryTotal: m.MemoryTotal,
MemoryUsed: m.MemoryUsed,
MemoryFree: m.MemoryFree,
Temperature: m.Temperature,
PowerUsage: m.PowerDraw,
FanSpeed: m.FanSpeed,
})
}
var vmMetrics []vmclient.Metric
for key, gpuDataList := range groupedMetrics {
var agentID string
var timestamp int64
fmt.Sscanf(key, "%s_%d", &agentID, &timestamp)
converted := convertToMetrics(agentID, string(protocol.MetricTypeGPU), gpuDataList, timestamp)
vmMetrics = append(vmMetrics, converted...)
}
if err := client.Write(ctx, vmMetrics); err != nil {
log.Printf(" 警告: 写入GPU数据失败 (offset=%d): %v", offset, err)
}
migratedCount += len(metrics)
offset += batchSize
if migratedCount%10000 == 0 || migratedCount == int(totalCount) {
log.Printf(" 进度: %d/%d (%.1f%%)", migratedCount, totalCount, float64(migratedCount)/float64(totalCount)*100)
}
}
return nil
}
// migrateTemperatureMetrics 迁移温度指标
func migrateTemperatureMetrics(ctx context.Context, db *gorm.DB, client *vmclient.VMClient) error {
var totalCount int64
if err := db.Model(&TemperatureMetric{}).Count(&totalCount).Error; err != nil {
return err
}
if totalCount == 0 {
log.Println(" 没有温度数据需要迁移")
return nil
}
log.Printf(" 找到 %d 条温度记录", totalCount)
offset := 0
migratedCount := 0
for {
var metrics []TemperatureMetric
if err := db.Offset(offset).Limit(batchSize).Order("timestamp ASC").Find(&metrics).Error; err != nil {
return err
}
if len(metrics) == 0 {
break
}
// 按 agent_id + timestamp 分组
groupedMetrics := make(map[string][]protocol.TemperatureData)
for _, m := range metrics {
key := fmt.Sprintf("%s_%d", m.AgentID, m.Timestamp)
groupedMetrics[key] = append(groupedMetrics[key], protocol.TemperatureData{
SensorKey: m.SensorKey,
Temperature: m.Temperature,
Type: m.SensorLabel,
})
}
var vmMetrics []vmclient.Metric
for key, tempDataList := range groupedMetrics {
var agentID string
var timestamp int64
fmt.Sscanf(key, "%s_%d", &agentID, &timestamp)
converted := convertToMetrics(agentID, string(protocol.MetricTypeTemperature), tempDataList, timestamp)
vmMetrics = append(vmMetrics, converted...)
}
if err := client.Write(ctx, vmMetrics); err != nil {
log.Printf(" 警告: 写入温度数据失败 (offset=%d): %v", offset, err)
}
migratedCount += len(metrics)
offset += batchSize
if migratedCount%10000 == 0 || migratedCount == int(totalCount) {
log.Printf(" 进度: %d/%d (%.1f%%)", migratedCount, totalCount, float64(migratedCount)/float64(totalCount)*100)
}
}
return nil
}
// migrateMonitorMetrics 迁移监控指标
func migrateMonitorMetrics(ctx context.Context, db *gorm.DB, client *vmclient.VMClient) error {
var totalCount int64
if err := db.Model(&MonitorMetric{}).Count(&totalCount).Error; err != nil {
return err
}
if totalCount == 0 {
log.Println(" 没有监控数据需要迁移")
return nil
}
log.Printf(" 找到 %d 条监控记录", totalCount)
offset := 0
migratedCount := 0
for {
var metrics []MonitorMetric
if err := db.Offset(offset).Limit(batchSize).Order("timestamp ASC").Find(&metrics).Error; err != nil {
return err
}
if len(metrics) == 0 {
break
}
// 按 agent_id + timestamp 分组
groupedMetrics := make(map[string][]protocol.MonitorData)
for _, m := range metrics {
key := fmt.Sprintf("%s_%d", m.AgentId, m.Timestamp)
groupedMetrics[key] = append(groupedMetrics[key], protocol.MonitorData{
AgentId: m.AgentId,
MonitorId: m.MonitorId,
Type: m.Type,
Target: m.Target,
Status: m.Status,
StatusCode: m.StatusCode,
ResponseTime: m.ResponseTime,
Error: m.Error,
Message: m.Message,
ContentMatch: m.ContentMatch,
CertExpiryTime: m.CertExpiryTime,
CertDaysLeft: m.CertDaysLeft,
CheckedAt: m.Timestamp, // 使用 timestamp 作为 CheckedAt
})
}
var vmMetrics []vmclient.Metric
for key, monitorDataList := range groupedMetrics {
var agentID string
var timestamp int64
fmt.Sscanf(key, "%s_%d", &agentID, &timestamp)
converted := convertToMetrics(agentID, string(protocol.MetricTypeMonitor), monitorDataList, timestamp)
vmMetrics = append(vmMetrics, converted...)
}
if err := client.Write(ctx, vmMetrics); err != nil {
log.Printf(" 警告: 写入监控数据失败 (offset=%d): %v", offset, err)
}
migratedCount += len(metrics)
offset += batchSize
if migratedCount%10000 == 0 || migratedCount == int(totalCount) {
log.Printf(" 进度: %d/%d (%.1f%%)", migratedCount, totalCount, float64(migratedCount)/float64(totalCount)*100)
}
}
return nil
}
// DropOldTables 删除旧的指标数据表
func DropOldTables(db *gorm.DB) error {
// 要删除的表列表(除了 host_metrics,它仍然需要保留)
tables := []string{
"cpu_metrics",
"memory_metrics",
"disk_metrics",
"network_metrics",
"network_connection_metrics",
"disk_io_metrics",
"gpu_metrics",
"temperature_metrics",
"monitor_metrics",
// 聚合表也删除
"cpu_metrics_aggs",
"memory_metrics_aggs",
"disk_metrics_aggs",
"network_metrics_aggs",
"network_connection_metrics_aggs",
"disk_io_metrics_aggs",
"gpu_metrics_aggs",
"temperature_metrics_aggs",
"monitor_metrics_aggs",
"aggregation_progress",
// 统计表
"monitor_stats",
}
for _, table := range tables {
// 检查表是否存在
if !db.Migrator().HasTable(table) {
log.Printf(" 表 %s 不存在,跳过", table)
continue
}
log.Printf(" 删除表: %s", table)
if err := db.Migrator().DropTable(table); err != nil {
log.Printf(" 警告: 删除表 %s 失败: %v", table, err)
continue
}
}
return nil
}
// convertToMetrics 将指标数据转换为 VictoriaMetrics Metric 对象
// 这个函数复制自 internal/service/metric_converter.go,避免循环依赖
func convertToMetrics(agentID string, metricType string, data interface{}, timestamp int64) []vmclient.Metric {
var metrics []vmclient.Metric
switch protocol.MetricType(metricType) {
case protocol.MetricTypeCPU:
cpuData := data.(*protocol.CPUData)
metrics = append(metrics, createMetric("pika_cpu_usage_percent", agentID, nil, cpuData.UsagePercent, timestamp))
metrics = append(metrics, createMetric("pika_cpu_cores_logical", agentID, nil, float64(cpuData.LogicalCores), timestamp))
metrics = append(metrics, createMetric("pika_cpu_cores_physical", agentID, nil, float64(cpuData.PhysicalCores), timestamp))
case protocol.MetricTypeMemory:
memData := data.(*protocol.MemoryData)
metrics = append(metrics, createMetric("pika_memory_usage_percent", agentID, nil, memData.UsagePercent, timestamp))
metrics = append(metrics, createMetric("pika_memory_total_bytes", agentID, nil, float64(memData.Total), timestamp))
metrics = append(metrics, createMetric("pika_memory_used_bytes", agentID, nil, float64(memData.Used), timestamp))
metrics = append(metrics, createMetric("pika_memory_available_bytes", agentID, nil, float64(memData.Available), timestamp))
metrics = append(metrics, createMetric("pika_memory_swap_total_bytes", agentID, nil, float64(memData.SwapTotal), timestamp))
metrics = append(metrics, createMetric("pika_memory_swap_used_bytes", agentID, nil, float64(memData.SwapUsed), timestamp))
case protocol.MetricTypeDisk:
diskDataList := data.([]protocol.DiskData)
for _, diskData := range diskDataList {
labels := map[string]string{"mount_point": diskData.MountPoint}
metrics = append(metrics, createMetric("pika_disk_usage_percent", agentID, labels, diskData.UsagePercent, timestamp))
metrics = append(metrics, createMetric("pika_disk_total_bytes", agentID, labels, float64(diskData.Total), timestamp))
metrics = append(metrics, createMetric("pika_disk_used_bytes", agentID, labels, float64(diskData.Used), timestamp))
metrics = append(metrics, createMetric("pika_disk_free_bytes", agentID, labels, float64(diskData.Free), timestamp))
}
case protocol.MetricTypeNetwork:
networkDataList := data.([]protocol.NetworkData)
for _, netData := range networkDataList {
labels := map[string]string{"interface": netData.Interface}
metrics = append(metrics, createMetric("pika_network_sent_bytes_rate", agentID, labels, float64(netData.BytesSentRate), timestamp))
metrics = append(metrics, createMetric("pika_network_recv_bytes_rate", agentID, labels, float64(netData.BytesRecvRate), timestamp))
metrics = append(metrics, createMetric("pika_network_sent_bytes_total", agentID, labels, float64(netData.BytesSentTotal), timestamp))
metrics = append(metrics, createMetric("pika_network_recv_bytes_total", agentID, labels, float64(netData.BytesRecvTotal), timestamp))
}
case protocol.MetricTypeNetworkConnection:
connData := data.(*protocol.NetworkConnectionData)
metrics = append(metrics, createMetric("pika_network_conn_established", agentID, nil, float64(connData.Established), timestamp))
metrics = append(metrics, createMetric("pika_network_conn_syn_sent", agentID, nil, float64(connData.SynSent), timestamp))
metrics = append(metrics, createMetric("pika_network_conn_syn_recv", agentID, nil, float64(connData.SynRecv), timestamp))
metrics = append(metrics, createMetric("pika_network_conn_time_wait", agentID, nil, float64(connData.TimeWait), timestamp))
metrics = append(metrics, createMetric("pika_network_conn_close_wait", agentID, nil, float64(connData.CloseWait), timestamp))
metrics = append(metrics, createMetric("pika_network_conn_total", agentID, nil, float64(connData.Total), timestamp))
case protocol.MetricTypeDiskIO:
diskIODataList := data.([]*protocol.DiskIOData)
// 汇总所有磁盘的 IO
var totalReadRate, totalWriteRate uint64
for _, diskIOData := range diskIODataList {
totalReadRate += diskIOData.ReadBytesRate
totalWriteRate += diskIOData.WriteBytesRate
}
metrics = append(metrics, createMetric("pika_disk_read_bytes_rate", agentID, nil, float64(totalReadRate), timestamp))
metrics = append(metrics, createMetric("pika_disk_write_bytes_rate", agentID, nil, float64(totalWriteRate), timestamp))
case protocol.MetricTypeGPU:
gpuDataList := data.([]protocol.GPUData)
for _, gpuData := range gpuDataList {
labels := map[string]string{
"gpu_index": fmt.Sprintf("%d", gpuData.Index),
"gpu_name": gpuData.Name,
}
metrics = append(metrics, createMetric("pika_gpu_utilization_percent", agentID, labels, gpuData.Utilization, timestamp))
metrics = append(metrics, createMetric("pika_gpu_memory_total_bytes", agentID, labels, float64(gpuData.MemoryTotal), timestamp))
metrics = append(metrics, createMetric("pika_gpu_memory_used_bytes", agentID, labels, float64(gpuData.MemoryUsed), timestamp))
metrics = append(metrics, createMetric("pika_gpu_temperature_celsius", agentID, labels, gpuData.Temperature, timestamp))
metrics = append(metrics, createMetric("pika_gpu_power_draw_watts", agentID, labels, gpuData.PowerUsage, timestamp))
}
case protocol.MetricTypeTemperature:
tempDataList := data.([]protocol.TemperatureData)
for _, tempData := range tempDataList {
labels := map[string]string{
"sensor_label": tempData.Type,
}
metrics = append(metrics, createMetric("pika_temperature_celsius", agentID, labels, tempData.Temperature, timestamp))
}
case protocol.MetricTypeMonitor:
monitorDataList := data.([]protocol.MonitorData)
for _, monitorData := range monitorDataList {
labels := map[string]string{
"monitor_id": monitorData.MonitorId,
"monitor_type": monitorData.Type,
"target": monitorData.Target,
}
metrics = append(metrics, createMetric("pika_monitor_response_time_ms", agentID, labels, float64(monitorData.ResponseTime), timestamp))
}
}
return metrics
}
// createMetric 创建 VictoriaMetrics Metric 对象
func createMetric(metricName, agentID string, extraLabels map[string]string, value float64, timestamp int64) vmclient.Metric {
labels := make(map[string]string)
labels["__name__"] = metricName
labels["agent_id"] = agentID
if extraLabels != nil {
for k, v := range extraLabels {
labels[k] = v
}
}
return vmclient.Metric{
Metric: labels,
Values: []float64{value},
Timestamps: []int64{timestamp},
}
}
+3 -335
View File
@@ -1,152 +1,6 @@
package models
// CPUMetric CPU指标
type CPUMetric struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
AgentID string `gorm:"index:idx_cpu_agent_ts,priority:1" json:"agentId"` // 探针ID
UsagePercent float64 `json:"usagePercent"` // CPU使用率
LogicalCores int `json:"logicalCores"` // 逻辑核心数
PhysicalCores int `json:"physicalCores"` // 物理核心数
ModelName string `json:"modelName"` // CPU型号
Timestamp int64 `gorm:"index:idx_cpu_agent_ts,priority:2;index:idx_cpu_ts" json:"timestamp"` // 时间戳(毫秒)
}
func (CPUMetric) TableName() string {
return "cpu_metrics"
}
// MemoryMetric 内存指标
type MemoryMetric struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
AgentID string `gorm:"index:idx_mem_agent_ts,priority:1" json:"agentId"` // 探针ID
Total uint64 `json:"total"` // 总内存(字节)
Used uint64 `json:"used"` // 已使用(字节)
Free uint64 `json:"free"` // 空闲(字节)
Available uint64 `json:"available"` // 可用内存(字节)
UsagePercent float64 `json:"usagePercent"` // 使用率
SwapTotal uint64 `json:"swapTotal"` // 总交换空间(字节)
SwapUsed uint64 `json:"swapUsed"` // 已使用交换空间(字节)
SwapFree uint64 `json:"swapFree"` // 空闲交换空间(字节)
Timestamp int64 `gorm:"index:idx_mem_agent_ts,priority:2;index:idx_mem_ts" json:"timestamp"` // 时间戳(毫秒)
}
func (MemoryMetric) TableName() string {
return "memory_metrics"
}
// DiskMetric 磁盘指标
type DiskMetric struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
AgentID string `gorm:"index:idx_disk_agent_ts,priority:1" json:"agentId"` // 探针ID
MountPoint string `json:"mountPoint"` // 挂载点
Total uint64 `json:"total"` // 总容量(字节)
Used uint64 `json:"used"` // 已使用(字节)
Free uint64 `json:"free"` // 空闲(字节)
UsagePercent float64 `json:"usagePercent"` // 使用率
Timestamp int64 `gorm:"index:idx_disk_agent_ts,priority:2;index:idx_disk_ts" json:"timestamp"` // 时间戳(毫秒)
}
func (DiskMetric) TableName() string {
return "disk_metrics"
}
// NetworkMetric 网络指标
type NetworkMetric struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
AgentID string `gorm:"index:idx_net_agent_ts,priority:1" json:"agentId"` // 探针ID
Interface string `json:"interface"` // 网卡名称
BytesSentRate uint64 `json:"bytesSentRate"` // 发送速率(字节/秒)
BytesRecvRate uint64 `json:"bytesRecvRate"` // 接收速率(字节/秒)
BytesSentTotal uint64 `json:"bytesSentTotal"` // 累计发送字节数
BytesRecvTotal uint64 `json:"bytesRecvTotal"` // 累计接收字节数
Timestamp int64 `gorm:"index:idx_net_agent_ts,priority:2;index:idx_net_ts" json:"timestamp"` // 时间戳(毫秒)
}
func (NetworkMetric) TableName() string {
return "network_metrics"
}
// NetworkConnectionMetric 网络连接统计指标
type NetworkConnectionMetric struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
AgentID string `gorm:"index:idx_netconn_agent_ts,priority:1" json:"agentId"` // 探针ID
Established uint32 `json:"established"` // ESTABLISHED 状态连接数
SynSent uint32 `json:"synSent"` // SYN_SENT 状态连接数
SynRecv uint32 `json:"synRecv"` // SYN_RECV 状态连接数
FinWait1 uint32 `json:"finWait1"` // FIN_WAIT1 状态连接数
FinWait2 uint32 `json:"finWait2"` // FIN_WAIT2 状态连接数
TimeWait uint32 `json:"timeWait"` // TIME_WAIT 状态连接数
Close uint32 `json:"close"` // CLOSE 状态连接数
CloseWait uint32 `json:"closeWait"` // CLOSE_WAIT 状态连接数
LastAck uint32 `json:"lastAck"` // LAST_ACK 状态连接数
Listen uint32 `json:"listen"` // LISTEN 状态连接数
Closing uint32 `json:"closing"` // CLOSING 状态连接数
Total uint32 `json:"total"` // 总连接数
Timestamp int64 `gorm:"index:idx_netconn_agent_ts,priority:2;index:idx_netconn_ts" json:"timestamp"` // 时间戳(毫秒)
}
func (NetworkConnectionMetric) TableName() string {
return "network_connection_metrics"
}
// DiskIOMetric 磁盘IO指标
type DiskIOMetric struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
AgentID string `gorm:"index:idx_diskio_agent_ts,priority:1" json:"agentId"` // 探针ID
Device string `json:"device"` // 设备名称
ReadCount uint64 `json:"readCount"` // 读取次数
WriteCount uint64 `json:"writeCount"` // 写入次数
ReadBytes uint64 `json:"readBytes"` // 读取字节数
WriteBytes uint64 `json:"writeBytes"` // 写入字节数
ReadBytesRate uint64 `json:"readBytesRate"` // 读取速率(字节/秒)
WriteBytesRate uint64 `json:"writeBytesRate"` // 写入速率(字节/秒)
ReadTime uint64 `json:"readTime"` // 读取时间(毫秒)
WriteTime uint64 `json:"writeTime"` // 写入时间(毫秒)
IoTime uint64 `json:"ioTime"` // IO时间(毫秒)
IopsInProgress uint64 `json:"iopsInProgress"` // 正在进行的IO操作数
Timestamp int64 `gorm:"index:idx_diskio_agent_ts,priority:2;index:idx_diskio_ts" json:"timestamp"` // 时间戳(毫秒)
}
func (DiskIOMetric) TableName() string {
return "disk_io_metrics"
}
// GPUMetric GPU指标
type GPUMetric struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
AgentID string `gorm:"index:idx_gpu_agent_idx_ts,priority:1" json:"agentId"` // 探针ID
Index int `gorm:"index:idx_gpu_agent_idx_ts,priority:2" json:"index"` // GPU索引
Name string `json:"name"` // GPU名称
Utilization float64 `json:"utilization"` // GPU使用率(%)
MemoryTotal uint64 `json:"memoryTotal"` // 总显存(字节)
MemoryUsed uint64 `json:"memoryUsed"` // 已使用显存(字节)
MemoryFree uint64 `json:"memoryFree"` // 空闲显存(字节)
Temperature float64 `json:"temperature"` // 温度(℃)
PowerDraw float64 `json:"powerDraw"` // 功耗(瓦)
FanSpeed float64 `json:"fanSpeed"` // 风扇转速(%)
PerformanceState string `json:"performanceState"` // 性能状态
Timestamp int64 `gorm:"index:idx_gpu_agent_idx_ts,priority:3;index:idx_gpu_ts" json:"timestamp"` // 时间戳(毫秒)
}
func (GPUMetric) TableName() string {
return "gpu_metrics"
}
// TemperatureMetric 温度指标
type TemperatureMetric struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
AgentID string `gorm:"index:idx_temp_agent_sensor_ts,priority:1" json:"agentId"` // 探针ID
SensorKey string `gorm:"index:idx_temp_agent_sensor_ts,priority:2" json:"sensorKey"` // 传感器标识
SensorLabel string `json:"sensorLabel"` // 传感器标签
Temperature float64 `json:"temperature"` // 温度(℃)
Timestamp int64 `gorm:"index:idx_temp_agent_sensor_ts,priority:3;index:idx_temp_ts" json:"timestamp"` // 时间戳(毫秒)
}
func (TemperatureMetric) TableName() string {
return "temperature_metrics"
}
// HostMetric 主机信息指标
// HostMetric 主机信息指标(静态信息,保存在 PostgreSQL)
type HostMetric struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
AgentID string `gorm:"uniqueIndex:ux_host_agent" json:"agentId"` // 探针ID(唯一约束用于 upsert)
@@ -165,191 +19,5 @@ func (HostMetric) TableName() string {
return "host_metrics"
}
// MonitorMetric 监控指标
type MonitorMetric struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
AgentId string `gorm:"index:idx_mon_agent_monitor_ts,priority:1" json:"agentId"` // 探针ID
MonitorId string `gorm:"index:idx_mon_agent_monitor_ts,priority:2;index:idx_mon_type_monitor_ts,priority:2" json:"monitorId"` // 监控项ID
Type string `gorm:"index:idx_mon_type_monitor_ts,priority:1" json:"type"` // 监控类型: http, tcp
Target string `json:"target"` // 监控目标
Status string `json:"status"` // 状态: up, down
StatusCode int `json:"statusCode"` // HTTP状态码
ResponseTime int64 `json:"responseTime"` // 响应时间(毫秒)
Error string `json:"error"` // 错误信息
Message string `json:"message"` // 附加信息
ContentMatch bool `json:"contentMatch"` // 内容匹配结果
CertExpiryTime int64 `json:"certExpiryTime"` // 证书过期时间(毫秒时间戳), 0表示无证书
CertDaysLeft int `json:"certDaysLeft"` // 证书剩余天数
Timestamp int64 `gorm:"index:idx_mon_agent_monitor_ts,priority:3;index:idx_mon_type_monitor_ts,priority:3;index:idx_mon_ts" json:"timestamp"` // 时间戳(毫秒)
}
func (MonitorMetric) TableName() string {
return "monitor_metrics"
}
// ----------- 聚合表 -----------
// AggregatedCPUMetricModel CPU聚合表
type AggregatedCPUMetricModel struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
AgentID string `gorm:"index:idx_cpuagg_agent_bucket,priority:1;uniqueIndex:ux_cpuagg_bucket,priority:1" json:"agentId"`
BucketSeconds int `gorm:"index:idx_cpuagg_agent_bucket,priority:2;uniqueIndex:ux_cpuagg_bucket,priority:2" json:"bucketSeconds"`
BucketStart int64 `gorm:"index:idx_cpuagg_agent_bucket,priority:3;uniqueIndex:ux_cpuagg_bucket,priority:3" json:"bucketStart"` // 毫秒
MaxUsage float64 `json:"maxUsage"`
LogicalCores int `json:"logicalCores"`
}
func (AggregatedCPUMetricModel) TableName() string {
return "cpu_metrics_aggs"
}
// AggregatedMemoryMetricModel 内存聚合表
type AggregatedMemoryMetricModel struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
AgentID string `gorm:"index:idx_memagg_agent_bucket,priority:1;uniqueIndex:ux_memagg_bucket,priority:1" json:"agentId"`
BucketSeconds int `gorm:"index:idx_memagg_agent_bucket,priority:2;uniqueIndex:ux_memagg_bucket,priority:2" json:"bucketSeconds"`
BucketStart int64 `gorm:"index:idx_memagg_agent_bucket,priority:3;uniqueIndex:ux_memagg_bucket,priority:3" json:"bucketStart"` // 毫秒
MaxUsage float64 `json:"maxUsage"`
Total uint64 `json:"total"`
}
func (AggregatedMemoryMetricModel) TableName() string {
return "memory_metrics_aggs"
}
// AggregatedDiskMetricModel 磁盘聚合表
type AggregatedDiskMetricModel struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
AgentID string `gorm:"index:idx_diskagg_agent_bucket_mp,priority:1;uniqueIndex:ux_diskagg_bucket,priority:1" json:"agentId"`
BucketSeconds int `gorm:"index:idx_diskagg_agent_bucket_mp,priority:2;uniqueIndex:ux_diskagg_bucket,priority:2" json:"bucketSeconds"`
BucketStart int64 `gorm:"index:idx_diskagg_agent_bucket_mp,priority:3;uniqueIndex:ux_diskagg_bucket,priority:3" json:"bucketStart"` // 毫秒
MountPoint string `gorm:"index:idx_diskagg_agent_bucket_mp,priority:4;uniqueIndex:ux_diskagg_bucket,priority:4" json:"mountPoint"`
MaxUsage float64 `json:"maxUsage"`
Total uint64 `json:"total"`
}
func (AggregatedDiskMetricModel) TableName() string {
return "disk_metrics_aggs"
}
// AggregatedNetworkMetricModel 网络聚合表(按网卡分组)
type AggregatedNetworkMetricModel struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
AgentID string `gorm:"index:idx_netagg_agent_bucket_iface,priority:1;uniqueIndex:ux_netagg_bucket,priority:1" json:"agentId"`
BucketSeconds int `gorm:"index:idx_netagg_agent_bucket_iface,priority:2;uniqueIndex:ux_netagg_bucket,priority:2" json:"bucketSeconds"`
BucketStart int64 `gorm:"index:idx_netagg_agent_bucket_iface,priority:3;uniqueIndex:ux_netagg_bucket,priority:3" json:"bucketStart"` // 毫秒
Interface string `gorm:"index:idx_netagg_agent_bucket_iface,priority:4;uniqueIndex:ux_netagg_bucket,priority:4" json:"interface"`
MaxSentRate float64 `json:"maxSentRate"`
MaxRecvRate float64 `json:"maxRecvRate"`
}
func (AggregatedNetworkMetricModel) TableName() string {
return "network_metrics_aggs"
}
// AggregatedNetworkConnectionMetricModel 网络连接聚合表
type AggregatedNetworkConnectionMetricModel struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
AgentID string `gorm:"index:idx_netconnagg_agent_bucket,priority:1;uniqueIndex:ux_netconnagg_bucket,priority:1" json:"agentId"`
BucketSeconds int `gorm:"index:idx_netconnagg_agent_bucket,priority:2;uniqueIndex:ux_netconnagg_bucket,priority:2" json:"bucketSeconds"`
BucketStart int64 `gorm:"index:idx_netconnagg_agent_bucket,priority:3;uniqueIndex:ux_netconnagg_bucket,priority:3" json:"bucketStart"` // 毫秒
MaxEstablished uint32 `json:"maxEstablished"`
MaxSynSent uint32 `json:"maxSynSent"`
MaxSynRecv uint32 `json:"maxSynRecv"`
MaxFinWait1 uint32 `json:"maxFinWait1"`
MaxFinWait2 uint32 `json:"maxFinWait2"`
MaxTimeWait uint32 `json:"maxTimeWait"`
MaxClose uint32 `json:"maxClose"`
MaxCloseWait uint32 `json:"maxCloseWait"`
MaxLastAck uint32 `json:"maxLastAck"`
MaxListen uint32 `json:"maxListen"`
MaxClosing uint32 `json:"maxClosing"`
MaxTotal uint32 `json:"maxTotal"`
}
func (AggregatedNetworkConnectionMetricModel) TableName() string {
return "network_connection_metrics_aggs"
}
// AggregatedDiskIOMetricModel 磁盘IO聚合表(汇总所有磁盘)
type AggregatedDiskIOMetricModel struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
AgentID string `gorm:"index:idx_diskioagg_agent_bucket,priority:1;uniqueIndex:ux_diskioagg_bucket,priority:1" json:"agentId"`
BucketSeconds int `gorm:"index:idx_diskioagg_agent_bucket,priority:2;uniqueIndex:ux_diskioagg_bucket,priority:2" json:"bucketSeconds"`
BucketStart int64 `gorm:"index:idx_diskioagg_agent_bucket,priority:3;uniqueIndex:ux_diskioagg_bucket,priority:3" json:"bucketStart"` // 毫秒
MaxReadBytesRate uint64 `json:"maxReadBytesRate"`
MaxWriteBytesRate uint64 `json:"maxWriteBytesRate"`
MaxIopsInProgress uint64 `json:"maxIopsInProgress"`
}
func (AggregatedDiskIOMetricModel) TableName() string {
return "disk_io_metrics_aggs"
}
// AggregatedGPUMetricModel GPU聚合表
type AggregatedGPUMetricModel struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
AgentID string `gorm:"index:idx_gpuagg_agent_bucket_idx,priority:1;uniqueIndex:ux_gpuagg_bucket,priority:1" json:"agentId"`
BucketSeconds int `gorm:"index:idx_gpuagg_agent_bucket_idx,priority:2;uniqueIndex:ux_gpuagg_bucket,priority:2" json:"bucketSeconds"`
BucketStart int64 `gorm:"index:idx_gpuagg_agent_bucket_idx,priority:3;uniqueIndex:ux_gpuagg_bucket,priority:3" json:"bucketStart"` // 毫秒
Index int `gorm:"index:idx_gpuagg_agent_bucket_idx,priority:4;uniqueIndex:ux_gpuagg_bucket,priority:4" json:"index"`
Name string `json:"name"`
MaxUtilization float64 `json:"maxUtilization"`
MaxMemoryUsed uint64 `json:"maxMemoryUsed"`
MaxTemperature float64 `json:"maxTemperature"`
MaxPowerDraw float64 `json:"maxPowerDraw"`
MemoryTotal uint64 `json:"memoryTotal"`
}
func (AggregatedGPUMetricModel) TableName() string {
return "gpu_metrics_aggs"
}
// AggregatedTemperatureMetricModel 温度聚合表
type AggregatedTemperatureMetricModel struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
AgentID string `gorm:"index:idx_tempagg_agent_bucket_sensor,priority:1;uniqueIndex:ux_tempagg_bucket,priority:1" json:"agentId"`
BucketSeconds int `gorm:"index:idx_tempagg_agent_bucket_sensor,priority:2;uniqueIndex:ux_tempagg_bucket,priority:2" json:"bucketSeconds"`
BucketStart int64 `gorm:"index:idx_tempagg_agent_bucket_sensor,priority:3;uniqueIndex:ux_tempagg_bucket,priority:3" json:"bucketStart"` // 毫秒
SensorKey string `gorm:"index:idx_tempagg_agent_bucket_sensor,priority:4;uniqueIndex:ux_tempagg_bucket,priority:4" json:"sensorKey"`
SensorLabel string `json:"sensorLabel"`
MaxTemperature float64 `json:"maxTemperature"`
}
func (AggregatedTemperatureMetricModel) TableName() string {
return "temperature_metrics_aggs"
}
// AggregationProgress 聚合进度记录
type AggregationProgress struct {
MetricType string `gorm:"primaryKey"` // cpu/memory/disk/network
BucketSeconds int `gorm:"primaryKey"`
LastBucket int64 `json:"lastBucket"` // 已完成的最后 bucket 起始时间(毫秒)
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli"`
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"`
}
func (AggregationProgress) TableName() string {
return "aggregation_progress"
}
// AggregatedMonitorMetricModel 监控指标聚合表
type AggregatedMonitorMetricModel struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
MonitorID string `gorm:"index:idx_monitoragg_monitor_agent_bucket,priority:1;uniqueIndex:ux_monitoragg_bucket,priority:1" json:"monitorId"`
AgentID string `gorm:"index:idx_monitoragg_monitor_agent_bucket,priority:2;uniqueIndex:ux_monitoragg_bucket,priority:2" json:"agentId"`
BucketSeconds int `gorm:"index:idx_monitoragg_monitor_agent_bucket,priority:3;uniqueIndex:ux_monitoragg_bucket,priority:3" json:"bucketSeconds"`
BucketStart int64 `gorm:"index:idx_monitoragg_monitor_agent_bucket,priority:4;uniqueIndex:ux_monitoragg_bucket,priority:4" json:"bucketStart"` // 毫秒
AvgResponse float64 `json:"avgResponse"` // 平均响应时间(ms)
MaxResponse int64 `json:"maxResponse"` // 最大响应时间(ms)
MinResponse int64 `json:"minResponse"` // 最小响应时间(ms)
SuccessCount int64 `json:"successCount"` // 成功次数
TotalCount int64 `json:"totalCount"` // 总次数
SuccessRate float64 `json:"successRate"` // 成功率(百分比)
LastStatus string `json:"lastStatus"` // 最后状态
LastError string `json:"lastError"` // 最后错误信息
}
func (AggregatedMonitorMetricModel) TableName() string {
return "monitor_metrics_aggs"
}
// 注意:所有其他指标数据已迁移到 VictoriaMetrics
// 包括:CPU, Memory, Disk, Network, NetworkConnection, DiskIO, GPU, Temperature, Monitor
-30
View File
@@ -1,30 +0,0 @@
package models
// MonitorStats 监控统计数据
type MonitorStats struct {
ID string `gorm:"primaryKey" json:"id"` // ID
AgentID string `json:"agentId"` // 探针ID
AgentName string `gorm:"-" json:"agentName,omitempty"` // 探针名称(不存储在数据库,仅用于 API 返回)
MonitorId string `json:"monitorId"` // 监控项ID
MonitorName string `gorm:"-" json:"name"` // 监控项名称(不存储在数据库,仅用于 API 返回)
MonitorType string `json:"type"` // 监控类型
Target string `json:"target"` // 目标地址
CurrentResponse int64 `json:"currentResponse"` // 当前响应时间(ms)
AvgResponse24h int64 `json:"avgResponse24h"` // 24小时平均响应时间(ms)
Uptime24h float64 `json:"uptime24h"` // 24小时在线率(百分比)
Uptime7d float64 `json:"uptime7d"` // 7天在线率(百分比)
CertExpiryDate int64 `json:"certExpiryDate"` // 证书过期时间(毫秒时间戳),0表示无证书
CertExpiryDays int `json:"certExpiryDays"` // 证书剩余天数
TotalChecks24h int64 `json:"totalChecks24h"` // 24小时总检测次数
SuccessChecks24h int64 `json:"successChecks24h"` // 24小时成功次数
TotalChecks7d int64 `json:"totalChecks7d"` // 7天总检测次数
SuccessChecks7d int64 `json:"successChecks7d"` // 7天成功次数
LastCheckTime int64 `json:"lastCheckTime"` // 最后检测时间
LastCheckStatus string `json:"lastCheckStatus"` // 最后检测状态: up/down
LastCheckError string `json:"lastCheckError"` // 最后检测错误信息
UpdatedAt int64 `gorm:"autoUpdateTime:milli" json:"updatedAt"` // 更新时间
}
func (MonitorStats) TableName() string {
return "monitor_stats"
}
-5
View File
@@ -80,11 +80,6 @@ var TimeRangeOptions = []TimeRangeOption{
{Label: "7天", Value: "7d"},
}
// MetricsConfig 指标数据配置
type MetricsConfig struct {
RetentionHours int `json:"retentionHours"` // 原始数据保留小时数(默认168小时=7天)
}
// AlertConfig 全局告警配置
type AlertConfig struct {
Enabled bool `json:"enabled"` // 是否启用全局告警
+16 -9
View File
@@ -2,12 +2,18 @@ package protocol
import "encoding/json"
// Message WebSocket消息结构
type Message struct {
// InputMessage WebSocket消息结构(主要用于接收)
type InputMessage struct {
Type MessageType `json:"type"`
Data json.RawMessage `json:"data"`
}
// OutboundMessage WebSocket 出站消息结构
type OutboundMessage struct {
Type MessageType `json:"type"`
Data interface{} `json:"data"`
}
// RegisterRequest 注册请求
type RegisterRequest struct {
AgentInfo AgentInfo `json:"agentInfo"`
@@ -31,12 +37,11 @@ type AgentInfo struct {
Version string `json:"version"` // 版本号
}
// MetricsWrapper 指标数据包装
type MetricsWrapper struct {
Type MetricType `json:"type"`
Data json.RawMessage `json:"data"`
// MetricsPayload 指标数据包装,发送端/接收端统一使用
type MetricsPayload struct {
Type MetricType `json:"type"`
Data interface{} `json:"data"`
}
type MessageType string
// 控制消息
@@ -283,9 +288,11 @@ type Evidence struct {
// MonitorData 监控数据
type MonitorData struct {
ID string `json:"id"` // 监控项ID
AgentId string `json:"agentId"` // 探针 ID
AgentName string `json:"agentName"` // 探针名称
MonitorId string `json:"monitorId"` // 监控项ID
Type string `json:"type"` // 监控类型: http, tcp
Target string `json:"target"` // 监控目标
Target string `json:"target,omitempty"` // 监控目标
Status string `json:"status"` // 状态: up, down
StatusCode int `json:"statusCode,omitempty"` // HTTP 状态码
ResponseTime int64 `json:"responseTime"` // 响应时间(毫秒)
+3 -958
View File
@@ -18,41 +18,6 @@ func NewMetricRepo(db *gorm.DB) *MetricRepo {
}
}
// SaveCPUMetric 保存CPU指标
func (r *MetricRepo) SaveCPUMetric(ctx context.Context, metric *models.CPUMetric) error {
return r.db.WithContext(ctx).Create(metric).Error
}
// SaveMemoryMetric 保存内存指标
func (r *MetricRepo) SaveMemoryMetric(ctx context.Context, metric *models.MemoryMetric) error {
return r.db.WithContext(ctx).Create(metric).Error
}
// SaveDiskMetric 保存磁盘指标
func (r *MetricRepo) SaveDiskMetric(ctx context.Context, metric *models.DiskMetric) error {
return r.db.WithContext(ctx).Create(metric).Error
}
// SaveNetworkMetric 保存网络指标
func (r *MetricRepo) SaveNetworkMetric(ctx context.Context, metric *models.NetworkMetric) error {
return r.db.WithContext(ctx).Create(metric).Error
}
// SaveDiskIOMetric 保存磁盘IO指标
func (r *MetricRepo) SaveDiskIOMetric(ctx context.Context, metric *models.DiskIOMetric) error {
return r.db.WithContext(ctx).Create(metric).Error
}
// SaveGPUMetric 保存GPU指标
func (r *MetricRepo) SaveGPUMetric(ctx context.Context, metric *models.GPUMetric) error {
return r.db.WithContext(ctx).Create(metric).Error
}
// SaveTemperatureMetric 保存温度指标
func (r *MetricRepo) SaveTemperatureMetric(ctx context.Context, metric *models.TemperatureMetric) error {
return r.db.WithContext(ctx).Create(metric).Error
}
// SaveHostMetric 保存主机信息指标(按 agent 覆盖,避免先删后插的空窗)
func (r *MetricRepo) SaveHostMetric(ctx context.Context, metric *models.HostMetric) error {
return r.db.WithContext(ctx).
@@ -63,930 +28,10 @@ func (r *MetricRepo) SaveHostMetric(ctx context.Context, metric *models.HostMetr
Create(metric).Error
}
// SaveNetworkConnectionMetric 保存网络连接统计指标
func (r *MetricRepo) SaveNetworkConnectionMetric(ctx context.Context, metric *models.NetworkConnectionMetric) error {
return r.db.WithContext(ctx).Create(metric).Error
}
// AggregatedNetworkConnectionMetric 网络连接统计聚合指标
type AggregatedNetworkConnectionMetric struct {
Timestamp int64 `json:"timestamp"`
MaxEstablished uint32 `json:"maxEstablished"` // 最大已建立连接数
MaxSynSent uint32 `json:"maxSynSent"` // 最大 SYN_SENT 连接数
MaxSynRecv uint32 `json:"maxSynRecv"` // 最大 SYN_RECV 连接数
MaxFinWait1 uint32 `json:"maxFinWait1"` // 最大 FIN_WAIT1 连接数
MaxFinWait2 uint32 `json:"maxFinWait2"` // 最大 FIN_WAIT2 连接数
MaxTimeWait uint32 `json:"maxTimeWait"` // 最大 TIME_WAIT 连接数
MaxClose uint32 `json:"maxClose"` // 最大 CLOSE 连接数
MaxCloseWait uint32 `json:"maxCloseWait"` // 最大 CLOSE_WAIT 连接数
MaxLastAck uint32 `json:"maxLastAck"` // 最大 LAST_ACK 连接数
MaxListen uint32 `json:"maxListen"` // 最大 LISTEN 连接数
MaxClosing uint32 `json:"maxClosing"` // 最大 CLOSING 连接数
MaxTotal uint32 `json:"maxTotal"` // 最大总连接数
}
// GetNetworkConnectionMetrics 获取聚合后的网络连接统计指标
func (r *MetricRepo) GetNetworkConnectionMetrics(ctx context.Context, agentID string, start, end int64, interval int) ([]AggregatedNetworkConnectionMetric, error) {
var metrics []AggregatedNetworkConnectionMetric
query := `
SELECT
CAST(FLOOR(timestamp / ?) * ? AS BIGINT) as timestamp,
MAX(established) as max_established,
MAX(syn_sent) as max_syn_sent,
MAX(syn_recv) as max_syn_recv,
MAX(fin_wait1) as max_fin_wait1,
MAX(fin_wait2) as max_fin_wait2,
MAX(time_wait) as max_time_wait,
MAX(close) as max_close,
MAX(close_wait) as max_close_wait,
MAX(last_ack) as max_last_ack,
MAX(listen) as max_listen,
MAX(closing) as max_closing,
MAX(total) as max_total
FROM network_connection_metrics
WHERE agent_id = ? AND timestamp >= ? AND timestamp <= ?
GROUP BY 1
ORDER BY timestamp ASC
`
intervalMs := int64(interval * 1000)
err := r.db.WithContext(ctx).
Raw(query, intervalMs, intervalMs, agentID, start, end).
Scan(&metrics).Error
return metrics, err
}
// DeleteOldMetrics 删除指定时间之前的所有指标数据
func (r *MetricRepo) DeleteOldMetrics(ctx context.Context, beforeTimestamp int64) error {
// 批量大小
batchSize := 1000
// 定义要清理的表(Host 信息只保留最新的,不需要清理)
tables := []interface{}{
&models.CPUMetric{},
&models.MemoryMetric{},
&models.DiskMetric{},
&models.NetworkMetric{},
&models.NetworkConnectionMetric{},
&models.DiskIOMetric{},
&models.GPUMetric{},
&models.TemperatureMetric{},
&models.MonitorMetric{},
}
// 对每个表进行分批删除
for _, table := range tables {
for {
// 分批删除,避免长事务
result := r.db.WithContext(ctx).
Where("timestamp < ?", beforeTimestamp).
Limit(batchSize).
Delete(table)
if result.Error != nil {
return result.Error
}
// 如果删除的行数少于批量大小,说明已经删除完毕
if result.RowsAffected < int64(batchSize) {
break
}
}
}
return nil
}
// AggregatedCPUMetric CPU聚合指标(使用最大值)
type AggregatedCPUMetric struct {
Timestamp int64 `json:"timestamp"`
MaxUsage float64 `json:"maxUsage"`
LogicalCores int `json:"logicalCores"`
}
// GetCPUMetrics 获取聚合后的CPU指标(取最大值)
// interval: 聚合间隔,单位秒(如:60表示1分钟)
func (r *MetricRepo) GetCPUMetrics(ctx context.Context, agentID string, start, end int64, interval int) ([]AggregatedCPUMetric, error) {
var metrics []AggregatedCPUMetric
query := `
SELECT
CAST(FLOOR(timestamp / ?) * ? AS BIGINT) as timestamp,
MAX(usage_percent) as max_usage,
MAX(logical_cores) as logical_cores
FROM cpu_metrics
WHERE agent_id = ? AND timestamp >= ? AND timestamp <= ?
GROUP BY 1
ORDER BY timestamp ASC
`
intervalMs := int64(interval * 1000)
err := r.db.WithContext(ctx).
Raw(query, intervalMs, intervalMs, agentID, start, end).
Scan(&metrics).Error
return metrics, err
}
// AggregatedMemoryMetric 内存聚合指标(使用最大值)
type AggregatedMemoryMetric struct {
Timestamp int64 `json:"timestamp"`
MaxUsage float64 `json:"maxUsage"`
Total uint64 `json:"total"`
}
// GetMemoryMetrics 获取聚合后的内存指标(取最大值)
func (r *MetricRepo) GetMemoryMetrics(ctx context.Context, agentID string, start, end int64, interval int) ([]AggregatedMemoryMetric, error) {
var metrics []AggregatedMemoryMetric
query := `
SELECT
CAST(FLOOR(timestamp / ?) * ? AS BIGINT) as timestamp,
MAX(usage_percent) as max_usage,
MAX(total) as total
FROM memory_metrics
WHERE agent_id = ? AND timestamp >= ? AND timestamp <= ?
GROUP BY 1
ORDER BY timestamp ASC
`
intervalMs := int64(interval * 1000)
err := r.db.WithContext(ctx).
Raw(query, intervalMs, intervalMs, agentID, start, end).
Scan(&metrics).Error
return metrics, err
}
// AggregatedDiskMetric 磁盘聚合指标(使用最大值)
type AggregatedDiskMetric struct {
Timestamp int64 `json:"timestamp"`
MountPoint string `json:"mountPoint"`
MaxUsage float64 `json:"maxUsage"`
Total uint64 `json:"total"`
}
// GetDiskMetrics 获取聚合后的磁盘指标(返回预聚合的总和数据)
// 直接查询 mount_point=” 的预聚合记录
func (r *MetricRepo) GetDiskMetrics(ctx context.Context, agentID string, start, end int64, interval int) ([]AggregatedDiskMetric, error) {
var metrics []AggregatedDiskMetric
query := `
SELECT
CAST(FLOOR(timestamp / ?) * ? AS BIGINT) as timestamp,
mount_point,
MAX(usage_percent) as max_usage,
MAX(total) as total
FROM disk_metrics
WHERE agent_id = ? AND timestamp >= ? AND timestamp <= ? AND mount_point = ?
GROUP BY 1, mount_point
ORDER BY timestamp ASC
`
intervalMs := int64(interval * 1000)
err := r.db.WithContext(ctx).
Raw(query, intervalMs, intervalMs, agentID, start, end, ""). // 空字符串查询总和记录
Scan(&metrics).Error
return metrics, err
}
// AggregatedNetworkMetric 网络聚合指标(按网卡分组)
type AggregatedNetworkMetric struct {
Timestamp int64 `json:"timestamp"`
Interface string `json:"interface"`
MaxSentRate float64 `json:"maxSentRate"`
MaxRecvRate float64 `json:"maxRecvRate"`
}
// AggregatedNetworkMetricByInterface 按网卡接口分组的网络聚合指标
type AggregatedNetworkMetricByInterface struct {
Timestamp int64 `json:"timestamp"`
Interface string `json:"interface"`
MaxSentRate float64 `json:"maxSentRate"`
MaxRecvRate float64 `json:"maxRecvRate"`
}
// GetNetworkMetrics 获取聚合后的网络指标(可选按网卡接口过滤)
// 不指定网卡时查询 interface=” 的预聚合数据,指定网卡时只返回该网卡的数据
func (r *MetricRepo) GetNetworkMetrics(ctx context.Context, agentID string, start, end int64, interval int, interfaceName string) ([]AggregatedNetworkMetric, error) {
var metrics []AggregatedNetworkMetric
intervalMs := int64(interval * 1000)
// 不管是否指定网卡,查询逻辑都一样:直接查询对应 interface 的数据
// interfaceName 为空字符串时,会查询到预先保存的总和数据
query := `
SELECT
CAST(FLOOR(timestamp / ?) * ? AS BIGINT) as timestamp,
interface,
MAX(bytes_sent_rate) as max_sent_rate,
MAX(bytes_recv_rate) as max_recv_rate
FROM network_metrics
WHERE agent_id = ? AND timestamp >= ? AND timestamp <= ? AND interface = ?
GROUP BY 1, interface
ORDER BY timestamp ASC
`
err := r.db.WithContext(ctx).
Raw(query, intervalMs, intervalMs, agentID, start, end, interfaceName).
Scan(&metrics).Error
return metrics, err
}
// SaveMonitorMetric 保存监控指标
func (r *MetricRepo) SaveMonitorMetric(ctx context.Context, metric *models.MonitorMetric) error {
return r.db.WithContext(ctx).Create(metric).Error
}
// GetMonitorMetrics 获取监控指标列表
func (r *MetricRepo) GetMonitorMetrics(ctx context.Context, agentID, monitorID string, start, end int64) ([]models.MonitorMetric, error) {
var metrics []models.MonitorMetric
query := r.db.WithContext(ctx).
Where("agent_id = ? AND timestamp >= ? AND timestamp <= ?", agentID, start, end)
// 如果指定了监控项ID,则只查询该监控项
if monitorID != "" {
query = query.Where("monitor_id = ?", monitorID)
}
err := query.Order("timestamp ASC").Find(&metrics).Error
return metrics, err
}
// GetMonitorMetricsByName 获取指定监控项的历史数据
func (r *MetricRepo) GetMonitorMetricsByName(ctx context.Context, agentID, monitorID string, start, end int64, limit int) ([]models.MonitorMetric, error) {
var metrics []models.MonitorMetric
query := r.db.WithContext(ctx).
Where("agent_id = ? AND monitor_id = ? AND timestamp >= ? AND timestamp <= ?", agentID, monitorID, start, end).
Order("timestamp DESC")
if limit > 0 {
query = query.Limit(limit)
}
err := query.Find(&metrics).Error
return metrics, err
}
// AggregatedMonitorMetric 聚合的监控指标
type AggregatedMonitorMetric struct {
Timestamp int64 `json:"timestamp"`
AgentID string `json:"agentId"`
AvgResponse float64 `json:"avgResponse"` // 平均响应时间
MaxResponse int64 `json:"maxResponse"` // 最大响应时间
MinResponse int64 `json:"minResponse"` // 最小响应时间
SuccessCount int64 `json:"successCount"` // 成功次数
TotalCount int64 `json:"totalCount"` // 总次数
SuccessRate float64 `json:"successRate"` // 成功率
LastStatus string `json:"lastStatus"` // 最后状态
LastErrorMsg string `json:"lastErrorMsg"` // 最后错误信息
}
// GetAggregatedMonitorMetrics 获取聚合后的监控指标(按探针和时间间隔聚合)
func (r *MetricRepo) GetAggregatedMonitorMetrics(ctx context.Context, monitorID string, start, end int64, interval int) ([]AggregatedMonitorMetric, error) {
var metrics []AggregatedMonitorMetric
query := `
WITH ranked_metrics AS (
SELECT
agent_id,
CAST(FLOOR(timestamp / ?) * ? AS BIGINT) as time_bucket,
response_time,
status,
error,
timestamp,
ROW_NUMBER() OVER (PARTITION BY agent_id, CAST(FLOOR(timestamp / ?) * ? AS BIGINT) ORDER BY timestamp DESC) as rn
FROM monitor_metrics
WHERE monitor_id = ? AND timestamp >= ? AND timestamp <= ?
)
SELECT
time_bucket as timestamp,
agent_id,
AVG(response_time) as avg_response,
MAX(response_time) as max_response,
MIN(response_time) as min_response,
SUM(CASE WHEN status = 'up' THEN 1 ELSE 0 END) as success_count,
COUNT(*) as total_count,
CAST(SUM(CASE WHEN status = 'up' THEN 1 ELSE 0 END) AS REAL) / CAST(COUNT(*) AS REAL) * 100 as success_rate,
MAX(CASE WHEN rn = 1 THEN status END) as last_status,
MAX(CASE WHEN rn = 1 THEN error END) as last_error_msg
FROM ranked_metrics
GROUP BY time_bucket, agent_id
ORDER BY timestamp ASC, agent_id
`
intervalMs := int64(interval * 1000)
err := r.db.WithContext(ctx).
Raw(query, intervalMs, intervalMs, intervalMs, intervalMs, monitorID, start, end).
Scan(&metrics).Error
return metrics, err
}
// AggregatedDiskIOMetric 磁盘IO聚合指标(所有磁盘汇总)
type AggregatedDiskIOMetric struct {
Timestamp int64 `json:"timestamp"`
MaxReadRate float64 `json:"maxReadRate"` // 最大读取速率(字节/秒)
MaxWriteRate float64 `json:"maxWriteRate"` // 最大写入速率(字节/秒)
TotalReadBytes uint64 `json:"totalReadBytes"` // 总读取字节数
TotalWriteBytes uint64 `json:"totalWriteBytes"` // 总写入字节数
MaxIopsInProgress uint64 `json:"maxIopsInProgress"` // 最大进行中的IO操作数
}
// GetDiskIOMetrics 获取聚合后的磁盘IO指标(已在存储时合并所有磁盘)
func (r *MetricRepo) GetDiskIOMetrics(ctx context.Context, agentID string, start, end int64, interval int) ([]AggregatedDiskIOMetric, error) {
var metrics []AggregatedDiskIOMetric
query := `
SELECT
CAST(FLOOR(timestamp / ?) * ? AS BIGINT) as timestamp,
MAX(read_bytes_rate) as max_read_rate,
MAX(write_bytes_rate) as max_write_rate,
MAX(read_bytes) as total_read_bytes,
MAX(write_bytes) as total_write_bytes,
MAX(iops_in_progress) as max_iops_in_progress
FROM disk_io_metrics
WHERE agent_id = ? AND timestamp >= ? AND timestamp <= ?
GROUP BY 1
ORDER BY timestamp ASC
`
intervalMs := int64(interval * 1000)
err := r.db.WithContext(ctx).
Raw(query, intervalMs, intervalMs, agentID, start, end).
Scan(&metrics).Error
return metrics, err
}
// AggregatedGPUMetric GPU聚合指标(使用最大值)
type AggregatedGPUMetric struct {
Timestamp int64 `json:"timestamp"`
Index int `json:"index"`
Name string `json:"name"`
MaxUtilization float64 `json:"maxUtilization"`
MaxMemoryUsed uint64 `json:"maxMemoryUsed"`
MaxTemperature float64 `json:"maxTemperature"`
MaxPowerDraw float64 `json:"maxPowerDraw"`
MemoryTotal uint64 `json:"memoryTotal"`
}
// GetGPUMetrics 获取聚合后的GPU指标
func (r *MetricRepo) GetGPUMetrics(ctx context.Context, agentID string, start, end int64, interval int) ([]AggregatedGPUMetric, error) {
var metrics []AggregatedGPUMetric
query := `
SELECT
CAST(FLOOR(timestamp / ?) * ? AS BIGINT) as timestamp,
MAX(utilization) as max_utilization,
MAX(memory_used) as max_memory_used,
MAX(temperature) as max_temperature,
MAX(power_draw) as max_power_draw
FROM gpu_metrics
WHERE agent_id = ? AND timestamp >= ? AND timestamp <= ?
GROUP BY 1
ORDER BY timestamp ASC
`
intervalMs := int64(interval * 1000)
err := r.db.WithContext(ctx).
Raw(query, intervalMs, intervalMs, agentID, start, end).
Scan(&metrics).Error
return metrics, err
}
// AggregatedTemperatureMetric 温度聚合指标(使用最大值)
type AggregatedTemperatureMetric struct {
Timestamp int64 `json:"timestamp"`
SensorKey string `json:"sensorKey"`
SensorLabel string `json:"sensorLabel"`
MaxTemperature float64 `json:"maxTemperature"`
}
// GetTemperatureMetrics 获取聚合后的温度指标
func (r *MetricRepo) GetTemperatureMetrics(ctx context.Context, agentID string, start, end int64, interval int) ([]AggregatedTemperatureMetric, error) {
var metrics []AggregatedTemperatureMetric
query := `
SELECT
CAST(FLOOR(timestamp / ?) * ? AS BIGINT) as timestamp,
sensor_key,
sensor_label,
MAX(temperature) as max_temperature
FROM temperature_metrics
WHERE agent_id = ? AND timestamp >= ? AND timestamp <= ?
GROUP BY 1, sensor_key, sensor_label
ORDER BY timestamp ASC, sensor_key
`
intervalMs := int64(interval * 1000)
err := r.db.WithContext(ctx).
Raw(query, intervalMs, intervalMs, agentID, start, end).
Scan(&metrics).Error
return metrics, err
}
// DeleteMonitorMetrics 删除指定监控任务的所有指标数据
func (r *MetricRepo) DeleteMonitorMetrics(ctx context.Context, monitorID string) error {
return r.db.WithContext(ctx).
Where("monitor_id = ?", monitorID).
Delete(&models.MonitorMetric{}).Error
}
// DeleteAgentMetrics 删除指定探针的所有指标数据
func (r *MetricRepo) DeleteAgentMetrics(ctx context.Context, agentID string) error {
tables := []interface{}{
&models.CPUMetric{},
&models.MemoryMetric{},
&models.DiskMetric{},
&models.DiskIOMetric{},
&models.NetworkMetric{},
&models.HostMetric{},
&models.GPUMetric{},
&models.TemperatureMetric{},
&models.MonitorMetric{},
if err := r.db.WithContext(ctx).Where("agent_id = ?", agentID).Delete(&models.HostMetric{}).Error; err != nil {
return err
}
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
for _, table := range tables {
if err := tx.Where("agent_id = ?", agentID).Delete(table).Error; err != nil {
return err
}
}
return nil
})
}
// GetLatestMonitorMetricsByType 获取指定类型的最新监控指标(所有探针)
func (r *MetricRepo) GetLatestMonitorMetricsByType(ctx context.Context, monitorType string) ([]*models.MonitorMetric, error) {
var metrics []*models.MonitorMetric
err := r.db.WithContext(ctx).Raw(`
SELECT m.*
FROM monitor_metrics m
INNER JOIN (
SELECT monitor_id, MAX(timestamp) AS ts
FROM monitor_metrics
WHERE type = ?
GROUP BY monitor_id
) latest ON m.monitor_id = latest.monitor_id AND m.timestamp = latest.ts
WHERE m.type = ?
ORDER BY m.monitor_id
`, monitorType, monitorType).Scan(&metrics).Error
return metrics, err
}
// GetAllLatestMonitorMetrics 获取所有最新的监控指标(所有探针的所有监控项,每个监控项的最新一条)
func (r *MetricRepo) GetAllLatestMonitorMetrics(ctx context.Context) ([]*models.MonitorMetric, error) {
var metrics []*models.MonitorMetric
err := r.db.WithContext(ctx).Raw(`
SELECT m.*
FROM monitor_metrics m
INNER JOIN (
SELECT monitor_id, MAX(timestamp) AS ts
FROM monitor_metrics
GROUP BY monitor_id
) latest ON m.monitor_id = latest.monitor_id AND m.timestamp = latest.ts
ORDER BY m.monitor_id
`).Scan(&metrics).Error
return metrics, err
}
// ----------- 聚合表操作 -----------
// AggregateCPUToAgg 将原始CPU数据聚合到聚合表
func (r *MetricRepo) AggregateCPUToAgg(ctx context.Context, bucketSeconds int, start, end int64) error {
bucketMs := int64(bucketSeconds * 1000)
return r.db.WithContext(ctx).Exec(`
INSERT INTO cpu_metrics_aggs (agent_id, bucket_seconds, bucket_start, max_usage, logical_cores)
SELECT
agent_id,
? as bucket_seconds,
(timestamp / ?) * ? as bucket_start,
MAX(usage_percent) as max_usage,
MAX(logical_cores) as logical_cores
FROM cpu_metrics
WHERE timestamp >= ? AND timestamp < ?
GROUP BY agent_id, bucket_start
ON CONFLICT (agent_id, bucket_seconds, bucket_start) DO UPDATE SET
max_usage = EXCLUDED.max_usage,
logical_cores = EXCLUDED.logical_cores
`, bucketSeconds, bucketMs, bucketMs, start, end).Error
}
// AggregateMemoryToAgg 将原始内存数据聚合到聚合表
func (r *MetricRepo) AggregateMemoryToAgg(ctx context.Context, bucketSeconds int, start, end int64) error {
bucketMs := int64(bucketSeconds * 1000)
return r.db.WithContext(ctx).Exec(`
INSERT INTO memory_metrics_aggs (agent_id, bucket_seconds, bucket_start, max_usage, total)
SELECT
agent_id,
? as bucket_seconds,
(timestamp / ?) * ? as bucket_start,
MAX(usage_percent) as max_usage,
MAX(total) as total
FROM memory_metrics
WHERE timestamp >= ? AND timestamp < ?
GROUP BY agent_id, bucket_start
ON CONFLICT (agent_id, bucket_seconds, bucket_start) DO UPDATE SET
max_usage = EXCLUDED.max_usage,
total = EXCLUDED.total
`, bucketSeconds, bucketMs, bucketMs, start, end).Error
}
// AggregateDiskToAgg 将原始磁盘数据聚合到聚合表
func (r *MetricRepo) AggregateDiskToAgg(ctx context.Context, bucketSeconds int, start, end int64) error {
bucketMs := int64(bucketSeconds * 1000)
return r.db.WithContext(ctx).Exec(`
INSERT INTO disk_metrics_aggs (agent_id, bucket_seconds, bucket_start, mount_point, max_usage, total)
SELECT
agent_id,
? as bucket_seconds,
(timestamp / ?) * ? as bucket_start,
mount_point,
MAX(usage_percent) as max_usage,
MAX(total) as total
FROM disk_metrics
WHERE timestamp >= ? AND timestamp < ?
GROUP BY agent_id, bucket_start, mount_point
ON CONFLICT (agent_id, bucket_seconds, bucket_start, mount_point) DO UPDATE SET
max_usage = EXCLUDED.max_usage,
total = EXCLUDED.total
`, bucketSeconds, bucketMs, bucketMs, start, end).Error
}
// AggregateNetworkToAgg 将原始网络数据聚合到聚合表(按网卡分组)
func (r *MetricRepo) AggregateNetworkToAgg(ctx context.Context, bucketSeconds int, start, end int64) error {
bucketMs := int64(bucketSeconds * 1000)
return r.db.WithContext(ctx).Exec(`
INSERT INTO network_metrics_aggs (agent_id, bucket_seconds, bucket_start, interface, max_sent_rate, max_recv_rate)
SELECT
agent_id,
? as bucket_seconds,
(timestamp / ?) * ? as bucket_start,
interface,
MAX(bytes_sent_rate) as max_sent_rate,
MAX(bytes_recv_rate) as max_recv_rate
FROM network_metrics
WHERE timestamp >= ? AND timestamp < ?
GROUP BY agent_id, bucket_start, interface
ON CONFLICT (agent_id, bucket_seconds, bucket_start, interface) DO UPDATE SET
max_sent_rate = EXCLUDED.max_sent_rate,
max_recv_rate = EXCLUDED.max_recv_rate
`, bucketSeconds, bucketMs, bucketMs, start, end).Error
}
// AggregateNetworkConnectionToAgg 将原始网络连接数据聚合到聚合表
func (r *MetricRepo) AggregateNetworkConnectionToAgg(ctx context.Context, bucketSeconds int, start, end int64) error {
bucketMs := int64(bucketSeconds * 1000)
return r.db.WithContext(ctx).Exec(`
INSERT INTO network_connection_metrics_aggs (
agent_id, bucket_seconds, bucket_start,
max_established, max_syn_sent, max_syn_recv, max_fin_wait1, max_fin_wait2,
max_time_wait, max_close, max_close_wait, max_last_ack, max_listen,
max_closing, max_total
)
SELECT
agent_id,
? as bucket_seconds,
(timestamp / ?) * ? as bucket_start,
MAX(established) as max_established,
MAX(syn_sent) as max_syn_sent,
MAX(syn_recv) as max_syn_recv,
MAX(fin_wait1) as max_fin_wait1,
MAX(fin_wait2) as max_fin_wait2,
MAX(time_wait) as max_time_wait,
MAX(close) as max_close,
MAX(close_wait) as max_close_wait,
MAX(last_ack) as max_last_ack,
MAX(listen) as max_listen,
MAX(closing) as max_closing,
MAX(total) as max_total
FROM network_connection_metrics
WHERE timestamp >= ? AND timestamp < ?
GROUP BY agent_id, bucket_start
ON CONFLICT (agent_id, bucket_seconds, bucket_start) DO UPDATE SET
max_established = EXCLUDED.max_established,
max_syn_sent = EXCLUDED.max_syn_sent,
max_syn_recv = EXCLUDED.max_syn_recv,
max_fin_wait1 = EXCLUDED.max_fin_wait1,
max_fin_wait2 = EXCLUDED.max_fin_wait2,
max_time_wait = EXCLUDED.max_time_wait,
max_close = EXCLUDED.max_close,
max_close_wait = EXCLUDED.max_close_wait,
max_last_ack = EXCLUDED.max_last_ack,
max_listen = EXCLUDED.max_listen,
max_closing = EXCLUDED.max_closing,
max_total = EXCLUDED.max_total
`, bucketSeconds, bucketMs, bucketMs, start, end).Error
}
// AggregateDiskIOToAgg 将原始磁盘IO数据聚合到聚合表(已在存储时合并所有磁盘)
func (r *MetricRepo) AggregateDiskIOToAgg(ctx context.Context, bucketSeconds int, start, end int64) error {
bucketMs := int64(bucketSeconds * 1000)
return r.db.WithContext(ctx).Exec(`
INSERT INTO disk_io_metrics_aggs (
agent_id, bucket_seconds, bucket_start,
max_read_bytes_rate, max_write_bytes_rate, max_iops_in_progress
)
SELECT
agent_id,
? as bucket_seconds,
(timestamp / ?) * ? as bucket_start,
MAX(read_bytes_rate) as max_read_bytes_rate,
MAX(write_bytes_rate) as max_write_bytes_rate,
MAX(iops_in_progress) as max_iops_in_progress
FROM disk_io_metrics
WHERE timestamp >= ? AND timestamp < ?
GROUP BY agent_id, bucket_start
ON CONFLICT (agent_id, bucket_seconds, bucket_start) DO UPDATE SET
max_read_bytes_rate = EXCLUDED.max_read_bytes_rate,
max_write_bytes_rate = EXCLUDED.max_write_bytes_rate,
max_iops_in_progress = EXCLUDED.max_iops_in_progress
`, bucketSeconds, bucketMs, bucketMs, start, end).Error
}
// AggregateGPUToAgg 将原始GPU数据聚合到聚合表
func (r *MetricRepo) AggregateGPUToAgg(ctx context.Context, bucketSeconds int, start, end int64) error {
bucketMs := int64(bucketSeconds * 1000)
return r.db.WithContext(ctx).Exec(`
INSERT INTO gpu_metrics_aggs (
agent_id, bucket_seconds, bucket_start, index, name,
max_utilization, max_memory_used, max_temperature, max_power_draw, memory_total
)
SELECT
agent_id,
? as bucket_seconds,
(timestamp / ?) * ? as bucket_start,
index,
MAX(name) as name,
MAX(utilization) as max_utilization,
MAX(memory_used) as max_memory_used,
MAX(temperature) as max_temperature,
MAX(power_draw) as max_power_draw,
MAX(memory_total) as memory_total
FROM gpu_metrics
WHERE timestamp >= ? AND timestamp < ?
GROUP BY agent_id, bucket_start, index
ON CONFLICT (agent_id, bucket_seconds, bucket_start, index) DO UPDATE SET
name = EXCLUDED.name,
max_utilization = EXCLUDED.max_utilization,
max_memory_used = EXCLUDED.max_memory_used,
max_temperature = EXCLUDED.max_temperature,
max_power_draw = EXCLUDED.max_power_draw,
memory_total = EXCLUDED.memory_total
`, bucketSeconds, bucketMs, bucketMs, start, end).Error
}
// AggregateTemperatureToAgg 将原始温度数据聚合到聚合表
func (r *MetricRepo) AggregateTemperatureToAgg(ctx context.Context, bucketSeconds int, start, end int64) error {
bucketMs := int64(bucketSeconds * 1000)
return r.db.WithContext(ctx).Exec(`
INSERT INTO temperature_metrics_aggs (
agent_id, bucket_seconds, bucket_start, sensor_key, sensor_label, max_temperature
)
SELECT
agent_id,
? as bucket_seconds,
(timestamp / ?) * ? as bucket_start,
sensor_key,
MAX(sensor_label) as sensor_label,
MAX(temperature) as max_temperature
FROM temperature_metrics
WHERE timestamp >= ? AND timestamp < ?
GROUP BY agent_id, bucket_start, sensor_key
ON CONFLICT (agent_id, bucket_seconds, bucket_start, sensor_key) DO UPDATE SET
sensor_label = EXCLUDED.sensor_label,
max_temperature = EXCLUDED.max_temperature
`, bucketSeconds, bucketMs, bucketMs, start, end).Error
}
// GetCPUMetricsAgg 从聚合表获取CPU指标
func (r *MetricRepo) GetCPUMetricsAgg(ctx context.Context, agentID string, start, end int64, bucketSeconds int) ([]AggregatedCPUMetric, error) {
var metrics []AggregatedCPUMetric
err := r.db.WithContext(ctx).
Table("cpu_metrics_aggs").
Select("bucket_start as timestamp, max_usage, logical_cores").
Where("agent_id = ? AND bucket_seconds = ? AND bucket_start >= ? AND bucket_start <= ?", agentID, bucketSeconds, start, end).
Order("bucket_start").
Scan(&metrics).Error
return metrics, err
}
// GetMemoryMetricsAgg 从聚合表获取内存指标
func (r *MetricRepo) GetMemoryMetricsAgg(ctx context.Context, agentID string, start, end int64, bucketSeconds int) ([]AggregatedMemoryMetric, error) {
var metrics []AggregatedMemoryMetric
err := r.db.WithContext(ctx).
Table("memory_metrics_aggs").
Select("bucket_start as timestamp, max_usage, total").
Where("agent_id = ? AND bucket_seconds = ? AND bucket_start >= ? AND bucket_start <= ?", agentID, bucketSeconds, start, end).
Order("bucket_start").
Scan(&metrics).Error
return metrics, err
}
// GetDiskMetricsAgg 从聚合表获取磁盘指标(返回预聚合的总和数据)
// 直接查询 mount_point=" 的预聚合记录
func (r *MetricRepo) GetDiskMetricsAgg(ctx context.Context, agentID string, start, end int64, bucketSeconds int) ([]AggregatedDiskMetric, error) {
var metrics []AggregatedDiskMetric
err := r.db.WithContext(ctx).
Table("disk_metrics_aggs").
Select("bucket_start as timestamp, mount_point, max_usage, total").
Where("agent_id = ? AND bucket_seconds = ? AND bucket_start >= ? AND bucket_start <= ? AND mount_point = ?",
agentID, bucketSeconds, start, end, ""). // 空字符串查询总和记录
Order("bucket_start").
Scan(&metrics).Error
return metrics, err
}
// GetNetworkMetricsAgg 从聚合表获取网络指标(可选按网卡接口过滤)
// 不指定网卡时查询 interface=" 的预聚合数据,指定网卡时只返回该网卡的数据
func (r *MetricRepo) GetNetworkMetricsAgg(ctx context.Context, agentID string, start, end int64, bucketSeconds int, interfaceName string) ([]AggregatedNetworkMetric, error) {
var metrics []AggregatedNetworkMetric
// 不管是否指定网卡,查询逻辑都一样:直接查询对应 interface 的数据
// interfaceName 为空字符串时,会查询到预先保存的总和数据
err := r.db.WithContext(ctx).
Table("network_metrics_aggs").
Select("bucket_start as timestamp, interface, max_sent_rate, max_recv_rate").
Where("agent_id = ? AND bucket_seconds = ? AND bucket_start >= ? AND bucket_start <= ? AND interface = ?",
agentID, bucketSeconds, start, end, interfaceName).
Order("bucket_start").
Scan(&metrics).Error
return metrics, err
}
// GetAvailableNetworkInterfaces 获取探针的可用网卡列表(不包括空白的总和记录)
func (r *MetricRepo) GetAvailableNetworkInterfaces(ctx context.Context, agentID string) ([]string, error) {
var interfaces []string
err := r.db.WithContext(ctx).
Table("network_metrics").
Select("DISTINCT interface").
Where("agent_id = ? AND interface != ?", agentID, ""). // 排除空字符串(总和记录)
Order("interface").
Pluck("interface", &interfaces).Error
return interfaces, err
}
// GetNetworkConnectionMetricsAgg 从聚合表获取网络连接指标
func (r *MetricRepo) GetNetworkConnectionMetricsAgg(ctx context.Context, agentID string, start, end int64, bucketSeconds int) ([]AggregatedNetworkConnectionMetric, error) {
var metrics []AggregatedNetworkConnectionMetric
err := r.db.WithContext(ctx).
Table("network_connection_metrics_aggs").
Select(`bucket_start as timestamp,
max_established, max_syn_sent, max_syn_recv,
max_fin_wait1, max_fin_wait2, max_time_wait,
max_close, max_close_wait, max_last_ack,
max_listen, max_closing, max_total`).
Where("agent_id = ? AND bucket_seconds = ? AND bucket_start >= ? AND bucket_start <= ?", agentID, bucketSeconds, start, end).
Order("bucket_start").
Scan(&metrics).Error
return metrics, err
}
// GetDiskIOMetricsAgg 从聚合表获取磁盘IO指标(汇总所有磁盘)
func (r *MetricRepo) GetDiskIOMetricsAgg(ctx context.Context, agentID string, start, end int64, bucketSeconds int) ([]AggregatedDiskIOMetric, error) {
var metrics []AggregatedDiskIOMetric
err := r.db.WithContext(ctx).
Table("disk_io_metrics_aggs").
Select(`bucket_start as timestamp,
max_read_bytes_rate as max_read_rate,
max_write_bytes_rate as max_write_rate,
max_iops_in_progress`).
Where("agent_id = ? AND bucket_seconds = ? AND bucket_start >= ? AND bucket_start <= ?", agentID, bucketSeconds, start, end).
Order("bucket_start").
Scan(&metrics).Error
return metrics, err
}
// GetGPUMetricsAgg 从聚合表获取GPU指标
func (r *MetricRepo) GetGPUMetricsAgg(ctx context.Context, agentID string, start, end int64, bucketSeconds int) ([]AggregatedGPUMetric, error) {
var metrics []AggregatedGPUMetric
err := r.db.WithContext(ctx).
Table("gpu_metrics_aggs").
Select(`bucket_start as timestamp, index, name,
max_utilization,
max_memory_used,
max_temperature,
max_power_draw,
memory_total`).
Where("agent_id = ? AND bucket_seconds = ? AND bucket_start >= ? AND bucket_start <= ?", agentID, bucketSeconds, start, end).
Order("bucket_start, index").
Scan(&metrics).Error
return metrics, err
}
// GetTemperatureMetricsAgg 从聚合表获取温度指标
func (r *MetricRepo) GetTemperatureMetricsAgg(ctx context.Context, agentID string, start, end int64, bucketSeconds int) ([]AggregatedTemperatureMetric, error) {
var metrics []AggregatedTemperatureMetric
err := r.db.WithContext(ctx).
Table("temperature_metrics_aggs").
Select(`bucket_start as timestamp, sensor_key, sensor_label,
max_temperature`).
Where("agent_id = ? AND bucket_seconds = ? AND bucket_start >= ? AND bucket_start <= ?", agentID, bucketSeconds, start, end).
Order("bucket_start, sensor_key").
Scan(&metrics).Error
return metrics, err
}
// UpsertAggregationProgress 更新或插入聚合进度
func (r *MetricRepo) UpsertAggregationProgress(ctx context.Context, metricType string, bucketSeconds int, lastBucket int64) error {
return r.db.WithContext(ctx).
Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "metric_type"}, {Name: "bucket_seconds"}},
DoUpdates: clause.AssignmentColumns([]string{"last_bucket"}),
}).
Create(&models.AggregationProgress{
MetricType: metricType,
BucketSeconds: bucketSeconds,
LastBucket: lastBucket,
}).Error
}
// GetAggregationProgress 获取聚合进度
func (r *MetricRepo) GetAggregationProgress(ctx context.Context, metricType string, bucketSeconds int) (*models.AggregationProgress, error) {
var progress models.AggregationProgress
err := r.db.WithContext(ctx).
Where("metric_type = ? AND bucket_seconds = ?", metricType, bucketSeconds).
First(&progress).Error
if err != nil {
return nil, err
}
return &progress, nil
}
// ----------- 监控指标聚合表操作 -----------
// AggregateMonitorMetricsToAgg 将原始监控数据聚合到聚合表
func (r *MetricRepo) AggregateMonitorMetricsToAgg(ctx context.Context, bucketSeconds int, start, end int64) error {
bucketMs := int64(bucketSeconds * 1000)
return r.db.WithContext(ctx).Exec(`
INSERT INTO monitor_metrics_aggs (
monitor_id, agent_id, bucket_seconds, bucket_start,
avg_response, max_response, min_response,
success_count, total_count, success_rate,
last_status, last_error
)
WITH ranked_metrics AS (
SELECT
monitor_id,
agent_id,
(timestamp / ?) * ? as bucket_start,
response_time,
status,
error,
timestamp,
ROW_NUMBER() OVER (PARTITION BY monitor_id, agent_id, (timestamp / ?) * ? ORDER BY timestamp DESC) as rn
FROM monitor_metrics
WHERE timestamp >= ? AND timestamp < ?
)
SELECT
monitor_id,
agent_id,
? as bucket_seconds,
bucket_start,
AVG(response_time) as avg_response,
MAX(response_time) as max_response,
MIN(response_time) as min_response,
SUM(CASE WHEN status = 'up' THEN 1 ELSE 0 END) as success_count,
COUNT(*) as total_count,
CAST(SUM(CASE WHEN status = 'up' THEN 1 ELSE 0 END) AS REAL) / CAST(COUNT(*) AS REAL) * 100 as success_rate,
MAX(CASE WHEN rn = 1 THEN status END) as last_status,
MAX(CASE WHEN rn = 1 THEN error END) as last_error
FROM ranked_metrics
GROUP BY monitor_id, agent_id, bucket_start
ON CONFLICT (monitor_id, agent_id, bucket_seconds, bucket_start) DO UPDATE SET
avg_response = EXCLUDED.avg_response,
max_response = EXCLUDED.max_response,
min_response = EXCLUDED.min_response,
success_count = EXCLUDED.success_count,
total_count = EXCLUDED.total_count,
success_rate = EXCLUDED.success_rate,
last_status = EXCLUDED.last_status,
last_error = EXCLUDED.last_error
`, bucketMs, bucketMs, bucketMs, bucketMs, start, end, bucketSeconds).Error
}
// GetMonitorMetricsAgg 从聚合表获取监控指标(按探针和时间间隔聚合)
func (r *MetricRepo) GetMonitorMetricsAgg(ctx context.Context, monitorID string, start, end int64, bucketSeconds int) ([]AggregatedMonitorMetric, error) {
var metrics []AggregatedMonitorMetric
err := r.db.WithContext(ctx).
Table("monitor_metrics_aggs").
Select(`bucket_start as timestamp, agent_id,
avg_response, max_response, min_response,
success_count, total_count, success_rate,
last_status, last_error as last_error_msg`).
Where("monitor_id = ? AND bucket_seconds = ? AND bucket_start >= ? AND bucket_start <= ?", monitorID, bucketSeconds, start, end).
Order("bucket_start, agent_id").
Scan(&metrics).Error
return metrics, err
return nil
}
+11
View File
@@ -68,3 +68,14 @@ func (r *MonitorRepo) FindByEnabled(ctx context.Context, enabled bool) ([]models
}
return monitors, nil
}
// FindByEnabledAndType 查找所有启用的监控任务
func (r *MonitorRepo) FindByEnabledAndType(ctx context.Context, enabled bool, typ string) ([]models.MonitorTask, error) {
var monitors []models.MonitorTask
if err := r.GetDB(ctx).
Where("enabled = ? and type = ?", enabled, typ).
Find(&monitors).Error; err != nil {
return nil, err
}
return monitors, nil
}
-66
View File
@@ -1,66 +0,0 @@
package repo
import (
"context"
"github.com/dushixiang/pika/internal/models"
"github.com/go-orz/orz"
"gorm.io/gorm"
)
type MonitorStatsRepo struct {
orz.Repository[models.MonitorStats, string]
db *gorm.DB
}
func NewMonitorStatsRepo(db *gorm.DB) *MonitorStatsRepo {
return &MonitorStatsRepo{
Repository: orz.NewRepository[models.MonitorStats, string](db),
db: db,
}
}
func (r *MonitorStatsRepo) FindByMonitorId(ctx context.Context, monitorId string) ([]models.MonitorStats, error) {
var statsList []models.MonitorStats
err := r.db.WithContext(ctx).
Where("monitor_id = ?", monitorId).
Find(&statsList).Error
return statsList, err
}
func (r *MonitorStatsRepo) FindByMonitorIdIn(ctx context.Context, monitorIds []string) ([]models.MonitorStats, error) {
var statsList []models.MonitorStats
err := r.db.WithContext(ctx).
Where("monitor_id in ?", monitorIds).
Find(&statsList).Error
return statsList, err
}
func (r *MonitorStatsRepo) DeleteByMonitorId(ctx context.Context, monitorId string) error {
return r.db.WithContext(ctx).
Where("monitor_id = ?", monitorId).
Delete(&models.MonitorStats{}).Error
}
func (r *MonitorStatsRepo) DeleteByAgentId(ctx context.Context, agentId string) error {
return r.db.WithContext(ctx).
Where("agent_id = ?", agentId).
Delete(&models.MonitorStats{}).Error
}
// FindAll 查找所有统计数据
func (r *MonitorStatsRepo) FindAll(ctx context.Context) ([]models.MonitorStats, error) {
var statsList []models.MonitorStats
err := r.db.WithContext(ctx).Find(&statsList).Error
return statsList, err
}
// DeleteByIDs 批量删除指定ID的统计数据
func (r *MonitorStatsRepo) DeleteByIDs(ctx context.Context, ids []string) error {
if len(ids) == 0 {
return nil
}
return r.db.WithContext(ctx).
Where("id IN ?", ids).
Delete(&models.MonitorStats{}).Error
}
+11 -29
View File
@@ -19,22 +19,20 @@ import (
type AgentService struct {
logger *zap.Logger
*orz.Service
AgentRepo *repo.AgentRepo
monitorStatsRepo *repo.MonitorStatsRepo
apiKeyService *ApiKeyService
metricService *MetricService
geoipService *GeoIPService
AgentRepo *repo.AgentRepo
apiKeyService *ApiKeyService
metricService *MetricService
geoipService *GeoIPService
}
func NewAgentService(logger *zap.Logger, db *gorm.DB, apiKeyService *ApiKeyService, metricService *MetricService, geoipService *GeoIPService) *AgentService {
return &AgentService{
logger: logger,
Service: orz.NewService(db),
AgentRepo: repo.NewAgentRepo(db),
monitorStatsRepo: repo.NewMonitorStatsRepo(db),
apiKeyService: apiKeyService,
metricService: metricService,
geoipService: geoipService,
logger: logger,
Service: orz.NewService(db),
AgentRepo: repo.NewAgentRepo(db),
apiKeyService: apiKeyService,
metricService: metricService,
geoipService: geoipService,
}
}
@@ -330,16 +328,6 @@ func (s *AgentService) GetStatistics(ctx context.Context) (map[string]interface{
}, nil
}
// GetMonitorMetrics 获取监控指标历史数据
func (s *AgentService) GetMonitorMetrics(ctx context.Context, agentID, monitorName string, start, end int64) ([]models.MonitorMetric, error) {
return s.metricService.GetMonitorMetrics(ctx, agentID, monitorName, start, end)
}
// GetMonitorMetricsByName 获取指定监控项的历史数据
func (s *AgentService) GetMonitorMetricsByName(ctx context.Context, agentID, monitorName string, start, end int64, limit int) ([]models.MonitorMetric, error) {
return s.metricService.GetMonitorMetricsByName(ctx, agentID, monitorName, start, end, limit)
}
// DeleteAgent 删除探针及其所有相关数据
func (s *AgentService) DeleteAgent(ctx context.Context, agentID string) error {
// 在事务中执行所有删除操作
@@ -350,13 +338,7 @@ func (s *AgentService) DeleteAgent(ctx context.Context, agentID string) error {
return err
}
// 2. 删除探针的监控统计数据
if err := s.monitorStatsRepo.DeleteByAgentId(ctx, agentID); err != nil {
s.logger.Error("删除探针监控统计数据失败", zap.String("agentId", agentID), zap.Error(err))
return err
}
// 3. 删除探针的审计结果
// 2. 删除探针的审计结果
if err := s.AgentRepo.DeleteAuditResults(ctx, agentID); err != nil {
s.logger.Error("删除探针审计结果失败", zap.String("agentId", agentID), zap.Error(err))
return err
+15 -14
View File
@@ -6,6 +6,7 @@ import (
"time"
"github.com/dushixiang/pika/internal/models"
"github.com/dushixiang/pika/internal/protocol"
"github.com/dushixiang/pika/internal/repo"
"github.com/go-orz/orz"
"go.uber.org/zap"
@@ -18,19 +19,19 @@ type AlertService struct {
AlertRecordRepo *repo.AlertRecordRepo
AlertStateRepo *repo.AlertStateRepo
agentRepo *repo.AgentRepo
metricRepo *repo.MetricRepo
monitorService *MonitorService
propertyService *PropertyService
notifier *Notifier
logger *zap.Logger
}
func NewAlertService(logger *zap.Logger, db *gorm.DB, propertyService *PropertyService, notifier *Notifier) *AlertService {
func NewAlertService(logger *zap.Logger, db *gorm.DB, propertyService *PropertyService, monitorService *MonitorService, notifier *Notifier) *AlertService {
return &AlertService{
Service: orz.NewService(db),
AlertRecordRepo: repo.NewAlertRecordRepo(db),
AlertStateRepo: repo.NewAlertStateRepo(db),
agentRepo: repo.NewAgentRepo(db),
metricRepo: repo.NewMetricRepo(db),
monitorService: monitorService,
propertyService: propertyService,
notifier: notifier,
logger: logger,
@@ -373,7 +374,7 @@ func (s *AlertService) CheckMonitorAlerts(ctx context.Context) error {
func (s *AlertService) checkCertificateAlerts(ctx context.Context, config *models.AlertConfig, now int64) error {
// 获取所有最新的监控指标(仅HTTPS类型)
// 这里需要查询最新的 monitor_metrics 记录,获取证书剩余天数
monitors, err := s.metricRepo.GetLatestMonitorMetricsByType(ctx, "http")
monitors, err := s.monitorService.GetLatestMonitorMetricsByType(ctx, "http")
if err != nil {
return err
}
@@ -396,10 +397,10 @@ func (s *AlertService) checkCertificateAlerts(ctx context.Context, config *model
// 检查证书剩余天数是否低于阈值
if certDaysLeft <= config.Rules.CertThreshold && certDaysLeft >= 0 {
// 触发告警(证书告警不需要持续时间,直接触发)
s.checkCertAlert(ctx, config, &agent, monitor, certDaysLeft, now)
s.checkCertAlert(ctx, config, &agent, &monitor, certDaysLeft, now)
} else {
// 恢复告警(如果之前触发过)
s.resolveCertAlert(ctx, config, &agent, monitor, certDaysLeft)
s.resolveCertAlert(ctx, config, &agent, &monitor, certDaysLeft)
}
}
@@ -407,7 +408,7 @@ func (s *AlertService) checkCertificateAlerts(ctx context.Context, config *model
}
// checkCertAlert 检查并触发证书告警
func (s *AlertService) checkCertAlert(ctx context.Context, config *models.AlertConfig, agent *models.Agent, monitor *models.MonitorMetric, certDaysLeft float64, now int64) {
func (s *AlertService) checkCertAlert(ctx context.Context, config *models.AlertConfig, agent *models.Agent, monitor *protocol.MonitorData, certDaysLeft float64, now int64) {
stateKey := fmt.Sprintf("%s:global:cert:%s", agent.ID, monitor.MonitorId)
// 从数据库加载状态
@@ -479,7 +480,7 @@ func (s *AlertService) checkCertAlert(ctx context.Context, config *models.AlertC
}
// resolveCertAlert 恢复证书告警
func (s *AlertService) resolveCertAlert(ctx context.Context, config *models.AlertConfig, agent *models.Agent, monitor *models.MonitorMetric, certDaysLeft float64) {
func (s *AlertService) resolveCertAlert(ctx context.Context, config *models.AlertConfig, agent *models.Agent, monitor *protocol.MonitorData, certDaysLeft float64) {
stateKey := fmt.Sprintf("%s:global:cert:%s", agent.ID, monitor.MonitorId)
state, err := s.AlertStateRepo.GetAlertState(ctx, stateKey)
@@ -536,7 +537,7 @@ func (s *AlertService) calculateCertLevel(daysLeft float64) string {
// checkServiceDownAlerts 检查服务下线告警
func (s *AlertService) checkServiceDownAlerts(ctx context.Context, config *models.AlertConfig, now int64) error {
// 获取所有最新的监控指标
monitors, err := s.metricRepo.GetAllLatestMonitorMetrics(ctx)
monitors, err := s.monitorService.GetAllLatestMonitorMetrics(ctx)
if err != nil {
return err
}
@@ -570,7 +571,7 @@ func (s *AlertService) checkServiceDownAlerts(ctx context.Context, config *model
if monitor.Status == "down" {
if state.StartTime == 0 {
state.StartTime = monitor.Timestamp
state.StartTime = monitor.CheckedAt
}
elapsedSeconds := (now - state.StartTime) / 1000
@@ -591,11 +592,11 @@ func (s *AlertService) checkServiceDownAlerts(ctx context.Context, config *model
}
if shouldFire {
s.fireServiceDownAlert(ctx, config, &agent, monitor, state, now)
s.fireServiceDownAlert(ctx, config, &agent, &monitor, state, now)
}
if shouldResolve {
s.resolveServiceDownAlert(ctx, config, &agent, monitor, state)
s.resolveServiceDownAlert(ctx, config, &agent, &monitor, state)
}
}
@@ -603,7 +604,7 @@ func (s *AlertService) checkServiceDownAlerts(ctx context.Context, config *model
}
// fireServiceDownAlert 触发服务下线告警
func (s *AlertService) fireServiceDownAlert(ctx context.Context, config *models.AlertConfig, agent *models.Agent, monitor *models.MonitorMetric, state *models.AlertState, now int64) {
func (s *AlertService) fireServiceDownAlert(ctx context.Context, config *models.AlertConfig, agent *models.Agent, monitor *protocol.MonitorData, state *models.AlertState, now int64) {
s.logger.Info("触发服务下线告警",
zap.String("agentId", agent.ID),
zap.String("monitorId", monitor.MonitorId),
@@ -641,7 +642,7 @@ func (s *AlertService) fireServiceDownAlert(ctx context.Context, config *models.
}
// resolveServiceDownAlert 恢复服务下线告警
func (s *AlertService) resolveServiceDownAlert(ctx context.Context, config *models.AlertConfig, agent *models.Agent, monitor *models.MonitorMetric, state *models.AlertState) {
func (s *AlertService) resolveServiceDownAlert(ctx context.Context, config *models.AlertConfig, agent *models.Agent, monitor *protocol.MonitorData, state *models.AlertState) {
s.logger.Info("服务下线告警恢复",
zap.String("agentId", agent.ID),
zap.String("monitorId", monitor.MonitorId),
+3 -10
View File
@@ -402,17 +402,10 @@ func (s *DDNSService) sendDDNSConfigToAgent(config *models.DDNSConfig) error {
return err
}
data, err := json.Marshal(configData)
if err != nil {
return err
}
msg := protocol.Message{
msgData, err := json.Marshal(protocol.OutboundMessage{
Type: protocol.MessageTypeDDNSConfig,
Data: data,
}
msgData, err := json.Marshal(msg)
Data: configData,
})
if err != nil {
return err
}
+129
View File
@@ -0,0 +1,129 @@
package service
import (
"fmt"
"github.com/dushixiang/pika/internal/protocol"
"github.com/dushixiang/pika/internal/vmclient"
)
// convertToMetrics 将指标数据转换为 VictoriaMetrics Metric 对象
func (s *MetricService) convertToMetrics(agentID string, metricType string, data interface{}, timestamp int64) []vmclient.Metric {
var metrics []vmclient.Metric
switch protocol.MetricType(metricType) {
case protocol.MetricTypeCPU:
cpuData := data.(*protocol.CPUData)
metrics = append(metrics, createMetric("pika_cpu_usage_percent", agentID, nil, cpuData.UsagePercent, timestamp))
metrics = append(metrics, createMetric("pika_cpu_cores_logical", agentID, nil, float64(cpuData.LogicalCores), timestamp))
metrics = append(metrics, createMetric("pika_cpu_cores_physical", agentID, nil, float64(cpuData.PhysicalCores), timestamp))
case protocol.MetricTypeMemory:
memData := data.(*protocol.MemoryData)
metrics = append(metrics, createMetric("pika_memory_usage_percent", agentID, nil, memData.UsagePercent, timestamp))
metrics = append(metrics, createMetric("pika_memory_total_bytes", agentID, nil, float64(memData.Total), timestamp))
metrics = append(metrics, createMetric("pika_memory_used_bytes", agentID, nil, float64(memData.Used), timestamp))
metrics = append(metrics, createMetric("pika_memory_available_bytes", agentID, nil, float64(memData.Available), timestamp))
metrics = append(metrics, createMetric("pika_memory_swap_total_bytes", agentID, nil, float64(memData.SwapTotal), timestamp))
metrics = append(metrics, createMetric("pika_memory_swap_used_bytes", agentID, nil, float64(memData.SwapUsed), timestamp))
case protocol.MetricTypeDisk:
diskDataList := data.([]protocol.DiskData)
for _, diskData := range diskDataList {
labels := map[string]string{"mount_point": diskData.MountPoint}
metrics = append(metrics, createMetric("pika_disk_usage_percent", agentID, labels, diskData.UsagePercent, timestamp))
metrics = append(metrics, createMetric("pika_disk_total_bytes", agentID, labels, float64(diskData.Total), timestamp))
metrics = append(metrics, createMetric("pika_disk_used_bytes", agentID, labels, float64(diskData.Used), timestamp))
metrics = append(metrics, createMetric("pika_disk_free_bytes", agentID, labels, float64(diskData.Free), timestamp))
}
case protocol.MetricTypeNetwork:
networkDataList := data.([]protocol.NetworkData)
for _, netData := range networkDataList {
labels := map[string]string{"interface": netData.Interface}
metrics = append(metrics, createMetric("pika_network_sent_bytes_rate", agentID, labels, float64(netData.BytesSentRate), timestamp))
metrics = append(metrics, createMetric("pika_network_recv_bytes_rate", agentID, labels, float64(netData.BytesRecvRate), timestamp))
metrics = append(metrics, createMetric("pika_network_sent_bytes_total", agentID, labels, float64(netData.BytesSentTotal), timestamp))
metrics = append(metrics, createMetric("pika_network_recv_bytes_total", agentID, labels, float64(netData.BytesRecvTotal), timestamp))
}
case protocol.MetricTypeNetworkConnection:
connData := data.(*protocol.NetworkConnectionData)
metrics = append(metrics, createMetric("pika_network_conn_established", agentID, nil, float64(connData.Established), timestamp))
metrics = append(metrics, createMetric("pika_network_conn_syn_sent", agentID, nil, float64(connData.SynSent), timestamp))
metrics = append(metrics, createMetric("pika_network_conn_syn_recv", agentID, nil, float64(connData.SynRecv), timestamp))
metrics = append(metrics, createMetric("pika_network_conn_time_wait", agentID, nil, float64(connData.TimeWait), timestamp))
metrics = append(metrics, createMetric("pika_network_conn_close_wait", agentID, nil, float64(connData.CloseWait), timestamp))
metrics = append(metrics, createMetric("pika_network_conn_total", agentID, nil, float64(connData.Total), timestamp))
case protocol.MetricTypeDiskIO:
diskIODataList := data.([]*protocol.DiskIOData)
// 汇总所有磁盘的 IO
var totalReadRate, totalWriteRate uint64
for _, diskIOData := range diskIODataList {
totalReadRate += diskIOData.ReadBytesRate
totalWriteRate += diskIOData.WriteBytesRate
}
metrics = append(metrics, createMetric("pika_disk_read_bytes_rate", agentID, nil, float64(totalReadRate), timestamp))
metrics = append(metrics, createMetric("pika_disk_write_bytes_rate", agentID, nil, float64(totalWriteRate), timestamp))
case protocol.MetricTypeGPU:
gpuDataList := data.([]protocol.GPUData)
for _, gpuData := range gpuDataList {
labels := map[string]string{
"gpu_index": fmt.Sprintf("%d", gpuData.Index),
"gpu_name": gpuData.Name,
}
metrics = append(metrics, createMetric("pika_gpu_utilization_percent", agentID, labels, gpuData.Utilization, timestamp))
metrics = append(metrics, createMetric("pika_gpu_memory_total_bytes", agentID, labels, float64(gpuData.MemoryTotal), timestamp))
metrics = append(metrics, createMetric("pika_gpu_memory_used_bytes", agentID, labels, float64(gpuData.MemoryUsed), timestamp))
metrics = append(metrics, createMetric("pika_gpu_temperature_celsius", agentID, labels, gpuData.Temperature, timestamp))
metrics = append(metrics, createMetric("pika_gpu_power_draw_watts", agentID, labels, gpuData.PowerUsage, timestamp))
}
case protocol.MetricTypeTemperature:
tempDataList := data.([]protocol.TemperatureData)
for _, tempData := range tempDataList {
// 只使用稳定的 sensor_label (Type) 作为标签
// 不使用 sensor_key,因为同一类型的最大值可能来自不同传感器,会导致产生多条时间序列
labels := map[string]string{
"sensor_label": tempData.Type,
}
metrics = append(metrics, createMetric("pika_temperature_celsius", agentID, labels, tempData.Temperature, timestamp))
}
case protocol.MetricTypeMonitor:
monitorDataList := data.([]protocol.MonitorData)
for _, monitorData := range monitorDataList {
labels := map[string]string{
"monitor_id": monitorData.MonitorId,
"monitor_type": monitorData.Type,
"target": monitorData.Target,
}
metrics = append(metrics, createMetric("pika_monitor_response_time_ms", agentID, labels, float64(monitorData.ResponseTime), timestamp))
}
}
return metrics
}
// createMetric 创建 VictoriaMetrics Metric 对象
func createMetric(metricName, agentID string, extraLabels map[string]string, value float64, timestamp int64) vmclient.Metric {
// 创建 metric labels,包含 __name__ 和 agent_id
labels := make(map[string]string)
labels["__name__"] = metricName
labels["agent_id"] = agentID
// 添加额外的 labels
if extraLabels != nil {
for k, v := range extraLabels {
labels[k] = v
}
}
return vmclient.Metric{
Metric: labels,
Values: []float64{value},
Timestamps: []int64{timestamp},
}
}
File diff suppressed because it is too large. Load diff
+98 -493
View File
@@ -7,13 +7,12 @@ import (
"strings"
"time"
"github.com/dushixiang/pika/internal/metric"
"github.com/dushixiang/pika/internal/models"
"github.com/dushixiang/pika/internal/protocol"
"github.com/dushixiang/pika/internal/repo"
ws "github.com/dushixiang/pika/internal/websocket"
"github.com/go-orz/cache"
"github.com/go-orz/orz"
"github.com/go-orz/toolkit"
"github.com/google/uuid"
"go.uber.org/zap"
"gorm.io/datatypes"
@@ -24,15 +23,10 @@ type MonitorService struct {
logger *zap.Logger
*repo.MonitorRepo
*orz.Service
agentRepo *repo.AgentRepo
metricRepo *repo.MetricRepo
monitorStatsRepo *repo.MonitorStatsRepo
wsManager *ws.Manager
// 监控概览缓存:缓存监控任务列表(使用不同的 key 区分 public 和 private)
overviewCache cache.Cache[string, []PublicMonitorOverview]
// 监控统计缓存:缓存单个监控任务的统计数据
statsCache cache.Cache[string, []models.MonitorStats]
agentRepo *repo.AgentRepo
metricRepo *repo.MetricRepo
metricService *MetricService
wsManager *ws.Manager
// 调度器引用(用于动态管理任务)
scheduler MonitorScheduler
@@ -45,19 +39,15 @@ type MonitorScheduler interface {
RemoveTask(monitorID string)
}
func NewMonitorService(logger *zap.Logger, db *gorm.DB, wsManager *ws.Manager) *MonitorService {
func NewMonitorService(logger *zap.Logger, db *gorm.DB, metricService *MetricService, wsManager *ws.Manager) *MonitorService {
return &MonitorService{
logger: logger,
Service: orz.NewService(db),
MonitorRepo: repo.NewMonitorRepo(db),
agentRepo: repo.NewAgentRepo(db),
metricRepo: repo.NewMetricRepo(db),
monitorStatsRepo: repo.NewMonitorStatsRepo(db),
wsManager: wsManager,
// 缓存 5 分钟,避免频繁查询
overviewCache: cache.New[string, []PublicMonitorOverview](5 * time.Minute),
statsCache: cache.New[string, []models.MonitorStats](5 * time.Minute),
logger: logger,
Service: orz.NewService(db),
MonitorRepo: repo.NewMonitorRepo(db),
agentRepo: repo.NewAgentRepo(db),
metricRepo: repo.NewMetricRepo(db),
metricService: metricService,
wsManager: wsManager,
}
}
@@ -82,29 +72,6 @@ type MonitorTaskRequest struct {
Tags []string `json:"tags"`
}
// PublicMonitorOverview 用于公开展示的监控配置及汇总数据
type PublicMonitorOverview struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Target string `json:"target"`
ShowTargetPublic bool `json:"showTargetPublic"` // 在公开页面是否显示目标地址
Description string `json:"description"`
Enabled bool `json:"enabled"`
Interval int `json:"interval"`
AgentIds []string `json:"agentIds"`
AgentCount int `json:"agentCount"`
LastCheckStatus string `json:"lastCheckStatus"`
LastCheckError string `json:"lastCheckError"`
CurrentResponse int64 `json:"currentResponse"`
AvgResponse24h int64 `json:"avgResponse24h"`
Uptime24h float64 `json:"uptime24h"`
Uptime7d float64 `json:"uptime7d"`
CertExpiryDate int64 `json:"certExpiryDate"`
CertExpiryDays int `json:"certExpiryDays"`
LastCheckTime int64 `json:"lastCheckTime"`
}
func (s *MonitorService) CreateMonitor(ctx context.Context, req *MonitorTaskRequest) (*models.MonitorTask, error) {
// 设置默认检测频率
interval := req.Interval
@@ -141,9 +108,6 @@ func (s *MonitorService) CreateMonitor(ctx context.Context, req *MonitorTaskRequ
return nil, err
}
// 清理缓存
s.clearCache(task.ID)
// 如果任务启用,添加到调度器
if task.Enabled && s.scheduler != nil {
if err := s.scheduler.AddTask(task.ID, task.Interval); err != nil {
@@ -191,9 +155,6 @@ func (s *MonitorService) UpdateMonitor(ctx context.Context, id string, req *Moni
return nil, err
}
// 清理缓存
s.clearCache(task.ID)
// 更新调度器
if s.scheduler != nil {
// 如果从禁用变为启用,或者间隔时间改变
@@ -226,19 +187,6 @@ func (s *MonitorService) DeleteMonitor(ctx context.Context, id string) error {
if err := s.MonitorRepo.DeleteById(ctx, id); err != nil {
return err
}
// 删除监控统计数据
if err := s.monitorStatsRepo.DeleteByMonitorId(ctx, id); err != nil {
s.logger.Error("删除监控统计数据失败", zap.String("monitorId", id), zap.Error(err))
return err
}
// 删除监控指标数据
if err := s.metricRepo.DeleteMonitorMetrics(ctx, id); err != nil {
s.logger.Error("删除监控指标数据失败", zap.String("monitorId", id), zap.Error(err))
return err
}
return nil
})
@@ -246,9 +194,6 @@ func (s *MonitorService) DeleteMonitor(ctx context.Context, id string) error {
return err
}
// 清理缓存
s.clearCache(id)
// 从调度器中移除
if s.scheduler != nil {
s.scheduler.RemoveTask(id)
@@ -258,76 +203,35 @@ func (s *MonitorService) DeleteMonitor(ctx context.Context, id string) error {
}
// ListByAuth 返回公开展示所需的监控配置和汇总统计
func (s *MonitorService) ListByAuth(ctx context.Context, isAuthenticated bool) ([]PublicMonitorOverview, error) {
// 构建缓存键:根据认证状态使用不同的 key
cacheKey := "overview:public"
if isAuthenticated {
cacheKey = "overview:private"
}
// 尝试从缓存获取
if cachedResult, ok := s.overviewCache.Get(cacheKey); ok {
return cachedResult, nil
}
// 缓存未命中,查询数据库
func (s *MonitorService) ListByAuth(ctx context.Context, isAuthenticated bool) ([]metric.PublicMonitorOverview, error) {
// 获取符合权限的监控任务列表
monitors, err := s.FindByAuth(ctx, isAuthenticated)
if err != nil {
return nil, err
}
if len(monitors) == 0 {
emptyResult := []PublicMonitorOverview{}
// 缓存空结果
s.overviewCache.Set(cacheKey, emptyResult, 5*time.Minute)
return emptyResult, nil
}
// 提取监控任务ID列表
monitorIds := make([]string, 0, len(monitors))
for _, monitor := range monitors {
monitorIds = append(monitorIds, monitor.ID)
}
// 批量获取统计数据(已经由后台任务预计算好)
statsList, err := s.monitorStatsRepo.FindByMonitorIdIn(ctx, monitorIds)
if err != nil {
return nil, err
}
// 按监控任务ID分组统计数据
statsMap := make(map[string][]models.MonitorStats, len(monitors))
for _, stats := range statsList {
statsMap[stats.MonitorId] = append(statsMap[stats.MonitorId], stats)
}
// 构建监控概览列表
items := make([]PublicMonitorOverview, 0, len(monitors))
items := make([]metric.PublicMonitorOverview, 0, len(monitors))
for _, monitor := range monitors {
// 直接聚合预计算的统计数据
summary := aggregateMonitorStats(statsMap[monitor.ID])
// 查询统计数据
stats := s.metricService.GetMonitorStats(monitor.ID)
// 构建监控概览对象
item := s.buildMonitorOverview(monitor, summary)
item := s.buildMonitorOverview(monitor, stats)
items = append(items, item)
}
// 缓存结果
s.overviewCache.Set(cacheKey, items, 5*time.Minute)
return items, nil
}
// buildMonitorOverview 构建监控概览对象
func (s *MonitorService) buildMonitorOverview(monitor models.MonitorTask, summary monitorOverviewSummary) PublicMonitorOverview {
func (s *MonitorService) buildMonitorOverview(monitor models.MonitorTask, stats *metric.MonitorStatsResult) metric.PublicMonitorOverview {
// 根据 ShowTargetPublic 字段决定是否返回真实的 Target
target := monitor.Target
if !monitor.ShowTargetPublic {
target = "******"
}
return PublicMonitorOverview{
overview := metric.PublicMonitorOverview{
ID: monitor.ID,
Name: monitor.Name,
Type: monitor.Type,
@@ -336,123 +240,20 @@ func (s *MonitorService) buildMonitorOverview(monitor models.MonitorTask, summar
Description: monitor.Description,
Enabled: monitor.Enabled,
Interval: monitor.Interval,
AgentIds: cloneAgentIDs(monitor.AgentIds),
AgentCount: summary.AgentCount,
LastCheckStatus: summary.LastCheckStatus,
LastCheckError: summary.LastCheckError,
CurrentResponse: summary.CurrentResponse,
AvgResponse24h: summary.AvgResponse24h,
Uptime24h: summary.Uptime24h,
Uptime7d: summary.Uptime7d,
CertExpiryDate: summary.CertExpiryDate,
CertExpiryDays: summary.CertExpiryDays,
LastCheckTime: summary.LastCheckTime,
}
}
type monitorOverviewSummary struct {
AgentCount int
LastCheckStatus string
LastCheckError string
CurrentResponse int64
AvgResponse24h int64
Uptime24h float64
Uptime7d float64
CertExpiryDate int64
CertExpiryDays int
LastCheckTime int64
}
func aggregateMonitorStats(stats []models.MonitorStats) monitorOverviewSummary {
summary := monitorOverviewSummary{
LastCheckStatus: "unknown",
AgentCount: stats.AgentCount,
Status: stats.Status,
ResponseTime: stats.ResponseTime,
ResponseTimeMin: stats.ResponseTimeMin,
ResponseTimeMax: stats.ResponseTimeMax,
CertExpiryTime: stats.CertExpiryTime,
CertDaysLeft: stats.CertDaysLeft,
LastCheckTime: stats.LastCheckTime,
}
if len(stats) == 0 {
return summary
}
// 复制探针状态分布
overview.AgentStats = stats.AgentStats
var totalCurrentResponse int64
var totalAvgResponse24h int64
var totalUptime24h float64
var totalUptime7d float64
var lastCheckTime int64
var lastCheckError string
var certExpiryDate int64
var certExpiryDays int
hasCert := false
hasUp := false
hasDown := false
for _, stat := range stats {
totalCurrentResponse += stat.CurrentResponse
totalAvgResponse24h += stat.AvgResponse24h
totalUptime24h += stat.Uptime24h
totalUptime7d += stat.Uptime7d
// 记录最新的检测时间和对应的错误信息
if stat.LastCheckTime > lastCheckTime {
lastCheckTime = stat.LastCheckTime
// 如果这是最新的检测且状态为 down,记录错误信息
if stat.LastCheckStatus == "down" {
lastCheckError = stat.LastCheckError
} else {
lastCheckError = ""
}
}
switch stat.LastCheckStatus {
case "up":
hasUp = true
case "down":
hasDown = true
}
if stat.CertExpiryDate > 0 {
if !hasCert || stat.CertExpiryDate < certExpiryDate {
certExpiryDate = stat.CertExpiryDate
certExpiryDays = stat.CertExpiryDays
hasCert = true
}
}
}
count := len(stats)
summary.AgentCount = count
if count > 0 {
summary.CurrentResponse = totalCurrentResponse / int64(count)
summary.AvgResponse24h = totalAvgResponse24h / int64(count)
summary.Uptime24h = totalUptime24h / float64(count)
summary.Uptime7d = totalUptime7d / float64(count)
}
summary.LastCheckTime = lastCheckTime
summary.LastCheckError = lastCheckError
switch {
case hasUp:
summary.LastCheckStatus = "up"
case hasDown:
summary.LastCheckStatus = "down"
default:
summary.LastCheckStatus = "unknown"
}
if hasCert {
summary.CertExpiryDate = certExpiryDate
summary.CertExpiryDays = certExpiryDays
}
return summary
}
func cloneAgentIDs(ids datatypes.JSONSlice[string]) []string {
if len(ids) == 0 {
return []string{}
}
copied := make([]string, len(ids))
copy(copied, []string(ids))
return copied
return overview
}
// resolveTargetAgents 计算监控任务对应的目标探针范围
@@ -508,17 +309,10 @@ func (s *MonitorService) resolveTargetAgents(monitor models.MonitorTask, availab
// sendMonitorConfigToAgent 向指定探针发送监控配置(内部方法)
func (s *MonitorService) sendMonitorConfigToAgent(agentID string, payload protocol.MonitorConfigPayload) error {
payloadData, err := json.Marshal(payload)
if err != nil {
return err
}
msg := protocol.Message{
msgData, err := json.Marshal(protocol.OutboundMessage{
Type: protocol.MessageTypeMonitorConfig,
Data: payloadData,
}
msgData, err := json.Marshal(msg)
Data: payload,
})
if err != nil {
return err
}
@@ -594,278 +388,94 @@ func (s *MonitorService) SendMonitorTaskToAgents(ctx context.Context, monitor mo
return nil
}
// CalculateMonitorStats 计算监控统计数据
func (s *MonitorService) CalculateMonitorStats(ctx context.Context) error {
now := time.Now()
// 获取所有启用的监控任务
monitors, err := s.MonitorRepo.FindByEnabled(ctx, true)
if err != nil {
return err
}
// 获取所有探针(包括在线和离线)
agents, err := s.agentRepo.FindAll(ctx)
if err != nil {
return err
}
// 收集所有有效的监控任务ID
validStatsIDs := make(map[string]bool)
// 为每个监控任务的每个探针计算统计数据
for _, monitor := range monitors {
// 使用统一的方法计算目标探针
targetAgents := s.resolveTargetAgents(monitor, agents)
for _, agent := range targetAgents {
stats, err := s.calculateStatsForAgentMonitor(ctx, agent.ID, monitor.ID, monitor.Type, monitor.Target, now)
if err != nil {
s.logger.Error("计算监控统计失败",
zap.String("agentID", agent.ID),
zap.String("monitorName", monitor.Name),
zap.Error(err))
continue
}
// 记录有效的监控任务ID
validStatsIDs[stats.ID] = true
if err := s.monitorStatsRepo.Save(ctx, stats); err != nil {
s.logger.Error("保存监控统计失败",
zap.String("agentID", agent.ID),
zap.String("monitorName", monitor.Name),
zap.Error(err))
}
}
}
// 清理无效监控任务的统计数据
if err := s.cleanupInvalidStats(ctx, validStatsIDs); err != nil {
s.logger.Error("清理无效统计数据失败", zap.Error(err))
// 不返回错误,继续运行
}
return nil
}
// calculateStatsForAgentMonitor 计算单个探针单个监控任务的统计数据
func (s *MonitorService) calculateStatsForAgentMonitor(ctx context.Context, agentID, monitorId, monitorType, target string, now time.Time) (*models.MonitorStats, error) {
stats := &models.MonitorStats{
ID: toolkit.Sign("monitor_stats", agentID, monitorId, monitorType, target),
AgentID: agentID,
MonitorId: monitorId,
MonitorType: monitorType,
Target: target,
}
// 计算24小时数据
start24h := now.Add(-24 * time.Hour).UnixMilli()
end := now.UnixMilli()
metrics24h, err := s.metricRepo.GetMonitorMetrics(ctx, agentID, monitorId, start24h, end)
if err != nil {
return nil, err
}
// 计算7天数据
start7d := now.Add(-7 * 24 * time.Hour).UnixMilli()
metrics7d, err := s.metricRepo.GetMonitorMetrics(ctx, agentID, monitorId, start7d, end)
if err != nil {
return nil, err
}
// 计算24小时统计
if len(metrics24h) > 0 {
var totalResponse int64
var successCount int64
lastMetric := metrics24h[len(metrics24h)-1]
for _, metric := range metrics24h {
if metric.Status == "up" {
successCount++
totalResponse += metric.ResponseTime
}
}
stats.TotalChecks24h = int64(len(metrics24h))
stats.SuccessChecks24h = successCount
if successCount > 0 {
stats.AvgResponse24h = totalResponse / successCount
}
if stats.TotalChecks24h > 0 {
stats.Uptime24h = float64(successCount) / float64(stats.TotalChecks24h) * 100
}
// 最后一次检测数据
stats.CurrentResponse = lastMetric.ResponseTime
stats.LastCheckTime = lastMetric.Timestamp
stats.LastCheckStatus = lastMetric.Status
stats.LastCheckError = lastMetric.Error
// 从最新的检测结果中获取证书信息
if lastMetric.CertExpiryTime > 0 {
stats.CertExpiryDate = lastMetric.CertExpiryTime
stats.CertExpiryDays = lastMetric.CertDaysLeft
}
}
// 计算7天统计
if len(metrics7d) > 0 {
var successCount int64
for _, metric := range metrics7d {
if metric.Status == "up" {
successCount++
}
}
stats.TotalChecks7d = int64(len(metrics7d))
stats.SuccessChecks7d = successCount
if stats.TotalChecks7d > 0 {
stats.Uptime7d = float64(successCount) / float64(stats.TotalChecks7d) * 100
}
}
return stats, nil
}
// GetMonitorStatsByID 获取监控任务的统计数据(聚合后的单个监控详情)
func (s *MonitorService) GetMonitorStatsByID(ctx context.Context, monitorID string) (*PublicMonitorOverview, error) {
func (s *MonitorService) GetMonitorStatsByID(ctx context.Context, monitorID string) (*metric.PublicMonitorOverview, error) {
// 查询监控任务
monitor, err := s.MonitorRepo.FindById(ctx, monitorID)
if err != nil {
return nil, err
}
// 获取统计数据
statsList, err := s.monitorStatsRepo.FindByMonitorId(ctx, monitor.ID)
if err != nil {
return nil, err
}
// 聚合统计数据
summary := aggregateMonitorStats(statsList)
// 查询统计数据
stats := s.metricService.GetMonitorStats(monitorID)
// 构建监控概览对象
overview := s.buildMonitorOverview(monitor, summary)
overview := s.buildMonitorOverview(monitor, stats)
return &overview, nil
}
// GetMonitorAgentStats 获取监控任务各探针的统计数据(详细列表)
func (s *MonitorService) GetMonitorAgentStats(ctx context.Context, monitorID string) ([]models.MonitorStats, error) {
// 构建缓存键
cacheKey := fmt.Sprintf("agents:%s", monitorID)
// 尝试从缓存获取
if cachedResult, ok := s.statsCache.Get(cacheKey); ok {
return cachedResult, nil
}
// 缓存未命中,查询数据库
// GetMonitorDetail 获取监控详情(整合stats和agents)
func (s *MonitorService) GetMonitorDetail(ctx context.Context, monitorID string) (*metric.MonitorDetailResponse, error) {
// 查询监控任务
monitor, err := s.MonitorRepo.FindById(ctx, monitorID)
if err != nil {
return nil, err
}
statsList, err := s.monitorStatsRepo.FindByMonitorId(ctx, monitor.ID)
if err != nil {
return nil, err
// 查询聚合统计
stats := s.metricService.GetMonitorStats(monitorID)
// 查询各探针数据
agents := s.metricService.GetMonitorAgentStats(monitorID)
// 根据 ShowTargetPublic 字段决定是否返回真实的 Target
target := monitor.Target
if !monitor.ShowTargetPublic {
target = "******"
}
// 获取探针列表
agents, err := s.agentRepo.FindAll(ctx)
if err != nil {
return nil, err
}
// 当前监控任务的关联的探针
targetAgents := s.resolveTargetAgents(monitor, agents)
var targetAgentsMap = make(map[string]string)
for _, agent := range targetAgents {
targetAgentsMap[agent.ID] = agent.Name
// 构建响应
detail := &metric.MonitorDetailResponse{
ID: monitor.ID,
Name: monitor.Name,
Type: monitor.Type,
Target: target,
ShowTargetPublic: monitor.ShowTargetPublic,
Description: monitor.Description,
Enabled: monitor.Enabled,
Interval: monitor.Interval,
Stats: stats,
Agents: agents,
}
// 填充监控名称、探针名称和隐私设置
var filteredStatsList []models.MonitorStats
for _, stats := range statsList {
agentName, ok := targetAgentsMap[stats.AgentID]
if !ok {
continue
}
stats.MonitorName = monitor.Name
// 根据 ShowTargetPublic 字段决定是否隐藏 Target
if !monitor.ShowTargetPublic {
stats.Target = "******"
}
stats.AgentName = agentName
filteredStatsList = append(filteredStatsList, stats)
}
// 缓存结果
s.statsCache.Set(cacheKey, filteredStatsList, 5*time.Minute)
return filteredStatsList, nil
return detail, nil
}
// GetMonitorHistory 获取监控任务的历史响应时间数据
// 根据前端时间范围选择,直接使用聚合表数据
// GetMonitorHistory 获取监控任务的历史时序数据
// 直接返回 VictoriaMetrics 的原始时序数据,包含所有探针的独立序列
// 支持时间范围:15m, 30m, 1h, 3h, 6h, 12h, 1d, 3d, 7d
func (s *MonitorService) GetMonitorHistory(ctx context.Context, monitorID, timeRange string) ([]repo.AggregatedMonitorMetric, error) {
monitor, err := s.MonitorRepo.FindById(ctx, monitorID)
if err != nil {
return nil, err
}
// 根据前端时间范围,选择最优的聚合粒度
func (s *MonitorService) GetMonitorHistory(ctx context.Context, monitorID, timeRange string) (*metric.GetMetricsResponse, error) {
// 计算时间范围
var duration time.Duration
var bucketSeconds int // 使用的聚合 bucket(秒)
switch timeRange {
case "15m":
duration = 15 * time.Minute
bucketSeconds = 60 // 1分钟聚合,约15个点
case "30m":
duration = 30 * time.Minute
bucketSeconds = 60 // 1分钟聚合,约30个点
case "1h":
duration = 1 * time.Hour
bucketSeconds = 300 // 5分钟聚合,约12个点
case "3h":
duration = 3 * time.Hour
bucketSeconds = 300 // 5分钟聚合,约36个点
case "6h":
duration = 6 * time.Hour
bucketSeconds = 900 // 15分钟聚合,约24个点
case "12h":
duration = 12 * time.Hour
bucketSeconds = 900 // 15分钟聚合,约48个点
case "1d", "24h":
duration = 24 * time.Hour
bucketSeconds = 1800 // 30分钟聚合,约48个点
case "3d":
duration = 3 * 24 * time.Hour
bucketSeconds = 3600 // 1小时聚合,约72个点
case "7d":
duration = 7 * 24 * time.Hour
bucketSeconds = 7200 // 2小时聚合,约84个点
default:
// 默认 15 分钟
duration = 15 * time.Minute
bucketSeconds = 60
}
now := time.Now()
end := now.UnixMilli()
start := now.Add(-duration).UnixMilli()
// 对齐时间范围到 bucket 边界
bucketMs := int64(bucketSeconds * 1000)
start, end = alignTimeRangeToBucket(start, end, bucketMs)
// 直接使用聚合表查询
return s.metricRepo.GetMonitorMetricsAgg(ctx, monitor.ID, start, end, bucketSeconds)
// 直接返回 VictoriaMetrics 查询结果,无需任何转换
return s.metricService.GetMonitorHistory(ctx, monitorID, start, end)
}
// GetMonitorByAuth 根据认证状态获取监控任务(已登录返回全部,未登录返回公开可见)
@@ -890,42 +500,37 @@ func (s *MonitorService) GetMonitorByAuth(ctx context.Context, id string, isAuth
return monitor, nil
}
// clearCache 清理监控任务相关的所有缓存
func (s *MonitorService) clearCache(monitorID string) {
// 清理概览缓存
s.overviewCache.Delete("overview:public")
s.overviewCache.Delete("overview:private")
// 清理统计缓存
s.statsCache.Delete(fmt.Sprintf("agents:%s", monitorID))
}
// cleanupInvalidStats 按监控任务维度清理无效的统计数据
// 删除不在有效监控任务列表中的所有统计数据(监控任务被禁用或删除)
func (s *MonitorService) cleanupInvalidStats(ctx context.Context, validStatsIDs map[string]bool) error {
// 获取所有现有的统计数据
allStats, err := s.monitorStatsRepo.FindAll(ctx)
// GetLatestMonitorMetricsByType 获取指定类型的最新监控指标(用于告警检查)
func (s *MonitorService) GetLatestMonitorMetricsByType(ctx context.Context, monitorType string) ([]protocol.MonitorData, error) {
// 查询数据库
monitorTasks, err := s.FindByEnabledAndType(ctx, true, monitorType)
if err != nil {
return err
return nil, err
}
// 收集需要删除的统计数据ID
idsToDelete := make([]string, 0)
for _, stats := range allStats {
// 如果该统计数据对应的监控任务不在有效列表中,删除该统计数据
if !validStatsIDs[stats.ID] {
idsToDelete = append(idsToDelete, stats.ID)
}
// 在缓存中查询最新的监控数据
var result []protocol.MonitorData
for _, task := range monitorTasks {
monitorData := s.metricService.GetMonitorAgentStats(task.ID)
result = append(result, monitorData...)
}
// 批量删除无效的统计数据
if len(idsToDelete) > 0 {
s.logger.Info("清理无效的监控统计数据",
zap.Int("count", len(idsToDelete)))
if err := s.monitorStatsRepo.DeleteByIDs(ctx, idsToDelete); err != nil {
return err
}
}
return nil
return result, nil
}
// GetAllLatestMonitorMetrics 获取所有最新监控指标(用于告警检查)
func (s *MonitorService) GetAllLatestMonitorMetrics(ctx context.Context) ([]protocol.MonitorData, error) {
// 查询所有最新的监控状态
monitorTasks, err := s.FindByEnabled(ctx, true)
if err != nil {
return nil, err
}
// 在缓存中查询最新的监控数据
var result []protocol.MonitorData
for _, task := range monitorTasks {
monitorData := s.metricService.GetMonitorAgentStats(task.ID)
result = append(result, monitorData...)
}
return result, nil
}
-23
View File
@@ -120,22 +120,6 @@ func (s *PropertyService) GetSystemConfig(ctx context.Context) (*models.SystemCo
return &systemConfig, nil
}
// GetMetricsConfig 获取指标配置
func (s *PropertyService) GetMetricsConfig(ctx context.Context) models.MetricsConfig {
var config models.MetricsConfig
err := s.GetValue(ctx, PropertyIDMetricsConfig, &config)
if err != nil {
// 返回默认配置
return models.MetricsConfig{}
}
return config
}
// SetMetricsConfig 设置指标配置
func (s *PropertyService) SetMetricsConfig(ctx context.Context, config models.MetricsConfig) error {
return s.Set(ctx, PropertyIDMetricsConfig, "指标数据配置", config)
}
// GetAlertConfig 获取告警配置
func (s *PropertyService) GetAlertConfig(ctx context.Context) (*models.AlertConfig, error) {
var config models.AlertConfig
@@ -252,13 +236,6 @@ func (s *PropertyService) InitializeDefaultConfigs(ctx context.Context) error {
Name: "通知渠道配置",
Value: []models.NotificationChannelConfig{},
},
{
ID: PropertyIDMetricsConfig,
Name: "指标数据配置",
Value: models.MetricsConfig{
RetentionHours: 168, // 默认7天
},
},
{
ID: PropertyIDAlertConfig,
Name: "告警配置",
+3 -10
View File
@@ -142,17 +142,10 @@ func (s *TamperService) sendConfigToAgent(agentID string, added, removed []strin
Removed: removed,
}
data, err := json.Marshal(configData)
if err != nil {
return err
}
msg := protocol.Message{
msgBytes, err := json.Marshal(protocol.OutboundMessage{
Type: protocol.MessageTypeTamperProtect,
Data: data,
}
msgBytes, err := json.Marshal(msg)
Data: configData,
})
if err != nil {
return err
}
+306
View File
@@ -0,0 +1,306 @@
package vmclient
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"
)
// VMClient VictoriaMetrics 客户端
type VMClient struct {
baseURL string
httpClient *http.Client
writeTimeout time.Duration
queryTimeout time.Duration
}
// QueryResult 查询结果
type QueryResult struct {
Status string `json:"status"`
Data ResultData `json:"data"`
}
// ResultData 查询结果数据
type ResultData struct {
ResultType string `json:"resultType"`
Result []Result `json:"result"`
}
// Result 单个时间序列结果
type Result struct {
Metric map[string]string `json:"metric"`
Values [][]interface{} `json:"values"` // [[timestamp, value], ...]
}
// DataPoint 数据点
type DataPoint struct {
Timestamp int64
Value float64
Labels map[string]string
}
// Metric VictoriaMetrics JSON Line Format 指标
type Metric struct {
Metric map[string]string `json:"metric"`
Values []float64 `json:"values"`
Timestamps []int64 `json:"timestamps"`
}
// NewVMClient 创建 VictoriaMetrics 客户端
func NewVMClient(baseURL string, writeTimeout, queryTimeout time.Duration) *VMClient {
if writeTimeout == 0 {
writeTimeout = 30 * time.Second
}
if queryTimeout == 0 {
queryTimeout = 60 * time.Second
}
return &VMClient{
baseURL: baseURL,
httpClient: &http.Client{
Timeout: writeTimeout, // 默认超时
},
writeTimeout: writeTimeout,
queryTimeout: queryTimeout,
}
}
// Write 写入指标(VictoriaMetrics JSON Line Format)
func (c *VMClient) Write(ctx context.Context, metrics []Metric) error {
if len(metrics) == 0 {
return nil
}
reqCtx, cancel := context.WithTimeout(ctx, c.writeTimeout)
defer cancel()
// 将 Metric 数组转换为 JSON Line Format (NDJSON)
var buf bytes.Buffer
encoder := json.NewEncoder(&buf)
for _, metric := range metrics {
if err := encoder.Encode(metric); err != nil {
return fmt.Errorf("encode metric failed: %w", err)
}
}
req, err := http.NewRequestWithContext(reqCtx, "POST", c.baseURL+"/api/v1/import", &buf)
if err != nil {
return fmt.Errorf("create request failed: %w", err)
}
req.Header.Set("Content-Type", "application/x-ndjson")
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("write metrics failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("write metrics failed with status %d: %s", resp.StatusCode, string(body))
}
return nil
}
// AutoStep 根据查询范围自动生成 step(适用于 VictoriaMetrics)
func AutoStep(start, end time.Time) time.Duration {
r := end.Sub(start)
switch {
case r <= time.Hour:
return 10 * time.Second
case r <= 2*time.Hour:
return 15 * time.Second
case r <= 6*time.Hour:
return 30 * time.Second
case r <= 12*time.Hour:
return time.Minute
case r <= 24*time.Hour:
return 2 * time.Minute
case r <= 3*24*time.Hour:
return 5 * time.Minute
case r <= 7*24*time.Hour:
return 10 * time.Minute
case r <= 30*24*time.Hour:
return 30 * time.Minute
default:
return time.Hour
}
}
// QueryRange 范围查询
// 如果 step 为 0,则让 VictoriaMetrics 自动选择合适的步长
func (c *VMClient) QueryRange(ctx context.Context, query string, start, end time.Time, step time.Duration) (*QueryResult, error) {
reqCtx, cancel := context.WithTimeout(ctx, c.queryTimeout)
defer cancel()
params := url.Values{}
params.Set("query", query)
params.Set("start", fmt.Sprintf("%d", start.Unix()))
params.Set("end", fmt.Sprintf("%d", end.Unix()))
if step > 0 {
params.Set("step", fmt.Sprintf("%ds", int(step.Seconds())))
} else {
autoStep := AutoStep(start, end)
params.Set("step", fmt.Sprintf("%ds", int(autoStep.Seconds())))
}
reqURL := fmt.Sprintf("%s/api/v1/query_range?%s", c.baseURL, params.Encode())
req, err := http.NewRequestWithContext(reqCtx, "GET", reqURL, nil)
if err != nil {
return nil, fmt.Errorf("create request failed: %w", err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("query range failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("query range failed with status %d: %s", resp.StatusCode, string(body))
}
var result QueryResult
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("decode response failed: %w", err)
}
if result.Status != "success" {
return nil, fmt.Errorf("query failed with status: %s", result.Status)
}
return &result, nil
}
// Query 即时查询
func (c *VMClient) Query(ctx context.Context, query string) (*QueryResult, error) {
reqCtx, cancel := context.WithTimeout(ctx, c.queryTimeout)
defer cancel()
params := url.Values{}
params.Set("query", query)
reqURL := fmt.Sprintf("%s/api/v1/query?%s", c.baseURL, params.Encode())
req, err := http.NewRequestWithContext(reqCtx, "GET", reqURL, nil)
if err != nil {
return nil, fmt.Errorf("create request failed: %w", err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("query failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("query failed with status %d: %s", resp.StatusCode, string(body))
}
var result QueryResult
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("decode response failed: %w", err)
}
if result.Status != "success" {
return nil, fmt.Errorf("query failed with status: %s", result.Status)
}
return &result, nil
}
// ConvertToDataPoints 将查询结果转换为数据点列表
func ConvertToDataPoints(result *QueryResult) []DataPoint {
if result == nil || len(result.Data.Result) == 0 {
return []DataPoint{}
}
var points []DataPoint
for _, r := range result.Data.Result {
for _, v := range r.Values {
if len(v) < 2 {
continue
}
// timestamp 是 float64(Unix 秒)
timestamp, ok := v[0].(float64)
if !ok {
continue
}
// value 是 string
valueStr, ok := v[1].(string)
if !ok {
continue
}
var value float64
if _, err := fmt.Sscanf(valueStr, "%f", &value); err != nil {
continue
}
points = append(points, DataPoint{
Timestamp: int64(timestamp * 1000), // 转换为毫秒
Value: value,
Labels: r.Metric,
})
}
}
return points
}
// GetLabelValues 获取指定 label 的所有值
func (c *VMClient) GetLabelValues(ctx context.Context, labelName string, match []string) ([]string, error) {
reqCtx, cancel := context.WithTimeout(ctx, c.queryTimeout)
defer cancel()
params := url.Values{}
for _, m := range match {
params.Add("match[]", m)
}
reqURL := fmt.Sprintf("%s/api/v1/label/%s/values?%s", c.baseURL, labelName, params.Encode())
req, err := http.NewRequestWithContext(reqCtx, "GET", reqURL, nil)
if err != nil {
return nil, fmt.Errorf("create request failed: %w", err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("get label values failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("get label values failed with status %d: %s", resp.StatusCode, string(body))
}
var result struct {
Status string `json:"status"`
Data []string `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("decode response failed: %w", err)
}
if result.Status != "success" {
return nil, fmt.Errorf("get label values failed with status: %s", result.Status)
}
return result.Data, nil
}
+1 -1
View File
@@ -221,7 +221,7 @@ func (c *Client) ReadPump(ctx context.Context) {
c.LastActive = time.Now()
// 解析消息
var msg protocol.Message
var msg protocol.InputMessage
if err := json.Unmarshal(message, &msg); err != nil {
c.Manager.logger.Error("failed to parse message", zap.Error(err), zap.String("agentID", c.ID))
continue
+35
View File
@@ -4,10 +4,13 @@
package internal
import (
"time"
"github.com/dushixiang/pika/internal/config"
"github.com/dushixiang/pika/internal/handler"
"github.com/dushixiang/pika/internal/repo"
"github.com/dushixiang/pika/internal/service"
"github.com/dushixiang/pika/internal/vmclient"
"github.com/dushixiang/pika/internal/websocket"
"github.com/google/wire"
"go.uber.org/zap"
@@ -17,6 +20,9 @@ import (
// InitializeApp 初始化应用
func InitializeApp(logger *zap.Logger, db *gorm.DB, cfg *config.AppConfig) (*AppComponents, error) {
wire.Build(
// VictoriaMetrics Client
provideVMClient,
service.NewAccountService,
service.NewAgentService,
service.NewUserService,
@@ -80,4 +86,33 @@ type AppComponents struct {
DDNSService *service.DDNSService
WSManager *websocket.Manager
VMClient *vmclient.VMClient
}
// provideVMClient 提供 VictoriaMetrics 客户端
func provideVMClient(cfg *config.AppConfig, logger *zap.Logger) *vmclient.VMClient {
// 检查配置
if cfg.VictoriaMetrics == nil || !cfg.VictoriaMetrics.Enabled {
logger.Info("VictoriaMetrics is not enabled, using default configuration")
// 返回一个默认配置的客户端(用于本地开发)
return vmclient.NewVMClient("http://localhost:8428", 30*time.Second, 60*time.Second)
}
// 使用配置创建客户端
writeTimeout := time.Duration(cfg.VictoriaMetrics.WriteTimeout) * time.Second
if writeTimeout == 0 {
writeTimeout = 30 * time.Second
}
queryTimeout := time.Duration(cfg.VictoriaMetrics.QueryTimeout) * time.Second
if queryTimeout == 0 {
queryTimeout = 60 * time.Second
}
logger.Info("VictoriaMetrics client initialized",
zap.String("url", cfg.VictoriaMetrics.URL),
zap.Duration("writeTimeout", writeTimeout),
zap.Duration("queryTimeout", queryTimeout))
return vmclient.NewVMClient(cfg.VictoriaMetrics.URL, writeTimeout, queryTimeout)
}
+33 -4
View File
@@ -11,9 +11,11 @@ import (
"github.com/dushixiang/pika/internal/handler"
"github.com/dushixiang/pika/internal/repo"
"github.com/dushixiang/pika/internal/service"
"github.com/dushixiang/pika/internal/vmclient"
"github.com/dushixiang/pika/internal/websocket"
"go.uber.org/zap"
"gorm.io/gorm"
"time"
)
// Injectors from wire.go:
@@ -28,14 +30,15 @@ func InitializeApp(logger *zap.Logger, db *gorm.DB, cfg *config.AppConfig) (*App
apiKeyService := service.NewApiKeyService(logger, db)
propertyService := service.NewPropertyService(logger, db)
trafficService := service.NewTrafficService(logger, db)
metricService := service.NewMetricService(logger, db, propertyService, trafficService)
vmClient := provideVMClient(cfg, logger)
metricService := service.NewMetricService(logger, db, propertyService, trafficService, vmClient)
geoIPService, err := service.NewGeoIPService(logger, cfg)
if err != nil {
return nil, err
}
agentService := service.NewAgentService(logger, db, apiKeyService, metricService, geoIPService)
manager := websocket.NewManager(logger)
monitorService := service.NewMonitorService(logger, db, manager)
monitorService := service.NewMonitorService(logger, db, metricService, manager)
tamperRepo := repo.NewTamperRepo(db)
tamperService := service.NewTamperService(logger, tamperRepo, manager)
ddnsConfigRepo := repo.NewDDNSConfigRepo(db)
@@ -44,10 +47,10 @@ func InitializeApp(logger *zap.Logger, db *gorm.DB, cfg *config.AppConfig) (*App
agentHandler := handler.NewAgentHandler(logger, agentService, metricService, monitorService, tamperService, ddnsService, manager)
apiKeyHandler := handler.NewApiKeyHandler(logger, apiKeyService)
notifier := service.NewNotifier(logger)
alertService := service.NewAlertService(logger, db, propertyService, notifier)
alertService := service.NewAlertService(logger, db, propertyService, monitorService, notifier)
alertHandler := handler.NewAlertHandler(logger, alertService)
propertyHandler := handler.NewPropertyHandler(logger, propertyService, notifier)
monitorHandler := handler.NewMonitorHandler(logger, monitorService, agentService)
monitorHandler := handler.NewMonitorHandler(logger, monitorService, metricService, agentService)
tamperHandler := handler.NewTamperHandler(logger, tamperService)
dnsProviderHandler := handler.NewDNSProviderHandler(logger, propertyService)
ddnsHandler := handler.NewDDNSHandler(logger, ddnsService)
@@ -70,6 +73,7 @@ func InitializeApp(logger *zap.Logger, db *gorm.DB, cfg *config.AppConfig) (*App
TamperService: tamperService,
DDNSService: ddnsService,
WSManager: manager,
VMClient: vmClient,
}
return appComponents, nil
}
@@ -98,4 +102,29 @@ type AppComponents struct {
DDNSService *service.DDNSService
WSManager *websocket.Manager
VMClient *vmclient.VMClient
}
// provideVMClient 提供 VictoriaMetrics 客户端
func provideVMClient(cfg *config.AppConfig, logger *zap.Logger) *vmclient.VMClient {
if cfg.VictoriaMetrics == nil || !cfg.VictoriaMetrics.Enabled {
logger.Info("VictoriaMetrics is not enabled, using default configuration")
return vmclient.NewVMClient("http://localhost:8428", 30*time.Second, 60*time.Second)
}
writeTimeout := time.Duration(cfg.VictoriaMetrics.WriteTimeout) * time.Second
if writeTimeout == 0 {
writeTimeout = 30 * time.Second
}
queryTimeout := time.Duration(cfg.VictoriaMetrics.QueryTimeout) * time.Second
if queryTimeout == 0 {
queryTimeout = 60 * time.Second
}
logger.Info("VictoriaMetrics client initialized", zap.String("url", cfg.VictoriaMetrics.URL), zap.Duration("writeTimeout", writeTimeout), zap.Duration("queryTimeout", queryTimeout))
return vmclient.NewVMClient(cfg.VictoriaMetrics.URL, writeTimeout, queryTimeout)
}
+9 -34
View File
@@ -1,8 +1,6 @@
package collector
import (
"encoding/json"
"github.com/dushixiang/pika/internal/protocol"
"github.com/dushixiang/pika/pkg/agent/config"
)
@@ -168,44 +166,21 @@ func (m *Manager) CollectAndSendDDNSIP(conn WebSocketWriter) error {
return nil
}
dataBytes, err := json.Marshal(ipData)
if err != nil {
return err
}
msg := protocol.Message{
return conn.WriteJSON(protocol.OutboundMessage{
Type: protocol.MessageTypeDDNSIPReport,
Data: dataBytes,
}
return conn.WriteJSON(msg)
Data: ipData,
})
}
// sendMetrics 发送指标数据
func (m *Manager) sendMetrics(conn WebSocketWriter, metricType protocol.MetricType, data interface{}) error {
dataBytes, err := json.Marshal(data)
if err != nil {
return err
}
//dataBytes = bytes.ReplaceAll(dataBytes, []byte{0x00}, []byte(`"`))
metrics := protocol.MetricsWrapper{
Type: metricType,
Data: json.RawMessage(dataBytes),
}
metricsData, err := json.Marshal(metrics)
if err != nil {
return err
}
msg := protocol.Message{
return conn.WriteJSON(protocol.OutboundMessage{
Type: protocol.MessageTypeMetrics,
Data: metricsData,
}
return conn.WriteJSON(msg)
Data: protocol.MetricsPayload{
Type: metricType,
Data: data,
},
})
}
// GetPublicIP 通过 API 获取公网 IP 地址
+4 -4
View File
@@ -64,7 +64,7 @@ func (c *MonitorCollector) Collect(items []protocol.MonitorItem) []protocol.Moni
result = c.checkICMP(item)
default:
result = protocol.MonitorData{
ID: item.ID,
MonitorId: item.ID,
Type: item.Type,
Target: item.Target,
Status: "down",
@@ -82,7 +82,7 @@ func (c *MonitorCollector) Collect(items []protocol.MonitorItem) []protocol.Moni
// checkHTTP 检查 HTTP/HTTPS 服务
func (c *MonitorCollector) checkHTTP(item protocol.MonitorItem) protocol.MonitorData {
result := protocol.MonitorData{
ID: item.ID,
MonitorId: item.ID,
Type: item.Type,
Target: item.Target,
CheckedAt: time.Now().UnixMilli(),
@@ -204,7 +204,7 @@ func (c *MonitorCollector) checkHTTP(item protocol.MonitorItem) protocol.Monitor
// checkTCP 检查 TCP 端口
func (c *MonitorCollector) checkTCP(item protocol.MonitorItem) protocol.MonitorData {
result := protocol.MonitorData{
ID: item.ID,
MonitorId: item.ID,
Type: item.Type,
Target: item.Target,
CheckedAt: time.Now().UnixMilli(),
@@ -239,7 +239,7 @@ func (c *MonitorCollector) checkTCP(item protocol.MonitorItem) protocol.MonitorD
// checkICMP 检查 ICMP (Ping)
func (c *MonitorCollector) checkICMP(item protocol.MonitorItem) protocol.MonitorData {
result := protocol.MonitorData{
ID: item.ID,
MonitorId: item.ID,
Type: item.Type,
Target: item.Target,
CheckedAt: time.Now().UnixMilli(),
+72 -103
View File
@@ -21,6 +21,7 @@ import (
"github.com/dushixiang/pika/pkg/version"
"github.com/gorilla/websocket"
"github.com/jpillora/backoff"
"github.com/sourcegraph/conc"
)
// 定义特殊错误类型
@@ -189,57 +190,79 @@ func (a *Agent) runOnce(ctx context.Context, onConnected func()) error {
a.setActiveConn(nil)
}()
// 创建完成通道
// 创建完成通道和错误通道
done := make(chan struct{})
errChan := make(chan error, 3)
errChan := make(chan error, 1) // 只需要接收第一个错误
// 创建 WaitGroup 用于等待所有 goroutine 退出
var wg conc.WaitGroup
// 启动读取循环(处理服务端的 Ping/Pong 等控制消息)
go func() {
wg.Go(func() {
if err := a.readLoop(rawConn, done); err != nil {
errChan <- fmt.Errorf("读取失败: %w", err)
select {
case errChan <- fmt.Errorf("读取失败: %w", err):
default:
}
}
}()
})
// 启动心跳和数据发送
go func() {
wg.Go(func() {
if err := a.heartbeatLoop(ctx, conn, done); err != nil {
errChan <- fmt.Errorf("心跳失败: %w", err)
select {
case errChan <- fmt.Errorf("心跳失败: %w", err):
default:
}
}
}()
})
go func() {
// 启动指标采集循环
wg.Go(func() {
if err := a.metricsLoop(ctx, conn, collectorManager, done); err != nil {
errChan <- fmt.Errorf("数据采集失败: %w", err)
select {
case errChan <- fmt.Errorf("数据采集失败: %w", err):
default:
}
}
}()
})
// 启动防篡改事件监控
go func() {
wg.Go(func() {
a.tamperEventLoop(ctx, conn, done)
}()
})
// 启动防篡改属性告警监控
go func() {
wg.Go(func() {
a.tamperAlertLoop(ctx, conn, done)
}()
})
// 等待错误或上下文取消
// 等待第一个错误或上下文取消
var returnErr error
select {
case err := <-errChan:
close(done)
// 连接已建立,无论什么原因断开都标记为已建立状态
log.Printf("连接断开: %v", err)
return ErrConnectionEstablished
returnErr = ErrConnectionEstablished
case <-ctx.Done():
close(done)
// 优雅关闭连接
closeMsg := websocket.FormatCloseMessage(websocket.CloseNormalClosure, "")
if err := conn.WriteMessage(websocket.CloseMessage, closeMsg); err != nil {
log.Printf("⚠️ 关闭连接失败: %v", err)
}
time.Sleep(time.Second)
return ctx.Err() // 返回上下文错误
// 收到取消信号
log.Println("收到停止信号,准备关闭连接")
returnErr = ctx.Err()
}
// 关闭 done channel,通知所有 goroutine 退出
close(done)
// 发送 WebSocket 关闭消息
closeMsg := websocket.FormatCloseMessage(websocket.CloseNormalClosure, "")
if err := conn.WriteMessage(websocket.CloseMessage, closeMsg); err != nil {
log.Printf("⚠️ 发送关闭消息失败: %v", err)
}
// 等待所有 goroutine 优雅退出
wg.Wait()
return returnErr
}
// readLoop 读取服务端消息(主要用于处理 Ping/Pong 和指令)
@@ -258,7 +281,7 @@ func (a *Agent) readLoop(conn *websocket.Conn, done chan struct{}) error {
}
// 解析消息
var msg protocol.Message
var msg protocol.InputMessage
if err := json.Unmarshal(message, &msg); err != nil {
log.Printf("⚠️ 解析消息失败: %v", err)
continue
@@ -313,22 +336,15 @@ func (a *Agent) registerAgent(conn *safeConn) error {
ApiKey: a.cfg.Server.APIKey,
}
reqData, err := json.Marshal(registerReq)
if err != nil {
return fmt.Errorf("序列化注册请求失败: %w", err)
}
msg := protocol.Message{
if err := conn.WriteJSON(protocol.OutboundMessage{
Type: protocol.MessageTypeRegister,
Data: reqData,
}
if err := conn.WriteJSON(msg); err != nil {
Data: registerReq,
}); err != nil {
return fmt.Errorf("发送注册消息失败: %w", err)
}
// 读取注册响应
var response protocol.Message
var response protocol.InputMessage
if err := conn.ReadJSON(&response); err != nil {
return fmt.Errorf("读取注册响应失败: %w", err)
}
@@ -392,11 +408,10 @@ func (a *Agent) heartbeatLoop(ctx context.Context, conn *safeConn, done chan str
for {
select {
case <-ticker.C:
msg := protocol.Message{
if err := conn.WriteJSON(protocol.OutboundMessage{
Type: protocol.MessageTypeHeartbeat,
Data: json.RawMessage(`{}`),
}
if err := conn.WriteJSON(msg); err != nil {
Data: struct{}{},
}); err != nil {
return fmt.Errorf("发送心跳失败: %w", err)
}
//log.Println("💓 心跳已发送")
@@ -581,24 +596,10 @@ func (a *Agent) sendCommandResponse(conn *safeConn, cmdID, cmdType, status, errM
Result: result,
}
respData, err := json.Marshal(resp)
if err != nil {
log.Printf("⚠️ 序列化指令响应失败: %v", err)
return
}
msg := protocol.Message{
if err := conn.WriteJSON(protocol.OutboundMessage{
Type: protocol.MessageTypeCommandResp,
Data: respData,
}
msgData, err := json.Marshal(msg)
if err != nil {
log.Printf("⚠️ 序列化消息失败: %v", err)
return
}
if err := conn.WriteMessage(websocket.TextMessage, msgData); err != nil {
Data: resp,
}); err != nil {
log.Printf("⚠️ 发送指令响应失败: %v", err)
}
}
@@ -670,18 +671,10 @@ func (a *Agent) sendTamperProtectResponse(success bool, message string, paths []
Error: errMsg,
}
respData, err := json.Marshal(resp)
if err != nil {
log.Printf("⚠️ 序列化防篡改保护响应失败: %v", err)
return
}
msg := protocol.Message{
if err := conn.WriteJSON(protocol.OutboundMessage{
Type: protocol.MessageTypeTamperProtect,
Data: respData,
}
if err := conn.WriteJSON(msg); err != nil {
Data: resp,
}); err != nil {
log.Printf("⚠️ 发送防篡改保护响应失败: %v", err)
}
}
@@ -705,18 +698,10 @@ func (a *Agent) tamperEventLoop(ctx context.Context, conn *safeConn, done chan s
Details: event.Details,
}
data, err := json.Marshal(eventData)
if err != nil {
log.Printf("⚠️ 序列化防篡改事件失败: %v", err)
continue
}
msg := protocol.Message{
if err := conn.WriteJSON(protocol.OutboundMessage{
Type: protocol.MessageTypeTamperEvent,
Data: data,
}
if err := conn.WriteJSON(msg); err != nil {
Data: eventData,
}); err != nil {
log.Printf("⚠️ 发送防篡改事件失败: %v", err)
} else {
log.Printf("📤 已上报防篡改事件: %s - %s", event.Path, event.Operation)
@@ -744,18 +729,10 @@ func (a *Agent) tamperAlertLoop(ctx context.Context, conn *safeConn, done chan s
Restored: alert.Restored,
}
data, err := json.Marshal(alertData)
if err != nil {
log.Printf("⚠️ 序列化属性篡改告警失败: %v", err)
continue
}
msg := protocol.Message{
if err := conn.WriteJSON(protocol.OutboundMessage{
Type: protocol.MessageTypeTamperAlert,
Data: data,
}
if err := conn.WriteJSON(msg); err != nil {
Data: alertData,
}); err != nil {
log.Printf("⚠️ 发送属性篡改告警失败: %v", err)
} else {
status := "未恢复"
@@ -829,18 +806,10 @@ func (a *Agent) collectAndSendDDNSIP(conn *safeConn, manager *collector.Manager,
return fmt.Errorf("未获取到任何 IP 地址")
}
// 序列化并发送
data, err := json.Marshal(ipReport)
if err != nil {
return fmt.Errorf("序列化 IP 报告失败: %w", err)
}
msg := protocol.Message{
if err := conn.WriteJSON(protocol.OutboundMessage{
Type: protocol.MessageTypeDDNSIPReport,
Data: data,
}
if err := conn.WriteJSON(msg); err != nil {
Data: ipReport,
}); err != nil {
return fmt.Errorf("发送 IP 报告失败: %w", err)
}
+8
View File
@@ -14,3 +14,11 @@ services:
interval: 10s
timeout: 5s
retries: 5
victoriametrics:
image: victoriametrics/victoria-metrics:v1.131.0
container_name: victoriametrics
restart: always
ports:
- "8428:8428" # API, PromQL, insert, select
volumes:
- ./data/vmdata:/victoria-metrics-data
+14 -5
View File
@@ -8,19 +8,28 @@ export interface ListAgentsResponse {
export interface GetAgentMetricsRequest {
agentId: string;
type: 'cpu' | 'memory' | 'disk' | 'network' | 'network_connection' | 'disk_io' | 'gpu' | 'temperature';
type: 'cpu' | 'memory' | 'disk' | 'network' | 'network_connection' | 'disk_io' | 'gpu' | 'temperature' | 'monitor';
range?: string; // 时间范围,如 '15m', '1h', '1d' 等,从后端配置获取
interface?: string; // 网卡过滤参数(仅对 network 类型有效)
}
// 新的统一数据格式
export interface MetricDataPoint {
timestamp: number;
value: number;
}
export interface MetricSeries {
name: string;
labels?: Record<string, string>;
data: MetricDataPoint[];
}
export interface GetAgentMetricsResponse {
agentId: string;
type: string;
range: string;
start: number;
end: number;
interface?: string;
metrics: any[];
series: MetricSeries[];
}
// 管理员接口 - 获取所有探针(需要认证)
+31 -19
View File
@@ -1,5 +1,5 @@
import {get, post, put, del} from './request';
import type {MonitorListResponse, MonitorTask, MonitorTaskRequest, MonitorStats, PublicMonitor} from '../types';
import {del, get, post, put} from './request';
import type {AgentMonitorStat, MonitorDetail, MonitorListResponse, MonitorTask, MonitorTaskRequest, PublicMonitor} from '../types';
export const listMonitors = (page: number = 1, pageSize: number = 10, keyword?: string) => {
const params = new URLSearchParams();
@@ -39,26 +39,38 @@ export const getMonitorStatsById = (id: string) => {
return get<PublicMonitor>(`/monitors/${encodeURIComponent(id)}/stats`);
};
// 公开接口 - 获取指定监控各探针的统计数据(详细列表)
// 公开接口 - 获取指定监控各探针的统计数据(直接从 VictoriaMetrics 查询)
export const getMonitorAgentStats = (id: string) => {
return get<MonitorStats[]>(`/monitors/${encodeURIComponent(id)}/agents`);
return get<AgentMonitorStat[]>(`/monitors/${encodeURIComponent(id)}/agents`);
};
// 聚合的监控历史数据
export interface AggregatedMonitorMetric {
timestamp: number;
agentId: string;
avgResponse: number;
maxResponse: number;
minResponse: number;
successCount: number;
totalCount: number;
successRate: number;
lastStatus: string;
lastErrorMsg: string;
// 公开接口 - 获取监控详情(整合版,包含stats和agents)
export const getMonitorDetail = (id: string) => {
return get<MonitorDetail>(`/monitors/${encodeURIComponent(id)}`);
};
// VictoriaMetrics 时序数据点
export interface MetricDataPoint {
timestamp: number; // 毫秒时间戳
value: number;
}
// 公开接口 - 获取指定监控的历史数据
export const getMonitorHistory = (id: string, range: string = '5m') => {
return get<AggregatedMonitorMetric[]>(`/monitors/${encodeURIComponent(id)}/history?range=${range}`);
// VictoriaMetrics 时序数据系列
export interface MetricSeries {
name: string; // 系列名称(如 "response_time")
labels?: Record<string, string>; // 标签(如 { agent_id: "xxx", monitor_id: "yyy" })
data: MetricDataPoint[]; // 数据点数组
}
// VictoriaMetrics 查询响应(直接返回时序数据)
export interface GetMetricsResponse {
agentId: string; // 为空表示多探针
type: string; // "monitor"
range: string; // 时间范围描述
series: MetricSeries[]; // 时序数据系列(每个探针一个系列)
}
// 公开接口 - 获取指定监控的历史数据(VictoriaMetrics 原始时序数据)
export const getMonitorHistory = (id: string, range: string = '15m') => {
return get<GetMetricsResponse>(`/monitors/${encodeURIComponent(id)}/history?range=${range}`);
};
+78
View File
@@ -0,0 +1,78 @@
// 紧凑型资源条组件
const CompactResourceBar = ({ value, label, subtext, icon: Icon, color = "bg-cyan-500" }) => {
const isCritical = value > 90;
const isWarning = value > 75;
// 颜色定义 (Hex codes for precise control)
let activeColor = "";
let iconClass = "";
if (isCritical) {
activeColor = "#f43f5e"; // Rose
iconClass = "text-rose-500";
} else if (isWarning) {
activeColor = "#f59e0b"; // Amber
iconClass = "text-amber-500";
} else if (color.includes("purple")) {
activeColor = "#a855f7"; // Purple
iconClass = "text-purple-500";
} else if (color.includes("blue")) {
activeColor = "#3b82f6"; // Blue
iconClass = "text-blue-500";
} else {
activeColor = "#06b6d4"; // Cyan (Default)
iconClass = "text-cyan-500";
}
return (
<div className="flex items-center w-full h-5 gap-2 text-xs font-mono group/bar">
{/* Icon & Label */}
<div className={`flex items-center gap-2 w-10 flex-shrink-0 ${iconClass}`}>
<Icon className="w-3.5 h-3.5" strokeWidth={2} />
<span className="text-[10px] font-bold tracking-wider opacity-80">{label}</span>
</div>
{/* Track Container */}
<div className="w-[100px] h-2 bg-[#121217] relative border border-white/5 overflow-hidden">
{/* Scale Marks Background (The "Ruler" effect) */}
<div
className="absolute inset-0 w-full h-full opacity-20 pointer-events-none z-0"
style={{
backgroundImage: 'linear-gradient(90deg, #94a3b8 1px, transparent 1px)',
backgroundSize: '10% 100%'
}}
></div>
{/* Active Bar with Industrial Stripes */}
<div
className="h-full relative transition-all duration-500 ease-out z-10"
style={{
width: `${Math.min(value, 100)}%`,
backgroundColor: activeColor,
// 45-degree angled stripes pattern
backgroundImage: 'linear-gradient(45deg,rgba(0,0,0,.2) 25%,transparent 25%,transparent 50%,rgba(0,0,0,.2) 50%,rgba(0,0,0,.2) 75%,transparent 75%,transparent)',
backgroundSize: '4px 4px'
}}
>
{/* Leading Edge Laser Line */}
<div className="absolute right-0 top-0 bottom-0 w-[1.5px] bg-white shadow-[0_0_8px_white]"></div>
</div>
</div>
<div className={`w-10 font-medium text-xs ${isCritical ? 'text-rose-400' : 'text-slate-200'}`}>{value.toFixed(1)}%</div>
{/* Value & Subtext */}
<div className="w-28 text-right flex items-baseline justify-end gap-2 flex-shrink-0">
{subtext && (
<span className="text-[9px] text-slate-500 opacity-60 group-hover/bar:opacity-100 transition-opacity">
{subtext}
</span>
)}
</div>
</div>
);
};
export default CompactResourceBar;
+39
View File
@@ -0,0 +1,39 @@
import React from 'react';
import {cn} from "@/lib/utils.ts";
interface Props {
className?: string
children: React.ReactNode;
animation?: boolean
hover?: boolean
}
const CyberCard = ({className, children, animation, hover}: Props) => {
return (
<div
className={cn(
"group bg-[#0f1016]/80 backdrop-blur-md border border-cyan-500/20 shadow-[0_0_15px_rgba(6,182,212,0.05)] transition-all duration-300 cursor-pointer overflow-hidden relative",
hover && "hover:border-cyan-500/50 hover:shadow-[0_0_25px_rgba(6,182,212,0.15)] hover:bg-[#0f1016]/90"
)}>
{/* 装饰性边框 */}
<div
className="absolute top-0 left-0 w-2 h-2 border-t-2 border-l-2 border-cyan-500/30 group-hover:border-cyan-400 transition-colors"></div>
<div
className="absolute top-0 right-0 w-2 h-2 border-t-2 border-r-2 border-cyan-500/30 group-hover:border-cyan-400 transition-colors"></div>
<div
className="absolute bottom-0 left-0 w-2 h-2 border-b-2 border-l-2 border-cyan-500/30 group-hover:border-cyan-400 transition-colors"></div>
<div
className="absolute bottom-0 right-0 w-2 h-2 border-b-2 border-r-2 border-cyan-500/30 group-hover:border-cyan-400 transition-colors"></div>
{animation &&
<div
className="absolute inset-0 bg-gradient-to-b from-transparent via-cyan-500/5 to-transparent -translate-y-full group-hover:translate-y-full transition-transform duration-1000 ease-in-out pointer-events-none"/>
}
<div className={cn("relative z-10 p-4", className)}>
{children}
</div>
</div>
);
};
export default CyberCard;
+53
View File
@@ -0,0 +1,53 @@
import type {FC} from 'react';
import {ArrowDown, ArrowUp, Network} from 'lucide-react';
import {formatBytes, formatSpeed} from '@/utils/util';
interface NetworkStatCardProps {
uploadRate: number;
downloadRate: number;
uploadTotal: number;
downloadTotal: number;
}
const NetworkStatCard: FC<NetworkStatCardProps> = ({
uploadRate,
downloadRate,
uploadTotal,
downloadTotal
}) => {
return (
<div className="relative overflow-hidden rounded-xl border border-blue-500/30 bg-blue-500/5 p-3 sm:p-5 text-blue-400">
<div className="absolute -right-4 -bottom-4 opacity-10 rotate-[-15deg]">
<Network className="w-16 sm:w-24 h-16 sm:h-24"/>
</div>
<div className="relative z-10 flex justify-between items-start">
<div className="flex-1 min-w-0">
<div className="text-[10px] font-mono uppercase tracking-widest opacity-70 mb-1">
网络统计
</div>
<div className="space-y-1 text-[10px] sm:text-xs font-mono">
<div className="flex items-center gap-1.5 sm:gap-2">
<ArrowUp className="w-3 h-3 text-blue-400 flex-shrink-0"/>
<span className="text-cyan-300 truncate">{formatSpeed(uploadRate)}</span>
<span className="text-cyan-600 text-[9px] sm:text-[10px] hidden sm:inline">
({formatBytes(uploadTotal)})
</span>
</div>
<div className="flex items-center gap-1.5 sm:gap-2">
<ArrowDown className="w-3 h-3 text-emerald-400 flex-shrink-0"/>
<span className="text-cyan-300 truncate">{formatSpeed(downloadRate)}</span>
<span className="text-cyan-600 text-[9px] sm:text-[10px] hidden sm:inline">
({formatBytes(downloadTotal)})
</span>
</div>
</div>
</div>
<div className="p-2 rounded-lg bg-black/20 backdrop-blur-sm border border-white/5 flex-shrink-0">
<Network className="w-4 sm:w-5 h-4 sm:h-5"/>
</div>
</div>
</div>
);
};
export default NetworkStatCard;
+2
View File
@@ -9,6 +9,7 @@ export interface Action {
type?: 'default' | 'primary';
onClick: () => void;
danger?: boolean;
disabled?: boolean;
}
interface PageHeaderProps {
@@ -36,6 +37,7 @@ export const PageHeader: React.FC<PageHeaderProps> = ({title, description, actio
icon={action.icon}
onClick={action.onClick}
danger={action.danger}
disabled={action.disabled}
>
{action.label}
</Button>
+16 -15
View File
@@ -1,53 +1,54 @@
import {Heart} from 'lucide-react';
import {Heart, Github} from 'lucide-react';
const PublicFooter = () => {
const currentYear = new Date().getFullYear();
const icpCode = window.SystemConfig?.ICPCode || '';
return (
<footer className="border-t border-slate-100 dark:border-white/10 bg-gradient-to-b from-white to-slate-50 dark:from-[#141414] dark:to-[#0d0d0d]">
<footer className="border-t border-cyan-900/50 bg-[#05050a]">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
{/* 底部版权信息 */}
<div className="py-6">
<div
className="flex flex-col items-center justify-between gap-3 text-xs text-slate-500 dark:text-slate-400 sm:flex-row">
<div className="flex items-center gap-1">
<span>© {currentYear}</span>
<div className="flex flex-col items-center justify-between gap-4 text-xs text-cyan-600 sm:flex-row font-mono">
<div className="flex flex-wrap items-center justify-center gap-2">
<span className="text-cyan-500">© {currentYear}</span>
<span className="text-cyan-900">|</span>
{/* GitHub 链接 */}
<a
href="https://github.com/dushixiang/pika"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 text-cyan-400 hover:text-cyan-300 transition-colors group"
title="查看 GitHub 仓库"
>
<div className="text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 underline transition-colors">Pika Monitor</div>
{/*<img src={GithubSvg} className="h-4 w-4" alt="GitHub"/>*/}
<Github className="h-3 w-3 group-hover:scale-110 transition-transform"/>
<span className="underline decoration-cyan-700 underline-offset-2">Pika Monitor</span>
</a>
<span className="text-slate-300 dark:text-slate-600">·</span>
<span>保持洞察,稳定运行</span>
<span className="text-cyan-900">|</span>
<span className="text-cyan-600/80 tracking-wider">保持洞察 · 稳定运行</span>
{/* ICP 备案号 */}
{icpCode && (
<>
<span className="text-slate-300 dark:text-slate-600">·</span>
<span className="text-cyan-900">|</span>
<a
href="https://beian.miit.gov.cn"
target="_blank"
rel="noopener noreferrer"
className="text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 transition-colors"
className="text-cyan-600/80 hover:text-cyan-400 transition-colors"
>
{icpCode}
</a>
</>
)}
</div>
<div className="flex items-center gap-1">
<div className="flex items-center gap-1.5 text-cyan-500">
<span>用</span>
<Heart className="h-3 w-3 fill-red-500 text-red-500"/>
<Heart className="h-3 w-3 fill-rose-500 text-rose-500 animate-pulse"/>
<span>构建</span>
</div>
</div>
</div>
</div>
<div className="h-[1px] w-full bg-gradient-to-r from-transparent via-cyan-500/30 to-transparent"></div>
</footer>
);
};
+147 -190
View File
@@ -1,23 +1,12 @@
import {useEffect, useRef, useState} from 'react';
import {Activity, LayoutGrid, List, LogIn, Moon, Server, Settings, Sun} from 'lucide-react';
import {useEffect, useState} from 'react';
import {Activity, LogIn, ServerIcon, Settings, Menu, X} from 'lucide-react';
import {getCurrentUser} from '../api/auth';
import {Link, useLocation} from "react-router-dom";
import {flushSync} from "react-dom";
interface PublicHeaderProps {
viewMode?: 'grid' | 'list';
onViewModeChange?: (mode: 'grid' | 'list') => void;
showViewToggle?: boolean;
}
const PublicHeader = ({
viewMode,
onViewModeChange,
showViewToggle = false
}: PublicHeaderProps) => {
const PublicHeader = () => {
const [isLoggedIn, setIsLoggedIn] = useState(false);
const [isDarkMode, setIsDarkMode] = useState(false);
const darkModeButtonRef = useRef<HTMLButtonElement>(null);
const [currentTime, setCurrentTime] = useState(new Date());
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
let location = useLocation();
useEffect(() => {
@@ -43,207 +32,175 @@ const PublicHeader = ({
});
}, []);
// 初始化暗色主题
// 时钟特效
useEffect(() => {
const savedTheme = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const shouldBeDark = savedTheme === 'dark' || (!savedTheme && prefersDark);
setIsDarkMode(shouldBeDark);
if (shouldBeDark) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
const t = setInterval(() => setCurrentTime(new Date()), 1000);
return () => clearInterval(t);
}, []);
// 切换暗色主题的函数,带动画效果
const toggleDarkMode = async (newIsDarkMode: boolean) => {
if (
!darkModeButtonRef.current ||
!document.startViewTransition ||
window.matchMedia('(prefers-reduced-motion: reduce)').matches
) {
// 如果不支持 View Transition API 或用户偏好减少动画,直接切换
setIsDarkMode(newIsDarkMode);
if (newIsDarkMode) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
localStorage.setItem('theme', newIsDarkMode ? 'dark' : 'light');
return;
}
await document.startViewTransition(() => {
flushSync(() => {
setIsDarkMode(newIsDarkMode);
if (newIsDarkMode) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
localStorage.setItem('theme', newIsDarkMode ? 'dark' : 'light');
});
}).ready;
const {top, left, width, height} = darkModeButtonRef.current.getBoundingClientRect();
const x = left + width / 2;
const y = top + height / 2;
const right = window.innerWidth - left;
const bottom = window.innerHeight - top;
const maxRadius = Math.hypot(
Math.max(left, right),
Math.max(top, bottom),
);
document.documentElement.animate(
{
clipPath: [
`circle(0px at ${x}px ${y}px)`,
`circle(${maxRadius}px at ${x}px ${y}px)`,
],
},
{
duration: 500,
easing: 'ease-in-out',
pseudoElement: '::view-transition-new(root)',
}
);
};
// 判断导航是否激活
const currentPath = location.pathname;
const isDeviceActive = currentPath === '/';
const isMonitorActive = currentPath === '/monitors';
let activeTab = 'servers';
if (currentPath.startsWith('/monitors')) {
activeTab = 'monitors';
}
return (
<header
className="sticky top-0 z-50 border-b border-slate-200 dark:border-white/10 bg-white/90 dark:bg-[#141414]/90 backdrop-blur">
<div className="mx-auto max-w-7xl px-4 py-3 sm:px-6 lg:px-8">
<div className="flex items-center justify-between gap-4">
{/* 左侧:品牌和导航 */}
<div className="flex items-center gap-3 sm:gap-6">
{/* Logo 和品牌 */}
<div className="flex items-center gap-2 sm:gap-3">
<img
src={"/api/logo"}
className="h-8 w-8 sm:h-9 sm:w-9 object-contain rounded-md"
alt={'logo'}
onError={(e) => {
e.currentTarget.src = '/logo.png';
}}
/>
<div className="hidden md:block">
<p className="text-[10px] font-semibold uppercase tracking-[0.3em] text-blue-600 dark:text-blue-300">
{window.SystemConfig?.SystemNameEn}
</p>
<h1 className="text-sm font-bold text-slate-900 dark:text-slate-50">
{window.SystemConfig?.SystemNameZh}
</h1>
<>
<header className="border-b border-cyan-900/50 bg-[#05050a]/80 backdrop-blur-xl sticky top-0 z-40">
<div className="max-w-7xl mx-auto px-4 h-20 flex items-center justify-between">
<div className="flex items-center gap-8">
<Link to={'/'}>
<div className="flex items-center gap-3 group cursor-pointer">
<div className="relative">
<img
src={"/api/logo"}
className="h-8 w-8 sm:h-9 sm:w-9 object-contain rounded-md"
alt={'logo'}
onError={(e) => {
e.currentTarget.src = '/logo.png';
}}
/>
</div>
<div>
<h1 className="text-2xl font-black tracking-widest text-transparent bg-clip-text bg-gradient-to-r from-cyan-400 via-blue-400 to-purple-400 uppercase italic">
{window.SystemConfig?.SystemNameEn}
</h1>
<p className="text-[10px] text-cyan-500/60 font-mono tracking-[0.3em] uppercase">
{window.SystemConfig?.SystemNameZh}
</p>
</div>
</div>
</div>
</Link>
{/* 导航链接 */}
<nav className="flex items-center gap-1">
<Link to="/">
<div
className={`inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 sm:px-3 sm:py-2 text-xs font-medium transition-all ${
isDeviceActive
? 'bg-blue-50 dark:bg-blue-500/15 text-blue-600 dark:text-blue-200'
: 'text-slate-600 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-800 hover:text-slate-900 dark:hover:text-slate-50'
}`}
>
<Server className="h-3.5 w-3.5 sm:h-4 sm:w-4"/>
<span className="sm:inline">设备监控</span>
</div>
</Link>
<Link to="/monitors">
<div
className={`inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 sm:px-3 sm:py-2 text-xs font-medium transition-all ${
isMonitorActive
? 'bg-blue-50 dark:bg-blue-500/15 text-blue-600 dark:text-blue-200'
: 'text-slate-600 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-800 hover:text-slate-900 dark:hover:text-slate-50'
}`}>
<Activity className="h-3.5 w-3.5 sm:h-4 sm:w-4"/>
<span className="sm:inline">服务监控</span>
</div>
</Link>
</nav>
{/* HUD Navigation - Desktop Only */}
<div className="hidden md:flex items-center gap-8">
{[
{id: 'servers', icon: ServerIcon, label: '设备监控', to: '/'},
{id: 'monitors', icon: Activity, label: '服务监控', to: '/monitors'}
].map(tab => (
<Link to={tab.to} key={tab.id}>
<button
className={`
relative group flex items-center gap-2 py-2 text-xs font-bold tracking-widest transition-colors cursor-pointer
${activeTab === tab.id ? 'text-cyan-400' : 'text-slate-500 hover:text-cyan-200'}
`}
>
<tab.icon
className={`w-4 h-4 ${activeTab === tab.id ? 'text-cyan-400' : 'text-slate-600 group-hover:text-cyan-200'}`}/>
{tab.label}
{/* Active Indicator (Underline Glow) */}
<span
className={`absolute -bottom-1 left-0 w-full h-[2px] bg-cyan-500 shadow-[0_0_10px_rgba(34,211,238,0.8)] transition-transform duration-300 origin-left ${activeTab === tab.id ? 'scale-x-100' : 'scale-x-0'}`}></span>
</button>
</Link>
))}
</div>
</div>
{/* 右侧:功能区 */}
<div className="flex items-center gap-2 sm:gap-3">
{/* 暗色主题切换 */}
<button
ref={darkModeButtonRef}
type="button"
onClick={() => toggleDarkMode(!isDarkMode)}
className="inline-flex items-center rounded-lg p-1.5 sm:p-2 text-slate-600 hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-800 transition-all"
title={isDarkMode ? "切换到亮色模式" : "切换到暗色模式"}
>
{isDarkMode ? (
<Sun className="h-4 w-4 sm:h-5 sm:w-5"/>
) : (
<Moon className="h-4 w-4 sm:h-5 sm:w-5"/>
)}
</button>
{/* Desktop Right Section */}
<div className="hidden md:flex items-center gap-6">
<div className="hidden lg:flex flex-col items-end">
<span className="text-xs font-mono text-cyan-400">{currentTime.toLocaleTimeString()}</span>
<span
className="text-[10px] text-cyan-600 font-mono tracking-widest">{currentTime.toLocaleDateString()}</span>
</div>
<div className="h-8 w-[1px] bg-cyan-900/50 hidden lg:block"></div>
{/* 视图切换 */}
{showViewToggle && viewMode && onViewModeChange && (
<div
className="hidden sm:inline-flex items-center gap-0.5 rounded-lg border border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/70 p-0.5">
<button
type="button"
onClick={() => onViewModeChange('grid')}
className={`inline-flex items-center rounded-md p-1.5 transition-all cursor-pointer ${
viewMode === 'grid'
? 'bg-white dark:bg-slate-900 text-blue-600 dark:text-blue-200 shadow-sm dark:shadow-slate-900/60'
: 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-100'
}`}
title="网格视图"
>
<LayoutGrid className="h-3.5 w-3.5 sm:h-4 sm:w-4"/>
</button>
<button
type="button"
onClick={() => onViewModeChange('list')}
className={`inline-flex items-center rounded-md p-1.5 transition-all cursor-pointer ${
viewMode === 'list'
? 'bg-white dark:bg-slate-900 text-blue-600 dark:text-blue-200 shadow-sm dark:shadow-slate-900/60'
: 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-100'
}`}
title="列表视图"
>
<List className="h-3.5 w-3.5 sm:h-4 sm:w-4"/>
</button>
</div>
)}
{/* 登录/管理后台按钮 */}
{/* 登录/管理后台按钮 - Desktop */}
{isLoggedIn ? (
<a
href="/admin"
className="inline-flex items-center gap-1.5 rounded-lg bg-blue-600 px-2.5 py-1.5 sm:px-3 sm:py-2 text-xs font-medium text-white hover:bg-blue-700 dark:bg-blue-500 dark:text-slate-950 dark:hover:bg-blue-400 transition-all"
className="flex items-center gap-2 px-4 py-2 bg-cyan-500/10 hover:bg-cyan-500/20 border border-cyan-500/50 text-cyan-400 rounded transition-all text-xs font-bold tracking-wider uppercase group"
target="_blank"
>
<Settings className="h-3.5 w-3.5 sm:h-4 sm:w-4"/>
<span className="sm:inline">管理后台</span>
<Settings className="w-3 h-3 group-hover:rotate-90 transition-transform"/>
<span>Admin</span>
</a>
) : (
<a
href="/login"
className="inline-flex items-center gap-1.5 rounded-lg bg-blue-600 px-2.5 py-1.5 sm:px-3 sm:py-2 text-xs font-medium text-white hover:bg-blue-700 dark:bg-blue-500 dark:text-slate-950 dark:hover:bg-blue-400 transition-all"
className="flex items-center gap-2 px-4 py-2 bg-cyan-500/10 hover:bg-cyan-500/20 border border-cyan-500/50 text-cyan-400 rounded transition-all text-xs font-bold tracking-wider uppercase group"
target="_blank"
>
<LogIn className="h-3.5 w-3.5 sm:h-4 sm:w-4"/>
<span className="sm:inline">登录</span>
<LogIn className="w-3 h-3"/>
<span>Login</span>
</a>
)}
</div>
{/* Mobile Menu Button */}
<button
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
className="md:hidden p-2 text-cyan-400 hover:bg-cyan-500/10 rounded transition-colors"
aria-label="Toggle menu"
>
{mobileMenuOpen ? (
<X className="w-6 h-6"/>
) : (
<Menu className="w-6 h-6"/>
)}
</button>
</div>
<div className="h-[1px] w-full bg-gradient-to-r from-transparent via-cyan-500/50 to-transparent"></div>
</header>
{/* Mobile Menu */}
{mobileMenuOpen && (
<div className="md:hidden fixed inset-0 top-20 bg-[#05050a]/95 backdrop-blur-xl z-30 animate-in slide-in-from-top">
<div className="flex flex-col p-4 gap-4">
{/* Mobile Navigation */}
{[
{id: 'servers', icon: ServerIcon, label: '设备监控', to: '/'},
{id: 'monitors', icon: Activity, label: '服务监控', to: '/monitors'}
].map(tab => (
<Link
to={tab.to}
key={tab.id}
onClick={() => setMobileMenuOpen(false)}
className={`
flex items-center gap-3 p-4 rounded-lg border transition-all
${activeTab === tab.id
? 'bg-cyan-500/20 border-cyan-500/50 text-cyan-400'
: 'bg-cyan-500/5 border-cyan-500/20 text-slate-400 hover:bg-cyan-500/10 hover:border-cyan-500/30'
}
`}
>
<tab.icon className="w-5 h-5"/>
<span className="font-bold tracking-wider">{tab.label}</span>
</Link>
))}
{/* Divider */}
<div className="h-[1px] bg-cyan-900/50 my-2"></div>
{/* Mobile Login/Admin Button */}
{isLoggedIn ? (
<a
href="/admin"
target="_blank"
className="flex items-center justify-center gap-3 p-4 bg-cyan-500/10 hover:bg-cyan-500/20 border border-cyan-500/50 text-cyan-400 rounded-lg transition-all font-bold tracking-wider uppercase"
>
<Settings className="w-5 h-5"/>
<span>管理后台</span>
</a>
) : (
<a
href="/login"
target="_blank"
className="flex items-center justify-center gap-3 p-4 bg-cyan-500/10 hover:bg-cyan-500/20 border border-cyan-500/50 text-cyan-400 rounded-lg transition-all font-bold tracking-wider uppercase"
>
<LogIn className="w-5 h-5"/>
<span>登录</span>
</a>
)}
</div>
</div>
</div>
</header>
)}
</>
);
};
+181
View File
@@ -0,0 +1,181 @@
import type {FC} from 'react';
import {
AlertTriangle,
ArrowDown,
ArrowUp,
Calendar,
Cpu,
HardDrive,
MemoryStick
} from 'lucide-react';
import type {Agent, LatestMetrics} from '@/types';
import CompactResourceBar from '@/components/CompactResourceBar';
import {formatBytes, formatSpeed} from '@/utils/util';
interface AgentWithMetrics extends Agent {
metrics?: LatestMetrics;
}
interface ServerCardProps {
server: AgentWithMetrics;
onClick: (serverId: string) => void;
}
const calculateNetworkSpeed = (metrics?: LatestMetrics) => {
if (!metrics?.network) {
return {upload: 0, download: 0};
}
return {
upload: metrics.network.totalBytesSentRate,
download: metrics.network.totalBytesRecvRate
};
};
const calculateDiskUsage = (metrics?: LatestMetrics) => {
if (!metrics?.disk) {
return 0;
}
return metrics.disk.usagePercent;
};
const ServerCard: FC<ServerCardProps> = ({server, onClick}) => {
const isOnline = server.status === 1;
const cpuUsage = server.metrics?.cpu?.usagePercent ?? 0;
const memoryUsage = server.metrics?.memory?.usagePercent ?? 0;
const memoryTotal = server.metrics?.memory?.total ?? 0;
const memoryUsed = server.metrics?.memory?.used ?? 0;
const diskUsage = calculateDiskUsage(server.metrics);
const diskTotal = server.metrics?.disk?.total ?? 0;
const diskUsed = server.metrics?.disk?.used ?? 0;
const {upload, download} = calculateNetworkSpeed(server.metrics);
return (
<div
tabIndex={0}
onClick={() => onClick(server.id)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onClick(server.id);
}
}}
className="group bg-[#0f1016]/80 backdrop-blur-md border border-cyan-500/20 shadow-[0_0_15px_rgba(6,182,212,0.05)] hover:border-cyan-500/50 hover:shadow-[0_0_25px_rgba(6,182,212,0.15)] hover:bg-[#0f1016]/90 transition-all duration-300 cursor-pointer overflow-hidden relative"
>
{/* 装饰性边框 */}
<div
className="absolute top-0 left-0 w-2 h-2 border-t-2 border-l-2 border-cyan-500/30 group-hover:border-cyan-400 transition-colors"></div>
<div
className="absolute top-0 right-0 w-2 h-2 border-t-2 border-r-2 border-cyan-500/30 group-hover:border-cyan-400 transition-colors"></div>
<div
className="absolute bottom-0 left-0 w-2 h-2 border-b-2 border-l-2 border-cyan-500/30 group-hover:border-cyan-400 transition-colors"></div>
<div
className="absolute bottom-0 right-0 w-2 h-2 border-b-2 border-r-2 border-cyan-500/30 group-hover:border-cyan-400 transition-colors"></div>
<div
className="absolute inset-0 bg-gradient-to-b from-transparent via-cyan-500/5 to-transparent -translate-y-full group-hover:translate-y-full transition-transform duration-1000 ease-in-out pointer-events-none"/>
<div className="relative z-10 p-5 space-y-2">
{/* 顶部:名称和状态 */}
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="font-bold text-cyan-100 font-mono text-base truncate">
{server.name || server.hostname}
</div>
<div className="flex items-center gap-2 text-[10px] text-cyan-600 mt-1 font-mono uppercase">
<span>{server.os}</span>
<span className="w-px h-2 bg-cyan-800"></span>
<span>{server.arch}</span>
</div>
</div>
{server.tags && server.tags.length > 0 && (
<div className="flex gap-1 flex-wrap justify-end">
{server.tags.slice(0, 2).map(tag => (
<span
key={tag}
className="px-1.5 py-0.5 bg-cyan-900/40 text-cyan-400 border border-cyan-700/50 text-[10px] font-mono rounded-sm whitespace-nowrap"
>
#{tag}
</span>
))}
{server.tags.length > 2 && (
<span
className="px-1.5 py-0.5 bg-cyan-900/40 text-cyan-400 border border-cyan-700/50 text-[10px] font-mono rounded-sm">
+{server.tags.length - 2}
</span>
)}
</div>
)}
</div>
{/* 资源使用情况 */}
{isOnline ? (
<div className="space-y-1">
<CompactResourceBar
value={cpuUsage}
label="CPU"
icon={Cpu}
subtext={null}
color="bg-blue-500"
/>
<CompactResourceBar
value={memoryUsage}
label="RAM"
icon={MemoryStick}
subtext={`${formatBytes(memoryUsed)}/${formatBytes(memoryTotal)}`}
color="bg-purple-500"
/>
<CompactResourceBar
value={diskUsage}
label="DSK"
icon={HardDrive}
subtext={`${formatBytes(diskUsed)}/${formatBytes(diskTotal)}`}
color="bg-emerald-500"
/>
</div>
) : (
<div className="text-xs text-rose-500 font-mono flex items-center gap-2 py-2">
<AlertTriangle className="w-4 h-4"/>
<span>离线</span>
</div>
)}
{/* 网络和流量 */}
<div className="flex items-center justify-between pt-2 border-t border-cyan-900/30">
<div className="flex gap-3 text-[10px] font-mono">
<span className="flex items-center gap-1 text-emerald-400/80">
<ArrowDown className="w-3 h-3"/>
{formatSpeed(download)}
</span>
<span className="flex items-center gap-1 text-blue-400/80">
<ArrowUp className="w-3 h-3"/>
{formatSpeed(upload)}
</span>
</div>
{server.expireTime > 0 && (
<div className="text-[10px] font-mono text-cyan-600 flex items-center gap-1">
<Calendar className="w-3 h-3"/>
{new Date(server.expireTime).toLocaleDateString('zh-CN')}
</div>
)}
</div>
{/* 流量限制 */}
{server.trafficLimit > 0 && (
<div className="pt-2 border-t border-cyan-900/30">
<div className="flex justify-between text-[9px] text-cyan-700 mb-1">
<span>流量使用</span>
<span>{Math.round((server.trafficUsed || 0) / server.trafficLimit * 100)}%</span>
</div>
<div className="h-1.5 bg-cyan-900/50 rounded-full overflow-hidden">
<div
className="h-full bg-cyan-400 transition-all"
style={{width: `${((server.trafficUsed || 0) / server.trafficLimit) * 100}%`}}
/>
</div>
</div>
)}
</div>
</div>
);
};
export default ServerCard;
+35
View File
@@ -0,0 +1,35 @@
// 统计卡片组件
const StatCard = ({title, value, icon: Icon, color}: {
title: string;
value: any;
icon: any;
color: string;
}) => {
const colorMap: any = {
gray: 'text-slate-400 border-slate-500/30 bg-slate-500/5',
emerald: 'text-emerald-400 border-emerald-500/30 bg-emerald-500/5',
rose: 'text-rose-400 border-rose-500/30 bg-rose-500/5',
blue: 'text-blue-400 border-blue-500/30 bg-blue-500/5'
};
const style = colorMap[color] || colorMap.gray;
return (
<div
className={`relative overflow-hidden rounded-xl border p-5 ${style} ${color === 'rose' && value > 0 ? 'animate-pulse bg-rose-500/10' : ''}`}>
<div className="absolute -right-4 -bottom-4 opacity-10 rotate-[-15deg]">
<Icon className="w-24 h-24"/>
</div>
<div className="relative z-10 flex justify-between items-start">
<div>
<div className="text-[10px] font-mono uppercase tracking-widest opacity-70 mb-1">{title}</div>
<div className="text-3xl font-black tracking-tight">{value}</div>
</div>
<div className="p-2 rounded-lg bg-black/20 backdrop-blur-sm border border-white/5">
<Icon className="w-5 h-5"/>
</div>
</div>
</div>
);
};
export default StatCard;
+55
View File
@@ -0,0 +1,55 @@
import type {ReactNode} from 'react';
import {cn} from '@/lib/utils';
import CyberCard from "@/components/CyberCard.tsx";
interface CardProps {
title?: string;
description?: string;
action?: ReactNode;
children: ReactNode;
variant?: 'light' | 'dark';
}
export const Card = ({
title,
description,
action,
children,
variant = 'light',
}: CardProps) => {
const isDark = variant === 'dark';
return (
<CyberCard className={'p-6'}>
{(title || description || action) && (
<div className={cn(
"flex flex-col gap-3 border-b pb-4 sm:flex-row sm:items-start sm:justify-between",
isDark ? "border-cyan-900/30" : "border-slate-200 dark:border-slate-700"
)}>
<div>
{title && (
<h2 className={cn(
"text-sm font-bold",
isDark
? "font-mono uppercase tracking-widest text-cyan-400"
: "text-lg font-semibold text-slate-900 dark:text-white"
)}>
{title}
</h2>
)}
{description && (
<p className={cn(
"mt-1 text-xs",
isDark ? "text-cyan-600" : "text-sm text-slate-500 dark:text-slate-400"
)}>
{description}
</p>
)}
</div>
{action && <div className="shrink-0">{action}</div>}
</div>
)}
<div className="pt-4">{children}</div>
</CyberCard>
);
};
@@ -0,0 +1,48 @@
import {TrendingUp} from 'lucide-react';
import {cn} from '@/lib/utils';
interface ChartPlaceholderProps {
variant?: 'light' | 'dark';
icon?: typeof TrendingUp;
title?: string;
subtitle?: string;
heightClass?: string;
}
export const ChartPlaceholder = ({
variant = 'light',
icon: Icon = TrendingUp,
title = '暂无数据',
subtitle = '等待采集新数据后展示图表',
heightClass = 'h-52',
}: ChartPlaceholderProps) => {
const isDark = variant === 'dark';
return (
<div className={cn(
"flex items-center justify-center rounded-lg border border-dashed text-sm",
isDark
? "border-cyan-500/30 bg-[#0a0b10]/50 text-cyan-600"
: "border-slate-200 dark:border-slate-700 text-slate-500 dark:text-slate-400",
heightClass
)}>
<div className="text-center">
<Icon className={cn(
"mx-auto mb-3 h-10 w-10",
isDark ? "text-cyan-900/50" : "text-slate-300 dark:text-slate-600"
)}/>
<p className={cn(
isDark ? "uppercase tracking-wider font-mono" : "font-medium"
)}>{title}</p>
{subtitle && (
<p className={cn(
"mt-1 text-xs",
isDark ? "text-cyan-800" : "text-slate-400 dark:text-slate-500"
)}>
{subtitle}
</p>
)}
</div>
</div>
);
};
@@ -0,0 +1,85 @@
import dayjs from 'dayjs';
import {cn} from '@/lib/utils';
type CustomTooltipProps = {
active?: boolean;
payload?: Array<{
name?: string;
value?: number;
color?: string;
dataKey?: string;
payload?: {
timestamp?: number | string;
[key: string]: unknown;
};
}>;
label?: string | number;
variant?: 'light' | 'dark';
unit?: string;
};
export const CustomTooltip = ({active, payload, label, variant = 'light', unit = '%'}: CustomTooltipProps) => {
if (!active || !payload || payload.length === 0) {
return null;
}
const isDark = variant === 'dark';
// 从 payload 中获取完整的时间戳信息(如果有的话)
const fullTimestamp = payload[0]?.payload?.timestamp;
const displayLabel = fullTimestamp
? dayjs(fullTimestamp).format(isDark ? 'YYYY-MM-DD HH:mm:ss' : 'MM-DD HH:mm')
: label;
return (
<div className={cn(
"rounded-md border px-3 py-2 text-xs shadow-xl",
isDark
? "border-cyan-700/50 bg-[#0a0b10]/95 backdrop-blur-sm"
: "border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 shadow-lg"
)}>
<p className={cn(
"font-semibold",
isDark
? "font-mono text-cyan-300 text-[10px] tracking-wider uppercase"
: "text-slate-700 dark:text-white mb-2"
)}>
{displayLabel}
</p>
<div className={cn(
isDark ? "mt-1 space-y-1" : "space-y-1"
)}>
{payload.map((entry, index) => {
if (!entry) {
return null;
}
const dotColor = entry.color ?? '#6366f1';
const title = entry.name ?? entry.dataKey ?? `系列 ${index + 1}`;
const value = typeof entry.value === 'number'
? Number.isFinite(entry.value)
? entry.value.toFixed(2)
: '-'
: entry.value;
return (
<p key={`${entry.dataKey ?? index}`} className={cn(
"flex items-center gap-2",
isDark ? "text-cyan-400" : "text-xs"
)}>
<span
className="inline-block h-2 w-2 rounded-full"
style={{backgroundColor: dotColor}}
/>
<span className={isDark ? "" : "text-slate-600 dark:text-slate-400"}>
{title}: <span className={cn(
isDark ? "" : "font-semibold text-slate-900 dark:text-white"
)}>{value}{unit}</span>
</span>
</p>
);
})}
</div>
</div>
);
};
+46
View File
@@ -0,0 +1,46 @@
import {AlertCircle, Server} from 'lucide-react';
import {cn} from '@/lib/utils';
interface EmptyStateProps {
variant?: 'light' | 'dark';
message?: string;
showBackButton?: boolean;
}
export const EmptyState = ({variant = 'light', message, showBackButton = false}: EmptyStateProps) => {
const isDark = variant === 'dark';
const Icon = isDark ? Server : AlertCircle;
const defaultMessage = isDark ? '服务器不存在或已离线' : '监控数据不存在';
return (
<div className={cn(
"flex min-h-screen items-center justify-center",
isDark ? "bg-[#05050a]" : "bg-slate-50 dark:bg-slate-900"
)}>
<div className="flex flex-col items-center gap-3 text-center">
<div className={cn(
"flex items-center justify-center",
isDark
? "h-16 w-16 rounded-lg bg-cyan-500/10 text-cyan-400"
: "h-16 w-16 rounded-full bg-slate-100 dark:bg-slate-800 text-slate-400 dark:text-slate-500"
)}>
<Icon className="h-8 w-8"/>
</div>
<p className={cn(
"text-sm font-mono",
isDark ? "text-cyan-600" : "text-slate-600 dark:text-slate-400"
)}>
{message || defaultMessage}
</p>
{showBackButton && !isDark && (
<button
onClick={() => window.history.back()}
className="mt-4 text-sm text-blue-600 dark:text-blue-400 hover:underline"
>
返回监控列表
</button>
)}
</div>
</div>
);
};
@@ -0,0 +1,19 @@
// 统计卡片组件
const LittleStatCard = ({
label,
value,
}: {
label: string;
value: string | number;
sublabel?: string;
}) => (
<div
key={label}
className="rounded-xl bg-black/40 border border-cyan-900/50 p-4 text-left hover:border-cyan-700/50 transition"
>
<p className="text-[10px] uppercase tracking-[0.3em] text-cyan-600 font-mono font-bold">{label}</p>
<p className="mt-2 text-base font-semibold text-cyan-100">{value}</p>
</div>
);
export default LittleStatCard;
@@ -0,0 +1,31 @@
import {Loader2} from 'lucide-react';
import {cn} from '@/lib/utils';
interface LoadingSpinnerProps {
variant?: 'light' | 'dark';
message?: string;
}
export const LoadingSpinner = ({variant = 'light', message}: LoadingSpinnerProps) => {
const isDark = variant === 'dark';
return (
<div className={cn(
"flex min-h-screen items-center justify-center",
isDark ? "bg-[#05050a]" : "bg-slate-50 dark:bg-slate-900"
)}>
<div className="flex flex-col items-center gap-3">
<Loader2 className={cn(
"h-8 w-8 animate-spin",
isDark ? "text-cyan-400" : "text-blue-500"
)}/>
<p className={cn(
"text-sm font-mono",
isDark ? "text-cyan-500" : "text-slate-600 dark:text-slate-400"
)}>
{message || (isDark ? '数据加载中...' : '数据加载中,请稍候...')}
</p>
</div>
</div>
);
};
@@ -0,0 +1,46 @@
import type {TimeRangeOption} from '@/api/property.ts';
import {cn} from '@/lib/utils';
interface TimeRangeSelectorProps {
value: string;
onChange: (value: string) => void;
options: readonly TimeRangeOption[];
variant?: 'light' | 'dark';
}
export const TimeRangeSelector = ({
value,
onChange,
options,
variant = 'light',
}: TimeRangeSelectorProps) => {
const isDark = variant === 'dark';
return (
<div className="flex flex-wrap items-center gap-2">
{options.map((option) => {
const isActive = option.value === value;
return (
<button
key={option.value}
type="button"
onClick={() => onChange(option.value)}
className={cn(
"rounded-lg border px-3 py-1.5 text-xs font-medium transition-all whitespace-nowrap",
isDark && "font-bold font-mono tracking-wider uppercase",
isActive
? isDark
? 'border-cyan-500/50 bg-cyan-500/20 text-cyan-300 shadow-[0_0_10px_rgba(34,211,238,0.3)]'
: 'border-blue-500 dark:border-blue-500 bg-blue-500 text-white shadow-sm'
: isDark
? 'border-cyan-900/30 bg-black/30 text-cyan-700 hover:text-cyan-400 hover:border-cyan-700'
: 'border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-600 dark:text-slate-300 hover:border-blue-300 dark:hover:border-blue-600 hover:text-blue-600 dark:hover:text-blue-400'
)}
>
{option.label}
</button>
);
})}
</div>
);
};
+6
View File
@@ -0,0 +1,6 @@
export {LoadingSpinner} from './LoadingSpinner';
export {EmptyState} from './EmptyState';
export {ChartPlaceholder} from './ChartPlaceholder';
export {CustomTooltip} from './CustomTooltip';
export {TimeRangeSelector} from './TimeRangeSelector';
export {Card} from './Card';
@@ -0,0 +1,128 @@
import {AlertCircle, Clock, MapPin} from 'lucide-react';
import {StatusBadge} from './StatusBadge';
import {CertBadge} from './CertBadge';
import {AGENT_COLORS} from '@/constants/colors';
import {formatDateTime, formatTime} from '@/utils/util';
import type {AgentMonitorStat} from '@/types';
import CyberCard from "@/components/CyberCard.tsx";
interface AgentStatsTableProps {
monitorStats: AgentMonitorStat[];
monitorType: string;
}
/**
* 探针监控统计表格组件
* 显示各探针的当前状态和统计数据
*/
export const AgentStatsTable = ({monitorStats, monitorType}: AgentStatsTableProps) => {
if (monitorStats.length === 0) {
return (
<div className="text-center py-12 text-cyan-600">
<p className="text-sm font-mono">暂无探针数据</p>
</div>
);
}
return (
<CyberCard className="p-6">
<div className="mb-6">
<h3 className="text-lg font-bold tracking-wide text-cyan-100 uppercase">探针监控详情</h3>
<p className="text-xs text-cyan-600 mt-1 font-mono">各探针的当前状态和统计数据</p>
</div>
<div className="overflow-x-auto -mx-6 px-6">
<table className="min-w-full">
<thead>
<tr className="border-b border-cyan-900/50">
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-widest text-cyan-500/60 font-mono">
探针名称
</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-widest text-cyan-500/60 font-mono">
状态
</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-widest text-cyan-500/60 font-mono">
响应时间
</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-widest text-cyan-500/60 font-mono hidden lg:table-cell">
最后检测
</th>
{monitorType === 'https' && (
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-widest text-cyan-500/60 font-mono hidden xl:table-cell">
证书信息
</th>
)}
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-widest text-cyan-500/60 font-mono hidden xl:table-cell">
错误信息
</th>
</tr>
</thead>
<tbody className="divide-y divide-cyan-900/30">
{monitorStats.map((stat, index) => {
const color = AGENT_COLORS[index % AGENT_COLORS.length];
return (
<tr key={stat.agentId}
className="hover:bg-cyan-950/20 transition-colors">
<td className="px-4 py-4">
<div className="flex items-center gap-3">
<span
className="inline-block h-2 w-2 rounded-full flex-shrink-0"
style={{backgroundColor: color}}
/>
<div className="flex items-center gap-2">
<MapPin className="h-3.5 w-3.5 text-cyan-600"/>
<span className="font-mono text-sm text-cyan-200">
{stat.agentName || stat.agentId.substring(0, 8)}
</span>
</div>
</div>
</td>
<td className="px-4 py-4">
<StatusBadge status={stat.status}/>
</td>
<td className="px-4 py-4">
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-cyan-600"/>
<span className="text-sm font-semibold text-cyan-100 font-mono">
{formatTime(stat.responseTime)}
</span>
</div>
</td>
<td className="px-4 py-4 text-sm text-cyan-400 font-mono hidden lg:table-cell">
{formatDateTime(stat.checkedAt)}
</td>
{monitorType === 'https' && (
<td className="px-4 py-4 hidden xl:table-cell">
{stat.certExpiryTime ? (
<CertBadge
expiryTime={stat.certExpiryTime}
daysLeft={stat.certDaysLeft}
/>
) : (
<span className="text-xs text-cyan-600">-</span>
)}
</td>
)}
<td className="px-4 py-4 hidden xl:table-cell">
{stat.status === 'down' && stat.message ? (
<div className="flex items-start gap-2 max-w-xs">
<AlertCircle
className="h-4 w-4 text-rose-400 flex-shrink-0 mt-0.5"/>
<span
className="text-xs text-rose-300 break-words line-clamp-2 font-mono">
{stat.message}
</span>
</div>
) : (
<span className="text-xs text-cyan-600">-</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</CyberCard>
);
};
+27
View File
@@ -0,0 +1,27 @@
import { ShieldCheck } from 'lucide-react';
import { cn } from '@/lib/utils';
interface CertBadgeProps {
expiryTime: number;
daysLeft: number;
}
export const CertBadge = ({ expiryTime, daysLeft }: CertBadgeProps) => {
if (!expiryTime || daysLeft === undefined) return null;
const isExpired = daysLeft < 0;
let colorClass = "text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-500/10 border-emerald-200 dark:border-emerald-500/20";
if (isExpired) {
colorClass = "text-rose-600 dark:text-rose-400 bg-rose-50 dark:bg-rose-500/10 border-rose-200 dark:border-rose-500/20";
} else if (daysLeft < 30) {
colorClass = "text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-500/10 border-amber-200 dark:border-amber-500/20";
}
return (
<div className={cn("flex items-center gap-1.5 px-2 py-1 rounded text-xs border", colorClass)}>
<ShieldCheck className="w-3 h-3" />
<span>{isExpired ? "已过期" : `${daysLeft} 天后过期`}</span>
</div>
);
};
+43
View File
@@ -0,0 +1,43 @@
import { Area, AreaChart, ResponsiveContainer } from 'recharts';
interface MiniChartProps {
data: Array<{ time: string; value: number }>;
status: string;
id: string;
}
export const MiniChart = ({ data, status, id }: MiniChartProps) => {
if (!data || data.length === 0) {
return (
<div className="h-16 w-full flex items-center justify-center text-xs text-slate-400 dark:text-slate-500">
暂无数据
</div>
);
}
const color = status === 'up' ? '#10b981' : status === 'down' ? '#f59e0b' : '#94a3b8';
return (
<div className="h-16 w-full -mb-2">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={data}>
<defs>
<linearGradient id={`colorLatency-${id}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={color} stopOpacity={0.2} />
<stop offset="95%" stopColor={color} stopOpacity={0} />
</linearGradient>
</defs>
<Area
type="monotone"
dataKey="value"
stroke={color}
fillOpacity={1}
fill={`url(#colorLatency-${id})`}
strokeWidth={1.5}
isAnimationActive={false}
/>
</AreaChart>
</ResponsiveContainer>
</div>
);
};
@@ -0,0 +1,84 @@
import {ArrowLeft} from 'lucide-react';
import {TypeIcon} from './TypeIcon';
import {StatusBadge} from './StatusBadge';
import {CertBadge} from './CertBadge';
import LittleStatCard from '@/components/common/LittleStatCard';
import type {PublicMonitor} from '@/types';
import CyberCard from "@/components/CyberCard.tsx";
interface MonitorHeroProps {
monitor: PublicMonitor;
onBack: () => void;
}
/**
* 监控详情头部组件
* 显示监控基本信息、状态和关键指标
*/
export const MonitorHero = ({monitor, onBack}: MonitorHeroProps) => {
return (
<CyberCard className={'p-6 space-y-6'}>
{/* 返回按钮 */}
<button
type="button"
onClick={onBack}
className="group inline-flex items-center gap-2 text-xs font-medium uppercase tracking-wider text-cyan-600 hover:text-cyan-400 transition font-mono"
>
<ArrowLeft className="h-4 w-4 transition-transform group-hover:-translate-x-1"/>
返回监控列表
</button>
{/* 监控信息 */}
<div className="flex flex-col gap-6 lg:flex-row lg:items-start lg:justify-between">
<div className="flex items-start gap-4 flex-1 min-w-0">
<div className="p-3 bg-cyan-950/30 border border-cyan-500/20 rounded-lg flex-shrink-0">
<TypeIcon type={monitor.type}/>
</div>
<div className="flex-1 min-w-0">
<div className="text-[10px] font-mono text-cyan-500/60 mb-1 tracking-wider">
MONITOR_ID: {monitor.id.toString().substring(0, 8)}
</div>
<div className="flex flex-wrap items-center gap-3 mb-2">
<h1 className="text-2xl sm:text-3xl font-bold truncate text-cyan-100 tracking-wide">{monitor.name}</h1>
<StatusBadge status={monitor.status}/>
</div>
<p className="text-sm text-cyan-500/80 font-mono truncate">
{monitor.showTargetPublic ? monitor.target : '******'}
</p>
</div>
</div>
{/* 统计卡片 */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 w-full lg:w-auto lg:min-w-[480px]">
<LittleStatCard
label="监控类型"
value={monitor.type.toUpperCase()}
/>
<LittleStatCard
label="探针数量"
value={monitor.agentCount}
/>
<LittleStatCard
label="平均响应"
value={`${monitor.responseTime}ms`}
/>
<LittleStatCard
label="最慢响应"
value={`${monitor.responseTimeMax}ms`}
/>
</div>
</div>
{/* 证书信息(如果是 HTTPS)*/}
{monitor.type === 'https' && monitor.certExpiryTime && (
<div className="flex items-center gap-2 pt-4 border-t border-cyan-900/50">
<span className="text-xs text-cyan-500/60 font-mono">SSL 证书:</span>
<CertBadge
expiryTime={monitor.certExpiryTime}
daysLeft={monitor.certDaysLeft}
/>
</div>
)}
</CyberCard>
);
};
@@ -0,0 +1,211 @@
import {useEffect, useMemo, useState} from 'react';
import {useQuery} from '@tanstack/react-query';
import {Area, AreaChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis} from 'recharts';
import {type GetMetricsResponse, getMonitorHistory} from '@/api/monitor';
import {AGENT_COLORS} from '@/constants/colors';
import {MONITOR_TIME_RANGE_OPTIONS} from '@/constants/time';
import type {AgentMonitorStat} from '@/types';
import CyberCard from "@/components/CyberCard.tsx";
import {ChartPlaceholder, CustomTooltip, TimeRangeSelector} from "@/components/common";
interface ResponseTimeChartProps {
monitorId: string;
monitorStats: AgentMonitorStat[];
}
/**
* 响应时间趋势图表组件
* 显示监控各探针的响应时间变化
*/
export const ResponseTimeChart = ({monitorId, monitorStats}: ResponseTimeChartProps) => {
const [selectedAgent, setSelectedAgent] = useState<string>('all');
const [timeRange, setTimeRange] = useState<string>('12h');
// 获取历史数据
const {data: historyData} = useQuery<GetMetricsResponse>({
queryKey: ['monitorHistory', monitorId, timeRange],
queryFn: async () => {
if (!monitorId) throw new Error('Monitor ID is required');
const response = await getMonitorHistory(monitorId, timeRange);
return response.data;
},
refetchInterval: 30000,
enabled: !!monitorId,
});
// 获取所有可用的探针列表
const availableAgents = useMemo(() => {
if (monitorStats.length === 0) return [];
return monitorStats.map(stat => ({
id: stat.agentId,
name: stat.agentName || stat.agentId.substring(0, 8),
}));
}, [monitorStats]);
// 当可用探针列表变化时,检查当前选择的探针是否还存在
useEffect(() => {
if (selectedAgent === 'all') return;
if (!availableAgents.find(agent => agent.id === selectedAgent)) {
setSelectedAgent('all');
}
}, [availableAgents, selectedAgent]);
// 生成图表数据
const chartData = useMemo(() => {
if (!historyData?.series) return [];
// 过滤出响应时间指标的 series
const responseTimeSeries = historyData.series?.filter(s => s.name === 'response_time');
// 根据选择的探针过滤(使用 agent_name,如果没有则fallback到 agent_id)
const filteredSeries = selectedAgent === 'all'
? responseTimeSeries
: responseTimeSeries.filter(s => {
// 优先使用 agent_name,如果没有则使用 agent_id
const agentIdentifier = s.labels?.agent_name || s.labels?.agent_id;
return agentIdentifier === selectedAgent;
});
if (filteredSeries.length === 0) return [];
// 按时间戳分组数据
const grouped: Record<number, any> = {};
filteredSeries.forEach(series => {
// 优先使用 agent_name,如果没有则使用 agent_id
const agentIdentifier = series.labels?.agent_name || series.labels?.agent_id || 'unknown';
const agentKey = `agent_${agentIdentifier}`;
series.data.forEach(point => {
if (!grouped[point.timestamp]) {
grouped[point.timestamp] = {
time: new Date(point.timestamp).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
}),
timestamp: point.timestamp,
};
}
grouped[point.timestamp][agentKey] = point.value;
});
});
// 按时间戳排序
return Object.values(grouped).sort((a, b) => a.timestamp - b.timestamp);
}, [historyData, selectedAgent]);
return (
<CyberCard className={'p-6'}>
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6">
<div>
<h3 className="text-lg font-bold tracking-wide text-cyan-100 uppercase">响应时间趋势</h3>
<p className="text-xs text-cyan-600 mt-1 font-mono">监控各探针的响应时间变化</p>
</div>
<div className="flex flex-col sm:flex-row flex-wrap items-start sm:items-center gap-3">
<TimeRangeSelector
value={timeRange}
onChange={setTimeRange}
options={MONITOR_TIME_RANGE_OPTIONS}
variant={'dark'}
/>
{availableAgents.length > 0 && (
<select
value={selectedAgent}
onChange={(e) => setSelectedAgent(e.target.value)}
className="rounded-lg border border-cyan-900/50 bg-black/40 px-3 py-2 text-xs font-medium text-cyan-300 hover:border-cyan-500/50 focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/20 transition-colors font-mono"
>
<option value="all">所有探针</option>
{availableAgents.map((agent) => (
<option key={agent.id} value={agent.name}>
{agent.name}
</option>
))}
</select>
)}
</div>
</div>
{chartData.length > 0 ? (
<ResponsiveContainer width="100%" height={360}>
<AreaChart data={chartData}>
<defs>
{monitorStats
.filter(stat => {
const agentIdentifier = stat.agentName || stat.agentId;
return selectedAgent === 'all' || agentIdentifier === selectedAgent;
})
.map((stat) => {
const originalIndex = monitorStats.findIndex(s => s.agentId === stat.agentId);
const agentIdentifier = stat.agentName || stat.agentId;
const agentKey = `agent_${agentIdentifier}`;
const color = AGENT_COLORS[originalIndex % AGENT_COLORS.length];
return (
<linearGradient key={agentKey} id={`gradient_${agentKey}`} x1="0" y1="0"
x2="0" y2="1">
<stop offset="5%" stopColor={color} stopOpacity={0.3}/>
<stop offset="95%" stopColor={color} stopOpacity={0}/>
</linearGradient>
);
})}
</defs>
<CartesianGrid
strokeDasharray="3 3"
className="stroke-cyan-900/30"
vertical={false}
/>
<XAxis
dataKey="time"
className="text-xs text-cyan-600 font-mono"
stroke="#164e63"
tickLine={false}
axisLine={false}
/>
<YAxis
className="text-xs text-cyan-600 font-mono"
stroke="#164e63"
tickLine={false}
axisLine={false}
tickFormatter={(value) => `${value}ms`}
/>
<Tooltip content={<CustomTooltip variant={'dark'}/>}/>
<Legend
wrapperStyle={{paddingTop: '20px'}}
iconType="circle"
/>
{monitorStats
.filter(stat => {
const agentIdentifier = stat.agentName || stat.agentId;
return selectedAgent === 'all' || agentIdentifier === selectedAgent;
})
.map((stat) => {
const originalIndex = monitorStats.findIndex(s => s.agentId === stat.agentId);
const agentIdentifier = stat.agentName || stat.agentId;
const agentKey = `agent_${agentIdentifier}`;
const color = AGENT_COLORS[originalIndex % AGENT_COLORS.length];
const agentLabel = stat.agentName || stat.agentId.substring(0, 8);
return (
<Area
key={agentKey}
type="monotone"
dataKey={agentKey}
name={agentLabel}
stroke={color}
strokeWidth={2}
fill={`url(#gradient_${agentKey})`}
activeDot={{r: 5, strokeWidth: 0}}
dot={false}
/>
);
})}
</AreaChart>
</ResponsiveContainer>
) : (
<ChartPlaceholder
variant={'dark'}
subtitle="正在收集数据,请稍后查看历史趋势"
heightClass="h-80"
/>
)}
</CyberCard>
);
};
@@ -0,0 +1,35 @@
import {cn} from '@/lib/utils';
interface StatusBadgeProps {
status: string;
}
export const StatusBadge = ({status}: StatusBadgeProps) => {
const styles = {
up: "bg-emerald-500/10 dark:bg-emerald-500/10 text-emerald-700 dark:text-emerald-400 border-emerald-500/20",
down: "bg-rose-500/10 dark:bg-rose-500/10 text-rose-700 dark:text-rose-400 border-rose-500/20",
unknown: "bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-300 border-slate-200 dark:border-slate-700",
};
const labels = {
up: "正常",
down: "异常",
unknown: "未知",
};
const style = styles[status as keyof typeof styles] || styles.unknown;
const label = labels[status as keyof typeof labels] || labels.unknown;
return (
<span
className={cn("px-2.5 py-0.5 rounded-full text-xs font-medium border flex items-center gap-1.5 w-fit", style)}>
<span className={cn(
"w-1.5 h-1.5 rounded-full animate-pulse",
status === 'up' ? 'bg-emerald-500 dark:bg-emerald-400' :
status === 'down' ? 'bg-rose-500 dark:bg-rose-400' :
'bg-slate-400 dark:bg-slate-400'
)}/>
{label}
</span>
);
};
+21
View File
@@ -0,0 +1,21 @@
import { Globe, Server, ShieldCheck, Wifi } from 'lucide-react';
interface TypeIconProps {
type: string;
}
export const TypeIcon = ({ type }: TypeIconProps) => {
switch (type.toLowerCase()) {
case 'https':
return <ShieldCheck className="w-4 h-4 text-purple-500 dark:text-purple-400" />;
case 'http':
return <Globe className="w-4 h-4 text-blue-500 dark:text-blue-400" />;
case 'tcp':
return <Server className="w-4 h-4 text-orange-500 dark:text-orange-400" />;
case 'icmp':
case 'ping':
return <Wifi className="w-4 h-4 text-cyan-500 dark:text-cyan-400" />;
default:
return <Server className="w-4 h-4 text-slate-500 dark:text-slate-400" />;
}
};
+7
View File
@@ -0,0 +1,7 @@
export {CertBadge} from './CertBadge';
export {MiniChart} from './MiniChart';
export {StatusBadge} from './StatusBadge';
export {TypeIcon} from './TypeIcon';
export {MonitorHero} from './MonitorHero';
export {ResponseTimeChart} from './ResponseTimeChart';
export {AgentStatsTable} from './AgentStatsTable';
@@ -0,0 +1,68 @@
import {Zap} from 'lucide-react';
import {Card} from '@/components/common';
import {formatBytes} from '@/utils/util';
import type {LatestMetrics} from '@/types';
interface GpuMonitorSectionProps {
latestMetrics: LatestMetrics | null;
}
/**
* GPU 监控区块组件
* 显示 GPU 使用情况、温度、显存和功耗信息
*/
export const GpuMonitorSection = ({latestMetrics}: GpuMonitorSectionProps) => {
// 如果没有 GPU 数据,不渲染组件
if (!latestMetrics?.gpu || latestMetrics.gpu.length === 0) {
return null;
}
return (
<Card title="GPU 监控" description="显卡使用情况和温度监控" variant="dark">
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
{latestMetrics.gpu.map((gpu) => (
<div
key={gpu.index}
className="rounded-xl border border-cyan-900/50 bg-black/30 p-4 backdrop-blur-sm hover:border-cyan-700/50 transition"
>
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<span
className="flex h-9 w-9 items-center justify-center rounded-lg bg-cyan-500/10 text-cyan-400">
<Zap className="h-4 w-4"/>
</span>
<div>
<p className="text-sm font-bold font-mono text-cyan-100">GPU {gpu.index}</p>
<p className="text-[10px] text-cyan-600">{gpu.name}</p>
</div>
</div>
<span className="text-2xl font-bold text-purple-400">
{gpu.utilization.toFixed(1)}%
</span>
</div>
<div className="space-y-2 text-xs">
<div className="flex items-center justify-between">
<span className="text-cyan-600 font-mono text-[10px] uppercase tracking-wider">温度</span>
<span className="font-medium text-cyan-200">{gpu.temperature.toFixed(1)}°C</span>
</div>
<div className="flex items-center justify-between">
<span className="text-cyan-600 font-mono text-[10px] uppercase tracking-wider">显存</span>
<span className="font-medium text-cyan-200">
{formatBytes(gpu.memoryUsed)} / {formatBytes(gpu.memoryTotal)}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-cyan-600 font-mono text-[10px] uppercase tracking-wider">功耗</span>
<span className="font-medium text-cyan-200">{gpu.powerDraw.toFixed(1)}W</span>
</div>
<div className="flex items-center justify-between">
<span className="text-cyan-600 font-mono text-[10px] uppercase tracking-wider">风扇转速</span>
<span className="font-medium text-cyan-200">{gpu.fanSpeed.toFixed(0)}%</span>
</div>
</div>
</div>
))}
</div>
</Card>
);
};
+16
View File
@@ -0,0 +1,16 @@
import type {ReactNode} from 'react';
interface InfoGridProps {
items: Array<{ label: string; value: ReactNode }>;
}
export const InfoGrid = ({items}: InfoGridProps) => (
<dl className="grid grid-cols-1 gap-4 text-sm sm:grid-cols-2">
{items.map((item) => (
<div key={item.label}>
<dt className="text-[10px] font-mono uppercase tracking-widest text-cyan-600">{item.label}</dt>
<dd className="mt-1 font-medium text-cyan-100">{item.value}</dd>
</div>
))}
</dl>
);
@@ -0,0 +1,61 @@
import {Network} from 'lucide-react';
import {Card} from '@/components/common';
import type {LatestMetrics} from '@/types';
interface NetworkConnectionSectionProps {
latestMetrics: LatestMetrics | null;
}
/**
* 网络连接统计区块组件
* 显示 TCP 连接各状态的实时统计数据
*/
export const NetworkConnectionSection = ({latestMetrics}: NetworkConnectionSectionProps) => {
// 如果没有网络连接数据,不渲染组件
if (!latestMetrics?.networkConnection) {
return null;
}
return (
<Card title="网络连接统计" description="TCP 连接各状态的实时统计数据" variant="dark">
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-6">
<div className="text-center">
<div className="text-[10px] text-cyan-600 font-mono uppercase tracking-wider">Total</div>
<div className="mt-1 text-lg font-semibold text-cyan-100">{latestMetrics.networkConnection.total}</div>
</div>
<div className="text-center">
<div className="text-[10px] text-cyan-600 font-mono uppercase tracking-wider">ESTABLISHED</div>
<div
className="mt-1 text-lg font-semibold text-emerald-400">{latestMetrics.networkConnection.established}</div>
</div>
<div className="text-center">
<div className="text-[10px] text-cyan-600 font-mono uppercase tracking-wider">TIME_WAIT</div>
<div
className="mt-1 text-lg font-semibold text-amber-400">{latestMetrics.networkConnection.timeWait}</div>
</div>
<div className="text-center">
<div className="text-[10px] text-cyan-600 font-mono uppercase tracking-wider">LISTEN</div>
<div
className="mt-1 text-lg font-semibold text-blue-400">{latestMetrics.networkConnection.listen}</div>
</div>
<div className="text-center">
<div className="text-[10px] text-cyan-600 font-mono uppercase tracking-wider">CLOSE_WAIT</div>
<div
className="mt-1 text-lg font-semibold text-rose-400">{latestMetrics.networkConnection.closeWait}</div>
</div>
<div className="text-center">
<div className="text-[10px] text-cyan-600 font-mono uppercase tracking-wider">OTHER</div>
<div className="mt-1 text-lg font-semibold text-cyan-500">
{latestMetrics.networkConnection.synSent +
latestMetrics.networkConnection.synRecv +
latestMetrics.networkConnection.finWait1 +
latestMetrics.networkConnection.finWait2 +
latestMetrics.networkConnection.close +
latestMetrics.networkConnection.lastAck +
latestMetrics.networkConnection.closing}
</div>
</div>
</div>
</Card>
);
};
+97
View File
@@ -0,0 +1,97 @@
import {ArrowLeft} from 'lucide-react';
import {cn} from '@/lib/utils';
import {formatBytes, formatDateTime, formatUptime} from '@/utils/util';
import type {Agent, LatestMetrics} from '@/types';
import LittleStatCard from '@/components/common/LittleStatCard';
import CyberCard from "@/components/CyberCard.tsx";
interface ServerHeroProps {
agent: Agent;
latestMetrics: LatestMetrics | null;
onBack: () => void;
}
/**
* 服务器头部信息组件
* 显示服务器基本信息、状态和关键指标
*/
export const ServerHero = ({agent, latestMetrics, onBack}: ServerHeroProps) => {
const displayName = agent?.name?.trim() ? agent.name : '未命名探针';
const isOnline = agent?.status === 1;
const statusDotStyles = isOnline ? 'bg-emerald-500' : 'bg-rose-500';
const statusText = isOnline ? '在线' : '离线';
const platformDisplay = latestMetrics?.host?.platform
? `${latestMetrics.host.platform} ${latestMetrics.host.platformVersion || ''}`.trim()
: agent?.os || '-';
const architectureDisplay = latestMetrics?.host?.kernelArch || agent?.arch || '-';
const uptimeDisplay = formatUptime(latestMetrics?.host?.uptime);
const lastSeenDisplay = agent ? formatDateTime(agent.lastSeenAt) : '-';
const networkSummary = latestMetrics?.network
? `${formatBytes(latestMetrics.network.totalBytesSentTotal)} ↑ / ${formatBytes(
latestMetrics.network.totalBytesRecvTotal,
)} ↓`
: '—';
const heroStats = [
{label: '运行系统', value: platformDisplay || '-'},
{label: '硬件架构', value: architectureDisplay || '-'},
{label: '最近心跳', value: lastSeenDisplay},
{label: '运行时长', value: uptimeDisplay},
];
return (
<CyberCard className={'p-6'}>
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-4">
<button
type="button"
onClick={onBack}
className="group inline-flex items-center gap-2 text-xs font-bold font-mono uppercase tracking-[0.3em] text-cyan-600 transition hover:text-cyan-400"
>
<ArrowLeft className="h-4 w-4 transition group-hover:-translate-x-0.5"/>
返回概览
</button>
<div className="flex items-start gap-4">
<div>
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-3xl font-bold text-cyan-100">{displayName}</h1>
<span
className={cn(
"inline-flex items-center gap-1 rounded-full px-3 py-0.5 text-xs font-bold font-mono uppercase tracking-wider",
isOnline
? 'bg-emerald-500/20 text-emerald-400 border border-emerald-500/50'
: 'bg-rose-500/20 text-rose-400 border border-rose-500/50'
)}
>
<span className={cn("h-1.5 w-1.5 rounded-full", statusDotStyles)}/>
{statusText}
</span>
</div>
<p className="mt-2 text-sm text-cyan-600 font-mono">
{[agent.hostname, agent.ip].filter(Boolean).join(' · ') || '-'}
</p>
</div>
</div>
</div>
<div className="grid w-full gap-3 sm:grid-cols-2 lg:w-auto lg:grid-cols-2 xl:grid-cols-4">
{heroStats.map((stat) => (
<LittleStatCard key={stat.label} label={stat.label} value={stat.value}/>
))}
</div>
</div>
<div
className="flex flex-wrap items-center gap-3 text-xs text-cyan-600 font-mono pt-4 border-t border-cyan-900/30">
<span>探针 ID:{agent.id}</span>
<span className="hidden h-1 w-1 rounded-full bg-cyan-900 sm:inline-block"/>
<span>版本:{agent.version || '-'}</span>
<span className="hidden h-1 w-1 rounded-full bg-cyan-900 sm:inline-block"/>
<span>网络累计:{networkSummary}</span>
</div>
</div>
</CyberCard>
);
};
@@ -0,0 +1,74 @@
import type {ReactNode} from 'react';
import type {LucideIcon} from 'lucide-react';
import {cn} from '@/lib/utils';
type AccentVariant = 'blue' | 'emerald' | 'purple' | 'amber';
export type SnapshotCardData = {
key: string;
icon: LucideIcon;
title: string;
usagePercent: string;
accent: AccentVariant;
metrics: Array<{ label: string; value: ReactNode }>;
};
const accentThemes: Record<AccentVariant, { icon: string; badge: string; highlight: string }> = {
blue: {
icon: 'text-blue-400',
badge: 'text-blue-400',
highlight: 'text-blue-400',
},
emerald: {
icon: 'text-emerald-400',
badge: 'text-emerald-400',
highlight: 'text-emerald-400',
},
purple: {
icon: 'text-purple-400',
badge: 'text-purple-400',
highlight: 'text-purple-400',
},
amber: {
icon: 'text-amber-400',
badge: 'text-amber-400',
highlight: 'text-amber-400',
},
};
interface SnapshotGridProps {
cards: SnapshotCardData[];
}
export const SnapshotGrid = ({cards}: SnapshotGridProps) => (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
{cards.map((card) => {
const theme = accentThemes[card.accent];
return (
<div
key={card.key}
className="rounded-xl border border-cyan-900/50 bg-black/40 p-4 transition hover:-translate-y-0.5 hover:border-cyan-700/50"
>
<div className="mb-3 flex items-start justify-between">
<div className="flex items-center gap-2">
<span className={cn("flex h-9 w-9 items-center justify-center rounded-lg bg-cyan-500/10", theme.icon)}>
<card.icon className="h-4 w-4"/>
</span>
<p className="text-xs font-bold font-mono uppercase tracking-wider text-cyan-300">{card.title}</p>
</div>
<span className={cn("text-xl font-bold", theme.highlight)}>{card.usagePercent}</span>
</div>
<div className="space-y-2">
{card.metrics.map((metric) => (
<div key={metric.label} className="flex items-center justify-between text-xs">
<span className="text-cyan-600 font-mono text-[10px] uppercase tracking-wider">{metric.label}</span>
<span
className="ml-2 text-right font-medium text-cyan-200">{metric.value}</span>
</div>
))}
</div>
</div>
);
})}
</div>
);
@@ -0,0 +1,20 @@
import {SnapshotGrid, type SnapshotCardData} from './SnapshotGrid';
import CyberCard from "@/components/CyberCard.tsx";
interface SnapshotSectionProps {
cards: SnapshotCardData[];
}
export const SnapshotSection = ({cards}: SnapshotSectionProps) => {
if (cards.length === 0) {
return null;
}
return (
<CyberCard className="p-6 space-y-4">
<div>
<h3 className="text-xs font-bold font-mono uppercase tracking-widest text-cyan-500">资源快照</h3>
</div>
<SnapshotGrid cards={cards}/>
</CyberCard>
);
};
@@ -0,0 +1,180 @@
import type {ReactNode} from 'react';
import {Cpu, Database, HardDrive, MemoryStick, Network} from 'lucide-react';
import {Card} from '@/components/common';
import {InfoGrid, SnapshotSection, type SnapshotCardData} from '@/components/server';
import {formatBytes, formatDateTime, formatPercentValue, formatUptime} from '@/utils/util';
import type {Agent, LatestMetrics} from '@/types';
import CyberCard from "@/components/CyberCard.tsx";
type AccentVariant = 'blue' | 'emerald' | 'purple' | 'amber';
interface SystemInfoSectionProps {
agent: Agent;
latestMetrics: LatestMetrics | null;
}
/**
* 系统信息区块组件
* 显示运行环境、运行状态、快照卡片和网络连接统计
*/
export const SystemInfoSection = ({agent, latestMetrics}: SystemInfoSectionProps) => {
// 环境信息
const platformDisplay = latestMetrics?.host?.platform
? `${latestMetrics.host.platform} ${latestMetrics.host.platformVersion || ''}`.trim()
: agent?.os || '-';
const architectureDisplay = latestMetrics?.host?.kernelArch || agent?.arch || '-';
const environmentInfo = [
{label: '操作系统', value: platformDisplay || '-'},
{label: '内核版本', value: latestMetrics?.host?.kernelVersion || '-'},
{label: '硬件架构', value: architectureDisplay || '-'},
{label: 'CPU 型号', value: latestMetrics?.cpu?.modelName || '-'},
{label: '逻辑核心', value: latestMetrics?.cpu?.logicalCores ?? '-'},
{label: '物理核心', value: latestMetrics?.cpu?.physicalCores ?? '-'},
];
// 状态信息
const uptimeDisplay = formatUptime(latestMetrics?.host?.uptime);
const bootTimeDisplay = latestMetrics?.host?.bootTime
? formatDateTime(latestMetrics.host.bootTime * 1000)
: '-';
const lastSeenDisplay = agent ? formatDateTime(agent.lastSeenAt) : '-';
const networkSummary = latestMetrics?.network
? `${formatBytes(latestMetrics.network.totalBytesSentTotal)} ↑ / ${formatBytes(
latestMetrics.network.totalBytesRecvTotal,
)} ↓`
: '—';
const statusInfo = [
{label: '启动时间', value: bootTimeDisplay},
{label: '运行时间', value: uptimeDisplay},
{label: '最近心跳', value: lastSeenDisplay},
{label: '进程数', value: latestMetrics?.host?.procs ?? '-'},
{label: '网络累计', value: networkSummary},
];
// 快照卡片
const snapshotCards: SnapshotCardData[] = [];
if (latestMetrics) {
snapshotCards.push({
key: 'cpu',
icon: Cpu,
title: 'CPU 使用',
usagePercent: `${formatPercentValue(latestMetrics.cpu?.usagePercent)}%`,
accent: 'blue',
metrics: [
{label: '当前使用', value: `${formatPercentValue(latestMetrics.cpu?.usagePercent)}%`},
{
label: '采样时间',
value: latestMetrics.cpu ? formatDateTime(latestMetrics.cpu.timestamp) : '-',
},
],
});
snapshotCards.push({
key: 'memory',
icon: MemoryStick,
title: '内存使用',
usagePercent: `${formatPercentValue(latestMetrics.memory?.usagePercent)}%`,
accent: 'emerald',
metrics: [
{
label: '已用 / 总量',
value: `${formatBytes(latestMetrics.memory?.used)} / ${formatBytes(latestMetrics.memory?.total)}`
},
{
label: 'Swap 已用',
value: `${formatBytes(latestMetrics.memory?.swapUsed)} / ${formatBytes(latestMetrics.memory?.swapTotal)}`
},
],
});
snapshotCards.push({
key: 'disk',
icon: HardDrive,
title: '磁盘使用',
usagePercent: latestMetrics.disk
? `${formatPercentValue(latestMetrics.disk.usagePercent)}%`
: '—',
accent: 'purple',
metrics: [
{
label: '已用 / 总量',
value: `${formatBytes(latestMetrics.disk?.used)} / ${formatBytes(latestMetrics.disk?.total)}`
},
{label: '磁盘数量', value: latestMetrics.disk?.totalDisks ?? '-'},
],
});
// 网络流量卡片 - 整合流量统计信息
const networkMetrics = [
{
label: '上行 / 下行',
value: `${formatBytes(latestMetrics.network?.totalBytesSentRate)}/s ↑ / ${formatBytes(
latestMetrics.network?.totalBytesRecvRate,
)}/s ↓`,
},
{
label: '网络累计',
value: `${formatBytes(latestMetrics.network?.totalBytesSentTotal)} ↑ / ${formatBytes(
latestMetrics.network?.totalBytesRecvTotal,
)} ↓`,
},
];
// 如果配置了流量限额,添加流量统计信息到网络卡片
if (agent && agent.trafficLimit && agent.trafficLimit > 0) {
const trafficUsedPercent = ((agent.trafficUsed || 0) / agent.trafficLimit) * 100;
const trafficRemaining = agent.trafficLimit - (agent.trafficUsed || 0);
networkMetrics.push({
label: '流量限额',
value: `${formatBytes(agent.trafficUsed || 0)} / ${formatBytes(agent.trafficLimit)} (${formatPercentValue(trafficUsedPercent)}%)`,
});
if (agent.trafficResetDay && agent.trafficResetDay > 0) {
networkMetrics.push({
label: '重置日期',
value: `每月${agent.trafficResetDay}号`,
});
}
}
snapshotCards.push({
key: 'network',
icon: Network,
title: '网络流量',
usagePercent: latestMetrics.network
? `${formatBytes(latestMetrics.network.totalBytesSentRate)}/s`
: '—',
accent: 'amber',
metrics: networkMetrics,
});
}
return (
<div>
<div className="space-y-6">
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<CyberCard className={'p-6'}>
<h3 className="text-xs font-bold font-mono uppercase tracking-widest text-cyan-400">运行环境</h3>
<p className="mt-1 text-[10px] text-cyan-700">来自最近一次探针上报的硬件与系统信息</p>
<div className="mt-4">
<InfoGrid items={environmentInfo}/>
</div>
</CyberCard>
<CyberCard className={'p-6'}>
<h3 className="text-xs font-bold font-mono uppercase tracking-widest text-cyan-400">运行状态</h3>
<p className="mt-1 text-[10px] text-cyan-700">关键时间与网络指标,帮助快速判断主机健康状况</p>
<div className="mt-4">
<InfoGrid items={statusInfo}/>
</div>
</CyberCard>
</div>
<SnapshotSection cards={snapshotCards}/>
</div>
</div>
);
};
@@ -0,0 +1,37 @@
import {Thermometer} from 'lucide-react';
import {Card} from '@/components/common';
import type {LatestMetrics} from '@/types';
interface TemperatureMonitorSectionProps {
latestMetrics: LatestMetrics | null;
}
/**
* 温度监控区块组件
* 显示系统各部件温度传感器数据
*/
export const TemperatureMonitorSection = ({latestMetrics}: TemperatureMonitorSectionProps) => {
// 如果没有温度数据,不渲染组件
if (!latestMetrics?.temperature || latestMetrics.temperature.length === 0) {
return null;
}
return (
<Card title="温度监控" description="系统各部件温度传感器数据" variant="dark">
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
{latestMetrics.temperature.sort((a, b) => a.sensorKey.localeCompare(b.sensorKey)).map((temp) => (
<div
key={temp.sensorKey}
className="rounded-xl border border-cyan-900/50 bg-black/30 p-4 backdrop-blur-sm hover:border-cyan-700/50 transition"
>
<div className="flex items-center gap-2 mb-2">
<Thermometer className="h-4 w-4 text-cyan-600"/>
<p className="text-[10px] font-bold font-mono uppercase tracking-wider text-cyan-400 truncate">{temp.type}</p>
</div>
<p className="text-2xl font-bold text-orange-400">{temp.temperature.toFixed(1)}°C</p>
</div>
))}
</div>
</Card>
);
};
@@ -0,0 +1,30 @@
import type {ReactNode} from 'react';
import type {LucideIcon} from 'lucide-react';
interface ChartContainerProps {
title: string;
icon: LucideIcon;
children: ReactNode;
action?: ReactNode;
}
/**
* 图表容器组件
* 为所有图表提供统一的标题、图标和可选的操作区域
*/
export const ChartContainer = ({title, icon: Icon, children, action}: ChartContainerProps) => {
return (
<section>
<div className="mb-3 flex items-center justify-between">
<h3 className="flex items-center gap-2 text-xs font-bold font-mono uppercase tracking-widest text-cyan-400">
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-cyan-500/10 text-cyan-400">
<Icon className="h-4 w-4"/>
</span>
{title}
</h3>
{action}
</div>
{children}
</section>
);
};
@@ -0,0 +1,90 @@
import {useMemo} from 'react';
import {Cpu} from 'lucide-react';
import {Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis} from 'recharts';
import {ChartPlaceholder, CustomTooltip} from '@/components/common';
import {useMetricsQuery} from '@/hooks/server/queries';
import {ChartContainer} from './ChartContainer';
interface CpuChartProps {
agentId: string;
timeRange: string;
}
/**
* CPU 使用率图表组件
*/
export const CpuChart = ({agentId, timeRange}: CpuChartProps) => {
// 数据查询
const {data: metricsResponse, isLoading} = useMetricsQuery({
agentId,
type: 'cpu',
range: timeRange,
});
// 数据转换
const chartData = useMemo(() => {
const cpuSeries = metricsResponse?.data.series?.find(s => s.name === 'usage');
if (!cpuSeries) return [];
return cpuSeries.data.map((point) => ({
time: new Date(point.timestamp).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
}),
usage: Number(point.value.toFixed(2)),
timestamp: point.timestamp,
}));
}, [metricsResponse]);
// 渲染
if (isLoading) {
return (
<ChartContainer title="CPU 使用率" icon={Cpu}>
<ChartPlaceholder variant="dark"/>
</ChartContainer>
);
}
return (
<ChartContainer title="CPU 使用率" icon={Cpu}>
{chartData.length > 0 ? (
<ResponsiveContainer width="100%" height={220}>
<AreaChart data={chartData}>
<defs>
<linearGradient id="cpuAreaGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#2563eb" stopOpacity={0.4}/>
<stop offset="95%" stopColor="#2563eb" stopOpacity={0}/>
</linearGradient>
</defs>
<CartesianGrid stroke="currentColor" strokeDasharray="4 4" className="stroke-cyan-900/30"/>
<XAxis
dataKey="time"
stroke="currentColor"
className="stroke-cyan-600"
style={{fontSize: '12px'}}
/>
<YAxis
domain={[0, 100]}
stroke="currentColor"
className="stroke-cyan-600"
style={{fontSize: '12px'}}
tickFormatter={(value) => `${value}%`}
/>
<Tooltip content={<CustomTooltip unit="%" variant="dark"/>}/>
<Area
type="monotone"
dataKey="usage"
name="CPU 使用率"
stroke="#2563eb"
strokeWidth={2}
fill="url(#cpuAreaGradient)"
activeDot={{r: 3}}
/>
</AreaChart>
</ResponsiveContainer>
) : (
<ChartPlaceholder variant="dark"/>
)}
</ChartContainer>
);
};
@@ -0,0 +1,122 @@
import {useMemo} from 'react';
import {HardDrive} from 'lucide-react';
import {Area, AreaChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis} from 'recharts';
import {ChartPlaceholder, CustomTooltip} from '@/components/common';
import {useMetricsQuery} from '@/hooks/server/queries';
import {ChartContainer} from './ChartContainer';
interface DiskIOChartProps {
agentId: string;
timeRange: string;
}
/**
* 磁盘 I/O 图表组件
*/
export const DiskIOChart = ({agentId, timeRange}: DiskIOChartProps) => {
// 数据查询
const {data: metricsResponse, isLoading} = useMetricsQuery({
agentId,
type: 'disk_io',
range: timeRange,
});
// 数据转换
const chartData = useMemo(() => {
if (!metricsResponse?.data.series || metricsResponse.data.series?.length === 0) return [];
const readSeries = metricsResponse.data.series?.find(s => s.name === 'read');
const writeSeries = metricsResponse.data.series?.find(s => s.name === 'write');
if (!readSeries || !writeSeries) return [];
// 按时间戳对齐数据
const timeMap = new Map<number, any>();
readSeries.data.forEach(point => {
const time = new Date(point.timestamp).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
});
timeMap.set(point.timestamp, {
time,
timestamp: point.timestamp,
read: Number((point.value / 1024 / 1024).toFixed(2)), // 转换为 MB/s
});
});
writeSeries.data.forEach(point => {
const existing = timeMap.get(point.timestamp);
if (existing) {
existing.write = Number((point.value / 1024 / 1024).toFixed(2));
}
});
return Array.from(timeMap.values());
}, [metricsResponse]);
// 渲染
if (isLoading) {
return (
<ChartContainer title="磁盘 I/O (MB/s)" icon={HardDrive}>
<ChartPlaceholder variant="dark"/>
</ChartContainer>
);
}
return (
<ChartContainer title="磁盘 I/O (MB/s)" icon={HardDrive}>
{chartData.length > 0 ? (
<ResponsiveContainer width="100%" height={220}>
<AreaChart data={chartData}>
<defs>
<linearGradient id="colorDiskRead" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#2C70F6" stopOpacity={0.3}/>
<stop offset="95%" stopColor="#2C70F6" stopOpacity={0}/>
</linearGradient>
<linearGradient id="colorDiskWrite" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#6FD598" stopOpacity={0.3}/>
<stop offset="95%" stopColor="#6FD598" stopOpacity={0}/>
</linearGradient>
</defs>
<CartesianGrid stroke="currentColor" strokeDasharray="4 4" className="stroke-cyan-900/30"/>
<XAxis
dataKey="time"
stroke="currentColor"
className="stroke-cyan-600"
style={{fontSize: '12px'}}
/>
<YAxis
stroke="currentColor"
className="stroke-cyan-600"
style={{fontSize: '12px'}}
tickFormatter={(value) => `${value} MB`}
/>
<Tooltip content={<CustomTooltip unit=" MB" variant="dark"/>}/>
<Legend/>
<Area
type="monotone"
dataKey="read"
name="读取"
stroke="#2C70F6"
strokeWidth={2}
fill="url(#colorDiskRead)"
activeDot={{r: 3}}
/>
<Area
type="monotone"
dataKey="write"
name="写入"
stroke="#6FD598"
strokeWidth={2}
fill="url(#colorDiskWrite)"
activeDot={{r: 3}}
/>
</AreaChart>
</ResponsiveContainer>
) : (
<ChartPlaceholder subtitle="暂无磁盘 I/O 采集数据" variant="dark"/>
)}
</ChartContainer>
);
};
@@ -0,0 +1,125 @@
import {useMemo} from 'react';
import {Zap} from 'lucide-react';
import {CartesianGrid, Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis} from 'recharts';
import {ChartPlaceholder, CustomTooltip} from '@/components/common';
import {useMetricsQuery} from '@/hooks/server/queries';
import {ChartContainer} from './ChartContainer';
interface GpuChartProps {
agentId: string;
timeRange: string;
}
/**
* GPU 使用率与温度图表组件
* 使用双 Y 轴显示使用率和温度
*/
export const GpuChart = ({agentId, timeRange}: GpuChartProps) => {
// 数据查询
const {data: metricsResponse, isLoading} = useMetricsQuery({
agentId,
type: 'gpu',
range: timeRange,
});
// 数据转换
const chartData = useMemo(() => {
if (!metricsResponse?.data.series || metricsResponse.data.series?.length === 0) return [];
// 按时间戳聚合利用率和温度系列
const timeMap = new Map<number, any>();
const utilizationSeries = metricsResponse.data.series?.find(s => s.name === 'utilization');
const temperatureSeries = metricsResponse.data.series?.find(s => s.name === 'temperature');
// 添加利用率数据
utilizationSeries?.data.forEach(point => {
const time = new Date(point.timestamp).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
});
timeMap.set(point.timestamp, {
time,
timestamp: point.timestamp,
utilization: Number(point.value.toFixed(2)),
});
});
// 添加温度数据
temperatureSeries?.data.forEach(point => {
const existing = timeMap.get(point.timestamp);
if (existing) {
existing.temperature = Number(point.value.toFixed(2));
}
});
return Array.from(timeMap.values());
}, [metricsResponse]);
// 渲染
if (isLoading) {
return (
<ChartContainer title="GPU 使用率与温度" icon={Zap}>
<ChartPlaceholder variant="dark"/>
</ChartContainer>
);
}
// 如果没有 GPU 数据,不渲染组件
if (chartData.length === 0) {
return null;
}
return (
<ChartContainer title="GPU 使用率与温度" icon={Zap}>
<ResponsiveContainer width="100%" height={220}>
<LineChart data={chartData}>
<CartesianGrid stroke="currentColor" strokeDasharray="4 4" className="stroke-cyan-900/30"/>
<XAxis
dataKey="time"
stroke="currentColor"
className="stroke-cyan-600"
style={{fontSize: '12px'}}
/>
<YAxis
yAxisId="left"
stroke="currentColor"
className="stroke-cyan-600"
style={{fontSize: '12px'}}
tickFormatter={(value) => `${value}%`}
/>
<YAxis
yAxisId="right"
orientation="right"
stroke="currentColor"
className="stroke-cyan-600"
style={{fontSize: '12px'}}
tickFormatter={(value) => `${value}°C`}
/>
<Tooltip content={<CustomTooltip unit="" variant="dark"/>}/>
<Legend/>
<Line
yAxisId="left"
type="monotone"
dataKey="utilization"
name="使用率 (%)"
stroke="#7c3aed"
strokeWidth={2}
dot={false}
activeDot={{r: 3}}
/>
<Line
yAxisId="right"
type="monotone"
dataKey="temperature"
name="温度 (°C)"
stroke="#f97316"
strokeWidth={2}
dot={false}
activeDot={{r: 3}}
/>
</LineChart>
</ResponsiveContainer>
</ChartContainer>
);
};
@@ -0,0 +1,90 @@
import {useMemo} from 'react';
import {MemoryStick} from 'lucide-react';
import {Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis} from 'recharts';
import {ChartPlaceholder, CustomTooltip} from '@/components/common';
import {useMetricsQuery} from '@/hooks/server/queries';
import {ChartContainer} from './ChartContainer';
interface MemoryChartProps {
agentId: string;
timeRange: string;
}
/**
* 内存使用率图表组件
*/
export const MemoryChart = ({agentId, timeRange}: MemoryChartProps) => {
// 数据查询
const {data: metricsResponse, isLoading} = useMetricsQuery({
agentId,
type: 'memory',
range: timeRange,
});
// 数据转换
const chartData = useMemo(() => {
const memorySeries = metricsResponse?.data.series?.find(s => s.name === 'usage');
if (!memorySeries) return [];
return memorySeries.data.map((point) => ({
time: new Date(point.timestamp).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
}),
usage: Number(point.value.toFixed(2)),
timestamp: point.timestamp,
}));
}, [metricsResponse]);
// 渲染
if (isLoading) {
return (
<ChartContainer title="内存使用率" icon={MemoryStick}>
<ChartPlaceholder variant="dark"/>
</ChartContainer>
);
}
return (
<ChartContainer title="内存使用率" icon={MemoryStick}>
{chartData.length > 0 ? (
<ResponsiveContainer width="100%" height={220}>
<AreaChart data={chartData}>
<defs>
<linearGradient id="memoryAreaGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#10b981" stopOpacity={0.4}/>
<stop offset="95%" stopColor="#10b981" stopOpacity={0}/>
</linearGradient>
</defs>
<CartesianGrid stroke="currentColor" strokeDasharray="4 4" className="stroke-cyan-900/30"/>
<XAxis
dataKey="time"
stroke="currentColor"
className="stroke-cyan-600"
style={{fontSize: '12px'}}
/>
<YAxis
domain={[0, 100]}
stroke="currentColor"
className="stroke-cyan-600"
style={{fontSize: '12px'}}
tickFormatter={(value) => `${value}%`}
/>
<Tooltip content={<CustomTooltip unit="%" variant="dark"/>}/>
<Area
type="monotone"
dataKey="usage"
name="内存使用率"
stroke="#10b981"
strokeWidth={2}
fill="url(#memoryAreaGradient)"
activeDot={{r: 3}}
/>
</AreaChart>
</ResponsiveContainer>
) : (
<ChartPlaceholder variant="dark"/>
)}
</ChartContainer>
);
};
@@ -0,0 +1,131 @@
import {useMemo} from 'react';
import {Activity} from 'lucide-react';
import {Area, AreaChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis} from 'recharts';
import {ChartPlaceholder, CustomTooltip} from '@/components/common';
import {useMetricsQuery} from '@/hooks/server/queries';
import {ChartContainer} from './ChartContainer';
interface MonitorChartProps {
agentId: string;
timeRange: string;
}
/**
* 监控响应时间图表组件
*/
export const MonitorChart = ({agentId, timeRange}: MonitorChartProps) => {
// 数据查询
const {data: metricsResponse, isLoading} = useMetricsQuery({
agentId,
type: 'monitor',
range: timeRange,
});
// 数据转换 - 支持多个监控任务
const chartData = useMemo(() => {
const series = metricsResponse?.data.series || [];
if (series.length === 0) return [];
// 收集所有时间戳
const timestampMap = new Map<number, any>();
series.forEach((s) => {
s.data.forEach((point) => {
if (!timestampMap.has(point.timestamp)) {
timestampMap.set(point.timestamp, {
time: new Date(point.timestamp).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
}),
timestamp: point.timestamp,
});
}
const dataPoint = timestampMap.get(point.timestamp);
// 使用 monitor_name 作为 key,如果没有则使用 monitor_id
const monitorKey = s.labels?.monitor_name || s.labels?.monitor_id || s.name;
dataPoint[monitorKey] = Number(point.value.toFixed(2));
});
});
// 转换为数组并排序
return Array.from(timestampMap.values()).sort((a, b) => a.timestamp - b.timestamp);
}, [metricsResponse]);
// 获取所有监控任务的列表(使用名称)
const monitorKeys = useMemo(() => {
const series = metricsResponse?.data.series || [];
return series.map(s => s.labels?.monitor_name || s.labels?.monitor_id || s.name);
}, [metricsResponse]);
// 颜色配置
const colors = ['#2563eb', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899'];
// 如果没有数据且不是加载中,不渲染组件
if (!isLoading && chartData.length === 0) {
return null;
}
// 渲染
if (isLoading) {
return (
<ChartContainer title="监控响应时间" icon={Activity}>
<ChartPlaceholder variant="dark"/>
</ChartContainer>
);
}
return (
<ChartContainer title="监控响应时间" icon={Activity}>
{chartData.length > 0 ? (
<ResponsiveContainer width="100%" height={220}>
<AreaChart data={chartData}>
<defs>
{monitorKeys.map((key, index) => (
<linearGradient key={key} id={`monitorAreaGradient-${index}`} x1="0" y1="0" x2="0"
y2="1">
<stop offset="5%" stopColor={colors[index % colors.length]} stopOpacity={0.4}/>
<stop offset="95%" stopColor={colors[index % colors.length]} stopOpacity={0}/>
</linearGradient>
))}
</defs>
<CartesianGrid stroke="currentColor" strokeDasharray="4 4" className="stroke-cyan-900/30"/>
<XAxis
dataKey="time"
stroke="currentColor"
className="stroke-cyan-600"
style={{fontSize: '12px'}}
/>
<YAxis
stroke="currentColor"
className="stroke-cyan-600"
style={{fontSize: '12px'}}
tickFormatter={(value) => `${value}ms`}
/>
<Tooltip content={<CustomTooltip unit="ms" variant="dark"/>}/>
{monitorKeys.length > 1 && (
<Legend
wrapperStyle={{fontSize: '12px'}}
iconType="line"
/>
)}
{monitorKeys.map((key, index) => (
<Area
key={key}
type="monotone"
dataKey={key}
name={key}
stroke={colors[index % colors.length]}
strokeWidth={2}
fill={`url(#monitorAreaGradient-${index})`}
activeDot={{r: 3}}
/>
))}
</AreaChart>
</ResponsiveContainer>
) : (
<ChartPlaceholder variant="dark"/>
)}
</ChartContainer>
);
};
@@ -0,0 +1,154 @@
import {useEffect, useMemo, useState} from 'react';
import {Network} from 'lucide-react';
import {Area, AreaChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis} from 'recharts';
import {ChartPlaceholder, CustomTooltip} from '@/components/common';
import {useMetricsQuery, useNetworkInterfacesQuery} from '@/hooks/server/queries';
import {INTERFACE_COLORS} from '@/constants/server';
import {ChartContainer} from './ChartContainer';
interface NetworkChartProps {
agentId: string;
timeRange: string;
}
/**
* 网络流量图表组件
* 支持网卡切换
*/
export const NetworkChart = ({agentId, timeRange}: NetworkChartProps) => {
const [selectedInterface, setSelectedInterface] = useState<string>('all');
// 查询网卡列表
const {data: interfacesData} = useNetworkInterfacesQuery(agentId);
const availableInterfaces = interfacesData?.data.interfaces || [];
// 当网卡列表变化时,验证选中的网卡
useEffect(() => {
if (selectedInterface !== 'all' && availableInterfaces.length > 0) {
if (!availableInterfaces.includes(selectedInterface)) {
setSelectedInterface('all');
}
}
}, [availableInterfaces, selectedInterface]);
// 查询网络数据
const {data: metricsResponse, isLoading} = useMetricsQuery({
agentId,
type: 'network',
range: timeRange,
interfaceName: selectedInterface !== 'all' ? selectedInterface : undefined,
});
// 数据转换
const chartData = useMemo(() => {
if (!metricsResponse?.data.series || metricsResponse.data.series?.length === 0) return [];
const uploadSeries = metricsResponse.data.series?.find(s => s.name === 'upload');
const downloadSeries = metricsResponse.data.series?.find(s => s.name === 'download');
if (!uploadSeries || !downloadSeries) return [];
const timeMap = new Map<number, any>();
uploadSeries.data.forEach(point => {
const time = new Date(point.timestamp).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
});
timeMap.set(point.timestamp, {
time,
timestamp: point.timestamp,
upload: Number((point.value / 1024 / 1024).toFixed(2)), // 转换为 MB/s
});
});
downloadSeries.data.forEach(point => {
const existing = timeMap.get(point.timestamp);
if (existing) {
existing.download = Number((point.value / 1024 / 1024).toFixed(2));
}
});
return Array.from(timeMap.values());
}, [metricsResponse]);
// 网卡选择器
const interfaceSelector = availableInterfaces.length > 0 && (
<select
value={selectedInterface}
onChange={(e) => setSelectedInterface(e.target.value)}
className="rounded-lg border border-cyan-900/50 bg-black/40 px-3 py-1.5 text-xs font-mono text-cyan-300 hover:border-cyan-700 focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/20"
>
{availableInterfaces.map((iface) => (
<option key={iface} value={iface}>
{iface}
</option>
))}
</select>
);
// 渲染
if (isLoading) {
return (
<ChartContainer title="网络流量(MB/s)" icon={Network} action={interfaceSelector}>
<ChartPlaceholder variant="dark"/>
</ChartContainer>
);
}
return (
<ChartContainer title="网络流量(MB/s)" icon={Network} action={interfaceSelector}>
{chartData.length > 0 ? (
<ResponsiveContainer width="100%" height={220}>
<AreaChart data={chartData}>
<defs>
<linearGradient id="color-upload" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={INTERFACE_COLORS[0].upload} stopOpacity={0.3}/>
<stop offset="95%" stopColor={INTERFACE_COLORS[0].upload} stopOpacity={0}/>
</linearGradient>
<linearGradient id="color-download" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={INTERFACE_COLORS[0].download} stopOpacity={0.3}/>
<stop offset="95%" stopColor={INTERFACE_COLORS[0].download} stopOpacity={0}/>
</linearGradient>
</defs>
<CartesianGrid stroke="currentColor" strokeDasharray="4 4" className="stroke-cyan-900/30"/>
<XAxis
dataKey="time"
stroke="currentColor"
className="stroke-cyan-600"
style={{fontSize: '12px'}}
/>
<YAxis
stroke="currentColor"
className="stroke-cyan-600"
style={{fontSize: '12px'}}
tickFormatter={(value) => `${value} MB`}
/>
<Tooltip content={<CustomTooltip unit=" MB/s" variant="dark"/>}/>
<Legend/>
<Area
type="monotone"
dataKey="upload"
name="上行"
stroke={INTERFACE_COLORS[0].upload}
strokeWidth={2}
fill="url(#color-upload)"
activeDot={{r: 3}}
/>
<Area
type="monotone"
dataKey="download"
name="下行"
stroke={INTERFACE_COLORS[0].download}
strokeWidth={2}
fill="url(#color-download)"
activeDot={{r: 3}}
/>
</AreaChart>
</ResponsiveContainer>
) : (
<ChartPlaceholder subtitle="稍后再次尝试刷新网络流量" variant="dark"/>
)}
</ChartContainer>
);
};
@@ -0,0 +1,124 @@
import {useMemo} from 'react';
import {Network} from 'lucide-react';
import {CartesianGrid, Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis} from 'recharts';
import {ChartPlaceholder, CustomTooltip} from '@/components/common';
import {useMetricsQuery} from '@/hooks/server/queries';
import {ChartContainer} from './ChartContainer';
interface NetworkConnectionChartProps {
agentId: string;
timeRange: string;
}
/**
* 网络连接统计图表组件
*/
export const NetworkConnectionChart = ({agentId, timeRange}: NetworkConnectionChartProps) => {
// 数据查询
const {data: metricsResponse, isLoading} = useMetricsQuery({
agentId,
type: 'network_connection',
range: timeRange,
});
// 数据转换
const chartData = useMemo(() => {
if (!metricsResponse?.data.series || metricsResponse.data.series?.length === 0) return [];
// 按时间戳聚合所有连接状态系列
const timeMap = new Map<number, any>();
metricsResponse.data.series?.forEach(series => {
const stateName = series.name; // established, time_wait, close_wait, listen
series.data.forEach(point => {
const time = new Date(point.timestamp).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
});
if (!timeMap.has(point.timestamp)) {
timeMap.set(point.timestamp, {time, timestamp: point.timestamp});
}
const existing = timeMap.get(point.timestamp)!;
// 转换为驼峰命名以匹配图表的 dataKey
const camelCaseName = stateName.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
existing[camelCaseName] = Number(point.value.toFixed(0));
});
});
return Array.from(timeMap.values());
}, [metricsResponse]);
// 渲染
if (isLoading) {
return (
<ChartContainer title="网络连接统计" icon={Network}>
<ChartPlaceholder variant="dark"/>
</ChartContainer>
);
}
return (
<ChartContainer title="网络连接统计" icon={Network}>
{chartData.length > 0 ? (
<ResponsiveContainer width="100%" height={220}>
<LineChart data={chartData}>
<CartesianGrid stroke="currentColor" strokeDasharray="4 4" className="stroke-cyan-900/30"/>
<XAxis
dataKey="time"
stroke="currentColor"
className="stroke-cyan-600"
style={{fontSize: '12px'}}
/>
<YAxis
stroke="currentColor"
className="stroke-cyan-600"
style={{fontSize: '12px'}}
/>
<Tooltip content={<CustomTooltip unit="" variant="dark"/>}/>
<Legend/>
<Line
type="monotone"
dataKey="established"
name="ESTABLISHED"
stroke="#10b981"
strokeWidth={2}
dot={false}
activeDot={{r: 3}}
/>
<Line
type="monotone"
dataKey="timeWait"
name="TIME_WAIT"
stroke="#f59e0b"
strokeWidth={2}
dot={false}
activeDot={{r: 3}}
/>
<Line
type="monotone"
dataKey="closeWait"
name="CLOSE_WAIT"
stroke="#ef4444"
strokeWidth={2}
dot={false}
activeDot={{r: 3}}
/>
<Line
type="monotone"
dataKey="listen"
name="LISTEN"
stroke="#3b82f6"
strokeWidth={2}
dot={false}
activeDot={{r: 3}}
/>
</LineChart>
</ResponsiveContainer>
) : (
<ChartPlaceholder subtitle="暂无网络连接统计数据" variant="dark"/>
)}
</ChartContainer>
);
};
@@ -0,0 +1,148 @@
import {useEffect, useMemo, useState} from 'react';
import {Thermometer} from 'lucide-react';
import {CartesianGrid, Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis} from 'recharts';
import {ChartPlaceholder, CustomTooltip} from '@/components/common';
import {useMetricsQuery} from '@/hooks/server/queries';
import {TEMPERATURE_COLORS} from '@/constants/server';
import {ChartContainer} from './ChartContainer';
interface TemperatureChartProps {
agentId: string;
timeRange: string;
}
/**
* 系统温度图表组件
* 支持温度类型切换
*/
export const TemperatureChart = ({agentId, timeRange}: TemperatureChartProps) => {
const [selectedTempType, setSelectedTempType] = useState<string>('all');
// 数据查询
const {data: metricsResponse, isLoading} = useMetricsQuery({
agentId,
type: 'temperature',
range: timeRange,
});
// 数据转换
const chartData = useMemo(() => {
if (!metricsResponse?.data.series || metricsResponse.data.series?.length === 0) return [];
// 按时间戳聚合所有温度系列
const timeMap = new Map<number, any>();
metricsResponse.data.series?.forEach(series => {
const sensorName = series.name; // 使用系列名称作为传感器标识
series.data.forEach(point => {
const time = new Date(point.timestamp).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
});
if (!timeMap.has(point.timestamp)) {
timeMap.set(point.timestamp, {time, timestamp: point.timestamp});
}
const existing = timeMap.get(point.timestamp)!;
existing[sensorName] = Number(point.value.toFixed(2));
});
});
return Array.from(timeMap.values());
}, [metricsResponse]);
// 提取所有唯一的温度类型
const temperatureTypes = useMemo(() => {
return metricsResponse?.data.series?.map(s => s.name).sort() || [];
}, [metricsResponse]);
// 根据选中的类型过滤温度数据
const filteredTemperatureTypes = useMemo(() => {
if (selectedTempType === 'all') {
return temperatureTypes;
}
return temperatureTypes.filter(type => type === selectedTempType);
}, [temperatureTypes, selectedTempType]);
// 当温度类型列表变化时,如果当前选中的类型不在列表中,重置为 'all'
useEffect(() => {
if (selectedTempType !== 'all' && temperatureTypes.length > 0) {
if (!temperatureTypes.includes(selectedTempType)) {
setSelectedTempType('all');
}
}
}, [temperatureTypes, selectedTempType]);
// 温度类型选择器
const tempTypeSelector = temperatureTypes.length > 1 && (
<select
value={selectedTempType}
onChange={(e) => setSelectedTempType(e.target.value)}
className="rounded-lg border border-cyan-900/50 bg-black/40 px-3 py-1.5 text-xs font-mono text-cyan-300 hover:border-cyan-700 focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/20"
>
<option value="all">所有类型</option>
{temperatureTypes.map((type) => (
<option key={type} value={type}>
{type}
</option>
))}
</select>
);
// 渲染
if (isLoading) {
return (
<ChartContainer title="系统温度" icon={Thermometer} action={tempTypeSelector}>
<ChartPlaceholder variant="dark"/>
</ChartContainer>
);
}
// 如果没有温度数据,不渲染组件
if (chartData.length === 0 || temperatureTypes.length === 0) {
return null;
}
return (
<ChartContainer title="系统温度" icon={Thermometer} action={tempTypeSelector}>
<ResponsiveContainer width="100%" height={220}>
<LineChart data={chartData}>
<CartesianGrid stroke="currentColor" strokeDasharray="4 4" className="stroke-cyan-900/30"/>
<XAxis
dataKey="time"
stroke="currentColor"
className="stroke-cyan-600"
style={{fontSize: '12px'}}
/>
<YAxis
stroke="currentColor"
className="stroke-cyan-600"
style={{fontSize: '12px'}}
tickFormatter={(value) => `${value}°C`}
/>
<Tooltip content={<CustomTooltip unit="°C" variant="dark"/>}/>
<Legend/>
{/* 为选中的温度类型渲染线条 */}
{filteredTemperatureTypes.map((type, index) => {
// 使用预定义颜色,如果没有则使用默认颜色
const color = TEMPERATURE_COLORS[type] || `hsl(${(index * 60) % 360}, 70%, 50%)`;
return (
<Line
key={type}
type="monotone"
dataKey={type}
name={type}
stroke={color}
strokeWidth={2}
dot={false}
activeDot={{r: 3}}
connectNulls
/>
);
})}
</LineChart>
</ResponsiveContainer>
</ChartContainer>
);
};
@@ -0,0 +1,9 @@
export {ChartContainer} from './ChartContainer';
export {CpuChart} from './CpuChart';
export {MemoryChart} from './MemoryChart';
export {NetworkChart} from './NetworkChart';
export {DiskIOChart} from './DiskIOChart';
export {NetworkConnectionChart} from './NetworkConnectionChart';
export {GpuChart} from './GpuChart';
export {TemperatureChart} from './TemperatureChart';
export {MonitorChart} from './MonitorChart';
+9
View File
@@ -0,0 +1,9 @@
export {InfoGrid} from './InfoGrid';
export {SnapshotGrid} from './SnapshotGrid';
export {SnapshotSection} from './SnapshotSection';
export type {SnapshotCardData} from './SnapshotGrid';
export {ServerHero} from './ServerHero';
export {SystemInfoSection} from './SystemInfoSection';
export {NetworkConnectionSection} from './NetworkConnectionSection';
export {GpuMonitorSection} from './GpuMonitorSection';
export {TemperatureMonitorSection} from './TemperatureMonitorSection';
+15
View File
@@ -0,0 +1,15 @@
// 探针颜色方案(用于 MonitorDetail 区分不同探针)
export const AGENT_COLORS = [
'#3b82f6', // blue
'#10b981', // emerald
'#f59e0b', // amber
'#8b5cf6', // violet
'#ec4899', // pink
'#06b6d4', // cyan
'#f97316', // orange
'#14b8a6', // teal
];
// 重新导出其他颜色配置
export {INTERFACE_COLORS, TEMPERATURE_COLORS, ACCENT_THEMES} from './server';
export type {TimeRangeOption} from '@/api/property.ts';
+43
View File
@@ -0,0 +1,43 @@
// 网卡颜色配置(上行和下行使用不同的色调)
export const INTERFACE_COLORS = [
{upload: '#6FD598', download: '#2C70F6'}, // 绿/蓝
{upload: '#f59e0b', download: '#8b5cf6'}, // 橙/紫
{upload: '#ec4899', download: '#06b6d4'}, // 粉/青
{upload: '#10b981', download: '#f97316'}, // 翠绿/深橙
{upload: '#14b8a6', download: '#2563eb'}, // 青绿/深蓝
];
// 温度类型颜色映射
export const TEMPERATURE_COLORS: Record<string, string> = {
'CPU': '#f97316', // 橙色
'GPU': '#8b5cf6', // 紫色
'DISK': '#06b6d4', // 青色
'BATTERY': '#10b981', // 绿色
'CHIPSET': '#f59e0b', // 琥珀色
'SYSTEM': '#6366f1', // 靛蓝色
'PSU': '#ec4899', // 粉色
};
// 主题配色方案
export const ACCENT_THEMES: Record<'blue' | 'emerald' | 'purple' | 'amber', { icon: string; badge: string; highlight: string }> = {
blue: {
icon: 'text-blue-400',
badge: 'text-blue-400',
highlight: 'text-blue-400',
},
emerald: {
icon: 'text-emerald-400',
badge: 'text-emerald-400',
highlight: 'text-emerald-400',
},
purple: {
icon: 'text-purple-400',
badge: 'text-purple-400',
highlight: 'text-purple-400',
},
amber: {
icon: 'text-amber-400',
badge: 'text-amber-400',
highlight: 'text-amber-400',
},
};
+22
View File
@@ -0,0 +1,22 @@
import type {TimeRangeOption} from '@/api/property.ts';
// 服务器详情页时间范围选项
export const SERVER_TIME_RANGE_OPTIONS: TimeRangeOption[] = [
{label: '15分钟', value: '15m'},
{label: '30分钟', value: '30m'},
{label: '1小时', value: '1h'},
{label: '3小时', value: '3h'},
{label: '6小时', value: '6h'},
{label: '12小时', value: '12h'},
{label: '1天', value: '1d'},
{label: '3天', value: '3d'},
{label: '7天', value: '7d'},
];
// 监控详情页时间范围选项
export const MONITOR_TIME_RANGE_OPTIONS: TimeRangeOption[] = [
{label: '12小时', value: '12h'},
{label: '1天', value: '1d'},
{label: '3天', value: '3d'},
{label: '7天', value: '7d'},
];
+5
View File
@@ -0,0 +1,5 @@
// React Query hooks
export {useAgentQuery} from './queries/useAgentQuery';
export {useLatestMetricsQuery} from './queries/useLatestMetricsQuery';
export {useMetricsQuery} from './queries/useMetricsQuery';
export {useNetworkInterfacesQuery} from './queries/useNetworkInterfacesQuery';
+4
View File
@@ -0,0 +1,4 @@
export {useAgentQuery} from './useAgentQuery';
export {useLatestMetricsQuery} from './useLatestMetricsQuery';
export {useMetricsQuery} from './useMetricsQuery';
export {useNetworkInterfacesQuery} from './useNetworkInterfacesQuery';
@@ -0,0 +1,16 @@
import {useQuery} from '@tanstack/react-query';
import {getAgent} from '@/api/agent';
/**
* 查询 Agent 基础信息
* @param agentId Agent ID
* @returns Agent 查询结果
*/
export const useAgentQuery = (agentId?: string) => {
return useQuery({
queryKey: ['agent', agentId],
queryFn: () => getAgent(agentId!),
enabled: !!agentId,
staleTime: 60000, // 1分钟缓存
});
};
@@ -0,0 +1,17 @@
import {useQuery} from '@tanstack/react-query';
import {getAgentLatestMetrics} from '@/api/agent';
/**
* 查询 Agent 最新指标
* 自动每 5 秒刷新一次
* @param agentId Agent ID
* @returns 最新指标查询结果
*/
export const useLatestMetricsQuery = (agentId?: string) => {
return useQuery({
queryKey: ['agent', agentId, 'metrics', 'latest'],
queryFn: () => getAgentLatestMetrics(agentId!),
enabled: !!agentId,
refetchInterval: 5000, // 5秒自动刷新
});
};
@@ -0,0 +1,30 @@
import {useQuery} from '@tanstack/react-query';
import {getAgentMetrics, type GetAgentMetricsRequest} from '@/api/agent';
interface UseMetricsQueryOptions {
agentId: string;
type: GetAgentMetricsRequest['type'];
range: string;
interfaceName?: string;
}
/**
* 查询 Agent 历史指标数据
* 自动每 30 秒刷新一次
* @param options 查询选项
* @returns 历史指标查询结果
*/
export const useMetricsQuery = ({agentId, type, range, interfaceName}: UseMetricsQueryOptions) => {
return useQuery({
queryKey: ['agent', agentId, 'metrics', type, range, interfaceName],
queryFn: () =>
getAgentMetrics({
agentId,
type,
range,
interface: interfaceName,
}),
enabled: !!agentId,
refetchInterval: 30000, // 30秒自动刷新
});
};
@@ -0,0 +1,15 @@
import {useQuery} from '@tanstack/react-query';
import {getAvailableNetworkInterfaces} from '@/api/agent';
/**
* 查询 Agent 可用网卡列表
* @param agentId Agent ID
* @returns 网卡列表查询结果
*/
export const useNetworkInterfacesQuery = (agentId?: string) => {
return useQuery({
queryKey: ['agent', agentId, 'network-interfaces'],
queryFn: () => getAvailableNetworkInterfaces(agentId!),
enabled: !!agentId,
});
};
+62
View File
@@ -0,0 +1,62 @@
import {Shield} from "lucide-react";
import {cn} from "@/lib/utils.ts";
import type {CertStats} from "@/types";
import {formatDate} from "@/utils/util.ts";
export const renderCert = (stats: CertStats) => {
const hasCert = stats.certExpiryTime > 0;
const certExpired = hasCert && stats.certDaysLeft < 0;
const certExpiringSoon = hasCert && stats.certDaysLeft >= 0 && stats.certDaysLeft < 30;
if (!hasCert) {
return <span className="text-sm text-slate-400 dark:text-slate-500">-</span>
}
return <div className="flex items-center gap-2">
<Shield
className={cn(
"h-4 w-4",
certExpired
? 'text-red-600 dark:text-red-400'
: certExpiringSoon
? 'text-yellow-600 dark:text-yellow-500'
: 'text-slate-400 dark:text-slate-500'
)}/>
<div className="text-xs">
<div
className={cn(
"font-medium",
certExpired
? 'text-red-700 dark:text-red-300'
: certExpiringSoon
? 'text-yellow-700 dark:text-yellow-300'
: 'text-slate-700 dark:text-slate-200'
)}>
{formatDate(stats.certExpiryTime)}
</div>
<div
className={cn(
certExpired
? 'text-red-600 dark:text-red-400'
: certExpiringSoon
? 'text-yellow-600 dark:text-yellow-400'
: 'text-slate-500 dark:text-slate-400'
)}>
{certExpired ? `已过期 ${Math.abs(stats.certDaysLeft)} 天` : `剩余 ${stats.certDaysLeft} 天`}
</div>
</div>
</div>
}
export const UptimeBar = ({uptime}: { uptime: number }) => {
const percentage = Math.min(Math.max(uptime, 0), 100);
const colorClass = percentage >= 99 ? 'bg-emerald-500' : percentage >= 95 ? 'bg-yellow-500' : 'bg-red-500';
return (
<div className="relative h-2 w-full overflow-hidden rounded-lg bg-slate-100 dark:bg-slate-800">
<div
className={cn("absolute inset-y-0 left-0 transition-all duration-500", colorClass)}
style={{width: `${percentage}%`}}
/>
</div>
);
};
+28 -810
View File
@@ -1,348 +1,17 @@
import type {ReactNode} from 'react';
import {useEffect, useMemo, useState} from 'react';
import {useNavigate, useParams} from 'react-router-dom';
import {useQuery} from '@tanstack/react-query';
import {
AlertCircle,
ArrowLeft,
CheckCircle2,
Clock,
Globe,
Loader2,
Server as ServerIcon,
Shield,
TrendingUp
} from 'lucide-react';
import type {TooltipProps} from 'recharts';
import {Area, AreaChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis,} from 'recharts';
import {
type AggregatedMonitorMetric,
getMonitorAgentStats,
getMonitorHistory,
getMonitorStatsById
} from '@/api/monitor.ts';
import type {MonitorStats, PublicMonitor} from '@/types';
import {cn} from '@/lib/utils';
const formatTime = (ms: number): string => {
if (!ms || ms <= 0) return '0 ms';
if (ms < 1000) return `${ms.toFixed(0)} ms`;
return `${(ms / 1000).toFixed(2)} s`;
};
const formatDate = (timestamp: number): string => {
if (!timestamp) return '-';
const date = new Date(timestamp);
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
});
};
const formatDateTime = (timestamp: number): string => {
if (!timestamp) return '-';
const date = new Date(timestamp);
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
};
const formatPercentValue = (value: number): string => (Number.isFinite(value) ? value.toFixed(2) : '0.00');
const LoadingSpinner = () => (
<div className="flex min-h-screen items-center justify-center dark:bg-[#141414]">
<div className="flex flex-col items-center gap-3">
<Loader2 className="h-8 w-8 animate-spin text-slate-400 dark:text-slate-400"/>
<p className="text-sm text-slate-500 dark:text-slate-400">数据加载中,请稍候...</p>
</div>
</div>
);
const EmptyState = ({message = '监控数据不存在'}: { message?: string }) => (
<div className="flex min-h-screen items-center justify-center dark:bg-[#141414]">
<div className="flex flex-col items-center gap-3 text-center">
<div
className="flex h-16 w-16 items-center justify-center rounded-full text-slate-400 dark:text-slate-300">
<Shield className="h-8 w-8"/>
</div>
<p className="text-sm text-slate-500 dark:text-slate-400">{message}</p>
</div>
</div>
);
const timeRangeOptions = [
{label: '15分钟', value: '15m'},
{label: '30分钟', value: '30m'},
{label: '1小时', value: '1h'},
{label: '3小时', value: '3h'},
{label: '6小时', value: '6h'},
{label: '12小时', value: '12h'},
{label: '1天', value: '1d'},
{label: '3天', value: '3d'},
{label: '7天', value: '7d'},
]
const ChartPlaceholder = ({
icon: Icon = TrendingUp,
title = '暂无数据',
subtitle = '等待采集新数据后展示图表',
heightClass = 'h-80',
}: {
icon?: typeof TrendingUp;
title?: string;
subtitle?: string;
heightClass?: string;
}) => (
<div
className={cn(
"flex items-center justify-center rounded-lg border border-dashed border-slate-200 dark:border-slate-700 text-sm text-slate-500 dark:text-slate-400",
heightClass
)}
>
<div className="text-center">
<Icon className="mx-auto mb-3 h-10 w-10 text-slate-300 dark:text-slate-600"/>
<p>{title}</p>
{subtitle ? <p className="mt-1 text-xs text-slate-400 dark:text-slate-500">{subtitle}</p> : null}
</div>
</div>
);
const Card = ({
title,
description,
action,
children,
}: {
title?: string;
description?: string;
action?: ReactNode;
children: ReactNode;
}) => (
<section
className="rounded-3xl border border-slate-200 dark:border-slate-700 p-6">
{(title || description || action) && (
<div
className="flex flex-col gap-3 border-b border-slate-100 dark:border-slate-700 pb-4 sm:flex-row sm:items-start sm:justify-between">
<div>
{title ?
<h2 className="text-lg font-semibold text-slate-900 dark:text-white">{title}</h2> : null}
{description ?
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">{description}</p> : null}
</div>
{action ? <div className="shrink-0">{action}</div> : null}
</div>
)}
<div className="pt-4">{children}</div>
</section>
);
const StatusBadge = ({status}: { status: string }) => {
let containerClass = 'bg-slate-100 dark:bg-slate-800/80 text-slate-600 dark:text-slate-400';
let label = '未知';
let icon = <Clock className="h-4 w-4"/>;
if (status === 'up') {
containerClass = 'bg-emerald-50 dark:bg-emerald-500/15 text-emerald-700 dark:text-emerald-200';
label = '正常';
icon = <CheckCircle2 className="h-4 w-4"/>;
} else if (status === 'down') {
containerClass = 'bg-red-50 dark:bg-rose-500/15 text-red-700 dark:text-rose-200';
label = '异常';
icon = <AlertCircle className="h-4 w-4"/>;
}
return (
<div
className={cn(
"inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-sm font-medium",
containerClass
)}>
{icon}
{label}
</div>
);
};
const UptimeBar = ({uptime}: { uptime: number }) => {
const percentage = Math.min(Math.max(uptime, 0), 100);
const colorClass = percentage >= 99 ? 'bg-emerald-500' : percentage >= 95 ? 'bg-yellow-500' : 'bg-red-500';
return (
<div className="flex items-center gap-2">
<div className="relative h-3 w-full overflow-hidden rounded-lg bg-slate-100 dark:bg-slate-700">
<div
className={cn("absolute inset-y-0 left-0 transition-all duration-500", colorClass)}
style={{width: `${percentage}%`}}
/>
</div>
<span className="text-sm font-semibold text-slate-700 dark:text-slate-300 w-16 text-right">
{formatPercentValue(percentage)}%
</span>
</div>
);
};
const statThemes = {
blue: {
icon: 'bg-slate-200 dark:bg-slate-700 text-slate-700 dark:text-slate-300',
accent: 'text-slate-700 dark:text-slate-300',
},
emerald: {
icon: 'bg-slate-200 dark:bg-slate-700 text-slate-700 dark:text-slate-300',
accent: 'text-slate-700 dark:text-slate-300',
},
amber: {
icon: 'bg-slate-200 dark:bg-slate-700 text-slate-700 dark:text-slate-300',
accent: 'text-slate-700 dark:text-slate-300',
},
rose: {
icon: 'bg-slate-200 dark:bg-slate-700 text-slate-700 dark:text-slate-300',
accent: 'text-slate-700 dark:text-slate-300',
},
};
const StatCard = ({icon, label, value, color = 'blue'}: {
icon: ReactNode;
label: string;
value: string | number;
color?: string;
}) => {
const theme = statThemes[color as keyof typeof statThemes] ?? statThemes.blue;
return (
<div
className="rounded-2xl border border-slate-200 dark:border-slate-700 p-4 transition hover:-translate-y-0.5">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3">
<div className={cn("flex h-11 w-11 items-center justify-center rounded-xl", theme.icon)}>
{icon}
</div>
<div>
<div className="text-sm font-semibold text-slate-800 dark:text-white">
{label}
</div>
<div className="mt-1 text-xs text-slate-500 dark:text-slate-400">当前指标</div>
</div>
</div>
<span className={cn("text-xl font-bold", theme.accent)}>{value}</span>
</div>
</div>
);
};
const CustomTooltip = ({active, payload, label, unit = ' ms'}: TooltipProps<number, string> & {
unit?: string;
label?: string;
payload?: any[]
}) => {
if (!active || !payload || payload.length === 0) {
return null;
}
// 从 payload 中获取完整的时间戳信息(如果有的话)
const fullTimestamp = payload[0]?.payload?.timestamp;
const displayLabel = fullTimestamp
? new Date(fullTimestamp).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
: label;
return (
<div
className="rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 px-3 py-2 text-xs">
<p className="font-semibold text-slate-700 dark:text-white">{displayLabel}</p>
<div className="mt-1 space-y-1">
{payload.map((entry, index) => {
if (!entry) {
return null;
}
const dotColor = entry.color ?? '#6366f1';
const title = entry.name ?? entry.dataKey ?? `系列 ${index + 1}`;
const value =
typeof entry.value === 'number'
? Number.isFinite(entry.value)
? entry.value.toFixed(2)
: '-'
: entry.value;
return (
<p key={`${entry.dataKey ?? index}`}
className="flex items-center gap-2 text-slate-600 dark:text-slate-400">
<span
className="inline-block h-2 w-2 rounded-full"
style={{backgroundColor: dotColor}}
/>
<span>
{title}: {value}{unit}
</span>
</p>
);
})}
</div>
</div>
);
};
const TimeRangeSelector = ({
value,
onChange,
options,
}: {
value: string;
onChange: (value: any) => void;
options: readonly { label: string; value: string }[];
}) => (
<div className="flex flex-wrap items-center gap-2">
{options.map((option) => {
const isActive = option.value === value;
return (
<button
key={option.value}
type="button"
onClick={() => onChange(option.value)}
className={cn(
"rounded-lg border px-3 py-1.5 text-sm transition",
isActive
? 'border-slate-300 dark:border-slate-600 bg-slate-600 dark:bg-slate-700 text-white'
: 'border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-600 dark:text-slate-300 hover:border-slate-300 dark:hover:border-slate-600 hover:text-slate-700 dark:hover:text-slate-200'
)}
>
{option.label}
</button>
);
})}
</div>
);
// 预定义的颜色方案
const AGENT_COLORS = [
'#2563eb', // blue
'#10b981', // emerald
'#f59e0b', // amber
'#8b5cf6', // violet
'#ec4899', // pink
'#06b6d4', // cyan
'#f97316', // orange
'#14b8a6', // teal
];
import {getMonitorAgentStats, getMonitorStatsById} from '@/api/monitor.ts';
import type {AgentMonitorStat, PublicMonitor} from '@/types';
import {MonitorHero, ResponseTimeChart, AgentStatsTable} from '@/components/monitor';
import {EmptyState, LoadingSpinner} from '@/components/common';
/**
* 监控详情页面
* 显示监控的详细信息、响应时间趋势和各探针统计
*/
const MonitorDetail = () => {
const navigate = useNavigate();
const {id} = useParams<{ id: string }>();
const [selectedAgent, setSelectedAgent] = useState<string>('all');
const [timeRange, setTimeRange] = useState<string>('15m');
// 获取监控详情(聚合数据)
const {data: monitorDetail, isLoading} = useQuery<PublicMonitor>({
@@ -357,7 +26,7 @@ const MonitorDetail = () => {
});
// 获取各探针的统计数据
const {data: monitorStats = []} = useQuery<MonitorStats[]>({
const {data: monitorStats = []} = useQuery<AgentMonitorStat[]>({
queryKey: ['monitorAgentStats', id],
queryFn: async () => {
if (!id) return [];
@@ -368,487 +37,36 @@ const MonitorDetail = () => {
enabled: !!id,
});
// 获取历史数据
const {data: historyData = []} = useQuery<AggregatedMonitorMetric[]>({
queryKey: ['monitorHistory', id, timeRange],
queryFn: async () => {
if (!id) return [];
const response = await getMonitorHistory(id, timeRange);
return response.data || [];
},
refetchInterval: 30000,
enabled: !!id,
});
// 获取所有可用的探针列表
const availableAgents = useMemo(() => {
if (monitorStats.length === 0) return [];
return monitorStats.map(stat => ({
id: stat.agentId,
label: stat.agentName || stat.agentId.substring(0, 8),
}));
}, [monitorStats]);
// 当可用探针列表变化时,检查当前选择的探针是否还存在
useEffect(() => {
if (selectedAgent === 'all') {
return;
}
if (!availableAgents.find(agent => agent.id === selectedAgent)) {
setSelectedAgent('all');
}
}, [availableAgents, selectedAgent]);
// 生成图表数据
const chartData = useMemo(() => {
if (historyData.length === 0) return [];
// 按时间戳分组数据
const grouped = historyData.reduce((acc, item) => {
const time = new Date(item.timestamp).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
if (!acc[time]) {
acc[time] = {time, timestamp: item.timestamp};
}
// 根据选择的探针过滤
if (selectedAgent === 'all' || item.agentId === selectedAgent) {
const agentKey = `agent_${item.agentId}`;
acc[time][agentKey] = item.avgResponse;
}
return acc;
}, {} as Record<string, any>);
return Object.values(grouped);
}, [historyData, selectedAgent]);
if (isLoading) {
return <LoadingSpinner/>;
return <LoadingSpinner variant="dark"/>;
}
if (!monitorDetail) {
return <EmptyState/>;
return <EmptyState variant="dark"/>;
}
const monitorTitle = monitorDetail.name ?? '监控详情';
const hasCert = monitorDetail.certExpiryDate > 0;
const certExpired = hasCert && monitorDetail.certExpiryDays < 0;
const certExpiringSoon = hasCert && monitorDetail.certExpiryDays >= 0 && monitorDetail.certExpiryDays < 30;
const heroStats = [
{label: '监控类型', value: monitorDetail.type.toUpperCase()},
{label: '探针数量', value: `${monitorDetail.agentCount} 个`},
{label: '24h在线率', value: `${formatPercentValue(monitorDetail.uptime24h)}%`},
{label: '7d在线率', value: `${formatPercentValue(monitorDetail.uptime7d)}%`},
];
return (
<div className="dark:bg-[#141414]">
<div className="mx-auto flex max-w-7xl flex-col px-4 pb-10 pt-6 sm:px-6 lg:px-8">
{/* Hero Section */}
<section
className="relative overflow-hidden rounded-3xl bg-gradient-to-r from-slate-700 via-slate-600 to-slate-700 dark:from-slate-800 dark:via-slate-700 dark:to-slate-800 p-6 text-white">
<div
className="absolute inset-0 opacity-30 [background-image:radial-gradient(circle_at_top,rgba(255,255,255,0.35),transparent_55%)]"/>
<div className="relative flex flex-col gap-6">
<div className="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-4">
<button
type="button"
onClick={() => navigate('/monitors')}
className="group inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.3em] text-white/70 transition hover:text-white"
>
<ArrowLeft className="h-4 w-4 transition group-hover:-translate-x-0.5"/>
返回监控列表
</button>
<div className="flex items-start gap-4">
<div
className="flex h-14 w-14 items-center justify-center rounded-2xl bg-white/10 text-white">
{monitorDetail.type === 'tcp' ? (
<ServerIcon className="h-7 w-7"/>
) : (
<Globe className="h-7 w-7"/>
)}
</div>
<div>
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-3xl font-semibold">{monitorTitle}</h1>
</div>
<p className="mt-2 text-sm text-white/80">
{monitorDetail.target}
</p>
<p className="text-xs text-white/60">公共视图 · 实时监控概览</p>
</div>
</div>
</div>
<div className="grid w-full gap-3 sm:grid-cols-2 lg:w-auto lg:grid-cols-2 xl:grid-cols-4">
{heroStats.map((stat) => (
<div
key={stat.label}
className="rounded-2xl bg-white/10 dark:bg-white/10 p-4 text-left backdrop-blur"
>
<p className="text-[11px] uppercase tracking-[0.3em] text-white/70">{stat.label}</p>
<p className="mt-2 text-base font-semibold text-white">{stat.value}</p>
</div>
))}
</div>
</div>
<div className="flex flex-wrap items-center gap-3 text-xs text-white/70">
<span>监控 ID:{monitorDetail.id || id}</span>
<span className="hidden h-1 w-1 rounded-full bg-white/30 sm:inline-block"/>
<span>探针数量:{monitorDetail.agentCount} 个</span>
<span className="hidden h-1 w-1 rounded-full bg-white/30 sm:inline-block"/>
<span>目标:{monitorDetail.target}</span>
</div>
</div>
</section>
<main className="flex-1 py-10 space-y-10">
{/* 概览统计 */}
<Card title="监控概览" description="当前监控状态和关键指标">
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
<StatCard
icon={<Clock className="h-6 w-6"/>}
label="当前响应"
value={formatTime(monitorDetail.currentResponse)}
color="blue"
/>
<StatCard
icon={<Clock className="h-6 w-6"/>}
label="24h 平均响应"
value={formatTime(monitorDetail.avgResponse24h)}
color="blue"
/>
<StatCard
icon={<CheckCircle2 className="h-6 w-6"/>}
label="24h 在线率"
value={`${formatPercentValue(monitorDetail.uptime24h)}%`}
color={monitorDetail.uptime24h >= 99 ? 'emerald' : monitorDetail.uptime24h >= 95 ? 'amber' : 'rose'}
/>
<StatCard
icon={<CheckCircle2 className="h-6 w-6"/>}
label="7d 在线率"
value={`${formatPercentValue(monitorDetail.uptime7d)}%`}
color={monitorDetail.uptime7d >= 99 ? 'emerald' : monitorDetail.uptime7d >= 95 ? 'amber' : 'rose'}
/>
</div>
{/* 当前错误信息 */}
{monitorDetail.lastCheckStatus === 'down' && monitorDetail.lastCheckError && (
<div
className="mt-6 rounded-2xl border border-red-200 dark:border-red-500/40 bg-red-50 dark:bg-red-500/10 p-6">
<div className="flex items-start gap-3">
<AlertCircle
className="h-6 w-6 text-red-600 dark:text-red-200 flex-shrink-0 mt-0.5"/>
<div className="flex-1 min-w-0">
<h3 className="text-lg font-semibold text-red-900 dark:text-red-100">
监控异常
</h3>
<p className="mt-2 text-sm text-red-700 dark:text-red-200 break-words">
{monitorDetail.lastCheckError}
</p>
{monitorDetail.lastCheckTime && (
<p className="mt-2 text-xs text-red-600 dark:text-red-300">
检测时间: {formatDateTime(monitorDetail.lastCheckTime)}
</p>
)}
</div>
</div>
</div>
)}
{/* 证书信息 */}
{hasCert && (
<div className={cn(
"mt-6 rounded-2xl border p-6",
certExpired
? 'border-red-200 dark:border-red-500/40 bg-red-50 dark:bg-red-500/10'
: certExpiringSoon
? 'border-amber-200 dark:border-amber-500/40 bg-amber-50 dark:bg-amber-500/10'
: 'border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800/40'
)}>
<div className="flex items-center gap-3">
<Shield className={cn(
"h-6 w-6",
certExpired
? 'text-red-600 dark:text-red-200'
: certExpiringSoon
? 'text-amber-600 dark:text-amber-200'
: 'text-slate-600 dark:text-slate-400'
)}/>
<div>
<h3 className={cn(
"text-lg font-semibold",
certExpired
? 'text-red-900 dark:text-red-100'
: certExpiringSoon
? 'text-amber-900 dark:text-amber-100'
: 'text-slate-900 dark:text-white'
)}>
TLS 证书信息
</h3>
<p className={cn(
"mt-1 text-sm",
certExpired
? 'text-red-700 dark:text-red-200'
: certExpiringSoon
? 'text-amber-700 dark:text-amber-200'
: 'text-slate-600 dark:text-slate-400'
)}>
证书到期时间: {formatDate(monitorDetail.certExpiryDate)}
{certExpired ? (
<span
className="ml-1">(已过期 {Math.abs(monitorDetail.certExpiryDays)} 天)</span>
) : (
<span className="ml-1">(剩余 {monitorDetail.certExpiryDays} 天)</span>
)}
</p>
{certExpired && (
<p className="mt-2 text-sm font-medium text-red-700 dark:text-red-200">
🚨 证书已过期,请立即更新
</p>
)}
{certExpiringSoon && (
<p className="mt-2 text-sm font-medium text-amber-700 dark:text-amber-200">
⚠️ 证书即将过期,请及时更新
</p>
)}
</div>
</div>
</div>
)}
</Card>
<div className="bg-[#05050a] min-h-screen">
<div className="mx-auto flex max-w-7xl flex-col px-4 pb-10 pt-4 sm:pt-6 sm:px-6 lg:px-8">
{/* 头部区域 */}
<MonitorHero
monitor={monitorDetail}
onBack={() => navigate('/monitors')}
/>
{/* 主内容区 */}
<main className="flex-1 py-6 sm:py-8 lg:py-10 space-y-6 sm:space-y-8 lg:space-y-10">
{/* 响应时间趋势图表 */}
<Card
title="历史趋势"
description="监控各探针的响应时间变化"
action={
<div className="flex flex-wrap items-center gap-2">
<TimeRangeSelector value={timeRange} onChange={setTimeRange}
options={timeRangeOptions}/>
{availableAgents.length > 0 && (
<select
value={selectedAgent}
onChange={(e) => setSelectedAgent(e.target.value)}
className="rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 px-3 py-2 text-sm text-slate-700 dark:text-slate-200 hover:border-slate-300 dark:hover:border-slate-600 focus:border-slate-500 dark:focus:border-slate-500 focus:outline-none focus:ring-2 focus:ring-slate-200 dark:focus:ring-slate-600/40"
>
<option value="all">所有探针</option>
{availableAgents.map((agent) => (
<option key={agent.id} value={agent.id}>
{agent.label}
</option>
))}
</select>
)}
</div>
}
>
{chartData.length > 0 ? (
<ResponsiveContainer width="100%" height={320}>
<AreaChart data={chartData}>
<defs>
{monitorStats.map((stat, index) => {
const agentKey = `agent_${stat.agentId}`;
const color = AGENT_COLORS[index % AGENT_COLORS.length];
return (
<linearGradient key={agentKey} id={`gradient_${agentKey}`} x1="0" y1="0"
x2="0" y2="1">
<stop offset="5%" stopColor={color} stopOpacity={0.3}/>
<stop offset="95%" stopColor={color} stopOpacity={0}/>
</linearGradient>
);
})}
</defs>
<CartesianGrid stroke="currentColor" strokeDasharray="4 4"
className="stroke-slate-200 dark:stroke-slate-700"/>
<XAxis
dataKey="time"
stroke="currentColor"
className="stroke-slate-400 dark:stroke-slate-500"
style={{fontSize: '12px'}}
/>
<YAxis
stroke="currentColor"
className="stroke-slate-400 dark:stroke-slate-500"
style={{fontSize: '12px'}}
tickFormatter={(value) => `${value} ms`}
/>
<Tooltip content={<CustomTooltip unit=" ms"/>} wrapperStyle={{
zIndex: 50,
}}/>
<Legend
wrapperStyle={{
display: 'none',
}}
className="hidden sm:block"
/>
{monitorStats
.filter(stat => selectedAgent === 'all' || stat.agentId === selectedAgent)
.map((stat) => {
// 使用原始索引保持颜色一致性
const originalIndex = monitorStats.findIndex(s => s.agentId === stat.agentId);
const agentKey = `agent_${stat.agentId}`;
const color = AGENT_COLORS[originalIndex % AGENT_COLORS.length];
const agentLabel = stat.agentName || stat.agentId.substring(0, 8);
return (
<Area
key={agentKey}
type="monotone"
dataKey={agentKey}
name={`探针 ${agentLabel}`}
stroke={color}
strokeWidth={2}
fill={`url(#gradient_${agentKey})`}
activeDot={{r: 4}}
/>
);
})}
</AreaChart>
</ResponsiveContainer>
) : (
<ChartPlaceholder
subtitle="正在收集数据,请稍后查看历史趋势"
heightClass="h-80"
/>
)}
</Card>
<ResponseTimeChart
monitorId={id!}
monitorStats={monitorStats}
/>
{/* 各探针详细数据 */}
<Card title="探针监控详情" description="各探针的当前状态和统计数据">
<div className="overflow-x-auto -mx-6 sm:mx-0">
<div className="inline-block min-w-full align-middle">
<div className="overflow-hidden">
<table className="min-w-full divide-y divide-slate-200 dark:divide-slate-800">
<thead className="dark:bg-slate-800">
<tr>
<th className="whitespace-nowrap px-4 sm:px-6 py-3 sm:py-4 text-left text-xs sm:text-sm font-semibold text-slate-700 dark:text-white">
探针 ID
</th>
<th className="whitespace-nowrap px-4 sm:px-6 py-3 sm:py-4 text-left text-xs sm:text-sm font-semibold text-slate-700 dark:text-slate-300">
状态
</th>
<th className="whitespace-nowrap px-4 sm:px-6 py-3 sm:py-4 text-left text-xs sm:text-sm font-semibold text-slate-700 dark:text-slate-300">
当前响应
</th>
<th className="whitespace-nowrap px-4 sm:px-6 py-3 sm:py-4 text-left text-xs sm:text-sm font-semibold text-slate-700 dark:text-slate-300">
24h 在线率
</th>
<th className="whitespace-nowrap px-4 sm:px-6 py-3 sm:py-4 text-left text-xs sm:text-sm font-semibold text-slate-700 dark:text-slate-300 hidden lg:table-cell">
7d 在线率
</th>
<th className="whitespace-nowrap px-4 sm:px-6 py-3 sm:py-4 text-left text-xs sm:text-sm font-semibold text-slate-700 dark:text-slate-300 hidden md:table-cell">
24h 检测
</th>
<th className="whitespace-nowrap px-4 sm:px-6 py-3 sm:py-4 text-left text-xs sm:text-sm font-semibold text-slate-700 dark:text-slate-300 hidden xl:table-cell">
最后检测
</th>
<th className="whitespace-nowrap px-4 sm:px-6 py-3 sm:py-4 text-left text-xs sm:text-sm font-semibold text-slate-700 dark:text-slate-300 hidden 2xl:table-cell">
错误信息
</th>
</tr>
</thead>
<tbody
className="divide-y divide-slate-100 dark:divide-slate-700">
{monitorStats.map((stats, index) => {
const color = AGENT_COLORS[index % AGENT_COLORS.length];
return (
<tr key={stats.id}
className="transition-colors">
<td className="whitespace-nowrap px-4 sm:px-6 py-3 sm:py-4">
<div className="flex items-center gap-2">
<span
className="inline-block h-2 w-2 sm:h-3 sm:w-3 rounded-full flex-shrink-0"
style={{backgroundColor: color}}
/>
<div className="flex flex-col min-w-0">
{stats.agentName ? (
<>
<span
className="text-xs sm:text-sm font-medium text-slate-900 dark:text-white truncate">
{stats.agentName}
</span>
<span
className="font-mono text-[10px] sm:text-xs text-slate-500 dark:text-slate-400">
{stats.agentId.substring(0, 8)}...
</span>
</>
) : (
<span
className="font-mono text-xs sm:text-sm text-slate-700 dark:text-slate-300">
{stats.agentId.substring(0, 8)}...
</span>
)}
</div>
</div>
</td>
<td className="whitespace-nowrap px-4 sm:px-6 py-3 sm:py-4">
<StatusBadge status={stats.lastCheckStatus}/>
</td>
<td className="whitespace-nowrap px-4 sm:px-6 py-3 sm:py-4">
<div className="flex items-center gap-1 sm:gap-2">
<Clock
className="h-3 w-3 sm:h-4 sm:w-4 text-slate-400 dark:text-slate-500 flex-shrink-0"/>
<span
className="text-xs sm:text-sm font-medium text-slate-900 dark:text-white">
{formatTime(stats.currentResponse)}
</span>
</div>
</td>
<td className="whitespace-nowrap px-4 sm:px-6 py-3 sm:py-4">
<div className="w-24 sm:w-32">
<UptimeBar uptime={stats.uptime24h}/>
</div>
</td>
<td className="whitespace-nowrap px-4 sm:px-6 py-3 sm:py-4 hidden lg:table-cell">
<div className="w-24 sm:w-32">
<UptimeBar uptime={stats.uptime7d}/>
</div>
</td>
<td className="whitespace-nowrap px-4 sm:px-6 py-3 sm:py-4 hidden md:table-cell">
<div
className="text-xs sm:text-sm text-slate-700 dark:text-slate-300">
{stats.successChecks24h} / {stats.totalChecks24h}
</div>
</td>
<td className="whitespace-nowrap px-4 sm:px-6 py-3 sm:py-4 hidden xl:table-cell">
<div
className="text-xs sm:text-sm text-slate-700 dark:text-slate-300">
{formatDateTime(stats.lastCheckTime)}
</div>
</td>
<td className="px-4 sm:px-6 py-3 sm:py-4 hidden 2xl:table-cell">
{stats.lastCheckStatus === 'down' && stats.lastCheckError ? (
<div className="max-w-xs">
<div className="flex items-start gap-2">
<AlertCircle
className="h-4 w-4 text-red-500 dark:text-red-400 flex-shrink-0 mt-0.5"/>
<span
className="text-xs sm:text-sm text-red-700 dark:text-red-300 break-words">
{stats.lastCheckError}
</span>
</div>
</div>
) : (
<span
className="text-xs sm:text-sm text-slate-500 dark:text-slate-400">-</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</div>
</Card>
<AgentStatsTable
monitorStats={monitorStats}
monitorType={monitorDetail.type}
/>
</main>
</div>
</div>
+333 -360
View File
@@ -1,389 +1,255 @@
import {useEffect} from 'react';
import {useNavigate} from 'react-router-dom';
import {useEffect, useMemo, useState} from 'react';
import {Link, useNavigate} from 'react-router-dom';
import {useQuery} from '@tanstack/react-query';
import {AlertCircle, CheckCircle2, Clock, Loader2, Shield} from 'lucide-react';
import {getPublicMonitors} from '@/api/monitor.ts';
import {
Activity,
AlertCircle,
BarChart3,
CheckCircle2,
Clock,
Globe,
Loader2,
Maximize2,
Search,
Server,
Shield,
ShieldCheck,
Wifi
} from 'lucide-react';
import {type GetMetricsResponse, getMonitorHistory, getPublicMonitors} from '@/api/monitor.ts';
import type {PublicMonitor} from '@/types';
import {usePublicLayout} from '../PublicLayout';
import {cn} from '@/lib/utils';
const formatTime = (ms: number): string => {
if (!ms || ms <= 0) return '0 ms';
if (ms < 1000) return `${ms.toFixed(0)} ms`;
return `${(ms / 1000).toFixed(2)} s`;
};
const formatDate = (timestamp: number): string => {
if (!timestamp) return '-';
const date = new Date(timestamp);
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
});
};
const formatPercentValue = (value: number): string => (Number.isFinite(value) ? value.toFixed(2) : '0.00');
import {formatDateTime} from "@/utils/util.ts";
import {StatusBadge} from '@/components/monitor/StatusBadge';
import {CertBadge} from '@/components/monitor/CertBadge';
import {MiniChart} from '@/components/monitor/MiniChart';
import StatCard from "@/components/StatCard.tsx";
import CyberCard from "@/components/CyberCard.tsx";
const LoadingSpinner = () => (
<div className="flex min-h-[400px] w-full items-center justify-center">
<div className="flex flex-col items-center gap-3 text-slate-600 dark:text-slate-400">
<Loader2 className="h-8 w-8 animate-spin"/>
<span className="text-sm">加载监控数据中...</span>
<div className="flex flex-col items-center gap-3 text-cyan-600">
<Loader2 className="h-8 w-8 animate-spin text-cyan-400"/>
<span className="text-sm font-mono">加载监控数据中...</span>
</div>
</div>
);
const StatusBadge = ({status}: { status: string }) => {
let containerClass = 'bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-300';
let label = '未知';
let icon = <Clock className="h-3.5 w-3.5"/>;
if (status === 'up') {
containerClass = 'bg-emerald-50 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300';
label = '正常';
icon = <CheckCircle2 className="h-3.5 w-3.5"/>;
} else if (status === 'down') {
containerClass = 'bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-300';
label = '异常';
icon = <AlertCircle className="h-3.5 w-3.5"/>;
}
return (
<div
className={cn(
"inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium",
containerClass
)}>
{icon}
{label}
</div>
);
};
const UptimeBar = ({uptime}: { uptime: number }) => {
const percentage = Math.min(Math.max(uptime, 0), 100);
const colorClass = percentage >= 99 ? 'bg-emerald-500' : percentage >= 95 ? 'bg-yellow-500' : 'bg-red-500';
return (
<div className="relative h-2 w-full overflow-hidden rounded-lg bg-slate-100 dark:bg-slate-800">
<div
className={cn("absolute inset-y-0 left-0 transition-all duration-500", colorClass)}
style={{width: `${percentage}%`}}
/>
</div>
);
};
const EmptyState = () => (
<div className="flex min-h-[400px] flex-col items-center justify-center text-slate-500 dark:text-slate-400">
<div className="flex min-h-[400px] flex-col items-center justify-center text-cyan-500">
<Shield className="mb-4 h-16 w-16 opacity-20"/>
<p className="text-lg font-medium">暂无监控数据</p>
<p className="mt-2 text-sm">请先在管理后台添加监控任务</p>
<p className="text-lg font-medium font-mono">暂无监控数据</p>
<p className="mt-2 text-sm text-cyan-600">请先在管理后台添加监控任务</p>
</div>
);
type DisplayMode = 'avg' | 'max';
type FilterStatus = 'all' | 'up' | 'down' | 'unknown';
// 类型图标组件
const TypeIcon = ({type}: { type: string }) => {
switch (type.toUpperCase()) {
case 'HTTPS':
return <ShieldCheck className="w-4 h-4 text-purple-400"/>;
case 'HTTP':
return <Globe className="w-4 h-4 text-blue-400"/>;
case 'TCP':
return <Server className="w-4 h-4 text-amber-400"/>;
case 'ICMP':
return <Wifi className="w-4 h-4 text-cyan-400"/>;
default:
return <Activity className="w-4 h-4 text-slate-400"/>;
}
};
// 监控卡片组件
const MonitorCard = ({monitor, displayMode}: {
monitor: PublicMonitor;
displayMode: DisplayMode;
}) => {
// 为每个监控卡片查询历史数据
const {data: historyData} = useQuery<GetMetricsResponse>({
queryKey: ['monitorHistory', monitor.id, '1h'],
queryFn: async () => {
const response = await getMonitorHistory(monitor.id, '1h');
return response.data;
},
refetchInterval: 60000,
staleTime: 30000,
});
// 将 VictoriaMetrics 的时序数据转换为图表数据
const chartData = useMemo(() => {
if (!historyData || !historyData.series || historyData.series?.length === 0) {
return [];
}
const timeMap = new Map<number, number[]>();
historyData.series?.forEach(series => {
if (series.data && series.data.length > 0) {
series.data.forEach(point => {
if (!timeMap.has(point.timestamp)) {
timeMap.set(point.timestamp, []);
}
timeMap.get(point.timestamp)!.push(point.value);
});
}
});
const result = Array.from(timeMap.entries())
.map(([timestamp, values]) => {
let aggregatedValue: number;
if (displayMode === 'avg') {
aggregatedValue = Math.round(values.reduce((a, b) => a + b, 0) / values.length);
} else {
aggregatedValue = Math.max(...values);
}
return {
time: new Date(timestamp).toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'}),
value: aggregatedValue,
};
});
return result;
}, [historyData, displayMode]);
const displayValue = displayMode === 'avg' ? monitor.responseTime : monitor.responseTimeMax;
const displayLabel = displayMode === 'avg' ? '平均延迟' : '最差节点延迟';
return (
<CyberCard className={'p-5'} animation={true} hover={true}>
{/* 头部 */}
<div className="flex justify-between items-start mb-4">
<div className="flex gap-3 flex-1 min-w-0">
<div
className="p-2.5 bg-cyan-950/30 border border-cyan-500/20 rounded-lg flex-shrink-0">
<TypeIcon type={monitor.type}/>
</div>
<div className="flex-1 min-w-0">
<div className="text-xs font-mono text-cyan-500/60 mb-0.5 tracking-wider">
TARGET_ID: {monitor.id.toString().substring(0, 8)}
</div>
<h3 className="font-bold text-sm text-cyan-100 tracking-wide truncate group-hover:text-cyan-400 transition-colors">
{monitor.name}
</h3>
</div>
</div>
<div className="flex-shrink-0 ml-2">
<StatusBadge status={monitor.status}/>
</div>
</div>
{/* 指标信息 */}
<div className="grid grid-cols-2 gap-4 mb-4">
<div>
<p className="text-xs text-cyan-500/60 mb-1 flex items-center gap-1">
{displayLabel}
{monitor.agentCount > 0 && (
<span
className="bg-slate-700 text-[10px] px-1.5 rounded-full text-cyan-300">
{monitor.agentCount} 节点
</span>
)}
</p>
<p className={cn(
"text-xl font-bold flex items-end gap-1 font-mono",
displayValue > 200 ? 'text-amber-400' : 'text-cyan-100'
)}>
{displayValue} <span className="text-xs font-normal text-cyan-600 mb-1">ms</span>
</p>
</div>
<div>
{monitor.type === 'https' && monitor.certExpiryTime ? (
<>
<p className="text-xs text-cyan-500/60 mb-1">SSL 证书</p>
<CertBadge
expiryTime={monitor.certExpiryTime}
daysLeft={monitor.certDaysLeft}
/>
</>
) : (
<>
<p className="text-xs text-cyan-500/60 mb-1">上次检测</p>
<p className="text-sm font-medium text-cyan-300 font-mono">
{formatDateTime(monitor.lastCheckTime)}
</p>
</>
)}
</div>
</div>
{/* 迷你走势图 */}
<MiniChart
data={chartData}
status={monitor.status}
id={monitor.id}
/>
</CyberCard>
);
};
interface Stats {
total: number;
online: number;
issues: number;
avgLatency: number;
}
const MonitorList = () => {
const navigate = useNavigate();
const {viewMode, setShowViewToggle} = usePublicLayout();
const [filter, setFilter] = useState<FilterStatus>('all');
const [searchKeyword, setSearchKeyword] = useState('');
const [displayMode, setDisplayMode] = useState<DisplayMode>('max');
// 挂载时启用视图切换,卸载时禁用
useEffect(() => {
setShowViewToggle(true);
return () => setShowViewToggle(false);
}, [setShowViewToggle]);
const {data: monitors = [], isLoading, dataUpdatedAt} = useQuery<PublicMonitor[]>({
const {data: monitors = [], isLoading} = useQuery<PublicMonitor[]>({
queryKey: ['publicMonitors'],
queryFn: async () => {
const response = await getPublicMonitors();
return response.data || [];
},
refetchInterval: 30000, // 30秒刷新一次
refetchInterval: 30000,
});
const monitorSummaries = monitors;
let [stats, setStats] = useState<Stats>();
const renderGridView = () => (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
{monitorSummaries.map((stats) => {
const hasCert = stats.certExpiryDate > 0;
const certExpired = hasCert && stats.certExpiryDays < 0;
const certExpiringSoon = hasCert && stats.certExpiryDays >= 0 && stats.certExpiryDays < 30;
// 过滤和搜索
const filteredMonitors = useMemo(() => {
let result = monitors;
return (
<div
key={stats.id}
role="button"
tabIndex={0}
onClick={() => navigate(`/monitors/${encodeURIComponent(stats.id)}`)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
navigate(`/monitors/${encodeURIComponent(stats.id)}`);
}
}}
className="group relative flex h-full cursor-pointer flex-col gap-4 rounded-2xl border border-slate-200 dark:border-slate-700 bg-transparent p-5 transition duration-200 hover:border-slate-300 dark:hover:border-slate-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-200"
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="text-base font-semibold text-slate-900 dark:text-white truncate">
{stats.name}
</h3>
<StatusBadge status={stats.lastCheckStatus}/>
</div>
<p className="mt-1 text-sm text-blue-700 dark:text-blue-400 truncate" title={stats.showTargetPublic ? stats.target : '已隐藏'}>
{stats.target}
</p>
{stats.agentCount > 1 && (
<p className="mt-1 text-xs text-slate-400 dark:text-slate-500">
{stats.agentCount} 个探针
</p>
)}
</div>
</div>
// 状态过滤
if (filter !== 'all') {
result = result.filter(m => m.status === filter);
}
<div className="space-y-3">
<div
className="flex items-center justify-between rounded-lg border border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/40 px-3 py-2.5">
<div className="flex items-center gap-2">
<div
className="flex h-7 w-7 items-center justify-center rounded-lg bg-slate-200 dark:bg-slate-700 text-slate-700 dark:text-slate-300">
<Clock className="h-3.5 w-3.5"/>
</div>
<span className="text-xs font-medium text-slate-600 dark:text-slate-300">当前响应</span>
</div>
<span className="text-sm font-bold text-slate-900 dark:text-white">
{formatTime(stats.currentResponse)}
</span>
</div>
// 搜索过滤
if (searchKeyword.trim()) {
const keyword = searchKeyword.toLowerCase();
result = result.filter(m =>
m.name.toLowerCase().includes(keyword) ||
m.target.toLowerCase().includes(keyword)
);
}
<div
className="flex items-center justify-between rounded-lg border border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/40 px-3 py-2.5">
<div className="flex items-center gap-2">
<div
className="flex h-7 w-7 items-center justify-center rounded-lg bg-slate-200 dark:bg-slate-700 text-slate-700 dark:text-slate-300">
<Clock className="h-3.5 w-3.5"/>
</div>
<span className="text-xs font-medium text-slate-600 dark:text-slate-300">24h 平均</span>
</div>
<span className="text-sm font-bold text-slate-900 dark:text-white">
{formatTime(stats.avgResponse24h)}
</span>
</div>
return result;
}, [monitors, filter, searchKeyword]);
<div className="space-y-2">
<div className="flex items-center justify-between text-xs">
<span className="font-medium text-slate-600 dark:text-slate-300">24h 在线率</span>
<span
className="font-semibold text-slate-900 dark:text-white">{formatPercentValue(stats.uptime24h)}%</span>
</div>
<UptimeBar uptime={stats.uptime24h}/>
</div>
// 统计信息
const calculateStats = (monitors: PublicMonitor[]) => {
const total = monitors.length;
const online = monitors.filter(m => m.status === 'up').length;
const issues = total - online;
const avgLatency = total > 0
? Math.round(monitors.reduce((acc, curr) => acc + curr.responseTime, 0) / total)
: 0;
return {total, online, issues, avgLatency};
}
<div className="space-y-2">
<div className="flex items-center justify-between text-xs">
<span className="font-medium text-slate-600 dark:text-slate-300">7d 在线率</span>
<span
className="font-semibold text-slate-900 dark:text-white">{formatPercentValue(stats.uptime7d)}%</span>
</div>
<UptimeBar uptime={stats.uptime7d}/>
</div>
{hasCert && (
<div
className={cn(
"flex items-center justify-between rounded-lg border px-3 py-2.5",
certExpired
? 'border-red-200 dark:border-red-500/40 bg-red-50 dark:bg-red-500/10'
: certExpiringSoon
? 'border-yellow-200 dark:border-yellow-500/40 bg-yellow-50 dark:bg-yellow-500/10'
: 'border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/40'
)}>
<div className="flex items-center gap-2">
<Shield
className={cn(
"h-4 w-4",
certExpired
? 'text-red-600 dark:text-red-400'
: certExpiringSoon
? 'text-yellow-600 dark:text-yellow-500'
: 'text-slate-600 dark:text-slate-400'
)}/>
<span className="text-xs font-medium text-slate-600 dark:text-slate-300">证书到期</span>
</div>
<div className="text-right">
<div
className={cn(
"text-xs font-medium",
certExpired
? 'text-red-700 dark:text-red-300'
: certExpiringSoon
? 'text-yellow-700 dark:text-yellow-300'
: 'text-slate-700 dark:text-slate-200'
)}>
{formatDate(stats.certExpiryDate)}
</div>
<div
className={cn(
"text-xs",
certExpired
? 'text-red-600 dark:text-red-400'
: certExpiringSoon
? 'text-yellow-600 dark:text-yellow-400'
: 'text-slate-500 dark:text-slate-400'
)}>
{certExpired ? `已过期 ${Math.abs(stats.certExpiryDays)} 天` : `剩余 ${stats.certExpiryDays} 天`}
</div>
</div>
</div>
)}
</div>
</div>
);
})}
</div>
);
const renderListView = () => (
<>
{/* 桌面端:使用表格布局 */}
<div className="hidden overflow-hidden rounded-md border border-slate-200 dark:border-slate-700 lg:block">
<table className="min-w-full divide-y divide-slate-200 dark:divide-slate-700 text-sm">
<thead className="bg-slate-50 dark:bg-slate-800">
<tr className="text-left text-xs font-semibold uppercase tracking-wide text-slate-700 dark:text-slate-300">
<th className="px-5 py-3">监控项</th>
<th className="px-5 py-3">状态</th>
<th className="px-5 py-3">当前响应</th>
<th className="px-5 py-3">24h 平均响应</th>
<th className="px-5 py-3">24h 在线率</th>
<th className="px-5 py-3">7d 在线率</th>
<th className="px-5 py-3">证书信息</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200 dark:divide-slate-700 text-slate-700 dark:text-slate-200">
{monitorSummaries.map((stats) => {
const hasCert = stats.certExpiryDate > 0;
const certExpired = hasCert && stats.certExpiryDays < 0;
const certExpiringSoon = hasCert && stats.certExpiryDays >= 0 && stats.certExpiryDays < 30;
return (
<tr
key={stats.id}
tabIndex={0}
onClick={() => navigate(`/monitors/${encodeURIComponent(stats.id)}`)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
navigate(`/monitors/${encodeURIComponent(stats.id)}`);
}
}}
className="cursor-pointer transition hover:bg-slate-50 dark:hover:bg-slate-800/50 focus-within:bg-slate-50 dark:focus-within:bg-slate-800/50 focus:outline-none focus-visible:ring-2 focus-visible:ring-slate-200"
>
<td className="px-5 py-4 align-center">
<div>
<div className="font-semibold text-slate-900 dark:text-white">
{stats.name}
</div>
<div className="mt-1 text-xs text-blue-700 dark:text-blue-400 break-all">
{stats.target}
</div>
{stats.agentCount > 1 && (
<div className="mt-1 text-xs text-slate-400 dark:text-slate-500">
{stats.agentCount} 个探针
</div>
)}
</div>
</td>
<td className="px-5 py-4 align-center">
<StatusBadge status={stats.lastCheckStatus}/>
</td>
<td className="px-5 py-4 align-center">
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-slate-400 dark:text-slate-500"/>
<span className="text-sm font-semibold text-slate-900 dark:text-white">
{formatTime(stats.currentResponse)}
</span>
</div>
</td>
<td className="px-5 py-4 align-center">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">
{formatTime(stats.avgResponse24h)}
</span>
</td>
<td className="px-5 py-4 align-center">
<div className="flex flex-col gap-2">
<div className="flex items-center gap-3">
<div className="w-24">
<UptimeBar uptime={stats.uptime24h}/>
</div>
<span className="text-xs font-semibold text-slate-900 dark:text-white w-14 text-right">
{formatPercentValue(stats.uptime24h)}%
</span>
</div>
</div>
</td>
<td className="px-5 py-4 align-center">
<div className="flex flex-col gap-2">
<div className="flex items-center gap-3">
<div className="w-24">
<UptimeBar uptime={stats.uptime7d}/>
</div>
<span className="text-xs font-semibold text-slate-900 dark:text-white w-14 text-right">
{formatPercentValue(stats.uptime7d)}%
</span>
</div>
</div>
</td>
<td className="px-5 py-4 align-center">
{hasCert ? (
<div className="flex items-center gap-2">
<Shield
className={cn(
"h-4 w-4",
certExpired
? 'text-red-600 dark:text-red-400'
: certExpiringSoon
? 'text-yellow-600 dark:text-yellow-500'
: 'text-slate-400 dark:text-slate-500'
)}/>
<div className="text-xs">
<div
className={cn(
"font-medium",
certExpired
? 'text-red-700 dark:text-red-300'
: certExpiringSoon
? 'text-yellow-700 dark:text-yellow-300'
: 'text-slate-700 dark:text-slate-200'
)}>
{formatDate(stats.certExpiryDate)}
</div>
<div
className={cn(
certExpired
? 'text-red-600 dark:text-red-400'
: certExpiringSoon
? 'text-yellow-600 dark:text-yellow-400'
: 'text-slate-500 dark:text-slate-400'
)}>
{certExpired ? `已过期 ${Math.abs(stats.certExpiryDays)} 天` : `剩余 ${stats.certExpiryDays} 天`}
</div>
</div>
</div>
) : (
<span className="text-sm text-slate-400 dark:text-slate-500">-</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</>
);
useEffect(() => {
let stats = calculateStats(monitors);
setStats(stats);
}, [monitors]);
if (isLoading) {
return (
@@ -394,13 +260,120 @@ const MonitorList = () => {
}
return (
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-8 space-y-6">
{monitorSummaries.length === 0 ? (
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-8">
{/* 统计卡片 */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
<StatCard
title="监控服务总数"
value={stats?.total}
icon={Server}
color="gray"
/>
<StatCard
title="系统正常"
value={stats?.online}
icon={CheckCircle2}
color="emerald"
/>
<StatCard
title="异常服务"
value={stats?.issues}
icon={AlertCircle}
color="rose"
/>
<StatCard
title="全局平均延迟"
value={`${stats?.avgLatency}ms`}
icon={Clock}
color="blue"
/>
</div>
{/* 过滤和搜索 */}
<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-black/40 p-1 rounded-lg border 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-cyan-500/20 text-cyan-300 border border-cyan-500/30'
: 'text-cyan-600 hover:text-cyan-400'
)}
>
{labels[f]}
</button>
);
})}
</div>
{/* 显示模式切换 */}
<div className="flex gap-1 bg-black/40 p-1 rounded-lg border border-cyan-900/50 items-center">
<span className="text-xs text-cyan-600 px-2 font-mono">卡片指标:</span>
<button
onClick={() => setDisplayMode('avg')}
className={cn(
"px-3 py-1.5 text-xs font-medium rounded transition-all flex items-center gap-1 font-mono cursor-pointer",
displayMode === 'avg'
? 'bg-cyan-500/20 text-cyan-300 border border-cyan-500/30'
: 'text-cyan-600 hover:text-cyan-400'
)}
>
<BarChart3 className="w-3 h-3"/> 平均
</button>
<button
onClick={() => setDisplayMode('max')}
className={cn(
"px-3 py-1.5 text-xs font-medium rounded transition-all flex items-center gap-1 font-mono cursor-pointer",
displayMode === 'max'
? 'bg-cyan-500/20 text-cyan-300 border border-cyan-500/30'
: 'text-cyan-600 hover:text-cyan-400'
)}
>
<Maximize2 className="w-3 h-3"/> 最差(Max)
</button>
</div>
</div>
{/* 搜索框 */}
<div className="relative w-full md:w-64 group">
<div
className="absolute -inset-0.5 bg-gradient-to-r from-cyan-500 to-blue-600 rounded-lg blur opacity-20 group-hover:opacity-40 transition duration-500"></div>
<div className="relative flex items-center bg-[#0a0b10] rounded-lg border border-cyan-900">
<Search className="w-4 h-4 ml-3 text-cyan-600"/>
<input
type="text"
placeholder="搜索服务名称或地址..."
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
className="w-full bg-transparent border-none text-xs text-cyan-100 p-2.5 focus:ring-0 placeholder-cyan-800 font-mono focus:outline-none"
/>
</div>
</div>
</div>
{/* 监控卡片列表 */}
{filteredMonitors.length === 0 ? (
<EmptyState/>
) : viewMode === 'grid' ? (
renderGridView()
) : (
renderListView()
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredMonitors.map(monitor => (
<Link to={`/monitors/${monitor.id}`}>
<MonitorCard
key={monitor.id}
monitor={monitor}
displayMode={displayMode}
/>
</Link>
))}
</div>
)}
</div>
);
File diff suppressed because it is too large. Load diff
+305 -526
View File
@@ -1,59 +1,38 @@
import {type ReactNode, useEffect, useState} from 'react';
import {Link, useNavigate} from 'react-router-dom';
import {type ReactNode, useMemo, useState} from 'react';
import {useNavigate} from 'react-router-dom';
import {useQuery} from '@tanstack/react-query';
import {Cpu, EthernetPortIcon, HardDrive, Loader2, MemoryStick, Network, Database} from 'lucide-react';
import {listAgents, getPublicTags} from '@/api/agent.ts';
import {
AlertTriangle,
ArrowDown,
ArrowUp,
Calendar,
CheckCircle2,
Cpu,
Filter,
HardDrive,
Loader2,
MemoryStick,
Server,
XCircle
} from 'lucide-react';
import {getPublicTags, listAgents} from '@/api/agent.ts';
import type {Agent, LatestMetrics} from '@/types';
import {usePublicLayout} from '../PublicLayout';
import {cn} from '@/lib/utils';
import CompactResourceBar from "@/components/CompactResourceBar.tsx";
import StatCard from "@/components/StatCard.tsx";
import ServerCard from "@/components/ServerCard.tsx";
import NetworkStatCard from "@/components/NetworkStatCard.tsx";
import {formatBytes, formatSpeed} from "@/utils/util.ts";
interface AgentWithMetrics extends Agent {
metrics?: LatestMetrics;
}
const formatSpeed = (bytesPerSecond: number): string => {
if (!bytesPerSecond || bytesPerSecond <= 0) return '0 B/s';
const k = 1024;
const sizes = ['B/s', 'K/s', 'M/s', 'G/s', 'T/s'];
const i = Math.min(Math.floor(Math.log(bytesPerSecond) / Math.log(k)), sizes.length - 1);
const value = bytesPerSecond / Math.pow(k, i);
// 根据数值大小调整精度,避免过长
const decimals = value >= 100 ? 0 : value >= 10 ? 1 : 2;
return `${value.toFixed(decimals)} ${sizes[i]}`;
};
const formatTraffic = (bytesPerSecond: number): string => {
if (!bytesPerSecond || bytesPerSecond <= 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.min(Math.floor(Math.log(bytesPerSecond) / Math.log(k)), sizes.length - 1);
return `${(bytesPerSecond / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`;
};
const formatBytes = (bytes: number): string => {
if (!bytes || bytes <= 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1);
return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`;
};
const formatPercentValue = (value: number): string => (Number.isFinite(value) ? value.toFixed(1) : '0.0');
const ProgressBar = ({percent, colorClass}: { percent: number; colorClass: string }) => (
<div className="relative h-2 w-full overflow-hidden rounded-lg bg-slate-100 dark:bg-slate-800">
<div
className={`absolute inset-y-0 left-0 ${colorClass} transition-all duration-500`}
style={{width: `${Math.min(Math.max(percent, 0), 100)}%`}}
/>
</div>
);
const LoadingSpinner = () => (
<div className="flex min-h-screen items-center justify-center bg-white dark:bg-[#141414]">
<div className="flex min-h-screen items-center justify-center bg-[#05050a]">
<div className="flex flex-col items-center gap-3">
<Loader2 className="h-8 w-8 animate-spin text-slate-400"/>
<p className="text-sm text-slate-500 dark:text-slate-400">数据加载中,请稍候...</p>
<Loader2 className="h-8 w-8 animate-spin text-cyan-400"/>
<p className="text-sm text-cyan-500 font-mono">数据加载中...</p>
</div>
</div>
);
@@ -66,12 +45,12 @@ interface EmptyStateProps {
const EmptyState = ({title, description, extra}: EmptyStateProps) => (
<div
className="flex flex-col items-center justify-center rounded-2xl border border-dashed border-slate-200 dark:border-slate-700 bg-white dark:bg-[#141414] p-12 text-center backdrop-blur">
<div className="flex h-16 w-16 items-center justify-center rounded-lg bg-slate-100 dark:bg-slate-800 text-slate-500 dark:text-slate-300">
className="flex flex-col items-center justify-center rounded-2xl border border-dashed border-cyan-500/30 bg-[#0a0b10]/90 p-12 text-center backdrop-blur">
<div className="flex h-16 w-16 items-center justify-center rounded-lg bg-cyan-500/10 text-cyan-400">
<HardDrive className="h-7 w-7"/>
</div>
<h3 className="mt-4 text-base font-semibold text-slate-900 dark:text-white">{title}</h3>
<p className="mt-2 max-w-sm text-sm text-slate-500 dark:text-slate-400">{description}</p>
<h3 className="mt-4 text-base font-semibold text-cyan-100 font-mono">{title}</h3>
<p className="mt-2 max-w-sm text-sm text-cyan-600">{description}</p>
{extra ? <div className="mt-4">{extra}</div> : null}
</div>
);
@@ -80,75 +59,27 @@ const calculateNetworkSpeed = (metrics?: LatestMetrics) => {
if (!metrics?.network) {
return {upload: 0, download: 0};
}
// 后端返回的已经是每秒速率(字节/秒),直接使用
return {
upload: metrics.network.totalBytesSentRate,
download: metrics.network.totalBytesRecvRate
};
};
const calculateNetworkTraffic = (metrics?: LatestMetrics) => {
if (!metrics?.network) {
return {totalUpload: 0, totalDownload: 0};
}
// 后端返回的累计流量,直接使用
return {
totalUpload: metrics.network.totalBytesSentTotal,
totalDownload: metrics.network.totalBytesRecvTotal
};
};
const calculateDiskUsage = (metrics?: LatestMetrics) => {
if (!metrics?.disk) {
return 0;
}
// 后端已经计算好平均使用率,直接返回
return metrics.disk.usagePercent;
};
const getProgressColor = (percent: number) => {
if (percent >= 85) return 'bg-rose-500';
if (percent >= 65) return 'bg-amber-500';
return 'bg-emerald-500';
};
// 获取状态显示信息
const getStatusDisplay = (status: number) => {
if (status === 1) {
return {
text: '在线',
bgColor: 'bg-emerald-50 dark:bg-emerald-500/15',
textColor: 'text-emerald-700 dark:text-emerald-200',
dotColor: 'bg-emerald-500 dark:bg-emerald-400',
};
}
return {
text: '离线',
bgColor: 'bg-slate-50 dark:bg-slate-800/80',
textColor: 'text-slate-600 dark:text-slate-400',
dotColor: 'bg-slate-400 dark:bg-slate-500',
};
};
const ServerList = () => {
const navigate = useNavigate();
const {viewMode, setShowViewToggle} = usePublicLayout();
const [selectedTag, setSelectedTag] = useState<string>('');
// 挂载时启用视图切换,卸载时禁用
useEffect(() => {
setShowViewToggle(true);
return () => setShowViewToggle(false);
}, [setShowViewToggle]);
const {data: agents = [], isLoading, dataUpdatedAt} = useQuery<AgentWithMetrics[]>({
const {data: agents = [], isLoading} = useQuery<AgentWithMetrics[]>({
queryKey: ['agents', 'online'],
queryFn: async () => {
const response = await listAgents();
// 后端已经在列表中包含了 metrics 数据,直接使用即可
return (response.data.items || []) as AgentWithMetrics[];
},
refetchInterval: 5000,
@@ -161,7 +92,7 @@ const ServerList = () => {
const response = await getPublicTags();
return response.data.tags || [];
},
refetchInterval: 30000, // 每30秒刷新一次标签
refetchInterval: 30000,
});
// 根据选中的标签过滤服务器
@@ -169,450 +100,298 @@ const ServerList = () => {
? agents.filter(agent => agent.tags?.includes(selectedTag))
: agents;
// 计算统计数据
const stats = useMemo(() => {
const total = agents.length;
const online = agents.filter(a => a.status === 1).length;
const offline = total - online;
// 计算网络统计
let totalUploadRate = 0;
let totalDownloadRate = 0;
let totalUploadTotal = 0;
let totalDownloadTotal = 0;
agents.forEach(agent => {
if (agent.status === 1 && agent.metrics?.network) {
totalUploadRate += agent.metrics.network.totalBytesSentRate || 0;
totalDownloadRate += agent.metrics.network.totalBytesRecvRate || 0;
totalUploadTotal += agent.metrics.network.totalBytesSentTotal || 0;
totalDownloadTotal += agent.metrics.network.totalBytesRecvTotal || 0;
}
});
return {
total,
online,
offline,
uploadRate: totalUploadRate,
downloadRate: totalDownloadRate,
uploadTotal: totalUploadTotal,
downloadTotal: totalDownloadTotal
};
}, [agents]);
const handleNavigate = (agentId: string) => {
navigate(`/servers/${agentId}`);
};
const renderGridView = () => (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
{filteredAgents.map((agent) => {
const cpuUsage = agent.metrics?.cpu?.usagePercent ?? 0;
const memoryUsage = agent.metrics?.memory?.usagePercent ?? 0;
const diskUsage = calculateDiskUsage(agent.metrics);
const {upload, download} = calculateNetworkSpeed(agent.metrics);
const {totalUpload, totalDownload} = calculateNetworkTraffic(agent.metrics);
const statusDisplay = getStatusDisplay(agent.status);
return (
<Link
key={agent.id}
tabIndex={0}
to={`/servers/${agent.id}`}
className={cn(
"group relative flex h-full cursor-pointer flex-col gap-5 rounded-2xl border border-slate-200 dark:border-slate-700 bg-transparent p-5 transition duration-200 hover:border-slate-300 dark:hover:border-slate-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-200",
agent.status !== 1 && "filter grayscale"
)}
>
<div className="flex flex-1 flex-col gap-4">
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-base font-semibold text-slate-900 dark:text-white">
{agent.name || agent.hostname}
</h3>
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1 text-xs font-medium",
statusDisplay.bgColor,
statusDisplay.textColor
)}>
<span className={cn("flex h-1.5 w-1.5 rounded-lg", statusDisplay.dotColor)}/>
{statusDisplay.text}
</span>
</div>
<span
className="inline-flex items-center gap-1 rounded-lg bg-slate-100 dark:bg-slate-800 px-2.5 py-1 text-xs font-medium text-slate-700 dark:text-slate-300">
{agent.os} · {agent.arch}
</span>
</div>
<div className="flex flex-wrap items-center gap-2 text-xs text-slate-500 dark:text-slate-400">
{agent.tags && agent.tags.length > 0 && (
agent.tags?.map((tag, index) => (
<span
key={index}
className="inline-flex items-center gap-1 rounded bg-blue-50 dark:bg-blue-500/10 px-2 py-0.5 text-blue-700 dark:text-blue-400">
{tag}
</span>
))
)}
{agent.expireTime > 0 && (
<span
className="inline-flex items-center gap-1 rounded bg-amber-50 dark:bg-amber-500/10 px-2 py-0.5 text-amber-700 dark:text-amber-200">
<span
className="font-medium">到期:</span> {new Date(agent.expireTime).toLocaleDateString('zh-CN')}
</span>
)}
</div>
</div>
</div>
<div className="flex flex-col gap-3">
<div className="grid grid-cols-3 gap-3">
<div
className="flex flex-col gap-2 rounded-lg border border-slate-200 dark:border-slate-700 bg-slate-50/50 dark:bg-slate-800/40 p-3">
<div className="flex items-center gap-2">
<div
className="flex h-7 w-7 items-center justify-center rounded-lg bg-slate-200 dark:bg-slate-700 text-slate-700 dark:text-slate-300">
<Cpu className="h-3.5 w-3.5"/>
</div>
<span className="text-xs font-medium text-slate-600 dark:text-slate-200">CPU</span>
</div>
<div className="text-sm font-bold text-slate-900 dark:text-white">
{formatPercentValue(cpuUsage)}%
</div>
<ProgressBar percent={cpuUsage} colorClass={getProgressColor(cpuUsage)}/>
</div>
<div
className="flex flex-col gap-2 rounded-lg border border-slate-200 dark:border-slate-700 bg-slate-50/50 dark:bg-slate-800/40 p-3">
<div className="flex items-center gap-2">
<div
className="flex h-7 w-7 items-center justify-center rounded-lg bg-slate-200 dark:bg-slate-700 text-slate-700 dark:text-slate-300">
<MemoryStick className="h-3.5 w-3.5"/>
</div>
<span className="text-xs font-medium text-slate-600 dark:text-slate-200">内存</span>
</div>
<div className="text-sm font-bold text-slate-900 dark:text-white">
{formatPercentValue(memoryUsage)}%
</div>
<ProgressBar percent={memoryUsage} colorClass={getProgressColor(memoryUsage)}/>
</div>
<div
className="flex flex-col gap-2 rounded-lg border border-slate-200 dark:border-slate-700 bg-slate-50/50 dark:bg-slate-800/40 p-3">
<div className="flex items-center gap-2">
<div
className="flex h-7 w-7 items-center justify-center rounded-lg bg-slate-200 dark:bg-slate-700 text-slate-700 dark:text-slate-300">
<HardDrive className="h-3.5 w-3.5"/>
</div>
<span className="text-xs font-medium text-slate-600 dark:text-slate-200">磁盘</span>
</div>
<div className="text-sm font-bold text-slate-900 dark:text-white">
{formatPercentValue(diskUsage)}%
</div>
<ProgressBar percent={diskUsage} colorClass={getProgressColor(diskUsage)}/>
</div>
</div>
<div className="space-y-2">
<div
className="flex items-center justify-between rounded-lg border border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/40 px-3 py-2.5">
<div className="flex items-center gap-2">
<div
className="flex h-7 w-7 items-center justify-center rounded-lg bg-slate-200 dark:bg-slate-700 text-slate-700 dark:text-slate-300">
<Network className="h-3.5 w-3.5"/>
</div>
<span className="text-xs font-medium text-slate-600 dark:text-slate-200">实时速率</span>
</div>
<div className="flex flex-col items-end gap-1 text-xs font-medium text-slate-700 dark:text-slate-100">
<span className="flex items-center gap-1">
<span className="text-slate-500 dark:text-slate-400">↑</span>
{formatSpeed(upload)}
</span>
<span className="flex items-center gap-1">
<span className="text-slate-500 dark:text-slate-400">↓</span>
{formatSpeed(download)}
</span>
</div>
</div>
<div
className="flex items-center justify-between rounded-lg border border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/40 px-3 py-2.5">
<div className="flex items-center gap-2">
<div
className="flex h-7 w-7 items-center justify-center rounded-lg bg-slate-200 dark:bg-slate-700 text-slate-700 dark:text-slate-300">
<EthernetPortIcon className="h-3.5 w-3.5"/>
</div>
<span className="text-xs font-medium text-slate-600 dark:text-slate-200">累计流量</span>
</div>
<div className="flex flex-col items-end gap-1 text-xs font-medium text-slate-700 dark:text-slate-100">
<span className="flex items-center gap-1">
<span className="text-slate-500 dark:text-slate-400">↑</span>
{formatTraffic(totalUpload)}
</span>
<span className="flex items-center gap-1">
<span className="text-slate-500 dark:text-slate-400">↓</span>
{formatTraffic(totalDownload)}
</span>
</div>
</div>
{agent.trafficLimit > 0 && (
<div
className="flex flex-col gap-2 rounded-lg border border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/40 px-3 py-2.5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div
className="flex h-7 w-7 items-center justify-center rounded-lg bg-slate-200 dark:bg-slate-700 text-slate-700 dark:text-slate-300">
<Database className="h-3.5 w-3.5"/>
</div>
<span className="text-xs font-medium text-slate-600 dark:text-slate-200">流量统计</span>
</div>
<span className="text-xs font-semibold text-slate-900 dark:text-white">
{formatPercentValue((agent.trafficUsed || 0) / agent.trafficLimit * 100)}%
</span>
</div>
<ProgressBar
percent={(agent.trafficUsed || 0) / agent.trafficLimit * 100}
colorClass={getProgressColor((agent.trafficUsed || 0) / agent.trafficLimit * 100)}
/>
<div className="flex items-center justify-between text-xs text-slate-600 dark:text-slate-300">
<span>{formatBytes(agent.trafficUsed || 0)} / {formatBytes(agent.trafficLimit)}</span>
{agent.trafficResetDay && agent.trafficResetDay > 0 && (
<span className="text-slate-500 dark:text-slate-400">每月{agent.trafficResetDay}号重置</span>
)}
</div>
</div>
)}
</div>
</div>
</Link>
);
})}
</div>
);
const renderListView = () => (
<>
{/* 桌面端:使用表格布局 */}
<div className="hidden overflow-hidden rounded-md border border-slate-200 dark:border-slate-700 lg:block">
<table className="min-w-full divide-y divide-slate-200 dark:divide-slate-700 text-sm">
<thead className="bg-slate-50 dark:bg-slate-800">
<tr className="text-left text-xs font-semibold uppercase tracking-wide text-slate-700 dark:text-slate-300">
<th className="px-5 py-3">服务器</th>
<th className="px-5 py-3">系统</th>
<th className="px-5 py-3">CPU</th>
<th className="px-5 py-3">内存</th>
<th className="px-5 py-3">磁盘</th>
<th className="px-5 py-3">网络</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200 dark:divide-slate-700 text-slate-700 dark:text-slate-200">
{filteredAgents.map((agent) => {
const cpuUsage = agent.metrics?.cpu?.usagePercent ?? 0;
const cpuModel = agent.metrics?.cpu?.modelName || '未知';
const cpuPhysicalCores = agent.metrics?.cpu?.physicalCores ?? 0;
const cpuLogicalCores = agent.metrics?.cpu?.logicalCores ?? 0;
const memoryUsage = agent.metrics?.memory?.usagePercent ?? 0;
const memoryTotal = agent.metrics?.memory?.total ?? 0;
const memoryUsed = agent.metrics?.memory?.used ?? 0;
const memoryAvailable = agent.metrics?.memory?.available ?? 0;
const diskUsage = calculateDiskUsage(agent.metrics);
const diskTotal = agent.metrics?.disk?.total ?? 0;
const diskUsed = agent.metrics?.disk?.used ?? 0;
const diskFree = agent.metrics?.disk?.free ?? 0;
const {upload, download} = calculateNetworkSpeed(agent.metrics);
const statusDisplay = getStatusDisplay(agent.status);
return (
<tr
key={agent.id}
tabIndex={0}
onClick={() => handleNavigate(agent.id)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
handleNavigate(agent.id);
}
}}
className={cn(
"cursor-pointer transition hover:bg-slate-50 dark:hover:bg-slate-800/50 focus-within:bg-slate-50 dark:focus-within:bg-slate-800/50 focus:outline-none focus-visible:ring-2 focus-visible:ring-slate-200",
agent.status !== 1 && "filter grayscale"
)}
>
<td className="px-5 py-4 align-center">
<div className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold text-slate-900 dark:text-white">
{agent.name || agent.hostname}
</span>
<span
className={cn(
"inline-flex items-center gap-1 rounded-lg px-2 py-0.5 text-xs font-medium",
statusDisplay.bgColor,
statusDisplay.textColor
)}>
<span className={cn("h-1.5 w-1.5 rounded-lg", statusDisplay.dotColor)}/>
{statusDisplay.text}
</span>
</div>
<div className="flex flex-wrap items-center gap-2 text-xs text-slate-500 dark:text-slate-400">
{agent.tags && agent.tags.length > 0 && (
agent.tags?.map((tag, index) => (
<span key={index} className="inline-flex items-center gap-1 text-blue-700 dark:text-blue-400">
{tag}
</span>
))
)}
{agent.expireTime > 0 && (
<span className="inline-flex items-center gap-1 text-amber-700 dark:text-amber-200">
<span
className="font-medium">到期:</span> {new Date(agent.expireTime).toLocaleDateString('zh-CN')}
</span>
)}
</div>
</div>
</td>
<td className="px-5 py-4 align-center text-xs text-slate-500 dark:text-slate-300">
<div>{agent.os}</div>
<div className="text-slate-400 dark:text-slate-400">{agent.arch}</div>
</td>
<td className="px-5 py-4 align-center">
<div className="flex flex-col gap-2">
<div className="flex items-center gap-3">
<div className="w-24">
<ProgressBar percent={cpuUsage}
colorClass={getProgressColor(cpuUsage)}/>
</div>
<span className="text-xs font-semibold text-slate-900 dark:text-white">
{formatPercentValue(cpuUsage)}%
</span>
</div>
<div className="text-xs text-slate-500 dark:text-slate-400">
<div className="truncate" style={{maxWidth: '200px'}}
title={cpuModel}>{cpuModel}</div>
<div>{cpuPhysicalCores}核{cpuLogicalCores}线程</div>
</div>
</div>
</td>
<td className="px-5 py-4 align-center">
<div className="flex flex-col gap-2">
<div className="flex items-center gap-3">
<div className="w-24">
<ProgressBar percent={memoryUsage}
colorClass={getProgressColor(memoryUsage)}/>
</div>
<span className="text-xs font-semibold text-slate-900 dark:text-white">
{formatPercentValue(memoryUsage)}%
</span>
</div>
<div className="text-xs text-slate-500 dark:text-slate-400">
<div>总计:{formatBytes(memoryTotal)}</div>
<div>已用:{formatBytes(memoryUsed)} / 剩余:{formatBytes(memoryAvailable)}</div>
</div>
</div>
</td>
<td className="px-5 py-4 align-center">
<div className="flex flex-col gap-2">
<div className="flex items-center gap-3">
<div className="w-24">
<ProgressBar percent={diskUsage}
colorClass={getProgressColor(diskUsage)}/>
</div>
<span className="text-xs font-semibold text-slate-900 dark:text-white">
{formatPercentValue(diskUsage)}%
</span>
</div>
<div className="text-xs text-slate-500 dark:text-slate-400">
<div>总计:{formatBytes(diskTotal)}</div>
<div>已用:{formatBytes(diskUsed)} / 剩余:{formatBytes(diskFree)}</div>
</div>
</div>
</td>
<td className="px-5 py-4 align-center">
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-1 text-xs text-slate-600 dark:text-slate-300">
<div className="flex items-center gap-1 whitespace-nowrap">
<span className="text-slate-400 dark:text-slate-500">↑</span>
<span className="font-medium tabular-nums">{formatSpeed(upload)}</span>
</div>
<div className="flex items-center gap-1 whitespace-nowrap">
<span className="text-slate-400 dark:text-slate-500">↓</span>
<span className="font-medium tabular-nums">{formatSpeed(download)}</span>
</div>
</div>
{agent.trafficLimit > 0 && (
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<div className="w-16">
<ProgressBar
percent={(agent.trafficUsed || 0) / agent.trafficLimit * 100}
colorClass={getProgressColor((agent.trafficUsed || 0) / agent.trafficLimit * 100)}
/>
</div>
<span className="text-xs font-semibold text-slate-900 dark:text-white">
{formatPercentValue((agent.trafficUsed || 0) / agent.trafficLimit * 100)}%
</span>
</div>
<div className="text-xs text-slate-500 dark:text-slate-400">
{formatBytes(agent.trafficUsed || 0)} / {formatBytes(agent.trafficLimit)}
</div>
</div>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</>
);
if (isLoading) {
return <LoadingSpinner/>;
}
return (
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-8 space-y-4">
{/* 标签过滤器 */}
{tagsData && tagsData.length > 0 && (
<div className="relative">
<div className="tag-filter-scrollbar flex items-center gap-3 overflow-x-auto pb-2">
<button
onClick={() => setSelectedTag('')}
className={cn(
"cursor-pointer inline-flex items-center gap-2 whitespace-nowrap rounded-lg px-4 py-2 text-sm font-medium transition-all duration-200",
selectedTag === ''
? 'bg-blue-500 dark:bg-blue-500 text-white shadow-md shadow-blue-500/30 dark:shadow-blue-500/30'
: 'bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300 hover:bg-slate-200 dark:hover:bg-slate-700'
)}
>
<span>全部</span>
<span className={cn(
"inline-flex items-center justify-center rounded-md px-2 py-0.5 text-xs font-semibold",
selectedTag === ''
? 'bg-blue-400 dark:bg-blue-400 text-white'
: 'bg-slate-200 dark:bg-slate-700 text-slate-600 dark:text-slate-400'
)}>
{agents.length}
</span>
</button>
{tagsData.map((tag) => {
const count = agents.filter(agent => agent.tags?.includes(tag)).length;
if (count === 0) return null;
// 计算所有标签(包括ALL和ONLINE/OFFLINE)
const allTags = ['ALL', 'ONLINE', 'OFFLINE'];
if (tagsData && tagsData.length > 0) {
tagsData.forEach((tag: string) => {
if (!allTags.includes(tag.toUpperCase())) {
allTags.push(tag.toUpperCase());
}
});
}
return (
<button
key={tag}
onClick={() => setSelectedTag(tag)}
className={cn(
"cursor-pointer inline-flex items-center gap-2 whitespace-nowrap rounded-lg px-4 py-2 text-sm font-medium transition-all duration-200",
selectedTag === tag
? 'bg-blue-500 dark:bg-blue-500 text-white shadow-md shadow-blue-500/30 dark:shadow-blue-500/30'
: 'bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300 hover:bg-slate-200 dark:hover:bg-slate-700'
)}
>
<span>{tag}</span>
<span className={cn(
"inline-flex items-center justify-center rounded-md px-2 py-0.5 text-xs font-semibold",
selectedTag === tag
? 'bg-blue-400 dark:bg-blue-400 text-white'
: 'bg-slate-200 dark:bg-slate-700 text-slate-600 dark:text-slate-400'
)}>
{count}
</span>
</button>
);
})}
// 过滤逻辑
let displayAgents = agents;
if (selectedTag === 'ONLINE') {
displayAgents = agents.filter(a => a.status === 1);
} else if (selectedTag === 'OFFLINE') {
displayAgents = agents.filter(a => a.status !== 1);
} else if (selectedTag && selectedTag !== 'ALL') {
displayAgents = agents.filter(a => a.tags?.map(t => t.toUpperCase()).includes(selectedTag));
}
return (
<div className="mx-auto max-w-7xl px-3 sm:px-6 lg:px-8 py-4 sm:py-8 space-y-4 sm:space-y-6">
{/* 统计卡片 */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 sm:gap-4">
<StatCard
title="设备总数"
value={stats.total}
icon={Server}
color="gray"
/>
<StatCard
title="在线设备"
value={stats.online}
icon={CheckCircle2}
color="emerald"
/>
<StatCard
title="离线设备"
value={stats.offline}
icon={XCircle}
color="rose"
/>
<NetworkStatCard
uploadRate={stats.uploadRate}
downloadRate={stats.downloadRate}
uploadTotal={stats.uploadTotal}
downloadTotal={stats.downloadTotal}
/>
</div>
{/* 标签过滤器 */}
{allTags.length > 1 && (
<div className="flex flex-wrap gap-1.5 sm:gap-2 items-center">
<div className="text-[10px] sm:text-xs font-mono text-cyan-600 flex items-center gap-1.5 sm:gap-2 mr-1 sm:mr-2">
<Filter className="w-3 h-3"/>
<span className="hidden sm:inline">FILTERS:</span>
</div>
{/* 右侧渐变提示 */}
<div className="pointer-events-none absolute right-0 top-0 h-full w-12 bg-gradient-to-l from-white dark:from-[#141414]" />
{allTags.map(tag => {
const tagKey = tag === 'ALL' ? '' : tag;
let count = 0;
if (tag === 'ALL') count = agents.length;
else if (tag === 'ONLINE') count = agents.filter(a => a.status === 1).length;
else if (tag === 'OFFLINE') count = agents.filter(a => a.status !== 1).length;
else count = agents.filter(a => a.tags?.map(t => t.toUpperCase()).includes(tag)).length;
if (count === 0 && tag !== 'ALL') return null;
return (
<button
key={tag}
onClick={() => setSelectedTag(tagKey)}
className={cn(
"px-3 py-1 rounded-full text-[10px] font-bold font-mono tracking-wider transition-all border cursor-pointer",
selectedTag === tagKey
? 'bg-cyan-500/20 text-cyan-300 border-cyan-500/50 shadow-[0_0_10px_rgba(34,211,238,0.3)]'
: 'bg-black/30 text-cyan-700 border-cyan-900/30 hover:text-cyan-400 hover:border-cyan-700'
)}
>
{tag} ({count})
</button>
);
})}
</div>
)}
{filteredAgents.length === 0 ? (
{/* 服务器列表 */}
{displayAgents.length === 0 ? (
<EmptyState
title={selectedTag ? '没有匹配的服务器' : '暂无在线服务器'}
description={selectedTag ? `标签 "${selectedTag}" 下暂无服务器` : '当前没有任何探针在线,请稍后再试。'}
/>
) : viewMode === 'grid' ? (
renderGridView()
) : (
renderListView()
<>
{/* 桌面端表格布局 */}
<div className="hidden md:block bg-[#0a0b10]/90 border border-cyan-900/50 rounded-md overflow-hidden shadow-2xl backdrop-blur-sm">
<table className="w-full text-left border-collapse">
<thead>
<tr className="bg-black/40 text-[10px] font-mono uppercase tracking-widest text-cyan-600 border-b border-cyan-900/50">
<th className="p-4 font-normal w-[300px]">System Identity</th>
<th className="p-4 font-normal">Resource Telemetry</th>
<th className="p-4 font-normal w-[200px]">Network I/O</th>
<th className="p-4 font-normal w-[180px]">Meta / Status</th>
</tr>
</thead>
<tbody className="divide-y divide-cyan-900/30">
{displayAgents.map(server => {
const isOnline = server.status === 1;
const cpuUsage = server.metrics?.cpu?.usagePercent ?? 0;
const memoryUsage = server.metrics?.memory?.usagePercent ?? 0;
const memoryTotal = server.metrics?.memory?.total ?? 0;
const memoryUsed = server.metrics?.memory?.used ?? 0;
const diskUsage = calculateDiskUsage(server.metrics);
const diskTotal = server.metrics?.disk?.total ?? 0;
const diskUsed = server.metrics?.disk?.used ?? 0;
const {upload, download} = calculateNetworkSpeed(server.metrics);
return (
<tr
key={server.id}
tabIndex={0}
onClick={() => handleNavigate(server.id)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
handleNavigate(server.id);
}
}}
className="group hover:bg-cyan-500/5 transition-colors cursor-pointer"
>
{/* Identity */}
<td className="p-4 align-top">
<div className="flex items-center gap-4">
<div>
<div
className="font-bold text-cyan-100 font-mono text-sm group-hover:text-cyan-400 transition-colors">
{server.name || server.hostname}
</div>
<div
className="flex items-center gap-2 text-[10px] text-cyan-600 mt-1 font-mono uppercase">
<span>{server.os}</span>
<span className="w-px h-2 bg-cyan-800"></span>
<span>{server.arch}</span>
</div>
</div>
</div>
</td>
{/* Resources */}
<td className="p-4 align-top">
{isOnline ? (
<div className="flex flex-col justify-center h-full gap-0.5">
<CompactResourceBar
value={cpuUsage}
label="CPU"
icon={Cpu}
subtext={null}
color="bg-blue-500"
/>
<CompactResourceBar
value={memoryUsage}
label="RAM"
icon={MemoryStick}
subtext={`${formatBytes(memoryUsed)}/${formatBytes(memoryTotal)}`}
color="bg-purple-500"
/>
<CompactResourceBar
value={diskUsage}
label="DSK"
icon={HardDrive}
subtext={`${formatBytes(diskUsed)}/${formatBytes(diskTotal)}`}
color="bg-emerald-500"
/>
</div>
) : (
<div
className="text-xs text-rose-500 font-mono flex items-center gap-2 py-4">
<AlertTriangle className="w-4 h-4"/>
<span>CONNECTION_LOST // RECONNECTING...</span>
</div>
)}
</td>
{/* Network */}
<td className="p-4 font-mono align-top">
<div className="flex flex-col gap-1.5 text-[10px] text-cyan-500 mb-3">
<span className="flex items-center gap-2 text-emerald-400/80">
<ArrowDown className="w-3 h-3"/>
<span>IN: {formatSpeed(download)}</span>
</span>
<span className="flex items-center gap-2 text-blue-400/80">
<ArrowUp className="w-3 h-3"/>
<span>OUT: {formatSpeed(upload)}</span>
</span>
</div>
{server.trafficLimit > 0 ? (
<div className="w-32">
<div
className="flex justify-between text-[9px] text-cyan-700 mb-0.5">
<span>流量使用</span>
<span>{Math.round((server.trafficUsed || 0) / server.trafficLimit * 100)}%</span>
</div>
<div className="h-1 bg-cyan-900/50 rounded-full overflow-hidden">
<div className="h-full bg-cyan-400"
style={{width: `${((server.trafficUsed || 0) / server.trafficLimit) * 100}%`}}></div>
</div>
</div>
) : (
<div className="text-[10px] text-cyan-800 italic">-- NO METERING --</div>
)}
</td>
{/* Meta */}
<td className="p-4 align-top">
<div className="flex flex-col gap-2">
<div className="flex gap-1 flex-wrap">
{server.tags && server.tags.length > 0 && server.tags.map(tag => (
<span key={tag}
className="px-1.5 py-0.5 bg-cyan-900/40 text-cyan-400 border border-cyan-700/50 text-[10px] font-mono rounded-sm">
#{tag}
</span>
))}
</div>
<div
className={`text-[10px] font-mono flex items-center gap-1 ${server.expireTime && server.expireTime > 0 ? 'text-cyan-600' : 'text-emerald-500/60'}`}>
{server.expireTime > 0 &&
<div className={'flex items-center gap-1'}>
<Calendar className="w-3 h-3"/>
<div>过期时间: {new Date(server.expireTime).toLocaleDateString('zh-CN')}</div>
</div>
}
</div>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* 移动端卡片布局 */}
<div className="md:hidden space-y-3">
{displayAgents.map(server => (
<ServerCard
key={server.id}
server={server}
onClick={handleNavigate}
/>
))}
</div>
</>
)}
</div>
);
+16 -54
View File
@@ -1,64 +1,26 @@
import {Outlet, useOutletContext} from 'react-router-dom';
import {useState, useEffect} from 'react';
import {Outlet} from 'react-router-dom';
import PublicHeader from '../components/PublicHeader';
import PublicFooter from '../components/PublicFooter';
type ViewMode = 'grid' | 'list';
interface PublicLayoutContextType {
viewMode: ViewMode;
setViewMode: (mode: ViewMode) => void;
showViewToggle: boolean;
setShowViewToggle: (show: boolean) => void;
}
const getInitialViewMode = (): ViewMode => {
// 检查是否为移动端(屏幕宽度小于 1024px)
const isMobile = window.innerWidth < 1024;
// 移动端默认使用 grid 视图
if (isMobile) {
return 'grid';
}
// 桌面端使用配置的默认视图
return window.SystemConfig?.DefaultView === 'grid' ? 'grid' : 'list';
};
const PublicLayout = () => {
const [viewMode, setViewMode] = useState<ViewMode>(getInitialViewMode());
const [showViewToggle, setShowViewToggle] = useState(false);
// 监听窗口大小变化,在移动端和桌面端切换时调整视图
useEffect(() => {
const handleResize = () => {
const isMobile = window.innerWidth < 1024;
if (isMobile && viewMode === 'list') {
setViewMode('grid');
}
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [viewMode]);
return (
<div className="min-h-screen bg-white dark:bg-[#141414] text-slate-900 dark:text-slate-100 flex flex-col transition-colors">
<PublicHeader
viewMode={viewMode}
onViewModeChange={setViewMode}
showViewToggle={showViewToggle}
/>
<main className="flex-1">
<Outlet context={{viewMode, setViewMode, showViewToggle, setShowViewToggle}}/>
</main>
<PublicFooter/>
<div className="min-h-screen bg-[#05050a] text-slate-200 flex flex-col relative overflow-x-hidden">
{/* 背景网格效果 */}
<div
className="fixed inset-0 pointer-events-none bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:30px_30px] opacity-20 z-0"></div>
{/* 顶部发光效果 */}
<div
className="fixed top-0 left-1/2 -translate-x-1/2 w-[1000px] h-[300px] bg-cyan-600/10 blur-[120px] rounded-full pointer-events-none z-0"></div>
<div className="relative z-10 flex flex-col min-h-screen">
<PublicHeader/>
<main className="flex-1">
<Outlet/>
</main>
<PublicFooter/>
</div>
</div>
);
};
export function usePublicLayout() {
return useOutletContext<PublicLayoutContextType>();
}
export default PublicLayout;
+50 -63
View File
@@ -214,7 +214,7 @@ export interface TemperatureMetric {
id: number;
agentId: string;
sensorKey: string;
sensorLabel: string;
type: string;
temperature: number;
timestamp: number;
}
@@ -293,76 +293,69 @@ export interface PublicMonitor {
interval: number;
agentIds: string[];
agentCount: number;
lastCheckStatus: string;
lastCheckError?: string;
currentResponse: number;
avgResponse24h: number;
uptime24h: number;
uptime7d: number;
certExpiryDate: number;
certExpiryDays: number;
status: string;
responseTime: number;
responseTimeMin: number;
responseTimeMax: number;
certExpiryTime: number;
certDaysLeft: number;
agentStats: {
up: number;
down: number;
unknown: number;
};
lastCheckTime: number;
}
// 监控统计数据
export interface MonitorStats {
id: number;
// 探针监控统计
export interface AgentMonitorStat {
agentId: string;
agentName?: string; // 探针名称
monitorId: string; // 监控项ID
name: string; // 监控项名称
type: string;
target: string;
currentResponse: number; // 当前响应时间(ms)
avgResponse24h: number; // 24小时平均响应时间(ms)
uptime24h: number; // 24小时在线率(百分比)
uptime7d: number; // 7天在线率(百分比)
certExpiryDate: number; // 证书过期时间(毫秒时间戳)
certExpiryDays: number; // 证书剩余天数
totalChecks24h: number; // 24小时总检测次数
successChecks24h: number; // 24小时成功次数
totalChecks7d: number; // 7天总检测次数
successChecks7d: number; // 7天成功次数
lastCheckTime: number; // 最后检测时间
lastCheckStatus: string; // 最后检测状态: up/down
lastCheckError?: string; // 最后检测错误信息
updatedAt: number; // 更新时间
}
// 监控指标(原始数据,用于图表)
export interface MonitorMetric {
id: number;
agentId: string;
name: string;
agentName: string;
monitorId: string;
type: string;
target: string;
status: string;
statusCode: number;
responseTime: number;
error: string;
checkedAt: number;
message: string;
contentMatch: boolean;
certExpiryTime: number;
certDaysLeft: number;
timestamp: number;
}
// 磁盘IO指标
export interface DiskIOMetric {
id: number;
agentId: string;
device: string;
readCount: number;
writeCount: number;
readBytes: number;
writeBytes: number;
readBytesRate: number;
writeBytesRate: number;
readTime: number;
writeTime: number;
ioTime: number;
iopsInProgress: number;
timestamp: number;
// 监控详情(整合版)
export interface MonitorDetail {
id: string;
name: string;
type: 'http' | 'https' | 'tcp' | 'icmp' | 'ping';
target: string;
showTargetPublic: boolean;
description?: string;
enabled: boolean;
interval: number;
stats: {
status: string;
responseTime: number;
responseTimeMin: number;
responseTimeMax: number;
lastCheckTime: number;
agentCount: number;
agentStats: {
up: number;
down: number;
unknown: number;
};
certExpiryTime: number;
certDaysLeft: number;
};
agents: AgentMonitorStat[];
}
// 监控统计数据
export interface CertStats {
certExpiryTime: number;
certDaysLeft: number;
}
// 网络连接统计
@@ -460,12 +453,6 @@ export interface AlertRecord {
updatedAt: number;
}
// 通用分页响应
export interface PageResponse<T> {
items: T[];
total: number;
}
// 流量统计相关
export interface TrafficAlerts {
sent80: boolean;
+71
View File
@@ -0,0 +1,71 @@
import dayjs from 'dayjs';
export const formatTime = (ms: number): string => {
if (!ms || ms <= 0) return '0 ms';
if (ms < 1000) return `${ms.toFixed(0)} ms`;
return `${(ms / 1000).toFixed(2)} s`;
};
export const formatDate = (timestamp: number): string => {
if (!timestamp) return '-';
const date = new Date(timestamp);
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
});
};
export const formatDateTime = (value: string | number | undefined | null): string => {
if (value === undefined || value === null || value === '') {
return '-';
}
return dayjs(value).format('YYYY-MM-DD HH:mm:ss');
};
export const formatSpeed = (bytesPerSecond: number): string => {
if (!bytesPerSecond || bytesPerSecond <= 0) return '0 B/s';
const k = 1024;
const sizes = ['B/s', 'K/s', 'M/s', 'G/s', 'T/s'];
const i = Math.min(Math.floor(Math.log(bytesPerSecond) / Math.log(k)), sizes.length - 1);
const value = bytesPerSecond / Math.pow(k, i);
const decimals = value >= 100 ? 0 : value >= 10 ? 1 : 2;
return `${value.toFixed(decimals)} ${sizes[i]}`;
};
export const formatBytes = (bytes: number | undefined | null): string => {
if (!bytes || bytes <= 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1);
return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`;
};
export const formatPercentValue = (value: number | undefined | null): string => {
if (value === undefined || value === null || Number.isNaN(value)) return '0.0';
return value.toFixed(1);
};
export const formatUptime = (seconds: number | undefined | null): string => {
if (seconds === undefined || seconds === null) return '-';
if (seconds <= 0) return '0 秒';
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const parts: string[] = [];
// 智能显示:只显示最重要的两个单位,避免文本过长
if (days > 0) {
parts.push(`${days} 天`);
if (hours > 0) parts.push(`${hours} 小时`);
} else if (hours > 0) {
parts.push(`${hours} 小时`);
if (minutes > 0) parts.push(`${minutes} 分钟`);
} else if (minutes > 0) {
parts.push(`${minutes} 分钟`);
}
return parts.length > 0 ? parts.join(' ') : '不到 1 分钟';
};