robert 1 周之前
父節點
當前提交
fd9ff6862e
共有 4 個文件被更改,包括 671 次插入65 次删除
  1. 198 23
      example/web_admin/index.html
  2. 136 42
      example/web_admin/main.go
  3. 142 0
      example/web_admin/web.md
  4. 195 0
      tcp_server.go

+ 198 - 23
example/web_admin/index.html

@@ -112,29 +112,124 @@
                 
                 <!-- Dashboard Tab -->
                 <div x-show="tab === 'dashboard'" class="space-y-6">
-                    <div class="grid grid-cols-1 md:grid-cols-3 gap-6">
-                        <!-- Stat Card -->
-                        <div class="bg-dark-800 p-6 rounded-2xl border border-dark-700">
-                            <div class="text-slate-400 mb-2">Total Users</div>
-                            <div class="text-3xl font-bold text-white" x-text="users.length || '-'"></div>
-                        </div>
-                        <div class="bg-dark-800 p-6 rounded-2xl border border-dark-700">
-                            <div class="text-slate-400 mb-2">Cluster Status</div>
-                            <div class="text-3xl font-bold text-green-400">Healthy</div>
+                    
+                    <!-- System Info Grid -->
+                    <template x-if="info">
+                        <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
+                            <!-- Node Status -->
+                            <div class="bg-dark-800 p-6 rounded-2xl border border-dark-700">
+                                <h4 class="text-slate-400 text-sm font-bold uppercase mb-4">Node Status</h4>
+                                <div class="space-y-2">
+                                    <div class="flex justify-between">
+                                        <span class="text-slate-500">ID</span>
+                                        <span class="text-white font-mono" x-text="info.node.id"></span>
+                                    </div>
+                                    <div class="flex justify-between">
+                                        <span class="text-slate-500">State</span>
+                                        <span class="font-bold" :class="info.node.state === 'Leader' ? 'text-yellow-400' : 'text-blue-400'" x-text="info.node.state"></span>
+                                    </div>
+                                    <div class="flex justify-between">
+                                        <span class="text-slate-500">Term</span>
+                                        <span class="text-white font-mono" x-text="info.node.term"></span>
+                                    </div>
+                                    <div class="flex justify-between">
+                                        <span class="text-slate-500">Health</span>
+                                        <span class="" :class="info.node.healthy ? 'text-green-400' : 'text-red-400'" x-text="info.node.healthy ? 'Healthy' : 'Unhealthy'"></span>
+                                    </div>
+                                </div>
+                            </div>
+
+                            <!-- Storage -->
+                            <div class="bg-dark-800 p-6 rounded-2xl border border-dark-700">
+                                <h4 class="text-slate-400 text-sm font-bold uppercase mb-4">Storage</h4>
+                                <div class="space-y-2">
+                                    <div class="flex justify-between">
+                                        <span class="text-slate-500">DB Size</span>
+                                        <span class="text-white font-mono" x-text="(info.storage.db_size / 1024).toFixed(2) + ' KB'"></span>
+                                    </div>
+                                    <div class="flex justify-between">
+                                        <span class="text-slate-500">Log Size</span>
+                                        <span class="text-white font-mono" x-text="(info.storage.log_size / 1024).toFixed(2) + ' KB'"></span>
+                                    </div>
+                                    <div class="flex justify-between">
+                                        <span class="text-slate-500">Mem Alloc</span>
+                                        <span class="text-white font-mono" x-text="(info.storage.mem_alloc / 1024 / 1024).toFixed(2) + ' MB'"></span>
+                                    </div>
+                                    <div class="flex justify-between">
+                                        <span class="text-slate-500">GC Count</span>
+                                        <span class="text-white font-mono" x-text="info.storage.num_gc"></span>
+                                    </div>
+                                </div>
+                            </div>
+
+                            <!-- Consensus -->
+                            <div class="bg-dark-800 p-6 rounded-2xl border border-dark-700">
+                                <h4 class="text-slate-400 text-sm font-bold uppercase mb-4">Consensus</h4>
+                                <div class="space-y-2">
+                                    <div class="flex justify-between">
+                                        <span class="text-slate-500">Commit Idx</span>
+                                        <span class="text-white font-mono" x-text="info.indices.commit_index"></span>
+                                    </div>
+                                    <div class="flex justify-between">
+                                        <span class="text-slate-500">Applied Idx</span>
+                                        <span class="text-white font-mono" x-text="info.indices.applied_index"></span>
+                                    </div>
+                                    <div class="flex justify-between">
+                                        <span class="text-slate-500">Last Log</span>
+                                        <span class="text-white font-mono" x-text="info.indices.last_log_index"></span>
+                                    </div>
+                                    <div class="flex justify-between">
+                                        <span class="text-slate-500">DB Applied</span>
+                                        <span class="text-white font-mono" x-text="info.indices.db_applied"></span>
+                                    </div>
+                                </div>
+                            </div>
+
+                            <!-- Cluster -->
+                            <div class="bg-dark-800 p-6 rounded-2xl border border-dark-700 overflow-hidden">
+                                <h4 class="text-slate-400 text-sm font-bold uppercase mb-4">Cluster (<span x-text="info.cluster_size"></span>)</h4>
+                                <div class="space-y-2 max-h-32 overflow-y-auto">
+                                    <template x-for="(addr, id) in info.cluster" :key="id">
+                                        <div class="flex justify-between items-center text-sm">
+                                            <div class="flex items-center gap-2">
+                                                <div class="w-2 h-2 rounded-full" :class="id === info.node.leader ? 'bg-yellow-400' : 'bg-green-400'"></div>
+                                                <span class="text-white font-mono" x-text="id"></span>
+                                            </div>
+                                            <span class="text-slate-500 text-xs" x-text="addr"></span>
+                                        </div>
+                                    </template>
+                                </div>
+                            </div>
                         </div>
