feat: M3 load balancing + health check (model/render/admin API status/UI)

This commit is contained in:
2026-08-17 17:45:03 +08:00
parent bd3a62a017
commit 467c7124ab
10 changed files with 658 additions and 33 deletions

View File

@ -4,9 +4,12 @@ package httpapi
import ( import (
"embed" "embed"
"encoding/json" "encoding/json"
"fmt"
"io"
"io/fs" "io/fs"
"net/http" "net/http"
"strings" "strings"
"time"
"webui4frpc/internal/process" "webui4frpc/internal/process"
"webui4frpc/internal/store" "webui4frpc/internal/store"
@ -131,6 +134,18 @@ func contentTypeFor(name string) string {
} }
} }
// proxyState is a per-proxy status row reported by the local frpc admin
// API (GET /api/status). Status is one of frpc's proxy phases:
// new | wait start | start error | running | check failed | closed.
type proxyState struct {
Name string `json:"name"`
Type string `json:"type"`
Status string `json:"status"`
Err string `json:"err,omitempty"`
LocalAddr string `json:"local_addr"`
RemoteAddr string `json:"remote_addr"`
}
func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) { func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet { if r.Method != http.MethodGet {
methodNotAllowed(w) methodNotAllowed(w)
@ -149,6 +164,13 @@ func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) {
// ConnState is the derived frps connection state: // ConnState is the derived frps connection state:
// connected | connecting | failed | not_started | disabled. // connected | connecting | failed | not_started | disabled.
ConnState string `json:"connState"` ConnState string `json:"connState"`
// AdminEnabled is true when the remote configures a local frpc admin
// API (adminPort > 0); then ProxyStates reflects real proxy health.
AdminEnabled bool `json:"adminEnabled"`
ProxyStates []proxyState `json:"proxyStates,omitempty"`
// GroupCount is the number of distinct LB groups across this remote's
// forwards (M3 display).
GroupCount int `json:"groupCount,omitempty"`
} }
profiles := make([]profileStatus, 0, len(remotes)) profiles := make([]profileStatus, 0, len(remotes))
@ -159,10 +181,24 @@ func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) {
for _, rv := range remotes { for _, rv := range remotes {
st, has := h.Process.Status(rv.Name) st, has := h.Process.Status(rv.Name)
fwd, _ := h.Store.LinksForRemote(rv.Name) fwd, _ := h.Store.LinksForRemote(rv.Name)
profiles = append(profiles, profileStatus{ p := profileStatus{
Name: rv.Name, Enabled: rv.Enabled, Status: st, HasProc: has, Forwards: fwd, Name: rv.Name, Enabled: rv.Enabled, Status: st, HasProc: has, Forwards: fwd,
ConnState: connStateOf(h.Process.LogPath(rv.Name), rv.Enabled, st), AdminEnabled: rv.AdminPort > 0,
}) GroupCount: groupCountOf(locals, fwd),
}
if rv.AdminPort > 0 {
// Query the local frpc admin API for true per-proxy state.
if states, err := fetchProxyStates(rv.AdminAddr, rv.AdminPort, rv.AdminUser, rv.AdminPassword); err == nil {
p.ProxyStates = states
}
}
// Prefer admin-derived conn state; fall back to log inference.
if p.AdminEnabled && len(p.ProxyStates) > 0 {
p.ConnState = connStateAdmin(p.ProxyStates)
} else {
p.ConnState = connStateOf(h.Process.LogPath(rv.Name), rv.Enabled, st)
}
profiles = append(profiles, p)
} }
// Local status: each local plus its forwarding targets and whether each // Local status: each local plus its forwarding targets and whether each
@ -251,6 +287,86 @@ func connStateOf(workerLog string, enabled bool, st process.Status) string {
} }
} }
// fetchProxyStates queries a worker's local frpc admin API (webServer) for
// true per-proxy status: GET /api/status returns a map keyed by proxy type,
// each value a list of { name, type, status, err, local_addr, remote_addr }.
// status is one of frpc's proxy phases:
//
// new | wait start | start error | running | check failed | closed
func fetchProxyStates(addr string, port int, user, pass string) ([]proxyState, error) {
if addr == "" {
addr = "127.0.0.1"
}
url := fmt.Sprintf("http://%s:%d/api/status", addr, port)
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.SetBasicAuth(user, pass)
cli := &http.Client{Timeout: 3 * time.Second}
resp, err := cli.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
_, _ = io.Copy(io.Discard, resp.Body)
return nil, fmt.Errorf("admin status HTTP %d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if err != nil {
return nil, err
}
var m map[string][]proxyState
if err := json.Unmarshal(body, &m); err != nil {
return nil, err
}
var out []proxyState
for _, arr := range m {
out = append(out, arr...)
}
return out, nil
}
// connStateAdmin derives the overall worker connection state from the per-proxy
// states reported by the local frpc admin API.
func connStateAdmin(states []proxyState) string {
if len(states) == 0 {
return "connecting"
}
anyRunning := false
for _, s := range states {
switch s.Status {
case "running":
anyRunning = true
case "wait start":
return "connecting"
}
}
if anyRunning {
return "connected"
}
return "failed"
}
// groupCountOf returns the number of distinct non-empty LB groups a remote
// participates in, by mapping each forward's service back to its local.
func groupCountOf(locals []store.Local, fwd []store.Forward) int {
groupByLocal := map[string]string{}
for _, l := range locals {
if l.LBGroup != "" {
groupByLocal[l.Name] = l.LBGroup
}
}
seen := map[string]bool{}
for _, f := range fwd {
if g := groupByLocal[f.Service]; g != "" {
seen[g] = true
}
}
return len(seen)
}
func methodNotAllowed(w http.ResponseWriter) { func methodNotAllowed(w http.ResponseWriter) {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed) http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
} }

View File

