From 503afe78b770ee4116bd7a278256d5bb5c50d4e3 Mon Sep 17 00:00:00 2001 From: fatedier Date: Mon, 1 Jun 2026 20:06:53 +0800 Subject: [PATCH 01/23] feat: add dashboard API v2 pagination endpoints (#5351) --- pkg/util/http/handler.go | 30 +++ server/api_router.go | 6 + server/http/controller_v2.go | 376 ++++++++++++++++++++++++++++++ server/http/controller_v2_test.go | 294 +++++++++++++++++++++++ server/http/model/v2.go | 46 ++++ 5 files changed, 752 insertions(+) create mode 100644 server/http/controller_v2.go create mode 100644 server/http/controller_v2_test.go create mode 100644 server/http/model/v2.go diff --git a/pkg/util/http/handler.go b/pkg/util/http/handler.go index d6c7be18..55de2932 100644 --- a/pkg/util/http/handler.go +++ b/pkg/util/http/handler.go @@ -26,6 +26,12 @@ type GeneralResponse struct { Msg string } +type V2Response struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data any `json:"data"` +} + // APIHandler is a handler function that returns a response object or an error. type APIHandler func(ctx *Context) (any, error) @@ -64,3 +70,27 @@ func MakeHTTPHandlerFunc(handler APIHandler) http.HandlerFunc { } } } + +// MakeHTTPHandlerFuncV2 wraps a handler response in the dashboard API v2 envelope. +func MakeHTTPHandlerFuncV2(handler APIHandler) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := NewContext(w, r) + res, err := handler(ctx) + if err != nil { + log.Warnf("http response [%s]: error: %v", r.URL.Path, err) + code := http.StatusInternalServerError + if e, ok := err.(*Error); ok { + code = e.Code + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(V2Response{Code: code, Msg: err.Error(), Data: nil}) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(V2Response{Code: http.StatusOK, Msg: "success", Data: res}) + } +} diff --git a/server/api_router.go b/server/api_router.go index bb9e44ed..ff9123f3 100644 --- a/server/api_router.go +++ b/server/api_router.go @@ -48,6 +48,12 @@ func (svr *Service) registerRouteHandlers(helper *httppkg.RouterRegisterHelper) subRouter.HandleFunc("/api/clients/{key}", httppkg.MakeHTTPHandlerFunc(apiController.APIClientDetail)).Methods("GET") subRouter.HandleFunc("/api/proxies", httppkg.MakeHTTPHandlerFunc(apiController.DeleteProxies)).Methods("DELETE") + 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") + subRouter.HandleFunc("/api/v2/proxies", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ProxyList)).Methods("GET") + subRouter.HandleFunc("/api/v2/proxies/{name}", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ProxyDetail)).Methods("GET") + // view subRouter.Handle("/favicon.ico", http.FileServer(helper.AssetsFS)).Methods("GET") subRouter.PathPrefix("/static/").Handler( diff --git a/server/http/controller_v2.go b/server/http/controller_v2.go new file mode 100644 index 00000000..2733a3b0 --- /dev/null +++ b/server/http/controller_v2.go @@ -0,0 +1,376 @@ +// Copyright 2026 The frp Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package http + +import ( + "cmp" + "fmt" + "math" + "net/http" + "slices" + "strconv" + "strings" + + v1 "github.com/fatedier/frp/pkg/config/v1" + "github.com/fatedier/frp/pkg/metrics/mem" + httppkg "github.com/fatedier/frp/pkg/util/http" + "github.com/fatedier/frp/server/http/model" +) + +const ( + defaultV2Page = 1 + defaultV2PageSize = 50 + maxV2PageSize = 200 +) + +var apiV2ProxyTypes = []string{ + string(v1.ProxyTypeTCP), + string(v1.ProxyTypeUDP), + string(v1.ProxyTypeHTTP), + string(v1.ProxyTypeHTTPS), + string(v1.ProxyTypeTCPMUX), + string(v1.ProxyTypeSTCP), + string(v1.ProxyTypeXTCP), + string(v1.ProxyTypeSUDP), +} + +// /api/v2/users +func (c *Controller) APIV2UserList(ctx *httppkg.Context) (any, error) { + page, pageSize, err := parseV2PageParams(ctx) + if err != nil { + return nil, err + } + if c.clientRegistry == nil { + return nil, fmt.Errorf("client registry unavailable") + } + + userStats := make(map[string]*model.V2UserResp) + for _, info := range c.clientRegistry.List() { + item := getOrCreateV2User(userStats, info.User) + item.ClientCount++ + } + for _, proxyInfo := range c.listV2ProxyStats("") { + item := getOrCreateV2User(userStats, proxyInfo.User) + item.ProxyCount++ + } + + q := strings.ToLower(ctx.Query("q")) + items := make([]model.V2UserResp, 0, len(userStats)) + for _, item := range userStats { + if q != "" && !strings.Contains(strings.ToLower(item.User), q) { + continue + } + items = append(items, *item) + } + slices.SortFunc(items, func(a, b model.V2UserResp) int { + return cmp.Compare(a.User, b.User) + }) + + return buildV2PageResp(items, page, pageSize), nil +} + +// /api/v2/clients +func (c *Controller) APIV2ClientList(ctx *httppkg.Context) (any, error) { + page, pageSize, err := parseV2PageParams(ctx) + if err != nil { + return nil, err + } + if c.clientRegistry == nil { + return nil, fmt.Errorf("client registry unavailable") + } + statusFilter, err := parseV2StatusFilter(ctx.Query("status")) + if err != nil { + return nil, err + } + + userFilter, filterByUser := queryValue(ctx, "user") + clientIDFilter := ctx.Query("clientID") + runIDFilter := ctx.Query("runID") + q := strings.ToLower(ctx.Query("q")) + + records := c.clientRegistry.List() + items := make([]model.ClientInfoResp, 0, len(records)) + for _, info := range records { + if filterByUser && info.User != userFilter { + continue + } + if clientIDFilter != "" && info.ClientID() != clientIDFilter { + continue + } + if runIDFilter != "" && info.RunID != runIDFilter { + continue + } + if !matchV2StatusFilter(info.Online, statusFilter) { + continue + } + resp := buildClientInfoResp(info) + if q != "" && !matchV2ClientQuery(resp, q) { + continue + } + items = append(items, resp) + } + + slices.SortFunc(items, func(a, b model.ClientInfoResp) int { + if v := cmp.Compare(a.User, b.User); v != 0 { + return v + } + if v := cmp.Compare(a.ClientID, b.ClientID); v != 0 { + return v + } + return cmp.Compare(a.Key, b.Key) + }) + + return buildV2PageResp(items, page, pageSize), nil +} + +// /api/v2/clients/{key} +func (c *Controller) APIV2ClientDetail(ctx *httppkg.Context) (any, error) { + return c.APIClientDetail(ctx) +} + +// /api/v2/proxies +func (c *Controller) APIV2ProxyList(ctx *httppkg.Context) (any, error) { + page, pageSize, err := parseV2PageParams(ctx) + if err != nil { + return nil, err + } + statusFilter, err := parseV2StatusFilter(ctx.Query("status")) + if err != nil { + return nil, err + } + + proxyType, err := parseV2ProxyTypeFilter(ctx.Query("type")) + if err != nil { + return nil, err + } + userFilter, filterByUser := queryValue(ctx, "user") + clientIDFilter := ctx.Query("clientID") + q := strings.ToLower(ctx.Query("q")) + + stats := c.listV2ProxyStats(proxyType) + items := make([]model.V2ProxyResp, 0, len(stats)) + for _, ps := range stats { + resp := c.buildV2ProxyResp(ps) + if filterByUser && resp.User != userFilter { + continue + } + if clientIDFilter != "" && resp.ClientID != clientIDFilter { + continue + } + if !matchV2StatusFilter(resp.Status.State == "online", statusFilter) { + continue + } + if q != "" && !matchV2ProxyQuery(resp, q) { + continue + } + items = append(items, resp) + } + + slices.SortFunc(items, func(a, b model.V2ProxyResp) int { + if v := cmp.Compare(a.Type, b.Type); v != 0 { + return v + } + return cmp.Compare(a.Name, b.Name) + }) + + return buildV2PageResp(items, page, pageSize), nil +} + +// /api/v2/proxies/{name} +func (c *Controller) APIV2ProxyDetail(ctx *httppkg.Context) (any, error) { + name := ctx.Param("name") + if name == "" { + return nil, fmt.Errorf("missing proxy name") + } + + ps := mem.StatsCollector.GetProxyByName(name) + if ps == nil { + return nil, httppkg.NewError(http.StatusNotFound, "no proxy info found") + } + return c.buildV2ProxyResp(ps), nil +} + +func getOrCreateV2User(items map[string]*model.V2UserResp, user string) *model.V2UserResp { + item, ok := items[user] + if !ok { + item = &model.V2UserResp{User: user} + items[user] = item + } + return item +} + +func parseV2PageParams(ctx *httppkg.Context) (int, int, error) { + page, err := parseV2PositiveInt(ctx.Query("page"), defaultV2Page, "page") + if err != nil { + return 0, 0, err + } + pageSize, err := parseV2PositiveInt(ctx.Query("pageSize"), defaultV2PageSize, "pageSize") + if err != nil { + return 0, 0, err + } + if pageSize > maxV2PageSize { + return 0, 0, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("pageSize must be between 1 and %d", maxV2PageSize)) + } + if page > math.MaxInt/pageSize { + return 0, 0, httppkg.NewError(http.StatusBadRequest, "page is too large") + } + return page, pageSize, nil +} + +func parseV2PositiveInt(raw string, defaultValue int, name string) (int, error) { + if raw == "" { + return defaultValue, nil + } + value, err := strconv.Atoi(raw) + if err != nil || value < 1 { + return 0, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("%s must be a positive integer", name)) + } + return value, nil +} + +func parseV2StatusFilter(raw string) (string, error) { + status := strings.ToLower(raw) + switch status { + case "", "all", "online", "offline": + return status, nil + default: + return "", httppkg.NewError(http.StatusBadRequest, "status must be one of all, online, offline") + } +} + +func parseV2ProxyTypeFilter(raw string) (string, error) { + proxyType := strings.ToLower(raw) + if proxyType == "" { + return "", nil + } + if slices.Contains(apiV2ProxyTypes, proxyType) { + return proxyType, nil + } + return "", httppkg.NewError(http.StatusBadRequest, "type must be one of tcp, udp, http, https, tcpmux, stcp, xtcp, sudp") +} + +func matchV2StatusFilter(online bool, filter string) bool { + switch filter { + case "", "all": + return true + case "online": + return online + case "offline": + return !online + default: + return true + } +} + +func buildV2PageResp[T any](items []T, page, pageSize int) model.V2PageResp[T] { + total := len(items) + return model.V2PageResp[T]{ + Total: total, + Page: page, + PageSize: pageSize, + Items: paginateV2Items(items, page, pageSize), + } +} + +func paginateV2Items[T any](items []T, page, pageSize int) []T { + start := (page - 1) * pageSize + if start >= len(items) { + return []T{} + } + end := min(start+pageSize, len(items)) + return items[start:end] +} + +func queryValue(ctx *httppkg.Context, key string) (string, bool) { + values, ok := ctx.Req.URL.Query()[key] + if !ok { + return "", false + } + if len(values) == 0 { + return "", true + } + return values[0], true +} + +func matchV2ClientQuery(item model.ClientInfoResp, q string) bool { + return containsV2Query(q, + item.Key, + item.User, + item.ClientID, + item.RunID, + item.Version, + item.WireProtocol, + item.Hostname, + item.ClientIP, + ) +} + +func matchV2ProxyQuery(item model.V2ProxyResp, q string) bool { + return containsV2Query(q, + item.Name, + item.Type, + item.User, + item.ClientID, + item.Status.State, + ) +} + +func containsV2Query(q string, values ...string) bool { + for _, value := range values { + if strings.Contains(strings.ToLower(value), q) { + return true + } + } + return false +} + +func (c *Controller) listV2ProxyStats(proxyType string) []*mem.ProxyStats { + if proxyType != "" { + return mem.StatsCollector.GetProxiesByType(proxyType) + } + + items := make([]*mem.ProxyStats, 0) + for _, t := range apiV2ProxyTypes { + items = append(items, mem.StatsCollector.GetProxiesByType(t)...) + } + return items +} + +func (c *Controller) buildV2ProxyResp(ps *mem.ProxyStats) model.V2ProxyResp { + state := "offline" + var spec any + if c.pxyManager != nil { + if pxy, ok := c.pxyManager.GetByName(ps.Name); ok { + state = "online" + spec = getConfFromConfigurer(pxy.GetConfigurer()) + } + } + + return model.V2ProxyResp{ + Name: ps.Name, + Type: ps.Type, + User: ps.User, + ClientID: ps.ClientID, + Spec: spec, + Status: model.V2ProxyStatusResp{ + State: state, + TodayTrafficIn: ps.TodayTrafficIn, + TodayTrafficOut: ps.TodayTrafficOut, + CurConns: ps.CurConns, + LastStartTime: ps.LastStartTime, + LastCloseTime: ps.LastCloseTime, + }, + } +} diff --git a/server/http/controller_v2_test.go b/server/http/controller_v2_test.go new file mode 100644 index 00000000..0db98294 --- /dev/null +++ b/server/http/controller_v2_test.go @@ -0,0 +1,294 @@ +// Copyright 2026 The frp Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package http + +import ( + "encoding/json" + "fmt" + "math" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gorilla/mux" + + v1 "github.com/fatedier/frp/pkg/config/v1" + "github.com/fatedier/frp/pkg/metrics/mem" + httppkg "github.com/fatedier/frp/pkg/util/http" + "github.com/fatedier/frp/server/http/model" + serverproxy "github.com/fatedier/frp/server/proxy" + "github.com/fatedier/frp/server/registry" +) + +type v2EnvelopeForTest[T any] struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data T `json:"data"` +} + +type fakeStatsCollector struct { + proxies map[string]*mem.ProxyStats +} + +func (f *fakeStatsCollector) GetServer() *mem.ServerStats { + return &mem.ServerStats{ProxyTypeCounts: map[string]int64{}} +} + +func (f *fakeStatsCollector) GetProxiesByType(proxyType string) []*mem.ProxyStats { + items := make([]*mem.ProxyStats, 0) + for _, ps := range f.proxies { + if ps.Type == proxyType { + items = append(items, ps) + } + } + return items +} + +func (f *fakeStatsCollector) GetProxiesByTypeAndName(proxyType string, proxyName string) *mem.ProxyStats { + ps := f.proxies[proxyName] + if ps != nil && ps.Type == proxyType { + return ps + } + return nil +} + +func (f *fakeStatsCollector) GetProxyByName(proxyName string) *mem.ProxyStats { + return f.proxies[proxyName] +} + +func (f *fakeStatsCollector) GetProxyTraffic(name string) *mem.ProxyTrafficInfo { + return nil +} + +func (f *fakeStatsCollector) ClearOfflineProxies() (int, int) { + return 0, len(f.proxies) +} + +func TestAPIV2ClientListEnvelopePaginationAndFilters(t *testing.T) { + controller := newV2TestController(t) + router := newV2TestRouter(controller) + + resp := performRequest(router, "/api/v2/clients?page=1&pageSize=1") + if resp.Code != http.StatusOK { + t.Fatalf("status mismatch, want %d got %d", http.StatusOK, resp.Code) + } + pageResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.ClientInfoResp]]](t, resp) + if pageResp.Code != http.StatusOK || pageResp.Msg != "success" { + t.Fatalf("envelope mismatch: %#v", pageResp) + } + if pageResp.Data.Total != 3 || pageResp.Data.Page != 1 || pageResp.Data.PageSize != 1 || len(pageResp.Data.Items) != 1 { + t.Fatalf("page data mismatch: %#v", pageResp.Data) + } + if got := pageResp.Data.Items[0].User; got != "" { + t.Fatalf("first sorted user mismatch, want empty got %q", got) + } + + resp = performRequest(router, "/api/v2/clients?user=&page=1&pageSize=50") + emptyUserResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.ClientInfoResp]]](t, resp) + if emptyUserResp.Data.Total != 1 || emptyUserResp.Data.Items[0].User != "" { + t.Fatalf("empty user filter mismatch: %#v", emptyUserResp.Data) + } + + resp = performRequest(router, "/api/v2/clients?user=alice&status=online&q=alice-host") + aliceResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.ClientInfoResp]]](t, resp) + if aliceResp.Data.Total != 1 || aliceResp.Data.Items[0].User != "alice" { + t.Fatalf("alice filter mismatch: %#v", aliceResp.Data) + } + + resp = performRequest(router, "/api/v2/clients?status=offline") + offlineResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.ClientInfoResp]]](t, resp) + if offlineResp.Data.Total != 1 || offlineResp.Data.Items[0].User != "bob" { + t.Fatalf("offline filter mismatch: %#v", offlineResp.Data) + } +} + +func TestAPIV2PageParamErrorsUseEnvelope(t *testing.T) { + controller := newV2TestController(t) + router := newV2TestRouter(controller) + + resp := performRequest(router, "/api/v2/clients?page=0") + if resp.Code != http.StatusBadRequest { + t.Fatalf("status mismatch, want %d got %d", http.StatusBadRequest, resp.Code) + } + errResp := decodeResponse[httppkg.V2Response](t, resp) + if errResp.Code != http.StatusBadRequest || errResp.Data != nil { + t.Fatalf("error envelope mismatch: %#v", errResp) + } + + resp = performRequest(router, "/api/v2/clients?pageSize=201") + if resp.Code != http.StatusBadRequest { + t.Fatalf("status mismatch, want %d got %d", http.StatusBadRequest, resp.Code) + } + + resp = performRequest(router, fmt.Sprintf("/api/v2/clients?page=%d&pageSize=2", math.MaxInt)) + if resp.Code != http.StatusBadRequest { + t.Fatalf("status mismatch for overflowing page offset, want %d got %d", http.StatusBadRequest, resp.Code) + } +} + +func TestAPIV2ClientDetailEnvelope(t *testing.T) { + controller := newV2TestController(t) + router := newV2TestRouter(controller) + + resp := performRequest(router, "/api/v2/clients/alice.client-a") + if resp.Code != http.StatusOK { + t.Fatalf("status mismatch, want %d got %d", http.StatusOK, resp.Code) + } + detailResp := decodeResponse[v2EnvelopeForTest[model.ClientInfoResp]](t, resp) + if detailResp.Data.User != "alice" || detailResp.Data.ClientID != "client-a" { + t.Fatalf("client detail mismatch: %#v", detailResp.Data) + } +} + +func TestAPIV2ProxyListDetailAndUsers(t *testing.T) { + controller := newV2TestController(t) + router := newV2TestRouter(controller) + + resp := performRequest(router, "/api/v2/proxies?type=invalid") + if resp.Code != http.StatusBadRequest { + t.Fatalf("invalid proxy type status mismatch, want %d got %d", http.StatusBadRequest, resp.Code) + } + errResp := decodeResponse[httppkg.V2Response](t, resp) + if errResp.Code != http.StatusBadRequest || errResp.Data != nil { + t.Fatalf("invalid proxy type error envelope mismatch: %#v", errResp) + } + + resp = performRequest(router, "/api/v2/proxies?type=tcp&user=&page=1&pageSize=50") + proxyResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.V2ProxyResp]]](t, resp) + if proxyResp.Data.Total != 1 { + t.Fatalf("proxy filter total mismatch: %#v", proxyResp.Data) + } + proxyItem := proxyResp.Data.Items[0] + if proxyItem.Name != "tcp-empty" || proxyItem.Type != "tcp" || proxyItem.User != "" || proxyItem.Status.State != "offline" { + t.Fatalf("proxy item mismatch: %#v", proxyItem) + } + + resp = performRequest(router, "/api/v2/proxies/tcp-alice") + proxyDetailResp := decodeResponse[v2EnvelopeForTest[model.V2ProxyResp]](t, resp) + if proxyDetailResp.Data.Name != "tcp-alice" || proxyDetailResp.Data.User != "alice" { + t.Fatalf("proxy detail mismatch: %#v", proxyDetailResp.Data) + } + + resp = performRequest(router, "/api/v2/users?page=1&pageSize=50") + userResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.V2UserResp]]](t, resp) + if userResp.Data.Total != 3 { + t.Fatalf("user total mismatch: %#v", userResp.Data) + } + for _, item := range userResp.Data.Items { + if item.ClientCount != 1 || item.ProxyCount != 1 { + t.Fatalf("user counts mismatch: %#v", item) + } + } +} + +func TestLegacyAPIResponsesRemainBare(t *testing.T) { + controller := newV2TestController(t) + router := newV2TestRouter(controller) + + resp := performRequest(router, "/api/clients") + var clients []model.ClientInfoResp + if err := json.Unmarshal(resp.Body.Bytes(), &clients); err != nil { + t.Fatalf("legacy clients should be a bare array: %v, body: %s", err, resp.Body.String()) + } + if len(clients) != 3 { + t.Fatalf("legacy clients total mismatch, want 3 got %d", len(clients)) + } + + resp = performRequest(router, "/api/proxy/tcp") + var proxies model.GetProxyInfoResp + if err := json.Unmarshal(resp.Body.Bytes(), &proxies); err != nil { + t.Fatalf("legacy proxy response should be {proxies}: %v, body: %s", err, resp.Body.String()) + } + if len(proxies.Proxies) != 2 { + t.Fatalf("legacy tcp proxy total mismatch, want 2 got %d", len(proxies.Proxies)) + } + var envelope httppkg.V2Response + if err := json.Unmarshal(resp.Body.Bytes(), &envelope); err == nil && envelope.Code != 0 { + t.Fatalf("legacy proxy response should not use v2 envelope: %#v", envelope) + } +} + +func newV2TestController(t *testing.T) *Controller { + t.Helper() + + oldStatsCollector := mem.StatsCollector + mem.StatsCollector = &fakeStatsCollector{ + proxies: map[string]*mem.ProxyStats{ + "tcp-empty": { + Name: "tcp-empty", + Type: "tcp", + User: "", + ClientID: "legacy-client", + TodayTrafficIn: 10, + TodayTrafficOut: 20, + CurConns: 1, + }, + "tcp-alice": { + Name: "tcp-alice", + Type: "tcp", + User: "alice", + ClientID: "client-a", + TodayTrafficIn: 30, + TodayTrafficOut: 40, + }, + "udp-bob": { + Name: "udp-bob", + Type: "udp", + User: "bob", + ClientID: "client-b", + }, + }, + } + t.Cleanup(func() { + mem.StatsCollector = oldStatsCollector + }) + + clientRegistry := registry.NewClientRegistry() + clientRegistry.Register("", "legacy-client", "run-empty", "empty-host", "1.0.0", "127.0.0.1", "v1") + clientRegistry.Register("alice", "client-a", "run-a", "alice-host", "1.0.0", "127.0.0.2", "v2") + clientRegistry.Register("bob", "client-b", "run-b", "bob-host", "1.0.0", "127.0.0.3", "v1") + clientRegistry.MarkOfflineByRunID("run-b") + + return NewController(&v1.ServerConfig{}, clientRegistry, serverproxy.NewManager()) +} + +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) + 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) + router.HandleFunc("/api/proxy/{type}", httppkg.MakeHTTPHandlerFunc(controller.APIProxyByType)).Methods(http.MethodGet) + return router +} + +func performRequest(handler http.Handler, target string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, target, nil) + resp := httptest.NewRecorder() + handler.ServeHTTP(resp, req) + return resp +} + +func decodeResponse[T any](t *testing.T, resp *httptest.ResponseRecorder) T { + t.Helper() + + var out T + if err := json.Unmarshal(resp.Body.Bytes(), &out); err != nil { + t.Fatalf("unmarshal response failed: %v, body: %s", err, resp.Body.String()) + } + return out +} diff --git a/server/http/model/v2.go b/server/http/model/v2.go new file mode 100644 index 00000000..edd3212d --- /dev/null +++ b/server/http/model/v2.go @@ -0,0 +1,46 @@ +// Copyright 2026 The frp Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package model + +type V2PageResp[T any] struct { + Total int `json:"total"` + Page int `json:"page"` + PageSize int `json:"pageSize"` + Items []T `json:"items"` +} + +type V2UserResp struct { + User string `json:"user"` + ClientCount int `json:"clientCount"` + ProxyCount int `json:"proxyCount"` +} + +type V2ProxyResp struct { + Name string `json:"name"` + Type string `json:"type"` + User string `json:"user"` + ClientID string `json:"clientID"` + Spec any `json:"spec"` + Status V2ProxyStatusResp `json:"status"` +} + +type V2ProxyStatusResp struct { + State string `json:"phase"` + TodayTrafficIn int64 `json:"todayTrafficIn"` + TodayTrafficOut int64 `json:"todayTrafficOut"` + CurConns int64 `json:"curConns"` + LastStartTime string `json:"lastStartTime"` + LastCloseTime string `json:"lastCloseTime"` +} From c6c545289ce07e4c718524bcbada0670ba0fdcb9 Mon Sep 17 00:00:00 2001 From: fatedier Date: Tue, 2 Jun 2026 13:39:37 +0800 Subject: [PATCH 02/23] fix: normalize web package lockfile (#5353) --- web/package-lock.json | 70 ------------------------------------------- 1 file changed, 70 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index d9a66bc8..5dd491cf 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -954,76 +954,6 @@ "node": ">= 8" } }, - "node_modules/@nuxt/kit": { - "version": "3.21.2", - "resolved": "https://registry.npmjs.org/@nuxt/kit/-/kit-3.21.2.tgz", - "integrity": "sha512-Bd6m6mrDrqpBEbX+g0rc66/ALd1sxlgdx5nfK9MAYO0yKLTOSK7McSYz1KcOYn3LQFCXOWfvXwaqih/b+REI1g==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "c12": "^3.3.3", - "consola": "^3.4.2", - "defu": "^6.1.4", - "destr": "^2.0.5", - "errx": "^0.1.0", - "exsolve": "^1.0.8", - "ignore": "^7.0.5", - "jiti": "^2.6.1", - "klona": "^2.0.6", - "knitwork": "^1.3.0", - "mlly": "^1.8.1", - "ohash": "^2.0.11", - "pathe": "^2.0.3", - "pkg-types": "^2.3.0", - "rc9": "^3.0.0", - "scule": "^1.3.0", - "semver": "^7.7.4", - "tinyglobby": "^0.2.15", - "ufo": "^1.6.3", - "unctx": "^2.5.0", - "untyped": "^2.0.0" - }, - "engines": { - "node": ">=18.12.0" - } - }, - "node_modules/@nuxt/kit/node_modules/confbox": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", - "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, - "node_modules/@nuxt/kit/node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 4" - } - }, - "node_modules/@nuxt/kit/node_modules/pkg-types": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", - "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "confbox": "^0.2.2", - "exsolve": "^1.0.7", - "pathe": "^2.0.3" - } - }, "node_modules/@parcel/watcher": { "version": "2.5.6", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", From 9bde0b07de61c27b627980d47f625ba41d3ceca8 Mon Sep 17 00:00:00 2001 From: fatedier Date: Wed, 3 Jun 2026 14:08:45 +0800 Subject: [PATCH 03/23] feat: paginate dashboard clients and proxies via API v2 (#5354) Move the frps dashboard Clients and Proxies views to the paginated /api/v2/clients and /api/v2/proxies endpoints instead of fetching all data at once, and extend server-side proxy search so the search box keeps working under pagination. Frontend: - Add V2Envelope/V2Page types and getV2 HTTP helper to api/http.ts - Add v2 paginated fetch functions to api/client.ts and api/proxy.ts - Add ClientV2Info and ProxyV2Info types for v2 API responses - Rewrite Clients.vue with server-side pagination, status/user search filtering, and ElPagination component - Rewrite Proxies.vue with server-side pagination, type tabs, client dropdown filter, and a search box that passes q to the API - Default page size 10, selectable sizes [10, 20, 50, 100] Backend: - Extend /api/v2/proxies q matching to also cover online proxy spec fields: TCP/UDP remotePort and HTTP/HTTPS/TCPMux customDomains and subdomain, so dashboard search no longer needs to scan every page - Add controller_v2 tests for the new spec-field matching --- server/http/controller_v2.go | 22 ++- server/http/controller_v2_test.go | 80 +++++++++ web/frps/src/api/client.ts | 20 ++- web/frps/src/api/http.ts | 61 +++++++ web/frps/src/api/proxy.ts | 39 +++- web/frps/src/types/client.ts | 10 ++ web/frps/src/types/proxy.ts | 29 +++ web/frps/src/views/Clients.vue | 162 ++++++++++++----- web/frps/src/views/Proxies.vue | 288 +++++++++++++++++------------- 9 files changed, 536 insertions(+), 175 deletions(-) diff --git a/server/http/controller_v2.go b/server/http/controller_v2.go index 2733a3b0..8a50a54f 100644 --- a/server/http/controller_v2.go +++ b/server/http/controller_v2.go @@ -318,13 +318,31 @@ func matchV2ClientQuery(item model.ClientInfoResp, q string) bool { } func matchV2ProxyQuery(item model.V2ProxyResp, q string) bool { - return containsV2Query(q, + values := []string{ item.Name, item.Type, item.User, item.ClientID, item.Status.State, - ) + } + + switch spec := item.Spec.(type) { + case *model.TCPOutConf: + values = append(values, strconv.Itoa(spec.RemotePort)) + case *model.UDPOutConf: + values = append(values, strconv.Itoa(spec.RemotePort)) + case *model.HTTPOutConf: + values = append(values, spec.CustomDomains...) + values = append(values, spec.SubDomain) + case *model.HTTPSOutConf: + values = append(values, spec.CustomDomains...) + values = append(values, spec.SubDomain) + case *model.TCPMuxOutConf: + values = append(values, spec.CustomDomains...) + values = append(values, spec.SubDomain) + } + + return containsV2Query(q, values...) } func containsV2Query(q string, values ...string) bool { diff --git a/server/http/controller_v2_test.go b/server/http/controller_v2_test.go index 0db98294..eb3c91ae 100644 --- a/server/http/controller_v2_test.go +++ b/server/http/controller_v2_test.go @@ -193,6 +193,86 @@ func TestAPIV2ProxyListDetailAndUsers(t *testing.T) { } } +func TestMatchV2ProxyQueryMatchesSpecFields(t *testing.T) { + tests := []struct { + name string + item model.V2ProxyResp + q string + want bool + }{ + { + name: "tcp remote port", + item: model.V2ProxyResp{Name: "tcp-proxy", Type: "tcp", Spec: &model.TCPOutConf{ + RemotePort: 6000, + }}, + q: "6000", + want: true, + }, + { + name: "udp remote port", + item: model.V2ProxyResp{Name: "udp-proxy", Type: "udp", Spec: &model.UDPOutConf{ + RemotePort: 7000, + }}, + q: "7000", + want: true, + }, + { + name: "remote port does not match colon form", + item: model.V2ProxyResp{Name: "tcp-proxy", Type: "tcp", Spec: &model.TCPOutConf{ + RemotePort: 6000, + }}, + q: ":6000", + want: false, + }, + { + name: "http custom domain", + item: model.V2ProxyResp{Name: "http-proxy", Type: "http", Spec: &model.HTTPOutConf{ + DomainConfig: v1.DomainConfig{CustomDomains: []string{"app.example.com"}}, + }}, + q: "app.example.com", + want: true, + }, + { + name: "https subdomain", + item: model.V2ProxyResp{Name: "https-proxy", Type: "https", Spec: &model.HTTPSOutConf{ + DomainConfig: v1.DomainConfig{SubDomain: "portal"}, + }}, + q: "portal", + want: true, + }, + { + name: "subdomain does not match expanded host", + item: model.V2ProxyResp{Name: "https-proxy", Type: "https", Spec: &model.HTTPSOutConf{ + DomainConfig: v1.DomainConfig{SubDomain: "portal"}, + }}, + q: "portal.example.com", + want: false, + }, + { + name: "tcpmux custom domain", + item: model.V2ProxyResp{Name: "tcpmux-proxy", Type: "tcpmux", Spec: &model.TCPMuxOutConf{ + DomainConfig: v1.DomainConfig{CustomDomains: []string{"mux.example.com"}}, + }}, + q: "mux.example.com", + want: true, + }, + { + name: "nil spec does not match spec fields", + item: model.V2ProxyResp{Name: "offline-proxy", Type: "tcp", Spec: nil}, + q: "6000", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := matchV2ProxyQuery(tt.item, tt.q); got != tt.want { + t.Fatalf("matchV2ProxyQuery() = %v, want %v", got, tt.want) + } + }) + } +} + func TestLegacyAPIResponsesRemainBare(t *testing.T) { controller := newV2TestController(t) router := newV2TestRouter(controller) diff --git a/web/frps/src/api/client.ts b/web/frps/src/api/client.ts index 41d382cc..2be7d239 100644 --- a/web/frps/src/api/client.ts +++ b/web/frps/src/api/client.ts @@ -1,10 +1,26 @@ -import { http } from './http' -import type { ClientInfoData } from '../types/client' +import { buildQueryString, http } from './http' +import type { V2Page } from './http' +import type { ClientInfoData, ClientListV2Params } from '../types/client' export const getClients = () => { return http.get('../api/clients') } +export const getClientsV2 = (params: ClientListV2Params = {}) => { + return http.getV2>( + `../api/v2/clients${buildQueryString({ + page: params.page, + pageSize: params.pageSize, + status: + params.status && params.status !== 'all' ? params.status : undefined, + q: params.q || undefined, + user: params.user, + clientID: params.clientID || undefined, + runID: params.runID || undefined, + })}`, + ) +} + export const getClient = (key: string) => { return http.get(`../api/clients/${key}`) } diff --git a/web/frps/src/api/http.ts b/web/frps/src/api/http.ts index d6291e9e..44100b07 100644 --- a/web/frps/src/api/http.ts +++ b/web/frps/src/api/http.ts @@ -11,6 +11,21 @@ class HTTPError extends Error { } } +export interface V2Envelope { + code: number + msg: string + data: T +} + +export interface V2Page { + total: number + page: number + pageSize: number + items: T[] +} + +type QueryParamValue = string | number | boolean | null | undefined + async function request(url: string, options: RequestInit = {}): Promise { const defaultOptions: RequestInit = { credentials: 'include', @@ -34,9 +49,55 @@ async function request(url: string, options: RequestInit = {}): Promise { return response.json() } +async function requestV2( + url: string, + options: RequestInit = {}, +): Promise { + const defaultOptions: RequestInit = { + credentials: 'include', + } + + const response = await fetch(url, { ...defaultOptions, ...options }) + const envelope = (await response.json().catch(() => null)) as + | V2Envelope + | null + + if (!response.ok) { + throw new HTTPError( + response.status, + response.statusText, + envelope?.msg || `HTTP ${response.status}`, + ) + } + + if (!envelope || typeof envelope.code !== 'number') { + throw new Error('Invalid API v2 response') + } + + if (envelope.code >= 400) { + throw new HTTPError(envelope.code, envelope.msg, envelope.msg) + } + + return envelope.data +} + +export const buildQueryString = ( + params: Record, +): string => { + const query = new URLSearchParams() + for (const [key, value] of Object.entries(params)) { + if (value === null || value === undefined) continue + query.append(key, String(value)) + } + const text = query.toString() + return text ? `?${text}` : '' +} + export const http = { get: (url: string, options?: RequestInit) => request(url, { ...options, method: 'GET' }), + getV2: (url: string, options?: RequestInit) => + requestV2(url, { ...options, method: 'GET' }), post: (url: string, body?: any, options?: RequestInit) => request(url, { ...options, diff --git a/web/frps/src/api/proxy.ts b/web/frps/src/api/proxy.ts index b323a588..20125842 100644 --- a/web/frps/src/api/proxy.ts +++ b/web/frps/src/api/proxy.ts @@ -1,7 +1,10 @@ -import { http } from './http' +import { buildQueryString, http } from './http' +import type { V2Page } from './http' import type { GetProxyResponse, + ProxyListV2Params, ProxyStatsInfo, + ProxyV2Info, TrafficResponse, } from '../types/proxy' @@ -9,6 +12,40 @@ export const getProxiesByType = (type: string) => { return http.get(`../api/proxy/${type}`) } +export const getProxiesV2 = async (params: ProxyListV2Params = {}) => { + const page = await http.getV2>( + `../api/v2/proxies${buildQueryString({ + page: params.page, + pageSize: params.pageSize, + status: + params.status && params.status !== 'all' ? params.status : undefined, + q: params.q || undefined, + type: params.type || undefined, + user: params.user, + clientID: params.clientID || undefined, + })}`, + ) + + return { + ...page, + items: page.items.map(toLegacyProxyStats), + } +} + +const toLegacyProxyStats = (proxy: ProxyV2Info): ProxyStatsInfo => ({ + name: proxy.name, + type: proxy.type, + conf: proxy.spec, + user: proxy.user, + clientID: proxy.clientID, + todayTrafficIn: proxy.status.todayTrafficIn, + todayTrafficOut: proxy.status.todayTrafficOut, + curConns: proxy.status.curConns, + lastStartTime: proxy.status.lastStartTime, + lastCloseTime: proxy.status.lastCloseTime, + status: proxy.status.phase, +}) + export const getProxy = (type: string, name: string) => { return http.get(`../api/proxy/${type}/${name}`) } diff --git a/web/frps/src/types/client.ts b/web/frps/src/types/client.ts index 85413ff0..96700fe9 100644 --- a/web/frps/src/types/client.ts +++ b/web/frps/src/types/client.ts @@ -13,3 +13,13 @@ export interface ClientInfoData { disconnectedAt?: number online: boolean } + +export interface ClientListV2Params { + page?: number + pageSize?: number + status?: 'all' | 'online' | 'offline' + q?: string + user?: string + clientID?: string + runID?: string +} diff --git a/web/frps/src/types/proxy.ts b/web/frps/src/types/proxy.ts index f5bc8e65..53645d48 100644 --- a/web/frps/src/types/proxy.ts +++ b/web/frps/src/types/proxy.ts @@ -1,5 +1,6 @@ export interface ProxyStatsInfo { name: string + type?: string conf: any user: string clientID: string @@ -15,6 +16,34 @@ export interface GetProxyResponse { proxies: ProxyStatsInfo[] } +export interface ProxyListV2Params { + page?: number + pageSize?: number + status?: 'all' | 'online' | 'offline' + q?: string + type?: string + user?: string + clientID?: string +} + +export interface ProxyV2Info { + name: string + type: string + user: string + clientID: string + spec: any + status: ProxyV2Status +} + +export interface ProxyV2Status { + phase: string + todayTrafficIn: number + todayTrafficOut: number + curConns: number + lastStartTime: string + lastCloseTime: string +} + export interface TrafficResponse { name: string trafficIn: number[] diff --git a/web/frps/src/views/Clients.vue b/web/frps/src/views/Clients.vue index 7afaa481..43d9f25b 100644 --- a/web/frps/src/views/Clients.vue +++ b/web/frps/src/views/Clients.vue @@ -16,7 +16,9 @@ > {{ tab.label }} - {{ tab.count }} + {{ + tab.count + }} @@ -33,9 +35,9 @@
-
+
@@ -44,82 +46,123 @@
+ +
+ +
@@ -274,6 +331,11 @@ onUnmounted(() => { padding: 60px 0; } +.pagination-section { + display: flex; + justify-content: flex-end; +} + /* Dark mode adjustments */ html.dark .status-tab { background: var(--el-bg-color-overlay); @@ -298,5 +360,9 @@ html.dark .status-tab.active { .status-tab { flex-shrink: 0; } + + .pagination-section { + justify-content: center; + } } diff --git a/web/frps/src/views/Proxies.vue b/web/frps/src/views/Proxies.vue index b7fdc700..80548bb8 100644 --- a/web/frps/src/views/Proxies.vue +++ b/web/frps/src/views/Proxies.vue @@ -8,7 +8,7 @@
- + Refresh @@ -74,9 +74,9 @@
-
+
+
+ +
+