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

@ -608,8 +608,32 @@ func main() {
balCooldown := int64(envFloat("W4F_BALANCE_COOLDOWN", 600)) balCooldown := int64(envFloat("W4F_BALANCE_COOLDOWN", 600))
balancer.SetCooldown(balCooldown) balancer.SetCooldown(balCooldown)
balancer.SetMode(balMode) balancer.SetMode(balMode)
log.Printf("ring[%s] load balancing %s (cooldown=%ds interval=%s, self-directed)", // A mode stored from the cluster page wins over the environment, so flipping
selfID, strings.ToUpper(balMode), balCooldown, balInterval) // the switch is not silently reverted by the next restart. SetMode is applied
// first so a stored value only has to be a no-op when the two agree.
balancer.SetModePersistence(
func() (string, bool) {
m, ok, err := st.BalanceMode()
if err != nil {
log.Printf("ring[%s] read stored balance mode: %v", selfID, err)
return "", false
}
return m, ok
},
func(m string) error {
s, err := st.Settings()
if err != nil {
return err
}
s.BalanceMode = m
return st.UpdateSettings(s)
},
)
// Report the EFFECTIVE mode, not balMode: a stored value may have overridden
// the environment, and a startup line naming the wrong mode is exactly how an
// operator ends up believing releasing is off while it is on.
log.Printf("ring[%s] load balancing %s (source=%s cooldown=%ds interval=%s, self-directed)",
selfID, strings.ToUpper(balancer.Mode()), balancer.ModeSource(), balCooldown, balInterval)
go balancer.Watch(clusterCtx, balInterval) go balancer.Watch(clusterCtx, balInterval)
// Startup reconcile: every node (leader or not) checks its own topology // Startup reconcile: every node (leader or not) checks its own topology

View File

@ -3,6 +3,7 @@ package cluster
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"strconv" "strconv"
"strings" "strings"
"testing" "testing"
@ -77,6 +78,9 @@ func migrateRing(t *testing.T, ownCount int) (*Engine, *Engine, *Engine) {
return n1, n2, n3 return n1, n2, n3
} }
// errNotWritable stands in for a failing settings write.
var errNotWritable = errors.New("not writable")
// ---- helpers ---- // ---- helpers ----
// balanceRing builds a 3-node ring with `owned` forwards on n1 (the evaluation // balanceRing builds a 3-node ring with `owned` forwards on n1 (the evaluation
@ -619,3 +623,106 @@ func TestReportOnBalancedNodeIsQuiet(t *testing.T) {
rep.TotalForwards, rep.AliveNodes, rep.Share) rep.TotalForwards, rep.AliveNodes, rep.Share)
} }
} }
// ---- mode switching (the cluster page's switch) ----
// TestStoredModeWinsOverEnv: flipping the switch must not silently revert on the
// next restart. The environment is only the bootstrap default; once an operator
// has chosen a mode it is persisted and wins.
func TestStoredModeWinsOverEnv(t *testing.T) {
n1, _, _ := balanceRing(t, 3, 0)
b := NewBalancer(n1, 0)
b.SetMode("dry-run") // the env default
var saved string
b.SetModePersistence(
func() (string, bool) { return "on", true },
func(m string) error { saved = m; return nil },
)
if b.Mode() != BalanceOn {
t.Fatalf("a stored mode must win over the environment default, got %q", b.Mode())
}
if b.ModeSource() != "stored" {
t.Errorf("ModeSource = %q, want stored", b.ModeSource())
}
_ = saved
}
// TestUnconfiguredFallsBackToEnv: with nothing stored, the environment default
// stands — otherwise every node would need a settings row to behave as documented.
func TestUnconfiguredFallsBackToEnv(t *testing.T) {
n1, _, _ := balanceRing(t, 3, 0)
b := NewBalancer(n1, 0)
b.SetMode("off")
b.SetModePersistence(func() (string, bool) { return "", false }, func(string) error { return nil })
if b.Mode() != BalanceOff {
t.Errorf("with nothing stored the env default must stand, got %q", b.Mode())
}
if b.ModeSource() != "stored" {
// persist is installed, so the node CAN be controlled
t.Logf("ModeSource=%q", b.ModeSource())
}
}
// TestPersistBeforeApply: if the write fails, the mode must not change.
// Applying first would leave the node running a mode it cannot reproduce after a
// restart — the switch would lie.
func TestPersistBeforeApply(t *testing.T) {
n1, _, _ := balanceRing(t, 3, 0)
b := NewBalancer(n1, 0)
b.SetMode(BalanceDryRun)
b.SetModePersistence(nil, func(string) error { return errNotWritable })
if err := b.SetModeFromUI(BalanceOn); err == nil {
t.Fatal("a failed save must surface an error")
}
if b.Mode() != BalanceDryRun {
t.Errorf("a failed save must leave the mode unchanged, got %q", b.Mode())
}
}
// TestSetModeFromUIRejectsNonsense: the endpoint takes a string from the wire, so
// it must refuse anything it does not understand rather than guessing.
func TestSetModeFromUIRejectsNonsense(t *testing.T) {
n1, _, _ := balanceRing(t, 3, 0)
b := NewBalancer(n1, 0)
b.SetMode(BalanceDryRun)
if err := b.SetModeFromUI("yes"); err == nil {
t.Fatal("an unknown mode must be rejected, not coerced")
}
if b.Mode() != BalanceDryRun {
t.Errorf("a rejected mode must not change anything, got %q", b.Mode())
}
}
// TestSetModeFromUIPersistsAndApplies: the happy path, both effects.
func TestSetModeFromUIPersistsAndApplies(t *testing.T) {
n1, _, _ := balanceRing(t, 3, 0)
b := NewBalancer(n1, 0)
b.SetMode(BalanceDryRun)
var saved string
b.SetModePersistence(nil, func(m string) error { saved = m; return nil })
if err := b.SetModeFromUI(BalanceOn); err != nil {
t.Fatalf("SetModeFromUI: %v", err)
}
if saved != BalanceOn {
t.Errorf("saved %q, want on", saved)
}
if b.Mode() != BalanceOn {
t.Errorf("mode = %q, want on", b.Mode())
}
if !b.Report().CanControl {
t.Error("a node with persistence installed should report itself controllable")
}
}
// TestReportCanControlOffWithoutPersistence: a node that cannot persist a choice
// must not advertise the switch (an embedder/test without a store).
func TestReportCanControlOffWithoutPersistence(t *testing.T) {
n1, _, _ := balanceRing(t, 3, 0)
b := NewBalancer(n1, 0)
if b.Report().CanControl {
t.Error("a balancer with no persistence must not report itself controllable")
}
}

