mirror of
https://gitcode.com/JianFeeeee/webui4frpc.git
synced 2026-10-15 12:14:37 +00:00
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:
@ -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: () =>
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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 > 均值 × {{ 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>
|
||||
|
||||
Reference in New Issue
Block a user