feat(dashboard): add v2 client detail status (#5381)

This commit is contained in:
fatedier
2026-06-26 21:15:30 +08:00
committed by GitHub
parent 393a533744
commit ae1c0504ec
8 changed files with 320 additions and 102 deletions

View File

@@ -50,7 +50,9 @@ func (svr *Service) registerRouteHandlers(helper *httppkg.RouterRegisterHelper)
subRouter.HandleFunc("/api/v2/users", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2UserList)).Methods("GET")
subRouter.HandleFunc("/api/v2/clients", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ClientList)).Methods("GET")
subRouter.HandleFunc("/api/v2/clients/{key}", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ClientDetail)).Methods("GET")
v2EncodedPathRouter := subRouter.NewRoute().Subrouter()
v2EncodedPathRouter.UseEncodedPath()
v2EncodedPathRouter.HandleFunc("/api/v2/clients/{key}", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ClientDetail)).Methods("GET")
subRouter.HandleFunc("/api/v2/proxies", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ProxyList)).Methods("GET")
subRouter.HandleFunc("/api/v2/proxies/{name}", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ProxyDetail)).Methods("GET")

View File

@@ -19,6 +19,7 @@ import (
"fmt"
"math"
"net/http"
"net/url"
"slices"
"strconv"
"strings"
@@ -27,6 +28,7 @@ import (
"github.com/fatedier/frp/pkg/metrics/mem"
httppkg "github.com/fatedier/frp/pkg/util/http"
"github.com/fatedier/frp/server/http/model"
"github.com/fatedier/frp/server/registry"
)
const (
@@ -137,7 +139,31 @@ func (c *Controller) APIV2ClientList(ctx *httppkg.Context) (any, error) {
// /api/v2/clients/{key}
func (c *Controller) APIV2ClientDetail(ctx *httppkg.Context) (any, error) {
return c.APIClientDetail(ctx)
key := ctx.Param("key")
if key == "" {
return nil, fmt.Errorf("missing client key")
}
decodedKey, err := url.PathUnescape(key)
if err != nil {
return nil, fmt.Errorf("invalid client key %q: %w", key, err)
}
key = decodedKey
if c.clientRegistry == nil {
return nil, fmt.Errorf("client registry unavailable")
}
info, ok := c.clientRegistry.GetByKey(key)
if !ok {
return nil, httppkg.NewError(http.StatusNotFound, fmt.Sprintf("client %s not found", key))
}
resp := buildClientInfoResp(info)
status := c.buildV2ClientStatus(info)
return model.V2ClientDetailResp{
ClientInfoResp: resp,
Status: status,
}, nil
}
// /api/v2/proxies
@@ -366,6 +392,24 @@ func (c *Controller) listV2ProxyStats(proxyType string) []*mem.ProxyStats {
return items
}
func (c *Controller) buildV2ClientStatus(info registry.ClientInfo) model.V2ClientStatusResp {
status := model.V2ClientStatusResp{State: "offline"}
if info.Online {
status.State = "online"
}
user := info.User
clientID := info.ClientID()
for _, ps := range c.listV2ProxyStats("") {
if ps.User != user || ps.ClientID != clientID {
continue
}
status.CurConns += ps.CurConns
status.ProxyCount++
}
return status
}
func (c *Controller) buildV2ProxyResp(ps *mem.ProxyStats) model.V2ProxyResp {
state := "offline"
var spec any

View File

@@ -20,6 +20,7 @@ import (
"math"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/gorilla/mux"
@@ -146,10 +147,49 @@ func TestAPIV2ClientDetailEnvelope(t *testing.T) {
if resp.Code != http.StatusOK {
t.Fatalf("status mismatch, want %d got %d", http.StatusOK, resp.Code)
}
detailResp := decodeResponse[v2EnvelopeForTest[model.ClientInfoResp]](t, resp)
detailResp := decodeResponse[v2EnvelopeForTest[model.V2ClientDetailResp]](t, resp)
if detailResp.Data.User != "alice" || detailResp.Data.ClientID != "client-a" {
t.Fatalf("client detail mismatch: %#v", detailResp.Data)
}
if detailResp.Data.Status.State != "online" || detailResp.Data.Status.CurConns != 5 || detailResp.Data.Status.ProxyCount != 2 {
t.Fatalf("client detail status mismatch: %#v", detailResp.Data.Status)
}
}
func TestAPIV2ClientDetailEncodedKey(t *testing.T) {
oldStatsCollector := mem.StatsCollector
mem.StatsCollector = &fakeStatsCollector{
proxies: map[string]*mem.ProxyStats{
"tcp-url": {
Name: "tcp-url",
Type: "tcp",
User: "url",
ClientID: "client/a?b#c",
CurConns: 7,
},
},
}
t.Cleanup(func() {
mem.StatsCollector = oldStatsCollector
})
clientRegistry := registry.NewClientRegistry()
clientRegistry.Register("url", "client/a?b#c", "run-url", "url-host", "1.0.0", "127.0.0.4", "v2")
controller := NewController(&v1.ServerConfig{}, clientRegistry, serverproxy.NewManager())
router := newV2TestRouter(controller)
encodedKey := url.PathEscape("url.client/a?b#c")
resp := performRequest(router, "/api/v2/clients/"+encodedKey)
if resp.Code != http.StatusOK {
t.Fatalf("encoded client key status mismatch, want %d got %d, body: %s", http.StatusOK, resp.Code, resp.Body.String())
}
encodedResp := decodeResponse[v2EnvelopeForTest[model.V2ClientDetailResp]](t, resp)
if encodedResp.Data.User != "url" || encodedResp.Data.ClientID != "client/a?b#c" {
t.Fatalf("encoded client detail mismatch: %#v", encodedResp.Data)
}
if encodedResp.Data.Status.CurConns != 7 || encodedResp.Data.Status.ProxyCount != 1 {
t.Fatalf("encoded client detail status mismatch: %#v", encodedResp.Data.Status)
}
}
func TestAPIV2ProxyListDetailAndUsers(t *testing.T) {
@@ -186,8 +226,13 @@ func TestAPIV2ProxyListDetailAndUsers(t *testing.T) {
if userResp.Data.Total != 3 {
t.Fatalf("user total mismatch: %#v", userResp.Data)
}
expectedProxyCounts := map[string]int{
"": 1,
"alice": 2,
"bob": 1,
}
for _, item := range userResp.Data.Items {
if item.ClientCount != 1 || item.ProxyCount != 1 {
if item.ClientCount != 1 || item.ProxyCount != expectedProxyCounts[item.User] {
t.Fatalf("user counts mismatch: %#v", item)
}
}
@@ -322,6 +367,14 @@ func newV2TestController(t *testing.T) *Controller {
ClientID: "client-a",
TodayTrafficIn: 30,
TodayTrafficOut: 40,
CurConns: 2,
},
"http-alice": {
Name: "http-alice",
Type: "http",
User: "alice",
ClientID: "client-a",
CurConns: 3,
},
"udp-bob": {
Name: "udp-bob",
@@ -348,7 +401,9 @@ func newV2TestRouter(controller *Controller) *mux.Router {
router := mux.NewRouter()
router.HandleFunc("/api/v2/users", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2UserList)).Methods(http.MethodGet)
router.HandleFunc("/api/v2/clients", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ClientList)).Methods(http.MethodGet)
router.HandleFunc("/api/v2/clients/{key}", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ClientDetail)).Methods(http.MethodGet)
encodedPathRouter := router.NewRoute().Subrouter()
encodedPathRouter.UseEncodedPath()
encodedPathRouter.HandleFunc("/api/v2/clients/{key}", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ClientDetail)).Methods(http.MethodGet)
router.HandleFunc("/api/v2/proxies", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ProxyList)).Methods(http.MethodGet)
router.HandleFunc("/api/v2/proxies/{name}", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ProxyDetail)).Methods(http.MethodGet)
router.HandleFunc("/api/clients", httppkg.MakeHTTPHandlerFunc(controller.APIClientList)).Methods(http.MethodGet)

View File

@@ -27,6 +27,17 @@ type V2UserResp struct {
ProxyCount int `json:"proxyCount"`
}
type V2ClientDetailResp struct {
ClientInfoResp
Status V2ClientStatusResp `json:"status"`
}
type V2ClientStatusResp struct {
State string `json:"phase"`
CurConns int64 `json:"curConns"`
ProxyCount int64 `json:"proxyCount"`
}
type V2ProxyResp struct {
Name string `json:"name"`
Type string `json:"type"`