-                        <div class="bg-dark-800 p-6 rounded-2xl border border-dark-700">
-                            <div class="text-slate-400 mb-2">Keys Checked</div>
-                            <div class="text-3xl font-bold text-blue-400" x-text="kvHistory.length"></div>
+                    </template>
+
+                    <!-- Loading State -->
+                    <template x-if="!info">
+                        <div class="text-center p-12 bg-dark-800 rounded-2xl border border-dark-700 animate-pulse">
+                            <div class="text-slate-500">Loading Node Info...</div>
                         </div>
-                    </div>
+                    </template>
                     
                     <div class="bg-dark-800 rounded-2xl border border-dark-700 p-6">
-                        <h3 class="text-lg font-bold text-white mb-4">Quick Command</h3>
+                        <h3 class="text-lg font-bold text-white mb-4">Web CLI</h3>
+                        <div class="mb-4 text-sm text-slate-400 bg-dark-900/50 p-3 rounded-lg border border-dark-700 font-mono leading-relaxed">
+                            <div class="flex flex-wrap gap-x-6 gap-y-2">
+                                <span><span class="text-brand-500">GET</span> &lt;key&gt;</span>
+                                <span><span class="text-brand-500">SET</span> &lt;key&gt; &lt;val&gt;</span>
+                                <span><span class="text-brand-500">DEL</span> &lt;key&gt;</span>
+                                <span><span class="text-brand-500">SEARCH</span> &lt;pattern&gt;</span>
+                                <span><span class="text-brand-500">COUNT</span> &lt;pattern&gt;</span>
+                                <span><span class="text-brand-500">INFO</span></span>
+                                <span><span class="text-brand-500">HELP</span></span>
+                            </div>
+                        </div>
                         <div class="flex gap-2">
-                            <input type="text" x-model="quickCmd" placeholder="GET system.config" class="flex-1 bg-dark-900 border border-dark-700 rounded-lg p-3 text-white outline-none focus:border-brand-500">
+                            <input type="text" x-model="quickCmd" @keyup.enter="runQuickCmd" placeholder="Type command (e.g. HELP)..." class="flex-1 bg-dark-900 border border-dark-700 rounded-lg p-3 text-white outline-none focus:border-brand-500 font-mono">
                             <button @click="runQuickCmd" class="bg-brand-600 px-6 rounded-lg text-white font-bold hover:bg-brand-500">Run</button>
                         </div>
-                        <pre x-show="quickResult" class="mt-4 bg-black/30 p-4 rounded-lg text-sm text-green-400 font-mono" x-text="quickResult"></pre>
+                        <div x-show="quickResult" class="mt-4 bg-black/50 p-4 rounded-lg border border-dark-700 overflow-x-auto">
+                            <pre class="text-sm text-green-400 font-mono whitespace-pre-wrap" x-text="quickResult"></pre>
+                        </div>
                     </div>
                 </div>
 
@@ -236,6 +331,9 @@
                                     </template>
                                     <div x-show="(r.permissions || []).length > 3" class="text-xs text-slate-500 italic">...and more</div>
                                 </div>
