# 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" .}}
{{ i18n "pages.nodes.connectedNodes" }}
[[ nodeRole ]]
{{ i18n "pages.nodes.refresh" }}
[[ record.nodeRole ]]
[[ record.lastHeartbeatAt ? formatTime(record.lastHeartbeatAt) : '-' ]]
[[ record.lastSyncAt ? formatTime(record.lastSyncAt) : '-' ]]
[[ nodes[0].nodeId ]]
[[ nodes[0].lastHeartbeatAt ? formatTime(nodes[0].lastHeartbeatAt) : '-' ]]
[[ nodes[0].lastSyncAt ? formatTime(nodes[0].lastSyncAt) : '-' ]]
[[ nodes[0].lastSeenVersion ]]
[[ nodes[0].lastError || '-' ]]
{{ i18n "pages.nodes.currentNodeConfig" }}
SQLite
MySQL/MariaDB
{{ i18n "pages.nodes.dbHost" }}
{{ i18n "pages.nodes.save" }}
{{template "page/body_scripts" .}}
{{ template "page/body_end" }}