feat(cluster,webapi): 集群页可切换负载均衡模式(每节点一个开关)

## 为什么之前没有开关(以及为什么现在这么加)

模式原本只读一次环境变量 `W4F_BALANCE_MODE`,UI 只显示「观察模式 / 已启用」。
把开关加进来时有两个坑必须一起解决,否则开关是个摆设:

1. **不持久化就等于骗人**:只用 env,页面上翻一下、重启一次就回到默认,
   而页面已经显示成功了。
2. **不能做成集群级开关**:每个成员只释放自己的转发,根本没有一个动作能把它们
   一起翻过来。做成集群开关就是撒谎。

所以:**每节点一个开关**,写的是本节点自己的设置。

## 持久化与优先级

- 新增 `settings` 表的 `balance_mode` 列 + `Store.BalanceMode()`。**单独一列**,
  不是塞进 settings JSON:那个 JSON 是整份重写的,而这是会被各节点同时写、必须
  各自收敛的状态,跟 `nodeKey`/`settings` 同一类处理。
- **存了就以存的为准**,env 只作首次启动的默认值;`Settings()` 读回时带上它,
  但 `UpdateSettings` 只在非空时镜像到列,所以一次无关的设置保存不会把它清掉。
- `SetModeFromUI` **先落盘再应用**:写失败就什么都不改,否则节点会运行一个重启后
  复现不出来的模式——开关就成了谎言。
- 认不出的模式一律拒绝(400),不猜。

## 报告新增两个字段

- `modeSource`: `env` | `stored` —— 说清当前模式是启动参数给的还是有人在这里选的。
- `canControl`: 由**调用者的权限**决定,只读身份不会看到一个点了会 403 的开关。

`PUT /cluster/balance/mode` 走 **write** 级鉴权:释放会停 worker、断活连接。

## 两处我自己抓到并修掉的错

1. **`ModeSource()` 一开始写成「能存就算 stored」**(判据是 `persist != nil`),
   于是**每个有 store 的节点都自称 stored**,哪怕从没人设过。canary 第一轮就露了:
   `mode=dry-run source=stored`。改成看「是否真的存过值」。
2. **启动日志打的是 env 变量而不是生效值**,存过 `on` 之后仍打 `DRY-RUN`——
   上一行的 `loaded from settings` 还在,两行自相矛盾,正好会让人以为释放是关的
   而它其实开着。

## 测试

新增 6 条:存的模式压过 env、没存就回落 env、落盘失败则模式不变、乱值被拒、
UI 切换同时落盘并生效、无持久化能力时不宣称可控制。
canary 实跑 6 步:初始 `dry-run/env` → 设 `on` → 复查 `on/stored` → 乱值 400 →
乱值后模式不变 → **重启后仍 `on/stored`**,启动日志 `load balancing ON (source=stored …)`。
vue-tsc 干净;go build / vet / go test ./... 全绿。
This commit is contained in:
JianFeeeee
2026-10-06 14:37:18 +08:00
parent 790eaa5ed1
commit c90d4f3791
8 changed files with 611 additions and 266 deletions

View File

@ -165,6 +165,16 @@ export const api = {
// cluster. 404 when the balancer is not wired up on that node.
balance: () => request<BalanceReport>("/api/manager/cluster/balance"),
// setBalanceMode switches load balancing on THIS node (off | dry-run | on).
// Per-node by design: each member releases only its own forwards, so there is
// no cluster-wide switch to flip. Requires write level.
setBalanceMode: (mode: string) =>
request<BalanceReport>("/api/manager/cluster/balance/mode", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mode }),
}),
// M6 cluster lifecycle (创建/加入/退出/移除).
// clusterCreate: reseed THIS node as a fresh standalone leader (创建集群).
clusterCreate: () =>

View File

