From 2721dd0f1389a0212d55c0e4b2217f4a72fe3140 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 24 Apr 2026 17:25:02 +0800 Subject: [PATCH] chore: bump version to v1.6.0-beta --- config/version | 2 +- ...2026-04-24-clash-yaml-codemirror-editor.md | 17 + .../2026-04-24-node-management-sidebar.md | 839 ++++++++++++++++++ ...26-04-24-node-management-sidebar-design.md | 157 ++++ 4 files changed, 1014 insertions(+), 1 deletion(-) create mode 100644 docs/Tasktracking/2026-04-24-clash-yaml-codemirror-editor.md create mode 100644 docs/superpowers/plans/2026-04-24-node-management-sidebar.md create mode 100644 docs/superpowers/specs/2026-04-24-node-management-sidebar-design.md diff --git a/config/version b/config/version index 09a48036..0d759e53 100644 --- a/config/version +++ b/config/version @@ -1 +1 @@ -v1.5.4.1-beta \ No newline at end of file +v1.6.0-beta \ No newline at end of file diff --git a/docs/Tasktracking/2026-04-24-clash-yaml-codemirror-editor.md b/docs/Tasktracking/2026-04-24-clash-yaml-codemirror-editor.md new file mode 100644 index 00000000..efdaac24 --- /dev/null +++ b/docs/Tasktracking/2026-04-24-clash-yaml-codemirror-editor.md @@ -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 `` 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` diff --git a/docs/superpowers/plans/2026-04-24-node-management-sidebar.md b/docs/superpowers/plans/2026-04-24-node-management-sidebar.md new file mode 100644 index 00000000..88c128de --- /dev/null +++ b/docs/superpowers/plans/2026-04-24-node-management-sidebar.md @@ -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" .}} + + + + + + + + + + + + + + + + + +
+ + + [[ 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" +``` diff --git a/docs/superpowers/specs/2026-04-24-node-management-sidebar-design.md b/docs/superpowers/specs/2026-04-24-node-management-sidebar-design.md new file mode 100644 index 00000000..36ab6e03 --- /dev/null +++ b/docs/superpowers/specs/2026-04-24-node-management-sidebar-design.md @@ -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** — `` (master) or `` (worker) + 2. **Node config 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)