feat(M6): token ring data plane + engine — pending adds disappear on claim, topology written back for full cluster view (per authoritative design)

This commit is contained in:
2026-08-18 08:24:37 +08:00
parent 5bd70b90c0
commit f6c4b91a96
9 changed files with 1166 additions and 202 deletions

View File

@ -2,143 +2,102 @@
<div class="cluster-page">
<div class="cluster-top">
<h2 class="page-title">集群</h2>
<span class="page-sub">负载均衡组 + 健康检查状态M3</span>
<button class="refresh-btn" @click="load">刷新</button>
<span class="page-sub">webui4frpc 节点网络 · frpc 二进制分发</span>
<button class="refresh-btn" @click="loadAll">刷新</button>
</div>
<div class="cluster-body">
<!-- 远程节点 × 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>
<!-- 集群节点本机 + 邻居 -->
<section class="section">
<h3 class="section-title">集群节点
<span class="hint"> -peer 心跳发现30s新节点自动从邻居拉取 frpc</span>
</h3>
<div class="node-grid">
<div v-for="(n, i) in nodes" :key="n.addr" class="node-card" :class="{ self: i === 0 }">
<div class="nc-head">
<span class="nc-name">{{ n.addr }}</span>
<span v-if="i === 0" class="nc-self">本机</span>
<span class="nc-state" :class="nodeStateClass(i)">{{ nodeStateLabel(i) }}</span>
</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 class="nc-meta">
<span>frpc: <b>{{ n.version || '-' }}</b></span>
</div>
<div class="nc-cache" v-if="n.cache?.length">
<span class="tag">缓存</span>
<span v-for="v in n.cache" :key="v" class="ver">{{ v }}</span>
</div>
<div class="nc-cache empty" v-else> frpc 缓存</div>
</div>
</section>
</div>
<div v-if="!nodes.length" class="empty-tip">尚未加入集群本机始终在内 -peer 连接邻居</div>
</section>
<el-empty v-if="!profileList.length && !localStatusList.length" description="暂无集群配置(先在状态/连接配置页添加 remote 与 local" />
</div>
<!-- 本机二进制缓存 + 管理 -->
<section class="section" v-if="cache.length">
<h3 class="section-title">本机 frpc 缓存
<span class="hint">来源本地 > 集群节点间交换 > GitHub URL</span>
</h3>
<div class="cache-list">
<div v-for="c in cache" :key="c.version" class="cache-row">
<span class="cv">{{ c.version }}</span>
<span class="cs">{{ (c.size / 1048576).toFixed(1) }} MB</span>
<span class="cp">{{ c.path }}</span>
</div>
</div>
<div class="prune-row">
<span>只保留最近</span>
<el-input-number v-model="keep" :min="1" :max="10" size="small" />
<span>个版本</span>
<button class="prune-btn" @click="prune">清理缓存</button>
<span v-if="prunedMsg" class="pruned">{{ prunedMsg }}</span>
</div>
</section>
<el-empty v-if="!nodes.length && !cache.length" description="集群无数据(未配置 -peer 且无 frpc 缓存)" />
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { ElMessage } from 'element-plus'
import { api } from '../api'
import type { StatusResp } from '../types'
import type { ClusterNode, CacheEntry } from '../types'
const status = ref<StatusResp | null>(null)
const nodes = ref<ClusterNode[]>([])
const cache = ref<CacheEntry[]>([])
const keep = ref(2)
const prunedMsg = ref('')
let timer: number | null = null
const profileList = computed(() => status.value?.profiles ?? [])
const localStatusList = computed(() => status.value?.localStatus ?? [])
const load = async () => {
const loadAll = async () => {
try {
status.value = await api.status()
} catch {
// keep last on transient errors
}
const r = await api.clusterNodes()
nodes.value = r.nodes
} catch { /* keep last */ }
try {
const c = await api.clusterCache()
cache.value = c.cache ?? []
} catch { /* keep last */ }
}
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 ''
// 首个节点恒为本机;其后为心跳发现的邻居。状态按 seenAt/可达性粗分:
// 本机=ok邻居暂显示已发现后续可加 seenAt 超时判定)。
const nodeStateLabel = (i: number) => (i === 0 ? '在线(本机)' : '已发现')
const nodeStateClass = (i: number) => (i === 0 ? 'ok' : 'pending')
const prune = async () => {
try {
const r = await api.pruneCache(keep.value)
cache.value = r.cache ?? []
prunedMsg.value = r.removed?.length ? `已移除: ${r.removed.join(', ')}` : '无变化'
ElMessage.success(prunedMsg.value)
} catch (e: any) {
ElMessage.error('清理失败: ' + (e.message || e))
}
}
onMounted(() => {
load()
timer = window.setInterval(load, 8000)
loadAll()
timer = window.setInterval(loadAll, 8000)
})
onBeforeUnmount(() => {
if (timer !== null) { clearInterval(timer); timer = null }
@ -146,85 +105,31 @@ onBeforeUnmount(() => {
</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;
}
.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; }
.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; }
}
.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; } }
.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;
}
.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(300px, 1fr)); gap: 12px; }
.node-card { border: 1px solid $color-border-light; border-radius: 10px; padding: 12px 14px; background: #fff; &.self { border-color: #409eff; } }
.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>
.nc-self { font-size: 11px; background: rgba(64,158,255,.12); color: #409eff; border-radius: 10px; padding: 1px 8px; }
.nc-state { margin-left: auto; font-size: 12px; padding: 1px 8px; border-radius: 8px; &.ok { background: #f0f9eb; color: $color-success; } &.pending { background: #ecf5ff; color: #409eff; } }
.nc-meta { margin: 6px 0; font-size: 12px; color: $color-text-secondary; }
.nc-cache { display: flex; gap: 4px; flex-wrap: wrap; align-items: center; }
.tag { font-size: 11px; background: #f2f3f5; color: $color-text-muted; border-radius: 6px; padding: 1px 6px; }
.ver { font-size: 11px; background: #ecf5ff; color: #409eff; border-radius: 6px; padding: 1px 6px; }
.nc-cache.empty { color: $color-text-muted; font-size: 12px; }
.empty-tip { color: $color-text-muted; font-size: 13px; padding: 8px; }
.cache-list { display: flex; flex-direction: column; gap: 6px; }
.cache-row { display: flex; align-items: center; gap: 10px; border: 1px solid $color-border-lighter; border-radius: 8px; padding: 6px 10px; font-size: 13px; }
.cv { font-weight: 600; }
.cs { color: $color-text-muted; font-size: 12px; }
.cp { margin-left: auto; color: $color-text-muted; font-size: 11px; word-break: break-all; }
.prune-row { display: flex; align-items: center; gap: 8px; margin-top: 10px; font-size: 13px; color: $color-text-secondary; }
.prune-btn { border: 1px solid $color-border; border-radius: 6px; padding: 3px 12px; background: #fff; cursor: pointer; font-size: 13px; &:hover { background: $color-bg-hover; } }
.pruned { color: $color-success; font-size: 12px; }
</style>