mirror of
https://github.com/fatedier/frp.git
synced 2026-07-16 09:19:17 +08:00
feat: add system prune API v2 (#5395)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -85,4 +85,5 @@ type Collector interface {
|
||||
GetProxyByName(proxyName string) *ProxyStats
|
||||
GetProxyTraffic(name string) *ProxyTrafficInfo
|
||||
ClearOfflineProxies() (int, int)
|
||||
PruneOfflineProxies() (int, int)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -98,6 +98,13 @@ export const http = {
|
||||
request<T>(url, { ...options, method: 'GET' }),
|
||||
getV2: <T>(url: string, options?: RequestInit) =>
|
||||
requestV2<T>(url, { ...options, method: 'GET' }),
|
||||
postV2: <T>(url: string, body?: any, options?: RequestInit) =>
|
||||
requestV2<T>(url, {
|
||||
...options,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...options?.headers },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
post: <T>(url: string, body?: any, options?: RequestInit) =>
|
||||
request<T>(url, {
|
||||
...options,
|
||||
|
||||
@@ -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<GetProxyResponse>(`../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<SystemPruneResponse>(
|
||||
'../api/v2/system/prune?type=offline_proxies',
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user