diff --git a/server/api_router.go b/server/api_router.go index ff9123f3..8becb239 100644 --- a/server/api_router.go +++ b/server/api_router.go @@ -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") diff --git a/server/http/controller_v2.go b/server/http/controller_v2.go index 8a50a54f..52dcd248 100644 --- a/server/http/controller_v2.go +++ b/server/http/controller_v2.go @@ -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 diff --git a/server/http/controller_v2_test.go b/server/http/controller_v2_test.go index eb3c91ae..44b5a6e9 100644 --- a/server/http/controller_v2_test.go +++ b/server/http/controller_v2_test.go @@ -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) diff --git a/server/http/model/v2.go b/server/http/model/v2.go index edd3212d..fd394a63 100644 --- a/server/http/model/v2.go +++ b/server/http/model/v2.go @@ -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"` diff --git a/web/frps/src/api/client.ts b/web/frps/src/api/client.ts index 2be7d239..7a3982b7 100644 --- a/web/frps/src/api/client.ts +++ b/web/frps/src/api/client.ts @@ -24,3 +24,7 @@ export const getClientsV2 = (params: ClientListV2Params = {}) => { export const getClient = (key: string) => { return http.get(`../api/clients/${key}`) } + +export const getClientV2 = (key: string) => { + return http.getV2(`../api/v2/clients/${encodeURIComponent(key)}`) +} diff --git a/web/frps/src/types/client.ts b/web/frps/src/types/client.ts index 96700fe9..33a46065 100644 --- a/web/frps/src/types/client.ts +++ b/web/frps/src/types/client.ts @@ -12,6 +12,13 @@ export interface ClientInfoData { lastConnectedAt: number disconnectedAt?: number online: boolean + status?: ClientStatus +} + +export interface ClientStatus { + phase: 'online' | 'offline' + curConns: number + proxyCount: number } export interface ClientListV2Params { diff --git a/web/frps/src/utils/client.ts b/web/frps/src/utils/client.ts index 8d26eeb8..833707e2 100644 --- a/web/frps/src/utils/client.ts +++ b/web/frps/src/utils/client.ts @@ -1,5 +1,5 @@ import { formatDistanceToNow } from './format' -import type { ClientInfoData } from '../types/client' +import type { ClientInfoData, ClientStatus } from '../types/client' export class Client { key: string @@ -15,6 +15,7 @@ export class Client { lastConnectedAt: Date disconnectedAt?: Date online: boolean + status: ClientStatus constructor(data: ClientInfoData) { this.key = data.key @@ -37,6 +38,11 @@ export class Client { this.disconnectedAt = new Date(data.disconnectedAt * 1000) } this.online = data.online + this.status = data.status || { + phase: this.online ? 'online' : 'offline', + curConns: 0, + proxyCount: 0, + } } get displayName(): string { diff --git a/web/frps/src/views/ClientDetail.vue b/web/frps/src/views/ClientDetail.vue index 42c9a25e..7fbe865f 100644 --- a/web/frps/src/views/ClientDetail.vue +++ b/web/frps/src/views/ClientDetail.vue @@ -55,7 +55,7 @@
Connections - {{ totalConnections }} + {{ client.status.curConns }}
Run ID @@ -85,7 +85,7 @@

Proxies

- {{ filteredProxies.length }} + {{ total }}
Loading...
-
+
-
+

No proxies match "{{ proxySearch }}"

No proxies found

+
+ +
@@ -130,13 +141,13 @@ @@ -483,6 +566,12 @@ html.dark .status-badge.online { padding: 16px; } +.pagination-section { + display: flex; + justify-content: center; + padding: 0 20px 20px; +} + .proxies-list { display: flex; flex-direction: column;