fix: worker 进程并发 double-close panic + 状态页连接语义重构 + 集群页骨架

- process: spawn 不再重复起 supervise goroutine(Restart 触发旧 supervise close doneCh 后再 close → panic);新增 closeOnce/supervising 守卫;Start 首次起 supervise,重启循环在同一 goroutine 内

- httpapi: status 增加 connState(connected/connecting/failed/not_started/disabled),由 worker 日志探测真实 frps 连接(不再把本地 worker 状态误当远程 frps 状态)

- StatusView: 远程卡片主状态改为连接状态徽标;启停按钮标注为控制本地 frpc worker;本地转发区保留 worker 状态显示

- 新增 ClusterView 集群页骨架(M3 LB+健康检查载体)+ App.vue 导航接入

- render: bandwidthLimit 从 proxy 顶层移入 transport 段(frpc 0.61 正确 schema,修复真实连不上的 bug)
This commit is contained in:
2026-08-17 15:13:25 +08:00
parent 58906c5a81
commit bd3a62a017
12 changed files with 221 additions and 57 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-DHaXL5Wt.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-m13EHiyb.css">
<script type="module" crossorigin src="/assets/index-C04K5jB8.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B6NLA8yp.css">
</head>
<body>
<div id="app"></div>

View File

@ -146,6 +146,9 @@ func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) {
Status process.Status `json:"process"`
HasProc bool `json:"hasProcess"`
Forwards []store.Forward `json:"forwards"`
// ConnState is the derived frps connection state:
// connected | connecting | failed | not_started | disabled.
ConnState string `json:"connState"`
}
profiles := make([]profileStatus, 0, len(remotes))
@ -158,6 +161,7 @@ func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) {
fwd, _ := h.Store.LinksForRemote(rv.Name)
profiles = append(profiles, profileStatus{
Name: rv.Name, Enabled: rv.Enabled, Status: st, HasProc: has, Forwards: fwd,
ConnState: connStateOf(h.Process.LogPath(rv.Name), rv.Enabled, st),
})
}
@ -204,6 +208,49 @@ func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, resp)
}
// connStateOf derives the frps connection state from the local worker process
// state plus a probe of its log. We never control the remote frps: the worker is
// our local frpc; "connected" means frpc has successfully logged in to frps.
// - disabled -> remote disabled, worker not started
// - running+login -> frpc logged in to frps (connected)
// - running -> frpc up but not yet logged in (connecting)
// - starting/restarting -> connecting
// - crashed/exit nonzero -> failed
// - stopped clean / no worker -> not_started
func connStateOf(workerLog string, enabled bool, st process.Status) string {
if !enabled {
return "disabled"
}
switch st.State {
case "running":
tail, _ := tailFile(workerLog, 64*1024)
if tail != "" {
// frpc prints "login to server success" once the control link is up.
if strings.Contains(tail, "login to server success") ||
strings.Contains(tail, "start proxy success") {
return "connected"
}
if strings.Contains(tail, "login to server error") ||
strings.Contains(tail, "connect to server error") ||
strings.Contains(tail, "connect server error") {
return "failed"
}
}
return "connecting"
case "starting", "restarting":
return "connecting"
case "crashed":
return "failed"
case "stopped":
if st.ExitCode != 0 || st.Err != "" {
return "failed"
}
return "not_started"
default:
return "not_started"
}
}
func methodNotAllowed(w http.ResponseWriter) {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}

View File

