fix(nodes): report probe heartbeat persistence failures (#6207)

Co-authored-by: n0ctal <293235942+n0ctal@users.noreply.github.com>
This commit is contained in:
n0ctal
2026-08-13 15:34:37 +05:00
committed by GitHub
parent 64f4f0746c
commit 4a5f6771b3
2 changed files with 53 additions and 1 deletions
+4 -1
View File
@@ -314,7 +314,10 @@ func (a *NodeController) probe(c *gin.Context) {
} else {
patch.Status = "online"
}
_ = a.nodeService.UpdateHeartbeat(id, patch)
if err := a.nodeService.UpdateHeartbeat(id, patch); err != nil {
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.test"), err)
return
}
jsonObj(c, patch.ToUI(probeErr == nil), nil)
}
@@ -2,6 +2,7 @@ package controller
import (
"encoding/json"
"errors"
"net"
"net/http"
"net/http/httptest"
@@ -12,6 +13,8 @@ import (
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"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/locale"
@@ -66,6 +69,52 @@ func TestNodeControllerResponsesDoNotLeakApiToken(t *testing.T) {
}
}
func TestNodeControllerProbeReportsHeartbeatPersistenceFailure(t *testing.T) {
engine := newNodeCredentialTestEngine(t)
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"success":true,"obj":{"cpu":1,"mem":{"current":1,"total":2},"xray":{"version":"1","state":"running"},"panelVersion":"v3.6.0","panelGuid":"guid","uptime":7,"netIO":{"up":3,"down":4}}}`))
}))
defer remote.Close()
host, portString, err := net.SplitHostPort(strings.TrimPrefix(remote.URL, "http://"))
if err != nil {
t.Fatalf("split remote addr: %v", err)
}
port, err := strconv.Atoi(portString)
if err != nil {
t.Fatalf("parse remote port: %v", err)
}
node := &model.Node{Scheme: "http", Address: host, Port: port, BasePath: "/", Enable: true, AllowPrivateAddress: true}
if err := database.GetDB().Create(node).Error; err != nil {
t.Fatalf("seed node: %v", err)
}
db := database.GetDB()
const callback = "test:fail_node_heartbeat_update"
errInjected := errors.New("injected heartbeat persistence failure")
if err := db.Callback().Update().Before("gorm:update").Register(callback, func(tx *gorm.DB) {
if tx.Statement != nil && tx.Statement.Table == "nodes" {
tx.AddError(errInjected)
}
}); err != nil {
t.Fatalf("register update callback: %v", err)
}
t.Cleanup(func() {
if err := db.Callback().Update().Remove(callback); err != nil {
t.Errorf("remove update callback: %v", err)
}
})
w := httptest.NewRecorder()
engine.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/panel/api/nodes/probe/"+strconv.Itoa(node.Id), nil))
if !strings.Contains(w.Body.String(), `"success":false`) {
t.Fatalf("probe reported success despite heartbeat persistence failure: %s", w.Body.String())
}
if !strings.Contains(w.Body.String(), errInjected.Error()) {
t.Fatalf("probe response omitted persistence error: %s", w.Body.String())
}
}
func TestNodeControllerAddAcceptsTokenButReturnsView(t *testing.T) {
engine := newNodeCredentialTestEngine(t)