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

@@ -8,7 +8,7 @@
</div>
<div class="actions-section">
<ActionButton variant="outline" size="small" @click="fetchData">
<ActionButton variant="outline" size="small" @click="refreshData">
Refresh
</ActionButton>
@@ -74,9 +74,9 @@
</div>
<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
v-for="proxy in filteredProxies"
v-for="proxy in proxies"
:key="`${proxy.type}:${proxy.name}`"
:proxy="proxy"
:show-type="activeType === 'all'"
@@ -87,6 +87,18 @@
</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
v-model="showClearDialog"
title="Clear Offline"
@@ -99,9 +111,9 @@
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { ref, computed, watch, onUnmounted } from 'vue'
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 ActionButton from '@shared/components/ActionButton.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 PopoverMenuItem from '@shared/components/PopoverMenuItem.vue'
import {
getProxiesByType,
getProxiesV2,
clearOfflineProxies as apiClearOfflineProxies,
} from '../api/proxy'
import { getServerInfo } from '../api/server'
import { getClients } from '../api/client'
import { getClientsV2 } from '../api/client'
import { Client } from '../utils/client'
import type { ProxyStatsInfo } from '../types/proxy'
const route = useRoute()
const router = useRouter()
@@ -149,6 +162,12 @@ const searchText = ref('')
const showClearDialog = ref(false)
const clientIDFilter = ref((route.query.clientID 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(() => {
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) => {
if (key) {
const client = clientOptions.value.find((c) => c.key === key)
@@ -263,122 +230,174 @@ const onClientFilterChange = (key: string) => {
const fetchClients = async () => {
try {
const json = await getClients()
clients.value = json.map((data) => new Client(data))
} catch {
// Ignore errors when fetching clients
const allClients: Client[] = []
let nextPage = 1
let totalClients = 0
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
let serverInfo: {
// Server info cache - cache the Promise itself so concurrent first calls
// from Promise.all (convertProxies) don't kick off multiple HTTP requests.
type ServerInfoLite = {
vhostHTTPPort: number
vhostHTTPSPort: number
tcpmuxHTTPConnectPort: number
subdomainHost: string
} | null = null
}
let serverInfoPromise: Promise<ServerInfoLite> | null = null
const fetchServerInfo = async () => {
if (serverInfo) return serverInfo
const res = await getServerInfo()
serverInfo = res
return serverInfo
const fetchServerInfo = (): Promise<ServerInfoLite> => {
if (!serverInfoPromise) {
serverInfoPromise = getServerInfo().catch((err) => {
// Allow retry after failure
serverInfoPromise = null
throw err
})
}
return serverInfoPromise
}
const convertProxies = async (
type: string,
json: any,
): Promise<BaseProxy[]> => {
const convertProxy = async (
proxy: ProxyStatsInfo,
): Promise<BaseProxy | null> => {
const type = proxy.type || activeType.value
if (type === 'tcp') {
return json.proxies.map((p: any) => new TCPProxy(p))
return new TCPProxy(proxy)
}
if (type === 'udp') {
return json.proxies.map((p: any) => new UDPProxy(p))
return new UDPProxy(proxy)
}
if (type === 'http') {
const info = await fetchServerInfo()
if (info && info.vhostHTTPPort) {
return json.proxies.map(
(p: any) => new HTTPProxy(p, info.vhostHTTPPort, info.subdomainHost),
)
return new HTTPProxy(proxy, info.vhostHTTPPort, info.subdomainHost)
}
return []
return null
}
if (type === 'https') {
const info = await fetchServerInfo()
if (info && info.vhostHTTPSPort) {
return json.proxies.map(
(p: any) => new HTTPSProxy(p, info.vhostHTTPSPort, info.subdomainHost),
)
return new HTTPSProxy(proxy, info.vhostHTTPSPort, info.subdomainHost)
}
return []
return null
}
if (type === 'tcpmux') {
const info = await fetchServerInfo()
if (info && info.tcpmuxHTTPConnectPort) {
return json.proxies.map(
(p: any) =>
new TCPMuxProxy(p, info.tcpmuxHTTPConnectPort, info.subdomainHost),
return new TCPMuxProxy(
proxy,
info.tcpmuxHTTPConnectPort,
info.subdomainHost,
)
}
return []
return null
}
if (type === 'stcp') {
return json.proxies.map((p: any) => new STCPProxy(p))
return new STCPProxy(proxy)
}
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
// pattern in ProxyDetail.vue so the type tag and meta render correctly.
return json.proxies.map((p: any) => {
const bp = new BaseProxy(p)
bp.type = type
return bp
})
const bp = new BaseProxy(proxy)
bp.type = type
return bp
}
const allProxyTypes = [
'tcp',
'udp',
'http',
'https',
'tcpmux',
'stcp',
'xtcp',
'sudp',
]
const convertProxies = async (items: ProxyStatsInfo[]): Promise<BaseProxy[]> => {
const converted = await Promise.all(items.map((item) => convertProxy(item)))
return converted.filter((item): item is BaseProxy => item !== null)
}
const fetchData = async () => {
loading.value = true
proxies.value = []
const fetchData = async (silent = false) => {
const seq = ++requestSeq
if (!silent) loading.value = true
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 results = await Promise.all(
allProxyTypes.map(async (t) => {
const json = await getProxiesByType(t)
return convertProxies(t, json)
}),
)
proxies.value = results.flat()
} else {
const json = await getProxiesByType(type)
proxies.value = await convertProxies(type, json)
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
}
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) {
if (seq !== requestSeq) return
ElMessage({
showClose: true,
message: 'Failed to fetch proxies: ' + error.message,
type: 'error',
})
} 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 () => {
showClearDialog.value = false
await clearOfflineProxies()
@@ -402,20 +421,36 @@ const clearOfflineProxies = async () => {
// Watch for type changes
watch(activeType, (newType) => {
clearSearchDebounce()
page.value = 1
// Update route but preserve query params
router.replace({ params: { type: newType }, query: route.query })
fetchData()
})
watch(searchText, () => {
clearSearchDebounce()
page.value = 1
searchDebounceTimer = window.setTimeout(() => {
searchDebounceTimer = null
fetchData()
}, 300)
})
// Watch for route query changes (client filter)
watch(
() => [route.query.clientID, route.query.user],
([newClientID, newUser]) => {
clientIDFilter.value = (newClientID as string) || ''
userFilter.value = (newUser as string) || ''
resetPageAndFetch()
},
)
onUnmounted(() => {
clearSearchDebounce()
})
// Initial fetch
fetchData()
fetchClients()
@@ -539,6 +574,11 @@ fetchClients()
padding: 60px 0;
}
.pagination-section {
display: flex;
justify-content: flex-end;
}
@media (max-width: 768px) {
.search-row {
flex-direction: column;
@@ -547,5 +587,9 @@ fetchClients()
.client-filter {
width: 100%;
}
.pagination-section {
justify-content: center;
}
}
</style>