feat(telemetry): agent 心跳遥测上报+节点列表覆写(第13条)

agent 每 60s 心跳捎带遥测: agentVersion/xrayVersion(按 mtime 缓存重读)/
uptime/最近一次主控请求 RTT/cpu%/mem%/公网 v4+v6(公网优先, ULA/私网兜底);
主控心跳处理按指针语义只存上报字段, 旧 agent 无遥测行为不变;
agent_nodes 加法迁移存遥测, 节点列表对 agent 行覆写真值,
panelVersion 显示 "agent <版本>", NodeView 新增 ipv4/ipv6。
This commit is contained in:
Muse
2026-10-22 11:33:56 +00:00
parent 84d7b85be7
commit 3536d4174e
10 changed files with 604 additions and 6 deletions
@@ -0,0 +1,96 @@
package controller
import (
"bytes"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"github.com/gin-gonic/gin"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
// P13: the heartbeat optionally carries a telemetry object. Reported fields
// are persisted onto the agent row; a heartbeat without telemetry (older
// agents) must not clear previously stored values, and a partial report
// only overwrites the fields it carries.
func TestAgentHeartbeatTelemetry(t *testing.T) {
gin.SetMode(gin.TestMode)
dbDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dbDir)
if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
db := database.GetDB()
node := &model.Node{Name: "telemetry-node", Address: "10.0.9.1"}
if err := db.Create(node).Error; err != nil {
t.Fatalf("seed node: %v", err)
}
const token = "telemetry-test-token"
agent := &model.AgentNode{
TokenHash: hashAgentToken(token),
TokenPrefix: "telemetry-",
Name: "telemetry-node",
NodeID: &node.Id,
}
if err := db.Create(agent).Error; err != nil {
t.Fatalf("seed agent: %v", err)
}
engine := gin.New()
NewAgentController(engine.Group("/panel/api/agent"))
post := func(body string) int {
req := httptest.NewRequest(http.MethodPost, "/panel/api/agent/heartbeat", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
engine.ServeHTTP(rec, req)
return rec.Code
}
load := func() model.AgentNode {
var row model.AgentNode
if err := db.First(&row, agent.Id).Error; err != nil {
t.Fatalf("reload agent: %v", err)
}
return row
}
full := `{"token":"` + token + `","timestamp":1,"telemetry":{` +
`"agentVersion":"3.7.0-agent.1","xrayVersion":"26.6.27","uptimeSeconds":90061,` +
`"latencyMs":12,"cpuPercent":3.5,"memPercent":41.2,` +
`"ipv4":"203.0.113.7","ipv6":"2001:db8::7"}}`
if code := post(full); code != http.StatusOK {
t.Fatalf("heartbeat with telemetry: status %d", code)
}
row := load()
if row.AgentVersion != "3.7.0-agent.1" || row.XrayVersion != "26.6.27" ||
row.UptimeSecs != 90061 || row.LatencyMs != 12 ||
row.CpuPct != 3.5 || row.MemPct != 41.2 ||
row.IPv4 != "203.0.113.7" || row.IPv6 != "2001:db8::7" || row.TelemetryAt == 0 {
t.Fatalf("telemetry not stored: %+v", row)
}
// No telemetry object (old agent): stored values survive.
if code := post(`{"token":"` + token + `","timestamp":2}`); code != http.StatusOK {
t.Fatalf("bare heartbeat: status %d", code)
}
row = load()
if row.XrayVersion != "26.6.27" || row.IPv4 != "203.0.113.7" || row.UptimeSecs != 90061 {
t.Fatalf("bare heartbeat cleared telemetry: %+v", row)
}
// Partial report: only the carried field changes.
if code := post(`{"token":"` + token + `","timestamp":3,"telemetry":{"cpuPercent":9.25}}`); code != http.StatusOK {
t.Fatalf("partial heartbeat: status %d", code)
}
row = load()
if row.CpuPct != 9.25 || row.MemPct != 41.2 || row.LatencyMs != 12 {
t.Fatalf("partial telemetry merged wrong: %+v", row)
}
}