mirror of
https://gitcode.com/JianFeeeee/webui4frpc.git
synced 2026-10-15 12:14:37 +00:00
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:
@ -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';
|
||||
|
||||
@ -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 > 均值 × {{ bal.ratio }}</span>
|
||||
<span>需持续:{{ Math.round(bal.sustainSec / 60) }} 分钟</span>
|
||||
<span>
|
||||
规则:本机转发 <b>{{ bal.selfForwards }}</b> 条
|
||||
> 平摊份额 <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);
|
||||
|
||||
Reference in New Issue
Block a user