feat(web): 集群页负载均衡改为「本机份额」视图

跟着 c69fb2c 的模型改。旧 UI 是中心调度视角(超载阈值 = Score × 均值、
leader 会迁 X→Y),新模型里每台节点只管自己的转发,所以页面必须**以本机为主语**。

## 变化

- 标题徽标从「观察模式 / 已启用」+「本节点非 leader」改为
  **「观察模式(不释放)/ 已启用(会释放)」+「本机超出份额 / 本机在份额内 / 非集群成员」**
  —— 没有 leader 这回事了,每台都在判断。
- 阈值行换成真正的规则:
  `本机转发 N 条 > 平摊份额 M 且至少 2 条时,本机自行释放一条`,
  并给出 `集群共 X 条 / Y 台存活` 与冷却间隔。
- 节点条改成**按转发数排序**(最忙的在前),条宽按最大转发数归一化;
  「超载」徽标改为「超出份额」(判据是转发数 vs 份额,不再看 Score)。
  Score 从卡片上去掉——新规则不用它,留着会让人以为它参与了判断。
- 计划区按 blockedBy 分四种:释放进行中 / 将释放 / 冷却中(含「已过 N 分钟」)/
  暂不释放(把机器原因翻成人话)/ 单节点 / 已均衡(给出具体条数与份额)。
- 加一句说明:每台各自判断、自己撤销一条丢回环、由最闲者认领、无中心调度。

## 一处刻意的设计

`blockedBy` 的文案单独做映射,因为「超出份额但不动」正是最容易被误读成故障的
状态——渲染成人话(冷却期未过 / 上一次释放尚未确认 / 可释放的转发都已停用)
才是这个字段存在的意义。

vue-tsc --noEmit 干净;npm run build 通过(中途踩到 Vue 模板重复 class 属性
导致解析失败,已修);canary 实跑端点 200、字段齐全、默认 dry-run;
go build / go test ./... 全绿。
This commit is contained in:
JianFeeeee
2026-10-06 14:06:23 +08:00
parent c69fb2caba
commit 790eaa5ed1
2 changed files with 134 additions and 70 deletions

View File

