From 9bde0b07de61c27b627980d47f625ba41d3ceca8 Mon Sep 17 00:00:00 2001 From: fatedier Date: Wed, 3 Jun 2026 14:08:45 +0800 Subject: [PATCH] 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 @@
-
+
+
+ +
+