@ -9,254 +9,254 @@ export type CanvasRemote = Remote;
export type CanvasLink = Link;
export interface Local {
name: string;
ip: string;
port: number;
protocol: string; // tcp | udp | http | https
// localOnly: forward stays on this node (loopback kept), no cluster.
localOnly?: boolean;
name: string;
ip: string;
port: number;
protocol: string; // tcp | udp | http | https
// localOnly: forward stays on this node (loopback kept), no cluster.
localOnly?: boolean;
// M1 advanced transport knobs (optional; empty/zero = frpc defaults)
useEncryption?: boolean;
useCompression?: boolean;
bandwidthLimit?: string;
poolCount?: number;
metadatas?: Record<string, string>;
annotations?: Record<string, string>;
// M1 advanced transport knobs (optional; empty/zero = frpc defaults)
useEncryption?: boolean;
useCompression?: boolean;
bandwidthLimit?: string;
poolCount?: number;
metadatas?: Record<string, string>;
annotations?: Record<string, string>;
// M2 HTTP/HTTPS routing (http/https only)
customDomains?: string; // comma-separated
subdomain?: string;
locations?: string[];
hostHeaderRewrite?: string;
httpHeaders?: Record<string, string>;
basicAuthUser?: string;
basicAuthPassword?: string;
// M2 HTTP/HTTPS routing (http/https only)
customDomains?: string; // comma-separated
subdomain?: string;
locations?: string[];
hostHeaderRewrite?: string;
httpHeaders?: Record<string, string>;
basicAuthUser?: string;
basicAuthPassword?: string;
// M3 load balancing group + health check
lbGroup?: string;
lbGroupKey?: string;
healthCheckType?: string; // "" | tcp | http
healthCheckPath?: string;
healthCheckTimeout?: number;
healthCheckMaxFailed?: number;
healthCheckInterval?: number;
// M3 load balancing group + health check
lbGroup?: string;
lbGroupKey?: string;
healthCheckType?: string; // "" | tcp | http
healthCheckPath?: string;
healthCheckTimeout?: number;
healthCheckMaxFailed?: number;
healthCheckInterval?: number;
}
export interface Remote {
name: string;
ip: string;
port: number; // frps connect port
token?: string;
url?: string;
enabled: boolean;
name: string;
ip: string;
port: number; // frps connect port
token?: string;
url?: string;
enabled: boolean;
// M1 transport section
transportProtocol?: string; // tcp | quic | kcp | websocket
transportTls?: boolean;
transportPool?: number;
transportTlsServerName?: string;
// M1 transport section
transportProtocol?: string; // tcp | quic | kcp | websocket
transportTls?: boolean;
transportPool?: number;
transportTlsServerName?: string;
// M2: frps vhostHTTPPort
vhostHttpPort?: number;
// M2: frps vhostHTTPPort
vhostHttpPort?: number;
// M3: local frpc admin API (webServer)
adminAddr?: string;
adminPort?: number;
adminUser?: string;
adminPassword?: string;
// M3: local frpc admin API (webServer)
adminAddr?: string;
adminPort?: number;
adminUser?: string;
adminPassword?: string;
}
export interface Link {
id?: number;
local: string;
remote: string;
remotePort: number;
offsetX?: number;
offsetY?: number;
// group: management label for one-click group start/stop on the forwards
// page (unrelated to frps load-balancing lbGroup on Local).
group?: string;
// disabled: per-forward stop flag. A disabled forward is omitted from the
// frpc config (local) / left out of the ring topology (cluster), so stop is
// per-card and survives canvas re-saves.
disabled?: boolean;
id?: number;
local: string;
remote: string;
remotePort: number;
offsetX?: number;
offsetY?: number;
// group: management label for one-click group start/stop on the forwards
// page (unrelated to frps load-balancing lbGroup on Local).
group?: string;
// disabled: per-forward stop flag. A disabled forward is omitted from the
// frpc config (local) / left out of the ring topology (cluster), so stop is
// per-card and survives canvas re-saves.
disabled?: boolean;
}
export interface CanvasData {
locals: Local[];
remotes: Remote[];
links: Link[];
locals: Local[];
remotes: Remote[];
links: Link[];
}
export interface Settings {
autoStartProfiles: boolean;
restartOnExit: boolean;
restartIntervalSeconds: number;
binaryPath?: string;
autoStartProfiles: boolean;
restartOnExit: boolean;
restartIntervalSeconds: number;
binaryPath?: string;
}
export interface ProcessStatus {
state: string;
pid?: number;
startTime?: number;
restartCount: number;
exitCode?: number;
err?: string;
state: string;
pid?: number;
startTime?: number;
restartCount: number;
exitCode?: number;
err?: string;
}
export interface ProxyState {
name: string;
type: string;
status: string; // new | wait start | start error | running | check failed | closed
err?: string;
local_addr?: string;
remote_addr?: string;
name: string;
type: string;
status: string; // new | wait start | start error | running | check failed | closed
err?: string;
local_addr?: string;
remote_addr?: string;
}
export interface StatusProfile {
name: string;
enabled: boolean;
process: ProcessStatus;
hasProcess: boolean;
forwards: { service: string; remotePort: number; localPort?: number }[];
// connState: derived frps connection state (we do not control remote frps)
connState?: string; // connected | connecting | failed | not_started | disabled
// M3: admin API state
adminEnabled?: boolean;
proxyStates?: ProxyState[];
groupCount?: number;
name: string;
enabled: boolean;
process: ProcessStatus;
hasProcess: boolean;
forwards: { service: string; remotePort: number; localPort?: number }[];
// connState: derived frps connection state (we do not control remote frps)
connState?: string; // connected | connecting | failed | not_started | disabled
// M3: admin API state
adminEnabled?: boolean;
proxyStates?: ProxyState[];
groupCount?: number;
}
export interface StatusResp {
version: string;
workDir: string;
settings: Settings;
services: Local[];
remotes: Remote[];
binaryPath: string;
profiles: StatusProfile[];
localStatus: LocalStatus[];
// forwards: the forward-centric list rendered as cards on the status page.
forwards: ForwardStatus[];
// selfId: this node's ring ID (== SelfAddr), to tell whether a cluster
// forward's owner is this node (worker runs here) or a peer.
selfId: string;
version: string;
workDir: string;
settings: Settings;
services: Local[];
remotes: Remote[];
binaryPath: string;
profiles: StatusProfile[];
localStatus: LocalStatus[];
// forwards: the forward-centric list rendered as cards on the status page.
forwards: ForwardStatus[];
// selfId: this node's ring ID (== SelfAddr), to tell whether a cluster
// forward's owner is this node (worker runs here) or a peer.
selfId: string;
}
// ForwardStatus is one forward (link) in the forward-centric status view.
// kind distinguishes 本地转发 (localOnly) from 远程转发 (cluster-distributed).
export interface ForwardStatus {
local: string;
remote: string;
remotePort: number;
localOnly: boolean;
kind: "local" | "remote";
ownerId?: string; // remote = topology owner; local = selfId
active: boolean; // remote = in topology; local = local worker running
disabled: boolean; // per-forward stop flag (true = stopped)
group?: string;
localIp?: string;
localPort?: number;
localProto?: string;
local: string;
remote: string;
remotePort: number;
localOnly: boolean;
kind: "local" | "remote";
ownerId?: string; // remote = topology owner; local = selfId
active: boolean; // remote = in topology; local = local worker running
disabled: boolean; // per-forward stop flag (true = stopped)
group?: string;
localIp?: string;
localPort?: number;
localProto?: string;
}
export interface LocalTargetStatus {
remote: string;
remotePort: number;
workerState: string;
remote: string;
remotePort: number;
workerState: string;
}
export interface LocalStatus {
local: Local;
targets: LocalTargetStatus[];
local: Local;
targets: LocalTargetStatus[];
}
export interface BinaryStatus {
binaryPath: string;
version?: string;
binaryPath: string;
version?: string;
}
export interface InstallResult {
path: string;
version: string;
path: string;
version: string;
}
// M6: cluster node registry + binary cache.
export interface ClusterNode {
addr: string;
cache?: string[];
version?: string;
addr: string;
cache?: string[];
version?: string;
}
export interface CacheEntry {
version: string;
path: string;
size: number;
modTime: number;
version: string;
path: string;
size: number;
modTime: number;
}
export interface ClusterNodesResp {
nodes: ClusterNode[];
nodes: ClusterNode[];
}
export interface CacheResp {
cache: CacheEntry[];
removed?: string[];
cache: CacheEntry[];
removed?: string[];
}
// M6 token-ring snapshot (GET /cluster/ring).
export interface RingNode {
id: string;
addr: string;
isLeader?: boolean;
alive: boolean;
load: { memPct: number; netPct: number; forwards?: number };
version?: string;
cache?: string[];
lastSeen?: number;
id: string;
addr: string;
isLeader?: boolean;
alive: boolean;
load: { memPct: number; netPct: number; forwards?: number };
version?: string;
cache?: string[];
lastSeen?: number;
}
export interface RingTaskInfo {
id: string;
revoke?: boolean;
removeNode?: string; // node-removal command (target rides the token to self-remove)
local: { name: string };
remote: { name: string };
link: { remotePort?: number };
id: string;
revoke?: boolean;
removeNode?: string; // node-removal command (target rides the token to self-remove)
local: { name: string };
remote: { name: string };
link: { remotePort?: number };
}
export interface RingTopoEntry {
taskId: string;
ownerId: string;
active: boolean;
local: { name: string };
remote: { name: string };
link: { remotePort?: number };
taskId: string;
ownerId: string;
active: boolean;
local: { name: string };
remote: { name: string };
link: { remotePort?: number };
}
export interface RingLogEntry {
seq: number;
node: string;
kind: string;
at?: number;
data?: Record<string, any> | null;
seq: number;
node: string;
kind: string;
at?: number;
data?: Record<string, any> | null;
}
export interface RingSnapshot {
selfId: string;
leaderId: string; // 当前 leader(持首令牌 + 判丢失 + 被上家监控)
cycle: number; // 周期号(leader 每收令牌回 +1)
lastSync: number; // 上次同步时间 (unix sec)
roundDelayMs?: number;
nodes: RingNode[];
pending: RingTaskInfo[];
topology: RingTopoEntry[];
log: RingLogEntry[];
// nodeKey: this node's cluster admission key. Newcomers must present it to
// join via this node (handleClusterJoin verifies). Shown on the cluster page
// for the operator to copy; omitted for non-cluster (standalone) nodes.
nodeKey?: string;
selfId: string;
leaderId: string; // 当前 leader(持首令牌 + 判丢失 + 被上家监控)
cycle: number; // 周期号(leader 每收令牌回 +1)
lastSync: number; // 上次同步时间 (unix sec)
roundDelayMs?: number;
nodes: RingNode[];
pending: RingTaskInfo[];
topology: RingTopoEntry[];
log: RingLogEntry[];
// nodeKey: this node's cluster admission key. Newcomers must present it to
// join via this node (handleClusterJoin verifies). Shown on the cluster page
// for the operator to copy; omitted for non-cluster (standalone) nodes.
nodeKey?: string;
}
// BalanceReport is one node's own view of rebalancing (GET /cluster/balance).
@ -267,122 +267,124 @@ export interface RingSnapshot {
// 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;
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;
mode: "off" | "dry-run" | "on";
modeSource: "env" | "stored"; // env = W4F_BALANCE_MODE default, stored = set here
enabled: boolean; // mode !== off
dryRun: boolean; // mode !== on
canControl: boolean; // this principal may switch it (write level)
isMember: boolean;
self: string;
selfForwards: number;
share: number; // the even split: ceil(total / alive)
totalForwards: number;
aliveNodes: number;
selfOverShare: boolean;
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
// 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[];
cooldownSec: number;
lastReleaseAt?: number;
nodes: BalanceNodeReport[];
}
export interface BalanceNodeReport {
id: string;
alive: boolean;
isLeader: boolean;
forwards: number;
memPct: number;
netPct: number;
score: number;
id: string;
alive: boolean;
isLeader: boolean;
forwards: number;
memPct: number;
netPct: number;
score: number;
}
// ---- Auth / accounts / API keys (M7) ----
export type AccessLevel = 'read' | 'write' | 'admin' | 'superadmin';
export type AccessLevel = "read" | "write" | "admin" | "superadmin";
// MeResp is the authenticated principal (GET /me). The frontend uses level to
// gate the UI: viewer (= auditors) sees read + export controls only.
export interface MeResp {
type: 'user' | 'service' | 'key';
name: string;
level: AccessLevel;
userId?: number;
type: "user" | "service" | "key";
name: string;
level: AccessLevel;
userId?: number;
}
export interface User {
id: number;
username: string;
role: 'admin' | 'viewer' | 'superadmin';
enabled: boolean;
system?: boolean; // flag-synced built-in account (UI read-only)
createdAt: number;
lastLoginAt: number;
id: number;
username: string;
role: "admin" | "viewer" | "superadmin";
enabled: boolean;
system?: boolean; // flag-synced built-in account (UI read-only)
createdAt: number;
lastLoginAt: number;
}
export interface ApiKey {
id: number;
userId: number;
prefix: string;
label: string;
scope: AccessLevel;
createdAt: number;
lastUsedAt: number;
expiresAt?: number;
id: number;
userId: number;
prefix: string;
label: string;
scope: AccessLevel;
createdAt: number;
lastUsedAt: number;
expiresAt?: number;
}
// ApiKeyCreated is the one-time response from POST /apikeys: the plaintext key
// is shown exactly once and never retrievable again.
export interface ApiKeyCreated {
id: number;
userId: number;
label: string;
scope: AccessLevel;
prefix: string;
createdAt: number;
key: string;
id: number;
userId: number;
label: string;
scope: AccessLevel;
prefix: string;
createdAt: number;
key: string;
}
export interface CanvasExportEnvelope {
_type: 'webui4frpc-canvas';
version: number;
exportedAt: number;
exporter: string;
canvas: CanvasData;
_type: "webui4frpc-canvas";
version: number;
exportedAt: number;
exporter: string;
canvas: CanvasData;
}
// Worker-log bundle (GET /cluster/logs/export): one entry per alive ring node.
export interface WorkerLogEntry {
name: string;
remote: string;
state: string;
lines: string;
name: string;
remote: string;
state: string;
lines: string;
}
export interface NodeLogsResp {
node: string;
workers: WorkerLogEntry[];
node: string;
workers: WorkerLogEntry[];
}
export interface ClusterWorkerLogNode {
id: string;
addr: string;
ok: boolean;
error?: string;
logs?: NodeLogsResp;
id: string;
addr: string;
ok: boolean;
error?: string;
logs?: NodeLogsResp;
}
export interface WorkerLogBundle {
_type: 'webui4frpc-worker-logs';
version: number;
exportedAt: number;
requester: string;
nodes: ClusterWorkerLogNode[];
_type: "webui4frpc-worker-logs";
version: number;
exportedAt: number;
requester: string;
nodes: ClusterWorkerLogNode[];
}

