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.WatchLeader(clusterCtx)
// Startup auto-submit: the leader re-submits local non-disabled,
// non-localOnly forwards from its SQLite store so the ring repopulates
// after a full-cluster restart (ring topology is in-memory only and is
// lost when all nodes crash simultaneously). On single-node restart the
// topology is recovered via AdoptState, and SubmitTask is idempotent
// via HasTask, so re-submitting is a harmless no-op in that case.
// Only the leader does this — the canvas is typically saved on the
// leader, so its local store has the complete set of forwards.
// Startup reconcile: every node (leader or not) checks its own topology
// entries and re-claims any whose per-forward frpc worker process is
// absent — the topology was recovered via AdoptState but the worker
// processes died with the restart. This is safer than the leader clearing
// and re-submitting all topology, which races with leader changes.
// LocalOnly forwards are never in the topology and are handled by the
// auto-start loop above.
go func() {
for {
if ring.IsLeader() {
@ -426,12 +425,44 @@ func main() {
}
// Let the ring stabilize after leader election / rejoin.
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()
if err != nil {
log.Printf("ring[%s] auto-submit: %v", ring.ID, err)
log.Printf("ring[%s] reconcile: %v", ring.ID, err)
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 {
if ln.Disabled {
continue
@ -444,12 +475,14 @@ func main() {
if !ok {
continue
}
k := ownerKey{ln.Local, ln.Remote, ln.RemotePort}
if _, inTopo := ownedBy[k]; inTopo {
continue // already reclaimed above
}
ring.SubmitTask(loc, rem, ln)
count++
}
if count > 0 {
log.Printf("ring[%s] leader auto-submitted %d forwards on startup", ring.ID, count)
submitted++
}
log.Printf("ring[%s] startup reconcile: reclaimed=%d submitted=%d", ring.ID, reclaimed, submitted)
}()
go func() {

View File

@ -758,6 +758,15 @@ func (e *Engine) CreateCluster() error {
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
// round and is claimed by the lowest-load member.
// 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" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<title>webui4frpc</title>
<script type="module" crossorigin src="/assets/index-D0MfyrKF.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D67FPIKJ.css">
<script type="module" crossorigin src="/assets/index-BUbn_fgs.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DQV0nqgE.css">
</head>
<body>
<div id="app"></div>

View File

@ -138,7 +138,6 @@ func NewServeMux(h *Handler) (http.Handler, error) {
return mux, nil
}
// 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
// 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 {
localByName[l.Name] = l
}
type topoKey struct{ local, remote string; port int }
type topoKey struct {
local, remote string
port int
}
ownerOf := map[topoKey]string{}
topoEntries := []*cluster.TopoEntry{} // full topology for canvas/status merge
selfID := h.SelfAddr
@ -281,9 +283,12 @@ func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) {
profiles = append(profiles, p)
}
// Local status: each local plus its forwarding targets and whether each
// target's worker is healthy (running). The per-forward owner/active detail
// lives in the forwards array below; this is the local-services overview.
// Local status: localOnly forwards that stay on THIS node — each one plus
// its forwarding target and whether the per-forward worker is healthy.
// 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 {
Remote string `json:"remote"`
RemotePort int `json:"remotePort"`
@ -296,6 +301,9 @@ func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) {
localStatuses := make([]localStatus, 0, len(locals))
for _, l := range locals {
if !l.LocalOnly {
continue
}
targets, err := h.Store.LinksForLocal(l.Name)
if err != nil {
targets = nil

View File

@ -50,7 +50,7 @@
<div class="k-val">{{ ring.topology.length }}</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-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>
<div class="w4f-kpi">
@ -102,7 +102,7 @@
<!-- Pending token commands -->
<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 v-for="t in ring.pending" :key="t.id" class="task-row" :class="taskClass(t)">
<span class="tk-id">{{ t.id }}</span>

View File

@ -15,12 +15,12 @@
<!-- 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-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-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>