From 5cd722b177dabc57148cbb25712ba2fc9c6e1197 Mon Sep 17 00:00:00 2001 From: fatedier Date: Tue, 7 Jul 2026 13:00:53 +0800 Subject: [PATCH] feat: add system prune API v2 (#5395) --- pkg/metrics/mem/server.go | 14 +++- pkg/metrics/mem/server_test.go | 64 ++++++++++++++++++ pkg/metrics/mem/types.go | 1 + server/api_router.go | 1 + server/http/controller_v2.go | 29 ++++++++ server/http/controller_v2_test.go | 107 +++++++++++++++++++++++++++++- server/http/model/v2.go | 6 ++ web/frps/src/api/http.ts | 7 ++ web/frps/src/api/proxy.ts | 10 ++- 9 files changed, 232 insertions(+), 7 deletions(-) diff --git a/pkg/metrics/mem/server.go b/pkg/metrics/mem/server.go index a3c2bb65..86ca0ad3 100644 --- a/pkg/metrics/mem/server.go +++ b/pkg/metrics/mem/server.go @@ -95,9 +95,7 @@ func (m *serverMetrics) clearUselessInfo(continuousOfflineDuration time.Duration defer m.mu.Unlock() total = len(m.info.ProxyStatistics) for name, data := range m.info.ProxyStatistics { - if !data.LastCloseTime.IsZero() && - data.LastStartTime.Before(data.LastCloseTime) && - m.clock.Since(data.LastCloseTime) > continuousOfflineDuration { + if m.shouldClearProxyStats(data, continuousOfflineDuration) { delete(m.info.ProxyStatistics, name) count++ log.Tracef("clear proxy [%s]'s statistics data, lastCloseTime: [%s]", name, data.LastCloseTime.String()) @@ -106,10 +104,20 @@ func (m *serverMetrics) clearUselessInfo(continuousOfflineDuration time.Duration return count, total } +func (m *serverMetrics) shouldClearProxyStats(data *ProxyStatistics, continuousOfflineDuration time.Duration) bool { + return !data.LastCloseTime.IsZero() && + data.LastStartTime.Before(data.LastCloseTime) && + m.clock.Since(data.LastCloseTime) > continuousOfflineDuration +} + func (m *serverMetrics) ClearOfflineProxies() (int, int) { return m.clearUselessInfo(0) } +func (m *serverMetrics) PruneOfflineProxies() (int, int) { + return m.clearUselessInfo(0) +} + func (m *serverMetrics) NewClient() { m.info.ClientCounts.Inc(1) } diff --git a/pkg/metrics/mem/server_test.go b/pkg/metrics/mem/server_test.go index fe9f9984..ee82b68a 100644 --- a/pkg/metrics/mem/server_test.go +++ b/pkg/metrics/mem/server_test.go @@ -43,6 +43,70 @@ func TestServerMetricsClearUselessInfoUsesClock(t *testing.T) { require.Empty(metrics.info.ProxyStatistics) } +func TestServerMetricsClearOfflineProxiesPreservesLegacyTotal(t *testing.T) { + require := require.New(t) + + start := time.Date(2026, time.May, 8, 12, 30, 0, 0, time.UTC) + clk := clocktesting.NewFakeClock(start.Add(time.Minute)) + metrics := newServerMetricsWithClock(clk) + metrics.info.ProxyStatistics["offline"] = &ProxyStatistics{ + Name: "offline", + LastStartTime: start.Add(-time.Hour), + LastCloseTime: start, + } + metrics.info.ProxyStatistics["online"] = &ProxyStatistics{ + Name: "online", + LastStartTime: start, + } + + cleared, total := metrics.ClearOfflineProxies() + + require.Equal(1, cleared) + require.Equal(2, total) + require.False(metrics.hasProxyStatistics("offline")) + require.True(metrics.hasProxyStatistics("online")) +} + +func TestServerMetricsPruneOfflineProxiesReportsTotalStats(t *testing.T) { + require := require.New(t) + + start := time.Date(2026, time.May, 8, 12, 30, 0, 0, time.UTC) + clk := clocktesting.NewFakeClock(start.Add(time.Minute)) + metrics := newServerMetricsWithClock(clk) + metrics.info.ProxyStatistics["offline"] = &ProxyStatistics{ + Name: "offline", + LastStartTime: start.Add(-time.Hour), + LastCloseTime: start, + } + metrics.info.ProxyStatistics["online"] = &ProxyStatistics{ + Name: "online", + LastStartTime: start, + } + metrics.info.ProxyStatistics["restarted"] = &ProxyStatistics{ + Name: "restarted", + LastStartTime: start.Add(30 * time.Second), + LastCloseTime: start, + } + metrics.info.ProxyStatistics["same-time"] = &ProxyStatistics{ + Name: "same-time", + LastStartTime: start, + LastCloseTime: start, + } + + cleared, total := metrics.PruneOfflineProxies() + + require.Equal(1, cleared) + require.Equal(4, total) + require.False(metrics.hasProxyStatistics("offline")) + require.True(metrics.hasProxyStatistics("online")) + require.True(metrics.hasProxyStatistics("restarted")) + require.True(metrics.hasProxyStatistics("same-time")) + + cleared, total = metrics.PruneOfflineProxies() + require.Equal(0, cleared) + require.Equal(3, total) +} + func TestServerMetricsRunUsesClockTicker(t *testing.T) { require := require.New(t) diff --git a/pkg/metrics/mem/types.go b/pkg/metrics/mem/types.go index b7661ba8..b566f4f2 100644 --- a/pkg/metrics/mem/types.go +++ b/pkg/metrics/mem/types.go @@ -85,4 +85,5 @@ type Collector interface { GetProxyByName(proxyName string) *ProxyStats GetProxyTraffic(name string) *ProxyTrafficInfo ClearOfflineProxies() (int, int) + PruneOfflineProxies() (int, int) } diff --git a/server/api_router.go b/server/api_router.go index 22bf81ea..d076f165 100644 --- a/server/api_router.go +++ b/server/api_router.go @@ -50,6 +50,7 @@ func (svr *Service) registerRouteHandlers(helper *httppkg.RouterRegisterHelper) subRouter.HandleFunc("/api/v2/users", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2UserList)).Methods("GET") subRouter.HandleFunc("/api/v2/system/info", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2SystemInfo)).Methods("GET") + subRouter.HandleFunc("/api/v2/system/prune", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2SystemPrune)).Methods("POST") subRouter.HandleFunc("/api/v2/clients", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ClientList)).Methods("GET") v2EncodedPathRouter := subRouter.NewRoute().Subrouter() v2EncodedPathRouter.UseEncodedPath() diff --git a/server/http/controller_v2.go b/server/http/controller_v2.go index e18154c6..f1b93364 100644 --- a/server/http/controller_v2.go +++ b/server/http/controller_v2.go @@ -35,6 +35,8 @@ const ( defaultV2Page = 1 defaultV2PageSize = 50 maxV2PageSize = 200 + + v2SystemPruneTypeOfflineProxies = "offline_proxies" ) var apiV2ProxyTypes = []string{ @@ -82,6 +84,21 @@ func (c *Controller) APIV2SystemInfo(ctx *httppkg.Context) (any, error) { }, nil } +// /api/v2/system/prune +func (c *Controller) APIV2SystemPrune(ctx *httppkg.Context) (any, error) { + pruneType, err := parseV2SystemPruneType(ctx.Query("type")) + if err != nil { + return nil, err + } + + cleared, total := mem.StatsCollector.PruneOfflineProxies() + return model.V2SystemPruneResp{ + Type: pruneType, + Cleared: cleared, + Total: total, + }, nil +} + // /api/v2/users func (c *Controller) APIV2UserList(ctx *httppkg.Context) (any, error) { page, pageSize, err := parseV2PageParams(ctx) @@ -321,6 +338,18 @@ func parseV2ProxyTypeFilter(raw string) (string, error) { return "", httppkg.NewError(http.StatusBadRequest, "type must be one of tcp, udp, http, https, tcpmux, stcp, xtcp, sudp") } +func parseV2SystemPruneType(raw string) (string, error) { + pruneType := strings.ToLower(raw) + switch pruneType { + case "": + return "", httppkg.NewError(http.StatusBadRequest, "type is required") + case v2SystemPruneTypeOfflineProxies: + return pruneType, nil + default: + return "", httppkg.NewError(http.StatusBadRequest, "type must be one of offline_proxies") + } +} + func matchV2StatusFilter(online bool, filter string) bool { switch filter { case "", "all": diff --git a/server/http/controller_v2_test.go b/server/http/controller_v2_test.go index e28cc8dc..3265f80c 100644 --- a/server/http/controller_v2_test.go +++ b/server/http/controller_v2_test.go @@ -41,8 +41,9 @@ type v2EnvelopeForTest[T any] struct { } type fakeStatsCollector struct { - server *mem.ServerStats - proxies map[string]*mem.ProxyStats + server *mem.ServerStats + proxies map[string]*mem.ProxyStats + pruneable map[string]bool } func (f *fakeStatsCollector) GetServer() *mem.ServerStats { @@ -82,6 +83,19 @@ func (f *fakeStatsCollector) ClearOfflineProxies() (int, int) { return 0, len(f.proxies) } +func (f *fakeStatsCollector) PruneOfflineProxies() (int, int) { + total := len(f.proxies) + cleared := 0 + for name := range f.pruneable { + if _, ok := f.proxies[name]; ok { + delete(f.proxies, name) + cleared++ + } + } + f.pruneable = map[string]bool{} + return cleared, total +} + func TestAPIV2SystemInfoEnvelope(t *testing.T) { oldStatsCollector := mem.StatsCollector mem.StatsCollector = &fakeStatsCollector{ @@ -184,6 +198,88 @@ func TestAPIV2SystemInfoEnvelope(t *testing.T) { } } +func TestAPIV2SystemPruneOfflineProxies(t *testing.T) { + oldStatsCollector := mem.StatsCollector + collector := &fakeStatsCollector{ + proxies: map[string]*mem.ProxyStats{ + "tcp-offline": {Name: "tcp-offline", Type: "tcp"}, + "http-offline": {Name: "http-offline", Type: "http"}, + "udp-offline": {Name: "udp-offline", Type: "udp"}, + "tcp-online": {Name: "tcp-online", Type: "tcp"}, + "http-online": {Name: "http-online", Type: "http"}, + "udp-online": {Name: "udp-online", Type: "udp"}, + "stcp-restarted": {Name: "stcp-restarted", Type: "stcp"}, + "xtcp-restarted": {Name: "xtcp-restarted", Type: "xtcp"}, + "sudp-same-time": {Name: "sudp-same-time", Type: "sudp"}, + "tcpmux-running": {Name: "tcpmux-running", Type: "tcpmux"}, + }, + pruneable: map[string]bool{ + "tcp-offline": true, + "http-offline": true, + "udp-offline": true, + }, + } + mem.StatsCollector = collector + t.Cleanup(func() { + mem.StatsCollector = oldStatsCollector + }) + + controller := NewController(&v1.ServerConfig{}, registry.NewClientRegistry(), serverproxy.NewManager()) + router := newV2TestRouter(controller) + + resp := performRequestWithMethod(router, http.MethodPost, "/api/v2/system/prune?type=offline_proxies") + if resp.Code != http.StatusOK { + t.Fatalf("status mismatch, want %d got %d, body: %s", http.StatusOK, resp.Code, resp.Body.String()) + } + rawResp := decodeResponse[v2EnvelopeForTest[map[string]json.RawMessage]](t, resp) + if rawResp.Code != http.StatusOK || rawResp.Msg != "success" { + t.Fatalf("envelope mismatch: %#v", rawResp) + } + assertRawJSONKeys(t, rawResp.Data, "cleared", "total", "type") + pruneResp := decodeResponse[v2EnvelopeForTest[model.V2SystemPruneResp]](t, resp) + if pruneResp.Data.Type != "offline_proxies" || pruneResp.Data.Cleared != 3 || pruneResp.Data.Total != 10 { + t.Fatalf("prune response mismatch: %#v", pruneResp.Data) + } + if _, ok := collector.proxies["tcp-offline"]; ok { + t.Fatal("pruned proxy statistics should be removed") + } + if _, ok := collector.proxies["tcp-online"]; !ok { + t.Fatal("online proxy statistics should remain") + } + + resp = performRequestWithMethod(router, http.MethodPost, "/api/v2/system/prune?type=offline_proxies") + if resp.Code != http.StatusOK { + t.Fatalf("second prune status mismatch, want %d got %d", http.StatusOK, resp.Code) + } + pruneResp = decodeResponse[v2EnvelopeForTest[model.V2SystemPruneResp]](t, resp) + if pruneResp.Data.Cleared != 0 || pruneResp.Data.Total != 7 { + t.Fatalf("second prune response mismatch: %#v", pruneResp.Data) + } +} + +func TestAPIV2SystemPruneTypeErrorsUseEnvelope(t *testing.T) { + controller := newV2TestController(t) + router := newV2TestRouter(controller) + + resp := performRequestWithMethod(router, http.MethodPost, "/api/v2/system/prune") + if resp.Code != http.StatusBadRequest { + t.Fatalf("missing type status mismatch, want %d got %d", http.StatusBadRequest, resp.Code) + } + errResp := decodeResponse[httppkg.V2Response](t, resp) + if errResp.Code != http.StatusBadRequest || errResp.Msg != "type is required" || errResp.Data != nil { + t.Fatalf("missing type error envelope mismatch: %#v", errResp) + } + + resp = performRequestWithMethod(router, http.MethodPost, "/api/v2/system/prune?type=clients") + if resp.Code != http.StatusBadRequest { + t.Fatalf("invalid type status mismatch, want %d got %d", http.StatusBadRequest, resp.Code) + } + errResp = decodeResponse[httppkg.V2Response](t, resp) + if errResp.Code != http.StatusBadRequest || errResp.Msg != "type must be one of offline_proxies" || errResp.Data != nil { + t.Fatalf("invalid type error envelope mismatch: %#v", errResp) + } +} + func TestAPIV2ClientListEnvelopePaginationAndFilters(t *testing.T) { controller := newV2TestController(t) router := newV2TestRouter(controller) @@ -527,6 +623,7 @@ 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/system/info", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2SystemInfo)).Methods(http.MethodGet) + router.HandleFunc("/api/v2/system/prune", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2SystemPrune)).Methods(http.MethodPost) router.HandleFunc("/api/v2/clients", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ClientList)).Methods(http.MethodGet) encodedPathRouter := router.NewRoute().Subrouter() encodedPathRouter.UseEncodedPath() @@ -540,7 +637,11 @@ func newV2TestRouter(controller *Controller) *mux.Router { } func performRequest(handler http.Handler, target string) *httptest.ResponseRecorder { - req := httptest.NewRequest(http.MethodGet, target, nil) + return performRequestWithMethod(handler, http.MethodGet, target) +} + +func performRequestWithMethod(handler http.Handler, method, target string) *httptest.ResponseRecorder { + req := httptest.NewRequest(method, target, nil) resp := httptest.NewRecorder() handler.ServeHTTP(resp, req) return resp diff --git a/server/http/model/v2.go b/server/http/model/v2.go index 51ba8e2c..d3e58969 100644 --- a/server/http/model/v2.go +++ b/server/http/model/v2.go @@ -50,6 +50,12 @@ type V2SystemInfoStatusResp struct { ProxyTypeCounts map[string]int64 `json:"proxyTypeCount"` } +type V2SystemPruneResp struct { + Type string `json:"type"` + Cleared int `json:"cleared"` + Total int `json:"total"` +} + type V2UserResp struct { User string `json:"user"` ClientCount int `json:"clientCount"` diff --git a/web/frps/src/api/http.ts b/web/frps/src/api/http.ts index 44100b07..2f59c1b7 100644 --- a/web/frps/src/api/http.ts +++ b/web/frps/src/api/http.ts @@ -98,6 +98,13 @@ export const http = { request(url, { ...options, method: 'GET' }), getV2: (url: string, options?: RequestInit) => requestV2(url, { ...options, method: 'GET' }), + postV2: (url: string, body?: any, options?: RequestInit) => + requestV2(url, { + ...options, + method: 'POST', + headers: { 'Content-Type': 'application/json', ...options?.headers }, + body: JSON.stringify(body), + }), 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 7e104136..75bbcd1f 100644 --- a/web/frps/src/api/proxy.ts +++ b/web/frps/src/api/proxy.ts @@ -8,6 +8,12 @@ import type { TrafficResponse, } from '../types/proxy' +export interface SystemPruneResponse { + type: 'offline_proxies' + cleared: number + total: number +} + export const getProxiesByType = (type: string) => { return http.get(`../api/proxy/${type}`) } @@ -66,5 +72,7 @@ export const getProxyTraffic = (name: string) => { } export const clearOfflineProxies = () => { - return http.delete('../api/proxies?status=offline') + return http.postV2( + '../api/v2/system/prune?type=offline_proxies', + ) }