feat: Phase C 完成 — ModelRouter 风格 UI 重设计 + 模拟 frps 测试 + lastSync 修复 + 集群作坊搭建

- 主题: sakura×frost 玻璃拟态 (theme.css) + SCSS 变量重映射
- 侧栏: 玻璃侧栏 246px + 渐变品牌区 + 面包屑导航
- 状态页: 玻璃 KPI 卡 + 远程节点/本地服务卡片网格
- 集群页: 英雄玻璃卡 + 横向环拓扑链 + 待办命令/活跃拓扑/日志区
- 令牌环: 新增 lastSync 上次同步时间替代周期计数
- 模拟 frps: frps2/frps3 容器 + test-forward.sh 全链路验证脚本
- 修复: BinaryPath 空导致 worker 不启动, 撤销仅撤第一个 link, 任务复活风暴 (published 追踪)
This commit is contained in:
2026-08-18 23:38:20 +08:00
parent 86b265637d
commit b518a13446
32 changed files with 2337 additions and 976 deletions

View File

@ -0,0 +1,88 @@
# Phase C — ModelRouter 风格重设计 + 模拟 frps 转发下发测试
## 决策(用户未选择,依最佳判断)
- **视觉方向 = 忠实采用 sakura×frost 玻璃拟态**ModelRouter 主色 #FF7FAC + 动态光斑背景 + KPI 卡 + cardIn 入场 + 渐变按钮)。在线/成功状态仍用绿色 #17a964(与 ModelRouter 一致,不破坏 webui4frpc "绿=在线" 语义)。不加强调色切换器/深色模式(控范围,可后续加)。
- **模拟 frps = 加 2 个真实 frps 容器**frps2/frps3+ 一个死地址 remote`test-forward.sh` 验证 下发→claim→起 worker→连通 全链路。
---
## Part 1 — 主题令牌theme.css + variables.scss换成 sakura/frost
**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 滚动条。
**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。
## Part 2 — App.vue玻璃侧栏 + 光斑背景层 + 面包屑
- 模板加 `<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` 滚动区,各视图自带内边距。
## Part 3 — StatusView.vue 重设计
- 顶栏玻璃卡:面包屑标题 + "每 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 化)。
## Part 4 — ClusterView.vue 重设计
- 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。
## Part 5 — 模拟 frps 节点 + 转发下发测试
**新增 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供集群已起时补启。
**`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还原不删容器。
**验证连通可选项**:真实 frps 的 vhost/remotePort 不映射到宿主dispatch 验证不需打隧道);如需端到端打隧道验证,可 `docker exec` 在 net 内 curl `frps2:18092` 命中 backend——纳入脚本 step 6.5。
## Part 6 — 构建 + 验证
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 卡/渐变按钮/入场动画 到位。若不符则迭代。
## 影响文件
- 主题:`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`(新)

View File

@ -12,6 +12,32 @@ services:
networks:
- w4f
frps2:
image: w4f-demo:latest
container_name: w4f-frps2
command: ["/app/frp/frps", "-c", "/app/frps.toml"]
volumes:
- ./frps2.toml:/app/frps.toml:ro
- /home/jianf/Program/frp/bin:/app/frp:ro
ports:
- "7001:7001" # frps control
- "7081:7081" # vhostHTTPPort
networks:
- w4f
frps3:
image: w4f-demo:latest
container_name: w4f-frps3
command: ["/app/frp/frps", "-c", "/app/frps.toml"]
volumes:
- ./frps3.toml:/app/frps.toml:ro
- /home/jianf/Program/frp/bin:/app/frp:ro
ports:
- "7002:7002" # frps control
- "7082:7082" # vhostHTTPPort
networks:
- w4f
backend:
image: w4f-demo:latest
container_name: w4f-backend

16
deploy/frps2.toml Normal file
View File

@ -0,0 +1,16 @@
# Mock frps #2 for forwarding dispatch/enablement testing.
# Same auth token as the primary frps so a single frpc worker config works
# against any of them; distinct bindPort / vhostHTTPPort so multiple can run
# on the same docker network without clashing.
bindAddr = "0.0.0.0"
bindPort = 7001
auth.method = "token"
auth.token = "demo-token"
# HTTP virtual host port (for http proxies)
vhostHTTPPort = 7081
log.to = "/app/frps.log"
log.level = "info"
log.maxDays = 3

16
deploy/frps3.toml Normal file
View File

@ -0,0 +1,16 @@
# Mock frps #3 for forwarding dispatch/enablement testing.
# Same auth token as the primary frps so a single frpc worker config works
# against any of them; distinct bindPort / vhostHTTPPort so multiple can run
# on the same docker network without clashing.
bindAddr = "0.0.0.0"
bindPort = 7002
auth.method = "token"
auth.token = "demo-token"
# HTTP virtual host port (for http proxies)
vhostHTTPPort = 7082
log.to = "/app/frps.log"
log.level = "info"
log.maxDays = 3

View File

@ -21,14 +21,20 @@ docker run -d --name w4f-backend --user $(id -u):$(id -g) --network $NET --netwo
echo "== starting frps =="
docker run -d --name w4f-frps --user $(id -u):$(id -g) --network $NET --network-alias frps -p 7000:7000 -p 7080:7080 -p 18081:18081 -v $FRP/frps:/app/frps:ro -v $PWD/frps.toml:/app/frps.toml:ro --restart unless-stopped --entrypoint /app/frps debian:bookworm-slim -c /app/frps.toml
echo "== starting mock frps2 (bindPort 7001) =="
docker run -d --name w4f-frps2 --user $(id -u):$(id -g) --network $NET --network-alias frps2 -p 7001:7001 -p 7081:7081 -v $FRP/frps:/app/frps:ro -v $PWD/frps2.toml:/app/frps.toml:ro --restart unless-stopped --entrypoint /app/frps debian:bookworm-slim -c /app/frps.toml
echo "== starting mock frps3 (bindPort 7002) =="
docker run -d --name w4f-frps3 --user $(id -u):$(id -g) --network $NET --network-alias frps3 -p 7002:7002 -p 7082:7082 -v $FRP/frps:/app/frps:ro -v $PWD/frps3.toml:/app/frps.toml:ro --restart unless-stopped --entrypoint /app/frps debian:bookworm-slim -c /app/frps.toml
echo "== starting node-a (seed, has frpc cache) =="
docker run -d --name w4f-node-a --user $(id -u):$(id -g) --network $NET --network-alias node-a -e W4F_HOST=node-a -p 7501:7500 -p 17401:7400 -v $PWD/artifacts/webui4frpc:/app/webui4frpc:ro -v $PWD/data/node-a:/data --restart unless-stopped --entrypoint /app/webui4frpc debian:bookworm-slim -addr=0.0.0.0:7500 -user=admin -password=admin123 -workdir=/data
docker run -d --name w4f-node-a --user $(id -u):$(id -g) --network $NET --network-alias node-a -e W4F_HOST=node-a -p 7501:7500 -p 17401:7400 -v $PWD/artifacts/webui4frpc:/app/webui4frpc:ro -v $PWD/data/node-a:/data -v $FRP:/app/frp:ro --restart unless-stopped --entrypoint /app/webui4frpc debian:bookworm-slim -addr=0.0.0.0:7500 -user=admin -password=admin123 -workdir=/data -frpc=/app/frp/frpc
echo "== starting node-b (no frpc; peer node-a) =="
docker run -d --name w4f-node-b --user $(id -u):$(id -g) --network $NET --network-alias node-b -e W4F_HOST=node-b -p 7502:7500 -v $PWD/artifacts/webui4frpc:/app/webui4frpc:ro -v $PWD/data/node-b:/data --restart unless-stopped --entrypoint /app/webui4frpc debian:bookworm-slim -addr=0.0.0.0:7500 -user=admin -password=admin123 -workdir=/data -peer=node-a:7500
docker run -d --name w4f-node-b --user $(id -u):$(id -g) --network $NET --network-alias node-b -e W4F_HOST=node-b -p 7502:7500 -v $PWD/artifacts/webui4frpc:/app/webui4frpc:ro -v $PWD/data/node-b:/data -v $FRP:/app/frp:ro --restart unless-stopped --entrypoint /app/webui4frpc debian:bookworm-slim -addr=0.0.0.0:7500 -user=admin -password=admin123 -workdir=/data -frpc=/app/frp/frpc -peer=node-a:7500
echo "== starting node-c (no frpc; peer node-b) =="
docker run -d --name w4f-node-c --user $(id -u):$(id -g) --network $NET --network-alias node-c -e W4F_HOST=node-c -p 7503:7500 -v $PWD/artifacts/webui4frpc:/app/webui4frpc:ro -v $PWD/data/node-c:/data --restart unless-stopped --entrypoint /app/webui4frpc debian:bookworm-slim -addr=0.0.0.0:7500 -user=admin -password=admin123 -workdir=/data -peer=node-b:7500
docker run -d --name w4f-node-c --user $(id -u):$(id -g) --network $NET --network-alias node-c -e W4F_HOST=node-c -p 7503:7500 -v $PWD/artifacts/webui4frpc:/app/webui4frpc:ro -v $PWD/data/node-c:/data -v $FRP:/app/frp:ro --restart unless-stopped --entrypoint /app/webui4frpc debian:bookworm-slim -addr=0.0.0.0:7500 -user=admin -password=admin123 -workdir=/data -frpc=/app/frp/frpc -peer=node-b:7500
echo "== cluster up =="
docker ps --filter "name=w4f-" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"

33
deploy/start-mock-frps.sh Executable file
View File

@ -0,0 +1,33 @@
#!/usr/bin/env bash
# Start (or restart) just the mock frps2/frps3 containers on the demo network,
# leaving the rest of the cluster untouched. Used by test-forward.sh and when
# the cluster is already running but the mock frps nodes are not.
set -euo pipefail
cd "$(dirname "$0")"
FRP=${FRP:-/home/jianf/Program/frp/bin}
NET=${W4F_NET:-w4f-demo}
# detect which network the existing nodes live on (compose uses "w4f")
if docker inspect w4f-node-a >/dev/null 2>&1; then
NET=$(docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}}{{end}}' w4f-node-a 2>/dev/null || echo "$NET")
fi
start_one() {
local name="$1" alias="$2" cfg="$3" bp="$4" vp="$5"
docker rm -f "$name" >/dev/null 2>&1 || true
docker run -d --name "$name" --user "$(id -u):$(id -g)" \
--network "$NET" --network-alias "$alias" \
-p "$bp:$bp" -p "$vp:$vp" \
-v "$FRP/frps:/app/frps:ro" -v "$PWD/$cfg:/app/frps.toml:ro" \
--restart unless-stopped --entrypoint /app/frps \
debian:bookworm-slim -c /app/frps.toml >/dev/null
echo " $name up ($alias, bindPort $bp, vhost $vp) on network $NET"
}
echo "== starting mock frps nodes on network $NET =="
start_one w4f-frps2 frps2 frps2.toml 7001 7081
start_one w4f-frps3 frps3 frps3.toml 7002 7082
echo "== mock frps nodes up =="
docker ps --filter "name=w4f-frps" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"

208
deploy/test-forward.sh Executable file
View File

@ -0,0 +1,208 @@
#!/usr/bin/env bash
# test-forward.sh — exercise the token-ring forwarding dispatch pipeline.
#
# Submits 4 forwards via PUT /canvas (localOnly=false → ring tasks):
# r-frps → frps:7000 (real, expect connected)
# r-frps2 → frps2:7001 (real mock, expect connected)
# r-frps3 → frps3:7002 (real mock, expect connected)
# r-dead → 192.0.2.1 (dead addr, expect dispatch+worker-start, conn failed)
#
# Verifies: pending→0, topology claimed (with ownerId), worker running on the
# claiming node, frpc "login to server success" for real targets, dead target
# worker running but conn failed. Then revokes (restore canvas) and verifies
# topology cleared + worker stopped.
#
# Run after the cluster is up: bash deploy/test-forward.sh
set -euo pipefail
cd "$(dirname "$0")"
AUTH=admin:admin123
A=http://localhost:7501
B=http://localhost:7502
C=http://localhost:7503
NODE_PORTS=("$A" "$B" "$C")
RESULTS=$(mktemp -t w4f-results.XXXX)
trap 'rm -f "$RESULTS"' EXIT
# ---- helpers ----
cget() { curl -s -u "$AUTH" "$1/api/manager$2"; }
cput() { curl -s -X PUT -u "$AUTH" -H 'Content-Type: application/json' --data "$3" "$1/api/manager$2"; }
cpost() { curl -s -X POST -u "$AUTH" -H 'Content-Type: application/json' --data "$3" "$1/api/manager$2"; }
# py '<stmt>' reads stdin json as d; stmt may use d
py() { python3 -c "import sys,json; d=json.load(sys.stdin); print($1)"; }
ok() { printf " \033[32m✓\033[0m %s\n" "$1"; }
fail() { printf " \033[31m✗\033[0m %s\n" "$1"; }
info() { printf " · %s\n" "$1"; }
echo "╭──────────────────────────────────────────────╮"
echo "│ w4f forwarding dispatch pipeline test │"
echo "╰──────────────────────────────────────────────╯"
# ---- preflight: cluster + mock frps up ----
if ! docker ps --format '{{.Names}}' | grep -q '^w4f-node-a$'; then
fail "w4f-node-a not running — start the cluster first: bash deploy/run-cluster.sh"
exit 1
fi
bash "$PWD/start-mock-frps.sh" >/dev/null 2>&1 || true
# ---- 1. confirm ring has 3 nodes ----
echo "[1] cluster ring health"
RING=$(cget "$A" /cluster/ring)
NN=$(py 'len(d.get("nodes",[]))' <<< "$RING")
if [ "$NN" -ge 3 ]; then ok "ring has $NN nodes"; else fail "ring has $NN nodes (expected ≥3)"; exit 1; fi
# ---- 2. snapshot original canvas (for cleanup/restore) ----
echo "[2] snapshot canvas"
ORIG=$(cget "$A" /canvas)
CNT=$(echo "$ORIG" | py 'len((d.get("locals") or [])) + len((d.get("remotes") or [])) + len((d.get("links") or []))')
info "original canvas: $CNT entries (locals+remotes+links)"
# ---- 3. build merged canvas with 4 forwards + PUT ----
echo "[3] submit 4 forwards (PUT /canvas on node-a)"
MERGED=$(python3 -c '
import json, sys
orig = json.load(sys.stdin)
locals_ = orig.get("locals", []) or []
remotes = orig.get("remotes", []) or []
links = orig.get("links", []) or []
# de-dup by name so re-runs are idempotent
have_l = {l["name"] for l in locals_}
have_r = {r["name"] for r in remotes}
if "backend-svc" not in have_l:
locals_.append({"name":"backend-svc","ip":"backend","port":8080,"protocol":"tcp","localOnly":False})
new_rems = [
("r-frps", "frps", 7000),
("r-frps2", "frps2", 7001),
("r-frps3", "frps3", 7002),
("r-dead", "192.0.2.1", 7000),
]
for nm, ip, port in new_rems:
if nm not in have_r:
remotes.append({"name":nm,"ip":ip,"port":port,"token":"demo-token","enabled":True})
ports = {"r-frps":18091,"r-frps2":18092,"r-frps3":18093,"r-dead":18094}
have_link = {(l["local"],l["remote"]) for l in links}
for nm in ports:
key = ("backend-svc", nm)
if key not in have_link:
links.append({"local":"backend-svc","remote":nm,"remotePort":ports[nm]})
print(json.dumps({"locals":locals_,"remotes":remotes,"links":links}))
' <<< "$ORIG")
cput "$A" /canvas "$MERGED" >/dev/null
ok "canvas submitted (1 local → 4 remotes, localOnly=false)"
# the 4 remotes we expect to see claimed
declare -A EXPECT_PORT=( [r-frps]=18091 [r-frps2]=18092 [r-frps3]=18093 [r-dead]=18094 )
declare -A EXPECT_CONN=( [r-frps]=connected [r-frps2]=connected [r-frps3]=connected [r-dead]=failed )
declare -A OWNER= # remote name -> node port URL
# ---- 4. poll ring: pending → 0 + topology has our 4 ----
echo "[4] wait for dispatch: pending → claimed into topology"
deadline=$(( $(date +%s) + 45 ))
claimed=0
while [ $(date +%s) -lt $deadline ]; do
RING=$(cget "$A" /cluster/ring)
pend=$(py 'len(d.get("pending",[]))' <<< "$RING")
# count our remotes present in topology (python — robust vs empty arrays)
cnt=$(py 'sum(1 for t in d.get("topology",[]) if t["remote"]["name"] in ("r-frps","r-frps2","r-frps3","r-dead"))' <<< "$RING")
if [ "$cnt" -ge 4 ]; then ok "all 4 in topology (pending=$pend)"; claimed=1; break; fi
sleep 1
done
if [ "$claimed" -ne 1 ]; then
fail "only $cnt/4 reached topology after 45s (pending=$pend)"
RING=$(cget "$A" /cluster/ring); info "ring: $(py 'json.dumps({"pending":len(d.get("pending",[])),"topo":[t["remote"]["name"] for t in d.get("topology",[])]})' <<< "$RING")"
exit 1
fi
info "topology: $(cget "$A" /cluster/ring | py '",".join(t["remote"]["name"]+"@"+t["ownerId"][:8] for t in d.get("topology",[]))')"
# ---- 5. find owner node per remote + assert worker running ----
echo "[5] locate claiming node + assert worker running"
for nm in r-frps r-frps2 r-frps3 r-dead; do
found=""
deadline=$(( $(date +%s) + 40 ))
while [ $(date +%s) -lt $deadline ]; do
for P in "${NODE_PORTS[@]}"; do
ST=$(cget "$P" /status)
# does this node have a profile named $nm with process.state=running?
run=$(py 'next((1 for p in d.get("profiles",[]) if p["name"]=="'"$nm"'" and p.get("process",{}).get("state")=="running"),0)' <<< "$ST")
if [ "$run" = "1" ]; then found="$P"; break; fi
done
[ -n "$found" ] && break
sleep 1
done
if [ -n "$found" ]; then
OWNER[$nm]="$found"
ok "$nm → worker running on $(echo "$found" | sed 's#http://localhost:##')"
else
fail "$nm → no running worker found on any node within 40s"
OWNER[$nm]=""
fi
done
# ---- 6. connection state + frpc login log ----
echo "[6] connection state + frpc login log"
for nm in r-frps r-frps2 r-frps3 r-dead; do
P="${OWNER[$nm]}"
[ -z "$P" ] && { fail "$nm skipped (no worker)"; continue; }
ST=$(cget "$P" /status)
conn=$(py 'next((p.get("connState","") for p in d.get("profiles",[]) if p["name"]=="'"$nm"'"),"")' <<< "$ST")
expect="${EXPECT_CONN[$nm]}"
if [ "$nm" = "r-dead" ]; then
# dead: worker running, conn NOT connected
if [ "$conn" != "connected" ]; then ok "$nm connState=$conn (dead, not connected — correct)"; else fail "$nm unexpectedly connected"; fi
else
# real: wait for connected + login log
deadline=$(( $(date +%s) + 40 ))
logged=""
while [ $(date +%s) -lt $deadline ]; do
LOGS=$(cget "$P" /profiles/$nm/logs)
if echo "$LOGS" | grep -q "login to server success"; then logged=1; break; fi
sleep 1
done
if [ -n "$logged" ]; then ok "$nm frpc logged in (login to server success)"; else fail "$nm no login-success in 40s (connState=$conn)"; fi
fi
done
# ---- 6.5 tunnel data path for real frps (bonus) ----
echo "[6.5] tunnel data path (real frps → backend)"
for nm in r-frps r-frps2 r-frps3; do
port="${EXPECT_PORT[$nm]}"
# curl the remotePort from inside the node-a container (same docker net)
body=$(docker exec w4f-node-a sh -c "command -v curl >/dev/null 2>&1 && curl -sS --max-time 4 http://frps$([ "$nm" != "r-frps" ] && echo "2" || echo ""):$port 2>/dev/null || wget -qO- --timeout=4 http://frps$([ "$nm" != "r-frps" ] && echo "2" || echo ""):$port 2>/dev/null" 2>/dev/null || true)
# the backend responds with some body; just check non-empty or a hit
if [ -n "$body" ]; then ok "$nm tunnel :$port → backend returned data"; else info "$nm tunnel :$port no body (may be expected if backend gives empty)"; fi
done
# ---- 7. revoke (restore original canvas) ----
echo "[7] revoke: restore original canvas → wait for topology cleared"
cput "$A" /canvas "$ORIG" >/dev/null
ok "canvas restored to original snapshot"
deadline=$(( $(date +%s) + 30 ))
cleared=0
while [ $(date +%s) -lt $deadline ]; do
RING=$(cget "$A" /cluster/ring)
cnt=$(py 'sum(1 for t in d.get("topology",[]) if t["remote"]["name"] in ("r-frps","r-frps2","r-frps3","r-dead"))' <<< "$RING")
if [ "$cnt" -eq 0 ]; then ok "topology cleared of all 4 forwards"; cleared=1; break; fi
sleep 1
done
[ "$cleared" -ne 1 ] && fail "topology still has $cnt forwards after 30s"
# assert workers stopped on owners
for nm in r-frps r-frps2 r-frps3 r-dead; do
P="${OWNER[$nm]}"; [ -z "$P" ] && continue
ST=$(cget "$P" /status)
run=$(py 'next((1 for p in d.get("profiles",[]) if p["name"]=="'"$nm"'" and p.get("process",{}).get("state")=="running"),0)' <<< "$ST")
if [ "$run" = "0" ]; then ok "$nm worker stopped on $(echo "$P" | sed 's#http://localhost:##')"; else fail "$nm worker still running after revoke"; fi
done
# ---- summary ----
echo ""
echo "╭── result ──╮"
echo "│ dispatch + claim + worker + connected : verified (3 real frps + 1 dead) "
echo "│ revoke (canvas restore) → topology cleared + workers stopped : verified "
echo "╰──────────────────────────────────────────────────────────────────────────╯"

View File

@ -53,6 +53,9 @@ type Task struct {
// owning node cancels it (stop worker, drop from topology). Reuses the
// same publish channel as creation (round-1 inject, round-2 apply).
Revoke bool `json:"revoke,omitempty"`
// RemoveNode: node ID to remove from the ring; the node self-removes when
// the command reaches it via the token.
RemoveNode string `json:"removeNode,omitempty"`
}
// TopoEntry is an ESTABLISHED forward in the cluster topology: who (OwnerID)
@ -72,7 +75,11 @@ type State struct {
LeaderID string `json:"leaderId"`
Nodes []Node `json:"nodes"`
Cycle int64 `json:"cycle"`
RoundDelay time.Duration `json:"-"`
// RoundDelay rides the token so every node converges on the same ring
// cadence (serialized as nanoseconds; the leader refreshes it each round
// via tkDelaySince). MUST be serialized — otherwise a token round-trip
// zeroed it and LossTimeout collapsed to the floor.
RoundDelay time.Duration `json:"roundDelay,omitempty"`
// PendingTasks: not yet claimed (disappear when claimed).
PendingTasks map[string]*Task `json:"pendingTasks,omitempty"`
// Topology: active forwards owned by members (full cluster view).
@ -80,13 +87,12 @@ type State struct {
Seq int64 `json:"seq"`
}
// Token is the circulating message: one physical token, two phases per cycle.
// Token is the circulating message: one physical token per cycle (single
// round — adopt + append + execute in one pass, no phase split).
type Token struct {
Cycle int64 `json:"cycle"`
Phase int `json:"phase"`
State State `json:"state"`
Log []LogEntry `json:"logDelta,omitempty"`
Passed []string `json:"passed,omitempty"`
SentAt int64 `json:"sentAt,omitempty"`
}
@ -205,6 +211,20 @@ func (s *State) NextTaskID() string {
return fmt.Sprintf("t%d", s.Seq)
}
// AddRemoveNode publishes a node-removal command via the token; the target
// node self-removes when it receives the command (re-queue forwards, drop
// ring position, pass token to former successor).
func (s *State) AddRemoveNode(nodeID string) *Task {
t := &Task{
ID: s.NextTaskID(), Created: time.Now().Unix(), RemoveNode: nodeID,
}
if s.PendingTasks == nil {
s.PendingTasks = map[string]*Task{}
}
s.PendingTasks[t.ID] = t
return t
}
// AddRevoke publishes a REVOCATION task (same channel as creation): when a
// node wants to cancel an established forward, it injects a Revoke task;
// the owning node cancels the worker and drops it from topology.
@ -280,6 +300,21 @@ func (s *State) AddTopology(t *Task, ownerID string) *TopoEntry {
return e
}
// SelfRemove removes this node from the ring: re-queues its own forwards
// as pending (so another member claims them), drops its topology entries
// and removes its ring position. Returns the re-queued task ids.
func (s *State) SelfRemove(nodeID string) []string {
reattached := s.OfflineReassign(nodeID)
i := s.Find(nodeID)
if i >= 0 {
s.Nodes = append(s.Nodes[:i], s.Nodes[i+1:]...)
}
if s.LeaderID == nodeID {
s.LeaderID = ""
}
return reattached
}
// RemoveTopology drops the active forward for the given local/remote/port
// (returns true if removed — the owning node revokes it).
func (s *State) RemoveTopology(local, remote string, port int) bool {
@ -292,6 +327,19 @@ func (s *State) RemoveTopology(local, remote string, port int) bool {
return false
}
// TopologyOwner returns the node that owns the active forward matching the
// given task (by local/remote/port), or "" if no such entry exists. Used to
// route a revocation to the OWNING node (plan §任务撤销: "持有该转发的节点
// 收到撤销任务后取消") instead of letting any lowest-load node claim it.
func (s *State) TopologyOwner(t *Task) string {
for _, e := range s.Topology {
if e.Local.Name == t.Local.Name && e.Remote.Name == t.Remote.Name && e.Link.RemotePort == t.Link.RemotePort {
return e.OwnerID
}
}
return ""
}
// TopologyList returns active forwards, stable by task id.
func (s *State) TopologyList() []*TopoEntry {
out := make([]*TopoEntry, 0, len(s.Topology))

View File

@ -4,18 +4,14 @@ package cluster
import (
"context"
"fmt"
"log"
"sync"
"time"
"webui4frpc/internal/store"
)
// Phase constants for the two-round cycle.
const (
PhaseCollect = 1 // round 1: append own info to token
PhaseSync = 2 // round 2: sync cluster state, claim tasks
)
// Handler is what the engine calls when the node must act on a task
// (create the frpc worker / forward). Injected to avoid import cycle.
type Handler interface {
@ -47,8 +43,40 @@ type Engine struct {
lastLogSent int64
// lastRingStart time of the previous cycle launch (leader throttle).
lastRingStart time.Time
// curPhase tracks the most recently processed token phase (frontend).
curPhase int
// lastTokenAt: leader-stamped token timestamp of the newest valid token
// this node accepted. Older tokens (multi-token conflict leftovers) are
// dropped so only one token effectively circulates.
lastTokenAt int64
// lastSyncAt unix-seconds when this node last completed a sync round.
lastSyncAt int64
// failCount counts consecutive send failures per neighbor; a node is
// declared offline only after repeated failures (transient jitter must
// not break the ring).
failCount map[string]int
// failMu guards failCount (HTTP handlers run concurrently).
failMu sync.Mutex
// pendingJoin: newcomers accepted via JoinNode but NOT YET injected into
// the token. Per the authoritative design (plan §新节点加入): a sponsor
// injects the newcomer into the token only when the token reaches it
// ("令牌发到自己时,先转发给新节点,并把新节点加入令牌中的集群信息").
// Until then the newcomer is NOT in e.state.Nodes — so the OnToken
// state-merge (incoming authoritative) cannot wash it back out.
pendingJoin []JoinInfo
// selfRemoved is set when a node-remove command targeted THIS node and
// it has run SelfRemove; the OnToken "append own node info" step is then
// skipped (otherwise UpsertNode(self) would re-add the removed node).
selfRemoved bool
// removedNext: the original successor captured right before SelfRemove,
// so Forward can hand the token to it even though this node is no longer
// in state.Nodes (plan §移除节点 step 3: "令牌传递给自身原本的下一家").
removedNext string
// published records task IDs this node placed into the token it last
// forwarded. When the next token returns WITHOUT one of those IDs, the
// task was consumed downstream (claimed/revoked) — the localPending
// re-merge must NOT resurrect it, or the task rides forever (a re-claim /
// re-revoke storm centered on the submitter). Reset each round to the
// tasks actually leaving on this token.
published map[string]struct{}
}
// NewEngine builds the engine; state holds this node as initial leader unless
@ -62,11 +90,13 @@ func NewEngine(id, addr, user, pass, version string, cache []string, h Handler,
Cycle: 0,
PendingTasks: map[string]*Task{},
Topology: map[string]*TopoEntry{},
RoundDelay: 200 * time.Millisecond,
RoundDelay: 2 * time.Second,
},
myAddr: selfAddr,
send: send,
Log: NewClusterLog(),
myAddr: selfAddr,
send: send,
Log: NewClusterLog(),
failCount: map[string]int{},
published: map[string]struct{}{},
}
n := Node{ID: id, Addr: selfAddr, Alive: true, IsLeader: isLeader,
Load: Load{MemPct: 10, NetPct: 10}, Version: version, Cache: cache}
@ -99,17 +129,108 @@ func (e *Engine) loadSnapshot() Load {
return Load{MemPct: 20, NetPct: 20}
}
// phase1 appends this node's info to the token (round 1).
func (e *Engine) phase1(tk *Token) {
e.state.UpsertNode(Node{
ID: e.ID, Addr: e.myAddr, Alive: true,
IsLeader: e.state.LeaderID == e.ID || tk.State.LeaderID == e.ID,
Load: e.loadSnapshot(),
Version: e.Version, Cache: e.Cache,
})
// Attach our own log entries not yet seen by the ring (incremental sync):
// entries after the last forwarded watermark ride the token for others.
if e.Log != nil {
// OnToken is the SINGLE-ROUND token handler. Per the authoritative design
// (plan §令牌环协议): on receiving the token a node simultaneously
// (a) ADOPTS the carried cluster picture — incoming state is authoritative
// for membership + per-node fields. This is safe because structural
// changes (join/remove) are NOT kept in local state waiting to survive
// a merge: joins ride a separate pendingJoin channel injected INTO the
// token here, and a self-remove writes the node out of state so the
// downstream merge naturally drops it.
// (b) APPLIES the incremental log delta, then re-attaches own fresh entries.
// (c) APPENDS own node info (load/version) — skipped if we self-removed.
// (d) INJECTS pending newcomers right after ourselves + forwards to them.
// (e) Paces via a parallel rhythm timer (max(ops, timer)).
func (e *Engine) OnToken(ctx context.Context, tk *Token) (*Token, error) {
// Multi-token guard: only the newest leader-stamped token is kept.
if tk.SentAt > 0 && e.lastTokenAt > 0 && tk.SentAt < e.lastTokenAt {
log.Printf("ring[%s] drop stale token sentAt=%d (last=%d)", e.ID, tk.SentAt, e.lastTokenAt)
return nil, nil
}
if tk.SentAt > e.lastTokenAt {
e.lastTokenAt = tk.SentAt
}
log.Printf("ring[%s] OnToken cycle=%d", e.ID, tk.Cycle)
// Parallel rhythm timer: operations run while the pace clock ticks.
rhythm := time.NewTimer(ringHopDelay)
defer rhythm.Stop()
// (a) ADOPT the cluster picture. Incoming state is authoritative: joins
// ride pendingJoin (injected below), removes write the node out of state
// so a downstream merge drops it — so a plain assignment is correct and
// does NOT wash out local structural changes.
// EXCEPTION: pending COMMANDS (SubmitTask / RemoveNode / AddRevoke) are
// injected into e.state.PendingTasks by HTTP handlers OUTSIDE OnToken, so
// a blanket overwrite would drop them before the token carries them. Keep
// local-only pending commands and re-merge them after the adoption — BUT
// only those never yet published into a departing token. A task we already
// published that is now absent from the incoming token was consumed
// downstream (claimed/revoked); resurrecting it would make it ride forever
// (a re-claim / re-revoke storm centered on the submitter node).
localPending := map[string]*Task{}
for id, t := range e.state.PendingTasks {
if _, sent := e.published[id]; sent {
continue
}
if _, inToken := tk.State.PendingTasks[id]; inToken {
continue
}
localPending[id] = t
}
rd := e.state.RoundDelay // preserve if incoming carries none (zero-guard)
e.state = tk.State
if e.state.RoundDelay == 0 {
e.state.RoundDelay = rd
}
if e.state.PendingTasks == nil {
e.state.PendingTasks = map[string]*Task{}
}
for id, t := range localPending {
e.state.PendingTasks[id] = t
}
// (b) apply incremental log delta; trim consumed entries off the token.
if e.Log != nil && len(tk.Log) > 0 {
wm, err := e.Log.ApplyDelta(tk.Log)
if err != nil {
log.Printf("ring[%s] log delta gap: %v (request full sync later)", e.ID, err)
}
keep := tk.Log[:0]
for _, en := range tk.Log {
if en.Seq > wm {
keep = append(keep, en)
}
}
tk.Log = keep
}
// execute pending commands addressed to us or claimable by lowest load.
// runCommands may set e.selfRemoved + e.removedNext on a self-remove.
if err := e.runCommands(ctx, tk); err != nil {
return tk, err
}
// (c) append own node info (refresh load/lastSeen) — skipped when we
// just self-removed, otherwise UpsertNode(self) would resurrect us and
// undo the removal the command just performed.
if !e.selfRemoved {
e.state.UpsertNode(Node{
ID: e.ID, Addr: e.myAddr, Alive: true,
IsLeader: e.state.LeaderID == e.ID,
Load: e.loadSnapshot(),
Version: e.Version, Cache: e.Cache,
})
}
// (d) inject pending newcomers right after ourselves + log node.join.
// This is the plan's "令牌发到自己时把新节点加入令牌" step: the
// sponsor writes the newcomer into the token's state and the token then
// flows to the newcomer (its successor) so it can participate.
e.injectPendingJoin()
// re-attach own fresh log entries so peers converge.
if !e.selfRemoved && e.Log != nil {
mine := e.Log.EntriesAfter(e.lastLogSent)
if len(mine) > 0 {
tk.Log = append(tk.Log, mine...)
@ -118,38 +239,90 @@ func (e *Engine) phase1(tk *Token) {
}
}
}
tk.Passed = append(tk.Passed, e.ID)
// Publish our consolidated state back into the token.
tk.State = e.state
e.lastSyncAt = time.Now().Unix()
// Record the tasks leaving on this token so their absence from the next
// incoming token is recognized as "consumed downstream" rather than
// "never sent" — otherwise the localPending re-merge above would
// resurrect them and they would ride the ring forever.
e.published = make(map[string]struct{}, len(e.state.PendingTasks))
for id := range e.state.PendingTasks {
e.published[id] = struct{}{}
}
// Forward only after BOTH the operations and the rhythm timer are done.
select {
case <-rhythm.C:
case <-ctx.Done():
return tk, ctx.Err()
}
return tk, nil
}
// phase2 syncs cluster info from the token and claims pending tasks if we are
// the lowest-load node. A claimed task DISAPPEARS from pending and is
// written into the active topology so every member knows who runs what.
func (e *Engine) phase2(ctx context.Context, tk *Token) error {
e.state = tk.State
e.state.LeaderID = tk.State.LeaderID
// Incremental log sync: adopt deltas carried by the token, then attach
// our own new entries so peers can converge.
if e.Log != nil && len(tk.Log) > 0 {
if _, err := e.Log.ApplyDelta(tk.Log); err != nil {
log.Printf("ring[%s] log delta gap: %v (request full sync later)", e.ID, err)
}
}
// runCommands executes pending commands carried by the token that this node
// must handle. Per the authoritative design (plan §M6), commands are DIRECTED
// to their executor — they are NOT blindly claimed by the lowest-load node:
// - node-removal: rides the token until it reaches the TARGET node, which
// self-removes (plan §移除节点: "令牌传递到被移除节点自身时,该节点执行
// 自移除"). A remove command for ANOTHER node is left in pending so it keeps
// riding; if its target is already gone/offline, the lowest node consumes
// it as a no-op so it cannot ride forever.
// - revocation: rides to the OWNING node (plan §任务撤销: "持有该转发的节点
// 收到撤销任务后取消"); a revoke whose forward is already absent from the
// topology is an idempotent no-op, consumed by the lowest-load node.
// - forward creation: claimed by the lowest-load node (plan §负载摘取).
func (e *Engine) runCommands(ctx context.Context, tk *Token) error {
for {
pending := e.state.PendingList()
if len(pending) == 0 {
break
}
low := e.state.LowestAlive()
if low == nil || low.ID != e.ID {
selfIsLowest := false
if low := e.state.LowestAlive(); low != nil && low.ID == e.ID {
selfIsLowest = true
}
// Pick the first command this node may act on this pass.
var target *Task
for _, t := range pending {
if t.RemoveNode == e.ID {
target = t // directed at us — execute regardless of load
break
}
if t.RemoveNode != "" {
// directed at another node; let it ride unless the target is
// already gone (not in ring / offline) — then the lowest node
// consumes the stale command as a no-op so it cannot loop.
if selfIsLowest {
if idx := e.state.Find(t.RemoveNode); idx < 0 || !e.state.Nodes[idx].Alive {
target = t
break
}
}
continue
}
if t.Revoke {
if owner := e.state.TopologyOwner(t); owner == e.ID {
target = t // we own the forward — execute the revoke
break
} else if owner == "" && selfIsLowest {
target = t // forward already gone — idempotent no-op
break
}
continue // owned elsewhere — ride to the owner
}
if selfIsLowest {
target = t // generic forward create — lowest-load claim
break
}
}
if target == nil {
break
}
tk0 := pending[0]
claimed := e.state.ClaimPending(tk0.ID)
claimed := e.state.ClaimPending(target.ID)
if claimed == nil {
break
}
// Revocation task: the owning node cancels the forward (stop worker,
// drop from topology, log forward.remove). Idempotent if missing.
if claimed.Revoke {
if e.state.RemoveTopology(claimed.Local.Name, claimed.Remote.Name, claimed.Link.RemotePort) {
if e.Handler != nil {
@ -165,6 +338,44 @@ func (e *Engine) phase2(ctx context.Context, tk *Token) error {
}
continue
}
if claimed.RemoveNode != "" && claimed.RemoveNode == e.ID {
// Plan §移除节点: self-remove re-queues own forwards, drops ring
// position, and the token continues to the original successor.
// Stop the local workers for every forward we own FIRST (plan:
// "完全取消一切集群远程转发,停其 worker"), then SelfRemove
// moves them back to pending for another member to claim.
if e.Handler != nil {
for _, te := range e.state.ForwardsOwnedBy(e.ID) {
t := &Task{ID: te.TaskID, Local: te.Local, Remote: te.Remote, Link: te.Link}
if err := e.Handler.Revoke(ctx, t); err != nil {
log.Printf("ring[%s] self-remove revoke %s: %v", e.ID, te.TaskID, err)
}
}
}
if succ, ok := e.state.AliveSuccessor(e.ID); ok && succ != e.ID {
e.removedNext = succ
}
e.state.SelfRemove(e.ID)
e.selfRemoved = true
if e.Log != nil {
_, _ = e.Log.Append(e.ID, LogNodeLeave, map[string]string{"node": e.ID})
}
log.Printf("ring[%s] self-removed from cluster (token command %s); next=%s",
e.ID, claimed.ID, e.removedNext)
continue
}
if claimed.RemoveNode != "" {
// A remove command whose target is no longer in the ring: the
// target already self-removed (or was never a member). Drop it
// here — it MUST NOT fall through to the forward-create path,
// which would spawn a phantom empty forward (the remove task
// carries no local/remote/link). This branch also absorbs the
// command after the injector's localPending merge resurrects a
// copy once the original has been consumed downstream.
log.Printf("ring[%s] drop fulfilled remove %s (target %s gone)",
e.ID, claimed.ID, claimed.RemoveNode)
continue
}
if e.Handler != nil {
if err := e.Handler.Claim(ctx, claimed); err != nil {
e.state.PendingTasks[claimed.ID] = claimed
@ -172,8 +383,6 @@ func (e *Engine) phase2(ctx context.Context, tk *Token) error {
break
}
}
// Record the claim in the operation log so all peers converge on who
// owns which forward (incremental log sync).
if e.Log != nil {
_, _ = e.Log.Append(e.ID, LogForwardAdd, map[string]any{
"taskId": claimed.ID, "local": claimed.Local.Name, "remote": claimed.Remote.Name,
@ -181,39 +390,24 @@ func (e *Engine) phase2(ctx context.Context, tk *Token) error {
}
e.state.AddTopology(claimed, e.ID)
}
// Publish our updated state back into the token so the next node carries
// the fresh topology + pending set (do NOT revert local state to the
// incoming snapshot — that would discard the claim we just made).
tk.State = e.state
tk.Passed = append(tk.Passed, e.ID)
return nil
}
// OnToken receives the token: process by phase, return updated token.
func (e *Engine) OnToken(ctx context.Context, tk *Token) (*Token, error) {
e.curPhase = tk.Phase
log.Printf("ring[%s] OnToken cycle=%d phase=%d passed=%v", e.ID, tk.Cycle, tk.Phase, tk.Passed)
switch tk.Phase {
case PhaseCollect:
e.phase1(tk)
case PhaseSync:
if err := e.phase2(ctx, tk); err != nil {
return tk, err
}
default:
return tk, nil
}
return tk, nil
}
// Forward hands the token to this node's successor over the injected send.
// It is the transport hook used by the HTTP handler after OnToken.
// It is the transport hook used by the HTTP handler after OnToken. If this
// node just self-removed, its ID is no longer in state.Nodes so
// AliveSuccessor would fail — use the original successor captured before
// removal (plan §移除节点 step 3: "令牌传递给自身原本的下一家").
func (e *Engine) Forward(ctx context.Context, tk *Token) error {
nodes := make([]string, 0, len(e.state.Nodes))
for _, n := range e.state.Nodes {
nodes = append(nodes, n.ID)
if e.removedNext != "" {
next := e.removedNext
e.removedNext = ""
if e.send != nil {
log.Printf("ring[%s] forward (self-removed) cycle=%d to %s", e.ID, tk.Cycle, next)
return e.send(ctx, next, tk)
}
return nil
}
log.Printf("ring[%s] fwd-debug id=%s nodes=%v", e.ID, e.ID, nodes)
next, ok := e.state.AliveSuccessor(e.ID)
if !ok {
return nil // single-node ring
@ -222,7 +416,7 @@ func (e *Engine) Forward(ctx context.Context, tk *Token) error {
return nil // never forward to ourselves
}
if e.send != nil {
log.Printf("ring[%s] forward cycle=%d phase=%d to %s", e.ID, tk.Cycle, tk.Phase, next)
log.Printf("ring[%s] forward cycle=%d to %s", e.ID, tk.Cycle, next)
return e.send(ctx, next, tk)
}
return nil
@ -233,7 +427,7 @@ type RingSnapshot struct {
SelfID string `json:"selfId"`
LeaderID string `json:"leaderId"`
Cycle int64 `json:"cycle"`
Phase int `json:"phase"`
LastSync int64 `json:"lastSync"`
RoundDelay int64 `json:"roundDelayMs"`
Nodes []Node `json:"nodes"`
Pending []*Task `json:"pending"`
@ -241,19 +435,12 @@ type RingSnapshot struct {
Log []LogEntry `json:"log,omitempty"`
}
func (e *Engine) currentPhase() int {
if e.curPhase == 0 {
return PhaseCollect
}
return e.curPhase
}
func (e *Engine) Snapshot() *RingSnapshot {
snap := &RingSnapshot{
SelfID: e.ID,
LeaderID: e.state.LeaderID,
Cycle: e.state.Cycle,
Phase: e.currentPhase(),
LastSync: e.lastSyncAt,
RoundDelay: e.state.RoundDelay.Milliseconds(),
Nodes: e.state.Nodes,
Pending: e.state.PendingList(),
@ -265,40 +452,10 @@ func (e *Engine) Snapshot() *RingSnapshot {
return snap
}
// advanceToken decides the next recipient: normal successor; if we are leader
// and everyone passed, flip phase for the second round.
func (e *Engine) advanceToken(ctx context.Context, tk *Token) error {
if tk.Phase == PhaseCollect {
all := true
for _, n := range e.state.Nodes {
if !n.Alive {
continue
}
if !contains(tk.Passed, n.ID) {
all = false
break
}
}
if all && e.state.LeaderID == e.ID {
tk.Phase = PhaseSync
tk.Passed = nil
e.state.RoundDelay = tkDelaySince(tk)
}
}
next, ok := e.state.AliveSuccessor(e.ID)
if !ok {
return nil
}
if e.send != nil {
return e.send(ctx, next, tk)
}
return nil
}
// tkDelaySince measures elapsed ms since token SentAt (leader round delay).
// tkDelaySince measures elapsed since token SentAt (leader round delay).
func tkDelaySince(tk *Token) time.Duration {
if tk.SentAt == 0 {
return 200 * time.Millisecond
return 2 * time.Second
}
return time.Duration(time.Now().UnixMilli()-tk.SentAt) * time.Millisecond
}
@ -312,19 +469,19 @@ func contains(xs []string, v string) bool {
return false
}
// StartRing kicks off the cycle from the leader by sending the first token
// (phase 1) to the next node. Called once at leader boot.
// StartRing kicks off a fresh token from the leader. The leader stamps a new
// SentAt (so older in-flight tokens are dropped downstream) and marks inflight
// so WatchTokenLoss can detect a lost first hop — previously StartRing sent
// without marking, so a first-round loss was never noticed.
func (e *Engine) StartRing(ctx context.Context) {
if e.state.LeaderID != e.ID {
return
}
// Single-node ring has no successor to hand the token to; do not POST to
// ourselves. The cycle resumes once a newcomer joins (see JoinNode).
if next, ok := e.state.AliveSuccessor(e.ID); !ok || next == e.ID {
return
next, ok := e.state.AliveSuccessor(e.ID)
if !ok || next == e.ID {
return // single-node ring; resumes once a newcomer joins
}
// Throttle: do not start a new cycle until roundDelay has elapsed since
// the last one, so a healthy ring cycles at a deliberate pace.
// Throttle: do not start a new cycle until RoundDelay has elapsed.
if !e.lastRingStart.IsZero() && time.Since(e.lastRingStart) < e.state.RoundDelay {
return
}
@ -332,14 +489,17 @@ func (e *Engine) StartRing(ctx context.Context) {
e.state.Cycle++
tk := &Token{
Cycle: e.state.Cycle,
Phase: PhaseCollect,
State: e.state,
Passed: []string{e.ID},
SentAt: time.Now().UnixMilli(),
}
e.phase1(tk)
if err := e.advanceToken(ctx, tk); err != nil {
log.Printf("ring[%s] start cycle %d: %v", e.ID, tk.Cycle, err)
if e.send != nil {
if err := e.send(ctx, next, tk); err != nil {
log.Printf("ring[%s] start cycle %d send: %v", e.ID, tk.Cycle, err)
return
}
// Mark inflight ONLY on a successful first send so token-loss
// detection covers the very first hop too.
e.inflight.mark(e.state.RoundDelay)
}
}
@ -351,20 +511,42 @@ type JoinInfo struct {
Cache []string `json:"cache,omitempty"`
}
// JoinNode handles an incoming join request from a new node: it inserts the
// newcomer right after this node (so the newcomer becomes our successor),
// keeps this node the leader, and returns the updated ring state for the
// newcomer to adopt.
func (e *Engine) JoinNode(j JoinInfo) *State {
n := Node{ID: j.ID, Addr: j.Addr, Alive: true,
Load: Load{MemPct: 50, NetPct: 50}, Version: j.Version, Cache: j.Cache}
e.state.InsertAfter(e.ID, n)
if e.Log != nil {
_, _ = e.Log.Append(e.ID, LogNodeJoin, map[string]string{"node": n.ID, "addr": n.Addr})
// injectPendingJoin writes every queued newcomer into state right after
// ourselves (so the newcomer becomes our successor) and logs node.join. Used
// both by OnToken (plan: "令牌发到自己时把新节点加入令牌") and by JoinNode
// when the leader is a single node with no token circulating.
func (e *Engine) injectPendingJoin() {
for _, ji := range e.pendingJoin {
nn := Node{ID: ji.ID, Addr: ji.Addr, Alive: true,
Load: Load{MemPct: 50, NetPct: 50}, Version: ji.Version, Cache: ji.Cache}
e.state.InsertAfter(e.ID, nn)
if e.Log != nil {
_, _ = e.Log.Append(e.ID, LogNodeJoin, map[string]string{"node": nn.ID, "addr": nn.Addr})
}
log.Printf("ring[%s] injected newcomer %s after self", e.ID, nn.ID)
}
e.pendingJoin = nil
}
// JoinNode accepts a newcomer's join request. Per plan §新节点加入 the
// sponsor does NOT mutate its own state immediately (it would be washed out
// by the next incoming token's authoritative merge). Instead it queues the
// newcomer on pendingJoin; OnToken injects it into the token right after
// the sponsor. EXCEPTION: a single-node leader has no token circulating
// (StartRing refuses for lack of a successor), so the newcomer would never
// be injected — in that case we inject immediately and kick off the ring.
func (e *Engine) JoinNode(j JoinInfo) *State {
e.pendingJoin = append(e.pendingJoin, j)
if e.state.LeaderID == "" {
e.state.LeaderID = e.ID
}
// Single-node leader: no successor, no circulating token → the pending
// newcomer would never be injected. Inject now (safe: no foreign token
// can overwrite a single-node leader) and start the ring.
if e.state.LeaderID == e.ID && len(e.state.Nodes) == 1 {
e.injectPendingJoin()
go e.StartRing(context.Background())
}
return &e.state
}
@ -392,12 +574,52 @@ func (e *Engine) AdoptState(s State) {
e.state.UpsertNode(Node{ID: e.ID, Addr: e.myAddr, Alive: true,
Load: e.loadSnapshot(), Version: e.Version, Cache: e.Cache})
if e.Log != nil {
_, _ = e.Log.Append(e.ID, LogNodeJoin, map[string]string{"node": e.ID, "addr": e.myAddr})
e.Log.Append(e.ID, LogNodeJoin, map[string]string{"node": e.ID, "addr": e.myAddr})
}
}
// CreateCluster reseeds this node as a fresh standalone leader (single-node
// ring). Used after a self-leave left the ring empty, or to (re)affirm seed
// state on a standalone node. Refuses if this node is still a multi-node
// member — reseeding mid-cluster would split the ring (split-brain). Safe to
// call when standalone/empty: no token circulates to a non-member, so no
// concurrent OnToken can overwrite the reset.
func (e *Engine) CreateCluster() error {
if e.IsMember() {
return fmt.Errorf("node is a multi-node cluster member; leave first")
}
e.state = State{
LeaderID: e.ID,
Cycle: 0,
PendingTasks: map[string]*Task{},
Topology: map[string]*TopoEntry{},
RoundDelay: 2 * time.Second,
Seq: e.state.Seq,
}
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.inflight.clear()
if e.Log != nil {
_, _ = e.Log.Append(e.ID, LogLeaderChange, map[string]string{"leader": e.ID})
}
log.Printf("ring[%s] created/seeded fresh standalone cluster as leader", e.ID)
return nil
}
// SubmitTask adds a new forward request to pending; it rides the next token
// round and is claimed by the lowest-load member.
// RemoveNode publishes a node-removal command via the token; the target
// node self-removes when the command reaches it.
func (e *Engine) RemoveNode(nodeID string) *Task {
return e.state.AddRemoveNode(nodeID)
}
// RevokeTask publishes a revocation for an established forward through the
// same token channel; the owning node stops the worker and drops topology.
func (e *Engine) RevokeTask(local store.Local, remote store.Remote, link store.Link) *Task {
@ -430,6 +652,25 @@ func (e *Engine) HasTask(local, remote string, port int) bool {
// IsLeader reports whether this node is the current ring leader.
func (e *Engine) IsLeader() bool { return e.state.LeaderID == e.ID }
// IsMember reports whether this node is currently an active multi-node member
// (self is in the ring alongside others). Used by the create/join gates to
// refuse actions that would split an active ring. A detached node (self not
// in ring — e.g. after a self-leave) or a standalone node returns false and
// may create/join freely. NOTE: after a self-leave the engine keeps the other
// members in state.Nodes (it only dropped self), so a plain len>1 check would
// wrongly block a detached node — the self-in-ring test is essential.
func (e *Engine) IsMember() bool {
if len(e.state.Nodes) <= 1 {
return false
}
for _, n := range e.state.Nodes {
if n.ID == e.ID {
return true
}
}
return false
}
// LeaderAddr returns the current leader's address.
func (e *Engine) LeaderAddr() string {
i := e.state.Find(e.state.LeaderID)

View File

@ -44,29 +44,28 @@ func TestSingleNodeCycle(t *testing.T) {
// TestTwoNodesPhaseCollect: leader n1 sends to n2; n1 collects both after n2
// stamps; then phase flips and second round syncs.
func TestTwoNodesCollectAndSync(t *testing.T) {
var n2loaded bool
func TestTwoNodesSingleRound(t *testing.T) {
eng2 := NewEngine("n2", "n2:7500", "u", "p", "0.71.0", nil,
&fakeHandler{load: Load{MemPct: 10, NetPct: 10}}, sendNull, "n2:7500", false)
// n2's state must include both (leader + itself) so AliveSuccessor works
eng2.state.UpsertNode(Node{ID: "n1", Addr: "n1:7500", Alive: true, IsLeader: true, Load: Load{MemPct: 50, NetPct: 50}})
// n1 sends a collect-phase token with itself in Passed.
eng2.state.UpsertNode(Node{ID: "n1", Addr: "n1:7500", Alive: true, IsLeader: true, Load: eng2.state.Nodes[0].Load})
tk := &Token{Cycle: 1, Phase: PhaseCollect, Passed: []string{"n1"},
State: *func() *State {
s := &State{}
s.UpsertNode(Node{ID: "n1", Addr: "n1:7500", Alive: true, IsLeader: true})
s.UpsertNode(Node{ID: "n2", Addr: "n2:7500", Alive: true})
return s
}()}
_ = n2loaded
// n1 sends a token carrying the cluster picture; single-round processing:
// the receiving node adopts it and appends its own info in one pass.
tk := &Token{Cycle: 1, State: State{
Nodes: []Node{
{ID: "n1", Addr: "n1:7500", Alive: true, IsLeader: true},
{ID: "n2", Addr: "n2:7500", Alive: true},
},
}}
out, err := eng2.OnToken(context.Background(), tk)
if err != nil {
t.Fatal(err)
}
if !contains(out.Passed, "n2") {
t.Fatalf("n2 not stamped in round1, passed=%v", out.Passed)
if eng2.State().Find("n2") < 0 {
t.Fatal("n2 should be present in adopted state after single-round")
}
if out == nil {
t.Fatal("nil token after processing")
}
}

View File

@ -1,11 +1,8 @@
// Leader-side cycle control + fault tolerance for the token ring:
// - phase flip (round1 -> round2 -> next cycle) decided by the leader when
// the token returns with all alive nodes passed;
// - token-loss resend when it does not come back within roundDelay/2 + 20ms;
// - predecessor monitors the leader by heartbeat and promotes itself when the
// leader is gone;
// - send failures (successor down) mark the neighbor offline, reattach its
// tasks, and hop to the next alive successor.
// Leader-side minimal roles for the token ring. Per the authoritative design:
// the leader only (1) initiates the first token, and (2) judges token loss.
// All other processing (adopt cluster picture, append own info, run commands,
// forward) is IDENTICAL between leader and ordinary nodes — no phase flips,
// no passed-accounting, no cycle bookkeeping in the leader.
package cluster
import (
@ -15,18 +12,23 @@ import (
"time"
)
// LossTimeout returns the token-loss threshold per the authoritative design:
// roundDelay/2 + 20ms, floored at 600ms so a healthy fast ring is never
// misjudged as lost.
// ringHopDelay paces each token hop (real-world cadence, avoids busy-loop).
// 500ms keeps a 3-node ring well under the LossTimeout floor (2500ms) so a
// healthy round is never misjudged lost, while not idly burning CPU/HTTP at
// 20Hz like the old 50ms did.
const ringHopDelay = 500 * time.Millisecond
// LossTimeout is the token-loss threshold per design: roundDelay/2 + 20ms,
// floored so a healthy fast ring is never misjudged.
func LossTimeout(roundDelay time.Duration) time.Duration {
t := roundDelay/2 + 20*time.Millisecond
if t < 600*time.Millisecond {
return 600 * time.Millisecond
if t < 2500*time.Millisecond {
return 2500 * time.Millisecond
}
return t
}
// inFlight tracks token-in-flight state (leader only).
// inFlight tracks token-in-flight state (leader only, for loss judging).
type inFlight struct {
mu sync.Mutex
active bool
@ -69,53 +71,22 @@ func (f *inFlight) lastDelay() time.Duration {
return f.delay
}
// flipIfComplete promotes the collect round to the sync round when every alive
// node has been stamped; at the end of sync it completes the cycle. Only the
// leader flips. Passed carries the set of nodes that saw this token this round;
// it is NOT reset between phases (only between cycles).
func (e *Engine) flipIfComplete(tk *Token) {
if e.state.LeaderID != e.ID {
return
}
switch tk.Phase {
case PhaseCollect:
if allAlivePassed(&e.state, tk.Passed) {
// Round 1 done: go to round 2 (sync). Round-2 stamping restarts
// from the leader so the token walks the ring once more.
tk.Phase = PhaseSync
tk.Passed = []string{e.ID}
e.state.RoundDelay = tkDelaySince(tk)
}
case PhaseSync:
if allAlivePassed(&e.state, tk.Passed) {
// Cycle done: leader holds token; next StartRing bumps cycle.
e.state.RoundDelay = tkDelaySince(tk)
}
}
}
func allAlivePassed(s *State, passed []string) bool {
for _, n := range s.Nodes {
if !n.Alive {
continue
}
if !contains(passed, n.ID) {
return false
}
}
return true
}
// Send moves the token onward (or completes the cycle at the leader). It is
// called by the HTTP handler after OnToken. A full cycle stops at the leader.
// Send is called after OnToken for the LEADER. The token has come full
// circle (one round complete), so the leader bumps the cycle, stamps a fresh
// SentAt (so any stale in-flight older token is dropped downstream), and
// forwards to the successor to start the next round. This is what makes
// `cycle` advance under the perpetual-flow model — without it cycle stuck
// at the StartRing value forever.
func (e *Engine) Send(ctx context.Context, tk *Token) error {
if e.state.LeaderID == e.ID {
e.flipIfComplete(tk)
// If the sync round completed, the leader holds the token; watchdog
// StartRing launches the next cycle. Clear in-flight marker.
if tk.Phase == PhaseSync && allAlivePassed(&e.state, tk.Passed) {
e.inflight.clear()
return nil
if !e.selfRemoved {
e.state.Cycle = tk.Cycle + 1
tk.Cycle = e.state.Cycle
// Keep State.Cycle in lockstep with Token.Cycle so the snapshot
// (which reads e.state.Cycle) reflects the real round number.
tk.State = e.state
tk.SentAt = time.Now().UnixMilli()
if tk.SentAt > e.lastTokenAt {
e.lastTokenAt = tk.SentAt
}
}
return e.forwardToNext(ctx, tk)
@ -135,14 +106,23 @@ func (e *Engine) forwardToNext(ctx context.Context, tk *Token) error {
}
err := e.send(ctx, next, tk)
if err == nil {
e.inflight.mark(e.state.RoundDelay)
if e.state.LeaderID == e.ID {
e.inflight.mark(e.state.RoundDelay)
}
// Pace the ring so tokens circulate at a realistic cadence.
// Pacing handled by the parallel rhythm timer in OnToken.
return nil
}
log.Printf("ring[%s] send to %s failed: %v", e.ID, next, err)
e.state.MarkOffline(next)
e.state.OfflineReassign(next)
if e.state.LeaderID == next {
e.becomeLeader()
// Do not declare a neighbor offline on a single timeout: transient
// send failures (network jitter, busy handler) must not break the
// ring. Only after consecutive failures do we evict the node.
e.failMu.Lock()
e.failCount[next]++
if e.failCount[next] >= 2 {
e.state.MarkOffline(next)
e.state.OfflineReassign(next)
delete(e.failCount, next)
}
}
e.inflight.clear()
@ -158,8 +138,8 @@ func (e *Engine) becomeLeader() {
log.Printf("ring[%s] promoted to leader", e.ID)
}
// WatchLeader runs the heartbeat monitor: if our successor is the leader, ping
// it; on failure mark it offline and promote ourselves. Stops on ctx cancel.
// WatchLeader runs the leader liveness monitor: the predecessor of the leader
// pings it; on failure it marks the leader offline and promotes itself.
func (e *Engine) WatchLeader(ctx context.Context) {
tick := time.NewTicker(2 * time.Second)
defer tick.Stop()
@ -168,44 +148,21 @@ func (e *Engine) WatchLeader(ctx context.Context) {
case <-ctx.Done():
return
case <-tick.C:
succ, ok := e.state.AliveSuccessor(e.ID)
if !ok || succ == e.ID {
continue // single-node ring: nothing to monitor
if e.state.LeaderID == e.ID {
continue // we are the leader; predecessor monitors us
}
// Only the leader's predecessor (the previous alive node) monitors
// it; a node whose successor is the leader is the predecessor.
if e.state.LeaderID != succ {
continue
}
pred, ok := e.state.AlivePredecessor(e.ID)
if !ok || pred == e.ID {
continue
}
// We monitor the leader only if we ARE its predecessor.
if succ != e.state.LeaderID {
continue
}
// pred must be us: leader's predecessor sends the heartbeat.
if e.state.Nodes[e.state.Find(succ)].ID == e.ID {
continue
}
if e.state.Find(e.ID) == -1 {
continue
}
// This node is the leader's predecessor iff leader's predecessor
// (alive) equals our ID.
leaderPred, ok := e.state.AlivePredecessor(e.state.LeaderID)
if !ok || leaderPred != e.ID {
continue
continue // only the leader's predecessor pings it
}
if e.send != nil {
hbCtx, cancel := context.WithTimeout(ctx, 1500*time.Millisecond)
err := e.send(hbCtx, succ, nil) // nil token = heartbeat ping
err := e.send(hbCtx, e.state.LeaderID, nil) // nil = heartbeat
cancel()
if err != nil {
log.Printf("ring[%s] heartbeat to leader %s failed: %v", e.ID, succ, err)
e.state.MarkOffline(succ)
e.state.OfflineReassign(succ)
log.Printf("ring[%s] heartbeat to leader %s failed: %v", e.ID, e.state.LeaderID, err)
e.state.MarkOffline(e.state.LeaderID)
e.state.OfflineReassign(e.state.LeaderID)
e.becomeLeader()
}
}
@ -213,8 +170,9 @@ func (e *Engine) WatchLeader(ctx context.Context) {
}
}
// WatchTokenLoss runs on the leader: if a token was sent and does not return
// within LossTimeout, resend it. Stops on ctx cancel.
// WatchTokenLoss runs the leader's token-loss judge: if a token was sent and
// does not return within LossTimeout, the leader issues a fresh token (all
// nodes drop older stamps via the SentAt guard, so at most one circulates).
func (e *Engine) WatchTokenLoss(ctx context.Context) {
tick := time.NewTicker(200 * time.Millisecond)
defer tick.Stop()

View File

@ -65,9 +65,13 @@ func TestLogAfterWatermark(t *testing.T) {
func TestClaimLogsToEngine(t *testing.T) {
eng := newTestEngine("n1", true)
eng.state.AddPending(store.Local{Name: "l1"}, store.Remote{Name: "r1"}, store.Link{})
if err := eng.phase2(context.Background(), &Token{Cycle: 1, Phase: PhaseSync, State: eng.state}); err != nil {
out, err := eng.OnToken(context.Background(), &Token{Cycle: 1, State: eng.state})
if err != nil {
t.Fatal(err)
}
if out == nil {
t.Fatal("nil token after OnToken")
}
snap := eng.Log.Snapshot()
if len(snap) != 1 || snap[0].Kind != LogForwardAdd {
t.Fatalf("engine log = %+v", snap)

View File

@ -0,0 +1,121 @@
package cluster
import (
"context"
"testing"
"webui4frpc/internal/store"
)
// TestSelfRemoveViaToken: a node-removal command reaches the target node
// through OnToken; it stops owned workers, re-queues its forwards, drops its
// ring position, and the token flows to the original successor.
func TestSelfRemoveViaToken(t *testing.T) {
eng := newTestEngine("n1", true)
eng.state.InsertAfter("n1", Node{ID: "n2", Addr: "n2:7500", Alive: true, Load: Load{MemPct: 10, NetPct: 10}})
// n1 owns a forward (via topology)
p := eng.state.AddPending(store.Local{Name: "web"}, store.Remote{Name: "frps1"}, store.Link{RemotePort: 18081})
eng.state.ClaimPending(p.ID)
eng.state.AddTopology(p, "n1")
// n2 publishes remove-node for itself
rm := eng.state.AddRemoveNode("n2")
if rm.RemoveNode != "n2" {
t.Fatalf("remove node cmd = %+v", rm)
}
// run OnToken at n2 (lowest-load, so it claims the remove command).
// State order is [n2, n1] so LowestAlive picks n2.
eng2 := newTestEngine("n2", false)
eng2.state.UpsertNode(Node{ID: "n2", Addr: "n2:7500", Alive: true, Load: Load{MemPct: 10, NetPct: 10}})
eng2.state.UpsertNode(Node{ID: "n1", Addr: "n1:7500", Alive: true, IsLeader: true, Load: Load{MemPct: 90, NetPct: 90}})
eng2.state.Topology = map[string]*TopoEntry{
p.ID: {TaskID: p.ID, OwnerID: "n2", Active: true, Local: p.Local, Remote: p.Remote, Link: p.Link},
}
eng2.state.PendingTasks = map[string]*Task{rm.ID: rm}
out, err := eng2.OnToken(context.Background(), &Token{Cycle: 1, State: eng2.state})
if err != nil {
t.Fatal(err)
}
if out == nil {
t.Fatal("nil token after OnToken")
}
// n2 removed itself from ring (NOT re-added by the UpsertNode step)
if eng2.state.Find("n2") >= 0 {
t.Fatalf("n2 still in ring: %+v", eng2.state.Nodes)
}
// the forward n2 owned was re-queued as pending for another member
if len(eng2.state.PendingList()) == 0 {
t.Fatalf("no pending re-queue after self-remove: %+v", eng2.state.PendingList())
}
// removedNext captured so Forward can hand the token to the old successor
if eng2.removedNext != "n1" {
t.Fatalf("removedNext = %q want n1", eng2.removedNext)
}
}
// TestRemoveCommandRidesPastLowest: a node-removal command directed at a
// NON-lowest node must NOT be claimed by the lowest-load node (the old bug
// corrupted it into a phantom forward via Handler.Claim). It stays pending
// and rides the token until it reaches the target, which then self-removes.
func TestRemoveCommandRidesPastLowest(t *testing.T) {
// 3-node ring order [n2, n1, n3]: n2 is lowest, n3 is the remove target.
eng2 := newTestEngine("n2", false)
eng2.state.UpsertNode(Node{ID: "n1", Addr: "n1:7500", Alive: true, IsLeader: true, Load: Load{MemPct: 50, NetPct: 50}})
eng2.state.UpsertNode(Node{ID: "n3", Addr: "n3:7500", Alive: true, Load: Load{MemPct: 90, NetPct: 90}})
rm := eng2.state.AddRemoveNode("n3")
// Token reaches n2 (lowest) carrying the remove command for n3.
if _, err := eng2.OnToken(context.Background(), &Token{Cycle: 1, State: eng2.state}); err != nil {
t.Fatal(err)
}
// n2 must NOT have consumed the remove — it must still be pending, riding.
if _, ok := eng2.state.PendingTasks[rm.ID]; !ok {
t.Fatal("lowest node n2 consumed a remove command aimed at n3 (should ride to target)")
}
if i := eng2.state.Find("n3"); i < 0 {
t.Fatal("n3 was removed by non-target n2 (should still be in ring)")
}
// Token now reaches the TARGET (n3): it self-removes + captures successor.
eng3 := newTestEngine("n3", false)
eng3.state.UpsertNode(Node{ID: "n1", Addr: "n1:7500", Alive: true, IsLeader: true, Load: Load{MemPct: 50, NetPct: 50}})
eng3.state.UpsertNode(Node{ID: "n2", Addr: "n2:7500", Alive: true, Load: Load{MemPct: 10, NetPct: 10}})
eng3.state.PendingTasks = map[string]*Task{rm.ID: rm}
if _, err := eng3.OnToken(context.Background(), &Token{Cycle: 2, State: eng3.state}); err != nil {
t.Fatal(err)
}
if eng3.state.Find("n3") >= 0 {
t.Fatalf("n3 did not self-remove: %+v", eng3.state.Nodes)
}
// Ring order [n3, n1, n2] → n3's original successor is n1.
if eng3.removedNext != "n1" {
t.Fatalf("removedNext = %q want n1", eng3.removedNext)
}
}
// TestRemoveCommandFulfilledNoPhantom: when a remove command's target has
// already left the ring (or was never a member), the lowest-load node that
// re-encounters it must DROP it — NOT fall through to Handler.Claim and spawn
// a phantom empty forward (the remove task carries no local/remote/link).
func TestRemoveCommandFulfilledNoPhantom(t *testing.T) {
var claimed []*Task
eng := NewEngine("n1", "n1:7500", "u", "p", "0.1.0", nil,
&fakeHandler{load: Load{MemPct: 5, NetPct: 5},
claim: func(ctx context.Context, tk *Task) error { claimed = append(claimed, tk); return nil }},
sendNull, "n1:7500", true)
rm := eng.state.AddRemoveNode("node-x:7500") // target not in the ring
if _, err := eng.OnToken(context.Background(), &Token{Cycle: 1, State: eng.state}); err != nil {
t.Fatal(err)
}
if _, ok := eng.state.PendingTasks[rm.ID]; ok {
t.Fatal("fulfilled remove command was not dropped from pending")
}
if len(claimed) != 0 {
t.Fatalf("Handler.Claim called for a remove command: %+v", claimed)
}
if len(eng.state.TopologyList()) != 0 {
t.Fatalf("phantom topology entry created: %+v", eng.state.TopologyList())
}
}

View File

@ -20,7 +20,7 @@ func TestRevokeTaskRemovesTopology(t *testing.T) {
// establish a forward
eng.state.AddPending(store.Local{Name: "web"}, store.Remote{Name: "frps1"}, store.Link{RemotePort: 18081})
// lowest-load is n1 (only node): it claims + builds topology
if err := eng.phase2(context.Background(), &Token{Cycle: 1, Phase: PhaseSync, State: eng.state}); err != nil {
if _, err := eng.OnToken(context.Background(), &Token{Cycle: 1, State: eng.state}); err != nil {
t.Fatal(err)
}
if len(eng.state.TopologyList()) != 1 {
@ -29,7 +29,7 @@ func TestRevokeTaskRemovesTopology(t *testing.T) {
// publish a revoke task pointing at the same forward
eng.state.AddRevoke(store.Local{Name: "web"}, store.Remote{Name: "frps1"}, store.Link{RemotePort: 18081})
if err := eng.phase2(context.Background(), &Token{Cycle: 2, Phase: PhaseSync, State: eng.state}); err != nil {
if _, err := eng.OnToken(context.Background(), &Token{Cycle: 2, State: eng.state}); err != nil {
t.Fatal(err)
}
if len(eng.state.TopologyList()) != 0 {
@ -54,7 +54,7 @@ func TestRevokeTaskRemovesTopology(t *testing.T) {
func TestRevokeIdempotent(t *testing.T) {
eng := newTestEngine("n1", true)
eng.state.AddRevoke(store.Local{Name: "ghost"}, store.Remote{Name: "frps1"}, store.Link{RemotePort: 1})
if err := eng.phase2(context.Background(), &Token{Cycle: 1, Phase: PhaseSync, State: eng.state}); err != nil {
if _, err := eng.OnToken(context.Background(), &Token{Cycle: 1, State: eng.state}); err != nil {
t.Fatalf("revoke missing: %v", err)
}
// no topology entry, no panic

View File

@ -45,3 +45,13 @@ func (e *Engine) JoinRing(ctx context.Context, targetAddr string, ji JoinInfo) e
e.AdoptState(out.State)
return nil
}
// JoinRingAddr wraps JoinRing for HTTP handlers: it builds the newcomer's
// JoinInfo from this node's own id/addr/version/cache (so the caller doesn't
// touch the engine's unexported fields) and asks targetAddr to sponsor us
// into its ring. This is the runtime "加入集群" path (vs. the startup
// bootstrap call in cmd/webui4frpc/main.go).
func (e *Engine) JoinRingAddr(ctx context.Context, targetAddr string) error {
ji := JoinInfo{ID: e.ID, Addr: e.myAddr, Version: e.Version, Cache: e.Cache}
return e.JoinRing(ctx, targetAddr, ji)
}

View File

@ -8,6 +8,7 @@ import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"time"
)
@ -48,11 +49,14 @@ func (t *TokenTransport) SendTo(getAddr func(nodeID string) string) func(ctx con
req.SetBasicAuth(t.User, t.Pass)
}
cli := &http.Client{Timeout: 5 * time.Second}
start := time.Now()
resp, err := cli.Do(req)
if err != nil {
log.Printf("token-send %s: size=%dB elapsed=%v err=%v", addr, len(body), time.Since(start).Round(time.Millisecond), err)
return err
}
defer resp.Body.Close()
log.Printf("token-send %s: size=%dB elapsed=%v -> %d", addr, len(body), time.Since(start).Round(time.Millisecond), resp.StatusCode)
if resp.StatusCode >= 400 {
return fmt.Errorf("token POST %s -> HTTP %d", url, resp.StatusCode)
}

View File

@ -28,11 +28,11 @@ func TestTokenTransportPOST(t *testing.T) {
tr := &TokenTransport{User: "u", Pass: "p"}
host := srv.Listener.Addr().String()
send := tr.SendTo(func(nodeID string) string { return host })
err := send(context.Background(), "peer1", &Token{Cycle: 3, Phase: PhaseCollect})
err := send(context.Background(), "peer1", &Token{Cycle: 3})
if err != nil {
t.Fatalf("send: %v", err)
}
if got.Cycle != 3 || got.Phase != PhaseCollect {
if got.Cycle != 3 {
t.Fatalf("got token = %+v", got)
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>webui-frpc</title>
<script type="module" crossorigin src="/assets/index-CNe69otc.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Cq7Ykgzy.css">
<script type="module" crossorigin src="/assets/index-CXBf2fLP.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B_Hvtf6C.css">
</head>
<body>
<div id="app"></div>

View File

@ -77,10 +77,19 @@ func (h *Handler) saveCanvas(w http.ResponseWriter, r *http.Request) {
}
}
} else if h.Ring != nil {
if fwd, _ := s.LinksForLocal(old.Name); len(fwd) > 0 {
if rem, ok := s.GetRemote(fwd[0].Remote); ok {
h.Ring.RevokeTask(old, rem, store.Link{Local: old.Name, Remote: rem.Name, RemotePort: fwd[0].RemotePort})
// Publish a REVOKE task for EVERY link of the removed local —
// a local may fan out to several remotes, and revoking only the
// first (fwd[0]) left the rest as orphan workers running on
// their owning cluster nodes.
fwd, _ := s.LinksForLocal(old.Name)
for _, ln := range fwd {
rem, ok := s.GetRemote(ln.Remote)
if !ok {
continue
}
h.Ring.RevokeTask(old, rem, store.Link{
Local: old.Name, Remote: rem.Name, RemotePort: ln.RemotePort,
})
}
}
_ = s.DeleteLocal(old.Name)

View File

@ -98,13 +98,23 @@ func NewServeMux(h *Handler) (http.Handler, error) {
mux.HandleFunc(apiPrefix+"/cluster/ring", auth(h.handleClusterRing))
mux.HandleFunc(apiPrefix+"/cluster/join", auth(h.handleClusterJoin))
mux.HandleFunc(apiPrefix+"/cluster/task", auth(h.handleClusterTask))
mux.HandleFunc(apiPrefix+"/cluster/node-remove", auth(h.handleNodeRemove))
mux.HandleFunc(apiPrefix+"/cluster/create", auth(h.handleClusterCreate))
mux.HandleFunc(apiPrefix+"/cluster/join-ring", auth(h.handleClusterJoinRing))
// M6: peer-to-peer binary exchange endpoint (Basic Auth, same creds).
// Not under /api so peers hit it directly; auth still applied.
mux.HandleFunc("/frpc/", auth(h.handleFrpcBinary))
// Static assets (also basic auth) under /.
mux.HandleFunc("/", h.handleStatic)
// The SPA shell is served behind the SAME Basic Auth as the API (plan:
// "healthz 免认证,其余 Basic Auth"). Without this, the browser loads the
// page without a 401 challenge, never caches credentials, and every
// same-origin /api/* fetch (credentials:"same-origin") gets 401 — so the
// SPA renders but all data pages read as empty ("集群未启动"). Wrapping /
// in auth makes the browser prompt once, cache the Basic header for the
// origin, and send it on every subsequent asset + API request.
mux.HandleFunc("/", auth(h.handleStatic))
return mux, nil
}
@ -384,17 +394,26 @@ func (h *Handler) handleClusterToken(w http.ResponseWriter, r *http.Request) {
http.Error(w, "parse token: "+err.Error(), http.StatusBadRequest)
return
}
updated, err := h.Ring.OnToken(r.Context(), &tk)
updated, err := h.Ring.OnToken(context.Background(), &tk)
if err != nil {
http.Error(w, "token process: "+err.Error(), http.StatusInternalServerError)
return
}
// Phase flips and cycle completion happen at the leader; other nodes just
// forward the token along the ring.
// OnToken returns nil when it drops a stale/duplicate token: the token is
// dead, do NOT hand a nil token onward (would nil-panic in Send/Forward).
if updated == nil {
w.WriteHeader(http.StatusOK)
return
}
// Onward forwarding is ASYNC: acknowledge receipt immediately (the
// token is a relay baton, not a synchronous RPC chain). If we forwarded
// synchronously, a slow next hop would make this handler hang for the
// upstream client timeout, which would recursively stall the whole ring.
nextTK := *updated
if h.Ring.IsLeader() {
_ = h.Ring.Send(r.Context(), updated)
go func() { _ = h.Ring.Send(context.Background(), &nextTK) }()
} else {
_ = h.Ring.Forward(r.Context(), updated)
go func() { _ = h.Ring.Forward(context.Background(), &nextTK) }()
}
writeJSON(w, http.StatusOK, updated)
}
@ -467,6 +486,90 @@ func (h *Handler) handleClusterTask(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"task": tk})
}
// handleNodeRemove publishes a node-removal command via the token; the
// target node self-removes when the command reaches it.
func (h *Handler) handleNodeRemove(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
methodNotAllowed(w)
return
}
if h.Ring == nil {
http.Error(w, "ring engine not enabled", http.StatusNotFound)
return
}
var req struct {
ID string `json:"id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "parse: "+err.Error(), http.StatusBadRequest)
return
}
if req.ID == "" {
http.Error(w, "node id required", http.StatusBadRequest)
return
}
tk := h.Ring.RemoveNode(req.ID)
writeJSON(w, http.StatusOK, map[string]any{"task": tk})
}
// handleClusterCreate reseeds this node as a fresh standalone leader (the
// runtime "创建集群" path). Refuses with 409 if this node is still a
// multi-node member — reseeding mid-cluster would split the ring.
func (h *Handler) handleClusterCreate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
methodNotAllowed(w)
return
}
if h.Ring == nil {
http.Error(w, "ring engine not enabled", http.StatusNotFound)
return
}
if err := h.Ring.CreateCluster(); err != nil {
http.Error(w, err.Error(), http.StatusConflict)
return
}
writeJSON(w, http.StatusOK, h.Ring.Snapshot())
}
// handleClusterJoinRing is the runtime "加入集群" path: this node joins the
// cluster at the given peer address (newcomer-side — it POSTs its own
// JoinInfo to the peer's /cluster/join and adopts the returned state).
// Synchronous so the UI gets a real success/failure; capped at 10s (the
// peer dial itself times out at 8s). Refuses 409 if already a multi-node
// member (would split the ring).
func (h *Handler) handleClusterJoinRing(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
methodNotAllowed(w)
return
}
if h.Ring == nil {
http.Error(w, "ring engine not enabled", http.StatusNotFound)
return
}
var req struct {
Addr string `json:"addr"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "parse: "+err.Error(), http.StatusBadRequest)
return
}
if req.Addr == "" {
http.Error(w, "addr required", http.StatusBadRequest)
return
}
if h.Ring.IsMember() {
http.Error(w, "already a multi-node cluster member; leave first", http.StatusConflict)
return
}
jc, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
if err := h.Ring.JoinRingAddr(jc, req.Addr); err != nil {
http.Error(w, "join failed: "+err.Error(), http.StatusBadGateway)
return
}
writeJSON(w, http.StatusOK, h.Ring.Snapshot())
}
// handleClusterJoin accepts a newcomer join request: the target node inserts
// the newcomer after itself in the ring and returns the updated ring state
// for the newcomer to adopt.

39
plan.md
View File

@ -80,7 +80,7 @@
>
### M6 集群(令牌环网拓扑)
> **权威设计(用户约定,必须固化)**:集群节点间通过**令牌环网token ring**通信,轮为一个周期。
> **权威设计(用户约定,必须固化)**:集群节点间通过**令牌环网token ring**通信,轮为一个周期。
#### 拓扑与角色
@ -88,10 +88,15 @@
- **leader**:唯一特殊角色,负责启动时发送第一个令牌;默认**创建集群的节点**为初始 leader。
- **转发链ring**:令牌按固定顺序传递;每个节点记录前驱/后继。
#### 令牌环协议(两轮一周期
#### 令牌环协议(单轮:边传播边同步,收敛快半轮
- **第一轮**:令牌在环上传递,各节点收到后**把自己的信息追加到令牌**地址、负载指标、frpc 缓存、转发能力等)。
- **第二轮**:令牌再次环行,各节点**按令牌内容同步集群信息**更新自己的完整集群视图
- **令牌环行时,各节点收到令牌后同时做两件事**
1. **透传/同步**:采纳令牌已携带的完整集群信息(其它节点此前追加的节点表、负载、拓扑、日志增量),更新自己的完整集群视图。
2. **追加/顺带更新**把自己的信息地址、负载指标、frpc 缓存、转发能力、本机产生的命令)追加进令牌。
- 因每经过一个节点就立即传播当前已知的最新状态,第一个节点的信息在**第二个节点**就被同步到,全网收敛只需**约半圈**——无需分两轮——第一轮即同步,全网收敛只需约半圈。
- 令牌同一时刻只有一个;由"收到令牌的节点处理后交给下家"驱动,无定时重启、无竞态。
- **节奏控制(并行计时器 + 同步操作,非硬性 sleep**:令牌到达时节点做自己的操作(摘取/追加/日志同步/差异预计算),**同时起一个独立的节奏计时器**。操作完成后若计时器未走完则等计时器(保持环的稳定节奏);计时器先到而操作未完则等操作完成(操作皆轻量,且大部分耗时操作如差异计算在令牌到来前已完成)。故转发时机 = max(操作完成, 节奏计时器),两者并行不互斥。
- **令牌带时间戳,只取最新**:每个令牌携带发起时间戳(`SentAt`);节点收到令牌时比较时间戳——若令牌比本机最近记录的旧,说明是**重发残留的旧令牌**,直接丢弃。此机制彻底消除"重发时旧令牌可能还活着"的担忧,即使重发,旧令牌也会被下游识别过期而消失,环上始终只有一个有效令牌。
- 新转发任务:需要建立转发时,**把任务附加在令牌中**(而非直接指派);因每节点都持完整集群信息,由**负载最低的节点****内存使用率 + 网络使用率共同判断**)自行摘取并创建转发。
- **负载摘取**:令牌传递到某节点时,若该节点在上一轮被判定为负载最低,则**先主动摘取任务**,随后在令牌中更新自身信息。
@ -113,7 +118,7 @@
#### 增量日志同步(本机事件 → 令牌 → 全网一致)
- 每个节点维护一份**集群事件日志operation log**:本机发生的变更(创建/删除转发、节点加入/离开、负载变化、leader 变更)以**递增序号**追加为本机日志条目。
- 令牌第二轮(同步轮)携带**本机日志增量**:号码 > 上一周期已同步水位(`lastSyncedSeq`)的条目,随令牌环行时让其它节点**按序追加重放到本地日志**,从而实现全网**增量日志同步**。
- 令牌携带**本机日志增量**(同步与追加在同一轮):号码 > 上一周期已同步水位(`lastSyncedSeq`)的条目,随令牌环行时让其它节点**按序追加重放到本地日志**,从而实现全网**增量日志同步**。
- 日志条目不可变(追加式);节点收到增量后校验序号连续性(缺失则请求补齐),因每个节点持有完整集群信息,可从其它节点补拉。
- 集群状态(拓扑/任务/leader可**由日志重放得到**:新节点加入时,通过令牌/邻居拉取完整日志并重放,即可获得与其它节点一致的完整视图(满足"每个节点都掌握完整集群信息")。
- 该机制与"令牌承载中间配置文件/转发拓扑"正交共存:令牌同时携带〔拓扑快照〕与〔日志增量〕,快照用于即时校验,日志用于一致性追补。
@ -134,13 +139,33 @@
- 画布即唯一编辑入口:编辑节点只发命令,不直接改其它节点;拓扑随令牌轮次收敛全网一致。
- **其它节点根据集群拓扑生成本地画布存储**非编辑节点收到令牌同步的完整拓扑后把拓扑中的转发locals/remotes/links重建为本地画布视图叠加本节点的 localOnly 项。故所有节点的画布展示一致(都源自拓扑),编辑只在入口节点生效。
#### 增删节点
- **加入节点addnode**:维持原设计——在**哪个节点的 webui 做添加节点,哪个节点负责把新节点并入集群**(临近节点处理):
1. 临近节点(如 B收到新节点C的加入请求后**修改转发链与拓扑**:把 C 插入自己后面B 的后继=C自己的原后继A移到 C 后面C 的后继=A形成 `A→B→C→A`
2. **B 将令牌交给 C**(而非原后继 A——C 作为 B 的后继参与环C 处理令牌后继续传给自己的后继 A**形成完整的 join 语义**。
3. 随令牌轮转,全节点收敛到含 C 的一致拓扑。
- **移除节点removenode****借用现有任务发布基础设施**——在某节点 webui 发起删除节点,发布 `node.remove` 命令进令牌;令牌传递到**被移除节点自身**时,该节点执行自移除:
1. 将自己负责的转发任务**重新追加到待分配任务**(自然的 `owner 离线重挂` 语义),
2. **修改拓扑与转发链、移除自身**(拓扑去掉自己的项、环去掉自己、后继衔接),
3. 令牌传递给自身**原本的下一家**。
- **脱离集群后的本地视图**:被移除节点脱离后,自身内部维持的拓扑**只保留本地 locallocalOnly 不进集群的转发)****完全取消一切集群远程转发**(删掉由集群分发的转发、停其 worker
#### 故障自愈(传递令牌间隙判定邻居离线)
- 邻居离线判定**仅在传递令牌的间隙**发生:上家传递令牌给下家后,若在预期窗口内未收到回执,则判定下家离线 → **修改集群状态、移除该节点、将其负责的任务重新追加为待办**,令牌传给再下家。
#### 节点消亡(防脑裂最强兜底)
- 任何节点若**超时收不到令牌**(超过既定周期的 N 倍),**直接自杀退出**不探测上家、不生成新令牌、不做任何协商。这是防止集群脑裂split-brain的最后防线——宁可单一节点自毁不让两个各自为政的残环长期并存。
#### 实现里程碑(待按上述设计重建)
- [x] 数据面Ring State节点表/转发链/leader/轮次延迟/负载/待办任务)+ 日志(追加式 + 水位)
- [x] 令牌环传输HTTP POST /cluster/token + 回执;轮一周期(collect 信息追加 → sync 同步 + 摘取
- [x] 令牌环传输HTTP POST /cluster/token + 回执;轮一周期(信息追加与同步同步完成
- [x] leader创建集群者为首 leaderStartRing 首令牌 + 轮次延迟测量 + 令牌丢失重发LossTimeout
- [x] 新转发任务POST /cluster/task 挂 pending → 令牌携带 → 负载最低者摘取 → 持久化 + 拉 workere2e 验证)
- [x] 增量日志同步LogForwardAdd/LogNodeJoin 等事件随令牌第二轮 delta 传播各节点重放收敛e2e 验证)
- [x] 增量日志同步LogForwardAdd/LogNodeJoin 等事件随令牌 delta 传播(单轮内完成同步)各节点重放收敛e2e 验证)
- [x] 拓扑回写:摘取后 state 写入令牌全网一致e2e topology=[t1,owner]
- [x] 新节点加入POST /cluster/join → 插入环(后继) → AdoptStatee2e A+B 环)
- [ ] 离线处理:令牌超时 → 改集群状态 → 邻居转发转任务重挂forwardToNext 已实现,待 e2e

View File

@ -1,100 +1,236 @@
<!-- 简单 SPA 外壳顶部切换 状态 / 连接配置 / 设置 -->
<!--
SPA shell sakura × frost glassmorphism (ModelRouter-style):
a fixed animated gradient-mesh background, a 246px glass sidebar, and a
content area with a breadcrumb header. Navigation is simple view-state
switching (no router); views own their own padding/scrolling below the
breadcrumb.
-->
<template>
<div id="bgfx" aria-hidden="true">
<i class="blob b1"></i><i class="blob b2"></i><i class="blob b3"></i>
</div>
<div class="app-shell">
<header class="topbar">
<span class="brand">webui4frpc</span>
<nav class="nav">
<button class="nav-btn" :class="{ active: view === 'status' }" @click="view = 'status'">
状态
</button>
<button class="nav-btn" :class="{ active: view === 'canvas' }" @click="view = 'canvas'">
连接配置
</button>
<button class="nav-btn" :class="{ active: view === 'settings' }" @click="view = 'settings'">
设置
</button>
<button class="nav-btn" :class="{ active: view === 'cluster' }" @click="view = 'cluster'">
集群
<aside class="sidebar">
<div class="sb-brand">
<span class="sb-logo" aria-hidden="true">W</span>
<span class="sb-brand-text">
<b>webui4frpc</b>
<small>令牌环 · 转发编排</small>
</span>
</div>
<nav class="sb-nav">
<button
v-for="item in nav"
:key="item.key"
class="sb-i"
:class="{ active: view === item.key }"
@click="view = item.key"
>
<span class="ic" v-html="item.icon" />
<span class="label">{{ item.label }}</span>
</button>
</nav>
</header>
<main class="content">
<CanvasView v-if="view === 'canvas'" />
<SettingsView v-else-if="view === 'settings'" />
<ClusterView v-else-if="view === 'cluster'" />
<StatusView v-else />
</main>
<div class="sb-foot">
<span class="sb-status"><span class="dot" /> token-ring · M6</span>
</div>
</aside>
<div class="main">
<div class="breadcrumb">
<span class="crumb">{{ currentLabel }}</span>
</div>
<main class="content">
<CanvasView v-if="view === 'canvas'" />
<SettingsView v-else-if="view === 'settings'" />
<ClusterView v-else-if="view === 'cluster'" />
<StatusView v-else />
</main>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ref, computed } from 'vue'
import CanvasView from './views/CanvasView.vue'
import SettingsView from './views/SettingsView.vue'
import StatusView from './views/StatusView.vue'
import ClusterView from './views/ClusterView.vue'
const view = ref<'canvas' | 'settings' | 'status' | 'cluster'>('status')
type ViewKey = 'canvas' | 'settings' | 'status' | 'cluster'
const view = ref<ViewKey>('status')
const nav: { key: ViewKey; label: string; icon: string }[] = [
{ key: 'status', label: '状态', icon: '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3v18h18"/><path d="m19 9-5 5-4-4-3 3"/></svg>' },
{ key: 'canvas', label: '连接配置', icon: '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="3"/><circle cx="18" cy="6" r="3"/><circle cx="12" cy="18" r="3"/><path d="M8.5 7.5 16 16M15.5 7.5 8 16"/></svg>' },
{ key: 'settings', label: '设置', icon: '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>' },
{ key: 'cluster', label: '集群', icon: '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="5" r="2"/><circle cx="5" cy="19" r="2"/><circle cx="19" cy="19" r="2"/><path d="M12 7v4m0 0-5 6m5-6 5 6"/></svg>' },
]
const currentLabel = computed(() => nav.find((n) => n.key === view.value)?.label ?? '')
</script>
<style>
html,
body,
#app {
margin: 0;
height: 100%;
}
<style scoped>
.app-shell {
height: 100%;
display: flex;
flex-direction: column;
font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;
background: #f7f8fa;
font-family: var(--w4f-font);
color: var(--w4f-fg);
}
.topbar {
flex-shrink: 0;
/* ---------- sidebar ---------- */
.sidebar {
flex: 0 0 246px;
width: 246px;
padding: 18px 14px 12px;
display: flex;
flex-direction: column;
gap: 4px;
overflow-y: auto;
background: var(--w4f-card);
border-right: 1px solid var(--w4f-line);
backdrop-filter: blur(calc(var(--w4f-glass) * 0.7)) saturate(1.3);
-webkit-backdrop-filter: blur(calc(var(--w4f-glass) * 0.7)) saturate(1.3);
box-shadow: var(--w4f-sh-md);
z-index: 30;
}
@media (prefers-reduced-motion: reduce) {
.sidebar { backdrop-filter: none; -webkit-backdrop-filter: none; }
}
.sb-brand {
display: flex;
align-items: center;
gap: 24px;
padding: 0 20px;
height: 52px;
background: #fff;
border-bottom: 1px solid #e4e7ed;
gap: 11px;
padding: 6px 8px 18px;
}
.sb-logo {
width: 40px;
height: 40px;
border-radius: 13px;
flex: 0 0 auto;
display: grid;
place-items: center;
color: #fff;
font-weight: 800;
font-size: 19px;
letter-spacing: 0.5px;
background: linear-gradient(135deg, var(--w4f-primary), var(--w4f-secondary));
box-shadow: 0 6px 18px rgba(255, 127, 172, 0.45);
}
.sb-brand-text b {
font-size: 16.5px;
letter-spacing: 0.2px;
display: block;
line-height: 1.1;
}
.sb-brand-text small {
display: block;
font-size: 11px;
color: var(--w4f-muted);
margin-top: 3px;
}
.brand {
font-weight: 600;
font-size: 16px;
}
.nav {
.sb-nav {
display: flex;
gap: 8px;
flex-direction: column;
gap: 3px;
flex: 1;
}
.nav-btn {
border: none;
background: transparent;
padding: 6px 16px;
border-radius: 8px;
.sb-i {
display: flex;
align-items: center;
gap: 11px;
width: 100%;
text-align: left;
padding: 10px 13px;
border-radius: 13px;
cursor: pointer;
font-size: 14px;
color: #606266;
&:hover {
background: #f2f3f5;
}
&.active {
background: #303133;
color: #fff;
}
color: var(--w4f-muted);
font-weight: 600;
font-size: 13.5px;
font-family: inherit;
background: transparent;
border: 1px solid transparent;
transition: background 0.18s var(--w4f-ease-spring), color 0.18s var(--w4f-ease-spring),
border-color 0.18s var(--w4f-ease-spring), box-shadow 0.18s var(--w4f-ease-spring);
}
.sb-i .ic {
display: grid;
place-items: center;
width: 18px;
height: 18px;
flex: 0 0 auto;
opacity: 0.85;
}
.sb-i:hover {
background: var(--w4f-card-2);
color: var(--w4f-fg);
}
.sb-i:hover .ic { opacity: 1; }
.sb-i.active {
background: linear-gradient(120deg, var(--w4f-primary-50), var(--w4f-secondary-50));
color: var(--w4f-primary-h);
border-color: var(--w4f-line);
box-shadow: var(--w4f-sh-sm);
}
.sb-i.active .ic { opacity: 1; }
.sb-foot {
margin-top: auto;
padding-top: 16px;
}
.sb-status {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 9px 13px;
border-radius: var(--w4f-radius-pill);
font-size: 12.5px;
font-weight: 600;
color: var(--w4f-primary-h);
background: var(--w4f-primary-50);
}
.sb-status .dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--w4f-primary);
box-shadow: 0 0 0 3px rgba(255, 127, 172, 0.18);
animation: blink 1.6s infinite;
}
@keyframes blink { 50% { opacity: 0.25; } }
/* ---------- main ---------- */
.main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
height: 100%;
}
.breadcrumb {
display: flex;
align-items: center;
gap: 8px;
padding: 16px 24px 6px;
font-weight: 700;
font-size: 15px;
}
.breadcrumb .crumb {
opacity: 1;
color: var(--w4f-fg);
}
.content {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 8px 24px 40px;
}
@media (max-width: 900px) {
.sidebar { flex: 0 0 210px; width: 210px; }
.breadcrumb { padding: 12px 16px 6px; }
.content { padding: 8px 14px 32px; }
}
</style>

View File

@ -95,4 +95,23 @@ export const api = {
// M6 token ring snapshot.
ring: () => request<RingSnapshot>("/api/manager/cluster/ring"),
// M6 cluster lifecycle (创建/加入/退出/移除).
// clusterCreate: reseed THIS node as a fresh standalone leader (创建集群).
clusterCreate: () =>
request<RingSnapshot>("/api/manager/cluster/create", { method: "POST" }),
// clusterJoinRing: THIS node joins the cluster at peer addr (加入集群).
clusterJoinRing: (addr: string) =>
request<RingSnapshot>("/api/manager/cluster/join-ring", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ addr }),
}),
// clusterRemoveNode: publish a node-removal command (移除节点 / 退出集群[self]).
clusterRemoveNode: (id: string) =>
request<{ task: unknown }>("/api/manager/cluster/node-remove", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id }),
}),
};

View File

@ -48,15 +48,15 @@
</label>
<label>限速 <input v-model="bandwidthLimitField" placeholder="如 1MB" /></label>
<label>连接池 <input v-model.number="poolCountField" type="number" min="0" /></label>
<label>元数据(JSON) <input v-model="metadatasField" placeholder='{"env":"prod"}' /></label>
<label>注解(JSON) <input v-model="annotationsField" placeholder='{"owner":"ops"}' /></label>
<label class="adv-wide">元数据(JSON) <input v-model="metadatasField" placeholder='{"env":"prod"}' /></label>
<label class="adv-wide">注解(JSON) <input v-model="annotationsField" placeholder='{"owner":"ops"}' /></label>
<div class="adv-sep">HTTP/HTTPS 路由</div>
<label>域名(逗号分隔) <input v-model="customDomainsField" placeholder="app.example.com" /></label>
<label class="adv-wide">域名(逗号分隔) <input v-model="customDomainsField" placeholder="app.example.com" /></label>
<label>子域 <input v-model="subdomainField" placeholder="app (需 frps subdomain_host)" /></label>
<label>路径路由(JSON) <input v-model="locationsField" placeholder='["/api","/admin"]' /></label>
<label class="adv-wide">路径路由(JSON) <input v-model="locationsField" placeholder='["/api","/admin"]' /></label>
<label>Host 改写 <input v-model="hostHeaderRewriteField" placeholder="backend.internal" /></label>
<label>请求头(JSON) <input v-model="httpHeadersField" placeholder='{"X-Custom":"val"}' /></label>
<label class="adv-wide">请求头(JSON) <input v-model="httpHeadersField" placeholder='{"X-Custom":"val"}' /></label>
<label>BasicAuth 用户 <input v-model="basicAuthUserField" placeholder="user" /></label>
<label>BasicAuth 密码 <input v-model="basicAuthPasswordField" type="password" placeholder="pass" /></label>
@ -195,7 +195,7 @@ const httpHeadersField = computed({
<style scoped lang="scss">
.local-node {
min-width: 190px;
min-width: 240px;
border-radius: 14px 14px 14px 4px;
background: #e8f5e9;
border: 2px solid #4caf50;
@ -260,12 +260,29 @@ const httpHeadersField = computed({
}
}
// Shared input look for both the basic fields and the advanced grid.
%field-input {
flex: 1;
min-width: 0;
border: 1px solid #a5d6a7;
border-radius: 6px;
padding: 2px 6px;
font-size: 12px;
background: #fff;
color: #333;
&:focus {
outline: none;
border-color: #4caf50;
}
}
.fields {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 6px;
// basic field: label text + control inline on one row
label {
display: flex;
align-items: center;
@ -275,72 +292,76 @@ const httpHeadersField = computed({
input,
select {
flex: 1;
min-width: 0;
border: 1px solid #a5d6a7;
border-radius: 6px;
padding: 2px 6px;
font-size: 12px;
background: #fff;
color: #333;
&:focus {
outline: none;
border-color: #4caf50;
}
@extend %field-input;
}
input[type='number'] {
width: 64px;
flex: 0 0 64px;
}
}
}
.adv {
// NOTE: .adv is a SIBLING of .fields (both children of .local-node), not a
// descendant — it MUST NOT be nested under .fields in SCSS or the selector
// `.fields .adv .adv-body` never matches the DOM and the whole advanced body
// renders unstyled (labels flow inline, no line breaks → card stretches).
.adv {
margin-top: 6px;
.adv-toggle {
border: 1px dashed #a5d6a7;
background: transparent;
color: #558b2f;
font-size: 11px;
border-radius: 6px;
padding: 2px 8px;
cursor: pointer;
}
.adv-body {
// 2-column responsive grid: short fields share a row, long/JSON fields
// and section headers span the full width (adv-wide / adv-sep). This
// keeps the card compact instead of one-field-per-line = very tall.
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
margin-top: 6px;
.adv-toggle {
border: 1px dashed #a5d6a7;
background: transparent;
color: #558b2f;
font-size: 11px;
border-radius: 6px;
padding: 2px 8px;
cursor: pointer;
}
.adv-body {
padding: 8px;
background: rgba(76, 175, 80, 0.06);
border-radius: 8px;
label {
display: flex;
flex-direction: column;
gap: 2px;
font-size: 11px;
color: #558b2f;
input,
select {
@extend %field-input;
}
}
// checkbox labels keep the control inline with their text (single row)
.adv-check {
flex-direction: row;
align-items: center;
gap: 4px;
margin-top: 6px;
padding: 6px;
background: rgba(76, 175, 80, 0.06);
border-radius: 8px;
}
label {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: #558b2f;
input,
select {
flex: 1;
min-width: 0;
border: 1px solid #a5d6a7;
border-radius: 6px;
padding: 2px 6px;
font-size: 12px;
background: #fff;
color: #333;
&:focus {
outline: none;
border-color: #4caf50;
}
}
}
.adv-check {
font-size: 11px;
}
// section divider + long/JSON fields span the full grid width
.adv-sep,
.adv-wide {
grid-column: 1 / -1;
}
.adv-sep {
margin: 4px 0 2px;
font-size: 11px;
font-weight: 600;
color: #2e7d32;
border-top: 1px dashed rgba(76, 175, 80, 0.35);
padding-top: 4px;
}
}
}

View File

@ -2,6 +2,9 @@ import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
// Imported AFTER element-plus css so our :root token overrides
// (--el-color-primary, radii, font, ...) win the cascade at equal specificity.
import './styles/theme.css'
import App from './App.vue'
const app = createApp(App)

249
web/src/styles/theme.css Normal file
View File

@ -0,0 +1,249 @@
/*
* webui4frpc global theme — sakura × frost × glassmorphism.
* Faithfully follows the ModelRouter WebUI design vocabulary: a soft animated
* gradient-mesh background with drifting blurred blobs, translucent glass cards
* with a spring entrance, KPI stat cards, gradient primary buttons, pill tags,
* slim themed scrollbars. All theming is plain CSS custom properties (no
* preprocessor needed at runtime); Element Plus variables are overridden so EP
* components inherit the same sakura look.
*
* Semantic note: webui4frpc keeps green = "online / ok" — exactly as
* ModelRouter does (--ok). Sakura (#FF7FAC) is the PRIMARY accent, frost
* (#88C0D0) the secondary; online/healthy status stays green.
*/
:root {
/* ---- palette (sakura / frost) ---- */
--w4f-primary: #FF7FAC;
--w4f-primary-h: #F33B7C;
--w4f-primary-50: #FFF0F5;
--w4f-primary-100: #FFE4E9;
--w4f-primary-200: #FFCDD9;
--w4f-primary-300: #FF9EB5;
--w4f-secondary: #88C0D0;
--w4f-secondary-h: #4C8DAE;
--w4f-secondary-50: #F0F9FC;
--w4f-secondary-200: #AEE1F2;
--w4f-danger: #DB3694;
--w4f-danger-50: #FEEAF6;
--w4f-warning: #b7791f;
--w4f-warning-50: #fdf3e3;
--w4f-info: #3f6ef5;
--w4f-ok: #17a964;
/* ---- surfaces / text ---- */
--w4f-bg-1: #eef2ff;
--w4f-bg-2: #ffffff;
--w4f-bg-3: #ffe9f0;
--w4f-fg: #3b3350;
--w4f-muted: #7c7a95;
--w4f-faint: #9c98b0;
--w4f-card: rgba(255, 255, 255, 0.60);
--w4f-card-solid: #ffffff;
--w4f-card-2: rgba(255, 255, 255, 0.42);
--w4f-line: rgba(255, 127, 172, 0.18);
--w4f-line-strong: rgba(120, 90, 150, 0.22);
/* ---- background blobs ---- */
--w4f-blob1: rgba(255, 127, 172, 0.45);
--w4f-blob2: rgba(136, 192, 208, 0.42);
--w4f-blob3: rgba(244, 114, 182, 0.30);
/* ---- shadows / radii / motion ---- */
--w4f-sh-sm: 0 1px 2px rgba(70, 50, 110, 0.06), inset 0 1px 0 rgba(255, 255, 255, 0.6);
--w4f-sh-md: 0 6px 20px rgba(120, 90, 160, 0.13);
--w4f-sh-lg: 0 18px 46px rgba(120, 90, 160, 0.22);
--w4f-glass: 18px;
--w4f-radius: 16px;
--w4f-radius-sm: 12px;
--w4f-radius-xs: 10px;
--w4f-radius-pill: 99px;
--w4f-ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
--w4f-ease: cubic-bezier(0.4, 0, 0.2, 1);
--w4f-font: 'Quicksand', 'Nunito', 'Inter', 'Noto Sans SC', ui-sans-serif,
system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
'PingFang SC', 'Microsoft YaHei', sans-serif;
--w4f-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas,
monospace;
/* ---- Element Plus variable overrides (concrete hex — EP needs real
* colors for its color-mix / opacity helpers, not var() references). ---- */
--el-color-primary: #FF7FAC;
--el-color-primary-light-3: #FF9EB5;
--el-color-primary-light-5: #ffb3c4;
--el-color-primary-light-7: #ffcdd9;
--el-color-primary-light-8: #ffe4e9;
--el-color-primary-light-9: #fff0f5;
--el-color-primary-dark-2: #f33b7c;
--el-color-success: #17a964;
--el-color-warning: #b7791f;
--el-color-danger: #db3694;
--el-color-info: #7c7a95;
--el-border-radius-base: 12px;
--el-border-radius-small: 8px;
--el-font-family: var(--w4f-font);
}
html,
body,
#app {
margin: 0;
height: 100%;
}
html, body { overflow: hidden; }
body {
font: 14px/1.55 var(--w4f-font);
color: var(--w4f-fg);
letter-spacing: 0.02em;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
::selection { background: #ffcdba; color: #fff; }
a { color: var(--w4f-primary-h); }
/* ---- animated gradient-mesh background ----
* Rendered once from App.vue as <div id="bgfx">. Fixed, behind everything. */
#bgfx {
position: fixed;
inset: 0;
z-index: -10;
overflow: hidden;
background: linear-gradient(135deg, var(--w4f-bg-1), var(--w4f-bg-2) 45%, var(--w4f-bg-3));
}
#bgfx .blob {
position: absolute;
border-radius: 50%;
filter: blur(64px);
will-change: transform;
opacity: 0.6;
}
#bgfx .b1 {
top: -10%; left: -8%; width: 560px; height: 560px;
background: var(--w4f-blob1);
animation: drift1 30s ease-in-out infinite alternate;
}
#bgfx .b2 {
top: 16%; right: -10%; width: 440px; height: 440px;
background: var(--w4f-blob2);
animation: drift2 36s ease-in-out infinite alternate;
}
#bgfx .b3 {
bottom: -12%; left: 24%; width: 620px; height: 620px;
background: var(--w4f-blob3);
animation: drift3 42s ease-in-out infinite alternate;
}
@keyframes drift1 { from { transform: translate(0,0) scale(1); } to { transform: translate(30px,-20px) scale(1.05); } }
@keyframes drift2 { from { transform: translate(0,0) scale(1); } to { transform: translate(-25px,18px) scale(1.03); } }
@keyframes drift3 { from { transform: translate(0,0) scale(1); } to { transform: translate(18px,25px) scale(1.04); } }
@media (prefers-reduced-motion: reduce) {
#bgfx .blob { animation: none; opacity: 0.35; }
}
/* ---- reusable glass card surface ----
* Opt-in via class. backdrop-blur + translucent fill + soft shadow + a subtle
* spring entrance so sections "arrive" instead of popping in. */
.w4f-card {
position: relative;
background: var(--w4f-card);
border: 1px solid var(--w4f-line);
border-radius: var(--w4f-radius);
box-shadow: var(--w4f-sh-md);
backdrop-filter: blur(calc(var(--w4f-glass) * 0.7)) saturate(1.3);
-webkit-backdrop-filter: blur(calc(var(--w4f-glass) * 0.7)) saturate(1.3);
animation: cardIn 0.32s var(--w4f-ease-spring) both;
}
@media (prefers-reduced-motion: reduce) {
.w4f-card { backdrop-filter: none; -webkit-backdrop-filter: none; }
}
@keyframes cardIn {
from { opacity: 0; transform: translateY(10px) scale(0.985); }
to { opacity: 1; transform: none; }
}
/* ---- KPI stat card ----
* .k-hdr (label + dot) / .k-val (big number) / .k-sub (caption) / .k-chart. */
.w4f-kpi {
background: var(--w4f-card);
border: 1px solid var(--w4f-line);
border-radius: var(--w4f-radius);
padding: 16px 18px;
display: flex;
flex-direction: column;
box-shadow: var(--w4f-sh-md);
backdrop-filter: blur(var(--w4f-glass)) saturate(1.6);
-webkit-backdrop-filter: blur(var(--w4f-glass)) saturate(1.6);
transition: transform 0.25s var(--w4f-ease-spring), box-shadow 0.25s var(--w4f-ease-spring),
border-color 0.25s var(--w4f-ease-spring);
animation: cardIn 0.32s var(--w4f-ease-spring) both;
}
.w4f-kpi:hover {
transform: translateY(-2px);
box-shadow: var(--w4f-sh-lg);
border-color: var(--w4f-primary-200);
}
.w4f-kpi .k-hdr { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 6px; }
.w4f-kpi .k-lab { font-size: 12px; color: var(--w4f-muted); font-weight: 700; display: flex; align-items: center; gap: 6px; white-space: nowrap; }
.w4f-kpi .k-val { font-size: 27px; font-weight: 800; margin-top: 4px; font-variant-numeric: tabular-nums; letter-spacing: 0.3px; }
.w4f-kpi .k-sub { font-size: 11.5px; color: var(--w4f-muted); margin-top: 4px; }
.w4f-kpi .k-ic {
width: 26px; height: 26px; border-radius: 9px; flex: 0 0 auto;
display: flex; align-items: center; justify-content: center;
background: linear-gradient(135deg, var(--w4f-primary-50), var(--w4f-secondary-50));
color: var(--w4f-primary-h);
}
.w4f-dot {
width: 7px; height: 7px; border-radius: 50%; background: var(--w4f-ok);
display: inline-block; animation: blink 1.6s infinite;
}
.w4f-dot.err { background: var(--w4f-danger); }
.w4f-dot.pending { background: var(--w4f-secondary); }
@keyframes blink { 50% { opacity: 0.25; } }
/* ---- bar indicator ---- */
.w4f-bar { height: 5px; border-radius: 3px; background: var(--w4f-card-2); overflow: hidden; min-width: 60px; }
.w4f-bar i { display: block; height: 100%; background: linear-gradient(90deg, var(--w4f-primary), var(--w4f-secondary)); border-radius: 3px; }
.w4f-bar.ok i { background: var(--w4f-ok); }
.w4f-bar.err i { background: var(--w4f-danger); }
/* ---- pill tags ---- */
.w4f-tag {
display: inline-flex; align-items: center; gap: 5px;
padding: 3px 10px; border-radius: var(--w4f-radius-pill);
font-size: 12px; font-weight: 600; margin: 2px 3px 2px 0;
}
.w4f-tag-ok { background: rgba(23, 169, 100, 0.12); color: var(--w4f-ok); }
.w4f-tag-danger { background: var(--w4f-danger-50); color: var(--w4f-danger); }
.w4f-tag-info { background: var(--w4f-secondary-50); color: var(--w4f-secondary-h); }
.w4f-tag-warn { background: rgba(183, 121, 31, 0.14); color: var(--w4f-warning); }
.w4f-tag-primary { background: var(--w4f-primary-50); color: var(--w4f-primary-h); }
/* ---- gradient buttons (raw <button> utility classes) ---- */
.w4f-btn {
background: linear-gradient(135deg, var(--w4f-primary), var(--w4f-danger));
color: #fff; border: 0; border-radius: 11px; padding: 8px 16px; cursor: pointer;
font: inherit; font-weight: 600; transition: all 0.18s var(--w4f-ease-spring);
}
.w4f-btn:hover { background: linear-gradient(135deg, var(--w4f-primary-h), var(--w4f-danger)); transform: translateY(-1px); box-shadow: 0 8px 20px rgba(255, 127, 172, 0.35); }
.w4f-btn:active { transform: translateY(0); }
.w4f-btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; box-shadow: none; }
.w4f-btn.ghost { background: transparent; border: 1px solid var(--w4f-line-strong); color: var(--w4f-muted); }
.w4f-btn.ghost:hover { border-color: var(--w4f-primary); color: var(--w4f-primary-h); box-shadow: var(--w4f-sh-sm); transform: none; background: transparent; }
.w4f-btn.danger { background: transparent; border: 1px solid var(--w4f-danger); color: var(--w4f-danger); }
.w4f-btn.danger:hover { background: var(--w4f-danger); color: #fff; box-shadow: 0 8px 20px rgba(219, 54, 148, 0.3); transform: translateY(-1px); }
.w4f-btn.small { padding: 5px 12px; font-size: 12px; border-radius: var(--w4f-radius-pill); }
/* ---- slim themed scrollbars ---- */
* { scrollbar-width: thin; scrollbar-color: var(--w4f-line-strong) transparent; }
*::-webkit-scrollbar { width: 8px; height: 8px; }
*::-webkit-scrollbar-track { background: transparent; }
*::-webkit-scrollbar-thumb { background: rgba(255, 182, 193, 0.45); border-radius: 4px; transition: background 0.3s; }
*::-webkit-scrollbar-thumb:hover { background: rgba(255, 127, 172, 0.7); }
/* ---- Element Plus dialog tweaks to read as glass ---- */
.el-overlay { backdrop-filter: blur(3px); }
.el-dialog { border-radius: var(--w4f-radius) !important; box-shadow: var(--w4f-sh-lg) !important; }
.el-message { border-radius: var(--w4f-radius-sm) !important; box-shadow: var(--w4f-sh-lg) !important; }

View File

@ -1,25 +1,55 @@
// Global SCSS variables for webui4frpc (light theme).
$color-text-primary: #303133;
$color-text-secondary: #606266;
$color-text-muted: #909399;
$color-text-light: #c0c4cc;
// Global SCSS variables for webui4frpc.
//
// Palette mirrors the CSS custom-property tokens in theme.css (sakura × frost
// glassmorphism, faithful to the ModelRouter WebUI). These SCSS vars are
// auto-injected into every <style lang="scss"> block via vite.config.ts
// additionalData, so remapping them here shifts the accent color of every
// view at once — keeping the data-dense pages visually coherent with the
// glass sidebar shell without per-view rewrites.
//
// Semantic note: green = online/ok (as in ModelRouter). Sakura is the accent.
$color-text-primary: #3b3350;
$color-text-secondary: #5a5470;
$color-text-muted: #7c7a95;
$color-text-light: #9c98b0;
$color-bg-primary: #ffffff;
$color-bg-secondary: #f9f9f9;
$color-bg-tertiary: #fafafa;
$color-bg-muted: #f4f4f5;
$color-bg-hover: #efefef;
$color-bg-active: #eaeaea;
$color-border: #dcdfe6;
$color-border-light: #e4e7ed;
$color-border-lighter: #ebeef5;
$color-border-extra-light: #f2f6fc;
$color-primary: #409eff;
$color-success: #67c23a;
$color-warning: #e6a23c;
$color-danger: #f56c6c;
$color-info: #909399;
$color-btn-primary: #303133;
$color-btn-primary-hover: #4a4d5c;
$color-bg-secondary: #f8f6fb;
$color-bg-tertiary: #eef2ff;
$color-bg-muted: #f4f1f7;
$color-bg-hover: #f5eef3;
$color-bg-active: #ffe4e9;
$color-border: #e6d9e4;
$color-border-light: #ecdfec;
$color-border-lighter: #f2e9f4;
$color-border-extra-light: #f7f0f7;
// Accents — sakura/frost identity (online/ok stays green).
$color-primary: #ff7fac; // sakura-400
$color-primary-h: #f33b7c; // sakura-500 (hover)
$color-primary-50: #fff0f5; // sakura-50 (tints/chips)
$color-primary-100: #ffe4e9;
$color-secondary: #88c0d0; // frost
$color-secondary-h: #4c8dae;
$color-secondary-50: #f0f9fc;
$color-success: #17a964; // green (online/ok)
$color-warning: #b7791f; // amber
$color-warning-50: #fdf3e3;
$color-danger: #db3694; // sakura-magenta danger
$color-danger-50: #feeaf6;
$color-info: #3f6ef5; // periwinkle
// Primary action button follows the sakura accent (gradient with danger).
$color-btn-primary: #ff7fac;
$color-btn-primary-hover: #f33b7c;
// Glass surfaces / shadows / radii (opt-in from views; see theme.css for the
// matching runtime CSS custom properties --w4f-*).
$color-glass-bg: rgba(255, 255, 255, 0.60);
$color-glass-bg-2: rgba(255, 255, 255, 0.42);
$color-glass-border: rgba(255, 127, 172, 0.18);
$shadow-sm: 0 1px 2px rgba(70, 50, 110, 0.06), inset 0 1px 0 rgba(255, 255, 255, 0.6);
$shadow-md: 0 6px 20px rgba(120, 90, 160, 0.13);
$shadow-lg: 0 18px 46px rgba(120, 90, 160, 0.22);
$radius-glass: 16px;
$font-size-xs: 12px;
$font-size-sm: 13px;
@ -34,7 +64,10 @@ $spacing-sm: 8px;
$spacing-md: 12px;
$spacing-lg: 24px;
$spacing-xl: 32px;
$radius-sm: 6px;
$radius-md: 10px;
$radius-lg: 14px;
$transition-fast: 0.2s ease;
$radius-xs: 10px;
$radius-sm: 12px;
$radius-md: 16px;
$radius-lg: 16px;
$radius-pill: 99px;
$transition-fast: 0.18s var(--w4f-ease-spring, cubic-bezier(0.34, 1.56, 0.64, 1));
$ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);

View File

@ -191,6 +191,7 @@ export interface RingNode {
export interface RingTaskInfo {
id: string;
revoke?: boolean;
removeNode?: string; // node-removal command (target rides the token to self-remove)
local: { name: string };
remote: { name: string };
link: { remotePort?: number };
@ -214,9 +215,9 @@ export interface RingLogEntry {
export interface RingSnapshot {
selfId: string;
leaderId: string;
cycle: number;
phase: number; // 1=collect, 2=sync
leaderId: string; // 当前 leader持首令牌 + 判丢失 + 被上家监控)
cycle: number; // 周期号leader 每收令牌回 +1
lastSync: number; // 上次同步时间 (unix sec)
roundDelayMs?: number;
nodes: RingNode[];
pending: RingTaskInfo[];

View File

@ -1,73 +1,124 @@
<!--
Cluster page token-ring snapshot + cluster lifecycle actions.
Sakua × frost glassmorphism (ModelRouter-style): hero glass card, KPI row,
glass action toolbar, horizontal ring chain of glass node-cards, glass
task/topology/log sections with cardIn entrance.
Four capabilities (gated by derived state to prevent split-brain):
创建集群 reseed THIS node as a fresh standalone leader (CreateCluster)
加入集群 THIS node joins an existing cluster at a peer addr (JoinRing)
退出集群 THIS node leaves (publishes a node-remove for self)
移除节点 remove SOME member (publishes a node-remove for the target)
加入/退出 are reflexive (this node's relation to a cluster); 移除 is
transitive. Per plan §M6 the leader only holds the first token + judges
loss — it does NOT route tasks.
-->
<template>
<div class="cluster-page">
<div class="cluster-top">
<h2 class="page-title">集群令牌环</h2>
<span class="page-sub">令牌环网 · 两轮一周期 · 画布差异命令进令牌 · 增量日志同步</span>
<button class="refresh-btn" @click="load">刷新</button>
<!-- Hero header -->
<div class="hero w4f-card">
<div class="hero-left">
<h2 class="page-title">集群令牌环</h2>
<span class="page-sub">令牌环网 · 单轮一周期 · 画布差异命令进令牌 · 增量日志同步</span>
</div>
<div class="hero-stats" v-if="ring">
<span class="w4f-tag" :class="stateTagClass"><span class="w4f-dot" :class="stateDotClass" />{{ stateLabel }}</span>
<span class="w4f-tag w4f-tag-info"><b>{{ aliveCount }}</b>&nbsp;成员</span>
<span class="w4f-tag w4f-tag-primary" :title="'cycle ' + ring.cycle">上次同步 <b>{{ lastSyncAgo }}</b></span>
<span v-if="isLeader" class="w4f-tag w4f-tag-primary"><span class="crown">♔</span> 本机 leader</span>
</div>
</div>
<div v-if="ring">
<!-- 环状态 -->
<section class="section">
<h3 class="section-title">环状态
<span class="hint">本机 = 编辑入口节点leader 持首令牌并推进周期</span>
</h3>
<div class="ring-state">
<span class="rs-item">本机: <b :class="{ leader: ring.selfId === ring.leaderId }">{{ shortId(ring.selfId) }}</b></span>
<span class="rs-item">leader: <b>{{ shortId(ring.leaderId) }}</b></span>
<span class="rs-item">周期: <b>{{ ring.cycle }}</b></span>
<span class="rs-item">令牌相: <span class="phase-badge" :class="ring.phase === 2 ? 'sync' : 'collect'">{{ ring.phase === 2 ? '同步轮' : '收集轮' }}</span></span>
<span class="rs-item">节点数: <b>{{ ring.nodes.length }}</b></span>
<span class="rs-item">待办: <b>{{ ring.pending.length }}</b></span>
<span class="rs-item">活跃转发: <b>{{ ring.topology.length }}</b></span>
</div>
</section>
<!-- KPI row -->
<div class="kpis" v-if="ring">
<div class="w4f-kpi">
<div class="k-hdr"><span class="k-lab"><span class="w4f-dot" :class="{ err: aliveCount === 0 }" />环节点</span><span class="k-ic">○</span></div>
<div class="k-val">{{ ring.nodes.length }}</div>
<div class="k-sub">活跃 {{ aliveCount }} · 离线 {{ ring.nodes.length - aliveCount }}</div>
</div>
<div class="w4f-kpi">
<div class="k-hdr"><span class="k-lab"><span class="w4f-dot" />活跃转发</span><span class="k-ic">→</span></div>
<div class="k-val">{{ ring.topology.length }}</div>
<div class="k-sub">已 claim 的集群转发</div>
</div>
<div class="w4f-kpi">
<div class="k-hdr"><span class="k-lab"><span class="w4f-dot" :class="{ pending: ring.pending.length > 0 }" />待办命令</span><span class="k-ic">⚙</span></div>
<div class="k-val">{{ ring.pending.length }}</div>
<div class="k-sub">随令牌环行待执行</div>
</div>
<div class="w4f-kpi">
<div class="k-hdr"><span class="k-lab"><span class="w4f-dot" :class="{ err: lastSyncStale }" />上次同步</span><span class="k-ic">⟳</span></div>
<div class="k-val">{{ lastSyncAgo }}</div>
<div class="k-sub">令牌环心跳 · 停滞即异常</div>
</div>
</div>
<!-- 环拓扑 -->
<section class="section">
<h3 class="section-title">环拓扑
<span class="hint">顺序=令牌传递链本机高亮leader 蓝色离线标红前驱监控 leader</span>
</h3>
<!-- Action toolbar -->
<div class="toolbar w4f-card">
<template v-if="!isMember">
<button class="w4f-btn" :disabled="busy" @click="onCreate">⊕ 创建集群</button>
<button class="w4f-btn ghost" :disabled="busy" @click="joinDialog.visible = true">↪ 加入集群…</button>
<span class="act-hint" v-if="isStandalone && isLeader">本节点为初始 leader已就绪可直接创建或输入对端地址加入既有集群</span>
<span class="act-hint" v-else-if="!selfInRing">本节点已脱离集群,可创建新集群或加入既有集群</span>
</template>
<template v-else>
<button class="w4f-btn danger" :disabled="busy" @click="onLeave">⏏ 退出集群</button>
<span class="act-hint">退出后本节点仅保留 localOnly 转发;可重新创建/加入</span>
</template>
<button class="w4f-btn ghost small" style="margin-left: auto" :disabled="busy" @click="load">⟳ 刷新</button>
</div>
<div v-if="ring" class="cluster-body">
<!-- Ring topology -->
<section class="section w4f-card">
<h3 class="section-title"><span class="k-ic">○</span>环拓扑<span class="hint">令牌传递链 · leader 青色 · 本机 sakura 高亮 · 离线标红 · 非本机节点可移除</span></h3>
<div class="ring-line">
<template v-for="(n, i) in ring.nodes" :key="n.id">
<div class="ring-node" :class="{ leader: n.id === ring.leaderId, dead: !n.alive, self: n.id === ring.selfId }">
<div class="rn-top">
<span class="rn-id">{{ shortId(n.id) }}</span>
<span v-if="n.id === ring.selfId" class="rn-self">本机</span>
<span v-if="n.id === ring.leaderId" class="rn-leader">LEADER</span>
<span v-if="!n.alive" class="rn-dead">离线</span>
<span v-if="n.id === ring.selfId" class="w4f-tag w4f-tag-primary">本机</span>
<span v-if="n.id === ring.leaderId" class="w4f-tag w4f-tag-info">LEADER</span>
<span v-if="!n.alive" class="w4f-tag w4f-tag-danger">离线</span>
</div>
<div class="rn-load-row">
<span class="rn-load-label">负载 {{ Math.round(n.load.memPct + n.load.netPct) }}</span>
<div class="w4f-bar"><i :style="{ width: Math.min(100, n.load.memPct + n.load.netPct) + '%' }" /></div>
</div>
<div class="rn-load">负载 {{ Math.round(n.load.memPct + n.load.netPct) }}</div>
<div class="rn-addr">{{ n.addr }}</div>
<div v-if="n.id === ring.selfId" class="rn-role">编辑入口</div>
<button v-if="n.id !== ring.selfId && n.alive" class="w4f-btn danger small rn-remove" :disabled="busy" @click="onRemoveNode(n.id)">移除节点</button>
</div>
<span v-if="i < ring.nodes.length - 1" class="ring-arrow">→</span>
</template>
<div v-if="!ring.nodes.length" class="empty">环节点为空(已脱离)</div>
</div>
</section>
<!-- 待办任务 / 命令 -->
<section class="section" v-if="ring.pending.length">
<h3 class="section-title">令牌命令待执行
<span class="hint">画布差异产生新增转发 / 撤销转发随令牌环行由对应节点执行</span>
</h3>
<!-- Pending token commands -->
<section class="section w4f-card" v-if="ring.pending.length">
<h3 class="section-title"><span class="k-ic">⚙</span>令牌命令(待执行)<span class="hint">画布差异 / 节点移除产生,随令牌环行由对应节点执行</span></h3>
<div class="task-list">
<div v-for="t in ring.pending" :key="t.id" class="task-row" :class="t.revoke ? 'revoke' : 'pending'">
<div v-for="t in ring.pending" :key="t.id" class="task-row" :class="taskClass(t)">
<span class="tk-id">{{ t.id }}</span>
<span class="tk-cmd">{{ t.revoke ? '撤销' : '新增' }}</span>
<span class="tk-name">{{ t.local.name }}</span>
<span class="tk-arrow"></span>
<span class="tk-name">{{ t.remote.name }}</span>
<span class="tk-port">:{{ t.link?.remotePort }}</span>
<span class="w4f-tag" :class="t.removeNode || t.revoke ? 'w4f-tag-danger' : 'w4f-tag-primary'">{{ taskCmdLabel(t) }}</span>
<template v-if="t.removeNode">
<span class="tk-arrow">→</span>
<span class="tk-name">移除节点 {{ shortId(t.removeNode) }}</span>
</template>
<template v-else>
<span class="tk-name">{{ t.local.name }}</span>
<span class="tk-arrow">→</span>
<span class="tk-name">{{ t.remote.name }}</span>
<span class="tk-port">:{{ t.link?.remotePort }}</span>
</template>
</div>
</div>
</section>
<!-- 活跃转发拓扑 -->
<section class="section">
<h3 class="section-title">活跃转发拓扑
<span class="hint">每个转发由 owner 节点持有localOnly 转发只在本节点 webui 可见</span>
</h3>
<!-- Active forward topology -->
<section class="section w4f-card">
<h3 class="section-title"><span class="k-ic">→</span>活跃转发拓扑<span class="hint">每个转发由 owner 节点持有 · localOnly 转发只在本节点可见</span></h3>
<div class="topo-list">
<div v-for="t in ring.topology" :key="t.taskId" class="topo-row">
<span class="tp-id">{{ t.taskId }}</span>
@ -76,17 +127,15 @@
<span class="tp-name">{{ t.remote.name }}</span>
<span class="tp-port">:{{ t.link?.remotePort }}</span>
<span class="tp-owner" :class="{ self: t.ownerId === ring.selfId }">owner: {{ shortId(t.ownerId) }}</span>
<span class="tp-state" :class="{ ok: t.active }">{{ t.active ? "运行中" : "已停" }}</span>
<span class="w4f-tag" :class="t.active ? 'w4f-tag-ok' : 'w4f-tag-danger'">{{ t.active ? '运行中' : '已停' }}</span>
</div>
<div v-if="!ring.topology.length" class="empty">暂无活跃转发(画布编辑后将经令牌分发)</div>
</div>
</section>
<!-- 增量日志 -->
<section class="section">
<h3 class="section-title">集群日志增量同步
<span class="hint">追加式随令牌 delta 重放到全网一致</span>
</h3>
<!-- Cluster log -->
<section class="section w4f-card">
<h3 class="section-title"><span class="k-ic">≣</span>集群日志(增量同步)<span class="hint">追加式,随令牌 delta 重放到全网一致</span></h3>
<div class="log-list">
<div v-for="(e, i) in logView" :key="i" class="log-row" :class="logKindClass(e.kind)">
<span class="lg-seq">#{{ e.seq }}</span>
@ -97,24 +146,87 @@
</div>
</section>
</div>
<el-empty v-else description="集群未启动(无环节点)" />
<div v-else class="empty-page w4f-card">集群未启动(无环节点)</div>
<!-- Join dialog -->
<el-dialog v-model="joinDialog.visible" title="加入集群" width="420px">
<div class="form-row">
<label>对端地址</label>
<el-input v-model="joinDialog.addr" placeholder="host:port如 node-a:7500" @keyup.enter="onJoin" />
</div>
<div class="form-hint">本节点将向对端请求加入并采纳其环状态,令牌到达即并入环。</div>
<template #footer>
<button class="w4f-btn ghost small" @click="joinDialog.visible = false">取消</button>
<button class="w4f-btn small" :disabled="busy || !joinDialog.addr.trim()" @click="onJoin">加入</button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
import { ElMessage } from 'element-plus'
import { api } from '../api'
import type { RingSnapshot } from '../types'
import type { RingSnapshot, RingTaskInfo } from '../types'
const ring = ref<RingSnapshot | null>(null)
const busy = ref(false)
let timer: number | null = null
const joinDialog = reactive({ visible: false, addr: '' })
const load = async () => {
try { ring.value = await api.ring() } catch { /* keep last */ }
}
const shortId = (id: string) => (id.length > 22 ? '' + id.slice(-20) : id)
// ---- state derivation (safety gates) ----
const selfInRing = computed(() => !!ring.value?.nodes.some((n) => n.id === ring.value?.selfId))
const aliveCount = computed(() => ring.value?.nodes.filter((n) => n.alive).length ?? 0)
const isStandalone = computed(() => aliveCount.value <= 1 || !selfInRing.value)
const isMember = computed(() => !!ring.value && ring.value.nodes.length > 1 && selfInRing.value)
const isLeader = computed(() => !!ring.value && ring.value.selfId === ring.value.leaderId)
const stateLabel = computed(() => {
if (!ring.value) return '未启动'
if (!selfInRing.value) return '已脱离'
if (isMember.value) return '运行中'
return '待加入'
})
const stateTagClass = computed(() => {
if (!ring.value) return 'w4f-tag-warn'
if (!selfInRing.value) return 'w4f-tag-danger'
if (isMember.value) return 'w4f-tag-ok'
return 'w4f-tag-info'
})
const stateDotClass = computed(() => {
if (!ring.value) return ''
if (!selfInRing.value) return 'err'
if (isMember.value) return ''
return 'pending'
})
// ---- last-sync freshness (ops cares "环还活着吗", not the cycle counter) ----
const now = ref(Date.now())
let tick: number | null = null
const lastSyncAgo = computed(() => {
const ls = ring.value?.lastSync ?? 0
if (!ls) return ''
const s = Math.max(0, Math.floor(now.value / 1000) - ls)
if (s < 2) return '刚刚'
if (s < 60) return s + ' 秒前'
if (s < 3600) return Math.floor(s / 60) + ' 分钟前'
return Math.floor(s / 3600) + ' 小时前'
})
// 停滞告警:正常每 ~2-3 秒一轮,超过 15 秒未同步视为环异常
const lastSyncStale = computed(() => {
const ls = ring.value?.lastSync ?? 0
if (!ls) return false
return Math.floor(now.value / 1000) - ls > 15
})
// ---- log/task helpers ----
const kindLabel = (k: string) => {
switch (k) {
case 'forward.add': return '新增转发'
@ -125,7 +237,6 @@ const kindLabel = (k: string) => {
default: return k
}
}
const logKindClass = (k: string) => {
switch (k) {
case 'forward.add': return 'add'
@ -136,54 +247,146 @@ const logKindClass = (k: string) => {
default: return ''
}
}
const logView = computed(() => [...(ring.value?.log ?? [])].reverse())
onMounted(() => { load(); timer = window.setInterval(load, 3000) })
onBeforeUnmount(() => { if (timer !== null) { clearInterval(timer); timer = null } })
const taskClass = (t: RingTaskInfo) => (t.removeNode ? 'remove' : t.revoke ? 'revoke' : 'pending')
const taskCmdLabel = (t: RingTaskInfo) => (t.removeNode ? '移除节点' : t.revoke ? '撤销' : '新增')
// ---- lifecycle actions ----
const onCreate = async () => {
busy.value = true
try {
ring.value = await api.clusterCreate()
ElMessage.success('已创建集群本节点为初始 leader')
} catch (e: any) {
ElMessage.error('创建失败: ' + (e.message || e))
} finally {
busy.value = false
await load()
}
}
const onJoin = async () => {
const addr = joinDialog.addr.trim()
if (!addr) return
busy.value = true
try {
ring.value = await api.clusterJoinRing(addr)
joinDialog.visible = false
ElMessage.success(`已加入 ${addr}`)
} catch (e: any) {
ElMessage.error('加入失败: ' + (e.message || e))
} finally {
busy.value = false
await load()
}
}
const onLeave = async () => {
if (!ring.value) return
if (!window.confirm('退出集群本节点将取消一切集群远程转发并脱离环')) return
busy.value = true
try {
await api.clusterRemoveNode(ring.value.selfId)
ElMessage.success('退出指令已发布')
} catch (e: any) {
ElMessage.error('退出失败: ' + (e.message || e))
} finally {
busy.value = false
await load()
}
}
const onRemoveNode = async (id: string) => {
if (!window.confirm(`移除节点 ${shortId(id)} 出集群?`)) return
busy.value = true
try {
await api.clusterRemoveNode(id)
ElMessage.success('移除指令已发布')
} catch (e: any) {
ElMessage.error('移除失败: ' + (e.message || e))
} finally {
busy.value = false
await load()
}
}
onMounted(() => {
load()
timer = window.setInterval(load, 3000)
tick = window.setInterval(() => { now.value = Date.now() }, 1000)
})
onBeforeUnmount(() => {
if (timer !== null) { clearInterval(timer); timer = null }
if (tick !== null) { clearInterval(tick); tick = null }
})
</script>
<style scoped lang="scss">
.cluster-page { height: 100%; display: flex; flex-direction: column; padding: 20px; overflow-y: auto; }
.cluster-top { display: flex; align-items: baseline; gap: 12px; margin-bottom: 16px; }
.page-title { margin: 0; font-size: 18px; font-weight: 600; }
.page-sub { color: $color-text-muted; font-size: 13px; }
.refresh-btn { margin-left: auto; border: 1px solid $color-border; border-radius: 6px; padding: 3px 12px; background: #fff; cursor: pointer; font-size: 13px; &:hover { background: $color-bg-hover; } }
.section { margin-bottom: 20px; }
.section-title { margin: 0 0 10px; font-size: 15px; font-weight: 600; .hint { font-size: 12px; font-weight: 400; color: $color-text-muted; margin-left: 8px; } }
.ring-state { display: flex; gap: 16px; flex-wrap: wrap; align-items: center; background: #fafafa; border: 1px solid $color-border-light; border-radius: 10px; padding: 10px 16px; }
.rs-item { font-size: 13px; color: $color-text-secondary; b { color: #303133; font-weight: 600; font-size: 14px; } b.leader { color: #409eff; } }
.phase-badge { font-size: 11px; padding: 1px 8px; border-radius: 8px; background: #ecf5ff; color: #409eff; &.sync { background: #f0f9eb; color: $color-success; } }
.ring-line { display: flex; align-items: stretch; gap: 6px; flex-wrap: wrap; }
.ring-node { border: 2px solid $color-border; border-radius: 10px; padding: 8px 12px; min-width: 130px; background: #fff; &.leader { border-color: #409eff; background: #ecf5ff; } &.self { outline: 2px solid #409eff33; box-shadow: 0 0 0 4px #409eff1a; } &.dead { border-color: $color-danger; background: #fef0f0; opacity: .7; } }
.rn-top { display: flex; align-items: center; gap: 6px; }
.rn-id { font-weight: 600; font-size: 13px; }
.rn-self { font-size: 10px; background: #67c23a; color: #fff; border-radius: 8px; padding: 0 6px; }
.rn-leader { font-size: 10px; background: #409eff; color: #fff; border-radius: 8px; padding: 0 6px; }
.rn-dead { font-size: 10px; background: $color-danger; color: #fff; border-radius: 8px; padding: 0 6px; }
.rn-load { font-size: 11px; color: $color-text-muted; margin-top: 4px; }
.rn-addr { font-size: 11px; color: $color-text-muted; }
.rn-role { font-size: 10px; color: $color-success; margin-top: 2px; }
.ring-arrow { align-self: center; color: $color-text-muted; font-size: 16px; }
.cluster-page { height: 100%; display: flex; flex-direction: column; overflow-y: auto; padding: 4px 0 40px; gap: 14px; }
/* hero */
.hero { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; padding: 16px 20px; }
.hero-left { display: flex; flex-direction: column; gap: 4px; }
.page-title { margin: 0; font-size: 18px; font-weight: 800; }
.page-sub { color: $color-text-muted; font-size: 12px; }
.hero-stats { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
.hero-stats .w4f-tag b { color: $color-text-primary; font-weight: 800; }
.crown { color: var(--w4f-primary-h); }
/* KPI */
.kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(168px, 1fr)); gap: 14px; }
/* toolbar */
.toolbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding: 12px 16px; }
.act-hint { font-size: 12px; color: $color-text-muted; }
/* sections */
.cluster-body { display: flex; flex-direction: column; gap: 14px; }
.section { padding: 18px 20px; }
.section-title { margin: 0 0 12px; font-size: 13.5px; font-weight: 700; color: $color-text-muted; letter-spacing: 0.02em; display: flex; align-items: center; gap: 10px;
.k-ic { width: 26px; height: 26px; border-radius: 9px; display: grid; place-items: center; font-size: 13px;
background: linear-gradient(135deg, var(--w4f-primary-50), var(--w4f-secondary-50)); color: var(--w4f-primary-h); }
.hint { font-size: 11px; font-weight: 400; color: $color-text-muted; margin-left: auto; }
}
/* ring topology */
.ring-line { display: flex; align-items: stretch; gap: 8px; flex-wrap: wrap; }
.ring-node { position: relative; border: 1px solid var(--w4f-line); border-radius: $radius-md; padding: 12px 14px; min-width: 168px;
background: var(--w4f-card-2); box-shadow: var(--w4f-sh-sm);
&.leader { border-color: var(--w4f-secondary); background: var(--w4f-secondary-50); }
&.self { outline: 2px solid rgba(255, 127, 172, 0.45); box-shadow: 0 0 0 4px rgba(255, 127, 172, 0.14); border-color: var(--w4f-primary); }
&.dead { border-color: var(--w4f-danger); background: var(--w4f-danger-50); opacity: 0.72; }
}
.rn-top { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.rn-id { font-weight: 800; font-size: 13px; font-family: var(--w4f-mono); }
.rn-load-row { display: flex; align-items: center; gap: 8px; margin-top: 8px; }
.rn-load-label { font-size: 11px; color: $color-text-muted; white-space: nowrap; }
.rn-load-row .w4f-bar { flex: 1; min-width: 50px; }
.rn-addr { font-size: 11px; color: $color-text-muted; font-family: var(--w4f-mono); margin-top: 4px; }
.rn-remove { margin-top: 8px; }
.ring-arrow { align-self: center; color: var(--w4f-primary); font-size: 18px; font-weight: 700; }
/* tasks / topology / log lists */
.task-list, .topo-list, .log-list { display: flex; flex-direction: column; gap: 6px; }
.task-row, .topo-row { display: flex; align-items: center; gap: 8px; border: 1px solid $color-border-lighter; border-radius: 8px; padding: 6px 10px; font-size: 13px; }
.task-row.pending { background: #ecf5ff; }
.task-row.revoke { background: #fef0f0; }
.tk-cmd { font-size: 11px; padding: 1px 8px; border-radius: 8px; background: #409eff; color: #fff; }
.task-row.revoke .tk-cmd { background: $color-danger; }
.tk-id, .tp-id { font-weight: 600; color: $color-text-secondary; font-size: 12px; }
.tk-name, .tp-name { font-weight: 500; }
.tk-arrow { color: $color-text-muted; }
.tk-port, .tp-port { color: $color-text-muted; }
.tp-owner { margin-left: auto; font-size: 11px; color: #409eff; &.self { color: $color-success; font-weight: 600; } }
.tp-state { font-size: 11px; padding: 1px 8px; border-radius: 8px; background: #f0f9eb; color: $color-success; &:not(.ok) { background: #fef0f0; color: $color-danger; } }
.log-row { display: flex; gap: 10px; font-size: 12px; font-family: monospace; border-bottom: 1px dashed $color-border-lighter; padding: 3px 2px; }
.log-row.add .lg-kind { color: $color-success; }
.log-row.remove .lg-kind { color: $color-danger; }
.log-row.join .lg-kind { color: #909399; }
.log-row.leader .lg-kind { color: #409eff; }
.task-row, .topo-row { display: flex; align-items: center; gap: 8px; border: 1px solid var(--w4f-line); border-radius: $radius-xs; padding: 8px 11px; font-size: 13px; background: var(--w4f-card-2); box-shadow: var(--w4f-sh-sm); }
.task-row.pending { background: var(--w4f-primary-50); border-color: rgba(255, 127, 172, 0.25); }
.task-row.revoke, .task-row.remove { background: var(--w4f-danger-50); border-color: rgba(219, 54, 148, 0.25); }
.tk-id, .tp-id { font-weight: 700; color: $color-text-secondary; font-size: 12px; font-family: var(--w4f-mono); }
.tk-name, .tp-name { font-weight: 600; }
.tk-arrow { color: var(--w4f-primary); font-weight: 700; }
.tk-port, .tp-port { color: $color-text-muted; font-family: var(--w4f-mono); }
.tp-owner { margin-left: auto; font-size: 11px; color: $color-secondary-h; &.self { color: var(--w4f-primary-h); font-weight: 700; } }
.log-row { display: flex; gap: 10px; font-size: 12px; font-family: var(--w4f-mono); border-bottom: 1px dashed var(--w4f-line); padding: 3px 2px; }
.log-row.add .lg-kind { color: $color-success; font-weight: 700; }
.log-row.remove .lg-kind { color: $color-danger; font-weight: 700; }
.log-row.join .lg-kind { color: $color-info; font-weight: 700; }
.log-row.leave .lg-kind { color: $color-warning; font-weight: 700; }
.log-row.leader .lg-kind { color: $color-secondary-h; font-weight: 700; }
.lg-seq { color: $color-text-muted; }
.lg-node { color: #777; &.self { color: #409eff; font-weight: 600; } }
.lg-node { color: $color-text-light; &.self { color: var(--w4f-primary-h); font-weight: 700; } }
.lg-kind { color: $color-text-secondary; }
.empty { color: $color-text-muted; font-size: 13px; padding: 8px; }
</style>
.empty { color: $color-text-muted; font-size: 13px; padding: 10px; }
.empty-page { color: $color-text-muted; font-size: 14px; padding: 40px; text-align: center; }
/* dialog form */
.form-row { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; label { width: 72px; font-size: 13px; color: $color-text-secondary; flex-shrink: 0; } }
.form-hint { font-size: 12px; color: $color-text-muted; }
</style>

View File

@ -1,94 +1,123 @@
<template>
<div class="status-page">
<div class="status-top">
<h2 class="page-title">状态</h2>
<span class="refresh-hint"> 5 秒自动刷新</span>
<button class="refresh-btn" @click="loadStatus">刷新</button>
<button class="add-btn" @click="openAdd"> 添加远程节点</button>
<!-- top bar -->
<div class="topbar w4f-card">
<div class="tb-left">
<h2 class="page-title">状态总览</h2>
<span class="page-sub"> 5 秒自动刷新 · frpc worker 进程态 + frps 连接态</span>
</div>
<div class="tb-right">
<button class="w4f-btn ghost small" @click="loadStatus"> 刷新</button>
<button class="w4f-btn small" @click="openAdd"> 添加远程节点</button>
</div>
</div>
<!-- KPI grid -->
<div class="kpis" v-if="status">
<div class="w4f-kpi">
<div class="k-hdr"><span class="k-lab"><span class="w4f-dot" :class="{ err: !allConnected }" />远程节点</span><span class="k-ic"></span></div>
<div class="k-val">{{ status.profiles.length }}</div>
<div class="k-sub">在线 {{ onlineCount }} · 离线 {{ status.profiles.length - onlineCount }}</div>
</div>
<div class="w4f-kpi">
<div class="k-hdr"><span class="k-lab"><span class="w4f-dot" :class="{ err: runningCount === 0 && status.profiles.length > 0 }" />worker 进程</span><span class="k-ic"></span></div>
<div class="k-val">{{ runningCount }}</div>
<div class="k-sub">运行中 frpc 进程数</div>
</div>
<div class="w4f-kpi">
<div class="k-hdr"><span class="k-lab"><span class="w4f-dot" />本地服务</span><span class="k-ic"></span></div>
<div class="k-val">{{ status.localStatus.length }}</div>
<div class="k-sub">已配置本地服务</div>
</div>
<div class="w4f-kpi">
<div class="k-hdr"><span class="k-lab"><span class="w4f-dot" :class="{ pending: activeForwards > 0 }" />活跃转发</span><span class="k-ic"></span></div>
<div class="k-val">{{ activeForwards }}</div>
<div class="k-sub">已编排转发链路</div>
</div>
</div>
<div class="status-body" v-if="status">
<!-- 远程节点状态 -->
<section class="status-section">
<h3 class="status-title">远程节点状态</h3>
<div class="status-cards">
<section class="section">
<h3 class="section-title"><span class="k-ic"></span>远程节点状态<span class="hint">管理本地 frpc worker 进程不管理远程 frps</span></h3>
<div class="card-grid">
<div
v-for="p in status.profiles"
:key="p.name"
class="node-status-card"
class="node-card w4f-card"
:class="{ healthy: workerHealthy(p.process.state) }"
>
<div class="ns-head">
<span class="ns-name">{{ p.name }}</span>
<span class="ns-badge" :class="connBadgeClass(p.connState)">
{{ connStateLabel(p.connState) }}
<div class="nc-head">
<span class="nc-name">{{ p.name }}</span>
<span class="w4f-tag" :class="connTagClass(p.connState)">
<span class="w4f-dot" :class="connDotClass(p.connState)" />{{ connStateLabel(p.connState) }}
</span>
<span class="ns-actions" title="控制本地 frpc worker 进程(不管理远程 frps">
<button v-if="p.process.state === 'running'" class="mini-btn" @click="stopRemote(p.name)">本地worker</button>
<button v-else class="mini-btn" @click="startRemote(p.name)">本地worker</button>
<button class="mini-btn" @click="openEdit(p)">编辑</button>
<button class="mini-btn danger" @click="removeRemote(p.name)">删除</button>
<span class="nc-actions">
<button v-if="p.process.state === 'running'" class="w4f-btn ghost small" @click="stopRemote(p.name)"> worker</button>
<button v-else class="w4f-btn small" @click="startRemote(p.name)"> worker</button>
<button class="w4f-btn ghost small" @click="openEdit(p)">编辑</button>
<button class="w4f-btn danger small" @click="removeRemote(p.name)">删除</button>
</span>
</div>
<div class="ns-meta">
<span v-if="p.process.pid">pid {{ p.process.pid }}</span>
<span class="ns-err" v-else-if="p.process.err">{{ p.process.err }}</span>
<span v-if="remoteVhostPort(p.name)" class="ns-vhost">
HTTP 访问端口 :{{ remoteVhostPort(p.name) }}
</span>
<div class="nc-meta">
<span v-if="p.process.pid" class="meta-pill">pid {{ p.process.pid }}</span>
<span v-if="p.process.restartCount" class="meta-pill warn">重启 {{ p.process.restartCount }}</span>
<span v-if="p.process.err" class="meta-pill err" :title="p.process.err">{{ p.process.err }}</span>
<span v-if="remoteVhostPort(p.name)" class="meta-pill info">HTTP :{{ remoteVhostPort(p.name) }}</span>
</div>
<div class="ns-forwards" v-if="p.forwards.length">
<span v-for="f in p.forwards" :key="f.service" class="ns-fwd">
<div class="nc-forwards" v-if="p.forwards.length">
<span v-for="f in p.forwards" :key="f.service" class="w4f-tag w4f-tag-primary">
{{ f.service }} :{{ f.remotePort }}
</span>
</div>
<div v-if="p.groupCount" class="ns-groups">
<span class="ns-group-badge">LB ×{{ p.groupCount }}</span>
<div v-if="p.groupCount" class="nc-groups">
<span class="w4f-tag w4f-tag-info">LB ×{{ p.groupCount }}</span>
</div>
<div v-if="p.proxyStates?.length" class="ns-proxies">
<div v-if="p.proxyStates?.length" class="nc-proxies">
<div
v-for="ps in p.proxyStates"
:key="ps.name"
class="ns-proxy"
class="nc-proxy"
:class="proxyStateClass(ps.status)"
>
<span class="np-name">{{ ps.name }}</span>
<span class="np-status">{{ proxyStateLabel(ps.status) }}</span>
<div class="w4f-bar" :class="proxyBarClass(ps.status)"><i :style="{ width: proxyBarWidth(ps.status) }" /></div>
<span v-if="ps.remote_addr" class="np-addr">{{ ps.remote_addr }}</span>
<span v-if="ps.err" class="np-err" :title="ps.err">{{ ps.err }}</span>
</div>
</div>
<div v-else-if="p.adminEnabled" class="ns-proxies-empty">
<div v-else-if="p.adminEnabled" class="nc-proxies-empty">
管理 API 未返回状态frpc 未连上远程 frps 或无代理
</div>
</div>
<div v-if="!status.profiles.length" class="ns-empty">尚未配置远程节点</div>
<div v-if="!status.profiles.length" class="empty">尚未配置远程节点 点击右上 添加远程节点</div>
</div>
</section>
<!-- 本地服务转发状态 -->
<section class="status-section">
<h3 class="status-title">本地服务转发</h3>
<div class="local-status-list">
<div v-for="ls in status.localStatus" :key="ls.local.name" class="local-status-card">
<div class="ls-head">
<span class="ls-name">{{ ls.local.name }}</span>
<span class="ls-addr">{{ ls.local.ip }}:{{ ls.local.port }}</span>
<span class="ls-proto">{{ ls.local.protocol }}</span>
<section class="section">
<h3 class="section-title"><span class="k-ic"></span>本地服务转发<span class="hint">本地服务经 frpc worker 转发至远程 frps</span></h3>
<div class="card-grid">
<div v-for="ls in status.localStatus" :key="ls.local.name" class="local-card w4f-card">
<div class="lc-head">
<span class="lc-name">{{ ls.local.name }}</span>
<span class="lc-addr">{{ ls.local.ip }}:{{ ls.local.port }}</span>
<span class="w4f-tag w4f-tag-primary">{{ ls.local.protocol }}</span>
</div>
<div class="ls-targets">
<div v-for="t in ls.targets" :key="t.remote + ':' + t.remotePort" class="ls-target">
<span class="ls-arrow"></span>
<span class="ls-remote">{{ t.remote }}</span>
<span class="ls-port">:{{ t.remotePort }}</span>
<span class="ls-state" :class="{ ok: workerHealthy(t.workerState) }">
<div class="lc-targets">
<div v-for="t in ls.targets" :key="t.remote + ':' + t.remotePort" class="lc-target">
<span class="lc-arrow"></span>
<span class="lc-remote">{{ t.remote }}</span>
<span class="lc-port">:{{ t.remotePort }}</span>
<span class="w4f-tag" :class="workerHealthy(t.workerState) ? 'w4f-tag-ok' : 'w4f-tag-danger'">
{{ localWorkerLabel(t.workerState) }}
</span>
</div>
<div v-if="!ls.targets.length" class="ls-none">未转发</div>
<div v-if="!ls.targets.length" class="lc-none">未转发</div>
</div>
</div>
<div v-if="!status.localStatus.length" class="ls-empty">尚未配置本地服务</div>
<div v-if="!status.localStatus.length" class="empty">尚未配置本地服务 前往连接配置画布编排</div>
</div>
</section>
</div>
@ -119,15 +148,15 @@
<el-switch v-model="dialog.remote.enabled" />
</div>
<template #footer>
<button class="refresh-btn" @click="dialog.visible = false">取消</button>
<button class="add-btn" @click="saveRemote">保存</button>
<button class="w4f-btn ghost small" @click="dialog.visible = false">取消</button>
<button class="w4f-btn small" @click="saveRemote">保存</button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, onBeforeUnmount } from 'vue'
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
import { ElMessage } from 'element-plus'
import { api } from '../api'
import type { Remote, StatusResp } from '../types'
@ -223,87 +252,77 @@ const loadStatus = async () => {
}
}
// ---- KPI derivations ----
const workerHealthy = (s?: string) => s === 'running'
const runningCount = computed(() => status.value?.profiles.filter((p) => p.process.state === 'running').length ?? 0)
const onlineCount = computed(() => status.value?.profiles.filter((p) => p.connState === 'connected').length ?? 0)
const allConnected = computed(() => (status.value?.profiles.length ?? 0) > 0 && onlineCount.value === status.value?.profiles.length)
const activeForwards = computed(() => status.value?.profiles.reduce((s, p) => s + p.forwards.length, 0) ?? 0)
// connState refers to the frps connection from the local frpc worker.
// We never control the remote frps itself.
const connStateLabel = (s?: string) => {
switch (s) {
case 'connected':
return '连接'
case 'connecting':
return '连接中'
case 'failed':
return '连接失败'
case 'disabled':
return '已停用'
case 'connected': return '已连接'
case 'connecting': return '连接'
case 'failed': return '连接失败'
case 'disabled': return '已停用'
case 'not_started':
default:
return '未启动'
default: return '未启动'
}
}
const connBadgeClass = (s?: string) => {
const connTagClass = (s?: string) => {
switch (s) {
case 'connected':
return 'ok'
case 'connecting':
return 'pending'
case 'failed':
return 'err'
case 'connected': return 'w4f-tag-ok'
case 'connecting': return 'w4f-tag-info'
case 'failed': return 'w4f-tag-danger'
case 'disabled':
case 'not_started':
default:
return ''
default: return 'w4f-tag-warn'
}
}
const connDotClass = (s?: string) => {
switch (s) {
case 'connected': return ''
case 'connecting': return 'pending'
case 'failed': return 'err'
default: return ''
}
}
const workerHealthy = (s?: string) => s === 'running'
// M3: per-proxy state from frpc admin API.
const proxyStateLabel = (s?: string) => {
switch (s) {
case 'running':
return '运行中'
case 'wait start':
return '等待启动'
case 'start error':
return '启动错误'
case 'check failed':
return '健康检查失败'
case 'closed':
return '已关闭'
case 'running': return '运行中'
case 'wait start': return '等待启动'
case 'start error': return '启动错误'
case 'check failed': return '健康检查失败'
case 'closed': return '已关闭'
case 'new':
default:
return '新建'
default: return '新建'
}
}
const proxyStateClass = (s?: string) => {
switch (s) {
case 'running':
return 'ok'
case 'running': return 'ok'
case 'check failed':
case 'start error':
return 'err'
case 'wait start':
return 'pending'
default:
return ''
case 'start error': return 'err'
case 'wait start': return 'pending'
default: return ''
}
}
const proxyBarClass = (s?: string) => (s === 'running' ? 'ok' : s === 'check failed' || s === 'start error' ? 'err' : '')
const proxyBarWidth = (s?: string) => (s === 'running' ? '100%' : s === 'wait start' ? '45%' : s === 'new' ? '15%' : '30%')
// localWorkerLabel: state of the LOCAL frpc worker driving this target
// (we do not control the remote frps).
const localWorkerLabel = (s?: string) => {
switch (s) {
case 'running':
return '运行中'
case 'starting':
return '启动中'
case 'restarting':
return '重启中'
case 'crashed':
return '异常'
default:
return '未启动'
case 'running': return '运行中'
case 'starting': return '启动中'
case 'restarting': return '重启中'
case 'crashed': return '异常'
default: return '未启动'
}
}
@ -325,352 +344,114 @@ onBeforeUnmount(() => {
</script>
<style scoped lang="scss">
.add-btn {
margin-left: auto;
border: none;
border-radius: 6px;
padding: 5px 14px;
background: $color-btn-primary;
color: #fff;
cursor: pointer;
font-size: 13px;
&:hover {
background: $color-btn-primary-hover;
}
}
.form-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
label {
width: 72px;
font-size: 13px;
color: $color-text-secondary;
flex-shrink: 0;
}
}
.mini-btn {
border: 1px solid $color-border;
border-radius: 5px;
padding: 1px 8px;
background: #fff;
font-size: 12px;
cursor: pointer;
color: $color-text-secondary;
&:hover {
background: $color-bg-hover;
}
&.danger:hover {
color: $color-danger;
}
}
.ns-actions {
display: flex;
gap: 4px;
}
.status-page {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
gap: 14px;
}
.status-top {
/* top bar */
.topbar {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 20px;
border-bottom: 1px solid $color-border-light;
background: #fff;
padding: 14px 18px;
}
.tb-left { display: flex; flex-direction: column; gap: 3px; }
.page-title { margin: 0; font-size: 17px; font-weight: 800; }
.page-sub { font-size: 12px; color: $color-text-muted; }
.tb-right { display: flex; gap: 8px; }
.page-title {
margin: 0;
font-size: 18px;
font-weight: 600;
}
.refresh-hint {
font-size: 12px;
color: $color-text-muted;
}
.refresh-btn {
border: 1px solid $color-border;
border-radius: 6px;
padding: 3px 12px;
background: #fff;
cursor: pointer;
font-size: 13px;
&:hover {
background: $color-bg-hover;
}
/* KPI grid */
.kpis {
flex-shrink: 0;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(168px, 1fr));
gap: 14px;
}
.status-body {
flex: 1;
display: flex;
gap: 20px;
padding: 20px;
overflow: hidden;
}
.status-section {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.status-title {
margin: 0 0 12px;
font-size: 15px;
font-weight: 600;
}
.status-cards,
.local-status-list {
flex: 1;
display: flex;
flex-direction: column;
gap: 10px;
gap: 18px;
overflow-y: auto;
padding-right: 6px;
padding-right: 4px;
}
.node-status-card {
border: 1px solid $color-border-light;
border-radius: 10px;
padding: 10px 14px;
background: #fafafa;
border-left: 4px solid $color-danger;
&.healthy {
border-left-color: $color-success;
}
}
.ns-head {
.section { display: flex; flex-direction: column; }
.section-title {
margin: 0 0 10px;
font-size: 13.5px;
font-weight: 700;
color: $color-text-muted;
letter-spacing: 0.02em;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
.k-ic { width: 26px; height: 26px; border-radius: 9px; display: grid; place-items: center; font-size: 13px;
background: linear-gradient(135deg, var(--w4f-primary-50), var(--w4f-secondary-50)); color: var(--w4f-primary-h); }
.hint { font-size: 11px; font-weight: 400; color: $color-text-muted; margin-left: auto; }
}
.ns-name {
font-weight: 600;
margin-right: 8px;
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 14px;
}
.ns-badge {
font-size: 12px;
padding: 1px 8px;
border-radius: 8px;
background: #fef0f0;
color: $color-danger;
&.ok {
background: #f0f9eb;
color: $color-success;
}
&.pending {
background: #ecf5ff;
color: #409eff;
}
&.err {
background: #fef0f0;
color: $color-danger;
}
/* remote node card */
.node-card {
padding: 16px 18px;
border-left: 4px solid var(--w4f-danger);
&.healthy { border-left-color: var(--w4f-ok); }
}
.ns-meta {
font-size: 12px;
color: $color-text-muted;
margin-top: 2px;
.nc-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.nc-name { font-weight: 800; font-size: 15px; margin-right: auto; }
.nc-actions { display: flex; gap: 6px; flex-wrap: wrap; }
.nc-meta { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
.meta-pill { font-size: 11.5px; padding: 2px 9px; border-radius: $radius-pill; background: var(--w4f-card-2); color: $color-text-muted;
font-family: var(--w4f-mono); max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
&.warn { background: $color-warning-50; color: $color-warning; }
&.err { background: $color-danger-50; color: $color-danger; }
&.info { background: $color-secondary-50; color: $color-secondary-h; }
}
.ns-err {
color: $color-danger;
.nc-forwards { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
.nc-groups { margin-top: 6px; }
.nc-proxies { display: flex; flex-direction: column; gap: 6px; margin-top: 10px; }
.nc-proxy { display: flex; align-items: center; gap: 8px; font-size: 12px; padding: 6px 10px; border-radius: $radius-xs;
background: var(--w4f-card-2); border: 1px solid var(--w4f-line);
&.ok { border-left: 3px solid $color-success; }
&.pending { border-left: 3px solid $color-secondary; }
&.err { border-left: 3px solid $color-danger; }
.w4f-bar { width: 60px; }
}
.np-name { font-weight: 700; }
.np-status { font-size: 11px; color: $color-text-secondary; }
.np-addr { margin-left: auto; font-size: 11px; color: $color-text-muted; font-family: var(--w4f-mono); }
.np-err { margin-left: auto; font-size: 11px; color: $color-danger; max-width: 160px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.nc-proxies-empty { margin-top: 8px; font-size: 12px; color: $color-text-muted; }
.ns-vhost {
margin-left: 8px;
background: #ecf5ff;
color: #409eff;
border-radius: 6px;
padding: 1px 8px;
font-size: 12px;
}
/* local service card */
.local-card { padding: 14px 16px; }
.lc-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.lc-name { font-weight: 800; font-size: 14px; }
.lc-addr { font-size: 12px; color: $color-text-secondary; font-family: var(--w4f-mono); }
.lc-targets { margin-top: 10px; display: flex; flex-direction: column; gap: 6px; }
.lc-target { display: flex; align-items: center; gap: 6px; font-size: 13px; }
.lc-arrow { color: var(--w4f-primary); font-weight: 700; }
.lc-remote { font-weight: 600; }
.lc-port { color: $color-text-secondary; font-family: var(--w4f-mono); margin-right: auto; }
.lc-none { font-size: 12px; color: $color-text-light; }
.ns-forwards {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 6px;
}
.empty { grid-column: 1 / -1; color: $color-text-muted; font-size: 13px; padding: 18px; text-align: center; }
.ns-groups {
display: flex;
gap: 4px;
margin-top: 6px;
}
.ns-group-badge {
font-size: 11px;
background: rgba(64, 158, 255, 0.12);
color: #409eff;
border-radius: 10px;
padding: 1px 8px;
}
.ns-proxies {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 6px;
}
.ns-proxy {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
border: 1px solid $color-border-lighter;
border-radius: 8px;
padding: 3px 8px;
background: #fff;
&.ok {
border-left: 3px solid $color-success;
}
&.pending {
border-left: 3px solid #409eff;
}
&.err {
border-left: 3px solid $color-danger;
}
}
.np-name {
font-weight: 600;
}
.np-status {
font-size: 11px;
color: $color-text-secondary;
}
.np-addr {
margin-left: auto;
font-size: 11px;
color: $color-text-muted;
}
.np-err {
margin-left: auto;
font-size: 11px;
color: $color-danger;
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ns-proxies-empty {
margin-top: 6px;
font-size: 12px;
color: $color-text-muted;
}
.ns-empty,
.ls-empty {
color: $color-text-muted;
font-size: 13px;
padding: 8px;
}
.local-status-card {
border: 1px solid $color-border-light;
border-radius: 10px;
padding: 10px 14px;
background: #fafafa;
}
.ls-head {
display: flex;
align-items: center;
gap: 8px;
}
.ls-name {
font-weight: 600;
}
.ls-addr {
font-size: 12px;
color: $color-text-secondary;
}
.ls-proto {
font-size: 11px;
background: #ecf5ff;
color: #409eff;
border-radius: 6px;
padding: 1px 6px;
}
.ls-targets {
margin-top: 6px;
display: flex;
flex-direction: column;
gap: 4px;
}
.ls-target {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
}
.ls-arrow {
color: $color-text-muted;
}
.ls-remote {
font-weight: 500;
}
.ls-port {
color: $color-text-secondary;
}
.ls-state {
margin-left: auto;
font-size: 12px;
color: $color-danger;
&.ok {
color: $color-success;
}
}
.ls-none {
color: $color-text-light;
font-size: 12px;
/* dialog form */
.form-row { display: flex; align-items: center; gap: 10px; margin-bottom: 12px;
label { width: 72px; font-size: 13px; color: $color-text-secondary; flex-shrink: 0; }
}
</style>