@ -259,9 +259,44 @@ export interface RingSnapshot {
nodeKey?: string;
}
// BalanceReport is the load balancer's read-only view (GET /cluster/balance).
// It exists because the balancer is otherwise silent in exactly the healthy
// case, which is indistinguishable from "balancing is switched off".
// BalanceReport is one node's own view of rebalancing (GET /cluster/balance).
//
// It is per-node by design: every member runs the same rule against its own
// forwards, and the answer that matters is the one on the node doing (or
// refusing to do) the releasing. So this reports "my share" first and the peers
// second, and there is no cluster-wide "would migrate" plan — the ring's normal
// lowest-load claim decides where a released forward lands.
export interface BalanceReleasePlan {
local: string;
remote: string;
remotePort: number;
owner: string;
}
export interface BalanceReport {
mode: "off" | "dry-run" | "on";
enabled: boolean; // mode !== off
dryRun: boolean; // mode !== on
isMember: boolean;
self: string;
selfForwards: number;
share: number; // the even split: ceil(total / alive)
totalForwards: number;
aliveNodes: number;
selfOverShare: boolean;
// wouldRelease is present whenever this node is over its share — including
// while it is deliberately holding back, which is what blockedBy explains.
wouldRelease?: BalanceReleasePlan;
releasing?: BalanceReleasePlan;
blockedBy?: string; // single-node | below-min | balanced | nothing-active | cooldown | in-flight
cooldownSec: number;
lastReleaseAt?: number;
nodes: BalanceNodeReport[];
}
export interface BalanceNodeReport {
id: string;
alive: boolean;
@ -272,27 +307,6 @@ export interface BalanceNodeReport {
score: number;
}
// BalanceMigrationPlan describes one forward moving between two nodes.
export interface BalanceMigrationPlan {
local: string;
remote: string;
remotePort: number;
from: string;
to: string;
}
export interface BalanceReport {
enabled: boolean; // false = dry-run (observe only)
dryRun: boolean;
ratio: number; // overload = score above ratio x mean
sustainSec: number; // seconds an imbalance must persist before acting
isLeader: boolean; // only the leader acts; followers report empty plans
nodes: BalanceNodeReport[];
overloadedSince?: number; // unix sec the current imbalance started
wouldMigrate?: BalanceMigrationPlan; // plan once the window is satisfied
inFlight?: BalanceMigrationPlan; // migration currently between phases
}
// ---- Auth / accounts / API keys (M7) ----
export type AccessLevel = 'read' | 'write' | 'admin' | 'superadmin';

View File

@ -128,77 +128,106 @@
<h3 class="section-title">
<span class="k-ic">⚖</span>负载均衡
<span class="w4f-tag" :class="bal.dryRun ? 'w4f-tag-info' : 'w4f-tag-warn'">
{{ bal.dryRun ? '观察模式(不迁移)' : '已启用(会迁移)' }}
{{ bal.dryRun ? '观察模式(不释放)' : '已启用(会释放)' }}
</span>
<span class="w4f-tag" v-if="!bal.isLeader" v-text="'本节点非 leader,只报告'"></span>
<span class="w4f-tag" v-if="!bal.isMember" v-text="'非集群成员'"></span>
<span class="w4f-tag" v-else-if="bal.releasing" v-text="'释放进行中'"></span>
<span class="w4f-tag w4f-tag-warn" v-else-if="bal.selfOverShare" v-text="'本机超出份额'"></span>
<span class="w4f-tag w4f-tag-ok" v-else v-text="'本机在份额内'"></span>
<span class="title-actions">
<button class="w4f-btn ghost small" :disabled="busy" @click="load">⟳ 刷新</button>
</span>
</h3>
<div class="bal-thresholds">
<span>超载阈值:Score &gt; 均值 × {{ bal.ratio }}</span>
<span>需持续:{{ Math.round(bal.sustainSec / 60) }} 分钟</span>
<span>
规则:本机转发 <b>{{ bal.selfForwards }}</b> 条
&gt; 平摊份额 <b>{{ bal.share }}</b>
且至少 {{ 2 }} 条时,本机自行释放一条
</span>
<span>集群共 {{ bal.totalForwards }} 条 / {{ bal.aliveNodes }} 台存活</span>
<span>两次释放间隔 ≥ {{ Math.round(bal.cooldownSec / 60) }} 分钟</span>
<span v-if="bal.dryRun" class="bal-hint">
设 <code>W4F_BALANCE_MODE=on</code> 才会真正迁移
设 <code>W4F_BALANCE_MODE=on</code> 才会真正释放
</span>
</div>
<!-- Per-node score bars, ordered by score so the hot node is first. -->
<p class="bal-note">
每个节点各自判断自己的转发,超了就自己撤销一条并丢回环上,
由环节点按「最闲者优先」重新认领。没有中心调度。
</p>
<!-- Per-node forward counts. Bars are scaled to the busiest node so the
shape reads whether the cluster holds 3 forwards or 300; the share
marker is what actually decides, not the bar. -->
<div class="bal-nodes">
<div
v-for="n in balNodes"
:key="n.id"
class="bal-node"
:class="{ self: n.id === ring?.selfId, hot: isHot(n) }"
:class="{ self: n.id === bal.self, over: n.forwards > bal.share }"
>
<div class="bn-top">
<span class="bn-id">{{ shortId(n.id) }}</span>
<span v-if="n.id === ring?.selfId" class="w4f-tag w4f-tag-primary">本机</span>
<span v-if="n.id === bal.self" class="w4f-tag w4f-tag-primary">本机</span>
<span v-if="n.isLeader" class="w4f-tag w4f-tag-info">LEADER</span>
<span v-if="!n.alive" class="w4f-tag w4f-tag-danger">离线</span>
<span v-if="isHot(n)" class="w4f-tag w4f-tag-warn">超载</span>
<span v-if="n.alive && n.forwards > bal.share" class="w4f-tag w4f-tag-warn">超出份额</span>
</div>
<div class="w4f-bar" :class="{ err: isHot(n) }">
<i :style="{ width: barWidth(n.score) + '%' }" />
<div class="w4f-bar" :class="{ err: n.alive && n.forwards > bal.share }">
<i :style="{ width: barWidth(n.forwards) + '%' }" />
</div>
<div class="bn-stats">
<span>转发 <b>{{ n.forwards }}</b></span>
<span>内存 <b>{{ n.memPct.toFixed(1) }}%</b></span>
<span>网络 <b>{{ n.netPct.toFixed(1) }}%</b></span>
<span class="bn-score">Score <b>{{ n.score.toFixed(1) }}</b></span>
<span class="bn-score">份额 <b>{{ bal.share }}</b></span>
</div>
</div>
<div v-if="!balNodes.length" class="empty">暂无节点负载数据</div>
</div>
<!-- What the balancer would do. A follower deliberately shows no plan:
asking a random node must not imply a decision the cluster won't make. -->
<div class="bal-plan" v-if="bal.isLeader">
<div v-if="bal.inFlight" class="bp-inflight">
<span class="w4f-tag w4f-tag-warn">迁移进行中</span>
<!-- What THIS node would do. The answer is per-node by design: every
member runs the same rule on its own forwards, so there is no
cluster-wide plan and no "the leader will move X to Y". -->
<div class="bal-plan">
<div v-if="bal.releasing" class="bp-inflight">
<span class="w4f-tag w4f-tag-warn">释放进行中</span>
<span class="bp-text">
{{ bal.inFlight.local }}:{{ bal.inFlight.remotePort }}
从 {{ shortId(bal.inFlight.from) }} 迁往 {{ shortId(bal.inFlight.to) }}
本机已释放 {{ bal.releasing.local }}:{{ bal.releasing.remotePort }},
等待环把它认领到别的节点
</span>
</div>
<div v-else-if="bal.wouldMigrate" class="bp-would">
<span class="w4f-tag w4f-tag-warn">将迁移</span>
<div v-else-if="bal.wouldRelease && !bal.blockedBy" class="bp-would">
<span class="w4f-tag w4f-tag-warn">将释放</span>
<span class="bp-text">
{{ bal.wouldMigrate.local }}:{{ bal.wouldMigrate.remotePort }}
从 {{ shortId(bal.wouldMigrate.from) }} 迁往 {{ shortId(bal.wouldMigrate.to) }}
本机将撤销 {{ bal.wouldRelease.local }}:{{ bal.wouldRelease.remotePort }}
并丢回环上,由最闲节点接管
</span>
</div>
<div v-else-if="bal.overloadedSince" class="bp-waiting">
<span class="w4f-tag w4f-tag-info">等待确认</span>
<div v-else-if="bal.wouldRelease && bal.blockedBy === 'cooldown'" class="bp-waiting">
<span class="w4f-tag w4f-tag-info">冷却中</span>
<span class="bp-text">
失衡已持续 {{ Math.floor((nowMs / 1000 - bal.overloadedSince) / 60) }} 分钟,
需连续 {{ Math.round(bal.sustainSec / 60) }} 分钟后才会迁移
本机超出份额,但距上次释放不足
{{ Math.round(bal.cooldownSec / 60) }} 分钟
<template v-if="bal.lastReleaseAt">
(已过 {{ cooldownAgo }})
</template>
</span>
</div>
<div v-else-if="bal.wouldRelease" class="bp-waiting">
<span class="w4f-tag w4f-tag-info">暂不释放</span>
<span class="bp-text">本机超出份额,原因:{{ blockedLabel }}</span>
</div>
<div v-else-if="!bal.isMember" class="bp-ok">
<span class="w4f-tag w4f-tag-ok">单节点</span>
<span class="bp-text">本机未加入集群,无需均衡</span>
</div>
<div v-else class="bp-ok">
<span class="w4f-tag w4f-tag-ok">已均衡</span>
<span class="bp-text">各节点负载在阈值内,无需迁移</span>
<span class="bp-text">
本机持有 {{ bal.selfForwards }} 条,未超平摊份额 {{ bal.share }},无需释放
</span>
</div>
</div>
</section>
@ -284,7 +313,7 @@ import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
import { ElMessage } from 'element-plus'
import { api, downloadAuditCsv } from '../api'
import { canWrite } from '../auth'
import type { BalanceNodeReport, BalanceReport, RingSnapshot, RingTaskInfo, RingLogEntry } from '../types'
import type { BalanceReport, RingSnapshot, RingTaskInfo, RingLogEntry } from '../types'
const ring = ref<RingSnapshot | null>(null)
const busy = ref(false)
@ -306,23 +335,40 @@ const load = async () => {
}
// ---- load balancer view ---- //
// Ordered by descending score so the hottest node is always on top.
const balNodes = computed(() => [...(bal.value?.nodes ?? [])].sort((a, b) => b.score - a.score))
const balAvgScore = computed(() => {
const n = bal.value?.nodes ?? []
return n.length ? n.reduce((s, x) => s + x.score, 0) / n.length : 0
})
// isHot mirrors the server's rule: above ratio x mean. The server additionally
// requires a forward-count floor and the sustained window, so a node can be
// "hot" here while the plan is still empty — that is the waiting state.
const isHot = (n: BalanceNodeReport) =>
balAvgScore.value > 0 && n.score > balAvgScore.value * (bal.value?.ratio ?? 2)
// barWidth scales against the max score rather than an absolute, so the shape is
// readable whether the cluster is idle (scores ~5) or loaded (scores ~500).
const barWidth = (score: number) => {
const max = Math.max(...(bal.value?.nodes ?? []).map((n) => n.score), 1)
return Math.min(100, Math.round((score / max) * 100))
// Each node decides about its OWN forwards, so this page has no cluster-wide
// plan to show — only what THIS node holds and what it is about to do.
// Ordered by descending count: the busiest node first is what an operator
// scanning for a hotspot wants.
const balNodes = computed(() => [...(bal.value?.nodes ?? [])].sort((a, b) => b.forwards - a.forwards))
// barWidth scales against the busiest node rather than an absolute, so the shape
// is readable whether the cluster holds 3 forwards or 300. The bar is context;
// `share` is what actually decides whether a node is over.
const barWidth = (forwards: number) => {
const max = Math.max(...(bal.value?.nodes ?? []).map((n) => n.forwards), 1)
return Math.min(100, Math.round((forwards / max) * 100))
}
// blockedLabel renders the server's machine reason as something readable. The
// reason matters: "over its share but not acting" is otherwise indistinguishable
// from "broken".
const blockedLabel = computed(() => {
switch (bal.value?.blockedBy) {
case 'cooldown': return '冷却期未过'
case 'in-flight': return '上一次释放尚未确认'
case 'nothing-active': return '本机可释放的转发都已停用'
case 'below-min': return '持有量低于下限'
case 'single-node': return '单节点环'
default: return bal.value?.blockedBy || '未说明'
}
})
// cooldownAgo says how long ago the last release was, so an operator can see
// whether the cooldown is about to expire.
const cooldownAgo = computed(() => {
const t = bal.value?.lastReleaseAt ?? 0
if (!t) return ''
const s = Math.max(0, Math.floor(nowMs.value / 1000) - t)
if (s < 60) return s + ' 秒前'
return Math.floor(s / 60) + ' 分钟前'
})
const shortId = (id: string) => (id.length > 22 ? '…' + id.slice(-20) : id)
@ -776,7 +822,11 @@ onBeforeUnmount(() => {
min-width: 0;
}
.bal-node.self { border-color: var(--w4f-primary); }
.bal-node.hot { border-color: var(--w4f-warning); }
.bal-node.over { border-color: var(--w4f-warning); }
.bal-note {
margin: 0 0 10px; font-size: 11.5px; line-height: 1.55;
color: var(--w4f-muted);
}
.bn-top { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-bottom: 7px; }
.bn-id {
font-family: var(--w4f-mono, ui-monospace, monospace);