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

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.