mirror of
https://gitcode.com/JianFeeeee/webui4frpc.git
synced 2026-10-15 12:14:37 +00:00
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:
@ -2,9 +2,11 @@ package cluster
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"webui4frpc/internal/store"
|
||||
@ -100,6 +102,11 @@ type Balancer struct {
|
||||
// reported remembers the last dry-run candidate so the observe-only mode
|
||||
// logs a change of mind rather than the same line every tick.
|
||||
reported string
|
||||
// persist saves a mode change so it survives a restart (nil = env-only).
|
||||
persist func(string) error
|
||||
// savedMode is the last mode this node stored, so the report can say whether
|
||||
// the active mode came from the environment or from an operator's choice.
|
||||
savedMode string
|
||||
}
|
||||
|
||||
// NewBalancer builds a balancer. It starts in observe-only mode: releasing a
|
||||
@ -114,21 +121,86 @@ func NewBalancer(e *Engine, cooldownSec int64) *Balancer {
|
||||
|
||||
// SetMode selects off / dry-run / on. An unrecognised value is treated as
|
||||
// dry-run — a typo must not be able to turn releasing on.
|
||||
//
|
||||
// Only a change is logged, and the reason is recorded, because SetMode is now
|
||||
// called both at boot (from the environment) and at runtime (from the cluster
|
||||
// page): a one-line "why did this just start moving my forwards" answer is the
|
||||
// difference between a switch an operator dares to use and one they don't.
|
||||
func (b *Balancer) SetMode(m string) {
|
||||
switch m {
|
||||
case BalanceOff, BalanceDryRun, BalanceOn:
|
||||
b.mode = m
|
||||
default:
|
||||
if m != BalanceOff && m != BalanceDryRun && m != BalanceOn {
|
||||
if m != "" {
|
||||
log.Printf("ring[%s] unknown balance mode %q; using %s", b.engine.ID, m, BalanceDryRun)
|
||||
}
|
||||
b.mode = BalanceDryRun
|
||||
m = BalanceDryRun
|
||||
}
|
||||
if b.mode == m {
|
||||
return
|
||||
}
|
||||
b.mode = m
|
||||
b.savedMode = m
|
||||
log.Printf("ring[%s] load balancing set to %s", b.engine.ID, strings.ToUpper(m))
|
||||
}
|
||||
|
||||
// Mode returns the active mode.
|
||||
func (b *Balancer) Mode() string { return b.mode }
|
||||
|
||||
// ModeSource reports where the active mode came from, for the report.
|
||||
//
|
||||
// "stored" means an operator chose it on the cluster page and it will survive a
|
||||
// restart; "env" means nothing was ever chosen here and the environment default
|
||||
// (or the built-in dry-run) applies. It is NOT "can this node save a choice" —
|
||||
// that is CanControl, and conflating the two made every node with a store claim
|
||||
// an operator had set it.
|
||||
func (b *Balancer) ModeSource() string {
|
||||
if b.savedMode != "" {
|
||||
return "stored"
|
||||
}
|
||||
return "env"
|
||||
}
|
||||
|
||||
// SetModePersistence installs the callback that saves a mode change so it
|
||||
// survives a restart, and applies any previously saved value.
|
||||
//
|
||||
// The environment is the BOOTSTRAP default; once an operator flips the switch on
|
||||
// the cluster page that stored value wins, otherwise the page would appear to
|
||||
// work and then silently revert on the next restart.
|
||||
func (b *Balancer) SetModePersistence(load func() (string, bool), save func(string) error) {
|
||||
b.persist = save
|
||||
if load == nil {
|
||||
return
|
||||
}
|
||||
if m, ok := load(); ok && m != "" {
|
||||
switch m {
|
||||
case BalanceOff, BalanceDryRun, BalanceOn:
|
||||
if b.mode != m {
|
||||
log.Printf("ring[%s] load balancing mode %s loaded from settings", b.engine.ID, m)
|
||||
}
|
||||
b.mode = m
|
||||
b.savedMode = m
|
||||
default:
|
||||
log.Printf("ring[%s] ignoring unknown stored balance mode %q", b.engine.ID, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SetModeFromUI changes the mode at runtime and persists it.
|
||||
//
|
||||
// The order matters: persist FIRST, then apply. If the write fails nothing
|
||||
// changes, so the switch cannot report success while behaving differently after
|
||||
// the next restart.
|
||||
func (b *Balancer) SetModeFromUI(m string) error {
|
||||
if m != BalanceOff && m != BalanceDryRun && m != BalanceOn {
|
||||
return fmt.Errorf("未知模式 %q", m)
|
||||
}
|
||||
if b.persist != nil {
|
||||
if err := b.persist(m); err != nil {
|
||||
return fmt.Errorf("保存负载均衡模式失败: %w", err)
|
||||
}
|
||||
}
|
||||
b.SetMode(m)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetCooldown overrides the per-node release cooldown.
|
||||
func (b *Balancer) SetCooldown(sec int64) {
|
||||
if sec > 0 {
|
||||
@ -336,11 +408,15 @@ func (b *Balancer) Watch(ctx context.Context, interval time.Duration) {
|
||||
// peer list is included because "am I over my share?" is only meaningful next to
|
||||
// what everyone else holds.
|
||||
type BalanceReport struct {
|
||||
Mode string `json:"mode"` // off | dry-run | on
|
||||
Enabled bool `json:"enabled"` // mode != off
|
||||
DryRun bool `json:"dryRun"` // mode != on
|
||||
IsMember bool `json:"isMember"`
|
||||
Self string `json:"self"`
|
||||
Mode string `json:"mode"` // off | dry-run | on
|
||||
ModeSource string `json:"modeSource"` // env | stored
|
||||
Enabled bool `json:"enabled"` // mode != off
|
||||
DryRun bool `json:"dryRun"` // mode != on
|
||||
// CanControl: this node offers the mode switch. False for a read-only
|
||||
// principal, so the server does not advertise a control the UI gates anyway.
|
||||
CanControl bool `json:"canControl"`
|
||||
IsMember bool `json:"isMember"`
|
||||
Self string `json:"self"`
|
||||
|
||||
SelfForwards int `json:"selfForwards"`
|
||||
Share int `json:"share"`
|
||||
@ -388,8 +464,10 @@ func (b *Balancer) Report() BalanceReport {
|
||||
now := time.Now().Unix()
|
||||
rep := BalanceReport{
|
||||
Mode: b.mode,
|
||||
ModeSource: b.ModeSource(),
|
||||
Enabled: b.mode != BalanceOff,
|
||||
DryRun: b.mode != BalanceOn,
|
||||
CanControl: b.persist != nil,
|
||||
IsMember: b.engine.IsMember(),
|
||||
Self: b.engine.ID,
|
||||
CooldownSec: b.cooldownSec,
|
||||
|
||||
Reference in New Issue
Block a user