diff --git a/cmd/serv/main.go b/cmd/serv/main.go index a9724c9..9edeed6 100644 --- a/cmd/serv/main.go +++ b/cmd/serv/main.go @@ -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) } diff --git a/docker-compose.yml b/docker-compose.yml index a3e1fef..23793f9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/internal/migrate/v0_0_13/metric.go b/internal/migrate/v0_0_13/metric.go new file mode 100644 index 0000000..e6cfc8a --- /dev/null +++ b/internal/migrate/v0_0_13/metric.go @@ -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" +} diff --git a/internal/migrate/v0_0_13/migrate.go b/internal/migrate/v0_0_13/migrate.go new file mode 100644 index 0000000..2982ff4 --- /dev/null +++ b/internal/migrate/v0_0_13/migrate.go @@ -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}, + } +} diff --git a/internal/protocol/message.go b/internal/protocol/message.go index a292d02..dadf299 100644 --- a/internal/protocol/message.go +++ b/internal/protocol/message.go @@ -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"` // 监控目标 diff --git a/internal/service/metric_service.go b/internal/service/metric_service.go index ce4a7b4..3ec6216 100644 --- a/internal/service/metric_service.go +++ b/internal/service/metric_service.go @@ -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) } diff --git a/web/src/components/monitor/AgentStatsTable.tsx b/web/src/components/monitor/AgentStatsTable.tsx new file mode 100644 index 0000000..b19b1f6 --- /dev/null +++ b/web/src/components/monitor/AgentStatsTable.tsx @@ -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 ( +
+

暂无探针数据

+
+ ); + } + + return ( +
+ {/* 装饰性边框 */} +
+
+
+
+ +
+
+

探针监控详情

+

各探针的当前状态和统计数据

+
+ +
+ + + + + + + + {monitorType === 'https' && ( + + )} + + + + + {monitorStats.map((stat, index) => { + const color = AGENT_COLORS[index % AGENT_COLORS.length]; + return ( + + + + + + {monitorType === 'https' && ( + + )} + + + ); + })} + +
+ 探针名称 + + 状态 + + 响应时间 + + 最后检测 + + 证书信息 + + 错误信息 +
+
+ +
+ + + {stat.agentName || stat.agentId.substring(0, 8)} + +
+
+
+ + +
+ + + {formatTime(stat.responseTime)} + +
+
+ {formatDateTime(stat.checkedAt)} + + {stat.certExpiryTime ? ( + + ) : ( + - + )} + + {stat.status === 'down' && stat.message ? ( +
+ + + {stat.message} + +
+ ) : ( + - + )} +
+
+
+
+ ); +}; diff --git a/web/src/components/monitor/MonitorHero.tsx b/web/src/components/monitor/MonitorHero.tsx new file mode 100644 index 0000000..9f4ada4 --- /dev/null +++ b/web/src/components/monitor/MonitorHero.tsx @@ -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 ( +
+ {/* 装饰性边框 */} +
+
+
+
+
+ +
+ {/* 返回按钮 */} + + + {/* 监控信息 */} +
+
+
+ +
+
+
+ MONITOR_ID: {monitor.id.toString().substring(0, 8)} +
+
+

{monitor.name}

+ +
+

+ {monitor.showTargetPublic ? monitor.target : '******'} +

+
+
+ + {/* 统计卡片 */} +
+ + + + +
+
+ + {/* 证书信息(如果是 HTTPS)*/} + {monitor.type === 'https' && monitor.certExpiryTime && ( +
+ SSL 证书: + +
+ )} +
+
+ ); +}; diff --git a/web/src/components/monitor/ResponseTimeChart.tsx b/web/src/components/monitor/ResponseTimeChart.tsx new file mode 100644 index 0000000..9f92f0f --- /dev/null +++ b/web/src/components/monitor/ResponseTimeChart.tsx @@ -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 ( +
+

{label}

+
+ {payload.map((entry: any, index: number) => ( +
+ + + {entry.name}: {entry.value} ms + +
+ ))} +
+
+ ); +}; + +// 图表占位符组件 +const ChartPlaceholder = ({subtitle, heightClass}: { subtitle: string; heightClass: string }) => ( +
+
+ +

{subtitle}

+
+
+); + +// 时间范围选择器组件 +const TimeRangeSelector = ({ + value, + onChange, + options + }: { + value: string; + onChange: (value: string) => void; + options: Array<{ value: string; label: string }>; +}) => ( +
+ {options.map(option => ( + + ))} +
+); + +/** + * 响应时间趋势图表组件 + * 显示监控各探针的响应时间变化 + */ +export const ResponseTimeChart = ({monitorId, monitorStats}: ResponseTimeChartProps) => { + const [selectedAgent, setSelectedAgent] = useState('all'); + const [timeRange, setTimeRange] = useState('1d'); + + // 获取历史数据 + const {data: historyData} = useQuery({ + 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 = {}; + + 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 ( +
+ {/* 装饰性边框 */} +
+
+
+
+ +
+
+
+

响应时间趋势

+

监控各探针的响应时间变化

+
+
+ + {availableAgents.length > 0 && ( + + )} +
+
+ + {chartData.length > 0 ? ( + + + + {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 ( + + + + + ); + })} + + + + `${value}ms`} + /> + }/> + + {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 ( + + ); + })} + + + ) : ( + + )} +
+
+ ); +}; diff --git a/web/src/components/monitor/index.ts b/web/src/components/monitor/index.ts new file mode 100644 index 0000000..8401b60 --- /dev/null +++ b/web/src/components/monitor/index.ts @@ -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'; diff --git a/web/src/components/server/GpuMonitorSection.tsx b/web/src/components/server/GpuMonitorSection.tsx new file mode 100644 index 0000000..cce578a --- /dev/null +++ b/web/src/components/server/GpuMonitorSection.tsx @@ -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 ( + +
+ {latestMetrics.gpu.map((gpu) => ( +
+
+
+ + + +
+

GPU {gpu.index}

+

{gpu.name}

+
+
+ + {gpu.utilization.toFixed(1)}% + +
+
+
+ 温度 + {gpu.temperature.toFixed(1)}°C +
+
+ 显存 + + {formatBytes(gpu.memoryUsed)} / {formatBytes(gpu.memoryTotal)} + +
+
+ 功耗 + {gpu.powerDraw.toFixed(1)}W +
+
+ 风扇转速 + {gpu.fanSpeed.toFixed(0)}% +
+
+
+ ))} +
+
+ ); +}; diff --git a/web/src/components/server/NetworkConnectionSection.tsx b/web/src/components/server/NetworkConnectionSection.tsx new file mode 100644 index 0000000..7b60092 --- /dev/null +++ b/web/src/components/server/NetworkConnectionSection.tsx @@ -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 ( + +
+
+
Total
+
{latestMetrics.networkConnection.total}
+
+
+
ESTABLISHED
+
{latestMetrics.networkConnection.established}
+
+
+
TIME_WAIT
+
{latestMetrics.networkConnection.timeWait}
+
+
+
LISTEN
+
{latestMetrics.networkConnection.listen}
+
+
+
CLOSE_WAIT
+
{latestMetrics.networkConnection.closeWait}
+
+
+
OTHER
+
+ {latestMetrics.networkConnection.synSent + + latestMetrics.networkConnection.synRecv + + latestMetrics.networkConnection.finWait1 + + latestMetrics.networkConnection.finWait2 + + latestMetrics.networkConnection.close + + latestMetrics.networkConnection.lastAck + + latestMetrics.networkConnection.closing} +
+
+
+
+ ); +}; diff --git a/web/src/components/server/ServerHero.tsx b/web/src/components/server/ServerHero.tsx new file mode 100644 index 0000000..d6366a7 --- /dev/null +++ b/web/src/components/server/ServerHero.tsx @@ -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 ( +
+
+
+
+ +
+
+
+

{displayName}

+ + + {statusText} + +
+

+ {[agent.hostname, agent.ip].filter(Boolean).join(' · ') || '-'} +

+
+
+
+ +
+ {heroStats.map((stat) => ( + + ))} +
+
+
+ 探针 ID:{agent.id} + + 版本:{agent.version || '-'} + + 网络累计:{networkSummary} +
+
+
+ ); +}; diff --git a/web/src/components/server/SystemInfoSection.tsx b/web/src/components/server/SystemInfoSection.tsx new file mode 100644 index 0000000..162c1f5 --- /dev/null +++ b/web/src/components/server/SystemInfoSection.tsx @@ -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 ( + +
+
+
+

运行环境

+

来自最近一次探针上报的硬件与系统信息

+
+ +
+
+
+

运行状态

+

关键时间与网络指标,帮助快速判断主机健康状况

+
+ +
+
+
+ +
+
+ ); +}; diff --git a/web/src/components/server/TemperatureMonitorSection.tsx b/web/src/components/server/TemperatureMonitorSection.tsx new file mode 100644 index 0000000..b859ec7 --- /dev/null +++ b/web/src/components/server/TemperatureMonitorSection.tsx @@ -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 ( + +
+ {latestMetrics.temperature.sort((a, b) => a.sensorKey.localeCompare(b.sensorKey)).map((temp) => ( +
+
+ +

{temp.type}

+
+

{temp.temperature.toFixed(1)}°C

+
+ ))} +
+
+ ); +}; diff --git a/web/src/components/server/charts/ChartContainer.tsx b/web/src/components/server/charts/ChartContainer.tsx new file mode 100644 index 0000000..fcd1f77 --- /dev/null +++ b/web/src/components/server/charts/ChartContainer.tsx @@ -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 ( +
+
+

+ + + + {title} +

+ {action} +
+ {children} +
+ ); +}; diff --git a/web/src/components/server/charts/CpuChart.tsx b/web/src/components/server/charts/CpuChart.tsx new file mode 100644 index 0000000..01ff0e9 --- /dev/null +++ b/web/src/components/server/charts/CpuChart.tsx @@ -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 ( + + + + ); + } + + return ( + + {chartData.length > 0 ? ( + + + + + + + + + + + `${value}%`} + /> + }/> + + + + ) : ( + + )} + + ); +}; diff --git a/web/src/components/server/charts/DiskIOChart.tsx b/web/src/components/server/charts/DiskIOChart.tsx new file mode 100644 index 0000000..e27e8f9 --- /dev/null +++ b/web/src/components/server/charts/DiskIOChart.tsx @@ -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(); + + 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 ( + + + + ); + } + + return ( + + {chartData.length > 0 ? ( + + + + + + + + + + + + + + + `${value} MB`} + /> + }/> + + + + + + ) : ( + + )} + + ); +}; diff --git a/web/src/components/server/charts/GpuChart.tsx b/web/src/components/server/charts/GpuChart.tsx new file mode 100644 index 0000000..4b8aba2 --- /dev/null +++ b/web/src/components/server/charts/GpuChart.tsx @@ -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(); + + 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 ( + + + + ); + } + + // 如果没有 GPU 数据,不渲染组件 + if (chartData.length === 0) { + return null; + } + + return ( + + + + + + `${value}%`} + /> + `${value}°C`} + /> + }/> + + + + + + + ); +}; diff --git a/web/src/components/server/charts/MemoryChart.tsx b/web/src/components/server/charts/MemoryChart.tsx new file mode 100644 index 0000000..2c5159a --- /dev/null +++ b/web/src/components/server/charts/MemoryChart.tsx @@ -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 ( + + + + ); + } + + return ( + + {chartData.length > 0 ? ( + + + + + + + + + + + `${value}%`} + /> + }/> + + + + ) : ( + + )} + + ); +}; diff --git a/web/src/components/server/charts/NetworkChart.tsx b/web/src/components/server/charts/NetworkChart.tsx new file mode 100644 index 0000000..b3550c8 --- /dev/null +++ b/web/src/components/server/charts/NetworkChart.tsx @@ -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('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(); + + 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 && ( + + ); + + // 渲染 + if (isLoading) { + return ( + + + + ); + } + + return ( + + {chartData.length > 0 ? ( + + + + + + + + + + + + + + + `${value} MB`} + /> + }/> + + + + + + ) : ( + + )} + + ); +}; diff --git a/web/src/components/server/charts/NetworkConnectionChart.tsx b/web/src/components/server/charts/NetworkConnectionChart.tsx new file mode 100644 index 0000000..c56bfad --- /dev/null +++ b/web/src/components/server/charts/NetworkConnectionChart.tsx @@ -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(); + + 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 ( + + + + ); + } + + return ( + + {chartData.length > 0 ? ( + + + + + + }/> + + + + + + + + ) : ( + + )} + + ); +}; diff --git a/web/src/components/server/charts/TemperatureChart.tsx b/web/src/components/server/charts/TemperatureChart.tsx new file mode 100644 index 0000000..b08d6f3 --- /dev/null +++ b/web/src/components/server/charts/TemperatureChart.tsx @@ -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('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(); + + 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 && ( + + ); + + // 渲染 + if (isLoading) { + return ( + + + + ); + } + + // 如果没有温度数据,不渲染组件 + if (chartData.length === 0 || temperatureTypes.length === 0) { + return null; + } + + return ( + + + + + + `${value}°C`} + /> + }/> + + {/* 为选中的温度类型渲染线条 */} + {filteredTemperatureTypes.map((type, index) => { + // 使用预定义颜色,如果没有则使用默认颜色 + const color = TEMPERATURE_COLORS[type] || `hsl(${(index * 60) % 360}, 70%, 50%)`; + return ( + + ); + })} + + + + ); +}; diff --git a/web/src/components/server/charts/index.ts b/web/src/components/server/charts/index.ts new file mode 100644 index 0000000..aba0a52 --- /dev/null +++ b/web/src/components/server/charts/index.ts @@ -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'; diff --git a/web/src/components/server/index.ts b/web/src/components/server/index.ts index 66438e3..9e76011 100644 --- a/web/src/components/server/index.ts +++ b/web/src/components/server/index.ts @@ -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'; diff --git a/web/src/hooks/server/index.ts b/web/src/hooks/server/index.ts new file mode 100644 index 0000000..94abf5f --- /dev/null +++ b/web/src/hooks/server/index.ts @@ -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'; diff --git a/web/src/hooks/server/queries/index.ts b/web/src/hooks/server/queries/index.ts new file mode 100644 index 0000000..454afb0 --- /dev/null +++ b/web/src/hooks/server/queries/index.ts @@ -0,0 +1,4 @@ +export {useAgentQuery} from './useAgentQuery'; +export {useLatestMetricsQuery} from './useLatestMetricsQuery'; +export {useMetricsQuery} from './useMetricsQuery'; +export {useNetworkInterfacesQuery} from './useNetworkInterfacesQuery'; diff --git a/web/src/hooks/server/queries/useAgentQuery.ts b/web/src/hooks/server/queries/useAgentQuery.ts new file mode 100644 index 0000000..9d168f3 --- /dev/null +++ b/web/src/hooks/server/queries/useAgentQuery.ts @@ -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分钟缓存 + }); +}; diff --git a/web/src/hooks/server/queries/useLatestMetricsQuery.ts b/web/src/hooks/server/queries/useLatestMetricsQuery.ts new file mode 100644 index 0000000..46af29d --- /dev/null +++ b/web/src/hooks/server/queries/useLatestMetricsQuery.ts @@ -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秒自动刷新 + }); +}; diff --git a/web/src/hooks/server/queries/useMetricsQuery.ts b/web/src/hooks/server/queries/useMetricsQuery.ts new file mode 100644 index 0000000..832429d --- /dev/null +++ b/web/src/hooks/server/queries/useMetricsQuery.ts @@ -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秒自动刷新 + }); +}; diff --git a/web/src/hooks/server/queries/useNetworkInterfacesQuery.ts b/web/src/hooks/server/queries/useNetworkInterfacesQuery.ts new file mode 100644 index 0000000..9f6c61b --- /dev/null +++ b/web/src/hooks/server/queries/useNetworkInterfacesQuery.ts @@ -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, + }); +}; diff --git a/web/src/pages/Public/MonitorDetail.tsx b/web/src/pages/Public/MonitorDetail.tsx index fc8f032..851ae08 100644 --- a/web/src/pages/Public/MonitorDetail.tsx +++ b/web/src/pages/Public/MonitorDetail.tsx @@ -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 = () => ( -
-
- - 加载监控详情中... -
-
-); - -// 空状态组件 -const EmptyState = () => ( -
- -

未找到监控数据

-

请检查监控 ID 是否正确

-
-); - -// 卡片容器组件 -const Card = ({ - title, - description, - action, - children - }: { - title: string; - description?: string; - action?: React.ReactNode; - children: React.ReactNode; -}) => ( -
- {/* 装饰性边框 */} -
-
-
-
- -
-
-
-

{title}

- {description &&

{description}

} -
- {action &&
{action}
} -
- {children} -
-
-); - -// 图表占位符组件 -const ChartPlaceholder = ({subtitle, heightClass}: { subtitle: string; heightClass: string }) => ( -
-
- -

{subtitle}

-
-
-); - -// 自定义 Tooltip 组件 -const CustomTooltip = ({active, payload, label}: any) => { - if (!active || !payload || payload.length === 0) return null; - - return ( -
-

{label}

-
- {payload.map((entry: any, index: number) => ( -
- - - {entry.name}: {entry.value} ms - -
- ))} -
-
- ); -}; - -// 时间范围选择器组件 -const TimeRangeSelector = ({ - value, - onChange, - options - }: { - value: string; - onChange: (value: string) => void; - options: Array<{ value: string; label: string }>; -}) => ( -
- {options.map(option => ( - - ))} -
-); - +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('all'); - const [timeRange, setTimeRange] = useState('1d'); // 获取监控详情(聚合数据) const {data: monitorDetail, isLoading} = useQuery({ @@ -156,365 +37,38 @@ const MonitorDetail = () => { enabled: !!id, }); - // 获取历史数据 - const {data: historyData} = useQuery({ - 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 = {}; - - 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 ( -
- -
- ); + return ; } if (!monitorDetail) { - return ( -
- -
- ); + return ; } return ( -
- {/* Hero Section */} -
- {/* 装饰性边框 */} -
-
-
-
-
+
+
+ {/* 头部区域 */} + navigate('/monitors')} + /> -
- {/* 返回按钮 */} - - - {/* 监控信息 */} -
-
-
- -
-
-
- MONITOR_ID: {monitorDetail.id.toString().substring(0, 8)} -
-
-

{monitorDetail.name}

- -
-

- {monitorDetail.showTargetPublic ? monitorDetail.target : '******'} -

-
-
- - {/* 统计卡片 */} -
- - - - -
-
- - {/* 证书信息(如果是 HTTPS)*/} - {monitorDetail.type === 'https' && monitorDetail.certExpiryTime && ( -
- SSL 证书: - -
- )} -
-
- - {/* 响应时间趋势图表 */} - - - {availableAgents.length > 0 && ( - - )} -
- } - > - {chartData.length > 0 ? ( - - - - {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 ( - - - - - ); - })} - - - - `${value}ms`} - /> - }/> - - {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 ( - - ); - })} - - - ) : ( - + {/* 响应时间趋势图表 */} + - )} - - {/* 各探针详细数据 */} - -
- - - - - - - - {monitorDetail.type === 'https' && ( - - )} - - - - - {monitorStats.map((stat, index) => { - const color = AGENT_COLORS[index % AGENT_COLORS.length]; - return ( - - - - - - {monitorDetail.type === 'https' && ( - - )} - - - ); - })} - -
- 探针 ID - - 状态 - - 响应时间 - - 最后检测 - - 证书信息 - - 错误信息 -
-
- -
- - - {stat.agentId.substring(0, 8)}... - -
-
-
- - -
- - - {formatTime(stat.responseTime)} - -
-
- {formatDateTime(stat.checkedAt)} - - {stat.certExpiryTime ? ( - - ) : ( - - - )} - - {stat.status === 'down' && stat.message ? ( -
- - - {stat.message} - -
- ) : ( - - - )} -
-
-
+ {/* 各探针详细数据 */} + + +
); }; diff --git a/web/src/pages/Public/ServerDetail.tsx b/web/src/pages/Public/ServerDetail.tsx index 439b5e2..97585a2 100644 --- a/web/src/pages/Public/ServerDetail.tsx +++ b/web/src/pages/Public/ServerDetail.tsx @@ -1,479 +1,42 @@ -import {type ReactNode, useEffect, useMemo, useState} from 'react'; +import {useState} from 'react'; import {useNavigate, useParams} from 'react-router-dom'; -import {ArrowLeft, Cpu, Database, HardDrive, MemoryStick, Network, Server, Thermometer, Zap} from 'lucide-react'; +import {Card, EmptyState, LoadingSpinner, TimeRangeSelector} from '@/components/common'; import { - Area, - AreaChart, - CartesianGrid, - Legend, - Line, - LineChart, - ResponsiveContainer, - Tooltip, - XAxis, - YAxis, -} from 'recharts'; -import {getAvailableNetworkInterfaces} from '@/api/agent.ts'; -import {cn} from '@/lib/utils'; -// 工具函数 -import {formatBytes, formatDateTime, formatPercentValue, formatUptime} from '@/utils/util'; -// Hooks -import {useAgentOverview, useAggregatedMetrics} from '@/hooks/server'; -// 常量 -import {INTERFACE_COLORS, TEMPERATURE_COLORS} from '@/constants/server'; + GpuMonitorSection, + NetworkConnectionSection, + ServerHero, + SystemInfoSection, + TemperatureMonitorSection, +} from '@/components/server'; +import { + CpuChart, + DiskIOChart, + GpuChart, + MemoryChart, + NetworkChart, + NetworkConnectionChart, + TemperatureChart, +} from '@/components/server/charts'; +import {useAgentQuery, useLatestMetricsQuery} from '@/hooks/server/queries'; import {SERVER_TIME_RANGE_OPTIONS} from '@/constants/time'; -// 公共组件 -import { - Card, - ChartPlaceholder, - CustomTooltip, - EmptyState, - LoadingSpinner, - TimeRangeSelector -} from '@/components/common'; -// 服务器组件 -import {InfoGrid, type SnapshotCardData, SnapshotSection} from '@/components/server'; -import LittleStatCard from "@/components/common/LittleStatCard.tsx"; - - -type AccentVariant = 'blue' | 'emerald' | 'purple' | 'amber'; - +/** + * 服务器详情页面 + * 显示服务器的详细信息、实时指标和历史趋势图表 + */ const ServerDetail = () => { const {id} = useParams<{ id: string }>(); const navigate = useNavigate(); - const [timeRange, setTimeRange] = useState('15m'); - const [selectedInterface, setSelectedInterface] = useState('all'); - const [selectedTempType, setSelectedTempType] = useState('all'); - const {agent, latestMetrics, loading} = useAgentOverview(id); - const metricsData = useAggregatedMetrics(id, timeRange, selectedInterface); - const cpuChartData = useMemo(() => { - // CPU 数据:取第一个系列(usage) - const cpuSeries = metricsData.cpu.find(s => s.name === 'usage'); - if (!cpuSeries) return []; + // 查询基础数据(用于页面头部和系统信息) + const {data: agentResponse, isLoading} = useAgentQuery(id); + const {data: latestMetricsResponse} = useLatestMetricsQuery(id); - 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, - })); - }, [metricsData.cpu]); + const agent = agentResponse?.data; + const latestMetrics = latestMetricsResponse?.data || null; - const memoryChartData = useMemo(() => { - // Memory 数据:取第一个系列(usage) - const memorySeries = metricsData.memory.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, - })); - }, [metricsData.memory]); - - // 获取所有可用的网卡列表(从后端接口获取) - const [availableInterfaces, setAvailableInterfaces] = useState([]); - - useEffect(() => { - if (!id) { - setAvailableInterfaces([]); - return; - } - - let cancelled = false; - - const fetchInterfaces = async () => { - try { - const response = await getAvailableNetworkInterfaces(id); - if (!cancelled) { - setAvailableInterfaces(response.data.interfaces || []); - } - } catch (error) { - console.error('Failed to load network interfaces:', error); - if (!cancelled) { - setAvailableInterfaces([]); - } - } - }; - - fetchInterfaces(); - - return () => { - cancelled = true; - }; - }, [id]); - - // 当网卡列表变化时,如果当前选中的网卡不在列表中,重置为 'all' - useEffect(() => { - if (selectedInterface !== 'all' && availableInterfaces.length > 0) { - if (!availableInterfaces.includes(selectedInterface)) { - setSelectedInterface('all'); - } - } - }, [availableInterfaces, selectedInterface]); - - const networkChartData = useMemo(() => { - if (metricsData.network.length === 0) return []; - - // 找到上行和下行系列 - const uploadSeries = metricsData.network.find(s => s.name === 'upload'); - const downloadSeries = metricsData.network.find(s => s.name === 'download'); - - if (!uploadSeries || !downloadSeries) return []; - - // 按时间戳对齐数据 - const timeMap = new Map(); - - 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()); - }, [metricsData.network]); - - // Disk I/O 图表数据 - const diskIOChartData = useMemo(() => { - if (metricsData.diskIO.length === 0) return []; - - // 找到读和写系列 - const readSeries = metricsData.diskIO.find(s => s.name === 'read'); - const writeSeries = metricsData.diskIO.find(s => s.name === 'write'); - - if (!readSeries || !writeSeries) return []; - - // 按时间戳对齐数据 - const timeMap = new Map(); - - 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()); - }, [metricsData.diskIO]); - - // GPU 图表数据(暂不支持,需要后端返回温度系列) - const gpuChartData = useMemo(() => { - if (metricsData.gpu.length === 0) return []; - - // 按时间戳聚合利用率和温度系列 - const timeMap = new Map(); - - const utilizationSeries = metricsData.gpu.find(s => s.name === 'utilization'); - const temperatureSeries = metricsData.gpu.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()); - }, [metricsData.gpu]); - - // Temperature 图表数据(按类型分组显示各类型的温度) - const temperatureChartData = useMemo(() => { - if (metricsData.temperature.length === 0) return []; - - // 按时间戳聚合所有温度系列 - const timeMap = new Map(); - - metricsData.temperature.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()); - }, [metricsData.temperature]); - - // 提取所有唯一的温度类型(用于图表 Line 渲染和下拉选择器) - const temperatureTypes = useMemo(() => { - return metricsData.temperature.map(s => s.name).sort(); - }, [metricsData.temperature]); - - // 网络连接图表数据 - const networkConnectionChartData = useMemo(() => { - if (metricsData.networkConnection.length === 0) return []; - - // 按时间戳聚合所有连接状态系列 - const timeMap = new Map(); - - metricsData.networkConnection.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()); - }, [metricsData.networkConnection]); - - // 根据选中的类型过滤温度数据 - 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 snapshotCards: SnapshotCardData[] = useMemo(() => { - if (!latestMetrics) { - return [] as Array<{ - key: string; - icon: typeof Cpu; - title: string; - usagePercent: string; - accent: AccentVariant; - metrics: Array<{ label: string; value: ReactNode }>; - }>; - } - - const cards: Array<{ - key: string; - icon: typeof Cpu; - title: string; - usagePercent: string; - accent: AccentVariant; - metrics: Array<{ label: string; value: ReactNode }>; - }> = []; - - cards.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) : '-', - }, - ], - }); - - cards.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)}` - }, - ], - }); - - cards.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 ?? '-'}, - ], - }); - - cards.push({ - key: 'network', - icon: Network, - title: '网络流量', - usagePercent: latestMetrics.network - ? `${formatBytes(latestMetrics.network.totalBytesSentRate)}/s` - : '—', - accent: 'amber', - metrics: [ - { - 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); - - cards.push({ - key: 'traffic', - icon: Database, - title: '流量统计', - usagePercent: `${formatPercentValue(trafficUsedPercent)}%`, - accent: 'blue', - metrics: [ - { - label: '已用 / 总量', - value: `${formatBytes(agent.trafficUsed || 0)} / ${formatBytes(agent.trafficLimit)}`, - }, - { - label: '剩余流量', - value: `${formatBytes(trafficRemaining > 0 ? trafficRemaining : 0)}`, - }, - ...(agent.trafficResetDay && agent.trafficResetDay > 0 - ? [{ - label: '重置日期', - value: `每月${agent.trafficResetDay}号`, - }] - : []), - ], - }); - } - - return cards; - }, [latestMetrics, agent]); - - 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 bootTimeDisplay = latestMetrics?.host?.bootTime - ? formatDateTime(latestMetrics.host.bootTime * 1000) - : '-'; - const lastSeenDisplay = agent ? formatDateTime(agent.lastSeenAt) : '-'; - 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 networkSummary = latestMetrics?.network - ? `${formatBytes(latestMetrics.network.totalBytesSentTotal)} ↑ / ${formatBytes( - latestMetrics.network.totalBytesRecvTotal, - )} ↓` - : '—'; - - 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 statusInfo = [ - {label: '启动时间', value: bootTimeDisplay}, - {label: '运行时间', value: uptimeDisplay}, - {label: '最近心跳', value: lastSeenDisplay}, - {label: '进程数', value: latestMetrics?.host?.procs ?? '-'}, - {label: '网络累计', value: networkSummary}, - ]; - - const heroStats = [ - {label: '运行系统', value: platformDisplay || '-'}, - {label: '硬件架构', value: architectureDisplay || '-'}, - {label: '最近心跳', value: lastSeenDisplay}, - {label: '运行时长', value: uptimeDisplay}, - ]; - - if (loading) { + if (isLoading) { return ; } @@ -483,674 +46,67 @@ const ServerDetail = () => { return (
-
-
-
-
-
- -
-
- -
-
-
-

{displayName}

- - - {statusText} - -
-

- {[agent.hostname, agent.ip].filter(Boolean).join(' · ') || '-'} -

-
-
-
+
+ {/* 头部区域 */} + navigate('/')} + /> -
- {heroStats.map((stat) => ( - - ))} -
-
-
- 探针 ID:{agent.id} - - 版本:{agent.version || '-'} - - 网络累计:{networkSummary} -
-
-
- -
- -
-
-
-

运行环境

-

来自最近一次探针上报的硬件与系统信息

-
- -
-
-
-

运行状态

-

关键时间与网络指标,帮助快速判断主机健康状况

-
- -
-
-
- {latestMetrics?.networkConnection && ( -
-

网络连接统计

-

TCP 连接各状态的实时统计数据

-
-
-
Total -
-
{latestMetrics.networkConnection.total}
-
-
-
ESTABLISHED -
-
{latestMetrics.networkConnection.established}
-
-
-
TIME_WAIT -
-
{latestMetrics.networkConnection.timeWait}
-
-
-
LISTEN -
-
{latestMetrics.networkConnection.listen}
-
-
-
CLOSE_WAIT -
-
{latestMetrics.networkConnection.closeWait}
-
-
-
OTHER -
-
- {latestMetrics.networkConnection.synSent + - latestMetrics.networkConnection.synRecv + - latestMetrics.networkConnection.finWait1 + - latestMetrics.networkConnection.finWait2 + - latestMetrics.networkConnection.close + - latestMetrics.networkConnection.lastAck + - latestMetrics.networkConnection.closing} -
-
-
-
- )} - -
-
+ {/* 主内容区 */} +
+ {/* 系统信息 */} + + {/* 历史趋势图表 */} } + action={ + + } > -
-
-

- - - - CPU 使用率 -

- {cpuChartData.length > 0 ? ( - - - - - - - - - - - `${value}%`} - /> - }/> - - - - ) : ( - - )} -
+
+ {/* 核心指标:大屏 2 列,小屏 1 列 */} +
+ + +
-
-

