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