mirror of
https://gitcode.com/JianFeeeee/webui4frpc.git
synced 2026-09-19 16:38:31 +00:00
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:
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-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>
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"`
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 {
|
||||
|
||||
45
web/src/views/ClusterView.vue
Normal file
45
web/src/views/ClusterView.vue
Normal 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>
|
||||
@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user