From c431f300bca6eddf1a1dc9f8cd3012d721a70481 Mon Sep 17 00:00:00 2001 From: dushixiang Date: Wed, 6 May 2026 23:18:50 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=AE=9E=E6=97=B6=E7=9B=91?= =?UTF-8?q?=E6=8E=A7=E6=A8=A1=E5=BC=8F=EF=BC=8Cagent=201s=20=E5=B9=B6?= =?UTF-8?q?=E8=A1=8C=E9=87=87=E9=9B=86=20+=20=E5=89=8D=E7=AB=AF=E6=BB=91?= =?UTF-8?q?=E5=8A=A8=E7=AA=97=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit agent - 移除 collector.interval 配置项;按指标类型内置节奏:cpu/memory/network/ disk_io/gpu/network_connection 1s,temperature 5s,disk 30s,host 60s - doCollectAndSend 改为并行执行本 tick 到期的采集器,避免串行累计耗时 超过 1s 节奏;每个 goroutine 加 recover 兜底,单点 panic 不拖垮整 tick server - LatestMetrics 加 sync.RWMutex + Update/Snapshot 方法,HandleMetricData 所有写入走 Update;GetLatestMetrics 返回 Snapshot,handler 在副本上 sanitize,修复 marshal 时与 ws 写入的 data race(go test -race 验证) - HandleMetricData defer Set 缓存项,每次上报刷新 TTL;host case 也走 Update,不再 1 小时后失效 - LatestMetrics.Timestamp 用服务端 time.Now(),agent 时钟倒拨不会冻结 前端实时图;Timestamp/DiskIOSummary 字段补到 LatestMetrics - AutoStep 新增 r ≤ 5m → 1s 档;timeRangeMilliseconds 加 2m frontend - 详情页加"实时"档位(默认),1s 拉 latestMetrics + 1m 历史播种 + 滑动 2 分钟窗口;快图表(CPU/Memory/Network/DiskIO/NetworkConnection)走 useLiveBuffer 追加,慢图表(GPU/Temperature/Monitor)保留 refetch - useLiveBuffer:seed 不再覆盖已追加的 livePoint;resetKey 处理切网卡 / 切 agent 时的语义复位 - formatChartTime live 档位精确到秒;修复 1d/24h 仅显示 HH:mm 跨夜歧义 - 慢图表用 React.memo,阻断 1Hz 节奏的级联重渲染 - GpuChart 修复 utilization/temperature 时间戳错位时温度点丢失 Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/agent/agent.example.yaml | 4 - cmd/agent/main.go | 2 - internal/handler/agent_metrics_handler.go | 9 +- internal/handler/agent_query_handler.go | 11 +- internal/metric/agent.go | 52 +++++- internal/service/metric_service.go | 173 +++++++++++------- internal/vmclient/client.go | 3 + pkg/agent/config/config.go | 16 +- pkg/agent/service/agent.go | 111 +++++++---- pkg/agent/service/service.go | 3 +- web/src/lib/format.ts | 21 ++- web/src/portal/components/CustomTooltip.tsx | 5 +- web/src/portal/components/server/CpuChart.tsx | 33 +++- .../portal/components/server/DiskIOChart.tsx | 53 +++++- web/src/portal/components/server/GpuChart.tsx | 39 +++- .../portal/components/server/MemoryChart.tsx | 33 +++- .../portal/components/server/MonitorChart.tsx | 13 +- .../portal/components/server/NetworkChart.tsx | 67 ++++++- .../server/NetworkConnectionChart.tsx | 52 +++++- .../components/server/TemperatureChart.tsx | 17 +- web/src/portal/constants/time.ts | 8 + web/src/portal/hooks/server.ts | 12 +- web/src/portal/hooks/useLiveBuffer.ts | 74 ++++++++ web/src/portal/pages/ServerDetail.tsx | 32 ++-- web/src/types/index.ts | 29 +++ 25 files changed, 655 insertions(+), 217 deletions(-) create mode 100644 web/src/portal/hooks/useLiveBuffer.ts diff --git a/cmd/agent/agent.example.yaml b/cmd/agent/agent.example.yaml index d8dd697..ee8cbe1 100644 --- a/cmd/agent/agent.example.yaml +++ b/cmd/agent/agent.example.yaml @@ -29,10 +29,6 @@ agent: # 采集器配置 collector: - # 数据采集间隔(秒) - # 建议: 5-60 秒,太短会增加服务器负载 - interval: 5 - # 网络采集包含的网卡列表(白名单,支持正则表达式) # 如果配置了此项,则只采集匹配的网卡,忽略 network_exclude # 例如: ["^eth0$", "^en0$", "^ens.*"] diff --git a/cmd/agent/main.go b/cmd/agent/main.go index 09e5a2f..f3788e6 100644 --- a/cmd/agent/main.go +++ b/cmd/agent/main.go @@ -506,7 +506,6 @@ func registerAgent(cmd *cobra.Command, args []string) { LogCompress: true, }, Collector: config.CollectorConfig{ - Interval: 5, NetworkExclude: config.DefaultNetworkExcludePatterns(), }, AutoUpdate: config.AutoUpdateConfig{ @@ -588,7 +587,6 @@ func showInfo(cmd *cobra.Command, args []string) { // 采集器配置 fmt.Println("📊 采集器配置:") - fmt.Printf(" 采集间隔: %d 秒\n", cfg.Collector.Interval) if len(cfg.Collector.NetworkExclude) > 0 { fmt.Printf(" 网卡过滤规则: %v\n", cfg.Collector.NetworkExclude) } diff --git a/internal/handler/agent_metrics_handler.go b/internal/handler/agent_metrics_handler.go index a361cc5..5b77a29 100644 --- a/internal/handler/agent_metrics_handler.go +++ b/internal/handler/agent_metrics_handler.go @@ -17,6 +17,7 @@ var validMetricTypes = map[string]struct{}{ var timeRangeMilliseconds = map[string]int64{ "1m": int64(time.Minute / time.Millisecond), + "2m": int64(2 * time.Minute / time.Millisecond), "5m": int64(5 * time.Minute / time.Millisecond), "15m": int64(15 * time.Minute / time.Millisecond), "30m": int64(30 * time.Minute / time.Millisecond), @@ -41,7 +42,7 @@ func parseTimeRange(rangeParam string) (start, end int64, err error) { durationMs, ok := timeRangeMilliseconds[rangeParam] if !ok { - return 0, 0, fmt.Errorf("无效的时间范围,支持: 1m, 5m, 15m, 30m, 1h, 3h, 6h, 12h, 1d/24h, 3d, 7d, 30d") + return 0, 0, fmt.Errorf("无效的时间范围,支持: 1m, 2m, 5m, 15m, 30m, 1h, 3h, 6h, 12h, 1d/24h, 3d, 7d, 30d") } start = end - durationMs @@ -147,12 +148,10 @@ func (h *AgentHandler) GetLatestMetrics(c echo.Context) error { if !ok { return orz.NewError(404, "探针最新指标不存在") } + // metrics 已经是独立 snapshot,未登录时直接在副本上抹掉敏感字段即可 if !isAuthenticated { - sanitized := *metrics - sanitized.NetworkInterfaces = nil - return orz.Ok(c, &sanitized) + metrics.NetworkInterfaces = nil } - return orz.Ok(c, metrics) } diff --git a/internal/handler/agent_query_handler.go b/internal/handler/agent_query_handler.go index ab4b0fa..1383ca8 100644 --- a/internal/handler/agent_query_handler.go +++ b/internal/handler/agent_query_handler.go @@ -82,14 +82,11 @@ func (h *AgentHandler) buildAgentListItem(agent models.Agent, isAuthenticated bo } metrics, ok := h.metricService.GetLatestMetrics(agent.ID) - if ok { - if !isAuthenticated && metrics != nil { - sanitized := *metrics - sanitized.NetworkInterfaces = nil - item["metrics"] = &sanitized - } else { - item["metrics"] = metrics + if ok && metrics != nil { + if !isAuthenticated { + metrics.NetworkInterfaces = nil } + item["metrics"] = metrics } return item diff --git a/internal/metric/agent.go b/internal/metric/agent.go index 83d5fec..c45fa0e 100644 --- a/internal/metric/agent.go +++ b/internal/metric/agent.go @@ -1,6 +1,10 @@ package metric -import "github.com/dushixiang/pika/internal/protocol" +import ( + "sync" + + "github.com/dushixiang/pika/internal/protocol" +) // DiskSummary 磁盘汇总数据 type DiskSummary struct { @@ -20,11 +24,28 @@ type NetworkSummary struct { TotalInterfaces int `json:"totalInterfaces"` // 网卡数量 } +// DiskIOSummary 磁盘IO汇总数据 +type DiskIOSummary struct { + TotalReadBytesRate uint64 `json:"totalReadBytesRate"` // 总读取速率(字节/秒) + TotalWriteBytesRate uint64 `json:"totalWriteBytesRate"` // 总写入速率(字节/秒) + TotalDevices int `json:"totalDevices"` // 设备数量 +} + // LatestMetrics 最新指标数据(用于API响应) +// +// 并发:写者(websocket 上报路径)通过 Update 取写锁修改字段;读者(HTTP latest 接口) +// 通过 Snapshot 取读锁拷贝出独立对象,避免序列化时与写者并发产生 data race。 +// 写者必须始终“整体替换”内部指针/切片字段(不要原地 mutate),这样读者拿到的浅拷贝 +// 仍然指向稳定的旧值。 type LatestMetrics struct { + mu sync.RWMutex `json:"-"` + + // Timestamp 探针采集该批指标的时间戳(毫秒),用于前端实时图表追加点位 + Timestamp int64 `json:"timestamp,omitempty"` CPU *protocol.CPUData `json:"cpu,omitempty"` Memory *protocol.MemoryData `json:"memory,omitempty"` Disk *DiskSummary `json:"disk,omitempty"` + DiskIO *DiskIOSummary `json:"diskIO,omitempty"` Network *NetworkSummary `json:"network,omitempty"` NetworkInterfaces []protocol.NetworkData `json:"networkInterfaces,omitempty"` NetworkConnection *protocol.NetworkConnectionData `json:"networkConnection,omitempty"` @@ -33,3 +54,32 @@ type LatestMetrics struct { Temp []protocol.TemperatureData `json:"temperature,omitempty"` Monitors []protocol.MonitorData `json:"monitors,omitempty"` } + +// Update 在写锁内执行 fn。fn 内可以读写任何字段(包括 Timestamp)。 +func (lm *LatestMetrics) Update(fn func(*LatestMetrics)) { + lm.mu.Lock() + defer lm.mu.Unlock() + fn(lm) +} + +// Snapshot 在读锁内对当前状态做浅拷贝。返回的对象拥有独立的零值互斥量, +// 调用方可以安全地读取/序列化/再 sanitize(修改返回对象上的字段不会影响缓存)。 +// 内部的指针/切片仍指向旧值,但因为写者每次都整体替换字段,所以是安全的。 +func (lm *LatestMetrics) Snapshot() *LatestMetrics { + lm.mu.RLock() + defer lm.mu.RUnlock() + return &LatestMetrics{ + Timestamp: lm.Timestamp, + CPU: lm.CPU, + Memory: lm.Memory, + Disk: lm.Disk, + DiskIO: lm.DiskIO, + Network: lm.Network, + NetworkInterfaces: lm.NetworkInterfaces, + NetworkConnection: lm.NetworkConnection, + Host: lm.Host, + GPU: lm.GPU, + Temp: lm.Temp, + Monitors: lm.Monitors, + } +} diff --git a/internal/service/metric_service.go b/internal/service/metric_service.go index 10af9ba..603f3e6 100644 --- a/internal/service/metric_service.go +++ b/internal/service/metric_service.go @@ -148,17 +148,31 @@ func (s *MetricService) CleanOrphanedAgentMetrics(ctx context.Context) error { } // HandleMetricData 处理指标数据 +// +// 并发:每次调用都会刷新 latestCache 的 TTL(defer Set),且对 latestMetrics 字段的写入 +// 走 Update 在写锁内 commit;读者侧 GetLatestMetrics 通过 Snapshot 取读锁拷贝,避免与 +// json.Marshal 之间的 data race。 +// +// 时间戳:sample.Timestamp(agent 端 wall clock)只用于写入 VictoriaMetrics 的样本时间, +// LatestMetrics.Timestamp 改用服务端 time.Now()(单调推进),避免 agent NTP 倒拨导致 +// 前端 useLiveBuffer 的去重逻辑永久冻结。 func (s *MetricService) HandleMetricData(ctx context.Context, agentID string, metricType string, data json.RawMessage, timestamp int64) error { if timestamp == 0 { timestamp = time.Now().UnixMilli() } + serverNow := time.Now().UnixMilli() - // 更新内存缓存 + // 取/建缓存项;defer 里再 Set 一次以刷新 TTL(即便本次没有任何字段变化也能延期) latestMetrics, ok := s.latestCache.Get(agentID) if !ok { latestMetrics = &metric.LatestMetrics{} - s.latestCache.Set(agentID, latestMetrics, time.Hour) } + defer s.latestCache.Set(agentID, latestMetrics, time.Hour) + + // 推进缓存时间戳:服务端 wall clock,永远向前 + latestMetrics.Update(func(lm *metric.LatestMetrics) { + lm.Timestamp = serverNow + }) // 解析数据并写入 VictoriaMetrics switch protocol.MetricType(metricType) { @@ -167,7 +181,9 @@ func (s *MetricService) HandleMetricData(ctx context.Context, agentID string, me if err := json.Unmarshal(data, &cpuData); err != nil { return err } - latestMetrics.CPU = &cpuData + latestMetrics.Update(func(lm *metric.LatestMetrics) { + lm.CPU = &cpuData + }) metrics := s.convertToMetrics(agentID, metricType, &cpuData, timestamp) return s.vmClient.Write(ctx, metrics) @@ -176,7 +192,9 @@ func (s *MetricService) HandleMetricData(ctx context.Context, agentID string, me if err := json.Unmarshal(data, &memData); err != nil { return err } - latestMetrics.Memory = &memData + latestMetrics.Update(func(lm *metric.LatestMetrics) { + lm.Memory = &memData + }) metrics := s.convertToMetrics(agentID, metricType, &memData, timestamp) return s.vmClient.Write(ctx, metrics) @@ -186,24 +204,24 @@ func (s *MetricService) HandleMetricData(ctx context.Context, agentID string, me return err } // 无有效磁盘数据时,不更新缓存(保留上一次有效值) - if len(diskDataList) == 0 { - metrics := s.convertToMetrics(agentID, metricType, diskDataList, timestamp) - return s.vmClient.Write(ctx, metrics) - } - // 计算汇总数据用于缓存 - var totalTotal, totalUsed, totalFree uint64 - for _, diskData := range diskDataList { - totalTotal += diskData.Total - totalUsed += diskData.Used - totalFree += diskData.Free - } - usagePercent := float64(totalUsed) / float64(totalTotal) * 100 - latestMetrics.Disk = &metric.DiskSummary{ - UsagePercent: usagePercent, - TotalDisks: len(diskDataList), - Total: totalTotal, - Used: totalUsed, - Free: totalFree, + if len(diskDataList) > 0 { + var totalTotal, totalUsed, totalFree uint64 + for _, diskData := range diskDataList { + totalTotal += diskData.Total + totalUsed += diskData.Used + totalFree += diskData.Free + } + usagePercent := float64(totalUsed) / float64(totalTotal) * 100 + summary := &metric.DiskSummary{ + UsagePercent: usagePercent, + TotalDisks: len(diskDataList), + Total: totalTotal, + Used: totalUsed, + Free: totalFree, + } + latestMetrics.Update(func(lm *metric.LatestMetrics) { + lm.Disk = summary + }) } metrics := s.convertToMetrics(agentID, metricType, diskDataList, timestamp) return s.vmClient.Write(ctx, metrics) @@ -214,32 +232,32 @@ func (s *MetricService) HandleMetricData(ctx context.Context, agentID string, me return err } // 无有效网络数据时,不更新缓存(保留上一次有效值) - if len(networkDataList) == 0 { - metrics := s.convertToMetrics(agentID, metricType, networkDataList, timestamp) - return s.vmClient.Write(ctx, metrics) - } - // 计算汇总数据用于缓存 - var totalSentRate, totalRecvRate uint64 - var totalSentTotal, totalRecvTotal uint64 - for _, netData := range networkDataList { - totalSentRate += netData.BytesSentRate - totalRecvRate += netData.BytesRecvRate - totalSentTotal += netData.BytesSentTotal - totalRecvTotal += netData.BytesRecvTotal - } - latestMetrics.Network = &metric.NetworkSummary{ - TotalBytesSentRate: totalSentRate, - TotalBytesRecvRate: totalRecvRate, - TotalBytesSentTotal: totalSentTotal, - TotalBytesRecvTotal: totalRecvTotal, - TotalInterfaces: len(networkDataList), - } - latestMetrics.NetworkInterfaces = networkDataList - // 更新流量统计 - if err := s.trafficService.UpdateAgentTraffic(ctx, agentID, totalRecvTotal, totalSentTotal); err != nil { - s.logger.Error("更新探针流量统计失败", - zap.String("agentId", agentID), - zap.Error(err)) + if len(networkDataList) > 0 { + var totalSentRate, totalRecvRate uint64 + var totalSentTotal, totalRecvTotal uint64 + for _, netData := range networkDataList { + totalSentRate += netData.BytesSentRate + totalRecvRate += netData.BytesRecvRate + totalSentTotal += netData.BytesSentTotal + totalRecvTotal += netData.BytesRecvTotal + } + summary := &metric.NetworkSummary{ + TotalBytesSentRate: totalSentRate, + TotalBytesRecvRate: totalRecvRate, + TotalBytesSentTotal: totalSentTotal, + TotalBytesRecvTotal: totalRecvTotal, + TotalInterfaces: len(networkDataList), + } + latestMetrics.Update(func(lm *metric.LatestMetrics) { + lm.Network = summary + lm.NetworkInterfaces = networkDataList + }) + // 更新流量统计(此处涉及外部 service,不持锁调用) + if err := s.trafficService.UpdateAgentTraffic(ctx, agentID, totalRecvTotal, totalSentTotal); err != nil { + s.logger.Error("更新探针流量统计失败", + zap.String("agentId", agentID), + zap.Error(err)) + } } metrics := s.convertToMetrics(agentID, metricType, networkDataList, timestamp) return s.vmClient.Write(ctx, metrics) @@ -249,7 +267,9 @@ func (s *MetricService) HandleMetricData(ctx context.Context, agentID string, me if err := json.Unmarshal(data, &connData); err != nil { return err } - latestMetrics.NetworkConnection = &connData + latestMetrics.Update(func(lm *metric.LatestMetrics) { + lm.NetworkConnection = &connData + }) metrics := s.convertToMetrics(agentID, metricType, &connData, timestamp) return s.vmClient.Write(ctx, metrics) @@ -258,6 +278,25 @@ func (s *MetricService) HandleMetricData(ctx context.Context, agentID string, me if err := json.Unmarshal(data, &diskIODataList); err != nil { return err } + // 无有效数据时不更新缓存(保留上一次有效值) + if len(diskIODataList) > 0 { + var totalRead, totalWrite uint64 + for _, ioData := range diskIODataList { + if ioData == nil { + continue + } + totalRead += ioData.ReadBytesRate + totalWrite += ioData.WriteBytesRate + } + summary := &metric.DiskIOSummary{ + TotalReadBytesRate: totalRead, + TotalWriteBytesRate: totalWrite, + TotalDevices: len(diskIODataList), + } + latestMetrics.Update(func(lm *metric.LatestMetrics) { + lm.DiskIO = summary + }) + } metrics := s.convertToMetrics(agentID, metricType, diskIODataList, timestamp) return s.vmClient.Write(ctx, metrics) @@ -266,7 +305,9 @@ func (s *MetricService) HandleMetricData(ctx context.Context, agentID string, me if err := json.Unmarshal(data, &hostData); err != nil { return err } - latestMetrics.Host = &hostData + latestMetrics.Update(func(lm *metric.LatestMetrics) { + lm.Host = &hostData + }) return nil case protocol.MetricTypeGPU: @@ -275,12 +316,11 @@ func (s *MetricService) HandleMetricData(ctx context.Context, agentID string, me return err } // 无 GPU 数据时,不更新缓存 - if len(gpuDataList) == 0 { - metrics := s.convertToMetrics(agentID, metricType, gpuDataList, timestamp) - return s.vmClient.Write(ctx, metrics) + if len(gpuDataList) > 0 { + latestMetrics.Update(func(lm *metric.LatestMetrics) { + lm.GPU = gpuDataList + }) } - // 更新缓存 - latestMetrics.GPU = gpuDataList metrics := s.convertToMetrics(agentID, metricType, gpuDataList, timestamp) return s.vmClient.Write(ctx, metrics) @@ -290,12 +330,11 @@ func (s *MetricService) HandleMetricData(ctx context.Context, agentID string, me return err } // 无温度数据时,不更新缓存 - if len(tempDataList) == 0 { - metrics := s.convertToMetrics(agentID, metricType, tempDataList, timestamp) - return s.vmClient.Write(ctx, metrics) + if len(tempDataList) > 0 { + latestMetrics.Update(func(lm *metric.LatestMetrics) { + lm.Temp = tempDataList + }) } - // 更新缓存 - latestMetrics.Temp = tempDataList metrics := s.convertToMetrics(agentID, metricType, tempDataList, timestamp) return s.vmClient.Write(ctx, metrics) @@ -307,8 +346,9 @@ func (s *MetricService) HandleMetricData(ctx context.Context, agentID string, me for i := range monitorDataList { monitorDataList[i].AgentId = agentID // 关联探针ID } - // 更新缓存 - latestMetrics.Monitors = monitorDataList + latestMetrics.Update(func(lm *metric.LatestMetrics) { + lm.Monitors = monitorDataList + }) for _, monitorData := range monitorDataList { s.updateMonitorCache(agentID, &monitorData, timestamp) } @@ -482,10 +522,15 @@ func (s *MetricService) updateMonitorCache(agentID string, monitorData *protocol s.monitorLatestCache.Set(monitorID, latestMetrics, 5*time.Minute) } -// GetLatestMetrics 获取最新指标 +// GetLatestMetrics 获取最新指标的快照 +// 返回值是缓存项的浅拷贝(无互斥量),调用方可以安全 marshal 或在副本上 sanitize 字段, +// 不会与上报路径产生 data race。 func (s *MetricService) GetLatestMetrics(agentID string) (*metric.LatestMetrics, bool) { metrics, ok := s.latestCache.Get(agentID) - return metrics, ok + if !ok { + return nil, false + } + return metrics.Snapshot(), true } // DeleteAgentMetrics 删除探针在 VictoriaMetrics 中的历史指标 diff --git a/internal/vmclient/client.go b/internal/vmclient/client.go index 896ab56..1d5eff9 100644 --- a/internal/vmclient/client.go +++ b/internal/vmclient/client.go @@ -114,6 +114,9 @@ func AutoStep(start, end time.Time) time.Duration { r := end.Sub(start) switch { + case r <= 5*time.Minute: + // 实时/极短窗口:与探针 1s 采集对齐,避免聚合丢点 + return 1 * time.Second case r <= time.Hour: return 10 * time.Second case r <= 3*time.Hour: diff --git a/pkg/agent/config/config.go b/pkg/agent/config/config.go index a052fd9..c46301e 100644 --- a/pkg/agent/config/config.go +++ b/pkg/agent/config/config.go @@ -69,9 +69,6 @@ type AgentConfig struct { // CollectorConfig 采集器配置 type CollectorConfig struct { - // 数据采集间隔(秒) - Interval int `yaml:"interval"` - // 网络采集包含的网卡列表(白名单,支持正则表达式) // 如果配置了此项,则只采集匹配的网卡,忽略 NetworkExclude // 例如: ["^eth0$", "^en0$", "^ens.*"] @@ -116,9 +113,7 @@ func DefaultConfig() *Config { LogMaxAge: 28, LogCompress: true, }, - Collector: CollectorConfig{ - Interval: 5, - }, + Collector: CollectorConfig{}, AutoUpdate: AutoUpdateConfig{ Enabled: true, CheckInterval: "10m", @@ -197,10 +192,6 @@ func (c *Config) Save(path string) error { // Validate 验证配置 func (c *Config) Validate() error { - if c.Collector.Interval <= 0 { - c.Collector.Interval = 5 - } - if c.AutoUpdate.Enabled { if _, err := time.ParseDuration(c.AutoUpdate.CheckInterval); err != nil { return fmt.Errorf("更新检查间隔格式错误: %w", err) @@ -224,11 +215,6 @@ func (c *Config) Validate() error { return nil } -// GetCollectorInterval 获取采集间隔时长 -func (c *Config) GetCollectorInterval() time.Duration { - return time.Duration(c.Collector.Interval) * time.Second -} - // GetUpdateCheckInterval 获取更新检查间隔时长 func (c *Config) GetUpdateCheckInterval() time.Duration { duration, _ := time.ParseDuration(c.AutoUpdate.CheckInterval) diff --git a/pkg/agent/service/agent.go b/pkg/agent/service/agent.go index 84d49a3..7c645d2 100644 --- a/pkg/agent/service/agent.go +++ b/pkg/agent/service/agent.go @@ -486,6 +486,9 @@ func (a *Agent) getCollectorManager() *collector.Manager { return a.collectorManager } +// collectorBaseInterval 采集循环的基础节奏,所有采集器的采集间隔必须是它的整数倍 +const collectorBaseInterval = 1 * time.Second + // metricsLoop 指标采集循环 func (a *Agent) metricsLoop(ctx context.Context) { manager := a.getCollectorManager() @@ -494,20 +497,20 @@ func (a *Agent) metricsLoop(ctx context.Context) { a.setCollectorManager(manager) } - // 立即采集一次动态数据 - if err := a.collectAndSendAllMetrics(manager); err != nil { + // 立即采集一次(tick=0 时所有采集器都到期,等于全量采集) + var tickCount uint64 + if err := a.collectAndSendAllMetrics(manager, tickCount); err != nil { slog.Warn("初始数据采集失败", "error", err) } - // 定时采集动态指标 - ticker := time.NewTicker(a.cfg.GetCollectorInterval()) + ticker := time.NewTicker(collectorBaseInterval) defer ticker.Stop() for { select { case <-ticker.C: - // 采集并发送各种动态指标 - if err := a.collectAndSendAllMetrics(manager); err != nil { + tickCount++ + if err := a.collectAndSendAllMetrics(manager, tickCount); err != nil { slog.Warn("数据采集失败", "error", err) } case <-ctx.Done(): @@ -517,7 +520,7 @@ func (a *Agent) metricsLoop(ctx context.Context) { } // collectAndSendAllMetrics 采集并发送所有动态指标 -func (a *Agent) collectAndSendAllMetrics(manager *collector.Manager) error { +func (a *Agent) collectAndSendAllMetrics(manager *collector.Manager, tickCount uint64) error { if manager == nil { return fmt.Errorf("采集器未初始化") } @@ -528,7 +531,7 @@ func (a *Agent) collectAndSendAllMetrics(manager *collector.Manager) error { done := make(chan result, 1) go func() { - done <- result{err: a.doCollectAndSend(manager)} + done <- result{err: a.doCollectAndSend(manager, tickCount)} }() select { @@ -560,7 +563,8 @@ func (t *collectTimer) done() { type collectFn func() (protocol.MetricSample, error) // doCollectAndSend 采集所有动态指标,打包成一个 batch 上报 -func (a *Agent) doCollectAndSend(manager *collector.Manager) error { +// 各采集器按自身 interval 节流;本 tick 到期的采集器并行执行,避免串行累计耗时压垮 1s 节奏 +func (a *Agent) doCollectAndSend(manager *collector.Manager, tickCount uint64) error { conn := a.getActiveConn() if conn != nil { if sent, err := a.outboundBuffer.Flush(conn); err != nil { @@ -571,45 +575,80 @@ func (a *Agent) doCollectAndSend(manager *collector.Manager) error { } // required=true 时采集失败计入错误;required=false 适用于 GPU/温度等可选项 + // interval 必须为 collectorBaseInterval 的整数倍 collectors := []struct { name string required bool + interval time.Duration fn collectFn }{ - {"cpu", true, manager.CollectCPU}, - {"memory", true, manager.CollectMemory}, - {"disk", true, manager.CollectDisk}, - {"disk_io", true, manager.CollectDiskIO}, - {"network", true, manager.CollectNetwork}, - {"network_connection", true, manager.CollectNetworkConnection}, - {"host", true, manager.CollectHost}, - {"gpu", false, manager.CollectGPU}, - {"temperature", false, manager.CollectTemperature}, + {"cpu", true, 1 * time.Second, manager.CollectCPU}, + {"memory", true, 1 * time.Second, manager.CollectMemory}, + {"disk_io", true, 1 * time.Second, manager.CollectDiskIO}, + {"network", true, 1 * time.Second, manager.CollectNetwork}, + {"gpu", false, 1 * time.Second, manager.CollectGPU}, + {"network_connection", true, 1 * time.Second, manager.CollectNetworkConnection}, + {"temperature", false, 5 * time.Second, manager.CollectTemperature}, + {"disk", true, 30 * time.Second, manager.CollectDisk}, + {"host", true, 60 * time.Second, manager.CollectHost}, } - samples := make([]protocol.MetricSample, 0, len(collectors)) - var hasError bool + type collectResult struct { + name string + required bool + sample protocol.MetricSample + err error + } - for _, c := range collectors { - func() { + // 收集本 tick 到期的采集器 + due := make([]int, 0, len(collectors)) + for i, c := range collectors { + every := uint64(c.interval / collectorBaseInterval) + if every == 0 || tickCount%every != 0 { + continue + } + due = append(due, i) + } + + results := make(chan collectResult, len(due)) + var wg sync.WaitGroup + for _, idx := range due { + c := collectors[idx] + wg.Add(1) + go func() { + defer wg.Done() + defer func() { + if r := recover(); r != nil { + slog.Error("采集器 panic", "collector", c.name, "panic", r) + results <- collectResult{name: c.name, required: c.required, err: fmt.Errorf("panic: %v", r)} + } + }() t := newCollectTimer(c.name) - defer t.done() sample, err := c.fn() - if err != nil { - if errors.Is(err, collector.ErrNoData) { - return - } - if c.required { - slog.Warn("采集指标失败", "collector", c.name, "error", err) - hasError = true - } else { - slog.Info("采集可选指标失败", "collector", c.name, "error", err) - } - return - } - samples = append(samples, sample) + t.done() + results <- collectResult{name: c.name, required: c.required, sample: sample, err: err} }() } + wg.Wait() + close(results) + + samples := make([]protocol.MetricSample, 0, len(due)) + var hasError bool + for r := range results { + if r.err != nil { + if errors.Is(r.err, collector.ErrNoData) { + continue + } + if r.required { + slog.Warn("采集指标失败", "collector", r.name, "error", r.err) + hasError = true + } else { + slog.Info("采集可选指标失败", "collector", r.name, "error", r.err) + } + continue + } + samples = append(samples, r.sample) + } writer := newOutboundWriter(conn, a.outboundBuffer) diff --git a/pkg/agent/service/service.go b/pkg/agent/service/service.go index c93a9fa..4fd9cad 100644 --- a/pkg/agent/service/service.go +++ b/pkg/agent/service/service.go @@ -290,8 +290,7 @@ func (m *ServiceManager) Run() error { }) slog.Info("配置加载成功", - "server_endpoint", m.cfg.Server.Endpoint, - "collector_interval", m.cfg.GetCollectorInterval()) + "server_endpoint", m.cfg.Server.Endpoint) // 初始化系统配置(Linux ICMP 权限等) configureICMP() diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts index dbd2268..5fd7221 100644 --- a/web/src/lib/format.ts +++ b/web/src/lib/format.ts @@ -68,12 +68,23 @@ export const formatUptime = (seconds: number | undefined | null): string => { */ export const formatChartTime = (timestamp: number, timeRange: string, rangeMs?: number): string => { const date = new Date(timestamp); - - // 解析时间范围,判断是否超过1天 + + // 实时模式:精确到秒 + if (timeRange === 'live') { + return date.toLocaleTimeString('zh-CN', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }); + } + + // 跨夜的窗口需要带日期,否则 "10:30" 在前一天和今天的图上是同一个标签 + // 1d / 24h 严格意义上等于一天,但实际跨夜,归到 long range const isLongRange = rangeMs !== undefined - ? rangeMs > 24 * 60 * 60 * 1000 - : timeRange.endsWith('d') && parseInt(timeRange) > 1; - + ? rangeMs >= 24 * 60 * 60 * 1000 + : timeRange === '1d' || timeRange === '24h' || (timeRange.endsWith('d') && parseInt(timeRange) > 1); + if (isLongRange) { // 超过1天:显示 "月/日 时:分" const month = String(date.getMonth() + 1).padStart(2, '0'); diff --git a/web/src/portal/components/CustomTooltip.tsx b/web/src/portal/components/CustomTooltip.tsx index 2414980..1d913e3 100644 --- a/web/src/portal/components/CustomTooltip.tsx +++ b/web/src/portal/components/CustomTooltip.tsx @@ -16,9 +16,10 @@ type CustomTooltipProps = { label?: string | number; unit?: string; className?: string; + timeFormat?: string; }; -export const CustomTooltip = ({active, payload, label, unit = '%', className}: CustomTooltipProps) => { +export const CustomTooltip = ({active, payload, label, unit = '%', className, timeFormat = 'MM-DD HH:mm'}: CustomTooltipProps) => { if (!active || !payload || payload.length === 0) { return null; } @@ -26,7 +27,7 @@ export const CustomTooltip = ({active, payload, label, unit = '%', className}: C // 从 payload 中获取完整的时间戳信息(如果有的话) const fullTimestamp = payload[0]?.payload?.timestamp; const displayLabel = fullTimestamp - ? dayjs(fullTimestamp).format('MM-DD HH:mm') + ? dayjs(fullTimestamp).format(timeFormat) : label; return ( diff --git a/web/src/portal/components/server/CpuChart.tsx b/web/src/portal/components/server/CpuChart.tsx index 806a1f8..fe39f14 100644 --- a/web/src/portal/components/server/CpuChart.tsx +++ b/web/src/portal/components/server/CpuChart.tsx @@ -4,41 +4,61 @@ import {Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAx import {ChartPlaceholder} from '@portal/components/ChartPlaceholder'; import {CustomTooltip} from '@portal/components/CustomTooltip'; import {useMetricsQuery} from '@portal/hooks/server'; +import {useLiveBuffer} from '@portal/hooks/useLiveBuffer'; +import {LIVE_INITIAL_RANGE, LIVE_WINDOW_MS} from '@portal/constants/time'; import {ChartContainer} from './ChartContainer'; import {formatChartTime} from '@/lib/format.ts'; +import type {LatestMetrics} from '@/types'; interface CpuChartProps { agentId: string; timeRange: string; start?: number; end?: number; + isLive?: boolean; + latestMetrics?: LatestMetrics | null; +} + +interface CpuPoint { + timestamp: number; + usage: number; } /** * CPU 使用率图表组件 */ -export const CpuChart = ({agentId, timeRange, start, end}: CpuChartProps) => { +export const CpuChart = ({agentId, timeRange, start, end, isLive, latestMetrics}: CpuChartProps) => { const rangeMs = start !== undefined && end !== undefined ? end - start : undefined; + const effectiveRange = isLive ? LIVE_INITIAL_RANGE : timeRange; // 数据查询 const {data: metricsResponse, isLoading} = useMetricsQuery({ agentId, type: 'cpu', - range: start !== undefined && end !== undefined ? undefined : timeRange, + range: start !== undefined && end !== undefined ? undefined : effectiveRange, start, end, }); - // 数据转换 - const chartData = useMemo(() => { + // 历史数据 + const initialData = useMemo(() => { const cpuSeries = metricsResponse?.data.series?.find(s => s.name === 'usage'); if (!cpuSeries) return []; - return cpuSeries.data.map((point) => ({ usage: Number(point.value.toFixed(2)), timestamp: point.timestamp, })); }, [metricsResponse]); + // 实时点 + const livePoint = useMemo(() => { + if (!isLive || !latestMetrics?.cpu || !latestMetrics.timestamp) return null; + const usage = latestMetrics.cpu.usagePercent; + if (typeof usage !== 'number' || !Number.isFinite(usage)) return null; + return {timestamp: latestMetrics.timestamp, usage: Number(usage.toFixed(2))}; + }, [isLive, latestMetrics]); + + const chartData = useLiveBuffer(initialData, !!isLive, livePoint, LIVE_WINDOW_MS, agentId); + // 渲染 if (isLoading) { return ( @@ -77,7 +97,7 @@ export const CpuChart = ({agentId, timeRange, start, end}: CpuChartProps) => { className="stroke-gray-400 dark:stroke-cyan-600 text-xs" tickFormatter={(value) => `${value}%`} /> - }/> + }/> { fill="url(#cpuAreaGradient)" activeDot={{r: 3}} connectNulls + isAnimationActive={!isLive} /> diff --git a/web/src/portal/components/server/DiskIOChart.tsx b/web/src/portal/components/server/DiskIOChart.tsx index 2770cd1..aea0a45 100644 --- a/web/src/portal/components/server/DiskIOChart.tsx +++ b/web/src/portal/components/server/DiskIOChart.tsx @@ -4,32 +4,46 @@ import {Area, AreaChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XA import {ChartPlaceholder} from '@portal/components/ChartPlaceholder'; import {CustomTooltip} from '@portal/components/CustomTooltip'; import {useMetricsQuery} from '@portal/hooks/server'; +import {useLiveBuffer} from '@portal/hooks/useLiveBuffer'; +import {LIVE_INITIAL_RANGE, LIVE_WINDOW_MS} from '@portal/constants/time'; import {ChartContainer} from './ChartContainer'; import {formatChartTime} from '@/lib/format.ts'; +import type {LatestMetrics} from '@/types'; interface DiskIOChartProps { agentId: string; timeRange: string; start?: number; end?: number; + isLive?: boolean; + latestMetrics?: LatestMetrics | null; } +interface DiskIOPoint { + timestamp: number; + read: number; + write: number; +} + +const toMB = (bytes: number) => Number((bytes / 1024 / 1024).toFixed(2)); + /** * 磁盘 I/O 图表组件 */ -export const DiskIOChart = ({agentId, timeRange, start, end}: DiskIOChartProps) => { +export const DiskIOChart = ({agentId, timeRange, start, end, isLive, latestMetrics}: DiskIOChartProps) => { const rangeMs = start !== undefined && end !== undefined ? end - start : undefined; + const effectiveRange = isLive ? LIVE_INITIAL_RANGE : timeRange; // 数据查询 const {data: metricsResponse, isLoading} = useMetricsQuery({ agentId, type: 'disk_io', - range: start !== undefined && end !== undefined ? undefined : timeRange, + range: start !== undefined && end !== undefined ? undefined : effectiveRange, start, end, }); - // 数据转换 - const chartData = useMemo(() => { + // 历史数据 + const initialData = useMemo(() => { if (!metricsResponse?.data.series || metricsResponse.data.series?.length === 0) return []; const readSeries = metricsResponse.data.series?.find(s => s.name === 'read'); @@ -37,25 +51,44 @@ export const DiskIOChart = ({agentId, timeRange, start, end}: DiskIOChartProps) if (!readSeries || !writeSeries) return []; - const timeMap = new Map(); + const timeMap = new Map(); readSeries.data.forEach(point => { timeMap.set(point.timestamp, { timestamp: point.timestamp, - read: Number((point.value / 1024 / 1024).toFixed(2)), + read: toMB(point.value), + write: 0, }); }); writeSeries.data.forEach(point => { const existing = timeMap.get(point.timestamp); if (existing) { - existing.write = Number((point.value / 1024 / 1024).toFixed(2)); + existing.write = toMB(point.value); + } else { + timeMap.set(point.timestamp, { + timestamp: point.timestamp, + read: 0, + write: toMB(point.value), + }); } }); - return Array.from(timeMap.values()); + return Array.from(timeMap.values()).sort((a, b) => a.timestamp - b.timestamp); }, [metricsResponse]); + // 实时点:用 latestMetrics.timestamp 作为同批次时间锚 + const livePoint = useMemo(() => { + if (!isLive || !latestMetrics?.diskIO || !latestMetrics.timestamp) return null; + return { + timestamp: latestMetrics.timestamp, + read: toMB(latestMetrics.diskIO.totalReadBytesRate), + write: toMB(latestMetrics.diskIO.totalWriteBytesRate), + }; + }, [isLive, latestMetrics]); + + const chartData = useLiveBuffer(initialData, !!isLive, livePoint, LIVE_WINDOW_MS, agentId); + // 渲染 if (isLoading) { return ( @@ -98,7 +131,7 @@ export const DiskIOChart = ({agentId, timeRange, start, end}: DiskIOChartProps) className="stroke-gray-400 dark:stroke-cyan-600 text-xs" tickFormatter={(value) => `${value} MB`} /> - }/> + }/> diff --git a/web/src/portal/components/server/GpuChart.tsx b/web/src/portal/components/server/GpuChart.tsx index 7a77c4b..a686a79 100644 --- a/web/src/portal/components/server/GpuChart.tsx +++ b/web/src/portal/components/server/GpuChart.tsx @@ -1,9 +1,10 @@ -import {useMemo} from 'react'; +import {memo, useMemo} from 'react'; import {Zap} from 'lucide-react'; import {CartesianGrid, Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis} from 'recharts'; import {ChartPlaceholder} from '@portal/components/ChartPlaceholder'; import {CustomTooltip} from '@portal/components/CustomTooltip'; import {useMetricsQuery} from '@portal/hooks/server'; +import {LIVE_INITIAL_RANGE} from '@portal/constants/time'; import {ChartContainer} from './ChartContainer'; import {formatChartTime} from '@/lib/format.ts'; @@ -12,47 +13,63 @@ interface GpuChartProps { timeRange: string; start?: number; end?: number; + isLive?: boolean; } /** * GPU 使用率与温度图表组件 * 使用双 Y 轴显示使用率和温度 + * + * 实时模式下不走 livePoint 追加(多卡聚合复杂),改为 5s 轮询 refetch;用 React.memo + * 阻断 ServerDetail 1s 节奏的级联重渲染,避免 Recharts 每秒空转。 */ -export const GpuChart = ({agentId, timeRange, start, end}: GpuChartProps) => { +const GpuChartImpl = ({agentId, timeRange, start, end, isLive}: GpuChartProps) => { const rangeMs = start !== undefined && end !== undefined ? end - start : undefined; + const effectiveRange = isLive ? LIVE_INITIAL_RANGE : timeRange; // 数据查询 const {data: metricsResponse, isLoading} = useMetricsQuery({ agentId, type: 'gpu', - range: start !== undefined && end !== undefined ? undefined : timeRange, + range: start !== undefined && end !== undefined ? undefined : effectiveRange, start, end, + refetchIntervalMs: isLive ? 5000 : undefined, }); // 数据转换 const chartData = useMemo(() => { if (!metricsResponse?.data.series || metricsResponse.data.series?.length === 0) return []; - const timeMap = new Map(); + const timeMap = new Map(); const utilizationSeries = metricsResponse.data?.series?.find(s => s.name === 'utilization'); const temperatureSeries = metricsResponse.data?.series?.find(s => s.name === 'temperature'); utilizationSeries?.data.forEach(point => { - timeMap.set(point.timestamp, { - timestamp: point.timestamp, - utilization: Number(point.value.toFixed(2)), - }); + const existing = timeMap.get(point.timestamp); + if (existing) { + existing.utilization = Number(point.value.toFixed(2)); + } else { + timeMap.set(point.timestamp, { + 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)); + } else { + timeMap.set(point.timestamp, { + timestamp: point.timestamp, + temperature: Number(point.value.toFixed(2)), + }); } }); - return Array.from(timeMap.values()); + return Array.from(timeMap.values()).sort((a, b) => a.timestamp - b.timestamp); }, [metricsResponse]); // 渲染 @@ -111,6 +128,7 @@ export const GpuChart = ({agentId, timeRange, start, end}: GpuChartProps) => { dot={false} activeDot={{r: 3}} connectNulls + isAnimationActive={!isLive} /> { dot={false} activeDot={{r: 3}} connectNulls + isAnimationActive={!isLive} /> ); }; + +export const GpuChart = memo(GpuChartImpl); diff --git a/web/src/portal/components/server/MemoryChart.tsx b/web/src/portal/components/server/MemoryChart.tsx index 3783b38..0035cea 100644 --- a/web/src/portal/components/server/MemoryChart.tsx +++ b/web/src/portal/components/server/MemoryChart.tsx @@ -4,41 +4,61 @@ import {Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAx import {ChartPlaceholder} from '@portal/components/ChartPlaceholder'; import {CustomTooltip} from '@portal/components/CustomTooltip'; import {useMetricsQuery} from '@portal/hooks/server'; +import {useLiveBuffer} from '@portal/hooks/useLiveBuffer'; +import {LIVE_INITIAL_RANGE, LIVE_WINDOW_MS} from '@portal/constants/time'; import {ChartContainer} from './ChartContainer'; import {formatChartTime} from '@/lib/format.ts'; +import type {LatestMetrics} from '@/types'; interface MemoryChartProps { agentId: string; timeRange: string; start?: number; end?: number; + isLive?: boolean; + latestMetrics?: LatestMetrics | null; +} + +interface MemoryPoint { + timestamp: number; + usage: number; } /** * 内存使用率图表组件 */ -export const MemoryChart = ({agentId, timeRange, start, end}: MemoryChartProps) => { +export const MemoryChart = ({agentId, timeRange, start, end, isLive, latestMetrics}: MemoryChartProps) => { const rangeMs = start !== undefined && end !== undefined ? end - start : undefined; + const effectiveRange = isLive ? LIVE_INITIAL_RANGE : timeRange; // 数据查询 const {data: metricsResponse, isLoading} = useMetricsQuery({ agentId, type: 'memory', - range: start !== undefined && end !== undefined ? undefined : timeRange, + range: start !== undefined && end !== undefined ? undefined : effectiveRange, start, end, }); - // 数据转换 - const chartData = useMemo(() => { + // 历史数据 + const initialData = useMemo(() => { const memorySeries = metricsResponse?.data.series?.find(s => s.name === 'usage'); if (!memorySeries) return []; - return memorySeries.data.map((point) => ({ usage: Number(point.value.toFixed(2)), timestamp: point.timestamp, })); }, [metricsResponse]); + // 实时点 + const livePoint = useMemo(() => { + if (!isLive || !latestMetrics?.memory || !latestMetrics.timestamp) return null; + const usage = latestMetrics.memory.usagePercent; + if (typeof usage !== 'number' || !Number.isFinite(usage)) return null; + return {timestamp: latestMetrics.timestamp, usage: Number(usage.toFixed(2))}; + }, [isLive, latestMetrics]); + + const chartData = useLiveBuffer(initialData, !!isLive, livePoint, LIVE_WINDOW_MS, agentId); + // 渲染 if (isLoading) { return ( @@ -77,7 +97,7 @@ export const MemoryChart = ({agentId, timeRange, start, end}: MemoryChartProps) className="stroke-gray-400 dark:stroke-cyan-600 text-xs" tickFormatter={(value) => `${value}%`} /> - }/> + }/> diff --git a/web/src/portal/components/server/MonitorChart.tsx b/web/src/portal/components/server/MonitorChart.tsx index f228b7b..bdd6fe1 100644 --- a/web/src/portal/components/server/MonitorChart.tsx +++ b/web/src/portal/components/server/MonitorChart.tsx @@ -1,4 +1,4 @@ -import {useEffect, useMemo, useState} from 'react'; +import {memo, useEffect, useMemo, useState} from 'react'; import {Activity, RotateCcw, ChevronDown, ChevronUp} from 'lucide-react'; import {Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis} from 'recharts'; import {ChartPlaceholder} from '@portal/components/ChartPlaceholder'; @@ -13,6 +13,7 @@ interface MonitorChartProps { timeRange: string; start?: number; end?: number; + isLive?: boolean; } /** @@ -154,19 +155,22 @@ const CustomLegend = ({ onClick, selectedMonitors, allMonitorKeys, colors, colla /** * 监控响应时间图表组件 */ -export const MonitorChart = ({agentId, timeRange, start, end}: MonitorChartProps) => { +const MonitorChartImpl = ({agentId, timeRange, start, end, isLive}: MonitorChartProps) => { const isMobile = useIsMobile(); const rangeMs = start !== undefined && end !== undefined ? end - start : undefined; const [selectedMonitors, setSelectedMonitors] = useState>(new Set()); const [legendCollapsed, setLegendCollapsed] = useState(true); // 移动端默认收起 + // 监控任务由探针自定义周期上报,实时模式下保留 15m 视图,10s 重查 + const effectiveRange = isLive ? '15m' : timeRange; // 数据查询 const {data: metricsResponse, isLoading} = useMetricsQuery({ agentId, type: 'monitor', - range: start !== undefined && end !== undefined ? undefined : timeRange, + range: start !== undefined && end !== undefined ? undefined : effectiveRange, start, end, + refetchIntervalMs: isLive ? 10000 : undefined, }); // 获取所有监控任务的列表(使用名称) @@ -428,6 +432,7 @@ export const MonitorChart = ({agentId, timeRange, start, end}: MonitorChartProps connectNulls onClick={handleAreaClick} style={{cursor: 'pointer'}} + isAnimationActive={!isLive} /> ); })} @@ -470,3 +475,5 @@ export const MonitorChart = ({agentId, timeRange, start, end}: MonitorChartProps ); }; + +export const MonitorChart = memo(MonitorChartImpl); diff --git a/web/src/portal/components/server/NetworkChart.tsx b/web/src/portal/components/server/NetworkChart.tsx index df4f53c..eb87030 100644 --- a/web/src/portal/components/server/NetworkChart.tsx +++ b/web/src/portal/components/server/NetworkChart.tsx @@ -4,24 +4,38 @@ import {Area, AreaChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XA import {ChartPlaceholder} from '@portal/components/ChartPlaceholder'; import {CustomTooltip} from '@portal/components/CustomTooltip'; import {useMetricsQuery, useNetworkInterfacesQuery} from '@portal/hooks/server'; +import {useLiveBuffer} from '@portal/hooks/useLiveBuffer'; +import {LIVE_INITIAL_RANGE, LIVE_WINDOW_MS} from '@portal/constants/time'; import {INTERFACE_COLORS} from '@portal/constants/server'; import {ChartContainer} from './ChartContainer'; import {formatChartTime} from '@/lib/format.ts'; +import type {LatestMetrics} from '@/types'; interface NetworkChartProps { agentId: string; timeRange: string; start?: number; end?: number; + isLive?: boolean; + latestMetrics?: LatestMetrics | null; } +interface NetworkPoint { + timestamp: number; + upload: number; + download: number; +} + +const toMB = (bytes: number) => Number((bytes / 1024 / 1024).toFixed(2)); + /** * 网络流量图表组件 * 支持网卡切换 */ -export const NetworkChart = ({agentId, timeRange, start, end}: NetworkChartProps) => { +export const NetworkChart = ({agentId, timeRange, start, end, isLive, latestMetrics}: NetworkChartProps) => { const [selectedInterface, setSelectedInterface] = useState('all'); const rangeMs = start !== undefined && end !== undefined ? end - start : undefined; + const effectiveRange = isLive ? LIVE_INITIAL_RANGE : timeRange; // 查询网卡列表 const {data: interfacesData} = useNetworkInterfacesQuery(agentId); @@ -40,14 +54,14 @@ export const NetworkChart = ({agentId, timeRange, start, end}: NetworkChartProps const {data: metricsResponse, isLoading} = useMetricsQuery({ agentId, type: 'network', - range: start !== undefined && end !== undefined ? undefined : timeRange, + range: start !== undefined && end !== undefined ? undefined : effectiveRange, start, end, interfaceName: selectedInterface !== 'all' ? selectedInterface : undefined, }); - // 数据转换 - const chartData = useMemo(() => { + // 历史数据 + const initialData = useMemo(() => { if (!metricsResponse?.data.series || metricsResponse.data.series?.length === 0) return []; const uploadSeries = metricsResponse.data.series?.find(s => s.name === 'upload'); @@ -55,25 +69,58 @@ export const NetworkChart = ({agentId, timeRange, start, end}: NetworkChartProps if (!uploadSeries || !downloadSeries) return []; - const timeMap = new Map(); + const timeMap = new Map(); uploadSeries.data.forEach(point => { timeMap.set(point.timestamp, { timestamp: point.timestamp, - upload: Number((point.value / 1024 / 1024).toFixed(2)), + upload: toMB(point.value), + download: 0, }); }); downloadSeries.data.forEach(point => { const existing = timeMap.get(point.timestamp); if (existing) { - existing.download = Number((point.value / 1024 / 1024).toFixed(2)); + existing.download = toMB(point.value); + } else { + timeMap.set(point.timestamp, { + timestamp: point.timestamp, + upload: 0, + download: toMB(point.value), + }); } }); - return Array.from(timeMap.values()); + return Array.from(timeMap.values()).sort((a, b) => a.timestamp - b.timestamp); }, [metricsResponse]); + // 实时点:用 latestMetrics.timestamp 作为采集批次时间锚点 + const livePoint = useMemo(() => { + if (!isLive || !latestMetrics?.timestamp) return null; + + let sentRate = 0; + let recvRate = 0; + if (selectedInterface === 'all') { + const summary = latestMetrics.network; + if (!summary) return null; + sentRate = summary.totalBytesSentRate; + recvRate = summary.totalBytesRecvRate; + } else { + const iface = latestMetrics.networkInterfaces?.find(i => i.interface === selectedInterface); + if (!iface) return null; + sentRate = iface.bytesSentRate; + recvRate = iface.bytesRecvRate; + } + return { + timestamp: latestMetrics.timestamp, + upload: toMB(sentRate), + download: toMB(recvRate), + }; + }, [isLive, latestMetrics, selectedInterface]); + + const chartData = useLiveBuffer(initialData, !!isLive, livePoint, LIVE_WINDOW_MS, `${agentId}|${selectedInterface}`); + // 网卡选择器 const interfaceSelector = availableInterfaces.length > 0 && (