+                                <button @click="openAddPermModal(r.name)" class="w-full py-2 bg-dark-700 hover:bg-dark-600 text-sm text-white rounded-lg transition">
+                                    Add Permission
+                                </button>
                             </div>
                         </template>
                     </div>
@@ -276,6 +374,37 @@
         </div>
     </div>
 
+    <!-- Add Permission Modal -->
+    <div x-show="showAddPermModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm" style="display: none;">
+        <div class="bg-dark-800 p-6 rounded-2xl w-full max-w-md border border-dark-700">
+            <h3 class="text-xl font-bold text-white mb-4">Add Permission to <span x-text="selectedRole" class="text-brand-500"></span></h3>
+            <div class="space-y-4">
+                <div>
+                    <label class="block text-sm font-medium mb-2 text-slate-400">Key Pattern</label>
+                    <input x-model="newPerm.pattern" type="text" placeholder="e.g. user.* or *" class="w-full bg-dark-900 border border-dark-700 rounded-lg p-3 text-white outline-none focus:border-brand-500">
+                </div>
+                <div>
+                    <label class="block text-sm font-medium mb-2 text-slate-400">Actions</label>
+                    <div class="flex gap-4 p-3 bg-dark-900 rounded-lg border border-dark-700">
+                        <label class="flex items-center gap-2 text-white cursor-pointer">
+                            <input type="checkbox" value="read" x-model="newPerm.actions" class="accent-brand-500"> Read
+                        </label>
+                        <label class="flex items-center gap-2 text-white cursor-pointer">
+                            <input type="checkbox" value="write" x-model="newPerm.actions" class="accent-brand-500"> Write
+                        </label>
+                        <label class="flex items-center gap-2 text-white cursor-pointer">
+                            <input type="checkbox" value="admin" x-model="newPerm.actions" class="accent-brand-500"> Admin
+                        </label>
+                    </div>
+                </div>
+                <div class="flex gap-3 pt-2">
+                    <button @click="addPermission" class="flex-1 bg-brand-600 text-white py-2 rounded-lg font-bold hover:bg-brand-500">Add</button>
+                    <button @click="showAddPermModal = false" class="flex-1 bg-dark-700 text-white py-2 rounded-lg font-bold hover:bg-dark-600">Cancel</button>
+                </div>
+            </div>
+        </div>
+    </div>
+
     <script>
         function app() {
             return {
@@ -293,6 +422,7 @@
                 users: [],
                 roles: [],
                 kvHistory: [], // Track KV ops
+                info: null,    // System Info
 
                 // KV Op
                 kvOp: 'GET',
@@ -311,10 +441,22 @@
                 showAddRoleModal: false,
                 newRoleName: '',
 
+                // Add Permission Modal
+                showAddPermModal: false,
+                selectedRole: '',
+                newPerm: { pattern: '', actions: [] },
+
                 init() {
                     if (this.token) {
                         this.isLoggedIn = true;
                         this.fetchUsers();
+                        this.fetchInfo();
+                        // Refresh info periodically
+                        setInterval(() => {
+                            if (this.isLoggedIn && this.tab === 'dashboard') {
+                                this.fetchInfo();
+                            }
+                        }, 2000);
                     }
                 },
 
@@ -330,7 +472,7 @@
                         });
                         const json = await res.json();
                         if (json.status === 'error') {
-                            if (json.error === 'Session Expired') {
+                            if (json.error === 'Session Expired' || json.error.startsWith('Connection failed')) {
                                 this.logout();
                             }
                             throw new Error(json.error);
@@ -380,17 +522,28 @@
                     try {
                         this.users = await this.api('USER_LIST');
                         if (typeof this.users === 'string') {
-                            // If proxy didn't parse JSON (edge case), try parse
                             this.users = JSON.parse(this.users);
                         }
                     } catch (e) {
                         console.error("Failed to fetch users", e);
-                        // Show error if it's "unknown command" suggesting outdated server
-                        if (e.message.includes("unknown command")) {
-                            alert("Server error: USER_LIST command not found. Please restart your Raft node to apply latest changes.");
-                        } else {
-                            this.errorMsg = "Failed to load users: " + e.message;
+                    }
+                },
+
+                async fetchInfo() {
+                    try {
+                        const res = await this.api('INFO');
+                        // TCP returns "OK {json}" or just {json} via proxy parsing?
+                        // api() helper returns json.data.
+                        // If TCP returns "OK <json>", proxy parses it if it looks like JSON.
+                        // Our INFO returns "OK <json>". 
+                        this.info = res;
+                        if (typeof this.info === 'string') {
+                            try {
+                                this.info = JSON.parse(this.info);
+                            } catch(e) { /* ignore */ }
                         }
+                    } catch (e) {
+                        console.error("Failed to fetch info", e);
                     }
                 },
 
@@ -456,6 +609,28 @@
                     }
                 },
 
