feat(web): cluster page optimized — self/leader/phase badges, ring arrow, create vs revoke command styling, topology owner highlight, colored log kinds, self highlights

This commit is contained in:
2026-08-18 09:54:29 +08:00
parent 40980944e5
commit 86b265637d
6 changed files with 94 additions and 53 deletions

View File

@ -190,6 +190,7 @@ export interface RingNode {
export interface RingTaskInfo {
id: string;
revoke?: boolean;
local: { name: string };
remote: { name: string };
link: { remotePort?: number };
@ -212,8 +213,10 @@ export interface RingLogEntry {
}
export interface RingSnapshot {
selfId: string;
leaderId: string;
cycle: number;
phase: number; // 1=collect, 2=sync
roundDelayMs?: number;
nodes: RingNode[];
pending: RingTaskInfo[];

View File

@ -2,66 +2,71 @@
<div class="cluster-page">
<div class="cluster-top">
<h2 class="page-title">集群令牌环</h2>
<span class="page-sub">令牌环网拓扑 · 两轮一周期 · 增量日志同步</span>
<span class="page-sub">令牌环网 · 两轮一周期 · 画布差异命令进令牌 · 增量日志同步</span>
<button class="refresh-btn" @click="load">刷新</button>
</div>
<div v-if="ring">
<!-- 环状态leader + 轮次 -->
<!-- 环状态 -->
<section class="section">
<h3 class="section-title">环状态
<span class="hint">leader 首令牌并推进周期各节点同步完整集群信息</span>
<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 :class="{ leader: ring.selfId === ring.leaderId }">{{ shortId(ring.selfId) }}</b></span>
<span class="rs-item">leader: <b>{{ shortId(ring.leaderId) }}</b></span>
<span class="rs-item">周期: <b>{{ ring.cycle }}</b></span>
<span class="rs-item">令牌相: <span class="phase-badge" :class="ring.phase === 2 ? 'sync' : 'collect'">{{ ring.phase === 2 ? '同步轮' : '收集轮' }}</span></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.pending.length }}</b></span>
<span class="rs-item">活跃转发: <b>{{ ring.topology.length }}</b></span>
</div>
</section>
<!-- 节点拓扑顺序 = 令牌传递链 -->
<!-- 拓扑 -->
<section class="section">
<h3 class="section-title">环拓扑
<span class="hint">按环顺序排列令牌沿此链传递leader 被前驱监控</span>
<span class="hint">顺序=令牌传递本机高亮leader 蓝色离线标红前驱监控 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="ring-node" :class="{ leader: n.id === ring.leaderId, dead: !n.alive, self: n.id === ring.selfId }">
<div class="rn-top">
<span class="rn-id">{{ shortId(n.id) }}</span>
<span v-if="n.id === ring.selfId" class="rn-self">本机</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 v-if="n.id === ring.selfId" class="rn-role">编辑入口</div>
</div>
<span v-if="i < ring.nodes.length - 1" class="ring-arrow"></span>
</template>
</div>
</section>
<!-- 待办任务挂令牌等负载最低者摘取 -->
<!-- 待办任务 / 命令 -->
<section class="section" v-if="ring.pending.length">
<h3 class="section-title">待办任务令牌携带</h3>
<h3 class="section-title">令牌命令待执行
<span class="hint">画布差异产生新增转发 / 撤销转发随令牌环行由对应节点执行</span>
</h3>
<div class="task-list">
<div v-for="t in ring.pending" :key="t.id" class="task-row pending">
<div v-for="t in ring.pending" :key="t.id" class="task-row" :class="t.revoke ? 'revoke' : 'pending'">
<span class="tk-id">{{ t.id }}</span>
<span class="tk-cmd">{{ t.revoke ? '撤销' : '新增' }}</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>
<span class="hint">每个转发由 owner 节点持有localOnly 转发只在本节点 webui 可见</span>
</h3>
<div class="topo-list">
<div v-for="t in ring.topology" :key="t.taskId" class="topo-row">
@ -70,22 +75,22 @@
<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-owner" :class="{ self: t.ownerId === ring.selfId }">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 v-if="!ring.topology.length" class="empty">暂无活跃转发画布编辑后将经令牌分发</div>
</div>
</section>
<!-- 增量日志令牌同步的历史 -->
<!-- 增量日志 -->
<section class="section">
<h3 class="section-title">集群日志增量同步
<span class="hint">追加式不可变随令牌 delta 传播各节点重放收敛</span>
<span class="hint">追加式随令牌 delta 重放到全网一致</span>
</h3>
<div class="log-list">
<div v-for="(e, i) in logView" :key="i" class="log-row">
<div v-for="(e, i) in logView" :key="i" class="log-row" :class="logKindClass(e.kind)">
<span class="lg-seq">#{{ e.seq }}</span>
<span class="lg-node">{{ shortId(e.node) }}</span>
<span class="lg-node" :class="{ self: e.node === ring.selfId }">{{ shortId(e.node) }}</span>
<span class="lg-kind">{{ kindLabel(e.kind) }}</span>
</div>
<div v-if="!logView.length" class="empty">日志为空</div>
@ -113,7 +118,7 @@ const shortId = (id: string) => (id.length > 22 ? '…' + id.slice(-20) : id)
const kindLabel = (k: string) => {
switch (k) {
case 'forward.add': return '新增转发'
case 'forward.remove': return '删除转发'
case 'forward.remove': return '撤销转发'
case 'node.join': return '节点加入'
case 'node.leave': return '节点离开'
case 'leader.change': return 'leader 变更'
@ -121,6 +126,17 @@ const kindLabel = (k: string) => {
}
}
const logKindClass = (k: string) => {
switch (k) {
case 'forward.add': return 'add'
case 'forward.remove': return 'remove'
case 'node.join': return 'join'
case 'node.leave': return 'leave'
case 'leader.change': return 'leader'
default: return ''
}
}
const logView = computed(() => [...(ring.value?.log ?? [])].reverse())
onMounted(() => { load(); timer = window.setInterval(load, 3000) })
@ -135,31 +151,39 @@ onBeforeUnmount(() => { if (timer !== null) { clearInterval(timer); timer = null
.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; } }
.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-state { display: flex; gap: 16px; 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; } b.leader { color: #409eff; } }
.phase-badge { font-size: 11px; padding: 1px 8px; border-radius: 8px; background: #ecf5ff; color: #409eff; &.sync { background: #f0f9eb; color: $color-success; } }
.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; } }
.ring-node { border: 2px solid $color-border; border-radius: 10px; padding: 8px 12px; min-width: 130px; background: #fff; &.leader { border-color: #409eff; background: #ecf5ff; } &.self { outline: 2px solid #409eff33; box-shadow: 0 0 0 4px #409eff1a; } &.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-self { font-size: 10px; background: #67c23a; color: #fff; border-radius: 8px; padding: 0 6px; }
.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; }
.rn-role { font-size: 10px; color: $color-success; margin-top: 2px; }
.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; }
.task-row.pending { background: #ecf5ff; }
.task-row.revoke { background: #fef0f0; }
.tk-cmd { font-size: 11px; padding: 1px 8px; border-radius: 8px; background: #409eff; color: #fff; }
.task-row.revoke .tk-cmd { background: $color-danger; }
.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-owner { margin-left: auto; font-size: 11px; color: #409eff; &.self { color: $color-success; font-weight: 600; } }
.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; }
.log-row.add .lg-kind { color: $color-success; }
.log-row.remove .lg-kind { color: $color-danger; }
.log-row.join .lg-kind { color: #909399; }
.log-row.leader .lg-kind { color: #409eff; }
.lg-seq { color: $color-text-muted; }
.lg-node { color: #409eff; }
.lg-node { color: #777; &.self { color: #409eff; font-weight: 600; } }
.lg-kind { color: $color-text-secondary; }
.empty { color: $color-text-muted; font-size: 13px; padding: 8px; }
</style>