mirror of
https://gitcode.com/JianFeeeee/webui4frpc.git
synced 2026-09-21 17:37:56 +00:00
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:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
4
internal/httpapi/dist/index.html
vendored
4
internal/httpapi/dist/index.html
vendored
@ -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>
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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.
|
||||
|
||||
Reference in New Issue
Block a user