feat: cluster reliability (leader failover, crash rejoin, key exchange) + auth/users + canvas/forwards enhancements + comprehensive README + API docs

- Cluster: forwardToNext offline detection (leader+non-leader), WatchLeader 1s heartbeat fallback, 409 for standalone nodes, Node.NodeKey key exchange via token ring, ClusterPeers persistence + auto-rejoin, Forward delegates to forwardToNext (bugfix)
- Auth: Basic Auth (flag-creds fast path) + bcrypt users (admin/viewer) + Bearer API keys (read/write/admin scope)
- Frontend: UsersView (accounts+API keys), ClusterView (ring/nodeKey/tasks/topology/log), StatusView (group management, per-proxy status), CanvasView (edge toggle/group), PortEdge (disabled/group labels)
- API: handlers split (canvas/forwards/users/logs), canvas export/import, forwards group start/stop/assign/delete, cluster endpoints
- Docs: comprehensive README rewrite (all flags/APIs/auth/cluster), docs/cluster-api.md (cluster management API reference)
- Deploy: run-cluster.sh now 4-node ring + 1 isolated standalone, test-forward.sh updated for 4 nodes
- Removed plan.md (design notes consolidated into README + API docs)
This commit is contained in:
2026-08-19 21:09:24 +08:00
parent b518a13446
commit eda9bb9597
55 changed files with 5462 additions and 793 deletions

View File