View File

@ -127,9 +127,24 @@
<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>
<!-- The switch edits the mode ON THIS NODE. Every member decides about
its own forwards, so a cluster-wide toggle would be a lie: there is
no single action that flips them together. -->
<el-select
v-if="bal.canControl"
v-model="balModeChoice"
size="small"
class="bal-mode"
:disabled="modeBusy"
@change="onModeChange"
>
<el-option label="关闭" value="off" />
<el-option label="观察模式(不释放)" value="dry-run" />
<el-option label="启用(会释放)" value="on" />
</el-select>
<span class="w4f-tag" v-else v-text="'只读'" ></span>
<span class="bal-src" v-if="bal.modeSource === 'stored'">已保存</span>
<span class="bal-src" v-else>来自启动参数</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>
@ -309,7 +324,7 @@
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
import { ref, reactive, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { api, downloadAuditCsv } from '../api'
import { canWrite } from '../auth'
@ -334,6 +349,34 @@ const load = async () => {
try { bal.value = await api.balance() } catch { bal.value = null }
}
// ---- load balancer mode switch ---- //
// The select is bound to a local mirror so the UI can show the pending choice
// while the request is in flight, and snap back if the server rejects it.
const balModeChoice = ref<string>('dry-run')
const modeBusy = ref(false)
watch(
() => bal.value?.mode,
(m) => { if (m) balModeChoice.value = m },
{ immediate: true },
)
const onModeChange = async (m: string) => {
if (!bal.value || modeBusy.value) return
modeBusy.value = true
try {
bal.value = await api.setBalanceMode(m)
ElMessage.success(
m === 'on' ? '本机已启用负载释放' : m === 'off' ? '本机已关闭负载均衡' : '本机已切到观察模式',
)
} catch (e) {
// Snap back: leaving the select showing a mode the server did not accept
// would misrepresent what the node is doing.
balModeChoice.value = bal.value.mode
ElMessage.error('切换失败:' + (e instanceof Error ? e.message : String(e)))
} finally {
modeBusy.value = false
}
}
// ---- load balancer view ---- //
// 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.
@ -823,6 +866,8 @@ onBeforeUnmount(() => {
}
.bal-node.self { border-color: var(--w4f-primary); }
.bal-node.over { border-color: var(--w4f-warning); }
.bal-mode { width: 168px; }
.bal-src { font-size: 11px; color: var(--w4f-muted); }
.bal-note {
margin: 0 0 10px; font-size: 11.5px; line-height: 1.55;
color: var(--w4f-muted);