vdi-cloud/platform/internal/server/terminal_metrics.go

98 lines
5.0 KiB
Go

package server
import (
"context"
"time"
"github.com/gin-gonic/gin"
"github.com/jmoiron/sqlx"
)
// Values are optional: unavailable sensors are null, never fabricated zeroes.
type terminalSystemMetrics struct {
CPUPercent *float64 `json:"cpuPercent" db:"cpu_percent" binding:"omitempty,min=0,max=100"`
MemoryPercent *float64 `json:"memoryPercent" db:"memory_percent" binding:"omitempty,min=0,max=100"`
SoCTemperatureC *float64 `json:"socTemperatureC" db:"soc_temperature_c" binding:"omitempty,min=-40,max=150"`
UptimeSeconds *float64 `json:"uptimeSeconds" db:"uptime_seconds" binding:"omitempty,min=0,max=3155760000"`
}
func saveTerminalMetrics(ctx context.Context, tx *sqlx.Tx, id string, input *terminalSystemMetrics) error {
metrics := terminalSystemMetrics{}
if input != nil {
metrics = *input
}
// Device auth already holds the terminal row lock, serializing concurrent heartbeats.
_, err := tx.ExecContext(ctx, `INSERT INTO terminal_metrics_current(terminal_id,sampled_at,supported,cpu_percent,memory_percent,soc_temperature_c,uptime_seconds)
VALUES($1,now(),$2,$3,$4,$5,$6) ON CONFLICT(terminal_id) DO UPDATE SET
sampled_at=EXCLUDED.sampled_at,supported=EXCLUDED.supported,cpu_percent=EXCLUDED.cpu_percent,
memory_percent=EXCLUDED.memory_percent,soc_temperature_c=EXCLUDED.soc_temperature_c,uptime_seconds=EXCLUDED.uptime_seconds`,
id, input != nil, metrics.CPUPercent, metrics.MemoryPercent, metrics.SoCTemperatureC, metrics.UptimeSeconds)
if err != nil {
return err
}
// Bound history growth even if an agent retries aggressively. Preserve null samples on downgrade.
_, err = tx.ExecContext(ctx, `INSERT INTO terminal_metric_samples(terminal_id,sampled_at,cpu_percent,memory_percent,soc_temperature_c,uptime_seconds)
SELECT $1,now(),$2,$3,$4,$5 WHERE NOT EXISTS(SELECT 1 FROM terminal_metric_samples
WHERE terminal_id=$1 AND sampled_at>now()-interval '10 seconds')`,
id, metrics.CPUPercent, metrics.MemoryPercent, metrics.SoCTemperatureC, metrics.UptimeSeconds)
return err
}
type terminalMetricPoint struct {
terminalSystemMetrics
Time time.Time `json:"time" db:"time"`
Samples int `json:"samples" db:"samples"`
}
func (s *Server) terminalMetrics(c *gin.Context) {
from, to, ok := timeRange(c, 7)
if !ok {
s.fail(c, 400, "invalid_range", "请选择有效时间范围,最长 7 天")
return
}
var latest struct {
terminalSystemMetrics
SampledAt *time.Time `json:"sampledAt" db:"sampled_at"`
Supported bool `json:"supported" db:"supported"`
Online bool `json:"online" db:"online"`
Fresh bool `json:"fresh" db:"fresh"`
}
// Query through the terminal identity so unknown devices return 404. A stopped,
// revoked or stale device keeps history but cannot present an old value as live.
err := s.DB.GetContext(c.Request.Context(), &latest, `SELECT m.sampled_at,COALESCE(m.supported,false) supported,
COALESCE(t.enabled AND t.last_seen>now()-interval '90 seconds',false) online,
COALESCE(t.enabled AND t.last_seen>now()-interval '90 seconds' AND m.sampled_at>now()-interval '90 seconds',false) fresh,
CASE WHEN t.enabled AND t.last_seen>now()-interval '90 seconds' AND m.sampled_at>now()-interval '90 seconds' THEN m.cpu_percent END cpu_percent,
CASE WHEN t.enabled AND t.last_seen>now()-interval '90 seconds' AND m.sampled_at>now()-interval '90 seconds' THEN m.memory_percent END memory_percent,
CASE WHEN t.enabled AND t.last_seen>now()-interval '90 seconds' AND m.sampled_at>now()-interval '90 seconds' THEN m.soc_temperature_c END soc_temperature_c,
CASE WHEN t.enabled AND t.last_seen>now()-interval '90 seconds' AND m.sampled_at>now()-interval '90 seconds' THEN m.uptime_seconds END uptime_seconds
FROM terminals t LEFT JOIN terminal_metrics_current m ON m.terminal_id=t.id WHERE t.id=$1`, c.Param("id"))
if err != nil {
s.dbError(c, err)
return
}
bucket := 60
if to.Sub(from) > 6*time.Hour {
bucket = 300
}
if to.Sub(from) > 24*time.Hour {
bucket = 1800
}
points := []terminalMetricPoint{}
// Explicit empty buckets break the chart across missing heartbeats.
err = s.DB.SelectContext(c.Request.Context(), &points, `WITH data AS (
SELECT date_bin(make_interval(secs=>$4),sampled_at,'2000-01-01'::timestamptz) time,
avg(cpu_percent) cpu_percent,avg(memory_percent) memory_percent,avg(soc_temperature_c) soc_temperature_c,
max(uptime_seconds) uptime_seconds,count(*) samples FROM terminal_metric_samples
WHERE terminal_id=$1 AND sampled_at>=$2 AND sampled_at<=$3 GROUP BY 1)
SELECT b.time,d.cpu_percent,d.memory_percent,d.soc_temperature_c,d.uptime_seconds,COALESCE(d.samples,0) samples
FROM generate_series(date_bin(make_interval(secs=>$4),$2::timestamptz,'2000-01-01'::timestamptz),
date_bin(make_interval(secs=>$4),$3::timestamptz,'2000-01-01'::timestamptz),make_interval(secs=>$4)) b(time)
LEFT JOIN data d ON d.time=b.time ORDER BY b.time`, c.Param("id"), from, to, bucket)
if err != nil {
s.dbError(c, err)
return
}
c.JSON(200, gin.H{"latest": latest, "items": points, "from": from, "to": to, "bucketSeconds": bucket, "retentionDays": 30})
}