From 68509f5d445b7819f2fc286c3004b811d35f5e99 Mon Sep 17 00:00:00 2001 From: fatedier Date: Wed, 8 Jul 2026 02:05:28 +0800 Subject: [PATCH] Add frps proxy traffic API v2 (#5398) --- server/api_router.go | 3 +- server/http/controller_v2.go | 71 +++++++++-- server/http/controller_v2_test.go | 177 +++++++++++++++++++++++++++- server/http/model/v2.go | 13 ++ web/frps/src/api/proxy.ts | 4 +- web/frps/src/components/Traffic.vue | 50 +++----- web/frps/src/types/proxy.ts | 11 +- 7 files changed, 280 insertions(+), 49 deletions(-) diff --git a/server/api_router.go b/server/api_router.go index d076f165..59a55016 100644 --- a/server/api_router.go +++ b/server/api_router.go @@ -56,7 +56,8 @@ func (svr *Service) registerRouteHandlers(helper *httppkg.RouterRegisterHelper) 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") + v2EncodedPathRouter.HandleFunc("/api/v2/proxies/{name}/traffic", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ProxyTraffic)).Methods("GET") + v2EncodedPathRouter.HandleFunc("/api/v2/proxies/{name}", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ProxyDetail)).Methods("GET") // view subRouter.Handle("/favicon.ico", http.FileServer(helper.AssetsFS)).Methods("GET") diff --git a/server/http/controller_v2.go b/server/http/controller_v2.go index f1b93364..e4d10ca8 100644 --- a/server/http/controller_v2.go +++ b/server/http/controller_v2.go @@ -23,6 +23,7 @@ import ( "slices" "strconv" "strings" + "time" v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/metrics/mem" @@ -37,6 +38,9 @@ const ( maxV2PageSize = 200 v2SystemPruneTypeOfflineProxies = "offline_proxies" + v2ProxyTrafficDefaultDays = 7 + v2ProxyTrafficUnit = "bytes" + v2ProxyTrafficGranularity = "day" ) var apiV2ProxyTypes = []string{ @@ -190,15 +194,10 @@ func (c *Controller) APIV2ClientList(ctx *httppkg.Context) (any, error) { // /api/v2/clients/{key} func (c *Controller) APIV2ClientDetail(ctx *httppkg.Context) (any, error) { - key := ctx.Param("key") - if key == "" { - return nil, fmt.Errorf("missing client key") - } - decodedKey, err := url.PathUnescape(key) + key, err := decodeV2PathParam(ctx, "key", "client key") if err != nil { - return nil, fmt.Errorf("invalid client key %q: %w", key, err) + return nil, err } - key = decodedKey if c.clientRegistry == nil { return nil, fmt.Errorf("client registry unavailable") @@ -267,9 +266,9 @@ func (c *Controller) APIV2ProxyList(ctx *httppkg.Context) (any, error) { // /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") + name, err := decodeV2PathParam(ctx, "name", "proxy name") + if err != nil { + return nil, err } ps := mem.StatsCollector.GetProxyByName(name) @@ -279,6 +278,33 @@ func (c *Controller) APIV2ProxyDetail(ctx *httppkg.Context) (any, error) { return c.buildV2ProxyResp(ps), nil } +// /api/v2/proxies/{name}/traffic +func (c *Controller) APIV2ProxyTraffic(ctx *httppkg.Context) (any, error) { + name, err := decodeV2PathParam(ctx, "name", "proxy name") + if err != nil { + return nil, err + } + + proxyTrafficInfo := mem.StatsCollector.GetProxyTraffic(name) + if proxyTrafficInfo == nil { + return nil, httppkg.NewError(http.StatusNotFound, "no proxy info found") + } + + return buildV2ProxyTrafficResp(name, proxyTrafficInfo, time.Now()), nil +} + +func decodeV2PathParam(ctx *httppkg.Context, key string, label string) (string, error) { + raw := ctx.Param(key) + if raw == "" { + return "", fmt.Errorf("missing %s", label) + } + decoded, err := url.PathUnescape(raw) + if err != nil { + return "", httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("invalid %s", label)) + } + return decoded, nil +} + func getOrCreateV2User(items map[string]*model.V2UserResp, user string) *model.V2UserResp { item, ok := items[user] if !ok { @@ -455,6 +481,31 @@ func (c *Controller) listV2ProxyStats(proxyType string) []*mem.ProxyStats { return items } +func buildV2ProxyTrafficResp(name string, traffic *mem.ProxyTrafficInfo, now time.Time) model.V2ProxyTrafficResp { + history := make([]model.V2ProxyTrafficPointResp, 0, v2ProxyTrafficDefaultDays) + for age := v2ProxyTrafficDefaultDays - 1; age >= 0; age-- { + history = append(history, model.V2ProxyTrafficPointResp{ + Date: now.AddDate(0, 0, -age).Format(time.DateOnly), + TrafficIn: v2TrafficValueAt(traffic.TrafficIn, age), + TrafficOut: v2TrafficValueAt(traffic.TrafficOut, age), + }) + } + + return model.V2ProxyTrafficResp{ + Name: name, + Unit: v2ProxyTrafficUnit, + Granularity: v2ProxyTrafficGranularity, + History: history, + } +} + +func v2TrafficValueAt(values []int64, todayFirstIndex int) int64 { + if todayFirstIndex >= len(values) { + return 0 + } + return values[todayFirstIndex] +} + func (c *Controller) buildV2ClientStatus(info registry.ClientInfo) model.V2ClientStatusResp { status := model.V2ClientStatusResp{State: "offline"} if info.Online { diff --git a/server/http/controller_v2_test.go b/server/http/controller_v2_test.go index 3265f80c..fe37e616 100644 --- a/server/http/controller_v2_test.go +++ b/server/http/controller_v2_test.go @@ -22,6 +22,7 @@ import ( "net/http/httptest" "net/url" "testing" + "time" "github.com/gorilla/mux" @@ -43,6 +44,7 @@ type v2EnvelopeForTest[T any] struct { type fakeStatsCollector struct { server *mem.ServerStats proxies map[string]*mem.ProxyStats + traffic map[string]*mem.ProxyTrafficInfo pruneable map[string]bool } @@ -76,7 +78,7 @@ func (f *fakeStatsCollector) GetProxyByName(proxyName string) *mem.ProxyStats { } func (f *fakeStatsCollector) GetProxyTraffic(name string) *mem.ProxyTrafficInfo { - return nil + return f.traffic[name] } func (f *fakeStatsCollector) ClearOfflineProxies() (int, int) { @@ -441,6 +443,140 @@ func TestAPIV2ProxyListDetailAndUsers(t *testing.T) { } } +func TestAPIV2ProxyTrafficEnvelopeSchemaAndHistory(t *testing.T) { + oldStatsCollector := mem.StatsCollector + mem.StatsCollector = &fakeStatsCollector{ + proxies: map[string]*mem.ProxyStats{ + "ssh": {Name: "ssh", Type: "tcp"}, + }, + traffic: map[string]*mem.ProxyTrafficInfo{ + "ssh": { + Name: "ssh", + TrafficIn: []int64{70, 60, 50, 40, 30, 20, 10}, + TrafficOut: []int64{700, 600, 500, 400, 300, 200, 100}, + }, + }, + } + t.Cleanup(func() { + mem.StatsCollector = oldStatsCollector + }) + + controller := NewController(&v1.ServerConfig{}, registry.NewClientRegistry(), serverproxy.NewManager()) + router := newV2TestRouter(controller) + + resp := performRequest(router, "/api/v2/proxies/ssh/traffic") + 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, "granularity", "history", "name", "unit") + + trafficResp := decodeResponse[v2EnvelopeForTest[model.V2ProxyTrafficResp]](t, resp) + if trafficResp.Data.Name != "ssh" || trafficResp.Data.Unit != "bytes" || trafficResp.Data.Granularity != "day" { + t.Fatalf("traffic metadata mismatch: %#v", trafficResp.Data) + } + if len(trafficResp.Data.History) != 7 { + t.Fatalf("history length mismatch, want 7 got %d: %#v", len(trafficResp.Data.History), trafficResp.Data.History) + } + + wantIn := []int64{10, 20, 30, 40, 50, 60, 70} + wantOut := []int64{100, 200, 300, 400, 500, 600, 700} + var prevDate time.Time + for i, point := range trafficResp.Data.History { + assertRawJSONKeysFromMessage(t, mustMarshalJSON(t, point), "date", "trafficIn", "trafficOut") + if point.TrafficIn != wantIn[i] || point.TrafficOut != wantOut[i] { + t.Fatalf("history[%d] traffic mismatch: %#v", i, point) + } + parsedDate, err := time.Parse(time.DateOnly, point.Date) + if err != nil { + t.Fatalf("history[%d] date should be yyyy-mm-dd, got %q: %v", i, point.Date, err) + } + if i > 0 && !parsedDate.Equal(prevDate.AddDate(0, 0, 1)) { + t.Fatalf("history dates should be oldest to newest, got %s after %s", point.Date, prevDate.Format(time.DateOnly)) + } + prevDate = parsedDate + } +} + +func TestAPIV2ProxyTrafficNotFoundEnvelope(t *testing.T) { + controller := newV2TestController(t) + router := newV2TestRouter(controller) + + resp := performRequest(router, "/api/v2/proxies/missing/traffic") + if resp.Code != http.StatusNotFound { + t.Fatalf("status mismatch, want %d got %d, body: %s", http.StatusNotFound, resp.Code, resp.Body.String()) + } + errResp := decodeResponse[httppkg.V2Response](t, resp) + if errResp.Code != http.StatusNotFound || errResp.Msg != "no proxy info found" || errResp.Data != nil { + t.Fatalf("not found envelope mismatch: %#v", errResp) + } +} + +func TestAPIV2ProxyDetailAndTrafficEncodedName(t *testing.T) { + name := "folder/ssh?x#y" + oldStatsCollector := mem.StatsCollector + mem.StatsCollector = &fakeStatsCollector{ + proxies: map[string]*mem.ProxyStats{ + name: {Name: name, Type: "tcp", User: "encoded"}, + }, + traffic: map[string]*mem.ProxyTrafficInfo{ + name: { + Name: name, + TrafficIn: []int64{1}, + TrafficOut: []int64{2}, + }, + }, + } + t.Cleanup(func() { + mem.StatsCollector = oldStatsCollector + }) + + controller := NewController(&v1.ServerConfig{}, registry.NewClientRegistry(), serverproxy.NewManager()) + router := newV2TestRouter(controller) + encodedName := url.PathEscape(name) + + resp := performRequest(router, "/api/v2/proxies/"+encodedName) + if resp.Code != http.StatusOK { + t.Fatalf("encoded proxy detail status mismatch, want %d got %d, body: %s", http.StatusOK, resp.Code, resp.Body.String()) + } + detailResp := decodeResponse[v2EnvelopeForTest[model.V2ProxyResp]](t, resp) + if detailResp.Data.Name != name || detailResp.Data.User != "encoded" { + t.Fatalf("encoded proxy detail mismatch: %#v", detailResp.Data) + } + + resp = performRequest(router, "/api/v2/proxies/"+encodedName+"/traffic") + if resp.Code != http.StatusOK { + t.Fatalf("encoded traffic status mismatch, want %d got %d, body: %s", http.StatusOK, resp.Code, resp.Body.String()) + } + trafficResp := decodeResponse[v2EnvelopeForTest[model.V2ProxyTrafficResp]](t, resp) + if trafficResp.Data.Name != name { + t.Fatalf("encoded traffic name mismatch: %#v", trafficResp.Data) + } + if got := trafficResp.Data.History[len(trafficResp.Data.History)-1]; got.TrafficIn != 1 || got.TrafficOut != 2 { + t.Fatalf("encoded traffic latest point mismatch: %#v", got) + } +} + +func TestAPIV2ProxyTrafficInvalidEncodedNameUses400Envelope(t *testing.T) { + controller := newV2TestController(t) + handler := httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ProxyTraffic) + req := httptest.NewRequest(http.MethodGet, "/api/v2/proxies/%25ZZ/traffic", nil) + req = mux.SetURLVars(req, map[string]string{"name": "%ZZ"}) + resp := httptest.NewRecorder() + handler.ServeHTTP(resp, req) + + if resp.Code != http.StatusBadRequest { + t.Fatalf("status mismatch, want %d got %d, body: %s", http.StatusBadRequest, resp.Code, resp.Body.String()) + } + errResp := decodeResponse[httppkg.V2Response](t, resp) + if errResp.Code != http.StatusBadRequest || errResp.Msg != "invalid proxy name" || errResp.Data != nil { + t.Fatalf("invalid encoded name envelope mismatch: %#v", errResp) + } +} + func TestMatchV2ProxyQueryMatchesSpecFields(t *testing.T) { tests := []struct { name string @@ -565,6 +701,24 @@ func TestLegacyAPIResponsesRemainBare(t *testing.T) { 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) } + + resp = performRequest(router, "/api/traffic/tcp-alice") + var traffic model.GetProxyTrafficResp + if err := json.Unmarshal(resp.Body.Bytes(), &traffic); err != nil { + t.Fatalf("legacy traffic should be a bare object: %v, body: %s", err, resp.Body.String()) + } + if traffic.Name != "tcp-alice" || + len(traffic.TrafficIn) != 2 || traffic.TrafficIn[0] != 7 || traffic.TrafficIn[1] != 6 || + len(traffic.TrafficOut) != 2 || traffic.TrafficOut[0] != 70 || traffic.TrafficOut[1] != 60 { + t.Fatalf("legacy traffic should preserve today-first arrays, got: %#v", traffic) + } + var trafficRaw map[string]json.RawMessage + if err := json.Unmarshal(resp.Body.Bytes(), &trafficRaw); err != nil { + t.Fatalf("unmarshal legacy traffic object failed: %v", err) + } + if _, ok := trafficRaw["data"]; ok { + t.Fatalf("legacy traffic should not use v2 envelope: %s", resp.Body.String()) + } } func newV2TestController(t *testing.T) *Controller { @@ -605,6 +759,13 @@ func newV2TestController(t *testing.T) *Controller { ClientID: "client-b", }, }, + traffic: map[string]*mem.ProxyTrafficInfo{ + "tcp-alice": { + Name: "tcp-alice", + TrafficIn: []int64{7, 6}, + TrafficOut: []int64{70, 60}, + }, + }, } t.Cleanup(func() { mem.StatsCollector = oldStatsCollector @@ -629,10 +790,12 @@ func newV2TestRouter(controller *Controller) *mux.Router { 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) + encodedPathRouter.HandleFunc("/api/v2/proxies/{name}/traffic", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ProxyTraffic)).Methods(http.MethodGet) + encodedPathRouter.HandleFunc("/api/v2/proxies/{name}", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ProxyDetail)).Methods(http.MethodGet) router.HandleFunc("/api/serverinfo", httppkg.MakeHTTPHandlerFunc(controller.APIServerInfo)).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) + router.HandleFunc("/api/traffic/{name}", httppkg.MakeHTTPHandlerFunc(controller.APIProxyTraffic)).Methods(http.MethodGet) return router } @@ -679,3 +842,13 @@ func assertRawJSONKeysFromMessage(t *testing.T, raw json.RawMessage, want ...str } assertRawJSONKeys(t, out, want...) } + +func mustMarshalJSON(t *testing.T, value any) json.RawMessage { + t.Helper() + + out, err := json.Marshal(value) + if err != nil { + t.Fatalf("marshal json failed: %v", err) + } + return out +} diff --git a/server/http/model/v2.go b/server/http/model/v2.go index d3e58969..74883370 100644 --- a/server/http/model/v2.go +++ b/server/http/model/v2.go @@ -90,3 +90,16 @@ type V2ProxyStatusResp struct { LastStartTime string `json:"lastStartTime"` LastCloseTime string `json:"lastCloseTime"` } + +type V2ProxyTrafficResp struct { + Name string `json:"name"` + Unit string `json:"unit"` + Granularity string `json:"granularity"` + History []V2ProxyTrafficPointResp `json:"history"` +} + +type V2ProxyTrafficPointResp struct { + Date string `json:"date"` + TrafficIn int64 `json:"trafficIn"` + TrafficOut int64 `json:"trafficOut"` +} diff --git a/web/frps/src/api/proxy.ts b/web/frps/src/api/proxy.ts index 75bbcd1f..891578e6 100644 --- a/web/frps/src/api/proxy.ts +++ b/web/frps/src/api/proxy.ts @@ -68,7 +68,9 @@ export const getProxyByName = (name: string) => { } export const getProxyTraffic = (name: string) => { - return http.get(`../api/traffic/${name}`) + return http.getV2( + `../api/v2/proxies/${encodeURIComponent(name)}/traffic`, + ) } export const clearOfflineProxies = () => { diff --git a/web/frps/src/components/Traffic.vue b/web/frps/src/components/Traffic.vue index 9142f3e0..9f4d422c 100644 --- a/web/frps/src/components/Traffic.vue +++ b/web/frps/src/components/Traffic.vue @@ -54,6 +54,7 @@ import { ref, onMounted } from 'vue' import { ElMessage } from 'element-plus' import { formatFileSize } from '../utils/format' import { getProxyTraffic } from '../api/proxy' +import type { TrafficResponse } from '../types/proxy' const props = defineProps<{ proxyName: string @@ -71,41 +72,24 @@ const chartData = ref< >([]) const maxVal = ref(0) -const processData = (trafficIn: number[], trafficOut: number[]) => { - // Ensure we have arrays and reverse them (server returns newest first) - const inArr = [...(trafficIn || [])].reverse() - const outArr = [...(trafficOut || [])].reverse() +const formatDateLabel = (date: string) => { + const parts = date.split('-') + if (parts.length !== 3) return date + return `${Number(parts[1])}-${Number(parts[2])}` +} - // Pad with zeros if less than 7 days - while (inArr.length < 7) inArr.unshift(0) - while (outArr.length < 7) outArr.unshift(0) - - // Slice to last 7 entries just in case - const finalIn = inArr.slice(-7) - const finalOut = outArr.slice(-7) - - // Calculate dates (last 7 days ending today) - const dates: string[] = [] - const d = new Date() - d.setDate(d.getDate() - 6) - - for (let i = 0; i < 7; i++) { - dates.push(`${d.getMonth() + 1}-${d.getDate()}`) - d.setDate(d.getDate() + 1) - } - - // Find max value for scaling - const maxIn = Math.max(...finalIn) - const maxOut = Math.max(...finalOut) +const processData = (history: TrafficResponse['history'] = []) => { + const points = history || [] + const maxIn = Math.max(0, ...points.map((item) => item.trafficIn)) + const maxOut = Math.max(0, ...points.map((item) => item.trafficOut)) maxVal.value = Math.max(maxIn, maxOut, 100) // Minimum scale 100 bytes - // Build chart data - chartData.value = dates.map((date, i) => ({ - date, - in: finalIn[i], - out: finalOut[i], - inPercent: (finalIn[i] / maxVal.value) * 100, - outPercent: (finalOut[i] / maxVal.value) * 100, + chartData.value = points.map((item) => ({ + date: formatDateLabel(item.date), + in: item.trafficIn, + out: item.trafficOut, + inPercent: (item.trafficIn / maxVal.value) * 100, + outPercent: (item.trafficOut / maxVal.value) * 100, })) } @@ -113,7 +97,7 @@ const fetchData = () => { loading.value = true getProxyTraffic(props.proxyName) .then((json) => { - processData(json.trafficIn, json.trafficOut) + processData(json.history) }) .catch((err) => { ElMessage({ diff --git a/web/frps/src/types/proxy.ts b/web/frps/src/types/proxy.ts index d509907f..c1d10f06 100644 --- a/web/frps/src/types/proxy.ts +++ b/web/frps/src/types/proxy.ts @@ -47,6 +47,13 @@ export interface ProxyV2Status { export interface TrafficResponse { name: string - trafficIn: number[] - trafficOut: number[] + unit: 'bytes' + granularity: 'day' + history: TrafficPoint[] +} + +export interface TrafficPoint { + date: string + trafficIn: number + trafficOut: number }