feat: M3 cluster page (LB groups + health check status) e2e-verified failover; M6 cache API + version path hardening + frontend cluster methods

This commit is contained in:
2026-08-17 22:44:04 +08:00
parent a842198447
commit 5bd70b90c0
11 changed files with 470 additions and 128 deletions

View File

@ -2,18 +2,147 @@
<div class="cluster-page">
<div class="cluster-top">
<h2 class="page-title">集群</h2>
<span class="page-sub">
负载均衡组(group)与健康检查(health check)将在这里展示 — M3 功能,占位
</span>
<span class="page-sub">负载均衡组 + 健康检查状态(M3)</span>
<button class="refresh-btn" @click="load">刷新</button>
</div>
<div class="cluster-body">
<el-empty description="集群功能开发中(M3:负载均衡与健康检查)" />
<!-- 远程节点 × LB 组概览 -->
<section class="section" v-if="profileList.length">
<h3 class="section-title">
远程节点 × 负载均衡组
<span class="hint">group 中同一 local 多成员自动负载均衡</span>
</h3>
<div class="node-grid">
<div v-for="p in profileList" :key="p.name" class="node-card">
<div class="nc-head">
<span class="nc-name">{{ p.name }}</span>
<span class="nc-badge" :class="connClass(p.connState)">
{{ connLabel(p.connState) }}
</span>
<span v-if="p.groupCount" class="nc-lb">LB ×{{ p.groupCount }}</span>
</div>
<div class="nc-meta">
<span v-if="p.adminEnabled" class="tag">admin API 状态</span>
<span class="fwd-count">{{ p.forwards.length }} 转发</span>
</div>
<!-- per-proxy 健康状态 -->
<div v-if="p.proxyStates?.length" class="proxy-list">
<div
v-for="ps in p.proxyStates"
:key="ps.name"
class="proxy-row"
:class="psClass(ps.status)"
>
<span class="pr-name">{{ ps.name }}</span>
<span class="pr-type">{{ ps.type }}</span>
<span class="pr-status">{{ psLabel(ps.status) }}</span>
<span v-if="ps.remote_addr" class="pr-addr">{{ ps.remote_addr }}</span>
<span v-if="ps.err" class="pr-err" :title="ps.err">{{ ps.err }}</span>
</div>
</div>
<div v-else class="proxy-empty">
{{ p.adminEnabled ? 'admin API 未返回状态(frpc 未连上 frps)' : '未启用 admin API(回退日志推断)' }}
</div>
</div>
</div>
</section>
<!-- 本地服务 → LB 组成员 -->
<section class="section" v-if="localStatusList.length">
<h3 class="section-title">本地服务 → 转发成员</h3>
<div class="ls-list">
<div v-for="ls in localStatusList" :key="ls.local.name" class="ls-card">
<div class="ls-head">
<span class="ls-name">{{ ls.local.name }}</span>
<span class="ls-addr">{{ ls.local.ip }}:{{ ls.local.port }}</span>
<span class="ls-proto">{{ ls.local.protocol }}</span>
<span v-if="ls.local.lbGroup" class="ls-lb">
group: {{ ls.local.lbGroup }}
</span>
</div>
<div class="ls-targets">
<div v-for="t in ls.targets" :key="t.remote + ':' + t.remotePort" class="ls-tgt">
→ {{ t.remote }}:{{ t.remotePort }}
<span class="ls-state" :class="{ ok: t.workerState === 'running' }">
{{ t.workerState }}
</span>
</div>
<div v-if="!ls.targets.length" class="ls-none">未转发</div>
</div>
</div>
</div>
</section>
<el-empty v-if="!profileList.length && !localStatusList.length" description="暂无集群配置(先在状态/连接配置页添加 remote 与 local)" />
</div>
</div>
</template>
<script setup lang="ts">
// M3 集群页骨架:group / groupKey 多后端负载均衡 + 健康检查(tcp/http)将在此呈现。
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { api } from '../api'
import type { StatusResp } from '../types'
const status = ref<StatusResp | null>(null)
let timer: number | null = null
const profileList = computed(() => status.value?.profiles ?? [])
const localStatusList = computed(() => status.value?.localStatus ?? [])
const load = async () => {
try {
status.value = await api.status()
} catch {
// keep last on transient errors
}
}
const connLabel = (s?: string) => {
switch (s) {
case 'connected': return '已连接'
case 'connecting': return '连接中'
case 'failed': return '连接失败'
case 'disabled': return '已停用'
default: return '未启动'
}
}
const connClass = (s?: string) => {
switch (s) {
case 'connected': return 'ok'
case 'connecting': return 'pending'
case 'failed': return 'err'
default: return ''
}
}
const psLabel = (s?: string) => {
switch (s) {
case 'running': return '运行中'
case 'wait start': return '等待启动'
case 'start error': return '启动错误'
case 'check failed': return '健康检查失败'
case 'closed': return '已关闭'
default: return '新建'
}
}
const psClass = (s?: string) => {
switch (s) {
case 'running': return 'ok'
case 'check failed':
case 'start error': return 'err'
case 'wait start': return 'pending'
default: return ''
}
}
onMounted(() => {
load()
timer = window.setInterval(load, 8000)
})
onBeforeUnmount(() => {
if (timer !== null) { clearInterval(timer); timer = null }
})
</script>
<style scoped lang="scss">
@ -30,16 +159,72 @@
gap: 12px;
margin-bottom: 16px;
}
.page-title {
margin: 0;
font-size: 18px;
font-weight: 600;
}
.page-sub {
color: $color-text-muted;
.page-title { margin: 0; font-size: 18px; font-weight: 600; }
.page-sub { color: $color-text-muted; font-size: 13px; }
.refresh-btn {
margin-left: auto;
border: 1px solid $color-border;
border-radius: 6px;
padding: 3px 12px;
background: #fff;
cursor: pointer;
font-size: 13px;
&:hover { background: $color-bg-hover; }
}
.cluster-body {
flex: 1;
.section { margin-bottom: 20px; }
.section-title {
margin: 0 0 10px;
font-size: 15px;
font-weight: 600;
.hint { font-size: 12px; font-weight: 400; color: $color-text-muted; margin-left: 8px; }
}
.node-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 12px;
}
.node-card {
border: 1px solid $color-border-light;
border-radius: 10px;
padding: 12px 14px;
background: #fff;
}
.nc-head { display: flex; align-items: center; gap: 8px; }
.nc-name { font-weight: 600; }
.nc-badge { font-size: 12px; padding: 1px 8px; border-radius: 8px; }
.nc-badge.ok { background: #f0f9eb; color: $color-success; }
.nc-badge.pending { background: #ecf5ff; color: #409eff; }
.nc-badge.err { background: #fef0f0; color: $color-danger; }
.nc-badge:not(.ok):not(.pending):not(.err) { background: #f2f3f5; color: $color-text-muted; }
.nc-lb { margin-left: auto; font-size: 11px; background: rgba(64,158,255,.12); color: #409eff; border-radius: 10px; padding: 1px 8px; }
.nc-meta { margin: 6px 0 8px; display: flex; gap: 8px; align-items: center; }
.tag { font-size: 11px; background: #ecf5ff; color: #409eff; border-radius: 6px; padding: 1px 6px; }
.fwd-count { font-size: 12px; color: $color-text-muted; }
.proxy-list { display: flex; flex-direction: column; gap: 4px; }
.proxy-row {
display: flex; align-items: center; gap: 8px;
font-size: 12px; border: 1px solid $color-border-lighter;
border-radius: 8px; padding: 3px 8px;
}
.proxy-row.ok { border-left: 3px solid $color-success; }
.proxy-row.pending { border-left: 3px solid #409eff; }
.proxy-row.err { border-left: 3px solid $color-danger; }
.pr-name { font-weight: 600; }
.pr-type { font-size: 11px; color: $color-text-muted; }
.pr-status { color: $color-text-secondary; }
.pr-addr { margin-left: auto; font-size: 11px; color: $color-text-muted; }
.pr-err { margin-left: auto; font-size: 11px; color: $color-danger; max-width: 160px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.proxy-empty { font-size: 12px; color: $color-text-muted; }
.ls-list { display: flex; flex-direction: column; gap: 8px; }
.ls-card { border: 1px solid $color-border-light; border-radius: 10px; padding: 10px 14px; background: #fafafa; }
.ls-head { display: flex; align-items: center; gap: 8px; }
.ls-name { font-weight: 600; }
.ls-addr { font-size: 12px; color: $color-text-secondary; }
.ls-proto { font-size: 11px; background: #ecf5ff; color: #409eff; border-radius: 6px; padding: 1px 6px; }
.ls-lb { font-size: 11px; background: rgba(64,158,255,.12); color: #409eff; border-radius: 6px; padding: 1px 6px; }
.ls-targets { margin-top: 6px; display: flex; flex-direction: column; gap: 4px; }
.ls-tgt { font-size: 13px; display: flex; align-items: center; gap: 6px; }
.ls-state { margin-left: auto; font-size: 12px; color: $color-danger; }
.ls-state.ok { color: $color-success; }
.ls-none { color: $color-text-light; font-size: 12px; }
</style>