Files
l-ui/internal/web/controller/agent_fast_report_test.go
T
Muse 20db43210e Move probe settings into sub-pages; per-node probe metadata to node edit dialog; drop probe-task batch add
Probe settings page (settings#probe) is now a tab container with three
second-level sub-pages, following the same antd Tabs pattern the other
settings tabs use for in-section grouping, in this order:
- status: status-page settings (the former public-status-page form),
  tab and heading renamed from public-status-page to status-page
  settings (new pages.settings.probe.statusPageTitle key, all 13
  locales);
- latency: latency probing task manager (ProbeTasksPanel), with its
  batch-add module removed (see below);
- notify: notifications, moved unchanged from the public-page tab
  (PublicPageTab, theme-store section) into ProbeNotifyTab.

Per-node probe metadata (ProbeMetaSection) no longer lives on the
probe settings page: it moved into the node edit dialog's
public-status-page section (NodeFormModal), where it is edited in
place per node — the component was already designed for that
embedding (own endpoints, immediate save, outside the node form).
The dialog's 'moved to probe settings' placeholder goes away with
it. The global probe settings stay where they were (status tab).

Batch-add removal (latency page): the batch card, its frontend state
and handlers (parseTargets, onBatchAdd), the POST
/panel/api/agent/probe-tasks/batch endpoint (route, form type,
handler), its dedicated TestProbeTasksBatch test, and the six
probeTasks.batch* locale keys are all deleted. The endpoint's only
production caller was the removed module; the single-task add/edit
flow, the target list and all other task endpoints are untouched.

Orphaned locale keys removed across all 13 locales (settingsTitle,
metaTitle, metaNodePlaceholder, movedHint, goSettings, plus the six
batch keys); the 13-locale key sets stay identical, enforced by the
i18n-dead-keys test. Backend APIs, setting keys, TG/webhook channel
logic and theme-store content are unchanged.
2026-10-24 15:57:28 +00:00

199 lines
7.1 KiB
Go

package controller
import (
"bytes"
"encoding/json"
"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"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
"github.com/mhsanaei/3x-ui/v3/internal/web/service/probe"
)
// V2.0.1 probe batch, master side: the fast-report endpoint ingests
// metrics, and the config poll carries the cadence + the node's
// counted-interface pin. Since V2.0.3 the fast report also stamps
// last-seen (the hub's themes gate batches on its freshness); it
// originally did not — see the probeReport handler comment.
func TestAgentFastReportAndConfigKeys(t *testing.T) {
gin.SetMode(gin.TestMode)
dbDir := t.TempDir()
t.Setenv("LUI_DB_FOLDER", dbDir)
if err := database.InitDB(filepath.Join(dbDir, "l-ui.db")); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
db := database.GetDB()
const token = "fast-report-token"
node := &model.Node{Name: "fast-node", Address: "192.0.2.1", Port: 8443, Enable: true}
if err := db.Create(node).Error; err != nil {
t.Fatalf("seed node: %v", err)
}
agent := &model.AgentNode{TokenHash: hashAgentToken(token), TokenPrefix: "fast-", Name: "fast-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"))
call := func(method, path, body string) (int, []byte) {
req := httptest.NewRequest(method, path, 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, rec.Body.Bytes()
}
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
}
// One heartbeat establishes last-seen.
if code, _ := call(http.MethodPost, "/panel/api/agent/heartbeat", `{"token":"`+token+`","timestamp":1}`); code != http.StatusOK {
t.Fatalf("heartbeat: %d", code)
}
seenAfterHeartbeat := load().LastSeen
if seenAfterHeartbeat == 0 {
t.Fatal("heartbeat did not stamp last-seen")
}
metrics := `{"probeMetrics":{"bootId":"boot-f/0123456789abcdef","uptime":60,` +
`"memTotal":1000,"memUsed":400,"netRxTotal":10,"netTxTotal":20}}`
// Age the stamp first so the fast report's touch is observable
// even within the same millisecond as the heartbeat's.
stale := seenAfterHeartbeat - 120_000
if err := db.Model(&model.AgentNode{}).Where("id = ?", agent.Id).Update("last_seen", stale).Error; err != nil {
t.Fatalf("age last-seen: %v", err)
}
if code, body := call(http.MethodPost, "/panel/api/agent/probe-report", metrics); code != http.StatusOK {
t.Fatalf("probe-report: %d (%s)", code, body)
}
row := load()
if row.ProbeMemUsed != 400 || row.ProbeAt == 0 {
t.Errorf("fast report metrics not stored: %+v", row)
}
if row.LastSeen <= stale {
t.Errorf("fast report did not stamp last-seen: %d -> %d (V2.0.3: themes gate on it)", stale, row.LastSeen)
}
// Unauthorized callers are refused.
req := httptest.NewRequest(http.MethodPost, "/panel/api/agent/probe-report", bytes.NewBufferString(metrics))
req.Header.Set("Authorization", "Bearer wrong")
rec := httptest.NewRecorder()
engine.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("probe-report with bad token: %d, want 401", rec.Code)
}
configObj := func() map[string]any {
code, body := call(http.MethodGet, "/panel/api/agent/config", "")
if code != http.StatusOK {
t.Fatalf("config poll: %d (%s)", code, body)
}
// getConfig answers with the bare payload (no envelope).
var obj map[string]any
if err := json.Unmarshal(body, &obj); err != nil {
t.Fatalf("config payload: %v", err)
}
return obj
}
obj := configObj()
if obj["probe_fast_interval"] != float64(3) {
t.Errorf("default probe_fast_interval = %v, want 3", obj["probe_fast_interval"])
}
if obj["probe_stat_iface"] != "" {
t.Errorf("default probe_stat_iface = %v, want empty", obj["probe_stat_iface"])
}
// Operator changes: cadence via the probe settings block, pin
// via the node's probe meta — both must ride the next poll.
settings := service.GetProbePublicSettings()
settings.FastIntervalSec = 9
if err := service.SetProbePublicSettings(settings); err != nil {
t.Fatalf("SetProbePublicSettings: %v", err)
}
st := probe.Default()
meta := probe.DefaultMeta(int64(agent.Id))
meta.StatIface = "eth9"
if err := st.UpsertMeta(meta); err != nil {
t.Fatalf("UpsertMeta: %v", err)
}
obj = configObj()
if obj["probe_fast_interval"] != float64(9) {
t.Errorf("probe_fast_interval = %v, want 9", obj["probe_fast_interval"])
}
if obj["probe_stat_iface"] != "eth9" {
t.Errorf("probe_stat_iface = %v, want eth9", obj["probe_stat_iface"])
}
// Settings validation clamps the ranges the page offers.
bad := service.GetProbePublicSettings()
bad.RetentionDays = 0
if err := service.SetProbePublicSettings(bad); err == nil {
t.Error("retention_days = 0 accepted, want refusal")
}
bad = service.GetProbePublicSettings()
bad.FastIntervalSec = 61
if err := service.SetProbePublicSettings(bad); err == nil {
t.Error("fast interval = 61 accepted, want refusal")
}
}
// V2.0.1 probe batch: the meta editor's calibration + interface pin
// fields round-trip and validate.
func TestProbeMetaCalibrationFields(t *testing.T) {
rig := newProbeTestRig(t)
db := database.GetDB()
nodeID := 77
node := &model.Node{Id: nodeID, Name: "meta-node", Address: "192.0.2.1", Port: 8443, Enable: true}
if err := db.Create(node).Error; err != nil {
t.Fatalf("create node: %v", err)
}
agent := &model.AgentNode{TokenHash: hashAgentToken("meta-token"), TokenPrefix: "meta-", Name: "meta-agent", NodeID: &nodeID}
if err := db.Create(agent).Error; err != nil {
t.Fatalf("create agent: %v", err)
}
path := "/panel/api/agent/probe-meta/77"
put := func(body string) envelope {
rec := rig.do(http.MethodPut, path, []byte(body), true)
return decodeEnvelope(t, rec.Body.Bytes())
}
base := `"price":0,"currency":"USD","billingCycle":"monthly",` +
`"expiresAt":null,"trafficLimit":0,"trafficMode":"sum","trafficResetDay":1,"public":true,"sort":0,` +
`"group":"","publicRemark":"","notify":false,"countryPin":"","ipv4Pin":"","ipv6Pin":""`
env := put(`{` + base + `,"trafficCalUpGb":2.5,"trafficCalDownGb":-1,"statIface":" eth0 "}`)
if !env.Success {
t.Fatalf("meta save with calibration refused: %s", env.Msg)
}
meta, found, err := probe.MetaFor(int64(agent.Id))
if err != nil || !found {
t.Fatalf("MetaFor: found=%v err=%v", found, err)
}
if meta.TrafficCalUpGB != 2.5 || meta.TrafficCalDownGB != -1 || meta.StatIface != "eth0" {
t.Errorf("stored meta = up %v down %v iface %q (pin must be trimmed)", meta.TrafficCalUpGB, meta.TrafficCalDownGB, meta.StatIface)
}
env = put(`{` + base + `,"statIface":"eth 0"}`)
if env.Success {
t.Error("interface name with a space accepted")
}
env = put(`{` + base + `,"statIface":"eth/0"}`)
if env.Success {
t.Error("interface name with a slash accepted")
}
}