@ -6,6 +6,8 @@ import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"path/filepath" "path/filepath"
"strconv"
"strings"
"testing" "testing"
"webui4frpc/internal/process" "webui4frpc/internal/process"
@ -138,3 +140,55 @@ func TestSettingsPutRoundTrip(t *testing.T) {
t.Fatalf("settings after reload = %+v", got) t.Fatalf("settings after reload = %+v", got)
} }
} }
// TestFetchProxyStatesFromAdminAPI verifies the M3 status path: we mock the
// frpc admin API (/api/status with Basic Auth) and confirm fetchProxyStates
// parses per-proxy rows and connStateAdmin derives the right summary.
func TestFetchProxyStatesFromAdminAPI(t *testing.T) {
// Mock frpc admin server.
admin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u, p, ok := r.BasicAuth()
if !ok || u != "admin" || p != "pw" {
w.WriteHeader(http.StatusUnauthorized)
return
}
if r.URL.Path != "/api/status" {
http.NotFound(w, r)
return
}
_ = json.NewEncoder(w).Encode(map[string][]proxyState{
"tcp": {
{Name: "web1", Type: "tcp", Status: "running", LocalAddr: "127.0.0.1:8080", RemoteAddr: "1.2.3.4:8080"},
{Name: "web2", Type: "tcp", Status: "check failed", Err: "health check failed"},
},
})
}))
defer admin.Close()
u := strings.TrimPrefix(admin.URL, "http://")
host, portStr, _ := strings.Cut(u, ":")
port, _ := strconv.Atoi(portStr)
states, err := fetchProxyStates(host, port, "admin", "pw")
if err != nil {
t.Fatalf("fetchProxyStates: %v", err)
}
if len(states) != 2 {
t.Fatalf("states = %+v", states)
}
if states[0].Status != "running" || states[1].Status != "check failed" {
t.Fatalf("unexpected states = %+v", states)
}
// connStateAdmin: one running -> connected.
if got := connStateAdmin(states); got != "connected" {
t.Fatalf("connStateAdmin = %q, want connected", got)
}
// All failed -> failed.
if got := connStateAdmin([]proxyState{{Name: "a", Status: "check failed"}, {Name: "b", Status: "start error"}}); got != "failed" {
t.Fatalf("connStateAdmin(all failed) = %q, want failed", got)
}
// wait start -> connecting.
if got := connStateAdmin([]proxyState{{Name: "a", Status: "wait start"}}); got != "connecting" {
t.Fatalf("connStateAdmin(wait start) = %q, want connecting", got)
}
}

View File

