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 (
"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)
}