Files
dushixiangandClaude Opus 4.7 c431f300bc 新增实时监控模式,agent 1s 并行采集 + 前端滑动窗口
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) <noreply@anthropic.com>
2026-05-06 23:18:50 +08:00

344 lines
8.6 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package vmclient
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"
)
// VMClient VictoriaMetrics 客户端
type VMClient struct {
baseURL string
httpClient *http.Client
writeTimeout time.Duration
queryTimeout time.Duration
}
// QueryResult 查询结果
type QueryResult struct {
Status string `json:"status"`
Data ResultData `json:"data"`
}
// ResultData 查询结果数据
type ResultData struct {
ResultType string `json:"resultType"`
Result []Result `json:"result"`
}
// Result 单个时间序列结果
type Result struct {
Metric map[string]string `json:"metric"`
Values [][]interface{} `json:"values"` // [[timestamp, value], ...]
}
// DataPoint 数据点
type DataPoint struct {
Timestamp int64
Value float64
Labels map[string]string
}
// Metric VictoriaMetrics JSON Line Format 指标
type Metric struct {
Metric map[string]string `json:"metric"`
Values []float64 `json:"values"`
Timestamps []int64 `json:"timestamps"`
}
// NewVMClient 创建 VictoriaMetrics 客户端
func NewVMClient(baseURL string, writeTimeout, queryTimeout time.Duration) *VMClient {
if writeTimeout == 0 {
writeTimeout = 30 * time.Second
}
if queryTimeout == 0 {
queryTimeout = 60 * time.Second
}
return &VMClient{
baseURL: baseURL,
httpClient: &http.Client{
Timeout: writeTimeout, // 默认超时
},
writeTimeout: writeTimeout,
queryTimeout: queryTimeout,
}
}
// Write 写入指标(VictoriaMetrics JSON Line Format)
func (c *VMClient) Write(ctx context.Context, metrics []Metric) error {
if len(metrics) == 0 {
return nil
}
reqCtx, cancel := context.WithTimeout(ctx, c.writeTimeout)
defer cancel()
// 将 Metric 数组转换为 JSON Line Format (NDJSON)
var buf bytes.Buffer
encoder := json.NewEncoder(&buf)
for _, metric := range metrics {
if err := encoder.Encode(metric); err != nil {
return fmt.Errorf("encode metric failed: %w", err)
}
}
req, err := http.NewRequestWithContext(reqCtx, "POST", c.baseURL+"/api/v1/import", &buf)
if err != nil {
return fmt.Errorf("create request failed: %w", err)
}
req.Header.Set("Content-Type", "application/x-ndjson")
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("write metrics failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("write metrics failed with status %d: %s", resp.StatusCode, string(body))
}
return nil
}
// AutoStep 根据查询范围自动生成 step(适用于 VictoriaMetrics)
func AutoStep(start, end time.Time) time.Duration {
r := end.Sub(start)
switch {
case r <= 5*time.Minute:
// 实时/极短窗口:与探针 1s 采集对齐,避免聚合丢点
return 1 * time.Second
case r <= time.Hour:
return 10 * time.Second
case r <= 3*time.Hour:
return 15 * time.Second
case r <= 6*time.Hour:
return 30 * time.Second
case r <= 12*time.Hour:
return time.Minute
case r <= 24*time.Hour:
return 2 * time.Minute
case r <= 3*24*time.Hour:
return 5 * time.Minute
case r <= 7*24*time.Hour:
return 10 * time.Minute
case r <= 30*24*time.Hour:
return 30 * time.Minute
default:
return time.Hour
}
}
// QueryRange 范围查询
// 如果 step 为 0,则让 VictoriaMetrics 自动选择合适的步长
func (c *VMClient) QueryRange(ctx context.Context, query string, start, end time.Time, step time.Duration) (*QueryResult, error) {
reqCtx, cancel := context.WithTimeout(ctx, c.queryTimeout)
defer cancel()
params := url.Values{}
params.Set("query", query)
params.Set("start", fmt.Sprintf("%d", start.Unix()))
params.Set("end", fmt.Sprintf("%d", end.Unix()))
if step > 0 {
params.Set("step", fmt.Sprintf("%ds", int(step.Seconds())))
} else {
autoStep := AutoStep(start, end)
params.Set("step", fmt.Sprintf("%ds", int(autoStep.Seconds())))
}
reqURL := fmt.Sprintf("%s/api/v1/query_range?%s", c.baseURL, params.Encode())
req, err := http.NewRequestWithContext(reqCtx, "GET", reqURL, nil)
if err != nil {
return nil, fmt.Errorf("create request failed: %w", err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("query range failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("query range failed with status %d: %s", resp.StatusCode, string(body))
}
var result QueryResult
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("decode response failed: %w", err)
}
if result.Status != "success" {
return nil, fmt.Errorf("query failed with status: %s", result.Status)
}
return &result, nil
}
// Query 即时查询
func (c *VMClient) Query(ctx context.Context, query string) (*QueryResult, error) {
reqCtx, cancel := context.WithTimeout(ctx, c.queryTimeout)
defer cancel()
params := url.Values{}
params.Set("query", query)
reqURL := fmt.Sprintf("%s/api/v1/query?%s", c.baseURL, params.Encode())
req, err := http.NewRequestWithContext(reqCtx, "GET", reqURL, nil)
if err != nil {
return nil, fmt.Errorf("create request failed: %w", err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("query failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("query failed with status %d: %s", resp.StatusCode, string(body))
}
var result QueryResult
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("decode response failed: %w", err)
}
if result.Status != "success" {
return nil, fmt.Errorf("query failed with status: %s", result.Status)
}
return &result, nil
}
// DeleteSeries 删除时间序列数据
func (c *VMClient) DeleteSeries(ctx context.Context, matchers []string) error {
if len(matchers) == 0 {
return nil
}
reqCtx, cancel := context.WithTimeout(ctx, c.queryTimeout)
defer cancel()
params := url.Values{}
for _, matcher := range matchers {
params.Add("match[]", matcher)
}
reqURL := fmt.Sprintf("%s/api/v1/admin/tsdb/delete_series?%s", c.baseURL, params.Encode())
req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, reqURL, nil)
if err != nil {
return fmt.Errorf("create request failed: %w", err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("delete series failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("delete series failed with status %d: %s", resp.StatusCode, string(body))
}
return nil
}
// ConvertToDataPoints 将查询结果转换为数据点列表
func ConvertToDataPoints(result *QueryResult) []DataPoint {
if result == nil || len(result.Data.Result) == 0 {
return []DataPoint{}
}
var points []DataPoint
for _, r := range result.Data.Result {
for _, v := range r.Values {
if len(v) < 2 {
continue
}
// timestamp 是 float64(Unix 秒)
timestamp, ok := v[0].(float64)
if !ok {
continue
}
// value 是 string
valueStr, ok := v[1].(string)
if !ok {
continue
}
var value float64
if _, err := fmt.Sscanf(valueStr, "%f", &value); err != nil {
continue
}
points = append(points, DataPoint{
Timestamp: int64(timestamp * 1000), // 转换为毫秒
Value: value,
Labels: r.Metric,
})
}
}
return points
}
// GetLabelValues 获取指定 label 的所有值
func (c *VMClient) GetLabelValues(ctx context.Context, labelName string, match []string) ([]string, error) {
reqCtx, cancel := context.WithTimeout(ctx, c.queryTimeout)
defer cancel()
params := url.Values{}
for _, m := range match {
params.Add("match[]", m)
}
reqURL := fmt.Sprintf("%s/api/v1/label/%s/values?%s", c.baseURL, labelName, params.Encode())
req, err := http.NewRequestWithContext(reqCtx, "GET", reqURL, nil)
if err != nil {
return nil, fmt.Errorf("create request failed: %w", err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("get label values failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("get label values failed with status %d: %s", resp.StatusCode, string(body))
}
var result struct {
Status string `json:"status"`
Data []string `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("decode response failed: %w", err)
}
if result.Status != "success" {
return nil, fmt.Errorf("get label values failed with status: %s", result.Status)
}
return result.Data, nil
}