From 16eb179eaf4a444284ae1d27dcc2c6d586bc48bb Mon Sep 17 00:00:00 2001 From: root Date: Fri, 24 Apr 2026 17:06:11 +0800 Subject: [PATCH] feat: add NodeController with list, getConfig, and updateConfig endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose node management API endpoints for the cluster feature: - GET /node/list — returns connected nodes with online status - GET /node/config — returns current node + DB configuration - POST /node/config — validates and persists node settings to x-ui.json --- web/controller/node.go | 170 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 web/controller/node.go diff --git a/web/controller/node.go b/web/controller/node.go new file mode 100644 index 00000000..01a44d08 --- /dev/null +++ b/web/controller/node.go @@ -0,0 +1,170 @@ +package controller + +import ( + "os" + "strconv" + "time" + + "github.com/mhsanaei/3x-ui/v2/config" + "github.com/mhsanaei/3x-ui/v2/database" + + "github.com/gin-gonic/gin" +) + +// NodeController handles node management API endpoints. +type NodeController struct { + BaseController +} + +// NewNodeController creates a new NodeController and initializes its routes. +func NewNodeController(g *gin.RouterGroup) *NodeController { + a := &NodeController{} + a.initRouter(g) + return a +} + +// initRouter sets up the routes for node management. +func (a *NodeController) initRouter(g *gin.RouterGroup) { + g.GET("/list", a.list) + g.GET("/config", a.getConfig) + g.POST("/config", a.updateConfig) +} + +// NodeView is the JSON shape returned to the frontend for each node. +type NodeView struct { + NodeID string `json:"nodeId"` + NodeRole string `json:"nodeRole"` + Online bool `json:"online"` + LastHeartbeatAt int64 `json:"lastHeartbeatAt"` + LastSyncAt int64 `json:"lastSyncAt"` + LastSeenVersion int64 `json:"lastSeenVersion"` + LastError string `json:"lastError"` +} + +// list returns connected nodes. Master sees all workers; worker sees the master. +func (a *NodeController) list(c *gin.Context) { + nodeCfg := config.GetNodeConfigFromJSON() + states, err := database.GetNodeStates() + if err != nil { + jsonMsg(c, "get node states", err) + return + } + + syncInterval := nodeCfg.SyncIntervalSeconds + if syncInterval <= 0 { + syncInterval = 30 + } + offlineThreshold := int64(syncInterval * 2) + now := time.Now().Unix() + + var nodes []NodeView + for _, s := range states { + // Master shows workers; worker shows master + if nodeCfg.Role == config.NodeRoleMaster && s.NodeRole != string(config.NodeRoleWorker) { + continue + } + if nodeCfg.Role == config.NodeRoleWorker && s.NodeRole != string(config.NodeRoleMaster) { + continue + } + online := (now - s.LastHeartbeatAt) < offlineThreshold + nodes = append(nodes, NodeView{ + NodeID: s.NodeID, + NodeRole: s.NodeRole, + Online: online, + LastHeartbeatAt: s.LastHeartbeatAt, + LastSyncAt: s.LastSyncAt, + LastSeenVersion: s.LastSeenVersion, + LastError: s.LastError, + }) + } + if nodes == nil { + nodes = []NodeView{} + } + + jsonObj(c, nodes, nil) +} + +// NodeConfigView is the JSON shape for node configuration. +type NodeConfigView struct { + Role string `json:"role"` + NodeID string `json:"nodeId"` + SyncInterval int `json:"syncInterval"` + TrafficFlushInterval int `json:"trafficFlushInterval"` + DBType string `json:"dbType"` + DBHost string `json:"dbHost"` + DBPort string `json:"dbPort"` + DBUser string `json:"dbUser"` + DBPass string `json:"dbPass"` + DBName string `json:"dbName"` +} + +// getConfig returns the current node configuration. +func (a *NodeController) getConfig(c *gin.Context) { + nodeCfg := config.GetNodeConfigFromJSON() + dbCfg := config.GetDBConfigFromJSON() + + jsonObj(c, NodeConfigView{ + Role: string(nodeCfg.Role), + NodeID: nodeCfg.NodeID, + SyncInterval: nodeCfg.SyncIntervalSeconds, + TrafficFlushInterval: nodeCfg.TrafficFlushSeconds, + DBType: dbCfg.Type, + DBHost: dbCfg.Host, + DBPort: dbCfg.Port, + DBUser: dbCfg.User, + DBPass: dbCfg.Password, + DBName: dbCfg.Name, + }, nil) +} + +// updateConfigRequest is the JSON body for updating node config. +type updateConfigRequest struct { + SyncInterval int `json:"syncInterval"` + TrafficFlushInterval int `json:"trafficFlushInterval"` + DBType string `json:"dbType"` + DBHost string `json:"dbHost"` + DBPort string `json:"dbPort"` + DBUser string `json:"dbUser"` + DBPass string `json:"dbPass"` + DBName string `json:"dbName"` +} + +// updateConfig updates the node configuration in x-ui.json. +func (a *NodeController) updateConfig(c *gin.Context) { + var req updateConfigRequest + if err := c.ShouldBindJSON(&req); err != nil { + jsonMsg(c, "invalid request", err) + return + } + + // Validate + if req.SyncInterval <= 0 { + jsonMsg(c, "syncInterval must be positive", os.ErrInvalid) + return + } + if req.TrafficFlushInterval <= 0 { + jsonMsg(c, "trafficFlushInterval must be positive", os.ErrInvalid) + return + } + + // Write each setting to JSON config + settings := map[string]string{ + "syncInterval": strconv.Itoa(req.SyncInterval), + "trafficFlushInterval": strconv.Itoa(req.TrafficFlushInterval), + "dbType": req.DBType, + "dbHost": req.DBHost, + "dbPort": req.DBPort, + "dbUser": req.DBUser, + "dbPassword": req.DBPass, + "dbName": req.DBName, + } + + for key, value := range settings { + if err := config.WriteSettingToJSON(key, value); err != nil { + jsonMsg(c, "save "+key, err) + return + } + } + + jsonMsg(c, I18nWeb(c, "pages.nodes.saveSuccess"), nil) +}