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

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)