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

@ -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.