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
This commit is contained in:
fatedier
2026-06-03 14:08:45 +08:00
committed by GitHub
parent c6c545289c
commit 9bde0b07de
9 changed files with 536 additions and 175 deletions

View File

@@ -318,13 +318,31 @@ func matchV2ClientQuery(item model.ClientInfoResp, q string) bool {
} }
func matchV2ProxyQuery(item model.V2ProxyResp, q string) bool { func matchV2ProxyQuery(item model.V2ProxyResp, q string) bool {
return containsV2Query(q, values := []string{
item.Name, item.Name,
item.Type, item.Type,
item.User, item.User,
item.ClientID, item.ClientID,
item.Status.State, 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 { func containsV2Query(q string, values ...string) bool {

View File

@@ -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) { func TestLegacyAPIResponsesRemainBare(t *testing.T) {
controller := newV2TestController(t) controller := newV2TestController(t)
router := newV2TestRouter(controller) router := newV2TestRouter(controller)

View File

@@ -1,10 +1,26 @@
import { http } from './http' import { buildQueryString, http } from './http'
import type { ClientInfoData } from '../types/client' import type { V2Page } from './http'
import type { ClientInfoData, ClientListV2Params } from '../types/client'
export const getClients = () => { export const getClients = () => {
return http.get<ClientInfoData[]>('../api/clients') return http.get<ClientInfoData[]>('../api/clients')
} }
export const getClientsV2 = (params: ClientListV2Params = {}) => {
return http.getV2<V2Page<ClientInfoData>>(
`../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) => { export const getClient = (key: string) => {
return http.get<ClientInfoData>(`../api/clients/${key}`) return http.get<ClientInfoData>(`../api/clients/${key}`)
} }

View File

@@ -11,6 +11,21 @@ class HTTPError extends Error {
} }
} }
export interface V2Envelope<T> {
code: number
msg: string
data: T
}
export interface V2Page<T> {
total: number
page: number
pageSize: number
items: T[]
}
type QueryParamValue = string | number | boolean | null | undefined
async function request<T>(url: string, options: RequestInit = {}): Promise<T> { async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
const defaultOptions: RequestInit = { const defaultOptions: RequestInit = {
credentials: 'include', credentials: 'include',
@@ -34,9 +49,55 @@ async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
return response.json() return response.json()
} }
async function requestV2<T>(
url: string,
options: RequestInit = {},
): Promise<T> {
const defaultOptions: RequestInit = {
credentials: 'include',
}
const response = await fetch(url, { ...defaultOptions, ...options })
const envelope = (await response.json().catch(() => null)) as
| V2Envelope<T>
| 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, QueryParamValue>,
): 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 = { export const http = {
get: <T>(url: string, options?: RequestInit) => get: <T>(url: string, options?: RequestInit) =>
request<T>(url, { ...options, method: 'GET' }), request<T>(url, { ...options, method: 'GET' }),
getV2: <T>(url: string, options?: RequestInit) =>
requestV2<T>(url, { ...options, method: 'GET' }),
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

@@ -1,7 +1,10 @@
import { http } from './http' import { buildQueryString, http } from './http'
import type { V2Page } from './http'
import type { import type {
GetProxyResponse, GetProxyResponse,
ProxyListV2Params,
ProxyStatsInfo, ProxyStatsInfo,
ProxyV2Info,
TrafficResponse, TrafficResponse,
} from '../types/proxy' } from '../types/proxy'
@@ -9,6 +12,40 @@ export const getProxiesByType = (type: string) => {
return http.get<GetProxyResponse>(`../api/proxy/${type}`) return http.get<GetProxyResponse>(`../api/proxy/${type}`)
} }
export const getProxiesV2 = async (params: ProxyListV2Params = {}) => {
const page = await http.getV2<V2Page<ProxyV2Info>>(
`../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) => { export const getProxy = (type: string, name: string) => {
return http.get<ProxyStatsInfo>(`../api/proxy/${type}/${name}`) return http.get<ProxyStatsInfo>(`../api/proxy/${type}/${name}`)
} }

View File

@@ -13,3 +13,13 @@ export interface ClientInfoData {
disconnectedAt?: number disconnectedAt?: number
online: boolean online: boolean
} }
export interface ClientListV2Params {
page?: number
pageSize?: number
status?: 'all' | 'online' | 'offline'
q?: string
user?: string
clientID?: string
runID?: string
}

View File

@@ -1,5 +1,6 @@
export interface ProxyStatsInfo { export interface ProxyStatsInfo {
name: string name: string
type?: string
conf: any conf: any
user: string user: string
clientID: string clientID: string
@@ -15,6 +16,34 @@ export interface GetProxyResponse {
proxies: ProxyStatsInfo[] 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 { export interface TrafficResponse {
name: string name: string
trafficIn: number[] trafficIn: number[]

View File

@@ -16,7 +16,9 @@
> >
<span class="status-dot" :class="tab.value"></span> <span class="status-dot" :class="tab.value"></span>
<span class="tab-label">{{ tab.label }}</span> <span class="tab-label">{{ tab.label }}</span>
<span class="tab-count">{{ tab.count }}</span> <span v-if="tab.count !== null" class="tab-count">{{
tab.count
}}</span>
</button> </button>
</div> </div>
</div> </div>
@@ -33,9 +35,9 @@
</div> </div>
<div v-loading="loading" class="clients-content"> <div v-loading="loading" class="clients-content">
<div v-if="filteredClients.length > 0" class="clients-list"> <div v-if="clients.length > 0" class="clients-list">
<ClientCard <ClientCard
v-for="client in filteredClients" v-for="client in clients"
:key="client.key" :key="client.key"
:client="client" :client="client"
/> />
@@ -44,82 +46,123 @@
<el-empty description="No clients found" /> <el-empty description="No clients found" />
</div> </div>
</div> </div>
<div v-if="total > 0" class="pagination-section">
<ElPagination
:current-page="page"
:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next"
@current-change="onPageChange"
@size-change="onPageSizeChange"
/>
</div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue' import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage, ElPagination } from 'element-plus'
import { Search } from '@element-plus/icons-vue' import { Search } from '@element-plus/icons-vue'
import { Client } from '../utils/client' import { Client } from '../utils/client'
import ClientCard from '../components/ClientCard.vue' import ClientCard from '../components/ClientCard.vue'
import { getClients } from '../api/client' import { getClientsV2 } from '../api/client'
const clients = ref<Client[]>([]) const clients = ref<Client[]>([])
const loading = ref(false) const loading = ref(false)
const searchText = ref('') const searchText = ref('')
const statusFilter = ref<'all' | 'online' | 'offline'>('all') const statusFilter = ref<'all' | 'online' | 'offline'>('all')
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
let refreshTimer: number | null = null let refreshTimer: number | null = null
let searchDebounceTimer: number | null = null
const stats = computed(() => { let requestSeq = 0
const total = clients.value.length
const online = clients.value.filter((c) => c.online).length
const offline = total - online
return { total, online, offline }
})
const statusTabs = computed(() => [ const statusTabs = computed(() => [
{ value: 'all' as const, label: 'All', count: stats.value.total }, {
{ value: 'online' as const, label: 'Online', count: stats.value.online }, value: 'all' as const,
{ value: 'offline' as const, label: 'Offline', count: stats.value.offline }, label: 'All',
count: statusFilter.value === 'all' ? total.value : null,
},
{
value: 'online' as const,
label: 'Online',
count: statusFilter.value === 'online' ? total.value : null,
},
{
value: 'offline' as const,
label: 'Offline',
count: statusFilter.value === 'offline' ? total.value : null,
},
]) ])
const filteredClients = computed(() => { const fetchData = async (silent = false) => {
let result = clients.value const seq = ++requestSeq
if (!silent) loading.value = true
// Filter by status
if (statusFilter.value === 'online') {
result = result.filter((c) => c.online)
} else if (statusFilter.value === 'offline') {
result = result.filter((c) => !c.online)
}
// Filter by search text
if (searchText.value) {
result = result.filter((c) => c.matchesFilter(searchText.value))
}
// Sort: online first, then by display name
result.sort((a, b) => {
if (a.online !== b.online) {
return a.online ? -1 : 1
}
return a.displayName.localeCompare(b.displayName)
})
return result
})
const fetchData = async () => {
loading.value = true
try { try {
const json = await getClients() const data = await getClientsV2({
clients.value = json.map((data) => new Client(data)) page: page.value,
pageSize: pageSize.value,
status: statusFilter.value,
q: searchText.value.trim(),
})
if (seq !== requestSeq) return
const maxPage = Math.max(1, Math.ceil(data.total / data.pageSize))
if (data.items.length === 0 && data.total > 0 && data.page > maxPage) {
page.value = maxPage
await fetchData(silent)
return
}
clients.value = data.items.map((item) => new Client(item))
total.value = data.total
page.value = data.page
pageSize.value = data.pageSize
} catch (error: any) { } catch (error: any) {
if (seq !== requestSeq) return
ElMessage({ ElMessage({
showClose: true, showClose: true,
message: 'Failed to fetch clients: ' + error.message, message: 'Failed to fetch clients: ' + error.message,
type: 'error', type: 'error',
}) })
} finally { } finally {
loading.value = false if (seq === requestSeq) {
loading.value = false
}
} }
} }
const clearSearchDebounce = () => {
if (searchDebounceTimer !== null) {
window.clearTimeout(searchDebounceTimer)
searchDebounceTimer = null
}
}
const resetPageAndFetch = () => {
clearSearchDebounce()
page.value = 1
fetchData()
}
const onPageChange = (value: number) => {
clearSearchDebounce()
page.value = value
fetchData()
}
const onPageSizeChange = (value: number) => {
pageSize.value = value
resetPageAndFetch()
}
const startAutoRefresh = () => { const startAutoRefresh = () => {
refreshTimer = window.setInterval(() => { refreshTimer = window.setInterval(() => {
fetchData() fetchData(true)
}, 5000) }, 5000)
} }
@@ -130,6 +173,19 @@ const stopAutoRefresh = () => {
} }
} }
watch(statusFilter, () => {
resetPageAndFetch()
})
watch(searchText, () => {
clearSearchDebounce()
page.value = 1
searchDebounceTimer = window.setTimeout(() => {
searchDebounceTimer = null
fetchData()
}, 300)
})
onMounted(() => { onMounted(() => {
fetchData() fetchData()
startAutoRefresh() startAutoRefresh()
@@ -137,6 +193,7 @@ onMounted(() => {
onUnmounted(() => { onUnmounted(() => {
stopAutoRefresh() stopAutoRefresh()
clearSearchDebounce()
}) })
</script> </script>
@@ -274,6 +331,11 @@ onUnmounted(() => {
padding: 60px 0; padding: 60px 0;
} }
.pagination-section {
display: flex;
justify-content: flex-end;
}
/* Dark mode adjustments */ /* Dark mode adjustments */
html.dark .status-tab { html.dark .status-tab {
background: var(--el-bg-color-overlay); background: var(--el-bg-color-overlay);
@@ -298,5 +360,9 @@ html.dark .status-tab.active {
.status-tab { .status-tab {
flex-shrink: 0; flex-shrink: 0;
} }
.pagination-section {
justify-content: center;
}
} }
</style> </style>

View File

@@ -8,7 +8,7 @@
</div> </div>
<div class="actions-section"> <div class="actions-section">
<ActionButton variant="outline" size="small" @click="fetchData"> <ActionButton variant="outline" size="small" @click="refreshData">
Refresh Refresh
</ActionButton> </ActionButton>
@@ -74,9 +74,9 @@
</div> </div>
<div v-loading="loading" class="proxies-content"> <div v-loading="loading" class="proxies-content">
<div v-if="filteredProxies.length > 0" class="proxies-list"> <div v-if="proxies.length > 0" class="proxies-list">
<ProxyCard <ProxyCard
v-for="proxy in filteredProxies" v-for="proxy in proxies"
:key="`${proxy.type}:${proxy.name}`" :key="`${proxy.type}:${proxy.name}`"
:proxy="proxy" :proxy="proxy"
:show-type="activeType === 'all'" :show-type="activeType === 'all'"
@@ -87,6 +87,18 @@
</div> </div>
</div> </div>
<div v-if="total > 0" class="pagination-section">
<ElPagination
:current-page="page"
:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next"
@current-change="onPageChange"
@size-change="onPageSizeChange"
/>
</div>
<ConfirmDialog <ConfirmDialog
v-model="showClearDialog" v-model="showClearDialog"
title="Clear Offline" title="Clear Offline"
@@ -99,9 +111,9 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch } from 'vue' import { ref, computed, watch, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage, ElPagination } from 'element-plus'
import { Search } from '@element-plus/icons-vue' import { Search } from '@element-plus/icons-vue'
import ActionButton from '@shared/components/ActionButton.vue' import ActionButton from '@shared/components/ActionButton.vue'
import ConfirmDialog from '@shared/components/ConfirmDialog.vue' import ConfirmDialog from '@shared/components/ConfirmDialog.vue'
@@ -119,12 +131,13 @@ import ProxyCard from '../components/ProxyCard.vue'
import PopoverMenu from '@shared/components/PopoverMenu.vue' import PopoverMenu from '@shared/components/PopoverMenu.vue'
import PopoverMenuItem from '@shared/components/PopoverMenuItem.vue' import PopoverMenuItem from '@shared/components/PopoverMenuItem.vue'
import { import {
getProxiesByType, getProxiesV2,
clearOfflineProxies as apiClearOfflineProxies, clearOfflineProxies as apiClearOfflineProxies,
} from '../api/proxy' } from '../api/proxy'
import { getServerInfo } from '../api/server' import { getServerInfo } from '../api/server'
import { getClients } from '../api/client' import { getClientsV2 } from '../api/client'
import { Client } from '../utils/client' import { Client } from '../utils/client'
import type { ProxyStatsInfo } from '../types/proxy'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -149,6 +162,12 @@ const searchText = ref('')
const showClearDialog = ref(false) const showClearDialog = ref(false)
const clientIDFilter = ref((route.query.clientID as string) || '') const clientIDFilter = ref((route.query.clientID as string) || '')
const userFilter = ref((route.query.user as string) || '') const userFilter = ref((route.query.user as string) || '')
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
const maxV2PageSize = 100
let requestSeq = 0
let searchDebounceTimer: number | null = null
const clientOptions = computed(() => { const clientOptions = computed(() => {
return clients.value return clients.value
@@ -193,58 +212,6 @@ const selectedClientInList = computed(() => {
) )
}) })
const filteredProxies = computed(() => {
let result = proxies.value
// Filter by clientID and user if specified
if (clientIDFilter.value) {
result = result.filter(
(p) => p.clientID === clientIDFilter.value && p.user === userFilter.value,
)
}
// Filter by search text across multiple fields
if (searchText.value) {
const search = searchText.value.toLowerCase()
result = result.filter((p) => {
const fields: unknown[] = [
p.name,
p.type,
p.clientID,
p.user,
p.addr,
p.port,
p.customDomains,
p.subdomain,
]
return fields.some((v) => matchesSearch(v, search))
})
}
return result
})
// Normalize a field of unknown shape (string / number / array / null) to a
// lowercase string for case-insensitive substring matching. Arrays are joined
// so e.g. customDomains: ["A.com","B.com"] is searchable as one blob.
const matchesSearch = (value: unknown, needle: string): boolean => {
if (value === null || value === undefined) return false
let str: string
if (Array.isArray(value)) {
str = value
.filter((v) => v !== null && v !== undefined)
.map((v) => String(v))
.join(' ')
} else if (typeof value === 'number') {
if (value === 0) return false
str = String(value)
} else {
str = String(value)
}
if (!str) return false
return str.toLowerCase().includes(needle)
}
const onClientFilterChange = (key: string) => { const onClientFilterChange = (key: string) => {
if (key) { if (key) {
const client = clientOptions.value.find((c) => c.key === key) const client = clientOptions.value.find((c) => c.key === key)
@@ -263,122 +230,174 @@ const onClientFilterChange = (key: string) => {
const fetchClients = async () => { const fetchClients = async () => {
try { try {
const json = await getClients() const allClients: Client[] = []
clients.value = json.map((data) => new Client(data)) let nextPage = 1
} catch { let totalClients = 0
// Ignore errors when fetching clients
do {
const data = await getClientsV2({
page: nextPage,
pageSize: maxV2PageSize,
})
allClients.push(...data.items.map((item) => new Client(item)))
totalClients = data.total
nextPage += 1
} while (allClients.length < totalClients)
clients.value = allClients
} catch (err) {
// Client dropdown is a non-critical side load; log for diagnostics
// but don't surface a toast (would compete with the main fetch error).
console.warn('Failed to fetch clients for filter:', err)
} }
} }
// Server info cache // Server info cache - cache the Promise itself so concurrent first calls
let serverInfo: { // from Promise.all (convertProxies) don't kick off multiple HTTP requests.
type ServerInfoLite = {
vhostHTTPPort: number vhostHTTPPort: number
vhostHTTPSPort: number vhostHTTPSPort: number
tcpmuxHTTPConnectPort: number tcpmuxHTTPConnectPort: number
subdomainHost: string subdomainHost: string
} | null = null }
let serverInfoPromise: Promise<ServerInfoLite> | null = null
const fetchServerInfo = async () => { const fetchServerInfo = (): Promise<ServerInfoLite> => {
if (serverInfo) return serverInfo if (!serverInfoPromise) {
const res = await getServerInfo() serverInfoPromise = getServerInfo().catch((err) => {
serverInfo = res // Allow retry after failure
return serverInfo serverInfoPromise = null
throw err
})
}
return serverInfoPromise
} }
const convertProxies = async ( const convertProxy = async (
type: string, proxy: ProxyStatsInfo,
json: any, ): Promise<BaseProxy | null> => {
): Promise<BaseProxy[]> => { const type = proxy.type || activeType.value
if (type === 'tcp') { if (type === 'tcp') {
return json.proxies.map((p: any) => new TCPProxy(p)) return new TCPProxy(proxy)
} }
if (type === 'udp') { if (type === 'udp') {
return json.proxies.map((p: any) => new UDPProxy(p)) return new UDPProxy(proxy)
} }
if (type === 'http') { if (type === 'http') {
const info = await fetchServerInfo() const info = await fetchServerInfo()
if (info && info.vhostHTTPPort) { if (info && info.vhostHTTPPort) {
return json.proxies.map( return new HTTPProxy(proxy, info.vhostHTTPPort, info.subdomainHost)
(p: any) => new HTTPProxy(p, info.vhostHTTPPort, info.subdomainHost),
)
} }
return [] return null
} }
if (type === 'https') { if (type === 'https') {
const info = await fetchServerInfo() const info = await fetchServerInfo()
if (info && info.vhostHTTPSPort) { if (info && info.vhostHTTPSPort) {
return json.proxies.map( return new HTTPSProxy(proxy, info.vhostHTTPSPort, info.subdomainHost)
(p: any) => new HTTPSProxy(p, info.vhostHTTPSPort, info.subdomainHost),
)
} }
return [] return null
} }
if (type === 'tcpmux') { if (type === 'tcpmux') {
const info = await fetchServerInfo() const info = await fetchServerInfo()
if (info && info.tcpmuxHTTPConnectPort) { if (info && info.tcpmuxHTTPConnectPort) {
return json.proxies.map( return new TCPMuxProxy(
(p: any) => proxy,
new TCPMuxProxy(p, info.tcpmuxHTTPConnectPort, info.subdomainHost), info.tcpmuxHTTPConnectPort,
info.subdomainHost,
) )
} }
return [] return null
} }
if (type === 'stcp') { if (type === 'stcp') {
return json.proxies.map((p: any) => new STCPProxy(p)) return new STCPProxy(proxy)
} }
if (type === 'sudp') { if (type === 'sudp') {
return json.proxies.map((p: any) => new SUDPProxy(p)) return new SUDPProxy(proxy)
} }
// Fallback for types without a dedicated class (e.g. xtcp). Matches the // Fallback for types without a dedicated class (e.g. xtcp). Matches the
// pattern in ProxyDetail.vue so the type tag and meta render correctly. // pattern in ProxyDetail.vue so the type tag and meta render correctly.
return json.proxies.map((p: any) => { const bp = new BaseProxy(proxy)
const bp = new BaseProxy(p) bp.type = type
bp.type = type return bp
return bp
})
} }
const allProxyTypes = [ const convertProxies = async (items: ProxyStatsInfo[]): Promise<BaseProxy[]> => {
'tcp', const converted = await Promise.all(items.map((item) => convertProxy(item)))
'udp', return converted.filter((item): item is BaseProxy => item !== null)
'http', }
'https',
'tcpmux',
'stcp',
'xtcp',
'sudp',
]
const fetchData = async () => { const fetchData = async (silent = false) => {
loading.value = true const seq = ++requestSeq
proxies.value = [] if (!silent) loading.value = true
try { try {
const type = activeType.value const q = searchText.value.trim()
const data = await getProxiesV2({
page: page.value,
pageSize: pageSize.value,
type: activeType.value === 'all' ? undefined : activeType.value,
q: q || undefined,
clientID: clientIDFilter.value || undefined,
user: clientIDFilter.value ? userFilter.value : undefined,
})
if (seq !== requestSeq) return
if (type === 'all') { const maxPage = Math.max(1, Math.ceil(data.total / data.pageSize))
const results = await Promise.all( if (data.items.length === 0 && data.total > 0 && data.page > maxPage) {
allProxyTypes.map(async (t) => { page.value = maxPage
const json = await getProxiesByType(t) await fetchData(silent)
return convertProxies(t, json) return
}),
)
proxies.value = results.flat()
} else {
const json = await getProxiesByType(type)
proxies.value = await convertProxies(type, json)
} }
const converted = await convertProxies(data.items)
if (seq !== requestSeq) return
proxies.value = converted
total.value = data.total
page.value = data.page
pageSize.value = data.pageSize
} catch (error: any) { } catch (error: any) {
if (seq !== requestSeq) return
ElMessage({ ElMessage({
showClose: true, showClose: true,
message: 'Failed to fetch proxies: ' + error.message, message: 'Failed to fetch proxies: ' + error.message,
type: 'error', type: 'error',
}) })
} finally { } finally {
loading.value = false if (seq === requestSeq) {
loading.value = false
}
} }
} }
const clearSearchDebounce = () => {
if (searchDebounceTimer !== null) {
window.clearTimeout(searchDebounceTimer)
searchDebounceTimer = null
}
}
const resetPageAndFetch = () => {
clearSearchDebounce()
page.value = 1
fetchData()
}
const refreshData = () => {
fetchData()
}
const onPageChange = (value: number) => {
clearSearchDebounce()
page.value = value
fetchData()
}
const onPageSizeChange = (value: number) => {
pageSize.value = value
resetPageAndFetch()
}
const handleClearConfirm = async () => { const handleClearConfirm = async () => {
showClearDialog.value = false showClearDialog.value = false
await clearOfflineProxies() await clearOfflineProxies()
@@ -402,20 +421,36 @@ const clearOfflineProxies = async () => {
// Watch for type changes // Watch for type changes
watch(activeType, (newType) => { watch(activeType, (newType) => {
clearSearchDebounce()
page.value = 1
// Update route but preserve query params // Update route but preserve query params
router.replace({ params: { type: newType }, query: route.query }) router.replace({ params: { type: newType }, query: route.query })
fetchData() fetchData()
}) })
watch(searchText, () => {
clearSearchDebounce()
page.value = 1
searchDebounceTimer = window.setTimeout(() => {
searchDebounceTimer = null
fetchData()
}, 300)
})
// Watch for route query changes (client filter) // Watch for route query changes (client filter)
watch( watch(
() => [route.query.clientID, route.query.user], () => [route.query.clientID, route.query.user],
([newClientID, newUser]) => { ([newClientID, newUser]) => {
clientIDFilter.value = (newClientID as string) || '' clientIDFilter.value = (newClientID as string) || ''
userFilter.value = (newUser as string) || '' userFilter.value = (newUser as string) || ''
resetPageAndFetch()
}, },
) )
onUnmounted(() => {
clearSearchDebounce()
})
// Initial fetch // Initial fetch
fetchData() fetchData()
fetchClients() fetchClients()
@@ -539,6 +574,11 @@ fetchClients()
padding: 60px 0; padding: 60px 0;
} }
.pagination-section {
display: flex;
justify-content: flex-end;
}
@media (max-width: 768px) { @media (max-width: 768px) {
.search-row { .search-row {
flex-direction: column; flex-direction: column;
@@ -547,5 +587,9 @@ fetchClients()
.client-filter { .client-filter {
width: 100%; width: 100%;
} }
.pagination-section {
justify-content: center;
}
} }
</style> </style>