View File

@ -2,9 +2,11 @@ package cluster
import ( import (
"context" "context"
"fmt"
"log" "log"
"sort" "sort"
"strconv" "strconv"
"strings"
"time" "time"
"webui4frpc/internal/store" "webui4frpc/internal/store"
@ -100,6 +102,11 @@ type Balancer struct {
// reported remembers the last dry-run candidate so the observe-only mode // reported remembers the last dry-run candidate so the observe-only mode
// logs a change of mind rather than the same line every tick. // logs a change of mind rather than the same line every tick.
reported string 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 // 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 // SetMode selects off / dry-run / on. An unrecognised value is treated as
// dry-run — a typo must not be able to turn releasing on. // 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) { func (b *Balancer) SetMode(m string) {
switch m { if m != BalanceOff && m != BalanceDryRun && m != BalanceOn {
case BalanceOff, BalanceDryRun, BalanceOn:
b.mode = m
default:
if m != "" { if m != "" {
log.Printf("ring[%s] unknown balance mode %q; using %s", b.engine.ID, m, BalanceDryRun) 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. // Mode returns the active mode.
func (b *Balancer) Mode() string { return b.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. // SetCooldown overrides the per-node release cooldown.
func (b *Balancer) SetCooldown(sec int64) { func (b *Balancer) SetCooldown(sec int64) {
if sec > 0 { if sec > 0 {
@ -337,8 +409,12 @@ func (b *Balancer) Watch(ctx context.Context, interval time.Duration) {
// what everyone else holds. // what everyone else holds.
type BalanceReport struct { type BalanceReport struct {
Mode string `json:"mode"` // off | dry-run | on Mode string `json:"mode"` // off | dry-run | on
ModeSource string `json:"modeSource"` // env | stored
Enabled bool `json:"enabled"` // mode != off Enabled bool `json:"enabled"` // mode != off
DryRun bool `json:"dryRun"` // mode != on 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"` IsMember bool `json:"isMember"`
Self string `json:"self"` Self string `json:"self"`
@ -388,8 +464,10 @@ func (b *Balancer) Report() BalanceReport {
now := time.Now().Unix() now := time.Now().Unix()
rep := BalanceReport{ rep := BalanceReport{
Mode: b.mode, Mode: b.mode,
ModeSource: b.ModeSource(),
Enabled: b.mode != BalanceOff, Enabled: b.mode != BalanceOff,
DryRun: b.mode != BalanceOn, DryRun: b.mode != BalanceOn,
CanControl: b.persist != nil,
IsMember: b.engine.IsMember(), IsMember: b.engine.IsMember(),
Self: b.engine.ID, Self: b.engine.ID,
CooldownSec: b.cooldownSec, CooldownSec: b.cooldownSec,

View File

@ -117,6 +117,7 @@ func NewServeMux(h *Handler) (http.Handler, error) {
mux.HandleFunc(apiPrefix+"/cluster/cache", h.auth("read")(h.handleClusterCache)) // GET read; POST write-checked inside mux.HandleFunc(apiPrefix+"/cluster/cache", h.auth("read")(h.handleClusterCache)) // GET read; POST write-checked inside
mux.HandleFunc(apiPrefix+"/cluster/ring", h.auth("read")(h.handleClusterRing)) mux.HandleFunc(apiPrefix+"/cluster/ring", h.auth("read")(h.handleClusterRing))
mux.HandleFunc(apiPrefix+"/cluster/balance", h.auth("read")(h.handleClusterBalance)) mux.HandleFunc(apiPrefix+"/cluster/balance", h.auth("read")(h.handleClusterBalance))
mux.HandleFunc(apiPrefix+"/cluster/balance/mode", h.auth("write")(h.handleClusterBalanceMode))
mux.HandleFunc(apiPrefix+"/node/logs", h.auth("read")(h.handleNodeLogs)) mux.HandleFunc(apiPrefix+"/node/logs", h.auth("read")(h.handleNodeLogs))
mux.HandleFunc(apiPrefix+"/cluster/logs/export", h.auth("read")(h.handleClusterLogsExport)) mux.HandleFunc(apiPrefix+"/cluster/logs/export", h.auth("read")(h.handleClusterLogsExport))
@ -655,6 +656,43 @@ func (h *Handler) handleClusterBalance(w http.ResponseWriter, r *http.Request) {
http.Error(w, "load balancer not enabled", http.StatusNotFound) http.Error(w, "load balancer not enabled", http.StatusNotFound)
return return
} }
rep := h.Balancer.Report()
// CanControl is decided by the caller's own level rather than by the
// presence of a stored value, so a read-only principal is never shown a
// switch that would 403.
rep.CanControl = hasLevel(r, "write")
writeJSON(w, http.StatusOK, rep)
}
// handleClusterBalanceMode switches load balancing on/off at runtime.
//
// It is per-node on purpose. Every member decides about its OWN forwards, so the
// switch belongs to each node: an operator may well want to turn releasing on
// everywhere, but the server should not pretend those are one atomic action.
// (A cluster-wide control can be layered on top later; the primitive here is the
// one that actually has an owner.)
//
// Write-level auth: releasing stops a worker and drops a live connection.
func (h *Handler) handleClusterBalanceMode(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
methodNotAllowed(w)
return
}
if h.Balancer == nil {
http.Error(w, "load balancer not enabled", http.StatusNotFound)
return
}
var req struct {
Mode string `json:"mode"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "parse json: "+err.Error(), http.StatusBadRequest)
return
}
if err := h.Balancer.SetModeFromUI(req.Mode); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
writeJSON(w, http.StatusOK, h.Balancer.Report()) writeJSON(w, http.StatusOK, h.Balancer.Report())
} }

View File

@ -128,6 +128,11 @@ type Settings struct {
// key). Cleared on explicit detach (detachAsStandalone) so a node that // key). Cleared on explicit detach (detachAsStandalone) so a node that
// intentionally left does NOT auto-rejoin. // intentionally left does NOT auto-rejoin.
ClusterPeers string `json:"clusterPeers,omitempty"` ClusterPeers string `json:"clusterPeers,omitempty"`
// BalanceMode is the load-balancing mode chosen in the UI: "", "off",
// "dry-run" or "on". Empty means "never configured here" so the environment
// variable stays the bootstrap default; a non-empty value wins over it,
// otherwise flipping the switch in the UI would silently revert on restart.
BalanceMode string `json:"balanceMode,omitempty"`
} }
// Forward is a rendered link row attached to a remote. // Forward is a rendered link row attached to a remote.
@ -748,6 +753,13 @@ func (s *Store) Settings() (Settings, error) {
if err := json.Unmarshal([]byte(raw), &st); err != nil { if err := json.Unmarshal([]byte(raw), &st); err != nil {
return Settings{AutoStartProfiles: true, RestartOnExit: true, RestartIntervalSeconds: 5}, nil return Settings{AutoStartProfiles: true, RestartOnExit: true, RestartIntervalSeconds: 5}, nil
} }
// BalanceMode lives in its own column (see UpdateSettings). Surface it here
// too so callers reading Settings get the effective value, but never let it
// overwrite the column on a write-back: UpdateSettings only mirrors a
// non-empty value, so an unrelated settings PUT cannot clear a UI choice.
if mode, ok, err := s.BalanceMode(); err == nil && ok {
st.BalanceMode = mode
}
return st, nil return st, nil
} }
@ -760,7 +772,36 @@ func (s *Store) UpdateSettings(st Settings) error {
"INSERT INTO settings(key, value) VALUES('settings', ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value", "INSERT INTO settings(key, value) VALUES('settings', ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value",
string(raw), string(raw),
) )
if err != nil {
return err return err
}
if mode := st.BalanceMode; mode != "" {
// Mirror into its own column for the same reason the other flagged
// fields have one: the settings JSON is rewritten wholesale, so a
// clustered field that rides that JSON would flip back and forth between
// nodes instead of converging.
if _, err := s.db.Exec(
"INSERT INTO settings(key, value) VALUES('balance_mode', ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value",
mode,
); err != nil {
return err
}
}
return nil
}
// BalanceMode returns the stored load-balancing mode, and whether one was ever
// stored (false = never configured here; the environment default applies).
func (s *Store) BalanceMode() (string, bool, error) {
var v string
err := s.db.QueryRow("SELECT value FROM settings WHERE key = 'balance_mode'").Scan(&v)
if errors.Is(err, sql.ErrNoRows) {
return "", false, nil
}
if err != nil {
return "", false, err
}
return v, v != "", nil
} }
// SetNodeKey persists just the cluster admission key, preserving all other // SetNodeKey persists just the cluster admission key, preserving all other

View File

@ -165,6 +165,16 @@ export const api = {
// cluster. 404 when the balancer is not wired up on that node. // cluster. 404 when the balancer is not wired up on that node.
balance: () => request<BalanceReport>("/api/manager/cluster/balance"), 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 (创建/加入/退出/移除). // M6 cluster lifecycle (创建/加入/退出/移除).
// clusterCreate: reseed THIS node as a fresh standalone leader (创建集群). // clusterCreate: reseed THIS node as a fresh standalone leader (创建集群).
clusterCreate: () => clusterCreate: () =>

View File

@ -275,8 +275,10 @@ export interface BalanceReleasePlan {
export interface BalanceReport { export interface BalanceReport {
mode: "off" | "dry-run" | "on"; mode: "off" | "dry-run" | "on";
modeSource: "env" | "stored"; // env = W4F_BALANCE_MODE default, stored = set here
enabled: boolean; // mode !== off enabled: boolean; // mode !== off
dryRun: boolean; // mode !== on dryRun: boolean; // mode !== on
canControl: boolean; // this principal may switch it (write level)
isMember: boolean; isMember: boolean;
self: string; self: string;
@ -309,12 +311,12 @@ export interface BalanceNodeReport {
// ---- Auth / accounts / API keys (M7) ---- // ---- 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 // MeResp is the authenticated principal (GET /me). The frontend uses level to
// gate the UI: viewer (= auditors) sees read + export controls only. // gate the UI: viewer (= auditors) sees read + export controls only.
export interface MeResp { export interface MeResp {
type: 'user' | 'service' | 'key'; type: "user" | "service" | "key";
name: string; name: string;
level: AccessLevel; level: AccessLevel;
userId?: number; userId?: number;
@ -323,7 +325,7 @@ export interface MeResp {
export interface User { export interface User {
id: number; id: number;
username: string; username: string;
role: 'admin' | 'viewer' | 'superadmin'; role: "admin" | "viewer" | "superadmin";
enabled: boolean; enabled: boolean;
system?: boolean; // flag-synced built-in account (UI read-only) system?: boolean; // flag-synced built-in account (UI read-only)
createdAt: number; createdAt: number;
@ -354,7 +356,7 @@ export interface ApiKeyCreated {
} }
export interface CanvasExportEnvelope { export interface CanvasExportEnvelope {
_type: 'webui4frpc-canvas'; _type: "webui4frpc-canvas";
version: number; version: number;
exportedAt: number; exportedAt: number;
exporter: string; exporter: string;
@ -380,7 +382,7 @@ export interface ClusterWorkerLogNode {
logs?: NodeLogsResp; logs?: NodeLogsResp;
} }
export interface WorkerLogBundle { export interface WorkerLogBundle {
_type: 'webui4frpc-worker-logs'; _type: "webui4frpc-worker-logs";
version: number; version: number;
exportedAt: number; exportedAt: number;
requester: string; requester: string;

View File

@ -127,9 +127,24 @@
<section class="section w4f-card bal-section" v-if="bal"> <section class="section w4f-card bal-section" v-if="bal">
<h3 class="section-title"> <h3 class="section-title">
<span class="k-ic">⚖</span>负载均衡 <span class="k-ic">⚖</span>负载均衡
<span class="w4f-tag" :class="bal.dryRun ? 'w4f-tag-info' : 'w4f-tag-warn'"> <!-- The switch edits the mode ON THIS NODE. Every member decides about
{{ bal.dryRun ? '观察模式(不释放)' : '已启用(会释放)' }} its own forwards, so a cluster-wide toggle would be a lie: there is
</span> 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-if="!bal.isMember" v-text="'非集群成员'"></span>
<span class="w4f-tag" v-else-if="bal.releasing" 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> <span class="w4f-tag w4f-tag-warn" v-else-if="bal.selfOverShare" v-text="'本机超出份额'"></span>
@ -309,7 +324,7 @@
</template> </template>
<script setup lang="ts"> <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 { ElMessage } from 'element-plus'
import { api, downloadAuditCsv } from '../api' import { api, downloadAuditCsv } from '../api'
import { canWrite } from '../auth' import { canWrite } from '../auth'
@ -334,6 +349,34 @@ const load = async () => {
try { bal.value = await api.balance() } catch { bal.value = null } 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 ---- // // ---- load balancer view ---- //
// Each node decides about its OWN forwards, so this page has no cluster-wide // 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. // 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.self { border-color: var(--w4f-primary); }
.bal-node.over { border-color: var(--w4f-warning); } .bal-node.over { border-color: var(--w4f-warning); }
.bal-mode { width: 168px; }
.bal-src { font-size: 11px; color: var(--w4f-muted); }
.bal-note { .bal-note {
margin: 0 0 10px; font-size: 11.5px; line-height: 1.55; margin: 0 0 10px; font-size: 11.5px; line-height: 1.55;
color: var(--w4f-muted); color: var(--w4f-muted);