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}) }