@ -47,12 +47,14 @@ type worker struct {
proc *exec.Cmd
logFile *rotatingFile
stopOnce sync.Once
stopCh chan struct{}
doneCh chan struct{}
stopOnce sync.Once
closeOnce sync.Once
stopCh chan struct{}
doneCh chan struct{}
mu sync.Mutex
status Status
mu sync.Mutex
status Status
supervising bool // guarded by Manager.mu
}
// Manager supervises all workers.
@ -103,6 +105,12 @@ func (m *Manager) Start(name string) error {
m.mu.Unlock()
return err
}
m.mu.Lock()
if !w.supervising {
w.supervising = true
go m.supervise(w)
}
m.mu.Unlock()
return nil
}
@ -145,12 +153,13 @@ func (m *Manager) spawn(w *worker) error {
w.proc = cmd
w.logFile = logWriter
m.setState(w, Status{State: "running", Pid: cmd.Process.Pid, StartTime: time.Now().Unix(), RestartCount: restartCount})
go m.supervise(w)
// NOTE: no new supervise goroutine here. The first Start()'s supervise loop
// re-spawns on restart; spawning here would race on doneCh close.
return nil
}
func (m *Manager) supervise(w *worker) {
defer close(w.doneCh)
defer w.closeOnce.Do(func() { close(w.doneCh) })
for {
err := w.proc.Wait()
exitCode := 0

View File

@ -34,11 +34,11 @@ type Proxy struct {
Annotations map[string]string
// M2 HTTP/HTTPS routing (http/https only).
Locations []string // path routing
HostHeaderRewrite string // rewrite Host header
HTTPHeaders map[string]string // additional request headers
BasicAuthUser string
BasicAuthPassword string
Locations []string // path routing
HostHeaderRewrite string // rewrite Host header
HTTPHeaders map[string]string // additional request headers
BasicAuthUser string
BasicAuthPassword string
}
// frpcAuth mirrors frpc's auth section.
@ -60,6 +60,12 @@ type frpcTLS struct {
ServerName string `json:"serverName,omitempty"`
}
// frpcProxyTransport is the per-proxy transport section (frpc >= 0.52)
// where bandwidthLimit is defined.
type frpcProxyTransport struct {
BandwidthLimit string `json:"bandwidthLimit,omitempty"`
}
// frpcBasicAuth mirrors frpc's HTTP basicAuth section.
type frpcBasicAuth struct {
User string `json:"user,omitempty"`
@ -77,13 +83,13 @@ type frpcProxy struct {
CustomDomains []string `json:"customDomains,omitempty"`
SubDomain string `json:"subdomain,omitempty"`
// M1 advanced transport fields.
UseEncryption bool `json:"useEncryption,omitempty"`
UseCompression bool `json:"useCompression,omitempty"`
BandwidthLimit string `json:"bandwidthLimit,omitempty"`
PoolCount int `json:"poolCount,omitempty"`
Metadatas map[string]string `json:"metadatas,omitempty"`
Annotations map[string]string `json:"annotations,omitempty"`
// M1 advanced transport fields. bandwidthLimit lives in transport.
UseEncryption bool `json:"useEncryption,omitempty"`
UseCompression bool `json:"useCompression,omitempty"`
PoolCount int `json:"poolCount,omitempty"`
Metadatas map[string]string `json:"metadatas,omitempty"`
Annotations map[string]string `json:"annotations,omitempty"`
Transport *frpcProxyTransport `json:"transport,omitempty"`
// M2 HTTP/HTTPS routing.
Locations []string `json:"locations,omitempty"`
@ -135,7 +141,6 @@ func Render(remote store.Remote, proxies []Proxy) ([]byte, error) {
SubDomain: p.SubDomain,
UseEncryption: p.UseEncryption,
UseCompression: p.UseCompression,
BandwidthLimit: p.BandwidthLimit,
PoolCount: p.PoolCount,
Metadatas: p.Metadatas,
Annotations: p.Annotations,
@ -143,6 +148,9 @@ func Render(remote store.Remote, proxies []Proxy) ([]byte, error) {
HostHeaderRewrite: p.HostHeaderRewrite,
HTTPHeaders: p.HTTPHeaders,
}
if p.BandwidthLimit != "" {
frpcP.Transport = &frpcProxyTransport{BandwidthLimit: p.BandwidthLimit}
}
if p.BasicAuthUser != "" || p.BasicAuthPassword != "" {
frpcP.HTTPBasicAuth = &frpcBasicAuth{User: p.BasicAuthUser, Password: p.BasicAuthPassword}
}

View File

@ -74,12 +74,15 @@ func TestRenderTransportAdvanced(t *testing.T) {
t.Fatalf("transport.poolCount = %d, want 3", cfg.Transport.PoolCount)
}
p := cfg.Proxies[0]
if !p.UseEncryption || !p.UseCompression || p.BandwidthLimit != "1MB" || p.PoolCount != 2 {
if !p.UseEncryption || !p.UseCompression || p.PoolCount != 2 {
t.Fatalf("proxy advanced fields = %+v", p)
}
if p.Metadatas["env"] != "prod" || p.Annotations["owner"] != "ops" {
t.Fatalf("proxy metadatas/annotations = %+v", p)
}
if p.Transport == nil || p.Transport.BandwidthLimit != "1MB" {
t.Fatalf("proxy transport.bandwidthLimit = %+v", p.Transport)
}
}
func TestRenderTransportOmittedWhenEmpty(t *testing.T) {
@ -97,7 +100,7 @@ func TestRenderTransportOmittedWhenEmpty(t *testing.T) {
if cfg.Transport.Protocol != "" || cfg.Transport.TLS != nil || cfg.Transport.PoolCount != 0 {
t.Fatalf("transport should be empty, got %+v", cfg.Transport)
}
if cfg.Proxies[0].UseEncryption || cfg.Proxies[0].BandwidthLimit != "" {
if cfg.Proxies[0].UseEncryption || cfg.Proxies[0].Transport != nil {
t.Fatalf("proxy advanced should be empty, got %+v", cfg.Proxies[0])
}
}
@ -106,11 +109,11 @@ func TestRenderHTTPAdvanced(t *testing.T) {
remote := store.Remote{Name: "srv", IP: "1.2.3.4", Port: 7000, Token: "secret"}
data, err := Render(remote, []Proxy{
{Name: "web", Type: "http", LocalIP: "127.0.0.1", LocalPort: 8080,
CustomDomains: []string{"app.example.com"},
Locations: []string{"/api", "/admin"},
CustomDomains: []string{"app.example.com"},
Locations: []string{"/api", "/admin"},
HostHeaderRewrite: "backend.internal",
HTTPHeaders: map[string]string{"X-Custom": "val"},
BasicAuthUser: "user", BasicAuthPassword: "pass"},
HTTPHeaders: map[string]string{"X-Custom": "val"},
BasicAuthUser: "user", BasicAuthPassword: "pass"},
})
if err != nil {
t.Fatal(err)

View File

@ -29,9 +29,9 @@ type Local struct {
Annotations map[string]string `json:"annotations,omitempty"`
// M2 HTTP/HTTPS routing (http/https only).
CustomDomains string `json:"customDomains,omitempty"` // comma-separated
CustomDomains string `json:"customDomains,omitempty"` // comma-separated
SubDomain string `json:"subdomain,omitempty"`
Locations []string `json:"locations,omitempty"` // path routing
Locations []string `json:"locations,omitempty"` // path routing
HostHeaderRewrite string `json:"hostHeaderRewrite,omitempty"`
HTTPHeaders map[string]string `json:"httpHeaders,omitempty"`
BasicAuthUser string `json:"basicAuthUser,omitempty"`

View File

@ -13,11 +13,15 @@
<button class="nav-btn" :class="{ active: view === 'settings' }" @click="view = 'settings'">
设置
</button>
<button class="nav-btn" :class="{ active: view === 'cluster' }" @click="view = 'cluster'">
集群
</button>
</nav>
</header>
<main class="content">
<CanvasView v-if="view === 'canvas'" />
<SettingsView v-else-if="view === 'settings'" />
<ClusterView v-else-if="view === 'cluster'" />
<StatusView v-else />
</main>
</div>
@ -28,8 +32,9 @@ import { ref } from 'vue'
import CanvasView from './views/CanvasView.vue'
import SettingsView from './views/SettingsView.vue'
import StatusView from './views/StatusView.vue'
import ClusterView from './views/ClusterView.vue'
const view = ref<'canvas' | 'settings' | 'status'>('status')
const view = ref<'canvas' | 'settings' | 'status' | 'cluster'>('status')
</script>
<style>

View File

@ -87,6 +87,8 @@ export interface StatusProfile {
process: ProcessStatus;
hasProcess: boolean;
forwards: { service: string; remotePort: number; localPort?: number }[];
// connState: derived frps connection state (we do not control remote frps)
connState?: string; // connected | connecting | failed | not_started | disabled
}
export interface StatusResp {

View File

@ -0,0 +1,45 @@
<template>
<div class="cluster-page">
<div class="cluster-top">
<h2 class="page-title">集群</h2>
<span class="page-sub">
负载均衡组group与健康检查health check将在这里展示 M3 功能占位
</span>
</div>
<div class="cluster-body">
<el-empty description="集群功能开发中M3负载均衡与健康检查" />
</div>
</div>
</template>
<script setup lang="ts">
// M3 集群页骨架group / groupKey 多后端负载均衡 + 健康检查tcp/http将在此呈现。
</script>
<style scoped lang="scss">
.cluster-page {
height: 100%;
display: flex;
flex-direction: column;
padding: 20px;
overflow-y: auto;
}
.cluster-top {
display: flex;
align-items: baseline;
gap: 12px;
margin-bottom: 16px;
}
.page-title {
margin: 0;
font-size: 18px;
font-weight: 600;
}
.page-sub {
color: $color-text-muted;
font-size: 13px;
}
.cluster-body {
flex: 1;
}
</style>

View File

@ -20,12 +20,12 @@
>
<div class="ns-head">
<span class="ns-name">{{ p.name }}</span>
<span class="ns-badge" :class="{ ok: workerHealthy(p.process.state) }">
{{ workerStateLabel(p.process.state) }}
<span class="ns-badge" :class="connBadgeClass(p.connState)">
{{ connStateLabel(p.connState) }}
</span>
<span class="ns-actions">
<button v-if="p.process.state === 'running'" class="mini-btn" @click="stopRemote(p.name)"></button>
<button v-else class="mini-btn" @click="startRemote(p.name)"></button>
<span class="ns-actions" title="控制本地 frpc worker 进程(不管理远程 frps">
<button v-if="p.process.state === 'running'" class="mini-btn" @click="stopRemote(p.name)">本地worker</button>
<button v-else class="mini-btn" @click="startRemote(p.name)">本地worker</button>
<button class="mini-btn" @click="openEdit(p)">编辑</button>
<button class="mini-btn danger" @click="removeRemote(p.name)">删除</button>
</span>
@ -63,7 +63,7 @@
<span class="ls-remote">{{ t.remote }}</span>
<span class="ls-port">:{{ t.remotePort }}</span>
<span class="ls-state" :class="{ ok: workerHealthy(t.workerState) }">
{{ workerStateLabel(t.workerState) }}
{{ localWorkerLabel(t.workerState) }}
</span>
</div>
<div v-if="!ls.targets.length" class="ls-none">未转发</div>
@ -204,10 +204,47 @@ const loadStatus = async () => {
}
}
const workerStateLabel = (s?: string) => {
// connState refers to the frps connection from the local frpc worker.
// We never control the remote frps itself.
const connStateLabel = (s?: string) => {
switch (s) {
case 'connected':
return '已连接'
case 'connecting':
return '连接中'
case 'failed':
return '连接失败'
case 'disabled':
return '已停用'
case 'not_started':
default:
return '未启动'
}
}
const connBadgeClass = (s?: string) => {
switch (s) {
case 'connected':
return 'ok'
case 'connecting':
return 'pending'
case 'failed':
return 'err'
case 'disabled':
case 'not_started':
default:
return ''
}
}
const workerHealthy = (s?: string) => s === 'running'
// localWorkerLabel: state of the LOCAL frpc worker driving this target
// (we do not control the remote frps).
const localWorkerLabel = (s?: string) => {
switch (s) {
case 'running':
return '通畅'
return '运行中'
case 'starting':
return '启动中'
case 'restarting':
@ -219,8 +256,6 @@ const workerStateLabel = (s?: string) => {
}
}
const workerHealthy = (s?: string) => s === 'running'
// remoteVhostPort returns the frps vhostHTTPPort for a remote (0 if unset).
const remoteVhostPort = (name: string): number =>
status.value?.remotes.find((r) => r.name === name)?.vhostHttpPort || 0
@ -397,6 +432,16 @@ onBeforeUnmount(() => {
background: #f0f9eb;
color: $color-success;
}
&.pending {
background: #ecf5ff;
color: #409eff;
}
&.err {
background: #fef0f0;
color: $color-danger;
}
}
.ns-meta {