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

@ -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 {