mirror of
https://gitcode.com/JianFeeeee/webui4frpc.git
synced 2026-09-19 16:38:31 +00:00
feat: M3 load balancing + health check (model/render/admin API status/UI)
This commit is contained in:
@ -4,9 +4,12 @@ package httpapi
|
||||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"webui4frpc/internal/process"
|
||||
"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) {
|
||||
if r.Method != http.MethodGet {
|
||||
methodNotAllowed(w)
|
||||
@ -149,6 +164,13 @@ func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
// ConnState is the derived frps connection state:
|
||||
// connected | connecting | failed | not_started | disabled.
|
||||
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))
|
||||
@ -159,10 +181,24 @@ func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
for _, rv := range remotes {
|
||||
st, has := h.Process.Status(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,
|
||||
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
|
||||
@ -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) {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
|
||||
@ -6,6 +6,8 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"webui4frpc/internal/process"
|
||||
@ -138,3 +140,55 @@ func TestSettingsPutRoundTrip(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -39,6 +39,19 @@ type Proxy struct {
|
||||
HTTPHeaders map[string]string // additional request headers
|
||||
BasicAuthUser 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.
|
||||
@ -96,16 +109,52 @@ type frpcProxy struct {
|
||||
HostHeaderRewrite string `json:"hostHeaderRewrite,omitempty"`
|
||||
HTTPHeaders map[string]string `json:"httpHeaders,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.
|
||||
type Config struct {
|
||||
ServerAddr string `json:"serverAddr"`
|
||||
ServerPort int `json:"serverPort"`
|
||||
Auth frpcAuth `json:"auth"`
|
||||
Transport frpcTransport `json:"transport,omitempty"`
|
||||
LoginFailExit bool `json:"loginFailExit"`
|
||||
Proxies []frpcProxy `json:"proxies"`
|
||||
ServerAddr string `json:"serverAddr"`
|
||||
ServerPort int `json:"serverPort"`
|
||||
Auth frpcAuth `json:"auth"`
|
||||
Transport frpcTransport `json:"transport,omitempty"`
|
||||
WebServer *frpcWebServer `json:"webServer,omitempty"`
|
||||
LoginFailExit bool `json:"loginFailExit"`
|
||||
Proxies []frpcProxy `json:"proxies"`
|
||||
}
|
||||
|
||||
// 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,
|
||||
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 != "" {
|
||||
cfg.Transport = frpcTransport{
|
||||
Protocol: remote.TransportProtocol,
|
||||
@ -154,6 +215,31 @@ func Render(remote store.Remote, proxies []Proxy) ([]byte, error) {
|
||||
if p.BasicAuthUser != "" || 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
|
||||
// default domain is <name>.local; frps matches it by Host header.
|
||||
if p.Type == "http" || p.Type == "https" {
|
||||
|
||||
@ -159,3 +159,108 @@ func TestRenderHTTPSUsesCustomDomains(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -36,6 +36,21 @@ type Local struct {
|
||||
HTTPHeaders map[string]string `json:"httpHeaders,omitempty"`
|
||||
BasicAuthUser string `json:"basicAuthUser,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.
|
||||
@ -56,6 +71,14 @@ type Remote struct {
|
||||
// M2: frps vhostHTTPPort (optional, displayed on the status page as the
|
||||
// HTTP access port for http/https services).
|
||||
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.
|
||||
@ -90,7 +113,22 @@ CREATE TABLE IF NOT EXISTS locals (
|
||||
name TEXT PRIMARY KEY,
|
||||
ip TEXT 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 (
|
||||
name TEXT PRIMARY KEY,
|
||||
@ -98,7 +136,16 @@ CREATE TABLE IF NOT EXISTS remotes (
|
||||
port INTEGER NOT NULL,
|
||||
token 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 (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@ -168,6 +215,13 @@ func (s *Store) migrate() error {
|
||||
"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 ''",
|
||||
"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": {
|
||||
"transport_protocol TEXT NOT NULL DEFAULT ''",
|
||||
@ -175,6 +229,10 @@ func (s *Store) migrate() error {
|
||||
"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 ''",
|
||||
},
|
||||
}
|
||||
for table, cols := range tables {
|
||||
@ -212,7 +270,7 @@ func (s *Store) Close() error { return s.db.Close() }
|
||||
// ---- Locals ----
|
||||
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
@ -222,7 +280,7 @@ func (s *Store) ListLocals() ([]Local, error) {
|
||||
var l Local
|
||||
var enc, comp int
|
||||
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
|
||||
}
|
||||
l.UseEncryption = enc != 0
|
||||
@ -240,8 +298,8 @@ func (s *Store) GetLocal(name string) (Local, bool) {
|
||||
var l Local
|
||||
var enc, comp int
|
||||
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)
|
||||
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 {
|
||||
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, &l.LBGroup, &l.LBGroupKey, &l.HealthCheckType, &l.HealthCheckPath, &l.HealthCheckTimeout, &l.HealthCheckMaxFailed, &l.HealthCheckInterval); err != nil {
|
||||
return Local{}, false
|
||||
}
|
||||
l.UseEncryption = enc != 0
|
||||
@ -255,9 +313,9 @@ func (s *Store) GetLocal(name string) (Local, bool) {
|
||||
|
||||
func (s *Store) UpsertLocal(l Local) error {
|
||||
_, 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(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) "+
|
||||
"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",
|
||||
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,
|
||||
"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, 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.LBGroup, l.LBGroupKey, l.HealthCheckType, l.HealthCheckPath, l.HealthCheckTimeout, l.HealthCheckMaxFailed, l.HealthCheckInterval,
|
||||
)
|
||||
return err
|
||||
}
|
||||
@ -318,7 +376,7 @@ func (s *Store) DeleteLocal(name string) error {
|
||||
// ---- Remotes ----
|
||||
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
@ -327,7 +385,7 @@ func (s *Store) ListRemotes() ([]Remote, error) {
|
||||
for rows.Next() {
|
||||
var r Remote
|
||||
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
|
||||
}
|
||||
r.Enabled = en != 0
|
||||
@ -340,8 +398,8 @@ func (s *Store) ListRemotes() ([]Remote, error) {
|
||||
func (s *Store) GetRemote(name string) (Remote, bool) {
|
||||
var r Remote
|
||||
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)
|
||||
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 {
|
||||
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, &r.AdminAddr, &r.AdminPort, &r.AdminUser, &r.AdminPassword); err != nil {
|
||||
return Remote{}, false
|
||||
}
|
||||
r.Enabled = en != 0
|
||||
@ -351,9 +409,9 @@ func (s *Store) GetRemote(name string) (Remote, bool) {
|
||||
|
||||
func (s *Store) UpsertRemote(r Remote) error {
|
||||
_, 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(?,?,?,?,?,?,?,?,?,?,?) "+
|
||||
"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",
|
||||
r.Name, r.IP, r.Port, r.Token, r.URL, boolToInt(r.Enabled), r.TransportProtocol, boolToInt(r.TransportTLS), r.TransportPool, r.TransportTLSServerName, r.VhostHTTPPort,
|
||||
"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, 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.AdminAddr, r.AdminPort, r.AdminUser, r.AdminPassword,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
30
plan.md
30
plan.md
@ -55,9 +55,11 @@
|
||||
|
||||
### M3 负载均衡与健康检查
|
||||
|
||||
- [ ] group / groupKey(多后端负载均衡)
|
||||
- [ ] health check:tcp / http,失败剔除与恢复
|
||||
- [ ] 状态页体现 LB 组与健康状态
|
||||
- [x] 模型扩展:Local 增加 lbGroup / lbGroupKey / healthCheck* 字段,Remote 增加 adminAddr / adminPort / adminUser / adminPassword(local frpc admin API)
|
||||
- [x] 渲染器:proxy 输出 loadBalancer + healthCheck 段;worker 配置输出 webServer 段(frpc >= 0.52 支持)
|
||||
- [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 更多代理类型
|
||||
|
||||
@ -74,12 +76,32 @@
|
||||
- [ ] 配置导出/导入(备份)
|
||||
- [ ] 日志查看页(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 单测)
|
||||
2. [x] 对齐 dev proxy 端口——vite.config.ts → 7500(支持 VITE_PROXY_TARGET 覆盖)
|
||||
3. M1 高级传输参数(store 加列 → render 输出 → 前端折叠表单 → 单测回归)
|
||||
4. M5 补一键构建脚本 / CI
|
||||
4. M3 负载均衡与健康检查(模型 → 渲染 → admin API 状态 → 集群页)— 进行中
|
||||
5. M6 集群内 frpc 二进制分发(节点间优先 → 外部 URL 兜底 → 新节点自动传输)
|
||||
|
||||
## 审查结论摘要(详见 FRPC_FEATURES_AUDIT.md)
|
||||
|
||||
|
||||
@ -55,6 +55,24 @@
|
||||
<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="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>
|
||||
@ -128,6 +146,13 @@ const subdomainField = field('subdomain')
|
||||
const hostHeaderRewriteField = field('hostHeaderRewrite')
|
||||
const basicAuthUserField = field('basicAuthUser')
|
||||
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
|
||||
const locationsField = computed({
|
||||
|
||||
@ -47,6 +47,15 @@
|
||||
<label>连接池 <input v-model.number="transportPoolField" type="number" min="0" /></label>
|
||||
<label class="adv-sep">HTTP 访问端口 (frps vhostHTTPPort)</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 > 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>
|
||||
@ -87,6 +96,10 @@ const transportTlsField = field('transportTls')
|
||||
const transportTlsServerNameField = field('transportTlsServerName')
|
||||
const transportPoolField = field('transportPool')
|
||||
const vhostHttpPortField = field('vhostHttpPort')
|
||||
const adminAddrField = field('adminAddr')
|
||||
const adminPortField = field('adminPort')
|
||||
const adminUserField = field('adminUser')
|
||||
const adminPasswordField = field('adminPassword')
|
||||
const showAdv = ref(false)
|
||||
</script>
|
||||
|
||||
|
||||
@ -30,6 +30,15 @@ export interface Local {
|
||||
httpHeaders?: Record<string, string>;
|
||||
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;
|
||||
}
|
||||
|
||||
export interface Remote {
|
||||
@ -48,6 +57,12 @@ export interface Remote {
|
||||
|
||||
// M2: frps vhostHTTPPort
|
||||
vhostHttpPort?: number;
|
||||
|
||||
// M3: local frpc admin API (webServer)
|
||||
adminAddr?: string;
|
||||
adminPort?: number;
|
||||
adminUser?: string;
|
||||
adminPassword?: string;
|
||||
}
|
||||
|
||||
export interface Link {
|
||||
@ -81,6 +96,15 @@ export interface ProcessStatus {
|
||||
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 {
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
@ -89,6 +113,10 @@ export interface StatusProfile {
|
||||
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 {
|
||||
|
||||
@ -42,6 +42,25 @@
|
||||
{{ f.service }} → :{{ f.remotePort }}
|
||||
</span>
|
||||
</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 v-if="!status.profiles.length" class="ns-empty">尚未配置远程节点</div>
|
||||
</div>
|
||||
@ -239,6 +258,38 @@ const connBadgeClass = (s?: string) => {
|
||||
|
||||
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
|
||||
// (we do not control the remote frps).
|
||||
const localWorkerLabel = (s?: string) => {
|
||||
@ -470,14 +521,81 @@ onBeforeUnmount(() => {
|
||||
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;
|
||||
background: $color-bg-muted;
|
||||
border-radius: 6px;
|
||||
padding: 1px 6px;
|
||||
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,
|
||||
.ls-empty {
|
||||
color: $color-text-muted;
|
||||
|
||||
Reference in New Issue
Block a user