# 集群修复 + 加入密钥 + 画布/状态页增强 + Seq 删除
## P0:关键 Bug(集群已炸)
### A1. Leader 退出后无 failover — ring_engine.go `runCommands` 自退出分支 (L358-383)
**根因**:leader 自退出时 `SelfRemove` 清 `LeaderID=""` → token 携空 LeaderID → 后继 `IsLeader()=false` → 调 `Forward` 非 `Send` → cycle 永不推进;`WatchLeader` 的 `AlivePredecessor("")` 失败 → 无人晋升 → ring 无 leader 直至 token 丢失。
**修复**:在 SelfRemove 前捕获 `wasLeader`,之后若 wasLeader 则指定 `removedNext`(原后继)为新 leader:
```go
wasLeader := e.state.LeaderID == e.ID
if succ, ok := e.state.AliveSuccessor(e.ID); ok && succ != e.ID {
e.removedNext = succ // 已有逻辑(L372-374)
}
e.state.SelfRemove(e.ID) // 已有
e.selfRemoved = true // 已有
// 新增:leader 退出时指定后继为新 leader
if wasLeader && e.removedNext != "" {
e.state.LeaderID = e.removedNext
for i := range e.state.Nodes {
e.state.Nodes[i].IsLeader = e.state.Nodes[i].ID == e.removedNext
}
if e.Log != nil {
_, _ = e.Log.Append(e.ID, LogLeaderChange, map[string]string{"leader": e.removedNext})
}
}
```
后继收到 token 时 `IsLeader()=true` → 调 `Send`(bump cycle)→ ring 恢复正常。
### A2. 退出后残留旧集群信息 — ring_engine.go `Forward` (L439-461) + 新 `detachAsStandalone()`
**根因**:`SelfRemove` 只移除自己出 Nodes,保留 Topology/Pending/Log/其他成员。退出后不再收 token → `e.state` 冻结 → `Snapshot()` 返回旧画面。非前端 bug。
**修复**:
1. 新增 `detachAsStandalone()`(≈ CreateCluster 去掉 IsMember 守卫 + 重置 Log):
```go
func (e *Engine) detachAsStandalone() {
e.state = State{
LeaderID: e.ID, PendingTasks: map[string]*Task{},
Topology: map[string]*TopoEntry{}, RoundDelay: 2 * time.Second,
}
e.state.UpsertNode(Node{ID: e.ID, Addr: e.myAddr, Alive: true, IsLeader: true,
Load: e.loadSnapshot(), Version: e.Version, Cache: e.Cache})
e.selfRemoved = false; e.removedNext = ""
e.lastRingStart = time.Time{}; e.lastTokenAt = 0; e.lastSyncAt = 0
e.inflight.clear(); e.failCount = map[string]int{}; e.published = map[string]struct{}{}
if e.Log != nil {
e.Log = NewClusterLog() // 清空旧集群日志
_, _ = e.Log.Append(e.ID, LogLeaderChange, map[string]string{"leader": e.ID})
}
log.Printf("ring[%s] detached to standalone after self-leave", e.ID)
}
```
2. 在 `Forward` 中,将 token 交给 removedNext 后(已发送),若 `e.selfRemoved` 则 detach:
```go
func (e *Engine) Forward(ctx context.Context, tk *Token) error {
if e.removedNext != "" {
next := e.removedNext; e.removedNext = ""
var err error
if e.send != nil {
err = e.send(ctx, next, tk) // 先发 token(携带旧 state 含新 leader)
}
if e.selfRemoved { // 发完后 detach(不影响已发 token)
e.detachAsStandalone()
}
return err
}
// ... 原有逻辑
}
```
**效果**:退出后 `Snapshot()` 返回 standalone(1 节点=自己,0 转发,空日志)→ 前端顶部数据区显示"待加入"+ 1 成员 + 0 活跃转发 → 不再残留旧拓扑/转发/日志。
### A3. 测试
- 现有 `ring_remove_test.go` 3 个测试全是非 leader 退出(wasLeader=false 不指定后继,detach 在 Forward 中测试不调)→ 不受影响 ✓
- 新增 `TestLeaderSelfRemoveDesignatesSuccessor`:leader 自退出 → 后继被指定为新 leader
- 新增 `TestDetachAfterForward`:自退出 + Forward 后 state 重置为 standalone
## P1:State.Seq 删除 — ring.go + ring_engine.go
(plan.md 从未要求 Seq;NextTaskID 注释 L213-221 已自述回归 bug)
- ring.go L90:State 结构体删 `Seq int64 json:"seq"`
- ring.go L212-235 NextTaskID:`max := int64(0)`(删 `max := s.Seq`),删 `s.Seq = max+1`,`return fmt.Sprintf("t%d", max+1)`
- ring_engine.go L635:CreateCluster 删 `Seq: e.state.Seq`
- LogEntry.Seq(ring_log.go)保留(日志水印,plan §增量日志同步)
## P1:集群加入密钥(per-node 准入密钥)
### 设计
- 每个节点有 `nodeKey`(随机 16 字节 hex),首次启动生成、持久化到 DB、重启后读回(稳定)
- 画布 webui 显示本节点 nodeKey(可复制);加入集群对话框需填对端地址 + 对端 nodeKey
- 对端 `handleClusterJoin` 验证 `ji.JoinKey == e.nodeKey`,不匹配 403
- `CreateCluster`/`AdoptState` 不重新生成(init 时已存在)
### B1. 数据模型
- `store.Settings`(store.go L114-119):加 `NodeKey string json:"nodeKey,omitempty"` + migrate 列 `node_key TEXT NOT NULL DEFAULT ''`
- `store.SetNodeKey(key string) error`:`UPDATE settings SET node_key=?`
- `Engine` struct(ring_engine.go L24-80):加 `nodeKey string` + `func (e *Engine) NodeKey() string { return e.nodeKey }`
- `JoinInfo`(ring_engine.go L544-550):加 `JoinKey string json:"joinKey,omitempty"`
- `RingSnapshot`(ring_engine.go L464-):加 `NodeKey string json:"nodeKey"`
### B2. 初始化 + 持久化
- `NewEngine`(ring_engine.go L84-105):签名加 `nodeKey string` 参数
- main.go 引擎初始化(L121):读 `settings.NodeKey`;若空 → `crypto/rand` 生成 16 字节 hex → `store.SetNodeKey` 持久化 → 传给 NewEngine
### B3. 加入验证
- `handleClusterJoin`(handlers.go L530-556):decode JoinInfo 后加 `if ji.JoinKey != h.Ring.NodeKey() { 403 }`
- `JoinRingAddr`(token_join.go L54-57):签名加 `joinKey string`,设 `ji.JoinKey = joinKey`
- `JoinRing`(token_join.go L16-47):JoinInfo 已含 JoinKey,随 POST body 发送
### B4. -peer 启动引导
- main.go flags:加 `-join-key string`(或 `W4F_JOIN_KEY` env)
- main.go L336-346 bootstrap:`ring.JoinRing(jc, peers[0], ji)` → `JoinRingAddr(ctx, peers[0], *joinKey)`
### B5. 前端
- types.ts:`RingSnapshot` 加 `nodeKey?: string`
- api.ts `clusterJoinRing`(L137-142):改 `(addr, joinKey)` → body `{addr, joinKey}`
- ClusterView.vue:
- 顶部 hero 或独立区域:显示本机 nodeKey(`ring.nodeKey`)+ 复制按钮
- 加入对话框(L167-178):加"加入密钥"输入框(`joinDialog.key`)
- `onJoin`(L369-383):`api.clusterJoinRing(addr, key)`
- `joinDialog` reactive(L193):加 `key: ''`
## P2:画布边启用/禁用开关 + 分组标签 — PortEdge.vue + CanvasView.vue
### PortEdge.vue
- computed `isDisabled`(读 `props.data?.disabled`)、`groupName`(读 `props.data?.group`)
- emits 加 `toggle-disabled`、`edit-group`(payload `{edgeId}`)
- 模板 `.port-label` 内端口号旁:`{{ isDisabled?'禁':'通' }}`;`{{ groupName }}`
- `@pointerdown.stop` 阻止拖拽(label 的 onPointerDown 不触发 → dragging 保持 false → onPointerUp `!wasDrag` 直接 return,不误开端口编辑器)
- strokeColor:disabled 时灰色(`var(--el-color-info-light-5)`)
### CanvasView.vue
- L60-67 edge 模板:加 `@toggle-disabled="onToggleDisabled" @edit-group="onEditGroup"`
- import ElMessageBox
- `onToggleDisabled({edgeId})`:翻 `edge.data.disabled`,`dirty=true`
- `onEditGroup({edgeId})`:ElMessageBox.prompt(列现有分组名,留空=未分组)→ 设 `edge.data.group`,`dirty=true`
- (buildPayload L638 已序列化 disabled,L637 已序列化 group,保存重建 L690-691 已回填 → 无需改)
## P2:状态页分组管理 — store + handler + route + api + StatusView
### 后端
- store.go:`SetLinkGroup(local, remote string, port int, group string) error`(`UPDATE links SET grp=? WHERE ...`;列 grp 已存在)
- handlers_forwards.go:`assignReq{Local,Remote,RemotePort,Group}` + `handleForwardsAssign`(findLinkByTriple→SetLinkGroup)+ `handleForwardsGroupDelete`(ListLinks 过滤 group→逐条 SetLinkGroup(...,""))
- server.go L78-81 路由块:`/forwards/assign`、`/forwards/group/delete`(均 write 级)
### 前端
- api.ts:`assignGroup(local,remote,remotePort,group)` + `deleteGroup(group)`
- StatusView.vue:
- 卡片 `.fwd-head` L70 后:`{{ f.group || '未分组' }}`
- 分组头 L45-48 `.grp-actions`:``
- `editGroup(f)`:ElMessageBox.prompt(列现有分组,留空=移出)→ api.assignGroup→loadStatus
- `deleteGroup(group)`:confirm→api.deleteGroup→loadStatus
- import ElMessageBox
## 不变(保留)
- 状态页所有现有按钮(启动/停止转发、一键启动/停止整组)+ forwards 端点
- handlers_forwards.go 全部现有函数
- ring 语义(SubmitTask/RevokeTask/HasTask/leader 选举/拓扑)
- 画布端口编辑对话框已有"分组"字段(保留,边上标签是额外快速入口)
## 构建验证
1. `go test ./internal/cluster/...`(Seq 删除 + leader failover + detach 不破坏现有测试 + 新测试通过)
2. `cd web && npm run build`;`cp -r web/dist internal/httpapi/dist`;`go build -o deploy/artifacts/webui4frpc ./cmd/webui4frpc`
3. `docker rm -f w4f-node-a b c d e; bash deploy/run-cluster.sh`
4. **leader failover**:a 为 leader → a 退出 → b 成为新 leader(cycle 继续推进)→ ring 正常
5. **退出残留**:a 退出后 a 的集群页显示 standalone(1 节点、0 转发、空日志),不残留旧拓扑
6. **加入密钥**:创建集群 → 显示 nodeKey → b 加入需填 a 的 nodeKey → 错误密钥 403 → 正确密钥加入成功
7. **画布边开关**:点"禁"→边变灰→保存→状态页 stopped;点"通"→恢复
8. **画布边分组**:点分组标签→输入名→保存→状态页显示分组
9. **状态页分组管理**:卡片分组标签→改组→即时生效;分组头删除分组→全移至未分组
10. `bash deploy/test-forward.sh` 通过
## 影响文件
- Go:`internal/cluster/ring.go`、`ring_engine.go`、`ring_leader.go`(仅注释/无改)、`token_join.go`、`internal/store/store.go`、`internal/httpapi/handlers.go`、`handlers_forwards.go`、`server.go`、`cmd/webui4frpc/main.go`
- 前端:`web/src/components/PortEdge.vue`、`views/CanvasView.vue`、`views/ClusterView.vue`、`views/StatusView.vue`、`api.ts`、`types.ts`