98 lines
5.0 KiB
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})
|
|
}
|