mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-07 05:34:17 +00:00
chore: bump version to v1.6.0-beta
This commit is contained in:
parent
678d35d303
commit
2721dd0f13
4 changed files with 1014 additions and 1 deletions
|
|
@ -1 +1 @@
|
|||
v1.5.4.1-beta
|
||||
v1.6.0-beta
|
||||
17
docs/Tasktracking/2026-04-24-clash-yaml-codemirror-editor.md
Normal file
17
docs/Tasktracking/2026-04-24-clash-yaml-codemirror-editor.md
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
# 2026-04-24: Clash YAML CodeMirror Editor + Settings Save Button Fix
|
||||
|
||||
## Changes
|
||||
1. **Fix: settings save button not enabling when toggling Clash Subscription**
|
||||
- `confAlerts` computed property crashed when `subClashURI`/`subURI`/`subJsonURI` was null/undefined
|
||||
- Added `|| ''` fallback before `.length` access for all three URI fields
|
||||
|
||||
2. **Feat: CodeMirror YAML editor for Clash template**
|
||||
- Replaced plain `<a-textarea>` with CodeMirror editor (YAML syntax highlighting, line numbers, auto-indent)
|
||||
- Added `web/assets/codemirror/yaml.js` (CodeMirror 5.65.1 YAML mode)
|
||||
- Updated `settings.html` with CodeMirror CSS/JS includes, tab change handler, and init method
|
||||
- Updated `clash.html` to use hidden textarea for CodeMirror attachment
|
||||
|
||||
3. **Chore: version bump to v1.5.4.1-beta**
|
||||
|
||||
## Tag
|
||||
- `v1.5.4.1-beta`
|
||||
839
docs/superpowers/plans/2026-04-24-node-management-sidebar.md
Normal file
839
docs/superpowers/plans/2026-04-24-node-management-sidebar.md
Normal file
|
|
@ -0,0 +1,839 @@
|
|||
# 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" .}}
|
||||
<a-layout id="app" v-cloak :class="themeSwitcher.currentTheme + ' nodes-page'">
|
||||
<a-sidebar></a-sidebar>
|
||||
<a-layout id="content-layout">
|
||||
<a-layout-content>
|
||||
<a-spin :spinning="loading" :delay="200" tip='{{ i18n "loading"}}'>
|
||||
<transition name="list" appear>
|
||||
<a-row :gutter="[isMobile ? 8 : 16, isMobile ? 8 : 12]">
|
||||
<!-- Connected Nodes Section -->
|
||||
<a-col :span="24">
|
||||
<a-card hoverable>
|
||||
<template #title>
|
||||
<a-row type="flex" justify="space-between" align="middle">
|
||||
<a-col>
|
||||
<a-space>
|
||||
<a-icon type="cluster"></a-icon>
|
||||
<span>{{ i18n "pages.nodes.connectedNodes" }}</span>
|
||||
<a-tag :color="nodeRole === 'master' ? 'blue' : 'green'">[[ nodeRole ]]</a-tag>
|
||||
</a-space>
|
||||
</a-col>
|
||||
<a-col>
|
||||
<a-button icon="reload" size="small" @click="loadNodes">{{ i18n "pages.nodes.refresh" }}</a-button>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</template>
|
||||
<a-table
|
||||
v-if="nodeRole === 'master'"
|
||||
:columns="nodeColumns"
|
||||
:data-source="nodes"
|
||||
:row-key="record => record.nodeId"
|
||||
:pagination="false"
|
||||
:scroll="isMobile ? { x: 700 } : undefined"
|
||||
size="middle">
|
||||
<template slot="status" slot-scope="text, record">
|
||||
<a-badge :status="record.online ? 'success' : 'error'" :text="record.online ? '{{ i18n "pages.nodes.online" }}' : '{{ i18n "pages.nodes.offline" }}'" />
|
||||
</template>
|
||||
<template slot="role" slot-scope="text, record">
|
||||
<a-tag :color="record.nodeRole === 'master' ? 'blue' : 'green'">[[ record.nodeRole ]]</a-tag>
|
||||
</template>
|
||||
<template slot="lastHeartbeat" slot-scope="text, record">
|
||||
[[ record.lastHeartbeatAt ? formatTime(record.lastHeartbeatAt) : '-' ]]
|
||||
</template>
|
||||
<template slot="lastSync" slot-scope="text, record">
|
||||
[[ record.lastSyncAt ? formatTime(record.lastSyncAt) : '-' ]]
|
||||
</template>
|
||||
</a-table>
|
||||
<div v-if="nodeRole === 'worker'">
|
||||
<a-empty v-if="nodes.length === 0" description='{{ i18n "pages.nodes.noWorkerNodes" }}' />
|
||||
<a-descriptions v-else bordered size="small" :column="isMobile ? 1 : 2">
|
||||
<a-descriptions-item label='{{ i18n "pages.nodes.nodeId" }}'>[[ nodes[0].nodeId ]]</a-descriptions-item>
|
||||
<a-descriptions-item label='{{ i18n "pages.nodes.status" }}'>
|
||||
<a-badge :status="nodes[0].online ? 'success' : 'error'" :text="nodes[0].online ? '{{ i18n "pages.nodes.online" }}' : '{{ i18n "pages.nodes.offline" }}'" />
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label='{{ i18n "pages.nodes.lastHeartbeat" }}'>[[ nodes[0].lastHeartbeatAt ? formatTime(nodes[0].lastHeartbeatAt) : '-' ]]</a-descriptions-item>
|
||||
<a-descriptions-item label='{{ i18n "pages.nodes.lastSync" }}'>[[ nodes[0].lastSyncAt ? formatTime(nodes[0].lastSyncAt) : '-' ]]</a-descriptions-item>
|
||||
<a-descriptions-item label='{{ i18n "pages.nodes.syncVersion" }}'>[[ nodes[0].lastSeenVersion ]]</a-descriptions-item>
|
||||
<a-descriptions-item label='{{ i18n "pages.nodes.error" }}'>[[ nodes[0].lastError || '-' ]]</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</div>
|
||||
<a-empty v-if="nodeRole === 'master' && nodes.length === 0" description='{{ i18n "pages.nodes.noWorkerNodes" }}' />
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<!-- Current Node Config Section -->
|
||||
<a-col :span="24">
|
||||
<a-card hoverable>
|
||||
<template #title>
|
||||
<a-space>
|
||||
<a-icon type="setting"></a-icon>
|
||||
<span>{{ i18n "pages.nodes.currentNodeConfig" }}</span>
|
||||
</a-space>
|
||||
</template>
|
||||
<a-form layout="vertical">
|
||||
<a-row :gutter="16">
|
||||
<a-col :xs="24" :sm="12" :md="8">
|
||||
<a-form-item label='{{ i18n "pages.nodes.role" }}'>
|
||||
<a-input :value="nodeConfig.role" disabled></a-input>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :md="8">
|
||||
<a-form-item label='{{ i18n "pages.nodes.nodeId" }}'>
|
||||
<a-input :value="nodeConfig.nodeId" disabled></a-input>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :md="8">
|
||||
<a-form-item label='{{ i18n "pages.nodes.dbType" }}'>
|
||||
<a-select v-model="nodeConfig.dbType" :disabled="saving">
|
||||
<a-select-option value="sqlite">SQLite</a-select-option>
|
||||
<a-select-option value="mysql">MySQL/MariaDB</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :md="8">
|
||||
<a-form-item label='{{ i18n "pages.nodes.syncInterval" }}'>
|
||||
<a-input-number v-model="nodeConfig.syncInterval" :min="5" :max="3600" style="width: 100%"></a-input-number>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :md="8">
|
||||
<a-form-item label='{{ i18n "pages.nodes.trafficFlushInterval" }}'>
|
||||
<a-input-number v-model="nodeConfig.trafficFlushInterval" :min="5" :max="3600" style="width: 100%"></a-input-number>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-divider>{{ i18n "pages.nodes.dbHost" }}</a-divider>
|
||||
<a-row :gutter="16">
|
||||
<a-col :xs="24" :sm="12" :md="8">
|
||||
<a-form-item label='{{ i18n "pages.nodes.dbHost" }}'>
|
||||
<a-input v-model="nodeConfig.dbHost" :disabled="saving"></a-input>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :md="8">
|
||||
<a-form-item label='{{ i18n "pages.nodes.dbPort" }}'>
|
||||
<a-input v-model="nodeConfig.dbPort" :disabled="saving"></a-input>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :md="8">
|
||||
<a-form-item label='{{ i18n "pages.nodes.dbName" }}'>
|
||||
<a-input v-model="nodeConfig.dbName" :disabled="saving"></a-input>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :md="8">
|
||||
<a-form-item label='{{ i18n "pages.nodes.dbUser" }}'>
|
||||
<a-input v-model="nodeConfig.dbUser" :disabled="saving"></a-input>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :md="8">
|
||||
<a-form-item label='{{ i18n "pages.nodes.dbPass" }}'>
|
||||
<a-input-password v-model="nodeConfig.dbPass" :disabled="saving"></a-input-password>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-form-item>
|
||||
<a-button type="primary" icon="save" :loading="saving" @click="saveConfig">
|
||||
{{ i18n "pages.nodes.save" }}
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</transition>
|
||||
</a-spin>
|
||||
</a-layout-content>
|
||||
</a-layout>
|
||||
</a-layout>
|
||||
{{template "page/body_scripts" .}}
|
||||
<script>
|
||||
const app = new Vue({
|
||||
el: '#app',
|
||||
delimiters: ['[[', ']]'],
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
saving: false,
|
||||
nodeRole: '{{ if .is_admin }}master{{ else }}worker{{ end }}',
|
||||
nodes: [],
|
||||
nodeConfig: {
|
||||
role: '',
|
||||
nodeId: '',
|
||||
syncInterval: 30,
|
||||
trafficFlushInterval: 10,
|
||||
dbType: '',
|
||||
dbHost: '',
|
||||
dbPort: '',
|
||||
dbUser: '',
|
||||
dbPass: '',
|
||||
dbName: '',
|
||||
},
|
||||
nodeColumns: [
|
||||
{ title: '{{ i18n "pages.nodes.nodeId" }}', dataIndex: 'nodeId', width: 150 },
|
||||
{ title: '{{ i18n "pages.nodes.status" }}', scopedSlots: { customRender: 'status' }, width: 100 },
|
||||
{ title: '{{ i18n "pages.nodes.role" }}', scopedSlots: { customRender: 'role' }, width: 80 },
|
||||
{ title: '{{ i18n "pages.nodes.lastHeartbeat" }}', scopedSlots: { customRender: 'lastHeartbeat' }, width: 180 },
|
||||
{ title: '{{ i18n "pages.nodes.lastSync" }}', scopedSlots: { customRender: 'lastSync' }, width: 180 },
|
||||
{ title: '{{ i18n "pages.nodes.syncVersion" }}', dataIndex: 'lastSeenVersion', width: 120 },
|
||||
{ title: '{{ i18n "pages.nodes.error" }}', dataIndex: 'lastError', ellipsis: true },
|
||||
],
|
||||
refreshTimer: null,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isMobile() {
|
||||
return window.innerWidth <= 768;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async loadNodes() {
|
||||
try {
|
||||
const res = await axios.get('api/nodes/list');
|
||||
if (res.data.success) {
|
||||
this.nodes = res.data.obj;
|
||||
// Determine current role from the first node or config
|
||||
if (this.nodes.length > 0) {
|
||||
// If we're master, all returned nodes are workers
|
||||
// If we're worker, returned nodes are master
|
||||
// We can also check from config
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load nodes', e);
|
||||
}
|
||||
},
|
||||
async loadConfig() {
|
||||
try {
|
||||
const res = await axios.get('api/nodes/config');
|
||||
if (res.data.success) {
|
||||
Object.assign(this.nodeConfig, res.data.obj);
|
||||
this.nodeRole = res.data.obj.role;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load node config', e);
|
||||
}
|
||||
},
|
||||
async saveConfig() {
|
||||
this.saving = true;
|
||||
try {
|
||||
const res = await axios.post('api/nodes/config', {
|
||||
syncInterval: this.nodeConfig.syncInterval,
|
||||
trafficFlushInterval: this.nodeConfig.trafficFlushInterval,
|
||||
dbType: this.nodeConfig.dbType,
|
||||
dbHost: this.nodeConfig.dbHost,
|
||||
dbPort: this.nodeConfig.dbPort,
|
||||
dbUser: this.nodeConfig.dbUser,
|
||||
dbPass: this.nodeConfig.dbPass,
|
||||
dbName: this.nodeConfig.dbName,
|
||||
});
|
||||
if (res.data.success) {
|
||||
this.$message.success(res.data.msg);
|
||||
} else {
|
||||
this.$message.error(res.data.msg);
|
||||
}
|
||||
} catch (e) {
|
||||
this.$message.error('Save failed');
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
formatTime(ts) {
|
||||
if (!ts) return '-';
|
||||
return moment.unix(ts).format('YYYY-MM-DD HH:mm:ss');
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.loadNodes();
|
||||
this.loadConfig();
|
||||
// Auto-refresh node list every 10 seconds
|
||||
this.refreshTimer = setInterval(() => {
|
||||
this.loadNodes();
|
||||
}, 10000);
|
||||
},
|
||||
beforeDestroy() {
|
||||
if (this.refreshTimer) {
|
||||
clearInterval(this.refreshTimer);
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
{{ template "page/body_end" }}
|
||||
</html>
|
||||
```
|
||||
|
||||
- [ ] **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"
|
||||
```
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
# Node Management Sidebar — Design Spec
|
||||
|
||||
**Date:** 2026-04-24
|
||||
**Status:** Approved
|
||||
|
||||
## Overview
|
||||
|
||||
Add a "Node Management" page accessible from the sidebar, visible only to admin users. The page displays connected node status and allows modifying node configuration.
|
||||
|
||||
## Behavior by Role
|
||||
|
||||
- **Master node:** Shows a table of all connected worker nodes with detailed status
|
||||
- **Worker node:** Shows a card with the master node's info
|
||||
|
||||
## Backend
|
||||
|
||||
### New Controller: `NodeController`
|
||||
|
||||
File: `web/controller/node.go`
|
||||
|
||||
API endpoints (all admin-only via `checkAdmin` middleware):
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/panel/api/nodes/list` | GET | Node list (master: all workers; worker: master) |
|
||||
| `/panel/api/nodes/config` | GET | Current node config |
|
||||
| `/panel/api/nodes/config` | POST | Update current node config |
|
||||
|
||||
### New Page Route
|
||||
|
||||
In `xui.go`, add:
|
||||
```go
|
||||
func (x *XUIController) Nodes(c *gin.Context) {
|
||||
// render nodes.html
|
||||
}
|
||||
```
|
||||
|
||||
Route: `GET /panel/nodes` → `XUIController.Nodes` (admin only)
|
||||
|
||||
### Data Sources
|
||||
|
||||
- **Node list:** Query `node_states` table via `database.GetNodeStates()` (new function in `database/shared_state.go`)
|
||||
- **Node config:** Read from `x-ui.json` via existing `config.GetNodeConfigFromJSON()`
|
||||
- **DB config:** Read from `AllSetting` entity (dbType, dbHost, dbPort, dbUser, dbPass, dbName)
|
||||
- **Online status:** `LastHeartbeatAt` > 2 × `syncInterval` ago → offline
|
||||
|
||||
### Config Update Logic
|
||||
|
||||
POST `/panel/api/nodes/config` accepts JSON body with:
|
||||
- `syncInterval` (int, seconds)
|
||||
- `trafficFlushInterval` (int, seconds)
|
||||
- `dbType`, `dbHost`, `dbPort`, `dbUser`, `dbPass`, `dbName`
|
||||
|
||||
Writes to `x-ui.json` under `"other"` group. Does NOT allow changing `nodeRole` or `nodeId` at runtime (displayed as read-only).
|
||||
|
||||
## Frontend
|
||||
|
||||
### New Page: `web/html/xui/nodes.html`
|
||||
|
||||
Structure (mirrors settings.html pattern):
|
||||
- Head section: imports, template includes
|
||||
- Vue app with two sections:
|
||||
1. **Node list** — `<a-table>` (master) or `<a-card>` (worker)
|
||||
2. **Node config form** — `<a-form>` with save button
|
||||
|
||||
### Node List Columns (master view)
|
||||
|
||||
| Column | Source |
|
||||
|--------|--------|
|
||||
| Node ID | `NodeState.NodeID` |
|
||||
| Status | Online/Offline (heartbeat check) |
|
||||
| Last Heartbeat | `NodeState.LastHeartbeatAt` (formatted) |
|
||||
| Last Sync | `NodeState.LastSyncAt` (formatted) |
|
||||
| Sync Version | `NodeState.LastSeenVersion` |
|
||||
| Error | `NodeState.LastError` |
|
||||
|
||||
Worker view: same fields in a card layout.
|
||||
|
||||
### Config Form Fields
|
||||
|
||||
| Field | Type | Editable |
|
||||
|-------|------|----------|
|
||||
| Node Role | Text | No (read-only) |
|
||||
| Node ID | Text | No (read-only) |
|
||||
| Sync Interval | Number (seconds) | Yes |
|
||||
| Traffic Flush Interval | Number (seconds) | Yes |
|
||||
| DB Type | Select (sqlite/mysql) | Yes |
|
||||
| DB Host | Text | Yes |
|
||||
| DB Port | Number | Yes |
|
||||
| DB User | Text | Yes |
|
||||
| DB Password | Password | Yes |
|
||||
| DB Name | Text | Yes |
|
||||
|
||||
### Auto-Refresh
|
||||
|
||||
Node list polls `/panel/api/nodes/list` every 10 seconds via `setInterval`.
|
||||
|
||||
### Sidebar Change
|
||||
|
||||
In `web/html/component/aSidebar.html`, add between `settings` and `xray`:
|
||||
```javascript
|
||||
{{if .is_admin}}
|
||||
{ key: '{{ .base_path }}panel/nodes', icon: 'cluster', title: '{{ i18n "menu.nodes"}}' },
|
||||
{{end}}
|
||||
```
|
||||
|
||||
## i18n
|
||||
|
||||
Add to `translate.en_US.toml` and `translate.zh_CN.toml`:
|
||||
```toml
|
||||
[menu]
|
||||
"nodes" = "Nodes" # en
|
||||
"nodes" = "节点管理" # zh
|
||||
|
||||
[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"
|
||||
"trafficFlushInterval" = "Traffic Flush Interval"
|
||||
"dbType" = "Database Type"
|
||||
"dbHost" = "Database Host"
|
||||
"dbPort" = "Database Port"
|
||||
"dbUser" = "Database User"
|
||||
"dbPass" = "Database Password"
|
||||
"dbName" = "Database Name"
|
||||
"save" = "Save"
|
||||
"saveSuccess" = "Saved successfully"
|
||||
"noWorkerNodes" = "No worker nodes connected"
|
||||
"masterNode" = "Master Node"
|
||||
"workerNodes" = "Worker Nodes"
|
||||
```
|
||||
|
||||
## Files to Create/Modify
|
||||
|
||||
| File | Action |
|
||||
|------|--------|
|
||||
| `web/controller/node.go` | **Create** — NodeController with list/config APIs |
|
||||
| `web/html/xui/nodes.html` | **Create** — Node management page |
|
||||
| `web/html/component/aSidebar.html` | **Modify** — Add nodes menu item |
|
||||
| `web/web.go` | **Modify** — Register routes and controller |
|
||||
| `web/controller/xui.go` | **Modify** — Add Nodes() page method |
|
||||
| `web/translation/translate.en_US.toml` | **Modify** — Add i18n keys |
|
||||
| `web/translation/translate.zh_CN.toml` | **Modify** — Add i18n keys |
|
||||
| `database/shared_state.go` | **Modify** — Add GetNodeStates() query function |
|
||||
|
||||
## Scope Boundaries
|
||||
|
||||
- **In scope:** View node status, modify node config, sidebar entry
|
||||
- **Out of scope:** Node registration/removal, restart, adding new nodes, real-time WebSocket updates (uses polling instead)
|
||||
Loading…
Reference in a new issue