feat(web): cluster page shows token ring — ring topology/leader/load, pending tasks, active forward topology with owner, incremental log sync view (e2e verified)

This commit is contained in:
2026-08-18 09:13:20 +08:00
parent 6959025219
commit bf602016b4
9 changed files with 217 additions and 138 deletions

View File

@ -6,6 +6,7 @@ import type {
ClusterNodesResp,
InstallResult,
Remote,
RingSnapshot,
Settings,
StatusResp,
} from "./types";
@ -91,4 +92,7 @@ export const api = {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ keep }),
}),
// M6 token ring snapshot.
ring: () => request<RingSnapshot>("/api/manager/cluster/ring"),
};

View File

@ -173,3 +173,48 @@ export interface CacheResp {
cache: CacheEntry[];
removed?: string[];
}
// M6 token-ring snapshot (GET /cluster/ring).
export interface RingNode {
id: string;
addr: string;
isLeader?: boolean;
alive: boolean;
load: { memPct: number; netPct: number };
version?: string;
cache?: string[];
lastSeen?: number;
}
export interface RingTaskInfo {
id: string;
local: { name: string };
remote: { name: string };
link: { remotePort?: number };
}
export interface RingTopoEntry {
taskId: string;
ownerId: string;
active: boolean;
local: { name: string };
remote: { name: string };
link: { remotePort?: number };
}
export interface RingLogEntry {
seq: number;
node: string;
kind: string;
at?: number;
}
export interface RingSnapshot {
leaderId: string;
cycle: number;
roundDelayMs?: number;
nodes: RingNode[];
pending: RingTaskInfo[];
topology: RingTopoEntry[];
log: RingLogEntry[];
}

View File

