99 files changed
+7107
-6599
No files matched your search
@@ -39,3 +39,4 @@ go.work.sum
|
||||
|
||||
config.yaml
|
||||
agent.yaml
|
||||
playground
|
||||
+174
-2
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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("启动流量重置检查任务")
|
||||
|
||||
@@ -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"` // 查询超时(秒)
|
||||
}
|
||||
@@ -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(), ®isterReq.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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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 // 额外标签
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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, ×tamp)
|
||||
|
||||
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, ×tamp)
|
||||
|
||||
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, ×tamp)
|
||||
|
||||
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, ×tamp)
|
||||
|
||||
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, ×tamp)
|
||||
|
||||
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, ×tamp)
|
||||
|
||||
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
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"` // 是否启用全局告警
|
||||
|
||||
@@ -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"` // 响应时间(毫秒)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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},
|
||||
}
|
||||
}
|
||||
+557
-657
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
}
|
||||
@@ -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: "告警配置",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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 地址
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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}`);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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" />;
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
@@ -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'},
|
||||
];
|
||||
@@ -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';
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 分钟';
|
||||
};
|
||||
Reference in new issue
Block a user