优化公共页面&增加自动迁移
This commit is contained in:
1 parent
36eaae9c40
commit
c0790e9ced
34 files changed
+3449
-1598
No files matched your search
+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)
|
||||
}
|
||||
+2
-2
@@ -29,9 +29,9 @@ services:
|
||||
- "-storageDataPath=/victoria-metrics-data"
|
||||
- "-httpListenAddr=:8428"
|
||||
ports:
|
||||
- "8428:8428" # API, PromQL, insert, select
|
||||
- "127.0.0.1:8428:8428" # API, PromQL, insert, select
|
||||
volumes:
|
||||
- ./vmdata:/victoria-metrics-data
|
||||
- ./data/vmdata:/victoria-metrics-data
|
||||
networks:
|
||||
- pika-network
|
||||
pika:
|
||||
|
||||
@@ -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},
|
||||
}
|
||||
}
|
||||
@@ -289,6 +289,7 @@ type Evidence struct {
|
||||
// MonitorData 监控数据
|
||||
type MonitorData struct {
|
||||
AgentId string `json:"agentId"` // 探针 ID
|
||||
AgentName string `json:"agentName"` // 探针名称
|
||||
MonitorId string `json:"monitorId"` // 监控项ID
|
||||
Type string `json:"type"` // 监控类型: http, tcp
|
||||
Target string `json:"target,omitempty"` // 监控目标
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
type MetricService struct {
|
||||
logger *zap.Logger
|
||||
metricRepo *repo.MetricRepo
|
||||
agentRepo *repo.AgentRepo
|
||||
propertyService *PropertyService
|
||||
trafficService *TrafficService // 流量统计服务
|
||||
vmClient *vmclient.VMClient
|
||||
@@ -36,6 +37,7 @@ func NewMetricService(logger *zap.Logger, db *gorm.DB, propertyService *Property
|
||||
return &MetricService{
|
||||
logger: logger,
|
||||
metricRepo: repo.NewMetricRepo(db),
|
||||
agentRepo: repo.NewAgentRepo(db),
|
||||
propertyService: propertyService,
|
||||
trafficService: trafficService,
|
||||
vmClient: vmClient,
|
||||
@@ -529,6 +531,42 @@ func (s *MetricService) GetMonitorHistory(ctx context.Context, monitorID string,
|
||||
series = append(series, convertedSeries...)
|
||||
}
|
||||
|
||||
// 收集所有 agent_id
|
||||
agentIdSet := make(map[string]struct{})
|
||||
for _, s := range series {
|
||||
if agentId, ok := s.Labels["agent_id"]; ok {
|
||||
agentIdSet[agentId] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// 查询 agent 信息
|
||||
if len(agentIdSet) > 0 {
|
||||
agentIds := make([]string, 0, len(agentIdSet))
|
||||
for agentId := range agentIdSet {
|
||||
agentIds = append(agentIds, agentId)
|
||||
}
|
||||
|
||||
agents, err := s.agentRepo.FindByIdIn(ctx, agentIds)
|
||||
if err != nil {
|
||||
s.logger.Error("查询 agent 信息失败", zap.Error(err))
|
||||
} else {
|
||||
// 构建 agentId -> agentName 映射
|
||||
agentNameMap := make(map[string]string)
|
||||
for _, agent := range agents {
|
||||
agentNameMap[agent.ID] = agent.Name
|
||||
}
|
||||
|
||||
// 在每个 series 的 labels 中添加 agent_name
|
||||
for i := range series {
|
||||
if agentId, ok := series[i].Labels["agent_id"]; ok {
|
||||
if agentName, exists := agentNameMap[agentId]; exists {
|
||||
series[i].Labels["agent_name"] = agentName
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &metric.GetMetricsResponse{
|
||||
AgentID: "", // 监控查询不限定单个agent
|
||||
Type: "monitor",
|
||||
@@ -546,10 +584,30 @@ func (s *MetricService) GetMonitorAgentStats(monitorID string) []protocol.Monito
|
||||
return []protocol.MonitorData{}
|
||||
}
|
||||
|
||||
// 转换为数组
|
||||
// 收集所有 agentId
|
||||
agentIds := make([]string, 0, len(latestMetrics.Agents))
|
||||
for agentId := range latestMetrics.Agents {
|
||||
agentIds = append(agentIds, agentId)
|
||||
}
|
||||
|
||||
// 查询 agent 信息
|
||||
ctx := context.Background()
|
||||
agents, err := s.agentRepo.FindByIdIn(ctx, agentIds)
|
||||
if err != nil {
|
||||
s.logger.Error("查询 agent 信息失败", zap.Error(err))
|
||||
}
|
||||
|
||||
// 构建 agentId -> agentName 映射
|
||||
agentNameMap := make(map[string]string)
|
||||
for _, agent := range agents {
|
||||
agentNameMap[agent.ID] = agent.Name
|
||||
}
|
||||
|
||||
// 转换为数组并填充 agent 名称
|
||||
result := make([]protocol.MonitorData, 0, len(latestMetrics.Agents))
|
||||
for _, stat := range latestMetrics.Agents {
|
||||
stat.Target = ""
|
||||
for agentId, stat := range latestMetrics.Agents {
|
||||
stat.Target = "" // 隐藏目标地址
|
||||
stat.AgentName = agentNameMap[agentId] // 填充 agent 名称
|
||||
result = append(result, *stat)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
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';
|
||||
|
||||
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 (
|
||||
<div
|
||||
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/30 hover:shadow-[0_0_25px_rgba(6,182,212,0.1)] transition-all duration-300 overflow-hidden relative">
|
||||
{/* 装饰性边框 */}
|
||||
<div
|
||||
className="absolute top-0 left-0 w-3 h-3 border-t-2 border-l-2 border-cyan-500/30 group-hover:border-cyan-400/50 transition-colors"></div>
|
||||
<div
|
||||
className="absolute top-0 right-0 w-3 h-3 border-t-2 border-r-2 border-cyan-500/30 group-hover:border-cyan-400/50 transition-colors"></div>
|
||||
<div
|
||||
className="absolute bottom-0 left-0 w-3 h-3 border-b-2 border-l-2 border-cyan-500/30 group-hover:border-cyan-400/50 transition-colors"></div>
|
||||
<div
|
||||
className="absolute bottom-0 right-0 w-3 h-3 border-b-2 border-r-2 border-cyan-500/30 group-hover:border-cyan-400/50 transition-colors"></div>
|
||||
|
||||
<div className="relative z-10 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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,98 @@
|
||||
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';
|
||||
|
||||
interface MonitorHeroProps {
|
||||
monitor: PublicMonitor;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 监控详情头部组件
|
||||
* 显示监控基本信息、状态和关键指标
|
||||
*/
|
||||
export const MonitorHero = ({monitor, onBack}: MonitorHeroProps) => {
|
||||
return (
|
||||
<div
|
||||
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/30 hover:shadow-[0_0_25px_rgba(6,182,212,0.1)] transition-all duration-300 overflow-hidden relative">
|
||||
{/* 装饰性边框 */}
|
||||
<div
|
||||
className="absolute top-0 left-0 w-3 h-3 border-t-2 border-l-2 border-cyan-500/30 group-hover:border-cyan-400/50 transition-colors"></div>
|
||||
<div
|
||||
className="absolute top-0 right-0 w-3 h-3 border-t-2 border-r-2 border-cyan-500/30 group-hover:border-cyan-400/50 transition-colors"></div>
|
||||
<div
|
||||
className="absolute bottom-0 left-0 w-3 h-3 border-b-2 border-l-2 border-cyan-500/30 group-hover:border-cyan-400/50 transition-colors"></div>
|
||||
<div
|
||||
className="absolute bottom-0 right-0 w-3 h-3 border-b-2 border-r-2 border-cyan-500/30 group-hover:border-cyan-400/50 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-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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,283 @@
|
||||
import {useEffect, useMemo, useState} from 'react';
|
||||
import {useQuery} from '@tanstack/react-query';
|
||||
import {Shield} from 'lucide-react';
|
||||
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 {cn} from '@/lib/utils';
|
||||
import type {AgentMonitorStat} from '@/types';
|
||||
|
||||
interface ResponseTimeChartProps {
|
||||
monitorId: string;
|
||||
monitorStats: AgentMonitorStat[];
|
||||
}
|
||||
|
||||
// 自定义 Tooltip 组件
|
||||
const CustomTooltip = ({active, payload, label}: any) => {
|
||||
if (!active || !payload || payload.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-[#0a0b10]/95 backdrop-blur-xl border border-cyan-500/30 rounded-lg p-3 shadow-[0_0_20px_rgba(6,182,212,0.2)]">
|
||||
<p className="text-xs text-cyan-400 mb-2 font-mono">{label}</p>
|
||||
<div className="space-y-1">
|
||||
{payload.map((entry: any, index: number) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full" style={{backgroundColor: entry.color}}></span>
|
||||
<span className="text-xs text-cyan-100 font-mono">
|
||||
{entry.name}: <span className="font-bold">{entry.value} ms</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 图表占位符组件
|
||||
const ChartPlaceholder = ({subtitle, heightClass}: { subtitle: string; heightClass: string }) => (
|
||||
<div
|
||||
className={cn("flex items-center justify-center border-2 border-dashed border-cyan-900/50 rounded-lg", heightClass)}>
|
||||
<div className="text-center text-cyan-600">
|
||||
<Shield className="h-12 w-12 mx-auto mb-3 opacity-20"/>
|
||||
<p className="text-sm font-mono">{subtitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 时间范围选择器组件
|
||||
const TimeRangeSelector = ({
|
||||
value,
|
||||
onChange,
|
||||
options
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
options: Array<{ value: string; label: string }>;
|
||||
}) => (
|
||||
<div className="flex gap-1 bg-black/40 p-1 rounded-lg border border-cyan-900/50">
|
||||
{options.map(option => (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => onChange(option.value)}
|
||||
className={cn(
|
||||
"px-3 py-1.5 text-xs font-medium rounded transition-all font-mono cursor-pointer",
|
||||
value === option.value
|
||||
? 'bg-cyan-500/20 text-cyan-300 border border-cyan-500/30'
|
||||
: 'text-cyan-600 hover:text-cyan-400'
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* 响应时间趋势图表组件
|
||||
* 显示监控各探针的响应时间变化
|
||||
*/
|
||||
export const ResponseTimeChart = ({monitorId, monitorStats}: ResponseTimeChartProps) => {
|
||||
const [selectedAgent, setSelectedAgent] = useState<string>('all');
|
||||
const [timeRange, setTimeRange] = useState<string>('1d');
|
||||
|
||||
// 获取历史数据
|
||||
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 (
|
||||
<div
|
||||
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/30 hover:shadow-[0_0_25px_rgba(6,182,212,0.1)] transition-all duration-300 overflow-hidden relative">
|
||||
{/* 装饰性边框 */}
|
||||
<div
|
||||
className="absolute top-0 left-0 w-3 h-3 border-t-2 border-l-2 border-cyan-500/30 group-hover:border-cyan-400/50 transition-colors"></div>
|
||||
<div
|
||||
className="absolute top-0 right-0 w-3 h-3 border-t-2 border-r-2 border-cyan-500/30 group-hover:border-cyan-400/50 transition-colors"></div>
|
||||
<div
|
||||
className="absolute bottom-0 left-0 w-3 h-3 border-b-2 border-l-2 border-cyan-500/30 group-hover:border-cyan-400/50 transition-colors"></div>
|
||||
<div
|
||||
className="absolute bottom-0 right-0 w-3 h-3 border-b-2 border-r-2 border-cyan-500/30 group-hover:border-cyan-400/50 transition-colors"></div>
|
||||
|
||||
<div className="relative z-10 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}
|
||||
/>
|
||||
{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/>}/>
|
||||
<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
|
||||
subtitle="正在收集数据,请稍后查看历史趋势"
|
||||
heightClass="h-80"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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,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,96 @@
|
||||
import {ArrowLeft, Server} 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';
|
||||
|
||||
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 (
|
||||
<section className="rounded-2xl border border-cyan-900/50 bg-[#0a0b10]/90 p-6 shadow-2xl backdrop-blur-sm">
|
||||
<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>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,179 @@
|
||||
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';
|
||||
|
||||
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 (
|
||||
<Card title="系统信息" description="探针基础属性、运行状态与资源概览" variant="dark">
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<div className="rounded-xl border border-cyan-900/50 bg-black/30 p-4 backdrop-blur-sm">
|
||||
<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>
|
||||
</div>
|
||||
<div className="rounded-xl border border-cyan-900/50 bg-black/30 p-4 backdrop-blur-sm">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<SnapshotSection cards={snapshotCards}/>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -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,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,8 @@
|
||||
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';
|
||||
@@ -2,3 +2,8 @@ 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,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,
|
||||
});
|
||||
};
|
||||
@@ -1,136 +1,17 @@
|
||||
import {useEffect, useMemo, useState} from 'react';
|
||||
import {useNavigate, useParams} from 'react-router-dom';
|
||||
import {useQuery} from '@tanstack/react-query';
|
||||
import {AlertCircle, ArrowLeft, Clock, Loader2, MapPin, Shield} from 'lucide-react';
|
||||
import {Area, AreaChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis} from 'recharts';
|
||||
import {type GetMetricsResponse, getMonitorAgentStats, getMonitorHistory, getMonitorStatsById} from '@/api/monitor.ts';
|
||||
import {getMonitorAgentStats, getMonitorStatsById} from '@/api/monitor.ts';
|
||||
import type {AgentMonitorStat, PublicMonitor} from '@/types';
|
||||
import {formatDateTime, formatTime} from '@/utils/util.ts';
|
||||
import {StatusBadge} from '@/components/monitor/StatusBadge';
|
||||
import {TypeIcon} from '@/components/monitor/TypeIcon';
|
||||
import {CertBadge} from '@/components/monitor/CertBadge';
|
||||
import {cn} from '@/lib/utils';
|
||||
// 常量
|
||||
import {AGENT_COLORS} from '@/constants/colors';
|
||||
import {MONITOR_TIME_RANGE_OPTIONS} from '@/constants/time';
|
||||
import LittleStatCard from "@/components/common/LittleStatCard.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-cyan-600">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-cyan-400"/>
|
||||
<span className="text-sm font-mono">加载监控详情中...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 空状态组件
|
||||
const EmptyState = () => (
|
||||
<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 font-mono">未找到监控数据</p>
|
||||
<p className="mt-2 text-sm text-cyan-600">请检查监控 ID 是否正确</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 卡片容器组件
|
||||
const Card = ({
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
children
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}) => (
|
||||
<div 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/30 hover:shadow-[0_0_25px_rgba(6,182,212,0.1)] transition-all duration-300 overflow-hidden relative">
|
||||
{/* 装饰性边框 */}
|
||||
<div className="absolute top-0 left-0 w-3 h-3 border-t-2 border-l-2 border-cyan-500/30 group-hover:border-cyan-400/50 transition-colors"></div>
|
||||
<div className="absolute top-0 right-0 w-3 h-3 border-t-2 border-r-2 border-cyan-500/30 group-hover:border-cyan-400/50 transition-colors"></div>
|
||||
<div className="absolute bottom-0 left-0 w-3 h-3 border-b-2 border-l-2 border-cyan-500/30 group-hover:border-cyan-400/50 transition-colors"></div>
|
||||
<div className="absolute bottom-0 right-0 w-3 h-3 border-b-2 border-r-2 border-cyan-500/30 group-hover:border-cyan-400/50 transition-colors"></div>
|
||||
|
||||
<div className="relative z-10 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">{title}</h3>
|
||||
{description && <p className="text-xs text-cyan-600 mt-1 font-mono">{description}</p>}
|
||||
</div>
|
||||
{action && <div className="flex-shrink-0">{action}</div>}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 图表占位符组件
|
||||
const ChartPlaceholder = ({subtitle, heightClass}: { subtitle: string; heightClass: string }) => (
|
||||
<div className={cn("flex items-center justify-center border-2 border-dashed border-cyan-900/50 rounded-lg", heightClass)}>
|
||||
<div className="text-center text-cyan-600">
|
||||
<Shield className="h-12 w-12 mx-auto mb-3 opacity-20"/>
|
||||
<p className="text-sm font-mono">{subtitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 自定义 Tooltip 组件
|
||||
const CustomTooltip = ({active, payload, label}: any) => {
|
||||
if (!active || !payload || payload.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-[#0a0b10]/95 backdrop-blur-xl border border-cyan-500/30 rounded-lg p-3 shadow-[0_0_20px_rgba(6,182,212,0.2)]">
|
||||
<p className="text-xs text-cyan-400 mb-2 font-mono">{label}</p>
|
||||
<div className="space-y-1">
|
||||
{payload.map((entry: any, index: number) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full" style={{backgroundColor: entry.color}}></span>
|
||||
<span className="text-xs text-cyan-100 font-mono">
|
||||
{entry.name}: <span className="font-bold">{entry.value} ms</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 时间范围选择器组件
|
||||
const TimeRangeSelector = ({
|
||||
value,
|
||||
onChange,
|
||||
options
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
options: Array<{ value: string; label: string }>;
|
||||
}) => (
|
||||
<div className="flex gap-1 bg-black/40 p-1 rounded-lg border border-cyan-900/50">
|
||||
{options.map(option => (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => onChange(option.value)}
|
||||
className={cn(
|
||||
"px-3 py-1.5 text-xs font-medium rounded transition-all font-mono cursor-pointer",
|
||||
value === option.value
|
||||
? 'bg-cyan-500/20 text-cyan-300 border border-cyan-500/30'
|
||||
: 'text-cyan-600 hover:text-cyan-400'
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
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>('1d');
|
||||
|
||||
// 获取监控详情(聚合数据)
|
||||
const {data: monitorDetail, isLoading} = useQuery<PublicMonitor>({
|
||||
@@ -156,365 +37,38 @@ const MonitorDetail = () => {
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
// 获取历史数据
|
||||
const {data: historyData} = useQuery<GetMetricsResponse>({
|
||||
queryKey: ['monitorHistory', id, timeRange],
|
||||
queryFn: async () => {
|
||||
if (!id) throw new Error('Monitor ID is required');
|
||||
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.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');
|
||||
|
||||
// 根据选择的探针过滤
|
||||
const filteredSeries = selectedAgent === 'all'
|
||||
? responseTimeSeries
|
||||
: responseTimeSeries.filter(s => s.labels?.agent_id === selectedAgent);
|
||||
|
||||
if (filteredSeries.length === 0) return [];
|
||||
|
||||
// 按时间戳分组数据
|
||||
const grouped: Record<number, any> = {};
|
||||
|
||||
filteredSeries.forEach(series => {
|
||||
const agentId = series.labels?.agent_id || 'unknown';
|
||||
const agentKey = `agent_${agentId}`;
|
||||
|
||||
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]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-8">
|
||||
<LoadingSpinner/>
|
||||
</div>
|
||||
);
|
||||
return <LoadingSpinner variant="dark"/>;
|
||||
}
|
||||
|
||||
if (!monitorDetail) {
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-8">
|
||||
<EmptyState/>
|
||||
</div>
|
||||
);
|
||||
return <EmptyState variant="dark"/>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-8 space-y-6">
|
||||
{/* Hero Section */}
|
||||
<div 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/30 hover:shadow-[0_0_25px_rgba(6,182,212,0.1)] transition-all duration-300 overflow-hidden relative">
|
||||
{/* 装饰性边框 */}
|
||||
<div className="absolute top-0 left-0 w-3 h-3 border-t-2 border-l-2 border-cyan-500/30 group-hover:border-cyan-400/50 transition-colors"></div>
|
||||
<div className="absolute top-0 right-0 w-3 h-3 border-t-2 border-r-2 border-cyan-500/30 group-hover:border-cyan-400/50 transition-colors"></div>
|
||||
<div className="absolute bottom-0 left-0 w-3 h-3 border-b-2 border-l-2 border-cyan-500/30 group-hover:border-cyan-400/50 transition-colors"></div>
|
||||
<div className="absolute bottom-0 right-0 w-3 h-3 border-b-2 border-r-2 border-cyan-500/30 group-hover:border-cyan-400/50 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="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')}
|
||||
/>
|
||||
|
||||
<div className="relative z-10 p-6 space-y-6">
|
||||
{/* 返回按钮 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/monitors')}
|
||||
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={monitorDetail.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: {monitorDetail.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">{monitorDetail.name}</h1>
|
||||
<StatusBadge status={monitorDetail.status}/>
|
||||
</div>
|
||||
<p className="text-sm text-cyan-500/80 font-mono truncate">
|
||||
{monitorDetail.showTargetPublic ? monitorDetail.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={monitorDetail.type.toUpperCase()}
|
||||
/>
|
||||
<LittleStatCard
|
||||
label="探针数量"
|
||||
value={monitorDetail.agentCount}
|
||||
/>
|
||||
<LittleStatCard
|
||||
label="平均响应"
|
||||
value={`${monitorDetail.responseTime}ms`}
|
||||
/>
|
||||
<LittleStatCard
|
||||
label="最慢响应"
|
||||
value={`${monitorDetail.responseTimeMax}ms`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 证书信息(如果是 HTTPS)*/}
|
||||
{monitorDetail.type === 'https' && monitorDetail.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={monitorDetail.certExpiryTime}
|
||||
daysLeft={monitorDetail.certDaysLeft}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 响应时间趋势图表 */}
|
||||
<Card
|
||||
title="响应时间趋势"
|
||||
description="监控各探针的响应时间变化"
|
||||
action={
|
||||
<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}
|
||||
/>
|
||||
{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.id}>
|
||||
探针 {agent.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{chartData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={360}>
|
||||
<AreaChart data={chartData}>
|
||||
<defs>
|
||||
{monitorStats
|
||||
.filter(stat => selectedAgent === 'all' || stat.agentId === selectedAgent)
|
||||
.map((stat, index) => {
|
||||
const originalIndex = monitorStats.findIndex(s => s.agentId === stat.agentId);
|
||||
const agentKey = `agent_${stat.agentId}`;
|
||||
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/>}/>
|
||||
<Legend
|
||||
wrapperStyle={{paddingTop: '20px'}}
|
||||
iconType="circle"
|
||||
/>
|
||||
{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.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
|
||||
subtitle="正在收集数据,请稍后查看历史趋势"
|
||||
heightClass="h-80"
|
||||
{/* 主内容区 */}
|
||||
<main className="flex-1 py-6 sm:py-8 lg:py-10 space-y-6 sm:space-y-8 lg:space-y-10">
|
||||
{/* 响应时间趋势图表 */}
|
||||
<ResponseTimeChart
|
||||
monitorId={id!}
|
||||
monitorStats={monitorStats}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* 各探针详细数据 */}
|
||||
<Card title="探针监控详情" description="各探针的当前状态和统计数据">
|
||||
<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">
|
||||
探针 ID
|
||||
</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>
|
||||
{monitorDetail.type === '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.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>
|
||||
{monitorDetail.type === '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>
|
||||
</Card>
|
||||
{/* 各探针详细数据 */}
|
||||
<AgentStatsTable
|
||||
monitorStats={monitorStats}
|
||||
monitorType={monitorDetail.type}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -311,6 +311,7 @@ export interface PublicMonitor {
|
||||
// 探针监控统计
|
||||
export interface AgentMonitorStat {
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
monitorId: string;
|
||||
type: string;
|
||||
target: string;
|
||||
|
||||
Reference in new issue
Block a user