- - - - 内存使用率 -

- {memoryChartData.length > 0 ? ( - - - - - - - - - - - `${value}%`} - /> - }/> - - - - ) : ( - - )} -
+ {/* 网络相关:大屏 2 列,中屏 1 列 */} +
+ + +
-
-
-

- - - - 网络流量(MB/s) -

- {availableInterfaces.length > 0 && ( - - )} -
- {networkChartData.length > 0 ? ( - - - - - - - - - - - - - - - `${value} MB`} - /> - }/> - - {/* 渲染上行和下行区域 */} - - - - - ) : ( - - )} -
+ {/* 进阶指标:单列全宽 */} +
+ +
-
-

- - - - 磁盘 I/O (MB/s) -

- {diskIOChartData.length > 0 ? ( - - - - - - - - - - - - - - - `${value} MB`} - /> - }/> - - - - - - ) : ( - - )} -
- -
-

- - - - 网络连接统计 -

- {networkConnectionChartData.length > 0 ? ( - - - - - - }/> - - - - - - - - ) : ( - - )} -
- - {gpuChartData.length > 0 && ( -
-

- - - - GPU 使用率与温度 -

- - - - - `${value}%`} - /> - `${value}°C`} - /> - }/> - - - - - -
- )} - - {temperatureChartData.length > 0 && temperatureTypes.length > 0 && ( -
-
-

- - - - 系统温度 -

- {temperatureTypes.length > 1 && ( - - )} -
- - - - - `${value}°C`} - /> - }/> - - {/* 为选中的温度类型渲染线条 */} - {filteredTemperatureTypes.map((type, index) => { - // 使用预定义颜色,如果没有则使用默认颜色 - const color = TEMPERATURE_COLORS[type] || `hsl(${(index * 60) % 360}, 70%, 50%)`; - return ( - - ); - })} - - -
- )} + {/* 硬件指标:条件渲染,单列全宽 */} +
+ + +
+ {/* 网络连接统计 */} + {/* GPU 监控 */} - {latestMetrics?.gpu && latestMetrics.gpu.length > 0 && ( - -
- {latestMetrics.gpu.map((gpu) => ( -
-
-
- - - -
-

GPU {gpu.index}

-

{gpu.name}

-
-
- - {gpu.utilization.toFixed(1)}% - -
-
-
- 温度 - {gpu.temperature.toFixed(1)}°C -
-
- 显存 - - {formatBytes(gpu.memoryUsed)} / {formatBytes(gpu.memoryTotal)} - -
-
- 功耗 - {gpu.powerDraw.toFixed(1)}W -
-
- 风扇转速 - {gpu.fanSpeed.toFixed(0)}% -
-
-
- ))} -
-
- )} + {/* 温度监控 */} - {latestMetrics?.temperature && latestMetrics.temperature.length > 0 && ( - -
- {latestMetrics.temperature.sort((a, b) => a.sensorKey.localeCompare(b.sensorKey)).map((temp) => ( -
-
- -

{temp.type}

-
-

{temp.temperature.toFixed(1)}°C

-
- ))} -
-
- )} +
diff --git a/web/src/types/index.ts b/web/src/types/index.ts index b72c78b..238d8ac 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -311,6 +311,7 @@ export interface PublicMonitor { // 探针监控统计 export interface AgentMonitorStat { agentId: string; + agentName: string; monitorId: string; type: string; target: string;