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.
199 lines
7.1 KiB
Go
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")
|
|
}
|
|
}
|