mirror of
https://github.com/fatedier/frp.git
synced 2026-07-18 22:39:18 +08:00
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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user