feat: add system prune API v2 (#5395)

This commit is contained in:
fatedier
2026-07-07 13:00:53 +08:00
committed by GitHub
parent 5876beceac
commit 5cd722b177
9 changed files with 232 additions and 7 deletions

View File

@@ -95,9 +95,7 @@ func (m *serverMetrics) clearUselessInfo(continuousOfflineDuration time.Duration
defer m.mu.Unlock() defer m.mu.Unlock()
total = len(m.info.ProxyStatistics) total = len(m.info.ProxyStatistics)
for name, data := range m.info.ProxyStatistics { for name, data := range m.info.ProxyStatistics {
if !data.LastCloseTime.IsZero() && if m.shouldClearProxyStats(data, continuousOfflineDuration) {
data.LastStartTime.Before(data.LastCloseTime) &&
m.clock.Since(data.LastCloseTime) > continuousOfflineDuration {
delete(m.info.ProxyStatistics, name) delete(m.info.ProxyStatistics, name)
count++ count++
log.Tracef("clear proxy [%s]'s statistics data, lastCloseTime: [%s]", name, data.LastCloseTime.String()) 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 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) { func (m *serverMetrics) ClearOfflineProxies() (int, int) {
return m.clearUselessInfo(0) return m.clearUselessInfo(0)
} }
func (m *serverMetrics) PruneOfflineProxies() (int, int) {
return m.clearUselessInfo(0)
}
func (m *serverMetrics) NewClient() { func (m *serverMetrics) NewClient() {
m.info.ClientCounts.Inc(1) m.info.ClientCounts.Inc(1)
} }

View File

@@ -43,6 +43,70 @@ func TestServerMetricsClearUselessInfoUsesClock(t *testing.T) {
require.Empty(metrics.info.ProxyStatistics) 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) { func TestServerMetricsRunUsesClockTicker(t *testing.T) {
require := require.New(t) require := require.New(t)

View File

@@ -85,4 +85,5 @@ type Collector interface {
GetProxyByName(proxyName string) *ProxyStats GetProxyByName(proxyName string) *ProxyStats
GetProxyTraffic(name string) *ProxyTrafficInfo GetProxyTraffic(name string) *ProxyTrafficInfo
ClearOfflineProxies() (int, int) ClearOfflineProxies() (int, int)
PruneOfflineProxies() (int, int)
} }

View File

@@ -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/users", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2UserList)).Methods("GET")
subRouter.HandleFunc("/api/v2/system/info", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2SystemInfo)).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") subRouter.HandleFunc("/api/v2/clients", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ClientList)).Methods("GET")
v2EncodedPathRouter := subRouter.NewRoute().Subrouter() v2EncodedPathRouter := subRouter.NewRoute().Subrouter()
v2EncodedPathRouter.UseEncodedPath() v2EncodedPathRouter.UseEncodedPath()

View File

@@ -35,6 +35,8 @@ const (
defaultV2Page = 1 defaultV2Page = 1
defaultV2PageSize = 50 defaultV2PageSize = 50
maxV2PageSize = 200 maxV2PageSize = 200
v2SystemPruneTypeOfflineProxies = "offline_proxies"
) )
var apiV2ProxyTypes = []string{ var apiV2ProxyTypes = []string{
@@ -82,6 +84,21 @@ func (c *Controller) APIV2SystemInfo(ctx *httppkg.Context) (any, error) {
}, nil }, 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 // /api/v2/users
func (c *Controller) APIV2UserList(ctx *httppkg.Context) (any, error) { func (c *Controller) APIV2UserList(ctx *httppkg.Context) (any, error) {
page, pageSize, err := parseV2PageParams(ctx) 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") 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 { func matchV2StatusFilter(online bool, filter string) bool {
switch filter { switch filter {
case "", "all": case "", "all":

View File

@@ -41,8 +41,9 @@ type v2EnvelopeForTest[T any] struct {
} }
type fakeStatsCollector struct { type fakeStatsCollector struct {
server *mem.ServerStats server *mem.ServerStats
proxies map[string]*mem.ProxyStats proxies map[string]*mem.ProxyStats
pruneable map[string]bool
} }
func (f *fakeStatsCollector) GetServer() *mem.ServerStats { func (f *fakeStatsCollector) GetServer() *mem.ServerStats {
@@ -82,6 +83,19 @@ func (f *fakeStatsCollector) ClearOfflineProxies() (int, int) {
return 0, len(f.proxies) 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) { func TestAPIV2SystemInfoEnvelope(t *testing.T) {
oldStatsCollector := mem.StatsCollector oldStatsCollector := mem.StatsCollector
mem.StatsCollector = &fakeStatsCollector{ 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) { func TestAPIV2ClientListEnvelopePaginationAndFilters(t *testing.T) {
controller := newV2TestController(t) controller := newV2TestController(t)
router := newV2TestRouter(controller) router := newV2TestRouter(controller)
@@ -527,6 +623,7 @@ func newV2TestRouter(controller *Controller) *mux.Router {
router := mux.NewRouter() router := mux.NewRouter()
router.HandleFunc("/api/v2/users", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2UserList)).Methods(http.MethodGet) 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/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) router.HandleFunc("/api/v2/clients", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ClientList)).Methods(http.MethodGet)
encodedPathRouter := router.NewRoute().Subrouter() encodedPathRouter := router.NewRoute().Subrouter()
encodedPathRouter.UseEncodedPath() encodedPathRouter.UseEncodedPath()
@@ -540,7 +637,11 @@ func newV2TestRouter(controller *Controller) *mux.Router {
} }
func performRequest(handler http.Handler, target string) *httptest.ResponseRecorder { 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() resp := httptest.NewRecorder()
handler.ServeHTTP(resp, req) handler.ServeHTTP(resp, req)
return resp return resp

View File

@@ -50,6 +50,12 @@ type V2SystemInfoStatusResp struct {
ProxyTypeCounts map[string]int64 `json:"proxyTypeCount"` ProxyTypeCounts map[string]int64 `json:"proxyTypeCount"`
} }
type V2SystemPruneResp struct {
Type string `json:"type"`
Cleared int `json:"cleared"`
Total int `json:"total"`
}
type V2UserResp struct { type V2UserResp struct {
User string `json:"user"` User string `json:"user"`
ClientCount int `json:"clientCount"` ClientCount int `json:"clientCount"`

View File

@@ -98,6 +98,13 @@ export const http = {
request<T>(url, { ...options, method: 'GET' }), request<T>(url, { ...options, method: 'GET' }),
getV2: <T>(url: string, options?: RequestInit) => getV2: <T>(url: string, options?: RequestInit) =>
requestV2<T>(url, { ...options, method: 'GET' }), 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) => post: <T>(url: string, body?: any, options?: RequestInit) =>
request<T>(url, { request<T>(url, {
...options, ...options,

View File

@@ -8,6 +8,12 @@ import type {
TrafficResponse, TrafficResponse,
} from '../types/proxy' } from '../types/proxy'
export interface SystemPruneResponse {
type: 'offline_proxies'
cleared: number
total: number
}
export const getProxiesByType = (type: string) => { export const getProxiesByType = (type: string) => {
return http.get<GetProxyResponse>(`../api/proxy/${type}`) return http.get<GetProxyResponse>(`../api/proxy/${type}`)
} }
@@ -66,5 +72,7 @@ export const getProxyTraffic = (name: string) => {
} }
export const clearOfflineProxies = () => { export const clearOfflineProxies = () => {
return http.delete('../api/proxies?status=offline') return http.postV2<SystemPruneResponse>(
'../api/v2/system/prune?type=offline_proxies',
)
} }