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 (
+
+
+
+
+
+
+
+ {stat.agentName || stat.agentId.substring(0, 8)}
+
+
+
+ |
+
+
+ |
+
+
+
+
+ {formatTime(stat.responseTime)}
+
+
+ |
+
+ {formatDateTime(stat.checkedAt)}
+ |
+ {monitorType === 'https' && (
+
+ {stat.certExpiryTime ? (
+
+ ) : (
+ -
+ )}
+ |
+ )}
+
+ {stat.status === 'down' && 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 }) => (
+
+);
+
+// 时间范围选择器组件
+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.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 }) => (
-
-);
-
-// 自定义 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 (
-
- );
- })}
-
-
- ) : (
-
+ {/* 响应时间趋势图表 */}
+
- )}
-
- {/* 各探针详细数据 */}
-
-
-
-
-
- |
- 探针 ID
- |
-
- 状态
- |
-
- 响应时间
- |
-
- 最后检测
- |
- {monitorDetail.type === 'https' && (
-
- 证书信息
- |
- )}
-
- 错误信息
- |
-
-
-
- {monitorStats.map((stat, index) => {
- const color = AGENT_COLORS[index % AGENT_COLORS.length];
- return (
-
-
-
-
-
-
-
- {stat.agentId.substring(0, 8)}...
-
-
-
- |
-
-
- |
-
-
-
-
- {formatTime(stat.responseTime)}
-
-
- |
-
- {formatDateTime(stat.checkedAt)}
- |
- {monitorDetail.type === 'https' && (
-
- {stat.certExpiryTime ? (
-
- ) : (
- -
- )}
- |
- )}
-
- {stat.status === 'down' && 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.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;