@ -1,88 +1,172 @@
# Phase C — ModelRouter 风格重设计 + 模拟 frps 转发下发测试
# 集群修复 + 加入密钥 + 画布/状态页增强 + Seq 删除
## 决策(用户未选择,依最佳判断
- **视觉方向 = 忠实采用 sakura×frost 玻璃拟态**ModelRouter 主色 #FF7FAC + 动态光斑背景 + KPI 卡 + cardIn 入场 + 渐变按钮)。在线/成功状态仍用绿色 #17a964(与 ModelRouter 一致,不破坏 webui4frpc "绿=在线" 语义)。不加强调色切换器/深色模式(控范围,可后续加)。
- **模拟 frps = 加 2 个真实 frps 容器**frps2/frps3+ 一个死地址 remote`test-forward.sh` 验证 下发→claim→起 worker→连通 全链路。
## 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 丢失。
## Part 1 — 主题令牌theme.css + variables.scss换成 sakura/frost
**修复**:在 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 恢复正常。
**web/src/styles/theme.css**(重写 `:root` + 增全局组件类):
- 调色:`--w4f-primary:#FF7FAC; -h:#F33B7C; -50:#FFF0F5; -100:#FFE4E9; -200:#FFCDD9; -300:#FF9EB5``--w4f-secondary:#88C0D0; -h:#4C8DAE; -50:#F0F9FC``--w4f-danger:#DB3694; -50:#FEEAF6``--w4f-ok:#17a964; --w4f-warn:#b7791f; --w4f-info:#3f6ef5`
- 表面/文字:`--w4f-bg-1:#eef2ff; -2:#fff; -3:#ffe9f0``--w4f-card:rgba(255,255,255,.60); -2:rgba(255,255,255,.42)``--w4f-line:rgba(255,127,172,.18); -strong:rgba(120,90,150,.22)``--w4f-fg:#3b3350; --w4f-muted:#7c7a95`
- 光斑:`--w4f-blob1/2/3`sakura/frost/pink 透明色)。
- 阴影:`--w4f-sh-sm/md/lg`(紫灰调 `rgba(120,90,160,..)``--w4f-glass:18px; --w4f-ease-spring:cubic-bezier(.34,1.56,.64,1)`
- EP 覆盖:`--el-color-primary:#FF7FAC` + light-3/5/7/8/9 + dark-2`--el-color-success:#17a964; danger:#DB3694;` 圆角/字体。
- body14px/1.55 `Quicksand,Nunito,Inter,...` 字号栈(不联网加载 web 字体用系统栈接近letter-spacing .02em。
- **动态光斑背景**`#bgfx` fixed + 3 `.blob` `filter:blur(64px)` + `@keyframes drift1/2/3``prefers-reduced-motion` 关动画。
- 全局类:`.w4f-card`(玻璃 + `cardIn` 入场动画)、`.w4f-kpi`KPI 卡基座 k-lab+blink dot/k-val/k-sub/k-chart 位)、`.w4f-bar`5px 渐变条)、`.w4f-tag` 变体、`.w4f-btn`(渐变主按钮 + ghost/danger/small 变体、sakura 滚动条。
### A2. 退出后残留旧集群信息 — ring_engine.go `Forward` (L439-461) + 新 `detachAsStandalone()`
**根因**`SelfRemove` 只移除自己出 Nodes保留 Topology/Pending/Log/其他成员。退出后不再收 token → `e.state` 冻结 → `Snapshot()` 返回旧画面。非前端 bug。
**web/src/styles/variables.scss**SCSS 变量重映射,保持视图内 `$color-*` 自动跟随)
- `$color-primary:#FF7FAC; -h:#F33B7C; -50:#FFF0F5; -100:#FFE4E9``$color-secondary:#88C0D0; -50:#F0F9FC``$color-success:#17a964`(保绿);`$color-danger:#DB3694; -50:#FEEAF6``$color-info:#3f6ef5`
- 文字色换紫灰调primary #3b3350 / secondary #5a5470 / muted #7c7a95 / light #9c98b0glass/shadow/radius/ease 对齐 theme.css。
**修复**
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()` 返回 standalone1 节点=自己0 转发,空日志)→ 前端顶部数据区显示"待加入"+ 1 成员 + 0 活跃转发 → 不再残留旧拓扑/转发/日志。
## Part 2 — App.vue玻璃侧栏 + 光斑背景层 + 面包屑
### A3. 测试
- 现有 `ring_remove_test.go` 3 个测试全是非 leader 退出wasLeader=false 不指定后继detach 在 Forward 中测试不调)→ 不受影响 ✓
- 新增 `TestLeaderSelfRemoveDesignatesSuccessor`leader 自退出 → 后继被指定为新 leader
- 新增 `TestDetachAfterForward`:自退出 + Forward 后 state 重置为 standalone
- 模板加 `<div class="bgfx" aria-hidden="true"><i class="blob b1"/><i class="blob b2"/><i class="blob b3"/></div>` 作固定背景z-index -10
- 侧栏 246px 玻璃(`backdrop-filter:blur(glass*.7) saturate(1.3)`),品牌区渐变 logo 方块 "W"(渐变 primary→secondary + 阴影nav item active = ModelRouter 渐变瓦片(`linear-gradient(120deg,primary-50,secondary-50)` + primary-h 文字 + sh-sm而非当前整块填色footer 活跃点 + "token-ring · M6" 药丸。
- 内容区顶部加 `.breadcrumb` 显示当前视图名(状态/连接配置/设置/集群),对齐 ModelRouter 布局。
- `.content` 滚动区,各视图自带内边距。
## P1State.Seq 删除 — ring.go + ring_engine.go
plan.md 从未要求 SeqNextTaskID 注释 L213-221 已自述回归 bug
- ring.go L90State 结构体删 `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 L635CreateCluster 删 `Seq: e.state.Seq`
- LogEntry.Seqring_log.go保留日志水印plan §增量日志同步)
## Part 3 — StatusView.vue 重设计
## P1集群加入密钥per-node 准入密钥)
- 顶栏玻璃卡:面包屑标题 + "每 5 秒自动刷新" + 刷新(ghost) + 添加远程节点(渐变 primary)。
- **KPI 网格**auto-fit minmax 168px远程节点数sub: 在线 X、在线 worker 数、本地服务数、活跃转发数。每卡 k-lab+blink dot、k-val 27px tabular-nums、k-sub。
- 远程节点卡:玻璃 + cardIn左边框色按健康绿/红),名称 + 连接药丸ok 绿/pending 青/err 红点),操作按钮(启=渐变 / 停=ghost / 编辑=ghost / 删=danger smallmeta(pid/err/vhost 药丸)forwards 药丸 chipsper-proxy 行带 5px bar 指示。
- 本地服务卡玻璃proto 药丸targets 带 → + 状态药丸。
- el-dialog 沿用EP 覆盖自动 sakura 化)。
### 设计
- 每个节点有 `nodeKey`(随机 16 字节 hex首次启动生成、持久化到 DB、重启后读回稳定
- 画布 webui 显示本节点 nodeKey可复制加入集群对话框需填对端地址 + 对端 nodeKey
- 对端 `handleClusterJoin` 验证 `ji.JoinKey == e.nodeKey`,不匹配 403
- `CreateCluster`/`AdoptState` 不重新生成init 时已存在)
## Part 4 — ClusterView.vue 重设计
### 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` structring_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"`
- Hero 玻璃卡:渐变标题 + sub状态药丸运行中=绿点 blink / 待加入=青 / 已脱离=红)+ 成员数 + 周期 + leader ♔。
- **KPI 行**:成员数、活跃转发、待办命令、周期。
- 操作工具条:创建集群(渐变 primary) / 加入…(ghost) / 退出(danger) / 刷新(ghost small),保留既 `!isMember`/`isMember` 安全门。
- 环拓扑横向玻璃节点卡链leader=青调 + 边框、self=sakura 高亮发光、dead=红暗;负载 baraddr mono移除按钮(small danger);→ 箭头。
- 待办命令/活跃拓扑/集群日志:玻璃卡 + sticky 表头 + 药丸标签(新增 sakura / 撤销·移除 danger / owner self=sakura+ 彩色 kind 徽章 + self 高亮,全部 cardIn 入场。
- 保留加入 el-dialog。
### B2. 初始化 + 持久化
- `NewEngine`ring_engine.go L84-105签名加 `nodeKey string` 参数
- main.go 引擎初始化L121`settings.NodeKey`;若空 → `crypto/rand` 生成 16 字节 hex → `store.SetNodeKey` 持久化 → 传给 NewEngine
## Part 5 — 模拟 frps 节点 + 转发下发测试
### B3. 加入验证
- `handleClusterJoin`handlers.go L530-556decode JoinInfo 后加 `if ji.JoinKey != h.Ring.NodeKey() { 403 }`
- `JoinRingAddr`token_join.go L54-57签名加 `joinKey string`,设 `ji.JoinKey = joinKey`
- `JoinRing`token_join.go L16-47JoinInfo 已含 JoinKey随 POST body 发送
**新增 frps 配置 + 容器**
- `deploy/frps2.toml`bindPort 7001, vhostHTTPPort 7081, auth.token=demo-token。
- `deploy/frps3.toml`bindPort 7002, vhostHTTPPort 7082, auth.token=demo-token。
- `deploy/run-cluster.sh` 增启动 `w4f-frps2`(--network-alias frps2, -p 7001:7001 -p 7081:7081) 与 `w4f-frps3`(alias frps3, -p 7002:7002 -p 7082:7082)。
- `deploy/compose.yaml` 增 frps2/frps3 服务compose 用户同步)。
- `deploy/start-mock-frps.sh`:仅启 frps2/frps3`docker rm -f` 这两个再 run供集群已起时补启。
### 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)`
**`deploy/test-forward.sh`**核心验证脚本Basic Auth admin:admin123
1. 确认 ring`GET node-a:7501 /cluster/ring` 断言 3 节点。
2. 快照原 canvas`GET node-a /canvas` 存原始 `{locals,remotes,links}`
3. 创建 4 条转发(`PUT node-a /canvas`localOnly=falselocal.ip=`backend` docker DNSport 8080
- `fwd-frps1` → remote frps:7000, remotePort 18091
- `fwd-frps2` → remote frps2:7001, remotePort 18092
- `fwd-frps3` → remote frps3:7002, remotePort 18093
- `fwd-dead` → remote 192.0.2.1:7000, remotePort 18094死地址验下发+起 workerconn 失败)
4. 轮询 `GET /cluster/ring`1s×~25断言 4 条 pending→0 且 topology 出现 4 条带 ownerId打印每条由谁 claim最低负载
5. 轮询各 owner `GET /status`:断言对应 remote process.state=running。
6. 轮询 owner `GET /profiles/{remote}/logs`:真实 frps 断言含 `login to server success`dead 断言 worker running 但 connState=failed。
7. 撤销测试:`PUT /canvas` 还原为步骤2快照移除我们的 locals→ 轮询 topology 4 条消失 + owner /status worker 已停。
8. 打印结果表task / target / claimed-by / worker / conn / 结论)。
9. 清理canvas 已在步骤7还原不删容器。
### 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` reactiveL193`key: ''`
**验证连通可选项**:真实 frps 的 vhost/remotePort 不映射到宿主dispatch 验证不需打隧道);如需端到端打隧道验证,可 `docker exec` 在 net 内 curl `frps2:18092` 命中 backend——纳入脚本 step 6.5。
## P2画布边启用/禁用开关 + 分组标签 — PortEdge.vue + CanvasView.vue
## Part 6 — 构建 + 验证
### PortEdge.vue
- computed `isDisabled`(读 `props.data?.disabled`)、`groupName`(读 `props.data?.group`
- emits 加 `toggle-disabled``edit-group`payload `{edgeId}`
- 模板 `.port-label` 内端口号旁:`<span class="port-toggle" :class="{off:isDisabled}" @pointerdown.stop @click.stop="emit('toggle-disabled',{edgeId:props.id})">{{ isDisabled?'禁':'通' }}</span>``<span v-if="groupName" class="port-group" @pointerdown.stop @click.stop="emit('edit-group',{edgeId:props.id})">{{ groupName }}</span>`
- `@pointerdown.stop` 阻止拖拽label 的 onPointerDown 不触发 → dragging 保持 false → onPointerUp `!wasDrag` 直接 return不误开端口编辑器
- strokeColordisabled 时灰色(`var(--el-color-info-light-5)`
1. `cd web && npm run build`
2. 同步前端:`rm -rf internal/httpapi/dist && cp -r web/dist internal/httpapi/dist`
3. `go build -o deploy/artifacts/webui4frpc ./cmd/webui4frpc`
4. 起/刷集群:`bash deploy/run-cluster.sh`(或既有集群:`bash deploy/start-mock-frps.sh && docker restart w4f-node-a w4f-node-b w4f-node-c`
5. `bash deploy/test-forward.sh` —— 验证转发下发链路
6. **视觉验证**(直击"太丑"反馈):用浏览器打开 `http://localhost:7501`admin/admin123截图 状态页 + 集群页,对照 ModelRouter 确认 光斑背景/玻璃 KPI 卡/渐变按钮/入场动画 到位。若不符则迭代。
### 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 已序列化 disabledL637 已序列化 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 后:`<span class="w4f-tag w4f-tag-info group-chip" @click="editGroup(f)">{{ f.group || '未分组' }}</span>`
- 分组头 L45-48 `.grp-actions``<button v-if="g.key" class="w4f-btn ghost small danger" @click="deleteGroup(g.key)">删除分组</button>`
- `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 成为新 leadercycle 继续推进)→ ring 正常
5. **退出残留**a 退出后 a 的集群页显示 standalone1 节点、0 转发、空日志),不残留旧拓扑
6. **加入密钥**:创建集群 → 显示 nodeKey → b 加入需填 a 的 nodeKey → 错误密钥 403 → 正确密钥加入成功
7. **画布边开关**:点"禁"→边变灰→保存→状态页 stopped点"通"→恢复
8. **画布边分组**:点分组标签→输入名→保存→状态页显示分组
9. **状态页分组管理**:卡片分组标签→改组→即时生效;分组头删除分组→全移至未分组
10. `bash deploy/test-forward.sh` 通过
## 影响文件
- 主题`web/src/styles/theme.css`(重写)、`web/src/styles/variables.scss`(重映射)
-`web/src/App.vue`(侧栏+光斑+面包屑)
- 视图:`web/src/views/StatusView.vue``web/src/views/ClusterView.vue`(重设计)
- 部署:`deploy/frps2.toml``deploy/frps3.toml`(新)、`deploy/run-cluster.sh``deploy/compose.yaml``deploy/start-mock-frps.sh`(新)、`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`