feat(web): 集群页显示负载均衡视图

接 8176478 的只读端点。此前 balancer 的判断**只能从日志看**,而且只在"确实失衡
且迟滞窗口已满"时才输出 —— 运维在健康状态下得到的是沉默,与"负载均衡没开"完全
无法区分。

## 界面

- **各节点 Score 条**:按分数降序(最热的在顶部),条宽按最大分数归一化 ——
  空闲时(score≈5)和繁忙时(score≈500)都能看出形状。
- **阈值说明**:直接写出"超载 = Score > 均值 × N"与"需持续 N 分钟",运维不必
  去猜这两个数字怎么定的。
- **四种状态**(仅 leader 显示):
  - 已均衡 —— 无超载节点
  - 等待确认 —— 已超载但迟滞窗口未满,并显示"已持续 N 分钟 / 需 N 分钟"
    ★ 这是最有价值的一态:否则"为什么还不迁"无从回答
  - 将迁移 / 迁移进行中 —— 给出具体转发与两端节点
- **follower 不显示计划**,与端点的 isLeader 门禁一致:随便查一台节点不该
  暗示集群不会做出的决定。
- **端点 404 时整块不渲染** —— 缺席比空面板诚实(空面板会让人以为支持)。

## 一处刻意的非对称

`load()` 里 ring 失败保留上次值,balance 失败则**清空**。理由:ring 是拓扑快照,
陈旧可接受;balance 是**决策面**,端点开始失败后仍显示"已均衡"比什么都不显示
更危险 —— 会让人以为系统确认过集群是好的。

vue-tsc --noEmit 干净;go build / go test ./... (go clean -testcache) 全绿。
This commit is contained in:
JianFeeeee
2026-10-06 12:58:17 +08:00
parent d347d2a648
commit b40b53b40b
3 changed files with 207 additions and 1 deletions

View File

