From b40b53b40bafc940312b7763c9285f41c5e5e2e1 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Tue, 6 Oct 2026 12:58:17 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E9=9B=86=E7=BE=A4=E9=A1=B5?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E8=B4=9F=E8=BD=BD=E5=9D=87=E8=A1=A1=E8=A7=86?= =?UTF-8?q?=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 接 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) 全绿。 --- web/src/api.ts | 6 ++ web/src/types.ts | 34 +++++++ web/src/views/ClusterView.vue | 168 +++++++++++++++++++++++++++++++++- 3 files changed, 207 insertions(+), 1 deletion(-) diff --git a/web/src/api.ts b/web/src/api.ts index be90532..92352d3 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -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("/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("/api/manager/cluster/balance"), + // M6 cluster lifecycle (创建/加入/退出/移除). // clusterCreate: reseed THIS node as a fresh standalone leader (创建集群). clusterCreate: () => diff --git a/web/src/types.ts b/web/src/types.ts index 34ade4c..17618fb 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -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'; diff --git a/web/src/views/ClusterView.vue b/web/src/views/ClusterView.vue index f50fc72..5ba3955 100644 --- a/web/src/views/ClusterView.vue +++ b/web/src/views/ClusterView.vue @@ -121,6 +121,87 @@ + +
+

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

+ +
+ 超载阈值:Score > 均值 × {{ bal.ratio }} + 需持续:{{ Math.round(bal.sustainSec / 60) }} 分钟 + + 设 W4F_BALANCE_MODE=on 才会真正迁移 + +
+ + +
+
+
+ {{ shortId(n.id) }} + 本机 + LEADER + 离线 + 超载 +
+
+ +
+
+ 转发 {{ n.forwards }} + 内存 {{ n.memPct.toFixed(1) }}% + 网络 {{ n.netPct.toFixed(1) }}% + Score {{ n.score.toFixed(1) }} +
+
+
暂无节点负载数据
+
+ + +
+
+ 迁移进行中 + + {{ bal.inFlight.local }}:{{ bal.inFlight.remotePort }} + 从 {{ shortId(bal.inFlight.from) }} 迁往 {{ shortId(bal.inFlight.to) }} + +
+
+ 将迁移 + + {{ bal.wouldMigrate.local }}:{{ bal.wouldMigrate.remotePort }} + 从 {{ shortId(bal.wouldMigrate.from) }} 迁往 {{ shortId(bal.wouldMigrate.to) }} + +
+
+ 等待确认 + + 失衡已持续 {{ Math.floor((nowMs / 1000 - bal.overloadedSince) / 60) }} 分钟, + 需连续 {{ Math.round(bal.sustainSec / 60) }} 分钟后才会迁移 + +
+
+ 已均衡 + 各节点负载在阈值内,无需迁移 +
+
+

→活跃转发拓扑

@@ -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(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(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; } +}