mirror of
https://github.com/fatedier/frp.git
synced 2026-07-16 09:19:17 +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:
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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[]
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
if (seq === requestSeq) {
|
||||||
loading.value = false
|
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>
|
||||||
|
|||||||
@@ -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,121 +230,173 @@ 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 {
|
||||||
|
if (seq === requestSeq) {
|
||||||
loading.value = false
|
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
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user