@ -39,6 +39,19 @@ type Proxy struct {
HTTPHeaders map[string]string // additional request headers HTTPHeaders map[string]string // additional request headers
BasicAuthUser string BasicAuthUser string
BasicAuthPassword string BasicAuthPassword string
// M3 Load balancing + health check.
// LBGroup/LBGroupKey put this proxy into a frps load-balancing group;
// all members with the same group are balanced across.
LBGroup string
LBGroupKey string
// HealthCheck* enable frpc's built-in health monitor. Empty type = off.
HealthCheckType string // "" | tcp | http
HealthCheckPath string // http only; path to GET on LocalIP:LocalPort
HealthCheckTimeout int // seconds, default 3
HealthCheckMaxFailed int // default 1
HealthCheckInterval int // seconds, default 10
} }
// frpcAuth mirrors frpc's auth section. // frpcAuth mirrors frpc's auth section.
@ -96,16 +109,52 @@ type frpcProxy struct {
HostHeaderRewrite string `json:"hostHeaderRewrite,omitempty"` HostHeaderRewrite string `json:"hostHeaderRewrite,omitempty"`
HTTPHeaders map[string]string `json:"httpHeaders,omitempty"` HTTPHeaders map[string]string `json:"httpHeaders,omitempty"`
HTTPBasicAuth *frpcBasicAuth `json:"basicAuth,omitempty"` HTTPBasicAuth *frpcBasicAuth `json:"basicAuth,omitempty"`
// M3 load balancer group + health check.
LoadBalancer *frpcLoadBalancer `json:"loadBalancer,omitempty"`
HealthCheck *frpcHealthCheck `json:"healthCheck,omitempty"`
}
// frpcWebServer is the frpc admin API (webServer) section. Enabled when
// AdminPort > 0; used by the manager to query true per-proxy status.
type frpcWebServer struct {
Addr string `json:"addr,omitempty"`
Port int `json:"port,omitempty"`
User string `json:"user,omitempty"`
Password string `json:"password,omitempty"`
}
// frpcLoadBalancer mirrors frpc's loadBalancer section (group load balancing).
type frpcLoadBalancer struct {
Group string `json:"group"`
GroupKey string `json:"groupKey,omitempty"`
}
// frpcHealthCheck mirrors frpc's healthCheck section.
type frpcHealthCheck struct {
Type string `json:"type"`
TimeoutSeconds int `json:"timeoutSeconds,omitempty"`
MaxFailed int `json:"maxFailed,omitempty"`
IntervalSeconds int `json:"intervalSeconds"`
Path string `json:"path,omitempty"`
HTTPHeaders []frpcHTTPHeader `json:"httpHeaders,omitempty"`
}
// frpcHTTPHeader is a single extra header for an http health check.
type frpcHTTPHeader struct {
Name string `json:"name"`
Value string `json:"value"`
} }
// Config is a complete frpc configuration document. // Config is a complete frpc configuration document.
type Config struct { type Config struct {
ServerAddr string `json:"serverAddr"` ServerAddr string `json:"serverAddr"`
ServerPort int `json:"serverPort"` ServerPort int `json:"serverPort"`
Auth frpcAuth `json:"auth"` Auth frpcAuth `json:"auth"`
Transport frpcTransport `json:"transport,omitempty"` Transport frpcTransport `json:"transport,omitempty"`
LoginFailExit bool `json:"loginFailExit"` WebServer *frpcWebServer `json:"webServer,omitempty"`
Proxies []frpcProxy `json:"proxies"` LoginFailExit bool `json:"loginFailExit"`
Proxies []frpcProxy `json:"proxies"`
} }
// Render builds the worker config JSON for a remote server from its setting // Render builds the worker config JSON for a remote server from its setting
@ -118,6 +167,18 @@ func Render(remote store.Remote, proxies []Proxy) ([]byte, error) {
LoginFailExit: false, LoginFailExit: false,
Proxies: make([]frpcProxy, 0, len(proxies)), Proxies: make([]frpcProxy, 0, len(proxies)),
} }
// M3: optional local frpc admin API (webServer) so the manager can query
// true per-proxy status. Only emitted when a port is configured.
if remote.AdminPort > 0 {
addr := remote.AdminAddr
if addr == "" {
addr = "127.0.0.1"
}
cfg.WebServer = &frpcWebServer{
Addr: addr, Port: remote.AdminPort,
User: remote.AdminUser, Password: remote.AdminPassword,
}
}
if remote.TransportProtocol != "" || remote.TransportTLS || remote.TransportPool > 0 || remote.TransportTLSServerName != "" { if remote.TransportProtocol != "" || remote.TransportTLS || remote.TransportPool > 0 || remote.TransportTLSServerName != "" {
cfg.Transport = frpcTransport{ cfg.Transport = frpcTransport{
Protocol: remote.TransportProtocol, Protocol: remote.TransportProtocol,
@ -154,6 +215,31 @@ func Render(remote store.Remote, proxies []Proxy) ([]byte, error) {
if p.BasicAuthUser != "" || p.BasicAuthPassword != "" { if p.BasicAuthUser != "" || p.BasicAuthPassword != "" {
frpcP.HTTPBasicAuth = &frpcBasicAuth{User: p.BasicAuthUser, Password: p.BasicAuthPassword} frpcP.HTTPBasicAuth = &frpcBasicAuth{User: p.BasicAuthUser, Password: p.BasicAuthPassword}
} }
// M3: load balancing group.
if p.LBGroup != "" {
frpcP.LoadBalancer = &frpcLoadBalancer{Group: p.LBGroup, GroupKey: p.LBGroupKey}
}
// M3: health check. Type "" means off; frpc defaults apply when a
// specific knob is left zero.
if p.HealthCheckType != "" {
hc := &frpcHealthCheck{
Type: p.HealthCheckType,
TimeoutSeconds: p.HealthCheckTimeout,
MaxFailed: p.HealthCheckMaxFailed,
IntervalSeconds: p.HealthCheckInterval,
Path: p.HealthCheckPath,
}
// The HTTP health probe needs its own headers (distinct from the
// proxy's request headers): map the proxy's HTTPHeaders through.
if len(p.HTTPHeaders) > 0 {
hh := make([]frpcHTTPHeader, 0, len(p.HTTPHeaders))
for name, val := range p.HTTPHeaders {
hh = append(hh, frpcHTTPHeader{Name: name, Value: val})
}
hc.HTTPHeaders = hh
}
frpcP.HealthCheck = hc
}
// http/https proxies use customDomains instead of remotePort. The // http/https proxies use customDomains instead of remotePort. The
// default domain is <name>.local; frps matches it by Host header. // default domain is <name>.local; frps matches it by Host header.
if p.Type == "http" || p.Type == "https" { if p.Type == "http" || p.Type == "https" {

View File

@ -159,3 +159,108 @@ func TestRenderHTTPSUsesCustomDomains(t *testing.T) {
t.Fatalf("customDomains = %+v", p.CustomDomains) t.Fatalf("customDomains = %+v", p.CustomDomains)
} }
} }
func TestRenderLoadBalancerAndHealthCheck(t *testing.T) {
remote := store.Remote{Name: "srv", IP: "1.2.3.4", Port: 7000, Token: "secret"}
data, err := Render(remote, []Proxy{
{Name: "web1", Type: "tcp", LocalIP: "127.0.0.1", LocalPort: 8080, RemotePort: 8080,
LBGroup: "web", LBGroupKey: "k",
HealthCheckType: "tcp", HealthCheckTimeout: 3, HealthCheckMaxFailed: 2, HealthCheckInterval: 5},
})
if err != nil {
t.Fatal(err)
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
t.Fatal(err)
}
p := cfg.Proxies[0]
if p.LoadBalancer == nil || p.LoadBalancer.Group != "web" || p.LoadBalancer.GroupKey != "k" {
t.Fatalf("loadBalancer = %+v", p.LoadBalancer)
}
if p.HealthCheck == nil || p.HealthCheck.Type != "tcp" || p.HealthCheck.IntervalSeconds != 5 ||
p.HealthCheck.MaxFailed != 2 || p.HealthCheck.TimeoutSeconds != 3 {
t.Fatalf("healthCheck = %+v", p.HealthCheck)
}
}
func TestRenderHealthCheckHTTPWithHeaders(t *testing.T) {
remote := store.Remote{Name: "srv", IP: "1.2.3.4", Port: 7000}
data, err := Render(remote, []Proxy{
{Name: "web", Type: "http", LocalIP: "127.0.0.1", LocalPort: 8080,
CustomDomains: []string{"app.example.com"},
HealthCheckType: "http",
HealthCheckPath: "/healthz",
HealthCheckTimeout: 2,
HealthCheckMaxFailed: 3,
HealthCheckInterval: 7,
HTTPHeaders: map[string]string{"X-Probe": "1"}},
})
if err != nil {
t.Fatal(err)
}
var cfg Config
_ = json.Unmarshal(data, &cfg)
p := cfg.Proxies[0]
if p.HealthCheck == nil || p.HealthCheck.Type != "http" || p.HealthCheck.Path != "/healthz" ||
p.HealthCheck.IntervalSeconds != 7 {
t.Fatalf("healthCheck = %+v", p.HealthCheck)
}
if p.HealthCheck.HTTPHeaders == nil || len(p.HealthCheck.HTTPHeaders) == 0 ||
p.HealthCheck.HTTPHeaders[0].Name != "X-Probe" || p.HealthCheck.HTTPHeaders[0].Value != "1" {
t.Fatalf("healthCheck.httpHeaders = %+v", p.HealthCheck.HTTPHeaders)
}
// http proxy must still use customDomains and no remotePort
if len(p.CustomDomains) != 1 || p.CustomDomains[0] != "app.example.com" || p.RemotePort != 0 {
t.Fatalf("http proxy routing = customDomains=%v remotePort=%d", p.CustomDomains, p.RemotePort)
}
}
func TestRenderHealthCheckOmittedWhenOff(t *testing.T) {
remote := store.Remote{Name: "srv", IP: "1.2.3.4", Port: 7000}
data, err := Render(remote, []Proxy{
{Name: "web", Type: "tcp", LocalIP: "127.0.0.1", LocalPort: 8080, RemotePort: 8080},
})
if err != nil {
t.Fatal(err)
}
var cfg Config
_ = json.Unmarshal(data, &cfg)
p := cfg.Proxies[0]
if p.LoadBalancer != nil || p.HealthCheck != nil {
t.Fatalf("lb/health should be omitted, got lb=%+v hc=%+v", p.LoadBalancer, p.HealthCheck)
}
}
func TestRenderAdminWebServer(t *testing.T) {
remote := store.Remote{Name: "srv", IP: "1.2.3.4", Port: 7000, Token: "secret",
AdminAddr: "127.0.0.1", AdminPort: 7400, AdminUser: "admin", AdminPassword: "pw"}
data, err := Render(remote, nil)
if err != nil {
t.Fatal(err)
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
t.Fatal(err)
}
if cfg.WebServer == nil {
t.Fatal("webServer missing when AdminPort set")
}
if cfg.WebServer.Port != 7400 || cfg.WebServer.Addr != "127.0.0.1" ||
cfg.WebServer.User != "admin" || cfg.WebServer.Password != "pw" {
t.Fatalf("webServer = %+v", cfg.WebServer)
}
}
func TestRenderAdminWebServerOmitted(t *testing.T) {
remote := store.Remote{Name: "srv", IP: "1.2.3.4", Port: 7000}
data, err := Render(remote, nil)
if err != nil {
t.Fatal(err)
}
var cfg Config
_ = json.Unmarshal(data, &cfg)
if cfg.WebServer != nil {
t.Fatalf("webServer should be omitted, got %+v", cfg.WebServer)
}
}

View File

@ -36,6 +36,21 @@ type Local struct {
HTTPHeaders map[string]string `json:"httpHeaders,omitempty"` HTTPHeaders map[string]string `json:"httpHeaders,omitempty"`
BasicAuthUser string `json:"basicAuthUser,omitempty"` BasicAuthUser string `json:"basicAuthUser,omitempty"`
BasicAuthPassword string `json:"basicAuthPassword,omitempty"` BasicAuthPassword string `json:"basicAuthPassword,omitempty"`
// M3 Load balancing: group / groupKey turn this local into a group member.
// The frps load balances the group's proxies (many-to-one backend set).
LBGroup string `json:"lbGroup,omitempty"`
LBGroupKey string `json:"lbGroupKey,omitempty"`
// Health check (frpc healthCheck): tcp probes LocalIP:LocalPort or an
// http GET to LocalIP:LocalPort + HealthCheckPath. When a group member
// fails its check, frpc stops routing to it; the server falls back to the
// surviving group members.
HealthCheckType string `json:"healthCheckType,omitempty"` // "" (off) | tcp | http
HealthCheckPath string `json:"healthCheckPath,omitempty"`
HealthCheckTimeout int `json:"healthCheckTimeout,omitempty"` // seconds, default 3
HealthCheckMaxFailed int `json:"healthCheckMaxFailed,omitempty"` // default 1
HealthCheckInterval int `json:"healthCheckInterval,omitempty"` // seconds, default 10
} }
// Remote is a remote server node on the canvas. // Remote is a remote server node on the canvas.
@ -56,6 +71,14 @@ type Remote struct {
// M2: frps vhostHTTPPort (optional, displayed on the status page as the // M2: frps vhostHTTPPort (optional, displayed on the status page as the
// HTTP access port for http/https services). // HTTP access port for http/https services).
VhostHTTPPort int `json:"vhostHttpPort,omitempty"` VhostHTTPPort int `json:"vhostHttpPort,omitempty"`
// M3: local frpc admin API (webServer). When AdminPort > 0 the worker
// exposes GET /api/status so we can report true per-proxy state
// (including health check results) instead of guessing from logs.
AdminAddr string `json:"adminAddr,omitempty"` // listen address, default 127.0.0.1
AdminPort int `json:"adminPort,omitempty"`
AdminUser string `json:"adminUser,omitempty"`
AdminPassword string `json:"adminPassword,omitempty"`
} }
// Link connects one local to one remote. // Link connects one local to one remote.
@ -90,7 +113,22 @@ CREATE TABLE IF NOT EXISTS locals (
name TEXT PRIMARY KEY, name TEXT PRIMARY KEY,
ip TEXT NOT NULL, ip TEXT NOT NULL,
port INTEGER NOT NULL, port INTEGER NOT NULL,
protocol TEXT NOT NULL DEFAULT 'tcp' protocol TEXT NOT NULL DEFAULT 'tcp',
use_encryption INTEGER NOT NULL DEFAULT 0,
use_compression INTEGER NOT NULL DEFAULT 0,
bandwidth_limit TEXT NOT NULL DEFAULT '',
pool_count INTEGER NOT NULL DEFAULT 0,
metadatas TEXT NOT NULL DEFAULT '',
annotations TEXT NOT NULL DEFAULT '',
custom_domains TEXT NOT NULL DEFAULT '',
subdomain TEXT NOT NULL DEFAULT '',
locations TEXT NOT NULL DEFAULT '',
host_header_rewrite TEXT NOT NULL DEFAULT '',
http_headers TEXT NOT NULL DEFAULT '',
basic_auth_user TEXT NOT NULL DEFAULT '',
basic_auth_password TEXT NOT NULL DEFAULT '',
lb_group TEXT NOT NULL DEFAULT '',
lb_group_key TEXT NOT NULL DEFAULT ''
); );
CREATE TABLE IF NOT EXISTS remotes ( CREATE TABLE IF NOT EXISTS remotes (
name TEXT PRIMARY KEY, name TEXT PRIMARY KEY,
@ -98,7 +136,16 @@ CREATE TABLE IF NOT EXISTS remotes (
port INTEGER NOT NULL, port INTEGER NOT NULL,
token TEXT NOT NULL DEFAULT '', token TEXT NOT NULL DEFAULT '',
url TEXT NOT NULL DEFAULT '', url TEXT NOT NULL DEFAULT '',
enabled INTEGER NOT NULL DEFAULT 1 enabled INTEGER NOT NULL DEFAULT 1,
transport_protocol TEXT NOT NULL DEFAULT '',
transport_tls INTEGER NOT NULL DEFAULT 0,
transport_pool INTEGER NOT NULL DEFAULT 0,
transport_tls_server_name TEXT NOT NULL DEFAULT '',
vhost_http_port INTEGER NOT NULL DEFAULT 0,
admin_addr TEXT NOT NULL DEFAULT '',
admin_port INTEGER NOT NULL DEFAULT 0,
admin_user TEXT NOT NULL DEFAULT '',
admin_password TEXT NOT NULL DEFAULT ''
); );
CREATE TABLE IF NOT EXISTS links ( CREATE TABLE IF NOT EXISTS links (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@ -168,6 +215,13 @@ func (s *Store) migrate() error {
"http_headers TEXT NOT NULL DEFAULT ''", "http_headers TEXT NOT NULL DEFAULT ''",
"basic_auth_user TEXT NOT NULL DEFAULT ''", "basic_auth_user TEXT NOT NULL DEFAULT ''",
"basic_auth_password TEXT NOT NULL DEFAULT ''", "basic_auth_password TEXT NOT NULL DEFAULT ''",
"lb_group TEXT NOT NULL DEFAULT ''",
"lb_group_key TEXT NOT NULL DEFAULT ''",
"health_check_type TEXT NOT NULL DEFAULT ''",
"health_check_path TEXT NOT NULL DEFAULT ''",
"health_check_timeout INTEGER NOT NULL DEFAULT 0",
"health_check_max_failed INTEGER NOT NULL DEFAULT 0",
"health_check_interval INTEGER NOT NULL DEFAULT 0",
}, },
"remotes": { "remotes": {
"transport_protocol TEXT NOT NULL DEFAULT ''", "transport_protocol TEXT NOT NULL DEFAULT ''",
@ -175,6 +229,10 @@ func (s *Store) migrate() error {
"transport_pool INTEGER NOT NULL DEFAULT 0", "transport_pool INTEGER NOT NULL DEFAULT 0",
"transport_tls_server_name TEXT NOT NULL DEFAULT ''", "transport_tls_server_name TEXT NOT NULL DEFAULT ''",
"vhost_http_port INTEGER NOT NULL DEFAULT 0", "vhost_http_port INTEGER NOT NULL DEFAULT 0",
"admin_addr TEXT NOT NULL DEFAULT ''",
"admin_port INTEGER NOT NULL DEFAULT 0",
"admin_user TEXT NOT NULL DEFAULT ''",
"admin_password TEXT NOT NULL DEFAULT ''",
}, },
} }
for table, cols := range tables { for table, cols := range tables {
@ -212,7 +270,7 @@ func (s *Store) Close() error { return s.db.Close() }
// ---- Locals ---- // ---- Locals ----
func (s *Store) ListLocals() ([]Local, error) { func (s *Store) ListLocals() ([]Local, error) {
rows, err := s.db.Query("SELECT name, ip, port, protocol, use_encryption, use_compression, bandwidth_limit, pool_count, metadatas, annotations, custom_domains, subdomain, locations, host_header_rewrite, http_headers, basic_auth_user, basic_auth_password FROM locals ORDER BY name") rows, err := s.db.Query("SELECT name, ip, port, protocol, use_encryption, use_compression, bandwidth_limit, pool_count, metadatas, annotations, custom_domains, subdomain, locations, host_header_rewrite, http_headers, basic_auth_user, basic_auth_password, lb_group, lb_group_key, health_check_type, health_check_path, health_check_timeout, health_check_max_failed, health_check_interval FROM locals ORDER BY name")
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -222,7 +280,7 @@ func (s *Store) ListLocals() ([]Local, error) {
var l Local var l Local
var enc, comp int var enc, comp int
var metaRaw, annoRaw, hdrRaw, locRaw string var metaRaw, annoRaw, hdrRaw, locRaw string
if err := rows.Scan(&l.Name, &l.IP, &l.Port, &l.Protocol, &enc, &comp, &l.BandwidthLimit, &l.PoolCount, &metaRaw, &annoRaw, &l.CustomDomains, &l.SubDomain, &locRaw, &l.HostHeaderRewrite, &hdrRaw, &l.BasicAuthUser, &l.BasicAuthPassword); err != nil { if err := rows.Scan(&l.Name, &l.IP, &l.Port, &l.Protocol, &enc, &comp, &l.BandwidthLimit, &l.PoolCount, &metaRaw, &annoRaw, &l.CustomDomains, &l.SubDomain, &locRaw, &l.HostHeaderRewrite, &hdrRaw, &l.BasicAuthUser, &l.BasicAuthPassword, &l.LBGroup, &l.LBGroupKey, &l.HealthCheckType, &l.HealthCheckPath, &l.HealthCheckTimeout, &l.HealthCheckMaxFailed, &l.HealthCheckInterval); err != nil {
return nil, err return nil, err
} }
l.UseEncryption = enc != 0 l.UseEncryption = enc != 0
@ -240,8 +298,8 @@ func (s *Store) GetLocal(name string) (Local, bool) {
var l Local var l Local
var enc, comp int var enc, comp int
var metaRaw, annoRaw, hdrRaw, locRaw string var metaRaw, annoRaw, hdrRaw, locRaw string
row := s.db.QueryRow("SELECT name, ip, port, protocol, use_encryption, use_compression, bandwidth_limit, pool_count, metadatas, annotations, custom_domains, subdomain, locations, host_header_rewrite, http_headers, basic_auth_user, basic_auth_password FROM locals WHERE name = ?", name) row := s.db.QueryRow("SELECT name, ip, port, protocol, use_encryption, use_compression, bandwidth_limit, pool_count, metadatas, annotations, custom_domains, subdomain, locations, host_header_rewrite, http_headers, basic_auth_user, basic_auth_password, lb_group, lb_group_key, health_check_type, health_check_path, health_check_timeout, health_check_max_failed, health_check_interval FROM locals WHERE name = ?", name)
if err := row.Scan(&l.Name, &l.IP, &l.Port, &l.Protocol, &enc, &comp, &l.BandwidthLimit, &l.PoolCount, &metaRaw, &annoRaw, &l.CustomDomains, &l.SubDomain, &locRaw, &l.HostHeaderRewrite, &hdrRaw, &l.BasicAuthUser, &l.BasicAuthPassword); err != nil { if err := row.Scan(&l.Name, &l.IP, &l.Port, &l.Protocol, &enc, &comp, &l.BandwidthLimit, &l.PoolCount, &metaRaw, &annoRaw, &l.CustomDomains, &l.SubDomain, &locRaw, &l.HostHeaderRewrite, &hdrRaw, &l.BasicAuthUser, &l.BasicAuthPassword, &l.LBGroup, &l.LBGroupKey, &l.HealthCheckType, &l.HealthCheckPath, &l.HealthCheckTimeout, &l.HealthCheckMaxFailed, &l.HealthCheckInterval); err != nil {
return Local{}, false return Local{}, false
} }
l.UseEncryption = enc != 0 l.UseEncryption = enc != 0
@ -255,9 +313,9 @@ func (s *Store) GetLocal(name string) (Local, bool) {
func (s *Store) UpsertLocal(l Local) error { func (s *Store) UpsertLocal(l Local) error {
_, err := s.db.Exec( _, err := s.db.Exec(
"INSERT INTO locals(name, ip, port, protocol, use_encryption, use_compression, bandwidth_limit, pool_count, metadatas, annotations, custom_domains, subdomain, locations, host_header_rewrite, http_headers, basic_auth_user, basic_auth_password) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) "+ "INSERT INTO locals(name, ip, port, protocol, use_encryption, use_compression, bandwidth_limit, pool_count, metadatas, annotations, custom_domains, subdomain, locations, host_header_rewrite, http_headers, basic_auth_user, basic_auth_password, lb_group, lb_group_key, health_check_type, health_check_path, health_check_timeout, health_check_max_failed, health_check_interval) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) "+
"ON CONFLICT(name) DO UPDATE SET ip=excluded.ip, port=excluded.port, protocol=excluded.protocol, use_encryption=excluded.use_encryption, use_compression=excluded.use_compression, bandwidth_limit=excluded.bandwidth_limit, pool_count=excluded.pool_count, metadatas=excluded.metadatas, annotations=excluded.annotations, custom_domains=excluded.custom_domains, subdomain=excluded.subdomain, locations=excluded.locations, host_header_rewrite=excluded.host_header_rewrite, http_headers=excluded.http_headers, basic_auth_user=excluded.basic_auth_user, basic_auth_password=excluded.basic_auth_password", "ON CONFLICT(name) DO UPDATE SET ip=excluded.ip, port=excluded.port, protocol=excluded.protocol, use_encryption=excluded.use_encryption, use_compression=excluded.use_compression, bandwidth_limit=excluded.bandwidth_limit, pool_count=excluded.pool_count, metadatas=excluded.metadatas, annotations=excluded.annotations, custom_domains=excluded.custom_domains, subdomain=excluded.subdomain, locations=excluded.locations, host_header_rewrite=excluded.host_header_rewrite, http_headers=excluded.http_headers, basic_auth_user=excluded.basic_auth_user, basic_auth_password=excluded.basic_auth_password, lb_group=excluded.lb_group, lb_group_key=excluded.lb_group_key, health_check_type=excluded.health_check_type, health_check_path=excluded.health_check_path, health_check_timeout=excluded.health_check_timeout, health_check_max_failed=excluded.health_check_max_failed, health_check_interval=excluded.health_check_interval",
l.Name, l.IP, l.Port, l.Protocol, boolToInt(l.UseEncryption), boolToInt(l.UseCompression), l.BandwidthLimit, l.PoolCount, encodeMap(l.Metadatas), encodeMap(l.Annotations), l.CustomDomains, l.SubDomain, encodeSlice(l.Locations), l.HostHeaderRewrite, encodeMap(l.HTTPHeaders), l.BasicAuthUser, l.BasicAuthPassword, l.Name, l.IP, l.Port, l.Protocol, boolToInt(l.UseEncryption), boolToInt(l.UseCompression), l.BandwidthLimit, l.PoolCount, encodeMap(l.Metadatas), encodeMap(l.Annotations), l.CustomDomains, l.SubDomain, encodeSlice(l.Locations), l.HostHeaderRewrite, encodeMap(l.HTTPHeaders), l.BasicAuthUser, l.BasicAuthPassword, l.LBGroup, l.LBGroupKey, l.HealthCheckType, l.HealthCheckPath, l.HealthCheckTimeout, l.HealthCheckMaxFailed, l.HealthCheckInterval,
) )
return err return err
} }
@ -318,7 +376,7 @@ func (s *Store) DeleteLocal(name string) error {
// ---- Remotes ---- // ---- Remotes ----
func (s *Store) ListRemotes() ([]Remote, error) { func (s *Store) ListRemotes() ([]Remote, error) {
rows, err := s.db.Query("SELECT name, ip, port, token, url, enabled, transport_protocol, transport_tls, transport_pool, transport_tls_server_name, vhost_http_port FROM remotes ORDER BY name") rows, err := s.db.Query("SELECT name, ip, port, token, url, enabled, transport_protocol, transport_tls, transport_pool, transport_tls_server_name, vhost_http_port, admin_addr, admin_port, admin_user, admin_password FROM remotes ORDER BY name")
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -327,7 +385,7 @@ func (s *Store) ListRemotes() ([]Remote, error) {
for rows.Next() { for rows.Next() {
var r Remote var r Remote
var en, tls int var en, tls int
if err := rows.Scan(&r.Name, &r.IP, &r.Port, &r.Token, &r.URL, &en, &r.TransportProtocol, &tls, &r.TransportPool, &r.TransportTLSServerName, &r.VhostHTTPPort); err != nil { if err := rows.Scan(&r.Name, &r.IP, &r.Port, &r.Token, &r.URL, &en, &r.TransportProtocol, &tls, &r.TransportPool, &r.TransportTLSServerName, &r.VhostHTTPPort, &r.AdminAddr, &r.AdminPort, &r.AdminUser, &r.AdminPassword); err != nil {
return nil, err return nil, err
} }
r.Enabled = en != 0 r.Enabled = en != 0
@ -340,8 +398,8 @@ func (s *Store) ListRemotes() ([]Remote, error) {
func (s *Store) GetRemote(name string) (Remote, bool) { func (s *Store) GetRemote(name string) (Remote, bool) {
var r Remote var r Remote
var en, tls int var en, tls int
row := s.db.QueryRow("SELECT name, ip, port, token, url, enabled, transport_protocol, transport_tls, transport_pool, transport_tls_server_name, vhost_http_port FROM remotes WHERE name = ?", name) row := s.db.QueryRow("SELECT name, ip, port, token, url, enabled, transport_protocol, transport_tls, transport_pool, transport_tls_server_name, vhost_http_port, admin_addr, admin_port, admin_user, admin_password FROM remotes WHERE name = ?", name)
if err := row.Scan(&r.Name, &r.IP, &r.Port, &r.Token, &r.URL, &en, &r.TransportProtocol, &tls, &r.TransportPool, &r.TransportTLSServerName, &r.VhostHTTPPort); err != nil { if err := row.Scan(&r.Name, &r.IP, &r.Port, &r.Token, &r.URL, &en, &r.TransportProtocol, &tls, &r.TransportPool, &r.TransportTLSServerName, &r.VhostHTTPPort, &r.AdminAddr, &r.AdminPort, &r.AdminUser, &r.AdminPassword); err != nil {
return Remote{}, false return Remote{}, false
} }
r.Enabled = en != 0 r.Enabled = en != 0
@ -351,9 +409,9 @@ func (s *Store) GetRemote(name string) (Remote, bool) {
func (s *Store) UpsertRemote(r Remote) error { func (s *Store) UpsertRemote(r Remote) error {
_, err := s.db.Exec( _, err := s.db.Exec(
"INSERT INTO remotes(name, ip, port, token, url, enabled, transport_protocol, transport_tls, transport_pool, transport_tls_server_name, vhost_http_port) VALUES(?,?,?,?,?,?,?,?,?,?,?) "+ "INSERT INTO remotes(name, ip, port, token, url, enabled, transport_protocol, transport_tls, transport_pool, transport_tls_server_name, vhost_http_port, admin_addr, admin_port, admin_user, admin_password) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) "+
"ON CONFLICT(name) DO UPDATE SET ip=excluded.ip, port=excluded.port, token=excluded.token, url=excluded.url, enabled=excluded.enabled, transport_protocol=excluded.transport_protocol, transport_tls=excluded.transport_tls, transport_pool=excluded.transport_pool, transport_tls_server_name=excluded.transport_tls_server_name, vhost_http_port=excluded.vhost_http_port", "ON CONFLICT(name) DO UPDATE SET ip=excluded.ip, port=excluded.port, token=excluded.token, url=excluded.url, enabled=excluded.enabled, transport_protocol=excluded.transport_protocol, transport_tls=excluded.transport_tls, transport_pool=excluded.transport_pool, transport_tls_server_name=excluded.transport_tls_server_name, vhost_http_port=excluded.vhost_http_port, admin_addr=excluded.admin_addr, admin_port=excluded.admin_port, admin_user=excluded.admin_user, admin_password=excluded.admin_password",
r.Name, r.IP, r.Port, r.Token, r.URL, boolToInt(r.Enabled), r.TransportProtocol, boolToInt(r.TransportTLS), r.TransportPool, r.TransportTLSServerName, r.VhostHTTPPort, r.Name, r.IP, r.Port, r.Token, r.URL, boolToInt(r.Enabled), r.TransportProtocol, boolToInt(r.TransportTLS), r.TransportPool, r.TransportTLSServerName, r.VhostHTTPPort, r.AdminAddr, r.AdminPort, r.AdminUser, r.AdminPassword,
) )
return err return err
} }

30
plan.md
View File

@ -55,9 +55,11 @@
### M3 负载均衡与健康检查 ### M3 负载均衡与健康检查
- [ ] group / groupKey(多后端负载均衡) - [x] 模型扩展:Local 增加 lbGroup / lbGroupKey / healthCheck* 字段,Remote 增加 adminAddr / adminPort / adminUser / adminPassword(local frpc admin API)
- [ ] health check:tcp / http,失败剔除与恢复 - [x] 渲染器:proxy 输出 loadBalancer + healthCheck 段;worker 配置输出 webServer 段(frpc >= 0.52 支持)
- [ ] 状态页体现 LB 组与健康状态 - [x] 状态页:按 admin API GET /api/status 拉取真实 per-proxy 状态(running / check failed / wait start / start error / new / closed),未启用 admin 时回退日志推断
- [x] 前端:LocalNode/RemoteNode 表单 + StatusView per-proxy 状态与 LB 组徽标(vue-tsc 通过)
- [ ] CLUSTER 页:展示负载均衡组(group)+ 健康检查状态(待 M3 后续迭代)
### M4 更多代理类型 ### M4 更多代理类型
@ -74,12 +76,32 @@
- [ ] 配置导出/导入(备份) - [ ] 配置导出/导入(备份)
- [ ] 日志查看页(worker 日志 tail UI) - [ ] 日志查看页(worker 日志 tail UI)
> 注:发布类条目已并入 **M7 运维与发布(原 M5)**,本节 M5 保留初始清单。
>
### M6 集群内 frpc 二进制分发(节点间优先,外部 URL 兜底)
> 诉求:集群内 frpc 二进制**优先通过集群节点间交换**获得,无法交换时才走外部 URL 下载;新节点加入集群时,**自动由邻居节点向其传输二进制**。
- [ ] 二进制来源优先级:本地已有缓存 > 集群节点间交换(HTTP/Chunked 拉取) > 外部 URL(GitHub Releases)
- [ ] 节点注册表:集群发现(IP:port 心跳)记录各节点已缓存的 frpc 版本与可用性
- [ ] 传输通道:节点间 HTTP GET /frpc/{version}(Basic Auth + 大小上限防放大),支持断点续传/校验和
- [ ] 入网引导:新节点加入时自动向邻居请求 latest 二进制并本地落盘缓存,随后按需启动
- [ ] 失败回退:节点间交换不可用时(如源节点离线/版本缺失)自动回退外部 URL 下载
- [ ] Cache 管理:版本保留策略(LRU / 仅保留常用)、可用性标记(黑名单失效节点)
- [ ] UI:设置页展示二进制来源与缓存状态,集群页展示节点间传输进度
### M7 运维与发布(原 M5)
- [x] git init + 首次 commit + .gitignore
- [ ] 一键构建脚本 / GitHub Actions release / 配置导入导出 / 日志查看页
## 建议推进顺序 ## 建议推进顺序
1. [x] 修复「设置保存失效」——给 `PUT /api/manager/settings` 补注册(server.go 方法分发 + httpapi 单测) 1. [x] 修复「设置保存失效」——给 `PUT /api/manager/settings` 补注册(server.go 方法分发 + httpapi 单测)
2. [x] 对齐 dev proxy 端口——vite.config.ts → 7500(支持 VITE_PROXY_TARGET 覆盖) 2. [x] 对齐 dev proxy 端口——vite.config.ts → 7500(支持 VITE_PROXY_TARGET 覆盖)
3. M1 高级传输参数(store 加列 → render 输出 → 前端折叠表单 → 单测回归) 3. M1 高级传输参数(store 加列 → render 输出 → 前端折叠表单 → 单测回归)
4. M5 补一键构建脚本 / CI 4. M3 负载均衡与健康检查(模型 → 渲染 → admin API 状态 → 集群页)— 进行中
5. M6 集群内 frpc 二进制分发(节点间优先 → 外部 URL 兜底 → 新节点自动传输)
## 审查结论摘要(详见 FRPC_FEATURES_AUDIT.md) ## 审查结论摘要(详见 FRPC_FEATURES_AUDIT.md)

View File

@ -55,6 +55,24 @@
<label>请求头(JSON) <input v-model="httpHeadersField" placeholder='{"X-Custom":"val"}' /></label> <label>请求头(JSON) <input v-model="httpHeadersField" placeholder='{"X-Custom":"val"}' /></label>
<label>BasicAuth 用户 <input v-model="basicAuthUserField" placeholder="user" /></label> <label>BasicAuth 用户 <input v-model="basicAuthUserField" placeholder="user" /></label>
<label>BasicAuth 密码 <input v-model="basicAuthPasswordField" type="password" placeholder="pass" /></label> <label>BasicAuth 密码 <input v-model="basicAuthPasswordField" type="password" placeholder="pass" /></label>
<div class="adv-sep">负载均衡 (M3)</div>
<label>LB 组 <input v-model="lbGroupField" placeholder="如 web (同组自动负载均衡)" /></label>
<label>组密钥 <input v-model="lbGroupKeyField" placeholder="组内相同" /></label>
<div class="adv-sep">健康检查 (M3)</div>
<label
>类型
<select v-model="healthCheckTypeField">
<option value="">关闭</option>
<option value="tcp">tcp</option>
<option value="http">http</option>
</select>
</label>
<label>HTTP 路径 <input v-model="healthCheckPathField" placeholder="/healthz" /></label>
<label>超时(秒) <input v-model.number="healthCheckTimeoutField" type="number" min="0" placeholder="默认3" /></label>
<label>失败次数 <input v-model.number="healthCheckMaxFailedField" type="number" min="0" placeholder="默认1" /></label>
<label>间隔(秒) <input v-model.number="healthCheckIntervalField" type="number" min="0" placeholder="默认10" /></label>
</div> </div>
</div> </div>
</div> </div>
@ -128,6 +146,13 @@ const subdomainField = field('subdomain')
const hostHeaderRewriteField = field('hostHeaderRewrite') const hostHeaderRewriteField = field('hostHeaderRewrite')
const basicAuthUserField = field('basicAuthUser') const basicAuthUserField = field('basicAuthUser')
const basicAuthPasswordField = field('basicAuthPassword') const basicAuthPasswordField = field('basicAuthPassword')
const lbGroupField = field('lbGroup')
const lbGroupKeyField = field('lbGroupKey')
const healthCheckTypeField = field('healthCheckType')
const healthCheckPathField = field('healthCheckPath')
const healthCheckTimeoutField = field('healthCheckTimeout')
const healthCheckMaxFailedField = field('healthCheckMaxFailed')
const healthCheckIntervalField = field('healthCheckInterval')
// locations is an array shown as JSON in the UI // locations is an array shown as JSON in the UI
const locationsField = computed({ const locationsField = computed({

View File

@ -47,6 +47,15 @@
<label>连接池 <input v-model.number="transportPoolField" type="number" min="0" /></label> <label>连接池 <input v-model.number="transportPoolField" type="number" min="0" /></label>
<label class="adv-sep">HTTP 访问端口 (frps vhostHTTPPort)</label> <label class="adv-sep">HTTP 访问端口 (frps vhostHTTPPort)</label>
<label>vhostHTTPPort <input v-model.number="vhostHttpPortField" type="number" min="0" /></label> <label>vhostHTTPPort <input v-model.number="vhostHttpPortField" type="number" min="0" /></label>
<label class="adv-sep">本地 frpc 管理 API (M3)</label>
<label class="adv-check">启用后状态页按真实代理状态展示
<span class="hint-txt">(adminPort &gt; 0)</span>
</label>
<label>地址 <input v-model="adminAddrField" placeholder="127.0.0.1" /></label>
<label>端口 <input v-model.number="adminPortField" type="number" min="0" placeholder="如 7400" /></label>
<label>用户 <input v-model="adminUserField" placeholder="admin" /></label>
<label>密码 <input v-model="adminPasswordField" type="password" placeholder="可选" /></label>
</div> </div>
</div> </div>
</div> </div>
@ -87,6 +96,10 @@ const transportTlsField = field('transportTls')
const transportTlsServerNameField = field('transportTlsServerName') const transportTlsServerNameField = field('transportTlsServerName')
const transportPoolField = field('transportPool') const transportPoolField = field('transportPool')
const vhostHttpPortField = field('vhostHttpPort') const vhostHttpPortField = field('vhostHttpPort')
const adminAddrField = field('adminAddr')
const adminPortField = field('adminPort')
const adminUserField = field('adminUser')
const adminPasswordField = field('adminPassword')
const showAdv = ref(false) const showAdv = ref(false)
</script> </script>

View File

@ -30,6 +30,15 @@ export interface Local {
httpHeaders?: Record<string, string>; httpHeaders?: Record<string, string>;
basicAuthUser?: string; basicAuthUser?: string;
basicAuthPassword?: 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;
} }
export interface Remote { export interface Remote {
@ -48,6 +57,12 @@ export interface Remote {
// M2: frps vhostHTTPPort // M2: frps vhostHTTPPort
vhostHttpPort?: number; vhostHttpPort?: number;
// M3: local frpc admin API (webServer)
adminAddr?: string;
adminPort?: number;
adminUser?: string;
adminPassword?: string;
} }
export interface Link { export interface Link {
@ -81,6 +96,15 @@ export interface ProcessStatus {
err?: string; 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;
}
export interface StatusProfile { export interface StatusProfile {
name: string; name: string;
enabled: boolean; enabled: boolean;
@ -89,6 +113,10 @@ export interface StatusProfile {
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
adminEnabled?: boolean;
proxyStates?: ProxyState[];
groupCount?: number;
} }
export interface StatusResp { export interface StatusResp {

View File

@ -42,6 +42,25 @@
{{ f.service }} → :{{ f.remotePort }} {{ f.service }} → :{{ f.remotePort }}
</span> </span>
</div> </div>
<div v-if="p.groupCount" class="ns-groups">
<span class="ns-group-badge">LB ×{{ p.groupCount }}</span>
</div>
<div v-if="p.proxyStates?.length" class="ns-proxies">
<div
v-for="ps in p.proxyStates"
:key="ps.name"
class="ns-proxy"
:class="proxyStateClass(ps.status)"
>
<span class="np-name">{{ ps.name }}</span>
<span class="np-status">{{ proxyStateLabel(ps.status) }}</span>
<span v-if="ps.remote_addr" class="np-addr">{{ ps.remote_addr }}</span>
<span v-if="ps.err" class="np-err" :title="ps.err">{{ ps.err }}</span>
</div>
</div>
<div v-else-if="p.adminEnabled" class="ns-proxies-empty">
管理 API 未返回状态(frpc 未连上远程 frps 或无代理)
</div>
</div> </div>
<div v-if="!status.profiles.length" class="ns-empty">尚未配置远程节点</div> <div v-if="!status.profiles.length" class="ns-empty">尚未配置远程节点</div>
</div> </div>
@ -239,6 +258,38 @@ const connBadgeClass = (s?: string) => {
const workerHealthy = (s?: string) => s === 'running' const workerHealthy = (s?: string) => s === 'running'
// M3: per-proxy state from frpc admin API.
const proxyStateLabel = (s?: string) => {
switch (s) {
case 'running':
return '运行中'
case 'wait start':
return '等待启动'
case 'start error':
return '启动错误'
case 'check failed':
return '健康检查失败'
case 'closed':
return '已关闭'
case 'new':
default:
return '新建'
}
}
const proxyStateClass = (s?: string) => {
switch (s) {
case 'running':
return 'ok'
case 'check failed':
case 'start error':
return 'err'
case 'wait start':
return 'pending'
default:
return ''
}
}
// localWorkerLabel: state of the LOCAL frpc worker driving this target // localWorkerLabel: state of the LOCAL frpc worker driving this target
// (we do not control the remote frps). // (we do not control the remote frps).
const localWorkerLabel = (s?: string) => { const localWorkerLabel = (s?: string) => {
@ -470,14 +521,81 @@ onBeforeUnmount(() => {
margin-top: 6px; margin-top: 6px;
} }
.ns-fwd { .ns-groups {
display: flex;
gap: 4px;
margin-top: 6px;
}
.ns-group-badge {
font-size: 11px;
background: rgba(64, 158, 255, 0.12);
color: #409eff;
border-radius: 10px;
padding: 1px 8px;
}
.ns-proxies {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 6px;
}
.ns-proxy {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
border: 1px solid $color-border-lighter;
border-radius: 8px;
padding: 3px 8px;
background: #fff;
&.ok {
border-left: 3px solid $color-success;
}
&.pending {
border-left: 3px solid #409eff;
}
&.err {
border-left: 3px solid $color-danger;
}
}
.np-name {
font-weight: 600;
}
.np-status {
font-size: 11px; font-size: 11px;
background: $color-bg-muted;
border-radius: 6px;
padding: 1px 6px;
color: $color-text-secondary; color: $color-text-secondary;
} }
.np-addr {
margin-left: auto;
font-size: 11px;
color: $color-text-muted;
}
.np-err {
margin-left: auto;
font-size: 11px;
color: $color-danger;
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ns-proxies-empty {
margin-top: 6px;
font-size: 12px;
color: $color-text-muted;
}
.ns-empty, .ns-empty,
.ls-empty { .ls-empty {
color: $color-text-muted; color: $color-text-muted;