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

@ -78,12 +78,12 @@ type State struct {
// Token is the circulating message: one physical token, two phases per cycle.
type Token struct {
Cycle int64 `json:"cycle"`
Phase int `json:"phase"`
State State `json:"state"`
Cycle int64 `json:"cycle"`
Phase int `json:"phase"`
State State `json:"state"`
Log []LogEntry `json:"logDelta,omitempty"`
Passed []string `json:"passed,omitempty"`
SentAt int64 `json:"sentAt,omitempty"`
Passed []string `json:"passed,omitempty"`
SentAt int64 `json:"sentAt,omitempty"`
}
// ---- 环图操作 ---- //

View File

@ -41,8 +41,8 @@ type Engine struct {
send func(ctx context.Context, next string, tk *Token) error
// inflight tracks token-in-flight state (leader only).
inflight inFlight
Log *ClusterLog
inflight inFlight
Log *ClusterLog
lastLogSent int64
// lastRingStart time of the previous cycle launch (leader throttle).
lastRingStart time.Time

View File

@ -29,19 +29,19 @@ const (
// LogEntry is one immutable, append-only cluster operation.
type LogEntry struct {
Seq int64 `json:"seq"`
Node string `json:"node"`
Kind string `json:"kind"`
Data json.RawMessage `json:"data,omitempty"`
At int64 `json:"at"`
Seq int64 `json:"seq"`
Node string `json:"node"`
Kind string `json:"kind"`
Data json.RawMessage `json:"data,omitempty"`
At int64 `json:"at"`
}
// ClusterLog is a node's local operation log with a sync watermark.
type ClusterLog struct {
mu sync.Mutex
Seq int64 // highest local seq issued
Log []LogEntry // append-only
Synced int64 // watermark: entries <= this are known to remote nodes
mu sync.Mutex
Seq int64 // highest local seq issued
Log []LogEntry // append-only
Synced int64 // watermark: entries <= this are known to remote nodes
}
// NewClusterLog creates an empty log with initial sequence.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>webui-frpc</title>
<script type="module" crossorigin src="/assets/index-C9C9_j3w.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C5T3LFGh.css">
<script type="module" crossorigin src="/assets/index-traMph4r.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CkgfmeCs.css">
</head>
<body>
<div id="app"></div>

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>