@ -2,6 +2,7 @@
import type {
ApiKey,
ApiKeyCreated,
BalanceReport,
BinaryStatus,
CacheResp,
CanvasData,
@ -159,6 +160,11 @@ export const api = {
// M6 token ring snapshot.
ring: () => request<RingSnapshot>("/api/manager/cluster/ring"),
// Load balancer read-only view. Reports per-node scores, whether anything is
// over the threshold, and the migration it would perform. Never mutates the
// cluster. 404 when the balancer is not wired up on that node.
balance: () => request<BalanceReport>("/api/manager/cluster/balance"),
// M6 cluster lifecycle (创建/加入/退出/移除).
// clusterCreate: reseed THIS node as a fresh standalone leader (创建集群).
clusterCreate: () =>

View File

@ -259,6 +259,40 @@ 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".
export interface BalanceNodeReport {
id: string;
alive: boolean;
isLeader: boolean;
forwards: number;
memPct: number;
netPct: number;
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

@ -121,6 +121,87 @@
</div>
</section>
<!-- Load balancer (read-only). Shown only when the endpoint responds: a
404 means this node has no balancer wired up, and an absent section is
the honest signal for "this build doesn't do balancing". -->
<section class="section w4f-card bal-section" v-if="bal">
<h3 class="section-title">
<span class="k-ic">⚖</span>负载均衡
<span class="w4f-tag" :class="bal.dryRun ? 'w4f-tag-info' : 'w4f-tag-warn'">
{{ bal.dryRun ? '观察模式(不迁移)' : '已启用(会迁移)' }}
</span>
<span class="w4f-tag" v-if="!bal.isLeader" v-text="'本节点非 leader,只报告'"></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 v-if="bal.dryRun" class="bal-hint">
设 <code>W4F_BALANCE_MODE=on</code> 才会真正迁移
</span>
</div>
<!-- Per-node score bars, ordered by score so the hot node is first. -->
<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) }"
>
<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.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>
</div>
<div class="w4f-bar" :class="{ err: isHot(n) }">
<i :style="{ width: barWidth(n.score) + '%' }" />
</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>
</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>
<span class="bp-text">
{{ bal.inFlight.local }}:{{ bal.inFlight.remotePort }}
从 {{ shortId(bal.inFlight.from) }} 迁往 {{ shortId(bal.inFlight.to) }}
</span>
</div>
<div v-else-if="bal.wouldMigrate" 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) }}
</span>
</div>
<div v-else-if="bal.overloadedSince" 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) }} 分钟后才会迁移
</span>
</div>
<div v-else class="bp-ok">
<span class="w4f-tag w4f-tag-ok">已均衡</span>
<span class="bp-text">各节点负载在阈值内,无需迁移</span>
</div>
</div>
</section>
<!-- Active forward topology -->
<section class="section w4f-card">
<h3 class="section-title"><span class="k-ic">→</span>活跃转发拓扑</h3>
@ -203,10 +284,14 @@ import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
import { ElMessage } from 'element-plus'
import { api, downloadAuditCsv } from '../api'
import { canWrite } from '../auth'
import type { RingSnapshot, RingTaskInfo, RingLogEntry } from '../types'
import type { BalanceNodeReport, BalanceReport, RingSnapshot, RingTaskInfo, RingLogEntry } from '../types'
const ring = ref<RingSnapshot | null>(null)
const busy = ref(false)
// bal is null when the node has no balancer wired up (endpoint 404s). The
// section is simply not rendered then, which is the honest signal for "this
// build does not do balancing" — better than an empty panel implying it does.
const bal = ref<BalanceReport | null>(null)
let timer: number | null = null
let visHandler: (() => void) | null = null
@ -214,6 +299,29 @@ const joinDialog = reactive({ visible: false, addr: '', key: '' })
const load = async () => {
try { ring.value = await api.ring() } catch { /* keep last */ }
// Loaded alongside the ring, but unlike it a failure clears the view: the
// balancer report is a decision surface, and showing a stale "已均衡" after the
// endpoint starts failing would be worse than showing nothing.
try { bal.value = await api.balance() } catch { bal.value = null }
}
// ---- 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))
}
const shortId = (id: string) => (id.length > 22 ? '…' + id.slice(-20) : id)
@ -260,6 +368,8 @@ const stateDotClass = computed(() => {
// ---- last-sync freshness (ops cares "环还活着吗", not the cycle counter) ----
const now = ref(Date.now())
// nowMs mirrors `now` as a plain number for the balance section's minute math.
const nowMs = computed(() => now.value)
let tick: number | null = null
const lastSyncAgo = computed(() => {
const ls = ring.value?.lastSync ?? 0
@ -638,4 +748,60 @@ onBeforeUnmount(() => {
.kpis { grid-template-columns: minmax(0, 1fr); }
.page-title { font-size: 15.5px; }
}
/* ---- Load balancer section ---- */
.bal-section .section-title { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.bal-section .section-title .k-ic { margin-right: 0; }
.bal-thresholds {
display: flex; flex-wrap: wrap; gap: 6px 14px;
font-size: 11.5px; color: var(--w4f-muted);
margin-bottom: 10px;
}
.bal-hint { color: var(--w4f-warning); }
.bal-hint code {
font-family: var(--w4f-mono, ui-monospace, monospace);
background: var(--w4f-card-2); padding: 1px 4px; border-radius: 3px;
}
.bal-nodes {
display: grid; gap: 8px;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
}
.bal-node {
border: 1px solid var(--w4f-line);
border-radius: 8px;
padding: 9px 11px;
background: var(--w4f-card-2);
min-width: 0;
}
.bal-node.self { border-color: var(--w4f-primary); }
.bal-node.hot { border-color: var(--w4f-warning); }
.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);
font-size: 12px; font-weight: 700;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.bn-stats {
display: flex; flex-wrap: wrap; gap: 3px 12px;
font-size: 11.5px; color: var(--w4f-muted); margin-top: 7px;
}
.bn-stats b { color: var(--w4f-text); font-variant-numeric: tabular-nums; }
.bn-score { margin-left: auto; }
.bal-plan { margin-top: 11px; }
.bp-inflight, .bp-would, .bp-waiting, .bp-ok {
display: flex; align-items: center; gap: 9px; flex-wrap: wrap;
padding: 8px 11px; border-radius: 8px;
font-size: 12.5px;
background: var(--w4f-card-2);
border: 1px solid var(--w4f-line);
}
.bp-inflight { border-color: var(--w4f-warning); }
.bp-text { min-width: 0; overflow-wrap: anywhere; }
@media (max-width: 380px) {
.bn-score { margin-left: 0; }
}
</style>