mirror of
https://gitcode.com/JianFeeeee/webui4frpc.git
synced 2026-09-20 00:47:57 +00:00
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:
@ -47,6 +47,8 @@ type Engine struct {
|
||||
lastLogSent int64
|
||||
// lastRingStart time of the previous cycle launch (leader throttle).
|
||||
lastRingStart time.Time
|
||||
// curPhase tracks the most recently processed token phase (frontend).
|
||||
curPhase int
|
||||
}
|
||||
|
||||
// NewEngine builds the engine; state holds this node as initial leader unless
|
||||
@ -189,6 +191,7 @@ func (e *Engine) phase2(ctx context.Context, tk *Token) error {
|
||||
|
||||
// OnToken receives the token: process by phase, return updated token.
|
||||
func (e *Engine) OnToken(ctx context.Context, tk *Token) (*Token, error) {
|
||||
e.curPhase = tk.Phase
|
||||
log.Printf("ring[%s] OnToken cycle=%d phase=%d passed=%v", e.ID, tk.Cycle, tk.Phase, tk.Passed)
|
||||
switch tk.Phase {
|
||||
case PhaseCollect:
|
||||
@ -227,8 +230,10 @@ func (e *Engine) Forward(ctx context.Context, tk *Token) error {
|
||||
|
||||
// Snapshot returns a serializable view of the ring for the frontend.
|
||||
type RingSnapshot struct {
|
||||
SelfID string `json:"selfId"`
|
||||
LeaderID string `json:"leaderId"`
|
||||
Cycle int64 `json:"cycle"`
|
||||
Phase int `json:"phase"`
|
||||
RoundDelay int64 `json:"roundDelayMs"`
|
||||
Nodes []Node `json:"nodes"`
|
||||
Pending []*Task `json:"pending"`
|
||||
@ -236,10 +241,19 @@ type RingSnapshot struct {
|
||||
Log []LogEntry `json:"log,omitempty"`
|
||||
}
|
||||
|
||||
func (e *Engine) currentPhase() int {
|
||||
if e.curPhase == 0 {
|
||||
return PhaseCollect
|
||||
}
|
||||
return e.curPhase
|
||||
}
|
||||
|
||||
func (e *Engine) Snapshot() *RingSnapshot {
|
||||
snap := &RingSnapshot{
|
||||
SelfID: e.ID,
|
||||
LeaderID: e.state.LeaderID,
|
||||
Cycle: e.state.Cycle,
|
||||
Phase: e.currentPhase(),
|
||||
RoundDelay: e.state.RoundDelay.Milliseconds(),
|
||||
Nodes: e.state.Nodes,
|
||||
Pending: e.state.PendingList(),
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
4
internal/httpapi/dist/index.html
vendored
4
internal/httpapi/dist/index.html
vendored
@ -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-traMph4r.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CkgfmeCs.css">
|
||||
<script type="module" crossorigin src="/assets/index-CNe69otc.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Cq7Ykgzy.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@ -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[];
|
||||
|
||||
@ -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>
|
||||
Reference in New Issue
Block a user