diff --git a/cmd/webui4frpc/main.go b/cmd/webui4frpc/main.go index 73fa04b..c2050a9 100644 --- a/cmd/webui4frpc/main.go +++ b/cmd/webui4frpc/main.go @@ -608,8 +608,32 @@ func main() { balCooldown := int64(envFloat("W4F_BALANCE_COOLDOWN", 600)) balancer.SetCooldown(balCooldown) balancer.SetMode(balMode) - log.Printf("ring[%s] load balancing %s (cooldown=%ds interval=%s, self-directed)", - selfID, strings.ToUpper(balMode), balCooldown, balInterval) + // A mode stored from the cluster page wins over the environment, so flipping + // 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) // Startup reconcile: every node (leader or not) checks its own topology diff --git a/internal/cluster/balance_test.go b/internal/cluster/balance_test.go index d77d8b4..53a2230 100644 --- a/internal/cluster/balance_test.go +++ b/internal/cluster/balance_test.go @@ -3,6 +3,7 @@ package cluster import ( "context" "encoding/json" + "errors" "strconv" "strings" "testing" @@ -77,6 +78,9 @@ func migrateRing(t *testing.T, ownCount int) (*Engine, *Engine, *Engine) { return n1, n2, n3 } +// errNotWritable stands in for a failing settings write. +var errNotWritable = errors.New("not writable") + // ---- helpers ---- // 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) } } + +// ---- 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") + } +} diff --git a/internal/cluster/ring_balance.go b/internal/cluster/ring_balance.go index 6e42623..5fc94e0 100644 --- a/internal/cluster/ring_balance.go +++ b/internal/cluster/ring_balance.go @@ -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, diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index a24db20..010e77d 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -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/ring", h.auth("read")(h.handleClusterRing)) 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+"/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) 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()) } diff --git a/internal/store/store.go b/internal/store/store.go index 799db75..b32b6e1 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -128,6 +128,11 @@ type Settings struct { // key). Cleared on explicit detach (detachAsStandalone) so a node that // intentionally left does NOT auto-rejoin. 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. @@ -748,6 +753,13 @@ func (s *Store) Settings() (Settings, error) { if err := json.Unmarshal([]byte(raw), &st); err != 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 } @@ -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", 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 diff --git a/web/src/api.ts b/web/src/api.ts index 92352d3..78f36af 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -165,6 +165,16 @@ export const api = { // cluster. 404 when the balancer is not wired up on that node. balance: () => request("/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("/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: () => diff --git a/web/src/types.ts b/web/src/types.ts index 923ec38..8fb70f1 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -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; - annotations?: Record; + // M1 advanced transport knobs (optional; empty/zero = frpc defaults) + useEncryption?: boolean; + useCompression?: boolean; + bandwidthLimit?: string; + poolCount?: number; + metadatas?: Record; + annotations?: Record; - // M2 HTTP/HTTPS routing (http/https only) - customDomains?: string; // comma-separated - subdomain?: string; - locations?: string[]; - hostHeaderRewrite?: string; - httpHeaders?: Record; - basicAuthUser?: string; - basicAuthPassword?: string; + // M2 HTTP/HTTPS routing (http/https only) + customDomains?: string; // comma-separated + subdomain?: string; + locations?: string[]; + hostHeaderRewrite?: string; + httpHeaders?: Record; + 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 | null; + seq: number; + node: string; + kind: string; + at?: number; + data?: Record | 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[]; } diff --git a/web/src/views/ClusterView.vue b/web/src/views/ClusterView.vue index 2846843..2891693 100644 --- a/web/src/views/ClusterView.vue +++ b/web/src/views/ClusterView.vue @@ -127,9 +127,24 @@

⚖负载均衡 - - {{ bal.dryRun ? '观察模式(不释放)' : '已启用(会释放)' }} - + + + + + + + + 已保存 + 来自启动参数 @@ -309,7 +324,7 @@