+                openAddPermModal(roleName) {
+                    this.selectedRole = roleName;
+                    this.newPerm = { pattern: '', actions: [] };
+                    this.showAddPermModal = true;
+                },
+
+                async addPermission() {
+                    if (!this.newPerm.pattern || this.newPerm.actions.length === 0) {
+                        alert("Please provide pattern and at least one action");
+                        return;
+                    }
+                    try {
+                        // Command: ROLE_PERMISSION_ADD <role> <pattern> <actions>
+                        const actionsStr = this.newPerm.actions.join(',');
+                        await this.api('ROLE_PERMISSION_ADD', [this.selectedRole, this.newPerm.pattern, actionsStr]);
+                        this.showAddPermModal = false;
+                        this.fetchRoles();
+                    } catch (e) {
+                        alert(e.message);
+                    }
+                },
+
                 async unlockUser(username) {
                     if(!confirm("Unlock user " + username + "?")) return;
                     try {

+ 136 - 42
example/web_admin/main.go

@@ -9,6 +9,7 @@ import (
 	"net"
 	"net/http"
 	"strings"
+	"sync"
 )
 
 // Configuration
@@ -28,7 +29,50 @@ type APIResponse struct {
 	Error  string `json:"error,omitempty"`
 }
 
+// TCPPool implements a simple connection pool
+type TCPPool struct {
+	mu    sync.Mutex
+	conns []net.Conn
+	addr  string
+}
+
+func NewTCPPool(addr string) *TCPPool {
+	return &TCPPool{
+		addr:  addr,
+		conns: make([]net.Conn, 0),
+	}
+}
+
+func (p *TCPPool) Get() (net.Conn, error) {
+	p.mu.Lock()
+	if len(p.conns) > 0 {
+		conn := p.conns[len(p.conns)-1]
+		p.conns = p.conns[:len(p.conns)-1]
+		p.mu.Unlock()
+		return conn, nil
+	}
+	p.mu.Unlock()
+	return net.Dial("tcp", p.addr)
+}
+
+func (p *TCPPool) Put(conn net.Conn) {
+	p.mu.Lock()
+	p.conns = append(p.conns, conn)
+	p.mu.Unlock()
+}
+
+func (p *TCPPool) Discard(conn net.Conn) {
+	if conn != nil {
+		conn.Close()
+	}
+}
+
+var pool *TCPPool
+
 func main() {
+	// Initialize Connection Pool
+	pool = NewTCPPool(TargetTCPAddr)
+
 	// Serve static files (HTML)
 	http.HandleFunc("/", handleIndex)
 
@@ -65,58 +109,108 @@ func handleAPI(w http.ResponseWriter, r *http.Request) {
 	// 2. Get Token from Header
 	token := r.Header.Get("X-Session-Token")
 
-	// 3. Connect to TCP
-	conn, err := net.Dial("tcp", TargetTCPAddr)
+	// 3. Connect to TCP (via Pool)
+	conn, err := pool.Get()
 	if err != nil {
-		jsonResponse(w, APIResponse{Status: "error", Error: "Connection failed: " + err.Error()})
+		jsonResponse(w, APIResponse{Status: "error", Error: "Connection failed: 无法连接到后端节点 (127.0.0.1:9011)。请确保 Raft 服务已启动。 (" + err.Error() + ")"})
 		return
 	}
-	defer conn.Close()
-	reader := bufio.NewReader(conn)
-
-	// 4. Authenticate if token present (Except for LOGIN command itself)
-	if req.Command != "LOGIN" && token != "" {
-		fmt.Fprintf(conn, "AUTH %s\n", token)
-		authResp, _ := reader.ReadString('\n')
-		if !strings.HasPrefix(authResp, "OK") {
+
+	// Helper to execute request with retry logic
+	execute := func(c net.Conn) error {
+		// Set deadline to avoid hanging
+		// c.SetDeadline(time.Now().Add(5 * time.Second))
+
+		reader := bufio.NewReader(c)
+
+		// 4. Authenticate if token present (Except for LOGIN command itself)
+		if req.Command != "LOGIN" && token != "" {
+			fmt.Fprintf(c, "AUTH %s\n", token)
+			authResp, err := reader.ReadString('\n')
+			if err != nil {
+				return err
+			}
+			if !strings.HasPrefix(authResp, "OK") {
+				// Special handling: if auth fails, we return a specific error that is NOT a network error
+				// but we still want to return it to the client.
+				// However, if we are reusing a connection, maybe the previous session is interfering?
+				// AUTH command should override previous session.
+				// If it fails here, it means the token is invalid.
+				return fmt.Errorf("Session Expired")
+			}
+		}
+
+		// 5. Construct TCP Command
+		cmdStr := req.Command
+		if len(req.Args) > 0 {
+			cmdStr += " " + strings.Join(req.Args, " ")
+		}
+		fmt.Fprintf(c, "%s\n", cmdStr)
+
+		// 6. Read TCP Response
+		respStr, err := reader.ReadString('\n')
+		if err != nil {
+			return err
+		}
+		respStr = strings.TrimSpace(respStr)
+
+		// 7. Parse Response
+		if strings.HasPrefix(respStr, "OK") {
+			payload := strings.TrimPrefix(respStr, "OK ")
+			// If payload looks like JSON, try to parse it
+			var jsonData any
+			if strings.HasPrefix(payload, "[") || strings.HasPrefix(payload, "{") {
+				if err := json.Unmarshal([]byte(payload), &jsonData); err == nil {
+					jsonResponse(w, APIResponse{Status: "ok", Data: jsonData})
+					return nil
+				}
+			}
+			jsonResponse(w, APIResponse{Status: "ok", Data: payload})
+		} else if strings.HasPrefix(respStr, "ERR") {
+			errMsg := strings.TrimPrefix(respStr, "ERR ")
+			jsonResponse(w, APIResponse{Status: "error", Error: errMsg})
+		} else {
+			jsonResponse(w, APIResponse{Status: "error", Error: "Unknown response: " + respStr})
+		}
+		return nil
+	}
+
+	// Try with pooled connection
+	err = execute(conn)
+	if err != nil {
+		// If error is "Session Expired", it's a logic error, not network. Don't retry.
+		if err.Error() == "Session Expired" {
+			pool.Put(conn) // Connection is fine, token is bad
 			jsonResponse(w, APIResponse{Status: "error", Error: "Session Expired"})
 			return
 		}
-	}
 
-	// 5. Construct TCP Command
-	// Escape args if necessary (simple space joining for now)
-	cmdStr := req.Command
-	if len(req.Args) > 0 {
-		cmdStr += " " + strings.Join(req.Args, " ")
-	}
-	fmt.Fprintf(conn, "%s\n", cmdStr)
-
-	// 6. Read TCP Response
-	respStr, _ := reader.ReadString('\n')
-	respStr = strings.TrimSpace(respStr)
-
-	// 7. Parse Response
-	if strings.HasPrefix(respStr, "OK") {
-		// Extract potential data payload
-		payload := strings.TrimPrefix(respStr, "OK ")
-
-		// If payload looks like JSON, try to parse it
-		var jsonData any
-		if strings.HasPrefix(payload, "[") || strings.HasPrefix(payload, "{") {
-			if err := json.Unmarshal([]byte(payload), &jsonData); err == nil {
-				jsonResponse(w, APIResponse{Status: "ok", Data: jsonData})
-				return
-			}
+		// If network error, discard and retry once
+		pool.Discard(conn)
+
+		// Retry with new connection
+		conn, err = net.Dial("tcp", TargetTCPAddr)
+		if err != nil {
+			jsonResponse(w, APIResponse{Status: "error", Error: "Connection failed: 无法连接到后端节点 (127.0.0.1:9011)。请确保 Raft 服务已启动。 (" + err.Error() + ")"})
+			return
 		}
 
-		// Otherwise return as string
-		jsonResponse(w, APIResponse{Status: "ok", Data: payload})
-	} else if strings.HasPrefix(respStr, "ERR") {
-		errMsg := strings.TrimPrefix(respStr, "ERR ")
-		jsonResponse(w, APIResponse{Status: "error", Error: errMsg})
+		err = execute(conn)
+		if err != nil {
+			pool.Discard(conn)
+			// Return error to client
+			errMsg := err.Error()
+			if errMsg != "Session Expired" {
+				errMsg = "Connection failed: 无法连接到后端节点。请确保 Raft 服务已启动。 (" + errMsg + ")"
+			}
+			jsonResponse(w, APIResponse{Status: "error", Error: errMsg})
+			return
+		}
+		// Success on retry
+		pool.Put(conn)
 	} else {
-		jsonResponse(w, APIResponse{Status: "error", Error: "Unknown response: " + respStr})
+		// Success on first try
+		pool.Put(conn)
 	}
 }
 

+ 142 - 0
example/web_admin/web.md

@@ -0,0 +1,142 @@
+# Raft KV Web Admin
+
+`web_admin` 是一个用于管理 Raft KV 存储集群的轻量级 Web 界面。它作为一个独立的 HTTP 服务运行,通过 TCP 协议连接到 Raft 节点,提供可视化的数据管理和权限控制功能。
+
+## 架构概览
+
+该工具由两部分组成:
+1.  **后端代理 (Go)**: 一个简单的 HTTP 服务器,负责托管静态页面并将前端的 REST API 请求转换为 TCP 指令转发给 Raft 节点。
+2.  **前端界面 (HTML/Alpine.js)**: 一个单页应用 (SPA),提供用户友好的操作界面。
+
+### 技术栈
+- **后端**: Go (标准库), TCP 连接池
+- **前端**: HTML5, Tailwind CSS (样式), Alpine.js (交互逻辑)
+
+## 功能特性
+
+### 1. 认证与安全
+- **登录/登出**: 支持基于用户名/密码(及可选 OTP)的身份验证。
+- **会话管理**: 使用 Token 进行会话保持,支持自动过期处理。
+
+### 2. 仪表盘 (Dashboard)
+- **实时集群状态 (Cluster Status)**: 实时显示节点状态(ID, Role, Term)、存储使用情况(DB/Log Size, Memory)、共识进度(Commit/Applied Index)及集群成员列表。
+- **Web CLI**: 提供完整的命令执行环境,支持 `INFO`, `SEARCH`, `COUNT`, `JOIN`, `LEAVE` 以及 `GET`/`SET`/`DEL` 等所有标准 Raft CLI 命令,方便远程运维和调试。
+
+### 3. KV 数据管理 (KV Explorer)
+- **数据操作**: 提供图形化界面进行 `GET` (查询)、`SET` (设置) 和 `DEL` (删除) 操作。
+- **操作历史**: 临时记录当前会话的操作历史。
+
+### 4. 用户管理 (User Management)
+- **用户列表**: 展示所有用户及其关联角色和激活状态。
+- **创建用户**: 支持创建新用户并分配初始角色。
+- **解锁用户**: 支持重置被锁定的用户状态 (`USER_UNLOCK`)。
+
+### 5. 角色与权限管理 (Role Management)
+- **角色列表**: 展示系统中的角色及其详细的权限规则。
+- **创建角色**: 支持定义新的业务角色。
+- **权限配置**: 支持为角色添加细粒度的权限规则(基于 Key 模式匹配 + 读/写/管理动作)。
+
+## 快速开始
+
+### 启动服务
+
+1. 确保 Raft 节点已启动并在默认端口 `9011` 监听。
+2. 运行 Web Admin:
+
+```bash
+go run example/web_admin/main.go
+```
+
+服务默认启动在 `http://localhost:8088`。
+
+### 配置
+
+在 `main.go` 中可以修改默认配置:
+
+```go
+const (
+    TargetTCPAddr = "127.0.0.1:9011" // Raft 节点地址
+    WebPort       = ":8088"          // Web Admin 监听端口
+)
+```
+
+## 6. 联合调试开发 (Role 示例)
+
+本节介绍了如何利用 `example/role` 目录下的多节点示例环境与 Web Admin 进行联合调试。该环境预置了带有 RBAC (基于角色的访问控制) 功能的 Raft 节点。
+
+### 6.1 环境准备
+
+`example/role` 包含两个节点配置 (`node1` 和 `node2`) 以及用于初始化测试数据的 CLI 命令。
+
+**步骤 1:启动 Raft 节点 1**
+
+打开一个新的终端,启动 `node1`:
+
+```bash
+# 终端 1
+cd example/role/node1
+go run main.go demo_cli.go
+```
+*   监听地址: `127.0.0.1:9001`
+*   包含功能: 自带 `demo-init` 命令用于初始化测试账户。
+
+**步骤 2:启动 Raft 节点 2 (可选)**
+
+打开另一个终端,启动 `node2` 以模拟集群环境:
+
+```bash
+# 终端 2
+cd example/role/node2
+go run main.go demo_cli.go
+```
+*   监听地址: `127.0.0.1:9002`
+
+**步骤 3:初始化测试数据**
+
+在 `node1` 的终端中,输入以下命令初始化角色和用户:
+
+```bash
+# 在 node1 运行的终端中输入
+demo-init
+```
+
+此命令将创建以下测试账户:
+*   **alice** (密码 `pass123`): 角色 `sales_manager` (允许设置 `product.discount` 为 0.5-0.9)
+*   **bob** (密码 `pass123`): 角色 `junior_sales` (允许设置 `product.discount` 为 0.8-0.95)
+
+### 6.2 配置 Web Admin
+
+修改 `example/web_admin/main.go` 以连接到 `node1` 的端口 (`9001`)。
+
+```go
+// example/web_admin/main.go
+
+const (
+    TargetTCPAddr = "127.0.0.1:9001" // 修改为 node1 的端口
+    WebPort       = ":8088"
+)
+```
+
+### 6.3 运行与验证
+
+1.  启动 Web Admin:
+    ```bash
+    go run example/web_admin/main.go
+    ```
+
+2.  访问 `http://localhost:8088`。
+
+3.  **测试登录**:
+    *   使用 `alice` / `pass123` 登录。
+
+4.  **测试权限控制**:
+    *   进入 **KV Explorer**。
+    *   尝试 `SET product.discount 0.6` -> **应成功** (在 0.5-0.9 范围内)。
+    *   尝试 `SET product.discount 0.1` -> **应失败** (超出范围)。
+    *   尝试 `SET system.config 1` -> **应失败** (无权限)。
+
+5.  **测试多角色视图**:
+    *   注销并使用 `bob` / `pass123` 登录。
+    *   尝试 `SET product.discount 0.6` -> **应失败** (bob 只能设置 0.8-0.95)。
+
+通过此流程,可以完整验证 Web Admin 的前端逻辑与后端 Raft 节点的权限系统是否协同工作正常。

+ 195 - 0
tcp_server.go

@@ -5,6 +5,8 @@ import (
 	"encoding/json"
 	"fmt"
 	"net"
+	"runtime"
+	"strconv"
 	"strings"
 )
 
@@ -177,6 +179,161 @@ func (s *KVServer) handleTCPConnection(conn net.Conn) {
 				resp = session.username
 			}
 
+		case "HELP":
+			helpText := `Available Commands:
+  GET <key>                     - Get value
+  SET <key> <value>             - Set value
+  DEL <key>                     - Delete value
+  SEARCH <pattern> [limit]      - Search keys (e.g. user.*)
+  COUNT <pattern>               - Count keys
+  INFO                          - Show system stats
+  WHOAMI                        - Show current user
+  JOIN <id> <addr>              - Add node (Root only)
+  LEAVE <id>                    - Remove node (Root only)
+  USER_LIST                     - List users (Admin)
+  ROLE_LIST                     - List roles (Admin)
+  LOGIN/LOGOUT/EXIT`
+			resp = "OK " + helpText
+
+		case "INFO":
+			// Check permission (Root only if auth enabled)
+			if s.AuthManager.IsEnabled() {
+				sess, err := s.AuthManager.GetSession(session.token)
+				if err != nil {
+					resp = fmt.Sprintf("ERR %v", err)
+					break
+				}
+				if sess.Username != "root" {
+					resp = "ERR Permission Denied: Root access required"
+					break
+				}
+			}
+
+			// Gather stats
+			stats := s.GetStats()
+			health := s.HealthCheck()
+			dbSize := s.GetDBSize()
+			logSize := s.GetLogSize()
+			var m runtime.MemStats
+			runtime.ReadMemStats(&m)
+
+			// Construct JSON response
+			info := map[string]interface{}{
+				"node": map[string]interface{}{
+					"id":      health.NodeID,
+					"state":   health.State,
+					"term":    health.Term,
+					"leader":  health.LeaderID,
+					"healthy": health.IsHealthy,
+				},
+				"storage": map[string]interface{}{
+					"db_size":   dbSize,
+					"log_size":  logSize,
+					"mem_alloc": m.Alloc,
+					"mem_sys":   m.Sys,
+					"num_gc":    m.NumGC,
+				},
+				"indices": map[string]interface{}{
+					"commit_index":   stats.CommitIndex,
+					"applied_index":  stats.LastApplied,
+					"last_log_index": stats.LastLogIndex,
+					"db_applied":     s.DB.GetLastAppliedIndex(),
+				},
+				"cluster":      stats.ClusterNodes,
+				"cluster_size": stats.ClusterSize,
+			}
+
+			data, err := json.Marshal(info)
+			if err != nil {
+				resp = fmt.Sprintf("ERR %v", err)
+			} else {
+				resp = "OK " + string(data)
+			}
+
+		case "SEARCH":
+			// Usage: SEARCH <pattern> [limit] [offset]
+			if len(parts) < 2 {
+				resp = "ERR usage: SEARCH <pattern> [limit] [offset]"
+			} else {
+				pattern := parts[1]
+				limit := 20
+				offset := 0
+				if len(parts) >= 3 {
+					if l, err := strconv.Atoi(parts[2]); err == nil {
+						limit = l
+					}
+				}
+				if len(parts) >= 4 {
+					if o, err := strconv.Atoi(parts[3]); err == nil {
+						offset = o
+					}
+				}
+
+				results, err := s.SearchAuthenticated(pattern, limit, offset, session.token)
+				if err != nil {
+					resp = fmt.Sprintf("ERR %v", err)
+				} else {
+					data, _ := json.Marshal(results)
+					resp = "OK " + string(data)
+				}
+			}
+
+		case "COUNT":
+			// Usage: COUNT <pattern>
+			if len(parts) < 2 {
+				resp = "ERR usage: COUNT <pattern>"
+			} else {
+				pattern := parts[1]
+				count, err := s.CountAuthenticated(pattern, session.token)
+				if err != nil {
+					resp = fmt.Sprintf("ERR %v", err)
+				} else {
+					resp = fmt.Sprintf("OK %d", count)
+				}
+			}
+
+		case "JOIN":
+			// Usage: JOIN <id> <addr>
+			// Admin only
+			if s.AuthManager.IsEnabled() {
+				sess, err := s.AuthManager.GetSession(session.token)
+				if err != nil || sess.Username != "root" {
+					resp = "ERR Permission Denied: Root access required"
+					break
+				}
+			}
+			if len(parts) < 3 {
+				resp = "ERR usage: JOIN <id> <addr>"
+			} else {
+				err := s.Join(parts[1], parts[2])
+				if err != nil {
+					resp = fmt.Sprintf("ERR %v", err)
+				} else {
+					resp = "OK Join request sent"
+				}
+			}
+
+		case "LEAVE":
+			// Usage: LEAVE <id>
+			// Admin only
+			if s.AuthManager.IsEnabled() {
+				sess, err := s.AuthManager.GetSession(session.token)
+				if err != nil || sess.Username != "root" {
+					resp = "ERR Permission Denied: Root access required"
+					break
+				}
+			}
+			if len(parts) < 2 {
+				resp = "ERR usage: LEAVE <id>"
+			} else {
+				err := s.Leave(parts[1])
+				if err != nil {
+					resp = fmt.Sprintf("ERR %v", err)
+				} else {
+					resp = "OK Leave request sent"
+				}
+			}
+
 		// --- Admin Commands ---
 
 		case "USER_LIST":
@@ -234,6 +391,44 @@ func (s *KVServer) handleTCPConnection(conn net.Conn) {
 					resp = "OK"
 				}
 			}
+
+		case "ROLE_PERMISSION_ADD":
+			// Usage: ROLE_PERMISSION_ADD <role> <pattern> <actions>
+			// Actions: comma separated list of actions (read,write,admin,*)
+			if len(parts) < 4 {
+				resp = "ERR usage: ROLE_PERMISSION_ADD <role> <pattern> <actions>"
+			} else {
+				roleName := parts[1]
+				pattern := parts[2]
+				actionsStr := parts[3]
+				actions := strings.Split(actionsStr, ",")
+
+				rolePtr, err := s.AuthManager.GetRole(roleName)
+				if err != nil {
+					resp = fmt.Sprintf("ERR %v", err)
+				} else {
+					// Create a copy to modify
+					role := *rolePtr
+					
+					// Deep copy permissions to avoid potential side effects on cached object
+					originalPerms := role.Permissions
+					role.Permissions = make([]Permission, len(originalPerms))
+					copy(role.Permissions, originalPerms)
+					
+					newPerm := Permission{
+						KeyPattern: pattern,
+						Actions:    actions,
+					}
+					role.Permissions = append(role.Permissions, newPerm)
+					
+					err := s.AuthManager.UpdateRole(role)
+					if err != nil {
+						resp = fmt.Sprintf("ERR %v", err)
+					} else {
+						resp = "OK"
+					}
+				}
+			}
 		
 		case "USER_UNLOCK":
 			// Usage: USER_UNLOCK <username>