3x-ui/web/controller/node.go
root 16eb179eaf feat: add NodeController with list, getConfig, and updateConfig endpoints
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
2026-04-24 17:06:11 +08:00

170 lines
4.9 KiB
Go

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)
}