优化公共页面&增加自动迁移

This commit is contained in:
dushixiang committed 2025-12-14 19:41:55 +08:00
1 parent 36eaae9c40
commit c0790e9ced
34 files changed
+3449 -1598

No files matched your search

+174 -2
View File
@@ -1,7 +1,179 @@
package main
import "github.com/dushixiang/pika/internal"
import (
"fmt"
"log"
"os"
"time"
"github.com/dushixiang/pika/internal"
"github.com/dushixiang/pika/internal/config"
v0_0_13 "github.com/dushixiang/pika/internal/migrate/v0_0_13"
"github.com/dushixiang/pika/internal/vmclient"
"github.com/go-orz/orz"
"github.com/spf13/cobra"
"go.uber.org/zap"
"gorm.io/gorm"
)
var (
configFile string
rootCmd = &cobra.Command{
Use: "pika-server",
Short: "Pika 服务器监控系统",
Long: `Pika 是一个轻量级的服务器监控系统,支持多探针部署和实时监控。`,
Run: func(cmd *cobra.Command, args []string) {
internal.Run(configFile)
},
}
serveCmd = &cobra.Command{
Use: "serve",
Short: "启动 Pika 服务器",
Long: `启动 Pika HTTP 服务器,提供 Web 界面和 API 服务。`,
Run: func(cmd *cobra.Command, args []string) {
internal.Run(configFile)
},
}
migrateCmd = &cobra.Command{
Use: "migrate_v0_0_13",
Short: "执行数据库迁移",
Long: `将历史 PostgreSQL 指标数据迁移到 VictoriaMetrics(仅需执行一次)。`,
Run: func(cmd *cobra.Command, args []string) {
runMigration(configFile)
},
}
)
func init() {
// 添加全局配置文件参数
rootCmd.PersistentFlags().StringVarP(&configFile, "config", "c", "./config.yaml", "配置文件路径")
// 添加子命令
rootCmd.AddCommand(serveCmd)
rootCmd.AddCommand(migrateCmd)
}
func main() {
internal.Run("./config.yaml")
if err := rootCmd.Execute(); err != nil {
fmt.Fprintf(os.Stderr, "错误: %v\n", err)
os.Exit(1)
}
}
// runMigration 执行数据迁移
func runMigration(configPath string) {
fmt.Println("=== Pika 数据迁移工具 ===")
fmt.Println("配置文件:", configPath)
fmt.Println()
// 初始化 orz 以获取数据库连接和配置
var db *gorm.DB
var vmClient *vmclient.VMClient
var logger *zap.Logger
err := orz.Quick(configPath, func(app *orz.App) error {
logger = app.Logger()
db = app.GetDatabase()
// 读取 VictoriaMetrics 配置
var appConfig config.AppConfig
_config := app.GetConfig()
if _config != nil {
if err := _config.App.Unmarshal(&appConfig); err != nil {
return fmt.Errorf("读取配置失败: %w", err)
}
}
// 初始化 VictoriaMetrics 客户端
vmClient = provideVMClient(&appConfig, logger)
return nil
})
if err != nil {
log.Fatalf("初始化失败: %v", err)
}
// 执行迁移
fmt.Println("开始迁移历史指标数据到 VictoriaMetrics...")
fmt.Println("说明: 数据将从 PostgreSQL 迁移到 VictoriaMetrics。")
fmt.Println("警告: 建议在迁移前备份 PostgreSQL 数据!")
fmt.Println()
fmt.Print("是否在迁移完成后自动删除旧的数据表?(yes/no): ")
var deleteOldTables bool
var answer string
fmt.Scanln(&answer)
if answer == "yes" || answer == "y" || answer == "Y" {
deleteOldTables = true
fmt.Println("✓ 将在迁移完成后自动删除旧表")
} else {
deleteOldTables = false
fmt.Println("✓ 将保留旧表,您可以稍后手动删除")
}
fmt.Println()
startTime := time.Now()
// 执行数据迁移
if err := v0_0_13.Migrate(db, vmClient); err != nil {
logger.Error("迁移失败", zap.Error(err))
log.Fatalf("迁移失败: %v", err)
}
elapsed := time.Since(startTime)
fmt.Println()
fmt.Printf("✓ 数据迁移成功完成!耗时: %s\n", elapsed)
// 根据用户选择决定是否删除旧表
if deleteOldTables {
fmt.Println()
fmt.Println("正在删除旧的指标数据表...")
if err := v0_0_13.DropOldTables(db); err != nil {
logger.Error("删除旧表失败", zap.Error(err))
log.Printf("警告: 删除旧表失败: %v\n", err)
fmt.Println("注意: 旧表删除失败,但数据已成功迁移,您可以手动删除这些表。")
} else {
fmt.Println("✓ 旧表删除完成")
fmt.Println()
fmt.Println("✓ 迁移任务全部完成!")
}
} else {
fmt.Println()
fmt.Println("提示: 旧数据表已保留,您可以验证数据后手动删除。")
fmt.Println("提示: 如需删除旧表,可以重新运行此命令并选择删除。")
}
fmt.Println()
fmt.Println("提示: 现在可以启动服务器: ./pika-server serve")
}
// provideVMClient 提供 VictoriaMetrics 客户端
func provideVMClient(cfg *config.AppConfig, logger *zap.Logger) *vmclient.VMClient {
// 检查配置
if cfg.VictoriaMetrics == nil || !cfg.VictoriaMetrics.Enabled {
logger.Info("VictoriaMetrics is not enabled, using default configuration")
// 返回一个默认配置的客户端(用于本地开发)
return vmclient.NewVMClient("http://localhost:8428", 30*time.Second, 60*time.Second)
}
// 使用配置创建客户端
writeTimeout := time.Duration(cfg.VictoriaMetrics.WriteTimeout) * time.Second
if writeTimeout == 0 {
writeTimeout = 30 * time.Second
}
queryTimeout := time.Duration(cfg.VictoriaMetrics.QueryTimeout) * time.Second
if queryTimeout == 0 {
queryTimeout = 60 * time.Second
}
logger.Info("VictoriaMetrics client initialized",
zap.String("url", cfg.VictoriaMetrics.URL),
zap.Duration("writeTimeout", writeTimeout),
zap.Duration("queryTimeout", queryTimeout))
return vmclient.NewVMClient(cfg.VictoriaMetrics.URL, writeTimeout, queryTimeout)
}
+2 -2
View File
@@ -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:
+355
View File
@@ -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"
}
+802
View File
@@ -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, &timestamp)
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, &timestamp)
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, &timestamp)
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, &timestamp)
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, &timestamp)
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, &timestamp)
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},
}
}
+1
View File
@@ -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"` // 监控目标
+61 -3
View File
@@ -22,6 +22,7 @@ import (
type MetricService struct {
logger *zap.Logger
metricRepo *repo.MetricRepo
agentRepo *repo.AgentRepo
propertyService *PropertyService
trafficService *TrafficService // 流量统计服务
vmClient *vmclient.VMClient
@@ -36,6 +37,7 @@ func NewMetricService(logger *zap.Logger, db *gorm.DB, propertyService *Property
return &MetricService{
logger: logger,
metricRepo: repo.NewMetricRepo(db),
agentRepo: repo.NewAgentRepo(db),
propertyService: propertyService,
trafficService: trafficService,
vmClient: vmClient,
@@ -529,6 +531,42 @@ func (s *MetricService) GetMonitorHistory(ctx context.Context, monitorID string,
series = append(series, convertedSeries...)
}
// 收集所有 agent_id
agentIdSet := make(map[string]struct{})
for _, s := range series {
if agentId, ok := s.Labels["agent_id"]; ok {
agentIdSet[agentId] = struct{}{}
}
}
// 查询 agent 信息
if len(agentIdSet) > 0 {
agentIds := make([]string, 0, len(agentIdSet))
for agentId := range agentIdSet {
agentIds = append(agentIds, agentId)
}
agents, err := s.agentRepo.FindByIdIn(ctx, agentIds)
if err != nil {
s.logger.Error("查询 agent 信息失败", zap.Error(err))
} else {
// 构建 agentId -> agentName 映射
agentNameMap := make(map[string]string)
for _, agent := range agents {
agentNameMap[agent.ID] = agent.Name
}
// 在每个 series 的 labels 中添加 agent_name
for i := range series {
if agentId, ok := series[i].Labels["agent_id"]; ok {
if agentName, exists := agentNameMap[agentId]; exists {
series[i].Labels["agent_name"] = agentName
}
}
}
}
}
return &metric.GetMetricsResponse{
AgentID: "", // 监控查询不限定单个agent
Type: "monitor",
@@ -546,10 +584,30 @@ func (s *MetricService) GetMonitorAgentStats(monitorID string) []protocol.Monito
return []protocol.MonitorData{}
}
// 转换为数组
// 收集所有 agentId
agentIds := make([]string, 0, len(latestMetrics.Agents))
for agentId := range latestMetrics.Agents {
agentIds = append(agentIds, agentId)
}
// 查询 agent 信息
ctx := context.Background()
agents, err := s.agentRepo.FindByIdIn(ctx, agentIds)
if err != nil {
s.logger.Error("查询 agent 信息失败", zap.Error(err))
}
// 构建 agentId -> agentName 映射
agentNameMap := make(map[string]string)
for _, agent := range agents {
agentNameMap[agent.ID] = agent.Name
}
// 转换为数组并填充 agent 名称
result := make([]protocol.MonitorData, 0, len(latestMetrics.Agents))
for _, stat := range latestMetrics.Agents {
stat.Target = ""
for agentId, stat := range latestMetrics.Agents {
stat.Target = "" // 隐藏目标地址
stat.AgentName = agentNameMap[agentId] // 填充 agent 名称
result = append(result, *stat)
}
@@ -0,0 +1,140 @@
import {AlertCircle, Clock, MapPin} from 'lucide-react';
import {StatusBadge} from './StatusBadge';
import {CertBadge} from './CertBadge';
import {AGENT_COLORS} from '@/constants/colors';
import {formatDateTime, formatTime} from '@/utils/util';
import type {AgentMonitorStat} from '@/types';
interface AgentStatsTableProps {
monitorStats: AgentMonitorStat[];
monitorType: string;
}
/**
* 探针监控统计表格组件
* 显示各探针的当前状态和统计数据
*/
export const AgentStatsTable = ({monitorStats, monitorType}: AgentStatsTableProps) => {
if (monitorStats.length === 0) {
return (
<div className="text-center py-12 text-cyan-600">
<p className="text-sm font-mono">暂无探针数据</p>
</div>
);
}
return (
<div
className="group bg-[#0f1016]/80 backdrop-blur-md border border-cyan-500/20 shadow-[0_0_15px_rgba(6,182,212,0.05)] hover:border-cyan-500/30 hover:shadow-[0_0_25px_rgba(6,182,212,0.1)] transition-all duration-300 overflow-hidden relative">
{/* 装饰性边框 */}
<div
className="absolute top-0 left-0 w-3 h-3 border-t-2 border-l-2 border-cyan-500/30 group-hover:border-cyan-400/50 transition-colors"></div>
<div
className="absolute top-0 right-0 w-3 h-3 border-t-2 border-r-2 border-cyan-500/30 group-hover:border-cyan-400/50 transition-colors"></div>
<div
className="absolute bottom-0 left-0 w-3 h-3 border-b-2 border-l-2 border-cyan-500/30 group-hover:border-cyan-400/50 transition-colors"></div>
<div
className="absolute bottom-0 right-0 w-3 h-3 border-b-2 border-r-2 border-cyan-500/30 group-hover:border-cyan-400/50 transition-colors"></div>
<div className="relative z-10 p-6">
<div className="mb-6">
<h3 className="text-lg font-bold tracking-wide text-cyan-100 uppercase">探针监控详情</h3>
<p className="text-xs text-cyan-600 mt-1 font-mono">各探针的当前状态和统计数据</p>
</div>
<div className="overflow-x-auto -mx-6 px-6">
<table className="min-w-full">
<thead>
<tr className="border-b border-cyan-900/50">
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-widest text-cyan-500/60 font-mono">
探针名称
</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-widest text-cyan-500/60 font-mono">
状态
</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-widest text-cyan-500/60 font-mono">
响应时间
</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-widest text-cyan-500/60 font-mono hidden lg:table-cell">
最后检测
</th>
{monitorType === 'https' && (
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-widest text-cyan-500/60 font-mono hidden xl:table-cell">
证书信息
</th>
)}
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-widest text-cyan-500/60 font-mono hidden xl:table-cell">
错误信息
</th>
</tr>
</thead>
<tbody className="divide-y divide-cyan-900/30">
{monitorStats.map((stat, index) => {
const color = AGENT_COLORS[index % AGENT_COLORS.length];
return (
<tr key={stat.agentId}
className="hover:bg-cyan-950/20 transition-colors">
<td className="px-4 py-4">
<div className="flex items-center gap-3">
<span
className="inline-block h-2 w-2 rounded-full flex-shrink-0"
style={{backgroundColor: color}}
/>
<div className="flex items-center gap-2">
<MapPin className="h-3.5 w-3.5 text-cyan-600"/>
<span className="font-mono text-sm text-cyan-200">
{stat.agentName || stat.agentId.substring(0, 8)}
</span>
</div>
</div>
</td>
<td className="px-4 py-4">
<StatusBadge status={stat.status}/>
</td>
<td className="px-4 py-4">
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-cyan-600"/>
<span className="text-sm font-semibold text-cyan-100 font-mono">
{formatTime(stat.responseTime)}
</span>
</div>
</td>
<td className="px-4 py-4 text-sm text-cyan-400 font-mono hidden lg:table-cell">
{formatDateTime(stat.checkedAt)}
</td>
{monitorType === 'https' && (
<td className="px-4 py-4 hidden xl:table-cell">
{stat.certExpiryTime ? (
<CertBadge
expiryTime={stat.certExpiryTime}
daysLeft={stat.certDaysLeft}
/>
) : (
<span className="text-xs text-cyan-600">-</span>
)}
</td>
)}
<td className="px-4 py-4 hidden xl:table-cell">
{stat.status === 'down' && stat.message ? (
<div className="flex items-start gap-2 max-w-xs">
<AlertCircle
className="h-4 w-4 text-rose-400 flex-shrink-0 mt-0.5"/>
<span
className="text-xs text-rose-300 break-words line-clamp-2 font-mono">
{stat.message}
</span>
</div>
) : (
<span className="text-xs text-cyan-600">-</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</div>
);
};
@@ -0,0 +1,98 @@
import {ArrowLeft} from 'lucide-react';
import {TypeIcon} from './TypeIcon';
import {StatusBadge} from './StatusBadge';
import {CertBadge} from './CertBadge';
import LittleStatCard from '@/components/common/LittleStatCard';
import type {PublicMonitor} from '@/types';
interface MonitorHeroProps {
monitor: PublicMonitor;
onBack: () => void;
}
/**
* 监控详情头部组件
* 显示监控基本信息、状态和关键指标
*/
export const MonitorHero = ({monitor, onBack}: MonitorHeroProps) => {
return (
<div
className="group bg-[#0f1016]/80 backdrop-blur-md border border-cyan-500/20 shadow-[0_0_15px_rgba(6,182,212,0.05)] hover:border-cyan-500/30 hover:shadow-[0_0_25px_rgba(6,182,212,0.1)] transition-all duration-300 overflow-hidden relative">
{/* 装饰性边框 */}
<div
className="absolute top-0 left-0 w-3 h-3 border-t-2 border-l-2 border-cyan-500/30 group-hover:border-cyan-400/50 transition-colors"></div>
<div
className="absolute top-0 right-0 w-3 h-3 border-t-2 border-r-2 border-cyan-500/30 group-hover:border-cyan-400/50 transition-colors"></div>
<div
className="absolute bottom-0 left-0 w-3 h-3 border-b-2 border-l-2 border-cyan-500/30 group-hover:border-cyan-400/50 transition-colors"></div>
<div
className="absolute bottom-0 right-0 w-3 h-3 border-b-2 border-r-2 border-cyan-500/30 group-hover:border-cyan-400/50 transition-colors"></div>
<div
className="absolute inset-0 bg-gradient-to-b from-transparent via-cyan-500/5 to-transparent -translate-y-full group-hover:translate-y-full transition-transform duration-1000 ease-in-out pointer-events-none"/>
<div className="relative z-10 p-6 space-y-6">
{/* 返回按钮 */}
<button
type="button"
onClick={onBack}
className="group inline-flex items-center gap-2 text-xs font-medium uppercase tracking-wider text-cyan-600 hover:text-cyan-400 transition font-mono"
>
<ArrowLeft className="h-4 w-4 transition-transform group-hover:-translate-x-1"/>
返回监控列表
</button>
{/* 监控信息 */}
<div className="flex flex-col gap-6 lg:flex-row lg:items-start lg:justify-between">
<div className="flex items-start gap-4 flex-1 min-w-0">
<div className="p-3 bg-cyan-950/30 border border-cyan-500/20 rounded-lg flex-shrink-0">
<TypeIcon type={monitor.type}/>
</div>
<div className="flex-1 min-w-0">
<div className="text-[10px] font-mono text-cyan-500/60 mb-1 tracking-wider">
MONITOR_ID: {monitor.id.toString().substring(0, 8)}
</div>
<div className="flex flex-wrap items-center gap-3 mb-2">
<h1 className="text-2xl sm:text-3xl font-bold truncate text-cyan-100 tracking-wide">{monitor.name}</h1>
<StatusBadge status={monitor.status}/>
</div>
<p className="text-sm text-cyan-500/80 font-mono truncate">
{monitor.showTargetPublic ? monitor.target : '******'}
</p>
</div>
</div>
{/* 统计卡片 */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 w-full lg:w-auto lg:min-w-[480px]">
<LittleStatCard
label="监控类型"
value={monitor.type.toUpperCase()}
/>
<LittleStatCard
label="探针数量"
value={monitor.agentCount}
/>
<LittleStatCard
label="平均响应"
value={`${monitor.responseTime}ms`}
/>
<LittleStatCard
label="最慢响应"
value={`${monitor.responseTimeMax}ms`}
/>
</div>
</div>
{/* 证书信息(如果是 HTTPS)*/}
{monitor.type === 'https' && monitor.certExpiryTime && (
<div className="flex items-center gap-2 pt-4 border-t border-cyan-900/50">
<span className="text-xs text-cyan-500/60 font-mono">SSL 证书:</span>
<CertBadge
expiryTime={monitor.certExpiryTime}
daysLeft={monitor.certDaysLeft}
/>
</div>
)}
</div>
</div>
);
};
@@ -0,0 +1,283 @@
import {useEffect, useMemo, useState} from 'react';
import {useQuery} from '@tanstack/react-query';
import {Shield} from 'lucide-react';
import {Area, AreaChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis} from 'recharts';
import {type GetMetricsResponse, getMonitorHistory} from '@/api/monitor';
import {AGENT_COLORS} from '@/constants/colors';
import {MONITOR_TIME_RANGE_OPTIONS} from '@/constants/time';
import {cn} from '@/lib/utils';
import type {AgentMonitorStat} from '@/types';
interface ResponseTimeChartProps {
monitorId: string;
monitorStats: AgentMonitorStat[];
}
// 自定义 Tooltip 组件
const CustomTooltip = ({active, payload, label}: any) => {
if (!active || !payload || payload.length === 0) return null;
return (
<div
className="bg-[#0a0b10]/95 backdrop-blur-xl border border-cyan-500/30 rounded-lg p-3 shadow-[0_0_20px_rgba(6,182,212,0.2)]">
<p className="text-xs text-cyan-400 mb-2 font-mono">{label}</p>
<div className="space-y-1">
{payload.map((entry: any, index: number) => (
<div key={index} className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full" style={{backgroundColor: entry.color}}></span>
<span className="text-xs text-cyan-100 font-mono">
{entry.name}: <span className="font-bold">{entry.value} ms</span>
</span>
</div>
))}
</div>
</div>
);
};
// 图表占位符组件
const ChartPlaceholder = ({subtitle, heightClass}: { subtitle: string; heightClass: string }) => (
<div
className={cn("flex items-center justify-center border-2 border-dashed border-cyan-900/50 rounded-lg", heightClass)}>
<div className="text-center text-cyan-600">
<Shield className="h-12 w-12 mx-auto mb-3 opacity-20"/>
<p className="text-sm font-mono">{subtitle}</p>
</div>
</div>
);
// 时间范围选择器组件
const TimeRangeSelector = ({
value,
onChange,
options
}: {
value: string;
onChange: (value: string) => void;
options: Array<{ value: string; label: string }>;
}) => (
<div className="flex gap-1 bg-black/40 p-1 rounded-lg border border-cyan-900/50">
{options.map(option => (
<button
key={option.value}
onClick={() => onChange(option.value)}
className={cn(
"px-3 py-1.5 text-xs font-medium rounded transition-all font-mono cursor-pointer",
value === option.value
? 'bg-cyan-500/20 text-cyan-300 border border-cyan-500/30'
: 'text-cyan-600 hover:text-cyan-400'
)}
>
{option.label}
</button>
))}
</div>
);
/**
* 响应时间趋势图表组件
* 显示监控各探针的响应时间变化
*/
export const ResponseTimeChart = ({monitorId, monitorStats}: ResponseTimeChartProps) => {
const [selectedAgent, setSelectedAgent] = useState<string>('all');
const [timeRange, setTimeRange] = useState<string>('1d');
// 获取历史数据
const {data: historyData} = useQuery<GetMetricsResponse>({
queryKey: ['monitorHistory', monitorId, timeRange],
queryFn: async () => {
if (!monitorId) throw new Error('Monitor ID is required');
const response = await getMonitorHistory(monitorId, timeRange);
return response.data;
},
refetchInterval: 30000,
enabled: !!monitorId,
});
// 获取所有可用的探针列表
const availableAgents = useMemo(() => {
if (monitorStats.length === 0) return [];
return monitorStats.map(stat => ({
id: stat.agentId,
name: stat.agentName || stat.agentId.substring(0, 8),
}));
}, [monitorStats]);
// 当可用探针列表变化时,检查当前选择的探针是否还存在
useEffect(() => {
if (selectedAgent === 'all') return;
if (!availableAgents.find(agent => agent.id === selectedAgent)) {
setSelectedAgent('all');
}
}, [availableAgents, selectedAgent]);
// 生成图表数据
const chartData = useMemo(() => {
if (!historyData?.series) return [];
// 过滤出响应时间指标的 series
const responseTimeSeries = historyData.series.filter(s => s.name === 'response_time');
// 根据选择的探针过滤(使用 agent_name,如果没有则fallback到 agent_id)
const filteredSeries = selectedAgent === 'all'
? responseTimeSeries
: responseTimeSeries.filter(s => {
// 优先使用 agent_name,如果没有则使用 agent_id
const agentIdentifier = s.labels?.agent_name || s.labels?.agent_id;
return agentIdentifier === selectedAgent;
});
if (filteredSeries.length === 0) return [];
// 按时间戳分组数据
const grouped: Record<number, any> = {};
filteredSeries.forEach(series => {
// 优先使用 agent_name,如果没有则使用 agent_id
const agentIdentifier = series.labels?.agent_name || series.labels?.agent_id || 'unknown';
const agentKey = `agent_${agentIdentifier}`;
series.data.forEach(point => {
if (!grouped[point.timestamp]) {
grouped[point.timestamp] = {
time: new Date(point.timestamp).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
}),
timestamp: point.timestamp,
};
}
grouped[point.timestamp][agentKey] = point.value;
});
});
// 按时间戳排序
return Object.values(grouped).sort((a, b) => a.timestamp - b.timestamp);
}, [historyData, selectedAgent]);
return (
<div
className="group bg-[#0f1016]/80 backdrop-blur-md border border-cyan-500/20 shadow-[0_0_15px_rgba(6,182,212,0.05)] hover:border-cyan-500/30 hover:shadow-[0_0_25px_rgba(6,182,212,0.1)] transition-all duration-300 overflow-hidden relative">
{/* 装饰性边框 */}
<div
className="absolute top-0 left-0 w-3 h-3 border-t-2 border-l-2 border-cyan-500/30 group-hover:border-cyan-400/50 transition-colors"></div>
<div
className="absolute top-0 right-0 w-3 h-3 border-t-2 border-r-2 border-cyan-500/30 group-hover:border-cyan-400/50 transition-colors"></div>
<div
className="absolute bottom-0 left-0 w-3 h-3 border-b-2 border-l-2 border-cyan-500/30 group-hover:border-cyan-400/50 transition-colors"></div>
<div
className="absolute bottom-0 right-0 w-3 h-3 border-b-2 border-r-2 border-cyan-500/30 group-hover:border-cyan-400/50 transition-colors"></div>
<div className="relative z-10 p-6">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6">
<div>
<h3 className="text-lg font-bold tracking-wide text-cyan-100 uppercase">响应时间趋势</h3>
<p className="text-xs text-cyan-600 mt-1 font-mono">监控各探针的响应时间变化</p>
</div>
<div className="flex flex-col sm:flex-row flex-wrap items-start sm:items-center gap-3">
<TimeRangeSelector
value={timeRange}
onChange={setTimeRange}
options={MONITOR_TIME_RANGE_OPTIONS}
/>
{availableAgents.length > 0 && (
<select
value={selectedAgent}
onChange={(e) => setSelectedAgent(e.target.value)}
className="rounded-lg border border-cyan-900/50 bg-black/40 px-3 py-2 text-xs font-medium text-cyan-300 hover:border-cyan-500/50 focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/20 transition-colors font-mono"
>
<option value="all">所有探针</option>
{availableAgents.map((agent) => (
<option key={agent.id} value={agent.name}>
{agent.name}
</option>
))}
</select>
)}
</div>
</div>
{chartData.length > 0 ? (
<ResponsiveContainer width="100%" height={360}>
<AreaChart data={chartData}>
<defs>
{monitorStats
.filter(stat => {
const agentIdentifier = stat.agentName || stat.agentId;
return selectedAgent === 'all' || agentIdentifier === selectedAgent;
})
.map((stat) => {
const originalIndex = monitorStats.findIndex(s => s.agentId === stat.agentId);
const agentIdentifier = stat.agentName || stat.agentId;
const agentKey = `agent_${agentIdentifier}`;
const color = AGENT_COLORS[originalIndex % AGENT_COLORS.length];
return (
<linearGradient key={agentKey} id={`gradient_${agentKey}`} x1="0" y1="0"
x2="0" y2="1">
<stop offset="5%" stopColor={color} stopOpacity={0.3}/>
<stop offset="95%" stopColor={color} stopOpacity={0}/>
</linearGradient>
);
})}
</defs>
<CartesianGrid
strokeDasharray="3 3"
className="stroke-cyan-900/30"
vertical={false}
/>
<XAxis
dataKey="time"
className="text-xs text-cyan-600 font-mono"
stroke="#164e63"
tickLine={false}
axisLine={false}
/>
<YAxis
className="text-xs text-cyan-600 font-mono"
stroke="#164e63"
tickLine={false}
axisLine={false}
tickFormatter={(value) => `${value}ms`}
/>
<Tooltip content={<CustomTooltip/>}/>
<Legend
wrapperStyle={{paddingTop: '20px'}}
iconType="circle"
/>
{monitorStats
.filter(stat => {
const agentIdentifier = stat.agentName || stat.agentId;
return selectedAgent === 'all' || agentIdentifier === selectedAgent;
})
.map((stat) => {
const originalIndex = monitorStats.findIndex(s => s.agentId === stat.agentId);
const agentIdentifier = stat.agentName || stat.agentId;
const agentKey = `agent_${agentIdentifier}`;
const color = AGENT_COLORS[originalIndex % AGENT_COLORS.length];
const agentLabel = stat.agentName || stat.agentId.substring(0, 8);
return (
<Area
key={agentKey}
type="monotone"
dataKey={agentKey}
name={agentLabel}
stroke={color}
strokeWidth={2}
fill={`url(#gradient_${agentKey})`}
activeDot={{r: 5, strokeWidth: 0}}
dot={false}
/>
);
})}
</AreaChart>
</ResponsiveContainer>
) : (
<ChartPlaceholder
subtitle="正在收集数据,请稍后查看历史趋势"
heightClass="h-80"
/>
)}
</div>
</div>
);
};
+7
View File
@@ -0,0 +1,7 @@
export {CertBadge} from './CertBadge';
export {MiniChart} from './MiniChart';
export {StatusBadge} from './StatusBadge';
export {TypeIcon} from './TypeIcon';
export {MonitorHero} from './MonitorHero';
export {ResponseTimeChart} from './ResponseTimeChart';
export {AgentStatsTable} from './AgentStatsTable';
@@ -0,0 +1,68 @@
import {Zap} from 'lucide-react';
import {Card} from '@/components/common';
import {formatBytes} from '@/utils/util';
import type {LatestMetrics} from '@/types';
interface GpuMonitorSectionProps {
latestMetrics: LatestMetrics | null;
}
/**
* GPU 监控区块组件
* 显示 GPU 使用情况、温度、显存和功耗信息
*/
export const GpuMonitorSection = ({latestMetrics}: GpuMonitorSectionProps) => {
// 如果没有 GPU 数据,不渲染组件
if (!latestMetrics?.gpu || latestMetrics.gpu.length === 0) {
return null;
}
return (
<Card title="GPU 监控" description="显卡使用情况和温度监控" variant="dark">
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
{latestMetrics.gpu.map((gpu) => (
<div
key={gpu.index}
className="rounded-xl border border-cyan-900/50 bg-black/30 p-4 backdrop-blur-sm hover:border-cyan-700/50 transition"
>
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<span
className="flex h-9 w-9 items-center justify-center rounded-lg bg-cyan-500/10 text-cyan-400">
<Zap className="h-4 w-4"/>
</span>
<div>
<p className="text-sm font-bold font-mono text-cyan-100">GPU {gpu.index}</p>
<p className="text-[10px] text-cyan-600">{gpu.name}</p>
</div>
</div>
<span className="text-2xl font-bold text-purple-400">
{gpu.utilization.toFixed(1)}%
</span>
</div>
<div className="space-y-2 text-xs">
<div className="flex items-center justify-between">
<span className="text-cyan-600 font-mono text-[10px] uppercase tracking-wider">温度</span>
<span className="font-medium text-cyan-200">{gpu.temperature.toFixed(1)}°C</span>
</div>
<div className="flex items-center justify-between">
<span className="text-cyan-600 font-mono text-[10px] uppercase tracking-wider">显存</span>
<span className="font-medium text-cyan-200">
{formatBytes(gpu.memoryUsed)} / {formatBytes(gpu.memoryTotal)}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-cyan-600 font-mono text-[10px] uppercase tracking-wider">功耗</span>
<span className="font-medium text-cyan-200">{gpu.powerDraw.toFixed(1)}W</span>
</div>
<div className="flex items-center justify-between">
<span className="text-cyan-600 font-mono text-[10px] uppercase tracking-wider">风扇转速</span>
<span className="font-medium text-cyan-200">{gpu.fanSpeed.toFixed(0)}%</span>
</div>
</div>
</div>
))}
</div>
</Card>
);
};
@@ -0,0 +1,61 @@
import {Network} from 'lucide-react';
import {Card} from '@/components/common';
import type {LatestMetrics} from '@/types';
interface NetworkConnectionSectionProps {
latestMetrics: LatestMetrics | null;
}
/**
* 网络连接统计区块组件
* 显示 TCP 连接各状态的实时统计数据
*/
export const NetworkConnectionSection = ({latestMetrics}: NetworkConnectionSectionProps) => {
// 如果没有网络连接数据,不渲染组件
if (!latestMetrics?.networkConnection) {
return null;
}
return (
<Card title="网络连接统计" description="TCP 连接各状态的实时统计数据" variant="dark">
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-6">
<div className="text-center">
<div className="text-[10px] text-cyan-600 font-mono uppercase tracking-wider">Total</div>
<div className="mt-1 text-lg font-semibold text-cyan-100">{latestMetrics.networkConnection.total}</div>
</div>
<div className="text-center">
<div className="text-[10px] text-cyan-600 font-mono uppercase tracking-wider">ESTABLISHED</div>
<div
className="mt-1 text-lg font-semibold text-emerald-400">{latestMetrics.networkConnection.established}</div>
</div>
<div className="text-center">
<div className="text-[10px] text-cyan-600 font-mono uppercase tracking-wider">TIME_WAIT</div>
<div
className="mt-1 text-lg font-semibold text-amber-400">{latestMetrics.networkConnection.timeWait}</div>
</div>
<div className="text-center">
<div className="text-[10px] text-cyan-600 font-mono uppercase tracking-wider">LISTEN</div>
<div
className="mt-1 text-lg font-semibold text-blue-400">{latestMetrics.networkConnection.listen}</div>
</div>
<div className="text-center">
<div className="text-[10px] text-cyan-600 font-mono uppercase tracking-wider">CLOSE_WAIT</div>
<div
className="mt-1 text-lg font-semibold text-rose-400">{latestMetrics.networkConnection.closeWait}</div>
</div>
<div className="text-center">
<div className="text-[10px] text-cyan-600 font-mono uppercase tracking-wider">OTHER</div>
<div className="mt-1 text-lg font-semibold text-cyan-500">
{latestMetrics.networkConnection.synSent +
latestMetrics.networkConnection.synRecv +
latestMetrics.networkConnection.finWait1 +
latestMetrics.networkConnection.finWait2 +
latestMetrics.networkConnection.close +
latestMetrics.networkConnection.lastAck +
latestMetrics.networkConnection.closing}
</div>
</div>
</div>
</Card>
);
};
+96
View File
@@ -0,0 +1,96 @@
import {ArrowLeft, Server} from 'lucide-react';
import {cn} from '@/lib/utils';
import {formatBytes, formatDateTime, formatUptime} from '@/utils/util';
import type {Agent, LatestMetrics} from '@/types';
import LittleStatCard from '@/components/common/LittleStatCard';
interface ServerHeroProps {
agent: Agent;
latestMetrics: LatestMetrics | null;
onBack: () => void;
}
/**
* 服务器头部信息组件
* 显示服务器基本信息、状态和关键指标
*/
export const ServerHero = ({agent, latestMetrics, onBack}: ServerHeroProps) => {
const displayName = agent?.name?.trim() ? agent.name : '未命名探针';
const isOnline = agent?.status === 1;
const statusDotStyles = isOnline ? 'bg-emerald-500' : 'bg-rose-500';
const statusText = isOnline ? '在线' : '离线';
const platformDisplay = latestMetrics?.host?.platform
? `${latestMetrics.host.platform} ${latestMetrics.host.platformVersion || ''}`.trim()
: agent?.os || '-';
const architectureDisplay = latestMetrics?.host?.kernelArch || agent?.arch || '-';
const uptimeDisplay = formatUptime(latestMetrics?.host?.uptime);
const lastSeenDisplay = agent ? formatDateTime(agent.lastSeenAt) : '-';
const networkSummary = latestMetrics?.network
? `${formatBytes(latestMetrics.network.totalBytesSentTotal)} ↑ / ${formatBytes(
latestMetrics.network.totalBytesRecvTotal,
)} ↓`
: '—';
const heroStats = [
{label: '运行系统', value: platformDisplay || '-'},
{label: '硬件架构', value: architectureDisplay || '-'},
{label: '最近心跳', value: lastSeenDisplay},
{label: '运行时长', value: uptimeDisplay},
];
return (
<section className="rounded-2xl border border-cyan-900/50 bg-[#0a0b10]/90 p-6 shadow-2xl backdrop-blur-sm">
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-4">
<button
type="button"
onClick={onBack}
className="group inline-flex items-center gap-2 text-xs font-bold font-mono uppercase tracking-[0.3em] text-cyan-600 transition hover:text-cyan-400"
>
<ArrowLeft className="h-4 w-4 transition group-hover:-translate-x-0.5"/>
返回概览
</button>
<div className="flex items-start gap-4">
<div>
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-3xl font-bold text-cyan-100">{displayName}</h1>
<span
className={cn(
"inline-flex items-center gap-1 rounded-full px-3 py-0.5 text-xs font-bold font-mono uppercase tracking-wider",
isOnline
? 'bg-emerald-500/20 text-emerald-400 border border-emerald-500/50'
: 'bg-rose-500/20 text-rose-400 border border-rose-500/50'
)}
>
<span className={cn("h-1.5 w-1.5 rounded-full", statusDotStyles)}/>
{statusText}
</span>
</div>
<p className="mt-2 text-sm text-cyan-600 font-mono">
{[agent.hostname, agent.ip].filter(Boolean).join(' · ') || '-'}
</p>
</div>
</div>
</div>
<div className="grid w-full gap-3 sm:grid-cols-2 lg:w-auto lg:grid-cols-2 xl:grid-cols-4">
{heroStats.map((stat) => (
<LittleStatCard key={stat.label} label={stat.label} value={stat.value}/>
))}
</div>
</div>
<div
className="flex flex-wrap items-center gap-3 text-xs text-cyan-600 font-mono pt-4 border-t border-cyan-900/30">
<span>探针 ID:{agent.id}</span>
<span className="hidden h-1 w-1 rounded-full bg-cyan-900 sm:inline-block"/>
<span>版本:{agent.version || '-'}</span>
<span className="hidden h-1 w-1 rounded-full bg-cyan-900 sm:inline-block"/>
<span>网络累计:{networkSummary}</span>
</div>
</div>
</section>
);
};
@@ -0,0 +1,179 @@
import type {ReactNode} from 'react';
import {Cpu, Database, HardDrive, MemoryStick, Network} from 'lucide-react';
import {Card} from '@/components/common';
import {InfoGrid, SnapshotSection, type SnapshotCardData} from '@/components/server';
import {formatBytes, formatDateTime, formatPercentValue, formatUptime} from '@/utils/util';
import type {Agent, LatestMetrics} from '@/types';
type AccentVariant = 'blue' | 'emerald' | 'purple' | 'amber';
interface SystemInfoSectionProps {
agent: Agent;
latestMetrics: LatestMetrics | null;
}
/**
* 系统信息区块组件
* 显示运行环境、运行状态、快照卡片和网络连接统计
*/
export const SystemInfoSection = ({agent, latestMetrics}: SystemInfoSectionProps) => {
// 环境信息
const platformDisplay = latestMetrics?.host?.platform
? `${latestMetrics.host.platform} ${latestMetrics.host.platformVersion || ''}`.trim()
: agent?.os || '-';
const architectureDisplay = latestMetrics?.host?.kernelArch || agent?.arch || '-';
const environmentInfo = [
{label: '操作系统', value: platformDisplay || '-'},
{label: '内核版本', value: latestMetrics?.host?.kernelVersion || '-'},
{label: '硬件架构', value: architectureDisplay || '-'},
{label: 'CPU 型号', value: latestMetrics?.cpu?.modelName || '-'},
{label: '逻辑核心', value: latestMetrics?.cpu?.logicalCores ?? '-'},
{label: '物理核心', value: latestMetrics?.cpu?.physicalCores ?? '-'},
];
// 状态信息
const uptimeDisplay = formatUptime(latestMetrics?.host?.uptime);
const bootTimeDisplay = latestMetrics?.host?.bootTime
? formatDateTime(latestMetrics.host.bootTime * 1000)
: '-';
const lastSeenDisplay = agent ? formatDateTime(agent.lastSeenAt) : '-';
const networkSummary = latestMetrics?.network
? `${formatBytes(latestMetrics.network.totalBytesSentTotal)} ↑ / ${formatBytes(
latestMetrics.network.totalBytesRecvTotal,
)} ↓`
: '—';
const statusInfo = [
{label: '启动时间', value: bootTimeDisplay},
{label: '运行时间', value: uptimeDisplay},
{label: '最近心跳', value: lastSeenDisplay},
{label: '进程数', value: latestMetrics?.host?.procs ?? '-'},
{label: '网络累计', value: networkSummary},
];
// 快照卡片
const snapshotCards: SnapshotCardData[] = [];
if (latestMetrics) {
snapshotCards.push({
key: 'cpu',
icon: Cpu,
title: 'CPU 使用',
usagePercent: `${formatPercentValue(latestMetrics.cpu?.usagePercent)}%`,
accent: 'blue',
metrics: [
{label: '当前使用', value: `${formatPercentValue(latestMetrics.cpu?.usagePercent)}%`},
{
label: '采样时间',
value: latestMetrics.cpu ? formatDateTime(latestMetrics.cpu.timestamp) : '-',
},
],
});
snapshotCards.push({
key: 'memory',
icon: MemoryStick,
title: '内存使用',
usagePercent: `${formatPercentValue(latestMetrics.memory?.usagePercent)}%`,
accent: 'emerald',
metrics: [
{
label: '已用 / 总量',
value: `${formatBytes(latestMetrics.memory?.used)} / ${formatBytes(latestMetrics.memory?.total)}`
},
{
label: 'Swap 已用',
value: `${formatBytes(latestMetrics.memory?.swapUsed)} / ${formatBytes(latestMetrics.memory?.swapTotal)}`
},
],
});
snapshotCards.push({
key: 'disk',
icon: HardDrive,
title: '磁盘使用',
usagePercent: latestMetrics.disk
? `${formatPercentValue(latestMetrics.disk.usagePercent)}%`
: '—',
accent: 'purple',
metrics: [
{
label: '已用 / 总量',
value: `${formatBytes(latestMetrics.disk?.used)} / ${formatBytes(latestMetrics.disk?.total)}`
},
{label: '磁盘数量', value: latestMetrics.disk?.totalDisks ?? '-'},
],
});
// 网络流量卡片 - 整合流量统计信息
const networkMetrics = [
{
label: '上行 / 下行',
value: `${formatBytes(latestMetrics.network?.totalBytesSentRate)}/s ↑ / ${formatBytes(
latestMetrics.network?.totalBytesRecvRate,
)}/s ↓`,
},
{
label: '网络累计',
value: `${formatBytes(latestMetrics.network?.totalBytesSentTotal)} ↑ / ${formatBytes(
latestMetrics.network?.totalBytesRecvTotal,
)} ↓`,
},
];
// 如果配置了流量限额,添加流量统计信息到网络卡片
if (agent && agent.trafficLimit && agent.trafficLimit > 0) {
const trafficUsedPercent = ((agent.trafficUsed || 0) / agent.trafficLimit) * 100;
const trafficRemaining = agent.trafficLimit - (agent.trafficUsed || 0);
networkMetrics.push({
label: '流量限额',
value: `${formatBytes(agent.trafficUsed || 0)} / ${formatBytes(agent.trafficLimit)} (${formatPercentValue(trafficUsedPercent)}%)`,
});
if (agent.trafficResetDay && agent.trafficResetDay > 0) {
networkMetrics.push({
label: '重置日期',
value: `每月${agent.trafficResetDay}号`,
});
}
}
snapshotCards.push({
key: 'network',
icon: Network,
title: '网络流量',
usagePercent: latestMetrics.network
? `${formatBytes(latestMetrics.network.totalBytesSentRate)}/s`
: '—',
accent: 'amber',
metrics: networkMetrics,
});
}
return (
<Card title="系统信息" description="探针基础属性、运行状态与资源概览" variant="dark">
<div className="space-y-6">
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<div className="rounded-xl border border-cyan-900/50 bg-black/30 p-4 backdrop-blur-sm">
<h3 className="text-xs font-bold font-mono uppercase tracking-widest text-cyan-400">运行环境</h3>
<p className="mt-1 text-[10px] text-cyan-700">来自最近一次探针上报的硬件与系统信息</p>
<div className="mt-4">
<InfoGrid items={environmentInfo}/>
</div>
</div>
<div className="rounded-xl border border-cyan-900/50 bg-black/30 p-4 backdrop-blur-sm">
<h3 className="text-xs font-bold font-mono uppercase tracking-widest text-cyan-400">运行状态</h3>
<p className="mt-1 text-[10px] text-cyan-700">关键时间与网络指标,帮助快速判断主机健康状况</p>
<div className="mt-4">
<InfoGrid items={statusInfo}/>
</div>
</div>
</div>
<SnapshotSection cards={snapshotCards}/>
</div>
</Card>
);
};
@@ -0,0 +1,37 @@
import {Thermometer} from 'lucide-react';
import {Card} from '@/components/common';
import type {LatestMetrics} from '@/types';
interface TemperatureMonitorSectionProps {
latestMetrics: LatestMetrics | null;
}
/**
* 温度监控区块组件
* 显示系统各部件温度传感器数据
*/
export const TemperatureMonitorSection = ({latestMetrics}: TemperatureMonitorSectionProps) => {
// 如果没有温度数据,不渲染组件
if (!latestMetrics?.temperature || latestMetrics.temperature.length === 0) {
return null;
}
return (
<Card title="温度监控" description="系统各部件温度传感器数据" variant="dark">
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
{latestMetrics.temperature.sort((a, b) => a.sensorKey.localeCompare(b.sensorKey)).map((temp) => (
<div
key={temp.sensorKey}
className="rounded-xl border border-cyan-900/50 bg-black/30 p-4 backdrop-blur-sm hover:border-cyan-700/50 transition"
>
<div className="flex items-center gap-2 mb-2">
<Thermometer className="h-4 w-4 text-cyan-600"/>
<p className="text-[10px] font-bold font-mono uppercase tracking-wider text-cyan-400 truncate">{temp.type}</p>
</div>
<p className="text-2xl font-bold text-orange-400">{temp.temperature.toFixed(1)}°C</p>
</div>
))}
</div>
</Card>
);
};
@@ -0,0 +1,30 @@
import type {ReactNode} from 'react';
import type {LucideIcon} from 'lucide-react';
interface ChartContainerProps {
title: string;
icon: LucideIcon;
children: ReactNode;
action?: ReactNode;
}
/**
* 图表容器组件
* 为所有图表提供统一的标题、图标和可选的操作区域
*/
export const ChartContainer = ({title, icon: Icon, children, action}: ChartContainerProps) => {
return (
<section>
<div className="mb-3 flex items-center justify-between">
<h3 className="flex items-center gap-2 text-xs font-bold font-mono uppercase tracking-widest text-cyan-400">
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-cyan-500/10 text-cyan-400">
<Icon className="h-4 w-4"/>
</span>
{title}
</h3>
{action}
</div>
{children}
</section>
);
};
@@ -0,0 +1,90 @@
import {useMemo} from 'react';
import {Cpu} from 'lucide-react';
import {Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis} from 'recharts';
import {ChartPlaceholder, CustomTooltip} from '@/components/common';
import {useMetricsQuery} from '@/hooks/server/queries';
import {ChartContainer} from './ChartContainer';
interface CpuChartProps {
agentId: string;
timeRange: string;
}
/**
* CPU 使用率图表组件
*/
export const CpuChart = ({agentId, timeRange}: CpuChartProps) => {
// 数据查询
const {data: metricsResponse, isLoading} = useMetricsQuery({
agentId,
type: 'cpu',
range: timeRange,
});
// 数据转换
const chartData = useMemo(() => {
const cpuSeries = metricsResponse?.data.series.find(s => s.name === 'usage');
if (!cpuSeries) return [];
return cpuSeries.data.map((point) => ({
time: new Date(point.timestamp).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
}),
usage: Number(point.value.toFixed(2)),
timestamp: point.timestamp,
}));
}, [metricsResponse]);
// 渲染
if (isLoading) {
return (
<ChartContainer title="CPU 使用率" icon={Cpu}>
<ChartPlaceholder variant="dark"/>
</ChartContainer>
);
}
return (
<ChartContainer title="CPU 使用率" icon={Cpu}>
{chartData.length > 0 ? (
<ResponsiveContainer width="100%" height={220}>
<AreaChart data={chartData}>
<defs>
<linearGradient id="cpuAreaGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#2563eb" stopOpacity={0.4}/>
<stop offset="95%" stopColor="#2563eb" stopOpacity={0}/>
</linearGradient>
</defs>
<CartesianGrid stroke="currentColor" strokeDasharray="4 4" className="stroke-cyan-900/30"/>
<XAxis
dataKey="time"
stroke="currentColor"
className="stroke-cyan-600"
style={{fontSize: '12px'}}
/>
<YAxis
domain={[0, 100]}
stroke="currentColor"
className="stroke-cyan-600"
style={{fontSize: '12px'}}
tickFormatter={(value) => `${value}%`}
/>
<Tooltip content={<CustomTooltip unit="%" variant="dark"/>}/>
<Area
type="monotone"
dataKey="usage"
name="CPU 使用率"
stroke="#2563eb"
strokeWidth={2}
fill="url(#cpuAreaGradient)"
activeDot={{r: 3}}
/>
</AreaChart>
</ResponsiveContainer>
) : (
<ChartPlaceholder variant="dark"/>
)}
</ChartContainer>
);
};
@@ -0,0 +1,122 @@
import {useMemo} from 'react';
import {HardDrive} from 'lucide-react';
import {Area, AreaChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis} from 'recharts';
import {ChartPlaceholder, CustomTooltip} from '@/components/common';
import {useMetricsQuery} from '@/hooks/server/queries';
import {ChartContainer} from './ChartContainer';
interface DiskIOChartProps {
agentId: string;
timeRange: string;
}
/**
* 磁盘 I/O 图表组件
*/
export const DiskIOChart = ({agentId, timeRange}: DiskIOChartProps) => {
// 数据查询
const {data: metricsResponse, isLoading} = useMetricsQuery({
agentId,
type: 'disk_io',
range: timeRange,
});
// 数据转换
const chartData = useMemo(() => {
if (!metricsResponse?.data.series || metricsResponse.data.series.length === 0) return [];
const readSeries = metricsResponse.data.series.find(s => s.name === 'read');
const writeSeries = metricsResponse.data.series.find(s => s.name === 'write');
if (!readSeries || !writeSeries) return [];
// 按时间戳对齐数据
const timeMap = new Map<number, any>();
readSeries.data.forEach(point => {
const time = new Date(point.timestamp).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
});
timeMap.set(point.timestamp, {
time,
timestamp: point.timestamp,
read: Number((point.value / 1024 / 1024).toFixed(2)), // 转换为 MB/s
});
});
writeSeries.data.forEach(point => {
const existing = timeMap.get(point.timestamp);
if (existing) {
existing.write = Number((point.value / 1024 / 1024).toFixed(2));
}
});
return Array.from(timeMap.values());
}, [metricsResponse]);
// 渲染
if (isLoading) {
return (
<ChartContainer title="磁盘 I/O (MB/s)" icon={HardDrive}>
<ChartPlaceholder variant="dark"/>
</ChartContainer>
);
}
return (
<ChartContainer title="磁盘 I/O (MB/s)" icon={HardDrive}>
{chartData.length > 0 ? (
<ResponsiveContainer width="100%" height={220}>
<AreaChart data={chartData}>
<defs>
<linearGradient id="colorDiskRead" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#2C70F6" stopOpacity={0.3}/>
<stop offset="95%" stopColor="#2C70F6" stopOpacity={0}/>
</linearGradient>
<linearGradient id="colorDiskWrite" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#6FD598" stopOpacity={0.3}/>
<stop offset="95%" stopColor="#6FD598" stopOpacity={0}/>
</linearGradient>
</defs>
<CartesianGrid stroke="currentColor" strokeDasharray="4 4" className="stroke-cyan-900/30"/>
<XAxis
dataKey="time"
stroke="currentColor"
className="stroke-cyan-600"
style={{fontSize: '12px'}}
/>
<YAxis
stroke="currentColor"
className="stroke-cyan-600"
style={{fontSize: '12px'}}
tickFormatter={(value) => `${value} MB`}
/>
<Tooltip content={<CustomTooltip unit=" MB" variant="dark"/>}/>
<Legend/>
<Area
type="monotone"
dataKey="read"
name="读取"
stroke="#2C70F6"
strokeWidth={2}
fill="url(#colorDiskRead)"
activeDot={{r: 3}}
/>
<Area
type="monotone"
dataKey="write"
name="写入"
stroke="#6FD598"
strokeWidth={2}
fill="url(#colorDiskWrite)"
activeDot={{r: 3}}
/>
</AreaChart>
</ResponsiveContainer>
) : (
<ChartPlaceholder subtitle="暂无磁盘 I/O 采集数据" variant="dark"/>
)}
</ChartContainer>
);
};
@@ -0,0 +1,125 @@
import {useMemo} from 'react';
import {Zap} from 'lucide-react';
import {CartesianGrid, Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis} from 'recharts';
import {ChartPlaceholder, CustomTooltip} from '@/components/common';
import {useMetricsQuery} from '@/hooks/server/queries';
import {ChartContainer} from './ChartContainer';
interface GpuChartProps {
agentId: string;
timeRange: string;
}
/**
* GPU 使用率与温度图表组件
* 使用双 Y 轴显示使用率和温度
*/
export const GpuChart = ({agentId, timeRange}: GpuChartProps) => {
// 数据查询
const {data: metricsResponse, isLoading} = useMetricsQuery({
agentId,
type: 'gpu',
range: timeRange,
});
// 数据转换
const chartData = useMemo(() => {
if (!metricsResponse?.data.series || metricsResponse.data.series.length === 0) return [];
// 按时间戳聚合利用率和温度系列
const timeMap = new Map<number, any>();
const utilizationSeries = metricsResponse.data.series.find(s => s.name === 'utilization');
const temperatureSeries = metricsResponse.data.series.find(s => s.name === 'temperature');
// 添加利用率数据
utilizationSeries?.data.forEach(point => {
const time = new Date(point.timestamp).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
});
timeMap.set(point.timestamp, {
time,
timestamp: point.timestamp,
utilization: Number(point.value.toFixed(2)),
});
});
// 添加温度数据
temperatureSeries?.data.forEach(point => {
const existing = timeMap.get(point.timestamp);
if (existing) {
existing.temperature = Number(point.value.toFixed(2));
}
});
return Array.from(timeMap.values());
}, [metricsResponse]);
// 渲染
if (isLoading) {
return (
<ChartContainer title="GPU 使用率与温度" icon={Zap}>
<ChartPlaceholder variant="dark"/>
</ChartContainer>
);
}
// 如果没有 GPU 数据,不渲染组件
if (chartData.length === 0) {
return null;
}
return (
<ChartContainer title="GPU 使用率与温度" icon={Zap}>
<ResponsiveContainer width="100%" height={220}>
<LineChart data={chartData}>
<CartesianGrid stroke="currentColor" strokeDasharray="4 4" className="stroke-cyan-900/30"/>
<XAxis
dataKey="time"
stroke="currentColor"
className="stroke-cyan-600"
style={{fontSize: '12px'}}
/>
<YAxis
yAxisId="left"
stroke="currentColor"
className="stroke-cyan-600"
style={{fontSize: '12px'}}
tickFormatter={(value) => `${value}%`}
/>
<YAxis
yAxisId="right"
orientation="right"
stroke="currentColor"
className="stroke-cyan-600"
style={{fontSize: '12px'}}
tickFormatter={(value) => `${value}°C`}
/>
<Tooltip content={<CustomTooltip unit="" variant="dark"/>}/>
<Legend/>
<Line
yAxisId="left"
type="monotone"
dataKey="utilization"
name="使用率 (%)"
stroke="#7c3aed"
strokeWidth={2}
dot={false}
activeDot={{r: 3}}
/>
<Line
yAxisId="right"
type="monotone"
dataKey="temperature"
name="温度 (°C)"
stroke="#f97316"
strokeWidth={2}
dot={false}
activeDot={{r: 3}}
/>
</LineChart>
</ResponsiveContainer>
</ChartContainer>
);
};
@@ -0,0 +1,90 @@
import {useMemo} from 'react';
import {MemoryStick} from 'lucide-react';
import {Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis} from 'recharts';
import {ChartPlaceholder, CustomTooltip} from '@/components/common';
import {useMetricsQuery} from '@/hooks/server/queries';
import {ChartContainer} from './ChartContainer';
interface MemoryChartProps {
agentId: string;
timeRange: string;
}
/**
* 内存使用率图表组件
*/
export const MemoryChart = ({agentId, timeRange}: MemoryChartProps) => {
// 数据查询
const {data: metricsResponse, isLoading} = useMetricsQuery({
agentId,
type: 'memory',
range: timeRange,
});
// 数据转换
const chartData = useMemo(() => {
const memorySeries = metricsResponse?.data.series.find(s => s.name === 'usage');
if (!memorySeries) return [];
return memorySeries.data.map((point) => ({
time: new Date(point.timestamp).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
}),
usage: Number(point.value.toFixed(2)),
timestamp: point.timestamp,
}));
}, [metricsResponse]);
// 渲染
if (isLoading) {
return (
<ChartContainer title="内存使用率" icon={MemoryStick}>
<ChartPlaceholder variant="dark"/>
</ChartContainer>
);
}
return (
<ChartContainer title="内存使用率" icon={MemoryStick}>
{chartData.length > 0 ? (
<ResponsiveContainer width="100%" height={220}>
<AreaChart data={chartData}>
<defs>
<linearGradient id="memoryAreaGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#10b981" stopOpacity={0.4}/>
<stop offset="95%" stopColor="#10b981" stopOpacity={0}/>
</linearGradient>
</defs>
<CartesianGrid stroke="currentColor" strokeDasharray="4 4" className="stroke-cyan-900/30"/>
<XAxis
dataKey="time"
stroke="currentColor"
className="stroke-cyan-600"
style={{fontSize: '12px'}}
/>
<YAxis
domain={[0, 100]}
stroke="currentColor"
className="stroke-cyan-600"
style={{fontSize: '12px'}}
tickFormatter={(value) => `${value}%`}
/>
<Tooltip content={<CustomTooltip unit="%" variant="dark"/>}/>
<Area
type="monotone"
dataKey="usage"
name="内存使用率"
stroke="#10b981"
strokeWidth={2}
fill="url(#memoryAreaGradient)"
activeDot={{r: 3}}
/>
</AreaChart>
</ResponsiveContainer>
) : (
<ChartPlaceholder variant="dark"/>
)}
</ChartContainer>
);
};
@@ -0,0 +1,154 @@
import {useEffect, useMemo, useState} from 'react';
import {Network} from 'lucide-react';
import {Area, AreaChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis} from 'recharts';
import {ChartPlaceholder, CustomTooltip} from '@/components/common';
import {useMetricsQuery, useNetworkInterfacesQuery} from '@/hooks/server/queries';
import {INTERFACE_COLORS} from '@/constants/server';
import {ChartContainer} from './ChartContainer';
interface NetworkChartProps {
agentId: string;
timeRange: string;
}
/**
* 网络流量图表组件
* 支持网卡切换
*/
export const NetworkChart = ({agentId, timeRange}: NetworkChartProps) => {
const [selectedInterface, setSelectedInterface] = useState<string>('all');
// 查询网卡列表
const {data: interfacesData} = useNetworkInterfacesQuery(agentId);
const availableInterfaces = interfacesData?.data.interfaces || [];
// 当网卡列表变化时,验证选中的网卡
useEffect(() => {
if (selectedInterface !== 'all' && availableInterfaces.length > 0) {
if (!availableInterfaces.includes(selectedInterface)) {
setSelectedInterface('all');
}
}
}, [availableInterfaces, selectedInterface]);
// 查询网络数据
const {data: metricsResponse, isLoading} = useMetricsQuery({
agentId,
type: 'network',
range: timeRange,
interfaceName: selectedInterface !== 'all' ? selectedInterface : undefined,
});
// 数据转换
const chartData = useMemo(() => {
if (!metricsResponse?.data.series || metricsResponse.data.series.length === 0) return [];
const uploadSeries = metricsResponse.data.series.find(s => s.name === 'upload');
const downloadSeries = metricsResponse.data.series.find(s => s.name === 'download');
if (!uploadSeries || !downloadSeries) return [];
const timeMap = new Map<number, any>();
uploadSeries.data.forEach(point => {
const time = new Date(point.timestamp).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
});
timeMap.set(point.timestamp, {
time,
timestamp: point.timestamp,
upload: Number((point.value / 1024 / 1024).toFixed(2)), // 转换为 MB/s
});
});
downloadSeries.data.forEach(point => {
const existing = timeMap.get(point.timestamp);
if (existing) {
existing.download = Number((point.value / 1024 / 1024).toFixed(2));
}
});
return Array.from(timeMap.values());
}, [metricsResponse]);
// 网卡选择器
const interfaceSelector = availableInterfaces.length > 0 && (
<select
value={selectedInterface}
onChange={(e) => setSelectedInterface(e.target.value)}
className="rounded-lg border border-cyan-900/50 bg-black/40 px-3 py-1.5 text-xs font-mono text-cyan-300 hover:border-cyan-700 focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/20"
>
{availableInterfaces.map((iface) => (
<option key={iface} value={iface}>
{iface}
</option>
))}
</select>
);
// 渲染
if (isLoading) {
return (
<ChartContainer title="网络流量(MB/s)" icon={Network} action={interfaceSelector}>
<ChartPlaceholder variant="dark"/>
</ChartContainer>
);
}
return (
<ChartContainer title="网络流量(MB/s)" icon={Network} action={interfaceSelector}>
{chartData.length > 0 ? (
<ResponsiveContainer width="100%" height={220}>
<AreaChart data={chartData}>
<defs>
<linearGradient id="color-upload" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={INTERFACE_COLORS[0].upload} stopOpacity={0.3}/>
<stop offset="95%" stopColor={INTERFACE_COLORS[0].upload} stopOpacity={0}/>
</linearGradient>
<linearGradient id="color-download" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={INTERFACE_COLORS[0].download} stopOpacity={0.3}/>
<stop offset="95%" stopColor={INTERFACE_COLORS[0].download} stopOpacity={0}/>
</linearGradient>
</defs>
<CartesianGrid stroke="currentColor" strokeDasharray="4 4" className="stroke-cyan-900/30"/>
<XAxis
dataKey="time"
stroke="currentColor"
className="stroke-cyan-600"
style={{fontSize: '12px'}}
/>
<YAxis
stroke="currentColor"
className="stroke-cyan-600"
style={{fontSize: '12px'}}
tickFormatter={(value) => `${value} MB`}
/>
<Tooltip content={<CustomTooltip unit=" MB/s" variant="dark"/>}/>
<Legend/>
<Area
type="monotone"
dataKey="upload"
name="上行"
stroke={INTERFACE_COLORS[0].upload}
strokeWidth={2}
fill="url(#color-upload)"
activeDot={{r: 3}}
/>
<Area
type="monotone"
dataKey="download"
name="下行"
stroke={INTERFACE_COLORS[0].download}
strokeWidth={2}
fill="url(#color-download)"
activeDot={{r: 3}}
/>
</AreaChart>
</ResponsiveContainer>
) : (
<ChartPlaceholder subtitle="稍后再次尝试刷新网络流量" variant="dark"/>
)}
</ChartContainer>
);
};
@@ -0,0 +1,124 @@
import {useMemo} from 'react';
import {Network} from 'lucide-react';
import {CartesianGrid, Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis} from 'recharts';
import {ChartPlaceholder, CustomTooltip} from '@/components/common';
import {useMetricsQuery} from '@/hooks/server/queries';
import {ChartContainer} from './ChartContainer';
interface NetworkConnectionChartProps {
agentId: string;
timeRange: string;
}
/**
* 网络连接统计图表组件
*/
export const NetworkConnectionChart = ({agentId, timeRange}: NetworkConnectionChartProps) => {
// 数据查询
const {data: metricsResponse, isLoading} = useMetricsQuery({
agentId,
type: 'network_connection',
range: timeRange,
});
// 数据转换
const chartData = useMemo(() => {
if (!metricsResponse?.data.series || metricsResponse.data.series.length === 0) return [];
// 按时间戳聚合所有连接状态系列
const timeMap = new Map<number, any>();
metricsResponse.data.series.forEach(series => {
const stateName = series.name; // established, time_wait, close_wait, listen
series.data.forEach(point => {
const time = new Date(point.timestamp).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
});
if (!timeMap.has(point.timestamp)) {
timeMap.set(point.timestamp, {time, timestamp: point.timestamp});
}
const existing = timeMap.get(point.timestamp)!;
// 转换为驼峰命名以匹配图表的 dataKey
const camelCaseName = stateName.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
existing[camelCaseName] = Number(point.value.toFixed(0));
});
});
return Array.from(timeMap.values());
}, [metricsResponse]);
// 渲染
if (isLoading) {
return (
<ChartContainer title="网络连接统计" icon={Network}>
<ChartPlaceholder variant="dark"/>
</ChartContainer>
);
}
return (
<ChartContainer title="网络连接统计" icon={Network}>
{chartData.length > 0 ? (
<ResponsiveContainer width="100%" height={220}>
<LineChart data={chartData}>
<CartesianGrid stroke="currentColor" strokeDasharray="4 4" className="stroke-cyan-900/30"/>
<XAxis
dataKey="time"
stroke="currentColor"
className="stroke-cyan-600"
style={{fontSize: '12px'}}
/>
<YAxis
stroke="currentColor"
className="stroke-cyan-600"
style={{fontSize: '12px'}}
/>
<Tooltip content={<CustomTooltip unit="" variant="dark"/>}/>
<Legend/>
<Line
type="monotone"
dataKey="established"
name="ESTABLISHED"
stroke="#10b981"
strokeWidth={2}
dot={false}
activeDot={{r: 3}}
/>
<Line
type="monotone"
dataKey="timeWait"
name="TIME_WAIT"
stroke="#f59e0b"
strokeWidth={2}
dot={false}
activeDot={{r: 3}}
/>
<Line
type="monotone"
dataKey="closeWait"
name="CLOSE_WAIT"
stroke="#ef4444"
strokeWidth={2}
dot={false}
activeDot={{r: 3}}
/>
<Line
type="monotone"
dataKey="listen"
name="LISTEN"
stroke="#3b82f6"
strokeWidth={2}
dot={false}
activeDot={{r: 3}}
/>
</LineChart>
</ResponsiveContainer>
) : (
<ChartPlaceholder subtitle="暂无网络连接统计数据" variant="dark"/>
)}
</ChartContainer>
);
};
@@ -0,0 +1,148 @@
import {useEffect, useMemo, useState} from 'react';
import {Thermometer} from 'lucide-react';
import {CartesianGrid, Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis} from 'recharts';
import {ChartPlaceholder, CustomTooltip} from '@/components/common';
import {useMetricsQuery} from '@/hooks/server/queries';
import {TEMPERATURE_COLORS} from '@/constants/server';
import {ChartContainer} from './ChartContainer';
interface TemperatureChartProps {
agentId: string;
timeRange: string;
}
/**
* 系统温度图表组件
* 支持温度类型切换
*/
export const TemperatureChart = ({agentId, timeRange}: TemperatureChartProps) => {
const [selectedTempType, setSelectedTempType] = useState<string>('all');
// 数据查询
const {data: metricsResponse, isLoading} = useMetricsQuery({
agentId,
type: 'temperature',
range: timeRange,
});
// 数据转换
const chartData = useMemo(() => {
if (!metricsResponse?.data.series || metricsResponse.data.series.length === 0) return [];
// 按时间戳聚合所有温度系列
const timeMap = new Map<number, any>();
metricsResponse.data.series.forEach(series => {
const sensorName = series.name; // 使用系列名称作为传感器标识
series.data.forEach(point => {
const time = new Date(point.timestamp).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
});
if (!timeMap.has(point.timestamp)) {
timeMap.set(point.timestamp, {time, timestamp: point.timestamp});
}
const existing = timeMap.get(point.timestamp)!;
existing[sensorName] = Number(point.value.toFixed(2));
});
});
return Array.from(timeMap.values());
}, [metricsResponse]);
// 提取所有唯一的温度类型
const temperatureTypes = useMemo(() => {
return metricsResponse?.data.series.map(s => s.name).sort() || [];
}, [metricsResponse]);
// 根据选中的类型过滤温度数据
const filteredTemperatureTypes = useMemo(() => {
if (selectedTempType === 'all') {
return temperatureTypes;
}
return temperatureTypes.filter(type => type === selectedTempType);
}, [temperatureTypes, selectedTempType]);
// 当温度类型列表变化时,如果当前选中的类型不在列表中,重置为 'all'
useEffect(() => {
if (selectedTempType !== 'all' && temperatureTypes.length > 0) {
if (!temperatureTypes.includes(selectedTempType)) {
setSelectedTempType('all');
}
}
}, [temperatureTypes, selectedTempType]);
// 温度类型选择器
const tempTypeSelector = temperatureTypes.length > 1 && (
<select
value={selectedTempType}
onChange={(e) => setSelectedTempType(e.target.value)}
className="rounded-lg border border-cyan-900/50 bg-black/40 px-3 py-1.5 text-xs font-mono text-cyan-300 hover:border-cyan-700 focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/20"
>
<option value="all">所有类型</option>
{temperatureTypes.map((type) => (
<option key={type} value={type}>
{type}
</option>
))}
</select>
);
// 渲染
if (isLoading) {
return (
<ChartContainer title="系统温度" icon={Thermometer} action={tempTypeSelector}>
<ChartPlaceholder variant="dark"/>
</ChartContainer>
);
}
// 如果没有温度数据,不渲染组件
if (chartData.length === 0 || temperatureTypes.length === 0) {
return null;
}
return (
<ChartContainer title="系统温度" icon={Thermometer} action={tempTypeSelector}>
<ResponsiveContainer width="100%" height={220}>
<LineChart data={chartData}>
<CartesianGrid stroke="currentColor" strokeDasharray="4 4" className="stroke-cyan-900/30"/>
<XAxis
dataKey="time"
stroke="currentColor"
className="stroke-cyan-600"
style={{fontSize: '12px'}}
/>
<YAxis
stroke="currentColor"
className="stroke-cyan-600"
style={{fontSize: '12px'}}
tickFormatter={(value) => `${value}°C`}
/>
<Tooltip content={<CustomTooltip unit="°C" variant="dark"/>}/>
<Legend/>
{/* 为选中的温度类型渲染线条 */}
{filteredTemperatureTypes.map((type, index) => {
// 使用预定义颜色,如果没有则使用默认颜色
const color = TEMPERATURE_COLORS[type] || `hsl(${(index * 60) % 360}, 70%, 50%)`;
return (
<Line
key={type}
type="monotone"
dataKey={type}
name={type}
stroke={color}
strokeWidth={2}
dot={false}
activeDot={{r: 3}}
connectNulls
/>
);
})}
</LineChart>
</ResponsiveContainer>
</ChartContainer>
);
};
@@ -0,0 +1,8 @@
export {ChartContainer} from './ChartContainer';
export {CpuChart} from './CpuChart';
export {MemoryChart} from './MemoryChart';
export {NetworkChart} from './NetworkChart';
export {DiskIOChart} from './DiskIOChart';
export {NetworkConnectionChart} from './NetworkConnectionChart';
export {GpuChart} from './GpuChart';
export {TemperatureChart} from './TemperatureChart';
+5
View File
@@ -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';
+5
View File
@@ -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';
+4
View File
@@ -0,0 +1,4 @@
export {useAgentQuery} from './useAgentQuery';
export {useLatestMetricsQuery} from './useLatestMetricsQuery';
export {useMetricsQuery} from './useMetricsQuery';
export {useNetworkInterfacesQuery} from './useNetworkInterfacesQuery';
@@ -0,0 +1,16 @@
import {useQuery} from '@tanstack/react-query';
import {getAgent} from '@/api/agent';
/**
* 查询 Agent 基础信息
* @param agentId Agent ID
* @returns Agent 查询结果
*/
export const useAgentQuery = (agentId?: string) => {
return useQuery({
queryKey: ['agent', agentId],
queryFn: () => getAgent(agentId!),
enabled: !!agentId,
staleTime: 60000, // 1分钟缓存
});
};
@@ -0,0 +1,17 @@
import {useQuery} from '@tanstack/react-query';
import {getAgentLatestMetrics} from '@/api/agent';
/**
* 查询 Agent 最新指标
* 自动每 5 秒刷新一次
* @param agentId Agent ID
* @returns 最新指标查询结果
*/
export const useLatestMetricsQuery = (agentId?: string) => {
return useQuery({
queryKey: ['agent', agentId, 'metrics', 'latest'],
queryFn: () => getAgentLatestMetrics(agentId!),
enabled: !!agentId,
refetchInterval: 5000, // 5秒自动刷新
});
};
@@ -0,0 +1,30 @@
import {useQuery} from '@tanstack/react-query';
import {getAgentMetrics, type GetAgentMetricsRequest} from '@/api/agent';
interface UseMetricsQueryOptions {
agentId: string;
type: GetAgentMetricsRequest['type'];
range: string;
interfaceName?: string;
}
/**
* 查询 Agent 历史指标数据
* 自动每 30 秒刷新一次
* @param options 查询选项
* @returns 历史指标查询结果
*/
export const useMetricsQuery = ({agentId, type, range, interfaceName}: UseMetricsQueryOptions) => {
return useQuery({
queryKey: ['agent', agentId, 'metrics', type, range, interfaceName],
queryFn: () =>
getAgentMetrics({
agentId,
type,
range,
interface: interfaceName,
}),
enabled: !!agentId,
refetchInterval: 30000, // 30秒自动刷新
});
};
@@ -0,0 +1,15 @@
import {useQuery} from '@tanstack/react-query';
import {getAvailableNetworkInterfaces} from '@/api/agent';
/**
* 查询 Agent 可用网卡列表
* @param agentId Agent ID
* @returns 网卡列表查询结果
*/
export const useNetworkInterfacesQuery = (agentId?: string) => {
return useQuery({
queryKey: ['agent', agentId, 'network-interfaces'],
queryFn: () => getAvailableNetworkInterfaces(agentId!),
enabled: !!agentId,
});
};
+29 -475
View File
@@ -1,136 +1,17 @@
import {useEffect, useMemo, useState} from 'react';
import {useNavigate, useParams} from 'react-router-dom';
import {useQuery} from '@tanstack/react-query';
import {AlertCircle, ArrowLeft, Clock, Loader2, MapPin, Shield} from 'lucide-react';
import {Area, AreaChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis} from 'recharts';
import {type GetMetricsResponse, getMonitorAgentStats, getMonitorHistory, getMonitorStatsById} from '@/api/monitor.ts';
import {getMonitorAgentStats, getMonitorStatsById} from '@/api/monitor.ts';
import type {AgentMonitorStat, PublicMonitor} from '@/types';
import {formatDateTime, formatTime} from '@/utils/util.ts';
import {StatusBadge} from '@/components/monitor/StatusBadge';
import {TypeIcon} from '@/components/monitor/TypeIcon';
import {CertBadge} from '@/components/monitor/CertBadge';
import {cn} from '@/lib/utils';
// 常量
import {AGENT_COLORS} from '@/constants/colors';
import {MONITOR_TIME_RANGE_OPTIONS} from '@/constants/time';
import LittleStatCard from "@/components/common/LittleStatCard.tsx";
// 加载状态组件
const LoadingSpinner = () => (
<div className="flex min-h-[400px] w-full items-center justify-center">
<div className="flex flex-col items-center gap-3 text-cyan-600">
<Loader2 className="h-8 w-8 animate-spin text-cyan-400"/>
<span className="text-sm font-mono">加载监控详情中...</span>
</div>
</div>
);
// 空状态组件
const EmptyState = () => (
<div className="flex min-h-[400px] flex-col items-center justify-center text-cyan-500">
<Shield className="mb-4 h-16 w-16 opacity-20"/>
<p className="text-lg font-medium font-mono">未找到监控数据</p>
<p className="mt-2 text-sm text-cyan-600">请检查监控 ID 是否正确</p>
</div>
);
// 卡片容器组件
const Card = ({
title,
description,
action,
children
}: {
title: string;
description?: string;
action?: React.ReactNode;
children: React.ReactNode;
}) => (
<div className="group bg-[#0f1016]/80 backdrop-blur-md border border-cyan-500/20 shadow-[0_0_15px_rgba(6,182,212,0.05)] hover:border-cyan-500/30 hover:shadow-[0_0_25px_rgba(6,182,212,0.1)] transition-all duration-300 overflow-hidden relative">
{/* 装饰性边框 */}
<div className="absolute top-0 left-0 w-3 h-3 border-t-2 border-l-2 border-cyan-500/30 group-hover:border-cyan-400/50 transition-colors"></div>
<div className="absolute top-0 right-0 w-3 h-3 border-t-2 border-r-2 border-cyan-500/30 group-hover:border-cyan-400/50 transition-colors"></div>
<div className="absolute bottom-0 left-0 w-3 h-3 border-b-2 border-l-2 border-cyan-500/30 group-hover:border-cyan-400/50 transition-colors"></div>
<div className="absolute bottom-0 right-0 w-3 h-3 border-b-2 border-r-2 border-cyan-500/30 group-hover:border-cyan-400/50 transition-colors"></div>
<div className="relative z-10 p-6">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6">
<div>
<h3 className="text-lg font-bold tracking-wide text-cyan-100 uppercase">{title}</h3>
{description && <p className="text-xs text-cyan-600 mt-1 font-mono">{description}</p>}
</div>
{action && <div className="flex-shrink-0">{action}</div>}
</div>
{children}
</div>
</div>
);
// 图表占位符组件
const ChartPlaceholder = ({subtitle, heightClass}: { subtitle: string; heightClass: string }) => (
<div className={cn("flex items-center justify-center border-2 border-dashed border-cyan-900/50 rounded-lg", heightClass)}>
<div className="text-center text-cyan-600">
<Shield className="h-12 w-12 mx-auto mb-3 opacity-20"/>
<p className="text-sm font-mono">{subtitle}</p>
</div>
</div>
);
// 自定义 Tooltip 组件
const CustomTooltip = ({active, payload, label}: any) => {
if (!active || !payload || payload.length === 0) return null;
return (
<div className="bg-[#0a0b10]/95 backdrop-blur-xl border border-cyan-500/30 rounded-lg p-3 shadow-[0_0_20px_rgba(6,182,212,0.2)]">
<p className="text-xs text-cyan-400 mb-2 font-mono">{label}</p>
<div className="space-y-1">
{payload.map((entry: any, index: number) => (
<div key={index} className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full" style={{backgroundColor: entry.color}}></span>
<span className="text-xs text-cyan-100 font-mono">
{entry.name}: <span className="font-bold">{entry.value} ms</span>
</span>
</div>
))}
</div>
</div>
);
};
// 时间范围选择器组件
const TimeRangeSelector = ({
value,
onChange,
options
}: {
value: string;
onChange: (value: string) => void;
options: Array<{ value: string; label: string }>;
}) => (
<div className="flex gap-1 bg-black/40 p-1 rounded-lg border border-cyan-900/50">
{options.map(option => (
<button
key={option.value}
onClick={() => onChange(option.value)}
className={cn(
"px-3 py-1.5 text-xs font-medium rounded transition-all font-mono cursor-pointer",
value === option.value
? 'bg-cyan-500/20 text-cyan-300 border border-cyan-500/30'
: 'text-cyan-600 hover:text-cyan-400'
)}
>
{option.label}
</button>
))}
</div>
);
import {MonitorHero, ResponseTimeChart, AgentStatsTable} from '@/components/monitor';
import {EmptyState, LoadingSpinner} from '@/components/common';
/**
* 监控详情页面
* 显示监控的详细信息、响应时间趋势和各探针统计
*/
const MonitorDetail = () => {
const navigate = useNavigate();
const {id} = useParams<{ id: string }>();
const [selectedAgent, setSelectedAgent] = useState<string>('all');
const [timeRange, setTimeRange] = useState<string>('1d');
// 获取监控详情(聚合数据)
const {data: monitorDetail, isLoading} = useQuery<PublicMonitor>({
@@ -156,365 +37,38 @@ const MonitorDetail = () => {
enabled: !!id,
});
// 获取历史数据
const {data: historyData} = useQuery<GetMetricsResponse>({
queryKey: ['monitorHistory', id, timeRange],
queryFn: async () => {
if (!id) throw new Error('Monitor ID is required');
const response = await getMonitorHistory(id, timeRange);
return response.data;
},
refetchInterval: 30000,
enabled: !!id,
});
// 获取所有可用的探针列表
const availableAgents = useMemo(() => {
if (monitorStats.length === 0) return [];
return monitorStats.map(stat => ({
id: stat.agentId,
label: stat.agentId.substring(0, 8),
}));
}, [monitorStats]);
// 当可用探针列表变化时,检查当前选择的探针是否还存在
useEffect(() => {
if (selectedAgent === 'all') return;
if (!availableAgents.find(agent => agent.id === selectedAgent)) {
setSelectedAgent('all');
}
}, [availableAgents, selectedAgent]);
// 生成图表数据
const chartData = useMemo(() => {
if (!historyData?.series) return [];
// 过滤出响应时间指标的 series
const responseTimeSeries = historyData.series.filter(s => s.name === 'response_time');
// 根据选择的探针过滤
const filteredSeries = selectedAgent === 'all'
? responseTimeSeries
: responseTimeSeries.filter(s => s.labels?.agent_id === selectedAgent);
if (filteredSeries.length === 0) return [];
// 按时间戳分组数据
const grouped: Record<number, any> = {};
filteredSeries.forEach(series => {
const agentId = series.labels?.agent_id || 'unknown';
const agentKey = `agent_${agentId}`;
series.data.forEach(point => {
if (!grouped[point.timestamp]) {
grouped[point.timestamp] = {
time: new Date(point.timestamp).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
}),
timestamp: point.timestamp,
};
}
grouped[point.timestamp][agentKey] = point.value;
});
});
// 按时间戳排序
return Object.values(grouped).sort((a, b) => a.timestamp - b.timestamp);
}, [historyData, selectedAgent]);
if (isLoading) {
return (
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-8">
<LoadingSpinner/>
</div>
);
return <LoadingSpinner variant="dark"/>;
}
if (!monitorDetail) {
return (
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-8">
<EmptyState/>
</div>
);
return <EmptyState variant="dark"/>;
}
return (
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-8 space-y-6">
{/* Hero Section */}
<div className="group bg-[#0f1016]/80 backdrop-blur-md border border-cyan-500/20 shadow-[0_0_15px_rgba(6,182,212,0.05)] hover:border-cyan-500/30 hover:shadow-[0_0_25px_rgba(6,182,212,0.1)] transition-all duration-300 overflow-hidden relative">
{/* 装饰性边框 */}
<div className="absolute top-0 left-0 w-3 h-3 border-t-2 border-l-2 border-cyan-500/30 group-hover:border-cyan-400/50 transition-colors"></div>
<div className="absolute top-0 right-0 w-3 h-3 border-t-2 border-r-2 border-cyan-500/30 group-hover:border-cyan-400/50 transition-colors"></div>
<div className="absolute bottom-0 left-0 w-3 h-3 border-b-2 border-l-2 border-cyan-500/30 group-hover:border-cyan-400/50 transition-colors"></div>
<div className="absolute bottom-0 right-0 w-3 h-3 border-b-2 border-r-2 border-cyan-500/30 group-hover:border-cyan-400/50 transition-colors"></div>
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-cyan-500/5 to-transparent -translate-y-full group-hover:translate-y-full transition-transform duration-1000 ease-in-out pointer-events-none"/>
<div className="bg-[#05050a] min-h-screen">
<div className="mx-auto flex max-w-7xl flex-col px-4 pb-10 pt-4 sm:pt-6 sm:px-6 lg:px-8">
{/* 头部区域 */}
<MonitorHero
monitor={monitorDetail}
onBack={() => navigate('/monitors')}
/>
<div className="relative z-10 p-6 space-y-6">
{/* 返回按钮 */}
<button
type="button"
onClick={() => navigate('/monitors')}
className="group inline-flex items-center gap-2 text-xs font-medium uppercase tracking-wider text-cyan-600 hover:text-cyan-400 transition font-mono"
>
<ArrowLeft className="h-4 w-4 transition-transform group-hover:-translate-x-1"/>
返回监控列表
</button>
{/* 监控信息 */}
<div className="flex flex-col gap-6 lg:flex-row lg:items-start lg:justify-between">
<div className="flex items-start gap-4 flex-1 min-w-0">
<div className="p-3 bg-cyan-950/30 border border-cyan-500/20 rounded-lg flex-shrink-0">
<TypeIcon type={monitorDetail.type}/>
</div>
<div className="flex-1 min-w-0">
<div className="text-[10px] font-mono text-cyan-500/60 mb-1 tracking-wider">
MONITOR_ID: {monitorDetail.id.toString().substring(0, 8)}
</div>
<div className="flex flex-wrap items-center gap-3 mb-2">
<h1 className="text-2xl sm:text-3xl font-bold truncate text-cyan-100 tracking-wide">{monitorDetail.name}</h1>
<StatusBadge status={monitorDetail.status}/>
</div>
<p className="text-sm text-cyan-500/80 font-mono truncate">
{monitorDetail.showTargetPublic ? monitorDetail.target : '******'}
</p>
</div>
</div>
{/* 统计卡片 */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 w-full lg:w-auto lg:min-w-[480px]">
<LittleStatCard
label="监控类型"
value={monitorDetail.type.toUpperCase()}
/>
<LittleStatCard
label="探针数量"
value={monitorDetail.agentCount}
/>
<LittleStatCard
label="平均响应"
value={`${monitorDetail.responseTime}ms`}
/>
<LittleStatCard
label="最慢响应"
value={`${monitorDetail.responseTimeMax}ms`}
/>
</div>
</div>
{/* 证书信息(如果是 HTTPS)*/}
{monitorDetail.type === 'https' && monitorDetail.certExpiryTime && (
<div className="flex items-center gap-2 pt-4 border-t border-cyan-900/50">
<span className="text-xs text-cyan-500/60 font-mono">SSL 证书:</span>
<CertBadge
expiryTime={monitorDetail.certExpiryTime}
daysLeft={monitorDetail.certDaysLeft}
/>
</div>
)}
</div>
</div>
{/* 响应时间趋势图表 */}
<Card
title="响应时间趋势"
description="监控各探针的响应时间变化"
action={
<div className="flex flex-col sm:flex-row flex-wrap items-start sm:items-center gap-3">
<TimeRangeSelector
value={timeRange}
onChange={setTimeRange}
options={MONITOR_TIME_RANGE_OPTIONS}
/>
{availableAgents.length > 0 && (
<select
value={selectedAgent}
onChange={(e) => setSelectedAgent(e.target.value)}
className="rounded-lg border border-cyan-900/50 bg-black/40 px-3 py-2 text-xs font-medium text-cyan-300 hover:border-cyan-500/50 focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/20 transition-colors font-mono"
>
<option value="all">所有探针</option>
{availableAgents.map((agent) => (
<option key={agent.id} value={agent.id}>
探针 {agent.label}
</option>
))}
</select>
)}
</div>
}
>
{chartData.length > 0 ? (
<ResponsiveContainer width="100%" height={360}>
<AreaChart data={chartData}>
<defs>
{monitorStats
.filter(stat => selectedAgent === 'all' || stat.agentId === selectedAgent)
.map((stat, index) => {
const originalIndex = monitorStats.findIndex(s => s.agentId === stat.agentId);
const agentKey = `agent_${stat.agentId}`;
const color = AGENT_COLORS[originalIndex % AGENT_COLORS.length];
return (
<linearGradient key={agentKey} id={`gradient_${agentKey}`} x1="0" y1="0"
x2="0" y2="1">
<stop offset="5%" stopColor={color} stopOpacity={0.3}/>
<stop offset="95%" stopColor={color} stopOpacity={0}/>
</linearGradient>
);
})}
</defs>
<CartesianGrid
strokeDasharray="3 3"
className="stroke-cyan-900/30"
vertical={false}
/>
<XAxis
dataKey="time"
className="text-xs text-cyan-600 font-mono"
stroke="#164e63"
tickLine={false}
axisLine={false}
/>
<YAxis
className="text-xs text-cyan-600 font-mono"
stroke="#164e63"
tickLine={false}
axisLine={false}
tickFormatter={(value) => `${value}ms`}
/>
<Tooltip content={<CustomTooltip/>}/>
<Legend
wrapperStyle={{paddingTop: '20px'}}
iconType="circle"
/>
{monitorStats
.filter(stat => selectedAgent === 'all' || stat.agentId === selectedAgent)
.map((stat) => {
const originalIndex = monitorStats.findIndex(s => s.agentId === stat.agentId);
const agentKey = `agent_${stat.agentId}`;
const color = AGENT_COLORS[originalIndex % AGENT_COLORS.length];
const agentLabel = stat.agentId.substring(0, 8);
return (
<Area
key={agentKey}
type="monotone"
dataKey={agentKey}
name={`探针 ${agentLabel}`}
stroke={color}
strokeWidth={2}
fill={`url(#gradient_${agentKey})`}
activeDot={{r: 5, strokeWidth: 0}}
dot={false}
/>
);
})}
</AreaChart>
</ResponsiveContainer>
) : (
<ChartPlaceholder
subtitle="正在收集数据,请稍后查看历史趋势"
heightClass="h-80"
{/* 主内容区 */}
<main className="flex-1 py-6 sm:py-8 lg:py-10 space-y-6 sm:space-y-8 lg:space-y-10">
{/* 响应时间趋势图表 */}
<ResponseTimeChart
monitorId={id!}
monitorStats={monitorStats}
/>
)}
</Card>
{/* 各探针详细数据 */}
<Card title="探针监控详情" description="各探针的当前状态和统计数据">
<div className="overflow-x-auto -mx-6 px-6">
<table className="min-w-full">
<thead>
<tr className="border-b border-cyan-900/50">
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-widest text-cyan-500/60 font-mono">
探针 ID
</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-widest text-cyan-500/60 font-mono">
状态
</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-widest text-cyan-500/60 font-mono">
响应时间
</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-widest text-cyan-500/60 font-mono hidden lg:table-cell">
最后检测
</th>
{monitorDetail.type === 'https' && (
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-widest text-cyan-500/60 font-mono hidden xl:table-cell">
证书信息
</th>
)}
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-widest text-cyan-500/60 font-mono hidden xl:table-cell">
错误信息
</th>
</tr>
</thead>
<tbody className="divide-y divide-cyan-900/30">
{monitorStats.map((stat, index) => {
const color = AGENT_COLORS[index % AGENT_COLORS.length];
return (
<tr key={stat.agentId}
className="hover:bg-cyan-950/20 transition-colors">
<td className="px-4 py-4">
<div className="flex items-center gap-3">
<span
className="inline-block h-2 w-2 rounded-full flex-shrink-0"
style={{backgroundColor: color}}
/>
<div className="flex items-center gap-2">
<MapPin className="h-3.5 w-3.5 text-cyan-600"/>
<span
className="font-mono text-sm text-cyan-200">
{stat.agentId.substring(0, 8)}...
</span>
</div>
</div>
</td>
<td className="px-4 py-4">
<StatusBadge status={stat.status}/>
</td>
<td className="px-4 py-4">
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-cyan-600"/>
<span className="text-sm font-semibold text-cyan-100 font-mono">
{formatTime(stat.responseTime)}
</span>
</div>
</td>
<td className="px-4 py-4 text-sm text-cyan-400 font-mono hidden lg:table-cell">
{formatDateTime(stat.checkedAt)}
</td>
{monitorDetail.type === 'https' && (
<td className="px-4 py-4 hidden xl:table-cell">
{stat.certExpiryTime ? (
<CertBadge
expiryTime={stat.certExpiryTime}
daysLeft={stat.certDaysLeft}
/>
) : (
<span
className="text-xs text-cyan-600">-</span>
)}
</td>
)}
<td className="px-4 py-4 hidden xl:table-cell">
{stat.status === 'down' && stat.message ? (
<div className="flex items-start gap-2 max-w-xs">
<AlertCircle
className="h-4 w-4 text-rose-400 flex-shrink-0 mt-0.5"/>
<span
className="text-xs text-rose-300 break-words line-clamp-2 font-mono">
{stat.message}
</span>
</div>
) : (
<span className="text-xs text-cyan-600">-</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</Card>
{/* 各探针详细数据 */}
<AgentStatsTable
monitorStats={monitorStats}
monitorType={monitorDetail.type}
/>
</main>
</div>
</div>
);
};
File diff suppressed because it is too large. Load diff
+1
View File
@@ -311,6 +311,7 @@ export interface PublicMonitor {
// 探针监控统计
export interface AgentMonitorStat {
agentId: string;
agentName: string;
monitorId: string;
type: string;
target: string;