# Node Management Sidebar — Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add a "节点管理" sidebar item and page that shows connected nodes (master→workers, worker→master) with detailed status and node configuration editing. **Architecture:** New NodeController serves API endpoints under `/panel/api/nodes/`. New `nodes.html` page follows existing patterns (Vue 2 + Ant Design Vue). Database layer adds `GetNodeStates()` query. Sidebar gets a new menu item gated by `{{if .is_admin}}`. **Tech Stack:** Go, Gin, GORM, Vue.js 2, Ant Design Vue 1.x, Go html/template --- ### Task 1: Add GetNodeStates database query **Files:** - Modify: `database/shared_state.go` - [ ] **Step 1: Add GetNodeStates function** Add this function to `database/shared_state.go`, after the existing `UpsertNodeState` function: ```go // GetNodeStates returns all node_state records ordered by node_id. func GetNodeStates() ([]model.NodeState, error) { var states []model.NodeState err := GetDB().Order("node_id").Find(&states).Error return states, err } ``` - [ ] **Step 2: Verify it compiles** Run: `cd /usr/x-ui/3x-ui && go build ./...` Expected: PASS (no output) - [ ] **Step 3: Commit** ```bash git add database/shared_state.go git commit -m "feat: add GetNodeStates query for node management" ``` --- ### Task 2: Create NodeController with API endpoints **Files:** - Create: `web/controller/node.go` - [ ] **Step 1: Create node.go with full controller** Create `web/controller/node.go`: ```go package controller import ( "encoding/json" "net/http" "os" "time" "github.com/mhsanaei/3x-ui/v2/config" "github.com/mhsanaei/3x-ui/v2/database" "github.com/mhsanaei/3x-ui/v2/database/model" "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 = g.Group("/nodes") g.Use(a.checkAdmin) 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": json.NumberString(req.SyncInterval), "trafficFlushInterval": json.NumberString(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) } ``` Wait — I need to check how `json.NumberString` works. Let me use `strconv.Itoa` instead. - [ ] **Step 2: Fix the import — use strconv instead of json.NumberString** The `updateConfig` function should use `strconv.Itoa` for integer-to-string conversion. Replace the settings map in the `updateConfig` function: ```go "strconv" ``` Add `"strconv"` to the import block, and change the settings map to: ```go 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, } ``` Remove the `"encoding/json"` and `"os"` imports (no longer needed). - [ ] **Step 3: Verify it compiles** Run: `cd /usr/x-ui/3x-ui && go build ./...` Expected: PASS - [ ] **Step 4: Commit** ```bash git add web/controller/node.go git commit -m "feat: add NodeController with list/config API endpoints" ``` --- ### Task 3: Register NodeController routes **Files:** - Modify: `web/controller/api.go:39-63` - Modify: `web/controller/xui.go:27-39` - [ ] **Step 1: Add NodeController to APIController** In `web/controller/api.go`, add a field to the `APIController` struct (after `userController`): ```go nodeController *NodeController ``` In the `initRouter` method, add node routes after the users API group (after line 59): ```go // Nodes API nodes := api.Group("/nodes") nodes.Use(a.checkAdmin) a.nodeController = NewNodeController(nodes) ``` Wait — the NodeController already calls `a.checkAdmin` in its own `initRouter`. Looking at the pattern: `ServerController` and `UserController` don't call `checkAdmin` themselves — the `api.go` applies it at the group level. But my NodeController's `initRouter` applies `a.checkAdmin` on its own sub-group. This would double-apply the middleware. Let me fix: the NodeController's `initRouter` should NOT call `a.checkAdmin` since the parent group in `api.go` already applies it. But actually, looking at the code more carefully: - `api.go` line 51-52: `server := api.Group("/server")` then `server.Use(a.checkAdmin)` then `a.serverController = NewServerController(server)` - The ServerController's `initRouter` receives the group and doesn't add `checkAdmin` again So I need to remove the `g.Use(a.checkAdmin)` from NodeController's `initRouter`. Let me update the NodeController code: In `web/controller/node.go`, remove the line `g.Use(a.checkAdmin)` from `initRouter`: ```go func (a *NodeController) initRouter(g *gin.RouterGroup) { g.GET("/list", a.list) g.GET("/config", a.getConfig) g.POST("/config", a.updateConfig) } ``` But wait — for the page route in `xui.go`, the `checkAdmin` is applied at the route level, not via middleware on the group. Let me check the XUI controller again... Looking at `xui.go:27-39`: ```go g = g.Group("/panel") g.Use(a.checkLogin) // ... g.GET("/settings", a.checkAdmin, a.settings) ``` So for page routes, `checkAdmin` is per-route. For API routes, it's per-group. The NodeController's API routes will be under the `/panel/api/nodes` group which already has `checkAdmin` applied. OK, the approach: 1. In `api.go`, add the node controller registration with `checkAdmin` on the group 2. In `xui.go`, add the page route with `checkAdmin` per-route 3. In `node.go`, remove the `checkAdmin` from `initRouter` since it's applied by the parent - [ ] **Step 1 (corrected): Update api.go — add NodeController** In `web/controller/api.go`: 1. Add field to struct (after `userController`): ```go nodeController *NodeController ``` 2. Add route registration in `initRouter` (after the users block, before the "Extra routes" comment): ```go // Nodes API nodes := api.Group("/nodes") nodes.Use(a.checkAdmin) a.nodeController = NewNodeController(nodes) ``` - [ ] **Step 2: Update node.go — remove checkAdmin from initRouter** In `web/controller/node.go`, change `initRouter` to remove `g.Use(a.checkAdmin)`: ```go func (a *NodeController) initRouter(g *gin.RouterGroup) { g.GET("/list", a.list) g.GET("/config", a.getConfig) g.POST("/config", a.updateConfig) } ``` - [ ] **Step 3: Update xui.go — add page route** In `web/controller/xui.go`, add the nodes page route in `initRouter` (after the `xray` line, before the `users` line): ```go g.GET("/nodes", a.checkAdmin, a.nodes) ``` Add the handler method (after `xraySettings`): ```go // nodes renders the node management page. func (a *XUIController) nodes(c *gin.Context) { html(c, "nodes.html", "pages.nodes.title", nil) } ``` - [ ] **Step 4: Verify it compiles** Run: `cd /usr/x-ui/3x-ui && go build ./...` Expected: PASS - [ ] **Step 5: Commit** ```bash git add web/controller/api.go web/controller/node.go web/controller/xui.go git commit -m "feat: register NodeController routes and nodes page" ``` --- ### Task 4: Add i18n translations **Files:** - Modify: `web/translation/translate.en_US.toml` - Modify: `web/translation/translate.zh_CN.toml` - [ ] **Step 1: Add English translations** In `web/translation/translate.en_US.toml`, add `nodes` to the `[menu]` section (after `"xray"`): ```toml "nodes" = "Nodes" ``` Add a new section at the end of the file: ```toml [pages.nodes] "title" = "Node Management" "nodeId" = "Node ID" "role" = "Role" "status" = "Status" "online" = "Online" "offline" = "Offline" "lastHeartbeat" = "Last Heartbeat" "lastSync" = "Last Sync" "syncVersion" = "Sync Version" "error" = "Error" "syncInterval" = "Sync Interval (seconds)" "trafficFlushInterval" = "Traffic Flush Interval (seconds)" "dbType" = "Database Type" "dbHost" = "Database Host" "dbPort" = "Database Port" "dbUser" = "Database User" "dbPass" = "Database Password" "dbName" = "Database Name" "save" = "Save" "saveSuccess" = "Node configuration saved successfully" "noWorkerNodes" = "No worker nodes connected" "masterNode" = "Master Node" "workerNodes" = "Worker Nodes" "currentNodeConfig" = "Current Node Configuration" "connectedNodes" = "Connected Nodes" "refresh" = "Refresh" ``` Also add the page title key. In the `[pages.nodes]` section, make sure `"title"` is present (it's used by `html(c, "nodes.html", "pages.nodes.title", nil)`). - [ ] **Step 2: Add Chinese translations** In `web/translation/translate.zh_CN.toml`, add `nodes` to the `[menu]` section (after `"xray"`): ```toml "nodes" = "节点管理" ``` Add a new section at the end of the file: ```toml [pages.nodes] "title" = "节点管理" "nodeId" = "节点 ID" "role" = "角色" "status" = "状态" "online" = "在线" "offline" = "离线" "lastHeartbeat" = "最后心跳" "lastSync" = "最后同步" "syncVersion" = "同步版本" "error" = "错误" "syncInterval" = "同步间隔(秒)" "trafficFlushInterval" = "流量刷新间隔(秒)" "dbType" = "数据库类型" "dbHost" = "数据库主机" "dbPort" = "数据库端口" "dbUser" = "数据库用户" "dbPass" = "数据库密码" "dbName" = "数据库名称" "save" = "保存" "saveSuccess" = "节点配置保存成功" "noWorkerNodes" = "暂无 Worker 节点连接" "masterNode" = "主节点" "workerNodes" = "Worker 节点" "currentNodeConfig" = "当前节点配置" "connectedNodes" = "已连接节点" "refresh" = "刷新" ``` - [ ] **Step 3: Verify it compiles** Run: `cd /usr/x-ui/3x-ui && go build ./...` Expected: PASS - [ ] **Step 4: Commit** ```bash git add web/translation/translate.en_US.toml web/translation/translate.zh_CN.toml git commit -m "feat: add i18n translations for node management" ``` --- ### Task 5: Add sidebar menu item **Files:** - Modify: `web/html/component/aSidebar.html:61-66` - [ ] **Step 1: Add nodes menu item** In `web/html/component/aSidebar.html`, add the nodes entry in the `tabs` array between the `settings` item and the `xray` item. After the closing `},` of the settings item (line 61), add: ```javascript {{if .is_admin}} { key: '{{ .base_path }}panel/nodes', icon: 'cluster', title: '{{ i18n "menu.nodes"}}' }, {{end}} ``` The full tabs array should now be: ```javascript tabs: [ { key: '{{ .base_path }}panel/', icon: 'dashboard', title: '{{ i18n "menu.dashboard"}}' }, { key: '{{ .base_path }}panel/inbounds', icon: 'user', title: '{{ i18n "menu.inbounds"}}' }, { key: '{{ .base_path }}panel/settings', icon: 'setting', title: '{{ i18n "menu.settings"}}' }, {{if .is_admin}} { key: '{{ .base_path }}panel/nodes', icon: 'cluster', title: '{{ i18n "menu.nodes"}}' }, {{end}} { key: '{{ .base_path }}panel/xray', icon: 'tool', title: '{{ i18n "menu.xray"}}' }, {{if .is_admin}} { key: '{{ .base_path }}panel/users', icon: 'team', title: '{{ i18n "menu.users"}}' }, {{end}} { key: '{{ .base_path }}logout/', icon: 'logout', title: '{{ i18n "menu.logout"}}' }, ], ``` - [ ] **Step 2: Commit** ```bash git add web/html/component/aSidebar.html git commit -m "feat: add nodes menu item to sidebar" ``` --- ### Task 6: Create nodes.html page **Files:** - Create: `web/html/nodes.html` - [ ] **Step 1: Create the full nodes.html page** Create `web/html/nodes.html`: ```html {{ template "page/head_start" .}} {{ template "page/head_end" .}} {{ template "page/body_start" .}}
[[ nodes[0].nodeId ]] [[ nodes[0].lastHeartbeatAt ? formatTime(nodes[0].lastHeartbeatAt) : '-' ]] [[ nodes[0].lastSyncAt ? formatTime(nodes[0].lastSyncAt) : '-' ]] [[ nodes[0].lastSeenVersion ]] [[ nodes[0].lastError || '-' ]]
SQLite MySQL/MariaDB {{ i18n "pages.nodes.dbHost" }} {{ i18n "pages.nodes.save" }}
{{template "page/body_scripts" .}} {{ template "page/body_end" }} ``` - [ ] **Step 2: Commit** ```bash git add web/html/nodes.html git commit -m "feat: add nodes.html page with node list and config form" ``` --- ### Task 7: Build and verify - [ ] **Step 1: Full build check** Run: `cd /usr/x-ui/3x-ui && go build ./...` Expected: PASS - [ ] **Step 2: Run vet** Run: `cd /usr/x-ui/3x-ui && go vet ./...` Expected: PASS - [ ] **Step 3: Final commit (if any fixes needed)** ```bash git add -A git commit -m "fix: address build issues from node management feature" ```