feat: M3 load balancing + health check (model/render/admin API status/UI)

This commit is contained in:
2026-08-17 17:45:03 +08:00
parent bd3a62a017
commit 467c7124ab
10 changed files with 658 additions and 33 deletions

View File

@ -55,6 +55,24 @@
<label>请求头(JSON) <input v-model="httpHeadersField" placeholder='{"X-Custom":"val"}' /></label>
<label>BasicAuth 用户 <input v-model="basicAuthUserField" placeholder="user" /></label>
<label>BasicAuth 密码 <input v-model="basicAuthPasswordField" type="password" placeholder="pass" /></label>
<div class="adv-sep">负载均衡 (M3)</div>
<label>LB <input v-model="lbGroupField" placeholder="如 web (同组自动负载均衡)" /></label>
<label>组密钥 <input v-model="lbGroupKeyField" placeholder="组内相同" /></label>
<div class="adv-sep">健康检查 (M3)</div>
<label
>类型
<select v-model="healthCheckTypeField">
<option value="">关闭</option>
<option value="tcp">tcp</option>
<option value="http">http</option>
</select>
</label>
<label>HTTP 路径 <input v-model="healthCheckPathField" placeholder="/healthz" /></label>
<label>超时() <input v-model.number="healthCheckTimeoutField" type="number" min="0" placeholder="默认3" /></label>
<label>失败次数 <input v-model.number="healthCheckMaxFailedField" type="number" min="0" placeholder="默认1" /></label>
<label>间隔() <input v-model.number="healthCheckIntervalField" type="number" min="0" placeholder="默认10" /></label>
</div>
</div>
</div>
@ -128,6 +146,13 @@ const subdomainField = field('subdomain')
const hostHeaderRewriteField = field('hostHeaderRewrite')
const basicAuthUserField = field('basicAuthUser')
const basicAuthPasswordField = field('basicAuthPassword')
const lbGroupField = field('lbGroup')
const lbGroupKeyField = field('lbGroupKey')
const healthCheckTypeField = field('healthCheckType')
const healthCheckPathField = field('healthCheckPath')
const healthCheckTimeoutField = field('healthCheckTimeout')
const healthCheckMaxFailedField = field('healthCheckMaxFailed')
const healthCheckIntervalField = field('healthCheckInterval')
// locations is an array shown as JSON in the UI
const locationsField = computed({

View File

@ -47,6 +47,15 @@
<label>连接池 <input v-model.number="transportPoolField" type="number" min="0" /></label>
<label class="adv-sep">HTTP 访问端口 (frps vhostHTTPPort)</label>
<label>vhostHTTPPort <input v-model.number="vhostHttpPortField" type="number" min="0" /></label>
<label class="adv-sep">本地 frpc 管理 API (M3)</label>
<label class="adv-check">启用后状态页按真实代理状态展示
<span class="hint-txt">(adminPort &gt; 0)</span>
</label>
<label>地址 <input v-model="adminAddrField" placeholder="127.0.0.1" /></label>
<label>端口 <input v-model.number="adminPortField" type="number" min="0" placeholder="如 7400" /></label>
<label>用户 <input v-model="adminUserField" placeholder="admin" /></label>
<label>密码 <input v-model="adminPasswordField" type="password" placeholder="可选" /></label>
</div>
</div>
</div>
@ -87,6 +96,10 @@ const transportTlsField = field('transportTls')
const transportTlsServerNameField = field('transportTlsServerName')
const transportPoolField = field('transportPool')
const vhostHttpPortField = field('vhostHttpPort')
const adminAddrField = field('adminAddr')
const adminPortField = field('adminPort')
const adminUserField = field('adminUser')
const adminPasswordField = field('adminPassword')
const showAdv = ref(false)
</script>

View File

@ -30,6 +30,15 @@ export interface Local {
httpHeaders?: Record<string, string>;
basicAuthUser?: string;
basicAuthPassword?: string;
// M3 load balancing group + health check
lbGroup?: string;
lbGroupKey?: string;
healthCheckType?: string; // "" | tcp | http
healthCheckPath?: string;
healthCheckTimeout?: number;
healthCheckMaxFailed?: number;
healthCheckInterval?: number;
}
export interface Remote {
@ -48,6 +57,12 @@ export interface Remote {
// M2: frps vhostHTTPPort
vhostHttpPort?: number;
// M3: local frpc admin API (webServer)
adminAddr?: string;
adminPort?: number;
adminUser?: string;
adminPassword?: string;
}
export interface Link {
@ -81,6 +96,15 @@ export interface ProcessStatus {
err?: string;
}
export interface ProxyState {
name: string;
type: string;
status: string; // new | wait start | start error | running | check failed | closed
err?: string;
local_addr?: string;
remote_addr?: string;
}
export interface StatusProfile {
name: string;
enabled: boolean;
@ -89,6 +113,10 @@ export interface StatusProfile {
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
// M3: admin API state
adminEnabled?: boolean;
proxyStates?: ProxyState[];
groupCount?: number;
}
export interface StatusResp {

View File

@ -42,6 +42,25 @@
{{ f.service }} :{{ f.remotePort }}
</span>
</div>
<div v-if="p.groupCount" class="ns-groups">
<span class="ns-group-badge">LB ×{{ p.groupCount }}</span>
</div>
<div v-if="p.proxyStates?.length" class="ns-proxies">
<div
v-for="ps in p.proxyStates"
:key="ps.name"
class="ns-proxy"
:class="proxyStateClass(ps.status)"
>
<span class="np-name">{{ ps.name }}</span>
<span class="np-status">{{ proxyStateLabel(ps.status) }}</span>
<span v-if="ps.remote_addr" class="np-addr">{{ ps.remote_addr }}</span>
<span v-if="ps.err" class="np-err" :title="ps.err">{{ ps.err }}</span>
</div>
</div>
<div v-else-if="p.adminEnabled" class="ns-proxies-empty">
管理 API 未返回状态frpc 未连上远程 frps 或无代理
</div>
</div>
<div v-if="!status.profiles.length" class="ns-empty">尚未配置远程节点</div>
</div>
@ -239,6 +258,38 @@ const connBadgeClass = (s?: string) => {
const workerHealthy = (s?: string) => s === 'running'
// M3: per-proxy state from frpc admin API.
const proxyStateLabel = (s?: string) => {
switch (s) {
case 'running':
return '运行中'
case 'wait start':
return '等待启动'
case 'start error':
return '启动错误'
case 'check failed':
return '健康检查失败'
case 'closed':
return '已关闭'
case 'new':
default:
return '新建'
}
}
const proxyStateClass = (s?: string) => {
switch (s) {
case 'running':
return 'ok'
case 'check failed':
case 'start error':
return 'err'
case 'wait start':
return 'pending'
default:
return ''
}
}
// localWorkerLabel: state of the LOCAL frpc worker driving this target
// (we do not control the remote frps).
const localWorkerLabel = (s?: string) => {
@ -470,14 +521,81 @@ onBeforeUnmount(() => {
margin-top: 6px;
}
.ns-fwd {
.ns-groups {
display: flex;
gap: 4px;
margin-top: 6px;
}
.ns-group-badge {
font-size: 11px;
background: rgba(64, 158, 255, 0.12);
color: #409eff;
border-radius: 10px;
padding: 1px 8px;
}
.ns-proxies {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 6px;
}
.ns-proxy {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
border: 1px solid $color-border-lighter;
border-radius: 8px;
padding: 3px 8px;
background: #fff;
&.ok {
border-left: 3px solid $color-success;
}
&.pending {
border-left: 3px solid #409eff;
}
&.err {
border-left: 3px solid $color-danger;
}
}
.np-name {
font-weight: 600;
}
.np-status {
font-size: 11px;
background: $color-bg-muted;
border-radius: 6px;
padding: 1px 6px;
color: $color-text-secondary;
}
.np-addr {
margin-left: auto;
font-size: 11px;
color: $color-text-muted;
}
.np-err {
margin-left: auto;
font-size: 11px;
color: $color-danger;
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ns-proxies-empty {
margin-top: 6px;
font-size: 12px;
color: $color-text-muted;
}
.ns-empty,
.ls-empty {
color: $color-text-muted;