@ -1,107 +1,130 @@
<template>
<div class="cluster-page">
<div class="cluster-top">
<h2 class="page-title">集群</h2>
<span class="page-sub">webui4frpc 节点网络 · frpc 二进制分发</span>
<button class="refresh-btn" @click="loadAll">刷新</button>
<h2 class="page-title">集群令牌环</h2>
<span class="page-sub">令牌环网拓扑 · 两轮一周期 · 增量日志同步</span>
<button class="refresh-btn" @click="load">刷新</button>
</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 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 v-if="ring">
<!-- 环状态leader + 轮次 -->
<section class="section">
<h3 class="section-title">环状态
<span class="hint">leader 持有首令牌并推进周期各节点同步完整集群信息</span>
</h3>
<div class="ring-state">
<span class="rs-item">leader: <b>{{ ring.leaderId }}</b></span>
<span class="rs-item">cycle: <b>{{ ring.cycle }}</b></span>
<span class="rs-item">节点数: <b>{{ ring.nodes.length }}</b></span>
<span class="rs-item">待办任务: <b>{{ ring.pending.length }}</b></span>
<span class="rs-item">活跃转发: <b>{{ ring.topology.length }}</b></span>
</div>
</div>
<div v-if="!nodes.length" class="empty-tip">尚未加入集群本机始终在内 -peer 连接邻居</div>
</section>
</section>
<!-- 本机二进制缓存 + 管理 -->
<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>
<!-- 环节点拓扑顺序 = 令牌传递链 -->
<section class="section">
<h3 class="section-title">环拓扑
<span class="hint">按环顺序排列令牌沿此链传递leader 被前驱监控</span>
</h3>
<div class="ring-line">
<template v-for="(n, i) in ring.nodes" :key="n.id">
<div class="ring-node" :class="{ leader: n.id === ring.leaderId, dead: !n.alive }">
<div class="rn-top">
<span class="rn-id">{{ shortId(n.id) }}</span>
<span v-if="n.id === ring.leaderId" class="rn-leader">LEADER</span>
<span v-if="!n.alive" class="rn-dead">离线</span>
</div>
<div class="rn-load">负载 {{ Math.round(n.load.memPct + n.load.netPct) }}</div>
<div class="rn-addr">{{ n.addr }}</div>
<div class="rn-cache" v-if="n.cache?.length">缓存: {{ n.cache.join(", ") }}</div>
</div>
<span v-if="i < ring.nodes.length - 1" class="ring-arrow"></span>
</template>
</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>
</section>
<el-empty v-if="!nodes.length && !cache.length" description="集群无数据(未配置 -peer 且无 frpc 缓存)" />
<!-- 待办任务挂令牌等负载最低者摘取 -->
<section class="section" v-if="ring.pending.length">
<h3 class="section-title">待办任务令牌携带</h3>
<div class="task-list">
<div v-for="t in ring.pending" :key="t.id" class="task-row pending">
<span class="tk-id">{{ t.id }}</span>
<span class="tk-name">{{ t.local.name }}</span>
<span class="tk-arrow"></span>
<span class="tk-name">{{ t.remote.name }}</span>
<span class="tk-port">:{{ t.link?.remotePort }}</span>
<span class="tk-state">待摘取</span>
</div>
</div>
</section>
<!-- 活跃转发拓扑谁负责哪个转发 -->
<section class="section">
<h3 class="section-title">活跃转发拓扑
<span class="hint">每个已建立的转发由所属节点持有全网一致</span>
</h3>
<div class="topo-list">
<div v-for="t in ring.topology" :key="t.taskId" class="topo-row">
<span class="tp-id">{{ t.taskId }}</span>
<span class="tp-name">{{ t.local.name }}</span>
<span class="tk-arrow"></span>
<span class="tp-name">{{ t.remote.name }}</span>
<span class="tp-port">:{{ t.link?.remotePort }}</span>
<span class="tp-owner">owner: {{ shortId(t.ownerId) }}</span>
<span class="tp-state" :class="{ ok: t.active }">{{ t.active ? "运行中" : "已停" }}</span>
</div>
<div v-if="!ring.topology.length" class="empty">暂无活跃转发</div>
</div>
</section>
<!-- 增量日志令牌同步的历史 -->
<section class="section">
<h3 class="section-title">集群日志增量同步
<span class="hint">追加式不可变随令牌 delta 传播各节点重放收敛</span>
</h3>
<div class="log-list">
<div v-for="(e, i) in logView" :key="i" class="log-row">
<span class="lg-seq">#{{ e.seq }}</span>
<span class="lg-node">{{ shortId(e.node) }}</span>
<span class="lg-kind">{{ kindLabel(e.kind) }}</span>
</div>
<div v-if="!logView.length" class="empty">日志为空</div>
</div>
</section>
</div>
<el-empty v-else description="集群未启动(无环节点)" />
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { ElMessage } from 'element-plus'
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { api } from '../api'
import type { ClusterNode, CacheEntry } from '../types'
import type { RingSnapshot } from '../types'
const nodes = ref<ClusterNode[]>([])
const cache = ref<CacheEntry[]>([])
const keep = ref(2)
const prunedMsg = ref('')
const ring = ref<RingSnapshot | null>(null)
let timer: number | null = null
const loadAll = async () => {
try {
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 load = async () => {
try { ring.value = await api.ring() } catch { /* keep last */ }
}
// 首个节点恒为本机;其后为心跳发现的邻居。状态按 seenAt/可达性粗分:
// 本机=ok邻居暂显示已发现后续可加 seenAt 超时判定)。
const nodeStateLabel = (i: number) => (i === 0 ? '在线(本机)' : '已发现')
const nodeStateClass = (i: number) => (i === 0 ? 'ok' : 'pending')
const shortId = (id: string) => (id.length > 22 ? '…' + id.slice(-20) : id)
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))
const kindLabel = (k: string) => {
switch (k) {
case 'forward.add': return '新增转发'
case 'forward.remove': return '删除转发'
case 'node.join': return '节点加入'
case 'node.leave': return '节点离开'
case 'leader.change': return 'leader 变更'
default: return k
}
}
onMounted(() => {
loadAll()
timer = window.setInterval(loadAll, 8000)
})
onBeforeUnmount(() => {
if (timer !== null) { clearInterval(timer); timer = null }
})
const logView = computed(() => [...(ring.value?.log ?? [])].reverse())
onMounted(() => { load(); timer = window.setInterval(load, 3000) })
onBeforeUnmount(() => { if (timer !== null) { clearInterval(timer); timer = null } })
</script>
<style scoped lang="scss">
@ -112,24 +135,31 @@ onBeforeUnmount(() => {
.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(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-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; }
.ring-state { display: flex; gap: 18px; flex-wrap: wrap; align-items: center; background: #fafafa; border: 1px solid $color-border-light; border-radius: 10px; padding: 10px 16px; }
.rs-item { font-size: 13px; color: $color-text-secondary; b { color: #303133; font-weight: 600; font-size: 14px; } }
.ring-line { display: flex; align-items: stretch; gap: 6px; flex-wrap: wrap; }
.ring-node { border: 2px solid $color-border; border-radius: 10px; padding: 8px 12px; min-width: 130px; background: #fff; &.leader { border-color: #409eff; background: #ecf5ff; } &.dead { border-color: $color-danger; background: #fef0f0; opacity: .7; } }
.rn-top { display: flex; align-items: center; gap: 6px; }
.rn-id { font-weight: 600; font-size: 13px; }
.rn-leader { font-size: 10px; background: #409eff; color: #fff; border-radius: 8px; padding: 0 6px; }
.rn-dead { font-size: 10px; background: $color-danger; color: #fff; border-radius: 8px; padding: 0 6px; }
.rn-load { font-size: 11px; color: $color-text-muted; margin-top: 4px; }
.rn-addr { font-size: 11px; color: $color-text-muted; }
.rn-cache { font-size: 11px; color: #409eff; }
.ring-arrow { align-self: center; color: $color-text-muted; font-size: 16px; }
.task-list, .topo-list, .log-list { display: flex; flex-direction: column; gap: 6px; }
.task-row, .topo-row { display: flex; align-items: center; gap: 8px; border: 1px solid $color-border-lighter; border-radius: 8px; padding: 6px 10px; font-size: 13px; }
.task-row.pending { background: #fffbe6; }
.tk-id, .tp-id { font-weight: 600; color: $color-text-secondary; font-size: 12px; }
.tk-name, .tp-name { font-weight: 500; }
.tk-arrow { color: $color-text-muted; }
.tk-port, .tp-port { color: $color-text-muted; }
.tk-state { margin-left: auto; font-size: 11px; color: #b8860b; }
.tp-owner { margin-left: auto; font-size: 11px; color: #409eff; }
.tp-state { font-size: 11px; padding: 1px 8px; border-radius: 8px; background: #f0f9eb; color: $color-success; &:not(.ok) { background: #fef0f0; color: $color-danger; } }
.log-row { display: flex; gap: 10px; font-size: 12px; font-family: monospace; border-bottom: 1px dashed $color-border-lighter; padding: 3px 2px; }
.lg-seq { color: $color-text-muted; }
.lg-node { color: #409eff; }
.lg-kind { color: $color-text-secondary; }
.empty { color: $color-text-muted; font-size: 13px; padding: 8px; }
</style>