From 9c644bb5b598d73f89b6db1c75de1721f056b5b6 Mon Sep 17 00:00:00 2001 From: dushixiang Date: Fri, 12 Dec 2025 21:37:58 +0800 Subject: [PATCH 01/18] =?UTF-8?q?=E5=88=87=E6=8D=A2=E6=97=B6=E5=BA=8F?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=AD=98=E5=82=A8=E4=B8=BAvictoriametrics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + docker-compose.yml | 17 +- internal/app.go | 29 +- internal/config/config.go | 20 +- internal/handler/agent_handler.go | 15 +- internal/models/metric.go | 338 +-------- internal/models/property.go | 5 - internal/repo/metric_repo.go | 997 +++----------------------- internal/service/agent_service.go | 4 +- internal/service/alert_service.go | 8 +- internal/service/metric_converter.go | 152 ++++ internal/service/metric_service.go | 970 +++++++++++-------------- internal/service/monitor_service.go | 8 +- internal/service/property_service.go | 23 - internal/vmclient/client.go | 337 +++++++++ internal/wire.go | 35 + internal/wire_gen.go | 33 +- pkg/agent/collector/manager.go | 2 - playground/docker-compose.yml | 8 + web/src/api/agent.ts | 17 +- web/src/pages/Public/ServerDetail.tsx | 406 +++++------ 21 files changed, 1363 insertions(+), 2062 deletions(-) create mode 100644 internal/service/metric_converter.go create mode 100644 internal/vmclient/client.go diff --git a/.gitignore b/.gitignore index 11ccca6..a0dba26 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,4 @@ go.work.sum config.yaml agent.yaml +playground diff --git a/docker-compose.yml b/docker-compose.yml index 53800f4..a3e1fef 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,7 +20,20 @@ services: restart: unless-stopped networks: - pika-network - + victoriametrics: + image: victoriametrics/victoria-metrics:v1.131.0 + container_name: victoriametrics + restart: always + command: + - "-retentionPeriod=7d" # 保留 7 天数据 + - "-storageDataPath=/victoria-metrics-data" + - "-httpListenAddr=:8428" + ports: + - "8428:8428" # API, PromQL, insert, select + volumes: + - ./vmdata:/victoria-metrics-data + networks: + - pika-network pika: image: dushixiang/pika:latest container_name: pika-server @@ -33,6 +46,8 @@ services: depends_on: postgresql: condition: service_healthy + victoriametrics: + condition: service_started restart: unless-stopped networks: - pika-network diff --git a/internal/app.go b/internal/app.go index b1723a1..977ba8e 100644 --- a/internal/app.go +++ b/internal/app.go @@ -81,12 +81,6 @@ func setup(app *orz.App) error { // 启动WebSocket管理器 go components.WSManager.Run(ctx) - // 启动数据清理任务 - go components.MetricService.StartCleanupTask(ctx) - - // 启动聚合下采样任务 - go components.MetricService.StartAggregationTask(ctx) - // 启动指标监控任务(用于告警检测) go startMetricsMonitoring(ctx, components, app.Logger()) @@ -305,20 +299,11 @@ func autoMigrate(database *gorm.DB) error { return database.AutoMigrate( &models.Agent{}, &models.ApiKey{}, - &models.CPUMetric{}, - &models.MemoryMetric{}, - &models.DiskMetric{}, - &models.NetworkMetric{}, - &models.NetworkConnectionMetric{}, - &models.DiskIOMetric{}, - &models.GPUMetric{}, - &models.TemperatureMetric{}, - &models.HostMetric{}, + &models.HostMetric{}, // 保留主机静态信息表 &models.AuditResult{}, &models.Property{}, &models.AlertRecord{}, &models.AlertState{}, - &models.MonitorMetric{}, &models.MonitorTask{}, &models.MonitorStats{}, &models.TamperProtectConfig{}, @@ -326,17 +311,7 @@ func autoMigrate(database *gorm.DB) error { &models.TamperAlert{}, &models.DDNSConfig{}, &models.DDNSRecord{}, - // 聚合表 - &models.AggregatedCPUMetricModel{}, - &models.AggregatedMemoryMetricModel{}, - &models.AggregatedDiskMetricModel{}, - &models.AggregatedNetworkMetricModel{}, - &models.AggregatedNetworkConnectionMetricModel{}, - &models.AggregatedDiskIOMetricModel{}, - &models.AggregatedGPUMetricModel{}, - &models.AggregatedTemperatureMetricModel{}, - &models.AggregatedMonitorMetricModel{}, - &models.AggregationProgress{}, + // 指标数据已迁移到 VictoriaMetrics ) } diff --git a/internal/config/config.go b/internal/config/config.go index f0916d7..06f99d9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -2,11 +2,12 @@ package config // AppConfig 应用配置 type AppConfig struct { - JWT JWTConfig `json:"JWT"` - Users map[string]string `json:"Users"` // 用户名 -> bcrypt加密的密码 - OIDC *OIDCConfig `json:"OIDC"` // OIDC配置(可选) - GitHub *GitHubOAuthConfig `json:"GitHub"` // GitHub OAuth配置(可选) - GeoIP *GeoIPConfig `json:"GeoIP"` // GeoIP配置(可选) + JWT JWTConfig `json:"JWT"` + Users map[string]string `json:"Users"` // 用户名 -> bcrypt加密的密码 + OIDC *OIDCConfig `json:"OIDC"` // OIDC配置(可选) + GitHub *GitHubOAuthConfig `json:"GitHub"` // GitHub OAuth配置(可选) + GeoIP *GeoIPConfig `json:"GeoIP"` // GeoIP配置(可选) + VictoriaMetrics *VMConfig `json:"VictoriaMetrics"` // VictoriaMetrics配置(可选) } // JWTConfig JWT配置 @@ -39,3 +40,12 @@ type GeoIPConfig struct { DBPath string `json:"DBPath"` // GeoIP数据库文件路径(如:GeoLite2-City.mmdb) DBLanguage string `json:"DBLanguage"` // 数据库语言(如:zh-CN、en) } + +// VMConfig VictoriaMetrics配置 +type VMConfig struct { + Enabled bool `json:"Enabled"` // 是否启用VictoriaMetrics + URL string `json:"URL"` // VictoriaMetrics地址 + RetentionDays int `json:"RetentionDays"` // 数据保留天数(用于文档说明) + WriteTimeout int `json:"WriteTimeout"` // 写入超时(秒) + QueryTimeout int `json:"QueryTimeout"` // 查询超时(秒) +} diff --git a/internal/handler/agent_handler.go b/internal/handler/agent_handler.go index 38ffc37..1587976 100644 --- a/internal/handler/agent_handler.go +++ b/internal/handler/agent_handler.go @@ -104,8 +104,6 @@ func (h *AgentHandler) HandleWebSocket(c echo.Context) error { // 注册探针 - 使用独立的context,不依赖HTTP请求的context agent, err := h.agentService.RegisterAgent(context.Background(), c.RealIP(), ®isterReq.AgentInfo, registerReq.ApiKey) if err != nil { - h.logger.Error("failed to register agent", zap.Error(err)) - // 发送注册失败响应 h.sendRegisterError(conn, err.Error()) conn.Close() @@ -444,20 +442,13 @@ func (h *AgentHandler) GetMetrics(c echo.Context) error { } // GetMetrics 内部会自动计算最优聚合间隔 - metrics, err := h.metricService.GetMetrics(ctx, agentID, metricType, start, end, 0, interfaceName) + metrics, err := h.metricService.GetMetrics(ctx, agentID, metricType, start, end, interfaceName) if err != nil { return err } - return orz.Ok(c, orz.Map{ - "agentId": agentID, - "type": metricType, - "range": rangeParam, - "start": start, - "end": end, - "interface": interfaceName, - "metrics": metrics, - }) + // 直接返回 GetMetricsResponse,避免额外嵌套 + return orz.Ok(c, metrics) } // GetLatestMetrics 获取探针最新指标(公开接口,已登录返回全部,未登录返回公开可见) diff --git a/internal/models/metric.go b/internal/models/metric.go index e6cfc8a..29a7ed8 100644 --- a/internal/models/metric.go +++ b/internal/models/metric.go @@ -1,152 +1,6 @@ package models -// CPUMetric CPU指标 -type CPUMetric struct { - ID uint `gorm:"primaryKey;autoIncrement" json:"id"` - AgentID string `gorm:"index:idx_cpu_agent_ts,priority:1" json:"agentId"` // 探针ID - UsagePercent float64 `json:"usagePercent"` // CPU使用率 - LogicalCores int `json:"logicalCores"` // 逻辑核心数 - PhysicalCores int `json:"physicalCores"` // 物理核心数 - ModelName string `json:"modelName"` // CPU型号 - Timestamp int64 `gorm:"index:idx_cpu_agent_ts,priority:2;index:idx_cpu_ts" json:"timestamp"` // 时间戳(毫秒) -} - -func (CPUMetric) TableName() string { - return "cpu_metrics" -} - -// MemoryMetric 内存指标 -type MemoryMetric struct { - ID uint `gorm:"primaryKey;autoIncrement" json:"id"` - AgentID string `gorm:"index:idx_mem_agent_ts,priority:1" json:"agentId"` // 探针ID - Total uint64 `json:"total"` // 总内存(字节) - Used uint64 `json:"used"` // 已使用(字节) - Free uint64 `json:"free"` // 空闲(字节) - Available uint64 `json:"available"` // 可用内存(字节) - UsagePercent float64 `json:"usagePercent"` // 使用率 - SwapTotal uint64 `json:"swapTotal"` // 总交换空间(字节) - SwapUsed uint64 `json:"swapUsed"` // 已使用交换空间(字节) - SwapFree uint64 `json:"swapFree"` // 空闲交换空间(字节) - Timestamp int64 `gorm:"index:idx_mem_agent_ts,priority:2;index:idx_mem_ts" json:"timestamp"` // 时间戳(毫秒) -} - -func (MemoryMetric) TableName() string { - return "memory_metrics" -} - -// DiskMetric 磁盘指标 -type DiskMetric struct { - ID uint `gorm:"primaryKey;autoIncrement" json:"id"` - AgentID string `gorm:"index:idx_disk_agent_ts,priority:1" json:"agentId"` // 探针ID - MountPoint string `json:"mountPoint"` // 挂载点 - Total uint64 `json:"total"` // 总容量(字节) - Used uint64 `json:"used"` // 已使用(字节) - Free uint64 `json:"free"` // 空闲(字节) - UsagePercent float64 `json:"usagePercent"` // 使用率 - Timestamp int64 `gorm:"index:idx_disk_agent_ts,priority:2;index:idx_disk_ts" json:"timestamp"` // 时间戳(毫秒) -} - -func (DiskMetric) TableName() string { - return "disk_metrics" -} - -// NetworkMetric 网络指标 -type NetworkMetric struct { - ID uint `gorm:"primaryKey;autoIncrement" json:"id"` - AgentID string `gorm:"index:idx_net_agent_ts,priority:1" json:"agentId"` // 探针ID - Interface string `json:"interface"` // 网卡名称 - BytesSentRate uint64 `json:"bytesSentRate"` // 发送速率(字节/秒) - BytesRecvRate uint64 `json:"bytesRecvRate"` // 接收速率(字节/秒) - BytesSentTotal uint64 `json:"bytesSentTotal"` // 累计发送字节数 - BytesRecvTotal uint64 `json:"bytesRecvTotal"` // 累计接收字节数 - Timestamp int64 `gorm:"index:idx_net_agent_ts,priority:2;index:idx_net_ts" json:"timestamp"` // 时间戳(毫秒) -} - -func (NetworkMetric) TableName() string { - return "network_metrics" -} - -// NetworkConnectionMetric 网络连接统计指标 -type NetworkConnectionMetric struct { - ID uint `gorm:"primaryKey;autoIncrement" json:"id"` - AgentID string `gorm:"index:idx_netconn_agent_ts,priority:1" json:"agentId"` // 探针ID - Established uint32 `json:"established"` // ESTABLISHED 状态连接数 - SynSent uint32 `json:"synSent"` // SYN_SENT 状态连接数 - SynRecv uint32 `json:"synRecv"` // SYN_RECV 状态连接数 - FinWait1 uint32 `json:"finWait1"` // FIN_WAIT1 状态连接数 - FinWait2 uint32 `json:"finWait2"` // FIN_WAIT2 状态连接数 - TimeWait uint32 `json:"timeWait"` // TIME_WAIT 状态连接数 - Close uint32 `json:"close"` // CLOSE 状态连接数 - CloseWait uint32 `json:"closeWait"` // CLOSE_WAIT 状态连接数 - LastAck uint32 `json:"lastAck"` // LAST_ACK 状态连接数 - Listen uint32 `json:"listen"` // LISTEN 状态连接数 - Closing uint32 `json:"closing"` // CLOSING 状态连接数 - Total uint32 `json:"total"` // 总连接数 - Timestamp int64 `gorm:"index:idx_netconn_agent_ts,priority:2;index:idx_netconn_ts" json:"timestamp"` // 时间戳(毫秒) -} - -func (NetworkConnectionMetric) TableName() string { - return "network_connection_metrics" -} - -// DiskIOMetric 磁盘IO指标 -type DiskIOMetric struct { - ID uint `gorm:"primaryKey;autoIncrement" json:"id"` - AgentID string `gorm:"index:idx_diskio_agent_ts,priority:1" json:"agentId"` // 探针ID - Device string `json:"device"` // 设备名称 - ReadCount uint64 `json:"readCount"` // 读取次数 - WriteCount uint64 `json:"writeCount"` // 写入次数 - ReadBytes uint64 `json:"readBytes"` // 读取字节数 - WriteBytes uint64 `json:"writeBytes"` // 写入字节数 - ReadBytesRate uint64 `json:"readBytesRate"` // 读取速率(字节/秒) - WriteBytesRate uint64 `json:"writeBytesRate"` // 写入速率(字节/秒) - ReadTime uint64 `json:"readTime"` // 读取时间(毫秒) - WriteTime uint64 `json:"writeTime"` // 写入时间(毫秒) - IoTime uint64 `json:"ioTime"` // IO时间(毫秒) - IopsInProgress uint64 `json:"iopsInProgress"` // 正在进行的IO操作数 - Timestamp int64 `gorm:"index:idx_diskio_agent_ts,priority:2;index:idx_diskio_ts" json:"timestamp"` // 时间戳(毫秒) -} - -func (DiskIOMetric) TableName() string { - return "disk_io_metrics" -} - -// GPUMetric GPU指标 -type GPUMetric struct { - ID uint `gorm:"primaryKey;autoIncrement" json:"id"` - AgentID string `gorm:"index:idx_gpu_agent_idx_ts,priority:1" json:"agentId"` // 探针ID - Index int `gorm:"index:idx_gpu_agent_idx_ts,priority:2" json:"index"` // GPU索引 - Name string `json:"name"` // GPU名称 - Utilization float64 `json:"utilization"` // GPU使用率(%) - MemoryTotal uint64 `json:"memoryTotal"` // 总显存(字节) - MemoryUsed uint64 `json:"memoryUsed"` // 已使用显存(字节) - MemoryFree uint64 `json:"memoryFree"` // 空闲显存(字节) - Temperature float64 `json:"temperature"` // 温度(℃) - PowerDraw float64 `json:"powerDraw"` // 功耗(瓦) - FanSpeed float64 `json:"fanSpeed"` // 风扇转速(%) - PerformanceState string `json:"performanceState"` // 性能状态 - Timestamp int64 `gorm:"index:idx_gpu_agent_idx_ts,priority:3;index:idx_gpu_ts" json:"timestamp"` // 时间戳(毫秒) -} - -func (GPUMetric) TableName() string { - return "gpu_metrics" -} - -// TemperatureMetric 温度指标 -type TemperatureMetric struct { - ID uint `gorm:"primaryKey;autoIncrement" json:"id"` - AgentID string `gorm:"index:idx_temp_agent_sensor_ts,priority:1" json:"agentId"` // 探针ID - SensorKey string `gorm:"index:idx_temp_agent_sensor_ts,priority:2" json:"sensorKey"` // 传感器标识 - SensorLabel string `json:"sensorLabel"` // 传感器标签 - Temperature float64 `json:"temperature"` // 温度(℃) - Timestamp int64 `gorm:"index:idx_temp_agent_sensor_ts,priority:3;index:idx_temp_ts" json:"timestamp"` // 时间戳(毫秒) -} - -func (TemperatureMetric) TableName() string { - return "temperature_metrics" -} - -// HostMetric 主机信息指标 +// HostMetric 主机信息指标(静态信息,保存在 PostgreSQL) type HostMetric struct { ID uint `gorm:"primaryKey;autoIncrement" json:"id"` AgentID string `gorm:"uniqueIndex:ux_host_agent" json:"agentId"` // 探针ID(唯一约束用于 upsert) @@ -165,191 +19,5 @@ func (HostMetric) TableName() string { return "host_metrics" } -// MonitorMetric 监控指标 -type MonitorMetric struct { - ID uint `gorm:"primaryKey;autoIncrement" json:"id"` - AgentId string `gorm:"index:idx_mon_agent_monitor_ts,priority:1" json:"agentId"` // 探针ID - MonitorId string `gorm:"index:idx_mon_agent_monitor_ts,priority:2;index:idx_mon_type_monitor_ts,priority:2" json:"monitorId"` // 监控项ID - Type string `gorm:"index:idx_mon_type_monitor_ts,priority:1" json:"type"` // 监控类型: http, tcp - Target string `json:"target"` // 监控目标 - Status string `json:"status"` // 状态: up, down - StatusCode int `json:"statusCode"` // HTTP状态码 - ResponseTime int64 `json:"responseTime"` // 响应时间(毫秒) - Error string `json:"error"` // 错误信息 - Message string `json:"message"` // 附加信息 - ContentMatch bool `json:"contentMatch"` // 内容匹配结果 - CertExpiryTime int64 `json:"certExpiryTime"` // 证书过期时间(毫秒时间戳), 0表示无证书 - CertDaysLeft int `json:"certDaysLeft"` // 证书剩余天数 - Timestamp int64 `gorm:"index:idx_mon_agent_monitor_ts,priority:3;index:idx_mon_type_monitor_ts,priority:3;index:idx_mon_ts" json:"timestamp"` // 时间戳(毫秒) -} - -func (MonitorMetric) TableName() string { - return "monitor_metrics" -} - -// ----------- 聚合表 ----------- - -// AggregatedCPUMetricModel CPU聚合表 -type AggregatedCPUMetricModel struct { - ID uint `gorm:"primaryKey;autoIncrement" json:"id"` - AgentID string `gorm:"index:idx_cpuagg_agent_bucket,priority:1;uniqueIndex:ux_cpuagg_bucket,priority:1" json:"agentId"` - BucketSeconds int `gorm:"index:idx_cpuagg_agent_bucket,priority:2;uniqueIndex:ux_cpuagg_bucket,priority:2" json:"bucketSeconds"` - BucketStart int64 `gorm:"index:idx_cpuagg_agent_bucket,priority:3;uniqueIndex:ux_cpuagg_bucket,priority:3" json:"bucketStart"` // 毫秒 - MaxUsage float64 `json:"maxUsage"` - LogicalCores int `json:"logicalCores"` -} - -func (AggregatedCPUMetricModel) TableName() string { - return "cpu_metrics_aggs" -} - -// AggregatedMemoryMetricModel 内存聚合表 -type AggregatedMemoryMetricModel struct { - ID uint `gorm:"primaryKey;autoIncrement" json:"id"` - AgentID string `gorm:"index:idx_memagg_agent_bucket,priority:1;uniqueIndex:ux_memagg_bucket,priority:1" json:"agentId"` - BucketSeconds int `gorm:"index:idx_memagg_agent_bucket,priority:2;uniqueIndex:ux_memagg_bucket,priority:2" json:"bucketSeconds"` - BucketStart int64 `gorm:"index:idx_memagg_agent_bucket,priority:3;uniqueIndex:ux_memagg_bucket,priority:3" json:"bucketStart"` // 毫秒 - MaxUsage float64 `json:"maxUsage"` - Total uint64 `json:"total"` -} - -func (AggregatedMemoryMetricModel) TableName() string { - return "memory_metrics_aggs" -} - -// AggregatedDiskMetricModel 磁盘聚合表 -type AggregatedDiskMetricModel struct { - ID uint `gorm:"primaryKey;autoIncrement" json:"id"` - AgentID string `gorm:"index:idx_diskagg_agent_bucket_mp,priority:1;uniqueIndex:ux_diskagg_bucket,priority:1" json:"agentId"` - BucketSeconds int `gorm:"index:idx_diskagg_agent_bucket_mp,priority:2;uniqueIndex:ux_diskagg_bucket,priority:2" json:"bucketSeconds"` - BucketStart int64 `gorm:"index:idx_diskagg_agent_bucket_mp,priority:3;uniqueIndex:ux_diskagg_bucket,priority:3" json:"bucketStart"` // 毫秒 - MountPoint string `gorm:"index:idx_diskagg_agent_bucket_mp,priority:4;uniqueIndex:ux_diskagg_bucket,priority:4" json:"mountPoint"` - MaxUsage float64 `json:"maxUsage"` - Total uint64 `json:"total"` -} - -func (AggregatedDiskMetricModel) TableName() string { - return "disk_metrics_aggs" -} - -// AggregatedNetworkMetricModel 网络聚合表(按网卡分组) -type AggregatedNetworkMetricModel struct { - ID uint `gorm:"primaryKey;autoIncrement" json:"id"` - AgentID string `gorm:"index:idx_netagg_agent_bucket_iface,priority:1;uniqueIndex:ux_netagg_bucket,priority:1" json:"agentId"` - BucketSeconds int `gorm:"index:idx_netagg_agent_bucket_iface,priority:2;uniqueIndex:ux_netagg_bucket,priority:2" json:"bucketSeconds"` - BucketStart int64 `gorm:"index:idx_netagg_agent_bucket_iface,priority:3;uniqueIndex:ux_netagg_bucket,priority:3" json:"bucketStart"` // 毫秒 - Interface string `gorm:"index:idx_netagg_agent_bucket_iface,priority:4;uniqueIndex:ux_netagg_bucket,priority:4" json:"interface"` - MaxSentRate float64 `json:"maxSentRate"` - MaxRecvRate float64 `json:"maxRecvRate"` -} - -func (AggregatedNetworkMetricModel) TableName() string { - return "network_metrics_aggs" -} - -// AggregatedNetworkConnectionMetricModel 网络连接聚合表 -type AggregatedNetworkConnectionMetricModel struct { - ID uint `gorm:"primaryKey;autoIncrement" json:"id"` - AgentID string `gorm:"index:idx_netconnagg_agent_bucket,priority:1;uniqueIndex:ux_netconnagg_bucket,priority:1" json:"agentId"` - BucketSeconds int `gorm:"index:idx_netconnagg_agent_bucket,priority:2;uniqueIndex:ux_netconnagg_bucket,priority:2" json:"bucketSeconds"` - BucketStart int64 `gorm:"index:idx_netconnagg_agent_bucket,priority:3;uniqueIndex:ux_netconnagg_bucket,priority:3" json:"bucketStart"` // 毫秒 - MaxEstablished uint32 `json:"maxEstablished"` - MaxSynSent uint32 `json:"maxSynSent"` - MaxSynRecv uint32 `json:"maxSynRecv"` - MaxFinWait1 uint32 `json:"maxFinWait1"` - MaxFinWait2 uint32 `json:"maxFinWait2"` - MaxTimeWait uint32 `json:"maxTimeWait"` - MaxClose uint32 `json:"maxClose"` - MaxCloseWait uint32 `json:"maxCloseWait"` - MaxLastAck uint32 `json:"maxLastAck"` - MaxListen uint32 `json:"maxListen"` - MaxClosing uint32 `json:"maxClosing"` - MaxTotal uint32 `json:"maxTotal"` -} - -func (AggregatedNetworkConnectionMetricModel) TableName() string { - return "network_connection_metrics_aggs" -} - -// AggregatedDiskIOMetricModel 磁盘IO聚合表(汇总所有磁盘) -type AggregatedDiskIOMetricModel struct { - ID uint `gorm:"primaryKey;autoIncrement" json:"id"` - AgentID string `gorm:"index:idx_diskioagg_agent_bucket,priority:1;uniqueIndex:ux_diskioagg_bucket,priority:1" json:"agentId"` - BucketSeconds int `gorm:"index:idx_diskioagg_agent_bucket,priority:2;uniqueIndex:ux_diskioagg_bucket,priority:2" json:"bucketSeconds"` - BucketStart int64 `gorm:"index:idx_diskioagg_agent_bucket,priority:3;uniqueIndex:ux_diskioagg_bucket,priority:3" json:"bucketStart"` // 毫秒 - MaxReadBytesRate uint64 `json:"maxReadBytesRate"` - MaxWriteBytesRate uint64 `json:"maxWriteBytesRate"` - MaxIopsInProgress uint64 `json:"maxIopsInProgress"` -} - -func (AggregatedDiskIOMetricModel) TableName() string { - return "disk_io_metrics_aggs" -} - -// AggregatedGPUMetricModel GPU聚合表 -type AggregatedGPUMetricModel struct { - ID uint `gorm:"primaryKey;autoIncrement" json:"id"` - AgentID string `gorm:"index:idx_gpuagg_agent_bucket_idx,priority:1;uniqueIndex:ux_gpuagg_bucket,priority:1" json:"agentId"` - BucketSeconds int `gorm:"index:idx_gpuagg_agent_bucket_idx,priority:2;uniqueIndex:ux_gpuagg_bucket,priority:2" json:"bucketSeconds"` - BucketStart int64 `gorm:"index:idx_gpuagg_agent_bucket_idx,priority:3;uniqueIndex:ux_gpuagg_bucket,priority:3" json:"bucketStart"` // 毫秒 - Index int `gorm:"index:idx_gpuagg_agent_bucket_idx,priority:4;uniqueIndex:ux_gpuagg_bucket,priority:4" json:"index"` - Name string `json:"name"` - MaxUtilization float64 `json:"maxUtilization"` - MaxMemoryUsed uint64 `json:"maxMemoryUsed"` - MaxTemperature float64 `json:"maxTemperature"` - MaxPowerDraw float64 `json:"maxPowerDraw"` - MemoryTotal uint64 `json:"memoryTotal"` -} - -func (AggregatedGPUMetricModel) TableName() string { - return "gpu_metrics_aggs" -} - -// AggregatedTemperatureMetricModel 温度聚合表 -type AggregatedTemperatureMetricModel struct { - ID uint `gorm:"primaryKey;autoIncrement" json:"id"` - AgentID string `gorm:"index:idx_tempagg_agent_bucket_sensor,priority:1;uniqueIndex:ux_tempagg_bucket,priority:1" json:"agentId"` - BucketSeconds int `gorm:"index:idx_tempagg_agent_bucket_sensor,priority:2;uniqueIndex:ux_tempagg_bucket,priority:2" json:"bucketSeconds"` - BucketStart int64 `gorm:"index:idx_tempagg_agent_bucket_sensor,priority:3;uniqueIndex:ux_tempagg_bucket,priority:3" json:"bucketStart"` // 毫秒 - SensorKey string `gorm:"index:idx_tempagg_agent_bucket_sensor,priority:4;uniqueIndex:ux_tempagg_bucket,priority:4" json:"sensorKey"` - SensorLabel string `json:"sensorLabel"` - MaxTemperature float64 `json:"maxTemperature"` -} - -func (AggregatedTemperatureMetricModel) TableName() string { - return "temperature_metrics_aggs" -} - -// AggregationProgress 聚合进度记录 -type AggregationProgress struct { - MetricType string `gorm:"primaryKey"` // cpu/memory/disk/network - BucketSeconds int `gorm:"primaryKey"` - LastBucket int64 `json:"lastBucket"` // 已完成的最后 bucket 起始时间(毫秒) - UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli"` - CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"` -} - -func (AggregationProgress) TableName() string { - return "aggregation_progress" -} - -// AggregatedMonitorMetricModel 监控指标聚合表 -type AggregatedMonitorMetricModel struct { - ID uint `gorm:"primaryKey;autoIncrement" json:"id"` - MonitorID string `gorm:"index:idx_monitoragg_monitor_agent_bucket,priority:1;uniqueIndex:ux_monitoragg_bucket,priority:1" json:"monitorId"` - AgentID string `gorm:"index:idx_monitoragg_monitor_agent_bucket,priority:2;uniqueIndex:ux_monitoragg_bucket,priority:2" json:"agentId"` - BucketSeconds int `gorm:"index:idx_monitoragg_monitor_agent_bucket,priority:3;uniqueIndex:ux_monitoragg_bucket,priority:3" json:"bucketSeconds"` - BucketStart int64 `gorm:"index:idx_monitoragg_monitor_agent_bucket,priority:4;uniqueIndex:ux_monitoragg_bucket,priority:4" json:"bucketStart"` // 毫秒 - AvgResponse float64 `json:"avgResponse"` // 平均响应时间(ms) - MaxResponse int64 `json:"maxResponse"` // 最大响应时间(ms) - MinResponse int64 `json:"minResponse"` // 最小响应时间(ms) - SuccessCount int64 `json:"successCount"` // 成功次数 - TotalCount int64 `json:"totalCount"` // 总次数 - SuccessRate float64 `json:"successRate"` // 成功率(百分比) - LastStatus string `json:"lastStatus"` // 最后状态 - LastError string `json:"lastError"` // 最后错误信息 -} - -func (AggregatedMonitorMetricModel) TableName() string { - return "monitor_metrics_aggs" -} +// 注意:所有其他指标数据已迁移到 VictoriaMetrics +// 包括:CPU, Memory, Disk, Network, NetworkConnection, DiskIO, GPU, Temperature, Monitor diff --git a/internal/models/property.go b/internal/models/property.go index 1c628ae..ff14437 100644 --- a/internal/models/property.go +++ b/internal/models/property.go @@ -80,11 +80,6 @@ var TimeRangeOptions = []TimeRangeOption{ {Label: "7天", Value: "7d"}, } -// MetricsConfig 指标数据配置 -type MetricsConfig struct { - RetentionHours int `json:"retentionHours"` // 原始数据保留小时数(默认168小时=7天) -} - // AlertConfig 全局告警配置 type AlertConfig struct { Enabled bool `json:"enabled"` // 是否启用全局告警 diff --git a/internal/repo/metric_repo.go b/internal/repo/metric_repo.go index 1d01b31..70fdedc 100644 --- a/internal/repo/metric_repo.go +++ b/internal/repo/metric_repo.go @@ -18,41 +18,6 @@ func NewMetricRepo(db *gorm.DB) *MetricRepo { } } -// SaveCPUMetric 保存CPU指标 -func (r *MetricRepo) SaveCPUMetric(ctx context.Context, metric *models.CPUMetric) error { - return r.db.WithContext(ctx).Create(metric).Error -} - -// SaveMemoryMetric 保存内存指标 -func (r *MetricRepo) SaveMemoryMetric(ctx context.Context, metric *models.MemoryMetric) error { - return r.db.WithContext(ctx).Create(metric).Error -} - -// SaveDiskMetric 保存磁盘指标 -func (r *MetricRepo) SaveDiskMetric(ctx context.Context, metric *models.DiskMetric) error { - return r.db.WithContext(ctx).Create(metric).Error -} - -// SaveNetworkMetric 保存网络指标 -func (r *MetricRepo) SaveNetworkMetric(ctx context.Context, metric *models.NetworkMetric) error { - return r.db.WithContext(ctx).Create(metric).Error -} - -// SaveDiskIOMetric 保存磁盘IO指标 -func (r *MetricRepo) SaveDiskIOMetric(ctx context.Context, metric *models.DiskIOMetric) error { - return r.db.WithContext(ctx).Create(metric).Error -} - -// SaveGPUMetric 保存GPU指标 -func (r *MetricRepo) SaveGPUMetric(ctx context.Context, metric *models.GPUMetric) error { - return r.db.WithContext(ctx).Create(metric).Error -} - -// SaveTemperatureMetric 保存温度指标 -func (r *MetricRepo) SaveTemperatureMetric(ctx context.Context, metric *models.TemperatureMetric) error { - return r.db.WithContext(ctx).Create(metric).Error -} - // SaveHostMetric 保存主机信息指标(按 agent 覆盖,避免先删后插的空窗) func (r *MetricRepo) SaveHostMetric(ctx context.Context, metric *models.HostMetric) error { return r.db.WithContext(ctx). @@ -63,164 +28,46 @@ func (r *MetricRepo) SaveHostMetric(ctx context.Context, metric *models.HostMetr Create(metric).Error } -// SaveNetworkConnectionMetric 保存网络连接统计指标 -func (r *MetricRepo) SaveNetworkConnectionMetric(ctx context.Context, metric *models.NetworkConnectionMetric) error { - return r.db.WithContext(ctx).Create(metric).Error +// GetAvailableNetworkInterfaces 获取探针的可用网卡列表 +// TODO: 需要改为从 VictoriaMetrics 查询 labels +func (r *MetricRepo) GetAvailableNetworkInterfaces(ctx context.Context, agentID string) ([]string, error) { + // 暂时返回空列表,后续改为从 VictoriaMetrics 查询 + // 使用 GET /api/v1/label/interface/values?match[]=pika_network_sent_bytes_rate{agent_id="xxx"} + return []string{}, nil } -// AggregatedNetworkConnectionMetric 网络连接统计聚合指标 -type AggregatedNetworkConnectionMetric struct { - Timestamp int64 `json:"timestamp"` - MaxEstablished uint32 `json:"maxEstablished"` // 最大已建立连接数 - MaxSynSent uint32 `json:"maxSynSent"` // 最大 SYN_SENT 连接数 - MaxSynRecv uint32 `json:"maxSynRecv"` // 最大 SYN_RECV 连接数 - MaxFinWait1 uint32 `json:"maxFinWait1"` // 最大 FIN_WAIT1 连接数 - MaxFinWait2 uint32 `json:"maxFinWait2"` // 最大 FIN_WAIT2 连接数 - MaxTimeWait uint32 `json:"maxTimeWait"` // 最大 TIME_WAIT 连接数 - MaxClose uint32 `json:"maxClose"` // 最大 CLOSE 连接数 - MaxCloseWait uint32 `json:"maxCloseWait"` // 最大 CLOSE_WAIT 连接数 - MaxLastAck uint32 `json:"maxLastAck"` // 最大 LAST_ACK 连接数 - MaxListen uint32 `json:"maxListen"` // 最大 LISTEN 连接数 - MaxClosing uint32 `json:"maxClosing"` // 最大 CLOSING 连接数 - MaxTotal uint32 `json:"maxTotal"` // 最大总连接数 -} - -// GetNetworkConnectionMetrics 获取聚合后的网络连接统计指标 -func (r *MetricRepo) GetNetworkConnectionMetrics(ctx context.Context, agentID string, start, end int64, interval int) ([]AggregatedNetworkConnectionMetric, error) { - var metrics []AggregatedNetworkConnectionMetric - - query := ` - SELECT - CAST(FLOOR(timestamp / ?) * ? AS BIGINT) as timestamp, - MAX(established) as max_established, - MAX(syn_sent) as max_syn_sent, - MAX(syn_recv) as max_syn_recv, - MAX(fin_wait1) as max_fin_wait1, - MAX(fin_wait2) as max_fin_wait2, - MAX(time_wait) as max_time_wait, - MAX(close) as max_close, - MAX(close_wait) as max_close_wait, - MAX(last_ack) as max_last_ack, - MAX(listen) as max_listen, - MAX(closing) as max_closing, - MAX(total) as max_total - FROM network_connection_metrics - WHERE agent_id = ? AND timestamp >= ? AND timestamp <= ? - GROUP BY 1 - ORDER BY timestamp ASC - ` - - intervalMs := int64(interval * 1000) - err := r.db.WithContext(ctx). - Raw(query, intervalMs, intervalMs, agentID, start, end). - Scan(&metrics).Error - - return metrics, err -} - -// DeleteOldMetrics 删除指定时间之前的所有指标数据 -func (r *MetricRepo) DeleteOldMetrics(ctx context.Context, beforeTimestamp int64) error { - // 批量大小 - batchSize := 1000 - - // 定义要清理的表(Host 信息只保留最新的,不需要清理) - tables := []interface{}{ - &models.CPUMetric{}, - &models.MemoryMetric{}, - &models.DiskMetric{}, - &models.NetworkMetric{}, - &models.NetworkConnectionMetric{}, - &models.DiskIOMetric{}, - &models.GPUMetric{}, - &models.TemperatureMetric{}, - &models.MonitorMetric{}, +// DeleteAgentMetrics 删除指定探针的所有指标数据 +// TODO: 需要改为调用 VictoriaMetrics 的 DELETE API +func (r *MetricRepo) DeleteAgentMetrics(ctx context.Context, agentID string) error { + // 1. 删除 PostgreSQL 中的主机信息 + if err := r.db.WithContext(ctx).Where("agent_id = ?", agentID).Delete(&models.HostMetric{}).Error; err != nil { + return err } - // 对每个表进行分批删除 - for _, table := range tables { - for { - // 分批删除,避免长事务 - result := r.db.WithContext(ctx). - Where("timestamp < ?", beforeTimestamp). - Limit(batchSize). - Delete(table) - - if result.Error != nil { - return result.Error - } - - // 如果删除的行数少于批量大小,说明已经删除完毕 - if result.RowsAffected < int64(batchSize) { - break - } - } - } + // 2. TODO: 调用 VictoriaMetrics 删除时间序列数据 + // DELETE /api/v1/admin/tsdb/delete_series?match[]=pika_*{agent_id="xxx"} return nil } -// AggregatedCPUMetric CPU聚合指标(使用最大值) +// 以下是兼容性数据结构定义,用于返回查询结果 +// TODO: 后续从 VictoriaMetrics 查询时使用这些结构 + +// AggregatedCPUMetric CPU聚合指标 type AggregatedCPUMetric struct { Timestamp int64 `json:"timestamp"` MaxUsage float64 `json:"maxUsage"` LogicalCores int `json:"logicalCores"` } -// GetCPUMetrics 获取聚合后的CPU指标(取最大值) -// interval: 聚合间隔,单位秒(如:60表示1分钟) -func (r *MetricRepo) GetCPUMetrics(ctx context.Context, agentID string, start, end int64, interval int) ([]AggregatedCPUMetric, error) { - var metrics []AggregatedCPUMetric - - query := ` - SELECT - CAST(FLOOR(timestamp / ?) * ? AS BIGINT) as timestamp, - MAX(usage_percent) as max_usage, - MAX(logical_cores) as logical_cores - FROM cpu_metrics - WHERE agent_id = ? AND timestamp >= ? AND timestamp <= ? - GROUP BY 1 - ORDER BY timestamp ASC - ` - - intervalMs := int64(interval * 1000) - err := r.db.WithContext(ctx). - Raw(query, intervalMs, intervalMs, agentID, start, end). - Scan(&metrics).Error - - return metrics, err -} - -// AggregatedMemoryMetric 内存聚合指标(使用最大值) +// AggregatedMemoryMetric 内存聚合指标 type AggregatedMemoryMetric struct { Timestamp int64 `json:"timestamp"` MaxUsage float64 `json:"maxUsage"` Total uint64 `json:"total"` } -// GetMemoryMetrics 获取聚合后的内存指标(取最大值) -func (r *MetricRepo) GetMemoryMetrics(ctx context.Context, agentID string, start, end int64, interval int) ([]AggregatedMemoryMetric, error) { - var metrics []AggregatedMemoryMetric - - query := ` - SELECT - CAST(FLOOR(timestamp / ?) * ? AS BIGINT) as timestamp, - MAX(usage_percent) as max_usage, - MAX(total) as total - FROM memory_metrics - WHERE agent_id = ? AND timestamp >= ? AND timestamp <= ? - GROUP BY 1 - ORDER BY timestamp ASC - ` - - intervalMs := int64(interval * 1000) - err := r.db.WithContext(ctx). - Raw(query, intervalMs, intervalMs, agentID, start, end). - Scan(&metrics).Error - - return metrics, err -} - -// AggregatedDiskMetric 磁盘聚合指标(使用最大值) +// AggregatedDiskMetric 磁盘聚合指标 type AggregatedDiskMetric struct { Timestamp int64 `json:"timestamp"` MountPoint string `json:"mountPoint"` @@ -228,32 +75,7 @@ type AggregatedDiskMetric struct { Total uint64 `json:"total"` } -// GetDiskMetrics 获取聚合后的磁盘指标(返回预聚合的总和数据) -// 直接查询 mount_point=” 的预聚合记录 -func (r *MetricRepo) GetDiskMetrics(ctx context.Context, agentID string, start, end int64, interval int) ([]AggregatedDiskMetric, error) { - var metrics []AggregatedDiskMetric - - query := ` - SELECT - CAST(FLOOR(timestamp / ?) * ? AS BIGINT) as timestamp, - mount_point, - MAX(usage_percent) as max_usage, - MAX(total) as total - FROM disk_metrics - WHERE agent_id = ? AND timestamp >= ? AND timestamp <= ? AND mount_point = ? - GROUP BY 1, mount_point - ORDER BY timestamp ASC - ` - - intervalMs := int64(interval * 1000) - err := r.db.WithContext(ctx). - Raw(query, intervalMs, intervalMs, agentID, start, end, ""). // 空字符串查询总和记录 - Scan(&metrics).Error - - return metrics, err -} - -// AggregatedNetworkMetric 网络聚合指标(按网卡分组) +// AggregatedNetworkMetric 网络聚合指标 type AggregatedNetworkMetric struct { Timestamp int64 `json:"timestamp"` Interface string `json:"interface"` @@ -261,169 +83,34 @@ type AggregatedNetworkMetric struct { MaxRecvRate float64 `json:"maxRecvRate"` } -// AggregatedNetworkMetricByInterface 按网卡接口分组的网络聚合指标 -type AggregatedNetworkMetricByInterface struct { - Timestamp int64 `json:"timestamp"` - Interface string `json:"interface"` - MaxSentRate float64 `json:"maxSentRate"` - MaxRecvRate float64 `json:"maxRecvRate"` +// AggregatedNetworkConnectionMetric 网络连接统计聚合指标 +type AggregatedNetworkConnectionMetric struct { + Timestamp int64 `json:"timestamp"` + 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"` } -// GetNetworkMetrics 获取聚合后的网络指标(可选按网卡接口过滤) -// 不指定网卡时查询 interface=” 的预聚合数据,指定网卡时只返回该网卡的数据 -func (r *MetricRepo) GetNetworkMetrics(ctx context.Context, agentID string, start, end int64, interval int, interfaceName string) ([]AggregatedNetworkMetric, error) { - var metrics []AggregatedNetworkMetric - - intervalMs := int64(interval * 1000) - - // 不管是否指定网卡,查询逻辑都一样:直接查询对应 interface 的数据 - // interfaceName 为空字符串时,会查询到预先保存的总和数据 - query := ` - SELECT - CAST(FLOOR(timestamp / ?) * ? AS BIGINT) as timestamp, - interface, - MAX(bytes_sent_rate) as max_sent_rate, - MAX(bytes_recv_rate) as max_recv_rate - FROM network_metrics - WHERE agent_id = ? AND timestamp >= ? AND timestamp <= ? AND interface = ? - GROUP BY 1, interface - ORDER BY timestamp ASC - ` - - err := r.db.WithContext(ctx). - Raw(query, intervalMs, intervalMs, agentID, start, end, interfaceName). - Scan(&metrics).Error - - return metrics, err -} - -// SaveMonitorMetric 保存监控指标 -func (r *MetricRepo) SaveMonitorMetric(ctx context.Context, metric *models.MonitorMetric) error { - return r.db.WithContext(ctx).Create(metric).Error -} - -// GetMonitorMetrics 获取监控指标列表 -func (r *MetricRepo) GetMonitorMetrics(ctx context.Context, agentID, monitorID string, start, end int64) ([]models.MonitorMetric, error) { - var metrics []models.MonitorMetric - query := r.db.WithContext(ctx). - Where("agent_id = ? AND timestamp >= ? AND timestamp <= ?", agentID, start, end) - - // 如果指定了监控项ID,则只查询该监控项 - if monitorID != "" { - query = query.Where("monitor_id = ?", monitorID) - } - - err := query.Order("timestamp ASC").Find(&metrics).Error - return metrics, err -} - -// GetMonitorMetricsByName 获取指定监控项的历史数据 -func (r *MetricRepo) GetMonitorMetricsByName(ctx context.Context, agentID, monitorID string, start, end int64, limit int) ([]models.MonitorMetric, error) { - var metrics []models.MonitorMetric - query := r.db.WithContext(ctx). - Where("agent_id = ? AND monitor_id = ? AND timestamp >= ? AND timestamp <= ?", agentID, monitorID, start, end). - Order("timestamp DESC") - - if limit > 0 { - query = query.Limit(limit) - } - - err := query.Find(&metrics).Error - return metrics, err -} - -// AggregatedMonitorMetric 聚合的监控指标 -type AggregatedMonitorMetric struct { - Timestamp int64 `json:"timestamp"` - AgentID string `json:"agentId"` - AvgResponse float64 `json:"avgResponse"` // 平均响应时间 - MaxResponse int64 `json:"maxResponse"` // 最大响应时间 - MinResponse int64 `json:"minResponse"` // 最小响应时间 - SuccessCount int64 `json:"successCount"` // 成功次数 - TotalCount int64 `json:"totalCount"` // 总次数 - SuccessRate float64 `json:"successRate"` // 成功率 - LastStatus string `json:"lastStatus"` // 最后状态 - LastErrorMsg string `json:"lastErrorMsg"` // 最后错误信息 -} - -// GetAggregatedMonitorMetrics 获取聚合后的监控指标(按探针和时间间隔聚合) -func (r *MetricRepo) GetAggregatedMonitorMetrics(ctx context.Context, monitorID string, start, end int64, interval int) ([]AggregatedMonitorMetric, error) { - var metrics []AggregatedMonitorMetric - - query := ` - WITH ranked_metrics AS ( - SELECT - agent_id, - CAST(FLOOR(timestamp / ?) * ? AS BIGINT) as time_bucket, - response_time, - status, - error, - timestamp, - ROW_NUMBER() OVER (PARTITION BY agent_id, CAST(FLOOR(timestamp / ?) * ? AS BIGINT) ORDER BY timestamp DESC) as rn - FROM monitor_metrics - WHERE monitor_id = ? AND timestamp >= ? AND timestamp <= ? - ) - SELECT - time_bucket as timestamp, - agent_id, - AVG(response_time) as avg_response, - MAX(response_time) as max_response, - MIN(response_time) as min_response, - SUM(CASE WHEN status = 'up' THEN 1 ELSE 0 END) as success_count, - COUNT(*) as total_count, - CAST(SUM(CASE WHEN status = 'up' THEN 1 ELSE 0 END) AS REAL) / CAST(COUNT(*) AS REAL) * 100 as success_rate, - MAX(CASE WHEN rn = 1 THEN status END) as last_status, - MAX(CASE WHEN rn = 1 THEN error END) as last_error_msg - FROM ranked_metrics - GROUP BY time_bucket, agent_id - ORDER BY timestamp ASC, agent_id - ` - - intervalMs := int64(interval * 1000) - err := r.db.WithContext(ctx). - Raw(query, intervalMs, intervalMs, intervalMs, intervalMs, monitorID, start, end). - Scan(&metrics).Error - - return metrics, err -} - -// AggregatedDiskIOMetric 磁盘IO聚合指标(所有磁盘汇总) +// AggregatedDiskIOMetric 磁盘IO聚合指标 type AggregatedDiskIOMetric struct { Timestamp int64 `json:"timestamp"` - MaxReadRate float64 `json:"maxReadRate"` // 最大读取速率(字节/秒) - MaxWriteRate float64 `json:"maxWriteRate"` // 最大写入速率(字节/秒) - TotalReadBytes uint64 `json:"totalReadBytes"` // 总读取字节数 - TotalWriteBytes uint64 `json:"totalWriteBytes"` // 总写入字节数 - MaxIopsInProgress uint64 `json:"maxIopsInProgress"` // 最大进行中的IO操作数 + MaxReadRate float64 `json:"maxReadRate"` + MaxWriteRate float64 `json:"maxWriteRate"` + TotalReadBytes uint64 `json:"totalReadBytes"` + TotalWriteBytes uint64 `json:"totalWriteBytes"` + MaxIopsInProgress uint64 `json:"maxIopsInProgress"` } -// GetDiskIOMetrics 获取聚合后的磁盘IO指标(已在存储时合并所有磁盘) -func (r *MetricRepo) GetDiskIOMetrics(ctx context.Context, agentID string, start, end int64, interval int) ([]AggregatedDiskIOMetric, error) { - var metrics []AggregatedDiskIOMetric - - query := ` - SELECT - CAST(FLOOR(timestamp / ?) * ? AS BIGINT) as timestamp, - MAX(read_bytes_rate) as max_read_rate, - MAX(write_bytes_rate) as max_write_rate, - MAX(read_bytes) as total_read_bytes, - MAX(write_bytes) as total_write_bytes, - MAX(iops_in_progress) as max_iops_in_progress - FROM disk_io_metrics - WHERE agent_id = ? AND timestamp >= ? AND timestamp <= ? - GROUP BY 1 - ORDER BY timestamp ASC - ` - - intervalMs := int64(interval * 1000) - err := r.db.WithContext(ctx). - Raw(query, intervalMs, intervalMs, agentID, start, end). - Scan(&metrics).Error - - return metrics, err -} - -// AggregatedGPUMetric GPU聚合指标(使用最大值) +// AggregatedGPUMetric GPU聚合指标 type AggregatedGPUMetric struct { Timestamp int64 `json:"timestamp"` Index int `json:"index"` @@ -435,32 +122,7 @@ type AggregatedGPUMetric struct { MemoryTotal uint64 `json:"memoryTotal"` } -// GetGPUMetrics 获取聚合后的GPU指标 -func (r *MetricRepo) GetGPUMetrics(ctx context.Context, agentID string, start, end int64, interval int) ([]AggregatedGPUMetric, error) { - var metrics []AggregatedGPUMetric - - query := ` - SELECT - CAST(FLOOR(timestamp / ?) * ? AS BIGINT) as timestamp, - MAX(utilization) as max_utilization, - MAX(memory_used) as max_memory_used, - MAX(temperature) as max_temperature, - MAX(power_draw) as max_power_draw - FROM gpu_metrics - WHERE agent_id = ? AND timestamp >= ? AND timestamp <= ? - GROUP BY 1 - ORDER BY timestamp ASC - ` - - intervalMs := int64(interval * 1000) - err := r.db.WithContext(ctx). - Raw(query, intervalMs, intervalMs, agentID, start, end). - Scan(&metrics).Error - - return metrics, err -} - -// AggregatedTemperatureMetric 温度聚合指标(使用最大值) +// AggregatedTemperatureMetric 温度聚合指标 type AggregatedTemperatureMetric struct { Timestamp int64 `json:"timestamp"` SensorKey string `json:"sensorKey"` @@ -468,525 +130,82 @@ type AggregatedTemperatureMetric struct { MaxTemperature float64 `json:"maxTemperature"` } -// GetTemperatureMetrics 获取聚合后的温度指标 -func (r *MetricRepo) GetTemperatureMetrics(ctx context.Context, agentID string, start, end int64, interval int) ([]AggregatedTemperatureMetric, error) { - var metrics []AggregatedTemperatureMetric - - query := ` - SELECT - CAST(FLOOR(timestamp / ?) * ? AS BIGINT) as timestamp, - sensor_key, - sensor_label, - MAX(temperature) as max_temperature - FROM temperature_metrics - WHERE agent_id = ? AND timestamp >= ? AND timestamp <= ? - GROUP BY 1, sensor_key, sensor_label - ORDER BY timestamp ASC, sensor_key - ` - - intervalMs := int64(interval * 1000) - err := r.db.WithContext(ctx). - Raw(query, intervalMs, intervalMs, agentID, start, end). - Scan(&metrics).Error - - return metrics, err +// AggregatedMonitorMetric 聚合的监控指标 +type AggregatedMonitorMetric struct { + Timestamp int64 `json:"timestamp"` + AgentID string `json:"agentId"` + AvgResponse float64 `json:"avgResponse"` + MaxResponse int64 `json:"maxResponse"` + MinResponse int64 `json:"minResponse"` + SuccessCount int64 `json:"successCount"` + TotalCount int64 `json:"totalCount"` + SuccessRate float64 `json:"successRate"` + LastStatus string `json:"lastStatus"` + LastErrorMsg string `json:"lastErrorMsg"` } -// DeleteMonitorMetrics 删除指定监控任务的所有指标数据 -func (r *MetricRepo) DeleteMonitorMetrics(ctx context.Context, monitorID string) error { - return r.db.WithContext(ctx). - Where("monitor_id = ?", monitorID). - Delete(&models.MonitorMetric{}).Error +// GetMonitorMetrics 获取监控指标历史数据 +// TODO: 需要改为从 VictoriaMetrics 查询 +func (r *MetricRepo) GetMonitorMetrics(ctx context.Context, agentID, monitorID string, start, end int64) ([]MonitorMetric, error) { + // 暂时返回空列表,后续改为从 VictoriaMetrics 查询 + return []MonitorMetric{}, nil } -// DeleteAgentMetrics 删除指定探针的所有指标数据 -func (r *MetricRepo) DeleteAgentMetrics(ctx context.Context, agentID string) error { - tables := []interface{}{ - &models.CPUMetric{}, - &models.MemoryMetric{}, - &models.DiskMetric{}, - &models.DiskIOMetric{}, - &models.NetworkMetric{}, - &models.HostMetric{}, - &models.GPUMetric{}, - &models.TemperatureMetric{}, - &models.MonitorMetric{}, - } - - return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - for _, table := range tables { - if err := tx.Where("agent_id = ?", agentID).Delete(table).Error; err != nil { - return err - } - } - return nil - }) +// GetMonitorMetricsByName 获取指定监控项的历史数据 +// TODO: 需要改为从 VictoriaMetrics 查询 +func (r *MetricRepo) GetMonitorMetricsByName(ctx context.Context, agentID, monitorID string, start, end int64, limit int) ([]MonitorMetric, error) { + // 暂时返回空列表,后续改为从 VictoriaMetrics 查询 + return []MonitorMetric{}, nil } // GetLatestMonitorMetricsByType 获取指定类型的最新监控指标(所有探针) -func (r *MetricRepo) GetLatestMonitorMetricsByType(ctx context.Context, monitorType string) ([]*models.MonitorMetric, error) { - var metrics []*models.MonitorMetric - - err := r.db.WithContext(ctx).Raw(` - SELECT m.* - FROM monitor_metrics m - INNER JOIN ( - SELECT monitor_id, MAX(timestamp) AS ts - FROM monitor_metrics - WHERE type = ? - GROUP BY monitor_id - ) latest ON m.monitor_id = latest.monitor_id AND m.timestamp = latest.ts - WHERE m.type = ? - ORDER BY m.monitor_id - `, monitorType, monitorType).Scan(&metrics).Error - - return metrics, err +// TODO: 需要改为从 VictoriaMetrics 查询 +func (r *MetricRepo) GetLatestMonitorMetricsByType(ctx context.Context, monitorType string) ([]*MonitorMetric, error) { + // 暂时返回空列表,后续改为从 VictoriaMetrics 查询 + // 使用 GET /api/v1/query?query=pika_monitor_status{type="xxx"} + return []*MonitorMetric{}, nil } -// GetAllLatestMonitorMetrics 获取所有最新的监控指标(所有探针的所有监控项,每个监控项的最新一条) -func (r *MetricRepo) GetAllLatestMonitorMetrics(ctx context.Context) ([]*models.MonitorMetric, error) { - var metrics []*models.MonitorMetric - - err := r.db.WithContext(ctx).Raw(` - SELECT m.* - FROM monitor_metrics m - INNER JOIN ( - SELECT monitor_id, MAX(timestamp) AS ts - FROM monitor_metrics - GROUP BY monitor_id - ) latest ON m.monitor_id = latest.monitor_id AND m.timestamp = latest.ts - ORDER BY m.monitor_id - `).Scan(&metrics).Error - - return metrics, err +// GetAllLatestMonitorMetrics 获取所有最新的监控指标(所有探针的所有监控项) +// TODO: 需要改为从 VictoriaMetrics 查询 +func (r *MetricRepo) GetAllLatestMonitorMetrics(ctx context.Context) ([]*MonitorMetric, error) { + // 暂时返回空列表,后续改为从 VictoriaMetrics 查询 + // 使用 GET /api/v1/query?query=pika_monitor_status + return []*MonitorMetric{}, nil } -// ----------- 聚合表操作 ----------- - -// AggregateCPUToAgg 将原始CPU数据聚合到聚合表 -func (r *MetricRepo) AggregateCPUToAgg(ctx context.Context, bucketSeconds int, start, end int64) error { - bucketMs := int64(bucketSeconds * 1000) - return r.db.WithContext(ctx).Exec(` - INSERT INTO cpu_metrics_aggs (agent_id, bucket_seconds, bucket_start, max_usage, logical_cores) - SELECT - agent_id, - ? as bucket_seconds, - (timestamp / ?) * ? as bucket_start, - MAX(usage_percent) as max_usage, - MAX(logical_cores) as logical_cores - FROM cpu_metrics - WHERE timestamp >= ? AND timestamp < ? - GROUP BY agent_id, bucket_start - ON CONFLICT (agent_id, bucket_seconds, bucket_start) DO UPDATE SET - max_usage = EXCLUDED.max_usage, - logical_cores = EXCLUDED.logical_cores - `, bucketSeconds, bucketMs, bucketMs, start, end).Error +// DeleteMonitorMetrics 删除指定监控任务的所有指标数据 +// TODO: 需要改为调用 VictoriaMetrics 删除 API +func (r *MetricRepo) DeleteMonitorMetrics(ctx context.Context, monitorID string) error { + // 暂时不删除数据,后续调用 VictoriaMetrics 删除 API + // DELETE /api/v1/admin/tsdb/delete_series?match[]=pika_monitor_*{monitor_id="xxx"} + return nil } -// AggregateMemoryToAgg 将原始内存数据聚合到聚合表 -func (r *MetricRepo) AggregateMemoryToAgg(ctx context.Context, bucketSeconds int, start, end int64) error { - bucketMs := int64(bucketSeconds * 1000) - return r.db.WithContext(ctx).Exec(` - INSERT INTO memory_metrics_aggs (agent_id, bucket_seconds, bucket_start, max_usage, total) - SELECT - agent_id, - ? as bucket_seconds, - (timestamp / ?) * ? as bucket_start, - MAX(usage_percent) as max_usage, - MAX(total) as total - FROM memory_metrics - WHERE timestamp >= ? AND timestamp < ? - GROUP BY agent_id, bucket_start - ON CONFLICT (agent_id, bucket_seconds, bucket_start) DO UPDATE SET - max_usage = EXCLUDED.max_usage, - total = EXCLUDED.total - `, bucketSeconds, bucketMs, bucketMs, start, end).Error -} - -// AggregateDiskToAgg 将原始磁盘数据聚合到聚合表 -func (r *MetricRepo) AggregateDiskToAgg(ctx context.Context, bucketSeconds int, start, end int64) error { - bucketMs := int64(bucketSeconds * 1000) - return r.db.WithContext(ctx).Exec(` - INSERT INTO disk_metrics_aggs (agent_id, bucket_seconds, bucket_start, mount_point, max_usage, total) - SELECT - agent_id, - ? as bucket_seconds, - (timestamp / ?) * ? as bucket_start, - mount_point, - MAX(usage_percent) as max_usage, - MAX(total) as total - FROM disk_metrics - WHERE timestamp >= ? AND timestamp < ? - GROUP BY agent_id, bucket_start, mount_point - ON CONFLICT (agent_id, bucket_seconds, bucket_start, mount_point) DO UPDATE SET - max_usage = EXCLUDED.max_usage, - total = EXCLUDED.total - `, bucketSeconds, bucketMs, bucketMs, start, end).Error -} - -// AggregateNetworkToAgg 将原始网络数据聚合到聚合表(按网卡分组) -func (r *MetricRepo) AggregateNetworkToAgg(ctx context.Context, bucketSeconds int, start, end int64) error { - bucketMs := int64(bucketSeconds * 1000) - return r.db.WithContext(ctx).Exec(` - INSERT INTO network_metrics_aggs (agent_id, bucket_seconds, bucket_start, interface, max_sent_rate, max_recv_rate) - SELECT - agent_id, - ? as bucket_seconds, - (timestamp / ?) * ? as bucket_start, - interface, - MAX(bytes_sent_rate) as max_sent_rate, - MAX(bytes_recv_rate) as max_recv_rate - FROM network_metrics - WHERE timestamp >= ? AND timestamp < ? - GROUP BY agent_id, bucket_start, interface - ON CONFLICT (agent_id, bucket_seconds, bucket_start, interface) DO UPDATE SET - max_sent_rate = EXCLUDED.max_sent_rate, - max_recv_rate = EXCLUDED.max_recv_rate - `, bucketSeconds, bucketMs, bucketMs, start, end).Error -} - -// AggregateNetworkConnectionToAgg 将原始网络连接数据聚合到聚合表 -func (r *MetricRepo) AggregateNetworkConnectionToAgg(ctx context.Context, bucketSeconds int, start, end int64) error { - bucketMs := int64(bucketSeconds * 1000) - return r.db.WithContext(ctx).Exec(` - INSERT INTO network_connection_metrics_aggs ( - agent_id, bucket_seconds, bucket_start, - max_established, max_syn_sent, max_syn_recv, max_fin_wait1, max_fin_wait2, - max_time_wait, max_close, max_close_wait, max_last_ack, max_listen, - max_closing, max_total - ) - SELECT - agent_id, - ? as bucket_seconds, - (timestamp / ?) * ? as bucket_start, - MAX(established) as max_established, - MAX(syn_sent) as max_syn_sent, - MAX(syn_recv) as max_syn_recv, - MAX(fin_wait1) as max_fin_wait1, - MAX(fin_wait2) as max_fin_wait2, - MAX(time_wait) as max_time_wait, - MAX(close) as max_close, - MAX(close_wait) as max_close_wait, - MAX(last_ack) as max_last_ack, - MAX(listen) as max_listen, - MAX(closing) as max_closing, - MAX(total) as max_total - FROM network_connection_metrics - WHERE timestamp >= ? AND timestamp < ? - GROUP BY agent_id, bucket_start - ON CONFLICT (agent_id, bucket_seconds, bucket_start) DO UPDATE SET - max_established = EXCLUDED.max_established, - max_syn_sent = EXCLUDED.max_syn_sent, - max_syn_recv = EXCLUDED.max_syn_recv, - max_fin_wait1 = EXCLUDED.max_fin_wait1, - max_fin_wait2 = EXCLUDED.max_fin_wait2, - max_time_wait = EXCLUDED.max_time_wait, - max_close = EXCLUDED.max_close, - max_close_wait = EXCLUDED.max_close_wait, - max_last_ack = EXCLUDED.max_last_ack, - max_listen = EXCLUDED.max_listen, - max_closing = EXCLUDED.max_closing, - max_total = EXCLUDED.max_total - `, bucketSeconds, bucketMs, bucketMs, start, end).Error -} - -// AggregateDiskIOToAgg 将原始磁盘IO数据聚合到聚合表(已在存储时合并所有磁盘) -func (r *MetricRepo) AggregateDiskIOToAgg(ctx context.Context, bucketSeconds int, start, end int64) error { - bucketMs := int64(bucketSeconds * 1000) - return r.db.WithContext(ctx).Exec(` - INSERT INTO disk_io_metrics_aggs ( - agent_id, bucket_seconds, bucket_start, - max_read_bytes_rate, max_write_bytes_rate, max_iops_in_progress - ) - SELECT - agent_id, - ? as bucket_seconds, - (timestamp / ?) * ? as bucket_start, - MAX(read_bytes_rate) as max_read_bytes_rate, - MAX(write_bytes_rate) as max_write_bytes_rate, - MAX(iops_in_progress) as max_iops_in_progress - FROM disk_io_metrics - WHERE timestamp >= ? AND timestamp < ? - GROUP BY agent_id, bucket_start - ON CONFLICT (agent_id, bucket_seconds, bucket_start) DO UPDATE SET - max_read_bytes_rate = EXCLUDED.max_read_bytes_rate, - max_write_bytes_rate = EXCLUDED.max_write_bytes_rate, - max_iops_in_progress = EXCLUDED.max_iops_in_progress - `, bucketSeconds, bucketMs, bucketMs, start, end).Error -} - -// AggregateGPUToAgg 将原始GPU数据聚合到聚合表 -func (r *MetricRepo) AggregateGPUToAgg(ctx context.Context, bucketSeconds int, start, end int64) error { - bucketMs := int64(bucketSeconds * 1000) - return r.db.WithContext(ctx).Exec(` - INSERT INTO gpu_metrics_aggs ( - agent_id, bucket_seconds, bucket_start, index, name, - max_utilization, max_memory_used, max_temperature, max_power_draw, memory_total - ) - SELECT - agent_id, - ? as bucket_seconds, - (timestamp / ?) * ? as bucket_start, - index, - MAX(name) as name, - MAX(utilization) as max_utilization, - MAX(memory_used) as max_memory_used, - MAX(temperature) as max_temperature, - MAX(power_draw) as max_power_draw, - MAX(memory_total) as memory_total - FROM gpu_metrics - WHERE timestamp >= ? AND timestamp < ? - GROUP BY agent_id, bucket_start, index - ON CONFLICT (agent_id, bucket_seconds, bucket_start, index) DO UPDATE SET - name = EXCLUDED.name, - max_utilization = EXCLUDED.max_utilization, - max_memory_used = EXCLUDED.max_memory_used, - max_temperature = EXCLUDED.max_temperature, - max_power_draw = EXCLUDED.max_power_draw, - memory_total = EXCLUDED.memory_total - `, bucketSeconds, bucketMs, bucketMs, start, end).Error -} - -// AggregateTemperatureToAgg 将原始温度数据聚合到聚合表 -func (r *MetricRepo) AggregateTemperatureToAgg(ctx context.Context, bucketSeconds int, start, end int64) error { - bucketMs := int64(bucketSeconds * 1000) - return r.db.WithContext(ctx).Exec(` - INSERT INTO temperature_metrics_aggs ( - agent_id, bucket_seconds, bucket_start, sensor_key, sensor_label, max_temperature - ) - SELECT - agent_id, - ? as bucket_seconds, - (timestamp / ?) * ? as bucket_start, - sensor_key, - MAX(sensor_label) as sensor_label, - MAX(temperature) as max_temperature - FROM temperature_metrics - WHERE timestamp >= ? AND timestamp < ? - GROUP BY agent_id, bucket_start, sensor_key - ON CONFLICT (agent_id, bucket_seconds, bucket_start, sensor_key) DO UPDATE SET - sensor_label = EXCLUDED.sensor_label, - max_temperature = EXCLUDED.max_temperature - `, bucketSeconds, bucketMs, bucketMs, start, end).Error -} - -// GetCPUMetricsAgg 从聚合表获取CPU指标 -func (r *MetricRepo) GetCPUMetricsAgg(ctx context.Context, agentID string, start, end int64, bucketSeconds int) ([]AggregatedCPUMetric, error) { - var metrics []AggregatedCPUMetric - err := r.db.WithContext(ctx). - Table("cpu_metrics_aggs"). - Select("bucket_start as timestamp, max_usage, logical_cores"). - Where("agent_id = ? AND bucket_seconds = ? AND bucket_start >= ? AND bucket_start <= ?", agentID, bucketSeconds, start, end). - Order("bucket_start"). - Scan(&metrics).Error - return metrics, err -} - -// GetMemoryMetricsAgg 从聚合表获取内存指标 -func (r *MetricRepo) GetMemoryMetricsAgg(ctx context.Context, agentID string, start, end int64, bucketSeconds int) ([]AggregatedMemoryMetric, error) { - var metrics []AggregatedMemoryMetric - err := r.db.WithContext(ctx). - Table("memory_metrics_aggs"). - Select("bucket_start as timestamp, max_usage, total"). - Where("agent_id = ? AND bucket_seconds = ? AND bucket_start >= ? AND bucket_start <= ?", agentID, bucketSeconds, start, end). - Order("bucket_start"). - Scan(&metrics).Error - return metrics, err -} - -// GetDiskMetricsAgg 从聚合表获取磁盘指标(返回预聚合的总和数据) -// 直接查询 mount_point=" 的预聚合记录 -func (r *MetricRepo) GetDiskMetricsAgg(ctx context.Context, agentID string, start, end int64, bucketSeconds int) ([]AggregatedDiskMetric, error) { - var metrics []AggregatedDiskMetric - err := r.db.WithContext(ctx). - Table("disk_metrics_aggs"). - Select("bucket_start as timestamp, mount_point, max_usage, total"). - Where("agent_id = ? AND bucket_seconds = ? AND bucket_start >= ? AND bucket_start <= ? AND mount_point = ?", - agentID, bucketSeconds, start, end, ""). // 空字符串查询总和记录 - Order("bucket_start"). - Scan(&metrics).Error - return metrics, err -} - -// GetNetworkMetricsAgg 从聚合表获取网络指标(可选按网卡接口过滤) -// 不指定网卡时查询 interface=" 的预聚合数据,指定网卡时只返回该网卡的数据 -func (r *MetricRepo) GetNetworkMetricsAgg(ctx context.Context, agentID string, start, end int64, bucketSeconds int, interfaceName string) ([]AggregatedNetworkMetric, error) { - var metrics []AggregatedNetworkMetric - - // 不管是否指定网卡,查询逻辑都一样:直接查询对应 interface 的数据 - // interfaceName 为空字符串时,会查询到预先保存的总和数据 - err := r.db.WithContext(ctx). - Table("network_metrics_aggs"). - Select("bucket_start as timestamp, interface, max_sent_rate, max_recv_rate"). - Where("agent_id = ? AND bucket_seconds = ? AND bucket_start >= ? AND bucket_start <= ? AND interface = ?", - agentID, bucketSeconds, start, end, interfaceName). - Order("bucket_start"). - Scan(&metrics).Error - return metrics, err -} - -// GetAvailableNetworkInterfaces 获取探针的可用网卡列表(不包括空白的总和记录) -func (r *MetricRepo) GetAvailableNetworkInterfaces(ctx context.Context, agentID string) ([]string, error) { - var interfaces []string - err := r.db.WithContext(ctx). - Table("network_metrics"). - Select("DISTINCT interface"). - Where("agent_id = ? AND interface != ?", agentID, ""). // 排除空字符串(总和记录) - Order("interface"). - Pluck("interface", &interfaces).Error - return interfaces, err -} - -// GetNetworkConnectionMetricsAgg 从聚合表获取网络连接指标 -func (r *MetricRepo) GetNetworkConnectionMetricsAgg(ctx context.Context, agentID string, start, end int64, bucketSeconds int) ([]AggregatedNetworkConnectionMetric, error) { - var metrics []AggregatedNetworkConnectionMetric - err := r.db.WithContext(ctx). - Table("network_connection_metrics_aggs"). - Select(`bucket_start as timestamp, - max_established, max_syn_sent, max_syn_recv, - max_fin_wait1, max_fin_wait2, max_time_wait, - max_close, max_close_wait, max_last_ack, - max_listen, max_closing, max_total`). - Where("agent_id = ? AND bucket_seconds = ? AND bucket_start >= ? AND bucket_start <= ?", agentID, bucketSeconds, start, end). - Order("bucket_start"). - Scan(&metrics).Error - return metrics, err -} - -// GetDiskIOMetricsAgg 从聚合表获取磁盘IO指标(汇总所有磁盘) -func (r *MetricRepo) GetDiskIOMetricsAgg(ctx context.Context, agentID string, start, end int64, bucketSeconds int) ([]AggregatedDiskIOMetric, error) { - var metrics []AggregatedDiskIOMetric - err := r.db.WithContext(ctx). - Table("disk_io_metrics_aggs"). - Select(`bucket_start as timestamp, - max_read_bytes_rate as max_read_rate, - max_write_bytes_rate as max_write_rate, - max_iops_in_progress`). - Where("agent_id = ? AND bucket_seconds = ? AND bucket_start >= ? AND bucket_start <= ?", agentID, bucketSeconds, start, end). - Order("bucket_start"). - Scan(&metrics).Error - return metrics, err -} - -// GetGPUMetricsAgg 从聚合表获取GPU指标 -func (r *MetricRepo) GetGPUMetricsAgg(ctx context.Context, agentID string, start, end int64, bucketSeconds int) ([]AggregatedGPUMetric, error) { - var metrics []AggregatedGPUMetric - err := r.db.WithContext(ctx). - Table("gpu_metrics_aggs"). - Select(`bucket_start as timestamp, index, name, - max_utilization, - max_memory_used, - max_temperature, - max_power_draw, - memory_total`). - Where("agent_id = ? AND bucket_seconds = ? AND bucket_start >= ? AND bucket_start <= ?", agentID, bucketSeconds, start, end). - Order("bucket_start, index"). - Scan(&metrics).Error - return metrics, err -} - -// GetTemperatureMetricsAgg 从聚合表获取温度指标 -func (r *MetricRepo) GetTemperatureMetricsAgg(ctx context.Context, agentID string, start, end int64, bucketSeconds int) ([]AggregatedTemperatureMetric, error) { - var metrics []AggregatedTemperatureMetric - err := r.db.WithContext(ctx). - Table("temperature_metrics_aggs"). - Select(`bucket_start as timestamp, sensor_key, sensor_label, - max_temperature`). - Where("agent_id = ? AND bucket_seconds = ? AND bucket_start >= ? AND bucket_start <= ?", agentID, bucketSeconds, start, end). - Order("bucket_start, sensor_key"). - Scan(&metrics).Error - return metrics, err -} - -// UpsertAggregationProgress 更新或插入聚合进度 -func (r *MetricRepo) UpsertAggregationProgress(ctx context.Context, metricType string, bucketSeconds int, lastBucket int64) error { - return r.db.WithContext(ctx). - Clauses(clause.OnConflict{ - Columns: []clause.Column{{Name: "metric_type"}, {Name: "bucket_seconds"}}, - DoUpdates: clause.AssignmentColumns([]string{"last_bucket"}), - }). - Create(&models.AggregationProgress{ - MetricType: metricType, - BucketSeconds: bucketSeconds, - LastBucket: lastBucket, - }).Error -} - -// GetAggregationProgress 获取聚合进度 -func (r *MetricRepo) GetAggregationProgress(ctx context.Context, metricType string, bucketSeconds int) (*models.AggregationProgress, error) { - var progress models.AggregationProgress - err := r.db.WithContext(ctx). - Where("metric_type = ? AND bucket_seconds = ?", metricType, bucketSeconds). - First(&progress).Error - if err != nil { - return nil, err - } - return &progress, nil -} - -// ----------- 监控指标聚合表操作 ----------- - -// AggregateMonitorMetricsToAgg 将原始监控数据聚合到聚合表 -func (r *MetricRepo) AggregateMonitorMetricsToAgg(ctx context.Context, bucketSeconds int, start, end int64) error { - bucketMs := int64(bucketSeconds * 1000) - return r.db.WithContext(ctx).Exec(` - INSERT INTO monitor_metrics_aggs ( - monitor_id, agent_id, bucket_seconds, bucket_start, - avg_response, max_response, min_response, - success_count, total_count, success_rate, - last_status, last_error - ) - WITH ranked_metrics AS ( - SELECT - monitor_id, - agent_id, - (timestamp / ?) * ? as bucket_start, - response_time, - status, - error, - timestamp, - ROW_NUMBER() OVER (PARTITION BY monitor_id, agent_id, (timestamp / ?) * ? ORDER BY timestamp DESC) as rn - FROM monitor_metrics - WHERE timestamp >= ? AND timestamp < ? - ) - SELECT - monitor_id, - agent_id, - ? as bucket_seconds, - bucket_start, - AVG(response_time) as avg_response, - MAX(response_time) as max_response, - MIN(response_time) as min_response, - SUM(CASE WHEN status = 'up' THEN 1 ELSE 0 END) as success_count, - COUNT(*) as total_count, - CAST(SUM(CASE WHEN status = 'up' THEN 1 ELSE 0 END) AS REAL) / CAST(COUNT(*) AS REAL) * 100 as success_rate, - MAX(CASE WHEN rn = 1 THEN status END) as last_status, - MAX(CASE WHEN rn = 1 THEN error END) as last_error - FROM ranked_metrics - GROUP BY monitor_id, agent_id, bucket_start - ON CONFLICT (monitor_id, agent_id, bucket_seconds, bucket_start) DO UPDATE SET - avg_response = EXCLUDED.avg_response, - max_response = EXCLUDED.max_response, - min_response = EXCLUDED.min_response, - success_count = EXCLUDED.success_count, - total_count = EXCLUDED.total_count, - success_rate = EXCLUDED.success_rate, - last_status = EXCLUDED.last_status, - last_error = EXCLUDED.last_error - `, bucketMs, bucketMs, bucketMs, bucketMs, start, end, bucketSeconds).Error -} - -// GetMonitorMetricsAgg 从聚合表获取监控指标(按探针和时间间隔聚合) +// GetMonitorMetricsAgg 获取聚合后的监控指标 +// TODO: 需要改为从 VictoriaMetrics 查询 func (r *MetricRepo) GetMonitorMetricsAgg(ctx context.Context, monitorID string, start, end int64, bucketSeconds int) ([]AggregatedMonitorMetric, error) { - var metrics []AggregatedMonitorMetric - err := r.db.WithContext(ctx). - Table("monitor_metrics_aggs"). - Select(`bucket_start as timestamp, agent_id, - avg_response, max_response, min_response, - success_count, total_count, success_rate, - last_status, last_error as last_error_msg`). - Where("monitor_id = ? AND bucket_seconds = ? AND bucket_start >= ? AND bucket_start <= ?", monitorID, bucketSeconds, start, end). - Order("bucket_start, agent_id"). - Scan(&metrics).Error - return metrics, err + // 暂时返回空列表,后续从 VictoriaMetrics 查询 + return []AggregatedMonitorMetric{}, nil } + +// MonitorMetric 监控指标(与service层定义保持一致) +type MonitorMetric struct { + ID uint `json:"id"` + AgentId string `json:"agentId"` + MonitorId string `json:"monitorId"` + Type string `json:"type"` + Target string `json:"target"` + Status string `json:"status"` + StatusCode int `json:"statusCode"` + ResponseTime int64 `json:"responseTime"` + Error string `json:"error"` + Message string `json:"message"` + ContentMatch bool `json:"contentMatch"` + CertExpiryTime int64 `json:"certExpiryTime"` + CertDaysLeft int `json:"certDaysLeft"` + Timestamp int64 `json:"timestamp"` +} + +// 注意:所有指标数据已迁移到 VictoriaMetrics +// 查询方法已移至 MetricService,通过 VMClient 访问 diff --git a/internal/service/agent_service.go b/internal/service/agent_service.go index 710ba2b..52c7607 100644 --- a/internal/service/agent_service.go +++ b/internal/service/agent_service.go @@ -331,12 +331,12 @@ func (s *AgentService) GetStatistics(ctx context.Context) (map[string]interface{ } // GetMonitorMetrics 获取监控指标历史数据 -func (s *AgentService) GetMonitorMetrics(ctx context.Context, agentID, monitorName string, start, end int64) ([]models.MonitorMetric, error) { +func (s *AgentService) GetMonitorMetrics(ctx context.Context, agentID, monitorName string, start, end int64) ([]MonitorMetric, error) { return s.metricService.GetMonitorMetrics(ctx, agentID, monitorName, start, end) } // GetMonitorMetricsByName 获取指定监控项的历史数据 -func (s *AgentService) GetMonitorMetricsByName(ctx context.Context, agentID, monitorName string, start, end int64, limit int) ([]models.MonitorMetric, error) { +func (s *AgentService) GetMonitorMetricsByName(ctx context.Context, agentID, monitorName string, start, end int64, limit int) ([]MonitorMetric, error) { return s.metricService.GetMonitorMetricsByName(ctx, agentID, monitorName, start, end, limit) } diff --git a/internal/service/alert_service.go b/internal/service/alert_service.go index b299b5c..50a908c 100644 --- a/internal/service/alert_service.go +++ b/internal/service/alert_service.go @@ -407,7 +407,7 @@ func (s *AlertService) checkCertificateAlerts(ctx context.Context, config *model } // checkCertAlert 检查并触发证书告警 -func (s *AlertService) checkCertAlert(ctx context.Context, config *models.AlertConfig, agent *models.Agent, monitor *models.MonitorMetric, certDaysLeft float64, now int64) { +func (s *AlertService) checkCertAlert(ctx context.Context, config *models.AlertConfig, agent *models.Agent, monitor *repo.MonitorMetric, certDaysLeft float64, now int64) { stateKey := fmt.Sprintf("%s:global:cert:%s", agent.ID, monitor.MonitorId) // 从数据库加载状态 @@ -479,7 +479,7 @@ func (s *AlertService) checkCertAlert(ctx context.Context, config *models.AlertC } // resolveCertAlert 恢复证书告警 -func (s *AlertService) resolveCertAlert(ctx context.Context, config *models.AlertConfig, agent *models.Agent, monitor *models.MonitorMetric, certDaysLeft float64) { +func (s *AlertService) resolveCertAlert(ctx context.Context, config *models.AlertConfig, agent *models.Agent, monitor *repo.MonitorMetric, certDaysLeft float64) { stateKey := fmt.Sprintf("%s:global:cert:%s", agent.ID, monitor.MonitorId) state, err := s.AlertStateRepo.GetAlertState(ctx, stateKey) @@ -603,7 +603,7 @@ func (s *AlertService) checkServiceDownAlerts(ctx context.Context, config *model } // fireServiceDownAlert 触发服务下线告警 -func (s *AlertService) fireServiceDownAlert(ctx context.Context, config *models.AlertConfig, agent *models.Agent, monitor *models.MonitorMetric, state *models.AlertState, now int64) { +func (s *AlertService) fireServiceDownAlert(ctx context.Context, config *models.AlertConfig, agent *models.Agent, monitor *repo.MonitorMetric, state *models.AlertState, now int64) { s.logger.Info("触发服务下线告警", zap.String("agentId", agent.ID), zap.String("monitorId", monitor.MonitorId), @@ -641,7 +641,7 @@ func (s *AlertService) fireServiceDownAlert(ctx context.Context, config *models. } // resolveServiceDownAlert 恢复服务下线告警 -func (s *AlertService) resolveServiceDownAlert(ctx context.Context, config *models.AlertConfig, agent *models.Agent, monitor *models.MonitorMetric, state *models.AlertState) { +func (s *AlertService) resolveServiceDownAlert(ctx context.Context, config *models.AlertConfig, agent *models.Agent, monitor *repo.MonitorMetric, state *models.AlertState) { s.logger.Info("服务下线告警恢复", zap.String("agentId", agent.ID), zap.String("monitorId", monitor.MonitorId), diff --git a/internal/service/metric_converter.go b/internal/service/metric_converter.go new file mode 100644 index 0000000..f9dd528 --- /dev/null +++ b/internal/service/metric_converter.go @@ -0,0 +1,152 @@ +package service + +import ( + "fmt" + + "github.com/dushixiang/pika/internal/protocol" + "github.com/dushixiang/pika/internal/vmclient" +) + +// convertToMetrics 将指标数据转换为 VictoriaMetrics Metric 对象 +func (s *MetricService) convertToMetrics(agentID string, metricType string, data interface{}, timestamp int64) []vmclient.Metric { + var metrics []vmclient.Metric + + switch protocol.MetricType(metricType) { + case protocol.MetricTypeCPU: + cpuData := data.(*protocol.CPUData) + metrics = append(metrics, createMetric("pika_cpu_usage_percent", agentID, nil, cpuData.UsagePercent, timestamp)) + metrics = append(metrics, createMetric("pika_cpu_cores_logical", agentID, nil, float64(cpuData.LogicalCores), timestamp)) + metrics = append(metrics, createMetric("pika_cpu_cores_physical", agentID, nil, float64(cpuData.PhysicalCores), timestamp)) + + case protocol.MetricTypeMemory: + memData := data.(*protocol.MemoryData) + metrics = append(metrics, createMetric("pika_memory_usage_percent", agentID, nil, memData.UsagePercent, timestamp)) + metrics = append(metrics, createMetric("pika_memory_total_bytes", agentID, nil, float64(memData.Total), timestamp)) + metrics = append(metrics, createMetric("pika_memory_used_bytes", agentID, nil, float64(memData.Used), timestamp)) + metrics = append(metrics, createMetric("pika_memory_available_bytes", agentID, nil, float64(memData.Available), timestamp)) + metrics = append(metrics, createMetric("pika_memory_swap_total_bytes", agentID, nil, float64(memData.SwapTotal), timestamp)) + metrics = append(metrics, createMetric("pika_memory_swap_used_bytes", agentID, nil, float64(memData.SwapUsed), timestamp)) + + case protocol.MetricTypeDisk: + diskDataList := data.([]protocol.DiskData) + for _, diskData := range diskDataList { + labels := map[string]string{"mount_point": diskData.MountPoint} + metrics = append(metrics, createMetric("pika_disk_usage_percent", agentID, labels, diskData.UsagePercent, timestamp)) + metrics = append(metrics, createMetric("pika_disk_total_bytes", agentID, labels, float64(diskData.Total), timestamp)) + metrics = append(metrics, createMetric("pika_disk_used_bytes", agentID, labels, float64(diskData.Used), timestamp)) + metrics = append(metrics, createMetric("pika_disk_free_bytes", agentID, labels, float64(diskData.Free), timestamp)) + } + + case protocol.MetricTypeNetwork: + networkDataList := data.([]protocol.NetworkData) + for _, netData := range networkDataList { + labels := map[string]string{"interface": netData.Interface} + metrics = append(metrics, createMetric("pika_network_sent_bytes_rate", agentID, labels, float64(netData.BytesSentRate), timestamp)) + metrics = append(metrics, createMetric("pika_network_recv_bytes_rate", agentID, labels, float64(netData.BytesRecvRate), timestamp)) + metrics = append(metrics, createMetric("pika_network_sent_bytes_total", agentID, labels, float64(netData.BytesSentTotal), timestamp)) + metrics = append(metrics, createMetric("pika_network_recv_bytes_total", agentID, labels, float64(netData.BytesRecvTotal), timestamp)) + } + + case protocol.MetricTypeNetworkConnection: + connData := data.(*protocol.NetworkConnectionData) + metrics = append(metrics, createMetric("pika_network_conn_established", agentID, nil, float64(connData.Established), timestamp)) + metrics = append(metrics, createMetric("pika_network_conn_syn_sent", agentID, nil, float64(connData.SynSent), timestamp)) + metrics = append(metrics, createMetric("pika_network_conn_syn_recv", agentID, nil, float64(connData.SynRecv), timestamp)) + metrics = append(metrics, createMetric("pika_network_conn_time_wait", agentID, nil, float64(connData.TimeWait), timestamp)) + metrics = append(metrics, createMetric("pika_network_conn_close_wait", agentID, nil, float64(connData.CloseWait), timestamp)) + metrics = append(metrics, createMetric("pika_network_conn_total", agentID, nil, float64(connData.Total), timestamp)) + + case protocol.MetricTypeDiskIO: + diskIODataList := data.([]*protocol.DiskIOData) + // 汇总所有磁盘的 IO + var totalReadRate, totalWriteRate uint64 + for _, diskIOData := range diskIODataList { + totalReadRate += diskIOData.ReadBytesRate + totalWriteRate += diskIOData.WriteBytesRate + } + metrics = append(metrics, createMetric("pika_disk_read_bytes_rate", agentID, nil, float64(totalReadRate), timestamp)) + metrics = append(metrics, createMetric("pika_disk_write_bytes_rate", agentID, nil, float64(totalWriteRate), timestamp)) + + case protocol.MetricTypeGPU: + gpuDataList := data.([]protocol.GPUData) + for _, gpuData := range gpuDataList { + labels := map[string]string{ + "gpu_index": fmt.Sprintf("%d", gpuData.Index), + "gpu_name": gpuData.Name, + } + metrics = append(metrics, createMetric("pika_gpu_utilization_percent", agentID, labels, gpuData.Utilization, timestamp)) + metrics = append(metrics, createMetric("pika_gpu_memory_total_bytes", agentID, labels, float64(gpuData.MemoryTotal), timestamp)) + metrics = append(metrics, createMetric("pika_gpu_memory_used_bytes", agentID, labels, float64(gpuData.MemoryUsed), timestamp)) + metrics = append(metrics, createMetric("pika_gpu_temperature_celsius", agentID, labels, gpuData.Temperature, timestamp)) + metrics = append(metrics, createMetric("pika_gpu_power_draw_watts", agentID, labels, gpuData.PowerUsage, timestamp)) + } + + case protocol.MetricTypeTemperature: + tempDataList := data.([]protocol.TemperatureData) + for _, tempData := range tempDataList { + labels := map[string]string{ + "sensor_key": tempData.SensorKey, + "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.ID, + "monitor_type": monitorData.Type, + "target": monitorData.Target, + } + metrics = append(metrics, createMetric("pika_monitor_response_time_ms", agentID, labels, float64(monitorData.ResponseTime), timestamp)) + + // 状态: up=1, down=0 + statusValue := 0.0 + if monitorData.Status == "up" { + statusValue = 1.0 + } + statusLabels := map[string]string{ + "monitor_id": monitorData.ID, + "monitor_type": monitorData.Type, + "target": monitorData.Target, + "status": monitorData.Status, + } + metrics = append(metrics, createMetric("pika_monitor_status", agentID, statusLabels, statusValue, timestamp)) + + // 状态码 + if monitorData.StatusCode > 0 { + metrics = append(metrics, createMetric("pika_monitor_status_code", agentID, labels, float64(monitorData.StatusCode), timestamp)) + } + + // 证书信息 + if monitorData.CertExpiryTime > 0 { + metrics = append(metrics, createMetric("pika_monitor_cert_expiry_timestamp_ms", agentID, labels, float64(monitorData.CertExpiryTime), timestamp)) + metrics = append(metrics, createMetric("pika_monitor_cert_days_left", agentID, labels, float64(monitorData.CertDaysLeft), timestamp)) + } + } + } + + return metrics +} + +// createMetric 创建 VictoriaMetrics Metric 对象 +func createMetric(metricName, agentID string, extraLabels map[string]string, value float64, timestamp int64) vmclient.Metric { + // 创建 metric labels,包含 __name__ 和 agent_id + labels := make(map[string]string) + labels["__name__"] = metricName + labels["agent_id"] = agentID + + // 添加额外的 labels + if extraLabels != nil { + for k, v := range extraLabels { + labels[k] = v + } + } + + return vmclient.Metric{ + Metric: labels, + Values: []float64{value}, + Timestamps: []int64{timestamp}, + } +} diff --git a/internal/service/metric_service.go b/internal/service/metric_service.go index d898a40..737bd60 100644 --- a/internal/service/metric_service.go +++ b/internal/service/metric_service.go @@ -3,40 +3,45 @@ package service import ( "context" "encoding/json" - "math" + "fmt" "time" "github.com/dushixiang/pika/internal/models" "github.com/dushixiang/pika/internal/protocol" "github.com/dushixiang/pika/internal/repo" + "github.com/dushixiang/pika/internal/vmclient" "github.com/go-orz/cache" "go.uber.org/zap" "gorm.io/gorm" ) -const ( - defaultMetricsRetentionHours = 24 * 7 // 默认保留 7 天 - defaultMaxQueryPoints = 300 // 默认最多返回 300 个点(优化前端渲染性能) -) - -var allowedIntervals = []int{ - 1, 2, 5, - 10, 15, 30, - 60, 120, 300, - 600, 900, 1800, - 3600, 7200, 14400, +// MetricDataPoint 统一的指标数据点结构 +type MetricDataPoint struct { + Timestamp int64 `json:"timestamp"` // 毫秒时间戳 + Value float64 `json:"value"` } -// 聚合任务支持的 bucket 列表(升序) -// 与前端 timeRangeOptions 对齐,优化查询效率 -var aggregationBuckets = []int{ - 60, // 1 分钟 - 适用于 15分钟、30分钟时间范围 - 300, // 5 分钟 - 适用于 1小时、3小时时间范围 - 900, // 15 分钟 - 适用于 6小时、12小时时间范围 - 1800, // 30 分钟 - 适用于 1天时间范围 - 3600, // 1 小时 - 适用于 3天时间范围 - 7200, // 2 小时 - 适用于 7天时间范围 +// MetricSeries 指标系列(支持多系列,如多网卡、多传感器) +type MetricSeries struct { + Name string `json:"name"` // 系列名称 + Labels map[string]string `json:"labels,omitempty"` // 额外标签 + Data []MetricDataPoint `json:"data"` // 数据点列表 +} + +// GetMetricsResponse 统一的查询响应格式 +type GetMetricsResponse struct { + AgentID string `json:"agentId"` + Type string `json:"type"` + Range string `json:"range"` + Series []MetricSeries `json:"series"` +} + +// QueryDefinition 查询定义(用于构建多个查询) +type QueryDefinition struct { + Name string // 系列名称 + Query string // PromQL 查询语句 + Labels map[string]string // 额外标签 } // MetricService 指标服务 @@ -46,18 +51,20 @@ type MetricService struct { monitorStatsRepo *repo.MonitorStatsRepo propertyService *PropertyService trafficService *TrafficService // 流量统计服务 + vmClient *vmclient.VMClient latestCache cache.Cache[string, *LatestMetrics] } // NewMetricService 创建指标服务 -func NewMetricService(logger *zap.Logger, db *gorm.DB, propertyService *PropertyService, trafficService *TrafficService) *MetricService { +func NewMetricService(logger *zap.Logger, db *gorm.DB, propertyService *PropertyService, trafficService *TrafficService, vmClient *vmclient.VMClient) *MetricService { return &MetricService{ logger: logger, metricRepo: repo.NewMetricRepo(db), monitorStatsRepo: repo.NewMonitorStatsRepo(db), propertyService: propertyService, trafficService: trafficService, + vmClient: vmClient, latestCache: cache.New[string, *LatestMetrics](time.Minute), } } @@ -66,20 +73,21 @@ func NewMetricService(logger *zap.Logger, db *gorm.DB, propertyService *Property func (s *MetricService) HandleMetricData(ctx context.Context, agentID string, metricType string, data json.RawMessage) error { now := time.Now().UnixMilli() + // 更新内存缓存 latestMetrics, ok := s.latestCache.Get(agentID) if !ok { latestMetrics = &LatestMetrics{} s.latestCache.Set(agentID, latestMetrics, time.Hour) } + // 解析数据并写入 VictoriaMetrics switch protocol.MetricType(metricType) { case protocol.MetricTypeCPU: - // CPU数据现在包含静态和动态信息 var cpuData protocol.CPUData if err := json.Unmarshal(data, &cpuData); err != nil { return err } - metric := &models.CPUMetric{ + metric := &CPUMetric{ AgentID: agentID, UsagePercent: cpuData.UsagePercent, LogicalCores: cpuData.LogicalCores, @@ -88,17 +96,17 @@ func (s *MetricService) HandleMetricData(ctx context.Context, agentID string, me Timestamp: now, } latestMetrics.CPU = metric - return s.metricRepo.SaveCPUMetric(ctx, metric) + metrics := s.convertToMetrics(agentID, metricType, &cpuData, now) + return s.vmClient.Write(ctx, metrics) case protocol.MetricTypeMemory: - // Memory数据现在包含静态和动态信息 var memData protocol.MemoryData if err := json.Unmarshal(data, &memData); err != nil { return err } - metric := &models.MemoryMetric{ + metric := &MemoryMetric{ AgentID: agentID, - Total: memData.Total, // 现在可以从合并后的数据获取 + Total: memData.Total, Used: memData.Used, Free: memData.Free, Available: memData.Available, @@ -109,113 +117,49 @@ func (s *MetricService) HandleMetricData(ctx context.Context, agentID string, me Timestamp: now, } latestMetrics.Memory = metric - return s.metricRepo.SaveMemoryMetric(ctx, metric) + metrics := s.convertToMetrics(agentID, metricType, &memData, now) + return s.vmClient.Write(ctx, metrics) case protocol.MetricTypeDisk: - // Disk现在是数组,需要批量处理 var diskDataList []protocol.DiskData if err := json.Unmarshal(data, &diskDataList); err != nil { return err } - - // 合并所有磁盘的数据用于保存总和 + // 计算汇总数据用于缓存 var totalTotal, totalUsed, totalFree uint64 - - // 保存每个磁盘的数据,同时累加总和 for _, diskData := range diskDataList { - // 保存单个磁盘数据 - metric := &models.DiskMetric{ - AgentID: agentID, - MountPoint: diskData.MountPoint, - Total: diskData.Total, - Used: diskData.Used, - Free: diskData.Free, - UsagePercent: diskData.UsagePercent, - Timestamp: now, - } - if err := s.metricRepo.SaveDiskMetric(ctx, metric); err != nil { - s.logger.Error("failed to save disk metric", - zap.Error(err), - zap.String("agentID", agentID), - zap.String("mountPoint", diskData.MountPoint)) - } - - // 累加所有磁盘的数据 totalTotal += diskData.Total totalUsed += diskData.Used totalFree += diskData.Free } - - // 保存合并后的总和数据(mount_point 字段设置为空字符串) var usagePercent float64 if totalTotal > 0 { usagePercent = float64(totalUsed) / float64(totalTotal) * 100 } - totalMetric := &models.DiskMetric{ - AgentID: agentID, - MountPoint: "all", + latestMetrics.Disk = &DiskSummary{ + UsagePercent: usagePercent, + TotalDisks: len(diskDataList), Total: totalTotal, Used: totalUsed, Free: totalFree, - UsagePercent: usagePercent, - Timestamp: now, } - latestMetrics.Disk = &DiskSummary{ - UsagePercent: totalMetric.UsagePercent, - TotalDisks: len(diskDataList), - Total: totalMetric.Total, - Used: totalMetric.Used, - Free: totalMetric.Free, - } - return s.metricRepo.SaveDiskMetric(ctx, totalMetric) + metrics := s.convertToMetrics(agentID, metricType, diskDataList, now) + return s.vmClient.Write(ctx, metrics) case protocol.MetricTypeNetwork: - // Network现在是数组,需要批量处理 var networkDataList []protocol.NetworkData if err := json.Unmarshal(data, &networkDataList); err != nil { return err } - - // 合并所有网卡的数据用于保存总和 + // 计算汇总数据用于缓存 var totalSentRate, totalRecvRate uint64 var totalSentTotal, totalRecvTotal uint64 - - // 保存每个网卡的数据,同时累加总和 for _, netData := range networkDataList { - // 保存单个网卡数据 - metric := &models.NetworkMetric{ - AgentID: agentID, - Interface: netData.Interface, - BytesSentRate: netData.BytesSentRate, - BytesRecvRate: netData.BytesRecvRate, - BytesSentTotal: netData.BytesSentTotal, - BytesRecvTotal: netData.BytesRecvTotal, - Timestamp: now, - } - if err := s.metricRepo.SaveNetworkMetric(ctx, metric); err != nil { - s.logger.Error("failed to save network metric", - zap.Error(err), - zap.String("agentID", agentID), - zap.String("interface", netData.Interface)) - } - - // 累加所有网卡的数据 totalSentRate += netData.BytesSentRate totalRecvRate += netData.BytesRecvRate totalSentTotal += netData.BytesSentTotal totalRecvTotal += netData.BytesRecvTotal } - - // 保存合并后的总和数据(interface 字段设置为空字符串) - totalMetric := &models.NetworkMetric{ - AgentID: agentID, - Interface: "all", // 空字符串表示所有网卡的合并数据 - BytesSentRate: totalSentRate, - BytesRecvRate: totalRecvRate, - BytesSentTotal: totalSentTotal, - BytesRecvTotal: totalRecvTotal, - Timestamp: now, - } latestMetrics.Network = &NetworkSummary{ TotalBytesSentRate: totalSentRate, TotalBytesRecvRate: totalRecvRate, @@ -223,28 +167,21 @@ func (s *MetricService) HandleMetricData(ctx context.Context, agentID string, me TotalBytesRecvTotal: totalRecvTotal, TotalInterfaces: len(networkDataList), } - - // 保存网络指标 - if err := s.metricRepo.SaveNetworkMetric(ctx, totalMetric); err != nil { - return err - } - - // 更新流量统计(仅处理汇总数据) + // 更新流量统计 if err := s.trafficService.UpdateAgentTraffic(ctx, agentID, totalRecvTotal); err != nil { s.logger.Error("更新探针流量统计失败", zap.String("agentId", agentID), zap.Error(err)) - // 流量统计失败不影响指标保存,只记录日志 } - - return nil + metrics := s.convertToMetrics(agentID, metricType, networkDataList, now) + return s.vmClient.Write(ctx, metrics) case protocol.MetricTypeNetworkConnection: var connData protocol.NetworkConnectionData if err := json.Unmarshal(data, &connData); err != nil { return err } - metric := &models.NetworkConnectionMetric{ + metric := &NetworkConnectionMetric{ AgentID: agentID, Established: connData.Established, SynSent: connData.SynSent, @@ -261,61 +198,23 @@ func (s *MetricService) HandleMetricData(ctx context.Context, agentID string, me Timestamp: now, } latestMetrics.NetworkConnection = metric - return s.metricRepo.SaveNetworkConnectionMetric(ctx, metric) + metrics := s.convertToMetrics(agentID, metricType, &connData, now) + return s.vmClient.Write(ctx, metrics) case protocol.MetricTypeDiskIO: - // DiskIO现在是数组,直接合并所有磁盘的数据存储为一条记录 var diskIODataList []*protocol.DiskIOData if err := json.Unmarshal(data, &diskIODataList); err != nil { return err } - - // 合并所有磁盘的数据 - var totalReadCount, totalWriteCount uint64 - var totalReadBytes, totalWriteBytes uint64 - var totalReadBytesRate, totalWriteBytesRate uint64 - var totalReadTime, totalWriteTime, totalIoTime uint64 - var maxIopsInProgress uint64 - - for _, diskIOData := range diskIODataList { - totalReadCount += diskIOData.ReadCount - totalWriteCount += diskIOData.WriteCount - totalReadBytes += diskIOData.ReadBytes - totalWriteBytes += diskIOData.WriteBytes - totalReadBytesRate += diskIOData.ReadBytesRate - totalWriteBytesRate += diskIOData.WriteBytesRate - totalReadTime += diskIOData.ReadTime - totalWriteTime += diskIOData.WriteTime - totalIoTime += diskIOData.IoTime - if diskIOData.IopsInProgress > maxIopsInProgress { - maxIopsInProgress = diskIOData.IopsInProgress - } - } - - // 保存合并后的数据(device 字段设置为空或 "all") - metric := &models.DiskIOMetric{ - AgentID: agentID, - Device: "all", - ReadCount: totalReadCount, - WriteCount: totalWriteCount, - ReadBytes: totalReadBytes, - WriteBytes: totalWriteBytes, - ReadBytesRate: totalReadBytesRate, - WriteBytesRate: totalWriteBytesRate, - ReadTime: totalReadTime, - WriteTime: totalWriteTime, - IoTime: totalIoTime, - IopsInProgress: maxIopsInProgress, - Timestamp: now, - } - return s.metricRepo.SaveDiskIOMetric(ctx, metric) + metrics := s.convertToMetrics(agentID, metricType, diskIODataList, now) + return s.vmClient.Write(ctx, metrics) case protocol.MetricTypeHost: var hostData protocol.HostInfoData if err := json.Unmarshal(data, &hostData); err != nil { return err } - // 保存主机信息 + // Host 信息仍然保存到 PostgreSQL(静态信息,不频繁变化) metric := &models.HostMetric{ AgentID: agentID, OS: hostData.OS, @@ -332,15 +231,14 @@ func (s *MetricService) HandleMetricData(ctx context.Context, agentID string, me return s.metricRepo.SaveHostMetric(ctx, metric) case protocol.MetricTypeGPU: - // GPU现在是数组,需要批量处理 var gpuDataList []protocol.GPUData if err := json.Unmarshal(data, &gpuDataList); err != nil { return err } - // 保存每个GPU的数据 - var gpuMetrics []models.GPUMetric + // 更新缓存 + var gpuMetrics []GPUMetric for _, gpuData := range gpuDataList { - metric := models.GPUMetric{ + gpuMetrics = append(gpuMetrics, GPUMetric{ AgentID: agentID, Index: gpuData.Index, Name: gpuData.Name, @@ -349,86 +247,47 @@ func (s *MetricService) HandleMetricData(ctx context.Context, agentID string, me MemoryUsed: gpuData.MemoryUsed, MemoryFree: gpuData.MemoryFree, Temperature: gpuData.Temperature, - PowerDraw: gpuData.PowerUsage, // protocol 中是 PowerUsage + PowerDraw: gpuData.PowerUsage, FanSpeed: gpuData.FanSpeed, - PerformanceState: "", // protocol 中没有这个字段,留空 + PerformanceState: "", Timestamp: now, - } - gpuMetrics = append(gpuMetrics, metric) - if err := s.metricRepo.SaveGPUMetric(ctx, &metric); err != nil { - s.logger.Error("failed to save gpu metric", - zap.Error(err), - zap.String("agentID", agentID), - zap.Int("index", gpuData.Index)) - } + }) } latestMetrics.GPU = gpuMetrics - return nil + metrics := s.convertToMetrics(agentID, metricType, gpuDataList, now) + return s.vmClient.Write(ctx, metrics) case protocol.MetricTypeTemperature: - // Temperature现在是数组,需要批量处理 var tempDataList []protocol.TemperatureData if err := json.Unmarshal(data, &tempDataList); err != nil { return err } - // 保存每个温度传感器的数据 - var tempMetrics []models.TemperatureMetric + // 更新缓存 + var tempMetrics []TemperatureMetric for _, tempData := range tempDataList { - // 使用 Type 作为 SensorLabel,更易读 sensorLabel := tempData.Type if sensorLabel == "" { - sensorLabel = tempData.SensorKey // 降级使用 SensorKey + sensorLabel = tempData.SensorKey } - - metric := models.TemperatureMetric{ + tempMetrics = append(tempMetrics, TemperatureMetric{ AgentID: agentID, SensorKey: tempData.SensorKey, SensorLabel: sensorLabel, Temperature: tempData.Temperature, Timestamp: now, - } - tempMetrics = append(tempMetrics, metric) - if err := s.metricRepo.SaveTemperatureMetric(ctx, &metric); err != nil { - s.logger.Error("failed to save temperature metric", - zap.Error(err), - zap.String("agentID", agentID), - zap.String("sensor", tempData.SensorKey)) - } + }) } latestMetrics.Temp = tempMetrics - return nil + metrics := s.convertToMetrics(agentID, metricType, tempDataList, now) + return s.vmClient.Write(ctx, metrics) case protocol.MetricTypeMonitor: - // 监控数据也是数组,需要批量处理 var monitorDataList []protocol.MonitorData if err := json.Unmarshal(data, &monitorDataList); err != nil { return err } - // 保存每个监控项的数据 - for _, monitorData := range monitorDataList { - metric := &models.MonitorMetric{ - AgentId: agentID, - MonitorId: monitorData.ID, - Type: monitorData.Type, - Target: monitorData.Target, - Status: monitorData.Status, - StatusCode: monitorData.StatusCode, - ResponseTime: monitorData.ResponseTime, - Error: monitorData.Error, - Message: monitorData.Message, - ContentMatch: monitorData.ContentMatch, - CertExpiryTime: monitorData.CertExpiryTime, - CertDaysLeft: monitorData.CertDaysLeft, - Timestamp: monitorData.CheckedAt, // 使用检测时间 - } - if err := s.metricRepo.SaveMonitorMetric(ctx, metric); err != nil { - s.logger.Error("failed to save monitor metric", - zap.Error(err), - zap.String("agentID", agentID), - zap.String("MonitorId", monitorData.ID)) - } - } - return nil + metrics := s.convertToMetrics(agentID, metricType, monitorDataList, now) + return s.vmClient.Write(ctx, metrics) default: s.logger.Warn("unknown metric type", zap.String("type", metricType)) @@ -436,197 +295,42 @@ func (s *MetricService) HandleMetricData(ctx context.Context, agentID string, me } } -// GetMetrics 获取聚合指标数据(自动路由到聚合表或原始表) -// interfaceName: 网卡过滤参数(仅对 network 类型有效) -func (s *MetricService) GetMetrics(ctx context.Context, agentID, metricType string, start, end int64, interval int, interfaceName string) (interface{}, error) { - start, end = s.normalizeTimeRange(ctx, start, end) - interval = s.DetermineInterval(ctx, start, end, interval) - - // 判断是否可以使用聚合表(仅支持部分指标类型) - aggCapable := map[string]bool{ - "cpu": true, - "memory": true, - "disk": true, - "network": true, - "network_connection": true, - "disk_io": true, - "gpu": true, - "temperature": true, +// GetMetrics 获取聚合指标数据(从 VictoriaMetrics 查询) +// 返回统一的 GetMetricsResponse 格式 +func (s *MetricService) GetMetrics(ctx context.Context, agentID, metricType string, start, end int64, interfaceName string) (*GetMetricsResponse, error) { + // 构造 PromQL 查询(返回多个查询以支持多系列) + queries := s.buildPromQLQueries(agentID, metricType, interfaceName) + if len(queries) == 0 { + return nil, fmt.Errorf("unsupported metric type: %s", metricType) } - // 智能选择聚合粒度:根据查询间隔选择最合适的bucket - // 例如:查询90秒数据时使用60秒bucket,查询600秒数据时使用300秒bucket - var bucketSeconds int - useAgg := false - if aggCapable[metricType] { - bucketSeconds = chooseAggregationBucket(interval) - useAgg = bucketSeconds > 0 - } + // 执行查询并转换结果 + // step 设为 0,让 VictoriaMetrics 自动选择合适的步长 + var series []MetricSeries - // 将时间范围对齐到最终使用的 bucket,避免不同时间框架出现桶数量偏差 - bucketMs := int64(interval * 1000) - if useAgg { - bucketMs = int64(bucketSeconds * 1000) - } - start, end = alignTimeRangeToBucket(start, end, bucketMs) - - switch metricType { - case "cpu": - if useAgg { - if metrics, err := s.metricRepo.GetCPUMetricsAgg(ctx, agentID, start, end, bucketSeconds); err == nil && len(metrics) > 0 { - return metrics, nil - } + for _, q := range queries { + result, err := s.vmClient.QueryRange(ctx, q.Query, + time.UnixMilli(start), + time.UnixMilli(end), + 0) + if err != nil { + s.logger.Error("查询 VictoriaMetrics 失败", + zap.String("query", q.Query), + zap.Error(err)) + continue // 跳过失败的查询,继续处理其他查询 } - return s.metricRepo.GetCPUMetrics(ctx, agentID, start, end, interval) - case "memory": - if useAgg { - if metrics, err := s.metricRepo.GetMemoryMetricsAgg(ctx, agentID, start, end, bucketSeconds); err == nil && len(metrics) > 0 { - return metrics, nil - } - } - return s.metricRepo.GetMemoryMetrics(ctx, agentID, start, end, interval) - case "disk": - if useAgg { - if metrics, err := s.metricRepo.GetDiskMetricsAgg(ctx, agentID, start, end, bucketSeconds); err == nil && len(metrics) > 0 { - return metrics, nil - } - } - return s.metricRepo.GetDiskMetrics(ctx, agentID, start, end, interval) - case "network": - if useAgg { - if metrics, err := s.metricRepo.GetNetworkMetricsAgg(ctx, agentID, start, end, bucketSeconds, interfaceName); err == nil && len(metrics) > 0 { - return metrics, nil - } - } - return s.metricRepo.GetNetworkMetrics(ctx, agentID, start, end, interval, interfaceName) - case "network_connection": - if useAgg { - if metrics, err := s.metricRepo.GetNetworkConnectionMetricsAgg(ctx, agentID, start, end, bucketSeconds); err == nil && len(metrics) > 0 { - return metrics, nil - } - } - return s.metricRepo.GetNetworkConnectionMetrics(ctx, agentID, start, end, interval) - case "disk_io": - if useAgg { - if metrics, err := s.metricRepo.GetDiskIOMetricsAgg(ctx, agentID, start, end, bucketSeconds); err == nil && len(metrics) > 0 { - return metrics, nil - } - } - return s.metricRepo.GetDiskIOMetrics(ctx, agentID, start, end, interval) - case "gpu": - if useAgg { - if metrics, err := s.metricRepo.GetGPUMetricsAgg(ctx, agentID, start, end, bucketSeconds); err == nil && len(metrics) > 0 { - return metrics, nil - } - } - return s.metricRepo.GetGPUMetrics(ctx, agentID, start, end, interval) - case "temperature": - if useAgg { - if metrics, err := s.metricRepo.GetTemperatureMetricsAgg(ctx, agentID, start, end, bucketSeconds); err == nil && len(metrics) > 0 { - return metrics, nil - } - } - return s.metricRepo.GetTemperatureMetrics(ctx, agentID, start, end, interval) - default: - return nil, nil - } -} -// DetermineInterval 根据配置、用户请求和时间范围决定聚合粒度 -func (s *MetricService) DetermineInterval(ctx context.Context, start, end int64, requested int) int { - interval := requested - if interval <= 0 { - interval = calculateBaseInterval(start, end) + // 转换查询结果为 MetricSeries + convertedSeries := s.convertQueryResultToSeries(result, q.Name, q.Labels) + series = append(series, convertedSeries...) } - interval = adjustIntervalForMaxPoints(start, end, interval, defaultMaxQueryPoints) - return interval -} - -// normalizeTimeRange 将时间范围限制在保留周期内,避免无意义的全表扫描 -func (s *MetricService) normalizeTimeRange(ctx context.Context, start, end int64) (int64, int64) { - cfg := s.getMetricsConfig(ctx) - retentionDuration := time.Duration(cfg.RetentionHours) * time.Hour - - retentionBoundary := time.Now().Add(-retentionDuration).UnixMilli() - if start < retentionBoundary { - start = retentionBoundary - } - if end <= start { - end = start + 1000 - } - return start, end -} - -// calculateBaseInterval 根据时间范围计算基础间隔 -func calculateBaseInterval(start, end int64) int { - duration := (end - start) / 1000 // 秒 - - switch { - case duration <= 60: // 1 分钟内 - return 2 - case duration <= 5*60: // 5 分钟内 - return 5 - case duration <= 15*60: // 15 分钟内 - return 15 - case duration <= 30*60: // 30 分钟内 - return 30 - case duration <= 60*60: // 1 小时内 - return 60 - case duration <= 3*60*60: // 3 小时内 - return 180 - case duration <= 6*60*60: // 6 小时内 - return 300 - case duration <= 12*60*60: // 12 小时内 - return 600 - case duration <= 24*60*60: // 1 天内 - return 900 - case duration <= 3*24*60*60: // 3 天内 - return 1800 - case duration <= 7*24*60*60: // 7 天内 - return 3600 - case duration <= 14*24*60*60: // 14 天内 - return 7200 - default: - return 14400 // 更长时间:4 小时粒度 - } -} - -// adjustIntervalForMaxPoints 根据最大数据点限制提升聚合粒度 -func adjustIntervalForMaxPoints(start, end int64, interval int, maxPoints int) int { - if interval <= 0 { - interval = 1 - } - if maxPoints <= 0 { - return alignInterval(interval) - } - - durationSeconds := float64(end-start) / 1000 - if durationSeconds <= 0 { - return alignInterval(interval) - } - - required := int(math.Ceil(durationSeconds / float64(maxPoints))) - interval = maxInt(interval, required) - return alignInterval(interval) -} - -// alignInterval 将间隔对齐到允许的值 -func alignInterval(interval int) int { - for _, candidate := range allowedIntervals { - if interval <= candidate { - return candidate - } - } - return allowedIntervals[len(allowedIntervals)-1] -} - -// maxInt 返回两个整数的最大值 -func maxInt(a, b int) int { - if a > b { - return a - } - return b + return &GetMetricsResponse{ + AgentID: agentID, + Type: metricType, + Range: fmt.Sprintf("%d-%d", start, end), + Series: series, + }, nil } // alignTimeRangeToBucket 将时间范围对齐到桶边界,确保不同时间框架的桶数一致 @@ -643,143 +347,6 @@ func alignTimeRangeToBucket(start, end int64, bucketMs int64) (int64, int64) { return alignedStart, alignedEnd } -// chooseAggregationBucket 根据请求的 interval 选择最合适的聚合 bucket(取不小于 interval 的最小桶) -func chooseAggregationBucket(interval int) int { - if interval <= 0 { - return 0 - } - for _, bucket := range aggregationBuckets { - if interval <= bucket { - return bucket - } - } - return aggregationBuckets[len(aggregationBuckets)-1] -} - -// getMetricsConfig 获取指标配置 -func (s *MetricService) getMetricsConfig(ctx context.Context) models.MetricsConfig { - cfg := models.MetricsConfig{ - RetentionHours: defaultMetricsRetentionHours, - } - - if s.propertyService == nil { - return cfg - } - - loaded := s.propertyService.GetMetricsConfig(ctx) - if loaded.RetentionHours > 0 { - cfg.RetentionHours = loaded.RetentionHours - } - return cfg -} - -// StartAggregationTask 启动聚合下采样任务 -func (s *MetricService) StartAggregationTask(ctx context.Context) { - ticker := time.NewTicker(1 * time.Minute) - defer ticker.Stop() - - s.logger.Info("aggregation task started") - - for { - select { - case <-ctx.Done(): - s.logger.Info("aggregation task stopped") - return - case <-ticker.C: - s.runAggregation(ctx) - } - } -} - -// runAggregation 按固定 bucket 下采样存储 -func (s *MetricService) runAggregation(ctx context.Context) { - cfg := s.getMetricsConfig(ctx) - retention := time.Duration(cfg.RetentionHours) * time.Hour - - for _, bucket := range aggregationBuckets { - s.aggregateMetric(ctx, "cpu", bucket, retention, s.metricRepo.AggregateCPUToAgg) - s.aggregateMetric(ctx, "memory", bucket, retention, s.metricRepo.AggregateMemoryToAgg) - s.aggregateMetric(ctx, "disk", bucket, retention, s.metricRepo.AggregateDiskToAgg) - s.aggregateMetric(ctx, "network", bucket, retention, s.metricRepo.AggregateNetworkToAgg) - s.aggregateMetric(ctx, "network_connection", bucket, retention, s.metricRepo.AggregateNetworkConnectionToAgg) - s.aggregateMetric(ctx, "disk_io", bucket, retention, s.metricRepo.AggregateDiskIOToAgg) - s.aggregateMetric(ctx, "gpu", bucket, retention, s.metricRepo.AggregateGPUToAgg) - s.aggregateMetric(ctx, "temperature", bucket, retention, s.metricRepo.AggregateTemperatureToAgg) - s.aggregateMetric(ctx, "monitor", bucket, retention, s.metricRepo.AggregateMonitorMetricsToAgg) - } -} - -type aggregateFn func(ctx context.Context, bucketSeconds int, start, end int64) error - -// aggregateMetric 聚合指定类型的指标 -func (s *MetricService) aggregateMetric(ctx context.Context, metricType string, bucketSeconds int, retention time.Duration, fn aggregateFn) { - bucketMs := int64(bucketSeconds * 1000) - - start := s.getAggregationStart(ctx, metricType, bucketSeconds, retention, bucketMs) - endBucket := (time.Now().Add(-time.Duration(bucketSeconds)*time.Second).UnixMilli() / bucketMs) * bucketMs - - if endBucket <= start { - return - } - - end := endBucket + bucketMs - 1 - - if err := fn(ctx, bucketSeconds, start, end); err != nil { - s.logger.Error("aggregate metric failed", zap.String("metricType", metricType), zap.Int("bucketSeconds", bucketSeconds), zap.Error(err)) - return - } - - if err := s.metricRepo.UpsertAggregationProgress(ctx, metricType, bucketSeconds, endBucket); err != nil { - s.logger.Error("update aggregation progress failed", zap.String("metricType", metricType), zap.Int("bucketSeconds", bucketSeconds), zap.Error(err)) - } -} - -// getAggregationStart 获取聚合开始时间 -func (s *MetricService) getAggregationStart(ctx context.Context, metricType string, bucketSeconds int, retention time.Duration, bucketMs int64) int64 { - progress, err := s.metricRepo.GetAggregationProgress(ctx, metricType, bucketSeconds) - if err == nil && progress != nil && progress.LastBucket > 0 { - return progress.LastBucket + bucketMs - } - - // 默认从保留窗口开始 - start := time.Now().Add(-retention).UnixMilli() - return (start / bucketMs) * bucketMs -} - -// StartCleanupTask 启动数据清理任务 -func (s *MetricService) StartCleanupTask(ctx context.Context) { - ticker := time.NewTicker(1 * time.Minute) - defer ticker.Stop() - - s.logger.Info("cleanup task started") - - for { - select { - case <-ctx.Done(): - s.logger.Info("cleanup task stopped") - return - case <-ticker.C: - s.cleanupOldMetrics(ctx) - } - } -} - -// cleanupOldMetrics 清理旧数据 -func (s *MetricService) cleanupOldMetrics(ctx context.Context) { - cfg := s.getMetricsConfig(ctx) - retentionDuration := time.Duration(cfg.RetentionHours) * time.Hour - before := time.Now().Add(-retentionDuration).UnixMilli() - - s.logger.Info("starting to clean old metrics", zap.Int64("beforeTimestamp", before), zap.Int("retentionHours", cfg.RetentionHours)) - - if err := s.metricRepo.DeleteOldMetrics(ctx, before); err != nil { - s.logger.Error("failed to clean old metrics", zap.Error(err)) - return - } - - s.logger.Info("old metrics cleaned successfully") -} - // GetLatestMetrics 获取最新指标 func (s *MetricService) GetLatestMetrics(ctx context.Context, agentID string) (*LatestMetrics, error) { metrics, _ := s.latestCache.Get(agentID) @@ -787,23 +354,168 @@ func (s *MetricService) GetLatestMetrics(ctx context.Context, agentID string) (* } // GetMonitorMetrics 获取监控指标历史数据 -func (s *MetricService) GetMonitorMetrics(ctx context.Context, agentID, monitorName string, start, end int64) ([]models.MonitorMetric, error) { - return s.metricRepo.GetMonitorMetrics(ctx, agentID, monitorName, start, end) +// TODO: 需要重写为从 VictoriaMetrics 查询 +func (s *MetricService) GetMonitorMetrics(ctx context.Context, agentID, monitorName string, start, end int64) ([]MonitorMetric, error) { + // 暂时返回空数据,后续从 VictoriaMetrics 查询 + return []MonitorMetric{}, nil } // GetMonitorMetricsByName 获取指定监控项的历史数据 -func (s *MetricService) GetMonitorMetricsByName(ctx context.Context, agentID, monitorName string, start, end int64, limit int) ([]models.MonitorMetric, error) { - return s.metricRepo.GetMonitorMetricsByName(ctx, agentID, monitorName, start, end, limit) +// TODO: 需要重写为从 VictoriaMetrics 查询 +func (s *MetricService) GetMonitorMetricsByName(ctx context.Context, agentID, monitorName string, start, end int64, limit int) ([]MonitorMetric, error) { + // 暂时返回空数据,后续从 VictoriaMetrics 查询 + return []MonitorMetric{}, nil } // DeleteAgentMetrics 删除探针的所有指标数据 func (s *MetricService) DeleteAgentMetrics(ctx context.Context, agentID string) error { - return s.metricRepo.DeleteAgentMetrics(ctx, agentID) + // 1. 删除 PostgreSQL 中的主机信息 + if err := s.metricRepo.DeleteAgentMetrics(ctx, agentID); err != nil { + s.logger.Error("删除 PostgreSQL 中的探针数据失败", + zap.String("agentID", agentID), + zap.Error(err)) + // 继续删除 VictoriaMetrics 中的数据 + } + + // 2. 删除 VictoriaMetrics 中的时间序列数据 + match := []string{fmt.Sprintf(`pika_.*{agent_id="%s"}`, agentID)} + if err := s.vmClient.DeleteSeries(ctx, match); err != nil { + s.logger.Error("删除 VictoriaMetrics 中的探针数据失败", + zap.String("agentID", agentID), + zap.Error(err)) + return err + } + + s.logger.Info("成功删除探针的所有指标数据", + zap.String("agentID", agentID)) + return nil } -// GetAvailableNetworkInterfaces 获取探针的可用网卡列表 +// DeleteMonitorMetrics 删除指定监控任务的所有指标数据 +func (s *MetricService) DeleteMonitorMetrics(ctx context.Context, monitorID string) error { + // 删除 VictoriaMetrics 中的监控指标数据 + match := []string{fmt.Sprintf(`pika_monitor_.*{monitor_id="%s"}`, monitorID)} + if err := s.vmClient.DeleteSeries(ctx, match); err != nil { + s.logger.Error("删除 VictoriaMetrics 中的监控数据失败", + zap.String("monitorID", monitorID), + zap.Error(err)) + return err + } + + s.logger.Info("成功删除监控任务的所有指标数据", + zap.String("monitorID", monitorID)) + return nil +} + +// GetAvailableNetworkInterfaces 获取探针的可用网卡列表(从 VictoriaMetrics 查询) func (s *MetricService) GetAvailableNetworkInterfaces(ctx context.Context, agentID string) ([]string, error) { - return s.metricRepo.GetAvailableNetworkInterfaces(ctx, agentID) + // 查询 interface label 的所有值,排除空字符串(汇总数据) + match := []string{fmt.Sprintf(`pika_network_sent_bytes_rate{agent_id="%s"}`, agentID)} + allInterfaces, err := s.vmClient.GetLabelValues(ctx, "interface", match) + if err != nil { + s.logger.Error("查询网卡列表失败", + zap.String("agentID", agentID), + zap.Error(err)) + return []string{}, nil // 返回空列表而不是错误 + } + + // 过滤掉空字符串(汇总数据) + interfaces := make([]string, 0, len(allInterfaces)) + for _, iface := range allInterfaces { + if iface != "" { + interfaces = append(interfaces, iface) + } + } + + return interfaces, nil +} + +// ===== 内存缓存使用的本地模型定义 ===== +// 注意:这些模型仅用于内存缓存,不再保存到 PostgreSQL + +// CPUMetric CPU指标(内存缓存) +type CPUMetric struct { + AgentID string `json:"agentId"` + UsagePercent float64 `json:"usagePercent"` + LogicalCores int `json:"logicalCores"` + PhysicalCores int `json:"physicalCores"` + ModelName string `json:"modelName"` + Timestamp int64 `json:"timestamp"` +} + +// MemoryMetric 内存指标(内存缓存) +type MemoryMetric struct { + AgentID string `json:"agentId"` + 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 `json:"timestamp"` +} + +// NetworkConnectionMetric 网络连接统计指标(内存缓存) +type NetworkConnectionMetric struct { + AgentID string `json:"agentId"` + Established uint32 `json:"established"` + SynSent uint32 `json:"synSent"` + SynRecv uint32 `json:"synRecv"` + FinWait1 uint32 `json:"finWait1"` + FinWait2 uint32 `json:"finWait2"` + TimeWait uint32 `json:"timeWait"` + Close uint32 `json:"close"` + CloseWait uint32 `json:"closeWait"` + LastAck uint32 `json:"lastAck"` + Listen uint32 `json:"listen"` + Closing uint32 `json:"closing"` + Total uint32 `json:"total"` + Timestamp int64 `json:"timestamp"` +} + +// GPUMetric GPU指标(内存缓存) +type GPUMetric struct { + AgentID string `json:"agentId"` + Index int `json:"index"` + Name string `json:"name"` + Utilization float64 `json:"utilization"` + 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 `json:"timestamp"` +} + +// TemperatureMetric 温度指标(内存缓存) +type TemperatureMetric struct { + AgentID string `json:"agentId"` + SensorKey string `json:"sensorKey"` + SensorLabel string `json:"sensorLabel"` + Temperature float64 `json:"temperature"` + Timestamp int64 `json:"timestamp"` +} + +// MonitorMetric 监控指标 +type MonitorMetric struct { + ID uint `json:"id"` + AgentId string `json:"agentId"` + MonitorId string `json:"monitorId"` + Type string `json:"type"` + Target string `json:"target"` + Status string `json:"status"` + StatusCode int `json:"statusCode"` + ResponseTime int64 `json:"responseTime"` + Error string `json:"error"` + Message string `json:"message"` + ContentMatch bool `json:"contentMatch"` + CertExpiryTime int64 `json:"certExpiryTime"` + CertDaysLeft int `json:"certDaysLeft"` + Timestamp int64 `json:"timestamp"` } // DiskSummary 磁盘汇总数据 @@ -826,12 +538,174 @@ type NetworkSummary struct { // LatestMetrics 最新指标数据(用于API响应) type LatestMetrics struct { - CPU *models.CPUMetric `json:"cpu,omitempty"` - Memory *models.MemoryMetric `json:"memory,omitempty"` - Disk *DiskSummary `json:"disk,omitempty"` - Network *NetworkSummary `json:"network,omitempty"` - NetworkConnection *models.NetworkConnectionMetric `json:"networkConnection,omitempty"` - Host *models.HostMetric `json:"host,omitempty"` - GPU []models.GPUMetric `json:"gpu,omitempty"` - Temp []models.TemperatureMetric `json:"temperature,omitempty"` + CPU *CPUMetric `json:"cpu,omitempty"` + Memory *MemoryMetric `json:"memory,omitempty"` + Disk *DiskSummary `json:"disk,omitempty"` + Network *NetworkSummary `json:"network,omitempty"` + NetworkConnection *NetworkConnectionMetric `json:"networkConnection,omitempty"` + Host *models.HostMetric `json:"host,omitempty"` + GPU []GPUMetric `json:"gpu,omitempty"` + Temp []TemperatureMetric `json:"temperature,omitempty"` +} + +// buildPromQLQueries 构造 PromQL 查询列表(支持多系列) +func (s *MetricService) buildPromQLQueries(agentID, metricType string, interfaceName string) []QueryDefinition { + var queries []QueryDefinition + + switch metricType { + case "cpu": + queries = []QueryDefinition{{ + Name: "usage", + Query: fmt.Sprintf(`pika_cpu_usage_percent{agent_id="%s"}`, agentID), + }} + + case "memory": + queries = []QueryDefinition{{ + Name: "usage", + Query: fmt.Sprintf(`pika_memory_usage_percent{agent_id="%s"}`, agentID), + }} + + case "disk": + queries = []QueryDefinition{{ + Name: "usage", + Query: fmt.Sprintf(`pika_disk_usage_percent{agent_id="%s",mount_point=""}`, agentID), + }} + + case "network": + // 网络流量:上行和下行 + if interfaceName != "" && interfaceName != "all" { + // 指定网卡 + queries = []QueryDefinition{ + { + Name: "upload", + Query: fmt.Sprintf(`pika_network_sent_bytes_rate{agent_id="%s",interface="%s"}`, agentID, interfaceName), + Labels: map[string]string{"interface": interfaceName}, + }, + { + Name: "download", + Query: fmt.Sprintf(`pika_network_recv_bytes_rate{agent_id="%s",interface="%s"}`, agentID, interfaceName), + Labels: map[string]string{"interface": interfaceName}, + }, + } + } else { + // 所有网卡汇总 + queries = []QueryDefinition{ + { + Name: "upload", + Query: fmt.Sprintf(`sum(pika_network_sent_bytes_rate{agent_id="%s"}) by (agent_id)`, agentID), + }, + { + Name: "download", + Query: fmt.Sprintf(`sum(pika_network_recv_bytes_rate{agent_id="%s"}) by (agent_id)`, agentID), + }, + } + } + + case "network_connection": + // 网络连接统计:多个状态 + queries = []QueryDefinition{ + {Name: "established", Query: fmt.Sprintf(`pika_network_conn_established{agent_id="%s"}`, agentID)}, + {Name: "time_wait", Query: fmt.Sprintf(`pika_network_conn_time_wait{agent_id="%s"}`, agentID)}, + {Name: "close_wait", Query: fmt.Sprintf(`pika_network_conn_close_wait{agent_id="%s"}`, agentID)}, + {Name: "listen", Query: fmt.Sprintf(`pika_network_conn_total{agent_id="%s"}`, agentID)}, + } + + case "disk_io": + // 磁盘 IO:读和写 + queries = []QueryDefinition{ + {Name: "read", Query: fmt.Sprintf(`pika_disk_read_bytes_rate{agent_id="%s"}`, agentID)}, + {Name: "write", Query: fmt.Sprintf(`pika_disk_write_bytes_rate{agent_id="%s"}`, agentID)}, + } + + case "gpu": + // GPU:利用率和温度(按 GPU 分组) + queries = []QueryDefinition{ + { + Name: "utilization", + Query: fmt.Sprintf(`pika_gpu_utilization_percent{agent_id="%s"}`, agentID), + }, + { + Name: "temperature", + Query: fmt.Sprintf(`pika_gpu_temperature_celsius{agent_id="%s"}`, agentID), + }, + } + + case "temperature": + // 温度:按传感器类型分组 + queries = []QueryDefinition{{ + Name: "temperature", + Query: fmt.Sprintf(`pika_temperature_celsius{agent_id="%s"}`, agentID), + }} + } + + return queries +} + +// convertQueryResultToSeries 将 VictoriaMetrics 查询结果转换为 MetricSeries +func (s *MetricService) convertQueryResultToSeries(result *vmclient.QueryResult, seriesName string, extraLabels map[string]string) []MetricSeries { + if result == nil || len(result.Data.Result) == 0 { + return []MetricSeries{} + } + + var allSeries []MetricSeries + + // 遍历所有时间序列 + for _, timeSeries := range result.Data.Result { + // 提取数据点 + var dataPoints []MetricDataPoint + for _, valueArray := range timeSeries.Values { + if len(valueArray) != 2 { + continue + } + + // valueArray: [timestamp(float64), value(string)] + timestamp, ok := valueArray[0].(float64) + if !ok { + continue + } + valueStr, ok := valueArray[1].(string) + if !ok { + continue + } + + var value float64 + fmt.Sscanf(valueStr, "%f", &value) + + dataPoints = append(dataPoints, MetricDataPoint{ + Timestamp: int64(timestamp * 1000), // 转换为毫秒 + Value: value, + }) + } + + // 合并标签 + labels := make(map[string]string) + for k, v := range timeSeries.Metric { + // 排除内部标签 + if k != "__name__" && k != "agent_id" { + labels[k] = v + } + } + // 添加额外标签 + for k, v := range extraLabels { + labels[k] = v + } + + // 构建系列名称(如果有特定标签如 GPU index 或 sensor_label,添加到名称中) + finalName := seriesName + if sensorLabel, ok := labels["sensor_label"]; ok { + finalName = sensorLabel + delete(labels, "sensor_label") // 已合并到名称中,从标签中删除 + } else if gpuIndex, ok := labels["gpu_index"]; ok { + finalName = fmt.Sprintf("GPU_%s", gpuIndex) + delete(labels, "gpu_index") + } + + allSeries = append(allSeries, MetricSeries{ + Name: finalName, + Labels: labels, + Data: dataPoints, + }) + } + + return allSeries } diff --git a/internal/service/monitor_service.go b/internal/service/monitor_service.go index 6db542b..af18dff 100644 --- a/internal/service/monitor_service.go +++ b/internal/service/monitor_service.go @@ -26,6 +26,7 @@ type MonitorService struct { *orz.Service agentRepo *repo.AgentRepo metricRepo *repo.MetricRepo + metricService *MetricService monitorStatsRepo *repo.MonitorStatsRepo wsManager *ws.Manager @@ -45,13 +46,14 @@ type MonitorScheduler interface { RemoveTask(monitorID string) } -func NewMonitorService(logger *zap.Logger, db *gorm.DB, wsManager *ws.Manager) *MonitorService { +func NewMonitorService(logger *zap.Logger, db *gorm.DB, metricService *MetricService, wsManager *ws.Manager) *MonitorService { return &MonitorService{ logger: logger, Service: orz.NewService(db), MonitorRepo: repo.NewMonitorRepo(db), agentRepo: repo.NewAgentRepo(db), metricRepo: repo.NewMetricRepo(db), + metricService: metricService, monitorStatsRepo: repo.NewMonitorStatsRepo(db), wsManager: wsManager, @@ -233,8 +235,8 @@ func (s *MonitorService) DeleteMonitor(ctx context.Context, id string) error { return err } - // 删除监控指标数据 - if err := s.metricRepo.DeleteMonitorMetrics(ctx, id); err != nil { + // 删除监控指标数据(从 VictoriaMetrics) + if err := s.metricService.DeleteMonitorMetrics(ctx, id); err != nil { s.logger.Error("删除监控指标数据失败", zap.String("monitorId", id), zap.Error(err)) return err } diff --git a/internal/service/property_service.go b/internal/service/property_service.go index c1354ee..43e3875 100644 --- a/internal/service/property_service.go +++ b/internal/service/property_service.go @@ -120,22 +120,6 @@ func (s *PropertyService) GetSystemConfig(ctx context.Context) (*models.SystemCo return &systemConfig, nil } -// GetMetricsConfig 获取指标配置 -func (s *PropertyService) GetMetricsConfig(ctx context.Context) models.MetricsConfig { - var config models.MetricsConfig - err := s.GetValue(ctx, PropertyIDMetricsConfig, &config) - if err != nil { - // 返回默认配置 - return models.MetricsConfig{} - } - return config -} - -// SetMetricsConfig 设置指标配置 -func (s *PropertyService) SetMetricsConfig(ctx context.Context, config models.MetricsConfig) error { - return s.Set(ctx, PropertyIDMetricsConfig, "指标数据配置", config) -} - // GetAlertConfig 获取告警配置 func (s *PropertyService) GetAlertConfig(ctx context.Context) (*models.AlertConfig, error) { var config models.AlertConfig @@ -252,13 +236,6 @@ func (s *PropertyService) InitializeDefaultConfigs(ctx context.Context) error { Name: "通知渠道配置", Value: []models.NotificationChannelConfig{}, }, - { - ID: PropertyIDMetricsConfig, - Name: "指标数据配置", - Value: models.MetricsConfig{ - RetentionHours: 168, // 默认7天 - }, - }, { ID: PropertyIDAlertConfig, Name: "告警配置", diff --git a/internal/vmclient/client.go b/internal/vmclient/client.go new file mode 100644 index 0000000..8109848 --- /dev/null +++ b/internal/vmclient/client.go @@ -0,0 +1,337 @@ +package vmclient + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" +) + +// VMClient VictoriaMetrics 客户端 +type VMClient struct { + baseURL string + httpClient *http.Client + writeTimeout time.Duration + queryTimeout time.Duration +} + +// QueryResult 查询结果 +type QueryResult struct { + Status string `json:"status"` + Data ResultData `json:"data"` +} + +// ResultData 查询结果数据 +type ResultData struct { + ResultType string `json:"resultType"` + Result []Result `json:"result"` +} + +// Result 单个时间序列结果 +type Result struct { + Metric map[string]string `json:"metric"` + Values [][]interface{} `json:"values"` // [[timestamp, value], ...] +} + +// DataPoint 数据点 +type DataPoint struct { + Timestamp int64 + Value float64 + Labels map[string]string +} + +// Metric VictoriaMetrics JSON Line Format 指标 +type Metric struct { + Metric map[string]string `json:"metric"` + Values []float64 `json:"values"` + Timestamps []int64 `json:"timestamps"` +} + +// NewVMClient 创建 VictoriaMetrics 客户端 +func NewVMClient(baseURL string, writeTimeout, queryTimeout time.Duration) *VMClient { + if writeTimeout == 0 { + writeTimeout = 30 * time.Second + } + if queryTimeout == 0 { + queryTimeout = 60 * time.Second + } + + return &VMClient{ + baseURL: baseURL, + httpClient: &http.Client{ + Timeout: writeTimeout, // 默认超时 + }, + writeTimeout: writeTimeout, + queryTimeout: queryTimeout, + } +} + +// Write 写入指标(VictoriaMetrics JSON Line Format) +func (c *VMClient) Write(ctx context.Context, metrics []Metric) error { + if len(metrics) == 0 { + return nil + } + + reqCtx, cancel := context.WithTimeout(ctx, c.writeTimeout) + defer cancel() + + // 将 Metric 数组转换为 JSON Line Format (NDJSON) + var buf bytes.Buffer + encoder := json.NewEncoder(&buf) + for _, metric := range metrics { + if err := encoder.Encode(metric); err != nil { + return fmt.Errorf("encode metric failed: %w", err) + } + } + + req, err := http.NewRequestWithContext(reqCtx, "POST", c.baseURL+"/api/v1/import", &buf) + if err != nil { + return fmt.Errorf("create request failed: %w", err) + } + + req.Header.Set("Content-Type", "application/x-ndjson") + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("write metrics failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("write metrics failed with status %d: %s", resp.StatusCode, string(body)) + } + + return nil +} + +// AutoStep 根据查询范围自动生成 step(适用于 VictoriaMetrics) +func AutoStep(start, end time.Time) time.Duration { + r := end.Sub(start) + + switch { + case r <= time.Hour: + return 10 * time.Second + case r <= 2*time.Hour: + return 15 * time.Second + case r <= 6*time.Hour: + return 30 * time.Second + case r <= 12*time.Hour: + return time.Minute + case r <= 24*time.Hour: + return 2 * time.Minute + case r <= 3*24*time.Hour: + return 5 * time.Minute + case r <= 7*24*time.Hour: + return 10 * time.Minute + case r <= 30*24*time.Hour: + return 30 * time.Minute + default: + return time.Hour + } +} + +// QueryRange 范围查询 +// 如果 step 为 0,则让 VictoriaMetrics 自动选择合适的步长 +func (c *VMClient) QueryRange(ctx context.Context, query string, start, end time.Time, step time.Duration) (*QueryResult, error) { + reqCtx, cancel := context.WithTimeout(ctx, c.queryTimeout) + defer cancel() + + params := url.Values{} + params.Set("query", query) + params.Set("start", fmt.Sprintf("%d", start.Unix())) + params.Set("end", fmt.Sprintf("%d", end.Unix())) + if step > 0 { + params.Set("step", fmt.Sprintf("%ds", int(step.Seconds()))) + } else { + autoStep := AutoStep(start, end) + params.Set("step", fmt.Sprintf("%ds", int(autoStep.Seconds()))) + } + + reqURL := fmt.Sprintf("%s/api/v1/query_range?%s", c.baseURL, params.Encode()) + + req, err := http.NewRequestWithContext(reqCtx, "GET", reqURL, nil) + if err != nil { + return nil, fmt.Errorf("create request failed: %w", err) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("query range failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("query range failed with status %d: %s", resp.StatusCode, string(body)) + } + + var result QueryResult + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("decode response failed: %w", err) + } + + if result.Status != "success" { + return nil, fmt.Errorf("query failed with status: %s", result.Status) + } + + return &result, nil +} + +// Query 即时查询 +func (c *VMClient) Query(ctx context.Context, query string) (*QueryResult, error) { + reqCtx, cancel := context.WithTimeout(ctx, c.queryTimeout) + defer cancel() + + params := url.Values{} + params.Set("query", query) + + reqURL := fmt.Sprintf("%s/api/v1/query?%s", c.baseURL, params.Encode()) + + req, err := http.NewRequestWithContext(reqCtx, "GET", reqURL, nil) + if err != nil { + return nil, fmt.Errorf("create request failed: %w", err) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("query failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("query failed with status %d: %s", resp.StatusCode, string(body)) + } + + var result QueryResult + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("decode response failed: %w", err) + } + + if result.Status != "success" { + return nil, fmt.Errorf("query failed with status: %s", result.Status) + } + + return &result, nil +} + +// DeleteSeries 删除时间序列 +func (c *VMClient) DeleteSeries(ctx context.Context, match []string) error { + reqCtx, cancel := context.WithTimeout(ctx, c.writeTimeout) + defer cancel() + + params := url.Values{} + for _, m := range match { + params.Add("match[]", m) + } + + reqURL := fmt.Sprintf("%s/api/v1/admin/tsdb/delete_series?%s", c.baseURL, params.Encode()) + + req, err := http.NewRequestWithContext(reqCtx, "POST", reqURL, nil) + if err != nil { + return fmt.Errorf("create request failed: %w", err) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("delete series failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("delete series failed with status %d: %s", resp.StatusCode, string(body)) + } + + return nil +} + +// ConvertToDataPoints 将查询结果转换为数据点列表 +func ConvertToDataPoints(result *QueryResult) []DataPoint { + if result == nil || len(result.Data.Result) == 0 { + return []DataPoint{} + } + + var points []DataPoint + for _, r := range result.Data.Result { + for _, v := range r.Values { + if len(v) < 2 { + continue + } + + // timestamp 是 float64(Unix 秒) + timestamp, ok := v[0].(float64) + if !ok { + continue + } + + // value 是 string + valueStr, ok := v[1].(string) + if !ok { + continue + } + + var value float64 + if _, err := fmt.Sscanf(valueStr, "%f", &value); err != nil { + continue + } + + points = append(points, DataPoint{ + Timestamp: int64(timestamp * 1000), // 转换为毫秒 + Value: value, + Labels: r.Metric, + }) + } + } + + return points +} + +// GetLabelValues 获取指定 label 的所有值 +func (c *VMClient) GetLabelValues(ctx context.Context, labelName string, match []string) ([]string, error) { + reqCtx, cancel := context.WithTimeout(ctx, c.queryTimeout) + defer cancel() + + params := url.Values{} + for _, m := range match { + params.Add("match[]", m) + } + + reqURL := fmt.Sprintf("%s/api/v1/label/%s/values?%s", c.baseURL, labelName, params.Encode()) + + req, err := http.NewRequestWithContext(reqCtx, "GET", reqURL, nil) + if err != nil { + return nil, fmt.Errorf("create request failed: %w", err) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("get label values failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("get label values failed with status %d: %s", resp.StatusCode, string(body)) + } + + var result struct { + Status string `json:"status"` + Data []string `json:"data"` + } + + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("decode response failed: %w", err) + } + + if result.Status != "success" { + return nil, fmt.Errorf("get label values failed with status: %s", result.Status) + } + + return result.Data, nil +} diff --git a/internal/wire.go b/internal/wire.go index 7fc903a..dff2fd8 100644 --- a/internal/wire.go +++ b/internal/wire.go @@ -4,10 +4,13 @@ package internal import ( + "time" + "github.com/dushixiang/pika/internal/config" "github.com/dushixiang/pika/internal/handler" "github.com/dushixiang/pika/internal/repo" "github.com/dushixiang/pika/internal/service" + "github.com/dushixiang/pika/internal/vmclient" "github.com/dushixiang/pika/internal/websocket" "github.com/google/wire" "go.uber.org/zap" @@ -17,6 +20,9 @@ import ( // InitializeApp 初始化应用 func InitializeApp(logger *zap.Logger, db *gorm.DB, cfg *config.AppConfig) (*AppComponents, error) { wire.Build( + // VictoriaMetrics Client + provideVMClient, + service.NewAccountService, service.NewAgentService, service.NewUserService, @@ -80,4 +86,33 @@ type AppComponents struct { DDNSService *service.DDNSService WSManager *websocket.Manager + VMClient *vmclient.VMClient +} + +// provideVMClient 提供 VictoriaMetrics 客户端 +func provideVMClient(cfg *config.AppConfig, logger *zap.Logger) *vmclient.VMClient { + // 检查配置 + if cfg.VictoriaMetrics == nil || !cfg.VictoriaMetrics.Enabled { + logger.Info("VictoriaMetrics is not enabled, using default configuration") + // 返回一个默认配置的客户端(用于本地开发) + return vmclient.NewVMClient("http://localhost:8428", 30*time.Second, 60*time.Second) + } + + // 使用配置创建客户端 + writeTimeout := time.Duration(cfg.VictoriaMetrics.WriteTimeout) * time.Second + if writeTimeout == 0 { + writeTimeout = 30 * time.Second + } + + queryTimeout := time.Duration(cfg.VictoriaMetrics.QueryTimeout) * time.Second + if queryTimeout == 0 { + queryTimeout = 60 * time.Second + } + + logger.Info("VictoriaMetrics client initialized", + zap.String("url", cfg.VictoriaMetrics.URL), + zap.Duration("writeTimeout", writeTimeout), + zap.Duration("queryTimeout", queryTimeout)) + + return vmclient.NewVMClient(cfg.VictoriaMetrics.URL, writeTimeout, queryTimeout) } diff --git a/internal/wire_gen.go b/internal/wire_gen.go index 52fd4cc..81fbacc 100644 --- a/internal/wire_gen.go +++ b/internal/wire_gen.go @@ -11,9 +11,11 @@ import ( "github.com/dushixiang/pika/internal/handler" "github.com/dushixiang/pika/internal/repo" "github.com/dushixiang/pika/internal/service" + "github.com/dushixiang/pika/internal/vmclient" "github.com/dushixiang/pika/internal/websocket" "go.uber.org/zap" "gorm.io/gorm" + "time" ) // Injectors from wire.go: @@ -28,14 +30,15 @@ func InitializeApp(logger *zap.Logger, db *gorm.DB, cfg *config.AppConfig) (*App apiKeyService := service.NewApiKeyService(logger, db) propertyService := service.NewPropertyService(logger, db) trafficService := service.NewTrafficService(logger, db) - metricService := service.NewMetricService(logger, db, propertyService, trafficService) + vmClient := provideVMClient(cfg, logger) + metricService := service.NewMetricService(logger, db, propertyService, trafficService, vmClient) geoIPService, err := service.NewGeoIPService(logger, cfg) if err != nil { return nil, err } agentService := service.NewAgentService(logger, db, apiKeyService, metricService, geoIPService) manager := websocket.NewManager(logger) - monitorService := service.NewMonitorService(logger, db, manager) + monitorService := service.NewMonitorService(logger, db, metricService, manager) tamperRepo := repo.NewTamperRepo(db) tamperService := service.NewTamperService(logger, tamperRepo, manager) ddnsConfigRepo := repo.NewDDNSConfigRepo(db) @@ -70,6 +73,7 @@ func InitializeApp(logger *zap.Logger, db *gorm.DB, cfg *config.AppConfig) (*App TamperService: tamperService, DDNSService: ddnsService, WSManager: manager, + VMClient: vmClient, } return appComponents, nil } @@ -98,4 +102,29 @@ type AppComponents struct { DDNSService *service.DDNSService WSManager *websocket.Manager + VMClient *vmclient.VMClient +} + +// provideVMClient 提供 VictoriaMetrics 客户端 +func provideVMClient(cfg *config.AppConfig, logger *zap.Logger) *vmclient.VMClient { + + if cfg.VictoriaMetrics == nil || !cfg.VictoriaMetrics.Enabled { + logger.Info("VictoriaMetrics is not enabled, using default configuration") + + return vmclient.NewVMClient("http://localhost:8428", 30*time.Second, 60*time.Second) + } + + writeTimeout := time.Duration(cfg.VictoriaMetrics.WriteTimeout) * time.Second + if writeTimeout == 0 { + writeTimeout = 30 * time.Second + } + + queryTimeout := time.Duration(cfg.VictoriaMetrics.QueryTimeout) * time.Second + if queryTimeout == 0 { + queryTimeout = 60 * time.Second + } + + logger.Info("VictoriaMetrics client initialized", zap.String("url", cfg.VictoriaMetrics.URL), zap.Duration("writeTimeout", writeTimeout), zap.Duration("queryTimeout", queryTimeout)) + + return vmclient.NewVMClient(cfg.VictoriaMetrics.URL, writeTimeout, queryTimeout) } diff --git a/pkg/agent/collector/manager.go b/pkg/agent/collector/manager.go index b3314e6..6022b97 100644 --- a/pkg/agent/collector/manager.go +++ b/pkg/agent/collector/manager.go @@ -188,8 +188,6 @@ func (m *Manager) sendMetrics(conn WebSocketWriter, metricType protocol.MetricTy return err } - //dataBytes = bytes.ReplaceAll(dataBytes, []byte{0x00}, []byte(`"`)) - metrics := protocol.MetricsWrapper{ Type: metricType, Data: json.RawMessage(dataBytes), diff --git a/playground/docker-compose.yml b/playground/docker-compose.yml index f293f4b..3bff3d7 100644 --- a/playground/docker-compose.yml +++ b/playground/docker-compose.yml @@ -14,3 +14,11 @@ services: interval: 10s timeout: 5s retries: 5 + victoriametrics: + image: victoriametrics/victoria-metrics:v1.131.0 + container_name: victoriametrics + restart: always + ports: + - "8428:8428" # API, PromQL, insert, select + volumes: + - ./data/vmdata:/victoria-metrics-data \ No newline at end of file diff --git a/web/src/api/agent.ts b/web/src/api/agent.ts index 6e38d49..d7802ed 100644 --- a/web/src/api/agent.ts +++ b/web/src/api/agent.ts @@ -13,14 +13,23 @@ export interface GetAgentMetricsRequest { interface?: string; // 网卡过滤参数(仅对 network 类型有效) } +// 新的统一数据格式 +export interface MetricDataPoint { + timestamp: number; + value: number; +} + +export interface MetricSeries { + name: string; + labels?: Record; + data: MetricDataPoint[]; +} + export interface GetAgentMetricsResponse { agentId: string; type: string; range: string; - start: number; - end: number; - interface?: string; - metrics: any[]; + series: MetricSeries[]; } // 管理员接口 - 获取所有探针(需要认证) diff --git a/web/src/pages/Public/ServerDetail.tsx b/web/src/pages/Public/ServerDetail.tsx index 2c15998..092c4d4 100644 --- a/web/src/pages/Public/ServerDetail.tsx +++ b/web/src/pages/Public/ServerDetail.tsx @@ -31,18 +31,14 @@ import { getAgentLatestMetrics, getAgentMetrics, type GetAgentMetricsRequest, + type GetAgentMetricsResponse, + type MetricSeries, + type MetricDataPoint, getAvailableNetworkInterfaces, } from '@/api/agent.ts'; import {type TimeRangeOption} from '@/api/property.ts'; import type { Agent, - AggregatedCPUMetric, - AggregatedDiskIOMetric, - AggregatedGPUMetric, - AggregatedMemoryMetric, - AggregatedNetworkConnectionMetric, - AggregatedNetworkMetric, - AggregatedTemperatureMetric, LatestMetrics } from '@/types'; import dayjs from "dayjs"; @@ -246,15 +242,15 @@ const TimeRangeSelector = ({ type MetricsTooltipProps = TooltipProps & { unit?: string, label?: string, payload?: any[] }; +// 新的统一 Metrics 状态类型 type MetricsState = { - cpu: AggregatedCPUMetric[]; - memory: AggregatedMemoryMetric[]; - network: AggregatedNetworkMetric[]; - networkConnection: AggregatedNetworkConnectionMetric[]; - // disk: AggregatedDiskMetric[]; - diskIO: AggregatedDiskIOMetric[]; - gpu: AggregatedGPUMetric[]; - temperature: AggregatedTemperatureMetric[]; + cpu: MetricSeries[]; + memory: MetricSeries[]; + network: MetricSeries[]; + networkConnection: MetricSeries[]; + diskIO: MetricSeries[]; + gpu: MetricSeries[]; + temperature: MetricSeries[]; }; const createEmptyMetricsState = (): MetricsState => ({ @@ -262,7 +258,6 @@ const createEmptyMetricsState = (): MetricsState => ({ memory: [], network: [], networkConnection: [], - // disk: [], diskIO: [], gpu: [], temperature: [], @@ -370,7 +365,8 @@ const useAggregatedMetrics = (agentId: string | undefined, range: string, interf if (cancelled) return; const nextState = createEmptyMetricsState(); metricRequestConfig.forEach(({key}, index) => { - nextState[key] = responses[index].data.metrics || []; + // 新的 API 返回 series 数组 + nextState[key] = responses[index].data.series || []; }); setMetrics(nextState); } catch (error) { @@ -517,31 +513,35 @@ const ServerDetail = () => { const {agent, latestMetrics, loading} = useAgentOverview(id); const metricsData = useAggregatedMetrics(id, timeRange, selectedInterface); - const cpuChartData = useMemo( - () => - metricsData.cpu.map((item) => ({ - time: new Date(item.timestamp).toLocaleTimeString('zh-CN', { - hour: '2-digit', - minute: '2-digit', - }), - usage: Number(item.maxUsage.toFixed(2)), - timestamp: item.timestamp, - })), - [metricsData.cpu] - ); + const cpuChartData = useMemo(() => { + // CPU 数据:取第一个系列(usage) + const cpuSeries = metricsData.cpu.find(s => s.name === 'usage'); + if (!cpuSeries) return []; - const memoryChartData = useMemo( - () => - metricsData.memory.map((item) => ({ - time: new Date(item.timestamp).toLocaleTimeString('zh-CN', { - hour: '2-digit', - minute: '2-digit', - }), - usage: Number(item.maxUsage.toFixed(2)), - timestamp: item.timestamp, - })), - [metricsData.memory] - ); + 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 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([]); @@ -587,126 +587,167 @@ const ServerDetail = () => { const networkChartData = useMemo(() => { if (metricsData.network.length === 0) return []; - // 按时间戳分组数据 - const grouped = metricsData.network.reduce((acc, item) => { - const time = new Date(item.timestamp).toLocaleTimeString('zh-CN', { + // 找到上行和下行系列 + 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', }); - - if (!acc[time]) { - acc[time] = {time, timestamp: item.timestamp}; - } - - // 后端已经根据 interface 参数返回了对应的数据 - // 使用当前选中的 interface 作为标识('all' 或具体网卡名) - const interfaceName = selectedInterface === 'all' ? 'total' : selectedInterface; - const uploadKey = `${interfaceName}_upload`; - const downloadKey = `${interfaceName}_download`; - // 转换为 MB/s - acc[time][uploadKey] = Number((item.maxSentRate / 1024 / 1024).toFixed(2)); - acc[time][downloadKey] = Number((item.maxRecvRate / 1024 / 1024).toFixed(2)); - - return acc; - }, {} as Record); - - return Object.values(grouped); - }, [metricsData.network, selectedInterface]); - - // Disk I/O 图表数据(汇总所有磁盘) - const diskIOChartData = useMemo(() => { - const aggregated: Record = {}; - - metricsData.diskIO.forEach((item) => { - const time = new Date(item.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 }); - - if (!aggregated[time]) { - aggregated[time] = {time, read: 0, write: 0, timestamp: item.timestamp}; - } - - // 转换为 MB/s - aggregated[time].read += item.maxReadRate / 1024 / 1024; - aggregated[time].write += item.maxWriteRate / 1024 / 1024; }); - return Object.values(aggregated).map((item) => ({ - ...item, - read: Number(item.read.toFixed(2)), - write: Number(item.write.toFixed(2)), - })); + 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 图表数据(汇总所有GPU的平均利用率) + // GPU 图表数据(暂不支持,需要后端返回温度系列) const gpuChartData = useMemo(() => { - const aggregated: Record = {}; + if (metricsData.gpu.length === 0) return []; - metricsData.gpu.forEach((item) => { - const time = new Date(item.timestamp).toLocaleTimeString('zh-CN', { + // 按时间戳聚合利用率和温度系列 + 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', }); - - if (!aggregated[time]) { - aggregated[time] = {time, utilization: 0, temperature: 0, count: 0, timestamp: item.timestamp}; - } - - aggregated[time].utilization += item.maxUtilization; - aggregated[time].temperature += item.maxTemperature; - aggregated[time].count += 1; + timeMap.set(point.timestamp, { + time, + timestamp: point.timestamp, + utilization: Number(point.value.toFixed(2)), + }); }); - return Object.values(aggregated).map((item) => ({ - time: item.time, - utilization: Number((item.utilization / item.count).toFixed(2)), - temperature: Number((item.temperature / item.count).toFixed(2)), - timestamp: item.timestamp, - })); + // 添加温度数据 + 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(() => { - const aggregated: Record = {}; + if (metricsData.temperature.length === 0) return []; - metricsData.temperature.forEach((item) => { - const time = new Date(item.timestamp).toLocaleTimeString('zh-CN', { - hour: '2-digit', - minute: '2-digit', + // 按时间戳聚合所有温度系列 + 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)); }); - - if (!aggregated[time]) { - aggregated[time] = {time, timestamp: item.timestamp}; - } - - // 使用 sensorLabel 作为数据键(类型名称,如 CPU、GPU 等) - const label = item.sensorLabel || 'Unknown'; - // 对于同一时间点的同一类型,取最大温度 - if (!aggregated[time][label] || item.maxTemperature > aggregated[time][label]) { - aggregated[time][label] = Number(item.maxTemperature.toFixed(2)); - } }); - return Object.values(aggregated); + return Array.from(timeMap.values()); }, [metricsData.temperature]); // 提取所有唯一的温度类型(用于图表 Line 渲染和下拉选择器) const temperatureTypes = useMemo(() => { - const types = new Set(); - metricsData.temperature.forEach((item) => { - const label = item.sensorLabel || 'Unknown'; - types.add(label); - }); - return Array.from(types).sort(); + 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') { @@ -1189,30 +1230,18 @@ const ServerDetail = () => { - {(() => { - const interfaceName = selectedInterface === 'all' ? 'total' : selectedInterface; - const colorConfig = INTERFACE_COLORS[0]; - const uploadKey = `${interfaceName}_upload`; - const downloadKey = `${interfaceName}_download`; - return ( - <> - - - - - - - - - - ); - })()} + + + + + + + + @@ -1230,38 +1259,25 @@ const ServerDetail = () => { /> }/> - {/* 渲染当前选中网卡的上行和下行区域 */} - {(() => { - // 根据 selectedInterface 确定显示的网卡名称和数据 key - const interfaceName = selectedInterface === 'all' ? 'total' : selectedInterface; - const colorConfig = INTERFACE_COLORS[0]; // 使用第一组颜色 - const uploadKey = `${interfaceName}_upload`; - const downloadKey = `${interfaceName}_download`; - const displayName = selectedInterface === 'all' ? '总计' : selectedInterface; - - return ( - <> - - - - ); - })()} + {/* 渲染上行和下行区域 */} + + ) : ( @@ -1339,19 +1355,9 @@ const ServerDetail = () => { 网络连接统计 - {metricsData.networkConnection.length > 0 ? ( + {networkConnectionChartData.length > 0 ? ( - ({ - time: new Date(item.timestamp).toLocaleTimeString('zh-CN', { - hour: '2-digit', - minute: '2-digit', - }), - established: item.maxEstablished, - timeWait: item.maxTimeWait, - closeWait: item.maxCloseWait, - listen: item.maxListen, - timestamp: item.timestamp, - }))}> + Date: Fri, 12 Dec 2025 21:48:10 +0800 Subject: [PATCH 02/18] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=B8=A9=E5=BA=A6?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E6=9F=A5=E8=AF=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/service/metric_converter.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/service/metric_converter.go b/internal/service/metric_converter.go index f9dd528..66b94df 100644 --- a/internal/service/metric_converter.go +++ b/internal/service/metric_converter.go @@ -84,8 +84,9 @@ func (s *MetricService) convertToMetrics(agentID string, metricType string, data case protocol.MetricTypeTemperature: tempDataList := data.([]protocol.TemperatureData) for _, tempData := range tempDataList { + // 只使用稳定的 sensor_label (Type) 作为标签 + // 不使用 sensor_key,因为同一类型的最大值可能来自不同传感器,会导致产生多条时间序列 labels := map[string]string{ - "sensor_key": tempData.SensorKey, "sensor_label": tempData.Type, } metrics = append(metrics, createMetric("pika_temperature_celsius", agentID, labels, tempData.Temperature, timestamp)) From e2b876f82e4e68ee75aa6fb721e3ad3c3e88a8dc Mon Sep 17 00:00:00 2001 From: dushixiang Date: Fri, 12 Dec 2025 22:36:15 +0800 Subject: [PATCH 03/18] =?UTF-8?q?=E9=87=8D=E6=9E=84=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E7=9B=91=E6=8E=A7=E6=95=B0=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/app.go | 34 +- internal/models/monitor_stats.go | 30 - internal/repo/metric_repo.go | 54 +- internal/repo/monitor_stats_repo.go | 66 --- internal/service/agent_service.go | 30 +- internal/service/alert_service.go | 18 +- internal/service/metric_service.go | 885 +++++++++++++++++++++++++++- internal/service/monitor_service.go | 545 ++++++----------- internal/wire_gen.go | 2 +- 9 files changed, 1085 insertions(+), 579 deletions(-) delete mode 100644 internal/models/monitor_stats.go delete mode 100644 internal/repo/monitor_stats_repo.go diff --git a/internal/app.go b/internal/app.go index 977ba8e..fef174f 100644 --- a/internal/app.go +++ b/internal/app.go @@ -90,9 +90,6 @@ func setup(app *orz.App) error { components.MonitorService.SetScheduler(monitorScheduler) monitorScheduler.Start(ctx) - // 启动监控统计计算任务 - go startMonitorStatsCalculation(ctx, components, app.Logger()) - // 启动流量重置检查任务(每小时检查一次) go startTrafficResetCheck(ctx, components, app.Logger()) @@ -305,7 +302,7 @@ func autoMigrate(database *gorm.DB) error { &models.AlertRecord{}, &models.AlertState{}, &models.MonitorTask{}, - &models.MonitorStats{}, + // MonitorStats 已废弃,统计数据从 VictoriaMetrics 查询 &models.TamperProtectConfig{}, &models.TamperEvent{}, &models.TamperAlert{}, @@ -420,35 +417,6 @@ func startMetricsMonitoring(ctx context.Context, components *AppComponents, logg } } -// startMonitorStatsCalculation 启动监控统计计算任务 -func startMonitorStatsCalculation(ctx context.Context, components *AppComponents, logger *zap.Logger) { - logger.Info("启动监控统计计算任务") - - ticker := time.NewTicker(5 * time.Minute) // 每5分钟计算一次统计数据 - defer ticker.Stop() - - // 首次启动时立即计算一次 - if err := components.MonitorService.CalculateMonitorStats(ctx); err != nil { - logger.Error("计算监控统计数据失败", zap.Error(err)) - } else { - logger.Info("监控统计数据计算完成") - } - - for { - select { - case <-ctx.Done(): - logger.Info("监控统计计算任务已停止") - return - case <-ticker.C: - if err := components.MonitorService.CalculateMonitorStats(ctx); err != nil { - logger.Error("计算监控统计数据失败", zap.Error(err)) - } else { - logger.Debug("监控统计数据计算完成") - } - } - } -} - // startTrafficResetCheck 启动流量重置检查定时任务 func startTrafficResetCheck(ctx context.Context, components *AppComponents, logger *zap.Logger) { logger.Info("启动流量重置检查任务") diff --git a/internal/models/monitor_stats.go b/internal/models/monitor_stats.go deleted file mode 100644 index 1e4733b..0000000 --- a/internal/models/monitor_stats.go +++ /dev/null @@ -1,30 +0,0 @@ -package models - -// MonitorStats 监控统计数据 -type MonitorStats struct { - ID string `gorm:"primaryKey" json:"id"` // ID - AgentID string `json:"agentId"` // 探针ID - AgentName string `gorm:"-" json:"agentName,omitempty"` // 探针名称(不存储在数据库,仅用于 API 返回) - MonitorId string `json:"monitorId"` // 监控项ID - MonitorName string `gorm:"-" json:"name"` // 监控项名称(不存储在数据库,仅用于 API 返回) - MonitorType string `json:"type"` // 监控类型 - Target string `json:"target"` // 目标地址 - CurrentResponse int64 `json:"currentResponse"` // 当前响应时间(ms) - AvgResponse24h int64 `json:"avgResponse24h"` // 24小时平均响应时间(ms) - Uptime24h float64 `json:"uptime24h"` // 24小时在线率(百分比) - Uptime7d float64 `json:"uptime7d"` // 7天在线率(百分比) - CertExpiryDate int64 `json:"certExpiryDate"` // 证书过期时间(毫秒时间戳),0表示无证书 - CertExpiryDays int `json:"certExpiryDays"` // 证书剩余天数 - TotalChecks24h int64 `json:"totalChecks24h"` // 24小时总检测次数 - SuccessChecks24h int64 `json:"successChecks24h"` // 24小时成功次数 - TotalChecks7d int64 `json:"totalChecks7d"` // 7天总检测次数 - SuccessChecks7d int64 `json:"successChecks7d"` // 7天成功次数 - LastCheckTime int64 `json:"lastCheckTime"` // 最后检测时间 - LastCheckStatus string `json:"lastCheckStatus"` // 最后检测状态: up/down - LastCheckError string `json:"lastCheckError"` // 最后检测错误信息 - UpdatedAt int64 `gorm:"autoUpdateTime:milli" json:"updatedAt"` // 更新时间 -} - -func (MonitorStats) TableName() string { - return "monitor_stats" -} diff --git a/internal/repo/metric_repo.go b/internal/repo/metric_repo.go index 70fdedc..13e5054 100644 --- a/internal/repo/metric_repo.go +++ b/internal/repo/metric_repo.go @@ -28,14 +28,6 @@ func (r *MetricRepo) SaveHostMetric(ctx context.Context, metric *models.HostMetr Create(metric).Error } -// GetAvailableNetworkInterfaces 获取探针的可用网卡列表 -// TODO: 需要改为从 VictoriaMetrics 查询 labels -func (r *MetricRepo) GetAvailableNetworkInterfaces(ctx context.Context, agentID string) ([]string, error) { - // 暂时返回空列表,后续改为从 VictoriaMetrics 查询 - // 使用 GET /api/v1/label/interface/values?match[]=pika_network_sent_bytes_rate{agent_id="xxx"} - return []string{}, nil -} - // DeleteAgentMetrics 删除指定探针的所有指标数据 // TODO: 需要改为调用 VictoriaMetrics 的 DELETE API func (r *MetricRepo) DeleteAgentMetrics(ctx context.Context, agentID string) error { @@ -144,50 +136,8 @@ type AggregatedMonitorMetric struct { LastErrorMsg string `json:"lastErrorMsg"` } -// GetMonitorMetrics 获取监控指标历史数据 -// TODO: 需要改为从 VictoriaMetrics 查询 -func (r *MetricRepo) GetMonitorMetrics(ctx context.Context, agentID, monitorID string, start, end int64) ([]MonitorMetric, error) { - // 暂时返回空列表,后续改为从 VictoriaMetrics 查询 - return []MonitorMetric{}, nil -} - -// GetMonitorMetricsByName 获取指定监控项的历史数据 -// TODO: 需要改为从 VictoriaMetrics 查询 -func (r *MetricRepo) GetMonitorMetricsByName(ctx context.Context, agentID, monitorID string, start, end int64, limit int) ([]MonitorMetric, error) { - // 暂时返回空列表,后续改为从 VictoriaMetrics 查询 - return []MonitorMetric{}, nil -} - -// GetLatestMonitorMetricsByType 获取指定类型的最新监控指标(所有探针) -// TODO: 需要改为从 VictoriaMetrics 查询 -func (r *MetricRepo) GetLatestMonitorMetricsByType(ctx context.Context, monitorType string) ([]*MonitorMetric, error) { - // 暂时返回空列表,后续改为从 VictoriaMetrics 查询 - // 使用 GET /api/v1/query?query=pika_monitor_status{type="xxx"} - return []*MonitorMetric{}, nil -} - -// GetAllLatestMonitorMetrics 获取所有最新的监控指标(所有探针的所有监控项) -// TODO: 需要改为从 VictoriaMetrics 查询 -func (r *MetricRepo) GetAllLatestMonitorMetrics(ctx context.Context) ([]*MonitorMetric, error) { - // 暂时返回空列表,后续改为从 VictoriaMetrics 查询 - // 使用 GET /api/v1/query?query=pika_monitor_status - return []*MonitorMetric{}, nil -} - -// DeleteMonitorMetrics 删除指定监控任务的所有指标数据 -// TODO: 需要改为调用 VictoriaMetrics 删除 API -func (r *MetricRepo) DeleteMonitorMetrics(ctx context.Context, monitorID string) error { - // 暂时不删除数据,后续调用 VictoriaMetrics 删除 API - // DELETE /api/v1/admin/tsdb/delete_series?match[]=pika_monitor_*{monitor_id="xxx"} - return nil -} - -// GetMonitorMetricsAgg 获取聚合后的监控指标 -// TODO: 需要改为从 VictoriaMetrics 查询 -func (r *MetricRepo) GetMonitorMetricsAgg(ctx context.Context, monitorID string, start, end int64, bucketSeconds int) ([]AggregatedMonitorMetric, error) { - // 暂时返回空列表,后续从 VictoriaMetrics 查询 - return []AggregatedMonitorMetric{}, nil -} +// 注意:监控相关的查询方法已迁移到 MetricService +// DeleteMonitorMetrics 方法已在 MetricService 中实现 // MonitorMetric 监控指标(与service层定义保持一致) type MonitorMetric struct { diff --git a/internal/repo/monitor_stats_repo.go b/internal/repo/monitor_stats_repo.go deleted file mode 100644 index 094b203..0000000 --- a/internal/repo/monitor_stats_repo.go +++ /dev/null @@ -1,66 +0,0 @@ -package repo - -import ( - "context" - - "github.com/dushixiang/pika/internal/models" - "github.com/go-orz/orz" - "gorm.io/gorm" -) - -type MonitorStatsRepo struct { - orz.Repository[models.MonitorStats, string] - db *gorm.DB -} - -func NewMonitorStatsRepo(db *gorm.DB) *MonitorStatsRepo { - return &MonitorStatsRepo{ - Repository: orz.NewRepository[models.MonitorStats, string](db), - db: db, - } -} - -func (r *MonitorStatsRepo) FindByMonitorId(ctx context.Context, monitorId string) ([]models.MonitorStats, error) { - var statsList []models.MonitorStats - err := r.db.WithContext(ctx). - Where("monitor_id = ?", monitorId). - Find(&statsList).Error - return statsList, err -} - -func (r *MonitorStatsRepo) FindByMonitorIdIn(ctx context.Context, monitorIds []string) ([]models.MonitorStats, error) { - var statsList []models.MonitorStats - err := r.db.WithContext(ctx). - Where("monitor_id in ?", monitorIds). - Find(&statsList).Error - return statsList, err -} - -func (r *MonitorStatsRepo) DeleteByMonitorId(ctx context.Context, monitorId string) error { - return r.db.WithContext(ctx). - Where("monitor_id = ?", monitorId). - Delete(&models.MonitorStats{}).Error -} - -func (r *MonitorStatsRepo) DeleteByAgentId(ctx context.Context, agentId string) error { - return r.db.WithContext(ctx). - Where("agent_id = ?", agentId). - Delete(&models.MonitorStats{}).Error -} - -// FindAll 查找所有统计数据 -func (r *MonitorStatsRepo) FindAll(ctx context.Context) ([]models.MonitorStats, error) { - var statsList []models.MonitorStats - err := r.db.WithContext(ctx).Find(&statsList).Error - return statsList, err -} - -// DeleteByIDs 批量删除指定ID的统计数据 -func (r *MonitorStatsRepo) DeleteByIDs(ctx context.Context, ids []string) error { - if len(ids) == 0 { - return nil - } - return r.db.WithContext(ctx). - Where("id IN ?", ids). - Delete(&models.MonitorStats{}).Error -} diff --git a/internal/service/agent_service.go b/internal/service/agent_service.go index 52c7607..1ca6625 100644 --- a/internal/service/agent_service.go +++ b/internal/service/agent_service.go @@ -19,22 +19,20 @@ import ( type AgentService struct { logger *zap.Logger *orz.Service - AgentRepo *repo.AgentRepo - monitorStatsRepo *repo.MonitorStatsRepo - apiKeyService *ApiKeyService - metricService *MetricService - geoipService *GeoIPService + AgentRepo *repo.AgentRepo + apiKeyService *ApiKeyService + metricService *MetricService + geoipService *GeoIPService } func NewAgentService(logger *zap.Logger, db *gorm.DB, apiKeyService *ApiKeyService, metricService *MetricService, geoipService *GeoIPService) *AgentService { return &AgentService{ - logger: logger, - Service: orz.NewService(db), - AgentRepo: repo.NewAgentRepo(db), - monitorStatsRepo: repo.NewMonitorStatsRepo(db), - apiKeyService: apiKeyService, - metricService: metricService, - geoipService: geoipService, + logger: logger, + Service: orz.NewService(db), + AgentRepo: repo.NewAgentRepo(db), + apiKeyService: apiKeyService, + metricService: metricService, + geoipService: geoipService, } } @@ -350,13 +348,7 @@ func (s *AgentService) DeleteAgent(ctx context.Context, agentID string) error { return err } - // 2. 删除探针的监控统计数据 - if err := s.monitorStatsRepo.DeleteByAgentId(ctx, agentID); err != nil { - s.logger.Error("删除探针监控统计数据失败", zap.String("agentId", agentID), zap.Error(err)) - return err - } - - // 3. 删除探针的审计结果 + // 2. 删除探针的审计结果 if err := s.AgentRepo.DeleteAuditResults(ctx, agentID); err != nil { s.logger.Error("删除探针审计结果失败", zap.String("agentId", agentID), zap.Error(err)) return err diff --git a/internal/service/alert_service.go b/internal/service/alert_service.go index 50a908c..2c55e5a 100644 --- a/internal/service/alert_service.go +++ b/internal/service/alert_service.go @@ -18,19 +18,19 @@ type AlertService struct { AlertRecordRepo *repo.AlertRecordRepo AlertStateRepo *repo.AlertStateRepo agentRepo *repo.AgentRepo - metricRepo *repo.MetricRepo + metricService *MetricService propertyService *PropertyService notifier *Notifier logger *zap.Logger } -func NewAlertService(logger *zap.Logger, db *gorm.DB, propertyService *PropertyService, notifier *Notifier) *AlertService { +func NewAlertService(logger *zap.Logger, db *gorm.DB, propertyService *PropertyService, metricService *MetricService, notifier *Notifier) *AlertService { return &AlertService{ Service: orz.NewService(db), AlertRecordRepo: repo.NewAlertRecordRepo(db), AlertStateRepo: repo.NewAlertStateRepo(db), agentRepo: repo.NewAgentRepo(db), - metricRepo: repo.NewMetricRepo(db), + metricService: metricService, propertyService: propertyService, notifier: notifier, logger: logger, @@ -373,7 +373,7 @@ func (s *AlertService) CheckMonitorAlerts(ctx context.Context) error { func (s *AlertService) checkCertificateAlerts(ctx context.Context, config *models.AlertConfig, now int64) error { // 获取所有最新的监控指标(仅HTTPS类型) // 这里需要查询最新的 monitor_metrics 记录,获取证书剩余天数 - monitors, err := s.metricRepo.GetLatestMonitorMetricsByType(ctx, "http") + monitors, err := s.metricService.GetLatestMonitorMetricsByType(ctx, "http") if err != nil { return err } @@ -396,10 +396,10 @@ func (s *AlertService) checkCertificateAlerts(ctx context.Context, config *model // 检查证书剩余天数是否低于阈值 if certDaysLeft <= config.Rules.CertThreshold && certDaysLeft >= 0 { // 触发告警(证书告警不需要持续时间,直接触发) - s.checkCertAlert(ctx, config, &agent, monitor, certDaysLeft, now) + s.checkCertAlert(ctx, config, &agent, &monitor, certDaysLeft, now) } else { // 恢复告警(如果之前触发过) - s.resolveCertAlert(ctx, config, &agent, monitor, certDaysLeft) + s.resolveCertAlert(ctx, config, &agent, &monitor, certDaysLeft) } } @@ -536,7 +536,7 @@ func (s *AlertService) calculateCertLevel(daysLeft float64) string { // checkServiceDownAlerts 检查服务下线告警 func (s *AlertService) checkServiceDownAlerts(ctx context.Context, config *models.AlertConfig, now int64) error { // 获取所有最新的监控指标 - monitors, err := s.metricRepo.GetAllLatestMonitorMetrics(ctx) + monitors, err := s.metricService.GetAllLatestMonitorMetrics(ctx) if err != nil { return err } @@ -591,11 +591,11 @@ func (s *AlertService) checkServiceDownAlerts(ctx context.Context, config *model } if shouldFire { - s.fireServiceDownAlert(ctx, config, &agent, monitor, state, now) + s.fireServiceDownAlert(ctx, config, &agent, &monitor, state, now) } if shouldResolve { - s.resolveServiceDownAlert(ctx, config, &agent, monitor, state) + s.resolveServiceDownAlert(ctx, config, &agent, &monitor, state) } } diff --git a/internal/service/metric_service.go b/internal/service/metric_service.go index 737bd60..864c0a8 100644 --- a/internal/service/metric_service.go +++ b/internal/service/metric_service.go @@ -46,12 +46,11 @@ type QueryDefinition struct { // MetricService 指标服务 type MetricService struct { - logger *zap.Logger - metricRepo *repo.MetricRepo - monitorStatsRepo *repo.MonitorStatsRepo - propertyService *PropertyService - trafficService *TrafficService // 流量统计服务 - vmClient *vmclient.VMClient + logger *zap.Logger + metricRepo *repo.MetricRepo + propertyService *PropertyService + trafficService *TrafficService // 流量统计服务 + vmClient *vmclient.VMClient latestCache cache.Cache[string, *LatestMetrics] } @@ -59,13 +58,12 @@ type MetricService struct { // NewMetricService 创建指标服务 func NewMetricService(logger *zap.Logger, db *gorm.DB, propertyService *PropertyService, trafficService *TrafficService, vmClient *vmclient.VMClient) *MetricService { return &MetricService{ - logger: logger, - metricRepo: repo.NewMetricRepo(db), - monitorStatsRepo: repo.NewMonitorStatsRepo(db), - propertyService: propertyService, - trafficService: trafficService, - vmClient: vmClient, - latestCache: cache.New[string, *LatestMetrics](time.Minute), + logger: logger, + metricRepo: repo.NewMetricRepo(db), + propertyService: propertyService, + trafficService: trafficService, + vmClient: vmClient, + latestCache: cache.New[string, *LatestMetrics](time.Minute), } } @@ -709,3 +707,864 @@ func (s *MetricService) convertQueryResultToSeries(result *vmclient.QueryResult, return allSeries } + +// ===== 监控查询相关 ===== + +// 监控查询类型常量 +const ( + MonitorQueryTypeCurrent = "current" // 当前状态 + MonitorQueryTypeStats24h = "stats_24h" // 24小时统计 + MonitorQueryTypeStats7d = "stats_7d" // 7天统计 + MonitorQueryTypeHistory = "history" // 历史趋势 +) + +// MonitorStatsResult 监控统计结果(所有探针的聚合数据) +type MonitorStatsResult struct { + CurrentResponse int64 // 所有探针平均响应时间(ms) + AvgResponse24h int64 // 24小时平均响应时间(ms) + Uptime24h float64 // 24小时在线率(百分比) + Uptime7d float64 // 7天在线率(百分比) + CertExpiryDate int64 // 最早过期的证书时间(毫秒时间戳) + CertExpiryDays int // 证书剩余天数 + LastCheckTime int64 // 最后检测时间(毫秒时间戳) + LastCheckStatus string // 聚合状态(up/down/unknown) + LastCheckError string // 最后检测错误信息 + AgentCount int // 探针数量 +} + +// AgentMonitorStat 单个探针的监控统计 +type AgentMonitorStat struct { + AgentID string + CurrentResponse int64 + AvgResponse24h int64 + Uptime24h float64 + Uptime7d float64 + LastCheckStatus string + LastCheckError string + LastCheckTime int64 + CertExpiryDate int64 + CertExpiryDays int + TotalChecks24h int64 + SuccessChecks24h int64 + TotalChecks7d int64 + SuccessChecks7d int64 +} + +// buildMonitorPromQLQueries 构建监控查询的 PromQL 语句 +func (s *MetricService) buildMonitorPromQLQueries(monitorID string, queryType string) []QueryDefinition { + var queries []QueryDefinition + + switch queryType { + case MonitorQueryTypeCurrent: + // 当前状态查询(即时查询) + queries = []QueryDefinition{ + {Name: "response_time", Query: fmt.Sprintf(`pika_monitor_response_time_ms{monitor_id="%s"}`, monitorID)}, + {Name: "status", Query: fmt.Sprintf(`pika_monitor_status{monitor_id="%s"}`, monitorID)}, + {Name: "cert_days", Query: fmt.Sprintf(`pika_monitor_cert_days_left{monitor_id="%s"}`, monitorID)}, + {Name: "cert_expiry", Query: fmt.Sprintf(`pika_monitor_cert_expiry_timestamp_ms{monitor_id="%s"}`, monitorID)}, + } + + case MonitorQueryTypeStats24h: + // 24小时统计查询 + queries = []QueryDefinition{ + // 24小时平均响应时间(按探针分组) + {Name: "avg_response_24h", Query: fmt.Sprintf(`avg_over_time(pika_monitor_response_time_ms{monitor_id="%s"}[24h])`, monitorID)}, + // 24小时成功次数 + {Name: "success_count_24h", Query: fmt.Sprintf(`count_over_time(pika_monitor_status{monitor_id="%s",status="up"}[24h:1m])`, monitorID)}, + // 24小时总检测次数 + {Name: "total_count_24h", Query: fmt.Sprintf(`count_over_time(pika_monitor_status{monitor_id="%s"}[24h:1m])`, monitorID)}, + } + + case MonitorQueryTypeStats7d: + // 7天统计查询 + queries = []QueryDefinition{ + // 7天成功次数 + {Name: "success_count_7d", Query: fmt.Sprintf(`count_over_time(pika_monitor_status{monitor_id="%s",status="up"}[7d:5m])`, monitorID)}, + // 7天总检测次数 + {Name: "total_count_7d", Query: fmt.Sprintf(`count_over_time(pika_monitor_status{monitor_id="%s"}[7d:5m])`, monitorID)}, + } + + case MonitorQueryTypeHistory: + // 历史趋势查询(范围查询) + queries = []QueryDefinition{ + {Name: "response_time", Query: fmt.Sprintf(`pika_monitor_response_time_ms{monitor_id="%s"}`, monitorID)}, + } + } + + return queries +} + +// GetMonitorStats 获取监控任务的聚合统计数据 +func (s *MetricService) GetMonitorStats(ctx context.Context, monitorID string) (*MonitorStatsResult, error) { + // 1. 查询当前状态 + currentQueries := s.buildMonitorPromQLQueries(monitorID, MonitorQueryTypeCurrent) + currentData := make(map[string]*vmclient.QueryResult) + for _, q := range currentQueries { + queryResult, err := s.vmClient.Query(ctx, q.Query) + if err != nil { + s.logger.Warn("查询当前状态失败", zap.String("query", q.Name), zap.Error(err)) + continue + } + currentData[q.Name] = queryResult + } + + // 2. 查询24小时统计 + stats24hQueries := s.buildMonitorPromQLQueries(monitorID, MonitorQueryTypeStats24h) + stats24hData := make(map[string]*vmclient.QueryResult) + for _, q := range stats24hQueries { + queryResult, err := s.vmClient.Query(ctx, q.Query) + if err != nil { + s.logger.Warn("查询24小时统计失败", zap.String("query", q.Name), zap.Error(err)) + continue + } + stats24hData[q.Name] = queryResult + } + + // 3. 查询7天统计 + stats7dQueries := s.buildMonitorPromQLQueries(monitorID, MonitorQueryTypeStats7d) + stats7dData := make(map[string]*vmclient.QueryResult) + for _, q := range stats7dQueries { + queryResult, err := s.vmClient.Query(ctx, q.Query) + if err != nil { + s.logger.Warn("查询7天统计失败", zap.String("query", q.Name), zap.Error(err)) + continue + } + stats7dData[q.Name] = queryResult + } + + // 4. 聚合计算 + return s.aggregateMonitorQueryResults(currentData, stats24hData, stats7dData), nil +} + +// aggregateMonitorQueryResults 聚合多个探针的查询结果 +func (s *MetricService) aggregateMonitorQueryResults( + currentData map[string]*vmclient.QueryResult, + stats24hData map[string]*vmclient.QueryResult, + stats7dData map[string]*vmclient.QueryResult, +) *MonitorStatsResult { + result := &MonitorStatsResult{ + LastCheckStatus: "unknown", + } + + // 解析当前状态数据 + agentStats := make(map[string]*AgentMonitorStat) + + // 处理响应时间 + if respResult, ok := currentData["response_time"]; ok && respResult != nil { + for _, ts := range respResult.Data.Result { + agentID := ts.Metric["agent_id"] + if agentID == "" { + continue + } + if _, exists := agentStats[agentID]; !exists { + agentStats[agentID] = &AgentMonitorStat{AgentID: agentID} + } + + // 获取最新值 + if len(ts.Values) > 0 { + lastValue := ts.Values[len(ts.Values)-1] + if len(lastValue) >= 2 { + timestamp, _ := lastValue[0].(float64) + valueStr, _ := lastValue[1].(string) + var value float64 + fmt.Sscanf(valueStr, "%f", &value) + + agentStats[agentID].CurrentResponse = int64(value) + agentStats[agentID].LastCheckTime = int64(timestamp * 1000) + } + } + } + } + + // 处理状态 + if statusResult, ok := currentData["status"]; ok && statusResult != nil { + for _, ts := range statusResult.Data.Result { + agentID := ts.Metric["agent_id"] + status := ts.Metric["status"] + if agentID == "" { + continue + } + if _, exists := agentStats[agentID]; !exists { + agentStats[agentID] = &AgentMonitorStat{AgentID: agentID} + } + + // 获取最新值 + if len(ts.Values) > 0 { + lastValue := ts.Values[len(ts.Values)-1] + if len(lastValue) >= 2 { + valueStr, _ := lastValue[1].(string) + var value float64 + fmt.Sscanf(valueStr, "%f", &value) + + if value > 0 { + agentStats[agentID].LastCheckStatus = "up" + } else { + agentStats[agentID].LastCheckStatus = "down" + } + // 状态标签中也包含了状态信息 + if status != "" { + agentStats[agentID].LastCheckStatus = status + } + } + } + } + } + + // 处理证书信息 + if certDaysResult, ok := currentData["cert_days"]; ok && certDaysResult != nil { + for _, ts := range certDaysResult.Data.Result { + agentID := ts.Metric["agent_id"] + if agentID == "" { + continue + } + if _, exists := agentStats[agentID]; !exists { + agentStats[agentID] = &AgentMonitorStat{AgentID: agentID} + } + + if len(ts.Values) > 0 { + lastValue := ts.Values[len(ts.Values)-1] + if len(lastValue) >= 2 { + valueStr, _ := lastValue[1].(string) + var value float64 + fmt.Sscanf(valueStr, "%f", &value) + agentStats[agentID].CertExpiryDays = int(value) + } + } + } + } + + if certExpiryResult, ok := currentData["cert_expiry"]; ok && certExpiryResult != nil { + for _, ts := range certExpiryResult.Data.Result { + agentID := ts.Metric["agent_id"] + if agentID == "" { + continue + } + if _, exists := agentStats[agentID]; !exists { + agentStats[agentID] = &AgentMonitorStat{AgentID: agentID} + } + + if len(ts.Values) > 0 { + lastValue := ts.Values[len(ts.Values)-1] + if len(lastValue) >= 2 { + valueStr, _ := lastValue[1].(string) + var value float64 + fmt.Sscanf(valueStr, "%f", &value) + agentStats[agentID].CertExpiryDate = int64(value) + } + } + } + } + + // 处理24小时统计 + if avgResp24h, ok := stats24hData["avg_response_24h"]; ok && avgResp24h != nil { + for _, ts := range avgResp24h.Data.Result { + agentID := ts.Metric["agent_id"] + if agentID == "" { + continue + } + if _, exists := agentStats[agentID]; !exists { + agentStats[agentID] = &AgentMonitorStat{AgentID: agentID} + } + + if len(ts.Values) > 0 { + lastValue := ts.Values[len(ts.Values)-1] + if len(lastValue) >= 2 { + valueStr, _ := lastValue[1].(string) + var value float64 + fmt.Sscanf(valueStr, "%f", &value) + agentStats[agentID].AvgResponse24h = int64(value) + } + } + } + } + + if successCount24h, ok := stats24hData["success_count_24h"]; ok && successCount24h != nil { + for _, ts := range successCount24h.Data.Result { + agentID := ts.Metric["agent_id"] + if agentID == "" { + continue + } + if _, exists := agentStats[agentID]; !exists { + agentStats[agentID] = &AgentMonitorStat{AgentID: agentID} + } + + if len(ts.Values) > 0 { + lastValue := ts.Values[len(ts.Values)-1] + if len(lastValue) >= 2 { + valueStr, _ := lastValue[1].(string) + var value float64 + fmt.Sscanf(valueStr, "%f", &value) + agentStats[agentID].SuccessChecks24h = int64(value) + } + } + } + } + + if totalCount24h, ok := stats24hData["total_count_24h"]; ok && totalCount24h != nil { + for _, ts := range totalCount24h.Data.Result { + agentID := ts.Metric["agent_id"] + if agentID == "" { + continue + } + if _, exists := agentStats[agentID]; !exists { + agentStats[agentID] = &AgentMonitorStat{AgentID: agentID} + } + + if len(ts.Values) > 0 { + lastValue := ts.Values[len(ts.Values)-1] + if len(lastValue) >= 2 { + valueStr, _ := lastValue[1].(string) + var value float64 + fmt.Sscanf(valueStr, "%f", &value) + agentStats[agentID].TotalChecks24h = int64(value) + } + } + } + } + + // 处理7天统计 + if successCount7d, ok := stats7dData["success_count_7d"]; ok && successCount7d != nil { + for _, ts := range successCount7d.Data.Result { + agentID := ts.Metric["agent_id"] + if agentID == "" { + continue + } + if _, exists := agentStats[agentID]; !exists { + agentStats[agentID] = &AgentMonitorStat{AgentID: agentID} + } + + if len(ts.Values) > 0 { + lastValue := ts.Values[len(ts.Values)-1] + if len(lastValue) >= 2 { + valueStr, _ := lastValue[1].(string) + var value float64 + fmt.Sscanf(valueStr, "%f", &value) + agentStats[agentID].SuccessChecks7d = int64(value) + } + } + } + } + + if totalCount7d, ok := stats7dData["total_count_7d"]; ok && totalCount7d != nil { + for _, ts := range totalCount7d.Data.Result { + agentID := ts.Metric["agent_id"] + if agentID == "" { + continue + } + if _, exists := agentStats[agentID]; !exists { + agentStats[agentID] = &AgentMonitorStat{AgentID: agentID} + } + + if len(ts.Values) > 0 { + lastValue := ts.Values[len(ts.Values)-1] + if len(lastValue) >= 2 { + valueStr, _ := lastValue[1].(string) + var value float64 + fmt.Sscanf(valueStr, "%f", &value) + agentStats[agentID].TotalChecks7d = int64(value) + } + } + } + } + + // 计算在线率 + for _, stat := range agentStats { + if stat.TotalChecks24h > 0 { + stat.Uptime24h = float64(stat.SuccessChecks24h) / float64(stat.TotalChecks24h) * 100 + } + if stat.TotalChecks7d > 0 { + stat.Uptime7d = float64(stat.SuccessChecks7d) / float64(stat.TotalChecks7d) * 100 + } + } + + // 聚合所有探针的数据 + if len(agentStats) == 0 { + return result + } + + var totalCurrentResponse int64 + var totalAvgResponse24h int64 + var totalUptime24h float64 + var totalUptime7d float64 + var lastCheckTime int64 + hasUp := false + hasDown := false + hasCert := false + var minCertExpiryDate int64 + var minCertExpiryDays int + + for _, stat := range agentStats { + totalCurrentResponse += stat.CurrentResponse + totalAvgResponse24h += stat.AvgResponse24h + totalUptime24h += stat.Uptime24h + totalUptime7d += stat.Uptime7d + + if stat.LastCheckTime > lastCheckTime { + lastCheckTime = stat.LastCheckTime + } + + if stat.LastCheckStatus == "up" { + hasUp = true + } else if stat.LastCheckStatus == "down" { + hasDown = true + } + + if stat.CertExpiryDate > 0 { + if !hasCert || stat.CertExpiryDate < minCertExpiryDate { + minCertExpiryDate = stat.CertExpiryDate + minCertExpiryDays = stat.CertExpiryDays + hasCert = true + } + } + } + + count := len(agentStats) + result.AgentCount = count + if count > 0 { + result.CurrentResponse = totalCurrentResponse / int64(count) + result.AvgResponse24h = totalAvgResponse24h / int64(count) + result.Uptime24h = totalUptime24h / float64(count) + result.Uptime7d = totalUptime7d / float64(count) + } + result.LastCheckTime = lastCheckTime + + // 聚合状态:只要有一个探针 up,整体就是 up + if hasUp { + result.LastCheckStatus = "up" + } else if hasDown { + result.LastCheckStatus = "down" + } + + if hasCert { + result.CertExpiryDate = minCertExpiryDate + result.CertExpiryDays = minCertExpiryDays + } + + return result +} + +// GetMonitorAgentStats 获取监控任务各探针的统计数据 +func (s *MetricService) GetMonitorAgentStats(ctx context.Context, monitorID string) ([]AgentMonitorStat, error) { + // 复用 GetMonitorStats 的逻辑,但保留每个探针的独立数据 + // 1. 查询当前状态 + currentQueries := s.buildMonitorPromQLQueries(monitorID, MonitorQueryTypeCurrent) + currentData := make(map[string]*vmclient.QueryResult) + for _, q := range currentQueries { + queryResult, err := s.vmClient.Query(ctx, q.Query) + if err != nil { + s.logger.Warn("查询当前状态失败", zap.String("query", q.Name), zap.Error(err)) + continue + } + currentData[q.Name] = queryResult + } + + // 2. 查询24小时统计 + stats24hQueries := s.buildMonitorPromQLQueries(monitorID, MonitorQueryTypeStats24h) + stats24hData := make(map[string]*vmclient.QueryResult) + for _, q := range stats24hQueries { + queryResult, err := s.vmClient.Query(ctx, q.Query) + if err != nil { + s.logger.Warn("查询24小时统计失败", zap.String("query", q.Name), zap.Error(err)) + continue + } + stats24hData[q.Name] = queryResult + } + + // 3. 查询7天统计 + stats7dQueries := s.buildMonitorPromQLQueries(monitorID, MonitorQueryTypeStats7d) + stats7dData := make(map[string]*vmclient.QueryResult) + for _, q := range stats7dQueries { + queryResult, err := s.vmClient.Query(ctx, q.Query) + if err != nil { + s.logger.Warn("查询7天统计失败", zap.String("query", q.Name), zap.Error(err)) + continue + } + stats7dData[q.Name] = queryResult + } + + // 4. 提取每个探针的数据(参考 aggregateMonitorQueryResults,但不聚合) + agentStatsMap := make(map[string]*AgentMonitorStat) + + // 处理响应时间 + if respResult, ok := currentData["response_time"]; ok && respResult != nil { + for _, ts := range respResult.Data.Result { + agentID := ts.Metric["agent_id"] + if agentID == "" { + continue + } + if _, exists := agentStatsMap[agentID]; !exists { + agentStatsMap[agentID] = &AgentMonitorStat{AgentID: agentID} + } + + if len(ts.Values) > 0 { + lastValue := ts.Values[len(ts.Values)-1] + if len(lastValue) >= 2 { + timestamp, _ := lastValue[0].(float64) + valueStr, _ := lastValue[1].(string) + var value float64 + fmt.Sscanf(valueStr, "%f", &value) + + agentStatsMap[agentID].CurrentResponse = int64(value) + agentStatsMap[agentID].LastCheckTime = int64(timestamp * 1000) + } + } + } + } + + // 处理状态 + if statusResult, ok := currentData["status"]; ok && statusResult != nil { + for _, ts := range statusResult.Data.Result { + agentID := ts.Metric["agent_id"] + status := ts.Metric["status"] + if agentID == "" { + continue + } + if _, exists := agentStatsMap[agentID]; !exists { + agentStatsMap[agentID] = &AgentMonitorStat{AgentID: agentID} + } + + if len(ts.Values) > 0 { + lastValue := ts.Values[len(ts.Values)-1] + if len(lastValue) >= 2 { + valueStr, _ := lastValue[1].(string) + var value float64 + fmt.Sscanf(valueStr, "%f", &value) + + if value > 0 { + agentStatsMap[agentID].LastCheckStatus = "up" + } else { + agentStatsMap[agentID].LastCheckStatus = "down" + } + if status != "" { + agentStatsMap[agentID].LastCheckStatus = status + } + } + } + } + } + + // 处理证书信息 + if certDaysResult, ok := currentData["cert_days"]; ok && certDaysResult != nil { + for _, ts := range certDaysResult.Data.Result { + agentID := ts.Metric["agent_id"] + if agentID == "" { + continue + } + if _, exists := agentStatsMap[agentID]; !exists { + agentStatsMap[agentID] = &AgentMonitorStat{AgentID: agentID} + } + + if len(ts.Values) > 0 { + lastValue := ts.Values[len(ts.Values)-1] + if len(lastValue) >= 2 { + valueStr, _ := lastValue[1].(string) + var value float64 + fmt.Sscanf(valueStr, "%f", &value) + agentStatsMap[agentID].CertExpiryDays = int(value) + } + } + } + } + + if certExpiryResult, ok := currentData["cert_expiry"]; ok && certExpiryResult != nil { + for _, ts := range certExpiryResult.Data.Result { + agentID := ts.Metric["agent_id"] + if agentID == "" { + continue + } + if _, exists := agentStatsMap[agentID]; !exists { + agentStatsMap[agentID] = &AgentMonitorStat{AgentID: agentID} + } + + if len(ts.Values) > 0 { + lastValue := ts.Values[len(ts.Values)-1] + if len(lastValue) >= 2 { + valueStr, _ := lastValue[1].(string) + var value float64 + fmt.Sscanf(valueStr, "%f", &value) + agentStatsMap[agentID].CertExpiryDate = int64(value) + } + } + } + } + + // 处理24小时统计 + if avgResp24h, ok := stats24hData["avg_response_24h"]; ok && avgResp24h != nil { + for _, ts := range avgResp24h.Data.Result { + agentID := ts.Metric["agent_id"] + if agentID == "" { + continue + } + if _, exists := agentStatsMap[agentID]; !exists { + agentStatsMap[agentID] = &AgentMonitorStat{AgentID: agentID} + } + + if len(ts.Values) > 0 { + lastValue := ts.Values[len(ts.Values)-1] + if len(lastValue) >= 2 { + valueStr, _ := lastValue[1].(string) + var value float64 + fmt.Sscanf(valueStr, "%f", &value) + agentStatsMap[agentID].AvgResponse24h = int64(value) + } + } + } + } + + if successCount24h, ok := stats24hData["success_count_24h"]; ok && successCount24h != nil { + for _, ts := range successCount24h.Data.Result { + agentID := ts.Metric["agent_id"] + if agentID == "" { + continue + } + if _, exists := agentStatsMap[agentID]; !exists { + agentStatsMap[agentID] = &AgentMonitorStat{AgentID: agentID} + } + + if len(ts.Values) > 0 { + lastValue := ts.Values[len(ts.Values)-1] + if len(lastValue) >= 2 { + valueStr, _ := lastValue[1].(string) + var value float64 + fmt.Sscanf(valueStr, "%f", &value) + agentStatsMap[agentID].SuccessChecks24h = int64(value) + } + } + } + } + + if totalCount24h, ok := stats24hData["total_count_24h"]; ok && totalCount24h != nil { + for _, ts := range totalCount24h.Data.Result { + agentID := ts.Metric["agent_id"] + if agentID == "" { + continue + } + if _, exists := agentStatsMap[agentID]; !exists { + agentStatsMap[agentID] = &AgentMonitorStat{AgentID: agentID} + } + + if len(ts.Values) > 0 { + lastValue := ts.Values[len(ts.Values)-1] + if len(lastValue) >= 2 { + valueStr, _ := lastValue[1].(string) + var value float64 + fmt.Sscanf(valueStr, "%f", &value) + agentStatsMap[agentID].TotalChecks24h = int64(value) + } + } + } + } + + // 处理7天统计 + if successCount7d, ok := stats7dData["success_count_7d"]; ok && successCount7d != nil { + for _, ts := range successCount7d.Data.Result { + agentID := ts.Metric["agent_id"] + if agentID == "" { + continue + } + if _, exists := agentStatsMap[agentID]; !exists { + agentStatsMap[agentID] = &AgentMonitorStat{AgentID: agentID} + } + + if len(ts.Values) > 0 { + lastValue := ts.Values[len(ts.Values)-1] + if len(lastValue) >= 2 { + valueStr, _ := lastValue[1].(string) + var value float64 + fmt.Sscanf(valueStr, "%f", &value) + agentStatsMap[agentID].SuccessChecks7d = int64(value) + } + } + } + } + + if totalCount7d, ok := stats7dData["total_count_7d"]; ok && totalCount7d != nil { + for _, ts := range totalCount7d.Data.Result { + agentID := ts.Metric["agent_id"] + if agentID == "" { + continue + } + if _, exists := agentStatsMap[agentID]; !exists { + agentStatsMap[agentID] = &AgentMonitorStat{AgentID: agentID} + } + + if len(ts.Values) > 0 { + lastValue := ts.Values[len(ts.Values)-1] + if len(lastValue) >= 2 { + valueStr, _ := lastValue[1].(string) + var value float64 + fmt.Sscanf(valueStr, "%f", &value) + agentStatsMap[agentID].TotalChecks7d = int64(value) + } + } + } + } + + // 计算在线率 + for _, stat := range agentStatsMap { + if stat.TotalChecks24h > 0 { + stat.Uptime24h = float64(stat.SuccessChecks24h) / float64(stat.TotalChecks24h) * 100 + } + if stat.TotalChecks7d > 0 { + stat.Uptime7d = float64(stat.SuccessChecks7d) / float64(stat.TotalChecks7d) * 100 + } + } + + // 转换为数组 + result := make([]AgentMonitorStat, 0, len(agentStatsMap)) + for _, stat := range agentStatsMap { + result = append(result, *stat) + } + + return result, nil +} + +// GetMonitorHistory 获取监控任务的历史趋势数据 +func (s *MetricService) GetMonitorHistory(ctx context.Context, monitorID string, start, end int64) (*GetMetricsResponse, error) { + queries := s.buildMonitorPromQLQueries(monitorID, MonitorQueryTypeHistory) + + var series []MetricSeries + for _, q := range queries { + result, err := s.vmClient.QueryRange( + ctx, + q.Query, + time.UnixMilli(start), + time.UnixMilli(end), + 0, // 自动步长 + ) + if err != nil { + s.logger.Warn("查询历史趋势失败", zap.String("query", q.Name), zap.Error(err)) + continue + } + convertedSeries := s.convertQueryResultToSeries(result, q.Name, q.Labels) + series = append(series, convertedSeries...) + } + + return &GetMetricsResponse{ + AgentID: "", // 监控查询不限定单个agent + Type: "monitor", + Range: fmt.Sprintf("%d-%d", start, end), + Series: series, + }, nil +} + +// GetLatestMonitorMetricsByType 获取指定类型的最新监控指标(用于告警检查) +func (s *MetricService) GetLatestMonitorMetricsByType(ctx context.Context, monitorType string) ([]repo.MonitorMetric, error) { + // 查询最新的监控状态(按 monitor_type 过滤) + query := fmt.Sprintf(`pika_monitor_status{monitor_type="%s"}`, monitorType) + result, err := s.vmClient.Query(ctx, query) + if err != nil { + return nil, fmt.Errorf("query latest monitor metrics failed: %w", err) + } + + metrics := make([]repo.MonitorMetric, 0) + for _, ts := range result.Data.Result { + metric := s.convertToMonitorMetric(ts) + metrics = append(metrics, metric) + } + + return metrics, nil +} + +// GetAllLatestMonitorMetrics 获取所有最新监控指标(用于告警检查) +func (s *MetricService) GetAllLatestMonitorMetrics(ctx context.Context) ([]repo.MonitorMetric, error) { + // 查询所有最新的监控状态 + query := `pika_monitor_status` + result, err := s.vmClient.Query(ctx, query) + if err != nil { + return nil, fmt.Errorf("query all latest monitor metrics failed: %w", err) + } + + metrics := make([]repo.MonitorMetric, 0) + for _, ts := range result.Data.Result { + metric := s.convertToMonitorMetric(ts) + metrics = append(metrics, metric) + } + + return metrics, nil +} + +// convertToMonitorMetric 将 VictoriaMetrics 查询结果转换为 MonitorMetric +func (s *MetricService) convertToMonitorMetric(ts vmclient.Result) repo.MonitorMetric { + metric := repo.MonitorMetric{ + AgentId: ts.Metric["agent_id"], + MonitorId: ts.Metric["monitor_id"], + Type: ts.Metric["monitor_type"], + Target: ts.Metric["target"], + Status: ts.Metric["status"], + } + + // 获取最新值(timestamp 和 status value) + if len(ts.Values) > 0 { + lastValue := ts.Values[len(ts.Values)-1] + if len(lastValue) >= 2 { + timestamp, _ := lastValue[0].(float64) + metric.Timestamp = int64(timestamp * 1000) // 转换为毫秒 + } + } + + // 查询其他相关指标(响应时间、状态码、证书信息等) + // 这里需要额外的查询来填充完整的 MonitorMetric 数据 + ctx := context.Background() + + // 查询响应时间 + respQuery := fmt.Sprintf(`pika_monitor_response_time_ms{agent_id="%s",monitor_id="%s"}`, metric.AgentId, metric.MonitorId) + if respResult, err := s.vmClient.Query(ctx, respQuery); err == nil && len(respResult.Data.Result) > 0 { + ts := respResult.Data.Result[0] + if len(ts.Values) > 0 { + lastValue := ts.Values[len(ts.Values)-1] + if len(lastValue) >= 2 { + valueStr, _ := lastValue[1].(string) + var value float64 + fmt.Sscanf(valueStr, "%f", &value) + metric.ResponseTime = int64(value) + } + } + } + + // 查询状态码 + statusCodeQuery := fmt.Sprintf(`pika_monitor_status_code{agent_id="%s",monitor_id="%s"}`, metric.AgentId, metric.MonitorId) + if statusCodeResult, err := s.vmClient.Query(ctx, statusCodeQuery); err == nil && len(statusCodeResult.Data.Result) > 0 { + ts := statusCodeResult.Data.Result[0] + if len(ts.Values) > 0 { + lastValue := ts.Values[len(ts.Values)-1] + if len(lastValue) >= 2 { + valueStr, _ := lastValue[1].(string) + var value float64 + fmt.Sscanf(valueStr, "%f", &value) + metric.StatusCode = int(value) + } + } + } + + // 查询证书信息 + certExpiryQuery := fmt.Sprintf(`pika_monitor_cert_expiry_timestamp_ms{agent_id="%s",monitor_id="%s"}`, metric.AgentId, metric.MonitorId) + if certResult, err := s.vmClient.Query(ctx, certExpiryQuery); err == nil && len(certResult.Data.Result) > 0 { + ts := certResult.Data.Result[0] + if len(ts.Values) > 0 { + lastValue := ts.Values[len(ts.Values)-1] + if len(lastValue) >= 2 { + valueStr, _ := lastValue[1].(string) + var value float64 + fmt.Sscanf(valueStr, "%f", &value) + metric.CertExpiryTime = int64(value) + } + } + } + + certDaysQuery := fmt.Sprintf(`pika_monitor_cert_days_left{agent_id="%s",monitor_id="%s"}`, metric.AgentId, metric.MonitorId) + if certDaysResult, err := s.vmClient.Query(ctx, certDaysQuery); err == nil && len(certDaysResult.Data.Result) > 0 { + ts := certDaysResult.Data.Result[0] + if len(ts.Values) > 0 { + lastValue := ts.Values[len(ts.Values)-1] + if len(lastValue) >= 2 { + valueStr, _ := lastValue[1].(string) + var value float64 + fmt.Sscanf(valueStr, "%f", &value) + metric.CertDaysLeft = int(value) + } + } + } + + return metric +} diff --git a/internal/service/monitor_service.go b/internal/service/monitor_service.go index af18dff..e9564c8 100644 --- a/internal/service/monitor_service.go +++ b/internal/service/monitor_service.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "sort" "strings" "time" @@ -13,7 +14,6 @@ import ( ws "github.com/dushixiang/pika/internal/websocket" "github.com/go-orz/cache" "github.com/go-orz/orz" - "github.com/go-orz/toolkit" "github.com/google/uuid" "go.uber.org/zap" "gorm.io/datatypes" @@ -24,16 +24,13 @@ type MonitorService struct { logger *zap.Logger *repo.MonitorRepo *orz.Service - agentRepo *repo.AgentRepo - metricRepo *repo.MetricRepo - metricService *MetricService - monitorStatsRepo *repo.MonitorStatsRepo - wsManager *ws.Manager + agentRepo *repo.AgentRepo + metricRepo *repo.MetricRepo + metricService *MetricService + wsManager *ws.Manager // 监控概览缓存:缓存监控任务列表(使用不同的 key 区分 public 和 private) overviewCache cache.Cache[string, []PublicMonitorOverview] - // 监控统计缓存:缓存单个监控任务的统计数据 - statsCache cache.Cache[string, []models.MonitorStats] // 调度器引用(用于动态管理任务) scheduler MonitorScheduler @@ -48,18 +45,16 @@ type MonitorScheduler interface { func NewMonitorService(logger *zap.Logger, db *gorm.DB, metricService *MetricService, wsManager *ws.Manager) *MonitorService { return &MonitorService{ - logger: logger, - Service: orz.NewService(db), - MonitorRepo: repo.NewMonitorRepo(db), - agentRepo: repo.NewAgentRepo(db), - metricRepo: repo.NewMetricRepo(db), - metricService: metricService, - monitorStatsRepo: repo.NewMonitorStatsRepo(db), - wsManager: wsManager, + logger: logger, + Service: orz.NewService(db), + MonitorRepo: repo.NewMonitorRepo(db), + agentRepo: repo.NewAgentRepo(db), + metricRepo: repo.NewMetricRepo(db), + metricService: metricService, + wsManager: wsManager, // 缓存 5 分钟,避免频繁查询 overviewCache: cache.New[string, []PublicMonitorOverview](5 * time.Minute), - statsCache: cache.New[string, []models.MonitorStats](5 * time.Minute), } } @@ -229,12 +224,6 @@ func (s *MonitorService) DeleteMonitor(ctx context.Context, id string) error { return err } - // 删除监控统计数据 - if err := s.monitorStatsRepo.DeleteByMonitorId(ctx, id); err != nil { - s.logger.Error("删除监控统计数据失败", zap.String("monitorId", id), zap.Error(err)) - return err - } - // 删除监控指标数据(从 VictoriaMetrics) if err := s.metricService.DeleteMonitorMetrics(ctx, id); err != nil { s.logger.Error("删除监控指标数据失败", zap.String("monitorId", id), zap.Error(err)) @@ -286,29 +275,32 @@ func (s *MonitorService) ListByAuth(ctx context.Context, isAuthenticated bool) ( return emptyResult, nil } - // 提取监控任务ID列表 - monitorIds := make([]string, 0, len(monitors)) - for _, monitor := range monitors { - monitorIds = append(monitorIds, monitor.ID) - } - - // 批量获取统计数据(已经由后台任务预计算好) - statsList, err := s.monitorStatsRepo.FindByMonitorIdIn(ctx, monitorIds) - if err != nil { - return nil, err - } - - // 按监控任务ID分组统计数据 - statsMap := make(map[string][]models.MonitorStats, len(monitors)) - for _, stats := range statsList { - statsMap[stats.MonitorId] = append(statsMap[stats.MonitorId], stats) - } - // 构建监控概览列表 items := make([]PublicMonitorOverview, 0, len(monitors)) for _, monitor := range monitors { - // 直接聚合预计算的统计数据 - summary := aggregateMonitorStats(statsMap[monitor.ID]) + // 从 VictoriaMetrics 查询统计数据 + stats, err := s.metricService.GetMonitorStats(ctx, monitor.ID) + if err != nil { + // 查询失败时使用默认值 + s.logger.Error("查询 VictoriaMetrics 失败", + zap.String("monitorID", monitor.ID), + zap.Error(err)) + stats = &MonitorStatsResult{} + } + + // 将 MonitorStatsResult 转换为 monitorOverviewSummary + summary := monitorOverviewSummary{ + AgentCount: stats.AgentCount, + CurrentResponse: stats.CurrentResponse, + AvgResponse24h: stats.AvgResponse24h, + Uptime24h: stats.Uptime24h, + Uptime7d: stats.Uptime7d, + LastCheckStatus: stats.LastCheckStatus, + LastCheckError: stats.LastCheckError, + LastCheckTime: stats.LastCheckTime, + CertExpiryDate: stats.CertExpiryDate, + CertExpiryDays: stats.CertExpiryDays, + } // 构建监控概览对象 item := s.buildMonitorOverview(monitor, summary) @@ -365,88 +357,6 @@ type monitorOverviewSummary struct { LastCheckTime int64 } -func aggregateMonitorStats(stats []models.MonitorStats) monitorOverviewSummary { - summary := monitorOverviewSummary{ - LastCheckStatus: "unknown", - } - - if len(stats) == 0 { - return summary - } - - var totalCurrentResponse int64 - var totalAvgResponse24h int64 - var totalUptime24h float64 - var totalUptime7d float64 - var lastCheckTime int64 - var lastCheckError string - var certExpiryDate int64 - var certExpiryDays int - hasCert := false - hasUp := false - hasDown := false - - for _, stat := range stats { - totalCurrentResponse += stat.CurrentResponse - totalAvgResponse24h += stat.AvgResponse24h - totalUptime24h += stat.Uptime24h - totalUptime7d += stat.Uptime7d - - // 记录最新的检测时间和对应的错误信息 - if stat.LastCheckTime > lastCheckTime { - lastCheckTime = stat.LastCheckTime - // 如果这是最新的检测且状态为 down,记录错误信息 - if stat.LastCheckStatus == "down" { - lastCheckError = stat.LastCheckError - } else { - lastCheckError = "" - } - } - - switch stat.LastCheckStatus { - case "up": - hasUp = true - case "down": - hasDown = true - } - - if stat.CertExpiryDate > 0 { - if !hasCert || stat.CertExpiryDate < certExpiryDate { - certExpiryDate = stat.CertExpiryDate - certExpiryDays = stat.CertExpiryDays - hasCert = true - } - } - } - - count := len(stats) - summary.AgentCount = count - if count > 0 { - summary.CurrentResponse = totalCurrentResponse / int64(count) - summary.AvgResponse24h = totalAvgResponse24h / int64(count) - summary.Uptime24h = totalUptime24h / float64(count) - summary.Uptime7d = totalUptime7d / float64(count) - } - summary.LastCheckTime = lastCheckTime - summary.LastCheckError = lastCheckError - - switch { - case hasUp: - summary.LastCheckStatus = "up" - case hasDown: - summary.LastCheckStatus = "down" - default: - summary.LastCheckStatus = "unknown" - } - - if hasCert { - summary.CertExpiryDate = certExpiryDate - summary.CertExpiryDays = certExpiryDays - } - - return summary -} - func cloneAgentIDs(ids datatypes.JSONSlice[string]) []string { if len(ids) == 0 { return []string{} @@ -596,140 +506,6 @@ func (s *MonitorService) SendMonitorTaskToAgents(ctx context.Context, monitor mo return nil } -// CalculateMonitorStats 计算监控统计数据 -func (s *MonitorService) CalculateMonitorStats(ctx context.Context) error { - now := time.Now() - - // 获取所有启用的监控任务 - monitors, err := s.MonitorRepo.FindByEnabled(ctx, true) - if err != nil { - return err - } - - // 获取所有探针(包括在线和离线) - agents, err := s.agentRepo.FindAll(ctx) - if err != nil { - return err - } - - // 收集所有有效的监控任务ID - validStatsIDs := make(map[string]bool) - - // 为每个监控任务的每个探针计算统计数据 - for _, monitor := range monitors { - // 使用统一的方法计算目标探针 - targetAgents := s.resolveTargetAgents(monitor, agents) - - for _, agent := range targetAgents { - stats, err := s.calculateStatsForAgentMonitor(ctx, agent.ID, monitor.ID, monitor.Type, monitor.Target, now) - if err != nil { - s.logger.Error("计算监控统计失败", - zap.String("agentID", agent.ID), - zap.String("monitorName", monitor.Name), - zap.Error(err)) - continue - } - - // 记录有效的监控任务ID - validStatsIDs[stats.ID] = true - - if err := s.monitorStatsRepo.Save(ctx, stats); err != nil { - s.logger.Error("保存监控统计失败", - zap.String("agentID", agent.ID), - zap.String("monitorName", monitor.Name), - zap.Error(err)) - } - } - } - - // 清理无效监控任务的统计数据 - if err := s.cleanupInvalidStats(ctx, validStatsIDs); err != nil { - s.logger.Error("清理无效统计数据失败", zap.Error(err)) - // 不返回错误,继续运行 - } - - return nil -} - -// calculateStatsForAgentMonitor 计算单个探针单个监控任务的统计数据 -func (s *MonitorService) calculateStatsForAgentMonitor(ctx context.Context, agentID, monitorId, monitorType, target string, now time.Time) (*models.MonitorStats, error) { - stats := &models.MonitorStats{ - ID: toolkit.Sign("monitor_stats", agentID, monitorId, monitorType, target), - AgentID: agentID, - MonitorId: monitorId, - MonitorType: monitorType, - Target: target, - } - - // 计算24小时数据 - start24h := now.Add(-24 * time.Hour).UnixMilli() - end := now.UnixMilli() - metrics24h, err := s.metricRepo.GetMonitorMetrics(ctx, agentID, monitorId, start24h, end) - if err != nil { - return nil, err - } - - // 计算7天数据 - start7d := now.Add(-7 * 24 * time.Hour).UnixMilli() - metrics7d, err := s.metricRepo.GetMonitorMetrics(ctx, agentID, monitorId, start7d, end) - if err != nil { - return nil, err - } - - // 计算24小时统计 - if len(metrics24h) > 0 { - var totalResponse int64 - var successCount int64 - lastMetric := metrics24h[len(metrics24h)-1] - - for _, metric := range metrics24h { - if metric.Status == "up" { - successCount++ - totalResponse += metric.ResponseTime - } - } - - stats.TotalChecks24h = int64(len(metrics24h)) - stats.SuccessChecks24h = successCount - if successCount > 0 { - stats.AvgResponse24h = totalResponse / successCount - } - if stats.TotalChecks24h > 0 { - stats.Uptime24h = float64(successCount) / float64(stats.TotalChecks24h) * 100 - } - - // 最后一次检测数据 - stats.CurrentResponse = lastMetric.ResponseTime - stats.LastCheckTime = lastMetric.Timestamp - stats.LastCheckStatus = lastMetric.Status - stats.LastCheckError = lastMetric.Error - - // 从最新的检测结果中获取证书信息 - if lastMetric.CertExpiryTime > 0 { - stats.CertExpiryDate = lastMetric.CertExpiryTime - stats.CertExpiryDays = lastMetric.CertDaysLeft - } - } - - // 计算7天统计 - if len(metrics7d) > 0 { - var successCount int64 - for _, metric := range metrics7d { - if metric.Status == "up" { - successCount++ - } - } - - stats.TotalChecks7d = int64(len(metrics7d)) - stats.SuccessChecks7d = successCount - if stats.TotalChecks7d > 0 { - stats.Uptime7d = float64(successCount) / float64(stats.TotalChecks7d) * 100 - } - } - - return stats, nil -} - // GetMonitorStatsByID 获取监控任务的统计数据(聚合后的单个监控详情) func (s *MonitorService) GetMonitorStatsByID(ctx context.Context, monitorID string) (*PublicMonitorOverview, error) { // 查询监控任务 @@ -738,14 +514,27 @@ func (s *MonitorService) GetMonitorStatsByID(ctx context.Context, monitorID stri return nil, err } - // 获取统计数据 - statsList, err := s.monitorStatsRepo.FindByMonitorId(ctx, monitor.ID) + // 从 VictoriaMetrics 查询统计数据 + stats, err := s.metricService.GetMonitorStats(ctx, monitorID) if err != nil { - return nil, err + s.logger.Error("查询 VictoriaMetrics 失败", zap.String("monitorID", monitorID), zap.Error(err)) + // 失败时返回默认值,不中断 + stats = &MonitorStatsResult{} } - // 聚合统计数据 - summary := aggregateMonitorStats(statsList) + // 转换为 monitorOverviewSummary 格式 + summary := monitorOverviewSummary{ + AgentCount: stats.AgentCount, + LastCheckStatus: stats.LastCheckStatus, + LastCheckError: stats.LastCheckError, + CurrentResponse: stats.CurrentResponse, + AvgResponse24h: stats.AvgResponse24h, + Uptime24h: stats.Uptime24h, + Uptime7d: stats.Uptime7d, + CertExpiryDate: stats.CertExpiryDate, + CertExpiryDays: stats.CertExpiryDays, + LastCheckTime: stats.LastCheckTime, + } // 构建监控概览对象 overview := s.buildMonitorOverview(monitor, summary) @@ -753,121 +542,198 @@ func (s *MonitorService) GetMonitorStatsByID(ctx context.Context, monitorID stri return &overview, nil } +type MonitorStats struct { + ID string `gorm:"primaryKey" json:"id"` // ID + AgentID string `json:"agentId"` // 探针ID + AgentName string `gorm:"-" json:"agentName,omitempty"` // 探针名称(不存储在数据库,仅用于 API 返回) + MonitorId string `json:"monitorId"` // 监控项ID + MonitorName string `gorm:"-" json:"name"` // 监控项名称(不存储在数据库,仅用于 API 返回) + MonitorType string `json:"type"` // 监控类型 + Target string `json:"target"` // 目标地址 + CurrentResponse int64 `json:"currentResponse"` // 当前响应时间(ms) + AvgResponse24h int64 `json:"avgResponse24h"` // 24小时平均响应时间(ms) + Uptime24h float64 `json:"uptime24h"` // 24小时在线率(百分比) + Uptime7d float64 `json:"uptime7d"` // 7天在线率(百分比) + CertExpiryDate int64 `json:"certExpiryDate"` // 证书过期时间(毫秒时间戳),0表示无证书 + CertExpiryDays int `json:"certExpiryDays"` // 证书剩余天数 + TotalChecks24h int64 `json:"totalChecks24h"` // 24小时总检测次数 + SuccessChecks24h int64 `json:"successChecks24h"` // 24小时成功次数 + TotalChecks7d int64 `json:"totalChecks7d"` // 7天总检测次数 + SuccessChecks7d int64 `json:"successChecks7d"` // 7天成功次数 + LastCheckTime int64 `json:"lastCheckTime"` // 最后检测时间 + LastCheckStatus string `json:"lastCheckStatus"` // 最后检测状态: up/down + LastCheckError string `json:"lastCheckError"` // 最后检测错误信息 + UpdatedAt int64 `gorm:"autoUpdateTime:milli" json:"updatedAt"` // 更新时间 +} + // GetMonitorAgentStats 获取监控任务各探针的统计数据(详细列表) -func (s *MonitorService) GetMonitorAgentStats(ctx context.Context, monitorID string) ([]models.MonitorStats, error) { - // 构建缓存键 - cacheKey := fmt.Sprintf("agents:%s", monitorID) - - // 尝试从缓存获取 - if cachedResult, ok := s.statsCache.Get(cacheKey); ok { - return cachedResult, nil - } - - // 缓存未命中,查询数据库 +func (s *MonitorService) GetMonitorAgentStats(ctx context.Context, monitorID string) ([]MonitorStats, error) { + // 查询监控任务 monitor, err := s.MonitorRepo.FindById(ctx, monitorID) if err != nil { return nil, err } - statsList, err := s.monitorStatsRepo.FindByMonitorId(ctx, monitor.ID) + // 从 VictoriaMetrics 查询各探针统计 + agentStats, err := s.metricService.GetMonitorAgentStats(ctx, monitorID) if err != nil { + s.logger.Error("查询 VictoriaMetrics 失败", zap.String("monitorID", monitorID), zap.Error(err)) return nil, err } - // 获取探针列表 - agents, err := s.agentRepo.FindAll(ctx) - if err != nil { - return nil, err - } - // 当前监控任务的关联的探针 - targetAgents := s.resolveTargetAgents(monitor, agents) - var targetAgentsMap = make(map[string]string) - for _, agent := range targetAgents { - targetAgentsMap[agent.ID] = agent.Name + // 填充探针名称、监控名称 + agentMap := make(map[string]*models.Agent) + if len(agentStats) > 0 { + agentIDs := make([]string, 0, len(agentStats)) + for _, stat := range agentStats { + agentIDs = append(agentIDs, stat.AgentID) + } + agents, _ := s.agentRepo.FindByIdIn(ctx, agentIDs) + for _, agent := range agents { + agentMap[agent.ID] = &agent + } } - // 填充监控名称、探针名称和隐私设置 - var filteredStatsList []models.MonitorStats - for _, stats := range statsList { - agentName, ok := targetAgentsMap[stats.AgentID] - if !ok { - continue + // 确定目标 target 值(是否隐藏) + target := monitor.Target + if !monitor.ShowTargetPublic { + target = "******" + } + + // 转换为 MonitorStats 格式 + result := make([]MonitorStats, 0, len(agentStats)) + for _, stat := range agentStats { + ms := MonitorStats{ + AgentID: stat.AgentID, + AgentName: "", + MonitorId: monitorID, + MonitorName: monitor.Name, + MonitorType: monitor.Type, + Target: target, + CurrentResponse: stat.CurrentResponse, + AvgResponse24h: stat.AvgResponse24h, + Uptime24h: stat.Uptime24h, + Uptime7d: stat.Uptime7d, + CertExpiryDate: stat.CertExpiryDate, + CertExpiryDays: stat.CertExpiryDays, + TotalChecks24h: stat.TotalChecks24h, + SuccessChecks24h: stat.SuccessChecks24h, + TotalChecks7d: stat.TotalChecks7d, + SuccessChecks7d: stat.SuccessChecks7d, + LastCheckTime: stat.LastCheckTime, + LastCheckStatus: stat.LastCheckStatus, + LastCheckError: stat.LastCheckError, } - stats.MonitorName = monitor.Name - - // 根据 ShowTargetPublic 字段决定是否隐藏 Target - if !monitor.ShowTargetPublic { - stats.Target = "******" + if agent, ok := agentMap[stat.AgentID]; ok { + ms.AgentName = agent.Name } - stats.AgentName = agentName - filteredStatsList = append(filteredStatsList, stats) + result = append(result, ms) } - // 缓存结果 - s.statsCache.Set(cacheKey, filteredStatsList, 5*time.Minute) - - return filteredStatsList, nil + return result, nil } // GetMonitorHistory 获取监控任务的历史响应时间数据 // 根据前端时间范围选择,直接使用聚合表数据 // 支持时间范围:15m, 30m, 1h, 3h, 6h, 12h, 1d, 3d, 7d func (s *MonitorService) GetMonitorHistory(ctx context.Context, monitorID, timeRange string) ([]repo.AggregatedMonitorMetric, error) { - monitor, err := s.MonitorRepo.FindById(ctx, monitorID) - if err != nil { + // 验证监控任务存在 + if _, err := s.MonitorRepo.FindById(ctx, monitorID); err != nil { return nil, err } - // 根据前端时间范围,选择最优的聚合粒度 + // 计算时间范围 var duration time.Duration - var bucketSeconds int // 使用的聚合 bucket(秒) - switch timeRange { case "15m": duration = 15 * time.Minute - bucketSeconds = 60 // 1分钟聚合,约15个点 case "30m": duration = 30 * time.Minute - bucketSeconds = 60 // 1分钟聚合,约30个点 case "1h": duration = 1 * time.Hour - bucketSeconds = 300 // 5分钟聚合,约12个点 case "3h": duration = 3 * time.Hour - bucketSeconds = 300 // 5分钟聚合,约36个点 case "6h": duration = 6 * time.Hour - bucketSeconds = 900 // 15分钟聚合,约24个点 case "12h": duration = 12 * time.Hour - bucketSeconds = 900 // 15分钟聚合,约48个点 case "1d", "24h": duration = 24 * time.Hour - bucketSeconds = 1800 // 30分钟聚合,约48个点 case "3d": duration = 3 * 24 * time.Hour - bucketSeconds = 3600 // 1小时聚合,约72个点 case "7d": duration = 7 * 24 * time.Hour - bucketSeconds = 7200 // 2小时聚合,约84个点 default: - // 默认 15 分钟 duration = 15 * time.Minute - bucketSeconds = 60 } now := time.Now() end := now.UnixMilli() start := now.Add(-duration).UnixMilli() - // 对齐时间范围到 bucket 边界 - bucketMs := int64(bucketSeconds * 1000) - start, end = alignTimeRangeToBucket(start, end, bucketMs) + // 从 VictoriaMetrics 查询历史数据 + metricsResp, err := s.metricService.GetMonitorHistory(ctx, monitorID, start, end) + if err != nil { + s.logger.Error("查询 VictoriaMetrics 失败", zap.String("monitorID", monitorID), zap.Error(err)) + return []repo.AggregatedMonitorMetric{}, nil + } - // 直接使用聚合表查询 - return s.metricRepo.GetMonitorMetricsAgg(ctx, monitor.ID, start, end, bucketSeconds) + // 转换为 AggregatedMonitorMetric 格式 + result := make([]repo.AggregatedMonitorMetric, 0) + + // 按时间戳分组,聚合多个探针的数据 + timestampMap := make(map[int64]map[string]float64) // timestamp -> agentID -> avgResponse + + for _, series := range metricsResp.Series { + // 从 series.Labels 中提取 agent_id + agentID := "" + if series.Labels != nil { + agentID = series.Labels["agent_id"] + } + if agentID == "" && len(metricsResp.Series) > 0 { + // 如果没有 agent_id 标签,尝试从 series name 中提取 + // 这是 response_time 系列,每个探针一条时间序列 + // series.Name 可能包含探针信息 + agentID = series.Name + } + + for _, dataPoint := range series.Data { + if timestampMap[dataPoint.Timestamp] == nil { + timestampMap[dataPoint.Timestamp] = make(map[string]float64) + } + timestampMap[dataPoint.Timestamp][agentID] = dataPoint.Value + } + } + + // 将 map 转换为数组,并计算每个时间点的聚合数据 + for timestamp, agentData := range timestampMap { + var totalResponse float64 + var count int + for _, avgResp := range agentData { + totalResponse += avgResp + count++ + } + + var avgResponse float64 + if count > 0 { + avgResponse = totalResponse / float64(count) + } + + result = append(result, repo.AggregatedMonitorMetric{ + Timestamp: timestamp, + AgentID: "", // 聚合数据不指定单个 agentID + AvgResponse: avgResponse, + }) + } + + // 按时间戳排序 + sort.Slice(result, func(i, j int) bool { + return result[i].Timestamp < result[j].Timestamp + }) + + return result, nil } // GetMonitorByAuth 根据认证状态获取监控任务(已登录返回全部,未登录返回公开可见) @@ -897,37 +763,4 @@ func (s *MonitorService) clearCache(monitorID string) { // 清理概览缓存 s.overviewCache.Delete("overview:public") s.overviewCache.Delete("overview:private") - - // 清理统计缓存 - s.statsCache.Delete(fmt.Sprintf("agents:%s", monitorID)) -} - -// cleanupInvalidStats 按监控任务维度清理无效的统计数据 -// 删除不在有效监控任务列表中的所有统计数据(监控任务被禁用或删除) -func (s *MonitorService) cleanupInvalidStats(ctx context.Context, validStatsIDs map[string]bool) error { - // 获取所有现有的统计数据 - allStats, err := s.monitorStatsRepo.FindAll(ctx) - if err != nil { - return err - } - - // 收集需要删除的统计数据ID - idsToDelete := make([]string, 0) - for _, stats := range allStats { - // 如果该统计数据对应的监控任务不在有效列表中,删除该统计数据 - if !validStatsIDs[stats.ID] { - idsToDelete = append(idsToDelete, stats.ID) - } - } - - // 批量删除无效的统计数据 - if len(idsToDelete) > 0 { - s.logger.Info("清理无效的监控统计数据", - zap.Int("count", len(idsToDelete))) - if err := s.monitorStatsRepo.DeleteByIDs(ctx, idsToDelete); err != nil { - return err - } - } - - return nil } diff --git a/internal/wire_gen.go b/internal/wire_gen.go index 81fbacc..9089732 100644 --- a/internal/wire_gen.go +++ b/internal/wire_gen.go @@ -47,7 +47,7 @@ func InitializeApp(logger *zap.Logger, db *gorm.DB, cfg *config.AppConfig) (*App agentHandler := handler.NewAgentHandler(logger, agentService, metricService, monitorService, tamperService, ddnsService, manager) apiKeyHandler := handler.NewApiKeyHandler(logger, apiKeyService) notifier := service.NewNotifier(logger) - alertService := service.NewAlertService(logger, db, propertyService, notifier) + alertService := service.NewAlertService(logger, db, propertyService, metricService, notifier) alertHandler := handler.NewAlertHandler(logger, alertService) propertyHandler := handler.NewPropertyHandler(logger, propertyService, notifier) monitorHandler := handler.NewMonitorHandler(logger, monitorService, agentService) From f95b75b8320c20c5424a530ff0099158c79d76fc Mon Sep 17 00:00:00 2001 From: dushixiang Date: Fri, 12 Dec 2025 23:24:43 +0800 Subject: [PATCH 04/18] =?UTF-8?q?=E9=87=8D=E6=9E=84=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E7=9B=91=E6=8E=A7=E6=95=B0=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/app.go | 28 ++-- internal/handler/agent_handler.go | 63 +++------ internal/protocol/message.go | 19 ++- internal/service/ddns_service.go | 13 +- internal/service/metric_service.go | 37 +++-- internal/service/monitor_service.go | 182 ++----------------------- internal/service/tamper_service.go | 13 +- internal/websocket/manager.go | 2 +- pkg/agent/collector/manager.go | 41 ++---- pkg/agent/service/agent.go | 100 ++++---------- web/src/api/monitor.ts | 59 +++++--- web/src/pages/Public/MonitorDetail.tsx | 100 +++++++------- 12 files changed, 205 insertions(+), 452 deletions(-) diff --git a/internal/app.go b/internal/app.go index fef174f..b0b16d0 100644 --- a/internal/app.go +++ b/internal/app.go @@ -294,21 +294,19 @@ func setupApi(app *orz.App, components *AppComponents) { func autoMigrate(database *gorm.DB) error { // 自动迁移数据库表 return database.AutoMigrate( - &models.Agent{}, - &models.ApiKey{}, - &models.HostMetric{}, // 保留主机静态信息表 - &models.AuditResult{}, - &models.Property{}, - &models.AlertRecord{}, - &models.AlertState{}, - &models.MonitorTask{}, - // MonitorStats 已废弃,统计数据从 VictoriaMetrics 查询 - &models.TamperProtectConfig{}, - &models.TamperEvent{}, - &models.TamperAlert{}, - &models.DDNSConfig{}, - &models.DDNSRecord{}, - // 指标数据已迁移到 VictoriaMetrics + &models.Agent{}, // 探针 + &models.ApiKey{}, // ApiKey + &models.HostMetric{}, // 保留主机静态信息表 + &models.AuditResult{}, // 审计历史 + &models.Property{}, // 系统属性 + &models.AlertRecord{}, // 告警记录 + &models.AlertState{}, // 告警状态 + &models.MonitorTask{}, // 服务监控 + &models.TamperProtectConfig{}, // 防篡改配置 + &models.TamperEvent{}, // 防篡改事件 + &models.TamperAlert{}, // 防篡改告警 + &models.DDNSConfig{}, // DDNS 配置 + &models.DDNSRecord{}, // DDNS 记录 ) } diff --git a/internal/handler/agent_handler.go b/internal/handler/agent_handler.go index 1587976..d36eebb 100644 --- a/internal/handler/agent_handler.go +++ b/internal/handler/agent_handler.go @@ -80,7 +80,7 @@ func (h *AgentHandler) HandleWebSocket(c echo.Context) error { conn.SetReadDeadline(time.Time{}) // 解析注册消息 - var msg protocol.Message + var msg protocol.InputMessage if err := json.Unmarshal(message, &msg); err != nil { h.logger.Error("failed to parse register message", zap.Error(err)) conn.Close() @@ -154,11 +154,15 @@ func (h *AgentHandler) handleWebSocketMessage(ctx context.Context, agentID strin case protocol.MessageTypeMetrics: // 指标数据 - var metricsWrapper protocol.MetricsWrapper + var metricsWrapper protocol.MetricsPayload if err := json.Unmarshal(data, &metricsWrapper); err != nil { return err } - return h.metricService.HandleMetricData(ctx, agentID, string(metricsWrapper.Type), metricsWrapper.Data) + metricsData, err := json.Marshal(metricsWrapper.Data) + if err != nil { + return err + } + return h.metricService.HandleMetricData(ctx, agentID, string(metricsWrapper.Type), json.RawMessage(metricsData)) case protocol.MessageTypeCommandResp: // 指令响应 @@ -230,21 +234,10 @@ func (h *AgentHandler) sendRegisterSuccess(conn *websocket.Conn, agentID string) AgentID: agentID, Status: "success", } - respData, err := json.Marshal(resp) - if err != nil { - return err - } - - msg := protocol.Message{ + return conn.WriteJSON(protocol.OutboundMessage{ Type: protocol.MessageTypeRegisterAck, - Data: respData, - } - msgData, err := json.Marshal(msg) - if err != nil { - return err - } - - return conn.WriteMessage(websocket.TextMessage, msgData) + Data: resp, + }) } // sendRegisterError 发送注册失败响应 @@ -253,15 +246,11 @@ func (h *AgentHandler) sendRegisterError(conn *websocket.Conn, errMsg string) er Status: "error", Message: errMsg, } - respData, _ := json.Marshal(resp) - msg := protocol.Message{ + return conn.WriteJSON(protocol.OutboundMessage{ Type: protocol.MessageTypeRegisterErr, - Data: respData, - } - msgData, _ := json.Marshal(msg) - - return conn.WriteMessage(websocket.TextMessage, msgData) + Data: resp, + }) } // sendTamperConfig 发送防篡改配置(探针初始化时发送完整配置作为新增) @@ -286,17 +275,10 @@ func (h *AgentHandler) sendTamperConfig(conn *websocket.Conn, agentID string) er Removed: []string{}, // 初始化时没有需要移除的 } - data, err := json.Marshal(configData) - if err != nil { - return err - } - - msg := protocol.Message{ + msgData, err := json.Marshal(protocol.OutboundMessage{ Type: protocol.MessageTypeTamperProtect, - Data: data, - } - - msgData, err := json.Marshal(msg) + Data: configData, + }) if err != nil { return err } @@ -595,17 +577,10 @@ func (h *AgentHandler) SendCommand(c echo.Context) error { Type: cmdType, } - reqData, err := json.Marshal(cmdReq) - if err != nil { - return err - } - - msg := protocol.Message{ + msgData, err := json.Marshal(protocol.OutboundMessage{ Type: protocol.MessageTypeCommand, - Data: reqData, - } - - msgData, err := json.Marshal(msg) + Data: cmdReq, + }) if err != nil { return err } diff --git a/internal/protocol/message.go b/internal/protocol/message.go index 84eb530..0cd6e3d 100644 --- a/internal/protocol/message.go +++ b/internal/protocol/message.go @@ -2,12 +2,18 @@ package protocol import "encoding/json" -// Message WebSocket消息结构 -type Message struct { +// InputMessage WebSocket消息结构(主要用于接收) +type InputMessage struct { Type MessageType `json:"type"` Data json.RawMessage `json:"data"` } +// OutboundMessage WebSocket 出站消息结构 +type OutboundMessage struct { + Type MessageType `json:"type"` + Data interface{} `json:"data"` +} + // RegisterRequest 注册请求 type RegisterRequest struct { AgentInfo AgentInfo `json:"agentInfo"` @@ -31,12 +37,11 @@ type AgentInfo struct { Version string `json:"version"` // 版本号 } -// MetricsWrapper 指标数据包装 -type MetricsWrapper struct { - Type MetricType `json:"type"` - Data json.RawMessage `json:"data"` +// MetricsPayload 指标数据包装,发送端/接收端统一使用 +type MetricsPayload struct { + Type MetricType `json:"type"` + Data interface{} `json:"data"` } - type MessageType string // 控制消息 diff --git a/internal/service/ddns_service.go b/internal/service/ddns_service.go index e67c557..b813c70 100644 --- a/internal/service/ddns_service.go +++ b/internal/service/ddns_service.go @@ -402,17 +402,10 @@ func (s *DDNSService) sendDDNSConfigToAgent(config *models.DDNSConfig) error { return err } - data, err := json.Marshal(configData) - if err != nil { - return err - } - - msg := protocol.Message{ + msgData, err := json.Marshal(protocol.OutboundMessage{ Type: protocol.MessageTypeDDNSConfig, - Data: data, - } - - msgData, err := json.Marshal(msg) + Data: configData, + }) if err != nil { return err } diff --git a/internal/service/metric_service.go b/internal/service/metric_service.go index 864c0a8..8034839 100644 --- a/internal/service/metric_service.go +++ b/internal/service/metric_service.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "strconv" "time" "github.com/dushixiang/pika/internal/models" @@ -666,9 +667,7 @@ func (s *MetricService) convertQueryResultToSeries(result *vmclient.QueryResult, continue } - var value float64 - fmt.Sscanf(valueStr, "%f", &value) - + value, _ := strconv.ParseFloat(valueStr, 64) dataPoints = append(dataPoints, MetricDataPoint{ Timestamp: int64(timestamp * 1000), // 转换为毫秒 Value: value, @@ -678,8 +677,8 @@ func (s *MetricService) convertQueryResultToSeries(result *vmclient.QueryResult, // 合并标签 labels := make(map[string]string) for k, v := range timeSeries.Metric { - // 排除内部标签 - if k != "__name__" && k != "agent_id" { + // 只排除 __name__ 内部标签,保留 agent_id(监控功能需要用它来区分探针) + if k != "__name__" { labels[k] = v } } @@ -734,20 +733,20 @@ type MonitorStatsResult struct { // AgentMonitorStat 单个探针的监控统计 type AgentMonitorStat struct { - AgentID string - CurrentResponse int64 - AvgResponse24h int64 - Uptime24h float64 - Uptime7d float64 - LastCheckStatus string - LastCheckError string - LastCheckTime int64 - CertExpiryDate int64 - CertExpiryDays int - TotalChecks24h int64 - SuccessChecks24h int64 - TotalChecks7d int64 - SuccessChecks7d int64 + AgentID string `json:"agentID,omitempty"` + CurrentResponse int64 `json:"currentResponse,omitempty"` + AvgResponse24h int64 `json:"avgResponse24H,omitempty"` + Uptime24h float64 `json:"uptime24H,omitempty"` + Uptime7d float64 `json:"uptime7D,omitempty"` + LastCheckStatus string `json:"lastCheckStatus,omitempty"` + LastCheckError string `json:"lastCheckError,omitempty"` + LastCheckTime int64 `json:"lastCheckTime,omitempty"` + CertExpiryDate int64 `json:"certExpiryDate,omitempty"` + CertExpiryDays int `json:"certExpiryDays,omitempty"` + TotalChecks24h int64 `json:"totalChecks24H,omitempty"` + SuccessChecks24h int64 `json:"successChecks24H,omitempty"` + TotalChecks7d int64 `json:"totalChecks7D,omitempty"` + SuccessChecks7d int64 `json:"successChecks7D,omitempty"` } // buildMonitorPromQLQueries 构建监控查询的 PromQL 语句 diff --git a/internal/service/monitor_service.go b/internal/service/monitor_service.go index e9564c8..02ebe3c 100644 --- a/internal/service/monitor_service.go +++ b/internal/service/monitor_service.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "sort" "strings" "time" @@ -420,17 +419,10 @@ func (s *MonitorService) resolveTargetAgents(monitor models.MonitorTask, availab // sendMonitorConfigToAgent 向指定探针发送监控配置(内部方法) func (s *MonitorService) sendMonitorConfigToAgent(agentID string, payload protocol.MonitorConfigPayload) error { - payloadData, err := json.Marshal(payload) - if err != nil { - return err - } - - msg := protocol.Message{ + msgData, err := json.Marshal(protocol.OutboundMessage{ Type: protocol.MessageTypeMonitorConfig, - Data: payloadData, - } - - msgData, err := json.Marshal(msg) + Data: payload, + }) if err != nil { return err } @@ -542,108 +534,17 @@ func (s *MonitorService) GetMonitorStatsByID(ctx context.Context, monitorID stri return &overview, nil } -type MonitorStats struct { - ID string `gorm:"primaryKey" json:"id"` // ID - AgentID string `json:"agentId"` // 探针ID - AgentName string `gorm:"-" json:"agentName,omitempty"` // 探针名称(不存储在数据库,仅用于 API 返回) - MonitorId string `json:"monitorId"` // 监控项ID - MonitorName string `gorm:"-" json:"name"` // 监控项名称(不存储在数据库,仅用于 API 返回) - MonitorType string `json:"type"` // 监控类型 - Target string `json:"target"` // 目标地址 - CurrentResponse int64 `json:"currentResponse"` // 当前响应时间(ms) - AvgResponse24h int64 `json:"avgResponse24h"` // 24小时平均响应时间(ms) - Uptime24h float64 `json:"uptime24h"` // 24小时在线率(百分比) - Uptime7d float64 `json:"uptime7d"` // 7天在线率(百分比) - CertExpiryDate int64 `json:"certExpiryDate"` // 证书过期时间(毫秒时间戳),0表示无证书 - CertExpiryDays int `json:"certExpiryDays"` // 证书剩余天数 - TotalChecks24h int64 `json:"totalChecks24h"` // 24小时总检测次数 - SuccessChecks24h int64 `json:"successChecks24h"` // 24小时成功次数 - TotalChecks7d int64 `json:"totalChecks7d"` // 7天总检测次数 - SuccessChecks7d int64 `json:"successChecks7d"` // 7天成功次数 - LastCheckTime int64 `json:"lastCheckTime"` // 最后检测时间 - LastCheckStatus string `json:"lastCheckStatus"` // 最后检测状态: up/down - LastCheckError string `json:"lastCheckError"` // 最后检测错误信息 - UpdatedAt int64 `gorm:"autoUpdateTime:milli" json:"updatedAt"` // 更新时间 -} - // GetMonitorAgentStats 获取监控任务各探针的统计数据(详细列表) -func (s *MonitorService) GetMonitorAgentStats(ctx context.Context, monitorID string) ([]MonitorStats, error) { - // 查询监控任务 - monitor, err := s.MonitorRepo.FindById(ctx, monitorID) - if err != nil { - return nil, err - } - - // 从 VictoriaMetrics 查询各探针统计 - agentStats, err := s.metricService.GetMonitorAgentStats(ctx, monitorID) - if err != nil { - s.logger.Error("查询 VictoriaMetrics 失败", zap.String("monitorID", monitorID), zap.Error(err)) - return nil, err - } - - // 填充探针名称、监控名称 - agentMap := make(map[string]*models.Agent) - if len(agentStats) > 0 { - agentIDs := make([]string, 0, len(agentStats)) - for _, stat := range agentStats { - agentIDs = append(agentIDs, stat.AgentID) - } - agents, _ := s.agentRepo.FindByIdIn(ctx, agentIDs) - for _, agent := range agents { - agentMap[agent.ID] = &agent - } - } - - // 确定目标 target 值(是否隐藏) - target := monitor.Target - if !monitor.ShowTargetPublic { - target = "******" - } - - // 转换为 MonitorStats 格式 - result := make([]MonitorStats, 0, len(agentStats)) - for _, stat := range agentStats { - ms := MonitorStats{ - AgentID: stat.AgentID, - AgentName: "", - MonitorId: monitorID, - MonitorName: monitor.Name, - MonitorType: monitor.Type, - Target: target, - CurrentResponse: stat.CurrentResponse, - AvgResponse24h: stat.AvgResponse24h, - Uptime24h: stat.Uptime24h, - Uptime7d: stat.Uptime7d, - CertExpiryDate: stat.CertExpiryDate, - CertExpiryDays: stat.CertExpiryDays, - TotalChecks24h: stat.TotalChecks24h, - SuccessChecks24h: stat.SuccessChecks24h, - TotalChecks7d: stat.TotalChecks7d, - SuccessChecks7d: stat.SuccessChecks7d, - LastCheckTime: stat.LastCheckTime, - LastCheckStatus: stat.LastCheckStatus, - LastCheckError: stat.LastCheckError, - } - - if agent, ok := agentMap[stat.AgentID]; ok { - ms.AgentName = agent.Name - } - - result = append(result, ms) - } - - return result, nil +// 直接返回 VictoriaMetrics 查询结果,无需额外转换 +func (s *MonitorService) GetMonitorAgentStats(ctx context.Context, monitorID string) ([]AgentMonitorStat, error) { + // 直接返回 VictoriaMetrics 查询结果 + return s.metricService.GetMonitorAgentStats(ctx, monitorID) } -// GetMonitorHistory 获取监控任务的历史响应时间数据 -// 根据前端时间范围选择,直接使用聚合表数据 +// GetMonitorHistory 获取监控任务的历史时序数据 +// 直接返回 VictoriaMetrics 的原始时序数据,包含所有探针的独立序列 // 支持时间范围:15m, 30m, 1h, 3h, 6h, 12h, 1d, 3d, 7d -func (s *MonitorService) GetMonitorHistory(ctx context.Context, monitorID, timeRange string) ([]repo.AggregatedMonitorMetric, error) { - // 验证监控任务存在 - if _, err := s.MonitorRepo.FindById(ctx, monitorID); err != nil { - return nil, err - } - +func (s *MonitorService) GetMonitorHistory(ctx context.Context, monitorID, timeRange string) (*GetMetricsResponse, error) { // 计算时间范围 var duration time.Duration switch timeRange { @@ -673,67 +574,8 @@ func (s *MonitorService) GetMonitorHistory(ctx context.Context, monitorID, timeR end := now.UnixMilli() start := now.Add(-duration).UnixMilli() - // 从 VictoriaMetrics 查询历史数据 - metricsResp, err := s.metricService.GetMonitorHistory(ctx, monitorID, start, end) - if err != nil { - s.logger.Error("查询 VictoriaMetrics 失败", zap.String("monitorID", monitorID), zap.Error(err)) - return []repo.AggregatedMonitorMetric{}, nil - } - - // 转换为 AggregatedMonitorMetric 格式 - result := make([]repo.AggregatedMonitorMetric, 0) - - // 按时间戳分组,聚合多个探针的数据 - timestampMap := make(map[int64]map[string]float64) // timestamp -> agentID -> avgResponse - - for _, series := range metricsResp.Series { - // 从 series.Labels 中提取 agent_id - agentID := "" - if series.Labels != nil { - agentID = series.Labels["agent_id"] - } - if agentID == "" && len(metricsResp.Series) > 0 { - // 如果没有 agent_id 标签,尝试从 series name 中提取 - // 这是 response_time 系列,每个探针一条时间序列 - // series.Name 可能包含探针信息 - agentID = series.Name - } - - for _, dataPoint := range series.Data { - if timestampMap[dataPoint.Timestamp] == nil { - timestampMap[dataPoint.Timestamp] = make(map[string]float64) - } - timestampMap[dataPoint.Timestamp][agentID] = dataPoint.Value - } - } - - // 将 map 转换为数组,并计算每个时间点的聚合数据 - for timestamp, agentData := range timestampMap { - var totalResponse float64 - var count int - for _, avgResp := range agentData { - totalResponse += avgResp - count++ - } - - var avgResponse float64 - if count > 0 { - avgResponse = totalResponse / float64(count) - } - - result = append(result, repo.AggregatedMonitorMetric{ - Timestamp: timestamp, - AgentID: "", // 聚合数据不指定单个 agentID - AvgResponse: avgResponse, - }) - } - - // 按时间戳排序 - sort.Slice(result, func(i, j int) bool { - return result[i].Timestamp < result[j].Timestamp - }) - - return result, nil + // 直接返回 VictoriaMetrics 查询结果,无需任何转换 + return s.metricService.GetMonitorHistory(ctx, monitorID, start, end) } // GetMonitorByAuth 根据认证状态获取监控任务(已登录返回全部,未登录返回公开可见) diff --git a/internal/service/tamper_service.go b/internal/service/tamper_service.go index 05a325e..134868b 100644 --- a/internal/service/tamper_service.go +++ b/internal/service/tamper_service.go @@ -142,17 +142,10 @@ func (s *TamperService) sendConfigToAgent(agentID string, added, removed []strin Removed: removed, } - data, err := json.Marshal(configData) - if err != nil { - return err - } - - msg := protocol.Message{ + msgBytes, err := json.Marshal(protocol.OutboundMessage{ Type: protocol.MessageTypeTamperProtect, - Data: data, - } - - msgBytes, err := json.Marshal(msg) + Data: configData, + }) if err != nil { return err } diff --git a/internal/websocket/manager.go b/internal/websocket/manager.go index 3c4afc2..246966a 100644 --- a/internal/websocket/manager.go +++ b/internal/websocket/manager.go @@ -221,7 +221,7 @@ func (c *Client) ReadPump(ctx context.Context) { c.LastActive = time.Now() // 解析消息 - var msg protocol.Message + var msg protocol.InputMessage if err := json.Unmarshal(message, &msg); err != nil { c.Manager.logger.Error("failed to parse message", zap.Error(err), zap.String("agentID", c.ID)) continue diff --git a/pkg/agent/collector/manager.go b/pkg/agent/collector/manager.go index 6022b97..cd04adf 100644 --- a/pkg/agent/collector/manager.go +++ b/pkg/agent/collector/manager.go @@ -1,8 +1,6 @@ package collector import ( - "encoding/json" - "github.com/dushixiang/pika/internal/protocol" "github.com/dushixiang/pika/pkg/agent/config" ) @@ -168,42 +166,21 @@ func (m *Manager) CollectAndSendDDNSIP(conn WebSocketWriter) error { return nil } - dataBytes, err := json.Marshal(ipData) - if err != nil { - return err - } - - msg := protocol.Message{ + return conn.WriteJSON(protocol.OutboundMessage{ Type: protocol.MessageTypeDDNSIPReport, - Data: dataBytes, - } - - return conn.WriteJSON(msg) + Data: ipData, + }) } // sendMetrics 发送指标数据 func (m *Manager) sendMetrics(conn WebSocketWriter, metricType protocol.MetricType, data interface{}) error { - dataBytes, err := json.Marshal(data) - if err != nil { - return err - } - - metrics := protocol.MetricsWrapper{ - Type: metricType, - Data: json.RawMessage(dataBytes), - } - - metricsData, err := json.Marshal(metrics) - if err != nil { - return err - } - - msg := protocol.Message{ + return conn.WriteJSON(protocol.OutboundMessage{ Type: protocol.MessageTypeMetrics, - Data: metricsData, - } - - return conn.WriteJSON(msg) + Data: protocol.MetricsPayload{ + Type: metricType, + Data: data, + }, + }) } // GetPublicIP 通过 API 获取公网 IP 地址 diff --git a/pkg/agent/service/agent.go b/pkg/agent/service/agent.go index 6f4c186..4cfa179 100644 --- a/pkg/agent/service/agent.go +++ b/pkg/agent/service/agent.go @@ -258,7 +258,7 @@ func (a *Agent) readLoop(conn *websocket.Conn, done chan struct{}) error { } // 解析消息 - var msg protocol.Message + var msg protocol.InputMessage if err := json.Unmarshal(message, &msg); err != nil { log.Printf("⚠️ 解析消息失败: %v", err) continue @@ -313,22 +313,15 @@ func (a *Agent) registerAgent(conn *safeConn) error { ApiKey: a.cfg.Server.APIKey, } - reqData, err := json.Marshal(registerReq) - if err != nil { - return fmt.Errorf("序列化注册请求失败: %w", err) - } - - msg := protocol.Message{ + if err := conn.WriteJSON(protocol.OutboundMessage{ Type: protocol.MessageTypeRegister, - Data: reqData, - } - - if err := conn.WriteJSON(msg); err != nil { + Data: registerReq, + }); err != nil { return fmt.Errorf("发送注册消息失败: %w", err) } // 读取注册响应 - var response protocol.Message + var response protocol.InputMessage if err := conn.ReadJSON(&response); err != nil { return fmt.Errorf("读取注册响应失败: %w", err) } @@ -392,11 +385,10 @@ func (a *Agent) heartbeatLoop(ctx context.Context, conn *safeConn, done chan str for { select { case <-ticker.C: - msg := protocol.Message{ + if err := conn.WriteJSON(protocol.OutboundMessage{ Type: protocol.MessageTypeHeartbeat, - Data: json.RawMessage(`{}`), - } - if err := conn.WriteJSON(msg); err != nil { + Data: struct{}{}, + }); err != nil { return fmt.Errorf("发送心跳失败: %w", err) } //log.Println("💓 心跳已发送") @@ -581,24 +573,10 @@ func (a *Agent) sendCommandResponse(conn *safeConn, cmdID, cmdType, status, errM Result: result, } - respData, err := json.Marshal(resp) - if err != nil { - log.Printf("⚠️ 序列化指令响应失败: %v", err) - return - } - - msg := protocol.Message{ + if err := conn.WriteJSON(protocol.OutboundMessage{ Type: protocol.MessageTypeCommandResp, - Data: respData, - } - - msgData, err := json.Marshal(msg) - if err != nil { - log.Printf("⚠️ 序列化消息失败: %v", err) - return - } - - if err := conn.WriteMessage(websocket.TextMessage, msgData); err != nil { + Data: resp, + }); err != nil { log.Printf("⚠️ 发送指令响应失败: %v", err) } } @@ -670,18 +648,10 @@ func (a *Agent) sendTamperProtectResponse(success bool, message string, paths [] Error: errMsg, } - respData, err := json.Marshal(resp) - if err != nil { - log.Printf("⚠️ 序列化防篡改保护响应失败: %v", err) - return - } - - msg := protocol.Message{ + if err := conn.WriteJSON(protocol.OutboundMessage{ Type: protocol.MessageTypeTamperProtect, - Data: respData, - } - - if err := conn.WriteJSON(msg); err != nil { + Data: resp, + }); err != nil { log.Printf("⚠️ 发送防篡改保护响应失败: %v", err) } } @@ -705,18 +675,10 @@ func (a *Agent) tamperEventLoop(ctx context.Context, conn *safeConn, done chan s Details: event.Details, } - data, err := json.Marshal(eventData) - if err != nil { - log.Printf("⚠️ 序列化防篡改事件失败: %v", err) - continue - } - - msg := protocol.Message{ + if err := conn.WriteJSON(protocol.OutboundMessage{ Type: protocol.MessageTypeTamperEvent, - Data: data, - } - - if err := conn.WriteJSON(msg); err != nil { + Data: eventData, + }); err != nil { log.Printf("⚠️ 发送防篡改事件失败: %v", err) } else { log.Printf("📤 已上报防篡改事件: %s - %s", event.Path, event.Operation) @@ -744,18 +706,10 @@ func (a *Agent) tamperAlertLoop(ctx context.Context, conn *safeConn, done chan s Restored: alert.Restored, } - data, err := json.Marshal(alertData) - if err != nil { - log.Printf("⚠️ 序列化属性篡改告警失败: %v", err) - continue - } - - msg := protocol.Message{ + if err := conn.WriteJSON(protocol.OutboundMessage{ Type: protocol.MessageTypeTamperAlert, - Data: data, - } - - if err := conn.WriteJSON(msg); err != nil { + Data: alertData, + }); err != nil { log.Printf("⚠️ 发送属性篡改告警失败: %v", err) } else { status := "未恢复" @@ -829,18 +783,10 @@ func (a *Agent) collectAndSendDDNSIP(conn *safeConn, manager *collector.Manager, return fmt.Errorf("未获取到任何 IP 地址") } - // 序列化并发送 - data, err := json.Marshal(ipReport) - if err != nil { - return fmt.Errorf("序列化 IP 报告失败: %w", err) - } - - msg := protocol.Message{ + if err := conn.WriteJSON(protocol.OutboundMessage{ Type: protocol.MessageTypeDDNSIPReport, - Data: data, - } - - if err := conn.WriteJSON(msg); err != nil { + Data: ipReport, + }); err != nil { return fmt.Errorf("发送 IP 报告失败: %w", err) } diff --git a/web/src/api/monitor.ts b/web/src/api/monitor.ts index f73700c..e4e6f34 100644 --- a/web/src/api/monitor.ts +++ b/web/src/api/monitor.ts @@ -39,26 +39,51 @@ export const getMonitorStatsById = (id: string) => { return get(`/monitors/${encodeURIComponent(id)}/stats`); }; -// 公开接口 - 获取指定监控各探针的统计数据(详细列表) +// 公开接口 - 获取指定监控各探针的统计数据(直接从 VictoriaMetrics 查询) export const getMonitorAgentStats = (id: string) => { - return get(`/monitors/${encodeURIComponent(id)}/agents`); + return get(`/monitors/${encodeURIComponent(id)}/agents`); }; -// 聚合的监控历史数据 -export interface AggregatedMonitorMetric { - timestamp: number; - agentId: string; - avgResponse: number; - maxResponse: number; - minResponse: number; - successCount: number; - totalCount: number; - successRate: number; - lastStatus: string; - lastErrorMsg: string; +// VictoriaMetrics 时序数据点 +export interface MetricDataPoint { + timestamp: number; // 毫秒时间戳 + value: number; } -// 公开接口 - 获取指定监控的历史数据 -export const getMonitorHistory = (id: string, range: string = '5m') => { - return get(`/monitors/${encodeURIComponent(id)}/history?range=${range}`); +// VictoriaMetrics 时序数据系列 +export interface MetricSeries { + name: string; // 系列名称(如 "response_time") + labels?: Record; // 标签(如 { agent_id: "xxx", monitor_id: "yyy" }) + data: MetricDataPoint[]; // 数据点数组 +} + +// VictoriaMetrics 查询响应(直接返回时序数据) +export interface GetMetricsResponse { + agentId: string; // 为空表示多探针 + type: string; // "monitor" + range: string; // 时间范围描述 + series: MetricSeries[]; // 时序数据系列(每个探针一个系列) +} + +// 探针监控统计(直接从 VictoriaMetrics 查询) +export interface AgentMonitorStat { + agentID: string; + currentResponse: number; + avgResponse24h: number; + uptime24h: number; + uptime7d: number; + lastCheckStatus: string; + lastCheckError: string; + lastCheckTime: number; + certExpiryDate: number; + certExpiryDays: number; + totalChecks24h: number; + successChecks24h: number; + totalChecks7d: number; + successChecks7d: number; +} + +// 公开接口 - 获取指定监控的历史数据(VictoriaMetrics 原始时序数据) +export const getMonitorHistory = (id: string, range: string = '15m') => { + return get(`/monitors/${encodeURIComponent(id)}/history?range=${range}`); }; diff --git a/web/src/pages/Public/MonitorDetail.tsx b/web/src/pages/Public/MonitorDetail.tsx index 221f242..75b9338 100644 --- a/web/src/pages/Public/MonitorDetail.tsx +++ b/web/src/pages/Public/MonitorDetail.tsx @@ -16,12 +16,13 @@ import { import type {TooltipProps} from 'recharts'; import {Area, AreaChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis,} from 'recharts'; import { - type AggregatedMonitorMetric, + type AgentMonitorStat, + type GetMetricsResponse, getMonitorAgentStats, getMonitorHistory, getMonitorStatsById } from '@/api/monitor.ts'; -import type {MonitorStats, PublicMonitor} from '@/types'; +import type {PublicMonitor} from '@/types'; import {cn} from '@/lib/utils'; const formatTime = (ms: number): string => { @@ -357,7 +358,7 @@ const MonitorDetail = () => { }); // 获取各探针的统计数据 - const {data: monitorStats = []} = useQuery({ + const {data: monitorStats = []} = useQuery({ queryKey: ['monitorAgentStats', id], queryFn: async () => { if (!id) return []; @@ -369,12 +370,12 @@ const MonitorDetail = () => { }); // 获取历史数据 - const {data: historyData = []} = useQuery({ + const {data: historyData} = useQuery({ queryKey: ['monitorHistory', id, timeRange], queryFn: async () => { - if (!id) return []; + if (!id) throw new Error('Monitor ID is required'); const response = await getMonitorHistory(id, timeRange); - return response.data || []; + return response.data; }, refetchInterval: 30000, enabled: !!id, @@ -384,8 +385,8 @@ const MonitorDetail = () => { const availableAgents = useMemo(() => { if (monitorStats.length === 0) return []; return monitorStats.map(stat => ({ - id: stat.agentId, - label: stat.agentName || stat.agentId.substring(0, 8), + id: stat.agentID, + label: stat.agentID.substring(0, 8), })); }, [monitorStats]); @@ -401,30 +402,42 @@ const MonitorDetail = () => { // 生成图表数据 const chartData = useMemo(() => { - if (historyData.length === 0) return []; + 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 = historyData.reduce((acc, item) => { - const time = new Date(item.timestamp).toLocaleTimeString('zh-CN', { - hour: '2-digit', - minute: '2-digit', - second: '2-digit', + 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', + second: '2-digit', + }), + timestamp: point.timestamp, + }; + } + grouped[point.timestamp][agentKey] = point.value; }); + }); - if (!acc[time]) { - acc[time] = {time, timestamp: item.timestamp}; - } - - // 根据选择的探针过滤 - if (selectedAgent === 'all' || item.agentId === selectedAgent) { - const agentKey = `agent_${item.agentId}`; - acc[time][agentKey] = item.avgResponse; - } - - return acc; - }, {} as Record); - - return Object.values(grouped); + // 按时间戳排序 + return Object.values(grouped).sort((a, b) => a.timestamp - b.timestamp); }, [historyData, selectedAgent]); if (isLoading) { @@ -655,7 +668,7 @@ const MonitorDetail = () => { {monitorStats.map((stat, index) => { - const agentKey = `agent_${stat.agentId}`; + const agentKey = `agent_${stat.agentID}`; const color = AGENT_COLORS[index % AGENT_COLORS.length]; return ( { className="hidden sm:block" /> {monitorStats - .filter(stat => selectedAgent === 'all' || stat.agentId === selectedAgent) + .filter(stat => selectedAgent === 'all' || stat.agentID === selectedAgent) .map((stat) => { // 使用原始索引保持颜色一致性 - const originalIndex = monitorStats.findIndex(s => s.agentId === stat.agentId); - const agentKey = `agent_${stat.agentId}`; + const originalIndex = monitorStats.findIndex(s => s.agentID === stat.agentID); + const agentKey = `agent_${stat.agentID}`; const color = AGENT_COLORS[originalIndex % AGENT_COLORS.length]; - const agentLabel = stat.agentName || stat.agentId.substring(0, 8); + const agentLabel = stat.agentID.substring(0, 8); return ( { {monitorStats.map((stats, index) => { const color = AGENT_COLORS[index % AGENT_COLORS.length]; return ( -
@@ -768,23 +781,10 @@ const MonitorDetail = () => { style={{backgroundColor: color}} />
- {stats.agentName ? ( - <> - - {stats.agentName} - - - {stats.agentId.substring(0, 8)}... - - - ) : ( - - {stats.agentId.substring(0, 8)}... + + {stats.agentID.substring(0, 8)}... - )}
From 821df7b2125b4ace414ef30f343d2d35d9e05159 Mon Sep 17 00:00:00 2001 From: dushixiang Date: Fri, 12 Dec 2025 23:26:56 +0800 Subject: [PATCH 05/18] =?UTF-8?q?=E7=A7=BB=E9=99=A4=E6=97=A0=E7=94=A8?= =?UTF-8?q?=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/handler/agent_handler.go | 28 ---------------------------- internal/service/agent_service.go | 10 ---------- internal/service/metric_service.go | 14 -------------- 3 files changed, 52 deletions(-) diff --git a/internal/handler/agent_handler.go b/internal/handler/agent_handler.go index d36eebb..744add1 100644 --- a/internal/handler/agent_handler.go +++ b/internal/handler/agent_handler.go @@ -672,34 +672,6 @@ func (h *AgentHandler) GetStatistics(c echo.Context) error { return orz.Ok(c, stats) } -// GetMonitorMetrics 获取监控指标数据 -func (h *AgentHandler) GetMonitorMetrics(c echo.Context) error { - agentID := c.Param("id") - monitorName := c.QueryParam("name") - rangeParam := c.QueryParam("range") - ctx := c.Request().Context() - - // 解析时间范围 - start, end, err := parseTimeRange(rangeParam) - if err != nil { - return orz.NewError(400, err.Error()) - } - - metrics, err := h.agentService.GetMonitorMetrics(ctx, agentID, monitorName, start, end) - if err != nil { - return err - } - - return orz.Ok(c, orz.Map{ - "agentId": agentID, - "name": monitorName, - "range": rangeParam, - "start": start, - "end": end, - "metrics": metrics, - }) -} - // Delete 删除探针 func (h *AgentHandler) Delete(c echo.Context) error { agentID := c.Param("id") diff --git a/internal/service/agent_service.go b/internal/service/agent_service.go index 1ca6625..6ecb2b2 100644 --- a/internal/service/agent_service.go +++ b/internal/service/agent_service.go @@ -328,16 +328,6 @@ func (s *AgentService) GetStatistics(ctx context.Context) (map[string]interface{ }, nil } -// GetMonitorMetrics 获取监控指标历史数据 -func (s *AgentService) GetMonitorMetrics(ctx context.Context, agentID, monitorName string, start, end int64) ([]MonitorMetric, error) { - return s.metricService.GetMonitorMetrics(ctx, agentID, monitorName, start, end) -} - -// GetMonitorMetricsByName 获取指定监控项的历史数据 -func (s *AgentService) GetMonitorMetricsByName(ctx context.Context, agentID, monitorName string, start, end int64, limit int) ([]MonitorMetric, error) { - return s.metricService.GetMonitorMetricsByName(ctx, agentID, monitorName, start, end, limit) -} - // DeleteAgent 删除探针及其所有相关数据 func (s *AgentService) DeleteAgent(ctx context.Context, agentID string) error { // 在事务中执行所有删除操作 diff --git a/internal/service/metric_service.go b/internal/service/metric_service.go index 8034839..5ef5d14 100644 --- a/internal/service/metric_service.go +++ b/internal/service/metric_service.go @@ -352,20 +352,6 @@ func (s *MetricService) GetLatestMetrics(ctx context.Context, agentID string) (* return metrics, nil } -// GetMonitorMetrics 获取监控指标历史数据 -// TODO: 需要重写为从 VictoriaMetrics 查询 -func (s *MetricService) GetMonitorMetrics(ctx context.Context, agentID, monitorName string, start, end int64) ([]MonitorMetric, error) { - // 暂时返回空数据,后续从 VictoriaMetrics 查询 - return []MonitorMetric{}, nil -} - -// GetMonitorMetricsByName 获取指定监控项的历史数据 -// TODO: 需要重写为从 VictoriaMetrics 查询 -func (s *MetricService) GetMonitorMetricsByName(ctx context.Context, agentID, monitorName string, start, end int64, limit int) ([]MonitorMetric, error) { - // 暂时返回空数据,后续从 VictoriaMetrics 查询 - return []MonitorMetric{}, nil -} - // DeleteAgentMetrics 删除探针的所有指标数据 func (s *MetricService) DeleteAgentMetrics(ctx context.Context, agentID string) error { // 1. 删除 PostgreSQL 中的主机信息 From 0e0b55381d8b9dceb4532aca12e6d5be1cbed259 Mon Sep 17 00:00:00 2001 From: dushixiang Date: Fri, 12 Dec 2025 23:52:21 +0800 Subject: [PATCH 06/18] =?UTF-8?q?=E7=A7=BB=E9=99=A4=2024=20=E5=B0=8F?= =?UTF-8?q?=E6=97=B6=E5=92=8C=207=E5=A4=A9=E5=9C=A8=E7=BA=BF=E7=8E=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/service/metric_service.go | 493 ++++------------------------ internal/service/monitor_service.go | 70 ++-- 2 files changed, 85 insertions(+), 478 deletions(-) diff --git a/internal/service/metric_service.go b/internal/service/metric_service.go index 5ef5d14..21e7222 100644 --- a/internal/service/metric_service.go +++ b/internal/service/metric_service.go @@ -705,43 +705,31 @@ const ( // MonitorStatsResult 监控统计结果(所有探针的聚合数据) type MonitorStatsResult struct { - CurrentResponse int64 // 所有探针平均响应时间(ms) - AvgResponse24h int64 // 24小时平均响应时间(ms) - Uptime24h float64 // 24小时在线率(百分比) - Uptime7d float64 // 7天在线率(百分比) - CertExpiryDate int64 // 最早过期的证书时间(毫秒时间戳) - CertExpiryDays int // 证书剩余天数 - LastCheckTime int64 // 最后检测时间(毫秒时间戳) - LastCheckStatus string // 聚合状态(up/down/unknown) - LastCheckError string // 最后检测错误信息 - AgentCount int // 探针数量 + Status string `json:"status"` // 聚合状态(up/down/unknown) + ResponseTime int64 `json:"responseTime"` // 当前平均响应时间(ms) + CertExpiryDate int64 `json:"certExpiryDate,omitempty"` // 最早过期的证书时间(毫秒时间戳) + CertExpiryDays int `json:"certExpiryDays,omitempty"` // 证书剩余天数 + AgentCount int `json:"agentCount"` // 探针数量 + LastCheckTime int64 `json:"lastCheckTime"` // 最后检测时间(毫秒时间戳) } // AgentMonitorStat 单个探针的监控统计 type AgentMonitorStat struct { - AgentID string `json:"agentID,omitempty"` - CurrentResponse int64 `json:"currentResponse,omitempty"` - AvgResponse24h int64 `json:"avgResponse24H,omitempty"` - Uptime24h float64 `json:"uptime24H,omitempty"` - Uptime7d float64 `json:"uptime7D,omitempty"` - LastCheckStatus string `json:"lastCheckStatus,omitempty"` - LastCheckError string `json:"lastCheckError,omitempty"` - LastCheckTime int64 `json:"lastCheckTime,omitempty"` - CertExpiryDate int64 `json:"certExpiryDate,omitempty"` - CertExpiryDays int `json:"certExpiryDays,omitempty"` - TotalChecks24h int64 `json:"totalChecks24H,omitempty"` - SuccessChecks24h int64 `json:"successChecks24H,omitempty"` - TotalChecks7d int64 `json:"totalChecks7D,omitempty"` - SuccessChecks7d int64 `json:"successChecks7D,omitempty"` + AgentID string `json:"agentID,omitempty"` + Status string `json:"status,omitempty"` // up/down/unknown + ResponseTime int64 `json:"responseTime,omitempty"` // 当前响应时间(ms) + LastCheckTime int64 `json:"lastCheckTime,omitempty"` // 最后检测时间(毫秒时间戳) + CertExpiryDate int64 `json:"certExpiryDate,omitempty"` // 证书过期时间(毫秒时间戳) + CertExpiryDays int `json:"certExpiryDays,omitempty"` // 证书剩余天数 } -// buildMonitorPromQLQueries 构建监控查询的 PromQL 语句 +// buildMonitorPromQLQueries 构建监控查询的 PromQL 语句(简化版:只查询当前状态) func (s *MetricService) buildMonitorPromQLQueries(monitorID string, queryType string) []QueryDefinition { var queries []QueryDefinition switch queryType { case MonitorQueryTypeCurrent: - // 当前状态查询(即时查询) + // 当前状态查询(即时查询)- 简化为只查询必要的指标 queries = []QueryDefinition{ {Name: "response_time", Query: fmt.Sprintf(`pika_monitor_response_time_ms{monitor_id="%s"}`, monitorID)}, {Name: "status", Query: fmt.Sprintf(`pika_monitor_status{monitor_id="%s"}`, monitorID)}, @@ -749,26 +737,6 @@ func (s *MetricService) buildMonitorPromQLQueries(monitorID string, queryType st {Name: "cert_expiry", Query: fmt.Sprintf(`pika_monitor_cert_expiry_timestamp_ms{monitor_id="%s"}`, monitorID)}, } - case MonitorQueryTypeStats24h: - // 24小时统计查询 - queries = []QueryDefinition{ - // 24小时平均响应时间(按探针分组) - {Name: "avg_response_24h", Query: fmt.Sprintf(`avg_over_time(pika_monitor_response_time_ms{monitor_id="%s"}[24h])`, monitorID)}, - // 24小时成功次数 - {Name: "success_count_24h", Query: fmt.Sprintf(`count_over_time(pika_monitor_status{monitor_id="%s",status="up"}[24h:1m])`, monitorID)}, - // 24小时总检测次数 - {Name: "total_count_24h", Query: fmt.Sprintf(`count_over_time(pika_monitor_status{monitor_id="%s"}[24h:1m])`, monitorID)}, - } - - case MonitorQueryTypeStats7d: - // 7天统计查询 - queries = []QueryDefinition{ - // 7天成功次数 - {Name: "success_count_7d", Query: fmt.Sprintf(`count_over_time(pika_monitor_status{monitor_id="%s",status="up"}[7d:5m])`, monitorID)}, - // 7天总检测次数 - {Name: "total_count_7d", Query: fmt.Sprintf(`count_over_time(pika_monitor_status{monitor_id="%s"}[7d:5m])`, monitorID)}, - } - case MonitorQueryTypeHistory: // 历史趋势查询(范围查询) queries = []QueryDefinition{ @@ -779,63 +747,29 @@ func (s *MetricService) buildMonitorPromQLQueries(monitorID string, queryType st return queries } -// GetMonitorStats 获取监控任务的聚合统计数据 +// GetMonitorStats 获取监控任务的聚合统计数据(简化版:只查询当前状态) func (s *MetricService) GetMonitorStats(ctx context.Context, monitorID string) (*MonitorStatsResult, error) { - // 1. 查询当前状态 - currentQueries := s.buildMonitorPromQLQueries(monitorID, MonitorQueryTypeCurrent) - currentData := make(map[string]*vmclient.QueryResult) - for _, q := range currentQueries { - queryResult, err := s.vmClient.Query(ctx, q.Query) - if err != nil { - s.logger.Warn("查询当前状态失败", zap.String("query", q.Name), zap.Error(err)) - continue - } - currentData[q.Name] = queryResult - } - - // 2. 查询24小时统计 - stats24hQueries := s.buildMonitorPromQLQueries(monitorID, MonitorQueryTypeStats24h) - stats24hData := make(map[string]*vmclient.QueryResult) - for _, q := range stats24hQueries { - queryResult, err := s.vmClient.Query(ctx, q.Query) - if err != nil { - s.logger.Warn("查询24小时统计失败", zap.String("query", q.Name), zap.Error(err)) - continue - } - stats24hData[q.Name] = queryResult - } - - // 3. 查询7天统计 - stats7dQueries := s.buildMonitorPromQLQueries(monitorID, MonitorQueryTypeStats7d) - stats7dData := make(map[string]*vmclient.QueryResult) - for _, q := range stats7dQueries { - queryResult, err := s.vmClient.Query(ctx, q.Query) - if err != nil { - s.logger.Warn("查询7天统计失败", zap.String("query", q.Name), zap.Error(err)) - continue - } - stats7dData[q.Name] = queryResult - } - - // 4. 聚合计算 - return s.aggregateMonitorQueryResults(currentData, stats24hData, stats7dData), nil -} - -// aggregateMonitorQueryResults 聚合多个探针的查询结果 -func (s *MetricService) aggregateMonitorQueryResults( - currentData map[string]*vmclient.QueryResult, - stats24hData map[string]*vmclient.QueryResult, - stats7dData map[string]*vmclient.QueryResult, -) *MonitorStatsResult { result := &MonitorStatsResult{ - LastCheckStatus: "unknown", + Status: "unknown", } - // 解析当前状态数据 + // 查询当前状态(只需4个查询) + queries := s.buildMonitorPromQLQueries(monitorID, MonitorQueryTypeCurrent) + queryData := make(map[string]*vmclient.QueryResult) + for _, q := range queries { + queryResult, err := s.vmClient.Query(ctx, q.Query) + if err != nil { + s.logger.Warn("查询监控状态失败", zap.String("query", q.Name), zap.Error(err)) + continue + } + queryData[q.Name] = queryResult + } + + // 聚合各探针的数据 agentStats := make(map[string]*AgentMonitorStat) // 处理响应时间 - if respResult, ok := currentData["response_time"]; ok && respResult != nil { + if respResult, ok := queryData["response_time"]; ok && respResult != nil { for _, ts := range respResult.Data.Result { agentID := ts.Metric["agent_id"] if agentID == "" { @@ -845,7 +779,6 @@ func (s *MetricService) aggregateMonitorQueryResults( agentStats[agentID] = &AgentMonitorStat{AgentID: agentID} } - // 获取最新值 if len(ts.Values) > 0 { lastValue := ts.Values[len(ts.Values)-1] if len(lastValue) >= 2 { @@ -853,8 +786,7 @@ func (s *MetricService) aggregateMonitorQueryResults( valueStr, _ := lastValue[1].(string) var value float64 fmt.Sscanf(valueStr, "%f", &value) - - agentStats[agentID].CurrentResponse = int64(value) + agentStats[agentID].ResponseTime = int64(value) agentStats[agentID].LastCheckTime = int64(timestamp * 1000) } } @@ -862,7 +794,7 @@ func (s *MetricService) aggregateMonitorQueryResults( } // 处理状态 - if statusResult, ok := currentData["status"]; ok && statusResult != nil { + if statusResult, ok := queryData["status"]; ok && statusResult != nil { for _, ts := range statusResult.Data.Result { agentID := ts.Metric["agent_id"] status := ts.Metric["status"] @@ -873,30 +805,16 @@ func (s *MetricService) aggregateMonitorQueryResults( agentStats[agentID] = &AgentMonitorStat{AgentID: agentID} } - // 获取最新值 if len(ts.Values) > 0 { - lastValue := ts.Values[len(ts.Values)-1] - if len(lastValue) >= 2 { - valueStr, _ := lastValue[1].(string) - var value float64 - fmt.Sscanf(valueStr, "%f", &value) - - if value > 0 { - agentStats[agentID].LastCheckStatus = "up" - } else { - agentStats[agentID].LastCheckStatus = "down" - } - // 状态标签中也包含了状态信息 - if status != "" { - agentStats[agentID].LastCheckStatus = status - } + if status != "" { + agentStats[agentID].Status = status } } } } // 处理证书信息 - if certDaysResult, ok := currentData["cert_days"]; ok && certDaysResult != nil { + if certDaysResult, ok := queryData["cert_days"]; ok && certDaysResult != nil { for _, ts := range certDaysResult.Data.Result { agentID := ts.Metric["agent_id"] if agentID == "" { @@ -918,7 +836,7 @@ func (s *MetricService) aggregateMonitorQueryResults( } } - if certExpiryResult, ok := currentData["cert_expiry"]; ok && certExpiryResult != nil { + if certExpiryResult, ok := queryData["cert_expiry"]; ok && certExpiryResult != nil { for _, ts := range certExpiryResult.Data.Result { agentID := ts.Metric["agent_id"] if agentID == "" { @@ -940,137 +858,12 @@ func (s *MetricService) aggregateMonitorQueryResults( } } - // 处理24小时统计 - if avgResp24h, ok := stats24hData["avg_response_24h"]; ok && avgResp24h != nil { - for _, ts := range avgResp24h.Data.Result { - agentID := ts.Metric["agent_id"] - if agentID == "" { - continue - } - if _, exists := agentStats[agentID]; !exists { - agentStats[agentID] = &AgentMonitorStat{AgentID: agentID} - } - - if len(ts.Values) > 0 { - lastValue := ts.Values[len(ts.Values)-1] - if len(lastValue) >= 2 { - valueStr, _ := lastValue[1].(string) - var value float64 - fmt.Sscanf(valueStr, "%f", &value) - agentStats[agentID].AvgResponse24h = int64(value) - } - } - } - } - - if successCount24h, ok := stats24hData["success_count_24h"]; ok && successCount24h != nil { - for _, ts := range successCount24h.Data.Result { - agentID := ts.Metric["agent_id"] - if agentID == "" { - continue - } - if _, exists := agentStats[agentID]; !exists { - agentStats[agentID] = &AgentMonitorStat{AgentID: agentID} - } - - if len(ts.Values) > 0 { - lastValue := ts.Values[len(ts.Values)-1] - if len(lastValue) >= 2 { - valueStr, _ := lastValue[1].(string) - var value float64 - fmt.Sscanf(valueStr, "%f", &value) - agentStats[agentID].SuccessChecks24h = int64(value) - } - } - } - } - - if totalCount24h, ok := stats24hData["total_count_24h"]; ok && totalCount24h != nil { - for _, ts := range totalCount24h.Data.Result { - agentID := ts.Metric["agent_id"] - if agentID == "" { - continue - } - if _, exists := agentStats[agentID]; !exists { - agentStats[agentID] = &AgentMonitorStat{AgentID: agentID} - } - - if len(ts.Values) > 0 { - lastValue := ts.Values[len(ts.Values)-1] - if len(lastValue) >= 2 { - valueStr, _ := lastValue[1].(string) - var value float64 - fmt.Sscanf(valueStr, "%f", &value) - agentStats[agentID].TotalChecks24h = int64(value) - } - } - } - } - - // 处理7天统计 - if successCount7d, ok := stats7dData["success_count_7d"]; ok && successCount7d != nil { - for _, ts := range successCount7d.Data.Result { - agentID := ts.Metric["agent_id"] - if agentID == "" { - continue - } - if _, exists := agentStats[agentID]; !exists { - agentStats[agentID] = &AgentMonitorStat{AgentID: agentID} - } - - if len(ts.Values) > 0 { - lastValue := ts.Values[len(ts.Values)-1] - if len(lastValue) >= 2 { - valueStr, _ := lastValue[1].(string) - var value float64 - fmt.Sscanf(valueStr, "%f", &value) - agentStats[agentID].SuccessChecks7d = int64(value) - } - } - } - } - - if totalCount7d, ok := stats7dData["total_count_7d"]; ok && totalCount7d != nil { - for _, ts := range totalCount7d.Data.Result { - agentID := ts.Metric["agent_id"] - if agentID == "" { - continue - } - if _, exists := agentStats[agentID]; !exists { - agentStats[agentID] = &AgentMonitorStat{AgentID: agentID} - } - - if len(ts.Values) > 0 { - lastValue := ts.Values[len(ts.Values)-1] - if len(lastValue) >= 2 { - valueStr, _ := lastValue[1].(string) - var value float64 - fmt.Sscanf(valueStr, "%f", &value) - agentStats[agentID].TotalChecks7d = int64(value) - } - } - } - } - - // 计算在线率 - for _, stat := range agentStats { - if stat.TotalChecks24h > 0 { - stat.Uptime24h = float64(stat.SuccessChecks24h) / float64(stat.TotalChecks24h) * 100 - } - if stat.TotalChecks7d > 0 { - stat.Uptime7d = float64(stat.SuccessChecks7d) / float64(stat.TotalChecks7d) * 100 - } - } - // 聚合所有探针的数据 if len(agentStats) == 0 { - return result + return result, nil } - var totalCurrentResponse int64 - var totalAvgResponse24h int64 - var totalUptime24h float64 - var totalUptime7d float64 + var totalResponseTime int64 var lastCheckTime int64 hasUp := false hasDown := false @@ -1079,18 +872,15 @@ func (s *MetricService) aggregateMonitorQueryResults( var minCertExpiryDays int for _, stat := range agentStats { - totalCurrentResponse += stat.CurrentResponse - totalAvgResponse24h += stat.AvgResponse24h - totalUptime24h += stat.Uptime24h - totalUptime7d += stat.Uptime7d + totalResponseTime += stat.ResponseTime if stat.LastCheckTime > lastCheckTime { lastCheckTime = stat.LastCheckTime } - if stat.LastCheckStatus == "up" { + if stat.Status == "up" { hasUp = true - } else if stat.LastCheckStatus == "down" { + } else if stat.Status == "down" { hasDown = true } @@ -1106,18 +896,15 @@ func (s *MetricService) aggregateMonitorQueryResults( count := len(agentStats) result.AgentCount = count if count > 0 { - result.CurrentResponse = totalCurrentResponse / int64(count) - result.AvgResponse24h = totalAvgResponse24h / int64(count) - result.Uptime24h = totalUptime24h / float64(count) - result.Uptime7d = totalUptime7d / float64(count) + result.ResponseTime = totalResponseTime / int64(count) } result.LastCheckTime = lastCheckTime // 聚合状态:只要有一个探针 up,整体就是 up if hasUp { - result.LastCheckStatus = "up" + result.Status = "up" } else if hasDown { - result.LastCheckStatus = "down" + result.Status = "down" } if hasCert { @@ -1125,53 +912,28 @@ func (s *MetricService) aggregateMonitorQueryResults( result.CertExpiryDays = minCertExpiryDays } - return result + return result, nil } -// GetMonitorAgentStats 获取监控任务各探针的统计数据 +// GetMonitorAgentStats 获取监控任务各探针的统计数据(简化版:只查询当前状态) func (s *MetricService) GetMonitorAgentStats(ctx context.Context, monitorID string) ([]AgentMonitorStat, error) { - // 复用 GetMonitorStats 的逻辑,但保留每个探针的独立数据 - // 1. 查询当前状态 - currentQueries := s.buildMonitorPromQLQueries(monitorID, MonitorQueryTypeCurrent) - currentData := make(map[string]*vmclient.QueryResult) - for _, q := range currentQueries { + // 查询当前状态(只需4个查询) + queries := s.buildMonitorPromQLQueries(monitorID, MonitorQueryTypeCurrent) + queryData := make(map[string]*vmclient.QueryResult) + for _, q := range queries { queryResult, err := s.vmClient.Query(ctx, q.Query) if err != nil { - s.logger.Warn("查询当前状态失败", zap.String("query", q.Name), zap.Error(err)) + s.logger.Warn("查询监控状态失败", zap.String("query", q.Name), zap.Error(err)) continue } - currentData[q.Name] = queryResult + queryData[q.Name] = queryResult } - // 2. 查询24小时统计 - stats24hQueries := s.buildMonitorPromQLQueries(monitorID, MonitorQueryTypeStats24h) - stats24hData := make(map[string]*vmclient.QueryResult) - for _, q := range stats24hQueries { - queryResult, err := s.vmClient.Query(ctx, q.Query) - if err != nil { - s.logger.Warn("查询24小时统计失败", zap.String("query", q.Name), zap.Error(err)) - continue - } - stats24hData[q.Name] = queryResult - } - - // 3. 查询7天统计 - stats7dQueries := s.buildMonitorPromQLQueries(monitorID, MonitorQueryTypeStats7d) - stats7dData := make(map[string]*vmclient.QueryResult) - for _, q := range stats7dQueries { - queryResult, err := s.vmClient.Query(ctx, q.Query) - if err != nil { - s.logger.Warn("查询7天统计失败", zap.String("query", q.Name), zap.Error(err)) - continue - } - stats7dData[q.Name] = queryResult - } - - // 4. 提取每个探针的数据(参考 aggregateMonitorQueryResults,但不聚合) + // 提取每个探针的数据 agentStatsMap := make(map[string]*AgentMonitorStat) // 处理响应时间 - if respResult, ok := currentData["response_time"]; ok && respResult != nil { + if respResult, ok := queryData["response_time"]; ok && respResult != nil { for _, ts := range respResult.Data.Result { agentID := ts.Metric["agent_id"] if agentID == "" { @@ -1188,8 +950,7 @@ func (s *MetricService) GetMonitorAgentStats(ctx context.Context, monitorID stri valueStr, _ := lastValue[1].(string) var value float64 fmt.Sscanf(valueStr, "%f", &value) - - agentStatsMap[agentID].CurrentResponse = int64(value) + agentStatsMap[agentID].ResponseTime = int64(value) agentStatsMap[agentID].LastCheckTime = int64(timestamp * 1000) } } @@ -1197,7 +958,7 @@ func (s *MetricService) GetMonitorAgentStats(ctx context.Context, monitorID stri } // 处理状态 - if statusResult, ok := currentData["status"]; ok && statusResult != nil { + if statusResult, ok := queryData["status"]; ok && statusResult != nil { for _, ts := range statusResult.Data.Result { agentID := ts.Metric["agent_id"] status := ts.Metric["status"] @@ -1209,27 +970,15 @@ func (s *MetricService) GetMonitorAgentStats(ctx context.Context, monitorID stri } if len(ts.Values) > 0 { - lastValue := ts.Values[len(ts.Values)-1] - if len(lastValue) >= 2 { - valueStr, _ := lastValue[1].(string) - var value float64 - fmt.Sscanf(valueStr, "%f", &value) - - if value > 0 { - agentStatsMap[agentID].LastCheckStatus = "up" - } else { - agentStatsMap[agentID].LastCheckStatus = "down" - } - if status != "" { - agentStatsMap[agentID].LastCheckStatus = status - } + if status != "" { + agentStatsMap[agentID].Status = status } } } } // 处理证书信息 - if certDaysResult, ok := currentData["cert_days"]; ok && certDaysResult != nil { + if certDaysResult, ok := queryData["cert_days"]; ok && certDaysResult != nil { for _, ts := range certDaysResult.Data.Result { agentID := ts.Metric["agent_id"] if agentID == "" { @@ -1251,7 +1000,7 @@ func (s *MetricService) GetMonitorAgentStats(ctx context.Context, monitorID stri } } - if certExpiryResult, ok := currentData["cert_expiry"]; ok && certExpiryResult != nil { + if certExpiryResult, ok := queryData["cert_expiry"]; ok && certExpiryResult != nil { for _, ts := range certExpiryResult.Data.Result { agentID := ts.Metric["agent_id"] if agentID == "" { @@ -1273,128 +1022,6 @@ func (s *MetricService) GetMonitorAgentStats(ctx context.Context, monitorID stri } } - // 处理24小时统计 - if avgResp24h, ok := stats24hData["avg_response_24h"]; ok && avgResp24h != nil { - for _, ts := range avgResp24h.Data.Result { - agentID := ts.Metric["agent_id"] - if agentID == "" { - continue - } - if _, exists := agentStatsMap[agentID]; !exists { - agentStatsMap[agentID] = &AgentMonitorStat{AgentID: agentID} - } - - if len(ts.Values) > 0 { - lastValue := ts.Values[len(ts.Values)-1] - if len(lastValue) >= 2 { - valueStr, _ := lastValue[1].(string) - var value float64 - fmt.Sscanf(valueStr, "%f", &value) - agentStatsMap[agentID].AvgResponse24h = int64(value) - } - } - } - } - - if successCount24h, ok := stats24hData["success_count_24h"]; ok && successCount24h != nil { - for _, ts := range successCount24h.Data.Result { - agentID := ts.Metric["agent_id"] - if agentID == "" { - continue - } - if _, exists := agentStatsMap[agentID]; !exists { - agentStatsMap[agentID] = &AgentMonitorStat{AgentID: agentID} - } - - if len(ts.Values) > 0 { - lastValue := ts.Values[len(ts.Values)-1] - if len(lastValue) >= 2 { - valueStr, _ := lastValue[1].(string) - var value float64 - fmt.Sscanf(valueStr, "%f", &value) - agentStatsMap[agentID].SuccessChecks24h = int64(value) - } - } - } - } - - if totalCount24h, ok := stats24hData["total_count_24h"]; ok && totalCount24h != nil { - for _, ts := range totalCount24h.Data.Result { - agentID := ts.Metric["agent_id"] - if agentID == "" { - continue - } - if _, exists := agentStatsMap[agentID]; !exists { - agentStatsMap[agentID] = &AgentMonitorStat{AgentID: agentID} - } - - if len(ts.Values) > 0 { - lastValue := ts.Values[len(ts.Values)-1] - if len(lastValue) >= 2 { - valueStr, _ := lastValue[1].(string) - var value float64 - fmt.Sscanf(valueStr, "%f", &value) - agentStatsMap[agentID].TotalChecks24h = int64(value) - } - } - } - } - - // 处理7天统计 - if successCount7d, ok := stats7dData["success_count_7d"]; ok && successCount7d != nil { - for _, ts := range successCount7d.Data.Result { - agentID := ts.Metric["agent_id"] - if agentID == "" { - continue - } - if _, exists := agentStatsMap[agentID]; !exists { - agentStatsMap[agentID] = &AgentMonitorStat{AgentID: agentID} - } - - if len(ts.Values) > 0 { - lastValue := ts.Values[len(ts.Values)-1] - if len(lastValue) >= 2 { - valueStr, _ := lastValue[1].(string) - var value float64 - fmt.Sscanf(valueStr, "%f", &value) - agentStatsMap[agentID].SuccessChecks7d = int64(value) - } - } - } - } - - if totalCount7d, ok := stats7dData["total_count_7d"]; ok && totalCount7d != nil { - for _, ts := range totalCount7d.Data.Result { - agentID := ts.Metric["agent_id"] - if agentID == "" { - continue - } - if _, exists := agentStatsMap[agentID]; !exists { - agentStatsMap[agentID] = &AgentMonitorStat{AgentID: agentID} - } - - if len(ts.Values) > 0 { - lastValue := ts.Values[len(ts.Values)-1] - if len(lastValue) >= 2 { - valueStr, _ := lastValue[1].(string) - var value float64 - fmt.Sscanf(valueStr, "%f", &value) - agentStatsMap[agentID].TotalChecks7d = int64(value) - } - } - } - } - - // 计算在线率 - for _, stat := range agentStatsMap { - if stat.TotalChecks24h > 0 { - stat.Uptime24h = float64(stat.SuccessChecks24h) / float64(stat.TotalChecks24h) * 100 - } - if stat.TotalChecks7d > 0 { - stat.Uptime7d = float64(stat.SuccessChecks7d) / float64(stat.TotalChecks7d) * 100 - } - } - // 转换为数组 result := make([]AgentMonitorStat, 0, len(agentStatsMap)) for _, stat := range agentStatsMap { diff --git a/internal/service/monitor_service.go b/internal/service/monitor_service.go index 02ebe3c..c6b3b33 100644 --- a/internal/service/monitor_service.go +++ b/internal/service/monitor_service.go @@ -90,15 +90,11 @@ type PublicMonitorOverview struct { Interval int `json:"interval"` AgentIds []string `json:"agentIds"` AgentCount int `json:"agentCount"` - LastCheckStatus string `json:"lastCheckStatus"` - LastCheckError string `json:"lastCheckError"` - CurrentResponse int64 `json:"currentResponse"` - AvgResponse24h int64 `json:"avgResponse24h"` - Uptime24h float64 `json:"uptime24h"` - Uptime7d float64 `json:"uptime7d"` - CertExpiryDate int64 `json:"certExpiryDate"` - CertExpiryDays int `json:"certExpiryDays"` - LastCheckTime int64 `json:"lastCheckTime"` + Status string `json:"status"` // up/down/unknown + ResponseTime int64 `json:"responseTime"` // 当前响应时间(ms) + CertExpiryDate int64 `json:"certExpiryDate"` // 证书过期时间 + CertExpiryDays int `json:"certExpiryDays"` // 证书剩余天数 + LastCheckTime int64 `json:"lastCheckTime"` // 最后检测时间 } func (s *MonitorService) CreateMonitor(ctx context.Context, req *MonitorTaskRequest) (*models.MonitorTask, error) { @@ -289,16 +285,12 @@ func (s *MonitorService) ListByAuth(ctx context.Context, isAuthenticated bool) ( // 将 MonitorStatsResult 转换为 monitorOverviewSummary summary := monitorOverviewSummary{ - AgentCount: stats.AgentCount, - CurrentResponse: stats.CurrentResponse, - AvgResponse24h: stats.AvgResponse24h, - Uptime24h: stats.Uptime24h, - Uptime7d: stats.Uptime7d, - LastCheckStatus: stats.LastCheckStatus, - LastCheckError: stats.LastCheckError, - LastCheckTime: stats.LastCheckTime, - CertExpiryDate: stats.CertExpiryDate, - CertExpiryDays: stats.CertExpiryDays, + AgentCount: stats.AgentCount, + Status: stats.Status, + ResponseTime: stats.ResponseTime, + LastCheckTime: stats.LastCheckTime, + CertExpiryDate: stats.CertExpiryDate, + CertExpiryDays: stats.CertExpiryDays, } // 构建监控概览对象 @@ -331,12 +323,8 @@ func (s *MonitorService) buildMonitorOverview(monitor models.MonitorTask, summar Interval: monitor.Interval, AgentIds: cloneAgentIDs(monitor.AgentIds), AgentCount: summary.AgentCount, - LastCheckStatus: summary.LastCheckStatus, - LastCheckError: summary.LastCheckError, - CurrentResponse: summary.CurrentResponse, - AvgResponse24h: summary.AvgResponse24h, - Uptime24h: summary.Uptime24h, - Uptime7d: summary.Uptime7d, + Status: summary.Status, + ResponseTime: summary.ResponseTime, CertExpiryDate: summary.CertExpiryDate, CertExpiryDays: summary.CertExpiryDays, LastCheckTime: summary.LastCheckTime, @@ -344,16 +332,12 @@ func (s *MonitorService) buildMonitorOverview(monitor models.MonitorTask, summar } type monitorOverviewSummary struct { - AgentCount int - LastCheckStatus string - LastCheckError string - CurrentResponse int64 - AvgResponse24h int64 - Uptime24h float64 - Uptime7d float64 - CertExpiryDate int64 - CertExpiryDays int - LastCheckTime int64 + AgentCount int + Status string // up/down/unknown + ResponseTime int64 // 当前响应时间(ms) + CertExpiryDate int64 // 证书过期时间 + CertExpiryDays int // 证书剩余天数 + LastCheckTime int64 // 最后检测时间 } func cloneAgentIDs(ids datatypes.JSONSlice[string]) []string { @@ -516,16 +500,12 @@ func (s *MonitorService) GetMonitorStatsByID(ctx context.Context, monitorID stri // 转换为 monitorOverviewSummary 格式 summary := monitorOverviewSummary{ - AgentCount: stats.AgentCount, - LastCheckStatus: stats.LastCheckStatus, - LastCheckError: stats.LastCheckError, - CurrentResponse: stats.CurrentResponse, - AvgResponse24h: stats.AvgResponse24h, - Uptime24h: stats.Uptime24h, - Uptime7d: stats.Uptime7d, - CertExpiryDate: stats.CertExpiryDate, - CertExpiryDays: stats.CertExpiryDays, - LastCheckTime: stats.LastCheckTime, + AgentCount: stats.AgentCount, + Status: stats.Status, + ResponseTime: stats.ResponseTime, + CertExpiryDate: stats.CertExpiryDate, + CertExpiryDays: stats.CertExpiryDays, + LastCheckTime: stats.LastCheckTime, } // 构建监控概览对象 From 21d5c7a7177354111d4f75553de1a08cd93a2d81 Mon Sep 17 00:00:00 2001 From: dushixiang Date: Sat, 13 Dec 2025 11:55:31 +0800 Subject: [PATCH 07/18] =?UTF-8?q?=E5=AE=8C=E6=88=90=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E7=9B=91=E6=8E=A7=E5=90=8E=E7=AB=AF=E9=87=8D=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/app.go | 6 +- internal/handler/agent_handler.go | 10 +- internal/handler/monitor_handler.go | 6 +- internal/metric/agent.go | 37 ++ internal/metric/metric.go | 29 + internal/metric/monitor.go | 20 + internal/protocol/message.go | 4 +- internal/repo/metric_repo.go | 124 ---- internal/repo/monitor_repo.go | 11 + internal/service/alert_service.go | 19 +- internal/service/metric_converter.go | 26 +- internal/service/metric_service.go | 855 +++++---------------------- internal/service/monitor_service.go | 50 +- internal/wire_gen.go | 2 +- pkg/agent/collector/monitor.go | 8 +- 15 files changed, 325 insertions(+), 882 deletions(-) create mode 100644 internal/metric/agent.go create mode 100644 internal/metric/metric.go create mode 100644 internal/metric/monitor.go diff --git a/internal/app.go b/internal/app.go index b0b16d0..d48a8be 100644 --- a/internal/app.go +++ b/internal/app.go @@ -371,9 +371,9 @@ func startMetricsMonitoring(ctx context.Context, components *AppComponents, logg for _, agent := range agents { // 获取最新指标 - latest, err := components.MetricService.GetLatestMetrics(ctx, agent.ID) - if err != nil { - logger.Debug("获取探针最新指标失败", zap.String("agentId", agent.ID), zap.Error(err)) + latest, ok := components.MetricService.GetLatestMetrics(agent.ID) + if !ok { + logger.Debug("获取探针最新指标失败", zap.String("agentId", agent.ID)) continue } if latest == nil { diff --git a/internal/handler/agent_handler.go b/internal/handler/agent_handler.go index 744add1..333df71 100644 --- a/internal/handler/agent_handler.go +++ b/internal/handler/agent_handler.go @@ -443,9 +443,9 @@ func (h *AgentHandler) GetLatestMetrics(c echo.Context) error { return err } - metrics, err := h.metricService.GetLatestMetrics(ctx, id) - if err != nil { - return err + metrics, ok := h.metricService.GetLatestMetrics(id) + if !ok { + return orz.NewError(404, "探针最新指标不存在") } return orz.Ok(c, metrics) @@ -514,8 +514,8 @@ func (h *AgentHandler) GetAgents(c echo.Context) error { } // 获取最新指标数据 - metrics, err := h.metricService.GetLatestMetrics(ctx, agent.ID) - if err == nil && metrics != nil { + metrics, ok := h.metricService.GetLatestMetrics(agent.ID) + if ok { item["metrics"] = metrics } diff --git a/internal/handler/monitor_handler.go b/internal/handler/monitor_handler.go index cb3624a..4bd5c3a 100644 --- a/internal/handler/monitor_handler.go +++ b/internal/handler/monitor_handler.go @@ -168,9 +168,9 @@ func (h *MonitorHandler) GetAgentStatsByID(c echo.Context) error { return err } - stats, err := h.monitorService.GetMonitorAgentStats(ctx, id) - if err != nil { - return err + stats, ok := h.monitorService.GetMonitorAgentStats(ctx, id) + if !ok { + return orz.NewError(404, "监控任务不存在") } return orz.Ok(c, stats) diff --git a/internal/metric/agent.go b/internal/metric/agent.go new file mode 100644 index 0000000..cc52d1c --- /dev/null +++ b/internal/metric/agent.go @@ -0,0 +1,37 @@ +package metric + +import ( + "github.com/dushixiang/pika/internal/models" + "github.com/dushixiang/pika/internal/protocol" +) + +// DiskSummary 磁盘汇总数据 +type DiskSummary struct { + UsagePercent float64 `json:"usagePercent"` // 平均使用率 + TotalDisks int `json:"totalDisks"` // 磁盘数量 + Total uint64 `json:"total"` // 总容量(字节) + Used uint64 `json:"used"` // 已使用(字节) + Free uint64 `json:"free"` // 空闲(字节) +} + +// NetworkSummary 网络汇总数据 +type NetworkSummary struct { + TotalBytesSentRate uint64 `json:"totalBytesSentRate"` // 总发送速率(字节/秒) + TotalBytesRecvRate uint64 `json:"totalBytesRecvRate"` // 总接收速率(字节/秒) + TotalBytesSentTotal uint64 `json:"totalBytesSentTotal"` // 累计总发送流量 + TotalBytesRecvTotal uint64 `json:"totalBytesRecvTotal"` // 累计总接收流量 + TotalInterfaces int `json:"totalInterfaces"` // 网卡数量 +} + +// LatestMetrics 最新指标数据(用于API响应) +type LatestMetrics struct { + CPU *protocol.CPUData `json:"cpu,omitempty"` + Memory *protocol.MemoryData `json:"memory,omitempty"` + Disk *DiskSummary `json:"disk,omitempty"` + Network *NetworkSummary `json:"network,omitempty"` + NetworkConnection *protocol.NetworkConnectionData `json:"networkConnection,omitempty"` + Host *models.HostMetric `json:"host,omitempty"` + GPU []protocol.GPUData `json:"gpu,omitempty"` + Temp []protocol.TemperatureData `json:"temperature,omitempty"` + Monitors []protocol.MonitorData `json:"monitors,omitempty"` +} diff --git a/internal/metric/metric.go b/internal/metric/metric.go new file mode 100644 index 0000000..2da79d8 --- /dev/null +++ b/internal/metric/metric.go @@ -0,0 +1,29 @@ +package metric + +// DataPoint 统一的指标数据点结构 +type DataPoint struct { + Timestamp int64 `json:"timestamp"` // 毫秒时间戳 + Value float64 `json:"value"` +} + +// Series 指标系列(支持多系列,如多网卡、多传感器) +type Series struct { + Name string `json:"name"` // 系列名称 + Labels map[string]string `json:"labels,omitempty"` // 额外标签 + Data []DataPoint `json:"data"` // 数据点列表 +} + +// GetMetricsResponse 统一的查询响应格式 +type GetMetricsResponse struct { + AgentID string `json:"agentId"` + Type string `json:"type"` + Range string `json:"range"` + Series []Series `json:"series"` +} + +// QueryDefinition 查询定义(用于构建多个查询) +type QueryDefinition struct { + Name string // 系列名称 + Query string // PromQL 查询语句 + Labels map[string]string // 额外标签 +} diff --git a/internal/metric/monitor.go b/internal/metric/monitor.go new file mode 100644 index 0000000..59c9003 --- /dev/null +++ b/internal/metric/monitor.go @@ -0,0 +1,20 @@ +package metric + +import "github.com/dushixiang/pika/internal/protocol" + +// LatestMonitorMetrics 监控任务的最新指标(按 agent 分组) +type LatestMonitorMetrics struct { + MonitorID string `json:"monitorId"` + Agents map[string]*protocol.MonitorData `json:"agents"` // key: agentID + UpdatedAt int64 `json:"updatedAt"` // 最后更新时间 +} + +// MonitorStatsResult 监控统计结果(所有探针的聚合数据) +type MonitorStatsResult struct { + Status string `json:"status"` // 聚合状态(up/down/unknown) + ResponseTime int64 `json:"responseTime"` // 当前平均响应时间(ms) + CertExpiryDate int64 `json:"certExpiryDate,omitempty"` // 最早过期的证书时间(毫秒时间戳) + CertExpiryDays int `json:"certExpiryDays,omitempty"` // 证书剩余天数 + AgentCount int `json:"agentCount"` // 探针数量 + LastCheckTime int64 `json:"lastCheckTime"` // 最后检测时间(毫秒时间戳) +} diff --git a/internal/protocol/message.go b/internal/protocol/message.go index 0cd6e3d..e105d62 100644 --- a/internal/protocol/message.go +++ b/internal/protocol/message.go @@ -288,7 +288,8 @@ type Evidence struct { // MonitorData 监控数据 type MonitorData struct { - ID string `json:"id"` // 监控项ID + AgentId string `json:"agentId"` // 探针 ID + MonitorId string `json:"monitorId"` // 监控项ID Type string `json:"type"` // 监控类型: http, tcp Target string `json:"target"` // 监控目标 Status string `json:"status"` // 状态: up, down @@ -301,6 +302,7 @@ type MonitorData struct { // TLS 证书信息(仅用于 HTTPS) CertExpiryTime int64 `json:"certExpiryTime,omitempty"` // 证书过期时间(毫秒时间戳) CertDaysLeft int `json:"certDaysLeft,omitempty"` // 证书剩余天数 + Timestamp int64 `json:"timestamp"` // 检测时间(毫秒时间戳) } // TamperProtectConfig 防篡改保护配置(增量更新) diff --git a/internal/repo/metric_repo.go b/internal/repo/metric_repo.go index 13e5054..f470ec8 100644 --- a/internal/repo/metric_repo.go +++ b/internal/repo/metric_repo.go @@ -29,133 +29,9 @@ func (r *MetricRepo) SaveHostMetric(ctx context.Context, metric *models.HostMetr } // DeleteAgentMetrics 删除指定探针的所有指标数据 -// TODO: 需要改为调用 VictoriaMetrics 的 DELETE API func (r *MetricRepo) DeleteAgentMetrics(ctx context.Context, agentID string) error { - // 1. 删除 PostgreSQL 中的主机信息 if err := r.db.WithContext(ctx).Where("agent_id = ?", agentID).Delete(&models.HostMetric{}).Error; err != nil { return err } - - // 2. TODO: 调用 VictoriaMetrics 删除时间序列数据 - // DELETE /api/v1/admin/tsdb/delete_series?match[]=pika_*{agent_id="xxx"} - return nil } - -// 以下是兼容性数据结构定义,用于返回查询结果 -// TODO: 后续从 VictoriaMetrics 查询时使用这些结构 - -// AggregatedCPUMetric CPU聚合指标 -type AggregatedCPUMetric struct { - Timestamp int64 `json:"timestamp"` - MaxUsage float64 `json:"maxUsage"` - LogicalCores int `json:"logicalCores"` -} - -// AggregatedMemoryMetric 内存聚合指标 -type AggregatedMemoryMetric struct { - Timestamp int64 `json:"timestamp"` - MaxUsage float64 `json:"maxUsage"` - Total uint64 `json:"total"` -} - -// AggregatedDiskMetric 磁盘聚合指标 -type AggregatedDiskMetric struct { - Timestamp int64 `json:"timestamp"` - MountPoint string `json:"mountPoint"` - MaxUsage float64 `json:"maxUsage"` - Total uint64 `json:"total"` -} - -// AggregatedNetworkMetric 网络聚合指标 -type AggregatedNetworkMetric struct { - Timestamp int64 `json:"timestamp"` - Interface string `json:"interface"` - MaxSentRate float64 `json:"maxSentRate"` - MaxRecvRate float64 `json:"maxRecvRate"` -} - -// AggregatedNetworkConnectionMetric 网络连接统计聚合指标 -type AggregatedNetworkConnectionMetric struct { - Timestamp int64 `json:"timestamp"` - 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"` -} - -// AggregatedDiskIOMetric 磁盘IO聚合指标 -type AggregatedDiskIOMetric struct { - Timestamp int64 `json:"timestamp"` - MaxReadRate float64 `json:"maxReadRate"` - MaxWriteRate float64 `json:"maxWriteRate"` - TotalReadBytes uint64 `json:"totalReadBytes"` - TotalWriteBytes uint64 `json:"totalWriteBytes"` - MaxIopsInProgress uint64 `json:"maxIopsInProgress"` -} - -// AggregatedGPUMetric GPU聚合指标 -type AggregatedGPUMetric struct { - Timestamp int64 `json:"timestamp"` - Index int `json:"index"` - Name string `json:"name"` - MaxUtilization float64 `json:"maxUtilization"` - MaxMemoryUsed uint64 `json:"maxMemoryUsed"` - MaxTemperature float64 `json:"maxTemperature"` - MaxPowerDraw float64 `json:"maxPowerDraw"` - MemoryTotal uint64 `json:"memoryTotal"` -} - -// AggregatedTemperatureMetric 温度聚合指标 -type AggregatedTemperatureMetric struct { - Timestamp int64 `json:"timestamp"` - SensorKey string `json:"sensorKey"` - SensorLabel string `json:"sensorLabel"` - MaxTemperature float64 `json:"maxTemperature"` -} - -// AggregatedMonitorMetric 聚合的监控指标 -type AggregatedMonitorMetric struct { - Timestamp int64 `json:"timestamp"` - AgentID string `json:"agentId"` - AvgResponse float64 `json:"avgResponse"` - MaxResponse int64 `json:"maxResponse"` - MinResponse int64 `json:"minResponse"` - SuccessCount int64 `json:"successCount"` - TotalCount int64 `json:"totalCount"` - SuccessRate float64 `json:"successRate"` - LastStatus string `json:"lastStatus"` - LastErrorMsg string `json:"lastErrorMsg"` -} - -// 注意:监控相关的查询方法已迁移到 MetricService -// DeleteMonitorMetrics 方法已在 MetricService 中实现 - -// MonitorMetric 监控指标(与service层定义保持一致) -type MonitorMetric struct { - ID uint `json:"id"` - AgentId string `json:"agentId"` - MonitorId string `json:"monitorId"` - Type string `json:"type"` - Target string `json:"target"` - Status string `json:"status"` - StatusCode int `json:"statusCode"` - ResponseTime int64 `json:"responseTime"` - Error string `json:"error"` - Message string `json:"message"` - ContentMatch bool `json:"contentMatch"` - CertExpiryTime int64 `json:"certExpiryTime"` - CertDaysLeft int `json:"certDaysLeft"` - Timestamp int64 `json:"timestamp"` -} - -// 注意:所有指标数据已迁移到 VictoriaMetrics -// 查询方法已移至 MetricService,通过 VMClient 访问 diff --git a/internal/repo/monitor_repo.go b/internal/repo/monitor_repo.go index ab3db11..b6193b0 100644 --- a/internal/repo/monitor_repo.go +++ b/internal/repo/monitor_repo.go @@ -68,3 +68,14 @@ func (r *MonitorRepo) FindByEnabled(ctx context.Context, enabled bool) ([]models } return monitors, nil } + +// FindByEnabledAndType 查找所有启用的监控任务 +func (r *MonitorRepo) FindByEnabledAndType(ctx context.Context, enabled bool, typ string) ([]models.MonitorTask, error) { + var monitors []models.MonitorTask + if err := r.GetDB(ctx). + Where("enabled = ? and type = ?", enabled, typ). + Find(&monitors).Error; err != nil { + return nil, err + } + return monitors, nil +} diff --git a/internal/service/alert_service.go b/internal/service/alert_service.go index 2c55e5a..30e69fe 100644 --- a/internal/service/alert_service.go +++ b/internal/service/alert_service.go @@ -6,6 +6,7 @@ import ( "time" "github.com/dushixiang/pika/internal/models" + "github.com/dushixiang/pika/internal/protocol" "github.com/dushixiang/pika/internal/repo" "github.com/go-orz/orz" "go.uber.org/zap" @@ -18,19 +19,19 @@ type AlertService struct { AlertRecordRepo *repo.AlertRecordRepo AlertStateRepo *repo.AlertStateRepo agentRepo *repo.AgentRepo - metricService *MetricService + monitorService *MonitorService propertyService *PropertyService notifier *Notifier logger *zap.Logger } -func NewAlertService(logger *zap.Logger, db *gorm.DB, propertyService *PropertyService, metricService *MetricService, notifier *Notifier) *AlertService { +func NewAlertService(logger *zap.Logger, db *gorm.DB, propertyService *PropertyService, monitorService *MonitorService, notifier *Notifier) *AlertService { return &AlertService{ Service: orz.NewService(db), AlertRecordRepo: repo.NewAlertRecordRepo(db), AlertStateRepo: repo.NewAlertStateRepo(db), agentRepo: repo.NewAgentRepo(db), - metricService: metricService, + monitorService: monitorService, propertyService: propertyService, notifier: notifier, logger: logger, @@ -373,7 +374,7 @@ func (s *AlertService) CheckMonitorAlerts(ctx context.Context) error { func (s *AlertService) checkCertificateAlerts(ctx context.Context, config *models.AlertConfig, now int64) error { // 获取所有最新的监控指标(仅HTTPS类型) // 这里需要查询最新的 monitor_metrics 记录,获取证书剩余天数 - monitors, err := s.metricService.GetLatestMonitorMetricsByType(ctx, "http") + monitors, err := s.monitorService.GetLatestMonitorMetricsByType(ctx, "http") if err != nil { return err } @@ -407,7 +408,7 @@ func (s *AlertService) checkCertificateAlerts(ctx context.Context, config *model } // checkCertAlert 检查并触发证书告警 -func (s *AlertService) checkCertAlert(ctx context.Context, config *models.AlertConfig, agent *models.Agent, monitor *repo.MonitorMetric, certDaysLeft float64, now int64) { +func (s *AlertService) checkCertAlert(ctx context.Context, config *models.AlertConfig, agent *models.Agent, monitor *protocol.MonitorData, certDaysLeft float64, now int64) { stateKey := fmt.Sprintf("%s:global:cert:%s", agent.ID, monitor.MonitorId) // 从数据库加载状态 @@ -479,7 +480,7 @@ func (s *AlertService) checkCertAlert(ctx context.Context, config *models.AlertC } // resolveCertAlert 恢复证书告警 -func (s *AlertService) resolveCertAlert(ctx context.Context, config *models.AlertConfig, agent *models.Agent, monitor *repo.MonitorMetric, certDaysLeft float64) { +func (s *AlertService) resolveCertAlert(ctx context.Context, config *models.AlertConfig, agent *models.Agent, monitor *protocol.MonitorData, certDaysLeft float64) { stateKey := fmt.Sprintf("%s:global:cert:%s", agent.ID, monitor.MonitorId) state, err := s.AlertStateRepo.GetAlertState(ctx, stateKey) @@ -536,7 +537,7 @@ func (s *AlertService) calculateCertLevel(daysLeft float64) string { // checkServiceDownAlerts 检查服务下线告警 func (s *AlertService) checkServiceDownAlerts(ctx context.Context, config *models.AlertConfig, now int64) error { // 获取所有最新的监控指标 - monitors, err := s.metricService.GetAllLatestMonitorMetrics(ctx) + monitors, err := s.monitorService.GetAllLatestMonitorMetrics(ctx) if err != nil { return err } @@ -603,7 +604,7 @@ func (s *AlertService) checkServiceDownAlerts(ctx context.Context, config *model } // fireServiceDownAlert 触发服务下线告警 -func (s *AlertService) fireServiceDownAlert(ctx context.Context, config *models.AlertConfig, agent *models.Agent, monitor *repo.MonitorMetric, state *models.AlertState, now int64) { +func (s *AlertService) fireServiceDownAlert(ctx context.Context, config *models.AlertConfig, agent *models.Agent, monitor *protocol.MonitorData, state *models.AlertState, now int64) { s.logger.Info("触发服务下线告警", zap.String("agentId", agent.ID), zap.String("monitorId", monitor.MonitorId), @@ -641,7 +642,7 @@ func (s *AlertService) fireServiceDownAlert(ctx context.Context, config *models. } // resolveServiceDownAlert 恢复服务下线告警 -func (s *AlertService) resolveServiceDownAlert(ctx context.Context, config *models.AlertConfig, agent *models.Agent, monitor *repo.MonitorMetric, state *models.AlertState) { +func (s *AlertService) resolveServiceDownAlert(ctx context.Context, config *models.AlertConfig, agent *models.Agent, monitor *protocol.MonitorData, state *models.AlertState) { s.logger.Info("服务下线告警恢复", zap.String("agentId", agent.ID), zap.String("monitorId", monitor.MonitorId), diff --git a/internal/service/metric_converter.go b/internal/service/metric_converter.go index 66b94df..b5d9630 100644 --- a/internal/service/metric_converter.go +++ b/internal/service/metric_converter.go @@ -96,35 +96,11 @@ func (s *MetricService) convertToMetrics(agentID string, metricType string, data monitorDataList := data.([]protocol.MonitorData) for _, monitorData := range monitorDataList { labels := map[string]string{ - "monitor_id": monitorData.ID, + "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)) - - // 状态: up=1, down=0 - statusValue := 0.0 - if monitorData.Status == "up" { - statusValue = 1.0 - } - statusLabels := map[string]string{ - "monitor_id": monitorData.ID, - "monitor_type": monitorData.Type, - "target": monitorData.Target, - "status": monitorData.Status, - } - metrics = append(metrics, createMetric("pika_monitor_status", agentID, statusLabels, statusValue, timestamp)) - - // 状态码 - if monitorData.StatusCode > 0 { - metrics = append(metrics, createMetric("pika_monitor_status_code", agentID, labels, float64(monitorData.StatusCode), timestamp)) - } - - // 证书信息 - if monitorData.CertExpiryTime > 0 { - metrics = append(metrics, createMetric("pika_monitor_cert_expiry_timestamp_ms", agentID, labels, float64(monitorData.CertExpiryTime), timestamp)) - metrics = append(metrics, createMetric("pika_monitor_cert_days_left", agentID, labels, float64(monitorData.CertDaysLeft), timestamp)) - } } } diff --git a/internal/service/metric_service.go b/internal/service/metric_service.go index 21e7222..3668c88 100644 --- a/internal/service/metric_service.go +++ b/internal/service/metric_service.go @@ -7,6 +7,7 @@ import ( "strconv" "time" + "github.com/dushixiang/pika/internal/metric" "github.com/dushixiang/pika/internal/models" "github.com/dushixiang/pika/internal/protocol" "github.com/dushixiang/pika/internal/repo" @@ -17,34 +18,6 @@ import ( "gorm.io/gorm" ) -// MetricDataPoint 统一的指标数据点结构 -type MetricDataPoint struct { - Timestamp int64 `json:"timestamp"` // 毫秒时间戳 - Value float64 `json:"value"` -} - -// MetricSeries 指标系列(支持多系列,如多网卡、多传感器) -type MetricSeries struct { - Name string `json:"name"` // 系列名称 - Labels map[string]string `json:"labels,omitempty"` // 额外标签 - Data []MetricDataPoint `json:"data"` // 数据点列表 -} - -// GetMetricsResponse 统一的查询响应格式 -type GetMetricsResponse struct { - AgentID string `json:"agentId"` - Type string `json:"type"` - Range string `json:"range"` - Series []MetricSeries `json:"series"` -} - -// QueryDefinition 查询定义(用于构建多个查询) -type QueryDefinition struct { - Name string // 系列名称 - Query string // PromQL 查询语句 - Labels map[string]string // 额外标签 -} - // MetricService 指标服务 type MetricService struct { logger *zap.Logger @@ -53,18 +26,21 @@ type MetricService struct { trafficService *TrafficService // 流量统计服务 vmClient *vmclient.VMClient - latestCache cache.Cache[string, *LatestMetrics] + latestCache cache.Cache[string, *metric.LatestMetrics] // Agent 最新指标缓存 + + monitorLatestCache cache.Cache[string, *metric.LatestMonitorMetrics] // 监控最新指标缓存 } // NewMetricService 创建指标服务 func NewMetricService(logger *zap.Logger, db *gorm.DB, propertyService *PropertyService, trafficService *TrafficService, vmClient *vmclient.VMClient) *MetricService { return &MetricService{ - logger: logger, - metricRepo: repo.NewMetricRepo(db), - propertyService: propertyService, - trafficService: trafficService, - vmClient: vmClient, - latestCache: cache.New[string, *LatestMetrics](time.Minute), + logger: logger, + metricRepo: repo.NewMetricRepo(db), + propertyService: propertyService, + trafficService: trafficService, + vmClient: vmClient, + latestCache: cache.New[string, *metric.LatestMetrics](time.Minute), + monitorLatestCache: cache.New[string, *metric.LatestMonitorMetrics](5 * time.Minute), // 监控数据缓存 5 分钟 } } @@ -75,7 +51,7 @@ func (s *MetricService) HandleMetricData(ctx context.Context, agentID string, me // 更新内存缓存 latestMetrics, ok := s.latestCache.Get(agentID) if !ok { - latestMetrics = &LatestMetrics{} + latestMetrics = &metric.LatestMetrics{} s.latestCache.Set(agentID, latestMetrics, time.Hour) } @@ -86,15 +62,7 @@ func (s *MetricService) HandleMetricData(ctx context.Context, agentID string, me if err := json.Unmarshal(data, &cpuData); err != nil { return err } - metric := &CPUMetric{ - AgentID: agentID, - UsagePercent: cpuData.UsagePercent, - LogicalCores: cpuData.LogicalCores, - PhysicalCores: cpuData.PhysicalCores, - ModelName: cpuData.ModelName, - Timestamp: now, - } - latestMetrics.CPU = metric + latestMetrics.CPU = &cpuData metrics := s.convertToMetrics(agentID, metricType, &cpuData, now) return s.vmClient.Write(ctx, metrics) @@ -103,19 +71,7 @@ func (s *MetricService) HandleMetricData(ctx context.Context, agentID string, me if err := json.Unmarshal(data, &memData); err != nil { return err } - metric := &MemoryMetric{ - AgentID: agentID, - Total: memData.Total, - Used: memData.Used, - Free: memData.Free, - Available: memData.Available, - UsagePercent: memData.UsagePercent, - SwapTotal: memData.SwapTotal, - SwapUsed: memData.SwapUsed, - SwapFree: memData.SwapFree, - Timestamp: now, - } - latestMetrics.Memory = metric + latestMetrics.Memory = &memData metrics := s.convertToMetrics(agentID, metricType, &memData, now) return s.vmClient.Write(ctx, metrics) @@ -135,7 +91,7 @@ func (s *MetricService) HandleMetricData(ctx context.Context, agentID string, me if totalTotal > 0 { usagePercent = float64(totalUsed) / float64(totalTotal) * 100 } - latestMetrics.Disk = &DiskSummary{ + latestMetrics.Disk = &metric.DiskSummary{ UsagePercent: usagePercent, TotalDisks: len(diskDataList), Total: totalTotal, @@ -159,7 +115,7 @@ func (s *MetricService) HandleMetricData(ctx context.Context, agentID string, me totalSentTotal += netData.BytesSentTotal totalRecvTotal += netData.BytesRecvTotal } - latestMetrics.Network = &NetworkSummary{ + latestMetrics.Network = &metric.NetworkSummary{ TotalBytesSentRate: totalSentRate, TotalBytesRecvRate: totalRecvRate, TotalBytesSentTotal: totalSentTotal, @@ -180,23 +136,7 @@ func (s *MetricService) HandleMetricData(ctx context.Context, agentID string, me if err := json.Unmarshal(data, &connData); err != nil { return err } - metric := &NetworkConnectionMetric{ - AgentID: agentID, - Established: connData.Established, - SynSent: connData.SynSent, - SynRecv: connData.SynRecv, - FinWait1: connData.FinWait1, - FinWait2: connData.FinWait2, - TimeWait: connData.TimeWait, - Close: connData.Close, - CloseWait: connData.CloseWait, - LastAck: connData.LastAck, - Listen: connData.Listen, - Closing: connData.Closing, - Total: connData.Total, - Timestamp: now, - } - latestMetrics.NetworkConnection = metric + latestMetrics.NetworkConnection = &connData metrics := s.convertToMetrics(agentID, metricType, &connData, now) return s.vmClient.Write(ctx, metrics) @@ -214,7 +154,7 @@ func (s *MetricService) HandleMetricData(ctx context.Context, agentID string, me return err } // Host 信息仍然保存到 PostgreSQL(静态信息,不频繁变化) - metric := &models.HostMetric{ + hostMetric := &models.HostMetric{ AgentID: agentID, OS: hostData.OS, Platform: hostData.Platform, @@ -226,8 +166,8 @@ func (s *MetricService) HandleMetricData(ctx context.Context, agentID string, me Procs: hostData.Procs, Timestamp: now, } - latestMetrics.Host = metric - return s.metricRepo.SaveHostMetric(ctx, metric) + latestMetrics.Host = hostMetric + return s.metricRepo.SaveHostMetric(ctx, hostMetric) case protocol.MetricTypeGPU: var gpuDataList []protocol.GPUData @@ -235,24 +175,7 @@ func (s *MetricService) HandleMetricData(ctx context.Context, agentID string, me return err } // 更新缓存 - var gpuMetrics []GPUMetric - for _, gpuData := range gpuDataList { - gpuMetrics = append(gpuMetrics, GPUMetric{ - AgentID: agentID, - Index: gpuData.Index, - Name: gpuData.Name, - Utilization: gpuData.Utilization, - MemoryTotal: gpuData.MemoryTotal, - MemoryUsed: gpuData.MemoryUsed, - MemoryFree: gpuData.MemoryFree, - Temperature: gpuData.Temperature, - PowerDraw: gpuData.PowerUsage, - FanSpeed: gpuData.FanSpeed, - PerformanceState: "", - Timestamp: now, - }) - } - latestMetrics.GPU = gpuMetrics + latestMetrics.GPU = gpuDataList metrics := s.convertToMetrics(agentID, metricType, gpuDataList, now) return s.vmClient.Write(ctx, metrics) @@ -262,21 +185,7 @@ func (s *MetricService) HandleMetricData(ctx context.Context, agentID string, me return err } // 更新缓存 - var tempMetrics []TemperatureMetric - for _, tempData := range tempDataList { - sensorLabel := tempData.Type - if sensorLabel == "" { - sensorLabel = tempData.SensorKey - } - tempMetrics = append(tempMetrics, TemperatureMetric{ - AgentID: agentID, - SensorKey: tempData.SensorKey, - SensorLabel: sensorLabel, - Temperature: tempData.Temperature, - Timestamp: now, - }) - } - latestMetrics.Temp = tempMetrics + latestMetrics.Temp = tempDataList metrics := s.convertToMetrics(agentID, metricType, tempDataList, now) return s.vmClient.Write(ctx, metrics) @@ -285,18 +194,27 @@ func (s *MetricService) HandleMetricData(ctx context.Context, agentID string, me if err := json.Unmarshal(data, &monitorDataList); err != nil { return err } + for i := range monitorDataList { + monitorDataList[i].AgentId = agentID // 关联探针ID + } + // 更新缓存 + latestMetrics.Monitors = monitorDataList + for _, monitorData := range monitorDataList { + s.updateMonitorCache(agentID, &monitorData, now) + } + metrics := s.convertToMetrics(agentID, metricType, monitorDataList, now) return s.vmClient.Write(ctx, metrics) default: - s.logger.Warn("unknown metric type", zap.String("type", metricType)) + s.logger.Warn("unknown cpiMetric type", zap.String("type", metricType)) return nil } } // GetMetrics 获取聚合指标数据(从 VictoriaMetrics 查询) // 返回统一的 GetMetricsResponse 格式 -func (s *MetricService) GetMetrics(ctx context.Context, agentID, metricType string, start, end int64, interfaceName string) (*GetMetricsResponse, error) { +func (s *MetricService) GetMetrics(ctx context.Context, agentID, metricType string, start, end int64, interfaceName string) (*metric.GetMetricsResponse, error) { // 构造 PromQL 查询(返回多个查询以支持多系列) queries := s.buildPromQLQueries(agentID, metricType, interfaceName) if len(queries) == 0 { @@ -305,7 +223,7 @@ func (s *MetricService) GetMetrics(ctx context.Context, agentID, metricType stri // 执行查询并转换结果 // step 设为 0,让 VictoriaMetrics 自动选择合适的步长 - var series []MetricSeries + var series []metric.Series for _, q := range queries { result, err := s.vmClient.QueryRange(ctx, q.Query, @@ -324,7 +242,7 @@ func (s *MetricService) GetMetrics(ctx context.Context, agentID, metricType stri series = append(series, convertedSeries...) } - return &GetMetricsResponse{ + return &metric.GetMetricsResponse{ AgentID: agentID, Type: metricType, Range: fmt.Sprintf("%d-%d", start, end), @@ -332,24 +250,31 @@ func (s *MetricService) GetMetrics(ctx context.Context, agentID, metricType stri }, nil } -// alignTimeRangeToBucket 将时间范围对齐到桶边界,确保不同时间框架的桶数一致 -func alignTimeRangeToBucket(start, end int64, bucketMs int64) (int64, int64) { - if bucketMs <= 0 { - return start, end +// updateMonitorCache 更新监控数据缓存 +func (s *MetricService) updateMonitorCache(agentID string, monitorData *protocol.MonitorData, timestamp int64) { + monitorID := monitorData.MonitorId + + // 获取或创建监控缓存 + latestMetrics, ok := s.monitorLatestCache.Get(monitorID) + if !ok { + latestMetrics = &metric.LatestMonitorMetrics{ + MonitorID: monitorID, + Agents: make(map[string]*protocol.MonitorData), + } } - alignedStart := (start / bucketMs) * bucketMs - endBucket := ((end - 1) / bucketMs) * bucketMs - alignedEnd := endBucket + bucketMs - 1 - if alignedEnd < alignedStart { - alignedEnd = alignedStart - } - return alignedStart, alignedEnd + + // 更新探针数据 + latestMetrics.Agents[agentID] = monitorData + latestMetrics.UpdatedAt = timestamp + + // 保存到缓存(5分钟过期) + s.monitorLatestCache.Set(monitorID, latestMetrics, 5*time.Minute) } // GetLatestMetrics 获取最新指标 -func (s *MetricService) GetLatestMetrics(ctx context.Context, agentID string) (*LatestMetrics, error) { - metrics, _ := s.latestCache.Get(agentID) - return metrics, nil +func (s *MetricService) GetLatestMetrics(agentID string) (*metric.LatestMetrics, bool) { + metrics, ok := s.latestCache.Get(agentID) + return metrics, ok } // DeleteAgentMetrics 删除探针的所有指标数据 @@ -415,143 +340,25 @@ func (s *MetricService) GetAvailableNetworkInterfaces(ctx context.Context, agent return interfaces, nil } -// ===== 内存缓存使用的本地模型定义 ===== -// 注意:这些模型仅用于内存缓存,不再保存到 PostgreSQL - -// CPUMetric CPU指标(内存缓存) -type CPUMetric struct { - AgentID string `json:"agentId"` - UsagePercent float64 `json:"usagePercent"` - LogicalCores int `json:"logicalCores"` - PhysicalCores int `json:"physicalCores"` - ModelName string `json:"modelName"` - Timestamp int64 `json:"timestamp"` -} - -// MemoryMetric 内存指标(内存缓存) -type MemoryMetric struct { - AgentID string `json:"agentId"` - 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 `json:"timestamp"` -} - -// NetworkConnectionMetric 网络连接统计指标(内存缓存) -type NetworkConnectionMetric struct { - AgentID string `json:"agentId"` - Established uint32 `json:"established"` - SynSent uint32 `json:"synSent"` - SynRecv uint32 `json:"synRecv"` - FinWait1 uint32 `json:"finWait1"` - FinWait2 uint32 `json:"finWait2"` - TimeWait uint32 `json:"timeWait"` - Close uint32 `json:"close"` - CloseWait uint32 `json:"closeWait"` - LastAck uint32 `json:"lastAck"` - Listen uint32 `json:"listen"` - Closing uint32 `json:"closing"` - Total uint32 `json:"total"` - Timestamp int64 `json:"timestamp"` -} - -// GPUMetric GPU指标(内存缓存) -type GPUMetric struct { - AgentID string `json:"agentId"` - Index int `json:"index"` - Name string `json:"name"` - Utilization float64 `json:"utilization"` - 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 `json:"timestamp"` -} - -// TemperatureMetric 温度指标(内存缓存) -type TemperatureMetric struct { - AgentID string `json:"agentId"` - SensorKey string `json:"sensorKey"` - SensorLabel string `json:"sensorLabel"` - Temperature float64 `json:"temperature"` - Timestamp int64 `json:"timestamp"` -} - -// MonitorMetric 监控指标 -type MonitorMetric struct { - ID uint `json:"id"` - AgentId string `json:"agentId"` - MonitorId string `json:"monitorId"` - Type string `json:"type"` - Target string `json:"target"` - Status string `json:"status"` - StatusCode int `json:"statusCode"` - ResponseTime int64 `json:"responseTime"` - Error string `json:"error"` - Message string `json:"message"` - ContentMatch bool `json:"contentMatch"` - CertExpiryTime int64 `json:"certExpiryTime"` - CertDaysLeft int `json:"certDaysLeft"` - Timestamp int64 `json:"timestamp"` -} - -// DiskSummary 磁盘汇总数据 -type DiskSummary struct { - UsagePercent float64 `json:"usagePercent"` // 平均使用率 - TotalDisks int `json:"totalDisks"` // 磁盘数量 - Total uint64 `json:"total"` // 总容量(字节) - Used uint64 `json:"used"` // 已使用(字节) - Free uint64 `json:"free"` // 空闲(字节) -} - -// NetworkSummary 网络汇总数据 -type NetworkSummary struct { - TotalBytesSentRate uint64 `json:"totalBytesSentRate"` // 总发送速率(字节/秒) - TotalBytesRecvRate uint64 `json:"totalBytesRecvRate"` // 总接收速率(字节/秒) - TotalBytesSentTotal uint64 `json:"totalBytesSentTotal"` // 累计总发送流量 - TotalBytesRecvTotal uint64 `json:"totalBytesRecvTotal"` // 累计总接收流量 - TotalInterfaces int `json:"totalInterfaces"` // 网卡数量 -} - -// LatestMetrics 最新指标数据(用于API响应) -type LatestMetrics struct { - CPU *CPUMetric `json:"cpu,omitempty"` - Memory *MemoryMetric `json:"memory,omitempty"` - Disk *DiskSummary `json:"disk,omitempty"` - Network *NetworkSummary `json:"network,omitempty"` - NetworkConnection *NetworkConnectionMetric `json:"networkConnection,omitempty"` - Host *models.HostMetric `json:"host,omitempty"` - GPU []GPUMetric `json:"gpu,omitempty"` - Temp []TemperatureMetric `json:"temperature,omitempty"` -} - // buildPromQLQueries 构造 PromQL 查询列表(支持多系列) -func (s *MetricService) buildPromQLQueries(agentID, metricType string, interfaceName string) []QueryDefinition { - var queries []QueryDefinition +func (s *MetricService) buildPromQLQueries(agentID, metricType string, interfaceName string) []metric.QueryDefinition { + var queries []metric.QueryDefinition switch metricType { case "cpu": - queries = []QueryDefinition{{ + queries = []metric.QueryDefinition{{ Name: "usage", Query: fmt.Sprintf(`pika_cpu_usage_percent{agent_id="%s"}`, agentID), }} case "memory": - queries = []QueryDefinition{{ + queries = []metric.QueryDefinition{{ Name: "usage", Query: fmt.Sprintf(`pika_memory_usage_percent{agent_id="%s"}`, agentID), }} case "disk": - queries = []QueryDefinition{{ + queries = []metric.QueryDefinition{{ Name: "usage", Query: fmt.Sprintf(`pika_disk_usage_percent{agent_id="%s",mount_point=""}`, agentID), }} @@ -560,7 +367,7 @@ func (s *MetricService) buildPromQLQueries(agentID, metricType string, interface // 网络流量:上行和下行 if interfaceName != "" && interfaceName != "all" { // 指定网卡 - queries = []QueryDefinition{ + queries = []metric.QueryDefinition{ { Name: "upload", Query: fmt.Sprintf(`pika_network_sent_bytes_rate{agent_id="%s",interface="%s"}`, agentID, interfaceName), @@ -574,7 +381,7 @@ func (s *MetricService) buildPromQLQueries(agentID, metricType string, interface } } else { // 所有网卡汇总 - queries = []QueryDefinition{ + queries = []metric.QueryDefinition{ { Name: "upload", Query: fmt.Sprintf(`sum(pika_network_sent_bytes_rate{agent_id="%s"}) by (agent_id)`, agentID), @@ -588,7 +395,7 @@ func (s *MetricService) buildPromQLQueries(agentID, metricType string, interface case "network_connection": // 网络连接统计:多个状态 - queries = []QueryDefinition{ + queries = []metric.QueryDefinition{ {Name: "established", Query: fmt.Sprintf(`pika_network_conn_established{agent_id="%s"}`, agentID)}, {Name: "time_wait", Query: fmt.Sprintf(`pika_network_conn_time_wait{agent_id="%s"}`, agentID)}, {Name: "close_wait", Query: fmt.Sprintf(`pika_network_conn_close_wait{agent_id="%s"}`, agentID)}, @@ -597,14 +404,14 @@ func (s *MetricService) buildPromQLQueries(agentID, metricType string, interface case "disk_io": // 磁盘 IO:读和写 - queries = []QueryDefinition{ + queries = []metric.QueryDefinition{ {Name: "read", Query: fmt.Sprintf(`pika_disk_read_bytes_rate{agent_id="%s"}`, agentID)}, {Name: "write", Query: fmt.Sprintf(`pika_disk_write_bytes_rate{agent_id="%s"}`, agentID)}, } case "gpu": // GPU:利用率和温度(按 GPU 分组) - queries = []QueryDefinition{ + queries = []metric.QueryDefinition{ { Name: "utilization", Query: fmt.Sprintf(`pika_gpu_utilization_percent{agent_id="%s"}`, agentID), @@ -617,7 +424,7 @@ func (s *MetricService) buildPromQLQueries(agentID, metricType string, interface case "temperature": // 温度:按传感器类型分组 - queries = []QueryDefinition{{ + queries = []metric.QueryDefinition{{ Name: "temperature", Query: fmt.Sprintf(`pika_temperature_celsius{agent_id="%s"}`, agentID), }} @@ -627,17 +434,17 @@ func (s *MetricService) buildPromQLQueries(agentID, metricType string, interface } // convertQueryResultToSeries 将 VictoriaMetrics 查询结果转换为 MetricSeries -func (s *MetricService) convertQueryResultToSeries(result *vmclient.QueryResult, seriesName string, extraLabels map[string]string) []MetricSeries { +func (s *MetricService) convertQueryResultToSeries(result *vmclient.QueryResult, seriesName string, extraLabels map[string]string) []metric.Series { if result == nil || len(result.Data.Result) == 0 { - return []MetricSeries{} + return nil } - var allSeries []MetricSeries + var allSeries []metric.Series // 遍历所有时间序列 for _, timeSeries := range result.Data.Result { // 提取数据点 - var dataPoints []MetricDataPoint + var dataPoints []metric.DataPoint for _, valueArray := range timeSeries.Values { if len(valueArray) != 2 { continue @@ -654,7 +461,7 @@ func (s *MetricService) convertQueryResultToSeries(result *vmclient.QueryResult, } value, _ := strconv.ParseFloat(valueStr, 64) - dataPoints = append(dataPoints, MetricDataPoint{ + dataPoints = append(dataPoints, metric.DataPoint{ Timestamp: int64(timestamp * 1000), // 转换为毫秒 Value: value, }) @@ -683,7 +490,7 @@ func (s *MetricService) convertQueryResultToSeries(result *vmclient.QueryResult, delete(labels, "gpu_index") } - allSeries = append(allSeries, MetricSeries{ + allSeries = append(allSeries, metric.Series{ Name: finalName, Labels: labels, Data: dataPoints, @@ -693,174 +500,84 @@ func (s *MetricService) convertQueryResultToSeries(result *vmclient.QueryResult, return allSeries } -// ===== 监控查询相关 ===== - -// 监控查询类型常量 -const ( - MonitorQueryTypeCurrent = "current" // 当前状态 - MonitorQueryTypeStats24h = "stats_24h" // 24小时统计 - MonitorQueryTypeStats7d = "stats_7d" // 7天统计 - MonitorQueryTypeHistory = "history" // 历史趋势 -) - -// MonitorStatsResult 监控统计结果(所有探针的聚合数据) -type MonitorStatsResult struct { - Status string `json:"status"` // 聚合状态(up/down/unknown) - ResponseTime int64 `json:"responseTime"` // 当前平均响应时间(ms) - CertExpiryDate int64 `json:"certExpiryDate,omitempty"` // 最早过期的证书时间(毫秒时间戳) - CertExpiryDays int `json:"certExpiryDays,omitempty"` // 证书剩余天数 - AgentCount int `json:"agentCount"` // 探针数量 - LastCheckTime int64 `json:"lastCheckTime"` // 最后检测时间(毫秒时间戳) -} - -// AgentMonitorStat 单个探针的监控统计 -type AgentMonitorStat struct { - AgentID string `json:"agentID,omitempty"` - Status string `json:"status,omitempty"` // up/down/unknown - ResponseTime int64 `json:"responseTime,omitempty"` // 当前响应时间(ms) - LastCheckTime int64 `json:"lastCheckTime,omitempty"` // 最后检测时间(毫秒时间戳) - CertExpiryDate int64 `json:"certExpiryDate,omitempty"` // 证书过期时间(毫秒时间戳) - CertExpiryDays int `json:"certExpiryDays,omitempty"` // 证书剩余天数 -} - -// buildMonitorPromQLQueries 构建监控查询的 PromQL 语句(简化版:只查询当前状态) -func (s *MetricService) buildMonitorPromQLQueries(monitorID string, queryType string) []QueryDefinition { - var queries []QueryDefinition - - switch queryType { - case MonitorQueryTypeCurrent: - // 当前状态查询(即时查询)- 简化为只查询必要的指标 - queries = []QueryDefinition{ - {Name: "response_time", Query: fmt.Sprintf(`pika_monitor_response_time_ms{monitor_id="%s"}`, monitorID)}, - {Name: "status", Query: fmt.Sprintf(`pika_monitor_status{monitor_id="%s"}`, monitorID)}, - {Name: "cert_days", Query: fmt.Sprintf(`pika_monitor_cert_days_left{monitor_id="%s"}`, monitorID)}, - {Name: "cert_expiry", Query: fmt.Sprintf(`pika_monitor_cert_expiry_timestamp_ms{monitor_id="%s"}`, monitorID)}, - } - - case MonitorQueryTypeHistory: - // 历史趋势查询(范围查询) - queries = []QueryDefinition{ - {Name: "response_time", Query: fmt.Sprintf(`pika_monitor_response_time_ms{monitor_id="%s"}`, monitorID)}, - } +// buildMonitorPromQLQueries 构建监控查询的 PromQL 语句 +func (s *MetricService) buildMonitorPromQLQueries(monitorID string) []metric.QueryDefinition { + var queries = []metric.QueryDefinition{ + {Name: "response_time", Query: fmt.Sprintf(`pika_monitor_response_time_ms{monitor_id="%s"}`, monitorID)}, } - return queries } -// GetMonitorStats 获取监控任务的聚合统计数据(简化版:只查询当前状态) -func (s *MetricService) GetMonitorStats(ctx context.Context, monitorID string) (*MonitorStatsResult, error) { - result := &MonitorStatsResult{ +// GetMonitorHistory 获取监控任务的历史趋势数据 +func (s *MetricService) GetMonitorHistory(ctx context.Context, monitorID string, start, end int64) (*metric.GetMetricsResponse, error) { + queries := s.buildMonitorPromQLQueries(monitorID) + + var series []metric.Series + for _, q := range queries { + result, err := s.vmClient.QueryRange( + ctx, + q.Query, + time.UnixMilli(start), + time.UnixMilli(end), + 0, // 自动步长 + ) + if err != nil { + s.logger.Warn("查询历史趋势失败", zap.String("query", q.Name), zap.Error(err)) + continue + } + convertedSeries := s.convertQueryResultToSeries(result, q.Name, q.Labels) + series = append(series, convertedSeries...) + } + + return &metric.GetMetricsResponse{ + AgentID: "", // 监控查询不限定单个agent + Type: "monitor", + Range: fmt.Sprintf("%d-%d", start, end), + Series: series, + }, nil +} + +// GetMonitorAgentStats 获取监控任务各探针的统计数据(只从缓存读取) +func (s *MetricService) GetMonitorAgentStats(ctx context.Context, monitorID string) ([]protocol.MonitorData, bool) { + // 从缓存读取监控数据 + latestMetrics, ok := s.monitorLatestCache.Get(monitorID) + if !ok { + // 缓存不存在,返回空列表 + return nil, false + } + + // 转换为数组 + result := make([]protocol.MonitorData, 0, len(latestMetrics.Agents)) + for _, stat := range latestMetrics.Agents { + result = append(result, *stat) + } + + return result, true +} + +// GetMonitorStats 获取监控任务的聚合统计数据(只从缓存读取) +func (s *MetricService) GetMonitorStats(ctx context.Context, monitorID string) (*metric.MonitorStatsResult, error) { + // 从缓存读取监控数据 + latestMetrics, ok := s.monitorLatestCache.Get(monitorID) + if !ok { + // 缓存不存在,返回默认值 + return &metric.MonitorStatsResult{ + Status: "unknown", + }, nil + } + + // 聚合各探针数据 + return s.aggregateMonitorStats(latestMetrics), nil +} + +// aggregateMonitorStats 聚合各探针的监控数据 +func (s *MetricService) aggregateMonitorStats(latestMetrics *metric.LatestMonitorMetrics) *metric.MonitorStatsResult { + result := &metric.MonitorStatsResult{ Status: "unknown", } - // 查询当前状态(只需4个查询) - queries := s.buildMonitorPromQLQueries(monitorID, MonitorQueryTypeCurrent) - queryData := make(map[string]*vmclient.QueryResult) - for _, q := range queries { - queryResult, err := s.vmClient.Query(ctx, q.Query) - if err != nil { - s.logger.Warn("查询监控状态失败", zap.String("query", q.Name), zap.Error(err)) - continue - } - queryData[q.Name] = queryResult - } - - // 聚合各探针的数据 - agentStats := make(map[string]*AgentMonitorStat) - - // 处理响应时间 - if respResult, ok := queryData["response_time"]; ok && respResult != nil { - for _, ts := range respResult.Data.Result { - agentID := ts.Metric["agent_id"] - if agentID == "" { - continue - } - if _, exists := agentStats[agentID]; !exists { - agentStats[agentID] = &AgentMonitorStat{AgentID: agentID} - } - - if len(ts.Values) > 0 { - lastValue := ts.Values[len(ts.Values)-1] - if len(lastValue) >= 2 { - timestamp, _ := lastValue[0].(float64) - valueStr, _ := lastValue[1].(string) - var value float64 - fmt.Sscanf(valueStr, "%f", &value) - agentStats[agentID].ResponseTime = int64(value) - agentStats[agentID].LastCheckTime = int64(timestamp * 1000) - } - } - } - } - - // 处理状态 - if statusResult, ok := queryData["status"]; ok && statusResult != nil { - for _, ts := range statusResult.Data.Result { - agentID := ts.Metric["agent_id"] - status := ts.Metric["status"] - if agentID == "" { - continue - } - if _, exists := agentStats[agentID]; !exists { - agentStats[agentID] = &AgentMonitorStat{AgentID: agentID} - } - - if len(ts.Values) > 0 { - if status != "" { - agentStats[agentID].Status = status - } - } - } - } - - // 处理证书信息 - if certDaysResult, ok := queryData["cert_days"]; ok && certDaysResult != nil { - for _, ts := range certDaysResult.Data.Result { - agentID := ts.Metric["agent_id"] - if agentID == "" { - continue - } - if _, exists := agentStats[agentID]; !exists { - agentStats[agentID] = &AgentMonitorStat{AgentID: agentID} - } - - if len(ts.Values) > 0 { - lastValue := ts.Values[len(ts.Values)-1] - if len(lastValue) >= 2 { - valueStr, _ := lastValue[1].(string) - var value float64 - fmt.Sscanf(valueStr, "%f", &value) - agentStats[agentID].CertExpiryDays = int(value) - } - } - } - } - - if certExpiryResult, ok := queryData["cert_expiry"]; ok && certExpiryResult != nil { - for _, ts := range certExpiryResult.Data.Result { - agentID := ts.Metric["agent_id"] - if agentID == "" { - continue - } - if _, exists := agentStats[agentID]; !exists { - agentStats[agentID] = &AgentMonitorStat{AgentID: agentID} - } - - if len(ts.Values) > 0 { - lastValue := ts.Values[len(ts.Values)-1] - if len(lastValue) >= 2 { - valueStr, _ := lastValue[1].(string) - var value float64 - fmt.Sscanf(valueStr, "%f", &value) - agentStats[agentID].CertExpiryDate = int64(value) - } - } - } - } - - // 聚合所有探针的数据 - if len(agentStats) == 0 { - return result, nil + if len(latestMetrics.Agents) == 0 { + return result } var totalResponseTime int64 @@ -871,11 +588,11 @@ func (s *MetricService) GetMonitorStats(ctx context.Context, monitorID string) ( var minCertExpiryDate int64 var minCertExpiryDays int - for _, stat := range agentStats { + for _, stat := range latestMetrics.Agents { totalResponseTime += stat.ResponseTime - if stat.LastCheckTime > lastCheckTime { - lastCheckTime = stat.LastCheckTime + if stat.Timestamp > lastCheckTime { + lastCheckTime = stat.Timestamp } if stat.Status == "up" { @@ -884,16 +601,16 @@ func (s *MetricService) GetMonitorStats(ctx context.Context, monitorID string) ( hasDown = true } - if stat.CertExpiryDate > 0 { - if !hasCert || stat.CertExpiryDate < minCertExpiryDate { - minCertExpiryDate = stat.CertExpiryDate - minCertExpiryDays = stat.CertExpiryDays + if stat.CertExpiryTime > 0 { + if !hasCert || stat.CertExpiryTime < minCertExpiryDate { + minCertExpiryDate = stat.CertExpiryTime + minCertExpiryDays = stat.CertDaysLeft hasCert = true } } } - count := len(agentStats) + count := len(latestMetrics.Agents) result.AgentCount = count if count > 0 { result.ResponseTime = totalResponseTime / int64(count) @@ -912,271 +629,5 @@ func (s *MetricService) GetMonitorStats(ctx context.Context, monitorID string) ( result.CertExpiryDays = minCertExpiryDays } - return result, nil -} - -// GetMonitorAgentStats 获取监控任务各探针的统计数据(简化版:只查询当前状态) -func (s *MetricService) GetMonitorAgentStats(ctx context.Context, monitorID string) ([]AgentMonitorStat, error) { - // 查询当前状态(只需4个查询) - queries := s.buildMonitorPromQLQueries(monitorID, MonitorQueryTypeCurrent) - queryData := make(map[string]*vmclient.QueryResult) - for _, q := range queries { - queryResult, err := s.vmClient.Query(ctx, q.Query) - if err != nil { - s.logger.Warn("查询监控状态失败", zap.String("query", q.Name), zap.Error(err)) - continue - } - queryData[q.Name] = queryResult - } - - // 提取每个探针的数据 - agentStatsMap := make(map[string]*AgentMonitorStat) - - // 处理响应时间 - if respResult, ok := queryData["response_time"]; ok && respResult != nil { - for _, ts := range respResult.Data.Result { - agentID := ts.Metric["agent_id"] - if agentID == "" { - continue - } - if _, exists := agentStatsMap[agentID]; !exists { - agentStatsMap[agentID] = &AgentMonitorStat{AgentID: agentID} - } - - if len(ts.Values) > 0 { - lastValue := ts.Values[len(ts.Values)-1] - if len(lastValue) >= 2 { - timestamp, _ := lastValue[0].(float64) - valueStr, _ := lastValue[1].(string) - var value float64 - fmt.Sscanf(valueStr, "%f", &value) - agentStatsMap[agentID].ResponseTime = int64(value) - agentStatsMap[agentID].LastCheckTime = int64(timestamp * 1000) - } - } - } - } - - // 处理状态 - if statusResult, ok := queryData["status"]; ok && statusResult != nil { - for _, ts := range statusResult.Data.Result { - agentID := ts.Metric["agent_id"] - status := ts.Metric["status"] - if agentID == "" { - continue - } - if _, exists := agentStatsMap[agentID]; !exists { - agentStatsMap[agentID] = &AgentMonitorStat{AgentID: agentID} - } - - if len(ts.Values) > 0 { - if status != "" { - agentStatsMap[agentID].Status = status - } - } - } - } - - // 处理证书信息 - if certDaysResult, ok := queryData["cert_days"]; ok && certDaysResult != nil { - for _, ts := range certDaysResult.Data.Result { - agentID := ts.Metric["agent_id"] - if agentID == "" { - continue - } - if _, exists := agentStatsMap[agentID]; !exists { - agentStatsMap[agentID] = &AgentMonitorStat{AgentID: agentID} - } - - if len(ts.Values) > 0 { - lastValue := ts.Values[len(ts.Values)-1] - if len(lastValue) >= 2 { - valueStr, _ := lastValue[1].(string) - var value float64 - fmt.Sscanf(valueStr, "%f", &value) - agentStatsMap[agentID].CertExpiryDays = int(value) - } - } - } - } - - if certExpiryResult, ok := queryData["cert_expiry"]; ok && certExpiryResult != nil { - for _, ts := range certExpiryResult.Data.Result { - agentID := ts.Metric["agent_id"] - if agentID == "" { - continue - } - if _, exists := agentStatsMap[agentID]; !exists { - agentStatsMap[agentID] = &AgentMonitorStat{AgentID: agentID} - } - - if len(ts.Values) > 0 { - lastValue := ts.Values[len(ts.Values)-1] - if len(lastValue) >= 2 { - valueStr, _ := lastValue[1].(string) - var value float64 - fmt.Sscanf(valueStr, "%f", &value) - agentStatsMap[agentID].CertExpiryDate = int64(value) - } - } - } - } - - // 转换为数组 - result := make([]AgentMonitorStat, 0, len(agentStatsMap)) - for _, stat := range agentStatsMap { - result = append(result, *stat) - } - - return result, nil -} - -// GetMonitorHistory 获取监控任务的历史趋势数据 -func (s *MetricService) GetMonitorHistory(ctx context.Context, monitorID string, start, end int64) (*GetMetricsResponse, error) { - queries := s.buildMonitorPromQLQueries(monitorID, MonitorQueryTypeHistory) - - var series []MetricSeries - for _, q := range queries { - result, err := s.vmClient.QueryRange( - ctx, - q.Query, - time.UnixMilli(start), - time.UnixMilli(end), - 0, // 自动步长 - ) - if err != nil { - s.logger.Warn("查询历史趋势失败", zap.String("query", q.Name), zap.Error(err)) - continue - } - convertedSeries := s.convertQueryResultToSeries(result, q.Name, q.Labels) - series = append(series, convertedSeries...) - } - - return &GetMetricsResponse{ - AgentID: "", // 监控查询不限定单个agent - Type: "monitor", - Range: fmt.Sprintf("%d-%d", start, end), - Series: series, - }, nil -} - -// GetLatestMonitorMetricsByType 获取指定类型的最新监控指标(用于告警检查) -func (s *MetricService) GetLatestMonitorMetricsByType(ctx context.Context, monitorType string) ([]repo.MonitorMetric, error) { - // 查询最新的监控状态(按 monitor_type 过滤) - query := fmt.Sprintf(`pika_monitor_status{monitor_type="%s"}`, monitorType) - result, err := s.vmClient.Query(ctx, query) - if err != nil { - return nil, fmt.Errorf("query latest monitor metrics failed: %w", err) - } - - metrics := make([]repo.MonitorMetric, 0) - for _, ts := range result.Data.Result { - metric := s.convertToMonitorMetric(ts) - metrics = append(metrics, metric) - } - - return metrics, nil -} - -// GetAllLatestMonitorMetrics 获取所有最新监控指标(用于告警检查) -func (s *MetricService) GetAllLatestMonitorMetrics(ctx context.Context) ([]repo.MonitorMetric, error) { - // 查询所有最新的监控状态 - query := `pika_monitor_status` - result, err := s.vmClient.Query(ctx, query) - if err != nil { - return nil, fmt.Errorf("query all latest monitor metrics failed: %w", err) - } - - metrics := make([]repo.MonitorMetric, 0) - for _, ts := range result.Data.Result { - metric := s.convertToMonitorMetric(ts) - metrics = append(metrics, metric) - } - - return metrics, nil -} - -// convertToMonitorMetric 将 VictoriaMetrics 查询结果转换为 MonitorMetric -func (s *MetricService) convertToMonitorMetric(ts vmclient.Result) repo.MonitorMetric { - metric := repo.MonitorMetric{ - AgentId: ts.Metric["agent_id"], - MonitorId: ts.Metric["monitor_id"], - Type: ts.Metric["monitor_type"], - Target: ts.Metric["target"], - Status: ts.Metric["status"], - } - - // 获取最新值(timestamp 和 status value) - if len(ts.Values) > 0 { - lastValue := ts.Values[len(ts.Values)-1] - if len(lastValue) >= 2 { - timestamp, _ := lastValue[0].(float64) - metric.Timestamp = int64(timestamp * 1000) // 转换为毫秒 - } - } - - // 查询其他相关指标(响应时间、状态码、证书信息等) - // 这里需要额外的查询来填充完整的 MonitorMetric 数据 - ctx := context.Background() - - // 查询响应时间 - respQuery := fmt.Sprintf(`pika_monitor_response_time_ms{agent_id="%s",monitor_id="%s"}`, metric.AgentId, metric.MonitorId) - if respResult, err := s.vmClient.Query(ctx, respQuery); err == nil && len(respResult.Data.Result) > 0 { - ts := respResult.Data.Result[0] - if len(ts.Values) > 0 { - lastValue := ts.Values[len(ts.Values)-1] - if len(lastValue) >= 2 { - valueStr, _ := lastValue[1].(string) - var value float64 - fmt.Sscanf(valueStr, "%f", &value) - metric.ResponseTime = int64(value) - } - } - } - - // 查询状态码 - statusCodeQuery := fmt.Sprintf(`pika_monitor_status_code{agent_id="%s",monitor_id="%s"}`, metric.AgentId, metric.MonitorId) - if statusCodeResult, err := s.vmClient.Query(ctx, statusCodeQuery); err == nil && len(statusCodeResult.Data.Result) > 0 { - ts := statusCodeResult.Data.Result[0] - if len(ts.Values) > 0 { - lastValue := ts.Values[len(ts.Values)-1] - if len(lastValue) >= 2 { - valueStr, _ := lastValue[1].(string) - var value float64 - fmt.Sscanf(valueStr, "%f", &value) - metric.StatusCode = int(value) - } - } - } - - // 查询证书信息 - certExpiryQuery := fmt.Sprintf(`pika_monitor_cert_expiry_timestamp_ms{agent_id="%s",monitor_id="%s"}`, metric.AgentId, metric.MonitorId) - if certResult, err := s.vmClient.Query(ctx, certExpiryQuery); err == nil && len(certResult.Data.Result) > 0 { - ts := certResult.Data.Result[0] - if len(ts.Values) > 0 { - lastValue := ts.Values[len(ts.Values)-1] - if len(lastValue) >= 2 { - valueStr, _ := lastValue[1].(string) - var value float64 - fmt.Sscanf(valueStr, "%f", &value) - metric.CertExpiryTime = int64(value) - } - } - } - - certDaysQuery := fmt.Sprintf(`pika_monitor_cert_days_left{agent_id="%s",monitor_id="%s"}`, metric.AgentId, metric.MonitorId) - if certDaysResult, err := s.vmClient.Query(ctx, certDaysQuery); err == nil && len(certDaysResult.Data.Result) > 0 { - ts := certDaysResult.Data.Result[0] - if len(ts.Values) > 0 { - lastValue := ts.Values[len(ts.Values)-1] - if len(lastValue) >= 2 { - valueStr, _ := lastValue[1].(string) - var value float64 - fmt.Sscanf(valueStr, "%f", &value) - metric.CertDaysLeft = int(value) - } - } - } - - return metric + return result } diff --git a/internal/service/monitor_service.go b/internal/service/monitor_service.go index c6b3b33..05fa8e3 100644 --- a/internal/service/monitor_service.go +++ b/internal/service/monitor_service.go @@ -7,6 +7,7 @@ import ( "strings" "time" + "github.com/dushixiang/pika/internal/metric" "github.com/dushixiang/pika/internal/models" "github.com/dushixiang/pika/internal/protocol" "github.com/dushixiang/pika/internal/repo" @@ -264,7 +265,7 @@ func (s *MonitorService) ListByAuth(ctx context.Context, isAuthenticated bool) ( } if len(monitors) == 0 { - emptyResult := []PublicMonitorOverview{} + var emptyResult []PublicMonitorOverview // 缓存空结果 s.overviewCache.Set(cacheKey, emptyResult, 5*time.Minute) return emptyResult, nil @@ -280,7 +281,7 @@ func (s *MonitorService) ListByAuth(ctx context.Context, isAuthenticated bool) ( s.logger.Error("查询 VictoriaMetrics 失败", zap.String("monitorID", monitor.ID), zap.Error(err)) - stats = &MonitorStatsResult{} + stats = &metric.MonitorStatsResult{} } // 将 MonitorStatsResult 转换为 monitorOverviewSummary @@ -495,7 +496,7 @@ func (s *MonitorService) GetMonitorStatsByID(ctx context.Context, monitorID stri if err != nil { s.logger.Error("查询 VictoriaMetrics 失败", zap.String("monitorID", monitorID), zap.Error(err)) // 失败时返回默认值,不中断 - stats = &MonitorStatsResult{} + stats = &metric.MonitorStatsResult{} } // 转换为 monitorOverviewSummary 格式 @@ -516,7 +517,7 @@ func (s *MonitorService) GetMonitorStatsByID(ctx context.Context, monitorID stri // GetMonitorAgentStats 获取监控任务各探针的统计数据(详细列表) // 直接返回 VictoriaMetrics 查询结果,无需额外转换 -func (s *MonitorService) GetMonitorAgentStats(ctx context.Context, monitorID string) ([]AgentMonitorStat, error) { +func (s *MonitorService) GetMonitorAgentStats(ctx context.Context, monitorID string) ([]protocol.MonitorData, bool) { // 直接返回 VictoriaMetrics 查询结果 return s.metricService.GetMonitorAgentStats(ctx, monitorID) } @@ -524,7 +525,7 @@ func (s *MonitorService) GetMonitorAgentStats(ctx context.Context, monitorID str // GetMonitorHistory 获取监控任务的历史时序数据 // 直接返回 VictoriaMetrics 的原始时序数据,包含所有探针的独立序列 // 支持时间范围:15m, 30m, 1h, 3h, 6h, 12h, 1d, 3d, 7d -func (s *MonitorService) GetMonitorHistory(ctx context.Context, monitorID, timeRange string) (*GetMetricsResponse, error) { +func (s *MonitorService) GetMonitorHistory(ctx context.Context, monitorID, timeRange string) (*metric.GetMetricsResponse, error) { // 计算时间范围 var duration time.Duration switch timeRange { @@ -586,3 +587,42 @@ func (s *MonitorService) clearCache(monitorID string) { s.overviewCache.Delete("overview:public") s.overviewCache.Delete("overview:private") } + +// GetLatestMonitorMetricsByType 获取指定类型的最新监控指标(用于告警检查) +func (s *MonitorService) GetLatestMonitorMetricsByType(ctx context.Context, monitorType string) ([]protocol.MonitorData, error) { + // 查询数据库 + monitorTasks, err := s.FindByEnabledAndType(ctx, true, monitorType) + if err != nil { + return nil, err + } + + // 在缓存中查询最新的监控数据 + var result []protocol.MonitorData + for _, task := range monitorTasks { + monitorData, ok := s.metricService.GetMonitorAgentStats(ctx, task.ID) + if ok { + result = append(result, monitorData...) + } + } + + return result, nil +} + +// GetAllLatestMonitorMetrics 获取所有最新监控指标(用于告警检查) +func (s *MonitorService) GetAllLatestMonitorMetrics(ctx context.Context) ([]protocol.MonitorData, error) { + // 查询所有最新的监控状态 + monitorTasks, err := s.FindByEnabled(ctx, true) + if err != nil { + return nil, err + } + + // 在缓存中查询最新的监控数据 + var result []protocol.MonitorData + for _, task := range monitorTasks { + monitorData, ok := s.metricService.GetMonitorAgentStats(ctx, task.ID) + if ok { + result = append(result, monitorData...) + } + } + return result, nil +} diff --git a/internal/wire_gen.go b/internal/wire_gen.go index 9089732..4a8ca9b 100644 --- a/internal/wire_gen.go +++ b/internal/wire_gen.go @@ -47,7 +47,7 @@ func InitializeApp(logger *zap.Logger, db *gorm.DB, cfg *config.AppConfig) (*App agentHandler := handler.NewAgentHandler(logger, agentService, metricService, monitorService, tamperService, ddnsService, manager) apiKeyHandler := handler.NewApiKeyHandler(logger, apiKeyService) notifier := service.NewNotifier(logger) - alertService := service.NewAlertService(logger, db, propertyService, metricService, notifier) + alertService := service.NewAlertService(logger, db, propertyService, monitorService, notifier) alertHandler := handler.NewAlertHandler(logger, alertService) propertyHandler := handler.NewPropertyHandler(logger, propertyService, notifier) monitorHandler := handler.NewMonitorHandler(logger, monitorService, agentService) diff --git a/pkg/agent/collector/monitor.go b/pkg/agent/collector/monitor.go index b0196d7..bcd2c26 100644 --- a/pkg/agent/collector/monitor.go +++ b/pkg/agent/collector/monitor.go @@ -64,7 +64,7 @@ func (c *MonitorCollector) Collect(items []protocol.MonitorItem) []protocol.Moni result = c.checkICMP(item) default: result = protocol.MonitorData{ - ID: item.ID, + MonitorId: item.ID, Type: item.Type, Target: item.Target, Status: "down", @@ -82,7 +82,7 @@ func (c *MonitorCollector) Collect(items []protocol.MonitorItem) []protocol.Moni // checkHTTP 检查 HTTP/HTTPS 服务 func (c *MonitorCollector) checkHTTP(item protocol.MonitorItem) protocol.MonitorData { result := protocol.MonitorData{ - ID: item.ID, + MonitorId: item.ID, Type: item.Type, Target: item.Target, CheckedAt: time.Now().UnixMilli(), @@ -204,7 +204,7 @@ func (c *MonitorCollector) checkHTTP(item protocol.MonitorItem) protocol.Monitor // checkTCP 检查 TCP 端口 func (c *MonitorCollector) checkTCP(item protocol.MonitorItem) protocol.MonitorData { result := protocol.MonitorData{ - ID: item.ID, + MonitorId: item.ID, Type: item.Type, Target: item.Target, CheckedAt: time.Now().UnixMilli(), @@ -239,7 +239,7 @@ func (c *MonitorCollector) checkTCP(item protocol.MonitorItem) protocol.MonitorD // checkICMP 检查 ICMP (Ping) func (c *MonitorCollector) checkICMP(item protocol.MonitorItem) protocol.MonitorData { result := protocol.MonitorData{ - ID: item.ID, + MonitorId: item.ID, Type: item.Type, Target: item.Target, CheckedAt: time.Now().UnixMilli(), From 31a9b6ea15d3e8fbae3605e2beb2a4de77ce9654 Mon Sep 17 00:00:00 2001 From: dushixiang Date: Sat, 13 Dec 2025 13:40:37 +0800 Subject: [PATCH 08/18] =?UTF-8?q?=E5=AE=8C=E6=88=90=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E7=9B=91=E6=8E=A7=E5=90=8E=E7=AB=AF=E9=87=8D=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- go.mod | 26 +-- go.sum | 26 +++ internal/handler/monitor_handler.go | 10 +- internal/metric/monitor.go | 22 ++- internal/protocol/message.go | 3 +- internal/service/alert_service.go | 2 +- internal/service/metric_service.go | 31 +-- internal/service/monitor_service.go | 132 +++---------- internal/wire_gen.go | 2 +- web/src/api/monitor.ts | 31 ++- web/src/pages/Public/Monitor.tsx | 62 ++++++ web/src/pages/Public/MonitorDetail.tsx | 152 +++----------- web/src/pages/Public/MonitorList.tsx | 264 +------------------------ web/src/types/index.ts | 76 +------ web/src/utils/util.ts | 28 +++ 15 files changed, 254 insertions(+), 613 deletions(-) create mode 100644 web/src/pages/Public/Monitor.tsx create mode 100644 web/src/utils/util.ts diff --git a/go.mod b/go.mod index e4472fd..ce29f53 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/dushixiang/pika -go 1.25.0 +go 1.25.5 require ( github.com/coreos/go-oidc/v3 v3.17.0 @@ -11,15 +11,15 @@ require ( github.com/go-orz/toolkit v0.1.0 github.com/go-playground/locales v0.14.1 github.com/go-playground/universal-translator v0.18.1 - github.com/go-playground/validator/v10 v10.28.0 + github.com/go-playground/validator/v10 v10.29.0 github.com/golang-jwt/jwt/v5 v5.3.0 github.com/google/uuid v1.6.0 github.com/google/wire v0.7.0 github.com/gorilla/websocket v1.5.3 github.com/jpillora/backoff v1.0.0 github.com/kardianos/service v1.2.4 - github.com/labstack/echo/v4 v4.13.4 - github.com/libdns/alidns v1.0.6-beta.2 + github.com/labstack/echo/v4 v4.14.0 + github.com/libdns/alidns v1.0.6-beta.3 github.com/libdns/cloudflare v0.2.2 github.com/libdns/huaweicloud v1.0.0 github.com/libdns/libdns v1.1.1 @@ -33,8 +33,8 @@ require ( github.com/spf13/cobra v1.10.2 github.com/valyala/fasttemplate v1.2.2 go.uber.org/zap v1.27.1 - golang.org/x/crypto v0.45.0 - golang.org/x/oauth2 v0.33.0 + golang.org/x/crypto v0.46.0 + golang.org/x/oauth2 v0.34.0 gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df gopkg.in/yaml.v3 v3.0.1 gorm.io/datatypes v1.2.7 @@ -46,7 +46,7 @@ require ( filippo.io/edwards25519 v1.1.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/ebitengine/purego v0.9.1 // indirect - github.com/gabriel-vasile/mimetype v1.4.11 // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/glebarez/go-sqlite v1.22.0 // indirect github.com/glebarez/sqlite v1.11.0 // indirect github.com/go-jose/go-jose/v4 v4.1.3 // indirect @@ -82,12 +82,12 @@ require ( github.com/yusufpapurcu/wmi v1.2.4 // indirect go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39 // indirect - golang.org/x/net v0.47.0 // indirect - golang.org/x/sync v0.18.0 // indirect - golang.org/x/sys v0.38.0 // indirect - golang.org/x/text v0.31.0 // indirect - golang.org/x/time v0.11.0 // indirect + golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/text v0.32.0 // indirect + golang.org/x/time v0.14.0 // indirect gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gorm.io/driver/mysql v1.6.0 // indirect diff --git a/go.sum b/go.sum index 4b6319f..a6e703e 100644 --- a/go.sum +++ b/go.sum @@ -19,6 +19,8 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik= github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ= github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc= github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= @@ -43,6 +45,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688= github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU= +github.com/go-playground/validator/v10 v10.29.0 h1:lQlF5VNJWNlRbRZNeOIkWElR+1LL/OuHcc0Kp14w1xk= +github.com/go-playground/validator/v10 v10.29.0/go.mod h1:D6QxqeMlgIPuT02L66f2ccrZ7AGgHkzKmmTMZhk/Kc4= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= @@ -90,12 +94,16 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA= github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ= +github.com/labstack/echo/v4 v4.14.0 h1:+tiMrDLxwv6u0oKtD03mv+V1vXXB3wCqPHJqPuIe+7M= +github.com/labstack/echo/v4 v4.14.0/go.mod h1:xmw1clThob0BSVRX1CRQkGQ/vjwcpOMjQZSZa9fKA/c= github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/libdns/alidns v1.0.6-beta.2 h1:cRhpaFGoQfGgsVWj+rpUdmbP74BCsH96tv3ZOwBPf38= github.com/libdns/alidns v1.0.6-beta.2/go.mod h1:RECwyQ88e9VqQVtSrvX76o1ux3gQUKGzMgxICi+u7Ec= +github.com/libdns/alidns v1.0.6-beta.3 h1:KAmb7FQ1tRzKsaAUGa7ZpGKAMRANwg7+1c7tUbSELq8= +github.com/libdns/alidns v1.0.6-beta.3/go.mod h1:RECwyQ88e9VqQVtSrvX76o1ux3gQUKGzMgxICi+u7Ec= github.com/libdns/cloudflare v0.2.2 h1:XWHv+C1dDcApqazlh08Q6pjytYLgR2a+Y3xrXFu0vsI= github.com/libdns/cloudflare v0.2.2/go.mod h1:w9uTmRCDlAoafAsTPnn2nJ0XHK/eaUMh86DUk8BWi60= github.com/libdns/huaweicloud v1.0.0 h1:BQUIkOAjF++ouiANRIE3jdMlCfeiAAr6tGqb+8hM1jo= @@ -182,18 +190,29 @@ golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWP golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39 h1:DHNhtq3sNNzrvduZZIiFyXWOL9IWaDPHqTnLJp+rCBY= golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0= +golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 h1:MDfG8Cvcqlt9XXrmEiD4epKn7VJHZO84hejP9Jmp0MM= +golang.org/x/exp v0.0.0-20251209150349-8475f28825e9/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU= golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= +golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo= golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -205,17 +224,24 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= +golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk= gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk= diff --git a/internal/handler/monitor_handler.go b/internal/handler/monitor_handler.go index 4bd5c3a..f210f9b 100644 --- a/internal/handler/monitor_handler.go +++ b/internal/handler/monitor_handler.go @@ -11,13 +11,15 @@ import ( type MonitorHandler struct { logger *zap.Logger monitorService *service.MonitorService + metricService *service.MetricService agentService *service.AgentService } -func NewMonitorHandler(logger *zap.Logger, monitorService *service.MonitorService, agentService *service.AgentService) *MonitorHandler { +func NewMonitorHandler(logger *zap.Logger, monitorService *service.MonitorService, metricService *service.MetricService, agentService *service.AgentService) *MonitorHandler { return &MonitorHandler{ logger: logger, monitorService: monitorService, + metricService: metricService, agentService: agentService, } } @@ -168,11 +170,7 @@ func (h *MonitorHandler) GetAgentStatsByID(c echo.Context) error { return err } - stats, ok := h.monitorService.GetMonitorAgentStats(ctx, id) - if !ok { - return orz.NewError(404, "监控任务不存在") - } - + stats := h.metricService.GetMonitorAgentStats(id) return orz.Ok(c, stats) } diff --git a/internal/metric/monitor.go b/internal/metric/monitor.go index 59c9003..c5c9965 100644 --- a/internal/metric/monitor.go +++ b/internal/metric/monitor.go @@ -13,8 +13,26 @@ type LatestMonitorMetrics struct { type MonitorStatsResult struct { Status string `json:"status"` // 聚合状态(up/down/unknown) ResponseTime int64 `json:"responseTime"` // 当前平均响应时间(ms) - CertExpiryDate int64 `json:"certExpiryDate,omitempty"` // 最早过期的证书时间(毫秒时间戳) - CertExpiryDays int `json:"certExpiryDays,omitempty"` // 证书剩余天数 + CertExpiryTime int64 `json:"certExpiryTime,omitempty"` // 证书过期时间(毫秒时间戳) + CertDaysLeft int `json:"certDaysLeft,omitempty"` // 证书剩余天数 AgentCount int `json:"agentCount"` // 探针数量 LastCheckTime int64 `json:"lastCheckTime"` // 最后检测时间(毫秒时间戳) } + +// PublicMonitorOverview 用于公开展示的监控配置及汇总数据 +type PublicMonitorOverview struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Target string `json:"target"` + ShowTargetPublic bool `json:"showTargetPublic"` // 在公开页面是否显示目标地址 + Description string `json:"description"` + Enabled bool `json:"enabled"` + Interval int `json:"interval"` + AgentCount int `json:"agentCount"` + Status string `json:"status"` // up/down/unknown + ResponseTime int64 `json:"responseTime"` // 当前响应时间(ms) + CertExpiryTime int64 `json:"certExpiryTime,omitempty"` // 证书过期时间(毫秒时间戳) + CertDaysLeft int `json:"certDaysLeft,omitempty"` // 证书剩余天数 + LastCheckTime int64 `json:"lastCheckTime"` // 最后检测时间 +} diff --git a/internal/protocol/message.go b/internal/protocol/message.go index e105d62..a292d02 100644 --- a/internal/protocol/message.go +++ b/internal/protocol/message.go @@ -291,7 +291,7 @@ type MonitorData struct { AgentId string `json:"agentId"` // 探针 ID MonitorId string `json:"monitorId"` // 监控项ID Type string `json:"type"` // 监控类型: http, tcp - Target string `json:"target"` // 监控目标 + Target string `json:"target,omitempty"` // 监控目标 Status string `json:"status"` // 状态: up, down StatusCode int `json:"statusCode,omitempty"` // HTTP 状态码 ResponseTime int64 `json:"responseTime"` // 响应时间(毫秒) @@ -302,7 +302,6 @@ type MonitorData struct { // TLS 证书信息(仅用于 HTTPS) CertExpiryTime int64 `json:"certExpiryTime,omitempty"` // 证书过期时间(毫秒时间戳) CertDaysLeft int `json:"certDaysLeft,omitempty"` // 证书剩余天数 - Timestamp int64 `json:"timestamp"` // 检测时间(毫秒时间戳) } // TamperProtectConfig 防篡改保护配置(增量更新) diff --git a/internal/service/alert_service.go b/internal/service/alert_service.go index 30e69fe..9c91f4c 100644 --- a/internal/service/alert_service.go +++ b/internal/service/alert_service.go @@ -571,7 +571,7 @@ func (s *AlertService) checkServiceDownAlerts(ctx context.Context, config *model if monitor.Status == "down" { if state.StartTime == 0 { - state.StartTime = monitor.Timestamp + state.StartTime = monitor.CheckedAt } elapsedSeconds := (now - state.StartTime) / 1000 diff --git a/internal/service/metric_service.go b/internal/service/metric_service.go index 3668c88..f6fbbe8 100644 --- a/internal/service/metric_service.go +++ b/internal/service/metric_service.go @@ -538,36 +538,37 @@ func (s *MetricService) GetMonitorHistory(ctx context.Context, monitorID string, } // GetMonitorAgentStats 获取监控任务各探针的统计数据(只从缓存读取) -func (s *MetricService) GetMonitorAgentStats(ctx context.Context, monitorID string) ([]protocol.MonitorData, bool) { +func (s *MetricService) GetMonitorAgentStats(monitorID string) []protocol.MonitorData { // 从缓存读取监控数据 latestMetrics, ok := s.monitorLatestCache.Get(monitorID) if !ok { // 缓存不存在,返回空列表 - return nil, false + return []protocol.MonitorData{} } // 转换为数组 result := make([]protocol.MonitorData, 0, len(latestMetrics.Agents)) for _, stat := range latestMetrics.Agents { + stat.Target = "" result = append(result, *stat) } - return result, true + return result } // GetMonitorStats 获取监控任务的聚合统计数据(只从缓存读取) -func (s *MetricService) GetMonitorStats(ctx context.Context, monitorID string) (*metric.MonitorStatsResult, error) { +func (s *MetricService) GetMonitorStats(monitorID string) *metric.MonitorStatsResult { // 从缓存读取监控数据 latestMetrics, ok := s.monitorLatestCache.Get(monitorID) if !ok { // 缓存不存在,返回默认值 return &metric.MonitorStatsResult{ Status: "unknown", - }, nil + } } // 聚合各探针数据 - return s.aggregateMonitorStats(latestMetrics), nil + return s.aggregateMonitorStats(latestMetrics) } // aggregateMonitorStats 聚合各探针的监控数据 @@ -585,14 +586,14 @@ func (s *MetricService) aggregateMonitorStats(latestMetrics *metric.LatestMonito hasUp := false hasDown := false hasCert := false - var minCertExpiryDate int64 - var minCertExpiryDays int + var minCertExpiryTime int64 + var minCertDaysLeft int for _, stat := range latestMetrics.Agents { totalResponseTime += stat.ResponseTime - if stat.Timestamp > lastCheckTime { - lastCheckTime = stat.Timestamp + if stat.CheckedAt > lastCheckTime { + lastCheckTime = stat.CheckedAt } if stat.Status == "up" { @@ -602,9 +603,9 @@ func (s *MetricService) aggregateMonitorStats(latestMetrics *metric.LatestMonito } if stat.CertExpiryTime > 0 { - if !hasCert || stat.CertExpiryTime < minCertExpiryDate { - minCertExpiryDate = stat.CertExpiryTime - minCertExpiryDays = stat.CertDaysLeft + if !hasCert || stat.CertExpiryTime < minCertExpiryTime { + minCertExpiryTime = stat.CertExpiryTime + minCertDaysLeft = stat.CertDaysLeft hasCert = true } } @@ -625,8 +626,8 @@ func (s *MetricService) aggregateMonitorStats(latestMetrics *metric.LatestMonito } if hasCert { - result.CertExpiryDate = minCertExpiryDate - result.CertExpiryDays = minCertExpiryDays + result.CertExpiryTime = minCertExpiryTime + result.CertDaysLeft = minCertDaysLeft } return result diff --git a/internal/service/monitor_service.go b/internal/service/monitor_service.go index 05fa8e3..d8e7a74 100644 --- a/internal/service/monitor_service.go +++ b/internal/service/monitor_service.go @@ -30,7 +30,7 @@ type MonitorService struct { wsManager *ws.Manager // 监控概览缓存:缓存监控任务列表(使用不同的 key 区分 public 和 private) - overviewCache cache.Cache[string, []PublicMonitorOverview] + overviewCache cache.Cache[string, []metric.PublicMonitorOverview] // 调度器引用(用于动态管理任务) scheduler MonitorScheduler @@ -54,7 +54,7 @@ func NewMonitorService(logger *zap.Logger, db *gorm.DB, metricService *MetricSer wsManager: wsManager, // 缓存 5 分钟,避免频繁查询 - overviewCache: cache.New[string, []PublicMonitorOverview](5 * time.Minute), + overviewCache: cache.New[string, []metric.PublicMonitorOverview](5 * time.Minute), } } @@ -79,25 +79,6 @@ type MonitorTaskRequest struct { Tags []string `json:"tags"` } -// PublicMonitorOverview 用于公开展示的监控配置及汇总数据 -type PublicMonitorOverview struct { - ID string `json:"id"` - Name string `json:"name"` - Type string `json:"type"` - Target string `json:"target"` - ShowTargetPublic bool `json:"showTargetPublic"` // 在公开页面是否显示目标地址 - Description string `json:"description"` - Enabled bool `json:"enabled"` - Interval int `json:"interval"` - AgentIds []string `json:"agentIds"` - AgentCount int `json:"agentCount"` - Status string `json:"status"` // up/down/unknown - ResponseTime int64 `json:"responseTime"` // 当前响应时间(ms) - CertExpiryDate int64 `json:"certExpiryDate"` // 证书过期时间 - CertExpiryDays int `json:"certExpiryDays"` // 证书剩余天数 - LastCheckTime int64 `json:"lastCheckTime"` // 最后检测时间 -} - func (s *MonitorService) CreateMonitor(ctx context.Context, req *MonitorTaskRequest) (*models.MonitorTask, error) { // 设置默认检测频率 interval := req.Interval @@ -245,7 +226,7 @@ func (s *MonitorService) DeleteMonitor(ctx context.Context, id string) error { } // ListByAuth 返回公开展示所需的监控配置和汇总统计 -func (s *MonitorService) ListByAuth(ctx context.Context, isAuthenticated bool) ([]PublicMonitorOverview, error) { +func (s *MonitorService) ListByAuth(ctx context.Context, isAuthenticated bool) ([]metric.PublicMonitorOverview, error) { // 构建缓存键:根据认证状态使用不同的 key cacheKey := "overview:public" if isAuthenticated { @@ -265,37 +246,19 @@ func (s *MonitorService) ListByAuth(ctx context.Context, isAuthenticated bool) ( } if len(monitors) == 0 { - var emptyResult []PublicMonitorOverview + var emptyResult []metric.PublicMonitorOverview // 缓存空结果 s.overviewCache.Set(cacheKey, emptyResult, 5*time.Minute) return emptyResult, nil } // 构建监控概览列表 - items := make([]PublicMonitorOverview, 0, len(monitors)) + items := make([]metric.PublicMonitorOverview, 0, len(monitors)) for _, monitor := range monitors { - // 从 VictoriaMetrics 查询统计数据 - stats, err := s.metricService.GetMonitorStats(ctx, monitor.ID) - if err != nil { - // 查询失败时使用默认值 - s.logger.Error("查询 VictoriaMetrics 失败", - zap.String("monitorID", monitor.ID), - zap.Error(err)) - stats = &metric.MonitorStatsResult{} - } - - // 将 MonitorStatsResult 转换为 monitorOverviewSummary - summary := monitorOverviewSummary{ - AgentCount: stats.AgentCount, - Status: stats.Status, - ResponseTime: stats.ResponseTime, - LastCheckTime: stats.LastCheckTime, - CertExpiryDate: stats.CertExpiryDate, - CertExpiryDays: stats.CertExpiryDays, - } - + // 查询统计数据 + stats := s.metricService.GetMonitorStats(monitor.ID) // 构建监控概览对象 - item := s.buildMonitorOverview(monitor, summary) + item := s.buildMonitorOverview(monitor, stats) items = append(items, item) } @@ -306,14 +269,14 @@ func (s *MonitorService) ListByAuth(ctx context.Context, isAuthenticated bool) ( } // buildMonitorOverview 构建监控概览对象 -func (s *MonitorService) buildMonitorOverview(monitor models.MonitorTask, summary monitorOverviewSummary) PublicMonitorOverview { +func (s *MonitorService) buildMonitorOverview(monitor models.MonitorTask, stats *metric.MonitorStatsResult) metric.PublicMonitorOverview { // 根据 ShowTargetPublic 字段决定是否返回真实的 Target target := monitor.Target if !monitor.ShowTargetPublic { target = "******" } - return PublicMonitorOverview{ + return metric.PublicMonitorOverview{ ID: monitor.ID, Name: monitor.Name, Type: monitor.Type, @@ -322,35 +285,15 @@ func (s *MonitorService) buildMonitorOverview(monitor models.MonitorTask, summar Description: monitor.Description, Enabled: monitor.Enabled, Interval: monitor.Interval, - AgentIds: cloneAgentIDs(monitor.AgentIds), - AgentCount: summary.AgentCount, - Status: summary.Status, - ResponseTime: summary.ResponseTime, - CertExpiryDate: summary.CertExpiryDate, - CertExpiryDays: summary.CertExpiryDays, - LastCheckTime: summary.LastCheckTime, + AgentCount: stats.AgentCount, + Status: stats.Status, + ResponseTime: stats.ResponseTime, + CertExpiryTime: stats.CertExpiryTime, + CertDaysLeft: stats.CertDaysLeft, + LastCheckTime: stats.LastCheckTime, } } -type monitorOverviewSummary struct { - AgentCount int - Status string // up/down/unknown - ResponseTime int64 // 当前响应时间(ms) - CertExpiryDate int64 // 证书过期时间 - CertExpiryDays int // 证书剩余天数 - LastCheckTime int64 // 最后检测时间 -} - -func cloneAgentIDs(ids datatypes.JSONSlice[string]) []string { - if len(ids) == 0 { - return []string{} - } - - copied := make([]string, len(ids)) - copy(copied, []string(ids)) - return copied -} - // resolveTargetAgents 计算监控任务对应的目标探针范围 // 规则: // 1. 如果既没有指定 AgentIds 也没有指定 Tags,返回所有传入的探针(全部节点) @@ -484,44 +427,21 @@ func (s *MonitorService) SendMonitorTaskToAgents(ctx context.Context, monitor mo } // GetMonitorStatsByID 获取监控任务的统计数据(聚合后的单个监控详情) -func (s *MonitorService) GetMonitorStatsByID(ctx context.Context, monitorID string) (*PublicMonitorOverview, error) { +func (s *MonitorService) GetMonitorStatsByID(ctx context.Context, monitorID string) (*metric.PublicMonitorOverview, error) { // 查询监控任务 monitor, err := s.MonitorRepo.FindById(ctx, monitorID) if err != nil { return nil, err } - // 从 VictoriaMetrics 查询统计数据 - stats, err := s.metricService.GetMonitorStats(ctx, monitorID) - if err != nil { - s.logger.Error("查询 VictoriaMetrics 失败", zap.String("monitorID", monitorID), zap.Error(err)) - // 失败时返回默认值,不中断 - stats = &metric.MonitorStatsResult{} - } - - // 转换为 monitorOverviewSummary 格式 - summary := monitorOverviewSummary{ - AgentCount: stats.AgentCount, - Status: stats.Status, - ResponseTime: stats.ResponseTime, - CertExpiryDate: stats.CertExpiryDate, - CertExpiryDays: stats.CertExpiryDays, - LastCheckTime: stats.LastCheckTime, - } - + // 查询统计数据 + stats := s.metricService.GetMonitorStats(monitorID) // 构建监控概览对象 - overview := s.buildMonitorOverview(monitor, summary) + overview := s.buildMonitorOverview(monitor, stats) return &overview, nil } -// GetMonitorAgentStats 获取监控任务各探针的统计数据(详细列表) -// 直接返回 VictoriaMetrics 查询结果,无需额外转换 -func (s *MonitorService) GetMonitorAgentStats(ctx context.Context, monitorID string) ([]protocol.MonitorData, bool) { - // 直接返回 VictoriaMetrics 查询结果 - return s.metricService.GetMonitorAgentStats(ctx, monitorID) -} - // GetMonitorHistory 获取监控任务的历史时序数据 // 直接返回 VictoriaMetrics 的原始时序数据,包含所有探针的独立序列 // 支持时间范围:15m, 30m, 1h, 3h, 6h, 12h, 1d, 3d, 7d @@ -599,10 +519,8 @@ func (s *MonitorService) GetLatestMonitorMetricsByType(ctx context.Context, moni // 在缓存中查询最新的监控数据 var result []protocol.MonitorData for _, task := range monitorTasks { - monitorData, ok := s.metricService.GetMonitorAgentStats(ctx, task.ID) - if ok { - result = append(result, monitorData...) - } + monitorData := s.metricService.GetMonitorAgentStats(task.ID) + result = append(result, monitorData...) } return result, nil @@ -619,10 +537,8 @@ func (s *MonitorService) GetAllLatestMonitorMetrics(ctx context.Context) ([]prot // 在缓存中查询最新的监控数据 var result []protocol.MonitorData for _, task := range monitorTasks { - monitorData, ok := s.metricService.GetMonitorAgentStats(ctx, task.ID) - if ok { - result = append(result, monitorData...) - } + monitorData := s.metricService.GetMonitorAgentStats(task.ID) + result = append(result, monitorData...) } return result, nil } diff --git a/internal/wire_gen.go b/internal/wire_gen.go index 4a8ca9b..01dde88 100644 --- a/internal/wire_gen.go +++ b/internal/wire_gen.go @@ -50,7 +50,7 @@ func InitializeApp(logger *zap.Logger, db *gorm.DB, cfg *config.AppConfig) (*App alertService := service.NewAlertService(logger, db, propertyService, monitorService, notifier) alertHandler := handler.NewAlertHandler(logger, alertService) propertyHandler := handler.NewPropertyHandler(logger, propertyService, notifier) - monitorHandler := handler.NewMonitorHandler(logger, monitorService, agentService) + monitorHandler := handler.NewMonitorHandler(logger, monitorService, metricService, agentService) tamperHandler := handler.NewTamperHandler(logger, tamperService) dnsProviderHandler := handler.NewDNSProviderHandler(logger, propertyService) ddnsHandler := handler.NewDDNSHandler(logger, ddnsService) diff --git a/web/src/api/monitor.ts b/web/src/api/monitor.ts index e4e6f34..a5265f4 100644 --- a/web/src/api/monitor.ts +++ b/web/src/api/monitor.ts @@ -1,5 +1,5 @@ -import {get, post, put, del} from './request'; -import type {MonitorListResponse, MonitorTask, MonitorTaskRequest, MonitorStats, PublicMonitor} from '../types'; +import {del, get, post, put} from './request'; +import type {MonitorListResponse, MonitorTask, MonitorTaskRequest, PublicMonitor} from '../types'; export const listMonitors = (page: number = 1, pageSize: number = 10, keyword?: string) => { const params = new URLSearchParams(); @@ -65,22 +65,19 @@ export interface GetMetricsResponse { series: MetricSeries[]; // 时序数据系列(每个探针一个系列) } -// 探针监控统计(直接从 VictoriaMetrics 查询) +// 探针监控统计 export interface AgentMonitorStat { - agentID: string; - currentResponse: number; - avgResponse24h: number; - uptime24h: number; - uptime7d: number; - lastCheckStatus: string; - lastCheckError: string; - lastCheckTime: number; - certExpiryDate: number; - certExpiryDays: number; - totalChecks24h: number; - successChecks24h: number; - totalChecks7d: number; - successChecks7d: number; + agentId: string; + monitorId: string; + type: string; + target: string; + status: string; + statusCode: number; + responseTime: number; + checkedAt: number; + message: string; + certExpiryTime: number; + certDaysLeft: number; } // 公开接口 - 获取指定监控的历史数据(VictoriaMetrics 原始时序数据) diff --git a/web/src/pages/Public/Monitor.tsx b/web/src/pages/Public/Monitor.tsx new file mode 100644 index 0000000..37022dc --- /dev/null +++ b/web/src/pages/Public/Monitor.tsx @@ -0,0 +1,62 @@ +import {Shield} from "lucide-react"; +import {cn} from "@/lib/utils.ts"; +import type {CertStats} from "@/types"; +import {formatDate} from "@/utils/util.ts"; + +export const renderCert = (stats: CertStats) => { + const hasCert = stats.certExpiryTime > 0; + const certExpired = hasCert && stats.certDaysLeft < 0; + const certExpiringSoon = hasCert && stats.certDaysLeft >= 0 && stats.certDaysLeft < 30; + if (!hasCert) { + return - + } + + return
+ +
+
+ {formatDate(stats.certExpiryTime)} +
+
+ {certExpired ? `已过期 ${Math.abs(stats.certDaysLeft)} 天` : `剩余 ${stats.certDaysLeft} 天`} +
+
+
+} + +export const UptimeBar = ({uptime}: { uptime: number }) => { + const percentage = Math.min(Math.max(uptime, 0), 100); + const colorClass = percentage >= 99 ? 'bg-emerald-500' : percentage >= 95 ? 'bg-yellow-500' : 'bg-red-500'; + + return ( +
+
+
+ ); +}; \ No newline at end of file diff --git a/web/src/pages/Public/MonitorDetail.tsx b/web/src/pages/Public/MonitorDetail.tsx index 75b9338..c099c77 100644 --- a/web/src/pages/Public/MonitorDetail.tsx +++ b/web/src/pages/Public/MonitorDetail.tsx @@ -24,35 +24,9 @@ import { } from '@/api/monitor.ts'; import type {PublicMonitor} from '@/types'; import {cn} from '@/lib/utils'; +import {formatDate, formatDateTime, formatTime} from "@/utils/util.ts"; +import {renderCert} from "@/pages/Public/Monitor.tsx"; -const formatTime = (ms: number): string => { - if (!ms || ms <= 0) return '0 ms'; - if (ms < 1000) return `${ms.toFixed(0)} ms`; - return `${(ms / 1000).toFixed(2)} s`; -}; - -const formatDate = (timestamp: number): string => { - if (!timestamp) return '-'; - const date = new Date(timestamp); - return date.toLocaleDateString('zh-CN', { - year: 'numeric', - month: '2-digit', - day: '2-digit' - }); -}; - -const formatDateTime = (timestamp: number): string => { - if (!timestamp) return '-'; - const date = new Date(timestamp); - return date.toLocaleString('zh-CN', { - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - second: '2-digit' - }); -}; const formatPercentValue = (value: number): string => (Number.isFinite(value) ? value.toFixed(2) : '0.00'); @@ -385,8 +359,8 @@ const MonitorDetail = () => { const availableAgents = useMemo(() => { if (monitorStats.length === 0) return []; return monitorStats.map(stat => ({ - id: stat.agentID, - label: stat.agentID.substring(0, 8), + id: stat.agentId, + label: stat.agentId.substring(0, 8), })); }, [monitorStats]); @@ -449,15 +423,13 @@ const MonitorDetail = () => { } const monitorTitle = monitorDetail.name ?? '监控详情'; - const hasCert = monitorDetail.certExpiryDate > 0; - const certExpired = hasCert && monitorDetail.certExpiryDays < 0; - const certExpiringSoon = hasCert && monitorDetail.certExpiryDays >= 0 && monitorDetail.certExpiryDays < 30; + const hasCert = monitorDetail.certExpiryTime > 0; + const certExpired = hasCert && monitorDetail.certDaysLeft < 0; + const certExpiringSoon = hasCert && monitorDetail.certDaysLeft >= 0 && monitorDetail.certDaysLeft < 30; const heroStats = [ {label: '监控类型', value: monitorDetail.type.toUpperCase()}, {label: '探针数量', value: `${monitorDetail.agentCount} 个`}, - {label: '24h在线率', value: `${formatPercentValue(monitorDetail.uptime24h)}%`}, - {label: '7d在线率', value: `${formatPercentValue(monitorDetail.uptime7d)}%`}, ]; return ( @@ -529,53 +501,11 @@ const MonitorDetail = () => { } label="当前响应" - value={formatTime(monitorDetail.currentResponse)} + value={formatTime(monitorDetail.responseTime)} color="blue" /> - } - label="24h 平均响应" - value={formatTime(monitorDetail.avgResponse24h)} - color="blue" - /> - } - label="24h 在线率" - value={`${formatPercentValue(monitorDetail.uptime24h)}%`} - color={monitorDetail.uptime24h >= 99 ? 'emerald' : monitorDetail.uptime24h >= 95 ? 'amber' : 'rose'} - /> - } - label="7d 在线率" - value={`${formatPercentValue(monitorDetail.uptime7d)}%`} - color={monitorDetail.uptime7d >= 99 ? 'emerald' : monitorDetail.uptime7d >= 95 ? 'amber' : 'rose'} - />
- {/* 当前错误信息 */} - {monitorDetail.lastCheckStatus === 'down' && monitorDetail.lastCheckError && ( -
-
- -
-

- 监控异常 -

-

- {monitorDetail.lastCheckError} -

- {monitorDetail.lastCheckTime && ( -

- 检测时间: {formatDateTime(monitorDetail.lastCheckTime)} -

- )} -
-
-
- )} - {/* 证书信息 */} {hasCert && (
{ ? 'text-amber-700 dark:text-amber-200' : 'text-slate-600 dark:text-slate-400' )}> - 证书到期时间: {formatDate(monitorDetail.certExpiryDate)} + 证书到期时间: {formatDate(monitorDetail.certExpiryTime)} {certExpired ? ( (已过期 {Math.abs(monitorDetail.certExpiryDays)} 天) + className="ml-1">(已过期 {Math.abs(monitorDetail.certDaysLeft)} 天) ) : ( - (剩余 {monitorDetail.certExpiryDays} 天) + (剩余 {monitorDetail.certDaysLeft} 天) )}

{certExpired && ( @@ -668,7 +598,7 @@ const MonitorDetail = () => { {monitorStats.map((stat, index) => { - const agentKey = `agent_${stat.agentID}`; + const agentKey = `agent_${stat.agentId}`; const color = AGENT_COLORS[index % AGENT_COLORS.length]; return ( { className="hidden sm:block" /> {monitorStats - .filter(stat => selectedAgent === 'all' || stat.agentID === selectedAgent) + .filter(stat => selectedAgent === 'all' || stat.agentId === selectedAgent) .map((stat) => { // 使用原始索引保持颜色一致性 - const originalIndex = monitorStats.findIndex(s => s.agentID === stat.agentID); - const agentKey = `agent_${stat.agentID}`; + 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); + const agentLabel = stat.agentId.substring(0, 8); return ( { 当前响应 - - 24h 在线率 - - - 7d 在线率 - - - 24h 检测 - 最后检测 + + 证书信息 + 错误信息 @@ -772,7 +696,7 @@ const MonitorDetail = () => { {monitorStats.map((stats, index) => { const color = AGENT_COLORS[index % AGENT_COLORS.length]; return ( -
@@ -783,13 +707,13 @@ const MonitorDetail = () => {
- {stats.agentID.substring(0, 8)}... + {stats.agentId.substring(0, 8)}...
- +
@@ -797,41 +721,25 @@ const MonitorDetail = () => { className="h-3 w-3 sm:h-4 sm:w-4 text-slate-400 dark:text-slate-500 flex-shrink-0"/> - {formatTime(stats.currentResponse)} + {formatTime(stats.responseTime)}
- -
- -
- - -
- -
- - -
- {stats.successChecks24h} / {stats.totalChecks24h} -
- - -
- {formatDateTime(stats.lastCheckTime)} -
+ + {formatDateTime(stats.checkedAt)} - {stats.lastCheckStatus === 'down' && stats.lastCheckError ? ( + {renderCert(stats)} + + + {stats.status === 'down' ? (
- {stats.lastCheckError} + {stats.message}
diff --git a/web/src/pages/Public/MonitorList.tsx b/web/src/pages/Public/MonitorList.tsx index 25134c7..e99f889 100644 --- a/web/src/pages/Public/MonitorList.tsx +++ b/web/src/pages/Public/MonitorList.tsx @@ -6,24 +6,8 @@ import {getPublicMonitors} from '@/api/monitor.ts'; import type {PublicMonitor} from '@/types'; import {usePublicLayout} from '../PublicLayout'; import {cn} from '@/lib/utils'; - -const formatTime = (ms: number): string => { - if (!ms || ms <= 0) return '0 ms'; - if (ms < 1000) return `${ms.toFixed(0)} ms`; - return `${(ms / 1000).toFixed(2)} s`; -}; - -const formatDate = (timestamp: number): string => { - if (!timestamp) return '-'; - const date = new Date(timestamp); - return date.toLocaleDateString('zh-CN', { - year: 'numeric', - month: '2-digit', - day: '2-digit' - }); -}; - -const formatPercentValue = (value: number): string => (Number.isFinite(value) ? value.toFixed(2) : '0.00'); +import {renderCert} from "@/pages/Public/Monitor.tsx"; +import {formatTime,} from "@/utils/util.ts" const LoadingSpinner = () => (
@@ -61,20 +45,6 @@ const StatusBadge = ({status}: { status: string }) => { ); }; -const UptimeBar = ({uptime}: { uptime: number }) => { - const percentage = Math.min(Math.max(uptime, 0), 100); - const colorClass = percentage >= 99 ? 'bg-emerald-500' : percentage >= 95 ? 'bg-yellow-500' : 'bg-red-500'; - - return ( -
-
-
- ); -}; - const EmptyState = () => (
@@ -104,148 +74,6 @@ const MonitorList = () => { const monitorSummaries = monitors; - const renderGridView = () => ( -
- {monitorSummaries.map((stats) => { - const hasCert = stats.certExpiryDate > 0; - const certExpired = hasCert && stats.certExpiryDays < 0; - const certExpiringSoon = hasCert && stats.certExpiryDays >= 0 && stats.certExpiryDays < 30; - - return ( -
navigate(`/monitors/${encodeURIComponent(stats.id)}`)} - onKeyDown={(event) => { - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault(); - navigate(`/monitors/${encodeURIComponent(stats.id)}`); - } - }} - className="group relative flex h-full cursor-pointer flex-col gap-4 rounded-2xl border border-slate-200 dark:border-slate-700 bg-transparent p-5 transition duration-200 hover:border-slate-300 dark:hover:border-slate-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-200" - > -
-
-
-

- {stats.name} -

- -
-

- {stats.target} -

- {stats.agentCount > 1 && ( -

- {stats.agentCount} 个探针 -

- )} -
-
- -
-
-
-
- -
- 当前响应 -
- - {formatTime(stats.currentResponse)} - -
- -
-
-
- -
- 24h 平均 -
- - {formatTime(stats.avgResponse24h)} - -
- -
-
- 24h 在线率 - {formatPercentValue(stats.uptime24h)}% -
- -
- -
-
- 7d 在线率 - {formatPercentValue(stats.uptime7d)}% -
- -
- - {hasCert && ( -
-
- - 证书到期 -
-
-
- {formatDate(stats.certExpiryDate)} -
-
- {certExpired ? `已过期 ${Math.abs(stats.certExpiryDays)} 天` : `剩余 ${stats.certExpiryDays} 天`} -
-
-
- )} -
-
- ); - })} -
- ); - const renderListView = () => ( <> {/* 桌面端:使用表格布局 */} @@ -256,18 +84,12 @@ const MonitorList = () => { 监控项 状态 当前响应 - 24h 平均响应 - 24h 在线率 - 7d 在线率 证书信息 - + {monitorSummaries.map((stats) => { - const hasCert = stats.certExpiryDate > 0; - const certExpired = hasCert && stats.certExpiryDays < 0; - const certExpiringSoon = hasCert && stats.certExpiryDays >= 0 && stats.certExpiryDays < 30; - return ( {
- +
- {formatTime(stats.currentResponse)} + {formatTime(stats.responseTime)}
- - {formatTime(stats.avgResponse24h)} - - - -
-
-
- -
- - {formatPercentValue(stats.uptime24h)}% - -
-
- - -
-
-
- -
- - {formatPercentValue(stats.uptime7d)}% - -
-
- - - {hasCert ? ( -
- -
-
- {formatDate(stats.certExpiryDate)} -
-
- {certExpired ? `已过期 ${Math.abs(stats.certExpiryDays)} 天` : `剩余 ${stats.certExpiryDays} 天`} -
-
-
- ) : ( - - - )} + {renderCert(stats)} ); @@ -397,11 +153,7 @@ const MonitorList = () => {
{monitorSummaries.length === 0 ? ( - ) : viewMode === 'grid' ? ( - renderGridView() - ) : ( - renderListView() - )} + ) : renderListView()}
); }; diff --git a/web/src/types/index.ts b/web/src/types/index.ts index d229aff..3acd332 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -293,76 +293,18 @@ export interface PublicMonitor { interval: number; agentIds: string[]; agentCount: number; - lastCheckStatus: string; - lastCheckError?: string; - currentResponse: number; - avgResponse24h: number; - uptime24h: number; - uptime7d: number; - certExpiryDate: number; - certExpiryDays: number; + + status: string; + responseTime: number; + certExpiryTime: number; + certDaysLeft: number; lastCheckTime: number; } // 监控统计数据 -export interface MonitorStats { - id: number; - agentId: string; - agentName?: string; // 探针名称 - monitorId: string; // 监控项ID - name: string; // 监控项名称 - type: string; - target: string; - currentResponse: number; // 当前响应时间(ms) - avgResponse24h: number; // 24小时平均响应时间(ms) - uptime24h: number; // 24小时在线率(百分比) - uptime7d: number; // 7天在线率(百分比) - certExpiryDate: number; // 证书过期时间(毫秒时间戳) - certExpiryDays: number; // 证书剩余天数 - totalChecks24h: number; // 24小时总检测次数 - successChecks24h: number; // 24小时成功次数 - totalChecks7d: number; // 7天总检测次数 - successChecks7d: number; // 7天成功次数 - lastCheckTime: number; // 最后检测时间 - lastCheckStatus: string; // 最后检测状态: up/down - lastCheckError?: string; // 最后检测错误信息 - updatedAt: number; // 更新时间 -} - -// 监控指标(原始数据,用于图表) -export interface MonitorMetric { - id: number; - agentId: string; - name: string; - type: string; - target: string; - status: string; - statusCode: number; - responseTime: number; - error: string; - message: string; - contentMatch: boolean; +export interface CertStats { certExpiryTime: number; certDaysLeft: number; - timestamp: number; -} - -// 磁盘IO指标 -export interface DiskIOMetric { - id: number; - agentId: string; - device: string; - readCount: number; - writeCount: number; - readBytes: number; - writeBytes: number; - readBytesRate: number; - writeBytesRate: number; - readTime: number; - writeTime: number; - ioTime: number; - iopsInProgress: number; - timestamp: number; } // 网络连接统计 @@ -460,12 +402,6 @@ export interface AlertRecord { updatedAt: number; } -// 通用分页响应 -export interface PageResponse { - items: T[]; - total: number; -} - // 流量统计相关 export interface TrafficAlerts { sent80: boolean; diff --git a/web/src/utils/util.ts b/web/src/utils/util.ts new file mode 100644 index 0000000..0223c6d --- /dev/null +++ b/web/src/utils/util.ts @@ -0,0 +1,28 @@ +export const formatTime = (ms: number): string => { + if (!ms || ms <= 0) return '0 ms'; + if (ms < 1000) return `${ms.toFixed(0)} ms`; + return `${(ms / 1000).toFixed(2)} s`; +}; + +export const formatDate = (timestamp: number): string => { + if (!timestamp) return '-'; + const date = new Date(timestamp); + return date.toLocaleDateString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit' + }); +}; + +export const formatDateTime = (timestamp: number): string => { + if (!timestamp) return '-'; + const date = new Date(timestamp); + return date.toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }); +}; \ No newline at end of file From 623cbecfb49eee81ad25287e5ee74eba7e76833a Mon Sep 17 00:00:00 2001 From: dushixiang Date: Sat, 13 Dec 2025 13:46:34 +0800 Subject: [PATCH 09/18] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E7=9B=91=E6=8E=A7=E8=AF=A6=E6=83=85=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/pages/Public/MonitorDetail.tsx | 81 +------------------------- 1 file changed, 3 insertions(+), 78 deletions(-) diff --git a/web/src/pages/Public/MonitorDetail.tsx b/web/src/pages/Public/MonitorDetail.tsx index c099c77..ad3f474 100644 --- a/web/src/pages/Public/MonitorDetail.tsx +++ b/web/src/pages/Public/MonitorDetail.tsx @@ -24,7 +24,7 @@ import { } from '@/api/monitor.ts'; import type {PublicMonitor} from '@/types'; import {cn} from '@/lib/utils'; -import {formatDate, formatDateTime, formatTime} from "@/utils/util.ts"; +import {formatDateTime, formatTime} from "@/utils/util.ts"; import {renderCert} from "@/pages/Public/Monitor.tsx"; @@ -423,13 +423,12 @@ const MonitorDetail = () => { } const monitorTitle = monitorDetail.name ?? '监控详情'; - const hasCert = monitorDetail.certExpiryTime > 0; - const certExpired = hasCert && monitorDetail.certDaysLeft < 0; - const certExpiringSoon = hasCert && monitorDetail.certDaysLeft >= 0 && monitorDetail.certDaysLeft < 30; const heroStats = [ {label: '监控类型', value: monitorDetail.type.toUpperCase()}, {label: '探针数量', value: `${monitorDetail.agentCount} 个`}, + {label: '当前响应', value: `${monitorDetail.responseTime}ms`}, + {label: '当前状态', value: `${monitorDetail.status}`}, ]; return ( @@ -467,7 +466,6 @@ const MonitorDetail = () => {

{monitorDetail.target}

-

公共视图 · 实时监控概览

@@ -495,79 +493,6 @@ const MonitorDetail = () => {
- {/* 概览统计 */} - -
- } - label="当前响应" - value={formatTime(monitorDetail.responseTime)} - color="blue" - /> -
- - {/* 证书信息 */} - {hasCert && ( -
-
- -
-

- TLS 证书信息 -

-

- 证书到期时间: {formatDate(monitorDetail.certExpiryTime)} - {certExpired ? ( - (已过期 {Math.abs(monitorDetail.certDaysLeft)} 天) - ) : ( - (剩余 {monitorDetail.certDaysLeft} 天) - )} -

- {certExpired && ( -

- 🚨 证书已过期,请立即更新 -

- )} - {certExpiringSoon && ( -

- ⚠️ 证书即将过期,请及时更新 -

- )} -
-
-
- )} -
- {/* 响应时间趋势图表 */} Date: Sun, 14 Dec 2025 11:57:37 +0800 Subject: [PATCH 10/18] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E7=9B=91=E6=8E=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/app.go | 1 + internal/handler/monitor_handler.go | 18 + internal/metric/monitor.go | 44 +- internal/service/metric_service.go | 36 +- internal/service/monitor_service.go | 91 ++-- web/src/api/monitor.ts | 7 +- .../monitor/AgentHealthIndicator.tsx | 51 ++ web/src/components/monitor/CertBadge.tsx | 27 ++ web/src/components/monitor/MiniChart.tsx | 43 ++ .../components/monitor/ResponseTimeRange.tsx | 20 + web/src/components/monitor/StatCard.tsx | 26 ++ web/src/components/monitor/StatusBadge.tsx | 35 ++ web/src/components/monitor/TypeIcon.tsx | 21 + web/src/pages/Public/MonitorDetail.tsx | 8 +- web/src/pages/Public/MonitorList.tsx | 437 +++++++++++++----- web/src/types/index.ts | 35 ++ 16 files changed, 719 insertions(+), 181 deletions(-) create mode 100644 web/src/components/monitor/AgentHealthIndicator.tsx create mode 100644 web/src/components/monitor/CertBadge.tsx create mode 100644 web/src/components/monitor/MiniChart.tsx create mode 100644 web/src/components/monitor/ResponseTimeRange.tsx create mode 100644 web/src/components/monitor/StatCard.tsx create mode 100644 web/src/components/monitor/StatusBadge.tsx create mode 100644 web/src/components/monitor/TypeIcon.tsx diff --git a/internal/app.go b/internal/app.go index d48a8be..00c7fab 100644 --- a/internal/app.go +++ b/internal/app.go @@ -195,6 +195,7 @@ func setupApi(app *orz.App, components *AppComponents) { publicApiWithOptionalAuth.GET("/monitors/:id/stats", components.MonitorHandler.GetStatsByID) publicApiWithOptionalAuth.GET("/monitors/:id/agents", components.MonitorHandler.GetAgentStatsByID) publicApiWithOptionalAuth.GET("/monitors/:id/history", components.MonitorHandler.GetHistoryByID) + publicApiWithOptionalAuth.GET("/monitors/:id", components.MonitorHandler.GetDetail) // 整合版接口 // Logo(公开访问)- 用于公共页面只获取 Logo publicApiWithOptionalAuth.GET("/logo", components.PropertyHandler.GetLogo) diff --git a/internal/handler/monitor_handler.go b/internal/handler/monitor_handler.go index f210f9b..6c1812c 100644 --- a/internal/handler/monitor_handler.go +++ b/internal/handler/monitor_handler.go @@ -160,6 +160,24 @@ func (h *MonitorHandler) GetStatsByID(c echo.Context) error { return orz.Ok(c, stats) } +// GetDetail 获取监控详情(整合版,公开接口,已登录返回全部,未登录返回公开可见) +func (h *MonitorHandler) GetDetail(c echo.Context) error { + id := c.Param("id") + ctx := c.Request().Context() + + // 验证监控任务访问权限 + if _, err := h.monitorService.GetMonitorByAuth(ctx, id, utils.IsAuthenticated(c)); err != nil { + return err + } + + detail, err := h.monitorService.GetMonitorDetail(ctx, id) + if err != nil { + return err + } + + return orz.Ok(c, detail) +} + // GetAgentStatsByID 获取指定监控任务各探针的统计数据(公开接口,已登录返回全部,未登录返回公开可见) func (h *MonitorHandler) GetAgentStatsByID(c echo.Context) error { id := c.Param("id") diff --git a/internal/metric/monitor.go b/internal/metric/monitor.go index c5c9965..28d4b67 100644 --- a/internal/metric/monitor.go +++ b/internal/metric/monitor.go @@ -11,12 +11,19 @@ type LatestMonitorMetrics struct { // MonitorStatsResult 监控统计结果(所有探针的聚合数据) type MonitorStatsResult struct { - Status string `json:"status"` // 聚合状态(up/down/unknown) - ResponseTime int64 `json:"responseTime"` // 当前平均响应时间(ms) - CertExpiryTime int64 `json:"certExpiryTime,omitempty"` // 证书过期时间(毫秒时间戳) - CertDaysLeft int `json:"certDaysLeft,omitempty"` // 证书剩余天数 - AgentCount int `json:"agentCount"` // 探针数量 - LastCheckTime int64 `json:"lastCheckTime"` // 最后检测时间(毫秒时间戳) + Status string `json:"status"` // 聚合状态(up/down/unknown) + ResponseTime int64 `json:"responseTime"` // 当前平均响应时间(ms) + ResponseTimeMin int64 `json:"responseTimeMin"` // 最快响应时间(ms) + ResponseTimeMax int64 `json:"responseTimeMax"` // 最慢响应时间(ms) + CertExpiryTime int64 `json:"certExpiryTime,omitempty"` // 证书过期时间(毫秒时间戳) + CertDaysLeft int `json:"certDaysLeft,omitempty"` // 证书剩余天数 + AgentCount int `json:"agentCount"` // 探针数量 + AgentStats struct { + Up int `json:"up"` // 正常探针数量 + Down int `json:"down"` // 异常探针数量 + Unknown int `json:"unknown"` // 未知状态探针数量 + } `json:"agentStats"` // 探针状态分布 + LastCheckTime int64 `json:"lastCheckTime"` // 最后检测时间(毫秒时间戳) } // PublicMonitorOverview 用于公开展示的监控配置及汇总数据 @@ -31,8 +38,29 @@ type PublicMonitorOverview struct { Interval int `json:"interval"` AgentCount int `json:"agentCount"` Status string `json:"status"` // up/down/unknown - ResponseTime int64 `json:"responseTime"` // 当前响应时间(ms) + ResponseTime int64 `json:"responseTime"` // 当前平均响应时间(ms) + ResponseTimeMin int64 `json:"responseTimeMin"` // 最快响应时间(ms) + ResponseTimeMax int64 `json:"responseTimeMax"` // 最慢响应时间(ms) CertExpiryTime int64 `json:"certExpiryTime,omitempty"` // 证书过期时间(毫秒时间戳) CertDaysLeft int `json:"certDaysLeft,omitempty"` // 证书剩余天数 - LastCheckTime int64 `json:"lastCheckTime"` // 最后检测时间 + AgentStats struct { + Up int `json:"up"` // 正常探针数量 + Down int `json:"down"` // 异常探针数量 + Unknown int `json:"unknown"` // 未知状态探针数量 + } `json:"agentStats"` // 探针状态分布 + LastCheckTime int64 `json:"lastCheckTime"` // 最后检测时间 +} + +// MonitorDetailResponse 监控详情响应(整合版) +type MonitorDetailResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Target string `json:"target"` + ShowTargetPublic bool `json:"showTargetPublic"` + Description string `json:"description"` + Enabled bool `json:"enabled"` + Interval int `json:"interval"` + Stats *MonitorStatsResult `json:"stats"` + Agents []protocol.MonitorData `json:"agents"` } diff --git a/internal/service/metric_service.go b/internal/service/metric_service.go index f6fbbe8..ce4a7b4 100644 --- a/internal/service/metric_service.go +++ b/internal/service/metric_service.go @@ -582,9 +582,10 @@ func (s *MetricService) aggregateMonitorStats(latestMetrics *metric.LatestMonito } var totalResponseTime int64 + var minResponseTime int64 = 9223372036854775807 // math.MaxInt64 + var maxResponseTime int64 var lastCheckTime int64 - hasUp := false - hasDown := false + var upCount, downCount, unknownCount int hasCert := false var minCertExpiryTime int64 var minCertDaysLeft int @@ -592,14 +593,26 @@ func (s *MetricService) aggregateMonitorStats(latestMetrics *metric.LatestMonito for _, stat := range latestMetrics.Agents { totalResponseTime += stat.ResponseTime + // 计算响应时间的最小值和最大值 + if stat.ResponseTime < minResponseTime { + minResponseTime = stat.ResponseTime + } + if stat.ResponseTime > maxResponseTime { + maxResponseTime = stat.ResponseTime + } + if stat.CheckedAt > lastCheckTime { lastCheckTime = stat.CheckedAt } - if stat.Status == "up" { - hasUp = true - } else if stat.Status == "down" { - hasDown = true + // 统计各状态的探针数量 + switch stat.Status { + case "up": + upCount++ + case "down": + downCount++ + default: + unknownCount++ } if stat.CertExpiryTime > 0 { @@ -616,12 +629,19 @@ func (s *MetricService) aggregateMonitorStats(latestMetrics *metric.LatestMonito if count > 0 { result.ResponseTime = totalResponseTime / int64(count) } + result.ResponseTimeMin = minResponseTime + result.ResponseTimeMax = maxResponseTime result.LastCheckTime = lastCheckTime + // 填充探针状态分布 + result.AgentStats.Up = upCount + result.AgentStats.Down = downCount + result.AgentStats.Unknown = unknownCount + // 聚合状态:只要有一个探针 up,整体就是 up - if hasUp { + if upCount > 0 { result.Status = "up" - } else if hasDown { + } else if downCount > 0 { result.Status = "down" } diff --git a/internal/service/monitor_service.go b/internal/service/monitor_service.go index d8e7a74..f5c1e26 100644 --- a/internal/service/monitor_service.go +++ b/internal/service/monitor_service.go @@ -12,7 +12,6 @@ import ( "github.com/dushixiang/pika/internal/protocol" "github.com/dushixiang/pika/internal/repo" ws "github.com/dushixiang/pika/internal/websocket" - "github.com/go-orz/cache" "github.com/go-orz/orz" "github.com/google/uuid" "go.uber.org/zap" @@ -29,9 +28,6 @@ type MonitorService struct { metricService *MetricService wsManager *ws.Manager - // 监控概览缓存:缓存监控任务列表(使用不同的 key 区分 public 和 private) - overviewCache cache.Cache[string, []metric.PublicMonitorOverview] - // 调度器引用(用于动态管理任务) scheduler MonitorScheduler } @@ -52,9 +48,6 @@ func NewMonitorService(logger *zap.Logger, db *gorm.DB, metricService *MetricSer metricRepo: repo.NewMetricRepo(db), metricService: metricService, wsManager: wsManager, - - // 缓存 5 分钟,避免频繁查询 - overviewCache: cache.New[string, []metric.PublicMonitorOverview](5 * time.Minute), } } @@ -115,9 +108,6 @@ func (s *MonitorService) CreateMonitor(ctx context.Context, req *MonitorTaskRequ return nil, err } - // 清理缓存 - s.clearCache(task.ID) - // 如果任务启用,添加到调度器 if task.Enabled && s.scheduler != nil { if err := s.scheduler.AddTask(task.ID, task.Interval); err != nil { @@ -165,9 +155,6 @@ func (s *MonitorService) UpdateMonitor(ctx context.Context, id string, req *Moni return nil, err } - // 清理缓存 - s.clearCache(task.ID) - // 更新调度器 if s.scheduler != nil { // 如果从禁用变为启用,或者间隔时间改变 @@ -214,9 +201,6 @@ func (s *MonitorService) DeleteMonitor(ctx context.Context, id string) error { return err } - // 清理缓存 - s.clearCache(id) - // 从调度器中移除 if s.scheduler != nil { s.scheduler.RemoveTask(id) @@ -227,31 +211,12 @@ func (s *MonitorService) DeleteMonitor(ctx context.Context, id string) error { // ListByAuth 返回公开展示所需的监控配置和汇总统计 func (s *MonitorService) ListByAuth(ctx context.Context, isAuthenticated bool) ([]metric.PublicMonitorOverview, error) { - // 构建缓存键:根据认证状态使用不同的 key - cacheKey := "overview:public" - if isAuthenticated { - cacheKey = "overview:private" - } - - // 尝试从缓存获取 - if cachedResult, ok := s.overviewCache.Get(cacheKey); ok { - return cachedResult, nil - } - - // 缓存未命中,查询数据库 // 获取符合权限的监控任务列表 monitors, err := s.FindByAuth(ctx, isAuthenticated) if err != nil { return nil, err } - if len(monitors) == 0 { - var emptyResult []metric.PublicMonitorOverview - // 缓存空结果 - s.overviewCache.Set(cacheKey, emptyResult, 5*time.Minute) - return emptyResult, nil - } - // 构建监控概览列表 items := make([]metric.PublicMonitorOverview, 0, len(monitors)) for _, monitor := range monitors { @@ -262,9 +227,6 @@ func (s *MonitorService) ListByAuth(ctx context.Context, isAuthenticated bool) ( items = append(items, item) } - // 缓存结果 - s.overviewCache.Set(cacheKey, items, 5*time.Minute) - return items, nil } @@ -276,7 +238,7 @@ func (s *MonitorService) buildMonitorOverview(monitor models.MonitorTask, stats target = "******" } - return metric.PublicMonitorOverview{ + overview := metric.PublicMonitorOverview{ ID: monitor.ID, Name: monitor.Name, Type: monitor.Type, @@ -288,10 +250,17 @@ func (s *MonitorService) buildMonitorOverview(monitor models.MonitorTask, stats AgentCount: stats.AgentCount, Status: stats.Status, ResponseTime: stats.ResponseTime, + ResponseTimeMin: stats.ResponseTimeMin, + ResponseTimeMax: stats.ResponseTimeMax, CertExpiryTime: stats.CertExpiryTime, CertDaysLeft: stats.CertDaysLeft, LastCheckTime: stats.LastCheckTime, } + + // 复制探针状态分布 + overview.AgentStats = stats.AgentStats + + return overview } // resolveTargetAgents 计算监控任务对应的目标探针范围 @@ -442,6 +411,43 @@ func (s *MonitorService) GetMonitorStatsByID(ctx context.Context, monitorID stri return &overview, nil } +// GetMonitorDetail 获取监控详情(整合stats和agents) +func (s *MonitorService) GetMonitorDetail(ctx context.Context, monitorID string) (*metric.MonitorDetailResponse, error) { + // 查询监控任务 + monitor, err := s.MonitorRepo.FindById(ctx, monitorID) + if err != nil { + return nil, err + } + + // 查询聚合统计 + stats := s.metricService.GetMonitorStats(monitorID) + + // 查询各探针数据 + agents := s.metricService.GetMonitorAgentStats(monitorID) + + // 根据 ShowTargetPublic 字段决定是否返回真实的 Target + target := monitor.Target + if !monitor.ShowTargetPublic { + target = "******" + } + + // 构建响应 + detail := &metric.MonitorDetailResponse{ + ID: monitor.ID, + Name: monitor.Name, + Type: monitor.Type, + Target: target, + ShowTargetPublic: monitor.ShowTargetPublic, + Description: monitor.Description, + Enabled: monitor.Enabled, + Interval: monitor.Interval, + Stats: stats, + Agents: agents, + } + + return detail, nil +} + // GetMonitorHistory 获取监控任务的历史时序数据 // 直接返回 VictoriaMetrics 的原始时序数据,包含所有探针的独立序列 // 支持时间范围:15m, 30m, 1h, 3h, 6h, 12h, 1d, 3d, 7d @@ -501,13 +507,6 @@ func (s *MonitorService) GetMonitorByAuth(ctx context.Context, id string, isAuth return monitor, nil } -// clearCache 清理监控任务相关的所有缓存 -func (s *MonitorService) clearCache(monitorID string) { - // 清理概览缓存 - s.overviewCache.Delete("overview:public") - s.overviewCache.Delete("overview:private") -} - // GetLatestMonitorMetricsByType 获取指定类型的最新监控指标(用于告警检查) func (s *MonitorService) GetLatestMonitorMetricsByType(ctx context.Context, monitorType string) ([]protocol.MonitorData, error) { // 查询数据库 diff --git a/web/src/api/monitor.ts b/web/src/api/monitor.ts index a5265f4..580a11a 100644 --- a/web/src/api/monitor.ts +++ b/web/src/api/monitor.ts @@ -1,5 +1,5 @@ import {del, get, post, put} from './request'; -import type {MonitorListResponse, MonitorTask, MonitorTaskRequest, PublicMonitor} from '../types'; +import type {MonitorDetail, MonitorListResponse, MonitorTask, MonitorTaskRequest, PublicMonitor} from '../types'; export const listMonitors = (page: number = 1, pageSize: number = 10, keyword?: string) => { const params = new URLSearchParams(); @@ -44,6 +44,11 @@ export const getMonitorAgentStats = (id: string) => { return get(`/monitors/${encodeURIComponent(id)}/agents`); }; +// 公开接口 - 获取监控详情(整合版,包含stats和agents) +export const getMonitorDetail = (id: string) => { + return get(`/monitors/${encodeURIComponent(id)}`); +}; + // VictoriaMetrics 时序数据点 export interface MetricDataPoint { timestamp: number; // 毫秒时间戳 diff --git a/web/src/components/monitor/AgentHealthIndicator.tsx b/web/src/components/monitor/AgentHealthIndicator.tsx new file mode 100644 index 0000000..e4663c6 --- /dev/null +++ b/web/src/components/monitor/AgentHealthIndicator.tsx @@ -0,0 +1,51 @@ +interface AgentHealthIndicatorProps { + total: number; + stats: { + up: number; + down: number; + unknown: number; + }; +} + +export const AgentHealthIndicator = ({ total, stats }: AgentHealthIndicatorProps) => { + const dots = []; + + // 生成 up 的绿点 + for (let i = 0; i < stats.up; i++) { + dots.push( + + ); + } + + // 生成 down 的红点 + for (let i = 0; i < stats.down; i++) { + dots.push( + + ); + } + + // 生成 unknown 的灰点 + for (let i = 0; i < stats.unknown; i++) { + dots.push( + + ); + } + + return ( +
+ {dots} + + ({stats.up}/{total}) + +
+ ); +}; diff --git a/web/src/components/monitor/CertBadge.tsx b/web/src/components/monitor/CertBadge.tsx new file mode 100644 index 0000000..75e4f9e --- /dev/null +++ b/web/src/components/monitor/CertBadge.tsx @@ -0,0 +1,27 @@ +import { ShieldCheck } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +interface CertBadgeProps { + expiryTime: number; + daysLeft: number; +} + +export const CertBadge = ({ expiryTime, daysLeft }: CertBadgeProps) => { + if (!expiryTime || daysLeft === undefined) return null; + + const isExpired = daysLeft < 0; + let colorClass = "text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-500/10 border-emerald-200 dark:border-emerald-500/20"; + + if (isExpired) { + colorClass = "text-rose-600 dark:text-rose-400 bg-rose-50 dark:bg-rose-500/10 border-rose-200 dark:border-rose-500/20"; + } else if (daysLeft < 30) { + colorClass = "text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-500/10 border-amber-200 dark:border-amber-500/20"; + } + + return ( +
+ + {isExpired ? "已过期" : `${daysLeft} 天后过期`} +
+ ); +}; diff --git a/web/src/components/monitor/MiniChart.tsx b/web/src/components/monitor/MiniChart.tsx new file mode 100644 index 0000000..b85693d --- /dev/null +++ b/web/src/components/monitor/MiniChart.tsx @@ -0,0 +1,43 @@ +import { Area, AreaChart, ResponsiveContainer } from 'recharts'; + +interface MiniChartProps { + data: Array<{ time: string; value: number }>; + status: string; + id: string; +} + +export const MiniChart = ({ data, status, id }: MiniChartProps) => { + if (!data || data.length === 0) { + return ( +
+ 暂无数据 +
+ ); + } + + const color = status === 'up' ? '#10b981' : status === 'down' ? '#f59e0b' : '#94a3b8'; + + return ( +
+ + + + + + + + + + + +
+ ); +}; diff --git a/web/src/components/monitor/ResponseTimeRange.tsx b/web/src/components/monitor/ResponseTimeRange.tsx new file mode 100644 index 0000000..0771d7b --- /dev/null +++ b/web/src/components/monitor/ResponseTimeRange.tsx @@ -0,0 +1,20 @@ +import {formatTime} from '@/utils/util'; + +interface ResponseTimeRangeProps { + min: number; + avg: number; + max: number; +} + +export const ResponseTimeRange = ({ min, avg, max }: ResponseTimeRangeProps) => { + return ( +
+ + {formatTime(avg)} + + + (范围: {formatTime(min)}-{formatTime(max)}) + +
+ ); +}; diff --git a/web/src/components/monitor/StatCard.tsx b/web/src/components/monitor/StatCard.tsx new file mode 100644 index 0000000..c72576b --- /dev/null +++ b/web/src/components/monitor/StatCard.tsx @@ -0,0 +1,26 @@ +import type {ReactNode} from 'react'; + +interface StatCardProps { + title: string; + value: string | number; + icon: ReactNode; + color?: string; +} + +export const StatCard = ({title, value, icon, color = "text-white dark:text-white"}: StatCardProps) => { + + console.log(`StatCard`, title, value, icon, color) + return ( +
+
+

{title}

+

{value}

+
+
+ {icon} +
+
+ ); +}; diff --git a/web/src/components/monitor/StatusBadge.tsx b/web/src/components/monitor/StatusBadge.tsx new file mode 100644 index 0000000..14e1e13 --- /dev/null +++ b/web/src/components/monitor/StatusBadge.tsx @@ -0,0 +1,35 @@ +import {cn} from '@/lib/utils'; + +interface StatusBadgeProps { + status: string; +} + +export const StatusBadge = ({status}: StatusBadgeProps) => { + const styles = { + up: "bg-emerald-500/10 dark:bg-emerald-500/10 text-emerald-700 dark:text-emerald-400 border-emerald-500/20", + down: "bg-rose-500/10 dark:bg-rose-500/10 text-rose-700 dark:text-rose-400 border-rose-500/20", + unknown: "bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-300 border-slate-200 dark:border-slate-700", + }; + + const labels = { + up: "正常", + down: "异常", + unknown: "未知", + }; + + const style = styles[status as keyof typeof styles] || styles.unknown; + const label = labels[status as keyof typeof labels] || labels.unknown; + + return ( + + + {label} + + ); +}; diff --git a/web/src/components/monitor/TypeIcon.tsx b/web/src/components/monitor/TypeIcon.tsx new file mode 100644 index 0000000..e91cf98 --- /dev/null +++ b/web/src/components/monitor/TypeIcon.tsx @@ -0,0 +1,21 @@ +import { Globe, Server, ShieldCheck, Wifi } from 'lucide-react'; + +interface TypeIconProps { + type: string; +} + +export const TypeIcon = ({ type }: TypeIconProps) => { + switch (type.toLowerCase()) { + case 'https': + return ; + case 'http': + return ; + case 'tcp': + return ; + case 'icmp': + case 'ping': + return ; + default: + return ; + } +}; diff --git a/web/src/pages/Public/MonitorDetail.tsx b/web/src/pages/Public/MonitorDetail.tsx index ad3f474..cb92465 100644 --- a/web/src/pages/Public/MonitorDetail.tsx +++ b/web/src/pages/Public/MonitorDetail.tsx @@ -52,12 +52,6 @@ const EmptyState = ({message = '监控数据不存在'}: { message?: string }) = ); const timeRangeOptions = [ - {label: '15分钟', value: '15m'}, - {label: '30分钟', value: '30m'}, - {label: '1小时', value: '1h'}, - {label: '3小时', value: '3h'}, - {label: '6小时', value: '6h'}, - {label: '12小时', value: '12h'}, {label: '1天', value: '1d'}, {label: '3天', value: '3d'}, {label: '7天', value: '7d'}, @@ -317,7 +311,7 @@ const MonitorDetail = () => { const navigate = useNavigate(); const {id} = useParams<{ id: string }>(); const [selectedAgent, setSelectedAgent] = useState('all'); - const [timeRange, setTimeRange] = useState('15m'); + const [timeRange, setTimeRange] = useState('1d'); // 获取监控详情(聚合数据) const {data: monitorDetail, isLoading} = useQuery({ diff --git a/web/src/pages/Public/MonitorList.tsx b/web/src/pages/Public/MonitorList.tsx index e99f889..1ed84b2 100644 --- a/web/src/pages/Public/MonitorList.tsx +++ b/web/src/pages/Public/MonitorList.tsx @@ -1,13 +1,17 @@ -import {useEffect} from 'react'; +import {useEffect, useMemo, useState} from 'react'; import {useNavigate} from 'react-router-dom'; import {useQuery} from '@tanstack/react-query'; -import {AlertCircle, CheckCircle2, Clock, Loader2, Shield} from 'lucide-react'; -import {getPublicMonitors} from '@/api/monitor.ts'; +import {AlertCircle, BarChart3, CheckCircle2, Clock, Loader2, Maximize2, Search, Server, Shield} from 'lucide-react'; +import {type GetMetricsResponse, getMonitorHistory, getPublicMonitors} from '@/api/monitor.ts'; import type {PublicMonitor} from '@/types'; -import {usePublicLayout} from '../PublicLayout'; import {cn} from '@/lib/utils'; -import {renderCert} from "@/pages/Public/Monitor.tsx"; -import {formatTime,} from "@/utils/util.ts" +import {formatDateTime} from "@/utils/util.ts"; +import {StatCard} from '@/components/monitor/StatCard'; +import {StatusBadge} from '@/components/monitor/StatusBadge'; +import {TypeIcon} from '@/components/monitor/TypeIcon'; +import {CertBadge} from '@/components/monitor/CertBadge'; +import {AgentHealthIndicator} from '@/components/monitor/AgentHealthIndicator'; +import {MiniChart} from '@/components/monitor/MiniChart'; const LoadingSpinner = () => (
@@ -18,33 +22,6 @@ const LoadingSpinner = () => (
); -const StatusBadge = ({status}: { status: string }) => { - let containerClass = 'bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-300'; - let label = '未知'; - let icon = ; - - if (status === 'up') { - containerClass = 'bg-emerald-50 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300'; - label = '正常'; - icon = ; - } else if (status === 'down') { - containerClass = 'bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-300'; - label = '异常'; - icon = ; - } - - return ( -
- {icon} - {label} -
- ); -}; - const EmptyState = () => (
@@ -53,17 +30,177 @@ const EmptyState = () => (
); +type DisplayMode = 'avg' | 'max'; +type FilterStatus = 'all' | 'up' | 'down' | 'unknown'; + +// 监控卡片组件 - 单独抽出以便使用独立的历史数据查询 +const MonitorCard = ({ + monitor, + displayMode, + onClick + }: { + monitor: PublicMonitor; + displayMode: DisplayMode; + onClick: () => void; +}) => { + // 为每个监控卡片查询历史数据(15分钟) + const {data: historyData} = useQuery({ + queryKey: ['monitorHistory', monitor.id, '15m'], + queryFn: async () => { + const response = await getMonitorHistory(monitor.id, '1h'); + return response.data; + }, + refetchInterval: 60000, // 1分钟刷新一次 + staleTime: 30000, // 30秒内认为数据是新鲜的 + }); + + // 将 VictoriaMetrics 的时序数据转换为图表数据(使用平均值或最大值) + const chartData = useMemo(() => { + if (!historyData || !historyData.series || historyData.series.length === 0) { + return []; + } + + // 创建一个时间戳到数据点的映射 + const timeMap = new Map(); + + // 遍历所有系列(每个探针一个系列) + historyData.series.forEach(series => { + if (series.data && series.data.length > 0) { + series.data.forEach(point => { + if (!timeMap.has(point.timestamp)) { + timeMap.set(point.timestamp, []); + } + timeMap.get(point.timestamp)!.push(point.value); + }); + } + }); + + // 转换为图表数据格式,并计算聚合值 + const result = Array.from(timeMap.entries()) + .map(([timestamp, values]) => { + let aggregatedValue: number; + if (displayMode === 'avg') { + // 计算平均值 + aggregatedValue = Math.round(values.reduce((a, b) => a + b, 0) / values.length); + } else { + // 使用最大值 + aggregatedValue = Math.max(...values); + } + + return { + time: new Date(timestamp).toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'}), + value: aggregatedValue, + }; + }); + + return result; + }, [historyData, displayMode]); + + const displayValue = displayMode === 'avg' ? monitor.responseTime : monitor.responseTimeMax; + const displayLabel = displayMode === 'avg' ? '平均延迟' : '最差节点延迟'; + + return ( +
+
+ {/* 头部:图标、名称、状态 */} +
+
+
+ +
+
+

+ {monitor.name} +

+

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

+
+
+
+ +
+
+ + {/* 探针健康度 */} + {monitor.agentStats && monitor.agentCount > 0 && ( +
+ +
+ )} + + {/* 指标信息 */} +
+
+

+ {displayLabel} + {monitor.agentCount > 0 && ( + + {monitor.agentCount} 节点 + + )} +

+

200 ? 'text-amber-600 dark:text-amber-400' : 'text-slate-900 dark:text-slate-200' + )}> + {displayValue} ms +

+
+
+ {monitor.type === 'https' && monitor.certExpiryTime ? ( + <> +

SSL 证书

+ + + ) : ( + <> +

上次检测

+

+ {formatDateTime(monitor.lastCheckTime)} +

+ + )} +
+
+ + {/* 迷你走势图 */} + +
+
+ ); +}; + +interface Stats { + total: number; + online: number; + issues: number; + avgLatency: number; +} + const MonitorList = () => { const navigate = useNavigate(); - const {viewMode, setShowViewToggle} = usePublicLayout(); + const [filter, setFilter] = useState('all'); + const [searchKeyword, setSearchKeyword] = useState(''); + const [displayMode, setDisplayMode] = useState('max'); - // 挂载时启用视图切换,卸载时禁用 - useEffect(() => { - setShowViewToggle(true); - return () => setShowViewToggle(false); - }, [setShowViewToggle]); - - const {data: monitors = [], isLoading, dataUpdatedAt} = useQuery({ + const {data: monitors = [], isLoading} = useQuery({ queryKey: ['publicMonitors'], queryFn: async () => { const response = await getPublicMonitors(); @@ -72,74 +209,45 @@ const MonitorList = () => { refetchInterval: 30000, // 30秒刷新一次 }); - const monitorSummaries = monitors; + let [stats, setStats] = useState(); - const renderListView = () => ( - <> - {/* 桌面端:使用表格布局 */} -
- - - - - - - - - - - {monitorSummaries.map((stats) => { - return ( - navigate(`/monitors/${encodeURIComponent(stats.id)}`)} - onKeyDown={(event) => { - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault(); - navigate(`/monitors/${encodeURIComponent(stats.id)}`); - } - }} - className="cursor-pointer transition hover:bg-slate-50 dark:hover:bg-slate-800/50 focus-within:bg-slate-50 dark:focus-within:bg-slate-800/50 focus:outline-none focus-visible:ring-2 focus-visible:ring-slate-200" - > - - - - - - ); - })} - -
监控项状态当前响应证书信息
-
-
- {stats.name} -
-
- {stats.target} -
- {stats.agentCount > 1 && ( -
- {stats.agentCount} 个探针 -
- )} -
-
- - -
- - - {formatTime(stats.responseTime)} - -
-
- {renderCert(stats)} -
-
- - ); + // 过滤和搜索 + const filteredMonitors = useMemo(() => { + let result = monitors; + + // 状态过滤 + if (filter !== 'all') { + result = result.filter(m => m.status === filter); + } + + // 搜索过滤 + if (searchKeyword.trim()) { + const keyword = searchKeyword.toLowerCase(); + result = result.filter(m => + m.name.toLowerCase().includes(keyword) || + m.target.toLowerCase().includes(keyword) + ); + } + + return result; + }, [monitors, filter, searchKeyword]); + + // 统计信息 + const calculateStats = (monitors: PublicMonitor[]) => { + const total = monitors.length; + const online = monitors.filter(m => m.status === 'up').length; + const issues = total - online; + const avgLatency = total > 0 + ? Math.round(monitors.reduce((acc, curr) => acc + curr.responseTime, 0) / total) + : 0; + return {total, online, issues, avgLatency}; + } + useEffect(() => { + let stats = calculateStats(monitors); + setStats(stats); + }, [monitors]); + + console.log(`stats`, stats) if (isLoading) { return ( @@ -150,10 +258,117 @@ const MonitorList = () => { } return ( -
- {monitorSummaries.length === 0 ? ( +
+ {/* 统计卡片 */} +
+ } + color="text-gray-700 dark:text-gray-400" + /> + } + color="text-emerald-700 dark:text-emerald-400" + /> + } + color={stats?.issues > 0 ? "text-rose-700 dark:text-rose-400" : "text-slate-600 dark:text-slate-400"} + /> + } + color="text-gray-700 dark:text-gray-400" + /> +
+ + {/* 过滤和搜索 */} +
+
+ {/* 状态过滤 */} +
+ {(['all', 'up', 'down', 'unknown'] as const).map(f => { + const labels = {all: '全部', up: '正常', down: '异常', unknown: '未知'}; + return ( + + ); + })} +
+ + {/* 显示模式切换 */} +
+ 卡片指标: + + +
+
+ + {/* 搜索框 */} +
+ + setSearchKeyword(e.target.value)} + className="w-full bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg pl-9 pr-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 text-slate-900 dark:text-slate-200" + /> +
+
+ + {/* 监控卡片列表 */} + {filteredMonitors.length === 0 ? ( - ) : renderListView()} + ) : ( +
+ {filteredMonitors.map(monitor => ( + navigate(`/monitors/${encodeURIComponent(monitor.id)}`)} + /> + ))} +
+ )}
); }; diff --git a/web/src/types/index.ts b/web/src/types/index.ts index 3acd332..01d95a5 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -296,11 +296,46 @@ export interface PublicMonitor { status: string; responseTime: number; + responseTimeMin: number; + responseTimeMax: number; certExpiryTime: number; certDaysLeft: number; + agentStats: { + up: number; + down: number; + unknown: number; + }; lastCheckTime: number; } +// 监控详情(整合版) +export interface MonitorDetail { + id: string; + name: string; + type: 'http' | 'https' | 'tcp' | 'icmp' | 'ping'; + target: string; + showTargetPublic: boolean; + description?: string; + enabled: boolean; + interval: number; + stats: { + status: string; + responseTime: number; + responseTimeMin: number; + responseTimeMax: number; + lastCheckTime: number; + agentCount: number; + agentStats: { + up: number; + down: number; + unknown: number; + }; + certExpiryTime: number; + certDaysLeft: number; + }; + agents: AgentMonitorStat[]; +} + // 监控统计数据 export interface CertStats { certExpiryTime: number; From 16fd34f16aab3e7cdd2a7457a3e3b327f2972764 Mon Sep 17 00:00:00 2001 From: dushixiang Date: Sun, 14 Dec 2025 16:01:19 +0800 Subject: [PATCH 11/18] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=85=AC=E5=85=B1?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/components/CompactResourceBar.tsx | 78 ++ web/src/components/PublicFooter.tsx | 31 +- web/src/components/PublicHeader.tsx | 274 ++---- web/src/components/StatCard.tsx | 35 + .../monitor/AgentHealthIndicator.tsx | 51 -- .../components/monitor/ResponseTimeRange.tsx | 20 - web/src/components/monitor/StatCard.tsx | 26 - web/src/pages/Public/MonitorDetail.tsx | 709 +++++++-------- web/src/pages/Public/MonitorList.tsx | 196 ++-- web/src/pages/Public/ServerDetail.tsx | 31 +- web/src/pages/Public/ServerList.tsx | 836 +++++++----------- web/src/pages/PublicLayout.tsx | 70 +- web/src/utils/util.ts | 18 + 13 files changed, 982 insertions(+), 1393 deletions(-) create mode 100644 web/src/components/CompactResourceBar.tsx create mode 100644 web/src/components/StatCard.tsx delete mode 100644 web/src/components/monitor/AgentHealthIndicator.tsx delete mode 100644 web/src/components/monitor/ResponseTimeRange.tsx delete mode 100644 web/src/components/monitor/StatCard.tsx diff --git a/web/src/components/CompactResourceBar.tsx b/web/src/components/CompactResourceBar.tsx new file mode 100644 index 0000000..9af1360 --- /dev/null +++ b/web/src/components/CompactResourceBar.tsx @@ -0,0 +1,78 @@ +// 紧凑型资源条组件 +const CompactResourceBar = ({ value, label, subtext, icon: Icon, color = "bg-cyan-500" }) => { + const isCritical = value > 90; + const isWarning = value > 75; + + // 颜色定义 (Hex codes for precise control) + let activeColor = ""; + let iconClass = ""; + + if (isCritical) { + activeColor = "#f43f5e"; // Rose + iconClass = "text-rose-500"; + } else if (isWarning) { + activeColor = "#f59e0b"; // Amber + iconClass = "text-amber-500"; + } else if (color.includes("purple")) { + activeColor = "#a855f7"; // Purple + iconClass = "text-purple-500"; + } else if (color.includes("blue")) { + activeColor = "#3b82f6"; // Blue + iconClass = "text-blue-500"; + } else { + activeColor = "#06b6d4"; // Cyan (Default) + iconClass = "text-cyan-500"; + } + + return ( +
+ {/* Icon & Label */} +
+ + {label} +
+ + {/* Track Container */} +
+ + {/* Scale Marks Background (The "Ruler" effect) */} +
+ + {/* Active Bar with Industrial Stripes */} +
+ {/* Leading Edge Laser Line */} +
+
+
+ +
{value.toFixed(1)}%
+ + {/* Value & Subtext */} +
+ + {subtext && ( + + {subtext} + + )} +
+
+ ); +}; + +export default CompactResourceBar; \ No newline at end of file diff --git a/web/src/components/PublicFooter.tsx b/web/src/components/PublicFooter.tsx index bb229ce..775b2da 100644 --- a/web/src/components/PublicFooter.tsx +++ b/web/src/components/PublicFooter.tsx @@ -1,53 +1,54 @@ -import {Heart} from 'lucide-react'; +import {Heart, Github} from 'lucide-react'; const PublicFooter = () => { const currentYear = new Date().getFullYear(); const icpCode = window.SystemConfig?.ICPCode || ''; return ( -
+
- {/* 底部版权信息 */}
-
-
- © {currentYear} +
+
+ © {currentYear} + | {/* GitHub 链接 */} -
Pika Monitor
- {/*GitHub*/} + + Pika Monitor
- · - 保持洞察,稳定运行 + | + 保持洞察 · 稳定运行 {/* ICP 备案号 */} {icpCode && ( <> - · + | {icpCode} )}
-
+
用 - + 构建
+
); }; diff --git a/web/src/components/PublicHeader.tsx b/web/src/components/PublicHeader.tsx index d491ce1..6a7a1e3 100644 --- a/web/src/components/PublicHeader.tsx +++ b/web/src/components/PublicHeader.tsx @@ -1,23 +1,11 @@ -import {useEffect, useRef, useState} from 'react'; -import {Activity, LayoutGrid, List, LogIn, Moon, Server, Settings, Sun} from 'lucide-react'; +import {useEffect, useState} from 'react'; +import {Activity, Hexagon, LogIn, ServerIcon, Settings} from 'lucide-react'; import {getCurrentUser} from '../api/auth'; import {Link, useLocation} from "react-router-dom"; -import {flushSync} from "react-dom"; -interface PublicHeaderProps { - viewMode?: 'grid' | 'list'; - onViewModeChange?: (mode: 'grid' | 'list') => void; - showViewToggle?: boolean; -} - -const PublicHeader = ({ - viewMode, - onViewModeChange, - showViewToggle = false - }: PublicHeaderProps) => { +const PublicHeader = () => { const [isLoggedIn, setIsLoggedIn] = useState(false); - const [isDarkMode, setIsDarkMode] = useState(false); - const darkModeButtonRef = useRef(null); + const [currentTime, setCurrentTime] = useState(new Date()); let location = useLocation(); useEffect(() => { @@ -43,89 +31,31 @@ const PublicHeader = ({ }); }, []); - // 初始化暗色主题 + // 时钟特效 useEffect(() => { - const savedTheme = localStorage.getItem('theme'); - const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; - const shouldBeDark = savedTheme === 'dark' || (!savedTheme && prefersDark); - - setIsDarkMode(shouldBeDark); - if (shouldBeDark) { - document.documentElement.classList.add('dark'); - } else { - document.documentElement.classList.remove('dark'); - } + const t = setInterval(() => setCurrentTime(new Date()), 1000); + return () => clearInterval(t); }, []); - // 切换暗色主题的函数,带动画效果 - const toggleDarkMode = async (newIsDarkMode: boolean) => { - if ( - !darkModeButtonRef.current || - !document.startViewTransition || - window.matchMedia('(prefers-reduced-motion: reduce)').matches - ) { - // 如果不支持 View Transition API 或用户偏好减少动画,直接切换 - setIsDarkMode(newIsDarkMode); - if (newIsDarkMode) { - document.documentElement.classList.add('dark'); - } else { - document.documentElement.classList.remove('dark'); - } - localStorage.setItem('theme', newIsDarkMode ? 'dark' : 'light'); - return; - } - - await document.startViewTransition(() => { - flushSync(() => { - setIsDarkMode(newIsDarkMode); - if (newIsDarkMode) { - document.documentElement.classList.add('dark'); - } else { - document.documentElement.classList.remove('dark'); - } - localStorage.setItem('theme', newIsDarkMode ? 'dark' : 'light'); - }); - }).ready; - - const {top, left, width, height} = darkModeButtonRef.current.getBoundingClientRect(); - const x = left + width / 2; - const y = top + height / 2; - const right = window.innerWidth - left; - const bottom = window.innerHeight - top; - const maxRadius = Math.hypot( - Math.max(left, right), - Math.max(top, bottom), - ); - - document.documentElement.animate( - { - clipPath: [ - `circle(0px at ${x}px ${y}px)`, - `circle(${maxRadius}px at ${x}px ${y}px)`, - ], - }, - { - duration: 500, - easing: 'ease-in-out', - pseudoElement: '::view-transition-new(root)', - } - ); - }; - // 判断导航是否激活 const currentPath = location.pathname; - const isDeviceActive = currentPath === '/'; - const isMonitorActive = currentPath === '/monitors'; + + let activeTab = 'servers'; + switch (currentPath) { + case '/': + activeTab = 'servers'; + break; + case '/monitors': + activeTab = 'monitors'; + break; + } return ( -
-
-
- {/* 左侧:品牌和导航 */} -
- {/* Logo 和品牌 */} -
+
+
+
+
+
-
-

- {window.SystemConfig?.SystemNameEn} -

-

- {window.SystemConfig?.SystemNameZh} -

-
- - {/* 导航链接 */} - +
+

+ {window.SystemConfig?.SystemNameZh} +

+

+ {window.SystemConfig?.SystemNameEn} +

+
- {/* 右侧:功能区 */} -
- {/* 暗色主题切换 */} - - - {/* 视图切换 */} - {showViewToggle && viewMode && onViewModeChange && ( -
+ {/* HUD Navigation - Simplified & No Border */} +
+ {[ + {id: 'servers', icon: ServerIcon, label: '设备监控', to: '/'}, + {id: 'monitors', icon: Activity, label: '服务监控', to: '/monitors'} + ].map(tab => ( + - -
- )} + + {tab.label} - {/* 登录/管理后台按钮 */} - {isLoggedIn ? ( - - - 管理后台 - - ) : ( - - - 登录 - - )} + {/* Active Indicator (Underline Glow) */} + + + + ))}
+ +
+
+ {currentTime.toLocaleTimeString()} + {currentTime.toLocaleDateString()} +
+
+ + {/* 登录/管理后台按钮 */} + {isLoggedIn ? ( + + + Admin + + ) : ( + + + Login + + )} +
+
); }; diff --git a/web/src/components/StatCard.tsx b/web/src/components/StatCard.tsx new file mode 100644 index 0000000..ad0d614 --- /dev/null +++ b/web/src/components/StatCard.tsx @@ -0,0 +1,35 @@ +// 统计卡片组件 +const StatCard = ({title, value, icon: Icon, color}: { + title: string; + value: any; + icon: any; + color: string; +}) => { + const colorMap: any = { + gray: 'text-slate-400 border-slate-500/30 bg-slate-500/5', + emerald: 'text-emerald-400 border-emerald-500/30 bg-emerald-500/5', + rose: 'text-rose-400 border-rose-500/30 bg-rose-500/5', + blue: 'text-blue-400 border-blue-500/30 bg-blue-500/5' + }; + const style = colorMap[color] || colorMap.gray; + + return ( +
0 ? 'animate-pulse bg-rose-500/10' : ''}`}> +
+ +
+
+
+
{title}
+
{value}
+
+
+ +
+
+
+ ); +}; + +export default StatCard; \ No newline at end of file diff --git a/web/src/components/monitor/AgentHealthIndicator.tsx b/web/src/components/monitor/AgentHealthIndicator.tsx deleted file mode 100644 index e4663c6..0000000 --- a/web/src/components/monitor/AgentHealthIndicator.tsx +++ /dev/null @@ -1,51 +0,0 @@ -interface AgentHealthIndicatorProps { - total: number; - stats: { - up: number; - down: number; - unknown: number; - }; -} - -export const AgentHealthIndicator = ({ total, stats }: AgentHealthIndicatorProps) => { - const dots = []; - - // 生成 up 的绿点 - for (let i = 0; i < stats.up; i++) { - dots.push( - - ); - } - - // 生成 down 的红点 - for (let i = 0; i < stats.down; i++) { - dots.push( - - ); - } - - // 生成 unknown 的灰点 - for (let i = 0; i < stats.unknown; i++) { - dots.push( - - ); - } - - return ( -
- {dots} - - ({stats.up}/{total}) - -
- ); -}; diff --git a/web/src/components/monitor/ResponseTimeRange.tsx b/web/src/components/monitor/ResponseTimeRange.tsx deleted file mode 100644 index 0771d7b..0000000 --- a/web/src/components/monitor/ResponseTimeRange.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import {formatTime} from '@/utils/util'; - -interface ResponseTimeRangeProps { - min: number; - avg: number; - max: number; -} - -export const ResponseTimeRange = ({ min, avg, max }: ResponseTimeRangeProps) => { - return ( -
- - {formatTime(avg)} - - - (范围: {formatTime(min)}-{formatTime(max)}) - -
- ); -}; diff --git a/web/src/components/monitor/StatCard.tsx b/web/src/components/monitor/StatCard.tsx deleted file mode 100644 index c72576b..0000000 --- a/web/src/components/monitor/StatCard.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import type {ReactNode} from 'react'; - -interface StatCardProps { - title: string; - value: string | number; - icon: ReactNode; - color?: string; -} - -export const StatCard = ({title, value, icon, color = "text-white dark:text-white"}: StatCardProps) => { - - console.log(`StatCard`, title, value, icon, color) - return ( -
-
-

{title}

-

{value}

-
-
- {icon} -
-
- ); -}; diff --git a/web/src/pages/Public/MonitorDetail.tsx b/web/src/pages/Public/MonitorDetail.tsx index cb92465..e6913ec 100644 --- a/web/src/pages/Public/MonitorDetail.tsx +++ b/web/src/pages/Public/MonitorDetail.tsx @@ -2,19 +2,9 @@ import type {ReactNode} from 'react'; import {useEffect, useMemo, useState} from 'react'; import {useNavigate, useParams} from 'react-router-dom'; import {useQuery} from '@tanstack/react-query'; -import { - AlertCircle, - ArrowLeft, - CheckCircle2, - Clock, - Globe, - Loader2, - Server as ServerIcon, - Shield, - TrendingUp -} from 'lucide-react'; +import {AlertCircle, ArrowLeft, Clock, Loader2, MapPin, TrendingUp} from 'lucide-react'; import type {TooltipProps} from 'recharts'; -import {Area, AreaChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis,} from 'recharts'; +import {Area, AreaChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis} from 'recharts'; import { type AgentMonitorStat, type GetMetricsResponse, @@ -25,37 +15,43 @@ import { import type {PublicMonitor} from '@/types'; import {cn} from '@/lib/utils'; import {formatDateTime, formatTime} from "@/utils/util.ts"; -import {renderCert} from "@/pages/Public/Monitor.tsx"; - - -const formatPercentValue = (value: number): string => (Number.isFinite(value) ? value.toFixed(2) : '0.00'); +import {StatusBadge} from '@/components/monitor/StatusBadge'; +import {TypeIcon} from '@/components/monitor/TypeIcon'; +import {CertBadge} from '@/components/monitor/CertBadge'; const LoadingSpinner = () => ( -
+
- -

数据加载中,请稍候...

+ +

数据加载中,请稍候...

); const EmptyState = ({message = '监控数据不存在'}: { message?: string }) => ( -
+
-
- +
+
-

{message}

+

{message}

+
); const timeRangeOptions = [ + {label: '1小时', value: '1h'}, + {label: '6小时', value: '6h'}, {label: '1天', value: '1d'}, {label: '3天', value: '3d'}, {label: '7天', value: '7d'}, -] +]; const ChartPlaceholder = ({ icon: Icon = TrendingUp, @@ -76,7 +72,7 @@ const ChartPlaceholder = ({ >
-

{title}

+

{title}

{subtitle ?

{subtitle}

: null}
@@ -93,170 +89,74 @@ const Card = ({ action?: ReactNode; children: ReactNode; }) => ( -
+
{(title || description || action) && ( -
+
- {title ? -

{title}

: null} - {description ? -

{description}

: null} + {title &&

{title}

} + {description &&

{description}

}
- {action ?
{action}
: null} + {action &&
{action}
}
)}
{children}
); -const StatusBadge = ({status}: { status: string }) => { - let containerClass = 'bg-slate-100 dark:bg-slate-800/80 text-slate-600 dark:text-slate-400'; - let label = '未知'; - let icon = ; - - if (status === 'up') { - containerClass = 'bg-emerald-50 dark:bg-emerald-500/15 text-emerald-700 dark:text-emerald-200'; - label = '正常'; - icon = ; - } else if (status === 'down') { - containerClass = 'bg-red-50 dark:bg-rose-500/15 text-red-700 dark:text-rose-200'; - label = '异常'; - icon = ; - } - - return ( -
- {icon} - {label} -
- ); -}; - -const UptimeBar = ({uptime}: { uptime: number }) => { - const percentage = Math.min(Math.max(uptime, 0), 100); - const colorClass = percentage >= 99 ? 'bg-emerald-500' : percentage >= 95 ? 'bg-yellow-500' : 'bg-red-500'; - - return ( -
-
-
-
- - {formatPercentValue(percentage)}% - -
- ); -}; - -const statThemes = { - blue: { - icon: 'bg-slate-200 dark:bg-slate-700 text-slate-700 dark:text-slate-300', - accent: 'text-slate-700 dark:text-slate-300', - }, - emerald: { - icon: 'bg-slate-200 dark:bg-slate-700 text-slate-700 dark:text-slate-300', - accent: 'text-slate-700 dark:text-slate-300', - }, - amber: { - icon: 'bg-slate-200 dark:bg-slate-700 text-slate-700 dark:text-slate-300', - accent: 'text-slate-700 dark:text-slate-300', - }, - rose: { - icon: 'bg-slate-200 dark:bg-slate-700 text-slate-700 dark:text-slate-300', - accent: 'text-slate-700 dark:text-slate-300', - }, -}; - -const StatCard = ({icon, label, value, color = 'blue'}: { - icon: ReactNode; +const StatCard = ({ + label, + value, + sublabel + }: { label: string; value: string | number; - color?: string; -}) => { - const theme = statThemes[color as keyof typeof statThemes] ?? statThemes.blue; + sublabel?: string; +}) => ( +
+

{label}

+

{value}

+ {sublabel &&

{sublabel}

} +
+); - return ( -
-
-
-
- {icon} -
-
-
- {label} -
-
当前指标
-
-
- {value} -
-
- ); -}; - -const CustomTooltip = ({active, payload, label, unit = ' ms'}: TooltipProps & { - unit?: string; - label?: string; - payload?: any[] -}) => { +const CustomTooltip = ({active, payload, label}: TooltipProps) => { if (!active || !payload || payload.length === 0) { return null; } - // 从 payload 中获取完整的时间戳信息(如果有的话) const fullTimestamp = payload[0]?.payload?.timestamp; const displayLabel = fullTimestamp ? new Date(fullTimestamp).toLocaleString('zh-CN', { - year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', - second: '2-digit' }) : label; return ( -
-

{displayLabel}

-
+
+

{displayLabel}

+
{payload.map((entry, index) => { - if (!entry) { - return null; - } + if (!entry) return null; - const dotColor = entry.color ?? '#6366f1'; - const title = entry.name ?? entry.dataKey ?? `系列 ${index + 1}`; - const value = - typeof entry.value === 'number' - ? Number.isFinite(entry.value) - ? entry.value.toFixed(2) - : '-' - : entry.value; + const dotColor = entry.color ?? '#3b82f6'; + const title = entry.name ?? `系列 ${index + 1}`; + const value = typeof entry.value === 'number' && Number.isFinite(entry.value) + ? entry.value.toFixed(2) + : '-'; return ( -

+

- - {title}: {value}{unit} + + {title}: {value} ms -

+
); })}
@@ -270,7 +170,7 @@ const TimeRangeSelector = ({ options, }: { value: string; - onChange: (value: any) => void; + onChange: (value: string) => void; options: readonly { label: string; value: string }[]; }) => (
@@ -282,10 +182,10 @@ const TimeRangeSelector = ({ type="button" onClick={() => onChange(option.value)} className={cn( - "rounded-lg border px-3 py-1.5 text-sm transition", + "rounded-lg border px-3 py-1.5 text-xs font-medium transition-all", isActive - ? 'border-slate-300 dark:border-slate-600 bg-slate-600 dark:bg-slate-700 text-white' - : 'border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-600 dark:text-slate-300 hover:border-slate-300 dark:hover:border-slate-600 hover:text-slate-700 dark:hover:text-slate-200' + ? 'border-blue-500 dark:border-blue-500 bg-blue-500 text-white shadow-sm' + : 'border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-600 dark:text-slate-300 hover:border-blue-300 dark:hover:border-blue-600 hover:text-blue-600 dark:hover:text-blue-400' )} > {option.label} @@ -297,7 +197,7 @@ const TimeRangeSelector = ({ // 预定义的颜色方案 const AGENT_COLORS = [ - '#2563eb', // blue + '#3b82f6', // blue '#10b981', // emerald '#f59e0b', // amber '#8b5cf6', // violet @@ -360,9 +260,7 @@ const MonitorDetail = () => { // 当可用探针列表变化时,检查当前选择的探针是否还存在 useEffect(() => { - if (selectedAgent === 'all') { - return; - } + if (selectedAgent === 'all') return; if (!availableAgents.find(agent => agent.id === selectedAgent)) { setSelectedAgent('all'); } @@ -395,7 +293,6 @@ const MonitorDetail = () => { time: new Date(point.timestamp).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', - second: '2-digit', }), timestamp: point.timestamp, }; @@ -416,267 +313,269 @@ const MonitorDetail = () => { return ; } - const monitorTitle = monitorDetail.name ?? '监控详情'; - - const heroStats = [ - {label: '监控类型', value: monitorDetail.type.toUpperCase()}, - {label: '探针数量', value: `${monitorDetail.agentCount} 个`}, - {label: '当前响应', value: `${monitorDetail.responseTime}ms`}, - {label: '当前状态', value: `${monitorDetail.status}`}, - ]; - return ( -
-
+
+
{/* Hero Section */} -
-
-
-
-
- -
-
- {monitorDetail.type === 'tcp' ? ( - - ) : ( - - )} -
-
-
-

{monitorTitle}

-
-

- {monitorDetail.target} -

+
+
+ {/* 返回按钮 */} + + + {/* 监控信息 */} +
+
+
+ +
+
+
+

{monitorDetail.name}

+
+

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

-
- {heroStats.map((stat) => ( -
-

{stat.label}

-

{stat.value}

-
- ))} + {/* 统计卡片 */} +
+ + + +
-
- 监控 ID:{monitorDetail.id || id} - - 探针数量:{monitorDetail.agentCount} 个 - - 目标:{monitorDetail.target} -
+ + {/* 证书信息(如果是 HTTPS)*/} + {monitorDetail.type === 'https' && monitorDetail.certExpiryTime && ( +
+ SSL 证书: +
+ +
+
+ )}
-
- {/* 响应时间趋势图表 */} - - - {availableAgents.length > 0 && ( - - )} -
- } - > - {chartData.length > 0 ? ( - - - - {monitorStats.map((stat, index) => { + {/* 响应时间趋势图表 */} + + + {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[index % AGENT_COLORS.length]; + const color = AGENT_COLORS[originalIndex % AGENT_COLORS.length]; return ( - + ); })} - - - - `${value} ms`} - /> - } wrapperStyle={{ - zIndex: 50, - }}/> - - {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 ( - - ); - })} - - - ) : ( - - )} - + + + + `${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 ( + + ); + })} + + + ) : ( + + )} + - {/* 各探针详细数据 */} - -
-
-
- - - - - - - - - - - - - {monitorStats.map((stats, index) => { - const color = AGENT_COLORS[index % AGENT_COLORS.length]; - return ( - - - - - - - - - ); - })} - -
- 探针 ID - - 状态 - - 当前响应 - - 最后检测 - - 证书信息 - - 错误信息 -
-
- -
- - {stats.agentId.substring(0, 8)}... - -
-
-
- - -
- - - {formatTime(stats.responseTime)} - -
-
- {formatDateTime(stats.checkedAt)} - - {renderCert(stats)} - - {stats.status === 'down' ? ( -
-
- - - {stats.message} - -
-
- ) : ( - - - )} -
-
-
-
-
-
+ {/* 各探针详细数据 */} + +
+ + + + + + + + {monitorDetail.type === 'https' && ( + + )} + + + + + {monitorStats.map((stat, index) => { + const color = AGENT_COLORS[index % AGENT_COLORS.length]; + return ( + + + + + + {monitorDetail.type === 'https' && ( + + )} + + + ); + })} + +
+ 探针 ID + + 状态 + + 响应时间 + + 最后检测 + + 证书信息 + + 错误信息 +
+
+ +
+ + + {stat.agentId.substring(0, 8)}... + +
+
+
+ + +
+ + + {formatTime(stat.responseTime)} + +
+
+ {formatDateTime(stat.checkedAt)} + + {stat.certExpiryTime ? ( + + ) : ( + - + )} + + {stat.status === 'down' && stat.message ? ( +
+ + + {stat.message} + +
+ ) : ( + - + )} +
+
+
); diff --git a/web/src/pages/Public/MonitorList.tsx b/web/src/pages/Public/MonitorList.tsx index 1ed84b2..e7759a2 100644 --- a/web/src/pages/Public/MonitorList.tsx +++ b/web/src/pages/Public/MonitorList.tsx @@ -1,69 +1,91 @@ import {useEffect, useMemo, useState} from 'react'; import {useNavigate} from 'react-router-dom'; import {useQuery} from '@tanstack/react-query'; -import {AlertCircle, BarChart3, CheckCircle2, Clock, Loader2, Maximize2, Search, Server, Shield} from 'lucide-react'; +import { + Activity, + AlertCircle, + BarChart3, + CheckCircle2, + Clock, + Globe, + Loader2, + Maximize2, + Search, + Server, + Shield, + ShieldCheck, + Wifi +} from 'lucide-react'; import {type GetMetricsResponse, getMonitorHistory, getPublicMonitors} from '@/api/monitor.ts'; import type {PublicMonitor} from '@/types'; import {cn} from '@/lib/utils'; import {formatDateTime} from "@/utils/util.ts"; -import {StatCard} from '@/components/monitor/StatCard'; import {StatusBadge} from '@/components/monitor/StatusBadge'; -import {TypeIcon} from '@/components/monitor/TypeIcon'; import {CertBadge} from '@/components/monitor/CertBadge'; -import {AgentHealthIndicator} from '@/components/monitor/AgentHealthIndicator'; import {MiniChart} from '@/components/monitor/MiniChart'; +import StatCard from "@/components/StatCard.tsx"; const LoadingSpinner = () => (
-
- - 加载监控数据中... +
+ + 加载监控数据中...
); const EmptyState = () => ( -
+
-

暂无监控数据

-

请先在管理后台添加监控任务

+

暂无监控数据

+

请先在管理后台添加监控任务

); type DisplayMode = 'avg' | 'max'; type FilterStatus = 'all' | 'up' | 'down' | 'unknown'; -// 监控卡片组件 - 单独抽出以便使用独立的历史数据查询 -const MonitorCard = ({ - monitor, - displayMode, - onClick - }: { +// 类型图标组件 +const TypeIcon = ({type}: { type: string }) => { + switch (type.toUpperCase()) { + case 'HTTPS': + return ; + case 'HTTP': + return ; + case 'TCP': + return ; + case 'ICMP': + return ; + default: + return ; + } +}; + +// 监控卡片组件 +const MonitorCard = ({monitor, displayMode, onClick}: { monitor: PublicMonitor; displayMode: DisplayMode; onClick: () => void; }) => { - // 为每个监控卡片查询历史数据(15分钟) + // 为每个监控卡片查询历史数据 const {data: historyData} = useQuery({ - queryKey: ['monitorHistory', monitor.id, '15m'], + queryKey: ['monitorHistory', monitor.id, '1h'], queryFn: async () => { const response = await getMonitorHistory(monitor.id, '1h'); return response.data; }, - refetchInterval: 60000, // 1分钟刷新一次 - staleTime: 30000, // 30秒内认为数据是新鲜的 + refetchInterval: 60000, + staleTime: 30000, }); - // 将 VictoriaMetrics 的时序数据转换为图表数据(使用平均值或最大值) + // 将 VictoriaMetrics 的时序数据转换为图表数据 const chartData = useMemo(() => { if (!historyData || !historyData.series || historyData.series.length === 0) { return []; } - // 创建一个时间戳到数据点的映射 const timeMap = new Map(); - // 遍历所有系列(每个探针一个系列) historyData.series.forEach(series => { if (series.data && series.data.length > 0) { series.data.forEach(point => { @@ -75,15 +97,12 @@ const MonitorCard = ({ } }); - // 转换为图表数据格式,并计算聚合值 const result = Array.from(timeMap.entries()) .map(([timestamp, values]) => { let aggregatedValue: number; if (displayMode === 'avg') { - // 计算平均值 aggregatedValue = Math.round(values.reduce((a, b) => a + b, 0) / values.length); } else { - // 使用最大值 aggregatedValue = Math.max(...values); } @@ -102,23 +121,35 @@ const MonitorCard = ({ return (
-
- {/* 头部:图标、名称、状态 */} + {/* 装饰性边框 */} +
+
+
+
+
+ +
+ {/* 头部 */}
-
+
+ className="p-2.5 bg-cyan-950/30 border border-cyan-500/20 rounded-lg flex-shrink-0">
-

+
+ TARGET_ID: {monitor.id.toString().substring(0, 8)} +
+

{monitor.name}

-

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

@@ -126,40 +157,29 @@ const MonitorCard = ({
- {/* 探针健康度 */} - {monitor.agentStats && monitor.agentCount > 0 && ( -
- -
- )} - {/* 指标信息 */}
-

+

{displayLabel} {monitor.agentCount > 0 && ( + className="bg-slate-700 text-[10px] px-1.5 rounded-full text-cyan-300"> {monitor.agentCount} 节点 )}

200 ? 'text-amber-600 dark:text-amber-400' : 'text-slate-900 dark:text-slate-200' + "text-xl font-bold flex items-end gap-1 font-mono", + displayValue > 200 ? 'text-amber-400' : 'text-cyan-100' )}> - {displayValue} ms + {displayValue} ms

{monitor.type === 'https' && monitor.certExpiryTime ? ( <> -

SSL 证书

+

SSL 证书

) : ( <> -

上次检测

-

+

上次检测

+

{formatDateTime(monitor.lastCheckTime)}

@@ -206,7 +226,7 @@ const MonitorList = () => { const response = await getPublicMonitors(); return response.data || []; }, - refetchInterval: 30000, // 30秒刷新一次 + refetchInterval: 30000, }); let [stats, setStats] = useState(); @@ -242,13 +262,12 @@ const MonitorList = () => { : 0; return {total, online, issues, avgLatency}; } + useEffect(() => { let stats = calculateStats(monitors); setStats(stats); }, [monitors]); - console.log(`stats`, stats) - if (isLoading) { return (
@@ -264,26 +283,26 @@ const MonitorList = () => { } - color="text-gray-700 dark:text-gray-400" + icon={Server} + color="gray" /> } - color="text-emerald-700 dark:text-emerald-400" + icon={CheckCircle2} + color="emerald" /> } - color={stats?.issues > 0 ? "text-rose-700 dark:text-rose-400" : "text-slate-600 dark:text-slate-400"} + icon={AlertCircle} + color="rose" /> } - color="text-gray-700 dark:text-gray-400" + icon={Clock} + color="blue" />
@@ -292,7 +311,7 @@ const MonitorList = () => {
{/* 状态过滤 */}
+ className="flex gap-2 bg-black/40 p-1 rounded-lg border border-cyan-900/50"> {(['all', 'up', 'down', 'unknown'] as const).map(f => { const labels = {all: '全部', up: '正常', down: '异常', unknown: '未知'}; return ( @@ -300,10 +319,10 @@ const MonitorList = () => { key={f} onClick={() => setFilter(f)} className={cn( - "px-4 py-1.5 rounded-md text-xs font-medium transition-all", + "px-4 py-1.5 rounded-md text-xs font-medium transition-all font-mono cursor-pointer", filter === f - ? 'bg-white dark:bg-slate-700 text-slate-900 dark:text-white shadow-sm' - : 'text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-200' + ? 'bg-cyan-500/20 text-cyan-300 border border-cyan-500/30' + : 'text-cyan-600 hover:text-cyan-400' )} > {labels[f]} @@ -313,16 +332,15 @@ const MonitorList = () => {
{/* 显示模式切换 */} -
- 卡片指标: +
+ 卡片指标:
{/* 搜索框 */} -
- - setSearchKeyword(e.target.value)} - className="w-full bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg pl-9 pr-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 text-slate-900 dark:text-slate-200" - /> +
+
+
+ + setSearchKeyword(e.target.value)} + className="w-full bg-transparent border-none text-xs text-cyan-100 p-2.5 focus:ring-0 placeholder-cyan-800 font-mono focus:outline-none" + /> +
diff --git a/web/src/pages/Public/ServerDetail.tsx b/web/src/pages/Public/ServerDetail.tsx index 092c4d4..50cfead 100644 --- a/web/src/pages/Public/ServerDetail.tsx +++ b/web/src/pages/Public/ServerDetail.tsx @@ -959,42 +959,39 @@ const ServerDetail = () => { return (
-
-
-
+
+
+ className="flex h-14 w-14 items-center justify-center rounded-2xl bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-700 text-blue-600 dark:text-blue-400">
-

{displayName}

+

{displayName}

{statusText}
-

+

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

@@ -1005,19 +1002,19 @@ const ServerDetail = () => { {heroStats.map((stat) => (
-

{stat.label}

-

{stat.value}

+

{stat.label}

+

{stat.value}

))}
-
+
探针 ID:{agent.id} - + 版本:{agent.version || '-'} - + 网络累计:{networkSummary}
diff --git a/web/src/pages/Public/ServerList.tsx b/web/src/pages/Public/ServerList.tsx index d39bb8d..20a2281 100644 --- a/web/src/pages/Public/ServerList.tsx +++ b/web/src/pages/Public/ServerList.tsx @@ -1,59 +1,37 @@ -import {type ReactNode, useEffect, useState} from 'react'; -import {Link, useNavigate} from 'react-router-dom'; +import {type ReactNode, useMemo, useState} from 'react'; +import {useNavigate} from 'react-router-dom'; import {useQuery} from '@tanstack/react-query'; -import {Cpu, EthernetPortIcon, HardDrive, Loader2, MemoryStick, Network, Database} from 'lucide-react'; -import {listAgents, getPublicTags} from '@/api/agent.ts'; +import { + AlertTriangle, + ArrowDown, + ArrowUp, + Calendar, + CheckCircle2, + Cpu, + Filter, + HardDrive, + Loader2, + MemoryStick, + Network, + Server, + XCircle +} from 'lucide-react'; +import {getPublicTags, listAgents} from '@/api/agent.ts'; import type {Agent, LatestMetrics} from '@/types'; -import {usePublicLayout} from '../PublicLayout'; import {cn} from '@/lib/utils'; +import CompactResourceBar from "@/components/CompactResourceBar.tsx"; +import StatCard from "@/components/StatCard.tsx"; +import {formatBytes, formatSpeed} from "@/utils/util.ts"; interface AgentWithMetrics extends Agent { metrics?: LatestMetrics; } -const formatSpeed = (bytesPerSecond: number): string => { - if (!bytesPerSecond || bytesPerSecond <= 0) return '0 B/s'; - const k = 1024; - const sizes = ['B/s', 'K/s', 'M/s', 'G/s', 'T/s']; - const i = Math.min(Math.floor(Math.log(bytesPerSecond) / Math.log(k)), sizes.length - 1); - const value = bytesPerSecond / Math.pow(k, i); - // 根据数值大小调整精度,避免过长 - const decimals = value >= 100 ? 0 : value >= 10 ? 1 : 2; - return `${value.toFixed(decimals)} ${sizes[i]}`; -}; - -const formatTraffic = (bytesPerSecond: number): string => { - if (!bytesPerSecond || bytesPerSecond <= 0) return '0 B'; - const k = 1024; - const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; - const i = Math.min(Math.floor(Math.log(bytesPerSecond) / Math.log(k)), sizes.length - 1); - return `${(bytesPerSecond / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`; -}; - -const formatBytes = (bytes: number): string => { - if (!bytes || bytes <= 0) return '0 B'; - const k = 1024; - const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; - const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1); - return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`; -}; - -const formatPercentValue = (value: number): string => (Number.isFinite(value) ? value.toFixed(1) : '0.0'); - -const ProgressBar = ({percent, colorClass}: { percent: number; colorClass: string }) => ( -
-
-
-); - const LoadingSpinner = () => ( -
+
- -

数据加载中,请稍候...

+ +

数据加载中...

); @@ -66,12 +44,12 @@ interface EmptyStateProps { const EmptyState = ({title, description, extra}: EmptyStateProps) => (
-
+ className="flex flex-col items-center justify-center rounded-2xl border border-dashed border-cyan-500/30 bg-[#0a0b10]/90 p-12 text-center backdrop-blur"> +
-

{title}

-

{description}

+

{title}

+

{description}

{extra ?
{extra}
: null}
); @@ -80,75 +58,27 @@ const calculateNetworkSpeed = (metrics?: LatestMetrics) => { if (!metrics?.network) { return {upload: 0, download: 0}; } - - // 后端返回的已经是每秒速率(字节/秒),直接使用 return { upload: metrics.network.totalBytesSentRate, download: metrics.network.totalBytesRecvRate }; }; -const calculateNetworkTraffic = (metrics?: LatestMetrics) => { - if (!metrics?.network) { - return {totalUpload: 0, totalDownload: 0}; - } - - // 后端返回的累计流量,直接使用 - return { - totalUpload: metrics.network.totalBytesSentTotal, - totalDownload: metrics.network.totalBytesRecvTotal - }; -}; - const calculateDiskUsage = (metrics?: LatestMetrics) => { if (!metrics?.disk) { return 0; } - - // 后端已经计算好平均使用率,直接返回 return metrics.disk.usagePercent; }; -const getProgressColor = (percent: number) => { - if (percent >= 85) return 'bg-rose-500'; - if (percent >= 65) return 'bg-amber-500'; - return 'bg-emerald-500'; -}; - -// 获取状态显示信息 -const getStatusDisplay = (status: number) => { - if (status === 1) { - return { - text: '在线', - bgColor: 'bg-emerald-50 dark:bg-emerald-500/15', - textColor: 'text-emerald-700 dark:text-emerald-200', - dotColor: 'bg-emerald-500 dark:bg-emerald-400', - }; - } - return { - text: '离线', - bgColor: 'bg-slate-50 dark:bg-slate-800/80', - textColor: 'text-slate-600 dark:text-slate-400', - dotColor: 'bg-slate-400 dark:bg-slate-500', - }; -}; - const ServerList = () => { const navigate = useNavigate(); - const {viewMode, setShowViewToggle} = usePublicLayout(); const [selectedTag, setSelectedTag] = useState(''); - // 挂载时启用视图切换,卸载时禁用 - useEffect(() => { - setShowViewToggle(true); - return () => setShowViewToggle(false); - }, [setShowViewToggle]); - - const {data: agents = [], isLoading, dataUpdatedAt} = useQuery({ + const {data: agents = [], isLoading} = useQuery({ queryKey: ['agents', 'online'], queryFn: async () => { const response = await listAgents(); - // 后端已经在列表中包含了 metrics 数据,直接使用即可 return (response.data.items || []) as AgentWithMetrics[]; }, refetchInterval: 5000, @@ -161,7 +91,7 @@ const ServerList = () => { const response = await getPublicTags(); return response.data.tags || []; }, - refetchInterval: 30000, // 每30秒刷新一次标签 + refetchInterval: 30000, }); // 根据选中的标签过滤服务器 @@ -169,450 +99,304 @@ const ServerList = () => { ? agents.filter(agent => agent.tags?.includes(selectedTag)) : agents; + // 计算统计数据 + const stats = useMemo(() => { + const total = agents.length; + const online = agents.filter(a => a.status === 1).length; + const offline = total - online; + + // 计算网络统计 + let totalUploadRate = 0; + let totalDownloadRate = 0; + let totalUploadTotal = 0; + let totalDownloadTotal = 0; + + agents.forEach(agent => { + if (agent.status === 1 && agent.metrics?.network) { + totalUploadRate += agent.metrics.network.totalBytesSentRate || 0; + totalDownloadRate += agent.metrics.network.totalBytesRecvRate || 0; + totalUploadTotal += agent.metrics.network.totalBytesSentTotal || 0; + totalDownloadTotal += agent.metrics.network.totalBytesRecvTotal || 0; + } + }); + + return { + total, + online, + offline, + uploadRate: totalUploadRate, + downloadRate: totalDownloadRate, + uploadTotal: totalUploadTotal, + downloadTotal: totalDownloadTotal + }; + }, [agents]); + const handleNavigate = (agentId: string) => { navigate(`/servers/${agentId}`); }; - const renderGridView = () => ( -
- {filteredAgents.map((agent) => { - const cpuUsage = agent.metrics?.cpu?.usagePercent ?? 0; - const memoryUsage = agent.metrics?.memory?.usagePercent ?? 0; - const diskUsage = calculateDiskUsage(agent.metrics); - const {upload, download} = calculateNetworkSpeed(agent.metrics); - const {totalUpload, totalDownload} = calculateNetworkTraffic(agent.metrics); - const statusDisplay = getStatusDisplay(agent.status); - - return ( - -
-
-
-
-

- {agent.name || agent.hostname} -

- - - {statusDisplay.text} - -
- - {agent.os} · {agent.arch} - -
-
- {agent.tags && agent.tags.length > 0 && ( - agent.tags?.map((tag, index) => ( - - {tag} - - )) - )} - {agent.expireTime > 0 && ( - - 到期: {new Date(agent.expireTime).toLocaleDateString('zh-CN')} - - )} -
-
-
- -
-
-
-
-
- -
- CPU -
-
- {formatPercentValue(cpuUsage)}% -
- -
- -
-
-
- -
- 内存 -
-
- {formatPercentValue(memoryUsage)}% -
- -
- -
-
-
- -
- 磁盘 -
-
- {formatPercentValue(diskUsage)}% -
- -
-
- -
-
-
-
- -
- 实时速率 -
-
- - ↑ - {formatSpeed(upload)} - - - ↓ - {formatSpeed(download)} - -
-
-
-
-
- -
- 累计流量 -
-
- - ↑ - {formatTraffic(totalUpload)} - - - ↓ - {formatTraffic(totalDownload)} - -
-
- {agent.trafficLimit > 0 && ( -
-
-
-
- -
- 流量统计 -
- - {formatPercentValue((agent.trafficUsed || 0) / agent.trafficLimit * 100)}% - -
- -
- {formatBytes(agent.trafficUsed || 0)} / {formatBytes(agent.trafficLimit)} - {agent.trafficResetDay && agent.trafficResetDay > 0 && ( - 每月{agent.trafficResetDay}号重置 - )} -
-
- )} -
-
- - ); - })} -
- ); - - const renderListView = () => ( - <> - {/* 桌面端:使用表格布局 */} -
- - - - - - - - - - - - - {filteredAgents.map((agent) => { - const cpuUsage = agent.metrics?.cpu?.usagePercent ?? 0; - const cpuModel = agent.metrics?.cpu?.modelName || '未知'; - const cpuPhysicalCores = agent.metrics?.cpu?.physicalCores ?? 0; - const cpuLogicalCores = agent.metrics?.cpu?.logicalCores ?? 0; - - const memoryUsage = agent.metrics?.memory?.usagePercent ?? 0; - const memoryTotal = agent.metrics?.memory?.total ?? 0; - const memoryUsed = agent.metrics?.memory?.used ?? 0; - const memoryAvailable = agent.metrics?.memory?.available ?? 0; - - const diskUsage = calculateDiskUsage(agent.metrics); - const diskTotal = agent.metrics?.disk?.total ?? 0; - const diskUsed = agent.metrics?.disk?.used ?? 0; - const diskFree = agent.metrics?.disk?.free ?? 0; - - const {upload, download} = calculateNetworkSpeed(agent.metrics); - const statusDisplay = getStatusDisplay(agent.status); - - return ( - handleNavigate(agent.id)} - onKeyDown={(event) => { - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault(); - handleNavigate(agent.id); - } - }} - className={cn( - "cursor-pointer transition hover:bg-slate-50 dark:hover:bg-slate-800/50 focus-within:bg-slate-50 dark:focus-within:bg-slate-800/50 focus:outline-none focus-visible:ring-2 focus-visible:ring-slate-200", - agent.status !== 1 && "filter grayscale" - )} - > - - - - - - - - - ); - })} - -
服务器系统CPU内存磁盘网络
-
-
- - {agent.name || agent.hostname} - - - - {statusDisplay.text} - -
-
- {agent.tags && agent.tags.length > 0 && ( - agent.tags?.map((tag, index) => ( - - {tag} - - )) - )} - {agent.expireTime > 0 && ( - - 到期: {new Date(agent.expireTime).toLocaleDateString('zh-CN')} - - )} -
-
-
-
{agent.os}
-
{agent.arch}
-
-
-
-
- -
- - {formatPercentValue(cpuUsage)}% - -
-
-
{cpuModel}
-
{cpuPhysicalCores}核{cpuLogicalCores}线程
-
-
-
-
-
-
- -
- - {formatPercentValue(memoryUsage)}% - -
-
-
总计:{formatBytes(memoryTotal)}
-
已用:{formatBytes(memoryUsed)} / 剩余:{formatBytes(memoryAvailable)}
-
-
-
-
-
-
- -
- - {formatPercentValue(diskUsage)}% - -
-
-
总计:{formatBytes(diskTotal)}
-
已用:{formatBytes(diskUsed)} / 剩余:{formatBytes(diskFree)}
-
-
-
-
-
-
- ↑ - {formatSpeed(upload)} -
-
- ↓ - {formatSpeed(download)} -
-
- {agent.trafficLimit > 0 && ( -
-
-
- -
- - {formatPercentValue((agent.trafficUsed || 0) / agent.trafficLimit * 100)}% - -
-
- {formatBytes(agent.trafficUsed || 0)} / {formatBytes(agent.trafficLimit)} -
-
- )} -
-
-
- - ); - if (isLoading) { return ; } - return ( -
- {/* 标签过滤器 */} - {tagsData && tagsData.length > 0 && ( -
-
- - {tagsData.map((tag) => { - const count = agents.filter(agent => agent.tags?.includes(tag)).length; - if (count === 0) return null; + // 计算所有标签(包括ALL和ONLINE/OFFLINE) + const allTags = ['ALL', 'ONLINE', 'OFFLINE']; + if (tagsData && tagsData.length > 0) { + tagsData.forEach((tag: string) => { + if (!allTags.includes(tag.toUpperCase())) { + allTags.push(tag.toUpperCase()); + } + }); + } - return ( - - ); - })} + // 过滤逻辑 + let displayAgents = agents; + if (selectedTag === 'ONLINE') { + displayAgents = agents.filter(a => a.status === 1); + } else if (selectedTag === 'OFFLINE') { + displayAgents = agents.filter(a => a.status !== 1); + } else if (selectedTag && selectedTag !== 'ALL') { + displayAgents = agents.filter(a => a.tags?.map(t => t.toUpperCase()).includes(selectedTag)); + } + + return ( +
+ {/* 统计卡片 */} +
+ + + +
+
+
- {/* 右侧渐变提示 */} -
+
+
+
网络统计 +
+
+
+ + {formatSpeed(stats.uploadRate)} + ({formatBytes(stats.uploadTotal)}) +
+
+ + {formatSpeed(stats.downloadRate)} + ({formatBytes(stats.downloadTotal)}) +
+
+
+
+ +
+
+
+
+ + {/* 标签过滤器 */} + {allTags.length > 1 && ( +
+
+ + FILTERS: +
+ {allTags.map(tag => { + const tagKey = tag === 'ALL' ? '' : tag; + let count = 0; + if (tag === 'ALL') count = agents.length; + else if (tag === 'ONLINE') count = agents.filter(a => a.status === 1).length; + else if (tag === 'OFFLINE') count = agents.filter(a => a.status !== 1).length; + else count = agents.filter(a => a.tags?.map(t => t.toUpperCase()).includes(tag)).length; + + if (count === 0 && tag !== 'ALL') return null; + + return ( + + ); + })}
)} - {filteredAgents.length === 0 ? ( + {/* 服务器表格 */} + {displayAgents.length === 0 ? ( - ) : viewMode === 'grid' ? ( - renderGridView() ) : ( - renderListView() +
+ + + + + + + + + + + {displayAgents.map(server => { + const isOnline = server.status === 1; + const cpuUsage = server.metrics?.cpu?.usagePercent ?? 0; + const memoryUsage = server.metrics?.memory?.usagePercent ?? 0; + const memoryTotal = server.metrics?.memory?.total ?? 0; + const memoryUsed = server.metrics?.memory?.used ?? 0; + const diskUsage = calculateDiskUsage(server.metrics); + const diskTotal = server.metrics?.disk?.total ?? 0; + const diskUsed = server.metrics?.disk?.used ?? 0; + const {upload, download} = calculateNetworkSpeed(server.metrics); + + return ( + handleNavigate(server.id)} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + handleNavigate(server.id); + } + }} + className="group hover:bg-cyan-500/5 transition-colors cursor-pointer" + > + {/* Identity */} + + + {/* Resources */} + + + {/* Network */} + + + {/* Meta */} + + + ); + })} + +
System IdentityResource TelemetryNetwork I/OMeta / Status
+
+
+
+ {server.name || server.hostname} +
+
+ {server.os} + + {server.arch} +
+
+
+
+ {isOnline ? ( +
+ + + +
+ ) : ( +
+ + CONNECTION_LOST // RECONNECTING... +
+ )} +
+
+ + + IN: {formatSpeed(download)} + + + + OUT: {formatSpeed(upload)} + +
+ {server.trafficLimit > 0 ? ( +
+
+ TRAFFIC + {Math.round((server.trafficUsed || 0) / server.trafficLimit * 100)}% +
+
+
+
+
+ ) : ( +
-- NO METERING --
+ )} +
+
+
+ {server.tags && server.tags.length > 0 && server.tags.map(tag => ( + + #{tag} + + ))} +
+
0 ? 'text-cyan-600' : 'text-emerald-500/60'}`}> + + {server.expireTime && server.expireTime > 0 + ? `EXP: ${new Date(server.expireTime).toLocaleDateString('zh-CN')}` + : 'PERMANENT_NODE' + } +
+
+
+
)}
); diff --git a/web/src/pages/PublicLayout.tsx b/web/src/pages/PublicLayout.tsx index 4545df2..35be789 100644 --- a/web/src/pages/PublicLayout.tsx +++ b/web/src/pages/PublicLayout.tsx @@ -1,64 +1,26 @@ -import {Outlet, useOutletContext} from 'react-router-dom'; -import {useState, useEffect} from 'react'; +import {Outlet} from 'react-router-dom'; import PublicHeader from '../components/PublicHeader'; import PublicFooter from '../components/PublicFooter'; -type ViewMode = 'grid' | 'list'; - -interface PublicLayoutContextType { - viewMode: ViewMode; - setViewMode: (mode: ViewMode) => void; - showViewToggle: boolean; - setShowViewToggle: (show: boolean) => void; -} - -const getInitialViewMode = (): ViewMode => { - // 检查是否为移动端(屏幕宽度小于 1024px) - const isMobile = window.innerWidth < 1024; - - // 移动端默认使用 grid 视图 - if (isMobile) { - return 'grid'; - } - - // 桌面端使用配置的默认视图 - return window.SystemConfig?.DefaultView === 'grid' ? 'grid' : 'list'; -}; - const PublicLayout = () => { - const [viewMode, setViewMode] = useState(getInitialViewMode()); - const [showViewToggle, setShowViewToggle] = useState(false); - - // 监听窗口大小变化,在移动端和桌面端切换时调整视图 - useEffect(() => { - const handleResize = () => { - const isMobile = window.innerWidth < 1024; - if (isMobile && viewMode === 'list') { - setViewMode('grid'); - } - }; - - window.addEventListener('resize', handleResize); - return () => window.removeEventListener('resize', handleResize); - }, [viewMode]); - return ( -
- -
- -
- +
+ {/* 背景网格效果 */} +
+ {/* 顶部发光效果 */} +
+ +
+ +
+ +
+ +
); }; -export function usePublicLayout() { - return useOutletContext(); -} - export default PublicLayout; diff --git a/web/src/utils/util.ts b/web/src/utils/util.ts index 0223c6d..bae1b61 100644 --- a/web/src/utils/util.ts +++ b/web/src/utils/util.ts @@ -25,4 +25,22 @@ export const formatDateTime = (timestamp: number): string => { minute: '2-digit', second: '2-digit' }); +}; + +export const formatSpeed = (bytesPerSecond: number): string => { + if (!bytesPerSecond || bytesPerSecond <= 0) return '0 B/s'; + const k = 1024; + const sizes = ['B/s', 'K/s', 'M/s', 'G/s', 'T/s']; + const i = Math.min(Math.floor(Math.log(bytesPerSecond) / Math.log(k)), sizes.length - 1); + const value = bytesPerSecond / Math.pow(k, i); + const decimals = value >= 100 ? 0 : value >= 10 ? 1 : 2; + return `${value.toFixed(decimals)} ${sizes[i]}`; +}; + +export const formatBytes = (bytes: number): string => { + if (!bytes || bytes <= 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1); + return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`; }; \ No newline at end of file From 73465d4e04ab1ec3f17466f4c6163b215974d21e Mon Sep 17 00:00:00 2001 From: dushixiang Date: Sun, 14 Dec 2025 16:21:08 +0800 Subject: [PATCH 12/18] =?UTF-8?q?=E4=BC=98=E5=8C=96=E8=AE=BE=E5=A4=87?= =?UTF-8?q?=E8=AF=A6=E6=83=85=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/pages/Public/ServerDetail.tsx | 259 +++++++++++++------------- 1 file changed, 129 insertions(+), 130 deletions(-) diff --git a/web/src/pages/Public/ServerDetail.tsx b/web/src/pages/Public/ServerDetail.tsx index 50cfead..04e55f3 100644 --- a/web/src/pages/Public/ServerDetail.tsx +++ b/web/src/pages/Public/ServerDetail.tsx @@ -99,22 +99,22 @@ const INTERFACE_COLORS = [ ]; const LoadingSpinner = () => ( -
+
- -

数据加载中,请稍候...

+ +

数据加载中...

); const EmptyState = ({message = '服务器不存在或已离线'}: { message?: string }) => ( -
+
+ className="flex h-16 w-16 items-center justify-center rounded-lg bg-cyan-500/10 text-cyan-400">
-

{message}

+

{message}

); @@ -132,14 +132,14 @@ const ChartPlaceholder = ({ }) => (
- -

{title}

- {subtitle ?

{subtitle}

: null} + +

{title}

+ {subtitle ?

{subtitle}

: null}
); @@ -156,15 +156,15 @@ const Card = ({ children: ReactNode; }) => (
+ className="rounded-2xl border border-cyan-900/50 bg-[#0a0b10]/90 p-6 shadow-2xl backdrop-blur-sm"> {(title || description || action) && (
+ className="flex flex-col gap-3 border-b border-cyan-900/30 pb-4 sm:flex-row sm:items-start sm:justify-between">
{title ? -

{title}

: null} +

{title}

: null} {description ? -

{description}

: null} +

{description}

: null}
{action ?
{action}
: null}
@@ -177,24 +177,24 @@ type AccentVariant = 'blue' | 'emerald' | 'purple' | 'amber'; const accentThemes: Record = { blue: { - icon: ' text-slate-700 dark:text-slate-300', - badge: ' text-slate-700 dark:text-slate-300', - highlight: 'text-slate-700 dark:text-slate-300', + icon: 'text-blue-400', + badge: 'text-blue-400', + highlight: 'text-blue-400', }, emerald: { - icon: ' text-slate-700 dark:text-slate-300', - badge: ' text-slate-700 dark:text-slate-300', - highlight: 'text-slate-700 dark:text-slate-300', + icon: 'text-emerald-400', + badge: 'text-emerald-400', + highlight: 'text-emerald-400', }, purple: { - icon: ' text-slate-700 dark:text-slate-300', - badge: ' text-slate-700 dark:text-slate-300', - highlight: 'text-slate-700 dark:text-slate-300', + icon: 'text-purple-400', + badge: 'text-purple-400', + highlight: 'text-purple-400', }, amber: { - icon: ' text-slate-700 dark:text-slate-300', - badge: ' text-slate-700 dark:text-slate-300', - highlight: 'text-slate-700 dark:text-slate-300', + icon: 'text-amber-400', + badge: 'text-amber-400', + highlight: 'text-amber-400', }, }; @@ -202,8 +202,8 @@ const InfoGrid = ({items}: { items: Array<{ label: string; value: ReactNode }> }
{items.map((item) => (
-
{item.label}
-
{item.value}
+
{item.label}
+
{item.value}
))}
@@ -227,10 +227,10 @@ const TimeRangeSelector = ({ type="button" onClick={() => onChange(option.value)} className={cn( - "rounded-lg border px-3 py-1.5 text-xs sm:text-sm font-medium transition whitespace-nowrap", + "rounded-lg border px-3 py-1.5 text-xs font-bold font-mono tracking-wider uppercase transition whitespace-nowrap", isActive - ? 'border-blue-200 dark:border-blue-400 bg-blue-600 dark:bg-blue-500 text-white' - : 'border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 text-slate-600 dark:text-slate-300 hover:border-blue-200 dark:hover:border-blue-500 hover:text-blue-600 dark:hover:text-blue-200' + ? 'border-cyan-500/50 bg-cyan-500/20 text-cyan-300 shadow-[0_0_10px_rgba(34,211,238,0.3)]' + : 'border-cyan-900/30 bg-black/30 text-cyan-700 hover:text-cyan-400 hover:border-cyan-700' )} > {option.label} @@ -402,23 +402,23 @@ const SnapshotGrid = ({cards}: { cards: SnapshotCardData[] }) => ( return (
- + -

{card.title}

+

{card.title}

{card.usagePercent}
{card.metrics.map((metric) => (
- {metric.label} + {metric.label} {metric.value} + className="ml-2 text-right font-medium text-cyan-200">{metric.value}
))}
@@ -435,7 +435,7 @@ const SnapshotSection = ({cards}: { cards: SnapshotCardData[] }) => { return (
-

资源快照

+

资源快照

@@ -455,8 +455,8 @@ const CustomTooltip = ({active, payload, label, unit = '%'}: MetricsTooltipProps return (
-

{displayLabel}

+ className="rounded-lg border border-cyan-700/50 bg-[#0a0b10]/95 backdrop-blur-sm px-3 py-2 text-xs shadow-xl"> +

{displayLabel}

{payload.map((entry, index) => { if (!entry) { @@ -474,7 +474,7 @@ const CustomTooltip = ({active, payload, label, unit = '%'}: MetricsTooltipProps return (

+ className="flex items-center gap-2 text-cyan-400"> { const lastSeenDisplay = agent ? formatDateTime(agent.lastSeenAt) : '-'; const displayName = agent?.name?.trim() ? agent.name : '未命名探针'; const isOnline = agent?.status === 1; - const statusDotStyles = isOnline ? 'bg-emerald-500 dark:bg-emerald-400' : 'bg-slate-400 dark:bg-slate-500'; + const statusDotStyles = isOnline ? 'bg-emerald-500' : 'bg-rose-500'; const statusText = isOnline ? '在线' : '离线'; const networkSummary = latestMetrics?.network @@ -957,41 +957,41 @@ const ServerDetail = () => { } return ( -

+
-
+
+ className="flex h-14 w-14 items-center justify-center rounded-xl bg-cyan-500/10 border border-cyan-500/30 text-cyan-400">
-

{displayName}

+

{displayName}

{statusText}
-

+

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

@@ -1002,19 +1002,19 @@ const ServerDetail = () => { {heroStats.map((stat) => (
-

{stat.label}

-

{stat.value}

+

{stat.label}

+

{stat.value}

))}
-
+
探针 ID:{agent.id} - + 版本:{agent.version || '-'} - + 网络累计:{networkSummary}
@@ -1025,17 +1025,17 @@ const ServerDetail = () => {
-

运行环境

-

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

+ className="rounded-xl border border-cyan-900/50 bg-black/30 p-4 backdrop-blur-sm"> +

运行环境

+

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

-

运行状态

-

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

+ className="rounded-xl border border-cyan-900/50 bg-black/30 p-4 backdrop-blur-sm"> +

运行状态

+

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

@@ -1043,41 +1043,40 @@ const ServerDetail = () => {
{latestMetrics?.networkConnection && (
-

网络连接统计

-

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

+ className="rounded-xl border border-cyan-900/50 bg-black/30 p-4 backdrop-blur-sm"> +

网络连接统计

+

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

-
Total
+
Total
{latestMetrics.networkConnection.total}
+ className="mt-1 text-lg font-semibold text-cyan-100">{latestMetrics.networkConnection.total}
-
ESTABLISHED +
ESTABLISHED
{latestMetrics.networkConnection.established}
+ className="mt-1 text-lg font-semibold text-emerald-400">{latestMetrics.networkConnection.established}
-
TIME_WAIT
+
TIME_WAIT
{latestMetrics.networkConnection.timeWait}
+ className="mt-1 text-lg font-semibold text-amber-400">{latestMetrics.networkConnection.timeWait}
-
LISTEN
+
LISTEN
{latestMetrics.networkConnection.listen}
+ className="mt-1 text-lg font-semibold text-blue-400">{latestMetrics.networkConnection.listen}
-
CLOSE_WAIT
+
CLOSE_WAIT
{latestMetrics.networkConnection.closeWait}
+ className="mt-1 text-lg font-semibold text-rose-400">{latestMetrics.networkConnection.closeWait}
-
OTHER
+
OTHER
+ className="mt-1 text-lg font-semibold text-cyan-500"> {latestMetrics.networkConnection.synSent + latestMetrics.networkConnection.synRecv + latestMetrics.networkConnection.finWait1 + @@ -1102,9 +1101,9 @@ const ServerDetail = () => { >
-

+

+ className="flex h-8 w-8 items-center justify-center rounded-lg bg-cyan-500/10 text-cyan-400"> CPU 使用率 @@ -1119,17 +1118,17 @@ const ServerDetail = () => { + className="stroke-cyan-900/30"/> `${value}%`} /> @@ -1151,9 +1150,9 @@ const ServerDetail = () => {

-

+

+ className="flex h-8 w-8 items-center justify-center rounded-lg bg-cyan-500/10 text-cyan-400"> 内存使用率 @@ -1168,17 +1167,17 @@ const ServerDetail = () => { + className="stroke-cyan-900/30"/> `${value}%`} /> @@ -1201,9 +1200,9 @@ const ServerDetail = () => {
-

+

+ className="flex h-8 w-8 items-center justify-center rounded-lg bg-cyan-500/10 text-cyan-400"> 网络流量(MB/s) @@ -1212,7 +1211,7 @@ const ServerDetail = () => { setSelectedTempType(e.target.value)} - className="rounded-lg border border-slate-200 dark:border-slate-700 px-3 py-1.5 text-sm text-slate-700 dark:text-slate-200 hover:border-slate-300 dark:hover:border-slate-600 focus:border-slate-500 dark:focus:border-slate-500 focus:outline-none focus:ring-2 focus:ring-slate-200 dark:focus:ring-slate-600/40" + className="rounded-lg border border-cyan-900/50 bg-black/40 px-3 py-1.5 text-xs font-mono text-cyan-300 hover:border-cyan-700 focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/20" > {temperatureTypes.map((type) => ( @@ -1550,44 +1549,44 @@ const ServerDetail = () => { {latestMetrics.gpu.map((gpu) => (
+ className="flex h-9 w-9 items-center justify-center rounded-lg bg-cyan-500/10 text-cyan-400">
-

GPU {gpu.index}

-

{gpu.name}

+

GPU {gpu.index}

+

{gpu.name}

- + {gpu.utilization.toFixed(1)}%
- 温度 + 温度 {gpu.temperature.toFixed(1)}°C + className="font-medium text-cyan-200">{gpu.temperature.toFixed(1)}°C
- 显存 - + 显存 + {formatBytes(gpu.memoryUsed)} / {formatBytes(gpu.memoryTotal)}
- 功耗 + 功耗 {gpu.powerDraw.toFixed(1)}W + className="font-medium text-cyan-200">{gpu.powerDraw.toFixed(1)}W
- 风扇转速 + 风扇转速 {gpu.fanSpeed.toFixed(0)}% + className="font-medium text-cyan-200">{gpu.fanSpeed.toFixed(0)}%
@@ -1603,13 +1602,13 @@ const ServerDetail = () => { {latestMetrics.temperature.sort((a, b) => a.sensorKey.localeCompare(b.sensorKey)).map((temp) => (
- -

{temp.sensorLabel}

+ +

{temp.sensorLabel}

-

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

+

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

))}

From 1659ac6f21134e63774ceb88524871fae333df7b Mon Sep 17 00:00:00 2001 From: dushixiang Date: Sun, 14 Dec 2025 17:27:33 +0800 Subject: [PATCH 13/18] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=85=AC=E5=85=B1?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/api/monitor.ts | 17 +- web/src/components/CompactResourceBar.tsx | 2 +- web/src/components/PageHeader.tsx | 2 + web/src/components/PublicHeader.tsx | 12 +- web/src/components/common/Card.tsx | 59 ++ .../components/common/ChartPlaceholder.tsx | 48 ++ web/src/components/common/CustomTooltip.tsx | 85 ++ web/src/components/common/EmptyState.tsx | 46 ++ web/src/components/common/LittleStatCard.tsx | 19 + web/src/components/common/LoadingSpinner.tsx | 31 + .../components/common/TimeRangeSelector.tsx | 46 ++ web/src/components/common/index.ts | 6 + web/src/components/server/InfoGrid.tsx | 16 + web/src/components/server/SnapshotGrid.tsx | 74 ++ web/src/components/server/SnapshotSection.tsx | 19 + web/src/components/server/index.ts | 4 + web/src/pages/Public/MonitorDetail.tsx | 744 ++++++++---------- web/src/pages/Public/ServerDetail.tsx | 607 ++------------ web/src/types/index.ts | 15 + web/src/utils/util.ts | 51 +- 20 files changed, 927 insertions(+), 976 deletions(-) create mode 100644 web/src/components/common/Card.tsx create mode 100644 web/src/components/common/ChartPlaceholder.tsx create mode 100644 web/src/components/common/CustomTooltip.tsx create mode 100644 web/src/components/common/EmptyState.tsx create mode 100644 web/src/components/common/LittleStatCard.tsx create mode 100644 web/src/components/common/LoadingSpinner.tsx create mode 100644 web/src/components/common/TimeRangeSelector.tsx create mode 100644 web/src/components/common/index.ts create mode 100644 web/src/components/server/InfoGrid.tsx create mode 100644 web/src/components/server/SnapshotGrid.tsx create mode 100644 web/src/components/server/SnapshotSection.tsx create mode 100644 web/src/components/server/index.ts diff --git a/web/src/api/monitor.ts b/web/src/api/monitor.ts index 580a11a..403239a 100644 --- a/web/src/api/monitor.ts +++ b/web/src/api/monitor.ts @@ -1,5 +1,5 @@ import {del, get, post, put} from './request'; -import type {MonitorDetail, MonitorListResponse, MonitorTask, MonitorTaskRequest, PublicMonitor} from '../types'; +import type {AgentMonitorStat, MonitorDetail, MonitorListResponse, MonitorTask, MonitorTaskRequest, PublicMonitor} from '../types'; export const listMonitors = (page: number = 1, pageSize: number = 10, keyword?: string) => { const params = new URLSearchParams(); @@ -70,21 +70,6 @@ export interface GetMetricsResponse { series: MetricSeries[]; // 时序数据系列(每个探针一个系列) } -// 探针监控统计 -export interface AgentMonitorStat { - agentId: string; - monitorId: string; - type: string; - target: string; - status: string; - statusCode: number; - responseTime: number; - checkedAt: number; - message: string; - certExpiryTime: number; - certDaysLeft: number; -} - // 公开接口 - 获取指定监控的历史数据(VictoriaMetrics 原始时序数据) export const getMonitorHistory = (id: string, range: string = '15m') => { return get(`/monitors/${encodeURIComponent(id)}/history?range=${range}`); diff --git a/web/src/components/CompactResourceBar.tsx b/web/src/components/CompactResourceBar.tsx index 9af1360..84540c7 100644 --- a/web/src/components/CompactResourceBar.tsx +++ b/web/src/components/CompactResourceBar.tsx @@ -60,7 +60,7 @@ const CompactResourceBar = ({ value, label, subtext, icon: Icon, color = "bg-cya

-
{value.toFixed(1)}%
+
{value.toFixed(1)}%
{/* Value & Subtext */}
diff --git a/web/src/components/PageHeader.tsx b/web/src/components/PageHeader.tsx index f5f2864..b0a91d1 100644 --- a/web/src/components/PageHeader.tsx +++ b/web/src/components/PageHeader.tsx @@ -9,6 +9,7 @@ export interface Action { type?: 'default' | 'primary'; onClick: () => void; danger?: boolean; + disabled?: boolean; } interface PageHeaderProps { @@ -36,6 +37,7 @@ export const PageHeader: React.FC = ({title, description, actio icon={action.icon} onClick={action.onClick} danger={action.danger} + disabled={action.disabled} > {action.label} diff --git a/web/src/components/PublicHeader.tsx b/web/src/components/PublicHeader.tsx index 6a7a1e3..f7ad674 100644 --- a/web/src/components/PublicHeader.tsx +++ b/web/src/components/PublicHeader.tsx @@ -1,5 +1,5 @@ import {useEffect, useState} from 'react'; -import {Activity, Hexagon, LogIn, ServerIcon, Settings} from 'lucide-react'; +import {Activity, LogIn, ServerIcon, Settings} from 'lucide-react'; import {getCurrentUser} from '../api/auth'; import {Link, useLocation} from "react-router-dom"; @@ -41,13 +41,9 @@ const PublicHeader = () => { const currentPath = location.pathname; let activeTab = 'servers'; - switch (currentPath) { - case '/': - activeTab = 'servers'; - break; - case '/monitors': - activeTab = 'monitors'; - break; + + if (currentPath.startsWith('/monitors')) { + activeTab = 'monitors'; } return ( diff --git a/web/src/components/common/Card.tsx b/web/src/components/common/Card.tsx new file mode 100644 index 0000000..396445d --- /dev/null +++ b/web/src/components/common/Card.tsx @@ -0,0 +1,59 @@ +import type {ReactNode} from 'react'; +import {cn} from '@/lib/utils'; + +interface CardProps { + title?: string; + description?: string; + action?: ReactNode; + children: ReactNode; + variant?: 'light' | 'dark'; +} + +export const Card = ({ + title, + description, + action, + children, + variant = 'light', + }: CardProps) => { + const isDark = variant === 'dark'; + + return ( +
+ {(title || description || action) && ( +
+
+ {title && ( +

+ {title} +

+ )} + {description && ( +

+ {description} +

+ )} +
+ {action &&
{action}
} +
+ )} +
{children}
+
+ ); +}; diff --git a/web/src/components/common/ChartPlaceholder.tsx b/web/src/components/common/ChartPlaceholder.tsx new file mode 100644 index 0000000..f37c2f0 --- /dev/null +++ b/web/src/components/common/ChartPlaceholder.tsx @@ -0,0 +1,48 @@ +import {TrendingUp} from 'lucide-react'; +import {cn} from '@/lib/utils'; + +interface ChartPlaceholderProps { + variant?: 'light' | 'dark'; + icon?: typeof TrendingUp; + title?: string; + subtitle?: string; + heightClass?: string; +} + +export const ChartPlaceholder = ({ + variant = 'light', + icon: Icon = TrendingUp, + title = '暂无数据', + subtitle = '等待采集新数据后展示图表', + heightClass = 'h-52', + }: ChartPlaceholderProps) => { + const isDark = variant === 'dark'; + + return ( +
+
+ +

{title}

+ {subtitle && ( +

+ {subtitle} +

+ )} +
+
+ ); +}; diff --git a/web/src/components/common/CustomTooltip.tsx b/web/src/components/common/CustomTooltip.tsx new file mode 100644 index 0000000..47453a7 --- /dev/null +++ b/web/src/components/common/CustomTooltip.tsx @@ -0,0 +1,85 @@ +import dayjs from 'dayjs'; +import {cn} from '@/lib/utils'; + +type CustomTooltipProps = { + active?: boolean; + payload?: Array<{ + name?: string; + value?: number; + color?: string; + dataKey?: string; + payload?: { + timestamp?: number | string; + [key: string]: unknown; + }; + }>; + label?: string | number; + variant?: 'light' | 'dark'; + unit?: string; +}; + +export const CustomTooltip = ({active, payload, label, variant = 'light', unit = '%'}: CustomTooltipProps) => { + if (!active || !payload || payload.length === 0) { + return null; + } + + const isDark = variant === 'dark'; + + // 从 payload 中获取完整的时间戳信息(如果有的话) + const fullTimestamp = payload[0]?.payload?.timestamp; + const displayLabel = fullTimestamp + ? dayjs(fullTimestamp).format(isDark ? 'YYYY-MM-DD HH:mm:ss' : 'MM-DD HH:mm') + : label; + + return ( +
+

+ {displayLabel} +

+
+ {payload.map((entry, index) => { + if (!entry) { + return null; + } + + const dotColor = entry.color ?? '#6366f1'; + const title = entry.name ?? entry.dataKey ?? `系列 ${index + 1}`; + const value = typeof entry.value === 'number' + ? Number.isFinite(entry.value) + ? entry.value.toFixed(2) + : '-' + : entry.value; + + return ( +

+ + + {title}: {value}{unit} + +

+ ); + })} +
+
+ ); +}; diff --git a/web/src/components/common/EmptyState.tsx b/web/src/components/common/EmptyState.tsx new file mode 100644 index 0000000..9b76efc --- /dev/null +++ b/web/src/components/common/EmptyState.tsx @@ -0,0 +1,46 @@ +import {AlertCircle, Server} from 'lucide-react'; +import {cn} from '@/lib/utils'; + +interface EmptyStateProps { + variant?: 'light' | 'dark'; + message?: string; + showBackButton?: boolean; +} + +export const EmptyState = ({variant = 'light', message, showBackButton = false}: EmptyStateProps) => { + const isDark = variant === 'dark'; + const Icon = isDark ? Server : AlertCircle; + const defaultMessage = isDark ? '服务器不存在或已离线' : '监控数据不存在'; + + return ( +
+
+
+ +
+

+ {message || defaultMessage} +

+ {showBackButton && !isDark && ( + + )} +
+
+ ); +}; diff --git a/web/src/components/common/LittleStatCard.tsx b/web/src/components/common/LittleStatCard.tsx new file mode 100644 index 0000000..e24df2b --- /dev/null +++ b/web/src/components/common/LittleStatCard.tsx @@ -0,0 +1,19 @@ +// 统计卡片组件 +const LittleStatCard = ({ + label, + value, + }: { + label: string; + value: string | number; + sublabel?: string; +}) => ( +
+

{label}

+

{value}

+
+); + +export default LittleStatCard; \ No newline at end of file diff --git a/web/src/components/common/LoadingSpinner.tsx b/web/src/components/common/LoadingSpinner.tsx new file mode 100644 index 0000000..73242b7 --- /dev/null +++ b/web/src/components/common/LoadingSpinner.tsx @@ -0,0 +1,31 @@ +import {Loader2} from 'lucide-react'; +import {cn} from '@/lib/utils'; + +interface LoadingSpinnerProps { + variant?: 'light' | 'dark'; + message?: string; +} + +export const LoadingSpinner = ({variant = 'light', message}: LoadingSpinnerProps) => { + const isDark = variant === 'dark'; + + return ( +
+
+ +

+ {message || (isDark ? '数据加载中...' : '数据加载中,请稍候...')} +

+
+
+ ); +}; diff --git a/web/src/components/common/TimeRangeSelector.tsx b/web/src/components/common/TimeRangeSelector.tsx new file mode 100644 index 0000000..2bcf8fa --- /dev/null +++ b/web/src/components/common/TimeRangeSelector.tsx @@ -0,0 +1,46 @@ +import type {TimeRangeOption} from '@/api/property.ts'; +import {cn} from '@/lib/utils'; + +interface TimeRangeSelectorProps { + value: string; + onChange: (value: string) => void; + options: readonly TimeRangeOption[]; + variant?: 'light' | 'dark'; +} + +export const TimeRangeSelector = ({ + value, + onChange, + options, + variant = 'light', + }: TimeRangeSelectorProps) => { + const isDark = variant === 'dark'; + + return ( +
+ {options.map((option) => { + const isActive = option.value === value; + return ( + + ); + })} +
+ ); +}; diff --git a/web/src/components/common/index.ts b/web/src/components/common/index.ts new file mode 100644 index 0000000..d5ad652 --- /dev/null +++ b/web/src/components/common/index.ts @@ -0,0 +1,6 @@ +export {LoadingSpinner} from './LoadingSpinner'; +export {EmptyState} from './EmptyState'; +export {ChartPlaceholder} from './ChartPlaceholder'; +export {CustomTooltip} from './CustomTooltip'; +export {TimeRangeSelector} from './TimeRangeSelector'; +export {Card} from './Card'; diff --git a/web/src/components/server/InfoGrid.tsx b/web/src/components/server/InfoGrid.tsx new file mode 100644 index 0000000..f7cb077 --- /dev/null +++ b/web/src/components/server/InfoGrid.tsx @@ -0,0 +1,16 @@ +import type {ReactNode} from 'react'; + +interface InfoGridProps { + items: Array<{ label: string; value: ReactNode }>; +} + +export const InfoGrid = ({items}: InfoGridProps) => ( +
+ {items.map((item) => ( +
+
{item.label}
+
{item.value}
+
+ ))} +
+); diff --git a/web/src/components/server/SnapshotGrid.tsx b/web/src/components/server/SnapshotGrid.tsx new file mode 100644 index 0000000..3c67e16 --- /dev/null +++ b/web/src/components/server/SnapshotGrid.tsx @@ -0,0 +1,74 @@ +import type {ReactNode} from 'react'; +import type {LucideIcon} from 'lucide-react'; +import {cn} from '@/lib/utils'; + +type AccentVariant = 'blue' | 'emerald' | 'purple' | 'amber'; + +export type SnapshotCardData = { + key: string; + icon: LucideIcon; + title: string; + usagePercent: string; + accent: AccentVariant; + metrics: Array<{ label: string; value: ReactNode }>; +}; + +const accentThemes: Record = { + blue: { + icon: 'text-blue-400', + badge: 'text-blue-400', + highlight: 'text-blue-400', + }, + emerald: { + icon: 'text-emerald-400', + badge: 'text-emerald-400', + highlight: 'text-emerald-400', + }, + purple: { + icon: 'text-purple-400', + badge: 'text-purple-400', + highlight: 'text-purple-400', + }, + amber: { + icon: 'text-amber-400', + badge: 'text-amber-400', + highlight: 'text-amber-400', + }, +}; + +interface SnapshotGridProps { + cards: SnapshotCardData[]; +} + +export const SnapshotGrid = ({cards}: SnapshotGridProps) => ( +
+ {cards.map((card) => { + const theme = accentThemes[card.accent]; + return ( +
+
+
+ + + +

{card.title}

+
+ {card.usagePercent} +
+
+ {card.metrics.map((metric) => ( +
+ {metric.label} + {metric.value} +
+ ))} +
+
+ ); + })} +
+); diff --git a/web/src/components/server/SnapshotSection.tsx b/web/src/components/server/SnapshotSection.tsx new file mode 100644 index 0000000..6e3d254 --- /dev/null +++ b/web/src/components/server/SnapshotSection.tsx @@ -0,0 +1,19 @@ +import {SnapshotGrid, type SnapshotCardData} from './SnapshotGrid'; + +interface SnapshotSectionProps { + cards: SnapshotCardData[]; +} + +export const SnapshotSection = ({cards}: SnapshotSectionProps) => { + if (cards.length === 0) { + return null; + } + return ( +
+
+

资源快照

+
+ +
+ ); +}; diff --git a/web/src/components/server/index.ts b/web/src/components/server/index.ts new file mode 100644 index 0000000..66438e3 --- /dev/null +++ b/web/src/components/server/index.ts @@ -0,0 +1,4 @@ +export {InfoGrid} from './InfoGrid'; +export {SnapshotGrid} from './SnapshotGrid'; +export {SnapshotSection} from './SnapshotSection'; +export type {SnapshotCardData} from './SnapshotGrid'; diff --git a/web/src/pages/Public/MonitorDetail.tsx b/web/src/pages/Public/MonitorDetail.tsx index e6913ec..fc8f032 100644 --- a/web/src/pages/Public/MonitorDetail.tsx +++ b/web/src/pages/Public/MonitorDetail.tsx @@ -1,211 +1,130 @@ -import type {ReactNode} from 'react'; import {useEffect, useMemo, useState} from 'react'; import {useNavigate, useParams} from 'react-router-dom'; import {useQuery} from '@tanstack/react-query'; -import {AlertCircle, ArrowLeft, Clock, Loader2, MapPin, TrendingUp} from 'lucide-react'; -import type {TooltipProps} from 'recharts'; +import {AlertCircle, ArrowLeft, Clock, Loader2, MapPin, Shield} from 'lucide-react'; import {Area, AreaChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis} from 'recharts'; -import { - type AgentMonitorStat, - type GetMetricsResponse, - getMonitorAgentStats, - getMonitorHistory, - getMonitorStatsById -} from '@/api/monitor.ts'; -import type {PublicMonitor} from '@/types'; -import {cn} from '@/lib/utils'; -import {formatDateTime, formatTime} from "@/utils/util.ts"; +import {type GetMetricsResponse, getMonitorAgentStats, getMonitorHistory, 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 = ({message = '监控数据不存在'}: { message?: string }) => ( -
-
-
- -
-

{message}

- -
-
-); - -const timeRangeOptions = [ - {label: '1小时', value: '1h'}, - {label: '6小时', value: '6h'}, - {label: '1天', value: '1d'}, - {label: '3天', value: '3d'}, - {label: '7天', value: '7d'}, -]; - -const ChartPlaceholder = ({ - icon: Icon = TrendingUp, - title = '暂无数据', - subtitle = '等待采集新数据后展示图表', - heightClass = 'h-80', - }: { - icon?: typeof TrendingUp; - title?: string; - subtitle?: string; - heightClass?: string; -}) => ( -
-
- -

{title}

- {subtitle ?

{subtitle}

: null} -
+// 空状态组件 +const EmptyState = () => ( +
+ +

未找到监控数据

+

请检查监控 ID 是否正确

); +// 卡片容器组件 const Card = ({ title, description, action, - children, + children }: { - title?: string; + title: string; description?: string; - action?: ReactNode; - children: ReactNode; + action?: React.ReactNode; + children: React.ReactNode; }) => ( -
- {(title || description || action) && ( -
-
- {title &&

{title}

} - {description &&

{description}

} -
- {action &&
{action}
} -
- )} -
{children}
-
-); +
+ {/* 装饰性边框 */} +
+
+
+
-const StatCard = ({ - label, - value, - sublabel - }: { - label: string; - value: string | number; - sublabel?: string; -}) => ( -
-

{label}

-

{value}

- {sublabel &&

{sublabel}

} +
+
+
+

{title}

+ {description &&

{description}

} +
+ {action &&
{action}
} +
+ {children} +
); -const CustomTooltip = ({active, payload, label}: TooltipProps) => { - if (!active || !payload || payload.length === 0) { - return null; - } +// 图表占位符组件 +const ChartPlaceholder = ({subtitle, heightClass}: { subtitle: string; heightClass: string }) => ( +
+
+ +

{subtitle}

+
+
+); - const fullTimestamp = payload[0]?.payload?.timestamp; - const displayLabel = fullTimestamp - ? new Date(fullTimestamp).toLocaleString('zh-CN', { - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - }) - : label; +// 自定义 Tooltip 组件 +const CustomTooltip = ({active, payload, label}: any) => { + if (!active || !payload || payload.length === 0) return null; return ( -
-

{displayLabel}

+
+

{label}

- {payload.map((entry, index) => { - if (!entry) return null; - - const dotColor = entry.color ?? '#3b82f6'; - const title = entry.name ?? `系列 ${index + 1}`; - const value = typeof entry.value === 'number' && Number.isFinite(entry.value) - ? entry.value.toFixed(2) - : '-'; - - return ( -
- - - {title}: {value} ms - -
- ); - })} + {payload.map((entry: any, index: number) => ( +
+ + + {entry.name}: {entry.value} ms + +
+ ))}
); }; +// 时间范围选择器组件 const TimeRangeSelector = ({ value, onChange, - options, + options }: { value: string; onChange: (value: string) => void; - options: readonly { label: string; value: string }[]; + options: Array<{ value: string; label: string }>; }) => ( -
- {options.map((option) => { - const isActive = option.value === value; - return ( - - ); - })} +
+ {options.map(option => ( + + ))}
); -// 预定义的颜色方案 -const AGENT_COLORS = [ - '#3b82f6', // blue - '#10b981', // emerald - '#f59e0b', // amber - '#8b5cf6', // violet - '#ec4899', // pink - '#06b6d4', // cyan - '#f97316', // orange - '#14b8a6', // teal -]; const MonitorDetail = () => { const navigate = useNavigate(); @@ -306,277 +225,296 @@ const MonitorDetail = () => { }, [historyData, selectedAgent]); if (isLoading) { - return ; + return ( +
+ +
+ ); } if (!monitorDetail) { - return ; + return ( +
+ +
+ ); } return ( -
-
- {/* Hero Section */} -
-
- {/* 返回按钮 */} - +
+ {/* Hero Section */} +
+ {/* 装饰性边框 */} +
+
+
+
+
- {/* 监控信息 */} -
-
-
- -
-
-
-

{monitorDetail.name}

- -
-

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

-
+
+ {/* 返回按钮 */} + + + {/* 监控信息 */} +
+
+
+
- - {/* 统计卡片 */} -
- - - - +
+
+ MONITOR_ID: {monitorDetail.id.toString().substring(0, 8)} +
+
+

{monitorDetail.name}

+ +
+

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

- {/* 证书信息(如果是 HTTPS)*/} - {monitorDetail.type === 'https' && monitorDetail.certExpiryTime && ( -
- SSL 证书: -
- -
-
+ {/* 统计卡片 */} +
+ + + + +
+
+ + {/* 证书信息(如果是 HTTPS)*/} + {monitorDetail.type === 'https' && monitorDetail.certExpiryTime && ( +
+ SSL 证书: + +
+ )} +
+
+ + {/* 响应时间趋势图表 */} + + + {availableAgents.length > 0 && ( + )}
-
- - {/* 响应时间趋势图表 */} - - - {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`} - /> - }/> - + } + > + {chartData.length > 0 ? ( + + + {monitorStats .filter(stat => selectedAgent === 'all' || stat.agentId === selectedAgent) - .map((stat) => { + .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]; - const agentLabel = stat.agentId.substring(0, 8); 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' && ( + - - - - {monitorDetail.type === 'https' && ( - - )} - - - - - {monitorStats.map((stat, index) => { - const color = AGENT_COLORS[index % AGENT_COLORS.length]; - return ( - - - - + + + + {monitorStats.map((stat, index) => { + const color = AGENT_COLORS[index % AGENT_COLORS.length]; + return ( + + - - {monitorDetail.type === 'https' && ( - - )} + + + + + + {monitorDetail.type === 'https' && ( - - ); - })} - -
+ 探针 ID + + 状态 + + 响应时间 + + 最后检测 + + 证书信息 - 状态 - - 响应时间 - - 最后检测 - - 证书信息 - - 错误信息 -
-
- -
- - - {stat.agentId.substring(0, 8)}... - -
-
-
- - + )} + + 错误信息 +
+
+
- - - {formatTime(stat.responseTime)} + + + {stat.agentId.substring(0, 8)}...
-
- {formatDateTime(stat.checkedAt)} - - {stat.certExpiryTime ? ( - - ) : ( - - - )} - + + +
+ + + {formatTime(stat.responseTime)} + +
+
+ {formatDateTime(stat.checkedAt)} + - {stat.status === 'down' && stat.message ? ( -
- - - {stat.message} - -
+ {stat.certExpiryTime ? ( + ) : ( - - + - )}
-
-
- + )} +
+ {stat.status === 'down' && stat.message ? ( +
+ + + {stat.message} + +
+ ) : ( + - + )} +
+
+
); }; diff --git a/web/src/pages/Public/ServerDetail.tsx b/web/src/pages/Public/ServerDetail.tsx index 04e55f3..9186799 100644 --- a/web/src/pages/Public/ServerDetail.tsx +++ b/web/src/pages/Public/ServerDetail.tsx @@ -1,19 +1,6 @@ -import React, {type ReactNode, useEffect, useMemo, useState} from 'react'; +import {type ReactNode, useEffect, useMemo, useState} from 'react'; import {useNavigate, useParams} from 'react-router-dom'; -import { - ArrowLeft, - Cpu, - Database, - HardDrive, - Loader2, - MemoryStick, - Network, - Server, - Thermometer, - TrendingUp, - Zap -} from 'lucide-react'; -import type {TooltipProps} from 'recharts'; +import {ArrowLeft, Cpu, Database, HardDrive, MemoryStick, Network, Server, Thermometer, Zap} from 'lucide-react'; import { Area, AreaChart, @@ -26,482 +13,31 @@ import { XAxis, YAxis, } from 'recharts'; -import { - getAgent, - getAgentLatestMetrics, - getAgentMetrics, - type GetAgentMetricsRequest, - type GetAgentMetricsResponse, - type MetricSeries, - type MetricDataPoint, - getAvailableNetworkInterfaces, -} from '@/api/agent.ts'; -import {type TimeRangeOption} from '@/api/property.ts'; -import type { - Agent, - LatestMetrics -} from '@/types'; -import dayjs from "dayjs"; +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'; +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"; -const formatBytes = (bytes: number | undefined | null): string => { - if (!bytes || bytes <= 0) return '0 B'; - const k = 1024; - const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; - const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1); - return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`; -}; - -const formatPercentValue = (value: number | undefined | null): string => { - if (value === undefined || value === null || Number.isNaN(value)) return '0.0'; - return value.toFixed(1); -}; - -const formatUptime = (seconds: number | undefined | null): string => { - if (seconds === undefined || seconds === null) return '-'; - if (seconds <= 0) return '0 秒'; - - const days = Math.floor(seconds / 86400); - const hours = Math.floor((seconds % 86400) / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - - const parts: string[] = []; - - // 智能显示:只显示最重要的两个单位,避免文本过长 - if (days > 0) { - parts.push(`${days} 天`); - if (hours > 0) parts.push(`${hours} 小时`); - } else if (hours > 0) { - parts.push(`${hours} 小时`); - if (minutes > 0) parts.push(`${minutes} 分钟`); - } else if (minutes > 0) { - parts.push(`${minutes} 分钟`); - } - - return parts.length > 0 ? parts.join(' ') : '不到 1 分钟'; -}; - -const formatDateTime = (value: string | number | undefined | null): string => { - if (value === undefined || value === null || value === '') { - return '-'; - } - - return dayjs(value).format('YYYY-MM-DD HH:mm:ss') -}; - -// 网卡颜色配置(上行和下行使用不同的色调) -const INTERFACE_COLORS = [ - {upload: '#6FD598', download: '#2C70F6'}, // 绿/蓝 - {upload: '#f59e0b', download: '#8b5cf6'}, // 橙/紫 - {upload: '#ec4899', download: '#06b6d4'}, // 粉/青 - {upload: '#10b981', download: '#f97316'}, // 翠绿/深橙 - {upload: '#14b8a6', download: '#2563eb'}, // 青绿/深蓝 -]; - -const LoadingSpinner = () => ( -
-
- -

数据加载中...

-
-
-); - -const EmptyState = ({message = '服务器不存在或已离线'}: { message?: string }) => ( -
-
-
- -
-

{message}

-
-
-); - -const ChartPlaceholder = ({ - icon: Icon = TrendingUp, - title = '暂无数据', - subtitle = '等待采集新数据后展示图表', - heightClass = 'h-52', - }: { - icon?: typeof TrendingUp; - title?: string; - subtitle?: string; - heightClass?: string; -}) => ( -
-
- -

{title}

- {subtitle ?

{subtitle}

: null} -
-
-); - -const Card = ({ - title, - description, - action, - children, - }: { - title?: string; - description?: string; - action?: ReactNode; - children: ReactNode; -}) => ( -
- {(title || description || action) && ( -
-
- {title ? -

{title}

: null} - {description ? -

{description}

: null} -
- {action ?
{action}
: null} -
- )} -
{children}
-
-); type AccentVariant = 'blue' | 'emerald' | 'purple' | 'amber'; -const accentThemes: Record = { - blue: { - icon: 'text-blue-400', - badge: 'text-blue-400', - highlight: 'text-blue-400', - }, - emerald: { - icon: 'text-emerald-400', - badge: 'text-emerald-400', - highlight: 'text-emerald-400', - }, - purple: { - icon: 'text-purple-400', - badge: 'text-purple-400', - highlight: 'text-purple-400', - }, - amber: { - icon: 'text-amber-400', - badge: 'text-amber-400', - highlight: 'text-amber-400', - }, -}; - -const InfoGrid = ({items}: { items: Array<{ label: string; value: ReactNode }> }) => ( -
- {items.map((item) => ( -
-
{item.label}
-
{item.value}
-
- ))} -
-); - -const TimeRangeSelector = ({ - value, - onChange, - options, - }: { - value: string; - onChange: (value: string) => void; - options: TimeRangeOption[]; -}) => ( -
- {options.map((option) => { - const isActive = option.value === value; - return ( - - ); - })} -
-); - -type MetricsTooltipProps = TooltipProps & { unit?: string, label?: string, payload?: any[] }; - -// 新的统一 Metrics 状态类型 -type MetricsState = { - cpu: MetricSeries[]; - memory: MetricSeries[]; - network: MetricSeries[]; - networkConnection: MetricSeries[]; - diskIO: MetricSeries[]; - gpu: MetricSeries[]; - temperature: MetricSeries[]; -}; - -const createEmptyMetricsState = (): MetricsState => ({ - cpu: [], - memory: [], - network: [], - networkConnection: [], - diskIO: [], - gpu: [], - temperature: [], -}); - -const metricRequestConfig: Array<{ key: keyof MetricsState; type: GetAgentMetricsRequest['type'] }> = [ - {key: 'cpu', type: 'cpu'}, - {key: 'memory', type: 'memory'}, - {key: 'network', type: 'network'}, - {key: 'networkConnection', type: 'network_connection'}, - // {key: 'disk', type: 'disk'}, - {key: 'diskIO', type: 'disk_io'}, - {key: 'gpu', type: 'gpu'}, - {key: 'temperature', type: 'temperature'}, -]; - -const useAgentOverview = (agentId?: string) => { - const [agent, setAgent] = useState(null); - const [latestMetrics, setLatestMetrics] = useState(null); - const [loading, setLoading] = useState(true); - - useEffect(() => { - let cancelled = false; - - if (!agentId) { - setAgent(null); - setLatestMetrics(null); - setLoading(false); - return; - } - - const fetchAgent = async () => { - setLoading(true); - try { - const [agentRes, latestRes] = await Promise.all([getAgent(agentId), getAgentLatestMetrics(agentId)]); - if (!cancelled) { - setAgent(agentRes.data); - setLatestMetrics(latestRes.data); - } - } catch (error) { - console.error('Failed to load agent details:', error); - } finally { - if (!cancelled) { - setLoading(false); - } - } - }; - - fetchAgent(); - - return () => { - cancelled = true; - }; - }, [agentId]); - - useEffect(() => { - if (!agentId) return; - - let cancelled = false; - - const refreshLatest = async () => { - try { - const latestRes = await getAgentLatestMetrics(agentId); - if (!cancelled) { - setLatestMetrics(latestRes.data); - } - } catch (error) { - console.error('Failed to refresh latest metrics:', error); - } - }; - - const timer = setInterval(refreshLatest, 5000); - return () => { - cancelled = true; - clearInterval(timer); - }; - }, [agentId]); - - return {agent, latestMetrics, loading}; -}; - -const useAggregatedMetrics = (agentId: string | undefined, range: string, interfaceName?: string) => { - const [metrics, setMetrics] = useState(() => createEmptyMetricsState()); - - useEffect(() => { - if (!agentId) { - setMetrics(createEmptyMetricsState()); - return; - } - - let cancelled = false; - - const fetchMetrics = async () => { - try { - const responses = await Promise.all( - metricRequestConfig.map(({type}) => { - // 只有 network 类型才需要传递 interface 参数 - const params: GetAgentMetricsRequest = {agentId, type, range}; - if (type === 'network' && interfaceName && interfaceName !== 'all') { - params.interface = interfaceName; - } - return getAgentMetrics(params); - }), - ); - if (cancelled) return; - const nextState = createEmptyMetricsState(); - metricRequestConfig.forEach(({key}, index) => { - // 新的 API 返回 series 数组 - nextState[key] = responses[index].data.series || []; - }); - setMetrics(nextState); - } catch (error) { - console.error('Failed to load metrics:', error); - } - }; - - fetchMetrics(); - const timer = setInterval(fetchMetrics, 30000); - return () => { - cancelled = true; - clearInterval(timer); - }; - }, [agentId, range, interfaceName]); - - return metrics; -}; - - -type SnapshotCardData = { - key: string; - icon: typeof Cpu; - title: string; - usagePercent: string; - accent: AccentVariant; - metrics: Array<{ label: string; value: ReactNode }>; -}; - -const SnapshotGrid = ({cards}: { cards: SnapshotCardData[] }) => ( -
- {cards.map((card) => { - const theme = accentThemes[card.accent]; - return ( -
-
-
- - - -

{card.title}

-
- {card.usagePercent} -
-
- {card.metrics.map((metric) => ( -
- {metric.label} - {metric.value} -
- ))} -
-
- ); - })} -
-); - -const SnapshotSection = ({cards}: { cards: SnapshotCardData[] }) => { - if (cards.length === 0) { - return null; - } - return ( -
-
-

资源快照

-
- -
- ); -}; - -const CustomTooltip = ({active, payload, label, unit = '%'}: MetricsTooltipProps) => { - if (!active || !payload || payload.length === 0) { - return null; - } - - // 从 payload 中获取完整的时间戳信息(如果有的话) - const fullTimestamp = payload[0]?.payload?.timestamp; - const displayLabel = fullTimestamp - ? dayjs(fullTimestamp).format('YYYY-MM-DD HH:mm:ss') - : label; - - return ( -
-

{displayLabel}

-
- {payload.map((entry, index) => { - if (!entry) { - return null; - } - - const dotColor = entry.color ?? '#6366f1'; - const title = entry.name ?? entry.dataKey ?? `系列 ${index + 1}`; - const value = - typeof entry.value === 'number' - ? Number.isFinite(entry.value) - ? entry.value.toFixed(2) - : '-' - : entry.value; - - return ( -

- - - {title}: {value} - {unit} - -

- ); - })} -
-
- ); -}; - -const timeRangeOptions = [ - {label: '15分钟', value: '15m'}, - {label: '30分钟', value: '30m'}, - {label: '1小时', value: '1h'}, - {label: '3小时', value: '3h'}, - {label: '6小时', value: '6h'}, - {label: '12小时', value: '12h'}, - {label: '1天', value: '1d'}, - {label: '3天', value: '3d'}, - {label: '7天', value: '7d'}, -] const ServerDetail = () => { const {id} = useParams<{ id: string }>(); @@ -765,17 +301,6 @@ const ServerDetail = () => { } }, [temperatureTypes, selectedTempType]); - // 温度类型颜色映射 - const temperatureColors: Record = { - 'CPU': '#f97316', // 橙色 - 'GPU': '#8b5cf6', // 紫色 - 'DISK': '#06b6d4', // 青色 - 'BATTERY': '#10b981', // 绿色 - 'CHIPSET': '#f59e0b', // 琥珀色 - 'SYSTEM': '#6366f1', // 靛蓝色 - 'PSU': '#ec4899', // 粉色 - }; - const snapshotCards: SnapshotCardData[] = useMemo(() => { if (!latestMetrics) { return [] as Array<{ @@ -949,17 +474,18 @@ const ServerDetail = () => { ]; if (loading) { - return ; + return ; } if (!agent) { - return ; + return ; } return (
-
+
@@ -1000,17 +526,12 @@ const ServerDetail = () => {
{heroStats.map((stat) => ( -
-

{stat.label}

-

{stat.value}

-
+ ))}
-
+
探针 ID:{agent.id} 版本:{agent.version || '-'} @@ -1021,7 +542,7 @@ const ServerDetail = () => {
- +
{

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

-
Total
+
Total +
{latestMetrics.networkConnection.total}
-
ESTABLISHED +
ESTABLISHED
{latestMetrics.networkConnection.established}
-
TIME_WAIT
+
TIME_WAIT +
{latestMetrics.networkConnection.timeWait}
-
LISTEN
+
LISTEN +
{latestMetrics.networkConnection.listen}
-
CLOSE_WAIT
+
CLOSE_WAIT +
{latestMetrics.networkConnection.closeWait}
-
OTHER
+
OTHER +
{latestMetrics.networkConnection.synSent + @@ -1096,8 +628,9 @@ const ServerDetail = () => { } + options={SERVER_TIME_RANGE_OPTIONS} variant="dark"/>} >
@@ -1132,7 +665,7 @@ const ServerDetail = () => { style={{fontSize: '12px'}} tickFormatter={(value) => `${value}%`} /> - }/> + }/> { ) : ( - + )}
@@ -1181,7 +714,7 @@ const ServerDetail = () => { style={{fontSize: '12px'}} tickFormatter={(value) => `${value}%`} /> - }/> + }/> { ) : ( - + )}
@@ -1253,7 +786,7 @@ const ServerDetail = () => { style={{fontSize: '12px'}} tickFormatter={(value) => `${value} MB`} /> - }/> + }/> {/* 渲染上行和下行区域 */} { ) : ( - + )}
@@ -1316,7 +849,7 @@ const ServerDetail = () => { style={{fontSize: '12px'}} tickFormatter={(value) => `${value} MB`} /> - }/> + }/> { ) : ( - + )}
@@ -1367,7 +900,7 @@ const ServerDetail = () => { className="stroke-cyan-600" style={{fontSize: '12px'}} /> - }/> + }/> { ) : ( - + )}
@@ -1446,7 +979,7 @@ const ServerDetail = () => { style={{fontSize: '12px'}} tickFormatter={(value) => `${value}°C`} /> - }/> + }/> { style={{fontSize: '12px'}} tickFormatter={(value) => `${value}°C`} /> - }/> + }/> {/* 为选中的温度类型渲染线条 */} {filteredTemperatureTypes.map((type, index) => { // 使用预定义颜色,如果没有则使用默认颜色 - const color = temperatureColors[type] || `hsl(${(index * 60) % 360}, 70%, 50%)`; + const color = TEMPERATURE_COLORS[type] || `hsl(${(index * 60) % 360}, 70%, 50%)`; return ( { {/* GPU 监控 */} {latestMetrics?.gpu && latestMetrics.gpu.length > 0 && ( - +
{latestMetrics.gpu.map((gpu) => (
{
- 温度 + 温度 {gpu.temperature.toFixed(1)}°C
- 显存 + 显存 {formatBytes(gpu.memoryUsed)} / {formatBytes(gpu.memoryTotal)}
- 功耗 + 功耗 {gpu.powerDraw.toFixed(1)}W
- 风扇转速 + 风扇转速 {gpu.fanSpeed.toFixed(0)}%
@@ -1597,7 +1134,7 @@ const ServerDetail = () => { {/* 温度监控 */} {latestMetrics?.temperature && latestMetrics.temperature.length > 0 && ( - +
{latestMetrics.temperature.sort((a, b) => a.sensorKey.localeCompare(b.sensorKey)).map((temp) => (
{ if (!ms || ms <= 0) return '0 ms'; if (ms < 1000) return `${ms.toFixed(0)} ms`; @@ -14,17 +16,11 @@ export const formatDate = (timestamp: number): string => { }); }; -export const formatDateTime = (timestamp: number): string => { - if (!timestamp) return '-'; - const date = new Date(timestamp); - return date.toLocaleString('zh-CN', { - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - second: '2-digit' - }); +export const formatDateTime = (value: string | number | undefined | null): string => { + if (value === undefined || value === null || value === '') { + return '-'; + } + return dayjs(value).format('YYYY-MM-DD HH:mm:ss'); }; export const formatSpeed = (bytesPerSecond: number): string => { @@ -37,10 +33,39 @@ export const formatSpeed = (bytesPerSecond: number): string => { return `${value.toFixed(decimals)} ${sizes[i]}`; }; -export const formatBytes = (bytes: number): string => { +export const formatBytes = (bytes: number | undefined | null): string => { if (!bytes || bytes <= 0) return '0 B'; const k = 1024; - const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1); return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`; +}; + +export const formatPercentValue = (value: number | undefined | null): string => { + if (value === undefined || value === null || Number.isNaN(value)) return '0.0'; + return value.toFixed(1); +}; + +export const formatUptime = (seconds: number | undefined | null): string => { + if (seconds === undefined || seconds === null) return '-'; + if (seconds <= 0) return '0 秒'; + + const days = Math.floor(seconds / 86400); + const hours = Math.floor((seconds % 86400) / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + + const parts: string[] = []; + + // 智能显示:只显示最重要的两个单位,避免文本过长 + if (days > 0) { + parts.push(`${days} 天`); + if (hours > 0) parts.push(`${hours} 小时`); + } else if (hours > 0) { + parts.push(`${hours} 小时`); + if (minutes > 0) parts.push(`${minutes} 分钟`); + } else if (minutes > 0) { + parts.push(`${minutes} 分钟`); + } + + return parts.length > 0 ? parts.join(' ') : '不到 1 分钟'; }; \ No newline at end of file From 36eaae9c409bbe6066ad530ef530951c38e022e6 Mon Sep 17 00:00:00 2001 From: dushixiang Date: Sun, 14 Dec 2025 17:30:28 +0800 Subject: [PATCH 14/18] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=85=AC=E5=85=B1?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/pages/Public/ServerDetail.tsx | 4 ++-- web/src/types/index.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/web/src/pages/Public/ServerDetail.tsx b/web/src/pages/Public/ServerDetail.tsx index 9186799..439b5e2 100644 --- a/web/src/pages/Public/ServerDetail.tsx +++ b/web/src/pages/Public/ServerDetail.tsx @@ -1034,7 +1034,7 @@ const ServerDetail = () => { + className="stroke-cyan-900/30"/> { >
-

{temp.sensorLabel}

+

{temp.type}

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

diff --git a/web/src/types/index.ts b/web/src/types/index.ts index bb87d35..b72c78b 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -214,7 +214,7 @@ export interface TemperatureMetric { id: number; agentId: string; sensorKey: string; - sensorLabel: string; + type: string; temperature: number; timestamp: number; } From c0790e9ced47d425137526ec77103862cf8ec6ce Mon Sep 17 00:00:00 2001 From: dushixiang Date: Sun, 14 Dec 2025 19:41:55 +0800 Subject: [PATCH 15/18] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=85=AC=E5=85=B1?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2&=E5=A2=9E=E5=8A=A0=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E8=BF=81=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/serv/main.go | 176 ++- docker-compose.yml | 4 +- internal/migrate/v0_0_13/metric.go | 355 +++++ internal/migrate/v0_0_13/migrate.go | 802 +++++++++++ internal/protocol/message.go | 1 + internal/service/metric_service.go | 64 +- .../components/monitor/AgentStatsTable.tsx | 140 ++ web/src/components/monitor/MonitorHero.tsx | 98 ++ .../components/monitor/ResponseTimeChart.tsx | 283 ++++ web/src/components/monitor/index.ts | 7 + .../components/server/GpuMonitorSection.tsx | 68 + .../server/NetworkConnectionSection.tsx | 61 + web/src/components/server/ServerHero.tsx | 96 ++ .../components/server/SystemInfoSection.tsx | 179 +++ .../server/TemperatureMonitorSection.tsx | 37 + .../server/charts/ChartContainer.tsx | 30 + web/src/components/server/charts/CpuChart.tsx | 90 ++ .../components/server/charts/DiskIOChart.tsx | 122 ++ web/src/components/server/charts/GpuChart.tsx | 125 ++ .../components/server/charts/MemoryChart.tsx | 90 ++ .../components/server/charts/NetworkChart.tsx | 154 +++ .../server/charts/NetworkConnectionChart.tsx | 124 ++ .../server/charts/TemperatureChart.tsx | 148 ++ web/src/components/server/charts/index.ts | 8 + web/src/components/server/index.ts | 5 + web/src/hooks/server/index.ts | 5 + web/src/hooks/server/queries/index.ts | 4 + web/src/hooks/server/queries/useAgentQuery.ts | 16 + .../server/queries/useLatestMetricsQuery.ts | 17 + .../hooks/server/queries/useMetricsQuery.ts | 30 + .../queries/useNetworkInterfacesQuery.ts | 15 + web/src/pages/Public/MonitorDetail.tsx | 504 +------ web/src/pages/Public/ServerDetail.tsx | 1188 +---------------- web/src/types/index.ts | 1 + 34 files changed, 3449 insertions(+), 1598 deletions(-) create mode 100644 internal/migrate/v0_0_13/metric.go create mode 100644 internal/migrate/v0_0_13/migrate.go create mode 100644 web/src/components/monitor/AgentStatsTable.tsx create mode 100644 web/src/components/monitor/MonitorHero.tsx create mode 100644 web/src/components/monitor/ResponseTimeChart.tsx create mode 100644 web/src/components/monitor/index.ts create mode 100644 web/src/components/server/GpuMonitorSection.tsx create mode 100644 web/src/components/server/NetworkConnectionSection.tsx create mode 100644 web/src/components/server/ServerHero.tsx create mode 100644 web/src/components/server/SystemInfoSection.tsx create mode 100644 web/src/components/server/TemperatureMonitorSection.tsx create mode 100644 web/src/components/server/charts/ChartContainer.tsx create mode 100644 web/src/components/server/charts/CpuChart.tsx create mode 100644 web/src/components/server/charts/DiskIOChart.tsx create mode 100644 web/src/components/server/charts/GpuChart.tsx create mode 100644 web/src/components/server/charts/MemoryChart.tsx create mode 100644 web/src/components/server/charts/NetworkChart.tsx create mode 100644 web/src/components/server/charts/NetworkConnectionChart.tsx create mode 100644 web/src/components/server/charts/TemperatureChart.tsx create mode 100644 web/src/components/server/charts/index.ts create mode 100644 web/src/hooks/server/index.ts create mode 100644 web/src/hooks/server/queries/index.ts create mode 100644 web/src/hooks/server/queries/useAgentQuery.ts create mode 100644 web/src/hooks/server/queries/useLatestMetricsQuery.ts create mode 100644 web/src/hooks/server/queries/useMetricsQuery.ts create mode 100644 web/src/hooks/server/queries/useNetworkInterfacesQuery.ts diff --git a/cmd/serv/main.go b/cmd/serv/main.go index a9724c9..9edeed6 100644 --- a/cmd/serv/main.go +++ b/cmd/serv/main.go @@ -1,7 +1,179 @@ package main -import "github.com/dushixiang/pika/internal" +import ( + "fmt" + "log" + "os" + "time" + + "github.com/dushixiang/pika/internal" + "github.com/dushixiang/pika/internal/config" + v0_0_13 "github.com/dushixiang/pika/internal/migrate/v0_0_13" + "github.com/dushixiang/pika/internal/vmclient" + "github.com/go-orz/orz" + "github.com/spf13/cobra" + "go.uber.org/zap" + "gorm.io/gorm" +) + +var ( + configFile string + rootCmd = &cobra.Command{ + Use: "pika-server", + Short: "Pika 服务器监控系统", + Long: `Pika 是一个轻量级的服务器监控系统,支持多探针部署和实时监控。`, + Run: func(cmd *cobra.Command, args []string) { + internal.Run(configFile) + }, + } + + serveCmd = &cobra.Command{ + Use: "serve", + Short: "启动 Pika 服务器", + Long: `启动 Pika HTTP 服务器,提供 Web 界面和 API 服务。`, + Run: func(cmd *cobra.Command, args []string) { + internal.Run(configFile) + }, + } + + migrateCmd = &cobra.Command{ + Use: "migrate_v0_0_13", + Short: "执行数据库迁移", + Long: `将历史 PostgreSQL 指标数据迁移到 VictoriaMetrics(仅需执行一次)。`, + Run: func(cmd *cobra.Command, args []string) { + runMigration(configFile) + }, + } +) + +func init() { + // 添加全局配置文件参数 + rootCmd.PersistentFlags().StringVarP(&configFile, "config", "c", "./config.yaml", "配置文件路径") + + // 添加子命令 + rootCmd.AddCommand(serveCmd) + rootCmd.AddCommand(migrateCmd) +} func main() { - internal.Run("./config.yaml") + if err := rootCmd.Execute(); err != nil { + fmt.Fprintf(os.Stderr, "错误: %v\n", err) + os.Exit(1) + } +} + +// runMigration 执行数据迁移 +func runMigration(configPath string) { + fmt.Println("=== Pika 数据迁移工具 ===") + fmt.Println("配置文件:", configPath) + fmt.Println() + + // 初始化 orz 以获取数据库连接和配置 + var db *gorm.DB + var vmClient *vmclient.VMClient + var logger *zap.Logger + + err := orz.Quick(configPath, func(app *orz.App) error { + logger = app.Logger() + db = app.GetDatabase() + + // 读取 VictoriaMetrics 配置 + var appConfig config.AppConfig + _config := app.GetConfig() + if _config != nil { + if err := _config.App.Unmarshal(&appConfig); err != nil { + return fmt.Errorf("读取配置失败: %w", err) + } + } + + // 初始化 VictoriaMetrics 客户端 + vmClient = provideVMClient(&appConfig, logger) + + return nil + }) + + if err != nil { + log.Fatalf("初始化失败: %v", err) + } + + // 执行迁移 + fmt.Println("开始迁移历史指标数据到 VictoriaMetrics...") + fmt.Println("说明: 数据将从 PostgreSQL 迁移到 VictoriaMetrics。") + fmt.Println("警告: 建议在迁移前备份 PostgreSQL 数据!") + fmt.Println() + fmt.Print("是否在迁移完成后自动删除旧的数据表?(yes/no): ") + + var deleteOldTables bool + var answer string + fmt.Scanln(&answer) + if answer == "yes" || answer == "y" || answer == "Y" { + deleteOldTables = true + fmt.Println("✓ 将在迁移完成后自动删除旧表") + } else { + deleteOldTables = false + fmt.Println("✓ 将保留旧表,您可以稍后手动删除") + } + + fmt.Println() + startTime := time.Now() + + // 执行数据迁移 + if err := v0_0_13.Migrate(db, vmClient); err != nil { + logger.Error("迁移失败", zap.Error(err)) + log.Fatalf("迁移失败: %v", err) + } + + elapsed := time.Since(startTime) + fmt.Println() + fmt.Printf("✓ 数据迁移成功完成!耗时: %s\n", elapsed) + + // 根据用户选择决定是否删除旧表 + if deleteOldTables { + fmt.Println() + fmt.Println("正在删除旧的指标数据表...") + if err := v0_0_13.DropOldTables(db); err != nil { + logger.Error("删除旧表失败", zap.Error(err)) + log.Printf("警告: 删除旧表失败: %v\n", err) + fmt.Println("注意: 旧表删除失败,但数据已成功迁移,您可以手动删除这些表。") + } else { + fmt.Println("✓ 旧表删除完成") + fmt.Println() + fmt.Println("✓ 迁移任务全部完成!") + } + } else { + fmt.Println() + fmt.Println("提示: 旧数据表已保留,您可以验证数据后手动删除。") + fmt.Println("提示: 如需删除旧表,可以重新运行此命令并选择删除。") + } + + fmt.Println() + fmt.Println("提示: 现在可以启动服务器: ./pika-server serve") +} + +// provideVMClient 提供 VictoriaMetrics 客户端 +func provideVMClient(cfg *config.AppConfig, logger *zap.Logger) *vmclient.VMClient { + // 检查配置 + if cfg.VictoriaMetrics == nil || !cfg.VictoriaMetrics.Enabled { + logger.Info("VictoriaMetrics is not enabled, using default configuration") + // 返回一个默认配置的客户端(用于本地开发) + return vmclient.NewVMClient("http://localhost:8428", 30*time.Second, 60*time.Second) + } + + // 使用配置创建客户端 + writeTimeout := time.Duration(cfg.VictoriaMetrics.WriteTimeout) * time.Second + if writeTimeout == 0 { + writeTimeout = 30 * time.Second + } + + queryTimeout := time.Duration(cfg.VictoriaMetrics.QueryTimeout) * time.Second + if queryTimeout == 0 { + queryTimeout = 60 * time.Second + } + + logger.Info("VictoriaMetrics client initialized", + zap.String("url", cfg.VictoriaMetrics.URL), + zap.Duration("writeTimeout", writeTimeout), + zap.Duration("queryTimeout", queryTimeout)) + + return vmclient.NewVMClient(cfg.VictoriaMetrics.URL, writeTimeout, queryTimeout) } diff --git a/docker-compose.yml b/docker-compose.yml index a3e1fef..23793f9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,9 +29,9 @@ services: - "-storageDataPath=/victoria-metrics-data" - "-httpListenAddr=:8428" ports: - - "8428:8428" # API, PromQL, insert, select + - "127.0.0.1:8428:8428" # API, PromQL, insert, select volumes: - - ./vmdata:/victoria-metrics-data + - ./data/vmdata:/victoria-metrics-data networks: - pika-network pika: diff --git a/internal/migrate/v0_0_13/metric.go b/internal/migrate/v0_0_13/metric.go new file mode 100644 index 0000000..e6cfc8a --- /dev/null +++ b/internal/migrate/v0_0_13/metric.go @@ -0,0 +1,355 @@ +package models + +// CPUMetric CPU指标 +type CPUMetric struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + AgentID string `gorm:"index:idx_cpu_agent_ts,priority:1" json:"agentId"` // 探针ID + UsagePercent float64 `json:"usagePercent"` // CPU使用率 + LogicalCores int `json:"logicalCores"` // 逻辑核心数 + PhysicalCores int `json:"physicalCores"` // 物理核心数 + ModelName string `json:"modelName"` // CPU型号 + Timestamp int64 `gorm:"index:idx_cpu_agent_ts,priority:2;index:idx_cpu_ts" json:"timestamp"` // 时间戳(毫秒) +} + +func (CPUMetric) TableName() string { + return "cpu_metrics" +} + +// MemoryMetric 内存指标 +type MemoryMetric struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + AgentID string `gorm:"index:idx_mem_agent_ts,priority:1" json:"agentId"` // 探针ID + Total uint64 `json:"total"` // 总内存(字节) + Used uint64 `json:"used"` // 已使用(字节) + Free uint64 `json:"free"` // 空闲(字节) + Available uint64 `json:"available"` // 可用内存(字节) + UsagePercent float64 `json:"usagePercent"` // 使用率 + SwapTotal uint64 `json:"swapTotal"` // 总交换空间(字节) + SwapUsed uint64 `json:"swapUsed"` // 已使用交换空间(字节) + SwapFree uint64 `json:"swapFree"` // 空闲交换空间(字节) + Timestamp int64 `gorm:"index:idx_mem_agent_ts,priority:2;index:idx_mem_ts" json:"timestamp"` // 时间戳(毫秒) +} + +func (MemoryMetric) TableName() string { + return "memory_metrics" +} + +// DiskMetric 磁盘指标 +type DiskMetric struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + AgentID string `gorm:"index:idx_disk_agent_ts,priority:1" json:"agentId"` // 探针ID + MountPoint string `json:"mountPoint"` // 挂载点 + Total uint64 `json:"total"` // 总容量(字节) + Used uint64 `json:"used"` // 已使用(字节) + Free uint64 `json:"free"` // 空闲(字节) + UsagePercent float64 `json:"usagePercent"` // 使用率 + Timestamp int64 `gorm:"index:idx_disk_agent_ts,priority:2;index:idx_disk_ts" json:"timestamp"` // 时间戳(毫秒) +} + +func (DiskMetric) TableName() string { + return "disk_metrics" +} + +// NetworkMetric 网络指标 +type NetworkMetric struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + AgentID string `gorm:"index:idx_net_agent_ts,priority:1" json:"agentId"` // 探针ID + Interface string `json:"interface"` // 网卡名称 + BytesSentRate uint64 `json:"bytesSentRate"` // 发送速率(字节/秒) + BytesRecvRate uint64 `json:"bytesRecvRate"` // 接收速率(字节/秒) + BytesSentTotal uint64 `json:"bytesSentTotal"` // 累计发送字节数 + BytesRecvTotal uint64 `json:"bytesRecvTotal"` // 累计接收字节数 + Timestamp int64 `gorm:"index:idx_net_agent_ts,priority:2;index:idx_net_ts" json:"timestamp"` // 时间戳(毫秒) +} + +func (NetworkMetric) TableName() string { + return "network_metrics" +} + +// NetworkConnectionMetric 网络连接统计指标 +type NetworkConnectionMetric struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + AgentID string `gorm:"index:idx_netconn_agent_ts,priority:1" json:"agentId"` // 探针ID + Established uint32 `json:"established"` // ESTABLISHED 状态连接数 + SynSent uint32 `json:"synSent"` // SYN_SENT 状态连接数 + SynRecv uint32 `json:"synRecv"` // SYN_RECV 状态连接数 + FinWait1 uint32 `json:"finWait1"` // FIN_WAIT1 状态连接数 + FinWait2 uint32 `json:"finWait2"` // FIN_WAIT2 状态连接数 + TimeWait uint32 `json:"timeWait"` // TIME_WAIT 状态连接数 + Close uint32 `json:"close"` // CLOSE 状态连接数 + CloseWait uint32 `json:"closeWait"` // CLOSE_WAIT 状态连接数 + LastAck uint32 `json:"lastAck"` // LAST_ACK 状态连接数 + Listen uint32 `json:"listen"` // LISTEN 状态连接数 + Closing uint32 `json:"closing"` // CLOSING 状态连接数 + Total uint32 `json:"total"` // 总连接数 + Timestamp int64 `gorm:"index:idx_netconn_agent_ts,priority:2;index:idx_netconn_ts" json:"timestamp"` // 时间戳(毫秒) +} + +func (NetworkConnectionMetric) TableName() string { + return "network_connection_metrics" +} + +// DiskIOMetric 磁盘IO指标 +type DiskIOMetric struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + AgentID string `gorm:"index:idx_diskio_agent_ts,priority:1" json:"agentId"` // 探针ID + Device string `json:"device"` // 设备名称 + ReadCount uint64 `json:"readCount"` // 读取次数 + WriteCount uint64 `json:"writeCount"` // 写入次数 + ReadBytes uint64 `json:"readBytes"` // 读取字节数 + WriteBytes uint64 `json:"writeBytes"` // 写入字节数 + ReadBytesRate uint64 `json:"readBytesRate"` // 读取速率(字节/秒) + WriteBytesRate uint64 `json:"writeBytesRate"` // 写入速率(字节/秒) + ReadTime uint64 `json:"readTime"` // 读取时间(毫秒) + WriteTime uint64 `json:"writeTime"` // 写入时间(毫秒) + IoTime uint64 `json:"ioTime"` // IO时间(毫秒) + IopsInProgress uint64 `json:"iopsInProgress"` // 正在进行的IO操作数 + Timestamp int64 `gorm:"index:idx_diskio_agent_ts,priority:2;index:idx_diskio_ts" json:"timestamp"` // 时间戳(毫秒) +} + +func (DiskIOMetric) TableName() string { + return "disk_io_metrics" +} + +// GPUMetric GPU指标 +type GPUMetric struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + AgentID string `gorm:"index:idx_gpu_agent_idx_ts,priority:1" json:"agentId"` // 探针ID + Index int `gorm:"index:idx_gpu_agent_idx_ts,priority:2" json:"index"` // GPU索引 + Name string `json:"name"` // GPU名称 + Utilization float64 `json:"utilization"` // GPU使用率(%) + MemoryTotal uint64 `json:"memoryTotal"` // 总显存(字节) + MemoryUsed uint64 `json:"memoryUsed"` // 已使用显存(字节) + MemoryFree uint64 `json:"memoryFree"` // 空闲显存(字节) + Temperature float64 `json:"temperature"` // 温度(℃) + PowerDraw float64 `json:"powerDraw"` // 功耗(瓦) + FanSpeed float64 `json:"fanSpeed"` // 风扇转速(%) + PerformanceState string `json:"performanceState"` // 性能状态 + Timestamp int64 `gorm:"index:idx_gpu_agent_idx_ts,priority:3;index:idx_gpu_ts" json:"timestamp"` // 时间戳(毫秒) +} + +func (GPUMetric) TableName() string { + return "gpu_metrics" +} + +// TemperatureMetric 温度指标 +type TemperatureMetric struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + AgentID string `gorm:"index:idx_temp_agent_sensor_ts,priority:1" json:"agentId"` // 探针ID + SensorKey string `gorm:"index:idx_temp_agent_sensor_ts,priority:2" json:"sensorKey"` // 传感器标识 + SensorLabel string `json:"sensorLabel"` // 传感器标签 + Temperature float64 `json:"temperature"` // 温度(℃) + Timestamp int64 `gorm:"index:idx_temp_agent_sensor_ts,priority:3;index:idx_temp_ts" json:"timestamp"` // 时间戳(毫秒) +} + +func (TemperatureMetric) TableName() string { + return "temperature_metrics" +} + +// HostMetric 主机信息指标 +type HostMetric struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + AgentID string `gorm:"uniqueIndex:ux_host_agent" json:"agentId"` // 探针ID(唯一约束用于 upsert) + OS string `json:"os"` // 操作系统 + Platform string `json:"platform"` // 平台 + PlatformVersion string `json:"platformVersion"` // 平台版本 + KernelVersion string `json:"kernelVersion"` // 内核版本 + KernelArch string `json:"kernelArch"` // 内核架构 + Uptime uint64 `json:"uptime"` // 运行时间(秒) + BootTime uint64 `json:"bootTime"` // 启动时间(Unix时间戳-秒) + Procs uint64 `json:"procs"` // 进程数 + Timestamp int64 `gorm:"index:idx_host_ts" json:"timestamp"` // 时间戳(毫秒) +} + +func (HostMetric) TableName() string { + return "host_metrics" +} + +// MonitorMetric 监控指标 +type MonitorMetric struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + AgentId string `gorm:"index:idx_mon_agent_monitor_ts,priority:1" json:"agentId"` // 探针ID + MonitorId string `gorm:"index:idx_mon_agent_monitor_ts,priority:2;index:idx_mon_type_monitor_ts,priority:2" json:"monitorId"` // 监控项ID + Type string `gorm:"index:idx_mon_type_monitor_ts,priority:1" json:"type"` // 监控类型: http, tcp + Target string `json:"target"` // 监控目标 + Status string `json:"status"` // 状态: up, down + StatusCode int `json:"statusCode"` // HTTP状态码 + ResponseTime int64 `json:"responseTime"` // 响应时间(毫秒) + Error string `json:"error"` // 错误信息 + Message string `json:"message"` // 附加信息 + ContentMatch bool `json:"contentMatch"` // 内容匹配结果 + CertExpiryTime int64 `json:"certExpiryTime"` // 证书过期时间(毫秒时间戳), 0表示无证书 + CertDaysLeft int `json:"certDaysLeft"` // 证书剩余天数 + Timestamp int64 `gorm:"index:idx_mon_agent_monitor_ts,priority:3;index:idx_mon_type_monitor_ts,priority:3;index:idx_mon_ts" json:"timestamp"` // 时间戳(毫秒) +} + +func (MonitorMetric) TableName() string { + return "monitor_metrics" +} + +// ----------- 聚合表 ----------- + +// AggregatedCPUMetricModel CPU聚合表 +type AggregatedCPUMetricModel struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + AgentID string `gorm:"index:idx_cpuagg_agent_bucket,priority:1;uniqueIndex:ux_cpuagg_bucket,priority:1" json:"agentId"` + BucketSeconds int `gorm:"index:idx_cpuagg_agent_bucket,priority:2;uniqueIndex:ux_cpuagg_bucket,priority:2" json:"bucketSeconds"` + BucketStart int64 `gorm:"index:idx_cpuagg_agent_bucket,priority:3;uniqueIndex:ux_cpuagg_bucket,priority:3" json:"bucketStart"` // 毫秒 + MaxUsage float64 `json:"maxUsage"` + LogicalCores int `json:"logicalCores"` +} + +func (AggregatedCPUMetricModel) TableName() string { + return "cpu_metrics_aggs" +} + +// AggregatedMemoryMetricModel 内存聚合表 +type AggregatedMemoryMetricModel struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + AgentID string `gorm:"index:idx_memagg_agent_bucket,priority:1;uniqueIndex:ux_memagg_bucket,priority:1" json:"agentId"` + BucketSeconds int `gorm:"index:idx_memagg_agent_bucket,priority:2;uniqueIndex:ux_memagg_bucket,priority:2" json:"bucketSeconds"` + BucketStart int64 `gorm:"index:idx_memagg_agent_bucket,priority:3;uniqueIndex:ux_memagg_bucket,priority:3" json:"bucketStart"` // 毫秒 + MaxUsage float64 `json:"maxUsage"` + Total uint64 `json:"total"` +} + +func (AggregatedMemoryMetricModel) TableName() string { + return "memory_metrics_aggs" +} + +// AggregatedDiskMetricModel 磁盘聚合表 +type AggregatedDiskMetricModel struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + AgentID string `gorm:"index:idx_diskagg_agent_bucket_mp,priority:1;uniqueIndex:ux_diskagg_bucket,priority:1" json:"agentId"` + BucketSeconds int `gorm:"index:idx_diskagg_agent_bucket_mp,priority:2;uniqueIndex:ux_diskagg_bucket,priority:2" json:"bucketSeconds"` + BucketStart int64 `gorm:"index:idx_diskagg_agent_bucket_mp,priority:3;uniqueIndex:ux_diskagg_bucket,priority:3" json:"bucketStart"` // 毫秒 + MountPoint string `gorm:"index:idx_diskagg_agent_bucket_mp,priority:4;uniqueIndex:ux_diskagg_bucket,priority:4" json:"mountPoint"` + MaxUsage float64 `json:"maxUsage"` + Total uint64 `json:"total"` +} + +func (AggregatedDiskMetricModel) TableName() string { + return "disk_metrics_aggs" +} + +// AggregatedNetworkMetricModel 网络聚合表(按网卡分组) +type AggregatedNetworkMetricModel struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + AgentID string `gorm:"index:idx_netagg_agent_bucket_iface,priority:1;uniqueIndex:ux_netagg_bucket,priority:1" json:"agentId"` + BucketSeconds int `gorm:"index:idx_netagg_agent_bucket_iface,priority:2;uniqueIndex:ux_netagg_bucket,priority:2" json:"bucketSeconds"` + BucketStart int64 `gorm:"index:idx_netagg_agent_bucket_iface,priority:3;uniqueIndex:ux_netagg_bucket,priority:3" json:"bucketStart"` // 毫秒 + Interface string `gorm:"index:idx_netagg_agent_bucket_iface,priority:4;uniqueIndex:ux_netagg_bucket,priority:4" json:"interface"` + MaxSentRate float64 `json:"maxSentRate"` + MaxRecvRate float64 `json:"maxRecvRate"` +} + +func (AggregatedNetworkMetricModel) TableName() string { + return "network_metrics_aggs" +} + +// AggregatedNetworkConnectionMetricModel 网络连接聚合表 +type AggregatedNetworkConnectionMetricModel struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + AgentID string `gorm:"index:idx_netconnagg_agent_bucket,priority:1;uniqueIndex:ux_netconnagg_bucket,priority:1" json:"agentId"` + BucketSeconds int `gorm:"index:idx_netconnagg_agent_bucket,priority:2;uniqueIndex:ux_netconnagg_bucket,priority:2" json:"bucketSeconds"` + BucketStart int64 `gorm:"index:idx_netconnagg_agent_bucket,priority:3;uniqueIndex:ux_netconnagg_bucket,priority:3" json:"bucketStart"` // 毫秒 + MaxEstablished uint32 `json:"maxEstablished"` + MaxSynSent uint32 `json:"maxSynSent"` + MaxSynRecv uint32 `json:"maxSynRecv"` + MaxFinWait1 uint32 `json:"maxFinWait1"` + MaxFinWait2 uint32 `json:"maxFinWait2"` + MaxTimeWait uint32 `json:"maxTimeWait"` + MaxClose uint32 `json:"maxClose"` + MaxCloseWait uint32 `json:"maxCloseWait"` + MaxLastAck uint32 `json:"maxLastAck"` + MaxListen uint32 `json:"maxListen"` + MaxClosing uint32 `json:"maxClosing"` + MaxTotal uint32 `json:"maxTotal"` +} + +func (AggregatedNetworkConnectionMetricModel) TableName() string { + return "network_connection_metrics_aggs" +} + +// AggregatedDiskIOMetricModel 磁盘IO聚合表(汇总所有磁盘) +type AggregatedDiskIOMetricModel struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + AgentID string `gorm:"index:idx_diskioagg_agent_bucket,priority:1;uniqueIndex:ux_diskioagg_bucket,priority:1" json:"agentId"` + BucketSeconds int `gorm:"index:idx_diskioagg_agent_bucket,priority:2;uniqueIndex:ux_diskioagg_bucket,priority:2" json:"bucketSeconds"` + BucketStart int64 `gorm:"index:idx_diskioagg_agent_bucket,priority:3;uniqueIndex:ux_diskioagg_bucket,priority:3" json:"bucketStart"` // 毫秒 + MaxReadBytesRate uint64 `json:"maxReadBytesRate"` + MaxWriteBytesRate uint64 `json:"maxWriteBytesRate"` + MaxIopsInProgress uint64 `json:"maxIopsInProgress"` +} + +func (AggregatedDiskIOMetricModel) TableName() string { + return "disk_io_metrics_aggs" +} + +// AggregatedGPUMetricModel GPU聚合表 +type AggregatedGPUMetricModel struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + AgentID string `gorm:"index:idx_gpuagg_agent_bucket_idx,priority:1;uniqueIndex:ux_gpuagg_bucket,priority:1" json:"agentId"` + BucketSeconds int `gorm:"index:idx_gpuagg_agent_bucket_idx,priority:2;uniqueIndex:ux_gpuagg_bucket,priority:2" json:"bucketSeconds"` + BucketStart int64 `gorm:"index:idx_gpuagg_agent_bucket_idx,priority:3;uniqueIndex:ux_gpuagg_bucket,priority:3" json:"bucketStart"` // 毫秒 + Index int `gorm:"index:idx_gpuagg_agent_bucket_idx,priority:4;uniqueIndex:ux_gpuagg_bucket,priority:4" json:"index"` + Name string `json:"name"` + MaxUtilization float64 `json:"maxUtilization"` + MaxMemoryUsed uint64 `json:"maxMemoryUsed"` + MaxTemperature float64 `json:"maxTemperature"` + MaxPowerDraw float64 `json:"maxPowerDraw"` + MemoryTotal uint64 `json:"memoryTotal"` +} + +func (AggregatedGPUMetricModel) TableName() string { + return "gpu_metrics_aggs" +} + +// AggregatedTemperatureMetricModel 温度聚合表 +type AggregatedTemperatureMetricModel struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + AgentID string `gorm:"index:idx_tempagg_agent_bucket_sensor,priority:1;uniqueIndex:ux_tempagg_bucket,priority:1" json:"agentId"` + BucketSeconds int `gorm:"index:idx_tempagg_agent_bucket_sensor,priority:2;uniqueIndex:ux_tempagg_bucket,priority:2" json:"bucketSeconds"` + BucketStart int64 `gorm:"index:idx_tempagg_agent_bucket_sensor,priority:3;uniqueIndex:ux_tempagg_bucket,priority:3" json:"bucketStart"` // 毫秒 + SensorKey string `gorm:"index:idx_tempagg_agent_bucket_sensor,priority:4;uniqueIndex:ux_tempagg_bucket,priority:4" json:"sensorKey"` + SensorLabel string `json:"sensorLabel"` + MaxTemperature float64 `json:"maxTemperature"` +} + +func (AggregatedTemperatureMetricModel) TableName() string { + return "temperature_metrics_aggs" +} + +// AggregationProgress 聚合进度记录 +type AggregationProgress struct { + MetricType string `gorm:"primaryKey"` // cpu/memory/disk/network + BucketSeconds int `gorm:"primaryKey"` + LastBucket int64 `json:"lastBucket"` // 已完成的最后 bucket 起始时间(毫秒) + UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli"` + CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"` +} + +func (AggregationProgress) TableName() string { + return "aggregation_progress" +} + +// AggregatedMonitorMetricModel 监控指标聚合表 +type AggregatedMonitorMetricModel struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + MonitorID string `gorm:"index:idx_monitoragg_monitor_agent_bucket,priority:1;uniqueIndex:ux_monitoragg_bucket,priority:1" json:"monitorId"` + AgentID string `gorm:"index:idx_monitoragg_monitor_agent_bucket,priority:2;uniqueIndex:ux_monitoragg_bucket,priority:2" json:"agentId"` + BucketSeconds int `gorm:"index:idx_monitoragg_monitor_agent_bucket,priority:3;uniqueIndex:ux_monitoragg_bucket,priority:3" json:"bucketSeconds"` + BucketStart int64 `gorm:"index:idx_monitoragg_monitor_agent_bucket,priority:4;uniqueIndex:ux_monitoragg_bucket,priority:4" json:"bucketStart"` // 毫秒 + AvgResponse float64 `json:"avgResponse"` // 平均响应时间(ms) + MaxResponse int64 `json:"maxResponse"` // 最大响应时间(ms) + MinResponse int64 `json:"minResponse"` // 最小响应时间(ms) + SuccessCount int64 `json:"successCount"` // 成功次数 + TotalCount int64 `json:"totalCount"` // 总次数 + SuccessRate float64 `json:"successRate"` // 成功率(百分比) + LastStatus string `json:"lastStatus"` // 最后状态 + LastError string `json:"lastError"` // 最后错误信息 +} + +func (AggregatedMonitorMetricModel) TableName() string { + return "monitor_metrics_aggs" +} diff --git a/internal/migrate/v0_0_13/migrate.go b/internal/migrate/v0_0_13/migrate.go new file mode 100644 index 0000000..2982ff4 --- /dev/null +++ b/internal/migrate/v0_0_13/migrate.go @@ -0,0 +1,802 @@ +package models + +import ( + "context" + "fmt" + "log" + + "github.com/dushixiang/pika/internal/protocol" + "github.com/dushixiang/pika/internal/vmclient" + "gorm.io/gorm" +) + +const ( + // 每批处理的数据量 + batchSize = 1000 +) + +// Migrate 执行数据迁移:将 PostgreSQL 中的历史指标数据迁移到 VictoriaMetrics +func Migrate(db *gorm.DB, client *vmclient.VMClient) error { + ctx := context.Background() + log.Println("开始迁移历史指标数据到 VictoriaMetrics...") + + // 迁移各类型的指标数据 + migrationTasks := []struct { + name string + tableName string + migrateFunc func(context.Context, *gorm.DB, *vmclient.VMClient) error + }{ + {"CPU指标", "cpu_metrics", migrateCPUMetrics}, + {"内存指标", "memory_metrics", migrateMemoryMetrics}, + {"磁盘指标", "disk_metrics", migrateDiskMetrics}, + {"网络指标", "network_metrics", migrateNetworkMetrics}, + {"网络连接指标", "network_connection_metrics", migrateNetworkConnectionMetrics}, + {"磁盘IO指标", "disk_io_metrics", migrateDiskIOMetrics}, + {"GPU指标", "gpu_metrics", migrateGPUMetrics}, + {"温度指标", "temperature_metrics", migrateTemperatureMetrics}, + {"监控指标", "monitor_metrics", migrateMonitorMetrics}, + } + + var errors []string + for _, task := range migrationTasks { + log.Printf("开始迁移 %s...", task.name) + if err := task.migrateFunc(ctx, db, client); err != nil { + errMsg := fmt.Sprintf("迁移 %s 失败: %v", task.name, err) + log.Println(errMsg) + errors = append(errors, errMsg) + continue // 继续迁移其他类型的数据 + } + log.Printf("✓ %s 迁移完成", task.name) + } + + if len(errors) > 0 { + log.Printf("迁移过程中遇到 %d 个错误,但已尽可能完成迁移", len(errors)) + return fmt.Errorf("迁移过程中遇到 %d 个错误", len(errors)) + } + + log.Println("✓ 数据迁移完成!") + return nil +} + +// migrateCPUMetrics 迁移CPU指标 +func migrateCPUMetrics(ctx context.Context, db *gorm.DB, client *vmclient.VMClient) error { + var totalCount int64 + if err := db.Model(&CPUMetric{}).Count(&totalCount).Error; err != nil { + return err + } + + if totalCount == 0 { + log.Println(" 没有CPU数据需要迁移") + return nil + } + + log.Printf(" 找到 %d 条CPU记录", totalCount) + + offset := 0 + migratedCount := 0 + + for { + var metrics []CPUMetric + if err := db.Offset(offset).Limit(batchSize).Order("timestamp ASC").Find(&metrics).Error; err != nil { + return err + } + + if len(metrics) == 0 { + break + } + + var vmMetrics []vmclient.Metric + for _, m := range metrics { + cpuData := &protocol.CPUData{ + UsagePercent: m.UsagePercent, + LogicalCores: m.LogicalCores, + PhysicalCores: m.PhysicalCores, + ModelName: m.ModelName, + } + + converted := convertToMetrics(m.AgentID, string(protocol.MetricTypeCPU), cpuData, m.Timestamp) + vmMetrics = append(vmMetrics, converted...) + } + + // 批量写入 VictoriaMetrics + if err := client.Write(ctx, vmMetrics); err != nil { + log.Printf(" 警告: 写入CPU数据失败 (offset=%d): %v", offset, err) + } + + migratedCount += len(metrics) + offset += batchSize + + // 打印进度 + if migratedCount%10000 == 0 || migratedCount == int(totalCount) { + log.Printf(" 进度: %d/%d (%.1f%%)", migratedCount, totalCount, float64(migratedCount)/float64(totalCount)*100) + } + } + + return nil +} + +// migrateMemoryMetrics 迁移内存指标 +func migrateMemoryMetrics(ctx context.Context, db *gorm.DB, client *vmclient.VMClient) error { + var totalCount int64 + if err := db.Model(&MemoryMetric{}).Count(&totalCount).Error; err != nil { + return err + } + + if totalCount == 0 { + log.Println(" 没有内存数据需要迁移") + return nil + } + + log.Printf(" 找到 %d 条内存记录", totalCount) + + offset := 0 + migratedCount := 0 + + for { + var metrics []MemoryMetric + if err := db.Offset(offset).Limit(batchSize).Order("timestamp ASC").Find(&metrics).Error; err != nil { + return err + } + + if len(metrics) == 0 { + break + } + + var vmMetrics []vmclient.Metric + for _, m := range metrics { + memData := &protocol.MemoryData{ + Total: m.Total, + Used: m.Used, + Free: m.Free, + Available: m.Available, + UsagePercent: m.UsagePercent, + SwapTotal: m.SwapTotal, + SwapUsed: m.SwapUsed, + SwapFree: m.SwapFree, + } + + converted := convertToMetrics(m.AgentID, string(protocol.MetricTypeMemory), memData, m.Timestamp) + vmMetrics = append(vmMetrics, converted...) + } + + if err := client.Write(ctx, vmMetrics); err != nil { + log.Printf(" 警告: 写入内存数据失败 (offset=%d): %v", offset, err) + } + + migratedCount += len(metrics) + offset += batchSize + + if migratedCount%10000 == 0 || migratedCount == int(totalCount) { + log.Printf(" 进度: %d/%d (%.1f%%)", migratedCount, totalCount, float64(migratedCount)/float64(totalCount)*100) + } + } + + return nil +} + +// migrateDiskMetrics 迁移磁盘指标 +func migrateDiskMetrics(ctx context.Context, db *gorm.DB, client *vmclient.VMClient) error { + var totalCount int64 + if err := db.Model(&DiskMetric{}).Count(&totalCount).Error; err != nil { + return err + } + + if totalCount == 0 { + log.Println(" 没有磁盘数据需要迁移") + return nil + } + + log.Printf(" 找到 %d 条磁盘记录", totalCount) + + offset := 0 + migratedCount := 0 + + for { + var metrics []DiskMetric + if err := db.Offset(offset).Limit(batchSize).Order("timestamp ASC").Find(&metrics).Error; err != nil { + return err + } + + if len(metrics) == 0 { + break + } + + // 按 agent_id + timestamp 分组 + groupedMetrics := make(map[string][]protocol.DiskData) + for _, m := range metrics { + key := fmt.Sprintf("%s_%d", m.AgentID, m.Timestamp) + groupedMetrics[key] = append(groupedMetrics[key], protocol.DiskData{ + MountPoint: m.MountPoint, + Total: m.Total, + Used: m.Used, + Free: m.Free, + UsagePercent: m.UsagePercent, + }) + } + + var vmMetrics []vmclient.Metric + for key, diskDataList := range groupedMetrics { + // 从 key 中提取 agentID 和 timestamp + var agentID string + var timestamp int64 + fmt.Sscanf(key, "%s_%d", &agentID, ×tamp) + + converted := convertToMetrics(agentID, string(protocol.MetricTypeDisk), diskDataList, timestamp) + vmMetrics = append(vmMetrics, converted...) + } + + if err := client.Write(ctx, vmMetrics); err != nil { + log.Printf(" 警告: 写入磁盘数据失败 (offset=%d): %v", offset, err) + } + + migratedCount += len(metrics) + offset += batchSize + + if migratedCount%10000 == 0 || migratedCount == int(totalCount) { + log.Printf(" 进度: %d/%d (%.1f%%)", migratedCount, totalCount, float64(migratedCount)/float64(totalCount)*100) + } + } + + return nil +} + +// migrateNetworkMetrics 迁移网络指标 +func migrateNetworkMetrics(ctx context.Context, db *gorm.DB, client *vmclient.VMClient) error { + var totalCount int64 + if err := db.Model(&NetworkMetric{}).Count(&totalCount).Error; err != nil { + return err + } + + if totalCount == 0 { + log.Println(" 没有网络数据需要迁移") + return nil + } + + log.Printf(" 找到 %d 条网络记录", totalCount) + + offset := 0 + migratedCount := 0 + + for { + var metrics []NetworkMetric + if err := db.Offset(offset).Limit(batchSize).Order("timestamp ASC").Find(&metrics).Error; err != nil { + return err + } + + if len(metrics) == 0 { + break + } + + // 按 agent_id + timestamp 分组 + groupedMetrics := make(map[string][]protocol.NetworkData) + for _, m := range metrics { + key := fmt.Sprintf("%s_%d", m.AgentID, m.Timestamp) + groupedMetrics[key] = append(groupedMetrics[key], protocol.NetworkData{ + Interface: m.Interface, + BytesSentRate: m.BytesSentRate, + BytesRecvRate: m.BytesRecvRate, + BytesSentTotal: m.BytesSentTotal, + BytesRecvTotal: m.BytesRecvTotal, + }) + } + + var vmMetrics []vmclient.Metric + for key, netDataList := range groupedMetrics { + var agentID string + var timestamp int64 + fmt.Sscanf(key, "%s_%d", &agentID, ×tamp) + + converted := convertToMetrics(agentID, string(protocol.MetricTypeNetwork), netDataList, timestamp) + vmMetrics = append(vmMetrics, converted...) + } + + if err := client.Write(ctx, vmMetrics); err != nil { + log.Printf(" 警告: 写入网络数据失败 (offset=%d): %v", offset, err) + } + + migratedCount += len(metrics) + offset += batchSize + + if migratedCount%10000 == 0 || migratedCount == int(totalCount) { + log.Printf(" 进度: %d/%d (%.1f%%)", migratedCount, totalCount, float64(migratedCount)/float64(totalCount)*100) + } + } + + return nil +} + +// migrateNetworkConnectionMetrics 迁移网络连接指标 +func migrateNetworkConnectionMetrics(ctx context.Context, db *gorm.DB, client *vmclient.VMClient) error { + var totalCount int64 + if err := db.Model(&NetworkConnectionMetric{}).Count(&totalCount).Error; err != nil { + return err + } + + if totalCount == 0 { + log.Println(" 没有网络连接数据需要迁移") + return nil + } + + log.Printf(" 找到 %d 条网络连接记录", totalCount) + + offset := 0 + migratedCount := 0 + + for { + var metrics []NetworkConnectionMetric + if err := db.Offset(offset).Limit(batchSize).Order("timestamp ASC").Find(&metrics).Error; err != nil { + return err + } + + if len(metrics) == 0 { + break + } + + var vmMetrics []vmclient.Metric + for _, m := range metrics { + connData := &protocol.NetworkConnectionData{ + Established: m.Established, + SynSent: m.SynSent, + SynRecv: m.SynRecv, + TimeWait: m.TimeWait, + CloseWait: m.CloseWait, + Total: m.Total, + } + + converted := convertToMetrics(m.AgentID, string(protocol.MetricTypeNetworkConnection), connData, m.Timestamp) + vmMetrics = append(vmMetrics, converted...) + } + + if err := client.Write(ctx, vmMetrics); err != nil { + log.Printf(" 警告: 写入网络连接数据失败 (offset=%d): %v", offset, err) + } + + migratedCount += len(metrics) + offset += batchSize + + if migratedCount%10000 == 0 || migratedCount == int(totalCount) { + log.Printf(" 进度: %d/%d (%.1f%%)", migratedCount, totalCount, float64(migratedCount)/float64(totalCount)*100) + } + } + + return nil +} + +// migrateDiskIOMetrics 迁移磁盘IO指标 +func migrateDiskIOMetrics(ctx context.Context, db *gorm.DB, client *vmclient.VMClient) error { + var totalCount int64 + if err := db.Model(&DiskIOMetric{}).Count(&totalCount).Error; err != nil { + return err + } + + if totalCount == 0 { + log.Println(" 没有磁盘IO数据需要迁移") + return nil + } + + log.Printf(" 找到 %d 条磁盘IO记录", totalCount) + + offset := 0 + migratedCount := 0 + + for { + var metrics []DiskIOMetric + if err := db.Offset(offset).Limit(batchSize).Order("timestamp ASC").Find(&metrics).Error; err != nil { + return err + } + + if len(metrics) == 0 { + break + } + + // 按 agent_id + timestamp 分组 + groupedMetrics := make(map[string][]*protocol.DiskIOData) + for _, m := range metrics { + key := fmt.Sprintf("%s_%d", m.AgentID, m.Timestamp) + groupedMetrics[key] = append(groupedMetrics[key], &protocol.DiskIOData{ + Device: m.Device, + ReadCount: m.ReadCount, + WriteCount: m.WriteCount, + ReadBytes: m.ReadBytes, + WriteBytes: m.WriteBytes, + ReadBytesRate: m.ReadBytesRate, + WriteBytesRate: m.WriteBytesRate, + ReadTime: m.ReadTime, + WriteTime: m.WriteTime, + IoTime: m.IoTime, + IopsInProgress: m.IopsInProgress, + }) + } + + var vmMetrics []vmclient.Metric + for key, diskIODataList := range groupedMetrics { + var agentID string + var timestamp int64 + fmt.Sscanf(key, "%s_%d", &agentID, ×tamp) + + converted := convertToMetrics(agentID, string(protocol.MetricTypeDiskIO), diskIODataList, timestamp) + vmMetrics = append(vmMetrics, converted...) + } + + if err := client.Write(ctx, vmMetrics); err != nil { + log.Printf(" 警告: 写入磁盘IO数据失败 (offset=%d): %v", offset, err) + } + + migratedCount += len(metrics) + offset += batchSize + + if migratedCount%10000 == 0 || migratedCount == int(totalCount) { + log.Printf(" 进度: %d/%d (%.1f%%)", migratedCount, totalCount, float64(migratedCount)/float64(totalCount)*100) + } + } + + return nil +} + +// migrateGPUMetrics 迁移GPU指标 +func migrateGPUMetrics(ctx context.Context, db *gorm.DB, client *vmclient.VMClient) error { + var totalCount int64 + if err := db.Model(&GPUMetric{}).Count(&totalCount).Error; err != nil { + return err + } + + if totalCount == 0 { + log.Println(" 没有GPU数据需要迁移") + return nil + } + + log.Printf(" 找到 %d 条GPU记录", totalCount) + + offset := 0 + migratedCount := 0 + + for { + var metrics []GPUMetric + if err := db.Offset(offset).Limit(batchSize).Order("timestamp ASC").Find(&metrics).Error; err != nil { + return err + } + + if len(metrics) == 0 { + break + } + + // 按 agent_id + timestamp 分组 + groupedMetrics := make(map[string][]protocol.GPUData) + for _, m := range metrics { + key := fmt.Sprintf("%s_%d", m.AgentID, m.Timestamp) + groupedMetrics[key] = append(groupedMetrics[key], protocol.GPUData{ + Index: m.Index, + Name: m.Name, + Utilization: m.Utilization, + MemoryTotal: m.MemoryTotal, + MemoryUsed: m.MemoryUsed, + MemoryFree: m.MemoryFree, + Temperature: m.Temperature, + PowerUsage: m.PowerDraw, + FanSpeed: m.FanSpeed, + }) + } + + var vmMetrics []vmclient.Metric + for key, gpuDataList := range groupedMetrics { + var agentID string + var timestamp int64 + fmt.Sscanf(key, "%s_%d", &agentID, ×tamp) + + converted := convertToMetrics(agentID, string(protocol.MetricTypeGPU), gpuDataList, timestamp) + vmMetrics = append(vmMetrics, converted...) + } + + if err := client.Write(ctx, vmMetrics); err != nil { + log.Printf(" 警告: 写入GPU数据失败 (offset=%d): %v", offset, err) + } + + migratedCount += len(metrics) + offset += batchSize + + if migratedCount%10000 == 0 || migratedCount == int(totalCount) { + log.Printf(" 进度: %d/%d (%.1f%%)", migratedCount, totalCount, float64(migratedCount)/float64(totalCount)*100) + } + } + + return nil +} + +// migrateTemperatureMetrics 迁移温度指标 +func migrateTemperatureMetrics(ctx context.Context, db *gorm.DB, client *vmclient.VMClient) error { + var totalCount int64 + if err := db.Model(&TemperatureMetric{}).Count(&totalCount).Error; err != nil { + return err + } + + if totalCount == 0 { + log.Println(" 没有温度数据需要迁移") + return nil + } + + log.Printf(" 找到 %d 条温度记录", totalCount) + + offset := 0 + migratedCount := 0 + + for { + var metrics []TemperatureMetric + if err := db.Offset(offset).Limit(batchSize).Order("timestamp ASC").Find(&metrics).Error; err != nil { + return err + } + + if len(metrics) == 0 { + break + } + + // 按 agent_id + timestamp 分组 + groupedMetrics := make(map[string][]protocol.TemperatureData) + for _, m := range metrics { + key := fmt.Sprintf("%s_%d", m.AgentID, m.Timestamp) + groupedMetrics[key] = append(groupedMetrics[key], protocol.TemperatureData{ + SensorKey: m.SensorKey, + Temperature: m.Temperature, + Type: m.SensorLabel, + }) + } + + var vmMetrics []vmclient.Metric + for key, tempDataList := range groupedMetrics { + var agentID string + var timestamp int64 + fmt.Sscanf(key, "%s_%d", &agentID, ×tamp) + + converted := convertToMetrics(agentID, string(protocol.MetricTypeTemperature), tempDataList, timestamp) + vmMetrics = append(vmMetrics, converted...) + } + + if err := client.Write(ctx, vmMetrics); err != nil { + log.Printf(" 警告: 写入温度数据失败 (offset=%d): %v", offset, err) + } + + migratedCount += len(metrics) + offset += batchSize + + if migratedCount%10000 == 0 || migratedCount == int(totalCount) { + log.Printf(" 进度: %d/%d (%.1f%%)", migratedCount, totalCount, float64(migratedCount)/float64(totalCount)*100) + } + } + + return nil +} + +// migrateMonitorMetrics 迁移监控指标 +func migrateMonitorMetrics(ctx context.Context, db *gorm.DB, client *vmclient.VMClient) error { + var totalCount int64 + if err := db.Model(&MonitorMetric{}).Count(&totalCount).Error; err != nil { + return err + } + + if totalCount == 0 { + log.Println(" 没有监控数据需要迁移") + return nil + } + + log.Printf(" 找到 %d 条监控记录", totalCount) + + offset := 0 + migratedCount := 0 + + for { + var metrics []MonitorMetric + if err := db.Offset(offset).Limit(batchSize).Order("timestamp ASC").Find(&metrics).Error; err != nil { + return err + } + + if len(metrics) == 0 { + break + } + + // 按 agent_id + timestamp 分组 + groupedMetrics := make(map[string][]protocol.MonitorData) + for _, m := range metrics { + key := fmt.Sprintf("%s_%d", m.AgentId, m.Timestamp) + groupedMetrics[key] = append(groupedMetrics[key], protocol.MonitorData{ + AgentId: m.AgentId, + MonitorId: m.MonitorId, + Type: m.Type, + Target: m.Target, + Status: m.Status, + StatusCode: m.StatusCode, + ResponseTime: m.ResponseTime, + Error: m.Error, + Message: m.Message, + ContentMatch: m.ContentMatch, + CertExpiryTime: m.CertExpiryTime, + CertDaysLeft: m.CertDaysLeft, + CheckedAt: m.Timestamp, // 使用 timestamp 作为 CheckedAt + }) + } + + var vmMetrics []vmclient.Metric + for key, monitorDataList := range groupedMetrics { + var agentID string + var timestamp int64 + fmt.Sscanf(key, "%s_%d", &agentID, ×tamp) + + converted := convertToMetrics(agentID, string(protocol.MetricTypeMonitor), monitorDataList, timestamp) + vmMetrics = append(vmMetrics, converted...) + } + + if err := client.Write(ctx, vmMetrics); err != nil { + log.Printf(" 警告: 写入监控数据失败 (offset=%d): %v", offset, err) + } + + migratedCount += len(metrics) + offset += batchSize + + if migratedCount%10000 == 0 || migratedCount == int(totalCount) { + log.Printf(" 进度: %d/%d (%.1f%%)", migratedCount, totalCount, float64(migratedCount)/float64(totalCount)*100) + } + } + + return nil +} + +// DropOldTables 删除旧的指标数据表 +func DropOldTables(db *gorm.DB) error { + // 要删除的表列表(除了 host_metrics,它仍然需要保留) + tables := []string{ + "cpu_metrics", + "memory_metrics", + "disk_metrics", + "network_metrics", + "network_connection_metrics", + "disk_io_metrics", + "gpu_metrics", + "temperature_metrics", + "monitor_metrics", + // 聚合表也删除 + "cpu_metrics_aggs", + "memory_metrics_aggs", + "disk_metrics_aggs", + "network_metrics_aggs", + "network_connection_metrics_aggs", + "disk_io_metrics_aggs", + "gpu_metrics_aggs", + "temperature_metrics_aggs", + "monitor_metrics_aggs", + "aggregation_progress", + // 统计表 + "monitor_stats", + } + + for _, table := range tables { + // 检查表是否存在 + if !db.Migrator().HasTable(table) { + log.Printf(" 表 %s 不存在,跳过", table) + continue + } + + log.Printf(" 删除表: %s", table) + if err := db.Migrator().DropTable(table); err != nil { + log.Printf(" 警告: 删除表 %s 失败: %v", table, err) + continue + } + } + + return nil +} + +// convertToMetrics 将指标数据转换为 VictoriaMetrics Metric 对象 +// 这个函数复制自 internal/service/metric_converter.go,避免循环依赖 +func convertToMetrics(agentID string, metricType string, data interface{}, timestamp int64) []vmclient.Metric { + var metrics []vmclient.Metric + + switch protocol.MetricType(metricType) { + case protocol.MetricTypeCPU: + cpuData := data.(*protocol.CPUData) + metrics = append(metrics, createMetric("pika_cpu_usage_percent", agentID, nil, cpuData.UsagePercent, timestamp)) + metrics = append(metrics, createMetric("pika_cpu_cores_logical", agentID, nil, float64(cpuData.LogicalCores), timestamp)) + metrics = append(metrics, createMetric("pika_cpu_cores_physical", agentID, nil, float64(cpuData.PhysicalCores), timestamp)) + + case protocol.MetricTypeMemory: + memData := data.(*protocol.MemoryData) + metrics = append(metrics, createMetric("pika_memory_usage_percent", agentID, nil, memData.UsagePercent, timestamp)) + metrics = append(metrics, createMetric("pika_memory_total_bytes", agentID, nil, float64(memData.Total), timestamp)) + metrics = append(metrics, createMetric("pika_memory_used_bytes", agentID, nil, float64(memData.Used), timestamp)) + metrics = append(metrics, createMetric("pika_memory_available_bytes", agentID, nil, float64(memData.Available), timestamp)) + metrics = append(metrics, createMetric("pika_memory_swap_total_bytes", agentID, nil, float64(memData.SwapTotal), timestamp)) + metrics = append(metrics, createMetric("pika_memory_swap_used_bytes", agentID, nil, float64(memData.SwapUsed), timestamp)) + + case protocol.MetricTypeDisk: + diskDataList := data.([]protocol.DiskData) + for _, diskData := range diskDataList { + labels := map[string]string{"mount_point": diskData.MountPoint} + metrics = append(metrics, createMetric("pika_disk_usage_percent", agentID, labels, diskData.UsagePercent, timestamp)) + metrics = append(metrics, createMetric("pika_disk_total_bytes", agentID, labels, float64(diskData.Total), timestamp)) + metrics = append(metrics, createMetric("pika_disk_used_bytes", agentID, labels, float64(diskData.Used), timestamp)) + metrics = append(metrics, createMetric("pika_disk_free_bytes", agentID, labels, float64(diskData.Free), timestamp)) + } + + case protocol.MetricTypeNetwork: + networkDataList := data.([]protocol.NetworkData) + for _, netData := range networkDataList { + labels := map[string]string{"interface": netData.Interface} + metrics = append(metrics, createMetric("pika_network_sent_bytes_rate", agentID, labels, float64(netData.BytesSentRate), timestamp)) + metrics = append(metrics, createMetric("pika_network_recv_bytes_rate", agentID, labels, float64(netData.BytesRecvRate), timestamp)) + metrics = append(metrics, createMetric("pika_network_sent_bytes_total", agentID, labels, float64(netData.BytesSentTotal), timestamp)) + metrics = append(metrics, createMetric("pika_network_recv_bytes_total", agentID, labels, float64(netData.BytesRecvTotal), timestamp)) + } + + case protocol.MetricTypeNetworkConnection: + connData := data.(*protocol.NetworkConnectionData) + metrics = append(metrics, createMetric("pika_network_conn_established", agentID, nil, float64(connData.Established), timestamp)) + metrics = append(metrics, createMetric("pika_network_conn_syn_sent", agentID, nil, float64(connData.SynSent), timestamp)) + metrics = append(metrics, createMetric("pika_network_conn_syn_recv", agentID, nil, float64(connData.SynRecv), timestamp)) + metrics = append(metrics, createMetric("pika_network_conn_time_wait", agentID, nil, float64(connData.TimeWait), timestamp)) + metrics = append(metrics, createMetric("pika_network_conn_close_wait", agentID, nil, float64(connData.CloseWait), timestamp)) + metrics = append(metrics, createMetric("pika_network_conn_total", agentID, nil, float64(connData.Total), timestamp)) + + case protocol.MetricTypeDiskIO: + diskIODataList := data.([]*protocol.DiskIOData) + // 汇总所有磁盘的 IO + var totalReadRate, totalWriteRate uint64 + for _, diskIOData := range diskIODataList { + totalReadRate += diskIOData.ReadBytesRate + totalWriteRate += diskIOData.WriteBytesRate + } + metrics = append(metrics, createMetric("pika_disk_read_bytes_rate", agentID, nil, float64(totalReadRate), timestamp)) + metrics = append(metrics, createMetric("pika_disk_write_bytes_rate", agentID, nil, float64(totalWriteRate), timestamp)) + + case protocol.MetricTypeGPU: + gpuDataList := data.([]protocol.GPUData) + for _, gpuData := range gpuDataList { + labels := map[string]string{ + "gpu_index": fmt.Sprintf("%d", gpuData.Index), + "gpu_name": gpuData.Name, + } + metrics = append(metrics, createMetric("pika_gpu_utilization_percent", agentID, labels, gpuData.Utilization, timestamp)) + metrics = append(metrics, createMetric("pika_gpu_memory_total_bytes", agentID, labels, float64(gpuData.MemoryTotal), timestamp)) + metrics = append(metrics, createMetric("pika_gpu_memory_used_bytes", agentID, labels, float64(gpuData.MemoryUsed), timestamp)) + metrics = append(metrics, createMetric("pika_gpu_temperature_celsius", agentID, labels, gpuData.Temperature, timestamp)) + metrics = append(metrics, createMetric("pika_gpu_power_draw_watts", agentID, labels, gpuData.PowerUsage, timestamp)) + } + + case protocol.MetricTypeTemperature: + tempDataList := data.([]protocol.TemperatureData) + for _, tempData := range tempDataList { + labels := map[string]string{ + "sensor_label": tempData.Type, + } + metrics = append(metrics, createMetric("pika_temperature_celsius", agentID, labels, tempData.Temperature, timestamp)) + } + + case protocol.MetricTypeMonitor: + monitorDataList := data.([]protocol.MonitorData) + for _, monitorData := range monitorDataList { + labels := map[string]string{ + "monitor_id": monitorData.MonitorId, + "monitor_type": monitorData.Type, + "target": monitorData.Target, + } + metrics = append(metrics, createMetric("pika_monitor_response_time_ms", agentID, labels, float64(monitorData.ResponseTime), timestamp)) + } + } + + return metrics +} + +// createMetric 创建 VictoriaMetrics Metric 对象 +func createMetric(metricName, agentID string, extraLabels map[string]string, value float64, timestamp int64) vmclient.Metric { + labels := make(map[string]string) + labels["__name__"] = metricName + labels["agent_id"] = agentID + + if extraLabels != nil { + for k, v := range extraLabels { + labels[k] = v + } + } + + return vmclient.Metric{ + Metric: labels, + Values: []float64{value}, + Timestamps: []int64{timestamp}, + } +} diff --git a/internal/protocol/message.go b/internal/protocol/message.go index a292d02..dadf299 100644 --- a/internal/protocol/message.go +++ b/internal/protocol/message.go @@ -289,6 +289,7 @@ type Evidence struct { // MonitorData 监控数据 type MonitorData struct { AgentId string `json:"agentId"` // 探针 ID + AgentName string `json:"agentName"` // 探针名称 MonitorId string `json:"monitorId"` // 监控项ID Type string `json:"type"` // 监控类型: http, tcp Target string `json:"target,omitempty"` // 监控目标 diff --git a/internal/service/metric_service.go b/internal/service/metric_service.go index ce4a7b4..3ec6216 100644 --- a/internal/service/metric_service.go +++ b/internal/service/metric_service.go @@ -22,6 +22,7 @@ import ( type MetricService struct { logger *zap.Logger metricRepo *repo.MetricRepo + agentRepo *repo.AgentRepo propertyService *PropertyService trafficService *TrafficService // 流量统计服务 vmClient *vmclient.VMClient @@ -36,6 +37,7 @@ func NewMetricService(logger *zap.Logger, db *gorm.DB, propertyService *Property return &MetricService{ logger: logger, metricRepo: repo.NewMetricRepo(db), + agentRepo: repo.NewAgentRepo(db), propertyService: propertyService, trafficService: trafficService, vmClient: vmClient, @@ -529,6 +531,42 @@ func (s *MetricService) GetMonitorHistory(ctx context.Context, monitorID string, series = append(series, convertedSeries...) } + // 收集所有 agent_id + agentIdSet := make(map[string]struct{}) + for _, s := range series { + if agentId, ok := s.Labels["agent_id"]; ok { + agentIdSet[agentId] = struct{}{} + } + } + + // 查询 agent 信息 + if len(agentIdSet) > 0 { + agentIds := make([]string, 0, len(agentIdSet)) + for agentId := range agentIdSet { + agentIds = append(agentIds, agentId) + } + + agents, err := s.agentRepo.FindByIdIn(ctx, agentIds) + if err != nil { + s.logger.Error("查询 agent 信息失败", zap.Error(err)) + } else { + // 构建 agentId -> agentName 映射 + agentNameMap := make(map[string]string) + for _, agent := range agents { + agentNameMap[agent.ID] = agent.Name + } + + // 在每个 series 的 labels 中添加 agent_name + for i := range series { + if agentId, ok := series[i].Labels["agent_id"]; ok { + if agentName, exists := agentNameMap[agentId]; exists { + series[i].Labels["agent_name"] = agentName + } + } + } + } + } + return &metric.GetMetricsResponse{ AgentID: "", // 监控查询不限定单个agent Type: "monitor", @@ -546,10 +584,30 @@ func (s *MetricService) GetMonitorAgentStats(monitorID string) []protocol.Monito return []protocol.MonitorData{} } - // 转换为数组 + // 收集所有 agentId + agentIds := make([]string, 0, len(latestMetrics.Agents)) + for agentId := range latestMetrics.Agents { + agentIds = append(agentIds, agentId) + } + + // 查询 agent 信息 + ctx := context.Background() + agents, err := s.agentRepo.FindByIdIn(ctx, agentIds) + if err != nil { + s.logger.Error("查询 agent 信息失败", zap.Error(err)) + } + + // 构建 agentId -> agentName 映射 + agentNameMap := make(map[string]string) + for _, agent := range agents { + agentNameMap[agent.ID] = agent.Name + } + + // 转换为数组并填充 agent 名称 result := make([]protocol.MonitorData, 0, len(latestMetrics.Agents)) - for _, stat := range latestMetrics.Agents { - stat.Target = "" + for agentId, stat := range latestMetrics.Agents { + stat.Target = "" // 隐藏目标地址 + stat.AgentName = agentNameMap[agentId] // 填充 agent 名称 result = append(result, *stat) } diff --git a/web/src/components/monitor/AgentStatsTable.tsx b/web/src/components/monitor/AgentStatsTable.tsx new file mode 100644 index 0000000..b19b1f6 --- /dev/null +++ b/web/src/components/monitor/AgentStatsTable.tsx @@ -0,0 +1,140 @@ +import {AlertCircle, Clock, MapPin} from 'lucide-react'; +import {StatusBadge} from './StatusBadge'; +import {CertBadge} from './CertBadge'; +import {AGENT_COLORS} from '@/constants/colors'; +import {formatDateTime, formatTime} from '@/utils/util'; +import type {AgentMonitorStat} from '@/types'; + +interface AgentStatsTableProps { + monitorStats: AgentMonitorStat[]; + monitorType: string; +} + +/** + * 探针监控统计表格组件 + * 显示各探针的当前状态和统计数据 + */ +export const AgentStatsTable = ({monitorStats, monitorType}: AgentStatsTableProps) => { + if (monitorStats.length === 0) { + return ( +
+

暂无探针数据

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

探针监控详情

+

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

+
+ +
+ + + + + + + + {monitorType === 'https' && ( + + )} + + + + + {monitorStats.map((stat, index) => { + const color = AGENT_COLORS[index % AGENT_COLORS.length]; + return ( + + + + + + {monitorType === 'https' && ( + + )} + + + ); + })} + +
+ 探针名称 + + 状态 + + 响应时间 + + 最后检测 + + 证书信息 + + 错误信息 +
+
+ +
+ + + {stat.agentName || stat.agentId.substring(0, 8)} + +
+
+
+ + +
+ + + {formatTime(stat.responseTime)} + +
+
+ {formatDateTime(stat.checkedAt)} + + {stat.certExpiryTime ? ( + + ) : ( + - + )} + + {stat.status === 'down' && stat.message ? ( +
+ + + {stat.message} + +
+ ) : ( + - + )} +
+
+
+
+ ); +}; diff --git a/web/src/components/monitor/MonitorHero.tsx b/web/src/components/monitor/MonitorHero.tsx new file mode 100644 index 0000000..9f4ada4 --- /dev/null +++ b/web/src/components/monitor/MonitorHero.tsx @@ -0,0 +1,98 @@ +import {ArrowLeft} from 'lucide-react'; +import {TypeIcon} from './TypeIcon'; +import {StatusBadge} from './StatusBadge'; +import {CertBadge} from './CertBadge'; +import LittleStatCard from '@/components/common/LittleStatCard'; +import type {PublicMonitor} from '@/types'; + +interface MonitorHeroProps { + monitor: PublicMonitor; + onBack: () => void; +} + +/** + * 监控详情头部组件 + * 显示监控基本信息、状态和关键指标 + */ +export const MonitorHero = ({monitor, onBack}: MonitorHeroProps) => { + return ( +
+ {/* 装饰性边框 */} +
+
+
+
+
+ +
+ {/* 返回按钮 */} + + + {/* 监控信息 */} +
+
+
+ +
+
+
+ MONITOR_ID: {monitor.id.toString().substring(0, 8)} +
+
+

{monitor.name}

+ +
+

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

+
+
+ + {/* 统计卡片 */} +
+ + + + +
+
+ + {/* 证书信息(如果是 HTTPS)*/} + {monitor.type === 'https' && monitor.certExpiryTime && ( +
+ SSL 证书: + +
+ )} +
+
+ ); +}; diff --git a/web/src/components/monitor/ResponseTimeChart.tsx b/web/src/components/monitor/ResponseTimeChart.tsx new file mode 100644 index 0000000..9f92f0f --- /dev/null +++ b/web/src/components/monitor/ResponseTimeChart.tsx @@ -0,0 +1,283 @@ +import {useEffect, useMemo, useState} from 'react'; +import {useQuery} from '@tanstack/react-query'; +import {Shield} from 'lucide-react'; +import {Area, AreaChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis} from 'recharts'; +import {type GetMetricsResponse, getMonitorHistory} from '@/api/monitor'; +import {AGENT_COLORS} from '@/constants/colors'; +import {MONITOR_TIME_RANGE_OPTIONS} from '@/constants/time'; +import {cn} from '@/lib/utils'; +import type {AgentMonitorStat} from '@/types'; + +interface ResponseTimeChartProps { + monitorId: string; + monitorStats: AgentMonitorStat[]; +} + +// 自定义 Tooltip 组件 +const CustomTooltip = ({active, payload, label}: any) => { + if (!active || !payload || payload.length === 0) return null; + + return ( +
+

{label}

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

{subtitle}

+
+
+); + +// 时间范围选择器组件 +const TimeRangeSelector = ({ + value, + onChange, + options + }: { + value: string; + onChange: (value: string) => void; + options: Array<{ value: string; label: string }>; +}) => ( +
+ {options.map(option => ( + + ))} +
+); + +/** + * 响应时间趋势图表组件 + * 显示监控各探针的响应时间变化 + */ +export const ResponseTimeChart = ({monitorId, monitorStats}: ResponseTimeChartProps) => { + const [selectedAgent, setSelectedAgent] = useState('all'); + const [timeRange, setTimeRange] = useState('1d'); + + // 获取历史数据 + const {data: historyData} = useQuery({ + queryKey: ['monitorHistory', monitorId, timeRange], + queryFn: async () => { + if (!monitorId) throw new Error('Monitor ID is required'); + const response = await getMonitorHistory(monitorId, timeRange); + return response.data; + }, + refetchInterval: 30000, + enabled: !!monitorId, + }); + + // 获取所有可用的探针列表 + const availableAgents = useMemo(() => { + if (monitorStats.length === 0) return []; + return monitorStats.map(stat => ({ + id: stat.agentId, + name: stat.agentName || stat.agentId.substring(0, 8), + })); + }, [monitorStats]); + + // 当可用探针列表变化时,检查当前选择的探针是否还存在 + useEffect(() => { + if (selectedAgent === 'all') return; + if (!availableAgents.find(agent => agent.id === selectedAgent)) { + setSelectedAgent('all'); + } + }, [availableAgents, selectedAgent]); + + // 生成图表数据 + const chartData = useMemo(() => { + if (!historyData?.series) return []; + + // 过滤出响应时间指标的 series + const responseTimeSeries = historyData.series.filter(s => s.name === 'response_time'); + + // 根据选择的探针过滤(使用 agent_name,如果没有则fallback到 agent_id) + const filteredSeries = selectedAgent === 'all' + ? responseTimeSeries + : responseTimeSeries.filter(s => { + // 优先使用 agent_name,如果没有则使用 agent_id + const agentIdentifier = s.labels?.agent_name || s.labels?.agent_id; + return agentIdentifier === selectedAgent; + }); + + if (filteredSeries.length === 0) return []; + + // 按时间戳分组数据 + const grouped: Record = {}; + + filteredSeries.forEach(series => { + // 优先使用 agent_name,如果没有则使用 agent_id + const agentIdentifier = series.labels?.agent_name || series.labels?.agent_id || 'unknown'; + const agentKey = `agent_${agentIdentifier}`; + + series.data.forEach(point => { + if (!grouped[point.timestamp]) { + grouped[point.timestamp] = { + time: new Date(point.timestamp).toLocaleTimeString('zh-CN', { + hour: '2-digit', + minute: '2-digit', + }), + timestamp: point.timestamp, + }; + } + grouped[point.timestamp][agentKey] = point.value; + }); + }); + + // 按时间戳排序 + return Object.values(grouped).sort((a, b) => a.timestamp - b.timestamp); + }, [historyData, selectedAgent]); + + return ( +
+ {/* 装饰性边框 */} +
+
+
+
+ +
+
+
+

响应时间趋势

+

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

+
+
+ + {availableAgents.length > 0 && ( + + )} +
+
+ + {chartData.length > 0 ? ( + + + + {monitorStats + .filter(stat => { + const agentIdentifier = stat.agentName || stat.agentId; + return selectedAgent === 'all' || agentIdentifier === selectedAgent; + }) + .map((stat) => { + const originalIndex = monitorStats.findIndex(s => s.agentId === stat.agentId); + const agentIdentifier = stat.agentName || stat.agentId; + const agentKey = `agent_${agentIdentifier}`; + const color = AGENT_COLORS[originalIndex % AGENT_COLORS.length]; + return ( + + + + + ); + })} + + + + `${value}ms`} + /> + }/> + + {monitorStats + .filter(stat => { + const agentIdentifier = stat.agentName || stat.agentId; + return selectedAgent === 'all' || agentIdentifier === selectedAgent; + }) + .map((stat) => { + const originalIndex = monitorStats.findIndex(s => s.agentId === stat.agentId); + const agentIdentifier = stat.agentName || stat.agentId; + const agentKey = `agent_${agentIdentifier}`; + const color = AGENT_COLORS[originalIndex % AGENT_COLORS.length]; + const agentLabel = stat.agentName || stat.agentId.substring(0, 8); + return ( + + ); + })} + + + ) : ( + + )} +
+
+ ); +}; diff --git a/web/src/components/monitor/index.ts b/web/src/components/monitor/index.ts new file mode 100644 index 0000000..8401b60 --- /dev/null +++ b/web/src/components/monitor/index.ts @@ -0,0 +1,7 @@ +export {CertBadge} from './CertBadge'; +export {MiniChart} from './MiniChart'; +export {StatusBadge} from './StatusBadge'; +export {TypeIcon} from './TypeIcon'; +export {MonitorHero} from './MonitorHero'; +export {ResponseTimeChart} from './ResponseTimeChart'; +export {AgentStatsTable} from './AgentStatsTable'; diff --git a/web/src/components/server/GpuMonitorSection.tsx b/web/src/components/server/GpuMonitorSection.tsx new file mode 100644 index 0000000..cce578a --- /dev/null +++ b/web/src/components/server/GpuMonitorSection.tsx @@ -0,0 +1,68 @@ +import {Zap} from 'lucide-react'; +import {Card} from '@/components/common'; +import {formatBytes} from '@/utils/util'; +import type {LatestMetrics} from '@/types'; + +interface GpuMonitorSectionProps { + latestMetrics: LatestMetrics | null; +} + +/** + * GPU 监控区块组件 + * 显示 GPU 使用情况、温度、显存和功耗信息 + */ +export const GpuMonitorSection = ({latestMetrics}: GpuMonitorSectionProps) => { + // 如果没有 GPU 数据,不渲染组件 + if (!latestMetrics?.gpu || latestMetrics.gpu.length === 0) { + return null; + } + + return ( + +
+ {latestMetrics.gpu.map((gpu) => ( +
+
+
+ + + +
+

GPU {gpu.index}

+

{gpu.name}

+
+
+ + {gpu.utilization.toFixed(1)}% + +
+
+
+ 温度 + {gpu.temperature.toFixed(1)}°C +
+
+ 显存 + + {formatBytes(gpu.memoryUsed)} / {formatBytes(gpu.memoryTotal)} + +
+
+ 功耗 + {gpu.powerDraw.toFixed(1)}W +
+
+ 风扇转速 + {gpu.fanSpeed.toFixed(0)}% +
+
+
+ ))} +
+
+ ); +}; diff --git a/web/src/components/server/NetworkConnectionSection.tsx b/web/src/components/server/NetworkConnectionSection.tsx new file mode 100644 index 0000000..7b60092 --- /dev/null +++ b/web/src/components/server/NetworkConnectionSection.tsx @@ -0,0 +1,61 @@ +import {Network} from 'lucide-react'; +import {Card} from '@/components/common'; +import type {LatestMetrics} from '@/types'; + +interface NetworkConnectionSectionProps { + latestMetrics: LatestMetrics | null; +} + +/** + * 网络连接统计区块组件 + * 显示 TCP 连接各状态的实时统计数据 + */ +export const NetworkConnectionSection = ({latestMetrics}: NetworkConnectionSectionProps) => { + // 如果没有网络连接数据,不渲染组件 + if (!latestMetrics?.networkConnection) { + return null; + } + + return ( + +
+
+
Total
+
{latestMetrics.networkConnection.total}
+
+
+
ESTABLISHED
+
{latestMetrics.networkConnection.established}
+
+
+
TIME_WAIT
+
{latestMetrics.networkConnection.timeWait}
+
+
+
LISTEN
+
{latestMetrics.networkConnection.listen}
+
+
+
CLOSE_WAIT
+
{latestMetrics.networkConnection.closeWait}
+
+
+
OTHER
+
+ {latestMetrics.networkConnection.synSent + + latestMetrics.networkConnection.synRecv + + latestMetrics.networkConnection.finWait1 + + latestMetrics.networkConnection.finWait2 + + latestMetrics.networkConnection.close + + latestMetrics.networkConnection.lastAck + + latestMetrics.networkConnection.closing} +
+
+
+
+ ); +}; diff --git a/web/src/components/server/ServerHero.tsx b/web/src/components/server/ServerHero.tsx new file mode 100644 index 0000000..d6366a7 --- /dev/null +++ b/web/src/components/server/ServerHero.tsx @@ -0,0 +1,96 @@ +import {ArrowLeft, Server} from 'lucide-react'; +import {cn} from '@/lib/utils'; +import {formatBytes, formatDateTime, formatUptime} from '@/utils/util'; +import type {Agent, LatestMetrics} from '@/types'; +import LittleStatCard from '@/components/common/LittleStatCard'; + +interface ServerHeroProps { + agent: Agent; + latestMetrics: LatestMetrics | null; + onBack: () => void; +} + +/** + * 服务器头部信息组件 + * 显示服务器基本信息、状态和关键指标 + */ +export const ServerHero = ({agent, latestMetrics, onBack}: ServerHeroProps) => { + const displayName = agent?.name?.trim() ? agent.name : '未命名探针'; + const isOnline = agent?.status === 1; + const statusDotStyles = isOnline ? 'bg-emerald-500' : 'bg-rose-500'; + const statusText = isOnline ? '在线' : '离线'; + + const platformDisplay = latestMetrics?.host?.platform + ? `${latestMetrics.host.platform} ${latestMetrics.host.platformVersion || ''}`.trim() + : agent?.os || '-'; + const architectureDisplay = latestMetrics?.host?.kernelArch || agent?.arch || '-'; + const uptimeDisplay = formatUptime(latestMetrics?.host?.uptime); + const lastSeenDisplay = agent ? formatDateTime(agent.lastSeenAt) : '-'; + + const networkSummary = latestMetrics?.network + ? `${formatBytes(latestMetrics.network.totalBytesSentTotal)} ↑ / ${formatBytes( + latestMetrics.network.totalBytesRecvTotal, + )} ↓` + : '—'; + + const heroStats = [ + {label: '运行系统', value: platformDisplay || '-'}, + {label: '硬件架构', value: architectureDisplay || '-'}, + {label: '最近心跳', value: lastSeenDisplay}, + {label: '运行时长', value: uptimeDisplay}, + ]; + + return ( +
+
+
+
+ +
+
+
+

{displayName}

+ + + {statusText} + +
+

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

+
+
+
+ +
+ {heroStats.map((stat) => ( + + ))} +
+
+
+ 探针 ID:{agent.id} + + 版本:{agent.version || '-'} + + 网络累计:{networkSummary} +
+
+
+ ); +}; diff --git a/web/src/components/server/SystemInfoSection.tsx b/web/src/components/server/SystemInfoSection.tsx new file mode 100644 index 0000000..162c1f5 --- /dev/null +++ b/web/src/components/server/SystemInfoSection.tsx @@ -0,0 +1,179 @@ +import type {ReactNode} from 'react'; +import {Cpu, Database, HardDrive, MemoryStick, Network} from 'lucide-react'; +import {Card} from '@/components/common'; +import {InfoGrid, SnapshotSection, type SnapshotCardData} from '@/components/server'; +import {formatBytes, formatDateTime, formatPercentValue, formatUptime} from '@/utils/util'; +import type {Agent, LatestMetrics} from '@/types'; + +type AccentVariant = 'blue' | 'emerald' | 'purple' | 'amber'; + +interface SystemInfoSectionProps { + agent: Agent; + latestMetrics: LatestMetrics | null; +} + +/** + * 系统信息区块组件 + * 显示运行环境、运行状态、快照卡片和网络连接统计 + */ +export const SystemInfoSection = ({agent, latestMetrics}: SystemInfoSectionProps) => { + // 环境信息 + const platformDisplay = latestMetrics?.host?.platform + ? `${latestMetrics.host.platform} ${latestMetrics.host.platformVersion || ''}`.trim() + : agent?.os || '-'; + const architectureDisplay = latestMetrics?.host?.kernelArch || agent?.arch || '-'; + + const environmentInfo = [ + {label: '操作系统', value: platformDisplay || '-'}, + {label: '内核版本', value: latestMetrics?.host?.kernelVersion || '-'}, + {label: '硬件架构', value: architectureDisplay || '-'}, + {label: 'CPU 型号', value: latestMetrics?.cpu?.modelName || '-'}, + {label: '逻辑核心', value: latestMetrics?.cpu?.logicalCores ?? '-'}, + {label: '物理核心', value: latestMetrics?.cpu?.physicalCores ?? '-'}, + ]; + + // 状态信息 + const uptimeDisplay = formatUptime(latestMetrics?.host?.uptime); + const bootTimeDisplay = latestMetrics?.host?.bootTime + ? formatDateTime(latestMetrics.host.bootTime * 1000) + : '-'; + const lastSeenDisplay = agent ? formatDateTime(agent.lastSeenAt) : '-'; + + const networkSummary = latestMetrics?.network + ? `${formatBytes(latestMetrics.network.totalBytesSentTotal)} ↑ / ${formatBytes( + latestMetrics.network.totalBytesRecvTotal, + )} ↓` + : '—'; + + const statusInfo = [ + {label: '启动时间', value: bootTimeDisplay}, + {label: '运行时间', value: uptimeDisplay}, + {label: '最近心跳', value: lastSeenDisplay}, + {label: '进程数', value: latestMetrics?.host?.procs ?? '-'}, + {label: '网络累计', value: networkSummary}, + ]; + + // 快照卡片 + const snapshotCards: SnapshotCardData[] = []; + + if (latestMetrics) { + snapshotCards.push({ + key: 'cpu', + icon: Cpu, + title: 'CPU 使用', + usagePercent: `${formatPercentValue(latestMetrics.cpu?.usagePercent)}%`, + accent: 'blue', + metrics: [ + {label: '当前使用', value: `${formatPercentValue(latestMetrics.cpu?.usagePercent)}%`}, + { + label: '采样时间', + value: latestMetrics.cpu ? formatDateTime(latestMetrics.cpu.timestamp) : '-', + }, + ], + }); + + snapshotCards.push({ + key: 'memory', + icon: MemoryStick, + title: '内存使用', + usagePercent: `${formatPercentValue(latestMetrics.memory?.usagePercent)}%`, + accent: 'emerald', + metrics: [ + { + label: '已用 / 总量', + value: `${formatBytes(latestMetrics.memory?.used)} / ${formatBytes(latestMetrics.memory?.total)}` + }, + { + label: 'Swap 已用', + value: `${formatBytes(latestMetrics.memory?.swapUsed)} / ${formatBytes(latestMetrics.memory?.swapTotal)}` + }, + ], + }); + + snapshotCards.push({ + key: 'disk', + icon: HardDrive, + title: '磁盘使用', + usagePercent: latestMetrics.disk + ? `${formatPercentValue(latestMetrics.disk.usagePercent)}%` + : '—', + accent: 'purple', + metrics: [ + { + label: '已用 / 总量', + value: `${formatBytes(latestMetrics.disk?.used)} / ${formatBytes(latestMetrics.disk?.total)}` + }, + {label: '磁盘数量', value: latestMetrics.disk?.totalDisks ?? '-'}, + ], + }); + + // 网络流量卡片 - 整合流量统计信息 + const networkMetrics = [ + { + label: '上行 / 下行', + value: `${formatBytes(latestMetrics.network?.totalBytesSentRate)}/s ↑ / ${formatBytes( + latestMetrics.network?.totalBytesRecvRate, + )}/s ↓`, + }, + { + label: '网络累计', + value: `${formatBytes(latestMetrics.network?.totalBytesSentTotal)} ↑ / ${formatBytes( + latestMetrics.network?.totalBytesRecvTotal, + )} ↓`, + }, + ]; + + // 如果配置了流量限额,添加流量统计信息到网络卡片 + if (agent && agent.trafficLimit && agent.trafficLimit > 0) { + const trafficUsedPercent = ((agent.trafficUsed || 0) / agent.trafficLimit) * 100; + const trafficRemaining = agent.trafficLimit - (agent.trafficUsed || 0); + + networkMetrics.push({ + label: '流量限额', + value: `${formatBytes(agent.trafficUsed || 0)} / ${formatBytes(agent.trafficLimit)} (${formatPercentValue(trafficUsedPercent)}%)`, + }); + + if (agent.trafficResetDay && agent.trafficResetDay > 0) { + networkMetrics.push({ + label: '重置日期', + value: `每月${agent.trafficResetDay}号`, + }); + } + } + + snapshotCards.push({ + key: 'network', + icon: Network, + title: '网络流量', + usagePercent: latestMetrics.network + ? `${formatBytes(latestMetrics.network.totalBytesSentRate)}/s` + : '—', + accent: 'amber', + metrics: networkMetrics, + }); + } + + return ( + +
+
+
+

运行环境

+

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

+
+ +
+
+
+

运行状态

+

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

+
+ +
+
+
+ +
+
+ ); +}; diff --git a/web/src/components/server/TemperatureMonitorSection.tsx b/web/src/components/server/TemperatureMonitorSection.tsx new file mode 100644 index 0000000..b859ec7 --- /dev/null +++ b/web/src/components/server/TemperatureMonitorSection.tsx @@ -0,0 +1,37 @@ +import {Thermometer} from 'lucide-react'; +import {Card} from '@/components/common'; +import type {LatestMetrics} from '@/types'; + +interface TemperatureMonitorSectionProps { + latestMetrics: LatestMetrics | null; +} + +/** + * 温度监控区块组件 + * 显示系统各部件温度传感器数据 + */ +export const TemperatureMonitorSection = ({latestMetrics}: TemperatureMonitorSectionProps) => { + // 如果没有温度数据,不渲染组件 + if (!latestMetrics?.temperature || latestMetrics.temperature.length === 0) { + return null; + } + + return ( + +
+ {latestMetrics.temperature.sort((a, b) => a.sensorKey.localeCompare(b.sensorKey)).map((temp) => ( +
+
+ +

{temp.type}

+
+

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

+
+ ))} +
+
+ ); +}; diff --git a/web/src/components/server/charts/ChartContainer.tsx b/web/src/components/server/charts/ChartContainer.tsx new file mode 100644 index 0000000..fcd1f77 --- /dev/null +++ b/web/src/components/server/charts/ChartContainer.tsx @@ -0,0 +1,30 @@ +import type {ReactNode} from 'react'; +import type {LucideIcon} from 'lucide-react'; + +interface ChartContainerProps { + title: string; + icon: LucideIcon; + children: ReactNode; + action?: ReactNode; +} + +/** + * 图表容器组件 + * 为所有图表提供统一的标题、图标和可选的操作区域 + */ +export const ChartContainer = ({title, icon: Icon, children, action}: ChartContainerProps) => { + return ( +
+
+

+ + + + {title} +

+ {action} +
+ {children} +
+ ); +}; diff --git a/web/src/components/server/charts/CpuChart.tsx b/web/src/components/server/charts/CpuChart.tsx new file mode 100644 index 0000000..01ff0e9 --- /dev/null +++ b/web/src/components/server/charts/CpuChart.tsx @@ -0,0 +1,90 @@ +import {useMemo} from 'react'; +import {Cpu} from 'lucide-react'; +import {Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis} from 'recharts'; +import {ChartPlaceholder, CustomTooltip} from '@/components/common'; +import {useMetricsQuery} from '@/hooks/server/queries'; +import {ChartContainer} from './ChartContainer'; + +interface CpuChartProps { + agentId: string; + timeRange: string; +} + +/** + * CPU 使用率图表组件 + */ +export const CpuChart = ({agentId, timeRange}: CpuChartProps) => { + // 数据查询 + const {data: metricsResponse, isLoading} = useMetricsQuery({ + agentId, + type: 'cpu', + range: timeRange, + }); + + // 数据转换 + const chartData = useMemo(() => { + const cpuSeries = metricsResponse?.data.series.find(s => s.name === 'usage'); + if (!cpuSeries) return []; + + return cpuSeries.data.map((point) => ({ + time: new Date(point.timestamp).toLocaleTimeString('zh-CN', { + hour: '2-digit', + minute: '2-digit', + }), + usage: Number(point.value.toFixed(2)), + timestamp: point.timestamp, + })); + }, [metricsResponse]); + + // 渲染 + if (isLoading) { + return ( + + + + ); + } + + return ( + + {chartData.length > 0 ? ( + + + + + + + + + + + `${value}%`} + /> + }/> + + + + ) : ( + + )} + + ); +}; diff --git a/web/src/components/server/charts/DiskIOChart.tsx b/web/src/components/server/charts/DiskIOChart.tsx new file mode 100644 index 0000000..e27e8f9 --- /dev/null +++ b/web/src/components/server/charts/DiskIOChart.tsx @@ -0,0 +1,122 @@ +import {useMemo} from 'react'; +import {HardDrive} from 'lucide-react'; +import {Area, AreaChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis} from 'recharts'; +import {ChartPlaceholder, CustomTooltip} from '@/components/common'; +import {useMetricsQuery} from '@/hooks/server/queries'; +import {ChartContainer} from './ChartContainer'; + +interface DiskIOChartProps { + agentId: string; + timeRange: string; +} + +/** + * 磁盘 I/O 图表组件 + */ +export const DiskIOChart = ({agentId, timeRange}: DiskIOChartProps) => { + // 数据查询 + const {data: metricsResponse, isLoading} = useMetricsQuery({ + agentId, + type: 'disk_io', + range: timeRange, + }); + + // 数据转换 + const chartData = useMemo(() => { + if (!metricsResponse?.data.series || metricsResponse.data.series.length === 0) return []; + + const readSeries = metricsResponse.data.series.find(s => s.name === 'read'); + const writeSeries = metricsResponse.data.series.find(s => s.name === 'write'); + + if (!readSeries || !writeSeries) return []; + + // 按时间戳对齐数据 + const timeMap = new Map(); + + readSeries.data.forEach(point => { + const time = new Date(point.timestamp).toLocaleTimeString('zh-CN', { + hour: '2-digit', + minute: '2-digit', + }); + timeMap.set(point.timestamp, { + time, + timestamp: point.timestamp, + read: Number((point.value / 1024 / 1024).toFixed(2)), // 转换为 MB/s + }); + }); + + writeSeries.data.forEach(point => { + const existing = timeMap.get(point.timestamp); + if (existing) { + existing.write = Number((point.value / 1024 / 1024).toFixed(2)); + } + }); + + return Array.from(timeMap.values()); + }, [metricsResponse]); + + // 渲染 + if (isLoading) { + return ( + + + + ); + } + + return ( + + {chartData.length > 0 ? ( + + + + + + + + + + + + + + + `${value} MB`} + /> + }/> + + + + + + ) : ( + + )} + + ); +}; diff --git a/web/src/components/server/charts/GpuChart.tsx b/web/src/components/server/charts/GpuChart.tsx new file mode 100644 index 0000000..4b8aba2 --- /dev/null +++ b/web/src/components/server/charts/GpuChart.tsx @@ -0,0 +1,125 @@ +import {useMemo} from 'react'; +import {Zap} from 'lucide-react'; +import {CartesianGrid, Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis} from 'recharts'; +import {ChartPlaceholder, CustomTooltip} from '@/components/common'; +import {useMetricsQuery} from '@/hooks/server/queries'; +import {ChartContainer} from './ChartContainer'; + +interface GpuChartProps { + agentId: string; + timeRange: string; +} + +/** + * GPU 使用率与温度图表组件 + * 使用双 Y 轴显示使用率和温度 + */ +export const GpuChart = ({agentId, timeRange}: GpuChartProps) => { + // 数据查询 + const {data: metricsResponse, isLoading} = useMetricsQuery({ + agentId, + type: 'gpu', + range: timeRange, + }); + + // 数据转换 + const chartData = useMemo(() => { + if (!metricsResponse?.data.series || metricsResponse.data.series.length === 0) return []; + + // 按时间戳聚合利用率和温度系列 + const timeMap = new Map(); + + const utilizationSeries = metricsResponse.data.series.find(s => s.name === 'utilization'); + const temperatureSeries = metricsResponse.data.series.find(s => s.name === 'temperature'); + + // 添加利用率数据 + utilizationSeries?.data.forEach(point => { + const time = new Date(point.timestamp).toLocaleTimeString('zh-CN', { + hour: '2-digit', + minute: '2-digit', + }); + timeMap.set(point.timestamp, { + time, + timestamp: point.timestamp, + utilization: Number(point.value.toFixed(2)), + }); + }); + + // 添加温度数据 + temperatureSeries?.data.forEach(point => { + const existing = timeMap.get(point.timestamp); + if (existing) { + existing.temperature = Number(point.value.toFixed(2)); + } + }); + + return Array.from(timeMap.values()); + }, [metricsResponse]); + + // 渲染 + if (isLoading) { + return ( + + + + ); + } + + // 如果没有 GPU 数据,不渲染组件 + if (chartData.length === 0) { + return null; + } + + return ( + + + + + + `${value}%`} + /> + `${value}°C`} + /> + }/> + + + + + + + ); +}; diff --git a/web/src/components/server/charts/MemoryChart.tsx b/web/src/components/server/charts/MemoryChart.tsx new file mode 100644 index 0000000..2c5159a --- /dev/null +++ b/web/src/components/server/charts/MemoryChart.tsx @@ -0,0 +1,90 @@ +import {useMemo} from 'react'; +import {MemoryStick} from 'lucide-react'; +import {Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis} from 'recharts'; +import {ChartPlaceholder, CustomTooltip} from '@/components/common'; +import {useMetricsQuery} from '@/hooks/server/queries'; +import {ChartContainer} from './ChartContainer'; + +interface MemoryChartProps { + agentId: string; + timeRange: string; +} + +/** + * 内存使用率图表组件 + */ +export const MemoryChart = ({agentId, timeRange}: MemoryChartProps) => { + // 数据查询 + const {data: metricsResponse, isLoading} = useMetricsQuery({ + agentId, + type: 'memory', + range: timeRange, + }); + + // 数据转换 + const chartData = useMemo(() => { + const memorySeries = metricsResponse?.data.series.find(s => s.name === 'usage'); + if (!memorySeries) return []; + + return memorySeries.data.map((point) => ({ + time: new Date(point.timestamp).toLocaleTimeString('zh-CN', { + hour: '2-digit', + minute: '2-digit', + }), + usage: Number(point.value.toFixed(2)), + timestamp: point.timestamp, + })); + }, [metricsResponse]); + + // 渲染 + if (isLoading) { + return ( + + + + ); + } + + return ( + + {chartData.length > 0 ? ( + + + + + + + + + + + `${value}%`} + /> + }/> + + + + ) : ( + + )} + + ); +}; diff --git a/web/src/components/server/charts/NetworkChart.tsx b/web/src/components/server/charts/NetworkChart.tsx new file mode 100644 index 0000000..b3550c8 --- /dev/null +++ b/web/src/components/server/charts/NetworkChart.tsx @@ -0,0 +1,154 @@ +import {useEffect, useMemo, useState} from 'react'; +import {Network} from 'lucide-react'; +import {Area, AreaChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis} from 'recharts'; +import {ChartPlaceholder, CustomTooltip} from '@/components/common'; +import {useMetricsQuery, useNetworkInterfacesQuery} from '@/hooks/server/queries'; +import {INTERFACE_COLORS} from '@/constants/server'; +import {ChartContainer} from './ChartContainer'; + +interface NetworkChartProps { + agentId: string; + timeRange: string; +} + +/** + * 网络流量图表组件 + * 支持网卡切换 + */ +export const NetworkChart = ({agentId, timeRange}: NetworkChartProps) => { + const [selectedInterface, setSelectedInterface] = useState('all'); + + // 查询网卡列表 + const {data: interfacesData} = useNetworkInterfacesQuery(agentId); + const availableInterfaces = interfacesData?.data.interfaces || []; + + // 当网卡列表变化时,验证选中的网卡 + useEffect(() => { + if (selectedInterface !== 'all' && availableInterfaces.length > 0) { + if (!availableInterfaces.includes(selectedInterface)) { + setSelectedInterface('all'); + } + } + }, [availableInterfaces, selectedInterface]); + + // 查询网络数据 + const {data: metricsResponse, isLoading} = useMetricsQuery({ + agentId, + type: 'network', + range: timeRange, + interfaceName: selectedInterface !== 'all' ? selectedInterface : undefined, + }); + + // 数据转换 + const chartData = useMemo(() => { + if (!metricsResponse?.data.series || metricsResponse.data.series.length === 0) return []; + + const uploadSeries = metricsResponse.data.series.find(s => s.name === 'upload'); + const downloadSeries = metricsResponse.data.series.find(s => s.name === 'download'); + + if (!uploadSeries || !downloadSeries) return []; + + const timeMap = new Map(); + + uploadSeries.data.forEach(point => { + const time = new Date(point.timestamp).toLocaleTimeString('zh-CN', { + hour: '2-digit', + minute: '2-digit', + }); + timeMap.set(point.timestamp, { + time, + timestamp: point.timestamp, + upload: Number((point.value / 1024 / 1024).toFixed(2)), // 转换为 MB/s + }); + }); + + downloadSeries.data.forEach(point => { + const existing = timeMap.get(point.timestamp); + if (existing) { + existing.download = Number((point.value / 1024 / 1024).toFixed(2)); + } + }); + + return Array.from(timeMap.values()); + }, [metricsResponse]); + + // 网卡选择器 + const interfaceSelector = availableInterfaces.length > 0 && ( + + ); + + // 渲染 + if (isLoading) { + return ( + + + + ); + } + + return ( + + {chartData.length > 0 ? ( + + + + + + + + + + + + + + + `${value} MB`} + /> + }/> + + + + + + ) : ( + + )} + + ); +}; diff --git a/web/src/components/server/charts/NetworkConnectionChart.tsx b/web/src/components/server/charts/NetworkConnectionChart.tsx new file mode 100644 index 0000000..c56bfad --- /dev/null +++ b/web/src/components/server/charts/NetworkConnectionChart.tsx @@ -0,0 +1,124 @@ +import {useMemo} from 'react'; +import {Network} from 'lucide-react'; +import {CartesianGrid, Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis} from 'recharts'; +import {ChartPlaceholder, CustomTooltip} from '@/components/common'; +import {useMetricsQuery} from '@/hooks/server/queries'; +import {ChartContainer} from './ChartContainer'; + +interface NetworkConnectionChartProps { + agentId: string; + timeRange: string; +} + +/** + * 网络连接统计图表组件 + */ +export const NetworkConnectionChart = ({agentId, timeRange}: NetworkConnectionChartProps) => { + // 数据查询 + const {data: metricsResponse, isLoading} = useMetricsQuery({ + agentId, + type: 'network_connection', + range: timeRange, + }); + + // 数据转换 + const chartData = useMemo(() => { + if (!metricsResponse?.data.series || metricsResponse.data.series.length === 0) return []; + + // 按时间戳聚合所有连接状态系列 + const timeMap = new Map(); + + metricsResponse.data.series.forEach(series => { + const stateName = series.name; // established, time_wait, close_wait, listen + series.data.forEach(point => { + const time = new Date(point.timestamp).toLocaleTimeString('zh-CN', { + hour: '2-digit', + minute: '2-digit', + }); + + if (!timeMap.has(point.timestamp)) { + timeMap.set(point.timestamp, {time, timestamp: point.timestamp}); + } + + const existing = timeMap.get(point.timestamp)!; + // 转换为驼峰命名以匹配图表的 dataKey + const camelCaseName = stateName.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()); + existing[camelCaseName] = Number(point.value.toFixed(0)); + }); + }); + + return Array.from(timeMap.values()); + }, [metricsResponse]); + + // 渲染 + if (isLoading) { + return ( + + + + ); + } + + return ( + + {chartData.length > 0 ? ( + + + + + + }/> + + + + + + + + ) : ( + + )} + + ); +}; diff --git a/web/src/components/server/charts/TemperatureChart.tsx b/web/src/components/server/charts/TemperatureChart.tsx new file mode 100644 index 0000000..b08d6f3 --- /dev/null +++ b/web/src/components/server/charts/TemperatureChart.tsx @@ -0,0 +1,148 @@ +import {useEffect, useMemo, useState} from 'react'; +import {Thermometer} from 'lucide-react'; +import {CartesianGrid, Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis} from 'recharts'; +import {ChartPlaceholder, CustomTooltip} from '@/components/common'; +import {useMetricsQuery} from '@/hooks/server/queries'; +import {TEMPERATURE_COLORS} from '@/constants/server'; +import {ChartContainer} from './ChartContainer'; + +interface TemperatureChartProps { + agentId: string; + timeRange: string; +} + +/** + * 系统温度图表组件 + * 支持温度类型切换 + */ +export const TemperatureChart = ({agentId, timeRange}: TemperatureChartProps) => { + const [selectedTempType, setSelectedTempType] = useState('all'); + + // 数据查询 + const {data: metricsResponse, isLoading} = useMetricsQuery({ + agentId, + type: 'temperature', + range: timeRange, + }); + + // 数据转换 + const chartData = useMemo(() => { + if (!metricsResponse?.data.series || metricsResponse.data.series.length === 0) return []; + + // 按时间戳聚合所有温度系列 + const timeMap = new Map(); + + metricsResponse.data.series.forEach(series => { + const sensorName = series.name; // 使用系列名称作为传感器标识 + series.data.forEach(point => { + const time = new Date(point.timestamp).toLocaleTimeString('zh-CN', { + hour: '2-digit', + minute: '2-digit', + }); + + if (!timeMap.has(point.timestamp)) { + timeMap.set(point.timestamp, {time, timestamp: point.timestamp}); + } + + const existing = timeMap.get(point.timestamp)!; + existing[sensorName] = Number(point.value.toFixed(2)); + }); + }); + + return Array.from(timeMap.values()); + }, [metricsResponse]); + + // 提取所有唯一的温度类型 + const temperatureTypes = useMemo(() => { + return metricsResponse?.data.series.map(s => s.name).sort() || []; + }, [metricsResponse]); + + // 根据选中的类型过滤温度数据 + const filteredTemperatureTypes = useMemo(() => { + if (selectedTempType === 'all') { + return temperatureTypes; + } + return temperatureTypes.filter(type => type === selectedTempType); + }, [temperatureTypes, selectedTempType]); + + // 当温度类型列表变化时,如果当前选中的类型不在列表中,重置为 'all' + useEffect(() => { + if (selectedTempType !== 'all' && temperatureTypes.length > 0) { + if (!temperatureTypes.includes(selectedTempType)) { + setSelectedTempType('all'); + } + } + }, [temperatureTypes, selectedTempType]); + + // 温度类型选择器 + const tempTypeSelector = temperatureTypes.length > 1 && ( + + ); + + // 渲染 + if (isLoading) { + return ( + + + + ); + } + + // 如果没有温度数据,不渲染组件 + if (chartData.length === 0 || temperatureTypes.length === 0) { + return null; + } + + return ( + + + + + + `${value}°C`} + /> + }/> + + {/* 为选中的温度类型渲染线条 */} + {filteredTemperatureTypes.map((type, index) => { + // 使用预定义颜色,如果没有则使用默认颜色 + const color = TEMPERATURE_COLORS[type] || `hsl(${(index * 60) % 360}, 70%, 50%)`; + return ( + + ); + })} + + + + ); +}; diff --git a/web/src/components/server/charts/index.ts b/web/src/components/server/charts/index.ts new file mode 100644 index 0000000..aba0a52 --- /dev/null +++ b/web/src/components/server/charts/index.ts @@ -0,0 +1,8 @@ +export {ChartContainer} from './ChartContainer'; +export {CpuChart} from './CpuChart'; +export {MemoryChart} from './MemoryChart'; +export {NetworkChart} from './NetworkChart'; +export {DiskIOChart} from './DiskIOChart'; +export {NetworkConnectionChart} from './NetworkConnectionChart'; +export {GpuChart} from './GpuChart'; +export {TemperatureChart} from './TemperatureChart'; diff --git a/web/src/components/server/index.ts b/web/src/components/server/index.ts index 66438e3..9e76011 100644 --- a/web/src/components/server/index.ts +++ b/web/src/components/server/index.ts @@ -2,3 +2,8 @@ export {InfoGrid} from './InfoGrid'; export {SnapshotGrid} from './SnapshotGrid'; export {SnapshotSection} from './SnapshotSection'; export type {SnapshotCardData} from './SnapshotGrid'; +export {ServerHero} from './ServerHero'; +export {SystemInfoSection} from './SystemInfoSection'; +export {NetworkConnectionSection} from './NetworkConnectionSection'; +export {GpuMonitorSection} from './GpuMonitorSection'; +export {TemperatureMonitorSection} from './TemperatureMonitorSection'; diff --git a/web/src/hooks/server/index.ts b/web/src/hooks/server/index.ts new file mode 100644 index 0000000..94abf5f --- /dev/null +++ b/web/src/hooks/server/index.ts @@ -0,0 +1,5 @@ +// React Query hooks +export {useAgentQuery} from './queries/useAgentQuery'; +export {useLatestMetricsQuery} from './queries/useLatestMetricsQuery'; +export {useMetricsQuery} from './queries/useMetricsQuery'; +export {useNetworkInterfacesQuery} from './queries/useNetworkInterfacesQuery'; diff --git a/web/src/hooks/server/queries/index.ts b/web/src/hooks/server/queries/index.ts new file mode 100644 index 0000000..454afb0 --- /dev/null +++ b/web/src/hooks/server/queries/index.ts @@ -0,0 +1,4 @@ +export {useAgentQuery} from './useAgentQuery'; +export {useLatestMetricsQuery} from './useLatestMetricsQuery'; +export {useMetricsQuery} from './useMetricsQuery'; +export {useNetworkInterfacesQuery} from './useNetworkInterfacesQuery'; diff --git a/web/src/hooks/server/queries/useAgentQuery.ts b/web/src/hooks/server/queries/useAgentQuery.ts new file mode 100644 index 0000000..9d168f3 --- /dev/null +++ b/web/src/hooks/server/queries/useAgentQuery.ts @@ -0,0 +1,16 @@ +import {useQuery} from '@tanstack/react-query'; +import {getAgent} from '@/api/agent'; + +/** + * 查询 Agent 基础信息 + * @param agentId Agent ID + * @returns Agent 查询结果 + */ +export const useAgentQuery = (agentId?: string) => { + return useQuery({ + queryKey: ['agent', agentId], + queryFn: () => getAgent(agentId!), + enabled: !!agentId, + staleTime: 60000, // 1分钟缓存 + }); +}; diff --git a/web/src/hooks/server/queries/useLatestMetricsQuery.ts b/web/src/hooks/server/queries/useLatestMetricsQuery.ts new file mode 100644 index 0000000..46af29d --- /dev/null +++ b/web/src/hooks/server/queries/useLatestMetricsQuery.ts @@ -0,0 +1,17 @@ +import {useQuery} from '@tanstack/react-query'; +import {getAgentLatestMetrics} from '@/api/agent'; + +/** + * 查询 Agent 最新指标 + * 自动每 5 秒刷新一次 + * @param agentId Agent ID + * @returns 最新指标查询结果 + */ +export const useLatestMetricsQuery = (agentId?: string) => { + return useQuery({ + queryKey: ['agent', agentId, 'metrics', 'latest'], + queryFn: () => getAgentLatestMetrics(agentId!), + enabled: !!agentId, + refetchInterval: 5000, // 5秒自动刷新 + }); +}; diff --git a/web/src/hooks/server/queries/useMetricsQuery.ts b/web/src/hooks/server/queries/useMetricsQuery.ts new file mode 100644 index 0000000..832429d --- /dev/null +++ b/web/src/hooks/server/queries/useMetricsQuery.ts @@ -0,0 +1,30 @@ +import {useQuery} from '@tanstack/react-query'; +import {getAgentMetrics, type GetAgentMetricsRequest} from '@/api/agent'; + +interface UseMetricsQueryOptions { + agentId: string; + type: GetAgentMetricsRequest['type']; + range: string; + interfaceName?: string; +} + +/** + * 查询 Agent 历史指标数据 + * 自动每 30 秒刷新一次 + * @param options 查询选项 + * @returns 历史指标查询结果 + */ +export const useMetricsQuery = ({agentId, type, range, interfaceName}: UseMetricsQueryOptions) => { + return useQuery({ + queryKey: ['agent', agentId, 'metrics', type, range, interfaceName], + queryFn: () => + getAgentMetrics({ + agentId, + type, + range, + interface: interfaceName, + }), + enabled: !!agentId, + refetchInterval: 30000, // 30秒自动刷新 + }); +}; diff --git a/web/src/hooks/server/queries/useNetworkInterfacesQuery.ts b/web/src/hooks/server/queries/useNetworkInterfacesQuery.ts new file mode 100644 index 0000000..9f6c61b --- /dev/null +++ b/web/src/hooks/server/queries/useNetworkInterfacesQuery.ts @@ -0,0 +1,15 @@ +import {useQuery} from '@tanstack/react-query'; +import {getAvailableNetworkInterfaces} from '@/api/agent'; + +/** + * 查询 Agent 可用网卡列表 + * @param agentId Agent ID + * @returns 网卡列表查询结果 + */ +export const useNetworkInterfacesQuery = (agentId?: string) => { + return useQuery({ + queryKey: ['agent', agentId, 'network-interfaces'], + queryFn: () => getAvailableNetworkInterfaces(agentId!), + enabled: !!agentId, + }); +}; diff --git a/web/src/pages/Public/MonitorDetail.tsx b/web/src/pages/Public/MonitorDetail.tsx index fc8f032..851ae08 100644 --- a/web/src/pages/Public/MonitorDetail.tsx +++ b/web/src/pages/Public/MonitorDetail.tsx @@ -1,136 +1,17 @@ -import {useEffect, useMemo, useState} from 'react'; import {useNavigate, useParams} from 'react-router-dom'; import {useQuery} from '@tanstack/react-query'; -import {AlertCircle, ArrowLeft, Clock, Loader2, MapPin, Shield} from 'lucide-react'; -import {Area, AreaChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis} from 'recharts'; -import {type GetMetricsResponse, getMonitorAgentStats, getMonitorHistory, getMonitorStatsById} from '@/api/monitor.ts'; +import {getMonitorAgentStats, getMonitorStatsById} from '@/api/monitor.ts'; import type {AgentMonitorStat, PublicMonitor} from '@/types'; -import {formatDateTime, formatTime} from '@/utils/util.ts'; -import {StatusBadge} from '@/components/monitor/StatusBadge'; -import {TypeIcon} from '@/components/monitor/TypeIcon'; -import {CertBadge} from '@/components/monitor/CertBadge'; -import {cn} from '@/lib/utils'; -// 常量 -import {AGENT_COLORS} from '@/constants/colors'; -import {MONITOR_TIME_RANGE_OPTIONS} from '@/constants/time'; -import LittleStatCard from "@/components/common/LittleStatCard.tsx"; - -// 加载状态组件 -const LoadingSpinner = () => ( -
-
- - 加载监控详情中... -
-
-); - -// 空状态组件 -const EmptyState = () => ( -
- -

未找到监控数据

-

请检查监控 ID 是否正确

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

{title}

- {description &&

{description}

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

{subtitle}

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

{label}

-
- {payload.map((entry: any, index: number) => ( -
- - - {entry.name}: {entry.value} ms - -
- ))} -
-
- ); -}; - -// 时间范围选择器组件 -const TimeRangeSelector = ({ - value, - onChange, - options - }: { - value: string; - onChange: (value: string) => void; - options: Array<{ value: string; label: string }>; -}) => ( -
- {options.map(option => ( - - ))} -
-); - +import {MonitorHero, ResponseTimeChart, AgentStatsTable} from '@/components/monitor'; +import {EmptyState, LoadingSpinner} from '@/components/common'; +/** + * 监控详情页面 + * 显示监控的详细信息、响应时间趋势和各探针统计 + */ const MonitorDetail = () => { const navigate = useNavigate(); const {id} = useParams<{ id: string }>(); - const [selectedAgent, setSelectedAgent] = useState('all'); - const [timeRange, setTimeRange] = useState('1d'); // 获取监控详情(聚合数据) const {data: monitorDetail, isLoading} = useQuery({ @@ -156,365 +37,38 @@ const MonitorDetail = () => { enabled: !!id, }); - // 获取历史数据 - const {data: historyData} = useQuery({ - queryKey: ['monitorHistory', id, timeRange], - queryFn: async () => { - if (!id) throw new Error('Monitor ID is required'); - const response = await getMonitorHistory(id, timeRange); - return response.data; - }, - refetchInterval: 30000, - enabled: !!id, - }); - - // 获取所有可用的探针列表 - const availableAgents = useMemo(() => { - if (monitorStats.length === 0) return []; - return monitorStats.map(stat => ({ - id: stat.agentId, - label: stat.agentId.substring(0, 8), - })); - }, [monitorStats]); - - // 当可用探针列表变化时,检查当前选择的探针是否还存在 - useEffect(() => { - if (selectedAgent === 'all') return; - if (!availableAgents.find(agent => agent.id === selectedAgent)) { - setSelectedAgent('all'); - } - }, [availableAgents, selectedAgent]); - - // 生成图表数据 - const chartData = useMemo(() => { - if (!historyData?.series) return []; - - // 过滤出响应时间指标的 series - const responseTimeSeries = historyData.series.filter(s => s.name === 'response_time'); - - // 根据选择的探针过滤 - const filteredSeries = selectedAgent === 'all' - ? responseTimeSeries - : responseTimeSeries.filter(s => s.labels?.agent_id === selectedAgent); - - if (filteredSeries.length === 0) return []; - - // 按时间戳分组数据 - const grouped: Record = {}; - - filteredSeries.forEach(series => { - const agentId = series.labels?.agent_id || 'unknown'; - const agentKey = `agent_${agentId}`; - - series.data.forEach(point => { - if (!grouped[point.timestamp]) { - grouped[point.timestamp] = { - time: new Date(point.timestamp).toLocaleTimeString('zh-CN', { - hour: '2-digit', - minute: '2-digit', - }), - timestamp: point.timestamp, - }; - } - grouped[point.timestamp][agentKey] = point.value; - }); - }); - - // 按时间戳排序 - return Object.values(grouped).sort((a, b) => a.timestamp - b.timestamp); - }, [historyData, selectedAgent]); - if (isLoading) { - return ( -
- -
- ); + return ; } if (!monitorDetail) { - return ( -
- -
- ); + return ; } return ( -
- {/* Hero Section */} -
- {/* 装饰性边框 */} -
-
-
-
-
+
+
+ {/* 头部区域 */} + navigate('/monitors')} + /> -
- {/* 返回按钮 */} - - - {/* 监控信息 */} -
-
-
- -
-
-
- MONITOR_ID: {monitorDetail.id.toString().substring(0, 8)} -
-
-

{monitorDetail.name}

- -
-

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

-
-
- - {/* 统计卡片 */} -
- - - - -
-
- - {/* 证书信息(如果是 HTTPS)*/} - {monitorDetail.type === 'https' && monitorDetail.certExpiryTime && ( -
- SSL 证书: - -
- )} -
-
- - {/* 响应时间趋势图表 */} - - - {availableAgents.length > 0 && ( - - )} -
- } - > - {chartData.length > 0 ? ( - - - - {monitorStats - .filter(stat => selectedAgent === 'all' || stat.agentId === selectedAgent) - .map((stat, index) => { - const originalIndex = monitorStats.findIndex(s => s.agentId === stat.agentId); - const agentKey = `agent_${stat.agentId}`; - const color = AGENT_COLORS[originalIndex % AGENT_COLORS.length]; - return ( - - - - - ); - })} - - - - `${value}ms`} - /> - }/> - - {monitorStats - .filter(stat => selectedAgent === 'all' || stat.agentId === selectedAgent) - .map((stat) => { - const originalIndex = monitorStats.findIndex(s => s.agentId === stat.agentId); - const agentKey = `agent_${stat.agentId}`; - const color = AGENT_COLORS[originalIndex % AGENT_COLORS.length]; - const agentLabel = stat.agentId.substring(0, 8); - return ( - - ); - })} - - - ) : ( - + {/* 响应时间趋势图表 */} + - )} - - {/* 各探针详细数据 */} - -
- - - - - - - - {monitorDetail.type === 'https' && ( - - )} - - - - - {monitorStats.map((stat, index) => { - const color = AGENT_COLORS[index % AGENT_COLORS.length]; - return ( - - - - - - {monitorDetail.type === 'https' && ( - - )} - - - ); - })} - -
- 探针 ID - - 状态 - - 响应时间 - - 最后检测 - - 证书信息 - - 错误信息 -
-
- -
- - - {stat.agentId.substring(0, 8)}... - -
-
-
- - -
- - - {formatTime(stat.responseTime)} - -
-
- {formatDateTime(stat.checkedAt)} - - {stat.certExpiryTime ? ( - - ) : ( - - - )} - - {stat.status === 'down' && stat.message ? ( -
- - - {stat.message} - -
- ) : ( - - - )} -
-
-
+ {/* 各探针详细数据 */} + + +
); }; diff --git a/web/src/pages/Public/ServerDetail.tsx b/web/src/pages/Public/ServerDetail.tsx index 439b5e2..97585a2 100644 --- a/web/src/pages/Public/ServerDetail.tsx +++ b/web/src/pages/Public/ServerDetail.tsx @@ -1,479 +1,42 @@ -import {type ReactNode, useEffect, useMemo, useState} from 'react'; +import {useState} from 'react'; import {useNavigate, useParams} from 'react-router-dom'; -import {ArrowLeft, Cpu, Database, HardDrive, MemoryStick, Network, Server, Thermometer, Zap} from 'lucide-react'; +import {Card, EmptyState, LoadingSpinner, TimeRangeSelector} from '@/components/common'; import { - Area, - AreaChart, - CartesianGrid, - Legend, - Line, - LineChart, - ResponsiveContainer, - Tooltip, - XAxis, - YAxis, -} from 'recharts'; -import {getAvailableNetworkInterfaces} from '@/api/agent.ts'; -import {cn} from '@/lib/utils'; -// 工具函数 -import {formatBytes, formatDateTime, formatPercentValue, formatUptime} from '@/utils/util'; -// Hooks -import {useAgentOverview, useAggregatedMetrics} from '@/hooks/server'; -// 常量 -import {INTERFACE_COLORS, TEMPERATURE_COLORS} from '@/constants/server'; + GpuMonitorSection, + NetworkConnectionSection, + ServerHero, + SystemInfoSection, + TemperatureMonitorSection, +} from '@/components/server'; +import { + CpuChart, + DiskIOChart, + GpuChart, + MemoryChart, + NetworkChart, + NetworkConnectionChart, + TemperatureChart, +} from '@/components/server/charts'; +import {useAgentQuery, useLatestMetricsQuery} from '@/hooks/server/queries'; import {SERVER_TIME_RANGE_OPTIONS} from '@/constants/time'; -// 公共组件 -import { - Card, - ChartPlaceholder, - CustomTooltip, - EmptyState, - LoadingSpinner, - TimeRangeSelector -} from '@/components/common'; -// 服务器组件 -import {InfoGrid, type SnapshotCardData, SnapshotSection} from '@/components/server'; -import LittleStatCard from "@/components/common/LittleStatCard.tsx"; - - -type AccentVariant = 'blue' | 'emerald' | 'purple' | 'amber'; - +/** + * 服务器详情页面 + * 显示服务器的详细信息、实时指标和历史趋势图表 + */ const ServerDetail = () => { const {id} = useParams<{ id: string }>(); const navigate = useNavigate(); - const [timeRange, setTimeRange] = useState('15m'); - const [selectedInterface, setSelectedInterface] = useState('all'); - const [selectedTempType, setSelectedTempType] = useState('all'); - const {agent, latestMetrics, loading} = useAgentOverview(id); - const metricsData = useAggregatedMetrics(id, timeRange, selectedInterface); - const cpuChartData = useMemo(() => { - // CPU 数据:取第一个系列(usage) - const cpuSeries = metricsData.cpu.find(s => s.name === 'usage'); - if (!cpuSeries) return []; + // 查询基础数据(用于页面头部和系统信息) + const {data: agentResponse, isLoading} = useAgentQuery(id); + const {data: latestMetricsResponse} = useLatestMetricsQuery(id); - return cpuSeries.data.map((point) => ({ - time: new Date(point.timestamp).toLocaleTimeString('zh-CN', { - hour: '2-digit', - minute: '2-digit', - }), - usage: Number(point.value.toFixed(2)), - timestamp: point.timestamp, - })); - }, [metricsData.cpu]); + const agent = agentResponse?.data; + const latestMetrics = latestMetricsResponse?.data || null; - const memoryChartData = useMemo(() => { - // Memory 数据:取第一个系列(usage) - const memorySeries = metricsData.memory.find(s => s.name === 'usage'); - if (!memorySeries) return []; - - return memorySeries.data.map((point) => ({ - time: new Date(point.timestamp).toLocaleTimeString('zh-CN', { - hour: '2-digit', - minute: '2-digit', - }), - usage: Number(point.value.toFixed(2)), - timestamp: point.timestamp, - })); - }, [metricsData.memory]); - - // 获取所有可用的网卡列表(从后端接口获取) - const [availableInterfaces, setAvailableInterfaces] = useState([]); - - useEffect(() => { - if (!id) { - setAvailableInterfaces([]); - return; - } - - let cancelled = false; - - const fetchInterfaces = async () => { - try { - const response = await getAvailableNetworkInterfaces(id); - if (!cancelled) { - setAvailableInterfaces(response.data.interfaces || []); - } - } catch (error) { - console.error('Failed to load network interfaces:', error); - if (!cancelled) { - setAvailableInterfaces([]); - } - } - }; - - fetchInterfaces(); - - return () => { - cancelled = true; - }; - }, [id]); - - // 当网卡列表变化时,如果当前选中的网卡不在列表中,重置为 'all' - useEffect(() => { - if (selectedInterface !== 'all' && availableInterfaces.length > 0) { - if (!availableInterfaces.includes(selectedInterface)) { - setSelectedInterface('all'); - } - } - }, [availableInterfaces, selectedInterface]); - - const networkChartData = useMemo(() => { - if (metricsData.network.length === 0) return []; - - // 找到上行和下行系列 - const uploadSeries = metricsData.network.find(s => s.name === 'upload'); - const downloadSeries = metricsData.network.find(s => s.name === 'download'); - - if (!uploadSeries || !downloadSeries) return []; - - // 按时间戳对齐数据 - const timeMap = new Map(); - - uploadSeries.data.forEach(point => { - const time = new Date(point.timestamp).toLocaleTimeString('zh-CN', { - hour: '2-digit', - minute: '2-digit', - }); - timeMap.set(point.timestamp, { - time, - timestamp: point.timestamp, - upload: Number((point.value / 1024 / 1024).toFixed(2)), // 转换为 MB/s - }); - }); - - downloadSeries.data.forEach(point => { - const existing = timeMap.get(point.timestamp); - if (existing) { - existing.download = Number((point.value / 1024 / 1024).toFixed(2)); - } - }); - - return Array.from(timeMap.values()); - }, [metricsData.network]); - - // Disk I/O 图表数据 - const diskIOChartData = useMemo(() => { - if (metricsData.diskIO.length === 0) return []; - - // 找到读和写系列 - const readSeries = metricsData.diskIO.find(s => s.name === 'read'); - const writeSeries = metricsData.diskIO.find(s => s.name === 'write'); - - if (!readSeries || !writeSeries) return []; - - // 按时间戳对齐数据 - const timeMap = new Map(); - - readSeries.data.forEach(point => { - const time = new Date(point.timestamp).toLocaleTimeString('zh-CN', { - hour: '2-digit', - minute: '2-digit', - }); - timeMap.set(point.timestamp, { - time, - timestamp: point.timestamp, - read: Number((point.value / 1024 / 1024).toFixed(2)), // 转换为 MB/s - }); - }); - - writeSeries.data.forEach(point => { - const existing = timeMap.get(point.timestamp); - if (existing) { - existing.write = Number((point.value / 1024 / 1024).toFixed(2)); - } - }); - - return Array.from(timeMap.values()); - }, [metricsData.diskIO]); - - // GPU 图表数据(暂不支持,需要后端返回温度系列) - const gpuChartData = useMemo(() => { - if (metricsData.gpu.length === 0) return []; - - // 按时间戳聚合利用率和温度系列 - const timeMap = new Map(); - - const utilizationSeries = metricsData.gpu.find(s => s.name === 'utilization'); - const temperatureSeries = metricsData.gpu.find(s => s.name === 'temperature'); - - // 添加利用率数据 - utilizationSeries?.data.forEach(point => { - const time = new Date(point.timestamp).toLocaleTimeString('zh-CN', { - hour: '2-digit', - minute: '2-digit', - }); - timeMap.set(point.timestamp, { - time, - timestamp: point.timestamp, - utilization: Number(point.value.toFixed(2)), - }); - }); - - // 添加温度数据 - temperatureSeries?.data.forEach(point => { - const existing = timeMap.get(point.timestamp); - if (existing) { - existing.temperature = Number(point.value.toFixed(2)); - } - }); - - return Array.from(timeMap.values()); - }, [metricsData.gpu]); - - // Temperature 图表数据(按类型分组显示各类型的温度) - const temperatureChartData = useMemo(() => { - if (metricsData.temperature.length === 0) return []; - - // 按时间戳聚合所有温度系列 - const timeMap = new Map(); - - metricsData.temperature.forEach(series => { - const sensorName = series.name; // 使用系列名称作为传感器标识 - series.data.forEach(point => { - const time = new Date(point.timestamp).toLocaleTimeString('zh-CN', { - hour: '2-digit', - minute: '2-digit', - }); - - if (!timeMap.has(point.timestamp)) { - timeMap.set(point.timestamp, {time, timestamp: point.timestamp}); - } - - const existing = timeMap.get(point.timestamp)!; - existing[sensorName] = Number(point.value.toFixed(2)); - }); - }); - - return Array.from(timeMap.values()); - }, [metricsData.temperature]); - - // 提取所有唯一的温度类型(用于图表 Line 渲染和下拉选择器) - const temperatureTypes = useMemo(() => { - return metricsData.temperature.map(s => s.name).sort(); - }, [metricsData.temperature]); - - // 网络连接图表数据 - const networkConnectionChartData = useMemo(() => { - if (metricsData.networkConnection.length === 0) return []; - - // 按时间戳聚合所有连接状态系列 - const timeMap = new Map(); - - metricsData.networkConnection.forEach(series => { - const stateName = series.name; // established, time_wait, close_wait, listen - series.data.forEach(point => { - const time = new Date(point.timestamp).toLocaleTimeString('zh-CN', { - hour: '2-digit', - minute: '2-digit', - }); - - if (!timeMap.has(point.timestamp)) { - timeMap.set(point.timestamp, {time, timestamp: point.timestamp}); - } - - const existing = timeMap.get(point.timestamp)!; - // 转换为驼峰命名以匹配图表的 dataKey - const camelCaseName = stateName.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()); - existing[camelCaseName] = Number(point.value.toFixed(0)); - }); - }); - - return Array.from(timeMap.values()); - }, [metricsData.networkConnection]); - - // 根据选中的类型过滤温度数据 - const filteredTemperatureTypes = useMemo(() => { - if (selectedTempType === 'all') { - return temperatureTypes; - } - return temperatureTypes.filter(type => type === selectedTempType); - }, [temperatureTypes, selectedTempType]); - - // 当温度类型列表变化时,如果当前选中的类型不在列表中,重置为 'all' - useEffect(() => { - if (selectedTempType !== 'all' && temperatureTypes.length > 0) { - if (!temperatureTypes.includes(selectedTempType)) { - setSelectedTempType('all'); - } - } - }, [temperatureTypes, selectedTempType]); - - const snapshotCards: SnapshotCardData[] = useMemo(() => { - if (!latestMetrics) { - return [] as Array<{ - key: string; - icon: typeof Cpu; - title: string; - usagePercent: string; - accent: AccentVariant; - metrics: Array<{ label: string; value: ReactNode }>; - }>; - } - - const cards: Array<{ - key: string; - icon: typeof Cpu; - title: string; - usagePercent: string; - accent: AccentVariant; - metrics: Array<{ label: string; value: ReactNode }>; - }> = []; - - cards.push({ - key: 'cpu', - icon: Cpu, - title: 'CPU 使用', - usagePercent: `${formatPercentValue(latestMetrics.cpu?.usagePercent)}%`, - accent: 'blue', - metrics: [ - {label: '当前使用', value: `${formatPercentValue(latestMetrics.cpu?.usagePercent)}%`}, - { - label: '采样时间', - value: latestMetrics.cpu ? formatDateTime(latestMetrics.cpu.timestamp) : '-', - }, - ], - }); - - cards.push({ - key: 'memory', - icon: MemoryStick, - title: '内存使用', - usagePercent: `${formatPercentValue(latestMetrics.memory?.usagePercent)}%`, - accent: 'emerald', - metrics: [ - { - label: '已用 / 总量', - value: `${formatBytes(latestMetrics.memory?.used)} / ${formatBytes(latestMetrics.memory?.total)}` - }, - { - label: 'Swap 已用', - value: `${formatBytes(latestMetrics.memory?.swapUsed)} / ${formatBytes(latestMetrics.memory?.swapTotal)}` - }, - ], - }); - - cards.push({ - key: 'disk', - icon: HardDrive, - title: '磁盘使用', - usagePercent: latestMetrics.disk - ? `${formatPercentValue(latestMetrics.disk.usagePercent)}%` - : '—', - accent: 'purple', - metrics: [ - { - label: '已用 / 总量', - value: `${formatBytes(latestMetrics.disk?.used)} / ${formatBytes(latestMetrics.disk?.total)}` - }, - {label: '磁盘数量', value: latestMetrics.disk?.totalDisks ?? '-'}, - ], - }); - - cards.push({ - key: 'network', - icon: Network, - title: '网络流量', - usagePercent: latestMetrics.network - ? `${formatBytes(latestMetrics.network.totalBytesSentRate)}/s` - : '—', - accent: 'amber', - metrics: [ - { - label: '上行 / 下行', - value: `${formatBytes(latestMetrics.network?.totalBytesSentRate)}/s ↑ / ${formatBytes( - latestMetrics.network?.totalBytesRecvRate, - )}/s ↓`, - }, - { - label: '网络累计', - value: `${formatBytes(latestMetrics.network?.totalBytesSentTotal)} ↑ / ${formatBytes( - latestMetrics.network?.totalBytesRecvTotal, - )} ↓`, - }, - ], - }); - - // 如果配置了流量限额,添加流量统计卡片 - if (agent && agent.trafficLimit && agent.trafficLimit > 0) { - const trafficUsedPercent = ((agent.trafficUsed || 0) / agent.trafficLimit) * 100; - const trafficRemaining = agent.trafficLimit - (agent.trafficUsed || 0); - - cards.push({ - key: 'traffic', - icon: Database, - title: '流量统计', - usagePercent: `${formatPercentValue(trafficUsedPercent)}%`, - accent: 'blue', - metrics: [ - { - label: '已用 / 总量', - value: `${formatBytes(agent.trafficUsed || 0)} / ${formatBytes(agent.trafficLimit)}`, - }, - { - label: '剩余流量', - value: `${formatBytes(trafficRemaining > 0 ? trafficRemaining : 0)}`, - }, - ...(agent.trafficResetDay && agent.trafficResetDay > 0 - ? [{ - label: '重置日期', - value: `每月${agent.trafficResetDay}号`, - }] - : []), - ], - }); - } - - return cards; - }, [latestMetrics, agent]); - - const platformDisplay = latestMetrics?.host?.platform - ? `${latestMetrics.host.platform} ${latestMetrics.host.platformVersion || ''}`.trim() - : agent?.os || '-'; - const architectureDisplay = latestMetrics?.host?.kernelArch || agent?.arch || '-'; - const uptimeDisplay = formatUptime(latestMetrics?.host?.uptime); - const bootTimeDisplay = latestMetrics?.host?.bootTime - ? formatDateTime(latestMetrics.host.bootTime * 1000) - : '-'; - const lastSeenDisplay = agent ? formatDateTime(agent.lastSeenAt) : '-'; - const displayName = agent?.name?.trim() ? agent.name : '未命名探针'; - const isOnline = agent?.status === 1; - const statusDotStyles = isOnline ? 'bg-emerald-500' : 'bg-rose-500'; - const statusText = isOnline ? '在线' : '离线'; - - const networkSummary = latestMetrics?.network - ? `${formatBytes(latestMetrics.network.totalBytesSentTotal)} ↑ / ${formatBytes( - latestMetrics.network.totalBytesRecvTotal, - )} ↓` - : '—'; - - const environmentInfo = [ - {label: '操作系统', value: platformDisplay || '-'}, - {label: '内核版本', value: latestMetrics?.host?.kernelVersion || '-'}, - {label: '硬件架构', value: architectureDisplay || '-'}, - {label: 'CPU 型号', value: latestMetrics?.cpu?.modelName || '-'}, - {label: '逻辑核心', value: latestMetrics?.cpu?.logicalCores ?? '-'}, - {label: '物理核心', value: latestMetrics?.cpu?.physicalCores ?? '-'}, - ]; - - const statusInfo = [ - {label: '启动时间', value: bootTimeDisplay}, - {label: '运行时间', value: uptimeDisplay}, - {label: '最近心跳', value: lastSeenDisplay}, - {label: '进程数', value: latestMetrics?.host?.procs ?? '-'}, - {label: '网络累计', value: networkSummary}, - ]; - - const heroStats = [ - {label: '运行系统', value: platformDisplay || '-'}, - {label: '硬件架构', value: architectureDisplay || '-'}, - {label: '最近心跳', value: lastSeenDisplay}, - {label: '运行时长', value: uptimeDisplay}, - ]; - - if (loading) { + if (isLoading) { return ; } @@ -483,674 +46,67 @@ const ServerDetail = () => { return (
-
-
-
-
-
- -
-
- -
-
-
-

{displayName}

- - - {statusText} - -
-

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

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

运行环境

-

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

-
- -
-
-
-

运行状态

-

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

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

网络连接统计

-

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

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

- - - - CPU 使用率 -

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

- - - - 内存使用率 -

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

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

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

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

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

- - - - 网络连接统计 -

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

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

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

- - - - 系统温度 -

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

GPU {gpu.index}

-

{gpu.name}

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

{temp.type}

-
-

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

-
- ))} -
-
- )} +
diff --git a/web/src/types/index.ts b/web/src/types/index.ts index b72c78b..238d8ac 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -311,6 +311,7 @@ export interface PublicMonitor { // 探针监控统计 export interface AgentMonitorStat { agentId: string; + agentName: string; monitorId: string; type: string; target: string; From 74179374df8cc5ef9db858a676700bba6c5b4463 Mon Sep 17 00:00:00 2001 From: dushixiang Date: Sun, 14 Dec 2025 21:15:11 +0800 Subject: [PATCH 16/18] =?UTF-8?q?=E4=BC=98=E5=8C=96=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- go.mod | 1 + go.sum | 2 ++ pkg/agent/service/agent.go | 22 ++++++++++--------- web/src/components/PublicHeader.tsx | 4 ++-- .../components/monitor/ResponseTimeChart.tsx | 2 +- web/src/components/server/charts/CpuChart.tsx | 2 +- .../components/server/charts/DiskIOChart.tsx | 6 ++--- web/src/components/server/charts/GpuChart.tsx | 6 ++--- .../components/server/charts/MemoryChart.tsx | 2 +- .../components/server/charts/NetworkChart.tsx | 6 ++--- .../server/charts/NetworkConnectionChart.tsx | 4 ++-- .../server/charts/TemperatureChart.tsx | 6 ++--- web/src/pages/Public/MonitorList.tsx | 4 ++-- 13 files changed, 36 insertions(+), 31 deletions(-) diff --git a/go.mod b/go.mod index ce29f53..92942ba 100644 --- a/go.mod +++ b/go.mod @@ -72,6 +72,7 @@ require ( github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rogpeppe/go-internal v1.13.1 // indirect github.com/sagikazarmark/locafero v0.12.0 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/spf13/viper v1.21.0 // indirect diff --git a/go.sum b/go.sum index a6e703e..0dd1304 100644 --- a/go.sum +++ b/go.sum @@ -149,6 +149,8 @@ github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88ee github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= github.com/shirou/gopsutil/v4 v4.25.11 h1:X53gB7muL9Gnwwo2evPSE+SfOrltMoR6V3xJAXZILTY= github.com/shirou/gopsutil/v4 v4.25.11/go.mod h1:EivAfP5x2EhLp2ovdpKSozecVXn1TmuG7SMzs/Wh4PU= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= diff --git a/pkg/agent/service/agent.go b/pkg/agent/service/agent.go index 4cfa179..317b7a9 100644 --- a/pkg/agent/service/agent.go +++ b/pkg/agent/service/agent.go @@ -21,6 +21,7 @@ import ( "github.com/dushixiang/pika/pkg/version" "github.com/gorilla/websocket" "github.com/jpillora/backoff" + "github.com/sourcegraph/conc" ) // 定义特殊错误类型 @@ -193,35 +194,36 @@ func (a *Agent) runOnce(ctx context.Context, onConnected func()) error { done := make(chan struct{}) errChan := make(chan error, 3) + var wg conc.WaitGroup // 启动读取循环(处理服务端的 Ping/Pong 等控制消息) - go func() { + wg.Go(func() { if err := a.readLoop(rawConn, done); err != nil { errChan <- fmt.Errorf("读取失败: %w", err) } - }() + }) // 启动心跳和数据发送 - go func() { + wg.Go(func() { if err := a.heartbeatLoop(ctx, conn, done); err != nil { errChan <- fmt.Errorf("心跳失败: %w", err) } - }() + }) - go func() { + wg.Go(func() { if err := a.metricsLoop(ctx, conn, collectorManager, done); err != nil { errChan <- fmt.Errorf("数据采集失败: %w", err) } - }() + }) // 启动防篡改事件监控 - go func() { + wg.Go(func() { a.tamperEventLoop(ctx, conn, done) - }() + }) // 启动防篡改属性告警监控 - go func() { + wg.Go(func() { a.tamperAlertLoop(ctx, conn, done) - }() + }) // 等待错误或上下文取消 select { diff --git a/web/src/components/PublicHeader.tsx b/web/src/components/PublicHeader.tsx index f7ad674..68aa441 100644 --- a/web/src/components/PublicHeader.tsx +++ b/web/src/components/PublicHeader.tsx @@ -63,10 +63,10 @@ const PublicHeader = () => {

- {window.SystemConfig?.SystemNameZh} + {window.SystemConfig?.SystemNameEn}

- {window.SystemConfig?.SystemNameEn} + {window.SystemConfig?.SystemNameZh}

diff --git a/web/src/components/monitor/ResponseTimeChart.tsx b/web/src/components/monitor/ResponseTimeChart.tsx index 9f92f0f..c15e69d 100644 --- a/web/src/components/monitor/ResponseTimeChart.tsx +++ b/web/src/components/monitor/ResponseTimeChart.tsx @@ -116,7 +116,7 @@ export const ResponseTimeChart = ({monitorId, monitorStats}: ResponseTimeChartPr if (!historyData?.series) return []; // 过滤出响应时间指标的 series - const responseTimeSeries = historyData.series.filter(s => s.name === 'response_time'); + const responseTimeSeries = historyData.series?.filter(s => s.name === 'response_time'); // 根据选择的探针过滤(使用 agent_name,如果没有则fallback到 agent_id) const filteredSeries = selectedAgent === 'all' diff --git a/web/src/components/server/charts/CpuChart.tsx b/web/src/components/server/charts/CpuChart.tsx index 01ff0e9..fc20cdb 100644 --- a/web/src/components/server/charts/CpuChart.tsx +++ b/web/src/components/server/charts/CpuChart.tsx @@ -23,7 +23,7 @@ export const CpuChart = ({agentId, timeRange}: CpuChartProps) => { // 数据转换 const chartData = useMemo(() => { - const cpuSeries = metricsResponse?.data.series.find(s => s.name === 'usage'); + const cpuSeries = metricsResponse?.data.series?.find(s => s.name === 'usage'); if (!cpuSeries) return []; return cpuSeries.data.map((point) => ({ diff --git a/web/src/components/server/charts/DiskIOChart.tsx b/web/src/components/server/charts/DiskIOChart.tsx index e27e8f9..4932eda 100644 --- a/web/src/components/server/charts/DiskIOChart.tsx +++ b/web/src/components/server/charts/DiskIOChart.tsx @@ -23,10 +23,10 @@ export const DiskIOChart = ({agentId, timeRange}: DiskIOChartProps) => { // 数据转换 const chartData = useMemo(() => { - if (!metricsResponse?.data.series || metricsResponse.data.series.length === 0) return []; + 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'); + const readSeries = metricsResponse.data.series?.find(s => s.name === 'read'); + const writeSeries = metricsResponse.data.series?.find(s => s.name === 'write'); if (!readSeries || !writeSeries) return []; diff --git a/web/src/components/server/charts/GpuChart.tsx b/web/src/components/server/charts/GpuChart.tsx index 4b8aba2..bf2bff7 100644 --- a/web/src/components/server/charts/GpuChart.tsx +++ b/web/src/components/server/charts/GpuChart.tsx @@ -24,13 +24,13 @@ export const GpuChart = ({agentId, timeRange}: GpuChartProps) => { // 数据转换 const chartData = useMemo(() => { - if (!metricsResponse?.data.series || metricsResponse.data.series.length === 0) return []; + 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'); + const utilizationSeries = metricsResponse.data.series?.find(s => s.name === 'utilization'); + const temperatureSeries = metricsResponse.data.series?.find(s => s.name === 'temperature'); // 添加利用率数据 utilizationSeries?.data.forEach(point => { diff --git a/web/src/components/server/charts/MemoryChart.tsx b/web/src/components/server/charts/MemoryChart.tsx index 2c5159a..ca644b8 100644 --- a/web/src/components/server/charts/MemoryChart.tsx +++ b/web/src/components/server/charts/MemoryChart.tsx @@ -23,7 +23,7 @@ export const MemoryChart = ({agentId, timeRange}: MemoryChartProps) => { // 数据转换 const chartData = useMemo(() => { - const memorySeries = metricsResponse?.data.series.find(s => s.name === 'usage'); + const memorySeries = metricsResponse?.data.series?.find(s => s.name === 'usage'); if (!memorySeries) return []; return memorySeries.data.map((point) => ({ diff --git a/web/src/components/server/charts/NetworkChart.tsx b/web/src/components/server/charts/NetworkChart.tsx index b3550c8..fafc604 100644 --- a/web/src/components/server/charts/NetworkChart.tsx +++ b/web/src/components/server/charts/NetworkChart.tsx @@ -41,10 +41,10 @@ export const NetworkChart = ({agentId, timeRange}: NetworkChartProps) => { // 数据转换 const chartData = useMemo(() => { - if (!metricsResponse?.data.series || metricsResponse.data.series.length === 0) return []; + 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'); + const uploadSeries = metricsResponse.data.series?.find(s => s.name === 'upload'); + const downloadSeries = metricsResponse.data.series?.find(s => s.name === 'download'); if (!uploadSeries || !downloadSeries) return []; diff --git a/web/src/components/server/charts/NetworkConnectionChart.tsx b/web/src/components/server/charts/NetworkConnectionChart.tsx index c56bfad..76f3b0b 100644 --- a/web/src/components/server/charts/NetworkConnectionChart.tsx +++ b/web/src/components/server/charts/NetworkConnectionChart.tsx @@ -23,12 +23,12 @@ export const NetworkConnectionChart = ({agentId, timeRange}: NetworkConnectionCh // 数据转换 const chartData = useMemo(() => { - if (!metricsResponse?.data.series || metricsResponse.data.series.length === 0) return []; + if (!metricsResponse?.data.series || metricsResponse.data.series?.length === 0) return []; // 按时间戳聚合所有连接状态系列 const timeMap = new Map(); - metricsResponse.data.series.forEach(series => { + 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', { diff --git a/web/src/components/server/charts/TemperatureChart.tsx b/web/src/components/server/charts/TemperatureChart.tsx index b08d6f3..8108074 100644 --- a/web/src/components/server/charts/TemperatureChart.tsx +++ b/web/src/components/server/charts/TemperatureChart.tsx @@ -27,12 +27,12 @@ export const TemperatureChart = ({agentId, timeRange}: TemperatureChartProps) => // 数据转换 const chartData = useMemo(() => { - if (!metricsResponse?.data.series || metricsResponse.data.series.length === 0) return []; + if (!metricsResponse?.data.series || metricsResponse.data.series?.length === 0) return []; // 按时间戳聚合所有温度系列 const timeMap = new Map(); - metricsResponse.data.series.forEach(series => { + metricsResponse.data.series?.forEach(series => { const sensorName = series.name; // 使用系列名称作为传感器标识 series.data.forEach(point => { const time = new Date(point.timestamp).toLocaleTimeString('zh-CN', { @@ -54,7 +54,7 @@ export const TemperatureChart = ({agentId, timeRange}: TemperatureChartProps) => // 提取所有唯一的温度类型 const temperatureTypes = useMemo(() => { - return metricsResponse?.data.series.map(s => s.name).sort() || []; + return metricsResponse?.data.series?.map(s => s.name).sort() || []; }, [metricsResponse]); // 根据选中的类型过滤温度数据 diff --git a/web/src/pages/Public/MonitorList.tsx b/web/src/pages/Public/MonitorList.tsx index e7759a2..859fb9b 100644 --- a/web/src/pages/Public/MonitorList.tsx +++ b/web/src/pages/Public/MonitorList.tsx @@ -80,13 +80,13 @@ const MonitorCard = ({monitor, displayMode, onClick}: { // 将 VictoriaMetrics 的时序数据转换为图表数据 const chartData = useMemo(() => { - if (!historyData || !historyData.series || historyData.series.length === 0) { + if (!historyData || !historyData.series || historyData.series?.length === 0) { return []; } const timeMap = new Map(); - historyData.series.forEach(series => { + historyData.series?.forEach(series => { if (series.data && series.data.length > 0) { series.data.forEach(point => { if (!timeMap.has(point.timestamp)) { From 53c771d965a5be6f1634466ef528e305cf63b7d2 Mon Sep 17 00:00:00 2001 From: dushixiang Date: Sun, 14 Dec 2025 22:17:39 +0800 Subject: [PATCH 17/18] =?UTF-8?q?=E4=BC=98=E5=8C=96=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/agent/service/agent.go | 53 ++- web/src/components/CyberCard.tsx | 39 ++ web/src/components/NetworkStatCard.tsx | 53 +++ web/src/components/PublicHeader.tsx | 217 +++++++---- web/src/components/ServerCard.tsx | 181 ++++++++++ web/src/components/common/Card.tsx | 10 +- web/src/components/common/CustomTooltip.tsx | 2 +- .../components/monitor/AgentStatsTable.tsx | 176 +++++---- web/src/components/monitor/MonitorHero.tsx | 130 +++---- .../components/monitor/ResponseTimeChart.tsx | 272 ++++++-------- web/src/components/server/ServerHero.tsx | 7 +- .../components/server/SystemInfoSection.tsx | 13 +- web/src/constants/colors.ts | 15 + web/src/constants/server.ts | 43 +++ web/src/constants/time.ts | 22 ++ web/src/pages/Public/MonitorList.tsx | 154 ++++---- web/src/pages/Public/ServerList.tsx | 337 +++++++++--------- 17 files changed, 1023 insertions(+), 701 deletions(-) create mode 100644 web/src/components/CyberCard.tsx create mode 100644 web/src/components/NetworkStatCard.tsx create mode 100644 web/src/components/ServerCard.tsx create mode 100644 web/src/constants/colors.ts create mode 100644 web/src/constants/server.ts create mode 100644 web/src/constants/time.ts diff --git a/pkg/agent/service/agent.go b/pkg/agent/service/agent.go index 317b7a9..6ca3500 100644 --- a/pkg/agent/service/agent.go +++ b/pkg/agent/service/agent.go @@ -190,28 +190,40 @@ func (a *Agent) runOnce(ctx context.Context, onConnected func()) error { a.setActiveConn(nil) }() - // 创建完成通道 + // 创建完成通道和错误通道 done := make(chan struct{}) - errChan := make(chan error, 3) + errChan := make(chan error, 1) // 只需要接收第一个错误 + // 创建 WaitGroup 用于等待所有 goroutine 退出 var wg conc.WaitGroup + // 启动读取循环(处理服务端的 Ping/Pong 等控制消息) wg.Go(func() { if err := a.readLoop(rawConn, done); err != nil { - errChan <- fmt.Errorf("读取失败: %w", err) + select { + case errChan <- fmt.Errorf("读取失败: %w", err): + default: + } } }) // 启动心跳和数据发送 wg.Go(func() { if err := a.heartbeatLoop(ctx, conn, done); err != nil { - errChan <- fmt.Errorf("心跳失败: %w", err) + select { + case errChan <- fmt.Errorf("心跳失败: %w", err): + default: + } } }) + // 启动指标采集循环 wg.Go(func() { if err := a.metricsLoop(ctx, conn, collectorManager, done); err != nil { - errChan <- fmt.Errorf("数据采集失败: %w", err) + select { + case errChan <- fmt.Errorf("数据采集失败: %w", err): + default: + } } }) @@ -225,23 +237,32 @@ func (a *Agent) runOnce(ctx context.Context, onConnected func()) error { a.tamperAlertLoop(ctx, conn, done) }) - // 等待错误或上下文取消 + // 等待第一个错误或上下文取消 + var returnErr error select { case err := <-errChan: - close(done) // 连接已建立,无论什么原因断开都标记为已建立状态 log.Printf("连接断开: %v", err) - return ErrConnectionEstablished + returnErr = ErrConnectionEstablished case <-ctx.Done(): - close(done) - // 优雅关闭连接 - closeMsg := websocket.FormatCloseMessage(websocket.CloseNormalClosure, "") - if err := conn.WriteMessage(websocket.CloseMessage, closeMsg); err != nil { - log.Printf("⚠️ 关闭连接失败: %v", err) - } - time.Sleep(time.Second) - return ctx.Err() // 返回上下文错误 + // 收到取消信号 + log.Println("收到停止信号,准备关闭连接") + returnErr = ctx.Err() } + + // 关闭 done channel,通知所有 goroutine 退出 + close(done) + + // 发送 WebSocket 关闭消息 + closeMsg := websocket.FormatCloseMessage(websocket.CloseNormalClosure, "") + if err := conn.WriteMessage(websocket.CloseMessage, closeMsg); err != nil { + log.Printf("⚠️ 发送关闭消息失败: %v", err) + } + + // 等待所有 goroutine 优雅退出 + wg.Wait() + + return returnErr } // readLoop 读取服务端消息(主要用于处理 Ping/Pong 和指令) diff --git a/web/src/components/CyberCard.tsx b/web/src/components/CyberCard.tsx new file mode 100644 index 0000000..c919d1c --- /dev/null +++ b/web/src/components/CyberCard.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import {cn} from "@/lib/utils.ts"; + +interface Props { + className?: string + children: React.ReactNode; + animation?: boolean + hover?: boolean +} + +const CyberCard = ({className, children, animation, hover}: Props) => { + return ( +
+ {/* 装饰性边框 */} +
+
+
+
+ {animation && +
+ } + +
+ {children} +
+
+ ); +}; + +export default CyberCard; \ No newline at end of file diff --git a/web/src/components/NetworkStatCard.tsx b/web/src/components/NetworkStatCard.tsx new file mode 100644 index 0000000..6b46fc1 --- /dev/null +++ b/web/src/components/NetworkStatCard.tsx @@ -0,0 +1,53 @@ +import type {FC} from 'react'; +import {ArrowDown, ArrowUp, Network} from 'lucide-react'; +import {formatBytes, formatSpeed} from '@/utils/util'; + +interface NetworkStatCardProps { + uploadRate: number; + downloadRate: number; + uploadTotal: number; + downloadTotal: number; +} + +const NetworkStatCard: FC = ({ + uploadRate, + downloadRate, + uploadTotal, + downloadTotal +}) => { + return ( +
+
+ +
+
+
+
+ 网络统计 +
+
+
+ + {formatSpeed(uploadRate)} + + ({formatBytes(uploadTotal)}) + +
+
+ + {formatSpeed(downloadRate)} + + ({formatBytes(downloadTotal)}) + +
+
+
+
+ +
+
+
+ ); +}; + +export default NetworkStatCard; diff --git a/web/src/components/PublicHeader.tsx b/web/src/components/PublicHeader.tsx index 68aa441..d62c734 100644 --- a/web/src/components/PublicHeader.tsx +++ b/web/src/components/PublicHeader.tsx @@ -1,11 +1,12 @@ import {useEffect, useState} from 'react'; -import {Activity, LogIn, ServerIcon, Settings} from 'lucide-react'; +import {Activity, LogIn, ServerIcon, Settings, Menu, X} from 'lucide-react'; import {getCurrentUser} from '../api/auth'; import {Link, useLocation} from "react-router-dom"; const PublicHeader = () => { const [isLoggedIn, setIsLoggedIn] = useState(false); const [currentTime, setCurrentTime] = useState(new Date()); + const [mobileMenuOpen, setMobileMenuOpen] = useState(false); let location = useLocation(); useEffect(() => { @@ -47,89 +48,159 @@ const PublicHeader = () => { } return ( -
-
-
-
-
- {'logo'} { - e.currentTarget.src = '/logo.png'; - }} - /> -
-
-

- {window.SystemConfig?.SystemNameEn} -

-

- {window.SystemConfig?.SystemNameZh} -

+ <> +
+
+
+ +
+
+ {'logo'} { + e.currentTarget.src = '/logo.png'; + }} + /> +
+
+

+ {window.SystemConfig?.SystemNameEn} +

+

+ {window.SystemConfig?.SystemNameZh} +

+
+
+ + + {/* HUD Navigation - Desktop Only */} +
+ {[ + {id: 'servers', icon: ServerIcon, label: '设备监控', to: '/'}, + {id: 'monitors', icon: Activity, label: '服务监控', to: '/monitors'} + ].map(tab => ( + + + + ))}
- {/* HUD Navigation - Simplified & No Border */} -
+ {/* Desktop Right Section */} +
+
+ {currentTime.toLocaleTimeString()} + {currentTime.toLocaleDateString()} +
+
+ + {/* 登录/管理后台按钮 - Desktop */} + {isLoggedIn ? ( + + + Admin + + ) : ( + + + Login + + )} +
+ + {/* Mobile Menu Button */} + +
+
+
+ + {/* Mobile Menu */} + {mobileMenuOpen && ( +
+
+ {/* Mobile Navigation */} {[ {id: 'servers', icon: ServerIcon, label: '设备监控', to: '/'}, {id: 'monitors', icon: Activity, label: '服务监控', to: '/monitors'} ].map(tab => ( - - + setMobileMenuOpen(false)} + className={` + flex items-center gap-3 p-4 rounded-lg border transition-all + ${activeTab === tab.id + ? 'bg-cyan-500/20 border-cyan-500/50 text-cyan-400' + : 'bg-cyan-500/5 border-cyan-500/20 text-slate-400 hover:bg-cyan-500/10 hover:border-cyan-500/30' + } + `} + > + + {tab.label} ))} + + {/* Divider */} +
+ + {/* Mobile Login/Admin Button */} + {isLoggedIn ? ( + + + 管理后台 + + ) : ( + + + 登录 + + )}
- -
-
- {currentTime.toLocaleTimeString()} - {currentTime.toLocaleDateString()} -
-
- - {/* 登录/管理后台按钮 */} - {isLoggedIn ? ( - - - Admin - - ) : ( - - - Login - - )} -
-
-
-
+ )} + ); }; diff --git a/web/src/components/ServerCard.tsx b/web/src/components/ServerCard.tsx new file mode 100644 index 0000000..fb40c05 --- /dev/null +++ b/web/src/components/ServerCard.tsx @@ -0,0 +1,181 @@ +import type {FC} from 'react'; +import { + AlertTriangle, + ArrowDown, + ArrowUp, + Calendar, + Cpu, + HardDrive, + MemoryStick +} from 'lucide-react'; +import type {Agent, LatestMetrics} from '@/types'; +import CompactResourceBar from '@/components/CompactResourceBar'; +import {formatBytes, formatSpeed} from '@/utils/util'; + +interface AgentWithMetrics extends Agent { + metrics?: LatestMetrics; +} + +interface ServerCardProps { + server: AgentWithMetrics; + onClick: (serverId: string) => void; +} + +const calculateNetworkSpeed = (metrics?: LatestMetrics) => { + if (!metrics?.network) { + return {upload: 0, download: 0}; + } + return { + upload: metrics.network.totalBytesSentRate, + download: metrics.network.totalBytesRecvRate + }; +}; + +const calculateDiskUsage = (metrics?: LatestMetrics) => { + if (!metrics?.disk) { + return 0; + } + return metrics.disk.usagePercent; +}; + +const ServerCard: FC = ({server, onClick}) => { + const isOnline = server.status === 1; + const cpuUsage = server.metrics?.cpu?.usagePercent ?? 0; + const memoryUsage = server.metrics?.memory?.usagePercent ?? 0; + const memoryTotal = server.metrics?.memory?.total ?? 0; + const memoryUsed = server.metrics?.memory?.used ?? 0; + const diskUsage = calculateDiskUsage(server.metrics); + const diskTotal = server.metrics?.disk?.total ?? 0; + const diskUsed = server.metrics?.disk?.used ?? 0; + const {upload, download} = calculateNetworkSpeed(server.metrics); + + return ( +
onClick(server.id)} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + onClick(server.id); + } + }} + className="group bg-[#0f1016]/80 backdrop-blur-md border border-cyan-500/20 shadow-[0_0_15px_rgba(6,182,212,0.05)] hover:border-cyan-500/50 hover:shadow-[0_0_25px_rgba(6,182,212,0.15)] hover:bg-[#0f1016]/90 transition-all duration-300 cursor-pointer overflow-hidden relative" + > + {/* 装饰性边框 */} +
+
+
+
+
+ +
+ {/* 顶部:名称和状态 */} +
+
+
+ {server.name || server.hostname} +
+
+ {server.os} + + {server.arch} +
+
+ {server.tags && server.tags.length > 0 && ( +
+ {server.tags.slice(0, 2).map(tag => ( + + #{tag} + + ))} + {server.tags.length > 2 && ( + + +{server.tags.length - 2} + + )} +
+ )} +
+ + {/* 资源使用情况 */} + {isOnline ? ( +
+ + + +
+ ) : ( +
+ + 离线 +
+ )} + + {/* 网络和流量 */} +
+
+ + + {formatSpeed(download)} + + + + {formatSpeed(upload)} + +
+ {server.expireTime > 0 && ( +
+ + {new Date(server.expireTime).toLocaleDateString('zh-CN')} +
+ )} +
+ + {/* 流量限制 */} + {server.trafficLimit > 0 && ( +
+
+ 流量使用 + {Math.round((server.trafficUsed || 0) / server.trafficLimit * 100)}% +
+
+
+
+
+ )} +
+
+ ); +}; + +export default ServerCard; diff --git a/web/src/components/common/Card.tsx b/web/src/components/common/Card.tsx index 396445d..d68587f 100644 --- a/web/src/components/common/Card.tsx +++ b/web/src/components/common/Card.tsx @@ -1,5 +1,6 @@ import type {ReactNode} from 'react'; import {cn} from '@/lib/utils'; +import CyberCard from "@/components/CyberCard.tsx"; interface CardProps { title?: string; @@ -19,12 +20,7 @@ export const Card = ({ const isDark = variant === 'dark'; return ( -
+ {(title || description || action) && (
)}
{children}
-
+ ); }; diff --git a/web/src/components/common/CustomTooltip.tsx b/web/src/components/common/CustomTooltip.tsx index 47453a7..0a15fbd 100644 --- a/web/src/components/common/CustomTooltip.tsx +++ b/web/src/components/common/CustomTooltip.tsx @@ -33,7 +33,7 @@ export const CustomTooltip = ({active, payload, label, variant = 'light', unit = return (
- {/* 装饰性边框 */} -
-
-
-
+ +
+

探针监控详情

+

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

+
-
-
-

探针监控详情

-

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

-
- -
- - - - - - - - {monitorType === 'https' && ( - - )} +
+
- 探针名称 - - 状态 - - 响应时间 - - 最后检测 - - 证书信息 -
+ + + + + + + {monitorType === 'https' && ( - - - - {monitorStats.map((stat, index) => { - const color = AGENT_COLORS[index % AGENT_COLORS.length]; - return ( - - + + + + {monitorStats.map((stat, index) => { + const color = AGENT_COLORS[index % AGENT_COLORS.length]; + return ( + + - - + + - - {monitorType === 'https' && ( - - )} + + + + {monitorType === 'https' && ( - - ); - })} - -
+ 探针名称 + + 状态 + + 响应时间 + + 最后检测 + - 错误信息 + 证书信息
-
+ )} +
+ 错误信息 +
+
-
- - +
+ + {stat.agentName || stat.agentId.substring(0, 8)} -
-
- - -
- - +
+
+ + +
+ + {formatTime(stat.responseTime)} -
-
- {formatDateTime(stat.checkedAt)} - - {stat.certExpiryTime ? ( - - ) : ( - - - )} - + {formatDateTime(stat.checkedAt)} + - {stat.status === 'down' && stat.message ? ( -
- - - {stat.message} - -
+ {stat.certExpiryTime ? ( + ) : ( - )}
-
+ )} + + {stat.status === 'down' && stat.message ? ( +
+ + + {stat.message} + +
+ ) : ( + - + )} + + + ); + })} + +
-
+ ); }; diff --git a/web/src/components/monitor/MonitorHero.tsx b/web/src/components/monitor/MonitorHero.tsx index 9f4ada4..50f092d 100644 --- a/web/src/components/monitor/MonitorHero.tsx +++ b/web/src/components/monitor/MonitorHero.tsx @@ -4,6 +4,7 @@ import {StatusBadge} from './StatusBadge'; import {CertBadge} from './CertBadge'; import LittleStatCard from '@/components/common/LittleStatCard'; import type {PublicMonitor} from '@/types'; +import CyberCard from "@/components/CyberCard.tsx"; interface MonitorHeroProps { monitor: PublicMonitor; @@ -16,83 +17,68 @@ interface MonitorHeroProps { */ export const MonitorHero = ({monitor, onBack}: MonitorHeroProps) => { return ( -
- {/* 装饰性边框 */} -
-
-
-
-
+ + {/* 返回按钮 */} + -
- {/* 返回按钮 */} - - - {/* 监控信息 */} -
-
-
- -
-
-
- MONITOR_ID: {monitor.id.toString().substring(0, 8)} -
-
-

{monitor.name}

- -
-

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

-
+ {/* 监控信息 */} +
+
+
+
- - {/* 统计卡片 */} -
- - - - +
+
+ MONITOR_ID: {monitor.id.toString().substring(0, 8)} +
+
+

{monitor.name}

+ +
+

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

- {/* 证书信息(如果是 HTTPS)*/} - {monitor.type === 'https' && monitor.certExpiryTime && ( -
- SSL 证书: - -
- )} + {/* 统计卡片 */} +
+ + + + +
-
+ + {/* 证书信息(如果是 HTTPS)*/} + {monitor.type === 'https' && monitor.certExpiryTime && ( +
+ SSL 证书: + +
+ )} + ); }; diff --git a/web/src/components/monitor/ResponseTimeChart.tsx b/web/src/components/monitor/ResponseTimeChart.tsx index c15e69d..533051a 100644 --- a/web/src/components/monitor/ResponseTimeChart.tsx +++ b/web/src/components/monitor/ResponseTimeChart.tsx @@ -1,86 +1,25 @@ 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'; +import CyberCard from "@/components/CyberCard.tsx"; +import {ChartPlaceholder, CustomTooltip, TimeRangeSelector} from "@/components/common"; interface ResponseTimeChartProps { monitorId: string; monitorStats: AgentMonitorStat[]; } -// 自定义 Tooltip 组件 -const CustomTooltip = ({active, payload, label}: any) => { - if (!active || !payload || payload.length === 0) return null; - - return ( -
-

{label}

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

{subtitle}

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

响应时间趋势

-

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

-
-
- - {availableAgents.length > 0 && ( - - )} -
+ +
+
+

响应时间趋势

+

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

+
+ + {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`} - /> - }/> - + {chartData.length > 0 ? ( + + + {monitorStats .filter(stat => { const agentIdentifier = stat.agentName || stat.agentId; @@ -254,30 +139,73 @@ export const ResponseTimeChart = ({monitorId, monitorStats}: ResponseTimeChartPr 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 ( - + + + + ); })} - - - ) : ( - - )} -
-
+ + + + `${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/server/ServerHero.tsx b/web/src/components/server/ServerHero.tsx index d6366a7..37388c4 100644 --- a/web/src/components/server/ServerHero.tsx +++ b/web/src/components/server/ServerHero.tsx @@ -1,8 +1,9 @@ -import {ArrowLeft, Server} from 'lucide-react'; +import {ArrowLeft} from 'lucide-react'; import {cn} from '@/lib/utils'; import {formatBytes, formatDateTime, formatUptime} from '@/utils/util'; import type {Agent, LatestMetrics} from '@/types'; import LittleStatCard from '@/components/common/LittleStatCard'; +import CyberCard from "@/components/CyberCard.tsx"; interface ServerHeroProps { agent: Agent; @@ -41,7 +42,7 @@ export const ServerHero = ({agent, latestMetrics, onBack}: ServerHeroProps) => { ]; return ( -
+
@@ -91,6 +92,6 @@ export const ServerHero = ({agent, latestMetrics, onBack}: ServerHeroProps) => { 网络累计:{networkSummary}
-
+ ); }; diff --git a/web/src/components/server/SystemInfoSection.tsx b/web/src/components/server/SystemInfoSection.tsx index 162c1f5..557a7df 100644 --- a/web/src/components/server/SystemInfoSection.tsx +++ b/web/src/components/server/SystemInfoSection.tsx @@ -4,6 +4,7 @@ import {Card} from '@/components/common'; import {InfoGrid, SnapshotSection, type SnapshotCardData} from '@/components/server'; import {formatBytes, formatDateTime, formatPercentValue, formatUptime} from '@/utils/util'; import type {Agent, LatestMetrics} from '@/types'; +import CyberCard from "@/components/CyberCard.tsx"; type AccentVariant = 'blue' | 'emerald' | 'purple' | 'amber'; @@ -154,26 +155,26 @@ export const SystemInfoSection = ({agent, latestMetrics}: SystemInfoSectionProps } return ( - +
-
+

运行环境

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

-
-
+ +

运行状态

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

-
+
- +
); }; diff --git a/web/src/constants/colors.ts b/web/src/constants/colors.ts new file mode 100644 index 0000000..45b28ae --- /dev/null +++ b/web/src/constants/colors.ts @@ -0,0 +1,15 @@ +// 探针颜色方案(用于 MonitorDetail 区分不同探针) +export const AGENT_COLORS = [ + '#3b82f6', // blue + '#10b981', // emerald + '#f59e0b', // amber + '#8b5cf6', // violet + '#ec4899', // pink + '#06b6d4', // cyan + '#f97316', // orange + '#14b8a6', // teal +]; + +// 重新导出其他颜色配置 +export {INTERFACE_COLORS, TEMPERATURE_COLORS, ACCENT_THEMES} from './server'; +export type {TimeRangeOption} from '@/api/property.ts'; diff --git a/web/src/constants/server.ts b/web/src/constants/server.ts new file mode 100644 index 0000000..950171a --- /dev/null +++ b/web/src/constants/server.ts @@ -0,0 +1,43 @@ +// 网卡颜色配置(上行和下行使用不同的色调) +export const INTERFACE_COLORS = [ + {upload: '#6FD598', download: '#2C70F6'}, // 绿/蓝 + {upload: '#f59e0b', download: '#8b5cf6'}, // 橙/紫 + {upload: '#ec4899', download: '#06b6d4'}, // 粉/青 + {upload: '#10b981', download: '#f97316'}, // 翠绿/深橙 + {upload: '#14b8a6', download: '#2563eb'}, // 青绿/深蓝 +]; + +// 温度类型颜色映射 +export const TEMPERATURE_COLORS: Record = { + 'CPU': '#f97316', // 橙色 + 'GPU': '#8b5cf6', // 紫色 + 'DISK': '#06b6d4', // 青色 + 'BATTERY': '#10b981', // 绿色 + 'CHIPSET': '#f59e0b', // 琥珀色 + 'SYSTEM': '#6366f1', // 靛蓝色 + 'PSU': '#ec4899', // 粉色 +}; + +// 主题配色方案 +export const ACCENT_THEMES: Record<'blue' | 'emerald' | 'purple' | 'amber', { icon: string; badge: string; highlight: string }> = { + blue: { + icon: 'text-blue-400', + badge: 'text-blue-400', + highlight: 'text-blue-400', + }, + emerald: { + icon: 'text-emerald-400', + badge: 'text-emerald-400', + highlight: 'text-emerald-400', + }, + purple: { + icon: 'text-purple-400', + badge: 'text-purple-400', + highlight: 'text-purple-400', + }, + amber: { + icon: 'text-amber-400', + badge: 'text-amber-400', + highlight: 'text-amber-400', + }, +}; diff --git a/web/src/constants/time.ts b/web/src/constants/time.ts new file mode 100644 index 0000000..fd8fa55 --- /dev/null +++ b/web/src/constants/time.ts @@ -0,0 +1,22 @@ +import type {TimeRangeOption} from '@/api/property.ts'; + +// 服务器详情页时间范围选项 +export const SERVER_TIME_RANGE_OPTIONS: TimeRangeOption[] = [ + {label: '15分钟', value: '15m'}, + {label: '30分钟', value: '30m'}, + {label: '1小时', value: '1h'}, + {label: '3小时', value: '3h'}, + {label: '6小时', value: '6h'}, + {label: '12小时', value: '12h'}, + {label: '1天', value: '1d'}, + {label: '3天', value: '3d'}, + {label: '7天', value: '7d'}, +]; + +// 监控详情页时间范围选项 +export const MONITOR_TIME_RANGE_OPTIONS: TimeRangeOption[] = [ + {label: '12小时', value: '12h'}, + {label: '1天', value: '1d'}, + {label: '3天', value: '3d'}, + {label: '7天', value: '7d'}, +]; diff --git a/web/src/pages/Public/MonitorList.tsx b/web/src/pages/Public/MonitorList.tsx index 859fb9b..7e70346 100644 --- a/web/src/pages/Public/MonitorList.tsx +++ b/web/src/pages/Public/MonitorList.tsx @@ -1,5 +1,5 @@ import {useEffect, useMemo, useState} from 'react'; -import {useNavigate} from 'react-router-dom'; +import {Link, useNavigate} from 'react-router-dom'; import {useQuery} from '@tanstack/react-query'; import { Activity, @@ -24,6 +24,7 @@ import {StatusBadge} from '@/components/monitor/StatusBadge'; import {CertBadge} from '@/components/monitor/CertBadge'; import {MiniChart} from '@/components/monitor/MiniChart'; import StatCard from "@/components/StatCard.tsx"; +import CyberCard from "@/components/CyberCard.tsx"; const LoadingSpinner = () => (
@@ -62,10 +63,9 @@ const TypeIcon = ({type}: { type: string }) => { }; // 监控卡片组件 -const MonitorCard = ({monitor, displayMode, onClick}: { +const MonitorCard = ({monitor, displayMode}: { monitor: PublicMonitor; displayMode: DisplayMode; - onClick: () => void; }) => { // 为每个监控卡片查询历史数据 const {data: historyData} = useQuery({ @@ -119,91 +119,74 @@ const MonitorCard = ({monitor, displayMode, onClick}: { const displayLabel = displayMode === 'avg' ? '平均延迟' : '最差节点延迟'; return ( -
- {/* 装饰性边框 */} -
-
-
-
-
- -
- {/* 头部 */} -
-
-
- -
-
-
- TARGET_ID: {monitor.id.toString().substring(0, 8)} -
-

- {monitor.name} -

-
+ + {/* 头部 */} +
+
+
+
-
- +
+
+ TARGET_ID: {monitor.id.toString().substring(0, 8)} +
+

+ {monitor.name} +

+
+ +
+
- {/* 指标信息 */} -
-
-

- {displayLabel} - {monitor.agentCount > 0 && ( - + {/* 指标信息 */} +

+
+

+ {displayLabel} + {monitor.agentCount > 0 && ( + {monitor.agentCount} 节点 - )} -

-

200 ? 'text-amber-400' : 'text-cyan-100' - )}> - {displayValue} ms -

-
-
- {monitor.type === 'https' && monitor.certExpiryTime ? ( - <> -

SSL 证书

- - - ) : ( - <> -

上次检测

-

- {formatDateTime(monitor.lastCheckTime)} -

- )} -
+

+

200 ? 'text-amber-400' : 'text-cyan-100' + )}> + {displayValue} ms +

+
+
+ {monitor.type === 'https' && monitor.certExpiryTime ? ( + <> +

SSL 证书

+ + + ) : ( + <> +

上次检测

+

+ {formatDateTime(monitor.lastCheckTime)} +

+ + )}
- - {/* 迷你走势图 */} -
-
+ + {/* 迷你走势图 */} + + ); }; @@ -382,12 +365,13 @@ const MonitorList = () => { ) : (
{filteredMonitors.map(monitor => ( - navigate(`/monitors/${encodeURIComponent(monitor.id)}`)} - /> + + + ))}
)} diff --git a/web/src/pages/Public/ServerList.tsx b/web/src/pages/Public/ServerList.tsx index 20a2281..30e5904 100644 --- a/web/src/pages/Public/ServerList.tsx +++ b/web/src/pages/Public/ServerList.tsx @@ -12,7 +12,6 @@ import { HardDrive, Loader2, MemoryStick, - Network, Server, XCircle } from 'lucide-react'; @@ -21,6 +20,8 @@ import type {Agent, LatestMetrics} from '@/types'; import {cn} from '@/lib/utils'; import CompactResourceBar from "@/components/CompactResourceBar.tsx"; import StatCard from "@/components/StatCard.tsx"; +import ServerCard from "@/components/ServerCard.tsx"; +import NetworkStatCard from "@/components/NetworkStatCard.tsx"; import {formatBytes, formatSpeed} from "@/utils/util.ts"; interface AgentWithMetrics extends Agent { @@ -160,9 +161,9 @@ const ServerList = () => { } return ( -
+
{/* 统计卡片 */} -
+
{ icon={XCircle} color="rose" /> -
-
- -
-
-
-
网络统计 -
-
-
- - {formatSpeed(stats.uploadRate)} - ({formatBytes(stats.uploadTotal)}) -
-
- - {formatSpeed(stats.downloadRate)} - ({formatBytes(stats.downloadTotal)}) -
-
-
-
- -
-
-
+
{/* 标签过滤器 */} {allTags.length > 1 && ( -
-
+
+
- FILTERS: + FILTERS:
{allTags.map(tag => { const tagKey = tag === 'ALL' ? '' : tag; @@ -245,158 +225,171 @@ const ServerList = () => {
)} - {/* 服务器表格 */} + {/* 服务器列表 */} {displayAgents.length === 0 ? ( ) : ( -
- - - - - - - - - - - {displayAgents.map(server => { - const isOnline = server.status === 1; - const cpuUsage = server.metrics?.cpu?.usagePercent ?? 0; - const memoryUsage = server.metrics?.memory?.usagePercent ?? 0; - const memoryTotal = server.metrics?.memory?.total ?? 0; - const memoryUsed = server.metrics?.memory?.used ?? 0; - const diskUsage = calculateDiskUsage(server.metrics); - const diskTotal = server.metrics?.disk?.total ?? 0; - const diskUsed = server.metrics?.disk?.used ?? 0; - const {upload, download} = calculateNetworkSpeed(server.metrics); + <> + {/* 桌面端表格布局 */} +
+
System IdentityResource TelemetryNetwork I/OMeta / Status
+ + + + + + + + + + {displayAgents.map(server => { + const isOnline = server.status === 1; + const cpuUsage = server.metrics?.cpu?.usagePercent ?? 0; + const memoryUsage = server.metrics?.memory?.usagePercent ?? 0; + const memoryTotal = server.metrics?.memory?.total ?? 0; + const memoryUsed = server.metrics?.memory?.used ?? 0; + const diskUsage = calculateDiskUsage(server.metrics); + const diskTotal = server.metrics?.disk?.total ?? 0; + const diskUsed = server.metrics?.disk?.used ?? 0; + const {upload, download} = calculateNetworkSpeed(server.metrics); - return ( - handleNavigate(server.id)} - onKeyDown={(event) => { - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault(); - handleNavigate(server.id); - } - }} - className="group hover:bg-cyan-500/5 transition-colors cursor-pointer" - > - {/* Identity */} - handleNavigate(server.id)} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + handleNavigate(server.id); + } + }} + className="group hover:bg-cyan-500/5 transition-colors cursor-pointer" + > + {/* Identity */} + + + {/* Resources */} + + + {/* Network */} + + + {/* Meta */} + + + + ); + })} + +
System IdentityResource TelemetryNetwork I/OMeta / Status
-
-
+ return ( +
+
+
+
+ {server.name || server.hostname} +
+
+ {server.os} + + {server.arch} +
+
+
+
+ {isOnline ? ( +
+ + + +
+ ) : (
- {server.name || server.hostname} + className="text-xs text-rose-500 font-mono flex items-center gap-2 py-4"> + + CONNECTION_LOST // RECONNECTING... +
+ )} +
+
+ + + IN: {formatSpeed(download)} + + + + OUT: {formatSpeed(upload)} + +
+ {server.trafficLimit > 0 ? ( +
+
+ 流量使用 + {Math.round((server.trafficUsed || 0) / server.trafficLimit * 100)}% +
+
+
+
+
+ ) : ( +
-- NO METERING --
+ )} +
+
+
+ {server.tags && server.tags.length > 0 && server.tags.map(tag => ( + + #{tag} + + ))}
- {server.os} - - {server.arch} + className={`text-[10px] font-mono flex items-center gap-1 ${server.expireTime && server.expireTime > 0 ? 'text-cyan-600' : 'text-emerald-500/60'}`}> + + {server.expireTime > 0 + ? `EXP: ${new Date(server.expireTime).toLocaleDateString('zh-CN')}` + : '' + }
- -
+
- {/* Resources */} - - {isOnline ? ( -
- - - -
- ) : ( -
- - CONNECTION_LOST // RECONNECTING... -
- )} - - - {/* Network */} - -
- - - IN: {formatSpeed(download)} - - - - OUT: {formatSpeed(upload)} - -
- {server.trafficLimit > 0 ? ( -
-
- TRAFFIC - {Math.round((server.trafficUsed || 0) / server.trafficLimit * 100)}% -
-
-
-
-
- ) : ( -
-- NO METERING --
- )} - - - {/* Meta */} - -
-
- {server.tags && server.tags.length > 0 && server.tags.map(tag => ( - - #{tag} - - ))} -
-
0 ? 'text-cyan-600' : 'text-emerald-500/60'}`}> - - {server.expireTime && server.expireTime > 0 - ? `EXP: ${new Date(server.expireTime).toLocaleDateString('zh-CN')}` - : 'PERMANENT_NODE' - } -
-
- - - ); - })} - - -
+ {/* 移动端卡片布局 */} +
+ {displayAgents.map(server => ( + + ))} +
+ )}
); From 992aa29043b56bea8f9eb7fba249eada7c989202 Mon Sep 17 00:00:00 2001 From: dushixiang Date: Sun, 14 Dec 2025 22:44:21 +0800 Subject: [PATCH 18/18] =?UTF-8?q?=E4=BC=98=E5=8C=96=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/handler/agent_handler.go | 2 +- internal/service/metric_service.go | 79 +++++++---- internal/service/monitor_service.go | 7 - internal/vmclient/client.go | 31 ----- web/src/api/agent.ts | 2 +- web/src/components/CompactResourceBar.tsx | 6 +- web/src/components/server/SnapshotSection.tsx | 5 +- .../components/server/charts/MonitorChart.tsx | 131 ++++++++++++++++++ web/src/components/server/charts/index.ts | 1 + web/src/pages/Public/ServerDetail.tsx | 6 + web/src/pages/Public/ServerList.tsx | 10 +- 11 files changed, 204 insertions(+), 76 deletions(-) create mode 100644 web/src/components/server/charts/MonitorChart.tsx diff --git a/internal/handler/agent_handler.go b/internal/handler/agent_handler.go index 333df71..c92ae51 100644 --- a/internal/handler/agent_handler.go +++ b/internal/handler/agent_handler.go @@ -408,7 +408,7 @@ func (h *AgentHandler) GetMetrics(c echo.Context) error { // 验证指标类型 validTypes := map[string]bool{ "cpu": true, "memory": true, "disk": true, "network": true, "network_connection": true, - "disk_io": true, "gpu": true, "temperature": true, + "disk_io": true, "gpu": true, "temperature": true, "monitor": true, } if metricType == "" { return orz.NewError(400, "指标类型不能为空") diff --git a/internal/service/metric_service.go b/internal/service/metric_service.go index 3ec6216..4cb379b 100644 --- a/internal/service/metric_service.go +++ b/internal/service/metric_service.go @@ -23,6 +23,7 @@ type MetricService struct { logger *zap.Logger metricRepo *repo.MetricRepo agentRepo *repo.AgentRepo + monitorRepo *repo.MonitorRepo propertyService *PropertyService trafficService *TrafficService // 流量统计服务 vmClient *vmclient.VMClient @@ -38,6 +39,7 @@ func NewMetricService(logger *zap.Logger, db *gorm.DB, propertyService *Property logger: logger, metricRepo: repo.NewMetricRepo(db), agentRepo: repo.NewAgentRepo(db), + monitorRepo: repo.NewMonitorRepo(db), propertyService: propertyService, trafficService: trafficService, vmClient: vmClient, @@ -244,6 +246,45 @@ func (s *MetricService) GetMetrics(ctx context.Context, agentID, metricType stri series = append(series, convertedSeries...) } + // 如果是监控类型,添加监控任务名称到标签中 + if metricType == "monitor" && len(series) > 0 { + // 收集所有 monitor_id + monitorIdSet := make(map[string]struct{}) + for _, s := range series { + if monitorId, ok := s.Labels["monitor_id"]; ok { + monitorIdSet[monitorId] = struct{}{} + } + } + + // 查询 monitor 信息 + if len(monitorIdSet) > 0 { + monitorIds := make([]string, 0, len(monitorIdSet)) + for monitorId := range monitorIdSet { + monitorIds = append(monitorIds, monitorId) + } + + monitors, err := s.monitorRepo.FindByIdIn(ctx, monitorIds) + if err != nil { + s.logger.Error("查询 monitor 信息失败", zap.Error(err)) + } else { + // 构建 monitorId -> monitorName 映射 + monitorNameMap := make(map[string]string) + for _, monitor := range monitors { + monitorNameMap[monitor.ID] = monitor.Name + } + + // 在每个 series 的 labels 中添加 monitor_name + for i := range series { + if monitorId, ok := series[i].Labels["monitor_id"]; ok { + if monitorName, exists := monitorNameMap[monitorId]; exists { + series[i].Labels["monitor_name"] = monitorName + } + } + } + } + } + } + return &metric.GetMetricsResponse{ AgentID: agentID, Type: metricType, @@ -289,33 +330,7 @@ func (s *MetricService) DeleteAgentMetrics(ctx context.Context, agentID string) // 继续删除 VictoriaMetrics 中的数据 } - // 2. 删除 VictoriaMetrics 中的时间序列数据 - match := []string{fmt.Sprintf(`pika_.*{agent_id="%s"}`, agentID)} - if err := s.vmClient.DeleteSeries(ctx, match); err != nil { - s.logger.Error("删除 VictoriaMetrics 中的探针数据失败", - zap.String("agentID", agentID), - zap.Error(err)) - return err - } - - s.logger.Info("成功删除探针的所有指标数据", - zap.String("agentID", agentID)) - return nil -} - -// DeleteMonitorMetrics 删除指定监控任务的所有指标数据 -func (s *MetricService) DeleteMonitorMetrics(ctx context.Context, monitorID string) error { - // 删除 VictoriaMetrics 中的监控指标数据 - match := []string{fmt.Sprintf(`pika_monitor_.*{monitor_id="%s"}`, monitorID)} - if err := s.vmClient.DeleteSeries(ctx, match); err != nil { - s.logger.Error("删除 VictoriaMetrics 中的监控数据失败", - zap.String("monitorID", monitorID), - zap.Error(err)) - return err - } - - s.logger.Info("成功删除监控任务的所有指标数据", - zap.String("monitorID", monitorID)) + // 2. 不主动删除 VictoriaMetrics 中的时间序列数据,利用过期机制自动删除数据 return nil } @@ -430,6 +445,13 @@ func (s *MetricService) buildPromQLQueries(agentID, metricType string, interface Name: "temperature", Query: fmt.Sprintf(`pika_temperature_celsius{agent_id="%s"}`, agentID), }} + + case "monitor": + // 监控:响应时间(该探针参与的所有监控任务) + queries = []metric.QueryDefinition{{ + Name: "response_time", + Query: fmt.Sprintf(`pika_monitor_response_time_ms{agent_id="%s"}`, agentID), + }} } return queries @@ -492,6 +514,9 @@ func (s *MetricService) convertQueryResultToSeries(result *vmclient.QueryResult, delete(labels, "gpu_index") } + // 移除 target 标签(避免数据泄露) + delete(labels, "target") + allSeries = append(allSeries, metric.Series{ Name: finalName, Labels: labels, diff --git a/internal/service/monitor_service.go b/internal/service/monitor_service.go index f5c1e26..e3a0f89 100644 --- a/internal/service/monitor_service.go +++ b/internal/service/monitor_service.go @@ -187,13 +187,6 @@ func (s *MonitorService) DeleteMonitor(ctx context.Context, id string) error { if err := s.MonitorRepo.DeleteById(ctx, id); err != nil { return err } - - // 删除监控指标数据(从 VictoriaMetrics) - if err := s.metricService.DeleteMonitorMetrics(ctx, id); err != nil { - s.logger.Error("删除监控指标数据失败", zap.String("monitorId", id), zap.Error(err)) - return err - } - return nil }) diff --git a/internal/vmclient/client.go b/internal/vmclient/client.go index 8109848..c3896bc 100644 --- a/internal/vmclient/client.go +++ b/internal/vmclient/client.go @@ -220,37 +220,6 @@ func (c *VMClient) Query(ctx context.Context, query string) (*QueryResult, error return &result, nil } -// DeleteSeries 删除时间序列 -func (c *VMClient) DeleteSeries(ctx context.Context, match []string) error { - reqCtx, cancel := context.WithTimeout(ctx, c.writeTimeout) - defer cancel() - - params := url.Values{} - for _, m := range match { - params.Add("match[]", m) - } - - reqURL := fmt.Sprintf("%s/api/v1/admin/tsdb/delete_series?%s", c.baseURL, params.Encode()) - - req, err := http.NewRequestWithContext(reqCtx, "POST", reqURL, nil) - if err != nil { - return fmt.Errorf("create request failed: %w", err) - } - - resp, err := c.httpClient.Do(req) - if err != nil { - return fmt.Errorf("delete series failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return fmt.Errorf("delete series failed with status %d: %s", resp.StatusCode, string(body)) - } - - return nil -} - // ConvertToDataPoints 将查询结果转换为数据点列表 func ConvertToDataPoints(result *QueryResult) []DataPoint { if result == nil || len(result.Data.Result) == 0 { diff --git a/web/src/api/agent.ts b/web/src/api/agent.ts index d7802ed..76fe287 100644 --- a/web/src/api/agent.ts +++ b/web/src/api/agent.ts @@ -8,7 +8,7 @@ export interface ListAgentsResponse { export interface GetAgentMetricsRequest { agentId: string; - type: 'cpu' | 'memory' | 'disk' | 'network' | 'network_connection' | 'disk_io' | 'gpu' | 'temperature'; + type: 'cpu' | 'memory' | 'disk' | 'network' | 'network_connection' | 'disk_io' | 'gpu' | 'temperature' | 'monitor'; range?: string; // 时间范围,如 '15m', '1h', '1d' 等,从后端配置获取 interface?: string; // 网卡过滤参数(仅对 network 类型有效) } diff --git a/web/src/components/CompactResourceBar.tsx b/web/src/components/CompactResourceBar.tsx index 84540c7..c25e057 100644 --- a/web/src/components/CompactResourceBar.tsx +++ b/web/src/components/CompactResourceBar.tsx @@ -25,9 +25,9 @@ const CompactResourceBar = ({ value, label, subtext, icon: Icon, color = "bg-cya } return ( -
+
{/* Icon & Label */} -
+
{label}
@@ -60,7 +60,7 @@ const CompactResourceBar = ({ value, label, subtext, icon: Icon, color = "bg-cya
-
{value.toFixed(1)}%
+
{value.toFixed(1)}%
{/* Value & Subtext */}
diff --git a/web/src/components/server/SnapshotSection.tsx b/web/src/components/server/SnapshotSection.tsx index 6e3d254..12426a2 100644 --- a/web/src/components/server/SnapshotSection.tsx +++ b/web/src/components/server/SnapshotSection.tsx @@ -1,4 +1,5 @@ import {SnapshotGrid, type SnapshotCardData} from './SnapshotGrid'; +import CyberCard from "@/components/CyberCard.tsx"; interface SnapshotSectionProps { cards: SnapshotCardData[]; @@ -9,11 +10,11 @@ export const SnapshotSection = ({cards}: SnapshotSectionProps) => { return null; } return ( -
+

资源快照

-
+ ); }; diff --git a/web/src/components/server/charts/MonitorChart.tsx b/web/src/components/server/charts/MonitorChart.tsx new file mode 100644 index 0000000..0f7964d --- /dev/null +++ b/web/src/components/server/charts/MonitorChart.tsx @@ -0,0 +1,131 @@ +import {useMemo} from 'react'; +import {Activity} from 'lucide-react'; +import {Area, AreaChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis} from 'recharts'; +import {ChartPlaceholder, CustomTooltip} from '@/components/common'; +import {useMetricsQuery} from '@/hooks/server/queries'; +import {ChartContainer} from './ChartContainer'; + +interface MonitorChartProps { + agentId: string; + timeRange: string; +} + +/** + * 监控响应时间图表组件 + */ +export const MonitorChart = ({agentId, timeRange}: MonitorChartProps) => { + // 数据查询 + const {data: metricsResponse, isLoading} = useMetricsQuery({ + agentId, + type: 'monitor', + range: timeRange, + }); + + // 数据转换 - 支持多个监控任务 + const chartData = useMemo(() => { + const series = metricsResponse?.data.series || []; + if (series.length === 0) return []; + + // 收集所有时间戳 + const timestampMap = new Map(); + + series.forEach((s) => { + s.data.forEach((point) => { + if (!timestampMap.has(point.timestamp)) { + timestampMap.set(point.timestamp, { + time: new Date(point.timestamp).toLocaleTimeString('zh-CN', { + hour: '2-digit', + minute: '2-digit', + }), + timestamp: point.timestamp, + }); + } + + const dataPoint = timestampMap.get(point.timestamp); + // 使用 monitor_name 作为 key,如果没有则使用 monitor_id + const monitorKey = s.labels?.monitor_name || s.labels?.monitor_id || s.name; + dataPoint[monitorKey] = Number(point.value.toFixed(2)); + }); + }); + + // 转换为数组并排序 + return Array.from(timestampMap.values()).sort((a, b) => a.timestamp - b.timestamp); + }, [metricsResponse]); + + // 获取所有监控任务的列表(使用名称) + const monitorKeys = useMemo(() => { + const series = metricsResponse?.data.series || []; + return series.map(s => s.labels?.monitor_name || s.labels?.monitor_id || s.name); + }, [metricsResponse]); + + // 颜色配置 + const colors = ['#2563eb', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899']; + + // 如果没有数据且不是加载中,不渲染组件 + if (!isLoading && chartData.length === 0) { + return null; + } + + // 渲染 + if (isLoading) { + return ( + + + + ); + } + + return ( + + {chartData.length > 0 ? ( + + + + {monitorKeys.map((key, index) => ( + + + + + ))} + + + + `${value}ms`} + /> + }/> + {monitorKeys.length > 1 && ( + + )} + {monitorKeys.map((key, index) => ( + + ))} + + + ) : ( + + )} + + ); +}; diff --git a/web/src/components/server/charts/index.ts b/web/src/components/server/charts/index.ts index aba0a52..d3d7b5c 100644 --- a/web/src/components/server/charts/index.ts +++ b/web/src/components/server/charts/index.ts @@ -6,3 +6,4 @@ export {DiskIOChart} from './DiskIOChart'; export {NetworkConnectionChart} from './NetworkConnectionChart'; export {GpuChart} from './GpuChart'; export {TemperatureChart} from './TemperatureChart'; +export {MonitorChart} from './MonitorChart'; diff --git a/web/src/pages/Public/ServerDetail.tsx b/web/src/pages/Public/ServerDetail.tsx index 97585a2..c622d01 100644 --- a/web/src/pages/Public/ServerDetail.tsx +++ b/web/src/pages/Public/ServerDetail.tsx @@ -13,6 +13,7 @@ import { DiskIOChart, GpuChart, MemoryChart, + MonitorChart, NetworkChart, NetworkConnectionChart, TemperatureChart, @@ -96,6 +97,11 @@ const ServerDetail = () => {
+ + {/* 监控指标:单列全宽 */} +
+ +
diff --git a/web/src/pages/Public/ServerList.tsx b/web/src/pages/Public/ServerList.tsx index 30e5904..21ad8e2 100644 --- a/web/src/pages/Public/ServerList.tsx +++ b/web/src/pages/Public/ServerList.tsx @@ -364,10 +364,12 @@ const ServerList = () => {
0 ? 'text-cyan-600' : 'text-emerald-500/60'}`}> - - {server.expireTime > 0 - ? `EXP: ${new Date(server.expireTime).toLocaleDateString('zh-CN')}` - : '' + + {server.expireTime > 0 && +
+ +
过期时间: {new Date(server.expireTime).toLocaleDateString('zh-CN')}
+
}