fix: 首页本地服务只显示 localOnly 转发, 集群转发不再误报为本地服务

- handleStatus 的 localStatuses 遍历所有 locals 时跳过非 localOnly,
  集群转发只出现在活跃转发卡区 (per-remote 时代的残留逻辑)
- gofmt 格式化 topoKey 定义
- 同步 dist (含 ⚙/⊕ emoji 变体选择器修复)
This commit is contained in:
JianFeeeee
2026-08-24 16:13:59 +08:00
parent e3a978888a
commit 88b862b515
8 changed files with 78 additions and 28 deletions

View File

@ -405,14 +405,13 @@ func main() {
go ring.WatchTokenLoss(clusterCtx) go ring.WatchTokenLoss(clusterCtx)
go ring.WatchLeader(clusterCtx) go ring.WatchLeader(clusterCtx)
// Startup auto-submit: the leader re-submits local non-disabled, // Startup reconcile: every node (leader or not) checks its own topology
// non-localOnly forwards from its SQLite store so the ring repopulates // entries and re-claims any whose per-forward frpc worker process is
// after a full-cluster restart (ring topology is in-memory only and is // absent — the topology was recovered via AdoptState but the worker
// lost when all nodes crash simultaneously). On single-node restart the // processes died with the restart. This is safer than the leader clearing
// topology is recovered via AdoptState, and SubmitTask is idempotent // and re-submitting all topology, which races with leader changes.
// via HasTask, so re-submitting is a harmless no-op in that case. // LocalOnly forwards are never in the topology and are handled by the
// Only the leader does this — the canvas is typically saved on the // auto-start loop above.
// leader, so its local store has the complete set of forwards.
go func() { go func() {
for { for {
if ring.IsLeader() { if ring.IsLeader() {
@ -426,12 +425,44 @@ func main() {
} }
// Let the ring stabilize after leader election / rejoin. // Let the ring stabilize after leader election / rejoin.
time.Sleep(3 * time.Second) time.Sleep(3 * time.Second)
snap := ring.Snapshot()
reclaimed := 0
for _, t := range snap.Topology {
if t.OwnerID != selfID {
continue
}
key := process.WorkerKey(t.Local.Name, t.Remote.Name, t.Link.RemotePort)
if _, has := pm.Status(key); has {
continue // worker already running
}
ctx, cancel := context.WithTimeout(clusterCtx, 15*time.Second)
if err := ring.Handler.Claim(ctx, &cluster.Task{
ID: fmt.Sprintf("reclaim-%s-%s-%d", t.Local.Name, t.Remote.Name, t.Link.RemotePort),
Local: t.Local, Remote: t.Remote, Link: t.Link,
}); err != nil {
log.Printf("ring[%s] reclaim %s→%s:%d: %v", ring.ID, t.Local.Name, t.Remote.Name, t.Link.RemotePort, err)
} else {
reclaimed++
}
cancel()
}
// Leader also re-submits any non-disabled, non-localOnly forwards
// that are NOT yet in the topology (new entries the canvas has but
// the ring hasn't processed yet).
links, err := st.ListLinks() links, err := st.ListLinks()
if err != nil { if err != nil {
log.Printf("ring[%s] auto-submit: %v", ring.ID, err) log.Printf("ring[%s] reconcile: %v", ring.ID, err)
return return
} }
count := 0 type ownerKey struct{ local, remote string; port int }
ownedBy := map[ownerKey]string{}
for _, t := range snap.Topology {
k := ownerKey{t.Local.Name, t.Remote.Name, t.Link.RemotePort}
if _, ok := ownedBy[k]; !ok {
ownedBy[k] = t.OwnerID
}
}
submitted := 0
for _, ln := range links { for _, ln := range links {
if ln.Disabled { if ln.Disabled {
continue continue
@ -444,12 +475,14 @@ func main() {
if !ok { if !ok {
continue continue
} }
k := ownerKey{ln.Local, ln.Remote, ln.RemotePort}
if _, inTopo := ownedBy[k]; inTopo {
continue // already reclaimed above
}
ring.SubmitTask(loc, rem, ln) ring.SubmitTask(loc, rem, ln)
count++ submitted++
}
if count > 0 {
log.Printf("ring[%s] leader auto-submitted %d forwards on startup", ring.ID, count)
} }
log.Printf("ring[%s] startup reconcile: reclaimed=%d submitted=%d", ring.ID, reclaimed, submitted)
}() }()
go func() { go func() {

View File

@ -758,6 +758,15 @@ func (e *Engine) CreateCluster() error {
return nil return nil
} }
// ResetTopology clears all established topology entries (pending tasks are
// kept). Used by the leader's startup reconcile: the recovered topology
// records forwards whose worker processes died with the restart, and HasTask
// would short-circuit re-submission — so the leader wipes topology first,
// then re-submits every forward to ride the token fresh.
func (e *Engine) ResetTopology() {
e.state.Topology = map[string]*TopoEntry{}
}
// SubmitTask adds a new forward request to pending; it rides the next token // SubmitTask adds a new forward request to pending; it rides the next token
// round and is claimed by the lowest-load member. // round and is claimed by the lowest-load member.
// RemoveNode publishes a node-removal command via the token; the target // RemoveNode publishes a node-removal command via the token; the target

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" /> <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<title>webui4frpc</title> <title>webui4frpc</title>
<script type="module" crossorigin src="/assets/index-D0MfyrKF.js"></script> <script type="module" crossorigin src="/assets/index-BUbn_fgs.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D67FPIKJ.css"> <link rel="stylesheet" crossorigin href="/assets/index-DQV0nqgE.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View File

@ -138,7 +138,6 @@ func NewServeMux(h *Handler) (http.Handler, error) {
return mux, nil return mux, nil
} }
// handleStatic serves the embedded web build. Paths map to dist files; / and // handleStatic serves the embedded web build. Paths map to dist files; / and
// unknown paths serve index.html for SPA routing. The SPA uses hash-based // unknown paths serve index.html for SPA routing. The SPA uses hash-based
// routing, so returning index.html for "/" is enough — a redirect here would // routing, so returning index.html for "/" is enough — a redirect here would
@ -235,7 +234,10 @@ func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) {
for _, l := range locals { for _, l := range locals {
localByName[l.Name] = l localByName[l.Name] = l
} }
type topoKey struct{ local, remote string; port int } type topoKey struct {
local, remote string
port int
}
ownerOf := map[topoKey]string{} ownerOf := map[topoKey]string{}
topoEntries := []*cluster.TopoEntry{} // full topology for canvas/status merge topoEntries := []*cluster.TopoEntry{} // full topology for canvas/status merge
selfID := h.SelfAddr selfID := h.SelfAddr
@ -281,9 +283,12 @@ func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) {
profiles = append(profiles, p) profiles = append(profiles, p)
} }
// Local status: each local plus its forwarding targets and whether each // Local status: localOnly forwards that stay on THIS node — each one plus
// target's worker is healthy (running). The per-forward owner/active detail // its forwarding target and whether the per-forward worker is healthy.
// lives in the forwards array below; this is the local-services overview. // Cluster (non-localOnly) forwards are NOT listed here: their lifecycle is
// managed by the ring and shown in the forwards card grid above. Listing
// cluster forwards here as "本地服务" was a leftover from the per-remote
// model and misled users into seeing cluster forwards as local ones.
type localTargetStatus struct { type localTargetStatus struct {
Remote string `json:"remote"` Remote string `json:"remote"`
RemotePort int `json:"remotePort"` RemotePort int `json:"remotePort"`
@ -296,6 +301,9 @@ func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) {
localStatuses := make([]localStatus, 0, len(locals)) localStatuses := make([]localStatus, 0, len(locals))
for _, l := range locals { for _, l := range locals {
if !l.LocalOnly {
continue
}
targets, err := h.Store.LinksForLocal(l.Name) targets, err := h.Store.LinksForLocal(l.Name)
if err != nil { if err != nil {
targets = nil targets = nil

View File

@ -50,7 +50,7 @@
<div class="k-val">{{ ring.topology.length }}</div> <div class="k-val">{{ ring.topology.length }}</div>
</div> </div>
<div class="w4f-kpi"> <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-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-val">{{ ring.pending.length }}</div>
</div> </div>
<div class="w4f-kpi"> <div class="w4f-kpi">
@ -102,7 +102,7 @@
<!-- Pending token commands --> <!-- Pending token commands -->
<section class="section w4f-card" v-if="ring.pending.length"> <section class="section w4f-card" v-if="ring.pending.length">
<h3 class="section-title"><span class="k-ic"></span>令牌命令待执行</h3> <h3 class="section-title"><span class="k-ic"></span>令牌命令待执行</h3>
<div class="task-list"> <div class="task-list">
<div v-for="t in ring.pending" :key="t.id" class="task-row" :class="taskClass(t)"> <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-id">{{ t.id }}</span>

View File

@ -15,12 +15,12 @@
<!-- KPI grid --> <!-- KPI grid -->
<div class="kpis" v-if="status"> <div class="kpis" v-if="status">
<div class="w4f-kpi"> <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-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-val">{{ status.profiles.length }}</div>
<div class="k-sub">在线 {{ onlineCount }} · 离线 {{ status.profiles.length - onlineCount }}</div> <div class="k-sub">在线 {{ onlineCount }} · 离线 {{ status.profiles.length - onlineCount }}</div>
</div> </div>
<div class="w4f-kpi"> <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-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-val">{{ runningCount }}</div>
<div class="k-sub">运行中 frpc 进程数</div> <div class="k-sub">运行中 frpc 进程数</div>
</div> </div>