diff --git a/web/src/types.ts b/web/src/types.ts index 17618fb..923ec38 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -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'; diff --git a/web/src/views/ClusterView.vue b/web/src/views/ClusterView.vue index 5ba3955..2846843 100644 --- a/web/src/views/ClusterView.vue +++ b/web/src/views/ClusterView.vue @@ -128,77 +128,106 @@

⚖负载均衡 - {{ bal.dryRun ? '观察模式(不迁移)' : '已启用(会迁移)' }} + {{ bal.dryRun ? '观察模式(不释放)' : '已启用(会释放)' }} - + + + +

- 超载阈值:Score > 均值 × {{ bal.ratio }} - 需持续:{{ Math.round(bal.sustainSec / 60) }} 分钟 + + 规则:本机转发 {{ bal.selfForwards }} 条 + > 平摊份额 {{ bal.share }} + 且至少 {{ 2 }} 条时,本机自行释放一条 + + 集群共 {{ bal.totalForwards }} 条 / {{ bal.aliveNodes }} 台存活 + 两次释放间隔 ≥ {{ Math.round(bal.cooldownSec / 60) }} 分钟 - 设 W4F_BALANCE_MODE=on 才会真正迁移 + 设 W4F_BALANCE_MODE=on 才会真正释放
- +

+ 每个节点各自判断自己的转发,超了就自己撤销一条并丢回环上, + 由环节点按「最闲者优先」重新认领。没有中心调度。 +

+ +
{{ shortId(n.id) }} - 本机 + 本机 LEADER 离线 - 超载 + 超出份额
-
- +
+
转发 {{ n.forwards }} 内存 {{ n.memPct.toFixed(1) }}% 网络 {{ n.netPct.toFixed(1) }}% - Score {{ n.score.toFixed(1) }} + 份额 {{ bal.share }}
暂无节点负载数据
- -
-
- 迁移进行中 + +
+
+ 释放进行中 - {{ bal.inFlight.local }}:{{ bal.inFlight.remotePort }} - 从 {{ shortId(bal.inFlight.from) }} 迁往 {{ shortId(bal.inFlight.to) }} + 本机已释放 {{ bal.releasing.local }}:{{ bal.releasing.remotePort }}, + 等待环把它认领到别的节点
-
- 将迁移 +
+ 将释放 - {{ bal.wouldMigrate.local }}:{{ bal.wouldMigrate.remotePort }} - 从 {{ shortId(bal.wouldMigrate.from) }} 迁往 {{ shortId(bal.wouldMigrate.to) }} + 本机将撤销 {{ bal.wouldRelease.local }}:{{ bal.wouldRelease.remotePort }} + 并丢回环上,由最闲节点接管
-
- 等待确认 +
+ 冷却中 - 失衡已持续 {{ Math.floor((nowMs / 1000 - bal.overloadedSince) / 60) }} 分钟, - 需连续 {{ Math.round(bal.sustainSec / 60) }} 分钟后才会迁移 + 本机超出份额,但距上次释放不足 + {{ Math.round(bal.cooldownSec / 60) }} 分钟 +
+
+ 暂不释放 + 本机超出份额,原因:{{ blockedLabel }} +
+
+ 单节点 + 本机未加入集群,无需均衡 +
已均衡 - 各节点负载在阈值内,无需迁移 + + 本机持有 {{ bal.selfForwards }} 条,未超平摊份额 {{ bal.share }},无需释放 +
@@ -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(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);